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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +185 -101
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/postgres-resource/SKILL.md +82 -18
  4. package/build/consts.d.ts +8 -1
  5. package/build/consts.d.ts.map +1 -1
  6. package/build/consts.js +8 -1
  7. package/build/consts.js.map +1 -1
  8. package/build/declarations.js +5 -5
  9. package/build/resource.d.ts +2 -2
  10. package/build/resource.d.ts.map +1 -1
  11. package/build/resource.js +95 -108
  12. package/build/resource.js.map +1 -1
  13. package/build/types.d.ts +27 -19
  14. package/build/types.d.ts.map +1 -1
  15. package/build/utils/criteria.d.ts +10 -6
  16. package/build/utils/criteria.d.ts.map +1 -1
  17. package/build/utils/criteria.js +13 -5
  18. package/build/utils/criteria.js.map +1 -1
  19. package/build/utils/diff.d.ts.map +1 -1
  20. package/build/utils/diff.js +31 -1
  21. package/build/utils/diff.js.map +1 -1
  22. package/build/utils/migrations.d.ts +2 -2
  23. package/build/utils/migrations.js +2 -2
  24. package/build/utils/name.d.ts +3 -3
  25. package/build/utils/name.js +3 -3
  26. package/build/utils/sql.d.ts +10 -2
  27. package/build/utils/sql.d.ts.map +1 -1
  28. package/build/utils/sql.js.map +1 -1
  29. package/build/utils/table.d.ts +4 -0
  30. package/build/utils/table.d.ts.map +1 -1
  31. package/build/utils/table.js +4 -0
  32. package/build/utils/table.js.map +1 -1
  33. package/package.json +7 -7
  34. package/src/consts.ts +8 -1
  35. package/src/declarations.ts +5 -5
  36. package/src/resource.ts +118 -135
  37. package/src/types.ts +29 -18
  38. package/src/utils/criteria.ts +25 -17
  39. package/src/utils/diff.ts +31 -1
  40. package/src/utils/migrations.ts +2 -2
  41. package/src/utils/name.ts +3 -3
  42. package/src/utils/sql.ts +0 -0
  43. package/src/utils/table.ts +5 -1
  44. package/tests/criteria.spec.ts +187 -0
  45. package/tests/diff.spec.ts +60 -0
  46. package/tests/name.spec.ts +3 -3
  47. package/tests/sql.spec.ts +4 -3
package/src/resource.ts CHANGED
@@ -1,17 +1,14 @@
1
1
  import { appendContextual, assertContext } from '@owlmeans/context'
2
- import type { BasicContext, Contextual } from '@owlmeans/context'
3
2
  import {
4
- MisshapedRecord, RecordExists, RecordUpdateFailed, UnknownRecordError,
5
- UnsupportedArgumentError, prepareListOptions
3
+ MisshapedRecord, RecordExists, RecordUpdateFailed, UnknownRecordError, UnsupportedArgumentError
6
4
  } from '@owlmeans/resource'
7
5
  import type {
8
- ListPager, MigrationStage, ResourceMaker, ResourceRecord
6
+ Criteria, FirstOptions, ListOptions, ListResult, MigrationStage, ResourceRecord, WriteOptions
9
7
  } from '@owlmeans/resource'
10
8
  import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
11
9
  import type { AnySchema, JSONSchemaType } from 'ajv'
12
10
  import { eq, sql } from 'drizzle-orm'
13
11
  import type { SQL } from 'drizzle-orm'
14
- import type { PgTable } from 'drizzle-orm/pg-core'
15
12
  import type { PoolClient, QueryResultRow } from 'pg'
16
13
 
17
14
  import { DEFAULT_DB_ALIAS, DEFAULT_PAGE_SIZE, ID_FIELD } from './consts.js'
@@ -31,29 +28,18 @@ import { refOf, resolvePlaceholders } from './utils/sql.js'
31
28
  type Config = ServerConfig
32
29
  type Context<C extends Config = Config> = ServerContext<C>
