@prisma-next/target-postgres 0.3.0-dev.4 → 0.3.0-dev.6

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.
@@ -0,0 +1,596 @@
1
+ import type { ContractMarkerRecord } from '@prisma-next/contract/types';
2
+ import type {
3
+ MigrationOperationPolicy,
4
+ SqlControlFamilyInstance,
5
+ SqlMigrationPlanContractInfo,
6
+ SqlMigrationPlanOperation,
7
+ SqlMigrationPlanOperationStep,
8
+ SqlMigrationRunner,
9
+ SqlMigrationRunnerExecuteOptions,
10
+ SqlMigrationRunnerFailure,
11
+ SqlMigrationRunnerResult,
12
+ } from '@prisma-next/family-sql/control';
13
+ import { runnerFailure, runnerSuccess } from '@prisma-next/family-sql/control';
14
+ import { verifySqlSchema } from '@prisma-next/family-sql/schema-verify';
15
+ import { readMarker } from '@prisma-next/family-sql/verify';
16
+ import { SqlQueryError } from '@prisma-next/sql-errors';
17
+ import type { Result } from '@prisma-next/utils/result';
18
+ import { ok, okVoid } from '@prisma-next/utils/result';
19
+ import type { PostgresPlanTargetDetails } from './planner';
20
+ import {
21
+ buildLedgerInsertStatement,
22
+ buildWriteMarkerStatements,
23
+ ensureLedgerTableStatement,
24
+ ensureMarkerTableStatement,
25
+ ensurePrismaContractSchemaStatement,
26
+ type SqlStatement,
27
+ } from './statement-builders';
28
+
29
+ interface RunnerConfig {
30
+ readonly defaultSchema: string;
31
+ }
32
+
33
+ interface ApplyPlanSuccessValue {
34
+ readonly operationsExecuted: number;
35
+ readonly executedOperations: readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[];
36
+ }
37
+
38
+ const DEFAULT_CONFIG: RunnerConfig = {
39
+ defaultSchema: 'public',
40
+ };
41
+
42
+ const LOCK_DOMAIN = 'prisma_next.contract.marker';
43
+
44
+ /**
45
+ * Deep clones and freezes a record object to prevent mutation.
46
+ * Recursively clones nested objects and arrays to ensure complete isolation.
47
+ */
48
+ function cloneAndFreezeRecord<T extends Record<string, unknown>>(value: T): T {
49
+ const cloned: Record<string, unknown> = {};
50
+ for (const [key, val] of Object.entries(value)) {
51
+ if (val === null || val === undefined) {
52
+ cloned[key] = val;
53
+ } else if (Array.isArray(val)) {
54
+ // Clone array (shallow clone of array elements)
55
+ cloned[key] = Object.freeze([...val]);
56
+ } else if (typeof val === 'object') {
57
+ // Recursively clone nested objects
58
+ cloned[key] = cloneAndFreezeRecord(val as Record<string, unknown>);
59
+ } else {
60
+ // Primitives are copied as-is
61
+ cloned[key] = val;
62
+ }
63
+ }
64
+ return Object.freeze(cloned) as T;
65
+ }
66
+
67
+ export function createPostgresMigrationRunner(
68
+ family: SqlControlFamilyInstance,
69
+ config: Partial<RunnerConfig> = {},
70
+ ): SqlMigrationRunner<PostgresPlanTargetDetails> {
71
+ return new PostgresMigrationRunner(family, { ...DEFAULT_CONFIG, ...config });
72
+ }
73
+
74
+ class PostgresMigrationRunner implements SqlMigrationRunner<PostgresPlanTargetDetails> {
75
+ constructor(
76
+ private readonly family: SqlControlFamilyInstance,
77
+ private readonly config: RunnerConfig,
78
+ ) {}
79
+
80
+ async execute(
81
+ options: SqlMigrationRunnerExecuteOptions<PostgresPlanTargetDetails>,
82
+ ): Promise<SqlMigrationRunnerResult> {
83
+ const schema = options.schemaName ?? this.config.defaultSchema;
84
+ const driver = options.driver;
85
+ const lockKey = `${LOCK_DOMAIN}:${schema}`;
86
+
87
+ // Static checks - fail fast before transaction
88
+ const destinationCheck = this.ensurePlanMatchesDestinationContract(
89
+ options.plan.destination,
90
+ options.destinationContract,
91
+ );
92
+ if (!destinationCheck.ok) {
93
+ return destinationCheck;
94
+ }
95
+
96
+ const policyCheck = this.enforcePolicyCompatibility(options.policy, options.plan.operations);
97
+ if (!policyCheck.ok) {
98
+ return policyCheck;
99
+ }
100
+
101
+ // Begin transaction for DB operations
102
+ await this.beginTransaction(driver);
103
+ let committed = false;
104
+ try {
105
+ await this.acquireLock(driver, lockKey);
106
+ await this.ensureControlTables(driver);
107
+ const existingMarker = await readMarker(driver);
108
+
109
+ // Validate plan origin matches existing marker (needs marker from DB)
110
+ const markerCheck = this.ensureMarkerCompatibility(existingMarker, options.plan);
111
+ if (!markerCheck.ok) {
112
+ return markerCheck;
113
+ }
114
+
115
+ // Apply plan operations or skip if marker already at destination
116
+ const markerAtDestination = this.markerMatchesDestination(existingMarker, options.plan);
117
+ let applyValue: ApplyPlanSuccessValue;
118
+
119
+ if (markerAtDestination) {
120
+ applyValue = { operationsExecuted: 0, executedOperations: [] };
121
+ } else {
122
+ const applyResult = await this.applyPlan(driver, options);
123
+ if (!applyResult.ok) {
124
+ return applyResult;
125
+ }
126
+ applyValue = applyResult.value;
127
+ }
128
+
129
+ // Verify resulting schema matches contract
130
+ // Step 1: Introspect live schema (DB I/O, family-owned)
131
+ const schemaIR = await this.family.introspect({
132
+ driver,
133
+ contractIR: options.destinationContract,
134
+ });
135
+
136
+ // Step 2: Pure verification (no DB I/O)
137
+ const schemaVerifyResult = verifySqlSchema({
138
+ contract: options.destinationContract,
139
+ schema: schemaIR,
140
+ strict: options.strictVerification ?? true,
141
+ context: options.context ?? {},
142
+ typeMetadataRegistry: this.family.typeMetadataRegistry,
143
+ frameworkComponents: options.frameworkComponents,
144
+ });
145
+ if (!schemaVerifyResult.ok) {
146
+ return runnerFailure('SCHEMA_VERIFY_FAILED', schemaVerifyResult.summary, {
147
+ why: 'The resulting database schema does not satisfy the destination contract.',
148
+ meta: {
149
+ issues: schemaVerifyResult.schema.issues,
150
+ },
151
+ });
152
+ }
153
+
154
+ // Record marker and ledger entries
155
+ await this.upsertMarker(driver, options, existingMarker);
156
+ await this.recordLedgerEntry(driver, options, existingMarker, applyValue.executedOperations);
157
+
158
+ await this.commitTransaction(driver);
159
+ committed = true;
160
+ return runnerSuccess({
161
+ operationsPlanned: options.plan.operations.length,
162
+ operationsExecuted: applyValue.operationsExecuted,
163
+ });
164
+ } finally {
165
+ if (!committed) {
166
+ await this.rollbackTransaction(driver);
167
+ }
168
+ }
169
+ }
170
+
171
+ private async applyPlan(
172
+ driver: SqlMigrationRunnerExecuteOptions<PostgresPlanTargetDetails>['driver'],
173
+ options: SqlMigrationRunnerExecuteOptions<PostgresPlanTargetDetails>,
174
+ ): Promise<Result<ApplyPlanSuccessValue, SqlMigrationRunnerFailure>> {
175
+ const checks = options.executionChecks;
176
+ const runPrechecks = checks?.prechecks !== false; // Default true
177
+ const runPostchecks = checks?.postchecks !== false; // Default true
178
+ const runIdempotency = checks?.idempotencyChecks !== false; // Default true
179
+
180
+ let operationsExecuted = 0;
181
+ const executedOperations: Array<SqlMigrationPlanOperation<PostgresPlanTargetDetails>> = [];
182
+ for (const operation of options.plan.operations) {
183
+ options.callbacks?.onOperationStart?.(operation);
184
+ try {
185
+ // Idempotency probe: only run if both postchecks and idempotency checks are enabled
186
+ if (runPostchecks && runIdempotency) {
187
+ const postcheckAlreadySatisfied = await this.expectationsAreSatisfied(
188
+ driver,
189
+ operation.postcheck,
190
+ );
191
+ if (postcheckAlreadySatisfied) {
192
+ executedOperations.push(this.createPostcheckPreSatisfiedSkipRecord(operation));
193
+ continue;
194
+ }
195
+ }
196
+
197
+ // Prechecks: only run if enabled
198
+ if (runPrechecks) {
199
+ const precheckResult = await this.runExpectationSteps(
200
+ driver,
201
+ operation.precheck,
202
+ operation,
203
+ 'precheck',
204
+ );
205
+ if (!precheckResult.ok) {
206
+ return precheckResult;
207
+ }
208
+ }
209
+
210
+ const executeResult = await this.runExecuteSteps(driver, operation.execute, operation);
211
+ if (!executeResult.ok) {
212
+ return executeResult;
213
+ }
214
+
215
+ // Postchecks: only run if enabled
216
+ if (runPostchecks) {
217
+ const postcheckResult = await this.runExpectationSteps(
218
+ driver,
219
+ operation.postcheck,
220
+ operation,
221
+ 'postcheck',
222
+ );
223
+ if (!postcheckResult.ok) {
224
+ return postcheckResult;
225
+ }
226
+ }
227
+
228
+ executedOperations.push(operation);
229
+ operationsExecuted += 1;
230
+ } finally {
231
+ options.callbacks?.onOperationComplete?.(operation);
232
+ }
233
+ }
234
+ return ok({ operationsExecuted, executedOperations });
235
+ }
236
+
237
+ private async ensureControlTables(
238
+ driver: SqlMigrationRunnerExecuteOptions<PostgresPlanTargetDetails>['driver'],
239
+ ): Promise<void> {
240
+ await this.executeStatement(driver, ensurePrismaContractSchemaStatement);
241
+ await this.executeStatement(driver, ensureMarkerTableStatement);
242
+ await this.executeStatement(driver, ensureLedgerTableStatement);
243
+ }
244
+
245
+ private async runExpectationSteps(
246
+ driver: SqlMigrationRunnerExecuteOptions<PostgresPlanTargetDetails>['driver'],
247
+ steps: readonly SqlMigrationPlanOperationStep[],
248
+ operation: SqlMigrationPlanOperation<PostgresPlanTargetDetails>,
249
+ phase: 'precheck' | 'postcheck',
250
+ ): Promise<Result<void, SqlMigrationRunnerFailure>> {
251
+ for (const step of steps) {
252
+ const result = await driver.query(step.sql);
253
+ if (!this.stepResultIsTrue(result.rows)) {
254
+ const code = phase === 'precheck' ? 'PRECHECK_FAILED' : 'POSTCHECK_FAILED';
255
+ return runnerFailure(
256
+ code,
257
+ `Operation ${operation.id} failed during ${phase}: ${step.description}`,
258
+ {
259
+ meta: {
260
+ operationId: operation.id,
261
+ phase,
262
+ stepDescription: step.description,
263
+ },
264
+ },
265
+ );
266
+ }
267
+ }
268
+ return okVoid();
269
+ }
270
+
271
+ private async runExecuteSteps(
272
+ driver: SqlMigrationRunnerExecuteOptions<PostgresPlanTargetDetails>['driver'],
273
+ steps: readonly SqlMigrationPlanOperationStep[],
274
+ operation: SqlMigrationPlanOperation<PostgresPlanTargetDetails>,
275
+ ): Promise<Result<void, SqlMigrationRunnerFailure>> {
276
+ for (const step of steps) {
277
+ try {
278
+ await driver.query(step.sql);
279
+ } catch (error: unknown) {
280
+ // Catch SqlQueryError and include normalized metadata
281
+ if (SqlQueryError.is(error)) {
282
+ return runnerFailure(
283
+ 'EXECUTION_FAILED',
284
+ `Operation ${operation.id} failed during execution: ${step.description}`,
285
+ {
286
+ why: error.message,
287
+ meta: {
288
+ operationId: operation.id,
289
+ stepDescription: step.description,
290
+ sql: step.sql,
291
+ sqlState: error.sqlState,
292
+ constraint: error.constraint,
293
+ table: error.table,
294
+ column: error.column,
295
+ detail: error.detail,
296
+ },
297
+ },
298
+ );
299
+ }
300
+ // Let SqlConnectionError and other errors propagate (fail-fast)
301
+ throw error;
302
+ }
303
+ }
304
+ return okVoid();
305
+ }
306
+
307
+ private stepResultIsTrue(rows: readonly Record<string, unknown>[]): boolean {
308
+ if (!rows || rows.length === 0) {
309
+ return false;
310
+ }
311
+ const firstRow = rows[0];
312
+ const firstValue = firstRow ? Object.values(firstRow)[0] : undefined;
313
+ if (typeof firstValue === 'boolean') {
314
+ return firstValue;
315
+ }
316
+ if (typeof firstValue === 'number') {
317
+ return firstValue !== 0;
318
+ }
319
+ if (typeof firstValue === 'string') {
320
+ const lower = firstValue.toLowerCase();
321
+ // PostgreSQL boolean representations: 't'/'f', 'true'/'false', '1'/'0'
322
+ if (lower === 't' || lower === 'true' || lower === '1') {
323
+ return true;
324
+ }
325
+ if (lower === 'f' || lower === 'false' || lower === '0') {
326
+ return false;
327
+ }
328
+ // For other strings, non-empty is truthy (though this case shouldn't occur for boolean checks)
329
+ return firstValue.length > 0;
330
+ }
331
+ return Boolean(firstValue);
332
+ }
333
+
334
+ private async expectationsAreSatisfied(
335
+ driver: SqlMigrationRunnerExecuteOptions<PostgresPlanTargetDetails>['driver'],
336
+ steps: readonly SqlMigrationPlanOperationStep[],
337
+ ): Promise<boolean> {
338
+ if (steps.length === 0) {
339
+ return false;
340
+ }
341
+ for (const step of steps) {
342
+ const result = await driver.query(step.sql);
343
+ if (!this.stepResultIsTrue(result.rows)) {
344
+ return false;
345
+ }
346
+ }
347
+ return true;
348
+ }
349
+
350
+ private createPostcheckPreSatisfiedSkipRecord(
351
+ operation: SqlMigrationPlanOperation<PostgresPlanTargetDetails>,
352
+ ): SqlMigrationPlanOperation<PostgresPlanTargetDetails> {
353
+ // Clone and freeze existing meta if present
354
+ const clonedMeta = operation.meta ? cloneAndFreezeRecord(operation.meta) : undefined;
355
+
356
+ // Create frozen runner metadata
357
+ const runnerMeta = Object.freeze({
358
+ skipped: true,
359
+ reason: 'postcheck_pre_satisfied',
360
+ });
361
+
362
+ // Merge and freeze the combined meta
363
+ const mergedMeta = Object.freeze({
364
+ ...(clonedMeta ?? {}),
365
+ runner: runnerMeta,
366
+ });
367
+
368
+ // Clone and freeze arrays to prevent mutation
369
+ const frozenPostcheck = Object.freeze([...operation.postcheck]);
370
+
371
+ return Object.freeze({
372
+ id: operation.id,
373
+ label: operation.label,
374
+ ...(operation.summary ? { summary: operation.summary } : {}),
375
+ operationClass: operation.operationClass,
376
+ target: operation.target, // Already frozen from plan creation
377
+ precheck: Object.freeze([]),
378
+ execute: Object.freeze([]),
379
+ postcheck: frozenPostcheck,
380
+ ...(operation.meta || mergedMeta ? { meta: mergedMeta } : {}),
381
+ });
382
+ }
383
+
384
+ private markerMatchesDestination(
385
+ marker: ContractMarkerRecord | null,
386
+ plan: SqlMigrationRunnerExecuteOptions<PostgresPlanTargetDetails>['plan'],
387
+ ): boolean {
388
+ if (!marker) {
389
+ return false;
390
+ }
391
+ if (marker.coreHash !== plan.destination.coreHash) {
392
+ return false;
393
+ }
394
+ if (plan.destination.profileHash && marker.profileHash !== plan.destination.profileHash) {
395
+ return false;
396
+ }
397
+ return true;
398
+ }
399
+
400
+ private enforcePolicyCompatibility(
401
+ policy: MigrationOperationPolicy,
402
+ operations: readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[],
403
+ ): Result<void, SqlMigrationRunnerFailure> {
404
+ const allowedClasses = new Set(policy.allowedOperationClasses);
405
+ for (const operation of operations) {
406
+ if (!allowedClasses.has(operation.operationClass)) {
407
+ return runnerFailure(
408
+ 'POLICY_VIOLATION',
409
+ `Operation ${operation.id} has class "${operation.operationClass}" which is not allowed by policy.`,
410
+ {
411
+ why: `Policy only allows: ${policy.allowedOperationClasses.join(', ')}.`,
412
+ meta: {
413
+ operationId: operation.id,
414
+ operationClass: operation.operationClass,
415
+ allowedClasses: policy.allowedOperationClasses,
416
+ },
417
+ },
418
+ );
419
+ }
420
+ }
421
+ return okVoid();
422
+ }
423
+
424
+ private ensureMarkerCompatibility(
425
+ marker: ContractMarkerRecord | null,
426
+ plan: SqlMigrationRunnerExecuteOptions<PostgresPlanTargetDetails>['plan'],
427
+ ): Result<void, SqlMigrationRunnerFailure> {
428
+ const origin = plan.origin ?? null;
429
+ if (!origin) {
430
+ if (!marker) {
431
+ return okVoid();
432
+ }
433
+ if (this.markerMatchesDestination(marker, plan)) {
434
+ return okVoid();
435
+ }
436
+ return runnerFailure(
437
+ 'MARKER_ORIGIN_MISMATCH',
438
+ `Existing contract marker (${marker.coreHash}) does not match plan origin (no marker expected).`,
439
+ {
440
+ meta: {
441
+ markerCoreHash: marker.coreHash,
442
+ expectedOrigin: null,
443
+ },
444
+ },
445
+ );
446
+ }
447
+
448
+ if (!marker) {
449
+ return runnerFailure(
450
+ 'MARKER_ORIGIN_MISMATCH',
451
+ `Missing contract marker: expected origin core hash ${origin.coreHash}.`,
452
+ {
453
+ meta: {
454
+ expectedOriginCoreHash: origin.coreHash,
455
+ },
456
+ },
457
+ );
458
+ }
459
+ if (marker.coreHash !== origin.coreHash) {
460
+ return runnerFailure(
461
+ 'MARKER_ORIGIN_MISMATCH',
462
+ `Existing contract marker (${marker.coreHash}) does not match plan origin (${origin.coreHash}).`,
463
+ {
464
+ meta: {
465
+ markerCoreHash: marker.coreHash,
466
+ expectedOriginCoreHash: origin.coreHash,
467
+ },
468
+ },
469
+ );
470
+ }
471
+ if (origin.profileHash && marker.profileHash !== origin.profileHash) {
472
+ return runnerFailure(
473
+ 'MARKER_ORIGIN_MISMATCH',
474
+ `Existing contract marker profile hash (${marker.profileHash}) does not match plan origin profile hash (${origin.profileHash}).`,
475
+ {
476
+ meta: {
477
+ markerProfileHash: marker.profileHash,
478
+ expectedOriginProfileHash: origin.profileHash,
479
+ },
480
+ },
481
+ );
482
+ }
483
+ return okVoid();
484
+ }
485
+
486
+ private ensurePlanMatchesDestinationContract(
487
+ destination: SqlMigrationPlanContractInfo,
488
+ contract: SqlMigrationRunnerExecuteOptions<PostgresPlanTargetDetails>['destinationContract'],
489
+ ): Result<void, SqlMigrationRunnerFailure> {
490
+ if (destination.coreHash !== contract.coreHash) {
491
+ return runnerFailure(
492
+ 'DESTINATION_CONTRACT_MISMATCH',
493
+ `Plan destination core hash (${destination.coreHash}) does not match provided contract core hash (${contract.coreHash}).`,
494
+ {
495
+ meta: {
496
+ planCoreHash: destination.coreHash,
497
+ contractCoreHash: contract.coreHash,
498
+ },
499
+ },
500
+ );
501
+ }
502
+ if (
503
+ destination.profileHash &&
504
+ contract.profileHash &&
505
+ destination.profileHash !== contract.profileHash
506
+ ) {
507
+ return runnerFailure(
508
+ 'DESTINATION_CONTRACT_MISMATCH',
509
+ `Plan destination profile hash (${destination.profileHash}) does not match provided contract profile hash (${contract.profileHash}).`,
510
+ {
511
+ meta: {
512
+ planProfileHash: destination.profileHash,
513
+ contractProfileHash: contract.profileHash,
514
+ },
515
+ },
516
+ );
517
+ }
518
+ return okVoid();
519
+ }
520
+
521
+ private async upsertMarker(
522
+ driver: SqlMigrationRunnerExecuteOptions<PostgresPlanTargetDetails>['driver'],
523
+ options: SqlMigrationRunnerExecuteOptions<PostgresPlanTargetDetails>,
524
+ existingMarker: ContractMarkerRecord | null,
525
+ ): Promise<void> {
526
+ const writeStatements = buildWriteMarkerStatements({
527
+ coreHash: options.plan.destination.coreHash,
528
+ profileHash:
529
+ options.plan.destination.profileHash ??
530
+ options.destinationContract.profileHash ??
531
+ options.plan.destination.coreHash,
532
+ contractJson: options.destinationContract,
533
+ canonicalVersion: null,
534
+ meta: {},
535
+ });
536
+ const statement = existingMarker ? writeStatements.update : writeStatements.insert;
537
+ await this.executeStatement(driver, statement);
538
+ }
539
+
540
+ private async recordLedgerEntry(
541
+ driver: SqlMigrationRunnerExecuteOptions<PostgresPlanTargetDetails>['driver'],
542
+ options: SqlMigrationRunnerExecuteOptions<PostgresPlanTargetDetails>,
543
+ existingMarker: ContractMarkerRecord | null,
544
+ executedOperations: readonly SqlMigrationPlanOperation<PostgresPlanTargetDetails>[],
545
+ ): Promise<void> {
546
+ const ledgerStatement = buildLedgerInsertStatement({
547
+ originCoreHash: existingMarker?.coreHash ?? null,
548
+ originProfileHash: existingMarker?.profileHash ?? null,
549
+ destinationCoreHash: options.plan.destination.coreHash,
550
+ destinationProfileHash:
551
+ options.plan.destination.profileHash ??
552
+ options.destinationContract.profileHash ??
553
+ options.plan.destination.coreHash,
554
+ contractJsonBefore: existingMarker?.contractJson ?? null,
555
+ contractJsonAfter: options.destinationContract,
556
+ operations: executedOperations,
557
+ });
558
+ await this.executeStatement(driver, ledgerStatement);
559
+ }
560
+
561
+ private async acquireLock(
562
+ driver: SqlMigrationRunnerExecuteOptions<PostgresPlanTargetDetails>['driver'],
563
+ key: string,
564
+ ): Promise<void> {
565
+ await driver.query('select pg_advisory_xact_lock(hashtext($1))', [key]);
566
+ }
567
+
568
+ private async beginTransaction(
569
+ driver: SqlMigrationRunnerExecuteOptions<PostgresPlanTargetDetails>['driver'],
570
+ ): Promise<void> {
571
+ await driver.query('BEGIN');
572
+ }
573
+
574
+ private async commitTransaction(
575
+ driver: SqlMigrationRunnerExecuteOptions<PostgresPlanTargetDetails>['driver'],
576
+ ): Promise<void> {
577
+ await driver.query('COMMIT');
578
+ }
579
+
580
+ private async rollbackTransaction(
581
+ driver: SqlMigrationRunnerExecuteOptions<PostgresPlanTargetDetails>['driver'],
582
+ ): Promise<void> {
583
+ await driver.query('ROLLBACK');
584
+ }
585
+
586
+ private async executeStatement(
587
+ driver: SqlMigrationRunnerExecuteOptions<PostgresPlanTargetDetails>['driver'],
588
+ statement: SqlStatement,
589
+ ): Promise<void> {
590
+ if (statement.params.length > 0) {
591
+ await driver.query(statement.sql, statement.params);
592
+ return;
593
+ }
594
+ await driver.query(statement.sql);
595
+ }
596
+ }