src/engine.js
Rama main · réplica de lectura del repositorio original.
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:"<word>"} (CLI: weave vocabulary icons <word>), 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]*?-->\s*)*(<!doctype svg[^>]*>\s*)?<svg[\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 = "<script>(() => { if (parent === window) return; let sent = -1, gap = -1; const send = () => { const h = Math.ceil(Math.max(document.documentElement.getBoundingClientRect().height, document.body ? document.body.scrollHeight : 0)); if (h === sent) return; const over = h - innerHeight; if (over > 0 && sent === innerHeight && over === gap) return; gap = over; sent = h; parent.postMessage({ weave: 'frame-height', height: h }, '*'); }; new ResizeObserver(send).observe(document.documentElement); addEventListener('load', send); })();</script>";
export const FRAME_LINK_BASE = "<base target=\"_blank\"><script>addEventListener('click', (e) => { const a = e.target.closest?.('a[href], area[href]'); if (!a || a.hasAttribute('target') || typeof a.href !== 'string') return; if (a.protocol === 'javascript:' || a.href.split('#')[0] === location.href.split('#')[0]) a.target = '_self'; }, true);</script>";
export const frameLinkBase = (bytes) => (/<base[\s/>]/i.test(bytes.toString('latin1')) ? '' : FRAME_LINK_BASE);
const scriptJson = (value) => JSON.stringify(value).replace(/</g, '\\u003c').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
export const frameStorageScript = (items = {}) => `<script>(() => {
const live = parent !== window;
const area = (seed, kept) => {
const map = new Map(Object.entries(seed));
let queued = false;
const send = () => { queued = false; parent.postMessage({ weave: 'frame-storage', items: Object.fromEntries(map) }, '*'); };
const changed = () => { if (kept && live && !queued) { queued = true; queueMicrotask(send); } };
const api = {
get length() { return map.size; },
key(i) { return [...map.keys()][i] ?? null; },
getItem(k) { k = String(k); return map.has(k) ? map.get(k) : null; },
setItem(k, v) { map.set(String(k), String(v)); changed(); },
removeItem(k) { if (map.delete(String(k))) changed(); },
clear() { if (map.size) { map.clear(); changed(); } },
};
return new Proxy(api, {
get: (t, p) => (typeof p === 'string' && !(p in t) ? map.get(p) : Reflect.get(t, p)),
set: (t, p, v) => { if (!(p in t)) t.setItem(p, v); return true; },
has: (t, p) => p in t || map.has(p),
deleteProperty: (t, p) => { t.removeItem(p); return true; },
ownKeys: () => [...map.keys()],
getOwnPropertyDescriptor: (t, p) => (map.has(p) ? { value: map.get(p), writable: true, enumerable: true, configurable: true } : undefined),
});
};
for (const [name, store] of [['localStorage', area(${scriptJson(items)}, true)], ['sessionStorage', area({}, false)]]) {
try { Object.defineProperty(window, name, { value: store, configurable: true, enumerable: true }); } catch {}
}
})();</script>`;
const LEADING_DOCTYPE = /^(?:\xEF\xBB\xBF)?\s*<!doctype[^>]*>/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/<id> — 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/<id> 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)}/<view>'`, '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: <field>} or {field, after: <field>}', '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/<id>/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 setIntegra