@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.
- package/AGENTS.md +51 -9
- package/README.md +378 -23
- package/agents/draft-ui.md +74 -0
- package/agents/engine.md +81 -0
- package/agents/shared-pieces.md +127 -0
- package/dist/engine/app-runtime.d.ts +7 -0
- package/dist/engine/app-runtime.js +30 -3
- package/dist/engine/commands.d.ts +23 -0
- package/dist/engine/commands.js +29 -0
- package/dist/engine/draft.d.ts +31 -0
- package/dist/engine/draft.js +88 -0
- package/dist/engine/host.d.ts +175 -1
- package/dist/engine/host.js +501 -9
- package/dist/engine/index.d.ts +4 -2
- package/dist/engine/index.js +2 -1
- package/dist/engine/publish.d.ts +100 -0
- package/dist/engine/publish.js +218 -0
- package/dist/engine/store.d.ts +8 -0
- package/dist/engine/store.js +8 -0
- package/dist/engine/worker-protocol.d.ts +1 -1
- package/dist/engine/worker-protocol.js +1 -0
- package/dist/lib/objects/App.d.ts +42 -1
- package/dist/lib/objects/App.js +72 -4
- package/dist/lib/objects/AppDraft.d.ts +31 -0
- package/dist/lib/objects/AppDraft.js +73 -0
- package/dist/lib/objects/AppDraftVersion.d.ts +25 -0
- package/dist/lib/objects/AppDraftVersion.js +68 -0
- package/dist/lib/objects/Approval.d.ts +4 -4
- package/dist/lib/objects/Approval.js +3 -3
- package/dist/lib/objects/index.d.ts +2 -0
- package/dist/lib/objects/index.js +2 -0
- package/dist/lib/server/approval-store.d.ts +6 -0
- package/dist/lib/server/approval-store.js +102 -0
- package/dist/lib/server/approvals.d.ts +15 -0
- package/dist/lib/server/approvals.js +62 -43
- package/dist/lib/server/builder.d.ts +18 -10
- package/dist/lib/server/builder.js +46 -18
- package/dist/lib/server/drafts.d.ts +100 -0
- package/dist/lib/server/drafts.js +324 -0
- package/dist/lib/server/events.d.ts +14 -0
- package/dist/lib/server/events.js +3 -0
- package/dist/lib/server/host.d.ts +44 -2
- package/dist/lib/server/host.js +155 -11
- package/dist/lib/server/results.d.ts +6 -0
- package/dist/lib/server/results.js +8 -0
- package/dist/lib/server/target.d.ts +24 -0
- package/dist/lib/server/target.js +64 -0
- package/dist/lib/server/version-store.d.ts +40 -1
- package/dist/lib/server/version-store.js +144 -32
- package/dist/manifest.json +699 -6
- package/dist/smrt-knowledge.okf.md +19 -2
- package/package.json +19 -15
package/dist/engine/host.js
CHANGED
|
@@ -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 () =>
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/dist/engine/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/engine/index.js
CHANGED
|
@@ -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";
|