@sixb/pg 0.0.1

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 (152) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -0
  3. package/dist/agents/agent-storage.d.ts +20 -0
  4. package/dist/agents/agent-storage.d.ts.map +1 -0
  5. package/dist/agents/index.d.ts +6 -0
  6. package/dist/agents/index.d.ts.map +1 -0
  7. package/dist/agents/messages.d.ts +13 -0
  8. package/dist/agents/messages.d.ts.map +1 -0
  9. package/dist/agents/rows.d.ts +77 -0
  10. package/dist/agents/rows.d.ts.map +1 -0
  11. package/dist/agents/runs.d.ts +25 -0
  12. package/dist/agents/runs.d.ts.map +1 -0
  13. package/dist/agents/threads.d.ts +13 -0
  14. package/dist/agents/threads.d.ts.map +1 -0
  15. package/dist/auth-storage/access-tokens.d.ts +33 -0
  16. package/dist/auth-storage/access-tokens.d.ts.map +1 -0
  17. package/dist/auth-storage/auth-storage.d.ts +41 -0
  18. package/dist/auth-storage/auth-storage.d.ts.map +1 -0
  19. package/dist/auth-storage/group-memberships.d.ts +21 -0
  20. package/dist/auth-storage/group-memberships.d.ts.map +1 -0
  21. package/dist/auth-storage/identities.d.ts +17 -0
  22. package/dist/auth-storage/identities.d.ts.map +1 -0
  23. package/dist/auth-storage/index.d.ts +3 -0
  24. package/dist/auth-storage/index.d.ts.map +1 -0
  25. package/dist/auth-storage/invitations.d.ts +29 -0
  26. package/dist/auth-storage/invitations.d.ts.map +1 -0
  27. package/dist/auth-storage/magic-links.d.ts +28 -0
  28. package/dist/auth-storage/magic-links.d.ts.map +1 -0
  29. package/dist/auth-storage/oidc-attempts.d.ts +18 -0
  30. package/dist/auth-storage/oidc-attempts.d.ts.map +1 -0
  31. package/dist/auth-storage/rows.d.ts +138 -0
  32. package/dist/auth-storage/rows.d.ts.map +1 -0
  33. package/dist/auth-storage/service-account-group-memberships.d.ts +17 -0
  34. package/dist/auth-storage/service-account-group-memberships.d.ts.map +1 -0
  35. package/dist/auth-storage/service-accounts.d.ts +14 -0
  36. package/dist/auth-storage/service-accounts.d.ts.map +1 -0
  37. package/dist/auth-storage/sessions.d.ts +55 -0
  38. package/dist/auth-storage/sessions.d.ts.map +1 -0
  39. package/dist/auth-storage/shared.d.ts +159 -0
  40. package/dist/auth-storage/shared.d.ts.map +1 -0
  41. package/dist/auth-storage/users.d.ts +19 -0
  42. package/dist/auth-storage/users.d.ts.map +1 -0
  43. package/dist/index.d.ts +127 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +11957 -0
  46. package/dist/index.js.map +55 -0
  47. package/dist/latest-run-query.d.ts +24 -0
  48. package/dist/latest-run-query.d.ts.map +1 -0
  49. package/dist/migrations.d.ts +20 -0
  50. package/dist/migrations.d.ts.map +1 -0
  51. package/dist/ontology-storage/commits.d.ts +11 -0
  52. package/dist/ontology-storage/commits.d.ts.map +1 -0
  53. package/dist/ontology-storage/index.d.ts +22 -0
  54. package/dist/ontology-storage/index.d.ts.map +1 -0
  55. package/dist/ontology-storage/materialization-session.d.ts +63 -0
  56. package/dist/ontology-storage/materialization-session.d.ts.map +1 -0
  57. package/dist/ontology-storage/materialization-state.d.ts +69 -0
  58. package/dist/ontology-storage/materialization-state.d.ts.map +1 -0
  59. package/dist/ontology-storage/materialization-writer.d.ts +15 -0
  60. package/dist/ontology-storage/materialization-writer.d.ts.map +1 -0
  61. package/dist/ontology-storage/materializations.d.ts +33 -0
  62. package/dist/ontology-storage/materializations.d.ts.map +1 -0
  63. package/dist/ontology-storage/outbox.d.ts +12 -0
  64. package/dist/ontology-storage/outbox.d.ts.map +1 -0
  65. package/dist/ontology-storage/shared.d.ts +95 -0
  66. package/dist/ontology-storage/shared.d.ts.map +1 -0
  67. package/dist/ontology-storage/sources.d.ts +25 -0
  68. package/dist/ontology-storage/sources.d.ts.map +1 -0
  69. package/dist/pg-action-run-storage.d.ts +21 -0
  70. package/dist/pg-action-run-storage.d.ts.map +1 -0
  71. package/dist/pg-client.d.ts +58 -0
  72. package/dist/pg-client.d.ts.map +1 -0
  73. package/dist/pg-object-query-compiler.d.ts +55 -0
  74. package/dist/pg-object-query-compiler.d.ts.map +1 -0
  75. package/dist/pg-object-storage.d.ts +90 -0
  76. package/dist/pg-object-storage.d.ts.map +1 -0
  77. package/dist/pg-pipeline-run-storage.d.ts +18 -0
  78. package/dist/pg-pipeline-run-storage.d.ts.map +1 -0
  79. package/dist/pg-projection-run-storage.d.ts +19 -0
  80. package/dist/pg-projection-run-storage.d.ts.map +1 -0
  81. package/dist/pg-rules-storage.d.ts +25 -0
  82. package/dist/pg-rules-storage.d.ts.map +1 -0
  83. package/dist/pg-sync-run-storage.d.ts +15 -0
  84. package/dist/pg-sync-run-storage.d.ts.map +1 -0
  85. package/dist/pg-timeseries-storage.d.ts +32 -0
  86. package/dist/pg-timeseries-storage.d.ts.map +1 -0
  87. package/dist/pg-webhook-delivery-storage.d.ts +17 -0
  88. package/dist/pg-webhook-delivery-storage.d.ts.map +1 -0
  89. package/dist/pg-webhook-run-storage.d.ts +14 -0
  90. package/dist/pg-webhook-run-storage.d.ts.map +1 -0
  91. package/dist/pg-workflow-intervention-storage.d.ts +16 -0
  92. package/dist/pg-workflow-intervention-storage.d.ts.map +1 -0
  93. package/dist/pg-workflow-run-storage.d.ts +49 -0
  94. package/dist/pg-workflow-run-storage.d.ts.map +1 -0
  95. package/dist/run-list-query.d.ts +30 -0
  96. package/dist/run-list-query.d.ts.map +1 -0
  97. package/dist/storage-errors.d.ts +16 -0
  98. package/dist/storage-errors.d.ts.map +1 -0
  99. package/dist/transactions.d.ts +10 -0
  100. package/dist/transactions.d.ts.map +1 -0
  101. package/dist/tsconfig.json +5 -0
  102. package/package.json +60 -0
  103. package/src/agents/agent-storage.ts +26 -0
  104. package/src/agents/index.ts +5 -0
  105. package/src/agents/messages.ts +144 -0
  106. package/src/agents/rows.ts +227 -0
  107. package/src/agents/runs.ts +330 -0
  108. package/src/agents/threads.ts +116 -0
  109. package/src/auth-storage/access-tokens.ts +254 -0
  110. package/src/auth-storage/auth-storage.ts +798 -0
  111. package/src/auth-storage/group-memberships.ts +47 -0
  112. package/src/auth-storage/identities.ts +85 -0
  113. package/src/auth-storage/index.ts +2 -0
  114. package/src/auth-storage/invitations.ts +280 -0
  115. package/src/auth-storage/magic-links.ts +140 -0
  116. package/src/auth-storage/oidc-attempts.ts +93 -0
  117. package/src/auth-storage/rows.ts +352 -0
  118. package/src/auth-storage/service-account-group-memberships.ts +150 -0
  119. package/src/auth-storage/service-accounts.ts +154 -0
  120. package/src/auth-storage/sessions.ts +193 -0
  121. package/src/auth-storage/shared.ts +799 -0
  122. package/src/auth-storage/users.ts +196 -0
  123. package/src/index.ts +367 -0
  124. package/src/latest-run-query.ts +42 -0
  125. package/src/migrations/001-initial-schema.sql +1195 -0
  126. package/src/migrations.ts +280 -0
  127. package/src/ontology-storage/commits.ts +115 -0
  128. package/src/ontology-storage/index.ts +35 -0
  129. package/src/ontology-storage/materialization-session.ts +426 -0
  130. package/src/ontology-storage/materialization-state.ts +1109 -0
  131. package/src/ontology-storage/materialization-writer.ts +509 -0
  132. package/src/ontology-storage/materializations.ts +939 -0
  133. package/src/ontology-storage/outbox.ts +216 -0
  134. package/src/ontology-storage/shared.ts +293 -0
  135. package/src/ontology-storage/sources.ts +658 -0
  136. package/src/pg-action-run-storage.ts +657 -0
  137. package/src/pg-client.ts +124 -0
  138. package/src/pg-object-query-compiler.ts +1545 -0
  139. package/src/pg-object-storage.ts +638 -0
  140. package/src/pg-pipeline-run-storage.ts +457 -0
  141. package/src/pg-projection-run-storage.ts +526 -0
  142. package/src/pg-rules-storage.ts +308 -0
  143. package/src/pg-sync-run-storage.ts +263 -0
  144. package/src/pg-timeseries-storage.ts +180 -0
  145. package/src/pg-webhook-delivery-storage.ts +180 -0
  146. package/src/pg-webhook-run-storage.ts +185 -0
  147. package/src/pg-workflow-intervention-storage.ts +339 -0
  148. package/src/pg-workflow-run-storage.ts +1065 -0
  149. package/src/run-list-query.ts +108 -0
  150. package/src/sql.d.ts +4 -0
  151. package/src/storage-errors.ts +72 -0
  152. package/src/transactions.ts +58 -0
