@happyvertical/smrt-app-shell 0.0.0-stage → 0.60.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/AGENTS.md +99 -0
  2. package/CLAUDE.md +1 -0
  3. package/LICENSE +7 -0
  4. package/README.md +173 -2
  5. package/agents/engine.md +85 -0
  6. package/dist/engine/app-runtime.d.ts +138 -0
  7. package/dist/engine/app-runtime.js +545 -0
  8. package/dist/engine/app-worker.d.ts +1 -0
  9. package/dist/engine/app-worker.js +62 -0
  10. package/dist/engine/commands.d.ts +94 -0
  11. package/dist/engine/commands.js +30 -0
  12. package/dist/engine/definition.d.ts +192 -0
  13. package/dist/engine/definition.js +497 -0
  14. package/dist/engine/errors.d.ts +23 -0
  15. package/dist/engine/errors.js +35 -0
  16. package/dist/engine/host.d.ts +153 -0
  17. package/dist/engine/host.js +791 -0
  18. package/dist/engine/index.d.ts +6 -0
  19. package/dist/engine/index.js +7 -0
  20. package/dist/engine/sandbox.d.ts +60 -0
  21. package/dist/engine/sandbox.js +260 -0
  22. package/dist/engine/snapshots.d.ts +7 -0
  23. package/dist/engine/snapshots.js +54 -0
  24. package/dist/engine/store.d.ts +39 -0
  25. package/dist/engine/store.js +32 -0
  26. package/dist/engine/worker-handle.d.ts +29 -0
  27. package/dist/engine/worker-handle.js +89 -0
  28. package/dist/engine/worker-protocol.d.ts +22 -0
  29. package/dist/engine/worker-protocol.js +20 -0
  30. package/dist/lib/objects/App.d.ts +60 -0
  31. package/dist/lib/objects/App.js +273 -0
  32. package/dist/lib/objects/AppVersion.d.ts +22 -0
  33. package/dist/lib/objects/AppVersion.js +62 -0
  34. package/dist/lib/objects/Approval.d.ts +39 -0
  35. package/dist/lib/objects/Approval.js +101 -0
  36. package/dist/lib/objects/AssistantMessage.d.ts +24 -0
  37. package/dist/lib/objects/AssistantMessage.js +63 -0
  38. package/dist/lib/objects/AssistantThread.d.ts +16 -0
  39. package/dist/lib/objects/AssistantThread.js +44 -0
  40. package/dist/lib/objects/index.d.ts +5 -0
  41. package/dist/lib/objects/index.js +5 -0
  42. package/dist/lib/server/approvals.d.ts +69 -0
  43. package/dist/lib/server/approvals.js +239 -0
  44. package/dist/lib/server/builder.d.ts +16 -0
  45. package/dist/lib/server/builder.js +71 -0
  46. package/dist/lib/server/events.d.ts +16 -0
  47. package/dist/lib/server/events.js +14 -0
  48. package/dist/lib/server/host.d.ts +22 -0
  49. package/dist/lib/server/host.js +96 -0
  50. package/dist/lib/server/owner.d.ts +33 -0
  51. package/dist/lib/server/owner.js +92 -0
  52. package/dist/lib/server/version-store.d.ts +20 -0
  53. package/dist/lib/server/version-store.js +115 -0
  54. package/dist/manifest.json +1680 -0
  55. package/dist/smrt-knowledge.okf.md +95 -0
  56. package/package.json +80 -3