33
30
 
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
31
+ /** Postgres has no row expiry — silently ignoring a TTL would lose data. */
32
+ const refuseTtl = (opts?: WriteOptions): void => {
33
+ if (opts?.ttl != null) {
34
+ throw new UnsupportedArgumentError('ttl')
35
+ }
36
+ }
51
37
 
52
38
  export const makePostgresResource = <
53
39
  R extends ResourceRecord, T extends PostgresResource<R> = PostgresResource<R>
54
40
  >(
55
41
  alias: string, dbAlias: string = DEFAULT_DB_ALIAS, serviceAlias: string = DEFAULT_DB_ALIAS,
56
- makeCustomResource?: ResourceMaker<R, T>, tableName?: string
42
+ tableName?: string
57
43
  ): T => {
58
44
  const location = `postgres-resource:${alias}`
59
45
  const declaration = getDeclaration(alias)
@@ -103,11 +89,24 @@ export const makePostgresResource = <
103
89
  return column
104
90
  }
105
91
 
106
- const identify = (table: TableSpec, property: string, value: unknown): SQL => {
107
- const column = columnOf(table, property)
92
+ /** `WHERE <column> = <value>` for one property, by its schema name. */
93
+ const identify = (
94
+ table: TableSpec, from: PgRuntimeTable, property: string, value: unknown
95
+ ): SQL => eq(from[columnOf(table, property).property], value)
108
96
 
109
- return eq(entity![column.property], value)
110
- }
97
+ /**
98
+ * The condition a read or a write is keyed on. A string is the record's id; a criteria
99
+ * object is translated in full, so one call can ask by several fields at once.
100
+ */
101
+ const where = (
102
+ table: TableSpec, from: PgRuntimeTable, query: string | Criteria<R>
103
+ ): SQL | undefined => typeof query === 'string'
104
+ ? identify(table, from, ID_FIELD, query)
105
+ : criteriaToSql(query, table, from)
106
+
107
+ /** What an unknown record is called in the error — the id, or the query that missed. */
108
+ const describe = (query: string | Criteria<R>): string =>
109
+ typeof query === 'string' ? query : JSON.stringify(query)
111
110
 
112
111
  const raw = async <Row extends QueryResultRow = QueryResultRow>(
113
112
  text: string, params?: unknown[]
@@ -125,67 +124,68 @@ export const makePostgresResource = <
125
124
  }
126
125
  }
127
126
 
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
- }
127
+ /**
128
+ * The single-record read both `get` and `load` answer with. Standalone so neither closes over
129
+ * `resource` — a member that referenced it could not be inferred against the generic.
130
+ */
131
+ const loadOne = async (query: string | Criteria<R>, opts?: FirstOptions<R>): Promise<R | null> => {
132
+ const { db, spec: table, entity: from } = await ensure()
134
133
 
135
- return record
136
- },
134
+ const rows = await db.drizzle.select().from(from).where(where(table, from, query))
135
+ .orderBy(...sortToSql(opts?.sort, table, from)).limit(1)
137
136
 
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')
137
+ return rows.length < 1 ? null : resultToRecord<R>(rows[0] as Record<string, unknown>, table)
138
+ }
139
+
140
+ const members: Partial<PostgresResource<R>> = {
141
+ get: (async (query: string | Criteria<R>, opts?: FirstOptions<R>): Promise<R> => {
142
+ const record = await loadOne(query, opts)
143
+ if (record == null) {
144
+ throw new UnknownRecordError(describe(query))
146
145
  }
147
- const { db, spec: table, entity: from } = await ensure()
148
146
 
149
- const rows = await db.drizzle.select().from(pgTable(from))
150
- .where(identify(table, field ?? ID_FIELD, id)).limit(1)
147
+ return record
148
+ }) as T['get'],
151
149
 
152
- return rows.length < 1 ? null : resultToRecord(rows[0] as Record<string, unknown>, table)
153
- },
150
+ load: loadOne as T['load'],
154
151
 
