@sixb/sqlite 0.1.1 → 0.1.2

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 (108) hide show
  1. package/README.md +18 -13
  2. package/dist/action-run-storage.d.ts +5 -2
  3. package/dist/action-run-storage.d.ts.map +1 -1
  4. package/dist/agents/agent-storage.d.ts +4 -2
  5. package/dist/agents/agent-storage.d.ts.map +1 -1
  6. package/dist/agents/rows.d.ts +3 -10
  7. package/dist/agents/rows.d.ts.map +1 -1
  8. package/dist/agents/runs.d.ts +3 -2
  9. package/dist/agents/runs.d.ts.map +1 -1
  10. package/dist/ai-usage-storage.d.ts +22 -0
  11. package/dist/ai-usage-storage.d.ts.map +1 -0
  12. package/dist/auth-storage/service-accounts.d.ts.map +1 -1
  13. package/dist/auth-storage/shared.d.ts +1 -10
  14. package/dist/auth-storage/shared.d.ts.map +1 -1
  15. package/dist/connector-connection-storage.d.ts +9 -0
  16. package/dist/connector-connection-storage.d.ts.map +1 -0
  17. package/dist/execution-storage.d.ts +14 -0
  18. package/dist/execution-storage.d.ts.map +1 -0
  19. package/dist/index.d.ts +13 -9
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +3862 -1042
  22. package/dist/index.js.map +30 -27
  23. package/dist/latest-run-query.d.ts.map +1 -1
  24. package/dist/migrations.d.ts +0 -1
  25. package/dist/migrations.d.ts.map +1 -1
  26. package/dist/ontology-storage/materialization-session.d.ts +50 -8
  27. package/dist/ontology-storage/materialization-session.d.ts.map +1 -1
  28. package/dist/ontology-storage/materialization-state.d.ts +4 -5
  29. package/dist/ontology-storage/materialization-state.d.ts.map +1 -1
  30. package/dist/ontology-storage/materialization-writer.d.ts.map +1 -1
  31. package/dist/ontology-storage/materializations.d.ts +0 -2
  32. package/dist/ontology-storage/materializations.d.ts.map +1 -1
  33. package/dist/ontology-storage/outbox.d.ts.map +1 -1
  34. package/dist/ontology-storage/shared.d.ts +3 -3
  35. package/dist/ontology-storage/shared.d.ts.map +1 -1
  36. package/dist/ontology-storage/sources.d.ts.map +1 -1
  37. package/dist/pipeline-run-storage.d.ts +7 -2
  38. package/dist/pipeline-run-storage.d.ts.map +1 -1
  39. package/dist/projection-run-storage.d.ts +9 -2
  40. package/dist/projection-run-storage.d.ts.map +1 -1
  41. package/dist/run-list-query.d.ts +1 -1
  42. package/dist/run-list-query.d.ts.map +1 -1
  43. package/dist/sync-run-storage.d.ts +7 -2
  44. package/dist/sync-run-storage.d.ts.map +1 -1
  45. package/dist/testing.d.ts +6 -0
  46. package/dist/testing.d.ts.map +1 -0
  47. package/dist/transactions.d.ts +3 -0
  48. package/dist/transactions.d.ts.map +1 -1
  49. package/dist/webhook-run-storage.d.ts +9 -2
  50. package/dist/webhook-run-storage.d.ts.map +1 -1
  51. package/dist/workflow-run-storage.d.ts +7 -3
  52. package/dist/workflow-run-storage.d.ts.map +1 -1
  53. package/package.json +3 -3
  54. package/src/action-run-storage.ts +51 -74
  55. package/src/agents/agent-storage.ts +5 -3
  56. package/src/agents/rows.ts +8 -40
  57. package/src/agents/runs.ts +27 -21
  58. package/src/ai-usage-storage.ts +324 -0
  59. package/src/auth-storage/service-accounts.ts +14 -21
  60. package/src/auth-storage/shared.ts +0 -22
  61. package/src/connector-connection-storage.ts +784 -0
  62. package/src/execution-storage.ts +158 -0
  63. package/src/index.ts +63 -22
  64. package/src/latest-run-query.ts +7 -1
  65. package/src/migrations/004-executions.sql +142 -0
  66. package/src/migrations/005-workflow-executions.sql +7 -0
  67. package/src/migrations/006-narrow-ontology-source-root-index.sql +4 -0
  68. package/src/migrations/007-split-overrides.sql +154 -0
  69. package/src/migrations/008-action-executions.sql +4 -0
  70. package/src/migrations/009-agent-executions.sql +115 -0
  71. package/src/migrations/010-ai-usage-accounting-foundation.sql +74 -0
  72. package/src/migrations/011-sync-failure-record.sql +22 -0
  73. package/src/migrations/012-pipeline-failure-record.sql +45 -0
  74. package/src/migrations/013-workflow-failure-record.sql +45 -0
  75. package/src/migrations/014-agent-failure-record.sql +59 -0
  76. package/src/migrations/015-projection-failure-record.sql +20 -0
  77. package/src/migrations/016-webhook-run-failure-record.sql +19 -0
  78. package/src/migrations/017-action-failure-record.sql +65 -0
  79. package/src/migrations/018-ontology-outbox-failure-record.sql +80 -0
  80. package/src/migrations/019-webhook-delivery-failure-record.sql +62 -0
  81. package/src/migrations/020-drop-run-usage-projections.sql +7 -0
  82. package/src/migrations/021-sync-pipeline-executions.sql +101 -0
  83. package/src/migrations/022-projection-executions.sql +244 -0
  84. package/src/migrations/023-webhook-executions.sql +51 -0
  85. package/src/migrations/024-ontology-commit-executions.sql +58 -0
  86. package/src/migrations/025-connector-connections.sql +257 -0
  87. package/src/migrations.ts +119 -4
  88. package/src/ontology-storage/materialization-session.ts +317 -143
  89. package/src/ontology-storage/materialization-state.ts +321 -112
  90. package/src/ontology-storage/materialization-writer.ts +142 -67
  91. package/src/ontology-storage/materializations.ts +67 -141
  92. package/src/ontology-storage/outbox.ts +10 -3
  93. package/src/ontology-storage/shared.ts +10 -4
  94. package/src/ontology-storage/sources.ts +4 -1
  95. package/src/pipeline-run-storage.ts +128 -43
  96. package/src/projection-run-storage.ts +209 -97
  97. package/src/run-list-query.ts +9 -4
  98. package/src/sync-run-storage.ts +118 -32
  99. package/src/testing.ts +20 -0
  100. package/src/transactions.ts +10 -3
  101. package/src/webhook-run-storage.ts +118 -74
  102. package/src/workflow-run-storage.ts +117 -131
  103. package/dist/validation.d.ts +0 -5
  104. package/dist/validation.d.ts.map +0 -1
  105. package/dist/webhook-delivery-storage.d.ts +0 -27
  106. package/dist/webhook-delivery-storage.d.ts.map +0 -1
  107. package/src/validation.ts +0 -30
  108. package/src/webhook-delivery-storage.ts +0 -272
