@sixb/pg 0.1.13 → 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.
Files changed (46) hide show
  1. package/dist/auth-storage/auth-storage.d.ts +2 -1
  2. package/dist/auth-storage/auth-storage.d.ts.map +1 -1
  3. package/dist/auth-storage/rows.d.ts +1 -1
  4. package/dist/auth-storage/rows.d.ts.map +1 -1
  5. package/dist/index.d.ts +8 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +779 -357
  8. package/dist/index.js.map +16 -16
  9. package/dist/latest-run-query.d.ts +2 -0
  10. package/dist/latest-run-query.d.ts.map +1 -1
  11. package/dist/migrations.d.ts.map +1 -1
  12. package/dist/ontology-storage/materialization-state.d.ts.map +1 -1
  13. package/dist/ontology-storage/shared.d.ts +12 -9
  14. package/dist/ontology-storage/shared.d.ts.map +1 -1
  15. package/dist/ontology-storage/source-cleanup.d.ts +7 -1
  16. package/dist/ontology-storage/source-cleanup.d.ts.map +1 -1
  17. package/dist/ontology-storage/source-roots.d.ts +8 -0
  18. package/dist/ontology-storage/source-roots.d.ts.map +1 -1
  19. package/dist/ontology-storage/sources.d.ts +8 -3
  20. package/dist/ontology-storage/sources.d.ts.map +1 -1
  21. package/dist/pg-client.d.ts +2 -0
  22. package/dist/pg-client.d.ts.map +1 -1
  23. package/dist/pg-projection-run-storage.d.ts +3 -1
  24. package/dist/pg-projection-run-storage.d.ts.map +1 -1
  25. package/dist/storage-errors.d.ts +13 -3
  26. package/dist/storage-errors.d.ts.map +1 -1
  27. package/dist/transactions.d.ts +23 -1
  28. package/dist/transactions.d.ts.map +1 -1
  29. package/package.json +3 -3
  30. package/src/auth-storage/auth-storage.ts +43 -75
  31. package/src/auth-storage/rows.ts +1 -1
  32. package/src/index.ts +8 -1
  33. package/src/latest-run-query.ts +10 -2
  34. package/src/migrations/050-directory-group-memberships.sql +5 -0
  35. package/src/migrations/051-projection-run-supersession.sql +43 -0
  36. package/src/migrations/052-compact-source-storage.sql +118 -0
  37. package/src/migrations.ts +30 -23
  38. package/src/ontology-storage/materialization-state.ts +20 -46
  39. package/src/ontology-storage/shared.ts +18 -12
  40. package/src/ontology-storage/source-cleanup.ts +66 -22
  41. package/src/ontology-storage/source-roots.ts +164 -102
  42. package/src/ontology-storage/sources.ts +150 -120
  43. package/src/pg-client.ts +132 -2
  44. package/src/pg-projection-run-storage.ts +56 -2
  45. package/src/storage-errors.ts +101 -15
  46. package/src/transactions.ts +113 -20
