@owlmeans/postgres-resource 0.1.18-rc.42 → 0.1.18-rc.43

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 (154) hide show
  1. package/README.md +13 -13
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/postgres-resource/SKILL.md +1 -1
  4. package/build/consts.d.ts +9 -0
  5. package/build/consts.d.ts.map +1 -1
  6. package/build/consts.js +6 -0
  7. package/build/consts.js.map +1 -1
  8. package/build/declarations/types.d.ts +12 -0
  9. package/build/declarations/types.d.ts.map +1 -0
  10. package/build/declarations/types.js +2 -0
  11. package/build/declarations/types.js.map +1 -0
  12. package/build/declarations.d.ts +5 -1
  13. package/build/declarations.d.ts.map +1 -1
  14. package/build/declarations.js +25 -15
  15. package/build/declarations.js.map +1 -1
  16. package/build/errors.d.ts +0 -20
  17. package/build/errors.d.ts.map +1 -1
  18. package/build/errors.js +1 -111
  19. package/build/errors.js.map +1 -1
  20. package/build/index.d.ts +3 -0
  21. package/build/index.d.ts.map +1 -1
  22. package/build/index.js +1 -0
  23. package/build/index.js.map +1 -1
  24. package/build/pg-error/types.d.ts +24 -0
  25. package/build/pg-error/types.d.ts.map +1 -0
  26. package/build/pg-error/types.js +2 -0
  27. package/build/pg-error/types.js.map +1 -0
  28. package/build/pg-error.d.ts +4 -0
  29. package/build/pg-error.d.ts.map +1 -0
  30. package/build/pg-error.js +100 -0
  31. package/build/pg-error.js.map +1 -0
  32. package/build/resource.d.ts.map +1 -1
  33. package/build/resource.js +15 -11
  34. package/build/resource.js.map +1 -1
  35. package/build/types.d.ts.map +1 -1
  36. package/build/utils/consts.local.d.ts +2 -0
  37. package/build/utils/consts.local.d.ts.map +1 -1
  38. package/build/utils/consts.local.js +2 -0
  39. package/build/utils/consts.local.js.map +1 -1
  40. package/build/utils/criteria/types.d.ts +31 -0
  41. package/build/utils/criteria/types.d.ts.map +1 -0
  42. package/build/utils/criteria/types.js +2 -0
  43. package/build/utils/criteria/types.js.map +1 -0
  44. package/build/utils/criteria.d.ts +3 -27
  45. package/build/utils/criteria.d.ts.map +1 -1
  46. package/build/utils/criteria.js +86 -104
  47. package/build/utils/criteria.js.map +1 -1
  48. package/build/utils/diff/types.d.ts +40 -0
  49. package/build/utils/diff/types.d.ts.map +1 -0
  50. package/build/utils/diff/types.js +2 -0
  51. package/build/utils/diff/types.js.map +1 -0
  52. package/build/utils/diff.d.ts +5 -35
  53. package/build/utils/diff.d.ts.map +1 -1
  54. package/build/utils/diff.js +320 -344
  55. package/build/utils/diff.js.map +1 -1
  56. package/build/utils/index.d.ts +9 -0
  57. package/build/utils/index.d.ts.map +1 -1
  58. package/build/utils/index.js.map +1 -1
  59. package/build/utils/introspect/types.d.ts +9 -0
  60. package/build/utils/introspect/types.d.ts.map +1 -0
  61. package/build/utils/introspect/types.js +2 -0
  62. package/build/utils/introspect/types.js.map +1 -0
  63. package/build/utils/introspect.d.ts +3 -3
  64. package/build/utils/introspect.d.ts.map +1 -1
  65. package/build/utils/introspect.js +41 -38
  66. package/build/utils/introspect.js.map +1 -1
  67. package/build/utils/life-cycle/types.d.ts +37 -0
  68. package/build/utils/life-cycle/types.d.ts.map +1 -0
  69. package/build/utils/life-cycle/types.js +2 -0
  70. package/build/utils/life-cycle/types.js.map +1 -0
  71. package/build/utils/life-cycle.d.ts +3 -33
  72. package/build/utils/life-cycle.d.ts.map +1 -1
  73. package/build/utils/life-cycle.js +88 -108
  74. package/build/utils/life-cycle.js.map +1 -1
  75. package/build/utils/marshal/types.d.ts +39 -0
  76. package/build/utils/marshal/types.d.ts.map +1 -0
  77. package/build/utils/marshal/types.js +2 -0
  78. package/build/utils/marshal/types.js.map +1 -0
  79. package/build/utils/marshal.d.ts +4 -32
  80. package/build/utils/marshal.d.ts.map +1 -1
  81. package/build/utils/marshal.js +121 -144
  82. package/build/utils/marshal.js.map +1 -1
  83. package/build/utils/migrations.d.ts.map +1 -1
  84. package/build/utils/migrations.js +41 -36
  85. package/build/utils/migrations.js.map +1 -1
  86. package/build/utils/name/types.d.ts +43 -0
  87. package/build/utils/name/types.d.ts.map +1 -0
  88. package/build/utils/name/types.js +2 -0
  89. package/build/utils/name/types.js.map +1 -0
  90. package/build/utils/name.d.ts +6 -35
  91. package/build/utils/name.d.ts.map +1 -1
  92. package/build/utils/name.js +38 -57
  93. package/build/utils/name.js.map +1 -1
  94. package/build/utils/schema/types.d.ts +22 -0
  95. package/build/utils/schema/types.d.ts.map +1 -0
  96. package/build/utils/schema/types.js +2 -0
  97. package/build/utils/schema/types.js.map +1 -0
  98. package/build/utils/schema.d.ts +4 -24
  99. package/build/utils/schema.d.ts.map +1 -1
  100. package/build/utils/schema.js +339 -352
  101. package/build/utils/schema.js.map +1 -1
  102. package/build/utils/sql/types.d.ts +25 -0
  103. package/build/utils/sql/types.d.ts.map +1 -0
  104. package/build/utils/sql/types.js +2 -0
  105. package/build/utils/sql/types.js.map +1 -0
  106. package/build/utils/sql.d.ts +4 -18
  107. package/build/utils/sql.d.ts.map +1 -1
  108. package/build/utils/sql.js +0 -0
  109. package/build/utils/sql.js.map +1 -1
  110. package/build/utils/sync/types.d.ts +25 -0
  111. package/build/utils/sync/types.d.ts.map +1 -0
  112. package/build/utils/sync/types.js +2 -0
  113. package/build/utils/sync/types.js.map +1 -0
  114. package/build/utils/sync.d.ts +2 -19
  115. package/build/utils/sync.d.ts.map +1 -1
  116. package/build/utils/sync.js +64 -71
  117. package/build/utils/sync.js.map +1 -1
  118. package/package.json +7 -7
  119. package/src/consts.ts +7 -0
  120. package/src/declarations/types.ts +12 -0
  121. package/src/declarations.ts +29 -14
  122. package/src/errors.ts +1 -121
  123. package/src/index.ts +3 -0
  124. package/src/pg-error/types.ts +23 -0
  125. package/src/pg-error.ts +114 -0
  126. package/src/resource.ts +17 -13
  127. package/src/types.ts +1 -0
  128. package/src/utils/consts.local.ts +3 -0
  129. package/src/utils/criteria/types.ts +32 -0
  130. package/src/utils/criteria.ts +95 -110
  131. package/src/utils/diff/types.ts +41 -0
  132. package/src/utils/diff.ts +330 -348
  133. package/src/utils/index.ts +9 -0
  134. package/src/utils/introspect/types.ts +9 -0
  135. package/src/utils/introspect.ts +51 -45
  136. package/src/utils/life-cycle/types.ts +41 -0
  137. package/src/utils/life-cycle.ts +102 -121
  138. package/src/utils/marshal/types.ts +40 -0
  139. package/src/utils/marshal.ts +129 -147
  140. package/src/utils/migrations.ts +39 -33
  141. package/src/utils/name/types.ts +44 -0
  142. package/src/utils/name.ts +47 -60
  143. package/src/utils/schema/types.ts +26 -0
  144. package/src/utils/schema.ts +359 -366
  145. package/src/utils/sql/types.ts +25 -0
  146. package/src/utils/sql.ts +0 -0
  147. package/src/utils/sync/types.ts +25 -0
  148. package/src/utils/sync.ts +62 -68
  149. package/tests/criteria.spec.ts +4 -1
  150. package/tests/diff.spec.ts +4 -1
  151. package/tests/errors.spec.ts +4 -3
  152. package/tests/name.spec.ts +5 -3
  153. package/tests/schema.spec.ts +3 -1
  154. package/tests/sql.spec.ts +20 -22
