@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.
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,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,