@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.
- package/README.md +2 -1
- package/package.json +28 -3
- package/src/auth/__tests__/login.test.ts +419 -0
- package/src/auth/__tests__/password.test.ts +122 -0
- package/src/auth/__tests__/reset.test.ts +296 -0
- package/src/auth/__tests__/tokens.test.ts +274 -0
- package/src/auth/__tests__/verification.test.ts +274 -0
- package/src/auth/index.ts +76 -0
- package/src/auth/login.ts +225 -0
- package/src/auth/password.ts +120 -0
- package/src/auth/reset.ts +243 -0
- package/src/auth/tokens.ts +612 -0
- package/src/auth/verification.ts +253 -0
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -0
- package/src/bundler/__tests__/cold-start.test.ts +504 -0
- package/src/bundler/__tests__/csp-nonce.test.ts +278 -0
- package/src/bundler/__tests__/dev-reliability.test.ts +619 -0
- package/src/bundler/__tests__/extended-watch.test.ts +710 -0
- package/src/bundler/__tests__/fast-refresh.test.ts +596 -0
- package/src/bundler/__tests__/hdr.test.ts +353 -0
- package/src/bundler/__tests__/hmr-client.test.ts +532 -0
- package/src/bundler/__tests__/manifest-schema.test.ts +266 -0
- package/src/bundler/__tests__/prod-smoke.test.ts +138 -0
- package/src/bundler/__tests__/slot-dispatch.test.ts +573 -0
- package/src/bundler/__tests__/url-cap-and-slot-regex.test.ts +286 -0
- package/src/bundler/__tests__/vendor-cache.test.ts +455 -0
- package/src/bundler/build.test.ts +8 -1
- package/src/bundler/build.ts +495 -37
- package/src/bundler/css.ts +326 -323
- package/src/bundler/dev.ts +1671 -80
- package/src/bundler/fast-refresh-plugin.ts +307 -0
- package/src/bundler/hmr-types.ts +252 -0
- package/src/bundler/manifest-schema.ts +301 -0
- package/src/bundler/safe-build.test.ts +128 -0
- package/src/bundler/safe-build.ts +77 -0
- package/src/bundler/scenario-matrix.ts +229 -0
- package/src/bundler/types.ts +19 -0
- package/src/bundler/vendor-cache-types.ts +130 -0
- package/src/bundler/vendor-cache.ts +526 -0
- package/src/client/router.ts +214 -56
- package/src/config/validate.ts +1 -0
- package/src/db/__tests__/db.test.ts +485 -0
- package/src/db/index.ts +513 -0
- package/src/db/migrations/__tests__/runner.test.ts +661 -0
- package/src/db/migrations/history-table.ts +345 -0
- package/src/db/migrations/lock.ts +269 -0
- package/src/db/migrations/runner.ts +633 -0
- package/src/desktop/__tests__/smoke.test.ts +100 -0
- package/src/desktop/__tests__/window.test.ts +172 -0
- package/src/desktop/__tests__/worker.test.ts +266 -0
- package/src/desktop/index.ts +43 -0
- package/src/desktop/types.ts +158 -0
- package/src/desktop/window.ts +492 -0
- package/src/desktop/worker.ts +180 -0
- package/src/devtools/ai/mcp-connector.ts +18 -16
- package/src/devtools/client/components/mandu-character.tsx +4 -1
- package/src/devtools/client/components/panel/panel-container.tsx +20 -5
- package/src/email/__tests__/email.test.ts +355 -0
- package/src/email/index.ts +282 -0
- package/src/email/resend.ts +163 -0
- package/src/email/smtp.ts +64 -0
- package/src/filling/__tests__/session-sqlite.test.ts +454 -0
- package/src/filling/context.ts +72 -78
- package/src/filling/cookie-codec.ts +299 -0
- package/src/filling/deps.ts +25 -1
- package/src/filling/filling.ts +28 -3
- package/src/filling/session-sqlite.ts +617 -0
- package/src/filling/session.ts +265 -216
- package/src/guard/decision-memory.test.ts +52 -22
- package/src/id/__tests__/id.test.ts +120 -0
- package/src/id/index.ts +105 -0
- package/src/kitchen/index.ts +2 -2
- package/src/kitchen/kitchen-handler.ts +86 -0
- package/src/kitchen/stream/activity-sse.ts +2 -1
- package/src/middleware/csrf.ts +328 -0
- package/src/middleware/index.ts +40 -0
- package/src/middleware/oauth/__tests__/oauth.test.ts +574 -0
- package/src/middleware/oauth/index.ts +505 -0
- package/src/middleware/oauth/providers.ts +115 -0
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -0
- package/src/middleware/rate-limit/index.ts +522 -0
- package/src/middleware/rate-limit/sqlite-store.ts +382 -0
- package/src/middleware/secure/__tests__/secure.test.ts +360 -0
- package/src/middleware/secure/csp.ts +193 -0
- package/src/middleware/secure/index.ts +417 -0
- package/src/middleware/session.ts +174 -0
- package/src/observability/event-bus.ts +81 -79
- package/src/paths.ts +37 -0
- package/src/perf/hmr-markers.ts +215 -0
- package/src/perf/index.ts +104 -0
- package/src/resource/__tests__/generator.test.ts +603 -2
- package/src/resource/ddl/__tests__/diff.test.ts +639 -0
- package/src/resource/ddl/__tests__/emit.test.ts +799 -0
- package/src/resource/ddl/__tests__/snapshot.test.ts +499 -0
- package/src/resource/ddl/diff.ts +392 -0
- package/src/resource/ddl/emit.ts +548 -0
- package/src/resource/ddl/persistence-types.ts +218 -0
- package/src/resource/ddl/snapshot.ts +447 -0
- package/src/resource/ddl/type-map.ts +223 -0
- package/src/resource/ddl/types.ts +232 -0
- package/src/resource/generator-repo.ts +610 -0
- package/src/resource/generator-schema.ts +476 -0
- package/src/resource/generator.ts +117 -1
- package/src/resource/index.ts +17 -1
- package/src/resource/schema.ts +30 -0
- package/src/router/fs-scanner.ts +3 -0
- package/src/runtime/__tests__/error-boundary-redaction.test.ts +141 -0
- package/src/runtime/__tests__/hdr-client.test.ts +223 -0
- package/src/runtime/__tests__/http-errors.test.ts +117 -0
- package/src/runtime/__tests__/not-found.test.ts +152 -0
- package/src/runtime/boundary.tsx +21 -1
- package/src/runtime/fast-refresh-runtime.ts +322 -0
- package/src/runtime/fast-refresh-types.ts +128 -0
- package/src/runtime/hmr-client.ts +409 -0
- package/src/runtime/http-errors.ts +113 -0
- package/src/runtime/index.ts +6 -0
- package/src/runtime/logger.ts +678 -677
- package/src/runtime/not-found.ts +93 -0
- package/src/runtime/redirect.ts +133 -0
- package/src/runtime/server.ts +679 -23
- package/src/runtime/ssr.ts +340 -10
- package/src/runtime/streaming-ssr.ts +222 -19
- package/src/scheduler/__tests__/scheduler.test.ts +514 -0
- package/src/scheduler/index.ts +343 -0
- package/src/storage/s3/__tests__/s3.test.ts +479 -0
- package/src/storage/s3/index.ts +412 -0
- package/src/testing/index.ts +247 -189
|
@@ -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
|
+
}
|