@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.
- package/package.json +21 -4
- package/src/auth/__tests__/login.test.ts +420 -419
- package/src/auth/__tests__/reset.test.ts +296 -296
- package/src/brain/adapters/anthropic-oauth.ts +421 -420
- package/src/brain/adapters/index.ts +2 -1
- package/src/brain/adapters/ollama.ts +1 -1
- package/src/brain/adapters/openai-oauth.ts +534 -533
- package/src/brain/brain.ts +2 -1
- package/src/brain/redactor.ts +196 -196
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
- package/src/bundler/__tests__/cold-start.test.ts +504 -504
- package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
- package/src/bundler/__tests__/hdr.test.ts +1 -1
- package/src/bundler/analyzer.ts +958 -958
- package/src/bundler/build.ts +104 -14
- package/src/bundler/dev.ts +125 -0
- package/src/bundler/hmr-types.ts +1 -0
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
- package/src/bundler/plugins/index.ts +14 -0
- package/src/bundler/plugins/react-compiler-lint.ts +253 -0
- package/src/bundler/plugins/react-compiler.ts +162 -0
- package/src/bundler/types.ts +12 -0
- package/src/change/integrity.ts +2 -1
- package/src/client/index.ts +10 -0
- package/src/client/island.ts +38 -11
- package/src/client/router.ts +6 -1
- package/src/config/mandu.ts +57 -0
- package/src/config/validate.ts +42 -0
- package/src/content/collection.ts +844 -809
- package/src/content/content-layer.ts +316 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/digest.ts +133 -133
- package/src/content/generate-types.ts +168 -168
- package/src/content/index.ts +6 -1
- package/src/content/llms-txt.ts +277 -277
- package/src/contract/define.ts +474 -474
- package/src/contract/route-helpers.ts +2 -1
- package/src/contract/zod-utils.ts +158 -155
- package/src/db/index.ts +513 -513
- package/src/desktop/__tests__/smoke.test.ts +100 -100
- package/src/desktop/webview-fallback.ts +583 -583
- package/src/desktop/window.ts +3 -1
- package/src/dev-error-overlay/overlay-client.ts +300 -300
- package/src/devtools/ai/mcp-connector.ts +499 -498
- package/src/devtools/client/components/kitchen-root.tsx +7 -2
- package/src/email/resend.ts +163 -163
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/graph.ts +898 -898
- package/src/guard/index.ts +16 -0
- package/src/guard/statistics.ts +578 -578
- package/src/guard/tsgolint-bridge.ts +512 -0
- package/src/i18n/locale-resolver.ts +214 -214
- package/src/id/__tests__/id.test.ts +120 -120
- package/src/intent/index.ts +321 -321
- package/src/island/index.ts +39 -23
- package/src/kitchen/api/contract-api.ts +15 -8
- package/src/kitchen/kitchen-ui.ts +2137 -2137
- package/src/lockfile/index.ts +3 -2
- package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
- package/src/middleware/secure/index.ts +417 -417
- package/src/observability/event-bus.ts +2 -2
- package/src/observability/metrics.ts +334 -334
- package/src/observability/tracing.ts +694 -694
- package/src/openapi/generator.ts +1 -1
- package/src/perf/user-marks.ts +553 -553
- package/src/plugins/registry.ts +387 -387
- package/src/resource/ddl/diff.ts +392 -392
- package/src/resource/ddl/snapshot.ts +448 -447
- package/src/resource/generator-schema.ts +477 -476
- package/src/resource/parser.ts +4 -2
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.ts +422 -422
- package/src/runtime/fast-refresh-types.ts +126 -128
- package/src/runtime/image-handler.ts +206 -195
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +36 -19
- package/src/runtime/session-key.ts +328 -328
- package/src/scheduler/__tests__/scheduler.test.ts +514 -514
- package/src/seo/resolve/index.ts +353 -353
- package/src/spec/load.ts +1 -1
- package/src/testing/reporter.ts +676 -676
- package/src/testing/server.ts +196 -196
- package/src/testing/snapshot.ts +444 -444
- package/src/utils/__tests__/lru-cache.test.ts +186 -186
- 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
|
-
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
let
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
if (
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
*
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
+
};
|