155
- list: async (criteria, opts) => {
152
+ list: async (criteria?: Criteria<R>, opts?: ListOptions<R>): Promise<ListResult<R>> => {
156
153
  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)
154
+ const condition = criteriaToSql(criteria, table, from)
155
+ const order = sortToSql(opts?.sort, table, from)
161
156
 
157
+ /** Counted separately, so `total` describes the whole match rather than the page. */
162
158
  const totals = await db.drizzle
163
- .select({ total: sql<number>`count(*)::int` }).from(pgTable(from)).where(where)
159
+ .select({ total: sql<number>`count(*)::int` }).from(from).where(condition)
164
160
  const total = Number((totals[0] as { total: number } | undefined)?.total ?? 0)
165
- pager.total = total
166
161
 
167
- const skip = (pager.page ?? 0) * size
168
- if (total === 0 || skip >= total) {
169
- return { items: [], pager }
170
- }
162
+ const marshal = (rows: unknown[]): R[] =>
163
+ rows.map(row => resultToRecord<R>(row as Record<string, unknown>, table))
171
164
 
172
- const rows = await db.drizzle.select().from(pgTable(from)).where(where)
173
- .orderBy(...sortToSql(pager.sort, table, from)).limit(size).offset(skip)
165
+ /** `size: 0` lifts the limit — the explicit, greppable way to read a whole table. */
166
+ const size = opts?.size ?? DEFAULT_PAGE_SIZE
167
+ if (size === 0) {
168
+ const all = total === 0
169
+ ? []
170
+ : await db.drizzle.select().from(from).where(condition).orderBy(...order)
174
171
 
175
- return { pager, items: rows.map(row => resultToRecord(row as Record<string, unknown>, table)) }
176
- },
172
+ return { items: marshal(all), total }
173
+ }
177
174
 
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
175
+ const page = opts?.page ?? 0
176
+ const skip = page * size
177
+ const rows = total === 0 || skip >= total
178
+ ? []
179
+ : await db.drizzle.select().from(from).where(condition).orderBy(...order)
180
+ .limit(size).offset(skip)
183
181
 
184
- return present
185
- ? resource.update(record, opts)
186
- : resource.create(record, typeof opts !== 'string' ? opts : undefined)
182
+ return { items: marshal(rows), total, page, size }
187
183
  },
188
184
 
185
+ save: async (record, opts) => record.id != null
186
+ ? resource.update(record, opts)
187
+ : resource.create(record, opts),
188
+
189
189
  create: async (record, opts) => {
190
190
  if (ID_FIELD in record && record.id == null) {
191
191
  delete record.id
@@ -193,9 +193,7 @@ export const makePostgresResource = <
193
193
  if (record.id != null) {
194
194
  throw new RecordExists('id-present')
195
195
  }
196
- if (opts?.ttl != null) {
197
- throw new UnsupportedArgumentError('ttl')
198
- }
196
+ refuseTtl(opts)
199
197
 
200
198
  return resource.insert(record)
201
199
  },
@@ -206,12 +204,12 @@ export const makePostgresResource = <
206
204
  { ...resource.getDefaults(), ...record } as Record<string, unknown>, table
207
205
  )
208
206
  try {
209
- const rows = await db.drizzle.insert(pgTable(into)).values(values as never).returning()
207
+ const rows = await db.drizzle.insert(into).values(values as never).returning()
210
208
  if (rows.length < 1) {
211
209
  throw new RecordUpdateFailed('creation')
212
210
  }
213
211
 
214
- return resultToRecord(rows[0] as Record<string, unknown>, table)
212
+ return resultToRecord<R>(rows[0] as Record<string, unknown>, table)
215
213
  } catch (error) {
216
214
  throw pgErrorToResourceError(error)
217
215
  }
@@ -240,47 +238,47 @@ export const makePostgresResource = <
240
238
  }
241
239
 
242
240
  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()
241
+ const rows = await db.drizzle.insert(into).values(values as never)
242
+ .onConflictDoUpdate({ target, set: set as never }).returning()
245
243
  if (rows.length < 1) {
246
244
  throw new RecordUpdateFailed('upsert')
247
245
  }
248
246
 
