@sixb/pg 0.1.16 → 0.1.17

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 (92) hide show
  1. package/README.md +125 -0
  2. package/dist/index.d.ts +15 -2
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +3201 -1473
  5. package/dist/index.js.map +32 -25
  6. package/dist/migrations.d.ts.map +1 -1
  7. package/dist/objects/aggregate-admission.d.ts +10 -0
  8. package/dist/objects/aggregate-admission.d.ts.map +1 -0
  9. package/dist/objects/preparation-lock.d.ts +6 -0
  10. package/dist/objects/preparation-lock.d.ts.map +1 -0
  11. package/dist/objects/prepare-queries.d.ts +5 -0
  12. package/dist/objects/prepare-queries.d.ts.map +1 -0
  13. package/dist/objects/query-compiler.d.ts +19 -0
  14. package/dist/objects/query-compiler.d.ts.map +1 -1
  15. package/dist/objects/query-indexes.d.ts +43 -0
  16. package/dist/objects/query-indexes.d.ts.map +1 -0
  17. package/dist/objects/reader.d.ts +7 -1
  18. package/dist/objects/reader.d.ts.map +1 -1
  19. package/dist/objects/storage.d.ts +1 -1
  20. package/dist/objects/storage.d.ts.map +1 -1
  21. package/dist/objects/text-count-index.d.ts +24 -0
  22. package/dist/objects/text-count-index.d.ts.map +1 -0
  23. package/dist/ontology-storage/index.d.ts +2 -0
  24. package/dist/ontology-storage/index.d.ts.map +1 -1
  25. package/dist/ontology-storage/materialization-session.d.ts +18 -38
  26. package/dist/ontology-storage/materialization-session.d.ts.map +1 -1
  27. package/dist/ontology-storage/materialization-state.d.ts +3 -26
  28. package/dist/ontology-storage/materialization-state.d.ts.map +1 -1
  29. package/dist/ontology-storage/materialization-writer.d.ts +51 -9
  30. package/dist/ontology-storage/materialization-writer.d.ts.map +1 -1
  31. package/dist/ontology-storage/materializations.d.ts +5 -7
  32. package/dist/ontology-storage/materializations.d.ts.map +1 -1
  33. package/dist/ontology-storage/outbox.d.ts.map +1 -1
  34. package/dist/ontology-storage/replacement-plans.d.ts +36 -0
  35. package/dist/ontology-storage/replacement-plans.d.ts.map +1 -0
  36. package/dist/ontology-storage/shared.d.ts +11 -5
  37. package/dist/ontology-storage/shared.d.ts.map +1 -1
  38. package/dist/ontology-storage/source-cleanup.d.ts.map +1 -1
  39. package/dist/ontology-storage/source-roots.d.ts +14 -1
  40. package/dist/ontology-storage/source-roots.d.ts.map +1 -1
  41. package/dist/ontology-storage/sources.d.ts +1 -2
  42. package/dist/ontology-storage/sources.d.ts.map +1 -1
  43. package/dist/pg-ai-cost-storage.d.ts.map +1 -1
  44. package/dist/pg-ai-limit-storage.d.ts.map +1 -1
  45. package/dist/pg-ai-usage-storage.d.ts +2 -0
  46. package/dist/pg-ai-usage-storage.d.ts.map +1 -1
  47. package/dist/pg-execution-storage.d.ts +28 -0
  48. package/dist/pg-execution-storage.d.ts.map +1 -1
  49. package/dist/pg-file-download-grant-storage.d.ts +11 -0
  50. package/dist/pg-file-download-grant-storage.d.ts.map +1 -0
  51. package/dist/pg-projection-run-storage.d.ts.map +1 -1
  52. package/dist/pg-rules-storage.d.ts.map +1 -1
  53. package/dist/transactions.d.ts +6 -2
  54. package/dist/transactions.d.ts.map +1 -1
  55. package/package.json +3 -3
  56. package/src/index.ts +53 -3
  57. package/src/migrations/053-replacement-plans.sql +72 -0
  58. package/src/migrations/054-slim-ontology-outbox.sql +108 -0
  59. package/src/migrations/055-object-query-preparation.sql +11 -0
  60. package/src/migrations/056-commit-touches.sql +41 -0
  61. package/src/migrations/057-audio-transcription.sql +10 -0
  62. package/src/migrations/058-reranking-model-kind.sql +11 -0
  63. package/src/migrations/059-file-download-grants.sql +16 -0
  64. package/src/migrations/060-drained-source-versions.sql +11 -0
  65. package/src/migrations.ts +20 -0
  66. package/src/objects/aggregate-admission.ts +21 -0
  67. package/src/objects/preparation-lock.ts +15 -0
  68. package/src/objects/prepare-queries.ts +181 -0
  69. package/src/objects/query-compiler.ts +404 -114
  70. package/src/objects/query-indexes.ts +348 -0
  71. package/src/objects/reader.ts +140 -51
  72. package/src/objects/storage.ts +15 -3
  73. package/src/objects/text-count-index.ts +455 -0
  74. package/src/ontology-storage/index.ts +3 -0
  75. package/src/ontology-storage/materialization-session.ts +116 -275
  76. package/src/ontology-storage/materialization-state.ts +8 -259
  77. package/src/ontology-storage/materialization-writer.ts +618 -508
  78. package/src/ontology-storage/materializations.ts +106 -289
  79. package/src/ontology-storage/outbox.ts +46 -15
  80. package/src/ontology-storage/replacement-plans.ts +1110 -0
  81. package/src/ontology-storage/shared.ts +27 -15
  82. package/src/ontology-storage/source-cleanup.ts +22 -10
  83. package/src/ontology-storage/source-roots.ts +35 -8
  84. package/src/ontology-storage/sources.ts +0 -30
  85. package/src/pg-ai-cost-storage.ts +12 -0
  86. package/src/pg-ai-limit-storage.ts +13 -2
  87. package/src/pg-ai-usage-storage.ts +8 -0
  88. package/src/pg-execution-storage.ts +8 -3
  89. package/src/pg-file-download-grant-storage.ts +76 -0
  90. package/src/pg-projection-run-storage.ts +6 -1
  91. package/src/pg-rules-storage.ts +7 -3
  92. package/src/transactions.ts +10 -3