@@ -1,8 +1,7 @@
1
1
  import type { Database } from "bun:sqlite"
2
2
  import {
3
- linkRefKey,
3
+ type EffectiveChangeCounts,
4
4
  MaterializationConflictError,
5
- objectRefKey,
6
5
  } from "@sixb/core/internal/materialization"
7
6
  import {
8
7
  correlateMaterializationChunk,
@@ -17,7 +16,6 @@ import type {
17
16
  MaterializationEventWorkRecord,
18
17
  MaterializationPlanChunk,
19
18
  MaterializationPlanHeader,
20
- MaterializationPlanWorkItem,
21
19
  MaterializationSession,
22
20
  MaterializationWorkPage,
23
21
  MaterializationWorkRecord,
@@ -25,7 +23,6 @@ import type {
25
23
  StreamMaterializationWorkInput,
26
24
  } from "@sixb/core/storage"
27
25
  import {
28
- type ReplacementIdentity,
29
26
  SQLITE_MATERIALIZATION_WORK_TABLE,
30
27
  SQLITE_REPLACEMENT_WORK_TABLE,
31
28
  } from "./materialization-state"
@@ -37,7 +34,53 @@ export interface SqliteOntologyTransactionContext {
37
34
  active: boolean
38
35
  }
39
36
 
40
- export class SqliteMaterializationSessionState extends ProviderMaterializationSessionState {}
37
+ interface WorkCursor {
38
+ readonly majorOrder: number
39
+ readonly minorOrder: number
40
+ readonly sortOne: string
41
+ readonly sortTwo: string
42
+ readonly recordKey: string
43
+ }
44
+
45
+ interface WorkDatabaseRow {
46
+ readonly record_key: string
47
+ readonly major_order: number
48
+ readonly minor_order: number
49
+ readonly sort_one: string
50
+ readonly sort_two: string
51
+ readonly payload: string
52
+ }
53
+
54
+ interface WorkSummary {
55
+ apply: number
56
+ cardinality: number
57
+ event: number
58
+ objectClassifications: number
59
+ linkClassifications: number
60
+ pointClassifications: number
61
+ objectsCreated: number
62
+ objectsUpdated: number
63
+ objectsDeleted: number
64
+ linksCreated: number
65
+ linksUpdated: number
66
+ linksDeleted: number
67
+ pointsCreated: number
68
+ pointsUpdated: number
69
+ latestObjectsChanged: number
70
+ }
71
+
72
+ export interface SqliteChunkSequenceProgress {
73
+ readonly appliedPlanCount: number
74
+ readonly appliedOutboxCount: number
75
+ readonly appliedPlanCursor: WorkCursor | null
76
+ readonly appliedEventCursor: WorkCursor | null
77
+ }
78
+
79
+ export class SqliteMaterializationSessionState extends ProviderMaterializationSessionState {
80
+ appliedPlanCursor: WorkCursor | null = null
81
+ appliedEventCursor: WorkCursor | null = null
82
+ readonly summary: WorkSummary = emptyWorkSummary()
83
+ }
41
84
 
42
85
  export class SqliteMaterializationSessions {
43
86
  private readonly sessions = new WeakMap<object, SqliteMaterializationSessionState>()
@@ -81,10 +124,14 @@ export class SqliteMaterializationSessions {
81
124
 
82
125
  release(session: SqliteMaterializationSessionState): void {
83
126
  if (!session.active) return
84
- this.deleteWork(session.id)
85
127
  session.active = false
86
128
  session.replacement = null
87
129
  this.live.delete(session)
130
+ // The normal transaction has one session. Dropping its transaction-local spool avoids an
131
+ // indexed DELETE over hundreds of thousands of rows and releases temp pages immediately.
132
+ // Preserve per-session deletion only when another live session still shares the tables.
133
+ if (this.live.size === 0) this.dropWorkTables()
134
+ else this.deleteWork(session.id)
88
135
  this.context?.materializations.complete(session.providerToken)
89
136
  }
90
137
 
@@ -95,16 +142,26 @@ export class SqliteMaterializationSessions {
95
142
  stage(input: StageMaterializationWorkInput): void {
96
143
  const session = this.require(input.session)
97
144
  const records = prepareMaterializationWork(session, input)
145
+ if (records.length === 0) return
98
146
  const insert = this.db.query(
99
147
  `
100
148
  INSERT INTO ${SQLITE_MATERIALIZATION_WORK_TABLE} (
101
149
  session_id, record_key, unique_key, kind, lane,
102
- major_order, minor_order, sort_one, sort_two, payload
103
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, json(?))
150
+ major_order, minor_order, sort_one, sort_two,
151
+ classification_entity_kind, classification_identity_key,
152
+ cardinality_view, cardinality_occupied, cardinality_source_type_id,
153
+ cardinality_source_primary_id, cardinality_link_id,
154
+ cardinality_target_type_id, cardinality_target_primary_id, payload
155
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, json(?))
104
156
  `
105
157
  )
158
+ this.db.run("SAVEPOINT sixb_ontology_stage_work")
159
+ let currentRecordKey: string | undefined
106
160
  try {
107
161
  for (const { record, uniqueKey, columns } of records) {
162
+ currentRecordKey = record.recordKey
163
+ const classification = record.kind === "classification" ? record : null
164
+ const cardinality = record.kind === "cardinality" ? record : null
108
165
  insert.run(
109
166
  session.id,
110
167
  record.recordKey,
@@ -115,15 +172,29 @@ export class SqliteMaterializationSessions {
115
172
  columns.minorOrder,
116
173
  columns.sortOne,
117
174
  columns.sortTwo,
175
+ classification?.entityKind ?? null,
176
+ classification?.identityKey ?? null,
177
+ cardinality?.view ?? null,
178
+ cardinality ? Number(cardinality.occupied) : null,
179
+ cardinality?.ref.source.objectTypeId ?? null,
180
+ cardinality?.ref.source.primaryId ?? null,
181
+ cardinality?.ref.linkId ?? null,
182
+ cardinality?.ref.target.objectTypeId ?? null,
183
+ cardinality?.ref.target.primaryId ?? null,
118
184
  canonicalJson(record)
119
185
  )
120
186
  }
187
+ this.db.run("RELEASE SAVEPOINT sixb_ontology_stage_work")
121
188
  } catch (error) {
189
+ this.db.run("ROLLBACK TO SAVEPOINT sixb_ontology_stage_work")
190
+ this.db.run("RELEASE SAVEPOINT sixb_ontology_stage_work")
122
191
  if (isSqliteConstraintError(error)) {
123
- throw duplicateWork(input.records[0]?.recordKey ?? "unknown")
192
+ throw duplicateWork(currentRecordKey)
124
193
  }
125
194
  throw error
126
195
  }
196
+ for (const prepared of records)
197
+ recordSummary(session.summary, prepared.record, prepared.columns)
127
198
  }
128
199
 
129
200
  async *stream(input: StreamMaterializationWorkInput): AsyncIterable<MaterializationWorkPage> {
@@ -137,26 +208,16 @@ export class SqliteMaterializationSessions {
137
208
  }
138
209
  stream.started = true
139
210
  session.workSealed = true
140
- let offset = 0
211
+ let cursor: WorkCursor | null = null
141
212
  while (true) {
142
213
  this.require(input.session)
143
- const rows = this.db
144
- .query(
145
- `
146
- SELECT payload
147
- FROM ${SQLITE_MATERIALIZATION_WORK_TABLE}
148
- WHERE session_id = ? AND lane = ?
149
- ORDER BY major_order, minor_order, sort_one, sort_two, record_key
150
- LIMIT ? OFFSET ?
151
- `
152
- )
153
- .all(session.id, input.order, input.pageRows, offset) as { readonly payload: string }[]
214
+ const rows = this.readLane(input.order, session.id, cursor, input.pageRows)
154
215
  if (rows.length === 0) break
155
216
  const records = rows.map((row) =>
156
217
  parseJson<MaterializationWorkRecord>(row.payload)
157
218
  ) as MaterializationWorkPage["records"]
158
- offset += records.length
159
- stream.emittedCount = offset
219
+ cursor = workCursor(rows[rows.length - 1]!)
220
+ stream.emittedCount += records.length
160
221
  yield { records }
161
222
  }
162
223
  this.require(input.session)
@@ -187,53 +248,55 @@ export class SqliteMaterializationSessions {
187
248
  })
188
249
  }
189
250
 
190
- assertChunkSequence(
251
+ prepareChunkSequence(
191
252
  session: SqliteMaterializationSessionState,
192
253
  chunk: MaterializationPlanChunk
193
- ): void {
254
+ ): SqliteChunkSequenceProgress {
194
255
  const items = materializationPlanItems(chunk)
256
+ const planRows = this.readLane("apply", session.id, session.appliedPlanCursor, items.length)
257
+ const expectedItems = planRows.map((row) => {
258
+ const record = parseJson<Extract<MaterializationWorkRecord, { readonly kind: "plan" }>>(
259
+ row.payload
260
+ )
261
+ return record.item
262
+ })
263
+ const eventRows = this.readLane(
264
+ "event",
265
+ session.id,
266
+ session.appliedEventCursor,
267
+ chunk.outbox.length
268
+ )
269
+ const expectedEvents = eventRows.map((row) =>
270
+ parseJson<MaterializationEventWorkRecord>(row.payload)
271
+ )
195
272
  const progress = correlateMaterializationChunk(
196
273
  session,
197
274
  chunk,
198
275
  items,
199
- this.planItems(session, session.appliedPlanCount, items.length),
200
- this.eventRecords(session, session.appliedOutboxCount, chunk.outbox.length)
276
+ expectedItems,
277
+ expectedEvents
201
278
  )
202
- session.appliedPlanCount = progress.appliedPlanCount
203
- session.appliedOutboxCount = progress.appliedOutboxCount
279
+ return {
280
+ ...progress,
281
+ appliedPlanCursor:
282
+ planRows.length === 0
283
+ ? session.appliedPlanCursor
284
+ : workCursor(planRows[planRows.length - 1]!),
285
+ appliedEventCursor:
286
+ eventRows.length === 0
287
+ ? session.appliedEventCursor
288
+ : workCursor(eventRows[eventRows.length - 1]!),
289
+ }
204
290
  }
205
291
 
206
- recordReplacementIdentities(
292
+ commitChunkSequence(
207
293
  session: SqliteMaterializationSessionState,
208
- identities: readonly ReplacementIdentity[]
294
+ progress: SqliteChunkSequenceProgress
209
295
  ): void {
210
- const upsert = this.db.query(
211
- `
212
- INSERT INTO ${SQLITE_REPLACEMENT_WORK_TABLE} (
213
- session_id, entity_kind, identity_key, diff_required
214
- ) VALUES (?, ?, ?, ?)
215
- ON CONFLICT(session_id, entity_kind, identity_key) DO UPDATE SET
216
- diff_required = MAX(diff_required, excluded.diff_required)
217
- `
218
- )
219
- for (const identity of identities) {
220
- upsert.run(
221
- session.id,
222
- identity.kind,
223
- identity.kind === "object" ? objectRefKey(identity.ref) : linkRefKey(identity.ref),
224
- identity.diffRequired ? 1 : 0
225
- )
226
- }
227
- }
228
-
229
- count(session: SqliteMaterializationSessionState, kind: string): number {
230
- const row = this.db
231
- .query(
232
- `SELECT COUNT(*) AS count FROM ${SQLITE_MATERIALIZATION_WORK_TABLE}
233
- WHERE session_id = ? AND kind = ?`
234
- )
235
- .get(session.id, kind) as { readonly count: number }
236
- return row.count
296
+ session.appliedPlanCount = progress.appliedPlanCount
297
+ session.appliedOutboxCount = progress.appliedOutboxCount
298
+ session.appliedPlanCursor = progress.appliedPlanCursor
299
+ session.appliedEventCursor = progress.appliedEventCursor
237
300
  }
238
301
 
239
302
  laneCounts(session: SqliteMaterializationSessionState): {
@@ -241,29 +304,46 @@ export class SqliteMaterializationSessions {
241
304
  readonly cardinality: number
242
305
  readonly event: number
243
306
  } {
244
- const rows = this.db
245
- .query(
246
- `SELECT lane, COUNT(*) AS count FROM ${SQLITE_MATERIALIZATION_WORK_TABLE}
247
- WHERE session_id = ? AND lane <> 'none'
248
- GROUP BY lane`
249
- )
250
- .all(session.id) as {
251
- readonly lane: "apply" | "cardinality" | "event"
252
- readonly count: number
253
- }[]
254
- const counts = { apply: 0, cardinality: 0, event: 0 }
255
- for (const row of rows) counts[row.lane] = row.count
256
- return counts
307
+ return {
308
+ apply: session.summary.apply,
309
+ cardinality: session.summary.cardinality,
310
+ event: session.summary.event,
311
+ }
257
312
  }
258
313
 
259
- records(session: SqliteMaterializationSessionState, kind?: string): MaterializationWorkRecord[] {
260
- const rows = this.db
261
- .query(
262
- `SELECT payload FROM ${SQLITE_MATERIALIZATION_WORK_TABLE}
263
- WHERE session_id = ? ${kind ? "AND kind = ?" : ""}`
264
- )
265
- .all(...(kind ? [session.id, kind] : [session.id])) as { readonly payload: string }[]
266
- return rows.map((row) => parseJson<MaterializationWorkRecord>(row.payload))
314
+ projectionCounts(session: SqliteMaterializationSessionState): EffectiveChangeCounts {
315
+ const summary = session.summary
316
+ return {
317
+ objectsCreated: summary.objectsCreated,
318
+ objectsUpdated: summary.objectsUpdated,
319
+ objectsDeleted: summary.objectsDeleted,
320
+ objectsUnchanged:
321
+ summary.objectClassifications -
322
+ summary.objectsCreated -
323
+ summary.objectsUpdated -
324
+ summary.objectsDeleted,
325
+ linksCreated: summary.linksCreated,
326
+ linksUpdated: summary.linksUpdated,
327
+ linksDeleted: summary.linksDeleted,
328
+ linksUnchanged:
329
+ summary.linkClassifications -
330
+ summary.linksCreated -
331
+ summary.linksUpdated -
332
+ summary.linksDeleted,
333
+ }
334
+ }
335
+
336
+ telemetrySummary(session: SqliteMaterializationSessionState, pointCount: number) {
337
+ const summary = session.summary
338
+ return {
339
+ classifiedPoints: summary.pointClassifications,
340
+ counts: {
341
+ pointsCreated: summary.pointsCreated,
342
+ pointsUpdated: summary.pointsUpdated,
343
+ pointsUnchanged: pointCount - summary.pointsCreated - summary.pointsUpdated,
344
+ latestObjectsChanged: summary.latestObjectsChanged,
345
+ },
346
+ }
267
347
  }
268
348
 
269
349
  assertClassificationCoverage(session: SqliteMaterializationSessionState): void {
@@ -272,84 +352,84 @@ export class SqliteMaterializationSessions {
272
352
  const mismatch = this.db
273
353
  .query(
274
354
  `
275
- WITH expected AS (
276
- SELECT entity_kind, identity_key
277
- FROM ${SQLITE_REPLACEMENT_WORK_TABLE}
278
- WHERE session_id = ? AND diff_required = 1
279
- AND (entity_kind = 'link' OR ? = 1)
280
- ), actual AS (
281
- SELECT json_extract(payload, '$.entityKind') AS entity_kind,
282
- json_extract(payload, '$.identityKey') AS identity_key
283
- FROM ${SQLITE_MATERIALIZATION_WORK_TABLE}
284
- WHERE session_id = ? AND kind = 'classification'
285
- AND json_extract(payload, '$.entityKind') IN ('object', 'link')
286
- ), differences AS (
287
- SELECT * FROM expected EXCEPT SELECT * FROM actual
355
+ WITH invalid AS (
356
+ SELECT 1
357
+ FROM ${SQLITE_REPLACEMENT_WORK_TABLE} AS expected
358
+ WHERE expected.session_id = ? AND expected.diff_required = 1
359
+ AND (expected.entity_kind = 'link' OR ? = 1)
360
+ AND NOT EXISTS (
361
+ SELECT 1 FROM ${SQLITE_MATERIALIZATION_WORK_TABLE} AS actual
362
+ WHERE actual.session_id = expected.session_id
363
+ AND actual.unique_key = 'classification:' || expected.entity_kind || ':'
364
+ || expected.identity_key
365
+ )
288
366
  UNION ALL
289
- SELECT * FROM actual EXCEPT SELECT * FROM expected
367
+ SELECT 1
368
+ FROM ${SQLITE_MATERIALIZATION_WORK_TABLE} AS actual
369
+ WHERE actual.session_id = ?
370
+ -- A bounded range uses the existing (session_id, unique_key) unique index and scans
371
+ -- classification entries only, instead of sorting the complete JSON work spool.
372
+ AND actual.unique_key >= 'classification:'
373
+ AND actual.unique_key < 'classification;'
374
+ AND (
375
+ actual.classification_entity_kind = 'point'
376
+ OR NOT EXISTS (
377
+ SELECT 1 FROM ${SQLITE_REPLACEMENT_WORK_TABLE} AS expected
378
+ WHERE expected.session_id = actual.session_id
379
+ AND expected.entity_kind = actual.classification_entity_kind
380
+ AND expected.identity_key = actual.classification_identity_key
381
+ AND expected.diff_required = 1
382
+ AND (expected.entity_kind = 'link' OR ? = 1)
383
+ )
384
+ )
290
385
  )
291
- SELECT 1 FROM differences LIMIT 1
386
+ SELECT 1 FROM invalid LIMIT 1
292
387
  `
293
388
  )
294
- .get(session.id, expectedObject, session.id)
295
- const pointClassifications = this.db
296
- .query(
297
- `
298
- SELECT 1 FROM ${SQLITE_MATERIALIZATION_WORK_TABLE}
299
- WHERE session_id = ? AND kind = 'classification'
300
- AND json_extract(payload, '$.entityKind') = 'point'
301
- LIMIT 1
302
- `
303
- )
304
- .get(session.id)
305
- if (mismatch || pointClassifications) {
389
+ .get(session.id, expectedObject, session.id, expectedObject)
390
+ if (mismatch) {
306
391
  invalidCorrelation(
307
392
  "Projection replacement classification coverage does not match its streamed state."
308
393
  )
309
394
  }
310
395
  }
311
396
 
312
- private planItems(
313
- session: SqliteMaterializationSessionState,
314
- offset: number,
397
+ private readLane(
398
+ lane: StreamMaterializationWorkInput["order"],
399
+ sessionId: string,
400
+ cursor: WorkCursor | null,
315
401
  limit: number
316
- ): MaterializationPlanWorkItem[] {
402
+ ): WorkDatabaseRow[] {
317
403
  if (limit === 0) return []
318
- const rows = this.db
319
- .query(
320
- `
321
- SELECT payload FROM ${SQLITE_MATERIALIZATION_WORK_TABLE}
322
- WHERE session_id = ? AND lane = 'apply'
323
- ORDER BY major_order, minor_order, sort_one, sort_two, record_key
324
- LIMIT ? OFFSET ?
325
- `
326
- )
327
- .all(session.id, limit, offset) as { readonly payload: string }[]
328
- return rows.map((row) => {
329
- const record = parseJson<Extract<MaterializationWorkRecord, { readonly kind: "plan" }>>(
330
- row.payload
331
- )
332
- return record.item
333
- })
334
- }
335
-
336
- private eventRecords(
337
- session: SqliteMaterializationSessionState,
338
- offset: number,
339
- limit: number
340
- ): MaterializationEventWorkRecord[] {
341
- if (limit === 0) return []
342
- const rows = this.db
404
+ const selected = `record_key, major_order, minor_order, sort_one, sort_two, payload`
405
+ if (!cursor) {
406
+ return this.db
407
+ .query(
408
+ `SELECT ${selected} FROM ${SQLITE_MATERIALIZATION_WORK_TABLE}
409
+ WHERE session_id = ? AND lane = ?
410
+ ORDER BY major_order, minor_order, sort_one, sort_two, record_key
411
+ LIMIT ?`
412
+ )
413
+ .all(sessionId, lane, limit) as WorkDatabaseRow[]
414
+ }
415
+ return this.db
343
416
  .query(
344
- `
345
- SELECT payload FROM ${SQLITE_MATERIALIZATION_WORK_TABLE}
346
- WHERE session_id = ? AND lane = 'event'
347
- ORDER BY major_order, minor_order, sort_one, sort_two, record_key
348
- LIMIT ? OFFSET ?
349
- `
417
+ `SELECT ${selected} FROM ${SQLITE_MATERIALIZATION_WORK_TABLE}
418
+ WHERE session_id = ? AND lane = ?
419
+ AND (major_order, minor_order, sort_one, sort_two, record_key) > (?, ?, ?, ?, ?)
420
+ ORDER BY major_order, minor_order, sort_one, sort_two, record_key
421
+ LIMIT ?`
350
422
  )
351
- .all(session.id, limit, offset) as { readonly payload: string }[]
352
- return rows.map((row) => parseJson<MaterializationEventWorkRecord>(row.payload))
423
+ .all(
424
+ sessionId,
425
+ lane,
426
+ cursor.majorOrder,
427
+ cursor.minorOrder,
428
+ cursor.sortOne,
429
+ cursor.sortTwo,
430
+ cursor.recordKey,
431
+ limit
432
+ ) as WorkDatabaseRow[]
353
433
  }
354
434
 
355
435
  private ensureTables(): void {
@@ -364,6 +444,15 @@ export class SqliteMaterializationSessions {
364
444
  minor_order INTEGER NOT NULL,
365
445
  sort_one TEXT NOT NULL,
366
446
  sort_two TEXT NOT NULL,
447
+ classification_entity_kind TEXT,
448
+ classification_identity_key TEXT,
449
+ cardinality_view TEXT,
450
+ cardinality_occupied INTEGER,
451
+ cardinality_source_type_id TEXT,
452
+ cardinality_source_primary_id TEXT,
453
+ cardinality_link_id TEXT,
454
+ cardinality_target_type_id TEXT,
455
+ cardinality_target_primary_id TEXT,
367
456
  payload TEXT NOT NULL CHECK (json_valid(payload)),
368
457
  PRIMARY KEY (session_id, record_key),
369
458
  UNIQUE (session_id, unique_key)
@@ -376,9 +465,14 @@ export class SqliteMaterializationSessions {
376
465
  session_id TEXT NOT NULL,
377
466
  entity_kind TEXT NOT NULL,
378
467
  identity_key TEXT NOT NULL,
468
+ sort_key TEXT NOT NULL,
379
469
  diff_required INTEGER NOT NULL CHECK (diff_required IN (0, 1)),
380
470
  PRIMARY KEY (session_id, entity_kind, identity_key)
381
471
  );
472
+ CREATE INDEX IF NOT EXISTS idx_ontology_replacement_work_order
473
+ ON ${SQLITE_REPLACEMENT_WORK_TABLE}(
474
+ session_id, entity_kind, sort_key, identity_key
475
+ );
382
476
  `)
383
477
  }
384
478
 
@@ -390,4 +484,84 @@ export class SqliteMaterializationSessions {
390
484
  .query(`DELETE FROM ${SQLITE_REPLACEMENT_WORK_TABLE} WHERE session_id = ?`)
391
485
  .run(sessionId)
392
486
  }
487
+
488
+ private dropWorkTables(): void {
489
+ this.db.run(`
490
+ DROP TABLE IF EXISTS ${SQLITE_MATERIALIZATION_WORK_TABLE};
491
+ DROP TABLE IF EXISTS ${SQLITE_REPLACEMENT_WORK_TABLE};
492
+ `)
493
+ }
494
+ }
495
+
496
+ function workCursor(row: WorkDatabaseRow): WorkCursor {
497
+ return {
498
+ majorOrder: row.major_order,
499
+ minorOrder: row.minor_order,
500
+ sortOne: row.sort_one,
501
+ sortTwo: row.sort_two,
502
+ recordKey: row.record_key,
503
+ }
504
+ }
505
+
506
+ function emptyWorkSummary(): WorkSummary {
507
+ return {
508
+ apply: 0,
509
+ cardinality: 0,
510
+ event: 0,
511
+ objectClassifications: 0,
512
+ linkClassifications: 0,
513
+ pointClassifications: 0,
514
+ objectsCreated: 0,
515
+ objectsUpdated: 0,
516
+ objectsDeleted: 0,
517
+ linksCreated: 0,
518
+ linksUpdated: 0,
519
+ linksDeleted: 0,
520
+ pointsCreated: 0,
521
+ pointsUpdated: 0,
522
+ latestObjectsChanged: 0,
523
+ }
524
+ }
525
+
526
+ function recordSummary(
527
+ summary: WorkSummary,
528
+ record: MaterializationWorkRecord,
529
+ columns: { readonly lane: "none" | "apply" | "cardinality" | "event" }
530
+ ): void {
531
+ if (columns.lane !== "none") summary[columns.lane] += 1
532
+
533
+ if (record.kind === "classification") {
534
+ if (record.entityKind === "object") summary.objectClassifications += 1
535
+ if (record.entityKind === "link") summary.linkClassifications += 1
536
+ if (record.entityKind === "point") summary.pointClassifications += 1
537
+ return
538
+ }
539
+ if (record.kind !== "plan") return
540
+
541
+ switch (record.item.kind) {
542
+ case "object-upsert":
543
+ summary.latestObjectsChanged += 1
544
+ if (record.item.value.expected.exists) summary.objectsUpdated += 1
545
+ else summary.objectsCreated += 1
546
+ return
547
+ case "object-delete":
548
+ summary.objectsDeleted += 1
549
+ return
550
+ case "link-upsert":
551
+ if (record.item.value.expected.exists) summary.linksUpdated += 1
552
+ else summary.linksCreated += 1
553
+ return
554
+ case "link-delete":
555
+ summary.linksDeleted += 1
556
+ return
557
+ case "point-upsert":
558
+ if (record.item.value.expected.lastCommitId === null) summary.pointsCreated += 1
559
+ else summary.pointsUpdated += 1
560
+ return
561
+ case "object-override-upsert":
562
+ case "object-override-delete":
563
+ case "link-override-upsert":
564
+ case "link-override-delete":
565
+ return
566
+ }
393
567
  }