@@ -0,0 +1,43 @@
1
+ -- Projection runs gain the terminal status `superseded`, and a running run may carry the failure
2
+ -- of its last attempt in `error` while the queue retries it.
3
+
4
+ -- The status checks were unnamed. Drop exactly the two that enumerate the terminal statuses, then
5
+ -- replace them with named ones that admit `superseded`.
6
+ DO $$
7
+ DECLARE status_constraint RECORD;
8
+ BEGIN
9
+ FOR status_constraint IN
10
+ SELECT conname
11
+ FROM pg_constraint
12
+ WHERE conrelid = 'projection_runs'::regclass
13
+ AND contype = 'c'
14
+ AND pg_get_constraintdef(oid) LIKE '%''cancelled''%'
15
+ LOOP
16
+ EXECUTE format('ALTER TABLE projection_runs DROP CONSTRAINT %I', status_constraint.conname);
17
+ END LOOP;
18
+ END $$;
19
+
20
+ ALTER TABLE projection_runs
21
+ ADD CONSTRAINT projection_runs_status_check CHECK (
22
+ status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled', 'superseded')
23
+ ),
24
+ ADD CONSTRAINT projection_runs_lifecycle_check CHECK (
25
+ (status = 'queued' AND attempt = 0 AND started_at IS NULL AND finished_at IS NULL
26
+ AND error IS NULL)
27
+ OR (status = 'running' AND attempt >= 1 AND started_at IS NOT NULL AND finished_at IS NULL)
28
+ OR (
29
+ status IN ('succeeded', 'failed', 'cancelled', 'superseded')
30
+ AND finished_at IS NOT NULL
31
+ AND (
32
+ (error->>'code' = 'queue.enqueue_failed'
33
+ AND status = 'failed' AND attempt = 0 AND started_at IS NULL)
34
+ OR (COALESCE(error->>'code', '') != 'queue.enqueue_failed'
35
+ AND attempt >= 1 AND started_at IS NOT NULL)
36
+ )
37
+ )
38
+ ),
39
+ -- A run is superseded instead of materialized, so it carries no failure, and only replacement
40
+ -- runs can be: telemetry appends readings rather than replace a source.
41
+ ADD CONSTRAINT projection_runs_superseded_check CHECK (
42
+ status != 'superseded' OR (error IS NULL AND materialization_protocol = 'replacement')
43
+ );
@@ -0,0 +1,118 @@
1
+ -- Source versions get a compact surrogate key; roots and rows stop repeating the text triple.
2
+ ALTER TABLE ontology_sources ADD COLUMN version_id BIGINT GENERATED ALWAYS AS IDENTITY;
3
+ ALTER TABLE ontology_sources ADD CONSTRAINT ontology_sources_version_id_key UNIQUE (version_id);
4
+
5
+ ALTER TABLE ontology_source_rows RENAME TO legacy_ontology_source_rows;
6
+ ALTER TABLE ontology_source_roots RENAME TO legacy_ontology_source_roots;
7
+ -- Index names are schema-wide: free them for the new tables.
8
+ DO $$
9
+ DECLARE
10
+ legacy record;
11
+ BEGIN
12
+ FOR legacy IN
13
+ SELECT index_class.relname AS name
14
+ FROM pg_index
15
+ JOIN pg_class AS index_class ON index_class.oid = pg_index.indexrelid
16
+ JOIN pg_class AS table_class ON table_class.oid = pg_index.indrelid
17
+ WHERE table_class.relnamespace = current_schema()::regnamespace
18
+ AND table_class.relname IN ('legacy_ontology_source_rows', 'legacy_ontology_source_roots')
19
+ LOOP
20
+ EXECUTE format('ALTER INDEX %I RENAME TO %I', legacy.name, left('legacy_' || legacy.name, 63));
21
+ END LOOP;
22
+ END $$;
23
+
24
+ -- A root is live while its version is published (active or superseded), it is not retired and
25
+ -- it is not a deletion. Activation therefore writes only the roots it replaces, never its own.
26
+ -- `root_key` is the canonical key; "C" orders it by its UTF-8 bytes.
27
+ CREATE TABLE ontology_source_roots (
28
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
29
+ version_id BIGINT NOT NULL REFERENCES ontology_sources (version_id) ON DELETE RESTRICT,
30
+ project_id TEXT NOT NULL,
31
+ root_key TEXT COLLATE "C" NOT NULL,
32
+ staging_ordinal BIGINT NOT NULL CHECK (staging_ordinal >= 0),
33
+ deleted BOOLEAN NOT NULL DEFAULT FALSE,
34
+ retired_at TIMESTAMPTZ,
35
+ CONSTRAINT ontology_source_roots_key UNIQUE (version_id, root_key),
36
+ CONSTRAINT ontology_source_roots_ordinal UNIQUE (version_id, staging_ordinal)
37
+ );
38
+ CREATE INDEX idx_ontology_source_roots_live
39
+ ON ontology_source_roots (project_id, root_key) WHERE retired_at IS NULL AND NOT deleted;
40
+ CREATE INDEX idx_ontology_source_roots_retired
41
+ ON ontology_source_roots (project_id, retired_at) WHERE retired_at IS NOT NULL;
42
+
43
+ -- A row's identity lives in its typed columns; `payload` is the assertion without kind and ref.
44
+ CREATE TABLE ontology_source_rows (
45
+ root_id BIGINT NOT NULL REFERENCES ontology_source_roots (id) ON DELETE RESTRICT,
46
+ entity_kind TEXT NOT NULL CHECK (entity_kind IN ('object', 'link')),
47
+ object_type_id TEXT,
48
+ primary_id TEXT,
49
+ source_type_id TEXT,
50
+ source_primary_id TEXT,
51
+ link_id TEXT,
52
+ target_type_id TEXT,
53
+ target_primary_id TEXT,
54
+ payload JSONB,
55
+ CHECK (
56
+ (
57
+ entity_kind = 'object'
58
+ AND object_type_id IS NOT NULL AND primary_id IS NOT NULL
59
+ AND source_type_id IS NULL AND source_primary_id IS NULL AND link_id IS NULL
60
+ AND target_type_id IS NULL AND target_primary_id IS NULL
61
+ )
62
+ OR
63
+ (
64
+ entity_kind = 'link'
65
+ AND object_type_id IS NULL AND primary_id IS NULL
66
+ AND source_type_id IS NOT NULL AND source_primary_id IS NOT NULL AND link_id IS NOT NULL
67
+ AND target_type_id IS NOT NULL AND target_primary_id IS NOT NULL
68
+ )
69
+ )
70
+ );
71
+ CREATE INDEX idx_ontology_source_rows_root ON ontology_source_rows (root_id);
72
+ CREATE INDEX idx_ontology_source_rows_link_source
73
+ ON ontology_source_rows (source_type_id, source_primary_id, link_id)
74
+ WHERE entity_kind = 'link';
75
+ CREATE INDEX idx_ontology_source_rows_link_target
76
+ ON ontology_source_rows (target_type_id, target_primary_id)
77
+ WHERE entity_kind = 'link';
78
+
79
+ -- A published root that was not active is retired, so liveness stays exactly what it was.
80
+ INSERT INTO ontology_source_roots (
81
+ version_id, project_id, root_key, staging_ordinal, deleted, retired_at
82
+ )
83
+ SELECT versions.version_id, roots.project_id,
84
+ convert_from(decode(roots.root_sort_key, 'hex'), 'UTF8'), roots.staging_ordinal, roots.deleted,
85
+ CASE
86
+ WHEN roots.active THEN NULL
87
+ WHEN roots.retired_at IS NOT NULL THEN roots.retired_at
88
+ WHEN versions.status IN ('active', 'superseded') THEN versions.updated_at
89
+ END
90
+ FROM legacy_ontology_source_roots AS roots
91
+ JOIN ontology_sources AS versions USING (project_id, source_id, materialization_id);
92
+
93
+ INSERT INTO ontology_source_rows (
94
+ root_id, entity_kind, object_type_id, primary_id,
95
+ source_type_id, source_primary_id, link_id, target_type_id, target_primary_id, payload
96
+ )
97
+ SELECT roots.id, rows.entity_kind, rows.object_type_id, rows.primary_id,
98
+ rows.source_type_id, rows.source_primary_id, rows.link_id, rows.target_type_id,
99
+ rows.target_primary_id, NULLIF(rows.assertion - 'kind' - 'ref', '{}'::jsonb)
100
+ FROM legacy_ontology_source_rows AS rows
101
+ JOIN ontology_sources AS versions USING (project_id, source_id, materialization_id)
102
+ JOIN ontology_source_roots AS roots
103
+ ON roots.version_id = versions.version_id
104
+ AND roots.root_key = convert_from(decode(rows.root_sort_key, 'hex'), 'UTF8');
105
+
106
+ DO $$
107
+ BEGIN
108
+ IF (SELECT count(*) FROM legacy_ontology_source_roots)
109
+ <> (SELECT count(*) FROM ontology_source_roots)
110
+ OR (SELECT count(*) FROM legacy_ontology_source_rows)
111
+ <> (SELECT count(*) FROM ontology_source_rows) THEN
112
+ RAISE EXCEPTION 'Source roots or rows were lost while compacting source storage.';
113
+ END IF;
114
+ END $$;
115
+
116
+ DROP TABLE legacy_ontology_source_rows;
117
+ DROP TABLE legacy_ontology_source_roots;
118
+ ANALYZE ontology_sources, ontology_source_roots, ontology_source_rows;
package/src/migrations.ts CHANGED
@@ -106,7 +106,17 @@ import ontologyCommitAttributionSql from "./migrations/047-ontology-commit-attri
106
106
  type: "text",
