@mandujs/core 0.20.10 → 0.22.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 (127) hide show
  1. package/README.md +2 -1
  2. package/package.json +28 -3
  3. package/src/auth/__tests__/login.test.ts +419 -0
  4. package/src/auth/__tests__/password.test.ts +122 -0
  5. package/src/auth/__tests__/reset.test.ts +296 -0
  6. package/src/auth/__tests__/tokens.test.ts +274 -0
  7. package/src/auth/__tests__/verification.test.ts +274 -0
  8. package/src/auth/index.ts +76 -0
  9. package/src/auth/login.ts +225 -0
  10. package/src/auth/password.ts +120 -0
  11. package/src/auth/reset.ts +243 -0
  12. package/src/auth/tokens.ts +612 -0
  13. package/src/auth/verification.ts +253 -0
  14. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  15. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  16. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  17. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  18. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  19. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  20. package/src/bundler/__tests__/hdr.test.ts +353 -0
  21. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  22. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  23. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  24. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  25. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  26. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  27. package/src/bundler/build.test.ts +8 -1
  28. package/src/bundler/build.ts +495 -37
  29. package/src/bundler/css.ts +326 -323
  30. package/src/bundler/dev.ts +1671 -80
  31. package/src/bundler/fast-refresh-plugin.ts +307 -0
  32. package/src/bundler/hmr-types.ts +252 -0
  33. package/src/bundler/manifest-schema.ts +301 -0
  34. package/src/bundler/safe-build.test.ts +128 -0
  35. package/src/bundler/safe-build.ts +77 -0
  36. package/src/bundler/scenario-matrix.ts +229 -0
  37. package/src/bundler/types.ts +19 -0
  38. package/src/bundler/vendor-cache-types.ts +130 -0
  39. package/src/bundler/vendor-cache.ts +526 -0
  40. package/src/client/router.ts +214 -56
  41. package/src/config/validate.ts +1 -0
  42. package/src/db/__tests__/db.test.ts +485 -0
  43. package/src/db/index.ts +513 -0
  44. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  45. package/src/db/migrations/history-table.ts +345 -0
  46. package/src/db/migrations/lock.ts +269 -0
  47. package/src/db/migrations/runner.ts +633 -0
  48. package/src/desktop/__tests__/smoke.test.ts +100 -0
  49. package/src/desktop/__tests__/window.test.ts +172 -0
  50. package/src/desktop/__tests__/worker.test.ts +266 -0
  51. package/src/desktop/index.ts +43 -0
  52. package/src/desktop/types.ts +158 -0
  53. package/src/desktop/window.ts +492 -0
  54. package/src/desktop/worker.ts +180 -0
  55. package/src/devtools/ai/mcp-connector.ts +18 -16
  56. package/src/devtools/client/components/mandu-character.tsx +4 -1
  57. package/src/devtools/client/components/panel/panel-container.tsx +20 -5
  58. package/src/email/__tests__/email.test.ts +355 -0
  59. package/src/email/index.ts +282 -0
  60. package/src/email/resend.ts +163 -0
  61. package/src/email/smtp.ts +64 -0
  62. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  63. package/src/filling/context.ts +72 -78
  64. package/src/filling/cookie-codec.ts +299 -0
  65. package/src/filling/deps.ts +25 -1
  66. package/src/filling/filling.ts +28 -3
  67. package/src/filling/session-sqlite.ts +617 -0
  68. package/src/filling/session.ts +265 -216
  69. package/src/guard/decision-memory.test.ts +52 -22
  70. package/src/id/__tests__/id.test.ts +120 -0
  71. package/src/id/index.ts +105 -0
  72. package/src/kitchen/index.ts +2 -2
  73. package/src/kitchen/kitchen-handler.ts +86 -0
  74. package/src/kitchen/stream/activity-sse.ts +2 -1
  75. package/src/middleware/csrf.ts +328 -0
  76. package/src/middleware/index.ts +40 -0
  77. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  78. package/src/middleware/oauth/index.ts +505 -0
  79. package/src/middleware/oauth/providers.ts +115 -0
  80. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  81. package/src/middleware/rate-limit/index.ts +522 -0
  82. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  83. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  84. package/src/middleware/secure/csp.ts +193 -0
  85. package/src/middleware/secure/index.ts +417 -0
  86. package/src/middleware/session.ts +174 -0
  87. package/src/observability/event-bus.ts +81 -79
  88. package/src/paths.ts +37 -0
  89. package/src/perf/hmr-markers.ts +215 -0
  90. package/src/perf/index.ts +104 -0
  91. package/src/resource/__tests__/generator.test.ts +603 -2
  92. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  93. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  94. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  95. package/src/resource/ddl/diff.ts +392 -0
  96. package/src/resource/ddl/emit.ts +548 -0
  97. package/src/resource/ddl/persistence-types.ts +218 -0
  98. package/src/resource/ddl/snapshot.ts +447 -0
  99. package/src/resource/ddl/type-map.ts +223 -0
  100. package/src/resource/ddl/types.ts +232 -0
  101. package/src/resource/generator-repo.ts +610 -0
  102. package/src/resource/generator-schema.ts +476 -0
  103. package/src/resource/generator.ts +117 -1
  104. package/src/resource/index.ts +17 -1
  105. package/src/resource/schema.ts +30 -0
  106. package/src/router/fs-scanner.ts +3 -0
  107. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  108. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  109. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  110. package/src/runtime/__tests__/not-found.test.ts +152 -0
  111. package/src/runtime/boundary.tsx +21 -1
  112. package/src/runtime/fast-refresh-runtime.ts +322 -0
  113. package/src/runtime/fast-refresh-types.ts +128 -0
  114. package/src/runtime/hmr-client.ts +409 -0
  115. package/src/runtime/http-errors.ts +113 -0
  116. package/src/runtime/index.ts +6 -0
  117. package/src/runtime/logger.ts +678 -677
  118. package/src/runtime/not-found.ts +93 -0
  119. package/src/runtime/redirect.ts +133 -0
  120. package/src/runtime/server.ts +679 -23
  121. package/src/runtime/ssr.ts +340 -10
  122. package/src/runtime/streaming-ssr.ts +222 -19
  123. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  124. package/src/scheduler/index.ts +343 -0
  125. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  126. package/src/storage/s3/index.ts +412 -0
  127. package/src/testing/index.ts +247 -189
