@owlmeans/postgres-resource 0.1.18-rc.3 → 0.1.18-rc.31

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 (47) 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 +82 -18
  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/resource.d.ts +2 -2
  10. package/build/resource.d.ts.map +1 -1
  11. package/build/resource.js +95 -108
  12. package/build/resource.js.map +1 -1
  13. package/build/types.d.ts +27 -19
  14. package/build/types.d.ts.map +1 -1
  15. package/build/utils/criteria.d.ts +10 -6
  16. package/build/utils/criteria.d.ts.map +1 -1
  17. package/build/utils/criteria.js +13 -5
  18. package/build/utils/criteria.js.map +1 -1
  19. package/build/utils/diff.d.ts.map +1 -1
  20. package/build/utils/diff.js +31 -1
  21. package/build/utils/diff.js.map +1 -1
  22. package/build/utils/migrations.d.ts +2 -2
  23. package/build/utils/migrations.js +2 -2
  24. package/build/utils/name.d.ts +3 -3
  25. package/build/utils/name.js +3 -3
  26. package/build/utils/sql.d.ts +10 -2
  27. package/build/utils/sql.d.ts.map +1 -1
  28. package/build/utils/sql.js.map +1 -1
  29. package/build/utils/table.d.ts +4 -0
  30. package/build/utils/table.d.ts.map +1 -1
  31. package/build/utils/table.js +4 -0
  32. package/build/utils/table.js.map +1 -1
  33. package/package.json +7 -7
  34. package/src/consts.ts +8 -1
  35. package/src/declarations.ts +5 -5
  36. package/src/resource.ts +118 -135
  37. package/src/types.ts +29 -18
  38. package/src/utils/criteria.ts +25 -17
  39. package/src/utils/diff.ts +31 -1
  40. package/src/utils/migrations.ts +2 -2
  41. package/src/utils/name.ts +3 -3
  42. package/src/utils/sql.ts +0 -0
  43. package/src/utils/table.ts +5 -1
  44. package/tests/criteria.spec.ts +187 -0
  45. package/tests/diff.spec.ts +60 -0
  46. package/tests/name.spec.ts +3 -3
  47. package/tests/sql.spec.ts +4 -3
@@ -1,5 +1,5 @@
1
1
  import { UnsupportedArgumentError } from '@owlmeans/resource'
2
- import type { ListCriteria, ListSort } from '@owlmeans/resource'
2
+ import type { Criteria, FieldOperators, Sort } from '@owlmeans/resource'
3
3
  import { and, asc, desc, or, sql } from 'drizzle-orm'
4
4
  import type { SQL } from 'drizzle-orm'
5
5
 
@@ -48,7 +48,7 @@ const inList = (table: PgRuntimeTable, column: ColumnSpec, values: unknown[], ne
48
48
  }
49
49
 
