@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,1545 @@
1
+ import {
2
+ type ObjectExpansion,
3
+ type ObjectQuery,
4
+ ObjectQueryExecutionError,
5
+ type ObjectQueryPredicate,
6
+ type ObjectQuerySetOperation,
7
+ type ObjectQuerySortField,
8
+ type QueryScalarKind,
9
+ } from "@sixb/core"
10
+
11
+ export interface PgObjectQueryPageRow {
12
+ object_type_id: string
13
+ primary_id: string
14
+ properties: unknown
15
+ _cursor_properties?: unknown
16
+ }
17
+
18
+ export interface CompiledPgObjectQuery {
19
+ sql: string
20
+ args: unknown[]
21
+ totalSql: string
22
+ totalArgs: unknown[]
23
+ order: CompiledOrder
24
+ hasMoreProbe?: CompiledHasMoreProbe
25
+ hasMore(rowCount: number, total?: number): boolean
26
+ trimRows<T>(rows: readonly T[]): readonly T[]
27
+ nextPageToken(rows: readonly PgObjectQueryPageRow[], rowCount: number): string | undefined
28
+ }
29
+
30
+ export interface CompiledPgScalarQuery {
31
+ sql: string
32
+ args: unknown[]
33
+ }
34
+
35
+ export interface CompiledPgFacetQuery {
36
+ sql: string
37
+ args: unknown[]
38
+ }
39
+
40
+ interface CompiledHasMoreProbe {
41
+ sql: string
42
+ args: unknown[]
43
+ hasMore(rowCount: number): boolean
44
+ }
45
+
46
+ interface CompileContext {
47
+ probeLimit: boolean
48
+ }
49
+
50
+ interface CompiledPredicate {
51
+ sql: string
52
+ args: unknown[]
53
+ }
54
+
55
+ interface CompiledAggregateSource {
56
+ sql: string
57
+ args: unknown[]
58
+ }
59
+
60
+ interface CompiledOrder {
61
+ sql: string
62
+ args: unknown[]
63
+ fields: readonly CompiledOrderField[]
64
+ propertyColumn: string
65
+ }
66
+
67
+ type CompiledOrderField =
68
+ | {
69
+ kind: "property"
70
+ propertyId: string
71
+ direction: "asc" | "desc"
72
+ scalarKind?: QueryScalarKind
73
+ }
74
+ | {
75
+ kind: "column"
76
+ column: "object_type_id" | "primary_id"
77
+ direction: "asc" | "desc"
78
+ }
79
+
80
+ interface EncodedPageToken {
81
+ version: 1
82
+ order: readonly string[]
83
+ values: readonly EncodedCursorValue[]
84
+ }
85
+
86
+ interface EncodedCursorValue {
87
+ nullish: boolean
88
+ value?: unknown
89
+ }
90
+
91
+ const PAGE_TOKEN_PREFIX = "keyset:"
92
+ const exactContext: CompileContext = { probeLimit: false }
93
+ // Per-parent fanout the core executor bakes into every pushed-down expansion;
94
+ // this default only guards a malformed (un-baked) IR and mirrors the core cap.
95
+ const DEFAULT_EXPANSION_FANOUT = 1_000
96
+
97
+ export function compilePgObjectQuery(
98
+ projectId: string,
99
+ query: ObjectQuery,
100
+ options: { includeTotal?: boolean } = {}
101
+ ): CompiledPgObjectQuery {
102
+ const compiled = compileObjectQueryInternal(projectId, query, {
103
+ probeLimit: options.includeTotal === false,
104
+ })
105
+ return {
106
+ ...compiled,
107
+ sql: numberPlaceholders(compiled.sql),
108
+ totalSql: numberPlaceholders(compiled.totalSql),
109
+ hasMoreProbe: compiled.hasMoreProbe
110
+ ? {
111
+ ...compiled.hasMoreProbe,
112
+ sql: numberPlaceholders(compiled.hasMoreProbe.sql),
113
+ }
114
+ : undefined,
115
+ }
116
+ }
117
+
118
+ export function compilePgObjectCountQuery(
119
+ projectId: string,
120
+ query: ObjectQuery
121
+ ): CompiledPgScalarQuery {
122
+ const source = compileAggregateSource(projectId, query)
123
+ return numberCompiledQuery({
124
+ sql: `
125
+ SELECT COUNT(*)::bigint AS count
126
+ FROM (${source.sql}) AS input
127
+ `,
128
+ args: source.args,
129
+ })
130
+ }
131
+
132
+ export function compilePgObjectExistsQuery(
133
+ projectId: string,
134
+ query: ObjectQuery
135
+ ): CompiledPgScalarQuery {
136
+ const source = compileAggregateSource(projectId, query)
137
+ return numberCompiledQuery({
138
+ sql: `
139
+ SELECT 1
140
+ FROM (${source.sql}) AS input
141
+ LIMIT 1
142
+ `,
143
+ args: source.args,
144
+ })
145
+ }
146
+
147
+ export function compilePgObjectFacetQuery(
148
+ projectId: string,
149
+ query: ObjectQuery,
150
+ propertyId: string,
151
+ limit: number
152
+ ): CompiledPgFacetQuery {
153
+ const source = compileAggregateSource(projectId, query)
154
+ return numberCompiledQuery({
155
+ sql: `
156
+ SELECT facet.value_type, facet.value_text, COUNT(*)::bigint AS count
157
+ FROM (
158
+ SELECT
159
+ jsonb_typeof(input.properties -> (?::text)) AS value_type,
160
+ input.properties ->> (?::text) AS value_text
161
+ FROM (${source.sql}) AS input
162
+ WHERE jsonb_exists(input.properties, ?::text)
163
+ ) AS facet
164
+ GROUP BY facet.value_type, facet.value_text
165
+ ORDER BY count DESC, facet.value_text ASC
166
+ LIMIT ?
167
+ `,
168
+ args: [propertyId, propertyId, ...source.args, propertyId, limit],
169
+ })
170
+ }
171
+
172
+ function compileObjectQueryInternal(
173
+ projectId: string,
174
+ query: ObjectQuery,
175
+ ctx: CompileContext
176
+ ): CompiledPgObjectQuery {
177
+ switch (query.kind) {
178
+ case "start":
179
+ return compileStart(projectId, query)
180
+ case "filter":
181
+ return compileFilter(projectId, query.input, query.predicate)
182
+ case "sort":
183
+ return compileSort(projectId, query.input, query.fields, ctx)
184
+ case "limit":
185
+ return compileLimit(projectId, query.input, query.limit, ctx)
186
+ case "page":
187
+ return compilePage(projectId, query.input, query.pageSize, query.pageToken)
188
+ case "traverse":
189
+ return compileTraversal(
190
+ projectId,
191
+ query.input,
192
+ query.linkId,
193
+ query.direction,
194
+ query.sourceObjectTypeId
195
+ )
196
+ case "set":
197
+ return compileSet(projectId, query.op, query.inputs)
198
+ case "project":
199
+ return compileProject(projectId, query.input, query.properties, ctx)
200
+ case "text":
201
+ return compileText(
202
+ projectId,
203
+ query.input,
204
+ query.query,
205
+ query.fields,
206
+ query.fieldsByObjectType
207
+ )
208
+ case "expand":
209
+ return compileExpand(projectId, query.input, query.expansions)
210
+ case "vector":
211
+ throw new Error(
212
+ `[SixbPg] PostgreSQL object storage does not support query node '${query.kind}'`
213
+ )
214
+ }
215
+ }
216
+
217
+ function compileStart(
218
+ projectId: string,
219
+ query: Extract<ObjectQuery, { kind: "start" }>
220
+ ): CompiledPgObjectQuery {
221
+ if (query.includeSubtypes === true) {
222
+ throw new Error("[SixbPg] PostgreSQL object storage does not support start.includeSubtypes")
223
+ }
224
+
225
+ const order = compileOrder(identityOrderFields())
226
+ const sql = `
227
+ SELECT *, properties AS _cursor_properties
228
+ FROM objects
229
+ WHERE project_id = ? AND object_type_id = ?
230
+ ORDER BY ${order.sql}
231
+ `
232
+
233
+ return {
234
+ sql,
235
+ args: [projectId, query.objectTypeId, ...order.args],
236
+ totalSql:
237
+ "SELECT COUNT(*)::bigint AS total FROM objects WHERE project_id = ? AND object_type_id = ?",
238
+ totalArgs: [projectId, query.objectTypeId],
239
+ order,
240
+ hasMore: () => false,
241
+ trimRows: identityRows,
242
+ nextPageToken: () => undefined,
243
+ }
244
+ }
245
+
246
+ function compileFilter(
247
+ projectId: string,
248
+ inputQuery: ObjectQuery,
249
+ predicateNode: ObjectQueryPredicate
250
+ ): CompiledPgObjectQuery {
251
+ return compileWhere(projectId, inputQuery, compilePredicate(predicateNode))
252
+ }
253
+
254
+ function compileWhere(
255
+ projectId: string,
256
+ inputQuery: ObjectQuery,
257
+ predicate: CompiledPredicate
258
+ ): CompiledPgObjectQuery {
259
+ const input = compileObjectQueryInternal(projectId, inputQuery, exactContext)
260
+ const sql = `
261
+ SELECT *
262
+ FROM (${input.sql}) AS input
263
+ WHERE ${predicate.sql}
264
+ ORDER BY ${input.order.sql}
265
+ `
266
+
267
+ return {
268
+ sql,
269
+ args: [...input.args, ...predicate.args, ...input.order.args],
270
+ totalSql: `
271
+ SELECT COUNT(*)::bigint AS total
272
+ FROM (${input.sql}) AS input
273
+ WHERE ${predicate.sql}
274
+ `,
275
+ totalArgs: [...input.args, ...predicate.args],
276
+ order: input.order,
277
+ hasMore: () => false,
278
+ trimRows: identityRows,
279
+ nextPageToken: () => undefined,
280
+ }
281
+ }
282
+
283
+ function compileText(
284
+ projectId: string,
285
+ inputQuery: ObjectQuery,
286
+ query: string,
287
+ fields: readonly string[] | undefined,
288
+ fieldsByObjectType: Readonly<Record<string, readonly string[]>> | undefined
289
+ ): CompiledPgObjectQuery {
290
+ return compileWhere(
291
+ projectId,
292
+ inputQuery,
293
+ compileTextSearchPredicate(query, fields, fieldsByObjectType)
294
+ )
295
+ }
296
+
297
+ function compileSort(
298
+ projectId: string,
299
+ inputQuery: ObjectQuery,
300
+ fields: readonly ObjectQuerySortField[],
301
+ ctx: CompileContext
302
+ ): CompiledPgObjectQuery {
303
+ const input = compileObjectQueryInternal(projectId, inputQuery, exactContext)
304
+ const probeInput =
305
+ ctx.probeLimit && needsLimitProbe(inputQuery)
306
+ ? compileObjectQueryInternal(projectId, inputQuery, ctx)
307
+ : undefined
308
+ const order = compileOrder(sortOrderFields(fields))
309
+
310
+ return {
311
+ sql: `
312
+ SELECT
313
+ input.project_id,
314
+ input.object_type_id,
315
+ input.primary_id,
316
+ input.properties,
317
+ input.properties AS _cursor_properties,
318
+ input.created_at,
319
+ input.updated_at,
320
+ input.version,
321
+ input.last_commit_id
322
+ FROM (${input.sql}) AS input
323
+ ORDER BY ${order.sql}
324
+ `,
325
+ args: [...input.args, ...order.args],
326
+ totalSql: input.totalSql,
327
+ totalArgs: input.totalArgs,
328
+ order,
329
+ hasMoreProbe: probeInput ? hasMoreProbeFor(probeInput) : input.hasMoreProbe,
330
+ hasMore: input.hasMore,
331
+ trimRows: input.trimRows,
332
+ nextPageToken: input.nextPageToken,
333
+ }
334
+ }
335
+
336
+ function compileLimit(
337
+ projectId: string,
338
+ inputQuery: ObjectQuery,
339
+ rawLimit: number,
340
+ ctx: CompileContext
341
+ ): CompiledPgObjectQuery {
342
+ const input = compileObjectQueryInternal(projectId, inputQuery, exactContext)
343
+ const limit = Math.max(0, rawLimit)
344
+ const rowLimit = ctx.probeLimit ? limit + 1 : limit
345
+
346
+ return {
347
+ sql: `
348
+ SELECT *
349
+ FROM (${input.sql}) AS input
350
+ ORDER BY ${input.order.sql}
351
+ LIMIT ?
352
+ `,
353
+ args: [...input.args, ...input.order.args, rowLimit],
354
+ totalSql: `
355
+ SELECT COUNT(*)::bigint AS total
356
+ FROM (${input.sql}) AS input
357
+ `,
358
+ totalArgs: input.args,
359
+ order: input.order,
360
+ hasMore: (rowCount, total) =>
361
+ total === undefined ? ctx.probeLimit && rowCount > limit : limit < total,
362
+ trimRows: ctx.probeLimit ? (rows) => rows.slice(0, limit) : identityRows,
363
+ nextPageToken: () => undefined,
364
+ }
365
+ }
366
+
367
+ function compilePage(
368
+ projectId: string,
369
+ inputQuery: ObjectQuery,
370
+ rawPageSize: number,
371
+ pageToken: string | undefined
372
+ ): CompiledPgObjectQuery {
373
+ const input = compileObjectQueryInternal(projectId, inputQuery, exactContext)
374
+ const pageSize = Math.max(0, rawPageSize)
375
+ const cursor = pageToken ? decodePageToken(pageToken, input.order.fields) : undefined
376
+ const cursorPredicate = cursor
377
+ ? compileKeysetPredicate(input.order.fields, cursor, input.order.propertyColumn)
378
+ : undefined
379
+ const whereSql = cursorPredicate ? `WHERE ${cursorPredicate.sql}` : ""
380
+
381
+ return {
382
+ sql: `
383
+ SELECT *
384
+ FROM (${input.sql}) AS input
385
+ ${whereSql}
386
+ ORDER BY ${input.order.sql}
387
+ LIMIT ?
388
+ `,
389
+ args: [...input.args, ...(cursorPredicate?.args ?? []), ...input.order.args, pageSize + 1],
390
+ totalSql: `
391
+ SELECT COUNT(*)::bigint AS total
392
+ FROM (${input.sql}) AS input
393
+ `,
394
+ totalArgs: input.args,
395
+ order: input.order,
396
+ hasMore: (rowCount) => rowCount > pageSize,
397
+ trimRows: (rows) => rows.slice(0, pageSize),
398
+ nextPageToken: (rows, rowCount) => {
399
+ if (rowCount <= pageSize || rows.length === 0) return undefined
400
+ return encodePageToken(rows[rows.length - 1], input.order.fields)
401
+ },
402
+ }
403
+ }
404
+
405
+ function compileTraversal(
406
+ projectId: string,
407
+ inputQuery: ObjectQuery,
408
+ linkId: string,
409
+ direction: "outgoing" | "incoming",
410
+ sourceObjectTypeId?: string
411
+ ): CompiledPgObjectQuery {
412
+ const input = compileObjectQueryInternal(projectId, inputQuery, exactContext)
413
+ const outputAlias = direction === "outgoing" ? "target_object" : "source_object"
414
+ const joinSql =
415
+ direction === "outgoing"
416
+ ? `
417
+ JOIN links AS edge
418
+ ON edge.project_id = input.project_id
419
+ AND edge.source_type_id = input.object_type_id
420
+ AND edge.source_id = input.primary_id
421
+ AND edge.link_id = ?
422
+ JOIN objects AS target_object
423
+ ON target_object.project_id = edge.project_id
424
+ AND target_object.object_type_id = edge.target_type_id
425
+ AND target_object.primary_id = edge.target_id
426
+ `
427
+ : `
428
+ JOIN links AS edge
429
+ ON edge.project_id = input.project_id
430
+ AND edge.target_type_id = input.object_type_id
431
+ AND edge.target_id = input.primary_id
432
+ AND edge.link_id = ?${sourceObjectTypeId === undefined ? "" : "\n AND edge.source_type_id = ?"}
433
+ JOIN objects AS source_object
434
+ ON source_object.project_id = edge.project_id
435
+ AND source_object.object_type_id = edge.source_type_id
436
+ AND source_object.primary_id = edge.source_id
437
+ `
438
+ const order = compileOrder(identityOrderFields())
439
+ const qualifiedOrder = compileOrder(identityOrderFields(), outputAlias)
440
+ const sql = `
441
+ SELECT DISTINCT ${outputAlias}.*, ${outputAlias}.properties AS _cursor_properties
442
+ FROM (${input.sql}) AS input
443
+ ${joinSql}
444
+ ORDER BY ${qualifiedOrder.sql}
445
+ `
446
+ const args = [
447
+ ...input.args,
448
+ linkId,
449
+ ...(sourceObjectTypeId === undefined ? [] : [sourceObjectTypeId]),
450
+ ...qualifiedOrder.args,
451
+ ]
452
+
453
+ return {
454
+ sql,
455
+ args,
456
+ totalSql: `SELECT COUNT(*)::bigint AS total FROM (${sql}) AS traversed`,
457
+ totalArgs: args,
458
+ order,
459
+ hasMore: () => false,
460
+ trimRows: identityRows,
461
+ nextPageToken: () => undefined,
462
+ }
463
+ }
464
+
465
+ /** Correlation handle for an expansion: the parent row's identity columns. */
466
+ interface ExpansionParent {
467
+ project: string
468
+ type: string
469
+ id: string
470
+ }
471
+
472
+ // `expand` attaches linked objects to each result row without changing which
473
+ // objects match (unlike `traverse`, which pivots to the targets). Each expansion
474
+ // becomes a correlated subquery that hydrates its links into a JSONB value, and
475
+ // all expansions are folded into one `_expand` object column the row mapper reads
476
+ // back. The input set is otherwise untouched, so pagination/order pass through.
477
+ function compileExpand(
478
+ projectId: string,
479
+ inputQuery: ObjectQuery,
480
+ expansions: readonly ObjectExpansion[]
481
+ ): CompiledPgObjectQuery {
482
+ const input = compileObjectQueryInternal(projectId, inputQuery, exactContext)
483
+ const expand = compileExpansionsObject(
484
+ expansions,
485
+ { project: "input.project_id", type: "input.object_type_id", id: "input.primary_id" },
486
+ ""
487
+ )
488
+ const inputOrder = compileOrder(input.order.fields, "input", "input._cursor_properties")
489
+ const sql = `
490
+ SELECT input.*, ${expand.sql} AS _expand
491
+ FROM (${input.sql}) AS input
492
+ ORDER BY ${inputOrder.sql}
493
+ `
494
+
495
+ return {
496
+ sql,
497
+ args: [...expand.args, ...input.args, ...inputOrder.args],
498
+ totalSql: input.totalSql,
499
+ totalArgs: input.totalArgs,
500
+ order: input.order,
501
+ hasMoreProbe: input.hasMoreProbe,
502
+ hasMore: input.hasMore,
503
+ trimRows: input.trimRows,
504
+ nextPageToken: input.nextPageToken,
505
+ }
506
+ }
507
+
508
+ // `jsonb_build_object(linkId, value, ...)` over a set of expansions sharing one
509
+ // parent. Link ids are user data, so they ride as parameters. Used both for the
510
+ // outer `_expand` column and for each child's nested `links`.
511
+ function compileExpansionsObject(
512
+ expansions: readonly ObjectExpansion[],
513
+ parent: ExpansionParent,
514
+ pathPrefix: string
515
+ ): CompiledPredicate {
516
+ const parts: string[] = []
517
+ const args: unknown[] = []
518
+ expansions.forEach((expansion, index) => {
519
+ const path = pathPrefix === "" ? `${index}` : `${pathPrefix}_${index}`
520
+ const value = compileExpansionValue(expansion, parent, path)
521
+ parts.push("?::text", value.sql)
522
+ args.push(expansion.linkId, ...value.args)
523
+ })
524
+ return { sql: `jsonb_build_object(${parts.join(", ")})`, args }
525
+ }
526
+
527
+ // One expansion's hydrated value. `row_number()` numbers a parent's links by the
528
+ // order from `compileExpansionOrder` (the expansion's `orderBy` against the target
529
+ // object's properties, then an identity tiebreak); `WHERE _ord <= N` keeps the
530
+ // top-N per parent in-DB; each retained neighbour becomes a JSONB object; and the
531
+ // value is an ordered array ("many") or the first element or null ("one"). The
532
+ // cardinality is core-resolved before pushdown.
533
+ function compileExpansionValue(
534
+ expansion: ObjectExpansion,
535
+ parent: ExpansionParent,
536
+ path: string
537
+ ): CompiledPredicate {
538
+ const edge = `edge_${path}`
539
+ const target = `tgt_${path}`
540
+ const ranked = `ranked_${path}`
541
+ const incoming = expansion.direction === "incoming"
542
+ // The hydrated neighbour is the link target (outgoing) or source (incoming);
543
+ // the parent sits on the opposite end, mirroring `compileTraversal`.
544
+ const neighborType = incoming ? `${edge}.source_type_id` : `${edge}.target_type_id`
545
+ const neighborId = incoming ? `${edge}.source_id` : `${edge}.target_id`
546
+ const parentType = incoming ? `${edge}.target_type_id` : `${edge}.source_type_id`
547
+ const parentId = incoming ? `${edge}.target_id` : `${edge}.source_id`
548
+
549
+ const child = compileExpansionChildJson(expansion, edge, target, path)
550
+ const order = compileExpansionOrder(expansion, target, neighborType, neighborId)
551
+
552
+ const whereParts = [
553
+ `${edge}.project_id = ${parent.project}`,
554
+ `${parentType} = ${parent.type}`,
555
+ `${parentId} = ${parent.id}`,
556
+ `${edge}.link_id = ?::text`,
557
+ ]
558
+ const whereArgs: unknown[] = [expansion.linkId]
559
+ if (incoming && expansion.sourceObjectTypeId !== undefined) {
560
+ whereParts.push(`${edge}.source_type_id = ?::text`)
561
+ whereArgs.push(expansion.sourceObjectTypeId)
562
+ }
563
+
564
+ const inner = `
565
+ SELECT ${child.sql} AS elem, row_number() OVER (ORDER BY ${order.sql}) AS _ord
566
+ FROM links AS ${edge}
567
+ JOIN objects AS ${target}
568
+ ON ${target}.project_id = ${edge}.project_id
569
+ AND ${target}.object_type_id = ${neighborType}
570
+ AND ${target}.primary_id = ${neighborId}
571
+ WHERE ${whereParts.join(" AND ")}
572
+ `
573
+ const innerArgs = [...child.args, ...order.args, ...whereArgs]
574
+
575
+ if (expansion.cardinality === "one") {
576
+ return {
577
+ sql: `(SELECT ${ranked}.elem FROM (${inner}) AS ${ranked} WHERE ${ranked}._ord = 1)`,
578
+ args: innerArgs,
579
+ }
580
+ }
581
+
582
+ // The fanout cap is baked into `limit` by the core executor before pushdown.
583
+ const limit = expansion.limit ?? DEFAULT_EXPANSION_FANOUT
584
+ return {
585
+ sql: `COALESCE((SELECT jsonb_agg(${ranked}.elem ORDER BY ${ranked}._ord) FROM (${inner}) AS ${ranked} WHERE ${ranked}._ord <= ?), '[]'::jsonb)`,
586
+ args: [...innerArgs, limit],
587
+ }
588
+ }
589
+
590
+ // Build one hydrated neighbour as an `ExpandedObjectRow`-shaped JSONB object.
591
+ // `linkProperties` always rides along (the mapper drops it when empty), and
592
+ // nested expansions recurse under `links`, correlated on this neighbour.
593
+ function compileExpansionChildJson(
594
+ expansion: ObjectExpansion,
595
+ edge: string,
596
+ target: string,
597
+ path: string
598
+ ): CompiledPredicate {
599
+ const fields = [
600
+ `'projectId', ${target}.project_id`,
601
+ `'objectTypeId', ${target}.object_type_id`,
602
+ `'primaryId', ${target}.primary_id`,
603
+ `'properties', ${target}.properties`,
604
+ `'createdAt', ${target}.created_at`,
605
+ `'updatedAt', ${target}.updated_at`,
606
+ `'version', ${target}.version`,
607
+ `'lastCommitId', ${target}.last_commit_id`,
608
+ `'linkProperties', ${edge}.properties`,
609
+ ]
610
+ const args: unknown[] = []
611
+
612
+ if (expansion.expand && expansion.expand.length > 0) {
613
+ const nested = compileExpansionsObject(
614
+ expansion.expand,
615
+ {
616
+ project: `${target}.project_id`,
617
+ type: `${target}.object_type_id`,
618
+ id: `${target}.primary_id`,
619
+ },
620
+ path
621
+ )
622
+ fields.push(`'links', ${nested.sql}`)
623
+ args.push(...nested.args)
624
+ }
625
+
626
+ return { sql: `jsonb_build_object(${fields.join(", ")})`, args }
627
+ }
628
+
629
+ // Order a parent's links the way the fallback's per-parent trim does: each
630
+ // property sorts nulls last with the requested direction (against the neighbour's
631
+ // JSONB properties), then a deterministic identity tiebreak on the neighbour.
632
+ function compileExpansionOrder(
633
+ expansion: ObjectExpansion,
634
+ target: string,
635
+ neighborType: string,
636
+ neighborId: string
637
+ ): CompiledPredicate {
638
+ const propertyColumn = `${target}.properties`
639
+ const clauses: string[] = []
640
+ const args: unknown[] = []
641
+
642
+ for (const field of expansion.orderBy ?? []) {
643
+ // Relevance is a no-op in the fallback comparator; mirror that by skipping it.
644
+ if (field.kind !== "property") continue
645
+ const direction = field.direction === "desc" ? "DESC" : "ASC"
646
+ clauses.push(
647
+ `CASE WHEN ${jsonTypeExpression(propertyColumn)} IS NULL OR ${jsonTypeExpression(propertyColumn)} = 'null' THEN 1 ELSE 0 END ASC`,
648
+ `${
649
+ field.scalarKind === "decimal"
650
+ ? `(${jsonTextExpression(propertyColumn)})::numeric`
651
+ : jsonValueExpression(propertyColumn)
652
+ } ${direction}`
653
+ )
654
+ args.push(field.propertyId, field.propertyId, field.propertyId)
655
+ }
656
+
657
+ clauses.push(`${neighborType} ASC`, `${neighborId} ASC`)
658
+ return { sql: clauses.join(", "), args }
659
+ }
660
+
661
+ function compileSet(
662
+ projectId: string,
663
+ op: ObjectQuerySetOperation,
664
+ inputs: readonly ObjectQuery[]
665
+ ): CompiledPgObjectQuery {
666
+ if (inputs.length === 0) {
667
+ const order = compileOrder(identityOrderFields())
668
+ return {
669
+ sql: `
670
+ SELECT *, properties AS _cursor_properties
671
+ FROM objects
672
+ WHERE 1 = 0
673
+ ORDER BY ${order.sql}
674
+ `,
675
+ args: order.args,
676
+ totalSql: "SELECT 0::bigint AS total",
677
+ totalArgs: [],
678
+ order,
679
+ hasMore: () => false,
680
+ trimRows: identityRows,
681
+ nextPageToken: () => undefined,
682
+ }
683
+ }
684
+
685
+ const compiledInputs = inputs.map((input) =>
686
+ compileObjectQueryInternal(projectId, input, exactContext)
687
+ )
688
+ const identities = compileSetIdentities(op, compiledInputs)
689
+ const order = compileOrder(identityOrderFields())
690
+ const selectedOrder = compileOrder(identityOrderFields(), "selected")
691
+ const sql = `
692
+ SELECT selected.*, selected.properties AS _cursor_properties
693
+ FROM (${identities.sql}) AS ids
694
+ JOIN objects AS selected
695
+ ON selected.project_id = ?
696
+ AND selected.object_type_id = ids.object_type_id
697
+ AND selected.primary_id = ids.primary_id
698
+ ORDER BY ${selectedOrder.sql}
699
+ `
700
+
701
+ return {
702
+ sql,
703
+ args: [...identities.args, projectId, ...selectedOrder.args],
704
+ totalSql: `SELECT COUNT(*)::bigint AS total FROM (${identities.sql}) AS ids`,
705
+ totalArgs: identities.args,
706
+ order,
707
+ hasMore: () => false,
708
+ trimRows: identityRows,
709
+ nextPageToken: () => undefined,
710
+ }
711
+ }
712
+
713
+ function compileProject(
714
+ projectId: string,
715
+ inputQuery: ObjectQuery,
716
+ properties: readonly string[] | undefined,
717
+ ctx: CompileContext
718
+ ): CompiledPgObjectQuery {
719
+ const input = compileObjectQueryInternal(projectId, inputQuery, ctx)
720
+ if (!properties) return input
721
+
722
+ const projection = compileProjectionExpression(properties)
723
+ const inputOrder = compileOrder(input.order.fields, "input", "input._cursor_properties")
724
+ const outputOrder = compileOrder(input.order.fields, undefined, "_cursor_properties")
725
+
726
+ return {
727
+ sql: `
728
+ SELECT
729
+ input.project_id,
730
+ input.object_type_id,
731
+ input.primary_id,
732
+ ${projection.sql} AS properties,
733
+ input._cursor_properties AS _cursor_properties,
734
+ input.created_at,
735
+ input.updated_at,
736
+ input.version,
737
+ input.last_commit_id
738
+ FROM (${input.sql}) AS input
739
+ ORDER BY ${inputOrder.sql}
740
+ `,
741
+ args: [...projection.args, ...input.args, ...inputOrder.args],
742
+ totalSql: input.totalSql,
743
+ totalArgs: input.totalArgs,
744
+ order: outputOrder,
745
+ hasMoreProbe: input.hasMoreProbe,
746
+ hasMore: input.hasMore,
747
+ trimRows: input.trimRows,
748
+ nextPageToken: input.nextPageToken,
749
+ }
750
+ }
751
+
752
+ function compileAggregateSource(projectId: string, query: ObjectQuery): CompiledAggregateSource {
753
+ switch (query.kind) {
754
+ case "start":
755
+ return compileAggregateStart(projectId, query)
756
+ case "filter":
757
+ return compileAggregateWhere(projectId, query.input, compilePredicate(query.predicate))
758
+ case "text": {
759
+ return compileAggregateWhere(
760
+ projectId,
761
+ query.input,
762
+ compileTextSearchPredicate(query.query, query.fields, query.fieldsByObjectType)
763
+ )
764
+ }
765
+ case "traverse":
766
+ return compileAggregateTraversal(
767
+ projectId,
768
+ query.input,
769
+ query.linkId,
770
+ query.direction,
771
+ query.sourceObjectTypeId
772
+ )
773
+ case "set":
774
+ return compileAggregateSet(projectId, query.op, query.inputs)
775
+ case "sort":
776
+ case "project":
777
+ // `expand` is output-shaping, like sort/project: aggregates ignore it.
778
+ case "expand":
779
+ return compileAggregateSource(projectId, query.input)
780
+ case "limit":
781
+ case "page":
782
+ return compileRowQueryAggregateSource(projectId, query)
783
+ case "vector":
784
+ throw new Error(`[SixbPg] PostgreSQL object storage does not support query node 'vector'`)
785
+ }
786
+ }
787
+
788
+ function compileAggregateStart(
789
+ projectId: string,
790
+ query: Extract<ObjectQuery, { kind: "start" }>
791
+ ): CompiledAggregateSource {
792
+ if (query.includeSubtypes === true) {
793
+ throw new Error("[SixbPg] PostgreSQL object storage does not support start.includeSubtypes")
794
+ }
795
+
796
+ return {
797
+ sql: `
798
+ SELECT project_id, object_type_id, primary_id, properties
799
+ FROM objects
800
+ WHERE project_id = ? AND object_type_id = ?
801
+ `,
802
+ args: [projectId, query.objectTypeId],
803
+ }
804
+ }
805
+
806
+ function compileAggregateWhere(
807
+ projectId: string,
808
+ inputQuery: ObjectQuery,
809
+ predicate: CompiledPredicate
810
+ ): CompiledAggregateSource {
811
+ const input = compileAggregateSource(projectId, inputQuery)
812
+ return {
813
+ sql: `
814
+ SELECT input.project_id, input.object_type_id, input.primary_id, input.properties
815
+ FROM (${input.sql}) AS input
816
+ WHERE ${predicate.sql}
817
+ `,
818
+ args: [...input.args, ...predicate.args],
819
+ }
820
+ }
821
+
822
+ function compileAggregateTraversal(
823
+ projectId: string,
824
+ inputQuery: ObjectQuery,
825
+ linkId: string,
826
+ direction: "outgoing" | "incoming",
827
+ sourceObjectTypeId?: string
828
+ ): CompiledAggregateSource {
829
+ const input = compileAggregateSource(projectId, inputQuery)
830
+ const outputAlias = direction === "outgoing" ? "target_object" : "source_object"
831
+ const joinSql =
832
+ direction === "outgoing"
833
+ ? `
834
+ JOIN links AS edge
835
+ ON edge.project_id = input.project_id
836
+ AND edge.source_type_id = input.object_type_id
837
+ AND edge.source_id = input.primary_id
838
+ AND edge.link_id = ?
839
+ JOIN objects AS target_object
840
+ ON target_object.project_id = edge.project_id
841
+ AND target_object.object_type_id = edge.target_type_id
842
+ AND target_object.primary_id = edge.target_id
843
+ `
844
+ : `
845
+ JOIN links AS edge
846
+ ON edge.project_id = input.project_id
847
+ AND edge.target_type_id = input.object_type_id
848
+ AND edge.target_id = input.primary_id
849
+ AND edge.link_id = ?${sourceObjectTypeId === undefined ? "" : "\n AND edge.source_type_id = ?"}
850
+ JOIN objects AS source_object
851
+ ON source_object.project_id = edge.project_id
852
+ AND source_object.object_type_id = edge.source_type_id
853
+ AND source_object.primary_id = edge.source_id
854
+ `
855
+
856
+ return {
857
+ sql: `
858
+ SELECT DISTINCT
859
+ ${outputAlias}.project_id,
860
+ ${outputAlias}.object_type_id,
861
+ ${outputAlias}.primary_id,
862
+ ${outputAlias}.properties
863
+ FROM (${input.sql}) AS input
864
+ ${joinSql}
865
+ `,
866
+ args: [
867
+ ...input.args,
868
+ linkId,
869
+ ...(sourceObjectTypeId === undefined ? [] : [sourceObjectTypeId]),
870
+ ],
871
+ }
872
+ }
873
+
874
+ function compileAggregateSet(
875
+ projectId: string,
876
+ op: ObjectQuerySetOperation,
877
+ inputs: readonly ObjectQuery[]
878
+ ): CompiledAggregateSource {
879
+ if (inputs.length === 0) {
880
+ return {
881
+ sql: `
882
+ SELECT project_id, object_type_id, primary_id, properties
883
+ FROM objects
884
+ WHERE 1 = 0
885
+ `,
886
+ args: [],
887
+ }
888
+ }
889
+
890
+ const compiledInputs = inputs.map((input) => compileAggregateSource(projectId, input))
891
+ const identities = compileSetIdentities(op, compiledInputs)
892
+ return {
893
+ sql: `
894
+ SELECT selected.project_id, selected.object_type_id, selected.primary_id, selected.properties
895
+ FROM (${identities.sql}) AS ids
896
+ JOIN objects AS selected
897
+ ON selected.project_id = ?
898
+ AND selected.object_type_id = ids.object_type_id
899
+ AND selected.primary_id = ids.primary_id
900
+ `,
901
+ args: [...identities.args, projectId],
902
+ }
903
+ }
904
+
905
+ function compileRowQueryAggregateSource(
906
+ projectId: string,
907
+ query: ObjectQuery
908
+ ): CompiledAggregateSource {
909
+ const rowQuery = compileObjectQueryInternal(projectId, query, exactContext)
910
+ return {
911
+ sql: `
912
+ SELECT input.project_id, input.object_type_id, input.primary_id, input.properties
913
+ FROM (${rowQuery.sql}) AS input
914
+ `,
915
+ args: rowQuery.args,
916
+ }
917
+ }
918
+
919
+ function needsLimitProbe(query: ObjectQuery): boolean {
920
+ switch (query.kind) {
921
+ case "limit":
922
+ return true
923
+ case "project":
924
+ case "sort":
925
+ return needsLimitProbe(query.input)
926
+ default:
927
+ return false
928
+ }
929
+ }
930
+
931
+ function hasMoreProbeFor(compiled: CompiledPgObjectQuery): CompiledHasMoreProbe {
932
+ return (
933
+ compiled.hasMoreProbe ?? {
934
+ sql: compiled.sql,
935
+ args: compiled.args,
936
+ hasMore: (rowCount) => compiled.hasMore(rowCount, undefined),
937
+ }
938
+ )
939
+ }
940
+
941
+ function compileSetIdentities(
942
+ op: ObjectQuerySetOperation,
943
+ inputs: readonly { sql: string; args: readonly unknown[] }[]
944
+ ): CompiledPredicate {
945
+ const operator = op === "union" ? "UNION" : op === "intersect" ? "INTERSECT" : "EXCEPT"
946
+ const identitySqls = inputs.map(
947
+ (input) => `SELECT object_type_id, primary_id FROM (${input.sql}) AS input`
948
+ )
949
+
950
+ return {
951
+ sql: identitySqls.join(` ${operator} `),
952
+ args: inputs.flatMap((input) => input.args),
953
+ }
954
+ }
955
+
956
+ function compileProjectionExpression(properties: readonly string[]): CompiledPredicate {
957
+ if (properties.length === 0) return { sql: "'{}'::jsonb", args: [] }
958
+
959
+ return {
960
+ sql: `
961
+ COALESCE(
962
+ (
963
+ SELECT jsonb_object_agg(projected.key, projected.value)
964
+ FROM jsonb_each(input.properties) AS projected(key, value)
965
+ WHERE projected.key IN (${properties.map(() => "?::text").join(", ")})
966
+ ),
967
+ '{}'::jsonb
968
+ )
969
+ `,
970
+ args: [...properties],
971
+ }
972
+ }
973
+
974
+ function compilePredicate(predicate: ObjectQueryPredicate): CompiledPredicate {
975
+ switch (predicate.op) {
976
+ case "and":
977
+ case "or": {
978
+ const items = predicate.items.map(compilePredicate)
979
+ const joiner = predicate.op === "and" ? " AND " : " OR "
980
+ return {
981
+ sql: `(${items.map((item) => item.sql).join(joiner)})`,
982
+ args: items.flatMap((item) => item.args),
983
+ }
984
+ }
985
+ case "not": {
986
+ const item = compilePredicate(predicate.item)
987
+ return negatePredicate(item)
988
+ }
989
+ case "eq":
990
+ return predicate.scalarKind === "decimal"
991
+ ? compileDecimalEqualityPredicate(predicate.propertyId, "eq", predicate.value)
992
+ : compileEqualityPredicate(predicate.propertyId, "eq", predicate.value)
993
+ case "neq":
994
+ return predicate.scalarKind === "decimal"
995
+ ? compileDecimalEqualityPredicate(predicate.propertyId, "neq", predicate.value)
996
+ : compileEqualityPredicate(predicate.propertyId, "neq", predicate.value)
997
+ case "lt":
998
+ case "lte":
999
+ case "gt":
1000
+ case "gte": {
1001
+ const op = sqlComparisonOperator(predicate.op)
1002
+ if (predicate.scalarKind === "decimal") {
1003
+ return {
1004
+ sql: `(${jsonTypeExpression()} = 'string' AND (${jsonTextExpression()})::numeric ${op} ?::numeric)`,
1005
+ args: [predicate.propertyId, predicate.propertyId, predicate.value],
1006
+ }
1007
+ }
1008
+ return {
1009
+ sql: `${jsonValueExpression()} ${op} ?::text::jsonb`,
1010
+ args: [predicate.propertyId, jsonbValue(predicate.value)],
1011
+ }
1012
+ }
1013
+ case "in":
1014
+ return predicate.scalarKind === "decimal"
1015
+ ? compileDecimalInPredicate(predicate.propertyId, predicate.values)
1016
+ : compileInPredicate(predicate.propertyId, predicate.values)
1017
+ case "exists": {
1018
+ const sql = `jsonb_exists(properties, ?::text)`
1019
+ const item: CompiledPredicate = { sql, args: [predicate.propertyId] }
1020
+ return predicate.value ? item : negatePredicate(item)
1021
+ }
1022
+ case "contains":
1023
+ return compileContainsPredicate(predicate.propertyId, predicate.value)
1024
+ }
1025
+ }
1026
+
1027
+ function compileDecimalEqualityPredicate(
1028
+ propertyId: string,
1029
+ op: "eq" | "neq",
1030
+ value: unknown
1031
+ ): CompiledPredicate {
1032
+ if (value === null) {
1033
+ const sql = `${jsonTypeExpression()} = 'null'`
1034
+ const item: CompiledPredicate = { sql, args: [propertyId] }
1035
+ return op === "eq" ? item : negatePredicate(item)
1036
+ }
1037
+
1038
+ if (op === "eq") {
1039
+ return {
1040
+ sql: `(${jsonTypeExpression()} = 'string' AND (${jsonTextExpression()})::numeric = ?::numeric)`,
1041
+ args: [propertyId, propertyId, value],
1042
+ }
1043
+ }
1044
+
1045
+ return {
1046
+ sql: `(${jsonTypeExpression()} IS NULL OR ${jsonTypeExpression()} = 'null' OR ${jsonTypeExpression()} <> 'string' OR (${jsonTextExpression()})::numeric <> ?::numeric)`,
1047
+ args: [propertyId, propertyId, propertyId, propertyId, value],
1048
+ }
1049
+ }
1050
+
1051
+ function compileDecimalInPredicate(
1052
+ propertyId: string,
1053
+ values: readonly unknown[]
1054
+ ): CompiledPredicate {
1055
+ if (values.length === 0) return { sql: "0 = 1", args: [] }
1056
+ const nonNullValues = values.filter((value) => value !== null)
1057
+ const clauses: string[] = []
1058
+ const args: unknown[] = []
1059
+
1060
+ if (nonNullValues.length !== values.length) {
1061
+ clauses.push(`${jsonTypeExpression()} = 'null'`)
1062
+ args.push(propertyId)
1063
+ }
1064
+
1065
+ if (nonNullValues.length > 0) {
1066
+ clauses.push(
1067
+ `(${jsonTypeExpression()} = 'string' AND (${jsonTextExpression()})::numeric IN (${nonNullValues
1068
+ .map(() => "?::numeric")
1069
+ .join(", ")}))`
1070
+ )
1071
+ args.push(propertyId, propertyId, ...nonNullValues)
1072
+ }
1073
+
1074
+ return {
1075
+ sql: `(${clauses.join(" OR ")})`,
1076
+ args,
1077
+ }
1078
+ }
1079
+
1080
+ function negatePredicate(predicate: CompiledPredicate): CompiledPredicate {
1081
+ return {
1082
+ sql: `(NOT COALESCE((${predicate.sql}), false))`,
1083
+ args: predicate.args,
1084
+ }
1085
+ }
1086
+
1087
+ function compileEqualityPredicate(
1088
+ propertyId: string,
1089
+ op: "eq" | "neq",
1090
+ value: unknown
1091
+ ): CompiledPredicate {
1092
+ if (value === null) {
1093
+ const sql = `${jsonTypeExpression()} = 'null'`
1094
+ const item: CompiledPredicate = { sql, args: [propertyId] }
1095
+ return op === "eq" ? item : negatePredicate(item)
1096
+ }
1097
+
1098
+ if (typeof value === "string") {
1099
+ if (op === "eq") {
1100
+ return {
1101
+ sql: `(${jsonTypeExpression()} = 'string' AND ${jsonTextExpression()} = ?::text)`,
1102
+ args: [propertyId, propertyId, value],
1103
+ }
1104
+ }
1105
+
1106
+ return {
1107
+ sql: `(${jsonTypeExpression()} IS NULL OR ${jsonTypeExpression()} = 'null' OR ${jsonTypeExpression()} <> 'string' OR ${jsonTextExpression()} <> ?::text)`,
1108
+ args: [propertyId, propertyId, propertyId, propertyId, value],
1109
+ }
1110
+ }
1111
+
1112
+ if (op === "eq") {
1113
+ return {
1114
+ sql: `${jsonValueExpression()} = ?::text::jsonb`,
1115
+ args: [propertyId, jsonbValue(value)],
1116
+ }
1117
+ }
1118
+
1119
+ return {
1120
+ sql: `(${jsonTypeExpression()} IS NULL OR ${jsonTypeExpression()} = 'null' OR ${jsonValueExpression()} <> ?::text::jsonb)`,
1121
+ args: [propertyId, propertyId, propertyId, jsonbValue(value)],
1122
+ }
1123
+ }
1124
+
1125
+ function compileInPredicate(propertyId: string, values: readonly unknown[]): CompiledPredicate {
1126
+ if (values.length === 0) return { sql: "0 = 1", args: [] }
1127
+
1128
+ const nonNullValues = values.filter((value) => value !== null)
1129
+ const stringValues = nonNullValues.filter((value): value is string => typeof value === "string")
1130
+ const jsonbValues = nonNullValues.filter((value) => typeof value !== "string")
1131
+ const clauses: string[] = []
1132
+ const args: unknown[] = []
1133
+
1134
+ if (values.length !== nonNullValues.length) {
1135
+ clauses.push(`${jsonTypeExpression()} = 'null'`)
1136
+ args.push(propertyId)
1137
+ }
1138
+
1139
+ if (stringValues.length > 0) {
1140
+ clauses.push(
1141
+ `(${jsonTypeExpression()} = 'string' AND ${jsonTextExpression()} IN (${stringValues
1142
+ .map(() => "?::text")
1143
+ .join(", ")}))`
1144
+ )
1145
+ args.push(propertyId, propertyId, ...stringValues)
1146
+ }
1147
+
1148
+ if (jsonbValues.length > 0) {
1149
+ clauses.push(
1150
+ `${jsonValueExpression()} IN (${jsonbValues.map(() => "?::text::jsonb").join(", ")})`
1151
+ )
1152
+ args.push(propertyId, ...jsonbValues.map(jsonbValue))
1153
+ }
1154
+
1155
+ return {
1156
+ sql: `(${clauses.join(" OR ")})`,
1157
+ args,
1158
+ }
1159
+ }
1160
+
1161
+ function compileContainsPredicate(propertyId: string, value: unknown): CompiledPredicate {
1162
+ const clauses: string[] = []
1163
+ const args: unknown[] = []
1164
+
1165
+ if (typeof value === "string") {
1166
+ clauses.push(
1167
+ `(${jsonTypeExpression()} = 'string' AND position(?::text in ${jsonTextExpression()}) > 0)`
1168
+ )
1169
+ args.push(propertyId, value, propertyId)
1170
+ }
1171
+
1172
+ clauses.push(
1173
+ `(${jsonTypeExpression()} = 'array' AND ${jsonValueExpression()} @> jsonb_build_array(?::text::jsonb))`
1174
+ )
1175
+ args.push(propertyId, propertyId, jsonbValue(value))
1176
+
1177
+ if (typeof value === "string") {
1178
+ clauses.push(
1179
+ `(${jsonTypeExpression()} = 'object' AND jsonb_exists(${jsonValueExpression()}, ?::text))`
1180
+ )
1181
+ args.push(propertyId, propertyId, value)
1182
+ }
1183
+
1184
+ return { sql: `(${clauses.join(" OR ")})`, args }
1185
+ }
1186
+
1187
+ function compileTextPredicate(query: string, fields: readonly string[]): CompiledPredicate {
1188
+ const terms = tokenize(query)
1189
+ if (terms.length === 0) return { sql: "0 = 1", args: [] }
1190
+
1191
+ const clauses = terms.map(() => {
1192
+ const fieldClauses = fields.map(
1193
+ () => `position(?::text in lower(coalesce(${jsonTextExpression()}, ''))) > 0`
1194
+ )
1195
+ return `(${fieldClauses.join(" OR ")})`
1196
+ })
1197
+ const args = terms.flatMap((term) => fields.flatMap((field) => [term, field]))
1198
+
1199
+ return { sql: `(${clauses.join(" AND ")})`, args }
1200
+ }
1201
+
1202
+ function compileTextSearchPredicate(
1203
+ query: string,
1204
+ fields: readonly string[] | undefined,
1205
+ fieldsByObjectType: Readonly<Record<string, readonly string[]>> | undefined
1206
+ ): CompiledPredicate {
1207
+ const predicate =
1208
+ fields && fields.length > 0
1209
+ ? compileTextPredicate(query, fields)
1210
+ : compileScopedTextPredicate(query, fieldsByObjectType)
1211
+
1212
+ if (!predicate) {
1213
+ throw new Error(
1214
+ "[SixbPg] PostgreSQL object text search requires fields or resolved text defaults"
1215
+ )
1216
+ }
1217
+
1218
+ return predicate
1219
+ }
1220
+
1221
+ function compileScopedTextPredicate(
1222
+ query: string,
1223
+ fieldsByObjectType: Readonly<Record<string, readonly string[]>> | undefined
1224
+ ): CompiledPredicate | null {
1225
+ const scopedPredicates: CompiledPredicate[] = []
1226
+
1227
+ for (const [objectTypeId, fields] of Object.entries(fieldsByObjectType ?? {})) {
1228
+ if (fields.length === 0) continue
1229
+ const predicate = compileTextPredicate(query, fields)
1230
+ scopedPredicates.push({
1231
+ sql: `(object_type_id = ? AND ${predicate.sql})`,
1232
+ args: [objectTypeId, ...predicate.args],
1233
+ })
1234
+ }
1235
+
1236
+ if (scopedPredicates.length === 0) return null
1237
+
1238
+ return {
1239
+ sql: `(${scopedPredicates.map((predicate) => predicate.sql).join(" OR ")})`,
1240
+ args: scopedPredicates.flatMap((predicate) => predicate.args),
1241
+ }
1242
+ }
1243
+
1244
+ function compileOrder(
1245
+ fields: readonly CompiledOrderField[],
1246
+ qualifier?: string,
1247
+ propertyColumn = "properties"
1248
+ ): CompiledOrder {
1249
+ const clauses: string[] = []
1250
+ const args: unknown[] = []
1251
+
1252
+ for (const field of fields) {
1253
+ if (field.kind === "column") {
1254
+ clauses.push(`${columnExpression(field.column, qualifier)} ${field.direction.toUpperCase()}`)
1255
+ continue
1256
+ }
1257
+
1258
+ const direction = field.direction === "desc" ? "DESC" : "ASC"
1259
+ clauses.push(
1260
+ `CASE WHEN ${jsonTypeExpression(propertyColumn)} IS NULL OR ${jsonTypeExpression(propertyColumn)} = 'null' THEN 1 ELSE 0 END ASC`,
1261
+ `${compiledPropertyValueExpression(field, propertyColumn)} ${direction}`
1262
+ )
1263
+ args.push(field.propertyId, field.propertyId, field.propertyId)
1264
+ }
1265
+
1266
+ return { sql: clauses.join(", "), args, fields, propertyColumn }
1267
+ }
1268
+
1269
+ function sortOrderFields(fields: readonly ObjectQuerySortField[]): readonly CompiledOrderField[] {
1270
+ const orderFields: CompiledOrderField[] = []
1271
+ for (const field of fields) {
1272
+ if (field.kind !== "property") {
1273
+ throw new Error("[SixbPg] PostgreSQL object storage does not support relevance sorting")
1274
+ }
1275
+
1276
+ orderFields.push({
1277
+ kind: "property",
1278
+ propertyId: field.propertyId,
1279
+ direction: field.direction === "desc" ? "desc" : "asc",
1280
+ scalarKind: field.scalarKind,
1281
+ })
1282
+ }
1283
+ orderFields.push(...identityOrderFields())
1284
+ return orderFields
1285
+ }
1286
+
1287
+ function identityOrderFields(): readonly CompiledOrderField[] {
1288
+ return [
1289
+ { kind: "column", column: "object_type_id", direction: "asc" },
1290
+ { kind: "column", column: "primary_id", direction: "asc" },
1291
+ ]
1292
+ }
1293
+
1294
+ function compileKeysetPredicate(
1295
+ fields: readonly CompiledOrderField[],
1296
+ cursor: readonly EncodedCursorValue[],
1297
+ propertyColumn = "properties"
1298
+ ): CompiledPredicate {
1299
+ const disjuncts: CompiledPredicate[] = []
1300
+
1301
+ for (let index = 0; index < fields.length; index += 1) {
1302
+ const equalityPredicates = fields
1303
+ .slice(0, index)
1304
+ .map((field, equalityIndex) =>
1305
+ compileFieldEquality(field, cursor[equalityIndex], propertyColumn)
1306
+ )
1307
+ const advancePredicate = compileFieldAfter(fields[index], cursor[index], propertyColumn)
1308
+ if (!advancePredicate) continue
1309
+
1310
+ const parts = [...equalityPredicates, advancePredicate]
1311
+ disjuncts.push({
1312
+ sql: `(${parts.map((part) => part.sql).join(" AND ")})`,
1313
+ args: parts.flatMap((part) => part.args),
1314
+ })
1315
+ }
1316
+
1317
+ if (disjuncts.length === 0) return { sql: "0 = 1", args: [] }
1318
+
1319
+ return {
1320
+ sql: `(${disjuncts.map((part) => part.sql).join(" OR ")})`,
1321
+ args: disjuncts.flatMap((part) => part.args),
1322
+ }
1323
+ }
1324
+
1325
+ function compileFieldEquality(
1326
+ field: CompiledOrderField,
1327
+ cursor: EncodedCursorValue,
1328
+ propertyColumn = "properties"
1329
+ ): CompiledPredicate {
1330
+ if (field.kind === "column") {
1331
+ return {
1332
+ sql: `${columnExpression(field.column)} = ?`,
1333
+ args: [cursor.value],
1334
+ }
1335
+ }
1336
+
1337
+ if (cursor.nullish) {
1338
+ return {
1339
+ sql: `(${jsonTypeExpression(propertyColumn)} IS NULL OR ${jsonTypeExpression(propertyColumn)} = 'null')`,
1340
+ args: [field.propertyId, field.propertyId],
1341
+ }
1342
+ }
1343
+
1344
+ return {
1345
+ sql: `(${jsonTypeExpression(propertyColumn)} IS NOT NULL AND ${jsonTypeExpression(propertyColumn)} != 'null' AND ${compiledPropertyValueExpression(field, propertyColumn)} = ${field.scalarKind === "decimal" ? "?::numeric" : "?::text::jsonb"})`,
1346
+ args: [
1347
+ field.propertyId,
1348
+ field.propertyId,
1349
+ field.propertyId,
1350
+ field.scalarKind === "decimal" ? cursor.value : jsonbValue(cursor.value),
1351
+ ],
1352
+ }
1353
+ }
1354
+
1355
+ function compileFieldAfter(
1356
+ field: CompiledOrderField,
1357
+ cursor: EncodedCursorValue,
1358
+ propertyColumn = "properties"
1359
+ ): CompiledPredicate | null {
1360
+ if (field.kind === "column") {
1361
+ return {
1362
+ sql: `${columnExpression(field.column)} ${field.direction === "desc" ? "<" : ">"} ?`,
1363
+ args: [cursor.value],
1364
+ }
1365
+ }
1366
+
1367
+ if (cursor.nullish) return null
1368
+
1369
+ const rankSql = `CASE WHEN ${jsonTypeExpression(propertyColumn)} IS NULL OR ${jsonTypeExpression(propertyColumn)} = 'null' THEN 1 ELSE 0 END`
1370
+ const valueOperator = field.direction === "desc" ? "<" : ">"
1371
+
1372
+ return {
1373
+ sql: `(${rankSql} > 0 OR (${rankSql} = 0 AND ${compiledPropertyValueExpression(field, propertyColumn)} ${valueOperator} ${field.scalarKind === "decimal" ? "?::numeric" : "?::text::jsonb"}))`,
1374
+ args: [
1375
+ field.propertyId,
1376
+ field.propertyId,
1377
+ field.propertyId,
1378
+ field.propertyId,
1379
+ field.propertyId,
1380
+ field.scalarKind === "decimal" ? cursor.value : jsonbValue(cursor.value),
1381
+ ],
1382
+ }
1383
+ }
1384
+
1385
+ function encodePageToken(row: PgObjectQueryPageRow, fields: readonly CompiledOrderField[]): string {
1386
+ const properties = objectProperties(row._cursor_properties ?? row.properties)
1387
+ const token: EncodedPageToken = {
1388
+ version: 1,
1389
+ order: fields.map(orderFieldKey),
1390
+ values: fields.map((field) => cursorValueForField(row, properties, field)),
1391
+ }
1392
+ return `${PAGE_TOKEN_PREFIX}${Buffer.from(JSON.stringify(token)).toString("base64url")}`
1393
+ }
1394
+
1395
+ function decodePageToken(
1396
+ token: string,
1397
+ fields: readonly CompiledOrderField[]
1398
+ ): readonly EncodedCursorValue[] {
1399
+ if (!token.startsWith(PAGE_TOKEN_PREFIX)) {
1400
+ throwInvalidPageToken("Invalid PostgreSQL object query page token")
1401
+ }
1402
+
1403
+ const raw = token.slice(PAGE_TOKEN_PREFIX.length)
1404
+ let decoded: unknown
1405
+ try {
1406
+ decoded = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"))
1407
+ } catch {
1408
+ throwInvalidPageToken("Invalid PostgreSQL object query page token")
1409
+ }
1410
+
1411
+ if (!isEncodedPageToken(decoded)) {
1412
+ throwInvalidPageToken("Invalid PostgreSQL object query page token")
1413
+ }
1414
+
1415
+ const expectedOrder = fields.map(orderFieldKey)
1416
+ if (
1417
+ decoded.order.length !== expectedOrder.length ||
1418
+ decoded.order.some((field, index) => field !== expectedOrder[index])
1419
+ ) {
1420
+ throwInvalidPageToken("PostgreSQL object query page token does not match query order")
1421
+ }
1422
+
1423
+ return decoded.values
1424
+ }
1425
+
1426
+ function throwInvalidPageToken(message: string): never {
1427
+ throw new ObjectQueryExecutionError("invalid_page_token", message, "$.pageToken")
1428
+ }
1429
+
1430
+ function isEncodedPageToken(value: unknown): value is EncodedPageToken {
1431
+ if (!isPlainObject(value)) return false
1432
+ if (value.version !== 1) return false
1433
+ if (!Array.isArray(value.order) || value.order.some((item) => typeof item !== "string")) {
1434
+ return false
1435
+ }
1436
+ if (!Array.isArray(value.values)) return false
1437
+ return value.values.every(
1438
+ (item) =>
1439
+ isPlainObject(item) &&
1440
+ typeof item.nullish === "boolean" &&
1441
+ (item.nullish || Object.hasOwn(item, "value"))
1442
+ )
1443
+ }
1444
+
1445
+ function cursorValueForField(
1446
+ row: PgObjectQueryPageRow,
1447
+ properties: Record<string, unknown>,
1448
+ field: CompiledOrderField
1449
+ ): EncodedCursorValue {
1450
+ const value =
1451
+ field.kind === "column"
1452
+ ? field.column === "object_type_id"
1453
+ ? row.object_type_id
1454
+ : row.primary_id
1455
+ : properties[field.propertyId]
1456
+
1457
+ return value === null || value === undefined ? { nullish: true } : { nullish: false, value }
1458
+ }
1459
+
1460
+ function orderFieldKey(field: CompiledOrderField): string {
1461
+ return field.kind === "column"
1462
+ ? `column:${field.column}:${field.direction}`
1463
+ : `property:${field.propertyId}:${field.scalarKind ?? "json"}:${field.direction}`
1464
+ }
1465
+
1466
+ function compiledPropertyValueExpression(
1467
+ field: Extract<CompiledOrderField, { kind: "property" }>,
1468
+ propertyColumn = "properties"
1469
+ ): string {
1470
+ return field.scalarKind === "decimal"
1471
+ ? `(${jsonTextExpression(propertyColumn)})::numeric`
1472
+ : jsonValueExpression(propertyColumn)
1473
+ }
1474
+
1475
+ function columnExpression(column: "object_type_id" | "primary_id", qualifier?: string): string {
1476
+ return qualifier ? `${qualifier}.${column}` : column
1477
+ }
1478
+
1479
+ function jsonValueExpression(column = "properties"): string {
1480
+ return `${column} -> (?::text)`
1481
+ }
1482
+
1483
+ function jsonTextExpression(column = "properties"): string {
1484
+ return `${column} ->> (?::text)`
1485
+ }
1486
+
1487
+ function jsonTypeExpression(column = "properties"): string {
1488
+ return `jsonb_typeof(${jsonValueExpression(column)})`
1489
+ }
1490
+
1491
+ function jsonbValue(value: unknown): string {
1492
+ if (value instanceof Date) return JSON.stringify(value.toISOString())
1493
+ const json = JSON.stringify(value)
1494
+ return json === undefined ? "null" : json
1495
+ }
1496
+
1497
+ function tokenize(query: string): string[] {
1498
+ return query
1499
+ .trim()
1500
+ .toLowerCase()
1501
+ .split(/\s+/)
1502
+ .filter((term) => term.length > 0)
1503
+ }
1504
+
1505
+ function sqlComparisonOperator(op: "lt" | "lte" | "gt" | "gte"): "<" | "<=" | ">" | ">=" {
1506
+ switch (op) {
1507
+ case "lt":
1508
+ return "<"
1509
+ case "lte":
1510
+ return "<="
1511
+ case "gt":
1512
+ return ">"
1513
+ case "gte":
1514
+ return ">="
1515
+ }
1516
+ }
1517
+
1518
+ function numberPlaceholders(sql: string): string {
1519
+ let index = 0
1520
+ return sql.replace(/\?/g, () => `$${++index}`)
1521
+ }
1522
+
1523
+ function numberCompiledQuery<T extends { sql: string; args: unknown[] }>(compiled: T): T {
1524
+ return {
1525
+ ...compiled,
1526
+ sql: numberPlaceholders(compiled.sql),
1527
+ }
1528
+ }
1529
+
1530
+ function objectProperties(value: unknown): Record<string, unknown> {
1531
+ if (typeof value === "string") {
1532
+ const parsed = JSON.parse(value) as unknown
1533
+ return isPlainObject(parsed) ? parsed : {}
1534
+ }
1535
+
1536
+ return isPlainObject(value) ? value : {}
1537
+ }
1538
+
1539
+ function identityRows<T>(rows: readonly T[]): readonly T[] {
1540
+ return rows
1541
+ }
1542
+
1543
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
1544
+ return typeof value === "object" && value !== null && !Array.isArray(value)
1545
+ }