@prisma-next/sql-schema-ir 0.16.0-dev.3 → 0.16.0-dev.30

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.
@@ -1,25 +1,77 @@
1
1
  import type { DiffableNode, SchemaNodeRef } from '@prisma-next/framework-components/control';
2
2
  import { freezeNode } from '@prisma-next/framework-components/ir';
3
+ import { isArrayEqual } from '@prisma-next/utils/array-equal';
3
4
  import { blindCast } from '@prisma-next/utils/casts';
5
+ import { InternalError } from '@prisma-next/utils/internal-error';
6
+ import { normalizeIndexOptionValue } from '../naming';
4
7
  import { RelationalSchemaNodeKind } from './schema-node-kinds';
5
8
  import type { SqlAnnotations } from './sql-column-ir';
6
9
  import { assertNode, defineNonEnumerable, SqlSchemaIRNode } from './sql-schema-ir-node';
7
10
 
8
- export interface SqlIndexIRInput {
9
- readonly columns: readonly string[];
11
+ /**
12
+ * An index's element structure — exactly one of a column tuple or an opaque
13
+ * expression, unrepresentable-otherwise at the type level. No discriminant
14
+ * is stored (the node keeps flat readonly accessors); the constructor's xor
15
+ * throw stays as the backstop for introspection rows and JSON-derived
16
+ * inputs that bypass this union.
17
+ */
18
+ export type SqlIndexElements =
19
+ | {
20
+ /** Column-tuple elements. */
21
+ readonly columns: readonly string[];
22
+ readonly expression?: never;
23
+ }
24
+ | {
25
+ readonly columns?: never;
26
+ /**
27
+ * Opaque SQL: the entire element list between the parens of CREATE
28
+ * INDEX — one string, never parsed.
29
+ */
30
+ readonly expression: string;
31
+ };
32
+
33
+ /**
34
+ * Every non-element field is a required key. Values that may legitimately
35
+ * be absent (an exact-named index's prefix, a default-method type) are
36
+ * typed `| undefined` instead of optional, so each construction site
37
+ * states the absence explicitly rather than omitting the key silently.
38
+ * Undefined values still produce an instance without the property.
39
+ */
40
+ export type SqlIndexIRInput = SqlIndexElements & {
41
+ /** Full physical name — the node's identity. */
42
+ readonly name: string;
43
+ /**
44
+ * The managed-mode name prefix — its PRESENCE is the naming-mode
45
+ * discriminator (there is no stored enum). Present ⇔ managed: the
46
+ * toolchain owns the physical name and `name === formatWireName(prefix,
47
+ * <8hex content hash>)`. Absent ⇔ exact: `name` is an adopted verbatim
48
+ * physical name whose identity the author owns entirely.
49
+ */
50
+ readonly prefix: string | undefined;
51
+ /** Opaque SQL: partial-index predicate (WHERE body, without the keyword). */
52
+ readonly where: string | undefined;
10
53
  readonly unique: boolean;
11
- readonly name?: string;
12
- readonly type?: string;
13
- readonly options?: Record<string, unknown>;
14
- readonly annotations?: SqlAnnotations;
54
+ readonly type: string | undefined;
55
+ readonly options: Record<string, unknown> | undefined;
56
+ readonly annotations: SqlAnnotations | undefined;
15
57
  /**
16
58
  * The index's own column nodes, as root-anchored chains. The derivation
17
59
  * stamps them so an index is dropped before the columns it is built on
18
- * (Postgres auto-drops the index when a covered column goes). Never
60
+ * (Postgres auto-drops the index when a covered column goes). An expression
61
+ * index stamps chains to every column of its table — a deterministic
62
+ * over-approximation, since the opaque expression is never parsed. Never
19
63
  * compared by `isEqualTo`.
20
64
  */
21
- readonly dependsOn?: readonly SchemaNodeRef[];
22
- }
65
+ readonly dependsOn: readonly SchemaNodeRef[] | undefined;
66
+ /**
67
+ * Whether the index is partial (has a row predicate). Required: every
68
+ * producer must assert partiality explicitly, because a partial unique
69
+ * index does not guarantee at-most-one row per key and so cannot back a
70
+ * 1:1 relation — "unknown" must not silently default to "total". Never
71
+ * compared by `isEqualTo` and never serialized.
72
+ */
73
+ readonly partial: boolean;
74
+ };
23
75
 
