@owlmeans/postgres-resource 0.1.18-rc.4 → 0.1.18-rc.41

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 (56) hide show
  1. package/README.md +185 -101
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/postgres-resource/SKILL.md +110 -21
  4. package/build/consts.d.ts +8 -1
  5. package/build/consts.d.ts.map +1 -1
  6. package/build/consts.js +8 -1
  7. package/build/consts.js.map +1 -1
  8. package/build/declarations.js +5 -5
  9. package/build/errors.d.ts +3 -1
  10. package/build/errors.d.ts.map +1 -1
  11. package/build/errors.js +4 -2
  12. package/build/errors.js.map +1 -1
  13. package/build/resource.d.ts +2 -2
  14. package/build/resource.d.ts.map +1 -1
  15. package/build/resource.js +112 -108
  16. package/build/resource.js.map +1 -1
  17. package/build/types.d.ts +34 -19
  18. package/build/types.d.ts.map +1 -1
  19. package/build/utils/criteria.d.ts +10 -6
  20. package/build/utils/criteria.d.ts.map +1 -1
  21. package/build/utils/criteria.js +156 -11
  22. package/build/utils/criteria.js.map +1 -1
  23. package/build/utils/diff.d.ts +13 -0
  24. package/build/utils/diff.d.ts.map +1 -1
  25. package/build/utils/diff.js +60 -6
  26. package/build/utils/diff.js.map +1 -1
  27. package/build/utils/migrations.d.ts +2 -2
  28. package/build/utils/migrations.d.ts.map +1 -1
  29. package/build/utils/migrations.js +11 -8
  30. package/build/utils/migrations.js.map +1 -1
  31. package/build/utils/name.d.ts +3 -3
  32. package/build/utils/name.js +3 -3
  33. package/build/utils/sql.d.ts +10 -2
  34. package/build/utils/sql.d.ts.map +1 -1
  35. package/build/utils/sql.js.map +1 -1
  36. package/build/utils/table.d.ts +4 -0
  37. package/build/utils/table.d.ts.map +1 -1
  38. package/build/utils/table.js +4 -0
  39. package/build/utils/table.js.map +1 -1
  40. package/package.json +7 -7
  41. package/src/consts.ts +8 -1
  42. package/src/declarations.ts +5 -5
  43. package/src/errors.ts +4 -2
  44. package/src/resource.ts +139 -135
  45. package/src/types.ts +36 -18
  46. package/src/utils/criteria.ts +202 -23
  47. package/src/utils/diff.ts +62 -7
  48. package/src/utils/migrations.ts +11 -8
  49. package/src/utils/name.ts +3 -3
  50. package/src/utils/sql.ts +0 -0
  51. package/src/utils/table.ts +5 -1
  52. package/tests/criteria.spec.ts +237 -0
  53. package/tests/diff.spec.ts +111 -0
  54. package/tests/errors.spec.ts +11 -0
  55. package/tests/name.spec.ts +3 -3
  56. package/tests/sql.spec.ts +4 -3
@@ -1,6 +1,6 @@
1
1
  import { UnsupportedArgumentError } from '@owlmeans/resource'
2
- import type { ListCriteria, ListSort } from '@owlmeans/resource'
3
- import { and, asc, desc, or, sql } from 'drizzle-orm'
2
+ import type { Criteria, FieldOperators, Sort } from '@owlmeans/resource'
3
+ import { and, asc, desc, or, param, sql } from 'drizzle-orm'
4
4
  import type { SQL } from 'drizzle-orm'
5
5
 
6
6
  import type { ColumnSpec, PgRuntimeTable, TableSpec } from '../types.js'
@@ -19,6 +19,58 @@ const value = (raw: unknown, column: ColumnSpec): SQL => {
19
19
  return sql`${raw}`
20
20
  }
21
21
 
