@sixb/sqlite 0.1.2 → 0.1.4

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 (40) hide show
  1. package/dist/agents/agent-storage.d.ts +5 -3
  2. package/dist/agents/agent-storage.d.ts.map +1 -1
  3. package/dist/agents/checkpoints.d.ts +18 -0
  4. package/dist/agents/checkpoints.d.ts.map +1 -0
  5. package/dist/agents/index.d.ts +1 -0
  6. package/dist/agents/index.d.ts.map +1 -1
  7. package/dist/agents/messages.d.ts +2 -1
  8. package/dist/agents/messages.d.ts.map +1 -1
  9. package/dist/agents/rows.d.ts +18 -1
  10. package/dist/agents/rows.d.ts.map +1 -1
  11. package/dist/ai-cost-storage.d.ts +15 -0
  12. package/dist/ai-cost-storage.d.ts.map +1 -0
  13. package/dist/ai-usage-storage.d.ts +2 -1
  14. package/dist/ai-usage-storage.d.ts.map +1 -1
  15. package/dist/index.d.ts +4 -3
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +979 -74
  18. package/dist/index.js.map +15 -13
  19. package/dist/migrations.d.ts.map +1 -1
  20. package/dist/object-storage.d.ts +4 -3
  21. package/dist/object-storage.d.ts.map +1 -1
  22. package/dist/ontology-storage/materialization-state.d.ts.map +1 -1
  23. package/dist/ontology-storage/materialization-writer.d.ts.map +1 -1
  24. package/package.json +3 -3
  25. package/src/agents/agent-storage.ts +6 -3
  26. package/src/agents/checkpoints.ts +205 -0
  27. package/src/agents/index.ts +1 -0
  28. package/src/agents/messages.ts +59 -0
  29. package/src/agents/rows.ts +44 -0
  30. package/src/ai-cost-storage.ts +744 -0
  31. package/src/ai-usage-storage.ts +19 -0
  32. package/src/index.ts +8 -2
  33. package/src/migrations/026-ai-cost-accounting.sql +57 -0
  34. package/src/migrations/027-agent-context-checkpoints.sql +28 -0
  35. package/src/migrations/028-object-override-edit-times.sql +3 -0
  36. package/src/migrations.ts +10 -0
  37. package/src/object-query-compiler.ts +50 -0
  38. package/src/object-storage.ts +138 -34
  39. package/src/ontology-storage/materialization-state.ts +2 -0
  40. package/src/ontology-storage/materialization-writer.ts +6 -3
