@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/lib/server/host.js
CHANGED
|
@@ -1,21 +1,32 @@
|
|
|
1
1
|
// Process-wide holders: where the shell gets its database, its data root and
|
|
2
|
-
// the
|
|
2
|
+
// the app hosts (the user app's runtime). Everything server-side reaches
|
|
3
3
|
// them through here, so tests can configure a temp directory and a SQLite
|
|
4
4
|
// database without SvelteKit.
|
|
5
5
|
//
|
|
6
|
-
// Data layout: the host's `dataDir` is `<data root>/user-app`, a plain
|
|
6
|
+
// Data layout: the live host's `dataDir` is `<data root>/user-app`, a plain
|
|
7
7
|
// directory inside the app-runtime data root, so the encrypted backup that
|
|
8
|
-
// seals the root also seals app.db, snapshots and archives. No symlinks.
|
|
8
|
+
// seals the root also seals app.db, snapshots and archives. No symlinks. An
|
|
9
|
+
// open draft is a second host over `<data root>/user-app-draft`, made and
|
|
10
|
+
// removed only by the engine (`forkDraft` / `discard`).
|
|
11
|
+
//
|
|
12
|
+
// TWO holders, never a "current" one: `getHost()` is the LIVE host, always;
|
|
13
|
+
// the draft host is reachable only through `hostFor('draft')` /
|
|
14
|
+
// `getDraftSlot()`, which callers reach by naming the copy (see target.ts).
|
|
9
15
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
10
16
|
import { join } from 'node:path';
|
|
11
17
|
import { fileURLToPath } from 'node:url';
|
|
12
|
-
import { AppHost } from "../../engine/index.js";
|
|
18
|
+
import { AppHost, discardDraftDirectory, EngineError, } from "../../engine/index.js";
|
|
19
|
+
import { AppDraftCollection } from "../objects/AppDraft.js";
|
|
13
20
|
import { publish } from "./events.js";
|
|
14
|
-
import { ShellVersionStore } from "./version-store.js";
|
|
21
|
+
import { DraftVersionStore, ShellVersionStore } from "./version-store.js";
|
|
22
|
+
/** Where the open draft's copy lives under the data root. */
|
|
23
|
+
export const DRAFT_DIRNAME = 'user-app-draft';
|
|
15
24
|
let context = null;
|
|
16
25
|
let syncDb = null;
|
|
17
26
|
let hostPromise = null;
|
|
18
27
|
let hostRef = null;
|
|
28
|
+
let storeRef = null;
|
|
29
|
+
let slotRef = null;
|
|
19
30
|
let shutdownHooked = false;
|
|
20
31
|
export function configureShell(next) {
|
|
21
32
|
context = next;
|
|
@@ -30,6 +41,11 @@ export async function getShellDb() {
|
|
|
30
41
|
syncDb = db;
|
|
31
42
|
return db;
|
|
32
43
|
}
|
|
44
|
+
/** The runtime's secrets master key, or null when it supplies none. */
|
|
45
|
+
export async function getShellSecretMasterKey() {
|
|
46
|
+
const provide = requireContext().secretMasterKey;
|
|
47
|
+
return provide ? provide() : null;
|
|
48
|
+
}
|
|
33
49
|
/** The shell database once `getShellDb()` has resolved. For sync thunks. */
|
|
34
50
|
export function shellDbSync() {
|
|
35
51
|
if (!syncDb)
|
|
@@ -50,18 +66,112 @@ function workerUrl() {
|
|
|
50
66
|
return local;
|
|
51
67
|
return new URL(import.meta.resolve('@happyvertical/smrt-app-shell/engine/worker'));
|
|
52
68
|
}
|
|
53
|
-
/**
|
|
69
|
+
/** Republish a host's events on the bus, tagged with the copy they came from. */
|
|
70
|
+
export function wireHostEvents(host, target) {
|
|
71
|
+
host.on('changed', (e) => publish({
|
|
72
|
+
type: 'changed',
|
|
73
|
+
target,
|
|
74
|
+
version: e.version,
|
|
75
|
+
summary: e.summary,
|
|
76
|
+
}));
|
|
77
|
+
host.on('records', (e) => publish({ type: 'records', target, model: e.model }));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Open a draft's host. `autoApprove` (risky changes need no approval) is the
|
|
81
|
+
* draft's alone: the engine refuses it unless `dir` holds its draft marker, so
|
|
82
|
+
* this call can never hand it to the live host.
|
|
83
|
+
*/
|
|
84
|
+
export function openDraftHost(db, id, dir) {
|
|
85
|
+
return AppHost.open(dir, {
|
|
86
|
+
store: new DraftVersionStore(db, id),
|
|
87
|
+
workerUrl: workerUrl(),
|
|
88
|
+
autoApprove: true,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* After the live host is open: settle drafts a restart interrupted, and if one
|
|
93
|
+
* is open, FREEZE live structure again (the freeze is not persisted) and
|
|
94
|
+
* reopen the draft host. A draft whose copy cannot be opened still counts as
|
|
95
|
+
* open (live stays frozen) with `problem` saying why; the owner discards it.
|
|
96
|
+
*/
|
|
97
|
+
async function adoptDraft(db, live, root) {
|
|
98
|
+
const dir = join(root, DRAFT_DIRNAME);
|
|
99
|
+
const drafts = await AppDraftCollection.create({ db });
|
|
100
|
+
const now = new Date().toISOString();
|
|
101
|
+
const rows = await drafts.list({ limit: 1000 });
|
|
102
|
+
for (const row of rows.filter((r) => r.status === 'starting')) {
|
|
103
|
+
row.status = 'discarded';
|
|
104
|
+
row.note = 'the start did not finish';
|
|
105
|
+
row.endedAt = now;
|
|
106
|
+
await row.save();
|
|
107
|
+
}
|
|
108
|
+
const open = rows
|
|
109
|
+
.filter((r) => r.status === 'open')
|
|
110
|
+
.sort((a, b) => (String(a.createdAt) < String(b.createdAt) ? 1 : -1));
|
|
111
|
+
for (const extra of open.slice(1)) {
|
|
112
|
+
extra.status = 'discarded';
|
|
113
|
+
extra.note = 'another draft was open';
|
|
114
|
+
extra.endedAt = now;
|
|
115
|
+
await extra.save();
|
|
116
|
+
}
|
|
117
|
+
const row = open[0];
|
|
118
|
+
if (!row) {
|
|
119
|
+
// A copy nothing refers to any more (a crash after a publish or discard
|
|
120
|
+
// was recorded): the engine removes it, and only if it is one of its own.
|
|
121
|
+
if (existsSync(dir))
|
|
122
|
+
try {
|
|
123
|
+
discardDraftDirectory(dir);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
// not removable (no marker, a symlink): left alone, never forced
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
live.setFrozen(true);
|
|
131
|
+
const slot = {
|
|
132
|
+
id: row.id,
|
|
133
|
+
name: row.name,
|
|
134
|
+
dir,
|
|
135
|
+
host: null,
|
|
136
|
+
problem: null,
|
|
137
|
+
};
|
|
138
|
+
slotRef = slot;
|
|
139
|
+
if (!existsSync(join(dir, 'app.db'))) {
|
|
140
|
+
slot.problem = 'the draft copy is missing from the data folder';
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
slot.host = await openDraftHost(db, slot.id, dir);
|
|
145
|
+
wireHostEvents(slot.host, 'draft');
|
|
146
|
+
}
|
|
147
|
+
catch (e) {
|
|
148
|
+
slot.problem = `the draft cannot be opened: ${e instanceof Error ? e.message : String(e)}`;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** The LIVE host, opened on first use. It is never the draft. */
|
|
54
152
|
export function getHost() {
|
|
55
153
|
hostPromise ??= (async () => {
|
|
56
154
|
const db = await getShellDb();
|
|
57
|
-
const
|
|
155
|
+
const root = await dataRoot();
|
|
156
|
+
const dir = join(root, 'user-app');
|
|
58
157
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
158
|
+
const store = new ShellVersionStore(db);
|
|
59
159
|
const host = await AppHost.open(dir, {
|
|
60
|
-
store
|
|
160
|
+
store,
|
|
61
161
|
workerUrl: workerUrl(),
|
|
62
162
|
});
|
|
63
|
-
host
|
|
64
|
-
|
|
163
|
+
wireHostEvents(host, 'live');
|
|
164
|
+
try {
|
|
165
|
+
await adoptDraft(db, host, root);
|
|
166
|
+
}
|
|
167
|
+
catch (e) {
|
|
168
|
+
// Never serve live thawed when we could not tell whether a draft is
|
|
169
|
+
// open: fail the open; the next call retries.
|
|
170
|
+
slotRef = null;
|
|
171
|
+
await host.close().catch(() => { });
|
|
172
|
+
throw e;
|
|
173
|
+
}
|
|
174
|
+
storeRef = store;
|
|
65
175
|
hostRef = host;
|
|
66
176
|
if (!shutdownHooked) {
|
|
67
177
|
shutdownHooked = true;
|
|
@@ -77,14 +187,48 @@ export function getHost() {
|
|
|
77
187
|
});
|
|
78
188
|
return hostPromise;
|
|
79
189
|
}
|
|
80
|
-
/** The host
|
|
190
|
+
/** The live host's version store (a publish names its draft on it). */
|
|
191
|
+
export async function getLiveStore() {
|
|
192
|
+
await getHost();
|
|
193
|
+
return storeRef;
|
|
194
|
+
}
|
|
195
|
+
/** The live host if it is already open (sync, for list_changed wiring). */
|
|
81
196
|
export function openHost() {
|
|
82
197
|
return hostRef;
|
|
83
198
|
}
|
|
199
|
+
/** The open draft (live is open too), or null when there is none. */
|
|
200
|
+
export async function getDraftSlot() {
|
|
201
|
+
await getHost();
|
|
202
|
+
return slotRef;
|
|
203
|
+
}
|
|
204
|
+
/** drafts.ts only: record the draft that just opened, or null once it is gone. */
|
|
205
|
+
export function setDraftSlot(slot) {
|
|
206
|
+
slotRef = slot;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* The host for a copy the REQUEST named. `live` is always the live host;
|
|
210
|
+
* `draft` is the draft host or a clear refusal, never live.
|
|
211
|
+
*/
|
|
212
|
+
export async function hostFor(target) {
|
|
213
|
+
const live = await getHost();
|
|
214
|
+
if (target === 'live')
|
|
215
|
+
return live;
|
|
216
|
+
const slot = slotRef;
|
|
217
|
+
if (!slot)
|
|
218
|
+
throw new EngineError('there is no draft open; start a draft first (or use the live app)', 'not_found');
|
|
219
|
+
if (!slot.host)
|
|
220
|
+
throw new EngineError(`the draft "${slot.name}" cannot be used: ${slot.problem ?? 'it is not open'}. Discard it to carry on.`, 'refused');
|
|
221
|
+
return slot.host;
|
|
222
|
+
}
|
|
84
223
|
export async function closeHost() {
|
|
85
224
|
const pending = hostPromise;
|
|
225
|
+
const slot = slotRef;
|
|
86
226
|
hostPromise = null;
|
|
87
227
|
hostRef = null;
|
|
228
|
+
storeRef = null;
|
|
229
|
+
slotRef = null;
|
|
230
|
+
if (slot?.host)
|
|
231
|
+
await slot.host.close().catch(() => { });
|
|
88
232
|
if (pending)
|
|
89
233
|
await (await pending.catch(() => null))?.close();
|
|
90
234
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ExecuteResult } from '../../engine/index.ts';
|
|
2
|
+
type PublicResult<T> = T extends unknown ? Omit<T, 'snapshot'> : never;
|
|
3
|
+
export type PublicExecuteResult = PublicResult<ExecuteResult>;
|
|
4
|
+
/** Snapshot files are engine recovery state, never part of a browser response. */
|
|
5
|
+
export declare function publicChangeResult<T>(value: T): PublicResult<T>;
|
|
6
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Snapshot files are engine recovery state, never part of a browser response. */
|
|
2
|
+
export function publicChangeResult(value) {
|
|
3
|
+
if (value && typeof value === 'object' && 'snapshot' in value) {
|
|
4
|
+
const { snapshot: _snapshot, ...shown } = value;
|
|
5
|
+
return shown;
|
|
6
|
+
}
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const TARGETS: readonly ["live", "draft"];
|
|
2
|
+
export type Target = (typeof TARGETS)[number];
|
|
3
|
+
export declare function isTarget(value: unknown): value is Target;
|
|
4
|
+
export interface CallScope {
|
|
5
|
+
/** Copy for record operations: app tools, app REST, record routes. */
|
|
6
|
+
records: Target;
|
|
7
|
+
/** Copy for builder operations: define/add/remove/undo/describe/history. */
|
|
8
|
+
structure: Target;
|
|
9
|
+
}
|
|
10
|
+
export declare const LIVE_SCOPE: Readonly<CallScope>;
|
|
11
|
+
/** Both halves on one copy. */
|
|
12
|
+
export declare function scopeFor(target: Target): CallScope;
|
|
13
|
+
/**
|
|
14
|
+
* A request's target value: absent means `live`; anything but `live` or
|
|
15
|
+
* `draft` is a 400, never a silent fallback to some other copy.
|
|
16
|
+
*/
|
|
17
|
+
export declare function parseTarget(value: unknown): Target;
|
|
18
|
+
/** Which scope one /mcp URL asks for: `?target=live|draft`, `?scope=assistant`, or none (live). */
|
|
19
|
+
export type McpScopeKey = Target | 'assistant';
|
|
20
|
+
export declare function mcpScopeKey(params: URLSearchParams): McpScopeKey;
|
|
21
|
+
/** Run `fn` with `scope` for the App model's builder methods. */
|
|
22
|
+
export declare function withScope<T>(scope: CallScope, fn: () => Promise<T>): Promise<T>;
|
|
23
|
+
/** The scope entered by the request entry, or all-live. */
|
|
24
|
+
export declare function currentScope(): CallScope;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Which copy of the app a request is about: `live` or `draft`.
|
|
2
|
+
//
|
|
3
|
+
// There is NO ambient "current host". Every entry (a REST route, an MCP
|
|
4
|
+
// request, an assistant tool call) reads the copy from the request itself,
|
|
5
|
+
// builds a {@link CallScope} and passes it down. The default is `live`, the
|
|
6
|
+
// strict side: the approval gate is on, and while a draft is open its
|
|
7
|
+
// structure is frozen. Only `hostFor('draft')` can reach the draft host, and
|
|
8
|
+
// only a request that said `draft` gets there.
|
|
9
|
+
//
|
|
10
|
+
// A scope has two halves because the person may look at the live records while
|
|
11
|
+
// the assistant builds in the draft: `records` is the copy for record
|
|
12
|
+
// operations (app tools, app REST, reports, record routes), `structure` the
|
|
13
|
+
// copy for builder operations (define, add, remove, undo, describe, history).
|
|
14
|
+
//
|
|
15
|
+
// The one piece of async-scoped state is the scope handed to the App model's
|
|
16
|
+
// builder methods (they are static methods with fixed signatures that smrt
|
|
17
|
+
// generates REST and MCP from). It is entered only by `withScope`, at the
|
|
18
|
+
// request entries, and reads as `live` when nothing entered it.
|
|
19
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
20
|
+
import { EngineError } from "../../engine/index.js";
|
|
21
|
+
export const TARGETS = ['live', 'draft'];
|
|
22
|
+
export function isTarget(value) {
|
|
23
|
+
return value === 'live' || value === 'draft';
|
|
24
|
+
}
|
|
25
|
+
export const LIVE_SCOPE = Object.freeze({
|
|
26
|
+
records: 'live',
|
|
27
|
+
structure: 'live',
|
|
28
|
+
});
|
|
29
|
+
/** Both halves on one copy. */
|
|
30
|
+
export function scopeFor(target) {
|
|
31
|
+
return { records: target, structure: target };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* A request's target value: absent means `live`; anything but `live` or
|
|
35
|
+
* `draft` is a 400, never a silent fallback to some other copy.
|
|
36
|
+
*/
|
|
37
|
+
export function parseTarget(value) {
|
|
38
|
+
if (value === undefined || value === null || value === '')
|
|
39
|
+
return 'live';
|
|
40
|
+
if (isTarget(value))
|
|
41
|
+
return value;
|
|
42
|
+
throw new EngineError("target must be 'live' or 'draft'", 'invalid');
|
|
43
|
+
}
|
|
44
|
+
export function mcpScopeKey(params) {
|
|
45
|
+
const scope = params.get('scope');
|
|
46
|
+
const target = params.get('target');
|
|
47
|
+
if (scope !== null && target !== null)
|
|
48
|
+
throw new EngineError('send either scope or target, not both', 'invalid');
|
|
49
|
+
if (scope !== null) {
|
|
50
|
+
if (scope !== 'assistant')
|
|
51
|
+
throw new EngineError("scope must be 'assistant'", 'invalid');
|
|
52
|
+
return 'assistant';
|
|
53
|
+
}
|
|
54
|
+
return parseTarget(target);
|
|
55
|
+
}
|
|
56
|
+
const scopeStore = new AsyncLocalStorage();
|
|
57
|
+
/** Run `fn` with `scope` for the App model's builder methods. */
|
|
58
|
+
export function withScope(scope, fn) {
|
|
59
|
+
return scopeStore.run({ ...scope }, fn);
|
|
60
|
+
}
|
|
61
|
+
/** The scope entered by the request entry, or all-live. */
|
|
62
|
+
export function currentScope() {
|
|
63
|
+
return scopeStore.getStore() ?? LIVE_SCOPE;
|
|
64
|
+
}
|
|
@@ -2,14 +2,53 @@ import type { DatabaseInterface } from '@happyvertical/sql';
|
|
|
2
2
|
import type { VersionRecord, VersionStore } from '../../engine/index.ts';
|
|
3
3
|
import { type App } from '../objects/App.ts';
|
|
4
4
|
export declare const APP_SLUG = "app";
|
|
5
|
+
/**
|
|
6
|
+
* Set on the live store for the duration of one publish: the draft whose
|
|
7
|
+
* `published` mark rides in `commit`'s transaction.
|
|
8
|
+
*/
|
|
9
|
+
export interface PublishMark {
|
|
10
|
+
draftId: string;
|
|
11
|
+
}
|
|
5
12
|
export declare class ShellVersionStore implements VersionStore {
|
|
6
13
|
private readonly db;
|
|
14
|
+
/** The draft the next `commit` publishes (see {@link PublishMark}); drafts.ts sets and clears it. */
|
|
15
|
+
publishing: PublishMark | null;
|
|
7
16
|
constructor(db: DatabaseInterface);
|
|
8
17
|
private versions;
|
|
9
18
|
private apps;
|
|
10
19
|
private row;
|
|
11
20
|
/** The singleton App row, created on first use. */
|
|
12
|
-
appRow(): Promise<App>;
|
|
21
|
+
appRow(db?: DatabaseInterface): Promise<App>;
|
|
22
|
+
current(): Promise<number | null>;
|
|
23
|
+
private point;
|
|
24
|
+
setCurrent(version: number): Promise<void>;
|
|
25
|
+
get(version: number): Promise<VersionRecord | null>;
|
|
26
|
+
list(): Promise<VersionRecord[]>;
|
|
27
|
+
private insert;
|
|
28
|
+
append(record: VersionRecord): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Append `records` and point at `current` in ONE transaction. Any failure
|
|
31
|
+
* (a version that already exists, a missing target, a write error) rolls all
|
|
32
|
+
* of it back, so a publish that fails here leaves no orphan versions and the
|
|
33
|
+
* pointer where it was. When a publish names its draft ({@link publishing}),
|
|
34
|
+
* the draft's `published` mark is in the same transaction.
|
|
35
|
+
*/
|
|
36
|
+
commit(records: VersionRecord[], current: number): Promise<void>;
|
|
37
|
+
markPurged(version: number): Promise<void>;
|
|
38
|
+
maxVersion(): Promise<number>;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* One draft's versions. The draft host only ever reads and appends; its own
|
|
42
|
+
* pointer is the `AppDraft` row's `currentVersion`. The row must exist before
|
|
43
|
+
* the host starts.
|
|
44
|
+
*/
|
|
45
|
+
export declare class DraftVersionStore implements VersionStore {
|
|
46
|
+
private readonly db;
|
|
47
|
+
readonly draftId: string;
|
|
48
|
+
constructor(db: DatabaseInterface, draftId: string);
|
|
49
|
+
private versions;
|
|
50
|
+
private draftRow;
|
|
51
|
+
private row;
|
|
13
52
|
current(): Promise<number | null>;
|
|
14
53
|
setCurrent(version: number): Promise<void>;
|
|
15
54
|
get(version: number): Promise<VersionRecord | null>;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { AppCollection } from "../objects/App.js";
|
|
2
|
+
import { AppDraftCollection } from "../objects/AppDraft.js";
|
|
3
|
+
import { AppDraftVersionCollection, } from "../objects/AppDraftVersion.js";
|
|
2
4
|
import { AppVersionCollection, } from "../objects/AppVersion.js";
|
|
3
5
|
export const APP_SLUG = 'app';
|
|
4
6
|
function toRecord(row) {
|
|
@@ -20,27 +22,55 @@ function toRecord(row) {
|
|
|
20
22
|
: String(row.createdAt),
|
|
21
23
|
};
|
|
22
24
|
}
|
|
25
|
+
/** The columns every version row shares, from an engine record. */
|
|
26
|
+
function columnsOf(record) {
|
|
27
|
+
return {
|
|
28
|
+
version: record.version,
|
|
29
|
+
parent: record.parent ?? -1,
|
|
30
|
+
author: record.author,
|
|
31
|
+
summary: record.summary,
|
|
32
|
+
definition: JSON.stringify(record.definition),
|
|
33
|
+
migration: record.migration ? JSON.stringify(record.migration) : '',
|
|
34
|
+
createdAt: record.createdAt,
|
|
35
|
+
purged: !!record.migration?.purged,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/** Mark a row's migration purged (shared by both stores). */
|
|
39
|
+
async function markRowPurged(row) {
|
|
40
|
+
if (!row)
|
|
41
|
+
return;
|
|
42
|
+
row.purged = true;
|
|
43
|
+
if (row.migration) {
|
|
44
|
+
row.migration = JSON.stringify({
|
|
45
|
+
...JSON.parse(row.migration),
|
|
46
|
+
purged: true,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
await row.save();
|
|
50
|
+
}
|
|
23
51
|
export class ShellVersionStore {
|
|
24
52
|
db;
|
|
53
|
+
/** The draft the next `commit` publishes (see {@link PublishMark}); drafts.ts sets and clears it. */
|
|
54
|
+
publishing = null;
|
|
25
55
|
constructor(db) {
|
|
26
56
|
this.db = db;
|
|
27
57
|
}
|
|
28
|
-
versions() {
|
|
29
|
-
return AppVersionCollection.create({ db
|
|
58
|
+
versions(db = this.db) {
|
|
59
|
+
return AppVersionCollection.create({ db });
|
|
30
60
|
}
|
|
31
|
-
apps() {
|
|
32
|
-
return AppCollection.create({ db
|
|
61
|
+
apps(db = this.db) {
|
|
62
|
+
return AppCollection.create({ db });
|
|
33
63
|
}
|
|
34
|
-
async row(version) {
|
|
35
|
-
const rows = await (await this.versions()).list({
|
|
64
|
+
async row(version, db = this.db) {
|
|
65
|
+
const rows = await (await this.versions(db)).list({
|
|
36
66
|
where: { version },
|
|
37
67
|
limit: 1,
|
|
38
68
|
});
|
|
39
69
|
return rows[0] ?? null;
|
|
40
70
|
}
|
|
41
71
|
/** The singleton App row, created on first use. */
|
|
42
|
-
async appRow() {
|
|
43
|
-
const apps = await this.apps();
|
|
72
|
+
async appRow(db = this.db) {
|
|
73
|
+
const apps = await this.apps(db);
|
|
44
74
|
const rows = await apps.list({ where: { slug: APP_SLUG }, limit: 1 });
|
|
45
75
|
if (rows[0])
|
|
46
76
|
return rows[0];
|
|
@@ -57,11 +87,11 @@ export class ShellVersionStore {
|
|
|
57
87
|
const v = Number((await this.appRow()).currentVersion);
|
|
58
88
|
return v < 0 ? null : v;
|
|
59
89
|
}
|
|
60
|
-
async
|
|
61
|
-
const rec = await this.row(version);
|
|
90
|
+
async point(version, db) {
|
|
91
|
+
const rec = await this.row(version, db);
|
|
62
92
|
if (!rec)
|
|
63
93
|
throw new Error(`version ${version} is not stored`);
|
|
64
|
-
const app = await this.appRow();
|
|
94
|
+
const app = await this.appRow(db);
|
|
65
95
|
app.currentVersion = version;
|
|
66
96
|
app.definition = rec.definition;
|
|
67
97
|
try {
|
|
@@ -72,6 +102,9 @@ export class ShellVersionStore {
|
|
|
72
102
|
}
|
|
73
103
|
await app.save();
|
|
74
104
|
}
|
|
105
|
+
async setCurrent(version) {
|
|
106
|
+
await this.point(version, this.db);
|
|
107
|
+
}
|
|
75
108
|
async get(version) {
|
|
76
109
|
const row = await this.row(version);
|
|
77
110
|
return row ? toRecord(row) : null;
|
|
@@ -80,36 +113,115 @@ export class ShellVersionStore {
|
|
|
80
113
|
const rows = await (await this.versions()).list({ limit: 100000 });
|
|
81
114
|
return rows.map(toRecord).sort((a, b) => a.version - b.version);
|
|
82
115
|
}
|
|
116
|
+
async insert(record, db) {
|
|
117
|
+
if (await this.row(record.version, db))
|
|
118
|
+
throw new Error(`version ${record.version} already exists`);
|
|
119
|
+
const created = await (await this.versions(db)).create(columnsOf(record));
|
|
120
|
+
await created.save();
|
|
121
|
+
}
|
|
122
|
+
async append(record) {
|
|
123
|
+
await this.insert(record, this.db);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Append `records` and point at `current` in ONE transaction. Any failure
|
|
127
|
+
* (a version that already exists, a missing target, a write error) rolls all
|
|
128
|
+
* of it back, so a publish that fails here leaves no orphan versions and the
|
|
129
|
+
* pointer where it was. When a publish names its draft ({@link publishing}),
|
|
130
|
+
* the draft's `published` mark is in the same transaction.
|
|
131
|
+
*/
|
|
132
|
+
async commit(records, current) {
|
|
133
|
+
const db = this.db;
|
|
134
|
+
if (typeof db.transaction !== 'function')
|
|
135
|
+
throw new Error('the shell database cannot run transactions');
|
|
136
|
+
const mark = this.publishing;
|
|
137
|
+
await db.transaction(async (tx) => {
|
|
138
|
+
const txDb = tx;
|
|
139
|
+
for (const r of records)
|
|
140
|
+
await this.insert(r, txDb);
|
|
141
|
+
await this.point(current, txDb);
|
|
142
|
+
if (mark) {
|
|
143
|
+
const draft = await (await AppDraftCollection.create({ db: txDb })).get(mark.draftId);
|
|
144
|
+
if (draft?.status !== 'open')
|
|
145
|
+
throw new Error('the draft is no longer open');
|
|
146
|
+
draft.status = 'published';
|
|
147
|
+
draft.endedAt = new Date().toISOString();
|
|
148
|
+
draft.publishedVersions = JSON.stringify(records.map((r) => r.version));
|
|
149
|
+
await draft.save();
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
async markPurged(version) {
|
|
154
|
+
await markRowPurged(await this.row(version));
|
|
155
|
+
}
|
|
156
|
+
async maxVersion() {
|
|
157
|
+
const rows = await (await this.versions()).list({ limit: 100000 });
|
|
158
|
+
return rows.length ? Math.max(...rows.map((r) => Number(r.version))) : -1;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* One draft's versions. The draft host only ever reads and appends; its own
|
|
163
|
+
* pointer is the `AppDraft` row's `currentVersion`. The row must exist before
|
|
164
|
+
* the host starts.
|
|
165
|
+
*/
|
|
166
|
+
export class DraftVersionStore {
|
|
167
|
+
db;
|
|
168
|
+
draftId;
|
|
169
|
+
constructor(db, draftId) {
|
|
170
|
+
this.db = db;
|
|
171
|
+
this.draftId = draftId;
|
|
172
|
+
}
|
|
173
|
+
versions() {
|
|
174
|
+
return AppDraftVersionCollection.create({ db: this.db });
|
|
175
|
+
}
|
|
176
|
+
async draftRow() {
|
|
177
|
+
const row = await (await AppDraftCollection.create({ db: this.db })).get(this.draftId);
|
|
178
|
+
if (!row)
|
|
179
|
+
throw new Error('the draft is not stored');
|
|
180
|
+
return row;
|
|
181
|
+
}
|
|
182
|
+
async row(version) {
|
|
183
|
+
const rows = await (await this.versions()).list({
|
|
184
|
+
where: { draftId: this.draftId, version },
|
|
185
|
+
limit: 1,
|
|
186
|
+
});
|
|
187
|
+
return rows[0] ?? null;
|
|
188
|
+
}
|
|
189
|
+
async current() {
|
|
190
|
+
const v = Number((await this.draftRow()).currentVersion);
|
|
191
|
+
return v < 0 ? null : v;
|
|
192
|
+
}
|
|
193
|
+
async setCurrent(version) {
|
|
194
|
+
if (!(await this.row(version)))
|
|
195
|
+
throw new Error(`version ${version} is not stored`);
|
|
196
|
+
const draft = await this.draftRow();
|
|
197
|
+
draft.currentVersion = version;
|
|
198
|
+
await draft.save();
|
|
199
|
+
}
|
|
200
|
+
async get(version) {
|
|
201
|
+
const row = await this.row(version);
|
|
202
|
+
return row ? toRecord(row) : null;
|
|
203
|
+
}
|
|
204
|
+
async list() {
|
|
205
|
+
const rows = await (await this.versions()).list({
|
|
206
|
+
where: { draftId: this.draftId },
|
|
207
|
+
limit: 100000,
|
|
208
|
+
});
|
|
209
|
+
return rows.map(toRecord).sort((a, b) => a.version - b.version);
|
|
210
|
+
}
|
|
83
211
|
async append(record) {
|
|
84
212
|
if (await this.row(record.version))
|
|
85
213
|
throw new Error(`version ${record.version} already exists`);
|
|
86
214
|
const created = await (await this.versions()).create({
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
author: record.author,
|
|
90
|
-
summary: record.summary,
|
|
91
|
-
definition: JSON.stringify(record.definition),
|
|
92
|
-
migration: record.migration ? JSON.stringify(record.migration) : '',
|
|
93
|
-
createdAt: record.createdAt,
|
|
94
|
-
purged: !!record.migration?.purged,
|
|
215
|
+
draftId: this.draftId,
|
|
216
|
+
...columnsOf(record),
|
|
95
217
|
});
|
|
96
218
|
await created.save();
|
|
97
219
|
}
|
|
98
220
|
async markPurged(version) {
|
|
99
|
-
|
|
100
|
-
if (!row)
|
|
101
|
-
return;
|
|
102
|
-
row.purged = true;
|
|
103
|
-
if (row.migration) {
|
|
104
|
-
row.migration = JSON.stringify({
|
|
105
|
-
...JSON.parse(row.migration),
|
|
106
|
-
purged: true,
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
await row.save();
|
|
221
|
+
await markRowPurged(await this.row(version));
|
|
110
222
|
}
|
|
111
223
|
async maxVersion() {
|
|
112
|
-
const rows = await
|
|
113
|
-
return rows.length ? Math.max(...rows.map((r) =>
|
|
224
|
+
const rows = await this.list();
|
|
225
|
+
return rows.length ? Math.max(...rows.map((r) => r.version)) : -1;
|
|
114
226
|
}
|
|
115
227
|
}
|