@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.
- package/README.md +125 -0
- package/dist/index.d.ts +15 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3201 -1473
- package/dist/index.js.map +32 -25
- package/dist/migrations.d.ts.map +1 -1
- package/dist/objects/aggregate-admission.d.ts +10 -0
- package/dist/objects/aggregate-admission.d.ts.map +1 -0
- package/dist/objects/preparation-lock.d.ts +6 -0
- package/dist/objects/preparation-lock.d.ts.map +1 -0
- package/dist/objects/prepare-queries.d.ts +5 -0
- package/dist/objects/prepare-queries.d.ts.map +1 -0
- package/dist/objects/query-compiler.d.ts +19 -0
- package/dist/objects/query-compiler.d.ts.map +1 -1
- package/dist/objects/query-indexes.d.ts +43 -0
- package/dist/objects/query-indexes.d.ts.map +1 -0
- package/dist/objects/reader.d.ts +7 -1
- package/dist/objects/reader.d.ts.map +1 -1
- package/dist/objects/storage.d.ts +1 -1
- package/dist/objects/storage.d.ts.map +1 -1
- package/dist/objects/text-count-index.d.ts +24 -0
- package/dist/objects/text-count-index.d.ts.map +1 -0
- package/dist/ontology-storage/index.d.ts +2 -0
- package/dist/ontology-storage/index.d.ts.map +1 -1
- package/dist/ontology-storage/materialization-session.d.ts +18 -38
- package/dist/ontology-storage/materialization-session.d.ts.map +1 -1
- package/dist/ontology-storage/materialization-state.d.ts +3 -26
- package/dist/ontology-storage/materialization-state.d.ts.map +1 -1
- package/dist/ontology-storage/materialization-writer.d.ts +51 -9
- package/dist/ontology-storage/materialization-writer.d.ts.map +1 -1
- package/dist/ontology-storage/materializations.d.ts +5 -7
- package/dist/ontology-storage/materializations.d.ts.map +1 -1
- package/dist/ontology-storage/outbox.d.ts.map +1 -1
- package/dist/ontology-storage/replacement-plans.d.ts +36 -0
- package/dist/ontology-storage/replacement-plans.d.ts.map +1 -0
- package/dist/ontology-storage/shared.d.ts +11 -5
- package/dist/ontology-storage/shared.d.ts.map +1 -1
- package/dist/ontology-storage/source-cleanup.d.ts.map +1 -1
- package/dist/ontology-storage/source-roots.d.ts +14 -1
- package/dist/ontology-storage/source-roots.d.ts.map +1 -1
- package/dist/ontology-storage/sources.d.ts +1 -2
- package/dist/ontology-storage/sources.d.ts.map +1 -1
- package/dist/pg-ai-cost-storage.d.ts.map +1 -1
- package/dist/pg-ai-limit-storage.d.ts.map +1 -1
- package/dist/pg-ai-usage-storage.d.ts +2 -0
- package/dist/pg-ai-usage-storage.d.ts.map +1 -1
- package/dist/pg-execution-storage.d.ts +28 -0
- package/dist/pg-execution-storage.d.ts.map +1 -1
- package/dist/pg-file-download-grant-storage.d.ts +11 -0
- package/dist/pg-file-download-grant-storage.d.ts.map +1 -0
- package/dist/pg-projection-run-storage.d.ts.map +1 -1
- package/dist/pg-rules-storage.d.ts.map +1 -1
- package/dist/transactions.d.ts +6 -2
- package/dist/transactions.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +53 -3
- package/src/migrations/053-replacement-plans.sql +72 -0
- package/src/migrations/054-slim-ontology-outbox.sql +108 -0
- package/src/migrations/055-object-query-preparation.sql +11 -0
- package/src/migrations/056-commit-touches.sql +41 -0
- package/src/migrations/057-audio-transcription.sql +10 -0
- package/src/migrations/058-reranking-model-kind.sql +11 -0
- package/src/migrations/059-file-download-grants.sql +16 -0
- package/src/migrations/060-drained-source-versions.sql +11 -0
- package/src/migrations.ts +20 -0
- package/src/objects/aggregate-admission.ts +21 -0
- package/src/objects/preparation-lock.ts +15 -0
- package/src/objects/prepare-queries.ts +181 -0
- package/src/objects/query-compiler.ts +404 -114
- package/src/objects/query-indexes.ts +348 -0
- package/src/objects/reader.ts +140 -51
- package/src/objects/storage.ts +15 -3
- package/src/objects/text-count-index.ts +455 -0
- package/src/ontology-storage/index.ts +3 -0
- package/src/ontology-storage/materialization-session.ts +116 -275
- package/src/ontology-storage/materialization-state.ts +8 -259
- package/src/ontology-storage/materialization-writer.ts +618 -508
- package/src/ontology-storage/materializations.ts +106 -289
- package/src/ontology-storage/outbox.ts +46 -15
- package/src/ontology-storage/replacement-plans.ts +1110 -0
- package/src/ontology-storage/shared.ts +27 -15
- package/src/ontology-storage/source-cleanup.ts +22 -10
- package/src/ontology-storage/source-roots.ts +35 -8
- package/src/ontology-storage/sources.ts +0 -30
- package/src/pg-ai-cost-storage.ts +12 -0
- package/src/pg-ai-limit-storage.ts +13 -2
- package/src/pg-ai-usage-storage.ts +8 -0
- package/src/pg-execution-storage.ts +8 -3
- package/src/pg-file-download-grant-storage.ts +76 -0
- package/src/pg-projection-run-storage.ts +6 -1
- package/src/pg-rules-storage.ts +7 -3
- 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
|
+
}
|
package/src/objects/reader.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
86
|
-
const
|
|
87
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
package/src/objects/storage.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
111
|
-
|
|
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 {
|