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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/AGENTS.md +99 -0
  2. package/CLAUDE.md +1 -0
  3. package/LICENSE +7 -0
  4. package/README.md +173 -2
  5. package/agents/engine.md +85 -0
  6. package/dist/engine/app-runtime.d.ts +138 -0
  7. package/dist/engine/app-runtime.js +545 -0
  8. package/dist/engine/app-worker.d.ts +1 -0
  9. package/dist/engine/app-worker.js +62 -0
  10. package/dist/engine/commands.d.ts +94 -0
  11. package/dist/engine/commands.js +30 -0
  12. package/dist/engine/definition.d.ts +192 -0
  13. package/dist/engine/definition.js +497 -0
  14. package/dist/engine/errors.d.ts +23 -0
  15. package/dist/engine/errors.js +35 -0
  16. package/dist/engine/host.d.ts +153 -0
  17. package/dist/engine/host.js +791 -0
  18. package/dist/engine/index.d.ts +6 -0
  19. package/dist/engine/index.js +7 -0
  20. package/dist/engine/sandbox.d.ts +60 -0
  21. package/dist/engine/sandbox.js +260 -0
  22. package/dist/engine/snapshots.d.ts +7 -0
  23. package/dist/engine/snapshots.js +54 -0
  24. package/dist/engine/store.d.ts +39 -0
  25. package/dist/engine/store.js +32 -0
  26. package/dist/engine/worker-handle.d.ts +29 -0
  27. package/dist/engine/worker-handle.js +89 -0
  28. package/dist/engine/worker-protocol.d.ts +22 -0
  29. package/dist/engine/worker-protocol.js +20 -0
  30. package/dist/lib/objects/App.d.ts +60 -0
  31. package/dist/lib/objects/App.js +273 -0
  32. package/dist/lib/objects/AppVersion.d.ts +22 -0
  33. package/dist/lib/objects/AppVersion.js +62 -0
  34. package/dist/lib/objects/Approval.d.ts +39 -0
  35. package/dist/lib/objects/Approval.js +101 -0
  36. package/dist/lib/objects/AssistantMessage.d.ts +24 -0
  37. package/dist/lib/objects/AssistantMessage.js +63 -0
  38. package/dist/lib/objects/AssistantThread.d.ts +16 -0
  39. package/dist/lib/objects/AssistantThread.js +44 -0
  40. package/dist/lib/objects/index.d.ts +5 -0
  41. package/dist/lib/objects/index.js +5 -0
  42. package/dist/lib/server/approvals.d.ts +69 -0
  43. package/dist/lib/server/approvals.js +239 -0
  44. package/dist/lib/server/builder.d.ts +16 -0
  45. package/dist/lib/server/builder.js +71 -0
  46. package/dist/lib/server/events.d.ts +16 -0
  47. package/dist/lib/server/events.js +14 -0
  48. package/dist/lib/server/host.d.ts +22 -0
  49. package/dist/lib/server/host.js +96 -0
  50. package/dist/lib/server/owner.d.ts +33 -0
  51. package/dist/lib/server/owner.js +92 -0
  52. package/dist/lib/server/version-store.d.ts +20 -0
  53. package/dist/lib/server/version-store.js +115 -0
  54. package/dist/manifest.json +1680 -0
  55. package/dist/smrt-knowledge.okf.md +95 -0
  56. package/package.json +80 -3
