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