@@ -0,0 +1,791 @@
1
+ // The host: owns the version history (through a VersionStore) and the data
2
+ // directory, and manages the user app, which runs as its own runtime in a
3
+ // worker thread. Every definition change starts a fresh app runtime from the
4
+ // new definition and swaps it in once it is ready; if it fails to start, the
5
+ // old one keeps running and the change is refused.
6
+ //
7
+ // The host touches only `dataDir`: app.db, snapshots/*.db, and the archives
8
+ // inside app.db. Plain files, no symlinks.
9
+ import { EventEmitter } from 'node:events';
10
+ import { existsSync, mkdirSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { isRiskyCommand, isRiskyTool, overwritesOnUndo, } from "./commands.js";
14
+ import { builtInFields, describeApp, EMPTY_APP, FORMAT, RECIPES, refuseChanges, restRouteAllowed, snake, tableFor, toolNamesFor, validate, } from "./definition.js";
15
+ import { ApprovalRequiredError, EngineError } from "./errors.js";
16
+ import { checkSyntax } from "./sandbox.js";
17
+ import { AppCallError, AppWorker } from "./worker-handle.js";
18
+ const now = () => new Date().toISOString();
19
+ function defaultWorkerUrl() {
20
+ for (const f of ['./app-worker.ts', './app-worker.js']) {
21
+ const u = new URL(f, import.meta.url);
22
+ if (existsSync(fileURLToPath(u)))
23
+ return u;
24
+ }
25
+ throw new EngineError('app-worker is missing next to the engine', 'refused');
26
+ }
27
+ const MAX_TEMPLATE_RECORDS = 100_000;
28
+ /** Method code an import may carry: all of it is shown for approval. */
29
+ const MAX_REVIEW_CODE_CHARS = 200_000;
30
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
31
+ export class AppHost extends EventEmitter {
32
+ def = EMPTY_APP;
33
+ version = 0;
34
+ dataDir;
35
+ lastSwapMs = 0;
36
+ store;
37
+ workerUrl;
38
+ app;
39
+ closed = false;
40
+ /** Changes are applied one at a time. Work inside the queue must not wait on it. */
41
+ queue = Promise.resolve();
42
+ constructor(dataDir, opts) {
43
+ super();
44
+ this.dataDir = dataDir;
45
+ this.store = opts.store;
46
+ this.workerUrl = opts.workerUrl ?? defaultWorkerUrl();
47
+ }
48
+ static async open(dataDir, opts) {
49
+ mkdirSync(join(dataDir, 'snapshots'), { recursive: true });
50
+ const host = new AppHost(dataDir, opts);
51
+ await host.boot();
52
+ return host;
53
+ }
54
+ async boot() {
55
+ let cur = await this.store.current();
56
+ if (cur === null) {
57
+ await this.store.append({
58
+ version: 0,
59
+ parent: null,
60
+ author: 'engine',
61
+ summary: 'empty app',
62
+ definition: EMPTY_APP,
63
+ migration: null,
64
+ createdAt: now(),
65
+ });
66
+ await this.store.setCurrent(0);
67
+ cur = 0;
68
+ }
69
+ const rec = await this.store.get(cur);
70
+ if (!rec)
71
+ throw new EngineError(`version ${cur} is missing from the store`, 'not_found');
72
+ const errs = validate(rec.definition);
73
+ if (errs.length)
74
+ throw new EngineError(`the stored definition is invalid: ${errs.join('; ')}`);
75
+ this.def = rec.definition;
76
+ this.version = cur;
77
+ this.app = this.spawn(this.def, this.version);
78
+ await this.app.ready;
79
+ }
80
+ spawn(def, version, migration) {
81
+ return new AppWorker(this.workerUrl, {
82
+ dataDir: this.dataDir,
83
+ def,
84
+ version,
85
+ migration,
86
+ });
87
+ }
88
+ // ---------- the change queue ----------
89
+ run(fn) {
90
+ if (this.closed)
91
+ return Promise.reject(new EngineError('the host is closed', 'refused'));
92
+ const p = this.queue.then(fn, fn);
93
+ this.queue = p.catch(() => { });
94
+ return p;
95
+ }
96
+ /**
97
+ * Start an app runtime on `def`; swap it in only if it comes up. With a
98
+ * migration the old runtime is stopped first, so nothing writes while tables
99
+ * change; if the new one fails, the migration rolled back and the old
100
+ * definition is started again.
101
+ */
102
+ async swap(def, version, migration) {
103
+ const t0 = performance.now();
104
+ const migrating = !!migration?.steps.length;
105
+ if (migrating)
106
+ await this.app.retire();
107
+ const next = this.spawn(def, version, migration);
108
+ let booted;
109
+ try {
110
+ booted = await next.ready;
111
+ }
112
+ catch (e) {
113
+ await next.terminate();
114
+ if (migrating) {
115
+ this.app = this.spawn(this.def, this.version);
116
+ await this.app.ready.catch(() => { }); // surfaced later by the dead worker
117
+ }
118
+ throw new EngineError(`the app did not start with this change, so it was not applied: ${e.message}`, 'migration_failed');
119
+ }
120
+ const old = this.app;
121
+ this.app = next;
122
+ if (!migrating)
123
+ void old.retire();
124
+ this.lastSwapMs = Math.round(performance.now() - t0);
125
+ return { ...booted, swapMs: this.lastSwapMs };
126
+ }
127
+ /**
128
+ * Swap to `next`, then persist. If persisting fails the app is put back
129
+ * where the store still says it is.
130
+ */
131
+ async transition(next, nextVersion, migration, persist) {
132
+ const swapped = await this.swap(next, nextVersion, migration);
133
+ try {
134
+ await persist();
135
+ }
136
+ catch (e) {
137
+ const back = migration?.steps.length
138
+ ? {
139
+ steps: migration.steps,
140
+ direction: migration.direction === 'up' ? 'down' : 'up',
141
+ version: migration.version,
142
+ }
143
+ : undefined;
144
+ try {
145
+ await this.swap(this.def, this.version, back);
146
+ }
147
+ catch {
148
+ // restartApp() can retry; the original error is what matters
149
+ }
150
+ throw e;
151
+ }
152
+ this.def = next;
153
+ this.version = nextVersion;
154
+ return swapped;
155
+ }
156
+ /** Validate, start the new app runtime, record the version. Every change takes this path. */
157
+ async applyChange(next, summary, author, steps = [], converting = new Set()) {
158
+ const errs = [
159
+ ...validate(next),
160
+ ...refuseChanges(this.def, next, converting),
161
+ ];
162
+ if (errs.length)
163
+ throw new EngineError(errs.join('\n'));
164
+ const v = (await this.store.maxVersion()) + 1;
165
+ const parent = this.version;
166
+ const migration = steps.length
167
+ ? { steps, direction: 'up', version: v }
168
+ : undefined;
169
+ const swapped = await this.transition(next, v, migration, async () => {
170
+ await this.store.append({
171
+ version: v,
172
+ parent,
173
+ author,
174
+ summary,
175
+ definition: next,
176
+ migration: steps.length ? { steps } : null,
177
+ createdAt: now(),
178
+ });
179
+ await this.store.setCurrent(v);
180
+ });
181
+ this.emit('changed', { version: v, summary });
182
+ return {
183
+ version: v,
184
+ summary,
185
+ statements: swapped.statements,
186
+ snapshot: swapped.snapshot,
187
+ appRestartMs: swapped.swapMs,
188
+ };
189
+ }
190
+ // ---------- commands ----------
191
+ /** What a command would do, without changing anything. */
192
+ plan(cmd) {
193
+ return this.run(async () => (await this.analyse(cmd)).plan);
194
+ }
195
+ /**
196
+ * Apply a command. A risky one throws {@link ApprovalRequiredError} unless
197
+ * `approved` is true, and then nothing has changed.
198
+ */
199
+ execute(cmd, opts) {
200
+ if (!opts || typeof opts.author !== 'string' || !opts.author)
201
+ return Promise.reject(new EngineError('execute needs an author'));
202
+ return this.run(async () => {
203
+ const a = await this.analyse(cmd);
204
+ if (a.plan.risky && opts.approved !== true)
205
+ throw new ApprovalRequiredError(a.plan);
206
+ return a.apply(opts.author);
207
+ });
208
+ }
209
+ clone() {
210
+ return structuredClone(this.def);
211
+ }
212
+ model(name) {
213
+ if (!Object.hasOwn(this.def.models, name))
214
+ throw new EngineError(`no model ${name}`, 'not_found');
215
+ return this.def.models[name];
216
+ }
217
+ /** Where an own field of `model` is stored. */
218
+ storage(model, field) {
219
+ const md = this.model(model);
220
+ if (Object.hasOwn(builtInFields(model, md), field))
221
+ throw new EngineError(`${model}.${field} is built in`, 'refused');
222
+ if (!Object.hasOwn(md.fields, field))
223
+ throw new EngineError(`no field ${model}.${field}`, 'not_found');
224
+ return {
225
+ table: tableFor(model, md),
226
+ column: snake(field),
227
+ fd: md.fields[field],
228
+ md,
229
+ };
230
+ }
231
+ /** Methods (of any model) whose code mentions `name`; their code is not rewritten. */
232
+ methodsMentioning(name) {
233
+ const re = new RegExp(`\\b${name}\\b`);
234
+ return Object.entries(this.def.models).flatMap(([m, md]) => Object.entries(md.methods ?? {})
235
+ .filter(([, meth]) => re.test(meth.code))
236
+ .map(([n]) => `${m}.${n}`));
237
+ }
238
+ async analyse(cmd) {
239
+ const risky = isRiskyCommand(cmd);
240
+ const change = (summary, d, extra = {}) => ({
241
+ plan: {
242
+ summary,
243
+ risky,
244
+ ...(extra.preview ? { preview: extra.preview } : {}),
245
+ ...(extra.affectedMethods?.length
246
+ ? { affectedMethods: extra.affectedMethods }
247
+ : {}),
248
+ },
249
+ apply: async (author) => {
250
+ const r = await this.applyChange(d, summary, author, extra.steps, extra.converting);
251
+ return extra.after ? extra.after(r) : r;
252
+ },
253
+ });
254
+ switch (cmd.type) {
255
+ case 'defineModel': {
256
+ if (Object.hasOwn(this.def.models, cmd.name))
257
+ throw new EngineError(`${cmd.name} already exists; use addField`);
258
+ const d = this.clone();
259
+ d.models[cmd.name] = {
260
+ ...(cmd.label ? { label: cmd.label } : {}),
261
+ fields: Object.fromEntries(Object.entries(cmd.fields ?? {}).map(([k, v]) => [k, shown(v)])),
262
+ methods: {},
263
+ };
264
+ this.assertValid(d);
265
+ return change(`define ${cmd.name}`, d);
266
+ }
267
+ case 'addField': {
268
+ const md = this.model(cmd.model);
269
+ if (Object.hasOwn(builtInFields(cmd.model, md), cmd.field))
270
+ throw new EngineError(`${cmd.model}.${cmd.field} is built in`, 'refused');
271
+ if (md.fields[cmd.field] && !md.fields[cmd.field].hidden)
272
+ throw new EngineError(`${cmd.model}.${cmd.field} already exists`);
273
+ const d = this.clone();
274
+ d.models[cmd.model].fields[cmd.field] = shown(cmd.definition);
275
+ this.assertValid(d);
276
+ return change(`add ${cmd.model}.${cmd.field}`, d);
277
+ }
278
+ case 'hideField': {
279
+ const st = this.storage(cmd.model, cmd.field);
280
+ if (st.fd.hidden)
281
+ throw new EngineError(`${cmd.model}.${cmd.field} is already hidden`);
282
+ const d = this.clone();
283
+ d.models[cmd.model].fields[cmd.field].hidden = true;
284
+ return change(`hide ${cmd.model}.${cmd.field}`, d);
285
+ }
286
+ case 'renameField': {
287
+ const st = this.storage(cmd.model, cmd.from);
288
+ if (Object.hasOwn(st.md.fields, cmd.to) ||
289
+ Object.hasOwn(builtInFields(cmd.model, st.md), cmd.to))
290
+ throw new EngineError(`${cmd.model}.${cmd.to} already exists`);
291
+ const d = this.clone();
292
+ // Keep field order: rebuild the object with the new key in place.
293
+ d.models[cmd.model].fields = Object.fromEntries(Object.entries(d.models[cmd.model].fields).map(([k, v]) => [
294
+ k === cmd.from ? cmd.to : k,
295
+ v,
296
+ ]));
297
+ this.assertValid(d);
298
+ return change(`rename ${cmd.model}.${cmd.from} -> ${cmd.to}`, d, {
299
+ steps: [
300
+ {
301
+ op: 'renameColumn',
302
+ table: st.table,
303
+ from: st.column,
304
+ to: snake(cmd.to),
305
+ },
306
+ ],
307
+ affectedMethods: this.methodsMentioning(cmd.from),
308
+ after: (r) => ({
309
+ ...r,
310
+ affectedMethods: this.methodsMentioning(cmd.from),
311
+ }),
312
+ });
313
+ }
314
+ case 'removeField': {
315
+ const st = this.storage(cmd.model, cmd.field);
316
+ const valuesArchived = await this.callApp('countValues', st.table, st.column);
317
+ const d = this.clone();
318
+ delete d.models[cmd.model].fields[cmd.field];
319
+ this.assertValid(d);
320
+ return change(`remove ${cmd.model}.${cmd.field}`, d, {
321
+ preview: {
322
+ model: cmd.model,
323
+ field: cmd.field,
324
+ valuesArchived,
325
+ note: 'The column is dropped; its values are archived so undo can restore them until cleanUp removes the archive.',
326
+ },
327
+ affectedMethods: this.methodsMentioning(cmd.field),
328
+ steps: [
329
+ {
330
+ op: 'dropColumn',
331
+ table: st.table,
332
+ key: 'id',
333
+ column: st.column,
334
+ type: st.fd.type,
335
+ },
336
+ ],
337
+ });
338
+ }
339
+ case 'changeFieldType': {
340
+ const st = this.storage(cmd.model, cmd.field);
341
+ const to = { ...st.fd, ...cmd.to, hidden: false };
342
+ if (cmd.to.type !== 'choice' && !cmd.to.choices)
343
+ delete to.choices;
344
+ if (cmd.to.type !== 'reference' && !cmd.to.ref)
345
+ delete to.ref;
346
+ const pv = await this.callApp('previewConversion', st.table, 'id', st.column, to);
347
+ const d = this.clone();
348
+ d.models[cmd.model].fields[cmd.field] = shown(to);
349
+ this.assertValid(d);
350
+ return change(`change ${cmd.model}.${cmd.field} ${st.fd.type} -> ${to.type}`, d, {
351
+ preview: {
352
+ model: cmd.model,
353
+ field: cmd.field,
354
+ from: st.fd.type,
355
+ to: to.type,
356
+ ...pv,
357
+ note: 'Values that do not convert become empty; all original values are archived so undo restores them.',
358
+ },
359
+ affectedMethods: this.methodsMentioning(cmd.field),
360
+ steps: [
361
+ {
362
+ op: 'convertColumn',
363
+ table: st.table,
364
+ key: 'id',
365
+ column: st.column,
366
+ from: st.fd,
367
+ to,
368
+ },
369
+ ],
370
+ converting: new Set([`${cmd.model}.${cmd.field}`]),
371
+ after: (r) => ({
372
+ ...r,
373
+ converted: pv.convertible,
374
+ emptied: pv.failingCount,
375
+ }),
376
+ });
377
+ }
378
+ case 'removeModel': {
379
+ const md = this.model(cmd.model);
380
+ if (md.extends)
381
+ throw new EngineError(`${cmd.model} belongs to a recipe; its table is shared with the shipped model`, 'refused');
382
+ const table = tableFor(cmd.model, md);
383
+ const rows = await this.callApp('countRows', table);
384
+ const d = this.clone();
385
+ delete d.models[cmd.model];
386
+ this.assertValid(d);
387
+ return change(`remove model ${cmd.model}`, d, {
388
+ preview: {
389
+ model: cmd.model,
390
+ rows,
391
+ note: 'The table is moved aside (renamed), not deleted, so undo restores it until cleanUp removes it.',
392
+ },
393
+ steps: [
394
+ {
395
+ op: 'archiveTable',
396
+ table,
397
+ archivedAs: `_archived_${table}_${Date.now()}`,
398
+ },
399
+ ],
400
+ });
401
+ }
402
+ case 'addMethod': {
403
+ this.model(cmd.model);
404
+ const problem = await checkSyntax(cmd.method?.code ?? '');
405
+ if (problem)
406
+ throw new EngineError(`method does not parse: ${problem}`);
407
+ const d = this.clone();
408
+ const existed = !!d.models[cmd.model].methods?.[cmd.name];
409
+ d.models[cmd.model].methods ??= {};
410
+ d.models[cmd.model].methods[cmd.name] =
411
+ cmd.method;
412
+ this.assertValid(d);
413
+ return change(`${existed ? 'replace' : 'add'} method ${cmd.model}.${cmd.name}()`, d, {
414
+ preview: {
415
+ model: cmd.model,
416
+ method: cmd.name,
417
+ description: cmd.method.description,
418
+ params: cmd.method.params ?? {},
419
+ code: cmd.method.code,
420
+ },
421
+ });
422
+ }
423
+ case 'enableRecipe': {
424
+ const recipe = Object.hasOwn(RECIPES, cmd.recipe)
425
+ ? RECIPES[cmd.recipe]
426
+ : undefined;
427
+ if (!recipe)
428
+ throw new EngineError(`unknown recipe '${cmd.recipe}'`);
429
+ if (this.def.recipes.includes(cmd.recipe))
430
+ throw new EngineError(`${cmd.recipe} is already on`);
431
+ const d = this.clone();
432
+ for (const [name, rm] of Object.entries(recipe.models)) {
433
+ if (d.models[name])
434
+ throw new EngineError(`${name} already exists`);
435
+ // The app-subclass rule: an empty app model extending the shipped one.
436
+ d.models[name] = {
437
+ label: name,
438
+ extends: rm.parent,
439
+ fields: {},
440
+ methods: {},
441
+ };
442
+ }
443
+ d.recipes.push(cmd.recipe);
444
+ this.assertValid(d);
445
+ return change(`enable ${cmd.recipe}`, d);
446
+ }
447
+ case 'undo':
448
+ return this.analyseUndo(cmd.version);
449
+ case 'cleanUp':
450
+ return this.analyseCleanUp(cmd.keep);
451
+ case 'exportTemplate':
452
+ return {
453
+ plan: { summary: 'export the app as a template', risky: false },
454
+ apply: () => this.exportTemplate(!!cmd.includeRecords),
455
+ };
456
+ case 'importTemplate':
457
+ return this.analyseImport(cmd.data, risky);
458
+ default:
459
+ throw new EngineError(`unknown command ${cmd.type}`);
460
+ }
461
+ }
462
+ assertValid(d) {
463
+ const errs = validate(d);
464
+ if (errs.length)
465
+ throw new EngineError(errs.join('\n'));
466
+ }
467
+ async analyseUndo(pin) {
468
+ const rec = await this.store.get(this.version);
469
+ if (!rec || rec.parent == null)
470
+ throw new EngineError('nothing to undo', 'refused');
471
+ if (pin !== undefined && pin !== rec.version)
472
+ throw new EngineError(`the app has changed since this undo was asked for (now at version ${rec.version}); ask again`, 'refused');
473
+ if (rec.migration?.purged)
474
+ throw new EngineError(`can't undo "${rec.summary}": its archived data was cleaned up`, 'refused');
475
+ const parent = await this.store.get(rec.parent);
476
+ if (!parent)
477
+ throw new EngineError(`version ${rec.parent} is missing`, 'not_found');
478
+ // Undo is the safety net, so it is immediate, except where going back
479
+ // overwrites newer data: a convertColumn step restores each row's original
480
+ // value, replacing whatever was written to that column since. Decided here
481
+ // from the stored version, never from the caller.
482
+ const steps = rec.migration?.steps ?? [];
483
+ const converts = steps.filter(overwritesOnUndo);
484
+ const risky = converts.length > 0;
485
+ let preview = {
486
+ version: rec.version,
487
+ steps: steps.length,
488
+ };
489
+ if (risky) {
490
+ const counts = await this.callApp('countOverwrites', rec.version, steps);
491
+ const fields = counts.map((c) => {
492
+ const where = this.locate(rec.definition, c.table, c.column);
493
+ return { ...where, rows: c.replaced };
494
+ });
495
+ preview = {
496
+ ...preview,
497
+ fields,
498
+ replaced: fields.reduce((n, f) => n + f.rows, 0),
499
+ note: 'Going back restores the original value of every row in these fields. Values written since the type change are replaced.',
500
+ };
501
+ }
502
+ return {
503
+ plan: {
504
+ summary: `undo: ${rec.summary}`,
505
+ risky,
506
+ preview,
507
+ },
508
+ apply: async () => {
509
+ const migration = rec.migration?.steps.length
510
+ ? {
511
+ steps: rec.migration.steps,
512
+ direction: 'down',
513
+ version: rec.version,
514
+ }
515
+ : undefined;
516
+ const swapped = await this.transition(parent.definition, parent.version, migration, () => this.store.setCurrent(parent.version));
517
+ const summary = `undo: ${rec.summary}`;
518
+ this.emit('changed', { version: parent.version, summary });
519
+ return {
520
+ version: parent.version,
521
+ summary,
522
+ undid: rec.summary,
523
+ statements: swapped.statements,
524
+ snapshot: swapped.snapshot,
525
+ appRestartMs: swapped.swapMs,
526
+ };
527
+ },
528
+ };
529
+ }
530
+ /** The model and field stored in `table.column` under `def`. */
531
+ locate(def, table, column) {
532
+ for (const [model, md] of Object.entries(def.models))
533
+ if (tableFor(model, md) === table)
534
+ for (const f of Object.keys(md.fields))
535
+ if (snake(f) === column)
536
+ return { model, field: f };
537
+ return { model: table, field: column };
538
+ }
539
+ /** The chain from the current version back to the root, newest first. */
540
+ async chain() {
541
+ const out = [];
542
+ let v = this.version;
543
+ while (v != null) {
544
+ const r = await this.store.get(v);
545
+ if (!r)
546
+ break;
547
+ out.push(r);
548
+ v = r.parent;
549
+ }
550
+ return out;
551
+ }
552
+ async analyseCleanUp(keep) {
553
+ if (!Number.isInteger(keep) || keep < 0)
554
+ throw new EngineError('keep must be a whole number, zero or more');
555
+ const chain = await this.chain();
556
+ const old = chain
557
+ .slice(keep)
558
+ .filter((r) => r.migration?.steps.length && !r.migration.purged);
559
+ const versions = old.map((r) => r.version);
560
+ const tables = old.flatMap((r) => (r.migration?.steps ?? []).flatMap((s) => s.op === 'archiveTable' ? [s.archivedAs] : []));
561
+ const counts = await this.callApp('countArchives', versions, tables);
562
+ // Snapshots taken before the newest purged change belong to changes that
563
+ // can no longer be undone.
564
+ const cutoff = Date.parse(chain[keep]?.createdAt ?? '');
565
+ return {
566
+ plan: {
567
+ summary: `clean up: keep the last ${keep} changes undoable`,
568
+ risky: true,
569
+ preview: {
570
+ versions,
571
+ archivedValues: counts.values,
572
+ archivedTables: counts.tables,
573
+ note: 'Those changes can no longer be undone.',
574
+ },
575
+ },
576
+ apply: async () => {
577
+ const purged = await this.callApp('purgeArchives', versions, tables);
578
+ for (const v of versions)
579
+ await this.store.markPurged(v);
580
+ const snapshots = Number.isFinite(cutoff)
581
+ ? await this.callApp('pruneSnapshots', cutoff)
582
+ : 0;
583
+ return { purgedVersions: versions, ...purged, snapshots };
584
+ },
585
+ };
586
+ }
587
+ // ---------- templates ----------
588
+ async exportTemplate(includeRecords) {
589
+ const out = {
590
+ format: FORMAT,
591
+ exportedAt: now(),
592
+ definition: this.def,
593
+ };
594
+ if (includeRecords) {
595
+ const records = {};
596
+ for (const m of Object.keys(this.def.models))
597
+ records[m] = await this.callApp('list', m, MAX_TEMPLATE_RECORDS);
598
+ out.records = records;
599
+ }
600
+ return structuredClone(out);
601
+ }
602
+ async analyseImport(data, risky) {
603
+ if (!isObj(data) || data.format !== FORMAT)
604
+ throw new EngineError(`unknown template format '${String(data?.format)}'`);
605
+ const errs = validate(data.definition);
606
+ if (errs.length)
607
+ throw new EngineError(`the template is invalid: ${errs.join('; ')}`);
608
+ if (this.version !== 0 || Object.keys(this.def.models).length)
609
+ throw new EngineError('import needs an empty app', 'refused');
610
+ const def = structuredClone(data.definition);
611
+ // The approval shows every method in full: what is approved is what runs.
612
+ const methods = [];
613
+ let codeChars = 0;
614
+ for (const [m, md] of Object.entries(def.models))
615
+ for (const [n, meth] of Object.entries(md.methods ?? {})) {
616
+ const problem = await checkSyntax(meth.code);
617
+ if (problem)
618
+ throw new EngineError(`${m}.${n}() does not parse: ${problem}`);
619
+ codeChars += meth.code.length;
620
+ methods.push({
621
+ model: m,
622
+ name: n,
623
+ description: meth.description,
624
+ params: meth.params ?? {},
625
+ code: meth.code,
626
+ });
627
+ }
628
+ if (codeChars > MAX_REVIEW_CODE_CHARS)
629
+ throw new EngineError(`the template carries too much code to review (${codeChars} characters, at most ${MAX_REVIEW_CODE_CHARS}); import it in smaller parts`, 'refused');
630
+ let records;
631
+ let total = 0;
632
+ if (data.records !== undefined) {
633
+ if (!isObj(data.records))
634
+ throw new EngineError('template records must be an object');
635
+ records = {};
636
+ for (const [m, rows] of Object.entries(data.records)) {
637
+ if (!Object.hasOwn(def.models, m) ||
638
+ !Array.isArray(rows) ||
639
+ rows.some((r) => !isObj(r)))
640
+ throw new EngineError(`template records for '${m}' are not valid`);
641
+ total += rows.length;
642
+ records[m] = rows;
643
+ }
644
+ if (total > MAX_TEMPLATE_RECORDS)
645
+ throw new EngineError('the template has too many records');
646
+ }
647
+ return {
648
+ plan: {
649
+ summary: 'import template',
650
+ risky,
651
+ preview: { models: Object.keys(def.models), methods, records: total },
652
+ },
653
+ apply: async (author) => {
654
+ const r = await this.applyChange(def, 'import template', author);
655
+ if (records && total) {
656
+ await this.callApp('importRecords', records);
657
+ this.emit('records', { model: null });
658
+ }
659
+ return r;
660
+ },
661
+ };
662
+ }
663
+ // ---------- the running app ----------
664
+ /** Talk to the app. Callers inside the change queue use this; others use onApp. */
665
+ async callApp(op, ...args) {
666
+ try {
667
+ return await this.app.call(op, ...args);
668
+ }
669
+ catch (e) {
670
+ if (e instanceof AppCallError) {
671
+ if (e.user)
672
+ throw new EngineError(e.message, 'invalid');
673
+ throw new EngineError(e.message, 'refused');
674
+ }
675
+ throw e;
676
+ }
677
+ }
678
+ async onApp(op, ...args) {
679
+ await this.queue; // never talk to an app that is being replaced
680
+ if (this.closed)
681
+ throw new EngineError('the host is closed', 'refused');
682
+ return this.callApp(op, ...args);
683
+ }
684
+ list(model, limit = 200) {
685
+ return this.onApp('list', model, limit);
686
+ }
687
+ get(model, id) {
688
+ return this.onApp('get', model, id);
689
+ }
690
+ async create(model, data) {
691
+ const r = await this.onApp('create', model, data);
692
+ this.emit('records', { model });
693
+ return r;
694
+ }
695
+ async update(model, id, data) {
696
+ const r = await this.onApp('update', model, id, data);
697
+ this.emit('records', { model });
698
+ return r;
699
+ }
700
+ async remove(model, id) {
701
+ const r = await this.onApp('remove', model, id);
702
+ this.emit('records', { model });
703
+ return r;
704
+ }
705
+ async runMethod(model, method, id = null, params = {}) {
706
+ const out = await this.onApp('runMethod', model, method, id, params);
707
+ if (out.applied)
708
+ this.emit('records', { model });
709
+ return out;
710
+ }
711
+ /** The app's own generated MCP tools. */
712
+ appTools() {
713
+ return this.onApp('tools');
714
+ }
715
+ /** Call one of the app's generated tools. A delete needs `approved: true`. */
716
+ async callAppTool(name, args = {}, opts = {}) {
717
+ if (isRiskyTool(name) && opts.approved !== true)
718
+ throw new ApprovalRequiredError({
719
+ summary: `call ${name}`,
720
+ risky: true,
721
+ preview: { tool: name, args },
722
+ });
723
+ const r = await this.onApp('callTool', name, args);
724
+ if (!r.isError) {
725
+ const model = Object.entries(this.def.models).find(([m, md]) => toolNamesFor(m, md).includes(name))?.[0] ?? null;
726
+ this.emit('records', { model });
727
+ }
728
+ return r;
729
+ }
730
+ /** The worker's generated REST routes, passed through. A DELETE needs `approved: true`. */
731
+ async rest(req, opts = {}) {
732
+ const method = req.method.toUpperCase();
733
+ // Only the app's own collection routes are served (see restRouteAllowed);
734
+ // checked before the gate so a reserved route is never even an approval.
735
+ if (!restRouteAllowed(this.def, req.url))
736
+ return {
737
+ status: 404,
738
+ contentType: 'application/json',
739
+ body: JSON.stringify({ error: 'Not found' }),
740
+ };
741
+ if (method === 'DELETE' && opts.approved !== true)
742
+ throw new ApprovalRequiredError({
743
+ summary: `DELETE ${req.url}`,
744
+ risky: true,
745
+ preview: { method, url: req.url },
746
+ });
747
+ const r = await this.onApp('rest', { ...req, method });
748
+ if (method !== 'GET' && method !== 'HEAD' && r.status < 400)
749
+ this.emit('records', { model: null });
750
+ return r;
751
+ }
752
+ describe() {
753
+ return describeApp(this.def, this.version);
754
+ }
755
+ async versions() {
756
+ return (await this.store.list()).map((r) => ({
757
+ version: r.version,
758
+ parent: r.parent,
759
+ author: r.author,
760
+ summary: r.summary,
761
+ createdAt: r.createdAt,
762
+ purged: !!r.migration?.purged,
763
+ current: r.version === this.version,
764
+ }));
765
+ }
766
+ /** Restart the app runtime on the same definition (e.g. after a crash). */
767
+ restartApp() {
768
+ return this.run(async () => {
769
+ const s = await this.swap(this.def, this.version);
770
+ this.emit('changed', { version: this.version, summary: 'app restarted' });
771
+ return { appRestartMs: s.swapMs };
772
+ });
773
+ }
774
+ /** Stop the app thread as a crash would. For tests and for the shell's own watchdogs. */
775
+ async killApp() {
776
+ await this.app.terminate();
777
+ }
778
+ get appAlive() {
779
+ return this.app.alive;
780
+ }
781
+ async close() {
782
+ await this.queue;
783
+ this.closed = true;
784
+ await this.app.terminate();
785
+ }
786
+ }
787
+ /** A field as stored in the definition: visible unless hidden is set later. */
788
+ function shown(fd) {
789
+ const { hidden: _hidden, ...rest } = fd;
790
+ return rest;
791
+ }