@sixb/pg 0.1.7 → 0.1.9

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