249
- return resultToRecord(rows[0] as Record<string, unknown>, table)
247
+ return resultToRecord<R>(rows[0] as Record<string, unknown>, table)
250
248
  } catch (error) {
251
249
  throw pgErrorToResourceError(error)
252
250
  }
253
251
  },
254
252
 
255
253
  update: async (record, opts) => {
254
+ refuseTtl(opts)
256
255
  const { db, spec: table, entity: target } = await ensure()
257
- const field = fieldOf(opts) ?? ID_FIELD
258
- const key = record[field as keyof typeof record]
256
+ const key = record.id
259
257
  if (key == null) {
260
- throw new MisshapedRecord(field === ID_FIELD ? ID_FIELD : 'no-field-value')
258
+ throw new MisshapedRecord(ID_FIELD)
261
259
  }
262
260
 
263
261
  /** Replace, not merge — the semantics mongo's `replaceOne` gives. `patch()` merges. */
264
262
  const values = recordToFullValues(record as Record<string, unknown>, table)
265
263
  try {
266
- const rows = await db.drizzle.update(pgTable(target)).set(values as never)
267
- .where(identify(table, field, key)).returning()
264
+ const rows = await db.drizzle.update(target).set(values as never)
265
+ .where(identify(table, target, ID_FIELD, key)).returning()
268
266
  if (rows.length < 1) {
269
- throw new UnknownRecordError(`${field}:${key}`)
267
+ throw new UnknownRecordError(`${key}`)
270
268
  }
271
269
 
272
- return resultToRecord(rows[0] as Record<string, unknown>, table)
270
+ return resultToRecord<R>(rows[0] as Record<string, unknown>, table)
273
271
  } catch (error) {
274
272
  throw pgErrorToResourceError(error)
275
273
  }
276
274
  },
277
275
 
278
276
  patch: async (record, opts) => {
277
+ refuseTtl(opts)
279
278
  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]
279
+ const key = record.id
282
280
  if (key == null) {
283
- throw new MisshapedRecord(field === ID_FIELD ? ID_FIELD : 'no-field-value')
281
+ throw new MisshapedRecord(ID_FIELD)
284
282
  }
285
283
 
286
284
  const values = recordToValues(record as Record<string, unknown>, table)
@@ -288,41 +286,36 @@ export const makePostgresResource = <
288
286
  delete values[table.byColumn[column]?.property ?? column]
289
287
  }
290
288
  if (Object.keys(values).length < 1) {
291
- return resource.get(`${key}`, field)
289
+ return resource.get(`${key}`)
292
290
  }
293
291
 
294
292
  try {
295
- const rows = await db.drizzle.update(pgTable(target)).set(values as never)
296
- .where(identify(table, field, key)).returning()
293
+ const rows = await db.drizzle.update(target).set(values as never)
294
+ .where(identify(table, target, ID_FIELD, key)).returning()
297
295
  if (rows.length < 1) {
298
- throw new UnknownRecordError(`${field}:${key}`)
296
+ throw new UnknownRecordError(`${key}`)
299
297
  }
300
298
 
301
- return resultToRecord(rows[0] as Record<string, unknown>, table)
299
+ return resultToRecord<R>(rows[0] as Record<string, unknown>, table)
302
300
  } catch (error) {
303
301
  throw pgErrorToResourceError(error)
304
302
  }
305
303
  },
306
304
 
