@prisma-next/target-postgres 0.14.0-dev.2 → 0.14.0-dev.21

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 (155) hide show
  1. package/dist/canonicalize-Bn3BM1Cn.mjs +46 -0
  2. package/dist/canonicalize-Bn3BM1Cn.mjs.map +1 -0
  3. package/dist/canonicalize-CAbXtVtB.d.mts +37 -0
  4. package/dist/canonicalize-CAbXtVtB.d.mts.map +1 -0
  5. package/dist/codecs.mjs.map +1 -1
  6. package/dist/contract-free.d.mts +8 -3
  7. package/dist/contract-free.d.mts.map +1 -1
  8. package/dist/contract-free.mjs +3 -3
  9. package/dist/control.d.mts +1 -1
  10. package/dist/control.mjs +6 -6
  11. package/dist/data-transform-BOWpliq8.mjs.map +1 -1
  12. package/dist/{data-transform-DDgWdB5o.d.mts → data-transform-qKy1U5V9.d.mts} +2 -2
  13. package/dist/{data-transform-DDgWdB5o.d.mts.map → data-transform-qKy1U5V9.d.mts.map} +1 -1
  14. package/dist/data-transform.d.mts +1 -1
  15. package/dist/{ddl-QDyOSeLc.mjs → ddl-BJPdPsT3.mjs} +62 -5
  16. package/dist/ddl-BJPdPsT3.mjs.map +1 -0
  17. package/dist/ddl.d.mts +3 -2
  18. package/dist/ddl.mjs +2 -2
  19. package/dist/{default-normalizer-DyyCHQWs.mjs → default-normalizer-CzV8-QSu.mjs} +58 -2
  20. package/dist/default-normalizer-CzV8-QSu.mjs.map +1 -0
  21. package/dist/default-normalizer.d.mts +1 -1
  22. package/dist/default-normalizer.d.mts.map +1 -1
  23. package/dist/default-normalizer.mjs +1 -1
  24. package/dist/descriptor-meta-CKpjK2Nv.mjs +17 -0
  25. package/dist/descriptor-meta-CKpjK2Nv.mjs.map +1 -0
  26. package/dist/descriptor-meta-runtime-DiVla56r.mjs +228 -0
  27. package/dist/descriptor-meta-runtime-DiVla56r.mjs.map +1 -0
  28. package/dist/{issue-planner-DL6g3CmE.mjs → issue-planner-YwXUKGZ1.mjs} +11 -9
  29. package/dist/issue-planner-YwXUKGZ1.mjs.map +1 -0
  30. package/dist/issue-planner.d.mts +3 -3
  31. package/dist/issue-planner.d.mts.map +1 -1
  32. package/dist/issue-planner.mjs +1 -1
  33. package/dist/migration.d.mts +3 -3
  34. package/dist/migration.mjs +2 -2
  35. package/dist/nodes-CSkPX3rI.d.mts +120 -0
  36. package/dist/nodes-CSkPX3rI.d.mts.map +1 -0
  37. package/dist/{nodes-Bbhs2rwj.mjs → nodes-Dh__NS58.mjs} +56 -2
  38. package/dist/nodes-Dh__NS58.mjs.map +1 -0
  39. package/dist/{op-factory-call-DmQEc3XV.d.mts → op-factory-call-CQ4P40QC.d.mts} +44 -13
  40. package/dist/op-factory-call-CQ4P40QC.d.mts.map +1 -0
  41. package/dist/{op-factory-call-D_p5vxwt.mjs → op-factory-call-DcY4BHNh.mjs} +199 -28
  42. package/dist/op-factory-call-DcY4BHNh.mjs.map +1 -0
  43. package/dist/op-factory-call.d.mts +1 -1
  44. package/dist/op-factory-call.mjs +1 -1
  45. package/dist/pack.d.mts +74 -1
  46. package/dist/pack.d.mts.map +1 -1
  47. package/dist/pack.mjs +1 -1
  48. package/dist/{planner-Bs_baQax.mjs → planner-_ZHpIQ1-.mjs} +115 -23
  49. package/dist/planner-_ZHpIQ1-.mjs.map +1 -0
  50. package/dist/{planner-ddl-builders-B2wOwLqI.mjs → planner-ddl-builders-B8Nn6nHN.mjs} +12 -27
  51. package/dist/planner-ddl-builders-B8Nn6nHN.mjs.map +1 -0
  52. package/dist/planner-ddl-builders.d.mts +2 -4
  53. package/dist/planner-ddl-builders.d.mts.map +1 -1
  54. package/dist/planner-ddl-builders.mjs +2 -2
  55. package/dist/{planner-produced-postgres-migration-QqHa2C2l.d.mts → planner-produced-postgres-migration-CNTSCMl7.d.mts} +3 -3
  56. package/dist/{planner-produced-postgres-migration-QqHa2C2l.d.mts.map → planner-produced-postgres-migration-CNTSCMl7.d.mts.map} +1 -1
  57. package/dist/{planner-produced-postgres-migration-Cji5vxUf.mjs → planner-produced-postgres-migration-Dv5ZAvHt.mjs} +2 -2
  58. package/dist/{planner-produced-postgres-migration-Cji5vxUf.mjs.map → planner-produced-postgres-migration-Dv5ZAvHt.mjs.map} +1 -1
  59. package/dist/planner-produced-postgres-migration.d.mts +1 -1
  60. package/dist/planner-produced-postgres-migration.mjs +1 -1
  61. package/dist/{planner-sql-checks-jqUUGyQR.mjs → planner-sql-checks-C7nLjabn.mjs} +2 -2
  62. package/dist/{planner-sql-checks-jqUUGyQR.mjs.map → planner-sql-checks-C7nLjabn.mjs.map} +1 -1
  63. package/dist/planner-sql-checks.mjs +1 -1
  64. package/dist/{planner-target-details-CIY6tLeo.d.mts → planner-target-details-HkP4RrRO.d.mts} +2 -2
  65. package/dist/planner-target-details-HkP4RrRO.d.mts.map +1 -0
  66. package/dist/planner-target-details.d.mts +1 -1
  67. package/dist/planner.d.mts +35 -3
  68. package/dist/planner.d.mts.map +1 -1
  69. package/dist/planner.mjs +2 -2
  70. package/dist/{postgres-contract-serializer-k3TAcPMY.mjs → postgres-contract-serializer-DJDGaS0q.mjs} +30 -21
  71. package/dist/postgres-contract-serializer-DJDGaS0q.mjs.map +1 -0
  72. package/dist/{postgres-migration-B5jKrXv3.mjs → postgres-migration-Bj0olfl3.mjs} +2 -2
  73. package/dist/{postgres-migration-B5jKrXv3.mjs.map → postgres-migration-Bj0olfl3.mjs.map} +1 -1
  74. package/dist/{postgres-migration-Y4YBJqkS.d.mts → postgres-migration-DJNJHcdP.d.mts} +4 -4
  75. package/dist/{postgres-migration-Y4YBJqkS.d.mts.map → postgres-migration-DJNJHcdP.d.mts.map} +1 -1
  76. package/dist/postgres-rls-policy-D_z1osEq.d.mts +60 -0
  77. package/dist/postgres-rls-policy-D_z1osEq.d.mts.map +1 -0
  78. package/dist/postgres-role-_CCuyPCQ.d.mts +33 -0
  79. package/dist/postgres-role-_CCuyPCQ.d.mts.map +1 -0
  80. package/dist/postgres-schema-Bn4tsMuo.d.mts +157 -0
  81. package/dist/postgres-schema-Bn4tsMuo.d.mts.map +1 -0
  82. package/dist/{postgres-schema-COGZ1ark.mjs → postgres-schema-DfuJq-q_.mjs} +141 -5
  83. package/dist/postgres-schema-DfuJq-q_.mjs.map +1 -0
  84. package/dist/postgres-schema-ir-BF0X8jit.mjs +66 -0
  85. package/dist/postgres-schema-ir-BF0X8jit.mjs.map +1 -0
  86. package/dist/postgres-schema-ir-BQ3RpZMz.d.mts +60 -0
  87. package/dist/postgres-schema-ir-BQ3RpZMz.d.mts.map +1 -0
  88. package/dist/postgres-schema-ir-annotations-CWHQ-H9Y.mjs +14 -0
  89. package/dist/postgres-schema-ir-annotations-CWHQ-H9Y.mjs.map +1 -0
  90. package/dist/render-ops.d.mts +1 -1
  91. package/dist/rls-canonicalize.d.mts +2 -0
  92. package/dist/rls-canonicalize.mjs +2 -0
  93. package/dist/runtime.d.mts +5 -5
  94. package/dist/runtime.d.mts.map +1 -1
  95. package/dist/runtime.mjs +2 -2
  96. package/dist/schema-ir-annotations.d.mts +8 -0
  97. package/dist/schema-ir-annotations.d.mts.map +1 -0
  98. package/dist/schema-ir-annotations.mjs +2 -0
  99. package/dist/types.d.mts +5 -140
  100. package/dist/types.mjs +3 -2
  101. package/package.json +21 -17
  102. package/src/contract-free/checks.ts +73 -0
  103. package/src/contract-free/ddl.ts +45 -1
  104. package/src/core/authoring.ts +115 -1
  105. package/src/core/codecs.ts +1 -1
  106. package/src/core/ddl/nodes.ts +94 -2
  107. package/src/core/default-normalizer.ts +60 -2
  108. package/src/core/descriptor-meta.ts +4 -0
  109. package/src/core/entity-kinds.ts +16 -0
  110. package/src/core/migrations/bound-schema.ts +10 -0
  111. package/src/core/migrations/control-policy.ts +3 -0
  112. package/src/core/migrations/issue-planner.ts +8 -0
  113. package/src/core/migrations/op-factory-call.ts +106 -21
  114. package/src/core/migrations/operations/columns.ts +24 -12
  115. package/src/core/migrations/operations/data-transform.ts +6 -0
  116. package/src/core/migrations/operations/rls.ts +106 -0
  117. package/src/core/migrations/operations/shared.ts +0 -30
  118. package/src/core/migrations/planner-ddl-builders.ts +15 -56
  119. package/src/core/migrations/planner-recipes.ts +31 -13
  120. package/src/core/migrations/planner-strategies.ts +3 -11
  121. package/src/core/migrations/planner-target-details.ts +3 -1
  122. package/src/core/migrations/planner.ts +99 -1
  123. package/src/core/migrations/verify-postgres-namespaces.ts +12 -15
  124. package/src/core/migrations/verify-postgres-rls-policies.ts +62 -0
  125. package/src/core/postgres-contract-serializer.ts +48 -33
  126. package/src/core/postgres-rls-policy.ts +103 -0
  127. package/src/core/postgres-role.ts +53 -0
  128. package/src/core/postgres-schema-ir-annotations.ts +22 -0
  129. package/src/core/postgres-schema-ir.ts +101 -0
  130. package/src/core/postgres-schema.ts +33 -13
  131. package/src/core/postgres-validators.ts +20 -0
  132. package/src/core/rls/canonicalize.ts +48 -0
  133. package/src/exports/contract-free.ts +7 -1
  134. package/src/exports/ddl.ts +4 -0
  135. package/src/exports/planner-ddl-builders.ts +0 -2
  136. package/src/exports/planner.ts +1 -0
  137. package/src/exports/rls-canonicalize.ts +6 -0
  138. package/src/exports/schema-ir-annotations.ts +1 -0
  139. package/src/exports/types.ts +12 -0
  140. package/dist/ddl-QDyOSeLc.mjs.map +0 -1
  141. package/dist/default-normalizer-DyyCHQWs.mjs.map +0 -1
  142. package/dist/descriptor-meta-CpGygXpI.mjs +0 -140
  143. package/dist/descriptor-meta-CpGygXpI.mjs.map +0 -1
  144. package/dist/issue-planner-DL6g3CmE.mjs.map +0 -1
  145. package/dist/nodes-Bbhs2rwj.mjs.map +0 -1
  146. package/dist/nodes-pLeLgdis.d.mts +0 -67
  147. package/dist/nodes-pLeLgdis.d.mts.map +0 -1
  148. package/dist/op-factory-call-D_p5vxwt.mjs.map +0 -1
  149. package/dist/op-factory-call-DmQEc3XV.d.mts.map +0 -1
  150. package/dist/planner-Bs_baQax.mjs.map +0 -1
  151. package/dist/planner-ddl-builders-B2wOwLqI.mjs.map +0 -1
  152. package/dist/planner-target-details-CIY6tLeo.d.mts.map +0 -1
  153. package/dist/postgres-contract-serializer-k3TAcPMY.mjs.map +0 -1
  154. package/dist/postgres-schema-COGZ1ark.mjs.map +0 -1
  155. package/dist/types.d.mts.map +0 -1
