@segmentstream-cloud/ontology 0.11.0 → 0.12.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.
package/README.md CHANGED
@@ -28,6 +28,12 @@ one Effect version.
28
28
  `ontology/` is a package of its own. The SDK is an ES module, so `ontology/package.json`
29
29
  must set `"type": "module"`.
30
30
 
31
+ SDK 0.12.0 adds warehouse replication enabled by default for all stored business entities,
32
+ fields and business relationships. Existing history captures immutable snapshots and the
33
+ service wakes the publisher after commits. Upgrade and redeploy to activate it; workspace
34
+ configuration can disable replication or exclude individual entities, fields and relationships. See
35
+ [setup and recovery](../../docs/ontology-replication.md).
36
+
31
37
  ## Define the ontology
32
38
 
33
39
  ```ts
@@ -46,7 +46,7 @@ export declare const readCatalog: (input: CatalogInput) => Effect.Effect<Ontolog
46
46
  * then runs in one transaction with `statement_timeout` and `lock_timeout` set, which also verifies
47
47
  * that the catalog still records the manifest the plan starts from and finally records the target. A
48
48
  * failure rolls the transaction back and leaves the applied version in place. A no-op plan writes
49
- * nothing.
49
+ * nothing when the runtime schema is also current.
50
50
  */
51
51
  export declare const applyMigration: (plan: MigrationPlan, options: ApplyMigrationOptions) => Effect.Effect<MigrationResult, ApplyMigrationError>;
52
52
  /**
@@ -1,8 +1,12 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { Duration, Effect, Schema } from "effect";
2
3
  import { canonicalJson, decodeManifestDocument, encodeManifest, manifestHash, } from "../manifest.js";
3
4
  import { CatalogInvalid, CatalogMismatch, MigrationFailed, MigrationRefused } from "./errors.js";
4
5
  import { isNoopMigration } from "./model.js";
5
6
  import { planMigration } from "./plan.js";
7
+ import { REPLICATION_DDL, REPLICATION_SCHEMA_VERSION, replicationEntityDdl, } from "../replication/schema.js";
8
+ import { createReplicationBaseline } from "../replication/baseline.js";
9
+ import { resolveReplicationSelection } from "../replication/model.js";
6
10
  import { API_KEYS_TABLE, CATALOG_TABLE, OAUTH_TABLES, USER_ROLES_TABLE, quoteIdentifier, quoteLiteral, } from "./storage.js";
7
11
  const CATALOG = quoteIdentifier(CATALOG_TABLE);
8
12
  /** Serializes migrations of one database; transaction-scoped, so it ends with the transaction. */
@@ -79,7 +83,7 @@ const runStep = (sql, step, stepIndex) => Effect.forEach(step.sql, (statement) =
79
83
  * then runs in one transaction with `statement_timeout` and `lock_timeout` set, which also verifies
80
84
  * that the catalog still records the manifest the plan starts from and finally records the target. A
81
85
  * failure rolls the transaction back and leaves the applied version in place. A no-op plan writes
82
- * nothing.
86
+ * nothing when the runtime schema is also current.
83
87
  */
84
88
  export const applyMigration = (plan, options) => Effect.gen(function* () {
85
89
  const { sql } = options;
@@ -89,8 +93,12 @@ export const applyMigration = (plan, options) => Effect.gen(function* () {
89
93
  refusals: plan.refusals,
90
94
  });
91
95
  }
92
- if (isNoopMigration(plan)) {
93
- return { hash: plan.target.hash, appliedSteps: 0, changed: false };
96
+ const [presence] = yield* sql.unsafe("SELECT to_regclass('_ontology_replication') IS NOT NULL AS present");
97
+ if (presence?.present) {
98
+ const [state] = yield* sql.unsafe("SELECT version, enabled FROM _ontology_replication WHERE id");
99
+ if (isNoopMigration(plan) && state?.version === REPLICATION_SCHEMA_VERSION) {
100
+ return { hash: plan.target.hash, appliedSteps: 0, changed: false };
101
+ }
94
102
  }
95
103
  const statementTimeout = Duration.toMillis(options.statementTimeout ?? DEFAULT_STATEMENT_TIMEOUT);
96
104
  const lockTimeout = Duration.toMillis(options.lockTimeout ?? DEFAULT_LOCK_TIMEOUT);
@@ -109,11 +117,26 @@ export const applyMigration = (plan, options) => Effect.gen(function* () {
109
117
  MIGRATION_LOCK,
110
118
  ]);
111
119
  yield* verifyCatalog(sql, plan, { forUpdate: true });
120
+ let refresh;
121
+ if (presence?.present) {
122
+ const [state] = yield* sql.unsafe("SELECT * FROM _ontology_replication WHERE id FOR UPDATE");
123
+ if (state?.enabled) {
124
+ refresh = state.exclusions ?? [];
125
+ // Writers wait on this row. Capture, schema changes and the new baseline commit together.
126
+ yield* sql.unsafe("UPDATE _ontology_replication SET enabled = false WHERE id");
127
+ }
128
+ }
112
129
  for (const [stepIndex, step] of plan.steps.entries()) {
113
130
  if (step.transactional) {
114
131
  yield* runStep(sql, step, stepIndex);
115
132
  }
116
133
  }
134
+ for (const statement of [...REPLICATION_DDL, ...replicationEntityDdl(plan.target)]) {
135
+ yield* sql.unsafe(statement).unprepared;
136
+ }
137
+ yield* sql.unsafe("UPDATE _ontology_replication SET version = $1 WHERE id", [
138
+ REPLICATION_SCHEMA_VERSION,
139
+ ]);
117
140
  yield* sql.unsafe(`
118
141
  INSERT INTO ${CATALOG} ("id", "version", "hash", "manifest")
119
142
  VALUES (true, $1, $2, $3::jsonb)