107
107
  }
108
108
  import nativeSessionsSql from "./migrations/049-native-sessions.sql" with { type: "text" }
109
- import type { SQL, SQLClient } from "./pg-client"
109
+ import directoryGroupMembershipsSql from "./migrations/050-directory-group-memberships.sql" with {
110
+ type: "text",
111
+ }
112
+ import projectionRunSupersessionSql from "./migrations/051-projection-run-supersession.sql" with {
113
+ type: "text",
114
+ }
115
+ import compactSourceStorageSql from "./migrations/052-compact-source-storage.sql" with {
116
+ type: "text",
117
+ }
118
+ import type { ReservedSQL, SQL, SQLClient } from "./pg-client"
119
+ import { runPgTransactionOn, undoOnFailure, withReservedPgConnection } from "./transactions"
110
120
 
111
121
  export interface PostgresMigrationContext {
112
122
  exec(sqlText: string): Promise<void>
@@ -186,7 +196,7 @@ export function quoteIdent(identifier: string): string {
186
196
  }
187
197
 
188
198
  function postgresMigrationSession(
189
- sql: SQL,
199
+ sql: ReservedSQL,
190
200
  schemaName: string
191
201
  ): { context: PostgresMigrationContext; state: MigrationHistoryStore } {
192
202
  const schema = quoteIdent(schemaName)
@@ -253,19 +263,15 @@ function postgresMigrationSession(
253
263
  },
254
264
  async transaction(run) {
255
265
  // `sql` is the reserved connection holding the migration advisory lock. porsager's
256
- // reserved connection has no `.begin`, so drive the transaction manually on it and
257
- // route exec()/markStarted()/markApplied() through the same connection via `active`.
266
+ // `.begin` would open the transaction on another pool connection, so drive it on this one
267
+ // and route exec()/markStarted()/markApplied() through it via `active`.
258
268
  const previous = active
259
269
  active = sql
260
- await sql.unsafe("BEGIN")
261
270
  try {
262
- await sql.unsafe(`SET LOCAL search_path TO ${schema}`)
263
- const result = await run()
264
- await sql.unsafe("COMMIT")
265
- return result
266
- } catch (error) {
267
- await sql.unsafe("ROLLBACK")
268
- throw error
271
+ return await runPgTransactionOn(sql, async () => {
272
+ await sql.unsafe(`SET LOCAL search_path TO ${schema}`)
273
+ return run()
274
+ })
269
275
  } finally {
270
276
  active = previous
271
277
  }
@@ -312,23 +318,21 @@ async function withPostgresMigrationLock<T>(
312
318
  readonly schemaName: string
313
319
  readonly migrations: MigrationSet<PostgresMigrationContext>
314
320
  },
315
- run: (sql: SQL) => Promise<T>
321
+ run: (sql: ReservedSQL) => Promise<T>
316
322
  ): Promise<T> {
317
- const sql = await params.sql.reserve()
318
323
  const [first, second] = advisoryLockParts(
319
324
  `storage:migration:${params.schemaName}:${params.migrations.adapterId}`
320
325
  )
321
326
 
322
- try {
327
+ // A session-level lock outlives any transaction, so it is released before the connection goes
328
+ // back to the pool — except when the connection was lost, which already released it.
329
+ return withReservedPgConnection(params.sql, async (sql) => {
330
+ const unlock = () => sql`SELECT pg_advisory_unlock(${first}, ${second})`
323
331
  await sql`SELECT pg_advisory_lock(${first}, ${second})`
324
- try {
325
- return await run(sql)
326
- } finally {
327
- await sql`SELECT pg_advisory_unlock(${first}, ${second})`
328
- }
329
- } finally {
330
- sql.release()
331
- }
332
+ const result = await undoOnFailure(() => run(sql), unlock)
333
+ await unlock()
334
+ return result
335
+ })
332
336
  }
333
337
 
334
338
  function advisoryLockParts(key: string): readonly [number, number] {
@@ -408,6 +412,9 @@ export const postgresStorageMigrations = defineMigrations<PostgresMigrationConte
408
412
  pgSql("047-ontology-commit-attribution", ontologyCommitAttributionSql),
409
413
  pgSql("048-file-upload-sessions", fileUploadSessionsSql),
410
414
  pgSql("049-native-sessions", nativeSessionsSql),
415
+ pgSql("050-directory-group-memberships", directoryGroupMembershipsSql),
416
+ pgSql("051-projection-run-supersession", projectionRunSupersessionSql),
417
+ pgSql("052-compact-source-storage", compactSourceStorageSql),
411
418
  ],
412
419
  })
