@prisma-next/family-sql 0.5.0-dev.9 → 0.5.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 (72) hide show
  1. package/README.md +2 -2
  2. package/dist/{authoring-type-constructors-BAR65pSK.mjs → authoring-type-constructors-F4JpCJl7.mjs} +14 -15
  3. package/dist/authoring-type-constructors-F4JpCJl7.mjs.map +1 -0
  4. package/dist/control-adapter.d.mts +26 -2
  5. package/dist/control-adapter.d.mts.map +1 -1
  6. package/dist/control-adapter.mjs +1 -1
  7. package/dist/control.d.mts +122 -40
  8. package/dist/control.d.mts.map +1 -1
  9. package/dist/control.mjs +1169 -24
  10. package/dist/control.mjs.map +1 -1
  11. package/dist/migration.d.mts +22 -24
  12. package/dist/migration.d.mts.map +1 -1
  13. package/dist/migration.mjs +25 -24
  14. package/dist/migration.mjs.map +1 -1
  15. package/dist/pack.d.mts +35 -23
  16. package/dist/pack.d.mts.map +1 -1
  17. package/dist/pack.mjs +3 -5
  18. package/dist/pack.mjs.map +1 -1
  19. package/dist/runtime.d.mts +19 -2
  20. package/dist/runtime.d.mts.map +1 -1
  21. package/dist/runtime.mjs +26 -4
  22. package/dist/runtime.mjs.map +1 -1
  23. package/dist/schema-verify.d.mts +2 -4
  24. package/dist/schema-verify.d.mts.map +1 -1
  25. package/dist/schema-verify.mjs +2 -3
  26. package/dist/test-utils.d.mts +2 -2
  27. package/dist/test-utils.mjs +2 -3
  28. package/dist/timestamp-now-generator-BWp8S2sa.mjs +86 -0
  29. package/dist/timestamp-now-generator-BWp8S2sa.mjs.map +1 -0
  30. package/dist/{types-C6K4mxDM.d.mts → types-BQBbcXg3.d.mts} +206 -28
  31. package/dist/types-BQBbcXg3.d.mts.map +1 -0
  32. package/dist/verify-pRYxnpiG.mjs +81 -0
  33. package/dist/verify-pRYxnpiG.mjs.map +1 -0
  34. package/dist/{verify-sql-schema-BBhkqEDo.d.mts → verify-sql-schema-CPHiuYHR.d.mts} +2 -3
  35. package/dist/verify-sql-schema-CPHiuYHR.d.mts.map +1 -0
  36. package/dist/{verify-sql-schema-Ovz7RXR5.mjs → verify-sql-schema-r1-2apHI.mjs} +18 -9
  37. package/dist/verify-sql-schema-r1-2apHI.mjs.map +1 -0
  38. package/dist/verify.d.mts +16 -21
  39. package/dist/verify.d.mts.map +1 -1
  40. package/dist/verify.mjs +2 -3
  41. package/package.json +23 -21
  42. package/src/core/authoring-field-presets.ts +35 -23
  43. package/src/core/control-adapter.ts +32 -0
  44. package/src/core/control-descriptor.ts +2 -1
  45. package/src/core/control-instance.ts +116 -18
  46. package/src/core/migrations/field-event-planner.ts +192 -0
  47. package/src/core/migrations/plan-helpers.ts +4 -0
  48. package/src/core/migrations/types.ts +200 -25
  49. package/src/core/operation-preview.ts +62 -0
  50. package/src/core/psl-contract-infer/default-mapping.ts +56 -0
  51. package/src/core/psl-contract-infer/name-transforms.ts +178 -0
  52. package/src/core/psl-contract-infer/postgres-default-mapping.ts +16 -0
  53. package/src/core/psl-contract-infer/postgres-type-map.ts +165 -0
  54. package/src/core/psl-contract-infer/printer-config.ts +55 -0
  55. package/src/core/psl-contract-infer/raw-default-parser.ts +91 -0
  56. package/src/core/psl-contract-infer/relation-inference.ts +196 -0
  57. package/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts +832 -0
  58. package/src/core/schema-verify/verify-helpers.ts +46 -6
  59. package/src/core/sql-migration.ts +25 -23
  60. package/src/core/timestamp-now-generator.ts +74 -0
  61. package/src/core/timestamp-now-runtime-generator.ts +24 -0
  62. package/src/core/verify.ts +46 -108
  63. package/src/exports/control.ts +11 -1
  64. package/src/exports/runtime.ts +2 -0
  65. package/src/exports/test-utils.ts +0 -1
  66. package/src/exports/verify.ts +1 -1
  67. package/dist/authoring-type-constructors-BAR65pSK.mjs.map +0 -1
  68. package/dist/types-C6K4mxDM.d.mts.map +0 -1
  69. package/dist/verify-4GshvY4p.mjs +0 -122
  70. package/dist/verify-4GshvY4p.mjs.map +0 -1
  71. package/dist/verify-sql-schema-BBhkqEDo.d.mts.map +0 -1
  72. package/dist/verify-sql-schema-Ovz7RXR5.mjs.map +0 -1
