@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.
Files changed (46) hide show
  1. package/README.md +34 -0
  2. package/dist/agents/rows.d.ts +3 -1
  3. package/dist/agents/rows.d.ts.map +1 -1
  4. package/dist/agents/threads.d.ts +3 -2
  5. package/dist/agents/threads.d.ts.map +1 -1
  6. package/dist/index.js +553 -54
  7. package/dist/index.js.map +16 -14
  8. package/dist/migrations.d.ts.map +1 -1
  9. package/dist/objects/query-compiler.d.ts +8 -1
  10. package/dist/objects/query-compiler.d.ts.map +1 -1
  11. package/dist/objects/read-scope.d.ts +1 -0
  12. package/dist/objects/read-scope.d.ts.map +1 -1
  13. package/dist/objects/reader.d.ts +1 -0
  14. package/dist/objects/reader.d.ts.map +1 -1
  15. package/dist/objects/rows.d.ts +1 -0
  16. package/dist/objects/rows.d.ts.map +1 -1
  17. package/dist/objects/storage.d.ts +1 -1
  18. package/dist/objects/storage.d.ts.map +1 -1
  19. package/dist/ontology-storage/index.d.ts +4 -0
  20. package/dist/ontology-storage/index.d.ts.map +1 -1
  21. package/dist/ontology-storage/materializations.d.ts +1 -0
  22. package/dist/ontology-storage/materializations.d.ts.map +1 -1
  23. package/dist/ontology-storage/vector-indexing.d.ts +20 -0
  24. package/dist/ontology-storage/vector-indexing.d.ts.map +1 -0
  25. package/dist/ontology-storage/vectors.d.ts +16 -0
  26. package/dist/ontology-storage/vectors.d.ts.map +1 -0
  27. package/dist/pg-ai-cost-storage.d.ts.map +1 -1
  28. package/package.json +3 -3
  29. package/src/agents/rows.ts +21 -0
  30. package/src/agents/threads.ts +47 -2
  31. package/src/migrations/041-agent-thread-workspaces.sql +2 -0
  32. package/src/migrations/042-vector-profiles.sql +28 -0
  33. package/src/migrations/043-vector-indexing.sql +24 -0
  34. package/src/migrations/044-vector-batching.sql +3 -0
  35. package/src/migrations/045-agent-thread-sandbox-state.sql +2 -0
  36. package/src/migrations.ts +14 -0
  37. package/src/objects/query-compiler.ts +102 -11
  38. package/src/objects/read-scope.ts +26 -25
  39. package/src/objects/reader.ts +33 -1
  40. package/src/objects/rows.ts +5 -1
  41. package/src/objects/storage.ts +20 -2
  42. package/src/ontology-storage/index.ts +15 -0
  43. package/src/ontology-storage/materializations.ts +13 -0
  44. package/src/ontology-storage/vector-indexing.ts +168 -0
  45. package/src/ontology-storage/vectors.ts +172 -0
  46. 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
- throw new Error(
267
- `[SixbPg] PostgreSQL object storage does not support query node '${query.kind}'`
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
- throw new Error(
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(`${columnExpression(field.column, qualifier)} ${field.direction.toUpperCase()}`)
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(column: "object_type_id" | "primary_id", qualifier?: string): string {
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
- stored.project_id,
183
- stored.object_type_id,
184
- stored.primary_id,
185
- COALESCE(
186
- (
187
- SELECT jsonb_object_agg(property.key, property.value)
188
- FROM jsonb_each(stored.properties) AS property(key, value)
189
- JOIN _sixb_scope_object_permissions AS permission
190
- ON permission.project_id = stored.project_id
191
- AND permission.object_type_id = stored.object_type_id
192
- AND permission.primary_id = stored.primary_id
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,
@@ -1,4 +1,5 @@
1
- import type { ObjectQuery } from "@sixb/core"
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[] = []
@@ -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 = rowToObject(row)
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
@@ -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 { PgStoreClient } from "../transactions"
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, vector search, and unresolved start.includeSubtypes remain planner fallback or rejection cases.",
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
+ }