24
76
  /**
25
77
  * Schema IR node for a secondary index as observed by introspection.
@@ -29,50 +81,67 @@ export interface SqlIndexIRInput {
29
81
  * verifier needs to distinguish them when comparing to the Contract.
30
82
  *
31
83
  * Implements `DiffableNode` so an index is directly a table's diff-tree
32
- * child. Indexes are frequently unnamed, so `id` is derived from the column
33
- * tuplethe same tuple that makes two indexes the same index, so it
34
- * doubles as the pairing key. `isEqualTo` is symmetric structural equality
35
- * on the remaining attributes: `unique`, `type`, and `options`. A unique
36
- * index and a non-unique index on the same columns are different objects
37
- * and are not equal — there is no "stronger satisfies weaker".
84
+ * child. Indexes are name-identified: every index contract-derived or
85
+ * introspectedcarries its full physical name, and `id` is that name.
86
+ * Names are catalog-unique per schema, so two indexes legitimately sharing
87
+ * one column tuple (a unique index beside a redundant plain index) are two
88
+ * distinct siblings, and expression indexes need no column tuple at all.
38
89
  *
39
- * Deliberately column-tuple-only (not `unique`): a contract `@@index`
40
- * (non-unique) must still pair against a live unique index of the same
41
- * columns so the differ can report the mismatch as an incompatible index
42
- * change rather than a spurious missing+extra pair see
43
- * `planner.unique-index-structural.test.ts`. The corollary is that two
44
- * indexes legitimately coexisting on one table with the *same* column tuple
45
- * (e.g. a unique index and a redundant plain index Postgres has no problem
46
- * hosting side by side) collide on this id; the postgres control adapter's
47
- * introspection keeps only one such index per table+column-tuple rather
48
- * than handing the differ two same-tree siblings it cannot represent.
90
+ * `isEqualTo` is selected by the receiver (the differ always calls
91
+ * `expected.isEqualTo(actual)`) and delegates to {@link contentEquals}
92
+ * the single node-owned content relation: both modes compare `unique`
93
+ * strict, `type` and option values through the named normalization seams,
94
+ * and `columns` ordered-strict when both sides carry them; an exact-named
95
+ * receiver (`prefix === undefined`) additionally byte-compares
96
+ * `expression`/`where` (both sides are reprints in the supported flow
97
+ * normalizing would only mask real drift); a managed receiver never
98
+ * compares bodies (the wire-name hash already commits to them).
99
+ *
100
+ * `expression`, `where`, and `unique` are genuine SQL-family attributes —
101
+ * functional and partial indexes are standard SQL that any SQL target may
102
+ * introspect, so the family node must represent them; a target declining
103
+ * to author them is a capability decision, not target-specificity.
49
104
  */