50
50
  const operators = (
51
- table: PgRuntimeTable, column: ColumnSpec, spec: Record<string, unknown>
51
+ table: PgRuntimeTable, column: ColumnSpec, spec: FieldOperators<any>
52
52
  ): SQL[] => {
53
53
  const conditions: SQL[] = []
54
54
  for (const [operator, operand] of Object.entries(spec)) {
@@ -116,28 +116,33 @@ const operators = (
116
116
 
117
117
  const escapeLike = (value: string): string => value.replace(/[\\%_]/g, match => `\\${match}`)
118
118
 
119
- const isOperatorSpec = (value: unknown): value is Record<string, unknown> =>
119
+ /**
120
+ * An object naming at least one `$` key is a spec, not a value to compare against.
121
+ * A Date and an array are values even though both are objects.
122
+ */
123
+ const isOperatorSpec = (value: unknown): value is FieldOperators<any> =>
120
124
  value != null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)
121
125
  && Object.keys(value).some(key => key.startsWith('$'))
122
126
 
123
127
  /**
124
- * Translate `ListCriteria` into a WHERE clause.
128
+ * Translate a {@link Criteria} into a WHERE clause. `undefined` means "no constraint at
129
+ * all" — the caller decides whether that is a full scan or a refusal.
125
130
  *
126
131
  * An unknown key raises rather than being skipped: a typo silently widening a query to
127
132
  * the whole table is the failure mode worth being loud about.
128
133
  *
129
134
  * @throws {UnsupportedArgumentError}
130
135
  */
131
- export const criteriaToSql = (
132
- criteria: ListCriteria | undefined, spec: TableSpec, table: PgRuntimeTable
136
+ export const criteriaToSql = <T>(
137
+ criteria: Criteria<T> | undefined, spec: TableSpec, table: PgRuntimeTable
133
138
  ): SQL | undefined => {
134
139
  const conditions = build(criteria, spec, table)
135
140
 
136
141
  return conditions.length > 0 ? and(...conditions) : undefined
137
142
  }
138
143
 
139
- const build = (
140
- criteria: ListCriteria | undefined, spec: TableSpec, table: PgRuntimeTable
144
+ const build = <T>(
145
+ criteria: Criteria<T> | undefined, spec: TableSpec, table: PgRuntimeTable
141
146
  ): SQL[] => {
142
147
  const conditions: SQL[] = []
143
148
  for (const [key, raw] of Object.entries(criteria ?? {})) {
@@ -147,7 +152,7 @@ const build = (
147
152
 
148
153
  if (key === '$and' || key === '$or') {
149
154
  const parts = (Array.isArray(raw) ? raw : [raw])
150
- .map(entry => and(...build(entry as ListCriteria, spec, table)))
155
+ .map(entry => and(...build(entry as Criteria<T>, spec, table)))
151
156
  .filter((entry): entry is SQL => entry != null)
152
157
  if (parts.length > 0) {
153
158
  const combined = key === '$and' ? and(...parts) : or(...parts)
@@ -158,7 +163,7 @@ const build = (
158
163
  continue
159
164
  }
160
165
  if (key === '$not') {
161
- const inner = and(...build(raw as ListCriteria, spec, table))
166
+ const inner = and(...build(raw as Criteria<T>, spec, table))
162
167
  if (inner != null) {
163
168
  conditions.push(sql`NOT (${inner})`)
164
169
  }
@@ -184,7 +189,7 @@ const build = (
184
189
  continue
185
190
  }
186
191
  if (isOperatorSpec(raw)) {
187
- conditions.push(...operators(table, column, raw as Record<string, unknown>))
192
+ conditions.push(...operators(table, column, raw))
188
193
  continue
189
194
  }
190
195
  if (Array.isArray(raw)) {
@@ -206,24 +211,27 @@ const build = (
206
211
  }
207
212
 
208
213
  /**
209
- * Translate `ListPager.sort` into ORDER BY. `[field, true]` is descending, matching the
210
- * `order ? -1 : 1` mapping mongo uses.
214
+ * Translate a {@link Sort} list into ORDER BY. A bare field name is ascending;
215
+ * `{ field, order: 'desc' }` reverses it — the same meaning every backend gives it.
211
216
  *
212
217
  * The primary key is always appended as a tiebreak. Postgres has no implicit row order, so
213
218
  * paginating on a non-unique sort key silently duplicates and skips rows between pages —
214
219
  * a difference from mongo that would otherwise surface as a data bug rather than an error.
215
220
  *
221
+ * A dotted path is a column this table does not have: criteria can reach into jsonb, ORDER BY
222
+ * cannot, and refusing is better than ordering by something the caller did not name.
223
+ *
216
224
  * @throws {UnsupportedArgumentError}
217
225
  */
218
- export const sortToSql = (
219
- sort: ListSort[] | undefined, spec: TableSpec, table: PgRuntimeTable
226
+ export const sortToSql = <T>(
227
+ sort: Sort<T>[] | undefined, spec: TableSpec, table: PgRuntimeTable
220
228
  ): SQL[] => {
221
229
  const order: SQL[] = []
222
230
  const seen: string[] = []
223
231
 
224
232
  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]
233
+ const property = typeof entry === 'string' ? entry : entry.field
234
+ const descending = typeof entry !== 'string' && entry.order === 'desc'
227
235
  const column = spec.byProperty[property]
228
236
  if (column == null) {
229
237
  throw new UnsupportedArgumentError(`sort:${property}`)
package/src/utils/diff.ts CHANGED
@@ -194,7 +194,31 @@ export const planSync = (
194
194
  continue
195
195
  }
196
196
 
197
+ /**
198
+ * A retype drops the column's default first.
199
+ *
200
+ * Postgres refuses `ALTER COLUMN … TYPE` outright when the column carries a DEFAULT it cannot
201
+ * cast to the new type — `42804 default for column "x" cannot be cast automatically` — and it
202
+ * refuses it before looking at a single row, so the `USING` clause never gets a chance to
203
+ * help. The whole plan runs in one transaction, so that refusal aborts the entire
204
+ * reconciliation: the table keeps its old shape and the resource fails every boot with an
205
+ * error about a column it is trying to fix. The standard remedy is to take the default out of
206
+ * the way, retype, and put it back — which is what the default-drift block below does, from
207
+ * the spec rather than from whatever the column happened to be carrying.
208
+ *
209
+ * The `id` column is the one that meets this in practice: it is created with a
210
+ * `gen_random_uuid()::text` default, so any change to its declared type hits this path.
211
+ */
212
+ let defaultDropped = false
197
213
  if (!additive && existing.type !== column.sqlType) {
214
+ if (existing.defaultExpr != null) {
215
+ statements.push({
216
+ kind: 'set-default',
217
+ target: column.column,
218
+ sql: `ALTER TABLE ${spec.qualified} ALTER COLUMN ${quoteIdent(column.column)} DROP DEFAULT`
219
+ })
220
+ defaultDropped = true
221
+ }
198
222
  const using = column.using ?? `${quoteIdent(column.column)}::${column.sqlType}`
199
223
  statements.push({
200
224
  kind: 'alter-type',
@@ -205,7 +229,13 @@ export const planSync = (
205
229
  }
206
230
 
207
231
  const expression = defaultExpression(column)
208
- if (normalizeDefault(expression) !== normalizeDefault(existing.defaultExpr)) {
232
+ // Once the retype has dropped it, the default has to be re-stated even when it matches what
233
+ // the column used to carry: the comparison is against the pre-plan introspection, which stops
234
+ // describing the column the moment the plan drops its default.
235
+ const defaultDrifted = defaultDropped
236
+ ? expression != null
237
+ : normalizeDefault(expression) !== normalizeDefault(existing.defaultExpr)
238
+ if (defaultDrifted) {
209
239
  statements.push(expression != null
210
240
  ? {
211
241
  kind: 'set-default',
@@ -47,8 +47,8 @@ export const makeTx = (
47
47
  /**
48
48
  * Migration ledger, one table per Postgres schema.
49
49
  *
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.
50
+ * It lives inside the resource's own schema, so every schema carries the ledger for the
51
+ * tables it holds — correct, because those tables are what the migrations changed.
52
52
  */
53
53
  export const makeMigrationStore = (
54
54
  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
  }
@@ -0,0 +1,187 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { UnsupportedArgumentError } from '@owlmeans/resource'
3
+ import type { Criteria, Sort } from '@owlmeans/resource'
4
+ import type { AnySchema } from 'ajv'
5
+ import { sql } from 'drizzle-orm'
6
+ import type { SQL } from 'drizzle-orm'
7
+ import { PgDialect } from 'drizzle-orm/pg-core'
8
+
9
+ import { criteriaToSql, schemaToTableSpec, sortToSql, specToTable } from '@owlmeans/postgres-resource'
10
+ import type { PgRuntimeTable, TableSpec } from '@owlmeans/postgres-resource'
11
+
12
+ /**
13
+ * Criteria translation is where a typo becomes either an error or a whole-table scan, and
14
+ * where a value has to stay a bound parameter instead of becoming statement text. Neither
15
+ * question needs a database: the dialect renders a clause exactly as the driver would
16
+ * receive it, so these specs assert on that rendering rather than on query results.
17
+ */
18
+
19
+ interface Thing {
20
+ id?: string
21
+ email: string
22
+ status?: string
23
+ age?: number
24
+ tags?: string[]
25
+ profile?: Record<string, unknown>
26
+ }
27
+
28
+ /** `email` is renamed on purpose — a clause must name the physical column, never the property. */
29
+ const schema = {
30
+ type: 'object',
31
+ properties: {
32
+ id: { type: 'string', format: 'uuid' },
33
+ email: { type: 'string', pg: { column: 'email_address' } },
34
+ status: { type: 'string', nullable: true },
35
+ age: { type: 'integer', nullable: true },
36
+ tags: { type: 'array', items: { type: 'string' }, nullable: true },
37
+ profile: { type: 'object', nullable: true }
38
+ },
39
+ required: ['id', 'email']
40
+ } as AnySchema
41
+
42
+ const spec: TableSpec = schemaToTableSpec('things', schema, 'app', 'things', true)
43
+ const table: PgRuntimeTable = specToTable(spec)
44
+ const dialect = new PgDialect()
45
+
46
+ const render = (statement: SQL): { sql: string, params: unknown[] } => {
47
+ const query = dialect.sqlToQuery(statement)
48
+
49
+ return { sql: query.sql, params: query.params }
50
+ }
51
+
52
+ const where = (criteria: Criteria<Thing>): { sql: string, params: unknown[] } => {
53
+ const clause = criteriaToSql(criteria, spec, table)
54
+ expect(clause).not.toBeUndefined()
55
+
56
+ return render(clause as SQL)
57
+ }
58
+
59
+ const order = (sort?: Sort<Thing>[]): string =>
60
+ render(sql.join(sortToSql(sort, spec, table), sql`, `)).sql
61
+
62
+ describe('@owlmeans/postgres-resource — criteriaToSql', () => {
63
+ test('reads a bare value as equality, against the physical column', () => {
64
+ const clause = where({ email: 'a@b.c' })
65
+
66
+ expect(clause.sql).toContain('"email_address"')
67
+ expect(clause.sql).toContain('=')
68
+ /** The value never reaches the statement text. */
69
+ expect(clause.sql).not.toContain('a@b.c')
70
+ expect(clause.params).toEqual(['a@b.c'])
71
+ })
72
+
73
+ test('reads a bare array as membership and null as absence', () => {
74
+ const list = where({ status: ['open', 'done'] })
75
+ expect(list.sql).toContain('IN')
76
+ expect(list.params).toEqual(['open', 'done'])
77
+
78
+ expect(where({ status: null }).sql).toContain('IS NULL')
79
+ })
80
+
81
+ /** `IN` never matches NULL, so a list carrying one has to be widened explicitly. */
82
+ test('widens a membership list that carries a null', () => {
83
+ const clause = where({ status: { $in: ['open', null] } })
84
+
85
+ expect(clause.sql).toContain('IN')
86
+ expect(clause.sql).toContain('IS NULL')
87
+ expect(clause.params).toEqual(['open'])
88
+ })
89
+
90
+ /** Postgres rejects an empty `IN ()`, and an empty set matches nothing — or everything, negated. */
91
+ test('collapses a membership list Postgres would reject', () => {
92
+ expect(where({ status: { $in: [] } }).sql).toContain('FALSE')
93
+ expect(where({ status: { $nin: [] } }).sql).toContain('TRUE')
94
+ })
95
+
96
+ test('translates the comparison and text operators', () => {
97
+ expect(where({ age: { $gte: 18 } }).params).toEqual([18])
98
+ expect(where({ age: { $between: [18, 65] } }).sql).toContain('BETWEEN')
99
+ expect(where({ email: { $ilike: '%@b.c' } }).sql).toContain('ILIKE')
100
+ expect(where({ email: { $regex: '^a' } }).sql).toContain('~')
101
+ expect(where({ status: { $exists: false } }).sql).toContain('IS NULL')
102
+ expect(where({ status: { $null: false } }).sql).toContain('IS NOT NULL')
103
+ })
104
+
105
+ /** A wildcard inside the operand is data, not a pattern the caller wrote. */
106
+ test('escapes the LIKE wildcards in startsWith and endsWith', () => {
107
+ expect(where({ email: { $startsWith: 'a_b' } }).params).toEqual(['a\\_b%'])
108
+ expect(where({ email: { $endsWith: '100%' } }).params).toEqual(['%100\\%'])
109
+ })
110
+
111
+ test('composes with $or and $not', () => {
112
+ const clause = where({
113
+ $or: [{ status: 'open' }, { age: { $lt: 18 } }],
114
+ $not: { email: 'a@b.c' }
115
+ })
116
+
117
+ expect(clause.sql).toContain(' or ')
118
+ expect(clause.sql).toContain('NOT')
119
+ expect(clause.params).toEqual(['open', 18, 'a@b.c'])
120
+ })
121
+
122
+ test('reaches into a jsonb column through a dotted path', () => {
123
+ const clause = where({ 'profile.city': 'Kyiv' })
124
+
125
+ expect(clause.sql).toContain('#>>')
126
+ expect(clause.params).toEqual(['{city}', 'Kyiv'])
127
+ })
128
+
129
+ test('matches a jsonb column against a whole object with containment', () => {
130
+ const bare = where({ profile: { city: 'Kyiv' } })
131
+ expect(bare.sql).toContain('@>')
132
+ /** A jsonb operand is stringified on the way out; the driver would send an object as text. */
133
+ expect(bare.params).toEqual(['{"city":"Kyiv"}'])
134
+
135
+ expect(where({ profile: { $contains: { city: 'Kyiv' } } }).sql).toContain('@>')
136
+ })
137
+
138
+ /** An unset filter must not empty the list it filters. */
139
+ test('skips an undefined value and answers nothing at all for an empty criteria', () => {
140
+ expect(criteriaToSql({ status: undefined }, spec, table)).toBeUndefined()
141
+ expect(criteriaToSql({}, spec, table)).toBeUndefined()
142
+ expect(criteriaToSql(undefined, spec, table)).toBeUndefined()
143
+ })
144
+
145
+ /** A typo silently widening a query to the whole table is worth being loud about. */
146
+ test('refuses an unknown key, an unknown operator and a path into a plain column', () => {
147
+ expect(() => criteriaToSql({ nope: 'x' } as unknown as Criteria<Thing>, spec, table))
148
+ .toThrow(UnsupportedArgumentError)
149
+ expect(() => criteriaToSql({ status: { $near: 'x' } } as unknown as Criteria<Thing>, spec, table))
150
+ .toThrow(UnsupportedArgumentError)
151
+ expect(() => criteriaToSql({ 'email.local': 'a' }, spec, table))
152
+ .toThrow(UnsupportedArgumentError)
153
+ })
154
+ })
155
+
156
+ describe('@owlmeans/postgres-resource — sortToSql', () => {
157
+ test('sorts a bare field name ascending', () => {
158
+ expect(order(['email'])).toContain('"email_address" asc')
159
+ })
160
+
161
+ test('reverses on order: desc', () => {
162
+ expect(order([{ field: 'email', order: 'desc' }])).toContain('"email_address" desc')
163
+ /** `{ field }` alone means the same as the bare name. */
164
+ expect(order([{ field: 'email' }])).toContain('"email_address" asc')
165
+ })
166
+
167
+ /**
168
+ * Postgres has no implicit row order, so paginating on a non-unique key would silently
169
+ * duplicate and skip rows between pages.
170
+ */
171
+ test('appends the primary key as a tiebreak, exactly once', () => {
172
+ const byStatus = order(['status'])
173
+ expect(byStatus).toContain('"status" asc')
174
+ expect(byStatus.split('"id" asc')).toHaveLength(2)
175
+
176
+ expect(order()).toContain('"id" asc')
177
+ /** Naming the key itself must not order by it twice. */
178
+ expect(order(['id']).split('"id" asc')).toHaveLength(2)
179
+ })
180
+
181
+ test('refuses a field the table does not have', () => {
182
+ expect(() => sortToSql(['nope'] as unknown as Sort<Thing>[], spec, table))
183
+ .toThrow(UnsupportedArgumentError)
184
+ /** ORDER BY cannot reach into jsonb the way a criteria path can. */
185
+ expect(() => sortToSql(['profile.city'], spec, table)).toThrow(UnsupportedArgumentError)
186
+ })
187
+ })
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { planSync, schemaToTableSpec } from '@owlmeans/postgres-resource'
4
+ import type { LiveColumn, LiveTable, TableSpec } from '@owlmeans/postgres-resource'
5
+
6
+ const specOf = (idType: 'string' | 'integer'): TableSpec =>
7
+ schemaToTableSpec('tasks', {
8
+ type: 'object',
9
+ properties: {
10
+ id: idType === 'string' ? { type: 'string' } : { type: 'integer' },
11
+ title: { type: 'string' },
12
+ },
13
+ required: ['id'],
14
+ } as never, 'app', 'task_entry', true)
15
+
16
+ const columnOf = (over: Partial<LiveColumn> & { name: string, type: string }): LiveColumn => ({
17
+ notNull: true, defaultExpr: null, identity: '', generated: '', ordinal: 1, ...over,
18
+ })
19
+
20
+ const liveOf = (columns: LiveColumn[]): LiveTable =>
21
+ ({ exists: true, columns, indexes: [], constraints: [] })
22
+
23
+ const kinds = (spec: TableSpec, live: LiveTable): string[] =>
24
+ planSync(spec, live).statements
25
+ .filter(statement => statement.target === 'id')
26
+ .map(statement => `${statement.kind}:${statement.sql.includes('DROP DEFAULT') ? 'drop' : statement.sql.includes('SET DEFAULT') ? 'set' : ''}`)
27
+
28
+ describe('@owlmeans/postgres-resource — retyping a column that carries a default', () => {
29
+ test('drops the default before the type change and restores it after', () => {
30
+ // Postgres refuses `ALTER COLUMN … TYPE` while the column has a DEFAULT it cannot cast, and
31
+ // the whole plan is one transaction — so without the drop the entire reconciliation aborts and
32
+ // the resource fails every boot.
33
+ const live = liveOf([
34
+ columnOf({ name: 'id', type: 'integer', defaultExpr: `gen_random_uuid()::text` }),
35
+ columnOf({ name: 'title', type: 'text', notNull: false }),
36
+ ])
37
+
38
+ expect(kinds(specOf('string'), live)).toEqual(['set-default:drop', 'alter-type:', 'set-default:set'])
39
+ })
40
+
41
+ test('a retype of a column without a default emits no default statements', () => {
42
+ const live = liveOf([
43
+ columnOf({ name: 'id', type: 'integer', defaultExpr: null }),
44
+ columnOf({ name: 'title', type: 'text', notNull: false }),
45
+ ])
46
+
47
+ expect(kinds(specOf('string'), live)).toEqual(['alter-type:', 'set-default:set'])
48
+ })
49
+
50
+ test('a column already on the right type keeps its default untouched', () => {
51
+ const spec = specOf('string')
52
+ const desired = spec.columns.find(column => column.column === 'id')!
53
+ const live = liveOf([
54
+ columnOf({ name: 'id', type: desired.sqlType, defaultExpr: desired.defaultRaw ?? null }),
55
+ columnOf({ name: 'title', type: 'text', notNull: false }),
56
+ ])
57
+
58
+ expect(kinds(spec, live)).toEqual([])
59
+ })
60
+ })
@@ -19,9 +19,9 @@ describe('@owlmeans/postgres-resource — identifiers', () => {
19
19
  })
20
20
 
21
21
  /**
22
- * Postgres truncates past `NAMEDATALEN - 1` server side and says nothing. Truncating here
23
- * instead keeps the hash that made the name unique — the shared `dbName()` overflow path
24
- * appends its disambiguator past byte 63, where the server would cut it off.
22
+ * Postgres truncates past `NAMEDATALEN - 1` server side and says nothing, which would let
23
+ * two names differing only beyond byte 63 collapse into one identifier. Clamping here
24
+ * instead keeps the hash suffix that tells them apart inside the limit.
25
25
  */
26
26
  test('keeps an overlong name unique inside the 63 byte limit', () => {
27
27
  const long = 'a'.repeat(200)
package/tests/sql.spec.ts CHANGED
@@ -82,9 +82,10 @@ describe('@owlmeans/postgres-resource — placeholder resolution', () => {
82
82
  })
83
83
 
84
84
  /**
85
- * The cache is keyed by context because a layer switch rebuilds every resource against a
86
- * different Postgres schema. A service level query has no owning table to key on, so a
87
- * process wide cache would hand the next tenant the previous one's table names.
85
+ * The cache is keyed by context because a context is what owns a set of database handles:
86
+ * a different context resolves the same alias against a different Postgres schema. A
87
+ * service level query has no owning table to key on, so a process wide cache would hand
88
+ * one context the other's table names.
88
89
  */
89
90
  test('does not leak resolved table names between contexts', () => {
90
91
  const tenantA = contextOf({ users: specOf('users', 'tenant_a', 'users') })