@@ -0,0 +1,638 @@
1
+ import type { ObjectQuery } from "@sixb/core"
2
+ import type {
3
+ CountObjectsInput,
4
+ CountObjectsResult,
5
+ ExistsObjectsInput,
6
+ ExistsObjectsResult,
7
+ ExpandedLinkValue,
8
+ ExpandedObjectRow,
9
+ FacetObjectsInput,
10
+ FacetObjectsResult,
11
+ LinkDirection,
12
+ ObjectLinkRow,
13
+ ObjectQueryCapabilities,
14
+ ObjectRow,
15
+ ObjectRowLinks,
16
+ ObjectStorage,
17
+ QueryObjectsInput,
18
+ QueryObjectsResult,
19
+ } from "@sixb/core/storage"
20
+ import type { SQLClient, SqlParameter } from "./pg-client"
21
+ import {
22
+ type CompiledPgObjectQuery,
23
+ compilePgObjectCountQuery,
24
+ compilePgObjectExistsQuery,
25
+ compilePgObjectFacetQuery,
26
+ compilePgObjectQuery,
27
+ } from "./pg-object-query-compiler"
28
+ import type { PgStoreClient } from "./transactions"
29
+
30
+ const PG_OBJECT_QUERY_CAPABILITIES: ObjectQueryCapabilities = {
31
+ queryObjects: true,
32
+ countObjects: true,
33
+ existsObjects: true,
34
+ facetObjects: true,
35
+ nodes: {
36
+ start: true,
37
+ filter: true,
38
+ text: true,
39
+ sort: true,
40
+ limit: true,
41
+ page: true,
42
+ traverse: true,
43
+ set: true,
44
+ project: true,
45
+ expand: true,
46
+ },
47
+ predicateOps: {
48
+ and: true,
49
+ or: true,
50
+ not: true,
51
+ eq: true,
52
+ neq: true,
53
+ lt: true,
54
+ lte: true,
55
+ gt: true,
56
+ gte: true,
57
+ in: true,
58
+ exists: true,
59
+ contains: true,
60
+ },
61
+ sortKinds: {
62
+ property: true,
63
+ },
64
+ traversalDirections: {
65
+ outgoing: true,
66
+ incoming: true,
67
+ },
68
+ setOps: {
69
+ union: true,
70
+ intersect: true,
71
+ subtract: true,
72
+ },
73
+ scalarOperations: {
74
+ string: { equality: true, ordering: true },
75
+ uuid: { equality: true, ordering: true },
76
+ boolean: { equality: true },
77
+ integer: { equality: true, ordering: true },
78
+ double: { equality: true, ordering: true },
79
+ decimal: { equality: true, ordering: true },
80
+ date: { equality: true, ordering: true },
81
+ timestamp: { equality: true, ordering: true },
82
+ },
83
+ limits: {
84
+ totalCount: true,
85
+ stablePageTokens: true,
86
+ },
87
+ notes: [
88
+ "PostgreSQL object query pushdown supports start/filter/text/sort/limit/page/traverse/set/project/expand over JSONB properties and object links.",
89
+ "expand hydrates linked objects in-database (top-N per parent via LATERAL + jsonb_agg); core resolves each expansion's cardinality before pushdown, and a mixed/unresolved one stays on the fallback.",
90
+ "Exact decimal predicates, ordering, and keyset pagination use PostgreSQL numeric casts.",
91
+ "Relevance sorting, vector search, and unresolved start.includeSubtypes remain planner fallback or rejection cases.",
92
+ ],
93
+ }
94
+
95
+ /**
96
+ * Build a `SELECT ... JOIN (VALUES ...) AS t(...) ON ... WHERE ...` query
97
+ * with positional parameters. Bun SQL's tagged-template helpers don't
98
+ * support VALUES inside SELECT/JOIN, so we construct the query string
99
+ * manually while keeping all user data in the parameter array.
100
+ */
101
+ function valuesJoin<Row = unknown>(
102
+ sql: SQLClient,
103
+ select: string,
104
+ columns: string[],
105
+ tuples: unknown[][],
106
+ where: string,
107
+ whereParams: unknown[]
108
+ ): Promise<Row[]> {
109
+ const alias = "t"
110
+ const colWidth = columns.length
111
+
112
+ // $1 … $N are reserved for the WHERE params that come first.
113
+ const base = whereParams.length
114
+ const valuePlaceholders = tuples
115
+ .map((_, i) => {
116
+ const cols = columns.map((_, j) => `$${base + i * colWidth + j + 1}`)
117
+ return `(${cols.join(",")})`
118
+ })
119
+ .join(",")
120
+
121
+ const onClause = columns
122
+ .map((c) => {
123
+ // Infer the table alias from the SELECT clause (first word after SELECT ... FROM)
124
+ const srcAlias = select.match(/FROM\s+\w+\s+(\w+)/i)?.[1] ?? select.split(" ").pop()
125
+ return `${srcAlias}.${c} = ${alias}.${c}`
126
+ })
127
+ .join(" AND ")
128
+
129
+ const query = `${select} JOIN (VALUES ${valuePlaceholders}) AS ${alias}(${columns.join(",")}) ON ${onClause} ${where}`
130
+ const params = [...whereParams, ...tuples.flat()]
131
+
132
+ return sql.unsafe(query, params as SqlParameter[]) as unknown as Promise<Row[]>
133
+ }
134
+
135
+ /**
136
+ * PostgreSQL-based ObjectStorage implementation.
137
+ *
138
+ * Stores object projections and links. V1 object-query IR pushdown covers the
139
+ * scalar JSONB-property and link-traversal subset declared by queryCapabilities().
140
+ *
141
+ * Requires `search_path` to be set to the Sixb schema on the connection.
142
+ *
143
+ * JSONB handling notes (Bun SQL specifics):
144
+ * - Writes use `${JSON.stringify(v)}::text::jsonb`. The `::text` forces the
145
+ * text wire type so that `::jsonb` parses the JSON text into a proper JSONB
146
+ * value (object, number, etc.) instead of a JSONB string literal.
147
+ * - The `@>` containment operator receives a plain JS object which Bun SQL
148
+ * auto-serialises as JSONB.
149
+ * - Reads return JSONB as native JS types (objects, numbers, booleans) when
150
+ * the data was stored correctly.
151
+ */
152
+ export class PgObjectStorage implements ObjectStorage {
153
+ constructor(private readonly sql: PgStoreClient) {}
154
+
155
+ queryCapabilities(): ObjectQueryCapabilities {
156
+ return PG_OBJECT_QUERY_CAPABILITIES
157
+ }
158
+
159
+ async queryObjects(params: QueryObjectsInput): Promise<QueryObjectsResult> {
160
+ const compiled = compilePgObjectQuery(params.projectId, params.query, {
161
+ includeTotal: params.includeTotal,
162
+ })
163
+ const total = params.includeTotal === false ? undefined : await readTotal(this.sql, compiled)
164
+ const rawRows = await this.sql.unsafe<ObjectQueryDatabaseRow[]>(
165
+ compiled.sql,
166
+ compiled.args as SqlParameter[]
167
+ )
168
+ const rows = compiled.trimRows(rawRows) as readonly ObjectQueryDatabaseRow[]
169
+ const hasMore =
170
+ total === undefined && compiled.hasMoreProbe
171
+ ? compiled.hasMoreProbe.hasMore(
172
+ (
173
+ await this.sql.unsafe(
174
+ compiled.hasMoreProbe.sql,
175
+ compiled.hasMoreProbe.args as SqlParameter[]
176
+ )
177
+ ).length
178
+ )
179
+ : compiled.hasMore(rawRows.length, total)
180
+
181
+ return {
182
+ objects: rows.map((row) => queryRowToObject(row)),
183
+ hasMore,
184
+ nextPageToken: compiled.nextPageToken(rows, rawRows.length),
185
+ ...(total === undefined ? {} : { total }),
186
+ }
187
+ }
188
+
189
+ async countObjects(params: CountObjectsInput): Promise<CountObjectsResult> {
190
+ const compiled = compilePgObjectCountQuery(params.projectId, stripOuterRowShape(params.query))
191
+ const [row] = await this.sql.unsafe<{ count: string | number | bigint }[]>(
192
+ compiled.sql,
193
+ compiled.args as SqlParameter[]
194
+ )
195
+ return { count: Number(row?.count ?? 0) }
196
+ }
197
+
198
+ async existsObjects(params: ExistsObjectsInput): Promise<ExistsObjectsResult> {
199
+ const compiled = compilePgObjectExistsQuery(params.projectId, stripOuterRowShape(params.query))
200
+ const [row] = await this.sql.unsafe<unknown[]>(compiled.sql, compiled.args as SqlParameter[])
201
+ return { exists: row !== undefined }
202
+ }
203
+
204
+ async facetObjects(params: FacetObjectsInput): Promise<FacetObjectsResult> {
205
+ return {
206
+ facets: await Promise.all(
207
+ params.facets.map(async (facet) => ({
208
+ propertyId: facet.propertyId,
209
+ buckets: await readFacetBuckets(
210
+ this.sql,
211
+ compilePgObjectFacetQuery(
212
+ params.projectId,
213
+ stripOuterRowShape(params.query),
214
+ facet.propertyId,
215
+ facet.limit
216
+ )
217
+ ),
218
+ }))
219
+ ),
220
+ }
221
+ }
222
+
223
+ async getByPrimaryId(params: {
224
+ projectId: string
225
+ objectTypeId: string
226
+ primaryId: string
227
+ }): Promise<ObjectRow | null> {
228
+ const [row] = await this.sql<ObjectDatabaseRow[]>`
229
+ SELECT * FROM objects
230
+ WHERE project_id = ${params.projectId}
231
+ AND object_type_id = ${params.objectTypeId}
232
+ AND primary_id = ${params.primaryId}
233
+ `
234
+
235
+ return row ? rowToObject(row) : null
236
+ }
237
+
238
+ async listLinks(params: {
239
+ projectId: string
240
+ objectTypeId: string
241
+ objectId: string
242
+ linkId?: string
243
+ direction?: LinkDirection
244
+ }): Promise<readonly ObjectLinkRow[]> {
245
+ const direction = params.direction ?? "outgoing"
246
+ const directionWhere =
247
+ direction === "incoming"
248
+ ? "target_type_id = $2 AND target_id = $3"
249
+ : direction === "both"
250
+ ? "((source_type_id = $2 AND source_id = $3) OR (target_type_id = $2 AND target_id = $3))"
251
+ : "source_type_id = $2 AND source_id = $3"
252
+ const query = `SELECT * FROM links WHERE project_id = $1 AND ${directionWhere}${
253
+ params.linkId ? " AND link_id = $4" : ""
254
+ }`
255
+ const args = params.linkId
256
+ ? [params.projectId, params.objectTypeId, params.objectId, params.linkId]
257
+ : [params.projectId, params.objectTypeId, params.objectId]
258
+ const rows = await this.sql.unsafe<LinkDatabaseRow[]>(query, args)
259
+
260
+ return rows.map((row) => rowToLink(row))
261
+ }
262
+
263
+ async getByPrimaryIdBatch(params: {
264
+ projectId: string
265
+ items: readonly { objectTypeId: string; primaryId: string }[]
266
+ }): Promise<Map<string, ObjectRow>> {
267
+ const result = new Map<string, ObjectRow>()
268
+ if (params.items.length === 0) return result
269
+ const rows = await valuesJoin<ObjectDatabaseRow>(
270
+ this.sql,
271
+ "SELECT o.* FROM objects o",
272
+ ["object_type_id", "primary_id"],
273
+ params.items.map((i) => [i.objectTypeId, i.primaryId]),
274
+ `WHERE o.project_id = $1`,
275
+ [params.projectId]
276
+ )
277
+
278
+ for (const row of rows) {
279
+ result.set(`${row.object_type_id}:${row.primary_id}`, rowToObject(row))
280
+ }
281
+ return result
282
+ }
283
+
284
+ async listLinksBatch(params: {
285
+ projectId: string
286
+ items: readonly { objectTypeId: string; objectId: string; linkId: string }[]
287
+ }): Promise<Map<string, ObjectLinkRow[]>> {
288
+ const result = new Map<string, ObjectLinkRow[]>()
289
+ if (params.items.length === 0) return result
290
+ const rows = await valuesJoin<LinkDatabaseRow>(
291
+ this.sql,
292
+ "SELECT l.* FROM links l",
293
+ ["source_type_id", "source_id", "link_id"],
294
+ params.items.map((i) => [i.objectTypeId, i.objectId, i.linkId]),
295
+ `WHERE l.project_id = $1`,
296
+ [params.projectId]
297
+ )
298
+
299
+ for (const row of rows) {
300
+ const key = `${row.source_type_id}:${row.source_id}:${row.link_id}`
301
+ const existing = result.get(key) ?? []
302
+ existing.push(rowToLink(row))
303
+ result.set(key, existing)
304
+ }
305
+ return result
306
+ }
307
+
308
+ async listIncidentLinksBatch(params: {
309
+ projectId: string
310
+ items: readonly { objectTypeId: string; objectId: string }[]
311
+ }): Promise<readonly ObjectLinkRow[]> {
312
+ if (params.items.length === 0) return []
313
+ const tuples = params.items.map((item) => [item.objectTypeId, item.objectId])
314
+
315
+ // Cover both link directions with two index-friendly equality joins (source-side, then
316
+ // target-side) rather than a single OR-join, which would defeat index usage. This is a constant
317
+ // number of round trips regardless of how many objects are deleted — issued sequentially because
318
+ // these reads run on the serializable commit transaction's single connection. A link incident to
319
+ // two listed objects matches both halves and is de-duplicated below.
320
+ const sourceRows = await valuesJoin<LinkDatabaseRow>(
321
+ this.sql,
322
+ "SELECT l.* FROM links l",
323
+ ["source_type_id", "source_id"],
324
+ tuples,
325
+ "WHERE l.project_id = $1",
326
+ [params.projectId]
327
+ )
328
+ const targetRows = await valuesJoin<LinkDatabaseRow>(
329
+ this.sql,
330
+ "SELECT l.* FROM links l",
331
+ ["target_type_id", "target_id"],
332
+ tuples,
333
+ "WHERE l.project_id = $1",
334
+ [params.projectId]
335
+ )
336
+
337
+ const deduped = new Map<string, ObjectLinkRow>()
338
+ for (const row of [...sourceRows, ...targetRows]) {
339
+ const link = rowToLink(row)
340
+ deduped.set(
341
+ `${link.sourceTypeId}:${link.sourceId}:${link.linkId}:${link.targetTypeId}:${link.targetId}`,
342
+ link
343
+ )
344
+ }
345
+ return [...deduped.values()]
346
+ }
347
+
348
+ async listByPrimaryIdPage(params: {
349
+ projectId: string
350
+ objectTypeId: string
351
+ afterPrimaryId?: string
352
+ limit: number
353
+ }): Promise<{ objects: readonly ObjectRow[]; nextPrimaryId?: string }> {
354
+ assertReconciliationPageLimit(params.limit)
355
+ const rows = await this.sql<ObjectDatabaseRow[]>`
356
+ SELECT * FROM objects
357
+ WHERE project_id = ${params.projectId}
358
+ AND object_type_id = ${params.objectTypeId}
359
+ ${params.afterPrimaryId ? this.sql`AND primary_id > ${params.afterPrimaryId}` : this.sql``}
360
+ ORDER BY primary_id ASC
361
+ LIMIT ${params.limit + 1}
362
+ `
363
+ const hasMore = rows.length > params.limit
364
+ const objects = rows.slice(0, params.limit).map((row) => rowToObject(row))
365
+ const last = objects.at(-1)
366
+ return {
367
+ objects,
368
+ ...(hasMore && last ? { nextPrimaryId: last.primaryId } : {}),
369
+ }
370
+ }
371
+
372
+ async list(params: {
373
+ projectId: string
374
+ objectTypeId?: string | readonly string[]
375
+ primaryIdPrefix?: string
376
+ primaryIdSuffix?: string
377
+ updatedAfter?: Date
378
+ updatedBefore?: Date
379
+ createdAfter?: Date
380
+ createdBefore?: Date
381
+ limit?: number
382
+ offset?: number
383
+ orderBy?: "createdAt" | "updatedAt" | "primaryId"
384
+ order?: "asc" | "desc"
385
+ }): Promise<{ objects: readonly ObjectRow[]; hasMore: boolean; total: number }> {
386
+ const queryOffset = params.offset ?? 0
387
+ const limit = params.limit ?? 50
388
+ const orderBy = params.orderBy ?? "updatedAt"
389
+ const order = params.order ?? "desc"
390
+ const orderColumn =
391
+ orderBy === "primaryId" ? "primary_id" : orderBy === "createdAt" ? "created_at" : "updated_at"
392
+
393
+ // Build WHERE conditions using conditional fragments
394
+ const typeFilter =
395
+ params.objectTypeId === undefined
396
+ ? this.sql``
397
+ : typeof params.objectTypeId === "string"
398
+ ? this.sql`AND object_type_id = ${params.objectTypeId}`
399
+ : this.sql`AND object_type_id IN ${this.sql(params.objectTypeId as string[])}`
400
+
401
+ const primaryIdPrefixFilter = params.primaryIdPrefix
402
+ ? this.sql`AND primary_id LIKE ${`${params.primaryIdPrefix}%`}`
403
+ : this.sql``
404
+
405
+ const primaryIdSuffixFilter = params.primaryIdSuffix
406
+ ? this.sql`AND primary_id LIKE ${`%${params.primaryIdSuffix}`}`
407
+ : this.sql``
408
+
409
+ const updatedAfterFilter = params.updatedAfter
410
+ ? this.sql`AND updated_at >= ${params.updatedAfter}`
411
+ : this.sql``
412
+
413
+ const updatedBeforeFilter = params.updatedBefore
414
+ ? this.sql`AND updated_at <= ${params.updatedBefore}`
415
+ : this.sql``
416
+
417
+ const createdAfterFilter = params.createdAfter
418
+ ? this.sql`AND created_at >= ${params.createdAfter}`
419
+ : this.sql``
420
+
421
+ const createdBeforeFilter = params.createdBefore
422
+ ? this.sql`AND created_at <= ${params.createdBefore}`
423
+ : this.sql``
424
+
425
+ const filters = this.sql`
426
+ ${typeFilter}
427
+ ${primaryIdPrefixFilter}
428
+ ${primaryIdSuffixFilter}
429
+ ${updatedAfterFilter}
430
+ ${updatedBeforeFilter}
431
+ ${createdAfterFilter}
432
+ ${createdBeforeFilter}
433
+ `
434
+
435
+ // Get total count
436
+ const [countResult] = await this.sql<{ total: number }[]>`
437
+ SELECT COUNT(*)::int AS total FROM objects
438
+ WHERE project_id = ${params.projectId}
439
+ ${filters}
440
+ `
441
+ const total = countResult.total
442
+
443
+ if (limit === 0) return { objects: [], hasMore: queryOffset < total, total }
444
+
445
+ // Get paginated results (+1 for hasMore check)
446
+ // Column and direction derived from a fixed mapping — safe to use as identifiers
447
+ const fetchLimit = limit + 1
448
+ const rows = await this.sql<ObjectDatabaseRow[]>`
449
+ SELECT * FROM objects
450
+ WHERE project_id = ${params.projectId}
451
+ ${filters}
452
+ ORDER BY ${this.sql(orderColumn)} ${order === "asc" ? this.sql`ASC` : this.sql`DESC`}
453
+ LIMIT ${fetchLimit}
454
+ OFFSET ${queryOffset}
455
+ `
456
+
457
+ const hasMore = rows.length > limit
458
+ const objects = rows.slice(0, limit).map((row) => rowToObject(row))
459
+
460
+ return { objects, hasMore, total }
461
+ }
462
+ }
463
+
464
+ function assertReconciliationPageLimit(limit: number): void {
465
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
466
+ throw new Error("Object reconciliation page limit must be a positive safe integer.")
467
+ }
468
+ }
469
+
470
+ async function readTotal(sql: SQLClient, compiled: CompiledPgObjectQuery): Promise<number> {
471
+ const [row] = await sql.unsafe<
472
+ {
473
+ total: string | number | bigint
474
+ }[]
475
+ >(compiled.totalSql, compiled.totalArgs as SqlParameter[])
476
+ return Number(row?.total ?? 0)
477
+ }
478
+
479
+ async function readFacetBuckets(
480
+ sql: SQLClient,
481
+ compiled: { sql: string; args: readonly unknown[] }
482
+ ): Promise<{ value: unknown; count: number }[]> {
483
+ const rows = await sql.unsafe<FacetDatabaseRow[]>(compiled.sql, [
484
+ ...compiled.args,
485
+ ] as SqlParameter[])
486
+
487
+ return rows.map((row) => ({
488
+ value: pgFacetValue(row.value_type, row.value_text),
489
+ count: Number(row.count),
490
+ }))
491
+ }
492
+
493
+ function pgFacetValue(valueType: string | null, valueText: string | null): unknown {
494
+ switch (valueType) {
495
+ case "string":
496
+ return valueText ?? ""
497
+ case "number":
498
+ return valueText === null ? null : Number(valueText)
499
+ case "boolean":
500
+ return valueText === "true"
501
+ case "null":
502
+ return null
503
+ case "array":
504
+ case "object":
505
+ return valueText === null ? null : JSON.parse(valueText)
506
+ default:
507
+ return valueText
508
+ }
509
+ }
510
+
511
+ function stripOuterRowShape(query: ObjectQuery): ObjectQuery {
512
+ switch (query.kind) {
513
+ case "limit":
514
+ case "page":
515
+ case "project":
516
+ case "sort":
517
+ return stripOuterRowShape(query.input)
518
+ default:
519
+ return query
520
+ }
521
+ }
522
+
523
+ function rowToObject(row: ObjectDatabaseRow): ObjectRow {
524
+ return {
525
+ projectId: row.project_id,
526
+ objectTypeId: row.object_type_id,
527
+ primaryId: row.primary_id,
528
+ properties: row.properties as Record<string, unknown>,
529
+ createdAt: new Date(row.created_at),
530
+ updatedAt: new Date(row.updated_at),
531
+ version: row.version,
532
+ lastCommitId: row.last_commit_id,
533
+ }
534
+ }
535
+
536
+ // Map a query row, attaching `links` when an `expand` pushdown produced them. The
537
+ // base columns map as usual; `_expand` (a `jsonb_build_object`) is revived into
538
+ // the runtime link shape the core executor's fallback also produces.
539
+ function queryRowToObject(row: ObjectQueryDatabaseRow): ObjectRow {
540
+ const base = rowToObject(row)
541
+ const links = reviveExpandedLinks(row._expand)
542
+ return links ? { ...base, links } : base
543
+ }
544
+
545
+ function reviveExpandedLinks(value: unknown): ObjectRowLinks | undefined {
546
+ if (!isPlainRecord(value)) return undefined
547
+ const links: ObjectRowLinks = {}
548
+ for (const [linkId, raw] of Object.entries(value)) {
549
+ links[linkId] = reviveExpandedLinkValue(raw)
550
+ }
551
+ return links
552
+ }
553
+
554
+ // A "one" expansion arrives as a single object or null; a "many" expansion as an
555
+ // array (already ordered and trimmed in the database).
556
+ function reviveExpandedLinkValue(value: unknown): ExpandedLinkValue {
557
+ if (value === null || value === undefined) return null
558
+ if (Array.isArray(value)) return value.map(reviveExpandedRow)
559
+ return reviveExpandedRow(value)
560
+ }
561
+
562
+ // Revive one hydrated neighbour from `compileExpansionChildJson`: JSONB timestamp
563
+ // strings back to `Date`, empty link properties dropped (matching the fallback),
564
+ // and nested links recursed.
565
+ function reviveExpandedRow(value: unknown): ExpandedObjectRow {
566
+ const row = isPlainRecord(value) ? value : {}
567
+ const expanded: ExpandedObjectRow = {
568
+ projectId: String(row.projectId),
569
+ objectTypeId: String(row.objectTypeId),
570
+ primaryId: String(row.primaryId),
571
+ properties: isPlainRecord(row.properties) ? row.properties : {},
572
+ createdAt: new Date(row.createdAt as string),
573
+ updatedAt: new Date(row.updatedAt as string),
574
+ version: Number(row.version),
575
+ lastCommitId: String(row.lastCommitId),
576
+ }
577
+ if (isPlainRecord(row.linkProperties) && Object.keys(row.linkProperties).length > 0) {
578
+ expanded.linkProperties = row.linkProperties
579
+ }
580
+ const links = reviveExpandedLinks(row.links)
581
+ if (links) expanded.links = links
582
+ return expanded
583
+ }
584
+
585
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
586
+ return typeof value === "object" && value !== null && !Array.isArray(value)
587
+ }
588
+
589
+ function rowToLink(row: LinkDatabaseRow): ObjectLinkRow {
590
+ return {
591
+ projectId: row.project_id,
592
+ sourceTypeId: row.source_type_id,
593
+ sourceId: row.source_id,
594
+ linkId: row.link_id,
595
+ targetTypeId: row.target_type_id,
596
+ targetId: row.target_id,
597
+ properties: row.properties ? (row.properties as Record<string, unknown>) : undefined,
598
+ createdAt: new Date(row.created_at),
599
+ updatedAt: new Date(row.updated_at),
600
+ lastCommitId: row.last_commit_id,
601
+ }
602
+ }
603
+
604
+ interface ObjectDatabaseRow {
605
+ project_id: string
606
+ object_type_id: string
607
+ primary_id: string
608
+ properties: unknown
609
+ created_at: Date | string
610
+ updated_at: Date | string
611
+ version: number
612
+ last_commit_id: string
613
+ }
614
+
615
+ interface ObjectQueryDatabaseRow extends ObjectDatabaseRow {
616
+ _cursor_properties?: unknown
617
+ /** `jsonb_build_object(linkId, value, ...)` from an `expand` pushdown; absent otherwise. */
618
+ _expand?: unknown
619
+ }
620
+
621
+ interface FacetDatabaseRow {
622
+ value_type: string | null
623
+ value_text: string | null
624
+ count: string | number | bigint
625
+ }
626
+
627
+ interface LinkDatabaseRow {
628
+ project_id: string
629
+ source_type_id: string
630
+ source_id: string
631
+ link_id: string
632
+ target_type_id: string
633
+ target_id: string
634
+ properties: unknown | null
635
+ created_at: Date | string
636
+ updated_at: Date | string
637
+ last_commit_id: string
638
+ }