toiljs 0.0.59 → 0.0.61

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 (158) hide show
  1. package/.github/workflows/ci.yml +31 -0
  2. package/CHANGELOG.md +15 -0
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/index.js +311 -118
  5. package/build/client/.tsbuildinfo +1 -1
  6. package/build/client/index.d.ts +1 -1
  7. package/build/client/index.js +1 -1
  8. package/build/client/routing/mount.js +12 -1
  9. package/build/client/ssr/markers.d.ts +1 -0
  10. package/build/client/ssr/markers.js +3 -0
  11. package/build/compiler/.tsbuildinfo +1 -1
  12. package/build/compiler/config.d.ts +21 -0
  13. package/build/compiler/config.js +35 -0
  14. package/build/compiler/docs.d.ts +2 -1
  15. package/build/compiler/docs.js +33 -304
  16. package/build/compiler/index.d.ts +13 -0
  17. package/build/compiler/index.js +113 -21
  18. package/build/compiler/template-build.d.ts +21 -1
  19. package/build/compiler/template-build.js +110 -26
  20. package/build/compiler/toil-docs.generated.d.ts +1 -0
  21. package/build/compiler/toil-docs.generated.js +20 -0
  22. package/build/devserver/.tsbuildinfo +1 -1
  23. package/build/devserver/daemon/catalog.d.ts +26 -0
  24. package/build/devserver/daemon/catalog.js +48 -0
  25. package/build/devserver/daemon/cron.d.ts +4 -0
  26. package/build/devserver/daemon/cron.js +50 -0
  27. package/build/devserver/daemon/host.d.ts +37 -0
  28. package/build/devserver/daemon/host.js +94 -0
  29. package/build/devserver/daemon/index.d.ts +34 -0
  30. package/build/devserver/daemon/index.js +241 -0
  31. package/build/devserver/db/catalog.d.ts +2 -0
  32. package/build/devserver/db/catalog.js +80 -0
  33. package/build/devserver/db/database.d.ts +80 -0
  34. package/build/devserver/db/database.js +1032 -0
  35. package/build/devserver/db/index.d.ts +3 -0
  36. package/build/devserver/db/index.js +3 -0
  37. package/build/devserver/db/routeKinds.d.ts +8 -0
  38. package/build/devserver/db/routeKinds.js +139 -0
  39. package/build/devserver/db/types.d.ts +121 -0
  40. package/build/devserver/db/types.js +52 -0
  41. package/build/devserver/email/index.js +1 -1
  42. package/build/devserver/index.d.ts +19 -24
  43. package/build/devserver/index.js +11 -165
  44. package/build/devserver/mstore/store.d.ts +18 -0
  45. package/build/devserver/mstore/store.js +82 -0
  46. package/build/devserver/{host.d.ts → runtime/host.d.ts} +7 -1
  47. package/build/devserver/{host.js → runtime/host.js} +51 -7
  48. package/build/devserver/{module.d.ts → runtime/module.d.ts} +2 -1
  49. package/build/devserver/{module.js → runtime/module.js} +34 -1
  50. package/build/devserver/server.d.ts +23 -0
  51. package/build/devserver/server.js +223 -0
  52. package/build/devserver/ssr.d.ts +25 -0
  53. package/build/devserver/ssr.js +114 -0
  54. package/build/devserver/wasm/sections.d.ts +2 -0
  55. package/build/devserver/wasm/sections.js +42 -0
  56. package/build/devserver/wasm/surface.d.ts +18 -0
  57. package/build/devserver/wasm/surface.js +41 -0
  58. package/docs/README.md +4 -4
  59. package/docs/auth-todo.md +6 -6
  60. package/docs/caching.md +5 -5
  61. package/docs/cli.md +15 -0
  62. package/docs/client.md +40 -0
  63. package/docs/crypto.md +4 -4
  64. package/docs/data.md +6 -6
  65. package/docs/email.md +28 -28
  66. package/docs/environment.md +10 -10
  67. package/docs/index.md +26 -0
  68. package/docs/ratelimit.md +10 -10
  69. package/docs/routing.md +2 -2
  70. package/docs/server.md +61 -0
  71. package/docs/ssr.md +561 -113
  72. package/docs/styling.md +22 -0
  73. package/docs/time.md +3 -3
  74. package/eslint.config.js +10 -1
  75. package/examples/basic/client/components/Header.tsx +3 -0
  76. package/examples/basic/client/routes/features/actions.tsx +0 -2
  77. package/examples/basic/client/routes/hello.tsx +89 -19
  78. package/examples/basic/client/styles/main.css +48 -0
  79. package/examples/basic/server/SsrHelloRender.ts +97 -0
  80. package/examples/basic/server/main.ts +5 -0
  81. package/examples/basic/server/migrations/GuestEntry.migration.ts +39 -0
  82. package/examples/basic/server/streams/Echo.ts +49 -0
  83. package/package.json +12 -10
  84. package/scripts/gen-toil-docs.mjs +96 -0
  85. package/server/runtime/time.ts +3 -3
  86. package/src/cli/create.ts +40 -3
  87. package/src/cli/db.ts +158 -0
  88. package/src/cli/diagnostics.ts +19 -0
  89. package/src/cli/doctor.ts +20 -0
  90. package/src/cli/index.ts +10 -0
  91. package/src/cli/update.ts +58 -0
  92. package/src/client/index.ts +1 -1
  93. package/src/client/routing/mount.tsx +18 -2
  94. package/src/client/ssr/markers.tsx +22 -0
  95. package/src/compiler/config.ts +88 -2
  96. package/src/compiler/docs.ts +47 -308
  97. package/src/compiler/index.ts +236 -32
  98. package/src/compiler/ssr-codegen.ts +1 -1
  99. package/src/compiler/template-build.ts +247 -46
  100. package/src/compiler/toil-docs.generated.ts +26 -0
  101. package/src/devserver/daemon/catalog.ts +120 -0
  102. package/src/devserver/daemon/cron.ts +87 -0
  103. package/src/devserver/daemon/host.ts +224 -0
  104. package/src/devserver/daemon/index.ts +349 -0
  105. package/src/devserver/db/catalog.ts +108 -0
  106. package/src/devserver/db/database.ts +1633 -0
  107. package/src/devserver/db/index.ts +18 -0
  108. package/src/devserver/db/routeKinds.ts +147 -0
  109. package/src/devserver/db/types.ts +139 -0
  110. package/src/devserver/email/index.ts +1 -1
  111. package/src/devserver/index.ts +31 -287
  112. package/src/devserver/mstore/store.ts +121 -0
  113. package/src/devserver/{host.ts → runtime/host.ts} +98 -7
  114. package/src/devserver/{module.ts → runtime/module.ts} +47 -1
  115. package/src/devserver/server.ts +393 -0
  116. package/src/devserver/ssr.ts +166 -0
  117. package/src/devserver/wasm/sections.ts +59 -0
  118. package/src/devserver/wasm/surface.ts +88 -0
  119. package/test/daemon-build.test.ts +198 -0
  120. package/test/daemon-catalog.test.ts +265 -0
  121. package/test/daemon-emulation.test.ts +216 -0
  122. package/test/db.test.ts +0 -0
  123. package/test/devserver-database.test.ts +510 -14
  124. package/test/devserver-pqauth.test.ts +1 -1
  125. package/test/devserver-secrets.test.ts +5 -1
  126. package/test/doctor.test.ts +13 -0
  127. package/test/email-preview.test.ts +6 -1
  128. package/test/example-guestbook.test.ts +43 -1
  129. package/test/fixtures/daemon-app.ts +56 -0
  130. package/test/global-setup.ts +17 -0
  131. package/test/pqauth-e2e.test.ts +1 -1
  132. package/test/ssr-render.test.ts +94 -27
  133. package/test/ssr-template.test.tsx +44 -1
  134. package/vitest.config.ts +3 -0
  135. package/build/devserver/database.d.ts +0 -8
  136. package/build/devserver/database.js +0 -418
  137. package/src/devserver/database.ts +0 -618
  138. /package/build/devserver/{dotenv.d.ts → config/dotenv.d.ts} +0 -0
  139. /package/build/devserver/{dotenv.js → config/dotenv.js} +0 -0
  140. /package/build/devserver/{env.d.ts → config/env.d.ts} +0 -0
  141. /package/build/devserver/{env.js → config/env.js} +0 -0
  142. /package/build/devserver/{ratelimit.d.ts → config/ratelimit.d.ts} +0 -0
  143. /package/build/devserver/{ratelimit.js → config/ratelimit.js} +0 -0
  144. /package/build/devserver/{cache.d.ts → http/cache.d.ts} +0 -0
  145. /package/build/devserver/{cache.js → http/cache.js} +0 -0
  146. /package/build/devserver/{envelope.d.ts → http/envelope.d.ts} +0 -0
  147. /package/build/devserver/{envelope.js → http/envelope.js} +0 -0
  148. /package/build/devserver/{proxy.d.ts → http/proxy.d.ts} +0 -0
  149. /package/build/devserver/{proxy.js → http/proxy.js} +0 -0
  150. /package/build/devserver/{crypto.d.ts → runtime/crypto.d.ts} +0 -0
  151. /package/build/devserver/{crypto.js → runtime/crypto.js} +0 -0
  152. /package/src/devserver/{dotenv.ts → config/dotenv.ts} +0 -0
  153. /package/src/devserver/{env.ts → config/env.ts} +0 -0
  154. /package/src/devserver/{ratelimit.ts → config/ratelimit.ts} +0 -0
  155. /package/src/devserver/{cache.ts → http/cache.ts} +0 -0
  156. /package/src/devserver/{envelope.ts → http/envelope.ts} +0 -0
  157. /package/src/devserver/{proxy.ts → http/proxy.ts} +0 -0
  158. /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()) {
@@ -163,9 +195,9 @@ function scaffold(
163
195
  // Generated files don't need formatting. (toilscript server decorators like @main /
164
196
  // @remote-on-functions are handled by the toiljs/prettier-plugin, so server/ is not ignored.)
165
197
  '.prettierignore':
166
- 'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\nserver/_emails.ts\nserver/toil-server-env.d.ts\n',
198
+ 'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\nserver/_emails.ts\nserver/_ssr/\nserver/toil-server-env.d.ts\n',
167
199
  '.gitignore':
168
- 'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\nhosts/*/_tmpl/\n# Local dev env vars/secrets (never commit)\n.env\n.env.secrets\n',
200
+ 'node_modules\nbuild\n.toil\nshared/server.ts\ntoil-env.d.ts\ntoil-routes.d.ts\nserver/_ssr/\nhosts/*/_tmpl/\n# Local dev env vars/secrets (never commit)\n.env\n.env.secrets\n',
169
201
  // Use the project's pinned TypeScript (node_modules) instead of VS Code's bundled
170
202
  // version, and prompt to switch, so the editor loads the toilscript LS plugin wired
171
203
  // in server/tsconfig.json (which clears the @database / @data editor false positives).
@@ -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);
@@ -102,5 +102,5 @@ export { Slot } from './components/Slot.js';
102
102
  export type { SlotProps } from './components/Slot.js';
103
103
  export { Server } from './rpc.js';
104
104
  export { parseError } from './errors.js';
105
- export { Hole, RawHtml, Repeat, Island, __setSsrBuild, __isSsrBuild } from './ssr/markers.js';
105
+ export { Hole, RawHtml, Repeat, Island, attr, __setSsrBuild, __isSsrBuild } from './ssr/markers.js';
106
106
  export type { HoleProps, RawHtmlProps, RepeatProps, IslandProps } from './ssr/markers.js';
@@ -65,9 +65,15 @@ export function mount(
65
65
  // branch, and the dev-only imports, are dead-code-eliminated and tree-shaken from production.
66
66
  if ((import.meta as unknown as { env: { DEV: boolean } }).env.DEV) {
67
67
  initDevErrorOverlay();
68
- createRoot(el).render(
68
+ // Dev tools (error overlay + toolbar) render into their OWN body-level
69
+ // container, never inside #root, so they don't perturb #root's markup.
70
+ // That lets a dev SSR document hydrate cleanly (the server only rendered
71
+ // the app into #root), and is harmless for a plain client-rendered page.
72
+ const devEl = document.createElement('div');
73
+ devEl.id = '__toil_dev';
74
+ document.body.appendChild(devEl);
75
+ createRoot(devEl).render(
69
76
  <>
70
- <DevErrorBoundary>{app}</DevErrorBoundary>
71
77
  <DevErrorOverlay />
72
78
  <DevToolbar
73
79
  routes={routes}
@@ -75,6 +81,16 @@ export function mount(
75
81
  />
76
82
  </>,
77
83
  );
84
+ const tree = <DevErrorBoundary>{app}</DevErrorBoundary>;
85
+ if (isSsrDocument()) {
86
+ // Dev edge-SSR: the dev server served real server-rendered markup
87
+ // (guest `render` + splice); hydrate it in place, same as production,
88
+ // instead of client-rendering from scratch.
89
+ seedSsrHydration(routes);
90
+ hydrateRoot(el, tree);
91
+ } else {
92
+ createRoot(el).render(tree);
93
+ }
78
94
  } else if (isSsrDocument()) {
79
95
  // Edge-SSR: the document already holds server-rendered markup. Seed the
80
96
  // loader cache from `#__toil_state` and hydrate in place (reuse the DOM)
@@ -76,6 +76,28 @@ export function Hole(props: HoleProps): ReactNode {
76
76
  return createElement(Fragment, null, props.children);
77
77
  }
78
78
 
79
+ /**
80
+ * An attribute-value hole. Unlike the child markers, an attribute is not a child
81
+ * node, so this is a *function* used in attribute position rather than a JSX
82
+ * element:
83
+ *
84
+ * ```tsx
85
+ * <a href={attr('profileUrl', d.url)} class={attr('cls', d.cls)}>…</a>
86
+ * ```
87
+ *
88
+ * Browser: returns `value` unchanged, so the attribute renders normally. Build:
89
+ * returns a PUA sentinel token, so the extractor records an `attr` slot at the
90
+ * attribute's byte offset (the token sits between the quotes and strips to zero
91
+ * bytes). The guest fills it per request with the React-escaped value (kind
92
+ * `attr`), which the host splices at that offset — exactly like a text hole, but
93
+ * inside an attribute. It composes with surrounding literal text
94
+ * (`` `btn ${attr('x', v)}` ``), and `setAttr`/`HtmlBuilder.attr` produce the
95
+ * value on the server side.
96
+ */
97
+ export function attr(id: string, value: string): string {
98
+ return ssrBuild ? token(HoleKindChar.Attr, id) : value;
99
+ }
100
+
79
101
  export interface RawHtmlProps {
80
102
  id: string;
81
103
  /** Raw HTML string. The author owns sanitisation (same as React
@@ -97,8 +97,32 @@ export interface ClientConfig {
97
97
  }
98
98
 
99
99
  /**
100
- * Server-side (toilscript WASM) configuration. Reserved: the compiler does not yet
101
- * build the server target via `toil build`; today it is compiled by `toilscript` directly.
100
+ * Dev node mode: which layer the single dev process emulates. `hot` runs only the
101
+ * request path (today's behavior); `regional`/`continental` would run streams off
102
+ * `release-hot.wasm` (Phase 4); `daemon` runs `release-cold.wasm`; `all` runs every
103
+ * surface in one process (the default for a full local run). DEV / self-host knob;
104
+ * the production edge reads the authoritative role from per-host TCF + the plan.
105
+ */
106
+ export type DevNodeMode = 'hot' | 'regional' | 'continental' | 'daemon' | 'all';
107
+
108
+ /** Daemon (L4) config mirror (dev / self-host). All optional. */
109
+ export interface DaemonConfig {
110
+ /** Region the daemon is pinned to (informational in dev; the dev process is leader). */
111
+ readonly region?: string;
112
+ /** Warm standby region (informational in dev). */
113
+ readonly standbyRegion?: string;
114
+ /** Default `@scheduled` interval (ms) when a task declares none. Default 60000. */
115
+ readonly defaultIntervalMs?: number;
116
+ /** Per-tick wall-clock budget (ms) before the dev scheduler logs an overrun. Default 30000. */
117
+ readonly tickBudgetMs?: number;
118
+ /** Per-tick gas cap (dev stub; charged-then-ignored). Mirrors `plan.gas_scheduled`. */
119
+ readonly gasTick?: number;
120
+ /** Max number of `@scheduled` tasks (mirrors `max_scheduled_tasks`). Default 64. */
121
+ readonly maxTasks?: number;
122
+ }
123
+
124
+ /**
125
+ * Server-side (toilscript → WASM) configuration.
102
126
  */
103
127
  export interface ServerConfig {
104
128
  /** Server source directory, relative to root. Default `server`. */
@@ -114,6 +138,20 @@ export interface ServerConfig {
114
138
  * the per-tenant env store); this drives `toiljs dev` / self-host.
115
139
  */
116
140
  readonly email?: EmailBackendConfig;
141
+ /** Which layer the dev process emulates. Default `all`. */
142
+ readonly nodeMode?: DevNodeMode;
143
+ /** Daemon (L4) config mirror (dev / self-host). */
144
+ readonly daemon?: DaemonConfig;
145
+ }
146
+
147
+ /** Fully-resolved {@link DaemonConfig}; every field non-optional, defaults applied. */
148
+ export interface ResolvedDaemonConfig {
149
+ readonly region: string | null;
150
+ readonly standbyRegion: string | null;
151
+ readonly defaultIntervalMs: number;
152
+ readonly tickBudgetMs: number;
153
+ readonly gasTick: number;
154
+ readonly maxTasks: number;
117
155
  }
118
156
 
119
157
  /**
@@ -157,6 +195,10 @@ export interface ResolvedToilConfig {
157
195
  readonly seo: SeoConfig | null;
158
196
  /** The `server.email` backend config (dev / self-host), or `null` when unset. */
159
197
  readonly email: EmailBackendConfig | null;
198
+ /** Which layer the dev process emulates (dev / self-host). Default `all`. */
199
+ readonly nodeMode: DevNodeMode;
200
+ /** Daemon (L4) config mirror (dev / self-host), every field resolved. */
201
+ readonly daemon: ResolvedDaemonConfig;
160
202
  /** Absolute path to the framework client runtime (`toiljs/client`). */
161
203
  readonly runtimePath: string;
162
204
  readonly vite: InlineConfig;
@@ -229,7 +271,51 @@ export async function loadConfig(
229
271
  : null,
230
272
  seo: client.seo ?? null,
231
273
  email: user.server?.email ?? null,
274
+ nodeMode: resolveNodeMode(user.server?.nodeMode),
275
+ daemon: resolveDaemonConfig(user.server?.daemon),
232
276
  runtimePath: resolveRuntimePath(),
233
277
  vite: client.vite ?? {},
234
278
  };
235
279
  }
280
+
281
+ const DEV_NODE_MODES: readonly DevNodeMode[] = [
282
+ 'hot',
283
+ 'regional',
284
+ 'continental',
285
+ 'daemon',
286
+ 'all',
287
+ ];
288
+
289
+ /** A `nodeMode` outside the enum falls back to `all` with a warning (fail-soft:
290
+ * the authoritative role is the edge's TCF + plan, so dev never bricks on it). */
291
+ function resolveNodeMode(mode: DevNodeMode | undefined): DevNodeMode {
292
+ if (mode === undefined) return 'all';
293
+ if (DEV_NODE_MODES.includes(mode)) return mode;
294
+ process.stdout.write(
295
+ ` ! server.nodeMode '${mode}' is not a valid node mode; falling back to 'all'\n`,
296
+ );
297
+ return 'all';
298
+ }
299
+
300
+ /** Resolve the daemon config with defaults + light, fail-soft clamping (never
301
+ * throws; the authoritative caps are the edge's). */
302
+ function resolveDaemonConfig(d: DaemonConfig | undefined): ResolvedDaemonConfig {
303
+ // The dev scheduler uses setInterval; a sub-second loop floods the console.
304
+ let defaultIntervalMs = d?.defaultIntervalMs ?? 60000;
305
+ if (defaultIntervalMs < 1000) {
306
+ process.stdout.write(
307
+ ` ! server.daemon.defaultIntervalMs ${String(defaultIntervalMs)} < 1000; clamping to 1000\n`,
308
+ );
309
+ defaultIntervalMs = 1000;
310
+ }
311
+ let maxTasks = d?.maxTasks ?? 64;
312
+ if (maxTasks <= 0 || maxTasks > 1024) maxTasks = Math.min(1024, Math.max(1, maxTasks || 64));
313
+ return {
314
+ region: d?.region ?? null,
315
+ standbyRegion: d?.standbyRegion ?? null,
316
+ defaultIntervalMs,
317
+ tickBudgetMs: d?.tickBudgetMs ?? 30000,
318
+ gasTick: d?.gasTick ?? 0,
319
+ maxTasks,
320
+ };
321
+ }