@@ -123,6 +146,16 @@ export const applyMigration = (plan, options) => Effect.gen(function* () {
123
146
  "manifest" = excluded."manifest",
124
147
  "applied_at" = now()
125
148
  `, [plan.target.version, plan.target.hash, canonicalJson(encodeManifest(plan.target))]);
149
+ if (refresh !== undefined) {
150
+ const selection = yield* Effect.sync(() => resolveReplicationSelection(plan.target, refresh));
151
+ yield* createReplicationBaseline(sql, {
152
+ generation: randomUUID(),
153
+ manifest: plan.target,
154
+ exclusions: refresh,
155
+ selection,
156
+ newSource: true,
157
+ });
158
+ }
126
159
  }));
127
160
  return { hash: plan.target.hash, appliedSteps: plan.steps.length, changed: true };
128
161
  }).pipe(Effect.withSpan("ontology.migrate.apply", {
@@ -0,0 +1,19 @@
1
+ import { Effect } from "effect";
2
+ import type { SqlClient } from "effect/unstable/sql";
3
+ import type { OntologyManifest } from "../manifest.js";
4
+ import { type ReplicationSelection, type ReplicationExclusion } from "./model.js";
5
+ export interface ReplicationTarget {
6
+ readonly generation: string;
7
+ readonly source_id: string;
8
+ readonly from_revision: string;
9
+ readonly manifest: OntologyManifest;
10
+ readonly selection: ReadonlyArray<ReplicationSelection>;
11
+ }
12
+ /** All baseline rows are committed with capture enablement; interrupted setup can safely resume. */
13
+ export declare const createReplicationBaseline: (sql: SqlClient.SqlClient, input: {
14
+ readonly generation: string;
15
+ readonly manifest: OntologyManifest;
16
+ readonly exclusions?: ReadonlyArray<ReplicationExclusion>;
17
+ readonly selection: ReadonlyArray<ReplicationSelection>;
18
+ readonly newSource?: boolean;
19
+ }) => Effect.Effect<ReplicationTarget, import("effect/unstable/sql/SqlError").SqlError, never>;
@@ -0,0 +1,97 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Effect } from "effect";
3
+ import { joinColumnNames, joinTableName, quoteIdentifier as qi, quoteLiteral as ql, tableName, } from "../migrate/storage.js";
4
+ import { REPLICATION_RECORD_BYTES, validateReplicationSelection, } from "./model.js";
5
+ import { snapshotExpression } from "./schema.js";
6
+ const MAX_BASELINE_RECORDS = 50_000;
7
+ /** All baseline rows are committed with capture enablement; interrupted setup can safely resume. */
8
+ export const createReplicationBaseline = (sql, input) => sql.withTransaction(Effect.gen(function* () {
9
+ yield* sql.unsafe("SET LOCAL statement_timeout = '30s'");
10
+ yield* sql.unsafe("SET LOCAL lock_timeout = '5s'");
11
+ const [state] = yield* sql.unsafe("SELECT source_id::text, revision::text, enabled FROM _ontology_replication WHERE id FOR UPDATE");
12
+ if (!state) {
13
+ return yield* Effect.die(new Error("Run the ontology SDK migration before enabling replication."));
14
+ }
15
+ const existing = yield* sql.unsafe("SELECT * FROM _ontology_replication_targets WHERE generation = $1::uuid", [input.generation]);
16
+ if (existing[0]) {
17
+ return existing[0];
18
+ }
19
+ if (state.enabled) {
20
+ return yield* Effect.die(new Error("Pause replication before starting a new generation."));
21
+ }
22
+ const manifest = input.manifest;
23
+ yield* Effect.sync(() => validateReplicationSelection(manifest, input.selection));
24
+ // Drain older SDK transactions that started before the compatibility fence was enabled.
25
+ const tables = manifest.entities
26
+ .filter((e) => e.source === undefined && e.name !== "user")
27
+ .flatMap((e) => [
28
+ tableName(e.name),
29
+ ...e.relationships
30
+ .filter((r) => r.cardinality === "many")
31
+ .map((r) => joinTableName(e.name, r.name)),
32
+ ]);
33
+ if (tables.length > 0) {
34
+ yield* sql.unsafe(`LOCK TABLE ${tables.map(qi).join(", ")} IN SHARE MODE`);
35
+ }
36
+ const source = input.newSource ? randomUUID() : state.source_id;
37
+ const selection = Object.fromEntries(input.selection.map((s) => [s.entityId, s]));
38
+ yield* sql.unsafe(`UPDATE _ontology_replication SET source_id = $1::uuid, selection = $2::jsonb, enabled = true,
39
+ exclusions = $3::jsonb, active_generation = $4::uuid WHERE id`, [
40
+ source,
41
+ JSON.stringify(selection),
42
+ JSON.stringify(input.exclusions ?? []),
43
+ input.generation,
44
+ ]);
45
+ const target = {
46
+ generation: input.generation,
47
+ source_id: source,
48
+ from_revision: state.revision,
49
+ manifest,
50
+ selection: input.selection,
51
+ };
52
+ yield* sql.unsafe(`INSERT INTO _ontology_replication_targets (generation, source_id, from_revision, manifest, selection)
53
+ VALUES ($1::uuid, $2::uuid, $3::bigint, $4::jsonb, $5::jsonb)`, [
54
+ target.generation,
55
+ source,
56
+ state.revision,
57
+ JSON.stringify(manifest),
58
+ JSON.stringify(input.selection),
59
+ ]);
60
+ let total = 0;
61
+ const baseline = (entityId, kind, records) => Effect.gen(function* () {
62
+ // Keep the snapshot in PostgreSQL; large baselines must not fill a serverless process's heap.
63
+ const [size] = yield* sql.unsafe(`SELECT count(*)::int AS count, max(octet_length(payload::text)) AS largest
64
+ FROM (${records} LIMIT ${MAX_BASELINE_RECORDS + 1}) snapshot`);
65
+ total += size.count;
66
+ if (total > MAX_BASELINE_RECORDS) {
67
+ return yield* Effect.die(new Error("The replication baseline exceeds 50,000 records; use a bulk bootstrap."));
68
+ }
69
+ if ((size.largest ?? 0) > REPLICATION_RECORD_BYTES) {
70
+ return yield* Effect.die(new Error("A projected baseline record exceeds the replication size limit."));
71
+ }
72
+ yield* sql.unsafe(`INSERT INTO _ontology_replication_baselines (generation, event)
73
+ SELECT $1::uuid, jsonb_build_object('source_id', $2::text, 'entity_id', $3::text,
74
+ 'record_key', record_key, 'record_kind', $4::text, 'revision', $5::text,
75
+ 'deleted', false, 'schema_hash', $6::text, 'payload', payload::text)
76
+ FROM (${records}) snapshot`, [target.generation, source, entityId, kind, state.revision, manifest.hash]);
77
+ });
78
+ for (const item of input.selection) {
79
+ const entity = manifest.entities.find((e) => e.id === item.entityId);
80
+ yield* baseline(entity.id, "entity", `SELECT r.id::text AS record_key,
81
+ (SELECT jsonb_object_agg(key, value) FROM jsonb_each(${snapshotExpression(entity, "r")})
82
+ WHERE key = 'id' OR ${ql(JSON.stringify(item.fields))}::jsonb ? key) AS payload
83
+ FROM ${qi(tableName(entity.name))} r`);
84
+ for (const relationship of entity.relationships.filter((r) => item.relationships?.includes(r.name))) {
85
+ const columns = joinColumnNames({
86
+ entity: entity.name,
87
+ relationship: relationship.name,
88
+ target: relationship.to,
89
+ });
90
+ yield* baseline(entity.id, `edge:${relationship.name}`, `SELECT ${qi(columns.source)}::text || ':' || ${qi(columns.target)}::text AS record_key,
91
+ jsonb_build_object('source_id', ${qi(columns.source)}::text,
92
+ 'target_id', ${qi(columns.target)}::text) AS payload
93
+ FROM ${qi(joinTableName(entity.name, relationship.name))}`);
94
+ }
95
+ }
96
+ return target;
97
+ }));
@@ -0,0 +1,23 @@
1
+ import type { ReplicationTarget } from "./store.js";
2
+ export declare const REPLICATION_TABLE_SCHEMA: readonly [...{
3
+ name: string;
4
+ type: string;
5
+ mode: string;
6
+ }[], {
7
+ readonly name: "revision";
8
+ readonly type: "NUMERIC";
9
+ readonly mode: "REQUIRED";
10
+ }, {
11
+ readonly name: "deleted";
12
+ readonly type: "BOOLEAN";
13
+ readonly mode: "REQUIRED";
14
+ }, {
15
+ readonly name: "payload";
16
+ readonly type: "JSON";
17
+ readonly mode: "REQUIRED";
18
+ }];
19
+ /** Rank first, then apply tombstones. Every view reads the whole retained event history. */
20
+ export declare function replicationViews(project: string, target: ReplicationTarget): {
21
+ readonly name: string;
22
+ readonly query: string;
23
+ }[];
@@ -0,0 +1,63 @@
1
+ import { queryFields } from "../query.js";
2
+ export const REPLICATION_TABLE_SCHEMA = [
3
+ ...[
4
+ "event_id",
5
+ "source_id",
6
+ "generation",
7
+ "entity_id",
8
+ "record_key",
9
+ "record_kind",
10
+ "schema_hash",
11
+ ].map((name) => ({ name, type: "STRING", mode: "REQUIRED" })),
12
+ { name: "revision", type: "NUMERIC", mode: "REQUIRED" },
13
+ { name: "deleted", type: "BOOLEAN", mode: "REQUIRED" },
14
+ { name: "payload", type: "JSON", mode: "REQUIRED" },
15
+ ];
16
+ const literal = (value) => `'${value.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
17
+ const identifier = (value) => {
18
+ if (!/^[A-Za-z0-9_-]+$/.test(value)) {
19
+ throw new Error("Invalid BigQuery replication identifier.");
20
+ }
21
+ return `\`${value}\``;
22
+ };
23
+ /** Rank first, then apply tombstones. Every view reads the whole retained event history. */
24
+ export function replicationViews(project, target) {
25
+ identifier(project);
26
+ const result = [];
27
+ const latest = (entity, kind, columns) => `SELECT ${columns} FROM (
28
+ SELECT *, ROW_NUMBER() OVER (PARTITION BY record_key ORDER BY revision DESC, event_id DESC) AS position
29
+ FROM \`${project}.ontology_internal.events\`
30
+ WHERE generation = ${literal(target.generation)} AND source_id = ${literal(target.source_id)}
31
+ AND entity_id = ${literal(entity)} AND record_kind = ${literal(kind)}
32
+ ) WHERE position = 1 AND NOT deleted`;
33
+ for (const selection of target.selection) {
34
+ const entity = target.manifest.entities.find((e) => e.id === selection.entityId);
35
+ const columns = queryFields(entity)
36
+ .filter((field) => field.name === "id" || selection.fields.includes(field.name))
37
+ .map((field) => {
38
+ const path = literal(`$.${field.name}`);
39
+ const type = {
40
+ integer: "INT64",
41
+ number: "FLOAT64",
42
+ boolean: "BOOL",
43
+ date: "DATE",
44
+ timestamp: "TIMESTAMP",
45
+ }[field.storage];
46
+ const value = field.storage === "json"
47
+ ? `JSON_QUERY(payload, ${path})`
48
+ : `JSON_VALUE(payload, ${path})`;
49
+ return `${type ? `SAFE_CAST(${value} AS ${type})` : value} AS ${identifier(field.name)}`;
50
+ });
51
+ result.push({ name: entity.name, query: latest(entity.id, "entity", columns.join(", ")) });
52
+ for (const relationship of selection.relationships ?? []) {
53
+ result.push({
54
+ name: `${entity.name}_${relationship}`,
55
+ query: latest(entity.id, `edge:${relationship}`, "JSON_VALUE(payload, '$.source_id') AS source_id, JSON_VALUE(payload, '$.target_id') AS target_id"),
56
+ });
57
+ }
58
+ }
59
+ if (new Set(result.map((v) => v.name.toLowerCase())).size !== result.length) {
60
+ throw new Error("Replicated entity and relationship view names collide.");
61
+ }
62
+ return result;
63
+ }
@@ -0,0 +1,38 @@
1
+ import type { OntologyManifest } from "../manifest.js";
2
+ /** The resolved projection for one immutable generation. `id` is always included. */
3
+ export interface ReplicationSelection {
4
+ readonly entityId: string;
5
+ readonly fields: ReadonlyArray<string>;
6
+ readonly relationships?: ReadonlyArray<string>;
7
+ }
8
+ /** Everything is replicated unless explicitly excluded. Internal identity data is never exported. */
9
+ export interface ReplicationExclusion {
10
+ readonly entityId: string;
11
+ readonly disabled?: boolean;
12
+ readonly fields?: ReadonlyArray<string>;
13
+ readonly relationships?: ReadonlyArray<string>;
14
+ }
15
+ export declare function resolveReplicationSelection(manifest: OntologyManifest, exclusions?: ReadonlyArray<ReplicationExclusion>): ReadonlyArray<ReplicationSelection>;
16
+ export interface ReplicationEvent {
17
+ readonly event_id: string;
18
+ readonly source_id: string;
19
+ readonly generation: string;
20
+ readonly entity_id: string;
21
+ readonly record_key: string;
22
+ readonly record_kind: string;
23
+ /** Decimal strings preserve PostgreSQL bigint precision in JavaScript. */
24
+ readonly revision: string;
25
+ readonly deleted: boolean;
26
+ readonly schema_hash: string;
27
+ /** Pub/Sub's BigQuery table-schema mode expects a string for a JSON column. */
28
+ readonly payload: string;
29
+ }
30
+ export declare const REPLICATION_BATCH_SIZE = 100;
31
+ export declare const REPLICATION_BATCH_BYTES = 1000000;
32
+ export declare const REPLICATION_RECORD_BYTES = 200000;
33
+ export declare function validateReplicationSelection(manifest: OntologyManifest, selection: ReadonlyArray<ReplicationSelection>): void;
34
+ export declare function replicationEventId(source: string, history: string, generation: string): string;
35
+ /** Includes base64 and JSON overhead, not just the original payload bytes. */
36
+ export declare function publishMessage(event: ReplicationEvent): {
37
+ data: string;
38
+ };
@@ -0,0 +1,89 @@
1
+ import { createHash } from "node:crypto";
2
+ import { queryFields } from "../query.js";
3
+ export function resolveReplicationSelection(manifest, exclusions = []) {
4
+ const entities = manifest.entities.filter((entity) => !entity.builtIn && !entity.source);
5
+ if (new Set(exclusions.map((item) => item.entityId)).size !== exclusions.length) {
6
+ throw new Error("Replication exclusions must name distinct entities.");
7
+ }
8
+ for (const item of exclusions) {
9
+ const entity = entities.find((candidate) => candidate.id === item.entityId);
10
+ if (!entity) {
11
+ throw new Error(`Unknown stored business entity in replication exclusions: ${item.entityId}.`);
12
+ }
13
+ const fields = new Set(queryFields(entity).map((field) => field.name));
14
+ const relationships = new Set(entity.relationships.map((relationship) => relationship.name));
15
+ if (item.fields?.some((field) => field === "id" || !fields.has(field))) {
16
+ throw new Error(`Unknown or required replication field on ${entity.name}.`);
17
+ }
18
+ if (item.relationships?.some((relationship) => !relationships.has(relationship))) {
19
+ throw new Error(`Unknown replication relationship on ${entity.name}.`);
20
+ }
21
+ }
22
+ const included = entities.filter((entity) => !exclusions.some((item) => item.entityId === entity.id && item.disabled));
23
+ const selection = included.map((entity) => {
24
+ const excluded = exclusions.find((item) => item.entityId === entity.id);
25
+ const excludedFields = new Set([
26
+ ...(excluded?.fields ?? []),
27
+ ...(excluded?.relationships ?? []).map((relationship) => `${relationship}_id`),
28
+ ]);
29
+ return {
30
+ entityId: entity.id,
31
+ fields: queryFields(entity)
32
+ .filter((field) => !excludedFields.has(field.name))
33
+ .map((field) => field.name),
34
+ relationships: entity.relationships
35
+ .filter((relationship) => relationship.cardinality === "many" &&
36
+ !excluded?.relationships?.includes(relationship.name) &&
37
+ included.some((target) => target.name === relationship.to))
38
+ .map((relationship) => relationship.name),
39
+ };
40
+ });
41
+ validateReplicationSelection(manifest, selection);
42
+ return selection;
43
+ }
44
+ export const REPLICATION_BATCH_SIZE = 100;
45
+ export const REPLICATION_BATCH_BYTES = 1_000_000;
46
+ // Leaves room for JSON escaping, the envelope and base64 inside a 1 MB publish request.
47
+ export const REPLICATION_RECORD_BYTES = 200_000;
48
+ export function validateReplicationSelection(manifest, selection) {
49
+ if (new Set(selection.map((s) => s.entityId)).size !== selection.length) {
50
+ throw new Error("Replicated entities must be distinct.");
51
+ }
52
+ const viewNames = new Set();
53
+ const addView = (name) => {
54
+ const key = name.toLowerCase();
55
+ if (viewNames.has(key)) {
56
+ throw new Error("Replicated entity and relationship view names collide.");
57
+ }
58
+ viewNames.add(key);
59
+ };
60
+ for (const item of selection) {
61
+ const entity = manifest.entities.find((e) => e.id === item.entityId);
62
+ if (!entity || entity.name === "user" || entity.source !== undefined) {
63
+ throw new Error(`Only stored business entities can be replicated: ${item.entityId}.`);
64
+ }
65
+ addView(entity.name);
66
+ const fields = new Set(queryFields(entity).map((f) => f.name));
67
+ if (item.fields.some((field) => !fields.has(field))) {
68
+ throw new Error(`Unknown replication field on ${entity.name}.`);
69
+ }
70
+ for (const name of item.relationships ?? []) {
71
+ const relationship = entity.relationships.find((r) => r.name === name);
72
+ const target = manifest.entities.find((e) => e.name === relationship?.to);
73
+ if (relationship?.cardinality !== "many" ||
74
+ !target ||
75
+ target.name === "user" ||
76
+ !selection.some((s) => s.entityId === target.id)) {
77
+ throw new Error(`Replication of ${entity.name}.${name} requires both business entities.`);
78
+ }
79
+ addView(`${entity.name}_${name}`);
80
+ }
81
+ }
82
+ }
83
+ export function replicationEventId(source, history, generation) {
84
+ return createHash("sha256").update(`${source}:${history}:${generation}`).digest("hex");
85
+ }
86
+ /** Includes base64 and JSON overhead, not just the original payload bytes. */
87
+ export function publishMessage(event) {
88
+ return { data: Buffer.from(JSON.stringify(event)).toString("base64") };
89
+ }
@@ -0,0 +1,7 @@
1
+ import type { EntityManifest, OntologyManifest } from "../manifest.js";
2
+ export declare const REPLICATION_SCHEMA_VERSION = 3;
3
+ /** Installed only by the SDK migration runner, never at service startup. */
4
+ export declare const REPLICATION_DDL: string[];
5
+ export declare function snapshotExpression(entity: EntityManifest, alias: string): string;
6
+ /** History is the durable source. Triggers also reject old writers while capture is enabled. */
7
+ export declare function replicationEntityDdl(manifest: OntologyManifest): ReadonlyArray<string>;
@@ -0,0 +1,123 @@
1
+ import { createHash } from "node:crypto";
2
+ import { queryFields } from "../query.js";
3
+ import { columnName, historyTableName, quoteIdentifier as qi, quoteLiteral as ql, relationshipColumnName, SYSTEM_COLUMNS, tableName, joinTableName, } from "../migrate/storage.js";
4
+ import { REPLICATION_RECORD_BYTES } from "./model.js";
5
+ export const REPLICATION_SCHEMA_VERSION = 3;
6
+ /** Installed only by the SDK migration runner, never at service startup. */
7
+ export const REPLICATION_DDL = [
8
+ `CREATE TABLE IF NOT EXISTS _ontology_replication (
9
+ id boolean PRIMARY KEY DEFAULT true CHECK (id), version integer NOT NULL,
10
+ source_id uuid NOT NULL DEFAULT gen_random_uuid(), revision bigint NOT NULL DEFAULT 0,
11
+ selection jsonb NOT NULL DEFAULT '{}'::jsonb, enabled boolean NOT NULL DEFAULT false
12
+ )`,
13
+ `INSERT INTO _ontology_replication (id, version) VALUES (true, ${REPLICATION_SCHEMA_VERSION}) ON CONFLICT DO NOTHING`,
14
+ `ALTER TABLE _ontology_replication ADD COLUMN IF NOT EXISTS exclusions jsonb NOT NULL DEFAULT '[]'::jsonb`,
15
+ `ALTER TABLE _ontology_replication ADD COLUMN IF NOT EXISTS active_generation uuid`,
16
+ `CREATE TABLE IF NOT EXISTS _ontology_replication_targets (
17
+ generation uuid PRIMARY KEY, source_id uuid NOT NULL, from_revision bigint NOT NULL,
18
+ manifest jsonb NOT NULL, selection jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
19
+ )`,
20
+ `CREATE TABLE IF NOT EXISTS _ontology_replication_baselines (
21
+ generation uuid NOT NULL REFERENCES _ontology_replication_targets(generation),
22
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(), event jsonb NOT NULL
23
+ )`,
24
+ `CREATE TABLE IF NOT EXISTS _ontology_replication_receipts (
25
+ generation uuid NOT NULL REFERENCES _ontology_replication_targets(generation),
26
+ event_id uuid NOT NULL, accepted_at timestamptz NOT NULL DEFAULT now(), verified_at timestamptz,
27
+ PRIMARY KEY (generation, event_id)
28
+ )`,
29
+ `CREATE OR REPLACE FUNCTION _ontology_replication_guard() RETURNS trigger LANGUAGE plpgsql AS $$
30
+ BEGIN
31
+ IF (SELECT enabled FROM _ontology_replication WHERE id) AND
32
+ current_setting('segmentstream.writer', true) IS DISTINCT FROM '1' THEN
33
+ RAISE EXCEPTION 'Replication requires a compatible ontology writer; pause replication before a data migration';
34
+ END IF;
35
+ RETURN NULL;
36
+ END $$`,
37
+ ];
38
+ export function snapshotExpression(entity, alias) {
39
+ const values = queryFields(entity).flatMap((field) => {
40
+ const system = SYSTEM_COLUMNS[field.name];
41
+ const column = system ??
42
+ (entity.properties.some((p) => p.name === field.name)
43
+ ? columnName(field.name)
44
+ : relationshipColumnName(field.name.slice(0, -3)));
45
+ // JSON numbers cannot safely carry a PostgreSQL bigint across all transports.
46
+ const cast = field.storage === "integer" ? "::text" : "";
47
+ return [ql(field.name), `${alias}.${qi(column)}${cast}`];
48
+ });
49
+ const chunks = [];
50
+ for (let offset = 0; offset < values.length; offset += 80) {
51
+ chunks.push(`jsonb_build_object(${values.slice(offset, offset + 80).join(", ")})`);
52
+ }
53
+ return `(${chunks.join(" || ")})`;
54
+ }
55
+ /** History is the durable source. Triggers also reject old writers while capture is enabled. */
56
+ export function replicationEntityDdl(manifest) {
57
+ return manifest.entities
58
+ .filter((e) => e.source === undefined && e.name !== "user")
59
+ .flatMap((entity) => {
60
+ const history = qi(historyTableName(entity.name));
61
+ const table = qi(tableName(entity.name));
62
+ // Keep names stable across renames and below PostgreSQL's 63-byte identifier limit.
63
+ const key = createHash("sha256").update(entity.id).digest("hex").slice(0, 40);
64
+ const fn = qi(`_replicate_${key}`);
65
+ const declared = entity.relationships
66
+ .filter((r) => r.cardinality === "many")
67
+ .map((r) => r.name);
68
+ return [
69
+ `ALTER TABLE ${history} ADD COLUMN IF NOT EXISTS replication jsonb`,
70
+ `CREATE INDEX IF NOT EXISTS ${qi(`_replication_${key}_idx`)} ON ${history}
71
+ (((replication->>'revision')::bigint)) WHERE replication IS NOT NULL`,
72
+ `CREATE OR REPLACE FUNCTION ${fn}() RETURNS trigger LANGUAGE plpgsql AS $$
73
+ 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';
75
+ BEGIN
76
+ SELECT * INTO state FROM _ontology_replication WHERE id;
77
+ policy := state.selection->${ql(entity.id)};
78
+ IF NOT state.enabled OR policy IS NULL THEN RETURN NEW; END IF;
79
+ record_key := NEW.record_id::text;
80
+ IF NEW.kind IN ('link', 'unlink') THEN
81
+ SELECT key INTO relation FROM jsonb_object_keys(NEW.changes) AS key LIMIT 1;
82
+ IF NOT (${ql(JSON.stringify(declared))}::jsonb ? relation) OR
83
+ NOT (COALESCE(policy->'relationships', '[]'::jsonb) ? relation) THEN RETURN NEW; END IF;
84
+ other_id := COALESCE(NEW.changes->relation->>'after', NEW.changes->relation->>'before');
85
+ record_key := NEW.record_id::text || ':' || other_id;
86
+ record_kind := 'edge:' || relation;
87
+ is_deleted := NEW.kind = 'unlink';
88
+ snapshot := jsonb_build_object('source_id', NEW.record_id::text, 'target_id', other_id);
89
+ ELSIF is_deleted THEN snapshot := '{}'::jsonb;
90
+ ELSE
91
+ SELECT ${snapshotExpression(entity, "record")} INTO snapshot FROM ${table} AS record WHERE id = NEW.record_id;
92
+ SELECT COALESCE(jsonb_object_agg(key, value), '{}'::jsonb) INTO snapshot
93
+ FROM jsonb_each(snapshot) WHERE key = 'id' OR (policy->'fields' ? key);
94
+ IF snapshot IS NULL OR snapshot = '{}'::jsonb THEN RAISE EXCEPTION 'Replication snapshot is missing'; END IF;
95
+ END IF;
96
+ IF octet_length(snapshot::text) > ${REPLICATION_RECORD_BYTES} THEN
97
+ RAISE EXCEPTION 'Projected ontology record exceeds the replication size limit';
98
+ END IF;
99
+ UPDATE _ontology_replication SET revision = revision + 1 WHERE id RETURNING * INTO state;
100
+ NEW.replication := jsonb_build_object('source_id', state.source_id::text,
101
+ 'entity_id', ${ql(entity.id)}, 'record_key', record_key, 'record_kind', record_kind,
102
+ 'revision', state.revision::text, 'deleted', is_deleted,
103
+ 'schema_hash', ${ql(manifest.hash)}, 'payload', snapshot::text);
104
+ RETURN NEW;
105
+ END $$`,
106
+ `DROP TRIGGER IF EXISTS _ontology_capture ON ${history}`,
107
+ `CREATE TRIGGER _ontology_capture BEFORE INSERT ON ${history} FOR EACH ROW EXECUTE FUNCTION ${fn}()`,
108
+ `DROP TRIGGER IF EXISTS _ontology_writer_guard ON ${table}`,
109
+ `CREATE TRIGGER _ontology_writer_guard BEFORE INSERT OR UPDATE OR DELETE OR TRUNCATE ON ${table}
110
+ FOR EACH STATEMENT EXECUTE FUNCTION _ontology_replication_guard()`,
111
+ ...entity.relationships
112
+ .filter((r) => r.cardinality === "many")
113
+ .flatMap((r) => {
114
+ const join = qi(joinTableName(entity.name, r.name));
115
+ return [
116
+ `DROP TRIGGER IF EXISTS _ontology_writer_guard ON ${join}`,
117
+ `CREATE TRIGGER _ontology_writer_guard BEFORE INSERT OR UPDATE OR DELETE OR TRUNCATE ON ${join}
118
+ FOR EACH STATEMENT EXECUTE FUNCTION _ontology_replication_guard()`,
119
+ ];
120
+ }),
121
+ ];
122
+ });
123
+ }
@@ -0,0 +1,35 @@
1
+ import { Effect } from "effect";
2
+ import type { SqlClient } from "effect/unstable/sql";
3
+ import { type ReplicationEvent, type ReplicationSelection, type ReplicationExclusion } from "./model.js";
4
+ import { type ReplicationTarget } from "./baseline.js";
5
+ export type { ReplicationTarget } from "./baseline.js";
6
+ export declare const startReplication: (sql: SqlClient.SqlClient, input: {
7
+ readonly generation: string;
8
+ readonly exclusions?: ReadonlyArray<ReplicationExclusion>;
9
+ readonly selection?: ReadonlyArray<ReplicationSelection>;
10
+ readonly newSource?: boolean;
11
+ }) => Effect.Effect<ReplicationTarget, import("../index.js").CatalogInvalid | import("effect/unstable/sql/SqlError").SqlError, never>;
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>;
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;
17
+ export declare const pendingReplicationEvents: (sql: SqlClient.SqlClient, target: ReplicationTarget) => Effect.Effect<{
18
+ readonly historyId: string;
19
+ readonly event: ReplicationEvent;
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
+ export declare const replicationStatus: (sql: SqlClient.SqlClient, target: ReplicationTarget) => Effect.Effect<{
25
+ total: string;
26
+ accepted: string;
27
+ verified: string;
28
+ }, import("effect/unstable/sql/SqlError").SqlError, never>;
29
+ export declare const unverifiedReplicationEvents: (sql: SqlClient.SqlClient, target: ReplicationTarget) => Effect.Effect<{
30
+ historyId: string;
31
+ eventId: string;
32
+ }[], 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>;
@@ -0,0 +1,88 @@
1
+ import { Effect } from "effect";
2
+ import { readCatalog } from "../migrate/apply.js";
3
+ import { historyTableName, quoteIdentifier as qi } from "../migrate/storage.js";
4
+ import { REPLICATION_BATCH_BYTES, REPLICATION_BATCH_SIZE, publishMessage, replicationEventId, resolveReplicationSelection, } from "./model.js";
5
+ import { createReplicationBaseline } 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");
10
+ const manifest = yield* readCatalog({ sql });
11
+ if (!manifest) {
12
+ return yield* Effect.die(new Error("The ontology has not been migrated."));
13
+ }
14
+ const selection = input.selection ??
15
+ (yield* Effect.sync(() => resolveReplicationSelection(manifest, input.exclusions)));
16
+ return yield* createReplicationBaseline(sql, { ...input, manifest, selection });
17
+ }));
18
+ export const activeReplicationTarget = (sql) => sql
19
+ .unsafe(`SELECT t.generation::text, t.source_id::text, t.from_revision::text, t.manifest, t.selection
20
+ FROM _ontology_replication_targets t JOIN _ontology_replication r ON r.active_generation = t.generation
21
+ WHERE r.id`)
22
+ .pipe(Effect.map((rows) => rows[0]));
23
+ export const pauseReplication = (sql) => sql.unsafe("UPDATE _ontology_replication SET enabled = false WHERE id");
24
+ export const replicationTarget = (sql, generation) => sql
25
+ .unsafe(`SELECT generation::text, source_id::text, from_revision::text, manifest, selection
26
+ FROM _ontology_replication_targets WHERE generation = $1::uuid`, [generation])
27
+ .pipe(Effect.map((rows) => rows[0]));
28
+ /** No timestamp/high-water cursor: every unacknowledged immutable event remains discoverable. */
29
+ export function replicationEventsSql(target) {
30
+ const histories = target.selection.map((item) => {
31
+ const entity = target.manifest.entities.find((e) => e.id === item.entityId);
32
+ return `SELECT id, replication AS event FROM ${qi(historyTableName(entity.name))}
33
+ WHERE replication IS NOT NULL AND replication->>'source_id' = $2
34
+ AND (replication->>'revision')::bigint > $3::bigint`;
35
+ });
36
+ const baseline = `SELECT id, event FROM _ontology_replication_baselines WHERE generation = $1::uuid
37
+ AND $2::text IS NOT NULL AND $3::bigint IS NOT NULL`;
38
+ return [baseline, ...histories].join(" UNION ALL ");
39
+ }
40
+ export const pendingReplicationEvents = (sql, target) => sql
41
+ .unsafe(`SELECT e.id::text, e.event FROM (${replicationEventsSql(target)}) e
42
+ WHERE NOT EXISTS (SELECT 1 FROM _ontology_replication_receipts r
43
+ WHERE r.generation = $1::uuid AND r.event_id = e.id)
44
+ ORDER BY (event->>'revision')::bigint, id LIMIT ${REPLICATION_BATCH_SIZE}`, [target.generation, target.source_id, target.from_revision])
45
+ .pipe(Effect.map((rows) => {
46
+ let size = 32;
47
+ const result = [];
48
+ for (const row of rows) {
49
+ const event = {
50
+ ...row.event,
51
+ generation: target.generation,
52
+ event_id: replicationEventId(target.source_id, row.id, target.generation),
53
+ };
54
+ const bytes = Buffer.byteLength(JSON.stringify(publishMessage(event))) + 1;
55
+ if (bytes + 32 > REPLICATION_BATCH_BYTES) {
56
+ throw new Error("A replication event exceeds the publish request limit.");
57
+ }
58
+ if (size + bytes > REPLICATION_BATCH_BYTES) {
59
+ break;
60
+ }
61
+ size += bytes;
62
+ result.push({ historyId: row.id, event });
63
+ }
64
+ return result;
65
+ }));
66
+ /** Call only after Pub/Sub accepts the complete batch. Lost responses deliberately produce duplicates. */
67
+ export const acknowledgeReplication = (sql, generation, ids) => sql.unsafe(`INSERT INTO _ontology_replication_receipts (generation, event_id)
68
+ SELECT $1::uuid, value::uuid FROM jsonb_array_elements_text($2::jsonb) ON CONFLICT DO NOTHING`, [generation, JSON.stringify(ids)]);
69
+ export const replayReplication = (sql, generation) => sql.unsafe("DELETE FROM _ontology_replication_receipts WHERE generation = $1::uuid", [
70
+ generation,
71
+ ]);
72
+ export const replicationStatus = (sql, target) => sql
73
+ .unsafe(`SELECT count(*)::text AS total, count(r.event_id)::text AS accepted, count(r.verified_at)::text AS verified
74
+ FROM (${replicationEventsSql(target)}) e
75
+ 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])
76
+ .pipe(Effect.map((rows) => rows[0]));
77
+ export const unverifiedReplicationEvents = (sql, target) => sql
78
+ .unsafe(`SELECT event_id::text AS id FROM _ontology_replication_receipts
79
+ WHERE generation = $1::uuid AND verified_at IS NULL ORDER BY accepted_at, event_id LIMIT 1000`, [target.generation])
80
+ .pipe(Effect.map((rows) => rows.map(({ id }) => ({
81
+ historyId: id,
82
+ eventId: replicationEventId(target.source_id, id, target.generation),
83
+ }))));
84
+ export const verifyReplicationEvents = (sql, generation, ids) => sql.unsafe(`UPDATE _ontology_replication_receipts SET verified_at = now()
85
+ WHERE generation = $1::uuid AND event_id IN (SELECT value::uuid FROM jsonb_array_elements_text($2::jsonb))`, [generation, JSON.stringify(ids)]);
86
+ /** Retry accepted events that never appeared, including dead letters and transport expiry. */
87
+ export const retryUnverifiedReplication = (sql, generation) => sql.unsafe(`DELETE FROM _ontology_replication_receipts WHERE generation = $1::uuid
88
+ AND verified_at IS NULL AND accepted_at < now() - interval '15 minutes'`, [generation]);
@@ -0,0 +1,4 @@
1
+ import { Effect } from "effect";
2
+ import type { SqlClient } from "effect/unstable/sql";
3
+ /** Must run inside the write transaction, before any business row locks. */
4
+ export declare const beginReplicationWrite: (sql: SqlClient.SqlClient) => Effect.Effect<void, import("effect/unstable/sql/SqlError").SqlError, never>;
@@ -0,0 +1,11 @@
1
+ import { Effect } from "effect";
2
+ /** Must run inside the write transaction, before any business row locks. */
3
+ export const beginReplicationWrite = (sql) => Effect.gen(function* () {
4
+ const [schema] = yield* sql.unsafe("SELECT to_regclass('_ontology_replication') IS NOT NULL AS present");
5
+ if (!schema?.present) {
6
+ 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");
11
+ });
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
+ import { beginReplicationWrite } from "../replication/transaction.js";
2
3
  import { Cause, Duration, Effect, Option, Schema } from "effect";
3
4
  import { SqlClient } from "effect/unstable/sql";
4
5
  import { isSqlError } from "effect/unstable/sql/SqlError";
@@ -204,7 +205,7 @@ export function runAction(ontology, request) {
204
205
  return result;
205
206
  });
206
207
  const result = yield* sql
207
- .withTransaction(transactionalHandler)
208
+ .withTransaction(beginReplicationWrite(sql).pipe(Effect.andThen(transactionalHandler)))
208
209
  .pipe(Effect.catchIf(isSqlError, (error) => Effect.die(error)));
209
210
  if (replay) {
210
211
  return replay;
@@ -11,6 +11,7 @@ import { buildOntology } from "../ontology.js";
11
11
  import { permissionsOf } from "../permissions.js";
12
12
  import { EMAIL_PATTERN } from "../property.js";
13
13
  import { role } from "../role.js";
14
+ import { beginReplicationWrite } from "../replication/transaction.js";
14
15
  import { User } from "../user.js";
15
16
  import { MemberNotFound, PrincipalRejected, UnknownRole } from "./errors.js";
16
17
  import { isRecordId } from "./records.js";
@@ -116,6 +117,7 @@ function signIn(ontology, sql, claims) {
116
117
  const email = claimedEmail !== undefined && EMAIL_PATTERN.test(claimedEmail) ? claimedEmail : undefined;
117
118
  return sql
118
119
  .withTransaction(Effect.gen(function* () {
120
+ yield* beginReplicationWrite(sql);
119
121
  const now = new Date();
120
122
  const signedIn = { last_sign_in_at: now.toISOString() };
121
123
  const [linked] = yield* runtime.find({
@@ -295,6 +297,7 @@ export function setUserRoles(ontology, input) {
295
297
  }
296
298
  const roles = [...new Set(input.roles)];
297
299
  yield* sql.withTransaction(Effect.gen(function* () {
300
+ yield* beginReplicationWrite(sql);
298
301
  yield* sql.unsafe(`DELETE FROM "${USER_ROLES_TABLE}" WHERE "user_id" = $1::uuid`, [
299
302
  input.userId,
300
303
  ]);
@@ -313,6 +316,7 @@ export function createServiceUser(ontology, input) {
313
316
  const sql = yield* SqlClient.SqlClient;
314
317
  const runtime = platformRuntime(ontology, sql, "user.create_service");
315
318
  return yield* sql.withTransaction(Effect.gen(function* () {
319
+ yield* beginReplicationWrite(sql);
316
320
  const user = yield* runtime
317
321
  .create({ entity: "user", values: { name: input.name, kind: "service" } })
318
322
  .pipe(Effect.orDie);
@@ -576,6 +580,7 @@ export function updateMemberRoles(ontology, change) {
576
580
  yield* refuseMember(revoke === "all" ? [] : revoke);
577
581
  const runtime = platformRuntime(ontology, sql, "user.invite");
578
582
  const attempt = sql.withTransaction(Effect.gen(function* () {
583
+ yield* beginReplicationWrite(sql);
579
584
  const found = yield* lockUser(runtime, { email });
580
585
  if (found !== undefined && found.kind !== "member") {
581
586
  return yield* new MemberNotFound({
@@ -636,6 +641,7 @@ export function setMemberActive(ontology, input) {
636
641
  const action = input.active ? "user.reactivate" : "user.deactivate";
637
642
  const runtime = platformRuntime(ontology, sql, action);
638
643
  return yield* sql.withTransaction(Effect.gen(function* () {
644
+ yield* beginReplicationWrite(sql);
639
645
  const user = yield* lockUser(runtime, input);
640
646
  if (user === undefined || user.kind !== "member") {
641
647
  return yield* memberNotFound(input);
@@ -60,6 +60,14 @@ export function backendLayer(ontology, blobStorage) {
60
60
  checkSignIn: (email) => withSql(checkSignIn(ontology, email)),
61
61
  resolveApiKey: (key) => withSql(resolvePrincipalByApiKey(ontology, Redacted.value(key))).pipe(Effect.catchTag("PrincipalRejected", rejectedAs("api_key"))),
62
62
  appliedHash: readCatalog({ sql }).pipe(Effect.map((manifest) => manifest?.hash), Effect.orDie),
63
+ replicationEnabled: Effect.gen(function* () {
64
+ const [presence] = yield* sql.unsafe("SELECT to_regclass('_ontology_replication') IS NOT NULL AS present");
65
+ if (!presence?.present) {
66
+ return false;
67
+ }
68
+ const [state] = yield* sql.unsafe("SELECT enabled FROM _ontology_replication WHERE id");
69
+ return state?.enabled === true;
70
+ }).pipe(Effect.orDie),
63
71
  oauth: sqlOAuthStore(ontology, sql),
64
72
  });
65
73
  }));
@@ -1,4 +1,5 @@
1
1
  import { Effect } from "effect";
2
+ import { beginReplicationWrite } from "../replication/transaction.js";
2
3
  import { isSqlError } from "effect/unstable/sql/SqlError";
3
4
  import { Conflict } from "../errors.js";
4
5
  import { InvalidQuery, WriteOutsideAction } from "./errors.js";
@@ -67,7 +68,7 @@ export function makeOntologyRuntime(ontology, sql, scope) {
67
68
  }
68
69
  const edits = [];
69
70
  const result = yield* sql
70
- .withTransaction(operation({ ...context, scope, edits }, entity))
71
+ .withTransaction(beginReplicationWrite(sql).pipe(Effect.andThen(operation({ ...context, scope, edits }, entity))))
71
72
  .pipe(Effect.catchIf(isSqlError, (error) => uniqueConflict(entity, error)));
72
73
  for (const edit of edits) {
73
74
  scope.onEdit?.(edit);
@@ -2,6 +2,7 @@ import { Layer } from "effect";
2
2
  import { type HttpApiGroup } from "effect/unstable/httpapi";
3
3
  import { type BuiltApi } from "./api.js";
4
4
  import type { AfterCommitHook } from "./config.js";
5
+ import type { ReplicationKick } from "./replication.js";
5
6
  import type { ConnectionTransportFactory } from "./connections.js";
6
7
  import { type RateLimit } from "./rate-limit.js";
7
8
  import { OntologyBackend } from "./runtime-contract.js";
@@ -11,4 +12,4 @@ import { OntologyBackend } from "./runtime-contract.js";
11
12
  */
12
13
  export declare const SIGN_IN_CHECK_LIMIT: RateLimit;
13
14
  /** The handlers of every group of the API; they require {@link OntologyBackend}. */
14
- export declare function handlersLayer(built: BuiltApi, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory): Layer.Layer<HttpApiGroup.Service<"ontology", string>, never, OntologyBackend>;
15
+ export declare function handlersLayer(built: BuiltApi, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory, replicate: ReplicationKick): Layer.Layer<HttpApiGroup.Service<"ontology", string>, never, OntologyBackend>;
@@ -178,8 +178,8 @@ function storageHandlers(built) {
178
178
  }));
179
179
  }
180
180
  /** The handlers of every group of the API; they require {@link OntologyBackend}. */
181
- export function handlersLayer(built, hooks, connections) {
182
- const operationsOf = (backend) => makeOperations(backend, built.plan, hooks, connections);
181
+ export function handlersLayer(built, hooks, connections, replicate) {
182
+ const operationsOf = (backend) => makeOperations(backend, built.plan, hooks, connections, replicate);
183
183
  const layers = [
184
184
  serviceHandlers(built, operationsOf),
185
185
  ...(built.plan.ontology.manifest.entities.some((entity) => entity.properties.some((property) => property.type === "blob"))
@@ -5,6 +5,7 @@ import { type TokenVerifier } from "../authentication.js";
5
5
  import type { AfterCommitHook } from "../config.js";
6
6
  import type { ConnectionTransportFactory } from "../connections.js";
7
7
  import { RequestId } from "../errors.js";
8
+ import type { ReplicationKick } from "../replication.js";
8
9
  import { OntologyBackend } from "../runtime-contract.js";
9
10
  /** The URL path of the ontology's MCP server, outside `/api/ontology`: one per app. */
10
11
  export declare const MCP_PATH = "/mcp";
@@ -14,6 +15,7 @@ export interface McpServerOptions {
14
15
  readonly built: BuiltApi;
15
16
  readonly hooks: ReadonlyMap<string, AfterCommitHook>;
16
17
  readonly connections: ConnectionTransportFactory;
18
+ readonly replicate: ReplicationKick;
17
19
  readonly verifyToken: TokenVerifier;
18
20
  }
19
21
  /**
@@ -361,7 +361,7 @@ export function mcpLayer(options) {
361
361
  const server = {
362
362
  built: options.built,
363
363
  tools,
364
- operations: makeOperations(backend, options.built.plan, options.hooks, options.connections),
364
+ operations: makeOperations(backend, options.built.plan, options.hooks, options.connections, options.replicate),
365
365
  };
366
366
  yield* router.add("POST", MCP_PATH, handlePost(server, options.verifyToken, backend));
367
367
  yield* router.add("GET", MCP_PATH, METHOD_NOT_ALLOWED);
@@ -4,6 +4,7 @@ import { PermissionDenied } from "../errors.js";
4
4
  import type { Query, QueryResult } from "../query.js";
5
5
  import { CurrentPrincipal } from "./authentication.js";
6
6
  import type { AfterCommitHook } from "./config.js";
7
+ import type { ReplicationKick } from "./replication.js";
7
8
  import { type ConnectionTransportFactory } from "./connections.js";
8
9
  import { InvalidActionInput, RequestId } from "./errors.js";
9
10
  import type { ApiPlan, PlannedAction, PlannedEntity, Traversal } from "./plan.js";
@@ -59,7 +60,7 @@ export interface Caller {
59
60
  }
60
61
  type Backend = OntologyBackend["Service"];
61
62
  /** The operations of a planned ontology, bound to its backend, after-commit hooks and connections. */
62
- export declare function makeOperations(backend: Backend, plan: ApiPlan, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory): {
63
+ export declare function makeOperations(backend: Backend, plan: ApiPlan, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory, replicate: ReplicationKick): {
63
64
  /** A page of records. Requires `read` on the entity and on every entity the query reaches. */
64
65
  find: (planned: PlannedEntity, query: Query) => Effect.Effect<QueryResult<Readonly<Record<string, unknown>>>, PermissionDenied | import("../index.js").InvalidQuery, CurrentPrincipal>;
65
66
  /** One record. Requires `read` on the entity. */
@@ -145,7 +145,7 @@ function traverseOf(backend, targets, planned, traversal, id, query) {
145
145
  return page(yield* backend.find({ entity: traversal.target, query: { ...query, where } }));
146
146
  });
147
147
  }
148
- function runActionOf(backend, ontology, hooks, connections) {
148
+ function runActionOf(backend, ontology, hooks, connections, replicate) {
149
149
  return (planned, { name, generated }, call) => Effect.gen(function* () {
150
150
  const principal = yield* CurrentPrincipal;
151
151
  const requestId = yield* RequestId;
@@ -161,9 +161,19 @@ function runActionOf(backend, ontology, hooks, connections) {
161
161
  identityToken: call.identityToken,
162
162
  audit: { action: `${planned.name}.${name}`, principalId: principal.id, requestId },
163
163
  }),
164
- ...(hook === undefined
165
- ? {}
166
- : { afterCommit: afterCommitOf(hook, { requestId, generated }, ontology) }),
164
+ ...(hook || backend.replicationEnabled
165
+ ? {
166
+ afterCommit: (committed) => Effect.gen(function* () {
167
+ if (hook) {
168
+ yield* afterCommitOf(hook, { requestId, generated }, ontology)(committed);
169
+ }
170
+ if (committed.edits.length > 0 &&
171
+ (yield* backend.replicationEnabled ?? Effect.succeed(false))) {
172
+ yield* replicate(call.identityToken);
173
+ }
174
+ }).pipe(Effect.catchCause((cause) => Effect.logWarning("ontology.replication.deferred", cause))),
175
+ }
176
+ : {}),
167
177
  });
168
178
  const { issues } = outcome.validation;
169
179
  if (!outcome.validation.ok && !call.validateOnly) {
@@ -199,7 +209,7 @@ function callerOf(backend) {
199
209
  });
200
210
  }
201
211
  /** The operations of a planned ontology, bound to its backend, after-commit hooks and connections. */
202
- export function makeOperations(backend, plan, hooks, connections) {
212
+ export function makeOperations(backend, plan, hooks, connections, replicate) {
203
213
  const targets = relationshipTargets(plan);
204
214
  return {
205
215
  /** A page of records. Requires `read` on the entity and on every entity the query reaches. */
@@ -211,7 +221,7 @@ export function makeOperations(backend, plan, hooks, connections) {
211
221
  /** The records a relationship reaches from one record, as a page of the target entity. */
212
222
  traverse: (planned, traversal, id, query) => traverseOf(backend, targets, planned, traversal, id, query),
213
223
  /** Runs an action as the caller, with the action's after-commit hook and connections. */
214
- runAction: runActionOf(backend, plan.ontology, hooks, connections),
224
+ runAction: runActionOf(backend, plan.ontology, hooks, connections, replicate),
215
225
  /** The caller's user, roles and permissions. It needs no permission. */
216
226
  caller: callerOf(backend),
217
227
  };
@@ -0,0 +1,5 @@
1
+ import { Effect } from "effect";
2
+ import type { OntologyEnvironment } from "./config.js";
3
+ export type ReplicationKick = (identity: string | undefined) => Effect.Effect<void>;
4
+ /** A fast path only. The committed history, not this request, makes delivery durable. */
5
+ export declare function deployedReplication(environment: OntologyEnvironment, fetchImpl?: typeof fetch): ReplicationKick;
@@ -0,0 +1,25 @@
1
+ import { Effect } from "effect";
2
+ /** A fast path only. The committed history, not this request, makes delivery durable. */
3
+ export function deployedReplication(environment, fetchImpl = fetch) {
4
+ return (identity) => {
5
+ if (!identity) {
6
+ return Effect.void;
7
+ }
8
+ return Effect.tryPromise(async () => {
9
+ const response = await fetchImpl(`${environment.segmentstreamUrl}/api/internal/v1/workspaces/` +
10
+ `${encodeURIComponent(environment.workspaceId)}/ontology/replication/drain`, {
11
+ method: "POST",
12
+ redirect: "error",
13
+ signal: AbortSignal.timeout(5000),
14
+ headers: {
15
+ "x-segmentstream-runtime-identity": identity,
16
+ "x-segmentstream-organization-id": environment.organizationId,
17
+ },
18
+ });
19
+ await response.body?.cancel();
20
+ if (!response.ok) {
21
+ throw new Error(`Replication wake-up returned HTTP ${response.status}.`);
22
+ }
23
+ }).pipe(Effect.catchCause((cause) => Effect.logWarning("ontology.replication.deferred", cause)));
24
+ };
25
+ }
@@ -189,6 +189,7 @@ declare const OntologyBackend_base: Context.ServiceClass<OntologyBackend, "@segm
189
189
  readonly resolveApiKey: (key: Redacted.Redacted<string>) => Effect.Effect<Principal | undefined>;
190
190
  /** The manifest hash recorded in the `_ontology` catalog table, or `undefined` before the first. */
191
191
  readonly appliedHash: Effect.Effect<string | undefined>;
192
+ readonly replicationEnabled?: Effect.Effect<boolean>;
192
193
  /** The OAuth tables, for members who connect agents; without it, agents use API keys. */
193
194
  readonly oauth?: OAuthStore;
194
195
  }>;
@@ -11,6 +11,7 @@ import { RequestId, failureResponse, internalError, errorBodyForWire, routeNotFo
11
11
  import { deployedConnections, localConnections } from "./connections.js";
12
12
  import { DocsRoute } from "./docs.js";
13
13
  import { handlersLayer } from "./handlers.js";
14
+ import { deployedReplication } from "./replication.js";
14
15
  import { mcpLayer } from "./mcp/server.js";
15
16
  import { oauthLayer } from "./oauth/server.js";
16
17
  import { planApi } from "./plan.js";
@@ -82,14 +83,15 @@ export function createOntologyService(ontology, config) {
82
83
  (config.localConnections === undefined
83
84
  ? deployedConnections(config)
84
85
  : localConnections(config.localConnections));
85
- const routes = HttpApiBuilder.layer(built.api).pipe(Layer.provide(handlersLayer(built, hooks, connections)), Layer.provide([
86
+ const replicate = deployedReplication(config);
87
+ const routes = HttpApiBuilder.layer(built.api).pipe(Layer.provide(handlersLayer(built, hooks, connections, replicate)), Layer.provide([
86
88
  authenticationLayer(verifyToken, exchangeSession),
87
89
  ErrorResponsesLive,
88
90
  storageMaintenanceAuthenticationLayer(config.storageMaintenanceKey),
89
91
  ]));
90
92
  const mcp = config.mcp === false
91
93
  ? Layer.empty
92
- : Layer.mergeAll(mcpLayer({ built, hooks, connections, verifyToken }), oauthLayer({ verifyToken }));
94
+ : Layer.mergeAll(mcpLayer({ built, hooks, connections, verifyToken, replicate }), oauthLayer({ verifyToken }));
93
95
  const application = Layer.mergeAll(routes, mcp, DocsRoute, UnmatchedRoutes).pipe(Layer.provide(backend), Layer.provide(HttpServer.layerServices));
94
96
  const web = HttpRouter.toWebHandler(application, { disableLogger: true });
95
97
  const hash = ontology.manifest.hash;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/ontology",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Define a SegmentStream workspace app's ontology: entities, relationships, roles and actions, written with Effect.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SegmentStream",