import '../public/chip-core.js'; import '../public/date-grain.js'; import '../public/number-core.js'; import '../public/term-core.js'; import '../public/icon-registry.js'; import '../public/mark-icons.js'; import '../public/editor-lib.js'; import '../public/list-core.js'; import '../public/view-pane-core.js'; import '../public/board-core.js'; import '../public/phone-row.js'; import '../public/cell-graphics.js'; import '../public/field-dialog-core.js'; import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, renameSync, openSync, readSync, closeSync } from 'node:fs'; import { createHash, createHmac, randomBytes } from 'node:crypto'; import { openKeyStore, keystoreFilePath, keystoreKind, migrateKeys } from './keystore.js'; import { join, dirname } from 'node:path'; import { lookup } from 'node:dns/promises'; import { BlockList, isIP } from 'node:net'; import { uuid, slug } from './ids.js'; import { stripEmptyEmbeds } from './markdown.js'; import { workspaceName, workspaceSlug, nameFromFile, hostSlugRefusal } from './workspace-name.js'; import { Store, WeaveError, writeFileDurable, streamToFileDurable } from './store.js'; import { parseCron, dueBetween, nextEvery, diffItems, retryAfterMs, pollPrune, POLL_SLOWEST_DEFAULT, POLL_SLOWEST_MAX } from './schedule.js'; import { DELIVERY_ATTEMPTS, DeliveryError, retryDelayMs, waitingReason, exhaustedReason, DELIVERIES_DESCRIPTION, DELIVERY_STATUSES, deliveryName, requestText, attemptsText, INBOUND_WINDOW_MS, ECHO_WINDOW_MS, ECHO_KEEP, claimKey, matchesEcho, sentValues, utcMinute, HOOK_BODY_MAX, HOOK_LOG_KEEP, hookKeyName, hookRefusal } from './delivery.js'; import * as Shares from './shares.js'; import { ANONYMOUS, RANK, SPACE_ACCESS, viewerOf, viewerFor, spaceRole, workspaceRole, seatRole, lower, withViewers, unrestricted } from './access.js'; import { nearestIcons } from './vocabulary.js'; import { evaluate, check as checkExpression, references as formulaReferences } from './formula.js'; import { aggregate as aggregateValues, describeNumbers, histogram, distribution, NUMERIC_AGGREGATES } from './stats.js'; import { FIELD_TYPE_VOCABULARY, VOCABULARY } from './vocabulary.js'; import { FORMS_DESCRIPTION, ensureFormColumns, refuseOnDocs, adoptForms, normalizeFormValues } from './forms.js'; import { TRIAL_STAGES } from './mail.js'; import { INTEGRATIONS_DESCRIPTION, INTEGRATION_KEPT, ensureIntegrationColumns, integrationHost, integrationKeyName, isIntegrationKey, refuseIntegrationKey, cleanSecret, keyHint, redact, checkRequest, readCheck, unreachable, authHeader, INTEGRATION_METHODS, CHECK_TIMEOUT_MS, POLL_BUDGET_DEFAULT } from './integrations.js'; function iconValue(v) { const s = String(v ?? '').trim(); if (!s) return ''; const reg = globalThis.weaveIconRegistry, marks = globalThis.weaveMarkIcons; if (reg?.resolve(s) || marks?.has(s)) return s; const near = nearestIcons(s).map((m) => `lucide:${m.name} (${m.category})`); throw new WeaveError(`Icon '${s}' is not in the inventory${near.length ? `; nearest: ${near.join(', ')}` : ''}. Search it with weave_vocabulary {section:"icons", query:""} (CLI: weave vocabulary icons ), or use a mark character`, 'invalid'); } const Term = globalThis.WeaveTerm; const ListCore = globalThis.weaveListCore; const ViewPane = globalThis.weaveViewPane; const BoardCore = globalThis.weaveBoardCore; const LAYOUTS = ['table', 'board']; const PhoneRow = globalThis.weavePhoneRow; const SYSTEM_TERMS = { spaces: 'space', tables: 'table', fields: 'field', workflows: 'workflow', forms: 'form' }; const CSV_FORMULA_START = /^'*[=+\-@\t\r]/; const unquoteCSVCell = (cell) => (cell.startsWith("'") && CSV_FORMULA_START.test(cell.slice(1)) ? cell.slice(1) : cell); export function parseCSV(text) { const rows = []; let row = []; let cell = ''; let inQuotes = false; const src = String(text ?? ''); for (let i = 0; i < src.length; i++) { const ch = src[i]; if (inQuotes) { if (ch === '"' && src[i + 1] === '"') { cell += '"'; i++; } else if (ch === '"') inQuotes = false; else cell += ch; } else if (ch === '"') { inQuotes = true; } else if (ch === ',') { row.push(cell); cell = ''; } else if (ch === '\n' || ch === '\r') { if (ch === '\r' && src[i + 1] === '\n') i++; row.push(cell); cell = ''; rows.push(row); row = []; } else { cell += ch; } } if (cell !== '' || row.length) { row.push(cell); rows.push(row); } return rows.filter((r) => !(r.length === 1 && r[0] === '')); } const VALUES_BLOCK = '@values'; const FILES_BLOCK = '@files'; const REFERENCE_PANELS = ['References', 'Referenced by']; const isFixedBlock = (key) => key === VALUES_BLOCK || key === FILES_BLOCK; const BLOB_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const isBodyBlock = (f) => f.type === 'document' || f.type === 'attachments' || (f.type === 'relation' && !!(f.many ?? f.config?.many)); const VALUE_TYPES = ['text', 'number', 'rating', 'date', 'daterange', 'checkbox', 'toggle', 'url', 'email', 'select', 'multiselect', 'workflow', 'relation', 'field', 'key', 'attachments', 'avatar']; const isFileField = (f) => f.type === 'attachments' || f.type === 'avatar'; export const AVATAR_SHAPES = ['round', 'square']; const AVATAR_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']; const SEARCHED_VALUE_TYPES = new Set(['text', 'url', 'email']); const REGISTRY_HITS = new Set(['workspaces', 'spaces', 'tables', 'views']); export const LOCKED_NAME = 'Private'; export const UPGRADE_FLOOR = 'v0.4.4'; function refuseBeforeFloor(state) { if (state.version === 2 && !state.databases && state.meta?.percentFractional) return; throw new WeaveError(`This workspace was last saved by a weave older than ${UPGRADE_FLOOR}. Open it once with weave v0.4.123, which upgrades it, then open it here.`, 'invalid'); } const RESERVED_NAMES = new Set(['__proto__', 'constructor', 'prototype']); const own = (o, k) => (o != null && Object.hasOwn(o, k) ? o[k] : undefined); const PASTED_ENTITY = /^\s*(?:(?:[a-z][a-z0-9+.-]*:\/\/[^/\s]+)?(?:\/w\/[^/\s]+)?\/e\/)?([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:[/?#]\S*)?\s*$/i; export const pastedEntityId = (ref) => PASTED_ENTITY.exec(String(ref ?? '').replace(/%2f/gi, '/'))?.[1].toLowerCase() ?? null; const byPosition = (list) => list.map((x, i) => [x, i]) .sort(([a, i], [b, j]) => (Number.isInteger(a.position) ? a.position : Infinity) - (Number.isInteger(b.position) ? b.position : Infinity) || i - j) .map(([x]) => x); const checkPosition = (position) => { if (!(Number.isInteger(position) && position >= 0)) throw new WeaveError('position is a whole number: 0 is the first place', 'invalid'); return position; }; const SYSTEM_SPACE_NAME = 'System'; const SYSTEM_SPACE_ICON = 'lucide:settings'; const LEGACY_SYSTEM_SPACE_NAME = 'workspace'; const answersLegacySystem = (sp, name) => sp?.system === 'workspace' && String(name).toLowerCase() === LEGACY_SYSTEM_SPACE_NAME; function refuseReserved(kind, name) { if (RESERVED_NAMES.has(String(name).trim())) throw new WeaveError(`'${String(name).trim()}' is reserved and cannot name a ${kind}`, 'invalid'); } export function sniffImage(bytes) { const b = Buffer.from(bytes ?? []); const at = (hex, i = 0) => b.subarray(i, i + hex.length / 2).toString('hex') === hex; if (at('89504e470d0a1a0a')) return 'image/png'; if (at('ffd8ff')) return 'image/jpeg'; if (at('474946383761') || at('474946383961')) return 'image/gif'; if (at('52494646') && at('57454250', 8)) return 'image/webp'; if (at('66747970', 4) && (at('61766966', 8) || at('61766973', 8))) return 'image/avif'; return null; } const fileBlobIds = (f) => (f.preview?.id ? [f.id, f.preview.id] : [f.id]); export const PREVIEW_MAX_BYTES = 256 * 1024; export const FILE_MAX_BYTES = 10 * 1024 * 1024; export const mbText = (bytes) => `${(bytes / 1048576).toFixed(1)} MB`; const PREVIEW_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']); export function logoType(bytes) { const head = Buffer.from(bytes ?? []).subarray(0, 1024).toString('utf8').replace(/^\ufeff/, ''); return sniffImage(bytes) ?? (/^\s*(<\?xml[^>]*>\s*)?(\s*)*(]*>\s*)?]/i.test(head) ? 'image/svg+xml' : null); } const LOCAL_RANGES = new BlockList(); for (const [net, bits] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.168.0.0', 16], ['224.0.0.0', 3]]) LOCAL_RANGES.addSubnet(net, bits, 'ipv4'); for (const [net, bits] of [['::', 128], ['::1', 128], ['fc00::', 7], ['fe80::', 10]]) LOCAL_RANGES.addSubnet(net, bits, 'ipv6'); export function localAddress(ip) { const v = isIP(ip); return v ? LOCAL_RANGES.check(ip, v === 6 ? 'ipv6' : 'ipv4') : false; } async function webhookRefusal(url) { if (process.env.WEAVE_WEBHOOK_ALLOW_PRIVATE === '1') return null; const host = url.hostname.replace(/^\[|\]$/g, ''); if (isIP(host)) return localAddress(host) ? `refused, ${host} is a private or local address` : null; const hit = (await lookup(host, { all: true })).find((a) => localAddress(a.address)); return hit ? `refused, ${host} resolves to ${hit.address}, a private or local address` : null; } const INLINE_FILE_TYPES = new Set(['application/pdf', 'text/plain']); export const HTML_VIEW_POLICY = "sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox; default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; media-src data: blob:"; export const HTML_PAGE_POLICY = "sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox; default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; media-src 'self' data: blob:; frame-src 'self'"; export const sandboxedHtmlHeaders = (extra = {}, policy = HTML_VIEW_POLICY) => ({ 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': policy, ...extra }); const HTML_FILE_TYPES = new Set(['text/html', 'application/xhtml+xml']); export const FILE_STORAGE_CAP = 5 * 1024 * 1024; export const isHtmlFile = (meta) => HTML_FILE_TYPES.has(String(meta?.mime ?? '').split(';')[0].trim().toLowerCase()); export const FRAME_HEIGHT_SCRIPT = ""; export const FRAME_LINK_BASE = ""; export const frameLinkBase = (bytes) => (/]/i.test(bytes.toString('latin1')) ? '' : FRAME_LINK_BASE); const scriptJson = (value) => JSON.stringify(value).replace(/ ``; const LEADING_DOCTYPE = /^(?:\xEF\xBB\xBF)?\s*]*>/i; export function viewHtml(bytes, head = FRAME_HEIGHT_SCRIPT) { const at = bytes.toString('latin1', 0, 4096).match(LEADING_DOCTYPE)?.[0].length ?? 0; return Buffer.concat([bytes.subarray(0, at), Buffer.from(head), bytes.subarray(at)]); } export function fileHeaders(meta, bytes, { view = false } = {}) { const claimed = String(meta.mime ?? '').split(';')[0].trim().toLowerCase(); const image = claimed.startsWith('image/') ? sniffImage(bytes) : null; const extra = String(process.env.WEAVE_INLINE_FILE_TYPES ?? '').toLowerCase().split(',').map((t) => t.trim()); const html = view && HTML_FILE_TYPES.has(claimed); const svg = view && claimed === 'image/svg+xml' && logoType(bytes) === claimed; const inline = svg ? claimed : image === claimed ? image : html ? 'text/html; charset=utf-8' : INLINE_FILE_TYPES.has(claimed) || (claimed && extra.includes(claimed)) ? claimed : null; const name = String(meta.name ?? 'file'); const ascii = name.replace(/[^\w.-]+/g, '_'); const utf8 = encodeURIComponent(name).replace(/['()*!]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()); return { 'Content-Type': inline ?? 'application/octet-stream', 'Content-Disposition': `${inline ? 'inline' : 'attachment'}; filename="${ascii}"; filename*=UTF-8''${utf8}`, 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': html ? HTML_VIEW_POLICY : svg ? "sandbox; default-src 'none'; style-src 'unsafe-inline'" : "sandbox; default-src 'none'", }; } export const ATTACHMENT_PREVIEWS = ['link', 'inline', 'auto', 'cover', 'intro']; export const ATTACHMENT_SIZES = ['small', 'medium', 'large']; export const ATTACHMENT_FITS = ['fill', 'trim']; const ATTACHMENT_LOOK_KEYS = ['preview', 'size', 'fit', 'displayInline']; const isBoolType = (t) => t === 'checkbox' || t === 'toggle'; const COMPUTED_TYPES = ['lookup', 'rollup', 'formula', 'view']; const DERIVED_CACHE_CAP = 32; export const { STATE_CATEGORIES, DEFAULT_WORKFLOW_STATES } = globalThis.fieldDialogCore; export const SYSTEM_SORT_KEYS = Object.fromEntries(Object.values(globalThis.fieldDialogCore.SYSTEM_SORT).map((s) => [s.name, s.key])); const TEXT_OPS = new Set(['contains', 'is-empty', 'not-empty']); export const VIEW_SHAPES = ['chip', 'card']; export const QUERY_SHAPES = ['full', 'row']; export function leanRow(row) { const raw = {}; for (const [name, value] of Object.entries(row.raw ?? {})) { if (value !== row.fields?.[name]) raw[name] = value; } return { id: row.id, publicId: row.publicId, name: row.name, fields: row.fields, raw, ...(row.deletedAt ? { deletedAt: row.deletedAt } : {}), }; } export const DESCRIPTION_SIZES = ['none', 'small', 'medium', 'large']; const DESCRIPTION_CHARS = { small: 0, medium: 120, large: 320 }; export const ACTIVITY_CAP = 500; export const ACTIVITY_PAGE = 500; const ACCOUNT_ACTOR = /account:([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/g; export const accountActor = (id) => `account:${id}`; const REMOVED_ACCOUNT = 'removed account'; const FIELD_CONFIG_ACTIONS = { 'field-config-updated': 'field-config-updated', 'field-config-undo': 'undo' }; const SCHEMA_AUDIT_ACTIONS = ['space-created', 'space-updated', 'space-trashed', 'space-deleted', 'space-restored', 'space-from-template', 'table-created', 'table-updated', 'table-moved', 'table-duplicated', 'table-trashed', 'table-deleted', 'table-restored', 'field-added', 'field-updated', 'field-config-updated', 'field-config-undo', 'field-migrated', 'field-trashed', 'field-deleted', 'field-restored', 'relation-added']; const UNTAGGED_SCHEMA_ACTIONS = new Set(SCHEMA_AUDIT_ACTIONS.filter((a) => a !== 'field-updated' && a !== 'table-updated')); const ONTOLOGY_TYPES = { number: 'num', daterange: 'dates', checkbox: 'check', document: 'doc', attachments: 'files', select: 'one', multiselect: 'many', workflow: 'flow' }; const ONTOLOGY_LEGEND = 'one/many/flow[..] are select/multiselect/workflow; a -> T (b) is a relation to T, T* many, b its inverse; a = .. is a lookup, rollup or formula. Every table also has Name, Description, Chip and Card.'; function ontologyEtag(counts) { const text = [...counts].filter(([, n]) => n > 0).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([id, n]) => `${id}:${n}`).join(','); return createHash('sha1').update(text).digest('hex').slice(0, 8); } function fieldDefinition(f) { const { width, ...config } = f.config ?? {}; return structuredClone({ name: f.name, type: f.type, config }); } function canonicalJSON(v) { return JSON.stringify(v, (k, x) => (x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.keys(x).sort().map((kk) => [kk, x[kk]])) : x)); } function definitionChanges(before, after) { const out = []; if (before.name !== after.name) out.push('name'); if (before.type !== after.type) out.push('type'); for (const k of new Set([...Object.keys(before.config), ...Object.keys(after.config)])) { if (canonicalJSON(before.config[k]) !== canonicalJSON(after.config[k])) out.push(k); } return out; } const VIEW_AUTO_SEGMENTS = { chip: 3, card: 4 }; const VIEW_DEFAULTS = { chip: { shape: 'chip', link: false, state: true, description: 'none', fields: null }, card: { shape: 'card', link: true, state: true, description: 'small', fields: null }, }; const VIEW_NAMES = { chip: 'Chip', card: 'Card' }; const VIEW_EXCLUDED_TYPES = ['document', 'attachments', 'avatar', 'key', 'field', 'view']; function plainLines(md, budget = 12) { const { kind, lines, label } = globalThis.WeaveEditorLib.docPreview(md, { lines: budget }); if (!kind) return []; if (!lines.length) return label ? [label] : []; return lines.map((l) => l .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') .replace(/(\*\*|__)(.+?)\1/g, '$2') .replace(/(\*|_)(.+?)\1/g, '$2') .replace(/`([^`]+)`/g, '$1') .replace(/~~(.+?)~~/g, '$1') .trim()).filter(Boolean); } const GRID_SYSTEM_COLUMNS = ['Created At', 'Modified At', 'Created By', 'Modified By']; const RETIRED_SYSTEM_FIELD = 'Activity'; const withoutRetired = (list) => (list ?? []).filter((n) => n !== RETIRED_SYSTEM_FIELD); const sysColumnId = (name) => `sys:${name}`; const sysColumnName = (id) => (typeof id === 'string' && id.startsWith('sys:') && GRID_SYSTEM_COLUMNS.includes(id.slice(4)) ? id.slice(4) : null); const viewEntryName = (db, id) => db.fields[id]?.name ?? sysColumnName(id); function placeField(db, id) { let at = db.fieldOrder.length; while (at > 0 && db.fields[db.fieldOrder[at - 1]]?.type === 'view') at--; db.fieldOrder.splice(at, 0, id); if (db.fields[id]?.type !== 'view') { for (const v of db.tableViews ?? []) { if (v.fields.includes(id)) continue; let k = v.fields.length; while (k > 0 && sysColumnName(v.fields[k - 1])) k--; v.fields.splice(k, 0, id); } } } function showBySchema(db, fields, id) { const at = db.fieldOrder.indexOf(id); for (let k = at - 1; k >= 0; k--) { const i = fields.indexOf(db.fieldOrder[k]); if (i >= 0) { fields.splice(i + 1, 0, id); return; } } fields.unshift(id); } const clip =(text, max) => (text.length <= max ? text : text.slice(0, max - 1).replace(/\s+\S*$/, '') + '…'); const DYNAMIC_DATE_DEFAULTS = ['today()', 'now()']; const DOCUMENT_KINDS = ['markdown', 'html', 'code']; export const CREDENTIAL_KINDS = ['apikey', 'token', 'password', 'id', 'pair']; export const KEYSTORES = ['local', '1password', 'aws-sm', 'google-sm', 'cloudflare', 'apple-passwords']; const DEFAULT_PAIR_PARTS = [{ name: 'id', secret: false }, { name: 'secret', secret: true }]; const NUMBER_COSTUME_KEYS = ['format', 'unit', 'currency', 'decimals', 'separator', 'accounting', 'display', 'scale', 'color']; const ROLLUP_COSTUME_KEYS = NUMBER_COSTUME_KEYS.filter((k) => k !== 'separator'); export const NUMBER_DISPLAYS = ['text', 'bar', 'ring', 'heat']; const isGraphicDisplay = (d) => d === 'bar' || d === 'ring' || d === 'heat'; export const CELL_COLORS = globalThis.weaveCellGraphics.COLORS; function cellColorValue(color) { if (color == null) return 'ink'; if (!CELL_COLORS.includes(color)) throw new WeaveError(`Invalid color '${color}' (${CELL_COLORS.join(', ')})`, 'invalid'); return color; } export const SPARKLINE_STYLES = ['line', 'column', 'winloss']; const lastNumber = (list) => { for (let i = list.length - 1; i >= 0; i--) if (typeof list[i] === 'number' && Number.isFinite(list[i])) return list[i]; return null; }; export const RATING_MAX = 100; const RATING_DEFAULTS = { max: 5, icon: 'lucide:star' }; const RATING_SCALE_AGGS = ['avg', 'min', 'max', 'median']; const ratingValue = (n, max) => Math.min(max, Math.max(0, Math.round(n))); const DATE_COSTUME_KEYS = ['grain', 'format', 'time', 'clock', 'zone', 'zoneName', 'pad', 'elapsed']; const FORMULA_COSTUME_KEYS = [...new Set([...NUMBER_COSTUME_KEYS, 'style', ...DATE_COSTUME_KEYS.filter((k) => k !== 'elapsed')])]; const DG = globalThis.weaveDateGrain; const DEFAULTABLE_TYPES = ['text', 'number', 'rating', 'date', 'daterange', 'checkbox', 'toggle', 'url', 'email', 'select', 'multiselect']; export const FIELD_TYPES = [...VALUE_TYPES, ...COMPUTED_TYPES, 'document']; const DOC_REVISION_WINDOW_MS = 10 * 60 * 1000; const docFingerprint = (text) => `${text.length}:${createHash('sha256').update(text).digest('hex').slice(0, 16)}`; function docChange(field, before, after) { const a = before.split('\n'); const b = after.split('\n'); let head = 0; while (head < a.length && head < b.length && a[head] === b[head]) head++; let tail = 0; while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail++; return { field, length: after.length, prevLength: before.length, delta: after.length - before.length, line: head + 1, linesAdded: b.length - head - tail, linesRemoved: a.length - head - tail, preview: (b[head] ?? a[head] ?? '').trim().slice(0, 120), }; } const RETIRED_STATE_CATEGORIES = { other: 'in-progress' }; const WORKFLOW_STATES = [ { name: 'Setup incomplete', category: 'not-started', default: true }, { name: 'Ready', category: 'done' }, ]; const workflowActor = (rowId) => `workflow:${rowId}`; const actorWorkflow = (actor) => (String(actor ?? '').startsWith('workflow:') ? String(actor).slice('workflow:'.length) : null); const HOME_RUNS = 20; const HOME_RUN_ROWS = 50; const templated = (v) => typeof v === 'string' && v.includes('{{'); const scheduleOut = (t) => ({ cron: t.cron, zone: t.zone, ...(t.where != null ? { where: t.where } : {}) }); const listenerOut = (t) => ({ integration: t.integration, url: t.url, method: t.method, ...(t.headers ? { headers: t.headers } : {}), ...(t.cursor ? { cursor: t.cursor } : {}), ...(t.items != null ? { items: t.items } : {}), key: t.key, maxInterval: t.maxInterval, }); const HOOK_PARTS = { key: 'The webhook-inbound key is the path in the body that names the delivery, such as id', items: 'The webhook-inbound items is the path in the body that holds a list, such as data', record: 'The webhook-inbound record is the path in the body, or in each item, that names the record to change by its public id or Name', from: 'The webhook-inbound from names the integration or host weave sends to, so a change weave wrote there is dropped as an echo', secret: 'The webhook-inbound secret names a keystore credential, never its value', }; function compileHook(trigger) { const t = {}; for (const [k, words] of Object.entries(HOOK_PARTS)) { const v = trigger[k]; if (v == null || v === '') continue; if (typeof v !== 'string' || !v.trim()) throw new WeaveError(words, 'invalid'); t[k] = v.trim(); } return t; } const hookOut = (t) => Object.fromEntries(Object.keys(HOOK_PARTS).filter((k) => t[k] != null).map((k) => [k, t[k]])); const triggerOut = (t) => (t.type === 'schedule' ? scheduleOut(t) : t.type === 'listener-changed' ? listenerOut(t) : t.type === 'webhook-inbound' ? hookOut(t) : {}); const LISTENER_ACTIONS = ['upsert-row', 'http-request']; const HOOK_RECORD_ACTIONS = ['set-field', 'add-comment']; const workflowScript = (spec) => `${JSON.stringify(spec, null, 2)}\n`; const WORKFLOW_PANEL = ['On', 'Name', 'When', 'Then', 'Health', 'Last Run']; function whenWords({ table, trigger: t }, hookPath = null) { if (t.type === 'webhook-inbound') return `A POST arrives at ${hookPath}`; if (t.type === 'entity-created') return `A record is created in ${table}`; if (t.type === 'field-updated') return `${t.field} changes in ${table}`; if (t.type === 'state-changed') return `${t.field} moves${t.toState ? ` to ${t.toState}` : ''} in ${table}`; if (t.type === 'listener-changed') { const u = new URL(t.url); return `An item changes at ${u.host}${u.pathname} through ${t.integration}`; } return `Schedule ${t.cron} ${t.zone}, ${t.where != null ? 'matching records' : 'every record'} in ${table}`; } function actionWords(a) { if (a.type === 'set-field') return a.value == null || a.value === '' ? `Clear ${a.field}` : `Set ${a.field} to ${JSON.stringify(a.value)}`; if (a.type === 'append-doc') return a.field ? `Append to ${a.field}` : 'Append to the document'; if (a.type === 'add-comment') return 'Add a comment'; if (a.type === 'upsert-row') return `Create or update a record in ${a.table} by ${a.key}`; if (a.type === 'http-request') return `Send a ${a.method} request to ${urlHost(a.url)}${a.integration ? ` with ${a.integration}` : ''}${a.signWith ? ', signed' : ''}`; return `Post to ${urlHost(a.url)}${a.signWith ? ', signed' : ''}`; } function urlHost(url) { try { return new URL(url).host; } catch { return url; } } const HTTP_STEP_BODY_MAX = 16 * 1024; const itemRow = (key, item) => ({ id: `item:${key}`, key, item, publicId: null, values: {}, docs: {}, comments: [] }); const hookPathOf = (wsId, id) => `/w/${wsId}/api/hooks/${id}`; const OUTBOUND = Symbol('outbound'); const CREDENTIAL_HEADERS = new Set(['authorization', 'proxy-authorization', 'cookie']); const blankTemplates = (s) => s.replace(/\{\{[^}]*\}\}/g, 'x'); function fillJson(v, fill) { if (typeof v === 'string') return fill(v); if (Array.isArray(v)) return v.map((x) => fillJson(x, fill)); if (v && typeof v === 'object') return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, fillJson(x, fill)])); return v; } function stepBody(text) { if (text === '') return null; try { return JSON.parse(text); } catch { return text; } } const thenWords = (spec) => spec.actions.map(actionWords).join(', then '); const UPSERT_KEY_TYPES = ['text', 'number', 'url', 'email']; const blankSource = (v) => v === undefined || v === null || v === '' || (Array.isArray(v) && !v.length); export function sourcePath(source, path) { let at = source; for (const part of String(path).split('.')) { if (at == null || typeof at !== 'object' || RESERVED_NAMES.has(part) || !Object.hasOwn(at, part)) return undefined; at = at[part]; } return at; } const { HUE_HEX, HUES, hueName } = globalThis.chipCore; function hueOf(o, { strict = false, fallback = 'slate' } = {}) { for (const authored of [o?.hue, o?.color]) { if (authored === undefined || authored === null || String(authored).trim() === '') continue; const hue = hueName(authored); if (hue) return hue; if (strict) { throw new WeaveError( `Unknown option colour '${authored}' (use a hue name: ${HUES.join(', ')}; ` + `or its hex, or '' for slate)`, 'invalid'); } } return fallback; } function normaliseOption(o, { strict = false } = {}) { if (typeof o === 'string') return { id: slug(o), name: o, hue: 'slate', icon: '', color: '' }; const hue = hueOf(o, { strict }); return { id: o.id ?? slug(o.name), name: o.name, hue, icon: iconValue(o.icon), color: HUE_HEX[hue], }; } const AGGREGATES = ['count', 'sum', 'avg', 'min', 'max', 'join', 'median', 'stdev', 'distinct', 'filled', 'empty', 'range']; const MAX_COMPUTE_DEPTH = 8; const CYCLE_PREFIX = '#CYCLE: '; const isCycle = (v) => typeof v === 'string' && v.startsWith(CYCLE_PREFIX); class CycleSignal extends Error { constructor(marker) { super(marker); this.marker = marker; } } const FORMULA_SCAN_CAP = 200; const RECIPE_KEYS = ['relationField', 'relation', 'targetField', 'aggregate']; export const DEFINABLE_TYPES = [ 'text', 'number', 'rating', 'date', 'daterange', 'checkbox', 'toggle', 'url', 'email', 'select', 'multiselect', 'workflow', 'document', 'field', 'key', 'attachments', 'avatar', ]; const MAX_DEFINITION_DEPTH = 4; export const TYPE_MIGRATIONS = { text: ['number', 'key', 'url', 'email', 'select', 'multiselect', 'date', 'formula'], formula: ['text'], number: ['rating', 'text'], rating: ['number', 'text'], url: ['text'], email: ['text'], key: ['text'], date: ['text'], checkbox: ['toggle', 'text'], toggle: ['checkbox', 'text'], select: ['multiselect', 'workflow', 'text'], multiselect: ['select', 'text'], workflow: ['select'], }; export const ONTOLOGY = { core: { key: 'entity', name: 'Entity', definition: 'One addressable thing: it has an id, a public id, a set of fields with values, and a dedicated entity view. Workspaces, spaces, tables and the records inside tables are all entities; they differ by level, not by kind.', identity: 'uuid, plus a per-table public id addressed as Table#n', storedIn: 'state.entities', view: '/e/ — the entity view: its fields, its documents, its comments, its files, its activity', has: ['values', 'documents', 'comments', 'files', 'activity'], api: ['getEntity', 'findEntity', 'readEntity', 'entityName', 'query'], }, levels: [ { key: 'workspace', name: 'Workspace', isEntity: true, registry: 'System/Workspaces', storedIn: 'state.meta', contains: 'spaces', identity: 'the .db file; a name and an optional logo', definition: 'One workspace file and everything in it. The top level of the hierarchy. Each workspace owns its users and system registry. Its spaces, tables and fields relate to its own workspace record; the hub routes between independent workspace files.', api: ['describeSchema', 'ontology', 'spaceVersions', 'exportJSON', 'importJSON', 'setWorkspaceLogo'], }, { key: 'space', name: 'Space', isEntity: true, registry: 'System/Spaces', storedIn: 'state.spaces', contains: 'tables', identity: 'uuid; name unique in the workspace, and the left half of Space/Table', definition: 'A space is a workspace\'s unit of meaning: the tables of one area, such as CRM or Development, each addressed as Space/Table. It carries its own schema version, and its record in System/Spaces is the same object seen as data.', api: ['createSpace', 'listSpaces', 'updateSpace', 'deleteSpace'], }, { key: 'table', name: 'Table', isEntity: true, registry: 'System/Tables', storedIn: 'state.tables', contains: 'records', identity: 'uuid; qualified name Space/Table', definition: 'An entity that is also an entity TYPE: the ordered set of fields every record inside it follows.', api: ['createTable', 'listTables', 'updateTable', 'moveTable', 'duplicateTable', 'deleteTable', 'qualifiedName'], }, { key: 'field', name: 'Field', isEntity: true, registry: 'System/Fields', storedIn: 'table.fields', contains: 'nothing', identity: 'uuid; name unique within its table', definition: 'One typed, named slot on a table. A field is not itself a record of data, but it has a record in the Fields registry — carrying its type and its definition — so the schema is editable as data.', api: ['addField', 'addRelation', 'updateField', 'deleteField', 'restoreField', 'materializeField'], }, { key: 'record', name: 'Record', isEntity: true, registry: null, storedIn: 'state.entities', contains: 'its values, documents, comments, files and activity', identity: 'uuid; public id addressed as Table#n', definition: 'An entity inside a table, typed by that table. Called whatever the domain calls it — item, entry, customer, company, account — without changing what it is. A grid draws it as a row, a board as a card.', note: 'A record needs no registry: it IS the data, and the table it belongs to is its entity type.', api: ['createEntity', 'updateEntity', 'deleteEntity', 'restoreEntity', 'listEntities'], }, ], constituents: [ { key: 'value', name: 'Value', storedIn: 'entity.values', definition: 'What one entity holds in one field, validated and coerced by that field’s type.', identity: 'the entity plus the field', api: ['resolveField', 'updateEntity', 'link', 'unlink', 'setState'], }, { key: 'document', name: 'Document', storedIn: 'entity.docs', definition: 'A long-form body — markdown, HTML or code — held in a document-typed field. An entity may carry any number.', identity: 'the entity plus the document field it fills', api: ['getDoc', 'setDoc', 'appendDoc', 'documentFields', 'descriptionField', 'listDocRevisions', 'getDocRevision', 'restoreDocRevision'], }, { key: 'comment', name: 'Comment', storedIn: 'entity.comments', definition: 'An authored, time-ordered note on an entity, kept separate from its documents.', identity: 'uuid within its entity', api: ['addComment', 'deleteComment'], }, { key: 'file', name: 'File', storedIn: 'entity.files', definition: 'A blob attached to an entity, stored beside the workspace file and referenced by id from attachments fields. An HTML file also keeps its page storage (Issue #1176): the localStorage its page writes while weave shows it, held in the store\'s file_storage table beside the file and never written into the file\'s bytes.', identity: 'uuid; the blob is files/ next to the workspace', api: ['attachFile', 'attachToField', 'readFile', 'readFileMeta', 'deleteFile', 'readFileStorage', 'writeFileStorage'], }, { key: 'activity', name: 'Activity', storedIn: 'entity.activity', definition: 'An append-only record of one thing that happened to an entity — created, field-updated, state-changed, relation-updated, doc-updated, doc-appended, comment-added, file-attached, automation-ran, undo. A table carries the same kind of entry for its fields\' configuration (field-config-updated and undo, with the definition before and after; Issue #428), kept in the audit log rather than on an entity. Every entry carries seq, a monotonic per-workspace commit counter: seq is the order, ts is the display. An entity keeps its newest 500; entity.activityDropped counts the older ones it no longer holds.', identity: 'entityId:index', api: ['activityFeed', 'getActivity'], }, ], records: [ { key: 'automation', name: 'Automation', isEntity: true, registry: 'System/Workflows', storedIn: 'state.entities', definition: 'A rule bound to one table, held as a record of System/Workflows (Feature #249): its Script is the rule as JSON — the table, a trigger (entity-created, field-updated, state-changed, or schedule: a cron expression, a zone and an optional where clause, Feature #312) and the actions it fires (set-field, append-doc, add-comment, webhook, upsert-row, http-request) — and its On toggle is the user\'s switch. The engine computes State (Setup incomplete, Ready) and stamps Health and Last Run on every fire. seq is the record number: the rules on one trigger fire in that order.', identity: 'uuid', api: ['createAutomation', 'listAutomations', 'describeAutomations', 'updateAutomation', 'deleteAutomation'], }, ], apparatus: [ { key: 'saved-view', name: 'Saved view', storedIn: 'state.meta.views', definition: 'A saved arrangement of one or more table blocks, each with its own filter and layout; optionally published read-only through a share token. Not to be confused with the entity view, which every entity has by existing.', identity: 'uuid; a share token when shared', api: ['createView', 'listViews', 'getView', 'deleteView', 'restoreView', 'resolveView', 'shareView', 'unshareView'], }, { key: 'account', name: 'Account', storedIn: 'state.meta.accounts', definition: 'A named token holder with a role — architect, editor, or observer (admin, writer and reader before 2026-10-02, rewritten on open). Only the token hash is kept. Browser sessions are kept beside it as sha256 hashes (Feature #222 part 2). A credentials[] array left on a record by the passkey door removed in Feature #243 is kept and ignored. Provider identities ({ issuer, subject }, no email) live on the record as identities[] (Feature #212, Feature #252); an identity is linked by redeeming a one-time invite kept as its sha256 in meta.identityInvites.', identity: 'uuid; name unique in the workspace', api: ['createAccount', 'listAccounts', 'setAccountRole', 'deleteAccount', 'verifyToken', 'setRequireAuth', 'createSession', 'verifySession', 'removedSession', 'listSessions', 'revokeSession', 'linkIdentity', 'inviteMember', 'listInvites', 'revokeInvite', 'identityInvite', 'redeemIdentityInvite', 'unlinkIdentity', 'accountForIdentity'], }, { key: 'key', name: 'Credential', storedIn: 'keystore', definition: 'A named secret — API key, token, password, id or pair — held outside the workspace, encrypted in weave\'s keystore or in the manager that owns it. A key field stores the NAME; the value never enters the .db. Reading it back is a separate audited act gated by the credential\'s own access list, never by a permission on the field.', identity: 'its name', api: ['setKey', 'hasKey', 'listKeys', 'resolveKey', 'revealKey', 'grantKey', 'revokeKey', 'deleteKey'], }, { key: 'audit', name: 'Audit entry', storedIn: 'store.audit_log', definition: 'A workspace-level record of a structural change: spaces, tables, fields, relations, saved views, accounts, keys, applied schemas. A schema entry names the spaces it touched; their count is each space\'s schema version, and spaceVersions() hashes the versions into the workspace etag (Feature #277).', identity: 'rowid in audit_log', api: ['listAudit'], }, { key: 'undo', name: 'Undo step', storedIn: 'store.undo_log', definition: 'A reversible before-image of one entity mutation, newest first, replayed by undo().', identity: 'rowid in undo_log', api: ['undo', 'listUndo'], }, ], aliases: ['row', 'item', 'entry', 'customer', 'company', 'account', 'task', 'deal', 'ticket', 'contact'], collisions: [ { alias: 'account', kind: 'account', note: 'An "account" record in a CRM table is a Record like any other; the Account kind here is a token holder with a role. Same word, two levels.', }, ], }; const { dressNumber } = globalThis.weaveNumberCore; function dressDate(c, iso) { return DG.formatDate(iso, c); } export function pickCostume(from, keys, into = {}) { for (const k of keys) if (from[k] != null) into[k] = from[k]; return into; } export function putCostume(config, keys, costume, touched = () => true) { for (const k of keys) { if (!touched(k)) continue; if (costume[k] == null) delete config[k]; else config[k] = costume[k]; } return config; } function rollupCostume(config) { if (!ROLLUP_COSTUME_KEYS.some((k) => config[k] != null)) return {}; return normalizeSelfContainedConfig('number', { ...config, separator: undefined }); } function formulaCostume(config) { if (config.grain == null) return normalizeSelfContainedConfig('number', config, { formula: true }); const date = normalizeSelfContainedConfig('date', config); return { ...date, grain: date.grain ?? [...DG.PARTS] }; } function dressDateRange(c, value) { return DG.formatDateRange(value, c); } function numberConfig(config, { formula = false } = {}) { const out = {}; if (config.format != null) { if (!['number', 'currency', 'percent', 'compact'].includes(config.format)) { throw new WeaveError(`Invalid number format '${config.format}' (number, currency, percent, compact)`, 'invalid'); } if (config.format !== 'number') out.format = config.format; } let unit = config.unit != null && String(config.unit).trim() ? String(config.unit).trim() : null; let currency = config.currency != null && String(config.currency).trim() ? String(config.currency).trim().toUpperCase() : null; if (out.format === 'currency' && !currency && unit && /^[A-Za-z]{3}$/.test(unit)) { currency = unit.toUpperCase(); unit = null; } if (currency) { try { new Intl.NumberFormat('en-US', { style: 'currency', currency }); } catch { throw new WeaveError(`'${currency}' is not a currency code (use ISO 4217: USD, EUR, GBP…)`, 'invalid'); } out.currency = currency; } if (unit) out.unit = unit; if (config.decimals != null) { if (!Number.isInteger(config.decimals) || config.decimals < 0 || config.decimals > 6) { throw new WeaveError(`Decimals must be 0..6, got '${config.decimals}'`, 'invalid'); } out.decimals = config.decimals; } if (config.separator != null) out.separator = !!config.separator; if (out.format === 'compact' && out.separator) throw new WeaveError('Compact groups on its own; a separator has nothing to add', 'invalid'); if (config.accounting) { if (out.format !== 'currency') throw new WeaveError('Accounting negatives need format currency', 'invalid'); out.accounting = true; } if (config.display === 'sparkline') { if (!formula) throw new WeaveError('A sparkline draws a list: only a formula can wear it', 'invalid'); out.display = 'sparkline'; if (config.style != null && !SPARKLINE_STYLES.includes(config.style)) { throw new WeaveError(`Invalid sparkline style '${config.style}' (${SPARKLINE_STYLES.join(', ')})`, 'invalid'); } if (config.style != null && config.style !== 'line') out.style = config.style; } else if (config.display != null) { if (!NUMBER_DISPLAYS.includes(config.display)) { throw new WeaveError(`Invalid number display '${config.display}' (${NUMBER_DISPLAYS.join(', ')})`, 'invalid'); } if (config.display !== 'text') out.display = config.display; } if (cellColorValue(config.color) !== 'ink') out.color = config.color; if (isGraphicDisplay(out.display) && config.scale != null && config.scale !== 'column') { const scale = config.scale; if (typeof scale !== 'number' || !Number.isFinite(scale) || scale <= 0) { throw new WeaveError("Scale is 'column' or a number above 0", 'invalid'); } out.scale = scale; } return out; } function dateConfig(type, config) { const out = {}; let grain; try { grain = DG.normalizeGrain(config.grain); } catch (e) { throw new WeaveError(e.message, 'invalid'); } if (grain) out.grain = grain; const time = !!config.time; if (time) out.time = true; const parts = grain ?? DG.PARTS; if (!parts.length && !time) throw new WeaveError('A grain with no date parts must keep a time of day', 'invalid'); if (config.format != null) { const problem = DG.formatProblem(parts, config.format); if (problem) throw new WeaveError(problem, 'invalid'); if (config.format !== DG.DEFAULT_FORMAT) out.format = config.format; } if (config.clock != null) { if (!DG.CLOCKS.includes(config.clock)) throw new WeaveError(`Invalid clock '${config.clock}' (${DG.CLOCKS.join(', ')})`, 'invalid'); if (!time) throw new WeaveError('A clock needs a time of day', 'invalid'); if (config.clock !== DG.DEFAULT_CLOCK) out.clock = config.clock; } if (config.zone != null) { if (!DG.ZONES.includes(config.zone)) throw new WeaveError(`Invalid zone '${config.zone}' (${DG.ZONES.join(', ')})`, 'invalid'); if (!time) throw new WeaveError('A zone needs a time of day', 'invalid'); if (config.zone === 'fixed') { if (!config.zoneName) throw new WeaveError('A fixed zone needs a zoneName (an IANA name: America/Los_Angeles, Europe/Berlin…)', 'invalid'); if (!DG.isZone(config.zoneName)) throw new WeaveError(`'${config.zoneName}' is not a time zone`, 'invalid'); out.zoneName = String(config.zoneName); } if (config.zone !== 'floating') out.zone = config.zone; } if (config.pad) out.pad = true; if (config.elapsed) { if (type !== 'daterange') throw new WeaveError('elapsed belongs to a range', 'invalid'); if (!time) throw new WeaveError('elapsed needs a time of day at both ends', 'invalid'); out.elapsed = true; } return out; } const optionName = (options, id) => options.find((o) => o.id === id)?.name ?? null; function credentialConfig(field) { const c = field?.config ?? {}; const kind = CREDENTIAL_KINDS.includes(c.kind) ? c.kind : 'apikey'; const keystore = KEYSTORES.includes(c.keystore) ? c.keystore : 'local'; return { kind, keystore, ...(kind === 'pair' ? { parts: c.parts ?? DEFAULT_PAIR_PARTS } : {}) }; } function costumeFromDescriptor(f, config) { pickCostume(f, FORMULA_COSTUME_KEYS, config); for (const k of DATE_COSTUME_KEYS) if (k !== 'format' && f[k] != null) config[k] = f[k]; } function recipeFromDescriptor(f, config) { if (f.via) config.relationField = f.via; if (f.viaTable) config.via = f.viaTable; if (f.where) config.where = f.where; if (f.targetField) config.targetField = f.targetField; if (f.aggregate) config.aggregate = f.aggregate; } function numberDescriptor(keys) { return (config, out) => { pickCostume(config, keys, out); if (config.display) out.color = config.color ?? 'ink'; }; } const asBoolean = (raw) => Boolean(raw); const asString = (raw) => String(raw); const dateType = (type) => ({ build: (config) => dateConfig(type, config), toDescriptor: (config, out) => pickCostume(config, DATE_COSTUME_KEYS, out), fromDescriptor: costumeFromDescriptor, merge(config, patch) { const costume = dateConfig(type, { ...config, ...patch }); putCostume(config, DATE_COSTUME_KEYS, costume, (k) => k in patch || k in costume); }, }); const optionType = { build: (config, { strict = false } = {}) => ({ options: (config.options ?? []).map((o) => normaliseOption(o, { strict })) }), merge(config, patch) { if (!patch.options) return; const stored = new Map(config.options.map((o) => [o.id, o])); config.options = patch.options.map((o) => { const was = typeof o === 'string' ? null : stored.get(o.id); const asStored = was && (o.hue ?? '') === (was.hue ?? '') && (o.color ?? '') === (was.color ?? ''); return normaliseOption(o, { strict: !asStored }); }); }, toDescriptor(config, out) { out.options = config.options.map((o) => o.name); out.optionsFull = config.options.map((o) => ({ id: o.id, name: o.name, hue: hueOf(o), icon: o.icon ?? '', color: o.color ?? '', })); }, fromDescriptor(f, config, existing) { if (!f.options) return; const named = new Map((f.optionsFull ?? []).map((o) => [o.name, o.color ?? ''])); const kept = new Map((existing?.options ?? []).map((o) => [o.name, o.color ?? ''])); const icons = new Map([ ...(existing?.options ?? []).map((o) => [o.name, o.icon ?? '']), ...(f.optionsFull ?? []).filter((o) => 'icon' in o).map((o) => [o.name, o.icon ?? '']), ]); config.options = f.options.map((name) => normaliseOption( { name, color: named.get(name) ?? kept.get(name) ?? '', icon: icons.get(name) ?? '' }, { strict: named.has(name) && named.get(name) !== kept.get(name) }, )); }, }; const CREDENTIAL_CONFIG_KEYS = ['kind', 'keystore', 'parts']; const FIELD_TYPE_CONFIG = { text: { build: (config) => (config.literal ? { literal: true } : {}), merge(config, patch) { if (!('literal' in patch)) return; if (FIELD_TYPE_CONFIG.text.build(patch).literal) config.literal = true; else delete config.literal; }, coerce(raw, { from, was }) { if (from === 'select') return optionName(was.options, raw); if (from === 'toggle') return raw ? was.on : was.off; if (from === 'multiselect') return (Array.isArray(raw) ? raw : [raw]).map((id) => optionName(was.options, id)).filter(Boolean).join(', '); if (from === 'workflow') return was.states.find((s) => s.id === raw)?.name ?? null; return String(raw); }, toDescriptor(config, out) { if (config.literal) out.literal = true; }, fromDescriptor(f, config) { if (f.literal != null) config.literal = f.literal; }, }, number: { build: numberConfig, merge(config, patch) { const costume = numberConfig({ ...config, ...patch }); putCostume(config, NUMBER_COSTUME_KEYS, costume, (k) => k in patch || k in costume || k in config); }, coerce(raw) { const n = Number(raw); return Number.isFinite(n) ? n : null; }, toDescriptor: numberDescriptor(NUMBER_COSTUME_KEYS), fromDescriptor: costumeFromDescriptor, }, formula: { merge(config, patch) { const switched = 'grain' in patch && (patch.grain == null) !== (config.grain == null); const costume = formulaCostume(switched ? patch : { ...config, ...patch }); putCostume(config, FORMULA_COSTUME_KEYS, costume, (k) => k in patch || k in costume || k in config); }, toDescriptor(config, out) { numberDescriptor(FORMULA_COSTUME_KEYS)(config, out); out.expression = config.expression; }, fromDescriptor(f, config) { if (f.expression) config.expression = f.expression; costumeFromDescriptor(f, config); }, }, lookup: { fromDescriptor: recipeFromDescriptor }, rollup: { merge(config, patch) { if (!ROLLUP_COSTUME_KEYS.some((k) => k in patch)) return; const merged = { ...config, ...patch }; for (const k of ROLLUP_COSTUME_KEYS) if (merged[k] === null) delete merged[k]; putCostume(config, ROLLUP_COSTUME_KEYS, rollupCostume(merged)); }, toDescriptor: (config, out) => pickCostume(config, ROLLUP_COSTUME_KEYS, out), fromDescriptor(f, config) { recipeFromDescriptor(f, config); costumeFromDescriptor(f, config); }, }, rating: { build(config) { const max = config.max ?? RATING_DEFAULTS.max; if (!Number.isInteger(max) || max < 1 || max > RATING_MAX) { throw new WeaveError(`A rating's max is a whole number from 1 to ${RATING_MAX}, got '${config.max}'`, 'invalid'); } const color = cellColorValue(config.color); return { max, icon: iconValue(config.icon ?? RATING_DEFAULTS.icon) || RATING_DEFAULTS.icon, ...(color !== 'ink' ? { color } : {}) }; }, merge(config, patch) { if (!('max' in patch || 'icon' in patch || 'color' in patch)) return; const { max, icon, color } = FIELD_TYPE_CONFIG.rating.build({ max: config.max, icon: config.icon, color: config.color, ...patch }); config.max = max; config.icon = icon; if (color) config.color = color; else delete config.color; if (typeof config.default === 'number') config.default = ratingValue(config.default, max); }, coerce(raw, { next }) { const n = Number(raw); return Number.isFinite(n) ? ratingValue(n, next.max) : null; }, toDescriptor(config, out) { out.max = config.max; out.icon = config.icon; out.color = config.color ?? 'ink'; }, fromDescriptor(f, config) { costumeFromDescriptor(f, config); if (f.max != null) config.max = f.max; if (f.icon != null) config.icon = f.icon; if (f.color != null) config.color = f.color; }, }, date: { ...dateType('date'), coerce(raw, { next, coerceDate }) { try { return coerceDate(next ?? {}, raw); } catch { return null; } }, }, daterange: dateType('daterange'), checkbox: { coerce: asBoolean }, toggle: { build(config) { const label = (key, fallback) => { if (config[key] == null) return fallback; const v = String(config[key]).trim(); if (!v) throw new WeaveError(`A toggle's ${key} label cannot be blank`, 'invalid'); return v; }; const on = label('on', 'On'); const off = label('off', 'Off'); if (on.toLowerCase() === off.toLowerCase()) throw new WeaveError(`A toggle needs two different labels, got '${on}' for both`, 'invalid'); return { on, off }; }, merge(config, patch) { if (!('on' in patch || 'off' in patch)) return; const { on, off } = FIELD_TYPE_CONFIG.toggle.build({ ...config, ...patch }); config.on = on; config.off = off; }, coerce: asBoolean, toDescriptor(config, out) { out.on = config.on; out.off = config.off; }, fromDescriptor(f, config) { if (f.on != null) config.on = f.on; if (f.off != null) config.off = f.off; }, }, url: { coerce: asString }, email: { coerce: (raw) => (/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(raw)) ? String(raw) : null) }, select: { ...optionType, coerce(raw, { from, next, findOption }) { if (from === 'multiselect') return (Array.isArray(raw) ? raw[0] : raw) ?? null; if (from === 'workflow') return raw; return findOption(next.options, String(raw).trim())?.id ?? null; }, }, multiselect: { ...optionType, coerce(raw, { from, next, findOption }) { if (from === 'select') return [raw]; return String(raw).split(',').map((v) => findOption(next.options, v.trim())?.id).filter(Boolean); }, }, workflow: { build(config, { strict = false } = {}) { const states = (config.states ?? DEFAULT_WORKFLOW_STATES).map((s) => (typeof s === 'string' ? { id: slug(s), name: s, category: 'in-progress', default: false } : { id: s.id ?? slug(s.name), name: s.name, category: RETIRED_STATE_CATEGORIES[s.category] ?? s.category ?? 'in-progress', default: !!s.default, ...(iconValue(s.icon) ? { icon: iconValue(s.icon) } : {}), ...(hueOf(s, { strict, fallback: null }) ? { hue: hueOf(s, { strict, fallback: null }) } : {}) })); if (states.length === 0) throw new WeaveError('Workflow field needs at least one state', 'invalid'); let marked = false; for (const s of states) { if (s.default && marked) s.default = false; marked ||= s.default; } for (const s of states) { if (!STATE_CATEGORIES.includes(s.category)) { throw new WeaveError(`Invalid state category '${s.category}' (use ${STATE_CATEGORIES.join(', ')})`, 'invalid'); } } return { states }; }, merge(config, patch) { if (patch.states) config.states = FIELD_TYPE_CONFIG.workflow.build({ states: patch.states }).states; }, coerce: (raw, { next }) => (next.states.some((s) => s.id === raw) ? raw : null), toDescriptor(config, out) { out.states = config.states.map((s) => ({ id: s.id, name: s.name, category: s.category, default: !!s.default, ...(s.icon ? { icon: s.icon } : {}), ...(s.hue ? { hue: s.hue } : {}) })); }, fromDescriptor(f, config) { if (f.states) config.states = f.states; }, }, field: { build(config) { const depth = config.depth ?? 1; if (!Number.isInteger(depth) || depth < 1 || depth > MAX_DEFINITION_DEPTH) { throw new WeaveError(`Definition depth must be 1..${MAX_DEFINITION_DEPTH}, got '${depth}'`, 'invalid'); } return { types: [...DEFINABLE_TYPES], depth }; }, toDescriptor(config, out) { out.types = [...config.types]; out.depth = config.depth; }, fromDescriptor(f, config) { if (f.types) config.types = f.types; if (f.depth != null) config.depth = f.depth; }, }, key: { build(config) { const kind = config.kind ?? 'apikey'; if (!CREDENTIAL_KINDS.includes(kind)) { throw new WeaveError(`Invalid credential kind '${kind}' (${CREDENTIAL_KINDS.join(', ')})`, 'invalid'); } const keystore = config.keystore ?? 'local'; if (!KEYSTORES.includes(keystore)) { throw new WeaveError(`Invalid keystore '${keystore}' (${KEYSTORES.join(', ')})`, 'invalid'); } const out = { kind, keystore }; if (kind === 'pair') { const parts = config.parts?.length ? config.parts : DEFAULT_PAIR_PARTS; if (parts.length !== 2) { throw new WeaveError(`A pair names exactly two parts, got ${parts.length}`, 'invalid'); } out.parts = parts.map((p) => { const name = typeof p === 'string' ? p : p.name; if (!name) throw new WeaveError('Every part of a pair needs a name', 'invalid'); return { name: String(name), secret: typeof p === 'string' ? false : !!p.secret }; }); } else if (config.parts != null) { throw new WeaveError(`Only a 'pair' credential names parts; '${kind}' holds one value`, 'invalid'); } return out; }, coerce: asString, merge(config, patch) { if (!CREDENTIAL_CONFIG_KEYS.some((k) => k in patch)) return; const seed = { ...config, ...patch }; if ((patch.kind ?? config.kind) !== 'pair' && !('parts' in patch)) delete seed.parts; const next = FIELD_TYPE_CONFIG.key.build(seed); config.kind = next.kind; config.keystore = next.keystore; if (next.parts) config.parts = next.parts; else delete config.parts; }, toDescriptor: (config, out) => Object.assign(out, credentialConfig({ config })), fromDescriptor(f, config) { for (const k of CREDENTIAL_CONFIG_KEYS) if (f[k] != null) config[k] = f[k]; if ((config.kind ?? 'apikey') !== 'pair') delete config.parts; }, }, attachments: { build(config) { const multiple = config.multiple == null ? true : !!config.multiple; const out = { multiple }; if (config.displayInline != null && typeof config.displayInline !== 'boolean') throw new WeaveError('displayInline must be a boolean', 'invalid'); if (config.displayInline) out.displayInline = true; const pick = (key, list, unmarked) => { const v = config[key]; if (v == null || v === '' || v === unmarked) return; if (!list.includes(v)) throw new WeaveError(`Invalid attachments ${key} '${v}' (${list.join(', ')})`, 'invalid'); out[key] = v; }; pick('preview', ATTACHMENT_PREVIEWS, null); pick('size', ATTACHMENT_SIZES, 'medium'); pick('fit', ATTACHMENT_FITS, 'trim'); if (['cover', 'intro'].includes(out.preview) && multiple) throw new WeaveError(out.preview === 'cover' ? "A cover preview needs a single-file field (multiple: false)" : "An introduction preview needs a single-file field (multiple: false)", 'invalid'); return out; }, merge(config, patch) { if (!['multiple', ...ATTACHMENT_LOOK_KEYS].some((k) => k in patch)) return; const next = FIELD_TYPE_CONFIG.attachments.build({ ...config, ...patch }); config.multiple = next.multiple; putCostume(config, ATTACHMENT_LOOK_KEYS, next); }, toDescriptor(config, out) { out.multiple = config.multiple !== false; pickCostume(config, ATTACHMENT_LOOK_KEYS, out); }, fromDescriptor(f, config) { if (f.multiple != null) config.multiple = f.multiple; pickCostume(f, ATTACHMENT_LOOK_KEYS, config); }, }, avatar: { build(config) { const shape = config.shape == null || config.shape === '' ? 'round' : config.shape; if (!AVATAR_SHAPES.includes(shape)) throw new WeaveError(`Invalid avatar shape '${shape}' (${AVATAR_SHAPES.join(', ')})`, 'invalid'); return { shape }; }, merge(config, patch) { if ('shape' in patch) config.shape = FIELD_TYPE_CONFIG.avatar.build({ shape: patch.shape }).shape; }, toDescriptor(config, out) { out.shape = config.shape ?? 'round'; }, fromDescriptor(f, config) { if (f.shape != null) config.shape = f.shape; }, }, document: { build(config) { const out = {}; if (config.displayInline != null && typeof config.displayInline !== 'boolean') throw new WeaveError('displayInline must be a boolean', 'invalid'); if (config.displayInline) out.displayInline = true; if (config.kind != null && config.kind !== 'markdown') { if (!DOCUMENT_KINDS.includes(config.kind)) throw new WeaveError(`Invalid document kind '${config.kind}' (${DOCUMENT_KINDS.join(', ')})`, 'invalid'); out.kind = config.kind; } return out; }, merge(config, patch) { if (!['kind', 'displayInline'].some(k => k in patch)) return; const next = FIELD_TYPE_CONFIG.document.build({ ...config, ...patch }); putCostume(config, ['kind', 'displayInline'], next); }, toDescriptor(config, out) { pickCostume(config, ['kind', 'displayInline'], out); }, fromDescriptor(f, config) { pickCostume(f, ['kind', 'displayInline'], config); }, }, view: { toDescriptor(config, out, { fieldName }) { out.role = config.shape; out.shape = config.shape; out.link = config.link; out.state = config.state; out.description = config.description; out.fields = config.fields == null ? null : config.fields.map(fieldName).filter(Boolean); }, fromDescriptor(f, config) { for (const k of ['link', 'state', 'description']) if (f[k] !== undefined) config[k] = f[k]; if (f.fields !== undefined) config.fields = f.fields; }, }, }; function normalizeSelfContainedConfig(type, config = {}, options = {}) { return FIELD_TYPE_CONFIG[type]?.build?.(config, options) ?? {}; } function normalizeDefinition(raw, depth, { strict = false } = {}) { if (raw == null) return null; if (typeof raw !== 'object' || Array.isArray(raw)) { throw new WeaveError('A field definition must be an object of { type, config }', 'invalid'); } const { type } = raw; if (!DEFINABLE_TYPES.includes(type)) { throw new WeaveError(`'${type}' is not a definable field type (use ${DEFINABLE_TYPES.join(', ')})`, 'invalid'); } if (type === 'field' && depth < 2) { throw new WeaveError( 'A definition at depth 1 must describe a leaf field; raise the field\'s depth to nest one', 'invalid'); } const config = normalizeSelfContainedConfig(type, raw.config ?? {}, { strict }); if (type === 'field' && config.depth > depth - 1) { throw new WeaveError( `A nested definition may declare depth ${depth - 1} at most, got ${config.depth}`, 'invalid'); } return { type, config }; } const formatFilters = (filters) => Object.entries(filters ?? {}).map(([f, states]) => `${f}: ${states.join(', ')}`).join('; '); const parseFilters = (text) => { const out = {}; for (const part of String(text ?? '').split(';').map((x) => x.trim()).filter(Boolean)) { const i = part.indexOf(':'); if (i < 0) throw new WeaveError(`A filter is 'Field: State, State' — got '${part}'`, 'invalid'); out[part.slice(0, i).trim()] = part.slice(i + 1).split(',').map((x) => x.trim()).filter(Boolean); } return out; }; const formatSort = (sort) => (sort ?? []).map((s) => `${s.field} ${s.dir}`).join(', '); const SORT_FORM = "Sort reads 'Field asc|desc, Field2 asc|desc'"; const parseSortPart = (part) => { if (part && typeof part === 'object' && !Array.isArray(part)) { const field = String(part.field ?? part.name ?? '').trim(); const dir = String(part.dir ?? part.direction ?? 'asc').trim().toLowerCase(); if (!field || !['asc', 'desc'].includes(dir)) throw new WeaveError(`${SORT_FORM}; got ${JSON.stringify(part)}`, 'invalid'); return { field, dir }; } const s = String(part ?? '').trim(); const m = s.match(/^(.+)\s+(asc|desc)$/i); if (m) return { field: m[1].trim(), dir: m[2].toLowerCase() }; if (/^[-+]\S/.test(s)) return { field: s.slice(1).trim(), dir: s[0] === '-' ? 'desc' : 'asc' }; return { field: s, dir: 'asc' }; }; const parseSort = (value) => { let v = value; if (typeof v === 'string' && /^\s*[[{]/.test(v)) { try { v = JSON.parse(v); } catch { throw new WeaveError(`${SORT_FORM}, e.g. 'Date desc'; got '${v}'`, 'invalid'); } } if (v && typeof v === 'object' && !Array.isArray(v)) v = [v]; const parts = Array.isArray(v) ? v : String(v ?? '').split(','); return parts.filter((x) => typeof x !== 'string' || x.trim()).map(parseSortPart); }; const MIN_COLUMN_WIDTH = 60; const VIEW_MIN_WIDTH = 40; const VIEW_MAX_WIDTH = 4000; const VIEW_DENSITIES = ['compact', 'comfortable', 'spacious']; const formatWidths = (widths) => Object.entries(widths ?? {}).map(([n, px]) => `${n} ${px}`).join(', '); function parseWidths(text) { const out = {}; for (const part of String(text ?? '').split(',').map((x) => x.trim()).filter(Boolean)) { const m = part.match(/^(.+?)\s+(\S+)$/); const px = m ? Number(m[2]) : NaN; if (!m || !Number.isFinite(px)) throw new WeaveError(`Widths reads 'Name 240, Due 112' — a field name, a space, its width in pixels; got '${part}'`, 'invalid'); out[m[1]] = px; } return out; } function fieldDescriptionValue(raw) { if (raw == null) return ''; if (typeof raw !== 'string') throw new WeaveError('A field description is plain text', 'invalid'); return raw.trim(); } const CREATE_INPUT_KEYS = new Set(['values', 'name', 'doc', 'docs']); const BULK_OPS = ['set', 'link', 'move', 'rollup']; export { WeaveError }; function nowISO() { return new Date().toISOString(); } function schemaFingerprint(state) { const { spaces = {}, tables = {}, automations = {}, entities, ...meta } = state ?? {}; const drop = (key, value) => (key === 'publicIdCounter' || key === 'activitySeq' ? undefined : value); const parts = [JSON.stringify(meta, drop)]; for (const collection of [spaces, tables, automations]) { for (const id of Object.keys(collection).sort()) parts.push(id, JSON.stringify(collection[id], drop)); } const text = parts.join('\u0000'); let h = 0x811c9dc5; for (let i = 0; i < text.length; i++) { h ^= text.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } return `${text.length.toString(16)}${h.toString(16).padStart(8, '0')}`; } export function templateDoc(spaceDoc, { name, rollups = [] } = {}) { if (!spaceDoc || typeof spaceDoc.space !== 'string' || !Array.isArray(spaceDoc.tables)) { throw new WeaveError('templateDoc takes one space entry of describeSchema()', 'invalid'); } if (spaceDoc.system) throw new WeaveError(`Space '${spaceDoc.space}' is the workspace's own system space and cannot be a template`, 'invalid'); const from = spaceDoc.space; const to = String(name ?? from).trim(); if (!to) throw new WeaveError('A template copy needs a name', 'invalid'); const prefix = `${from}/`; const inside = new Set(spaceDoc.tables.map((t) => prefix + t.name)); const move = (q) => (typeof q === 'string' && q.startsWith(prefix) ? `${to}/${q.slice(prefix.length)}` : q); const without = (o, keys) => Object.fromEntries(Object.entries(o).filter(([k]) => !keys.includes(k))); const src = structuredClone(spaceDoc); const skipped = []; const gone = new Map(src.tables.map((t) => [t.name, new Set()])); const tableOf = (q) => (inside.has(q) ? src.tables.find((t) => prefix + t.name === q) : null); const drop = (t, f, why) => { gone.get(t.name).add(f.name); skipped.push({ table: t.name, field: f.name, ...why }); }; for (const t of src.tables) { for (const f of t.fields ?? []) { if (f.type !== 'relation') continue; const targets = Array.isArray(f.targetDbs) ? f.targetDbs : [f.targetDb]; if (targets.some((q) => !inside.has(q))) drop(t, f, { targetDb: f.targetDbs ?? f.targetDb }); } } for (let moved = true; moved;) { moved = false; for (const t of src.tables) { const out = gone.get(t.name); for (const f of t.fields ?? []) { if (out.has(f.name) || f.role === 'name') continue; let why = null; if ((f.type === 'lookup' || f.type === 'rollup') && f.via != null) { const rel = (t.fields ?? []).find((x) => x.name === f.via); const far = rel ? tableOf(rel.targetDb) : null; if (out.has(f.via)) why = { via: f.via }; else if (far && f.targetField != null && gone.get(far.name).has(f.targetField)) why = { via: f.via, targetField: f.targetField }; } else if (f.type === 'formula' && f.expression) { let refs = []; try { refs = formulaReferences(f.expression); } catch { refs = []; } const hit = refs.find((n) => out.has(n)); if (hit) why = { reads: hit }; } if (why) { drop(t, f, why); moved = true; } } } } const scrub = (out, list) => (list ?? []).filter((n) => !out.has(n)); const scrubKeys = (out, obj) => (obj ? Object.fromEntries(Object.entries(obj).filter(([k]) => !out.has(k))) : obj); const doc = { ...without(src, ['spaceId', 'url', 'template', 'tables', 'id']), space: to, tables: src.tables.map((t) => { const out = gone.get(t.name); const table = without(t, ['id', 'url', 'entityCount', 'maxPublicId']); if (table.qualified != null) table.qualified = move(table.qualified); table.fields = (t.fields ?? []).filter((f) => !out.has(f.name)).map((f) => { const field = without(f, ['id', 'targetDbId', 'targetDbIds', 'inverseFieldId', 'viaTableId']); if (field.targetDb != null) field.targetDb = move(field.targetDb); if (Array.isArray(field.targetDbs)) field.targetDbs = field.targetDbs.map(move); if (field.viaTable != null) field.viaTable = move(field.viaTable); if (Array.isArray(field.optionsFull)) field.optionsFull = field.optionsFull.map((o) => without(o, ['id'])); if (Array.isArray(field.states)) field.states = field.states.map((st) => without(st, ['id'])); if (field.type === 'view' && Array.isArray(field.fields)) field.fields = scrub(out, field.fields); return field; }); if (!out.size) { if (Array.isArray(table.views)) table.views = table.views.map((v) => without(v, ['id'])); return table; } if (table.hiddenFields) table.hiddenFields = scrub(out, table.hiddenFields); if (table.filters) table.filters = scrubKeys(out, table.filters); if (table.sort) table.sort = table.sort.filter((x) => !out.has(x.field)); if (table.bodyBlocks) table.bodyBlocks = scrub(out, table.bodyBlocks); if (Array.isArray(table.views)) { table.views = table.views.map((v) => { const view = without(v, ['id']); if (view.fields) view.fields = scrub(out, view.fields); if (view.filters) view.filters = scrubKeys(out, view.filters); if (view.sort) view.sort = view.sort.filter((x) => !out.has(x.field)); if (view.widths) view.widths = scrubKeys(out, view.widths); return view; }); } return table; }), }; const kept = []; for (const r of rollups ?? []) { const far = tableOf(r.viaTable); if (!far) { skipped.push({ table: 'System/Spaces', field: r.name, viaTable: r.viaTable }); continue; } if (r.targetField != null && gone.get(far.name).has(r.targetField)) { skipped.push({ table: 'System/Spaces', field: r.name, viaTable: r.viaTable, targetField: r.targetField }); continue; } const out = without(r, ['id', 'viaTableId']); out.viaTable = move(r.viaTable); kept.push(out); } return { doc, rollups: kept, skipped }; } let HELD = null; export function deferMigrations() { HELD ??= new Set(); } export function runDeferredMigrations() { const held = HELD; HELD = null; for (const w of held ?? []) { w.settleDeferred(); } } export function detachHubRegistry(root, members = []) { return root.detachHubRegistry(members); } function detachedRegistryState(root, member, members) { const source = root.state; const state = structuredClone(member.state); const tables = Object.values(source.tables).filter(t => t.system && !t.deletedAt); const localTables = Object.values(state.tables).filter(t => t.system); const tableMap = new Map(localTables.map(t => [t.id, tables.find(r => r.system === t.system)?.id ?? t.id])); const fieldMap = new Map(); const rowMap = new Map(); const optionMap = new Map(); const moveActor = actor => { const match = /^account:([0-9a-f-]{36})(.*)$/.exec(String(actor ?? '')); if (!match || Object.hasOwn(state.meta.accounts ?? {}, match[1])) return actor; const owner = [root, ...members].find(w => Object.hasOwn(w.state.meta.accounts ?? {}, match[1])); return `from ${owner?.state.meta.name ?? 'legacy workspace'}: ${owner?.state.meta.accounts[match[1]].name ?? 'removed account'}${match[2]}`; }; const sourceRows = Object.values(source.entities); const field = (t, name) => Object.values(t.fields).find(f => f.name === name); const owner = row => { const t = source.tables[row.dbId]; if (!t?.system) return null; if (t.system === 'workspaces') return row.sysId; const workspace = field(t, 'Workspace'); if (workspace && row.values[workspace.id]) return source.entities[row.values[workspace.id]]?.sysId; if (t.system === 'forms') { const ref = row.values[field(t, 'Table')?.id]; const tableRow = source.entities[Array.isArray(ref) ? ref[0] : ref]; return tableRow && owner(tableRow); } return null; }; const selected = sourceRows.filter(row => owner(row) === state.meta.id); const selectedIds = new Set(selected.map(row => row.id)); const localIds = new Set(Object.keys(state.entities)); const move = value => { if (typeof value === 'string') return rowMap.get(value) ?? fieldMap.get(value) ?? tableMap.get(value) ?? value; if (Array.isArray(value)) return value.map(move); if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([k, v]) => [fieldMap.get(k) ?? k, move(v)])); return value; }; for (const local of localTables) { const target = tables.find(t => t.system === local.system); if (!target) throw new WeaveError(`Cannot detach ${state.meta.name}: missing ${local.system} registry`, 'invalid'); for (const f of Object.values(local.fields)) { const match = f.type === 'rollup' && f.config.via ? Object.values(target.fields).find(r => r.type === 'rollup' && r.config.via === f.config.via && r.config.aggregate === f.config.aggregate && r.config.targetField === f.config.targetField) : field(target, f.name); if (match && match.type !== f.type) throw new WeaveError(`Cannot detach ${state.meta.name}: conflicting ${local.name}.${f.name}`, 'invalid'); if (match) { fieldMap.set(f.id, match.id); for (const option of f.config.options ?? f.config.states ?? []) { const mapped = (match.config.options ?? match.config.states ?? []).find(o => o.name === option.name); if (mapped) optionMap.set(option.id, mapped.id); } } } for (const row of Object.values(state.entities).filter(row => row.dbId === local.id)) { const match = row.sysId && selected.find(r => r.dbId === target.id && r.sysId === row.sysId); if (match) rowMap.set(row.id, match.id); } } const localSystem = Object.values(state.spaces).find(s => s.system === 'workspace'); if (!localSystem) throw new WeaveError(`Cannot detach ${state.meta.name}: missing local System space`, 'invalid'); localSystem.deletedAt = null; for (const t of localTables) delete state.tables[t.id]; for (const original of tables) { const t = structuredClone(original); t.spaceId = localSystem.id; const local = localTables.find(l => l.system === t.system); if (local?.tableViews) t.tableViews = move(structuredClone(local.tableViews)); for (const f of Object.values(local?.fields ?? {})) { if (!fieldMap.has(f.id) && !t.fields[f.id]) t.fields[f.id] = move(structuredClone(f)); const mapped = t.fields[fieldMap.get(f.id)]; if (!mapped) continue; const key = f.type === 'workflow' ? 'states' : ['select', 'multiselect'].includes(f.type) ? 'options' : null; if (key) { for (const option of f.config[key] ?? []) { if (mapped.config[key].some(o => o.name === option.name)) continue; if (mapped.config[key].some(o => o.id === option.id)) throw new WeaveError(`Cannot detach ${state.meta.name}: conflicting option ${t.name}.${f.name}`, 'invalid'); mapped.config[key].push(structuredClone(option)); } } if (!f.system && !mapped.system) { const normalize = value => Array.isArray(value) ? value.map(normalize) : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => [k, normalize(v)])) : value; const semantic = config => normalize(Object.fromEntries(Object.entries(config).filter(([k]) => !['options', 'states'].includes(k)))); if (JSON.stringify(semantic(move(f.config))) !== JSON.stringify(semantic(mapped.config))) throw new WeaveError(`Cannot detach ${state.meta.name}: conflicting field configuration ${t.name}.${f.name}`, 'invalid'); } } t.fieldOrder = [...new Set([...t.fieldOrder, ...Object.keys(t.fields)])]; t.publicIdCounter = Math.max(t.publicIdCounter ?? 0, local?.publicIdCounter ?? 0); for (const f of Object.values(t.fields)) { const targets = f.type === 'relation' ? (f.config.targetDbs ?? [f.config.targetDb]) : f.type === 'rollup' && f.config.via ? [f.config.via] : []; if (targets.some(id => id && !tables.some(r => r.id === id) && !state.tables[id])) { if (f.type === 'rollup' && f.config.via) { delete t.fields[f.id]; t.fieldOrder = t.fieldOrder.filter(id => id !== f.id); continue; } throw new WeaveError(`Cannot detach ${state.meta.name}: cross-workspace field ${t.name}.${f.name}`, 'invalid'); } } state.tables[t.id] = t; } const rewriteDoc = value => String(value).replace(/(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`]*`)|(\[\[[^\]\n]+\]\])/g, (token, code, wiki) => { if (code) return code; const inner = wiki.slice(2, -2); const ref = inner.split('|', 1)[0].trim(); const typed = /^(table|space|workspace):(.+)$/.exec(ref); const id = (typed ? typed[2] : ref).trim(); if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) return token; const target = typed?.[1] === 'table' ? tableMap.get(id) : !typed ? rowMap.get(id) : null; if (!target) { const foreign = typed?.[1] === 'table' ? !state.tables[id] && [root, ...members].some(w => w.state.tables[id]) : typed?.[1] === 'space' ? !state.spaces[id] && [root, ...members].some(w => w.state.spaces[id]) : typed?.[1] === 'workspace' ? id !== state.meta.id && [root, ...members].some(w => w.state.meta.id === id) : !selectedIds.has(id) && !localIds.has(id) && [root, ...members].some(w => w !== member && w.state.entities[id]); if (foreign) throw new WeaveError(`Cannot detach ${state.meta.name}: cross-workspace document reference ${id}`, 'invalid'); return token; } return token.replace(id, target); }); const entities = {}; for (const original of Object.values(state.entities)) { const row = structuredClone(original); const oldTable = member.state.tables[original.dbId]; row.values = Object.fromEntries(Object.entries(original.values).map(([id, value]) => { const type = oldTable?.fields[id]?.type; const mapped = type === 'relation' ? move(value) : ['select', 'workflow', 'multiselect'].includes(type) ? (Array.isArray(value) ? value.map(v => optionMap.get(v) ?? v) : optionMap.get(value) ?? value) : structuredClone(value); return [fieldMap.get(id) ?? id, mapped]; })); row.docs = Object.fromEntries(Object.entries(original.docs ?? {}).map(([id, value]) => [fieldMap.get(id) ?? id, rewriteDoc(value)])); if (row.sysId) row.sysId = tableMap.get(row.sysId) ?? fieldMap.get(row.sysId) ?? row.sysId; row.id = rowMap.get(original.id) ?? original.id; row.dbId = tableMap.get(original.dbId) ?? original.dbId; entities[row.id] = row; } for (const original of selected) { const prior = entities[original.id]; const row = structuredClone(original); row.values = { ...(prior?.values ?? {}), ...row.values }; for (const key of ['files', 'comments', 'activity']) row[key] = [...new Map([...(prior?.[key] ?? []), ...(row[key] ?? [])].map(item => [item.id ?? JSON.stringify(item), item])).values()]; row.docs = { ...(prior?.docs ?? {}), ...Object.fromEntries(Object.entries(row.docs ?? {}).map(([id, value]) => [id, rewriteDoc(value)])) }; entities[row.id] = row; } for (const row of Object.values(entities)) { for (const key of ['createdBy', 'modifiedBy']) if (row[key] != null) row[key] = moveActor(row[key]); for (const comment of row.comments ?? []) comment.author = moveActor(comment.author); for (const activity of row.activity ?? []) { activity.actor = moveActor(activity.actor); if (activity.detail?.author != null) activity.detail.author = moveActor(activity.detail.author); } } state.entities = entities; for (const t of Object.values(state.tables)) { if (!t.system) { for (const f of Object.values(t.fields)) f.config = move(f.config); if (t.tableViews) t.tableViews = move(t.tableViews); } for (const f of Object.values(t.fields)) { if (f.type !== 'relation') continue; for (const row of Object.values(entities).filter(r => r.dbId === t.id)) { const refs = [].concat(row.values[f.id] ?? []); for (const id of refs) { const target = source.entities[id]; if (target && source.tables[target.dbId]?.system && !selectedIds.has(id) && !localIds.has(id)) { throw new WeaveError(`Cannot detach ${state.meta.name}: cross-workspace relation ${t.name}.${f.name}`, 'invalid'); } } } } } delete state.meta.registry; state.meta.localRegistry = { source: source.meta.id, rows: [...selectedIds] }; return { state, ids: selectedIds, rowMap, fieldMap, rewriteDoc, moveActor }; } export const inviteUrl = (origin, code) => `${origin}/api/auth/oidc/start?invite=${encodeURIComponent(code)}`; const USER_COLUMNS = [ ['Email', 'text', {}], ['Role', 'select', { options: [{ name: 'Architect', hue: 'purple' }, { name: 'Editor', hue: 'blue' }, { name: 'Observer', hue: 'slate' }] }], ['Status', 'select', { options: [{ name: 'Invited', hue: 'amber' }, { name: 'Active', hue: 'green' }] }], ['Kind', 'select', { options: [{ name: 'Person', hue: 'slate' }, { name: 'Agent', hue: 'slate' }] }], ['Avatar', 'avatar', { shape: 'round' }], ['Identities', 'number', { decimals: 0 }], ['Last Seen', 'date', { time: true }], ['Created', 'date', { time: true }], ]; const USER_KEPT = ['Email', 'Status', 'Kind', 'Identities', 'Last Seen', 'Created']; const capital = (s) => String(s ?? '').charAt(0).toUpperCase() + String(s ?? '').slice(1); export class Weave { #dirty = new Set(); #dirtyAll = false; #held = false; #detachedStorage = null; #schemaVersion = null; #scales = new Map(); #rows = null; #query = null; #cutoffs = 0; #computing = []; constructor({ path = null, actor = 'local', keystorePath = null, store = null, keystoreEnv = null, keyStore = null, revisionWindowMs = DOC_REVISION_WINDOW_MS, name = null, localKeyAccess = null } = {}) { this.actor = actor; this.localKeyAccess = localKeyAccess; this.revisionWindowMs = revisionWindowMs; this.keystorePath = keystorePath ?? keystoreFilePath(process.env.WEAVE_KEYSTORE) ?? join(process.env.HOME ?? '.', '.weave', 'keystore.json'); this.keystoreEnv = keystoreEnv ?? process.env; this.#keyStore = keyStore; Object.defineProperty(this, 'keyStore', { get: () => this.#keyStore ?? openKeyStore({ path: this.keystorePath, env: this.keystoreEnv }) }); this.store = store ?? new Store(path); const loaded = this.store.load(); if (loaded && (!loaded.meta || (loaded.tables == null && loaded.databases == null))) { throw new WeaveError(`'${path}' is not a Weave workspace file`, 'invalid'); } this.state = loaded ?? { version: 2, meta: { name: name ?? nameFromFile(path) ?? workspaceName().slug, createdAt: nowISO(), percentFractional: true }, spaces: {}, tables: {}, entities: {}, automations: {}, }; this.#migrate(); if (this.state.meta.registry !== 'hub') this.#ensureMetaTables(); this.#repairDoubledInverses(); if (this.#settlePositions() | this.#landBlobs()) this.save(); HELD?.add(this); } #migrate() { const s = this.state; refuseBeforeFloor(s); if (!s.meta.id) { s.meta.id = uuid(); if (this.store.path) this.save(); } s.tables = s.tables ?? {}; let changed = false; for (const db of Object.values(s.tables)) { if (this.#reconcileFieldOrder(db)) changed = true; if (!db.system) { if (this.#ensureDescriptionField(db)) changed = true; if (this.#ensureViewFields(db)) changed = true; } if (this.#ensureTableViews(db)) changed = true; if (this.#ensureSystemColumnsInViews(db)) changed = true; if (this.#dropRetiredSystemField(db)) changed = true; if (this.#ensureStandardViewName(db)) changed = true; } if (s.meta.activitySeq == null) { s.meta.activitySeq = 0; changed = true; } { const pending = []; const touched = new Set(); for (const e of Object.values(s.entities ?? {})) { let high = ''; (e.activity ?? []).forEach((a, i) => { const ts = String(a.ts ?? ''); if (ts > high) high = ts; if (a.seq == null) { pending.push({ e, a, i, key: high }); touched.add(e); } }); } if (pending.length) { pending.sort((x, y) => (x.key === y.key ? (x.e.id === y.e.id ? x.i - y.i : (x.e.id < y.e.id ? -1 : 1)) : (x.key < y.key ? -1 : 1))); let n = s.meta.activitySeq; for (const row of pending) row.a.seq = ++n; for (const e of touched) this.#mark(e); s.meta.activitySeq = n; changed = true; } } { const autos = Object.values(s.automations ?? {}); const high = Math.max(s.meta.automationSeq ?? 0, ...autos.map((a) => a.seq ?? 0)); if (s.meta.automationSeq !== high) { s.meta.automationSeq = high; changed = true; } for (const auto of autos) { if (auto.seq != null) continue; auto.seq = ++s.meta.automationSeq; changed = true; } } for (const a of Object.values(s.meta.accounts ?? {})) { const role = Weave.roleName(a.role); if (role !== a.role) { a.role = role; changed = true; } } if (this.#scrubIdentityEmails()) changed = true; if (Shares.liftViewShares(s)) changed = true; if (Shares.expireLegacy(s)) changed = true; if (this.#settlePositions()) changed = true; if (changed) this.save(); } #settlePositions() { let changed = false; const settle = (list) => { let top = Math.max(-1, ...list.map((x) => (Number.isInteger(x.position) ? x.position : -1))); for (const x of list) { if (Number.isInteger(x.position)) continue; x.position = ++top; changed = true; } }; settle(Object.values(this.state.spaces ?? {})); const bySpace = new Map(); for (const t of Object.values(this.state.tables ?? {})) { if (!bySpace.has(t.spaceId)) bySpace.set(t.spaceId, []); bySpace.get(t.spaceId).push(t); } for (const list of bySpace.values()) settle(list); return changed; } #place(all, item, position) { checkPosition(position); const rest = all.filter((x) => x !== item); const live = rest.filter((x) => !x.deletedAt); const anchor = live[position]; const at = anchor ? rest.indexOf(anchor) : live.length ? rest.indexOf(live[live.length - 1]) + 1 : rest.length; rest.splice(at, 0, item); rest.forEach((x, i) => { x.position = i; }); } #spacesInOrder() { return byPosition(Object.values(this.state.spaces)); } #tablesOfSpace(spaceId) { return byPosition(Object.values(this.state.tables).filter((d) => d.spaceId === spaceId)); } #reconcileFieldOrder(db) { const fields = db.fields ?? {}; const seen = new Set(); const order = []; for (const id of db.fieldOrder ?? []) { if (!fields[id] || seen.has(id)) continue; seen.add(id); order.push(id); } for (const id of Object.keys(fields)) if (!seen.has(id)) order.push(id); const was = db.fieldOrder ?? []; if (order.length === was.length && order.every((id, i) => id === was[i])) return false; db.fieldOrder = order; return true; } #ensureDescriptionField(db) { if (db.descriptionFieldId === null) return false; if (db.descriptionFieldId && db.fields[db.descriptionFieldId]?.type === 'document') return false; const adopted = this.documentFields(db)[0]; if (adopted) { db.descriptionFieldId = adopted.id; return true; } const docField = { id: uuid(), name: 'Description', type: 'document', config: {} }; db.fields[docField.id] = docField; placeField(db, docField.id); db.descriptionFieldId = docField.id; return true; } #repairDoubledInverses() { for (const target of Object.values(this.state.tables)) { if (target.system) continue; for (const field of Object.values(target.fields)) { const source = field.type === 'relation' && field.config?.inverseFieldId ? this.state.tables[field.config.targetDb] : null; if (!source || source.system || !/ss$/.test(field.name) || field.name.slice(0, -1) !== source.name) continue; const name = this.#inverseName(source, field.config.many); if (name === field.name || this.findField(target, name)) continue; try { this.updateField(target.id, field.id, { name }); } catch (err) { if (!(err instanceof WeaveError)) throw err; } } } } #ensureViewFields(db) { if (db.system) return false; let changed = false; for (const shape of VIEW_SHAPES) { const key = `${shape}FieldId`; if (db[key] && db.fields[db[key]]?.type === 'view') continue; let name = VIEW_NAMES[shape]; for (let n = 2; Object.values(db.fields).some((f) => f.name.toLowerCase() === name.toLowerCase()); n++) name = `${VIEW_NAMES[shape]} ${n}`; const field = { id: uuid(), name, type: 'view', config: { ...VIEW_DEFAULTS[shape] }, system: true }; db.fields[field.id] = field; db.fieldOrder.push(field.id); db[key] = field.id; if (!db.tableViews && !(db.hiddenFields ?? []).includes(name)) db.hiddenFields = [...(db.hiddenFields ?? []), name]; changed = true; } return changed; } #ensureSystemColumnsInViews(db) { if (db.system || db.systemColumnsInViews || !Array.isArray(db.tableViews)) return false; db.systemColumnsInViews = true; const ids = (db.systemFields ?? []).filter((n) => GRID_SYSTEM_COLUMNS.includes(n)).map(sysColumnId); for (const v of db.tableViews) for (const id of ids) if (!v.fields.includes(id)) v.fields.push(id); return true; } #dropRetiredSystemField(db) { const col = sysColumnId(RETIRED_SYSTEM_FIELD); const views = (db.tableViews ?? []).filter((v) => v.fields?.includes(col)); if (!db.systemFields?.includes(RETIRED_SYSTEM_FIELD) && !views.length) return false; if (db.systemFields) db.systemFields = withoutRetired(db.systemFields); for (const v of views) v.fields = v.fields.filter((id) => id !== col); return true; } #ensureStandardViewName(db) { if (db.standardViewNamed || !Array.isArray(db.tableViews)) return false; db.standardViewNamed = true; const taken = db.tableViews.some((v) => v.name.toLowerCase() === 'standard'); const old = db.tableViews.find((v) => v.name === 'Default'); if (old && !taken) old.name = 'Standard'; return true; } #ensureTableViews(db) { if (Array.isArray(db.tableViews)) return false; const hidden = new Set(db.hiddenFields ?? []); db.standardViewNamed = true; const view = { id: uuid(), name: 'Standard', fields: db.fieldOrder.filter((id) => db.fields[id] && !hidden.has(db.fields[id].name)) }; if (db.filters && Object.keys(db.filters).length) view.filters = db.filters; if (db.sort?.length) view.sort = db.sort; db.tableViews = [view]; delete db.hiddenFields; delete db.filters; delete db.sort; return true; } viewField(dbRef, shape) { const db = dbRef && typeof dbRef === 'object' ? dbRef : this.getTable(dbRef); if (!VIEW_SHAPES.includes(shape)) throw new WeaveError(`A view is a chip or a card, not '${shape}'`, 'invalid'); if (db.system) return null; const f = db.fields?.[db[`${shape}FieldId`]]; return f?.type === 'view' ? f : null; } #normalizeViewConfig(db, field, config) { const out = { ...field.config }; if (config.shape != null && config.shape !== out.shape) throw new WeaveError(`The shape is fixed: this field is the ${out.shape}`, 'invalid'); for (const k of ['link', 'state']) { if (config[k] === undefined) continue; if (typeof config[k] !== 'boolean') throw new WeaveError(`${k} is true or false`, 'invalid'); out[k] = config[k]; } if (config.description !== undefined) { if (!DESCRIPTION_SIZES.includes(config.description)) throw new WeaveError(`description is one of ${DESCRIPTION_SIZES.join(', ')}`, 'invalid'); out.description = config.description; } if (config.fields !== undefined) { if (config.fields === null) out.fields = null; else { if (!Array.isArray(config.fields)) throw new WeaveError('fields is a list of field names, or null for the first few', 'invalid'); out.fields = config.fields.map((ref) => { const f = this.findField(db, ref); if (!f) throw new WeaveError(`'${ref}' is not a field of ${db.name}`, 'invalid'); if (f.type === 'view') throw new WeaveError(`A ${out.shape} cannot show itself or the other view`, 'invalid'); if (f.id === db.nameFieldId) throw new WeaveError('The name is always shown', 'invalid'); if (VIEW_EXCLUDED_TYPES.includes(f.type)) throw new WeaveError(`A ${f.type} field cannot ride on a ${out.shape}; the description preview has its own setting`, 'invalid'); return f.id; }); } } return out; } #viewSegmentFields(e, db, cfg, limit) { const glanceable = (f) => f.id !== db.nameFieldId && !VIEW_EXCLUDED_TYPES.includes(f.type) && f.type !== 'workflow'; if (Array.isArray(cfg.fields)) return cfg.fields.map((id) => db.fields[id]).filter(Boolean); const out = []; for (const fid of db.fieldOrder) { if (out.length >= limit) break; const f = db.fields[fid]; if (!f || !glanceable(f)) continue; const v = this.#resolve(e, db, f, 0); if (v == null || v === '' || (Array.isArray(v) && !v.length)) continue; out.push(f); } return out; } #faceOf(e) { const db = this.state.tables[e.dbId]; const field = Object.values(db.fields).find((f) => f.type === 'avatar' && !f.deletedAt); if (!field) return null; const name = this.entityName(e); const core = globalThis.chipCore; const id = (Array.isArray(e.values[field.id]) ? e.values[field.id] : []).find((x) => e.files.some((m) => m.id === x)) ?? null; const kind = db.system === 'users' ? this.#displayValue(db, this.findField(db, 'Kind'), this.#resolve(e, db, this.findField(db, 'Kind'), 0), e) : null; return { shape: field.config.shape ?? 'round', initials: core.initialsFor(name), hue: core.hueForName(name), file: id, badge: String(kind?.name ?? kind ?? '') === 'Agent' ? 'agent' : null, }; } renderView(entityRef, shape, { limit = null, config = null } = {}) { const e = this.getEntity(entityRef); const db = this.state.tables[e.dbId]; const field = this.viewField(db, shape); const cfg = config ? this.#normalizeViewConfig(db, field ?? { config: { ...VIEW_DEFAULTS[shape] } }, config) : (field?.config ?? VIEW_DEFAULTS[shape]); const out = { shape, id: e.id, publicId: e.publicId, url: this.permalink('entity', e.id), name: this.entityName(e), link: cfg.link, state: null, description: null, fields: [] }; const face = this.#faceOf(e); if (face) out.avatar = face; const wf = Object.values(db.fields).find((f) => f.type === 'workflow'); if (cfg.state && wf) { const st = this.#resolve(e, db, wf, 0); const def = wf.config.states.find((s) => s.id === st || s.name === st); if (def) out.state = { name: def.name, category: def.category, ...(def.hue ? { hue: def.hue } : {}) }; } if (cfg.description !== 'none') { const docField = this.descriptionField(db); const text = docField ? plainLines(e.docs?.[docField.id] ?? '') : ''; if (text) { const max = DESCRIPTION_CHARS[cfg.description]; out.description = max ? clip(text.join(' '), max) : text[0]; } } const budget = (limit ?? VIEW_AUTO_SEGMENTS[shape]) - (out.state ? 1 : 0); for (const f of this.#viewSegmentFields(e, db, cfg, Math.max(budget, 0))) { const resolved = this.#resolve(e, db, f, 0); const v = this.#displayValue(db, f, resolved, e); const shown = f.type === 'toggle' ? (v ? f.config.on : f.config.off) : v; const seg = { label: f.name, value: shown == null ? '' : Array.isArray(shown) ? shown.map((x) => x?.name ?? x).join(', ') : String(shown?.name ?? shown) }; if ((f.type === 'select' || f.type === 'multiselect') && seg.value) { const named = (name) => { const o = f.config.options?.find((x) => x.name === name); return { name, ...(o?.hue ? { hue: o.hue } : {}), ...(o?.icon ? { icon: o.icon } : {}) }; }; seg.type = f.type; if (f.type === 'select') seg.option = named(seg.value); else seg.options = (Array.isArray(shown) ? shown : []).map((x) => named(x?.name ?? x)); } if (f.type === 'formula' || f.type === 'rollup' || f.type === 'lookup') seg.type = f.type; const nd = typeof resolved === 'number' ? this.#numberDisplay(db, f) : null; if (nd) seg.meter = { display: nd.display, value: resolved, scale: this.#scaleOf(db, f), color: nd.color }; const rated = Array.isArray(resolved) ? resolved.some((v) => typeof v === 'number') : typeof resolved === 'number'; const rt = rated ? this.#ratingOf(db, f) : null; if (rt) { seg.rating = Array.isArray(resolved) ? { values: resolved.map((v) => (typeof v === 'number' ? v : null)), ...rt } : { value: resolved, ...rt }; } if (f.type === 'formula' && f.config.display === 'sparkline' && Array.isArray(resolved)) seg.spark = { style: f.config.style ?? 'line', values: resolved, color: f.config.color ?? 'ink' }; out.fields.push(seg); } return out; } #viewLine(v) { const parts = []; parts.push(v.link ? `#${v.publicId} ${v.name}` : v.name); if (v.state) parts.push(v.state.name); if (v.description) parts.push(v.description); for (const f of v.fields) if (f.value !== '') parts.push(`${f.label} ${f.value}`); return parts.join(' · '); } termOf(dbRef) { const db = dbRef && typeof dbRef === 'object' ? dbRef : this.getTable(dbRef); const term = Term.resolve(db.fields?.[db.nameFieldId]?.config); if (!term.set && db.system && SYSTEM_TERMS[db.system]) return { ...Term.normalize({ singular: SYSTEM_TERMS[db.system] }), set: false }; return term; } #inverseName(db, many) { return Term.inverseName(db.name, this.termOf(db), many); } #setTerm(db, term, { save = true } = {}) { const field = db.fields[db.nameFieldId]; if (!field) throw new WeaveError('This table has no Name field', 'invalid'); if (term == null) delete field.config.term; else { try { field.config.term = Term.normalize(term); } catch (err) { throw new WeaveError(err.message, 'invalid'); } } if (save) { this.#syncFieldRow(db, field); this.save(); if (!db.system) this.#audit('field-updated', { table: db.name, name: field.name, patch: ['term'] }); } } descriptionField(db) { if (!db?.fields) return null; if (db.system) return null; if (db.descriptionFieldId === null) return null; const f = db.fields[db.descriptionFieldId]; if (f?.type === 'document') return f; return this.documentFields(db)[0] ?? null; } save() { if (this.#wfQueue.size && !this.#stamping) { const ids = [...this.#wfQueue]; this.#wfQueue.clear(); for (const id of ids) { const row = own(this.state.entities, id); if (row && !row.deletedAt) this.#settleWorkflow(row); } } if (HELD) this.#held = true; else { const pending = this.#detachedStorage; if (pending) for (const [id, bytes] of pending.blobs) { const path = this.#blobPath(id); if (!path) { (this.state.fileBlobs ??= {})[id] = bytes.toString('base64'); continue; } if (existsSync(path)) continue; writeFileDurable(path, bytes); } this.store.batch(() => { if (pending) { for (const [id, storage] of pending.storage) this.store.setFileStorage(id, storage); for (const group of pending.revisions) { const restored = new Map(); const existing = this.store.listDocRevisions(group.entityId, group.fieldId, { limit: 200 }).map(r => this.store.getDocRevision(group.entityId, group.fieldId, r.seq)); for (const revision of group.rows) { const same = existing.find(r => r.at === revision.at && r.actor === revision.actor && r.text === revision.text); const seq = same?.seq ?? this.store.pushDocRevision({ ...revision, entityId: group.entityId, fieldId: group.fieldId, restoredFrom: restored.get(revision.restoredFrom) ?? null }).seq; restored.set(revision.seq, seq); } } } if (this.store.save(this.state, { dirty: this.#dirty, all: this.#dirtyAll })) this.#rows?.byPublicId.clear(); }); this.#detachedStorage = null; this.#dirty.clear(); this.#dirtyAll = false; } this.#schemaVersion = null; this.#scales.clear(); this.#forgetDerived(); } #touching = null; touching(fn) { const outer = this.#touching; const set = new Set(); this.#touching = set; try { return { result: fn(), touched: [...set] }; } finally { this.#touching = outer; if (outer) for (const id of set) outer.add(id); } } affectedBy(id, touched = []) { const out = new Set([id, ...touched]); const e = own(this.state.entities, id); const db = e ? this.state.tables[e.dbId] : null; for (const f of Object.values(db?.fields ?? {})) { if (f.type !== 'relation') continue; for (const rid of this.#relationIds(e, f)) out.add(rid); } return [...out]; } #mark(entityOrId) { const e = typeof entityOrId === 'string' ? own(this.state.entities, entityOrId) : entityOrId; const db = e?.dbId ? this.state.tables[e.dbId] : null; const nf = db?.fields?.[db.nameFieldId]; if (nf?.type === 'formula' && e.values) { try { const v = this.#resolve(e, db, nf, 0); e.values[nf.id] = v == null ? '' : String(v); } catch {} } const id = typeof entityOrId === 'string' ? entityOrId : entityOrId.id; this.#dirty.add(id); this.#forgetDerived(); this.#touching?.add(id); if (db?.system === 'workflows' && !this.#stamping) { this.#wfQueue.add(id); this.#wfEpoch++; } } settleDeferred() { this.maybeRefresh(); const held = this.#held; this.#held = false; this.#migrate(); this.#migrateAutomations(); if (held) this.save(); } maybeRefresh() { if (!this.store.changedExternally?.()) return false; const loaded = this.store.reload(); if (loaded) { this.state = loaded; if (this.registryHost) this.#syncAll(); this.#migrateAutomations(); } this.#schemaVersion = null; this.#scales.clear(); this.#forgetDerived(); return true; } schemaVersion() { this.#schemaVersion ??= schemaFingerprint(this.state); return this.#schemaVersion; } spaceMembers(ref) { const sp = this.getSpace(ref); return Object.entries(sp.members ?? {}).map(([id, m]) => { const a = own(this.state.meta.accounts ?? {}, id); return a ? { id, name: a.name, role: Weave.roleName(m.role) ?? Weave.roleName(a.role), workspaceRole: Weave.roleName(a.role), override: !!m.role } : null; }).filter(Boolean); } setSpaceMember(ref, accountRef, { role = null } = {}) { const sp = this.getSpace(ref); if (sp.system) throw new WeaveError(`Space '${sp.name}' is the workspace's own system space; it has no members`, 'invalid'); this.#mayChange(sp.id, 'architect'); const a = this.#account(accountRef); const asked = role == null || role === '' ? null : Weave.roleName(role); if (asked != null && !Weave.ROLES.includes(asked)) throw new WeaveError(`Invalid role '${role}' (${Weave.ROLES.join(', ')})`, 'invalid'); (sp.members ??= {})[a.id] = asked ? { role: asked } : {}; this.save(); this.#audit('space-member-set', { space: sp.name, name: a.name, role: asked }, sp.id); return this.spaceMembers(sp.id); } removeSpaceMember(ref, accountRef) { const sp = this.getSpace(ref); this.#mayChange(sp.id, 'architect'); const a = this.#account(accountRef); if (own(sp.members ?? {}, a.id)) { delete sp.members[a.id]; if (!Object.keys(sp.members).length) delete sp.members; this.save(); this.#audit('space-member-removed', { space: sp.name, name: a.name }, sp.id); } return this.spaceMembers(sp.id); } #nameHolders(kind, name, { spaceId, except } = {}) { const key = String(name).toLowerCase(); const hits = Object.values(kind === 'Space' ? this.state.spaces : this.state.tables) .filter((x) => x !== except && (spaceId === undefined || x.spaceId === spaceId) && x.name.toLowerCase() === key); return { live: hits.find((x) => !x.deletedAt), held: hits.find((x) => x.deletedAt) }; } #assertNameFree(kind, name, label, scope) { const { live, held } = this.#nameHolders(kind, name, scope); if (live) throw new WeaveError(`${kind} '${label}' already exists`, 'conflict'); if (held) throw new WeaveError(`${kind} '${label}' is in the trash — restore or purge it first`, 'conflict'); } createSpace({ name, description = '', icon = '', template = false }) { if (!name) throw new WeaveError('Space name is required', 'invalid'); refuseReserved('space', name); this.#assertNameFree('Space', name, name); const space = { id: uuid(), name, description, ...(iconValue(icon) ? { icon: iconValue(icon) } : {}), ...(template === true ? { template: true } : {}), createdAt: nowISO() }; this.state.spaces[space.id] = space; this.#settlePositions(); this.save(); this.#syncSpaceRow(space); if (!space.system) this.#audit('space-created', { name: space.name }, space.id); return space; } listSpaces({ includeDeleted = false } = {}) { const hidden = this.#hiddenSpaces(); const all = hidden ? this.#spacesInOrder().filter((s) => !hidden.has(s.id)) : this.#spacesInOrder(); return includeDeleted ? all : all.filter((s) => !s.deletedAt); } findSpace(ref) { if (ref && typeof ref === 'object') ref = ref.id; const hidden = this.#hiddenSpaces(); const live = Object.values(this.state.spaces).filter((s) => !s.deletedAt && !hidden?.has(s.id)); const byId = own(this.state.spaces, ref); return (byId && !hidden?.has(byId.id) ? byId : undefined) ?? live.find((s) => s.name === ref) ?? live.find((s) => s.name.toLowerCase() === String(ref).toLowerCase()) ?? live.find((s) => answersLegacySystem(s, ref)); } getSpace(ref) { const s = this.findSpace(ref); if (!s) throw new WeaveError(`Space '${ref}' not found`, 'not-found'); return s; } updateSpace(ref, patch) { this.#mayChange(this.getSpace(ref).id, 'architect'); const s = this.getSpace(ref); if (patch.name != null) refuseReserved('space', patch.name); if (patch.position != null) checkPosition(patch.position); if (patch.template != null && typeof patch.template !== 'boolean') throw new WeaveError(`A space's template is true or false, got ${JSON.stringify(patch.template)}`, 'invalid'); if (patch.access != null && !SPACE_ACCESS.includes(patch.access)) throw new WeaveError(`A space is open or private, got ${JSON.stringify(patch.access)}`, 'invalid'); if (patch.access != null && s.system) throw new WeaveError(`Space '${s.name}' is the workspace's own system space and stays open`, 'invalid'); this.#audit('space-updated', { name: s.name, patch: Object.keys(patch) }, s.id); if (patch.name != null) s.name = patch.name; if (patch.description != null) s.description = patch.description; if (patch.icon != null) { const v = iconValue(patch.icon); if (v) s.icon = v; else delete s.icon; } if (patch.template != null) { if (patch.template) s.template = true; else delete s.template; } if (patch.access != null) { if (patch.access === 'private') s.access = 'private'; else delete s.access; } if (patch.position != null) this.#place(this.#spacesInOrder(), s, patch.position); this.#syncSpaceRow(s); this.save(); return s; } deleteSpace(ref, { hard = false } = {}) { this.#mayChange(this.getSpace(ref).id, 'architect'); const s = this.getSpace(ref); if (s.system) throw new WeaveError(`Space '${s.name}' is the workspace's own system space and cannot be deleted`, 'invalid'); if (!hard) { if (s.deletedAt) return s; s.deletedAt = nowISO(); this.#trashSysRow('spaces', s.id); this.#audit('space-trashed', { name: s.name }, s.id); this.save(); return s; } for (const db of this.listTables(s.id, { includeDeleted: true })) this.deleteTable(db.id, { hard: true }); delete this.state.spaces[s.id]; this.#dropSysRow('spaces', s.id); this.#audit('space-deleted', { name: s.name }, s.id); this.save(); } restoreSpace(ref) { if (ref && typeof ref === 'object') ref = ref.id; const s = own(this.state.spaces, ref) ?? Object.values(this.state.spaces).find((x) => x.name.toLowerCase() === String(ref).toLowerCase()); if (!s) throw new WeaveError(`Space '${ref}' not found`, 'not-found'); this.#mayChange(s.id, 'architect'); if (!s.deletedAt) return s; if (s.system && this.state.meta.registry === 'hub') throw new WeaveError('The registry lives at the weave root now — this workspace\'s own System space is a tombstone', 'invalid'); if (this.#nameHolders('Space', s.name).live) throw new WeaveError(`A live space already holds the name '${s.name}'`, 'conflict'); s.deletedAt = null; this.#restoreSysRow('spaces', s.id); this.#audit('space-restored', { name: s.name }, s.id); this.save(); return s; } createTable({ space, name, description = '', icon = '' }) { this.#mayChange(this.getSpace(space).id, 'architect'); const sp = this.getSpace(space); if (!name) throw new WeaveError('Table name is required', 'invalid'); refuseReserved('table', name); const qualified = `${sp.name}/${name}`; this.#assertNameFree('Table', name, qualified, { spaceId: sp.id }); const nameField = { id: uuid(), name: 'Name', type: 'text', config: {} }; const docField = { id: uuid(), name: 'Description', type: 'document', config: {} }; const db = { id: uuid(), spaceId: sp.id, name, description, icon: iconValue(icon), publicIdCounter: 0, nameFieldId: nameField.id, descriptionFieldId: docField.id, fields: { [nameField.id]: nameField, [docField.id]: docField }, fieldOrder: [nameField.id, docField.id], createdAt: nowISO(), }; this.#ensureViewFields(db); this.#ensureTableViews(db); this.#ensureSystemColumnsInViews(db); this.state.tables[db.id] = db; this.#settlePositions(); this.save(); this.#syncTableRow(db); for (const f of Object.values(db.fields)) this.#syncFieldRow(db, f); if (!db.system) this.#audit('table-created', { space: sp.name, name: db.name }, sp.id); return db; } listTables(spaceId = null, { includeDeleted = false } = {}) { const hidden = this.#hiddenSpaces(); let all = byPosition(Object.values(this.state.tables).filter((d) => !hidden?.has(d.spaceId))); if (!includeDeleted) all = all.filter((d) => !d.deletedAt && !this.state.spaces[d.spaceId]?.deletedAt); if (spaceId) return all.filter((d) => d.spaceId === spaceId); const rank = new Map(this.#spacesInOrder().map((sp, i) => [sp.id, i])); return all.map((d, i) => [d, i]).sort(([a, i], [b, j]) => (rank.get(a.spaceId) ?? Infinity) - (rank.get(b.spaceId) ?? Infinity) || i - j).map(([d]) => d); } userTables() { return this.listTables().filter((d) => !d.system && !this.state.spaces[d.spaceId]?.system); } qualifiedName(db) { const sp = this.state.spaces[db.spaceId]; return `${sp ? sp.name : '?'}/${db.name}`; } findTable(ref) { if (ref && typeof ref === 'object') ref = ref.id; const hidden = this.#hiddenSpaces(); if (own(this.state.tables, ref)) return hidden?.has(this.state.tables[ref].spaceId) ? undefined : this.state.tables[ref]; const all = Object.values(this.state.tables).filter((d) => !d.deletedAt && !this.state.spaces[d.spaceId]?.deletedAt && !hidden?.has(d.spaceId)); if (String(ref).includes('/')) { const [spName, dbName] = String(ref).split('/'); const named = all.filter((d) => d.name.toLowerCase() === dbName.toLowerCase()); return named.find((d) => this.state.spaces[d.spaceId]?.name.toLowerCase() === spName.toLowerCase()) ?? named.find((d) => answersLegacySystem(this.state.spaces[d.spaceId], spName)); } const matches = all.filter((d) => d.name.toLowerCase() === String(ref).toLowerCase()); if (matches.length > 1) throw new WeaveError(`Table name '${ref}' is ambiguous; qualify as Space/Name`, 'ambiguous'); return matches[0]; } getTable(ref) { const db = this.findTable(ref); if (!db) throw new WeaveError(`Table '${ref}' not found`, 'not-found'); return db; } updateTable(ref, patch) { this.#mayShape(ref); const db = this.getTable(ref); if (patch.name != null) refuseReserved('table', patch.name); const shape = (t) => JSON.stringify([t.name, t.description ?? '', t.fieldOrder, t.position]); if (patch.position != null) checkPosition(patch.position); const was = shape(db); if (patch.name != null && db.system && patch.name !== db.name) throw new WeaveError(`Table '${db.name}' is part of the system registry and cannot be renamed`, 'invalid'); if (patch.name != null) db.name = patch.name; if (patch.description != null) db.description = patch.description; if (patch.icon != null) db.icon = iconValue(patch.icon); if (patch.noun != null) { if (typeof patch.noun !== 'string') throw new WeaveError('A noun is a short string (e.g. "invoice")', 'invalid'); this.#setTerm(db, patch.noun.trim() ? { singular: patch.noun } : null); } if (patch.systemFields != null) { for (const n of patch.systemFields) { if (!GRID_SYSTEM_COLUMNS.includes(n)) throw new WeaveError(`'${n}' is not a system field (${GRID_SYSTEM_COLUMNS.join(', ')})`, 'invalid'); } db.systemFields = [...patch.systemFields]; const cur = !db.system ? db.tableViews?.[0] : null; if (cur) { const want = new Set(patch.systemFields.filter((n) => GRID_SYSTEM_COLUMNS.includes(n)).map(sysColumnId)); const show = [...want].filter((id) => !cur.fields.includes(id)).map(sysColumnName); const hide = cur.fields.filter((id) => sysColumnName(id) && !want.has(id)).map(sysColumnName); if (show.length || hide.length) this.#writeView(db, cur.name, { show, hide }); } } if (patch.filters != null || patch.sort != null || patch.hiddenFields != null) { this.#writeDefaultView(db, patch); } if (patch.hideRollups != null) { if (typeof patch.hideRollups !== 'boolean') throw new WeaveError('hideRollups is true or false', 'invalid'); db.hideRollups = patch.hideRollups; } if (patch.referencePanels != null) { if (!Array.isArray(patch.referencePanels)) throw new WeaveError(`referencePanels is a list of ${REFERENCE_PANELS.join(', ')}`, 'invalid'); for (const n of patch.referencePanels) { if (!REFERENCE_PANELS.includes(n)) throw new WeaveError(`'${n}' is not a reference panel (${REFERENCE_PANELS.join(', ')})`, 'invalid'); } const on = REFERENCE_PANELS.filter((n) => patch.referencePanels.includes(n)); if (on.length) db.referencePanels = on; else delete db.referencePanels; } if (patch.bodyOrder != null) { if (!Array.isArray(patch.bodyOrder)) throw new WeaveError('bodyOrder is a list of block keys', 'invalid'); const keys = patch.bodyOrder.map((ref2) => { if (isFixedBlock(ref2)) return ref2; const f = this.getField(db.id, ref2); if (!isBodyBlock(f)) throw new WeaveError(`'${f.name}' is not a body block — value fields move inside ${VALUES_BLOCK}`, 'invalid'); return f.id; }); if (new Set(keys).size !== keys.length) throw new WeaveError('bodyOrder lists every block at most once, and it repeated one', 'invalid'); if (keys.length) db.bodyOrder = keys; else delete db.bodyOrder; } if (patch.fieldOrder != null) { const ids = patch.fieldOrder.map((ref2) => this.getField(db.id, ref2).id); for (const fid of db.fieldOrder) if (db.fields[fid]?.type === 'view' && !ids.includes(fid)) ids.push(fid); const unique = new Set(ids); if (unique.size !== ids.length || ids.length !== db.fieldOrder.length) { throw new WeaveError('fieldOrder must list every field exactly once', 'invalid'); } db.fieldOrder = ids; } if (patch.position != null) this.#place(this.#tablesOfSpace(db.spaceId), db, patch.position); this.#syncTableRow(db); this.save(); if (!db.system && shape(db) !== was) this.#audit('table-updated', { name: db.name, patch: Object.keys(patch) }, this.#spacesTouching(db)); if (patch.name != null) this.#reg.#settleWorkflows(); return db; } moveTable(ref, spaceRef, { position = null } = {}) { this.#mayShape(ref); this.#mayChange(this.getSpace(spaceRef).id, 'architect'); if (position != null) checkPosition(position); const db = this.getTable(ref); if (db.system) throw new WeaveError(`Table '${db.name}' is part of the system registry`, 'invalid'); if (db.deletedAt) throw new WeaveError(`Table '${db.name}' is in the trash — restore it first`, 'conflict'); const sp = this.getSpace(spaceRef); if (sp.system) throw new WeaveError(`Space '${sp.name}' is the workspace's own system space and cannot hold your tables`, 'invalid'); if (sp.deletedAt) throw new WeaveError(`Space '${sp.name}' is in the trash — restore it first`, 'conflict'); if (sp.id === db.spaceId) { if (position == null) return db; return this.updateTable(db.id, { position }); } this.#assertNameFree('Table', db.name, `${sp.name}/${db.name}`, { spaceId: sp.id, except: db }); const from = this.state.spaces[db.spaceId]?.name; const left = db.spaceId; db.spaceId = sp.id; delete db.position; if (position != null) this.#place(this.#tablesOfSpace(sp.id), db, position); else this.#settlePositions(); this.save(); this.#syncTableRow(db); this.#audit('table-moved', { name: db.name, from, to: sp.name }, [left, ...this.#spacesTouching(db)]); return db; } duplicateTable(ref) { this.#mayShape(ref); const src = this.getTable(ref); if (src.system) throw new WeaveError(`Table '${src.name}' is part of the system registry`, 'invalid'); if (src.deletedAt) throw new WeaveError(`Table '${src.name}' is in the trash — restore it first`, 'conflict'); const sp = this.state.spaces[src.spaceId]; const taken = (n) => { const { live, held } = this.#nameHolders('Table', n, { spaceId: src.spaceId }); return !!(live || held); }; let name = `${src.name} Copy`; for (let i = 2; taken(name); i++) name = `${src.name} Copy ${i}`; const newId = uuid(); const idMap = new Map(Object.keys(src.fields).map((fid) => [fid, uuid()])); const mapId = (fid) => idMap.get(fid) ?? fid; const fields = {}; const touchedTargets = []; for (const f of Object.values(src.fields)) { const nf = structuredClone(f); nf.id = mapId(f.id); if (f.type === 'relation' && !f.config.targetDbs) { if (f.config.targetDb === src.id) { nf.config.targetDb = newId; nf.config.inverseFieldId = mapId(f.config.inverseFieldId); } else { const target = this.state.tables[f.config.targetDb]; const srcInv = target?.fields[f.config.inverseFieldId]; if (target && srcInv) { let invName = `${srcInv.name} Copy`; for (let i = 2; this.findField(target, invName); i++) invName = `${srcInv.name} Copy ${i}`; const inv = { id: uuid(), name: invName, type: 'relation', config: { ...structuredClone(srcInv.config), targetDb: newId, inverseFieldId: nf.id } }; nf.config.inverseFieldId = inv.id; target.fields[inv.id] = inv; placeField(target, inv.id); touchedTargets.push([target, inv]); } else { delete nf.config.inverseFieldId; } } } if (f.type === 'lookup' || f.type === 'rollup') { nf.config.relationField = mapId(f.config.relationField); if (nf.config.targetField != null) nf.config.targetField = mapId(nf.config.targetField); } if (f.type === 'view' && Array.isArray(f.config.fields)) { nf.config.fields = f.config.fields.map(mapId); } fields[nf.id] = nf; } const db = { ...structuredClone({ description: src.description, icon: src.icon, noun: src.noun, systemFields: src.systemFields, hideRollups: src.hideRollups, referencePanels: src.referencePanels }), tableViews: (src.tableViews ?? []).map((v) => { const copy = { ...structuredClone(v), id: uuid(), fields: v.fields.map(mapId) }; if (copy.widths) copy.widths = Object.fromEntries(Object.entries(copy.widths).map(([k, px]) => [mapId(k), px])); if (copy.group) copy.group = copy.group.map((l) => ({ ...l, field: mapId(l.field) })); if (copy.completedBy) copy.completedBy = mapId(copy.completedBy); delete copy.parked; delete copy.order; return copy; }), id: newId, spaceId: src.spaceId, name, publicIdCounter: 0, nameFieldId: mapId(src.nameFieldId), descriptionFieldId: src.descriptionFieldId == null ? src.descriptionFieldId : mapId(src.descriptionFieldId), chipFieldId: src.chipFieldId ? mapId(src.chipFieldId) : undefined, cardFieldId: src.cardFieldId ? mapId(src.cardFieldId) : undefined, fields, fieldOrder: src.fieldOrder.map(mapId), createdAt: nowISO(), }; if (src.bodyOrder) db.bodyOrder = src.bodyOrder.map((k) => (isFixedBlock(k) ? k : mapId(k))); for (const k of Object.keys(db)) if (db[k] === undefined) delete db[k]; this.state.tables[db.id] = db; this.#settlePositions(); this.save(); this.#syncTableRow(db); for (const f of Object.values(db.fields)) this.#syncFieldRow(db, f); for (const [target, inv] of touchedTargets) { this.#syncFieldRow(target, inv); this.#syncTableRow(target); } this.#audit('table-duplicated', { space: sp?.name, source: src.name, name: db.name }, this.#spacesTouching(db)); return db; } deleteTable(ref, { hard = false } = {}) { this.#mayShape(ref); const db = this.getTable(ref); if (db.system) throw new WeaveError(`Table '${db.name}' is part of the system registry`, 'invalid'); if (!hard) { if (db.deletedAt) return db; db.deletedAt = nowISO(); this.#trashSysRow('tables', db.id); for (const v of db.tableViews ?? []) this.#trashSysRow('views', v.id); this.#audit('table-trashed', { name: db.name }, this.#spacesTouching(db)); this.save(); return db; } const touched = this.#spacesTouching(db); for (const e of this.listEntities(db.id, { includeDeleted: true })) { this.deleteEntity(e.id, { hard: true }); } for (const field of Object.values(db.fields)) { if (field.type === 'relation') { const other = this.state.tables[field.config.targetDb]; if (other && other.id !== db.id) { this.#removeFieldRaw(other, field.config.inverseFieldId); this.#dropFieldRow(field.config.inverseFieldId); } } } for (const other of Object.values(this.state.tables)) { if (other.id === db.id) continue; for (const f of [...Object.values(other.fields)]) { if (f.type !== 'relation' || !f.config.targetDbs?.includes(db.id)) continue; f.config.targetDbs = f.config.targetDbs.filter((id) => id !== db.id); if (!f.config.targetDbs.length) { this.#removeFieldRaw(other, f.id); this.#dropFieldRow(f.id); } else { this.#syncFieldRow(other, f); } } } for (const [id, auto] of Object.entries(this.state.automations)) { if (auto.dbId === db.id) delete this.state.automations[id]; } for (const f of Object.values(db.fields)) this.#dropFieldRow(f.id); for (const t of Object.values(db.trashedFields ?? {})) for (const snap of [t.field, ...t.dependents]) this.#dropFieldRow(snap.field.id); for (const v of db.tableViews ?? []) this.#dropSysRow('views', v.id); const spacesT = this.#sysTable('spaces'); if (spacesT) { const reg = this.#reg; for (const f of Object.values(spacesT.fields)) { if (f.type === 'rollup' && f.config.via === db.id) { reg.#removeFieldRaw(spacesT, f.id); reg.#dropFieldRow(f.id); } } if (reg !== this) reg.save(); } delete this.state.tables[db.id]; this.#dropSysRow('tables', db.id); this.#audit('table-deleted', { name: db.name }, touched); this.save(); } restoreTable(ref) { this.#mayShape(ref); if (ref && typeof ref === 'object') ref = ref.id; let db = own(this.state.tables, ref); if (!db) { const [spName, dbName] = String(ref).includes('/') ? String(ref).split('/') : [null, String(ref)]; const named = Object.values(this.state.tables).filter((d) => d.name.toLowerCase() === String(dbName).toLowerCase()); db = named.find((d) => !spName || this.state.spaces[d.spaceId]?.name.toLowerCase() === spName.toLowerCase()) ?? named.find((d) => answersLegacySystem(this.state.spaces[d.spaceId], spName)); } if (!db) throw new WeaveError(`Table '${ref}' not found`, 'not-found'); if (!db.deletedAt) return db; const sp = this.state.spaces[db.spaceId]; if (sp?.deletedAt) throw new WeaveError(`Table '${db.name}' is inside the trashed space '${sp.name}' — restore the space first`, 'invalid'); if (this.#nameHolders('Table', db.name, { spaceId: db.spaceId }).live) throw new WeaveError(`A live table already holds the name '${this.qualifiedName(db)}'`, 'conflict'); db.deletedAt = null; this.#restoreSysRow('tables', db.id); for (const v of db.tableViews ?? []) this.#restoreSysRow('views', v.id); this.#audit('table-restored', { name: db.name }, this.#spacesTouching(db)); this.save(); return db; } relationMapMmd() { const lines = ['graph LR']; const nid = (db) => 'T' + db.id.replaceAll('-', '').slice(0, 8); for (const sp of this.listSpaces()) { if (sp.system) continue; const tables = this.listTables(sp.id).filter((t) => !t.system); if (!tables.length) continue; lines.push(` subgraph ${JSON.stringify(sp.name)}`); for (const t of tables) lines.push(` ${nid(t)}[${JSON.stringify(t.name)}]`); lines.push(' end'); } const seen = new Set(); for (const db of Object.values(this.state.tables)) { if (db.system || this.#tableHidden(db)) continue; for (const f of Object.values(db.fields)) { if (f.type !== 'relation' || seen.has(f.id)) continue; seen.add(f.id); seen.add(f.config.inverseFieldId); for (const tid of this.relationTargetDbIds(f)) { const target = this.state.tables[tid]; if (!target || target.system || this.#tableHidden(target)) continue; lines.push(` ${nid(db)} -- ${JSON.stringify(f.name)} --> ${nid(target)}`); } } } return lines.join('\n') + '\n'; } createView({ name, blocks = [] } = {}) { if (!name) throw new WeaveError('View name is required', 'invalid'); refuseReserved('view', name); const views = (this.state.meta.views ??= {}); const resolved = blocks.map((b) => { const db = this.getTable(b.table); const view = b.view ?? 'table'; if (!VOCABULARY.viewKinds.includes(view)) throw new WeaveError(`View kind '${view}' is not drawn; use ${VOCABULARY.viewKinds.join(', ')}`, 'invalid'); return { dbId: db.id, where: b.where ?? null, view }; }); const v = { id: uuid(), name, blocks: resolved, createdAt: nowISO(), createdBy: this.actor }; views[v.id] = v; this.save(); this.#audit('view-created', { name }); return v; } listViews({ trash = false } = {}) { if (trash) { return Object.values(this.state.meta.trashedViews ?? {}) .sort((a, b) => String(b.deletedAt).localeCompare(String(a.deletedAt))) .map((v) => structuredClone(v)); } const hidden = this.#hiddenSpaces(); return Object.values(this.state.meta.views ?? {}).map((v) => ({ ...v, ...(hidden ? { blocks: v.blocks.filter((b) => !this.#tableHidden(this.state.tables[b.dbId], hidden)) } : {}), createdBy: this.actorName(v.createdBy ?? null), shared: this.listShares({ kind: 'view', id: v.id }).length > 0 })); } getView(id) { const v = own(this.state.meta.views, id); if (!v) throw new WeaveError(`View '${id}' not found`, 'not-found'); return v; } deleteView(id, { hard = false } = {}) { const trashed = own(this.state.meta.trashedViews, id); if (trashed) { if (!hard) return { id: trashed.id, deleted: true }; delete this.state.meta.trashedViews[trashed.id]; if (!Object.keys(this.state.meta.trashedViews).length) delete this.state.meta.trashedViews; this.save(); this.#audit('view-deleted', { name: trashed.name }); return { id: trashed.id, deleted: true }; } const v = this.getView(id); const position = Object.keys(this.state.meta.views).indexOf(v.id); delete this.state.meta.views[v.id]; if (!hard) (this.state.meta.trashedViews ??= {})[v.id] = { ...v, deletedAt: nowISO(), position }; this.save(); this.#audit(hard ? 'view-deleted' : 'view-trashed', { name: v.name }); return { id: v.id, deleted: true }; } restoreView(id) { const v = own(this.state.meta.trashedViews, id); if (!v) { if (own(this.state.meta.views, id)) return this.state.meta.views[id]; throw new WeaveError(`View '${id}' not found in the trash`, 'not-found'); } const { deletedAt, position, ...live } = v; delete this.state.meta.trashedViews[v.id]; if (!Object.keys(this.state.meta.trashedViews).length) delete this.state.meta.trashedViews; const views = (this.state.meta.views ??= {}); const behind = Object.keys(views).slice(position ?? Infinity); views[v.id] = live; for (const key of behind) { const moved = views[key]; delete views[key]; views[key] = moved; } this.save(); this.#audit('view-restored', { name: v.name }); return live; } resolveView(id) { const v = this.getView(id); return { id: v.id, name: v.name, blocks: v.blocks.filter((b) => !this.#tableHidden(this.state.tables[b.dbId])).map((b) => { const db = this.state.tables[b.dbId]; if (!db) return { table: '(deleted table)', items: [] }; const { items } = this.query(db.id, { where: b.where ?? undefined }); return { table: this.qualifiedName(db), view: b.view, items: items.map((e) => this.readEntity(e.id)) }; }), }; } shareView(id) { const v = this.getView(id); const held = this.listShares({ kind: 'view', id: v.id }).find((g) => g.mode === 'read' && g.visibility === 'public'); const g = held ?? this.mintShare({ scope: { kind: 'view', id: v.id }, label: v.name }); return { url: g.url, token: g.token }; } unshareView(id) { const v = this.getView(id); for (const g of this.listShares({ kind: 'view', id: v.id })) this.revokeShare(g.id); return { id: v.id, shared: false }; } viewByShareToken(token) { const g = this.shareByToken(token); return g?.scope.kind === 'view' ? own(this.state.meta.views, g.scope.id) : null; } mintShare(opts = {}) { const g = Shares.mint(this, opts); this.save(); this.#audit('share-minted', Shares.auditDetail(g)); return g; } listShares(opts = {}) { return Shares.list(this, opts ?? {}); } revokeShare(id, { any = true } = {}) { const { grant, changed } = Shares.revoke(this, id, { any }); if (changed) { this.save(); this.#audit('share-revoked', Shares.auditDetail(grant)); } return grant; } renewShare(id) { const g = Shares.renew(this, id); this.save(); this.#audit('share-renewed', Shares.auditDetail(g)); return g; } updateShare(id, patch = {}) { return this.store.batch(() => { this.maybeRefresh(); const g = Shares.update(this, id, patch); this.save(); this.#audit('share-updated', Shares.auditDetail(g)); return g; }); } regenerateShare(id, patch = {}) { return this.store.batch(() => { this.maybeRefresh(); const g = Shares.regenerate(this, id, patch); this.save(); this.#audit('share-regenerated', Shares.auditDetail(g)); return g; }); } shareByToken(token) { return Shares.byToken(this, token); } tableView(ref, patch = null) { const { db, name } = this.#viewTarget(ref); this.#ensureTableViews(db); const keys = patch ? Object.keys(patch).filter((k) => patch[k] !== undefined) : []; if (name == null) { if (keys.length) throw new WeaveError(`Name the view to write: '${this.qualifiedName(db)}/'`, 'invalid'); return { table: this.qualifiedName(db), views: db.tableViews.map((v) => this.#viewOut(db, v)) }; } if (name.toLowerCase() === 'blank') { if (keys.length) throw new WeaveError(`Blank is read-only: it is the raw table — every field in schema order, no filter, no sort. Start a view from it instead: {from: 'blank'} under a new name`, 'invalid'); return { name: 'Blank', blank: true, fields: this.#blankFields(db).map((id) => db.fields[id].name) }; } const i = this.#viewIndex(db, name); if (!keys.length) { if (i < 0) throw this.#noView(db, name); return this.#viewOut(db, db.tableViews[i]); } return this.#writeView(db, name, patch); } #viewTarget(ref) { if (ref && typeof ref === 'object') return { db: this.getTable(ref), name: null }; const s = String(ref ?? ''); const parts = s.split('/'); if (parts.length <= 2) { const db = this.findTable(s); if (db) return { db, name: null }; } if (parts.length >= 2) { const db = this.findTable(parts.slice(0, -1).join('/')); if (db) return { db, name: parts.at(-1) }; } throw new WeaveError(`No table or view '${s}' — name a table ('Issue') or one of its views ('Issue/Open bugs')`, 'not-found'); } #viewIndex(db, ref) { const r = String(ref).toLowerCase(); return db.tableViews.findIndex((v) => v.id === ref || v.name.toLowerCase() === r); } #noView(db, name) { const have = db.tableViews.map((v) => v.name).join(', '); return new WeaveError(`View '${name}' not found on ${this.qualifiedName(db)} — it has: ${have}`, 'not-found'); } #blankFields(db) { return db.fieldOrder.filter((id) => db.fields[id] && db.fields[id].type !== 'view'); } #viewOut(db, v) { const out = { id: v.id, name: v.name }; out.fields = v.fields.map((id) => viewEntryName(db, id)).filter(Boolean); if (v.filters) out.filters = structuredClone(v.filters); if (v.sort) out.sort = structuredClone(v.sort); if (v.search) out.search = v.search; const widths = {}; for (const [id, px] of Object.entries(v.widths ?? {})) { const n = viewEntryName(db, id); if (n) widths[n] = px; } if (Object.keys(widths).length) out.widths = widths; if (v.frozen) out.frozen = v.frozen; if (v.density) out.density = v.density; if (v.deleted) out.deleted = true; if (typeof v.rollups === 'boolean') out.rollups = v.rollups; if (LAYOUTS.includes(v.layout)) out.layout = v.layout; if (v.groups) out.groups = v.groups; if (v.picture) out.picture = v.picture; for (const k of ['fit', 'frame', 'flow']) if (v[k]) out[k] = v[k]; if (ViewPane.SUBTOTALS.includes(v.subtotals)) out.subtotals = v.subtotals; const group = (v.group ?? []).filter((l) => db.fields[l.field]).map((l) => ({ field: db.fields[l.field].name, ...ListCore.compactLevel(db.fields[l.field].type, l) })); if (group.length) out.group = group; if (db.fields[v.completedBy]) out.completedBy = db.fields[v.completedBy].name; if (Array.isArray(v.collapsed)) out.collapsed = [...v.collapsed]; if (v.hideEmpty) out.hideEmpty = true; const order = (v.order ?? []).map((id) => own(this.state.entities, id)).filter((e) => e && !e.deletedAt && e.dbId === db.id).map((e) => e.publicId); if (order.length) out.order = order; if (v.phoneRow) out.phoneRow = PhoneRow.compact(this.#phoneRowNamed(db, v.phoneRow)); return out; } #phoneRowNamed(db, p) { return { lead: db.fields[p.lead]?.name ?? null, chips: p.chips.map((id) => db.fields[id]?.name).filter(Boolean), labels: !!p.labels, wrap: !!p.wrap }; } #writePhoneRow(db, next, patch) { if (patch === null) { delete next.phoneRow; return; } const keys = PhoneRow.KEYS.join(', '); if (typeof patch !== 'object' || Array.isArray(patch)) throw new WeaveError(`phoneRow is {${keys}}: the phone list row's lead field, its chip fields in order, and two switches. null clears it`, 'invalid'); const stray = Object.keys(patch).filter((k) => !PhoneRow.KEYS.includes(k)); if (stray.length) throw new WeaveError(`phoneRow takes ${keys} — not ${stray.join(', ')}`, 'invalid'); const shown = next.fields.map((id) => viewEntryName(db, id)).filter(Boolean); const fields = db.fieldOrder.map((id) => db.fields[id]).filter(Boolean).map((f) => ({ name: f.name, type: f.type, role: f.id === db.nameFieldId ? 'name' : null })); const cur = PhoneRow.resolve(fields, shown, next.phoneRow ? this.#phoneRowNamed(db, next.phoneRow) : null); const pick = (ref) => { const f = this.#listField(db, ref); if (f.id === db.nameFieldId) throw new WeaveError(`'${f.name}' is the record's name: line one always shows it, so it is never a lead or a chip`, 'invalid'); return f.id; }; for (const k of ['labels', 'wrap']) { if (patch[k] !== undefined && typeof patch[k] !== 'boolean') throw new WeaveError(`phoneRow.${k} is true or false — got ${JSON.stringify(patch[k])}`, 'invalid'); } if (patch.chips !== undefined && !Array.isArray(patch.chips)) throw new WeaveError('phoneRow.chips is the list of fields on the chip line, in order', 'invalid'); const lead = patch.lead === undefined ? (cur.lead == null ? null : pick(cur.lead)) : patch.lead == null || patch.lead === '' ? null : pick(patch.lead); const chips = (patch.chips ?? cur.chips).map(pick); if (new Set(chips).size !== chips.length) throw new WeaveError('phoneRow.chips names each field once', 'invalid'); if (patch.lead !== undefined && patch.chips === undefined) { const k = chips.indexOf(lead); if (k >= 0) chips.splice(k, 1); } if (lead != null && chips.includes(lead)) throw new WeaveError(`'${db.fields[lead].name}' is either the lead or a chip, not both`, 'invalid'); next.phoneRow = { lead, chips }; if (patch.labels ?? cur.labels) next.phoneRow.labels = true; if (patch.wrap ?? cur.wrap) next.phoneRow.wrap = true; } #listField(db, ref) { const f = this.findField(db, ref); if (!f) throw new WeaveError(`'${ref}' is not a field of ${this.qualifiedName(db)}`, 'not-found'); return f; } #sameGrouping(db, view) { if (!view.group?.length) return []; const sig = ListCore.groupSig(view.group); return db.tableViews.filter((v) => v.id !== view.id && v.group?.length && ListCore.groupSig(v.group) === sig); } #checkGroup(db, input) { let levels; try { levels = ListCore.parseGroup(input); } catch (err) { throw new WeaveError(err.message, 'invalid'); } const seen = new Set(); return levels.map((raw) => { const f = this.#listField(db, raw.field); if (seen.has(f.id)) throw new WeaveError(`group names each field once: '${f.name}' is there twice`, 'invalid'); seen.add(f.id); try { return { field: f.id, ...ListCore.normalizeLevel({ name: f.name, type: f.type }, raw) }; } catch (err) { throw new WeaveError(err.message, 'invalid'); } }); } #orderRow(db, ref) { const pid = typeof ref === 'number' ? ref : /^#?\d+$/.test(String(ref).trim()) ? Number(String(ref).trim().replace('#', '')) : null; const e = pid != null ? this.#rowsByPublicId(db.id, pid).find((x) => x.publicId === pid) : own(this.state.entities, String(ref)); if (!e || e.dbId !== db.id) throw new WeaveError(`'${ref}' is not a record of ${this.qualifiedName(db)} — order lists its records as #ids`, 'invalid'); return e.id; } #writeListKeys(db, next, patch) { if (patch.layout !== undefined) { if (patch.layout == null || patch.layout === '') delete next.layout; else if (LAYOUTS.includes(patch.layout)) next.layout = patch.layout; else throw new WeaveError(`layout is table or board — got ${JSON.stringify(patch.layout)}`, 'invalid'); } if (patch.groups !== undefined) { if (patch.groups == null || patch.groups === '') delete next.groups; else if (BoardCore.GROUPS.includes(patch.groups)) next.groups = patch.groups; else throw new WeaveError(`groups is stacked or side — got ${JSON.stringify(patch.groups)}`, 'invalid'); } if (patch.picture !== undefined) { if (patch.picture == null || patch.picture === '') delete next.picture; else if (BoardCore.PICTURES.includes(patch.picture)) next.picture = patch.picture; else throw new WeaveError(`picture is off, small, medium or large — got ${JSON.stringify(patch.picture)}`, 'invalid'); } for (const [key, allowed] of [['fit', BoardCore.FITS], ['frame', BoardCore.FRAMES], ['flow', BoardCore.FLOWS]]) { if (patch[key] === undefined) continue; if (patch[key] == null || patch[key] === '') delete next[key]; else if (allowed.includes(patch[key])) next[key] = patch[key]; else throw new WeaveError(`${key} is ${allowed.slice(0, -1).join(', ')} or ${allowed.at(-1)} — got ${JSON.stringify(patch[key])}`, 'invalid'); } if (patch.subtotals !== undefined) { if (patch.subtotals == null || patch.subtotals === 'all') delete next.subtotals; else if (ViewPane.SUBTOTALS.includes(patch.subtotals)) next.subtotals = patch.subtotals; else throw new WeaveError(`subtotals is all, top or none: which group levels carry a Σ row — got ${JSON.stringify(patch.subtotals)}`, 'invalid'); } if (patch.group !== undefined) { const before = ListCore.groupSig(next.group); const g = patch.group == null ? [] : this.#checkGroup(db, patch.group); if (g.length) next.group = g; else delete next.group; if (patch.collapsed === undefined && ListCore.groupSig(g) !== before) { const twin = this.#sameGrouping(db, next)[0]; if (Array.isArray(twin?.collapsed)) next.collapsed = [...twin.collapsed]; else delete next.collapsed; } } if (patch.completedBy !== undefined) { if (patch.completedBy == null || patch.completedBy === '') delete next.completedBy; else { const f = this.#listField(db, patch.completedBy); if (!isBoolType(f.type)) throw new WeaveError(`completedBy is a checkbox or toggle field: '${f.name}' is a ${f.type}`, 'invalid'); next.completedBy = f.id; } } if (patch.collapsed !== undefined) { if (patch.collapsed == null) delete next.collapsed; else { if (!Array.isArray(patch.collapsed) || patch.collapsed.some((x) => typeof x !== 'string')) throw new WeaveError('collapsed is a list of group paths, e.g. ["Japan › P1", "Completed"]', 'invalid'); next.collapsed = [...new Set(patch.collapsed.map((x) => x.trim()).filter(Boolean))].slice(0, 500); } } if (patch.order !== undefined) { if (patch.order == null) delete next.order; else { if (!Array.isArray(patch.order)) throw new WeaveError('order is the list of records, as #ids, in the order the view shows them', 'invalid'); const ids = [...new Set(patch.order.map((ref) => this.#orderRow(db, ref)))]; if (ids.length) next.order = ids; else delete next.order; } } } #checkViewName(db, name, self = null) { const n = typeof name === 'string' ? name.trim() : ''; if (!n) throw new WeaveError('A view needs a name', 'invalid'); refuseReserved('view', n); if (n.includes('/')) throw new WeaveError(`A view name cannot hold '/' — it separates the table from the view: '${n}'`, 'invalid'); if (n.toLowerCase() === 'blank') throw new WeaveError("'Blank' is reserved: 'Table/blank' reads the raw table", 'invalid'); const clash = db.tableViews.find((v) => v !== self && v.name.toLowerCase() === n.toLowerCase()); if (clash) throw new WeaveError(`${this.qualifiedName(db)} already has a view named '${clash.name}'`, 'conflict'); return n; } #writeView(db, name, patch) { const KNOWN = ['name', 'fields', 'show', 'hide', 'move', 'filters', 'sort', 'search', 'default', 'position', 'from', 'delete', 'widths', 'frozen', 'density', 'deleted', 'rollups', 'subtotals', 'layout', 'groups', 'picture', 'fit', 'frame', 'flow', 'group', 'completedBy', 'collapsed', 'hideEmpty', 'order', 'phoneRow']; const unknown = Object.keys(patch).filter((k) => patch[k] !== undefined && !KNOWN.includes(k)); if (unknown.length) throw new WeaveError(`Unknown view key${unknown.length > 1 ? 's' : ''} ${unknown.join(', ')} — a view takes ${KNOWN.join(', ')}`, 'invalid'); const views = db.tableViews; let i = this.#viewIndex(db, name); if (patch.delete) { if (i < 0) throw this.#noView(db, name); if (views.length === 1) { throw new WeaveError(`'${views[i].name}' is the only view of ${this.qualifiedName(db)}, and a table keeps at least one view — make another first (a new name with no from starts from every field, no filter, no sort)`, 'invalid'); } const [gone] = views.splice(i, 1); this.#dropSysRow('views', gone.id); this.save(); this.#syncTableRow(db); this.#audit('table-view-deleted', { table: this.qualifiedName(db), name: gone.name }); return { name: gone.name, deleted: true }; } let next; if (i < 0) { const from = patch.from == null || String(patch.from).toLowerCase() === 'blank' ? null : this.#viewIndex(db, patch.from); if (from != null && from < 0) throw this.#noView(db, patch.from); next = from == null ? { fields: this.#blankFields(db) } : structuredClone(views[from]); next.id = uuid(); next.name = this.#checkViewName(db, name); } else { if (patch.from != null) throw new WeaveError(`${this.qualifiedName(db)} already has a view named '${views[i].name}' — from copies into a new name`, 'conflict'); next = structuredClone(views[i]); } const list = (x) => (x == null ? [] : Array.isArray(x) ? x : [x]); const fid = (ref) => { const sys = typeof ref === 'string' && !this.findField(db, ref) ? GRID_SYSTEM_COLUMNS.find((n) => n.toLowerCase() === ref.trim().toLowerCase()) : null; return sys ? sysColumnId(sys) : this.getField(db.id, ref).id; }; if (patch.name != null && i >= 0) next.name = this.#checkViewName(db, patch.name, views[i]); if (patch.fields != null) { if (!Array.isArray(patch.fields)) throw new WeaveError('fields is the list of visible field names, in column order', 'invalid'); const ids = patch.fields.map(fid); if (new Set(ids).size !== ids.length) throw new WeaveError('fields names each field once', 'invalid'); next.fields = ids; for (const id of ids) if (next.parked) delete next.parked[id]; } for (const n of list(patch.hide)) { const id = fid(n); const k = next.fields.indexOf(id); if (k < 0) continue; const fz = next.frozen ?? 0; (next.parked ??= {})[id] = { after: k > 0 ? next.fields[k - 1] : '', frozen: k < fz }; next.fields.splice(k, 1); if (k < fz) next.frozen = fz - 1; } for (const n of list(patch.show)) { const id = fid(n); if (next.fields.includes(id)) continue; const p = next.parked?.[id]; if (next.parked) delete next.parked[id]; const fz = next.frozen ?? 0; let at = p && (p.after === '' || next.fields.includes(p.after)) ? (p.after === '' ? 0 : next.fields.indexOf(p.after) + 1) : null; if (at == null && sysColumnName(id)) at = next.fields.length; if (at == null) { showBySchema(db, next.fields, id); at = next.fields.indexOf(id); next.fields.splice(at, 1); } if (p?.frozen) { at = Math.min(at, fz); next.frozen = fz + 1; } else at = Math.max(at, fz); next.fields.splice(at, 0, id); } for (const m of list(patch.move)) { if (!m || typeof m !== 'object' || m.field == null || (m.before == null) === (m.after == null)) { throw new WeaveError('move is {field, before: } or {field, after: }', 'invalid'); } const id = fid(m.field); const anchor = fid(m.before ?? m.after); if (!next.fields.includes(anchor)) throw new WeaveError(`'${m.before ?? m.after}' is hidden in this view — move beside a field that shows, or show it first`, 'invalid'); if (id === anchor) continue; next.fields = next.fields.filter((x) => x !== id); const k = next.fields.indexOf(anchor); next.fields.splice(m.before != null ? k : k + 1, 0, id); if (next.parked) delete next.parked[id]; } if (patch.widths != null) { if (typeof patch.widths !== 'object' || Array.isArray(patch.widths)) throw new WeaveError('widths is an object of { fieldName: pixels } — null clears one', 'invalid'); const widths = { ...(next.widths ?? {}) }; for (const [n, px] of Object.entries(patch.widths)) { const id = fid(n); if (px === null) { delete widths[id]; continue; } if (typeof px !== 'number' || !Number.isFinite(px) || px < VIEW_MIN_WIDTH || px > VIEW_MAX_WIDTH) { throw new WeaveError(`A column width is a number of pixels from ${VIEW_MIN_WIDTH} to ${VIEW_MAX_WIDTH} — got ${JSON.stringify(px)} for '${n}'`, 'invalid'); } widths[id] = Math.round(px); } if (Object.keys(widths).length) next.widths = widths; else delete next.widths; } if (patch.frozen != null) { if (!Number.isInteger(patch.frozen) || patch.frozen < 0) throw new WeaveError('frozen is a whole number: how many of the leading fields stay frozen beside # (0 — only #)', 'invalid'); if (patch.frozen > next.fields.length) throw new WeaveError(`frozen is at most the ${next.fields.length} fields this view shows`, 'invalid'); next.frozen = patch.frozen; } if (patch.density != null) { const d = typeof patch.density === 'string' ? patch.density.trim().toLowerCase() : ''; if (!VIEW_DENSITIES.includes(d)) throw new WeaveError(`density is compact, comfortable or spacious — got ${JSON.stringify(patch.density)}`, 'invalid'); if (d === 'comfortable') delete next.density; else next.density = d; } if (patch.deleted != null) { if (typeof patch.deleted !== 'boolean') throw new WeaveError(`deleted is true or false: whether the view shows its deleted records. Got ${JSON.stringify(patch.deleted)}`, 'invalid'); if (patch.deleted) next.deleted = true; else delete next.deleted; } if (patch.rollups !== undefined) { if (patch.rollups !== null && typeof patch.rollups !== 'boolean') throw new WeaveError(`rollups is true, false or null: whether the view draws the Σ row (null follows the table). Got ${JSON.stringify(patch.rollups)}`, 'invalid'); if (patch.rollups === null) delete next.rollups; else next.rollups = patch.rollups; } if (patch.hideEmpty !== undefined) { if (patch.hideEmpty !== null && typeof patch.hideEmpty !== 'boolean') throw new WeaveError(`hideEmpty is true, false or null: whether grouped layouts leave out groups with no records. Got ${JSON.stringify(patch.hideEmpty)}`, 'invalid'); if (patch.hideEmpty) next.hideEmpty = true; else delete next.hideEmpty; } this.#writeListKeys(db, next, patch); if (patch.phoneRow !== undefined) this.#writePhoneRow(db, next, patch.phoneRow); if ((next.frozen ?? 0) > next.fields.length) next.frozen = next.fields.length; if (!next.frozen) delete next.frozen; if (next.parked && !Object.keys(next.parked).length) delete next.parked; if (patch.filters != null) { const f = this.#checkFilters(db, patch.filters); if (f) next.filters = f; else delete next.filters; } if (patch.sort != null) { const s = this.#checkSort(db, patch.sort); if (s) next.sort = s; else delete next.sort; } const notes = this.#sortOutOfGroup(db, next); if (patch.search !== undefined) { if (patch.search !== null && typeof patch.search !== 'string') throw new WeaveError(`search is the text the table search box holds for this view ('' or null clears it). Got ${JSON.stringify(patch.search)}`, 'invalid'); const text = (patch.search ?? '').trim(); if (text) next.search = text; else delete next.search; } if (patch.default === false && i === 0) throw new WeaveError(`'${next.name}' is the default because it is first — move another view to position 0 instead`, 'invalid'); if (patch.position != null && !(Number.isInteger(patch.position) && patch.position >= 0)) { throw new WeaveError('position is a whole number: 0 is the first place in the strip (the default)', 'invalid'); } const created = i < 0; if (patch.collapsed !== undefined) { for (const v of this.#sameGrouping(db, next)) { if (Array.isArray(next.collapsed)) v.collapsed = [...next.collapsed]; else delete v.collapsed; } } if (created) { views.push(next); i = views.length - 1; } else views[i] = next; const to = patch.default === true ? 0 : patch.position != null ? Math.min(patch.position, views.length - 1) : i; if (to !== i) { views.splice(i, 1); views.splice(to, 0, next); i = to; } this.save(); this.#syncTableRow(db); this.#audit(created ? 'table-view-created' : 'table-view-updated', { table: this.qualifiedName(db), name: next.name }); return { ...this.#viewOut(db, next), ...(created ? { created: true } : {}), ...(notes.length ? { notes } : {}) }; } #sortOutOfGroup(db, next) { if (!next.sort?.length || !next.group?.length) return []; const n = ViewPane.normalize({ sort: next.sort, group: next.group.map((l) => ({ field: db.fields[l.field]?.name })) }); if (n.sort.length) next.sort = n.sort; else delete next.sort; return n.notes; } #writeDefaultView(db, { filters, sort, hiddenFields }) { this.#ensureTableViews(db); const cur = db.tableViews[0]; const patch = {}; if (filters != null) patch.filters = filters; if (sort != null) patch.sort = sort; if (hiddenFields != null) { if (!Array.isArray(hiddenFields)) throw new WeaveError('hiddenFields is a list of field names', 'invalid'); const hide = new Set(); for (const n of hiddenFields) { if (GRID_SYSTEM_COLUMNS.includes(n) || n === RETIRED_SYSTEM_FIELD) continue; const f = this.findField(db, n); if (!f) throw new WeaveError(`'${n}' is not a field of ${db.name}`, 'invalid'); hide.add(f.id); } const shown = new Set(cur?.fields ?? []); patch.hide = [...shown].filter((id) => hide.has(id)); patch.show = db.fieldOrder.filter((id) => !hide.has(id) && !shown.has(id)); } return this.#writeView(db, cur?.name ?? 'Standard', patch); } #checkFilters(db, filters) { if (typeof filters !== 'object' || Array.isArray(filters)) { throw new WeaveError('filters is an object of { fieldName: [stateOrOptionNames] }', 'invalid'); } const out = {}; for (const [fname, states] of Object.entries(filters)) { const f = this.findField(db, fname); if (!f || !['workflow', 'toggle', 'select', 'multiselect'].includes(f.type)) throw new WeaveError(`'${fname}' is not a workflow, toggle, single-select or multi-select field of ${db.name}`, 'invalid'); if (!Array.isArray(states)) throw new WeaveError(`The filter on '${fname}' is a list of ${f.type.endsWith('select') ? 'option' : 'state'} names`, 'invalid'); const names = f.type === 'toggle' ? [f.config.on, f.config.off] : f.type.endsWith('select') ? (f.config.options ?? []).map((o) => o.name) : f.config.states.map((st) => st.name); for (const s of states) { if (!names.includes(s)) throw new WeaveError(`'${s}' is not ${f.type.endsWith('select') ? 'an option' : 'a state'} of ${db.name}.${f.name}`, 'invalid'); } if (states.length) out[f.name] = [...states]; } return Object.keys(out).length ? out : null; } viewQuery(dbRef, ref) { const { view, ...out } = this.#viewQueryIn(this.getTable(dbRef), ref); return out; } #viewQueryIn(db, ref) { this.#ensureTableViews(db); const v = db.tableViews[this.#viewIndex(db, ref)]; if (!v) throw this.#noView(db, ref); const where = []; for (const [name, states] of Object.entries(v.filters ?? {})) { const f = this.findField(db, name); if (!f || !['workflow', 'toggle', 'select', 'multiselect'].includes(f.type) || !states?.length) continue; where.push([f.name, 'in', f.type === 'toggle' ? states.map((s) => s === f.config.on) : [...states]]); } return { where, sort: structuredClone(v.sort ?? []), search: v.search ?? '', view: v }; } #checkSort(db, sort) { if (!Array.isArray(sort)) throw new WeaveError('sort is a list of { field, dir }', 'invalid'); const out = sort.map((s) => { const f = this.findField(db, s.field); if (!f && !Object.hasOwn(SYSTEM_SORT_KEYS, s.field)) { throw new WeaveError(`Field '${s.field}' not found in table '${db.name}'. ${SORT_FORM}, e.g. 'Date desc'`, 'not-found'); } const name = f ? f.name : s.field; const dir = s.dir ?? 'asc'; if (!['asc', 'desc'].includes(dir)) throw new WeaveError(`Sort direction is asc or desc, got '${s.dir}'`, 'invalid'); return { field: name, dir }; }); const twice = out.find((s, i) => out.findIndex((x) => x.field === s.field) !== i); if (twice) throw new WeaveError(`sort names each field once: '${twice.field}' is there twice`, 'invalid'); if (out.length > ViewPane.SORT_CAP) throw new WeaveError(`A view sorts at most ${ViewPane.SORT_CAP} levels deep (primary, secondary, tertiary), got ${out.length}`, 'invalid'); return out.length ? out : null; } #defaultViewConfig(db) { const v = db.tableViews?.[0]; if (!v) return {}; const shown = new Set(v.fields); const hidden = db.fieldOrder.filter((id) => db.fields[id] && !shown.has(id)).map((id) => db.fields[id].name); return { ...(hidden.length ? { hiddenFields: hidden } : {}), ...(v.filters ? { filters: v.filters } : {}), ...(v.sort ? { sort: v.sort } : {}), }; } applySchema(doc, { dryRun = false, allowDestructive = false, partial = false } = {}) { if (!Array.isArray(doc)) throw new WeaveError('A schema document is the array describeSchema() returns', 'invalid'); const plan = this.#buildCopy().#applySchemaPass(doc, { allowDestructive, partial }); if (dryRun) return plan; return this.#atomically(() => this.#applySchemaPass(doc, { allowDestructive, partial })); } #atomically(fn) { const state = structuredClone(this.state); const dirty = new Set(this.#dirty); const dirtyAll = this.#dirtyAll; try { return this.store.batch(fn); } catch (err) { this.#adoptRolledBack(state); this.#dirty = dirty; this.#dirtyAll = dirtyAll; throw err; } } #applySchemaPass(doc, { allowDestructive, partial }) { const plan = []; const act = (action, subject, fn) => { plan.push({ action, subject }); fn(); }; const configFromDescriptor = (f, existing = null, as = f.type ?? existing?.type) => { const config = existing ? { ...existing.config } : {}; FIELD_TYPE_CONFIG[as]?.fromDescriptor?.(f, config, existing?.config); if (f.default !== undefined) config.default = f.default; if (f.width != null) config.width = f.width; if (f.type !== 'view' && f.description != null) config.description = f.description; if (f.term != null) config.term = f.term; return config; }; const DESCRIPTOR_KEYS = ['options', 'states', 'expression', 'via', 'viaTable', 'where', 'targetField', 'aggregate', 'default', 'width', 'format', 'unit', 'currency', 'decimals', 'separator', 'accounting', 'display', 'scale', 'style', 'time', 'kind', 'multiple', 'types', 'depth', 'grain', 'clock', 'zone', 'zoneName', 'pad', 'elapsed', 'term', 'link', 'state', 'description', 'fields', 'max', 'icon', 'preview', 'size', 'fit', 'literal', 'on', 'off', 'keystore', 'parts', 'shape', 'color']; const colorsOf = (full) => JSON.stringify((full ?? []).map((o) => ({ name: o.name, color: o.color ?? '' }))); const fieldChanged = (fDoc, have) => { if (!have) return true; if (fDoc.optionsFull && colorsOf(fDoc.optionsFull) !== colorsOf(have.optionsFull)) return true; if (fDoc.optionsFull) { const had = new Map((have.optionsFull ?? []).map((o) => [o.name, o.icon ?? ''])); if (fDoc.optionsFull.some((o) => 'icon' in o && (o.icon ?? '') !== (had.get(o.name) ?? ''))) return true; } return DESCRIPTOR_KEYS.some((k) => k in fDoc && JSON.stringify(fDoc[k]) !== JSON.stringify(have[k])); }; const current = new Map(); for (const sp of this.describeSchema()) { for (const t of sp.tables) current.set(`${sp.space}/${t.name}`, t); } const wanted = doc.filter((sp) => !sp.system); const docTable = (q) => { const cut = String(q ?? '').lastIndexOf('/'); return doc.find((sp) => sp.space === String(q).slice(0, cut))?.tables?.find((t) => t.name === String(q).slice(cut + 1)); }; const cardinalityOf = (fDoc) => { const inv = fDoc.inverseField != null ? docTable(fDoc.targetDb)?.fields?.find((x) => x.name === fDoc.inverseField) : null; if (!inv) return fDoc.many ? 'one-to-many' : 'many-to-one'; return ({ 'true,true': 'many-to-many', 'true,false': 'one-to-many', 'false,true': 'many-to-one', 'false,false': 'one-to-one' })[`${!!fDoc.many},${!!inv.many}`]; }; const relationArgs = (fDoc) => ({ name: fDoc.name, ...(Array.isArray(fDoc.targetDbs) ? { targetDbs: fDoc.targetDbs } : { targetDb: fDoc.targetDb }), cardinality: cardinalityOf(fDoc), inverseName: fDoc.inverseField ?? undefined, }); const DEFERRED = new Set(['relation', 'lookup', 'rollup', 'formula']); const created = []; for (const spDoc of wanted) { let sp = this.findSpace(spDoc.space); if (!sp) { act('create-space', spDoc.space, () => { sp = this.createSpace({ name: spDoc.space, description: spDoc.description ?? '', template: spDoc.template === true }); if (spDoc.icon) this.updateSpace(sp.id, { icon: spDoc.icon }); }); } else { const patch = {}; if (spDoc.description != null && spDoc.description !== (sp.description ?? '')) patch.description = spDoc.description; if (spDoc.icon != null && spDoc.icon !== (sp.icon ?? '')) patch.icon = spDoc.icon; if (spDoc.template != null && !!spDoc.template !== !!sp.template) patch.template = !!spDoc.template; if (Object.keys(patch).length) act('update-space', spDoc.space, () => this.updateSpace(sp.id, patch)); } for (const tDoc of spDoc.tables ?? []) { const qualified = `${spDoc.space}/${tDoc.name}`; let db = this.findTable(qualified); if (db?.system) continue; if (!db) { act('create-table', qualified, () => this.#withoutFieldLog(() => { db = this.createTable({ space: spDoc.space, name: tDoc.name, description: tDoc.description ?? '', icon: tDoc.icon ?? '' }); const described = (tDoc.fields ?? []).find((f) => f.role === 'description') ?? (tDoc.fields ?? []).find((f) => f.type === 'document' && f.name === 'Description'); if (described) { if (described.name !== 'Description') this.updateField(db.id, db.descriptionFieldId, { name: described.name }); const cfg = configFromDescriptor(described, null, 'document'); if (cfg && Object.keys(cfg).length) this.updateField(db.id, db.descriptionFieldId, { config: cfg }); } else { this.deleteField(db.id, db.descriptionFieldId); } const named = (tDoc.fields ?? []).find((f) => f.role === 'name') ?? (tDoc.fields ?? []).find((f) => f.name === 'Name'); if (named) { if (named.name !== 'Name') this.updateField(db.id, db.nameFieldId, { name: named.name }); } for (const f of tDoc.fields ?? []) { if (f === named || f === described) continue; if (DEFERRED.has(f.type)) continue; if (f.type === 'view') { const minted = this.viewField(db, f.role ?? f.shape); if (minted && f.name !== minted.name) this.updateField(db.id, minted.id, { name: f.name }); continue; } this.addField(db.id, { name: f.name, type: f.type, config: configFromDescriptor(f) }); } })); created.push({ db, tDoc, named: (tDoc.fields ?? []).find((f) => f.role === 'name') ?? (tDoc.fields ?? []).find((f) => f.name === 'Name') }); continue; } const tPatch = {}; if (tDoc.description != null && tDoc.description !== (db.description ?? '')) tPatch.description = tDoc.description; if (tDoc.icon != null && tDoc.icon !== (db.icon ?? '')) tPatch.icon = tDoc.icon; if (tDoc.noun != null && tDoc.noun !== (this.termOf(db).set ? this.termOf(db).singular : '')) tPatch.noun = tDoc.noun; const dv = this.#defaultViewConfig(db); if (!tDoc.views) { if ('hiddenFields' in tDoc && JSON.stringify(tDoc.hiddenFields ?? []) !== JSON.stringify(dv.hiddenFields ?? [])) { tPatch.hiddenFields = tDoc.hiddenFields ?? []; } if ('filters' in tDoc && JSON.stringify(tDoc.filters ?? {}) !== JSON.stringify(dv.filters ?? {})) { tPatch.filters = tDoc.filters ?? {}; } if ('sort' in tDoc && JSON.stringify(tDoc.sort ?? []) !== JSON.stringify(dv.sort ?? [])) { tPatch.sort = tDoc.sort ?? []; } } const sys = withoutRetired(tDoc.systemFields); if ('systemFields' in tDoc && JSON.stringify(sys) !== JSON.stringify(db.systemFields ?? [])) { tPatch.systemFields = sys; } if ('hideRollups' in tDoc && (tDoc.hideRollups !== false) !== (db.hideRollups !== false)) tPatch.hideRollups = !!tDoc.hideRollups; if ('referencePanels' in tDoc && JSON.stringify(tDoc.referencePanels ?? []) !== JSON.stringify(db.referencePanels ?? [])) tPatch.referencePanels = tDoc.referencePanels ?? []; if (Object.keys(tPatch).length) act('update-table', qualified, () => this.updateTable(db.id, tPatch)); let nameTypeChange = null; const createdHere = new Set(); for (const fDoc of tDoc.fields ?? []) { let existing = Object.values(db.fields).find((x) => x.name === fDoc.name); if (!existing && fDoc.role === 'name') { existing = db.fields[db.nameFieldId]; act('update-field', `${qualified}.${fDoc.name}`, () => this.updateField(db.id, db.nameFieldId, { name: fDoc.name })); } if (!existing && fDoc.type === 'view' && VIEW_SHAPES.includes(fDoc.role ?? fDoc.shape)) { existing = this.viewField(db, fDoc.role ?? fDoc.shape); if (existing) act('update-field', `${qualified}.${fDoc.name}`, () => this.updateField(db.id, existing.id, { name: fDoc.name })); } if (existing && existing.id === db.nameFieldId && fDoc.type && fDoc.type !== existing.type && ['text', 'formula'].includes(fDoc.type)) { nameTypeChange = () => act('update-field', `${qualified}.${fDoc.name}`, () => this.updateField(db.id, db.nameFieldId, { type: fDoc.type, config: configFromDescriptor(fDoc, existing) })); continue; } if (!existing) { createdHere.add(fDoc.name); if (fDoc.type === 'relation') { act('create-relation', `${qualified}.${fDoc.name}`, () => this.addRelation(db.id, relationArgs(fDoc))); } else { act('create-field', `${qualified}.${fDoc.name}`, () => this.addField(db.id, { name: fDoc.name, type: fDoc.type, config: configFromDescriptor(fDoc) })); } continue; } if (existing.type !== fDoc.type) { throw new WeaveError(`'${qualified}.${fDoc.name}' cannot change type ('${existing.type}' → '${fDoc.type}') — delete the field and create it anew`, 'invalid'); } const nextCfg = configFromDescriptor(fDoc, existing); const have = current.get(qualified)?.fields.find((x) => x.name === fDoc.name); if (fieldChanged(fDoc, have)) { act('update-field', `${qualified}.${fDoc.name}`, () => this.updateField(db.id, existing.id, { config: nextCfg })); } } nameTypeChange?.(); for (const existing of Object.values(db.fields)) { if (existing.system || existing.id === db.nameFieldId) continue; if (existing.type === 'relation' && existing.inverseOf) continue; const still = (tDoc.fields ?? []).some((f) => f.name === existing.name); if (!still) { if (!allowDestructive) throw new WeaveError(`Applying this document would delete '${qualified}.${existing.name}' — a destructive change needs allowDestructive`, 'invalid'); act('delete-field', `${qualified}.${existing.name}`, () => this.deleteField(db.id, existing.id)); } } if ((tDoc.fields ?? []).length) { const wanted = []; for (const fDoc of tDoc.fields) { const f = Object.values(db.fields).find((x) => x.name === fDoc.name); if (f && !wanted.includes(f.id)) wanted.push(f.id); } for (const id of db.fieldOrder) if (!wanted.includes(id)) wanted.push(id); const views = wanted.filter((id) => db.fields[id]?.type === 'view'); const ordered = [...wanted.filter((id) => !views.includes(id)), ...views]; wanted.splice(0, wanted.length, ...ordered); if (wanted.length === db.fieldOrder.length && JSON.stringify(wanted) !== JSON.stringify(db.fieldOrder)) { act('reorder-fields', qualified, () => this.updateTable(db.id, { fieldOrder: wanted })); } } if (Array.isArray(tDoc.views)) { this.#applyViews(db, tDoc.views, act, allowDestructive, createdHere); const legacy = this.#legacyEdits(db, tDoc, createdHere); if (Object.keys(legacy).length) act('update-table', qualified, () => this.updateTable(db.id, legacy)); } const body = this.#bodyOrderFrom(db, tDoc); if (body) act('update-table', qualified, () => this.updateTable(db.id, { bodyOrder: body })); } if (sp) { for (const db of this.listTables(sp?.id)) { if (db.system) continue; const still = (spDoc.tables ?? []).some((t) => t.name === db.name); if (!still) { if (!allowDestructive) throw new WeaveError(`Applying this document would delete table '${spDoc.space}/${db.name}' — a destructive change needs allowDestructive`, 'invalid'); act('delete-table', `${spDoc.space}/${db.name}`, () => this.deleteTable(db.id)); } } } } if (created.length) { this.#withoutFieldLog(() => { for (const { db, tDoc } of created) { for (const f of tDoc.fields ?? []) { if (f.type !== 'relation') continue; const field = this.findField(db, f.name) ?? this.addRelation(db.id, relationArgs(f)).field; const cfg = {}; if (f.width != null) cfg.width = f.width; if (f.description != null) cfg.description = f.description; if (Object.keys(cfg).length) this.updateField(db.id, field.id, { config: { ...field.config, ...cfg } }); } } let pending = created.flatMap(({ db, tDoc, named }) => (tDoc.fields ?? []) .filter((f) => f !== named && ['lookup', 'rollup', 'formula'].includes(f.type) && !this.findField(db, f.name)) .map((f) => ({ db, f }))); while (pending.length) { const left = []; let last = null; for (const p of pending) { try { this.addField(p.db.id, { name: p.f.name, type: p.f.type, config: configFromDescriptor(p.f) }); } catch (err) { left.push(p); last = err; } } if (left.length === pending.length) throw last; pending = left; } for (const { db, named } of created) { if (named?.type === 'formula') this.updateField(db.id, db.nameFieldId, { type: 'formula', config: configFromDescriptor(named) }); } for (const { db, tDoc } of created) { for (const f of tDoc.fields ?? []) { if (f.type !== 'view') continue; const minted = this.findField(db, f.name); if (minted?.type !== 'view') continue; const cfg = configFromDescriptor(f); delete cfg.shape; if (Object.keys(cfg).length) this.updateField(db.id, minted.id, { config: cfg }); } this.#applyTableCostume(db, tDoc); } }); } const spacesT = this.#sysTable('spaces'); const reg = this.#reg; const spacesDoc = doc.find((sp) => sp.system === 'workspace')?.tables?.find((t) => t.system === 'spaces'); if (spacesT && spacesDoc) { const wantedRollups = (spacesDoc.fields ?? []).filter((f) => f.type === 'rollup' && f.viaTable); const cfgOf = (f) => ({ via: this.findTable(f.viaTable)?.id ?? f.viaTable, targetField: f.targetField, aggregate: f.aggregate, ...(f.where ? { where: f.where } : {}) }); const regSpaces = reg === this ? current.get(this.qualifiedName(spacesT)) : reg.describeSchema().find((sp) => sp.system === 'workspace')?.tables.find((t) => t.system === 'spaces'); for (const fDoc of wantedRollups) { const existing = Object.values(spacesT.fields).find((x) => x.name === fDoc.name); const have = regSpaces?.fields.find((x) => x.name === fDoc.name); if (existing && !fieldChanged(fDoc, have)) continue; if (existing) act('delete-field', `System/Spaces.${fDoc.name}`, () => reg.deleteField(spacesT.id, existing.id)); act('create-field', `System/Spaces.${fDoc.name}`, () => reg.addField(spacesT.id, { name: fDoc.name, type: 'rollup', config: cfgOf(fDoc) })); } for (const existing of partial ? [] : Object.values(spacesT.fields)) { if (existing.type !== 'rollup' || !existing.config.via) continue; if (wantedRollups.some((f) => f.name === existing.name)) continue; if (!allowDestructive) throw new WeaveError(`Applying this document would delete 'System/Spaces.${existing.name}' — a destructive change needs allowDestructive`, 'invalid'); act('delete-field', `System/Spaces.${existing.name}`, () => this.deleteField(spacesT.id, existing.id)); } } for (const sp of partial ? [] : this.listSpaces()) { if (sp.system) continue; const still = wanted.some((d) => d.space === sp.name); if (!still) { if (!allowDestructive) throw new WeaveError(`Applying this document would delete space '${sp.name}' — a destructive change needs allowDestructive`, 'invalid'); act('delete-space', sp.name, () => this.deleteSpace(sp.id)); } } if (plan.length) this.#audit('schema-applied', { changes: plan.length }); return plan; } listTemplates() { return this.listSpaces().filter((sp) => sp.template && !sp.system); } useTemplate(spaceRef, target, { name } = {}) { if (!(target instanceof Weave)) throw new WeaveError('useTemplate needs the target workspace', 'invalid'); const sp = this.getSpace(spaceRef); if (sp.system) throw new WeaveError(`Space '${sp.name}' is the workspace's own system space and cannot be a template`, 'invalid'); const as = String(name ?? sp.name).trim(); if (!as) throw new WeaveError('A template copy needs a name', 'invalid'); const where = target.state.meta.name || 'the target workspace'; if (target.listSpaces().some((x) => x.name.toLowerCase() === as.toLowerCase())) { throw new WeaveError(`${where} already has a space named '${as}'`, 'conflict'); } const entry = this.describeSchema().find((x) => x.spaceId === sp.id); const mine = new Set(this.listTables(sp.id).map((t) => t.id)); const rollups = []; for (const f of Object.values(this.#sysTable('spaces')?.fields ?? {})) { if (f.type !== 'rollup' || !mine.has(f.config.via)) continue; const via = this.state.tables[f.config.via]; rollups.push({ name: f.name, type: 'rollup', viaTable: this.qualifiedName(via), aggregate: f.config.aggregate, ...(f.config.targetField ? { targetField: via.fields[f.config.targetField]?.name } : {}), ...(f.config.where ? { where: structuredClone(f.config.where) } : {}), }); } const { doc, rollups: carried, skipped } = templateDoc(entry, { name: as, rollups }); const theirs = target.#sysTable('spaces'); const landing = []; for (const r of carried) { if (!theirs) { skipped.push({ table: 'System/Spaces', field: r.name, viaTable: r.viaTable }); continue; } const taken = (n) => !!target.findField(theirs, n); const n = taken(r.name) ? `${r.name} (${as})` : r.name; if (taken(n)) { skipped.push({ table: 'System/Spaces', field: r.name, viaTable: r.viaTable }); continue; } landing.push({ ...r, name: n }); } const full = [doc, ...(landing.length ? [{ space: SYSTEM_SPACE_NAME, system: 'workspace', tables: [{ name: 'Spaces', system: 'spaces', fields: landing }] }] : [])]; let plan; try { plan = target.applySchema(full, { partial: true }); } catch (err) { const half = target.listSpaces().find((x) => x.name === as); if (half) { try { target.deleteSpace(half.id, { hard: true }); } catch {} } throw err; } const made = target.getSpace(as); target.#audit('space-from-template', { name: made.name, template: sp.name, from: this.state.meta.name ?? null }, made.id); return { space: made, plan, skipped }; } build(spec, { dryRun = false, skipExistingRows = false } = {}) { const trial = this.#buildCopy().#buildRun(spec, { keepGoing: true, skipExistingRows }); if (dryRun || trial.errors.length) return { ok: !trial.errors.length, ...(dryRun ? { dryRun: true } : {}), ...trial }; const run = this.#buildRun(spec, { keepGoing: false, skipExistingRows }); return { ok: !run.errors.length, ...run }; } #buildCopy() { const state = structuredClone(this.state); for (const a of Object.values(state.automations ?? {})) a.actions = (a.actions ?? []).filter((x) => x.type !== 'webhook'); const store = new Store(null); store.load = () => state; const copy = new Weave({ store, actor: this.actor, keystorePath: this.keystorePath, keystoreEnv: this.keystoreEnv, keyStore: this.#keyStore }); copy.#noWebhooks = true; copy.#trialHosted = !!this.registryHost; return copy; } #buildRun(spec, { keepGoing, skipExistingRows }) { const created = { spaces: 0, tables: 0, fields: 0, relations: 0, rows: 0 }; const existing = []; const ignored = []; const errors = []; let halted = false; const fail = (path, err) => { if (halted) return; errors.push({ path, error: err.message }); if (!keepGoing) halted = true; }; const step = (path, fn) => { if (halted) return undefined; try { return fn(); } catch (err) { fail(path, err); return undefined; } }; const extra = (path, obj, keys) => { const left = Object.keys(obj).filter((k) => !keys.includes(k)); if (left.length) ignored.push({ path, keys: left }); }; const isObj = (v) => v != null && typeof v === 'object' && !Array.isArray(v); const cosmetic = (path, holder, key, check) => { if (!isObj(holder) || holder[key] == null || holder[key] === '') return holder; try { check(holder[key]); return holder; } catch (err) { const { [key]: _dropped, ...rest } = holder; ignored.push({ path, keys: [key], reason: err.message }); return rest; } }; const listAt = (path, v) => { if (v == null) return []; if (Array.isArray(v)) return v; fail(path, new WeaveError(`'${path}' must be a list`, 'invalid')); return []; }; const computedMade = []; let computed = null; const result = () => ({ created, existing, ignored, errors, ...(computed ? { computed } : {}) }); if (!isObj(spec)) { fail('', new WeaveError('A build spec is an object: {workspace?, spaces: [{name, tables: [{name, fields, records}]}]}', 'invalid')); return result(); } extra('', spec, ['workspace', 'description', 'spaces']); if ((spec.workspace != null && spec.workspace !== this.state.meta.name) || spec.description != null) { step('workspace', () => this.updateWorkspace({ name: spec.workspace ?? null, description: spec.description ?? null })); } const tables = []; const failedTables = new Set(); listAt('spaces', spec.spaces).forEach((sp, i) => { const at = `spaces[${i}]`; if (!isObj(sp)) return fail(at, new WeaveError('A space is an object: {name, icon?, description?, tables}', 'invalid')); extra(at, sp, ['name', 'icon', 'description', 'tables']); sp = cosmetic(at, sp, 'icon', iconValue); let space = sp.name ? this.findSpace(sp.name) : null; if (space) existing.push(`space ${space.name}`); else { space = step(at, () => this.createSpace({ name: sp.name, description: sp.description ?? '', icon: sp.icon ?? '' })); if (!space) return undefined; created.spaces += 1; } listAt(`${at}.tables`, sp.tables).forEach((t, j) => { const tAt = `${at}.tables[${j}]`; if (!isObj(t)) return fail(tAt, new WeaveError('A table is an object: {name, icon?, description?, fields?, records?, fieldOrder?, hidden?, sort?, automations?}', 'invalid')); extra(tAt, t, ['name', 'icon', 'description', 'fields', 'records', 'rows', 'fieldOrder', 'hidden', 'sort', 'automations']); const tw = cosmetic(tAt, t, 'icon', iconValue); let db = t.name ? this.findTable(`${space.name}/${t.name}`) : null; if (db) existing.push(`table ${space.name}/${db.name}`); else { db = step(tAt, () => this.createTable({ space: space.id, name: t.name, description: t.description ?? '', icon: tw.icon ?? '' })); if (!db) { if (t.name) failedTables.add(String(t.name).toLowerCase()); return undefined; } created.tables += 1; } tables.push({ at: tAt, spec: t.records === undefined ? t : { ...t, rows: t.records }, db, failed: new Set() }); return undefined; }); return undefined; }); const target = (ref) => { const hit = tables.filter((tb) => tb.db.name.toLowerCase() === String(ref).toLowerCase()); return hit.length === 1 ? hit[0].db.id : ref; }; const phase = (f) => (f?.type === 'relation' ? 1 : f?.type === 'lookup' || f?.type === 'rollup' ? 2 : f?.type === 'formula' ? 3 : 0); const fields = tables.flatMap((tb) => listAt(`${tb.at}.fields`, tb.spec.fields).map((f, k) => ({ tb, f, at: `${tb.at}.fields[${k}]` }))); for (const p of [0, 1, 2, 3]) { for (const { tb, f, at } of fields) { if (phase(f) !== p) continue; if (!isObj(f)) { fail(at, new WeaveError('A field is an object: {name, type, ...config}', 'invalid')); continue; } const { name, type, ...sent } = f; let config = type === 'rating' ? cosmetic(at, sent, 'icon', iconValue) : sent; if (Array.isArray(config.options)) { const hue = (k) => (v) => hueOf({ [k]: v }, { strict: true }); config = { ...config, options: config.options.map((o, n) => ['icon', 'hue', 'color'] .reduce((acc, k) => cosmetic(`${at}.options[${n}]`, acc, k, k === 'icon' ? iconValue : hue(k)), o)) }; } if (Array.isArray(config.states)) { config = { ...config, states: config.states.map((st, n) => cosmetic(`${at}.states[${n}]`, st, 'icon', iconValue)) }; } const reads = type === 'lookup' || type === 'rollup' ? [config.relationField ?? config.relation] : type === 'formula' && typeof config.expression === 'string' ? formulaReferences(config.expression) : []; if (reads.some((r) => r != null && tb.failed.has(r)) || (type === 'relation' && !Array.isArray(config.to) && config.to != null && failedTables.has(String(config.to).toLowerCase()))) { tb.failed.add(name); continue; } const have = name ? this.findField(tb.db, name) : null; if (have) { if (have.type === type) existing.push(`field ${this.qualifiedName(tb.db)}.${have.name}`); else { tb.failed.add(name); fail(at, new WeaveError(`Field '${name}' already exists as ${have.type}, not ${type}`, 'conflict')); } continue; } if (type === 'relation') { const { to, cardinality, inverseName, ...rest } = config; if (Object.keys(rest).length) ignored.push({ path: at, keys: Object.keys(rest) }); const where = Array.isArray(to) ? { targetDbs: to.map(target) } : { targetDb: to == null ? undefined : target(to) }; if (step(at, () => this.addRelation(tb.db.id, { name, ...where, cardinality, inverseName }))) created.relations += 1; else tb.failed.add(name); continue; } const field = step(at, () => this.addField(tb.db.id, { name, type, config })); if (!field) { tb.failed.add(name); continue; } created.fields += 1; if (p >= 2) computedMade.push({ tb, name: field.name }); const takes = new Set([...(FIELD_TYPE_VOCABULARY.find((v) => v.type === type)?.config ?? []), 'width', 'description', 'default', 'relation']); const dropped = Object.keys(config).filter((k) => !(k in field.config) && !takes.has(k)); if (dropped.length) ignored.push({ path: at, keys: dropped }); } } const relationOf = (tb, key) => { const f = this.findField(tb.db, key); return f?.type === 'relation' ? f : null; }; const resolves = (f, v) => { try { this.#normalizeRelationInput(f, v); return true; } catch { return false; } }; const needs = new Map(tables.map((tb) => [tb, new Set()])); for (const tb of tables) { for (const row of Array.isArray(tb.spec.rows) ? tb.spec.rows : []) { if (!isObj(row)) continue; for (const [key, v] of Object.entries(row)) { const f = relationOf(tb, key); if (!f || v == null) continue; for (const id of this.relationTargetDbIds(f)) { for (const other of tables) if (other !== tb && other.db.id === id) needs.get(tb).add(other); } } } } const rowOrder = []; const visit = (tb, path) => { if (rowOrder.includes(tb) || path.has(tb)) return; path.add(tb); for (const other of needs.get(tb)) visit(other, path); path.delete(tb); rowOrder.push(tb); }; for (const tb of tables) visit(tb, new Set()); const links = []; for (const tb of rowOrder) { const held = skipExistingRows ? new Set(this.listEntities(tb.db.id).map((e) => this.entityName(e))) : null; listAt(`${tb.at}.rows`, tb.spec.rows).forEach((row, k) => { const at = `${tb.at}.rows[${k}]`; if (!isObj(row)) return fail(at, new WeaveError('A record is an object of values by field name', 'invalid')); const rowName = row.Name ?? row.name ?? row[tb.db.fields[tb.db.nameFieldId]?.name]; if (held && rowName != null && held.has(String(rowName))) { existing.push(`record ${this.qualifiedName(tb.db)}: ${rowName}`); return undefined; } const values = {}; const rel = {}; for (const [key, v] of Object.entries(row)) { if (tb.failed.has(key)) continue; const f = relationOf(tb, key); (f && !resolves(f, v) ? rel : values)[key] = v; } const e = step(at, () => this.createEntity(tb.db.id, values)); if (!e) return undefined; created.rows += 1; held?.add(this.entityName(e)); if (Object.keys(rel).length) links.push({ at, id: e.id, rel }); return undefined; }); } for (const { at, id, rel } of links) step(at, () => this.updateEntity(id, rel)); for (const tb of tables) { const ok = (n) => !tb.failed.has(n); const names = (key) => listAt(`${tb.at}.${key}`, tb.spec[key]).filter(ok); if (tb.spec.fieldOrder != null) step(`${tb.at}.fieldOrder`, () => this.#buildOrder(tb.db, names('fieldOrder'))); if (tb.spec.hidden != null) step(`${tb.at}.hidden`, () => this.tableView(`${tb.db.id}/${this.tableView(tb.db.id).views[0].name}`, { hide: names('hidden') })); if (tb.spec.sort != null) { step(`${tb.at}.sort`, () => { const raw = typeof tb.spec.sort === 'string' ? parseSort(tb.spec.sort) : listAt(`${tb.at}.sort`, tb.spec.sort); const sort = raw.flatMap((x) => (typeof x === 'string' ? parseSort(x) : [x])).filter((x) => !isObj(x) || ok(x.field)); return this.updateTable(tb.db.id, { sort }); }); } } for (const tb of tables) { const have = new Set(this.listAutomations(tb.db.id).map((a) => String(a.name).toLowerCase())); listAt(`${tb.at}.automations`, tb.spec.automations).forEach((rule, k) => { const at = `${tb.at}.automations[${k}]`; if (!isObj(rule)) return fail(at, new WeaveError('A rule is an object: {name, trigger, actions, on?}', 'invalid')); extra(at, rule, ['name', 'trigger', 'actions', 'on']); const name = String(rule.name ?? '').trim(); if (!name) return fail(at, new WeaveError('A rule needs a name', 'invalid')); if (have.has(name.toLowerCase())) { existing.push(`automation ${this.qualifiedName(tb.db)}: ${name}`); return undefined; } const made = step(at, () => { try { this.#compileSpec({ table: tb.db.id, trigger: rule.trigger, actions: rule.actions }); } catch (err) { throw new WeaveError(`Rule '${name}' would read Setup incomplete: ${err.message}`, 'invalid'); } if (this.#trialHosted && !this.#sysTable('workflows')) return true; return this.createAutomation(tb.db.id, { name, trigger: rule.trigger, actions: rule.actions, enabled: rule.on !== false }); }); if (!made) return undefined; have.add(name.toLowerCase()); created.automations = (created.automations ?? 0) + 1; return undefined; }); } if (computedMade.length) { const brief = (v) => (Array.isArray(v) ? v.map(brief) : isObj(v) && 'name' in v ? v.name : v); computed = {}; for (const { tb, name } of computedMade) { computed[`${this.qualifiedName(tb.db)}.${name}`] = this.query(tb.db.id, { limit: 3, fields: [name] }).items.map((e) => brief(e.fields[name])); } } return result(); } #buildOrder(db, names) { const lead = names.map((n) => this.getField(db.id, n).id); const order = [...new Set([...lead, ...db.fieldOrder])]; this.updateTable(db.id, { fieldOrder: order }); const [view] = this.tableView(db.id).views; const shown = new Set(view.fields.map((n) => this.findField(db, n)?.id ?? n)); const cols = [...order.filter((id) => shown.has(id)), ...[...shown].filter((id) => !order.includes(id))]; this.tableView(`${db.id}/${view.name}`, { fields: cols }); } #applyTableCostume(db, tDoc) { const patch = {}; const nameDoc = (tDoc.fields ?? []).find((f) => f.role === 'name') ?? (tDoc.fields ?? []).find((f) => f.name === 'Name'); if (nameDoc?.term) this.#setTerm(db, nameDoc.term); else if (tDoc.noun) patch.noun = tDoc.noun; if (!tDoc.views) { if (tDoc.hiddenFields?.length) patch.hiddenFields = [...tDoc.hiddenFields]; if (tDoc.filters && Object.keys(tDoc.filters).length) patch.filters = tDoc.filters; if (tDoc.sort?.length) patch.sort = tDoc.sort; } if (withoutRetired(tDoc.systemFields).length) patch.systemFields = withoutRetired(tDoc.systemFields); if (tDoc.hideRollups != null) patch.hideRollups = !!tDoc.hideRollups; if (tDoc.referencePanels?.length) patch.referencePanels = tDoc.referencePanels; const wanted = []; for (const fDoc of tDoc.fields ?? []) { const f = Object.values(db.fields).find((x) => x.name === fDoc.name); if (f && !wanted.includes(f.id)) wanted.push(f.id); } for (const id of db.fieldOrder) if (!wanted.includes(id)) wanted.push(id); if (wanted.length === db.fieldOrder.length && JSON.stringify(wanted) !== JSON.stringify(db.fieldOrder)) patch.fieldOrder = wanted; if (Object.keys(patch).length) this.updateTable(db.id, patch); const body = this.#bodyOrderFrom(db, tDoc); if (body) this.updateTable(db.id, { bodyOrder: body }); if (Array.isArray(tDoc.views)) { this.#applyViews(db, tDoc.views, (a, s, fn) => fn(), true); const legacy = this.#legacyEdits(db, tDoc); if (Object.keys(legacy).length) this.updateTable(db.id, legacy); } } #bodyOrderFrom(db, tDoc) { if (!Array.isArray(tDoc.bodyBlocks)) return null; const want = tDoc.bodyBlocks.filter((n) => isFixedBlock(n) || (this.findField(db, n) && isBodyBlock(this.findField(db, n)))); const now = this.bodyBlocks(db).filter((n) => want.includes(n)); return JSON.stringify(want) === JSON.stringify(now) ? null : want; } #legacyEdits(db, tDoc, created = new Set()) { const v0 = tDoc.views[0]; const out = {}; const fresh = (list) => (list ?? []).every((n) => GRID_SYSTEM_COLUMNS.includes(n) || n === RETIRED_SYSTEM_FIELD || this.findField(db, n)); if ('hiddenFields' in tDoc && Array.isArray(v0?.fields) && fresh(tDoc.hiddenFields)) { const shown = new Set(v0.fields); const echo = (tDoc.fields ?? []).map((f) => f.name).filter((n) => !shown.has(n) && !created.has(n)); if (JSON.stringify(tDoc.hiddenFields ?? []) !== JSON.stringify(echo)) out.hiddenFields = tDoc.hiddenFields ?? []; } if ('filters' in tDoc && JSON.stringify(tDoc.filters ?? {}) !== JSON.stringify(v0?.filters ?? {})) out.filters = tDoc.filters ?? {}; if ('sort' in tDoc && JSON.stringify(tDoc.sort ?? []) !== JSON.stringify(v0?.sort ?? [])) out.sort = tDoc.sort ?? []; return out; } #applyViews(db, docViews, act, allowDestructive, created = new Set()) { const q = this.qualifiedName(db); const has = (n) => !!this.findField(db, n) || GRID_SYSTEM_COLUMNS.includes(n); const clean = (vDoc) => { const out = {}; if (vDoc.fields) { out.fields = vDoc.fields.filter(has); for (const n of created) if (!out.fields.includes(n) && has(n)) out.fields.push(n); } if (vDoc.filters) out.filters = Object.fromEntries(Object.entries(vDoc.filters).filter(([k]) => has(k))); if (vDoc.sort) out.sort = vDoc.sort.filter((s) => has(s.field)); if (typeof vDoc.search === 'string' && vDoc.search.trim()) out.search = vDoc.search; if (vDoc.widths) out.widths = Object.fromEntries(Object.entries(vDoc.widths).filter(([k]) => has(k))); if (vDoc.frozen) out.frozen = vDoc.frozen; if (vDoc.density) out.density = vDoc.density; if (vDoc.deleted) out.deleted = true; if (typeof vDoc.rollups === 'boolean') out.rollups = vDoc.rollups; if (vDoc.hideEmpty) out.hideEmpty = true; for (const k of ['layout', 'groups', 'picture', 'fit', 'frame', 'flow', 'subtotals']) if (vDoc[k]) out[k] = vDoc[k]; if (Array.isArray(vDoc.group)) out.group = vDoc.group.filter((l) => has(typeof l === 'string' ? l : l?.field)); if (vDoc.completedBy && has(vDoc.completedBy)) out.completedBy = vDoc.completedBy; const pr = vDoc.phoneRow; if (pr && typeof pr === 'object') out.phoneRow = PhoneRow.compact({ lead: pr.lead != null && has(pr.lead) ? pr.lead : null, chips: (pr.chips ?? []).filter(has), labels: pr.labels, wrap: pr.wrap }); for (const k of ['filters', 'sort', 'widths']) if (out[k] && !Object.keys(out[k]).length) delete out[k]; return out; }; const named = new Set(docViews.map((v) => String(v?.name ?? '').toLowerCase())); const doomed = db.tableViews.filter((v) => !named.has(v.name.toLowerCase())); if (doomed.length && !allowDestructive) { throw new WeaveError(`Applying this document would delete view '${q}/${doomed[0].name}' — a destructive change needs allowDestructive`, 'invalid'); } if (!docViews.length && db.tableViews.length) { throw new WeaveError(`A table keeps at least one view — the document lists none for ${q}`, 'invalid'); } for (const raw of docViews) { const vDoc = { name: raw.name, ...clean(raw) }; const i = this.#viewIndex(db, vDoc.name); const have = i >= 0 ? this.#viewOut(db, db.tableViews[i]) : null; const patch = {}; if (vDoc.fields && JSON.stringify(vDoc.fields) !== JSON.stringify(have?.fields)) patch.fields = vDoc.fields; if (JSON.stringify(vDoc.filters ?? null) !== JSON.stringify(have?.filters ?? null)) patch.filters = vDoc.filters ?? {}; if (JSON.stringify(vDoc.sort ?? null) !== JSON.stringify(have?.sort ?? null)) patch.sort = vDoc.sort ?? []; if ((vDoc.search?.trim() ?? '') !== (have?.search ?? '')) patch.search = vDoc.search ?? ''; if (JSON.stringify(vDoc.widths ?? {}) !== JSON.stringify(have?.widths ?? {})) { patch.widths = { ...Object.fromEntries(Object.keys(have?.widths ?? {}).map((n) => [n, null])), ...(vDoc.widths ?? {}) }; } if ((vDoc.frozen ?? 0) !== (have?.frozen ?? 0)) patch.frozen = vDoc.frozen ?? 0; if ((vDoc.density ?? 'comfortable') !== (have?.density ?? 'comfortable')) patch.density = vDoc.density ?? 'comfortable'; if (!!vDoc.deleted !== !!have?.deleted) patch.deleted = !!vDoc.deleted; if ((vDoc.rollups ?? null) !== (have?.rollups ?? null)) patch.rollups = vDoc.rollups ?? null; if (!!vDoc.hideEmpty !== !!have?.hideEmpty) patch.hideEmpty = !!vDoc.hideEmpty; for (const k of ['layout', 'groups', 'picture', 'fit', 'frame', 'flow', 'subtotals']) if ((vDoc[k] ?? null) !== (have?.[k] ?? null)) patch[k] = vDoc[k] ?? null; if (JSON.stringify(vDoc.group ?? []) !== JSON.stringify(have?.group ?? [])) patch.group = vDoc.group ?? null; if ((vDoc.completedBy ?? null) !== (have?.completedBy ?? null)) patch.completedBy = vDoc.completedBy ?? null; if (JSON.stringify(vDoc.phoneRow ?? null) !== JSON.stringify(have?.phoneRow ?? null)) { patch.phoneRow = vDoc.phoneRow ? { lead: vDoc.phoneRow.lead ?? null, chips: vDoc.phoneRow.chips ?? [], labels: !!vDoc.phoneRow.labels, wrap: !!vDoc.phoneRow.wrap } : null; } if (!Object.keys(patch).length) continue; act(have ? 'update-view' : 'create-view', `${q}/${vDoc.name}`, () => this.tableView(`${db.id}/${vDoc.name}`, patch)); } for (const v of doomed) act('delete-view', `${q}/${v.name}`, () => this.tableView(`${db.id}/${v.id}`, { delete: true })); const strip = [...db.tableViews.filter((v) => named.has(v.name.toLowerCase())).map((v) => v.name.toLowerCase())]; for (const raw of docViews) if (!strip.includes(String(raw.name).toLowerCase())) strip.push(String(raw.name).toLowerCase()); docViews.forEach((raw, k) => { const at = strip.indexOf(String(raw.name).toLowerCase()); if (at === k) return; strip.splice(at, 1); strip.splice(k, 0, String(raw.name).toLowerCase()); act('update-view', `${q}/${raw.name}`, () => this.tableView(`${db.id}/${raw.name}`, { position: k })); }); } workspaceOrder() { const reg = this.#reg; const t = reg.#sysTable('workspaces'); if (!t) throw new WeaveError('This file holds no local workspace registry; migrate its legacy registry before ordering workspaces', 'invalid'); const rank = new Map((reg.state.meta.workspaceOrder ?? []).map((id, i) => [id, i])); return reg.listEntities(t.id).filter((r) => r.sysId) .map((r, i) => ({ id: r.sysId, name: reg.entityName(r), i })) .sort((a, b) => (rank.get(a.id) ?? Infinity) - (rank.get(b.id) ?? Infinity) || a.i - b.i) .map(({ id, name }, position) => ({ id, name, position })); } moveWorkspace(ref, position) { checkPosition(position); const order = this.workspaceOrder(); const key = String(ref ?? '').toLowerCase(); const hit = order.find((w) => w.id === ref) ?? order.find((w) => w.name.toLowerCase() === key); if (!hit) throw new WeaveError(`Workspace '${ref}' not found`, 'not-found'); const ids = order.map((w) => w.id).filter((id) => id !== hit.id); ids.splice(Math.min(position, ids.length), 0, hit.id); const reg = this.#reg; reg.state.meta.workspaceOrder = ids; reg.save(); reg.#audit('workspace-moved', { name: hit.name, position: ids.indexOf(hit.id) }); return reg.workspaceOrder(); } getWorkspace() { const m = this.state.meta; return { id: m.id, name: m.name, title: m.title ?? m.name, description: m.description ?? '', logo: !!m.logo, requireAuth: !!m.requireAuth, linkPreview: !!m.linkPreview }; } updateWorkspace({ name = null, description = null, linkPreview = null } = {}) { if (name != null && name !== this.state.meta.name) refuseOnDocs(this, 'renamed'); if (description != null) this.state.meta.description = String(description); if (linkPreview != null) { if ([true, 'true', 'on'].includes(linkPreview)) this.state.meta.linkPreview = true; else delete this.state.meta.linkPreview; } if (name != null && name !== this.state.meta.name) { const slug = workspaceSlug(name); const refused = hostSlugRefusal(slug); if (refused) throw new WeaveError(refused.message, refused.code); const was = String(this.state.meta.name ?? '').toLowerCase(); const aliases = [...(this.state.meta.aliases ?? []), ...(was ? [was] : [])].filter((a, i, all) => a !== slug && all.indexOf(a) === i); if (aliases.length) this.state.meta.aliases = aliases; else delete this.state.meta.aliases; this.state.meta.name = slug; this.state.meta.title = String(name).trim(); if (this.state.meta.title === slug) delete this.state.meta.title; } this.#audit('workspace-updated', { name: this.state.meta.name }); this.save(); this.#syncWorkspaceRow(); return this.getWorkspace(); } #keyStore = null; migrateKeys(to) { if (!['file', 'keychain'].includes(to)) throw new WeaveError(`Name the store to move the keys to: --to keychain or --to file`, 'invalid'); if (!this.#localKeys()) throw new WeaveError('Moving the key store is a local operator act: run weave key migrate on the machine that holds it', 'forbidden'); const env = this.keystoreEnv ?? process.env; const from = this.#keyStore ?? openKeyStore({ path: this.keystorePath, env, kind: to === 'keychain' ? 'file' : 'keychain' }); const target = openKeyStore({ path: this.keystorePath, env, kind: to }); if (!this.#keyStore) { const after = keystoreKind({ path: this.keystorePath, pick: env.WEAVE_KEYSTORE ?? process.env.WEAVE_KEYSTORE, exists: to === 'file' }); if (after !== to) throw new WeaveError(`After the move weave would still read the ${after} store for ${this.keystorePath}: set WEAVE_KEYSTORE=${to} for this move and for every weave that reads these keys`, 'invalid'); } const moved = migrateKeys(from, target); if (this.#keyStore) this.#keyStore = target; this.#audit('keys-migrated', { from: moved.from, to: moved.to, count: moved.moved.length }); return moved; } credentialConfig(field) { return credentialConfig(field); } setKey(name, secret) { if (!name) throw new WeaveError('Key name is required', 'invalid'); refuseReserved('key', name); refuseIntegrationKey(name); const data = this.keyStore.read(); const prior = own(data.keys, name); if (prior && !this.#mayGrant(prior)) throw this.#grantRefusal(prior, 'replace', name); data.keys[name] = { value: secret ?? '', owner: prior?.owner ?? this.#callerAccount(), workspace: prior?.workspace ?? this.state.meta.id, shared: prior?.shared ?? false, createdAt: prior?.createdAt ?? nowISO(), ...(prior ? { rotatedAt: nowISO() } : {}), }; this.keyStore.write(data); this.#audit('key-set', { name }); return { name, set: true }; } deleteKey(name) { refuseIntegrationKey(name); const data = this.keyStore.read(); if (!Object.hasOwn(data.keys, name)) throw new WeaveError(`Key '${name}' not found`, 'not-found'); if (!this.#mayGrant(data.keys[name])) throw this.#grantRefusal(data.keys[name], 'delete', name); delete data.keys[name]; this.keyStore.write(data); this.#audit('key-deleted', { name }); return { name, deleted: true }; } hasKey(name) { const entry = own(this.keyStore.read().keys, name); return !!entry && (this.#keyInWorkspace(entry) || this.#localKeys()); } listKeys() { const { keys } = this.keyStore.read(); return Object.keys(keys).filter(name => !isIntegrationKey(name) && (this.#keyInWorkspace(keys[name]) || this.#localKeys())).sort().map((name) => ({ name, set: true, owner: this.actorName(keys[name].owner ?? null), shared: this.#namedShared(keys[name].shared ?? false), })); } resolveKey(name) { refuseIntegrationKey(name); const { keys } = this.keyStore.read(); const entry = own(keys, name); if (!entry) throw new WeaveError(`Key '${name}' not found in the keystore`, 'not-found'); if (!this.#keyInWorkspace(entry) && !this.#localKeys()) throw new WeaveError(`Key '${name}' belongs to another workspace or needs local recovery`, 'forbidden'); return this.keyStore.value(name, entry); } #localKeys() { return (this.localKeyAccess === true || this.localKeyAccess !== false && !this.listAccounts().length) && !/^account:/.test(String(this.actor ?? '')); } #keyInWorkspace(entry) { if (entry.workspace) return entry.workspace === this.state.meta.id; const id = /^account:(.+)$/.exec(entry.owner ?? '')?.[1]; return !!id && Object.hasOwn(this.state.meta.accounts ?? {}, id); } #mayReveal(entry) { if (!entry) return false; if (this.#localKeys()) return true; if (!this.#keyInWorkspace(entry)) return false; if (!this.#callerAccount()) return false; if (entry.shared === true) return true; if (Array.isArray(entry.shared) && entry.shared.some((a) => this.#isKeyHolder(a))) return true; return this.#isKeyHolder(entry.owner); } revealKey(name, { via = 'show' } = {}) { refuseIntegrationKey(name); const data = this.keyStore.read(); const entry = own(data.keys, name); if (!entry) throw new WeaveError(`Key '${name}' not found in the keystore`, 'not-found'); if (!this.#mayReveal(entry)) { throw new WeaveError(`'${name}' is not shared with you — its owner has to grant it`, 'forbidden'); } this.#audit('key-revealed', { name, via }); return this.keyStore.value(name, entry); } ownsKey(name) { return this.#isKeyHolder(own(this.keyStore.read().keys, name)?.owner); } #callerAccount() { const m = /^account:([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?: via MCP)?$/.exec(String(this.actor ?? '')); return m && this.#accountById(m[1]) ? `account:${m[1]}` : null; } #isKeyHolder(who) { const me = this.#callerAccount(); return !!me && who === me; } #mayGrant(entry) { if (this.#localKeys()) return true; if (!this.#keyInWorkspace(entry)) return false; return /^account:/.test(entry.owner ?? '') && this.#isKeyHolder(entry.owner); } #grantRefusal(entry, verb, name) { const who = /^account:/.test(entry.owner ?? '') ? `'${this.actorName(entry.owner)}'` : 'a local CLI operator'; return new WeaveError(`Only ${who} can ${verb} '${name}'`, 'forbidden'); } grantKey(name, account) { if (!account) throw new WeaveError('Name the account the credential is shared with', 'invalid'); refuseIntegrationKey(name); const data = this.keyStore.read(); const entry = own(data.keys, name); if (!entry) throw new WeaveError(`Key '${name}' not found`, 'not-found'); if (!this.#mayGrant(entry)) throw this.#grantRefusal(entry, 'share', name); if (!entry.workspace && this.#localKeys()) entry.workspace = this.state.meta.id; const who = account === true ? true : this.#actorFor(account); if (who == null) throw new WeaveError(`No account named '${account}': a credential is shared with an account`, 'invalid'); const list = Array.isArray(entry.shared) ? entry.shared : []; if (who !== true && !list.includes(who)) list.push(who); entry.shared = who === true ? true : list; this.keyStore.write(data); this.#audit('key-granted', { name, to: account === true ? 'everyone' : account }); return { name, shared: this.#namedShared(entry.shared) }; } revokeKey(name, account) { refuseIntegrationKey(name); const data = this.keyStore.read(); const entry = own(data.keys, name); if (!entry) throw new WeaveError(`Key '${name}' not found`, 'not-found'); if (!this.#mayGrant(entry)) throw this.#grantRefusal(entry, 'unshare', name); const who = this.#actorFor(account); entry.shared = Array.isArray(entry.shared) ? entry.shared.filter((a) => a !== account && a !== who) : false; this.keyStore.write(data); this.#audit('key-revoked', { name, from: account }); return { name, shared: this.#namedShared(entry.shared) }; } #renameKeyHolder(from, to) { const data = this.keyStore.read(); let moved = 0; for (const entry of Object.values(data.keys)) { if (entry.owner === from) { entry.owner = to; moved++; } if (Array.isArray(entry.shared) && entry.shared.includes(from)) { entry.shared = [...new Set(entry.shared.map((a) => (a === from ? to : a)))]; moved++; } } if (moved) this.keyStore.write(data); } credentialLink(field, ref) { const { keystore } = this.credentialConfig(field); const r = encodeURIComponent(String(ref ?? '')); switch (keystore) { case '1password': return `onepassword://search/?q=${r}`; case 'aws-sm': return `https://console.aws.amazon.com/secretsmanager/secret?name=${r}`; case 'google-sm': return `https://console.cloud.google.com/security/secret-manager/secret/${r}`; case 'cloudflare': return `https://dash.cloudflare.com/?to=/:account/workers/services`; case 'apple-passwords': return 'x-apple.systempreferences:com.apple.Passwords-Settings.extension'; default: return null; } } static ROLES = ['architect', 'editor', 'observer']; static ROLE_LABELS = { architect: 'Architect, paid', editor: 'Editor, paid seat', observer: 'Observer, free' }; static OLD_ROLES = { admin: 'architect', writer: 'editor', reader: 'observer' }; static roleName(role) { return Weave.OLD_ROLES[role] ?? role; } #audit(action, detail = {}, spaces = null, actor = this.actor) { const touched = spaces ? [...new Set([].concat(spaces).filter(Boolean))] : []; this.store.audit({ at: nowISO(), actor, action, detail: touched.length ? { ...detail, spaces: touched } : detail }); this.#reg.#settleWorkflows(); } #spacesTouching(db, field = null) { const ids = new Set([db.spaceId]); const fields = field ? [field] : Object.values(db.fields); for (const f of fields) { if (f.type !== 'relation') continue; for (const tid of [f.config.targetDb, ...(f.config.targetDbs ?? [])]) { if (this.state.tables[tid]) ids.add(this.state.tables[tid].spaceId); } } const watched = new Set(fields.map((f) => f.id)); for (const other of Object.values(this.state.tables)) { for (const f of Object.values(other.fields)) { if (f.config?.targetField && watched.has(f.config.targetField)) ids.add(other.spaceId); if (!field && f.type === 'relation' && (f.config.targetDb === db.id || f.config.targetDbs?.includes(db.id))) ids.add(other.spaceId); } } return [...ids]; } auditWorkspace(action, name, actor = this.actor) { if (!['workspace-created', 'workspace-trashed', 'workspace-restored', 'workspace-deleted'].includes(action)) throw new WeaveError(`Unknown workspace action '${action}'`, 'invalid'); this.#audit(action, { name }, null, actor); } listAudit(opts = {}) { const hidden = this.#hiddenSpaces(); const named = (r) => ({ ...r, actor: this.actorName(r.actor), detail: this.#namedDetail(Weave.#publicDetail(r.detail)) }); if (!hidden) return this.store.listAudit(opts).map(named); const { limit = 100, offset = 0, ...rest } = opts; const seen = this.store.listAudit({ ...rest, limit: -1, offset: 0 }).filter((r) => !(r.detail?.spaces ?? []).some((id) => hidden.has(id))); return seen.slice(offset, limit < 0 ? undefined : offset + limit).map(named); } createAccount({ name, role: asked = 'editor', space = null } = {}) { return this.#createAccount({ name, role: asked, space }); } #createAccount({ name, role: asked = 'editor', space = null } = {}, id = uuid()) { if (!name) throw new WeaveError('Account name is required', 'invalid'); refuseReserved('account', name); let role = Weave.roleName(asked); if (!Weave.ROLES.includes(role)) throw new WeaveError(`Invalid role '${asked}' (${Weave.ROLES.join(', ')})`, 'invalid'); const sp = space == null || space === '' ? null : this.getSpace(space); if (sp?.system) throw new WeaveError(`A token is scoped to a space people made, not '${sp.name}'`, 'invalid'); const caller = viewerOf(this)?.accountId; const issuer = caller ? own(this.state.meta.accounts ?? {}, caller) ?? null : null; if (issuer) { const reach = sp ? spaceRole(this, viewerFor(issuer), sp) : workspaceRole(this, viewerFor(issuer)); if (reach == null) throw new WeaveError(`Space '${space}' not found`, 'not-found'); role = lower(role, reach); } const accounts = (this.state.meta.accounts ??= {}); if (Object.values(accounts).some((a) => a.name === name)) throw new WeaveError(`Account '${name}' already exists`, 'conflict'); const token = 'wv_' + randomBytes(24).toString('base64url'); const account = { id, name, role, tokenHash: this.#hash(token), createdAt: nowISO(), ...(sp ? { space: sp.id } : {}), ...(issuer ? { issuedBy: issuer.id } : {}) }; accounts[account.id] = account; this.save(); this.#audit('account-created', { name, role }); this.#syncUsers(); const { tokenHash, ...pub } = account; const note = role !== asked ? `Role '${asked}' is now '${role}'; the old name is deprecated and accepted for one more release` : undefined; return { account: pub, token, ...(note ? { note } : {}) }; } verifyToken(token) { if (!token) return null; const h = this.#hash(token); const a = Object.values(this.state.meta.accounts ?? {}).find((x) => x.tokenHash === h); if (!a) return null; const { tokenHash, ...pub } = a; return pub; } listAccounts() { return Object.values(this.state.meta.accounts ?? {}).map(({ tokenHash, credentials, ...pub }) => ({ ...pub, seatRole: seatRole(this, pub) })); } noteTokenWrite(accountId, { method, path, status }) { const a = own(this.state.meta.accounts ?? {}, accountId); if (!a?.space) return; this.#audit('token-write', { name: a.name, method, path, status }, a.space, accountActor(a.id)); } actorName(actor) { if (typeof actor !== 'string' || !actor.includes('account:')) return actor; return actor.replace(ACCOUNT_ACTOR, (_, id) => this.#accountById(id)?.name ?? REMOVED_ACCOUNT); } isActor(who) { if (who == null) return false; if (who === this.actor) return true; const name = this.actorName(this.actor); return name !== REMOVED_ACCOUNT && this.actorName(who) === name; } #accountById(id) { for (const w of [this, ...this.#engines()]) { const a = own(w.state.meta.accounts ?? {}, id); if (a) return a; } return null; } #actorFor(name) { for (const w of [this, ...this.#engines()]) { const a = Object.values(w.state.meta.accounts ?? {}).find((x) => x.name === name); if (a) return accountActor(a.id); } return null; } #namedShared(shared) { return Array.isArray(shared) ? shared.map((a) => this.actorName(a)) : shared; } #namedDetail(detail) { if (!detail || typeof detail !== 'object' || Array.isArray(detail)) return detail; return Object.fromEntries(Object.entries(detail).map(([k, v]) => [k, typeof v === 'string' ? this.actorName(v) : v])); } #namedActivity(a) { return { ...a, actor: this.actorName(a.actor ?? null), detail: this.#namedDetail(this.#lockedNames(a.detail)) }; } #lockedNames(detail) { if (!Array.isArray(detail?.added) && !Array.isArray(detail?.removed)) return detail; const hidden = this.#hiddenSpaces(); if (!hidden) return detail; const memo = this.#query; let names = memo?.lockedNames; if (!names) { names = new Set(); for (const e of Object.values(this.state.entities)) if (hidden.has(this.state.tables[e.dbId]?.spaceId)) names.add(this.entityName(e)); if (memo) memo.lockedNames = names; } const lock = (list) => (Array.isArray(list) ? list.map((n) => (names.has(n) ? LOCKED_NAME : n)) : list); return { ...detail, added: lock(detail.added), removed: lock(detail.removed) }; } deleteAccount(ref) { const accounts = this.state.meta.accounts ?? {}; const a = own(accounts, ref) ?? Object.values(accounts).find((x) => x.name === ref); if (!a) throw new WeaveError(`Account '${ref}' not found`, 'not-found'); delete accounts[a.id]; for (const sp of Object.values(this.state.spaces)) if (own(sp.members ?? {}, a.id)) delete sp.members[a.id]; const sessions = this.state.meta.sessions ?? {}; const removedAt = nowISO(); let ended = 0; for (const [h, s] of Object.entries(sessions)) { if (s.accountId !== a.id) continue; sessions[h] = { removedAt, expiresAt: s.expiresAt }; ended += 1; } this.save(); this.#audit('account-deleted', { name: a.name }); if (ended) this.#audit('session-revoked', { name: a.name, count: ended, all: true }); this.#syncUsers(); return { id: a.id, deleted: true }; } onboardedAt(accountId = null) { return (accountId ? this.state.meta.accounts?.[accountId]?.onboardedAt : this.state.meta.onboardedAt) ?? null; } markOnboarded(accountId = null) { const holder = accountId ? own(this.state.meta.accounts ?? {}, accountId) : this.state.meta; if (!holder) throw new WeaveError(`Account '${accountId}' not found`, 'not-found'); if (holder.onboardedAt) return holder.onboardedAt; holder.onboardedAt = nowISO(); this.save(); this.#audit('onboarded', accountId ? { name: holder.name } : {}); return holder.onboardedAt; } agentSeen(accountId) { const a = this.state.meta.accounts?.[accountId]; return a?.agentSeenAt ? { at: a.agentSeenAt, client: a.agentClient ?? null } : null; } noteAgent(accountId, client = null) { const holder = own(this.state.meta.accounts ?? {}, accountId); if (!holder || holder.agentSeenAt) return holder?.agentSeenAt ?? null; holder.agentSeenAt = nowISO(); holder.agentClient = client ? String(client).slice(0, 80) : null; this.save(); this.#audit('agent-connected', { name: holder.name, ...(holder.agentClient ? { client: holder.agentClient } : {}) }); return holder.agentSeenAt; } setRequireAuth(on) { this.state.meta.requireAuth = !!on; this.save(); this.#audit(on ? 'auth-required-on' : 'auth-required-off'); return this.state.meta.requireAuth; } billing() { const m = this.state.meta; return { slug: m.name, name: m.title || m.name, createdAt: m.createdAt ?? null, trialFrom: m.trialFrom ?? null, billing: { orgId: m.billing?.orgId ?? null } }; } startTrial(at = nowISO()) { if (!this.state.meta.trialFrom) { this.state.meta.trialFrom = at; this.save(); } return this.state.meta.trialFrom; } markTrialMail(stage, at = nowISO()) { if (!TRIAL_STAGES.includes(stage)) throw new WeaveError(`'${stage}' is not a trial mail stage (${TRIAL_STAGES.join(', ')})`, 'invalid'); const marks = this.state.meta.trialMail ?? (this.state.meta.trialMail = {}); if (!marks[stage]) { marks[stage] = at; this.save(); } return marks[stage]; } linkBillingOrg(orgId) { const id = String(orgId ?? '').trim(); if (!/^org_[A-Za-z0-9]+$/.test(id)) throw new WeaveError(`'${orgId}' is not a Clerk organization id`, 'invalid'); if (this.state.meta.billing?.orgId === id) return id; this.state.meta.billing = { ...(this.state.meta.billing ?? {}), orgId: id }; this.save(); this.#audit('billing-org-linked', { orgId: id }); return id; } static SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; static LAST_SEEN_MS = 60 * 60 * 1000; #account(ref) { const accounts = this.state.meta.accounts ?? {}; const a = own(accounts, ref) ?? Object.values(accounts).find((x) => x.name === ref); if (!a) throw new WeaveError(`Account '${ref}' not found`, 'not-found'); return a; } #hash(token) { return createHash('sha256').update(String(token)).digest('hex'); } createSession(accountRef, { ua = '' } = {}) { const a = this.#account(accountRef); const sessions = (this.state.meta.sessions ??= {}); const now = Date.now(); for (const [h, s] of Object.entries(sessions)) if (Date.parse(s.expiresAt) <= now) delete sessions[h]; const token = randomBytes(32).toString('base64url'); const at = new Date(now).toISOString(); const expiresAt = new Date(now + Weave.SESSION_TTL_MS).toISOString(); sessions[this.#hash(token)] = { accountId: a.id, createdAt: at, expiresAt, lastSeenAt: at, ua: String(ua ?? '').slice(0, 200) }; this.save(); this.#audit('session-created', { name: a.name }); this.#syncUsers(); return { token, expiresAt, account: { id: a.id, name: a.name, role: a.role } }; } verifySession(token) { if (!token) return null; const h = this.#hash(token); const s = this.state.meta.sessions?.[h]; if (!s) return null; const now = Date.now(); if (Date.parse(s.expiresAt) <= now) { delete this.state.meta.sessions[h]; this.save(); return null; } if (s.removedAt) return null; const a = this.state.meta.accounts?.[s.accountId]; if (!a) return null; const renewed = now - Date.parse(s.lastSeenAt) > 60 * 1000; if (renewed) { s.lastSeenAt = new Date(now).toISOString(); s.expiresAt = new Date(now + Weave.SESSION_TTL_MS).toISOString(); this.save(); if (this.#lastSeenStale(a.id, now)) this.#syncUsers(); } const { tokenHash, ...pub } = a; return { ...pub, sessionId: h, expiresAt: s.expiresAt, renewed }; } #lastSeenStale(accountId, now) { const t = this.#sysTable('users'); if (!t) return false; const row = this.#sysRow('users', accountId); const at = Date.parse(row?.values[this.#sysField(t, 'Last Seen')?.id] ?? ''); return !(now - at < Weave.LAST_SEEN_MS); } removedSession(token) { if (!token) return null; const s = this.state.meta.sessions?.[this.#hash(token)]; if (!s?.removedAt || Date.parse(s.expiresAt) <= Date.now()) return null; return { removedAt: s.removedAt }; } listSessions(accountRef) { const a = this.#account(accountRef); const now = Date.now(); return Object.entries(this.state.meta.sessions ?? {}) .filter(([, s]) => s.accountId === a.id && Date.parse(s.expiresAt) > now) .map(([id, s]) => ({ id, ...s })) .sort((x, y) => y.lastSeenAt.localeCompare(x.lastSeenAt)); } revokeSession(accountRef, { id = null, all = false, except = null } = {}) { const a = this.#account(accountRef); const sessions = this.state.meta.sessions ?? {}; const gone = []; for (const [h, s] of Object.entries(sessions)) { if (s.accountId !== a.id) continue; if (h === except) continue; if (all || (id && h.startsWith(id))) { delete sessions[h]; gone.push(h); } } if (!all && id && !gone.length) throw new WeaveError(`Session '${id}' not found on '${a.name}'`, 'not-found'); if (gone.length) { this.save(); this.#audit('session-revoked', { name: a.name, count: gone.length, all: !!all }); } return { revoked: gone.length }; } static INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000; #issuer(issuer) { return String(issuer ?? '').trim().replace(/\/+$/, ''); } linkIdentity(accountRef, { issuer, email } = {}) { const a = this.#account(accountRef); const iss = this.#issuer(issuer); if (!iss) throw new WeaveError('An identity needs the issuer of the provider that vouches for it', 'invalid'); if (email !== undefined) throw new WeaveError('weave no longer links by email (Feature #252): link mints an invite link, and the person who opens it and signs in is linked', 'invalid'); const { code, expiresAt } = this.#mintInvite({ accountId: a.id, issuer: iss }); this.#audit('identity-invited', { name: a.name, issuer: iss }); return { account: a.name, issuer: iss, code, expiresAt }; } #mintInvite(entry) { const invites = (this.state.meta.identityInvites ??= {}); const now = Date.now(); for (const [h, i] of Object.entries(invites)) if (Date.parse(i.expiresAt) <= now) delete invites[h]; const code = 'wvi_' + randomBytes(32).toString('base64url'); const h = this.#hash(code); const expiresAt = new Date(now + Weave.INVITE_TTL_MS).toISOString(); invites[h] = { ...entry, createdAt: new Date(now).toISOString(), expiresAt }; this.save(); return { code, h, expiresAt }; } #invite(code) { const h = this.#hash(code); const i = code ? this.state.meta.identityInvites?.[h] : null; if (!i || Date.parse(i.expiresAt) <= Date.now()) return null; if (!i.accountId) return { h, i, a: null }; const a = this.state.meta.accounts?.[i.accountId]; return a ? { h, i, a } : null; } inviteMember({ email, role: asked = 'editor', issuer } = {}) { const iss = this.#issuer(issuer); if (!iss) throw new WeaveError('Inviting needs a sign-in provider: set WEAVE_OIDC_ISSUER (the issuer the person signs in at)', 'invalid'); const mail = String(email ?? '').trim().toLowerCase(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(mail)) throw new WeaveError(`An invite needs an email address (got '${email ?? ''}')`, 'invalid'); const role = Weave.roleName(asked); if (!Weave.ROLES.includes(role)) throw new WeaveError(`Invalid role '${asked}' (${Weave.ROLES.join(', ')})`, 'invalid'); if (this.listInvites().some((i) => i.email === mail)) throw new WeaveError(`${mail} already has a pending invite; revoke it to send a new one`, 'conflict'); const { code, h } = this.#mintInvite({ accountId: null, issuer: iss, email: mail, role, invitedBy: this.actor }); this.#audit('member-invited', { role, issuer: iss }); this.#syncUsers(); return { ...this.listInvites().find((i) => i.id === h), code }; } listInvites() { const now = Date.now(); return Object.entries(this.state.meta.identityInvites ?? {}) .filter(([, i]) => !i.accountId && Date.parse(i.expiresAt) > now) .map(([id, i]) => ({ id, email: i.email, name: this.#inviteName(i), role: i.role, roleLabel: Weave.ROLE_LABELS[i.role], workspace: this.state.meta.name, invitedBy: this.actorName(i.invitedBy), createdAt: i.createdAt, expiresAt: i.expiresAt })) .sort((x, y) => x.createdAt.localeCompare(y.createdAt)); } revokeInvite(id) { const invites = this.state.meta.identityInvites ?? {}; const key = String(id ?? '').length >= 8 ? Object.keys(invites).find((h) => h.startsWith(String(id))) : null; if (!key) throw new WeaveError(`Invite '${id ?? ''}' not found`, 'not-found'); delete invites[key]; this.save(); this.#audit('invite-revoked', {}); this.#syncUsers(); return { revoked: 1 }; } #inviteName(i) { const wanted = String(i.name ?? '').trim(); if (wanted && !Object.values(this.state.meta.accounts ?? {}).some((a) => a.name === wanted)) { try { refuseReserved('account', wanted); return wanted; } catch {} } return this.#memberName(i.email); } renameAccount(ref, name) { const a = this.#account(ref); const next = String(name ?? '').trim(); if (!next) throw new WeaveError('Account name is required', 'invalid'); if (next === a.name) return { id: a.id, name: a.name }; refuseReserved('account', next); if (Object.values(this.state.meta.accounts ?? {}).some((x) => x.name === next)) throw new WeaveError(`Account '${next}' already exists`, 'conflict'); const from = a.name; a.name = next; this.save(); this.#renameKeyHolder(from, next); this.#audit('account-renamed', { from, name: next }); this.#syncUsers(); return { id: a.id, name: next }; } setAccountRole(ref, asked) { const a = this.#account(ref); const role = Weave.roleName(String(asked ?? '').toLowerCase()); if (!Weave.ROLES.includes(role)) throw new WeaveError(`Invalid role '${asked}' (${Weave.ROLES.join(', ')})`, 'invalid'); if (role === a.role) return { id: a.id, role }; if (Weave.roleName(a.role) === 'architect' && role !== 'architect' && !Object.values(this.state.meta.accounts).some(x => x.id !== a.id && Weave.roleName(x.role) === 'architect')) { throw new WeaveError(`${a.name} is the last architect: make someone else an architect before changing this role`, 'invalid'); } a.role = role; this.save(); this.#audit('account-role-changed', { name: a.name, role }); this.#syncUsers(); return { id: a.id, role }; } #memberName(email) { const base = String(email ?? '').split('@')[0].toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '') || 'member'; const taken = new Set(Object.values(this.state.meta.accounts ?? {}).map((a) => a.name)); for (let n = 1; ; n++) { const name = n === 1 ? base : `${base}-${n}`; if (taken.has(name)) continue; try { refuseReserved('account', name); return name; } catch {} } } identityInvite(code) { const found = this.#invite(code); return found ? { account: found.a?.name ?? this.#inviteName(found.i), issuer: found.i.issuer, expiresAt: found.i.expiresAt } : null; } invitesForEmail(email) { const mail = String(email ?? '').trim().toLowerCase(); return mail ? this.listInvites().filter((i) => i.email === mail) : []; } acceptInvite(id, who = {}) { const mail = String(who.email ?? '').trim().toLowerCase(); const i = Object.hasOwn(this.state.meta.identityInvites ?? {}, String(id)) ? this.state.meta.identityInvites[id] : null; if (!mail || !i || i.accountId || i.email !== mail || Date.parse(i.expiresAt) <= Date.now()) throw new WeaveError('This invite expired, was already used or is for another address', 'not-found'); return this.#redeem({ h: String(id), i, a: null }, who); } redeemIdentityInvite(code, who = {}) { const found = this.#invite(code); if (!found) throw new WeaveError('This invite expired or was already used', 'not-found'); return this.#redeem(found, who); } #redeem({ h, i, a }, { issuer, subject, email } = {}) { const mail = String(email ?? '').trim().toLowerCase(); const stamp = (x) => { if (mail) x.verifiedEmail = mail; return x; }; const iss = this.#issuer(issuer); if (iss !== i.issuer) throw new WeaveError('This invite is for another provider', 'invalid'); if (!subject) throw new WeaveError('The provider named nobody', 'invalid'); const sub = String(subject); for (const other of Object.values(this.state.meta.accounts ?? {})) { if (other.id !== a?.id && (other.identities ?? []).some((x) => x.issuer === iss && x.subject === sub)) { throw new WeaveError(`This identity already opens '${other.name}'`, 'conflict'); } } delete this.state.meta.identityInvites[h]; const at = nowISO(); if (!a) { const { account } = this.#createAccount({ name: this.#inviteName(i), role: i.role }, i.rowId ?? uuid()); const row = this.state.meta.accounts[account.id]; row.identities = [stamp({ issuer: iss, subject: sub, createdAt: at, lastUsedAt: at })]; row.onboardedAt = at; this.save(); this.#audit('member-joined', { name: row.name, role: row.role, invitedBy: i.invitedBy ?? null }); this.#syncUsers(); const { tokenHash, ...pub } = row; return pub; } let identity = (a.identities ?? []).find((x) => x.issuer === iss && x.subject === sub); if (!identity) { identity = stamp({ issuer: iss, subject: sub, createdAt: at, lastUsedAt: at }); (a.identities ??= []).push(identity); this.#audit('identity-linked', { name: a.name, issuer: iss }); } else stamp(identity).lastUsedAt = at; this.save(); this.#syncUsers(); const { tokenHash, ...pub } = a; return pub; } unlinkIdentity(accountRef, { issuer = null, subject } = {}) { const a = this.#account(accountRef); const sub = String(subject ?? ''); const iss = issuer ? this.#issuer(issuer) : null; const keep = (a.identities ?? []).filter((i) => !(i.subject === sub && (!iss || i.issuer === iss))); const unlinked = (a.identities ?? []).length - keep.length; if (!sub || !unlinked) throw new WeaveError(`No identity '${sub}' on '${a.name}'`, 'not-found'); a.identities = keep; this.save(); this.#audit('identity-unlinked', { name: a.name, ...(iss ? { issuer: iss } : {}) }); this.#syncUsers(); return { unlinked, remaining: keep.length }; } accountForIdentity({ issuer, subject, email } = {}, { touch = true } = {}) { if (!issuer || !subject) return null; for (const a of Object.values(this.state.meta.accounts ?? {})) { const i = (a.identities ?? []).find((x) => x.issuer === issuer && x.subject === String(subject)); if (!i) continue; if (touch) { i.lastUsedAt = nowISO(); const mail = String(email ?? '').trim().toLowerCase(); if (mail) i.verifiedEmail = mail; this.save(); this.#syncUsers(); } const { tokenHash, ...pub } = a; return pub; } return null; } #scrubIdentityEmails() { const meta = this.state.meta; let changed = false; for (const a of Object.values(meta.accounts ?? {})) { if (!a.identities?.some((i) => 'email' in i || !i.subject)) continue; a.identities = a.identities.filter((i) => i.subject).map(({ email, emailVerified, ...rest }) => rest); changed = true; } for (const r of this.store.listAudit({ limit: -1, actions: ['identity-linked', 'identity-unlinked', 'identity-pinned'] })) { if (!r.detail || !('email' in r.detail)) continue; const { email, ...rest } = r.detail; this.store.setAuditDetail(r.seq, rest); } return changed; } #inMetaSync = false; #oneTime = new Map(); inviteIssuer = null; localKeyAccess = null; registryHost = null; members = []; get #reg() { return this.registryHost ?? this; } #engines() { const r = this.#reg; return [r, ...r.members]; } #engineOf(wsId) { if (!wsId || wsId === this.state.meta.id) return this; return this.#engines().find((w) => w.state.meta.id === wsId) ?? this; } #wsIdOfRow(row) { const reg = this.#reg; const db = reg.state.tables[row.dbId]; if (!db?.system) return null; if (db.system === 'workspaces') return row.sysId ?? null; const f = this.#sysField(db, 'Workspace'); const wsRow = f && row.values[f.id] ? reg.state.entities[row.values[f.id]] : null; return wsRow?.sysId ?? null; } #ownerOf(row) { return this.#engineOf(this.#wsIdOfRow(row)); } #tableAnywhere(id) { for (const w of this.#engines()) { const table = own(w.state.tables, id); if (table) return { owner: w, table }; } return null; } #fieldAnywhere(fieldId) { for (const w of this.#engines()) { const table = Object.values(w.state.tables).find((t) => own(t.fields, fieldId)); if (table) return { owner: w, table, field: table.fields[fieldId] }; } return null; } #retireDetachedOrphans(peers, sourceIds) { const structural = new Set(['workspaces', 'spaces', 'tables', 'fields', 'views']); const exists = (state, kind, id) => kind === 'spaces' ? Object.hasOwn(state.spaces, id) : kind === 'tables' ? Object.hasOwn(state.tables, id) : kind === 'fields' ? Object.values(state.tables).some(t => Object.hasOwn(t.fields, id)) : kind === 'views' ? Object.values(state.tables).some(t => t.tableViews?.some(v => v.id === id)) : true; const rows = Object.values(this.state.entities); const orphans = new Set(); for (const row of rows) { const table = this.state.tables[row.dbId]; const kind = table?.system; if (row.deletedAt || !kind) continue; let workspaceId = this.#wsIdOfRow(row); if (!workspaceId && kind === 'forms') { const target = row.values[this.#sysField(table, 'Table')?.id]; const targetRow = this.state.entities[Array.isArray(target) ? target[0] : target]; if (targetRow) workspaceId = this.#wsIdOfRow(targetRow); } if (!sourceIds.has(row.id) && workspaceId && workspaceId !== this.state.meta.id) { orphans.add(row.id); continue; } if (!row.sysId || !structural.has(kind) || exists(this.state, kind, row.sysId)) continue; if (peers.some(w => exists(w.state, kind, row.sysId))) throw new WeaveError(`Cannot detach ${this.state.meta.name}: registry subject ${row.sysId} still exists in another workspace or before migration`, 'invalid'); if (kind === 'tables' && rows.some(e => e.dbId === row.sysId && !e.deletedAt)) throw new WeaveError(`Cannot detach ${this.state.meta.name}: orphan table ${row.sysId} still contains live rows`, 'invalid'); orphans.add(row.id); } if (!orphans.size) return; const navigation = []; for (const row of rows) { if (row.deletedAt || orphans.has(row.id)) continue; const table = this.state.tables[row.dbId]; if (!table || table.deletedAt) continue; for (const f of Object.values(table.fields)) { if (f.type !== 'relation' || ![].concat(row.values[f.id] ?? []).some(id => orphans.has(id))) continue; if (!f.system || !structural.has(table.system)) throw new WeaveError(`Cannot detach ${this.state.meta.name}: live reference ${table.name}.${f.name} points to an orphan registry row`, 'invalid'); navigation.push([row, f.id]); } for (const doc of Object.values(row.docs ?? {})) { String(doc).replace(/(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`]*`)|(\[\[[^\]\n]+\]\])/g, (token, code, wiki) => { if (code) return token; const ref = wiki.slice(2, -2).split('|', 1)[0].trim(); let target; try { target = this.getEntity(ref); } catch { return token; } if (orphans.has(target.id)) throw new WeaveError(`Cannot detach ${this.state.meta.name}: live document reference points to an orphan registry row`, 'invalid'); return token; }); } } for (const [row, fieldId] of navigation) { const value = row.values[fieldId]; row.values[fieldId] = Array.isArray(value) ? value.filter(id => !orphans.has(id)) : null; this.#mark(row); } for (const id of orphans) { this.state.entities[id].deletedAt = nowISO(); this.#mark(id); } } detachHubRegistry(members = []) { const legacy = members.filter(w => w !== this && w.state.meta.registry === 'hub' && !w.state.meta.localRegistry); const resumed = members.filter(w => w.state.meta.localRegistry?.source === this.state.meta.id); if (!legacy.length && !resumed.length) return { migrated: 0 }; for (const w of legacy) { if (w.state.meta.requireAuth && !w.listAccounts().length) throw new WeaveError(`Cannot detach ${w.state.meta.name}: this protected workspace has no local account; create an explicit local account or invite before upgrading`, 'invalid'); } const plans = legacy.map(w => ({ w, ...detachedRegistryState(this, w, members) })); const moving = new Set([...plans.flatMap(p => [...p.ids]), ...resumed.flatMap(w => w.state.meta.localRegistry.rows ?? [])]); for (const w of resumed) { for (const id of w.state.meta.localRegistry.rows ?? []) { if (!w.state.entities[id] && this.state.entities[id] && !this.state.entities[id].deletedAt) throw new WeaveError(`Cannot detach ${w.state.meta.name}: copied registry row ${id} is missing`, 'invalid'); } } for (const table of Object.values(this.state.tables)) { for (const f of Object.values(table.fields)) { if (f.type !== 'relation' || f.system) continue; for (const row of Object.values(this.state.entities).filter(r => r.dbId === table.id && !r.deletedAt && !moving.has(r.id))) { if ([].concat(row.values[f.id] ?? []).some(id => moving.has(id))) throw new WeaveError(`Cannot detach: cross-workspace relation ${table.name}.${f.name}`, 'invalid'); } } } for (const plan of plans) { const check = new Weave(); check.state = structuredClone(plan.state); check.#dirtyAll = true; check.#retireDetachedOrphans([this, ...members], plan.ids); check.#ensureMetaTables(); for (const table of check.listTables()) { for (const f of Object.values(table.fields)) { if (f.type !== 'formula') continue; const result = check.checkFormula(table.id, f.config.expression, { excludeField: f.id }); if (!result.ok) throw new WeaveError(`Cannot detach ${plan.state.meta.name}: invalid formula ${table.name}.${f.name}: ${result.error}`, 'invalid'); } } const problems = check.registryReport().problems; if (problems.length) throw new WeaveError(`Cannot detach ${plan.state.meta.name}: ${problems[0].name}: ${problems[0].problem}`, 'invalid'); plan.state = check.state; HELD?.delete(check); } for (const plan of plans) { const pending = { blobs: new Map(), storage: new Map(), revisions: [] }; const collect = (origin, row, entityId, mapField = id => id) => { for (const file of row.files ?? []) { for (const id of [file.id, file.preview?.id].filter(Boolean)) { const bytes = origin.#readBlob(id); if (!bytes) throw new WeaveError(`Cannot detach ${plan.state.meta.name}: file blob ${id} is missing`, 'invalid'); const existing = pending.blobs.get(id) ?? plan.w.#readBlob(id); if (existing && !existing.equals(bytes)) throw new WeaveError(`Cannot detach ${plan.state.meta.name}: conflicting file blob ${id}`, 'invalid'); pending.blobs.set(id, bytes); } const storage = origin.store.getFileStorage(file.id); if (storage) { const existing = pending.storage.get(file.id) ?? plan.w.store.getFileStorage(file.id); if (existing && existing.json !== storage.json) throw new WeaveError(`Cannot detach ${plan.state.meta.name}: conflicting page storage ${file.id}`, 'invalid'); pending.storage.set(file.id, storage); } } for (const f of Object.values(origin.state.tables[row.dbId]?.fields ?? {})) { if (f.type !== 'document') continue; const rows = origin.store.listDocRevisions(row.id, f.id, { limit: 200 }).reverse().map(r => ({ ...origin.store.getDocRevision(row.id, f.id, r.seq), actor: plan.moveActor(r.actor), text: plan.rewriteDoc(origin.store.getDocRevision(row.id, f.id, r.seq).text) })); if (rows.length) pending.revisions.push({ entityId, fieldId: mapField(f.id), rows }); } }; for (const id of plan.ids) collect(this, this.state.entities[id], id); for (const row of Object.values(plan.w.state.entities)) { if (!plan.rowMap.has(row.id) && !Object.keys(row.docs ?? {}).some(id => plan.fieldMap.has(id))) continue; collect(plan.w, row, plan.rowMap.get(row.id) ?? row.id, id => plan.fieldMap.get(id) ?? id); } const groups = new Map(); for (const group of pending.revisions) { const key = `${group.entityId}:${group.fieldId}`; const signatures = groups.get(key) ?? new Set(plan.w.store.listDocRevisions(group.entityId, group.fieldId, { limit: 200 }).map(r => { const revision = plan.w.store.getDocRevision(group.entityId, group.fieldId, r.seq); return JSON.stringify([revision.at, revision.actor, revision.text]); })); for (const r of group.rows) signatures.add(JSON.stringify([r.at, r.actor, r.text])); if (signatures.size > 200) throw new WeaveError(`Cannot detach ${plan.state.meta.name}: combined document history exceeds 200 revisions`, 'invalid'); groups.set(key, signatures); } plan.storage = pending; } const rollups = []; for (const t of Object.values(this.state.tables)) { if (!t.system) continue; for (const f of Object.values(t.fields)) { if (f.type !== 'rollup' || !f.config.via) continue; const owner = [...plans.map(p => p.state), ...resumed.map(w => w.state)].find(state => state.tables[f.config.via]); if (!owner) continue; const copied = Object.values(owner.tables).find(r => r.system === t.system)?.fields[f.id]; if (!copied && resumed.some(w => w.state === owner)) continue; if (!copied) throw new WeaveError(`Cannot detach: copied rollup ${t.name}.${f.name} is missing`, 'invalid'); rollups.push([t, f.id]); } } if (!plans.length && !rollups.length && ![...moving].some(id => this.state.entities[id] && !this.state.entities[id].deletedAt)) return { migrated: 0 }; if (HELD) { HELD.delete(this); for (const { w } of plans) { HELD.delete(w); HELD.add(w); } HELD.add(this); } for (const w of resumed) if (w.#detachedStorage) { if (HELD) HELD.add(w); w.save(); } for (const { w, state, storage } of plans) { w.#detachedStorage = storage; w.state = state; w.registryHost = null; w.members = []; w.#dirtyAll = true; w.save(); } this.members = this.members.filter(w => !legacy.includes(w)); for (const id of moving) { const row = this.state.entities[id]; if (row && !row.deletedAt) { row.deletedAt = nowISO(); this.#mark(row); } } for (const [t, id] of rollups) { delete t.fields[id]; t.fieldOrder = t.fieldOrder.filter(fieldId => fieldId !== id); } this.state.meta.detachedRegistry = { rows: [...new Set([...(this.state.meta.detachedRegistry?.rows ?? []), ...moving])], files: [...new Set([...(this.state.meta.detachedRegistry?.files ?? []), ...[...moving].flatMap(id => (this.state.entities[id]?.files ?? []).flatMap(f => [f.id, f.preview?.id].filter(Boolean)))])], }; this.state.meta.localRegistry = 1; this.save(); return { migrated: plans.length }; } joinRegistry(root) { if (root === this) throw new WeaveError('A workspace cannot join its own registry', 'invalid'); this.registryHost = root; if (!root.members.includes(this)) root.members.push(this); let dirty = false; if (this.state.meta.registry !== 'hub') { this.state.meta.registry = 'hub'; dirty = true; } const rollups = []; for (const sp of Object.values(this.state.spaces)) { if (sp.system !== 'workspace' || sp.deletedAt) continue; sp.deletedAt = nowISO(); dirty = true; for (const t of Object.values(this.state.tables)) { if (t.spaceId !== sp.id || t.deletedAt) continue; if (t.system === 'spaces') for (const f of Object.values(t.fields)) if (f.type === 'rollup' && f.config.via) rollups.push(f); t.deletedAt = nowISO(); } } if (dirty) this.save(); this.#syncAll(); adoptForms(this, root); const spacesT = root.#sysTable('spaces'); for (const f of rollups) { const via = this.state.tables[f.config.via]; if (!via || via.deletedAt) continue; const name = root.findField(spacesT, f.name) ? `${f.name} (${this.state.meta.name})` : f.name; if (root.findField(spacesT, name)) continue; const config = { via: f.config.via, aggregate: f.config.aggregate, ...(f.config.targetField ? { targetField: f.config.targetField } : {}), ...(f.config.where ? { where: f.config.where } : {}) }; try { root.addField(spacesT.id, { name, type: 'rollup', config }); } catch {} } this.#migrateAutomations(); root.#settleWorkflows(); return this; } hostRegistry() { if (this.state.meta.registry !== 'hub') return this; delete this.state.meta.registry; this.registryHost = null; for (const sp of Object.values(this.state.spaces)) { if (sp.system !== 'workspace') continue; sp.deletedAt = null; for (const t of Object.values(this.state.tables)) if (t.spaceId === sp.id) t.deletedAt = null; } this.#ensureMetaTables(); this.#settlePositions(); this.save(); return this; } dropWorkspace(wsId) { this.members = this.members.filter((m) => m.state.meta.id !== wsId); for (const kind of ['users', 'views', 'fields', 'tables', 'spaces']) { const t = this.#sysTable(kind); if (!t) continue; for (const row of this.listEntities(t.id, { includeDeleted: true })) { if (this.#wsIdOfRow(row) === wsId) this.#metaSync(() => this.deleteEntity(row.id, { hard: true })); } } const wsRow = this.#sysRow('workspaces', wsId); if (wsRow) this.#metaSync(() => this.deleteEntity(wsRow.id, { hard: true })); this.save(); } #syncAll() { if (!this.#sysTable('spaces')) return; this.#syncWorkspaceRow(); for (const sp of Object.values(this.state.spaces)) this.#syncSpaceRow(sp); for (const t of Object.values(this.state.tables)) this.#syncTableRow(t); for (const t of Object.values(this.state.tables)) { if (t.system) continue; for (const f of Object.values(t.fields)) this.#syncFieldRow(t, f); } this.#syncUsers(); } #userRows() { const meta = this.state.meta; const now = Date.now(); const rows = new Map(); const seen = {}; for (const s of Object.values(meta.sessions ?? {})) { if (s.accountId && !s.removedAt && (!seen[s.accountId] || s.lastSeenAt > seen[s.accountId])) seen[s.accountId] = s.lastSeenAt; } for (const a of Object.values(meta.accounts ?? {})) { const ids = a.identities ?? []; const last = [seen[a.id], ...ids.map((i) => i.lastUsedAt)].filter(Boolean).sort().pop() ?? null; rows.set(a.id, { Name: a.name, Email: ids.find((i) => i.verifiedEmail)?.verifiedEmail ?? '', Role: capital(Weave.roleName(a.role)), Status: 'Active', Kind: ids.length ? 'Person' : 'Agent', Identities: ids.length, 'Last Seen': last, Created: a.createdAt ?? null, }); } let minted = false; for (const i of Object.values(meta.identityInvites ?? {})) { if (i.accountId || Date.parse(i.expiresAt) <= now) continue; if (!i.rowId) { i.rowId = uuid(); minted = true; } rows.set(i.rowId, { Name: this.#inviteName(i), Email: i.email, Role: capital(i.role), Status: 'Invited', Kind: 'Person', Identities: 0, 'Last Seen': null, Created: i.createdAt ?? null, }); } if (minted) this.save(); return rows; } #syncUsers() { const t = this.#sysTable('users'); if (!t) return; const reg = this.#reg; const wsRow = this.#sysRow('workspaces', this.state.meta.id); const want = this.#userRows(); for (const row of reg.listEntities(t.id, { includeDeleted: true })) { if ((this.#wsIdOfRow(row) ?? reg.state.meta.id) !== this.state.meta.id) continue; if (!row.sysId || !want.has(row.sysId)) reg.#metaSync(() => reg.deleteEntity(row.id, { hard: true })); } for (const [sysId, values] of want) { const row = this.#sysRow('users', sysId); if (!row) { const made = reg.#metaSync(() => reg.createEntity(t.id, { values: { ...values, ...(wsRow ? { Workspace: wsRow.id } : {}) } })); made.sysId = sysId; reg.#mark(made); reg.save(); continue; } const patch = {}; for (const [k, v] of Object.entries(values)) { const f = this.#sysField(t, k); if (f && JSON.stringify(row.values[f.id] ?? null) !== JSON.stringify(reg.#validateValue(f, v) ?? null)) patch[k] = v; } if (row.deletedAt) reg.#metaSync(() => reg.restoreEntity(row.id)); if (Object.keys(patch).length) reg.#metaSync(() => reg.updateEntity(row.id, patch)); } } #userTarget(row) { const owner = this.#ownerOf(row); const account = own(owner.state.meta.accounts ?? {}, row.sysId); if (account) return { owner, account }; const entry = Object.entries(owner.state.meta.identityInvites ?? {}).find(([, i]) => i.rowId === row.sysId); if (entry) return { owner, inviteKey: entry[0], invite: entry[1] }; throw new WeaveError(`Users record '${this.entityName(row)}' describes no account or invite`, 'not-found'); } #userValues(input) { const flat = Object.fromEntries(Object.entries(input ?? {}).filter(([k]) => !CREATE_INPUT_KEYS.has(k))); return { ...flat, ...(input?.values ?? {}) }; } #createUserRow(db, input) { const values = this.#userValues(input); const name = String(input?.name ?? values.Name ?? '').trim(); const email = String(values.Email ?? '').trim(); const role = String(values.Role ?? 'editor').toLowerCase(); const kind = String(values.Kind ?? (email ? 'Person' : 'Agent')).toLowerCase(); for (const k of ['Status', 'Identities', 'Last Seen', 'Created']) { if (values[k] != null && values[k] !== '') throw new WeaveError(`'${k}' is kept by weave and cannot be set on a Users record`, 'invalid'); } let owner = this; if (values.Workspace != null) { const wsRow = this.findEntity(this.#sysTable('workspaces').id, values.Workspace) ?? this.#sysRow('workspaces', values.Workspace); if (!wsRow) throw new WeaveError(`Workspace record '${values.Workspace}' not found`, 'not-found'); owner = this.#engineOf(wsRow.sysId); } for (const k of ['Name', 'Email', 'Role', 'Kind', 'Workspace', 'Status', 'Identities', 'Last Seen', 'Created']) delete values[k]; let sysId; let once; if (email) { if (kind === 'agent') throw new WeaveError('An agent has no Email: leave Email empty and give it a Name', 'invalid'); const made = owner.inviteMember({ email, role, issuer: this.#reg.inviteIssuer ?? process.env.WEAVE_OIDC_ISSUER }); const entry = owner.state.meta.identityInvites[made.id]; entry.rowId ??= uuid(); if (name) entry.name = name; owner.save(); sysId = entry.rowId; once = { invite: { code: made.code, expiresAt: made.expiresAt } }; } else if (kind === 'agent') { if (!name) throw new WeaveError('An agent account needs a Name', 'invalid'); const made = owner.createAccount({ name, role }); sysId = made.account.id; once = { token: made.token }; } else { throw new WeaveError('A Users record needs an Email to invite a person, or Kind Agent and a Name to make an agent account', 'invalid'); } owner.#syncUsers(); const row = this.#sysRow('users', sysId); if (Object.keys(values).length) this.#metaSync(() => this.updateEntity(row.id, values)); this.#reg.#oneTime.set(row.id, once); return this.getEntity(row.id); } #updateUserRow(e, db, valuesByName) { const patch = { ...valuesByName }; for (const k of [...USER_KEPT, 'Workspace']) { if (!(k in patch)) continue; const f = this.#sysField(db, k); if (f && JSON.stringify(this.#validateValue(f, patch[k]) ?? null) !== JSON.stringify(e.values[f.id] ?? null)) { throw new WeaveError(`'${k}' is kept by weave and cannot be edited on a Users record`, 'invalid'); } delete patch[k]; } if ('Name' in patch || 'Role' in patch) { const { owner, account, invite } = this.#userTarget(e); if ('Role' in patch) { if (account) owner.setAccountRole(account.id, patch.Role); else owner.#setInviteRole(invite, patch.Role); } if ('Name' in patch) { if (account) owner.renameAccount(account.id, patch.Name); else { invite.name = String(patch.Name ?? '').trim() || undefined; owner.save(); owner.#syncUsers(); } } delete patch.Name; delete patch.Role; } if (Object.keys(patch).length) this.#metaSync(() => this.updateEntity(e.id, patch)); return this.getEntity(e.id); } #setInviteRole(invite, asked) { const role = Weave.roleName(String(asked ?? '').toLowerCase()); if (!Weave.ROLES.includes(role)) throw new WeaveError(`Invalid role '${asked}' (${Weave.ROLES.join(', ')})`, 'invalid'); invite.role = role; this.save(); this.#audit('invite-role-changed', { role }); this.#syncUsers(); } #deleteUserRow(e) { const { owner, account, inviteKey } = this.#userTarget(e); if (account) owner.deleteAccount(account.id); else { delete owner.state.meta.identityInvites[inviteKey]; owner.save(); owner.#audit('invite-revoked', {}); owner.#syncUsers(); } return { id: e.id, purged: true }; } takeOneTime(id) { const reg = this.#reg; const once = reg.#oneTime.get(id) ?? null; reg.#oneTime.delete(id); return once; } #syncWorkspaceRow() { const t = this.#sysTable('workspaces'); if (!t) return undefined; const reg = this.#reg; const meta = this.state.meta; const desc = meta.description ?? ''; let row = this.#sysRow('workspaces', meta.id); if (!row) { row = reg.#metaSync(() => reg.createEntity(t.id, { name: meta.name, values: { Description: desc } })); row.sysId = meta.id; reg.#mark(row); if (meta.deletedAt) reg.#metaSync(() => reg.deleteEntity(row.id)); reg.save(); return row; } const patch = {}; if (reg.entityName(row) !== meta.name) patch.Name = meta.name; const descF = this.#sysField(t, 'Description'); if ((row.values[descF.id] ?? '') !== desc) patch.Description = desc; if (Object.keys(patch).length) reg.#metaSync(() => reg.updateEntity(row.id, patch)); if (!!meta.deletedAt !== !!row.deletedAt) reg.#metaSync(() => (meta.deletedAt ? reg.deleteEntity(row.id) : reg.restoreEntity(row.id))); return row; } syncRegistry() { this.#syncAll(); return this; } #metaSync(fn) { const was = this.#inMetaSync; this.#inMetaSync = true; try { return fn(); } finally { this.#inMetaSync = was; } } #inUndo = false; #undoOwner = null; #recordUndo(kind, e, data = {}) { if (this.#inMetaSync || this.#inUndo) return; const db = this.state.tables[e.dbId]; if (!db || db.system) return; if (kind === 'update' && data.before) data = { ...data, after: this.#undoAfter(e, data.before) }; this.store.pushUndo({ ts: nowISO(), actor: this.#undoOwner ?? this.actor, kind, entityId: e.id, table: this.qualifiedName(db), publicId: e.publicId, name: this.entityName(e), data, }); } #undoBefore(e, db, fieldRefs) { const before = { values: {}, docs: {} }; for (const ref of fieldRefs) { const f = typeof ref === 'object' ? ref : this.findField(db, ref); if (!f || COMPUTED_TYPES.includes(f.type)) continue; if (f.type === 'document') before.docs[f.id] = e.docs?.[f.id] ?? ''; else before.values[f.id] = structuredClone(e.values[f.id] ?? null); } return before; } #undoAfter(e, before) { const after = { values: {}, docs: {} }; for (const fid of Object.keys(before.values)) after.values[fid] = structuredClone(e.values[fid] ?? null); for (const fid of Object.keys(before.docs)) after.docs[fid] = docFingerprint(e.docs?.[fid] ?? ''); return after; } #lastWriter(e, fieldName) { for (let i = (e.activity ?? []).length - 1; i >= 0; i--) { const a = e.activity[i]; if (a.kind === 'undo' || a.detail?.field !== fieldName) continue; return this.actorName(a.actor ?? null); } return null; } #undoChanged(e, before) { for (const [fid, prev] of Object.entries(before.values)) { if (JSON.stringify(e.values[fid] ?? null) !== JSON.stringify(prev)) return true; } for (const [fid, prev] of Object.entries(before.docs)) { if ((e.docs?.[fid] ?? '') !== prev) return true; } return false; } undo({ steps = 1 } = {}) { const undone = []; this.#inUndo = true; try { for (let i = 0; i < steps; i++) { const entry = this.store.popUndo(this.actor); if (!entry) break; const summary = { kind: entry.kind, entity: `${entry.table}#${entry.publicId}`, name: entry.name, ts: entry.ts }; const e = this.state.entities[entry.entityId]; if (!e) { undone.push({ ...summary, skipped: 'entity purged' }); continue; } if (this.state.meta.detachedRegistry?.rows.includes(e.id)) { undone.push({ ...summary, skipped: 'entity moved to its workspace' }); continue; } const db = this.state.tables[e.dbId]; switch (entry.kind) { case 'create': case 'restore': this.deleteEntity(e.id); break; case 'delete': this.restoreEntity(e.id); break; case 'update': { const before = entry.data.before ?? { values: {}, docs: {} }; const after = entry.data.after ?? null; const fields = []; const kept = []; for (const [fid, prev] of Object.entries(before.values)) { const f = db.fields[fid]; if (!f) continue; if (after && JSON.stringify(e.values[fid] ?? null) !== JSON.stringify(after.values[fid] ?? null)) { kept.push({ field: f.name, by: this.#lastWriter(e, f.name) }); continue; } fields.push(f.name); if (f.type === 'relation') { const ids = prev == null ? [] : Array.isArray(prev) ? prev : [prev]; this.#setRelationValue(e, db, f, ids.filter((id) => this.state.entities[id])); } else { e.values[fid] = structuredClone(prev); } } for (const [fid, text] of Object.entries(before.docs)) { if (!db.fields[fid]) continue; const was = e.docs[fid] ?? ''; if (after && docFingerprint(was) !== (after.docs[fid] ?? null)) { kept.push({ field: db.fields[fid].name, by: this.#lastWriter(e, db.fields[fid].name) }); continue; } fields.push(db.fields[fid].name); e.docs[fid] = text; if (was !== text) this.#recordDocRevision(e, db.fields[fid], was, text, { fresh: true }); } if (!fields.length && kept.length) { undone.push({ ...summary, skipped: 'changed since', kept }); continue; } e.updatedAt = nowISO(); e.modifiedBy = this.actor; this.#logActivity(e, 'undo', { fields }); this.#mark(e); this.save(); if (kept.length) { undone.push({ ...summary, kept }); continue; } break; } case 'comment-add': e.comments = e.comments.filter((c) => c.id !== entry.data.commentId); this.#mark(e); this.save(); break; case 'comment-delete': e.comments.push(entry.data.comment); this.#mark(e); this.save(); break; case 'file-attach': this.deleteFile(e.id, entry.data.fileId); break; } undone.push(summary); } } finally { this.#inUndo = false; } return { undone }; } listUndo({ limit = 20 } = {}) { return this.store.listUndo({ limit, actor: this.actor }) .map(({ data, ...summary }) => ({ ...summary, actor: this.actorName(summary.actor), entity: `${summary.table}#${summary.publicId}` })); } #sysTable(kind) { if (this.state.meta.registry === 'hub' && !this.registryHost) return undefined; return Object.values(this.#reg.state.tables).find((t) => t.system === kind && !t.deletedAt); } #sysRow(kind, sysId) { const t = this.#sysTable(kind); if (!t) return undefined; return this.#reg.#tableRows(t.id).find((e) => e.sysId === sysId); } #relIds(row, table, fieldName) { const f = this.#sysField(table, fieldName); const v = row.values[f.id]; return Array.isArray(v) ? v : v == null ? [] : [v]; } #sysField(table, name) { return Object.values(table.fields).find((f) => f.name === name); } #ensureMetaTables() { const s = this.state; let ws = Object.values(s.spaces).find((x) => x.system === 'workspace'); if (!ws) { ws = { id: uuid(), name: SYSTEM_SPACE_NAME, description: 'The workspace itself: its spaces and tables, as records.', icon: SYSTEM_SPACE_ICON, system: 'workspace', createdAt: nowISO() }; s.spaces[ws.id] = ws; } else if (ws.name.toLowerCase() === LEGACY_SYSTEM_SPACE_NAME || !ws.icon) { const taken = Object.values(s.spaces).some((x) => x !== ws && !x.deletedAt && x.name.toLowerCase() === SYSTEM_SPACE_NAME.toLowerCase()); if (!taken) ws.name = SYSTEM_SPACE_NAME; ws.icon ||= SYSTEM_SPACE_ICON; this.save(); } const mkTable = (name, kind, description) => { const nameF = { id: uuid(), name: 'Name', type: 'text', config: {}, system: true }; const descF = { id: uuid(), name: 'Description', type: 'text', config: {}, system: true }; const t = { id: uuid(), spaceId: ws.id, name, description, icon: '', system: kind, publicIdCounter: 0, nameFieldId: nameF.id, fields: { [nameF.id]: nameF, [descF.id]: descF }, fieldOrder: [nameF.id, descF.id], createdAt: nowISO(), }; this.#ensureTableViews(t); s.tables[t.id] = t; return t; }; const spacesT = this.#sysTable('spaces') ?? mkTable('Spaces', 'spaces', 'Every space in this workspace, as a record. Creating a record creates the space; renaming it renames the space; hard-deleting it deletes the space and everything in it.'); if (!this.#sysField(spacesT, 'Template')) this.addField(spacesT.id, { name: 'Template', type: 'checkbox' }).system = true; const tablesT = this.#sysTable('tables') ?? mkTable('Tables', 'tables', 'Every table in this workspace, as a record related to its space. Creating a record creates the table; renaming it renames the table; hard-deleting it deletes the table and its records.'); if (!this.#sysField(tablesT, 'Space')) { const { field, inverse } = this.addRelation(tablesT.id, { name: 'Space', targetDb: spacesT.id, cardinality: 'many-to-one', inverseName: 'Tables' }); field.system = true; inverse.system = true; } if (!this.#sysField(tablesT, 'Field Order')) this.addField(tablesT.id, { name: 'Field Order', type: 'text' }).system = true; if (!this.#sysField(tablesT, 'Hidden Fields')) this.addField(tablesT.id, { name: 'Hidden Fields', type: 'text' }).system = true; if (!this.#sysField(tablesT, 'Filter')) this.addField(tablesT.id, { name: 'Filter', type: 'text' }).system = true; if (!this.#sysField(tablesT, 'Sort')) this.addField(tablesT.id, { name: 'Sort', type: 'text' }).system = true; if (!this.#sysField(tablesT, 'Hide Rollups')) this.addField(tablesT.id, { name: 'Hide Rollups', type: 'checkbox' }).system = true; const fieldsT = this.#sysTable('fields') ?? mkTable('Fields', 'fields', 'Every field of every table, as a record related to its table and carrying its definition. Creating a record creates the column; renaming it renames the column; editing its Definition changes the config; hard-deleting it deletes the column.'); if (!this.#sysField(fieldsT, 'Table')) { const { field, inverse } = this.addRelation(fieldsT.id, { name: 'Table', targetDb: tablesT.id, cardinality: 'many-to-one', inverseName: 'Fields' }); field.system = true; inverse.system = true; } if (!this.#sysField(fieldsT, 'Type')) this.addField(fieldsT.id, { name: 'Type', type: 'text' }).system = true; if (!this.#sysField(fieldsT, 'Definition')) this.addField(fieldsT.id, { name: 'Definition', type: 'field', config: { depth: 4 } }).system = true; const viewsT = this.#sysTable('views') ?? mkTable('Views', 'views', 'Every view over every table, as a record related to its table: the columns it shows in order, its state filter and its sort. The first view of a table is its default. Creating a record creates the view; editing it edits the view; deleting it deletes the view.'); if (!this.#sysField(viewsT, 'Table')) { const { field, inverse } = this.addRelation(viewsT.id, { name: 'Table', targetDb: tablesT.id, cardinality: 'many-to-one', inverseName: 'Views' }); field.system = true; inverse.system = true; } for (const [n, type] of [['Fields', 'text'], ['Filter', 'text'], ['Sort', 'text'], ['Search', 'text'], ['Default', 'checkbox'], ['Position', 'number'], ['Frozen', 'number'], ['Widths', 'text'], ['Density', 'text'], ['Show Deleted', 'checkbox'], ['Rollup Row', 'checkbox'], ['Layout', 'text'], ['Group', 'text'], ['Groups', 'text'], ['Picture', 'text']]) { if (!this.#sysField(viewsT, n)) this.addField(viewsT.id, { name: n, type, ...(type === 'number' ? { config: { decimals: 0 } } : {}) }).system = true; } const wfT = this.#sysTable('workflows') ?? mkTable('Workflows', 'workflows', 'Every workflow in this workspace, as a row: an On switch, the tables and spaces it touches, its executable script, version, state, health, last run, and a mermaid diagram of itself.'); if (!this.#sysField(wfT, 'Tables')) { const { field, inverse } = this.addRelation(wfT.id, { name: 'Tables', targetDb: tablesT.id, cardinality: 'many-to-many', inverseName: 'Workflows' }); field.system = true; inverse.system = true; } if (!this.#sysField(wfT, 'Spaces')) { const { field, inverse } = this.addRelation(wfT.id, { name: 'Spaces', targetDb: spacesT.id, cardinality: 'many-to-many', inverseName: 'Workflows' }); field.system = true; inverse.system = true; } if (!this.#sysField(wfT, 'Script')) this.addField(wfT.id, { name: 'Script', type: 'document', config: { kind: 'code' } }).system = true; if (!this.#sysField(wfT, 'Version')) this.addField(wfT.id, { name: 'Version', type: 'number', config: { decimals: 0 } }).system = true; const stateF = this.#sysField(wfT, 'State'); if (!stateF) { this.addField(wfT.id, { name: 'State', type: 'workflow', config: { states: WORKFLOW_STATES } }).system = true; } else if (stateF.config.states.map((s) => s.name).join() !== WORKFLOW_STATES.map((s) => s.name).join()) { stateF.config = normalizeSelfContainedConfig('workflow', { states: WORKFLOW_STATES }); this.save(); } if (!this.#sysField(wfT, 'Health')) { this.addField(wfT.id, { name: 'Health', type: 'select', config: { options: [ { name: 'Healthy', hue: 'green' }, { name: 'Warning', hue: 'amber' }, { name: 'Failed', hue: 'red' }, { name: 'No runs', hue: 'slate' }, ] } }).system = true; } const healthF = this.#sysField(wfT, 'Health'); if (!healthF.config.options.some((o) => o.name === 'No runs')) { healthF.config.options.push(normaliseOption({ name: 'No runs', hue: 'slate' })); this.save(); } if (!this.#sysField(wfT, 'Health Reason')) this.addField(wfT.id, { name: 'Health Reason', type: 'text' }).system = true; if (!this.#sysField(wfT, 'Last Run')) this.addField(wfT.id, { name: 'Last Run', type: 'date', config: { time: true } }).system = true; if (!this.#sysField(wfT, 'Diagram')) this.addField(wfT.id, { name: 'Diagram', type: 'document' }).system = true; if (!this.#sysField(wfT, 'Type')) this.addField(wfT.id, { name: 'Type', type: 'select', config: { options: [] } }).system = true; if (!this.#sysField(wfT, 'On')) { const on = this.addField(wfT.id, { name: 'On', type: 'toggle', config: { on: 'On', off: 'Off' } }); on.system = true; const lead = (ids) => { ids.splice(ids.indexOf(on.id), 1); ids.splice(ids.indexOf(wfT.nameFieldId) + 1, 0, on.id); }; lead(wfT.fieldOrder); for (const v of wfT.tableViews ?? []) if (v.fields.includes(on.id)) lead(v.fields); this.save(); } const deliveriesT = this.#sysTable('deliveries') ?? mkTable('Failed Deliveries', 'deliveries', DELIVERIES_DESCRIPTION); if (!this.#sysField(deliveriesT, 'Status')) this.addField(deliveriesT.id, { name: 'Status', type: 'select', config: { options: DELIVERY_STATUSES } }).system = true; if (!this.#sysField(deliveriesT, 'Workflow')) { const { field, inverse } = this.addRelation(deliveriesT.id, { name: 'Workflow', targetDb: wfT.id, cardinality: 'many-to-one', inverseName: 'Failed Deliveries' }); field.system = true; inverse.system = true; } for (const [n, type, config] of [['Record', 'text'], ['Request', 'document', { kind: 'code' }], ['Attempts', 'document', { kind: 'code' }], ['Last Error', 'text'], ['Last Attempt', 'date', { time: true }]]) { if (!this.#sysField(deliveriesT, n)) this.addField(deliveriesT.id, { name: n, type, ...(config ? { config } : {}) }).system = true; } const usersT = this.#sysTable('users') ?? mkTable('Users', 'users', 'Every account and pending invite in this workspace, as a record. A record with an Email invites that person at its Role; a record with Kind Agent and no Email makes an agent account and shows its token once. Editing Role or Name changes the account; deleting the record revokes the invite or deletes the account.'); for (const [n, type, config] of USER_COLUMNS) { if (!this.#sysField(usersT, n)) this.addField(usersT.id, { name: n, type, config }).system = true; } const wsT = this.#sysTable('workspaces') ?? mkTable('Workspaces', 'workspaces', 'This workspace as a record. Its spaces, tables, fields and workflows relate back to it. Workspaces are created and deleted through the hub, not as records.'); const integrationsT = this.#sysTable('integrations') ?? mkTable('Integrations', 'integrations', INTEGRATIONS_DESCRIPTION); ensureIntegrationColumns(this, integrationsT); for (const [t, inverseName] of [[spacesT, 'Spaces'], [tablesT, 'Tables'], [fieldsT, 'Fields'], [wfT, 'Workflows'], [viewsT, 'Views'], [usersT, 'Users'], [integrationsT, 'Integrations'], [deliveriesT, 'Failed Deliveries']]) { if (this.#sysField(t, 'Workspace')) continue; const { field, inverse } = this.addRelation(t.id, { name: 'Workspace', targetDb: wsT.id, cardinality: 'many-to-one', inverseName }); field.system = true; inverse.system = true; } for (const n of ['When', 'Then']) if (!this.#sysField(wfT, n)) this.addField(wfT.id, { name: n, type: 'text' }).system = true; if (!wfT.workflowPanel) { wfT.workflowPanel = true; const ids = WORKFLOW_PANEL.map((n) => this.#sysField(wfT, n)?.id ?? wfT.nameFieldId); const view = wfT.tableViews?.find((v) => v.name === 'Standard') ?? wfT.tableViews?.[0]; if (view) { view.fields = ids; view.widths = { ...(view.widths ?? {}), [ids[2]]: 280, [ids[3]]: 320 }; view.phoneRow = { lead: ids[4], chips: [ids[0], ids[2], ids[3]], wrap: true }; } this.save(); } ensureFormColumns(this, this.#sysTable('forms') ?? mkTable('Forms', 'forms', FORMS_DESCRIPTION), tablesT); this.#syncAll(); this.#migrateAutomations(); this.#settleWorkflows(); } registryReport() { const problems = []; const rowOf = (kind, sysId) => this.#sysRow(kind, sysId); const relIds = (row, table, fieldName) => this.#relIds(row, table, fieldName); const tablesT = this.#sysTable('tables'); const fieldsT = this.#sysTable('fields'); if (!tablesT || !fieldsT) return { problems, rows: 0 }; let rows = 0; const engines = this.registryHost ? [this] : [this, ...this.members]; const mine = new Set(engines.map((w) => w.state.meta.id)); for (const db of engines.flatMap((w) => w.listTables())) { if (db.system) continue; const tableRow = rowOf('tables', db.id); if (!tableRow) { problems.push({ kind: 'table', name: this.qualifiedName(db), problem: 'no registry row' }); continue; } rows++; const spaceRow = rowOf('spaces', db.spaceId); if (spaceRow && !relIds(tableRow, tablesT, 'Space').includes(spaceRow.id)) { problems.push({ kind: 'table', name: this.qualifiedName(db), problem: 'row is not registered to its space' }); } for (const f of Object.values(db.fields)) { const fieldRow = rowOf('fields', f.id); if (!fieldRow) { problems.push({ kind: 'field', name: `${db.name}.${f.name}`, problem: 'no registry row' }); continue; } rows++; if (!relIds(fieldRow, fieldsT, 'Table').includes(tableRow.id)) { problems.push({ kind: 'field', name: `${db.name}.${f.name}`, problem: 'row is not registered to its table' }); } } } const reg = this.#reg; for (const [kind, sysTable, lookup] of [ ['table', tablesT, (id) => this.#tableAnywhere(id)], ['field', fieldsT, (id) => this.#fieldOwner(id)], ['view', this.#sysTable('views'), (id) => this.#viewAnywhere(id)], ]) { if (!sysTable) continue; for (const row of reg.listEntities(sysTable.id)) { if (!mine.has(this.#wsIdOfRow(row))) continue; if (row.sysId && !lookup(row.sysId)) { problems.push({ kind, name: this.entityName(row), problem: 'row describes nothing that exists', rowId: row.id }); } } } return { problems, rows }; } rebuildRegistry() { const before = this.registryReport().problems; const engines = this.registryHost ? [this] : [this, ...this.members]; for (const w of engines) w.#syncAll(); const reg = this.#reg; for (const p of before) { if (p.problem === 'row describes nothing that exists' && p.rowId) { const row = reg.state.entities[p.rowId]; if (row) reg.#metaSync(() => reg.deleteEntity(row.id, { hard: true })); } } reg.save(); const after = this.registryReport().problems; return { repaired: before.length - after.length, remaining: after }; } #syncSpaceRow(space) { const t = this.#sysTable('spaces'); if (!t) return undefined; if (space.system && this.registryHost) return undefined; const reg = this.#reg; const wsRow = this.#sysRow('workspaces', this.state.meta.id); let row = this.#sysRow('spaces', space.id); if (!row) { row = reg.#metaSync(() => reg.createEntity(t.id, { name: space.name, values: { Description: space.description ?? '', ...(space.template && this.#sysField(t, 'Template') ? { Template: true } : {}), ...(wsRow ? { Workspace: wsRow.id } : {}) } })); row.sysId = space.id; reg.#mark(row); if (space.deletedAt) reg.#metaSync(() => reg.deleteEntity(row.id)); reg.save(); return row; } const patch = {}; if (reg.entityName(row) !== space.name) patch.Name = space.name; const descF = this.#sysField(t, 'Description'); if ((row.values[descF.id] ?? '') !== (space.description ?? '')) patch.Description = space.description ?? ''; if (wsRow && !this.#relIds(row, t, 'Workspace').includes(wsRow.id)) patch.Workspace = wsRow.id; const templateF = this.#sysField(t, 'Template'); if (templateF && !!row.values[templateF.id] !== !!space.template) patch.Template = !!space.template; if (Object.keys(patch).length) reg.#metaSync(() => reg.updateEntity(row.id, patch)); return row; } #syncTableRow(db) { const t = this.#sysTable('tables'); if (!t) return undefined; if (db.system && this.registryHost) return undefined; const reg = this.#reg; const wsRow = this.#sysRow('workspaces', this.state.meta.id); const spaceRow = this.#sysRow('spaces', db.spaceId); let row = this.#sysRow('tables', db.id); if (!row) { row = reg.#metaSync(() => reg.createEntity(t.id, { name: db.name, values: { Description: db.description ?? '', ...(spaceRow ? { Space: spaceRow.id } : {}), ...(wsRow ? { Workspace: wsRow.id } : {}) }, })); row.sysId = db.id; reg.#mark(row); if (db.deletedAt) reg.#metaSync(() => reg.deleteEntity(row.id)); reg.save(); } const patch = {}; if (reg.entityName(row) !== db.name) patch.Name = db.name; const descF = this.#sysField(t, 'Description'); if ((row.values[descF.id] ?? '') !== (db.description ?? '')) patch.Description = db.description ?? ''; if (spaceRow && !this.#relIds(row, t, 'Space').includes(spaceRow.id)) patch.Space = spaceRow.id; if (wsRow && !this.#relIds(row, t, 'Workspace').includes(wsRow.id)) patch.Workspace = wsRow.id; const orderF = this.#sysField(t, 'Field Order'); if (orderF) { const order = db.fieldOrder.map((id) => db.fields[id]?.name).filter(Boolean).join(', '); if ((row.values[orderF.id] ?? '') !== order) patch['Field Order'] = order; } const dv = this.#defaultViewConfig(db); const hiddenF = this.#sysField(t, 'Hidden Fields'); if (hiddenF) { const hidden = (dv.hiddenFields ?? []).join(', '); if ((row.values[hiddenF.id] ?? '') !== hidden) patch['Hidden Fields'] = hidden; } const filterF = this.#sysField(t, 'Filter'); if (filterF) { const txt = formatFilters(dv.filters); if ((row.values[filterF.id] ?? '') !== txt) patch.Filter = txt; } const sortF = this.#sysField(t, 'Sort'); if (sortF) { const txt = formatSort(dv.sort); if ((row.values[sortF.id] ?? '') !== txt) patch.Sort = txt; } const hideF = this.#sysField(t, 'Hide Rollups'); if (hideF && !!row.values[hideF.id] !== (db.hideRollups !== false)) patch['Hide Rollups'] = db.hideRollups !== false; if (Object.keys(patch).length) reg.#metaSync(() => reg.updateEntity(row.id, patch)); this.#syncViewRows(db, row); return row; } #syncViewRows(db, tableRow) { const t = this.#sysTable('views'); if (!t || !db.tableViews || !this.#sysField(t, 'Position') || !this.#sysField(t, 'Workspace')) return; const reg = this.#reg; const wsRow = this.#sysRow('workspaces', this.state.meta.id); db.tableViews.forEach((v, i) => { const want = { Name: v.name, Fields: v.fields.map((id) => viewEntryName(db, id)).filter(Boolean).join(', '), Filter: formatFilters(v.filters), Sort: formatSort(v.sort), Search: v.search ?? '', Default: i === 0, Position: i, Frozen: v.frozen ?? 0, Widths: formatWidths(this.#viewOut(db, v).widths), Density: v.density ?? '', 'Show Deleted': !!v.deleted, 'Rollup Row': typeof v.rollups === 'boolean' ? v.rollups : db.hideRollups === false, Layout: LAYOUTS.includes(v.layout) ? v.layout : '', Groups: v.groups ?? '', Picture: v.picture ?? '', Group: (v.group ?? []).map((l) => db.fields[l.field]?.name).filter(Boolean).join(ListCore.SEP), }; let row = this.#sysRow('views', v.id); if (!row) { row = reg.#metaSync(() => reg.createEntity(t.id, { name: v.name, values: { ...want, Table: tableRow.id, ...(wsRow ? { Workspace: wsRow.id } : {}) }, })); row.sysId = v.id; reg.#mark(row); if (db.deletedAt) reg.#metaSync(() => reg.deleteEntity(row.id)); reg.save(); return; } const patch = {}; if (reg.entityName(row) !== v.name) patch.Name = v.name; for (const k of ['Fields', 'Filter', 'Sort', 'Search', 'Default', 'Position', 'Frozen', 'Widths', 'Density', 'Show Deleted', 'Rollup Row', 'Layout', 'Group', 'Groups', 'Picture']) { const f = this.#sysField(t, k); if (!f) continue; if ((row.values[f.id] ?? (['Default', 'Show Deleted', 'Rollup Row'].includes(k) ? false : k === 'Frozen' ? 0 : '')) !== want[k]) patch[k] = want[k]; } if (!this.#relIds(row, t, 'Table').includes(tableRow.id)) patch.Table = tableRow.id; if (wsRow && !this.#relIds(row, t, 'Workspace').includes(wsRow.id)) patch.Workspace = wsRow.id; if (Object.keys(patch).length) reg.#metaSync(() => reg.updateEntity(row.id, patch)); }); } #viewAnywhere(viewId) { for (const w of this.#engines()) { for (const table of Object.values(w.state.tables)) { const view = table.tableViews?.find((v) => v.id === viewId); if (view) return { owner: w, table, view }; } } return null; } #syncFieldRow(db, f) { if (db.system) return undefined; const t = this.#sysTable('fields'); if (!t) return undefined; const tableRow = this.#sysRow('tables', db.id); if (!tableRow) return undefined; const reg = this.#reg; const wsRow = this.#sysRow('workspaces', this.state.meta.id); const definable = DEFINABLE_TYPES.includes(f.type) && !(f.type === 'field' && (f.config.depth ?? 1) >= 4); let row = this.#sysRow('fields', f.id); if (!row) { row = reg.#metaSync(() => reg.createEntity(t.id, { name: f.name, values: { Table: tableRow.id, Type: f.type, ...(wsRow ? { Workspace: wsRow.id } : {}), ...(definable ? { Definition: { type: f.type, config: f.config } } : {}), }, })); row.sysId = f.id; reg.#mark(row); reg.save(); return row; } const patch = {}; if (reg.entityName(row) !== f.name) patch.Name = f.name; if (definable) patch.Definition = { type: f.type, config: f.config }; if (!this.#relIds(row, t, 'Table').includes(tableRow.id)) patch.Table = tableRow.id; if (wsRow && !this.#relIds(row, t, 'Workspace').includes(wsRow.id)) patch.Workspace = wsRow.id; if (Object.keys(patch).length) reg.#metaSync(() => reg.updateEntity(row.id, patch)); return row; } #dropFieldRow(fieldId) { const row = this.#sysRow('fields', fieldId); const reg = this.#reg; if (row) reg.#metaSync(() => reg.deleteEntity(row.id, { hard: true })); } #fieldOwner(fieldId) { return this.#fieldAnywhere(fieldId)?.table; } #dropSysRow(kind, sysId) { const row = this.#sysRow(kind, sysId); const reg = this.#reg; if (row) reg.#metaSync(() => reg.deleteEntity(row.id, { hard: true })); } #trashSysRow(kind, sysId) { const row = this.#sysRow(kind, sysId); const reg = this.#reg; if (row && !row.deletedAt) reg.#metaSync(() => reg.deleteEntity(row.id)); } #restoreSysRow(kind, sysId) { const row = this.#sysRow(kind, sysId); const reg = this.#reg; if (row?.deletedAt) reg.#metaSync(() => reg.restoreEntity(row.id)); } #integrationNamed(db, values) { const out = {}; for (const [k, v] of Object.entries(values ?? {})) out[this.findField(db, k)?.name ?? k] = v; for (const name of INTEGRATION_KEPT) { if (!(name in out)) continue; if (out[name] != null && out[name] !== '') { throw new WeaveError(name === 'Credential' ? 'A key never goes into a row: paste it with Replace on the Integrations row (PUT /api/integrations//credential), where it is tested and sealed' : `'${name}' is kept by weave and cannot be set on an Integrations row`, 'invalid'); } delete out[name]; } return out; } #integrationProvider(db, v) { if (v == null || v === '') return null; const f = this.#sysField(db, 'Provider'); const hit = f.config.options.find((o) => o.id === v || o.name.toLowerCase() === String(v).toLowerCase()); if (!hit) throw new WeaveError(`Provider '${v}' is not one of ${f.config.options.map((o) => o.name).join(', ')}`, 'invalid'); return hit.name; } #integrationInput(db, input) { const values = this.#integrationNamed(db, { ...Object.fromEntries(Object.entries(input ?? {}).filter(([k]) => !CREATE_INPUT_KEYS.has(k))), ...(input?.values ?? {}) }); const provider = this.#integrationProvider(db, values.Provider); if ('Host' in values) values.Host = integrationHost(values.Host, provider); if (!('On' in values)) values.On = true; const wsRow = values.Workspace == null ? this.#sysRow('workspaces', this.state.meta.id) : this.#sysRow('workspaces', values.Workspace); if (wsRow) values.Workspace = wsRow.id; return { ...Object.fromEntries(Object.entries(input ?? {}).filter(([k]) => k !== 'values' && CREATE_INPUT_KEYS.has(k))), values }; } #updateIntegrationRow(e, db, valuesByName) { const patch = this.#integrationNamed(db, valuesByName); const F = (n) => this.#sysField(db, n); if ('Workspace' in patch) { const next = patch.Workspace == null ? null : (this.findEntity(this.#sysTable('workspaces').id, patch.Workspace) ?? this.#sysRow('workspaces', patch.Workspace))?.id; if (next !== (e.values[F('Workspace').id] ?? null)) throw new WeaveError("An Integrations row belongs to the workspace it was made in and cannot move", 'invalid'); delete patch.Workspace; } const was = { provider: this.#integrationProvider(db, e.values[F('Provider').id]), host: e.values[F('Host').id] ?? null }; let rebound = false; if ('Host' in patch || 'Provider' in patch) { const provider = 'Provider' in patch ? this.#integrationProvider(db, patch.Provider) : was.provider; patch.Host = integrationHost('Host' in patch ? patch.Host : was.host, provider); rebound = provider !== was.provider || patch.Host !== was.host; } if (Object.keys(patch).length) this.#metaSync(() => this.updateEntity(e.id, patch)); if (rebound) this.#dropIntegrationKey(e, 'The Host or Provider changed: paste the key again with Replace'); return this.getEntity(e.id); } #onReg(fn) { const reg = this.#reg; if (reg === this) return fn(reg); const was = reg.actor; reg.actor = this.actor; try { return fn(reg); } finally { reg.actor = was; } } #stampIntegration(row, values) { this.#onReg((reg) => reg.#metaSync(() => reg.updateEntity(row.id, values))); } #integrationRow(ref) { const reg = this.#reg; const t = reg.#sysTable('integrations'); const row = t && (own(reg.state.entities, ref) ?? reg.findEntity(t.id, ref)); if (!row || row.dbId !== t.id || row.deletedAt || (this.#wsIdOfRow(row) ?? reg.state.meta.id) !== this.state.meta.id) { throw new WeaveError(`No integration '${ref}' in this workspace`, 'not-found'); } return row; } #integrationTarget(row) { const t = this.#reg.#sysTable('integrations'); const F = (n) => this.#sysField(t, n); return { name: this.#reg.entityName(row), provider: this.#integrationProvider(t, row.values[F('Provider').id]), host: row.values[F('Host').id] ?? null, on: row.values[F('On').id] === true, }; } #integrationEntry(rowId) { const entry = own(this.keyStore.read().keys, integrationKeyName(rowId)); return entry?.integration === true && entry.workspace === this.state.meta.id ? entry : null; } #sealIntegrationKey(rowId, secret) { const data = this.keyStore.read(); const name = integrationKeyName(rowId); const prior = own(data.keys, name); data.keys[name] = { value: secret, owner: null, workspace: this.state.meta.id, shared: false, integration: true, createdAt: prior?.createdAt ?? nowISO(), ...(prior ? { rotatedAt: nowISO() } : {}), }; this.keyStore.write(data); } #dropIntegrationKey(row, reason) { const name = integrationKeyName(row.id); const had = this.#onReg((reg) => { const data = reg.keyStore.read(); if (!Object.hasOwn(data.keys, name)) return false; delete data.keys[name]; reg.keyStore.write(data); reg.#audit('key-deleted', { name, integration: reg.entityName(row) }); return true; }); const credF = this.#sysField(this.#reg.#sysTable('integrations'), 'Credential'); if (had || row.values[credF.id] != null) { this.#stampIntegration(row, { Credential: null, 'Key Hint': null, 'Connected As': null, State: 'Setup incomplete', 'Health Reason': reason }); } return had; } async #checkIntegration(provider, host, secret) { const { url, init } = checkRequest(provider, host, secret); const target = new URL(url); try { const refusal = await webhookRefusal(target); if (refusal) return { ok: false, reason: `${host}: ${refusal}` }; const res = await fetch(target, { ...init, redirect: 'manual', signal: AbortSignal.timeout(CHECK_TIMEOUT_MS) }); return await readCheck(provider, host, res); } catch (err) { return { ok: false, reason: unreachable(host, err) }; } } async integrationFetch(ref, url, { method = 'GET', headers = {}, body = undefined } = {}) { const row = this.#integrationRow(ref); const target = this.#integrationTarget(row); if (!target.on) throw new WeaveError(`Integration '${target.name}' is Off: switch it On to use its key`, 'forbidden'); const entry = this.#reg.#integrationEntry(row.id); if (!entry || !target.host || !target.provider) throw new WeaveError(`Integration '${target.name}' has no key yet: paste one with Replace`, 'invalid'); let u; try { u = new URL(String(url)); } catch { throw new WeaveError(`'${url}' is not a URL`, 'invalid'); } if (u.protocol !== 'https:' || u.hostname !== target.host || u.port || u.username || u.password) { throw new WeaveError(`Integration '${target.name}' only reaches https://${target.host}: refused ${u.protocol}//${u.host}`, 'forbidden'); } const verb = String(method).toUpperCase(); if (!INTEGRATION_METHODS.includes(verb)) throw new WeaveError(`Method '${method}' is not one of ${INTEGRATION_METHODS.join(', ')}`, 'invalid'); const sent = new Headers(headers); if (sent.has('authorization') || sent.has('proxy-authorization') || sent.has('cookie')) { throw new WeaveError('weave sets the Authorization header from the integration key: a caller may not set Authorization, Proxy-Authorization or Cookie', 'invalid'); } const refusal = await webhookRefusal(u); if (refusal) throw new WeaveError(`Integration '${target.name}': ${target.host} ${refusal}`, 'forbidden'); const secret = this.#reg.keyStore.value(integrationKeyName(row.id), entry); sent.set('Authorization', authHeader(target.provider, secret)); let res = null; let failure = null; try { res = await fetch(u, { method: verb, headers: sent, body, redirect: 'manual', signal: AbortSignal.timeout(CHECK_TIMEOUT_MS) }); } catch (err) { failure = unreachable(target.host, err); } this.#onReg((reg) => { const live = own(reg.state.entities, row.id); const f = reg.#sysField(reg.#sysTable('integrations'), 'Last Used'); if (live && f) reg.#stamp(live, () => { live.values[f.id] = nowISO(); }); reg.#audit('key-used', { integration: target.name, host: target.host, method: verb, path: u.pathname, status: res?.status ?? null, ...(failure ? { error: failure } : {}) }); reg.save(); }); if (failure) throw Object.assign(new WeaveError(`Integration '${target.name}': ${failure}`, 'invalid'), { unreachable: true }); const text = redact(await res.text(), secret); return { status: res.status, ok: res.ok, headers: Object.fromEntries([...res.headers].filter(([k]) => k.toLowerCase() !== 'set-cookie')), body: text }; } async setIntegrationCredential(ref, value) { const row = this.#integrationRow(ref); const secret = cleanSecret(value); const before = this.#integrationTarget(row); if (!before.provider || !before.host) { throw new WeaveError(`Integration '${before.name}' needs a Provider and a Host before its key: the key is tested against that host`, 'invalid'); } this.#stampIntegration(row, { State: 'Checking' }); const result = await this.#checkIntegration(before.provider, before.host, secret); const now = this.#integrationRow(row.id); const after = this.#integrationTarget(now); if (after.provider !== before.provider || after.host !== before.host) { throw new WeaveError(`Integration '${after.name}' changed its Host or Provider while the key was tested: paste it again`, 'conflict'); } const at = nowISO(); const had = !!this.#reg.#integrationEntry(row.id); if (!result.ok) { const reason = redact(result.reason, secret) + (had ? ' The previous key is still stored.' : ''); this.#stampIntegration(row, { State: 'Failed', 'Health Reason': reason, 'Last Checked': at }); this.#onReg((reg) => reg.#audit('integration-check-failed', { integration: after.name, host: after.host })); return { ...this.#reg.readEntity(row.id), connected: false, reason }; } this.#onReg((reg) => { reg.#sealIntegrationKey(row.id, secret); reg.#audit('key-set', { name: integrationKeyName(row.id), integration: after.name, host: after.host, replaced: had }); }); this.#stampIntegration(row, { Credential: integrationKeyName(row.id), 'Key Hint': keyHint(secret), State: 'Connected', 'Connected As': redact(result.connectedAs, secret), 'Health Reason': null, 'Last Checked': at, }); return { ...this.#reg.readEntity(row.id), connected: true }; } #interceptCreate(db, input) { if (!db.system || this.#inMetaSync) return undefined; if (db.system === 'workspaces') throw new WeaveError('A workspace is created from the hub (POST /api/workspaces), not as a record', 'invalid'); if (db.system === 'users') return this.#createUserRow(db, input); if (db.system === 'workflows') { const values = { ...Object.fromEntries(Object.entries(input ?? {}).filter(([k]) => !CREATE_INPUT_KEYS.has(k))), ...(input?.values ?? {}) }; this.#guardWorkflowOn(null, db, values, input?.docs); const wsRow = values.Workspace == null ? null : this.#sysRow('workspaces', values.Workspace); if (wsRow) (input.values && 'Workspace' in input.values ? input.values : input).Workspace = wsRow.id; return undefined; } if (!['spaces', 'tables', 'fields', 'views'].includes(db.system)) return undefined; const flat = Object.fromEntries(Object.entries(input ?? {}).filter(([k]) => !['name', 'values', 'doc', 'docs'].includes(k))); const values = { ...flat, ...(input?.values ?? {}) }; const name = input?.name ?? values.Name; if (!name) throw new WeaveError('Name is required', 'invalid'); const description = values.Description ?? ''; delete values.Name; delete values.Description; let made; const wsRef = values.Workspace; delete values.Workspace; if (db.system === 'spaces') { let owner = this; if (wsRef != null) { const wsRow = this.findEntity(this.#sysTable('workspaces').id, wsRef) ?? this.#sysRow('workspaces', wsRef); if (!wsRow) throw new WeaveError(`Workspace record '${wsRef}' not found`, 'not-found'); owner = this.#engineOf(wsRow.sysId); } const template = !!values.Template; delete values.Template; made = this.#sysRow('spaces', owner.createSpace({ name, description, template }).id); } else if (db.system === 'tables') { const spaceRef = values.Space; delete values.Space; if (spaceRef == null) throw new WeaveError(`A Tables record needs its 'Space' — which space the table lives in`, 'invalid'); const spaceRow = this.findEntity(this.#sysTable('spaces').id, spaceRef); if (!spaceRow) throw new WeaveError(`Space record '${spaceRef}' not found`, 'not-found'); const owner = this.#ownerOf(spaceRow); if (owner.state.spaces[spaceRow.sysId]?.system) { throw new WeaveError(`Space '${this.entityName(spaceRow)}' is part of the system registry — create tables in your own spaces`, 'invalid'); } made = this.#sysRow('tables', owner.createTable({ space: spaceRow.sysId, name, description }).id); } else if (db.system === 'fields') { const tableRef = values.Table; const def = values.Definition; delete values.Table; delete values.Definition; delete values.Type; if (tableRef == null) throw new WeaveError(`A Fields record needs its 'Table' — which table the column lands on`, 'invalid'); if (!def || typeof def !== 'object' || !def.type) throw new WeaveError(`A Fields record needs its 'Definition' — the column's shape`, 'invalid'); const tableRow = this.findEntity(this.#sysTable('tables').id, tableRef); if (!tableRow) throw new WeaveError(`Table record '${tableRef}' not found`, 'not-found'); const owner = this.#ownerOf(tableRow); if (owner.state.tables[tableRow.sysId]?.system) { throw new WeaveError(`Table '${this.entityName(tableRow)}' is part of the system registry — its columns are fixed`, 'invalid'); } const f = owner.addField(tableRow.sysId, { name, type: def.type, config: def.config ?? {} }); made = this.#sysRow('fields', f.id); } else if (db.system === 'views') { const tableRef = values.Table; delete values.Table; if (tableRef == null) throw new WeaveError(`A Views record needs its 'Table' — which table the view is over`, 'invalid'); const tableRow = this.findEntity(this.#sysTable('tables').id, tableRef); if (!tableRow) throw new WeaveError(`Table record '${tableRef}' not found`, 'not-found'); const owner = this.#ownerOf(tableRow); const v = owner.tableView(`${tableRow.sysId}/${name}`, { from: 'blank', ...this.#viewRowPatch(values, null) }); made = this.#sysRow('views', v.id); if (description) values.Description = description; } else { return undefined; } if (Object.keys(values).length) this.#metaSync(() => this.updateEntity(made.id, values)); return this.getEntity(made.id); } #interceptUpdate(e, db, valuesByName) { if (!db.system || this.#inMetaSync) return undefined; if (db.system === 'workspaces') { const patch = { ...valuesByName }; if ('Name' in patch) throw new WeaveError("Rename a workspace from its own page — the hub's name index moves with it", 'invalid'); if ('Description' in patch) { this.#engineOf(e.sysId).updateWorkspace({ description: patch.Description }); delete patch.Description; } if (Object.keys(patch).length) this.#metaSync(() => this.updateEntity(e.id, patch)); return this.getEntity(e.id); } if (db.system === 'workflows') { this.#guardWorkflowOn(e, db, valuesByName); return undefined; } if (db.system === 'users') return this.#updateUserRow(e, db, valuesByName); if (db.system === 'integrations') return this.#updateIntegrationRow(e, db, valuesByName); if (db.system === 'deliveries') return this.#updateDeliveryRow(e, db, valuesByName); if (!['spaces', 'tables', 'fields', 'views'].includes(db.system)) return undefined; const patch = { ...valuesByName }; if ('Workspace' in patch) { const next = patch.Workspace == null ? null : this.findEntity(this.#sysTable('workspaces').id, patch.Workspace)?.id; if (next !== (e.values[this.#sysField(db, 'Workspace').id] ?? null)) throw new WeaveError("A record's Workspace follows the structure it describes and cannot move", 'invalid'); delete patch.Workspace; } if (db.system === 'views') { const hit = this.#viewAnywhere(e.sysId); if (!hit) throw new WeaveError(`View record '${this.entityName(e)}' describes no view`, 'not-found'); if ('Table' in patch) { const next = patch.Table == null ? null : this.findEntity(this.#sysTable('tables').id, patch.Table)?.id; if (next !== (e.values[this.#sysField(db, 'Table').id] ?? null)) throw new WeaveError('A view cannot move between tables', 'invalid'); delete patch.Table; } const vp = this.#viewRowPatch(patch, hit.table.tableViews[0] === hit.view, hit.owner.#viewOut(hit.table, hit.view)); if (Object.keys(vp).length) hit.owner.tableView(`${hit.table.id}/${hit.view.id}`, vp); } else if (db.system === 'fields') { const hit = this.#fieldAnywhere(e.sysId); const owner = hit?.table; const f = hit?.field; if ('Type' in patch) throw new WeaveError(`'Type' follows the Definition — change the definition, not the label`, 'invalid'); if ('Table' in patch) { const next = patch.Table == null ? null : this.findEntity(this.#sysTable('tables').id, patch.Table)?.id; const cur = e.values[this.#sysField(db, 'Table').id] ?? null; if (next !== cur) throw new WeaveError('A field cannot move between tables', 'invalid'); delete patch.Table; } if ('Name' in patch) { hit.owner.updateField(owner.id, f.id, { name: patch.Name }); delete patch.Name; } if ('Definition' in patch) { const def = patch.Definition; if (!DEFINABLE_TYPES.includes(f.type)) { throw new WeaveError(`A ${f.type} field's shape is edited through the schema verbs, not its Definition`, 'invalid'); } if (!def || def.type !== f.type) { throw new WeaveError(`A definition cannot change its type ('${f.type}' → '${def?.type}') — delete the column and create it anew`, 'invalid'); } if (!def.config || typeof def.config !== 'object') { throw new WeaveError("A definition carries its shape under `config` — {type, config: {…}}", 'invalid'); } hit.owner.updateField(owner.id, f.id, { config: def.config ?? {} }); delete patch.Definition; } } else { if (db.system === 'tables' && 'Space' in patch) { const spaceF = this.#sysField(db, 'Space'); const next = patch.Space == null ? null : this.findEntity(this.#sysTable('spaces').id, patch.Space)?.id; if (next !== (e.values[spaceF.id] ?? null)) { throw new WeaveError('A table cannot move between spaces yet', 'invalid'); } delete patch.Space; } const structural = {}; if ('Name' in patch) { structural.name = patch.Name; delete patch.Name; } if ('Description' in patch) { structural.description = patch.Description; delete patch.Description; } if (db.system === 'spaces' && 'Template' in patch) { structural.template = !!patch.Template; delete patch.Template; } if (db.system === 'tables') { const split = (v) => String(v ?? '').split(',').map((x) => x.trim()).filter(Boolean); if ('Field Order' in patch) { structural.fieldOrder = split(patch['Field Order']); delete patch['Field Order']; } if ('Hidden Fields' in patch) { structural.hiddenFields = split(patch['Hidden Fields']); delete patch['Hidden Fields']; } if ('Filter' in patch) { structural.filters = parseFilters(patch.Filter); delete patch.Filter; } if ('Sort' in patch) { structural.sort = parseSort(patch.Sort); delete patch.Sort; } if ('Hide Rollups' in patch) { structural.hideRollups = !!patch['Hide Rollups']; delete patch['Hide Rollups']; } } if (Object.keys(structural).length) { const owner = this.#ownerOf(e); if (db.system === 'spaces') owner.updateSpace(e.sysId, structural); else owner.updateTable(e.sysId, structural); } } if (Object.keys(patch).length) this.#metaSync(() => this.updateEntity(e.id, patch)); return this.getEntity(e.id); } #viewRowPatch(values, isDefault, cur = null) { const vp = {}; const take = (k) => { const v = values[k]; delete values[k]; return v; }; if ('Name' in values) vp.name = take('Name'); if ('Fields' in values) vp.fields = String(take('Fields') ?? '').split(',').map((x) => x.trim()).filter(Boolean); if ('Filter' in values) vp.filters = parseFilters(take('Filter')); if ('Sort' in values) vp.sort = parseSort(take('Sort')); if ('Search' in values) vp.search = String(take('Search') ?? ''); if ('Default' in values) { const on = !!take('Default'); if (on) vp.default = true; else if (isDefault) vp.default = false; } if ('Position' in values) { const p = take('Position'); if (p != null && p !== '') vp.position = Number(p); } if ('Frozen' in values) { const n = take('Frozen'); vp.frozen = n == null || n === '' ? 0 : Number(n); } if ('Density' in values) vp.density = String(take('Density') ?? '').trim() || 'comfortable'; if ('Show Deleted' in values) vp.deleted = !!take('Show Deleted'); if ('Rollup Row' in values) vp.rollups = !!take('Rollup Row'); for (const [col, key] of [['Layout', 'layout'], ['Groups', 'groups'], ['Picture', 'picture']]) if (col in values) vp[key] = String(take(col) ?? '').trim().toLowerCase() || null; if ('Group' in values) { const names = ListCore.parseGroup(String(take('Group') ?? '')).map((l) => l.field); vp.group = names.map((n) => cur?.group?.find((l) => l.field.toLowerCase() === n.toLowerCase()) ?? n); } if ('Widths' in values) { vp.widths = { ...Object.fromEntries(Object.keys(cur?.widths ?? {}).map((n) => [n, null])), ...parseWidths(take('Widths')) }; } return vp; } #interceptDelete(e, db, hard) { if (!db.system || this.#inMetaSync) return undefined; if (db.system === 'workspaces') throw new WeaveError('A workspace is deleted from the hub (DELETE /api/workspaces/:id), not as a record', 'invalid'); if (db.system === 'users') return this.#deleteUserRow(e); if (db.system === 'integrations') { this.#dropIntegrationKey(e, null); return undefined; } if (!['spaces', 'tables', 'fields', 'views'].includes(db.system)) return undefined; if (db.system === 'views') { const hit = this.#viewAnywhere(e.sysId); if (hit) hit.owner.tableView(`${hit.table.id}/${hit.view.id}`, { delete: true }); else this.#metaSync(() => this.deleteEntity(e.id, { hard: true })); return { id: e.id, purged: true }; } if (db.system === 'fields') { const hit = this.#fieldAnywhere(e.sysId); const owner = hit?.table; if (owner && owner.nameFieldId === e.sysId) { throw new WeaveError('Cannot delete the Name field', 'invalid'); } const binned = owner ? null : this.#trashedFieldAnywhere(e.sysId); if (owner) hit.owner.deleteField(owner.id, e.sysId, { hard }); else if (binned) binned.owner.deleteField(binned.table.id, e.sysId, { hard }); else this.#metaSync(() => this.deleteEntity(e.id, { hard })); return hard ? { id: e.id, purged: true } : this.readEntity(e.id); } const owner = this.#ownerOf(e); if (db.system === 'spaces') owner.deleteSpace(e.sysId, { hard }); else owner.deleteTable(e.sysId, { hard }); return hard ? { id: e.id, purged: true } : this.readEntity(e.id); } findField(db, ref) { if (ref && typeof ref === 'object') ref = ref.id; if (own(db.fields, ref)) return db.fields[ref]; const fields = Object.values(db.fields); return fields.find((f) => f.name === ref) ?? fields.find((f) => f.name.toLowerCase() === String(ref).toLowerCase()) ?? (String(ref) === 'Name' ? db.fields[db.nameFieldId] : undefined); } getField(dbRef, ref) { const db = this.getTable(dbRef); const f = this.findField(db, ref); if (!f) throw new WeaveError(`Field '${ref}' not found in table '${db.name}'`, 'not-found'); return f; } materializeField(dbRef, name, def) { if (!def || typeof def !== 'object' || !def.type) { throw new WeaveError(`'${name}' has no definition to materialize`, 'invalid'); } return this.addField(dbRef, { name, type: def.type, config: def.config ?? {} }); } addField(dbRef, { name, type, config = {} }) { this.#mayShape(dbRef); const db = this.getTable(dbRef); if (!name) throw new WeaveError('Field name is required', 'invalid'); refuseReserved('field', name); if (this.findField(db, name)) throw new WeaveError(`Field '${name}' already exists`, 'conflict'); if (!FIELD_TYPES.includes(type)) throw new WeaveError(`Unknown field type '${type}'`, 'invalid'); if (type === 'relation') throw new WeaveError(`Use addRelation() to create relation fields`, 'invalid'); if (type === 'view') throw new WeaveError('The chip and the card are minted on every table; configure those instead', 'invalid'); const field = { id: uuid(), name, type, config: {} }; if (type === 'avatar' && Object.values(db.fields).some((f) => f.type === 'avatar' && !f.deletedAt)) { throw new WeaveError(`Table '${db.name}' already has an avatar field; a record has one face`, 'conflict'); } if (FIELD_TYPE_CONFIG[type]?.build) { field.config = normalizeSelfContainedConfig(type, config, { strict: true }); } else if (type === 'lookup') { field.config = this.#recipe(db, 'lookup', config); } else if (type === 'rollup') { const aggregate = config.aggregate ?? 'count'; if (!AGGREGATES.includes(aggregate)) throw new WeaveError(`Invalid aggregate '${aggregate}' (use ${AGGREGATES.join(', ')})`, 'invalid'); if (config.via != null && config.relationField == null && config.relation == null) { if (db.system !== 'spaces') throw new WeaveError('A rollup over a whole table lives on the Spaces registry row that holds the table — add it there (config.via names the table)', 'invalid'); const hit = this.#tableAnywhere(config.via) ?? { owner: this, table: this.getTable(config.via) }; const viaT = hit.table; if (viaT.system) throw new WeaveError(`Table '${viaT.name}' is part of the system registry — roll up your own tables`, 'invalid'); let targetFieldId = null; if (aggregate !== 'count') targetFieldId = hit.owner.getField(viaT.id, config.targetField).id; const where = config.where && (Array.isArray(config.where) ? config.where.length : true) ? config.where : null; if (where) hit.owner.#checkWhere(viaT, where); field.config = { via: viaT.id, targetField: targetFieldId, aggregate, ...(where ? { where } : {}) }; } else { field.config = this.#recipe(db, 'rollup', { ...config, aggregate }); } if (config.separator != null) field.config.separator = String(config.separator); Object.assign(field.config, rollupCostume(config)); } else if (type === 'formula') { if (!config.expression) throw new WeaveError('Formula field needs an expression', 'invalid'); const checked = checkExpression(config.expression, Object.values(db.fields).map((f) => f.name)); if (!checked.ok) throw new WeaveError(checked.error, 'invalid'); this.#refuseFormulaCycle(db, field.id, field.name, config.expression); field.config = { expression: config.expression, ...formulaCostume(config) }; } if (config.default !== undefined && config.default !== null) { field.config.default = this.#validateDefault(field, config.default); } if (config.width != null) { const width = Number(config.width); if (!Number.isFinite(width) || width < MIN_COLUMN_WIDTH) { throw new WeaveError(`Column width must be a number of at least ${MIN_COLUMN_WIDTH}px`, 'invalid'); } field.config.width = Math.round(width); } if (config.description != null) { const description = fieldDescriptionValue(config.description); if (description) field.config.description = description; } db.fields[field.id] = field; placeField(db, field.id); this.save(); this.#syncFieldRow(db, field); this.#syncTableRow(db); if (!db.system) this.#audit('field-added', { table: db.name, name: field.name, type: field.type }, this.#spacesTouching(db, field)); return field; } addRelation(dbRef, { name, targetDb, targetDbs, cardinality = 'many-to-one', inverseName }) { this.#mayShape(dbRef); const db = this.getTable(dbRef); if (!name) throw new WeaveError('Relation field name is required', 'invalid'); refuseReserved('field', name); if (inverseName != null) refuseReserved('field', inverseName); if (this.findField(db, name)) throw new WeaveError(`Field '${name}' already exists`, 'conflict'); const cards = { 'many-to-one': { thisMany: false, targetMany: true }, 'one-to-many': { thisMany: true, targetMany: false }, 'many-to-many': { thisMany: true, targetMany: true }, 'one-to-one': { thisMany: false, targetMany: false }, }; const card = cards[cardinality]; if (!card) throw new WeaveError(`Invalid cardinality '${cardinality}'`, 'invalid'); if (targetDbs !== undefined) { const members = (Array.isArray(targetDbs) ? targetDbs : [targetDbs]).map((r) => this.getTable(r)); if (!members.length) throw new WeaveError('A relation needs at least one target table', 'invalid'); if (new Set(members.map((m) => m.id)).size !== members.length) { throw new WeaveError('Duplicate table in the target set', 'invalid'); } if (members.length > 1) { const a = { id: uuid(), name, type: 'relation', config: { targetDbs: members.map((m) => m.id), many: card.thisMany } }; db.fields[a.id] = a; placeField(db, a.id); this.save(); this.#syncFieldRow(db, a); this.#syncTableRow(db); if (!db.system) this.#audit('relation-added', { table: db.name, name: a.name, targets: members.map((m) => this.qualifiedName(m)) }, this.#spacesTouching(db, a)); return { field: a, inverse: null }; } targetDb = members[0].id; } if (targetDb == null) throw new WeaveError('A relation needs a targetDb (or a targetDbs list)', 'invalid'); const target = this.getTable(targetDb); const invName = inverseName ?? this.#inverseName(db, card.targetMany); if (this.findField(target, invName)) throw new WeaveError(`Field '${invName}' already exists in target table`, 'conflict'); const a = { id: uuid(), name, type: 'relation', config: { targetDb: target.id, many: card.thisMany } }; const b = { id: uuid(), name: invName, type: 'relation', config: { targetDb: db.id, many: card.targetMany } }; a.config.inverseFieldId = b.id; b.config.inverseFieldId = a.id; db.fields[a.id] = a; placeField(db, a.id); target.fields[b.id] = b; placeField(target, b.id); this.save(); this.#syncFieldRow(db, a); this.#syncFieldRow(target, b); this.#syncTableRow(db); this.#syncTableRow(target); if (!db.system) this.#audit('relation-added', { table: db.name, name: a.name, target: target.name }, this.#spacesTouching(db, a)); return { field: a, inverse: b }; } bodyBlocks(dbRef) { const db = this.getTable(dbRef); const known = [VALUES_BLOCK]; for (const id of db.fieldOrder) { const f = db.fields[id]; if (f && isBodyBlock(f)) known.push(id); } const seen = new Set(); const out = []; for (const key of db.bodyOrder ?? []) { if (seen.has(key) || !(known.includes(key) || key === FILES_BLOCK)) continue; seen.add(key); out.push(key); } for (const key of known) if (!seen.has(key)) out.push(key); return out.map((key) => isFixedBlock(key) ? key : db.fields[key].name); } #recipe(db, type, config) { const what = type === 'lookup' ? 'Lookup' : 'Rollup'; const rel = this.getField(db.id, config.relationField ?? config.relation); if (rel.type !== 'relation') throw new WeaveError(`${what} must point at a relation field`, 'invalid'); if (rel.config.targetDbs) throw new WeaveError(`${what} needs a single-target relation`, 'invalid'); const aggregate = type === 'lookup' ? null : config.aggregate ?? 'count'; if (aggregate && !AGGREGATES.includes(aggregate)) throw new WeaveError(`Invalid aggregate '${aggregate}' (use ${AGGREGATES.join(', ')})`, 'invalid'); if (aggregate !== 'count' && (config.targetField == null || config.targetField === '')) { throw new WeaveError(type === 'lookup' ? 'A lookup needs a targetField to read' : `A rollup that runs ${aggregate} needs a targetField to read`, 'invalid'); } if (type === 'lookup') return { relationField: rel.id, targetField: this.getField(rel.config.targetDb, config.targetField).id }; const targetField = aggregate === 'count' ? null : this.getField(rel.config.targetDb, config.targetField).id; return { relationField: rel.id, targetField, aggregate }; } #refuseSpaceRepoint(field, c) { const sent = (k) => k in c && c[k] !== undefined; const far = this.#tableAnywhere(field.config.via); const target = !sent('targetField') || (c.targetField ?? null) === field.config.targetField || (c.targetField != null && far?.owner.findField(far.table, c.targetField)?.id === field.config.targetField); if (sent('relationField') || sent('relation') || (sent('aggregate') && c.aggregate !== field.config.aggregate) || !target) { throw new WeaveError(`${field.name} is a space rollup: its recipe is set from the grid's Σ row, not here`, 'invalid'); } } #repoint(db, field, c) { const relRef = c.relationField ?? c.relation; if (relRef != null && !('targetField' in c) && field.config.targetField) { const nextTable = this.state.tables[this.getField(db.id, relRef).config.targetDb]; const oldTable = this.state.tables[db.fields[field.config.relationField]?.config.targetDb]; if (nextTable && !nextTable.fields[field.config.targetField]) { const oldName = oldTable?.fields[field.config.targetField]?.name ?? field.config.targetField; throw new WeaveError(`'${oldName}' is a field of ${oldTable?.name ?? 'the old table'}, not ${nextTable.name}: name the targetField to read there`, 'invalid'); } } return this.#recipe(db, field.type, { relationField: relRef ?? field.config.relationField, targetField: 'targetField' in c ? c.targetField : field.config.targetField, aggregate: c.aggregate ?? field.config.aggregate, }); } #formulasBroken(before, after) { const was = new Map(before.map((h) => [h.field, h])); return after.filter((h) => { const prior = was.get(h.field) ?? { ok: true, errors: 0, nulls: 0 }; return (prior.ok && !h.ok) || h.errors > prior.errors || h.nulls > prior.nulls; }).map(({ field, rows, errors, nulls, error }) => ({ field, rows, errors, nulls, ...(error ? { error } : {}) })); } #formulaHealth(db, field) { const reads = (f) => { try { return formulaReferences(f.config.expression ?? '').some((n) => String(n).toLowerCase() === field.name.toLowerCase()); } catch { return false; } }; return Object.values(db.fields).filter((f) => f.type === 'formula' && reads(f)).map((f) => { const checked = this.checkFormula(db.id, f.config.expression, { excludeField: f.id, scan: true }); const scan = checked.scan ?? { rows: 0, errors: 0, nulls: 0, sampleByOutcome: {} }; return { field: f.name, ok: checked.ok, rows: scan.rows, errors: scan.errors, nulls: scan.nulls, error: checked.ok ? scan.sampleByOutcome.error?.error ?? null : checked.error }; }); } updateField(dbRef, fieldRef, patch) { this.#mayShape(dbRef); return this.#updateField(dbRef, fieldRef, patch, null).field; } fieldChangeSince(dbRef, fieldId, seq) { const [last] = this.activityFeed({ entityId: this.getTable(dbRef).id, kinds: ['field-config-updated'], limit: 1 }).items; return last && last.seq > seq && last.detail.fieldId === fieldId ? last : null; } #updateField(dbRef, fieldRef, patch, undo, restore = null) { const db = this.getTable(dbRef); const field = this.getField(db.id, fieldRef); const before = fieldDefinition(field); const touchedBefore = this.#spacesTouching(db, field); const recipeSent = (patch.type == null || patch.type === field.type) && (field.type === 'lookup' || field.type === 'rollup') && patch.config && RECIPE_KEYS.some((k) => k in patch.config); if (recipeSent && field.config.via) this.#refuseSpaceRepoint(field, patch.config); const nextRecipe = recipeSent && !field.config.via ? this.#repoint(db, field, patch.config) : null; let renamed = false; if (patch.name != null) refuseReserved('field', patch.name); if (patch.name != null && patch.name !== field.name) { for (const v of db.tableViews ?? []) { if (v.filters?.[field.name]) { v.filters[patch.name] = v.filters[field.name]; delete v.filters[field.name]; } for (const s of v.sort ?? []) if (s.field === field.name) s.field = patch.name; } field.name = patch.name; renamed = true; } if (patch.type != null && patch.type !== field.type && field.type === 'view') { throw new WeaveError(`The ${field.config.shape} is fixed as a view — rename or reconfigure it`, 'invalid'); } let migrated = null; let healthBefore = null; let recipeBefore = null; if (patch.type != null && patch.type !== field.type) { migrated = this.#migrateFieldType(db, field, patch.type, patch.config ?? {}, { force: !!undo, restore }); for (const e of this.listEntities(db.id, { includeDeleted: true })) this.#mark(e); if (!undo) { patch = { ...patch, config: Object.fromEntries(Object.entries(patch.config ?? {}).filter(([k]) => ['width', 'default'].includes(k))) }; if (!Object.keys(patch.config).length) delete patch.config; } } if (patch.config) { if ('subtable' in patch.config) { const sub = this.#subtableConfig(field, patch.config.subtable); if (sub) field.config.subtable = sub; else delete field.config.subtable; } if ('term' in patch.config) { if (field.id !== db.nameFieldId) throw new WeaveError('The record term lives on the Name field', 'invalid'); this.#setTerm(db, patch.config.term, { save: false }); } if ('width' in patch.config) { const width = patch.config.width; if (width === null) delete field.config.width; else if (typeof width !== 'number' || !Number.isFinite(width) || width < MIN_COLUMN_WIDTH) { throw new WeaveError(`Column width must be a number of at least ${MIN_COLUMN_WIDTH}px`, 'invalid'); } else field.config.width = Math.round(width); } if ('description' in patch.config && field.type !== 'view') { const description = fieldDescriptionValue(patch.config.description); if (description) field.config.description = description; else delete field.config.description; } if (nextRecipe) { healthBefore = this.#formulaHealth(db, field); recipeBefore = this.#ontologyComputed(db, field, () => ''); Object.assign(field.config, nextRecipe); } FIELD_TYPE_CONFIG[field.type]?.merge?.(field.config, patch.config); if (field.type === 'view') field.config = this.#normalizeViewConfig(db, field, patch.config); if (field.type === 'formula' && patch.config.expression) { const names = Object.values(db.fields).filter((f) => f.id !== field.id).map((f) => f.name); const checked = checkExpression(patch.config.expression, names); if (!checked.ok) throw new WeaveError(checked.error, 'invalid'); this.#refuseFormulaCycle(db, field.id, field.name, patch.config.expression); field.config.expression = patch.config.expression; } if ('default' in patch.config) { if (patch.config.default === null) delete field.config.default; else field.config.default = this.#validateDefault(field, patch.config.default); } else if ((field.type === 'select' || field.type === 'multiselect') && patch.config.options && field.config.default != null) { const ids = new Set(field.config.options.map((o) => o.id)); const kept = [].concat(field.config.default).filter((id) => ids.has(id)); if (!kept.length) delete field.config.default; else field.config.default = field.type === 'select' ? kept[0] : kept; } } this.#syncFieldRow(db, field); if (renamed) this.#syncTableRow(db); const after = fieldDefinition(field); const changed = definitionChanges(before, after); const recipeMoved = !!nextRecipe && RECIPE_KEYS.some((k) => changed.includes(k)); const recipe = recipeMoved ? { before: recipeBefore, after: this.#ontologyComputed(db, field, () => '') } : null; const broken = recipeMoved ? this.#formulasBroken(healthBefore, this.#formulaHealth(db, field)) : []; const logged = !db.system && !this.#fieldLogQuiet && changed.length ? { table: this.qualifiedName(db), tableId: db.id, fieldId: field.id, field: field.name, changed, before, after, seq: this.#nextSeq(), ...(recipe ? { recipe } : {}), ...(broken.length ? { formulas: broken } : {}), ...(before.type !== after.type ? { lossy: true } : {}), ...(migrated ? { snapshot: migrated.rows } : {}), ...(undo ?? {}), ...(restore && migrated ? migrated.counts : {}) } : null; this.save(); if (logged) { this.#audit(undo ? 'field-config-undo' : 'field-config-updated', logged, [...touchedBefore, ...this.#spacesTouching(db, field)]); if (migrated) this.#dropOlderSnapshots(field.id, logged.seq); } else if (!db.system) this.#audit('field-updated', { table: db.name, name: field.name, patch: Object.keys(patch) }, changed.length ? [...touchedBefore, ...this.#spacesTouching(db, field)] : null); return { field, counts: migrated?.counts ?? null }; } #subtableConfig(field, raw) { if (field.type !== 'relation') throw new WeaveError('A subtable belongs on a relation field', 'invalid'); if (raw == null) return null; if (typeof raw !== 'object' || Array.isArray(raw)) throw new WeaveError('A subtable is an object of fields, filter and sort', 'invalid'); const target = field.config.targetDbs ? null : this.state.tables[field.config.targetDb]; if (!target) throw new WeaveError('A subtable needs a relation with one target table', 'invalid'); const named = (n) => { const f = typeof n === 'string' ? this.findField(target, n) : null; if (!f) throw new WeaveError(`Field '${n}' not found in table '${target.name}'`, 'not-found'); return f.name; }; const list = (key) => { if (raw[key] == null) return null; if (!Array.isArray(raw[key])) throw new WeaveError(`A subtable ${key} is a list`, 'invalid'); return raw[key]; }; const out = {}; const fields = list('fields'); if (fields) out.fields = fields.map(named); const filter = list('filter'); if (filter?.length) { this.#checkWhere(target, filter); out.filter = structuredClone(filter); } const sort = (list('sort') ?? []).map((s) => { const { field: name, dir = 'asc' } = typeof s === 'string' ? { field: s } : s ?? {}; if (dir !== 'asc' && dir !== 'desc') throw new WeaveError('A subtable sort runs asc or desc', 'invalid'); return { field: Object.hasOwn(SYSTEM_SORT_KEYS, name) ? name : named(name), dir }; }); if (sort.length) out.sort = sort; return Object.keys(out).length ? out : null; } #dropOlderSnapshots(fieldId, keepSeq) { for (const r of this.store.listAudit({ limit: -1, actions: Object.keys(FIELD_CONFIG_ACTIONS) })) { const d = r.detail ?? {}; if (d.fieldId !== fieldId || !d.snapshot || d.seq === keepSeq) continue; const { snapshot, ...rest } = d; this.store.setAuditDetail(r.seq, { ...rest, snapshotDropped: true }); } } static #publicDetail(d) { return d?.snapshot ? { ...d, snapshot: { rows: Object.keys(d.snapshot).length } } : d; } #fieldLogQuiet = false; #withoutFieldLog(fn) { const was = this.#fieldLogQuiet; this.#fieldLogQuiet = true; try { return fn(); } finally { this.#fieldLogQuiet = was; } } #fieldConfigRows() { return this.store.listAudit({ limit: -1, actions: Object.keys(FIELD_CONFIG_ACTIONS) }).map((r) => { const d = r.detail ?? {}; const db = this.state.tables[d.tableId]; return { id: `${d.tableId}:f${r.seq}`, seq: d.seq, ts: r.at, kind: FIELD_CONFIG_ACTIONS[r.action], actor: r.actor ?? null, detail: Weave.#publicDetail(d), entityId: d.tableId, entityName: db?.fields[d.fieldId]?.name ?? d.field, publicId: null, dbId: d.tableId, db: db ? this.qualifiedName(db) : d.table, space: db ? (this.state.spaces[db.spaceId]?.name ?? null) : null, deleted: !db || !!db.deletedAt, scope: 'field', }; }); } #fieldConfigRow(id, rows = this.#fieldConfigRows()) { const row = rows.find((r) => r.id === String(id)); if (!row) throw new WeaveError(`Activity '${id}' is not a field configuration entry`, 'not-found'); return row; } #rollbackCheck(row, rows) { const d = row.detail; const db = this.state.tables[d.tableId]; if (!db || db.deletedAt) return { ok: false, code: 'conflict', reason: `The table ${d.table} is gone or in the trash, so ${d.field} cannot be rolled back.` }; const field = db.fields[d.fieldId]; if (!field) return { ok: false, code: 'conflict', reason: `${d.field} has been deleted, so there is nothing to roll back.` }; if (d.lossy && !d.snapshot) { return { ok: false, code: 'conflict', reason: d.snapshotDropped ? `The values from before this change are no longer kept: weave keeps them for the newest type change of ${field.name} only, and a newer one replaced them. Rolling back this entry cannot bring its values back.` : `This type change was recorded before weave kept the values a type change converts, so rolling it back cannot bring its values back.` }; } const later = rows.find((r) => r.detail.fieldId === d.fieldId && r.seq > row.seq); if (later) return { ok: false, code: 'conflict', reason: `${field.name} changed again after this entry (${later.id}). Roll back the newer change first.` }; if (canonicalJSON(fieldDefinition(field)) !== canonicalJSON(d.after)) { return { ok: false, code: 'conflict', reason: `${field.name} has changed since this entry, so its definition no longer matches what the entry recorded.` }; } return { ok: true }; } rollbackFieldConfig(id, { table = null, field = null, via = 'rollback' } = {}) { const rows = this.#fieldConfigRows(); const row = this.#fieldConfigRow(id, rows); const d = row.detail; if (table != null && this.getTable(table).id !== d.tableId) throw new WeaveError(`Activity '${id}' belongs to ${d.table}, not ${table}`, 'invalid'); if (field != null && this.getField(d.tableId, field).id !== d.fieldId) throw new WeaveError(`Activity '${id}' is a change to ${d.field}, not ${field}`, 'invalid'); const check = this.#rollbackCheck(row, rows); if (!check.ok) throw new WeaveError(check.reason, check.code); const f = this.state.tables[d.tableId].fields[d.fieldId]; const now = fieldDefinition(f); const patch = { config: structuredClone(d.before.config) }; if (d.before.name !== f.name) patch.name = d.before.name; if (f.type !== 'view') for (const k of Object.keys(now.config)) if (!(k in d.before.config)) patch.config[k] = null; let restore = null; if (d.before.type !== f.type) { patch.type = d.before.type; const auditSeq = Number(String(row.id).slice(String(row.id).lastIndexOf(':f') + 2)); restore = this.store.listAudit({ limit: -1, actions: Object.keys(FIELD_CONFIG_ACTIONS) }).find((r) => r.seq === auditSeq)?.detail?.snapshot ?? {}; } const seq = this.state.meta.activitySeq ?? 0; const { counts } = this.#updateField(d.tableId, f.id, patch, { of: row.id, via: via === 'undo' ? 'undo' : 'rollback' }, restore); const made = this.#fieldConfigRows().find((r) => r.kind === 'undo' && r.seq > seq); return { field: f, activity: made?.id ?? null, ...(counts ?? {}) }; } #formulaCycle(db, fieldId, fieldName, expression) { const seen = new Set([fieldId]); const walk = (expr, path) => { for (const name of formulaReferences(expr)) { const f = this.findField(db, name); if (!f) { if (String(name).toLowerCase() === String(fieldName).toLowerCase()) return [...path, fieldName]; continue; } if (f.id === fieldId) return [...path, fieldName]; if (f.type !== 'formula') continue; if (seen.has(f.id)) continue; seen.add(f.id); const hit = walk(f.config?.expression ?? '', [...path, f.name]); if (hit) return hit; } return null; }; return walk(expression, [fieldName]); } #refuseFormulaCycle(db, fieldId, fieldName, expression) { const path = this.#formulaCycle(db, fieldId, fieldName, expression); if (path) { throw new WeaveError(`Formula '${fieldName}' would close a reference cycle: ${path.join(' → ')}`, 'invalid'); } } checkFormula(dbRef, expression, { entity = null, excludeField = null, scan = false } = {}) { const db = this.getTable(dbRef); const names = Object.values(db.fields).filter((f) => f.id !== excludeField && f.name !== excludeField).map((f) => f.name); const checked = checkExpression(expression, names); if (!checked.ok) return checked; const edited = excludeField && this.findField(db, excludeField); if (edited) { const path = this.#formulaCycle(db, edited.id, edited.name, expression); if (path) return { ok: false, error: `Formula '${edited.name}' would close a reference cycle: ${path.join(' → ')}` }; } const rows = this.listEntities(db.id); const e = entity ? this.getEntity(entity) : rows[0]; if (!e) return { ok: true }; const temp = { id: '__preview', name: '__preview', type: 'formula', config: { expression } }; const nameOf = (row) => String(row.values[db.nameFieldId] ?? ''); const kindOf = (v) => (v == null ? 'null' : typeof v === 'string' && (v.startsWith('#ERR: ') || isCycle(v)) ? 'error' : Array.isArray(v) ? 'list' : Number.isNaN(v) ? 'error' : typeof v === 'number' ? 'number' : typeof v === 'boolean' ? 'boolean' : 'text'); const preview = this.#resolve(e, db, temp, 0); const result = { ok: true, preview, previewEntity: nameOf(e), type: kindOf(preview) }; if (!scan) return result; const sample = rows.slice(0, FORMULA_SCAN_CAP); const out = { rows: sample.length, capped: rows.length > FORMULA_SCAN_CAP, nulls: 0, errors: 0, sampleByOutcome: { ok: null, null: null, error: null } }; const types = {}; for (const row of sample) { const v = this.#resolve(row, db, temp, 0); const kind = kindOf(v); if (kind === 'null') { out.nulls++; out.sampleByOutcome.null ??= { entity: nameOf(row), id: row.id }; } else if (kind === 'error') { out.errors++; out.sampleByOutcome.error ??= { entity: nameOf(row), id: row.id, error: isCycle(v) ? v : Number.isNaN(v) ? 'not a number' : v.slice(6) }; } else { types[kind] = (types[kind] ?? 0) + 1; out.sampleByOutcome.ok ??= { entity: nameOf(row), id: row.id, value: v }; } } const dominant = Object.entries(types).sort((a, b) => b[1] - a[1])[0]; if (dominant) result.type = dominant[0]; result.scan = out; return result; } #migrateFieldType(db, field, toType, config, { force = false, restore = null } = {}) { if (field.type === 'text' && toType === 'relation') return this.#textToRelation(db, field, config); if (field.type === 'relation' && toType === 'text' && force) return this.#relationToText(db, field, restore); const allowed = TYPE_MIGRATIONS[field.type] ?? []; if (!force && !allowed.includes(toType)) { throw new WeaveError(`A ${field.type} field can become ${allowed.length ? allowed.join(', ') : 'nothing else'} — not ${toType}`, 'invalid'); } if (field.id === db.nameFieldId && !(['text', 'formula'].includes(toType) && ['text', 'formula'].includes(field.type))) { throw new WeaveError('The Name field can be text or a formula — a name is a label, not a number or a date', 'invalid'); } const from = field.type; const rows = this.listEntities(db.id, { includeDeleted: true }); let nextConfig; if (toType === 'select' || toType === 'multiselect') { let options; if (from === 'select' || from === 'multiselect') options = field.config.options.map((o) => ({ ...o })); else if (from === 'workflow') options = field.config.states.map((s) => ({ id: s.id, name: s.name, color: '' })); else { const seen = new Map(); for (const e of rows) { const raw = e.values[field.id]; const parts = toType === 'multiselect' ? String(raw ?? '').split(',') : [raw]; for (const p of parts) { const v = String(p ?? '').trim(); if (v && !seen.has(v.toLowerCase())) seen.set(v.toLowerCase(), v); } } options = [...seen.values()]; } const sent = !!config.options?.length; if (sent) options = config.options; nextConfig = normalizeSelfContainedConfig(toType, { options }, { strict: sent }); } else if (toType === 'workflow') { const states = (from === 'select' ? field.config.options : []).map((o) => ({ id: o.id, name: o.name, category: 'in-progress', default: o.id === field.config.default })); nextConfig = normalizeSelfContainedConfig('workflow', { states: config.states?.length ? config.states : (states.length ? states : undefined) }); } else if (toType === 'formula') { if (!config.expression) throw new WeaveError('Formula field needs an expression', 'invalid'); const checked = checkExpression(config.expression, Object.values(db.fields).filter((f) => f.id !== field.id).map((f) => f.name)); if (!checked.ok) throw new WeaveError(checked.error, 'invalid'); this.#refuseFormulaCycle(db, field.id, field.name, config.expression); nextConfig = { expression: config.expression, ...formulaCostume(config) }; } else { nextConfig = normalizeSelfContainedConfig(toType, config); } const frozen = from === 'formula' ? new Map(rows.map((e) => { const v = this.#resolve(e, db, field, 0); return [e.id, v == null ? null : String(v)]; })) : null; const convert = FIELD_TYPE_CONFIG[toType]?.coerce; const ctx = { from, was: field.config, next: nextConfig, findOption: (opts, ref) => this.#findOption(opts, ref), coerceDate: (c, raw) => this.#coerceDate(c, raw) }; const coerce = (raw, e) => { if (frozen) return toType === 'text' ? frozen.get(e.id) : null; if (toType === 'formula') return null; if (raw == null || raw === '') return isBoolType(toType) ? false : null; return convert ? convert(raw, ctx) : null; }; const held = (v) => !(v == null || v === '' || (Array.isArray(v) && !v.length)); const snapshot = {}; const counts = restore ? { restored: 0, left: 0, converted: 0 } : null; for (const e of rows) { const raw = e.values[field.id]; let next = coerce(raw, e); if (restore) { const pair = restore[e.id]; if (pair && canonicalJSON(raw ?? null) === canonicalJSON(pair[1] ?? null)) { next = structuredClone(pair[0]); counts.restored += 1; } else if (pair) counts.left += 1; else if (held(raw)) counts.converted += 1; } if (held(raw)) snapshot[e.id] = [structuredClone(raw), structuredClone(next ?? null)]; e.values[field.id] = next; } field.type = toType; const width = field.config.width; const term = field.id === db.nameFieldId ? field.config.term : undefined; const description = field.config.description; const dflt = isBoolType(from) && isBoolType(toType) ? field.config.default : undefined; field.config = nextConfig; if (dflt !== undefined) field.config.default = dflt; if (width) field.config.width = width; if (description) field.config.description = description; if (term) field.config.term = term; if (!db.system) this.#audit('field-migrated', { table: db.name, name: field.name, from, to: toType, rows: rows.length }, db.spaceId); return { rows: snapshot, counts }; } #textToRelation(db, field, config) { if (field.id === db.nameFieldId) throw new WeaveError('The Name field can be text or a formula — a name is a label, not a link', 'invalid'); const ref = config.targetDb ?? config.to; if (ref == null || ref === '') throw new WeaveError('A text field becomes a relation to one table: pass config.targetDb, the table its values name', 'invalid'); const target = this.getTable(ref); const cardinality = config.cardinality ?? 'many-to-one'; if (!['many-to-one', 'many-to-many'].includes(cardinality)) throw new WeaveError(`A text field becomes a many-to-one or many-to-many relation, not ${cardinality}`, 'invalid'); const many = cardinality === 'many-to-many'; const invName = config.inverseName ?? this.#inverseName(db, true); refuseReserved('field', invName); if (this.findField(target, invName)) throw new WeaveError(`Field '${invName}' already exists in target table — pass config.inverseName`, 'conflict'); const exact = new Map(); const folded = new Map(); for (const e of this.listEntities(target.id)) { const n = this.entityName(e); if (!exact.has(n)) exact.set(n, e.id); if (!folded.has(n.toLowerCase())) folded.set(n.toLowerCase(), e.id); } const lookup = (v) => exact.get(v) ?? folded.get(v.toLowerCase()); const rows = this.listEntities(db.id, { includeDeleted: true }); const parts = (raw) => (raw == null || raw === '' ? [] : many ? String(raw).split(',') : [String(raw)]).map((s) => s.trim()).filter(Boolean); const missing = [...new Set(rows.flatMap((e) => parts(e.values[field.id])).filter((v) => !lookup(v)))]; if (missing.length && !config.createMissing) { const shown = missing.slice(0, 10).map((v) => `'${v}'`).join(', '); throw new WeaveError(`No ${target.name} record is named ${shown}${missing.length > 10 ? ` and ${missing.length - 10} more` : ''}: make those records first, or pass config.createMissing: true`, 'invalid'); } for (const v of missing) if (!lookup(v)) { const made = this.createEntity(target.id, { name: v }); exact.set(v, made.id); folded.set(v.toLowerCase(), made.id); } const inverse = { id: uuid(), name: invName, type: 'relation', config: { targetDb: db.id, many: true, inverseFieldId: field.id } }; target.fields[inverse.id] = inverse; placeField(target, inverse.id); const raws = new Map(rows.map((e) => [e.id, e.values[field.id]])); const { width, description } = field.config; field.type = 'relation'; field.config = { targetDb: target.id, many, inverseFieldId: inverse.id, ...(width ? { width } : {}), ...(description ? { description } : {}) }; const snapshot = {}; for (const e of rows) { const raw = raws.get(e.id); e.values[field.id] = many ? [] : null; const ids = [...new Set(parts(raw).map(lookup))]; if (ids.length) this.#setRelationValue(e, db, field, ids); if (raw != null && raw !== '') snapshot[e.id] = [raw, structuredClone(e.values[field.id] ?? null)]; } this.#syncFieldRow(target, inverse); this.#syncTableRow(target); if (!db.system) this.#audit('field-migrated', { table: db.name, name: field.name, from: 'text', to: 'relation', rows: rows.length, target: target.name }, [db.spaceId, target.spaceId]); return { rows: snapshot, counts: null }; } #relationToText(db, field, restore) { if (field.config.targetDbs) throw new WeaveError('A relation to several tables cannot become text', 'invalid'); const invId = field.config.inverseFieldId; const readers = this.listTables().flatMap((t) => Object.values(t.fields) .filter((f) => (f.type === 'lookup' || f.type === 'rollup') && [field.id, invId].includes(f.config.relationField)) .map((f) => `${t.name}.${f.name}`)); if (readers.length) throw new WeaveError(`${readers.join(', ')} read${readers.length === 1 ? 's' : ''} ${field.name} — delete ${readers.length === 1 ? 'it' : 'them'} first`, 'invalid'); const rows = this.listEntities(db.id, { includeDeleted: true }); const snapshot = {}; const counts = restore ? { restored: 0, left: 0, converted: 0 } : null; for (const e of rows) { const ids = this.#relationIds(e, field); const stored = e.values[field.id] ?? null; let next = ids.map((id) => this.state.entities[id]).filter(Boolean).map((t) => this.entityName(t)).join(', ') || null; const pair = restore?.[e.id]; if (pair && canonicalJSON(stored) === canonicalJSON(pair[1] ?? null)) { next = pair[0]; counts.restored += 1; } else if (pair) counts.left += 1; else if (counts && ids.length) counts.converted += 1; if (ids.length) snapshot[e.id] = [structuredClone(stored), next]; if (ids.length) this.#setRelationValue(e, db, field, []); e.values[field.id] = next; } const target = this.state.tables[field.config.targetDb]; if (target && invId) { this.#removeFieldRaw(target, invId); this.#dropFieldRow(invId); this.#syncTableRow(target); } const { width, description } = field.config; field.type = 'text'; field.config = { ...(width ? { width } : {}), ...(description ? { description } : {}) }; if (!db.system) this.#audit('field-migrated', { table: db.name, name: field.name, from: 'relation', to: 'text', rows: rows.length }, db.spaceId); return { rows: snapshot, counts }; } deleteField(dbRef, fieldRef, { hard = false } = {}) { this.#mayShape(dbRef); const db = this.getTable(dbRef); const binned = this.findField(db, fieldRef) ? null : this.#trashedField(db, fieldRef); if (binned) { if (hard) this.#purgeTrashedField(db, binned); return { id: binned.field.field.id, name: binned.field.field.name, db: this.qualifiedName(db), deleted: true }; } const field = this.getField(db.id, fieldRef); if (field.id === db.nameFieldId) throw new WeaveError('Cannot delete the Name field', 'invalid'); if (field.type === 'view') throw new WeaveError(`Cannot delete '${field.name}': every record has a ${field.config.shape} by existing — hide it instead`, 'invalid'); if (field.system) throw new WeaveError(`Field '${field.name}' is part of the system registry`, 'invalid'); const readers = this.#targetFieldReaders(field.id); if (readers.length) { throw new WeaveError(`Cannot delete '${field.name}': ${readers.join(', ')} read${readers.length === 1 ? 's' : ''} it — delete ${readers.length === 1 ? 'that field' : 'those fields'} first`, 'invalid'); } const entry = hard || db.system ? null : this.#fieldTrashEntry(db, field); const touched = this.#spacesTouching(db, field); for (const shape of VIEW_SHAPES) { const v = this.viewField(db, shape); if (Array.isArray(v?.config.fields) && v.config.fields.includes(field.id)) v.config.fields = v.config.fields.filter((id) => id !== field.id); } if (field.id === db.descriptionFieldId) db.descriptionFieldId = null; if (field.type === 'relation') { for (const e of this.listEntities(db.id)) { if (e.values[field.id] != null) this.#setRelationValue(e, db, field, []); } const other = this.state.tables[field.config.targetDb]; if (other) this.#removeFieldRaw(other, field.config.inverseFieldId); } for (const f of Object.values(db.fields)) { if ((f.type === 'lookup' || f.type === 'rollup') && f.config.relationField === field.id) { this.#removeFieldRaw(db, f.id); } } this.#removeFieldRaw(db, field.id); if (entry) { (db.trashedFields ??= {})[field.id] = entry; this.#trashSysRow('fields', field.id); } else { this.#dropFieldRow(field.id); } if (field.type === 'relation' && field.config.inverseFieldId) this.#dropFieldRow(field.config.inverseFieldId); this.#syncTableRow(db); if (field.type === 'relation' && field.config.targetDb) { const far = this.state.tables[field.config.targetDb]; if (far) this.#syncTableRow(far); } this.save(); if (!db.system) this.#audit(entry ? 'field-trashed' : 'field-deleted', { table: db.name, name: field.name }, touched); return { id: field.id, name: field.name, db: this.qualifiedName(db), deleted: true }; } restoreField(dbRef, fieldRef) { this.#mayShape(dbRef); const db = this.getTable(dbRef); const binned = this.#trashedField(db, fieldRef); if (!binned) throw new WeaveError(`Field '${fieldRef}' not found in the trash of '${db.name}'`, 'not-found'); const sp = this.state.spaces[db.spaceId]; if (db.deletedAt || sp?.deletedAt) throw new WeaveError(`Table '${db.name}' is in the trash — restore the table first`, 'invalid'); const main = binned.field.field; const clash = (table, name) => Object.values(table.fields).find((f) => f.name.toLowerCase() === name.toLowerCase()); if (clash(db, main.name)) throw new WeaveError(`A live field already holds the name '${db.name}.${main.name}' — rename it first`, 'conflict'); for (const dep of binned.dependents) { if (clash(db, dep.field.name)) throw new WeaveError(`A live field already holds the name '${db.name}.${dep.field.name}' — rename it first`, 'conflict'); } let far = null; if (binned.inverse) { far = this.state.tables[binned.inverse.dbId]; if (!far) throw new WeaveError(`'${main.name}' points at a table that is gone — purge it instead`, 'conflict'); if (clash(far, binned.inverse.field.name)) { throw new WeaveError(`A live field already holds the name '${far.name}.${binned.inverse.field.name}' — rename it first`, 'conflict'); } } const placements = [[db, binned.field], ...binned.dependents.map((d) => [db, d]), ...(far ? [[far, binned.inverse]] : [])] .sort((x, y) => x[1].position - y[1].position); for (const [table, snap] of placements) this.#reinstateField(table, snap); delete db.trashedFields[main.id]; if (!Object.keys(db.trashedFields).length) delete db.trashedFields; this.#restoreSysRow('fields', main.id); for (const [table, snap] of placements) this.#syncFieldRow(table, table.fields[snap.field.id]); this.#syncTableRow(db); if (far && far.id !== db.id) this.#syncTableRow(far); this.save(); if (!db.system) this.#audit('field-restored', { table: db.name, name: main.name }, this.#spacesTouching(db, db.fields[main.id])); return db.fields[main.id]; } #trashedField(db, ref) { if (ref && typeof ref === 'object') ref = ref.id; const bin = db.trashedFields ?? {}; if (own(bin, ref)) return bin[ref]; return Object.values(bin) .filter((t) => t.field.field.name.toLowerCase() === String(ref).toLowerCase()) .sort((a, b) => String(b.deletedAt).localeCompare(String(a.deletedAt)))[0] ?? null; } #trashedFieldAnywhere(fieldId) { for (const w of this.#engines()) { const table = Object.values(w.state.tables).find((t) => own(t.trashedFields ?? {}, fieldId)); if (table) return { owner: w, table }; } return null; } #purgeTrashedField(db, binned) { const main = binned.field.field; delete db.trashedFields[main.id]; if (!Object.keys(db.trashedFields).length) delete db.trashedFields; this.#dropFieldRow(main.id); for (const dep of binned.dependents) this.#dropFieldRow(dep.field.id); this.save(); if (!db.system) this.#audit('field-deleted', { table: db.name, name: main.name }, db.spaceId); } #fieldTrashEntry(db, field) { const entry = { deletedAt: nowISO(), field: this.#fieldSnapshot(db, field), inverse: null, dependents: [] }; if (field.type === 'relation' && field.config.inverseFieldId) { const far = this.state.tables[field.config.targetDb]; const inv = far?.fields[field.config.inverseFieldId]; if (inv) entry.inverse = { dbId: far.id, ...this.#fieldSnapshot(far, inv) }; } for (const f of Object.values(db.fields)) { if ((f.type === 'lookup' || f.type === 'rollup') && f.config.relationField === field.id) entry.dependents.push(this.#fieldSnapshot(db, f)); } return entry; } #fieldSnapshot(db, field) { const snap = { field: structuredClone(field), position: db.fieldOrder.indexOf(field.id), values: {}, docs: {}, shapes: [], views: [] }; if (db.descriptionFieldId === field.id) snap.description = true; for (const shape of VIEW_SHAPES) { const at = this.viewField(db, shape)?.config.fields?.indexOf(field.id) ?? -1; if (at >= 0) snap.shapes.push({ shape, index: at }); } for (const v of db.tableViews ?? []) { const index = v.fields.indexOf(field.id); const place = { id: v.id, index }; if (index >= 0 && index < (v.frozen ?? 0)) place.frozen = true; if (v.widths?.[field.id] != null) place.width = v.widths[field.id]; if (v.parked?.[field.id] != null) place.parked = v.parked[field.id]; if (v.filters?.[field.name] != null) place.filter = v.filters[field.name]; const sorts = (v.sort ?? []).map((s, i) => [i, s]).filter(([, s]) => s.field === field.name); if (sorts.length) place.sort = sorts; if (Object.keys(place).length > 2 || index >= 0) snap.views.push(place); } for (const e of this.listEntities(db.id)) { if (e.values[field.id] != null) snap.values[e.id] = structuredClone(e.values[field.id]); if (e.docs?.[field.id] != null) snap.docs[e.id] = e.docs[field.id]; } return snap; } #reinstateField(db, snap) { const f = structuredClone(snap.field); db.fields[f.id] = f; if (!db.fieldOrder.includes(f.id)) db.fieldOrder.splice(Math.max(0, Math.min(snap.position, db.fieldOrder.length)), 0, f.id); if (snap.description && db.descriptionFieldId == null) db.descriptionFieldId = f.id; for (const { shape, index } of snap.shapes) { const vf = this.viewField(db, shape); if (Array.isArray(vf?.config.fields) && !vf.config.fields.includes(f.id)) vf.config.fields.splice(Math.min(index, vf.config.fields.length), 0, f.id); } for (const place of snap.views) { const v = (db.tableViews ?? []).find((x) => x.id === place.id); if (!v) continue; if (place.index >= 0 && !v.fields.includes(f.id)) v.fields.splice(Math.min(place.index, v.fields.length), 0, f.id); if (place.frozen) v.frozen = (v.frozen ?? 0) + 1; if (place.width != null) (v.widths ??= {})[f.id] = place.width; if (place.parked != null) (v.parked ??= {})[f.id] = place.parked; if (place.filter != null) (v.filters ??= {})[f.name] = place.filter; for (const [i, s] of place.sort ?? []) { v.sort ??= []; if (!v.sort.some((x) => x.field === s.field)) v.sort.splice(Math.min(i, v.sort.length), 0, s); } } const exists = (id) => !!own(this.state.entities, id); for (const [id, value] of Object.entries(snap.values)) { const e = own(this.state.entities, id); if (!e) continue; if (f.type === 'relation') { const ids = (Array.isArray(value) ? value : [value]).filter(exists); e.values[f.id] = f.config.many ? ids : (ids[0] ?? null); } else { e.values[f.id] = value; } this.#mark(e); } for (const [id, doc] of Object.entries(snap.docs)) { const e = own(this.state.entities, id); if (!e) continue; (e.docs ??= {})[f.id] = doc; this.#mark(e); } } #targetFieldReaders(fieldId) { const out = []; for (const t of Object.values(this.state.tables)) { for (const f of Object.values(t.fields)) { if ((f.type === 'lookup' || f.type === 'rollup') && f.config.targetField === fieldId) out.push(`${t.name}.${f.name}`); } } return out; } #removeFieldRaw(db, fieldId) { if (!db.fields[fieldId]) return; const gone = db.fields[fieldId].name; delete db.fields[fieldId]; db.fieldOrder = db.fieldOrder.filter((id) => id !== fieldId); for (const v of db.tableViews ?? []) { const k = v.fields.indexOf(fieldId); if (k >= 0 && k < (v.frozen ?? 0)) { v.frozen -= 1; if (!v.frozen) delete v.frozen; } if (v.widths) { delete v.widths[fieldId]; if (!Object.keys(v.widths).length) delete v.widths; } if (v.parked) { delete v.parked[fieldId]; if (!Object.keys(v.parked).length) delete v.parked; } v.fields = v.fields.filter((id) => id !== fieldId); if (v.filters?.[gone]) { delete v.filters[gone]; if (!Object.keys(v.filters).length) delete v.filters; } if (v.sort?.some((s) => s.field === gone)) { v.sort = v.sort.filter((s) => s.field !== gone); if (!v.sort.length) delete v.sort; } } for (const e of this.listEntities(db.id)) { delete e.values[fieldId]; if (e.docs) delete e.docs[fieldId]; this.#mark(e); } } documentFields(db) { return db.fieldOrder.map((id) => db.fields[id]).filter((f) => f.type === 'document'); } #resolveDocField(db, fieldRef = null) { if (fieldRef == null) { const described = this.descriptionField(db) ?? this.documentFields(db)[0]; if (!described) throw new WeaveError(`Table '${db.name}' has no document field`, 'not-found'); return described; } const f = this.findField(db, fieldRef); if (!f || f.type !== 'document') throw new WeaveError(`'${fieldRef}' is not a document field of '${db.name}'`, 'invalid'); return f; } listEntities(dbId, { includeDeleted = false } = {}) { const rows = this.#tableRows(dbId); return includeDeleted ? rows : rows.filter((e) => !e.deletedAt); } #rowIndex() { const entities = this.state.entities; if (this.#rows?.entities !== entities) { this.#rows = { entities, byTable: new Map(), byPublicId: new Map(), sortKeys: new Map(), sortOrders: new Map(), whereHits: new Map(), liveCounts: null }; for (const e of Object.values(entities)) this.#indexRow(e); } return this.#rows; } #forgetDerived() { const index = this.#rows; if (!index) return; index.sortKeys.clear(); index.sortOrders.clear(); index.whereHits.clear(); index.liveCounts = null; } #derivedKey(db, ...parts) { const d = this.now(); const day = d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate(); return [db.id, ...parts, this.viewerZone ?? '', day, this.#hiddenKey()].join('\n'); } #remember(map, key, value) { if (map.size >= DERIVED_CACHE_CAP) map.delete(map.keys().next().value); map.set(key, value); return value; } #sortKeys(db, path) { const index = this.#rowIndex(); const key = this.#derivedKey(db, String(path)); return index.sortKeys.get(key) ?? this.#remember(index.sortKeys, key, new Map()); } #indexRow(e) { const index = this.#rows; if (index?.entities !== this.state.entities) return; let table = index.byTable.get(e.dbId); if (!table) index.byTable.set(e.dbId, (table = new Map())); table.set(e.id, e); index.byPublicId.delete(e.dbId); } #unindexRow(e) { const index = this.#rows; if (index?.entities !== this.state.entities) return; index.byTable.get(e.dbId)?.delete(e.id); index.byPublicId.delete(e.dbId); } #tableRows(dbId) { const table = this.#rowIndex().byTable.get(dbId); return table ? [...table.values()] : []; } #rowsByPublicId(dbId, publicId) { const index = this.#rowIndex(); let pids = index.byPublicId.get(dbId); if (!pids) { pids = new Map(); for (const e of index.byTable.get(dbId)?.values() ?? []) { const key = String(e.publicId); const same = pids.get(key); if (same) same.push(e); else pids.set(key, [e]); } index.byPublicId.set(dbId, pids); } return pids.get(String(publicId)) ?? []; } #liveRowCounts() { const index = this.#rowIndex(); if (index.liveCounts) return index.liveCounts; const counts = new Map(); for (const [dbId, table] of index.byTable) { let live = 0; for (const e of table.values()) if (!e.deletedAt) live++; if (live) counts.set(dbId, live); } index.liveCounts = counts; return counts; } listTrash(dbRef = null, { workspace = null } = {}) { const dbId = dbRef == null ? null : this.getTable(dbRef).id; const mine = (e) => { const inWs = this.#inWorkspace(this.state.tables[e.dbId] ?? {}, workspace); return !inWs || inWs(e); }; return Object.values(this.state.entities) .filter((e) => e.deletedAt && (dbId == null || e.dbId === dbId) && mine(e) && !this.#rowHidden(e) && this.state.tables[e.dbId]?.system !== 'workspaces') .sort((a, b) => String(b.deletedAt).localeCompare(String(a.deletedAt))) .map((e) => this.readEntity(e.id)); } #present(id) { const e = own(this.state.entities, id); if (!e || e.deletedAt) return null; const db = this.state.tables[e.dbId]; if (!db || db.deletedAt || this.state.spaces[db.spaceId]?.deletedAt) return null; return e; } #liveEntity(id) { const e = this.#present(id); return e && !this.#rowHidden(e) ? e : null; } #lockedEntity(id) { const e = this.#present(id); return !!e && this.#rowHidden(e); } #hiddenSpaces() { const v = viewerOf(this); if (!v) return null; const memo = this.#query; if (memo && memo.viewer === v) return memo.hidden; const hidden = new Set(); for (const sp of Object.values(this.state.spaces)) if (spaceRole(this, v, sp) == null) hidden.add(sp.id); const out = hidden.size ? hidden : null; if (memo) { memo.viewer = v; memo.hidden = out; } return out; } #hiddenKey() { const hidden = this.#hiddenSpaces(); return hidden ? [...hidden].sort().join(',') : ''; } #tableHidden(db, hidden = this.#hiddenSpaces()) { return !!db && !!hidden?.has(db.spaceId); } #rowHidden(e, hidden = this.#hiddenSpaces()) { const db = e && this.state.tables[e.dbId]; if (!db) return false; if (hidden?.has(db.spaceId)) return true; return !!db.system && this.#sysRowHidden(e, db); } #sysRowHidden(e, db, depth = 0) { const owner = this.#ownerOf(e); const hidden = owner.#hiddenSpaces(); if (!hidden || depth > 2) return false; const tableOf = (id) => own(owner.state.tables, id) ?? Object.values(owner.state.tables).find((t) => own(t.fields, id) || t.tableViews?.some((v) => v.id === id)); switch (db.system) { case 'spaces': return hidden.has(e.sysId); case 'tables': case 'fields': case 'views': { const t = tableOf(e.sysId); return !!t && hidden.has(t.spaceId); } case 'users': case 'workspaces': return false; default: { const reg = this.#reg; return Object.values(db.fields).some((f) => f.type === 'relation' && ['spaces', 'tables'].includes(reg.state.tables[f.config.targetDb]?.system) && this.#relationIds(e, f).some((id) => { const r = own(reg.state.entities, id); return !!r && reg.#sysRowHidden(r, reg.state.tables[r.dbId], depth + 1); })); } } } #framed(fn) { if (this.#query) return fn(); this.#query = { rollupRows: new Map(), computed: new Map() }; try { return fn(); } finally { this.#query = null; } } #mayChange(spaceId, need) { const v = viewerOf(this); if (!v) return; const sp = this.state.spaces[spaceId]; const role = spaceRole(this, v, sp); if (role == null) throw new WeaveError(`Space '${spaceId}' not found`, 'not-found'); if (RANK[role] >= RANK[need]) return; throw new WeaveError(need === 'architect' ? `Changing the structure of '${sp.name}' needs an architect of that space` : `Your role in '${sp.name}' is ${role}: it reads and comments, nothing else`, 'forbidden'); } #readOnlyFor(db) { const v = viewerOf(this); if (!v || v === ANONYMOUS || !this.#guarded(db)) return false; const role = spaceRole(this, v, this.state.spaces[db.spaceId]); return role != null && RANK[role] < RANK.editor; } #guarded(db) { return !!viewerOf(this) && !db.system && !this.state.spaces[db.spaceId]?.system; } #mayWriteRow(ref) { if (!viewerOf(this)) return; const db = this.state.tables[this.getEntity(ref).dbId]; if (this.#guarded(db)) this.#mayChange(db.spaceId, 'editor'); } #mayWriteTable(ref) { if (!viewerOf(this)) return; const db = this.getTable(ref); if (this.#guarded(db)) this.#mayChange(db.spaceId, 'editor'); } #mayShape(ref) { if (!viewerOf(this)) return; const db = this.getTable(ref); if (this.#guarded(db)) this.#mayChange(db.spaceId, 'architect'); } canSee(ref) { const e = own(this.state.entities, ref); if (e) return !this.#rowHidden(e); const db = own(this.state.tables, ref); if (db) return !this.#tableHidden(db); return !this.#hiddenSpaces()?.has(ref); } hidesAnySpace() { return !!this.#hiddenSpaces(); } asAccount(account, fn) { const viewer = viewerFor(account); const prev = this.actor; return withViewers({ of: (w) => (w === this ? viewer : undefined) }, () => { this.actor = accountActor(account.id); try { return fn(); } finally { this.actor = prev; } }); } permalink(kind, id) { const ws = `/w/${this.state.meta.name}`; if (kind === 'workspace') return `${ws}/`; if (kind === 'view') return `${ws}/#/view/${id}`; return `/${{ entity: 'e', table: 't', space: 's' }[kind]}/${id}`; } getEntity(id) { const e = own(this.state.entities, id) ?? own(this.state.entities, pastedEntityId(id)); if (e && this.#rowHidden(e)) throw new WeaveError(`Entity '${id}' not found`, 'not-found'); if (e) return e; const m = /^(.+)#(\d+)$/.exec(String(id)); if (m && this.findTable(m[1])) { const found = this.findEntity(m[1], '#' + m[2]); if (found) return found; const dbId = this.findTable(m[1]).id; const trashed = this.#rowsByPublicId(dbId, Number(m[2])).find((x) => x.deletedAt && x.publicId === Number(m[2])); if (trashed) return trashed; } throw new WeaveError(`Entity '${id}' not found`, 'not-found'); } findEntity(dbRef, ref) { if (ref && typeof ref === 'object') ref = ref.id; const byId = own(this.state.entities, ref) ?? own(this.state.entities, pastedEntityId(ref)); if (byId) return this.#rowHidden(byId) ? undefined : byId; const db = this.getTable(dbRef); const q = /^(.+)#(\d+)$/.exec(String(ref)); if (q) { const qt = this.findTable(q[1]); if (qt && qt.id === db.id) { const byPid = this.#rowsByPublicId(db.id, q[2]).find((e) => !e.deletedAt); if (byPid) return byPid; } } const pid = String(ref).replace(/^#/, ''); if (/^\d+$/.test(pid)) { const byPid = this.#rowsByPublicId(db.id, pid).find((e) => !e.deletedAt); if (byPid) return byPid; } const list = this.listEntities(db.id); return list.find((e) => this.entityName(e) === ref) ?? list.find((e) => this.entityName(e).toLowerCase() === String(ref).toLowerCase()); } entityName(e) { const db = this.state.tables[e.dbId]; const f = db?.fields?.[db.nameFieldId]; if (f?.type === 'formula') { const v = this.#resolve(e, db, f, 0); return v == null ? '' : String(v); } return String(e.values[db.nameFieldId] ?? ''); } createEntity(dbRef, input = {}, { depth = 0, viewerZone = null } = {}) { if (viewerZone != null) return this.#inViewerZone(viewerZone, () => this.createEntity(dbRef, input, { depth })); this.#mayWriteTable(dbRef); const db = this.getTable(dbRef); const meta = this.#interceptCreate(db, input); if (meta) return meta; return this.store.batch(() => this.#insertEntity(db, input, depth)); } #claimPublicId(db) { db.publicIdCounter = Math.max(db.publicIdCounter ?? 0, this.store.maxPublicId(db.id)); return ++db.publicIdCounter; } #insertEntity(db, input, depth) { if (db.system === 'integrations' && !this.#inMetaSync) input = this.#integrationInput(db, input); if (db.system === 'forms') input = { ...input, values: normalizeFormValues(this, db, { ...Object.fromEntries(Object.entries(input).filter(([key]) => !CREATE_INPUT_KEYS.has(key))), ...(input.values ?? {}) }) }; const flat = Object.fromEntries( Object.entries(input).filter(([k]) => !CREATE_INPUT_KEYS.has(k))); const values = { ...flat, ...(input.values ?? {}) }; const nameField = db.fields[db.nameFieldId]; if (nameField?.type === 'formula') { delete values.Name; delete values[nameField.name]; } else if (input.name != null && values.Name == null && values[nameField?.name] == null) { values[nameField?.name ?? 'Name'] = input.name; } const e = { id: uuid(), dbId: db.id, publicId: this.#claimPublicId(db), values: {}, docs: {}, comments: [], activity: [], files: [], createdAt: nowISO(), updatedAt: nowISO(), createdBy: this.actor, modifiedBy: this.actor, }; if (input.doc) { const f = this.#resolveDocField(db); e.docs[f.id] = stripEmptyEmbeds(input.doc); } for (const [fieldName, md] of Object.entries(input.docs ?? {})) { const f = this.#resolveDocField(db, fieldName); e.docs[f.id] = stripEmptyEmbeds(md); } const named = new Set(); for (const key of Object.keys(values)) { const f = this.findField(db, key); if (f) named.add(f.id); } for (const f of Object.values(db.fields)) { if (named.has(f.id)) continue; if (f.type === 'workflow') { const d = f.config.states.find((s) => s.default); if (d) e.values[f.id] = d.id; } else if (f.config?.default !== undefined) { e.values[f.id] = this.#resolveDefault(f); } } this.state.entities[e.id] = e; this.#indexRow(e); this.#logActivity(e, 'created', {}); try { this.#applyValues(e, db, values, { depth, isCreate: true }); } catch (err) { delete this.state.entities[e.id]; this.#unindexRow(e); db.publicIdCounter--; throw err; } this.#recordUndo('create', e); for (const [fid, text] of Object.entries(e.docs)) { if (text && db.fields[fid]) this.#recordDocRevision(e, db.fields[fid], '', text, { fresh: true }); } this.#runAutomations(db, e, { type: 'entity-created' }, depth); this.save(); return e; } updateEntity(id, valuesByName, { depth = 0, expectedRevision = null, viewerZone = null } = {}) { if (viewerZone != null) return this.#inViewerZone(viewerZone, () => this.updateEntity(id, valuesByName, { depth, expectedRevision })); this.#mayWriteRow(id); if (expectedRevision != null) return this.#updateAtRevision(id, valuesByName, expectedRevision); const e = this.getEntity(id); const db = this.state.tables[e.dbId]; const meta = this.#interceptUpdate(e, db, valuesByName); if (meta) return meta; if (db.system === 'forms') valuesByName = normalizeFormValues(this, db, valuesByName, e); const before = this.#undoBefore(e, db, Object.keys(valuesByName)); this.#applyValues(e, db, valuesByName, { depth }); if (this.#undoChanged(e, before)) this.#recordUndo('update', e, { before }); this.save(); return e; } #checkValues(e, db, valuesByName) { for (const [key, raw] of Object.entries(valuesByName)) { const field = this.findField(db, key); if (!field) throw new WeaveError(`Field '${key}' not found in table '${db.name}'`, 'not-found'); if (COMPUTED_TYPES.includes(field.type)) { throw new WeaveError(`Field '${field.name}' is computed (${field.type}) and cannot be written`, 'invalid'); } if (field.type === 'relation') { const ids = this.#normalizeRelationInput(field, raw); if (!field.config.many && ids.length > 1) throw new WeaveError(`Field '${field.name}' holds a single entity`, 'invalid'); continue; } if (field.type === 'workflow') { const empty = raw == null || raw === ''; const states = field.config.states; if (!empty && !states.some((s) => s.id === raw || s.name === raw || s.name.toLowerCase() === String(raw).toLowerCase())) { throw new WeaveError(`'${raw}' is not a state of '${field.name}'`, 'invalid'); } continue; } if (field.type === 'document') continue; const val = this.#validateValue(field, raw); if (!isFileField(field)) continue; for (const id of val) { const meta = e.files.find((x) => x.id === id); if (!meta) throw new WeaveError(`'${id}' is not a file on this entity — upload it here first`, 'invalid'); if (field.type === 'avatar' && !AVATAR_IMAGE_TYPES.includes(String(meta.mime ?? '').toLowerCase())) { throw new WeaveError(`'${meta.name}' is not an image; an avatar takes a PNG, JPEG, GIF or WebP`, 'invalid'); } } } } #applyValues(e, db, valuesByName, { depth = 0, isCreate = false } = {}) { this.#checkValues(e, db, valuesByName); const held = this.#holdRows(e, db, valuesByName); const after = []; try { this.#writeValues(e, db, valuesByName, { depth, isCreate, after }); } catch (err) { this.#restoreRows(held); throw err; } for (const step of after) step(); } #holdRows(e, db, valuesByName) { const ids = new Set([e.id]); for (const [key, raw] of Object.entries(valuesByName)) { const field = this.findField(db, key); if (field?.type !== 'relation') continue; const next = this.#normalizeRelationInput(field, raw); const inverse = field.config.inverseFieldId ? this.state.tables[field.config.targetDb]?.fields[field.config.inverseFieldId] : null; for (const id of [...this.#relationIds(e, field), ...next]) { ids.add(id); const holder = inverse && !inverse.config.many ? own(this.state.entities, id)?.values[inverse.id] : null; if (holder) ids.add(holder); } } const held = new Map(); for (const id of ids) { const row = own(this.state.entities, id); if (row) held.set(id, [row, structuredClone(row)]); } return held; } #restoreRows(held) { for (const [row, copy] of held.values()) { for (const key of Object.keys(row)) delete row[key]; Object.assign(row, copy); this.#mark(row); } } #writeValues(e, db, valuesByName, { depth, isCreate, after }) { for (const [key, raw] of Object.entries(valuesByName)) { const field = this.findField(db, key); if (!field) throw new WeaveError(`Field '${key}' not found in table '${db.name}'`, 'not-found'); if (COMPUTED_TYPES.includes(field.type)) { throw new WeaveError(`Field '${field.name}' is computed (${field.type}) and cannot be written`, 'invalid'); } if (field.type === 'relation') { const ids = this.#normalizeRelationInput(field, raw); this.#setRelationValue(e, db, field, ids); continue; } if (field.type === 'workflow') { this.#setStateInternal(e, db, field, raw, depth, after); continue; } if (field.type === 'document') { const md = stripEmptyEmbeds(raw); const before = e.docs[field.id] ?? ''; if (before === md) continue; const prior = { at: e.updatedAt, by: e.modifiedBy }; e.docs[field.id] = md; e.updatedAt = nowISO(); e.modifiedBy = this.actor; this.#mark(e); if (!isCreate) this.#logActivity(e, 'doc-updated', docChange(field.name, before, md)); after.push(() => this.#recordDocRevision(e, field, before, md, { prior })); continue; } const val = this.#validateValue(field, raw); if (isFileField(field)) { for (const id of val) { const meta = e.files.find((x) => x.id === id); if (!meta) { throw new WeaveError(`'${id}' is not a file on this entity — upload it here first`, 'invalid'); } if (field.type === 'avatar' && !AVATAR_IMAGE_TYPES.includes(String(meta.mime ?? '').toLowerCase())) { throw new WeaveError(`'${meta.name}' is not an image; an avatar takes a PNG, JPEG, GIF or WebP`, 'invalid'); } } } const old = e.values[field.id]; if (JSON.stringify(old) === JSON.stringify(val)) continue; e.values[field.id] = val; e.updatedAt = nowISO(); e.modifiedBy = this.actor; this.#mark(e); if (!isCreate) this.#logActivity(e, 'field-updated', { field: field.name, from: old ?? null, to: val }); after.push(() => this.#runAutomations(db, e, { type: 'field-updated', fieldId: field.id }, depth)); } } #validateDefault(field, raw) { if (!DEFAULTABLE_TYPES.includes(field.type)) { throw new WeaveError(`A ${field.type} field cannot carry a default value`, 'invalid'); } if (field.type === 'date' && DYNAMIC_DATE_DEFAULTS.includes(String(raw).trim())) return String(raw).trim(); return this.#validateValue(field, raw); } now() { return new Date(); } #resolveDefault(field) { const d = field.config.default; if (field.type === 'date' && DYNAMIC_DATE_DEFAULTS.includes(d)) { const c = field.config; const zone = c.zone === 'fixed' ? c.zoneName : c.zone === 'instant' ? 'UTC' : this.viewerZone ?? 'UTC'; const wall = DG.fromInstant(this.now().toISOString(), zone); if (c.grain != null) return DG.coerce(c, wall); const stamp = d === 'now()' && c.time ? wall : wall.slice(0, 10); return c.zone === 'instant' && c.time ? stamp + 'Z' : stamp; } return d; } #coerceDate(config, raw) { if (config.zone === 'instant') { const v = DG.coerceInstant(raw); if (!v) throw new WeaveError(`'${raw}' is not a valid date and time`, 'invalid'); return v; } if (config.grain == null) { const p = DG.partsOf(raw); const full = p?.y != null && p.m != null && p.d != null; if (Number.isNaN(Date.parse(raw)) || (!full && (p || !/\b\d{4}\b/.test(String(raw))))) throw new WeaveError(`'${raw}' is not a valid date`, 'invalid'); return String(raw); } try { return DG.coerce(config, raw); } catch (e) { throw new WeaveError(e.message, 'invalid'); } } #validateValue(field, raw) { if (raw == null || raw === '') return isBoolType(field.type) ? false : null; switch (field.type) { case 'toggle': { if (typeof raw === 'string') { const s = raw.trim().toLowerCase(); if (s === field.config.on.toLowerCase() || ['true', '1', 'yes'].includes(s)) return true; if (s === field.config.off.toLowerCase() || ['false', '0', 'no'].includes(s)) return false; throw new WeaveError(`'${raw}' is not a state of '${field.name}' (${field.config.on}, ${field.config.off})`, 'invalid'); } return Boolean(raw); } case 'text': return String(raw); case 'url': case 'email': { const s = String(raw); if (field.type === 'email' && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(s)) { throw new WeaveError(`'${s}' is not a valid email`, 'invalid'); } return s; } case 'number': { const n = Number(raw); if (!Number.isFinite(n)) throw new WeaveError(`'${raw}' is not a number`, 'invalid'); return n; } case 'rating': { const n = typeof raw === 'boolean' ? NaN : Number(raw); if (!Number.isFinite(n)) throw new WeaveError(`'${raw}' is not a rating of '${field.name}' (a whole number from 0 to ${field.config.max})`, 'invalid'); return ratingValue(n, field.config.max); } case 'date': return this.#coerceDate(field.config, raw); case 'daterange': { const { start, end } = raw ?? {}; if (start == null || end == null || start === '' || end === '') throw new WeaveError('Date range needs valid start and end', 'invalid'); return { start: this.#coerceDate(field.config, start), end: this.#coerceDate(field.config, end) }; } case 'checkbox': return Boolean(raw); case 'select': { const opt = this.#findOption(field.config.options, raw); if (!opt) throw new WeaveError(`'${raw}' is not an option of '${field.name}'`, 'invalid'); return opt.id; } case 'multiselect': { const arr = Array.isArray(raw) ? raw : [raw]; return arr.map((r) => { const opt = this.#findOption(field.config.options, r); if (!opt) throw new WeaveError(`'${r}' is not an option of '${field.name}'`, 'invalid'); return opt.id; }); } case 'field': return normalizeDefinition(raw, field.config.depth ?? 1, { strict: !this.#inMetaSync }); case 'key': return String(raw); case 'avatar': case 'attachments': { const arr = raw == null ? [] : Array.isArray(raw) ? raw : [raw]; if ((field.type === 'avatar' || field.config.multiple === false) && arr.length > 1) throw new WeaveError(`'${field.name}' holds one file`, 'invalid'); return arr.map((r) => String(r && typeof r === 'object' ? r.id : r)); } default: throw new WeaveError(`Cannot write field type '${field.type}'`, 'invalid'); } } #findOption(options, ref) { return options.find((o) => o.id === ref) ?? options.find((o) => o.name === ref) ?? options.find((o) => o.name.toLowerCase() === String(ref).toLowerCase()); } relationTargetDbIds(field) { return field.config.targetDbs ?? [field.config.targetDb]; } #normalizeRelationInput(field, raw) { const arr = raw == null ? [] : Array.isArray(raw) ? raw : [raw]; const memberIds = this.relationTargetDbIds(field); return arr.map((r) => { if (typeof r === 'string' && this.#lockedEntity(r) && memberIds.includes(this.state.entities[r].dbId)) return r; let target = null; for (const dbId of memberIds) { target = this.findEntity(dbId, r); if (target) break; } if (!target && own(this.state.entities, r)) throw new WeaveError(`Entity '${r}' is not in a related table`, 'invalid'); if (!target) throw new WeaveError(`Related entity '${r}' not found`, 'not-found'); if (!memberIds.includes(target.dbId)) throw new WeaveError(`Entity '${r}' is not in the related table`, 'invalid'); return target.id; }); } #setRelationValue(e, db, field, newIds) { if (!field.config.many && newIds.length > 1) { throw new WeaveError(`Field '${field.name}' holds a single entity`, 'invalid'); } const oldIds = this.#relationIds(e, field); const kept = oldIds.filter((id) => this.#lockedEntity(id)); if (kept.length && !field.config.many && newIds[0] !== kept[0]) throw new WeaveError(`'${field.name}' holds a record of a private space; a member of that space changes it`, 'forbidden'); newIds = [...newIds.filter((id) => !this.#lockedEntity(id) || kept.includes(id)), ...kept.filter((id) => !newIds.includes(id))]; const removed = oldIds.filter((id) => !newIds.includes(id)); const added = newIds.filter((id) => !oldIds.includes(id)); if (!removed.length && !added.length) return; if (!field.config.inverseFieldId) { for (const rid of added) this.#mark(this.getEntity(rid)); } else { const targetDb = this.state.tables[field.config.targetDb]; const inverse = targetDb.fields[field.config.inverseFieldId]; for (const rid of removed) { const t = this.state.entities[rid]; if (!t) continue; this.#pluck(t, inverse, e.id); this.#logInverse(t, inverse, { removed: [this.entityName(e)] }); } for (const rid of added) { const t = this.getEntity(rid); if (inverse.config.many) { const cur = this.#relationIds(t, inverse); if (!cur.includes(e.id)) t.values[inverse.id] = [...cur, e.id]; } else { const prevHolder = t.values[inverse.id]; if (prevHolder && prevHolder !== e.id) { const p = this.state.entities[prevHolder]; if (p) { this.#pluck(p, field, t.id); this.#logInverse(p, field, { removed: [this.entityName(t)] }); } } t.values[inverse.id] = e.id; } this.#logInverse(t, inverse, { added: [this.entityName(e)] }); } } e.values[field.id] = field.config.many ? newIds : (newIds[0] ?? null); e.updatedAt = nowISO(); e.modifiedBy = this.actor; this.#logActivity(e, 'relation-updated', { field: field.name, added: added.map((id) => this.entityName(this.state.entities[id])), removed: removed.map((id) => this.state.entities[id] ? this.entityName(this.state.entities[id]) : id), }); } #logInverse(t, field, { added = [], removed = [] }) { this.#logActivity(t, 'relation-updated', { field: field.name, added, removed, inverse: true }); } #relationIds(e, field) { const v = e.values[field.id]; return v == null ? [] : Array.isArray(v) ? v : [v]; } #pluck(entity, field, removeId) { const cur = this.#relationIds(entity, field); const next = cur.filter((id) => id !== removeId); entity.values[field.id] = field.config.many ? next : (next[0] ?? null); entity.updatedAt = nowISO(); this.#mark(entity); } link(entityId, fieldRef, targetRefs) { this.#mayWriteRow(entityId); const e = this.getEntity(entityId); const db = this.state.tables[e.dbId]; const field = this.getField(db.id, fieldRef); if (field.type !== 'relation') throw new WeaveError(`Field '${field.name}' is not a relation`, 'invalid'); const addIds = this.#normalizeRelationInput(field, targetRefs); const cur = this.#relationIds(e, field); const next = field.config.many ? [...new Set([...cur, ...addIds])] : addIds.slice(-1); const before = this.#undoBefore(e, db, [field]); this.#setRelationValue(e, db, field, next); if (this.#undoChanged(e, before)) this.#recordUndo('update', e, { before }); this.save(); return e; } unlink(entityId, fieldRef, targetRefs) { this.#mayWriteRow(entityId); const e = this.getEntity(entityId); const db = this.state.tables[e.dbId]; const field = this.getField(db.id, fieldRef); if (field.type !== 'relation') throw new WeaveError(`Field '${field.name}' is not a relation`, 'invalid'); const removeIds = this.#normalizeRelationInput(field, targetRefs); const next = this.#relationIds(e, field).filter((id) => !removeIds.includes(id)); const before = this.#undoBefore(e, db, [field]); this.#setRelationValue(e, db, field, next); if (this.#undoChanged(e, before)) this.#recordUndo('update', e, { before }); this.save(); return e; } setState(entityId, fieldRef, stateRef, { depth = 0, viewerZone = null } = {}) { if (viewerZone != null) return this.#inViewerZone(viewerZone, () => this.setState(entityId, fieldRef, stateRef, { depth })); this.#mayWriteRow(entityId); const e = this.getEntity(entityId); const db = this.state.tables[e.dbId]; const field = this.getField(db.id, fieldRef); if (field.type !== 'workflow') throw new WeaveError(`Field '${field.name}' is not a workflow`, 'invalid'); const before = this.#undoBefore(e, db, [field]); this.#setStateInternal(e, db, field, stateRef, depth); if (this.#undoChanged(e, before)) this.#recordUndo('update', e, { before }); this.save(); return e; } #setStateInternal(e, db, field, stateRef, depth, after = null) { const empty = stateRef == null || stateRef === ''; const state = empty ? null : field.config.states.find((s) => s.id === stateRef) ?? field.config.states.find((s) => s.name === stateRef) ?? field.config.states.find((s) => s.name.toLowerCase() === String(stateRef).toLowerCase()); if (!empty && !state) throw new WeaveError(`'${stateRef}' is not a state of '${field.name}'`, 'invalid'); const old = e.values[field.id] ?? null; if (old === (state?.id ?? null)) return; e.values[field.id] = state?.id ?? null; e.updatedAt = nowISO(); e.modifiedBy = this.actor; const oldName = field.config.states.find((s) => s.id === old)?.name ?? null; this.#logActivity(e, 'state-changed', { field: field.name, from: oldName, to: state?.name ?? null }); const run = () => this.#runAutomations(db, e, { type: 'state-changed', fieldId: field.id, toStateId: state?.id ?? null }, depth); if (after) after.push(run); else run(); } deleteEntity(ref, { hard = false } = {}) { this.#mayWriteRow(ref); const e = this.getEntity(ref); const id = e.id; { const db = this.state.tables[e.dbId]; const meta = this.#interceptDelete(e, db, hard); if (meta) return meta; } const db = this.state.tables[e.dbId]; if (!hard) { if (e.deletedAt) return this.readEntity(id); e.deletedAt = nowISO(); e.updatedAt = e.deletedAt; this.#logActivity(e, 'deleted', {}, { ts: e.deletedAt }); this.#recordUndo('delete', e); this.#mark(e); this.save(); return this.readEntity(id); } for (const field of Object.values(db.fields)) { if (field.type === 'relation' && e.values[field.id] != null) { this.#setRelationValue(e, db, field, []); } } delete this.state.entities[id]; this.#unindexRow(e); for (const f of e.files) for (const blob of fileBlobIds(f)) this.#dropBlob(blob); this.store.deleteDocRevisions(id); this.#mark(id); this.save(); return { id, purged: true }; } bulk(ids, op, params = {}) { for (const id of ids == null ? [] : [].concat(ids)) this.#mayWriteRow(id); const list = ids == null ? [] : Array.isArray(ids) ? ids : [ids]; if (!list.length) throw new WeaveError('ids is required: the records to act on', 'invalid'); if (!BULK_OPS.includes(op)) throw new WeaveError(`Unknown bulk op '${op}' (${BULK_OPS.join(', ')})`, 'invalid'); return this.#batched(() => this.#bulkRows(list, op, params)); } #bulkRows(list, op, params) { const out = { op, done: [], failed: [] }; const each = (fn) => { for (const id of list) { try { fn(this.getEntity(id)); out.done.push(id); } catch (err) { out.failed.push({ id, error: err.message }); } } }; switch (op) { case 'set': { const names = Object.keys(params.values ?? {}); out.changed = []; each((e) => { const db = this.state.tables[e.dbId]; const before = this.#undoBefore(e, db, names.filter((n) => this.findField(db, n))); this.updateEntity(e.id, params.values ?? {}); if (this.#undoChanged(e, before)) out.changed.push(e.id); }); break; } case 'link': each((e) => this.link(e.id, params.field, params.targets ?? [])); break; case 'move': { const to = this.getTable(params.table); out.moved = []; each((e) => { const from = this.state.tables[e.dbId]; if (to.system || from.system) throw new WeaveError('Records cannot move into or out of a system table', 'invalid'); if (to.id === from.id) throw new WeaveError(`Already in ${this.qualifiedName(to)}`, 'invalid'); out.moved.push(this.#moveEntity(e, from, to)); }); break; } case 'rollup': { const first = this.getEntity(list[0]); const db = this.state.tables[first.dbId]; const field = this.getField(db.id, params.field); if (field.type !== 'relation') throw new WeaveError(`Field '${field.name}' is not a relation`, 'invalid'); const target = params.table ? this.getTable(params.table) : this.state.tables[this.relationTargetDbIds(field)[0]]; const parent = this.createEntity(target, { name: params.name, values: params.values ?? {} }); each((e) => this.link(e.id, field.name, [parent.id])); out.parent = this.readEntity(parent.id); break; } } return out; } #portable(e, f) { const raw = e.values[f.id]; if (f.type === 'select') return f.config.options.find((o) => o.id === raw)?.name ?? raw; if (f.type === 'multiselect') return (raw ?? []).map((id) => f.config.options.find((o) => o.id === id)?.name ?? id); if (f.type === 'workflow') return f.config.states.find((s) => s.id === raw)?.name ?? raw; return raw; } #moveEntity(e, from, to) { const values = {}, docs = {}, skipped = []; for (const f of Object.values(from.fields)) { if (COMPUTED_TYPES.includes(f.type)) continue; const raw = f.type === 'document' ? (e.docs[f.id] ?? '') : e.values[f.id]; if (raw == null || raw === '' || raw === false || (Array.isArray(raw) && !raw.length)) continue; const tf = Object.values(to.fields).find((x) => x.name === f.name && x.type === f.type); if (!tf) { skipped.push(f.name); continue; } try { if (f.type === 'document') { docs[tf.name] = raw; continue; } const v = this.#portable(e, f); if (tf.type === 'relation') this.#normalizeRelationInput(tf, v); else if (tf.type === 'workflow') { if (!tf.config.states.some((s) => s.name === v)) throw new WeaveError(`'${v}' is not a state of '${tf.name}'`, 'invalid'); } else this.#validateValue(tf, v); values[tf.name] = v; } catch { skipped.push(f.name); } } const made = this.createEntity(to, { values, docs }); if (e.files.length) skipped.push('files'); if (e.comments.length) skipped.push('comments'); this.#logActivity(made, 'moved', { from: this.qualifiedName(from), publicId: e.publicId, skipped }); this.deleteEntity(e.id); return { from: e.id, to: made.id, skipped }; } restoreEntity(id) { this.#mayWriteRow(id); const e = this.getEntity(id); if (this.state.meta.detachedRegistry?.rows.includes(e.id)) throw new WeaveError('This registry row moved to its workspace', 'forbidden'); { const db = this.state.tables[e.dbId]; const binned = db?.system === 'fields' && e.deletedAt && !this.#inMetaSync ? this.#trashedFieldAnywhere(e.sysId) : null; if (binned) { binned.owner.restoreField(binned.table.id, e.sysId); return this.readEntity(id); } if (db?.system && !this.#inMetaSync && ['spaces', 'tables'].includes(db.system)) { const owner = this.#ownerOf(e); if (db.system === 'spaces') owner.restoreSpace(e.sysId); else owner.restoreTable(e.sysId); return this.readEntity(id); } } if (!e.deletedAt) return this.readEntity(id); e.deletedAt = null; e.updatedAt = nowISO(); e.modifiedBy = this.actor; this.#logActivity(e, 'restored', {}, { ts: e.updatedAt }); this.#recordUndo('restore', e); this.#mark(e); this.save(); return this.readEntity(id); } resolveField(e, fieldRef, depth = 0) { const db = this.state.tables[e.dbId]; const field = this.findField(db, fieldRef); if (!field) { if (fieldRef === 'createdAt' || fieldRef === 'Created At') return e.createdAt; if (fieldRef === 'updatedAt' || fieldRef === 'Updated At') return e.updatedAt; if (fieldRef === 'publicId' || fieldRef === 'Public Id') return e.publicId; throw new WeaveError(`Field '${fieldRef}' not found in table '${db.name}'`, 'not-found'); } return this.#resolve(e, db, field, depth); } #resolve(e, db, field, depth) { if (depth > MAX_COMPUTE_DEPTH) { this.#cutoffs++; return null; } if (field.type !== 'formula' && field.type !== 'lookup' && field.type !== 'rollup') { return this.#resolveValue(e, db, field, depth); } const key = `${e.id}:${field.id}`; const memo = this.#query?.computed; const known = memo?.get(key); if (known && depth <= known.depth) return known.value; const frame = { key, name: field.name, row: e.id, rowName: this.#rawName(e, db) }; const at = this.#computing.findIndex((c) => c.key === key); if (at >= 0) { this.#cutoffs++; const loop = [...this.#computing.slice(at), frame]; const travels = new Set(loop.map((c) => c.row)).size > 1; return CYCLE_PREFIX + loop.map((c) => (travels && c.rowName ? `${c.rowName} › ${c.name}` : c.name)).join(' → '); } this.#computing.push(frame); const cutoffs = this.#cutoffs; try { const value = this.#resolveValue(e, db, field, depth); if (memo && this.#cutoffs === cutoffs) memo.set(key, { value, depth: Math.max(depth, known?.depth ?? 0) }); return value; } finally { this.#computing.pop(); } } #rawName(e, db) { const raw = e.values?.[db?.nameFieldId]; return raw == null ? '' : String(raw); } #resolveValue(e, db, field, depth) { switch (field.type) { case 'relation': return this.#relationIds(e, field).filter((id) => this.#liveEntity(id) || this.#lockedEntity(id)); case 'lookup': { const rel = db.fields[field.config.relationField]; const targetDb = rel && this.state.tables[rel.config.targetDb]; const targetField = targetDb?.fields[field.config.targetField]; if (!targetField) return null; const vals = this.#relationIds(e, rel) .map((id) => this.#liveEntity(id)) .filter(Boolean) .map((t) => this.#resolve(t, targetDb, targetField, depth + 1)); const looped = vals.find(isCycle); if (looped) return looped; return rel.config.many ? vals : (vals[0] ?? null); } case 'rollup': { const { targetDb, targetField, owner } = this.#rollupTarget(db, field); if (!targetDb) return null; let rows; if (field.config.via) { if (e.sysId !== targetDb.spaceId) return null; rows = this.#rollupRows(owner, targetDb, field); if (!rows) return null; } else { const rel = db.fields[field.config.relationField]; rows = this.#relationIds(e, rel).map((id) => this.#liveEntity(id)).filter(Boolean); } if (field.config.aggregate === 'count') return rows.length; if (!targetField) return null; const ticks = isBoolType(targetField.type) && NUMERIC_AGGREGATES.includes(field.config.aggregate); const vals = rows.map((t) => this.#resolve(t, targetDb, targetField, depth + 1)).map((v) => (ticks && !isCycle(v) ? (v ? 1 : 0) : v)); const looped = vals.find(isCycle); if (looped) return looped; const display = ['distinct', 'join'].includes(field.config.aggregate) ? rows.map((t, i) => this.#displayValue(targetDb, targetField, vals[i], t)) : null; return aggregateValues(field.config.aggregate, vals, { display, separator: field.config.separator ?? ', ' }); } case 'view': return this.renderView(e.id, field.config.shape); case 'formula': { try { return evaluate(field.config.expression, (name) => { const f = this.findField(db, name); if (!f) { if (name === 'PublicId') return e.publicId; throw new WeaveError(`Formula references unknown field '${name}'`, 'invalid'); } const v = this.#resolve(e, db, f, depth + 1); if (isCycle(v)) throw new CycleSignal(v); return typeof v === 'number' || f.type === 'date' ? v : this.#displayValue(db, f, v, e); }); } catch (err) { if (err instanceof CycleSignal) return err.marker; return `#ERR: ${err.message}`; } } case 'document': return e.docs?.[field.id] ?? ''; case 'rating': { const v = e.values[field.id]; return typeof v === 'number' ? Math.min(v, field.config.max) : null; } default: return e.values[field.id] ?? (isBoolType(field.type) ? false : null); } } #displayValue(db, field, resolved, e = null) { if (resolved == null) return null; if (isCycle(resolved)) return resolved; switch (field.type) { case 'select': return this.#findOption(field.config.options, resolved)?.name ?? resolved; case 'multiselect': return resolved.map((id) => this.#findOption(field.config.options, id)?.name ?? id); case 'workflow': return field.config.states.find((s) => s.id === resolved)?.name ?? resolved; case 'field': { const c = resolved.config ?? {}; const n = c.options?.length ?? c.states?.length ?? 0; const unit = c.states ? 'state' : 'option'; if (n) return `${resolved.type} · ${n} ${unit}${n === 1 ? '' : 's'}`; const costume = resolved.type === 'number' || resolved.type === 'formula' ? (c.format === 'currency' ? `currency ${c.currency ?? 'USD'}` : c.format === 'percent' ? 'percent' : c.unit ?? null) : resolved.type === 'date' || resolved.type === 'daterange' ? ([c.grain ? (c.grain.length ? c.grain.join('·') : 'time') : null, c.format ?? (c.time ? 'with time' : null)].filter(Boolean).join(' · ') || null) : resolved.type === 'document' ? c.kind ?? null : resolved.type === 'field' ? `depth ${c.depth ?? 1}` : null; return costume ? `${resolved.type} · ${costume}` : resolved.type; } case 'key': { const { keystore } = this.credentialConfig(field); const unset = keystore === 'local' && !this.hasKey(resolved) ? ' (unset)' : ''; return `✱✱✱✱ ${resolved}${unset}`; } case 'avatar': case 'attachments': { if (!Array.isArray(resolved) || !resolved.length) return null; const names = resolved.map((id) => { const f = e?.files?.find((x) => x.id === id); if (!f) return '(missing)'; return this.#hasBlob(id) ? f.name : `${f.name} (missing)`; }); return names.join(', '); } case 'date': { return dressDate({ ...field.config, now: this.now(), viewerZone: this.viewerZone ?? 'UTC' }, resolved); } case 'daterange': return dressDateRange({ ...field.config, now: this.now(), viewerZone: this.viewerZone ?? 'UTC' }, resolved); case 'number': return dressNumber(field.config, resolved); case 'formula': if (field.config.grain) return typeof resolved === 'string' ? dressDate({ ...field.config, now: this.now(), viewerZone: this.viewerZone ?? 'UTC' }, resolved) : resolved; return typeof resolved === 'number' ? dressNumber(field.config, resolved) : resolved; case 'view': return resolved && typeof resolved === 'object' ? this.#viewLine(resolved) : resolved; case 'relation': { const names = resolved.map((id) => { const t = this.state.entities[id]; return t ? (this.#rowHidden(t) ? LOCKED_NAME : this.entityName(t)) : id; }); return field.config.many ? names : (names[0] ?? null); } case 'lookup': { const far = this.#lookupRelation(db, field, resolved); if (far) { const names = far.ids.map((id) => (this.#lockedEntity(id) ? LOCKED_NAME : this.entityName(this.state.entities[id]))); return far.many ? names : (names[0] ?? null); } const rel = db.fields[field.config.relationField]; const targetDb = rel && this.state.tables[rel.config.targetDb]; const target = targetDb?.fields[field.config.targetField]; if (!['select', 'multiselect', 'workflow'].includes(target?.type)) return resolved; const dress = (v) => this.#displayValue(targetDb, target, v); return rel.config.many ? resolved.map(dress) : dress(resolved); } case 'rollup': { const { targetField } = this.#rollupTarget(db, field); const agg = field.config.aggregate; if (typeof resolved === 'number' && ROLLUP_COSTUME_KEYS.some((k) => field.config[k] != null)) { const c = Object.fromEntries(ROLLUP_COSTUME_KEYS.filter((k) => field.config[k] != null).map((k) => [k, field.config[k]])); const fractional = !Number.isInteger(resolved) && c.decimals == null && c.format !== 'currency' && c.format !== 'percent'; return dressNumber(fractional ? { ...c, decimals: 2 } : c, resolved); } if (!targetField || !NUMERIC_AGGREGATES.includes(agg)) return resolved; if (typeof resolved === 'number' && (targetField.type === 'number' || targetField.type === 'formula')) { const c = targetField.config; const fractional = !Number.isInteger(resolved) && c.decimals == null && c.format !== 'currency'; return dressNumber(fractional ? { ...c, decimals: 2 } : c, resolved); } if (typeof resolved === 'string' && targetField.type === 'date' && (agg === 'min' || agg === 'max')) { return dressDate({ ...targetField.config, now: this.now(), viewerZone: this.viewerZone ?? 'UTC' }, resolved); } return resolved; } default: return resolved; } } #lookupRelation(db, field, resolved) { if (field.type !== 'lookup') return null; const rel = db.fields[field.config.relationField]; const target = rel && this.state.tables[rel.config.targetDb]?.fields[field.config.targetField]; if (target?.type !== 'relation') return null; const lists = rel.config.many ? resolved : [resolved]; const ids = Array.isArray(lists) ? lists.flat().filter((id) => typeof id === 'string' && !isCycle(id)) : []; return { ids: [...new Set(ids)], many: !!(rel.config.many || target.config.many) }; } #rollupRows(owner, targetDb, field) { const shared = this.#query?.rollupRows; const key = `${targetDb.id}:${field.id}`; if (shared?.has(key)) return shared.get(key); const cutoffs = owner.#cutoffs; let rows = owner.listEntities(targetDb.id); const w = field.config.where; if (w) { try { rows = rows.filter((r) => owner.#matchNode(r, targetDb, Array.isArray(w) ? { and: w } : w)); } catch { rows = null; } } if (shared && owner.#cutoffs === cutoffs) shared.set(key, rows); return rows; } #rollupTarget(db, field) { let targetDb = null; let owner = this; if (field.config.via) { const hit = this.#tableAnywhere(field.config.via); targetDb = hit?.table; owner = hit?.owner ?? this; if (targetDb?.deletedAt) targetDb = null; } else { const rel = db.fields[field.config.relationField]; targetDb = rel && this.state.tables[rel.config.targetDb]; } const targetField = targetDb && field.config.targetField ? targetDb.fields[field.config.targetField] ?? null : null; return { targetDb: targetDb ?? null, targetField, owner }; } #checkWhere(db, node) { if (Array.isArray(node)) { if (node.length === 3 && typeof node[0] === 'string') { const parts = node[0].split('.'); let cdb = db; for (let i = 0; i < parts.length; i++) { const last = i === parts.length - 1; if (last && (parts[i] === 'id' || parts[i] === 'publicId')) return; const f = this.findField(cdb, parts[i]); if (!f) throw new WeaveError(`Field '${parts[i]}' not found in table '${cdb.name}'`, 'not-found'); if (!last) { if (f.type !== 'relation' || !f.config.targetDb) throw new WeaveError(`'${parts[i]}' is not a relation; cannot traverse`, 'invalid'); cdb = this.state.tables[f.config.targetDb]; } } return; } for (const n of node) this.#checkWhere(db, n); return; } if (node && typeof node === 'object' && (node.and || node.or)) { for (const n of node.and ?? node.or) this.#checkWhere(db, n); return; } throw new WeaveError('Invalid where node', 'invalid'); } tableRollups(dbRef, { field = null } = {}) { const db = this.getTable(dbRef); const under = field == null ? null : this.getField(db.id, field); const spacesT = this.#sysTable('spaces'); const row = spacesT && this.#sysRow('spaces', db.spaceId); if (!row) return []; const out = []; const reg = this.#reg; for (const fid of spacesT.fieldOrder) { const f = spacesT.fields[fid]; if (f?.type !== 'rollup' || f.config.via !== db.id) continue; if (under && (f.config.targetField ?? db.nameFieldId) !== under.id) continue; const value = reg.#resolve(row, spacesT, f, 0); const display = value == null ? null : reg.#displayValue(spacesT, f, value, row); out.push({ fieldId: f.id, name: f.name, spaceRowId: row.id, targetField: f.config.targetField ? db.fields[f.config.targetField]?.name ?? null : null, aggregate: f.config.aggregate, where: f.config.where ?? null, value, display: display == null ? null : String(display), }); } return out; } tableStats(dbRef, { by = null, where = null, field = null } = {}) { const db = this.getTable(dbRef); const only = field == null ? null : this.getField(db.id, field); let rows = this.listEntities(db.id); if (where && (Array.isArray(where) ? where.length : true)) { this.#checkWhere(db, where); rows = rows.filter((r) => this.#matchNode(r, db, Array.isArray(where) ? { and: where } : where)); } const SKIP = new Set(['view', 'document', 'attachments', 'avatar', 'key', 'field']); const fields = db.fieldOrder.map((id) => db.fields[id]).filter((f) => f && !SKIP.has(f.type) && (!only || f.id === only.id)); const isBlank = (v) => v == null || v === '' || (Array.isArray(v) && v.length === 0); const read = (f) => rows.map((e) => this.#resolve(e, db, f, 0)); const dressAll = (f, vals) => rows.map((e, i) => this.#displayValue(db, f, vals[i], e)); const numericCostume = (f) => { if (f.type === 'number' || f.type === 'formula') return f.config; if (f.type === 'rollup') return this.#rollupTarget(db, f).targetField?.config ?? {}; return {}; }; const dress = (f, v) => { if (v == null) return null; if (typeof v !== 'number') return String(v); const c = numericCostume(f); const fractional = !Number.isInteger(v) && c.decimals == null && c.format !== 'currency'; return String(dressNumber(fractional ? { ...c, decimals: 2 } : c, v)); }; const kindOf = (f, vals) => { if (f.type === 'number' || f.type === 'rating') return 'number'; if (f.type === 'formula' || f.type === 'rollup' || f.type === 'lookup') { return vals.some((v) => typeof v === 'number') ? 'number' : vals.some((v) => Array.isArray(v)) ? 'category' : 'text'; } if (['select', 'multiselect', 'workflow', 'checkbox', 'toggle', 'relation'].includes(f.type)) return 'category'; if (f.type === 'date') return 'date'; return 'text'; }; const dayOf = (iso) => Date.parse(String(iso).length <= 10 ? `${iso}T00:00:00Z` : iso); const columns = fields.map((f) => { const vals = read(f); const kind = kindOf(f, vals); const col = { id: f.id, name: f.name, type: f.type, kind, filled: vals.filter((v) => !isBlank(v)).length, empty: vals.filter(isBlank).length }; if (kind === 'number') { col.summary = describeNumbers(vals); col.display = Object.fromEntries(Object.entries(col.summary).map(([k, v]) => [k, k === 'n' ? String(v) : dress(f, v)])); col.histogram = histogram(vals, 10).map((b) => ({ ...b, fromDisplay: dress(f, b.from), toDisplay: dress(f, b.to) })); } else if (kind === 'category') { col.distribution = distribution(dressAll(f, vals)); } else if (kind === 'date') { const iso = vals.filter((v) => typeof v === 'string' && v); col.earliest = aggregateValues('min', iso); col.latest = aggregateValues('max', iso); col.earliestDisplay = col.earliest == null ? null : this.#displayValue(db, f, col.earliest); col.latestDisplay = col.latest == null ? null : this.#displayValue(db, f, col.latest); col.spanDays = col.earliest == null ? null : Math.round((dayOf(col.latest) - dayOf(col.earliest)) / 86400000); col.byMonth = distribution(iso.map((v) => v.slice(0, 7))); } else { col.distinct = aggregateValues('distinct', dressAll(f, vals)); } return col; }); const out = { table: this.qualifiedName(db), rows: rows.length, columns, rollups: this.tableRollups(db.id, { field: only?.id ?? null }) }; if (by) { const byF = this.getField(db.id, by); const keys = dressAll(byF, read(byF)); const numeric = columns.filter((c) => c.kind === 'number' && c.id !== byF.id).map((c) => db.fields[c.id]); const buckets = new Map(); rows.forEach((e, i) => { const ks = isBlank(keys[i]) ? [null] : (Array.isArray(keys[i]) ? keys[i] : [keys[i]]); for (const k of ks) { const key = isBlank(k) ? null : (typeof k === 'object' ? JSON.stringify(k) : k); if (!buckets.has(key)) buckets.set(key, []); buckets.get(key).push(e); } }); out.by = byF.name; out.groups = [...buckets].map(([value, members]) => { const g = { value, rows: members.length, columns: {}, display: {}, rollups: this.#rollupsOver(db, members) }; for (const f of numeric) { const vals = members.map((e) => this.#resolve(e, db, f, 0)); const d = describeNumbers(vals); g.columns[f.name] = { n: d.n, sum: d.sum, avg: d.avg, median: d.median, min: d.min, max: d.max }; g.display[f.name] = Object.fromEntries(Object.entries(g.columns[f.name]).map(([k, v]) => [k, k === 'n' ? String(v) : dress(f, v)])); } return g; }).sort((a, b) => b.rows - a.rows || (a.value == null) - (b.value == null) || String(a.value).localeCompare(String(b.value))); } return out; } entityRevision(id) { return createHash('sha256').update(JSON.stringify(this.getEntity(id))).digest('hex'); } conditionalWrite(id, { expectedRevision, operation, field, markdown, state, stateField } = {}) { this.#mayWriteRow(id); if (!/^[a-f0-9]{64}$/.test(expectedRevision ?? '') || !['append', 'state', 'close'].includes(operation)) throw new WeaveError('A conditional write requires expectedRevision and an append, state or close operation', 'invalid'); if (operation === 'close' && !stateField) throw new WeaveError('A close requires stateField naming the workflow field it moves', 'invalid'); if (operation === 'close' && (state == null || state === '')) throw new WeaveError('A close requires the state it moves to; clear a workflow field with the state operation', 'invalid'); let attempted = false; try { return this.store.batch(() => { this.maybeRefresh(); if (this.entityRevision(id) !== expectedRevision) throw new WeaveError('Record revision changed; read it again before writing', 'conflict'); if (operation === 'state') { attempted = true; this.setState(id, field, state); } else { if (typeof markdown !== 'string' || !markdown.trim()) throw new WeaveError('An append requires nonempty markdown', 'invalid'); attempted = true; this.appendDoc(id, markdown, field); if (operation === 'close') this.setState(id, stateField, state); } return this.readEntity(id); }); } catch (err) { if (attempted) this.#adoptRolledBack(); throw err; } } #updateAtRevision(id, valuesByName, expectedRevision) { let attempted = false; try { return this.store.batch(() => { this.maybeRefresh(); if (this.entityRevision(id) !== expectedRevision) throw new WeaveError('This record changed since you read it; nothing was written', 'stale'); attempted = true; return this.updateEntity(id, valuesByName); }); } catch (err) { if (attempted) this.#adoptRolledBack(); throw err; } } #batched(fn) { try { return this.store.batch(fn); } catch (err) { this.#adoptRolledBack(); throw err; } } #adoptRolledBack(loaded = this.store.reload()) { if (!loaded) return; this.state = loaded; this.#schemaVersion = null; this.#scales.clear(); this.#forgetDerived(); } #inViewerZone(viewerZone, fn) { if (viewerZone == null) return fn(); const prev = this.viewerZone; this.viewerZone = DG.isZone(viewerZone) ? viewerZone : null; try { return fn(); } finally { this.viewerZone = prev; } } readEntity(id, { viewerZone = null } = {}) { return this.#inViewerZone(viewerZone, () => this.#framed(() => this.#readEntityIn(id))); } #readEntityIn(id, { pick = null, chips = null } = {}) { const e = this.getEntity(id); const db = this.state.tables[e.dbId]; const fields = {}; const raw = {}; for (const fid of db.fieldOrder) { if (pick && !pick.ids.has(fid)) continue; const f = db.fields[fid]; const resolved = this.#resolve(e, db, f, 0); raw[f.name] = resolved; const far = f.type === 'relation' ? { ids: resolved, many: f.config.many } : this.#lookupRelation(db, f, resolved); if (far) { const summaries = far.ids.map((rid) => { if (!chips) return this.#summary(rid); const s = chips[rid] ?? this.#summary(rid); if (!s) return null; chips[rid] = s; return { id: s.id, publicId: s.publicId, name: s.name, ...(s.avatar ? { avatar: s.avatar } : {}), ...(s.locked ? { locked: true } : {}) }; }).filter(Boolean); fields[f.name] = far.many ? summaries : (summaries[0] ?? null); } else { fields[f.name] = this.#displayValue(db, f, resolved, e); } } const docs = {}; for (const f of this.documentFields(db)) if (!pick || pick.ids.has(f.id)) docs[f.name] = e.docs?.[f.id] ?? ''; const defaultDocField = this.descriptionField(db) ?? this.documentFields(db)[0]; const face = this.#faceOf(e); const scales = {}; for (const fid of db.fieldOrder) { if (pick && !pick.ids.has(fid)) continue; const f = db.fields[fid]; if (this.#numberDisplay(db, f)) scales[f.name] = this.#scaleOf(db, f); } if (pick) { const docNamed = defaultDocField && pick.ids.has(defaultDocField.id); const held = new Set(db.fieldOrder.filter((fid) => pick.ids.has(fid) && db.fields[fid].type === 'attachments').flatMap((fid) => raw[db.fields[fid].name] ?? [])); const files = held.size ? e.files.filter((f) => held.has(f.id)).map((f) => (this.#hasBlob(f.id) ? f : { ...f, missing: true })) : null; return { id: e.id, revision: this.entityRevision(e.id), publicId: e.publicId, db: this.qualifiedName(db), dbId: db.id, name: this.entityName(e), fields, raw, ...(docNamed ? { doc: e.docs?.[defaultDocField.id] ?? '', docField: defaultDocField.name } : {}), docs, ...(files ? { files } : {}), createdAt: e.createdAt, updatedAt: e.updatedAt, createdBy: this.actorName(e.createdBy ?? null), modifiedBy: this.actorName(e.modifiedBy ?? null), deletedAt: e.deletedAt ?? null, url: this.permalink('entity', e.id), ...(face ? { avatar: face } : {}), ...(Object.keys(scales).length ? { scales } : {}), ...(e.sysId ? { sysId: e.sysId, sysWorkspaceId: this.#wsIdOfRow(e) } : {}), ...(this.#readOnlyFor(db) ? { readOnly: true } : {}), }; } return { id: e.id, revision: this.entityRevision(e.id), publicId: e.publicId, db: this.qualifiedName(db), dbId: db.id, name: this.entityName(e), fields, raw, doc: defaultDocField ? (e.docs?.[defaultDocField.id] ?? '') : '', docField: defaultDocField ? defaultDocField.name : null, docs, ...(face ? { avatar: face } : {}), comments: e.comments.map((c) => ({ ...c, author: this.actorName(c.author) })), activity: (e.activity ?? []).map((a) => this.#namedActivity(a)), activityDropped: e.activityDropped ?? 0, files: e.files.map((f) => (this.#hasBlob(f.id) ? f : { ...f, missing: true })), createdAt: e.createdAt, updatedAt: e.updatedAt, createdBy: this.actorName(e.createdBy ?? null), modifiedBy: this.actorName(e.modifiedBy ?? null), deletedAt: e.deletedAt ?? null, url: this.permalink('entity', e.id), ...(Object.keys(scales).length ? { scales } : {}), ...(e.sysId ? { sysId: e.sysId, sysWorkspaceId: this.#wsIdOfRow(e) } : {}), ...(this.#readOnlyFor(db) ? { readOnly: true } : {}), }; } #numberDisplay(db, f) { let c = null; if (f.type === 'number' || f.type === 'formula') c = f.config; else if (f.type === 'rollup' && f.config.display) c = f.config; else if (f.type === 'rollup' && NUMERIC_AGGREGATES.includes(f.config.aggregate)) { const { targetField } = this.#rollupTarget(db, f); if (targetField && (targetField.type === 'number' || targetField.type === 'formula')) c = targetField.config; } return c && isGraphicDisplay(c.display) ? { display: c.display, scale: c.scale ?? 'column', color: c.color ?? 'ink' } : null; } #scaleOf(db, f) { const d = this.#numberDisplay(db, f); if (!d) return null; if (typeof d.scale === 'number') return d.scale; const key = `${db.id}:${f.id}:${this.#hiddenKey()}`; if (!this.#scales.has(key)) { const vals = this.listEntities(db.id).map((r) => this.#resolve(r, db, f, 0)).filter((v) => typeof v === 'number' && Number.isFinite(v)); this.#scales.set(key, vals.length ? aggregateValues('max', vals) : null); } return this.#scales.get(key); } #ratingOf(db, f) { const scaleOf = (r) => ({ max: r.config.max, icon: r.config.icon, color: r.config.color ?? 'ink' }); if (f.type === 'rating') return scaleOf(f); if (f.type === 'lookup') { const rel = db.fields[f.config.relationField]; const target = rel && this.state.tables[rel.config.targetDb]?.fields[f.config.targetField]; return target?.type === 'rating' ? scaleOf(target) : null; } if (f.type === 'rollup' && RATING_SCALE_AGGS.includes(f.config.aggregate)) { const { targetField } = this.#rollupTarget(db, f); return targetField?.type === 'rating' ? scaleOf(targetField) : null; } return null; } #summary(id) { const e = own(this.state.entities, id); if (!e) return null; if (this.#rowHidden(e)) return { id: e.id, publicId: null, name: LOCKED_NAME, db: null, locked: true }; const db = this.state.tables[e.dbId]; const face = this.#faceOf(e); return { id: e.id, publicId: e.publicId, name: this.entityName(e), db: this.qualifiedName(db), ...(face ? { avatar: face } : {}), ...(db.system ? {} : { chip: this.renderView(e.id, 'chip') }) }; } #inWorkspace(db, workspace) { if (db.system !== 'workflows') return null; const want = workspace ?? this.state.meta.id; return (e) => this.#workflowWsId(e) === want; } #workflowWsId(row) { return this.#wsIdOfRow(row) ?? this.#reg.state.meta.id; } workflowWorkspace(id) { const e = own(this.#reg.state.entities, id); if (!e || this.#reg.state.tables[e.dbId]?.system !== 'workflows') return undefined; return this.#workflowWsId(e); } query(dbRef, { viewerZone = null, workspace = null, ...opts } = {}) { return this.#inViewerZone(viewerZone, () => this.#inQuery(dbRef, opts, workspace)); } #inQuery(dbRef, opts, workspace) { return this.#framed(() => this.#queryIn(dbRef, opts, workspace)); } #queryIn(dbRef, { where = [], sort = [], limit = null, offset = 0, select = null, fields = null, relations = 'full', includeDeleted = null, trashCount = false, countAll = false, search = '', view = null, groupBy = null, shape = 'full' } = {}, workspace = null) { const db = this.getTable(dbRef); let grouped = null; if (view != null && view !== '') { const saved = this.#viewQueryIn(db, view); if (saved.where.length) where = { and: [...saved.where, Array.isArray(where ?? []) ? { and: where ?? [] } : where] }; if (!sort?.length) sort = saved.sort; if (!String(search ?? '').trim()) search = saved.search; if (saved.view.group?.length) grouped = saved.view; } if (groupBy != null && groupBy !== '') { const v = this.#viewQueryIn(db, groupBy).view; grouped = v.group?.length ? v : null; } includeDeleted = includeDeleted ?? (grouped ? !!grouped.deleted : false); const inWs = this.#inWorkspace(db, workspace); const scoped = (list) => (inWs ? list.filter(inWs) : list); if (fields != null && !Array.isArray(fields)) throw new WeaveError('fields must be a list of field names', 'invalid'); if (fields && select) throw new WeaveError('Ask for select or fields, not both: select is a flat projection, fields cuts the entity', 'invalid'); if (relations != null && relations !== 'full' && relations !== 'chip') throw new WeaveError(`relations must be 'full' or 'chip' (got '${relations}')`, 'invalid'); if (!QUERY_SHAPES.includes(shape ?? 'full')) throw new WeaveError(`shape must be 'full' or 'row' (got '${shape}')`, 'invalid'); const pick = fields && { ids: new Set() }; for (const name of fields ?? []) { const f = this.findField(db, name); if (f) pick.ids.add(f.id); else if (!Object.hasOwn(SYSTEM_SORT_KEYS, name)) this.getField(db.id, name); } const chips = relations === 'chip' && !select ? Object.create(null) : null; const table = scoped(db.system ? this.#tableRows(db.id).filter((e) => !this.#rowHidden(e)) : this.#tableRows(db.id)); let live = 0; for (const e of table) if (!e.deletedAt) live++; let rows = includeDeleted ? table : table.filter((e) => !e.deletedAt); const all = countAll ? live : null; if (String(search ?? '').trim()) { const hit = new Set(this.#searchHits(search, db.id).map((h) => h.e.id)); rows = rows.filter((e) => hit.has(e.id)); } const trashed = trashCount ? table.length - live : null; const filtered = where && (Array.isArray(where) ? where.length : true); if (filtered) { const index = this.#rowIndex(); const whereKey = this.#derivedKey(db, JSON.stringify(where)); const hits = index.whereHits.get(whereKey); if (hits) rows = rows.filter((e) => hits.has(e.id)); else { const node = Array.isArray(where) ? { and: where } : where; const matched = new Set(); for (const e of table) if (this.#matchNode(e, db, node)) matched.add(e.id); this.#remember(index.whereHits, whereKey, matched); rows = rows.filter((e) => matched.has(e.id)); } } const orderKey = sort.length && !inWs && !filtered && !String(search ?? '').trim() ? this.#derivedKey(db, JSON.stringify(sort), String(!!includeDeleted)) : null; const ordered = orderKey ? this.#rowIndex().sortOrders.get(orderKey) : undefined; if (ordered) rows = ordered; else { if (sort.length) rows = [...rows].sort((a, b) => a.publicId - b.publicId); for (const s of [...sort].reverse()) { const { field, dir = 'asc' } = typeof s === 'string' ? { field: s } : s; if (rows.length < 2) continue; const mul = dir === 'desc' ? -1 : 1; const keys = this.#sortKeys(db, field); const keyed = rows.map((e) => { if (keys.has(e.id)) return { e, v: keys.get(e.id) }; const got = this.#pathValue(e, db, field, { undressed: true }); const v = got === '' || (Array.isArray(got) && !got.length) ? null : got; keys.set(e.id, v); return { e, v }; }); rows = keyed.sort(({ v: av }, { v: bv }) => { if (av == null && bv == null) return 0; if (av == null) return 1; if (bv == null) return -1; if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * mul; return String(av).localeCompare(String(bv)) * mul; }).map(({ e }) => e); } if (orderKey) this.#remember(this.#rowIndex().sortOrders, orderKey, rows); } const total = rows.length; const liveTotal = includeDeleted ? rows.reduce((n, e) => n + (e.deletedAt ? 0 : 1), 0) : null; const groups = grouped ? this.#viewGroups(db, grouped, rows) : null; rows = rows.slice(offset, limit == null || limit < 0 ? undefined : offset + limit); const nextOffset = rows.length && offset + rows.length < total ? offset + rows.length : null; const items = rows.map((e) => { if (!select) { const row = this.#readEntityIn(e.id, { pick, chips }); return shape === 'row' ? leanRow(row) : row; } const out = { id: e.id, publicId: e.publicId, name: this.entityName(e) }; for (const path of select) out[path] = this.#pathValue(e, db, path); return out; }); return { total, ...(liveTotal != null ? { liveTotal } : {}), ...(nextOffset != null ? { nextOffset } : {}), items, ...(chips ? { chips } : {}), ...(trashCount ? { trashCount: trashed } : {}), ...(countAll ? { all } : {}), ...(groups ?? {}) }; } #groupFieldShape(f) { return { name: f.name, type: f.type, ...(f.type === 'select' || f.type === 'multiselect' ? { options: f.config.options.map((o) => o.name) } : {}), ...(f.type === 'workflow' ? { states: f.config.states.map((st) => ({ name: st.name })) } : {}), ...(f.type === 'toggle' ? { on: f.config.on, off: f.config.off } : {}), }; } #groupTargets(f) { const target = f.type === 'relation' && !f.config.targetDbs ? this.state.tables[f.config.targetDb] : null; if (!target) return null; const first = target.tableViews?.[0]; const sort = first?.sort?.length ? first.sort : []; let rows = this.#queryIn(target.id, { sort, select: ['id'] }).items; const order = sort.length ? [] : (first?.order ?? []); if (order.length) { const at = new Map(order.map((id, i) => [id, i])); rows = rows.map((r, i) => ({ r, i })).sort((a, b) => (at.get(a.r.id) ?? Infinity) - (at.get(b.r.id) ?? Infinity) || a.i - b.i).map((x) => x.r); } return rows.map((r) => ({ id: r.id, publicId: r.publicId, name: r.name })); } #viewGroups(db, v, rows) { const ids = (v.group ?? []).map((l) => l.field).filter((id) => db.fields[id]); const group = (v.group ?? []).filter((l) => db.fields[l.field]).map((l) => ({ ...l, field: db.fields[l.field].name })); const shapes = ids.map((id) => this.#groupFieldShape(db.fields[id])); const pick = { ids: new Set(ids) }; const byId = new Map(rows.map((e) => [e.id, e])); const items = rows.map((e) => ({ ...this.#readEntityIn(e.id, { pick, chips: Object.create(null) }), deleted: !!e.deletedAt })); const levels = ViewPane.levelsFor(group, shapes, { domains: ids.map((id) => this.#groupTargets(db.fields[id])), items }); const m = ViewPane.model(ViewPane.rowsFor(items, levels), { levels }); const live = (list) => ViewPane.liveRows(list).map((r) => byId.get(r.id)).filter(Boolean); return { groups: m.nodes.map((n) => ({ path: n.path.map((p, i) => ({ field: levels[i].f.name, value: p.label })), count: n.count, rollups: this.#rollupsOver(db, live(n.rows)), })), groupTotal: { count: ViewPane.liveRows(items).length, rollups: this.#rollupsOver(db, rows.filter((e) => !e.deletedAt)) }, }; } #rollupsOver(db, entities) { const spacesT = this.#sysTable('spaces'); const row = spacesT && this.#sysRow('spaces', db.spaceId); if (!row) return []; const reg = this.#reg; const nameField = db.fields[db.nameFieldId]?.name ?? 'Name'; const out = []; for (const fid of spacesT.fieldOrder) { const f = spacesT.fields[fid]; if (f?.type !== 'rollup' || f.config.via !== db.id || f.config.where) continue; const agg = f.config.aggregate; const target = f.config.targetField ? db.fields[f.config.targetField] ?? null : null; let value = null; if (agg === 'count') value = entities.length; else if (target) { const ticks = isBoolType(target.type) && NUMERIC_AGGREGATES.includes(agg); const vals = entities.map((e) => this.#resolve(e, db, target, 1)).map((x) => (ticks && !isCycle(x) ? (x ? 1 : 0) : x)); if (!vals.some(isCycle)) { const display = ['distinct', 'join'].includes(agg) ? entities.map((e, i) => this.#displayValue(db, target, vals[i], e)) : null; value = aggregateValues(agg, vals, { display, separator: f.config.separator ?? ', ' }); } } const shown = value == null ? null : reg.#displayValue(spacesT, f, value, row); out.push({ field: target?.name ?? nameField, aggregate: agg, value, display: shown == null ? null : String(shown) }); } return out; } #matchNode(e, db, node) { if (Array.isArray(node)) return this.#matchCondition(e, db, node); if (node.and) return node.and.every((n) => this.#matchNode(e, db, n)); if (node.or) return node.or.some((n) => this.#matchNode(e, db, n)); throw new WeaveError('Invalid where node', 'invalid'); } #matchCondition(e, db, [path, op, value]) { const v = this.#pathValue(e, db, path, { numbers: !TEXT_OPS.has(op) }); const list = Array.isArray(v) ? v : [v]; switch (op) { case '=': return list.some((x) => this.#looseEq(x, value)); case '!=': return !list.some((x) => this.#looseEq(x, value)); case '<': return list.some((x) => x != null && x < value); case '<=': return list.some((x) => x != null && x <= value); case '>': return list.some((x) => x != null && x > value); case '>=': return list.some((x) => x != null && x >= value); case 'contains': return list.some((x) => String(x ?? '').toLowerCase().includes(String(value).toLowerCase())); case 'in': return list.some((x) => (Array.isArray(value) ? value : [value]).some((y) => this.#looseEq(x, y))); case 'is-empty': return v == null || v === '' || (Array.isArray(v) && v.length === 0); case 'not-empty': return !(v == null || v === '' || (Array.isArray(v) && v.length === 0)); default: throw new WeaveError(`Unknown operator '${op}'`, 'invalid'); } } #definitionRank(f, resolved) { if (resolved == null || isCycle(resolved)) return null; const list = f.type === 'workflow' ? f.config.states : f.config.options; const at = f.type === 'workflow' ? list.findIndex((s) => s.id === resolved) : list.indexOf(this.#findOption(list, resolved)); return at < 0 ? list.length : at; } #looseEq(a, b) { return a === b || String(a) === String(b); } #pathValue(e, db, path, { undressed = false, numbers = false } = {}) { const parts = String(path).split('.'); let current = [{ e, db }]; for (let i = 0; i < parts.length; i++) { const isLast = i === parts.length - 1; const next = []; const results = []; for (const { e: ce, db: cdb } of current) { if (parts[i] === 'id') { results.push(ce.id); continue; } if (parts[i] === 'publicId' || parts[i] === 'Public Id') { results.push(ce.publicId); continue; } if (parts[i] === 'createdAt') { results.push(ce.createdAt); continue; } if (parts[i] === 'updatedAt') { results.push(ce.updatedAt); continue; } const f = this.findField(cdb, parts[i]); if (!f && Object.hasOwn(SYSTEM_SORT_KEYS, parts[i])) { results.push(this.actorName(ce[SYSTEM_SORT_KEYS[parts[i]]] ?? null)); continue; } if (!f) throw new WeaveError(`Field '${parts[i]}' not found in table '${cdb.name}'`, 'not-found'); let resolved = this.#resolve(ce, cdb, f, 0); if (isLast && f.type === 'formula' && f.config.display === 'sparkline' && Array.isArray(resolved)) resolved = lastNumber(resolved); if (isLast) { const bare = undressed || numbers; results.push(bare && typeof resolved === 'number' ? resolved : bare && f.type === 'date' ? resolved : bare && f.type === 'daterange' ? DG.rangeKey(resolved) : undressed && (f.type === 'select' || f.type === 'workflow') ? this.#definitionRank(f, resolved) : this.#displayValue(cdb, f, resolved, ce)); } else { if (f.type !== 'relation') throw new WeaveError(`'${parts[i]}' is not a relation; cannot traverse`, 'invalid'); for (const rid of resolved) { const t = this.#liveEntity(rid); if (t) next.push({ e: t, db: this.state.tables[t.dbId] }); } } } if (isLast) { const flat = results.flatMap((r) => (Array.isArray(r) ? r : [r])); return parts.length === 1 && !Array.isArray(results[0]) && results.length === 1 ? results[0] : flat.length === 1 ? flat[0] : flat; } current = next; if (!current.length) return null; } return null; } getDoc(entityId, fieldRef = null) { const e = this.getEntity(entityId); const db = this.state.tables[e.dbId]; const f = this.#resolveDocField(db, fieldRef); return e.docs?.[f.id] ?? ''; } #docText(markdown, verb) { if (markdown == null) throw new WeaveError(`A document ${verb} needs its text: pass the markdown, '' to clear the document. Nothing was passed.`, 'invalid'); return stripEmptyEmbeds(markdown); } setDoc(entityId, markdown, fieldRef = null, { base = null } = {}) { if (base != null) return this.#setDocAtBase(entityId, markdown, fieldRef, base); return this.#writeDoc(entityId, markdown, fieldRef, 'write', 'doc-updated', (before, text) => text); } docBase(entityId, fieldRef = null) { const e = this.getEntity(entityId); const f = this.#resolveDocField(this.state.tables[e.dbId], fieldRef); return createHash('sha256').update(e.docs?.[f.id] ?? '').digest('hex'); } #setDocAtBase(entityId, markdown, fieldRef, base) { let attempted = false; try { return this.store.batch(() => { this.maybeRefresh(); if (this.docBase(entityId, fieldRef) !== base) throw new WeaveError('This document changed since you opened it; nothing was written', 'stale'); attempted = true; return this.setDoc(entityId, markdown, fieldRef); }); } catch (err) { if (attempted) this.#adoptRolledBack(); throw err; } } appendDoc(entityId, markdown, fieldRef = null) { return this.#writeDoc(entityId, markdown, fieldRef, 'append', 'doc-appended', (before, text) => (before ? before.replace(/\n*$/, '\n\n') : '') + text); } #writeDoc(entityId, markdown, fieldRef, verb, kind, compute) { this.#mayWriteRow(entityId); const e = this.getEntity(entityId); const text = this.#docText(markdown, verb); const db = this.state.tables[e.dbId]; const f = this.#resolveDocField(db, fieldRef); e.docs = e.docs ?? {}; const before = e.docs[f.id] ?? ''; const after = compute(before, text); if (before === after) return e; const prior = { at: e.updatedAt, by: e.modifiedBy }; e.docs[f.id] = after; e.updatedAt = nowISO(); e.modifiedBy = this.actor; const r = this.#restoring; this.#logActivity(e, kind, r ? { ...docChange(f.name, before, after), restoredFrom: r.seq, restoredAt: r.at, restoredBy: r.actor ?? null } : docChange(f.name, before, after)); this.#recordUndo('update', e, { before: { values: {}, docs: { [f.id]: before } } }); this.#recordDocRevision(e, f, before, after, { prior }); this.save(); return e; } #docRevisionFresh = false; #restoring = null; #recordDocRevision(e, f, before, after, { prior = null, fresh = false } = {}) { if (this.#inMetaSync) return; const db = this.state.tables[e.dbId]; if (!db || db.system) return; const now = nowISO(); const [latest] = this.store.listDocRevisions(e.id, f.id, { limit: 1 }); if (!latest && before) { this.store.pushDocRevision({ entityId: e.id, fieldId: f.id, at: prior?.at ?? now, actor: prior?.by ?? null, text: before }); } else if (latest && !fresh && !this.#docRevisionFresh && latest.actor === this.actor && latest.restoredFrom == null && Date.now() - Date.parse(latest.at) < this.revisionWindowMs) { this.store.replaceDocRevision(latest.seq, { at: now, text: after }); return; } this.store.pushDocRevision({ entityId: e.id, fieldId: f.id, at: now, actor: this.actor, text: after, restoredFrom: this.#restoring?.seq ?? null }); } listDocRevisions(entityId, fieldRef = null, { limit = 50 } = {}) { const e = this.getEntity(entityId); const f = this.#resolveDocField(this.state.tables[e.dbId], fieldRef); const revisions = this.store.listDocRevisions(e.id, f.id, { limit: Math.max(1, Math.min(1000, Number(limit) || 50)) }); return { field: f.name, revisions: revisions.map((r) => ({ ...r, actor: this.actorName(r.actor) })) }; } getDocRevision(entityId, fieldRef, seq) { const rev = this.#docRevision(entityId, fieldRef, seq); return { ...rev, actor: this.actorName(rev.actor) }; } #docRevision(entityId, fieldRef, seq) { const e = this.getEntity(entityId); const f = this.#resolveDocField(this.state.tables[e.dbId], fieldRef); const rev = this.store.getDocRevision(e.id, f.id, Number(seq)); if (!rev) throw new WeaveError(`Revision ${seq} of ${f.name} not found on this entity`, 'not-found'); return rev; } restoreDocRevision(entityId, fieldRef, seq) { this.#mayWriteRow(entityId); const rev = this.#docRevision(entityId, fieldRef, seq); const changed = this.getDoc(entityId, fieldRef) !== rev.text; this.#docRevisionFresh = true; this.#restoring = rev; try { this.setDoc(entityId, rev.text, fieldRef); } finally { this.#docRevisionFresh = false; this.#restoring = null; } const e = this.getEntity(entityId); const f = this.#resolveDocField(this.state.tables[e.dbId], fieldRef); return { ok: true, changed, field: f.name, seq: rev.seq, at: rev.at, length: rev.text.length }; } addComment(entityId, { author = 'anonymous', text }) { if (!text) throw new WeaveError('Comment text is required', 'invalid'); const e = this.getEntity(entityId); const comment = { id: uuid(), author, text, createdAt: nowISO() }; e.comments.push(comment); this.#logActivity(e, 'comment-added', { author }); this.#recordUndo('comment-add', e, { commentId: comment.id }); this.save(); return { ...comment, author: this.actorName(author) }; } deleteComment(entityId, commentId) { const e = this.getEntity(entityId); const removed = e.comments.find((c) => c.id === commentId); e.comments = e.comments.filter((c) => c.id !== commentId); if (removed) this.#recordUndo('comment-delete', e, { comment: removed }); this.#mark(e); this.save(); return { id: commentId, deleted: Boolean(removed) }; } activityFeed(opts = {}) { return this.#framed(() => this.#activityFeed(opts)); } #activityFeed({ entityId = null, tableRef = null, kinds = null, since = null, sinceSeq = null, limit = null, offset = 0 } = {}) { const after = sinceSeq == null ? null : Number(sinceSeq); if (after != null && !Number.isFinite(after)) throw new WeaveError('sinceSeq is the seq a page ended on, a number', 'invalid'); if (after != null && since) throw new WeaveError('Read the feed by sinceSeq or by since, not both: sinceSeq pages it, since cuts it at a timestamp', 'invalid'); const wanted = kinds?.length ? new Set(kinds) : null; const dbId = tableRef ? this.getTable(tableRef).id : null; const tableOnly = entityId && own(this.state.tables, entityId) ? entityId : null; if (entityId && !tableOnly) entityId = this.getEntity(entityId).id; const rows = []; let dropped = 0; const hidden = this.#hiddenSpaces(); if (!entityId || tableOnly) { for (const r of this.#fieldConfigRows()) { if ((tableOnly ?? dbId) && r.dbId !== (tableOnly ?? dbId)) continue; if (hidden && this.#tableHidden(this.state.tables[r.dbId], hidden)) continue; if (wanted && !wanted.has(r.kind)) continue; if (after != null ? (r.seq ?? 0) <= after : since && r.ts < since) continue; rows.push(r); } } for (const e of Object.values(this.state.entities)) { if (tableOnly) break; if (entityId && e.id !== entityId) continue; if (dbId && e.dbId !== dbId) continue; if (hidden && this.#rowHidden(e, hidden)) continue; dropped += e.activityDropped ?? 0; const db = this.state.tables[e.dbId]; (e.activity ?? []).forEach((a, i) => { if (wanted && !wanted.has(a.kind)) return; if (after != null ? (a.seq ?? 0) <= after : since && a.ts < since) return; rows.push({ id: `${e.id}:${i}`, seq: a.seq, ts: a.ts, kind: a.kind, actor: a.actor ?? null, detail: a.detail ?? {}, entityId: e.id, entityName: this.entityName(e), publicId: e.publicId, dbId: e.dbId, db: db ? this.qualifiedName(db) : null, space: db ? (this.state.spaces[db.spaceId]?.name ?? null) : null, deleted: !!e.deletedAt, }); }); } rows.sort((x, y) => { const xs = x.seq ?? Infinity, ys = y.seq ?? Infinity; if (xs !== ys) return after != null ? xs - ys : ys - xs; return (x.ts < y.ts ? 1 : (x.ts > y.ts ? -1 : 0)) * (after != null ? -1 : 1); }); const page = limit ?? ACTIVITY_PAGE; const items = page < 0 ? rows.slice(offset) : rows.slice(offset, offset + page); return { total: rows.length, dropped, ...(after != null ? { nextSeq: items.reduce((top, r) => Math.max(top, r.seq ?? 0), after) } : {}), items: items.map((r) => this.#namedActivity(r)), }; } getActivity(id) { const at = String(id).lastIndexOf(':'); const entityId = String(id).slice(0, at); if (String(id).slice(at + 1).startsWith('f')) { const rows = this.#fieldConfigRows(); const row = this.#fieldConfigRow(id, rows); const { code, ...rollback } = this.#rollbackCheck(row, rows); return { ...this.#namedActivity(row), rollback }; } const index = Number(String(id).slice(at + 1)); const e = own(this.state.entities, entityId); const a = e?.activity?.[index]; if (!a) throw new WeaveError(`Activity '${id}' not found`, 'not-found'); return this.activityFeed({ entityId, limit: -1 }).items.find((r) => r.id === `${entityId}:${index}`); } spaceHome(ref) { return this.#framed(() => { const sp = this.getSpace(ref); return { space: { id: sp.id, name: sp.name, icon: sp.icon || null, description: sp.description ?? '' }, tables: this.#homeTables(sp.id, this.#liveRowCounts()), runs: this.#homeRuns((id) => id === sp.id), }; }); } workspaceHome() { return this.#framed(() => { const ws = this.getWorkspace(); const counts = this.#liveRowCounts(); const spaces = this.listSpaces().filter((sp) => !sp.system); const ids = new Set(spaces.map((sp) => sp.id)); return { workspace: { name: ws.title, description: ws.description }, spaces: spaces.map((sp) => ({ id: sp.id, name: sp.name, icon: sp.icon || null, tables: this.#homeTables(sp.id, counts) })), runs: this.#homeRuns((id) => ids.has(id)), }; }); } #homeTables(spaceId, counts) { return this.listTables(spaceId).map((db) => ({ id: db.id, name: db.name, icon: db.icon || null, rowCount: counts.get(db.id) ?? 0, views: (db.tableViews ?? []).map((v) => ({ id: v.id, name: v.name, layout: v.layout ?? 'table' })), })); } #homeRuns(inHome) { const hidden = this.#hiddenSpaces(); const here = (e) => !e.deletedAt && inHome(this.state.tables[e.dbId]?.spaceId) && !this.#rowHidden(e, hidden); const fired = []; const writes = new Map(); for (const e of Object.values(this.state.entities)) { const mine = here(e); for (const a of e.activity ?? []) { if (a.kind === 'automation-ran') { fired.push({ e, a }); continue; } const workflow = mine && a.seq != null ? actorWorkflow(a.actor) : null; if (!workflow) continue; if (!writes.has(workflow)) writes.set(workflow, []); writes.get(workflow).push({ e, seq: a.seq }); } } fired.sort((x, y) => (x.a.seq ?? 0) - (y.a.seq ?? 0) || (x.a.ts < y.a.ts ? -1 : 1)); const runs = []; for (const { e, a } of fired) { const workflow = a.detail?.workflow ?? null; let run = runs.at(-1); const sameRun = run && run.workflow === workflow && a.seq != null && a.seq === run.lastSeq + 1 && !run.failed && !run.rows.has(e); if (!sameRun) { run = { workflow, firstSeq: a.seq ?? 0, rows: new Set(), comments: [], failed: null, name: null, at: null, originId: this.state.tables[e.dbId]?.spaceId ?? null }; runs.push(run); } run.rows.add(e); run.lastSeq = a.seq; run.failed = a.detail?.failed ?? null; run.name = a.detail?.name ?? null; run.at = a.ts; if (here(e) && (a.detail?.steps ?? []).some((s) => s && 'text' in s && !('field' in s))) run.comments.push(e); } const seen = new Map(); for (const list of writes.values()) list.sort((x, y) => x.seq - y.seq); for (const run of runs) { const list = writes.get(run.workflow) ?? []; let i = seen.get(run.workflow) ?? 0; run.changed = new Set(run.comments); for (; i < list.length && list[i].seq < run.firstSeq; i++) run.changed.add(list[i].e); seen.set(run.workflow, i); } const reg = this.#reg; const newest = new Set(); const out = []; for (let i = runs.length - 1; i >= 0 && out.length < HOME_RUNS; i--) { const run = runs[i]; if (hidden?.has(run.originId)) continue; let failedStep = run.failed ? { step: run.failed.step ?? null, error: run.failed.error ?? null } : null; if (!failedStep && !newest.has(run.workflow)) { const row = own(reg.state.entities, run.workflow); const fields = row && !row.deletedAt ? reg.#readEntityIn(row.id).fields : null; if (fields?.Health === 'Failed') failedStep = { step: null, error: fields['Health Reason'] ?? null }; } newest.add(run.workflow); if (!run.changed.size && !(failedStep && inHome(run.originId))) continue; const changedTables = new Map(); for (const e of run.changed) { const table = this.state.tables[e.dbId].name; changedTables.set(table, (changedTables.get(table) ?? 0) + 1); } out.push({ workflowId: run.workflow, name: run.name, originSpace: this.state.spaces[run.originId]?.name ?? null, at: run.at, outcome: failedStep ? 'attention' : 'success', failedStep, changedCount: run.changed.size, changedTables: [...changedTables].map(([table, count]) => ({ table, count })), changedRows: [...run.changed].slice(0, HOME_RUN_ROWS).map((e) => ({ id: e.id, publicId: e.publicId, name: this.entityName(e), table: this.state.tables[e.dbId].name, })), }); } return out; } #nextSeq() { this.state.meta.activitySeq = (this.state.meta.activitySeq ?? 0) + 1; return this.state.meta.activitySeq; } #logActivity(e, kind, detail, { ts = null } = {}) { const last = e.activity[e.activity.length - 1]; if (kind === 'doc-updated' && last?.kind === 'doc-updated' && last.detail?.field === detail.field && last.actor === this.actor && detail.restoredFrom == null && last.detail?.restoredFrom == null && Date.now() - Date.parse(last.ts) < 10 * 60 * 1000) { last.ts = nowISO(); last.detail = { ...detail, prevLength: last.detail.prevLength, delta: detail.length - last.detail.prevLength }; this.#mark(e); return; } const workflow = actorWorkflow(this.actor); e.activity.push({ ts: ts ?? nowISO(), kind, detail, actor: this.actor, seq: this.#nextSeq(), ...(workflow ? { origin: { workflow } } : {}) }); if (e.activity.length > ACTIVITY_CAP) { e.activityDropped = (e.activityDropped ?? 0) + e.activity.length - ACTIVITY_CAP; e.activity = e.activity.slice(-ACTIVITY_CAP); } this.#mark(e); } #stamping = false; #wfQueue = new Set(); #wfEpoch = 0; #wfRows = null; #wfRules = null; #noWebhooks = false; #trialHosted = false; webhookTimeoutMs = 5000; deliveryAttempts = DELIVERY_ATTEMPTS; createAutomation(dbRef, { name, trigger, actions, enabled = true }) { const db = this.getTable(dbRef); const compiled = this.#compileSpec({ table: db.id, trigger, actions }); const reg = this.#reg; const t = this.#sysTable('workflows'); if (!t) throw new WeaveError('This workspace has no Workflows table to hold the rule: open it from its hub', 'invalid'); const wsRow = this.#sysRow('workspaces', this.state.meta.id); const values = { Name: name ?? 'Automation', On: enabled !== false, ...(wsRow ? { Workspace: wsRow.id } : {}) }; const row = this.#asReg(() => reg.createEntity(t.id, { values, docs: { Script: workflowScript(this.#renderSpec(compiled)) } })); if (!db.system) this.#audit('automation-created', { table: db.name, name: values.Name, trigger: compiled.trigger.type }); return this.#ruleOut(reg.#allRules().find((r) => r.row === row)); } #asReg(fn) { const reg = this.#reg; if (reg === this) return fn(); const was = reg.actor; reg.actor = this.actor; try { return fn(); } finally { reg.actor = was; } } #compileSpec(spec) { if (!spec || typeof spec !== 'object' || Array.isArray(spec)) throw new WeaveError('The Script is not a rule: write {"table", "trigger", "actions"} as JSON', 'invalid'); if (spec.table == null || spec.table === '') throw new WeaveError('The Script names no table', 'invalid'); const db = this.getTable(spec.table); const { trigger, actions } = spec; if (!trigger?.type || !['entity-created', 'field-updated', 'state-changed', 'schedule', 'listener-changed', 'webhook-inbound'].includes(trigger.type)) { throw new WeaveError(`Invalid automation trigger`, 'invalid'); } const t = { type: trigger.type }; if (trigger.type === 'schedule') { if (trigger.cron == null || trigger.cron === '') throw new WeaveError('A schedule trigger needs a cron expression: minute hour day month weekday, as in "0 9 * * 1-5"', 'invalid'); try { t.cron = parseCron(trigger.cron).text; } catch (err) { throw new WeaveError(err.message, 'invalid'); } const zone = trigger.zone ?? 'UTC'; if (!DG.isZone(zone)) throw new WeaveError(`'${zone}' is not a time zone`, 'invalid'); t.zone = zone; if (trigger.where != null) { if (typeof trigger.where !== 'object') throw new WeaveError('A schedule where clause is a query filter: a list of [field, op, value] terms, or {and}/{or}', 'invalid'); this.query(db.id, { where: trigger.where, limit: 1, relations: 'chip' }); t.where = trigger.where; } } else if (trigger.type === 'listener-changed') { Object.assign(t, this.#compileListener(trigger)); } else if (trigger.type === 'webhook-inbound') { Object.assign(t, compileHook(trigger)); } else if (trigger.type !== 'entity-created') { const f = this.getField(db.id, trigger.field); t.fieldId = f.id; if (trigger.type === 'state-changed' && trigger.toState) { const st = f.config.states?.find((s) => s.id === trigger.toState || s.name === trigger.toState); if (!st) throw new WeaveError(`Unknown state '${trigger.toState}'`, 'invalid'); t.toStateId = st.id; } } if (!Array.isArray(actions ?? [])) throw new WeaveError('The Script\'s actions are a list', 'invalid'); const acts = (actions ?? []).map((a) => { if (a?.type === 'set-field') { const f = this.getField(db.id, a.field); if (!templated(a.value)) this.#checkFits(f, a.value); return { type: 'set-field', fieldId: f.id, value: a.value }; } if (a?.type === 'append-doc') { const act = { type: 'append-doc', text: String(a.text ?? '') }; if (a.field) act.fieldId = this.#resolveDocField(db, a.field).id; else if (!this.documentFields(db).length) throw new WeaveError(`Table '${db.name}' has no document to append to`, 'invalid'); return act; } if (a?.type === 'add-comment') return { type: 'add-comment', text: String(a.text ?? ''), ...(a.author && a.author !== 'automation' ? { author: a.author } : {}) }; if (a?.type === 'webhook') { if (!/^https?:\/\//.test(a.url ?? '')) throw new WeaveError('Webhook action needs an http(s) url', 'invalid'); if (a.signWith == null || a.signWith === '') return { type: 'webhook', url: a.url }; if (typeof a.signWith !== 'string') throw new WeaveError('A webhook action\'s signWith names a credential held in the keystore, as a string: its value never enters the Script', 'invalid'); return { type: 'webhook', url: a.url, signWith: a.signWith }; } if (a?.type === 'upsert-row') return this.#compileUpsert(a); if (a?.type === 'http-request') return this.#compileHttpRequest(db, a); throw new WeaveError(`Unknown automation action '${a?.type}'`, 'invalid'); }); if (!acts.length) throw new WeaveError('Automation needs at least one action', 'invalid'); if (t.type === 'listener-changed' && acts.some((a) => !LISTENER_ACTIONS.includes(a.type) || a.response)) { throw new WeaveError('A listener-changed rule runs on items from the source, not on a record: its actions are upsert-row or http-request, with no response map', 'invalid'); } if (t.type === 'webhook-inbound') { const odd = acts.find((a) => !LISTENER_ACTIONS.includes(a.type) && !(t.record && HOOK_RECORD_ACTIONS.includes(a.type))); if (odd) throw new WeaveError(HOOK_RECORD_ACTIONS.includes(odd.type) ? `A webhook-inbound rule runs on the body it is sent: ${odd.type} needs a trigger that names the record by a path, as record` : `A webhook-inbound rule runs upsert-row and http-request, or set-field and add-comment on the record it names: not ${odd.type}`, 'invalid'); if (acts.some((a) => a.response)) throw new WeaveError('A webhook-inbound rule\'s http-request takes no response map: its answer has no record to write to', 'invalid'); } return { dbId: db.id, trigger: t, actions: acts }; } #compileListener(trigger) { if (typeof trigger.integration !== 'string' || !trigger.integration) throw new WeaveError('A listener-changed trigger names the System/Integrations record whose key reads the source, as integration', 'invalid'); const method = String(trigger.method ?? 'GET').toUpperCase(); if (method !== 'GET' && method !== 'POST') throw new WeaveError(`A listener-changed trigger reads with GET or POST, not '${trigger.method}'`, 'invalid'); let u; try { u = new URL(String(trigger.url ?? '')); } catch { throw new WeaveError('A listener-changed trigger needs the https url of a list endpoint', 'invalid'); } const target = this.#integrationTarget(this.#integrationRow(trigger.integration)); if (!target.host) throw new WeaveError(`Integration '${target.name}' has no Host yet`, 'invalid'); if (u.protocol !== 'https:' || u.host !== target.host) throw new WeaveError(`Integration '${target.name}' only reaches https://${target.host}: refused ${u.protocol}//${u.host}`, 'invalid'); const t = { integration: target.name, url: u.href, method }; if (trigger.headers != null) { if (typeof trigger.headers !== 'object' || Array.isArray(trigger.headers)) throw new WeaveError('The listener-changed headers are an object of name: value strings', 'invalid'); for (const [k, v] of Object.entries(trigger.headers)) { if (typeof v !== 'string') throw new WeaveError(`The listener-changed header '${k}' takes a string value`, 'invalid'); if (CREDENTIAL_HEADERS.has(k.toLowerCase())) throw new WeaveError(`A Script never carries a '${k}' header: weave sets Authorization from the integration key`, 'invalid'); try { new Headers([[k, v]]); } catch { throw new WeaveError(`'${k}' is not a valid header`, 'invalid'); } } t.headers = { ...trigger.headers }; } if (trigger.cursor != null) { const c = trigger.cursor; if (!c || typeof c !== 'object' || typeof c.param !== 'string' || !c.param || typeof c.path !== 'string' || !c.path) { throw new WeaveError('A listener-changed cursor is {param, path}: the query parameter weave sends, and the path in the answer that holds the next cursor', 'invalid'); } t.cursor = { param: c.param, path: c.path }; } if (trigger.items != null && trigger.items !== '') { if (typeof trigger.items !== 'string') throw new WeaveError('The listener-changed items is the path in the JSON answer that holds the list, such as data', 'invalid'); t.items = trigger.items.trim(); } if (typeof trigger.key !== 'string' || !trigger.key.trim()) throw new WeaveError('A listener-changed trigger needs a key: the path in each item that names it, such as id', 'invalid'); t.key = trigger.key.trim(); const slowest = trigger.maxInterval ?? POLL_SLOWEST_DEFAULT; if (!Number.isInteger(slowest) || slowest < 1 || slowest > POLL_SLOWEST_MAX) throw new WeaveError(`The listener-changed maxInterval is a whole number of minutes from 1 to ${POLL_SLOWEST_MAX}`, 'invalid'); t.maxInterval = slowest; return t; } #compileUpsert(a) { if (a.table == null || a.table === '') throw new WeaveError('The upsert-row action names no target table', 'invalid'); const target = this.getTable(a.table); if (a.key == null || a.key === '') throw new WeaveError('The upsert-row action needs a key field holding the external id', 'invalid'); const key = this.getField(target.id, a.key); if (!UPSERT_KEY_TYPES.includes(key.type)) throw new WeaveError(`Key field '${key.name}' is a ${key.type} field: a key holds text, a number, a url or an email`, 'invalid'); if (!a.fields || typeof a.fields !== 'object' || Array.isArray(a.fields)) throw new WeaveError('The upsert-row fields map each target field to a source path, as an object', 'invalid'); const fields = Object.entries(a.fields).map(([name, path]) => { const f = this.getField(target.id, name); if (COMPUTED_TYPES.includes(f.type)) throw new WeaveError(`Field '${f.name}' is computed (${f.type}) and cannot be written`, 'invalid'); if (typeof path !== 'string' || !path.trim()) throw new WeaveError(`Field '${f.name}' needs a source path such as row.Name`, 'invalid'); return [f.id, path.trim()]; }); if (!fields.some(([fid]) => fid === key.id)) throw new WeaveError(`The upsert-row fields map no source path to the key field '${key.name}'`, 'invalid'); return { type: 'upsert-row', dbId: target.id, keyFieldId: key.id, fields }; } #compileHttpRequest(db, a) { const method = String(a.method ?? 'POST').toUpperCase(); if (!INTEGRATION_METHODS.includes(method)) throw new WeaveError(`The http-request method '${a.method}' is not one of ${INTEGRATION_METHODS.join(', ')}`, 'invalid'); const url = typeof a.url === 'string' ? a.url.trim() : ''; const origin = /^(https?):\/\/([^/?#]*)/i.exec(url); if (!origin) throw new WeaveError('The http-request action needs an http(s) url', 'invalid'); if (origin[2].includes('{{')) throw new WeaveError('The scheme and host of an http-request url are written out, so weave can check them when the rule is saved: templates go in the path, the query, the headers or the body', 'invalid'); try { new URL(blankTemplates(url)); } catch { throw new WeaveError(`'${url}' is not a URL`, 'invalid'); } const act = { type: 'http-request', method, url }; if (a.integration != null && a.integration !== '') { if (typeof a.integration !== 'string') throw new WeaveError('An http-request integration is the Name of a System/Integrations record, as a string', 'invalid'); const target = this.#integrationTarget(this.#integrationRow(a.integration)); if (!target.host) throw new WeaveError(`Integration '${target.name}' has no Host yet`, 'invalid'); if (origin[1].toLowerCase() !== 'https' || origin[2].toLowerCase() !== target.host) { throw new WeaveError(`Integration '${target.name}' only reaches https://${target.host}: refused ${origin[1]}://${origin[2]}`, 'invalid'); } act.integration = target.name; } if (a.signWith != null && a.signWith !== '') { if (typeof a.signWith !== 'string') throw new WeaveError('An http-request signWith names a credential held in the keystore, as a string: its value never enters the Script', 'invalid'); act.signWith = a.signWith; } if (a.headers != null) { if (typeof a.headers !== 'object' || Array.isArray(a.headers)) throw new WeaveError('The http-request headers are an object of name: value strings', 'invalid'); for (const [k, v] of Object.entries(a.headers)) { if (typeof v !== 'string') throw new WeaveError(`The http-request header '${k}' takes a string value`, 'invalid'); if (CREDENTIAL_HEADERS.has(k.toLowerCase())) throw new WeaveError(`A Script never carries a '${k}' header: put the key on a System/Integrations record and name it in integration`, 'invalid'); try { new Headers([[k, blankTemplates(v)]]); } catch { throw new WeaveError(`'${k}' is not a valid http-request header`, 'invalid'); } } act.headers = { ...a.headers }; } if (a.body !== undefined && a.body !== null) { if (typeof a.body !== 'string' && typeof a.body !== 'object') throw new WeaveError('The http-request body is a text template or a JSON object or list', 'invalid'); if (method === 'GET' || method === 'HEAD') throw new WeaveError(`A ${method} request carries no body`, 'invalid'); act.body = a.body; } if (a.response != null) { if (typeof a.response !== 'object' || Array.isArray(a.response)) throw new WeaveError('The http-request response maps each field of the record to a path in the JSON answer, as an object', 'invalid'); act.response = Object.entries(a.response).map(([name, path]) => { const f = this.getField(db.id, name); if (COMPUTED_TYPES.includes(f.type) || f.type === 'document') throw new WeaveError(`Field '${f.name}' is a ${f.type} field and cannot take a value from the answer`, 'invalid'); if (typeof path !== 'string' || !path.trim()) throw new WeaveError(`Field '${f.name}' needs a path in the answer, such as id or data.id`, 'invalid'); return [f.id, path.trim()]; }); } return act; } #checkFits(f, value) { if (COMPUTED_TYPES.includes(f.type)) throw new WeaveError(`Field '${f.name}' is computed (${f.type}) and cannot be written`, 'invalid'); if (f.type === 'relation') { this.#normalizeRelationInput(f, value); return; } if (f.type === 'workflow') { if (value == null || value === '') return; const hit = f.config.states.some((s) => s.id === value || s.name === value || s.name.toLowerCase() === String(value).toLowerCase()); if (!hit) throw new WeaveError(`'${value}' is not a state of '${f.name}'`, 'invalid'); return; } if (f.type !== 'document') this.#validateValue(f, value); } #renderSpec(c) { const db = own(this.state.tables, c.dbId); if (!db || db.deletedAt) return null; const fname = (fid) => { if (!own(db.fields, fid)) throw new WeaveError(`Field '${fid}' not found`, 'not-found'); return db.fields[fid].name; }; try { const trigger = { type: c.trigger.type }; Object.assign(trigger, triggerOut(c.trigger)); if (c.trigger.fieldId) trigger.field = fname(c.trigger.fieldId); if (c.trigger.toStateId) { const st = db.fields[c.trigger.fieldId].config.states?.find((s) => s.id === c.trigger.toStateId); if (!st) return null; trigger.toState = st.name; } const actions = c.actions.map((a) => { if (a.type === 'set-field') return { type: a.type, field: fname(a.fieldId), value: a.value }; if (a.type === 'append-doc') return { type: a.type, ...(a.fieldId ? { field: fname(a.fieldId) } : {}), text: a.text }; if (a.type === 'add-comment') return { type: a.type, text: a.text, ...(a.author ? { author: a.author } : {}) }; if (a.type === 'upsert-row') { const target = own(this.state.tables, a.dbId); if (!target || target.deletedAt) throw new WeaveError(`Table '${a.dbId}' not found`, 'not-found'); const tname = (fid) => { if (!own(target.fields, fid)) throw new WeaveError(`Field '${fid}' not found`, 'not-found'); return target.fields[fid].name; }; return { type: a.type, table: this.qualifiedName(target), key: tname(a.keyFieldId), fields: Object.fromEntries(a.fields.map(([fid, path]) => [tname(fid), path])) }; } if (a.type === 'http-request') { return { type: a.type, method: a.method, url: a.url, ...(a.integration ? { integration: a.integration } : {}), ...(a.signWith ? { signWith: a.signWith } : {}), ...(a.headers ? { headers: a.headers } : {}), ...(a.body !== undefined ? { body: a.body } : {}), ...(a.response ? { response: Object.fromEntries(a.response.map(([fid, path]) => [fname(fid), path])) } : {}), }; } return { type: a.type, url: a.url, ...(a.signWith ? { signWith: a.signWith } : {}) }; }); return { table: this.qualifiedName(db), trigger, actions }; } catch { return null; } } #workflowRows() { const t = this.#sysTable('workflows'); if (!t) return []; if (this.#wfRows?.state !== this.state || this.#wfRows.epoch !== this.#wfEpoch || this.#wfRows.table !== t) { const rows = this.#tableRows(t.id).sort((a, b) => a.publicId - b.publicId); this.#wfRows = { state: this.state, epoch: this.#wfEpoch, table: t, rows }; } return this.#wfRows.rows.filter((e) => this.state.entities[e.id] === e && !e.deletedAt); } #workflowHome(row) { const wsId = this.#wsIdOfRow(row); if (!wsId) return this; return this.#engines().find((w) => w.state.meta.id === wsId); } #wfField(name) { return this.#sysField(this.#sysTable('workflows'), name); } #workflowRule(row, text = row.docs?.[this.#wfField('Script').id] ?? '') { const owner = this.#workflowHome(row); if (!owner) return null; try { const src = String(text).trim(); if (!src) throw new WeaveError('The Script is empty: write the rule as JSON {"table", "trigger", "actions"}', 'invalid'); let spec; try { spec = JSON.parse(src); } catch (err) { throw new WeaveError(`The Script is not JSON: ${err.message}`, 'invalid'); } let w = owner; if (spec?.table != null && !owner.findTable(spec.table)) w = this.#engines().find((x) => x.findTable(spec.table)) ?? owner; const compiled = w.#compileSpec(spec); const heard = compiled.trigger.type === 'listener-changed' ? w.state.meta.listeners?.[row.id] : null; const problem = heard?.problem && heard.sig === JSON.stringify(compiled.trigger) ? heard.problem : null; return { row, owner: w, ...compiled, problem }; } catch (err) { return { row, owner, dbId: null, trigger: null, actions: [], problem: err.message }; } } #allRules() { const rows = this.#workflowRows(); if (!rows.length) return []; const engines = this.#engines(); const key = [this.state, this.#wfEpoch, ...engines.flatMap((w) => [w, w.schemaVersion()])]; const c = this.#wfRules; if (c && c.key.length === key.length && c.key.every((k, i) => k === key[i])) return c.rules; const schemaMoved = !c || c.key.length !== key.length || c.key.some((k, i) => i !== 1 && k !== key[i]); const rules = rows.map((row) => (schemaMoved ? this.#settleWorkflow(row) : this.#workflowRule(row))).filter(Boolean); this.#wfRules = { key: [this.state, this.#wfEpoch, ...engines.flatMap((w) => [w, w.schemaVersion()])], rules }; if (this.#stampDirty) { this.#stampDirty = false; this.save(); } return rules; } #settleWorkflows() { if (!this.#sysTable('workflows')) return; this.#wfRules = null; this.#allRules(); } #stampDirty = false; #stamp(row, fn) { const was = this.#stamping; this.#stamping = true; try { if (fn() !== false) { this.#mark(row); this.#stampDirty = true; } } finally { this.#stamping = was; } } #setOption(row, field, name) { const id = name == null ? null : (field.type === 'workflow' ? field.config.states : field.config.options).find((o) => o.name === name)?.id ?? null; if ((row.values[field.id] ?? null) === id) return false; row.values[field.id] = id; return true; } #settleWorkflow(row) { const F = (n) => this.#wfField(n); const script = F('Script'); let rule = this.#workflowRule(row); if (!rule) return null; const text = row.docs?.[script.id] ?? ''; if (rule.problem && row.rule?.text === text) { const hit = this.#tableAnywhere(row.rule.dbId); const spec = hit && hit.owner.#renderSpec(row.rule); const next = spec && workflowScript(spec); if (next && next !== text) { this.#stamp(row, () => { row.docs[script.id] = next; }); rule = this.#workflowRule(row); } } const on = row.values[F('On').id] === true; const lastRun = row.values[F('Last Run').id] ?? null; const health = F('Health'); const healthName = health.config.options.find((o) => o.id === row.values[health.id])?.name ?? null; const reasonF = F('Health Reason'); const reason = row.values[reasonF.id] ?? ''; const SETUP = 'Setup incomplete: '; this.#stamp(row, () => { let moved = this.#setOption(row, F('State'), rule.problem ? 'Setup incomplete' : 'Ready'); const setReason = (r) => { if ((row.values[reasonF.id] ?? '') === r) return; row.values[reasonF.id] = r || null; moved = true; }; if (rule.problem) { if (on) moved = this.#setOption(row, health, 'Failed') || moved; else if (!healthName) moved = this.#setOption(row, health, 'No runs') || moved; setReason(SETUP + rule.problem); } else if (String(reason).startsWith(SETUP)) { if (healthName === 'Failed') moved = this.#setOption(row, health, lastRun ? 'Healthy' : 'No runs') || moved; setReason(''); } else if (!healthName) { moved = this.#setOption(row, health, 'No runs') || moved; } if (!rule.problem) { const next = { text, dbId: rule.dbId, trigger: rule.trigger, actions: rule.actions }; if (JSON.stringify(row.rule) !== JSON.stringify(next)) { row.rule = next; moved = true; } } const spec = rule.problem ? null : rule.owner.#renderSpec(rule); const hookPath = spec && rule.trigger.type === 'webhook-inbound' ? rule.owner.#hookOf(row.id).path : null; for (const [n, words] of [['When', spec && whenWords(spec, hookPath)], ['Then', spec && thenWords(spec)]]) { const wordsF = F(n); if (!wordsF || (row.values[wordsF.id] ?? null) === (words || null)) continue; row.values[wordsF.id] = words || null; moved = true; } return moved; }); if (!rule.problem) { const db = rule.owner.state.tables[rule.dbId]; const wfT = this.#sysTable('workflows'); for (const [name, kind, id] of [['Tables', 'tables', db.id], ['Spaces', 'spaces', db.spaceId], ['Workspace', 'workspaces', rule.owner.state.meta.id]]) { const target = this.#sysRow(kind, id); if (!target) continue; const f = F(name); const cur = this.#relationIds(row, f); if (cur.length === 1 && cur[0] === target.id) continue; this.#stamp(row, () => { this.#setRelationValue(row, wfT, f, [target.id]); }); } } return rule; } #stampRun(row, failure, at = nowISO(), waiting = null, note = null) { const health = this.#wfField('Health'); const reasonF = this.#wfField('Health Reason'); this.#stamp(row, () => { row.values[this.#wfField('Last Run').id] = at; this.#setOption(row, health, failure ? 'Failed' : waiting ? 'Warning' : 'Healthy'); row.values[reasonF.id] = failure ?? waiting ?? note ?? null; }); } #stampPoll(row, health, reason) { const reasonF = this.#wfField('Health Reason'); this.#stamp(row, () => { let moved = this.#setOption(row, this.#wfField('Health'), health); if ((row.values[reasonF.id] ?? null) !== reason) { row.values[reasonF.id] = reason; moved = true; } return moved; }); } #guardWorkflowOn(e, db, values, docs = {}) { if (this.#stamping || this.#migrating) return; const onF = this.#sysField(db, 'On'); const scriptF = this.#sysField(db, 'Script'); if (!onF || !scriptF) return; const keyOf = (f) => Object.keys(values ?? {}).find((k) => this.findField(db, k)?.id === f.id); const onKey = keyOf(onF); if (onKey === undefined || this.#validateValue(onF, values[onKey]) !== true) return; if (e?.values[onF.id] === true) return; const scriptKey = keyOf(scriptF); const text = scriptKey !== undefined ? values[scriptKey] : docs?.Script ?? e?.docs?.[scriptF.id] ?? ''; const reg = this.#reg; const probe = e ?? { dbId: db.id, values: {}, docs: {} }; const rule = reg.#workflowRule(probe, text ?? ''); if (rule?.problem) { const name = values.Name ?? (e ? this.entityName(e) : '') ?? ''; throw new WeaveError(`Workflow '${name}' cannot be switched On until its setup is complete. ${rule.problem}`, 'invalid'); } } #ruleOut(r) { const out = { id: r.row.id, seq: r.row.publicId, dbId: r.dbId, name: this.#reg.entityName(r.row), trigger: r.trigger, actions: r.actions, enabled: r.row.values[this.#wfField('On').id] === true, state: r.problem ? 'Setup incomplete' : 'Ready', }; if (r.problem) out.problem = r.problem; if (r.trigger?.type === 'webhook-inbound') { const hook = r.owner.#hookOf(r.row.id); out.hook = { path: hook.path, revealed: !!hook.entry.revealedAt, ...(hook.entry.log?.[0] ? { last: hook.entry.log[0] } : {}) }; } return out; } #myRules() { return this.#reg.#allRules().filter((r) => r.owner === this); } listAutomations(dbRef = null) { const mine = this.#myRules(); const db = dbRef ? this.getTable(dbRef) : null; return mine.filter((r) => !db || r.dbId === db.id).map((r) => this.#ruleOut(r)); } describeAutomations(dbRef = null) { return this.listAutomations(dbRef).map((auto) => { const db = auto.dbId ? this.state.tables[auto.dbId] : null; const fieldName = (fid) => db?.fields[fid]?.name ?? null; const trigger = auto.trigger ? { type: auto.trigger.type } : null; if (auto.trigger) Object.assign(trigger, triggerOut(auto.trigger)); if (auto.trigger?.fieldId) trigger.field = fieldName(auto.trigger.fieldId); if (auto.trigger?.toStateId && auto.trigger.fieldId) { trigger.toState = db.fields[auto.trigger.fieldId]?.config.states ?.find((s) => s.id === auto.trigger.toStateId)?.name ?? null; } return { id: auto.id, seq: auto.seq, name: auto.name, table: db ? this.qualifiedName(db) : null, tableId: auto.dbId, enabled: auto.enabled, state: auto.state, ...(auto.problem ? { problem: auto.problem } : {}), trigger, ...(auto.hook ? { hook: auto.hook } : {}), actions: auto.actions.map((a) => { if (a.type === 'set-field') return { type: a.type, field: fieldName(a.fieldId) }; if (a.type === 'append-doc') return { type: a.type, field: a.fieldId ? fieldName(a.fieldId) : (db ? this.descriptionField(db)?.name ?? null : null) }; if (a.type === 'webhook') return { type: a.type, url: a.url }; if (a.type === 'http-request') return { type: a.type, method: a.method, url: a.url, ...(a.integration ? { integration: a.integration } : {}) }; if (a.type === 'upsert-row') { const target = this.state.tables[a.dbId]; return { type: a.type, table: target ? this.qualifiedName(target) : null, key: target?.fields[a.keyFieldId]?.name ?? null }; } return { type: a.type }; }), }; }); } #myRow(id) { const r = this.#myRules().find((x) => x.row.id === id); if (r) return r.row; const t = this.#sysTable('workflows'); const row = t ? own(this.#reg.state.entities, id) : undefined; return row && row.dbId === t.id && row.deletedAt && this.#reg.#workflowHome(row) === this ? row : null; } updateAutomation(id, patch) { const row = this.#myRow(id); if (!row || row.deletedAt) throw new WeaveError(`Automation '${id}' not found`, 'not-found'); const values = {}; if (patch.enabled != null) values.On = !!patch.enabled; if (patch.name != null) values.Name = patch.name; if (Object.keys(values).length) this.#asReg(() => this.#reg.updateEntity(row.id, values)); const changed = ['enabled', 'name'].filter((k) => patch[k] != null); const r = this.#myRules().find((x) => x.row === row); const db = r?.dbId ? this.state.tables[r.dbId] : null; if (changed.length && !db?.system) this.#audit('automation-updated', { table: db?.name ?? null, name: this.#reg.entityName(row), patch: changed }); return this.#ruleOut(r); } deleteAutomation(id) { const row = this.#myRow(id); if (!row || row.deletedAt) return { id, deleted: false }; const r = this.#myRules().find((x) => x.row === row); const db = r?.dbId ? this.state.tables[r.dbId] : null; this.#asReg(() => this.#reg.deleteEntity(row.id)); if (!db?.system) this.#audit('automation-deleted', { table: db?.name ?? null, name: this.#reg.entityName(row) }); return { id, deleted: true }; } #migrating = false; #migrateAutomations() { const autos = Object.values(this.state.automations ?? {}); if (!autos.length) return; const t = this.#sysTable('workflows'); if (!t || !this.#wfField('Script')) return; const reg = this.#reg; const key = (a) => a.seq ?? Infinity; autos.sort((x, y) => (key(x) === key(y) ? 0 : key(x) - key(y))); const wsRow = this.#sysRow('workspaces', this.state.meta.id); this.#migrating = true; reg.#migrating = true; try { for (const auto of autos) { const spec = this.#renderSpec(auto) ?? { table: auto.dbId, trigger: auto.trigger, actions: auto.actions }; const Script = workflowScript(spec); const tableRow = this.#sysRow('tables', auto.dbId); const name = auto.name ?? 'Automation'; const adopt = tableRow && reg.#workflowRows().find((e) => reg.entityName(e) === name && reg.#relIds(e, t, 'Tables').includes(tableRow.id)); if (adopt) { reg.#metaSync(() => reg.updateEntity(adopt.id, { Script })); } else { reg.#metaSync(() => reg.createEntity(t.id, { values: { Name: name, On: auto.enabled !== false, ...(wsRow ? { Workspace: wsRow.id } : {}) }, docs: { Script } })); } delete this.state.automations[auto.id]; } } finally { this.#migrating = false; reg.#migrating = false; } reg.save(); if (reg !== this) this.save(); } #runAutomations(db, e, event, depth) { return unrestricted(() => this.#runAutomationsIn(db, e, event, depth)); } #runAutomationsIn(db, e, event, depth) { if (depth >= 3) return; const reg = this.#reg; for (const rule of reg.#allRules()) { if (rule.owner !== this || rule.problem || rule.dbId !== db.id) continue; if (rule.row.deletedAt || rule.row.values[reg.#wfField('On').id] !== true) continue; const t = rule.trigger; if (t.type !== event.type) continue; if (t.fieldId && t.fieldId !== event.fieldId) continue; if (t.toStateId && t.toStateId !== event.toStateId) continue; this.#fireRule(rule, db, [e], event, depth); } } #fireRule(rule, db, rows, event, depth, at = nowISO()) { const reg = this.#reg; const run = { failed: false, rule, db, rows, event, depth, at, name: reg.entityName(rule.row), ran: [], waited: false }; this.#stepRun(run, 0, 0); } #pendingRuns = new Set(); async automationsSettled() { while (this.#pendingRuns.size) await Promise.all([...this.#pendingRuns]); } #liveRow(e) { return own(this.state.entities, e.id) ?? e; } #stepRun(run, r, i, answer) { const { rule, event, depth, name } = run; let failure = null; const person = this.actor; const owner = this.#undoOwner; this.#undoOwner = run.owner ??= owner ?? person; this.actor = workflowActor(rule.row.id); try { for (; r < run.rows.length; r++, i = 0) { run.ran[r] ??= { e: this.#liveRow(run.rows[r]), steps: {}, outs: [] }; const step = run.ran[r]; for (; i < rule.actions.length; i++) { let out; if (answer !== undefined) { step.e = this.#liveRow(step.e); if (step.e.item === undefined && (step.e.deletedAt || !own(this.state.entities, step.e.id))) throw new WeaveError('The record was deleted before the answer came back', 'not-found'); const action = rule.actions[i]; out = action.type === 'webhook' ? { url: action.url, sent: true } : this.#httpLand(action, step.e, run.db, answer, depth); answer = undefined; } else { out = this.#runAction(rule, name, rule.actions[i], run.db, step.e, event, depth, run, step.steps) ?? null; if (out?.[OUTBOUND]) { this.#awaitStep(run, r, i, out[OUTBOUND]); return; } } step.steps[String(i + 1)] = out; step.outs.push(out); } } } catch (err) { failure = err.message; run.failed = true; } finally { this.actor = person; this.#undoOwner = owner; } this.#finishRun(run, failure); } #awaitStep(run, r, i, request) { run.waited = true; this.#inFlight.add(request.delivery); const job = this.#send(request).then( (answer) => { this.#dropDelivery(request.delivery); this.#rememberEcho(request, run.ran[r]?.e ?? run.rows[r]); if (request.deadLetter) this.#letterLanded(request, answer); run.db = own(this.state.tables, run.db.id) ?? run.db; unrestricted(() => this.#stepRun(run, r, i, answer)); }, (err) => unrestricted(() => this.#deliveryFailed(run, r, i, request, err)), ).catch(() => {}).finally(() => { this.#inFlight.delete(request.delivery); this.#pendingRuns.delete(job); }); this.#pendingRuns.add(job); } #finishRun(run, failure) { const reg = this.#reg; const { ran, name, rule } = run; ran.forEach(({ e, outs }, i) => { if (!own(this.state.entities, e.id)) return; this.#logActivity(this.#liveRow(e), 'automation-ran', { name, workflow: rule.row.id, steps: outs, ...(failure != null && i === ran.length - 1 ? { failed: { step: outs.length + 1, error: failure } } : {}), }); }); const waiting = failure == null ? (this.state.meta.deliveries ?? []).filter((d) => d.workflow === rule.row.id).at(-1)?.reason ?? null : null; const note = rule.trigger?.type === 'listener-changed' ? this.#pollWords(rule.row.id) : rule.trigger?.type === 'webhook-inbound' ? this.#hookWords(rule.row.id) : null; reg.#stampRun(own(reg.state.entities, rule.row.id) ?? rule.row, failure, run.at, waiting, note); if (run.waited) this.save(); if (reg !== this) reg.save(); } #inFlight = new Set(); claimInbound(source, key, { now = this.now(), windowMs = INBOUND_WINDOW_MS } = {}) { if (typeof source !== 'string' || !source) throw new WeaveError('An inbound delivery names its source', 'invalid'); if (key == null || key === '') throw new WeaveError('An inbound delivery needs a delivery key to be acknowledged once', 'invalid'); const store = (this.state.meta.inboundKeys ??= {}); const out = claimKey(store[source], String(key), now.getTime(), windowMs); store[source] = out.seen; if (!out.duplicate) this.save(); return { duplicate: out.duplicate, key: String(key), firstAt: new Date(out.firstAt).toISOString() }; } isEcho(source, change, { now = this.now(), windowMs = ECHO_WINDOW_MS } = {}) { return matchesEcho(this.state.meta.echoes, { ...change, source }, now.getTime(), windowMs); } #hookOf(rowId) { const hooks = (this.state.meta.hooks ??= {}); let id = Object.keys(hooks).find((k) => hooks[k].row === rowId); if (!id) { id = randomBytes(16).toString('hex'); hooks[id] = { row: rowId, createdAt: nowISO(), log: [] }; this.save(); } return { id, entry: hooks[id], path: hookPathOf(this.state.meta.name, id) }; } #hookWords(rowId) { const last = Object.values(this.state.meta.hooks ?? {}).find((h) => h.row === rowId)?.log?.[0]; if (!last) return null; return `Last delivery ${utcMinute(new Date(last.at))}: ${last.outcome}${last.reason ? `: ${last.reason}` : ''}`; } #hookSecret(id) { const keys = this.#reg.keyStore; const name = hookKeyName(id); const entry = own(keys.read().keys, name); return entry?.hook === true && entry.workspace === this.state.meta.id ? keys.value(name, entry) : null; } #hookRule(rowId) { const mine = this.#myRules(); const rule = mine.find((r) => r.row.id === rowId) ?? mine.find((r) => this.#reg.entityName(r.row) === rowId); if (!rule || rule.row.deletedAt) throw new WeaveError(`Automation '${rowId}' not found`, 'not-found'); if (rule.trigger?.type !== 'webhook-inbound') throw new WeaveError(`Automation '${this.#reg.entityName(rule.row)}' has no webhook-inbound trigger`, 'invalid'); return rule; } hookDeliveries(automation) { const rule = this.#hookRule(automation); return structuredClone(this.#hookOf(rule.row.id).entry.log ?? []); } revealHookSecret(automation, { rotate = false } = {}) { const rule = this.#hookRule(automation); if (rule.trigger.secret) throw new WeaveError(`This hook checks deliveries with the keystore credential '${rule.trigger.secret}': manage it with weave key`, 'invalid'); const { id, entry, path } = this.#hookOf(rule.row.id); if (entry.revealedAt && !rotate) throw new WeaveError(`The secret was shown once, at ${entry.revealedAt}: rotate to mint a new one, which retires the old one`, 'conflict'); const secret = `whsec_${randomBytes(32).toString('base64url')}`; this.#onReg((reg) => { const data = reg.keyStore.read(); data.keys[hookKeyName(id)] = { value: secret, owner: null, workspace: this.state.meta.id, shared: false, hook: true, createdAt: nowISO() }; reg.keyStore.write(data); }); entry.revealedAt = nowISO(); this.#audit('hook-secret-revealed', { automation: this.#reg.entityName(rule.row), hook: id, rotated: !!rotate }); this.save(); return { automation: rule.row.id, url: path, endpoint: `/api/hooks/${id}`, secret }; } #hookRecord(db, ref) { if (ref == null || typeof ref === 'object') return null; const text = String(ref).trim(); const pid = /^#?(\d+)$/.exec(text); if (pid) return this.listEntities(db.id).find((x) => x.publicId === Number(pid[1])) ?? null; try { return this.findEntity(db.id, text) ?? null; } catch { return null; } } receiveHook(hookId, { bytes = null, header = () => undefined, size = null } = {}) { const was = this.actor; this.actor = 'webhook'; try { return unrestricted(() => this.#receiveHook(hookId, bytes, header, size)); } finally { this.actor = was; } } #receiveHook(hookId, bytes, header, size) { const missing = { status: 404, answer: { error: 'No hook at this address', code: 'not-found' } }; const entry = /^[0-9a-f]{32}$/.test(String(hookId)) ? own(this.state.meta.hooks ?? {}, hookId) : undefined; if (!entry) return missing; const reg = this.#reg; const rule = reg.#allRules().find((r) => r.owner === this && r.row.id === entry.row); const row = rule && own(reg.state.entities, rule.row.id); if (!row || row.deletedAt || (rule.trigger && rule.trigger.type !== 'webhook-inbound')) return missing; const now = this.now(); const record = { at: now.toISOString(), size: bytes?.length ?? (Number.isFinite(size) ? size : 0) }; const save = () => { this.save(); if (reg !== this) reg.save(); }; const log = (status, outcome, reason = null, key = null) => { entry.log = [{ ...record, status, outcome, ...(reason ? { reason } : {}), ...(key != null ? { delivery: String(key) } : {}) }, ...(entry.log ?? [])].slice(0, HOOK_LOG_KEEP); if (['refused', 'failed', 'setup incomplete'].includes(outcome)) reg.#stampPoll(row, 'Warning', this.#hookWords(row.id)); }; const answer = (status, outcome, body, reason = null, key = null) => { log(status, outcome, reason, key); save(); return { status, answer: body }; }; const refuse = (status, code, reason, outcome = 'refused') => answer(status, outcome, { error: `Refused: ${reason}`, code }, reason); if (bytes == null) return refuse(413, 'too-large', `body over ${HOOK_BODY_MAX} bytes`); const t = rule.trigger; let secret = null; try { secret = t?.secret ? this.resolveKey(t.secret) : this.#hookSecret(hookId); } catch { secret = null; } const refusal = hookRefusal(secret, header, bytes, now.getTime()); if (refusal) return refuse(401, 'unauthorized', refusal); if (rule.problem) return answer(202, 'setup incomplete', { accepted: false, ran: 0, reason: `The rule is not set up: ${rule.problem}` }, rule.problem); if (row.values[reg.#wfField('On').id] !== true) return answer(202, 'off', { accepted: false, ran: 0, reason: 'The rule is Off: nothing ran' }, 'the rule is Off'); const text = bytes.toString('utf8'); let body; try { body = text ? JSON.parse(text) : null; } catch { body = text; } const list = t.items ? sourcePath(body, t.items) : [body]; if (!Array.isArray(list)) return refuse(422, 'invalid', `the items path '${t.items}' finds no list in the body`); const db = own(this.state.tables, rule.dbId); if (!db || db.deletedAt) return refuse(422, 'invalid', 'the rule\'s table is gone'); let echoes = 0; const fire = []; for (const item of list) { if (t.from && item && typeof item === 'object' && !Array.isArray(item) && this.isEcho(t.from, { values: item }, { now })) { echoes++; continue; } let target = null; if (t.record) { const ref = sourcePath(item, t.record); target = this.#hookRecord(db, ref); if (!target) return answer(422, 'failed', { error: `No record '${ref ?? ''}' in ${this.qualifiedName(db)}: nothing ran`, code: 'invalid' }, `no record '${ref ?? ''}' in ${this.qualifiedName(db)}`); } fire.push({ item, target }); } const key = t.key ? sourcePath(body, t.key) : header('x-weave-delivery'); const keyed = key != null && key !== '' && typeof key !== 'object'; if (keyed) { const claim = this.claimInbound(`hook:${hookId}`, key, { now }); if (claim.duplicate) return answer(200, 'duplicate', { duplicate: true, ran: 0, firstAt: claim.firstAt }, `first seen ${claim.firstAt}`, key); } log(200, fire.length || !echoes ? 'ran' : 'echo', null, keyed ? key : null); fire.forEach(({ item, target }, i) => { const event = { type: 'webhook-inbound', trigger: { body, ...(t.items ? { item } : {}) } }; this.#fireRule(rule, db, [target ?? itemRow(`${keyed ? key : hookId}#${i + 1}`, item ?? null)], event, 0, record.at); }); save(); return { status: 200, answer: { ran: fire.length, echoes, ...(keyed ? { delivery: String(key) } : {}) } }; } #rememberEcho(request, e) { const values = request.body === undefined ? null : sentValues(request.body); if (!values) return; const now = this.now().getTime(); const source = request.integration ?? new URL(request.url).host; const kept = (this.state.meta.echoes ?? []).filter((x) => now - x.at <= ECHO_WINDOW_MS); kept.push({ source, record: e?.id ?? null, publicId: e?.publicId ?? null, values, at: now }); this.state.meta.echoes = kept.slice(-ECHO_KEEP); } pendingDeliveries() { return (this.state.meta.deliveries ?? []).map((d) => ({ id: d.request.delivery, workflow: d.workflow, kind: d.request.kind, method: d.request.method, url: d.request.url, attempts: d.request.attempts.length, next: d.next, reason: d.reason, })); } #dropDelivery(id) { const list = this.state.meta.deliveries; if (!list?.some((d) => d.request.delivery === id)) return false; const rest = list.filter((d) => d.request.delivery !== id); if (rest.length) this.state.meta.deliveries = rest; else delete this.state.meta.deliveries; return true; } #deliveryFailed(run, r, i, request, err) { const reg = this.#reg; const now = this.now(); const attempts = [...(request.attempts ?? []), { at: now.toISOString(), status: err?.status ?? null, error: err?.message ?? String(err) }]; const cap = this.deliveryAttempts; this.#dropDelivery(request.delivery); if (!err?.retryable || attempts.length >= cap) { run.failed = true; if (request.deadLetter || err?.retryable) this.#deadLetter(run, r, request, attempts); this.#finishRun(run, err?.retryable ? exhaustedReason(err.message, attempts.length) : attempts.at(-1).error); return; } const next = new Date(now.getTime() + retryDelayMs(attempts.length)); const reason = waitingReason(err.message, attempts.length, cap, next); (this.state.meta.deliveries ??= []).push({ ...this.#runSnapshot(run, r, i, { ...request, attempts }), next: next.toISOString(), reason }); const row = own(reg.state.entities, run.rule.row.id); if (row && !row.deletedAt) reg.#stampRun(row, null, run.at, reason); this.save(); if (reg !== this) reg.save(); } #runSnapshot(run, r, i, request) { return JSON.parse(JSON.stringify({ workflow: run.rule.row.id, dbId: run.db.id, rows: run.rows.map((e) => (e.item !== undefined ? { id: e.id, key: e.key, item: e.item } : e.id)), event: run.event, depth: run.depth, at: run.at, r, i, ran: run.ran.map((x) => x && { id: x.e.id, steps: x.steps, outs: x.outs, ...(x.e.item !== undefined ? { key: x.e.key, item: x.e.item } : {}) }), request, })); } #deadLetter(run, r, request, attempts) { const reg = this.#reg; const t = reg.#sysTable('deliveries'); if (!t) return; const F = (n) => reg.#sysField(t, n); const last = attempts.at(-1); const existing = request.deadLetter ? own(reg.state.entities, request.deadLetter) : null; if (existing && !existing.deletedAt) { const all = [...(existing.delivery?.request?.attempts ?? []), ...attempts]; reg.#stamp(existing, () => { reg.#setOption(existing, F('Status'), 'Failed'); existing.values[F('Last Error').id] = last.error; existing.values[F('Last Attempt').id] = last.at; existing.docs = { ...(existing.docs ?? {}), [F('Attempts').id]: attemptsText(all) }; existing.delivery = { ...existing.delivery, request: { ...existing.delivery.request, attempts: all } }; }); return; } const e = this.#liveRow(run.ran[r]?.e ?? run.rows[r]); const { deadLetter, ...kept } = request; const made = reg.#metaSync(() => reg.createEntity(t.id, { name: deliveryName(request), values: { Status: 'Failed', Workflow: run.rule.row.id, 'Last Error': last.error, 'Last Attempt': last.at, Record: e.item !== undefined ? `${run.rule.trigger.integration ?? 'Webhook'} item ${e.key}` : `${this.qualifiedName(run.db)} #${e.publicId} ${this.entityName(e)}`, ...(reg.#sysRow('workspaces', this.state.meta.id) ? { Workspace: reg.#sysRow('workspaces', this.state.meta.id).id } : {}), }, docs: { Request: requestText(request), Attempts: attemptsText(attempts) }, })); const row = own(reg.state.entities, made.id); reg.#stamp(row, () => { row.delivery = this.#runSnapshot(run, r, run.ran[r]?.outs.length ?? 0, { ...kept, attempts }); }); } #letterLanded(request, answer) { const reg = this.#reg; const t = reg.#sysTable('deliveries'); const row = own(reg.state.entities, request.deadLetter); if (!t || !row || row.deletedAt) return; const F = (n) => reg.#sysField(t, n); const at = this.now().toISOString(); const all = [...(row.delivery?.request?.attempts ?? []), ...(request.attempts ?? []), { at, status: answer.status, error: `HTTP ${answer.status}, delivered` }]; reg.#stamp(row, () => { reg.#setOption(row, F('Status'), 'Delivered'); row.values[F('Last Attempt').id] = at; row.docs = { ...(row.docs ?? {}), [F('Attempts').id]: attemptsText(all) }; row.delivery = { ...row.delivery, request: { ...row.delivery.request, attempts: all } }; }); reg.save(); } replayDelivery(ref) { const reg = this.#reg; const t = reg.#sysTable('deliveries'); const row = t && (own(reg.state.entities, ref) ?? reg.findEntity(t.id, ref)); if (!row || row.dbId !== t.id || row.deletedAt) throw new WeaveError(`No failed delivery '${ref}'`, 'not-found'); const name = reg.entityName(row); const statusF = reg.#sysField(t, 'Status'); const status = statusF.config.options.find((o) => o.id === row.values[statusF.id])?.name ?? null; if (status === 'Delivered') throw new WeaveError(`The delivery '${name}' was already delivered`, 'invalid'); const d = row.delivery; if (!d?.request) throw new WeaveError(`The delivery '${name}' holds no stored request to send`, 'invalid'); const rule = reg.#allRules().find((x) => x.row.id === d.workflow); const owner = rule?.owner; const run = owner?.#reviveRun(d); if (!run) throw new WeaveError(`The workflow behind the delivery '${name}' is gone or no longer sends this step, so it cannot be replayed`, 'invalid'); if (owner.#inFlight.has(d.request.delivery) || (owner.state.meta.deliveries ?? []).some((x) => x.request.delivery === d.request.delivery)) { throw new WeaveError(`The delivery '${name}' is being sent already`, 'conflict'); } reg.#stamp(row, () => reg.#setOption(row, statusF, 'Replay')); reg.save(); owner.#awaitStep(run, d.r, d.i, { ...d.request, attempts: [], deadLetter: row.id }); return { id: row.id, publicId: row.publicId, name, status: 'Replay' }; } #updateDeliveryRow(e, db, valuesByName) { const statusF = this.#sysField(db, 'Status'); const key = Object.keys(valuesByName).find((k) => this.findField(db, k)?.id === statusF.id); if (key === undefined) return undefined; const want = statusF.config.options.find((o) => o.id === valuesByName[key] || o.name.toLowerCase() === String(valuesByName[key] ?? '').toLowerCase())?.name; if (want !== 'Replay') throw new WeaveError('weave sets Failed and Delivered: set Status to Replay to send the delivery again', 'invalid'); const { [key]: _, ...rest } = valuesByName; if (Object.keys(rest).length) this.#metaSync(() => this.updateEntity(e.id, rest)); this.replayDelivery(e.id); return this.getEntity(e.id); } #reviveRun(d) { const reg = this.#reg; const rule = reg.#allRules().find((x) => x.owner === this && x.row.id === d.workflow); const db = own(this.state.tables, d.dbId); const action = rule?.actions?.[d.i]; if (!rule || rule.problem || rule.row.deletedAt || !db || (action?.type !== 'webhook' && action?.type !== 'http-request')) return null; const live = (id) => own(this.state.entities, id); const rows = d.rows.map((id, k) => (typeof id === 'object' ? itemRow(id.key, id.item) : live(id) ?? (k <= d.r ? { id } : null))).filter(Boolean); const ran = d.ran.map((x) => x && { e: x.item !== undefined ? itemRow(x.key, x.item) : live(x.id) ?? { id: x.id }, steps: x.steps, outs: x.outs }); return { failed: false, rule, db, rows, event: d.event, depth: d.depth, at: d.at, name: reg.entityName(rule.row), ran, waited: true }; } runDeliveries({ now = this.now() } = {}) { let sent = 0; let dropped = false; for (const d of [...(this.state.meta.deliveries ?? [])]) { if (this.#inFlight.has(d.request.delivery) || Date.parse(d.next) > now.getTime()) continue; const run = this.#reviveRun(d); if (!run) { dropped = this.#dropDelivery(d.request.delivery) || dropped; continue; } this.#awaitStep(run, d.r, d.i, d.request); sent++; } if (dropped) this.save(); return sent; } #scheduleSince = new Map(); runSchedules({ now = this.now() } = {}) { this.runDeliveries({ now }); const reg = this.#reg; const F = (n) => reg.#wfField(n); let fired = 0; for (const rule of reg.#allRules()) { if (rule.owner !== this || rule.trigger?.type !== 'schedule') continue; const row = rule.row; if (rule.problem || row.deletedAt || row.values[F('On').id] !== true) { this.#scheduleSince.set(row.id, now.getTime()); continue; } const seen = this.#scheduleSince.get(row.id); const lastRun = row.values[F('Last Run').id]; if (seen === undefined && lastRun == null) { this.#scheduleSince.set(row.id, now.getTime()); continue; } const since = new Date(Math.max(lastRun ? Date.parse(lastRun) : 0, seen ?? 0)); if (!dueBetween(parseCron(rule.trigger.cron), rule.trigger.zone, since, now)) continue; const db = this.state.tables[rule.dbId]; const rows = this.query(db.id, { where: rule.trigger.where ?? [], relations: 'chip' }).items .map((item) => own(this.state.entities, item.id)).filter(Boolean); this.#fireRule(rule, db, rows, { type: 'schedule' }, 0, now.toISOString()); fired++; } if (fired) this.save(); this.#runListeners(now); return fired; } #polling = new Set(); #pollState(rule) { const store = (this.state.meta.listeners ??= {}); const sig = JSON.stringify(rule.trigger); if (store[rule.row.id]?.sig !== sig) store[rule.row.id] = { sig, snapshot: null, every: 1, next: 0, failures: 0 }; return store[rule.row.id]; } #pollBudget(name) { const reg = this.#reg; const t = reg.#sysTable('integrations'); const row = t && reg.findEntity(t.id, name); const v = row ? row.values[reg.#sysField(t, 'Poll Budget')?.id] : null; return Number.isFinite(v) && v >= 1 ? Math.floor(v) : POLL_BUDGET_DEFAULT; } #runListeners(now) { const reg = this.#reg; const on = reg.#wfField('On'); const ms = now.getTime(); const logs = this.state.meta.pollLog ?? {}; for (const rule of reg.#allRules()) { if (rule.owner !== this || rule.trigger?.type !== 'listener-changed' || rule.problem) continue; if (rule.row.deletedAt || rule.row.values[on.id] !== true || this.#polling.has(rule.row.id)) continue; const state = this.#pollState(rule); if (state.next > ms) continue; const name = rule.trigger.integration; const log = pollPrune(logs[name], ms); if (log.length >= this.#pollBudget(name)) { logs[name] = log; continue; } logs[name] = [...log, ms]; this.state.meta.pollLog = logs; this.#polling.add(rule.row.id); const job = this.#poll(rule, state, now) .then((answer) => unrestricted(() => this.#pollLanded(rule.row.id, state.sig, now, answer))) .catch((err) => console.error(`weave: poll for '${reg.entityName(rule.row)}' failed: ${err?.message ?? err}`)) .finally(() => { this.#polling.delete(rule.row.id); this.#pendingRuns.delete(job); }); this.#pendingRuns.add(job); } } async #poll(rule, state, now) { const t = rule.trigger; const u = new URL(t.url); if (t.cursor && state.cursor != null) u.searchParams.set(t.cursor.param, String(state.cursor)); const headers = { ...(t.headers ?? {}), ...(state.etag ? { 'If-None-Match': state.etag } : {}) }; const where = `${t.method} ${u.host}${u.pathname}`; try { return { where, res: await this.integrationFetch(t.integration, u.href, { method: t.method, headers }) }; } catch (err) { return { where, error: err, at: now }; } } #pollWords(rowId) { const s = this.state.meta.listeners?.[rowId]; if (!s?.last) return null; const n = Object.keys(s.snapshot ?? {}).length; return `Last poll ${utcMinute(new Date(s.last))}, next ${utcMinute(new Date(s.next))}, ${n} item${n === 1 ? '' : 's'} seen`; } #pollLanded(rowId, sig, now, { where, res, error }) { const reg = this.#reg; const rule = reg.#allRules().find((r) => r.owner === this && r.row.id === rowId); const state = this.state.meta.listeners?.[rowId]; if (!rule || rule.problem || !state || state.sig !== sig) return; const row = own(reg.state.entities, rowId) ?? rule.row; const t = rule.trigger; const ms = now.getTime(); state.last = ms; const backOff = (wait, health, reason) => { state.failures = (state.failures ?? 0) + 1; state.next = ms + (wait ?? retryDelayMs(state.failures)); reg.#stampPoll(row, health, `${reason}; next poll ${utcMinute(new Date(state.next))}`); }; const done = () => { this.save(); if (reg !== this) reg.save(); }; if (error) { backOff(null, error.unreachable ? 'Warning' : 'Failed', `${where}: ${error.message}`); return done(); } let changed = false; if (res.status !== 304) { if (!res.ok) { const wait = retryAfterMs(res.headers?.['retry-after'], ms); backOff(wait, res.status === 408 || res.status === 429 || res.status >= 500 ? 'Warning' : 'Failed', `${where} answered HTTP ${res.status}`); return done(); } let parsed; try { parsed = JSON.parse(res.body); } catch { parsed = undefined; } const list = t.items ? sourcePath(parsed, t.items) : parsed; if (!Array.isArray(list)) { state.problem = t.items ? `the items path '${t.items}' finds no list in the answer from ${where}` : `the answer from ${where} is not a JSON list: name the path to the list in items`; reg.#settleWorkflows(); return done(); } const first = state.snapshot == null; const { changes, seen } = diffItems(state.snapshot, list, (item) => sourcePath(item, t.key)); state.snapshot = t.cursor && !first ? { ...state.snapshot, ...seen } : seen; if (t.cursor) { const next = sourcePath(parsed, t.cursor.path); if (next != null && typeof next !== 'object') state.cursor = next; } state.etag = res.headers?.etag ?? null; changed = first || changes.length > 0; state.every = nextEvery(state.every ?? 1, changed, t.maxInterval); state.next = ms + state.every * 60_000; state.failures = 0; const fire = changes.filter((c) => !this.isEcho(t.integration, { values: c.values }, { now })); const db = own(this.state.tables, rule.dbId); if (db) for (const c of fire) this.#fireRule(rule, db, [itemRow(c.key, c.item)], { type: 'listener-changed' }, 0, now.toISOString()); if (fire.length) return done(); } else { state.every = nextEvery(state.every ?? 1, false, t.maxInterval); state.next = ms + state.every * 60_000; state.failures = 0; } reg.#stampPoll(row, 'Healthy', this.#pollWords(rowId)); return done(); } #runAction(rule, name, action, db, e, event, depth, run, steps = {}) { const zone = rule.trigger?.zone ?? null; if (action.type === 'set-field') { const f = db.fields[action.fieldId]; if (!f) return null; const value = templated(action.value) ? this.#template(action.value, e, db, steps, event.trigger, zone) : action.value; const before = this.#undoBefore(e, db, [f]); this.#applyValues(e, db, { [f.name]: value }, { depth: depth + 1 }); if (this.#undoChanged(e, before)) this.#recordUndo('update', e, { before }); return { field: f.name, value }; } else if (action.type === 'append-doc') { const docField = db.fields[action.fieldId] ?? this.documentFields(db)[0]; if (docField) { e.docs = e.docs ?? {}; const before = e.docs[docField.id] ?? ''; const text = stripEmptyEmbeds(this.#template(action.text, e, db, steps, event.trigger, zone)); const after = (before ? before.replace(/\n*$/, '\n\n') : '') + text; const prior = { at: e.updatedAt, by: e.modifiedBy }; this.#recordUndo('update', e, { before: { values: {}, docs: { [docField.id]: before } } }); e.docs[docField.id] = after; e.updatedAt = nowISO(); e.modifiedBy = this.actor; this.#logActivity(e, 'doc-appended', docChange(docField.name, before, after)); this.#recordDocRevision(e, docField, before, after, { prior }); return { field: docField.name, text }; } } else if (action.type === 'add-comment') { const comment = { id: uuid(), author: action.author ?? this.actor, text: this.#template(action.text, e, db, steps, event.trigger, zone), createdAt: nowISO() }; e.comments.push(comment); this.#recordUndo('comment-add', e, { commentId: comment.id }); return { id: comment.id, text: comment.text }; } else if (action.type === 'upsert-row') { const { e: row, created } = this.#upsertRow(action, this.#upsertSource(e, db, event, steps), depth); return { id: row.id, publicId: row.publicId, name: this.entityName(row), created }; } else if (action.type === 'webhook') { if (this.#noWebhooks) return { url: action.url, sent: false }; const body = JSON.stringify({ event: event.type, workspace: this.state.meta.name, entity: this.#summary(e.id), automation: name, at: nowISO(), }); return { [OUTBOUND]: { kind: 'webhook', method: 'POST', url: action.url, headers: { 'Content-Type': 'application/json' }, body, ...(action.signWith ? { signWith: action.signWith } : {}), delivery: uuid() } }; } else if (action.type === 'http-request') { if (this.#noWebhooks) return { status: null, ok: false, body: null }; return { [OUTBOUND]: this.#httpRequest(action, e, db, steps, event.trigger, zone) }; } return null; } #httpRequest(action, e, db, steps, trigger, zone = null) { const fill = (text) => this.#template(text, e, db, steps, trigger, zone); const headers = Object.fromEntries(Object.entries(action.headers ?? {}).map(([k, v]) => [k, fill(v)])); let body; if (typeof action.body === 'string') body = fill(action.body); else if (action.body !== undefined) { body = JSON.stringify(fillJson(action.body, fill)); if (!Object.keys(headers).some((k) => k.toLowerCase() === 'content-type')) headers['Content-Type'] = 'application/json'; } const filled = fill(action.url); let u; try { u = new URL(filled); } catch { throw new WeaveError(`${action.method} ${filled} is not a URL once its templates are filled in`, 'invalid'); } return { kind: 'http-request', method: action.method, url: u.href, headers, ...(body !== undefined ? { body } : {}), ...(action.integration ? { integration: action.integration } : {}), ...(action.signWith ? { signWith: action.signWith } : {}), delivery: uuid() }; } async #send(request) { const { kind, method, body, integration, signWith } = request; const u = new URL(request.url); const hook = kind === 'webhook'; const where = hook ? `POST ${u.host}` : `${method} ${u.host}${u.pathname}`; const ms = this.webhookTimeoutMs; const said = { status: (s) => (hook ? `${where}: HTTP ${s}` : `${where} answered HTTP ${s}`), timeout: () => (hook ? `${where}: timed out after ${ms / 1000} s` : `${where}: did not answer within ${ms / 1000} s`), network: (code) => (hook ? `${where}: ${code}` : `${where}: could not connect (${code})`), }; const code = (err) => err?.cause?.code ?? err?.cause?.message ?? err?.code ?? err?.message ?? String(err); let headers; try { headers = { ...request.headers, ...this.#signedHeaders(signWith, request.delivery, body ?? '', !!integration) }; } catch (err) { throw new DeliveryError(err.message, { refused: true }); } const init = { method, headers, ...(body !== undefined ? { body } : {}) }; if (integration) { let res; try { res = await this.integrationFetch(integration, u.href, init); } catch (err) { throw new DeliveryError(`${where}: ${err.message}`, { network: !(err instanceof WeaveError) || err.unreachable === true, refused: err instanceof WeaveError && err.unreachable !== true }); } if (!res.ok) throw new DeliveryError(said.status(res.status), { status: res.status }); return res; } let refusal; try { refusal = await webhookRefusal(u); } catch (err) { throw new DeliveryError(hook ? said.network(code(err)) : `${where}: could not resolve ${u.hostname} (${err?.code ?? err?.message})`, { network: true }); } if (refusal) throw new DeliveryError(`${where}: ${refusal}`, { refused: true }); let res; try { const answer = await fetch(u, { ...init, redirect: 'manual', signal: AbortSignal.timeout(ms) }); if (hook) answer.body?.cancel().catch(() => {}); res = { status: answer.status, ok: answer.ok, body: hook ? '' : await answer.text() }; } catch (err) { throw new DeliveryError(err?.name === 'TimeoutError' ? said.timeout() : said.network(code(err)), { network: true }); } if (!res.ok) throw new DeliveryError(said.status(res.status), { status: res.status }); return res; } #httpLand(action, e, db, res, depth) { const parsed = stepBody(res.body); const values = {}; for (const [fid, path] of action.response ?? []) { const f = db.fields[fid]; const v = f ? sourcePath(parsed, path) : undefined; if (v === undefined || v === null) continue; values[f.name] = typeof v === 'object' ? JSON.stringify(v) : v; } if (Object.keys(values).length) { const before = this.#undoBefore(e, db, Object.keys(values)); this.#applyValues(e, db, values, { depth: depth + 1 }); if (this.#undoChanged(e, before)) this.#recordUndo('update', e, { before }); } const kept = JSON.stringify(parsed).length <= HTTP_STEP_BODY_MAX ? { body: parsed } : { body: null, truncated: true }; return { status: res.status, ok: res.ok, ...kept }; } #upsertSource(e, db, event, steps = {}) { if (e.item !== undefined) return { row: {}, event: { type: event.type }, trigger: event.trigger ?? { item: e.item }, steps }; const row = { PublicId: e.publicId }; for (const [name, v] of Object.entries(this.#readEntityIn(e.id).fields)) { const chip = (x) => (x && typeof x === 'object' && 'publicId' in x ? x.name : x); row[name] = Array.isArray(v) ? v.map(chip) : chip(v); } const field = event.fieldId ? db.fields[event.fieldId] : null; const toState = event.toStateId ? field?.config.states?.find((s) => s.id === event.toStateId)?.name : null; return { row, event: { type: event.type, ...(field ? { field: field.name } : {}), ...(toState ? { toState } : {}), ...(event.payload ?? {}) }, ...(event.trigger ? { trigger: event.trigger } : {}), steps }; } #upsertRow(action, source, depth) { const target = own(this.state.tables, action.dbId); if (!target || target.deletedAt) throw new WeaveError('The upsert-row target table is gone', 'invalid'); const key = target.fields[action.keyFieldId]; const values = {}; for (const [fid, path] of action.fields) { const f = target.fields[fid]; const v = sourcePath(source, path); if (!f || blankSource(v)) continue; this.#checkFits(f, v); values[f.name] = v; } if (!key || values[key.name] === undefined) throw new WeaveError(`The upsert-row source has no value for the key field '${key?.name ?? action.keyFieldId}'`, 'invalid'); const want = String(this.#validateValue(key, values[key.name])); const hit = this.listEntities(target.id) .filter((x) => x.values[key.id] != null && String(x.values[key.id]) === want) .sort((x, y) => x.publicId - y.publicId)[0]; if (!hit) return { e: this.createEntity(target.id, { values }, { depth: depth + 1 }), created: true }; delete values[key.name]; return { e: this.updateEntity(hit.id, values, { depth: depth + 1 }), created: false }; } #signedHeaders(signWith, delivery, body, stamped = false) { if (!signWith && !stamped) return {}; const secret = signWith ? this.resolveKey(signWith) : null; const timestamp = String(Math.floor(this.now().getTime() / 1000)); return { 'X-Weave-Timestamp': timestamp, 'X-Weave-Delivery': delivery, ...(secret ? { 'X-Weave-Signature': `sha256=${createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex')}` } : {}), }; } #template(text, e, db, steps = {}, trigger = undefined, zone = null) { return text.replace(/\{\{([^}]+)\}\}/g, (_, name) => { const key = name.trim(); if (key.startsWith('steps.')) { const v = sourcePath({ steps }, key); return v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v); } if (key.startsWith('trigger.')) { const from = trigger ?? (e.item === undefined ? undefined : { item: e.item }); const v = from === undefined ? undefined : sourcePath({ trigger: from }, key); return v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v); } if (key === 'PublicId') return String(e.publicId); if (key === 'Today' || key === 'Today:iso') { const read = zone ?? this.viewerZone ?? 'UTC'; const iso = (DG.fromInstant(this.now().toISOString(), read) ?? this.now().toISOString()).slice(0, 10); return key === 'Today' ? dressDate({ now: this.now(), viewerZone: read }, iso) : iso; } const f = this.findField(db, key); if (!f) return ''; const v = this.#displayValue(db, f, this.#resolve(e, db, f, 0), e); const s = v == null ? '' : Array.isArray(v) ? v.join(', ') : String(v); return s === '' ? `(no ${f.name})` : s; }); } search(text, { limit = 25, table = null } = {}) { const scope = table == null ? null : this.getTable(table).id; return this.#searchHits(text, scope) .map(({ e, score, snippet }) => ({ ...this.#summary(e.id), url: this.permalink('entity', e.id), score, snippet })) .sort((a, b) => b.score - a.score).slice(0, limit); } #searchHits(text, dbId = null) { const needle = String(text ?? '').toLowerCase().trim(); if (!needle) return []; const idm = /^(.*?)#\s*(\d+)$/.exec(needle) ?? /^()(\d+)$/.exec(needle); const idNum = idm ? Number(idm[2]) : null; const idTable = idm ? idm[1].trim() : ''; const exact = pastedEntityId(needle); const results = []; const hidden = this.#hiddenSpaces(); for (const e of dbId ? this.listEntities(dbId) : Object.values(this.state.entities)) { if (e.deletedAt || this.#rowHidden(e, hidden)) continue; const home = this.state.tables[e.dbId]; if (!home || home.deletedAt || this.state.spaces[home.spaceId]?.deletedAt) continue; const name = this.entityName(e); const docText = Object.values(e.docs ?? {}).join('\n'); const comments = e.comments.map((c) => c.text).join('\n'); const textValues = Object.values(home.fields) .filter((f) => SEARCHED_VALUE_TYPES.has(f.type) && f.id !== home.nameFieldId && typeof e.values?.[f.id] === 'string') .map((f) => e.values[f.id]).join('\n'); let score = e.id === exact ? 20 : 0; let snippet = ''; if (idNum !== null && e.publicId === idNum) { if (!idTable || home.name.toLowerCase().startsWith(idTable) || this.qualifiedName(home).toLowerCase().includes(idTable)) score += 20; } if (name.toLowerCase().includes(needle)) score += 10; const hay = textValues + '\n' + docText + '\n' + comments; const idx = hay.toLowerCase().indexOf(needle); if (idx >= 0) { score += 5; snippet = hay.slice(Math.max(0, idx - 40), idx + needle.length + 40).replace(/\n+/g, ' ').trim(); } if (score > 0) results.push({ e, score, snippet }); } return results; } universalSearch(text, { limit = 25, tables = null } = {}) { if (tables != null) return this.#scopedSearch(text, { limit, tables }); const needle = String(text).toLowerCase().trim(); if (!needle) return []; const results = []; if (this.state.meta.name.toLowerCase().includes(needle)) { results.push({ kind: 'workspace', id: 'workspace', name: this.state.meta.name, url: this.permalink('workspace'), score: 8 }); } for (const sp of this.listSpaces()) { if (sp.name.toLowerCase().includes(needle)) { results.push({ kind: 'space', id: sp.id, name: sp.name, url: this.permalink('space', sp.id), score: 9 }); } } let tableCounts = null; for (const db of this.listTables()) { if (db.name.toLowerCase().includes(needle) || this.qualifiedName(db).toLowerCase().includes(needle)) { tableCounts ??= this.#liveRowCounts(); results.push({ kind: 'table', id: db.id, name: this.qualifiedName(db), url: this.permalink('table', db.id), entityCount: tableCounts.get(db.id) ?? 0, score: 9, }); } } for (const v of this.listViews()) { if (v.name.toLowerCase().includes(needle)) { results.push({ kind: 'view', id: v.id, name: v.name, url: this.permalink('view', v.id), score: 8 }); } } const rows = this.#searchHits(text) .filter(({ e }) => !REGISTRY_HITS.has(this.state.tables[e.dbId]?.system)) .sort((a, b) => b.score - a.score).slice(0, limit); for (const { e, score, snippet } of rows) { results.push({ kind: 'entity', url: this.permalink('entity', e.id), ...this.#summary(e.id), score, snippet }); } return Weave.capRows(results, limit); } #scopedSearch(text, { limit, tables }) { const ids = [...new Set((Array.isArray(tables) ? tables : [tables]).map((t) => this.getTable(t).id))]; const hit = (e, score = 0, snippet = '') => ({ kind: 'entity', url: this.permalink('entity', e.id), ...this.#summary(e.id), score, snippet }); if (!String(text ?? '').trim()) { return ids.flatMap((id) => this.listEntities(id)) .filter((e) => this.#liveEntity(e.id)) .sort((a, b) => String(b.updatedAt ?? '').localeCompare(String(a.updatedAt ?? ''))) .slice(0, limit) .map((e) => hit(e)); } return ids.flatMap((id) => this.#searchHits(text, id)) .sort((a, b) => b.score - a.score).slice(0, limit) .map(({ e, score, snippet }) => hit(e, score, snippet)); } static capRows(hits, limit) { const tier = (h) => (h.kind !== 'entity' ? 1 : h.score >= 20 ? 2 : 0); let rows = 0; return [...hits].sort((a, b) => tier(b) - tier(a) || b.score - a.score) .filter((h) => h.kind !== 'entity' || rows++ < limit); } previewFields(entityRef, limit = 3) { const v = this.renderView(entityRef, 'chip', { limit }); return [ ...(v.state ? [{ label: this.#stateLabel(v), value: v.state.name }] : []), ...v.fields.map((f) => ({ label: f.label, value: f.value })), ].slice(0, limit); } #stateLabel(v) { const db = this.state.tables[this.getEntity(v.id).dbId]; return Object.values(db.fields).find((f) => f.type === 'workflow')?.name ?? 'State'; } referencesTo(entityRef) { const target = this.getEntity(entityRef); const db = this.state.tables[target.dbId]; const esc = (x) => String(x).replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); const names = [...new Set([db.name, this.qualifiedName(db)])].map(esc).join('|'); const bracket = new RegExp(`\\[\\[\\s*(?:${names})\\s*#${target.publicId}\\s*(?:\\|[^\\]]*)?\\]\\]`, 'i'); const uuidRef = new RegExp(`\\[\\[\\s*${esc(target.id)}\\s*(?:\\|[^\\]]*)?\\]\\]`, 'i'); const permalink = new RegExp(`/e/${esc(target.id)}(?=[/#?"')\\s]|$)`, 'i'); const out = []; for (const e of Object.values(this.state.entities)) { if (e.deletedAt || e.id === target.id || this.#rowHidden(e)) continue; const docs = Object.values(e.docs ?? {}).filter(Boolean); if (docs.some((t) => bracket.test(t) || uuidRef.test(t) || permalink.test(t))) { out.push(this.#summary(e.id)); } } return out.sort((a, b) => a.db.localeCompare(b.db) || a.publicId - b.publicId); } referencesFrom(entityRef) { const e = this.getEntity(entityRef); const text = Object.values(e.docs ?? {}).filter(Boolean).join('\n'); const ids = new Set(); const add = (id) => { const t = own(this.state.entities, id); if (t && !t.deletedAt && t.id !== e.id && !this.#rowHidden(t)) ids.add(t.id); }; for (const m of text.matchAll(/\[\[\s*([^\][|#\n]+?)\s*#(\d+)\s*(?:\|[^\]]*)?\]\]/g)) { try { add(this.getEntity(`${m[1].trim()}#${m[2]}`).id); } catch {} } const uuidPat = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi; for (const m of text.matchAll(new RegExp(`\\[\\[\\s*(${uuidPat.source})\\s*(?:\\|[^\\]]*)?\\]\\]`, 'gi'))) add(m[1].toLowerCase()); const far = new Map(); const link = new RegExp(`(?:\\[([^\\]\\n]*)\\]\\()?((?:https?://([^/\\s"'()<>]+))?(?:/w/([a-z0-9-]+))?/e/(${uuidPat.source}))(?=[/#?"')\\s]|$)`, 'gi'); for (const m of text.matchAll(link)) { const id = m[5].toLowerCase(); if (own(this.state.entities, id)) { add(id); continue; } const workspace = (m[4] ?? /^([a-z0-9-]+)\.weave\./i.exec(m[3] ?? '')?.[1])?.toLowerCase(); if (!workspace || workspace === this.state.meta.name || far.has(`${workspace}/${id}`)) continue; far.set(`${workspace}/${id}`, { id, external: true, workspace, name: m[1]?.trim() || null, url: m[2] }); } return [...ids].map((id) => this.#summary(id)) .sort((a, b) => a.db.localeCompare(b.db) || a.publicId - b.publicId) .concat([...far.values()]); } storageStats() { let entities = 0; let fileBytes = 0; for (const e of Object.values(this.state.entities)) { if (!e.deletedAt) entities += 1; for (const f of e.files ?? []) fileBytes += (f.size ?? 0) + (f.preview?.size ?? 0); } return { entities, sizeBytes: (this.store.sizeBytes?.() ?? 0) + fileBytes }; } #blobPath(id) { if (!BLOB_ID.test(String(id))) throw new WeaveError(`Invalid file id '${id}'`, 'invalid'); if (!this.store.path) return null; const p = join(dirname(this.store.path), 'files', id); if (dirname(p) !== join(dirname(this.store.path), 'files')) throw new WeaveError(`Invalid file id '${id}'`, 'invalid'); return p; } #hasBlob(id) { const p = this.#blobPath(id); return p ? existsSync(p) : this.state.fileBlobs?.[id] != null; } #readBlob(id) { const p = this.#blobPath(id); if (p) return existsSync(p) ? readFileSync(p) : null; const b64 = this.state.fileBlobs?.[id]; return b64 == null ? null : Buffer.from(b64, 'base64'); } #dropBlob(id) { if (this.state.meta.detachedRegistry?.files.includes(id)) return; if (this.state.fileBlobs) delete this.state.fileBlobs[id]; if (this.state.meta.logo?.id === id) return; for (const e of Object.values(this.state.entities)) if (e.files.some((f) => f.id === id)) return; const p = this.#blobPath(id); if (p && !this.store.siblingNames(id)) rmSync(p, { force: true }); } #stagePath(id) { if (!this.#blobPath(id)) throw new WeaveError('This workspace keeps file bytes in its database, so it cannot take a streamed upload', 'invalid'); return join(dirname(this.store.path), 'uploads', id); } #blobHead(path, bytes = 64) { const fd = openSync(path, 'r'); try { const head = Buffer.alloc(bytes); return head.subarray(0, readSync(fd, head, 0, bytes, 0)); } finally { closeSync(fd); } } filesOnDisk() { return !!this.store.path; } stageBlob() { const id = uuid(); const path = this.#stagePath(id); mkdirSync(dirname(path), { recursive: true }); return { id, path }; } async streamBlob(source, max) { const staged = this.stageBlob(); try { return { ...staged, size: await streamToFileDurable(source, staged.path, max) }; } catch (err) { rmSync(staged.path, { force: true }); throw err; } } dropStagedBlob(id) { rmSync(this.#stagePath(id), { force: true }); } #adoptBlob(id) { const blob = this.#blobPath(id); mkdirSync(dirname(blob), { recursive: true }); renameSync(this.#stagePath(id), blob); } #writeBlob(id, buf) { const p = this.#blobPath(id); if (p) { writeFileDurable(p, buf); return; } this.state.fileBlobs = this.state.fileBlobs ?? {}; this.state.fileBlobs[id] = buf.toString('base64'); } attachFile(entityId, { name, mime = 'application/octet-stream', bytes, staged = null, preview = null }) { this.#mayWriteRow(entityId); const e = this.getEntity(entityId); const buf = staged ? null : Buffer.isBuffer(bytes) ? bytes : Buffer.from(String(bytes), 'base64'); const size = staged ? staged.size : buf.length; if (size > FILE_MAX_BYTES) throw new WeaveError(`${name} is ${mbText(size)}; files can be up to ${mbText(FILE_MAX_BYTES)}`, 'too-large'); const small = preview?.bytes == null ? null : Buffer.isBuffer(preview.bytes) ? preview.bytes : Buffer.from(String(preview.bytes), 'base64'); const smallType = small && sniffImage(small); if (small && (!sniffImage(staged ? this.#blobHead(this.#stagePath(staged.id)) : buf) || !PREVIEW_TYPES.has(smallType))) { throw new WeaveError('A preview is a PNG, JPEG or WebP image, and only an image upload takes one', 'unsupported-type'); } if (small && small.length > PREVIEW_MAX_BYTES) throw new WeaveError(`A preview is at most ${PREVIEW_MAX_BYTES} bytes; this one is ${small.length}`, 'invalid'); const file = { id: staged ? staged.id : uuid(), name: String(name), size, mime, createdAt: nowISO() }; if (staged) this.#adoptBlob(file.id); else this.#writeBlob(file.id, buf); if (small) { file.preview = { id: uuid(), size: small.length, mime: smallType }; this.#writeBlob(file.preview.id, small); } e.files.push(file); this.#logActivity(e, 'file-attached', { name: file.name }); this.#recordUndo('file-attach', e, { fileId: file.id }); this.save(); return file; } attachToField(entityId, fieldRef, fileInput) { this.#mayWriteRow(entityId); const e = this.getEntity(entityId); const db = this.state.tables[e.dbId]; const field = this.getField(db.id, fieldRef); if (!isFileField(field)) throw new WeaveError(`Field '${field.name}' is not an attachments field`, 'invalid'); if (field.type === 'avatar') { if (fileInput?.staged) throw new WeaveError('An avatar is attached with its bytes, not through a streamed upload', 'invalid'); const bytes = fileInput?.bytes; const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(String(bytes ?? ''), 'base64'); const mime = sniffImage(buf); if (!mime) throw new WeaveError('An avatar must be a PNG, JPEG, GIF or WebP image', 'unsupported-type'); fileInput = { ...fileInput, bytes: buf, mime }; } const file = this.attachFile(e.id, fileInput); const cur = Array.isArray(e.values[field.id]) ? e.values[field.id] : []; this.updateEntity(e.id, { [field.name]: field.type === 'avatar' || field.config.multiple === false ? [file.id] : [...cur, file.id] }); return file; } readFileMeta(fileId) { for (const e of Object.values(this.state.entities)) { const meta = e.files.find((f) => f.id === fileId); if (meta && !this.#rowHidden(e)) return meta; } throw new WeaveError(`File '${fileId}' not found`, 'not-found'); } readFile(fileId, { preview = false } = {}) { const meta = this.readFileMeta(fileId); const small = preview && meta.preview && this.#readBlob(meta.preview.id); if (small) return { meta: { ...meta.preview, name: meta.name }, bytes: small }; const bytes = this.#readBlob(fileId); if (bytes == null) throw new WeaveError('File blob missing', 'not-found'); return { meta, bytes }; } #fileMeta(fileId) { for (const e of Object.values(this.state.entities)) { const meta = e.files.find((f) => f.id === fileId); if (meta) return meta; } throw new WeaveError(`File '${fileId}' not found`, 'not-found'); } readFileStorage(fileId) { this.#fileMeta(fileId); const row = this.store.getFileStorage(fileId); return { file: fileId, items: row ? JSON.parse(row.json) : {}, bytes: row ? Buffer.byteLength(row.json) : 0, updatedAt: row?.at ?? null }; } writeFileStorage(fileId, items) { const holder = Object.values(this.state.entities).find((x) => x.files?.some((f) => f.id === fileId)); if (holder) this.#mayWriteRow(holder.id); const meta = this.#fileMeta(fileId); if (!isHtmlFile(meta)) throw new WeaveError(`'${meta.name}' is not an HTML file; only an HTML file keeps page storage`, 'invalid'); if (!items || typeof items !== 'object' || Array.isArray(items)) throw new WeaveError('Page storage is an object of string keys to string values, under `items`', 'invalid'); for (const [k, v] of Object.entries(items)) { if (typeof v !== 'string') throw new WeaveError(`Page storage values are strings; '${k}' holds a ${v === null ? 'null' : typeof v}`, 'invalid'); } const json = JSON.stringify(items); const bytes = Buffer.byteLength(json); if (bytes > FILE_STORAGE_CAP) { throw new WeaveError(`'${meta.name}' tried to keep ${(bytes / 1048576).toFixed(1)} MB of page storage; a file keeps at most ${FILE_STORAGE_CAP / 1048576} MB, so this change was not saved`, 'too-large'); } const at = nowISO(); this.store.setFileStorage(fileId, { at, json }); return { file: fileId, bytes, updatedAt: at }; } setWorkspaceLogo({ name, bytes }) { const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(String(bytes), 'base64'); const mime = logoType(buf); if (!mime) throw new WeaveError('A logo must be a PNG, JPEG, GIF, WebP or SVG image', 'unsupported-type'); if (this.state.meta.logo) this.deleteWorkspaceLogo(); const logo = { id: uuid(), name: String(name), size: buf.length, mime, createdAt: nowISO() }; this.#writeBlob(logo.id, buf); this.state.meta.logo = logo; this.save(); return logo; } getWorkspaceLogo() { const logo = this.state.meta.logo; if (!logo) throw new WeaveError('Workspace has no logo', 'not-found'); const bytes = this.#readBlob(logo.id); if (bytes == null) throw new WeaveError('Logo blob missing', 'not-found'); return { meta: logo, bytes }; } deleteWorkspaceLogo() { const logo = this.state.meta.logo; if (!logo) return; delete this.state.meta.logo; this.#dropBlob(logo.id); this.save(); } deleteFile(entityId, fileId) { this.#mayWriteRow(entityId); const e = this.getEntity(entityId); const had = e.files.find((f) => f.id === fileId); e.files = e.files.filter((f) => f.id !== fileId); const db = this.state.tables[e.dbId]; for (const f of Object.values(db.fields)) { if (isFileField(f) && Array.isArray(e.values[f.id]) && e.values[f.id].includes(fileId)) { e.values[f.id] = e.values[f.id].filter((id) => id !== fileId); } } this.#dropBlob(fileId); if (had?.preview) this.#dropBlob(had.preview.id); if (had) this.store.deleteFileStorage(fileId); this.#mark(e); this.save(); return { id: fileId, entity: e.id, deleted: !!had }; } importCSV(dbRef, csvText) { this.#mayWriteTable(dbRef); const db = this.getTable(dbRef); const rows = parseCSV(csvText).map((row) => row.map(unquoteCSVCell)); if (!rows.length) return { created: 0 }; const header = rows[0]; const skip = new Set(['Public Id', 'Created At', 'Updated At', 'publicId', 'createdAt', 'updatedAt']); const writable = header.map((name) => { if (skip.has(name)) return null; const f = this.findField(db, name); if (!f || ['lookup', 'rollup', 'formula'].includes(f.type)) return null; return f; }); return this.#batched(() => this.#importRows(db, rows, writable)); } #importRows(db, rows, writable) { let created = 0; const errors = []; for (const row of rows.slice(1)) { if (row.every((c) => c === '')) continue; const values = {}; writable.forEach((f, i) => { if (!f || row[i] === '' || row[i] == null) return; let v = row[i]; if (f.type === 'multiselect' || (f.type === 'relation' && f.config.many)) { v = v.split(';').map((s) => s.trim()).filter(Boolean); } else if (f.type === 'checkbox') { v = ['true', '1', 'yes', '✓', 'x'].includes(v.toLowerCase()); } else if (f.type === 'toggle') { const s = v.trim().toLowerCase(); v = s === f.config.on.toLowerCase() ? true : s === f.config.off.toLowerCase() ? false : ['true', '1', 'yes', '✓', 'x'].includes(s); } values[f.name] = v; }); try { this.createEntity(db, { values }); created++; } catch (err) { errors.push({ row: created + errors.length + 2, error: err.message }); } } this.#audit('csv-imported', { table: db.name, created }); return { created, errors }; } spaceVersions() { const { counts } = this.#schemaHistory(); return { etag: ontologyEtag(counts), spaces: this.listSpaces().map((s) => ({ id: s.id, name: s.name, version: counts.get(s.id) ?? 0 })) }; } ontology({ depth = 'outline', concept = null, since = null } = {}) { if ((depth ?? 'outline') !== 'outline') throw new WeaveError(`depth is "outline"; read one table in full with concept (got ${JSON.stringify(depth)})`, 'invalid'); const { counts, seen } = this.#schemaHistory(); if (concept != null && concept !== '') return this.#ontologyConcept(this.getTable(concept), counts); const etag = ontologyEtag(counts); const spaces = this.listSpaces(); const label = this.#ontologyLabel(); const block = (sp) => (sp.system ? [`${sp.name} (system registry, read one with concept): ${this.listTables(sp.id).map((db) => db.name).join(', ')}`] : [`${sp.name} v${counts.get(sp.id) ?? 0}:`, ...this.listTables(sp.id).map((db) => ` ${db.name}: ${this.#ontologyFields(db, label).join('; ') || '-'}`)]); const tables = spaces.reduce((n, sp) => n + this.listTables(sp.id).length, 0); const outline = [`weave ontology: ${tables} tables in ${spaces.length} spaces, one line each. ${ONTOLOGY_LEGEND}`, ...spaces.flatMap(block), `etag ${etag}`].join('\n'); if (since == null || since === '') return outline; if (String(since) === etag) return `unchanged; etag ${etag}`; const then = seen.get(String(since)); if (!then) return `etag ${since} is not one this workspace issued; the whole outline follows.\n${outline}`; const moved = [...counts.keys()].filter((id) => counts.get(id) > (then.get(id) ?? 0)); const live = spaces.filter((sp) => moved.includes(sp.id)); const gone = moved.filter((id) => !live.some((sp) => sp.id === id)).map((id) => this.state.spaces[id]?.name ?? id); return [ `since ${since}: ${moved.length} space${moved.length === 1 ? '' : 's'} changed. ${ONTOLOGY_LEGEND}`, ...live.flatMap(block), ...(gone.length ? [`gone: ${gone.join(', ')}`] : []), `etag ${etag}`, ].join('\n'); } #schemaHistory() { const spacesNamed = (n) => Object.values(this.state.spaces).filter((s) => s.name === n).map((s) => s.id); const tablesNamed = (ref) => { if (!ref) return []; const [sp, t] = String(ref).includes('/') ? String(ref).split('/') : [null, String(ref)]; return Object.values(this.state.tables).filter((x) => x.name === t && (!sp || this.state.spaces[x.spaceId]?.name === sp)).map((x) => x.spaceId); }; const resolve = (action, d) => { if (Array.isArray(d.spaces)) return d.spaces; if (!UNTAGGED_SCHEMA_ACTIONS.has(action)) return []; if (this.state.tables[d.tableId]) return [this.state.tables[d.tableId].spaceId]; if (action.startsWith('space-')) return spacesNamed(d.name); if (action === 'table-created' || action === 'table-duplicated') return spacesNamed(d.space); if (action === 'table-moved') return [...spacesNamed(d.from), ...spacesNamed(d.to)]; if (action.startsWith('table-')) return tablesNamed(d.name); return [...tablesNamed(d.table), ...tablesNamed(d.target), ...(d.targets ?? []).flatMap(tablesNamed)]; }; const counts = new Map(); const seen = new Map([[ontologyEtag(counts), new Map()]]); const hidden = this.#hiddenSpaces(); for (const r of this.store.listAudit({ limit: -1, actions: SCHEMA_AUDIT_ACTIONS }).reverse()) { const touched = new Set(resolve(r.action, r.detail ?? {}).filter((id) => !hidden?.has(id))); if (!touched.size) continue; for (const id of touched) counts.set(id, (counts.get(id) ?? 0) + 1); seen.set(ontologyEtag(counts), new Map(counts)); } return { counts, seen }; } #ontologyLabel() { const names = new Map(); for (const t of this.listTables()) names.set(t.name.toLowerCase(), (names.get(t.name.toLowerCase()) ?? 0) + 1); return (t) => (!t ? '?' : this.#tableHidden(t) ? LOCKED_NAME : names.get(t.name.toLowerCase()) > 1 && this.state.tables[t.id] === t ? this.qualifiedName(t) : t.name); } #ontologyFields(db, label) { const cap = (names) => (names.length > 8 ? [...names.slice(0, 8), `+${names.length - 8}`] : names).join('|'); const parts = []; for (const fid of db.fieldOrder) { const f = db.fields[fid]; if (!f || f.type === 'view' || f.id === db.nameFieldId || f.id === db.descriptionFieldId) continue; const c = f.config ?? {}; const computed = this.#ontologyComputed(db, f, label); if (f.type === 'relation') { const targets = (c.targetDbs ?? [c.targetDb]).map((id) => label(this.state.tables[id])).join('|'); const inverse = c.targetDb && !this.#tableHidden(this.state.tables[c.targetDb]) ? this.state.tables[c.targetDb]?.fields[c.inverseFieldId]?.name : null; parts.push(`${f.name} -> ${targets}${c.many ? '*' : ''}${inverse ? ` (${inverse})` : ''}`); } else if (computed != null) { parts.push(`${f.name} = ${computed.length > 48 ? `${computed.slice(0, 47)}…` : computed}`); } else { const type = ONTOLOGY_TYPES[f.type] ?? f.type; const names = f.type === 'workflow' ? c.states.map((x) => x.name) : f.type === 'select' || f.type === 'multiselect' ? c.options.map((o) => o.name) : null; parts.push(`${f.name} ${type}${names ? `[${cap(names)}]` : ''}`); } } return parts; } #ontologyComputed(db, f, label) { const c = f.config ?? {}; if (f.type === 'formula') return String(c.expression ?? '').replace(/\s+/g, ' ').trim(); if (f.type !== 'lookup' && f.type !== 'rollup') return null; let base; let tdb; if (f.type === 'rollup' && c.via) { tdb = this.#tableAnywhere(c.via)?.table; base = label(tdb); } else { const rel = db.fields[c.relationField]; base = rel?.name ?? '?'; tdb = rel ? this.state.tables[rel.config.targetDb] : null; } const target = c.targetField ? (this.#tableHidden(tdb) ? '?' : tdb?.fields[c.targetField]?.name ?? '?') : null; const path = target ? `${base}.${target}` : base; return f.type === 'lookup' ? path : `${c.aggregate}(${path})`; } #ontologyConcept(db, counts) { const rows = this.#liveRowCounts().get(db.id) ?? 0; const sp = this.state.spaces[db.spaceId]; const qualified = (t) => (!t ? '?' : this.#tableHidden(t) ? LOCKED_NAME : this.qualifiedName(t)); const lines = [`${this.qualifiedName(db)}: ${rows} record${rows === 1 ? '' : 's'}, space ${sp?.name ?? '?'} v${counts.get(db.spaceId) ?? 0}.${db.description ? ` ${db.description}` : ''}`, 'Fields:']; for (const fid of db.fieldOrder) { const f = db.fields[fid]; if (!f || f.type === 'view') continue; const c = f.config ?? {}; const parts = [f.type]; if (f.id === db.nameFieldId) parts.push('the row name'); if (f.id === db.descriptionFieldId) parts.push('the row description'); if (f.type === 'select' || f.type === 'multiselect') parts.push(`options ${c.options.map((o) => o.name).join(', ')}`); if (f.type === 'workflow') parts.push(`states ${c.states.map((x) => `${x.name} (${x.category}${x.default ? ', default' : ''})`).join(', ')}`); if (f.type === 'relation') { const members = (c.targetDbs ?? [c.targetDb]).map((id) => this.state.tables[id]).filter(Boolean); parts[0] = `relation -> ${members.map(qualified).join(' | ')}, ${c.many ? 'many' : 'one'}`; const inverse = c.targetDb && !this.#tableHidden(this.state.tables[c.targetDb]) ? this.state.tables[c.targetDb]?.fields[c.inverseFieldId]?.name : null; parts.push(inverse ? `inverse ${inverse}` : 'one-way'); } const computed = this.#ontologyComputed(db, f, qualified); if (computed != null) parts[0] = `${f.type} ${computed}`; if (f.type === 'number' || f.type === 'formula') for (const k of ['format', 'currency', 'unit']) if (c[k]) parts.push(`${k} ${c[k]}`); if (f.type === 'rating') parts.push(`max ${c.max}`); if (f.type === 'toggle') parts.push(`on ${c.on}, off ${c.off}`); if (f.type === 'document' && c.kind) parts.push(`kind ${c.kind}`); if (f.type === 'field') parts.push(`types ${(c.types ?? []).join(', ')}`); if (c.default !== undefined) { const named = (id) => c.options?.find((o) => o.id === id)?.name ?? id; parts.push(`default ${f.type === 'select' || f.type === 'multiselect' ? [].concat(c.default).map(named).join(', ') : JSON.stringify(c.default)}`); } lines.push(`- ${f.name}: ${parts.join('; ')}${c.description ? `. ${c.description}` : ''}`); } return lines.join('\n'); } describeSchema() { const counts = this.#liveRowCounts(); return this.listSpaces().map((sp) => ({ space: sp.name, spaceId: sp.id, url: this.permalink('space', sp.id), description: sp.description ?? '', ...(sp.system ? { system: sp.system } : {}), ...(sp.icon ? { icon: sp.icon } : {}), ...(sp.template ? { template: true } : {}), tables: this.listTables(sp.id).map((db) => ({ id: db.id, name: db.name, url: this.permalink('table', db.id), description: db.description ?? '', ...(db.system ? { system: db.system } : {}), ...(db.icon ? { icon: db.icon } : {}), ...(db.systemFields?.length ? { systemFields: [...db.systemFields] } : {}), ...structuredClone(this.#defaultViewConfig(db)), views: (db.tableViews ?? []).map((v) => this.#viewOut(db, v)), ...(typeof db.hideRollups === 'boolean' ? { hideRollups: db.hideRollups } : {}), ...(db.referencePanels?.length ? { referencePanels: [...db.referencePanels] } : {}), bodyBlocks: this.bodyBlocks(db), term: this.termOf(db), ...(this.termOf(db).set ? { noun: this.termOf(db).singular } : {}), qualified: this.qualifiedName(db), entityCount: counts.get(db.id) ?? 0, maxPublicId: db.publicIdCounter ?? 0, fields: db.fieldOrder.map((fid) => { const f = db.fields[fid]; const describing = { fieldName: (id) => db.fields[id]?.name }; const out = { id: f.id, name: f.name, type: f.type }; if (f.config.width) out.width = f.config.width; if (f.type !== 'view' && f.config.description) out.description = f.config.description; if (f.id === db.nameFieldId && f.config.term) out.term = { ...f.config.term }; if (f.type === 'relation' && f.config.targetDbs) { const members = f.config.targetDbs.map((tid) => this.state.tables[tid]).filter(Boolean); out.targetDbs = members.map((t) => (this.#tableHidden(t) ? LOCKED_NAME : this.qualifiedName(t))); out.targetDbIds = members.map((t) => t.id); out.many = f.config.many; } else if (f.type === 'relation') { const target = this.state.tables[f.config.targetDb]; const locked = this.#tableHidden(target); out.targetDb = locked ? LOCKED_NAME : this.qualifiedName(target); out.targetDbId = target.id; out.many = f.config.many; out.inverseFieldId = f.config.inverseFieldId; out.inverseField = locked ? null : target.fields[f.config.inverseFieldId]?.name ?? null; if (locked) out.locked = true; if (f.config.subtable) out.subtable = structuredClone(f.config.subtable); } if (f.type === 'rollup' && f.config.via) { const found = this.#tableAnywhere(f.config.via)?.table; const tdb = this.#tableHidden(found) ? null : found; if (tdb) { out.viaTable = this.qualifiedName(tdb); out.viaTableId = tdb.id; } if (f.config.targetField) out.targetField = tdb?.fields[f.config.targetField]?.name; if (f.config.where) out.where = f.config.where; out.aggregate = f.config.aggregate; } else if (f.type === 'lookup' || f.type === 'rollup') { const rel = db.fields[f.config.relationField]; out.via = rel?.name; if (f.config.targetField) { const tdb = this.state.tables[rel.config.targetDb]; out.targetField = this.#tableHidden(tdb) ? null : tdb.fields[f.config.targetField]?.name; } if (f.type === 'rollup') out.aggregate = f.config.aggregate; } FIELD_TYPE_CONFIG[f.type]?.toDescriptor?.(f.config, out, describing); if (f.type === 'rollup') { const nd = this.#numberDisplay(db, f); if (nd) { out.display = nd.display; if (typeof nd.scale === 'number') out.scale = nd.scale; out.color = nd.color; } } if (f.type === 'lookup' || f.type === 'rollup') { const rt = this.#ratingOf(db, f); if (rt) out.rating = rt; } if (f.id === db.descriptionFieldId) out.role = 'description'; if (f.id === db.nameFieldId) out.role = 'name'; if (f.config?.default !== undefined) out.default = f.config.default; return out; }), })), })); } exportJSON({ blobs: withBlobs = true } = {}) { if (this.#hiddenSpaces()) throw new WeaveError('A full export carries every space; it needs access to all of them', 'forbidden'); this.#audit('workspace-exported', { blobs: withBlobs }); const out = JSON.parse(JSON.stringify(this.state)); delete out.fileBlobs; for (const a of Object.values(out.meta.accounts ?? {})) { delete a.tokenHash; for (const i of a.identities ?? []) delete i.verifiedEmail; } const usersT = Object.values(out.tables).find((t) => t.system === 'users'); const emailF = usersT && Object.values(usersT.fields).find((f) => f.name === 'Email'); if (emailF) for (const e of Object.values(out.entities)) if (e.dbId === usersT.id) delete e.values[emailF.id]; for (const v of [...Object.values(out.meta.views ?? {}), ...Object.values(out.meta.trashedViews ?? {})]) delete v.shareToken; Shares.redactTokens(out); delete out.meta.formReceipts; delete out.meta.sessions; delete out.meta.invites; delete out.meta.identityInvites; if (!withBlobs) return out; const blobs = {}; const carry = (id) => { const bytes = this.#readBlob(id); if (bytes) blobs[id] = bytes.toString('base64'); }; for (const e of Object.values(this.state.entities)) for (const f of e.files ?? []) fileBlobIds(f).forEach(carry); if (this.state.meta.logo) carry(this.state.meta.logo.id); if (Object.keys(blobs).length) out.fileBlobs = blobs; return out; } #landBlobs() { const blobs = this.state.fileBlobs; if (!blobs || !this.store.path) return false; for (const [id, b64] of Object.entries(blobs)) this.#writeBlob(id, Buffer.from(b64, 'base64')); delete this.state.fileBlobs; return true; } #keepSecrets(prior) { const meta = this.state.meta; for (const a of Object.values(meta.accounts ?? {})) { const was = prior.accounts?.[a.id]; if (was?.tokenHash && a.tokenHash === undefined) a.tokenHash = was.tokenHash; for (const i of a.identities ?? []) { const mail = was?.identities?.find((x) => x.issuer === i.issuer && x.subject === i.subject)?.verifiedEmail; if (mail && i.verifiedEmail === undefined) i.verifiedEmail = mail; } } Shares.keepTokens(meta, prior); for (const kind of ['sessions', 'identityInvites']) { if (meta[kind] !== undefined || !prior[kind]) continue; const kept = Object.entries(prior[kind]).filter(([, s]) => (kind === 'identityInvites' && !s.accountId) || meta.accounts?.[s.accountId]); if (kept.length) meta[kind] = Object.fromEntries(kept); } } importJSON(state) { if (!state || typeof state !== 'object') throw new WeaveError('Unsupported workspace format', 'invalid'); refuseBeforeFloor(state); const ids = [...Object.keys(state.fileBlobs ?? {}), state.meta?.logo?.id]; for (const e of Object.values(state.entities ?? {})) for (const f of e?.files ?? []) ids.push(...fileBlobIds(f ?? {})); for (const id of ids) { if (id !== undefined && !BLOB_ID.test(String(id))) throw new WeaveError(`Invalid file id '${id}' in the import`, 'invalid'); } const prior = this.state.meta ?? {}; const tables = { ...state.tables }; const maps = [state.spaces, tables, state.entities, state.automations, state.meta?.views, state.meta?.accounts, ...Object.values(tables).map((t) => t?.fields)]; for (const m of maps) for (const k of Object.keys(m ?? {})) refuseReserved('row id', k); this.state = JSON.parse(JSON.stringify(state)); this.#migrate(); this.#keepSecrets(prior); if (this.state.meta.registry !== 'hub') this.#ensureMetaTables(); else if (this.registryHost) { this.#syncAll(); this.#migrateAutomations(); } this.#landBlobs(); this.#dirtyAll = true; this.save(); this.#audit('workspace-imported', { entities: Object.keys(this.state.entities).length }); return this.#fileLedger(); } #fileLedger() { let files = 0; const missing = []; for (const e of Object.values(this.state.entities)) { for (const f of e.files ?? []) { files++; if (!this.#hasBlob(f.id)) missing.push({ entity: e.id, file: f.id, name: f.name }); } } if (!missing.length) return { files, missing }; const warning = `${missing.length} of ${files} file${files === 1 ? '' : 's'} arrived without ${missing.length === 1 ? 'its' : 'their'} bytes: ` + 'the dump names them and carries none. Export with blobs (weave export, GET /api/export, or weave_export_json with blobs: true) to bring them.'; return { files, missing, warning }; } exportCSV(dbRef) { const db = this.getTable(dbRef); const fieldNames = db.fieldOrder.map((fid) => db.fields[fid]).filter((f) => f.type !== 'view').map((f) => f.name); const header = ['Public Id', ...fieldNames, 'Created At', 'Updated At']; const esc = (v) => { if (v == null) return ''; const raw = Array.isArray(v) ? v.map((x) => (x && typeof x === 'object' ? x.name : x)).join('; ') : typeof v === 'object' ? (v.name ?? JSON.stringify(v)) : String(v); const s = typeof v !== 'number' && CSV_FORMULA_START.test(raw) ? `'${raw}` : raw; return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; }; const lines = [header.map(esc).join(',')]; for (const e of this.listEntities(db.id)) { const read = this.readEntity(e.id); lines.push([e.publicId, ...fieldNames.map((n) => read.fields[n]), e.createdAt, e.updatedAt].map(esc).join(',')); } return lines.join('\n') + '\n'; } }