@owlmeans/postgres-resource 0.1.18-rc.4 → 0.1.18-rc.40

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 (48) hide show
  1. package/README.md +185 -101
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/postgres-resource/SKILL.md +110 -21
  4. package/build/consts.d.ts +8 -1
  5. package/build/consts.d.ts.map +1 -1
  6. package/build/consts.js +8 -1
  7. package/build/consts.js.map +1 -1
  8. package/build/declarations.js +5 -5
  9. package/build/resource.d.ts +2 -2
  10. package/build/resource.d.ts.map +1 -1
  11. package/build/resource.js +112 -108
  12. package/build/resource.js.map +1 -1
  13. package/build/types.d.ts +34 -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 +156 -11
  18. package/build/utils/criteria.js.map +1 -1
  19. package/build/utils/diff.d.ts +13 -0
  20. package/build/utils/diff.d.ts.map +1 -1
  21. package/build/utils/diff.js +60 -6
  22. package/build/utils/diff.js.map +1 -1
  23. package/build/utils/migrations.d.ts +2 -2
  24. package/build/utils/migrations.js +2 -2
  25. package/build/utils/name.d.ts +3 -3
  26. package/build/utils/name.js +3 -3
  27. package/build/utils/sql.d.ts +10 -2
  28. package/build/utils/sql.d.ts.map +1 -1
  29. package/build/utils/sql.js.map +1 -1
  30. package/build/utils/table.d.ts +4 -0
  31. package/build/utils/table.d.ts.map +1 -1
  32. package/build/utils/table.js +4 -0
  33. package/build/utils/table.js.map +1 -1
  34. package/package.json +7 -7
  35. package/src/consts.ts +8 -1
  36. package/src/declarations.ts +5 -5
  37. package/src/resource.ts +139 -135
  38. package/src/types.ts +36 -18
  39. package/src/utils/criteria.ts +202 -23
  40. package/src/utils/diff.ts +62 -7
  41. package/src/utils/migrations.ts +2 -2
  42. package/src/utils/name.ts +3 -3
  43. package/src/utils/sql.ts +0 -0
  44. package/src/utils/table.ts +5 -1
  45. package/tests/criteria.spec.ts +237 -0
  46. package/tests/diff.spec.ts +111 -0
  47. package/tests/name.spec.ts +3 -3
  48. 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)
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()
133
+
134
+ const rows = await db.drizzle.select().from(from).where(where(table, from, query))
135
+ .orderBy(...sortToSql(opts?.sort, table, from)).limit(1)
136
+
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)
131
143
  if (record == null) {
132
- throw new UnknownRecordError(id)
144
+ throw new UnknownRecordError(describe(query))
133
145
  }
134
146
 
135
147
  return record
136
- },
148
+ }) as T['get'],
137
149
 
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()
150
+ load: loadOne as T['load'],
148
151
 
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) => {
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,25 +323,45 @@ 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
  },
351
343
 
344
+ countBy: async (criteria, fields) => {
345
+ const { db, spec: table, entity: from } = await ensure()
346
+ if (fields.length < 1 || fields.some(field => field === 'count')) {
347
+ /** `count` names the answer's own tally; grouping by nothing is `count()`. */
348
+ throw new UnsupportedArgumentError(`count-by:${fields.join(',')}`)
349
+ }
350
+ const columns = fields.map(field => columnOf(table, field))
351
+ const selection: Record<string, SQL | PgRuntimeTable[string]> = Object.fromEntries(
352
+ columns.map(column => [column.property, from[column.property]])
353
+ )
354
+ const rows = await db.drizzle
355
+ .select({ ...selection, count: sql<number>`count(*)::int` } as never)
356
+ .from(from).where(criteriaToSql(criteria, table, from))
357
+ .groupBy(...columns.map(column => from[column.property]))
358
+
359
+ return (rows as Array<Record<string, unknown>>).map(row => {
360
+ const { count, ...group } = row
361
+ return { ...resultToRecord<Record<string, unknown>>(group, table), count: Number(count ?? 0) }
362
+ })
363
+ },
364
+
352
365
  getDefaults: () => {
353
366
  const schema = resource.schema as JSONSchemaType<unknown> | undefined
354
367
  if (schema == null) {
@@ -366,9 +379,9 @@ export const makePostgresResource = <
366
379
  }, {}) as Partial<R>
367
380
  },
368
381
 
369
- query: async (text, params) => (await raw(text, params)).rows,
382
+ query: (async (text: string, params?: unknown[]) => (await raw(text, params)).rows) as T['query'],
370
383
 