50
105
  export class SqlIndexIR extends SqlSchemaIRNode implements DiffableNode {
51
106
  override readonly nodeKind = RelationalSchemaNodeKind.index;
52
107
 
53
- readonly columns: readonly string[];
108
+ readonly name: string;
54
109
  readonly unique: boolean;
55
- declare readonly name?: string;
110
+ declare readonly prefix?: string;
111
+ declare readonly columns?: readonly string[];
112
+ declare readonly expression?: string;
113
+ declare readonly where?: string;
56
114
  declare readonly type?: string;
57
115
  declare readonly options?: Record<string, unknown>;
58
116
  declare readonly annotations?: SqlAnnotations;
59
117
  /** See {@link SqlIndexIRInput.dependsOn}. Non-enumerable so it stays out of JSON and structural equality, matching `SqlColumnIR.codecRef`. */
60
118
  declare readonly dependsOn?: readonly SchemaNodeRef[];
119
+ /** See {@link SqlIndexIRInput.partial}. Non-enumerable so it stays out of JSON and structural equality, matching `dependsOn`. */
120
+ declare readonly partial: boolean;
61
121
 
62
122
  constructor(input: SqlIndexIRInput) {
63
123
  super();
64
- this.columns = input.columns;
124
+ if ((input.columns === undefined) === (input.expression === undefined)) {
125
+ throw new InternalError(
126
+ `SqlIndexIR "${input.name}": exactly one of columns or expression must be set.`,
127
+ );
128
+ }
129
+ this.name = input.name;
65
130
  this.unique = input.unique;
66
- if (input.name !== undefined) this.name = input.name;
131
+ if (input.prefix !== undefined) this.prefix = input.prefix;
132
+ if (input.columns !== undefined) this.columns = input.columns;
133
+ if (input.expression !== undefined) this.expression = input.expression;
134
+ if (input.where !== undefined) this.where = input.where;
67
135
  if (input.type !== undefined) this.type = input.type;
68
136
  if (input.options !== undefined) this.options = input.options;
69
137
  if (input.annotations !== undefined) this.annotations = input.annotations;
70
138
  defineNonEnumerable(this, 'dependsOn', input.dependsOn);
139
+ defineNonEnumerable(this, 'partial', input.partial);
71
140
  freezeNode(this);
72
141
  }
73
142
 
74
143
  get id(): string {
75
- return `index:${this.columns.join(',')}`;
144
+ return `index:${this.name}`;
76
145
  }
77
146
 
78
147
  children(): readonly DiffableNode[] {
@@ -84,12 +153,11 @@ export class SqlIndexIR extends SqlSchemaIRNode implements DiffableNode {
84
153
  }
85
154
 
86
155
  /**
87
- * Symmetric structural equality: two paired index nodes are equal iff their
88
- * `unique` flag, `type`, and (loosely-compared) `options` all match. There
89
- * is no satisfaction a unique index does not equal a non-unique index.
90
- * `options` compares loosely (introspection stringifies reloptions); `type`
91
- * compares strictly after the introspection-side btree→undefined
92
- * normalization done at construction.
156
+ * Mode-selected structural equality see the class doc. Delegates to the
157
+ * single node-owned relation: `columns` compare ordered-strict when both
158
+ * sides carry them; an exact receiver (`prefix === undefined`)
159
+ * byte-compares `expression ?? ''` and `where ?? ''`; a managed receiver
160
+ * never compares bodies (the wire-name hash already commits to them).
93
161
  */
94
162
  isEqualTo(other: DiffableNode): boolean {
95
163
  const node = blindCast<
@@ -97,21 +165,80 @@ export class SqlIndexIR extends SqlSchemaIRNode implements DiffableNode {
97
165
  'every diff-tree node the differ pairs is a SqlSchemaIRNode'
98
166
  >(other);
99
167
  assertNode(node, 'SqlIndexIR', SqlIndexIR.is);
168
+ return this.contentEquals(node, {
169
+ columnPresence: 'when-both-defined',
170
+ bodies: this.prefix !== undefined ? 'ignored' : 'verbatim',
171
+ });
172
+ }
173
+
174
+ /**
175
+ * The single index content-equality relation — every comparer (the differ
176
+ * via {@link isEqualTo}, the planner's rename content-pairing) calls this
177
+ * with its mode-appropriate strictness rather than growing a parallel
178
+ * relation:
179
+ *
180
+ * - `columnPresence: 'when-both-defined'` (the differ's rule) compares
181
+ * the tuples ordered-strict only when both sides carry them — a paired
182
+ * node's identity already agreed, so a column node meeting an
183
+ * expression node skips the tuple.
184
+ * - `columnPresence: 'matching'` (the rename-pairing rule) additionally
185
+ * requires presence to agree: a column index never pairs an expression
186
+ * index.
187
+ * - `bodies: 'verbatim'` byte-compares `expression ?? ''` / `where ?? ''`
188
+ * (absent ≡ empty, no normalization — both sides are reprints in the
189
+ * supported flow); `bodies: 'ignored'` skips them (managed identity —
190
+ * the wire-name hash commits to the content).
191
+ *
192
+ * `unique` compares strictly; `type` and option VALUES compare through
193
+ * the named normalization seams below.
194
+ */
195
+ contentEquals(
196
+ other: SqlIndexIR,
197
+ strictness: {
198
+ readonly columnPresence: 'when-both-defined' | 'matching';
199
+ readonly bodies: 'verbatim' | 'ignored';
200
+ },
201
+ ): boolean {
202
+ const columnsEqual =
203
+ strictness.columnPresence === 'matching'
204
+ ? (this.columns === undefined) === (other.columns === undefined) &&
205
+ (this.columns === undefined || isArrayEqual(this.columns, other.columns ?? []))
206
+ : this.columns === undefined ||
207
+ other.columns === undefined ||
208
+ isArrayEqual(this.columns, other.columns);
209
+ const structurallyEqual =
210
+ this.unique === other.unique &&
211
+ normalizeIndexType(this.type) === normalizeIndexType(other.type) &&
212
+ indexOptionsEqual(this.options, other.options) &&
213
+ columnsEqual;
214
+ if (!structurallyEqual) return false;
215
+ if (strictness.bodies === 'ignored') return true;
100
216
  return (
101
- this.unique === node.unique &&
102
- this.type === node.type &&
103
- indexOptionsLooselyEqual(this.options, node.options)
217
+ (this.expression ?? '') === (other.expression ?? '') &&
218
+ (this.where ?? '') === (other.where ?? '')
104
219
  );
105
220
  }
106
221
  }
107
222
 
108
223
  /**
109
- * Option-bag equality ported from the relational walk: same key set, values
110
- * compared via `String()` coercion Postgres introspection returns
111
- * reloptions values as raw strings (`'70'`, `'false'`) while contract option
112
- * leaves are typed (number, boolean, string).
224
+ * Comparison-side normalization seam: the default access method (`btree` in
225
+ * every supported SQL target) compares as absent, so an authored
226
+ * `type: "btree"` and a default-method introspected index (whose type the
227
+ * adapter or constructor normalized away) are equal. Applied by
228
+ * {@link SqlIndexIR.contentEquals} only — the wire-name hash keeps the
229
+ * authored spelling.
230
+ */
231
+ function normalizeIndexType(type: string | undefined): string | undefined {
232
+ return type === 'btree' ? undefined : type;
233
+ }
234
+
235
+ /**
236
+ * Option-bag equality: same key set, values compared through
237
+ * {@link normalizeIndexOptionValue} — Postgres introspection returns
238
+ * reloptions values as catalog-reprint strings (`'70'`, `'on'`) while
239
+ * contract option leaves are typed (number, boolean, string).
113
240
  */
114
- function indexOptionsLooselyEqual(
241
+ function indexOptionsEqual(
115
242
  a: Record<string, unknown> | undefined,
116
243
  b: Record<string, unknown> | undefined,
117
244
  ): boolean {
@@ -123,7 +250,7 @@ function indexOptionsLooselyEqual(
123
250
  }
124
251
  if (aKeys.length === 0) return true;
125
252
  for (const key of aKeys) {
126
- if (String(a?.[key]) !== String(b?.[key])) {
253
+ if (normalizeIndexOptionValue(a?.[key]) !== normalizeIndexOptionValue(b?.[key])) {
127
254
  return false;
128
255
  }
129
256
  }
package/src/naming.ts ADDED
@@ -0,0 +1,135 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { structuredError } from '@prisma-next/utils/structured-error';
3
+
4
+ export function defaultIndexName(tableName: string, columns: readonly string[]): string {
5
+ return `${tableName}_${columns.join('_')}_idx`;
6
+ }
7
+
8
+ export interface WireName {
9
+ /** The user-supplied part before the `_<8hex>` suffix. */
10
+ readonly prefix: string;
11
+ /** The 8-lowercase-hex content-hash suffix. */
12
+ readonly hash: string;
13
+ }
14
+
15
+ const WIRE_NAME_PATTERN = /^(.+)_([0-9a-f]{8})$/;
16
+
17
+ /**
18
+ * Assembles a wire name from its user-supplied prefix and its 8-hex
19
+ * content-hash suffix. This module owns the `<prefix>_<hash>` format on both
20
+ * sides — construction here and parsing in {@link parseWireName} — so the two
21
+ * never drift.
22
+ */
23
+ export function formatWireName(prefix: string, hash: string): string {
24
+ return `${prefix}_${hash}`;
25
+ }
26
+
27
+ /**
28
+ * Splits a wire name (`<prefix>_<8hex>`) into its prefix and content-hash
29
+ * suffix. Returns `undefined` when the name does not follow the wire-name
30
+ * shape (e.g. an object created outside the toolchain) — callers treat such
31
+ * names as all-prefix. Consumed by introspection (prefix extraction) and by
32
+ * rename pairing (same hash, different prefix).
33
+ */
34
+ export function parseWireName(name: string): WireName | undefined {
35
+ const match = WIRE_NAME_PATTERN.exec(name);
36
+ const prefix = match?.[1];
37
+ const hash = match?.[2];
38
+ if (prefix === undefined || hash === undefined) return undefined;
39
+ return { prefix, hash };
40
+ }
41
+
42
+ /**
43
+ * Stabilizes an authored SQL body (index expression, partial-index predicate,
44
+ * RLS policy predicate) for hashing: trim, and collapse runs of internal
45
+ * whitespace to a single space.
46
+ *
47
+ * This is deliberately minimal. The content hash is the equivalence relation
48
+ * for a wire-named object, and the wire name (prefix + hash) is the only
49
+ * thing ever compared — the hash is never recomputed from an introspected
50
+ * body, so there is no need to match the database's reprinted form. Minimal
51
+ * normalization also protects the no-collision property: aggressive rewriting
52
+ * (lowercasing, paren-stripping, cast-alias folding) risks collapsing two
53
+ * distinct bodies onto one hash.
54
+ *
55
+ * The normalizer is a stability commitment: any change re-suffixes all wire names.
56
+ */
57
+ export function normalizeSqlBody(sql: string): string {
58
+ return sql.replace(/\s+/g, ' ').trim();
59
+ }
60
+
61
+ export interface IndexContentHashParts {
62
+ readonly expression?: string;
63
+ readonly where?: string;
64
+ readonly columns?: readonly string[];
65
+ readonly unique: boolean;
66
+ readonly type?: string;
67
+ readonly options?: Record<string, unknown>;
68
+ }
69
+
70
+ /**
71
+ * Returns the first 8 lowercase hex characters of the SHA-256 digest over the
72
+ * canonical content tuple for an index:
73
+ *
74
+ * [normalizeSqlBody(expression), normalizeSqlBody(where), columns, unique, type, sortedOptions]
75
+ *
76
+ * Columns hash in authored order — column order is semantic in an index.
77
+ * Option values are `String()`-coerced (matching the loose option equality
78
+ * used for diffing) so a hash computed from typed contract values agrees with
79
+ * one recomputed from introspected reloptions strings. The prefix, schema,
80
+ * and table are excluded (they are orthogonal to index equivalence).
81
+ *
82
+ * The tuple order and encoding are a stability commitment with the same
83
+ * status as the RLS tuple: any change re-suffixes every wire name.
84
+ */
85
+ /**
86
+ * Canonicalizes one index option VALUE to the `on`/`off` boolean spelling:
87
+ * JS booleans and the common catalog spellings (`pg_class.reloptions`
88
+ * stores whatever spelling the DDL used, so a live index may carry
89
+ * `'true'`/`'false'` or `'on'`/`'off'`) all map to one form; everything
90
+ * else via `String()` (fully specified for numbers, so no platform
91
+ * variance). Shared by the wire-name hash tuple, the node's option
92
+ * equality, and the DDL renderer, so an authored `{ fastupdate: true }`
93
+ * agrees with a live index created under any boolean spelling.
94
+ */
95
+ export function normalizeIndexOptionValue(value: unknown): string {
96
+ if (value === true || value === 'true' || value === 'on') return 'on';
97
+ if (value === false || value === 'false' || value === 'off') return 'off';
98
+ return String(value);
99
+ }
100
+
101
+ export function computeIndexContentHash(parts: IndexContentHashParts): string {
102
+ const sortedOptions = Object.entries(parts.options ?? {})
103
+ .map(([key, value]): readonly [string, string] => [key, normalizeIndexOptionValue(value)])
104
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
105
+
106
+ const tuple = JSON.stringify([
107
+ normalizeSqlBody(parts.expression ?? ''),
108
+ normalizeSqlBody(parts.where ?? ''),
109
+ parts.columns ?? [],
110
+ parts.unique,
111
+ parts.type ?? '',
112
+ sortedOptions,
113
+ ]);
114
+ return createHash('sha256').update(tuple).digest('hex').slice(0, 8);
115
+ }
116
+
117
+ /**
118
+ * Postgres identifiers cap at 63 characters and the wire name appends a
119
+ * 9-character `_<8hex>` suffix, so an authored prefix is bounded at 54.
120
+ */
121
+ export const WIRE_NAME_PREFIX_MAX_LENGTH = 54;
122
+
123
+ /**
124
+ * Rejects a wire-name prefix over {@link WIRE_NAME_PREFIX_MAX_LENGTH}.
125
+ * `subject` opens the error message (e.g. `defineContract: policy prefix`).
126
+ */
127
+ export function assertWireNamePrefixLength(prefix: string, subject: string): void {
128
+ if (prefix.length > WIRE_NAME_PREFIX_MAX_LENGTH) {
129
+ throw structuredError(
130
+ 'CONTRACT.WIRE_NAME_PREFIX_TOO_LONG',
131
+ `${subject} "${prefix}" exceeds the ${WIRE_NAME_PREFIX_MAX_LENGTH}-character maximum (Postgres identifiers cap at 63 characters and the wire name appends a 9-character hash suffix).`,
132
+ { meta: { prefix, maxLength: WIRE_NAME_PREFIX_MAX_LENGTH } },
133
+ );
134
+ }
135
+ }
package/src/types.ts CHANGED
@@ -33,7 +33,10 @@ export {
33
33
  type SqlForeignKeyIRInput,
34
34
  type SqlReferentialAction,
35
35
  } from './ir/sql-foreign-key-ir';
36
- export { SqlIndexIR, type SqlIndexIRInput } from './ir/sql-index-ir';
36
+ export {
37
+ SqlIndexIR,
38
+ type SqlIndexIRInput,
39
+ } from './ir/sql-index-ir';
37
40
  export { SqlSchemaIR, type SqlSchemaIRInput } from './ir/sql-schema-ir';
38
41
  export { assertNode, defineNonEnumerable, SqlSchemaIRNode } from './ir/sql-schema-ir-node';
39
42
  export { SqlTableIR, type SqlTableIRInput } from './ir/sql-table-ir';
@@ -1 +0,0 @@
1
- {"version":3,"file":"naming.mjs","names":[],"sources":["../../src/exports/naming.ts"],"sourcesContent":["export function defaultIndexName(tableName: string, columns: readonly string[]): string {\n return `${tableName}_${columns.join('_')}_idx`;\n}\n"],"mappings":";AAAA,SAAgB,iBAAiB,WAAmB,SAAoC;CACtF,OAAO,GAAG,UAAU,GAAG,QAAQ,KAAK,GAAG,EAAE;AAC3C"}