toiljs 0.0.59 → 0.0.60

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 (72) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/cli/index.js +309 -116
  4. package/build/compiler/.tsbuildinfo +1 -1
  5. package/build/devserver/.tsbuildinfo +1 -1
  6. package/build/devserver/db/catalog.d.ts +1 -0
  7. package/build/devserver/db/catalog.js +80 -0
  8. package/build/devserver/db/database.d.ts +64 -0
  9. package/build/devserver/db/database.js +662 -0
  10. package/build/devserver/db/index.d.ts +3 -0
  11. package/build/devserver/db/index.js +3 -0
  12. package/build/devserver/db/types.d.ts +58 -0
  13. package/build/devserver/db/types.js +20 -0
  14. package/build/devserver/email/index.js +1 -1
  15. package/build/devserver/index.d.ts +9 -24
  16. package/build/devserver/index.js +4 -165
  17. package/build/devserver/{host.d.ts → runtime/host.d.ts} +1 -1
  18. package/build/devserver/{host.js → runtime/host.js} +6 -6
  19. package/build/devserver/{module.d.ts → runtime/module.d.ts} +1 -1
  20. package/build/devserver/{module.js → runtime/module.js} +8 -1
  21. package/build/devserver/server.d.ts +17 -0
  22. package/build/devserver/server.js +164 -0
  23. package/docs/time.md +2 -2
  24. package/examples/basic/server/migrations/GuestEntry.migration.ts +39 -0
  25. package/package.json +2 -2
  26. package/server/runtime/time.ts +3 -3
  27. package/src/cli/create.ts +38 -1
  28. package/src/cli/db.ts +158 -0
  29. package/src/cli/diagnostics.ts +19 -0
  30. package/src/cli/doctor.ts +20 -0
  31. package/src/cli/index.ts +10 -0
  32. package/src/cli/update.ts +58 -0
  33. package/src/devserver/db/catalog.ts +100 -0
  34. package/src/devserver/db/database.ts +1169 -0
  35. package/src/devserver/db/index.ts +18 -0
  36. package/src/devserver/db/types.ts +76 -0
  37. package/src/devserver/email/index.ts +1 -1
  38. package/src/devserver/index.ts +19 -287
  39. package/src/devserver/{host.ts → runtime/host.ts} +6 -6
  40. package/src/devserver/{module.ts → runtime/module.ts} +13 -1
  41. package/src/devserver/server.ts +292 -0
  42. package/test/db.test.ts +0 -0
  43. package/test/devserver-database.test.ts +114 -9
  44. package/test/devserver-pqauth.test.ts +1 -1
  45. package/test/devserver-secrets.test.ts +5 -1
  46. package/test/doctor.test.ts +13 -0
  47. package/test/example-guestbook.test.ts +43 -1
  48. package/test/pqauth-e2e.test.ts +1 -1
  49. package/build/devserver/database.d.ts +0 -8
  50. package/build/devserver/database.js +0 -418
  51. package/src/devserver/database.ts +0 -618
  52. /package/build/devserver/{dotenv.d.ts → config/dotenv.d.ts} +0 -0
  53. /package/build/devserver/{dotenv.js → config/dotenv.js} +0 -0
  54. /package/build/devserver/{env.d.ts → config/env.d.ts} +0 -0
  55. /package/build/devserver/{env.js → config/env.js} +0 -0
  56. /package/build/devserver/{ratelimit.d.ts → config/ratelimit.d.ts} +0 -0
  57. /package/build/devserver/{ratelimit.js → config/ratelimit.js} +0 -0
  58. /package/build/devserver/{cache.d.ts → http/cache.d.ts} +0 -0
  59. /package/build/devserver/{cache.js → http/cache.js} +0 -0
  60. /package/build/devserver/{envelope.d.ts → http/envelope.d.ts} +0 -0
  61. /package/build/devserver/{envelope.js → http/envelope.js} +0 -0
  62. /package/build/devserver/{proxy.d.ts → http/proxy.d.ts} +0 -0
  63. /package/build/devserver/{proxy.js → http/proxy.js} +0 -0
  64. /package/build/devserver/{crypto.d.ts → runtime/crypto.d.ts} +0 -0
  65. /package/build/devserver/{crypto.js → runtime/crypto.js} +0 -0
  66. /package/src/devserver/{dotenv.ts → config/dotenv.ts} +0 -0
  67. /package/src/devserver/{env.ts → config/env.ts} +0 -0
  68. /package/src/devserver/{ratelimit.ts → config/ratelimit.ts} +0 -0
  69. /package/src/devserver/{cache.ts → http/cache.ts} +0 -0
  70. /package/src/devserver/{envelope.ts → http/envelope.ts} +0 -0
  71. /package/src/devserver/{proxy.ts → http/proxy.ts} +0 -0
  72. /package/src/devserver/{crypto.ts → runtime/crypto.ts} +0 -0