307
- delete: async (id, opts) => {
305
+ delete: async id => {
308
306
  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
307
 
315
308
  /** 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()
309
+ const rows = await db.drizzle.delete(from)
310
+ .where(identify(table, from, ID_FIELD, id)).returning()
318
311
 
319
- return rows.length < 1 ? null : resultToRecord(rows[0] as Record<string, unknown>, table)
312
+ return rows.length < 1 ? null : resultToRecord<R>(rows[0] as Record<string, unknown>, table)
320
313
  },
321
314
 
322
- pick: async (id, opts) => {
323
- const record = await resource.delete(id, opts)
315
+ take: async id => {
316
+ const record = await resource.delete(id)
324
317
  if (record == null) {
325
- throw new UnknownRecordError(typeof id === 'string' ? id : (id.id ?? 'unknown'))
318
+ throw new UnknownRecordError(id)
326
319
  }
327
320
 
328
321
  return record
@@ -330,21 +323,20 @@ export const makePostgresResource = <
330
323
 
331
324
  purge: async criteria => {
332
325
  const { db, spec: table, entity: from } = await ensure()
333
- const where = criteriaToSql(criteria, table, from)
334
- if (where == null) {
326
+ const condition = criteriaToSql(criteria, table, from)
327
+ if (condition == null) {
335
328
  /** An empty criteria object here would silently truncate the table. */
336
329
  throw new UnsupportedArgumentError('purge:no-criteria')
337
330
  }
338
- const rows = await db.drizzle.delete(pgTable(from)).where(where).returning()
331
+ const rows = await db.drizzle.delete(from).where(condition).returning()
339
332
 
340
333
  return rows.length
341
334
  },
342
335
 
343
336
  count: async criteria => {
344
337
  const { db, spec: table, entity: from } = await ensure()
345
- const options = prepareListOptions(DEFAULT_PAGE_SIZE, criteria)
346
338
  const rows = await db.drizzle.select({ total: sql<number>`count(*)::int` })
347
- .from(pgTable(from)).where(criteriaToSql(options.criteria, table, from))
339
+ .from(from).where(criteriaToSql(criteria, table, from))
348
340
 
349
341
  return Number((rows[0] as { total: number } | undefined)?.total ?? 0)
350
342
  },
@@ -366,9 +358,9 @@ export const makePostgresResource = <
366
358
  }, {}) as Partial<R>
367
359
  },
368
360
 
369
- query: async (text, params) => (await raw(text, params)).rows,
361
+ query: (async (text: string, params?: unknown[]) => (await raw(text, params)).rows) as T['query'],
370
362
 
371
- queryOne: async (text, params) => (await raw(text, params)).rows[0] ?? null,
363
+ queryOne: (async (text: string, params?: unknown[]) => (await raw(text, params)).rows[0] ?? null) as T['queryOne'],
372
364
 
373
365
  execute: async (text, params) => (await raw(text, params)).count,
374
366
 
@@ -376,14 +368,14 @@ export const makePostgresResource = <
376
368
  const { spec: table } = await ensure()
377
369
  const result = await raw(text, params)
378
370
 
379
- return result.rows.map(row => rowToRecord(row, table))
371
+ return result.rows.map(row => rowToRecord<R>(row, table))
380
372
  },
381
373
 
382
374
  selectOne: async (text, params) => {
383
375
  const { spec: table } = await ensure()
384
376
  const result = await raw(text, params)
385
377
 
386
- return result.rows.length < 1 ? null : rowToRecord(result.rows[0], table)
378
+ return result.rows.length < 1 ? null : rowToRecord<R>(result.rows[0], table)
387
379
  },
388
380
 
389
381
  ref: resourceAlias => {
@@ -444,13 +436,14 @@ export const makePostgresResource = <
444
436
  return postgres.client(dbAlias)
445
437
  },
446
438
 
447
- index: <Type extends PostgresResource<R>>(name: string, index: PgIndexSpec) => {
439
+ /** Chainable — the declaration store is keyed by alias, so this object is the whole state. */
440
+ index: (name: string, index: PgIndexSpec) => {
448
441
  declaration.indexes.push({ ...index, name })
449
442
 
450
- return resource as unknown as Type
443
+ return resource
451
444
  },
452
445
 
453
- /** `this`-returning in the interface — the implementation returns that very object. */
446
+ /** Self-returning in the interface — the implementation returns that very object. */
454
447
  migration: ((name: string, apply: (tx: PostgresTx) => Promise<void>, stage?: MigrationStage) => {
455
448
  declaration.migrations.register(name, apply, stage)
456
449
 
@@ -458,12 +451,14 @@ export const makePostgresResource = <
458
451
  }) as T['migration'],
459
452
 
460
453
  migrations: () => declaration.migrations
461
- } as Partial<T>)
454
+ }
455
+
456
+ const resource: T = appendContextual<T>(alias, members as Partial<T>)
462
457
 