@@ -0,0 +1,545 @@
1
+ // The user app's runtime. Runs inside its own worker thread, so it has its own
2
+ // smrt registry: built fresh from one definition, thrown away on the next.
3
+ //
4
+ // Migrations here are SQLite-shaped (RENAME/DROP COLUMN, VACUUM INTO,
5
+ // pragma_table_info). Postgres is future work.
6
+ import { mkdirSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import { ObjectRegistry, planRuntimeModelDDL, registerRuntimeModel, } from '@happyvertical/smrt-core';
9
+ import { MCPGenerator } from '@happyvertical/smrt-core/generators/mcp';
10
+ import { APIGenerator } from '@happyvertical/smrt-core/generators/rest';
11
+ import { getDatabase } from '@happyvertical/sql';
12
+ import { builtInFields, classNameFor, coerceValue, convertValue, describeApp, RECIPES, recipeModelFor, restRouteAllowed, routeFor, snake, sqlType, storageFamily, tableFor, toolNamesFor, toRuntimeModel, USER_PKG, visible, } from "./definition.js";
13
+ import { AppError } from "./errors.js";
14
+ import { runMethod, SANDBOX_LIMITS, validateWrites, } from "./sandbox.js";
15
+ import { pruneMethodSnapshots, pruneMigrationSnapshots } from "./snapshots.js";
16
+ const q = (s) => `"${s.replace(/"/g, '""')}"`;
17
+ const OWNER = { id: 'owner', roles: ['admin'] };
18
+ const PROFILE_TYPES = '@happyvertical/smrt-profiles:ProfileType';
19
+ export class AppRuntime {
20
+ def;
21
+ version;
22
+ db;
23
+ dataDir;
24
+ snapshots = 0;
25
+ constructor(dataDir, def, version) {
26
+ this.dataDir = dataDir;
27
+ this.def = def;
28
+ this.version = version;
29
+ }
30
+ /**
31
+ * Open app.db, register the definition, and bring the tables up to it: the
32
+ * migration's steps (if any) and then the additive changes, all in one
33
+ * transaction. A failure leaves app.db exactly as it was.
34
+ */
35
+ async boot(migration) {
36
+ mkdirSync(join(this.dataDir, 'snapshots'), { recursive: true });
37
+ this.db = await getDatabase({
38
+ type: 'sqlite',
39
+ url: join(this.dataDir, 'app.db'),
40
+ });
41
+ await this.register();
42
+ const steps = migration?.steps ?? [];
43
+ if (!steps.length)
44
+ await this.checkColumns(this.db);
45
+ // With steps the tables change first; planning happens inside the transaction.
46
+ const needed = steps.length > 0 || (await this.plan(this.db, false)).length > 0;
47
+ const statements = [];
48
+ let snapshot = null;
49
+ if (needed) {
50
+ snapshot = await this.snapshot(steps.length
51
+ ? `migrate-v${migration?.version}-${migration?.direction}`
52
+ : 'schema');
53
+ await this.db.transaction(async (tx) => {
54
+ await tx.query(`CREATE TABLE IF NOT EXISTS _archive (id INTEGER PRIMARY KEY AUTOINCREMENT, version INTEGER NOT NULL, tbl TEXT NOT NULL, col TEXT NOT NULL, record_id TEXT NOT NULL, value TEXT, created_at TEXT NOT NULL)`);
55
+ const ordered = migration?.direction === 'down' ? [...steps].reverse() : steps;
56
+ for (const step of ordered)
57
+ statements.push(...(await this.runStep(tx, step, migration)));
58
+ await this.checkColumns(tx);
59
+ statements.push(...(await this.plan(tx, true)));
60
+ });
61
+ }
62
+ await this.db.query(`CREATE TABLE IF NOT EXISTS _app_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
63
+ for (const [k, v] of [
64
+ ['version', String(this.version)],
65
+ ['definition', JSON.stringify(this.def)],
66
+ ])
67
+ await this.db
68
+ .execute `INSERT INTO _app_meta (key, value) VALUES (${k}, ${v}) ON CONFLICT(key) DO UPDATE SET value = excluded.value`;
69
+ return { statements, snapshot };
70
+ }
71
+ // ---------- registry and tables ----------
72
+ /** Register the definition's models in this (fresh) registry. */
73
+ async register() {
74
+ if (this.def.recipes.length)
75
+ await import('@happyvertical/smrt-profiles');
76
+ for (const [model, md] of Object.entries(this.def.models)) {
77
+ const rt = this;
78
+ const methods = {};
79
+ for (const name of Object.keys(md.methods ?? {}))
80
+ methods[name] = function (params) {
81
+ return rt.runMethod(model, name, this.id ?? null, params ?? {});
82
+ };
83
+ registerRuntimeModel(toRuntimeModel(model, md), {
84
+ methods: methods,
85
+ });
86
+ // The app uses only its subclass: close the shipped parent's generated
87
+ // surfaces so its tools do not duplicate the subclass's.
88
+ const parent = recipeModelFor(model, md)?.parent;
89
+ if (parent)
90
+ ObjectRegistry.registerOverride(parent, { api: false, mcp: false });
91
+ }
92
+ }
93
+ qualified(model) {
94
+ return `${USER_PKG}:${classNameFor(model, this.def.models[model])}`;
95
+ }
96
+ /**
97
+ * Statements that bring tables up to the registered models, reading
98
+ * through `h`. With `run`, each model's statements execute before the next
99
+ * is planned (the subclass of a shipped model plans against its parent's
100
+ * table). Shipped tables come first.
101
+ */
102
+ async plan(h, run) {
103
+ const qualifieds = [
104
+ ...this.def.recipes.flatMap((r) => RECIPES[r]?.tables ?? []),
105
+ ...Object.keys(this.def.models).map((m) => this.qualified(m)),
106
+ ];
107
+ // A subclass plans after the shipped parent it extends.
108
+ const out = [];
109
+ for (const qn of qualifieds) {
110
+ const statements = await planRuntimeModelDDL(qn, 'sqlite', (t) => this.existingColumns(h, t));
111
+ if (run)
112
+ for (const s of statements)
113
+ await h.query(s);
114
+ out.push(...statements);
115
+ if (!run && statements.length)
116
+ break; // enough to know there is work
117
+ }
118
+ return out;
119
+ }
120
+ async existingColumns(h, table) {
121
+ const rows = (await h.query(`SELECT name FROM pragma_table_info(?)`, [table])).rows;
122
+ return rows.length ? rows.map((r) => String(r.name)) : null;
123
+ }
124
+ /** A column that exists with another storage family than its field wants: refuse. */
125
+ async checkColumns(h) {
126
+ for (const [model, md] of Object.entries(this.def.models)) {
127
+ const table = tableFor(model, md);
128
+ const have = await this.columnTypes(h, table);
129
+ if (!have.size)
130
+ continue;
131
+ for (const [f, fd] of Object.entries(visible(md.fields))) {
132
+ const col = have.get(snake(f));
133
+ if (col && storageFamily(col) !== storageFamily(sqlType(fd.type)))
134
+ throw new AppError(`${table}.${snake(f)} already holds ${col} data; remove that field or change its type instead`);
135
+ }
136
+ }
137
+ }
138
+ async columnTypes(h, t) {
139
+ const rows = (await h.query(`SELECT name, type FROM pragma_table_info(?)`, [t])).rows;
140
+ return new Map(rows.map((r) => [String(r.name), String(r.type)]));
141
+ }
142
+ async tableExists(h, t) {
143
+ return ((await h.query(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, [t])).rows.length > 0);
144
+ }
145
+ // ---------- migration steps ----------
146
+ /** Run one step up or down inside the boot transaction; returns what it did. */
147
+ async runStep(tx, step, m) {
148
+ const done = [];
149
+ const exec = async (sql, params = []) => {
150
+ await tx.query(sql, params);
151
+ done.push(sql);
152
+ };
153
+ const now = new Date().toISOString();
154
+ const up = m.direction === 'up';
155
+ switch (step.op) {
156
+ case 'renameColumn':
157
+ await exec(`ALTER TABLE ${q(step.table)} RENAME COLUMN ${q(up ? step.from : step.to)} TO ${q(up ? step.to : step.from)}`);
158
+ break;
159
+ case 'archiveTable':
160
+ await exec(`ALTER TABLE ${q(up ? step.table : step.archivedAs)} RENAME TO ${q(up ? step.archivedAs : step.table)}`);
161
+ break;
162
+ case 'dropColumn':
163
+ if (up) {
164
+ if (!(await this.columnTypes(tx, step.table)).has(step.column))
165
+ break;
166
+ await exec(`INSERT INTO _archive (version, tbl, col, record_id, value, created_at) SELECT ?, ?, ?, ${q(step.key)}, ${q(step.column)}, ? FROM ${q(step.table)} WHERE ${q(step.column)} IS NOT NULL AND ${q(step.column)} <> ''`, [m.version, step.table, step.column, now]);
167
+ await exec(`ALTER TABLE ${q(step.table)} DROP COLUMN ${q(step.column)}`);
168
+ }
169
+ else {
170
+ await exec(`ALTER TABLE ${q(step.table)} ADD COLUMN ${q(step.column)} ${sqlType(step.type)}`);
171
+ await exec(`UPDATE ${q(step.table)} SET ${q(step.column)} = (SELECT value FROM _archive a WHERE a.version = ? AND a.tbl = ? AND a.col = ? AND a.record_id = ${q(step.table)}.${q(step.key)})`, [m.version, step.table, step.column]);
172
+ await exec(`DELETE FROM _archive WHERE version = ? AND tbl = ? AND col = ?`, [m.version, step.table, step.column]);
173
+ }
174
+ break;
175
+ case 'convertColumn': {
176
+ const target = up ? step.to : step.from;
177
+ const tmp = `${step.column}__new`;
178
+ const rows = (await tx.query(`SELECT ${q(step.key)} AS k, ${q(step.column)} AS v FROM ${q(step.table)}`)).rows;
179
+ const archived = new Map();
180
+ if (up) {
181
+ await exec(`INSERT INTO _archive (version, tbl, col, record_id, value, created_at) SELECT ?, ?, ?, ${q(step.key)}, ${q(step.column)}, ? FROM ${q(step.table)} WHERE ${q(step.column)} IS NOT NULL`, [m.version, step.table, step.column, now]);
182
+ }
183
+ else {
184
+ for (const r of (await tx.query(`SELECT record_id, value FROM _archive WHERE version = ? AND tbl = ? AND col = ?`, [m.version, step.table, step.column])).rows)
185
+ archived.set(String(r.record_id), r.value);
186
+ }
187
+ await exec(`ALTER TABLE ${q(step.table)} ADD COLUMN ${q(tmp)} ${sqlType(target.type)}`);
188
+ for (const r of rows) {
189
+ // Going back, a row's original value wins; rows made since are converted back.
190
+ const value = !up && archived.has(String(r.k))
191
+ ? archived.get(String(r.k))
192
+ : convertValue(r.v, target).value;
193
+ await tx.query(`UPDATE ${q(step.table)} SET ${q(tmp)} = ? WHERE ${q(step.key)} = ?`, [value, r.k]);
194
+ }
195
+ done.push(`-- converted ${rows.length} values to ${target.type}`);
196
+ await exec(`ALTER TABLE ${q(step.table)} DROP COLUMN ${q(step.column)}`);
197
+ await exec(`ALTER TABLE ${q(step.table)} RENAME COLUMN ${q(tmp)} TO ${q(step.column)}`);
198
+ if (!up)
199
+ await exec(`DELETE FROM _archive WHERE version = ? AND tbl = ? AND col = ?`, [m.version, step.table, step.column]);
200
+ break;
201
+ }
202
+ }
203
+ return done;
204
+ }
205
+ // ---------- previews and archives ----------
206
+ /** What a type change would do to existing values, without changing anything. */
207
+ async previewConversion(table, key, column, to) {
208
+ const rows = (await this.db.query(`SELECT ${q(key)} AS k, ${q(column)} AS v FROM ${q(table)}`)).rows;
209
+ const failing = rows.filter((r) => r.v !== null && !convertValue(r.v, to).ok);
210
+ const withValue = rows.filter((r) => r.v !== null).length;
211
+ return {
212
+ rows: rows.length,
213
+ withValue,
214
+ convertible: withValue - failing.length,
215
+ failing: failing.slice(0, 10).map((r) => ({ id: r.k, value: r.v })),
216
+ failingCount: failing.length,
217
+ };
218
+ }
219
+ /** How many non-empty values a column holds (for a removal preview). */
220
+ async countValues(table, column) {
221
+ if (!(await this.columnTypes(this.db, table)).has(column))
222
+ return 0;
223
+ const r = await this.db.query(`SELECT COUNT(*) AS n FROM ${q(table)} WHERE ${q(column)} IS NOT NULL AND ${q(column)} <> ''`);
224
+ return Number(r.rows[0].n);
225
+ }
226
+ async countRows(table) {
227
+ if (!(await this.tableExists(this.db, table)))
228
+ return 0;
229
+ const r = await this.db.query(`SELECT COUNT(*) AS n FROM ${q(table)}`);
230
+ return Number(r.rows[0].n);
231
+ }
232
+ /** How many archived values and tables the given versions hold. */
233
+ async countArchives(versions, tables) {
234
+ let values = 0;
235
+ if (await this.tableExists(this.db, '_archive'))
236
+ for (const v of versions)
237
+ values += Number((await this.db.query(`SELECT COUNT(*) AS n FROM _archive WHERE version = ?`, [v])).rows[0].n);
238
+ return { values, tables: tables.length };
239
+ }
240
+ /**
241
+ * How many rows undoing `version`'s `convertColumn` steps would overwrite:
242
+ * rows whose current value is not what converting the archived original
243
+ * gives (they were edited, or created over the original, since).
244
+ */
245
+ async countOverwrites(version, steps) {
246
+ const out = [];
247
+ for (const step of steps) {
248
+ if (step.op !== 'convertColumn')
249
+ continue;
250
+ let replaced = 0;
251
+ if ((await this.tableExists(this.db, '_archive')) &&
252
+ (await this.columnTypes(this.db, step.table)).has(step.column)) {
253
+ const archived = (await this.db.query(`SELECT record_id, value FROM _archive WHERE version = ? AND tbl = ? AND col = ?`, [version, step.table, step.column])).rows;
254
+ const current = new Map((await this.db.query(`SELECT ${q(step.key)} AS k, ${q(step.column)} AS v FROM ${q(step.table)}`)).rows.map((r) => [String(r.k), r.v]));
255
+ for (const a of archived) {
256
+ const id = String(a.record_id);
257
+ if (!current.has(id))
258
+ continue; // deleted since: nothing to overwrite
259
+ const was = convertValue(a.value, step.to).value;
260
+ const now = current.get(id);
261
+ if ((was ?? null) !== (now ?? null) && String(was) !== String(now))
262
+ replaced++;
263
+ }
264
+ }
265
+ out.push({ table: step.table, column: step.column, replaced });
266
+ }
267
+ return out;
268
+ }
269
+ /** Delete archived values and tables for the given versions. */
270
+ async purgeArchives(versions, tables) {
271
+ if (!versions.length && !tables.length)
272
+ return { values: 0, tables: 0 };
273
+ let values = 0;
274
+ await this.db.transaction(async (tx) => {
275
+ if (await this.tableExists(tx, '_archive'))
276
+ for (const v of versions)
277
+ values +=
278
+ (await tx.query(`DELETE FROM _archive WHERE version = ?`, [v]))
279
+ .rowCount ?? 0;
280
+ for (const t of tables)
281
+ if (await this.tableExists(tx, t))
282
+ await tx.query(`DROP TABLE ${q(t)}`);
283
+ });
284
+ return { values, tables: tables.length };
285
+ }
286
+ /** A consistent copy of app.db in snapshots/, made with VACUUM INTO. */
287
+ async snapshot(kind) {
288
+ const safe = kind.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 60);
289
+ const file = join(this.dataDir, 'snapshots', `${Date.now()}-${this.snapshots++}-${safe}.db`);
290
+ await this.db.query(`VACUUM INTO '${file.replace(/'/g, "''")}'`);
291
+ // Applied method writes can come in a stream: keep only the newest few.
292
+ if (safe.startsWith('method-'))
293
+ pruneMethodSnapshots(join(this.dataDir, 'snapshots'));
294
+ return file;
295
+ }
296
+ /** Delete migrate/schema snapshots taken before `beforeMs` (clean up). */
297
+ pruneSnapshots(beforeMs) {
298
+ return pruneMigrationSnapshots(join(this.dataDir, 'snapshots'), Number(beforeMs));
299
+ }
300
+ // ---------- SMRT's generated surfaces ----------
301
+ mcp() {
302
+ return new MCPGenerator({ name: 'app', version: String(this.version) }, {
303
+ db: this.db,
304
+ user: OWNER,
305
+ });
306
+ }
307
+ toolNames() {
308
+ return new Set(Object.entries(this.def.models).flatMap(([m, md]) => toolNamesFor(m, md)));
309
+ }
310
+ /** The app's own generated MCP tools: chore_list, chore_complete, ... */
311
+ async tools() {
312
+ const mine = this.toolNames();
313
+ return (await this.mcp().generateTools()).filter((t) => mine.has(t.name));
314
+ }
315
+ async callTool(name, args) {
316
+ if (!this.toolNames().has(name))
317
+ throw new AppError(`unknown tool ${name}`);
318
+ const r = await this.mcp().handleToolCall({
319
+ method: 'tools/call',
320
+ params: { name, arguments: args },
321
+ });
322
+ return {
323
+ content: r.content,
324
+ isError: !!r.isError,
325
+ structuredContent: r.structuredContent,
326
+ };
327
+ }
328
+ /** SMRT's generated REST routes. */
329
+ async rest(req) {
330
+ if (!restRouteAllowed(this.def, req.url))
331
+ return {
332
+ status: 404,
333
+ contentType: 'application/json',
334
+ body: JSON.stringify({ error: 'Not found' }),
335
+ };
336
+ const api = new APIGenerator({
337
+ basePath: '/api',
338
+ authMiddleware: () => async (r) => r,
339
+ }, { db: this.db });
340
+ for (const [m, md] of Object.entries(this.def.models))
341
+ api.registerCollection(routeFor(m, md), (await ObjectRegistry.getCollection(this.qualified(m), {
342
+ db: this.db,
343
+ })));
344
+ const url = req.url.startsWith('/')
345
+ ? `http://app.local${req.url}`
346
+ : req.url;
347
+ const hasBody = req.body !== undefined && req.method !== 'GET' && req.method !== 'HEAD';
348
+ const r = await api.generateHandler()(new Request(url, {
349
+ method: req.method,
350
+ headers: { 'content-type': 'application/json' },
351
+ body: hasBody ? req.body : undefined,
352
+ }));
353
+ return {
354
+ status: r.status,
355
+ contentType: r.headers.get('content-type') ?? 'application/json',
356
+ body: await r.text(),
357
+ };
358
+ }
359
+ // ---------- records ----------
360
+ describe() {
361
+ return describeApp(this.def, this.version);
362
+ }
363
+ modelDef(model) {
364
+ if (!Object.hasOwn(this.def.models, model))
365
+ throw new AppError(`no model ${model}`);
366
+ return this.def.models[model];
367
+ }
368
+ /** Fields a record of `model` carries: built-in ones and visible own ones. */
369
+ fieldsOf(model) {
370
+ const md = this.modelDef(model);
371
+ return { ...builtInFields(model, md), ...visible(md.fields) };
372
+ }
373
+ async collection(model) {
374
+ this.modelDef(model);
375
+ return ObjectRegistry.getCollection(this.qualified(model), { db: this.db });
376
+ }
377
+ plain(model, obj) {
378
+ const out = {
379
+ id: obj.id,
380
+ createdAt: toIso(obj.created_at ?? obj.createdAt),
381
+ };
382
+ for (const f of Object.keys(this.fieldsOf(model))) {
383
+ const v = obj[f];
384
+ out[f] = v instanceof Date ? v.toISOString() : (v ?? null);
385
+ }
386
+ return out;
387
+ }
388
+ async list(model, limit = 200) {
389
+ const rows = await (await this.collection(model)).list({
390
+ limit,
391
+ orderBy: 'created_at DESC',
392
+ });
393
+ return rows.map((r) => this.plain(model, r));
394
+ }
395
+ async get(model, id) {
396
+ const obj = await (await this.collection(model)).get(id);
397
+ if (!obj)
398
+ throw new AppError(`no ${model} ${id}`);
399
+ return this.plain(model, obj);
400
+ }
401
+ /** Check and convert `data` for a write. `refs: false` skips target lookups (imports). */
402
+ async coerce(model, data, creating, refs = true) {
403
+ const fields = this.fieldsOf(model);
404
+ const out = {};
405
+ for (const [k, v] of Object.entries(data)) {
406
+ if (!Object.hasOwn(fields, k))
407
+ throw new AppError(`${model} has no field '${k}' (fields: ${Object.keys(fields).join(', ')})`);
408
+ const fd = fields[k];
409
+ const c = coerceValue(fd, v);
410
+ if (!c.ok)
411
+ throw new AppError(`${model}.${k} ${c.error}`);
412
+ if (refs && fd.type === 'reference' && c.value !== null) {
413
+ const target = fd.ref;
414
+ if (!(await (await this.collection(target)).get(String(c.value))))
415
+ throw new AppError(`${model}.${k}: no ${target} with id ${c.value}`);
416
+ }
417
+ out[k] = c.value;
418
+ }
419
+ if (creating)
420
+ for (const [k, fd] of Object.entries(fields))
421
+ if (fd.required &&
422
+ (data[k] === undefined || data[k] === null || data[k] === ''))
423
+ throw new AppError(`${model}.${k} is required`);
424
+ return out;
425
+ }
426
+ async create(model, data, opts = {}) {
427
+ const base = await this.coerce(model, data, true, opts.refs !== false);
428
+ if (recipeModelFor(model, this.def.models[model]))
429
+ base.typeId = await this.personType();
430
+ if (opts.id)
431
+ base.id = opts.id;
432
+ const obj = await (await this.collection(model)).create(base);
433
+ return this.get(model, String(obj.id));
434
+ }
435
+ async update(model, id, data) {
436
+ const base = await this.coerce(model, data, false);
437
+ const obj = await (await this.collection(model)).get(id);
438
+ if (!obj)
439
+ throw new AppError(`no ${model} ${id}`);
440
+ for (const [k, v] of Object.entries(base))
441
+ obj[k] = v;
442
+ await obj.save();
443
+ return this.get(model, id);
444
+ }
445
+ async remove(model, id) {
446
+ const obj = await (await this.collection(model)).get(id);
447
+ if (!obj)
448
+ throw new AppError(`no ${model} ${id}`);
449
+ await obj.delete();
450
+ return { deleted: id };
451
+ }
452
+ /** Create records of a template with their ids (references stay valid). */
453
+ async importRecords(records) {
454
+ let n = 0;
455
+ for (const [model, rows] of Object.entries(records)) {
456
+ this.modelDef(model);
457
+ for (const row of rows) {
458
+ const { id, createdAt: _c, ...data } = row;
459
+ await this.create(model, data, {
460
+ id: typeof id === 'string' ? id : undefined,
461
+ refs: false,
462
+ });
463
+ n++;
464
+ }
465
+ }
466
+ return { records: n };
467
+ }
468
+ /** Find or create the real `person` ProfileType (the type of every app Profile). */
469
+ async personType() {
470
+ const types = await ObjectRegistry.getCollection(PROFILE_TYPES, {
471
+ db: this.db,
472
+ });
473
+ const found = (await types.list({ where: { slug: 'person' }, limit: 1 }))[0];
474
+ if (found)
475
+ return String(found.id);
476
+ return String((await types.create({ name: 'Person', slug: 'person' })).id);
477
+ }
478
+ // ---------- methods ----------
479
+ async runMethod(model, method, id, params = {}) {
480
+ const md = this.modelDef(model).methods?.[method];
481
+ if (!md)
482
+ throw new AppError(`no method ${model}.${method}()`);
483
+ for (const [p, pd] of Object.entries(md.params ?? {}))
484
+ if (pd.required && params[p] == null)
485
+ throw new AppError(`param ${p} is required`);
486
+ const data = {};
487
+ for (const m of Object.keys(this.def.models))
488
+ data[m] = await this.list(m, 1000);
489
+ const record = id ? await this.get(model, id) : null;
490
+ let out;
491
+ try {
492
+ out = await runMethod(md.code, { record, params, data });
493
+ }
494
+ catch (e) {
495
+ throw new AppError(e.message);
496
+ }
497
+ let writes;
498
+ try {
499
+ writes = validateWrites(out.writes, Object.fromEntries(Object.keys(this.def.models).map((m) => [
500
+ m,
501
+ { fields: this.fieldsOf(m) },
502
+ ])), SANDBOX_LIMITS.maxWrites);
503
+ await this.checkWrites(writes);
504
+ }
505
+ catch (e) {
506
+ throw new AppError(`method ${model}.${method}() proposed bad writes, none applied: ${e.message}`);
507
+ }
508
+ if (!writes.length)
509
+ return { result: out.result, writes: [], applied: 0, snapshot: null };
510
+ // Snapshot first: anything the writes do can be taken back from it.
511
+ const snapshot = await this.snapshot(`method-${model}.${method}`);
512
+ let applied = 0;
513
+ try {
514
+ for (const w of writes) {
515
+ if (w.op === 'create')
516
+ await this.create(w.model, w.data ?? {});
517
+ else if (w.op === 'update')
518
+ await this.update(w.model, String(w.id), w.data ?? {});
519
+ else
520
+ await this.remove(w.model, String(w.id));
521
+ applied++;
522
+ }
523
+ }
524
+ catch (e) {
525
+ throw new AppError(`method ${model}.${method}() failed after ${applied} of ${writes.length} writes (snapshot: ${snapshot}): ${e.message}`);
526
+ }
527
+ return { result: out.result, writes, applied, snapshot };
528
+ }
529
+ /** Reads only: records exist, required fields are present, references resolve. */
530
+ async checkWrites(writes) {
531
+ for (const w of writes) {
532
+ if (w.op === 'create')
533
+ await this.coerce(w.model, w.data ?? {}, true);
534
+ else {
535
+ if (!(await (await this.collection(w.model)).get(String(w.id))))
536
+ throw new AppError(`no ${w.model} ${w.id}`);
537
+ if (w.op === 'update')
538
+ await this.coerce(w.model, w.data ?? {}, false);
539
+ }
540
+ }
541
+ }
542
+ }
543
+ function toIso(v) {
544
+ return v instanceof Date ? v.toISOString() : (v ?? null);
545
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,62 @@
1
+ // The worker entry: runs one AppRuntime in its own thread. The host's handle
2
+ // on it is in worker-handle.ts. Messages are plain JSON-safe values.
3
+ import { isMainThread, parentPort, workerData } from 'node:worker_threads';
4
+ import { WORKER_OPS } from "./worker-protocol.js";
5
+ /**
6
+ * `@happyvertical/sql` starts an HMAC key generation on the libuv pool when it
7
+ * loads (a Postgres cache key). Node aborts the whole process if a worker is
8
+ * terminated while that job is still pending, so the worker reports ready (or
9
+ * failed) only after it has settled. Everything the host does to a worker
10
+ * (retire, terminate) happens after that message.
11
+ */
12
+ const settleStartupJobs = async () => {
13
+ const pending = globalThis
14
+ .__haveSqlPostgresCacheHmacKey;
15
+ if (pending && typeof pending.then === 'function')
16
+ await pending.catch(() => { });
17
+ };
18
+ if (!isMainThread && workerData?.appWorker) {
19
+ const { AppRuntime } = await import("./app-runtime.js");
20
+ const { AppError } = await import("./errors.js");
21
+ const { dataDir, def, version, migration } = workerData;
22
+ const rt = new AppRuntime(dataDir, def, version);
23
+ const port = parentPort;
24
+ let ok = false;
25
+ try {
26
+ const t0 = performance.now();
27
+ const booted = await rt.boot(migration);
28
+ ok = true;
29
+ await settleStartupJobs();
30
+ port.postMessage({
31
+ ready: true,
32
+ ...booted,
33
+ bootMs: Math.round(performance.now() - t0),
34
+ });
35
+ }
36
+ catch (e) {
37
+ await settleStartupJobs();
38
+ port.postMessage({ ready: false, error: e.message });
39
+ }
40
+ if (ok)
41
+ port.on('message', async ({ id, op, args }) => {
42
+ let reply;
43
+ try {
44
+ if (!WORKER_OPS.includes(op))
45
+ throw new Error(`bad op ${op}`);
46
+ reply = {
47
+ id,
48
+ ok: true,
49
+ value: await rt[op](...args),
50
+ };
51
+ }
52
+ catch (e) {
53
+ reply = {
54
+ id,
55
+ ok: false,
56
+ error: e.message,
57
+ user: e instanceof AppError,
58
+ };
59
+ }
60
+ port.postMessage(reply);
61
+ });
62
+ }