371
- queryOne: async (text, params) => (await raw(text, params)).rows[0] ?? null,
384
+ queryOne: (async (text: string, params?: unknown[]) => (await raw(text, params)).rows[0] ?? null) as T['queryOne'],
372
385
 
373
386
  execute: async (text, params) => (await raw(text, params)).count,
374
387
 
@@ -376,14 +389,14 @@ export const makePostgresResource = <
376
389
  const { spec: table } = await ensure()
377
390
  const result = await raw(text, params)
378
391
 
379
- return result.rows.map(row => rowToRecord(row, table))
392
+ return result.rows.map(row => rowToRecord<R>(row, table))
380
393
  },
381
394
 
382
395
  selectOne: async (text, params) => {
383
396
  const { spec: table } = await ensure()
384
397
  const result = await raw(text, params)
385
398
 
386
- return result.rows.length < 1 ? null : rowToRecord(result.rows[0], table)
399
+ return result.rows.length < 1 ? null : rowToRecord<R>(result.rows[0], table)
387
400
  },
388
401
 
389
402
  ref: resourceAlias => {
@@ -444,13 +457,14 @@ export const makePostgresResource = <
444
457
  return postgres.client(dbAlias)
445
458
  },
446
459
 
447
- index: <Type extends PostgresResource<R>>(name: string, index: PgIndexSpec) => {
460
+ /** Chainable — the declaration store is keyed by alias, so this object is the whole state. */
461
+ index: (name: string, index: PgIndexSpec) => {
448
462
  declaration.indexes.push({ ...index, name })
449
463
 
450
- return resource as unknown as Type
464
+ return resource
451
465
  },
452
466
 
453
- /** `this`-returning in the interface — the implementation returns that very object. */
467
+ /** Self-returning in the interface — the implementation returns that very object. */
454
468
  migration: ((name: string, apply: (tx: PostgresTx) => Promise<void>, stage?: MigrationStage) => {
455
469
  declaration.migrations.register(name, apply, stage)
456
470
 
@@ -458,12 +472,14 @@ export const makePostgresResource = <
458
472
  }) as T['migration'],
459
473
 
460
474
  migrations: () => declaration.migrations
461
- } as Partial<T>)
475
+ }
476
+
477
+ const resource: T = appendContextual<T>(alias, members as Partial<T>)
462
478
 
463
479
  /**
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.
480
+ * The AJV schema is stored per alias rather than on the object, so a second maker run for
481
+ * the same alias (a custom maker, a repeated maker, a spec) picks up the schema the app
482
+ * assigned after construction instead of silently taking the table's structure with it.
467
483
  */
468
484
  Object.defineProperty(resource, 'schema', {
469
485
  enumerable: true,
@@ -483,8 +499,7 @@ export const makePostgresResource = <
483
499
 
484
500
  /**
485
501
  * 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.
502
+ * carry characters no identifier allows.
488
503
  */
489
504
  if (tableName != null) {
490
505
  resource.name = tableName
@@ -505,16 +520,5 @@ export const makePostgresResource = <
505
520
  entity = initialized.entity
506
521
  }
507
522
 
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
523
  return resource
520
524
  }
package/src/types.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import type {
2
- DbLocker, ListCriteria, ListOptions, MigratableResource, Resource,
3
- ResourceDbService, ResourceLocker, ResourceRecord
2
+ Criteria, 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,26 @@ 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>
122
+ /**
123
+ * `count(*)` of the rows matching `criteria`, per distinct combination of `fields` — one
124
+ * `GROUP BY` round trip. Each answer carries the group's values (by property name) and `count`.
125
+ */
126
+ countBy: (
127
+ criteria: Criteria<T> | undefined, fields: Array<keyof T & string>
128
+ ) => Promise<Array<Record<string, unknown> & { count: number }>>
115
129
  }
116
130
 
117
131
  /** Compiled, database ready description of a resource's table. */
@@ -245,11 +259,15 @@ export interface PgReferenceSpec {
245
259
  }
246
260
 
247
261
  /**
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.
262
+ * The Drizzle table `specToTable` compiles from a {@link TableSpec}.
263
+ *
264
+ * Both halves are load bearing. `PgTable` is what the query builder accepts and what keeps
265
+ * the rows it hands back usable — erasing it to `never` would collapse every result row.
266
+ * The index signature is how a column is reached: the columns are keyed by schema property
267
+ * name and only known at runtime, so `eq(entity[column.property], value)` is a plain lookup
268
+ * rather than a cast.
251
269
  */
252
- export type PgRuntimeTable = Record<string, any>
270
+ export type PgRuntimeTable = PgTable & Record<string, PgColumn>
253
271
 
254
272
  /** One column as Postgres currently reports it. */
255
273
  export interface LiveColumn {