@segmentstream-cloud/ontology 0.24.1 → 0.25.0

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.
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { queryFields } from "../query.js";
3
3
  import { columnName, historyTableName, quoteIdentifier as qi, quoteLiteral as ql, relationshipColumnName, SYSTEM_COLUMNS, tableName, joinTableName, } from "../migrate/storage.js";
4
4
  import { REPLICATION_RECORD_BYTES } from "./model.js";
5
- export const REPLICATION_SCHEMA_VERSION = 3;
5
+ export const REPLICATION_SCHEMA_VERSION = 4;
6
6
  /** Installed only by the SDK migration runner, never at service startup. */
7
7
  export const REPLICATION_DDL = [
8
8
  `CREATE TABLE IF NOT EXISTS _ontology_replication (
@@ -17,6 +17,49 @@ export const REPLICATION_DDL = [
17
17
  generation uuid PRIMARY KEY, source_id uuid NOT NULL, from_revision bigint NOT NULL,
18
18
  manifest jsonb NOT NULL, selection jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
19
19
  )`,
20
+ `DO $$ BEGIN
21
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint
22
+ WHERE conrelid = '_ontology_replication'::regclass AND conname = '_ontology_replication_protocol_floor') THEN
23
+ ALTER TABLE _ontology_replication ADD CONSTRAINT _ontology_replication_protocol_floor
24
+ CHECK (version >= 4) NOT VALID;
25
+ END IF;
26
+ END $$`,
27
+ `CREATE SEQUENCE IF NOT EXISTS _ontology_replication_revision AS bigint CACHE 1`,
28
+ `SELECT setval('_ontology_replication_revision', GREATEST(
29
+ (SELECT revision FROM _ontology_replication WHERE id),
30
+ (SELECT last_value FROM _ontology_replication_revision), 1), true)
31
+ WHERE (SELECT version FROM _ontology_replication WHERE id) < 4`,
32
+ `ALTER TABLE _ontology_replication_targets
33
+ ADD COLUMN IF NOT EXISTS protocol integer NOT NULL DEFAULT 3,
34
+ ADD COLUMN IF NOT EXISTS baseline_complete boolean NOT NULL DEFAULT true,
35
+ ADD COLUMN IF NOT EXISTS baseline_cursor jsonb NOT NULL DEFAULT '{}'::jsonb,
36
+ ADD COLUMN IF NOT EXISTS initial_revision bigint,
37
+ ADD COLUMN IF NOT EXISTS backfill_complete boolean NOT NULL DEFAULT true,
38
+ ADD COLUMN IF NOT EXISTS backfill_cursor jsonb NOT NULL DEFAULT '{}'::jsonb,
39
+ ADD COLUMN IF NOT EXISTS backfill_revision bigint,
40
+ ADD COLUMN IF NOT EXISTS verified_count bigint NOT NULL DEFAULT 0,
41
+ ADD COLUMN IF NOT EXISTS replay_complete boolean NOT NULL DEFAULT true,
42
+ ADD COLUMN IF NOT EXISTS replay_cursor uuid`,
43
+ `CREATE TABLE IF NOT EXISTS _ontology_replication_delivery (
44
+ generation uuid NOT NULL REFERENCES _ontology_replication_targets(generation),
45
+ event_id uuid NOT NULL, history_table text, revision bigint NOT NULL,
46
+ is_baseline boolean NOT NULL DEFAULT false,
47
+ accepted_at timestamptz, verified_at timestamptz,
48
+ next_verify_at timestamptz NOT NULL DEFAULT now(), last_checked_at timestamptz,
49
+ PRIMARY KEY (generation, event_id)
50
+ )`,
51
+ `CREATE INDEX IF NOT EXISTS _ontology_delivery_pending ON _ontology_replication_delivery
52
+ (generation, revision, event_id) WHERE accepted_at IS NULL`,
53
+ `CREATE INDEX IF NOT EXISTS _ontology_delivery_verify ON _ontology_replication_delivery
54
+ (generation, next_verify_at, event_id) WHERE accepted_at IS NOT NULL AND verified_at IS NULL`,
55
+ `CREATE INDEX IF NOT EXISTS _ontology_delivery_unverified ON _ontology_replication_delivery
56
+ (generation, event_id) WHERE verified_at IS NULL`,
57
+ `CREATE INDEX IF NOT EXISTS _ontology_delivery_initial ON _ontology_replication_delivery
58
+ (generation, revision, event_id) WHERE verified_at IS NULL`,
59
+ `UPDATE _ontology_replication_targets SET backfill_complete = false,
60
+ backfill_revision = (SELECT revision FROM _ontology_replication WHERE id),
61
+ initial_revision = (SELECT revision FROM _ontology_replication WHERE id)
62
+ WHERE protocol = 3 AND backfill_revision IS NULL`,
20
63
  `CREATE TABLE IF NOT EXISTS _ontology_replication_baselines (
21
64
  generation uuid NOT NULL REFERENCES _ontology_replication_targets(generation),
22
65
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(), event jsonb NOT NULL
@@ -29,7 +72,7 @@ export const REPLICATION_DDL = [
29
72
  `CREATE OR REPLACE FUNCTION _ontology_replication_guard() RETURNS trigger LANGUAGE plpgsql AS $$
30
73
  BEGIN
31
74
  IF (SELECT enabled FROM _ontology_replication WHERE id) AND
32
- current_setting('segmentstream.writer', true) IS DISTINCT FROM '1' THEN
75
+ COALESCE(current_setting('segmentstream.writer', true), '') NOT IN ('1', '2') THEN
33
76
  RAISE EXCEPTION 'Replication requires a compatible ontology writer; pause replication before a data migration';
34
77
  END IF;
35
78
  RETURN NULL;
@@ -71,7 +114,8 @@ export function replicationEntityDdl(manifest) {
71
114
  (((replication->>'revision')::bigint)) WHERE replication IS NOT NULL`,
72
115
  `CREATE OR REPLACE FUNCTION ${fn}() RETURNS trigger LANGUAGE plpgsql AS $$
73
116
  DECLARE state _ontology_replication; policy jsonb; snapshot jsonb; relation text; other_id text;
74
- record_key text; record_kind text := 'entity'; is_deleted boolean := NEW.kind = 'delete';
117
+ event_revision bigint; record_key text; record_kind text := 'entity';
118
+ is_deleted boolean := NEW.kind = 'delete';
75
119
  BEGIN
76
120
  SELECT * INTO state FROM _ontology_replication WHERE id;
77
121
  policy := state.selection->${ql(entity.id)};
@@ -96,11 +140,13 @@ export function replicationEntityDdl(manifest) {
96
140
  IF octet_length(snapshot::text) > ${REPLICATION_RECORD_BYTES} THEN
97
141
  RAISE EXCEPTION 'Projected ontology record exceeds the replication size limit';
98
142
  END IF;
99
- UPDATE _ontology_replication SET revision = revision + 1 WHERE id RETURNING * INTO state;
143
+ event_revision := nextval('_ontology_replication_revision');
100
144
  NEW.replication := jsonb_build_object('source_id', state.source_id::text,
101
145
  'entity_id', ${ql(entity.id)}, 'record_key', record_key, 'record_kind', record_kind,
102
- 'revision', state.revision::text, 'deleted', is_deleted,
146
+ 'revision', event_revision::text, 'generation', state.active_generation::text, 'deleted', is_deleted,
103
147
  'schema_hash', ${ql(manifest.hash)}, 'payload', snapshot::text);
148
+ INSERT INTO _ontology_replication_delivery (generation, event_id, history_table, revision)
149
+ VALUES (state.active_generation, NEW.id, TG_TABLE_NAME, event_revision);
104
150
  RETURN NEW;
105
151
  END $$`,
106
152
  `DROP TRIGGER IF EXISTS _ontology_capture ON ${history}`,
@@ -1,35 +1,38 @@
1
1
  import { Effect } from "effect";
2
2
  import type { SqlClient } from "effect/unstable/sql";
3
3
  import { type ReplicationEvent, type ReplicationSelection, type ReplicationExclusion } from "./model.js";
4
- import { ReplicationBaselineFailed, type ReplicationTarget } from "./baseline.js";
4
+ import { advanceReplicationBaseline, ReplicationBaselineFailed, type ReplicationTarget } from "./baseline.js";
5
5
  export type { ReplicationTarget } from "./baseline.js";
6
6
  export declare const startReplication: (sql: SqlClient.SqlClient, input: {
7
7
  readonly generation: string;
8
8
  readonly exclusions?: ReadonlyArray<ReplicationExclusion>;
9
9
  readonly selection?: ReadonlyArray<ReplicationSelection>;
10
10
  readonly newSource?: boolean;
11
- }) => Effect.Effect<ReplicationTarget, import("../index.js").CatalogInvalid | ReplicationBaselineFailed | import("effect/unstable/sql/SqlError").SqlError, never>;
11
+ }) => Effect.Effect<ReplicationTarget, import("../index.js").CatalogInvalid | import("effect/unstable/sql/SqlError").SqlError | ReplicationBaselineFailed, never>;
12
12
  export declare const activeReplicationTarget: (sql: SqlClient.SqlClient) => Effect.Effect<ReplicationTarget, import("effect/unstable/sql/SqlError").SqlError, never>;
13
- export declare const pauseReplication: (sql: SqlClient.SqlClient) => import("effect/unstable/sql/Statement").Statement<object>;
13
+ export declare const pauseReplication: (sql: SqlClient.SqlClient) => Effect.Effect<void, import("effect/unstable/sql/SqlError").SqlError, never>;
14
14
  export declare const replicationTarget: (sql: SqlClient.SqlClient, generation: string) => Effect.Effect<ReplicationTarget, import("effect/unstable/sql/SqlError").SqlError, never>;
15
- /** No timestamp/high-water cursor: every unacknowledged immutable event remains discoverable. */
16
- export declare function replicationEventsSql(target: ReplicationTarget): string;
15
+ export { advanceReplicationBaseline };
16
+ /** Retained history is recovery storage; frequent delivery reads only indexed outstanding references. */
17
17
  export declare const pendingReplicationEvents: (sql: SqlClient.SqlClient, target: ReplicationTarget) => Effect.Effect<{
18
18
  readonly historyId: string;
19
19
  readonly event: ReplicationEvent;
20
20
  }[], import("effect/unstable/sql/SqlError").SqlError, never>;
21
- /** Call only after Pub/Sub accepts the complete batch. Lost responses deliberately produce duplicates. */
22
- export declare const acknowledgeReplication: (sql: SqlClient.SqlClient, generation: string, ids: ReadonlyArray<string>) => import("effect/unstable/sql/Statement").Statement<object>;
23
- export declare const replayReplication: (sql: SqlClient.SqlClient, generation: string) => import("effect/unstable/sql/Statement").Statement<object>;
24
21
  export declare const replicationStatus: (sql: SqlClient.SqlClient, target: ReplicationTarget) => Effect.Effect<{
25
22
  total: string;
26
23
  accepted: string;
27
24
  verified: string;
28
25
  }, import("effect/unstable/sql/SqlError").SqlError, never>;
26
+ /** Initialization is a fixed fence, so continuing writes cannot move the readiness goal. */
27
+ export declare const replicationInitiallyReady: (sql: SqlClient.SqlClient, generation: string) => Effect.Effect<boolean, import("effect/unstable/sql/SqlError").SqlError, never>;
29
28
  export declare const unverifiedReplicationEvents: (sql: SqlClient.SqlClient, target: ReplicationTarget) => Effect.Effect<{
30
29
  historyId: string;
31
30
  eventId: string;
32
31
  }[], import("effect/unstable/sql/SqlError").SqlError, never>;
33
- export declare const verifyReplicationEvents: (sql: SqlClient.SqlClient, generation: string, ids: ReadonlyArray<string>) => import("effect/unstable/sql/Statement").Statement<object>;
34
- /** Retry accepted events that never appeared, including dead letters and transport expiry. */
35
- export declare const retryUnverifiedReplication: (sql: SqlClient.SqlClient, generation: string) => import("effect/unstable/sql/Statement").Statement<object>;
32
+ export declare const advanceReplicationReplay: (sql: SqlClient.SqlClient, generation: string) => Effect.Effect<boolean, import("effect/unstable/sql/SqlError").SqlError, never>;
33
+ /** Static pre-upgrade histories have no new inserts: v4 writers atomically enqueue new history. */
34
+ export declare const advanceReplicationBackfill: (sql: SqlClient.SqlClient, generation: string) => Effect.Effect<boolean, import("../index.js").CatalogInvalid | import("effect/unstable/sql/SqlError").SqlError, never>;
35
+ export declare const acknowledgeReplication: (sql: SqlClient.SqlClient, generation: string, ids: ReadonlyArray<string>, version?: number) => Effect.Effect<void, import("effect/unstable/sql/SqlError").SqlError, never>;
36
+ export declare const verifyReplicationEvents: (sql: SqlClient.SqlClient, generation: string, ids: ReadonlyArray<string>, version?: number) => Effect.Effect<void, import("effect/unstable/sql/SqlError").SqlError, never>;
37
+ export declare const retryUnverifiedReplication: (sql: SqlClient.SqlClient, generation: string, ids?: ReadonlyArray<string>, version?: number) => Effect.Effect<void, import("effect/unstable/sql/SqlError").SqlError, never>;
38
+ export declare const replayReplication: (sql: SqlClient.SqlClient, generation: string) => Effect.Effect<void, import("effect/unstable/sql/SqlError").SqlError, never>;
@@ -1,12 +1,12 @@
1
1
  import { Effect } from "effect";
2
+ import * as legacy from "./legacy-store.js";
3
+ import { createReplicationBaseline as createLegacyBaseline } from "./legacy-baseline.js";
4
+ import { lockReplicationLifecycle, shareReplicationLifecycle } from "./transaction.js";
2
5
  import { readCatalog } from "../migrate/apply.js";
3
6
  import { historyTableName, quoteIdentifier as qi } from "../migrate/storage.js";
4
7
  import { REPLICATION_BATCH_BYTES, REPLICATION_BATCH_SIZE, publishMessage, replicationEventId, resolveReplicationSelection, } from "./model.js";
5
- import { createReplicationBaseline, ReplicationBaselineFailed, } from "./baseline.js";
6
- export const startReplication = (sql, input) => sql.withTransaction(Effect.gen(function* () {
7
- yield* sql.unsafe("SET LOCAL statement_timeout = '30s'");
8
- yield* sql.unsafe("SET LOCAL lock_timeout = '5s'");
9
- yield* sql.unsafe("SELECT id FROM _ontology_replication WHERE id FOR UPDATE");
8
+ import { createReplicationBaseline, advanceReplicationBaseline, ReplicationBaselineFailed, } from "./baseline.js";
9
+ export const startReplication = (sql, input) => Effect.gen(function* () {
10
10
  const manifest = yield* readCatalog({ sql });
11
11
  if (!manifest) {
12
12
  return yield* new ReplicationBaselineFailed({
@@ -18,76 +18,265 @@ export const startReplication = (sql, input) => sql.withTransaction(Effect.gen(f
18
18
  try: () => resolveReplicationSelection(manifest, input.exclusions),
19
19
  catch: (cause) => new ReplicationBaselineFailed({ message: "Invalid replication exclusions.", cause }),
20
20
  }));
21
- return yield* createReplicationBaseline(sql, { ...input, manifest, selection });
21
+ const version = yield* protocol(sql);
22
+ return yield* version >= 4
23
+ ? createReplicationBaseline(sql, { ...input, manifest, selection })
24
+ : createLegacyBaseline(sql, { ...input, manifest, selection }).pipe(Effect.map((target) => target));
25
+ });
26
+ const targetFields = `generation::text, source_id::text, from_revision::text, manifest, selection,
27
+ protocol, baseline_complete, backfill_complete, initial_revision::text, replay_complete, 4 AS delivery_version`;
28
+ const protocol = (sql) => sql
29
+ .unsafe("SELECT version FROM _ontology_replication WHERE id")
30
+ .pipe(Effect.map((rows) => rows[0]?.version ?? 3));
31
+ export const activeReplicationTarget = (sql) => Effect.gen(function* () {
32
+ const version = yield* protocol(sql);
33
+ const fields = version >= 4
34
+ ? targetFields
35
+ : "generation::text, source_id::text, from_revision::text, manifest, selection";
36
+ const rows = yield* sql.unsafe(`SELECT ${fields} FROM _ontology_replication_targets
37
+ WHERE generation = (SELECT active_generation FROM _ontology_replication WHERE id)`);
38
+ return rows[0];
39
+ });
40
+ export const pauseReplication = (sql) => sql.withTransaction(Effect.gen(function* () {
41
+ yield* lockReplicationLifecycle(sql);
42
+ yield* sql.unsafe("UPDATE _ontology_replication SET enabled = false WHERE id");
22
43
  }));
23
- export const activeReplicationTarget = (sql) => sql
24
- .unsafe(`SELECT t.generation::text, t.source_id::text, t.from_revision::text, t.manifest, t.selection
25
- FROM _ontology_replication_targets t JOIN _ontology_replication r ON r.active_generation = t.generation
26
- WHERE r.id`)
27
- .pipe(Effect.map((rows) => rows[0]));
28
- export const pauseReplication = (sql) => sql.unsafe("UPDATE _ontology_replication SET enabled = false WHERE id");
29
- export const replicationTarget = (sql, generation) => sql
30
- .unsafe(`SELECT generation::text, source_id::text, from_revision::text, manifest, selection
31
- FROM _ontology_replication_targets WHERE generation = $1::uuid`, [generation])
32
- .pipe(Effect.map((rows) => rows[0]));
33
- /** No timestamp/high-water cursor: every unacknowledged immutable event remains discoverable. */
34
- export function replicationEventsSql(target) {
35
- const histories = target.selection.map((item) => {
36
- const entity = target.manifest.entities.find((e) => e.id === item.entityId);
37
- return `SELECT id, replication AS event FROM ${qi(historyTableName(entity.name))}
38
- WHERE replication IS NOT NULL AND replication->>'source_id' = $2
39
- AND (replication->>'revision')::bigint > $3::bigint`;
40
- });
41
- const baseline = `SELECT id, event FROM _ontology_replication_baselines WHERE generation = $1::uuid
42
- AND $2::text IS NOT NULL AND $3::bigint IS NOT NULL`;
43
- return [baseline, ...histories].join(" UNION ALL ");
44
- }
45
- export const pendingReplicationEvents = (sql, target) => sql
46
- .unsafe(`SELECT e.id::text, e.event FROM (${replicationEventsSql(target)}) e
47
- WHERE NOT EXISTS (SELECT 1 FROM _ontology_replication_receipts r
48
- WHERE r.generation = $1::uuid AND r.event_id = e.id)
49
- ORDER BY (event->>'revision')::bigint, id LIMIT ${REPLICATION_BATCH_SIZE}`, [target.generation, target.source_id, target.from_revision])
50
- .pipe(Effect.map((rows) => {
44
+ export const replicationTarget = (sql, generation) => Effect.gen(function* () {
45
+ const version = yield* protocol(sql);
46
+ const fields = version >= 4
47
+ ? targetFields
48
+ : "generation::text, source_id::text, from_revision::text, manifest, selection";
49
+ const rows = yield* sql.unsafe(`SELECT ${fields} FROM _ontology_replication_targets WHERE generation = $1::uuid`, [generation]);
50
+ return rows[0];
51
+ });
52
+ export { advanceReplicationBaseline };
53
+ /** Retained history is recovery storage; frequent delivery reads only indexed outstanding references. */
54
+ export const pendingReplicationEvents = (sql, target) => Effect.gen(function* () {
55
+ if (target.delivery_version !== 4 &&
56
+ target.protocol !== 4) {
57
+ return yield* legacy.pendingReplicationEvents(sql, target);
58
+ }
59
+ const references = yield* sql.unsafe(`SELECT event_id::text AS id, history_table FROM _ontology_replication_delivery
60
+ WHERE generation = $1::uuid AND accepted_at IS NULL
61
+ ORDER BY revision, event_id LIMIT ${REPLICATION_BATCH_SIZE}`, [target.generation]);
62
+ const groups = new Map();
63
+ for (const reference of references) {
64
+ const ids = groups.get(reference.history_table) ?? [];
65
+ ids.push(reference.id);
66
+ groups.set(reference.history_table, ids);
67
+ }
68
+ const events = new Map();
69
+ for (const [table, ids] of groups) {
70
+ const rows = yield* sql.unsafe(table === null
71
+ ? `SELECT id::text, event FROM _ontology_replication_baselines
72
+ WHERE generation = $1::uuid AND id IN (SELECT value::uuid FROM jsonb_array_elements_text($2::jsonb))`
73
+ : `SELECT id::text, replication AS event FROM ${qi(table)}
74
+ WHERE $1::uuid IS NOT NULL AND id IN (SELECT value::uuid FROM jsonb_array_elements_text($2::jsonb))`, [target.generation, JSON.stringify(ids)]);
75
+ for (const row of rows) {
76
+ events.set(row.id, row.event);
77
+ }
78
+ }
51
79
  let size = 32;
52
80
  const result = [];
53
- for (const row of rows) {
81
+ for (const reference of references) {
82
+ const payload = events.get(reference.id);
83
+ if (!payload) {
84
+ return yield* Effect.die(new Error("A queued replication event is missing from retained history."));
85
+ }
54
86
  const event = {
55
- ...row.event,
87
+ ...payload,
56
88
  generation: target.generation,
57
- event_id: replicationEventId(target.source_id, row.id, target.generation),
89
+ event_id: replicationEventId(target.source_id, reference.id, target.generation),
58
90
  };
59
91
  const bytes = Buffer.byteLength(JSON.stringify(publishMessage(event))) + 1;
60
92
  if (bytes + 32 > REPLICATION_BATCH_BYTES) {
61
- throw new Error("A replication event exceeds the publish request limit.");
93
+ return yield* Effect.die(new Error("A replication event exceeds the publish request limit."));
62
94
  }
63
95
  if (size + bytes > REPLICATION_BATCH_BYTES) {
64
96
  break;
65
97
  }
66
98
  size += bytes;
67
- result.push({ historyId: row.id, event });
99
+ result.push({ historyId: reference.id, event });
68
100
  }
69
101
  return result;
102
+ });
103
+ /** Lost publish responses deliberately leave the same immutable IDs pending. */
104
+ const acknowledgeReplicationV4 = (sql, generation, ids) => sql.withTransaction(Effect.gen(function* () {
105
+ // Receipt foreign keys touch targets after delivery rows; drain before migration table locks.
106
+ yield* shareReplicationLifecycle(sql);
107
+ yield* sql.unsafe(`WITH accepted AS (
108
+ UPDATE _ontology_replication_delivery SET accepted_at = now(), next_verify_at = now()
109
+ WHERE generation = $1::uuid AND accepted_at IS NULL
110
+ AND event_id IN (SELECT value::uuid FROM jsonb_array_elements_text($2::jsonb))
111
+ RETURNING event_id, accepted_at
112
+ ) INSERT INTO _ontology_replication_receipts (generation, event_id, accepted_at)
113
+ SELECT $1::uuid, event_id, accepted_at FROM accepted ON CONFLICT DO NOTHING`, [generation, JSON.stringify(ids)]);
114
+ }));
115
+ export const replicationStatus = (sql, target) => target.delivery_version !== 4 &&
116
+ target.protocol !== 4
117
+ ? legacy.replicationStatus(sql, target)
118
+ : sql
119
+ .unsafe(`SELECT (t.verified_count + outstanding.total)::text AS total,
120
+ (t.verified_count + outstanding.accepted)::text AS accepted, t.verified_count::text AS verified
121
+ FROM _ontology_replication_targets t CROSS JOIN LATERAL (
122
+ SELECT count(*) AS total, count(accepted_at) AS accepted FROM _ontology_replication_delivery
123
+ WHERE generation = t.generation AND verified_at IS NULL
124
+ ) outstanding WHERE t.generation = $1::uuid`, [target.generation])
125
+ .pipe(Effect.map((rows) => rows[0]));
126
+ /** Initialization is a fixed fence, so continuing writes cannot move the readiness goal. */
127
+ export const replicationInitiallyReady = (sql, generation) => sql
128
+ .unsafe(`SELECT baseline_complete AND backfill_complete AND replay_complete AND NOT EXISTS (
129
+ SELECT 1 FROM _ontology_replication_delivery d WHERE d.generation = t.generation
130
+ AND d.verified_at IS NULL AND (d.is_baseline OR d.revision <= t.initial_revision)
131
+ ) AS ready FROM _ontology_replication_targets t WHERE generation = $1::uuid`, [generation])
132
+ .pipe(Effect.map((rows) => rows[0]?.ready ?? false));
133
+ export const unverifiedReplicationEvents = (sql, target) => target.delivery_version !== 4 &&
134
+ target.protocol !== 4
135
+ ? legacy.unverifiedReplicationEvents(sql, target)
136
+ : sql
137
+ .unsafe(`SELECT event_id::text AS id FROM _ontology_replication_delivery
138
+ WHERE generation = $1::uuid AND accepted_at IS NOT NULL AND verified_at IS NULL
139
+ AND next_verify_at <= now() ORDER BY next_verify_at, event_id LIMIT 1000`, [target.generation])
140
+ .pipe(Effect.map((rows) => rows.map(({ id }) => ({
141
+ historyId: id,
142
+ eventId: replicationEventId(target.source_id, id, target.generation),
143
+ }))));
144
+ const verifyReplicationEventsV4 = (sql, generation, ids) => sql.unsafe(`WITH locked AS MATERIALIZED (
145
+ SELECT generation FROM _ontology_replication_targets WHERE generation = $1::uuid FOR NO KEY UPDATE
146
+ ), verified AS (
147
+ UPDATE _ontology_replication_delivery SET verified_at = now(), last_checked_at = now()
148
+ WHERE generation = (SELECT generation FROM locked) AND accepted_at IS NOT NULL AND verified_at IS NULL
149
+ AND event_id IN (SELECT value::uuid FROM jsonb_array_elements_text($2::jsonb))
150
+ RETURNING event_id, verified_at
151
+ ), receipts AS (
152
+ UPDATE _ontology_replication_receipts r SET verified_at = v.verified_at FROM verified v
153
+ WHERE r.generation = $1::uuid AND r.event_id = v.event_id
154
+ ) UPDATE _ontology_replication_targets SET verified_count = verified_count + (SELECT count(*) FROM verified)
155
+ WHERE generation = $1::uuid`, [generation, JSON.stringify(ids)]);
156
+ /** Retry only IDs actually checked and absent; a verification backlog is never a delivery failure. */
157
+ const retryUnverifiedReplicationV4 = (sql, generation, missingIds = []) => sql.unsafe(`WITH checked AS (
158
+ UPDATE _ontology_replication_delivery SET last_checked_at = now(), next_verify_at = now() + interval '30 seconds',
159
+ accepted_at = CASE WHEN accepted_at < now() - interval '15 minutes' THEN NULL ELSE accepted_at END
160
+ WHERE generation = $1::uuid AND accepted_at IS NOT NULL AND verified_at IS NULL
161
+ AND event_id IN (SELECT value::uuid FROM jsonb_array_elements_text($2::jsonb))
162
+ RETURNING event_id, accepted_at
163
+ ) DELETE FROM _ontology_replication_receipts r USING checked c
164
+ WHERE r.generation = $1::uuid AND r.event_id = c.event_id AND c.accepted_at IS NULL`, [generation, JSON.stringify(missingIds)]);
165
+ /** Replay initialization is constant time; reset work is resumed in bounded pages. */
166
+ const replayReplicationV4 = (sql, generation) => sql.withTransaction(Effect.gen(function* () {
167
+ yield* lockReplicationLifecycle(sql);
168
+ yield* sql.unsafe(`UPDATE _ontology_replication_targets SET replay_complete = false, replay_cursor = NULL,
169
+ initial_revision = (SELECT CASE WHEN is_called THEN last_value ELSE 0 END FROM _ontology_replication_revision)
170
+ WHERE generation = $1::uuid`, [generation]);
171
+ }));
172
+ export const advanceReplicationReplay = (sql, generation) => sql.withTransaction(Effect.gen(function* () {
173
+ const [target] = yield* sql.unsafe(`SELECT replay_complete, replay_cursor::text, initial_revision::text FROM _ontology_replication_targets
174
+ WHERE generation = $1::uuid FOR NO KEY UPDATE`, [generation]);
175
+ if (!target || target.replay_complete) {
176
+ return true;
177
+ }
178
+ const rows = yield* sql.unsafe(`SELECT event_id::text AS id, verified_at IS NOT NULL AS verified FROM _ontology_replication_delivery
179
+ WHERE generation = $1::uuid AND ($2::uuid IS NULL OR event_id > $2::uuid)
180
+ AND (is_baseline OR revision <= $3::bigint)
181
+ ORDER BY event_id LIMIT 1000`, [generation, target.replay_cursor, target.initial_revision]);
182
+ const ids = JSON.stringify(rows.map((row) => row.id));
183
+ yield* sql.unsafe(`UPDATE _ontology_replication_delivery SET accepted_at = NULL, verified_at = NULL, next_verify_at = now()
184
+ WHERE generation = $1::uuid AND event_id IN (SELECT value::uuid FROM jsonb_array_elements_text($2::jsonb))`, [generation, ids]);
185
+ yield* sql.unsafe(`DELETE FROM _ontology_replication_receipts WHERE generation = $1::uuid
186
+ AND event_id IN (SELECT value::uuid FROM jsonb_array_elements_text($2::jsonb))`, [generation, ids]);
187
+ const complete = rows.length < 1000;
188
+ yield* sql.unsafe(`UPDATE _ontology_replication_targets SET replay_cursor = $2::uuid, replay_complete = $3,
189
+ verified_count = verified_count - $4::bigint WHERE generation = $1::uuid`, [
190
+ generation,
191
+ rows.at(-1)?.id ?? target.replay_cursor,
192
+ complete,
193
+ rows.filter((row) => row.verified).length,
194
+ ]);
195
+ return complete;
196
+ }));
197
+ /** Static pre-upgrade histories have no new inserts: v4 writers atomically enqueue new history. */
198
+ export const advanceReplicationBackfill = (sql, generation) => sql.withTransaction(Effect.gen(function* () {
199
+ yield* shareReplicationLifecycle(sql);
200
+ const [target] = yield* sql.unsafe(`SELECT ${targetFields}, backfill_cursor, backfill_revision::text
201
+ FROM _ontology_replication_targets WHERE generation = $1::uuid FOR NO KEY UPDATE`, [generation]);
202
+ if (!target || target.backfill_complete) {
203
+ return true;
204
+ }
205
+ const catalog = yield* readCatalog({ sql });
206
+ const tables = [
207
+ null,
208
+ ...target.selection.map((item) => {
209
+ const entity = (catalog ?? target.manifest).entities.find((entity) => entity.id === item.entityId) ??
210
+ target.manifest.entities.find((entity) => entity.id === item.entityId);
211
+ return historyTableName(entity.name);
212
+ }),
213
+ ];
214
+ const position = target.backfill_cursor.table ?? 0;
215
+ const table = tables[position];
216
+ const cursor = target.backfill_cursor.id ?? null;
217
+ const rows = yield* sql.unsafe(table === null
218
+ ? `SELECT id::text FROM _ontology_replication_baselines WHERE generation = $1::uuid
219
+ AND ($2::uuid IS NULL OR id > $2::uuid) ORDER BY id LIMIT 1000`
220
+ : `SELECT id::text FROM ${qi(table)} WHERE replication IS NOT NULL
221
+ AND replication->>'source_id' = $3 AND (replication->>'revision')::bigint > $4::bigint
222
+ AND (replication->>'revision')::bigint <= $5::bigint
223
+ AND ($2::uuid IS NULL OR id > $2::uuid) AND $1::uuid IS NOT NULL ORDER BY id LIMIT 1000`, table === null
224
+ ? [generation, cursor]
225
+ : [generation, cursor, target.source_id, target.from_revision, target.backfill_revision]);
226
+ const ids = JSON.stringify(rows.map((row) => row.id));
227
+ const eventSelect = table === null
228
+ ? `SELECT id, event FROM _ontology_replication_baselines WHERE generation = $1::uuid`
229
+ : `SELECT id, replication AS event FROM ${qi(table)}`;
230
+ yield* sql.unsafe(`WITH inserted AS (
231
+ INSERT INTO _ontology_replication_delivery
232
+ (generation, event_id, history_table, revision, is_baseline, accepted_at, verified_at, next_verify_at)
233
+ SELECT $1::uuid, e.id, $3::text, (e.event->>'revision')::bigint, $4::boolean,
234
+ r.accepted_at, r.verified_at, now() FROM (${eventSelect}) e
235
+ LEFT JOIN _ontology_replication_receipts r ON r.generation = $1::uuid AND r.event_id = e.id
236
+ WHERE e.id IN (SELECT value::uuid FROM jsonb_array_elements_text($2::jsonb))
237
+ ON CONFLICT DO NOTHING RETURNING verified_at
238
+ ) UPDATE _ontology_replication_targets SET verified_count = verified_count +
239
+ (SELECT count(verified_at) FROM inserted) WHERE generation = $1::uuid`, [generation, ids, table, table === null]);
240
+ const finishedTable = rows.length < 1000;
241
+ const complete = finishedTable && position + 1 >= tables.length;
242
+ const next = finishedTable
243
+ ? { table: position + 1 }
244
+ : { table: position, id: rows.at(-1).id };
245
+ yield* sql.unsafe(`UPDATE _ontology_replication_targets SET backfill_cursor = $2::jsonb, backfill_complete = $3
246
+ WHERE generation = $1::uuid`, [generation, JSON.stringify(next), complete]);
247
+ return complete;
70
248
  }));
71
- /** Call only after Pub/Sub accepts the complete batch. Lost responses deliberately produce duplicates. */
72
- export const acknowledgeReplication = (sql, generation, ids) => sql.unsafe(`INSERT INTO _ontology_replication_receipts (generation, event_id)
73
- SELECT $1::uuid, value::uuid FROM jsonb_array_elements_text($2::jsonb) ON CONFLICT DO NOTHING`, [generation, JSON.stringify(ids)]);
74
- export const replayReplication = (sql, generation) => sql.unsafe("DELETE FROM _ontology_replication_receipts WHERE generation = $1::uuid", [
75
- generation,
76
- ]);
77
- export const replicationStatus = (sql, target) => sql
78
- .unsafe(`SELECT count(*)::text AS total, count(r.event_id)::text AS accepted, count(r.verified_at)::text AS verified
79
- FROM (${replicationEventsSql(target)}) e
80
- LEFT JOIN _ontology_replication_receipts r ON r.generation = $1::uuid AND r.event_id = e.id`, [target.generation, target.source_id, target.from_revision])
81
- .pipe(Effect.map((rows) => rows[0]));
82
- export const unverifiedReplicationEvents = (sql, target) => sql
83
- .unsafe(`SELECT event_id::text AS id FROM _ontology_replication_receipts
84
- WHERE generation = $1::uuid AND verified_at IS NULL ORDER BY accepted_at, event_id LIMIT 1000`, [target.generation])
85
- .pipe(Effect.map((rows) => rows.map(({ id }) => ({
86
- historyId: id,
87
- eventId: replicationEventId(target.source_id, id, target.generation),
88
- }))));
89
- export const verifyReplicationEvents = (sql, generation, ids) => sql.unsafe(`UPDATE _ontology_replication_receipts SET verified_at = now()
90
- WHERE generation = $1::uuid AND event_id IN (SELECT value::uuid FROM jsonb_array_elements_text($2::jsonb))`, [generation, JSON.stringify(ids)]);
91
- /** Retry accepted events that never appeared, including dead letters and transport expiry. */
92
- export const retryUnverifiedReplication = (sql, generation) => sql.unsafe(`DELETE FROM _ontology_replication_receipts WHERE generation = $1::uuid
93
- AND verified_at IS NULL AND accepted_at < now() - interval '15 minutes'`, [generation]);
249
+ export const acknowledgeReplication = (sql, generation, ids, version) => Effect.gen(function* () {
250
+ const current = version ?? (yield* protocol(sql));
251
+ yield* current >= 4
252
+ ? acknowledgeReplicationV4(sql, generation, ids)
253
+ : legacy.acknowledgeReplication(sql, generation, ids);
254
+ });
255
+ export const verifyReplicationEvents = (sql, generation, ids, version) => Effect.gen(function* () {
256
+ const current = version ?? (yield* protocol(sql));
257
+ yield* current >= 4
258
+ ? verifyReplicationEventsV4(sql, generation, ids)
259
+ : legacy.verifyReplicationEvents(sql, generation, ids);
260
+ });
261
+ export const retryUnverifiedReplication = (sql, generation, ids = [], version) => Effect.gen(function* () {
262
+ const current = version ?? (yield* protocol(sql));
263
+ if (current >= 4) {
264
+ yield* retryUnverifiedReplicationV4(sql, generation, ids);
265
+ }
266
+ else {
267
+ // Even legacy recovery retries only checked missing IDs, never the unchecked backlog.
268
+ yield* sql.unsafe(`DELETE FROM _ontology_replication_receipts WHERE generation = $1::uuid
269
+ AND verified_at IS NULL AND accepted_at < now() - interval '15 minutes'
270
+ AND event_id IN (SELECT value::uuid FROM jsonb_array_elements_text($2::jsonb))`, [generation, JSON.stringify(ids)]);
271
+ }
272
+ });
273
+ export const replayReplication = (sql, generation) => Effect.gen(function* () {
274
+ const current = yield* protocol(sql);
275
+ if (current >= 4) {
276
+ yield* replayReplicationV4(sql, generation);
277
+ yield* advanceReplicationReplay(sql, generation);
278
+ }
279
+ else {
280
+ yield* legacy.replayReplication(sql, generation);
281
+ }
282
+ });
@@ -1,4 +1,8 @@
1
1
  import { Effect } from "effect";
2
2
  import type { SqlClient } from "effect/unstable/sql";
3
+ export declare const REPLICATION_LIFECYCLE_LOCK = "segmentstream.ontology.replication.lifecycle";
4
+ /** Legacy writers take the singleton first; every lifecycle operation keeps that order. */
5
+ export declare const lockReplicationLifecycle: (sql: SqlClient.SqlClient) => Effect.Effect<void, import("effect/unstable/sql/SqlError").SqlError, never>;
6
+ export declare const shareReplicationLifecycle: (sql: SqlClient.SqlClient) => import("effect/unstable/sql/Statement").Statement<object>;
3
7
  /** Must run inside the write transaction, before any business row locks. */
4
8
  export declare const beginReplicationWrite: (sql: SqlClient.SqlClient) => Effect.Effect<void, import("effect/unstable/sql/SqlError").SqlError, never>;
@@ -1,11 +1,30 @@
1
1
  import { Effect } from "effect";
2
+ export const REPLICATION_LIFECYCLE_LOCK = "segmentstream.ontology.replication.lifecycle";
3
+ /** Legacy writers take the singleton first; every lifecycle operation keeps that order. */
4
+ export const lockReplicationLifecycle = (sql) => Effect.gen(function* () {
5
+ yield* sql.unsafe("SELECT id FROM _ontology_replication WHERE id FOR UPDATE");
6
+ yield* sql.unsafe("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [
7
+ REPLICATION_LIFECYCLE_LOCK,
8
+ ]);
9
+ });
10
+ export const shareReplicationLifecycle = (sql) => sql.unsafe("SELECT pg_advisory_xact_lock_shared(hashtextextended($1, 0))", [
11
+ REPLICATION_LIFECYCLE_LOCK,
12
+ ]);
2
13
  /** Must run inside the write transaction, before any business row locks. */
3
14
  export const beginReplicationWrite = (sql) => Effect.gen(function* () {
4
15
  const [schema] = yield* sql.unsafe("SELECT to_regclass('_ontology_replication') IS NOT NULL AS present");
5
16
  if (!schema?.present) {
6
17
  return;
7
- } // Platform principal operations also serve pre-upgrade databases.
8
- yield* sql.unsafe("SELECT set_config('segmentstream.writer', '1', true)");
9
- // Lock even while disabled: enabling and the baseline must exclude concurrent writes.
10
- yield* sql.unsafe("SELECT id FROM _ontology_replication WHERE id FOR UPDATE");
18
+ }
19
+ // Shared admission permits independent writes while lifecycle changes drain admitted writers.
20
+ const [state] = yield* sql.unsafe("SELECT version FROM _ontology_replication WHERE id");
21
+ if ((state?.version ?? 0) < 4) {
22
+ // A new service may be deployed before its owner applies the protocol upgrade.
23
+ yield* sql.unsafe("SELECT id FROM _ontology_replication WHERE id FOR UPDATE");
24
+ yield* sql.unsafe("SELECT set_config('segmentstream.writer', '1', true)");
25
+ }
26
+ else {
27
+ yield* shareReplicationLifecycle(sql);
28
+ yield* sql.unsafe("SELECT set_config('segmentstream.writer', '2', true)");
29
+ }
11
30
  });
@@ -0,0 +1,35 @@
1
+ import { Context, Effect } from "effect";
2
+ /** Admission happens before reserving SQL connections; two pool slots remain for other work. */
3
+ export declare const ACTION_TRANSACTION_LIMITS: {
4
+ readonly concurrency: 3;
5
+ readonly maxWaiting: 32;
6
+ readonly queueTimeoutMs: 5000;
7
+ readonly handlerTimeoutMs: 60000;
8
+ readonly statementTimeoutMs: 30000;
9
+ readonly lockTimeoutMs: 5000;
10
+ };
11
+ export interface ActionTransactionLimits {
12
+ readonly handlerTimeoutMs: number;
13
+ readonly statementTimeoutMs: number;
14
+ readonly lockTimeoutMs: number;
15
+ }
16
+ export declare class ActionTransactionDeadline extends Error {
17
+ readonly name = "ActionTransactionDeadline";
18
+ }
19
+ export declare class ActionAdmissionRejected extends Error {
20
+ readonly name = "ActionAdmissionRejected";
21
+ }
22
+ export interface ActionTransactionsService {
23
+ readonly limits: ActionTransactionLimits;
24
+ readonly admit: <A, E, R>(effect: Effect.Effect<A, E, R>) => Effect.Effect<A, E, R>;
25
+ }
26
+ declare const ActionTransactions_base: Context.ServiceClass<ActionTransactions, "segmentstream/ontology/ActionTransactions", ActionTransactionsService>;
27
+ /** Internal configuration and one admission gate per service instance, never per request. */
28
+ export declare class ActionTransactions extends ActionTransactions_base {
29
+ }
30
+ export declare function makeActionTransactions(options?: ActionTransactionLimits & {
31
+ readonly concurrency: number;
32
+ readonly maxWaiting: number;
33
+ readonly queueTimeoutMs: number;
34
+ }): ActionTransactionsService;
35
+ export {};
@@ -0,0 +1,54 @@
1
+ import { Context, Duration, Effect, Exit, Semaphore } from "effect";
2
+ /** Admission happens before reserving SQL connections; two pool slots remain for other work. */
3
+ export const ACTION_TRANSACTION_LIMITS = {
4
+ concurrency: 3,
5
+ maxWaiting: 32,
6
+ queueTimeoutMs: 5_000,
7
+ handlerTimeoutMs: 60_000,
8
+ statementTimeoutMs: 30_000,
9
+ lockTimeoutMs: 5_000,
10
+ };
11
+ export class ActionTransactionDeadline extends Error {
12
+ name = "ActionTransactionDeadline";
13
+ }
14
+ export class ActionAdmissionRejected extends Error {
15
+ name = "ActionAdmissionRejected";
16
+ }
17
+ /** Internal configuration and one admission gate per service instance, never per request. */
18
+ export class ActionTransactions extends Context.Service()("segmentstream/ontology/ActionTransactions") {
19
+ }
20
+ export function makeActionTransactions(options = ACTION_TRANSACTION_LIMITS) {
21
+ const permits = Semaphore.makeUnsafe(options.concurrency);
22
+ let waiting = 0;
23
+ let active = 0;
24
+ return {
25
+ limits: options,
26
+ admit: (effect) => Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
27
+ const queuedAt = Date.now();
28
+ if (!(yield* permits.takeIfAvailable(1))) {
29
+ if (waiting >= options.maxWaiting) {
30
+ yield* Effect.logWarning("ontology.action.admission_rejected").pipe(Effect.annotateLogs({ active, waiting, reason: "queue_full" }));
31
+ return yield* Effect.die(new ActionAdmissionRejected("Action write queue is full."));
32
+ }
33
+ waiting += 1;
34
+ yield* restore(permits.take(1).pipe(Effect.timeoutOrElse({
35
+ duration: Duration.millis(options.queueTimeoutMs),
36
+ orElse: () => Effect.logWarning("ontology.action.admission_rejected").pipe(Effect.annotateLogs({ active, waiting, reason: "queue_timeout" }), Effect.andThen(Effect.die(new ActionAdmissionRejected("Action write admission timed out.")))),
37
+ }))).pipe(Effect.ensuring(Effect.sync(() => {
38
+ waiting -= 1;
39
+ })));
40
+ }
41
+ active += 1;
42
+ const startedAt = Date.now();
43
+ return yield* restore(effect).pipe(Effect.onExit((exit) => Effect.logDebug("ontology.action.transaction_completed").pipe(Effect.annotateLogs({
44
+ active,
45
+ waiting,
46
+ queueMs: startedAt - queuedAt,
47
+ transactionMs: Date.now() - startedAt,
48
+ outcome: Exit.isSuccess(exit) ? "success" : "failure",
49
+ }))), Effect.ensuring(Effect.sync(() => {
50
+ active -= 1;
51
+ }).pipe(Effect.andThen(permits.release(1)))));
52
+ })),
53
+ };
54
+ }