@sixb/pg 0.1.11 → 0.1.12
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 +34 -0
- package/dist/agents/rows.d.ts +3 -1
- package/dist/agents/rows.d.ts.map +1 -1
- package/dist/agents/threads.d.ts +3 -2
- package/dist/agents/threads.d.ts.map +1 -1
- package/dist/index.js +553 -54
- package/dist/index.js.map +16 -14
- package/dist/migrations.d.ts.map +1 -1
- package/dist/objects/query-compiler.d.ts +8 -1
- package/dist/objects/query-compiler.d.ts.map +1 -1
- package/dist/objects/read-scope.d.ts +1 -0
- package/dist/objects/read-scope.d.ts.map +1 -1
- package/dist/objects/reader.d.ts +1 -0
- package/dist/objects/reader.d.ts.map +1 -1
- package/dist/objects/rows.d.ts +1 -0
- package/dist/objects/rows.d.ts.map +1 -1
- package/dist/objects/storage.d.ts +1 -1
- package/dist/objects/storage.d.ts.map +1 -1
- package/dist/ontology-storage/index.d.ts +4 -0
- package/dist/ontology-storage/index.d.ts.map +1 -1
- package/dist/ontology-storage/materializations.d.ts +1 -0
- package/dist/ontology-storage/materializations.d.ts.map +1 -1
- package/dist/ontology-storage/vector-indexing.d.ts +20 -0
- package/dist/ontology-storage/vector-indexing.d.ts.map +1 -0
- package/dist/ontology-storage/vectors.d.ts +16 -0
- package/dist/ontology-storage/vectors.d.ts.map +1 -0
- package/dist/pg-ai-cost-storage.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/agents/rows.ts +21 -0
- package/src/agents/threads.ts +47 -2
- package/src/migrations/041-agent-thread-workspaces.sql +2 -0
- package/src/migrations/042-vector-profiles.sql +28 -0
- package/src/migrations/043-vector-indexing.sql +24 -0
- package/src/migrations/044-vector-batching.sql +3 -0
- package/src/migrations/045-agent-thread-sandbox-state.sql +2 -0
- package/src/migrations.ts +14 -0
- package/src/objects/query-compiler.ts +102 -11
- package/src/objects/read-scope.ts +26 -25
- package/src/objects/reader.ts +33 -1
- package/src/objects/rows.ts +5 -1
- package/src/objects/storage.ts +20 -2
- package/src/ontology-storage/index.ts +15 -0
- package/src/ontology-storage/materializations.ts +13 -0
- package/src/ontology-storage/vector-indexing.ts +168 -0
- package/src/ontology-storage/vectors.ts +172 -0
- package/src/pg-ai-cost-storage.ts +4 -0
|
@@ -7,6 +7,12 @@ import {
|
|
|
7
7
|
type ObjectQuerySortField,
|
|
8
8
|
type QueryScalarKind,
|
|
9
9
|
} from "@sixb/core"
|
|
10
|
+
import {
|
|
11
|
+
hasVectorProfile,
|
|
12
|
+
isVectorProfileQuery,
|
|
13
|
+
normalizeVector,
|
|
14
|
+
vectorSearchCandidateLimit,
|
|
15
|
+
} from "@sixb/core/internal/query"
|
|
10
16
|
|
|
11
17
|
export interface PgObjectQueryPageRow {
|
|
12
18
|
object_type_id: string
|
|
@@ -16,6 +22,7 @@ export interface PgObjectQueryPageRow {
|
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
export interface CompiledPgObjectQuery {
|
|
25
|
+
vectorProbe?: { sql: string; args: unknown[]; limit: number }
|
|
19
26
|
sql: string
|
|
20
27
|
args: unknown[]
|
|
21
28
|
totalSql: string
|
|
@@ -74,7 +81,8 @@ type CompiledOrderField =
|
|
|
74
81
|
}
|
|
75
82
|
| {
|
|
76
83
|
kind: "column"
|
|
77
|
-
column: "object_type_id" | "primary_id"
|
|
84
|
+
column: "object_type_id" | "primary_id" | "_vector_score"
|
|
85
|
+
binary?: boolean
|
|
78
86
|
direction: "asc" | "desc"
|
|
79
87
|
}
|
|
80
88
|
|
|
@@ -99,6 +107,12 @@ export function compilePgObjectQuery(
|
|
|
99
107
|
query: ObjectQuery,
|
|
100
108
|
options: { includeTotal?: boolean; source?: PgObjectQuerySource } = {}
|
|
101
109
|
): CompiledPgObjectQuery {
|
|
110
|
+
if (hasVectorProfile(query) && !isVectorProfileQuery(query)) {
|
|
111
|
+
throw new ObjectQueryExecutionError(
|
|
112
|
+
"unsupported_vector_composition",
|
|
113
|
+
"Vector search supports one profile with filters before ranking and limit/project after."
|
|
114
|
+
)
|
|
115
|
+
}
|
|
102
116
|
const source = options.source ?? DEFAULT_OBJECT_QUERY_SOURCE
|
|
103
117
|
const compiled = source.wrapQuery(
|
|
104
118
|
compileObjectQueryInternal(projectId, query, {
|
|
@@ -110,6 +124,9 @@ export function compilePgObjectQuery(
|
|
|
110
124
|
...compiled,
|
|
111
125
|
sql: numberPlaceholders(compiled.sql),
|
|
112
126
|
totalSql: numberPlaceholders(compiled.totalSql),
|
|
127
|
+
vectorProbe: compiled.vectorProbe
|
|
128
|
+
? { ...compiled.vectorProbe, sql: numberPlaceholders(compiled.vectorProbe.sql) }
|
|
129
|
+
: undefined,
|
|
113
130
|
hasMoreProbe: compiled.hasMoreProbe
|
|
114
131
|
? {
|
|
115
132
|
...compiled.hasMoreProbe,
|
|
@@ -123,6 +140,7 @@ export function compilePgObjectQuery(
|
|
|
123
140
|
export interface PgObjectQuerySource {
|
|
124
141
|
readonly objectsTable: string
|
|
125
142
|
readonly linksTable: string
|
|
143
|
+
readonly objectPropertyPermissionsTable?: string
|
|
126
144
|
/** Wrap a terminal SELECT without its own WITH clause; the selected source owns the only WITH. */
|
|
127
145
|
wrapStatement(
|
|
128
146
|
sql: string,
|
|
@@ -263,9 +281,74 @@ function compileObjectQueryInternal(
|
|
|
263
281
|
case "expand":
|
|
264
282
|
return compileExpand(projectId, query.input, query.expansions, ctx)
|
|
265
283
|
case "vector":
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
284
|
+
return compileVector(projectId, query, ctx)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function compileVector(
|
|
289
|
+
projectId: string,
|
|
290
|
+
query: Extract<ObjectQuery, { kind: "vector" }>,
|
|
291
|
+
ctx: CompileContext
|
|
292
|
+
): CompiledPgObjectQuery {
|
|
293
|
+
if (
|
|
294
|
+
typeof query.vector === "string" ||
|
|
295
|
+
!isVectorProfileQuery(query) ||
|
|
296
|
+
!query.configuration ||
|
|
297
|
+
!Number.isSafeInteger(query.k) ||
|
|
298
|
+
query.k < 1 ||
|
|
299
|
+
query.k > 1000 ||
|
|
300
|
+
query.vector.length < 1 ||
|
|
301
|
+
query.vector.length > 16000
|
|
302
|
+
) {
|
|
303
|
+
throw new ObjectQueryExecutionError(
|
|
304
|
+
"invalid_vector_query",
|
|
305
|
+
"A validated named vector profile is required."
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
const input = compileObjectQueryInternal(projectId, query.input, exactContext(ctx))
|
|
309
|
+
const permissions = ctx.source.objectPropertyPermissionsTable
|
|
310
|
+
const permissionJoin = permissions
|
|
311
|
+
? `JOIN (
|
|
312
|
+
SELECT project_id, object_type_id, primary_id, array_agg(property_id) AS property_ids
|
|
313
|
+
FROM ${permissions}
|
|
314
|
+
GROUP BY project_id, object_type_id, primary_id
|
|
315
|
+
) AS vector_permissions ON vector_permissions.project_id = vectors.project_id
|
|
316
|
+
AND vector_permissions.object_type_id = vectors.object_type_id
|
|
317
|
+
AND vector_permissions.primary_id = vectors.primary_id`
|
|
318
|
+
: ""
|
|
319
|
+
const authorized = permissions ? `AND vectors.source <@ vector_permissions.property_ids` : ""
|
|
320
|
+
const candidates = `FROM (${input.sql}) AS input
|
|
321
|
+
JOIN object_vectors AS vectors ON vectors.project_id = input.project_id
|
|
322
|
+
AND vectors.object_type_id = input.object_type_id AND vectors.primary_id = input.primary_id
|
|
323
|
+
${permissionJoin}
|
|
324
|
+
WHERE vectors.profile = ? AND vectors.configuration = ?
|
|
325
|
+
AND cardinality(vectors.embedding) = ? ${authorized}`
|
|
326
|
+
const candidateArgs = [...input.args, query.profile!, query.configuration, query.vector.length]
|
|
327
|
+
// Bound distance work, not just returned rows. Exceeding the envelope fails before ranking.
|
|
328
|
+
const limit = vectorSearchCandidateLimit(query.vector.length)
|
|
329
|
+
const values = normalizeVector(query.vector, query.vector.length)
|
|
330
|
+
const order = compileOrder([
|
|
331
|
+
{ kind: "column", column: "_vector_score", direction: "desc" },
|
|
332
|
+
...identityOrderFields().map((field) => ({ ...field, binary: true })),
|
|
333
|
+
])
|
|
334
|
+
const sql = `SELECT * FROM (SELECT input.*, 1 - (vectors.embedding::public.vector OPERATOR(public.<=>) ?::public.vector) AS _vector_score
|
|
335
|
+
${candidates}) AS ranked ORDER BY ${order.sql} LIMIT ?`
|
|
336
|
+
const args = [JSON.stringify(values), ...candidateArgs, query.k]
|
|
337
|
+
return {
|
|
338
|
+
sql,
|
|
339
|
+
args,
|
|
340
|
+
// Top-k cardinality depends on eligibility, not distances or ranking.
|
|
341
|
+
totalSql: `SELECT COUNT(*)::bigint AS total FROM (SELECT 1 ${candidates} LIMIT ?) AS eligible`,
|
|
342
|
+
totalArgs: [...candidateArgs, query.k],
|
|
343
|
+
order,
|
|
344
|
+
vectorProbe: {
|
|
345
|
+
sql: `SELECT COUNT(*)::bigint AS total FROM (SELECT 1 ${candidates} LIMIT ?) AS candidates`,
|
|
346
|
+
args: [...candidateArgs, limit + 1],
|
|
347
|
+
limit,
|
|
348
|
+
},
|
|
349
|
+
hasMore: () => false,
|
|
350
|
+
trimRows: identityRows,
|
|
351
|
+
nextPageToken: () => undefined,
|
|
269
352
|
}
|
|
270
353
|
}
|
|
271
354
|
|
|
@@ -377,6 +460,7 @@ function compileWhere(
|
|
|
377
460
|
WHERE ${predicate.sql}
|
|
378
461
|
`,
|
|
379
462
|
totalArgs: [...input.args, ...predicate.args],
|
|
463
|
+
vectorProbe: input.vectorProbe,
|
|
380
464
|
order: input.order,
|
|
381
465
|
hasMore: () => false,
|
|
382
466
|
trimRows: identityRows,
|
|
@@ -424,7 +508,7 @@ function compileSort(
|
|
|
424
508
|
input.created_at,
|
|
425
509
|
input.updated_at,
|
|
426
510
|
input.version,
|
|
427
|
-
input.last_commit_id
|
|
511
|
+
input.last_commit_id${input.vectorProbe ? ", input._vector_score" : ""}
|
|
428
512
|
FROM (${input.sql}) AS input
|
|
429
513
|
ORDER BY ${order.sql}
|
|
430
514
|
`,
|
|
@@ -462,6 +546,7 @@ function compileLimit(
|
|
|
462
546
|
FROM (${input.sql}) AS input
|
|
463
547
|
`,
|
|
464
548
|
totalArgs: input.args,
|
|
549
|
+
vectorProbe: input.vectorProbe,
|
|
465
550
|
order: input.order,
|
|
466
551
|
hasMore: (rowCount, total) =>
|
|
467
552
|
total === undefined ? ctx.probeLimit && rowCount > limit : limit < total,
|
|
@@ -499,6 +584,7 @@ function compilePage(
|
|
|
499
584
|
FROM (${input.sql}) AS input
|
|
500
585
|
`,
|
|
501
586
|
totalArgs: input.args,
|
|
587
|
+
vectorProbe: input.vectorProbe,
|
|
502
588
|
order: input.order,
|
|
503
589
|
hasMore: (rowCount) => rowCount > pageSize,
|
|
504
590
|
trimRows: (rows) => rows.slice(0, pageSize),
|
|
@@ -607,6 +693,7 @@ function compileExpand(
|
|
|
607
693
|
args: [...expand.args, ...input.args, ...inputOrder.args],
|
|
608
694
|
totalSql: input.totalSql,
|
|
609
695
|
totalArgs: input.totalArgs,
|
|
696
|
+
vectorProbe: input.vectorProbe,
|
|
610
697
|
order: input.order,
|
|
611
698
|
hasMoreProbe: input.hasMoreProbe,
|
|
612
699
|
hasMore: input.hasMore,
|
|
@@ -849,13 +936,14 @@ function compileProject(
|
|
|
849
936
|
input.created_at,
|
|
850
937
|
input.updated_at,
|
|
851
938
|
input.version,
|
|
852
|
-
input.last_commit_id
|
|
939
|
+
input.last_commit_id${input.vectorProbe ? ", input._vector_score" : ""}
|
|
853
940
|
FROM (${input.sql}) AS input
|
|
854
941
|
ORDER BY ${inputOrder.sql}
|
|
855
942
|
`,
|
|
856
943
|
args: [...projection.args, ...input.args, ...inputOrder.args],
|
|
857
944
|
totalSql: input.totalSql,
|
|
858
945
|
totalArgs: input.totalArgs,
|
|
946
|
+
vectorProbe: input.vectorProbe,
|
|
859
947
|
order: outputOrder,
|
|
860
948
|
hasMoreProbe: input.hasMoreProbe,
|
|
861
949
|
hasMore: input.hasMore,
|
|
@@ -909,9 +997,7 @@ function compileAggregateSource(
|
|
|
909
997
|
case "page":
|
|
910
998
|
return compileRowQueryAggregateSource(projectId, query, source)
|
|
911
999
|
case "vector":
|
|
912
|
-
|
|
913
|
-
`[SixbPg] PostgreSQL object storage does not support query node '${query.kind}'`
|
|
914
|
-
)
|
|
1000
|
+
return compileRowQueryAggregateSource(projectId, query, source)
|
|
915
1001
|
}
|
|
916
1002
|
}
|
|
917
1003
|
|
|
@@ -1416,7 +1502,9 @@ function compileOrder(
|
|
|
1416
1502
|
|
|
1417
1503
|
for (const field of fields) {
|
|
1418
1504
|
if (field.kind === "column") {
|
|
1419
|
-
clauses.push(
|
|
1505
|
+
clauses.push(
|
|
1506
|
+
`${columnExpression(field.column, qualifier)}${field.binary ? ' COLLATE "C"' : ""} ${field.direction.toUpperCase()}`
|
|
1507
|
+
)
|
|
1420
1508
|
continue
|
|
1421
1509
|
}
|
|
1422
1510
|
|
|
@@ -1637,7 +1725,10 @@ function compiledPropertyValueExpression(
|
|
|
1637
1725
|
: jsonValueExpression(propertyColumn)
|
|
1638
1726
|
}
|
|
1639
1727
|
|
|
1640
|
-
function columnExpression(
|
|
1728
|
+
function columnExpression(
|
|
1729
|
+
column: "object_type_id" | "primary_id" | "_vector_score",
|
|
1730
|
+
qualifier?: string
|
|
1731
|
+
): string {
|
|
1641
1732
|
return qualifier ? `${qualifier}.${column}` : column
|
|
1642
1733
|
}
|
|
1643
1734
|
|
|
@@ -18,6 +18,7 @@ export interface PgSelectedObjectReadSource extends PgObjectQuerySource {
|
|
|
18
18
|
*
|
|
19
19
|
* The complete compiled scope is serialized once. Every statement receives that one JSONB
|
|
20
20
|
* document and the bound project id, keeping the parameter count constant as selections grow.
|
|
21
|
+
* Group property grants after resolving paths so redaction reads only each object's own grants.
|
|
21
22
|
*/
|
|
22
23
|
export function compilePgSelectedObjectReadSource(
|
|
23
24
|
projectId: string,
|
|
@@ -43,6 +44,9 @@ export function compilePgSelectedObjectReadSource(
|
|
|
43
44
|
wrapQuery: (query: CompiledPgObjectQuery): CompiledPgObjectQuery => ({
|
|
44
45
|
...query,
|
|
45
46
|
...wrapStatement(query.sql, query.args),
|
|
47
|
+
vectorProbe: query.vectorProbe
|
|
48
|
+
? { ...query.vectorProbe, ...wrapStatement(query.vectorProbe.sql, query.vectorProbe.args) }
|
|
49
|
+
: undefined,
|
|
46
50
|
totalSql: `${compiled.sql}\n${query.totalSql}`,
|
|
47
51
|
totalArgs: [...compiled.args, ...query.totalArgs],
|
|
48
52
|
...(query.hasMoreProbe
|
|
@@ -177,32 +181,29 @@ function compileSelectedReadScopeCte(scopeJson: string, projectId: string): Comp
|
|
|
177
181
|
SELECT DISTINCT project_id, object_type_id, primary_id
|
|
178
182
|
FROM _sixb_scope_reachable
|
|
179
183
|
),
|
|
184
|
+
_sixb_scope_object_grants AS (
|
|
185
|
+
SELECT reachable.project_id, reachable.object_type_id, reachable.primary_id,
|
|
186
|
+
array_agg(DISTINCT selected_property.value) FILTER (WHERE selected_property.value IS NOT NULL) AS property_ids
|
|
187
|
+
FROM _sixb_scope_reachable AS reachable
|
|
188
|
+
LEFT JOIN _sixb_scope_object_selections AS selection
|
|
189
|
+
ON selection.node_id = reachable.node_id
|
|
190
|
+
AND selection.object_type_id = reachable.object_type_id
|
|
191
|
+
LEFT JOIN LATERAL jsonb_array_elements_text(selection.property_ids) AS selected_property(value) ON true
|
|
192
|
+
GROUP BY reachable.project_id, reachable.object_type_id, reachable.primary_id
|
|
193
|
+
),
|
|
180
194
|
_sixb_scope_objects AS (
|
|
181
|
-
SELECT
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
AND permission.property_id = property.key
|
|
194
|
-
),
|
|
195
|
-
'{}'::jsonb
|
|
196
|
-
) AS properties,
|
|
197
|
-
stored.created_at,
|
|
198
|
-
stored.updated_at,
|
|
199
|
-
stored.version,
|
|
200
|
-
stored.last_commit_id
|
|
201
|
-
FROM objects AS stored
|
|
202
|
-
JOIN _sixb_scope_object_ids AS visible
|
|
203
|
-
ON visible.project_id = stored.project_id
|
|
204
|
-
AND visible.object_type_id = stored.object_type_id
|
|
205
|
-
AND visible.primary_id = stored.primary_id
|
|
195
|
+
SELECT stored.project_id, stored.object_type_id, stored.primary_id,
|
|
196
|
+
COALESCE((
|
|
197
|
+
SELECT jsonb_object_agg(property.key, property.value)
|
|
198
|
+
FROM jsonb_each(stored.properties) AS property(key, value)
|
|
199
|
+
WHERE property.key = ANY(grants.property_ids)
|
|
200
|
+
), '{}'::jsonb) AS properties,
|
|
201
|
+
stored.created_at, stored.updated_at, stored.version, stored.last_commit_id
|
|
202
|
+
FROM _sixb_scope_object_grants AS grants
|
|
203
|
+
JOIN objects AS stored
|
|
204
|
+
ON stored.project_id = grants.project_id
|
|
205
|
+
AND stored.object_type_id = grants.object_type_id
|
|
206
|
+
AND stored.primary_id = grants.primary_id
|
|
206
207
|
),
|
|
207
208
|
_sixb_scope_reached_links(
|
|
208
209
|
project_id,
|
package/src/objects/reader.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type ObjectQuery, ObjectQueryExecutionError } from "@sixb/core"
|
|
2
|
+
import { hasVectorProfile } from "@sixb/core/internal/query"
|
|
2
3
|
import type {
|
|
3
4
|
CountObjectsInput,
|
|
4
5
|
CountObjectsResult,
|
|
@@ -51,7 +52,35 @@ export class PgObjectReader {
|
|
|
51
52
|
private readonly source: PgObjectQuerySource | PgSelectedObjectReadSource
|
|
52
53
|
) {}
|
|
53
54
|
|
|
55
|
+
private async assertVectorSearch(
|
|
56
|
+
params: QueryObjectsInput | CountObjectsInput | ExistsObjectsInput | FacetObjectsInput
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
if (!hasVectorProfile(params.query)) return
|
|
59
|
+
const compiled = compilePgObjectQuery(params.projectId, params.query, { source: this.source })
|
|
60
|
+
if (!compiled.vectorProbe) return
|
|
61
|
+
const extensions = await this.sql.unsafe<{ ready: boolean }[]>(
|
|
62
|
+
"SELECT EXISTS (SELECT 1 FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace WHERE e.extname = 'vector' AND n.nspname = 'public') AS ready"
|
|
63
|
+
)
|
|
64
|
+
if (!extensions[0]?.ready)
|
|
65
|
+
throw new ObjectQueryExecutionError(
|
|
66
|
+
"vector_extension_missing",
|
|
67
|
+
"PostgreSQL vector search requires pgvector installed in schema public (CREATE EXTENSION vector WITH SCHEMA public)."
|
|
68
|
+
)
|
|
69
|
+
const probe = compiled.vectorProbe
|
|
70
|
+
const [row] = await this.sql.unsafe<{ total: string | number }[]>(
|
|
71
|
+
probe.sql,
|
|
72
|
+
probe.args as SqlParameter[]
|
|
73
|
+
)
|
|
74
|
+
if (Number(row?.total ?? 0) > probe.limit) {
|
|
75
|
+
throw new ObjectQueryExecutionError(
|
|
76
|
+
"vector_work_limit_exceeded",
|
|
77
|
+
`Exact vector search supports at most ${probe.limit} eligible vectors at this dimension. Narrow the query filters.`
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
54
82
|
async queryObjects(params: QueryObjectsInput): Promise<QueryObjectsResult> {
|
|
83
|
+
await this.assertVectorSearch(params)
|
|
55
84
|
const sql = this.sql
|
|
56
85
|
const source = this.source
|
|
57
86
|
const compiled = compilePgObjectQuery(params.projectId, params.query, {
|
|
@@ -85,6 +114,7 @@ export class PgObjectReader {
|
|
|
85
114
|
}
|
|
86
115
|
|
|
87
116
|
async countObjects(params: CountObjectsInput): Promise<CountObjectsResult> {
|
|
117
|
+
await this.assertVectorSearch(params)
|
|
88
118
|
const sql = this.sql
|
|
89
119
|
const source = this.source
|
|
90
120
|
const compiled = compilePgObjectCountQuery(params.projectId, stripOuterRowShape(params.query), {
|
|
@@ -98,6 +128,7 @@ export class PgObjectReader {
|
|
|
98
128
|
}
|
|
99
129
|
|
|
100
130
|
async existsObjects(params: ExistsObjectsInput): Promise<ExistsObjectsResult> {
|
|
131
|
+
await this.assertVectorSearch(params)
|
|
101
132
|
const sql = this.sql
|
|
102
133
|
const source = this.source
|
|
103
134
|
const compiled = compilePgObjectExistsQuery(
|
|
@@ -110,6 +141,7 @@ export class PgObjectReader {
|
|
|
110
141
|
}
|
|
111
142
|
|
|
112
143
|
async facetObjects(params: FacetObjectsInput): Promise<FacetObjectsResult> {
|
|
144
|
+
await this.assertVectorSearch(params)
|
|
113
145
|
const sql = this.sql
|
|
114
146
|
const source = this.source
|
|
115
147
|
const facets: ObjectFacetResult[] = []
|
package/src/objects/rows.ts
CHANGED
|
@@ -23,7 +23,10 @@ export function rowToObject(row: ObjectDatabaseRow): ObjectRow {
|
|
|
23
23
|
// base columns map as usual; `_expand` (a `jsonb_build_object`) is revived into
|
|
24
24
|
// the runtime link shape the core executor's fallback also produces.
|
|
25
25
|
export function queryRowToObject(row: ObjectQueryDatabaseRow): ObjectRow {
|
|
26
|
-
const base =
|
|
26
|
+
const base = {
|
|
27
|
+
...rowToObject(row),
|
|
28
|
+
...(row._vector_score === undefined ? {} : { score: row._vector_score }),
|
|
29
|
+
}
|
|
27
30
|
const links = reviveExpandedLinks(row._expand)
|
|
28
31
|
return links ? { ...base, links } : base
|
|
29
32
|
}
|
|
@@ -109,6 +112,7 @@ export interface ObjectDatabaseRow {
|
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
export interface ObjectQueryDatabaseRow extends ObjectDatabaseRow {
|
|
115
|
+
_vector_score?: number
|
|
112
116
|
_cursor_properties?: unknown
|
|
113
117
|
/** `jsonb_build_object(linkId, value, ...)` from an `expand` pushdown; absent otherwise. */
|
|
114
118
|
_expand?: unknown
|
package/src/objects/storage.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasVectorProfile } from "@sixb/core/internal/query"
|
|
1
2
|
import type {
|
|
2
3
|
CompiledSelectedObjectReadScope,
|
|
3
4
|
CountObjectsInput,
|
|
@@ -21,7 +22,7 @@ import type {
|
|
|
21
22
|
QueryObjectsResult,
|
|
22
23
|
} from "@sixb/core/storage"
|
|
23
24
|
import type { SQLClient, SqlParameter } from "../pg-client"
|
|
24
|
-
import type
|
|
25
|
+
import { type PgStoreClient, runPgRepeatableReadTransaction } from "../transactions"
|
|
25
26
|
import { DEFAULT_OBJECT_QUERY_SOURCE } from "./query-compiler"
|
|
26
27
|
import { assertLinkQueryLimit, PgObjectReader } from "./reader"
|
|
27
28
|
import {
|
|
@@ -43,6 +44,7 @@ const PG_OBJECT_QUERY_CAPABILITIES: ObjectQueryCapabilities = {
|
|
|
43
44
|
refs: true,
|
|
44
45
|
filter: true,
|
|
45
46
|
text: true,
|
|
47
|
+
vector: true,
|
|
46
48
|
sort: true,
|
|
47
49
|
limit: true,
|
|
48
50
|
page: true,
|
|
@@ -95,7 +97,7 @@ const PG_OBJECT_QUERY_CAPABILITIES: ObjectQueryCapabilities = {
|
|
|
95
97
|
"PostgreSQL object query pushdown supports start/refs/filter/text/sort/limit/page/traverse/set/project/expand over JSONB properties and object links.",
|
|
96
98
|
"expand hydrates linked objects in-database (top-N per parent via LATERAL + jsonb_agg); core resolves each expansion's cardinality before pushdown, and a mixed/unresolved one stays on the fallback.",
|
|
97
99
|
"Exact decimal predicates, ordering, and keyset pagination use PostgreSQL numeric casts.",
|
|
98
|
-
"Relevance sorting
|
|
100
|
+
"Relevance sorting and unresolved start.includeSubtypes remain planner fallback or rejection cases.",
|
|
99
101
|
],
|
|
100
102
|
}
|
|
101
103
|
|
|
@@ -120,18 +122,34 @@ export class PgObjectStorage implements ObjectStorage {
|
|
|
120
122
|
}
|
|
121
123
|
|
|
122
124
|
async queryObjects(params: QueryObjectsInput): Promise<QueryObjectsResult> {
|
|
125
|
+
if (hasVectorProfile(params.query))
|
|
126
|
+
return runPgRepeatableReadTransaction(this.sql, (tx) =>
|
|
127
|
+
new PgObjectReader(tx, DEFAULT_OBJECT_QUERY_SOURCE).queryObjects(params)
|
|
128
|
+
)
|
|
123
129
|
return this.reader.queryObjects(params)
|
|
124
130
|
}
|
|
125
131
|
|
|
126
132
|
async countObjects(params: CountObjectsInput): Promise<CountObjectsResult> {
|
|
133
|
+
if (hasVectorProfile(params.query))
|
|
134
|
+
return runPgRepeatableReadTransaction(this.sql, (tx) =>
|
|
135
|
+
new PgObjectReader(tx, DEFAULT_OBJECT_QUERY_SOURCE).countObjects(params)
|
|
136
|
+
)
|
|
127
137
|
return this.reader.countObjects(params)
|
|
128
138
|
}
|
|
129
139
|
|
|
130
140
|
async existsObjects(params: ExistsObjectsInput): Promise<ExistsObjectsResult> {
|
|
141
|
+
if (hasVectorProfile(params.query))
|
|
142
|
+
return runPgRepeatableReadTransaction(this.sql, (tx) =>
|
|
143
|
+
new PgObjectReader(tx, DEFAULT_OBJECT_QUERY_SOURCE).existsObjects(params)
|
|
144
|
+
)
|
|
131
145
|
return this.reader.existsObjects(params)
|
|
132
146
|
}
|
|
133
147
|
|
|
134
148
|
async facetObjects(params: FacetObjectsInput): Promise<FacetObjectsResult> {
|
|
149
|
+
if (hasVectorProfile(params.query))
|
|
150
|
+
return runPgRepeatableReadTransaction(this.sql, (tx) =>
|
|
151
|
+
new PgObjectReader(tx, DEFAULT_OBJECT_QUERY_SOURCE).facetObjects(params)
|
|
152
|
+
)
|
|
135
153
|
return this.reader.facetObjects(params)
|
|
136
154
|
}
|
|
137
155
|
|
|
@@ -6,12 +6,16 @@ import { PgOntologyMaterializationStorage } from "./materializations"
|
|
|
6
6
|
import { PgOntologyOutboxStorage } from "./outbox"
|
|
7
7
|
import type { PgRootOperation } from "./shared"
|
|
8
8
|
import { PgOntologySourceStorage } from "./sources"
|
|
9
|
+
import { PgVectorIndexingStorage } from "./vector-indexing"
|
|
10
|
+
import { PgOntologyVectorStorage } from "./vectors"
|
|
9
11
|
|
|
10
12
|
export class PgOntologyStorage implements OntologyStorage {
|
|
11
13
|
readonly commits: PgOntologyCommitStorage
|
|
12
14
|
readonly sources: PgOntologySourceStorage
|
|
13
15
|
readonly materializations: PgOntologyMaterializationStorage
|
|
14
16
|
readonly outbox: PgOntologyOutboxStorage
|
|
17
|
+
readonly vectorIndexing: PgVectorIndexingStorage
|
|
18
|
+
readonly vectors: PgOntologyVectorStorage
|
|
15
19
|
|
|
16
20
|
constructor(input: {
|
|
17
21
|
readonly sql: SQLClient
|
|
@@ -25,6 +29,17 @@ export class PgOntologyStorage implements OntologyStorage {
|
|
|
25
29
|
input.transactionContext
|
|
26
30
|
)
|
|
27
31
|
this.outbox = new PgOntologyOutboxStorage(input.runRootOperation)
|
|
32
|
+
this.vectorIndexing = new PgVectorIndexingStorage(
|
|
33
|
+
input.sql,
|
|
34
|
+
input.runRootOperation,
|
|
35
|
+
(session, projectId) => this.materializations.assertVectorSession(session, projectId)
|
|
36
|
+
)
|
|
37
|
+
this.vectors = new PgOntologyVectorStorage(
|
|
38
|
+
input.sql,
|
|
39
|
+
input.runRootOperation,
|
|
40
|
+
(session, projectId, commitId) =>
|
|
41
|
+
this.materializations.assertVectorSession(session, projectId, commitId)
|
|
42
|
+
)
|
|
28
43
|
}
|
|
29
44
|
|
|
30
45
|
deactivateSessions(): void {
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
linkRefSortKey,
|
|
10
10
|
linkScopeSortKey,
|
|
11
11
|
MaterializationConflictError,
|
|
12
|
+
MaterializationValidationError,
|
|
12
13
|
objectRefKey,
|
|
13
14
|
objectRefSortKey,
|
|
14
15
|
telemetryPointKey,
|
|
@@ -100,6 +101,18 @@ export class PgOntologyMaterializationStorage implements OntologyMaterialization
|
|
|
100
101
|
this.writer = new PgMaterializationWriter(sql)
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
assertVectorSession(session: MaterializationSession, projectId: string, commitId?: string): void {
|
|
105
|
+
const active = this.sessions.require(session)
|
|
106
|
+
if (
|
|
107
|
+
active.header.commit.projectId !== projectId ||
|
|
108
|
+
(commitId !== undefined && active.header.commit.id !== commitId)
|
|
109
|
+
) {
|
|
110
|
+
throw new MaterializationValidationError(
|
|
111
|
+
"Vector mutation does not belong to this materialization session."
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
103
116
|
async begin(input: MaterializationPlanHeader): Promise<MaterializationSession> {
|
|
104
117
|
assertMaterializationHeader(input)
|
|
105
118
|
const session = await this.sessions.create(input)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { parseSixbFailure } from "@sixb/core/internal/errors"
|
|
2
|
+
import {
|
|
3
|
+
type MaterializationSession,
|
|
4
|
+
type OntologyVectorIndexingStorage,
|
|
5
|
+
VECTOR_INDEXING_FAILURE_CODES,
|
|
6
|
+
type VectorIndexingRequest,
|
|
7
|
+
type VectorIndexingWork,
|
|
8
|
+
} from "@sixb/core/storage"
|
|
9
|
+
import type { SQLClient } from "../pg-client"
|
|
10
|
+
import { jsonParameter as json, type PgRootOperation } from "./shared"
|
|
11
|
+
|
|
12
|
+
interface WorkRow {
|
|
13
|
+
batch_id: string | null
|
|
14
|
+
request: VectorIndexingRequest
|
|
15
|
+
status: VectorIndexingWork["status"]
|
|
16
|
+
available_at: Date | string
|
|
17
|
+
values_json: number[] | null
|
|
18
|
+
error: string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class PgVectorIndexingStorage implements OntologyVectorIndexingStorage {
|
|
22
|
+
constructor(
|
|
23
|
+
private readonly sql: SQLClient,
|
|
24
|
+
private readonly run: PgRootOperation,
|
|
25
|
+
private readonly assertSession: (session: MaterializationSession, projectId: string) => void
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
async schedule(input: Parameters<OntologyVectorIndexingStorage["schedule"]>[0]) {
|
|
29
|
+
this.assertSession(input.session, input.projectId)
|
|
30
|
+
if (input.deleted.length)
|
|
31
|
+
await this.sql`
|
|
32
|
+
DELETE FROM object_vector_indexing AS work
|
|
33
|
+
USING jsonb_array_elements(${json(this.sql, input.deleted)}::jsonb) AS deleted(ref)
|
|
34
|
+
WHERE work.project_id = ${input.projectId}
|
|
35
|
+
AND work.object_type_id = deleted.ref->>'objectTypeId'
|
|
36
|
+
AND work.primary_id = deleted.ref->>'primaryId'
|
|
37
|
+
`
|
|
38
|
+
if (input.requests.length)
|
|
39
|
+
await this.sql`
|
|
40
|
+
INSERT INTO object_vector_indexing (
|
|
41
|
+
project_id, id, object_type_id, primary_id, profile, batch_id, request, status, available_at, dispatch_at
|
|
42
|
+
)
|
|
43
|
+
SELECT ${input.projectId}, entry->>'id', entry#>>'{ref,objectTypeId}',
|
|
44
|
+
entry#>>'{ref,primaryId}', entry->>'profile', entry->>'batchId', entry - 'batchId', 'pending',
|
|
45
|
+
${input.availableAt}::timestamptz, ${input.availableAt}::timestamptz
|
|
46
|
+
FROM jsonb_array_elements(${json(this.sql, input.requests)}::jsonb) AS pending(entry)
|
|
47
|
+
ON CONFLICT (project_id, object_type_id, primary_id, profile) DO UPDATE SET
|
|
48
|
+
id = EXCLUDED.id, batch_id = EXCLUDED.batch_id, request = EXCLUDED.request, status = 'pending',
|
|
49
|
+
available_at = EXCLUDED.available_at, dispatch_at = EXCLUDED.dispatch_at,
|
|
50
|
+
values_json = NULL, error = NULL
|
|
51
|
+
`
|
|
52
|
+
}
|
|
53
|
+
async complete(input: Parameters<OntologyVectorIndexingStorage["complete"]>[0]) {
|
|
54
|
+
this.assertSession(input.session, input.projectId)
|
|
55
|
+
if (!input.entries.length) return
|
|
56
|
+
await this.sql`
|
|
57
|
+
DELETE FROM object_vector_indexing AS work
|
|
58
|
+
USING jsonb_array_elements(${json(this.sql, input.entries)}::jsonb) AS completed(entry)
|
|
59
|
+
WHERE work.project_id = ${input.projectId}
|
|
60
|
+
AND work.object_type_id = entry#>>'{ref,objectTypeId}'
|
|
61
|
+
AND work.primary_id = entry#>>'{ref,primaryId}'
|
|
62
|
+
AND work.profile = entry->>'profile'
|
|
63
|
+
AND work.request->>'configuration' = entry->>'configuration'
|
|
64
|
+
AND work.request->>'sourceFingerprint' = entry->>'sourceFingerprint'
|
|
65
|
+
`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async dispatched(input: Parameters<OntologyVectorIndexingStorage["dispatched"]>[0]) {
|
|
69
|
+
if (!input.ids.length) return
|
|
70
|
+
await this.run(async (sql) => {
|
|
71
|
+
await sql`UPDATE object_vector_indexing SET dispatch_at = ${input.nextDispatchAt} WHERE project_id = ${input.projectId} AND id = ANY(${sql.array([...input.ids])}::text[])`
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
async get(input: Parameters<OntologyVectorIndexingStorage["get"]>[0]) {
|
|
75
|
+
return this.run(async (sql) => {
|
|
76
|
+
const rows = await sql<
|
|
77
|
+
WorkRow[]
|
|
78
|
+
>`SELECT batch_id, request, status, available_at, values_json, error FROM object_vector_indexing WHERE project_id = ${input.projectId} AND id = ${input.id}`
|
|
79
|
+
return rows[0] ? decode(rows[0]) : null
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
async getBatch(input: Parameters<OntologyVectorIndexingStorage["getBatch"]>[0]) {
|
|
83
|
+
return this.run(async (sql) =>
|
|
84
|
+
(
|
|
85
|
+
await sql<WorkRow[]>`
|
|
86
|
+
SELECT batch_id, request, status, available_at, values_json, error
|
|
87
|
+
FROM object_vector_indexing WHERE project_id = ${input.projectId}
|
|
88
|
+
AND batch_id = ${input.batchId} ORDER BY id`
|
|
89
|
+
).map(decode)
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async getBatchMember(input: Parameters<OntologyVectorIndexingStorage["getBatchMember"]>[0]) {
|
|
94
|
+
return this.run(async (sql) => {
|
|
95
|
+
const rows = await sql<WorkRow[]>`
|
|
96
|
+
SELECT batch_id, request, status, available_at, values_json, error
|
|
97
|
+
FROM object_vector_indexing WHERE project_id = ${input.projectId}
|
|
98
|
+
AND object_type_id = ${input.ref.objectTypeId} AND primary_id = ${input.ref.primaryId}
|
|
99
|
+
AND profile = ${input.profile} AND batch_id = ${input.batchId}`
|
|
100
|
+
return rows[0] ? decode(rows[0]) : null
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async updateBatch(input: Parameters<OntologyVectorIndexingStorage["updateBatch"]>[0]) {
|
|
105
|
+
if (!input.updates.length) return true
|
|
106
|
+
return this.run(async (sql) => {
|
|
107
|
+
const rows = await sql<{ id: string }[]>`
|
|
108
|
+
WITH changes AS MATERIALIZED (
|
|
109
|
+
SELECT item FROM jsonb_array_elements(${json(sql, input.updates)}::jsonb) AS entries(item)
|
|
110
|
+
), locked AS MATERIALIZED (
|
|
111
|
+
SELECT work.id FROM object_vector_indexing AS work
|
|
112
|
+
JOIN changes ON work.id = item->>'id' AND work.status = item->>'expectedStatus'
|
|
113
|
+
WHERE work.project_id = ${input.projectId} ORDER BY work.id FOR UPDATE OF work
|
|
114
|
+
)
|
|
115
|
+
UPDATE object_vector_indexing AS work SET
|
|
116
|
+
status = changes.item->>'status', available_at = (changes.item->>'availableAt')::timestamptz,
|
|
117
|
+
values_json = COALESCE(changes.item->'values', work.values_json),
|
|
118
|
+
error = (changes.item->'error')::text
|
|
119
|
+
FROM changes, locked
|
|
120
|
+
WHERE work.project_id = ${input.projectId} AND work.id = locked.id
|
|
121
|
+
AND work.id = changes.item->>'id'
|
|
122
|
+
AND (${!input.requireAll} OR (SELECT count(*) FROM locked) = ${input.updates.length})
|
|
123
|
+
RETURNING work.id`
|
|
124
|
+
return !input.requireAll || rows.length === input.updates.length
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async listDue(input: Parameters<OntologyVectorIndexingStorage["listDue"]>[0]) {
|
|
129
|
+
return this.run(async (sql) =>
|
|
130
|
+
(
|
|
131
|
+
await sql<
|
|
132
|
+
WorkRow[]
|
|
133
|
+
>`SELECT batch_id, request, status, available_at, NULL AS values_json, error FROM object_vector_indexing WHERE project_id = ${input.projectId} AND status <> 'failed' AND dispatch_at <= ${input.now} ORDER BY dispatch_at, id LIMIT ${input.limit}`
|
|
134
|
+
).map(decode)
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
async update(input: Parameters<OntologyVectorIndexingStorage["update"]>[0]) {
|
|
138
|
+
return this.run(
|
|
139
|
+
async (sql) =>
|
|
140
|
+
(
|
|
141
|
+
await sql`
|
|
142
|
+
UPDATE object_vector_indexing SET status = ${input.status}, available_at = ${input.availableAt},
|
|
143
|
+
values_json = COALESCE(${input.values ? json(sql, input.values) : null}, values_json), error = ${input.error ? JSON.stringify(input.error) : null}
|
|
144
|
+
WHERE project_id = ${input.projectId} AND id = ${input.id} AND status = ${input.expectedStatus} RETURNING id
|
|
145
|
+
`
|
|
146
|
+
).length === 1
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
async remove(input: Parameters<OntologyVectorIndexingStorage["remove"]>[0]) {
|
|
150
|
+
await this.run(async (sql) => {
|
|
151
|
+
await sql`DELETE FROM object_vector_indexing WHERE project_id = ${input.projectId} AND id = ${input.id}`
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function decode(row: WorkRow): VectorIndexingWork {
|
|
156
|
+
return {
|
|
157
|
+
...row.request,
|
|
158
|
+
...(row.batch_id ? { batchId: row.batch_id } : {}),
|
|
159
|
+
status: row.status,
|
|
160
|
+
availableAt: new Date(row.available_at).toISOString(),
|
|
161
|
+
...(row.values_json ? { values: row.values_json } : {}),
|
|
162
|
+
...(row.error
|
|
163
|
+
? {
|
|
164
|
+
error: parseSixbFailure(row.error, VECTOR_INDEXING_FAILURE_CODES),
|
|
165
|
+
}
|
|
166
|
+
: {}),
|
|
167
|
+
}
|
|
168
|
+
}
|