Skip to content

Commit 9e7cffb

Browse files
refactor(target-postgres): policy content equality is node-owned; policy inputs are required-but-undefined
PostgresPolicySchemaNode owns the single policy content-equality relation (contentEquals); the exact-mode isEqualTo delegates to it and the planner's phase-2 rename pairing calls it instead of a planner-local twin — the same consolidation the index pass received. PostgresRlsPolicyInput and PostgresPolicySchemaNodeInput document the prefix-presence naming-mode discriminator and type legitimately-absent values as required-but-undefined; construction sites state absence explicitly instead of conditionally omitting keys. Signed-off-by: willbot <w.a.madden+machine@gmail.com> Signed-off-by: Will Madden <madden@prisma.io>
1 parent a4b7558 commit 9e7cffb

28 files changed

Lines changed: 144 additions & 80 deletions

packages/3-targets/3-targets/postgres/src/core/authoring.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,8 @@ function buildRlsPolicyEntity(input: {
172172
namespaceId: input.namespaceId,
173173
operation: input.operation,
174174
roles: input.roles,
175-
...ifDefined('using', input.using),
176-
...ifDefined('withCheck', input.withCheck),
175+
using: input.using,
176+
withCheck: input.withCheck,
177177
permissive: true,
178178
});
179179
}
@@ -237,17 +237,18 @@ function lowerRlsPolicyFromBlock(
237237
});
238238
return undefined;
239239
}
240-
// Every policy has a SQL body, so every `@@map` policy gets the D9
240+
// Every policy has a SQL body, so every `@@map` policy gets the
241241
// exact-name body-comparison warning (batched once per build).
242242
ctx.warnings?.push(exactNameBodyWarningEntry({ subject: 'policy', exactName }));
243243
return new PostgresRlsPolicy({
244244
name: exactName,
245+
prefix: undefined,
245246
tableName,
246247
namespaceId: block.namespaceId,
247248
operation,
248249
roles,
249-
...ifDefined('using', using),
250-
...ifDefined('withCheck', withCheck),
250+
using,
251+
withCheck,
251252
permissive: true,
252253
});
253254
}

