@sixb/sqlite 0.1.14 → 0.1.15

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/src/migrations.ts CHANGED
@@ -115,6 +115,12 @@ import nativeSessionsSql from "./migrations/049-native-sessions.sql" with { type
115
115
  import directoryGroupMembershipsSql from "./migrations/050-directory-group-memberships.sql" with {
116
116
  type: "text",
117
117
  }
118
+ import projectionRunSupersessionSql from "./migrations/051-projection-run-supersession.sql" with {
119
+ type: "text",
120
+ }
121
+ import compactSourceStorageSql from "./migrations/052-compact-source-storage.sql" with {
122
+ type: "text",
123
+ }
118
124
 
119
125
  const MIGRATIONS_TABLE_SQL = `
120
126
  CREATE TABLE IF NOT EXISTS sixb_migrations (
@@ -220,6 +226,26 @@ export const sqliteStorageMigrations = defineMigrations({
220
226
  sqliteSql("048-file-upload-sessions", fileUploadSessionsSql),
221
227
  sqliteSql("049-native-sessions", nativeSessionsSql),
222
228
  sqliteSql("050-directory-group-memberships", directoryGroupMembershipsSql),
229
+ sqliteSql("051-projection-run-supersession", projectionRunSupersessionSql),
230
+ sqliteStep(
231
+ "052-compact-source-storage",
232
+ (db) => {
233
+ // SQLite has no procedural block to check the copy in SQL: count around it instead.
234
+ const before = sourceStorageCounts(db)
235
+ db.run(compactSourceStorageSql)
236
+ const after = sourceStorageCounts(db)
237
+ if (
238
+ after.versions !== before.versions ||
239
+ after.roots !== before.roots ||
240
+ after.rows !== before.rows
241
+ ) {
242
+ throw new Error(
243
+ "[SixbSqliteStorage] Source versions, roots or rows were lost while compacting source storage."
244
+ )
245
+ }
246
+ },
247
+ { checksum: checksum(compactSourceStorageSql) }
248
+ ),
223
249
  ],
224
250
  })
225
251
 
@@ -378,6 +404,18 @@ function rowToMigrationRecord(row: unknown): MigrationRecord {
378
404
  }
379
405
  }
380
406
 
407
+ function sourceStorageCounts(db: Database): {
408
+ readonly versions: number
409
+ readonly roots: number
410
+ readonly rows: number
411
+ } {
412
+ return db
413
+ .query(`SELECT (SELECT count(*) FROM ontology_sources) AS versions,
414
+ (SELECT count(*) FROM ontology_source_roots) AS roots,
415
+ (SELECT count(*) FROM ontology_source_rows) AS rows`)
416
+ .get() as { readonly versions: number; readonly roots: number; readonly rows: number }
417
+ }
418
+
381
419
  function checksum(value: string): string {
382
420
  return createHash("sha256").update(value).digest("hex")
383
421
  }
@@ -43,8 +43,15 @@ import {
43
43
  parseJson,
44
44
  type SqliteOntologySourceAssertionRow,
45
45
  type SqliteStoredOverrideRow,
46
+ sourceAssertion,
46
47
  } from "./shared"
47
- import { activeSourceRows, replacementAssertionRows, replacementSourceRows } from "./source-roots"
48
+ import {
49
+ activeSourceRows,
50
+ LIVE_SOURCE_ROWS_JOIN,
51
+ replacementAssertionRows,
52
+ replacementSourceRows,
53
+ SOURCE_ASSERTION_COLUMNS,
54
+ } from "./source-roots"
48
55
 
49
56
  export const SQLITE_MATERIALIZATION_WORK_TABLE = "ontology_materialization_work"
50
57
  export const SQLITE_REPLACEMENT_WORK_TABLE = "ontology_replacement_work"
@@ -438,19 +445,13 @@ export class SqliteMaterializationStateReader {
438
445
  json_extract(value, '$.linkId') AS link_id
439
446
  FROM json_each(?)
440
447
  )
441
- SELECT rows.* FROM requested
448
+ SELECT ${SOURCE_ASSERTION_COLUMNS} FROM requested
442
449
  CROSS JOIN ontology_source_rows AS rows
443
- ON rows.project_id = ?
444
- AND rows.entity_kind = 'link'
450
+ ON rows.entity_kind = 'link'
445
451
  AND rows.source_type_id = requested.source_type_id
446
452
  AND rows.source_primary_id = requested.source_primary_id
447
453
  AND rows.link_id = requested.link_id
448
- JOIN ontology_source_roots AS sources
449
- ON sources.project_id = rows.project_id
450
- AND sources.source_id = rows.source_id
451
- AND sources.materialization_id = rows.materialization_id
452
- AND sources.root_sort_key = rows.root_sort_key
453
- WHERE sources.active = 1`
454
+ ${LIVE_SOURCE_ROWS_JOIN}`
454
455
  )
455
456
  .all(requestedJson, this.projectId) as SqliteOntologySourceAssertionRow[]
456
457
  const slotOverrideRows = this.db
@@ -570,12 +571,8 @@ export class SqliteMaterializationStateReader {
570
571
  SELECT rows.source_type_id, rows.source_primary_id, rows.link_id,
571
572
  rows.target_type_id, rows.target_primary_id
572
573
  FROM ontology_source_rows AS rows
573
- JOIN ontology_source_roots AS sources
574
- ON sources.project_id = rows.project_id
575
- AND sources.source_id = rows.source_id
576
- AND sources.materialization_id = rows.materialization_id
577
- AND sources.root_sort_key = rows.root_sort_key
578
- WHERE rows.project_id = ? AND rows.entity_kind = 'link' AND sources.active = 1
574
+ ${LIVE_SOURCE_ROWS_JOIN}
575
+ WHERE rows.entity_kind = 'link'
579
576
  ), selected AS (
580
577
  SELECT DISTINCT links.*,
581
578
  ${linkSortExpression("links")} AS sort_key
@@ -752,7 +749,7 @@ export class SqliteMaterializationStateReader {
752
749
  }
753
750
 
754
751
  private prepareReplacementObjects(input: ReplacementIdentityInput): void {
755
- const rows = replacementSourceRows({
752
+ const rows = replacementSourceRows(this.db, {
756
753
  projectId: this.projectId,
757
754
  sourceId: input.sourceId,
758
755
  materializationId: input.candidateMaterializationId,
@@ -765,7 +762,7 @@ export class SqliteMaterializationStateReader {
765
762
  session_id, entity_kind, identity_key, sort_key, diff_required
766
763
  )
767
764
  SELECT ?, 'object', json_array(object_type_id, primary_id),
768
- MIN(entity_sort_key), 1
765
+ ${objectSortExpression()}, 1
769
766
  FROM (${rows.sql}) AS rows
770
767
  WHERE entity_kind = 'object'
771
768
  GROUP BY object_type_id, primary_id
@@ -775,7 +772,7 @@ export class SqliteMaterializationStateReader {
775
772
  }
776
773
 
777
774
  private prepareReplacementLinks(input: ReplacementIdentityInput): void {
778
- const rows = replacementSourceRows({
775
+ const rows = replacementSourceRows(this.db, {
779
776
  projectId: this.projectId,
780
777
  sourceId: input.sourceId,
781
778
  materializationId: input.candidateMaterializationId,
@@ -834,31 +831,21 @@ export class SqliteMaterializationStateReader {
834
831
  UNION
835
832
  SELECT rows.source_type_id, rows.source_primary_id AS source_id,
836
833
  rows.link_id, rows.target_type_id, rows.target_primary_id AS target_id
837
- FROM ontology_source_rows AS rows
838
- JOIN ontology_source_roots AS sources
839
- ON sources.project_id = rows.project_id
840
- AND sources.source_id = rows.source_id
841
- AND sources.materialization_id = rows.materialization_id
842
- AND sources.root_sort_key = rows.root_sort_key
843
- JOIN incident_objects
844
- ON incident_objects.object_type_id = rows.source_type_id
845
- AND incident_objects.primary_id = rows.source_primary_id
846
- WHERE rows.project_id = ? AND rows.entity_kind = 'link'
847
- AND sources.active = 1
834
+ FROM incident_objects
835
+ CROSS JOIN ontology_source_rows AS rows
836
+ ON rows.entity_kind = 'link'
837
+ AND rows.source_type_id = incident_objects.object_type_id
838
+ AND rows.source_primary_id = incident_objects.primary_id
839
+ ${LIVE_SOURCE_ROWS_JOIN}
848
840
  UNION
849
841
  SELECT rows.source_type_id, rows.source_primary_id AS source_id,
850
842
  rows.link_id, rows.target_type_id, rows.target_primary_id AS target_id
851
- FROM ontology_source_rows AS rows
852
- JOIN ontology_source_roots AS sources
853
- ON sources.project_id = rows.project_id
854
- AND sources.source_id = rows.source_id
855
- AND sources.materialization_id = rows.materialization_id
856
- AND sources.root_sort_key = rows.root_sort_key
857
- JOIN incident_objects
858
- ON incident_objects.object_type_id = rows.target_type_id
859
- AND incident_objects.primary_id = rows.target_primary_id
860
- WHERE rows.project_id = ? AND rows.entity_kind = 'link'
861
- AND sources.active = 1
843
+ FROM incident_objects
844
+ CROSS JOIN ontology_source_rows AS rows
845
+ ON rows.entity_kind = 'link'
846
+ AND rows.target_type_id = incident_objects.object_type_id
847
+ AND rows.target_primary_id = incident_objects.primary_id
848
+ ${LIVE_SOURCE_ROWS_JOIN}
862
849
  ), diff_links AS (
863
850
  SELECT * FROM replacement_links
864
851
  UNION SELECT * FROM incident_links
@@ -1062,9 +1049,7 @@ function storedSource(row: SqliteOntologySourceAssertionRow): StoredSourceAssert
1062
1049
  return {
1063
1050
  source: { projectionId: row.source_id },
1064
1051
  materializationId: row.materialization_id,
1065
- root: parseJson<StoredSourceAssertion["root"]>(row.root),
1066
- assertion: parseJson<StoredSourceAssertion["assertion"]>(row.assertion),
1067
- stagingOrdinal: row.staging_ordinal,
1052
+ ...sourceAssertion(row),
1068
1053
  } as StoredSourceAssertion
1069
1054
  }
1070
1055
 
@@ -1130,6 +1115,11 @@ function storedPoint(row: TelemetryRow): StoredTelemetryPoint {
1130
1115
  }
1131
1116
  }
1132
1117
 
1118
+ function objectSortExpression(alias?: string): string {
1119
+ const prefix = alias ? `${alias}.` : ""
1120
+ return `LOWER(HEX(CAST(json_array(${prefix}object_type_id, ${prefix}primary_id) AS BLOB)))`
1121
+ }
1122
+
1133
1123
  export function linkSortExpression(alias?: string): string {
1134
1124
  const prefix = alias ? `${alias}.` : ""
1135
1125
  return `LOWER(HEX(CAST(json_array(${prefix}source_type_id, ${prefix}source_id, ${prefix}link_id, ${prefix}target_type_id, ${prefix}target_id) AS BLOB)))`
@@ -1,7 +1,10 @@
1
1
  import type { Database } from "bun:sqlite"
2
2
  import { parseSixbFailure } from "@sixb/core/internal/errors"
3
- import type { ProjectionEntityRef } from "@sixb/core/internal/materialization"
4
3
  import { MaterializationConflictError } from "@sixb/core/internal/materialization"
4
+ import {
5
+ sourceAssertionFromColumns,
6
+ sourceEntityFromKey,
7
+ } from "@sixb/core/internal/ontology-storage-provider"
5
8
  import type {
6
9
  AssertSourceMaterializationExecutionInput,
7
10
  OntologyCommitRecord,
@@ -48,6 +51,7 @@ export interface SqliteOntologyCommitRow {
48
51
  }
49
52
 
50
53
  export interface SqliteOntologySourceRow {
54
+ readonly version_id: number
51
55
  readonly project_id: string
52
56
  readonly source_id: string
53
57
  readonly materialization_id: string
@@ -74,19 +78,21 @@ export interface SqliteOntologySourceRow {
74
78
  readonly updated_at: string
75
79
  }
76
80
 
81
+ /** An assertion with its root and version, as `SOURCE_ASSERTION_COLUMNS` selects it. */
77
82
  export interface SqliteOntologySourceAssertionRow {
78
- readonly project_id: string
79
83
  readonly source_id: string
80
84
  readonly materialization_id: string
81
- readonly entity_kind: "object" | "link"
82
- readonly entity_key: string
83
- readonly entity_sort_key: string
84
- readonly root_kind: "object" | "link"
85
85
  readonly root_key: string
86
- readonly root_sort_key: string
87
86
  readonly staging_ordinal: number
88
- readonly root: string
89
- readonly assertion: string
87
+ readonly entity_kind: "object" | "link"
88
+ readonly object_type_id: string | null
89
+ readonly primary_id: string | null
90
+ readonly source_type_id: string | null
91
+ readonly source_primary_id: string | null
92
+ readonly link_id: string | null
93
+ readonly target_type_id: string | null
94
+ readonly target_primary_id: string | null
95
+ readonly payload: string | null
90
96
  }
91
97
 
92
98
  export interface SqliteStoredOverrideRow {
@@ -149,8 +155,11 @@ export function sourceRecord(row: SqliteOntologySourceRow): OntologySourceRecord
149
155
 
150
156
  export function sourceAssertion(row: SqliteOntologySourceAssertionRow): StageSourceAssertion {
151
157
  return {
152
- root: parseJson<ProjectionEntityRef>(row.root),
153
- assertion: parseJson<StageSourceAssertion["assertion"]>(row.assertion),
158
+ root: sourceEntityFromKey(row.root_key),
159
+ assertion: sourceAssertionFromColumns(
160
+ row,
161
+ row.payload === null ? null : parseJson<unknown>(row.payload)
162
+ ),
154
163
  stagingOrdinal: row.staging_ordinal,
155
164
  }
156
165
  }
@@ -2,38 +2,79 @@ import type { Database } from "bun:sqlite"
2
2
  import type {
3
3
  CleanupTerminalSourceMaterializationsInput,
4
4
  CleanupTerminalSourceMaterializationsResult,
5
+ PurgeAbandonedSourceMaterializationsInput,
5
6
  } from "@sixb/core/storage"
6
7
 
8
+ /**
9
+ * Superseded versions can still hold live roots (a delta keeps unchanged roots where they were),
10
+ * so they are cleaned root by root once retired. An abandoned candidate was never published:
11
+ * everything it holds goes, one whole version at a time, each delete on its own version prefix.
12
+ */
13
+ export function purgeAbandonedSourceVersions(
14
+ db: Database,
15
+ input: PurgeAbandonedSourceMaterializationsInput
16
+ ): CleanupTerminalSourceMaterializationsResult {
17
+ let remaining = input.limit
18
+ let rowsDeleted = 0
19
+ let materializationsDeleted = 0
20
+ const nextCandidate = db.query(`
21
+ SELECT version_id FROM ontology_sources
22
+ WHERE project_id = ? AND status = 'abandoned'
23
+ ORDER BY terminal_at, source_id, materialization_id LIMIT 1
24
+ `)
25
+ const deleteRows = db.query(`DELETE FROM ontology_source_rows WHERE rowid IN (
26
+ SELECT rows.rowid FROM ontology_source_roots AS roots
27
+ CROSS JOIN ontology_source_rows AS rows ON rows.root_id = roots.id
28
+ WHERE roots.version_id = ? LIMIT ?)`)
29
+ const deleteRoots = db.query(`DELETE FROM ontology_source_roots WHERE id IN (
30
+ SELECT id FROM ontology_source_roots WHERE version_id = ? LIMIT ?)`)
31
+ const deleteManifest = db.query(
32
+ "DELETE FROM ontology_sources WHERE version_id = ? AND status = 'abandoned'"
33
+ )
34
+ while (remaining > 0) {
35
+ const candidate = nextCandidate.get(input.projectId) as { readonly version_id: number } | null
36
+ if (!candidate) break
37
+ const rows = deleteRows.run(candidate.version_id, remaining).changes
38
+ rowsDeleted += rows
39
+ remaining -= rows
40
+ if (remaining === 0) break
41
+ const roots = deleteRoots.run(candidate.version_id, remaining).changes
42
+ rowsDeleted += roots
43
+ remaining -= roots
44
+ if (remaining === 0) break
45
+ const manifest = deleteManifest.run(candidate.version_id).changes
46
+ if (manifest === 0) break
47
+ materializationsDeleted += manifest
48
+ remaining -= manifest
49
+ }
50
+ return { rowsDeleted, materializationsDeleted }
51
+ }
52
+
7
53
  export function cleanupSourceVersions(
8
54
  db: Database,
9
55
  input: CleanupTerminalSourceMaterializationsInput
10
56
  ): CleanupTerminalSourceMaterializationsResult {
11
57
  let remaining = input.limit
58
+ const retired = `roots.project_id = ? AND roots.retired_at < ? AND versions.status = 'superseded'`
12
59
  let rowsDeleted = db
13
60
  .query(`DELETE FROM ontology_source_rows WHERE rowid IN (
14
61
  SELECT rows.rowid FROM ontology_source_roots AS roots
15
- JOIN ontology_sources AS sources USING (project_id, source_id, materialization_id)
16
- CROSS JOIN ontology_source_rows AS rows
17
- ON rows.project_id = roots.project_id AND rows.source_id = roots.source_id
18
- AND rows.materialization_id = roots.materialization_id AND rows.root_sort_key = roots.root_sort_key
19
- WHERE roots.project_id = ? AND roots.active = 0 AND roots.retired_at < ?
20
- AND sources.status IN ('superseded', 'abandoned')
21
- ORDER BY roots.retired_at, roots.source_id, roots.materialization_id, roots.root_sort_key, rows.entity_sort_key
62
+ JOIN ontology_sources AS versions ON versions.version_id = roots.version_id
63
+ CROSS JOIN ontology_source_rows AS rows ON rows.root_id = roots.id
64
+ WHERE ${retired}
65
+ ORDER BY roots.retired_at, roots.id
22
66
  LIMIT ?
23
67
  )`)
24
68
  .run(input.projectId, input.terminalBefore, remaining).changes
25
69
  remaining -= rowsDeleted
26
70
  if (remaining > 0) {
27
71
  const removed = db
28
- .query(`DELETE FROM ontology_source_roots WHERE rowid IN (
29
- SELECT roots.rowid FROM ontology_source_roots AS roots
30
- JOIN ontology_sources AS sources USING (project_id, source_id, materialization_id)
31
- WHERE roots.project_id = ? AND roots.active = 0 AND roots.retired_at < ?
32
- AND sources.status IN ('superseded', 'abandoned')
33
- AND NOT EXISTS (SELECT 1 FROM ontology_source_rows AS rows
34
- WHERE rows.project_id = roots.project_id AND rows.source_id = roots.source_id
35
- AND rows.materialization_id = roots.materialization_id AND rows.root_sort_key = roots.root_sort_key)
36
- ORDER BY roots.retired_at, roots.source_id, roots.materialization_id, roots.root_sort_key LIMIT ?
72
+ .query(`DELETE FROM ontology_source_roots WHERE id IN (
73
+ SELECT roots.id FROM ontology_source_roots AS roots
74
+ JOIN ontology_sources AS versions ON versions.version_id = roots.version_id
75
+ WHERE ${retired}
76
+ AND NOT EXISTS (SELECT 1 FROM ontology_source_rows AS rows WHERE rows.root_id = roots.id)
77
+ ORDER BY roots.retired_at, roots.id LIMIT ?
37
78
  )`)
38
79
  .run(input.projectId, input.terminalBefore, remaining).changes
39
80
  rowsDeleted += removed
@@ -43,15 +84,11 @@ export function cleanupSourceVersions(
43
84
  remaining === 0
44
85
  ? 0
45
86
  : db
46
- .query(`DELETE FROM ontology_sources WHERE rowid IN (
47
- SELECT sources.rowid FROM ontology_sources AS sources
48
- WHERE project_id = ? AND status IN ('superseded', 'abandoned') AND terminal_at < ?
87
+ .query(`DELETE FROM ontology_sources WHERE version_id IN (
88
+ SELECT versions.version_id FROM ontology_sources AS versions
89
+ WHERE project_id = ? AND status = 'superseded' AND terminal_at < ?
49
90
  AND NOT EXISTS (SELECT 1 FROM ontology_source_roots AS roots
50
- WHERE roots.project_id = sources.project_id AND roots.source_id = sources.source_id
51
- AND roots.materialization_id = sources.materialization_id)
52
- AND NOT EXISTS (SELECT 1 FROM ontology_source_rows AS rows
53
- WHERE rows.project_id = sources.project_id AND rows.source_id = sources.source_id
54
- AND rows.materialization_id = sources.materialization_id)
91
+ WHERE roots.version_id = versions.version_id)
55
92
  ORDER BY terminal_at, source_id, materialization_id LIMIT ?
56
93
  )`)
57
94
  .run(input.projectId, input.terminalBefore, remaining).changes