// šŸ¤– The app-schema engine: a small interpreter for the declarative app schema // (docs/app-schema.md). A schema describes an app's state, how events fold into // it, and how the folded state presents as a searchable, sectioned list with // actions — in plain JSON that a human, an LLM, and BOTH runtimes (this JS // engine and the Swift twin in ios/LChatCore/Sources/LChatCore/SchemaEngine.swift) // can read. The two implementations are drift-gated by the shared event vectors // (test/event-vectors/*) and by test/schema-engine.test.mjs — change behaviour // here and the Swift twin must change in the same PR. // // Deliberately dependency-free and DOM-free so it runs in Node tests unchanged. // ---- values: `$refs`, literals, and tiny numeric ops ---------------------- // A schema value is JSON. Strings starting with "$" are references into the // evaluation scope ("$item.done", "$event.text", "$now"); "$$" escapes a // literal leading dollar. Objects of the shape {"add":[a,b]} / {"sub":[a,b]} // are the only computed values. Everything else is itself. function lookupPath(scope, path) { let cur = scope; for (const part of path.split(".")) { if (cur == null || typeof cur !== "object") return null; cur = cur[part]; } return cur === undefined ? null : cur; } export function evalValue(v, scope) { if (typeof v === "string") { if (v.startsWith("$$")) return v.slice(1); if (v.startsWith("$")) return lookupPath(scope, v.slice(1)); return v; } if (Array.isArray(v)) return v.map((x) => evalValue(x, scope)); if (v && typeof v === "object") { if (Array.isArray(v.add) && v.add.length === 2) return num(evalValue(v.add[0], scope)) + num(evalValue(v.add[1], scope)); if (Array.isArray(v.sub) && v.sub.length === 2) return num(evalValue(v.sub[0], scope)) - num(evalValue(v.sub[1], scope)); const out = {}; for (const [k, x] of Object.entries(v)) out[k] = evalValue(x, scope); return out; } return v; } function num(v) { const n = typeof v === "number" ? v : typeof v === "string" ? parseFloat(v) : v === true ? 1 : 0; return isNaN(n) ? 0 : n; } function truthy(v) { if (Array.isArray(v)) return v.length > 0; return !!v; } // ---- conditions ----------------------------------------------------------- // {"truthy": v} {"not": c} {"all": [c…]} {"any": [c…]} {"eq": [a,b]} // {"empty": v} {"gt"|"gte"|"lt"|"lte": [a,b]} export function evalCond(c, scope) { if (c == null) return true; if ("truthy" in c) return truthy(evalValue(c.truthy, scope)); if ("not" in c) return !evalCond(c.not, scope); if ("all" in c) return c.all.every((x) => evalCond(x, scope)); if ("any" in c) return c.any.some((x) => evalCond(x, scope)); if ("eq" in c) return evalValue(c.eq[0], scope) === evalValue(c.eq[1], scope); if ("empty" in c) return !truthy(evalValue(c.empty, scope)); if ("gt" in c) return num(evalValue(c.gt[0], scope)) > num(evalValue(c.gt[1], scope)); if ("gte" in c) return num(evalValue(c.gte[0], scope)) >= num(evalValue(c.gte[1], scope)); if ("lt" in c) return num(evalValue(c.lt[0], scope)) < num(evalValue(c.lt[1], scope)); if ("lte" in c) return num(evalValue(c.lte[0], scope)) <= num(evalValue(c.lte[1], scope)); return false; } // ---- templates ------------------------------------------------------------ // Display strings interpolate "{path}" or "{path|filter}" against the scope; // "{{" escapes a literal brace. Filters: count (array length), reltime // ("in 2h" / "5m ago" / "now" — needs $now in scope), abstime (locale // "Wed, Jul 9, 14:30"), number (String(n)). export function relTime(when, now) { let diff = when - now; const past = diff < 0; diff = Math.abs(diff); const mins = Math.round(diff / 60000); let s; if (mins < 1) return "now"; if (mins < 60) s = mins + "m"; else if (mins < 1440) s = Math.round(mins / 60) + "h"; else s = Math.round(mins / 1440) + "d"; return past ? s + " ago" : "in " + s; } export function absTime(when) { try { return new Date(when).toLocaleString([], { weekday: "short", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); } catch { return ""; } } function applyFilter(v, filter, scope) { switch (filter) { case "count": return Array.isArray(v) ? String(v.length) : "0"; case "reltime": return relTime(num(v), num(scope.now)); case "abstime": return absTime(num(v)); case "number": return String(num(v)); default: return str(v); } } function str(v) { if (v == null) return ""; if (typeof v === "string") return v; if (typeof v === "number") return String(v); if (typeof v === "boolean") return v ? "true" : "false"; return ""; } export function evalTemplate(tpl, scope) { let out = ""; let i = 0; while (i < tpl.length) { const ch = tpl[i]; if (ch === "{") { if (tpl[i + 1] === "{") { out += "{"; i += 2; continue; } const end = tpl.indexOf("}", i); if (end < 0) { out += tpl.slice(i); break; } const expr = tpl.slice(i + 1, end); const bar = expr.indexOf("|"); const path = (bar >= 0 ? expr.slice(0, bar) : expr).trim(); const filter = bar >= 0 ? expr.slice(bar + 1).trim() : ""; const v = lookupPath(scope, path); out += filter ? applyFilter(v, filter, scope) : str(v); i = end + 1; } else { out += ch; i++; } } return out; } // ---- state & fold --------------------------------------------------------- // The declared zero value for a field type ("string" | "bool" | "number" | "time"). function zeroFor(type) { return type === "string" ? "" : type === "bool" ? false : 0; } // Coerce a value to a declared field type — missing/null becomes the zero // value, so "$event.ts" on an event with no ts folds to 0, exactly like the // hand-written reducers' `e.ts || 0`. function coerce(v, type) { if (v == null) return zeroFor(type); switch (type) { case "string": return typeof v === "string" ? v : str(v); case "bool": return truthy(v); case "number": case "time": return num(v); default: return v; } } export function initialState(schema) { const s = {}; for (const key of Object.keys(schema.state || {})) s[key] = []; return s; } // foldSchemaEvent applies one event to state via the schema's declarative fold // ops. Unknown types and failed `require` guards return state unchanged. Pure: // always returns fresh arrays, never mutates. `add` is idempotent by the // collection key (Invariant #1 in apps/README.md). export function foldSchemaEvent(schema, state, event) { const spec = (schema.events || {})[event && event.type]; if (!spec) return state; const scope = { event, state }; for (const r of spec.require || []) { if (!truthy(evalValue(r, scope))) return state; } let next = state; for (const op of spec.fold || []) next = applyFoldOp(schema, next, op, event); return next; } function collectionDecl(schema, name) { return (schema.state || {})[name] || { key: "id", fields: {} }; } function applyFoldOp(schema, state, op, event) { const scope = { event, state }; if (op.add) { const decl = collectionDecl(schema, op.add.to); const list = state[op.add.to] || []; const item = {}; for (const [field, v] of Object.entries(op.add.item || {})) { item[field] = coerce(evalValue(v, scope), decl.fields[field]); } const keyField = decl.key || "id"; if (list.some((i) => i[keyField] === item[keyField])) return { ...state, [op.add.to]: list.slice() }; return { ...state, [op.add.to]: [...list, item] }; } if (op.update) { const decl = collectionDecl(schema, op.update.in); const keyField = decl.key || "id"; const id = evalValue(op.update.id, scope); const list = (state[op.update.in] || []).map((i) => { if (i[keyField] !== id) return i; const patch = {}; for (const [field, v] of Object.entries(op.update.set || {})) { patch[field] = coerce(evalValue(v, scope), decl.fields[field]); } return { ...i, ...patch }; }); return { ...state, [op.update.in]: list }; } if (op.remove) { const decl = collectionDecl(schema, op.remove.from); const keyField = decl.key || "id"; const id = evalValue(op.remove.id, scope); return { ...state, [op.remove.from]: (state[op.remove.from] || []).filter((i) => i[keyField] !== id) }; } if (op.move) { const decl = collectionDecl(schema, op.move.in); const keyField = decl.key || "id"; const id = evalValue(op.move.id, scope); const list = state[op.move.in] || []; const from = list.findIndex((i) => i[keyField] === id); if (from < 0) return { ...state, [op.move.in]: list.slice() }; // `after` names the item this one now follows; ""/missing = the list's // start. An unknown `after` id (it was removed concurrently) is a no-op — // both runtimes must agree, so the rule is pinned here. const after = str(evalValue(op.move.after, scope)); const next = list.slice(); const [moved] = next.splice(from, 1); let to = 0; if (after) { const anchor = next.findIndex((i) => i[keyField] === after); if (anchor < 0) return { ...state, [op.move.in]: list.slice() }; to = anchor + 1; } next.splice(to, 0, moved); return { ...state, [op.move.in]: next }; } if (op.sort) { const by = op.sort.by; const dir = op.sort.dir === "desc" ? -1 : 1; // Decorate with the index so the sort is stable on every runtime (Swift's // sort isn't guaranteed stable; JS's is — the tiebreak keeps them twins). const list = (state[op.sort.in] || []).map((item, idx) => ({ item, idx })); list.sort((a, b) => { const av = num(a.item[by]); const bv = num(b.item[by]); if (av !== bv) return (av - bv) * dir; return a.idx - b.idx; }); return { ...state, [op.sort.in]: list.map((e) => e.item) }; } return state; } // ---- partition: the grace-period buckets ---------------------------------- // Splits the primary collection into { active, grace, archived } from the // clock — the reducer only records the completion time; the linger-then-tuck // behaviour is DERIVED here on every render (never a timer mutating state). // A flagged item with no timestamp (legacy data) reads as long-archived. export function partitionState(schema, state, now) { const p = schema.partition; const items = (state || {})[schema.collection] || []; const active = []; const grace = []; const archived = []; if (!p) return { active: items.slice(), grace, archived }; for (const item of items) { const at = item[p.flag] ? item[p.at] || 1 : 0; if (!at) active.push(item); else if (now - at < p.graceMs) grace.push(item); else archived.push(item); } return { active, grace, archived }; } // ---- the list render model ------------------------------------------------- // listModel resolves the main view against state + the clock + transient UI // state (collapsed sections, a search query) into a plain description a // renderer draws directly: summary line, sections with ordered rows (each row // knowing whether it's in its grace window), and the empty-state line. Both // renderers (schema-render.js on the web, SchemaAppView in SwiftUI) consume // this same shape. export function listModel(schema, state, now, ui = {}) { const view = schema.views && schema.views.main; if (!view) return null; // A never-used app thread has no folded state at all (the host only seeds on // the first event), so `state` can arrive undefined — treat it as empty. state = state || initialState(schema); const items = state[schema.collection] || []; const buckets = partitionState(schema, state, now); const scope = { state, items, buckets, now }; let summary = ""; for (const c of view.summary || []) { if (evalCond(c.if, scope)) { summary = evalTemplate(c.text, scope); break; } } const query = (ui.query || "").trim().toLowerCase(); const searchFields = (view.search && view.search.fields) || []; const matches = (item) => { if (!query) return true; return searchFields.some((f) => str(item[f]).toLowerCase().includes(query)); }; const graceIds = new Set(buckets.grace.map((i) => i[collectionDecl(schema, schema.collection).key || "id"])); const indexOf = new Map(items.map((i, idx) => [i, idx])); const sections = []; for (const s of view.sections || []) { const names = Array.isArray(s.source) ? s.source : [s.source]; let rows = names.flatMap((n) => buckets[n] || []).filter(matches); if (s.order && s.order.by) { const by = s.order.by; const dir = s.order.dir === "desc" ? -1 : 1; const decorated = rows.map((item) => ({ item, idx: indexOf.get(item) || 0 })); decorated.sort((a, b) => { const av = by === "_index" ? a.idx : num(a.item[by]); const bv = by === "_index" ? b.idx : num(b.item[by]); if (av !== bv) return (av - bv) * dir; return a.idx - b.idx; }); rows = decorated.map((d) => d.item); } const collapsed = s.collapsible ? (ui.collapsed && s.id in ui.collapsed ? !!ui.collapsed[s.id] : !!s.collapsed) : false; const keyField = collectionDecl(schema, schema.collection).key || "id"; sections.push({ id: s.id, title: s.title ? evalTemplate(s.title, { ...scope, count: rows.length }) : "", collapsible: !!s.collapsible, collapsed, // Manual reorder: the action a drag runs (with $item + $after bound by // the renderer). Suppressed while searching — a filtered list has no // meaningful drop position. descending tells the renderer how a visual // drop maps to the array anchor. reorder: (!query && s.reorder && s.reorder.action) || "", descending: !!(s.order && s.order.dir === "desc"), rows: rows.map((item) => ({ item, grace: graceIds.has(item[keyField]) })), }); } // The empty line shows when every non-collapsible section has no rows. let empty = ""; const visibleRows = sections.filter((s) => !s.collapsible).reduce((n, s) => n + s.rows.length, 0); if (!visibleRows) { for (const c of view.empty || []) { if (evalCond(c.if, scope)) { empty = evalTemplate(c.text, scope); break; } } } return { summary, sections, empty, buckets }; } // rowModel resolves one row's presentation (title/subtitle/note text + flags) // for an item — the per-row half of listModel, split out so a renderer can // re-resolve a single row on its refresh tick without rebuilding the list. export function rowModel(schema, item, now) { const view = schema.views && schema.views.main; const row = (view && view.row) || {}; const scope = { item, now }; const out = { title: "", strike: false, muted: false, subtitle: "", accent: false, note: "" }; if (row.title) { out.title = evalTemplate(row.title.text || "", scope); out.strike = row.title.strike ? evalCond(row.title.strike, scope) : false; out.muted = row.title.muted ? evalCond(row.title.muted, scope) : false; } for (const c of row.subtitle || []) { if (evalCond(c.if, scope)) { out.subtitle = evalTemplate(c.text, scope); out.accent = !!c.accent && evalCond(c.accent, scope); break; } } for (const c of row.note || []) { if (evalCond(c.if, scope)) { out.note = evalTemplate(c.text, scope); break; } } return out; } // detailModel resolves the detail ("show") view for one item: title + // sections (definition rows / text blocks / buttons), conditions applied. export function detailModel(schema, item, now) { const view = schema.views && schema.views.detail; if (!view) return null; const scope = { item, now }; const sections = []; for (const s of view.sections || []) { if (s.kind === "definitions") { const rows = (s.rows || []).filter((r) => evalCond(r.if, scope)).map((r) => ({ term: r.term, value: evalTemplate(r.value || "", scope) })); if (rows.length) sections.push({ kind: "definitions", rows }); } else if (s.kind === "text") { if (evalCond(s.if, scope)) { const body = evalTemplate(s.body || "", scope); if (body) sections.push({ kind: "text", body }); } } else if (s.kind === "buttons") { const items = (s.items || []).filter((b) => evalCond(b.if, scope)).map((b) => ({ label: b.label, action: b.action, role: b.role || "", dismiss: !!b.dismiss })); if (items.length) sections.push({ kind: "buttons", items }); } } return { title: evalTemplate(view.title || "", scope), sections }; } // ---- chat projection ------------------------------------------------------- // The schema's `chat` map projects an app event into the Chat view: a bot // bubble (string) or a /me-style action line ({ text, action: true }), or // null for silence — the same contract as an app's chatMessage hook. export function chatProjection(schema, event, opts) { const spec = (schema.chat || {})[event && event.type]; if (!spec) return null; const scope = { event }; if (!evalCond(spec.if, scope)) return null; const tpl = opts && opts.group && spec.groupText ? spec.groupText : spec.text; const text = evalTemplate(tpl, scope); return spec.action ? { text, action: true } : text; } // ---- actions --------------------------------------------------------------- // runAction executes a named action against an io surface the host provides: // io.emit(event) — encrypt + send an event into the thread // io.schedule(event, at) — server-schedule a future event; resolves sid|null // io.unschedule(sid) — cancel a pending schedule // io.newId() — a fresh unique entity id // io.parse(kind, text) — run a named parser; null = parse failure // io.hint(text) — surface inline feedback (a failed parse's nudge) // scope carries the trigger's bindings: { item } for a row action, { input } // for the composer (already trimmed). `$newId` is minted once per run, so a // schedule step and the emit that records its sid share the same id. export async function runAction(schema, name, scope, io) { const action = (schema.actions || {})[name]; if (!action) return false; let mintedId = null; const ctx = { ...scope, get newId() { if (mintedId === null) mintedId = io.newId(); return mintedId; }, sid: "", parsed: null, }; for (const r of action.require || []) { if (!truthy(evalValue(r, ctx))) return false; } for (const step of action.steps || []) { if (step.if && !evalCond(step.if, ctx)) continue; if (step.parse) { const text = evalValue(step.parse.text, ctx); const parsed = io.parse ? io.parse(step.parse.kind, str(text)) : null; if (!parsed) { if (step.parse.onFail && step.parse.onFail.hint && io.hint) io.hint(step.parse.onFail.hint); return false; } ctx.parsed = parsed; } else if (step.schedule) { const at = num(evalValue(step.schedule.at, ctx)); const sid = await io.schedule(evalValue(step.schedule.event, ctx), at); ctx.sid = sid || ""; } else if (step.unschedule) { await io.unschedule(str(evalValue(step.unschedule, ctx))); } else if (step.emit) { await io.emit(evalValue(step.emit, ctx)); } } return true; } // tickerRules evaluates the schema's per-item timed rules (e.g. Reminders' // client-side fallback firing for sid-less items) and returns the events due // to emit now. `fired` is the host's per-session dedupe set for `once` rules — // pass the same Set every tick; this function adds to it. export function tickerRules(schema, state, now, fired) { const t = schema.ticker; if (!t) return []; const items = (state || {})[schema.collection] || []; const keyField = collectionDecl(schema, schema.collection).key || "id"; const out = []; for (const rule of t.perItem || []) { for (const item of items) { const scope = { item, now }; if (!evalCond(rule.if, scope)) continue; const dedupe = item[keyField]; if (rule.once) { if (fired && fired.has(dedupe)) continue; if (fired) fired.add(dedupe); } out.push(evalValue(rule.emit, scope)); } } return out; }