package/src/cli/create.ts CHANGED
@@ -40,6 +40,38 @@ import { isPackageManager, isValidName, resolveProjectDir } from './validate.js'
40
40
 
41
41
  export type Template = 'app' | 'minimal';
42
42
 
43
+ /**
44
+ * The `server/migrations/` README, scaffolded by `create` and ensured by `doctor`/`update`. ToilDB
45
+ * migrations are enforced by convention: a `@migrate` function MUST live in a `*.migration.ts` file
46
+ * under a `migrations/` folder (the toilscript compiler raises a compile error otherwise), and the
47
+ * build auto-discovers every such file. Keeping the convention documented next to the folder means a
48
+ * fresh project ships with a place to evolve `@data` value types from day one.
49
+ */
50
+ export const MIGRATIONS_README =
51
+ '# migrations/\n\n' +
52
+ 'ToilDB schema migrations. One file per evolving `@data` value type, named `<Type>.migration.ts`\n' +
53
+ '(e.g. `User.migration.ts`).\n\n' +
54
+ 'Each file keeps the OLD `@data` shapes (e.g. `UserV1`) alongside the `@migrate` transform(s) that\n' +
55
+ 'carry old records forward, and imports the CURRENT value type from the app. The build\n' +
56
+ 'auto-discovers every `*.migration.ts` under the project.\n\n' +
57
+ 'Do NOT put `@migrate` anywhere else: a `@migrate` outside a `*.migration.ts` file in a\n' +
58
+ '`migrations/` folder is a compile error.\n\n' +
59
+ '## Example\n\n' +
60
+ '`User.migration.ts`:\n\n' +
61
+ '```ts\n' +
62
+ "import { User } from '../models/User';\n\n" +
63
+ '// The previous on-disk shape of a User record.\n' +
64
+ '@data\n' +
65
+ 'export class UserV1 {\n' +
66
+ " name: string = '';\n" +
67
+ '}\n\n' +
68
+ '// Carry a UserV1 forward into the current User. Runs once per record on read.\n' +
69
+ '@migrate\n' +
70
+ 'export function up(old: UserV1, into: User): void {\n' +
71
+ ' into.name = old.name;\n' +
72
+ '}\n' +
73
+ '```\n';
74
+
43
75
  /** Human label for each preprocessor in the styling picker. */
