@sixb/pg 0.1.16 → 0.1.18
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,455 @@
|
|
|
1
|
+
import { createHash } from "node:crypto"
|
|
2
|
+
import type { ObjectQuery, ObjectQueryPredicate } 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
|
+
import {
|
|
8
|
+
compilePgFacetSummary,
|
|
9
|
+
compilePgObjectCountQuery,
|
|
10
|
+
compilePgObjectQuery,
|
|
11
|
+
DEFAULT_OBJECT_QUERY_SOURCE,
|
|
12
|
+
} from "./query-compiler"
|
|
13
|
+
|
|
14
|
+
export interface PgObjectTextCountIndex {
|
|
15
|
+
readonly projectId: string
|
|
16
|
+
readonly objectTypeId: string
|
|
17
|
+
readonly propertyId: string
|
|
18
|
+
/** Up to four string equality filters used with this text search. */
|
|
19
|
+
readonly filters?: readonly string[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function describe(input: PgObjectTextCountIndex) {
|
|
23
|
+
const definition = { ...input, filters: [...new Set(input.filters ?? [])].sort() }
|
|
24
|
+
if (definition.filters.length > 4)
|
|
25
|
+
throw new Error("[SixbPg] Text counts support at most four string filters")
|
|
26
|
+
for (const value of [
|
|
27
|
+
input.projectId,
|
|
28
|
+
input.objectTypeId,
|
|
29
|
+
input.propertyId,
|
|
30
|
+
...definition.filters,
|
|
31
|
+
])
|
|
32
|
+
literal(value)
|
|
33
|
+
const key = hash(
|
|
34
|
+
JSON.stringify([
|
|
35
|
+
definition.projectId,
|
|
36
|
+
definition.objectTypeId,
|
|
37
|
+
definition.propertyId,
|
|
38
|
+
definition.filters,
|
|
39
|
+
])
|
|
40
|
+
)
|
|
41
|
+
const textColumn = `sixb_qtext_${key}`
|
|
42
|
+
const filters = new Map(
|
|
43
|
+
definition.filters.map((property) => [
|
|
44
|
+
property,
|
|
45
|
+
`sixb_qfilter_${hash(JSON.stringify([input.projectId, input.objectTypeId, property]))}`,
|
|
46
|
+
])
|
|
47
|
+
)
|
|
48
|
+
const scope = `project_id = ${literal(input.projectId)} AND object_type_id = ${literal(input.objectTypeId)}`
|
|
49
|
+
// Bound B-tree entry size without restricting application data. Long rows use an exact
|
|
50
|
+
// overflow path; adding this index must never make a later valid object write fail.
|
|
51
|
+
const compact = [
|
|
52
|
+
`octet_length(${identifier(textColumn)}) <= 1024`,
|
|
53
|
+
...[...filters.values()].map(
|
|
54
|
+
(column) => `(${identifier(column)} IS NULL OR octet_length(${identifier(column)}) <= 128)`
|
|
55
|
+
),
|
|
56
|
+
].join(" AND ")
|
|
57
|
+
return { definition, name: `sixb_text_count_${key}`, textColumn, filters, scope, compact }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Explicit offline preparation: adding STORED generated columns rewrites existing objects. */
|
|
61
|
+
export async function preparePgObjectTextCounts(
|
|
62
|
+
sql: SQL | ReservedSQL,
|
|
63
|
+
schema: string,
|
|
64
|
+
inputs: readonly PgObjectTextCountIndex[]
|
|
65
|
+
): Promise<readonly string[]> {
|
|
66
|
+
const definitions = inputs.map(describe)
|
|
67
|
+
if (!definitions.length) return []
|
|
68
|
+
|
|
69
|
+
const [extension] = await sql<{ ready: boolean }[]>`
|
|
70
|
+
SELECT EXISTS (
|
|
71
|
+
SELECT 1 FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace
|
|
72
|
+
WHERE e.extname = 'pg_trgm' AND n.nspname = 'public'
|
|
73
|
+
) AS ready
|
|
74
|
+
`
|
|
75
|
+
if (!extension?.ready)
|
|
76
|
+
throw new Error("[SixbPg] Text counts require CREATE EXTENSION pg_trgm WITH SCHEMA public")
|
|
77
|
+
const table = `${identifier(schema)}.objects`
|
|
78
|
+
const names: string[] = []
|
|
79
|
+
await withReservedPgConnection(sql, async (connection) => {
|
|
80
|
+
let connected = true
|
|
81
|
+
try {
|
|
82
|
+
await lockPgPreparation(connection, `${schema}.objects.query-indexes`)
|
|
83
|
+
|
|
84
|
+
const existing = await connection<{ attname: string }[]>`
|
|
85
|
+
SELECT attname FROM pg_attribute
|
|
86
|
+
WHERE attrelid = ${table}::regclass AND attnum > 0 AND NOT attisdropped
|
|
87
|
+
`
|
|
88
|
+
const columns = new Set(existing.map((row) => row.attname))
|
|
89
|
+
const additions = new Map<string, string>()
|
|
90
|
+
for (const item of definitions) {
|
|
91
|
+
additions.set(
|
|
92
|
+
item.textColumn,
|
|
93
|
+
`CASE WHEN ${item.scope} THEN lower(coalesce(properties ->> ${literal(item.definition.propertyId)}, '')) END`
|
|
94
|
+
)
|
|
95
|
+
for (const [property, column] of item.filters)
|
|
96
|
+
additions.set(
|
|
97
|
+
column,
|
|
98
|
+
`CASE WHEN ${item.scope} AND jsonb_typeof(properties -> ${literal(property)}) = 'string' THEN properties ->> ${literal(property)} END`
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const missing = [...additions].filter(([column]) => !columns.has(column))
|
|
103
|
+
if (missing.length > 0) {
|
|
104
|
+
await connection.unsafe(
|
|
105
|
+
`ALTER TABLE ${table} ${missing.map(([column, expression]) => `ADD COLUMN ${identifier(column)} text GENERATED ALWAYS AS (${expression}) STORED`).join(", ")}`
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const item of definitions) {
|
|
110
|
+
const statements = textCountIndexStatements(item)
|
|
111
|
+
for (const [name, expression] of statements) {
|
|
112
|
+
await connection.unsafe(
|
|
113
|
+
`CREATE INDEX CONCURRENTLY IF NOT EXISTS ${identifier(name)} ON ${table} ${expression}`
|
|
114
|
+
)
|
|
115
|
+
const [valid] = await connection<{ indisvalid: boolean }[]>`
|
|
116
|
+
SELECT indisvalid FROM pg_index
|
|
117
|
+
WHERE indexrelid = ${`${identifier(schema)}.${identifier(name)}`}::regclass
|
|
118
|
+
`
|
|
119
|
+
if (!valid?.indisvalid)
|
|
120
|
+
throw new Error(
|
|
121
|
+
`[SixbPg] Text count index ${schema}.${name} is invalid. Drop it concurrently and retry preparation.`
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
await connection.unsafe(
|
|
125
|
+
`COMMENT ON INDEX ${identifier(schema)}.${identifier(item.name)} IS ${literal(JSON.stringify(item.definition))}`
|
|
126
|
+
)
|
|
127
|
+
names.push(item.name)
|
|
128
|
+
}
|
|
129
|
+
await connection.unsafe(`ANALYZE ${table}`)
|
|
130
|
+
} catch (error) {
|
|
131
|
+
connected = !isConnectionLost(error)
|
|
132
|
+
throw error
|
|
133
|
+
} finally {
|
|
134
|
+
if (connected)
|
|
135
|
+
await connection`SELECT pg_advisory_unlock(hashtextextended(${`${schema}.objects.query-indexes`}, 0))`
|
|
136
|
+
}
|
|
137
|
+
})
|
|
138
|
+
return names
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
type TextCountDefinition = ReturnType<typeof describe>
|
|
142
|
+
|
|
143
|
+
/** Cover compact text/filter values; separate overflow indexes preserve unbounded values. */
|
|
144
|
+
function textCountIndexStatements(item: TextCountDefinition): [string, string][] {
|
|
145
|
+
const predicate = `${item.scope} AND (${item.compact})`
|
|
146
|
+
const statements: [string, string][] = [
|
|
147
|
+
[
|
|
148
|
+
item.name,
|
|
149
|
+
`(${[...item.filters.values(), item.textColumn].map(identifier).join(", ")}) WHERE ${predicate}`,
|
|
150
|
+
],
|
|
151
|
+
[
|
|
152
|
+
`${item.name}_gin`,
|
|
153
|
+
`USING gin (${identifier(item.textColumn)} public.gin_trgm_ops) WHERE ${predicate}`,
|
|
154
|
+
],
|
|
155
|
+
[`${item.name}_overflow`, `(primary_id) WHERE ${item.scope} AND NOT (${item.compact})`],
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
if (item.filters.size > 0) {
|
|
159
|
+
const columns = [...item.filters.values()].map(identifier)
|
|
160
|
+
const compact = columns
|
|
161
|
+
.map((column) => `(${column} IS NOT NULL AND octet_length(${column}) <= 128)`)
|
|
162
|
+
.join(" AND ")
|
|
163
|
+
statements.push(
|
|
164
|
+
[`${item.name}_facets`, `(${columns.join(", ")}) WHERE ${item.scope} AND (${compact})`],
|
|
165
|
+
[`${item.name}_facet_overflow`, `(primary_id) WHERE ${item.scope} AND NOT (${compact})`],
|
|
166
|
+
[
|
|
167
|
+
`${item.name}_text_facets_overflow`,
|
|
168
|
+
`(primary_id) WHERE ${item.scope} AND NOT ((${item.compact}) AND (${columns.map((column) => `${column} IS NOT NULL`).join(" AND ")}))`,
|
|
169
|
+
]
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return statements
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Narrow optimization for flat counts; unsupported predicates and selected scopes use the normal compiler. */
|
|
177
|
+
export async function compilePgIndexedTextCount(
|
|
178
|
+
sql: SQLClient,
|
|
179
|
+
projectId: string,
|
|
180
|
+
query: ObjectQuery
|
|
181
|
+
): Promise<{ sql: string; args: unknown[] } | undefined> {
|
|
182
|
+
return compilePgIndexedTextAggregate(sql, projectId, query)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function compilePgIndexedTextFacets(
|
|
186
|
+
sql: SQLClient,
|
|
187
|
+
projectId: string,
|
|
188
|
+
query: ObjectQuery,
|
|
189
|
+
facets: readonly { propertyId: string; limit: number }[]
|
|
190
|
+
): Promise<{ sql: string; args: unknown[] } | undefined> {
|
|
191
|
+
return compilePgIndexedTextAggregate(sql, projectId, query, facets)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Try compact indexes first, then merge exact results for values outside their size bounds. */
|
|
195
|
+
async function compilePgIndexedTextAggregate(
|
|
196
|
+
sql: SQLClient,
|
|
197
|
+
projectId: string,
|
|
198
|
+
query: ObjectQuery,
|
|
199
|
+
facets?: readonly { propertyId: string; limit: number }[]
|
|
200
|
+
): Promise<{ sql: string; args: unknown[] } | undefined> {
|
|
201
|
+
const shape = flatCount(query)
|
|
202
|
+
if (!shape || (!shape.text.length && !facets)) return undefined
|
|
203
|
+
|
|
204
|
+
const rows = await sql<{ name: string; definition: string }[]>`
|
|
205
|
+
SELECT c.relname AS name, obj_description(c.oid,'pg_class') AS definition
|
|
206
|
+
FROM pg_index i JOIN pg_class c ON c.oid=i.indexrelid
|
|
207
|
+
WHERE i.indrelid='objects'::regclass AND i.indisvalid
|
|
208
|
+
AND c.relname LIKE 'sixb_text_count_%' AND obj_description(c.oid,'pg_class') IS NOT NULL`
|
|
209
|
+
for (const row of rows) {
|
|
210
|
+
const definition = parseDefinition(row.definition)
|
|
211
|
+
if (
|
|
212
|
+
!definition ||
|
|
213
|
+
definition.projectId !== projectId ||
|
|
214
|
+
definition.objectTypeId !== shape.objectTypeId
|
|
215
|
+
)
|
|
216
|
+
continue
|
|
217
|
+
const item = describe(definition)
|
|
218
|
+
if (item.name !== row.name) continue
|
|
219
|
+
if (facets?.some((facet) => !item.filters.has(facet.propertyId))) continue
|
|
220
|
+
if (
|
|
221
|
+
shape.text.some((text) => {
|
|
222
|
+
const fields = text.fields?.length
|
|
223
|
+
? text.fields
|
|
224
|
+
: text.fieldsByObjectType?.[shape.objectTypeId]
|
|
225
|
+
return fields?.length !== 1 || fields[0] !== definition.propertyId
|
|
226
|
+
})
|
|
227
|
+
)
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
const args: unknown[] = []
|
|
231
|
+
const bind = (value: unknown) => {
|
|
232
|
+
args.push(value)
|
|
233
|
+
return `$${args.length}`
|
|
234
|
+
}
|
|
235
|
+
const where = [
|
|
236
|
+
`project_id=${bind(projectId)}`,
|
|
237
|
+
`object_type_id=${bind(shape.objectTypeId)}`,
|
|
238
|
+
`(${item.compact})`,
|
|
239
|
+
]
|
|
240
|
+
let supported = true
|
|
241
|
+
for (const predicate of shape.predicates) {
|
|
242
|
+
const condition = stringPredicate(predicate, item.filters, bind)
|
|
243
|
+
if (!condition) {
|
|
244
|
+
supported = false
|
|
245
|
+
break
|
|
246
|
+
}
|
|
247
|
+
where.push(condition)
|
|
248
|
+
}
|
|
249
|
+
if (!supported) continue
|
|
250
|
+
|
|
251
|
+
const broadWhere = [...where]
|
|
252
|
+
for (const text of shape.text) {
|
|
253
|
+
const terms = text.query.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
|
254
|
+
if (!terms.length) {
|
|
255
|
+
where.push("false")
|
|
256
|
+
broadWhere.push("false")
|
|
257
|
+
}
|
|
258
|
+
for (const term of terms) {
|
|
259
|
+
where.push(
|
|
260
|
+
`${identifier(item.textColumn)} LIKE ${bind(`%${term.replace(/[\\%_]/g, "\\$&")}%`)}::text`
|
|
261
|
+
)
|
|
262
|
+
// POSITION deliberately chooses a covering B-tree scan for broad searches; GIN
|
|
263
|
+
// must fetch/recheck the heap and becomes lossy beyond work_mem.
|
|
264
|
+
broadWhere.push(`position(${bind(term)}::text in ${identifier(item.textColumn)}) > 0`)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const input = { projectId, query, item, where, broadWhere, args }
|
|
269
|
+
if (facets) return compileIndexedFacets(input, facets, shape.text.length > 0, bind)
|
|
270
|
+
return compileIndexedCount(input)
|
|
271
|
+
}
|
|
272
|
+
return undefined
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
interface IndexedAggregateInput {
|
|
276
|
+
projectId: string
|
|
277
|
+
query: ObjectQuery
|
|
278
|
+
item: TextCountDefinition
|
|
279
|
+
where: string[]
|
|
280
|
+
broadWhere: string[]
|
|
281
|
+
args: unknown[]
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function compileIndexedFacets(
|
|
285
|
+
{ projectId, query, item, where, broadWhere, args }: IndexedAggregateInput,
|
|
286
|
+
facets: readonly { propertyId: string; limit: number }[],
|
|
287
|
+
hasText: boolean,
|
|
288
|
+
bind: (value: unknown) => string
|
|
289
|
+
): { sql: string; args: unknown[] } {
|
|
290
|
+
const allString =
|
|
291
|
+
[...item.filters.values()].map((column) => `${identifier(column)} IS NOT NULL`).join(" AND ") ||
|
|
292
|
+
"true"
|
|
293
|
+
const filterCompact =
|
|
294
|
+
[...item.filters.values()]
|
|
295
|
+
.map(
|
|
296
|
+
(column) =>
|
|
297
|
+
`(${identifier(column)} IS NOT NULL AND octet_length(${identifier(column)}) <= 128)`
|
|
298
|
+
)
|
|
299
|
+
.join(" AND ") || "true"
|
|
300
|
+
const compact = hasText ? `(${item.compact}) AND (${allString})` : filterCompact
|
|
301
|
+
// Plain facets do not need to read the text column. Their covering index is tiny.
|
|
302
|
+
const nativeWhere = hasText
|
|
303
|
+
? [...where, allString]
|
|
304
|
+
: [where[0]!, where[1]!, compact, ...where.slice(3)]
|
|
305
|
+
const nativeBroad = [...broadWhere, allString]
|
|
306
|
+
const fields = facets
|
|
307
|
+
.map(
|
|
308
|
+
(facet, i) =>
|
|
309
|
+
`'string'::text AS f${i}_type, ${identifier(item.filters.get(facet.propertyId)!)} AS f${i}_value`
|
|
310
|
+
)
|
|
311
|
+
.join(", ")
|
|
312
|
+
|
|
313
|
+
// Read oversized or non-string values through the general compiler.
|
|
314
|
+
const overflow = compilePgObjectQuery(projectId, query, {
|
|
315
|
+
includeTotal: false,
|
|
316
|
+
source: {
|
|
317
|
+
...DEFAULT_OBJECT_QUERY_SOURCE,
|
|
318
|
+
objectsTable: `(SELECT * FROM objects WHERE ${item.scope} AND NOT (${compact}) OFFSET 0) AS _sixb_facet_overflow`,
|
|
319
|
+
},
|
|
320
|
+
})
|
|
321
|
+
const offset = args.length
|
|
322
|
+
const overflowSql = overflow.sql.replace(/\$(\d+)/g, (_, number) => `$${Number(number) + offset}`)
|
|
323
|
+
args.push(...overflow.args)
|
|
324
|
+
const overflowFields = facets
|
|
325
|
+
.map((facet, i) => {
|
|
326
|
+
const property = bind(facet.propertyId)
|
|
327
|
+
return `jsonb_typeof(properties -> ${property}::text) AS f${i}_type, properties ->> ${property}::text AS f${i}_value`
|
|
328
|
+
})
|
|
329
|
+
.join(", ")
|
|
330
|
+
|
|
331
|
+
// Probe at most 4097 matches before choosing the broad covering-index scan.
|
|
332
|
+
const native = hasText
|
|
333
|
+
? `WITH _sixb_bounded AS MATERIALIZED (SELECT ${fields} FROM objects WHERE ${nativeWhere.join(" AND ")} LIMIT 4097)
|
|
334
|
+
SELECT * FROM _sixb_bounded WHERE (SELECT count(*) FROM _sixb_bounded) <= 4096
|
|
335
|
+
UNION ALL SELECT ${fields} FROM objects WHERE ${nativeBroad.join(" AND ")} AND (SELECT count(*) FROM _sixb_bounded) > 4096`
|
|
336
|
+
: `SELECT ${fields} FROM objects WHERE ${nativeWhere.join(" AND ")}`
|
|
337
|
+
return compilePgFacetSummary(
|
|
338
|
+
{
|
|
339
|
+
sql: `SELECT * FROM (${native}) AS _sixb_native_facets UNION ALL SELECT ${overflowFields} FROM (${overflowSql}) AS _sixb_overflow_facets`,
|
|
340
|
+
args,
|
|
341
|
+
},
|
|
342
|
+
facets,
|
|
343
|
+
true
|
|
344
|
+
)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function compileIndexedCount({
|
|
348
|
+
projectId,
|
|
349
|
+
query,
|
|
350
|
+
item,
|
|
351
|
+
where,
|
|
352
|
+
broadWhere,
|
|
353
|
+
args,
|
|
354
|
+
}: IndexedAggregateInput): { sql: string; args: unknown[] } {
|
|
355
|
+
const overflow = compilePgObjectCountQuery(projectId, query, {
|
|
356
|
+
source: {
|
|
357
|
+
...DEFAULT_OBJECT_QUERY_SOURCE,
|
|
358
|
+
objectsTable: `(SELECT * FROM objects WHERE ${item.scope} AND NOT (${item.compact}) OFFSET 0) AS _sixb_long_text`,
|
|
359
|
+
},
|
|
360
|
+
})
|
|
361
|
+
const offset = args.length
|
|
362
|
+
return {
|
|
363
|
+
sql: `WITH _sixb_bounded_text_count AS MATERIALIZED (SELECT 1 FROM objects WHERE ${where.join(" AND ")} LIMIT 4097)
|
|
364
|
+
SELECT CASE WHEN (SELECT count(*) FROM _sixb_bounded_text_count) > 4096
|
|
365
|
+
THEN (SELECT count(*) FROM objects WHERE ${broadWhere.join(" AND ")})
|
|
366
|
+
ELSE (SELECT count(*) FROM _sixb_bounded_text_count) END
|
|
367
|
+
+ (${overflow.sql.replace(/\$(\d+)/g, (_, number) => `$${Number(number) + offset}`)}) AS count`,
|
|
368
|
+
args: [...args, ...overflow.args],
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function flatCount(query: ObjectQuery):
|
|
373
|
+
| {
|
|
374
|
+
objectTypeId: string
|
|
375
|
+
text: Extract<ObjectQuery, { kind: "text" }>[]
|
|
376
|
+
predicates: ObjectQueryPredicate[]
|
|
377
|
+
}
|
|
378
|
+
| undefined {
|
|
379
|
+
if (query.kind === "start")
|
|
380
|
+
return query.includeSubtypes
|
|
381
|
+
? undefined
|
|
382
|
+
: { objectTypeId: query.objectTypeId, text: [], predicates: [] }
|
|
383
|
+
if (!["filter", "text", "sort", "expand"].includes(query.kind) || !("input" in query))
|
|
384
|
+
return undefined
|
|
385
|
+
const shape = flatCount(query.input)
|
|
386
|
+
if (!shape) return undefined
|
|
387
|
+
if (query.kind === "text") shape.text.push(query)
|
|
388
|
+
if (query.kind === "filter") shape.predicates.push(query.predicate)
|
|
389
|
+
return shape
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function stringPredicate(
|
|
393
|
+
predicate: ObjectQueryPredicate,
|
|
394
|
+
columns: ReadonlyMap<string, string>,
|
|
395
|
+
bind: (value: unknown) => string
|
|
396
|
+
): string | undefined {
|
|
397
|
+
if (predicate.op === "and" || predicate.op === "or") {
|
|
398
|
+
if (!predicate.items.length) return predicate.op === "and" ? "true" : "false"
|
|
399
|
+
const items = predicate.items.map((item) => stringPredicate(item, columns, bind))
|
|
400
|
+
return items.every((item) => item !== undefined)
|
|
401
|
+
? `(${items.join(predicate.op === "and" ? " AND " : " OR ")})`
|
|
402
|
+
: undefined
|
|
403
|
+
}
|
|
404
|
+
if ((predicate.op !== "eq" && predicate.op !== "in") || predicate.scalarKind === "decimal")
|
|
405
|
+
return undefined
|
|
406
|
+
const column = columns.get(predicate.propertyId)
|
|
407
|
+
if (!column) return undefined
|
|
408
|
+
const values = predicate.op === "in" ? predicate.values : [predicate.value]
|
|
409
|
+
if (!values.every((value) => typeof value === "string")) return undefined
|
|
410
|
+
return values.length
|
|
411
|
+
? `${identifier(column)} IN (${values.map((value) => `${bind(value)}::text`).join(", ")})`
|
|
412
|
+
: "false"
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function parseDefinition(text: string): PgObjectTextCountIndex | undefined {
|
|
416
|
+
try {
|
|
417
|
+
const value: unknown = JSON.parse(text)
|
|
418
|
+
if (
|
|
419
|
+
!value ||
|
|
420
|
+
typeof value !== "object" ||
|
|
421
|
+
!("projectId" in value) ||
|
|
422
|
+
typeof value.projectId !== "string" ||
|
|
423
|
+
!("objectTypeId" in value) ||
|
|
424
|
+
typeof value.objectTypeId !== "string" ||
|
|
425
|
+
!("propertyId" in value) ||
|
|
426
|
+
typeof value.propertyId !== "string" ||
|
|
427
|
+
!("filters" in value) ||
|
|
428
|
+
!Array.isArray(value.filters) ||
|
|
429
|
+
value.filters.length > 4 ||
|
|
430
|
+
!value.filters.every((field) => typeof field === "string")
|
|
431
|
+
)
|
|
432
|
+
return undefined
|
|
433
|
+
return {
|
|
434
|
+
projectId: value.projectId,
|
|
435
|
+
objectTypeId: value.objectTypeId,
|
|
436
|
+
propertyId: value.propertyId,
|
|
437
|
+
filters: value.filters,
|
|
438
|
+
}
|
|
439
|
+
} catch {
|
|
440
|
+
return undefined
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function hash(value: string): string {
|
|
445
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 24)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function identifier(value: string): string {
|
|
449
|
+
return `"${value.replace(/"/g, '""')}"`
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function literal(value: string): string {
|
|
453
|
+
if (value.includes("\0")) throw new Error("[SixbPg] Text count identifiers cannot contain NUL")
|
|
454
|
+
return `E'${value.replace(/\\/g, "\\\\").replace(/'/g, "''")}'`
|
|
455
|
+
}
|
|
@@ -4,6 +4,7 @@ import { PgOntologyCommitStorage } from "./commits"
|
|
|
4
4
|
import type { PgOntologyTransactionContext } from "./materialization-session"
|
|
5
5
|
import { PgOntologyMaterializationStorage } from "./materializations"
|
|
6
6
|
import { PgOntologyOutboxStorage } from "./outbox"
|
|
7
|
+
import { PgOntologyReplacementPlanStorage } from "./replacement-plans"
|
|
7
8
|
import type { PgRootOperation } from "./shared"
|
|
8
9
|
import { PgOntologySourceStorage } from "./sources"
|
|
9
10
|
import { PgVectorIndexingStorage } from "./vector-indexing"
|
|
@@ -13,6 +14,7 @@ export class PgOntologyStorage implements OntologyStorage {
|
|
|
13
14
|
readonly commits: PgOntologyCommitStorage
|
|
14
15
|
readonly sources: PgOntologySourceStorage
|
|
15
16
|
readonly materializations: PgOntologyMaterializationStorage
|
|
17
|
+
readonly replacementPlans: PgOntologyReplacementPlanStorage
|
|
16
18
|
readonly outbox: PgOntologyOutboxStorage
|
|
17
19
|
readonly vectorIndexing: PgVectorIndexingStorage
|
|
18
20
|
readonly vectors: PgOntologyVectorStorage
|
|
@@ -28,6 +30,7 @@ export class PgOntologyStorage implements OntologyStorage {
|
|
|
28
30
|
input.sql,
|
|
29
31
|
input.transactionContext
|
|
30
32
|
)
|
|
33
|
+
this.replacementPlans = new PgOntologyReplacementPlanStorage(input.sql)
|
|
31
34
|
this.outbox = new PgOntologyOutboxStorage(input.runRootOperation)
|
|
32
35
|
this.vectorIndexing = new PgVectorIndexingStorage(
|
|
33
36
|
input.sql,
|