@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.
- package/LICENSE +21 -0
- package/README.md +219 -0
- package/agent-meta/instructions/postgres-resource.instructions.md +60 -0
- package/agent-meta/manifest.json +23 -0
- package/agent-meta/skills/postgres-resource/SKILL.md +186 -0
- package/build/consts.d.ts +81 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +86 -0
- package/build/consts.js.map +1 -0
- package/build/declarations.d.ts +12 -0
- package/build/declarations.d.ts.map +1 -0
- package/build/declarations.js +28 -0
- package/build/declarations.js.map +1 -0
- package/build/errors.d.ts +70 -0
- package/build/errors.d.ts.map +1 -0
- package/build/errors.js +201 -0
- package/build/errors.js.map +1 -0
- package/build/helper.d.ts +4 -0
- package/build/helper.d.ts.map +1 -0
- package/build/helper.js +5 -0
- package/build/helper.js.map +1 -0
- package/build/index.d.ts +8 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +7 -0
- package/build/index.js.map +1 -0
- package/build/resource.d.ts +4 -0
- package/build/resource.d.ts.map +1 -0
- package/build/resource.js +395 -0
- package/build/resource.js.map +1 -0
- package/build/types.d.ts +273 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/build/utils/criteria.d.ts +24 -0
- package/build/utils/criteria.d.ts.map +1 -0
- package/build/utils/criteria.js +209 -0
- package/build/utils/criteria.js.map +1 -0
- package/build/utils/diff.d.ts +24 -0
- package/build/utils/diff.d.ts.map +1 -0
- package/build/utils/diff.js +330 -0
- package/build/utils/diff.js.map +1 -0
- package/build/utils/index.d.ts +12 -0
- package/build/utils/index.d.ts.map +1 -0
- package/build/utils/index.js +12 -0
- package/build/utils/index.js.map +1 -0
- package/build/utils/introspect.d.ts +7 -0
- package/build/utils/introspect.d.ts.map +1 -0
- package/build/utils/introspect.js +70 -0
- package/build/utils/introspect.js.map +1 -0
- package/build/utils/life-cycle.d.ts +39 -0
- package/build/utils/life-cycle.d.ts.map +1 -0
- package/build/utils/life-cycle.js +119 -0
- package/build/utils/life-cycle.js.map +1 -0
- package/build/utils/marshal.d.ts +36 -0
- package/build/utils/marshal.d.ts.map +1 -0
- package/build/utils/marshal.js +152 -0
- package/build/utils/marshal.js.map +1 -0
- package/build/utils/migrations.d.ts +16 -0
- package/build/utils/migrations.d.ts.map +1 -0
- package/build/utils/migrations.js +108 -0
- package/build/utils/migrations.js.map +1 -0
- package/build/utils/name.d.ts +39 -0
- package/build/utils/name.d.ts.map +1 -0
- package/build/utils/name.js +63 -0
- package/build/utils/name.js.map +1 -0
- package/build/utils/schema.d.ts +28 -0
- package/build/utils/schema.d.ts.map +1 -0
- package/build/utils/schema.js +356 -0
- package/build/utils/schema.js.map +1 -0
- package/build/utils/sql.d.ts +22 -0
- package/build/utils/sql.d.ts.map +1 -0
- package/build/utils/sql.js +0 -0
- package/build/utils/sql.js.map +1 -0
- package/build/utils/sync.d.ts +21 -0
- package/build/utils/sync.d.ts.map +1 -0
- package/build/utils/sync.js +93 -0
- package/build/utils/sync.js.map +1 -0
- package/build/utils/table.d.ts +8 -0
- package/build/utils/table.d.ts.map +1 -0
- package/build/utils/table.js +68 -0
- package/build/utils/table.js.map +1 -0
- package/package.json +50 -0
- package/src/consts.ts +95 -0
- package/src/declarations.ts +41 -0
- package/src/errors.ts +242 -0
- package/src/helper.ts +7 -0
- package/src/index.ts +7 -0
- package/src/resource.ts +524 -0
- package/src/types.ts +310 -0
- package/src/utils/criteria.ts +246 -0
- package/src/utils/diff.ts +363 -0
- package/src/utils/index.ts +11 -0
- package/src/utils/introspect.ts +87 -0
- package/src/utils/life-cycle.ts +155 -0
- package/src/utils/marshal.ts +175 -0
- package/src/utils/migrations.ts +135 -0
- package/src/utils/name.ts +79 -0
- package/src/utils/schema.ts +426 -0
- package/src/utils/sql.ts +0 -0
- package/src/utils/sync.ts +106 -0
- package/src/utils/table.ts +76 -0
- package/tests/errors.spec.ts +121 -0
- package/tests/name.spec.ts +75 -0
- package/tests/schema.spec.ts +258 -0
- package/tests/sql.spec.ts +104 -0
- package/tsconfig.json +16 -0
package/src/resource.ts
ADDED
|
@@ -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
|
+
}
|