44
76
  const PREPROCESSOR_LABEL: Record<Preprocessor, string> = {
45
77
  css: 'Plain CSS',
@@ -114,7 +146,7 @@ function scaffold(
114
146
  '@types/react-dom': '^19.2.3',
115
147
  eslint: '^10.2.0',
116
148
  prettier: '^3.8.1',
117
- toilscript: '^0.1.36',
149
+ toilscript: '^0.1.37',
118
150
  typescript: '^6.0.3',
119
151
  };
120
152
  for (const dep of requiredPackages(features).sort()) {
@@ -259,6 +291,10 @@ function scaffold(
259
291
  ' npm run build',
260
292
  '',
261
293
  ].join('\n'),
294
+ // ToilDB migrations live under server/migrations/ (folder + `*.migration.ts` is enforced by
295
+ // the compiler; the build auto-discovers them). Ship the folder, documented, with no live
296
+ // *.migration.ts: a @migrate referencing types a fresh project lacks would break the build.
297
+ 'server/migrations/README.md': MIGRATIONS_README,
262
298
  };
263
299
 
264
300
  // The `app` template's client UI + server are copied from examples/basic at runtime; `minimal`
@@ -377,6 +413,7 @@ function minimalServer(): Record<string, string> {
377
413
  '| `main.ts` | The entry point: wires the handler and imports the surface modules. |\n' +
378
414
  '| `core/` | The request handler and shared app logic (state, helpers). |\n' +
379
415
  '| `models/` | `@data` classes, the typed wire model shared by HTTP and RPC. One type per file. |\n' +
416
+ '| `migrations/` | ToilDB schema migrations: a `<Type>.migration.ts` per evolving `@data` value type, holding the old shapes + the `@migrate` transform. |\n' +
380
417
  '| `routes/` | `@rest` controllers (HTTP). One controller per file, named after its class. |\n' +
381
418
  '| `services/` | `@service` classes and free `@remote` functions (typed RPC). |\n' +
382
419
  '| `scheduled/` | Reserved for scheduled tasks (not shipped yet). |\n\n' +
package/src/cli/db.ts ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * `toiljs db <action>` — manage the dev server's on-disk ToilDB data.
3
+ *
4
+ * Under `toiljs dev`, the in-process ToilDB emulator persists every family
5
+ * (records / views / unique / events / membership / counter / capacity) plus each
6
+ * row's schema_version to `<root>/.toil/devdata.json` (so data + migrations survive
7
+ * restarts). These subcommands let you inspect, reset, snapshot, and restore that
8
+ * store from outside a running server — handy for fixtures, sharing repro data, or
9
+ * wiping a corrupt dev state. The snapshot is the exact JSON the dev DB writes, so
10
+ * an exported file imports cleanly (and a hand-crafted one seeds the dev DB).
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ import { accent, bold, danger, dim, success, warn } from './ui.js';
16
+
17
+ export interface DbOptions {
18
+ /** Project root (where `.toil/` lives); defaults to the current directory. */
19
+ root?: string;
20
+ }
21
+
22
+ /** The top-level families a valid snapshot carries (mirrors DbSnapshot). */
23
+ const FAMILIES = ['store', 'views', 'members', 'counters', 'events', 'eventDedup', 'capacity'] as const;
24
+
25
+ function devdataPath(opts: DbOptions): string {
26
+ return path.join(path.resolve(opts.root ?? process.cwd()), '.toil', 'devdata.json');
27
+ }
28
+
29
+ function out(line: string): void {
30
+ process.stdout.write(line + '\n');
31
+ }
32
+
33
+ /** Per-family key counts for a snapshot file, or null if unreadable. */
34
+ function familyCounts(file: string): Record<string, number> | null {
35
+ let snap: Record<string, unknown>;
36
+ try {
37
+ snap = JSON.parse(fs.readFileSync(file, 'utf8')) as Record<string, unknown>;
38
+ } catch {
39
+ return null;
40
+ }
41
+ const counts: Record<string, number> = {};
42
+ for (const f of FAMILIES) {
43
+ const fam = snap[f];
44
+ counts[f] = fam && typeof fam === 'object' ? Object.keys(fam).length : 0;
45
+ }
46
+ return counts;
47
+ }
48
+
49
+ function isSnapshot(v: unknown): boolean {
50
+ return typeof v === 'object' && v !== null && FAMILIES.every((f) => f in (v as object));
51
+ }
52
+
53
+ /** Run `toiljs db <action> [fileArg]`. */
54
+ export function runDb(action: string | undefined, fileArg: string | undefined, opts: DbOptions): void {
55
+ const file = devdataPath(opts);
56
+ const rel = path.relative(process.cwd(), file) || file;
57
+
58
+ switch (action) {
59
+ case 'reset':
60
+ case 'purge': {
61
+ if (!fs.existsSync(file)) {
62
+ out(dim(` dev database already empty (${rel} not found)`));
63
+ return;
64
+ }
65
+ fs.rmSync(file);
66
+ out(success(' ✓ ') + 'dev database reset ' + dim(`(${rel} deleted)`));
67
+ return;
68
+ }
69
+
70
+ case 'export': {
71
+ if (!fs.existsSync(file)) {
72
+ out(warn(' ! ') + `nothing to export (${rel} not found)`);
73
+ process.exitCode = 1;
74
+ return;
75
+ }
76
+ let pretty: string;
77
+ try {
78
+ pretty = JSON.stringify(JSON.parse(fs.readFileSync(file, 'utf8')), null, 2) + '\n';
79
+ } catch {
80
+ out(danger(' ✗ ') + `the dev database at ${rel} is unreadable`);
81
+ process.exitCode = 1;
82
+ return;
83
+ }
84
+ if (fileArg === undefined) {
85
+ process.stdout.write(pretty); // no banner/prefix -> pipe-friendly
86
+ return;
87
+ }
88
+ fs.writeFileSync(path.resolve(fileArg), pretty);
89
+ out(success(' ✓ ') + 'exported dev database to ' + dim(fileArg));
90
+ return;
91
+ }
92
+
93
+ case 'import': {
94
+ if (fileArg === undefined) {
95
+ out(danger(' ✗ ') + 'usage: ' + dim('toiljs db import <file>'));
96
+ process.exitCode = 1;
97
+ return;
98
+ }
99
+ let parsed: unknown;
100
+ try {
101
+ parsed = JSON.parse(fs.readFileSync(path.resolve(fileArg), 'utf8'));
102
+ } catch (e) {
103
+ out(danger(' ✗ ') + `cannot read/parse ${fileArg}: ${(e as Error).message}`);
104
+ process.exitCode = 1;
105
+ return;
106
+ }
107
+ if (!isSnapshot(parsed)) {
108
+ out(danger(' ✗ ') + `${fileArg} is not a toiljs dev database snapshot`);
109
+ process.exitCode = 1;
110
+ return;
111
+ }
112
+ fs.mkdirSync(path.dirname(file), { recursive: true });
113
+ fs.writeFileSync(file, JSON.stringify(parsed)); // compact: the dev DB's on-disk form
114
+ out(success(' ✓ ') + `imported dev database from ${dim(fileArg)} ` + dim(`(${rel})`));
115
+ return;
116
+ }
117
+
118
+ case 'path':
119
+ out(file); // bare path, scriptable
120
+ return;
121
+
122
+ case 'status':
123
+ case 'info': {
124
+ out(bold(' dev database') + dim(` ${rel}`));
125
+ if (!fs.existsSync(file)) {
126
+ out(dim(' (empty — no data written yet; run the dev server to populate it)'));
127
+ return;
128
+ }
129
+ const counts = familyCounts(file);
130
+ if (counts === null) {
131
+ out(danger(' ✗ unreadable snapshot'));
132
+ process.exitCode = 1;
133
+ return;
134
+ }
135
+ out(dim(` ${(fs.statSync(file).size / 1024).toFixed(1)} KiB`));
136
+ const nonEmpty = FAMILIES.filter((f) => counts[f] > 0);
137
+ if (nonEmpty.length === 0) out(dim(' (no rows)'));
138
+ else for (const f of nonEmpty) out(' ' + accent(f.padEnd(12)) + String(counts[f]));
139
+ return;
140
+ }
141
+
142
+ default:
143
+ out(
144
+ [
145
+ bold('Usage') + ' ' + dim('toiljs db') + ' <action>',
146
+ '',
147
+ bold('Actions'),
148
+ ' ' + accent('status'.padEnd(16)) + dim('show the dev DB path + per-family row counts'),
149
+ ' ' + accent('reset'.padEnd(16)) + dim('delete all dev data (alias: purge)'),
150
+ ' ' + accent('export [file]'.padEnd(16)) + dim('write a snapshot to <file> (or stdout)'),
151
+ ' ' + accent('import <file>'.padEnd(16)) + dim('replace the dev DB with a snapshot'),
152
+ ' ' + accent('path'.padEnd(16)) + dim('print the devdata.json path'),
153
+ ].join('\n'),
154
+ );
155
+ if (action !== undefined) process.exitCode = 1; // unknown action
156
+ return;
157
+ }
158
+ }
@@ -521,6 +521,25 @@ export function checkServerTsPlugin(present: boolean): Check {
521
521
  };
522
522
  }