@@ -25,6 +25,10 @@ import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
25
25
  import { blindCast } from '@prisma-next/utils/casts';
26
26
  import { parsePostgresDefault } from '../default-normalizer';
27
27
  import { normalizeSchemaNativeType } from '../native-type-normalizer';
28
+ import { assertPostgresRlsPolicy } from '../postgres-rls-policy';
29
+ import { isPostgresSchema } from '../postgres-schema';
30
+ import { isPostgresSchemaIR } from '../postgres-schema-ir';
31
+ import { resolveDdlSchemaForNamespaceStorage } from '../postgres-schema-ir-annotations';
28
32
  import {
29
33
  formatPostgresControlPolicySubjectLabel,
30
34
  resolvePostgresCallControlPolicySubject,
@@ -33,9 +37,15 @@ import {
33
37
  } from './control-policy';
34
38
  import { planIssues } from './issue-planner';
35
39
  import type { PostgresOpFactoryCall } from './op-factory-call';
40
+ import {
41
+ CreatePostgresRlsPolicyCall,
42
+ DropPostgresRlsPolicyCall,
43
+ EnableRowLevelSecurityCall,
44
+ } from './op-factory-call';
36
45
  import { TypeScriptRenderablePostgresMigration } from './planner-produced-postgres-migration';
37
46
  import { postgresPlannerStrategies } from './planner-strategies';
38
47
  import { verifyPostgresNamespacePresence } from './verify-postgres-namespaces';
48
+ import { diffPostgresRlsPolicies } from './verify-postgres-rls-policies';
39
49
 
40
50
  type PlannerFrameworkComponents = SqlMigrationPlannerPlanOptions extends {
41
51
  readonly frameworkComponents: infer T;
@@ -194,6 +204,44 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr
194
204
  return plannerFailure(result.failure);
195
205
  }
196
206
 
207
+ // Translate RLS diff issues to DDL calls and run through control-policy
208
+ // partition. This runs AFTER the structural planIssues pass so the RLS
209
+ // calls can refer to tables that may have been created in this plan.
210
+ //
211
+ // Fail loud when the contract declares RLS policies but the schema is
212
+ // contract-derived (migration plan path). Without a live-introspected
213
+ // PostgresSchemaIR there is no baseline to reconcile against, so
214
+ // silently emitting no RLS DDL would be wrong. The user must run
215
+ // `db update` / `db init` against a live database to emit RLS.
216
+ if (!isPostgresSchemaIR(options.schema)) {
217
+ const policyNames: string[] = [];
218
+ for (const ns of Object.values(options.contract.storage.namespaces)) {
219
+ if (isPostgresSchema(ns)) {
220
+ for (const [policyName, policy] of Object.entries(ns.policy)) {
221
+ policyNames.push(`${policy.tableName}.${policyName}`);
222
+ }
223
+ }
224
+ }
225
+ if (policyNames.length > 0) {
226
+ return plannerFailure([
227
+ {
228
+ kind: 'unsupportedOperation',
229
+ summary: `migration plan does not yet emit RLS policies (the offline contract→schema derivation cannot carry them). Author RLS via 'db update' / 'db init' against a live database. Tracked for a follow-up slice (extension migration participation seam). Affected policies: ${policyNames.join(', ')}`,
230
+ },
231
+ ]);
232
+ }
233
+ }
234
+ const rlsCalls = this.planRlsDiff(options);
235
+ const rlsPartition = partitionCallsByControlPolicy({
236
+ calls: rlsCalls,
237
+ contract: options.contract,
238
+ resolveControlPolicySubject: (call) =>
239
+ resolvePostgresCallControlPolicySubject(call, options.contract),
240
+ resolveFactoryName: (call) => call.factoryName,
241
+ formatSubjectLabel: (factoryName, subject) =>
242
+ formatPostgresControlPolicySubjectLabel(factoryName, subject, options.contract),
243
+ });
244
+
197
245
  // Inline `onFieldEvent`-emitted ops after structural DDL. The fixed
198
246
  // ordering is `structural → added → dropped → altered`, with
199
247
  // within-group sorting by `(tableName, fieldName)` so re-emits are
@@ -219,9 +267,10 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr
219
267
  formatSubjectLabel: (factoryName, subject) =>
220
268
  formatPostgresControlPolicySubjectLabel(factoryName, subject, options.contract),
221
269
  });