@@ -0,0 +1,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
+ );
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,6 +1,13 @@
1
1
  /**
2
2
  * Resource Generator
3
3
  * Main orchestrator for generating resource artifacts
4
+ *
5
+ * Phase 4c extension: when a resource declares `options.persistence`, the
6
+ * orchestrator also emits a typed repo module (`*.repo.ts`) alongside the
7
+ * existing contract/types/slot/client artifacts. See `generator-repo.ts`
8
+ * for emission details; schema + migration generation is app-level and
9
+ * handled by `generateSchemaArtifacts` (separate entry point called by
10
+ * the CLI's `mandu db plan`).
4
11
  */
5
12
 
6
13
  import type { ParsedResource } from "./parser";
@@ -9,7 +16,15 @@ import { generateResourceContract } from "./generators/contract";
9
16
  import { generateResourceTypes } from "./generators/types";
10
17
  import { generateResourceSlot } from "./generators/slot";
11
18
  import { generateResourceClient } from "./generators/client";
19
+ import { generateRepoSource, shouldEmitRepo } from "./generator-repo";
20
+ import {
21
+ computeSchemaGeneration,
22
+ writeSchemaArtifacts,
23
+ type SchemaGenerationResult,
24
+ type WriteSchemaArtifactsResult,
25
+ } from "./generator-schema";
12
26
  import { resolveGeneratedPaths } from "../paths";
27
+ import type { SqlProvider } from "./ddl/types";
13
28
  import path from "path";
14
29
  import fs from "fs/promises";
15
30
 
@@ -23,7 +38,7 @@ export interface GeneratorOptions {
23
38
  /** 기존 슬롯 덮어쓰기 (기본: false) */
24
39
  force?: boolean;
25
40
  /** 특정 파일만 생성 */
26
- only?: ("contract" | "types" | "slot" | "client")[];
41
+ only?: ("contract" | "types" | "slot" | "client" | "repo")[];
27
42
  }
28
43
 
29
44
  // ============================================
@@ -35,6 +50,12 @@ export interface GeneratorResult {
35
50
  created: string[];
36
51
  skipped: string[];
37
52
  errors: string[];
53
+ /**
54
+ * Phase 4c — set when a repo was emitted for this resource. When the
55
+ * resource has no `options.persistence`, this is `false` and no repo
56
+ * file is written.
57
+ */
58
+ repoEmitted?: boolean;
38
59
  }
39
60
 
40
61
  // ============================================