523
523
 
524
+ /**
525
+ * Whether the server has a `migrations/` folder. ToilDB `@migrate` functions MUST live in a
526
+ * `*.migration.ts` file under `migrations/` (the toilscript compiler enforces folder + extension as
527
+ * a compile error), and the build auto-discovers them, so a server project should keep the folder
528
+ * ready. Older projects predate the convention, so this WARNS (does not fail) and points at the
529
+ * one-command fix (`toiljs update` creates it).
530
+ */
531
+ export function checkMigrationsDir(exists: boolean): Check {
532
+ return exists
533
+ ? { id: 'migrations-dir', label: 'server/migrations/ directory', status: 'pass' }
534
+ : {
535
+ id: 'migrations-dir',
536
+ label: 'server/migrations/ directory',
537
+ status: 'warn',
538
+ detail: 'no server/migrations/ folder; ToilDB @migrate functions must live in a *.migration.ts file under it',
539
+ fix: 'Create server/migrations/ (one <Type>.migration.ts per evolving @data value type), or run `toiljs update` to add it.',
540
+ };
541
+ }
542
+
524
543
  // --- Security -------------------------------------------------------------------------------------
525
544
 
526
545
  /** Whether the project uses the auth primitive, and whether its session secret is configured. */
package/src/cli/doctor.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  checkDir,
28
28
  checkDuplicatePatterns,
