@sixb/pg 0.1.16 → 0.1.17

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 (92) hide show
  1. package/README.md +125 -0
  2. package/dist/index.d.ts +15 -2
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +3201 -1473
  5. package/dist/index.js.map +32 -25
  6. package/dist/migrations.d.ts.map +1 -1
  7. package/dist/objects/aggregate-admission.d.ts +10 -0
  8. package/dist/objects/aggregate-admission.d.ts.map +1 -0
  9. package/dist/objects/preparation-lock.d.ts +6 -0
  10. package/dist/objects/preparation-lock.d.ts.map +1 -0
  11. package/dist/objects/prepare-queries.d.ts +5 -0
  12. package/dist/objects/prepare-queries.d.ts.map +1 -0
  13. package/dist/objects/query-compiler.d.ts +19 -0
  14. package/dist/objects/query-compiler.d.ts.map +1 -1
  15. package/dist/objects/query-indexes.d.ts +43 -0
  16. package/dist/objects/query-indexes.d.ts.map +1 -0
  17. package/dist/objects/reader.d.ts +7 -1
  18. package/dist/objects/reader.d.ts.map +1 -1
  19. package/dist/objects/storage.d.ts +1 -1
  20. package/dist/objects/storage.d.ts.map +1 -1
  21. package/dist/objects/text-count-index.d.ts +24 -0
  22. package/dist/objects/text-count-index.d.ts.map +1 -0
  23. package/dist/ontology-storage/index.d.ts +2 -0
  24. package/dist/ontology-storage/index.d.ts.map +1 -1
  25. package/dist/ontology-storage/materialization-session.d.ts +18 -38
  26. package/dist/ontology-storage/materialization-session.d.ts.map +1 -1
  27. package/dist/ontology-storage/materialization-state.d.ts +3 -26
  28. package/dist/ontology-storage/materialization-state.d.ts.map +1 -1
  29. package/dist/ontology-storage/materialization-writer.d.ts +51 -9
  30. package/dist/ontology-storage/materialization-writer.d.ts.map +1 -1
  31. package/dist/ontology-storage/materializations.d.ts +5 -7
  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/replacement-plans.d.ts +36 -0
  35. package/dist/ontology-storage/replacement-plans.d.ts.map +1 -0
  36. package/dist/ontology-storage/shared.d.ts +11 -5
  37. package/dist/ontology-storage/shared.d.ts.map +1 -1
  38. package/dist/ontology-storage/source-cleanup.d.ts.map +1 -1
  39. package/dist/ontology-storage/source-roots.d.ts +14 -1
  40. package/dist/ontology-storage/source-roots.d.ts.map +1 -1
  41. package/dist/ontology-storage/sources.d.ts +1 -2
  42. package/dist/ontology-storage/sources.d.ts.map +1 -1
  43. package/dist/pg-ai-cost-storage.d.ts.map +1 -1
  44. package/dist/pg-ai-limit-storage.d.ts.map +1 -1
  45. package/dist/pg-ai-usage-storage.d.ts +2 -0
  46. package/dist/pg-ai-usage-storage.d.ts.map +1 -1
  47. package/dist/pg-execution-storage.d.ts +28 -0
  48. package/dist/pg-execution-storage.d.ts.map +1 -1
  49. package/dist/pg-file-download-grant-storage.d.ts +11 -0
  50. package/dist/pg-file-download-grant-storage.d.ts.map +1 -0
  51. package/dist/pg-projection-run-storage.d.ts.map +1 -1
  52. package/dist/pg-rules-storage.d.ts.map +1 -1
  53. package/dist/transactions.d.ts +6 -2
  54. package/dist/transactions.d.ts.map +1 -1
  55. package/package.json +3 -3
  56. package/src/index.ts +53 -3
  57. package/src/migrations/053-replacement-plans.sql +72 -0
  58. package/src/migrations/054-slim-ontology-outbox.sql +108 -0
  59. package/src/migrations/055-object-query-preparation.sql +11 -0
  60. package/src/migrations/056-commit-touches.sql +41 -0
  61. package/src/migrations/057-audio-transcription.sql +10 -0
  62. package/src/migrations/058-reranking-model-kind.sql +11 -0
  63. package/src/migrations/059-file-download-grants.sql +16 -0
  64. package/src/migrations/060-drained-source-versions.sql +11 -0
  65. package/src/migrations.ts +20 -0
  66. package/src/objects/aggregate-admission.ts +21 -0
  67. package/src/objects/preparation-lock.ts +15 -0
  68. package/src/objects/prepare-queries.ts +181 -0
  69. package/src/objects/query-compiler.ts +404 -114
  70. package/src/objects/query-indexes.ts +348 -0
  71. package/src/objects/reader.ts +140 -51
  72. package/src/objects/storage.ts +15 -3
  73. package/src/objects/text-count-index.ts +455 -0
  74. package/src/ontology-storage/index.ts +3 -0
  75. package/src/ontology-storage/materialization-session.ts +116 -275
  76. package/src/ontology-storage/materialization-state.ts +8 -259
  77. package/src/ontology-storage/materialization-writer.ts +618 -508
  78. package/src/ontology-storage/materializations.ts +106 -289
  79. package/src/ontology-storage/outbox.ts +46 -15
  80. package/src/ontology-storage/replacement-plans.ts +1110 -0
  81. package/src/ontology-storage/shared.ts +27 -15
  82. package/src/ontology-storage/source-cleanup.ts +22 -10
  83. package/src/ontology-storage/source-roots.ts +35 -8
  84. package/src/ontology-storage/sources.ts +0 -30
  85. package/src/pg-ai-cost-storage.ts +12 -0
  86. package/src/pg-ai-limit-storage.ts +13 -2
  87. package/src/pg-ai-usage-storage.ts +8 -0
  88. package/src/pg-execution-storage.ts +8 -3
  89. package/src/pg-file-download-grant-storage.ts +76 -0
  90. package/src/pg-projection-run-storage.ts +6 -1
  91. package/src/pg-rules-storage.ts +7 -3
  92. package/src/transactions.ts +10 -3
