@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,524 @@
1
+ import { appendContextual, assertContext } from '@owlmeans/context'
2
+ import type { BasicContext, Contextual } from '@owlmeans/context'
3
+ import {
4
+ MisshapedRecord, RecordExists, RecordUpdateFailed, UnknownRecordError,
5
+ UnsupportedArgumentError, prepareListOptions
6
+ } from '@owlmeans/resource'
7
+ import type {
8
+ ListPager, MigrationStage, ResourceMaker, ResourceRecord
9
+ } from '@owlmeans/resource'
10
+ import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
11
+ import type { AnySchema, JSONSchemaType } from 'ajv'
12
+ import { eq, sql } from 'drizzle-orm'
13
+ import type { SQL } from 'drizzle-orm'
14
+ import type { PgTable } from 'drizzle-orm/pg-core'
15
+ import type { PoolClient, QueryResultRow } from 'pg'
16
+
17
+ import { DEFAULT_DB_ALIAS, DEFAULT_PAGE_SIZE, ID_FIELD } from './consts.js'
18
+ import { getDeclaration } from './declarations.js'
19
+ import { pgErrorToResourceError } from './errors.js'
20
+ import { getSchemaSecureFeilds } from './helper.js'
21
+ import type {
22
+ ColumnSpec, PgIndexSpec, PgRuntimeTable, PostgresDb, PostgresDbService, PostgresResource,
23
+ PostgresTx, TableSpec
24
+ } from './types.js'
25
+ import { criteriaToSql, sortToSql } from './utils/criteria.js'
26
+ import { initializeTable } from './utils/life-cycle.js'
27
+ import { recordToFullValues, recordToValues, resultToRecord, rowToRecord } from './utils/marshal.js'
28
+ import { makeTx } from './utils/migrations.js'
29
+ import { refOf, resolvePlaceholders } from './utils/sql.js'
30
+
31
+ type Config = ServerConfig
32
+ type Context<C extends Config = Config> = ServerContext<C>
33
+
34
+ type Getter = string | { field?: string, ttl?: number | Date | string }
35
+
36
+ const fieldOf = (opts?: Getter): string | undefined =>
37
+ typeof opts === 'string' ? opts : opts?.field
38
+
39
+ const ttlOf = (opts?: Getter): unknown =>
40
+ typeof opts === 'object' ? opts.ttl : undefined
41
+
42
+ /**
43
+ * Hand a runtime built table to Drizzle's query builder.
44
+ *
45
+ * {@link PgRuntimeTable} is deliberately untyped — the table is compiled from a JSON schema
46
+ * at runtime, so there is no static shape to infer. Naming the erased `PgTable` here is what
47
+ * keeps the builder's *result* types usable; casting the argument to `never` instead would
48
+ * silently collapse every returned row to `never`.
49
+ */
50
+ const pgTable = (table: PgRuntimeTable): PgTable => table as unknown as PgTable
51
+
52
+ export const makePostgresResource = <
53
+ R extends ResourceRecord, T extends PostgresResource<R> = PostgresResource<R>
54
+ >(
55
+ alias: string, dbAlias: string = DEFAULT_DB_ALIAS, serviceAlias: string = DEFAULT_DB_ALIAS,
56
+ makeCustomResource?: ResourceMaker<R, T>, tableName?: string
57
+ ): T => {
58
+ const location = `postgres-resource:${alias}`
59
+ const declaration = getDeclaration(alias)
60
+
61
+ /** Set by `init()`; every data method goes through `ensure()`, which guarantees them. */
62
+ let spec: TableSpec | undefined
63
+ let entity: PgRuntimeTable | undefined
64
+ let initializing: Promise<void> | undefined
65
+
66
+ const service = (): PostgresDbService => {
67
+ const context = assertContext<Config, Context>(resource.ctx as Context, location)
68
+
69
+ return context.service<PostgresDbService>(serviceAlias ?? dbAlias)
70
+ }
71
+
72
+ const handle = async (): Promise<PostgresDb> => {
73
+ const postgres = service()
74
+ await postgres.ready()
75
+
76
+ return postgres.db(dbAlias)
77
+ }
78
+
79
+ /**
80
+ * Resources are normally initialized by the context, but a resource registered after
81
+ * `init()` — or reached from a spec that never ran one — would otherwise fail with an
82
+ * opaque "undefined table". Initializing on demand, once, keeps both paths working.
83
+ */
84
+ const ensure = async (): Promise<{ db: PostgresDb, spec: TableSpec, entity: PgRuntimeTable }> => {
85
+ const db = await handle()
86
+ if (spec == null || entity == null) {
87
+ initializing = initializing ?? resource.init!()
88
+ await initializing
89
+ }
90
+ if (spec == null || entity == null) {
91
+ throw new SyntaxError(`Postgres resource not initialized: ${alias}`)
92
+ }
93
+
94
+ return { db, spec, entity }
95
+ }
96
+
97
+ const columnOf = (table: TableSpec, property: string): ColumnSpec => {
98
+ const column = table.byProperty[property]
99
+ if (column == null) {
100
+ throw new UnsupportedArgumentError(`field:${property}`)
101
+ }
102
+
103
+ return column
104
+ }
105
+
106
+ const identify = (table: TableSpec, property: string, value: unknown): SQL => {
107
+ const column = columnOf(table, property)
108
+
109
+ return eq(entity![column.property], value)
110
+ }
111
+
112
+ const raw = async <Row extends QueryResultRow = QueryResultRow>(
113
+ text: string, params?: unknown[]
114
+ ): Promise<{ rows: Row[], count: number }> => {
115
+ const { db, spec: table } = await ensure()
116
+ const context = assertContext<Config, Context>(resource.ctx as Context, location)
117
+ try {
118
+ const result = await db.pool.query<Row>(
119
+ resolvePlaceholders(text, context, table), params as never[]
120
+ )
121
+
122
+ return { rows: result.rows, count: result.rowCount ?? 0 }
123
+ } catch (error) {
124
+ throw pgErrorToResourceError(error)
125
+ }
126
+ }
127
+
128
+ const resource: T = appendContextual<T>(alias, {
129
+ get: async (id, field, opts) => {
130
+ const record = await resource.load(id, field, opts)
131
+ if (record == null) {
132
+ throw new UnknownRecordError(id)
133
+ }
134
+
135
+ return record
136
+ },
137
+
138
+ load: async (id, field, opts) => {
139
+ if (typeof field === 'object') {
140
+ opts = field
141
+ field = field.field
142
+ }
143
+ if (ttlOf(opts) != null) {
144
+ /** Postgres has no row expiry — silently ignoring a TTL would lose data. */
145
+ throw new UnsupportedArgumentError('ttl')
146
+ }
147
+ const { db, spec: table, entity: from } = await ensure()
148
+
149
+ const rows = await db.drizzle.select().from(pgTable(from))
150
+ .where(identify(table, field ?? ID_FIELD, id)).limit(1)
151
+
152
+ return rows.length < 1 ? null : resultToRecord(rows[0] as Record<string, unknown>, table)
153
+ },
154
+
155
+ list: async (criteria, opts) => {
156
+ const { db, spec: table, entity: from } = await ensure()
157
+ const options = prepareListOptions(DEFAULT_PAGE_SIZE, criteria, opts)
158
+ const pager: ListPager = options.pager ?? {}
159
+ const size = pager.size ?? DEFAULT_PAGE_SIZE
160
+ const where = criteriaToSql(options.criteria, table, from)
161
+
162
+ const totals = await db.drizzle
163
+ .select({ total: sql<number>`count(*)::int` }).from(pgTable(from)).where(where)
164
+ const total = Number((totals[0] as { total: number } | undefined)?.total ?? 0)
165
+ pager.total = total
166
+
167
+ const skip = (pager.page ?? 0) * size
168
+ if (total === 0 || skip >= total) {
169
+ return { items: [], pager }
170
+ }
171
+
172
+ const rows = await db.drizzle.select().from(pgTable(from)).where(where)
173
+ .orderBy(...sortToSql(pager.sort, table, from)).limit(size).offset(skip)
174
+
175
+ return { pager, items: rows.map(row => resultToRecord(row as Record<string, unknown>, table)) }
176
+ },
177
+
178
+ save: async (record, opts) => {
179
+ const field = fieldOf(opts)
180
+ const present = field != null
181
+ ? record[field as keyof typeof record] != null
182
+ : record.id != null
183
+
184
+ return present
185
+ ? resource.update(record, opts)
186
+ : resource.create(record, typeof opts !== 'string' ? opts : undefined)
187
+ },
188
+
189
+ create: async (record, opts) => {
190
+ if (ID_FIELD in record && record.id == null) {
191
+ delete record.id
192
+ }
193
+ if (record.id != null) {
194
+ throw new RecordExists('id-present')
195
+ }
196
+ if (opts?.ttl != null) {
197
+ throw new UnsupportedArgumentError('ttl')
198
+ }
199
+
200
+ return resource.insert(record)
201
+ },
202
+
203
+ insert: async record => {
204
+ const { db, spec: table, entity: into } = await ensure()
205
+ const values = recordToValues(
206
+ { ...resource.getDefaults(), ...record } as Record<string, unknown>, table
207
+ )
208
+ try {
209
+ const rows = await db.drizzle.insert(pgTable(into)).values(values as never).returning()
210
+ if (rows.length < 1) {
211
+ throw new RecordUpdateFailed('creation')
212
+ }
213
+
214
+ return resultToRecord(rows[0] as Record<string, unknown>, table)
215
+ } catch (error) {
216
+ throw pgErrorToResourceError(error)
217
+ }
218
+ },
219
+
220
+ upsert: async (record, conflict) => {
221
+ const { db, spec: table, entity: into } = await ensure()
222
+ const values = recordToValues(
223
+ { ...resource.getDefaults(), ...record } as Record<string, unknown>, table
224
+ )
225
+ /** `conflict` names properties; the primary key is the default arbiter. */
226
+ const properties = conflict ?? table.primaryKey
227
+ .map(column => table.byColumn[column]?.property)
228
+ .filter((property): property is string => property != null)
229
+ const target = properties.map(property => into[columnOf(table, property).property])
230
+ if (target.length < 1) {
231
+ throw new UnsupportedArgumentError('upsert:no-conflict-target')
232
+ }
233
+ /** The arbiter columns identify the row — updating them to themselves is noise. */
234
+ const set = { ...values }
235
+ for (const property of properties) {
236
+ delete set[property]
237
+ }
238
+ if (Object.keys(set).length < 1) {
239
+ throw new UnsupportedArgumentError('upsert:nothing-to-update')
240
+ }
241
+
242
+ try {
243
+ const rows = await db.drizzle.insert(pgTable(into)).values(values as never)
244
+ .onConflictDoUpdate({ target: target as never, set: set as never }).returning()
245
+ if (rows.length < 1) {
246
+ throw new RecordUpdateFailed('upsert')
247
+ }
248
+
249
+ return resultToRecord(rows[0] as Record<string, unknown>, table)
250
+ } catch (error) {
251
+ throw pgErrorToResourceError(error)
252
+ }
253
+ },
254
+
255
+ update: async (record, opts) => {
256
+ const { db, spec: table, entity: target } = await ensure()
257
+ const field = fieldOf(opts) ?? ID_FIELD
258
+ const key = record[field as keyof typeof record]
259
+ if (key == null) {
260
+ throw new MisshapedRecord(field === ID_FIELD ? ID_FIELD : 'no-field-value')
261
+ }
262
+
263
+ /** Replace, not merge — the semantics mongo's `replaceOne` gives. `patch()` merges. */
264
+ const values = recordToFullValues(record as Record<string, unknown>, table)
265
+ try {
266
+ const rows = await db.drizzle.update(pgTable(target)).set(values as never)
267
+ .where(identify(table, field, key)).returning()
268
+ if (rows.length < 1) {
269
+ throw new UnknownRecordError(`${field}:${key}`)
270
+ }
271
+
272
+ return resultToRecord(rows[0] as Record<string, unknown>, table)
273
+ } catch (error) {
274
+ throw pgErrorToResourceError(error)
275
+ }
276
+ },
277
+
278
+ patch: async (record, opts) => {
279
+ const { db, spec: table, entity: target } = await ensure()
280
+ const field = (typeof opts === 'string' ? opts : opts?.field) ?? ID_FIELD
281
+ const key = record[field as keyof typeof record]
282
+ if (key == null) {
283
+ throw new MisshapedRecord(field === ID_FIELD ? ID_FIELD : 'no-field-value')
284
+ }
285
+
286
+ const values = recordToValues(record as Record<string, unknown>, table)
287
+ for (const column of table.primaryKey) {
288
+ delete values[table.byColumn[column]?.property ?? column]
289
+ }
290
+ if (Object.keys(values).length < 1) {
291
+ return resource.get(`${key}`, field)
292
+ }
293
+
294
+ try {
295
+ const rows = await db.drizzle.update(pgTable(target)).set(values as never)
296
+ .where(identify(table, field, key)).returning()
297
+ if (rows.length < 1) {
298
+ throw new UnknownRecordError(`${field}:${key}`)
299
+ }
300
+
301
+ return resultToRecord(rows[0] as Record<string, unknown>, table)
302
+ } catch (error) {
303
+ throw pgErrorToResourceError(error)
304
+ }
305
+ },
306
+
307
+ delete: async (id, opts) => {
308
+ const { db, spec: table, entity: from } = await ensure()
309
+ const field = fieldOf(opts) ?? ID_FIELD
310
+ const key = typeof id === 'object' ? id[field as keyof typeof id] : id
311
+ if (key == null) {
312
+ throw new MisshapedRecord(field === ID_FIELD ? ID_FIELD : 'no-field-value')
313
+ }
314
+
315
+ /** One statement — mongo's read-then-delete can lose the record between the two. */
316
+ const rows = await db.drizzle.delete(pgTable(from))
317
+ .where(identify(table, field, key)).returning()
318
+
319
+ return rows.length < 1 ? null : resultToRecord(rows[0] as Record<string, unknown>, table)
320
+ },
321
+
322
+ pick: async (id, opts) => {
323
+ const record = await resource.delete(id, opts)
324
+ if (record == null) {
325
+ throw new UnknownRecordError(typeof id === 'string' ? id : (id.id ?? 'unknown'))
326
+ }
327
+
328
+ return record
329
+ },
330
+
331
+ purge: async criteria => {
332
+ const { db, spec: table, entity: from } = await ensure()
333
+ const where = criteriaToSql(criteria, table, from)
334
+ if (where == null) {
335
+ /** An empty criteria object here would silently truncate the table. */
336
+ throw new UnsupportedArgumentError('purge:no-criteria')
337
+ }
338
+ const rows = await db.drizzle.delete(pgTable(from)).where(where).returning()
339
+
340
+ return rows.length
341
+ },
342
+
343
+ count: async criteria => {
344
+ const { db, spec: table, entity: from } = await ensure()
345
+ const options = prepareListOptions(DEFAULT_PAGE_SIZE, criteria)
346
+ const rows = await db.drizzle.select({ total: sql<number>`count(*)::int` })
347
+ .from(pgTable(from)).where(criteriaToSql(options.criteria, table, from))
348
+
349
+ return Number((rows[0] as { total: number } | undefined)?.total ?? 0)
350
+ },
351
+
352
+ getDefaults: () => {
353
+ const schema = resource.schema as JSONSchemaType<unknown> | undefined
354
+ if (schema == null) {
355
+ return {}
356
+ }
357
+
358
+ return Object.entries(schema.properties ?? {}).reduce<Record<string, unknown>>(
359
+ (defaults, [key, value]) => {
360
+ const property = value as { default?: unknown }
361
+ if (property.default === undefined) {
362
+ return defaults
363
+ }
364
+
365
+ return { ...defaults, [key]: property.default }
366
+ }, {}) as Partial<R>
367
+ },
368
+
369
+ query: async (text, params) => (await raw(text, params)).rows,
370
+
371
+ queryOne: async (text, params) => (await raw(text, params)).rows[0] ?? null,
372
+
373
+ execute: async (text, params) => (await raw(text, params)).count,
374
+
375
+ select: async (text, params) => {
376
+ const { spec: table } = await ensure()
377
+ const result = await raw(text, params)
378
+
379
+ return result.rows.map(row => rowToRecord(row, table))
380
+ },
381
+
382
+ selectOne: async (text, params) => {
383
+ const { spec: table } = await ensure()
384
+ const result = await raw(text, params)
385
+
386
+ return result.rows.length < 1 ? null : rowToRecord(result.rows[0], table)
387
+ },
388
+
389
+ ref: resourceAlias => {
390
+ const context = assertContext<Config, Context>(resource.ctx as Context, location)
391
+ if (spec == null) {
392
+ throw new SyntaxError(`Postgres resource not initialized: ${alias}`)
393
+ }
394
+
395
+ return refOf(context, spec, resourceAlias)
396
+ },
397
+
398
+ transaction: async fn => {
399
+ const { db, spec: table } = await ensure()
400
+ const context = assertContext<Config, Context>(resource.ctx as Context, location)
401
+ const client: PoolClient = await db.pool.connect()
402
+ try {
403
+ await client.query('BEGIN')
404
+ const result = await fn(makeTx(
405
+ client,
406
+ text => resolvePlaceholders(text, context, table),
407
+ resourceAlias => refOf(context, table, resourceAlias)
408
+ ))
409
+ await client.query('COMMIT')
410
+
411
+ return result
412
+ } catch (error) {
413
+ await client.query('ROLLBACK').catch(() => undefined)
414
+ throw pgErrorToResourceError(error)
415
+ } finally {
416
+ client.release()
417
+ }
418
+ },
419
+
420
+ lock: async (record, fields) => {
421
+ fields ??= getSchemaSecureFeilds(resource.schema ?? {})
422
+ if (fields.length < 1) {
423
+ throw new SyntaxError(`No fields to lock: ${JSON.stringify(record)}`)
424
+ }
425
+
426
+ return service().lock(dbAlias, record, fields) as Promise<R>
427
+ },
428
+
429
+ unlock: async (record, fields) => {
430
+ fields ??= getSchemaSecureFeilds(resource.schema ?? {})
431
+ if (fields.length < 1) {
432
+ throw new SyntaxError(`No fields to unlock: ${JSON.stringify(record)}`)
433
+ }
434
+
435
+ return service().unlock(dbAlias, record, fields) as Promise<R>
436
+ },
437
+
438
+ db: async () => handle(),
439
+
440
+ client: async () => {
441
+ const postgres = service()
442
+ await postgres.ready()
443
+
444
+ return postgres.client(dbAlias)
445
+ },
446
+
447
+ index: <Type extends PostgresResource<R>>(name: string, index: PgIndexSpec) => {
448
+ declaration.indexes.push({ ...index, name })
449
+
450
+ return resource as unknown as Type
451
+ },
452
+
453
+ migration: <Type extends PostgresResource<R>>(
454
+ name: string, apply: (tx: PostgresTx) => Promise<void>, stage?: MigrationStage
455
+ ) => {
456
+ declaration.migrations.register(name, apply, stage)
457
+
458
+ return resource as unknown as Type
459
+ }
460
+ } as Partial<T>)
461
+
462
+ /**
463
+ * The AJV schema is stored per alias rather than on the object, so it survives
464
+ * `reinitializeContext` — which rebuilds the resource and would otherwise drop a schema
465
+ * the app assigned after construction, silently taking the table's structure with it.
466
+ */
467
+ Object.defineProperty(resource, 'schema', {
468
+ enumerable: true,
469
+ get: (): AnySchema | undefined => declaration.schema,
470
+ set: (schema: AnySchema | undefined) => { declaration.schema = schema }
471
+ })
472
+
473
+ Object.defineProperty(resource, 'table', {
474
+ enumerable: true,
475
+ get: (): TableSpec | undefined => spec
476
+ })
477
+
478
+ Object.defineProperty(resource, 'entity', {
479
+ enumerable: true,
480
+ get: (): PgRuntimeTable | undefined => entity
481
+ })
482
+
483
+ Object.defineProperty(resource, 'migrations', {
484
+ enumerable: true,
485
+ get: () => declaration.migrations
486
+ })
487
+
488
+ /**
489
+ * Explicit table name override, decoupled from the registration alias — an alias may
490
+ * carry characters no identifier allows. Threaded back through the recursive maker call
491
+ * below so it survives a context switch.
492
+ */
493
+ if (tableName != null) {
494
+ resource.name = tableName
495
+ }
496
+
497
+ resource.init = async () => {
498
+ const context = assertContext<Config, Context>(resource.ctx as Context, location)
499
+ const postgres = service()
500
+ await postgres.ready()
501
+ const db = await postgres.db(dbAlias)
502
+ const config = postgres.config(dbAlias)
503
+
504
+ const initialized = await initializeTable(
505
+ db, config, resource as unknown as PostgresResource<ResourceRecord>, context,
506
+ task => postgres.defer(dbAlias, task)
507
+ )
508
+ spec = initialized.spec
509
+ entity = initialized.entity
510
+ }
511
+
512
+ resource.reinitializeContext = <Type extends Contextual>(context: BasicContext<Config>) => {
513
+ const replacement = (makeCustomResource?.(dbAlias, serviceAlias)
514
+ ?? makePostgresResource<R, T>(
515
+ alias, dbAlias, serviceAlias, makeCustomResource, tableName
516
+ )) as unknown as Type
517
+
518
+ replacement.ctx = context
519
+
520
+ return replacement
521
+ }
522
+
523
+ return resource
524
+ }