29
29
  type CheckGroup,
30
+ checkMigrationsDir,
30
31
  checkMountSlots,
31
32
  checkNode,
32
33
  checkPackageManager,
@@ -279,6 +280,24 @@ function serverTsconfigPath(root: string, toilconfig: Record<string, unknown> |
279
280
  return null;
280
281
  }
281
282
 
283
+ /**
284
+ * Whether a `migrations/` folder exists under any server dir (the dir of a toilconfig entry,
285
+ * conventionally `server/`). ToilDB `@migrate` functions live in `*.migration.ts` files there, and
286
+ * the build auto-discovers them; a missing folder is a warn, not a fail (older projects predate it).
287
+ */
288
+ function serverMigrationsExist(root: string, toilconfig: Record<string, unknown> | null): boolean {
289
+ const dirs = new Set<string>();
290
+ const entries = Array.isArray(toilconfig?.entries)
291
+ ? (toilconfig.entries as unknown[]).filter((e): e is string => typeof e === 'string')
292
+ : [];
293
+ for (const e of entries) dirs.add(path.dirname(path.resolve(root, e)));
294
+ if (dirs.size === 0) dirs.add(path.join(root, 'server'));
295
+ for (const dir of dirs) {
296
+ if (fs.existsSync(path.join(dir, 'migrations'))) return true;
297
+ }
298
+ return false;
299
+ }
300
+
282
301
  /** Whether a parsed tsconfig's `compilerOptions.plugins` references the toilscript LS plugin. */
283
302
  function tsconfigHasToilPlugin(tsconfig: Record<string, unknown> | null): boolean {
284
303
  const plugins = asRecord(tsconfig?.compilerOptions)?.plugins;
@@ -840,6 +859,7 @@ export async function runDoctor(opts: DoctorOptions): Promise<void> {
840
859
  checkRestDispatch(restFacts),
841
860
  checkPrettierPlugin(prettierPresent),
842
861
  checkServerTsPlugin(serverTsPluginPresent),
862
+ checkMigrationsDir(serverMigrationsExist(root, toilconfig)),
843
863
  ]
844
864
  : [checkToilconfig(false)],
845
865
  },
package/src/cli/index.ts CHANGED
@@ -8,6 +8,7 @@ import { build, dev, start } from 'toiljs/compiler';
8
8
 
9
9
  import { runConfigure } from './configure.js';
10
10
  import { runCreate, type Template } from './create.js';
11
+ import { runDb } from './db.js';
11
12
  import { runDoctor } from './doctor.js';
12
13
  import { notifyIfOutdated } from './notify.js';
13
14
  import { runUpdate } from './update.js';
@@ -132,6 +133,7 @@ function printHelp(): void {
132
133
  cmd('start', 'self-host the built app (hyper-express / uWS)'),
133
134
  cmd('doctor', 'diagnose project setup and dependencies'),
134
135
  cmd('update', 'check for and apply dependency updates'),
136
+ cmd('db <action>', 'manage dev DB data (status | reset | export | import)'),
135
137
  '',
136
138
  bold('Options'),
137
139
  cmd('--root <dir>', 'project root (default: current directory)'),
@@ -255,6 +257,14 @@ async function main(): Promise<void> {
255
257
  });
256
258
  break;
257
259
 
260
+ case 'db': {
261
+ // `toiljs db <action> [file]` — no banner so `path`/`export` pipe cleanly.
262
+ const action = rest[0];
263
+ const fileArg = rest[1] !== undefined && !rest[1].startsWith('-') ? rest[1] : undefined;
264
+ runDb(action, fileArg, { root: flags.root });
265
+ break;
266
+ }
267
+
258
268
  case 'help':
259
269
  case '--help':
260
270
  case '-h':
package/src/cli/update.ts CHANGED
@@ -9,6 +9,7 @@ import path from 'node:path';
9
9
 
10
10
  import { cancel, intro, isCancel, multiselect, note, outro, spinner } from '@clack/prompts';