413
420
 
@@ -38,15 +38,21 @@ import type {
38
38
  } from "@sixb/core/storage"
39
39
  import type { SQLClient } from "../pg-client"
40
40
  import {
41
- databaseSafeInteger,
42
41
  jsonParameter,
43
42
  linkRefFromColumns,
44
43
  objectRefFromColumns,
45
44
  type PgOntologySourceAssertionRow,
46
45
  type PgStoredOverrideRow,
46
+ sourceAssertion,
47
47
  toIsoString,
48
48
  } from "./shared"
49
- import { activeSourceRows, replacementAssertionRows, replacementSourceRows } from "./source-roots"
49
+ import {
50
+ activeSourceRows,
51
+ liveSourceRowsJoin,
52
+ replacementAssertionRows,
53
+ replacementSourceRows,
54
+ sourceAssertionColumns,
55
+ } from "./source-roots"
50
56
 
51
57
  export const PG_MATERIALIZATION_WORK_TABLE = "ontology_materialization_work"
52
58
  export const PG_REPLACEMENT_WORK_TABLE = "ontology_replacement_work"
@@ -410,20 +416,14 @@ export class PgMaterializationStateReader {
410
416
  scope_sort_key TEXT, source_type_id TEXT, source_id TEXT, link_id TEXT
411
417
  )