@@ -1,10 +1,10 @@
1
1
  import type { Contract } from '@prisma-next/contract/types';
2
2
  import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components';
3
3
  import type {
4
+ ContractSpace,
4
5
  ControlAdapterDescriptor,
5
6
  ControlDriverInstance,
6
7
  ControlExtensionDescriptor,
7
- DataTransformOperation,
8
8
  MigratableTargetDescriptor,
9
9
  MigrationOperationPolicy,
10
10
  MigrationPlan,
@@ -16,10 +16,16 @@ import type {
16
16
  MigrationRunnerFailure,
17
17
  MigrationRunnerSuccessValue,
18
18
  OperationContext,
19
+ OpFactoryCall,
19
20
  SchemaIssue,
20
21
  } from '@prisma-next/framework-components/control';
21
- import type { SqlStorage, StorageTypeInstance } from '@prisma-next/sql-contract/types';
22
- import type { SqlOperationDescriptor } from '@prisma-next/sql-operations';
22
+ import type {
23
+ SqlStorage,
24
+ StorageColumn,
25
+ StorageTable,
26
+ StorageTypeInstance,
27
+ } from '@prisma-next/sql-contract/types';
28
+ import type { SqlOperationDescriptors } from '@prisma-next/sql-operations';
23
29
  import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types';
24
30
  import type { Result } from '@prisma-next/utils/result';
25
31
  import type { SqlControlFamilyInstance } from '../control-instance';
@@ -83,6 +89,41 @@ export interface ResolveIdentityValueInput {
83
89
  readonly typeParams?: Record<string, unknown>;
84
90
  }
85
91
 
92
+ /**
93
+ * Per-field lifecycle event a codec hook can react to.
94
+ *
95
+ * Fired during app-space migration emission as the SQL family diffs the
96
+ * prior contract against the new contract.
97
+ *
98
+ * - `'added'` — the field is present in the new contract but not the prior.
99
+ * - `'dropped'` — the field is present in the prior contract but not the new.
100
+ * - `'altered'` — the field is present in both and any property other than
101
+ * `codecId` differs. Codec-id changes are a v1 non-goal:
102
+ * when only `codecId` differs, no `'altered'` event fires.
103
+ */
104
+ export type FieldEvent = 'added' | 'dropped' | 'altered';
105
+
106
+ /**
107
+ * Context passed to {@link CodecControlHooks.onFieldEvent}.
108
+ *
109
+ * `tableName` and `fieldName` are always populated; `priorTable` /
110
+ * `priorField` carry the prior contract's view of the table and column
111
+ * (present for `'dropped'` and `'altered'`); `newTable` / `newField`
112
+ * carry the new contract's view (present for `'added'` and `'altered'`).
113
+ *
114
+ * The hook only ever receives app-space contract IR — extension-space
115
+ * fields are scoped out by the API: the hook is wired at the
116
+ * application emitter only.
117
+ */
118
+ export interface FieldEventContext {
119
+ readonly tableName: string;
120
+ readonly fieldName: string;
121
+ readonly priorTable?: StorageTable;
122
+ readonly newTable?: StorageTable;
123
+ readonly priorField?: StorageColumn;
124
+ readonly newField?: StorageColumn;
125
+ }
126
+
86
127
  export interface CodecControlHooks<TTargetDetails = unknown> {
87
128
  planTypeOperations?: (options: {
88
129
  readonly typeName: string;
@@ -123,22 +164,57 @@ export interface CodecControlHooks<TTargetDetails = unknown> {
123
164
  * - undefined: no opinion; planner may use built-in fallbacks
124
165
  */
125
166
  resolveIdentityValue?: (input: ResolveIdentityValueInput) => string | null | undefined;
167
+ /**
168
+ * Reacts to per-field added / dropped / altered events as the app-space
169
+ * emitter diffs the prior contract against the new contract. Returned
170
+ * ops are inlined into the app-space migration's `ops.json` alongside
171
+ * the user's structural ops.
172
+ *
173
+ * Synchronous. Each returned op must carry its own `invariantId`. Hooks
174
+ * are dispatched per `(table, field)` based on the field's `codecId`
175
+ * (the new field's codec for `'added'` / `'altered'`; the prior field's
176
+ * codec for `'dropped'`).
177
+ */
178
+ onFieldEvent?: (event: FieldEvent, ctx: FieldEventContext) => readonly OpFactoryCall[];
126
179
  }
127
180
 
128
181
  export interface SqlControlExtensionDescriptor<TTargetId extends string>
129
182
  extends ControlExtensionDescriptor<'sql', TTargetId> {
130
183
  readonly databaseDependencies?: ComponentDatabaseDependencies<unknown>;
131
- readonly queryOperations?: () => ReadonlyArray<SqlOperationDescriptor>;
184
+ readonly queryOperations?: () => SqlOperationDescriptors;
185
+ /**
186
+ * Schema-contributing extensions opt into the per-space planner / runner /
187
+ * verifier by setting this field. Extensions without it are codec-only or
188
+ * query-ops-only — today's behaviour preserved.
189
+ *
190
+ * The shape comes from `@prisma-next/framework-components/control`
191
+ * (`ContractSpace`) — contract-space identity is a framework concept,
192
+ * not a SQL-specific one. The SQL family specialises the generic to
193
+ * `Contract<SqlStorage>` so descriptor authors continue to see a
194
+ * typed contract value.
195
+ *
196
+ * @see specs/framework-mechanism.spec.md § 1.
197
+ */
198
+ readonly contractSpace?: ContractSpace<Contract<SqlStorage>>;
132
199
  }
133
200
 
134
201
  export interface SqlControlAdapterDescriptor<TTargetId extends string>
135
202
  extends ControlAdapterDescriptor<'sql', TTargetId> {
136
- readonly queryOperations?: () => ReadonlyArray<SqlOperationDescriptor>;
203
+ readonly queryOperations?: () => SqlOperationDescriptors;
137
204
  }
138
205
 
139
206
  export interface SqlMigrationPlanOperationStep {
140
207
  readonly description: string;
141
208
  readonly sql: string;
209
+ /**
210
+ * Optional parameter values bound at execution time. The runner forwards
211
+ * these to `driver.query(sql, params ?? [])`, so step authors can use
212
+ * placeholder syntax (`$1`, `$2`, …) instead of inlining literals into
213
+ * the SQL string. Reuses the driver's parameter binder rather than
214
+ * rolling per-target literal serialization for every type the planner
215
+ * may emit.
216
+ */
217
+ readonly params?: readonly unknown[];
142
218
  readonly meta?: AnyRecord;
143
219
  }
144
220
 
@@ -168,25 +244,27 @@ export interface SqlMigrationPlanOperation<TTargetDetails> extends MigrationPlan
168
244
  readonly meta?: AnyRecord;
169
245
  }
170
246
 
171
- /**
172
- * Union of all operation shapes a SQL-family migration may emit: schema-facing
173
- * `SqlMigrationPlanOperation`s and family-agnostic `DataTransformOperation`s.
174
- *
175
- * Mirrors `AnyMongoMigrationOperation` in shape — the runner already handles
176
- * both branches via `isDataTransformOperation`, and authored `migration.ts`
177
- * files must be able to intermix `dataTransform(endContract, …)` calls with
178
- * DDL factory calls (e.g. `setNotNull(…)`) in a single `operations` array.
179
- */
180
- export type AnySqlMigrationOperation<TTargetDetails> =
181
- | SqlMigrationPlanOperation<TTargetDetails>
182
- | DataTransformOperation;
183
-
184
247
  export interface SqlMigrationPlanContractInfo {
185
248
  readonly storageHash: string;
186
249
  readonly profileHash?: string;
187
250
  }
188
251
 
189
252
  export interface SqlMigrationPlan<TTargetDetails> extends MigrationPlan {
253
+ /**
254
+ * Contract space this plan applies to. The runner uses this to key the
255
+ * `prisma_contract.marker` row it writes/reads (`space = <spaceId>`),
256
+ * so per-extension plans hit per-extension marker rows instead of all
257
+ * collapsing onto the app's row.
258
+ *
259
+ * App-plan callers pass `APP_SPACE_ID` (`'app'`); per-extension plans
260
+ * pass the extension's space id. Required at every call site so the
261
+ * type system surfaces every place that needs to thread the value
262
+ * (rather than letting an `?? APP_SPACE_ID` fall-through silently
263
+ * collapse multi-space markers onto the `'app'` row).
264
+ *
265
+ * @see specs/framework-mechanism.spec.md § 2.
266
+ */
267
+ readonly spaceId: string;
190
268
  /**
191
269
  * Origin contract identity that the plan expects the database to currently be at.
192
270
  * If omitted or null, the runner skips origin validation entirely.
@@ -197,6 +275,15 @@ export interface SqlMigrationPlan<TTargetDetails> extends MigrationPlan {
197
275
  */
198
276
  readonly destination: SqlMigrationPlanContractInfo;
199
277
  readonly operations: readonly SqlMigrationPlanOperation<TTargetDetails>[];
278
+ /**
279
+ * Sorted, deduplicated invariant ids declared by this plan's data-transform
280
+ * ops. Required at the SQL-family layer (the SQL runners consume this as
281
+ * the source of truth for marker writes and self-edge no-op checks); the
282
+ * framework-level {@link MigrationPlan.providedInvariants} stays optional
283
+ * because `db init` / `db update` plans don't have a corresponding
284
+ * migration manifest.
285
+ */
286
+ readonly providedInvariants: readonly string[];
200
287
  readonly meta?: AnyRecord;
201
288
  }
202
289
 
@@ -243,14 +330,29 @@ export interface SqlMigrationPlannerPlanOptions {
243
330
  readonly policy: MigrationOperationPolicy;
244
331
  readonly schemaName?: string;
245
332
  /**
246
- * The "from" contract (state the planner assumes the database starts at).
247
- * Only `migration plan` supplies this; `db update` / `db init` reconcile
248
- * against the live schema with no old contract. Strategies that need
249
- * from/to column-shape comparisons (unsafe type change, nullability
333
+ * Contract space the plan applies to. The planner stamps this onto
334
+ * the produced {@link SqlMigrationPlan.spaceId} so the runner keys
335
+ * the marker row by the right space. App-plan callers pass
336
+ * `APP_SPACE_ID`; per-extension callers pass the extension's space
337
+ * id.
338
+ */
339
+ readonly spaceId: string;
340
+ /**
341
+ * The "from" contract (state the planner assumes the database starts at),
342
+ * or `null` for reconciliation flows that have no prior contract.
343
+ *
344
+ * Required at every call site so the structural fact "I have a prior
345
+ * contract / I don't" is visible in the type. `migration plan` supplies
346
+ * the previous bundle's `metadata.toContract`; `db update` / `db init`
347
+ * reconcile against the live schema and pass `null`. Strategies that
348
+ * need from/to column-shape comparisons (unsafe type change, nullability
250
349
  * tightening) use this to decide whether to emit `dataTransform`
251
- * placeholders.
350
+ * placeholders; they short-circuit when it is `null`.
351
+ *
352
+ * Planners also derive the "from" identity they stamp onto the produced
353
+ * plan's `describe()` as `fromContract?.storage.storageHash ?? null`.
252
354
  */
253
- readonly fromContract?: Contract<SqlStorage> | null;
355
+ readonly fromContract: Contract<SqlStorage> | null;
254
356
  /**
255
357
  * Active framework components participating in this composition.
256
358
  * SQL targets can interpret this list to derive database dependencies.
@@ -271,6 +373,14 @@ export interface SqlMigrationRunnerExecuteCallbacks<TTargetDetails> {
271
373
  export interface SqlMigrationRunnerExecuteOptions<TTargetDetails> {
272
374
  readonly plan: SqlMigrationPlan<TTargetDetails>;
273
375
  readonly driver: ControlDriverInstance<'sql', string>;
376
+ /**
377
+ * Logical contract space this plan applies to. When omitted the
378
+ * runner derives the space from {@link SqlMigrationPlan.spaceId};
379
+ * when supplied, the runner asserts it matches `plan.spaceId` so a
380
+ * caller cannot accidentally write the marker row for a different
381
+ * space than the plan was produced for.
382
+ */
383
+ readonly space?: string;
274
384
  /**
275
385
  * Destination contract IR.
276
386
  * Must correspond to `plan.destination` and is used for schema verification and marker/ledger writes.
@@ -300,11 +410,13 @@ export interface SqlMigrationRunnerExecuteOptions<TTargetDetails> {
300
410
 
301
411
  export type SqlMigrationRunnerErrorCode =
302
412
  | 'DESTINATION_CONTRACT_MISMATCH'
413
+ | 'LEGACY_MARKER_SHAPE'
303
414
  | 'MARKER_ORIGIN_MISMATCH'
304
415
  | 'POLICY_VIOLATION'
305
416
  | 'PRECHECK_FAILED'
306
417
  | 'POSTCHECK_FAILED'
307
418
  | 'SCHEMA_VERIFY_FAILED'
419
+ | 'FOREIGN_KEY_VIOLATION'
308
420
  | 'EXECUTION_FAILED';
309
421
 
310
422
  export interface SqlMigrationRunnerFailure extends MigrationRunnerFailure {
@@ -320,22 +432,85 @@ export type SqlMigrationRunnerResult = Result<
320
432
  >;
321
433
 
322
434
  export interface SqlMigrationRunner<TTargetDetails> {
435
+ /**
436
+ * Apply a single migration plan, opening and managing its own
437
+ * transaction (and any target-specific connection-level setup, e.g.
438
+ * SQLite's `PRAGMA foreign_keys` toggle). Existing single-space
439
+ * callers route through here.
440
+ */
323
441
  execute(
324
442
  options: SqlMigrationRunnerExecuteOptions<TTargetDetails>,
325
443
  ): Promise<SqlMigrationRunnerResult>;
444
+
445
+ /**
446
+ * Apply a single migration plan against an already-open connection
447
+ * **without** opening a transaction. The caller is responsible for
448
+ * wrapping the call (and any siblings) in `BEGIN` / `COMMIT` /
449
+ * `ROLLBACK`. Used by the per-space runner wiring to fan out across
450
+ * contract spaces inside one outer transaction so a mid-apply
451
+ * failure rolls back every space's writes.
452
+ *
453
+ * Idempotent control-table setup (`prisma_contract.*`) and marker
454
+ * writes use `options.space` to address the per-space marker row.
455
+ */
456
+ executeOnConnection(
457
+ options: SqlMigrationRunnerExecuteOptions<TTargetDetails>,
458
+ ): Promise<SqlMigrationRunnerResult>;
459
+
460
+ /**
461
+ * Apply per-space plans across multiple contract spaces inside a
462
+ * single outer transaction. The caller orders the input list
463
+ * (typically via the aggregate planner's `applyOrder`: extensions
464
+ * alphabetical, then app); the runner is responsible for opening
465
+ * / committing the outer
466
+ * transaction (and any target-specific connection-level setup such
467
+ * as the SQLite FK pragma toggle). A failure on any space rolls
468
+ * back every space's writes.
469
+ *
470
+ * Each space's `SqlMigrationRunnerExecuteOptions` must reference the
471
+ * same `driver` (the connection the outer transaction is open on).
472
+ * Per-space marker writes use `options.space` to address the row.
473
+ */
474
+ executeAcrossSpaces(options: {
475
+ readonly driver: ControlDriverInstance<'sql', string>;
476
+ readonly perSpaceOptions: ReadonlyArray<SqlMigrationRunnerExecuteOptions<TTargetDetails>>;
477
+ }): Promise<MultiSpaceRunnerResult>;
478
+ }
479
+
480
+ export interface MultiSpaceRunnerSuccessValue {
481
+ readonly perSpaceResults: ReadonlyArray<{
482
+ readonly space: string;
483
+ readonly value: SqlMigrationRunnerSuccessValue;
484
+ }>;
326
485
  }
327
486
 
487
+ export interface MultiSpaceRunnerFailure extends SqlMigrationRunnerFailure {
488
+ readonly failingSpace: string;
489
+ }
490
+
491
+ export type MultiSpaceRunnerResult = Result<MultiSpaceRunnerSuccessValue, MultiSpaceRunnerFailure>;
492
+
328
493
  export interface SqlControlTargetDescriptor<TTargetId extends string, TTargetDetails>
329
494
  extends MigratableTargetDescriptor<'sql', TTargetId, SqlControlFamilyInstance> {
330
- readonly queryOperations?: () => ReadonlyArray<SqlOperationDescriptor>;
495
+ readonly queryOperations?: () => SqlOperationDescriptors;
331
496
  createPlanner(family: SqlControlFamilyInstance): SqlMigrationPlanner<TTargetDetails>;
332
497
  createRunner(family: SqlControlFamilyInstance): SqlMigrationRunner<TTargetDetails>;
333
498
  }
334
499
 
335
500
  export interface CreateSqlMigrationPlanOptions<TTargetDetails> {
336
501
  readonly targetId: string;
502
+ /**
503
+ * Contract space this plan applies to. Mirrors {@link SqlMigrationPlan.spaceId}.
504
+ */
505
+ readonly spaceId: string;
337
506
  readonly origin?: SqlMigrationPlanContractInfo | null;
338
507
  readonly destination: SqlMigrationPlanContractInfo;
339
508
  readonly operations: readonly SqlMigrationPlanOperation<TTargetDetails>[];
509
+ /**
510
+ * Sorted, deduplicated invariant ids for this plan; mirrors the required
511
+ * field on {@link SqlMigrationPlan}. Callers without a migration manifest
512
+ * (`db init`, `db update`, planner-built plans) pass `[]`.
513
+ */
514
+ readonly providedInvariants: readonly string[];
340
515
  readonly meta?: AnyRecord;
341
516
  }
@@ -0,0 +1,62 @@
1
+ import type {
2
+ MigrationPlanOperation,
3
+ OperationPreview,
4
+ } from '@prisma-next/framework-components/control';
5
+
6
+ /**
7
+ * Shape of an SQL execute step on `SqlMigrationPlanOperation`. Used for runtime
8
+ * type narrowing without importing the concrete SQL type.
9
+ */
10
+ interface SqlExecuteStep {
11
+ readonly sql: string;
12
+ }
13
+
14
+ function isDdlStatement(sqlStatement: string): boolean {
15
+ const trimmed = sqlStatement.trim().toLowerCase();
16
+ return (
17
+ trimmed.startsWith('create ') || trimmed.startsWith('alter ') || trimmed.startsWith('drop ')
18
+ );
19
+ }
20
+
21
+ function hasExecuteSteps(
22
+ operation: MigrationPlanOperation,
23
+ ): operation is MigrationPlanOperation & { readonly execute: readonly SqlExecuteStep[] } {
24
+ const candidate = operation as unknown as Record<string, unknown>;
25
+ if (!('execute' in candidate) || !Array.isArray(candidate['execute'])) {
26
+ return false;
27
+ }
28
+ return candidate['execute'].every(
29
+ (step: unknown) => typeof step === 'object' && step !== null && 'sql' in step,
30
+ );
31
+ }
32
+
33
+ /**
34
+ * Extracts a best-effort SQL DDL preview for CLI plan output.
35
+ * Presentation-only: never used to decide migration correctness.
36
+ */
37
+ export function extractSqlDdl(operations: readonly MigrationPlanOperation[]): string[] {
38
+ const statements: string[] = [];
39
+ for (const operation of operations) {
40
+ if (!hasExecuteSteps(operation)) {
41
+ continue;
42
+ }
43
+ for (const step of operation.execute) {
44
+ if (typeof step.sql === 'string' && isDdlStatement(step.sql)) {
45
+ statements.push(step.sql.trim());
46
+ }
47
+ }
48
+ }
49
+ return statements;
50
+ }
51
+
52
+ /**
53
+ * Wraps `extractSqlDdl` into the family-agnostic `OperationPreview` shape.
54
+ * Each statement carries `language: 'sql'`.
55
+ */
56
+ export function sqlOperationsToPreview(
57
+ operations: readonly MigrationPlanOperation[],
58
+ ): OperationPreview {
59
+ return {
60
+ statements: extractSqlDdl(operations).map((text) => ({ text, language: 'sql' })),
61
+ };
62
+ }
@@ -0,0 +1,56 @@
1
+ import type { ColumnDefault } from '@prisma-next/contract/types';
2
+
3
+ const DEFAULT_FUNCTION_ATTRIBUTES: Readonly<Record<string, string>> = {
4
+ 'autoincrement()': '@default(autoincrement())',
5
+ 'now()': '@default(now())',
6
+ };
7
+
8
+ export interface DefaultMappingOptions {
9
+ readonly functionAttributes?: Readonly<Record<string, string>>;
10
+ readonly fallbackFunctionAttribute?: ((expression: string) => string | undefined) | undefined;
11
+ }
12
+
13
+ export type DefaultMappingResult = { readonly attribute: string } | { readonly comment: string };
14
+
15
+ export function mapDefault(
16
+ columnDefault: ColumnDefault,
17
+ options?: DefaultMappingOptions,
18
+ ): DefaultMappingResult {
19
+ switch (columnDefault.kind) {
20
+ case 'literal':
21
+ return { attribute: `@default(${formatLiteralValue(columnDefault.value)})` };
22
+ case 'function': {
23
+ const attribute =
24
+ options?.functionAttributes?.[columnDefault.expression] ??
25
+ DEFAULT_FUNCTION_ATTRIBUTES[columnDefault.expression] ??
26
+ options?.fallbackFunctionAttribute?.(columnDefault.expression);
27
+ return attribute
28
+ ? { attribute }
29
+ : { comment: `// Raw default: ${columnDefault.expression.replace(/[\r\n]+/g, ' ')}` };
30
+ }
31
+ }
32
+ }
33
+
34
+ function formatLiteralValue(value: unknown): string {
35
+ if (value === null) {
36
+ return 'null';
37
+ }
38
+
39
+ switch (typeof value) {
40
+ case 'boolean':
41
+ case 'number':
42
+ return String(value);
43
+ case 'string':
44
+ return quoteString(value);
45
+ default:
46
+ return quoteString(JSON.stringify(value));
47
+ }
48
+ }
49
+
50
+ function quoteString(str: string): string {
51
+ return `"${escapeString(str)}"`;
52
+ }
53
+
54
+ function escapeString(str: string): string {
55
+ return JSON.stringify(str).slice(1, -1);
56
+ }
@@ -0,0 +1,178 @@
1
+ const PSL_RESERVED_WORDS = new Set(['model', 'enum', 'types', 'type', 'generator', 'datasource']);
2
+
3
+ const IDENTIFIER_PART_PATTERN = /[A-Za-z0-9]+/g;
4
+
5
+ type NameResult = {
6
+ readonly name: string;
7
+ readonly map?: string;
8
+ };
9
+
10
+ function hasSeparators(input: string): boolean {
11
+ return /[^A-Za-z0-9]/.test(input);
12
+ }
13
+
14
+ function extractIdentifierParts(input: string): string[] {
15
+ return input.match(IDENTIFIER_PART_PATTERN) ?? [];
16
+ }
17
+
18
+ function createSyntheticIdentifier(input: string): string {
19
+ let hash = 2166136261;
20
+
21
+ for (const char of input) {
22
+ hash ^= char.codePointAt(0) ?? 0;
23
+ hash = Math.imul(hash, 16777619);
24
+ }
25
+
26
+ return `x${(hash >>> 0).toString(16)}`;
27
+ }
28
+
29
+ function sanitizeIdentifierCharacters(input: string): string {
30
+ const sanitized = input.replace(/[^\w]/g, '');
31
+ return sanitized.length > 0 ? sanitized : createSyntheticIdentifier(input);
32
+ }
33
+
34
+ function capitalize(word: string): string {
35
+ return word.charAt(0).toUpperCase() + word.slice(1);
36
+ }
37
+
38
+ function snakeToPascalCase(input: string): string {
39
+ const parts = extractIdentifierParts(input);
40
+ if (parts.length === 0) {
41
+ return capitalize(sanitizeIdentifierCharacters(input));
42
+ }
43
+ return parts.map(capitalize).join('');
44
+ }
45
+
46
+ function snakeToCamelCase(input: string): string {
47
+ const parts = extractIdentifierParts(input);
48
+ if (parts.length === 0) {
49
+ return sanitizeIdentifierCharacters(input);
50
+ }
51
+ const [firstPart = input, ...rest] = parts;
52
+ return firstPart.charAt(0).toLowerCase() + firstPart.slice(1) + rest.map(capitalize).join('');
53
+ }
54
+
55
+ function needsEscaping(name: string): boolean {
56
+ return PSL_RESERVED_WORDS.has(name.toLowerCase()) || /^\d/.test(name);
57
+ }
58
+
59
+ function escapeName(name: string): string {
60
+ return `_${name}`;
61
+ }
62
+
63
+ function escapeIfNeeded(name: string): string {
64
+ return needsEscaping(name) ? escapeName(name) : name;
65
+ }
66
+
67
+ export function toModelName(tableName: string): NameResult {
68
+ let name: string;
69
+
70
+ if (hasSeparators(tableName)) {
71
+ name = snakeToPascalCase(tableName);
72
+ } else {
73
+ name = tableName.charAt(0).toUpperCase() + tableName.slice(1);
74
+ }
75
+
76
+ if (needsEscaping(name)) {
77
+ const escaped = escapeName(name);
78
+ return { name: escaped, map: tableName };
79
+ }
80
+
81
+ if (name !== tableName) {
82
+ return { name, map: tableName };
83
+ }
84
+
85
+ return { name };
86
+ }
87
+
88
+ export function toFieldName(columnName: string): NameResult {
89
+ let name: string;
90
+
91
+ if (hasSeparators(columnName)) {
92
+ name = snakeToCamelCase(columnName);
93
+ } else {
94
+ name = columnName.charAt(0).toLowerCase() + columnName.slice(1);
95
+ }
96
+
97
+ if (needsEscaping(name)) {
98
+ const escaped = escapeName(name);
99
+ return { name: escaped, map: columnName };
100
+ }
101
+
102
+ if (name !== columnName) {
103
+ return { name, map: columnName };
104
+ }
105
+
106
+ return { name };
107
+ }
108
+
109
+ export function toEnumName(pgTypeName: string): NameResult {
110
+ let name: string;
111
+
112
+ if (hasSeparators(pgTypeName)) {
113
+ name = snakeToPascalCase(pgTypeName);
114
+ } else {
115
+ name = pgTypeName.charAt(0).toUpperCase() + pgTypeName.slice(1);
116
+ }
117
+
118
+ if (needsEscaping(name)) {
119
+ const escaped = escapeName(name);
120
+ return { name: escaped, map: pgTypeName };
121
+ }
122
+
123
+ if (name !== pgTypeName) {
124
+ return { name, map: pgTypeName };
125
+ }
126
+
127
+ return { name };
128
+ }
129
+
130
+ export function pluralize(word: string): string {
131
+ if (
132
+ word.endsWith('s') ||
133
+ word.endsWith('x') ||
134
+ word.endsWith('z') ||
135
+ word.endsWith('ch') ||
136
+ word.endsWith('sh')
137
+ ) {
138
+ return `${word}es`;
139
+ }
140
+ if (word.endsWith('y') && !/[aeiou]y$/i.test(word)) {
141
+ return `${word.slice(0, -1)}ies`;
142
+ }
143
+ return `${word}s`;
144
+ }
145
+
146
+ export function deriveRelationFieldName(
147
+ fkColumns: readonly string[],
148
+ referencedTableName: string,
149
+ ): string {
150
+ if (fkColumns.length === 1) {
151
+ const [col = referencedTableName] = fkColumns;
152
+ const stripped = col.replace(/_id$/i, '').replace(/Id$/, '');
153
+
154
+ if (stripped.length > 0 && stripped !== col) {
155
+ return escapeIfNeeded(snakeToCamelCase(stripped));
156
+ }
157
+ return escapeIfNeeded(snakeToCamelCase(referencedTableName));
158
+ }
159
+
160
+ return escapeIfNeeded(snakeToCamelCase(referencedTableName));
161
+ }
162
+
163
+ export function deriveBackRelationFieldName(childModelName: string, isOneToOne: boolean): string {
164
+ const base = childModelName.charAt(0).toLowerCase() + childModelName.slice(1);
165
+ return isOneToOne ? base : pluralize(base);
166
+ }
167
+
168
+ export function toNamedTypeName(columnName: string): string {
169
+ let name: string;
170
+
171
+ if (hasSeparators(columnName)) {
172
+ name = snakeToPascalCase(columnName);
173
+ } else {
174
+ name = columnName.charAt(0).toUpperCase() + columnName.slice(1);
175
+ }
176
+
177
+ return escapeIfNeeded(name);
178
+ }
@@ -0,0 +1,16 @@
1
+ import type { DefaultMappingOptions } from './default-mapping';
2
+
3
+ const POSTGRES_FUNCTION_ATTRIBUTES: Readonly<Record<string, string>> = {
4
+ 'gen_random_uuid()': '@default(dbgenerated("gen_random_uuid()"))',
5
+ };
6
+
7
+ function formatDbGeneratedAttribute(expression: string): string {
8
+ return `@default(dbgenerated(${JSON.stringify(expression)}))`;
9
+ }
10
+
11
+ export function createPostgresDefaultMapping(): DefaultMappingOptions {
12
+ return {
13
+ functionAttributes: POSTGRES_FUNCTION_ATTRIBUTES,
14
+ fallbackFunctionAttribute: formatDbGeneratedAttribute,
15
+ };
16
+ }