@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
@@ -0,0 +1,237 @@
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, compared as typed JSON', () => {
123
+ const clause = where({ 'profile.city': 'Kyiv' })
124
+
125
+ /** The path is a bound text[] and the value a bound jsonb literal — neither is statement text. */
126
+ expect(clause.sql).toContain('#> $1::text[]')
127
+ expect(clause.sql).toContain('$2::jsonb')
128
+ expect(clause.params).toEqual([['city'], '"Kyiv"'])
129
+
130
+ /** A number or a boolean is compared as itself, never as its text. */
131
+ expect(where({ 'profile.age': 30 } as Criteria<Thing>).params).toEqual([['age'], '30'])
132
+ expect(where({ 'profile.verified': true } as Criteria<Thing>).params).toEqual([['verified'], 'true'])
133
+ })
134
+
135
+ test('reads a dotted array as membership, null as absence, and operators as they read on a column', () => {
136
+ const list = where({ 'profile.tier': ['gold', 'silver'] } as Criteria<Thing>)
137
+ expect(list.sql).toContain('= ANY(ARRAY[')
138
+ expect(list.sql).toContain(']::jsonb[])')
139
+ expect(list.params).toEqual([['tier'], '"gold"', '"silver"'])
140
+
141
+ const absent = where({ 'profile.tier': null } as Criteria<Thing>)
142
+ expect(absent.sql).toContain('IS NULL')
143
+ expect(absent.sql).toContain(`'null'::jsonb`)
144
+
145
+ const widened = where({ 'profile.tier': { $in: ['gold', null] } } as Criteria<Thing>)
146
+ expect(widened.sql).toContain('= ANY(ARRAY[')
147
+ expect(widened.sql).toContain('IS NULL')
148
+
149
+ expect(where({ 'profile.age': { $gte: 18 } } as Criteria<Thing>).sql).toContain('>= $2::jsonb')
150
+ expect(where({ 'profile.city': { $ilike: 'ky%' } } as Criteria<Thing>).sql).toContain('#>> $1::text[]) ILIKE')
151
+ expect(where({ 'profile.city': { $exists: true } } as Criteria<Thing>).sql).toMatch(/^NOT \(.* IS NULL OR .* = 'null'::jsonb\)$/)
152
+ expect(where({ 'profile.tags': { $contains: 'a' } } as Criteria<Thing>).params).toEqual([['tags'], '["a"]'])
153
+ /** A path segment holding a comma or a brace stays one segment. */
154
+ expect(where({ 'profile.a,b': 1 } as Criteria<Thing>).params).toEqual([['a,b'], '1'])
155
+ })
156
+
157
+ test('binds an array operand of a native array column as ONE array parameter, wrapping a scalar', () => {
158
+ const scalar = where({ tags: { $contains: 'rare' } })
159
+ expect(scalar.sql).toContain('"tags" @> $1::text[]')
160
+ expect(scalar.params).toEqual([['rare']])
161
+
162
+ const many = where({ tags: { $overlaps: ['rare', 'new'] } })
163
+ expect(many.sql).toContain('"tags" && $1::text[]')
164
+ expect(many.params).toEqual([['rare', 'new']])
165
+
166
+ expect(where({ tags: { $contained: ['rare', 'new'] } }).sql).toContain('<@ $1::text[]')
167
+ })
168
+
169
+ test('reads jsonb array operators as JSON containment, and overlap element by element', () => {
170
+ const contains = where({ profile: { $contains: 'rare' } })
171
+ expect(contains.sql).toContain('"profile" @> $1::jsonb')
172
+ expect(contains.params).toEqual(['["rare"]'])
173
+
174
+ const overlaps = where({ profile: { $overlaps: ['rare', 2] } })
175
+ expect(overlaps.sql).toContain('jsonb_array_elements')
176
+ expect(overlaps.params).toEqual(['"rare"', '2', '"rare"', '2'])
177
+ })
178
+
179
+ test('matches a jsonb column against a whole object with containment', () => {
180
+ const bare = where({ profile: { city: 'Kyiv' } })
181
+ expect(bare.sql).toContain('@>')
182
+ /** A jsonb operand is stringified on the way out; the driver would send an object as text. */
183
+ expect(bare.params).toEqual(['{"city":"Kyiv"}'])
184
+
185
+ expect(where({ profile: { $contains: { city: 'Kyiv' } } }).sql).toContain('@>')
186
+ })
187
+
188
+ /** An unset filter must not empty the list it filters. */
189
+ test('skips an undefined value and answers nothing at all for an empty criteria', () => {
190
+ expect(criteriaToSql({ status: undefined }, spec, table)).toBeUndefined()
191
+ expect(criteriaToSql({}, spec, table)).toBeUndefined()
192
+ expect(criteriaToSql(undefined, spec, table)).toBeUndefined()
193
+ })
194
+
195
+ /** A typo silently widening a query to the whole table is worth being loud about. */
196
+ test('refuses an unknown key, an unknown operator and a path into a plain column', () => {
197
+ expect(() => criteriaToSql({ nope: 'x' } as unknown as Criteria<Thing>, spec, table))
198
+ .toThrow(UnsupportedArgumentError)
199
+ expect(() => criteriaToSql({ status: { $near: 'x' } } as unknown as Criteria<Thing>, spec, table))
200
+ .toThrow(UnsupportedArgumentError)
201
+ expect(() => criteriaToSql({ 'email.local': 'a' }, spec, table))
202
+ .toThrow(UnsupportedArgumentError)
203
+ })
204
+ })
205
+
206
+ describe('@owlmeans/postgres-resource — sortToSql', () => {
207
+ test('sorts a bare field name ascending', () => {
208
+ expect(order(['email'])).toContain('"email_address" asc')
209
+ })
210
+
211
+ test('reverses on order: desc', () => {
212
+ expect(order([{ field: 'email', order: 'desc' }])).toContain('"email_address" desc')
213
+ /** `{ field }` alone means the same as the bare name. */
214
+ expect(order([{ field: 'email' }])).toContain('"email_address" asc')
215
+ })
216
+
217
+ /**
218
+ * Postgres has no implicit row order, so paginating on a non-unique key would silently
219
+ * duplicate and skip rows between pages.
220
+ */
221
+ test('appends the primary key as a tiebreak, exactly once', () => {
222
+ const byStatus = order(['status'])
223
+ expect(byStatus).toContain('"status" asc')
224
+ expect(byStatus.split('"id" asc')).toHaveLength(2)
225
+
226
+ expect(order()).toContain('"id" asc')
227
+ /** Naming the key itself must not order by it twice. */
228
+ expect(order(['id']).split('"id" asc')).toHaveLength(2)
229
+ })
230
+
231
+ test('refuses a field the table does not have', () => {
232
+ expect(() => sortToSql(['nope'] as unknown as Sort<Thing>[], spec, table))
233
+ .toThrow(UnsupportedArgumentError)
234
+ /** ORDER BY cannot reach into jsonb the way a criteria path can. */
235
+ expect(() => sortToSql(['profile.city'], spec, table)).toThrow(UnsupportedArgumentError)
236
+ })
237
+ })
@@ -0,0 +1,111 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+
3
+ import { canonicalDefinition, 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
+ })
61
+
62
+ /**
63
+ * What Postgres reports (`pg_indexes.indexdef`, `pg_get_constraintdef`) for objects created from the
64
+ * statements the plan emits — captured from a live server. Each pair must compare equal, or every
65
+ * boot drops and recreates the object.
66
+ */
67
+ const RENDERED: Array<[string, string]> = [
68
+ ['CREATE INDEX "t_a" ON "app"."t" USING btree ("garden", "season")', 'CREATE INDEX t_a ON app.t USING btree (garden, season)'],
69
+ ['CREATE UNIQUE INDEX "t_b" ON "app"."t" USING btree ("garden", COALESCE("bed", \'\'), "season")',
70
+ 'CREATE UNIQUE INDEX t_b ON app.t USING btree (garden, COALESCE(bed, \'\'::text), season)'],
71
+ ['CREATE INDEX "t_c" ON "app"."t" USING btree ("garden", "crop") WHERE "crop" IS NOT NULL',
72
+ 'CREATE INDEX t_c ON app.t USING btree (garden, crop) WHERE (crop IS NOT NULL)'],
73
+ ['CREATE INDEX "t_d" ON "app"."t" USING btree ("garden") WHERE ("soil"->>\'state\') = \'wet\'',
74
+ 'CREATE INDEX t_d ON app.t USING btree (garden) WHERE ((soil ->> \'state\'::text) = \'wet\'::text)'],
75
+ ['CREATE INDEX "t_f" ON "app"."t" USING btree ("entityId", "crop") WHERE "kind" = \'a\'',
76
+ 'CREATE INDEX t_f ON app.t USING btree ("entityId", crop) WHERE (kind = \'a\'::text)'],
77
+ ['CREATE INDEX "t_g" ON "app"."t" USING btree (lower("crop"))', 'CREATE INDEX t_g ON app.t USING btree (lower((crop)::text))'],
78
+ ['CHECK (("season" IN (\'spring\', \'summer\')))', 'CHECK ((season = ANY (ARRAY[\'spring\'::text, \'summer\'::text])))'],
79
+ ['CHECK (("kind" IS NULL OR "kind" IN (\'a\', \'b\')))', 'CHECK (((kind IS NULL) OR (kind = ANY (ARRAY[\'a\'::text, \'b\'::text]))))'],
80
+ ]
81
+
82
+ describe('@owlmeans/postgres-resource — comparing a declared default with the live one', () => {
83
+ test('a varchar id\'s generated default, stored with its casts, is no drift', () => {
84
+ const spec = schemaToTableSpec('shelves', {
85
+ type: 'object', properties: { id: { type: 'string', maxLength: 128 } }, required: ['id'],
86
+ } as never, 'app', 'shelf', true)
87
+ const live = liveOf([columnOf({ name: 'id', type: 'character varying(128)', defaultExpr: '(gen_random_uuid())::character varying' })])
88
+
89
+ expect(planSync(spec, live).statements).toEqual([])
90
+ })
91
+ })
92
+
93
+ describe('@owlmeans/postgres-resource — comparing a declared definition with the live one', () => {
94
+ test('a definition Postgres re-rendered compares equal to the statement that created it', () => {
95
+ for (const [declared, live] of RENDERED) {
96
+ expect([declared, canonicalDefinition(declared)]).toEqual([declared, canonicalDefinition(live)])
97
+ }
98
+ })
99
+
100
+ test('a real change still differs: columns, uniqueness, method and predicate', () => {
101
+ const base = 'CREATE INDEX t_a ON app.t USING btree (garden, season)'
102
+ for (const changed of [
103
+ 'CREATE INDEX t_a ON app.t USING btree (season, garden)',
104
+ 'CREATE UNIQUE INDEX t_a ON app.t USING btree (garden, season)',
105
+ 'CREATE INDEX t_a ON app.t USING gin (garden, season)',
106
+ 'CREATE INDEX t_a ON app.t USING btree (garden, season) WHERE (season IS NOT NULL)',
107
+ ]) {
108
+ expect(canonicalDefinition(changed)).not.toBe(canonicalDefinition(base))
109
+ }
110
+ })
111
+ })
@@ -97,6 +97,17 @@ describe('@owlmeans/postgres-resource — driver error translation', () => {
97
97
  expect(pgErrorToResourceError(original)).toBe(original)
98
98
  })
99
99
 
100
+ test('preserves the executed SQL and driver cause for migration diagnostics', () => {
101
+ const original = driver({ code: '42703', message: 'column o.enquiryId does not exist' })
102
+ const query = 'UPDATE "app"."opportunity" o SET "enquiryId" = $1 WHERE o."enquiryId" IS NULL'
103
+ const failure = pgErrorToResourceError(original, query)
104
+
105
+ expect(failure).toBeInstanceOf(PostgresError)
106
+ expect(failure.message).toContain('42703')
107
+ expect(failure.message).toContain(`SQL: ${query}`)
108
+ expect(failure.cause).toBe(original)
109
+ })
110
+
100
111
  test('leaves an error that is not from the driver alone', () => {
101
112
  const plain = new Error('not postgres at all')
102
113
 
@@ -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') })