@@ -0,0 +1,108 @@
1
+ -- An outbox event stores what changed and nothing it can be rebuilt from: delivery rebuilds the
2
+ -- event from its commit and the execution that commit ran under. The commit also stops copying
3
+ -- `requested_by` and `executor` from that execution (047): the execution is canonical, immutable,
4
+ -- and already referenced by the commit.
5
+
6
+ -- Every stored event must rebuild exactly as it was stored: consumers would otherwise receive a
7
+ -- different event than the one committed. Fail rather than convert one that would not. Who asked
8
+ -- and what wrote are derived from the execution exactly as 047 derived the commit's copies.
9
+ DO $$
10
+ DECLARE
11
+ mismatched BIGINT;
12
+ BEGIN
13
+ SELECT COUNT(*) INTO mismatched
14
+ FROM ontology_outbox AS outbox
15
+ LEFT JOIN ontology_commits AS commits
16
+ ON commits.project_id = outbox.project_id AND commits.id = outbox.commit_id
17
+ LEFT JOIN executions
18
+ ON executions.project_id = commits.project_id AND executions.id = commits.execution_id
19
+ CROSS JOIN LATERAL (
20
+ SELECT outbox.envelope->>'type' AS type, outbox.envelope->'payload' AS payload
21
+ ) AS event
22
+ CROSS JOIN LATERAL (
23
+ SELECT
24
+ CASE
25
+ WHEN executions.requested_by_user_id IS NOT NULL
26
+ THEN jsonb_build_object('type', 'user', 'id', executions.requested_by_user_id)
27
+ WHEN executions.requested_by_service_account_id IS NOT NULL
28
+ THEN jsonb_build_object(
29
+ 'type', 'serviceAccount', 'id', executions.requested_by_service_account_id
30
+ )
31
+ END AS requested_by,
32
+ CASE executions.executor_kind
33
+ WHEN 'request'
34
+ THEN jsonb_build_object('type', 'request', 'requestId', executions.executor_id)
35
+ WHEN 'agent' THEN jsonb_build_object('type', 'agent', 'runId', executions.executor_id)
36
+ WHEN 'kernel' THEN jsonb_build_object(
37
+ 'type', 'kernel',
38
+ 'operation',
39
+ CASE executions.authority_kernel_operation
40
+ WHEN 'ontology.recover'
41
+ THEN jsonb_build_object('type', 'ontology.recover', 'recoveryId', executions.executor_id)
42
+ ELSE jsonb_build_object(
43
+ 'type', 'ontology.indexVectors', 'indexingId', executions.executor_id
44
+ )
45
+ END
46
+ )
47
+ ELSE jsonb_build_object(
48
+ 'type', 'primitive',
49
+ 'kind', executions.executor_kind,
50
+ 'id', executions.authority_primitive_id,
51
+ 'runId', executions.executor_id
52
+ )
53
+ END AS executor
54
+ ) AS attribution
55
+ WHERE executions.id IS NULL
56
+ OR outbox.envelope->>'id' IS DISTINCT FROM outbox.id
57
+ OR (outbox.envelope->>'commitOrdinal')::bigint IS DISTINCT FROM outbox.commit_ordinal
58
+ OR outbox.envelope->>'commitId' IS DISTINCT FROM commits.id
59
+ OR outbox.envelope->>'projectId' IS DISTINCT FROM commits.project_id
60
+ OR outbox.envelope->'schemaVersion' IS DISTINCT FROM '1'::jsonb
61
+ OR (outbox.envelope->>'occurredAt')::timestamptz IS DISTINCT FROM commits.committed_at
62
+ OR outbox.envelope->'origin' IS DISTINCT FROM commits.origin
63
+ OR outbox.envelope->>'correlationId' IS DISTINCT FROM executions.correlation_id
64
+ OR outbox.envelope->'requestedBy' IS DISTINCT FROM attribution.requested_by
65
+ OR outbox.envelope->'executor' IS DISTINCT FROM attribution.executor
66
+ OR outbox.envelope->>'topic' IS DISTINCT FROM CASE split_part(event.type, '.', 1)
67
+ WHEN 'object' THEN 'objects'
68
+ WHEN 'link' THEN 'links'
69
+ WHEN 'telemetry' THEN 'telemetry'
70
+ END
71
+ OR outbox.envelope->>'partitionKey' IS DISTINCT FROM CASE split_part(event.type, '.', 1)
72
+ WHEN 'object' THEN concat_ws(':', event.payload->>'objectTypeId', event.payload->>'primaryId')
73
+ WHEN 'link' THEN concat_ws(
74
+ ':', event.payload->>'sourceTypeId', event.payload->>'sourceId', event.payload->>'linkId'
75
+ )
76
+ WHEN 'telemetry' THEN concat_ws(
77
+ ':', event.payload->>'objectTypeId', event.payload->>'objectId', event.payload->>'propertyId'
78
+ )
79
+ END
80
+ OR (
81
+ event.type IN ('object.created', 'link.created')
82
+ AND event.payload->'propertyChanges' IS DISTINCT FROM COALESCE(
83
+ (
84
+ SELECT jsonb_object_agg(
85
+ property.key, jsonb_build_object('operation', 'created', 'after', property.value)
86
+ )
87
+ FROM jsonb_each(COALESCE(event.payload->'properties', '{}'::jsonb)) AS property
88
+ ),
89
+ '{}'::jsonb
90
+ )
91
+ );
92
+ IF mismatched > 0 THEN
93
+ RAISE EXCEPTION '[SixbPg] % outbox events would not rebuild from their commit.', mismatched;
94
+ END IF;
95
+ END $$;
96
+
97
+ ALTER TABLE ontology_outbox ADD COLUMN event JSONB;
98
+ UPDATE ontology_outbox SET event = jsonb_build_object(
99
+ 'type', envelope->'type',
100
+ 'payload', CASE
101
+ WHEN envelope->>'type' IN ('object.created', 'link.created')
102
+ THEN (envelope->'payload') - 'propertyChanges'
103
+ ELSE envelope->'payload'
104
+ END
105
+ );
106
+ ALTER TABLE ontology_outbox ALTER COLUMN event SET NOT NULL, DROP COLUMN envelope;
107
+
108
+ ALTER TABLE ontology_commits DROP COLUMN requested_by, DROP COLUMN executor;
@@ -0,0 +1,11 @@
1
+ -- Object predicates use typed property expressions, not containment on the whole JSON document.
2
+ -- The whole-document index accelerates none of the compiler paths and amplifies every write.
3
+ DROP INDEX IF EXISTS idx_objects_properties;
4
+
5
+ -- Completion is recorded only after all non-transactional index builds and generated-column backfills finish.
6
+ CREATE TABLE object_query_preparation (
7
+ singleton BOOLEAN PRIMARY KEY DEFAULT true CHECK (singleton),
8
+ fingerprint TEXT NOT NULL,
9
+ result JSONB NOT NULL,
10
+ prepared_at TIMESTAMPTZ NOT NULL DEFAULT now()
11
+ );
@@ -0,0 +1,41 @@
1
+ -- Every commit records the entities whose plan inputs it changed, so a replacement plan's refresh
2
+ -- rechecks only what the commits since it was last fresh touched, instead of the whole plan.
3
+ -- UNLOGGED like the plans they serve: a crash truncates both together.
4
+ CREATE UNLOGGED TABLE ontology_commit_touches (
5
+ project_id TEXT NOT NULL,
6
+ -- The transaction of the commit, compared with a plan's watermark snapshot like `xact_id` on
7
+ -- `ontology_commits`.
8
+ xact_id xid8 NOT NULL DEFAULT pg_current_xact_id(),
9
+ entity_kind TEXT NOT NULL CHECK (entity_kind IN ('object', 'link', 'scope')),
10
+ -- `objectRefKey`, `linkRefKey` or `linkScopeKey`.
11
+ identity_key TEXT COLLATE "C" NOT NULL
12
+ );
13
+ CREATE INDEX idx_ontology_commit_touches_xact ON ontology_commit_touches (project_id, xact_id);
14
+
15
+ -- Touches of the project's commits before this transaction may have been purged: a plan whose
16
+ -- watermark does not see them all checks every identity.
17
+ CREATE UNLOGGED TABLE ontology_commit_touch_horizons (
18
+ project_id TEXT PRIMARY KEY,
19
+ xact_id xid8 NOT NULL
20
+ );
21
+
22
+ -- Existing plans predate the touches of the commits since their watermark: they plan again.
23
+ TRUNCATE ontology_replacement_plan_work, ontology_replacement_plan_identities,
24
+ ontology_replacement_plans;
25
+
26
+ -- Link expansion starts only from what changed since it last ran: `expansions` counts the runs,
27
+ -- and an identity's `expand_at` is the run that has yet to start from it (an object once planned,
28
+ -- a link once it needs a diff). `source_key` and `target_key` find a link from either endpoint.
29
+ ALTER TABLE ontology_replacement_plans ADD COLUMN expansions INTEGER NOT NULL DEFAULT 0;
30
+ ALTER TABLE ontology_replacement_plan_identities
31
+ ADD COLUMN expand_at INTEGER,
32
+ ADD COLUMN source_key TEXT COLLATE "C",
33
+ ADD COLUMN target_key TEXT COLLATE "C";
34
+ CREATE INDEX idx_ontology_replacement_plan_identities_expand
35
+ ON ontology_replacement_plan_identities (version_id, entity_kind, expand_at);
36
+ CREATE INDEX idx_ontology_replacement_plan_identities_source
37
+ ON ontology_replacement_plan_identities (version_id, source_key)
38
+ WHERE source_key IS NOT NULL;
39
+ CREATE INDEX idx_ontology_replacement_plan_identities_target
40
+ ON ontology_replacement_plan_identities (version_id, target_key)
41
+ WHERE target_key IS NOT NULL;
@@ -0,0 +1,10 @@
1
+ ALTER TABLE ai_model_call_usage
2
+ ADD COLUMN audio_duration_ms BIGINT
3
+ CHECK (audio_duration_ms IS NULL OR audio_duration_ms >= 0);
4
+
5
+ ALTER TABLE ai_model_call_usage
6
+ ADD COLUMN model_kind TEXT
7
+ CHECK (
8
+ model_kind IS NULL
9
+ OR model_kind IN ('language', 'image', 'video', 'embedding', 'decision', 'transcription')
10
+ );
@@ -0,0 +1,11 @@
1
+ ALTER TABLE ai_model_call_usage
2
+ DROP CONSTRAINT ai_model_call_usage_model_kind_check;
3
+
4
+ ALTER TABLE ai_model_call_usage
5
+ ADD CONSTRAINT ai_model_call_usage_model_kind_check
6
+ CHECK (
7
+ model_kind IS NULL
8
+ OR model_kind IN (
9
+ 'language', 'image', 'video', 'embedding', 'decision', 'transcription', 'reranking'
10
+ )
11
+ );
@@ -0,0 +1,16 @@
1
+ -- URLs issued by blobs.createDownloadUrl(). Only the SHA-256 of each URL's token is stored.
2
+ CREATE TABLE file_download_grants (
3
+ project_id TEXT NOT NULL CHECK (length(trim(project_id)) > 0),
4
+ id TEXT NOT NULL CHECK (length(trim(id)) > 0),
5
+ token_hash TEXT NOT NULL CHECK (token_hash ~ '^[0-9a-f]{64}$'),
6
+ file JSONB NOT NULL CHECK (jsonb_typeof(file) = 'object'),
7
+ execution_id TEXT NOT NULL CHECK (length(trim(execution_id)) > 0),
8
+ created_at TIMESTAMPTZ NOT NULL,
9
+ expires_at TIMESTAMPTZ NOT NULL,
10
+ revoked_at TIMESTAMPTZ,
11
+ PRIMARY KEY (project_id, id),
12
+ UNIQUE (project_id, token_hash),
13
+ CHECK (expires_at > created_at)
14
+ );
15
+
16
+ CREATE INDEX idx_file_download_grants_expiry ON file_download_grants (project_id, expires_at);
@@ -0,0 +1,11 @@
1
+ -- A superseded version's `root_count` counts the roots it still holds: cleanup lowers it as it
2
+ -- deletes them. A version that reaches zero is found through this index, instead of every
3
+ -- superseded version being walked and probed for roots on every maintenance pass.
4
+ UPDATE ontology_sources AS versions
5
+ SET root_count = (SELECT count(*) FROM ontology_source_roots AS roots
6
+ WHERE roots.version_id = versions.version_id)
7
+ WHERE status = 'superseded' AND root_count <> (SELECT count(*) FROM ontology_source_roots AS roots
8
+ WHERE roots.version_id = versions.version_id);
9
+ CREATE INDEX idx_ontology_sources_drained
10
+ ON ontology_sources (project_id, terminal_at, source_id, materialization_id)
11
+ WHERE status = 'superseded' AND root_count = 0;
package/src/migrations.ts CHANGED
@@ -115,6 +115,14 @@ import projectionRunSupersessionSql from "./migrations/051-projection-run-supers
115
115
  import compactSourceStorageSql from "./migrations/052-compact-source-storage.sql" with {
116
116
  type: "text",
117
117
  }