packages/3-targets/3-targets/postgres/src/core/migrations/contract-to-postgres-database-schema-node.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,13 @@ function columnDependsOn(
6060
function toPolicyNode(policy: PostgresRlsPolicy, namespaceId: string): PostgresPolicySchemaNode {
6161
return new PostgresPolicySchemaNode({
6262
name: policy.name,
63-
...ifDefined('prefix', policy.prefix),
63+
prefix: policy.prefix,
6464
tableName: policy.tableName,
6565
namespaceId,
6666
operation: policy.operation,
6767
roles: [...policy.roles],
68-
...ifDefined('using', policy.using),
69-
...ifDefined('withCheck', policy.withCheck),
68+
using: policy.using,
69+
withCheck: policy.withCheck,
7070
permissive: policy.permissive,
7171
dependsOn: [tableDependsOn(namespaceId, policy.tableName), ...policy.roles.map(roleDependsOn)],
7272
});

packages/3-targets/3-targets/postgres/src/core/migrations/op-factory-call.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1721,7 +1721,10 @@ export class CreatePostgresRlsPolicyCall extends PostgresOpFactoryCallNode {
17211721

17221722
renderTypeScript(): string {
17231723
const p = this.policy;
1724-
const input: PostgresRlsPolicyInput = {
1724+
const input = blindCast<
1725+
PostgresRlsPolicyInput,
1726+
'ifDefined keeps absent keys out of the rendered TypeScript source'
1727+
>({
17251728
name: p.name,
17261729
...ifDefined('prefix', p.prefix),
17271730
tableName: p.tableName,
@@ -1731,7 +1734,7 @@ export class CreatePostgresRlsPolicyCall extends PostgresOpFactoryCallNode {
17311734
...ifDefined('using', p.using),
17321735
...ifDefined('withCheck', p.withCheck),
17331736
permissive: p.permissive,
1734-
};
1737+
});
17351738
return `this.createRlsPolicy({ schema: ${jsonToTsSource(this.schemaName)}, table: ${jsonToTsSource(this.tableName)}, policy: ${jsonToTsSource(input)} })`;
17361739
}
17371740

packages/3-targets/3-targets/postgres/src/core/migrations/planner.ts

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr
512512
* pairs a `not-found` and a `not-expected` policy on the SAME table whose
513513
* wire-name content hashes match but prefixes differ (prefix-only rename);
514514
* phase 2 pairs remaining managed-missing policies against remaining
515-
* extras of any name shape by verbatim content (`policyContentEqual` —
515+
* extras of any name shape by verbatim content (the node-owned `contentEquals` —
516516
* exact→managed adoption). Multi-candidate groups pair deterministically
517517
* by sorted name; leftovers proceed as create/drop; an exact-named
518518
* missing policy never content-pairs.
@@ -630,7 +630,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr
630630
// Phase 2 — content pairing (exact→managed convergence, D7 policy
631631
// half): a remaining managed-missing policy pairs with a remaining
632632
// extra of ANY name shape when the content matches verbatim
633-
// (`policyContentEqual` — not the normalized hash tuple). This is how
633+
// (the node-owned `contentEquals` — not the normalized hash tuple). This is how
634634
// replacing `@@map` with the plain head converges as one
635635
// `ALTER POLICY … RENAME`. Deterministic like the index pass: missing
636636
// already iterates sorted by name, candidates consume sorted by name.
@@ -645,7 +645,7 @@ export class PostgresMigrationPlanner implements MigrationPlanner<'sql', 'postgr
645645
!renamedExtras.has(extraFinding) &&
646646
extraFinding.schemaForTable === missingFinding.schemaForTable &&
647647
extraFinding.node.tableName === missingFinding.node.tableName &&
648-
policyContentEqual(missingFinding.node, extraFinding.node),
648+
missingFinding.node.contentEquals(extraFinding.node),
649649
);
650650
if (candidate === undefined) continue;
651651
renamedExtras.add(candidate);
@@ -811,29 +811,13 @@ function relationalNamespaceNode(
811811
function policyNodeToContractPolicy(node: PostgresPolicySchemaNode): PostgresRlsPolicy {
812812
return new PostgresRlsPolicy({
813813
name: node.name,
814-
...ifDefined('prefix', node.prefix),
814+
prefix: node.prefix,
815815
tableName: node.tableName,
816816
namespaceId: node.namespaceId,
817817
operation: node.operation,
818818
roles: [...node.roles],
819-
...ifDefined('using', node.using),
820-
...ifDefined('withCheck', node.withCheck),
819+
using: node.using,
820+
withCheck: node.withCheck,
821821
permissive: node.permissive,
822822
});
823823
}
824-
825-
/**
826-
* Content equality for the policy rename phase 2: `operation` and
827-
* `permissive` strict, `roles` compared sorted, `using`/`withCheck` VERBATIM
828-
* byte-for-byte with absent ≡ empty — the same relation the exact-mode
829-
* `isEqualTo` uses, deliberately NOT the normalized wire-hash tuple.
830-
*/
831-
function policyContentEqual(a: PostgresPolicySchemaNode, b: PostgresPolicySchemaNode): boolean {
832-
return (
833-
a.operation === b.operation &&
834-
a.permissive === b.permissive &&
835-
isArrayEqual([...a.roles].sort(), [...b.roles].sort()) &&
836-
(a.using ?? '') === (b.using ?? '') &&
837-
(a.withCheck ?? '') === (b.withCheck ?? '')
838-
);
839-
}

packages/3-targets/3-targets/postgres/src/core/postgres-rls-policy.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,28 +5,36 @@ import { formatWireName, parseWireName } from '@prisma-next/sql-schema-ir/naming
55

66
export type RlsPolicyOperation = 'select' | 'insert' | 'update' | 'delete' | 'all';
77

8+
/**
9+
* Every field is a required key. Values that may legitimately be absent
10+
* (an exact-named policy's prefix, a missing predicate) are typed
11+
* `| undefined` instead of optional, so each construction site states the
12+
* absence explicitly rather than omitting the key silently.
13+
*/
814
export interface PostgresRlsPolicyInput {
915
/**
10-
* Full physical name. Managed: `<prefix>_<8hex>`. Exact: the verbatim
11-
* adopted name. Stored as-is; hashing is not this class's job.
16+
* Full physical name. Stored as-is; hashing is not this class's job.
1217
*/
1318
readonly name: string;
1419
/**
15-
* Wire-name prefix (the part before the `_<8hex>` suffix). Present ⇔
16-
* managed; absent ⇔ exact-named.
20+
* The managed-mode name prefix — its PRESENCE is the naming-mode
21+
* discriminator (there is no stored enum). Present ⇔ managed: the
22+
* toolchain owns the physical name and `name === formatWireName(prefix,
23+
* <8hex content hash>)`. Absent ⇔ exact: `name` is an adopted verbatim
24+
* physical name whose identity the author owns entirely.
1725
*/
18-
readonly prefix?: string;
26+
readonly prefix: string | undefined;
1927
/** Name of the table this policy attaches to, by name within the same schema. */
2028
readonly tableName: string;
2129
/** Namespace coordinate (schema name). Policies are schema-scoped. */
2230
readonly namespaceId: string;
2331
readonly operation: RlsPolicyOperation;
24-
/** Sorted role names rendered in `TO <roles>`. Plain strings in this slice. */
32+
/** Sorted role names rendered in `TO <roles>`. Plain strings for now. */
2533
readonly roles: readonly string[];
2634
/** USING predicate SQL string, if present. */
27-
readonly using?: string;
35+
readonly using: string | undefined;
2836
/** WITH CHECK predicate SQL string, if present. */
29-
readonly withCheck?: string;
37+
readonly withCheck: string | undefined;
3038
/** `true` = `AS PERMISSIVE`, `false` = `AS RESTRICTIVE`. */
3139
readonly permissive: boolean;
3240
}

packages/3-targets/3-targets/postgres/src/core/schema-ir/postgres-policy-schema-node.ts

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,23 @@ import { InternalError } from '@prisma-next/utils/internal-error';
88
import type { RlsPolicyOperation } from '../postgres-rls-policy';
99
import { PostgresSchemaNodeKind } from './schema-node-kinds';
1010

11+
/**
12+
* Every field is a required key. Values that may legitimately be absent
13+
* (an exact-named policy's prefix, a missing predicate) are typed
14+
* `| undefined` instead of optional, so each construction site states the
15+
* absence explicitly rather than omitting the key silently.
16+
*/
1117
export interface PostgresPolicySchemaNodeInput {
12-
/** Full physical name. Managed: `<prefix>_<8hex>`. Exact: verbatim. */
18+
/** Full physical name — the node's identity. */
1319
readonly name: string;
1420
/**
15-
* Wire-name prefix (the part before the `_<8hex>` suffix). Present ⇔
16-
* managed; absent ⇔ exact-named.
21+
* The managed-mode name prefix — its PRESENCE is the naming-mode
22+
* discriminator (there is no stored enum). Present ⇔ managed: the
23+
* toolchain owns the physical name and `name === formatWireName(prefix,
24+
* <8hex content hash>)`. Absent ⇔ exact: `name` is an adopted verbatim
25+
* physical name whose identity the author owns entirely.
1726
*/
18-
readonly prefix?: string;
27+
readonly prefix: string | undefined;
1928
/** Name of the table this policy attaches to, by name within the same schema. */
2029
readonly tableName: string;
2130
/** Namespace coordinate (schema name). */
@@ -24,9 +33,9 @@ export interface PostgresPolicySchemaNodeInput {
2433
/** Sorted role names rendered in `TO <roles>`. */
2534
readonly roles: readonly string[];
2635
/** USING predicate SQL string, if present. */
27-
readonly using?: string;
36+
readonly using: string | undefined;
2837
/** WITH CHECK predicate SQL string, if present. */
29-
readonly withCheck?: string;
38+
readonly withCheck: string | undefined;
3039
/** `true` = `AS PERMISSIVE`, `false` = `AS RESTRICTIVE`. */
3140
readonly permissive: boolean;
3241
/**
@@ -35,7 +44,7 @@ export interface PostgresPolicySchemaNodeInput {
3544
* the derivation, which holds the parent (database/namespace) context.
3645
* Never compared by `isEqualTo`.
3746
*/
38-
readonly dependsOn?: readonly SchemaNodeRef[];
47+
readonly dependsOn: readonly SchemaNodeRef[] | undefined;
3948
}
4049

4150
/**
@@ -109,12 +118,24 @@ export class PostgresPolicySchemaNode extends SqlSchemaIRNode implements Diffabl
109118
if (this.prefix !== undefined) {
110119
return this.id === node.id;
111120
}
121+
return this.contentEquals(node);
122+
}
123+
124+
/**
125+
* The single policy content-equality relation — the exact-mode
126+
* {@link isEqualTo} and the planner's rename content-pairing both call
127+
* this rather than growing a parallel relation: `operation` and
128+
* `permissive` strict, `roles` compared sorted, `using`/`withCheck`
129+
* VERBATIM byte-for-byte with absent ≡ empty — deliberately NOT the
130+
* normalized wire-hash tuple.
131+
*/
132+
contentEquals(other: PostgresPolicySchemaNode): boolean {
112133
return (
113-
this.operation === node.operation &&
114-
this.permissive === node.permissive &&
115-
isArrayEqual([...this.roles].sort(), [...node.roles].sort()) &&
116-
(this.using ?? '') === (node.using ?? '') &&
117-
(this.withCheck ?? '') === (node.withCheck ?? '')
134+
this.operation === other.operation &&
135+
this.permissive === other.permissive &&
136+
isArrayEqual([...this.roles].sort(), [...other.roles].sort()) &&
137+
(this.using ?? '') === (other.using ?? '') &&
138+
(this.withCheck ?? '') === (other.withCheck ?? '')
118139
);
119140
}
120141

packages/3-targets/3-targets/postgres/test/migrations/contract-to-postgres-database-schema-node.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ function makePolicy(name: string): PostgresRlsPolicy {
2929
roles: ['authenticated'],
3030
using: '(auth.uid() = user_id)',
3131
permissive: true,
32+
withCheck: undefined,
3233
});
3334
}
3435

@@ -270,6 +271,8 @@ describe('contractToPostgresDatabaseSchemaNode', () => {
270271
operation: 'select',
271272
roles: ['authenticated'],
272273
permissive: true,
274+
using: undefined,
275+
withCheck: undefined,
273276
});
274277
expect(() =>
275278
contractToPostgresDatabaseSchemaNode(makeContract({ policies: [orphan] }), projectionOptions),

packages/3-targets/3-targets/postgres/test/migrations/op-factory-call.lowering.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,7 @@ function makePolicy(): PostgresRlsPolicy {
921921
roles: ['authenticated'],
922922
using: 'author_id = current_user_id()',
923923
permissive: true,
924+
withCheck: undefined,
924925
});
925926
}
926927

packages/3-targets/3-targets/postgres/test/migrations/render-typescript.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ describe('renderCallsToTypeScript (postgres) — facade import surface', () => {
163163
roles: ['authenticated'],
164164
using: '(owner_id = auth.uid())',
165165
permissive: true,
166+
withCheck: undefined,
166167
});
167168

168169
const rawOp = {

packages/3-targets/3-targets/postgres/test/migrations/rls-enablement-planner.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ function contractPolicy(name: string): PostgresRlsPolicy {
4848
roles: ['authenticated'],
4949
using: '(auth.uid() = user_id)',
5050
permissive: true,
51+
withCheck: undefined,
5152
});
5253
}
5354

@@ -127,12 +128,12 @@ function actualSchema(options: {
127128
(policy) =>
128129
new PostgresPolicySchemaNode({
129130
name: policy.name,
130-
...(policy.prefix !== undefined ? { prefix: policy.prefix } : {}),
131+
prefix: policy.prefix,
131132
tableName: policy.tableName,
132133
namespaceId: 'public',
133134
operation: policy.operation,
134135
roles: [...policy.roles],
135-
...(policy.using !== undefined ? { using: policy.using } : {}),
136+
using: policy.using,
136137
permissive: policy.permissive,
137138
}),
138139
),

0 commit comments

Comments
 (0)