@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
@@ -0,0 +1,100 @@
1
+ import type { AppDefinition, FieldDef, Step } from './definition.ts';
2
+ import type { VersionRecord } from './store.ts';
3
+ /** What a draft hands to a publish: its base and the versions made since, oldest first. */
4
+ export interface DraftChain {
5
+ baseVersion: number;
6
+ base: VersionRecord;
7
+ versions: VersionRecord[];
8
+ }
9
+ /** Stable JSON: object keys sorted, so equal values give equal text. */
10
+ export declare function canonical(v: unknown): string;
11
+ /**
12
+ * Identity of what a publish would apply: the live version it starts from and,
13
+ * per draft version, its summary, definition and steps. The numbers the
14
+ * versions get in live are not part of it.
15
+ */
16
+ export declare function chainDigest(liveVersion: number, chain: DraftChain): string;
17
+ export interface FieldChange {
18
+ field: string;
19
+ from: FieldDef;
20
+ to: FieldDef;
21
+ }
22
+ export interface ModelDiff {
23
+ fieldsAdded: string[];
24
+ fieldsRemoved: string[];
25
+ fieldsChanged: FieldChange[];
26
+ }
27
+ export interface DefinitionDiff {
28
+ modelsAdded: string[];
29
+ modelsRemoved: string[];
30
+ /** Per model that exists on both sides and differs. */
31
+ models: Record<string, ModelDiff>;
32
+ recipesAdded: string[];
33
+ recipesRemoved: string[];
34
+ }
35
+ /** What changes from `from` to `to`. A rename shows as a removal and an addition; the steps say it was a rename. */
36
+ export declare function diffDefinitions(from: AppDefinition, to: AppDefinition): DefinitionDiff;
37
+ export interface PublishMethod {
38
+ model: string;
39
+ name: string;
40
+ description: string;
41
+ params: Record<string, unknown>;
42
+ /** The full code: what the owner approves is what will run. */
43
+ code: string;
44
+ change: 'added' | 'changed' | 'unchanged';
45
+ }
46
+ /** Every method of `to`, in full, marked against `from`. */
47
+ export declare function listMethods(from: AppDefinition, to: AppDefinition): PublishMethod[];
48
+ /** Names of the archive tables a chain's steps create. */
49
+ export declare function archivedTables(steps: Step[]): string[];
50
+ export declare const JOURNAL_FILE = "publish-journal.json";
51
+ export declare const ASIDE = ".prepublish";
52
+ export interface PublishJournal {
53
+ format: 1;
54
+ /** Live's version before, the version the publish ends on. */
55
+ oldVersion: number;
56
+ finalVersion: number;
57
+ rehearsal: string;
58
+ /** The prepublish snapshot (a retained copy of the old data). */
59
+ snapshot: string;
60
+ startedAt: string;
61
+ }
62
+ export declare function readJournal(dataDir: string): PublishJournal | null;
63
+ /** Written whole and renamed into place, so it is either absent or complete. */
64
+ export declare function writeJournal(dataDir: string, j: PublishJournal): void;
65
+ export declare function removeJournal(dataDir: string): void;
66
+ /**
67
+ * A `.prepublish` file already there is not ours (a publish removes its own):
68
+ * restoring it later would replace live's data with it. So a publish refuses
69
+ * to start, and `restoreAside` is only ever run for asides this publish made.
70
+ */
71
+ export declare function assertNoAside(dataDir: string): void;
72
+ /**
73
+ * Rename live's database files to `<name>.prepublish`. The side files go
74
+ * first and `app.db` last, so "app.db is gone" always means "all of them are
75
+ * aside". Refuses if an aside file is already there (a leftover to look at).
76
+ */
77
+ export declare function moveAside(dataDir: string): void;
78
+ /**
79
+ * Put the `.prepublish` files back. If the main file was moved aside, whatever
80
+ * stands at the live names now belongs to the new database and is removed
81
+ * first (a foreign -wal next to the old file would be replayed into it).
82
+ * If only side files were moved, the main file never left: just rename them back.
83
+ * Safe to run at any point of the swap, and again.
84
+ */
85
+ export declare function restoreAside(dataDir: string): void;
86
+ export declare function dropAside(dataDir: string): void;
87
+ export declare function rehearsalDirName(): string;
88
+ export declare function makeRehearsalDir(dataDir: string): string;
89
+ /** Remove one rehearsal directory, only if it is one of ours (by name). */
90
+ export declare function removeRehearsal(dataDir: string, dir: string): void;
91
+ /**
92
+ * Run at every host start, before the app opens: finish or undo an interrupted
93
+ * publish. The new database was never written to before the store committed
94
+ * (the change queue is held until then), so:
95
+ * - the store already points at the published version: the publish happened,
96
+ * only the leftovers are removed;
97
+ * - otherwise the old files come back.
98
+ * Returns what it did.
99
+ */
100
+ export declare function reconcilePublish(dataDir: string, current: number | null): 'none' | 'rolled-forward' | 'rolled-back';
@@ -0,0 +1,218 @@
1
+ // Publishing a draft: the pure parts (digest, definition diff) and the file
2
+ // operations around the guarded swap of `app.db`, with their crash recovery.
3
+ //
4
+ // The swap, in order (host.ts drives it; every step has a tested failure path):
5
+ // 1. a consistent copy of live is rehearsed with the draft's changes
6
+ // 2. a snapshot of live is taken (snapshots/<ts>-0-migrate-prepublish.db)
7
+ // 3. the journal is written, the live worker is retired
8
+ // 4. live's app.db (+ -wal/-shm/-journal) is renamed to `<name>.prepublish`,
9
+ // the rehearsed file is renamed to app.db <- the crash window
10
+ // 5. the new worker starts, the versions are written to the store
11
+ // 6. the journal and the `.prepublish` files are removed
12
+ // Before step 4 nothing of live has changed; after a failure in 4 or 5 the
13
+ // `.prepublish` files are renamed back, so app.db is byte-for-byte what it was.
14
+ import { createHash } from 'node:crypto';
15
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
16
+ import { basename, dirname, join } from 'node:path';
17
+ import { EngineError } from "./errors.js";
18
+ /** Stable JSON: object keys sorted, so equal values give equal text. */
19
+ export function canonical(v) {
20
+ const norm = (x) => Array.isArray(x)
21
+ ? x.map(norm)
22
+ : x && typeof x === 'object'
23
+ ? Object.fromEntries(Object.entries(x)
24
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
25
+ .map(([k, val]) => [k, norm(val)]))
26
+ : x;
27
+ return JSON.stringify(norm(v));
28
+ }
29
+ /**
30
+ * Identity of what a publish would apply: the live version it starts from and,
31
+ * per draft version, its summary, definition and steps. The numbers the
32
+ * versions get in live are not part of it.
33
+ */
34
+ export function chainDigest(liveVersion, chain) {
35
+ return createHash('sha256')
36
+ .update(canonical({
37
+ liveVersion,
38
+ base: chain.base.definition,
39
+ versions: chain.versions.map((r) => ({
40
+ author: r.author,
41
+ summary: r.summary,
42
+ definition: r.definition,
43
+ steps: r.migration?.steps ?? [],
44
+ })),
45
+ }))
46
+ .digest('hex');
47
+ }
48
+ const keys = (o) => Object.keys(o ?? {});
49
+ /** What changes from `from` to `to`. A rename shows as a removal and an addition; the steps say it was a rename. */
50
+ export function diffDefinitions(from, to) {
51
+ const out = {
52
+ modelsAdded: keys(to.models).filter((m) => !(m in from.models)),
53
+ modelsRemoved: keys(from.models).filter((m) => !(m in to.models)),
54
+ models: {},
55
+ recipesAdded: to.recipes.filter((r) => !from.recipes.includes(r)),
56
+ recipesRemoved: from.recipes.filter((r) => !to.recipes.includes(r)),
57
+ };
58
+ for (const m of keys(to.models)) {
59
+ if (!(m in from.models))
60
+ continue;
61
+ const a = from.models[m].fields;
62
+ const b = to.models[m].fields;
63
+ const d = {
64
+ fieldsAdded: keys(b).filter((f) => !(f in a)),
65
+ fieldsRemoved: keys(a).filter((f) => !(f in b)),
66
+ fieldsChanged: keys(b)
67
+ .filter((f) => f in a && canonical(a[f]) !== canonical(b[f]))
68
+ .map((f) => ({ field: f, from: a[f], to: b[f] })),
69
+ };
70
+ if (d.fieldsAdded.length ||
71
+ d.fieldsRemoved.length ||
72
+ d.fieldsChanged.length)
73
+ out.models[m] = d;
74
+ }
75
+ return out;
76
+ }
77
+ /** Every method of `to`, in full, marked against `from`. */
78
+ export function listMethods(from, to) {
79
+ const out = [];
80
+ for (const [model, md] of Object.entries(to.models))
81
+ for (const [name, meth] of Object.entries(md.methods ?? {})) {
82
+ const before = from.models[model]?.methods?.[name];
83
+ out.push({
84
+ model,
85
+ name,
86
+ description: meth.description,
87
+ params: meth.params ?? {},
88
+ code: meth.code,
89
+ change: !before
90
+ ? 'added'
91
+ : canonical(before) === canonical(meth)
92
+ ? 'unchanged'
93
+ : 'changed',
94
+ });
95
+ }
96
+ return out;
97
+ }
98
+ /** Names of the archive tables a chain's steps create. */
99
+ export function archivedTables(steps) {
100
+ return steps.flatMap((s) => (s.op === 'archiveTable' ? [s.archivedAs] : []));
101
+ }
102
+ // ---------- the journal and the file swap ----------
103
+ export const JOURNAL_FILE = 'publish-journal.json';
104
+ export const ASIDE = '.prepublish';
105
+ /** The files that make up a SQLite database, main file LAST (see {@link moveAside}). */
106
+ const DB_PARTS = ['app.db-journal', 'app.db-shm', 'app.db-wal', 'app.db'];
107
+ const REHEARSAL = /^publish-rehearsal-[A-Za-z0-9]+$/;
108
+ export function readJournal(dataDir) {
109
+ const file = join(dataDir, JOURNAL_FILE);
110
+ if (!existsSync(file))
111
+ return null;
112
+ try {
113
+ const j = JSON.parse(readFileSync(file, 'utf8'));
114
+ if (j?.format !== 1 ||
115
+ !Number.isInteger(j.oldVersion) ||
116
+ !Number.isInteger(j.finalVersion))
117
+ throw new Error('bad shape');
118
+ return j;
119
+ }
120
+ catch {
121
+ // Guessing about a half-finished swap could lose data: refuse to start.
122
+ throw new EngineError(`${JOURNAL_FILE} in ${dataDir} is unreadable, so an interrupted publish cannot be recovered automatically; restore app.db from the .prepublish files or the newest migrate-prepublish snapshot`, 'refused');
123
+ }
124
+ }
125
+ /** Written whole and renamed into place, so it is either absent or complete. */
126
+ export function writeJournal(dataDir, j) {
127
+ const tmp = join(dataDir, `${JOURNAL_FILE}.tmp`);
128
+ writeFileSync(tmp, JSON.stringify(j));
129
+ renameSync(tmp, join(dataDir, JOURNAL_FILE));
130
+ }
131
+ export function removeJournal(dataDir) {
132
+ rmSync(join(dataDir, JOURNAL_FILE), { force: true });
133
+ rmSync(join(dataDir, `${JOURNAL_FILE}.tmp`), { force: true });
134
+ }
135
+ /**
136
+ * A `.prepublish` file already there is not ours (a publish removes its own):
137
+ * restoring it later would replace live's data with it. So a publish refuses
138
+ * to start, and `restoreAside` is only ever run for asides this publish made.
139
+ */
140
+ export function assertNoAside(dataDir) {
141
+ for (const n of DB_PARTS)
142
+ if (existsSync(join(dataDir, n + ASIDE)))
143
+ throw new EngineError(`${n + ASIDE} is already there from an earlier publish; not overwriting it`, 'refused');
144
+ }
145
+ /**
146
+ * Rename live's database files to `<name>.prepublish`. The side files go
147
+ * first and `app.db` last, so "app.db is gone" always means "all of them are
148
+ * aside". Refuses if an aside file is already there (a leftover to look at).
149
+ */
150
+ export function moveAside(dataDir) {
151
+ assertNoAside(dataDir);
152
+ for (const n of DB_PARTS)
153
+ if (existsSync(join(dataDir, n)))
154
+ renameSync(join(dataDir, n), join(dataDir, n + ASIDE));
155
+ }
156
+ /**
157
+ * Put the `.prepublish` files back. If the main file was moved aside, whatever
158
+ * stands at the live names now belongs to the new database and is removed
159
+ * first (a foreign -wal next to the old file would be replayed into it).
160
+ * If only side files were moved, the main file never left: just rename them back.
161
+ * Safe to run at any point of the swap, and again.
162
+ */
163
+ export function restoreAside(dataDir) {
164
+ if (existsSync(join(dataDir, `app.db${ASIDE}`)))
165
+ for (const n of DB_PARTS)
166
+ rmSync(join(dataDir, n), { force: true });
167
+ for (const n of DB_PARTS)
168
+ if (existsSync(join(dataDir, n + ASIDE)))
169
+ renameSync(join(dataDir, n + ASIDE), join(dataDir, n));
170
+ }
171
+ export function dropAside(dataDir) {
172
+ for (const n of DB_PARTS)
173
+ rmSync(join(dataDir, n + ASIDE), { force: true });
174
+ }
175
+ export function rehearsalDirName() {
176
+ return `publish-rehearsal-${createHash('sha1')
177
+ .update(`${process.hrtime.bigint()}${Math.random()}`)
178
+ .digest('hex')
179
+ .slice(0, 10)}`;
180
+ }
181
+ export function makeRehearsalDir(dataDir) {
182
+ const dir = join(dataDir, rehearsalDirName());
183
+ mkdirSync(join(dir, 'final'), { recursive: true });
184
+ return dir;
185
+ }
186
+ /** Remove one rehearsal directory, only if it is one of ours (by name). */
187
+ export function removeRehearsal(dataDir, dir) {
188
+ if (dirname(dir) === dataDir && REHEARSAL.test(basename(dir)))
189
+ rmSync(dir, { recursive: true, force: true });
190
+ }
191
+ /**
192
+ * Run at every host start, before the app opens: finish or undo an interrupted
193
+ * publish. The new database was never written to before the store committed
194
+ * (the change queue is held until then), so:
195
+ * - the store already points at the published version: the publish happened,
196
+ * only the leftovers are removed;
197
+ * - otherwise the old files come back.
198
+ * Returns what it did.
199
+ */
200
+ export function reconcilePublish(dataDir, current) {
201
+ const journal = readJournal(dataDir);
202
+ let did = 'none';
203
+ if (journal) {
204
+ if (current === journal.finalVersion) {
205
+ dropAside(dataDir);
206
+ did = 'rolled-forward';
207
+ }
208
+ else {
209
+ restoreAside(dataDir);
210
+ did = 'rolled-back';
211
+ }
212
+ removeJournal(dataDir);
213
+ }
214
+ for (const e of readdirSync(dataDir, { withFileTypes: true }))
215
+ if (e.isDirectory() && REHEARSAL.test(e.name))
216
+ rmSync(join(dataDir, e.name), { recursive: true, force: true });
217
+ return did;
218
+ }
@@ -25,6 +25,13 @@ export interface VersionStore {
25
25
  markPurged(version: number): Promise<void>;
26
26
  /** Highest version number held, or -1 when empty. */
27
27
  maxVersion(): Promise<number>;
28
+ /**
29
+ * Optional: append `records` and make `current` the current version as ONE
30
+ * atomic change (all or nothing). A publish uses it when present; without it
31
+ * the host appends one by one and a failure part-way leaves the appended
32
+ * records behind as unreachable history (they are never current).
33
+ */
34
+ commit?(records: VersionRecord[], current: number): Promise<void>;
28
35
  }