222
- const calls = [...result.value.calls, ...fieldEventPartition.kept];
270
+ const calls = [...result.value.calls, ...rlsPartition.kept, ...fieldEventPartition.kept];
223
271
  const warnings: SqlPlannerConflict[] = [
224
272
  ...issuePartition.warnings,
273
+ ...rlsPartition.warnings,
225
274
  ...fieldEventPartition.warnings,
226
275
  ];
227
276
 
@@ -240,6 +289,54 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr
240
289
  });
241
290
  }
242
291
 
292
+ private planRlsDiff(options: PlannerOptionsWithComponents): readonly PostgresOpFactoryCall[] {
293
+ if (!isPostgresSchemaIR(options.schema)) {
294
+ // Non-PostgresSchemaIR (migration plan path) reaches here only when the
295
+ // contract declares NO policies — `planSql` already returned a failure
296
+ // for the has-policies case. This [] is the genuine no-RLS case.
297
+ return [];
298
+ }
299
+ const diffIssues = diffPostgresRlsPolicies({
300
+ contract: options.contract,
301
+ schema: options.schema,
302
+ });
303
+
304
+ const allowsDestructive = options.policy.allowedOperationClasses.includes('destructive');
305
+ const calls: PostgresOpFactoryCall[] = [];
306
+ const seenEnableTables = new Set<string>();
307
+
308
+ for (const issue of diffIssues) {
309
+ const { namespaceId, entityName: policyName } = issue.coordinate;
310
+ const schemaForTable = resolveDdlSchemaForNamespaceStorage(
311
+ options.contract.storage,
312
+ namespaceId,
313
+ options.schema,
314
+ );
315
+
316
+ // 'mismatch' is unreachable for content-addressed policies: the wire name
317
+ // encodes the body hash, so two policies sharing a coordinate (same name)
318
+ // are always equal and isEqualTo never returns false.
319
+ if (issue.outcome === 'missing') {
320
+ assertPostgresRlsPolicy(issue.expected);
321
+ const tableKey = `${schemaForTable}.${issue.expected.tableName}`;
322
+ if (!seenEnableTables.has(tableKey)) {
323
+ seenEnableTables.add(tableKey);
324
+ calls.push(new EnableRowLevelSecurityCall(schemaForTable, issue.expected.tableName));
325
+ }
326
+ calls.push(
327
+ new CreatePostgresRlsPolicyCall(schemaForTable, issue.expected.tableName, issue.expected),
328
+ );
329
+ } else if (issue.outcome === 'extra' && allowsDestructive) {
330
+ assertPostgresRlsPolicy(issue.actual);
331
+ calls.push(
332
+ new DropPostgresRlsPolicyCall(schemaForTable, issue.actual.tableName, policyName),
333
+ );
334
+ }
335
+ }
336
+
337
+ return calls;
338
+ }
339
+
243
340
  private ensureAdditivePolicy(policy: MigrationOperationPolicy) {
244
341
  if (!policy.allowedOperationClasses.includes('additive')) {
245
342
  return plannerFailure([
@@ -274,6 +371,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr
274
371
  // rather than in the family verifier. Stitch it in here so a single
275
372
  // `SchemaIssue[]` flows through `planIssues` and the planner emits
276
373
  // CREATE SCHEMA in the dep bucket before any CreateTableCall.
374
+ // RLS policy drift is handled separately via diffPostgresRlsPolicies → planRlsDiff.
277
375
  const namespaceIssues = verifyPostgresNamespacePresence({
278
376
  contract: options.contract,
279
377
  schema: options.schema,
@@ -4,6 +4,7 @@ import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
4
4
  import type { SqlStorage } from '@prisma-next/sql-contract/types';
5
5
  import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types';
6
6
  import { isPostgresSchema } from '../postgres-schema';
7
+ import { isPostgresSchemaIR } from '../postgres-schema-ir';
7
8
 
8
9
  /**
9
10
  * Resolves the live-database schema name for a given namespace
@@ -23,25 +24,21 @@ function resolveDdlSchemaName(storage: SqlStorage, namespaceId: string): string
23
24
  }
24
25
 
25
26
  /**
26
- * Reads the introspected list of schema names from the Postgres-flavoured
27
- * annotations slot on the schema IR. Defaults to the always-present
28
- * `public` schema when introspection did not populate the slot — a fresh
29
- * Postgres database always carries `public` (unless an operator dropped
30
- * it manually), so any verifier path that runs without an enriched
31
- * introspection still suppresses the redundant `CREATE SCHEMA "public"`.
27
+ * Reads the introspected list of schema names from a `PostgresSchemaIR`.
28
+ * Defaults to the always-present `public` schema when the schema IR is not a
29
+ * `PostgresSchemaIR` a fresh Postgres database always carries `public`
30
+ * (unless an operator dropped it manually), so any verifier path that runs
31
+ * without an enriched introspection still suppresses the redundant
32
+ * `CREATE SCHEMA "public"`.
32
33
  *
33
34
  * Production introspection (`PostgresControlAdapter.introspect`) is the
34
- * authoritative source: it queries `pg_namespace` and writes every
35
- * non-system schema into `annotations.pg.existingSchemas`. Tests that
36
- * want to assert against a richer initial state pass the slot
37
- * explicitly via the schema IR.
35
+ * authoritative source: it queries `pg_namespace` and sets `existingSchemas`
36
+ * on the returned `PostgresSchemaIR`. Tests that want to assert against a
37
+ * richer initial state construct a `PostgresSchemaIR` explicitly.
38
38
  */
39
39
  function existingSchemasFromSchema(schema: SqlSchemaIR): readonly string[] {
40
- const annotations = (schema as { annotations?: { pg?: { existingSchemas?: unknown } } })
41
- .annotations;
42
- const slot = annotations?.pg?.existingSchemas;
43
- if (Array.isArray(slot)) {
44
- return slot.filter((s): s is string => typeof s === 'string');
40
+ if (isPostgresSchemaIR(schema)) {
41
+ return schema.existingSchemas;
45
42
  }
46
43
  return ['public'];
47
44
  }
@@ -0,0 +1,62 @@
1
+ import type { Contract } from '@prisma-next/contract/types';
2
+ import type { SchemaDiffIssue } from '@prisma-next/framework-components/control';
3
+ import { diffNodes } from '@prisma-next/framework-components/control';
4
+ import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
5
+ import type { SqlStorage } from '@prisma-next/sql-contract/types';
6
+ import { isPostgresSchema } from '../postgres-schema';
7
+ import type { PostgresSchemaIR } from '../postgres-schema-ir';
8
+
9
+ // Postgres binds the late-bound (`__unbound__`) namespace to the `public`
10
+ // schema, so an unbound contract owns `public` in the live database. Both the
11
+ // contract slot ids and the introspected policy coordinates can carry either
12
+ // form depending on how the schema was lowered, so normalize both sides to the
13
+ // same id before comparing ownership.
14
+ const POSTGRES_DEFAULT_NAMESPACE_ID = 'public';
15
+
16
+ function resolveNamespaceId(namespaceId: string): string {
17
+ return namespaceId === UNBOUND_NAMESPACE_ID ? POSTGRES_DEFAULT_NAMESPACE_ID : namespaceId;
18
+ }
19
+
20
+ /**
21
+ * Computes RLS policy drift between the contract and the live DB schema using
22
+ * the generic {@link diffNodes} differ. Returns `SchemaDiffIssue[]` keyed by
23
+ * the full `EntityCoordinate` (plane + namespaceId + entityKind + entityName).
24
+ *
25
+ * Both sides supply `PostgresRlsPolicy` nodes that implement `DiffableNode`
26
+ * with a concrete namespace coordinate — the contract carries explicit
27
+ * `namespaceId` set during lowering (e.g. `'public'`); the introspected schema
28
+ * carries the resolved DDL schema name. The differ matches purely on coordinate
29
+ * identity; no namespace interpretation happens here.
30
+ *
31
+ * The diff is scoped to the namespaces the verified contract owns. The live
32
+ * schema introspection returns every policy across all DB schemas, but a policy
33
+ * in a namespace this contract does not own belongs to another contract space
34
+ * (or is external) and must not be reported as extra. Without this scope a space
35
+ * that owns only `auth`/`storage` (e.g. the supabase extension space) would flag
36
+ * the application space's `public.*` policies as extra during verify.
37
+ *
38
+ * Ownership is the set of namespaces the contract declares (its
39
+ * `storage.namespaces` slot keys), with the late-bound `__unbound__` slot
40
+ * resolved to the Postgres default `public` so a single-namespace contract still
41
+ * owns — and diffs — its `public` policies.
42
+ */
43
+ export function diffPostgresRlsPolicies(input: {
44
+ readonly contract: Contract<SqlStorage>;
45
+ readonly schema: PostgresSchemaIR;
46
+ }): readonly SchemaDiffIssue[] {
47
+ const { contract, schema } = input;
48
+
49
+ const ownedNamespaceIds = new Set(
50
+ Object.keys(contract.storage.namespaces).map(resolveNamespaceId),
51
+ );
52
+
53
+ const expected = Object.values(contract.storage.namespaces).flatMap((ns) =>
54
+ isPostgresSchema(ns) ? Object.values(ns.policy) : [],
55
+ );
56
+
57
+ const actual = schema.rlsPolicies.filter((policy) =>
58
+ ownedNamespaceIds.has(resolveNamespaceId(policy.namespaceId)),
59
+ );
60
+
61
+ return diffNodes(expected, actual);
62
+ }
@@ -10,15 +10,15 @@ import {
10
10
  isAuthoringEntityTypeDescriptor,
11
11
  } from '@prisma-next/framework-components/authoring';
12
12
  import {
13
+ type AnyEntityKindDescriptor,
13
14
  type Namespace,
14
- NamespaceBase,
15
15
  UNBOUND_NAMESPACE_ID,
16
16
  } from '@prisma-next/framework-components/ir';
17
- import type { SqlNamespaceTablesInput, SqlStorage } from '@prisma-next/sql-contract/types';
17
+ import type { SqlNamespaceInput, SqlStorage } from '@prisma-next/sql-contract/types';
18
18
  import { blindCast } from '@prisma-next/utils/casts';
19
- import type { JsonObject } from '@prisma-next/utils/json';
19
+ import type { JsonObject, JsonValue } from '@prisma-next/utils/json';
20
20
  import { postgresAuthoringEntityTypes } from './authoring';
21
- import { postgresTargetDescriptorMeta } from './descriptor-meta';
21
+ import { policyEntityKind, roleEntityKind } from './entity-kinds';
22
22
  import { isPostgresSchema, PostgresSchema } from './postgres-schema';
23
23
 
24
24
  const POSTGRES_AUTHORING_CTX: AuthoringEntityContext = {
@@ -32,7 +32,8 @@ function isAuthoringEntityTypeFactoryOutput(
32
32
  return (
33
33
  typeof output === 'object' &&
34
34
  output !== null &&
35
- typeof (output as AuthoringEntityTypeFactoryOutput).factory === 'function'
35
+ 'factory' in output &&
36
+ typeof output.factory === 'function'
36
37
  );
37
38
  }
38
39
 
@@ -65,34 +66,29 @@ function collectStorageTypesHydrators(
65
66
  }
66
67
 
67
68
  export class PostgresContractSerializer extends SqlContractSerializerBase<Contract<SqlStorage>> {
68
- constructor() {
69
+ constructor(extraPackEntityKinds: readonly AnyEntityKindDescriptor[] = []) {
69
70
  const storageTypesHydrators = collectStorageTypesHydrators(postgresAuthoringEntityTypes);
70
- super(storageTypesHydrators);
71
- }
72
-
73
- protected override get defaultNamespaceId(): string {
74
- return postgresTargetDescriptorMeta.defaultNamespaceId;
71
+ super(storageTypesHydrators, [policyEntityKind, roleEntityKind, ...extraPackEntityKinds]);
75
72
  }
76
73
 
77
74
  protected override hydrateSqlNamespaceEntry(
78
75
  nsId: string,
79
- raw: Namespace | Record<string, unknown>,
80
- ): Namespace | SqlNamespaceTablesInput {
81
- if (raw instanceof NamespaceBase) {
82
- return raw;
83
- }
76
+ raw: Record<string, unknown>,
77
+ ): Namespace | SqlNamespaceInput {
84
78
  const hydrated = blindCast<
85
- SqlNamespaceTablesInput,
86
- 'super.hydrateSqlNamespaceEntry returns SqlNamespaceTablesInput when raw is not a NamespaceBase'
79
+ SqlNamespaceInput,
80
+ 'raw is always plain JSON, so super.hydrateSqlNamespaceEntry returns SqlNamespaceInput'
87
81
  >(super.hydrateSqlNamespaceEntry(nsId, raw));
88
82
  const { id, entries } = hydrated;
89
83
 
90
- const valueSetSlot = entries['valueSet'];
91
- const hasValueSets = valueSetSlot !== undefined && Object.keys(valueSetSlot).length > 0;
92
- const emptyTables = Object.keys(entries['table'] ?? {}).length === 0;
93
- if (id === UNBOUND_NAMESPACE_ID && emptyTables && !hasValueSets) {
84
+ const allSlotsEmpty = Object.values(entries).every(
85
+ (slot) => slot === undefined || Object.keys(slot).length === 0,
86
+ );
87
+ if (id === UNBOUND_NAMESPACE_ID && allSlotsEmpty) {
94
88
  return PostgresSchema.unbound;
95
89
  }
90
+ const valueSetSlot = entries['valueSet'];
91
+ const hasValueSets = valueSetSlot !== undefined && Object.keys(valueSetSlot).length > 0;
96
92
  return new PostgresSchema({
97
93
  id,
98
94
  entries: {
@@ -118,7 +114,7 @@ export class PostgresContractSerializer extends SqlContractSerializerBase<Contra
118
114
  table: Object.fromEntries(
119
115
  Object.entries(ns.entries.table ?? {}).map(([tableName, table]) => [
120
116
  tableName,
121
- this.serializeJsonValue(table) as JsonObject,
117
+ this.serializeJsonObject(table),
122
118
  ]),
123
119
  ),
124
120
  },
@@ -132,42 +128,61 @@ export class PostgresContractSerializer extends SqlContractSerializerBase<Contra
132
128
  if (storage.types !== undefined) {
133
129
  const typesOut: Record<string, JsonObject> = {};
134
130
  for (const [name, entry] of Object.entries(storage.types)) {
135
- typesOut[name] = this.serializeJsonValue(entry) as JsonObject;
131
+ typesOut[name] = this.serializeJsonObject(entry);
136
132
  }
137
133
  storageOut['types'] = typesOut;
138
134
  }
139
- return {
135
+ return blindCast<
136
+ JsonObject,
137
+ 'contract minus storage plus a JSON-shaped storageOut is a JsonObject'
138
+ >({
140
139
  ...rest,
141
140
  storage: storageOut,
142
- } as unknown as JsonObject;
141
+ });
143
142
  }
144
143
 
145
144
  private serializePostgresNamespace(ns: PostgresSchema, isUnboundSlot: boolean): JsonObject {
146
145
  const tablesOut: Record<string, JsonObject> = {};
147
146
  for (const [tableName, table] of Object.entries(ns.table)) {
148
- tablesOut[tableName] = this.serializeJsonValue(table) as JsonObject;
147
+ tablesOut[tableName] = this.serializeJsonObject(table);
149
148
  }
150
149
  const valueSetEntries = ns.valueSet;
151
150
  const valueSetOut: Record<string, JsonObject> = {};
152
151
  if (valueSetEntries !== undefined) {
153
152
  for (const [valueSetName, valueSet] of Object.entries(valueSetEntries)) {
154
- valueSetOut[valueSetName] = blindCast<
155
- JsonObject,
156
- 'serializeJsonValue round-trips the value-set node through JSON, yielding a JsonObject'
157
- >(this.serializeJsonValue(valueSet));
153
+ valueSetOut[valueSetName] = this.serializeJsonObject(valueSet);
158
154
  }
159
155
  }
156
+ const roleOut: Record<string, JsonObject> = {};
157
+ for (const [roleName, role] of Object.entries(ns.role)) {
158
+ roleOut[roleName] = this.serializeJsonObject(role);
159
+ }
160
+ const policyOut: Record<string, JsonObject> = {};
161
+ for (const [policyName, policy] of Object.entries(ns.policy)) {
162
+ policyOut[policyName] = this.serializeJsonObject(policy);
163
+ }
160
164
  return {
161
165
  id: ns.id,
162
166
  kind: isUnboundSlot ? 'postgres-unbound-schema' : 'postgres-schema',
163
167
  entries: {
164
168
  table: tablesOut,
165
169
  ...(Object.keys(valueSetOut).length > 0 ? { valueSet: valueSetOut } : {}),
170
+ ...(Object.keys(roleOut).length > 0 ? { role: roleOut } : {}),
171
+ ...(Object.keys(policyOut).length > 0 ? { policy: policyOut } : {}),
166
172
  },
167
173
  };
168
174
  }
169
175
 
170
- private serializeJsonValue(value: unknown): unknown {
171
- return JSON.parse(JSON.stringify(value)) as unknown;
176
+ private serializeJsonObject(value: unknown): JsonObject {
177
+ return blindCast<
178
+ JsonObject,
179
+ 'serializeJsonValue round-trips an IR node through JSON, yielding a JsonObject'
180
+ >(this.serializeJsonValue(value));
181
+ }
182
+
183
+ private serializeJsonValue(value: unknown): JsonValue {
184
+ return blindCast<JsonValue, 'JSON.parse(JSON.stringify(x)) yields a JsonValue'>(
185
+ JSON.parse(JSON.stringify(value)),
186
+ );
172
187
  }
173
188
  }
@@ -0,0 +1,103 @@
1
+ import type { DiffableNode } from '@prisma-next/framework-components/control';
2
+ import type { EntityCoordinate } from '@prisma-next/framework-components/ir';
3
+ import { freezeNode } from '@prisma-next/framework-components/ir';
4
+ import { SqlNode } from '@prisma-next/sql-contract/types';
5
+
6
+ export type RlsPolicyOperation = 'select' | 'insert' | 'update' | 'delete' | 'all';
7
+
8
+ export interface PostgresRlsPolicyInput {
9
+ /** Full wire name: `<prefix>_<8hex>`. Stored as-is; hashing is not this class's job. */
10
+ readonly name: string;
11
+ /** User-supplied prefix (the part before the `_<8hex>` suffix). */
12
+ readonly prefix: string;
13
+ /** Name of the table this policy attaches to, by name within the same schema. */
14
+ readonly tableName: string;
15
+ /** Namespace coordinate (schema name). Policies are schema-scoped. */
16
+ readonly namespaceId: string;
17
+ readonly operation: RlsPolicyOperation;
18
+ /** Sorted role names rendered in `TO <roles>`. Plain strings in this slice. */
19
+ readonly roles: readonly string[];
20
+ /** USING predicate SQL string, if present. */
21
+ readonly using?: string;
22
+ /** WITH CHECK predicate SQL string, if present. */
23
+ readonly withCheck?: string;
24
+ /** `true` = `AS PERMISSIVE`, `false` = `AS RESTRICTIVE`. */
25
+ readonly permissive: boolean;
26
+ }
27
+
28
+ /**
29
+ * Postgres IR class for a row-level security policy (`CREATE POLICY … ON …`).
30
+ *
31
+ * Target-only concept — no SQL-family abstract. Extends `SqlNode` directly.
32
+ * Frozen at construction via `freezeNode(this)`. The `kind: 'policy'`
33
+ * discriminant is enumerable (overrides SqlNode's non-enumerable `'sql'`) so it
34
+ * survives JSON serialization and enables dispatch. The literal matches the
35
+ * entries key (one-string rule: node.kind === entries key === entity kind).
36
+ */
37
+ export class PostgresRlsPolicy extends SqlNode implements DiffableNode {
38
+ override readonly kind = 'policy' as const;
39
+ readonly name: string;
40
+ readonly prefix: string;
41
+ readonly tableName: string;
42
+ readonly namespaceId: string;
43
+ readonly operation: RlsPolicyOperation;
44
+ readonly roles: readonly string[];
45
+ declare readonly using?: string;
46
+ declare readonly withCheck?: string;
47
+ readonly permissive: boolean;
48
+
49
+ constructor(input: PostgresRlsPolicyInput) {
50
+ super();
51
+ this.name = input.name;
52
+ this.prefix = input.prefix;
53
+ this.tableName = input.tableName;
54
+ this.namespaceId = input.namespaceId;
55
+ this.operation = input.operation;
56
+ this.roles = Object.freeze([...input.roles]);
57
+ if (input.using !== undefined) this.using = input.using;
58
+ if (input.withCheck !== undefined) this.withCheck = input.withCheck;
59
+ this.permissive = input.permissive;
60
+ freezeNode(this);
61
+ }
62
+
63
+ identity(): EntityCoordinate {
64
+ return {
65
+ plane: 'storage',
66
+ namespaceId: this.namespaceId,
67
+ entityKind: 'policy',
68
+ entityName: this.name,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Equality by wire name only. The wire name is `<prefix>_<sha256(body)[0..8]>`,
74
+ * so name-equality IS body-equality — two policies with different bodies
75
+ * have different hashes and therefore different names. We deliberately
76
+ * skip comparing bodies directly because Postgres reprints predicate
77
+ * expressions (e.g. strips outer parentheses), so a byte-compare against
78
+ * the authored body would produce false mismatches on a clean re-verify.
79
+ */
80
+ isEqualTo(other: DiffableNode): boolean {
81
+ if (!isPostgresRlsPolicy(other)) {
82
+ throw new Error(
83
+ `PostgresRlsPolicy.isEqualTo: expected a PostgresRlsPolicy, got ${other.identity().entityKind}`,
84
+ );
85
+ }
86
+ return this.name === other.name;
87
+ }
88
+ }
89
+
90
+ export function isPostgresRlsPolicy(node: DiffableNode | undefined): node is PostgresRlsPolicy {
91
+ return node !== undefined && 'kind' in node && node.kind === 'policy';
92
+ }
93
+
94
+ export function assertPostgresRlsPolicy(
95
+ node: DiffableNode | undefined,
96
+ ): asserts node is PostgresRlsPolicy {
97
+ if (!isPostgresRlsPolicy(node)) {
98
+ const kind = node !== undefined && 'kind' in node ? String(node.kind) : typeof node;
99
+ throw new Error(
100
+ `planRlsDiff: expected a PostgresRlsPolicy on the policy-diff path but got ${kind}`,
101
+ );
102
+ }
103
+ }
@@ -0,0 +1,53 @@
1
+ import type { DiffableNode } from '@prisma-next/framework-components/control';
2
+ import type { EntityCoordinate } from '@prisma-next/framework-components/ir';
3
+ import { freezeNode } from '@prisma-next/framework-components/ir';
4
+ import { SqlNode } from '@prisma-next/sql-contract/types';
5
+
6
+ export interface PostgresRoleInput {
7
+ readonly name: string;
8
+ /**
9
+ * Namespace coordinate. Roles are cluster-scoped; pass `UNBOUND_NAMESPACE_ID`
10
+ * from `@prisma-next/framework-components/ir`.
11
+ */
12
+ readonly namespaceId: string;
13
+ }
14
+
15
+ /**
16
+ * Postgres IR class for a database role (`CREATE ROLE …`).
17
+ *
18
+ * Roles are cluster-scoped, so their namespace coordinate is always
19
+ * `UNBOUND_NAMESPACE_ID`. Target-only concept — no SQL-family abstract.
20
+ * Extends `SqlNode` directly, frozen at construction via `freezeNode(this)`.
21
+ * The `kind: 'role'` discriminant is enumerable so it survives JSON.
22
+ * Matches the entries key (one-string rule).
23
+ */
24
+ export class PostgresRole extends SqlNode implements DiffableNode {
25
+ override readonly kind = 'role' as const;
26
+ readonly name: string;
27
+ readonly namespaceId: string;
28
+
29
+ constructor(input: PostgresRoleInput) {
30
+ super();
31
+ this.name = input.name;
32
+ this.namespaceId = input.namespaceId;
33
+ freezeNode(this);
34
+ }
35
+
36
+ identity(): EntityCoordinate {
37
+ return {
38
+ plane: 'storage',
39
+ namespaceId: this.namespaceId,
40
+ entityKind: 'role',
41
+ entityName: this.name,
42
+ };
43
+ }
44
+
45
+ isEqualTo(other: DiffableNode): boolean {
46
+ if (!(other instanceof PostgresRole)) {
47
+ throw new Error(
48
+ `PostgresRole.isEqualTo: expected a PostgresRole, got ${other.identity().entityKind}`,
49
+ );
50
+ }
51
+ return this.name === other.name;
52
+ }
53
+ }
@@ -0,0 +1,22 @@
1
+ import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
2
+ import type { SqlStorage } from '@prisma-next/sql-contract/types';
3
+ import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types';
4
+ import { isPostgresSchema } from './postgres-schema';
5
+ import { isPostgresSchemaIR } from './postgres-schema-ir';
6
+
7
+ export function resolveDdlSchemaForNamespaceStorage(
8
+ storage: SqlStorage,
9
+ namespaceId: string,
10
+ schemaIr?: SqlSchemaIR,
11
+ ): string {
12
+ if (namespaceId === UNBOUND_NAMESPACE_ID) {
13
+ return (
14
+ (schemaIr && isPostgresSchemaIR(schemaIr) ? schemaIr.pgSchemaName : undefined) ?? 'public'
15
+ );
16
+ }
17
+ const namespace = storage.namespaces[namespaceId];
18
+ if (namespace && isPostgresSchema(namespace)) {
19
+ return namespace.ddlSchemaName(storage);
20
+ }
21
+ return namespaceId;
22
+ }