@owlmeans/postgres-resource 0.1.15

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 (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +219 -0
  3. package/agent-meta/instructions/postgres-resource.instructions.md +60 -0
  4. package/agent-meta/manifest.json +23 -0
  5. package/agent-meta/skills/postgres-resource/SKILL.md +186 -0
  6. package/build/consts.d.ts +81 -0
  7. package/build/consts.d.ts.map +1 -0
  8. package/build/consts.js +86 -0
  9. package/build/consts.js.map +1 -0
  10. package/build/declarations.d.ts +12 -0
  11. package/build/declarations.d.ts.map +1 -0
  12. package/build/declarations.js +28 -0
  13. package/build/declarations.js.map +1 -0
  14. package/build/errors.d.ts +70 -0
  15. package/build/errors.d.ts.map +1 -0
  16. package/build/errors.js +201 -0
  17. package/build/errors.js.map +1 -0
  18. package/build/helper.d.ts +4 -0
  19. package/build/helper.d.ts.map +1 -0
  20. package/build/helper.js +5 -0
  21. package/build/helper.js.map +1 -0
  22. package/build/index.d.ts +8 -0
  23. package/build/index.d.ts.map +1 -0
  24. package/build/index.js +7 -0
  25. package/build/index.js.map +1 -0
  26. package/build/resource.d.ts +4 -0
  27. package/build/resource.d.ts.map +1 -0
  28. package/build/resource.js +395 -0
  29. package/build/resource.js.map +1 -0
  30. package/build/types.d.ts +273 -0
  31. package/build/types.d.ts.map +1 -0
  32. package/build/types.js +2 -0
  33. package/build/types.js.map +1 -0
  34. package/build/utils/criteria.d.ts +24 -0
  35. package/build/utils/criteria.d.ts.map +1 -0
  36. package/build/utils/criteria.js +209 -0
  37. package/build/utils/criteria.js.map +1 -0
  38. package/build/utils/diff.d.ts +24 -0
  39. package/build/utils/diff.d.ts.map +1 -0
  40. package/build/utils/diff.js +330 -0
  41. package/build/utils/diff.js.map +1 -0
  42. package/build/utils/index.d.ts +12 -0
  43. package/build/utils/index.d.ts.map +1 -0
  44. package/build/utils/index.js +12 -0
  45. package/build/utils/index.js.map +1 -0
  46. package/build/utils/introspect.d.ts +7 -0
  47. package/build/utils/introspect.d.ts.map +1 -0
  48. package/build/utils/introspect.js +70 -0
  49. package/build/utils/introspect.js.map +1 -0
  50. package/build/utils/life-cycle.d.ts +39 -0
  51. package/build/utils/life-cycle.d.ts.map +1 -0
  52. package/build/utils/life-cycle.js +119 -0
  53. package/build/utils/life-cycle.js.map +1 -0
  54. package/build/utils/marshal.d.ts +36 -0
  55. package/build/utils/marshal.d.ts.map +1 -0
  56. package/build/utils/marshal.js +152 -0
  57. package/build/utils/marshal.js.map +1 -0
  58. package/build/utils/migrations.d.ts +16 -0
  59. package/build/utils/migrations.d.ts.map +1 -0
  60. package/build/utils/migrations.js +108 -0
  61. package/build/utils/migrations.js.map +1 -0
  62. package/build/utils/name.d.ts +39 -0
  63. package/build/utils/name.d.ts.map +1 -0
  64. package/build/utils/name.js +63 -0
  65. package/build/utils/name.js.map +1 -0
  66. package/build/utils/schema.d.ts +28 -0
  67. package/build/utils/schema.d.ts.map +1 -0
  68. package/build/utils/schema.js +356 -0
  69. package/build/utils/schema.js.map +1 -0
  70. package/build/utils/sql.d.ts +22 -0
  71. package/build/utils/sql.d.ts.map +1 -0
  72. package/build/utils/sql.js +0 -0
  73. package/build/utils/sql.js.map +1 -0
  74. package/build/utils/sync.d.ts +21 -0
  75. package/build/utils/sync.d.ts.map +1 -0
  76. package/build/utils/sync.js +93 -0
  77. package/build/utils/sync.js.map +1 -0
  78. package/build/utils/table.d.ts +8 -0
  79. package/build/utils/table.d.ts.map +1 -0
  80. package/build/utils/table.js +68 -0
  81. package/build/utils/table.js.map +1 -0
  82. package/package.json +50 -0
  83. package/src/consts.ts +95 -0
  84. package/src/declarations.ts +41 -0
  85. package/src/errors.ts +242 -0
  86. package/src/helper.ts +7 -0
  87. package/src/index.ts +7 -0
  88. package/src/resource.ts +524 -0
  89. package/src/types.ts +310 -0
  90. package/src/utils/criteria.ts +246 -0
  91. package/src/utils/diff.ts +363 -0
  92. package/src/utils/index.ts +11 -0
  93. package/src/utils/introspect.ts +87 -0
  94. package/src/utils/life-cycle.ts +155 -0
  95. package/src/utils/marshal.ts +175 -0
  96. package/src/utils/migrations.ts +135 -0
  97. package/src/utils/name.ts +79 -0
  98. package/src/utils/schema.ts +426 -0
  99. package/src/utils/sql.ts +0 -0
  100. package/src/utils/sync.ts +106 -0
  101. package/src/utils/table.ts +76 -0
  102. package/tests/errors.spec.ts +121 -0
  103. package/tests/name.spec.ts +75 -0
  104. package/tests/schema.spec.ts +258 -0
  105. package/tests/sql.spec.ts +104 -0
  106. package/tsconfig.json +16 -0
@@ -0,0 +1,121 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { MisshapedRecord, RecordExists, ResourceError } from '@owlmeans/resource'
3
+
4
+ import {
5
+ describePgError, PgErrorCode, PostgresCastRequired, PostgresCheckError, PostgresConnectionError,
6
+ PostgresConstraintError, PostgresDeadlockError, PostgresError, PostgresForeignKeyError,
7
+ pgErrorToResourceError
8
+ } from '@owlmeans/postgres-resource'
9
+
10
+ interface DriverShape {
11
+ code: string
12
+ message?: string
13
+ constraint?: string
14
+ table?: string
15
+ column?: string
16
+ detail?: string
17
+ hint?: string
18
+ severity?: string
19
+ where?: string
20
+ }
21
+
22
+ const driver = (shape: DriverShape): Error => Object.assign(new Error(shape.message ?? 'pg'), shape)
23
+
24
+ /** What Drizzle raises: its own class, with the driver error hung off `cause`. */
25
+ const wrapped = (shape: DriverShape): Error => {
26
+ const failure = new Error(`Failed query: insert into "app"."users" ...`)
27
+ failure.cause = driver(shape)
28
+ failure.name = 'DrizzleQueryError'
29
+
30
+ return failure
31
+ }
32
+
33
+ describe('@owlmeans/postgres-resource — driver error translation', () => {
34
+ test('maps every code the resource layer promises a class for', () => {
35
+ const cases: Array<[string, Function]> = [
36
+ [PgErrorCode.UniqueViolation, RecordExists],
37
+ [PgErrorCode.NotNullViolation, MisshapedRecord],
38
+ [PgErrorCode.ForeignKeyViolation, PostgresForeignKeyError],
39
+ [PgErrorCode.CheckViolation, PostgresCheckError],
40
+ [PgErrorCode.DatatypeMismatch, PostgresCastRequired],
41
+ [PgErrorCode.CannotCoerce, PostgresCastRequired],
42
+ [PgErrorCode.DeadlockDetected, PostgresDeadlockError]
43
+ ]
44
+
45
+ for (const [code, expected] of cases) {
46
+ expect(pgErrorToResourceError(driver({ code }))).toBeInstanceOf(expected as never)
47
+ }
48
+
49
+ /** Class prefixes, for the codes with no dedicated class. */
50
+ expect(pgErrorToResourceError(driver({ code: '08006' }))).toBeInstanceOf(PostgresConnectionError)
51
+ expect(pgErrorToResourceError(driver({ code: '23P01' }))).toBeInstanceOf(PostgresConstraintError)
52
+ expect(pgErrorToResourceError(driver({ code: '42P01' }))).toBeInstanceOf(PostgresError)
53
+ })
54
+
55
+ /**
56
+ * CRUD goes through Drizzle, so the driver error always arrives wrapped. Classifying on
57
+ * the outer error collapses the whole vocabulary above into one opaque class on exactly
58
+ * the paths callers use most.
59
+ */
60
+ test('classifies a driver error Drizzle has wrapped', () => {
61
+ const failure = pgErrorToResourceError(wrapped({
62
+ code: PgErrorCode.UniqueViolation, constraint: 'users_email_key'
63
+ }))
64
+
65
+ expect(failure).toBeInstanceOf(RecordExists)
66
+ expect(failure.message).toContain(PgErrorCode.UniqueViolation)
67
+ expect(failure.message).toContain('users_email_key')
68
+ /** The wrapper is kept as the cause — unwrapping is for classification, not for loss. */
69
+ expect((failure.cause as Error).name).toBe('DrizzleQueryError')
70
+ })
71
+
72
+ test('leads with the code and never echoes bound parameters', () => {
73
+ const described = describePgError(wrapped({
74
+ code: PgErrorCode.UniqueViolation,
75
+ message: 'duplicate key value violates unique constraint "users_email_key"',
76
+ constraint: 'users_email_key',
77
+ table: 'users',
78
+ column: 'email',
79
+ detail: 'Key (email)=(a@b.c) already exists.',
80
+ hint: 'try another',
81
+ severity: 'ERROR',
82
+ where: `SQL statement "INSERT INTO users VALUES ('secret')"`
83
+ }))
84
+
85
+ /** The fixer classifies on this text, so the code has to be the first thing in it. */
86
+ expect(described.startsWith(PgErrorCode.UniqueViolation)).toBe(true)
87
+ expect(described).toContain('table=users')
88
+ expect(described).toContain('column=email')
89
+ expect(described).toContain('hint=try another')
90
+ /** `where` can quote the statement, and the statement can quote a value. */
91
+ expect(described).not.toContain('SQL statement')
92
+ })
93
+
94
+ test('passes a framework error through untouched', () => {
95
+ const original = new ResourceError('already-translated')
96
+
97
+ expect(pgErrorToResourceError(original)).toBe(original)
98
+ })
99
+
100
+ test('leaves an error that is not from the driver alone', () => {
101
+ const plain = new Error('not postgres at all')
102
+
103
+ expect(pgErrorToResourceError(plain)).toBe(plain)
104
+ expect(describePgError(plain)).toBe('not postgres at all')
105
+ expect(pgErrorToResourceError('a string')).toBeInstanceOf(PostgresError)
106
+ })
107
+
108
+ test('survives a circular cause chain', () => {
109
+ const outer = new Error('outer') as Error & { cause?: unknown }
110
+ const inner = new Error('inner') as Error & { cause?: unknown }
111
+ outer.cause = inner
112
+ inner.cause = outer
113
+
114
+ /**
115
+ * Returning at all is the assertion — an unbounded walk would spin forever. Note that
116
+ * `expect(fn).not.toThrow()` cannot say this in Bun: it reports a *returned* `Error` as
117
+ * a thrown one, and every one of these translations returns exactly that.
118
+ */
119
+ expect(pgErrorToResourceError(outer)).toBe(outer)
120
+ })
121
+ })
@@ -0,0 +1,75 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { DbConfig, ResourceRecord } from '@owlmeans/resource'
3
+
4
+ import {
5
+ advisoryKey, assertSqlIdentifier, pgIdentifier, pgTableName, qualify, quoteIdent, quoteLiteral
6
+ } from '@owlmeans/postgres-resource'
7
+ import type { PostgresResource } from '@owlmeans/postgres-resource'
8
+
9
+ const resource = (alias: string, name?: string): PostgresResource<ResourceRecord> =>
10
+ ({ alias, ...(name != null ? { name } : {}) }) as PostgresResource<ResourceRecord>
11
+
12
+ describe('@owlmeans/postgres-resource — identifiers', () => {
13
+ test('sanitizes an arbitrary name into something Postgres accepts', () => {
14
+ expect(pgIdentifier('users')).toBe('users')
15
+ /** Resource aliases carry `-` and `:`; column and table names cannot. */
16
+ expect(pgIdentifier('wl-profile')).toBe('wl_profile')
17
+ expect(pgIdentifier('auth:token')).toBe('auth_token')
18
+ expect(pgIdentifier('9lives')).toBe('_9lives')
19
+ })
20
+
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.
25
+ */
26
+ test('keeps an overlong name unique inside the 63 byte limit', () => {
27
+ const long = 'a'.repeat(200)
28
+ const identifier = pgIdentifier(long)
29
+
30
+ expect(identifier.length).toBe(63)
31
+ expect(Buffer.byteLength(identifier, 'utf8')).toBeLessThanOrEqual(63)
32
+ expect(identifier).not.toBe(pgIdentifier(`${long}b`))
33
+ expect(identifier).toBe(pgIdentifier(long))
34
+ })
35
+
36
+ test('assertSqlIdentifier is the last thing between a config value and injection', () => {
37
+ expect(assertSqlIdentifier('app_role')).toBe('app_role')
38
+ expect(() => assertSqlIdentifier('app"; DROP DATABASE x; --')).toThrow(SyntaxError)
39
+ expect(() => assertSqlIdentifier('has space')).toThrow(SyntaxError)
40
+ expect(() => assertSqlIdentifier('a'.repeat(64))).toThrow(SyntaxError)
41
+ })
42
+
43
+ test('quotes identifiers and literals', () => {
44
+ expect(quoteIdent('users')).toBe('"users"')
45
+ expect(quoteIdent('we"ird')).toBe('"we""ird"')
46
+ expect(qualify('app', 'users')).toBe('"app"."users"')
47
+
48
+ expect(quoteLiteral("O'Brien")).toBe("'O''Brien'")
49
+ /** No escaping makes a NUL byte safe, so it is refused instead of encoded. */
50
+ expect(() => quoteLiteral('nul\0byte')).toThrow(SyntaxError)
51
+ })
52
+
53
+ test('derives the physical table name from the resource and its prefix', () => {
54
+ const config = { service: 'postgres', host: 'localhost' } as DbConfig
55
+
56
+ expect(pgTableName(config, resource('wl-profile'))).toBe('wl_profile')
57
+ /** An explicit `name` wins — the alias may be unusable as an identifier. */
58
+ expect(pgTableName(config, resource('wl-profile', 'profiles'))).toBe('profiles')
59
+ expect(pgTableName({ ...config, resourcePrefix: 'app_' }, resource('users'))).toBe('app_users')
60
+ })
61
+
62
+ test('advisory keys are a stable function of the qualified table', () => {
63
+ const key = advisoryKey('"app"."users"')
64
+
65
+ expect(key).toHaveLength(2)
66
+ expect(key).toEqual(advisoryKey('"app"."users"'))
67
+ expect(key).not.toEqual(advisoryKey('"app"."posts"'))
68
+ /** `pg_advisory_lock(int, int)` — anything outside int32 would be rejected by the server. */
69
+ for (const half of key) {
70
+ expect(Number.isInteger(half)).toBe(true)
71
+ expect(half).toBeGreaterThanOrEqual(-2147483648)
72
+ expect(half).toBeLessThanOrEqual(2147483647)
73
+ }
74
+ })
75
+ })
@@ -0,0 +1,258 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { UnsupportedArgumentError } from '@owlmeans/resource'
3
+ import type { AnySchema } from 'ajv'
4
+
5
+ import { schemaToTableSpec, toFormatType } from '@owlmeans/postgres-resource'
6
+ import type { TableSpec } from '@owlmeans/postgres-resource'
7
+
8
+ /**
9
+ * Compiling an AJV schema into a table specification is the one step every other part of
10
+ * the package trusts: DDL emission, drift detection, marshalling and placeholder
11
+ * resolution all read the result rather than the schema. It needs no database, so it is
12
+ * covered here rather than behind the integration gate.
13
+ */
14
+ const compile = (schema: AnySchema, table: string = 'things'): TableSpec =>
15
+ schemaToTableSpec('things', schema, 'app', table, true)
16
+
17
+ describe('@owlmeans/postgres-resource — schemaToTableSpec', () => {
18
+ test('normalizes type spellings to what format_type() reports', () => {
19
+ expect(toFormatType('varchar(320)')).toBe('character varying(320)')
20
+ expect(toFormatType('int4')).toBe('integer')
21
+ expect(toFormatType('int8')).toBe('bigint')
22
+ expect(toFormatType('timestamptz')).toBe('timestamp with time zone')
23
+ expect(toFormatType('float8')).toBe('double precision')
24
+ expect(toFormatType('NUMERIC( 12 , 2 )')).toBe('numeric(12,2)')
25
+ expect(toFormatType('text[]')).toBe('text[]')
26
+ })
27
+
28
+ test('synthesizes the id primary key when the schema does not declare one', () => {
29
+ const spec = compile({ type: 'object', properties: { name: { type: 'string' } } } as AnySchema)
30
+
31
+ expect(spec.primaryKey).toEqual(['id'])
32
+ const id = spec.byProperty.id
33
+ expect(id.sqlType).toBe('text')
34
+ expect(id.notNull).toBe(true)
35
+ expect(id.defaultRaw).toBe('gen_random_uuid()::text')
36
+ /** First, so `SELECT *` and every diff read it where a hand written table would put it. */
37
+ expect(spec.columns[0].property).toBe('id')
38
+ })
39
+
40
+ test('promotes a declared uuid id to the primary key with a server side default', () => {
41
+ const spec = compile({
42
+ type: 'object',
43
+ properties: { id: { type: 'string', format: 'uuid' }, name: { type: 'string' } },
44
+ required: ['id', 'name']
45
+ } as AnySchema)
46
+
47
+ expect(spec.primaryKey).toEqual(['id'])
48
+ expect(spec.byProperty.id.sqlType).toBe('uuid')
49
+ expect(spec.byProperty.id.defaultRaw).toBe('gen_random_uuid()')
50
+ expect(spec.qualified).toBe('"app"."things"')
51
+ })
52
+
53
+ test('maps JSON types onto Postgres types', () => {
54
+ const spec = compile({
55
+ type: 'object',
56
+ properties: {
57
+ plain: { type: 'string' },
58
+ bounded: { type: 'string', maxLength: 64 },
59
+ uuid: { type: 'string', format: 'uuid' },
60
+ stamp: { type: 'string', format: 'date-time' },
61
+ day: { type: 'string', format: 'date' },
62
+ blob: { type: 'string', format: 'binary' },
63
+ small: { type: 'integer' },
64
+ big: { type: 'integer', maximum: 9007199254740991 },
65
+ real: { type: 'number' },
66
+ money: { type: 'number', pg: { precision: 12, scale: 2 } },
67
+ flag: { type: 'boolean' },
68
+ payload: { type: 'object' },
69
+ /** The framework's date convention — an `object` carrying `format: 'date-time'`. */
70
+ createdAt: { type: 'object', format: 'date-time' },
71
+ labels: { type: 'array', items: { type: 'string' } },
72
+ rows: { type: 'array', items: { type: 'object' } }
73
+ }
74
+ } as AnySchema)
75
+
76
+ const types = Object.fromEntries(spec.columns.map(column => [column.property, column.sqlType]))
77
+ expect(types).toMatchObject({
78
+ plain: 'text',
79
+ bounded: 'character varying(64)',
80
+ uuid: 'uuid',
81
+ stamp: 'timestamp with time zone',
82
+ day: 'date',
83
+ blob: 'bytea',
84
+ small: 'integer',
85
+ big: 'bigint',
86
+ real: 'double precision',
87
+ money: 'numeric(12,2)',
88
+ flag: 'boolean',
89
+ payload: 'jsonb',
90
+ createdAt: 'timestamp with time zone',
91
+ labels: 'text[]',
92
+ rows: 'jsonb'
93
+ })
94
+ expect(spec.byProperty.labels.array).toBe(true)
95
+ expect(spec.byProperty.rows.array).toBe(false)
96
+ expect(spec.byProperty.payload.jsonb).toBe(true)
97
+ expect(spec.byProperty.createdAt.jsonType).toBe('date')
98
+ })
99
+
100
+ /**
101
+ * Mongo's mapper lost nullability twice, both times on a branch that recomputed it
102
+ * instead of receiving it — `date-time` and optional objects. Those two shapes are
103
+ * asserted explicitly, not just the general rule.
104
+ */
105
+ test('threads nullability through every branch', () => {
106
+ const spec = compile({
107
+ type: 'object',
108
+ properties: {
109
+ id: { type: 'string', format: 'uuid' },
110
+ needed: { type: 'string' },
111
+ optional: { type: 'string' },
112
+ nullableStamp: { type: 'object', format: 'date-time', nullable: true },
113
+ requiredStamp: { type: 'object', format: 'date-time' },
114
+ optionalObject: { type: 'object' },
115
+ unionNull: { type: ['string', 'null'] },
116
+ forcedNull: { type: 'string', pg: { nullable: true } },
117
+ forcedNotNull: { type: 'string', pg: { nullable: false } }
118
+ },
119
+ required: ['id', 'needed', 'requiredStamp', 'unionNull', 'forcedNull', 'forcedNotNull']
120
+ } as AnySchema)
121
+
122
+ const notNull = Object.fromEntries(spec.columns.map(column => [column.property, column.notNull]))
123
+ expect(notNull).toMatchObject({
124
+ id: true,
125
+ needed: true,
126
+ optional: false,
127
+ nullableStamp: false,
128
+ requiredStamp: true,
129
+ optionalObject: false,
130
+ unionNull: false,
131
+ forcedNull: false,
132
+ forcedNotNull: true
133
+ })
134
+ })
135
+
136
+ test('applies the pg override vocabulary', () => {
137
+ const spec = compile({
138
+ type: 'object',
139
+ properties: {
140
+ id: { type: 'string', format: 'uuid' },
141
+ email: { type: 'string', pg: { type: 'varchar', length: 320, unique: true } },
142
+ ownerId: {
143
+ type: 'string',
144
+ format: 'uuid',
145
+ pg: { references: { resource: 'users', onDelete: 'cascade' }, index: true }
146
+ },
147
+ renamed: { type: 'string', pg: { column: 'renamed_to' } },
148
+ touched: { type: 'object', format: 'date-time', pg: { defaultRaw: 'now()' } },
149
+ legacy: { type: 'string', pg: { managed: false } },
150
+ score: { type: 'integer', pg: { check: '{{col}} >= 0' } }
151
+ },
152
+ required: ['id', 'email'],
153
+ pg: { indexes: [{ name: 'idx_things_email_owner', columns: ['email', 'ownerId'] }] }
154
+ } as AnySchema)
155
+
156
+ expect(spec.byProperty.email.sqlType).toBe('character varying(320)')
157
+ expect(spec.uniques).toEqual([{ columns: ['email'], name: 'things_email_key' }])
158
+
159
+ expect(spec.references).toHaveLength(1)
160
+ expect(spec.references[0]).toMatchObject({
161
+ property: 'ownerId', resource: 'users', column: 'id', onDelete: 'cascade',
162
+ name: 'things_ownerId_fkey'
163
+ })
164
+
165
+ /** No implicit snake_casing anywhere — only an explicit `column` renames. */
166
+ expect(spec.byProperty.renamed.column).toBe('renamed_to')
167
+ expect(spec.byColumn.renamed_to.property).toBe('renamed')
168
+ expect(spec.byProperty.touched.defaultRaw).toBe('now()')
169
+
170
+ expect(spec.unmanaged).toContain('legacy')
171
+ expect(spec.checks.map(check => check.expression)).toContain('"score" >= 0')
172
+
173
+ const names = spec.indexes.map(index => index.name)
174
+ expect(names).toContain('idx_things_email_owner')
175
+ expect(names).toContain('things_ownerId_idx')
176
+ /** Index specs address properties; the compiled spec addresses physical columns. */
177
+ expect(spec.indexes.find(index => index.name === 'idx_things_email_owner')?.columns)
178
+ .toEqual(['email', 'ownerId'])
179
+ })
180
+
181
+ test('turns a string enum into a CHECK that tolerates NULL when the column does', () => {
182
+ const spec = compile({
183
+ type: 'object',
184
+ properties: {
185
+ id: { type: 'string', format: 'uuid' },
186
+ status: { type: 'string', enum: ['active', 'banned'] },
187
+ mood: { type: 'string', enum: ['ok'], nullable: true }
188
+ },
189
+ required: ['id', 'status']
190
+ } as AnySchema)
191
+
192
+ const expressions = spec.checks.map(check => check.expression)
193
+ expect(expressions).toContain(`"status" IN ('active', 'banned')`)
194
+ expect(expressions).toContain(`"mood" IS NULL OR "mood" IN ('ok')`)
195
+ })
196
+
197
+ test('flattens allOf composition the way the framework schemas are written', () => {
198
+ const spec = compile({
199
+ type: 'object',
200
+ allOf: [
201
+ { type: 'object', properties: { id: { type: 'string', format: 'uuid' } }, required: ['id'] },
202
+ { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }
203
+ ],
204
+ properties: { extra: { type: 'boolean' } }
205
+ } as AnySchema)
206
+
207
+ expect(Object.keys(spec.byProperty).sort()).toEqual(['extra', 'id', 'name'])
208
+ expect(spec.byProperty.name.notNull).toBe(true)
209
+ expect(spec.byProperty.extra.notNull).toBe(false)
210
+ })
211
+
212
+ test('honors a composite primary key declared at the root', () => {
213
+ const spec = compile({
214
+ type: 'object',
215
+ properties: {
216
+ tenant: { type: 'string' },
217
+ key: { type: 'string' },
218
+ value: { type: 'string' }
219
+ },
220
+ pg: { primaryKey: ['tenant', 'key'] }
221
+ } as AnySchema)
222
+
223
+ expect(spec.primaryKey).toEqual(['tenant', 'key'])
224
+ expect(spec.byProperty.tenant.notNull).toBe(true)
225
+ expect(spec.byProperty.key.notNull).toBe(true)
226
+ /** No id is synthesized once the schema says what identity means. */
227
+ expect(spec.byProperty.id).toBeUndefined()
228
+ })
229
+
230
+ test('stores a secure field as unbounded text and refuses to make it unique', () => {
231
+ const spec = compile({
232
+ type: 'object',
233
+ properties: { token: { type: 'string', maxLength: 32, secure: true } }
234
+ } as AnySchema)
235
+
236
+ expect(spec.byProperty.token.sqlType).toBe('text')
237
+ expect(spec.byProperty.token.secure).toBe(true)
238
+
239
+ expect(() => compile({
240
+ type: 'object',
241
+ properties: { token: { type: 'string', secure: true, pg: { unique: true } } }
242
+ } as AnySchema)).toThrow(UnsupportedArgumentError)
243
+ })
244
+
245
+ test('rejects an index or key naming a property that does not exist', () => {
246
+ expect(() => compile({
247
+ type: 'object',
248
+ properties: { name: { type: 'string' } },
249
+ pg: { indexes: [{ columns: ['nope'] }] }
250
+ } as AnySchema)).toThrow(UnsupportedArgumentError)
251
+
252
+ expect(() => compile({
253
+ type: 'object',
254
+ properties: { name: { type: 'string' } },
255
+ pg: { primaryKey: ['nope'] }
256
+ } as AnySchema)).toThrow(UnsupportedArgumentError)
257
+ })
258
+ })
@@ -0,0 +1,104 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import type { BasicContext } from '@owlmeans/context'
3
+
4
+ import {
5
+ PostgresPlaceholderError, refOf, resolvePlaceholders, schemaToTableSpec
6
+ } from '@owlmeans/postgres-resource'
7
+ import type { TableSpec } from '@owlmeans/postgres-resource'
8
+
9
+ const specOf = (alias: string, schema: string, table: string): TableSpec =>
10
+ schemaToTableSpec(alias, {
11
+ type: 'object',
12
+ properties: {
13
+ id: { type: 'string', format: 'uuid' },
14
+ email: { type: 'string', pg: { column: 'email_address' } }
15
+ },
16
+ required: ['id']
17
+ } as never, schema, table, true)
18
+
19
+ /**
20
+ * The placeholder resolver only ever reads `context.resource(alias).table`, so a literal
21
+ * stand-in is both sufficient and clearer than booting a context — and it lets the
22
+ * uninitialized-resource case be constructed at all, which a real context would race.
23
+ */
24
+ const contextOf = (resources: Record<string, TableSpec | null>): BasicContext<any> => ({
25
+ resource: (alias: string) => {
26
+ if (!(alias in resources)) {
27
+ throw new Error(`unknown:${alias}`)
28
+ }
29
+
30
+ return { table: resources[alias] }
31
+ }
32
+ } as unknown as BasicContext<any>)
33
+
34
+ const users = specOf('users', 'app', 'users')
35
+ const posts = specOf('posts', 'app', 'posts')
36
+ const context = contextOf({ users, posts })
37
+
38
+ describe('@owlmeans/postgres-resource — placeholder resolution', () => {
39
+ test('resolves the owning resource', () => {
40
+ expect(resolvePlaceholders('SELECT * FROM {{}}', context, posts))
41
+ .toBe('SELECT * FROM "app"."posts"')
42
+ expect(resolvePlaceholders('SELECT * FROM {{ self }}', context, posts))
43
+ .toBe('SELECT * FROM "app"."posts"')
44
+ })
45
+
46
+ test('resolves another registered resource, its columns, its bare name and its schema', () => {
47
+ expect(resolvePlaceholders('SELECT * FROM {{users}}', context, posts))
48
+ .toBe('SELECT * FROM "app"."users"')
49
+ /** The physical column, not the property — a `pg: { column }` rename has to survive. */
50
+ expect(resolvePlaceholders('SELECT {{users.email}}', context, posts))
51
+ .toBe('SELECT "app"."users"."email_address"')
52
+ expect(resolvePlaceholders('ON CONSTRAINT {{#users}}', context, posts))
53
+ .toBe('ON CONSTRAINT "users"')
54
+ expect(resolvePlaceholders('SET search_path = {{$}}', context, posts))
55
+ .toBe('SET search_path = "app"')
56
+ })
57
+
58
+ test('leaves bound parameters exactly as written', () => {
59
+ const text = `SELECT * FROM {{}} WHERE "id" = $1 AND "email" = $2 AND note = '{{not-a-placeholder'`
60
+ expect(resolvePlaceholders(text, context, posts))
61
+ .toBe(`SELECT * FROM "app"."posts" WHERE "id" = $1 AND "email" = $2 AND note = '{{not-a-placeholder'`)
62
+ })
63
+
64
+ test('refuses an alias it cannot resolve rather than substituting blindly', () => {
65
+ expect(() => resolvePlaceholders('SELECT * FROM {{nope}}', context, posts))
66
+ .toThrow(PostgresPlaceholderError)
67
+ expect(() => resolvePlaceholders('SELECT {{users.nope}}', context, posts))
68
+ .toThrow(PostgresPlaceholderError)
69
+ /** Registered but not yet initialized: the table name genuinely isn't known. */
70
+ expect(() => resolvePlaceholders('SELECT * FROM {{late}}', contextOf({ late: null }), posts))
71
+ .toThrow(PostgresPlaceholderError)
72
+ })
73
+
74
+ test('refuses a self reference in a query that has no owning resource', () => {
75
+ expect(() => resolvePlaceholders('SELECT * FROM {{}}', context, null))
76
+ .toThrow(PostgresPlaceholderError)
77
+ expect(() => resolvePlaceholders('SET search_path = {{$}}', context, null))
78
+ .toThrow(PostgresPlaceholderError)
79
+ /** Other aliases stay addressable — a service level query is scopeless, not blind. */
80
+ expect(resolvePlaceholders('SELECT * FROM {{users}}', context, null))
81
+ .toBe('SELECT * FROM "app"."users"')
82
+ })
83
+
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.
88
+ */
89
+ test('does not leak resolved table names between contexts', () => {
90
+ const tenantA = contextOf({ users: specOf('users', 'tenant_a', 'users') })
91
+ const tenantB = contextOf({ users: specOf('users', 'tenant_b', 'users') })
92
+
93
+ expect(resolvePlaceholders('SELECT * FROM {{users}}', tenantA, null))
94
+ .toBe('SELECT * FROM "tenant_a"."users"')
95
+ expect(resolvePlaceholders('SELECT * FROM {{users}}', tenantB, null))
96
+ .toBe('SELECT * FROM "tenant_b"."users"')
97
+ })
98
+
99
+ test('refOf answers the same question without any SQL around it', () => {
100
+ expect(refOf(context, posts)).toBe('"app"."posts"')
101
+ expect(refOf(context, posts, 'users')).toBe('"app"."users"')
102
+ expect(() => refOf(context, null)).toThrow(PostgresPlaceholderError)
103
+ })
104
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": [
3
+ "@owlmeans/dep-config/tsconfig.base.json",
4
+ "@owlmeans/dep-config/tsconfig.node.json"
5
+ ],
6
+ "compilerOptions": {
7
+ "rootDir": "./src/",
8
+ "outDir": "./build/"
9
+ },
10
+ "exclude": [
11
+ "./dist/**/*",
12
+ "./build/**/*",
13
+ "./tests/**/*",
14
+ "./*.ts"
15
+ ]
16
+ }