@@ -7,6 +7,7 @@ import {
7
7
  AiUsageStorageError,
8
8
  aggregateAiModelCallUsage,
9
9
  assertAiUsageExecutionId,
10
+ type GetLatestAiModelCallForExecutionInput,
10
11
  normalizeAiModelCallRecord,
11
12
  type RecordAiModelCallInput,
12
13
  type RecordAiModelCallResult,
@@ -84,6 +85,24 @@ export class SqliteAiUsageStorage implements AiUsageStorage {
84
85
  return summary!
85
86
  }
86
87
 
88
+ async getLatestForExecution(
89
+ input: GetLatestAiModelCallForExecutionInput
90
+ ): Promise<AiModelCallUsageRecord | null> {
91
+ assertNonBlankProjectId(input.projectId)
92
+ assertAiUsageExecutionId(input.executionId)
93
+ const row = this.connection.db
94
+ .query(
95
+ `
96
+ SELECT * FROM ai_model_call_usage
97
+ WHERE project_id = ? AND execution_id = ?
98
+ ORDER BY occurred_at DESC, id DESC
99
+ LIMIT 1
100
+ `
101
+ )
102
+ .get(input.projectId, input.executionId) as AiUsageRow | null
103
+ return row ? this.rowToRecord(row) : null
104
+ }
105
+
87
106
  async summarizeExecutions(
88
107
  input: SummarizeAiUsageExecutionsInput
89
108
  ): Promise<readonly AiUsageExecutionSummary[]> {
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  createWorkflowRunOperationScope,
20
20
  } from "@sixb/core/internal/storage-operation-scope"
21
21
  import {
22
+ type AiCostStorage,
22
23
  type AiUsageStorage,
23
24
  createTransactionStorageProxy,
24
25
  StorageTransactionError,
@@ -26,6 +27,7 @@ import {
26
27
  } from "@sixb/core/storage"
27
28
  import { SqliteActionRunStorage } from "./action-run-storage"
28
29
  import { SqliteAgentStorage } from "./agents"
30
+ import { SqliteAiCostStorage } from "./ai-cost-storage"
29
31
  import { SqliteAiUsageStorage } from "./ai-usage-storage"
30
32
  import { SqliteAuthStorage } from "./auth-storage"
31
33
  import { SqliteConnectorConnectionStorage } from "./connector-connection-storage"
@@ -61,8 +63,8 @@ export interface SqliteStorageOptions {
61
63
  /**
62
64
  * SQLite storage provider for Sixb.
63
65
  *
64
- * Bundles object, timeseries, auth, execution, AI usage, sync run, pipeline run, projection run,
65
- * workflow run, webhook run, and connector connection storage backed by SQLite.
66
+ * Bundles object, timeseries, auth, execution, AI accounting, sync run, pipeline run, projection
67
+ * run, workflow run, webhook run, and connector connection storage backed by SQLite.
66
68
  *
67
69
  * Usage:
68
70
  * ```ts
@@ -81,6 +83,7 @@ export class SqliteStorage implements MigrationCapableStorage {
81
83
  readonly executions: SqliteExecutionStorage
82
84
  readonly agents: SqliteAgentStorage
83
85
  readonly aiUsage: AiUsageStorage
86
+ readonly aiCosts: AiCostStorage
84
87
  readonly actionRuns: SqliteActionRunStorage
85
88
  readonly pipelineRuns: SqlitePipelineRunStorage
86
89
  readonly syncRuns: SqliteSyncRunStorage
@@ -143,6 +146,7 @@ export class SqliteStorage implements MigrationCapableStorage {
143
146
  this.executions = createOperationScopedFacade(stores.executions, scope)
144
147
  this.agents = createAgentOperationScope(stores.agents, scope)
145
148
  this.aiUsage = createOperationScopedFacade(stores.aiUsage, scope)
149
+ this.aiCosts = createOperationScopedFacade(stores.aiCosts, scope)
146
150
  this.actionRuns = createOperationScopedFacade(stores.actionRuns, scope)
147
151
  this.pipelineRuns = createOperationScopedFacade(stores.pipelineRuns, scope)
148
152
  this.timeseries = createOperationScopedFacade(readTimeseries, readScope)
@@ -298,6 +302,7 @@ function createSqliteStores(
298
302
  executions,
299
303
  agents: new SqliteAgentStorage({ connection, executions }),
300
304
  aiUsage: new SqliteAiUsageStorage({ connection }),
305
+ aiCosts: new SqliteAiCostStorage(connection),
301
306
  actionRuns: new SqliteActionRunStorage({ connection, executions }),
302
307
  pipelineRuns: new SqlitePipelineRunStorage({ connection, executions }),
303
308
  timeseries: new SqliteTimeseriesStorage({ connection }),
@@ -318,6 +323,7 @@ interface SqliteStoreSet {
318
323
  readonly executions: SqliteExecutionStorage
319
324
  readonly agents: SqliteAgentStorage
320
325
  readonly aiUsage: SqliteAiUsageStorage
326
+ readonly aiCosts: SqliteAiCostStorage
321
327
  readonly actionRuns: SqliteActionRunStorage
322
328
  readonly pipelineRuns: SqlitePipelineRunStorage
323
329
  readonly syncRuns: SqliteSyncRunStorage
@@ -0,0 +1,57 @@
1
+ CREATE TABLE ai_model_call_valuations (
2
+ project_id TEXT NOT NULL CHECK (length(trim(project_id)) > 0),
3
+ usage_record_id TEXT NOT NULL CHECK (length(trim(usage_record_id)) > 0),
4
+ status TEXT NOT NULL CHECK (status IN ('rated', 'unpriceable')),
5
+ provider_id TEXT CHECK (provider_id IS NULL OR length(trim(provider_id)) > 0),
6
+ model_id TEXT CHECK (model_id IS NULL OR length(trim(model_id)) > 0),
7
+ currency TEXT CHECK (
8
+ currency IS NULL OR (length(currency) = 3 AND currency GLOB '[A-Z][A-Z][A-Z]')
9
+ ),
10
+ amount_nanos INTEGER CHECK (
11
+ amount_nanos IS NULL OR (typeof(amount_nanos) = 'integer' AND amount_nanos >= 0)
12
+ ),
13
+ reason TEXT CHECK (
14
+ reason IS NULL OR reason IN (
15
+ 'missingBillingIdentity',
16
+ 'missingCatalogEntry',
17
+ 'missingUsageMeter',
18
+ 'unsupportedPricingDimension',
19
+ 'invalidUsageForFormula'
20
+ )
21
+ ),
22
+ details TEXT NOT NULL CHECK (json_valid(details) AND json_type(details) = 'object'),
23
+ rated_at TEXT NOT NULL CHECK (length(trim(rated_at)) > 0),
24
+ PRIMARY KEY (project_id, usage_record_id),
25
+ FOREIGN KEY (project_id, usage_record_id)
26
+ REFERENCES ai_model_call_usage(project_id, id)
27
+ ON DELETE RESTRICT,
28
+ CHECK (
29
+ (
30
+ status = 'rated'
31
+ AND provider_id IS NOT NULL
32
+ AND model_id IS NOT NULL
33
+ AND currency IS NOT NULL
34
+ AND amount_nanos IS NOT NULL
35
+ AND reason IS NULL
36
+ )
37
+ OR
38
+ (
39
+ status = 'unpriceable'
40
+ AND currency IS NULL
41
+ AND amount_nanos IS NULL
42
+ AND reason IS NOT NULL
43
+ AND (
44
+ (reason = 'missingBillingIdentity' AND provider_id IS NULL AND model_id IS NULL)
45
+ OR
46
+ (reason <> 'missingBillingIdentity' AND provider_id IS NOT NULL AND model_id IS NOT NULL)
47
+ )
48
+ )
49
+ )
50
+ );
51
+
52
+ CREATE INDEX idx_ai_model_call_usage_provider_model_time
53
+ ON ai_model_call_usage (project_id, provider_id, requested_model_id, occurred_at, id);
54
+ CREATE INDEX idx_ai_model_call_usage_model_time
55
+ ON ai_model_call_usage (project_id, requested_model_id, occurred_at, id);
56
+ CREATE INDEX idx_ai_model_call_valuations_summary
57
+ ON ai_model_call_valuations (project_id, status, currency, usage_record_id);
@@ -0,0 +1,28 @@
1
+ CREATE TABLE agent_context_checkpoints (
2
+ project_id TEXT NOT NULL,
3
+ id TEXT NOT NULL,
4
+ thread_id TEXT NOT NULL,
5
+ created_by_run_id TEXT NOT NULL,
6
+ previous_checkpoint_id TEXT,
7
+ reason TEXT NOT NULL CHECK (reason IN ('threshold', 'overflow')),
8
+ summary TEXT NOT NULL CHECK (length(trim(summary)) > 0),
9
+ summary_format_version INTEGER NOT NULL CHECK (summary_format_version = 1),
10
+ summarized_through_seq INTEGER NOT NULL CHECK (summarized_through_seq >= 1),
11
+ observed_head_seq INTEGER NOT NULL,
12
+ estimated_input_tokens_before INTEGER NOT NULL CHECK (estimated_input_tokens_before >= 0),
13
+ estimated_input_tokens_after INTEGER NOT NULL CHECK (estimated_input_tokens_after >= 0),
14
+ summary_model_id TEXT NOT NULL CHECK (length(trim(summary_model_id)) > 0),
15
+ created_at TEXT NOT NULL,
16
+ PRIMARY KEY (project_id, id),
17
+ UNIQUE (project_id, created_by_run_id),
18
+ FOREIGN KEY (project_id, thread_id)
19
+ REFERENCES agent_threads(project_id, id) ON DELETE CASCADE,
20
+ FOREIGN KEY (project_id, created_by_run_id)
21
+ REFERENCES agent_runs(project_id, id) ON DELETE RESTRICT,
22
+ FOREIGN KEY (project_id, previous_checkpoint_id)
23
+ REFERENCES agent_context_checkpoints(project_id, id) ON DELETE RESTRICT,
24
+ CHECK (summarized_through_seq < observed_head_seq)
25
+ );
26
+
27
+ CREATE INDEX idx_agent_context_checkpoints_thread_latest
28
+ ON agent_context_checkpoints(project_id, thread_id, summarized_through_seq DESC, created_at DESC, id DESC);
@@ -0,0 +1,3 @@
1
+ ALTER TABLE ontology_object_overrides
2
+ ADD COLUMN edited_at TEXT NOT NULL DEFAULT '{}'
3
+ CHECK (json_valid(edited_at) AND json_type(edited_at) = 'object');
package/src/migrations.ts CHANGED
@@ -69,6 +69,13 @@ import ontologyCommitExecutionsSql from "./migrations/024-ontology-commit-execut
69
69
  import connectorConnectionsSql from "./migrations/025-connector-connections.sql" with {
70
70
  type: "text",
71
71
  }
72
+ import aiCostAccountingSql from "./migrations/026-ai-cost-accounting.sql" with { type: "text" }
73
+ import agentContextCheckpointsSql from "./migrations/027-agent-context-checkpoints.sql" with {
74
+ type: "text",
75
+ }
76
+ import objectOverrideEditTimesSql from "./migrations/028-object-override-edit-times.sql" with {
77
+ type: "text",
78
+ }
72
79
 
73
80
  const MIGRATIONS_TABLE_SQL = `
74
81
  CREATE TABLE IF NOT EXISTS sixb_migrations (
@@ -149,6 +156,9 @@ export const sqliteStorageMigrations = defineMigrations({
149
156
  { checksum: checksum(ontologyCommitExecutionsSql) }
150
157
  ),
151
158
  sqliteSql("025-connector-connections", connectorConnectionsSql),
159
+ sqliteSql("026-ai-cost-accounting", aiCostAccountingSql),
160
+ sqliteSql("027-agent-context-checkpoints", agentContextCheckpointsSql),
161
+ sqliteSql("028-object-override-edit-times", objectOverrideEditTimesSql),
152
162
  ],
153
163
  })
154
164
 
@@ -98,6 +98,8 @@ function compileObjectQueryInternal(
98
98
  switch (query.kind) {
99
99
  case "start":
100
100
  return compileStart(projectId, query)
101
+ case "refs":
102
+ return compileRefs(projectId, query)
101
103
  case "filter":
102
104
  return compileFilter(projectId, query.input, query.predicate)
103
105
  case "sort":
@@ -162,6 +164,54 @@ function compileStart(
162
164
  }
163
165
  }
164
166
 
167
+ function compileRefs(
168
+ projectId: string,
169
+ query: Extract<ObjectQuery, { kind: "refs" }>
170
+ ): CompiledObjectQuery {
171
+ if (query.refs.length === 0) {
172
+ throw new Error("[Sixb] SQLite object storage requires at least one ref")
173
+ }
174
+
175
+ const order = compileOrder(identityOrderFields())
176
+ const selectedOrder = compileOrder(identityOrderFields(), "selected")
177
+ const refsJson = JSON.stringify(query.refs)
178
+ const requested = `
179
+ SELECT DISTINCT
180
+ json_extract(ref.value, '$.objectTypeId') AS object_type_id,
181
+ json_extract(ref.value, '$.primaryId') AS primary_id
182
+ FROM json_each(?) AS ref
183
+ `
184
+ const sql = `
185
+ WITH requested AS (${requested})
186
+ SELECT selected.*, selected.properties AS _cursor_properties
187
+ FROM requested
188
+ JOIN objects AS selected
189
+ ON selected.project_id = ?
190
+ AND selected.object_type_id = requested.object_type_id
191
+ AND selected.primary_id = requested.primary_id
192
+ ORDER BY ${selectedOrder.sql}
193
+ `
194
+
195
+ return {
196
+ sql,
197
+ args: [refsJson, projectId, ...selectedOrder.args],
198
+ totalSql: `
199
+ WITH requested AS (${requested})
200
+ SELECT COUNT(*) AS total
201
+ FROM requested
202
+ JOIN objects AS selected
203
+ ON selected.project_id = ?
204
+ AND selected.object_type_id = requested.object_type_id
205
+ AND selected.primary_id = requested.primary_id
206
+ `,
207
+ totalArgs: [refsJson, projectId],
208
+ order,
209
+ hasMore: () => false,
210
+ trimRows: identityRows,
211
+ nextPageToken: () => undefined,
212
+ }
213
+ }
214
+
165
215
  function compileFilter(
166
216
  projectId: string,
167
217
  inputQuery: ObjectQuery,
@@ -9,16 +9,21 @@ import type {
9
9
  ExpandedObjectRow,
10
10
  FacetObjectsInput,
11
11
  FacetObjectsResult,
12
+ LinkBatchKey,
12
13
  LinkDirection,
14
+ ObjectBatchKey,
13
15
  ObjectFacetRequest,
14
16
  ObjectLinkRow,
15
17
  ObjectQueryCapabilities,
16
18
  ObjectRow,
17
19
  ObjectRowLinks,
18
20
  ObjectStorage,
21
+ QueryObjectLinksInput,
22
+ QueryObjectLinksResult,
19
23
  QueryObjectsInput,
20
24
  QueryObjectsResult,
21
25
  } from "@sixb/core/storage"
26
+ import { linkBatchKey, objectBatchKey } from "@sixb/core/storage"
22
27
  import { installFreshSqliteSchema } from "./migrations"
23
28
  import { type CompiledObjectQuery, compileObjectQuery } from "./object-query-compiler"
24
29
  import {
@@ -41,6 +46,7 @@ const SQLITE_OBJECT_QUERY_CAPABILITIES: ObjectQueryCapabilities = {
41
46
  facetObjects: true,
42
47
  nodes: {
43
48
  start: true,
49
+ refs: true,
44
50
  filter: true,
45
51
  text: true,
46
52
  sort: true,
@@ -92,7 +98,7 @@ const SQLITE_OBJECT_QUERY_CAPABILITIES: ObjectQueryCapabilities = {
92
98
  stablePageTokens: true,
93
99
  },
94
100
  notes: [
95
- "SQLite object query pushdown supports start/filter/text/sort/limit/page/traverse/set/project/expand over JSON properties and object links.",
101
+ "SQLite object query pushdown supports start/refs/filter/text/sort/limit/page/traverse/set/project/expand over JSON properties and object links.",
96
102
  "expand hydrates linked objects in-database (top-N per parent via row_number() + json_group_array); core resolves each expansion's cardinality before pushdown, and a mixed/unresolved one stays on the fallback.",
97
103
  "Ordered decimal predicates and sorting use the bounded core fallback because SQLite has no native exact decimal type; canonical decimal equality remains pushdown-safe.",
98
104
  "Relevance sorting, vector search, and unresolved start.includeSubtypes remain planner fallback or rejection cases.",
@@ -219,22 +225,24 @@ export class SqliteObjectStorage implements ObjectStorage {
219
225
  async getByPrimaryIdBatch(params: {
220
226
  projectId: string
221
227
  items: readonly { objectTypeId: string; primaryId: string }[]
222
- }): Promise<Map<string, ObjectRow>> {
223
- const result = new Map<string, ObjectRow>()
228
+ }): Promise<Map<ObjectBatchKey, ObjectRow>> {
229
+ const result = new Map<ObjectBatchKey, ObjectRow>()
224
230
  if (params.items.length === 0) return result
225
231
 
226
- const stmt = this.db.query(
227
- "SELECT * FROM objects WHERE project_id = ? AND object_type_id = ? AND primary_id = ?"
228
- )
229
- for (const item of params.items) {
230
- const row = stmt.get(
231
- params.projectId,
232
- item.objectTypeId,
233
- item.primaryId
234
- ) as DatabaseRow | null
235
- if (row) {
236
- result.set(`${item.objectTypeId}:${item.primaryId}`, this.rowToObject(row))
237
- }
232
+ const rows = this.db
233
+ .query(
234
+ `
235
+ SELECT object.*
236
+ FROM json_each(?) AS requested
237
+ JOIN objects AS object
238
+ ON object.project_id = ?
239
+ AND object.object_type_id = json_extract(requested.value, '$.objectTypeId')
240
+ AND object.primary_id = json_extract(requested.value, '$.primaryId')
241
+ `
242
+ )
243
+ .all(JSON.stringify(params.items), params.projectId) as DatabaseRow[]
244
+ for (const row of rows) {
245
+ result.set(objectBatchKey(row.object_type_id, row.primary_id), this.rowToObject(row))
238
246
  }
239
247
  return result
240
248
  }
@@ -242,8 +250,8 @@ export class SqliteObjectStorage implements ObjectStorage {
242
250
  async listLinksBatch(params: {
243
251
  projectId: string
244
252
  items: readonly { objectTypeId: string; objectId: string; linkId: string }[]
245
- }): Promise<Map<string, ObjectLinkRow[]>> {
246
- const result = new Map<string, ObjectLinkRow[]>()
253
+ }): Promise<Map<LinkBatchKey, ObjectLinkRow[]>> {
254
+ const result = new Map<LinkBatchKey, ObjectLinkRow[]>()
247
255
  if (params.items.length === 0) return result
248
256
 
249
257
  const stmt = this.db.query(
@@ -258,7 +266,7 @@ export class SqliteObjectStorage implements ObjectStorage {
258
266
  ) as LinkDatabaseRow[]
259
267
  if (rows.length > 0) {
260
268
  result.set(
261
- `${item.objectTypeId}:${item.objectId}:${item.linkId}`,
269
+ linkBatchKey(item.objectTypeId, item.objectId, item.linkId),
262
270
  rows.map((r) => this.rowToLink(r))
263
271
  )
264
272
  }
@@ -266,26 +274,116 @@ export class SqliteObjectStorage implements ObjectStorage {
266
274
  return result
267
275
  }
268
276
 
277
+ async queryLinks(params: QueryObjectLinksInput): Promise<QueryObjectLinksResult> {
278
+ assertLinkQueryLimit(params.limit)
279
+ if (params.objectRefs.length === 0 || params.endpointObjectTypeIds?.length === 0) {
280
+ return { links: [], hasMore: false }
281
+ }
282
+
283
+ const sourceJoin = `
284
+ SELECT link.*
285
+ FROM links AS link
286
+ JOIN requested
287
+ ON requested.object_type_id = link.source_type_id
288
+ AND requested.object_id = link.source_id
289
+ WHERE link.project_id = ?
290
+ `
291
+ const targetJoin = `
292
+ SELECT link.*
293
+ FROM links AS link
294
+ JOIN requested
295
+ ON requested.object_type_id = link.target_type_id
296
+ AND requested.object_id = link.target_id
297
+ WHERE link.project_id = ?
298
+ `
299
+ const incidentSql =
300
+ params.direction === "outgoing"
301
+ ? sourceJoin
302
+ : params.direction === "incoming"
303
+ ? targetJoin
304
+ : `${sourceJoin} UNION ${targetJoin}`
305
+ const args: (string | number)[] = [JSON.stringify(params.objectRefs), params.projectId]
306
+ if (params.direction === "both") args.push(params.projectId)
307
+
308
+ const predicates: string[] = []
309
+ if (params.linkId !== undefined) {
310
+ predicates.push("link_id = ?")
311
+ args.push(params.linkId)
312
+ }
313
+ if (params.endpointObjectTypeIds !== undefined) {
314
+ const allowedTypes = JSON.stringify([...new Set(params.endpointObjectTypeIds)])
315
+ predicates.push(
316
+ "source_type_id IN (SELECT value FROM json_each(?))",
317
+ "target_type_id IN (SELECT value FROM json_each(?))"
318
+ )
319
+ args.push(allowedTypes, allowedTypes)
320
+ }
321
+ if (params.after) {
322
+ predicates.push(
323
+ "(source_type_id, source_id, link_id, target_type_id, target_id) > (?, ?, ?, ?, ?)"
324
+ )
325
+ args.push(...params.after)
326
+ }
327
+ args.push(params.limit + 1)
328
+
329
+ const whereSql = predicates.length > 0 ? `WHERE ${predicates.join(" AND ")}` : ""
330
+ const rows = this.db
331
+ .query(
332
+ `
333
+ WITH requested AS (
334
+ SELECT DISTINCT
335
+ json_extract(value, '$.objectTypeId') AS object_type_id,
336
+ json_extract(value, '$.primaryId') AS object_id
337
+ FROM json_each(?)
338
+ ), incident AS (${incidentSql})
339
+ SELECT *
340
+ FROM incident
341
+ ${whereSql}
342
+ ORDER BY source_type_id, source_id, link_id, target_type_id, target_id
343
+ LIMIT ?
344
+ `
345
+ )
346
+ .all(...args) as LinkDatabaseRow[]
347
+
348
+ return {
349
+ links: rows.slice(0, params.limit).map((row) => this.rowToLink(row)),
350
+ hasMore: rows.length > params.limit,
351
+ }
352
+ }
353
+
269
354
  async listIncidentLinksBatch(params: {
270
355
  projectId: string
271
356
  items: readonly { objectTypeId: string; objectId: string }[]
272
357
  }): Promise<readonly ObjectLinkRow[]> {
273
- const deduped = new Map<string, ObjectLinkRow>()
274
- for (const item of params.items) {
275
- const rows = await this.listLinks({
276
- projectId: params.projectId,
277
- objectTypeId: item.objectTypeId,
278
- objectId: item.objectId,
279
- direction: "both",
280
- })
281
- for (const row of rows) {
282
- deduped.set(
283
- `${row.sourceTypeId}:${row.sourceId}:${row.linkId}:${row.targetTypeId}:${row.targetId}`,
284
- row
285
- )
286
- }
287
- }
288
- return [...deduped.values()]
358
+ if (params.items.length === 0) return []
359
+
360
+ const rows = this.db
361
+ .query(
362
+ `
363
+ WITH requested AS (
364
+ SELECT
365
+ json_extract(value, '$.objectTypeId') AS object_type_id,
366
+ json_extract(value, '$.objectId') AS object_id
367
+ FROM json_each(?)
368
+ )
369
+ SELECT link.*
370
+ FROM links AS link
371
+ JOIN requested
372
+ ON requested.object_type_id = link.source_type_id
373
+ AND requested.object_id = link.source_id
374
+ WHERE link.project_id = ?
375
+ UNION
376
+ SELECT link.*
377
+ FROM links AS link
378
+ JOIN requested
379
+ ON requested.object_type_id = link.target_type_id
380
+ AND requested.object_id = link.target_id
381
+ WHERE link.project_id = ?
382
+ `
383
+ )
384
+ .all(JSON.stringify(params.items), params.projectId, params.projectId) as LinkDatabaseRow[]
385
+
386
+ return rows.map((row) => this.rowToLink(row))
289
387
  }
290
388
 
291
389
  async listByPrimaryIdPage(params: {
@@ -457,6 +555,12 @@ function assertReconciliationPageLimit(limit: number): void {
457
555
  }
458
556
  }
459
557
 
558
+ function assertLinkQueryLimit(limit: number): void {
559
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
560
+ throw new Error("Object link query limit must be a positive safe integer.")
561
+ }
562
+ }
563
+
460
564
  function readTotal(db: Database, compiled: CompiledObjectQuery): number {
461
565
  const row = db.query(compiled.totalSql).get(...compiled.totalArgs) as { total: number }
462
566
  return row.total
@@ -86,6 +86,7 @@ interface LinkScopeRow extends EffectiveLinkRow {
86
86
  interface ObjectOverrideRow extends SqliteStoredOverrideRow {
87
87
  readonly object_type_id: string
88
88
  readonly primary_id: string
89
+ readonly edited_at: string
89
90
  }
90
91
 
91
92
  interface LinkOverrideRow extends SqliteStoredOverrideRow {
@@ -1028,6 +1029,7 @@ function storedObjectOverride(row: ObjectOverrideRow): StoredObjectOverride {
1028
1029
  return {
1029
1030
  ref,
1030
1031
  value: parseJson<StoredObjectOverride["value"]>(row.value),
1032
+ editedAt: parseJson<Readonly<Record<string, string>>>(row.edited_at),
1031
1033
  lastCommitId: row.last_commit_id,
1032
1034
  updatedAt: row.updated_at,
1033
1035
  }
@@ -41,14 +41,16 @@ export class SqliteMaterializationWriter {
41
41
  this.db
42
42
  .query(
43
43
  `INSERT INTO ontology_object_overrides (
44
- project_id, object_type_id, primary_id, value, last_commit_id, updated_at
45
- ) VALUES (?, ?, ?, json(?), ?, ?)`
44
+ project_id, object_type_id, primary_id, value, edited_at,
45
+ last_commit_id, updated_at
46
+ ) VALUES (?, ?, ?, json(?), json(?), ?, ?)`
46
47
  )
47
48
  .run(
48
49
  projectId,
49
50
  item.ref.objectTypeId,
50
51
  item.ref.primaryId,
51
52
  canonicalJson(item.value),
53
+ canonicalJson(item.editedAt),
52
54
  item.lastCommitId,
53
55
  item.updatedAt
54
56
  )
@@ -64,12 +66,13 @@ export class SqliteMaterializationWriter {
64
66
  this.db
65
67
  .query(
66
68
  `UPDATE ontology_object_overrides
67
- SET value = json(?), last_commit_id = ?, updated_at = ?
69
+ SET value = json(?), edited_at = json(?), last_commit_id = ?, updated_at = ?
68
70
  WHERE project_id = ? AND object_type_id = ? AND primary_id = ?
69
71
  AND last_commit_id = ?`
70
72
  )
71
73
  .run(
72
74
  canonicalJson(item.value),
75
+ canonicalJson(item.editedAt),
73
76
  item.lastCommitId,
74
77
  item.updatedAt,
75
78
  projectId,