@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
package/src/types.ts ADDED
@@ -0,0 +1,310 @@
1
+ import type {
2
+ DbLocker, ListCriteria, ListOptions, MigrationRegistry, MigrationStage, Resource,
3
+ ResourceDbService, ResourceLocker, ResourceRecord
4
+ } from '@owlmeans/resource'
5
+ import type { AnySchema } from 'ajv'
6
+ import type { Pool, PoolClient, QueryResultRow } from 'pg'
7
+ import type { NodePgDatabase } from 'drizzle-orm/node-postgres'
8
+
9
+ import type { PgAutoSync, PgIndexMethod, PgReferentialAction } from './consts.js'
10
+
11
+ /**
12
+ * The handle {@link PostgresDbService.db} resolves to. Carries the pool alongside the
13
+ * query builder because structure reconciliation, advisory locking and custom SQL all
14
+ * need a raw session that Drizzle doesn't expose.
15
+ */
16
+ export interface PostgresDb {
17
+ /** Drizzle bound to this config alias' pool. */
18
+ drizzle: NodePgDatabase<Record<string, never>>
19
+ pool: Pool
20
+ /** The Postgres SCHEMA — i.e. `service.name(alias)`, layer suffixed. */
21
+ schema: string
22
+ /** The Postgres DATABASE — i.e. `config.meta.database`. Never layer suffixed. */
23
+ database: string
24
+ }
25
+
26
+ export interface PostgresDbService extends ResourceDbService<PostgresDb, Pool>, DbLocker<ResourceRecord> {
27
+ /** Fully qualified `"schema"."table"` of a registered postgres resource. */
28
+ qualify: (resourceAlias: string, configAlias?: string) => string
29
+ /** Parameterised query straight on the pool — `$1` style placeholders, values never interpolated. */
30
+ query: <Row extends QueryResultRow = QueryResultRow>(
31
+ text: string, params?: unknown[], configAlias?: string
32
+ ) => Promise<Row[]>
33
+ transaction: <R>(fn: (tx: PostgresTx) => Promise<R>, configAlias?: string) => Promise<R>
34
+ /**
35
+ * Work held back until every resource has initialized — foreign keys whose target table
36
+ * belongs to a resource whose own `init()` may not have run yet. Drained by the Loading
37
+ * middleware `appendPostgres` registers.
38
+ */
39
+ defer: (configAlias: string, task: () => Promise<void>) => void
40
+ /** Run every task {@link defer} has queued for a config alias, then clear the queue. */
41
+ drain: (configAlias?: string) => Promise<void>
42
+ }
43
+
44
+ /** `DbConfig.meta` shape this package understands. */
45
+ export interface PostgresMeta {
46
+ /** Postgres DATABASE. Falls back to the connection user, per libpq. */
47
+ database?: string
48
+ /** Structure reconciliation policy. Defaults to {@link PgAutoSync.Full}. */
49
+ autoSync?: PgAutoSync | `${PgAutoSync}`
50
+ /** Passed straight to `pg.Pool`. */
51
+ ssl?: boolean | Record<string, unknown>
52
+ max?: number
53
+ idleTimeoutMillis?: number
54
+ connectionTimeoutMillis?: number
55
+ statementTimeoutMillis?: number
56
+ /** Attempts of the `SELECT 1` readiness probe. */
57
+ retries?: number
58
+ retryDelayMillis?: number
59
+ /** Whole connection string — wins over host/user/secret when present. */
60
+ url?: string
61
+ }
62
+
63
+ /** Transaction scoped façade handed to migrations and {@link PostgresResource.transaction}. */
64
+ export interface PostgresTx {
65
+ client: PoolClient
66
+ /** `{{alias}}` placeholders resolved against the context, `$1..$n` bound. */
67
+ query: <Row extends QueryResultRow = QueryResultRow>(text: string, params?: unknown[]) => Promise<Row[]>
68
+ queryOne: <Row extends QueryResultRow = QueryResultRow>(text: string, params?: unknown[]) => Promise<Row | null>
69
+ /** Returns the affected row count. */
70
+ execute: (text: string, params?: unknown[]) => Promise<number>
71
+ /** Fully qualified identifier of a registered resource; omit for the owning resource. */
72
+ ref: (resourceAlias?: string) => string
73
+ }
74
+
75
+ export interface PostgresResource<T extends ResourceRecord> extends Resource<T>, ResourceLocker<T> {
76
+ /** Physical table name override. Defaults to the resource alias, sanitized. */
77
+ name?: string
78
+ /** The AJV schema — single source of truth for the table structure. */
79
+ schema?: AnySchema
80
+ /** Compiled table specification. Available once `init()` has run. */
81
+ table: TableSpec
82
+ /** The Drizzle runtime table built from {@link table}. */
83
+ entity: PgRuntimeTable
84
+ migrations: MigrationRegistry<PostgresTx>
85
+
86
+ db: () => Promise<PostgresDb>
87
+ client: () => Promise<Pool>
88
+
89
+ /** Declare an index the JSON schema can't express. Chainable. */
90
+ index: <Type extends PostgresResource<T>>(name: string, spec: PgIndexSpec) => Type
91
+ /** Register a code migration. Chainable. */
92
+ migration: <Type extends PostgresResource<T>>(
93
+ name: string, apply: (tx: PostgresTx) => Promise<void>, stage?: MigrationStage
94
+ ) => Type
95
+
96
+ getDefaults: () => Partial<T>
97
+
98
+ /** Raw rows, no marshalling. */
99
+ query: <Row extends QueryResultRow = QueryResultRow>(text: string, params?: unknown[]) => Promise<Row[]>
100
+ queryOne: <Row extends QueryResultRow = QueryResultRow>(text: string, params?: unknown[]) => Promise<Row | null>
101
+ /** Returns the affected row count. */
102
+ execute: (text: string, params?: unknown[]) => Promise<number>
103
+ /** Rows marshalled back into `T` through the table spec. */
104
+ select: <Type extends T>(text: string, params?: unknown[]) => Promise<Type[]>
105
+ selectOne: <Type extends T>(text: string, params?: unknown[]) => Promise<Type | null>
106
+ /** Fully qualified `"schema"."table"`; pass an alias to reference another resource. */
107
+ ref: (resourceAlias?: string) => string
108
+
109
+ transaction: <R>(fn: (tx: PostgresTx) => Promise<R>) => Promise<R>
110
+
111
+ /** Create with a caller supplied id — `create()` refuses one, mirroring mongo. */
112
+ insert: <Type extends T>(record: Partial<Type>) => Promise<Type>
113
+ /** `INSERT ... ON CONFLICT (...) DO UPDATE`. Defaults to conflicting on the primary key. */
114
+ upsert: <Type extends T>(record: Partial<Type>, conflict?: string[]) => Promise<Type>
115
+ /** Merge semantics — `update()` replaces the whole record, mirroring mongo's `replaceOne`. */
116
+ patch: <Type extends T>(record: Partial<Type>, opts?: string | { field?: string }) => Promise<Type>
117
+ /** Bulk delete by criteria. Returns the deleted row count. */
118
+ purge: (criteria: ListCriteria) => Promise<number>
119
+ count: (criteria?: ListCriteria | ListOptions) => Promise<number>
120
+ }
121
+
122
+ /** Compiled, database ready description of a resource's table. */
123
+ export interface TableSpec {
124
+ alias: string
125
+ /** Postgres namespace. */
126
+ schema: string
127
+ /** Physical table name. */
128
+ table: string
129
+ /** `"schema"."table"` — already quoted. */
130
+ qualified: string
131
+ columns: ColumnSpec[]
132
+ byProperty: Record<string, ColumnSpec>
133
+ byColumn: Record<string, ColumnSpec>
134
+ /** Column names. */
135
+ primaryKey: string[]
136
+ uniques: PgUniqueSpec[]
137
+ checks: PgCheckSpec[]
138
+ indexes: PgIndexSpec[]
139
+ references: PgReferenceSpec[]
140
+ /** Columns reconciliation must never touch. */
141
+ unmanaged: string[]
142
+ autoSync: boolean
143
+ comment?: string
144
+ }
145
+
146
+ export interface ColumnSpec {
147
+ /** JSON schema property name. */
148
+ property: string
149
+ /** Physical column name. */
150
+ column: string
151
+ /** Canonical `format_type` spelling, e.g. `character varying(320)`. */
152
+ sqlType: string
153
+ jsonType: ColumnJsonType
154
+ notNull: boolean
155
+ primaryKey: boolean
156
+ defaultLiteral?: string | number | boolean | null
157
+ /** Raw SQL default expression, e.g. `now()`. */
158
+ defaultRaw?: string
159
+ /** `secure: true` in the JSON schema — the stored value is ciphertext. */
160
+ secure: boolean
161
+ /** Value has to be `JSON.stringify`d on the way in. */
162
+ jsonb: boolean
163
+ /** Postgres array column. */
164
+ array: boolean
165
+ /** `USING` expression for `ALTER COLUMN ... TYPE`. */
166
+ using?: string
167
+ managed: boolean
168
+ comment?: string
169
+ }
170
+
171
+ export type ColumnJsonType =
172
+ 'string' | 'number' | 'integer' | 'bigint' | 'boolean' | 'object' | 'array' | 'date' | 'binary' | 'unknown'
173
+
174
+ /** Per-property `pg` keyword vocabulary. */
175
+ export interface PgPropertyOverride {
176
+ column?: string
177
+ /** Raw Postgres type — wins over everything inferred from the JSON schema. */
178
+ type?: string
179
+ length?: number
180
+ precision?: number
181
+ scale?: number
182
+ nullable?: boolean
183
+ default?: string | number | boolean | null
184
+ defaultRaw?: string
185
+ primaryKey?: boolean
186
+ unique?: boolean | string
187
+ index?: boolean | PgIndexSpec | PgIndexSpec[]
188
+ references?: PgReferenceSpec
189
+ jsonb?: boolean
190
+ array?: boolean
191
+ /** `{{col}}` expands to the quoted column name. */
192
+ check?: string
193
+ using?: string
194
+ /** `false` puts the column outside reconciliation's authority. */
195
+ managed?: boolean
196
+ comment?: string
197
+ }
198
+
199
+ /** Root level `pg` keyword vocabulary. */
200
+ export interface PgRootOverride {
201
+ table?: string
202
+ schema?: string
203
+ /** Property names forming a composite primary key. */
204
+ primaryKey?: string[]
205
+ unique?: PgUniqueSpec[]
206
+ indexes?: PgIndexSpec[]
207
+ checks?: PgCheckSpec[]
208
+ /** Physical column names reconciliation must leave alone. */
209
+ unmanaged?: string[]
210
+ autoSync?: boolean
211
+ comment?: string
212
+ }
213
+
214
+ export interface PgIndexSpec {
215
+ name?: string
216
+ /** Property names — mapped to physical columns at compile time. */
217
+ columns?: string | string[]
218
+ /** Raw expression index. Wins over `columns`. */
219
+ expression?: string
220
+ unique?: boolean
221
+ method?: PgIndexMethod | `${PgIndexMethod}`
222
+ /** Partial index predicate. */
223
+ where?: string
224
+ }
225
+
226
+ export interface PgUniqueSpec {
227
+ name?: string
228
+ /** Property names. */
229
+ columns: string[]
230
+ }
231
+
232
+ export interface PgCheckSpec {
233
+ name?: string
234
+ expression: string
235
+ }
236
+
237
+ export interface PgReferenceSpec {
238
+ /** Local property holding the key. Filled in by the compiler. */
239
+ property?: string
240
+ /** Resolve the target table from a registered resource alias... */
241
+ resource?: string
242
+ /** ...or name it directly. */
243
+ table?: string
244
+ schema?: string
245
+ /** Target column. Defaults to `id`. */
246
+ column?: string
247
+ name?: string
248
+ onDelete?: PgReferentialAction | `${PgReferentialAction}`
249
+ onUpdate?: PgReferentialAction | `${PgReferentialAction}`
250
+ }
251
+
252
+ /**
253
+ * The Drizzle table object. Deliberately opaque — tables are built at runtime from JSON
254
+ * schemas, so Drizzle's compile time inference has nothing to infer from and every
255
+ * column would be `any` anyway. Cast it at the call site when you need the typed builder.
256
+ */
257
+ export type PgRuntimeTable = Record<string, any>
258
+
259
+ /** One column as Postgres currently reports it. */
260
+ export interface LiveColumn {
261
+ name: string
262
+ /** `format_type` output — directly comparable with {@link ColumnSpec.sqlType}. */
263
+ type: string
264
+ notNull: boolean
265
+ defaultExpr: string | null
266
+ identity: string
267
+ generated: string
268
+ ordinal: number
269
+ }
270
+
271
+ export interface LiveIndex {
272
+ name: string
273
+ definition: string
274
+ }
275
+
276
+ export interface LiveConstraint {
277
+ name: string
278
+ /** `p` primary, `u` unique, `f` foreign, `c` check. */
279
+ type: string
280
+ definition: string
281
+ }
282
+
283
+ export interface LiveTable {
284
+ exists: boolean
285
+ columns: LiveColumn[]
286
+ indexes: LiveIndex[]
287
+ constraints: LiveConstraint[]
288
+ }
289
+
290
+ /** One reconciliation step, kept separate from its SQL so it can be logged and explained. */
291
+ export interface DdlStatement {
292
+ kind: DdlKind
293
+ /** What the statement touches — column, index or constraint name. */
294
+ target: string
295
+ sql: string
296
+ /** True when applying this loses data. */
297
+ destructive?: boolean
298
+ /** Rows that would be affected, sampled immediately before a destructive step. */
299
+ affected?: number
300
+ }
301
+
302
+ export type DdlKind =
303
+ 'create-schema' | 'create-table' | 'add-column' | 'set-default' | 'backfill' | 'alter-type'
304
+ | 'set-not-null' | 'drop-not-null' | 'drop-constraint' | 'add-constraint' | 'drop-index'
305
+ | 'create-index' | 'add-foreign-key' | 'drop-column' | 'comment'
306
+
307
+ export interface DdlPlan {
308
+ fresh: boolean
309
+ statements: DdlStatement[]
310
+ }
@@ -0,0 +1,246 @@
1
+ import { UnsupportedArgumentError } from '@owlmeans/resource'
2
+ import type { ListCriteria, ListSort } from '@owlmeans/resource'
3
+ import { and, asc, desc, or, sql } from 'drizzle-orm'
4
+ import type { SQL } from 'drizzle-orm'
5
+
6
+ import type { ColumnSpec, PgRuntimeTable, TableSpec } from '../types.js'
7
+
8
+ const COMPARISON: Record<string, string> = {
9
+ $eq: '=', $ne: '<>', $gt: '>', $gte: '>=', $lt: '<', $lte: '<='
10
+ }
11
+
12
+ const columnRef = (table: PgRuntimeTable, column: ColumnSpec): SQL => sql`${table[column.property]}`
13
+
14
+ const value = (raw: unknown, column: ColumnSpec): SQL => {
15
+ if (column.jsonb && raw != null && typeof raw !== 'string') {
16
+ return sql`${JSON.stringify(raw)}`
17
+ }
18
+
19
+ return sql`${raw}`
20
+ }
21
+
22
+ const inList = (table: PgRuntimeTable, column: ColumnSpec, values: unknown[], negate: boolean): SQL => {
23
+ const present = values.filter(entry => entry != null)
24
+ const hasNull = present.length !== values.length
25
+
26
+ if (present.length < 1) {
27
+ /** Postgres rejects an empty `IN ()`, and an empty set matches nothing (or everything, negated). */
28
+ return hasNull
29
+ ? sql`${columnRef(table, column)} IS ${negate ? sql`NOT` : sql``} NULL`
30
+ : negate ? sql`TRUE` : sql`FALSE`
31
+ }
32
+
33
+ const list = sql.join(present.map(entry => value(entry, column)), sql`, `)
34
+ const membership = negate
35
+ ? sql`${columnRef(table, column)} NOT IN (${list})`
36
+ : sql`${columnRef(table, column)} IN (${list})`
37
+
38
+ if (!hasNull) {
39
+ return membership
40
+ }
41
+ /**
42
+ * `IN` never matches NULL, so `{ $in: [null, 'x'] }` — a real shape in this codebase —
43
+ * has to be widened explicitly or the null branch silently disappears.
44
+ */
45
+ return negate
46
+ ? sql`(${membership} AND ${columnRef(table, column)} IS NOT NULL)`
47
+ : sql`(${membership} OR ${columnRef(table, column)} IS NULL)`
48
+ }
49
+
50
+ const operators = (
51
+ table: PgRuntimeTable, column: ColumnSpec, spec: Record<string, unknown>
52
+ ): SQL[] => {
53
+ const conditions: SQL[] = []
54
+ for (const [operator, operand] of Object.entries(spec)) {
55
+ if (operator in COMPARISON) {
56
+ conditions.push(operand == null
57
+ ? sql`${columnRef(table, column)} IS ${operator === '$ne' ? sql`NOT` : sql``} NULL`
58
+ : sql`${columnRef(table, column)} ${sql.raw(COMPARISON[operator])} ${value(operand, column)}`)
59
+ continue
60
+ }
61
+ switch (operator) {
62
+ case '$in':
63
+ case '$nin':
64
+ conditions.push(inList(table, column, Array.isArray(operand) ? operand : [operand], operator === '$nin'))
65
+ break
66
+ case '$exists':
67
+ conditions.push(operand === false
68
+ ? sql`${columnRef(table, column)} IS NULL`
69
+ : sql`${columnRef(table, column)} IS NOT NULL`)
70
+ break
71
+ case '$null':
72
+ conditions.push(operand === false
73
+ ? sql`${columnRef(table, column)} IS NOT NULL`
74
+ : sql`${columnRef(table, column)} IS NULL`)
75
+ break
76
+ case '$like':
77
+ conditions.push(sql`${columnRef(table, column)} LIKE ${operand}`)
78
+ break
79
+ case '$ilike':
80
+ conditions.push(sql`${columnRef(table, column)} ILIKE ${operand}`)
81
+ break
82
+ case '$regex':
83
+ conditions.push(sql`${columnRef(table, column)} ~ ${operand}`)
84
+ break
85
+ case '$startsWith':
86
+ conditions.push(sql`${columnRef(table, column)} LIKE ${`${escapeLike(`${operand}`)}%`}`)
87
+ break
88
+ case '$endsWith':
89
+ conditions.push(sql`${columnRef(table, column)} LIKE ${`%${escapeLike(`${operand}`)}`}`)
90
+ break
91
+ case '$between': {
92
+ if (!Array.isArray(operand) || operand.length !== 2) {
93
+ throw new UnsupportedArgumentError(`criteria:$between:${column.property}`)
94
+ }
95
+ conditions.push(
96
+ sql`${columnRef(table, column)} BETWEEN ${value(operand[0], column)} AND ${value(operand[1], column)}`
97
+ )
98
+ break
99
+ }
100
+ case '$contains':
101
+ conditions.push(sql`${columnRef(table, column)} @> ${value(operand, column)}`)
102
+ break
103
+ case '$contained':
104
+ conditions.push(sql`${columnRef(table, column)} <@ ${value(operand, column)}`)
105
+ break
106
+ case '$overlaps':
107
+ conditions.push(sql`${columnRef(table, column)} && ${value(operand, column)}`)
108
+ break
109
+ default:
110
+ throw new UnsupportedArgumentError(`criteria-operator:${operator}`)
111
+ }
112
+ }
113
+
114
+ return conditions
115
+ }
116
+
117
+ const escapeLike = (value: string): string => value.replace(/[\\%_]/g, match => `\\${match}`)
118
+
119
+ const isOperatorSpec = (value: unknown): value is Record<string, unknown> =>
120
+ value != null && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)
121
+ && Object.keys(value).some(key => key.startsWith('$'))
122
+
123
+ /**
124
+ * Translate `ListCriteria` into a WHERE clause.
125
+ *
126
+ * An unknown key raises rather than being skipped: a typo silently widening a query to
127
+ * the whole table is the failure mode worth being loud about.
128
+ *
129
+ * @throws {UnsupportedArgumentError}
130
+ */
131
+ export const criteriaToSql = (
132
+ criteria: ListCriteria | undefined, spec: TableSpec, table: PgRuntimeTable
133
+ ): SQL | undefined => {
134
+ const conditions = build(criteria, spec, table)
135
+
136
+ return conditions.length > 0 ? and(...conditions) : undefined
137
+ }
138
+
139
+ const build = (
140
+ criteria: ListCriteria | undefined, spec: TableSpec, table: PgRuntimeTable
141
+ ): SQL[] => {
142
+ const conditions: SQL[] = []
143
+ for (const [key, raw] of Object.entries(criteria ?? {})) {
144
+ if (raw === undefined) {
145
+ continue
146
+ }
147
+
148
+ if (key === '$and' || key === '$or') {
149
+ const parts = (Array.isArray(raw) ? raw : [raw])
150
+ .map(entry => and(...build(entry as ListCriteria, spec, table)))
151
+ .filter((entry): entry is SQL => entry != null)
152
+ if (parts.length > 0) {
153
+ const combined = key === '$and' ? and(...parts) : or(...parts)
154
+ if (combined != null) {
155
+ conditions.push(combined)
156
+ }
157
+ }
158
+ continue
159
+ }
160
+ if (key === '$not') {
161
+ const inner = and(...build(raw as ListCriteria, spec, table))
162
+ if (inner != null) {
163
+ conditions.push(sql`NOT (${inner})`)
164
+ }
165
+ continue
166
+ }
167
+
168
+ /** `profile.city` reaches into a jsonb column rather than naming a column. */
169
+ const [head, ...path] = key.split('.')
170
+ const column = spec.byProperty[head]
171
+ if (column == null) {
172
+ throw new UnsupportedArgumentError(`criteria:${key}`)
173
+ }
174
+ if (path.length > 0) {
175
+ if (!column.jsonb) {
176
+ throw new UnsupportedArgumentError(`criteria-path:${key}`)
177
+ }
178
+ conditions.push(sql`${columnRef(table, column)} #>> ${`{${path.join(',')}}`} = ${`${raw}`}`)
179
+ continue
180
+ }
181
+
182
+ if (raw === null) {
183
+ conditions.push(sql`${columnRef(table, column)} IS NULL`)
184
+ continue
185
+ }
186
+ if (isOperatorSpec(raw)) {
187
+ conditions.push(...operators(table, column, raw as Record<string, unknown>))
188
+ continue
189
+ }
190
+ if (Array.isArray(raw)) {
191
+ /**
192
+ * A bare array means `IN` for a relational store, which is overwhelmingly the intent.
193
+ * Exact array equality against a `text[]` column stays available as `{ $eq: [...] }`.
194
+ */
195
+ conditions.push(inList(table, column, raw, false))
196
+ continue
197
+ }
198
+ if (column.jsonb && typeof raw === 'object') {
199
+ conditions.push(sql`${columnRef(table, column)} @> ${JSON.stringify(raw)}`)
200
+ continue
201
+ }
202
+ conditions.push(sql`${columnRef(table, column)} = ${value(raw, column)}`)
203
+ }
204
+
205
+ return conditions
206
+ }
207
+
208
+ /**
209
+ * Translate `ListPager.sort` into ORDER BY. `[field, true]` is descending, matching the
210
+ * `order ? -1 : 1` mapping mongo uses.
211
+ *
212
+ * The primary key is always appended as a tiebreak. Postgres has no implicit row order, so
213
+ * paginating on a non-unique sort key silently duplicates and skips rows between pages —
214
+ * a difference from mongo that would otherwise surface as a data bug rather than an error.
215
+ *
216
+ * @throws {UnsupportedArgumentError}
217
+ */
218
+ export const sortToSql = (
219
+ sort: ListSort[] | undefined, spec: TableSpec, table: PgRuntimeTable
220
+ ): SQL[] => {
221
+ const order: SQL[] = []
222
+ const seen: string[] = []
223
+
224
+ for (const entry of sort ?? []) {
225
+ /** Both `ListSort` forms are handled explicitly — destructuring a plain string yields characters. */
226
+ const [property, descending] = typeof entry === 'string' ? [entry, false] : [entry[0], entry[1] === true]
227
+ const column = spec.byProperty[property]
228
+ if (column == null) {
229
+ throw new UnsupportedArgumentError(`sort:${property}`)
230
+ }
231
+ seen.push(column.column)
232
+ order.push(descending ? desc(table[column.property]) : asc(table[column.property]))
233
+ }
234
+
235
+ for (const key of spec.primaryKey) {
236
+ if (seen.includes(key)) {
237
+ continue
238
+ }
239
+ const column = spec.byColumn[key]
240
+ if (column != null) {
241
+ order.push(asc(table[column.property]))
242
+ }
243
+ }
244
+
245
+ return order
246
+ }