22
+ /** The operand of an array operator as a list — a scalar is the list of itself. */
23
+ const listOf = (operand: unknown): unknown[] => Array.isArray(operand) ? operand : [operand]
24
+
25
+ const isPlainObject = (raw: unknown): raw is Record<string, unknown> =>
26
+ raw != null && typeof raw === 'object' && !Array.isArray(raw) && !(raw instanceof Date)
27
+
28
+ /** A JSON value as a bound `jsonb` literal. */
29
+ const json = (raw: unknown): SQL => sql`${JSON.stringify(raw instanceof Date ? raw.toISOString() : raw)}::jsonb`
30
+
31
+ /** `ARRAY[$1, $2]::jsonb[]` — one bound JSON literal per value. */
32
+ const jsonList = (values: unknown[]): SQL => values.length === 0
33
+ ? sql`ARRAY[]::jsonb[]`
34
+ : sql`ARRAY[${sql.join(values.map(entry => sql`${JSON.stringify(entry instanceof Date ? entry.toISOString() : entry)}`), sql`, `)}]::jsonb[]`
35
+
36
+ /**
37
+ * `@>` / `<@` against a jsonb value: an object is contained as an object, anything else as the
38
+ * JSON array it is (or the array of the one value it is).
39
+ */
40
+ const jsonContainment = (target: SQL, operator: '@>' | '<@', operand: unknown): SQL =>
41
+ sql`${target} ${sql.raw(operator)} ${json(isPlainObject(operand) ? operand : listOf(operand))}`
42
+
43
+ /**
44
+ * `&&` has no jsonb form: an array value overlaps when one of its elements is one of the operands,
45
+ * and a scalar value — like every other store reads it — when it is one of them.
46
+ */
47
+ const jsonOverlap = (target: SQL, operand: unknown): SQL => {
48
+ const values = listOf(operand)
49
+ return sql`(CASE WHEN jsonb_typeof(${target}) = 'array' THEN EXISTS (SELECT 1 FROM jsonb_array_elements(${target}) AS element WHERE element = ANY(${jsonList(values)})) ELSE ${target} = ANY(${jsonList(values)}) END)`
50
+ }
51
+
52
+ /**
53
+ * `@>` / `<@` / `&&` on a column. A native array column takes ONE bound array parameter cast to the
54
+ * column's own type (a bare JS array in a template would expand into a row constructor), a jsonb
55
+ * column takes JSON, and a scalar operand is the one-element list either way.
56
+ */
57
+ const arrayOperator = (
58
+ table: PgRuntimeTable, column: ColumnSpec, operator: '$contains' | '$contained' | '$overlaps', operand: unknown
59
+ ): SQL => {
60
+ const target = columnRef(table, column)
61
+ if (column.jsonb) {
62
+ return operator === '$overlaps'
63
+ ? jsonOverlap(target, operand)
64
+ : jsonContainment(target, operator === '$contains' ? '@>' : '<@', operand)
65
+ }
66
+ const symbol = sql.raw(operator === '$contains' ? '@>' : operator === '$contained' ? '<@' : '&&')
67
+ if (column.array) {
68
+ return sql`${target} ${symbol} ${param(listOf(operand))}::${sql.raw(column.sqlType)}`
69
+ }
70
+
71
+ return sql`${target} ${symbol} ${value(operand, column)}`
72
+ }
73
+
22
74
  const inList = (table: PgRuntimeTable, column: ColumnSpec, values: unknown[], negate: boolean): SQL => {
23
75
  const present = values.filter(entry => entry != null)
24
76
  const hasNull = present.length !== values.length
@@ -48,7 +100,7 @@ const inList = (table: PgRuntimeTable, column: ColumnSpec, values: unknown[], ne
48
100
  }
49
101
 
50
102
  const operators = (
51
- table: PgRuntimeTable, column: ColumnSpec, spec: Record<string, unknown>
103
+ table: PgRuntimeTable, column: ColumnSpec, spec: FieldOperators<any>
52
104
  ): SQL[] => {
53
105
  const conditions: SQL[] = []
54
106
  for (const [operator, operand] of Object.entries(spec)) {
@@ -98,13 +150,112 @@ const operators = (
98
150
  break
99
151
  }
100
152
  case '$contains':
101
- conditions.push(sql`${columnRef(table, column)} @> ${value(operand, column)}`)
153
+ case '$contained':
154
+ case '$overlaps':
155
+ conditions.push(arrayOperator(table, column, operator, operand))
156
+ break
157
+ default:
158
+ throw new UnsupportedArgumentError(`criteria-operator:${operator}`)
159
+ }
160
+ }
161
+
162
+ return conditions
163
+ }
164
+
165
+ const escapeLike = (value: string): string => value.replace(/[\\%_]/g, match => `\\${match}`)
166
+
167
+ // ─── Dotted paths into a jsonb column ────────────────────────────────────────────────────────────
168
+
169
+ /**
170
+ * A value at a path, as the jsonb it is (`#>`) and as text (`#>>`). The path is ONE bound `text[]`
171
+ * parameter, so a segment holding a comma or a brace stays one segment.
172
+ */
173
+ interface PathTarget {
174
+ value: SQL
175
+ text: SQL
176
+ /** Absent, or a JSON `null` — what every other store reads as "no value". */
177
+ missing: SQL
178
+ }
179
+
180
+ const pathTarget = (table: PgRuntimeTable, column: ColumnSpec, path: string[]): PathTarget => {
181
+ const value = sql`(${columnRef(table, column)} #> ${param(path)}::text[])`
182
+
183
+ return {
184
+ value,
185
+ text: sql`(${columnRef(table, column)} #>> ${param(path)}::text[])`,
186
+ missing: sql`(${value} IS NULL OR ${value} = 'null'::jsonb)`,
187
+ }
188
+ }
189
+
190
+ /** `$in` / `$nin` / a bare list at a path — the column rules: a `null` entry widens to absence. */
191
+ const pathList = (target: PathTarget, values: unknown[], negate: boolean): SQL => {
192
+ const present = values.filter(entry => entry != null)
193
+ const hasNull = present.length !== values.length
194
+
195
+ if (present.length < 1) {
196
+ return hasNull
197
+ ? negate ? sql`NOT ${target.missing}` : target.missing
198
+ : negate ? sql`TRUE` : sql`FALSE`
199
+ }
200
+ const membership = sql`${target.value} = ANY(${jsonList(present)})`
201
+ if (negate) {
202
+ return sql`(NOT ${target.missing} AND NOT (${membership}))`
203
+ }
204
+
205
+ return hasNull ? sql`(${membership} OR ${target.missing})` : membership
206
+ }
207
+
208
+ /** The operators at a path, each read as the same operator reads on a column. */
209
+ const pathOperators = (target: PathTarget, spec: FieldOperators<any>, key: string): SQL[] => {
210
+ const conditions: SQL[] = []
211
+ for (const [operator, operand] of Object.entries(spec)) {
212
+ if (operator in COMPARISON) {
213
+ conditions.push(operand == null
214
+ ? operator === '$ne' ? sql`NOT ${target.missing}` : target.missing
215
+ : sql`${target.value} ${sql.raw(COMPARISON[operator])} ${json(operand)}`)
216
+ continue
217
+ }
218
+ switch (operator) {
219
+ case '$in':
220
+ case '$nin':
221
+ conditions.push(pathList(target, listOf(operand), operator === '$nin'))
222
+ break
223
+ case '$exists':
224
+ conditions.push(operand === false ? target.missing : sql`NOT ${target.missing}`)
225
+ break
226
+ case '$null':
227
+ conditions.push(operand === false ? sql`NOT ${target.missing}` : target.missing)
228
+ break
229
+ case '$like':
230
+ conditions.push(sql`${target.text} LIKE ${operand}`)
231
+ break
232
+ case '$ilike':
233
+ conditions.push(sql`${target.text} ILIKE ${operand}`)
234
+ break
235
+ case '$regex':
236
+ conditions.push(sql`${target.text} ~ ${operand}`)
237
+ break
238
+ case '$startsWith':
239
+ conditions.push(sql`${target.text} LIKE ${`${escapeLike(`${operand}`)}%`}`)
240
+ break
241
+ case '$endsWith':
242
+ conditions.push(sql`${target.text} LIKE ${`%${escapeLike(`${operand}`)}`}`)
243
+ break
244
+ case '$between': {
245
+ if (!Array.isArray(operand) || operand.length !== 2) {
246
+ throw new UnsupportedArgumentError(`criteria:$between:${key}`)
247
+ }
248
+ conditions.push(sql`${target.value} BETWEEN ${json(operand[0])} AND ${json(operand[1])}`)
249
+ break
250
+ }
251
+ case '$contains':
252
+ conditions.push(jsonContainment(target.value, '@>', operand))
102
253
  break
103
254
  case '$contained':
104
- conditions.push(sql`${columnRef(table, column)} <@ ${value(operand, column)}`)
255
+ conditions.push(jsonContainment(target.value, '<@', operand))
105
256
  break
106
257
  case '$overlaps':
107
- conditions.push(sql`${columnRef(table, column)} && ${value(operand, column)}`)
258
+ conditions.push(jsonOverlap(target.value, operand))
108
259
  break
109
260
  default:
110
261
  throw new UnsupportedArgumentError(`criteria-operator:${operator}`)
@@ -114,30 +265,55 @@ const operators = (
114
265
  return conditions
115
266
  }
116
267
 
117
- const escapeLike = (value: string): string => value.replace(/[\\%_]/g, match => `\\${match}`)
268
+ /**
269
+ * A criteria value at a dotted path, with the meaning it has on a column: a bare value is typed
270
+ * JSON equality (a number is not its text), a bare list is membership, `null` is absence, an object
271
+ * of operators applies each, and a plain object is containment.
272
+ */
273
+ const pathCondition = (target: PathTarget, raw: unknown, key: string): SQL[] => {
274
+ if (raw === null) {
275
+ return [target.missing]
276
+ }
277
+ if (isOperatorSpec(raw)) {
278
+ return pathOperators(target, raw, key)
279
+ }
280
+ if (Array.isArray(raw)) {
281
+ return [pathList(target, raw, false)]
282
+ }
283
+ if (isPlainObject(raw)) {
284
+ return [jsonContainment(target.value, '@>', raw)]
285
+ }
118
286
 
119
- const isOperatorSpec = (value: unknown): value is Record<string, unknown> =>
287
+ return [sql`${target.value} = ${json(raw)}`]
288
+ }
289
+
290
+ /**
291
+ * An object naming at least one `$` key is a spec, not a value to compare against.
292
+ * A Date and an array are values even though both are objects.
293
+ */
294
+ const isOperatorSpec = (value: unknown): value is FieldOperators<any> =>
120
295
  value != null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)
121
296
  && Object.keys(value).some(key => key.startsWith('$'))
122
297
 
123
298
  /**
124
- * Translate `ListCriteria` into a WHERE clause.
299
+ * Translate a {@link Criteria} into a WHERE clause. `undefined` means "no constraint at
300
+ * all" — the caller decides whether that is a full scan or a refusal.
125
301
  *
126
302
  * An unknown key raises rather than being skipped: a typo silently widening a query to
127
303
  * the whole table is the failure mode worth being loud about.
128
304
  *
129
305
  * @throws {UnsupportedArgumentError}
130
306
  */
131
- export const criteriaToSql = (
132
- criteria: ListCriteria | undefined, spec: TableSpec, table: PgRuntimeTable
307
+ export const criteriaToSql = <T>(
308
+ criteria: Criteria<T> | undefined, spec: TableSpec, table: PgRuntimeTable
133
309
  ): SQL | undefined => {
134
310
  const conditions = build(criteria, spec, table)
135
311
 
136
312
  return conditions.length > 0 ? and(...conditions) : undefined
137
313
  }
138
314
 
139
- const build = (
140
- criteria: ListCriteria | undefined, spec: TableSpec, table: PgRuntimeTable
315
+ const build = <T>(
316
+ criteria: Criteria<T> | undefined, spec: TableSpec, table: PgRuntimeTable
141
317
  ): SQL[] => {
142
318
  const conditions: SQL[] = []
143
319
  for (const [key, raw] of Object.entries(criteria ?? {})) {
@@ -147,7 +323,7 @@ const build = (
147
323
 
148
324
  if (key === '$and' || key === '$or') {
149
325
  const parts = (Array.isArray(raw) ? raw : [raw])
150
- .map(entry => and(...build(entry as ListCriteria, spec, table)))
326
+ .map(entry => and(...build(entry as Criteria<T>, spec, table)))
151
327
  .filter((entry): entry is SQL => entry != null)
152
328
  if (parts.length > 0) {
153
329
  const combined = key === '$and' ? and(...parts) : or(...parts)
@@ -158,7 +334,7 @@ const build = (
158
334
  continue
159
335
  }
160
336
  if (key === '$not') {
161
- const inner = and(...build(raw as ListCriteria, spec, table))
337
+ const inner = and(...build(raw as Criteria<T>, spec, table))
162
338
  if (inner != null) {
163
339
  conditions.push(sql`NOT (${inner})`)
164
340
  }
@@ -175,7 +351,7 @@ const build = (
175
351
  if (!column.jsonb) {
176
352
  throw new UnsupportedArgumentError(`criteria-path:${key}`)
177
353
  }
178
- conditions.push(sql`${columnRef(table, column)} #>> ${`{${path.join(',')}}`} = ${`${raw}`}`)
354
+ conditions.push(...pathCondition(pathTarget(table, column, path), raw, key))
179
355
  continue
180
356
  }
181
357
 
@@ -184,7 +360,7 @@ const build = (
184
360
  continue
185
361
  }
186
362
  if (isOperatorSpec(raw)) {
187
- conditions.push(...operators(table, column, raw as Record<string, unknown>))
363
+ conditions.push(...operators(table, column, raw))
188
364
  continue
189
365
  }
190
366
  if (Array.isArray(raw)) {
@@ -206,24 +382,27 @@ const build = (
206
382
  }
207
383
 
208
384
  /**
209
- * Translate `ListPager.sort` into ORDER BY. `[field, true]` is descending, matching the
210
- * `order ? -1 : 1` mapping mongo uses.
385
+ * Translate a {@link Sort} list into ORDER BY. A bare field name is ascending;
386
+ * `{ field, order: 'desc' }` reverses it — the same meaning every backend gives it.
211
387
  *
212
388
  * The primary key is always appended as a tiebreak. Postgres has no implicit row order, so
213
389
  * paginating on a non-unique sort key silently duplicates and skips rows between pages —
214
390
  * a difference from mongo that would otherwise surface as a data bug rather than an error.
215
391
  *
392
+ * A dotted path is a column this table does not have: criteria can reach into jsonb, ORDER BY
393
+ * cannot, and refusing is better than ordering by something the caller did not name.
394
+ *
216
395
  * @throws {UnsupportedArgumentError}
217
396
  */
218
- export const sortToSql = (
219
- sort: ListSort[] | undefined, spec: TableSpec, table: PgRuntimeTable
397
+ export const sortToSql = <T>(
398
+ sort: Sort<T>[] | undefined, spec: TableSpec, table: PgRuntimeTable
220
399
  ): SQL[] => {
221
400
  const order: SQL[] = []
222
401
  const seen: string[] = []
223
402
 
224
403
  for (const entry of sort ?? []) {
225
- /** Both `ListSort` forms are handled explicitly — destructuring a plain string yields characters. */
226
- const [property, descending] = typeof entry === 'string' ? [entry, false] : [entry[0], entry[1] === true]
404
+ const property = typeof entry === 'string' ? entry : entry.field
405
+ const descending = typeof entry !== 'string' && entry.order === 'desc'
227
406
  const column = spec.byProperty[property]
228
407
  if (column == null) {
229
408
  throw new UnsupportedArgumentError(`sort:${property}`)
package/src/utils/diff.ts CHANGED
@@ -23,19 +23,44 @@ const defaultExpression = (column: ColumnSpec): string | undefined => {
23
23
  return undefined
24
24
  }
25
25
 
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
28
+
26
29
  /**
27
- * Postgres echoes defaults back with an explicit cast — a declared `'active'` comes back
28
- * as `'active'::text`. Strip a trailing cast when the literal underneath still matches, so
29
- * an unchanged default doesn't look like drift on every boot.
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.
30
33
  */
31
34
  const normalizeDefault = (expression: string | null | undefined): string | null => {
32
35
  if (expression == null) return null
33
36
 
34
- return expression.trim().replace(/::[a-z0-9_ ."\[\]]+$/i, '').trim().toLowerCase()
37
+ /**
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.
40
+ */
41
+ return expression.toLowerCase().replace(CAST, '').replace(/[()]/g, '').replace(/\s+/g, ' ').trim()
35
42
  }
36
43
 
37
- const normalizeDefinition = (definition: string): string =>
38
- definition.replace(/\s+/g, ' ').replace(/ ?, ?/g, ',').trim().toLowerCase()
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
39
64
 
40
65
  const columnList = (columns: string[]): string => columns.map(quoteIdent).join(', ')
41
66
 
@@ -194,7 +219,31 @@ export const planSync = (
194
219
  continue
195
220
  }
196
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
197
238
  if (!additive && existing.type !== column.sqlType) {
239
+ if (existing.defaultExpr != null) {
240
+ statements.push({
241
+ kind: 'set-default',
242
+ target: column.column,
243
+ sql: `ALTER TABLE ${spec.qualified} ALTER COLUMN ${quoteIdent(column.column)} DROP DEFAULT`
244
+ })
245
+ defaultDropped = true
246
+ }
198
247
  const using = column.using ?? `${quoteIdent(column.column)}::${column.sqlType}`
199
248
  statements.push({
200
249
  kind: 'alter-type',
@@ -205,7 +254,13 @@ export const planSync = (
205
254
  }
206
255
 
207
256
  const expression = defaultExpression(column)
208
- if (normalizeDefault(expression) !== normalizeDefault(existing.defaultExpr)) {
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) {
209
264
  statements.push(expression != null
210
265
  ? {
211
266
  kind: 'set-default',
@@ -15,29 +15,32 @@ export const makeTx = (
15
15
  client,
16
16
 
17
17
  query: async <Row extends QueryResultRow = QueryResultRow>(text: string, params?: unknown[]) => {
18
+ const query = resolve(text)
18
19
  try {
19
- const result = await client.query<Row>(resolve(text), params as never[])
20
+ const result = await client.query<Row>(query, params as never[])
20
21
  return result.rows
21
22
  } catch (error) {
22
- throw pgErrorToResourceError(error)
23
+ throw pgErrorToResourceError(error, query)
23
24
  }
24
25
  },
25
26
 
26
27
  queryOne: async <Row extends QueryResultRow = QueryResultRow>(text: string, params?: unknown[]) => {
28
+ const query = resolve(text)
27
29
  try {
28
- const result = await client.query<Row>(resolve(text), params as never[])
30
+ const result = await client.query<Row>(query, params as never[])
29
31
  return result.rows[0] ?? null
30
32
  } catch (error) {
31
- throw pgErrorToResourceError(error)
33
+ throw pgErrorToResourceError(error, query)
32
34
  }
33
35
  },
34
36
 
35
37
  execute: async (text: string, params?: unknown[]) => {
38
+ const query = resolve(text)
36
39
  try {
37
- const result = await client.query(resolve(text), params as never[])
40
+ const result = await client.query(query, params as never[])
38
41
  return result.rowCount ?? 0
39
42
  } catch (error) {
40
- throw pgErrorToResourceError(error)
43
+ throw pgErrorToResourceError(error, query)
41
44
  }
42
45
  },
43
46
 
@@ -47,8 +50,8 @@ export const makeTx = (
47
50
  /**
48
51
  * Migration ledger, one table per Postgres schema.
49
52
  *
50
- * It lives inside the resource's own schema, so an Entity layer schema carries its own
51
- * ledger — correct, because it also carries its own tables.
53
+ * It lives inside the resource's own schema, so every schema carries the ledger for the
54
+ * tables it holds — correct, because those tables are what the migrations changed.
52
55
  */
53
56
  export const makeMigrationStore = (
54
57
  client: PoolClient,
package/src/utils/name.ts CHANGED
@@ -10,9 +10,9 @@ const SAFE_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_$]*$/
10
10
  /**
11
11
  * Coerce an arbitrary name into a legal Postgres identifier of at most 63 bytes.
12
12
  *
13
- * The shared `dbName()` helper's own overflow fallback emits 96 characters, which the
14
- * server would silently truncate — losing the hash suffix that made the name unique in
15
- * the first place. Truncating here keeps the disambiguator inside the limit.
13
+ * Postgres cuts anything past `NAMEDATALEN - 1` off server side and says nothing about
14
+ * it, so two names that differ only beyond byte 63 would collapse into one identifier.
15
+ * Clamping here instead leaves room for a hash suffix that keeps them apart.
16
16
  */
17
17
  export const pgIdentifier = (name: string): string => {
18
18
  const sanitized = name.replace(/[^A-Za-z0-9_$]/g, '_')
package/src/utils/sql.ts CHANGED
Binary file
@@ -24,6 +24,10 @@ const pgRaw = customType<{ data: unknown, driverData: unknown, config: { sqlType
24
24
  * Build the Drizzle table used for CRUD query construction. Structure DDL is emitted from
25
25
  * the {@link TableSpec} directly — Drizzle exposes no runtime DDL generator (that lives in
26
26
  * drizzle-kit, which would be a second owner of the same tables).
27
+ *
28
+ * The result is stated as a {@link PgRuntimeTable}: the columns exist, keyed by schema
29
+ * property name, but only this function knows which ones — Drizzle's own inference has a
30
+ * literal column map to work from, and here that map was assembled a few lines ago.
27
31
  */
28
32
  export const specToTable = (spec: TableSpec): PgRuntimeTable => {
29
33
  const namespace = pgSchema(spec.schema)
@@ -72,5 +76,5 @@ export const specToTable = (spec: TableSpec): PgRuntimeTable => {
72
76
  .filter((column: unknown) => column != null) as [any, ...any[]]
73
77
  })]
74
78
  : [])
75
- ]) as PgRuntimeTable
79
+ ]) as unknown as PgRuntimeTable
76
80
  }