@mandujs/core 0.21.0 → 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 (122) hide show
  1. package/package.json +94 -69
  2. package/src/auth/__tests__/login.test.ts +419 -0
  3. package/src/auth/__tests__/password.test.ts +122 -0
  4. package/src/auth/__tests__/reset.test.ts +296 -0
  5. package/src/auth/__tests__/tokens.test.ts +274 -0
  6. package/src/auth/__tests__/verification.test.ts +274 -0
  7. package/src/auth/index.ts +76 -0
  8. package/src/auth/login.ts +225 -0
  9. package/src/auth/password.ts +120 -0
  10. package/src/auth/reset.ts +243 -0
  11. package/src/auth/tokens.ts +612 -0
  12. package/src/auth/verification.ts +253 -0
  13. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
  14. package/src/bundler/__tests__/cold-start.test.ts +504 -0
  15. package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
  16. package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
  17. package/src/bundler/__tests__/extended-watch.test.ts +710 -0
  18. package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
  19. package/src/bundler/__tests__/hdr.test.ts +353 -0
  20. package/src/bundler/__tests__/hmr-client.test.ts +532 -0
  21. package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
  22. package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
  23. package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
  24. package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
  25. package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
  26. package/src/bundler/build.test.ts +8 -1
  27. package/src/bundler/build.ts +310 -18
  28. package/src/bundler/css.ts +326 -323
  29. package/src/bundler/dev.ts +1611 -59
  30. package/src/bundler/fast-refresh-plugin.ts +307 -0
  31. package/src/bundler/hmr-types.ts +252 -0
  32. package/src/bundler/manifest-schema.ts +301 -0
  33. package/src/bundler/safe-build.test.ts +128 -0
  34. package/src/bundler/safe-build.ts +77 -0
  35. package/src/bundler/scenario-matrix.ts +229 -0
  36. package/src/bundler/types.ts +11 -0
  37. package/src/bundler/vendor-cache-types.ts +130 -0
  38. package/src/bundler/vendor-cache.ts +526 -0
  39. package/src/client/router.ts +214 -56
  40. package/src/db/__tests__/db.test.ts +485 -0
  41. package/src/db/index.ts +513 -0
  42. package/src/db/migrations/__tests__/runner.test.ts +661 -0
  43. package/src/db/migrations/history-table.ts +345 -0
  44. package/src/db/migrations/lock.ts +269 -0
  45. package/src/db/migrations/runner.ts +633 -0
  46. package/src/desktop/__tests__/smoke.test.ts +100 -0
  47. package/src/desktop/__tests__/window.test.ts +172 -0
  48. package/src/desktop/__tests__/worker.test.ts +266 -0
  49. package/src/desktop/index.ts +43 -0
  50. package/src/desktop/types.ts +158 -0
  51. package/src/desktop/window.ts +492 -0
  52. package/src/desktop/worker.ts +180 -0
  53. package/src/email/__tests__/email.test.ts +355 -0
  54. package/src/email/index.ts +282 -0
  55. package/src/email/resend.ts +163 -0
  56. package/src/email/smtp.ts +64 -0
  57. package/src/filling/__tests__/session-sqlite.test.ts +454 -0
  58. package/src/filling/context.ts +72 -78
  59. package/src/filling/cookie-codec.ts +299 -0
  60. package/src/filling/deps.ts +25 -1
  61. package/src/filling/filling.ts +28 -3
  62. package/src/filling/session-sqlite.ts +617 -0
  63. package/src/filling/session.ts +265 -216
  64. package/src/guard/decision-memory.test.ts +52 -22
  65. package/src/id/__tests__/id.test.ts +120 -0
  66. package/src/id/index.ts +105 -0
  67. package/src/kitchen/index.ts +2 -2
  68. package/src/kitchen/kitchen-handler.ts +86 -0
  69. package/src/kitchen/stream/activity-sse.ts +2 -1
  70. package/src/middleware/csrf.ts +328 -0
  71. package/src/middleware/index.ts +40 -0
  72. package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
  73. package/src/middleware/oauth/index.ts +505 -0
  74. package/src/middleware/oauth/providers.ts +115 -0
  75. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
  76. package/src/middleware/rate-limit/index.ts +522 -0
  77. package/src/middleware/rate-limit/sqlite-store.ts +382 -0
  78. package/src/middleware/secure/__tests__/secure.test.ts +360 -0
  79. package/src/middleware/secure/csp.ts +193 -0
  80. package/src/middleware/secure/index.ts +417 -0
  81. package/src/middleware/session.ts +174 -0
  82. package/src/observability/event-bus.ts +81 -79
  83. package/src/paths.ts +37 -0
  84. package/src/perf/hmr-markers.ts +215 -0
  85. package/src/perf/index.ts +104 -0
  86. package/src/resource/__tests__/generator.test.ts +603 -2
  87. package/src/resource/ddl/__tests__/diff.test.ts +639 -0
  88. package/src/resource/ddl/__tests__/emit.test.ts +799 -0
  89. package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
  90. package/src/resource/ddl/diff.ts +392 -0
  91. package/src/resource/ddl/emit.ts +548 -0
  92. package/src/resource/ddl/persistence-types.ts +218 -0
  93. package/src/resource/ddl/snapshot.ts +447 -0
  94. package/src/resource/ddl/type-map.ts +223 -0
  95. package/src/resource/ddl/types.ts +232 -0
  96. package/src/resource/generator-repo.ts +610 -0
  97. package/src/resource/generator-schema.ts +476 -0
  98. package/src/resource/generator.ts +117 -1
  99. package/src/resource/index.ts +17 -1
  100. package/src/resource/schema.ts +30 -0
  101. package/src/router/fs-scanner.ts +3 -0
  102. package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
  103. package/src/runtime/__tests__/hdr-client.test.ts +223 -0
  104. package/src/runtime/__tests__/http-errors.test.ts +117 -0
  105. package/src/runtime/__tests__/not-found.test.ts +152 -0
  106. package/src/runtime/boundary.tsx +21 -1
  107. package/src/runtime/fast-refresh-runtime.ts +322 -0
  108. package/src/runtime/fast-refresh-types.ts +128 -0
  109. package/src/runtime/hmr-client.ts +409 -0
  110. package/src/runtime/http-errors.ts +113 -0
  111. package/src/runtime/index.ts +6 -0
  112. package/src/runtime/logger.ts +678 -677
  113. package/src/runtime/not-found.ts +93 -0
  114. package/src/runtime/redirect.ts +133 -0
  115. package/src/runtime/server.ts +518 -20
  116. package/src/runtime/ssr.ts +340 -10
  117. package/src/runtime/streaming-ssr.ts +222 -19
  118. package/src/scheduler/__tests__/scheduler.test.ts +514 -0
  119. package/src/scheduler/index.ts +343 -0
  120. package/src/storage/s3/__tests__/s3.test.ts +479 -0
  121. package/src/storage/s3/index.ts +412 -0
  122. package/src/testing/index.ts +58 -0
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Phase 4c — Extended persistence options for `ResourceDefinition`.
3
+ *
4
+ * # Why a separate file (not module augmentation)?
5
+ *
6
+ * Module-augmenting `ResourceOptions.persistence` would pollute the shape
7
+ * of `ResourceDefinition` for every consumer of `@mandujs/core` — including
8
+ * apps that never opt into persistence. The augmentation would also load
9
+ * DDL-specific symbols into the global type graph, which is exactly what
10
+ * the `ddl/` subdirectory isolation is trying to prevent.
11
+ *
12
+ * Instead we keep persistence options as an INDEPENDENT type and narrow at
13
+ * the use site (`snapshotFromResources`). `ResourceDefinition.options.persistence`
14
+ * is typed as `unknown` by the public schema; this module's `asPersistence()`
15
+ * is the single type-check gate. This preserves backward compatibility —
16
+ * existing resource files without `persistence` keep working — while
17
+ * giving the DDL layer a fully-typed view.
18
+ *
19
+ * # Identifier validation
20
+ *
21
+ * All identifier-shaped fields (`tableName`, `fieldOverrides[key].columnName`,
22
+ * `indexes[].name`) are validated against {@link SAFE_PERSISTENCE_IDENTIFIER_RE}
23
+ * at narrowing time. This is defense-in-depth in addition to `quoteIdent`:
24
+ * - `quoteIdent` catches SQL-injection characters (double quotes, backticks,
25
+ * NUL bytes) — but only when the value reaches DDL emission.
26
+ * - The same values ALSO feed `path.join` calls in `writeSchemaArtifacts`
27
+ * (`.mandu/generated/server/schema/{tableName}.sql`) and could otherwise
28
+ * allow path traversal via `..`, `/`, or `\` in the declared name.
29
+ * - Restricting to `[A-Za-z_][A-Za-z0-9_]*` closes both surfaces uniformly
30
+ * and matches the constraint already enforced on `definition.name` by
31
+ * `validateResourceDefinition` in schema.ts.
32
+ *
33
+ * References:
34
+ * - docs/rfcs/0001-db-resource-layer.md §4 D1 (opt-in persistence field)
35
+ * - docs/rfcs/0001-db-resource-layer.md Appendix D.1 (dialect divergence)
36
+ * - docs/security/phase-4c-audit.md §H-01 (path traversal remediation)
37
+ * - packages/core/src/resource/ddl/types.ts (canonical DDL contract)
38
+ */
39
+
40
+ import type { SqlProvider, DdlDefault, DdlIndex } from "./types";
41
+
42
+ // ============================================
43
+ // Identifier validation
44
+ // ============================================
45
+
46
+ /**
47
+ * The set of names allowed for DDL identifiers that originate from
48
+ * user-authored resource options (`tableName`, `columnName`, index
49
+ * `name`). Starts with a letter or underscore, followed by letters,
50
+ * digits, or underscores — i.e. the portable SQL identifier subset
51
+ * that is also safe to interpolate into a filesystem path segment.
52
+ *
53
+ * Rejects:
54
+ * - path separators (`/`, `\`) — prevents path traversal via
55
+ * `writeSchemaArtifacts` which writes `{tableName}.sql`.
56
+ * - `..`, `.` — parent/current directory markers.
57
+ * - whitespace and control characters — break both filesystems and
58
+ * terminal rendering in CLI output.
59
+ * - quote characters — redundant with `quoteIdent` but cheaper to
60
+ * reject early than per-dialect at emit time.
61
+ *
62
+ * Tightness rationale: we prefer a whitelist over a blacklist because
63
+ * OS + dialect quoting behavior varies; a whitelist aligns the two and
64
+ * stays simple to reason about.
65
+ */
66
+ export const SAFE_PERSISTENCE_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
67
+
68
+ /**
69
+ * Upper bound — mirrors `emit.ts:MAX_IDENT_LENGTH` so DDL identifiers
70
+ * never grow past the tighter of PG (63) / MySQL (64) limits. Enforcing
71
+ * it here gives a clearer error than the downstream emit-time throw.
72
+ */
73
+ const MAX_PERSISTENCE_IDENTIFIER_LENGTH = 63;
74
+
75
+ function assertSafeIdentifier(kind: string, value: string): void {
76
+ if (value.length === 0) {
77
+ throw new TypeError(`options.persistence.${kind} must not be empty`);
78
+ }
79
+ if (value.length > MAX_PERSISTENCE_IDENTIFIER_LENGTH) {
80
+ throw new TypeError(
81
+ `options.persistence.${kind} too long (${value.length} > ${MAX_PERSISTENCE_IDENTIFIER_LENGTH}): ${value.slice(0, 32)}...`
82
+ );
83
+ }
84
+ if (!SAFE_PERSISTENCE_IDENTIFIER_RE.test(value)) {
85
+ throw new TypeError(
86
+ `options.persistence.${kind} ${JSON.stringify(value)} contains characters outside [A-Za-z0-9_] or does not start with a letter/underscore. ` +
87
+ `This restriction blocks SQL injection and path traversal uniformly; use ${SAFE_PERSISTENCE_IDENTIFIER_RE} to construct the name.`
88
+ );
89
+ }
90
+ }
91
+
92
+ // ============================================
93
+ // Extended persistence options — opt-in
94
+ // ============================================
95
+
96
+ /**
97
+ * Per-field override block. Merged on top of values derived from
98
+ * `ResourceField` by `snapshotFromResources`. All fields are optional.
99
+ *
100
+ * `columnName` beats the default `camelCase → snake_case` transform.
101
+ * `nullable` beats `!field.required`.
102
+ * `default` beats `field.default` (and bypasses its string-magic parsing).
103
+ * `maxLength` is used when the Mandu field type is `string`.
104
+ */
105
+ export interface FieldOverride {
106
+ /** Explicit column name override. Must match `/^[a-z_][a-z0-9_]*$/i`. */
107
+ columnName?: string;
108
+ /** Explicit NULL allowance. */
109
+ nullable?: boolean;
110
+ /** Explicit DEFAULT clause (bypasses `field.default` string magic). */
111
+ default?: DdlDefault;
112
+ /** VARCHAR length for `string` fields. Ignored for other types. */
113
+ maxLength?: number;
114
+ /** Mark as indexed (single-column, non-unique). */
115
+ indexed?: boolean;
116
+ /** Mark as UNIQUE. */
117
+ unique?: boolean;
118
+ }
119
+
120
+ /**
121
+ * Persistence block on a resource definition — opt-in to DDL/migration.
122
+ * Resources without this field are ignored by the diff engine.
123
+ */
124
+ export interface ExtendedResourcePersistence {
125
+ /** Which SQL provider this resource targets. Must be consistent across the project. */
126
+ provider: SqlProvider;
127
+ /** Explicit table name. Overrides auto-pluralization of `resource.name`. */
128
+ tableName?: string;
129
+ /**
130
+ * Primary key field key. Can be a single string (v1) or a 1-element array
131
+ * (future-compatible). Multi-element arrays are rejected — composite keys
132
+ * are v2+.
133
+ */
134
+ primaryKey?: string | [string];
135
+ /** Multi-column indexes. Single-column indexes live on the field itself. */
136
+ indexes?: DdlIndex[];
137
+ /** Per-field overrides keyed by the Mandu field name. */
138
+ fieldOverrides?: Record<string, FieldOverride>;
139
+ }
140
+
141
+ // ============================================
142
+ // Safe narrowing
143
+ // ============================================
144
+
145
+ /**
146
+ * Narrow `unknown` (the public schema type for `options.persistence`) to
147
+ * `ExtendedResourcePersistence`. Returns `undefined` when the value is
148
+ * missing or empty. Throws `TypeError` on structurally-broken objects OR
149
+ * on identifier-shaped fields that contain SQL-injection / path-traversal
150
+ * characters (see {@link SAFE_PERSISTENCE_IDENTIFIER_RE}).
151
+ *
152
+ * This is the ONLY place the DDL layer trusts the shape of the persistence
153
+ * block; downstream code never sees `unknown`.
154
+ */
155
+ export function asPersistence(raw: unknown): ExtendedResourcePersistence | undefined {
156
+ if (raw === undefined || raw === null) return undefined;
157
+ if (typeof raw !== "object") {
158
+ throw new TypeError(`options.persistence must be an object, got ${typeof raw}`);
159
+ }
160
+ const obj = raw as Record<string, unknown>;
161
+ const provider = obj.provider;
162
+ if (provider !== "postgres" && provider !== "mysql" && provider !== "sqlite") {
163
+ throw new TypeError(
164
+ `options.persistence.provider must be one of "postgres" | "mysql" | "sqlite", got ${JSON.stringify(provider)}`
165
+ );
166
+ }
167
+ if (obj.tableName !== undefined) {
168
+ if (typeof obj.tableName !== "string") {
169
+ throw new TypeError(`options.persistence.tableName must be a string`);
170
+ }
171
+ assertSafeIdentifier("tableName", obj.tableName);
172
+ }
173
+ if (obj.primaryKey !== undefined) {
174
+ const pk = obj.primaryKey;
175
+ if (
176
+ typeof pk !== "string" &&
177
+ !(Array.isArray(pk) && pk.length === 1 && typeof pk[0] === "string")
178
+ ) {
179
+ throw new TypeError(
180
+ `options.persistence.primaryKey must be a string or single-element string array (composite keys are v2)`
181
+ );
182
+ }
183
+ }
184
+ if (obj.indexes !== undefined) {
185
+ if (!Array.isArray(obj.indexes)) {
186
+ throw new TypeError(`options.persistence.indexes must be an array`);
187
+ }
188
+ for (let i = 0; i < obj.indexes.length; i++) {
189
+ const idx = obj.indexes[i] as { name?: unknown } | undefined;
190
+ if (idx && typeof idx === "object" && typeof idx.name === "string") {
191
+ assertSafeIdentifier(`indexes[${i}].name`, idx.name);
192
+ }
193
+ }
194
+ }
195
+ if (obj.fieldOverrides !== undefined) {
196
+ if (typeof obj.fieldOverrides !== "object" || obj.fieldOverrides === null || Array.isArray(obj.fieldOverrides)) {
197
+ throw new TypeError(`options.persistence.fieldOverrides must be an object`);
198
+ }
199
+ for (const [key, value] of Object.entries(obj.fieldOverrides as Record<string, unknown>)) {
200
+ if (value === undefined || value === null) continue;
201
+ if (typeof value !== "object" || Array.isArray(value)) {
202
+ throw new TypeError(
203
+ `options.persistence.fieldOverrides.${key} must be an object`
204
+ );
205
+ }
206
+ const col = (value as { columnName?: unknown }).columnName;
207
+ if (col !== undefined) {
208
+ if (typeof col !== "string") {
209
+ throw new TypeError(
210
+ `options.persistence.fieldOverrides.${key}.columnName must be a string`
211
+ );
212
+ }
213
+ assertSafeIdentifier(`fieldOverrides.${key}.columnName`, col);
214
+ }
215
+ }
216
+ }
217
+ return obj as unknown as ExtendedResourcePersistence;
218
+ }
@@ -0,0 +1,447 @@
1
+ /**
2
+ * Phase 4c — Schema snapshot normalization + serialization.
3
+ *
4
+ * Pure functions, no I/O beyond the Bun-native SHA-256 hasher. Given the
5
+ * same `ParsedResource[]`, these functions MUST produce byte-identical
6
+ * `Snapshot` objects and byte-identical serialized JSON — this guarantee
7
+ * is what makes the snapshot file usable as a git-checked-in artifact and
8
+ * what makes checksum-based tamper detection (Agent C) meaningful.
9
+ *
10
+ * Pipeline:
11
+ *
12
+ * ParsedResource[] --snapshotFromResources--> Snapshot
13
+ * |
14
+ * serializeSnapshot | parseSnapshot
15
+ * v
16
+ * JSON string (committed to .mandu/schema/applied.json)
17
+ *
18
+ * Nothing here touches the filesystem — the CLI (Agent E) and generator
19
+ * (Agent D) are responsible for where the snapshot lives on disk.
20
+ *
21
+ * Normalization rules (documented in detail on each helper below):
22
+ * 1. Only resources with a well-formed `options.persistence` are included.
23
+ * 2. All resources must target the same `SqlProvider`; mixing throws.
24
+ * 3. Table name: `options.persistence.tableName` > auto-pluralized
25
+ * `definition.name`. `options.autoPlural === false` keeps it singular.
26
+ * 4. Column name: `fieldOverrides[key].columnName` > `camelCase → snake_case`.
27
+ * 5. Primary key: `options.persistence.primaryKey` (string|[string]) >
28
+ * field with `primary: true` > error. Composite PK is v2+.
29
+ * 6. `DdlDefault` derived from `field.default` with the string-magic
30
+ * "now" / "current_timestamp" shortcut. See `normalizeDefault`.
31
+ *
32
+ * References:
33
+ * docs/bun/phase-4c-team-plan.md §3 Agent B
34
+ * docs/rfcs/0001-db-resource-layer.md Appendix D.1 (dialect divergence)
35
+ * docs/rfcs/0001-db-resource-layer.md §4 D5 (opt-in `persistence` field)
36
+ */
37
+
38
+ import type { ParsedResource } from "../parser";
39
+ import type { ResourceField, ResourceOptions } from "../schema";
40
+ import type {
41
+ DdlDefault,
42
+ DdlFieldDef,
43
+ DdlFieldType,
44
+ DdlIndex,
45
+ DdlResource,
46
+ Snapshot,
47
+ SqlProvider,
48
+ } from "./types";
49
+ import { asPersistence, type ExtendedResourcePersistence, type FieldOverride } from "./persistence-types";
50
+
51
+ // ============================================
52
+ // Public API
53
+ // ============================================
54
+
55
+ /**
56
+ * Normalize `ParsedResource[]` into a provider-tagged `Snapshot`.
57
+ *
58
+ * Resources whose `options.persistence` is missing/empty are silently
59
+ * dropped — the resource generator emits contract/types/slot/client but
60
+ * no DDL for such resources.
61
+ *
62
+ * Insertion order of fields (the order keys appear in the source
63
+ * `definition.fields` object) is preserved; this is the author's intent
64
+ * and the emit order for `CREATE TABLE`.
65
+ *
66
+ * Top-level `snapshot.resources` is sorted alphabetically by `name` —
67
+ * this is what makes `serializeSnapshot` output stable across runs even
68
+ * if the caller scans files in a different order.
69
+ *
70
+ * @throws TypeError on:
71
+ * - conflicting providers across persistent resources
72
+ * - zero primary-key fields on a persistent resource
73
+ * - more than one primary-key field (composite PK is v2)
74
+ * - duplicate table name after pluralization
75
+ * - invalid `field.default` value (functions, symbols, objects)
76
+ * - structurally broken `options.persistence` (see `asPersistence`)
77
+ */
78
+ export function snapshotFromResources(resources: readonly ParsedResource[]): Snapshot {
79
+ const ddlResources: DdlResource[] = [];
80
+ let provider: SqlProvider | undefined;
81
+ const seenTableNames = new Map<string, string>(); // tableName -> first resource that claimed it
82
+
83
+ for (const parsed of resources) {
84
+ // `options.persistence` is not declared on the public ResourceOptions —
85
+ // it's an opt-in additive field (see persistence-types.ts for rationale).
86
+ // Read it via an unknown cast and narrow with `asPersistence`.
87
+ const rawPersistence = (parsed.definition.options as Record<string, unknown> | undefined)?.persistence;
88
+ const persistence = asPersistence(rawPersistence);
89
+ if (!persistence) continue; // non-persistent resource — skip
90
+
91
+ if (provider === undefined) {
92
+ provider = persistence.provider;
93
+ } else if (provider !== persistence.provider) {
94
+ throw new TypeError(
95
+ `Mixed SQL providers in resource set: resource "${parsed.resourceName}" declares provider "${persistence.provider}" but the snapshot is already building for "${provider}". All persistent resources in a project must share one provider.`
96
+ );
97
+ }
98
+
99
+ const ddlResource = normalizeResource(parsed, persistence);
100
+
101
+ const prior = seenTableNames.get(ddlResource.name);
102
+ if (prior !== undefined) {
103
+ throw new TypeError(
104
+ `Duplicate table name "${ddlResource.name}" — resources "${prior}" and "${parsed.resourceName}" both map to it. Use options.persistence.tableName to disambiguate.`
105
+ );
106
+ }
107
+ seenTableNames.set(ddlResource.name, parsed.resourceName);
108
+
109
+ ddlResources.push(ddlResource);
110
+ }
111
+
112
+ ddlResources.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
113
+
114
+ return {
115
+ version: 1,
116
+ provider: provider ?? "postgres", // empty set → default to postgres; it's meaningless for an empty resource list
117
+ resources: ddlResources,
118
+ generatedAt: new Date().toISOString(),
119
+ };
120
+ }
121
+
122
+ /**
123
+ * Deterministic JSON serialization — 2-space indent, sorted object keys,
124
+ * stable array order. Byte-for-byte stable for the same `Snapshot`,
125
+ * modulo `generatedAt` which the caller controls.
126
+ *
127
+ * We intentionally sort object keys rather than trusting the property
128
+ * insertion order of the callers' objects — this guards against subtle
129
+ * non-determinism when `Snapshot` is built by code that constructs
130
+ * objects in different orders across refactors.
131
+ *
132
+ * Arrays are NOT sorted — their order is semantically load-bearing
133
+ * (field emit order, sorted resource order).
134
+ */
135
+ export function serializeSnapshot(s: Snapshot): string {
136
+ return stringifyWithSortedKeys(s, 2);
137
+ }
138
+
139
+ /**
140
+ * Parse a snapshot JSON string.
141
+ *
142
+ * @throws TypeError on invalid JSON, missing required fields, or
143
+ * `version` that this build does not understand.
144
+ */
145
+ export function parseSnapshot(raw: string): Snapshot {
146
+ let parsed: unknown;
147
+ try {
148
+ parsed = JSON.parse(raw);
149
+ } catch (err) {
150
+ throw new TypeError(
151
+ `Invalid snapshot JSON: ${err instanceof Error ? err.message : String(err)}`
152
+ );
153
+ }
154
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
155
+ throw new TypeError(`Snapshot must be a JSON object, got ${typeof parsed}`);
156
+ }
157
+ const obj = parsed as Record<string, unknown>;
158
+ if (obj.version !== 1) {
159
+ throw new TypeError(
160
+ `Unsupported snapshot version ${JSON.stringify(obj.version)}. This build understands version 1 only.`
161
+ );
162
+ }
163
+ if (obj.provider !== "postgres" && obj.provider !== "mysql" && obj.provider !== "sqlite") {
164
+ throw new TypeError(`Snapshot has invalid provider ${JSON.stringify(obj.provider)}`);
165
+ }
166
+ if (!Array.isArray(obj.resources)) {
167
+ throw new TypeError(`Snapshot.resources must be an array`);
168
+ }
169
+ if (typeof obj.generatedAt !== "string") {
170
+ throw new TypeError(`Snapshot.generatedAt must be an ISO string`);
171
+ }
172
+ // Deeper structural validation of resources is deferred to the diff
173
+ // engine; tampering with fields of a stored snapshot would be caught
174
+ // by the migration runtime's checksum before diff ever runs.
175
+ return parsed as Snapshot;
176
+ }
177
+
178
+ /**
179
+ * SHA-256 of the canonical serialization of a snapshot. Useful for quick
180
+ * change detection ("has the committed schema drifted from the applied
181
+ * snapshot?").
182
+ *
183
+ * Note: `generatedAt` is part of the canonical serialization, so two
184
+ * snapshots with the same resources but different generation times will
185
+ * hash differently. Callers that want a time-stable hash should zero out
186
+ * `generatedAt` before calling.
187
+ */
188
+ export function hashSnapshot(s: Snapshot): string {
189
+ const canonical = serializeSnapshot(s);
190
+ const hasher = new Bun.CryptoHasher("sha256");
191
+ hasher.update(canonical);
192
+ return hasher.digest("hex");
193
+ }
194
+
195
+ // ============================================
196
+ // Internals — resource normalization
197
+ // ============================================
198
+
199
+ function normalizeResource(
200
+ parsed: ParsedResource,
201
+ persistence: ExtendedResourcePersistence
202
+ ): DdlResource {
203
+ const { definition } = parsed;
204
+ const tableName = resolveTableName(definition.name, definition.options, persistence);
205
+ const overrides = persistence.fieldOverrides ?? {};
206
+
207
+ // Respect insertion order — Object.entries preserves the author's key order.
208
+ const fieldEntries = Object.entries(definition.fields);
209
+ if (fieldEntries.length === 0) {
210
+ // parser.ts already validates this, but guard defensively.
211
+ throw new TypeError(`Resource "${parsed.resourceName}" has no fields`);
212
+ }
213
+
214
+ const declaredPk = resolveDeclaredPrimaryKey(persistence.primaryKey);
215
+ const fields: DdlFieldDef[] = [];
216
+ const pkFieldKeys: string[] = [];
217
+ // Author field key (e.g. "passwordHash") → resolved column name (e.g. "password_hash").
218
+ // Used by index normalization below to resolve declared index fields.
219
+ const keyToColumn = new Map<string, string>();
220
+
221
+ for (const [fieldKey, field] of fieldEntries) {
222
+ const override = overrides[fieldKey];
223
+ const ddlField = normalizeField(fieldKey, field, override, declaredPk, parsed.resourceName);
224
+ if (ddlField.primary) pkFieldKeys.push(fieldKey);
225
+ fields.push(ddlField);
226
+ keyToColumn.set(fieldKey, ddlField.name);
227
+ }
228
+
229
+ if (pkFieldKeys.length === 0) {
230
+ throw new TypeError(
231
+ `Resource "${parsed.resourceName}" must have exactly one primary key field. Mark a field with \`primary: true\` (via fieldOverrides) or declare \`options.persistence.primaryKey\`.`
232
+ );
233
+ }
234
+ if (pkFieldKeys.length > 1) {
235
+ throw new TypeError(
236
+ `Resource "${parsed.resourceName}" has ${pkFieldKeys.length} primary key fields (${pkFieldKeys.join(", ")}). Composite primary keys are not supported in v1.`
237
+ );
238
+ }
239
+
240
+ const indexes = normalizeIndexes(persistence.indexes, keyToColumn, parsed.resourceName);
241
+
242
+ return { name: tableName, fields, indexes };
243
+ }
244
+
245
+ function resolveTableName(
246
+ resourceName: string,
247
+ options: ResourceOptions | undefined,
248
+ persistence: ExtendedResourcePersistence
249
+ ): string {
250
+ if (persistence.tableName) return persistence.tableName;
251
+ // `options.pluralName` from the existing schema takes precedence over auto-plural,
252
+ // but `tableName` trumps both — this preserves backward compat for users who
253
+ // had a `pluralName` before Phase 4c.
254
+ if (options?.pluralName) return options.pluralName;
255
+ if (options?.autoPlural === false) return resourceName;
256
+ return pluralize(resourceName);
257
+ }
258
+
259
+ /**
260
+ * Conservative v1 pluralizer.
261
+ *
262
+ * Rules (in order):
263
+ * 1. ends with `y` preceded by a consonant → `ies` ("city" → "cities")
264
+ * 2. ends with `s` / `x` / `z` / `ch` / `sh` → `+es` ("box" → "boxes")
265
+ * 3. default → `+s` ("user" → "users")
266
+ *
267
+ * English has irregular plurals the framework cannot infer — that's the
268
+ * escape hatch `options.persistence.tableName` exists for.
269
+ */
270
+ function pluralize(singular: string): string {
271
+ if (/[^aeiou]y$/i.test(singular)) {
272
+ return singular.slice(0, -1) + "ies";
273
+ }
274
+ if (/(?:s|x|z|ch|sh)$/i.test(singular)) {
275
+ return singular + "es";
276
+ }
277
+ return singular + "s";
278
+ }
279
+
280
+ function resolveDeclaredPrimaryKey(declared: ExtendedResourcePersistence["primaryKey"]): string | undefined {
281
+ if (declared === undefined) return undefined;
282
+ if (typeof declared === "string") return declared;
283
+ return declared[0];
284
+ }
285
+
286
+ // ============================================
287
+ // Internals — field normalization
288
+ // ============================================
289
+
290
+ function normalizeField(
291
+ fieldKey: string,
292
+ field: ResourceField,
293
+ override: FieldOverride | undefined,
294
+ declaredPk: string | undefined,
295
+ resourceName: string
296
+ ): DdlFieldDef {
297
+ const name = override?.columnName ?? toSnakeCase(fieldKey);
298
+ const nullable = override?.nullable ?? !(field.required ?? false);
299
+
300
+ // A field is a primary key if:
301
+ // - the explicit `persistence.primaryKey` names it, OR
302
+ // - the field's declaration carries `primary: true` (an opt-in, not the
303
+ // default on Mandu's ResourceField type; accessed via a best-effort
304
+ // cast because `ResourceField` predates this feature).
305
+ const declaredPkMatch = declaredPk !== undefined && declaredPk === fieldKey;
306
+ const fieldLevelPk = Boolean((field as ResourceField & { primary?: boolean }).primary);
307
+ const primary = declaredPkMatch || fieldLevelPk;
308
+
309
+ const unique = override?.unique ?? Boolean((field as ResourceField & { unique?: boolean }).unique);
310
+ const indexed = override?.indexed ?? Boolean((field as ResourceField & { indexed?: boolean }).indexed);
311
+
312
+ const def = override?.default ?? normalizeDefault(field.default, fieldKey, resourceName);
313
+
314
+ const result: DdlFieldDef = {
315
+ name,
316
+ type: field.type as DdlFieldType,
317
+ nullable,
318
+ primary,
319
+ unique,
320
+ indexed,
321
+ };
322
+ if (def !== undefined) result.default = def;
323
+ const maxLength = override?.maxLength ?? (field as ResourceField & { maxLength?: number }).maxLength;
324
+ if (typeof maxLength === "number") result.maxLength = maxLength;
325
+ return result;
326
+ }
327
+
328
+ /**
329
+ * `camelCase` / `PascalCase` → `snake_case`. Leaves already-snake names
330
+ * untouched. Runs of consecutive capitals are treated as a single word
331
+ * (`HTTPRequest` → `http_request`) which matches PostgreSQL/Drizzle
332
+ * conventions.
333
+ */
334
+ export function toSnakeCase(input: string): string {
335
+ if (input.length === 0) return input;
336
+ // Insert underscores at run-of-caps/start-of-word boundaries, then lowercase.
337
+ return input
338
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2") // camelCase → camel_Case
339
+ .replace(/([A-Z])([A-Z][a-z])/g, "$1_$2") // HTTPServer → HTTP_Server
340
+ .toLowerCase();
341
+ }
342
+
343
+ /**
344
+ * Normalize `ResourceField.default` to a `DdlDefault` discriminated union.
345
+ *
346
+ * Rules:
347
+ * undefined → no DEFAULT (returns undefined)
348
+ * null → { kind: "null" }
349
+ * "now" | "current_timestamp" → { kind: "now" }
350
+ * string (other) → { kind: "literal", value }
351
+ * number | boolean → { kind: "literal", value }
352
+ * function | symbol | object | array → throws TypeError
353
+ *
354
+ * Arrays/objects are explicitly rejected: their JSON representation depends
355
+ * on the receiving dialect's JSON column semantics, and the v1 contract
356
+ * is that DEFAULT values are scalar. Users with JSON defaults should use
357
+ * `{ kind: "sql", expr: "'[]'" }` via `fieldOverrides[key].default`.
358
+ */
359
+ function normalizeDefault(
360
+ raw: unknown,
361
+ fieldKey: string,
362
+ resourceName: string
363
+ ): DdlDefault | undefined {
364
+ if (raw === undefined) return undefined;
365
+ if (raw === null) return { kind: "null" };
366
+ if (typeof raw === "string") {
367
+ if (raw === "now" || raw === "current_timestamp") return { kind: "now" };
368
+ return { kind: "literal", value: raw };
369
+ }
370
+ if (typeof raw === "number") {
371
+ if (!Number.isFinite(raw)) {
372
+ throw new TypeError(
373
+ `Field "${resourceName}.${fieldKey}" has non-finite default (${String(raw)}). DEFAULT must be a finite number.`
374
+ );
375
+ }
376
+ return { kind: "literal", value: raw };
377
+ }
378
+ if (typeof raw === "boolean") return { kind: "literal", value: raw };
379
+ // Functions, symbols, objects, arrays, BigInt — none are representable
380
+ // as a portable SQL DEFAULT literal. Force the user to be explicit via
381
+ // `fieldOverrides[key].default = { kind: "sql", expr: ... }`.
382
+ throw new TypeError(
383
+ `Field "${resourceName}.${fieldKey}" has unsupported default type (${typeof raw}). Use a string, number, boolean, or null — or override via options.persistence.fieldOverrides.${fieldKey}.default with an explicit DdlDefault.`
384
+ );
385
+ }
386
+
387
+ // ============================================
388
+ // Internals — index normalization
389
+ // ============================================
390
+
391
+ /**
392
+ * Normalize user-declared indexes to DDL shape. The author's `idx.fields`
393
+ * entries are field KEYS (as written in `definition.fields`); we resolve
394
+ * each to the post-snake_case column name via `keyToColumn`. Entries
395
+ * that don't match a known field key are passed through verbatim —
396
+ * that supports the escape hatch of referencing a column directly.
397
+ */
398
+ function normalizeIndexes(
399
+ declared: DdlIndex[] | undefined,
400
+ keyToColumn: ReadonlyMap<string, string>,
401
+ resourceName: string
402
+ ): DdlIndex[] {
403
+ if (!declared || declared.length === 0) return [];
404
+ const list = declared.map((idx) => {
405
+ if (!idx.name) throw new TypeError(`Index on "${resourceName}" is missing a name`);
406
+ if (!Array.isArray(idx.fields) || idx.fields.length === 0) {
407
+ throw new TypeError(`Index "${resourceName}.${idx.name}" must declare at least one field`);
408
+ }
409
+ const columns = idx.fields.map((key) => keyToColumn.get(key) ?? key);
410
+ return { name: idx.name, fields: columns, unique: Boolean(idx.unique) };
411
+ });
412
+ // Stable sort by name — makes snapshot output deterministic.
413
+ list.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
414
+ // Detect duplicate index names after normalization.
415
+ for (let i = 1; i < list.length; i++) {
416
+ if (list[i].name === list[i - 1].name) {
417
+ throw new TypeError(`Duplicate index name "${list[i].name}" on resource "${resourceName}"`);
418
+ }
419
+ }
420
+ return list;
421
+ }
422
+
423
+ // ============================================
424
+ // Internals — deterministic JSON
425
+ // ============================================
426
+
427
+ /**
428
+ * `JSON.stringify` that sorts object keys at every depth. Arrays are
429
+ * preserved in order. `undefined` values (and their keys) are dropped.
430
+ */
431
+ function stringifyWithSortedKeys(value: unknown, indent: number): string {
432
+ return JSON.stringify(sortKeys(value), null, indent);
433
+ }
434
+
435
+ function sortKeys(value: unknown): unknown {
436
+ if (Array.isArray(value)) return value.map(sortKeys);
437
+ if (value && typeof value === "object") {
438
+ const entries = Object.entries(value as Record<string, unknown>).filter(
439
+ ([, v]) => v !== undefined
440
+ );
441
+ entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
442
+ const out: Record<string, unknown> = {};
443
+ for (const [k, v] of entries) out[k] = sortKeys(v);
444
+ return out;
445
+ }
446
+ return value;
447
+ }