463
458
  /**
464
- * The AJV schema is stored per alias rather than on the object, so it survives
465
- * `reinitializeContext` — which rebuilds the resource and would otherwise drop a schema
466
- * the app assigned after construction, silently taking the table's structure with it.
459
+ * The AJV schema is stored per alias rather than on the object, so a second maker run for
460
+ * the same alias (a custom maker, a repeated maker, a spec) picks up the schema the app
461
+ * assigned after construction instead of silently taking the table's structure with it.
467
462
  */
468
463
  Object.defineProperty(resource, 'schema', {
469
464
  enumerable: true,
@@ -483,8 +478,7 @@ export const makePostgresResource = <
483
478
 
484
479
  /**
485
480
  * Explicit table name override, decoupled from the registration alias — an alias may
486
- * carry characters no identifier allows. Threaded back through the recursive maker call
487
- * below so it survives a context switch.
481
+ * carry characters no identifier allows.
488
482
  */
489
483
  if (tableName != null) {
490
484
  resource.name = tableName
@@ -505,16 +499,5 @@ export const makePostgresResource = <
505
499
  entity = initialized.entity
506
500
  }
507
501
 
508
- resource.reinitializeContext = <Type extends Contextual>(context: BasicContext<Config>) => {
509
- const replacement = (makeCustomResource?.(dbAlias, serviceAlias)
510
- ?? makePostgresResource<R, T>(
511
- alias, dbAlias, serviceAlias, makeCustomResource, tableName
512
- )) as unknown as Type
513
-
514
- replacement.ctx = context
515
-
516
- return replacement
517
- }
518
-
519
502
  return resource
520
503
  }
package/src/types.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import type {
2
- DbLocker, ListCriteria, ListOptions, MigratableResource, Resource,
3
- ResourceDbService, ResourceLocker, ResourceRecord
2
+ DbLocker, LockableResource, MigratableResource, Resource,
3
+ ResourceDbService, ResourceRecord, WriteOptions
4
4
  } from '@owlmeans/resource'
5
5
  import type { AnySchema } from 'ajv'
6
6
  import type { Pool, PoolClient, QueryResultRow } from 'pg'
7
7
  import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
8
+ import type { PgColumn, PgTable } from 'drizzle-orm/pg-core'
8
9
 
9
10
  import type { PgAutoSync, PgIndexMethod, PgReferentialAction } from './consts.js'
10
11
 
@@ -17,9 +18,9 @@ export interface PostgresDb {
17
18
  /** Drizzle bound to this config alias' pool. */
18
19
  drizzle: NodePgDatabase<Record<string, never>>
19
20
  pool: Pool
20
- /** The Postgres SCHEMA — i.e. `service.name(alias)`, layer suffixed. */
21
+ /** The Postgres SCHEMA this config alias' resources live in — i.e. `service.name(alias)`. */
21
22
  schema: string
22
- /** The Postgres DATABASE — i.e. `config.meta.database`. Never layer suffixed. */
23
+ /** The Postgres DATABASE the pool is connected to — i.e. `config.meta.database`. */
23
24
  database: string
24
25
  }
25
26
 
@@ -72,7 +73,16 @@ export interface PostgresTx {
72
73
  ref: (resourceAlias?: string) => string
73
74
  }
74
75
 
75
- export interface PostgresResource<T extends ResourceRecord> extends Resource<T>, ResourceLocker<T>, MigratableResource<PostgresTx> {
76
+ /**
77
+ * A `Resource<T>` over one Postgres table.
78
+ *
79
+ * Every method beyond the base contract exists because a table has structure a document
80
+ * store does not: identity supplied by the caller, conflict arbiters, partial writes, and
81
+ * SQL that no criteria object can express. Type the resource once —
82
+ * `context.resource<PostgresResource<Project>>(alias)` — rather than per call.
83
+ */
84
+ export interface PostgresResource<T extends ResourceRecord>
85
+ extends Resource<T>, LockableResource<T>, MigratableResource<PostgresTx, PostgresResource<T>> {
76
86
  /** Physical table name override. Defaults to the resource alias, sanitized. */
77
87
  name?: string
78
88
  /** The AJV schema — single source of truth for the table structure. */
@@ -86,7 +96,7 @@ export interface PostgresResource<T extends ResourceRecord> extends Resource<T>,
86
96
  client: () => Promise<Pool>
87
97
 
88
98
  /** Declare an index the JSON schema can't express. Chainable. */
89
- index: <Type extends PostgresResource<T>>(name: string, spec: PgIndexSpec) => Type
99
+ index: (name: string, spec: PgIndexSpec) => PostgresResource<T>
90
100
 
91
101
  getDefaults: () => Partial<T>
92
102
 
@@ -96,22 +106,19 @@ export interface PostgresResource<T extends ResourceRecord> extends Resource<T>,
96
106
  /** Returns the affected row count. */
97
107
  execute: (text: string, params?: unknown[]) => Promise<number>
98
108
  /** Rows marshalled back into `T` through the table spec. */
99
- select: <Type extends T>(text: string, params?: unknown[]) => Promise<Type[]>
100
- selectOne: <Type extends T>(text: string, params?: unknown[]) => Promise<Type | null>
109
+ select: (text: string, params?: unknown[]) => Promise<T[]>
110
+ selectOne: (text: string, params?: unknown[]) => Promise<T | null>
101
111
  /** Fully qualified `"schema"."table"`; pass an alias to reference another resource. */
102
112
  ref: (resourceAlias?: string) => string
103
113
 
104
114
  transaction: <R>(fn: (tx: PostgresTx) => Promise<R>) => Promise<R>
105
115
 
106
116
  /** Create with a caller supplied id — `create()` refuses one, mirroring mongo. */
107
- insert: <Type extends T>(record: Partial<Type>) => Promise<Type>
117
+ insert: (record: Partial<T>) => Promise<T>
108
118
  /** `INSERT ... ON CONFLICT (...) DO UPDATE`. Defaults to conflicting on the primary key. */
109
- upsert: <Type extends T>(record: Partial<Type>, conflict?: string[]) => Promise<Type>
119
+ upsert: (record: Partial<T>, conflict?: string[]) => Promise<T>
110
120
  /** Merge semantics — `update()` replaces the whole record, mirroring mongo's `replaceOne`. */
111
- patch: <Type extends T>(record: Partial<Type>, opts?: string | { field?: string }) => Promise<Type>
112
- /** Bulk delete by criteria. Returns the deleted row count. */
113
- purge: (criteria: ListCriteria) => Promise<number>
114
- count: (criteria?: ListCriteria | ListOptions) => Promise<number>
121
+ patch: (record: Partial<T>, opts?: WriteOptions) => Promise<T>
115
122
  }
116
123
 
117
124
  /** Compiled, database ready description of a resource's table. */
@@ -245,11 +252,15 @@ export interface PgReferenceSpec {
245
252
  }
246
253
 
247
254
  /**
248
- * The Drizzle table object. Deliberately opaque — tables are built at runtime from JSON
249
- * schemas, so Drizzle's compile time inference has nothing to infer from and every
250
- * column would be `any` anyway. Cast it at the call site when you need the typed builder.
255
+ * The Drizzle table `specToTable` compiles from a {@link TableSpec}.
256
+ *
257
+ * Both halves are load bearing. `PgTable` is what the query builder accepts and what keeps
258
+ * the rows it hands back usable — erasing it to `never` would collapse every result row.
259
+ * The index signature is how a column is reached: the columns are keyed by schema property
260
+ * name and only known at runtime, so `eq(entity[column.property], value)` is a plain lookup
261
+ * rather than a cast.
251
262
  */
252
- export type PgRuntimeTable = Record<string, any>
263
+ export type PgRuntimeTable = PgTable & Record<string, PgColumn>
253
264
 
254
265
  /** One column as Postgres currently reports it. */
255
266
  export interface LiveColumn {