118
+ import replacementPlansSql from "./migrations/053-replacement-plans.sql" with { type: "text" }
119
+ import slimOntologyOutboxSql from "./migrations/054-slim-ontology-outbox.sql" with { type: "text" }
120
+ import objectQueryPreparationSql from "./migrations/055-object-query-preparation.sql" with {
121
+ type: "text",
122
+ }
123
+ import commitTouchesSql from "./migrations/056-commit-touches.sql" with { type: "text" }
124
+ import audioTranscriptionSql from "./migrations/057-audio-transcription.sql" with { type: "text" }
125
+ import rerankingModelKindSql from "./migrations/058-reranking-model-kind.sql" with { type: "text" }
118
126
  import type { ReservedSQL, SQL, SQLClient } from "./pg-client"
119
127
  import { runPgTransactionOn, undoOnFailure, withReservedPgConnection } from "./transactions"
120
128
 
@@ -415,6 +423,14 @@ export const postgresStorageMigrations = defineMigrations<PostgresMigrationConte
415
423
  pgSql("050-directory-group-memberships", directoryGroupMembershipsSql),
416
424
  pgSql("051-projection-run-supersession", projectionRunSupersessionSql),
417
425
  pgSql("052-compact-source-storage", compactSourceStorageSql),
426
+ pgSql("053-replacement-plans", replacementPlansSql),
427
+ pgSql("054-slim-ontology-outbox", slimOntologyOutboxSql),
428
+ pgSql("055-object-query-preparation", objectQueryPreparationSql),
429
+ pgSql("056-commit-touches", commitTouchesSql),
430
+ pgSql("057-audio-transcription", audioTranscriptionSql),
431
+ pgSql("058-reranking-model-kind", rerankingModelKindSql),
432
+ pgSql("059-file-download-grants", fileDownloadGrantsSql),
433
+ pgSql("060-drained-source-versions", drainedSourceVersionsSql),
418
434
  ],
