@happyvertical/smrt-app-shell 0.60.18 → 0.61.0

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 (52) hide show
  1. package/AGENTS.md +51 -9
  2. package/README.md +378 -23
  3. package/agents/draft-ui.md +74 -0
  4. package/agents/engine.md +81 -0
  5. package/agents/shared-pieces.md +127 -0
  6. package/dist/engine/app-runtime.d.ts +7 -0
  7. package/dist/engine/app-runtime.js +30 -3
  8. package/dist/engine/commands.d.ts +23 -0
  9. package/dist/engine/commands.js +29 -0
  10. package/dist/engine/draft.d.ts +31 -0
  11. package/dist/engine/draft.js +88 -0
  12. package/dist/engine/host.d.ts +175 -1
  13. package/dist/engine/host.js +501 -9
  14. package/dist/engine/index.d.ts +4 -2
  15. package/dist/engine/index.js +2 -1
  16. package/dist/engine/publish.d.ts +100 -0
  17. package/dist/engine/publish.js +218 -0
  18. package/dist/engine/store.d.ts +8 -0
  19. package/dist/engine/store.js +8 -0
  20. package/dist/engine/worker-protocol.d.ts +1 -1
  21. package/dist/engine/worker-protocol.js +1 -0
  22. package/dist/lib/objects/App.d.ts +42 -1
  23. package/dist/lib/objects/App.js +72 -4
  24. package/dist/lib/objects/AppDraft.d.ts +31 -0
  25. package/dist/lib/objects/AppDraft.js +73 -0
  26. package/dist/lib/objects/AppDraftVersion.d.ts +25 -0
  27. package/dist/lib/objects/AppDraftVersion.js +68 -0
  28. package/dist/lib/objects/Approval.d.ts +4 -4
  29. package/dist/lib/objects/Approval.js +3 -3
  30. package/dist/lib/objects/index.d.ts +2 -0
  31. package/dist/lib/objects/index.js +2 -0
  32. package/dist/lib/server/approval-store.d.ts +6 -0
  33. package/dist/lib/server/approval-store.js +102 -0
  34. package/dist/lib/server/approvals.d.ts +15 -0
  35. package/dist/lib/server/approvals.js +62 -43
  36. package/dist/lib/server/builder.d.ts +18 -10
  37. package/dist/lib/server/builder.js +46 -18
  38. package/dist/lib/server/drafts.d.ts +100 -0
  39. package/dist/lib/server/drafts.js +324 -0
  40. package/dist/lib/server/events.d.ts +14 -0
  41. package/dist/lib/server/events.js +3 -0
  42. package/dist/lib/server/host.d.ts +44 -2
  43. package/dist/lib/server/host.js +155 -11
  44. package/dist/lib/server/results.d.ts +6 -0
  45. package/dist/lib/server/results.js +8 -0
  46. package/dist/lib/server/target.d.ts +24 -0
  47. package/dist/lib/server/target.js +64 -0
  48. package/dist/lib/server/version-store.d.ts +40 -1
  49. package/dist/lib/server/version-store.js +144 -32
  50. package/dist/manifest.json +699 -6
  51. package/dist/smrt-knowledge.okf.md +19 -2
  52. package/package.json +19 -15
@@ -7,12 +7,14 @@
7
7
  // The host touches only `dataDir`: app.db, snapshots/*.db, and the archives
8
8
  // inside app.db. Plain files, no symlinks.
9
9
  import { EventEmitter } from 'node:events';
10
- import { existsSync, mkdirSync } from 'node:fs';
11
- import { join } from 'node:path';
10
+ import { existsSync, mkdirSync, renameSync, rmSync } from 'node:fs';
11
+ import { dirname, join } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
- import { isRiskyCommand, isRiskyTool, overwritesOnUndo, } from "./commands.js";
13
+ import { isRiskyCommand, isRiskyTool, isStructuralCommand, overwritesOnUndo, } from "./commands.js";
14
14
  import { builtInFields, describeApp, EMPTY_APP, FORMAT, RECIPES, refuseChanges, restRouteAllowed, snake, tableFor, toolNamesFor, validate, } from "./definition.js";
15
+ import { checkDraftPath, readDraftMarker, removeDraftDirectory, sameDir, writeDraftMarker, } from "./draft.js";
15
16
  import { ApprovalRequiredError, EngineError } from "./errors.js";
17
+ import { archivedTables, assertNoAside, canonical, chainDigest, diffDefinitions, dropAside, listMethods, makeRehearsalDir, moveAside, reconcilePublish, removeJournal, removeRehearsal, restoreAside, writeJournal, } from "./publish.js";
16
18
  import { checkSyntax } from "./sandbox.js";
