@owlmeans/postgres 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 (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +139 -0
  3. package/agent-meta/instructions/postgres.instructions.md +57 -0
  4. package/agent-meta/manifest.json +23 -0
  5. package/agent-meta/skills/postgres/SKILL.md +121 -0
  6. package/build/bootstrap.d.ts +16 -0
  7. package/build/bootstrap.d.ts.map +1 -0
  8. package/build/bootstrap.js +106 -0
  9. package/build/bootstrap.js.map +1 -0
  10. package/build/consts.d.ts +29 -0
  11. package/build/consts.d.ts.map +1 -0
  12. package/build/consts.js +30 -0
  13. package/build/consts.js.map +1 -0
  14. package/build/index.d.ts +8 -0
  15. package/build/index.d.ts.map +1 -0
  16. package/build/index.js +7 -0
  17. package/build/index.js.map +1 -0
  18. package/build/middleware.d.ts +11 -0
  19. package/build/middleware.d.ts.map +1 -0
  20. package/build/middleware.js +21 -0
  21. package/build/middleware.js.map +1 -0
  22. package/build/service.d.ts +9 -0
  23. package/build/service.d.ts.map +1 -0
  24. package/build/service.js +221 -0
  25. package/build/service.js.map +1 -0
  26. package/build/types.d.ts +40 -0
  27. package/build/types.d.ts.map +1 -0
  28. package/build/types.js +2 -0
  29. package/build/types.js.map +1 -0
  30. package/build/utils/config.d.ts +27 -0
  31. package/build/utils/config.d.ts.map +1 -0
  32. package/build/utils/config.js +96 -0
  33. package/build/utils/config.js.map +1 -0
  34. package/build/utils/connection.d.ts +15 -0
  35. package/build/utils/connection.d.ts.map +1 -0
  36. package/build/utils/connection.js +56 -0
  37. package/build/utils/connection.js.map +1 -0
  38. package/package.json +45 -0
  39. package/src/bootstrap.ts +126 -0
  40. package/src/consts.ts +36 -0
  41. package/src/index.ts +7 -0
  42. package/src/middleware.ts +26 -0
  43. package/src/service.ts +287 -0
  44. package/src/types.ts +42 -0
  45. package/src/utils/config.ts +109 -0
  46. package/src/utils/connection.ts +64 -0
  47. package/tests/bootstrap.spec.ts +142 -0
  48. package/tests/context.ts +204 -0
  49. package/tests/crud.spec.ts +227 -0
  50. package/tests/custom-sql.spec.ts +196 -0
  51. package/tests/migration.spec.ts +260 -0
  52. package/tests/sync.spec.ts +156 -0
  53. package/tsconfig.json +16 -0
@@ -0,0 +1,196 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
+ import { makePostgresResource, PostgresPlaceholderError } from '@owlmeans/postgres-resource'
3
+ import type { PostgresResource } from '@owlmeans/postgres-resource'
4
+ import type { ResourceRecord } from '@owlmeans/resource'
5
+
6
+ import { gate, makeSuite } from './context.js'
7
+ import type { PostgresService } from './context.js'
8
+
9
+ interface Author extends ResourceRecord {
10
+ id?: string
11
+ name: string
12
+ email?: string
13
+ }
14
+
15
+ interface Book extends ResourceRecord {
16
+ id?: string
17
+ authorId: string
18
+ title: string
19
+ price?: number
20
+ meta?: Record<string, unknown>
21
+ }
22
+
23
+ const authorSchema = {
24
+ type: 'object',
25
+ properties: {
26
+ id: { type: 'string', format: 'uuid' },
27
+ name: { type: 'string' },
28
+ /** Renamed on purpose — every placeholder form has to emit the *physical* column. */
29
+ email: { type: 'string', nullable: true, pg: { column: 'email_address' } }
30
+ },
31
+ required: ['id', 'name']
32
+ } as never
33
+
34
+ const bookSchema = {
35
+ type: 'object',
36
+ properties: {
37
+ id: { type: 'string', format: 'uuid' },
38
+ authorId: { type: 'string', format: 'uuid', pg: { references: { resource: 'sql-authors' } } },
39
+ title: { type: 'string' },
40
+ price: { type: 'number', nullable: true },
41
+ meta: { type: 'object', nullable: true }
42
+ },
43
+ required: ['id', 'authorId', 'title']
44
+ } as never
45
+
46
+ /** The value every one of these specs is really about: it must never become SQL. */
47
+ const HOSTILE = `'); DROP TABLE "sql_books"; --`
48
+
49
+ const suite = makeSuite('sql')
50
+ const it = gate.skip ? test.skip : test
51
+
52
+ describe('@owlmeans/postgres — custom SQL with alias placeholders', () => {
53
+ if (gate.skip) {
54
+ test.skip(gate.reason ?? 'postgres gate closed', () => {})
55
+
56
+ return
57
+ }
58
+
59
+ let pg: PostgresService
60
+ let authors: PostgresResource<Author>
61
+ let books: PostgresResource<Book>
62
+ let tolkien: Author
63
+
64
+ beforeAll(async () => {
65
+ const author = makePostgresResource<Author, PostgresResource<Author>>('sql-authors')
66
+ author.schema = authorSchema
67
+ const book = makePostgresResource<Book, PostgresResource<Book>>('sql-books')
68
+ book.schema = bookSchema
69
+
70
+ const booted = await suite.boot({ resources: [author, book] })
71
+ pg = booted.pg
72
+ authors = booted.context.resource<PostgresResource<Author>>('sql-authors')
73
+ books = booted.context.resource<PostgresResource<Book>>('sql-books')
74
+
75
+ tolkien = await authors.create({ name: 'Tolkien', email: 'jrr@shire.me' })
76
+ await books.create({ authorId: tolkien.id as string, title: 'The Hobbit', price: 9.5 })
77
+ await books.create({
78
+ authorId: tolkien.id as string, title: 'The Silmarillion', price: 14, meta: { posthumous: true }
79
+ })
80
+ })
81
+
82
+ afterAll(async () => {
83
+ await suite.teardown()
84
+ })
85
+
86
+ it('answers the same qualified name through every route to it', () => {
87
+ const qualified = `"${suite.schema}"."sql_authors"`
88
+
89
+ expect(authors.ref()).toBe(qualified)
90
+ expect(authors.table.qualified).toBe(qualified)
91
+ expect(books.ref('sql-authors')).toBe(qualified)
92
+ expect(pg.qualify('sql-authors')).toBe(qualified)
93
+ })
94
+
95
+ it('substitutes the table, its columns, its bare name and its schema', async () => {
96
+ const [row] = await books.query<{ title: string, author: string }>(
97
+ `SELECT {{sql-books.title}} AS title, {{sql-authors.name}} AS author
98
+ FROM {{}} JOIN {{sql-authors}} ON {{sql-books.authorId}} = {{sql-authors.id}}
99
+ WHERE {{sql-books.title}} = $1`,
100
+ ['The Hobbit']
101
+ )
102
+
103
+ expect(row).toEqual({ title: 'The Hobbit', author: 'Tolkien' })
104
+
105
+ /** `{{$}}` and `{{#alias}}` compose into the same reference `{{alias}}` emits. */
106
+ const [counted] = await books.query<{ n: number }>('SELECT count(*)::int AS n FROM {{$}}.{{#sql-books}}')
107
+ expect(counted.n).toBe(2)
108
+ })
109
+
110
+ it('emits the physical column a property was renamed to', async () => {
111
+ const [row] = await authors.query<{ email_address: string }>(
112
+ 'SELECT {{sql-authors.email}} FROM {{}} WHERE "name" = $1', ['Tolkien']
113
+ )
114
+
115
+ /** The property is `email`; nothing but `email_address` exists in the database. */
116
+ expect(row.email_address).toBe('jrr@shire.me')
117
+ await expect(authors.query('SELECT {{sql-authors.emailAddress}} FROM {{}}'))
118
+ .rejects.toThrow(PostgresPlaceholderError)
119
+ })
120
+
121
+ it('keeps values in parameters, never in the statement', async () => {
122
+ const created = await authors.create({ name: HOSTILE })
123
+
124
+ const [row] = await authors.query<{ name: string }>(
125
+ 'SELECT "name" FROM {{}} WHERE "id" = $1', [created.id]
126
+ )
127
+ expect(row.name).toBe(HOSTILE)
128
+
129
+ /** The statement the value tried to become would have dropped this table. */
130
+ const [alive] = await books.query<{ n: number }>('SELECT count(*)::int AS n FROM {{}}')
131
+ expect(alive.n).toBe(2)
132
+
133
+ await authors.delete(created.id as string)
134
+ })
135
+
136
+ it('marshals through select where query hands back the raw row', async () => {
137
+ const raw = await authors.queryOne<{ email_address: string, email?: string }>(
138
+ 'SELECT * FROM {{}} WHERE "name" = $1', ['Tolkien']
139
+ )
140
+ expect(raw?.email_address).toBe('jrr@shire.me')
141
+ expect(raw?.email).toBeUndefined()
142
+
143
+ const record = await authors.selectOne<Author>('SELECT * FROM {{}} WHERE "name" = $1', ['Tolkien'])
144
+ expect(record?.email).toBe('jrr@shire.me')
145
+ expect(record?.id).toBe(tolkien.id as string)
146
+
147
+ /** jsonb comes back parsed, not as a string, on both routes. */
148
+ const [book] = await books.select<Book>(`SELECT * FROM {{}} WHERE "title" = $1`, ['The Silmarillion'])
149
+ expect(book.meta).toEqual({ posthumous: true })
150
+ })
151
+
152
+ it('reports affected rows from execute', async () => {
153
+ expect(await books.execute('UPDATE {{}} SET "price" = "price" + $1', [1])).toBe(2)
154
+ expect(await books.execute('UPDATE {{}} SET "price" = $1 WHERE "title" = $2', [10.5, 'The Hobbit'])).toBe(1)
155
+ expect(await books.execute('UPDATE {{}} SET "price" = $1 WHERE "title" = $2', [0, 'Nothing'])).toBe(0)
156
+ })
157
+
158
+ it('refuses an alias it cannot resolve instead of emitting it', async () => {
159
+ await expect(books.query('SELECT * FROM {{ghosts}}')).rejects.toThrow(PostgresPlaceholderError)
160
+ /** A service level query is scopeless — it has no owning table for `{{}}` to mean. */
161
+ await expect(pg.query('SELECT * FROM {{}}')).rejects.toThrow(PostgresPlaceholderError)
162
+ expect(await pg.query('SELECT count(*)::int AS n FROM {{sql-books}}')).toEqual([{ n: 2 }])
163
+ })
164
+
165
+ it('commits a transaction and rolls back a failed one', async () => {
166
+ const added = await books.transaction(async tx => {
167
+ await tx.execute('INSERT INTO {{}} ("authorId", "title") VALUES ($1, $2)', [tolkien.id, 'Unfinished Tales'])
168
+
169
+ return await tx.queryOne<{ n: number }>('SELECT count(*)::int AS n FROM {{}}')
170
+ })
171
+ expect(added?.n).toBe(3)
172
+ expect(await books.count()).toBe(3)
173
+
174
+ await expect(books.transaction(async tx => {
175
+ await tx.execute('DELETE FROM {{}}')
176
+ expect(tx.ref()).toBe(`"${suite.schema}"."sql_books"`)
177
+ expect(tx.ref('sql-authors')).toBe(`"${suite.schema}"."sql_authors"`)
178
+
179
+ throw new Error('deliberate')
180
+ })).rejects.toThrow('deliberate')
181
+
182
+ /** Nothing the transaction did survives it. */
183
+ expect(await books.count()).toBe(3)
184
+ })
185
+
186
+ it('exposes the same transaction façade at service level', async () => {
187
+ const total = await pg.transaction(async tx => {
188
+ await tx.execute('DELETE FROM {{sql-books}} WHERE "title" = $1', ['Unfinished Tales'])
189
+
190
+ return await tx.query<{ n: number }>('SELECT count(*)::int AS n FROM {{sql-books}}')
191
+ })
192
+
193
+ expect(total).toEqual([{ n: 2 }])
194
+ expect(await books.count()).toBe(2)
195
+ })
196
+ })
@@ -0,0 +1,260 @@
1
+ import { afterAll, describe, expect, test } from 'bun:test'
2
+ import { makePostgresResource, resetDeclarations } from '@owlmeans/postgres-resource'
3
+ import type { PostgresResource, PostgresTx } from '@owlmeans/postgres-resource'
4
+ import { MigrationConflict, MigrationError, MigrationStage } from '@owlmeans/resource'
5
+ import type { ResourceRecord } from '@owlmeans/resource'
6
+
7
+ import { gate, ledgerOf, makeSuite, raw, shapeOf } from './context.js'
8
+ import type { LedgerRow, PostgresService } from './context.js'
9
+
10
+ interface Note extends ResourceRecord {
11
+ id?: string
12
+ title?: string
13
+ slug?: string
14
+ note?: string
15
+ }
16
+
17
+ const noteSchema = (extra: Record<string, unknown> = {}, required: string[] = []): unknown => ({
18
+ type: 'object',
19
+ properties: {
20
+ id: { type: 'string', format: 'uuid' },
21
+ title: { type: 'string', nullable: true },
22
+ ...extra
23
+ },
24
+ required: ['id', ...required]
25
+ })
26
+
27
+ /**
28
+ * Migration bodies live at module scope because the checksum hashes their source text: two
29
+ * inline arrow functions that happen to read the same are the same migration, and two that
30
+ * differ by a space are a conflict. Naming them makes both facts deliberate.
31
+ */
32
+ const ran: Record<string, number> = {}
33
+ const tick = (name: string): void => { ran[name] = (ran[name] ?? 0) + 1 }
34
+
35
+ /** Would fail with `42P01` if it ever ran — the table does not exist yet at pre stage. */
36
+ const ghostBody = async (tx: PostgresTx): Promise<void> => {
37
+ tick('ghost')
38
+ await tx.execute('ALTER TABLE {{}} ADD COLUMN "ghost" text')
39
+ }
40
+
41
+ const seedBody = async (tx: PostgresTx): Promise<void> => {
42
+ tick('seed')
43
+ await tx.execute('INSERT INTO {{}} ("title") VALUES ($1)', ['seeded'])
44
+ }
45
+
46
+ /** Adds and backfills the column reconciliation is about to demand `NOT NULL` on. */
47
+ const slugBody = async (tx: PostgresTx): Promise<void> => {
48
+ tick('slug')
49
+ await tx.execute('ALTER TABLE {{}} ADD COLUMN "slug" text')
50
+ await tx.execute(`UPDATE {{}} SET "slug" = 'backfilled' WHERE "slug" IS NULL`)
51
+ }
52
+
53
+ /** Writes into a column reconciliation has only just created. */
54
+ const noteBody = async (tx: PostgresTx): Promise<void> => {
55
+ tick('note')
56
+ await tx.execute(`UPDATE {{}} SET "note" = 'seen'`)
57
+ }
58
+
59
+ const failingBody = async (): Promise<void> => {
60
+ tick('failing')
61
+ throw new Error('deliberate')
62
+ }
63
+
64
+ const driftBody = async (tx: PostgresTx): Promise<void> => { await tx.execute('SELECT 1') }
65
+ const driftEdited = async (tx: PostgresTx): Promise<void> => { await tx.execute('SELECT 2') }
66
+
67
+ const copyBody = async (tx: PostgresTx): Promise<void> => {
68
+ tick('copy')
69
+ await tx.execute('INSERT INTO {{}} ("title") SELECT "title" FROM {{mig-src}}')
70
+ }
71
+
72
+ const suite = makeSuite('migration')
73
+ const it = gate.skip ? test.skip : test
74
+
75
+ interface Built {
76
+ alias: string
77
+ declare?: (resource: PostgresResource<Note>) => void
78
+ schema?: unknown
79
+ }
80
+
81
+ const build = (spec: Built): PostgresResource<Note> => {
82
+ const resource = makePostgresResource<Note, PostgresResource<Note>>(spec.alias)
83
+ resource.schema = (spec.schema ?? noteSchema()) as never
84
+ spec.declare?.(resource)
85
+
86
+ return resource
87
+ }
88
+
89
+ const boot = async (...specs: Built[]): Promise<PostgresService> =>
90
+ (await suite.boot({ resources: specs.map(build) })).pg
91
+
92
+ const ledger = async (pg: PostgresService, alias: string): Promise<LedgerRow[]> =>
93
+ (await ledgerOf(pg, suite.schema)).filter(row => row.alias === alias)
94
+
95
+ describe('@owlmeans/postgres — code registered migrations', () => {
96
+ if (gate.skip) {
97
+ test.skip(gate.reason ?? 'postgres gate closed', () => {})
98
+
99
+ return
100
+ }
101
+
102
+ afterAll(async () => {
103
+ await suite.teardown()
104
+ })
105
+
106
+ /**
107
+ * A migration that reproduces a shape the current schema already produces must not be
108
+ * replayed against a table that was just created with that shape. Here the proof is
109
+ * blunt: `ghostBody` targets a table that does not exist at pre stage, so replaying it
110
+ * would abort the boot outright.
111
+ */
112
+ it('baselines every migration on a table it just created', async () => {
113
+ const pg = await boot({
114
+ alias: 'mig-a',
115
+ declare: resource => {
116
+ resource.migration('0001-ghost', ghostBody)
117
+ resource.migration('0002-seed', seedBody, MigrationStage.Post)
118
+ }
119
+ })
120
+
121
+ const rows = await ledger(pg, 'mig-a')
122
+ expect(rows.map(row => row.name)).toEqual(['0001-ghost', '0002-seed'])
123
+ expect(rows.every(row => row.baseline)).toBe(true)
124
+ /** Baselining covers both stages in one write, so the recorded stage is the declared one. */
125
+ expect(rows.map(row => row.stage)).toEqual(['pre', 'post'])
126
+
127
+ expect(ran.ghost).toBeUndefined()
128
+ expect(ran.seed).toBeUndefined()
129
+ expect(Object.keys(await shapeOf(pg, suite.schema, 'mig_a'))).not.toContain('ghost')
130
+ })
131
+
132
+ it('skips what the ledger already records on the next boot', async () => {
133
+ const pg = await boot({
134
+ alias: 'mig-a',
135
+ declare: resource => {
136
+ resource.migration('0001-ghost', ghostBody)
137
+ resource.migration('0002-seed', seedBody, MigrationStage.Post)
138
+ }
139
+ })
140
+
141
+ expect(ran.ghost).toBeUndefined()
142
+ expect(ran.seed).toBeUndefined()
143
+ expect(await ledger(pg, 'mig-a')).toHaveLength(2)
144
+ })
145
+
146
+ it('applies a migration added after the table exists, exactly once', async () => {
147
+ await boot({ alias: 'mig-b' })
148
+ expect(await ledger(await boot({ alias: 'mig-b' }), 'mig-b')).toHaveLength(0)
149
+
150
+ const applied = await boot({
151
+ alias: 'mig-b',
152
+ declare: resource => { resource.migration('0001-seed', seedBody, MigrationStage.Post) }
153
+ })
154
+
155
+ expect(ran.seed).toBe(1)
156
+ const rows = await ledger(applied, 'mig-b')
157
+ expect(rows).toHaveLength(1)
158
+ /** Applied, not baselined — the distinction is what makes a replay detectable. */
159
+ expect(rows[0].baseline).toBe(false)
160
+
161
+ /** Re-registering an identical body is a no-op, so this boot has the same registry. */
162
+ const again = await boot({
163
+ alias: 'mig-b',
164
+ declare: resource => { resource.migration('0001-seed', seedBody, MigrationStage.Post) }
165
+ })
166
+
167
+ expect(ran.seed).toBe(1)
168
+ expect(await ledger(again, 'mig-b')).toHaveLength(1)
169
+ expect(await again.query('SELECT count(*)::int AS n FROM {{mig-b}}')).toEqual([{ n: 1 }])
170
+ })
171
+
172
+ /**
173
+ * Both stages in one boot, each doing something the other stage could not: `slug` has to
174
+ * exist and be backfilled *before* reconciliation demands `NOT NULL` on it, and `note`
175
+ * cannot be written to until reconciliation has created it.
176
+ */
177
+ it('runs pre migrations before reconciliation and post migrations after it', async () => {
178
+ const before = await boot({ alias: 'mig-c' })
179
+ await before.query('INSERT INTO {{mig-c}} ("title") VALUES ($1)', ['existing'])
180
+
181
+ const pg = await boot({
182
+ alias: 'mig-c',
183
+ schema: noteSchema({ slug: { type: 'string' }, note: { type: 'string', nullable: true } }, ['slug']),
184
+ declare: resource => {
185
+ resource.migration('0001-slug', slugBody)
186
+ resource.migration('0002-note', noteBody, MigrationStage.Post)
187
+ }
188
+ })
189
+
190
+ expect(ran.slug).toBe(1)
191
+ expect(ran.note).toBe(1)
192
+
193
+ const shape = await shapeOf(pg, suite.schema, 'mig_c')
194
+ expect(shape.slug).toBe('text NOT NULL')
195
+ expect(shape.note).toBe('text')
196
+
197
+ expect(await pg.query('SELECT "title", "slug", "note" FROM {{mig-c}}'))
198
+ .toEqual([{ title: 'existing', slug: 'backfilled', note: 'seen' }])
199
+ })
200
+
201
+ it('aborts initialization when a migration throws, and records nothing', async () => {
202
+ await boot({ alias: 'mig-d' })
203
+
204
+ await expect(boot({
205
+ alias: 'mig-d',
206
+ declare: resource => { resource.migration('0001-boom', failingBody, MigrationStage.Post) }
207
+ })).rejects.toThrow(MigrationError)
208
+
209
+ expect(ran.failing).toBe(1)
210
+ /** The context never came up, so the ledger has to be read outside it. */
211
+ const rows = await raw(async pool => (await pool.query<{ name: string }>(
212
+ `SELECT "name" FROM "${suite.schema}"."_owlmeans_migrations" WHERE "alias" = $1`, ['mig-d']
213
+ )).rows)
214
+ expect(rows).toHaveLength(0)
215
+ })
216
+
217
+ it('refuses to start when an applied migration body has been edited', async () => {
218
+ await boot({ alias: 'mig-e' })
219
+ await boot({
220
+ alias: 'mig-e',
221
+ declare: resource => { resource.migration('0001-drift', driftBody, MigrationStage.Post) }
222
+ })
223
+
224
+ /** A restarted process with an edited source file — the registry has to be rebuilt. */
225
+ resetDeclarations('mig-e')
226
+
227
+ await expect(boot({
228
+ alias: 'mig-e',
229
+ declare: resource => { resource.migration('0001-drift', driftEdited, MigrationStage.Post) }
230
+ })).rejects.toThrow(MigrationConflict)
231
+ })
232
+
233
+ it('rejects a changed body under a name already registered in this process', () => {
234
+ resetDeclarations('mig-conflict')
235
+ const resource = makePostgresResource<Note, PostgresResource<Note>>('mig-conflict')
236
+ resource.migration('0001-drift', driftBody)
237
+
238
+ expect(() => resource.migration('0001-drift', driftEdited)).toThrow(MigrationConflict)
239
+ /** Identical re-registration is routine — every context switch re-runs the maker. */
240
+ expect(() => resource.migration('0001-drift', driftBody)).not.toThrow()
241
+ })
242
+
243
+ /**
244
+ * Registration order matters here where it doesn't for foreign keys: a key is deferred
245
+ * until every resource has initialized, but a migration runs inside its own resource's
246
+ * `init()`, so an alias it names has to have been initialized already.
247
+ */
248
+ it('resolves another resource by alias inside a migration', async () => {
249
+ const pg = await boot({ alias: 'mig-src' }, { alias: 'mig-f' })
250
+ await pg.query('INSERT INTO {{mig-src}} ("title") VALUES ($1)', ['copied'])
251
+
252
+ const applied = await boot({ alias: 'mig-src' }, {
253
+ alias: 'mig-f',
254
+ declare: resource => { resource.migration('0001-copy', copyBody, MigrationStage.Post) }
255
+ })
256
+
257
+ expect(ran.copy).toBe(1)
258
+ expect(await applied.query('SELECT "title" FROM {{mig-f}}')).toEqual([{ title: 'copied' }])
259
+ })
260
+ })
@@ -0,0 +1,156 @@
1
+ import { afterAll, describe, expect, test } from 'bun:test'
2
+ import { makePostgresResource, PgAutoSync, PostgresCastRequired } from '@owlmeans/postgres-resource'
3
+ import type { PostgresResource } from '@owlmeans/postgres-resource'
4
+ import type { ResourceRecord } from '@owlmeans/resource'
5
+
6
+ import { gate, makeSuite, shapeOf } from './context.js'
7
+ import type { PostgresService } from '@owlmeans/postgres'
8
+
9
+ interface Thing extends ResourceRecord {
10
+ id?: string
11
+ name?: string
12
+ }
13
+
14
+ const v1 = {
15
+ type: 'object',
16
+ properties: {
17
+ id: { type: 'string', format: 'uuid' },
18
+ name: { type: 'string' },
19
+ hits: { type: 'integer', nullable: true },
20
+ legacy: { type: 'string', nullable: true }
21
+ },
22
+ required: ['id', 'name']
23
+ }
24
+
25
+ /** `hits` widens to bigint, `name` gains a length, `note` appears, `legacy` disappears. */
26
+ const v2 = {
27
+ type: 'object',
28
+ properties: {
29
+ id: { type: 'string', format: 'uuid' },
30
+ name: { type: 'string', pg: { type: 'varchar', length: 100 } },
31
+ hits: { type: 'integer', nullable: true, pg: { type: 'bigint' } },
32
+ note: { type: 'string', nullable: true }
33
+ },
34
+ required: ['id', 'name']
35
+ }
36
+
37
+ /** `name` text → integer, with `'a'` already stored: the cast is legal, the data is not. */
38
+ const v3 = {
39
+ type: 'object',
40
+ properties: {
41
+ id: { type: 'string', format: 'uuid' },
42
+ name: { type: 'integer' }
43
+ },
44
+ required: ['id', 'name']
45
+ }
46
+
47
+ const suite = makeSuite('sync')
48
+ const it = gate.skip ? test.skip : test
49
+
50
+ const boot = async (autoSync: PgAutoSync, schema: unknown): Promise<PostgresService> => {
51
+ const thing = makePostgresResource<Thing, PostgresResource<Thing>>('sync-things')
52
+ thing.schema = schema as never
53
+
54
+ return (await suite.boot({ autoSync, resources: [thing] })).pg
55
+ }
56
+
57
+ const shape = async (pg: PostgresService): Promise<Record<string, string>> =>
58
+ await shapeOf(pg, suite.schema, 'sync_things')
59
+
60
+ describe('@owlmeans/postgres — structure reconciliation', () => {
61
+ if (gate.skip) {
62
+ test.skip(gate.reason ?? 'postgres gate closed', () => {})
63
+
64
+ return
65
+ }
66
+
67
+ afterAll(async () => {
68
+ await suite.teardown()
69
+ })
70
+
71
+ it('creates the table on the first boot', async () => {
72
+ const pg = await boot(PgAutoSync.Full, v1)
73
+
74
+ expect(await shape(pg)).toEqual({
75
+ id: 'uuid NOT NULL',
76
+ name: 'text NOT NULL',
77
+ hits: 'integer',
78
+ legacy: 'text'
79
+ })
80
+
81
+ await pg.query(
82
+ `INSERT INTO {{sync-things}} ("name", "hits", "legacy") VALUES ($1, $2, $3)`, ['a', 1, 'keep']
83
+ )
84
+ })
85
+
86
+ /**
87
+ * The adoption path for a table this package did not create: converge what is missing
88
+ * without touching what is there, verify the plan comes out empty, then flip to `full`.
89
+ */
90
+ it('additive adds columns but never retypes and never drops', async () => {
91
+ const additive = await shape(await boot(PgAutoSync.Additive, v2))
92
+
93
+ expect(additive.note).toBe('text')
94
+ expect(additive.legacy).toBe('text')
95
+ expect(additive.hits).toBe('integer')
96
+ expect(additive.name).toBe('text NOT NULL')
97
+ })
98
+
99
+ it('full drops, retypes and tightens — and the rows survive', async () => {
100
+ const pg = await boot(PgAutoSync.Full, v2)
101
+ const full = await shape(pg)
102
+
103
+ expect(full.legacy).toBeUndefined()
104
+ expect(full.hits).toBe('bigint')
105
+ expect(full.name).toBe('character varying(100) NOT NULL')
106
+
107
+ const rows = await pg.query<{ name: string, hits: string | number }>(
108
+ `SELECT "name", "hits" FROM {{sync-things}}`
109
+ )
110
+ expect(rows).toHaveLength(1)
111
+ expect(rows[0].name).toBe('a')
112
+ })
113
+
114
+ it('off reconciles nothing at all', async () => {
115
+ /** v1 declares `legacy` and no `note`; with sync off the table keeps its v2 shape. */
116
+ const off = await shape(await boot(PgAutoSync.Off, v1))
117
+
118
+ expect(off.note).toBe('text')
119
+ expect(off.legacy).toBeUndefined()
120
+ })
121
+
122
+ it('refuses an unsafe cast instead of truncating, and rolls the whole plan back', async () => {
123
+ const before = await shape(await boot(PgAutoSync.Off, v2))
124
+
125
+ await expect(boot(PgAutoSync.Full, v3)).rejects.toThrow(PostgresCastRequired)
126
+
127
+ /** DDL runs in one transaction, so a refused step leaves nothing half applied. */
128
+ expect(await shape(await boot(PgAutoSync.Off, v2))).toEqual(before)
129
+ })
130
+
131
+ it('names the escape hatch in the message it raises', async () => {
132
+ const failure = await boot(PgAutoSync.Full, v3).catch((error: Error) => error)
133
+
134
+ expect(failure).toBeInstanceOf(PostgresCastRequired)
135
+ expect((failure as Error).message).toContain('using')
136
+ expect((failure as Error).message).toContain('ALTER TABLE')
137
+ })
138
+
139
+ it('refuses a cast Postgres has no entry for at all', async () => {
140
+ const flag = {
141
+ type: 'object',
142
+ properties: { id: { type: 'string', format: 'uuid' }, flag: { type: 'boolean', nullable: true } },
143
+ required: ['id']
144
+ }
145
+ await boot(PgAutoSync.Full, flag)
146
+
147
+ /** boolean → timestamptz: no registered cast, so it fails on an empty column too. */
148
+ await expect(boot(PgAutoSync.Full, {
149
+ ...flag,
150
+ properties: {
151
+ ...flag.properties,
152
+ flag: { type: 'object', format: 'date-time', nullable: true }
153
+ }
154
+ })).rejects.toThrow(PostgresCastRequired)
155
+ })
156
+ })
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
+ }