@mandujs/core 0.41.2 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. package/src/utils/bun.ts +8 -8
@@ -1,476 +1,477 @@
1
- /**
2
- * Phase 4c — Schema + migration file orchestration.
3
- *
4
- * The piece of the generator that runs ONCE per project (not once per
5
- * resource): computes the desired `Snapshot` from all persistent
6
- * resources, diffs it against `applied.json`, and composes:
7
- *
8
- * - Per-resource CREATE TABLE snapshots at
9
- * `.mandu/generated/server/schema/{table}.sql` (derived — docs for humans).
10
- * - An auto-generated migration file at
11
- * `spec/db/migrations/NNNN_auto_<timestamp>.sql` when changes exist.
12
- *
13
- * # Snapshot state model (pending vs applied)
14
- *
15
- * There is exactly ONE on-disk snapshot file: `.mandu/schema/applied.json`.
16
- * Ownership:
17
- *
18
- * - Agent C's migration runner WRITES it after a successful `mandu db apply`.
19
- * - This module (Agent D) only READS it.
20
- *
21
- * We deliberately do NOT maintain a separate `pending.json`. Rationale:
22
- * 1. The "pending" state is ephemeral — it's whatever
23
- * `snapshotFromResources(resources)` returns right now. Persisting
24
- * it would create a third source of truth that could drift from
25
- * both the resource files and the migration file.
26
- * 2. The migration file itself is the durable artifact. Its checksum
27
- * (computed by Agent C's runner) is what detects drift between
28
- * "what we planned" and "what was applied".
29
- * 3. If the user runs `mandu db plan` twice without applying, we want
30
- * the second run to pick up the FIRST auto-migration that's still
31
- * pending and diff against THAT cumulatively, not re-emit the same
32
- * migration. This module implements that by considering only files
33
- * in the migrations dir that are not yet applied — see
34
- * `readPendingMigrationsCount`.
35
- *
36
- * # Filename sequencing
37
- *
38
- * `writeSchemaArtifacts` scans `spec/db/migrations` for the highest
39
- * existing NNNN prefix, assigns NNNN+1, and never overwrites any file
40
- * already present (respects `MIGRATION_FILE_RE` from the runner). User-
41
- * edited migrations are sacred: the generator will only ever ADD new
42
- * files at higher sequence numbers.
43
- *
44
- * # Path traversal defense (Phase 4c.R4 security audit — H-01)
45
- *
46
- * `tableName` originates from:
47
- * - `persistence.tableName` — validated by `asPersistence` (only
48
- * `[A-Za-z_][A-Za-z0-9_]*` accepted).
49
- * - `options.pluralName` — pre-4c field, NOT format-validated at
50
- * resource-load time.
51
- * - auto-pluralized `resource.name` — resource.name is validated by
52
- * `validateResourceDefinition` (same alphabet).
53
- *
54
- * To close the remaining gap (pluralName), `writeSchemaArtifacts`
55
- * verifies every resolved table name against a conservative identifier
56
- * regex AND asserts the `path.join` result stays under
57
- * `resourceSchemaOutDir` before touching the filesystem. Same for the
58
- * auto-migration file (whose name is NNNN + ISO timestamp, both under
59
- * our control, but routed through the same guard for uniformity).
60
- *
61
- * # References
62
- *
63
- * - docs/rfcs/0001-db-resource-layer.md §D3 (resource → DDL auto-derived)
64
- * - docs/rfcs/0001-db-resource-layer.md §D4 (self-rolled migration runner)
65
- * - docs/rfcs/0001-db-resource-layer.md Appendix D (post-4a normative)
66
- * - docs/security/phase-4c-audit.md §H-01 (path traversal remediation)
67
- * - packages/core/src/db/migrations/runner.ts (Agent C — applied.json owner)
68
- */
69
-
70
- import { promises as fs } from "node:fs";
71
- import path from "node:path";
72
-
73
- import type { ParsedResource } from "./parser";
74
- import type { Change, Snapshot, SqlProvider } from "./ddl/types";
75
- import { diffSnapshots } from "./ddl/diff";
76
- import { emitChanges, emitCreateTable, emitSchema } from "./ddl/emit";
77
- import { parseSnapshot, snapshotFromResources } from "./ddl/snapshot";
78
- import { resolveGeneratedPaths } from "../paths";
79
-
80
- // ============================================
81
- // Public API — types
82
- // ============================================
83
-
84
- /** The combined output of `computeSchemaGeneration`. */
85
- export interface SchemaGenerationResult {
86
- /** The next snapshot that WOULD be applied (for logging / debugging). */
87
- nextSnapshot: Snapshot;
88
- /** Full desired schema SQL (all CREATE TABLE blocks concatenated). */
89
- desiredSchema: string;
90
- /** Per-resource CREATE TABLE slices keyed by table name. */
91
- desiredSchemaByTable: Record<string, string>;
92
- /** Changes vs `applied.json`; empty array if schema unchanged. */
93
- changes: Change[];
94
- /** Migration SQL body (without BEGIN/COMMIT); empty string if no changes. */
95
- migrationSql: string;
96
- /**
97
- * Suggested migration filename (relative, `NNNN_auto_<ts>.sql`) — `null`
98
- * when `changes` is empty. Final NNNN is assigned at write time by
99
- * `writeSchemaArtifacts` which scans the migrations directory; this
100
- * value is a preview and may differ if another process adds a file
101
- * between `computeSchemaGeneration` and `writeSchemaArtifacts`.
102
- */
103
- migrationFilename: string | null;
104
- /** Which provider the nextSnapshot targets. Mirror of `nextSnapshot.provider`. */
105
- provider: SqlProvider;
106
- }
107
-
108
- /** The result of the write step. All fields absolute paths or booleans. */
109
- export interface WriteSchemaArtifactsResult {
110
- /** Number of per-resource schema files written. */
111
- schemaFilesWritten: number;
112
- /** Absolute paths of every schema file written (may include overwrites). */
113
- schemaFilePaths: string[];
114
- /** Absolute path of the migration file written, or `null` if no changes. */
115
- migrationFilePath: string | null;
116
- /** The assigned NNNN for the migration (preserves the numeric sequence). */
117
- migrationVersion: string | null;
118
- }
119
-
120
- // ============================================
121
- // Public API — compute
122
- // ============================================
123
-
124
- /**
125
- * Compute the diff between the current resource files and the applied
126
- * snapshot. Does NOT write anything to disk.
127
- *
128
- * Steps:
129
- * 1. Filter to persistent resources via `snapshotFromResources`
130
- * (non-persistent resources are silently dropped).
131
- * 2. Read `.mandu/schema/applied.json`. Missing → `null` → first-run.
132
- * 3. Diff via `diffSnapshots`.
133
- * 4. Compose SQL outputs.
134
- *
135
- * Throws if resources declare mixed providers (delegated to
136
- * `snapshotFromResources`), or if applied.json exists but is malformed.
137
- */
138
- export async function computeSchemaGeneration(
139
- resources: readonly ParsedResource[],
140
- rootDir: string,
141
- /**
142
- * Provider override — useful for CLI flags where the operator wants to
143
- * generate DDL for a different target than what's declared in the
144
- * resources (e.g. initial setup). When omitted, the provider is
145
- * derived from the resources' persistence blocks. When resources
146
- * conflict with the override, we THROW so the caller realizes they
147
- * need to align the two.
148
- */
149
- provider?: SqlProvider,
150
- ): Promise<SchemaGenerationResult> {
151
- const nextSnapshot = snapshotFromResources(resources);
152
- const paths = resolveGeneratedPaths(rootDir);
153
-
154
- if (provider !== undefined && nextSnapshot.resources.length > 0 && nextSnapshot.provider !== provider) {
155
- throw new TypeError(
156
- `computeSchemaGeneration: provider override "${provider}" conflicts with ` +
157
- `resource-declared provider "${nextSnapshot.provider}". Align persistence.provider on ` +
158
- `your resources or drop the override.`,
159
- );
160
- }
161
- if (provider !== undefined && nextSnapshot.resources.length === 0) {
162
- // Empty resource set — override the fallback so the caller's intent is honored.
163
- (nextSnapshot as { provider: SqlProvider }).provider = provider;
164
- }
165
-
166
- const applied = await readAppliedSnapshot(paths.schemaStateDir);
167
-
168
- const changes = diffSnapshots(applied, nextSnapshot);
169
-
170
- // Per-resource schema snippets (for the human-readable
171
- // `.mandu/generated/server/schema/{table}.sql` files).
172
- const desiredSchemaByTable: Record<string, string> = {};
173
- for (const resource of nextSnapshot.resources) {
174
- desiredSchemaByTable[resource.name] =
175
- emitCreateTable(resource, nextSnapshot.provider);
176
- }
177
-
178
- const desiredSchema = emitSchema(nextSnapshot.resources, nextSnapshot.provider);
179
- const migrationSql = changes.length > 0 ? composeMigrationSql(changes, nextSnapshot.provider) : "";
180
-
181
- return {
182
- nextSnapshot,
183
- desiredSchema,
184
- desiredSchemaByTable,
185
- changes,
186
- migrationSql,
187
- migrationFilename:
188
- changes.length > 0 ? previewMigrationFilename() : null,
189
- provider: nextSnapshot.provider,
190
- };
191
- }
192
-
193
- // ============================================
194
- // Public API — write
195
- // ============================================
196
-
197
- /**
198
- * User-derived segment whitelist. Matches `SAFE_PERSISTENCE_IDENTIFIER_RE`
199
- * from `ddl/persistence-types.ts`. Starts with a letter or `_`, then
200
- * letters / digits / underscores only. No `.`, `/`, `\`, spaces, shell
201
- * metachars, control chars — path traversal impossible.
202
- *
203
- * Applied to: per-resource `tableName` keys in `desiredSchemaByTable`,
204
- * which can originate from `options.pluralName` or `persistence.tableName`.
205
- * (Both ultimately flow through `snapshot.ts:resolveTableName`.)
206
- */
207
- const SAFE_TABLE_FILE_SEGMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
208
-
209
- /**
210
- * Generator-derived segment whitelist. The auto-migration filename is
211
- * `${NNNN}_auto_${ISO_TIMESTAMP_WITH_DASHES}` where the ISO timestamp
212
- * has had `:` + `.` replaced with `-`. The character set reduces to
213
- * digits, `_`, `-`, `T`, `Z`. This segment is NEVER user-derived but
214
- * the path guard is applied for uniformity.
215
- */
216
- const SAFE_MIGRATION_FILE_SEGMENT_RE = /^[A-Za-z0-9_\-]+$/;
217
-
218
- /**
219
- * Join `dir` + `segment + suffix` and ensure the resolved path stays
220
- * strictly under `dir`. Validates `segment` against `allowRe` first
221
- * (blocks `..`, `/`, `\`, control chars), then resolves and asserts
222
- * containment as defense-in-depth.
223
- *
224
- * Defense-in-depth rationale (H-01 from Phase 4c audit):
225
- * - `asPersistence` catches malicious `tableName` / `columnName` /
226
- * `indexes[].name` at narrowing time.
227
- * - This check catches anything that slipped through — e.g.
228
- * `options.pluralName` (pre-4c, no runtime format check) — AND
229
- * asserts the resolved path never escapes `dir` even if a future
230
- * refactor loosens the regex.
231
- */
232
- function safeJoinSegment(
233
- dir: string,
234
- segment: string,
235
- suffix: string,
236
- allowRe: RegExp,
237
- ): string {
238
- if (typeof segment !== "string" || segment.length === 0) {
239
- throw new TypeError(`safeJoinSegment: segment must be a non-empty string`);
240
- }
241
- if (!allowRe.test(segment)) {
242
- throw new Error(
243
- `[@mandujs/core/resource] refused to write file whose name segment ${JSON.stringify(segment)} ` +
244
- `does not match ${allowRe}. This blocks path-traversal via resource-derived names.`,
245
- );
246
- }
247
- const joined = path.join(dir, `${segment}${suffix}`);
248
- const resolvedDir = path.resolve(dir);
249
- const resolvedJoin = path.resolve(joined);
250
- // Must live strictly inside `resolvedDir` — i.e. share the exact
251
- // prefix + path separator. The equality check on `path.join` guards
252
- // against cross-platform resolution surprises (mixed separators,
253
- // UNC paths on Windows).
254
- if (
255
- resolvedJoin !== path.join(resolvedDir, `${segment}${suffix}`) ||
256
- !resolvedJoin.startsWith(resolvedDir + path.sep)
257
- ) {
258
- throw new Error(
259
- `[@mandujs/core/resource] refused to write outside ${resolvedDir}: resolved path ${resolvedJoin}`,
260
- );
261
- }
262
- return joined;
263
- }
264
-
265
- /**
266
- * Write per-resource schema snippets and (if changes exist) a new
267
- * migration file to disk.
268
- *
269
- * Guarantees:
270
- * - Never overwrites an existing `NNNN_*.sql` file in the migrations
271
- * directory. The next sequence number is assigned at write time
272
- * based on a fresh scan.
273
- * - `.mandu/schema/applied.json` is NEVER written from this module.
274
- * Agent C's migration runner owns that file and writes it only
275
- * after a successful `mandu db apply`. This keeps drift detection
276
- * meaningful: `applied.json` always reflects what the DB actually
277
- * has, not what we intended to apply.
278
- * - Creates parent directories as needed (`mkdir -p` semantics).
279
- * - Rejects any `tableName` / migration version that would resolve
280
- * outside the target directory (see `safeJoinSegment`).
281
- *
282
- * Returns the paths of written files so the caller can log / report to
283
- * the user.
284
- */
285
- export async function writeSchemaArtifacts(
286
- result: SchemaGenerationResult,
287
- rootDir: string,
288
- ): Promise<WriteSchemaArtifactsResult> {
289
- const paths = resolveGeneratedPaths(rootDir);
290
-
291
- const schemaFilePaths: string[] = [];
292
-
293
- if (Object.keys(result.desiredSchemaByTable).length > 0) {
294
- await ensureDir(paths.resourceSchemaOutDir);
295
- for (const [tableName, sql] of Object.entries(result.desiredSchemaByTable)) {
296
- const filePath = safeJoinSegment(
297
- paths.resourceSchemaOutDir,
298
- tableName,
299
- ".sql",
300
- SAFE_TABLE_FILE_SEGMENT_RE,
301
- );
302
- // Schema files are DERIVED — always regenerate. Format the file
303
- // with a header so human readers don't confuse it with a migration.
304
- const body = `-- @generated by Mandu — do not edit.
305
- -- Source: spec/resources (resource definition)
306
- -- Regenerate with \`mandu generate\` or \`mandu db plan\`.
307
- --
308
- -- NOTE: This file is a SNAPSHOT of the current desired schema. It is
309
- -- NOT applied by the migration runner. For changes to reach your
310
- -- database, use the NNNN_*.sql files in spec/db/migrations instead.
311
-
312
- ${sql}
313
- `;
314
- await fs.writeFile(filePath, body, "utf8");
315
- schemaFilePaths.push(filePath);
316
- }
317
- }
318
-
319
- let migrationFilePath: string | null = null;
320
- let migrationVersion: string | null = null;
321
-
322
- if (result.migrationSql.length > 0 && result.changes.length > 0) {
323
- await ensureDir(paths.migrationsDir);
324
- const nextVersion = await findNextMigrationVersion(paths.migrationsDir);
325
- // Timestamp chars are a fixed [0-9:T.-Z] subset from
326
- // `new Date().toISOString()`; after the `[:.]` → `-` replacement
327
- // only `[0-9T-Z]` remain — safe for a file segment. Auto-migration
328
- // names are never user-derived but we route through `safeJoinSegment`
329
- // for uniformity.
330
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
331
- const filenameSegment = `${nextVersion}_auto_${timestamp}`;
332
- migrationFilePath = safeJoinSegment(
333
- paths.migrationsDir,
334
- filenameSegment,
335
- ".sql",
336
- SAFE_MIGRATION_FILE_SEGMENT_RE,
337
- );
338
- migrationVersion = nextVersion;
339
-
340
- const body = `-- @generated by Mandu — human-editable.
341
- -- Auto-generated on: ${new Date().toISOString()}
342
- -- Changes detected: ${result.changes.length}
343
- -- Target provider: ${result.provider}
344
- --
345
- -- This file was composed by \`mandu db plan\`. Review it, edit if
346
- -- necessary, and apply with \`mandu db apply\`. You own this file after
347
- -- it's created — the generator will NEVER overwrite it.
348
-
349
- ${result.migrationSql}`;
350
- await fs.writeFile(migrationFilePath, body, "utf8");
351
- }
352
-
353
- return {
354
- schemaFilesWritten: schemaFilePaths.length,
355
- schemaFilePaths,
356
- migrationFilePath,
357
- migrationVersion,
358
- };
359
- }
360
-
361
- // ============================================
362
- // Internals — migration SQL composition
363
- // ============================================
364
-
365
- /**
366
- * Wrap the sequence of `Change` → SQL emission with a transaction
367
- * header/footer. SQLite uses `BEGIN` / `COMMIT` (plain) because its
368
- * migration runner invokes each file via `db.transaction()` anyway —
369
- * but the explicit BEGIN/COMMIT is harmless inside an already-open tx
370
- * and makes the file runnable standalone via `sqlite3 foo.db < file.sql`.
371
- */
372
- function composeMigrationSql(changes: readonly Change[], provider: SqlProvider): string {
373
- const body = emitChanges(changes, provider);
374
- if (body.length === 0) return "";
375
- return `BEGIN;
376
-
377
- ${body}
378
-
379
- COMMIT;`;
380
- }
381
-
382
- // ============================================
383
- // Internals — filesystem I/O
384
- // ============================================
385
-
386
- async function readAppliedSnapshot(schemaStateDir: string): Promise<Snapshot | null> {
387
- const appliedPath = path.join(schemaStateDir, "applied.json");
388
- let raw: string;
389
- try {
390
- raw = await fs.readFile(appliedPath, "utf8");
391
- } catch (err) {
392
- const code = (err as { code?: string }).code;
393
- if (code === "ENOENT") {
394
- // First run — no applied snapshot yet. Diff engine accepts null
395
- // and emits a `create-table` change per resource.
396
- return null;
397
- }
398
- throw err;
399
- }
400
- try {
401
- return parseSnapshot(raw);
402
- } catch (err) {
403
- // Malformed applied.json is operator-visible; rethrow with a hint.
404
- const msg = err instanceof Error ? err.message : String(err);
405
- throw new Error(
406
- `[computeSchemaGeneration] Failed to parse ${appliedPath}: ${msg}. ` +
407
- `Either revert the file to its prior state or delete it to force a full re-create.`,
408
- );
409
- }
410
- }
411
-
412
- /**
413
- * Scan `spec/db/migrations/` and return the next zero-padded 4-digit
414
- * sequence. First run "0001". If existing files use wider padding
415
- * (e.g. "12345_foo.sql"), the next returned version matches that width.
416
- *
417
- * Missing directory → "0001". Non-migration files in the directory are
418
- * ignored the regex matches the runner's.
419
- */
420
- async function findNextMigrationVersion(migrationsDir: string): Promise<string> {
421
- const re = /^(\d{4,})_[^/\\]+\.sql$/i;
422
- let entries: string[];
423
- try {
424
- entries = await fs.readdir(migrationsDir);
425
- } catch (err) {
426
- const code = (err as { code?: string }).code;
427
- if (code === "ENOENT") return "0001";
428
- throw err;
429
- }
430
-
431
- let max = 0;
432
- let width = 4;
433
- for (const entry of entries) {
434
- const match = re.exec(entry);
435
- if (!match) continue;
436
- const version = match[1]!;
437
- const n = Number.parseInt(version, 10);
438
- if (Number.isFinite(n) && n > max) max = n;
439
- if (version.length > width) width = version.length;
440
- }
441
-
442
- const next = (max + 1).toString();
443
- // Zero-pad to width; if the new number exceeds the old width (overflow
444
- // from e.g. 9999 10000), grow the width so padding remains consistent.
445
- if (next.length > width) width = next.length;
446
- return next.padStart(width, "0");
447
- }
448
-
449
- function previewMigrationFilename(): string {
450
- // Preview is deliberately fuzzy: the real NNNN is assigned at write
451
- // time in `writeSchemaArtifacts`. Consumers should treat this as
452
- // informational only.
453
- const ts = new Date().toISOString().replace(/[:.]/g, "-");
454
- return `NNNN_auto_${ts}.sql`;
455
- }
456
-
457
- async function ensureDir(dir: string): Promise<void> {
458
- await fs.mkdir(dir, { recursive: true });
459
- }
460
-
461
- // ============================================
462
- // Internals — test hooks
463
- // ============================================
464
-
465
- /**
466
- * Exposed for unit tests ONLY. Consumers must NOT reach in here — these
467
- * helpers are private API.
468
- */
469
- export const _internalForTests = {
470
- findNextMigrationVersion,
471
- composeMigrationSql,
472
- readAppliedSnapshot,
473
- safeJoinSegment,
474
- SAFE_TABLE_FILE_SEGMENT_RE,
475
- SAFE_MIGRATION_FILE_SEGMENT_RE,
476
- };
1
+ /**
2
+ * Phase 4c — Schema + migration file orchestration.
3
+ *
4
+ * The piece of the generator that runs ONCE per project (not once per
5
+ * resource): computes the desired `Snapshot` from all persistent
6
+ * resources, diffs it against `applied.json`, and composes:
7
+ *
8
+ * - Per-resource CREATE TABLE snapshots at
9
+ * `.mandu/generated/server/schema/{table}.sql` (derived — docs for humans).
10
+ * - An auto-generated migration file at
11
+ * `spec/db/migrations/NNNN_auto_<timestamp>.sql` when changes exist.
12
+ *
13
+ * # Snapshot state model (pending vs applied)
14
+ *
15
+ * There is exactly ONE on-disk snapshot file: `.mandu/schema/applied.json`.
16
+ * Ownership:
17
+ *
18
+ * - Agent C's migration runner WRITES it after a successful `mandu db apply`.
19
+ * - This module (Agent D) only READS it.
20
+ *
21
+ * We deliberately do NOT maintain a separate `pending.json`. Rationale:
22
+ * 1. The "pending" state is ephemeral — it's whatever
23
+ * `snapshotFromResources(resources)` returns right now. Persisting
24
+ * it would create a third source of truth that could drift from
25
+ * both the resource files and the migration file.
26
+ * 2. The migration file itself is the durable artifact. Its checksum
27
+ * (computed by Agent C's runner) is what detects drift between
28
+ * "what we planned" and "what was applied".
29
+ * 3. If the user runs `mandu db plan` twice without applying, we want
30
+ * the second run to pick up the FIRST auto-migration that's still
31
+ * pending and diff against THAT cumulatively, not re-emit the same
32
+ * migration. This module implements that by considering only files
33
+ * in the migrations dir that are not yet applied — see
34
+ * `readPendingMigrationsCount`.
35
+ *
36
+ * # Filename sequencing
37
+ *
38
+ * `writeSchemaArtifacts` scans `spec/db/migrations` for the highest
39
+ * existing NNNN prefix, assigns NNNN+1, and never overwrites any file
40
+ * already present (respects `MIGRATION_FILE_RE` from the runner). User-
41
+ * edited migrations are sacred: the generator will only ever ADD new
42
+ * files at higher sequence numbers.
43
+ *
44
+ * # Path traversal defense (Phase 4c.R4 security audit — H-01)
45
+ *
46
+ * `tableName` originates from:
47
+ * - `persistence.tableName` — validated by `asPersistence` (only
48
+ * `[A-Za-z_][A-Za-z0-9_]*` accepted).
49
+ * - `options.pluralName` — pre-4c field, NOT format-validated at
50
+ * resource-load time.
51
+ * - auto-pluralized `resource.name` — resource.name is validated by
52
+ * `validateResourceDefinition` (same alphabet).
53
+ *
54
+ * To close the remaining gap (pluralName), `writeSchemaArtifacts`
55
+ * verifies every resolved table name against a conservative identifier
56
+ * regex AND asserts the `path.join` result stays under
57
+ * `resourceSchemaOutDir` before touching the filesystem. Same for the
58
+ * auto-migration file (whose name is NNNN + ISO timestamp, both under
59
+ * our control, but routed through the same guard for uniformity).
60
+ *
61
+ * # References
62
+ *
63
+ * - docs/rfcs/0001-db-resource-layer.md §D3 (resource → DDL auto-derived)
64
+ * - docs/rfcs/0001-db-resource-layer.md §D4 (self-rolled migration runner)
65
+ * - docs/rfcs/0001-db-resource-layer.md Appendix D (post-4a normative)
66
+ * - docs/security/phase-4c-audit.md §H-01 (path traversal remediation)
67
+ * - packages/core/src/db/migrations/runner.ts (Agent C — applied.json owner)
68
+ */
69
+
70
+ import { promises as fs } from "node:fs";
71
+ import path from "node:path";
72
+
73
+ import type { ParsedResource } from "./parser";
74
+ import type { Change, Snapshot, SqlProvider } from "./ddl/types";
75
+ import { diffSnapshots } from "./ddl/diff";
76
+ import { emitChanges, emitCreateTable, emitSchema } from "./ddl/emit";
77
+ import { parseSnapshot, snapshotFromResources } from "./ddl/snapshot";
78
+ import { resolveGeneratedPaths } from "../paths";
79
+
80
+ // ============================================
81
+ // Public API — types
82
+ // ============================================
83
+
84
+ /** The combined output of `computeSchemaGeneration`. */
85
+ export interface SchemaGenerationResult {
86
+ /** The next snapshot that WOULD be applied (for logging / debugging). */
87
+ nextSnapshot: Snapshot;
88
+ /** Full desired schema SQL (all CREATE TABLE blocks concatenated). */
89
+ desiredSchema: string;
90
+ /** Per-resource CREATE TABLE slices keyed by table name. */
91
+ desiredSchemaByTable: Record<string, string>;
92
+ /** Changes vs `applied.json`; empty array if schema unchanged. */
93
+ changes: Change[];
94
+ /** Migration SQL body (without BEGIN/COMMIT); empty string if no changes. */
95
+ migrationSql: string;
96
+ /**
97
+ * Suggested migration filename (relative, `NNNN_auto_<ts>.sql`) — `null`
98
+ * when `changes` is empty. Final NNNN is assigned at write time by
99
+ * `writeSchemaArtifacts` which scans the migrations directory; this
100
+ * value is a preview and may differ if another process adds a file
101
+ * between `computeSchemaGeneration` and `writeSchemaArtifacts`.
102
+ */
103
+ migrationFilename: string | null;
104
+ /** Which provider the nextSnapshot targets. Mirror of `nextSnapshot.provider`. */
105
+ provider: SqlProvider;
106
+ }
107
+
108
+ /** The result of the write step. All fields absolute paths or booleans. */
109
+ export interface WriteSchemaArtifactsResult {
110
+ /** Number of per-resource schema files written. */
111
+ schemaFilesWritten: number;
112
+ /** Absolute paths of every schema file written (may include overwrites). */
113
+ schemaFilePaths: string[];
114
+ /** Absolute path of the migration file written, or `null` if no changes. */
115
+ migrationFilePath: string | null;
116
+ /** The assigned NNNN for the migration (preserves the numeric sequence). */
117
+ migrationVersion: string | null;
118
+ }
119
+
120
+ // ============================================
121
+ // Public API — compute
122
+ // ============================================
123
+
124
+ /**
125
+ * Compute the diff between the current resource files and the applied
126
+ * snapshot. Does NOT write anything to disk.
127
+ *
128
+ * Steps:
129
+ * 1. Filter to persistent resources via `snapshotFromResources`
130
+ * (non-persistent resources are silently dropped).
131
+ * 2. Read `.mandu/schema/applied.json`. Missing → `null` → first-run.
132
+ * 3. Diff via `diffSnapshots`.
133
+ * 4. Compose SQL outputs.
134
+ *
135
+ * Throws if resources declare mixed providers (delegated to
136
+ * `snapshotFromResources`), or if applied.json exists but is malformed.
137
+ */
138
+ export async function computeSchemaGeneration(
139
+ resources: readonly ParsedResource[],
140
+ rootDir: string,
141
+ /**
142
+ * Provider override — useful for CLI flags where the operator wants to
143
+ * generate DDL for a different target than what's declared in the
144
+ * resources (e.g. initial setup). When omitted, the provider is
145
+ * derived from the resources' persistence blocks. When resources
146
+ * conflict with the override, we THROW so the caller realizes they
147
+ * need to align the two.
148
+ */
149
+ provider?: SqlProvider,
150
+ ): Promise<SchemaGenerationResult> {
151
+ const nextSnapshot = snapshotFromResources(resources);
152
+ const paths = resolveGeneratedPaths(rootDir);
153
+
154
+ if (provider !== undefined && nextSnapshot.resources.length > 0 && nextSnapshot.provider !== provider) {
155
+ throw new TypeError(
156
+ `computeSchemaGeneration: provider override "${provider}" conflicts with ` +
157
+ `resource-declared provider "${nextSnapshot.provider}". Align persistence.provider on ` +
158
+ `your resources or drop the override.`,
159
+ );
160
+ }
161
+ if (provider !== undefined && nextSnapshot.resources.length === 0) {
162
+ // Empty resource set — override the fallback so the caller's intent is honored.
163
+ (nextSnapshot as { provider: SqlProvider }).provider = provider;
164
+ }
165
+
166
+ const applied = await readAppliedSnapshot(paths.schemaStateDir);
167
+
168
+ const changes = diffSnapshots(applied, nextSnapshot);
169
+
170
+ // Per-resource schema snippets (for the human-readable
171
+ // `.mandu/generated/server/schema/{table}.sql` files).
172
+ const desiredSchemaByTable: Record<string, string> = {};
173
+ for (const resource of nextSnapshot.resources) {
174
+ desiredSchemaByTable[resource.name] =
175
+ emitCreateTable(resource, nextSnapshot.provider);
176
+ }
177
+
178
+ const desiredSchema = emitSchema(nextSnapshot.resources, nextSnapshot.provider);
179
+ const migrationSql = changes.length > 0 ? composeMigrationSql(changes, nextSnapshot.provider) : "";
180
+
181
+ return {
182
+ nextSnapshot,
183
+ desiredSchema,
184
+ desiredSchemaByTable,
185
+ changes,
186
+ migrationSql,
187
+ migrationFilename:
188
+ changes.length > 0 ? previewMigrationFilename() : null,
189
+ provider: nextSnapshot.provider,
190
+ };
191
+ }
192
+
193
+ // ============================================
194
+ // Public API — write
195
+ // ============================================
196
+
197
+ /**
198
+ * User-derived segment whitelist. Matches `SAFE_PERSISTENCE_IDENTIFIER_RE`
199
+ * from `ddl/persistence-types.ts`. Starts with a letter or `_`, then
200
+ * letters / digits / underscores only. No `.`, `/`, `\`, spaces, shell
201
+ * metachars, control chars — path traversal impossible.
202
+ *
203
+ * Applied to: per-resource `tableName` keys in `desiredSchemaByTable`,
204
+ * which can originate from `options.pluralName` or `persistence.tableName`.
205
+ * (Both ultimately flow through `snapshot.ts:resolveTableName`.)
206
+ */
207
+ const SAFE_TABLE_FILE_SEGMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
208
+
209
+ /**
210
+ * Generator-derived segment whitelist. The auto-migration filename is
211
+ * `${NNNN}_auto_${ISO_TIMESTAMP_WITH_DASHES}` where the ISO timestamp
212
+ * has had `:` + `.` replaced with `-`. The character set reduces to
213
+ * digits, `_`, `-`, `T`, `Z`. This segment is NEVER user-derived but
214
+ * the path guard is applied for uniformity.
215
+ */
216
+ const SAFE_MIGRATION_FILE_SEGMENT_RE = /^[A-Za-z0-9_\-]+$/;
217
+
218
+ /**
219
+ * Join `dir` + `segment + suffix` and ensure the resolved path stays
220
+ * strictly under `dir`. Validates `segment` against `allowRe` first
221
+ * (blocks `..`, `/`, `\`, control chars), then resolves and asserts
222
+ * containment as defense-in-depth.
223
+ *
224
+ * Defense-in-depth rationale (H-01 from Phase 4c audit):
225
+ * - `asPersistence` catches malicious `tableName` / `columnName` /
226
+ * `indexes[].name` at narrowing time.
227
+ * - This check catches anything that slipped through — e.g.
228
+ * `options.pluralName` (pre-4c, no runtime format check) — AND
229
+ * asserts the resolved path never escapes `dir` even if a future
230
+ * refactor loosens the regex.
231
+ */
232
+ function safeJoinSegment(
233
+ dir: string,
234
+ segment: string,
235
+ suffix: string,
236
+ allowRe: RegExp,
237
+ ): string {
238
+ if (typeof segment !== "string" || segment.length === 0) {
239
+ throw new TypeError(`safeJoinSegment: segment must be a non-empty string`);
240
+ }
241
+ if (!allowRe.test(segment)) {
242
+ throw new Error(
243
+ `[@mandujs/core/resource] refused to write file whose name segment ${JSON.stringify(segment)} ` +
244
+ `does not match ${allowRe}. This blocks path-traversal via resource-derived names.`,
245
+ );
246
+ }
247
+ const joined = path.join(dir, `${segment}${suffix}`);
248
+ const resolvedDir = path.resolve(dir);
249
+ const resolvedJoin = path.resolve(joined);
250
+ // Must live strictly inside `resolvedDir` — i.e. share the exact
251
+ // prefix + path separator. The equality check on `path.join` guards
252
+ // against cross-platform resolution surprises (mixed separators,
253
+ // UNC paths on Windows).
254
+ if (
255
+ resolvedJoin !== path.join(resolvedDir, `${segment}${suffix}`) ||
256
+ !resolvedJoin.startsWith(resolvedDir + path.sep)
257
+ ) {
258
+ throw new Error(
259
+ `[@mandujs/core/resource] refused to write outside ${resolvedDir}: resolved path ${resolvedJoin}`,
260
+ );
261
+ }
262
+ return joined;
263
+ }
264
+
265
+ /**
266
+ * Write per-resource schema snippets and (if changes exist) a new
267
+ * migration file to disk.
268
+ *
269
+ * Guarantees:
270
+ * - Never overwrites an existing `NNNN_*.sql` file in the migrations
271
+ * directory. The next sequence number is assigned at write time
272
+ * based on a fresh scan.
273
+ * - `.mandu/schema/applied.json` is NEVER written from this module.
274
+ * Agent C's migration runner owns that file and writes it only
275
+ * after a successful `mandu db apply`. This keeps drift detection
276
+ * meaningful: `applied.json` always reflects what the DB actually
277
+ * has, not what we intended to apply.
278
+ * - Creates parent directories as needed (`mkdir -p` semantics).
279
+ * - Rejects any `tableName` / migration version that would resolve
280
+ * outside the target directory (see `safeJoinSegment`).
281
+ *
282
+ * Returns the paths of written files so the caller can log / report to
283
+ * the user.
284
+ */
285
+ export async function writeSchemaArtifacts(
286
+ result: SchemaGenerationResult,
287
+ rootDir: string,
288
+ ): Promise<WriteSchemaArtifactsResult> {
289
+ const paths = resolveGeneratedPaths(rootDir);
290
+
291
+ const schemaFilePaths: string[] = [];
292
+
293
+ if (Object.keys(result.desiredSchemaByTable).length > 0) {
294
+ await ensureDir(paths.resourceSchemaOutDir);
295
+ for (const [tableName, sql] of Object.entries(result.desiredSchemaByTable)) {
296
+ const filePath = safeJoinSegment(
297
+ paths.resourceSchemaOutDir,
298
+ tableName,
299
+ ".sql",
300
+ SAFE_TABLE_FILE_SEGMENT_RE,
301
+ );
302
+ // Schema files are DERIVED — always regenerate. Format the file
303
+ // with a header so human readers don't confuse it with a migration.
304
+ const body = `-- @generated by Mandu — do not edit.
305
+ -- Source: spec/resources (resource definition)
306
+ -- Regenerate with \`mandu generate\` or \`mandu db plan\`.
307
+ --
308
+ -- NOTE: This file is a SNAPSHOT of the current desired schema. It is
309
+ -- NOT applied by the migration runner. For changes to reach your
310
+ -- database, use the NNNN_*.sql files in spec/db/migrations instead.
311
+
312
+ ${sql}
313
+ `;
314
+ await fs.writeFile(filePath, body, "utf8");
315
+ schemaFilePaths.push(filePath);
316
+ }
317
+ }
318
+
319
+ let migrationFilePath: string | null = null;
320
+ let migrationVersion: string | null = null;
321
+
322
+ if (result.migrationSql.length > 0 && result.changes.length > 0) {
323
+ await ensureDir(paths.migrationsDir);
324
+ const nextVersion = await findNextMigrationVersion(paths.migrationsDir);
325
+ // Timestamp chars are a fixed [0-9:T.-Z] subset from
326
+ // `new Date().toISOString()`; after the `[:.]` → `-` replacement
327
+ // only `[0-9T-Z]` remain — safe for a file segment. Auto-migration
328
+ // names are never user-derived but we route through `safeJoinSegment`
329
+ // for uniformity.
330
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
331
+ const filenameSegment = `${nextVersion}_auto_${timestamp}`;
332
+ migrationFilePath = safeJoinSegment(
333
+ paths.migrationsDir,
334
+ filenameSegment,
335
+ ".sql",
336
+ SAFE_MIGRATION_FILE_SEGMENT_RE,
337
+ );
338
+ migrationVersion = nextVersion;
339
+
340
+ const body = `-- @generated by Mandu — human-editable.
341
+ -- Auto-generated on: ${new Date().toISOString()}
342
+ -- Changes detected: ${result.changes.length}
343
+ -- Target provider: ${result.provider}
344
+ --
345
+ -- This file was composed by \`mandu db plan\`. Review it, edit if
346
+ -- necessary, and apply with \`mandu db apply\`. You own this file after
347
+ -- it's created — the generator will NEVER overwrite it.
348
+
349
+ ${result.migrationSql}`;
350
+ await fs.writeFile(migrationFilePath, body, "utf8");
351
+ }
352
+
353
+ return {
354
+ schemaFilesWritten: schemaFilePaths.length,
355
+ schemaFilePaths,
356
+ migrationFilePath,
357
+ migrationVersion,
358
+ };
359
+ }
360
+
361
+ // ============================================
362
+ // Internals — migration SQL composition
363
+ // ============================================
364
+
365
+ /**
366
+ * Wrap the sequence of `Change` → SQL emission with a transaction
367
+ * header/footer. SQLite uses `BEGIN` / `COMMIT` (plain) because its
368
+ * migration runner invokes each file via `db.transaction()` anyway —
369
+ * but the explicit BEGIN/COMMIT is harmless inside an already-open tx
370
+ * and makes the file runnable standalone via `sqlite3 foo.db < file.sql`.
371
+ */
372
+ function composeMigrationSql(changes: readonly Change[], provider: SqlProvider): string {
373
+ const body = emitChanges(changes, provider);
374
+ if (body.length === 0) return "";
375
+ return `BEGIN;
376
+
377
+ ${body}
378
+
379
+ COMMIT;`;
380
+ }
381
+
382
+ // ============================================
383
+ // Internals — filesystem I/O
384
+ // ============================================
385
+
386
+ async function readAppliedSnapshot(schemaStateDir: string): Promise<Snapshot | null> {
387
+ const appliedPath = path.join(schemaStateDir, "applied.json");
388
+ let raw: string;
389
+ try {
390
+ raw = await fs.readFile(appliedPath, "utf8");
391
+ } catch (err) {
392
+ const code = (err as { code?: string }).code;
393
+ if (code === "ENOENT") {
394
+ // First run — no applied snapshot yet. Diff engine accepts null
395
+ // and emits a `create-table` change per resource.
396
+ return null;
397
+ }
398
+ throw err;
399
+ }
400
+ try {
401
+ return parseSnapshot(raw);
402
+ } catch (err) {
403
+ // Malformed applied.json is operator-visible; rethrow with a hint.
404
+ const msg = err instanceof Error ? err.message : String(err);
405
+ throw new Error(
406
+ `[computeSchemaGeneration] Failed to parse ${appliedPath}: ${msg}. ` +
407
+ `Either revert the file to its prior state or delete it to force a full re-create.`,
408
+ { cause: err },
409
+ );
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Scan `spec/db/migrations/` and return the next zero-padded 4-digit
415
+ * sequence. First run → "0001". If existing files use wider padding
416
+ * (e.g. "12345_foo.sql"), the next returned version matches that width.
417
+ *
418
+ * Missing directory "0001". Non-migration files in the directory are
419
+ * ignored — the regex matches the runner's.
420
+ */
421
+ async function findNextMigrationVersion(migrationsDir: string): Promise<string> {
422
+ const re = /^(\d{4,})_[^/\\]+\.sql$/i;
423
+ let entries: string[];
424
+ try {
425
+ entries = await fs.readdir(migrationsDir);
426
+ } catch (err) {
427
+ const code = (err as { code?: string }).code;
428
+ if (code === "ENOENT") return "0001";
429
+ throw err;
430
+ }
431
+
432
+ let max = 0;
433
+ let width = 4;
434
+ for (const entry of entries) {
435
+ const match = re.exec(entry);
436
+ if (!match) continue;
437
+ const version = match[1]!;
438
+ const n = Number.parseInt(version, 10);
439
+ if (Number.isFinite(n) && n > max) max = n;
440
+ if (version.length > width) width = version.length;
441
+ }
442
+
443
+ const next = (max + 1).toString();
444
+ // Zero-pad to width; if the new number exceeds the old width (overflow
445
+ // from e.g. 9999 → 10000), grow the width so padding remains consistent.
446
+ if (next.length > width) width = next.length;
447
+ return next.padStart(width, "0");
448
+ }
449
+
450
+ function previewMigrationFilename(): string {
451
+ // Preview is deliberately fuzzy: the real NNNN is assigned at write
452
+ // time in `writeSchemaArtifacts`. Consumers should treat this as
453
+ // informational only.
454
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
455
+ return `NNNN_auto_${ts}.sql`;
456
+ }
457
+
458
+ async function ensureDir(dir: string): Promise<void> {
459
+ await fs.mkdir(dir, { recursive: true });
460
+ }
461
+
462
+ // ============================================
463
+ // Internals — test hooks
464
+ // ============================================
465
+
466
+ /**
467
+ * Exposed for unit tests ONLY. Consumers must NOT reach in here — these
468
+ * helpers are private API.
469
+ */
470
+ export const _internalForTests = {
471
+ findNextMigrationVersion,
472
+ composeMigrationSql,
473
+ readAppliedSnapshot,
474
+ safeJoinSegment,
475
+ SAFE_TABLE_FILE_SEGMENT_RE,
476
+ SAFE_MIGRATION_FILE_SEGMENT_RE,
477
+ };