17
19
  import { AppCallError, AppWorker } from "./worker-handle.js";
18
20
  const now = () => new Date().toISOString();
@@ -37,6 +39,15 @@ export class AppHost extends EventEmitter {
37
39
  workerUrl;
38
40
  app;
39
41
  closed = false;
42
+ #autoApprove = false;
43
+ frozenNow = false;
44
+ /** What boot did about an interrupted publish: `rolled-back` put the old data back. */
45
+ recovery = 'none';
46
+ /** Set when this host is a draft copy (its directory holds the draft marker). */
47
+ draft;
48
+ /** Record/tool/REST calls currently inside the worker (see {@link drainInflight}). */
49
+ inflight = 0;
50
+ idleWaiters = [];
40
51
  /** Changes are applied one at a time. Work inside the queue must not wait on it. */
41
52
  queue = Promise.resolve();
42
53
  constructor(dataDir, opts) {
@@ -44,8 +55,16 @@ export class AppHost extends EventEmitter {
44
55
  this.dataDir = dataDir;
45
56
  this.store = opts.store;
46
57
  this.workerUrl = opts.workerUrl ?? defaultWorkerUrl();
58
+ this.frozenNow = opts.frozen === true;
59
+ this.#autoApprove = opts.autoApprove === true;
60
+ this.draft = readDraftMarker(dataDir);
47
61
  }
48
62
  static async open(dataDir, opts) {
63
+ if (opts.autoApprove) {
64
+ const marker = readDraftMarker(dataDir);
65
+ if (!marker || sameDir(marker.liveDir, dataDir))
66
+ throw new EngineError('autoApprove is for draft copies only; this directory is not one', 'refused');
67
+ }
49
68
  mkdirSync(join(dataDir, 'snapshots'), { recursive: true });
50
69
  const host = new AppHost(dataDir, opts);
51
70
  await host.boot();
@@ -53,6 +72,8 @@ export class AppHost extends EventEmitter {
53
72
  }
54
73
  async boot() {
55
74
  let cur = await this.store.current();
75
+ // Finish or undo a publish a crash interrupted, before the app opens.
76
+ this.recovery = reconcilePublish(this.dataDir, cur);
56
77
  if (cur === null) {
57
78
  await this.store.append({
58
79
  version: 0,
@@ -72,6 +93,8 @@ export class AppHost extends EventEmitter {
72
93
  const errs = validate(rec.definition);
73
94
  if (errs.length)
74
95
  throw new EngineError(`the stored definition is invalid: ${errs.join('; ')}`);
96
+ if (this.draft && !(await this.store.get(this.draft.baseVersion)))
97
+ throw new EngineError(`the draft's base version ${this.draft.baseVersion} is missing from its store`, 'not_found');
75
98
  this.def = rec.definition;
76
99
  this.version = cur;
77
100
  this.app = this.spawn(this.def, this.version);
@@ -190,7 +213,26 @@ export class AppHost extends EventEmitter {
190
213
  // ---------- commands ----------
191
214
  /** What a command would do, without changing anything. */
192
215
  plan(cmd) {
193
- return this.run(async () => (await this.analyse(cmd)).plan);
216
+ return this.run(async () => {
217
+ this.refuseIfFrozen(cmd);
218
+ return (await this.analyse(cmd)).plan;
219
+ });
220
+ }
221
+ /** True while structural commands are refused. */
222
+ get frozen() {
223
+ return this.frozenNow;
224
+ }
225
+ /**
226
+ * Freeze or thaw the host. Frozen, every command in `STRUCTURAL_COMMANDS`
227
+ * (and `plan` of it) is refused with code `refused`; nothing about records
228
+ * changes. The shell freezes the live host while a draft is open.
229
+ */
230
+ setFrozen(frozen) {
231
+ this.frozenNow = frozen;
232
+ }
233
+ refuseIfFrozen(cmd) {
234
+ if (this.frozenNow && isStructuralCommand(cmd))
235
+ throw new EngineError(`the app is frozen while a draft is open, so '${String(cmd?.type)}' is refused; make the change in the draft`, 'refused');
194
236
  }
195
237
  /**
196
238
  * Apply a command. A risky one throws {@link ApprovalRequiredError} unless
@@ -200,8 +242,9 @@ export class AppHost extends EventEmitter {
200
242
  if (!opts || typeof opts.author !== 'string' || !opts.author)
201
243
  return Promise.reject(new EngineError('execute needs an author'));
202
244
  return this.run(async () => {
245
+ this.refuseIfFrozen(cmd);
203
246
  const a = await this.analyse(cmd);
204
- if (a.plan.risky && opts.approved !== true)
247
+ if (a.plan.risky && opts.approved !== true && !this.#autoApprove)
205
248
  throw new ApprovalRequiredError(a.plan);
206
249
  return a.apply(opts.author);
207
250
  });
@@ -446,6 +489,8 @@ export class AppHost extends EventEmitter {
446
489
  }
447
490
  case 'undo':
448
491
  return this.analyseUndo(cmd.version);
492
+ case 'undoTo':
493
+ return this.analyseUndoTo(cmd.version, cmd.from);
449
494
  case 'cleanUp':
450
495
  return this.analyseCleanUp(cmd.keep);
451
496
  case 'exportTemplate':
@@ -465,6 +510,9 @@ export class AppHost extends EventEmitter {
465
510
  throw new EngineError(errs.join('\n'));
466
511
  }
467
512
  async analyseUndo(pin) {
513
+ // The base of a draft is a live version: its archives are live's, not this copy's.
514
+ if (this.draft && this.version === this.draft.baseVersion)
515
+ throw new EngineError('nothing to undo in the draft', 'refused');
468
516
  const rec = await this.store.get(this.version);
469
517
  if (!rec || rec.parent == null)
470
518
  throw new EngineError('nothing to undo', 'refused');
@@ -527,6 +575,82 @@ export class AppHost extends EventEmitter {
527
575
  },
528
576
  };
529
577
  }
578
+ /**
579
+ * Undo back to `target`: the same step as `undo`, repeated newest first, as
580
+ * one command with one decision. Each step is its own consistent state (its
581
+ * own swap and store write), so a failure part-way leaves the app at the
582
+ * version it had reached, and says so.
583
+ */
584
+ async analyseUndoTo(target, from) {
585
+ if (!Number.isInteger(target))
586
+ throw new EngineError('undoTo needs a version number');
587
+ if (from !== undefined && from !== this.version)
588
+ throw new EngineError(`the app has changed since this undo was asked for (now at version ${this.version}); ask again`, 'refused');
589
+ if (target === this.version)
590
+ throw new EngineError(`the app is already at version ${target}`, 'refused');
591
+ const chain = await this.chain(); // newest first
592
+ const at = chain.findIndex((r) => r.version === target);
593
+ if (at < 0)
594
+ throw new EngineError(`version ${target} is not in the history leading to version ${this.version}`, 'refused');
595
+ if (this.draft && target < this.draft.baseVersion)
596
+ throw new EngineError('the draft cannot go back past its base', 'refused');
597
+ const undone = chain.slice(0, at); // newest first
598
+ const purged = undone.find((r) => r.migration?.purged);
599
+ if (purged)
600
+ throw new EngineError(`can't undo "${purged.summary}": its archived data was cleaned up`, 'refused');
601
+ const converting = undone.filter((r) => r.migration?.steps.some(overwritesOnUndo));
602
+ let preview = {
603
+ to: target,
604
+ versions: undone.map((r) => ({ version: r.version, summary: r.summary })),
605
+ };
606
+ if (converting.length) {
607
+ const fields = [];
608
+ for (const r of converting) {
609
+ const counts = await this.callApp('countOverwrites', r.version, r.migration?.steps ?? []);
610
+ for (const c of counts)
611
+ fields.push({
612
+ ...this.locate(r.definition, c.table, c.column),
613
+ rows: c.replaced,
614
+ });
615
+ }
616
+ preview = {
617
+ ...preview,
618
+ fields,
619
+ replaced: fields.reduce((n, f) => n + f.rows, 0),
620
+ note: 'Going back restores the original value of every row in these fields. Values written since the type change are replaced.',
621
+ };
622
+ }
623
+ const summary = `undo ${undone.length} change${undone.length === 1 ? '' : 's'} back to version ${target}`;
624
+ return {
625
+ plan: { summary, risky: converting.length > 0, preview },
626
+ apply: async () => {
627
+ const statements = [];
628
+ let snapshot = null;
629
+ let appRestartMs = 0;
630
+ for (const [i, r] of undone.entries()) {
631
+ try {
632
+ const step = await (await this.analyseUndo(r.version)).apply('');
633
+ const c = step;
634
+ statements.push(...c.statements);
635
+ snapshot = c.snapshot ?? snapshot;
636
+ appRestartMs += c.appRestartMs;
637
+ }
638
+ catch (e) {
639
+ const code = e instanceof EngineError ? e.code : 'migration_failed';
640
+ throw new EngineError(`undid ${i} of ${undone.length} changes; the app is now at version ${this.version}: ${e.message}`, code);
641
+ }
642
+ }
643
+ return {
644
+ version: target,
645
+ summary,
646
+ undid: undone.map((r) => r.summary).join('; '),
647
+ statements,
648
+ snapshot,
649
+ appRestartMs,
650
+ };
651
+ },
652
+ };
653
+ }
530
654
  /** The model and field stored in `table.column` under `def`. */
531
655
  locate(def, table, column) {
532
656
  for (const [model, md] of Object.entries(def.models))
@@ -676,10 +800,378 @@ export class AppHost extends EventEmitter {
676
800
  }
677
801
  }
678
802
  async onApp(op, ...args) {
679
- await this.queue; // never talk to an app that is being replaced
803
+ // Never talk to an app that is being replaced: wait until the queue is
804
+ // idle AND no change was queued while waiting. The check and the call below
805
+ // run in one synchronous step, so no change can start in between.
806
+ for (;;) {
807
+ const q = this.queue;
808
+ await q;
809
+ if (q === this.queue)
810
+ break;
811
+ }
680
812
  if (this.closed)
681
813
  throw new EngineError('the host is closed', 'refused');
682
- return this.callApp(op, ...args);
814
+ this.inflight++;
815
+ try {
816
+ return await this.callApp(op, ...args);
817
+ }
818
+ finally {
819
+ if (--this.inflight === 0) {
820
+ const waiters = this.idleWaiters;
821
+ this.idleWaiters = [];
822
+ for (const w of waiters)
823
+ w();
824
+ }
825
+ }
826
+ }
827
+ /**
828
+ * Wait for the calls already inside the worker to finish. Used inside the
829
+ * change queue, where no new call can start (see {@link onApp}), before a
830
+ * consistent copy of the data is taken.
831
+ */
832
+ async drainInflight() {
833
+ while (this.inflight > 0)
834
+ await new Promise((r) => this.idleWaiters.push(r));
835
+ }
836
+ // ---------- drafts ----------
837
+ /**
838
+ * Start a draft: copy the live data into `draftDir` (a directory that must
839
+ * not exist yet; this call creates it) and return a second host over the
840
+ * copy, with `autoApprove` on. The copy is a `VACUUM INTO` taken in the
841
+ * change queue with no call in flight, so it is one consistent state; live is
842
+ * only read. If anything fails, the directory this call created is removed.
843
+ * Only an ordinary (live) host can fork. Live may keep serving records while
844
+ * the draft is open; the caller freezes live structure with `setFrozen`.
845
+ */
846
+ forkDraft(draftDir, opts) {
847
+ if (this.draft)
848
+ return Promise.reject(new EngineError('a draft cannot be forked again', 'refused'));
849
+ return this.run(async () => {
850
+ const dir = checkDraftPath(draftDir, this.dataDir);
851
+ if ((await opts.store.current()) !== null)
852
+ throw new EngineError('the draft needs an empty version store', 'refused');
853
+ const base = await this.store.get(this.version);
854
+ if (!base)
855
+ throw new EngineError(`version ${this.version} is missing`, 'not_found');
856
+ await this.drainInflight();
857
+ mkdirSync(dirname(dir), { recursive: true });
858
+ mkdirSync(dir); // EEXIST: someone else made it first; not ours to remove
859
+ try {
860
+ await this.callApp('copyTo', join(dir, 'app.db'));
861
+ writeDraftMarker(dir, this.dataDir, base.version);
862
+ await opts.store.append(structuredClone(base));
863
+ await opts.store.setCurrent(base.version);
864
+ return await AppHost.open(dir, {
865
+ store: opts.store,
866
+ workerUrl: this.workerUrl,
867
+ autoApprove: true,
868
+ });
869
+ }
870
+ catch (e) {
871
+ rmSync(dir, { recursive: true, force: true });
872
+ throw e;
873
+ }
874
+ });
875
+ }
876
+ /**
877
+ * Throw this draft away: stop its app and delete its directory (only a
878
+ * directory holding the draft marker is ever deleted). The caller discards
879
+ * the draft's version store.
880
+ */
881
+ async discard() {
882
+ if (!this.draft)
883
+ throw new EngineError('only a draft can be discarded', 'refused');
884
+ await this.close();
885
+ removeDraftDirectory(this.dataDir);
886
+ }
887
+ /**
888
+ * This draft's versions since its base, oldest first, with the base record.
889
+ * What a publish takes from the draft.
890
+ */
891
+ draftChain() {
892
+ const draft = this.draft;
893
+ if (!draft)
894
+ return Promise.reject(new EngineError('not a draft', 'refused'));
895
+ return this.run(async () => {
896
+ const recs = await this.chain(); // current back to the base
897
+ const at = recs.findIndex((r) => r.version === draft.baseVersion);
898
+ if (at < 0)
899
+ throw new EngineError('the draft history does not reach its base version', 'not_found');
900
+ return {
901
+ baseVersion: draft.baseVersion,
902
+ base: recs[at],
903
+ versions: recs.slice(0, at).reverse(),
904
+ };
905
+ });
906
+ }
907
+ /** Checks shared by preview and publish; runs inside the change queue. */
908
+ async publishInputs(draft) {
909
+ if (this.draft)
910
+ throw new EngineError('a draft cannot be published into a draft', 'refused');
911
+ if (draft === this || !draft.draft)
912
+ throw new EngineError('that is not a draft', 'refused');
913
+ if (!sameDir(draft.draft.liveDir, this.dataDir))
914
+ throw new EngineError('that draft was made from another app or location; discard it and start a new one', 'refused');
915
+ const chain = await draft.draftChain();
916
+ if (chain.baseVersion !== this.version)
917
+ throw new EngineError(`the app moved from version ${chain.baseVersion} to ${this.version} since the draft started; discard the draft and start again`, 'refused');
918
+ const live = await this.store.get(this.version);
919
+ if (!live ||
920
+ canonical(live.definition) !== canonical(chain.base.definition) ||
921
+ canonical(this.def) !== canonical(chain.base.definition))
922
+ throw new EngineError('the app is not what the draft started from; discard the draft and start again', 'refused');
923
+ if (!chain.versions.length)
924
+ throw new EngineError('the draft has no changes to publish', 'refused');
925
+ return { chain, digest: chainDigest(this.version, chain) };
926
+ }
927
+ /** The version numbers a publish of `chain` would give: after everything live's store holds. */
928
+ async nextNumbers(chain) {
929
+ const max = await this.store.maxVersion();
930
+ return chain.versions.map((_, i) => max + 1 + i);
931
+ }
932
+ /**
933
+ * Replay the draft's versions, in order, onto the copy of live in `dir`: one
934
+ * short worker per version, with that version's stored definition and steps
935
+ * and the number it will have in live (archives are keyed by it). Ends by
936
+ * writing the result as one standalone file, `dir/final/app.db`. Touches
937
+ * nothing but `dir`.
938
+ */
939
+ async replay(dir, chain, numbers) {
940
+ const boots = [];
941
+ const allSteps = chain.versions.flatMap((r) => r.migration?.steps ?? []);
942
+ let archives = { values: 0, tables: 0 };
943
+ const file = join(dir, 'final', 'app.db');
944
+ for (let i = 0; i < chain.versions.length; i++) {
945
+ const rec = chain.versions[i];
946
+ const errs = validate(rec.definition);
947
+ if (errs.length)
948
+ throw new EngineError(`"${rec.summary}" from the draft is invalid: ${errs.join('; ')}`);
949
+ const steps = rec.migration?.steps ?? [];
950
+ const w = new AppWorker(this.workerUrl, {
951
+ dataDir: dir,
952
+ def: rec.definition,
953
+ version: numbers[i],
954
+ migration: steps.length
955
+ ? { steps, direction: 'up', version: numbers[i] }
956
+ : undefined,
957
+ });
958
+ try {
959
+ boots.push(await w.ready);
960
+ if (i === chain.versions.length - 1) {
961
+ archives = await w.call('countArchives', numbers, archivedTables(allSteps));
962
+ await w.call('copyTo', file);
963
+ }
964
+ }
965
+ catch (e) {
966
+ await w.terminate().catch(() => { });
967
+ throw new EngineError(`"${rec.summary}" from the draft does not apply to the current data, so nothing was published: ${e.message}`, 'migration_failed');
968
+ }
969
+ await w.retire();
970
+ }
971
+ return { boots, archives, file };
972
+ }
973
+ /**
974
+ * What publishing `draft` would do: the draft's versions replayed on a fresh
975
+ * copy of the CURRENT live data (the live app is not touched and only waits
976
+ * while the copy is taken). `digest` pins the answer for `publishDraft`.
977
+ */
978
+ async previewPublish(draft) {
979
+ const ctx = await this.run(async () => {
980
+ const { chain, digest } = await this.publishInputs(draft);
981
+ await this.drainInflight();
982
+ const numbers = await this.nextNumbers(chain);
983
+ const dir = makeRehearsalDir(this.dataDir);
984
+ try {
985
+ await this.callApp('copyTo', join(dir, 'app.db'));
986
+ }
987
+ catch (e) {
988
+ removeRehearsal(this.dataDir, dir);
989
+ throw e;
990
+ }
991
+ return { chain, digest, numbers, dir, from: this.def, at: this.version };
992
+ });
993
+ try {
994
+ const r = await this.replay(ctx.dir, ctx.chain, ctx.numbers);
995
+ const final = ctx.chain.versions[ctx.chain.versions.length - 1].definition;
996
+ return {
997
+ digest: ctx.digest,
998
+ risky: true,
999
+ fromVersion: ctx.at,
1000
+ toVersion: ctx.numbers[ctx.numbers.length - 1],
1001
+ versions: ctx.chain.versions.map((rec, i) => ({
1002
+ version: ctx.numbers[i],
1003
+ draftVersion: rec.version,
1004
+ author: rec.author,
1005
+ summary: rec.summary,
1006
+ steps: rec.migration?.steps ?? [],
1007
+ statements: r.boots[i].statements,
1008
+ })),
1009
+ definition: diffDefinitions(ctx.from, final),
1010
+ archives: r.archives,
1011
+ methods: listMethods(ctx.from, final),
1012
+ };
1013
+ }
1014
+ finally {
1015
+ removeRehearsal(this.dataDir, ctx.dir);
1016
+ }
1017
+ }
1018
+ /**
1019
+ * Publish `draft` into live. Always needs `approved: true` and the `expect`
1020
+ * digest of a preview; refuses if the draft's chain, or the app it started
1021
+ * from, is not what that preview was of.
1022
+ *
1023
+ * It rehearses on a fresh copy of the current live data (so records added
1024
+ * since the fork are kept and draft records are not), then swaps the file:
1025
+ * the old data is first copied to snapshots/<ts>-0-migrate-prepublish.db,
1026
+ * live's files are renamed aside and the rehearsed file renamed into place.
1027
+ * The versions land in live as N ordinary versions (numbers after live's
1028
+ * highest, summaries tagged with `draftName`), so `undo` / `undoTo` work.
1029
+ * Any failure before the store is written puts the old files back (live's
1030
+ * `app.db` is byte-for-byte what it was) and leaves the version unchanged.
1031
+ * Live records wait (not fail) while this runs. The caller discards the draft.
1032
+ */
1033
+ publishDraft(draft, opts) {
1034
+ if (!opts || typeof opts.author !== 'string' || !opts.author)
1035
+ return Promise.reject(new EngineError('publish needs an author'));
1036
+ if (typeof opts.expect !== 'string' || !opts.expect)
1037
+ return Promise.reject(new EngineError('publish needs the digest of a preview', 'refused'));
1038
+ if (opts.approved !== true)
1039
+ return Promise.reject(new ApprovalRequiredError({
1040
+ summary: 'publish the draft',
1041
+ risky: true,
1042
+ preview: { digest: opts.expect },
1043
+ }));
1044
+ return this.run(() => this.doPublish(draft, opts));
1045
+ }
1046
+ async persistVersions(records, current) {
1047
+ if (this.store.commit)
1048
+ return this.store.commit(records, current);
1049
+ for (const r of records)
1050
+ await this.store.append(r);
1051
+ await this.store.setCurrent(current);
1052
+ }
1053
+ async doPublish(draft, opts) {
1054
+ const { chain, digest } = await this.publishInputs(draft);
1055
+ if (digest !== opts.expect)
1056
+ throw new EngineError('the draft is not what was reviewed (it changed since the preview); review it again', 'refused');
1057
+ const stage = async (s) => {
1058
+ await opts.onStage?.(s);
1059
+ };
1060
+ assertNoAside(this.dataDir);
1061
+ await this.drainInflight();
1062
+ const numbers = await this.nextNumbers(chain);
1063
+ const finalNumber = numbers[numbers.length - 1];
1064
+ const finalDef = chain.versions[chain.versions.length - 1].definition;
1065
+ const tag = opts.draftName
1066
+ ? `[draft: ${opts.draftName.replace(/\s+/g, ' ').trim()}] `
1067
+ : '';
1068
+ const oldVersion = this.version;
1069
+ const dir = makeRehearsalDir(this.dataDir);
1070
+ const t0 = performance.now();
1071
+ let retired = false;
1072
+ let journaled = false;
1073
+ let asideStarted = false;
1074
+ let next = null;
1075
+ let snapshot = '';
1076
+ let archives = { values: 0, tables: 0 };
1077
+ try {
1078
+ await this.callApp('copyTo', join(dir, 'app.db'));
1079
+ await stage('copied');
1080
+ const r = await this.replay(dir, chain, numbers);
1081
+ archives = r.archives;
1082
+ await stage('rehearsed');
1083
+ snapshot = join(this.dataDir, 'snapshots', `${Date.now()}-0-migrate-prepublish.db`);
1084
+ await this.callApp('copyTo', snapshot);
1085
+ await stage('snapshotted');
1086
+ writeJournal(this.dataDir, {
1087
+ format: 1,
1088
+ oldVersion,
1089
+ finalVersion: finalNumber,
1090
+ rehearsal: dir,
1091
+ snapshot,
1092
+ startedAt: now(),
1093
+ });
1094
+ journaled = true;
1095
+ await stage('journaled');
1096
+ retired = true;
1097
+ await this.app.retire();
1098
+ await stage('retired');
1099
+ asideStarted = true;
1100
+ moveAside(this.dataDir);
1101
+ await stage('moved-aside');
1102
+ renameSync(r.file, join(this.dataDir, 'app.db'));
1103
+ await stage('swapped');
1104
+ next = this.spawn(finalDef, finalNumber);
1105
+ await next.ready;
1106
+ await stage('respawned');
1107
+ const stamp = now();
1108
+ await this.persistVersions(chain.versions.map((rec, i) => ({
1109
+ version: numbers[i],
1110
+ parent: i === 0 ? oldVersion : numbers[i - 1],
1111
+ author: rec.author,
1112
+ summary: `${tag}${rec.summary}`,
1113
+ definition: structuredClone(rec.definition),
1114
+ migration: rec.migration?.steps.length
1115
+ ? { steps: structuredClone(rec.migration.steps) }
1116
+ : null,
1117
+ createdAt: stamp,
1118
+ })), finalNumber);
1119
+ }
1120
+ catch (e) {
1121
+ await this.abortPublish({ retired, asideStarted, journaled, next, dir }, e);
1122
+ throw e; // not reached when abortPublish throws its own
1123
+ }
1124
+ // The store has the versions: the publish is final.
1125
+ this.app = next;
1126
+ this.def = structuredClone(finalDef);
1127
+ this.version = finalNumber;
1128
+ this.lastSwapMs = Math.round(performance.now() - t0);
1129
+ try {
1130
+ await opts.onStage?.('committed');
1131
+ }
1132
+ catch {
1133
+ // too late to matter
1134
+ }
1135
+ try {
1136
+ dropAside(this.dataDir);
1137
+ removeJournal(this.dataDir);
1138
+ removeRehearsal(this.dataDir, dir);
1139
+ }
1140
+ catch {
1141
+ // leftovers are removed at the next start (reconcilePublish)
1142
+ }
1143
+ const summary = `${tag}published ${chain.versions.length} change${chain.versions.length === 1 ? '' : 's'}`;
1144
+ this.emit('changed', { version: finalNumber, summary });
1145
+ return {
1146
+ version: finalNumber,
1147
+ versions: numbers,
1148
+ summary,
1149
+ snapshot,
1150
+ archives,
1151
+ appRestartMs: this.lastSwapMs,
1152
+ };
1153
+ }
1154
+ /** Undo a failed publish: put live back exactly as it was and start it again. */
1155
+ async abortPublish(s, cause) {
1156
+ try {
1157
+ if (s.next)
1158
+ await s.next.terminate().catch(() => { });
1159
+ if (s.retired) {
1160
+ await this.app.terminate().catch(() => { });
1161
+ if (s.asideStarted)
1162
+ restoreAside(this.dataDir);
1163
+ }
1164
+ if (s.journaled)
1165
+ removeJournal(this.dataDir);
1166
+ removeRehearsal(this.dataDir, s.dir);
1167
+ if (s.retired) {
1168
+ this.app = this.spawn(this.def, this.version);
1169
+ await this.app.ready.catch(() => { }); // restartApp() can retry
1170
+ }
1171
+ }
1172
+ catch (e2) {
1173
+ throw new EngineError(`${cause.message}; and putting the old data back failed: ${e2.message}. It is restored when the app next starts.`, 'migration_failed');
1174
+ }
683
1175
  }
684
1176
  list(model, limit = 200) {
685
1177
  return this.onApp('list', model, limit);
@@ -714,7 +1206,7 @@ export class AppHost extends EventEmitter {
714
1206
  }
715
1207
  /** Call one of the app's generated tools. A delete needs `approved: true`. */
716
1208
  async callAppTool(name, args = {}, opts = {}) {
717
- if (isRiskyTool(name) && opts.approved !== true)
1209
+ if (isRiskyTool(name) && opts.approved !== true && !this.#autoApprove)
718
1210
  throw new ApprovalRequiredError({
719
1211
  summary: `call ${name}`,
720
1212
  risky: true,
@@ -738,7 +1230,7 @@ export class AppHost extends EventEmitter {
738
1230
  contentType: 'application/json',
739
1231
  body: JSON.stringify({ error: 'Not found' }),
740
1232
  };
741
- if (method === 'DELETE' && opts.approved !== true)
1233
+ if (method === 'DELETE' && opts.approved !== true && !this.#autoApprove)
742
1234
  throw new ApprovalRequiredError({
743
1235
  summary: `DELETE ${req.url}`,
744
1236
  risky: true,
@@ -1,6 +1,8 @@
1
- export { type Command, type CommandType, type ExecuteOptions, isRiskyCommand, isRiskyTool, type Plan, RISKY_COMMANDS, type Template, } from './commands.ts';
1
+ export { type Command, type CommandType, type ExecuteOptions, isRiskyCommand, isRiskyTool, isStructuralCommand, type Plan, RISKY_COMMANDS, STRUCTURAL_COMMANDS, type Template, } from './commands.ts';
2
2
  export { type AppDefinition, builtInFields, coerceValue, convertValue, type DescribedModel, describeApp, EMPTY_APP, FIELD_NAME_PATTERN, FIELD_TYPES, type FieldDef, type FieldType, FORMAT, isValidFieldName, isValidModelName, type MethodDef, type MethodParamDef, type Migration, type ModelDef, RECIPES, type Recipe, type RecipeModel, refuseChanges, type Step, snake, suggestFieldName, tableFor, USER_PKG, validate, } from './definition.ts';
3
+ export { type DraftMarker, removeDraftDirectory as discardDraftDirectory, } from './draft.ts';
3
4
  export { AppError, ApprovalRequiredError, EngineError, type EngineErrorCode, WorkerCrashedError, } from './errors.ts';
4
- export { AppHost, type ChangeResult, type CleanUpResult, type ExecuteResult, type HostOptions, type MethodResult, type RestResult, type ToolResult, type VersionSummary, } from './host.ts';
5
+ export { AppHost, type ChangeResult, type CleanUpResult, type ExecuteResult, type ForkDraftOptions, type HostOptions, type MethodResult, type PublishOptions, type PublishPreview, type PublishResult, type PublishStage, type PublishVersionPreview, type RestResult, type ToolResult, type VersionSummary, } from './host.ts';
6
+ export type { DefinitionDiff, DraftChain, FieldChange, ModelDiff, PublishMethod, } from './publish.ts';
5
7
  export { checkSyntax, type MethodInput, type MethodOutput, runMethod, SANDBOX_LIMITS, SandboxError, type SandboxLimits, validateWrites, type WritableModel, type Write, } from './sandbox.ts';
6
8
  export { MemoryVersionStore, type VersionMigration, type VersionRecord, type VersionStore, } from './store.ts';
@@ -1,6 +1,7 @@
1
1
  // The app-shell engine: runs a person's own smrt app from a definition.
2
- export { isRiskyCommand, isRiskyTool, RISKY_COMMANDS, } from "./commands.js";
2
+ export { isRiskyCommand, isRiskyTool, isStructuralCommand, RISKY_COMMANDS, STRUCTURAL_COMMANDS, } from "./commands.js";
3
3
  export { builtInFields, coerceValue, convertValue, describeApp, EMPTY_APP, FIELD_NAME_PATTERN, FIELD_TYPES, FORMAT, isValidFieldName, isValidModelName, RECIPES, refuseChanges, snake, suggestFieldName, tableFor, USER_PKG, validate, } from "./definition.js";
4
+ export { removeDraftDirectory as discardDraftDirectory, } from "./draft.js";
4
5
  export { AppError, ApprovalRequiredError, EngineError, WorkerCrashedError, } from "./errors.js";
5
6
  export { AppHost, } from "./host.js";
6
7
  export { checkSyntax, runMethod, SANDBOX_LIMITS, SandboxError, validateWrites, } from "./sandbox.js";