@sixb/pg 0.1.14 → 0.1.16

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/index.d.ts +8 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +743 -313
  4. package/dist/index.js.map +15 -15
  5. package/dist/latest-run-query.d.ts +2 -0
  6. package/dist/latest-run-query.d.ts.map +1 -1
  7. package/dist/migrations.d.ts.map +1 -1
  8. package/dist/ontology-storage/materialization-state.d.ts.map +1 -1
  9. package/dist/ontology-storage/shared.d.ts +12 -9
  10. package/dist/ontology-storage/shared.d.ts.map +1 -1
  11. package/dist/ontology-storage/source-cleanup.d.ts +7 -1
  12. package/dist/ontology-storage/source-cleanup.d.ts.map +1 -1
  13. package/dist/ontology-storage/source-roots.d.ts +8 -0
  14. package/dist/ontology-storage/source-roots.d.ts.map +1 -1
  15. package/dist/ontology-storage/sources.d.ts +8 -3
  16. package/dist/ontology-storage/sources.d.ts.map +1 -1
  17. package/dist/pg-client.d.ts +2 -0
  18. package/dist/pg-client.d.ts.map +1 -1
  19. package/dist/pg-projection-run-storage.d.ts +3 -1
  20. package/dist/pg-projection-run-storage.d.ts.map +1 -1
  21. package/dist/storage-errors.d.ts +13 -3
  22. package/dist/storage-errors.d.ts.map +1 -1
  23. package/dist/transactions.d.ts +23 -1
  24. package/dist/transactions.d.ts.map +1 -1
  25. package/package.json +3 -3
  26. package/src/index.ts +8 -1
  27. package/src/latest-run-query.ts +10 -2
  28. package/src/migrations/051-projection-run-supersession.sql +43 -0
  29. package/src/migrations/052-compact-source-storage.sql +118 -0
  30. package/src/migrations.ts +26 -23
  31. package/src/objects/query-compiler.ts +1 -1
  32. package/src/ontology-storage/materialization-state.ts +20 -46
  33. package/src/ontology-storage/shared.ts +18 -12
  34. package/src/ontology-storage/source-cleanup.ts +66 -22
  35. package/src/ontology-storage/source-roots.ts +164 -102
  36. package/src/ontology-storage/sources.ts +150 -120
  37. package/src/pg-client.ts +132 -2
  38. package/src/pg-projection-run-storage.ts +56 -2
  39. package/src/storage-errors.ts +101 -15
  40. package/src/transactions.ts +113 -20
package/src/migrations.ts CHANGED
@@ -109,7 +109,14 @@ import nativeSessionsSql from "./migrations/049-native-sessions.sql" with { type
109
109
  import directoryGroupMembershipsSql from "./migrations/050-directory-group-memberships.sql" with {
110
110
  type: "text",
111
111
  }
112
- import type { SQL, SQLClient } from "./pg-client"
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"
113
120
 
114
121
  export interface PostgresMigrationContext {
115
122
  exec(sqlText: string): Promise<void>
@@ -189,7 +196,7 @@ export function quoteIdent(identifier: string): string {
189
196
  }
190
197
 
191
198
  function postgresMigrationSession(
192
- sql: SQL,
199
+ sql: ReservedSQL,
193
200
  schemaName: string
194
201
  ): { context: PostgresMigrationContext; state: MigrationHistoryStore } {
195
202
  const schema = quoteIdent(schemaName)
@@ -256,19 +263,15 @@ function postgresMigrationSession(
256
263
  },
257
264
  async transaction(run) {
258
265
  // `sql` is the reserved connection holding the migration advisory lock. porsager's
259
- // reserved connection has no `.begin`, so drive the transaction manually on it and
260
- // 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`.
261
268
  const previous = active
262
269
  active = sql
263
- await sql.unsafe("BEGIN")
264
270
  try {
265
- await sql.unsafe(`SET LOCAL search_path TO ${schema}`)
266
- const result = await run()
267
- await sql.unsafe("COMMIT")
268
- return result
269
- } catch (error) {
270
- await sql.unsafe("ROLLBACK")
271
- throw error
271
+ return await runPgTransactionOn(sql, async () => {
272
+ await sql.unsafe(`SET LOCAL search_path TO ${schema}`)
273
+ return run()
274
+ })
272
275
  } finally {
273
276
  active = previous
274
277
  }
@@ -315,23 +318,21 @@ async function withPostgresMigrationLock<T>(
315
318
  readonly schemaName: string
316
319
  readonly migrations: MigrationSet<PostgresMigrationContext>
317
320
  },
318
- run: (sql: SQL) => Promise<T>
321
+ run: (sql: ReservedSQL) => Promise<T>
319
322
  ): Promise<T> {
320
- const sql = await params.sql.reserve()
321
323
  const [first, second] = advisoryLockParts(
322
324
  `storage:migration:${params.schemaName}:${params.migrations.adapterId}`
323
325
  )
324
326
 
325
- 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})`
326
331
  await sql`SELECT pg_advisory_lock(${first}, ${second})`
327
- try {
328
- return await run(sql)
329
- } finally {
330
- await sql`SELECT pg_advisory_unlock(${first}, ${second})`
331
- }
332
- } finally {
333
- sql.release()
334
- }
332
+ const result = await undoOnFailure(() => run(sql), unlock)
333
+ await unlock()
334
+ return result
335
+ })
335
336
  }
336
337
 
337
338
  function advisoryLockParts(key: string): readonly [number, number] {
@@ -412,6 +413,8 @@ export const postgresStorageMigrations = defineMigrations<PostgresMigrationConte
412
413
  pgSql("048-file-upload-sessions", fileUploadSessionsSql),
413
414
  pgSql("049-native-sessions", nativeSessionsSql),
414
415
  pgSql("050-directory-group-memberships", directoryGroupMembershipsSql),
416
+ pgSql("051-projection-run-supersession", projectionRunSupersessionSql),
417
+ pgSql("052-compact-source-storage", compactSourceStorageSql),
415
418
  ],
416
419
  })
417
420
 
@@ -110,7 +110,7 @@ export function compilePgObjectQuery(
110
110
  if (hasVectorProfile(query) && !isVectorProfileQuery(query)) {
111
111
  throw new ObjectQueryExecutionError(
112
112
  "unsupported_vector_composition",
113
- "Vector search supports one profile with filters before ranking and limit/project after."
113
+ "Vector search supports one profile with filters, traversals or sets before ranking and limit/project after."
114
114
  )
115
115
  }
116
116
  const source = options.source ?? DEFAULT_OBJECT_QUERY_SOURCE
@@ -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
  }