@@ -0,0 +1,348 @@
1
+ import { createHash } from "node:crypto"
2
+ import type { ObjectQuery, QueryScalarKind } from "@sixb/core"
3
+ import type { ReservedSQL, SQL, SQLClient } from "../pg-client"
4
+ import { isConnectionLost } from "../storage-errors"
5
+ import { withReservedPgConnection } from "../transactions"
6
+ import { lockPgPreparation } from "./preparation-lock"
7
+
8
+ export interface PgObjectQueryIndexScope {
9
+ readonly projectId: string
10
+ readonly objectTypeId: string
11
+ /** Optional equality conditions. Partial indexes also accelerate exact counts of these views. */
12
+ readonly where?: Readonly<Record<string, string | number | boolean | null>>
13
+ }
14
+
15
+ export type PgObjectQueryIndex = PgObjectQueryIndexScope &
16
+ (
17
+ | {
18
+ readonly kind: "sort"
19
+ /** Leading string equality filters, in query order. */
20
+ readonly equality?: readonly string[]
21
+ readonly fields: readonly {
22
+ readonly propertyId: string
23
+ readonly direction?: "asc" | "desc"
24
+ readonly scalarKind?: QueryScalarKind
25
+ }[]
26
+ }
27
+ | { readonly kind: "incomingLinks"; readonly where?: never }
28
+ | { readonly kind: "text"; readonly propertyId: string }
29
+ | { readonly kind: "value"; readonly propertyId: string }
30
+ | { readonly kind: "count" }
31
+ | { readonly kind: "filter"; readonly properties: readonly string[] }
32
+ )
33
+
34
+ /** Explicit maintenance operation: never creates indexes in the request path or a transaction. */
35
+ export async function ensurePgObjectQueryIndexes(
36
+ sql: SQL | ReservedSQL,
37
+ schema: string,
38
+ indexes: readonly PgObjectQueryIndex[]
39
+ ): Promise<readonly string[]> {
40
+ const definitions = indexes.map((index) => ({
41
+ table: index.kind === "incomingLinks" ? "links" : "objects",
42
+ expression: compileIndex(index),
43
+ index,
44
+ }))
45
+ if (indexes.some((index) => index.kind === "text")) {
46
+ const [extension] = await sql<{ ready: boolean }[]>`
47
+ SELECT EXISTS (
48
+ SELECT 1 FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace
49
+ WHERE e.extname = 'pg_trgm' AND n.nspname = 'public'
50
+ ) AS ready`
51
+ if (!extension?.ready) {
52
+ throw new Error(
53
+ "[SixbPg] Text query indexes require pg_trgm in schema public. Run CREATE EXTENSION pg_trgm WITH SCHEMA public before ensuring indexes."
54
+ )
55
+ }
56
+ }
57
+
58
+ const names: string[] = []
59
+ await withReservedPgConnection(sql, async (connection) => {
60
+ let connected = true
61
+ try {
62
+ // One concurrent build per table, across definitions and across processes.
63
+ await lockPgPreparation(connection, `${schema}.objects.query-indexes`)
64
+ for (const definition of definitions) {
65
+ names.push(await createQueryIndex(connection, schema, definition))
66
+ }
67
+
68
+ await ensureExpressionStatistics(connection, schema, indexes)
69
+ } catch (error) {
70
+ connected = !isConnectionLost(error)
71
+ throw error
72
+ } finally {
73
+ if (connected)
74
+ await connection`SELECT pg_advisory_unlock(hashtextextended(${`${schema}.objects.query-indexes`}, 0))`
75
+ }
76
+ })
77
+ return names
78
+ }
79
+
80
+ async function createQueryIndex(
81
+ connection: ReservedSQL,
82
+ schema: string,
83
+ definition: { table: string; expression: string; index: PgObjectQueryIndex }
84
+ ): Promise<string> {
85
+ const name = `sixb_query_${createHash("sha256")
86
+ .update(
87
+ definition.table === "objects"
88
+ ? definition.expression
89
+ : `${definition.table}:${definition.expression}`
90
+ )
91
+ .digest("hex")
92
+ .slice(0, 32)}`
93
+ await connection.unsafe(
94
+ `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${identifier(name)} ON ${identifier(schema)}.${identifier(definition.table)} ${definition.expression}`
95
+ )
96
+ const [index] = await connection<{ valid: boolean }[]>`
97
+ SELECT i.indisvalid AS valid FROM pg_index i
98
+ JOIN pg_class c ON c.oid = i.indexrelid
99
+ JOIN pg_namespace n ON n.oid = c.relnamespace
100
+ WHERE n.nspname = ${schema} AND c.relname = ${name}`
101
+ if (!index?.valid)
102
+ throw new Error(
103
+ `[SixbPg] Query index ${schema}.${name} is invalid after an interrupted build. Drop that index concurrently and retry ensureObjectQueryIndexes().`
104
+ )
105
+ if (definition.index.kind === "sort") {
106
+ await connection.unsafe(
107
+ `COMMENT ON INDEX ${identifier(schema)}.${identifier(name)} IS ${literal(JSON.stringify(definition.index))}`
108
+ )
109
+ const scope = `project_id = ${literal(definition.index.projectId)} AND object_type_id = ${literal(definition.index.objectTypeId)}`
110
+ await connection.unsafe(
111
+ `CREATE INDEX CONCURRENTLY IF NOT EXISTS ${identifier(`${name}_overflow`)} ON ${identifier(schema)}.objects (primary_id) WHERE ${scope} AND NOT (${sortIndexBound(definition.index)})`
112
+ )
113
+ }
114
+ return name
115
+ }
116
+
117
+ async function ensureExpressionStatistics(
118
+ connection: ReservedSQL,
119
+ schema: string,
120
+ indexes: readonly PgObjectQueryIndex[]
121
+ ): Promise<void> {
122
+ let statisticsChanged = false
123
+ // Partial-index statistics do not describe expressions for the whole table. Without
124
+ // expression statistics JSON's default selectivity can turn a 4M-row view into an
125
+ // estimated 125-row sort, even when the right ordered index exists.
126
+ const expressions = new Set(indexes.flatMap(statisticsExpressions))
127
+ for (const expression of expressions) {
128
+ const name = `sixb_query_stats_${createHash("sha256").update(expression).digest("hex").slice(0, 24)}`
129
+ const [existing] = await connection`
130
+ SELECT 1 FROM pg_statistic_ext s JOIN pg_namespace n ON n.oid = s.stxnamespace
131
+ WHERE n.nspname = ${schema} AND s.stxname = ${name}`
132
+ if (existing) continue
133
+ await connection.unsafe(
134
+ `CREATE STATISTICS ${identifier(schema)}.${identifier(name)} ON project_id, object_type_id, ${expression} FROM ${identifier(schema)}.objects`
135
+ )
136
+ statisticsChanged = true
137
+ }
138
+ if (statisticsChanged) await connection.unsafe(`ANALYZE ${identifier(schema)}.objects`)
139
+ }
140
+
141
+ function statisticsExpressions(index: PgObjectQueryIndex): string[] {
142
+ if (index.kind === "incomingLinks") return []
143
+ const equality = new Set(Object.keys(index.where ?? {}))
144
+ if (index.kind === "filter") for (const property of index.properties) equality.add(property)
145
+ if (index.kind === "sort") for (const property of index.equality ?? []) equality.add(property)
146
+ const expressions = [...equality].map(
147
+ (property) =>
148
+ `jsonb_typeof(properties -> ${literal(property)}), (properties ->> ${literal(property)}), (md5(properties ->> ${literal(property)}))`
149
+ )
150
+ if (index.kind === "value") expressions.push(`(properties -> ${literal(index.propertyId)})`)
151
+ if (index.kind === "text")
152
+ expressions.push(`lower(coalesce(properties ->> ${literal(index.propertyId)}, ''))`)
153
+ if (index.kind === "sort")
154
+ for (const field of index.fields) {
155
+ expressions.push(
156
+ field.scalarKind === "decimal"
157
+ ? `((properties ->> ${literal(field.propertyId)})::numeric)`
158
+ : `(NULLIF(properties -> ${literal(field.propertyId)}, 'null'::jsonb))`
159
+ )
160
+ }
161
+ return expressions
162
+ }
163
+
164
+ function compileIndex(index: PgObjectQueryIndex): string {
165
+ if (index.kind === "incomingLinks") {
166
+ if (index.where)
167
+ throw new Error("[SixbPg] Incoming link indexes do not accept object property conditions")
168
+ return `(target_id, link_id, source_type_id, source_id) INCLUDE (project_id, target_type_id) WHERE project_id = ${literal(index.projectId)} AND target_type_id = ${literal(index.objectTypeId)}`
169
+ }
170
+ const predicate = [
171
+ `project_id = ${literal(index.projectId)}`,
172
+ `object_type_id = ${literal(index.objectTypeId)}`,
173
+ ]
174
+ for (const [propertyId, value] of Object.entries(index.where ?? {}).sort(([a], [b]) =>
175
+ a.localeCompare(b)
176
+ )) {
177
+ const property = `properties -> ${literal(propertyId)}`
178
+ if (value === null) predicate.push(`jsonb_typeof(${property}) = 'null'`)
179
+ else if (typeof value === "string") {
180
+ predicate.push(
181
+ `jsonb_typeof(${property}) = 'string'`,
182
+ `properties ->> ${literal(propertyId)} = ${literal(value)}`,
183
+ `md5(properties ->> ${literal(propertyId)}) = md5(${literal(value)})`
184
+ )
185
+ } else {
186
+ if (typeof value === "number" && !Number.isFinite(value)) {
187
+ throw new Error("[SixbPg] Query index conditions require finite numbers")
188
+ }
189
+ predicate.push(`${property} = ${literal(JSON.stringify(value))}::jsonb`)
190
+ }
191
+ }
192
+ if (index.kind === "text") {
193
+ return `USING gin (lower(coalesce(properties ->> ${literal(index.propertyId)}, '')) public.gin_trgm_ops) WHERE ${predicate.join(" AND ")}`
194
+ }
195
+
196
+ // The partial predicate fixes the scope. Repeating those columns as leading keys
197
+ // makes PostgreSQL scan past them when it removes their redundant conditions.
198
+ const fields: string[] = []
199
+ if (index.kind === "count") {
200
+ fields.push("project_id", "object_type_id")
201
+ } else if (index.kind === "value") {
202
+ fields.push(`(properties -> ${literal(index.propertyId)})`)
203
+ }
204
+
205
+ if (index.kind === "filter") {
206
+ if (!index.properties.length || index.properties.length > 8)
207
+ throw new Error("[SixbPg] A filter index requires one to eight string properties")
208
+ for (const propertyId of index.properties)
209
+ fields.push(`(md5(properties ->> ${literal(propertyId)}))`)
210
+ }
211
+ if (index.kind === "sort") {
212
+ predicate.push(sortIndexBound(index))
213
+ if (index.fields.length === 0 || index.fields.length + (index.equality?.length ?? 0) > 8) {
214
+ throw new Error("[SixbPg] A sort query index requires between one and eight fields")
215
+ }
216
+ for (const propertyId of index.equality ?? []) {
217
+ fields.push(`(properties ->> ${literal(propertyId)})`)
218
+ }
219
+ for (const field of index.fields) {
220
+ const property = `properties -> ${literal(field.propertyId)}`
221
+ fields.push(
222
+ `(CASE WHEN jsonb_typeof(${property}) IS NULL OR jsonb_typeof(${property}) = 'null' THEN 1 ELSE 0 END)`,
223
+ `${field.scalarKind === "decimal" ? `((properties ->> ${literal(field.propertyId)})::numeric)` : `(NULLIF(${property}, 'null'::jsonb))`} ${field.direction === "desc" ? "DESC" : "ASC"}`
224
+ )
225
+ }
226
+ fields.push("primary_id")
227
+ }
228
+ return `(${fields.join(", ")}) WHERE ${predicate.join(" AND ")}`
229
+ }
230
+
231
+ function literal(value: string): string {
232
+ if (value.includes("\0"))
233
+ throw new Error("[SixbPg] Query index names and values cannot contain NUL")
234
+ return `E'${value.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`
235
+ }
236
+
237
+ function identifier(value: string): string {
238
+ return `"${value.replace(/"/g, '""')}"`
239
+ }
240
+
241
+ /** Keep complete B-tree entries below PostgreSQL's page limit, without constraining valid data.
242
+ * Ordered reads merge a small indexed page with the exact overflow page. */
243
+ export function sortIndexBound(index: Extract<PgObjectQueryIndex, { kind: "sort" }>): string {
244
+ const sizes = [
245
+ "octet_length(primary_id)",
246
+ ...index.fields.map(
247
+ (field) => `coalesce(octet_length((properties -> ${literal(field.propertyId)})::text), 0)`
248
+ ),
249
+ ...(index.equality ?? []).map(
250
+ (property) => `coalesce(octet_length(properties ->> ${literal(property)}), 0)`
251
+ ),
252
+ ]
253
+ return `(${sizes.join(" + ")}) <= 1800`
254
+ }
255
+
256
+ /** Only split a flat, bounded ordered read; joins, nested limits and projections keep the
257
+ * general compiler. Its unrestricted fallback always preserves semantics. */
258
+ export async function findPgSortIndexBound(
259
+ sql: SQLClient,
260
+ projectId: string,
261
+ query: ObjectQuery
262
+ ): Promise<string | undefined> {
263
+ while (query.kind === "expand") query = query.input
264
+ if (query.kind !== "page" && query.kind !== "limit") return undefined
265
+ let input = query.input
266
+ let fields: Extract<ObjectQuery, { kind: "sort" }>["fields"] | undefined
267
+ const equality = new Set<string>()
268
+ while (input.kind === "sort" || input.kind === "filter" || input.kind === "text") {
269
+ if (input.kind === "sort") {
270
+ if (fields) return undefined
271
+ fields = input.fields
272
+ }
273
+ if (input.kind === "filter") {
274
+ const inspect = (predicate: Extract<ObjectQuery, { kind: "filter" }>["predicate"]): void => {
275
+ if (predicate.op === "and") predicate.items.forEach(inspect)
276
+ else if (predicate.op === "eq" && typeof predicate.value === "string")
277
+ equality.add(predicate.propertyId)
278
+ else if (
279
+ predicate.op === "in" &&
280
+ predicate.values.length === 1 &&
281
+ typeof predicate.values[0] === "string"
282
+ )
283
+ equality.add(predicate.propertyId)
284
+ }
285
+ inspect(input.predicate)
286
+ }
287
+ input = input.input
288
+ }
289
+ let objectTypeId: string | undefined
290
+ if (input.kind === "start" && !input.includeSubtypes) {
291
+ objectTypeId = input.objectTypeId
292
+ } else if (input.kind === "traverse" && input.direction === "incoming") {
293
+ objectTypeId = input.sourceObjectTypeId
294
+ }
295
+
296
+ if (!objectTypeId || !fields?.length || fields.some((field) => field.kind !== "property"))
297
+ return undefined
298
+
299
+ const rows = await sql<{ definition: string }[]>`
300
+ SELECT obj_description(c.oid, 'pg_class') AS definition
301
+ FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
302
+ WHERE i.indrelid = 'objects'::regclass AND i.indisvalid
303
+ AND c.relname LIKE 'sixb_query_%'
304
+ AND obj_description(c.oid, 'pg_class') IS NOT NULL
305
+ `
306
+ const matches: Extract<PgObjectQueryIndex, { kind: "sort" }>[] = []
307
+ for (const row of rows) {
308
+ let candidate: unknown
309
+ try {
310
+ candidate = JSON.parse(row.definition)
311
+ } catch {
312
+ continue
313
+ }
314
+ if (!candidate || typeof candidate !== "object") continue
315
+ // Metadata is generated by this module. Verify all SQL-interpolated members before using it.
316
+ const index = candidate as Partial<Extract<PgObjectQueryIndex, { kind: "sort" }>>
317
+ if (
318
+ index.kind !== "sort" ||
319
+ index.projectId !== projectId ||
320
+ index.objectTypeId !== objectTypeId ||
321
+ index.where ||
322
+ !Array.isArray(index.fields) ||
323
+ index.fields.length !== fields.length
324
+ )
325
+ continue
326
+ if (
327
+ index.equality !== undefined &&
328
+ (!Array.isArray(index.equality) ||
329
+ !index.equality.every((property) => typeof property === "string" && equality.has(property)))
330
+ )
331
+ continue
332
+ if (
333
+ !index.fields.every(
334
+ (field, i) =>
335
+ field &&
336
+ typeof field.propertyId === "string" &&
337
+ fields[i]?.kind === "property" &&
338
+ fields[i].propertyId === field.propertyId &&
339
+ (field.direction ?? "asc") === (fields[i].direction ?? "asc") &&
340
+ (field.scalarKind === "decimal") === (fields[i].scalarKind === "decimal")
341
+ )
342
+ )
343
+ continue
344
+ matches.push(index as Extract<PgObjectQueryIndex, { kind: "sort" }>)
345
+ }
346
+ matches.sort((a, b) => (b.equality?.length ?? 0) - (a.equality?.length ?? 0))
347
+ return matches[0] ? sortIndexBound(matches[0]) : undefined
348
+ }
@@ -10,7 +10,6 @@ import type {
10
10
  LinkBatchKey,
11
11
  LinkDirection,
12
12
  ObjectBatchKey,
13
- ObjectFacetResult,
14
13
  ObjectLinkRow,
15
14
  ObjectReadStorage,
16
15
  ObjectRow,
@@ -21,17 +20,19 @@ import type {
21
20
  } from "@sixb/core/storage"
22
21
  import { linkBatchKey, objectBatchKey } from "@sixb/core/storage"
23
22
  import type { SQLClient, SqlParameter } from "../pg-client"
23
+ import type { PgAggregateAdmission } from "./aggregate-admission"
24
24
  import {
25
25
  type CompiledPgObjectQuery,
26
26
  compilePgObjectCountQuery,
27
27
  compilePgObjectExistsQuery,
28
- compilePgObjectFacetQuery,
28
+ compilePgObjectFacetsQuery,
29
29
  compilePgObjectQuery,
30
30
  compilePgObjectStatement,
31
+ DEFAULT_OBJECT_QUERY_SOURCE,
31
32
  type PgObjectQuerySource,
32
33
  } from "./query-compiler"
34
+ import { findPgSortIndexBound } from "./query-indexes"
33
35
  import type { PgSelectedObjectReadSource } from "./read-scope"
34
-
35
36
  import {
36
37
  type FacetDatabaseRow,
37
38
  type LinkBatchDatabaseRow,
@@ -45,11 +46,14 @@ import {
45
46
  rowToLink,
46
47
  rowToObject,
47
48
  } from "./rows"
49
+ import { compilePgIndexedTextCount, compilePgIndexedTextFacets } from "./text-count-index"
48
50
 
51
+ /** Execute object and link reads, using prepared access paths when the query shape allows it. */
49
52
  export class PgObjectReader {
50
53
  constructor(
51
54
  private readonly sql: SQLClient,
52
- private readonly source: PgObjectQuerySource | PgSelectedObjectReadSource
55
+ private readonly source: PgObjectQuerySource | PgSelectedObjectReadSource,
56
+ private readonly aggregateAdmission?: PgAggregateAdmission
53
57
  ) {}
54
58
 
55
59
  private async assertVectorSearch(
@@ -82,28 +86,20 @@ export class PgObjectReader {
82
86
  async queryObjects(params: QueryObjectsInput): Promise<QueryObjectsResult> {
83
87
  await this.assertVectorSearch(params)
84
88
  const sql = this.sql
85
- const source = this.source
86
- const compiled = compilePgObjectQuery(params.projectId, params.query, {
87
- includeTotal: params.includeTotal,
88
- source,
89
- })
90
- const total = params.includeTotal === false ? undefined : await readTotal(sql, compiled)
89
+ const compiled = await this.compilePageQuery(params)
90
+ const total = await this.readQueryTotal(params, compiled)
91
+
91
92
  const rawRows = await sql.unsafe<ObjectQueryDatabaseRow[]>(
92
93
  compiled.sql,
93
94
  compiled.args as SqlParameter[]
94
95
  )
95
96
  const rows = compiled.trimRows(rawRows) as readonly ObjectQueryDatabaseRow[]
96
- const hasMore =
97
- total === undefined && compiled.hasMoreProbe
98
- ? compiled.hasMoreProbe.hasMore(
99
- (
100
- await sql.unsafe(
101
- compiled.hasMoreProbe.sql,
102
- compiled.hasMoreProbe.args as SqlParameter[]
103
- )
104
- ).length
105
- )
106
- : compiled.hasMore(rawRows.length, total)
97
+ let hasMore = compiled.hasMore(rawRows.length, total)
98
+ if (total === undefined && compiled.hasMoreProbe) {
99
+ const probe = compiled.hasMoreProbe
100
+ const candidates = await sql.unsafe(probe.sql, probe.args as SqlParameter[])
101
+ hasMore = probe.hasMore(candidates.length)
102
+ }
107
103
 
108
104
  return {
109
105
  objects: rows.map((row) => queryRowToObject(row)),
@@ -113,13 +109,78 @@ export class PgObjectReader {
113
109
  }
114
110
  }
115
111
 
116
- async countObjects(params: CountObjectsInput): Promise<CountObjectsResult> {
117
- await this.assertVectorSearch(params)
112
+ /** Use the ordered traversal path only when its bounded probe finds a broad fanout. */
113
+ private async compilePageQuery(params: QueryObjectsInput): Promise<CompiledPgObjectQuery> {
118
114
  const sql = this.sql
119
115
  const source = this.source
120
- const compiled = compilePgObjectCountQuery(params.projectId, stripOuterRowShape(params.query), {
116
+ const sortIndexBound =
117
+ source === DEFAULT_OBJECT_QUERY_SOURCE
118
+ ? await findPgSortIndexBound(sql, params.projectId, params.query)
119
+ : undefined
120
+ let compiled = compilePgObjectQuery(params.projectId, params.query, {
121
+ sortIndexBound,
122
+ includeTotal: params.includeTotal,
121
123
  source,
122
124
  })
125
+ if (compiled.traversalProbe) {
126
+ const probe = compiled.traversalProbe
127
+ const candidates = await sql.unsafe(probe.sql, probe.args as SqlParameter[])
128
+ if (candidates.length > probe.limit) {
129
+ compiled = compilePgObjectQuery(params.projectId, params.query, {
130
+ includeTotal: params.includeTotal,
131
+ source,
132
+ correlatedTraversal: true,
133
+ sortIndexBound,
134
+ })
135
+ }
136
+ }
137
+ return compiled
138
+ }
139
+
140
+ private async readQueryTotal(
141
+ params: QueryObjectsInput,
142
+ compiled: CompiledPgObjectQuery
143
+ ): Promise<number | undefined> {
144
+ if (params.includeTotal === false) return undefined
145
+
146
+ const computeTotal = async () => {
147
+ if (this.source === DEFAULT_OBJECT_QUERY_SOURCE) {
148
+ const indexed = await compilePgIndexedTextCount(
149
+ this.sql,
150
+ params.projectId,
151
+ totalCountInput(params.query)
152
+ )
153
+ if (indexed) {
154
+ const [row] = await this.sql.unsafe(indexed.sql, indexed.args as SqlParameter[])
155
+ return Number(row?.count ?? 0)
156
+ }
157
+ }
158
+ return readTotal(this.sql, compiled)
159
+ }
160
+
161
+ if (this.aggregateAdmission) return this.aggregateAdmission.run(computeTotal)
162
+ return computeTotal()
163
+ }
164
+
165
+ async countObjects(params: CountObjectsInput): Promise<CountObjectsResult> {
166
+ if (this.aggregateAdmission) {
167
+ return this.aggregateAdmission.run(() =>
168
+ new PgObjectReader(this.sql, this.source).countObjects(params)
169
+ )
170
+ }
171
+
172
+ await this.assertVectorSearch(params)
173
+ const sql = this.sql
174
+ const source = this.source
175
+ const indexed =
176
+ source === DEFAULT_OBJECT_QUERY_SOURCE
177
+ ? await compilePgIndexedTextCount(sql, params.projectId, stripOuterRowShape(params.query))
178
+ : undefined
179
+ const compiled =
180
+ indexed ??
181
+ compilePgObjectCountQuery(params.projectId, stripOuterRowShape(params.query), {
182
+ source,
183
+ })
123
184
  const [row] = await sql.unsafe<{ count: string | number | bigint }[]>(
124
185
  compiled.sql,
125
186
  compiled.args as SqlParameter[]
@@ -141,26 +202,53 @@ export class PgObjectReader {
141
202
  }
142
203
 
143
204
  async facetObjects(params: FacetObjectsInput): Promise<FacetObjectsResult> {
205
+ if (this.aggregateAdmission) {
206
+ return this.aggregateAdmission.run(() =>
207
+ new PgObjectReader(this.sql, this.source).facetObjects(params)
208
+ )
209
+ }
210
+
144
211
  await this.assertVectorSearch(params)
145
212
  const sql = this.sql
146
213
  const source = this.source
147
- const facets: ObjectFacetResult[] = []
148
- for (const facet of params.facets) {
149
- facets.push({
150
- propertyId: facet.propertyId,
151
- buckets: await readFacetBuckets(
152
- sql,
153
- compilePgObjectFacetQuery(
214
+ const indexed =
215
+ source === DEFAULT_OBJECT_QUERY_SOURCE
216
+ ? await compilePgIndexedTextFacets(
217
+ sql,
154
218
  params.projectId,
155
219
  stripOuterRowShape(params.query),
156
- facet.propertyId,
157
- facet.limit,
158
- { source }
220
+ params.facets
159
221
  )
160
- ),
161
- })
222
+ : undefined
223
+ const compiled =
224
+ indexed ??
225
+ compilePgObjectFacetsQuery(
226
+ params.projectId,
227
+ stripOuterRowShape(params.query),
228
+ params.facets,
229
+ { source }
230
+ )
231
+ const rows = await sql.unsafe<(FacetDatabaseRow & { facet_index: number })[]>(
232
+ compiled.sql,
233
+ compiled.args as SqlParameter[]
234
+ )
235
+ const facets = params.facets.map((facet) => ({
236
+ propertyId: facet.propertyId,
237
+ buckets: [] as { value: unknown; count: number }[],
238
+ }))
239
+ let total = 0
240
+ for (const row of rows) {
241
+ if (row.facet_index === -1) {
242
+ total = Number(row.count)
243
+ } else {
244
+ facets[row.facet_index]?.buckets.push({
245
+ value: pgFacetValue(row.value_type, row.value_text),
246
+ count: Number(row.count),
247
+ })
248
+ }
162
249
  }
163
- return { facets }
250
+
251
+ return { total, facets }
164
252
  }
165
253
 
166
254
  async getByPrimaryId(params: {
@@ -585,20 +673,6 @@ async function readTotal(sql: SQLClient, compiled: CompiledPgObjectQuery): Promi
585
673
  return Number(row?.total ?? 0)
586
674
  }
587
675
 
588
- async function readFacetBuckets(
589
- sql: SQLClient,
590
- compiled: { sql: string; args: readonly unknown[] }
591
- ): Promise<{ value: unknown; count: number }[]> {
592
- const rows = await sql.unsafe<FacetDatabaseRow[]>(compiled.sql, [
593
- ...compiled.args,
594
- ] as SqlParameter[])
595
-
596
- return rows.map((row) => ({
597
- value: pgFacetValue(row.value_type, row.value_text),
598
- count: Number(row.count),
599
- }))
600
- }
601
-
602
676
  function pgFacetValue(valueType: string | null, valueText: string | null): unknown {
603
677
  switch (valueType) {
604
678
  case "string":
@@ -617,6 +691,21 @@ function pgFacetValue(valueType: string | null, valueText: string | null): unkno
617
691
  }
618
692
  }
619
693
 
694
+ // A page's total counts its input, including any inner limit/page. Do not strip those bounds.
695
+ function totalCountInput(query: ObjectQuery): ObjectQuery {
696
+ switch (query.kind) {
697
+ case "sort":
698
+ case "project":
699
+ case "expand":
700
+ return totalCountInput(query.input)
701
+ case "limit":
702
+ case "page":
703
+ return query.input
704
+ default:
705
+ return query
706
+ }
707
+ }
708
+
620
709
  function stripOuterRowShape(query: ObjectQuery): ObjectQuery {
621
710
  switch (query.kind) {
622
711
  case "limit":
@@ -22,7 +22,12 @@ import type {
22
22
  QueryObjectsResult,
23
23
  } from "@sixb/core/storage"
24
24
  import type { SQLClient, SqlParameter } from "../pg-client"
25
- import { type PgStoreClient, runPgRepeatableReadTransaction } from "../transactions"
25
+ import {
26
+ canStartPgTransaction,
27
+ type PgStoreClient,
28
+ runPgRepeatableReadTransaction,
29
+ } from "../transactions"
30
+ import { PgAggregateAdmission } from "./aggregate-admission"
26
31
  import { DEFAULT_OBJECT_QUERY_SOURCE } from "./query-compiler"
27
32
  import { assertLinkQueryLimit, PgObjectReader } from "./reader"
28
33
  import {
@@ -107,8 +112,15 @@ const PG_OBJECT_QUERY_CAPABILITIES: ObjectQueryCapabilities = {
107
112
  export class PgObjectStorage implements ObjectStorage {
108
113
  private readonly reader: PgObjectReader
109
114
 
110
- constructor(private readonly sql: PgStoreClient) {
111
- this.reader = new PgObjectReader(sql, DEFAULT_OBJECT_QUERY_SOURCE)
115
+ constructor(
116
+ private readonly sql: PgStoreClient,
117
+ maxConcurrentAggregates = 1
118
+ ) {
119
+ this.reader = new PgObjectReader(
120
+ sql,
121
+ DEFAULT_OBJECT_QUERY_SOURCE,
122
+ canStartPgTransaction(sql) ? new PgAggregateAdmission(maxConcurrentAggregates) : undefined
123
+ )
112
124
  }
113
125
 
114
126
  queryCapabilities(): ObjectQueryCapabilities {