@@ -114,6 +135,11 @@ export async function generateResourceArtifacts(
114
135
  if (!only || only.includes("client")) {
115
136
  await generateClient(definition, resourceName, paths.resourceClientDir, result);
116
137
  }
138
+
139
+ // 5. Phase 4c — Generate Repo (only when persistence is declared, always regenerate)
140
+ if ((!only || only.includes("repo")) && shouldEmitRepo(parsed)) {
141
+ await generateRepo(parsed, paths.resourceReposDir, result);
142
+ }
117
143
  } catch (error) {
118
144
  result.success = false;
119
145
  result.errors.push(
@@ -210,6 +236,34 @@ async function generateClient(
210
236
  result.created.push(clientPath);
211
237
  }
212
238
 
239
+ /**
240
+ * Phase 4c — Generate repo file.
241
+ *
242
+ * Always regenerate (derived). Caller is expected to have already checked
243
+ * `shouldEmitRepo(parsed)` to avoid emitting repos for non-persistent
244
+ * resources. We double-check here because `generateRepoSource` throws on
245
+ * non-persistent resources by default.
246
+ */
247
+ async function generateRepo(
248
+ parsed: ParsedResource,
249
+ reposDir: string,
250
+ result: GeneratorResult
251
+ ): Promise<void> {
252
+ await ensureDir(reposDir);
253
+
254
+ const repoContent = generateRepoSource(parsed, { enable: false });
255
+ if (repoContent === null) {
256
+ // Non-persistent resource — caller should have filtered this out but
257
+ // we handle it gracefully.
258
+ return;
259
+ }
260
+
261
+ const repoPath = path.join(reposDir, `${parsed.definition.name}.repo.ts`);
262
+ await Bun.write(repoPath, repoContent);
263
+ result.created.push(repoPath);
264
+ result.repoEmitted = true;
265
+ }
266
+
213
267
  // ============================================
214
268
  // Batch Generation
215
269
  // ============================================
@@ -247,6 +301,68 @@ export async function generateResourcesArtifacts(
247
301
  return combinedResult;
248
302
  }
249
303
 
304
+ // ============================================
305
+ // Phase 4c — App-level schema + migration orchestration
306
+ // ============================================
307
+
308
+ /**
309
+ * Options for `generateSchemaArtifacts`. All fields optional.
310
+ */
311
+ export interface SchemaArtifactsOptions {
312
+ /**
313
+ * Project root. Required.
314
+ */
315
+ rootDir: string;
316
+ /**
317
+ * Provider override. When omitted, the provider is derived from the
318
+ * resources' persistence blocks. Pass this to generate DDL for a
319
+ * different target than what's declared in the resources (useful for
320
+ * CLI flags like `mandu db plan --provider sqlite`).
321
+ */
322
+ provider?: SqlProvider;
323
+ /**
324
+ * Dry-run mode: compute the diff + SQL but DO NOT write any files.
325
+ * The caller inspects the returned result and decides whether to persist.
326
+ */
327
+ dryRun?: boolean;
328
+ }
329
+
330
+ /**
331
+ * The combined result of an app-level schema generation pass.
332
+ */
333
+ export interface SchemaArtifactsResult {
334
+ /** The computed diff / desired SQL / preview filename. */
335
+ generation: SchemaGenerationResult;
336
+ /** The actual write result, or `null` when `dryRun: true`. */
337
+ write: WriteSchemaArtifactsResult | null;
338
+ }
339
+
340
+ /**
341
+ * Run the schema + migration generation step for an entire project.
342
+ *
343
+ * This is distinct from `generateResourceArtifacts` which handles
344
+ * per-resource contract/slot/types/client/repo emission. Schema + migration
345
+ * are project-level because a single migration file aggregates changes
346
+ * across ALL persistent resources.
347
+ *
348
+ * Typical caller: `mandu db plan` in the CLI (Agent E).
349
+ *
350
+ * @returns The computed generation result and (unless `dryRun: true`)
351
+ * the write result describing which files were created on disk.
352
+ */
353
+ export async function generateSchemaArtifacts(
354
+ resources: ParsedResource[],
355
+ options: SchemaArtifactsOptions,
356
+ ): Promise<SchemaArtifactsResult> {
357
+ const { rootDir, provider, dryRun = false } = options;
358
+ const generation = await computeSchemaGeneration(resources, rootDir, provider);
359
+ if (dryRun) {
360
+ return { generation, write: null };
361
+ }
362
+ const write = await writeSchemaArtifacts(generation, rootDir);
363
+ return { generation, write };
364
+ }
365
+
250
366
  // ============================================
251
367
  // Summary Logging
252
368
  // ============================================
@@ -30,13 +30,29 @@ export type { ParsedResource } from "./parser";
30
30
  export {
31
31
  generateResourceArtifacts,
32
32
  generateResourcesArtifacts,
33
+ generateSchemaArtifacts,
33
34
  logGeneratorResult,
34
35
  } from "./generator";
35
36
 
36
- export type { GeneratorOptions, GeneratorResult } from "./generator";
37
+ export type {
38
+ GeneratorOptions,
39
+ GeneratorResult,
40
+ SchemaArtifactsOptions,
41
+ SchemaArtifactsResult,
42
+ } from "./generator";
37
43
 
38
44
  // Individual Generators (for advanced use)
39
45
  export { generateResourceContract } from "./generators/contract";
40
46
  export { generateResourceTypes } from "./generators/types";
41
47
  export { generateResourceSlot } from "./generators/slot";
42
48
  export { generateResourceClient } from "./generators/client";
49
+ export { generateRepoSource, shouldEmitRepo } from "./generator-repo";
50
+ export type { RepoGenerationOptions } from "./generator-repo";
51
+ export {
52
+ computeSchemaGeneration,
53
+ writeSchemaArtifacts,
54
+ } from "./generator-schema";
55
+ export type {
56
+ SchemaGenerationResult,
57
+ WriteSchemaArtifactsResult,
58
+ } from "./generator-schema";