| 1 | // π€ The app-schema engine: a small interpreter for the declarative app schema |
| 2 | // (docs/app-schema.md). A schema describes an app's state, how events fold into |
| 3 | // it, and how the folded state presents as a searchable, sectioned list with |
| 4 | // actions β in plain JSON that a human, an LLM, and BOTH runtimes (this JS |
| 5 | // engine and the Swift twin in ios/LChatCore/Sources/LChatCore/SchemaEngine.swift) |
| 6 | // can read. The two implementations are drift-gated by the shared event vectors |
| 7 | // (test/event-vectors/*) and by test/schema-engine.test.mjs β change behaviour |
| 8 | // here and the Swift twin must change in the same PR. |
| 9 | // |
| 10 | // Deliberately dependency-free and DOM-free so it runs in Node tests unchanged. |
| 11 | |
| 12 | // ---- values: `$refs`, literals, and tiny numeric ops ---------------------- |
| 13 | // A schema value is JSON. Strings starting with "$" are references into the |
| 14 | // evaluation scope ("$item.done", "$event.text", "$now"); "$$" escapes a |
| 15 | // literal leading dollar. Objects of the shape {"add":[a,b]} / {"sub":[a,b]} |
| 16 | // are the only computed values. Everything else is itself. |
| 17 | |
| 18 | function lookupPath(scope, path) { |
| 19 | let cur = scope; |
| 20 | for (const part of path.split(".")) { |
| 21 | if (cur == null || typeof cur !== "object") return null; |
| 22 | cur = cur[part]; |
| 23 | } |
| 24 | return cur === undefined ? null : cur; |
| 25 | } |
| 26 | |
| 27 | export function evalValue(v, scope) { |
| 28 | if (typeof v === "string") { |
| 29 | if (v.startsWith("$$")) return v.slice(1); |
| 30 | if (v.startsWith("$")) return lookupPath(scope, v.slice(1)); |
| 31 | return v; |
| 32 | } |
| 33 | if (Array.isArray(v)) return v.map((x) => evalValue(x, scope)); |
| 34 | if (v && typeof v === "object") { |
| 35 | if (Array.isArray(v.add) && v.add.length === 2) return num(evalValue(v.add[0], scope)) + num(evalValue(v.add[1], scope)); |
| 36 | if (Array.isArray(v.sub) && v.sub.length === 2) return num(evalValue(v.sub[0], scope)) - num(evalValue(v.sub[1], scope)); |
| 37 | const out = {}; |
| 38 | for (const [k, x] of Object.entries(v)) out[k] = evalValue(x, scope); |
| 39 | return out; |
| 40 | } |
| 41 | return v; |
| 42 | } |
| 43 | |
| 44 | function num(v) { |
| 45 | const n = typeof v === "number" ? v : typeof v === "string" ? parseFloat(v) : v === true ? 1 : 0; |
| 46 | return isNaN(n) ? 0 : n; |
| 47 | } |
| 48 | |
| 49 | function truthy(v) { |
| 50 | if (Array.isArray(v)) return v.length > 0; |
| 51 | return !!v; |
| 52 | } |
| 53 | |
| 54 | // ---- conditions ----------------------------------------------------------- |
| 55 | // {"truthy": v} {"not": c} {"all": [cβ¦]} {"any": [cβ¦]} {"eq": [a,b]} |
| 56 | // {"empty": v} {"gt"|"gte"|"lt"|"lte": [a,b]} |
| 57 | |
| 58 | export function evalCond(c, scope) { |
| 59 | if (c == null) return true; |
| 60 | if ("truthy" in c) return truthy(evalValue(c.truthy, scope)); |
| 61 | if ("not" in c) return !evalCond(c.not, scope); |
| 62 | if ("all" in c) return c.all.every((x) => evalCond(x, scope)); |
| 63 | if ("any" in c) return c.any.some((x) => evalCond(x, scope)); |
| 64 | if ("eq" in c) return evalValue(c.eq[0], scope) === evalValue(c.eq[1], scope); |
| 65 | if ("empty" in c) return !truthy(evalValue(c.empty, scope)); |
| 66 | if ("gt" in c) return num(evalValue(c.gt[0], scope)) > num(evalValue(c.gt[1], scope)); |
| 67 | if ("gte" in c) return num(evalValue(c.gte[0], scope)) >= num(evalValue(c.gte[1], scope)); |
| 68 | if ("lt" in c) return num(evalValue(c.lt[0], scope)) < num(evalValue(c.lt[1], scope)); |
| 69 | if ("lte" in c) return num(evalValue(c.lte[0], scope)) <= num(evalValue(c.lte[1], scope)); |
| 70 | return false; |
| 71 | } |
| 72 | |
| 73 | // ---- templates ------------------------------------------------------------ |
| 74 | // Display strings interpolate "{path}" or "{path|filter}" against the scope; |
| 75 | // "{{" escapes a literal brace. Filters: count (array length), reltime |
| 76 | // ("in 2h" / "5m ago" / "now" β needs $now in scope), abstime (locale |
| 77 | // "Wed, Jul 9, 14:30"), number (String(n)). |
| 78 | |
| 79 | export function relTime(when, now) { |
| 80 | let diff = when - now; |
| 81 | const past = diff < 0; |
| 82 | diff = Math.abs(diff); |
| 83 | const mins = Math.round(diff / 60000); |
| 84 | let s; |
| 85 | if (mins < 1) return "now"; |
| 86 | if (mins < 60) s = mins + "m"; |
| 87 | else if (mins < 1440) s = Math.round(mins / 60) + "h"; |
| 88 | else s = Math.round(mins / 1440) + "d"; |
| 89 | return past ? s + " ago" : "in " + s; |
| 90 | } |
| 91 | |
| 92 | export function absTime(when) { |
| 93 | try { |
| 94 | return new Date(when).toLocaleString([], { weekday: "short", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); |
| 95 | } catch { |
| 96 | return ""; |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | function applyFilter(v, filter, scope) { |
| 101 | switch (filter) { |
| 102 | case "count": |
| 103 | return Array.isArray(v) ? String(v.length) : "0"; |
| 104 | case "reltime": |
| 105 | return relTime(num(v), num(scope.now)); |
| 106 | case "abstime": |
| 107 | return absTime(num(v)); |
| 108 | case "number": |
| 109 | return String(num(v)); |
| 110 | default: |
| 111 | return str(v); |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | function str(v) { |
| 116 | if (v == null) return ""; |
| 117 | if (typeof v === "string") return v; |
| 118 | if (typeof v === "number") return String(v); |
| 119 | if (typeof v === "boolean") return v ? "true" : "false"; |
| 120 | return ""; |
| 121 | } |
| 122 | |
| 123 | export function evalTemplate(tpl, scope) { |
| 124 | let out = ""; |
| 125 | let i = 0; |
| 126 | while (i < tpl.length) { |
| 127 | const ch = tpl[i]; |
| 128 | if (ch === "{") { |
| 129 | if (tpl[i + 1] === "{") { |
| 130 | out += "{"; |
| 131 | i += 2; |
| 132 | continue; |
| 133 | } |
| 134 | const end = tpl.indexOf("}", i); |
| 135 | if (end < 0) { |
| 136 | out += tpl.slice(i); |
| 137 | break; |
| 138 | } |
| 139 | const expr = tpl.slice(i + 1, end); |
| 140 | const bar = expr.indexOf("|"); |
| 141 | const path = (bar >= 0 ? expr.slice(0, bar) : expr).trim(); |
| 142 | const filter = bar >= 0 ? expr.slice(bar + 1).trim() : ""; |
| 143 | const v = lookupPath(scope, path); |
| 144 | out += filter ? applyFilter(v, filter, scope) : str(v); |
| 145 | i = end + 1; |
| 146 | } else { |
| 147 | out += ch; |
| 148 | i++; |
| 149 | } |
| 150 | } |
| 151 | return out; |
| 152 | } |
| 153 | |
| 154 | // ---- state & fold --------------------------------------------------------- |
| 155 | |
| 156 | // The declared zero value for a field type ("string" | "bool" | "number" | "time"). |
| 157 | function zeroFor(type) { |
| 158 | return type === "string" ? "" : type === "bool" ? false : 0; |
| 159 | } |
| 160 | |
| 161 | // Coerce a value to a declared field type β missing/null becomes the zero |
| 162 | // value, so "$event.ts" on an event with no ts folds to 0, exactly like the |
| 163 | // hand-written reducers' `e.ts || 0`. |
| 164 | function coerce(v, type) { |
| 165 | if (v == null) return zeroFor(type); |
| 166 | switch (type) { |
| 167 | case "string": |
| 168 | return typeof v === "string" ? v : str(v); |
| 169 | case "bool": |
| 170 | return truthy(v); |
| 171 | case "number": |
| 172 | case "time": |
| 173 | return num(v); |
| 174 | default: |
| 175 | return v; |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | export function initialState(schema) { |
| 180 | const s = {}; |
| 181 | for (const key of Object.keys(schema.state || {})) s[key] = []; |
| 182 | return s; |
| 183 | } |
| 184 | |
| 185 | // foldSchemaEvent applies one event to state via the schema's declarative fold |
| 186 | // ops. Unknown types and failed `require` guards return state unchanged. Pure: |
| 187 | // always returns fresh arrays, never mutates. `add` is idempotent by the |
| 188 | // collection key (Invariant #1 in apps/README.md). |
| 189 | export function foldSchemaEvent(schema, state, event) { |
| 190 | const spec = (schema.events || {})[event && event.type]; |
| 191 | if (!spec) return state; |
| 192 | const scope = { event, state }; |
| 193 | for (const r of spec.require || []) { |
| 194 | if (!truthy(evalValue(r, scope))) return state; |
| 195 | } |
| 196 | let next = state; |
| 197 | for (const op of spec.fold || []) next = applyFoldOp(schema, next, op, event); |
| 198 | return next; |
| 199 | } |
| 200 | |
| 201 | function collectionDecl(schema, name) { |
| 202 | return (schema.state || {})[name] || { key: "id", fields: {} }; |
| 203 | } |
| 204 | |
| 205 | function applyFoldOp(schema, state, op, event) { |
| 206 | const scope = { event, state }; |
| 207 | if (op.add) { |
| 208 | const decl = collectionDecl(schema, op.add.to); |
| 209 | const list = state[op.add.to] || []; |
| 210 | const item = {}; |
| 211 | for (const [field, v] of Object.entries(op.add.item || {})) { |
| 212 | item[field] = coerce(evalValue(v, scope), decl.fields[field]); |
| 213 | } |
| 214 | const keyField = decl.key || "id"; |
| 215 | if (list.some((i) => i[keyField] === item[keyField])) return { ...state, [op.add.to]: list.slice() }; |
| 216 | return { ...state, [op.add.to]: [...list, item] }; |
| 217 | } |
| 218 | if (op.update) { |
| 219 | const decl = collectionDecl(schema, op.update.in); |
| 220 | const keyField = decl.key || "id"; |
| 221 | const id = evalValue(op.update.id, scope); |
| 222 | const list = (state[op.update.in] || []).map((i) => { |
| 223 | if (i[keyField] !== id) return i; |
| 224 | const patch = {}; |
| 225 | for (const [field, v] of Object.entries(op.update.set || {})) { |
| 226 | patch[field] = coerce(evalValue(v, scope), decl.fields[field]); |
| 227 | } |
| 228 | return { ...i, ...patch }; |
| 229 | }); |
| 230 | return { ...state, [op.update.in]: list }; |
| 231 | } |
| 232 | if (op.remove) { |
| 233 | const decl = collectionDecl(schema, op.remove.from); |
| 234 | const keyField = decl.key || "id"; |
| 235 | const id = evalValue(op.remove.id, scope); |
| 236 | return { ...state, [op.remove.from]: (state[op.remove.from] || []).filter((i) => i[keyField] !== id) }; |
| 237 | } |
| 238 | if (op.move) { |
| 239 | const decl = collectionDecl(schema, op.move.in); |
| 240 | const keyField = decl.key || "id"; |
| 241 | const id = evalValue(op.move.id, scope); |
| 242 | const list = state[op.move.in] || []; |
| 243 | const from = list.findIndex((i) => i[keyField] === id); |
| 244 | if (from < 0) return { ...state, [op.move.in]: list.slice() }; |
| 245 | // `after` names the item this one now follows; ""/missing = the list's |
| 246 | // start. An unknown `after` id (it was removed concurrently) is a no-op β |
| 247 | // both runtimes must agree, so the rule is pinned here. |
| 248 | const after = str(evalValue(op.move.after, scope)); |
| 249 | const next = list.slice(); |
| 250 | const [moved] = next.splice(from, 1); |
| 251 | let to = 0; |
| 252 | if (after) { |
| 253 | const anchor = next.findIndex((i) => i[keyField] === after); |
| 254 | if (anchor < 0) return { ...state, [op.move.in]: list.slice() }; |
| 255 | to = anchor + 1; |
| 256 | } |
| 257 | next.splice(to, 0, moved); |
| 258 | return { ...state, [op.move.in]: next }; |
| 259 | } |
| 260 | if (op.sort) { |
| 261 | const by = op.sort.by; |
| 262 | const dir = op.sort.dir === "desc" ? -1 : 1; |
| 263 | // Decorate with the index so the sort is stable on every runtime (Swift's |
| 264 | // sort isn't guaranteed stable; JS's is β the tiebreak keeps them twins). |
| 265 | const list = (state[op.sort.in] || []).map((item, idx) => ({ item, idx })); |
| 266 | list.sort((a, b) => { |
| 267 | const av = num(a.item[by]); |
| 268 | const bv = num(b.item[by]); |
| 269 | if (av !== bv) return (av - bv) * dir; |
| 270 | return a.idx - b.idx; |
| 271 | }); |
| 272 | return { ...state, [op.sort.in]: list.map((e) => e.item) }; |
| 273 | } |
| 274 | return state; |
| 275 | } |
| 276 | |
| 277 | // ---- partition: the grace-period buckets ---------------------------------- |
| 278 | // Splits the primary collection into { active, grace, archived } from the |
| 279 | // clock β the reducer only records the completion time; the linger-then-tuck |
| 280 | // behaviour is DERIVED here on every render (never a timer mutating state). |
| 281 | // A flagged item with no timestamp (legacy data) reads as long-archived. |
| 282 | |
| 283 | export function partitionState(schema, state, now) { |
| 284 | const p = schema.partition; |
| 285 | const items = (state || {})[schema.collection] || []; |
| 286 | const active = []; |
| 287 | const grace = []; |
| 288 | const archived = []; |
| 289 | if (!p) return { active: items.slice(), grace, archived }; |
| 290 | for (const item of items) { |
| 291 | const at = item[p.flag] ? item[p.at] || 1 : 0; |
| 292 | if (!at) active.push(item); |
| 293 | else if (now - at < p.graceMs) grace.push(item); |
| 294 | else archived.push(item); |
| 295 | } |
| 296 | return { active, grace, archived }; |
| 297 | } |
| 298 | |
| 299 | // ---- the list render model ------------------------------------------------- |
| 300 | // listModel resolves the main view against state + the clock + transient UI |
| 301 | // state (collapsed sections, a search query) into a plain description a |
| 302 | // renderer draws directly: summary line, sections with ordered rows (each row |
| 303 | // knowing whether it's in its grace window), and the empty-state line. Both |
| 304 | // renderers (schema-render.js on the web, SchemaAppView in SwiftUI) consume |
| 305 | // this same shape. |
| 306 | |
| 307 | export function listModel(schema, state, now, ui = {}) { |
| 308 | const view = schema.views && schema.views.main; |
| 309 | if (!view) return null; |
| 310 | // A never-used app thread has no folded state at all (the host only seeds on |
| 311 | // the first event), so `state` can arrive undefined β treat it as empty. |
| 312 | state = state || initialState(schema); |
| 313 | const items = state[schema.collection] || []; |
| 314 | const buckets = partitionState(schema, state, now); |
| 315 | const scope = { state, items, buckets, now }; |
| 316 | |
| 317 | let summary = ""; |
| 318 | for (const c of view.summary || []) { |
| 319 | if (evalCond(c.if, scope)) { |
| 320 | summary = evalTemplate(c.text, scope); |
| 321 | break; |
| 322 | } |
| 323 | } |
| 324 | |
| 325 | const query = (ui.query || "").trim().toLowerCase(); |
| 326 | const searchFields = (view.search && view.search.fields) || []; |
| 327 | const matches = (item) => { |
| 328 | if (!query) return true; |
| 329 | return searchFields.some((f) => str(item[f]).toLowerCase().includes(query)); |
| 330 | }; |
| 331 | |
| 332 | const graceIds = new Set(buckets.grace.map((i) => i[collectionDecl(schema, schema.collection).key || "id"])); |
| 333 | const indexOf = new Map(items.map((i, idx) => [i, idx])); |
| 334 | |
| 335 | const sections = []; |
| 336 | for (const s of view.sections || []) { |
| 337 | const names = Array.isArray(s.source) ? s.source : [s.source]; |
| 338 | let rows = names.flatMap((n) => buckets[n] || []).filter(matches); |
| 339 | if (s.order && s.order.by) { |
| 340 | const by = s.order.by; |
| 341 | const dir = s.order.dir === "desc" ? -1 : 1; |
| 342 | const decorated = rows.map((item) => ({ item, idx: indexOf.get(item) || 0 })); |
| 343 | decorated.sort((a, b) => { |
| 344 | const av = by === "_index" ? a.idx : num(a.item[by]); |
| 345 | const bv = by === "_index" ? b.idx : num(b.item[by]); |
| 346 | if (av !== bv) return (av - bv) * dir; |
| 347 | return a.idx - b.idx; |
| 348 | }); |
| 349 | rows = decorated.map((d) => d.item); |
| 350 | } |
| 351 | const collapsed = s.collapsible ? (ui.collapsed && s.id in ui.collapsed ? !!ui.collapsed[s.id] : !!s.collapsed) : false; |
| 352 | const keyField = collectionDecl(schema, schema.collection).key || "id"; |
| 353 | sections.push({ |
| 354 | id: s.id, |
| 355 | title: s.title ? evalTemplate(s.title, { ...scope, count: rows.length }) : "", |
| 356 | collapsible: !!s.collapsible, |
| 357 | collapsed, |
| 358 | // Manual reorder: the action a drag runs (with $item + $after bound by |
| 359 | // the renderer). Suppressed while searching β a filtered list has no |
| 360 | // meaningful drop position. descending tells the renderer how a visual |
| 361 | // drop maps to the array anchor. |
| 362 | reorder: (!query && s.reorder && s.reorder.action) || "", |
| 363 | descending: !!(s.order && s.order.dir === "desc"), |
| 364 | rows: rows.map((item) => ({ item, grace: graceIds.has(item[keyField]) })), |
| 365 | }); |
| 366 | } |
| 367 | |
| 368 | // The empty line shows when every non-collapsible section has no rows. |
| 369 | let empty = ""; |
| 370 | const visibleRows = sections.filter((s) => !s.collapsible).reduce((n, s) => n + s.rows.length, 0); |
| 371 | if (!visibleRows) { |
| 372 | for (const c of view.empty || []) { |
| 373 | if (evalCond(c.if, scope)) { |
| 374 | empty = evalTemplate(c.text, scope); |
| 375 | break; |
| 376 | } |
| 377 | } |
| 378 | } |
| 379 | |
| 380 | return { summary, sections, empty, buckets }; |
| 381 | } |
| 382 | |
| 383 | // rowModel resolves one row's presentation (title/subtitle/note text + flags) |
| 384 | // for an item β the per-row half of listModel, split out so a renderer can |
| 385 | // re-resolve a single row on its refresh tick without rebuilding the list. |
| 386 | export function rowModel(schema, item, now) { |
| 387 | const view = schema.views && schema.views.main; |
| 388 | const row = (view && view.row) || {}; |
| 389 | const scope = { item, now }; |
| 390 | const out = { title: "", strike: false, muted: false, subtitle: "", accent: false, note: "" }; |
| 391 | if (row.title) { |
| 392 | out.title = evalTemplate(row.title.text || "", scope); |
| 393 | out.strike = row.title.strike ? evalCond(row.title.strike, scope) : false; |
| 394 | out.muted = row.title.muted ? evalCond(row.title.muted, scope) : false; |
| 395 | } |
| 396 | for (const c of row.subtitle || []) { |
| 397 | if (evalCond(c.if, scope)) { |
| 398 | out.subtitle = evalTemplate(c.text, scope); |
| 399 | out.accent = !!c.accent && evalCond(c.accent, scope); |
| 400 | break; |
| 401 | } |
| 402 | } |
| 403 | for (const c of row.note || []) { |
| 404 | if (evalCond(c.if, scope)) { |
| 405 | out.note = evalTemplate(c.text, scope); |
| 406 | break; |
| 407 | } |
| 408 | } |
| 409 | return out; |
| 410 | } |
| 411 | |
| 412 | // detailModel resolves the detail ("show") view for one item: title + |
| 413 | // sections (definition rows / text blocks / buttons), conditions applied. |
| 414 | export function detailModel(schema, item, now) { |
| 415 | const view = schema.views && schema.views.detail; |
| 416 | if (!view) return null; |
| 417 | const scope = { item, now }; |
| 418 | const sections = []; |
| 419 | for (const s of view.sections || []) { |
| 420 | if (s.kind === "definitions") { |
| 421 | const rows = (s.rows || []).filter((r) => evalCond(r.if, scope)).map((r) => ({ term: r.term, value: evalTemplate(r.value || "", scope) })); |
| 422 | if (rows.length) sections.push({ kind: "definitions", rows }); |
| 423 | } else if (s.kind === "text") { |
| 424 | if (evalCond(s.if, scope)) { |
| 425 | const body = evalTemplate(s.body || "", scope); |
| 426 | if (body) sections.push({ kind: "text", body }); |
| 427 | } |
| 428 | } else if (s.kind === "buttons") { |
| 429 | const items = (s.items || []).filter((b) => evalCond(b.if, scope)).map((b) => ({ label: b.label, action: b.action, role: b.role || "", dismiss: !!b.dismiss })); |
| 430 | if (items.length) sections.push({ kind: "buttons", items }); |
| 431 | } |
| 432 | } |
| 433 | return { title: evalTemplate(view.title || "", scope), sections }; |
| 434 | } |
| 435 | |
| 436 | // ---- chat projection ------------------------------------------------------- |
| 437 | // The schema's `chat` map projects an app event into the Chat view: a bot |
| 438 | // bubble (string) or a /me-style action line ({ text, action: true }), or |
| 439 | // null for silence β the same contract as an app's chatMessage hook. |
| 440 | |
| 441 | export function chatProjection(schema, event, opts) { |
| 442 | const spec = (schema.chat || {})[event && event.type]; |
| 443 | if (!spec) return null; |
| 444 | const scope = { event }; |
| 445 | if (!evalCond(spec.if, scope)) return null; |
| 446 | const tpl = opts && opts.group && spec.groupText ? spec.groupText : spec.text; |
| 447 | const text = evalTemplate(tpl, scope); |
| 448 | return spec.action ? { text, action: true } : text; |
| 449 | } |
| 450 | |
| 451 | // ---- actions --------------------------------------------------------------- |
| 452 | // runAction executes a named action against an io surface the host provides: |
| 453 | // io.emit(event) β encrypt + send an event into the thread |
| 454 | // io.schedule(event, at) β server-schedule a future event; resolves sid|null |
| 455 | // io.unschedule(sid) β cancel a pending schedule |
| 456 | // io.newId() β a fresh unique entity id |
| 457 | // io.parse(kind, text) β run a named parser; null = parse failure |
| 458 | // io.hint(text) β surface inline feedback (a failed parse's nudge) |
| 459 | // scope carries the trigger's bindings: { item } for a row action, { input } |
| 460 | // for the composer (already trimmed). `$newId` is minted once per run, so a |
| 461 | // schedule step and the emit that records its sid share the same id. |
| 462 | |
| 463 | export async function runAction(schema, name, scope, io) { |
| 464 | const action = (schema.actions || {})[name]; |
| 465 | if (!action) return false; |
| 466 | let mintedId = null; |
| 467 | const ctx = { |
| 468 | ...scope, |
| 469 | get newId() { |
| 470 | if (mintedId === null) mintedId = io.newId(); |
| 471 | return mintedId; |
| 472 | }, |
| 473 | sid: "", |
| 474 | parsed: null, |
| 475 | }; |
| 476 | for (const r of action.require || []) { |
| 477 | if (!truthy(evalValue(r, ctx))) return false; |
| 478 | } |
| 479 | for (const step of action.steps || []) { |
| 480 | if (step.if && !evalCond(step.if, ctx)) continue; |
| 481 | if (step.parse) { |
| 482 | const text = evalValue(step.parse.text, ctx); |
| 483 | const parsed = io.parse ? io.parse(step.parse.kind, str(text)) : null; |
| 484 | if (!parsed) { |
| 485 | if (step.parse.onFail && step.parse.onFail.hint && io.hint) io.hint(step.parse.onFail.hint); |
| 486 | return false; |
| 487 | } |
| 488 | ctx.parsed = parsed; |
| 489 | } else if (step.schedule) { |
| 490 | const at = num(evalValue(step.schedule.at, ctx)); |
| 491 | const sid = await io.schedule(evalValue(step.schedule.event, ctx), at); |
| 492 | ctx.sid = sid || ""; |
| 493 | } else if (step.unschedule) { |
| 494 | await io.unschedule(str(evalValue(step.unschedule, ctx))); |
| 495 | } else if (step.emit) { |
| 496 | await io.emit(evalValue(step.emit, ctx)); |
| 497 | } |
| 498 | } |
| 499 | return true; |
| 500 | } |
| 501 | |
| 502 | // tickerRules evaluates the schema's per-item timed rules (e.g. Reminders' |
| 503 | // client-side fallback firing for sid-less items) and returns the events due |
| 504 | // to emit now. `fired` is the host's per-session dedupe set for `once` rules β |
| 505 | // pass the same Set every tick; this function adds to it. |
| 506 | export function tickerRules(schema, state, now, fired) { |
| 507 | const t = schema.ticker; |
| 508 | if (!t) return []; |
| 509 | const items = (state || {})[schema.collection] || []; |
| 510 | const keyField = collectionDecl(schema, schema.collection).key || "id"; |
| 511 | const out = []; |
| 512 | for (const rule of t.perItem || []) { |
| 513 | for (const item of items) { |
| 514 | const scope = { item, now }; |
| 515 | if (!evalCond(rule.if, scope)) continue; |
| 516 | const dedupe = item[keyField]; |
| 517 | if (rule.once) { |
| 518 | if (fired && fired.has(dedupe)) continue; |
| 519 | if (fired) fired.add(dedupe); |
| 520 | } |
| 521 | out.push(evalValue(rule.emit, scope)); |
| 522 | } |
| 523 | } |
| 524 | return out; |
| 525 | } |
| 526 | |