11
11
 
12
+ import { MIGRATIONS_README } from './create.js';
12
13
  import { capture, run } from './proc.js';
13
14
  import { buildRows, type Bump, parseNcuJson, type UpdateRow } from './updates.js';
14
15
  import { accent, danger, dim, success, warn } from './ui.js';
@@ -49,6 +50,53 @@ function readDependencies(pkgPath: string): Record<string, string> {
49
50
  return { ...merge(pkg.dependencies), ...merge(pkg.devDependencies) };
50
51
  }
51
52
 
53
+ /**
54
+ * The server dir(s) of a project: the directories of the toilconfig.json `entries`, conventionally
55
+ * `server/`. Falls back to `<root>/server` when there is no toilconfig (or it has no string entries),
56
+ * matching how `doctor` locates the server tree.
57
+ */
58
+ function serverDirs(root: string): string[] {
59
+ const dirs = new Set<string>();
60
+ try {
61
+ const parsed: unknown = JSON.parse(
62
+ fs.readFileSync(path.join(root, 'toilconfig.json'), 'utf8'),
63
+ );
64
+ const entries =
65
+ typeof parsed === 'object' &&
66
+ parsed !== null &&
67
+ Array.isArray((parsed as { entries?: unknown }).entries)
68
+ ? (parsed as { entries: unknown[] }).entries.filter(
69
+ (e): e is string => typeof e === 'string',
70
+ )
71
+ : [];
72
+ for (const e of entries) dirs.add(path.dirname(path.resolve(root, e)));
73
+ } catch {
74
+ // no/unreadable toilconfig.json: fall back to the conventional server/ below
75
+ }
76
+ if (dirs.size === 0) dirs.add(path.join(root, 'server'));
77
+ return [...dirs];
78
+ }
79
+
80
+ /**
81
+ * Ensures the ToilDB `migrations/` folder exists under each server dir that exists. `@migrate`
82
+ * functions MUST live in a `*.migration.ts` file under `migrations/` (folder + extension is a
83
+ * compile error otherwise) and the build auto-discovers them, so an up-to-date project keeps the
84
+ * folder ready. Idempotent: only writes the folder + README where the server dir exists and the
85
+ * folder is absent. Returns the project-relative paths created (for the note), empty if nothing.
86
+ */
87
+ function ensureMigrationsDirs(root: string): string[] {
88
+ const created: string[] = [];
89
+ for (const dir of serverDirs(root)) {
90
+ if (!fs.existsSync(dir)) continue;
91
+ const migrations = path.join(dir, 'migrations');
92
+ if (fs.existsSync(migrations)) continue;
93
+ fs.mkdirSync(migrations, { recursive: true });
94
+ fs.writeFileSync(path.join(migrations, 'README.md'), MIGRATIONS_README);
95
+ created.push(path.relative(root, migrations) || 'migrations');
96
+ }
97
+ return created;
98
+ }
99
+
52
100
  function bumpColor(bump: Bump, text: string): string {
53
101
  if (bump === 'major') return danger(text);
54
102
  if (bump === 'minor') return warn(text);
@@ -85,6 +133,16 @@ export async function runUpdate(opts: UpdateOptions): Promise<void> {
85
133
 
86
134
  intro(accent('toiljs update'));
87
135
 
136
+ // Bring the project's structure up to date: ensure the ToilDB migrations folder exists (it is
137
+ // newer than some projects, and the compiler requires @migrate functions to live under it).
138
+ const createdMigrations = ensureMigrationsDirs(root);
139
+ if (createdMigrations.length > 0) {
140
+ note(
141
+ createdMigrations.map((p) => `${dim('+')} ${p}/`).join('\n'),
142
+ 'Created ToilDB migrations folder',
143
+ );
144
+ }
145
+
88
146
  const s = spinner();
89
147
  s.start('Checking the registry for updates');
90
148
  const res = await capture('npx', ncuArgs(['--jsonUpgraded']), root);
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Parse a compiled server wasm's `toildb.catalog` custom section into a map of
3
+ * `"<Db>/<collection>"` (the resolve key the guest passes to
4
+ * `data.resolve_collection`) -> the collection's CURRENT `schema_version`.
5
+ *
6
+ * The dev DB uses this to STAMP each write with the value type's current schema
7
+ * version. When the developer evolves a `@data` type and rebuilds, the catalog
8
+ * version changes; data already on disk keeps its OLD stamp, so a read surfaces
9
+ * that old version and the guest's woven `decodeInto` runs the `@migrate` - the
10
+ * dev-side equivalent of the edge binding the cap's schema_version into the row.
11
+ *
12
+ * Wire format mirrors `toildb::catalog` / the backend `db_catalog` decoder and the
13
+ * compiler's `buildToilDbCatalog` emitter (all little-endian).
14
+ */
15
+
16
+ /** Read a LEB128 from `buf` at `pos`; throws on overrun (the section is a
17
+ * tenant-built, possibly mid-rebuild wasm, so it must never over-read). */
18
+ import { DataReader } from 'toiljs/io';
19
+
20
+ function leb(buf: Buffer, pos: number): [number, number] {
21
+ let result = 0;
22
+ let shift = 0;
23
+ let p = pos;
24
+ for (;;) {
25
+ if (p >= buf.length) throw new RangeError('leb128 past end of buffer');
26
+ const b = buf[p++];
27
+ result |= (b & 0x7f) << shift;
28
+ if ((b & 0x80) === 0) break;
29
+ shift += 7;
30
+ if (shift > 35) throw new RangeError('leb128 too long');
31
+ }
32
+ return [result >>> 0, p];
33
+ }
34
+
35
+ /** The bytes of the named wasm custom section, or null if absent. Bounds-checked
36
+ * so a truncated/garbage module can never read past the buffer. */
37
+ function customSection(wasm: Buffer, want: string): Buffer | null {
38
+ let pos = 8; // skip the 8-byte magic + version header
39
+ while (pos < wasm.length) {
40
+ const id = wasm[pos++];
41
+ let size: number;
42
+ [size, pos] = leb(wasm, pos);
43
+ const end = pos + size;
44
+ if (end > wasm.length || end < pos) return null; // truncated section table
45
+ if (id === 0) {
46
+ let nameLen: number;
47
+ let namePos: number;
48
+ [nameLen, namePos] = leb(wasm, pos);
49
+ if (namePos + nameLen <= end && wasm.toString('latin1', namePos, namePos + nameLen) === want)
50
+ return wasm.subarray(namePos + nameLen, end);
51
+ }
52
+ pos = end;
53
+ }
54
+ return null;
55
+ }
56
+
57
+ /** `"<Db>/<collection>"` -> current `schema_version` for every collection. Decoded
58
+ * with the shared bounds-checked {@link DataReader} (it returns 0/empty + flips
59
+ * `.ok` past the end), so a truncated/garbage section - e.g. a mid-rebuild wasm -
60
+ * yields only the collections that decoded cleanly and never over-reads. Mirrors
61
+ * the compiler's `buildToilDbCatalog` emitter + the backend `db_catalog` decoder. */
62
+ export function parseCatalog(wasm: Buffer): Map<string, number> {
63
+ const out = new Map<string, number>();
64
+ let sec: Buffer | null;
65
+ try {
66
+ sec = customSection(wasm, 'toildb.catalog');
67
+ } catch {
68
+ return out; // garbage section table (mid-rebuild) -> no catalog
69
+ }
70
+ if (sec === null) return out;
71
+
72
+ const r = new DataReader(sec);
73
+ r.readU16(); // catalog format version
74
+ const ndb = r.readU16();
75
+ for (let d = 0; d < ndb && r.ok; d++) {
76
+ const db = r.readString();
77
+ const nc = r.readU16();
78
+ for (let c = 0; c < nc && r.ok; c++) {
79
+ const name = r.readString();
80
+ r.readU8(); // family
81
+ r.readString(); // keyType
82
+ r.readString(); // valueType
83
+ r.readU32(); // valueDataId
84
+ const schemaVersion = r.readU32();
85
+ r.readU32(); // generation
86
+ r.readU8(); // replication (emitter order: replication then placement)
87
+ r.readU8(); // placement
88
+ const nFields = r.readU16();
89
+ for (let f = 0; f < nFields; f++) {
90
+ r.readString(); // field name
91
+ r.readString(); // field type
92
+ r.readU8(); // isArray
93
+ }
94
+ const nMig = r.readU16();
95
+ for (let m = 0; m < nMig; m++) r.readU32(); // migratableFrom versions
96
+ if (r.ok) out.set(db + '/' + name, schemaVersion >>> 0);
97
+ }
98
+ }
99
+ return out;
100
+ }