29
36
  export declare class MemoryVersionStore implements VersionStore {
30
37
  private cur;
@@ -36,4 +43,5 @@ export declare class MemoryVersionStore implements VersionStore {
36
43
  append(record: VersionRecord): Promise<void>;
37
44
  markPurged(version: number): Promise<void>;
38
45
  maxVersion(): Promise<number>;
46
+ commit(records: VersionRecord[], current: number): Promise<void>;
39
47
  }
@@ -29,4 +29,12 @@ export class MemoryVersionStore {
29
29
  async maxVersion() {
30
30
  return this.records.size ? Math.max(...this.records.keys()) : -1;
31
31
  }
32
+ async commit(records, current) {
33
+ for (const r of records)
34
+ if (this.records.has(r.version))
35
+ throw new Error(`version ${r.version} already exists`);
36
+ for (const r of records)
37
+ this.records.set(r.version, structuredClone(r));
38
+ this.cur = current;
39
+ }
32
40
  }
@@ -1,4 +1,4 @@
1
- export declare const WORKER_OPS: readonly ["list", "get", "create", "update", "remove", "runMethod", "tools", "callTool", "rest", "snapshot", "previewConversion", "countValues", "countRows", "countArchives", "countOverwrites", "pruneSnapshots", "purgeArchives", "importRecords"];
1
+ export declare const WORKER_OPS: readonly ["list", "get", "create", "update", "remove", "runMethod", "tools", "callTool", "rest", "snapshot", "copyTo", "previewConversion", "countValues", "countRows", "countArchives", "countOverwrites", "pruneSnapshots", "purgeArchives", "importRecords"];
2
2
  export type WorkerOp = (typeof WORKER_OPS)[number];
3
3
  export type WorkerCall = {
4
4
  id: number;
@@ -9,6 +9,7 @@ export const WORKER_OPS = [
9
9
  'callTool',
10
10
  'rest',
11
11
  'snapshot',
12
+ 'copyTo',
12
13
  'previewConversion',
13
14
  'countValues',
14
15
  'countRows',
@@ -45,15 +45,56 @@ export declare class App extends SmrtObject {
45
45
  static exportTemplate(includeRecords?: boolean): Promise<BuilderResult>;
46
46
  /** Import a template, replacing the definition (risky if it has code). */
47
47
  static importTemplate(data: Record<string, unknown>): Promise<BuilderResult>;
48
+ /**
49
+ * Undo every change after `version` in one step (the history leading to now).
50
+ * Waits for the owner's approval when any of them converted a field's type.
51
+ * "Undo this publish": pass the version before the publish's first one.
52
+ */
53
+ static undoTo(version: number): Promise<BuilderResult>;
54
+ /**
55
+ * Start a draft: a test copy of the app (with its records) to build in while
56
+ * the live app keeps working. Live structure is frozen until the draft is
57
+ * published or discarded. One draft at a time.
58
+ */
59
+ static startDraft(name?: string): Promise<import("../server/drafts.ts").DraftInfo>;
60
+ /**
61
+ * Preview publishing the open draft: what changes, the SQL, every method's
62
+ * code, and a digest. Read only.
63
+ */
64
+ static previewPublish(): Promise<import("../../engine/host.ts").PublishPreview>;
65
+ /**
66
+ * Publish the open draft's changes to the live app. ALWAYS waits for the
67
+ * owner's approval (`pending_approval`); it applies only if the draft is
68
+ * unchanged since this preview. Pass `expect` (the preview's digest) to
69
+ * refuse a draft that changed since you looked.
70
+ */
71
+ static publishDraft(expect?: string): Promise<import("../server/approvals.ts").PendingApproval>;
72
+ /** Throw the open draft away (waits for the owner's approval). The live app is not changed. */
73
+ static discardDraft(): Promise<import("../server/approvals.ts").PendingApproval>;
48
74
  /** The models, fields and methods of the app now. */
49
75
  static describe(): Promise<{
50
76
  name: string;
51
77
  version: number;
52
78
  recipes: string[];
53
79
  models: Record<string, import("../../engine/definition.ts").DescribedModel>;
80
+ mode: "live" | "draft";
81
+ draft: import("../server/drafts.ts").DraftInfo | null;
82
+ frozen: boolean;
54
83
  }>;
55
84
  /** Every version of the app, newest last. */
56
- static history(): Promise<import("../../engine/host.ts").VersionSummary[]>;
85
+ static history(): Promise<{
86
+ publish: {
87
+ draftId: string;
88
+ name: string;
89
+ } | null;
90
+ version: number;
91
+ parent: number | null;
92
+ author: string;
93
+ summary: string;
94
+ createdAt: string;
95
+ purged: boolean;
96
+ current: boolean;
97
+ }[]>;
57
98
  }
58
99
  export declare class AppCollection extends SmrtCollection<App> {
59
100
  static readonly _itemClass: typeof App;
@@ -7,8 +7,9 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
7
7
  import { field, SmrtCollection, SmrtObject, smrt, } from '@happyvertical/smrt-core';
8
8
  import { EngineError, } from "../../engine/index.js";
9
9
  import { runCommand } from "../server/builder.js";
10
- import { getHost } from "../server/host.js";
10
+ import { describeCopy, historyFor, previewDraft, requestDiscard, requestPublish, startDraft, } from "../server/drafts.js";
11
11
  import { assertOwner } from "../server/owner.js";
12
+ import { currentScope } from "../server/target.js";
12
13
  // The decorator config must be a literal: the smrt() scanner reads it
13
14
  // statically (a shared constant would be invisible to it and every generated
14
15
  // surface would default to full CRUD). The collection repeats it exactly:
@@ -42,9 +43,13 @@ function str(value, what) {
42
43
  return value;
43
44
  }
44
45
  const FIELD_EXAMPLE = "{type: 'integer'}";
46
+ /**
47
+ * Run a builder command on the copy the REQUEST named (`withScope`, entered by
48
+ * the REST route or the tool dispatch; all-live when nothing entered it).
49
+ */
45
50
  async function run(command) {
46
51
  const owner = await assertOwner();
47
- return runCommand(command, owner.id);
52
+ return runCommand(command, owner.id, currentScope().structure);
48
53
  }
49
54
  /**
50
55
  * The shell's one app: a singleton row holding the current definition. Its
@@ -166,15 +171,58 @@ let App = class App extends SmrtObject {
166
171
  data: obj(data, 'data', '{format: 1, definition: {...}}'),
167
172
  });
168
173
  }
174
+ /**
175
+ * Undo every change after `version` in one step (the history leading to now).
176
+ * Waits for the owner's approval when any of them converted a field's type.
177
+ * "Undo this publish": pass the version before the publish's first one.
178
+ */
179
+ static async undoTo(version) {
180
+ const n = Number(version);
181
+ if (!Number.isInteger(n))
182
+ throw new EngineError('version must be a whole number', 'invalid');
183
+ return run({ type: 'undoTo', version: n });
184
+ }
185
+ /**
186
+ * Start a draft: a test copy of the app (with its records) to build in while
187
+ * the live app keeps working. Live structure is frozen until the draft is
188
+ * published or discarded. One draft at a time.
189
+ */
190
+ static async startDraft(name) {
191
+ const owner = await assertOwner();
192
+ return startDraft(name, owner.id);
193
+ }
194
+ /**
195
+ * Preview publishing the open draft: what changes, the SQL, every method's
196
+ * code, and a digest. Read only.
197
+ */
198
+ static async previewPublish() {
199
+ await assertOwner();
200
+ return (await previewDraft()).preview;
201
+ }
202
+ /**
203
+ * Publish the open draft's changes to the live app. ALWAYS waits for the
204
+ * owner's approval (`pending_approval`); it applies only if the draft is
205
+ * unchanged since this preview. Pass `expect` (the preview's digest) to
206
+ * refuse a draft that changed since you looked.
207
+ */
208
+ static async publishDraft(expect) {
209
+ const owner = await assertOwner();
210
+ return requestPublish(expect, owner.id);
211
+ }
212
+ /** Throw the open draft away (waits for the owner's approval). The live app is not changed. */
213
+ static async discardDraft() {
214
+ const owner = await assertOwner();
215
+ return requestDiscard(owner.id);
216
+ }
169
217
  /** The models, fields and methods of the app now. */
170
218
  static async describe() {
171
219
  await assertOwner();
172
- return (await getHost()).describe();
220
+ return describeCopy(currentScope().structure);
173
221
  }
174
222
  /** Every version of the app, newest last. */
175
223
  static async history() {
176
224
  await assertOwner();
177
- return (await getHost()).versions();
225
+ return historyFor(currentScope().structure);
178
226
  }
179
227
  };
180
228
  __decorate([
@@ -207,6 +255,11 @@ App = __decorate([
207
255
  'importTemplate',
208
256
  'describe',
209
257
  'history',
258
+ 'undoTo',
259
+ 'startDraft',
260
+ 'previewPublish',
261
+ 'publishDraft',
262
+ 'discardDraft',
210
263
  ],
211
264
  },
212
265
  mcp: {
@@ -226,6 +279,11 @@ App = __decorate([
226
279
  'importTemplate',
227
280
  'describe',
228
281
  'history',
282
+ 'undoTo',
283
+ 'startDraft',
284
+ 'previewPublish',
285
+ 'publishDraft',
286
+ 'discardDraft',
229
287
  ],
230
288
  },
231
289
  cli: {
@@ -245,6 +303,11 @@ App = __decorate([
245
303
  'importTemplate',
246
304
  'describe',
247
305
  'history',
306
+ 'undoTo',
307
+ 'startDraft',
308
+ 'previewPublish',
309
+ 'publishDraft',
310
+ 'discardDraft',
248
311
  ],
249
312
  },
250
313
  operationPermissions: {
@@ -263,6 +326,11 @@ App = __decorate([
263
326
  importTemplate: { action: 'update' },
264
327
  describe: { action: 'read' },
265
328
  history: { action: 'read' },
329
+ undoTo: { action: 'update' },
330
+ startDraft: { action: 'update' },
331
+ previewPublish: { action: 'read' },
332
+ publishDraft: { action: 'update' },
333
+ discardDraft: { action: 'update' },
266
334
  },
267
335
  })
268
336
  ], App);
@@ -0,0 +1,31 @@
1
+ import { SmrtCollection, SmrtObject } from '@happyvertical/smrt-core';
2
+ export type AppDraftStatus = 'starting' | 'open' | 'published' | 'discarded';
3
+ /**
4
+ * One draft of the user app (shell.db): a test copy the owner builds in while
5
+ * the live app keeps serving records. At most one is `starting` or `open` at a
6
+ * time. Rows are never deleted: a draft ends as `published` (with the live
7
+ * versions it became) or `discarded`. The copy itself is a directory the
8
+ * engine made (`<data root>/user-app-draft`); this row is the shell's record
9
+ * of it. Written only by `drafts.ts` and the version stores; no generated
10
+ * surface.
11
+ */
12
+ export declare class AppDraft extends SmrtObject {
13
+ name: string;
14
+ /** `starting` while the copy is made, `open`, then `published` or `discarded`. */
15
+ status: AppDraftStatus;
16
+ /** The live version the draft was copied from, `-1` until it is. */
17
+ baseVersion: number;
18
+ /** The draft's own current version (its store's pointer), `-1` for none yet. */
19
+ currentVersion: number;
20
+ createdBy: string;
21
+ createdAt: string;
22
+ /** When it was published or discarded. */
23
+ endedAt: string;
24
+ /** The live version numbers a publish created, JSON array text. */
25
+ publishedVersions: string;
26
+ /** Why it ended without publishing ("start failed", "discarded"), if worth saying. */
27
+ note: string;
28
+ }
29
+ export declare class AppDraftCollection extends SmrtCollection<AppDraft> {
30
+ static readonly _itemClass: typeof AppDraft;
31
+ }
@@ -0,0 +1,73 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ import { field, SmrtCollection, SmrtObject, smrt, } from '@happyvertical/smrt-core';
8
+ /**
9
+ * One draft of the user app (shell.db): a test copy the owner builds in while
10
+ * the live app keeps serving records. At most one is `starting` or `open` at a
11
+ * time. Rows are never deleted: a draft ends as `published` (with the live
12
+ * versions it became) or `discarded`. The copy itself is a directory the
13
+ * engine made (`<data root>/user-app-draft`); this row is the shell's record
14
+ * of it. Written only by `drafts.ts` and the version stores; no generated
15
+ * surface.
16
+ */
17
+ let AppDraft = class AppDraft extends SmrtObject {
18
+ name = 'Draft';
19
+ /** `starting` while the copy is made, `open`, then `published` or `discarded`. */
20
+ status = 'starting';
21
+ /** The live version the draft was copied from, `-1` until it is. */
22
+ baseVersion = -1;
23
+ /** The draft's own current version (its store's pointer), `-1` for none yet. */
24
+ currentVersion = -1;
25
+ createdBy = '';
26
+ createdAt = '';
27
+ /** When it was published or discarded. */
28
+ endedAt = '';
29
+ /** The live version numbers a publish created, JSON array text. */
30
+ publishedVersions = '';
31
+ /** Why it ended without publishing ("start failed", "discarded"), if worth saying. */
32
+ note = '';
33
+ };
34
+ __decorate([
35
+ field({ type: 'text', required: true })
36
+ ], AppDraft.prototype, "name", void 0);
37
+ __decorate([
38
+ field({ type: 'text' })
39
+ ], AppDraft.prototype, "status", void 0);
40
+ __decorate([
41
+ field({ type: 'integer' })
42
+ ], AppDraft.prototype, "baseVersion", void 0);
43
+ __decorate([
44
+ field({ type: 'integer' })
45
+ ], AppDraft.prototype, "currentVersion", void 0);
46
+ __decorate([
47
+ field({ type: 'text' })
48
+ ], AppDraft.prototype, "createdBy", void 0);
49
+ __decorate([
50
+ field({ type: 'text' })
51
+ ], AppDraft.prototype, "createdAt", void 0);
52
+ __decorate([
53
+ field({ type: 'text' })
54
+ ], AppDraft.prototype, "endedAt", void 0);
55
+ __decorate([
56
+ field({ type: 'text' })
57
+ ], AppDraft.prototype, "publishedVersions", void 0);
58
+ __decorate([
59
+ field({ type: 'text' })
60
+ ], AppDraft.prototype, "note", void 0);
61
+ AppDraft = __decorate([
62
+ smrt({
63
+ tableName: 'app_drafts',
64
+ api: false,
65
+ mcp: false,
66
+ cli: false,
67
+ conflictColumns: ['id'],
68
+ })
69
+ ], AppDraft);
70
+ export { AppDraft };
71
+ export class AppDraftCollection extends SmrtCollection {
72
+ static _itemClass = AppDraft;
73
+ }