@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.
- package/README.md +13 -13
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/postgres-resource/SKILL.md +1 -1
- package/build/consts.d.ts +9 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +6 -0
- package/build/consts.js.map +1 -1
- package/build/declarations/types.d.ts +12 -0
- package/build/declarations/types.d.ts.map +1 -0
- package/build/declarations/types.js +2 -0
- package/build/declarations/types.js.map +1 -0
- package/build/declarations.d.ts +5 -1
- package/build/declarations.d.ts.map +1 -1
- package/build/declarations.js +25 -15
- package/build/declarations.js.map +1 -1
- package/build/errors.d.ts +0 -20
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js +1 -111
- package/build/errors.js.map +1 -1
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/pg-error/types.d.ts +24 -0
- package/build/pg-error/types.d.ts.map +1 -0
- package/build/pg-error/types.js +2 -0
- package/build/pg-error/types.js.map +1 -0
- package/build/pg-error.d.ts +4 -0
- package/build/pg-error.d.ts.map +1 -0
- package/build/pg-error.js +100 -0
- package/build/pg-error.js.map +1 -0
- package/build/resource.d.ts.map +1 -1
- package/build/resource.js +15 -11
- package/build/resource.js.map +1 -1
- package/build/types.d.ts.map +1 -1
- package/build/utils/consts.local.d.ts +2 -0
- package/build/utils/consts.local.d.ts.map +1 -1
- package/build/utils/consts.local.js +2 -0
- package/build/utils/consts.local.js.map +1 -1
- package/build/utils/criteria/types.d.ts +31 -0
- package/build/utils/criteria/types.d.ts.map +1 -0
- package/build/utils/criteria/types.js +2 -0
- package/build/utils/criteria/types.js.map +1 -0
- package/build/utils/criteria.d.ts +3 -27
- package/build/utils/criteria.d.ts.map +1 -1
- package/build/utils/criteria.js +86 -104
- package/build/utils/criteria.js.map +1 -1
- package/build/utils/diff/types.d.ts +40 -0
- package/build/utils/diff/types.d.ts.map +1 -0
- package/build/utils/diff/types.js +2 -0
- package/build/utils/diff/types.js.map +1 -0
- package/build/utils/diff.d.ts +5 -35
- package/build/utils/diff.d.ts.map +1 -1
- package/build/utils/diff.js +320 -344
- package/build/utils/diff.js.map +1 -1
- package/build/utils/index.d.ts +9 -0
- package/build/utils/index.d.ts.map +1 -1
- package/build/utils/index.js.map +1 -1
- package/build/utils/introspect/types.d.ts +9 -0
- package/build/utils/introspect/types.d.ts.map +1 -0
- package/build/utils/introspect/types.js +2 -0
- package/build/utils/introspect/types.js.map +1 -0
- package/build/utils/introspect.d.ts +3 -3
- package/build/utils/introspect.d.ts.map +1 -1
- package/build/utils/introspect.js +41 -38
- package/build/utils/introspect.js.map +1 -1
- package/build/utils/life-cycle/types.d.ts +37 -0
- package/build/utils/life-cycle/types.d.ts.map +1 -0
- package/build/utils/life-cycle/types.js +2 -0
- package/build/utils/life-cycle/types.js.map +1 -0
- package/build/utils/life-cycle.d.ts +3 -33
- package/build/utils/life-cycle.d.ts.map +1 -1
- package/build/utils/life-cycle.js +88 -108
- package/build/utils/life-cycle.js.map +1 -1
- package/build/utils/marshal/types.d.ts +39 -0
- package/build/utils/marshal/types.d.ts.map +1 -0
- package/build/utils/marshal/types.js +2 -0
- package/build/utils/marshal/types.js.map +1 -0
- package/build/utils/marshal.d.ts +4 -32
- package/build/utils/marshal.d.ts.map +1 -1
- package/build/utils/marshal.js +121 -144
- package/build/utils/marshal.js.map +1 -1
- package/build/utils/migrations.d.ts.map +1 -1
- package/build/utils/migrations.js +41 -36
- package/build/utils/migrations.js.map +1 -1
- package/build/utils/name/types.d.ts +43 -0
- package/build/utils/name/types.d.ts.map +1 -0
- package/build/utils/name/types.js +2 -0
- package/build/utils/name/types.js.map +1 -0
- package/build/utils/name.d.ts +6 -35
- package/build/utils/name.d.ts.map +1 -1
- package/build/utils/name.js +38 -57
- package/build/utils/name.js.map +1 -1
- package/build/utils/schema/types.d.ts +22 -0
- package/build/utils/schema/types.d.ts.map +1 -0
- package/build/utils/schema/types.js +2 -0
- package/build/utils/schema/types.js.map +1 -0
- package/build/utils/schema.d.ts +4 -24
- package/build/utils/schema.d.ts.map +1 -1
- package/build/utils/schema.js +339 -352
- package/build/utils/schema.js.map +1 -1
- package/build/utils/sql/types.d.ts +25 -0
- package/build/utils/sql/types.d.ts.map +1 -0
- package/build/utils/sql/types.js +2 -0
- package/build/utils/sql/types.js.map +1 -0
- package/build/utils/sql.d.ts +4 -18
- package/build/utils/sql.d.ts.map +1 -1
- package/build/utils/sql.js +0 -0
- package/build/utils/sql.js.map +1 -1
- package/build/utils/sync/types.d.ts +25 -0
- package/build/utils/sync/types.d.ts.map +1 -0
- package/build/utils/sync/types.js +2 -0
- package/build/utils/sync/types.js.map +1 -0
- package/build/utils/sync.d.ts +2 -19
- package/build/utils/sync.d.ts.map +1 -1
- package/build/utils/sync.js +64 -71
- package/build/utils/sync.js.map +1 -1
- package/package.json +7 -7
- package/src/consts.ts +7 -0
- package/src/declarations/types.ts +12 -0
- package/src/declarations.ts +29 -14
- package/src/errors.ts +1 -121
- package/src/index.ts +3 -0
- package/src/pg-error/types.ts +23 -0
- package/src/pg-error.ts +114 -0
- package/src/resource.ts +17 -13
- package/src/types.ts +1 -0
- package/src/utils/consts.local.ts +3 -0
- package/src/utils/criteria/types.ts +32 -0
- package/src/utils/criteria.ts +95 -110
- package/src/utils/diff/types.ts +41 -0
- package/src/utils/diff.ts +330 -348
- package/src/utils/index.ts +9 -0
- package/src/utils/introspect/types.ts +9 -0
- package/src/utils/introspect.ts +51 -45
- package/src/utils/life-cycle/types.ts +41 -0
- package/src/utils/life-cycle.ts +102 -121
- package/src/utils/marshal/types.ts +40 -0
- package/src/utils/marshal.ts +129 -147
- package/src/utils/migrations.ts +39 -33
- package/src/utils/name/types.ts +44 -0
- package/src/utils/name.ts +47 -60
- package/src/utils/schema/types.ts +26 -0
- package/src/utils/schema.ts +359 -366
- package/src/utils/sql/types.ts +25 -0
- package/src/utils/sql.ts +0 -0
- package/src/utils/sync/types.ts +25 -0
- package/src/utils/sync.ts +62 -68
- package/tests/criteria.spec.ts +4 -1
- package/tests/diff.spec.ts +4 -1
- package/tests/errors.spec.ts +4 -3
- package/tests/name.spec.ts +5 -3
- package/tests/schema.spec.ts +3 -1
- 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 {
|
|
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
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
if (column.defaultLiteral !== undefined) return literal(column.defaultLiteral)
|
|
21
|
+
return `'${value.replace(/'/g, "''")}'`
|
|
22
|
+
}
|
|
23
23
|
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
37
|
-
* `
|
|
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
|
-
|
|
40
|
-
|
|
36
|
+
const normalizeDefault = (expression: string | null | undefined): string | null => {
|
|
37
|
+
if (expression == null) return null
|
|
41
38
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
159
|
-
|
|
102
|
+
if (targetTable == null) {
|
|
103
|
+
throw new PostgresPlaceholderError(`fk-no-target:${reference.property}`)
|
|
160
104
|
}
|
|
161
105
|
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
146
|
+
return { fresh: true, statements }
|
|
147
|
+
}
|
|
183
148
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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: '
|
|
164
|
+
kind: 'add-column',
|
|
207
165
|
target: column.column,
|
|
208
|
-
sql: `ALTER TABLE ${spec.qualified}
|
|
166
|
+
sql: `ALTER TABLE ${spec.qualified} ADD COLUMN ${quoteIdent(column.column)} ${column.sqlType}`
|
|
167
|
+
+ (expression != null ? ` DEFAULT ${expression}` : '')
|
|
209
168
|
})
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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-
|
|
179
|
+
kind: 'set-not-null',
|
|
240
180
|
target: column.column,
|
|
241
|
-
sql: `ALTER TABLE ${spec.qualified} ALTER COLUMN ${quoteIdent(column.column)}
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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)}
|
|
222
|
+
sql: `ALTER TABLE ${spec.qualified} ALTER COLUMN ${quoteIdent(column.column)}`
|
|
223
|
+
+ ` TYPE ${column.sqlType} USING ${using}`
|
|
273
224
|
})
|
|
274
|
-
|
|
225
|
+
}
|
|
275
226
|
|
|
276
|
-
|
|
277
|
-
|
|
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: '
|
|
259
|
+
kind: column.notNull ? 'set-not-null' : 'drop-not-null',
|
|
280
260
|
target: column.column,
|
|
281
|
-
sql: `
|
|
282
|
-
+ `
|
|
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
|
-
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
|
|
337
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
375
|
-
|
|
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
|
-
|
|
381
|
-
|
|
382
|
-
const
|
|
383
|
-
for (const
|
|
384
|
-
|
|
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-
|
|
337
|
+
kind: 'drop-index',
|
|
389
338
|
target: existing.name,
|
|
390
|
-
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
/** Value used to fill an existing row before a column becomes `NOT NULL`. */
|
|
404
|
-
const fallback = (column: ColumnSpec): string => {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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)
|