412
418
  )
413
- SELECT rows.*
419
+ SELECT ${sourceAssertionColumns(this.sql)}
414
420
  FROM ontology_source_rows AS rows
415
421
  JOIN requested
416
422
  ON requested.source_type_id = rows.source_type_id
417
423
  AND requested.source_id = rows.source_primary_id
418
424
  AND requested.link_id = rows.link_id
419
- JOIN ontology_source_roots AS sources
420
- ON sources.project_id = rows.project_id
421
- AND sources.source_id = rows.source_id
422
- AND sources.materialization_id = rows.materialization_id
423
- AND sources.root_sort_key = rows.root_sort_key
424
- WHERE rows.project_id = ${this.projectId}
425
- AND rows.entity_kind = 'link'
426
- AND sources.active
425
+ ${liveSourceRowsJoin(this.sql, this.projectId)}
426
+ WHERE rows.entity_kind = 'link'
427
427
  `,
428
428
  this.sql<LinkSlotOverrideRow[]>`
429
429
  WITH requested AS (
@@ -582,32 +582,20 @@ export class PgMaterializationStateReader {
582
582
  SELECT rows.source_type_id, rows.source_primary_id AS source_id,
583
583
  rows.link_id, rows.target_type_id, rows.target_primary_id AS target_id
584
584
  FROM ontology_source_rows AS rows
585
- JOIN ontology_source_roots AS sources
586
- ON sources.project_id = rows.project_id
587
- AND sources.source_id = rows.source_id
588
- AND sources.materialization_id = rows.materialization_id
589
- AND sources.root_sort_key = rows.root_sort_key
585
+ ${liveSourceRowsJoin(this.sql, this.projectId)}
590
586
  JOIN requested
591
587
  ON requested.object_type_id = rows.source_type_id
592
588
  AND requested.primary_id = rows.source_primary_id
593
- WHERE rows.project_id = ${this.projectId}
594
- AND rows.entity_kind = 'link'
595
- AND sources.active
589
+ WHERE rows.entity_kind = 'link'
596
590
  UNION
597
591
  SELECT rows.source_type_id, rows.source_primary_id AS source_id,
598
592
  rows.link_id, rows.target_type_id, rows.target_primary_id AS target_id
599
593
  FROM ontology_source_rows AS rows
600
- JOIN ontology_source_roots AS sources
601
- ON sources.project_id = rows.project_id
602
- AND sources.source_id = rows.source_id
603
- AND sources.materialization_id = rows.materialization_id
604
- AND sources.root_sort_key = rows.root_sort_key
594
+ ${liveSourceRowsJoin(this.sql, this.projectId)}
605
595
  JOIN requested
606
596
  ON requested.object_type_id = rows.target_type_id
607
597
  AND requested.primary_id = rows.target_primary_id
608
- WHERE rows.project_id = ${this.projectId}
609
- AND rows.entity_kind = 'link'
610
- AND sources.active
598
+ WHERE rows.entity_kind = 'link'
611
599
  ), selected AS (
612
600
  SELECT DISTINCT links.*,
613
601
  ${this.sql.unsafe(linkSortExpression("links"))} AS sort_key
@@ -912,32 +900,20 @@ export class PgMaterializationStateReader {
912
900
  SELECT rows.source_type_id, rows.source_primary_id AS source_id,
913
901
  rows.link_id, rows.target_type_id, rows.target_primary_id AS target_id
914
902
  FROM ontology_source_rows AS rows
915
- JOIN ontology_source_roots AS sources
916
- ON sources.project_id = rows.project_id
917
- AND sources.source_id = rows.source_id
918
- AND sources.materialization_id = rows.materialization_id
919
- AND sources.root_sort_key = rows.root_sort_key
903
+ ${liveSourceRowsJoin(this.sql, this.projectId)}
920
904
  JOIN incident_objects
921
905
  ON incident_objects.object_type_id = rows.source_type_id
922
906
  AND incident_objects.primary_id = rows.source_primary_id
923
- WHERE rows.project_id = ${this.projectId}
924
- AND rows.entity_kind = 'link'
925
- AND sources.active
907
+ WHERE rows.entity_kind = 'link'
926
908
  UNION
927
909
  SELECT rows.source_type_id, rows.source_primary_id AS source_id,
928
910
  rows.link_id, rows.target_type_id, rows.target_primary_id AS target_id
929
911
  FROM ontology_source_rows AS rows
930
- JOIN ontology_source_roots AS sources
931
- ON sources.project_id = rows.project_id
932
- AND sources.source_id = rows.source_id
933
- AND sources.materialization_id = rows.materialization_id
934
- AND sources.root_sort_key = rows.root_sort_key
912
+ ${liveSourceRowsJoin(this.sql, this.projectId)}
935
913
  JOIN incident_objects
936
914
  ON incident_objects.object_type_id = rows.target_type_id
937
915
  AND incident_objects.primary_id = rows.target_primary_id
938
- WHERE rows.project_id = ${this.projectId}
939
- AND rows.entity_kind = 'link'
940
- AND sources.active
916
+ WHERE rows.entity_kind = 'link'
941
917
  ), diff_links AS (
942
918
  SELECT * FROM replacement_links
943
919
  UNION SELECT * FROM incident_links
@@ -1104,9 +1080,7 @@ function storedSource(row: PgOntologySourceAssertionRow): StoredSourceAssertion
1104
1080
  return {
1105
1081
  source: { projectionId: row.source_id },
1106
1082
  materializationId: row.materialization_id,
1107
- root: structuredClone(row.root) as StoredSourceAssertion["root"],
1108
- assertion: structuredClone(row.assertion) as StoredSourceAssertion["assertion"],
1109
- stagingOrdinal: databaseSafeInteger(row.staging_ordinal, "Source staging ordinal"),
1083
+ ...sourceAssertion(row),
1110
1084
  } as StoredSourceAssertion
1111
1085
  }
1112
1086
 
@@ -1,6 +1,9 @@
1
1
  import { parseSixbFailure } from "@sixb/core/internal/errors"
2
- import type { ProjectionEntityRef } from "@sixb/core/internal/materialization"
3
2
  import { MaterializationConflictError } from "@sixb/core/internal/materialization"
3
+ import {
4
+ sourceAssertionFromColumns,
5
+ sourceEntityFromKey,
6
+ } from "@sixb/core/internal/ontology-storage-provider"
4
7
  import type {
5
8
  AssertSourceMaterializationExecutionInput,
6
9
  OntologyCommitRecord,
@@ -50,6 +53,7 @@ export interface PgOntologyCommitRow {
50
53
  }
51
54
 
52
55
  export interface PgOntologySourceRow {
56
+ readonly version_id: string
53
57
  readonly project_id: string
54
58
  readonly source_id: string
55
59
  readonly materialization_id: string
@@ -76,19 +80,21 @@ export interface PgOntologySourceRow {
76
80
  readonly updated_at: Date | string
77
81
  }
78
82
 
83
+ /** An assertion with its root and version, as `sourceAssertionColumns` selects it. */
79
84
  export interface PgOntologySourceAssertionRow {
80
- readonly project_id: string
81
85
  readonly source_id: string
82
86
  readonly materialization_id: string
83
- readonly entity_kind: "object" | "link"
84
- readonly entity_key: unknown
85
- readonly entity_sort_key: string
86
- readonly root_kind: "object" | "link"
87
- readonly root_key: unknown
88
- readonly root_sort_key: string
87
+ readonly root_key: string
89
88
  readonly staging_ordinal: number | string
90
- readonly root: unknown
91
- readonly assertion: unknown
89
+ readonly entity_kind: "object" | "link"
90
+ readonly object_type_id: string | null
91
+ readonly primary_id: string | null
92
+ readonly source_type_id: string | null
93
+ readonly source_primary_id: string | null
94
+ readonly link_id: string | null
95
+ readonly target_type_id: string | null
96
+ readonly target_primary_id: string | null
97
+ readonly payload: unknown
92
98
  }
93
99
 
94
100
  export interface PgStoredOverrideRow {
@@ -174,8 +180,8 @@ export function sourceRecord(row: PgOntologySourceRow): OntologySourceRecord {
174
180
 
175
181
  export function sourceAssertion(row: PgOntologySourceAssertionRow): StageSourceAssertion {
176
182
  return {
177
- root: structuredClone(row.root) as ProjectionEntityRef,
178
- assertion: structuredClone(row.assertion) as StageSourceAssertion["assertion"],
183
+ root: sourceEntityFromKey(row.root_key),
184
+ assertion: sourceAssertionFromColumns(row, row.payload),
179
185
  stagingOrdinal: databaseSafeInteger(row.staging_ordinal, "Source staging ordinal"),
180
186
  }
181
187
  }
@@ -1,24 +1,74 @@
1
1
  import type {
2
2
  CleanupTerminalSourceMaterializationsInput,
3
3
  CleanupTerminalSourceMaterializationsResult,
4
+ PurgeAbandonedSourceMaterializationsInput,
4
5
  } from "@sixb/core/storage"
5
6
  import type { SQLClient } from "../pg-client"
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 async function purgeAbandonedSourceVersions(
14
+ sql: SQLClient,
15
+ input: PurgeAbandonedSourceMaterializationsInput
16
+ ): Promise<CleanupTerminalSourceMaterializationsResult> {
17
+ let remaining = input.limit
18
+ let rowsDeleted = 0
19
+ let materializationsDeleted = 0
20
+ while (remaining > 0) {
21
+ const [candidate] = await sql<{ readonly version_id: string }[]>`
22
+ SELECT version_id FROM ontology_sources
23
+ WHERE project_id = ${input.projectId} AND status = 'abandoned'
24
+ ORDER BY terminal_at, source_id, materialization_id
25
+ LIMIT 1 FOR UPDATE SKIP LOCKED
26
+ `
27
+ if (!candidate) break
28
+ const rows = await sql`
29
+ DELETE FROM ontology_source_rows WHERE ctid IN (
30
+ SELECT rows.ctid FROM ontology_source_roots AS roots
31
+ JOIN ontology_source_rows AS rows ON rows.root_id = roots.id
32
+ WHERE roots.version_id = ${candidate.version_id} LIMIT ${remaining}
33
+ )
34
+ `
35
+ rowsDeleted += rows.count
36
+ remaining -= rows.count
37
+ if (remaining === 0) break
38
+ const roots = await sql`
39
+ DELETE FROM ontology_source_roots WHERE ctid IN (
40
+ SELECT ctid FROM ontology_source_roots
41
+ WHERE version_id = ${candidate.version_id} LIMIT ${remaining}
42
+ )
43
+ `
44
+ rowsDeleted += roots.count
45
+ remaining -= roots.count
46
+ if (remaining === 0) break
47
+ const manifest = await sql`
48
+ DELETE FROM ontology_sources
49
+ WHERE version_id = ${candidate.version_id} AND status = 'abandoned'
50
+ `
51
+ if (manifest.count === 0) break
52
+ materializationsDeleted += manifest.count
53
+ remaining -= manifest.count
54
+ }
55
+ return { rowsDeleted, materializationsDeleted }
56
+ }
57
+
7
58
  export async function cleanupSourceVersions(
8
59
  sql: SQLClient,
9
60
  input: CleanupTerminalSourceMaterializationsInput
10
61
  ): Promise<CleanupTerminalSourceMaterializationsResult> {
11
62
  let remaining = input.limit
63
+ const retired = sql`roots.project_id = ${input.projectId}
64
+ AND roots.retired_at < ${input.terminalBefore} AND versions.status = 'superseded'`
12
65
  const removedRows = await sql`
13
66
  WITH selected AS (
14
67
  SELECT rows.ctid FROM ontology_source_roots AS roots
15
- JOIN ontology_sources AS sources USING (project_id, source_id, materialization_id)
16
- 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 = ${input.projectId} AND NOT roots.active AND roots.retired_at < ${input.terminalBefore}
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
68
+ JOIN ontology_sources AS versions ON versions.version_id = roots.version_id
69
+ JOIN ontology_source_rows AS rows ON rows.root_id = roots.id
70
+ WHERE ${retired}
71
+ ORDER BY roots.retired_at, roots.id
22
72
  LIMIT ${remaining} FOR UPDATE OF rows SKIP LOCKED
23
73
  ) DELETE FROM ontology_source_rows AS rows USING selected WHERE rows.ctid = selected.ctid RETURNING 1
24
74
  `
@@ -28,13 +78,10 @@ export async function cleanupSourceVersions(
28
78
  const removed = await sql`
29
79
  WITH selected AS (
30
80
  SELECT roots.ctid FROM ontology_source_roots AS roots
31
- JOIN ontology_sources AS sources USING (project_id, source_id, materialization_id)
32
- WHERE roots.project_id = ${input.projectId} AND NOT roots.active AND roots.retired_at < ${input.terminalBefore}
33
- AND sources.status IN ('superseded', 'abandoned')
34
- AND NOT EXISTS (SELECT 1 FROM ontology_source_rows AS rows
35
- WHERE rows.project_id = roots.project_id AND rows.source_id = roots.source_id
36
- AND rows.materialization_id = roots.materialization_id AND rows.root_sort_key = roots.root_sort_key)
37
- ORDER BY roots.retired_at, roots.source_id, roots.materialization_id, roots.root_sort_key
81
+ JOIN ontology_sources AS versions ON versions.version_id = roots.version_id
82
+ WHERE ${retired}
83
+ AND NOT EXISTS (SELECT 1 FROM ontology_source_rows AS rows WHERE rows.root_id = roots.id)
84
+ ORDER BY roots.retired_at, roots.id
38
85
  LIMIT ${remaining} FOR UPDATE OF roots SKIP LOCKED
39
86
  ) DELETE FROM ontology_source_roots AS roots USING selected WHERE roots.ctid = selected.ctid RETURNING 1
40
87
  `
@@ -46,16 +93,13 @@ export async function cleanupSourceVersions(
46
93
  ? []
47
94
  : await sql`
48
95
  WITH selected AS (
49
- SELECT sources.ctid FROM ontology_sources AS sources
50
- WHERE project_id = ${input.projectId} AND status IN ('superseded', 'abandoned') AND terminal_at < ${input.terminalBefore}
96
+ SELECT versions.ctid FROM ontology_sources AS versions
97
+ WHERE project_id = ${input.projectId} AND status = 'superseded'
98
+ AND terminal_at < ${input.terminalBefore}
51
99
  AND NOT EXISTS (SELECT 1 FROM ontology_source_roots AS roots
52
- WHERE roots.project_id = sources.project_id AND roots.source_id = sources.source_id
53
- AND roots.materialization_id = sources.materialization_id)
54
- AND NOT EXISTS (SELECT 1 FROM ontology_source_rows AS rows
55
- WHERE rows.project_id = sources.project_id AND rows.source_id = sources.source_id
56
- AND rows.materialization_id = sources.materialization_id)
100
+ WHERE roots.version_id = versions.version_id)
57
101
  ORDER BY terminal_at, source_id, materialization_id LIMIT ${remaining} FOR UPDATE SKIP LOCKED
58
- ) DELETE FROM ontology_sources AS sources USING selected WHERE sources.ctid = selected.ctid RETURNING 1
102
+ ) DELETE FROM ontology_sources AS versions USING selected WHERE versions.ctid = selected.ctid RETURNING 1
59
103
  `
60
104
  return { rowsDeleted, materializationsDeleted: removedManifests.length }
61
105
  }