package/src/utils/diff.ts CHANGED
@@ -6,414 +6,396 @@ import { PostgresPlaceholderError } from '../errors.js'
6
6
  import type {
7
7
  ColumnSpec, DdlPlan, DdlStatement, LiveTable, PgIndexSpec, PostgresResource, TableSpec
8
8
  } from '../types.js'
9
- import { qualify, quoteIdent } from './name.js'
9
+ import { pgNameHelper } from './name.js'
10
10
  import { CAST } from './consts.local.js'
11
+ import type { PgDiffHelper } from './diff/types.js'
11
12
 
12
- const literal = (value: string | number | boolean | null): string => {
13
- if (value === null) return 'NULL'
14
- if (typeof value === 'number') return `${value}`
15
- if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE'
13
+ export const createPgDiffHelper = (): PgDiffHelper => {
14
+ const { qualify, quoteIdent } = pgNameHelper
16
15
 
17
- return `'${value.replace(/'/g, "''")}'`
18
- }
16
+ const literal = (value: string | number | boolean | null): string => {
17
+ if (value === null) return 'NULL'
18
+ if (typeof value === 'number') return `${value}`
19
+ if (typeof value === 'boolean') return value ? 'TRUE' : 'FALSE'
19
20
 
20
- const defaultExpression = (column: ColumnSpec): string | undefined => {
21
- if (column.defaultRaw != null) return column.defaultRaw
22
- if (column.defaultLiteral !== undefined) return literal(column.defaultLiteral)
21
+ return `'${value.replace(/'/g, "''")}'`
22
+ }
23
23
 
24
- return undefined
25
- }
24
+ const defaultExpression = (column: ColumnSpec): string | undefined => {
25
+ if (column.defaultRaw != null) return column.defaultRaw
26
+ if (column.defaultLiteral !== undefined) return literal(column.defaultLiteral)
26
27
 
27
- /**
28
- * Postgres echoes defaults back with explicit casts — a declared `'active'` comes back as
29
- * `'active'::text`. Compare without them, so an unchanged default doesn't look like drift on
30
- * every boot.
31
- */
32
- const normalizeDefault = (expression: string | null | undefined): string | null => {
33
- if (expression == null) return null
28
+ return undefined
29
+ }
34
30
 
35
31
  /**
36
- * Casts anywhere (a `varchar` id's `gen_random_uuid()::text` is stored as
37
- * `(gen_random_uuid())::character varying`) and the parentheses Postgres adds around them.
32
+ * Postgres echoes defaults back with explicit casts — a declared `'active'` comes back as
33
+ * `'active'::text`. Compare without them, so an unchanged default doesn't look like drift on
34
+ * every boot.
38
35
  */
39
- return expression.toLowerCase().replace(CAST, '').replace(/[()]/g, '').replace(/\s+/g, ' ').trim()
40
- }
36
+ const normalizeDefault = (expression: string | null | undefined): string | null => {
37
+ if (expression == null) return null
41
38
 
42
- /**
43
- * The form an index or constraint definition is compared in, so the statement this package emits
44
- * and the text Postgres reports for the object it created compare equal.
45
- *
46
- * Postgres deparses what it stores: it leaves an identifier unquoted when it can, casts every
47
- * literal (`''::text`), casts a `varchar` column inside an expression, wraps a partial predicate and
48
- * each of its operator expressions in parentheses, and rewrites `x IN (…)` as `x = ANY (ARRAY[…])`.
49
- * Comparing the raw texts made every boot drop and recreate every declared index and every enum
50
- * `CHECK`. So both sides lose case, quotes, casts, whitespace and parentheses, and `= ANY (ARRAY[…])`
51
- * reads as `IN (…)`. What that cannot tell apart — two definitions differing only in how their
52
- * operators group — is a change to make under a new name.
53
- */
54
- export const canonicalDefinition = (definition: string): string => definition
55
- .toLowerCase()
56
- .replace(/"/g, '')
57
- .replace(CAST, '')
58
- .replace(/[\s()]/g, '')
59
- .replace(/=anyarray\[([^\]]*)\]/g, 'in$1')
60
-
61
- const normalizeDefinition = canonicalDefinition
62
-
63
- const columnList = (columns: string[]): string => columns.map(quoteIdent).join(', ')
64
-
65
- const indexStatement = (spec: TableSpec, entry: PgIndexSpec): string => {
66
- const target = entry.expression != null
67
- ? `(${entry.expression})`
68
- : `(${columnList(entry.columns as string[])})`
69
- const where = entry.where != null ? ` WHERE ${entry.where}` : ''
70
-
71
- return `CREATE ${entry.unique === true ? 'UNIQUE ' : ''}INDEX ${quoteIdent(entry.name!)}`
72
- + ` ON ${spec.qualified} USING ${entry.method ?? PgIndexMethod.BTree} ${target}${where}`
73
- }
39
+ /**
40
+ * Casts anywhere (a `varchar` id's `gen_random_uuid()::text` is stored as
41
+ * `(gen_random_uuid())::character varying`) and the parentheses Postgres adds around them.
42
+ */
43
+ return expression.toLowerCase().replace(CAST, '').replace(/[()]/g, '').replace(/\s+/g, ' ').trim()
44
+ }
74
45
 
75
- const columnDefinition = (column: ColumnSpec): string => {
76
- const parts = [quoteIdent(column.column), column.sqlType]
77
- if (column.notNull) parts.push('NOT NULL')
78
- const expression = defaultExpression(column)
79
- if (expression != null) parts.push(`DEFAULT ${expression}`)
46
+ const canonicalDefinition = (definition: string): string => definition
47
+ .toLowerCase()
48
+ .replace(/"/g, '')
49
+ .replace(CAST, '')
50
+ .replace(/[\s()]/g, '')
51
+ .replace(/=anyarray\[([^\]]*)\]/g, 'in$1')
80
52
 
81
- return parts.join(' ')
82
- }
53
+ const normalizeDefinition = canonicalDefinition
83
54
 
84
- /**
85
- * Foreign keys are planned separately from the rest of the table, and applied only once
86
- * every resource has initialized: a key points at a table another resource owns, and that
87
- * resource's own `init()` may not have run yet.
88
- *
89
- * @throws {PostgresPlaceholderError} when a target can't be resolved.
90
- */
91
- export const planForeignKeys = (
92
- spec: TableSpec, context?: BasicContext<any>
93
- ): DdlStatement[] => spec.references.map(reference => {
94
- const column = reference.property != null ? spec.byProperty[reference.property] : undefined
95
- if (column == null) {
96
- throw new PostgresPlaceholderError(`fk-unknown-property:${reference.property}`)
97
- }
55
+ const columnList = (columns: string[]): string => columns.map(quoteIdent).join(', ')
98
56
 
99
- let targetSchema = reference.schema ?? spec.schema
100
- let targetTable = reference.table
101
- if (reference.resource != null) {
102
- if (context == null) {
103
- throw new PostgresPlaceholderError(`fk-no-context:${reference.resource}`)
104
- }
105
- let target: PostgresResource<ResourceRecord>
106
- try {
107
- target = context.resource<PostgresResource<ResourceRecord>>(reference.resource)
108
- } catch {
109
- throw new PostgresPlaceholderError(`fk-unknown-resource:${reference.resource}`)
110
- }
111
- if (target.table == null) {
112
- throw new PostgresPlaceholderError(`fk-uninitialized:${reference.resource}`)
113
- }
114
- targetSchema = target.table.schema
115
- targetTable = target.table.table
116
- }
117
- if (targetTable == null) {
118
- throw new PostgresPlaceholderError(`fk-no-target:${reference.property}`)
57
+ const indexStatement = (spec: TableSpec, entry: PgIndexSpec): string => {
58
+ const target = entry.expression != null
59
+ ? `(${entry.expression})`
60
+ : `(${columnList(entry.columns as string[])})`
61
+ const where = entry.where != null ? ` WHERE ${entry.where}` : ''
62
+
63
+ return `CREATE ${entry.unique === true ? 'UNIQUE ' : ''}INDEX ${quoteIdent(entry.name!)}`
64
+ + ` ON ${spec.qualified} USING ${entry.method ?? PgIndexMethod.BTree} ${target}${where}`
119
65
  }
120
66
 
121
- const actions = [
122
- reference.onDelete != null ? ` ON DELETE ${reference.onDelete}` : '',
123
- reference.onUpdate != null ? ` ON UPDATE ${reference.onUpdate}` : ''
124
- ].join('')
125
-
126
- return {
127
- kind: 'add-foreign-key' as const,
128
- target: reference.name!,
129
- sql: `ALTER TABLE ${spec.qualified} ADD CONSTRAINT ${quoteIdent(reference.name!)}`
130
- + ` FOREIGN KEY (${quoteIdent(column.column)})`
131
- + ` REFERENCES ${qualify(targetSchema, targetTable)} (${quoteIdent(reference.column!)})${actions}`
67
+ const columnDefinition = (column: ColumnSpec): string => {
68
+ const parts = [quoteIdent(column.column), column.sqlType]
69
+ if (column.notNull) parts.push('NOT NULL')
70
+ const expression = defaultExpression(column)
71
+ if (expression != null) parts.push(`DEFAULT ${expression}`)
72
+
73
+ return parts.join(' ')
132
74
  }
133
- })
134
-
135
- /**
136
- * Compare the compiled specification against the live table and produce the ordered DDL
137
- * that converges one onto the other.
138
- *
139
- * Statement order is load bearing:
140
- * - `DROP COLUMN` is last, because a `USING` cast or a backfill may legitimately read a
141
- * column that is about to disappear.
142
- * - A primary key constraint is never dropped: it cascades into every referencing key and
143
- * can't be undone inside the same transaction once dependents exist.
144
- *
145
- * Foreign keys are not part of the plan at all — see {@link planForeignKeys}.
146
- */
147
- export const planSync = (
148
- spec: TableSpec, live: LiveTable, additive: boolean = false
149
- ): DdlPlan => {
150
- if (!live.exists) {
151
- const constraints: string[] = []
152
- if (spec.primaryKey.length > 0) {
153
- constraints.push(`CONSTRAINT ${quoteIdent(`${spec.table}_pkey`)} PRIMARY KEY (${columnList(spec.primaryKey)})`)
75
+
76
+ const planForeignKeys = (
77
+ spec: TableSpec, context?: BasicContext<any>
78
+ ): DdlStatement[] => spec.references.map(reference => {
79
+ const column = reference.property != null ? spec.byProperty[reference.property] : undefined
80
+ if (column == null) {
81
+ throw new PostgresPlaceholderError(`fk-unknown-property:${reference.property}`)
154
82
  }
155
- for (const unique of spec.uniques) {
156
- constraints.push(`CONSTRAINT ${quoteIdent(unique.name!)} UNIQUE (${columnList(unique.columns)})`)
83
+
84
+ let targetSchema = reference.schema ?? spec.schema
85
+ let targetTable = reference.table
86
+ if (reference.resource != null) {
87
+ if (context == null) {
88
+ throw new PostgresPlaceholderError(`fk-no-context:${reference.resource}`)
89
+ }
90
+ let target: PostgresResource<ResourceRecord>
91
+ try {
92
+ target = context.resource<PostgresResource<ResourceRecord>>(reference.resource)
93
+ } catch {
94
+ throw new PostgresPlaceholderError(`fk-unknown-resource:${reference.resource}`)
95
+ }
96
+ if (target.table == null) {
97
+ throw new PostgresPlaceholderError(`fk-uninitialized:${reference.resource}`)
98
+ }
99
+ targetSchema = target.table.schema
100
+ targetTable = target.table.table
157
101
  }
158
- for (const check of spec.checks) {
159
- constraints.push(`CONSTRAINT ${quoteIdent(check.name!)} CHECK (${check.expression})`)
102
+ if (targetTable == null) {
103
+ throw new PostgresPlaceholderError(`fk-no-target:${reference.property}`)
160
104
  }
161
105
 
162
- const statements: DdlStatement[] = [{
163
- kind: 'create-table',
164
- target: spec.table,
165
- sql: `CREATE TABLE ${spec.qualified} (\n `
166
- + [...spec.columns.map(columnDefinition), ...constraints].join(',\n ')
167
- + '\n)'
168
- }]
169
- for (const entry of spec.indexes) {
170
- statements.push({ kind: 'create-index', target: entry.name!, sql: indexStatement(spec, entry) })
106
+ const actions = [
107
+ reference.onDelete != null ? ` ON DELETE ${reference.onDelete}` : '',
108
+ reference.onUpdate != null ? ` ON UPDATE ${reference.onUpdate}` : ''
109
+ ].join('')
110
+
111
+ return {
112
+ kind: 'add-foreign-key' as const,
113
+ target: reference.name!,
114
+ sql: `ALTER TABLE ${spec.qualified} ADD CONSTRAINT ${quoteIdent(reference.name!)}`
115
+ + ` FOREIGN KEY (${quoteIdent(column.column)})`
116
+ + ` REFERENCES ${qualify(targetSchema, targetTable)} (${quoteIdent(reference.column!)})${actions}`
171
117
  }
118
+ })
119
+
120
+ const planSync = (
121
+ spec: TableSpec, live: LiveTable, additive: boolean = false
122
+ ): DdlPlan => {
123
+ if (!live.exists) {
124
+ const constraints: string[] = []
125
+ if (spec.primaryKey.length > 0) {
126
+ constraints.push(`CONSTRAINT ${quoteIdent(`${spec.table}_pkey`)} PRIMARY KEY (${columnList(spec.primaryKey)})`)
127
+ }
128
+ for (const unique of spec.uniques) {
129
+ constraints.push(`CONSTRAINT ${quoteIdent(unique.name!)} UNIQUE (${columnList(unique.columns)})`)
130
+ }
131
+ for (const check of spec.checks) {
132
+ constraints.push(`CONSTRAINT ${quoteIdent(check.name!)} CHECK (${check.expression})`)
133
+ }
172
134
 
173
- return { fresh: true, statements }
174
- }
175
-
176
- const statements: DdlStatement[] = []
177
- const byName: Record<string, typeof live.columns[number]> = {}
178
- for (const column of live.columns) {
179
- byName[column.name] = column
180
- }
135
+ const statements: DdlStatement[] = [{
136
+ kind: 'create-table',
137
+ target: spec.table,
138
+ sql: `CREATE TABLE ${spec.qualified} (\n `
139
+ + [...spec.columns.map(columnDefinition), ...constraints].join(',\n ')
140
+ + '\n)'
141
+ }]
142
+ for (const entry of spec.indexes) {
143
+ statements.push({ kind: 'create-index', target: entry.name!, sql: indexStatement(spec, entry) })
144
+ }
181
145
 
182
- const managed = spec.columns.filter(column => column.managed)
146
+ return { fresh: true, statements }
147
+ }
183
148
 
184
- /** 1. Columns the table doesn't have yet — always added nullable so existing rows survive. */
185
- for (const column of managed) {
186
- if (byName[column.column] != null) {
187
- continue
149
+ const statements: DdlStatement[] = []
150
+ const byName: Record<string, typeof live.columns[number]> = {}
151
+ for (const column of live.columns) {
152
+ byName[column.name] = column
188
153
  }
189
- const expression = defaultExpression(column)
190
- statements.push({
191
- kind: 'add-column',
192
- target: column.column,
193
- sql: `ALTER TABLE ${spec.qualified} ADD COLUMN ${quoteIdent(column.column)} ${column.sqlType}`
194
- + (expression != null ? ` DEFAULT ${expression}` : '')
195
- })
196
- if (column.notNull) {
197
- if (expression == null) {
198
- statements.push({
199
- kind: 'backfill',
200
- target: column.column,
201
- sql: `UPDATE ${spec.qualified} SET ${quoteIdent(column.column)} = ${fallback(column)}`
202
- + ` WHERE ${quoteIdent(column.column)} IS NULL`
203
- })
154
+
155
+ const managed = spec.columns.filter(column => column.managed)
156
+
157
+ /** 1. Columns the table doesn't have yet — always added nullable so existing rows survive. */
158
+ for (const column of managed) {
159
+ if (byName[column.column] != null) {
160
+ continue
204
161
  }
162
+ const expression = defaultExpression(column)
205
163
  statements.push({
206
- kind: 'set-not-null',
164
+ kind: 'add-column',
207
165
  target: column.column,
208
- sql: `ALTER TABLE ${spec.qualified} ALTER COLUMN ${quoteIdent(column.column)} SET NOT NULL`
166
+ sql: `ALTER TABLE ${spec.qualified} ADD COLUMN ${quoteIdent(column.column)} ${column.sqlType}`
167
+ + (expression != null ? ` DEFAULT ${expression}` : '')
209
168
  })
210
- }
211
- }
212
-
213
- /** 2-4. Type, nullability and default drift on columns that already exist. */
214
- for (const column of managed) {
215
- const existing = byName[column.column]
216
- if (existing == null) {
217
- continue
218
- }
219
-
220
- /**
221
- * A retype drops the column's default first.
222
- *
223
- * Postgres refuses `ALTER COLUMN … TYPE` outright when the column carries a DEFAULT it cannot
224
- * cast to the new type — `42804 default for column "x" cannot be cast automatically` — and it
225
- * refuses it before looking at a single row, so the `USING` clause never gets a chance to
226
- * help. The whole plan runs in one transaction, so that refusal aborts the entire
227
- * reconciliation: the table keeps its old shape and the resource fails every boot with an
228
- * error about a column it is trying to fix. The standard remedy is to take the default out of
229
- * the way, retype, and put it back — which is what the default-drift block below does, from
230
- * the spec rather than from whatever the column happened to be carrying.
231
- *
232
- * The `id` column is the one that meets this in practice: it is created with a
233
- * `gen_random_uuid()::text` default, so any change to its declared type hits this path.
234
- */
235
- let defaultDropped = false
236
- if (!additive && existing.type !== column.sqlType) {
237
- if (existing.defaultExpr != null) {
169
+ if (column.notNull) {
170
+ if (expression == null) {
171
+ statements.push({
172
+ kind: 'backfill',
173
+ target: column.column,
174
+ sql: `UPDATE ${spec.qualified} SET ${quoteIdent(column.column)} = ${fallback(column)}`
175
+ + ` WHERE ${quoteIdent(column.column)} IS NULL`
176
+ })
177
+ }
238
178
  statements.push({
239
- kind: 'set-default',
179
+ kind: 'set-not-null',
240
180
  target: column.column,
241
- sql: `ALTER TABLE ${spec.qualified} ALTER COLUMN ${quoteIdent(column.column)} DROP DEFAULT`
181
+ sql: `ALTER TABLE ${spec.qualified} ALTER COLUMN ${quoteIdent(column.column)} SET NOT NULL`
242
182
  })
243
- defaultDropped = true
244
183
  }
245
- const using = column.using ?? `${quoteIdent(column.column)}::${column.sqlType}`
246
- statements.push({
247
- kind: 'alter-type',
248
- target: column.column,
249
- sql: `ALTER TABLE ${spec.qualified} ALTER COLUMN ${quoteIdent(column.column)}`
250
- + ` TYPE ${column.sqlType} USING ${using}`
251
- })
252
184
  }
253
185
 
254
- const expression = defaultExpression(column)
255
- // Once the retype has dropped it, the default has to be re-stated even when it matches what
256
- // the column used to carry: the comparison is against the pre-plan introspection, which stops
257
- // describing the column the moment the plan drops its default.
258
- const defaultDrifted = defaultDropped
259
- ? expression != null
260
- : normalizeDefault(expression) !== normalizeDefault(existing.defaultExpr)
261
- if (defaultDrifted) {
262
- statements.push(expression != null
263
- ? {
264
- kind: 'set-default',
265
- target: column.column,
266
- sql: `ALTER TABLE ${spec.qualified} ALTER COLUMN ${quoteIdent(column.column)}`
267
- + ` SET DEFAULT ${expression}`
186
+ /** 2-4. Type, nullability and default drift on columns that already exist. */
187
+ for (const column of managed) {
188
+ const existing = byName[column.column]
189
+ if (existing == null) {
190
+ continue
191
+ }
192
+
193
+ /**
194
+ * A retype drops the column's default first.
195
+ *
196
+ * Postgres refuses `ALTER COLUMN … TYPE` outright when the column carries a DEFAULT it cannot
197
+ * cast to the new type — `42804 default for column "x" cannot be cast automatically` — and it
198
+ * refuses it before looking at a single row, so the `USING` clause never gets a chance to
199
+ * help. The whole plan runs in one transaction, so that refusal aborts the entire
200
+ * reconciliation: the table keeps its old shape and the resource fails every boot with an
201
+ * error about a column it is trying to fix. The standard remedy is to take the default out of
202
+ * the way, retype, and put it back — which is what the default-drift block below does, from
203
+ * the spec rather than from whatever the column happened to be carrying.
204
+ *
205
+ * The `id` column is the one that meets this in practice: it is created with a
206
+ * `gen_random_uuid()::text` default, so any change to its declared type hits this path.
207
+ */
208
+ let defaultDropped = false
209
+ if (!additive && existing.type !== column.sqlType) {
210
+ if (existing.defaultExpr != null) {
211
+ statements.push({
212
+ kind: 'set-default',
213
+ target: column.column,
214
+ sql: `ALTER TABLE ${spec.qualified} ALTER COLUMN ${quoteIdent(column.column)} DROP DEFAULT`
215
+ })
216
+ defaultDropped = true
268
217
  }
269
- : {
270
- kind: 'set-default',
218
+ const using = column.using ?? `${quoteIdent(column.column)}::${column.sqlType}`
219
+ statements.push({
220
+ kind: 'alter-type',
271
221
  target: column.column,
272
- sql: `ALTER TABLE ${spec.qualified} ALTER COLUMN ${quoteIdent(column.column)} DROP DEFAULT`
222
+ sql: `ALTER TABLE ${spec.qualified} ALTER COLUMN ${quoteIdent(column.column)}`
223
+ + ` TYPE ${column.sqlType} USING ${using}`
273
224
  })
274
- }
225
+ }
275
226
 
276
- if (column.notNull !== existing.notNull) {
277
- if (column.notNull) {
227
+ const expression = defaultExpression(column)
228
+ // Once the retype has dropped it, the default has to be re-stated even when it matches what
229
+ // the column used to carry: the comparison is against the pre-plan introspection, which stops
230
+ // describing the column the moment the plan drops its default.
231
+ const defaultDrifted = defaultDropped
232
+ ? expression != null
233
+ : normalizeDefault(expression) !== normalizeDefault(existing.defaultExpr)
234
+ if (defaultDrifted) {
235
+ statements.push(expression != null
236
+ ? {
237
+ kind: 'set-default',
238
+ target: column.column,
239
+ sql: `ALTER TABLE ${spec.qualified} ALTER COLUMN ${quoteIdent(column.column)}`
240
+ + ` SET DEFAULT ${expression}`
241
+ }
242
+ : {
243
+ kind: 'set-default',
244
+ target: column.column,
245
+ sql: `ALTER TABLE ${spec.qualified} ALTER COLUMN ${quoteIdent(column.column)} DROP DEFAULT`
246
+ })
247
+ }
248
+
249
+ if (column.notNull !== existing.notNull) {
250
+ if (column.notNull) {
251
+ statements.push({
252
+ kind: 'backfill',
253
+ target: column.column,
254
+ sql: `UPDATE ${spec.qualified} SET ${quoteIdent(column.column)}`
255
+ + ` = ${expression ?? fallback(column)} WHERE ${quoteIdent(column.column)} IS NULL`
256
+ })
257
+ }
278
258
  statements.push({
279
- kind: 'backfill',
259
+ kind: column.notNull ? 'set-not-null' : 'drop-not-null',
280
260
  target: column.column,
281
- sql: `UPDATE ${spec.qualified} SET ${quoteIdent(column.column)}`
282
- + ` = ${expression ?? fallback(column)} WHERE ${quoteIdent(column.column)} IS NULL`
261
+ sql: `ALTER TABLE ${spec.qualified} ALTER COLUMN ${quoteIdent(column.column)}`
262
+ + ` ${column.notNull ? 'SET' : 'DROP'} NOT NULL`
283
263
  })
284
264
  }
285
- statements.push({
286
- kind: column.notNull ? 'set-not-null' : 'drop-not-null',
287
- target: column.column,
288
- sql: `ALTER TABLE ${spec.qualified} ALTER COLUMN ${quoteIdent(column.column)}`
289
- + ` ${column.notNull ? 'SET' : 'DROP'} NOT NULL`
290
- })
291
- }
292
- }
293
-
294
- /** 5. Unique and check constraints. Primary keys are deliberately excluded. */
295
- const desiredConstraints: Record<string, string> = {}
296
- for (const unique of spec.uniques) {
297
- desiredConstraints[unique.name!] = `UNIQUE (${columnList(unique.columns)})`
298
- }
299
- for (const check of spec.checks) {
300
- desiredConstraints[check.name!] = `CHECK ((${check.expression}))`
301
- }
302
- const liveConstraints: Record<string, string> = {}
303
- for (const constraint of live.constraints) {
304
- // Primary and foreign keys have their own paths. A NOT NULL (`n`) is a constraint row since
305
- // Postgres 18 (`<table>_<column>_not_null`) and is owned by the column step above: dropping it by
306
- // name is refused on a primary key column, and on any other it would undo a `required` property.
307
- if (constraint.type === 'p' || constraint.type === 'f' || constraint.type === 'n') {
308
- continue
309
265
  }
310
- liveConstraints[constraint.name] = constraint.definition
311
- }
312
266
 
313
- for (const [name, definition] of Object.entries(liveConstraints)) {
314
- const desired = desiredConstraints[name]
315
- if (desired != null && normalizeDefinition(desired) === normalizeDefinition(definition)) {
316
- continue
317
- }
318
- if (desired == null && additive) {
319
- continue
320
- }
321
- if (desired == null && !isGenerated(name, spec)) {
322
- /** A constraint nobody declared was added by hand — leave it alone. */
323
- continue
267
+ /** 5. Unique and check constraints. Primary keys are deliberately excluded. */
268
+ const desiredConstraints: Record<string, string> = {}
269
+ for (const unique of spec.uniques) {
270
+ desiredConstraints[unique.name!] = `UNIQUE (${columnList(unique.columns)})`
324
271
  }
325
- statements.push({
326
- kind: 'drop-constraint',
327
- target: name,
328
- sql: `ALTER TABLE ${spec.qualified} DROP CONSTRAINT ${quoteIdent(name)}`
329
- })
330
- }
331
- for (const [name, definition] of Object.entries(desiredConstraints)) {
332
- const existing = liveConstraints[name]
333
- if (existing != null && normalizeDefinition(existing) === normalizeDefinition(definition)) {
334
- continue
272
+ for (const check of spec.checks) {
273
+ desiredConstraints[check.name!] = `CHECK ((${check.expression}))`
335
274
  }
336
- if (existing != null && additive) {
337
- continue
275
+ const liveConstraints: Record<string, string> = {}
276
+ for (const constraint of live.constraints) {
277
+ // Primary and foreign keys have their own paths. A NOT NULL (`n`) is a constraint row since
278
+ // Postgres 18 (`<table>_<column>_not_null`) and is owned by the column step above: dropping it by
279
+ // name is refused on a primary key column, and on any other it would undo a `required` property.
280
+ if (constraint.type === 'p' || constraint.type === 'f' || constraint.type === 'n') {
281
+ continue
282
+ }
283
+ liveConstraints[constraint.name] = constraint.definition
338
284
  }
339
- statements.push({
340
- kind: 'add-constraint',
341
- target: name,
342
- sql: `ALTER TABLE ${spec.qualified} ADD CONSTRAINT ${quoteIdent(name)} ${definition}`
343
- })
344
- }
345
285
 
346
- /** 6. Indexes. Constraint backed indexes are owned by their constraint, not by us. */
347
- const constraintBacked = live.constraints.map(constraint => constraint.name)
348
- const desiredIndexes: Record<string, string> = {}
349
- for (const entry of spec.indexes) {
350
- desiredIndexes[entry.name!] = indexStatement(spec, entry)
351
- }
352
- for (const existing of live.indexes) {
353
- if (constraintBacked.includes(existing.name)) {
354
- continue
355
- }
356
- const desired = desiredIndexes[existing.name]
357
- if (desired != null && normalizeDefinition(desired) === normalizeDefinition(existing.definition)) {
358
- continue
359
- }
360
- if (desired == null && (additive || !isGenerated(existing.name, spec))) {
361
- continue
362
- }
363
- statements.push({
364
- kind: 'drop-index',
365
- target: existing.name,
366
- sql: `DROP INDEX ${qualify(spec.schema, existing.name)}`
367
- })
368
- }
369
- for (const [name, statement] of Object.entries(desiredIndexes)) {
370
- const existing = live.indexes.find(entry => entry.name === name)
371
- if (existing != null && normalizeDefinition(existing.definition) === normalizeDefinition(statement)) {
372
- continue
286
+ for (const [name, definition] of Object.entries(liveConstraints)) {
287
+ const desired = desiredConstraints[name]
288
+ if (desired != null && normalizeDefinition(desired) === normalizeDefinition(definition)) {
289
+ continue
290
+ }
291
+ if (desired == null && additive) {
292
+ continue
293
+ }
294
+ if (desired == null && !isGenerated(name, spec)) {
295
+ /** A constraint nobody declared was added by hand — leave it alone. */
296
+ continue
297
+ }
298
+ statements.push({
299
+ kind: 'drop-constraint',
300
+ target: name,
301
+ sql: `ALTER TABLE ${spec.qualified} DROP CONSTRAINT ${quoteIdent(name)}`
302
+ })
373
303
  }
374
- if (constraintBacked.includes(name)) {
375
- continue
304
+ for (const [name, definition] of Object.entries(desiredConstraints)) {
305
+ const existing = liveConstraints[name]
306
+ if (existing != null && normalizeDefinition(existing) === normalizeDefinition(definition)) {
307
+ continue
308
+ }
309
+ if (existing != null && additive) {
310
+ continue
311
+ }
312
+ statements.push({
313
+ kind: 'add-constraint',
314
+ target: name,
315
+ sql: `ALTER TABLE ${spec.qualified} ADD CONSTRAINT ${quoteIdent(name)} ${definition}`
316
+ })
376
317
  }
377
- statements.push({ kind: 'create-index', target: name, sql: statement })
378
- }
379
318
 
380
- /** 7. Drops, last and only in full mode. */
381
- if (!additive) {
382
- const keep = [...managed.map(column => column.column), ...spec.unmanaged]
383
- for (const existing of live.columns) {
384
- if (keep.includes(existing.name) || existing.generated !== '') {
319
+ /** 6. Indexes. Constraint backed indexes are owned by their constraint, not by us. */
320
+ const constraintBacked = live.constraints.map(constraint => constraint.name)
321
+ const desiredIndexes: Record<string, string> = {}
322
+ for (const entry of spec.indexes) {
323
+ desiredIndexes[entry.name!] = indexStatement(spec, entry)
324
+ }
325
+ for (const existing of live.indexes) {
326
+ if (constraintBacked.includes(existing.name)) {
327
+ continue
328
+ }
329
+ const desired = desiredIndexes[existing.name]
330
+ if (desired != null && normalizeDefinition(desired) === normalizeDefinition(existing.definition)) {
331
+ continue
332
+ }
333
+ if (desired == null && (additive || !isGenerated(existing.name, spec))) {
385
334
  continue
386
335
  }
387
336
  statements.push({
388
- kind: 'drop-column',
337
+ kind: 'drop-index',
389
338
  target: existing.name,
390
- destructive: true,
391
- sql: `ALTER TABLE ${spec.qualified} DROP COLUMN ${quoteIdent(existing.name)}`
339
+ sql: `DROP INDEX ${qualify(spec.schema, existing.name)}`
392
340
  })
393
341
  }
394
- }
342
+ for (const [name, statement] of Object.entries(desiredIndexes)) {
343
+ const existing = live.indexes.find(entry => entry.name === name)
344
+ if (existing != null && normalizeDefinition(existing.definition) === normalizeDefinition(statement)) {
345
+ continue
346
+ }
347
+ if (constraintBacked.includes(name)) {
348
+ continue
349
+ }
350
+ statements.push({ kind: 'create-index', target: name, sql: statement })
351
+ }
395
352
 
396
- return { fresh: false, statements }
397
- }
353
+ /** 7. Drops, last and only in full mode. */
354
+ if (!additive) {
355
+ const keep = [...managed.map(column => column.column), ...spec.unmanaged]
356
+ for (const existing of live.columns) {
357
+ if (keep.includes(existing.name) || existing.generated !== '') {
358
+ continue
359
+ }
360
+ statements.push({
361
+ kind: 'drop-column',
362
+ target: existing.name,
363
+ destructive: true,
364
+ sql: `ALTER TABLE ${spec.qualified} DROP COLUMN ${quoteIdent(existing.name)}`
365
+ })
366
+ }
367
+ }
368
+
369
+ return { fresh: false, statements }
370
+ }
398
371
 
399
- /** A name this package would have generated is a name this package may drop. */
400
- const isGenerated = (name: string, spec: TableSpec): boolean =>
401
- name.startsWith(`${spec.table}_`)
402
-
403
- /** Value used to fill an existing row before a column becomes `NOT NULL`. */
404
- const fallback = (column: ColumnSpec): string => {
405
- if (column.array) return `'{}'::${column.sqlType}`
406
- if (column.jsonb) return `'{}'::jsonb`
407
- switch (column.jsonType) {
408
- case 'number':
409
- case 'integer':
410
- case 'bigint':
411
- return '0'
412
- case 'boolean':
413
- return 'FALSE'
414
- case 'date':
415
- return 'now()'
416
- default:
417
- return `''::${column.sqlType}`
372
+ /** A name this package would have generated is a name this package may drop. */
373
+ const isGenerated = (name: string, spec: TableSpec): boolean =>
374
+ name.startsWith(`${spec.table}_`)
375
+
376
+ /** Value used to fill an existing row before a column becomes `NOT NULL`. */
377
+ const fallback = (column: ColumnSpec): string => {
378
+ if (column.array) return `'{}'::${column.sqlType}`
379
+ if (column.jsonb) return `'{}'::jsonb`
380
+ switch (column.jsonType) {
381
+ case 'number':
382
+ case 'integer':
383
+ case 'bigint':
384
+ return '0'
385
+ case 'boolean':
386
+ return 'FALSE'
387
+ case 'date':
388
+ return 'now()'
389
+ default:
390
+ return `''::${column.sqlType}`
391
+ }
418
392
  }
393
+
394
+ return { canonicalDefinition, planForeignKeys, planSync }
419
395
  }
396
+
397
+ export const pgDiffHelper = createPgDiffHelper()
398
+
399
+ /** @deprecated compat:factory-refactor — use `pgDiffHelper.planSync(…)` */
400
+ export const planSync = (spec: TableSpec, live: LiveTable, additive: boolean = false): DdlPlan =>
401
+ pgDiffHelper.planSync(spec, live, additive)