419
435
  })
420
436
 
@@ -439,3 +455,7 @@ import connectorOptionalPkceSql from "./migrations/040-connector-optional-pkce.s
439
455
  type: "text",
440
456
  }
441
457
  import fileUploadSessionsSql from "./migrations/048-file-upload-sessions.sql" with { type: "text" }
458
+ import fileDownloadGrantsSql from "./migrations/059-file-download-grants.sql" with { type: "text" }
459
+ import drainedSourceVersionsSql from "./migrations/060-drained-source-versions.sql" with {
460
+ type: "text",
461
+ }
@@ -0,0 +1,21 @@
1
+ /** Limit expensive root aggregates before they reserve a database connection. Pages can
2
+ * use the remaining pool capacity. No results or object revisions are retained here. */
3
+ export class PgAggregateAdmission {
4
+ private active = 0
5
+ private readonly waiting: (() => void)[] = []
6
+
7
+ constructor(private readonly concurrency = 1) {}
8
+
9
+ async run<T>(compute: () => Promise<T>): Promise<T> {
10
+ if (this.active >= this.concurrency)
11
+ await new Promise<void>((resolve) => this.waiting.push(resolve))
12
+ else this.active++
13
+ try {
14
+ return await compute()
15
+ } finally {
16
+ const next = this.waiting.shift()
17
+ if (next) next()
18
+ else this.active--
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,15 @@
1
+ import type { ReservedSQL } from "../pg-client"
2
+
3
+ /** CREATE INDEX CONCURRENTLY on expressions/partial indexes waits for older snapshots.
4
+ * A blocking pg_advisory_lock SELECT can hold one while waiting for the index builder,
5
+ * producing a cycle. Try-lock in short statements and wait outside PostgreSQL instead. */
6
+ export async function lockPgPreparation(sql: ReservedSQL, key: string): Promise<void> {
7
+ let delay = 25
8
+ while (true) {
9
+ const [row] = await sql<{ acquired: boolean }[]>`
10
+ SELECT pg_try_advisory_lock(hashtextextended(${key}, 0)) AS acquired`
11
+ if (row?.acquired) return
12
+ await Bun.sleep(delay)
13
+ delay = Math.min(delay * 2, 500)
14
+ }
15
+ }
@@ -0,0 +1,181 @@
1
+ import { createHash } from "node:crypto"
2
+ import type { ObjectQueryIndexDefinition } from "@sixb/core"
3
+ import type { ObjectQueryPreparationPlan, ObjectQueryPreparationResult } from "@sixb/core/storage"
4
+ import type { ReservedSQL, SQL, SQLClient } from "../pg-client"
5
+ import { undoOnFailure, withReservedPgConnection } from "../transactions"
6
+ import { lockPgPreparation } from "./preparation-lock"
7
+ import { ensurePgObjectQueryIndexes, type PgObjectQueryIndex } from "./query-indexes"
8
+ import { type PgObjectTextCountIndex, preparePgObjectTextCounts } from "./text-count-index"
9
+
10
+ /** Reconcile ontology indexes and text columns once per plan, coordinating concurrent startups. */
11
+ export async function preparePgObjectQueries(
12
+ sql: SQL,
13
+ schema: string,
14
+ plan: ObjectQueryPreparationPlan
15
+ ): Promise<ObjectQueryPreparationResult> {
16
+ const fingerprint = preparationFingerprint(plan)
17
+ const current = await readCompletedPreparation(sql, fingerprint)
18
+ if (current) return current
19
+
20
+ // Hold one session through the entire preparation, including CONCURRENTLY builds (which
21
+ // cannot run in a transaction). Helpers reuse it instead of taking another pool connection.
22
+ return withReservedPgConnection(sql, async (connection) => {
23
+ const key = `${schema}.objects.query-indexes`
24
+ await lockPgPreparation(connection, key)
25
+ const unlock = () => connection`SELECT pg_advisory_unlock(hashtextextended(${key}, 0))`
26
+ const result = await undoOnFailure(async () => {
27
+ const existing = await readCompletedPreparation(connection, fingerprint)
28
+ if (existing) return existing
29
+
30
+ console.info(
31
+ `[SixbPg] Preparing query indexes for ${plan.projectId}; initial backfills may block writes.`
32
+ )
33
+ const result = await prepareStructures(connection, schema, plan)
34
+
35
+ // Record completion only after every index and generated-column backfill succeeds.
36
+ await connection`
37
+ INSERT INTO object_query_preparation (singleton, fingerprint, result)
38
+ VALUES (true, ${fingerprint}, ${connection.json({ ...result, warnings: [...result.warnings] })})
39
+ ON CONFLICT (singleton) DO UPDATE
40
+ SET fingerprint = excluded.fingerprint, result = excluded.result, prepared_at = now()
41
+ `
42
+ return result
43
+ }, unlock)
44
+ await unlock()
45
+
46
+ return result
47
+ })
48
+ }
49
+
50
+ function preparationFingerprint(plan: ObjectQueryPreparationPlan): string {
51
+ const objectTypes = plan.objectTypes
52
+ .map((type) => ({
53
+ objectTypeId: type.objectTypeId,
54
+ properties: [...type.properties].sort((a, b) => a.propertyId.localeCompare(b.propertyId)),
55
+ indexes: type.indexes
56
+ .map(normalizeIndex)
57
+ .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
58
+ }))
59
+ .sort((a, b) => a.objectTypeId.localeCompare(b.objectTypeId))
60
+
61
+ // Bump the version when physical generation changes, even if the ontology stays the same.
62
+ const definition = { version: 1, projectId: plan.projectId, objectTypes }
63
+ return createHash("sha256").update(JSON.stringify(definition)).digest("hex")
64
+ }
65
+
66
+ function normalizeIndex(index: ObjectQueryIndexDefinition) {
67
+ if (index.kind === "sort") {
68
+ return {
69
+ kind: index.kind,
70
+ fields: index.fields.map((field) => ({
71
+ propertyId: field.propertyId,
72
+ direction: field.direction ?? "asc",
73
+ })),
74
+ filters: index.filters ?? [],
75
+ }
76
+ }
77
+
78
+ return {
79
+ kind: index.kind,
80
+ propertyId: index.propertyId,
81
+ filters: [...(index.filters ?? [])].sort(),
82
+ }
83
+ }
84
+
85
+ async function readCompletedPreparation(
86
+ sql: SQLClient,
87
+ fingerprint: string
88
+ ): Promise<ObjectQueryPreparationResult | undefined> {
89
+ const [row] = await sql<{ result: ObjectQueryPreparationResult }[]>`
90
+ SELECT result FROM object_query_preparation
91
+ WHERE singleton AND fingerprint = ${fingerprint}
92
+ `
93
+ if (!row) return undefined
94
+
95
+ return { ...row.result, status: "current" }
96
+ }
97
+
98
+ async function prepareStructures(
99
+ sql: ReservedSQL,
100
+ schema: string,
101
+ plan: ObjectQueryPreparationPlan
102
+ ): Promise<ObjectQueryPreparationResult> {
103
+ const { indexes, textCounts, warnings } = compilePreparationPlan(plan)
104
+
105
+ if (indexes.some((index) => index.kind === "text") || textCounts.length > 0) {
106
+ await ensureTrigrams(sql)
107
+ }
108
+
109
+ const names = await ensurePgObjectQueryIndexes(sql, schema, indexes)
110
+ const texts = await preparePgObjectTextCounts(sql, schema, textCounts)
111
+ return {
112
+ status: "prepared",
113
+ objectTypes: plan.objectTypes.length,
114
+ indexes: new Set([...names, ...texts]).size,
115
+ warnings,
116
+ }
117
+ }
118
+
119
+ function compilePreparationPlan(plan: ObjectQueryPreparationPlan) {
120
+ const indexes: PgObjectQueryIndex[] = []
121
+ const textCounts: PgObjectTextCountIndex[] = []
122
+ const warnings: string[] = []
123
+
124
+ for (const type of plan.objectTypes) {
125
+ const scope = { projectId: plan.projectId, objectTypeId: type.objectTypeId }
126
+ indexes.push({ ...scope, kind: "incomingLinks" })
127
+
128
+ for (const property of type.properties) {
129
+ if (property.text) indexes.push({ ...scope, kind: "text", propertyId: property.propertyId })
130
+ if (property.sortable) {
131
+ indexes.push({
132
+ ...scope,
133
+ kind: "sort",
134
+ fields: [{ propertyId: property.propertyId, scalarKind: property.scalarKind }],
135
+ })
136
+ }
137
+
138
+ if (property.filterable && property.scalarKind === "string") {
139
+ indexes.push({ ...scope, kind: "filter", properties: [property.propertyId] })
140
+ } else if (
141
+ property.filterable &&
142
+ property.scalarKind &&
143
+ ["integer", "double", "boolean", "date", "timestamp", "uuid"].includes(property.scalarKind)
144
+ ) {
145
+ indexes.push({ ...scope, kind: "value", propertyId: property.propertyId })
146
+ } else if (property.filterable) {
147
+ warnings.push(
148
+ `${type.objectTypeId}.${property.propertyId}: no dedicated equality index for ${property.scalarKind ?? "this schema"}; queries remain exact through the general path.`
149
+ )
150
+ }
151
+ }
152
+
153
+ for (const index of type.indexes) {
154
+ if (index.kind === "sort") {
155
+ indexes.push({
156
+ ...scope,
157
+ kind: "sort",
158
+ equality: index.filters,
159
+ fields: index.fields.map((field) => ({
160
+ ...field,
161
+ scalarKind: type.properties.find((property) => property.propertyId === field.propertyId)
162
+ ?.scalarKind,
163
+ })),
164
+ })
165
+ } else {
166
+ textCounts.push({ ...scope, propertyId: index.propertyId, filters: index.filters })
167
+ }
168
+ }
169
+ }
170
+ return { indexes, textCounts, warnings }
171
+ }
172
+
173
+ // Extension creation is database-wide, whereas index preparation is schema-scoped.
174
+ async function ensureTrigrams(sql: ReservedSQL): Promise<void> {
175
+ await lockPgPreparation(sql, "sixb.extension.pg_trgm")
176
+ const unlock = () => sql`SELECT pg_advisory_unlock(hashtextextended('sixb.extension.pg_trgm', 0))`
177
+ await undoOnFailure(async () => {
178
+ await sql.unsafe("CREATE EXTENSION IF NOT EXISTS pg_trgm WITH SCHEMA public")
179
+ }, unlock)
180
+ await unlock()
181
+ }