@segmentstream-cloud/ontology 0.11.0 → 0.13.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.
Files changed (71) hide show
  1. package/README.md +51 -15
  2. package/dist/aggregate-routes.d.ts +95 -0
  3. package/dist/aggregate-routes.js +332 -0
  4. package/dist/analyze.js +83 -16
  5. package/dist/entity.d.ts +31 -15
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/manifest.d.ts +57 -23
  9. package/dist/manifest.js +77 -30
  10. package/dist/measure.d.ts +11 -2
  11. package/dist/measure.js +14 -1
  12. package/dist/migrate/apply.d.ts +1 -1
  13. package/dist/migrate/apply.js +36 -3
  14. package/dist/migrate/plan.d.ts +1 -1
  15. package/dist/migrate/plan.js +38 -13
  16. package/dist/migrate/storage.d.ts +3 -2
  17. package/dist/migrate/storage.js +15 -5
  18. package/dist/names.d.ts +4 -2
  19. package/dist/names.js +4 -1
  20. package/dist/query.d.ts +5 -2
  21. package/dist/query.js +6 -2
  22. package/dist/relationship.d.ts +36 -5
  23. package/dist/relationship.js +5 -3
  24. package/dist/render.d.ts +47 -3
  25. package/dist/render.js +117 -11
  26. package/dist/replication/baseline.d.ts +19 -0
  27. package/dist/replication/baseline.js +97 -0
  28. package/dist/replication/bigquery.d.ts +23 -0
  29. package/dist/replication/bigquery.js +63 -0
  30. package/dist/replication/model.d.ts +38 -0
  31. package/dist/replication/model.js +99 -0
  32. package/dist/replication/schema.d.ts +7 -0
  33. package/dist/replication/schema.js +123 -0
  34. package/dist/replication/store.d.ts +35 -0
  35. package/dist/replication/store.js +88 -0
  36. package/dist/replication/transaction.d.ts +4 -0
  37. package/dist/replication/transaction.js +11 -0
  38. package/dist/runtime/pipeline.js +2 -1
  39. package/dist/runtime/principals.js +6 -0
  40. package/dist/runtime/service-binding.js +8 -0
  41. package/dist/runtime/sql-runtime.js +2 -1
  42. package/dist/service/aggregate-platform.d.ts +60 -0
  43. package/dist/service/aggregate-platform.js +189 -0
  44. package/dist/service/aggregate.d.ts +42 -0
  45. package/dist/service/aggregate.js +145 -0
  46. package/dist/service/api.d.ts +5 -3
  47. package/dist/service/api.js +52 -6
  48. package/dist/service/catalog.d.ts +10 -1
  49. package/dist/service/catalog.js +12 -3
  50. package/dist/service/config.d.ts +13 -1
  51. package/dist/service/config.js +11 -1
  52. package/dist/service/errors.d.ts +32 -0
  53. package/dist/service/errors.js +102 -0
  54. package/dist/service/handlers.d.ts +3 -1
  55. package/dist/service/handlers.js +67 -29
  56. package/dist/service/mcp/server.d.ts +5 -0
  57. package/dist/service/mcp/server.js +61 -9
  58. package/dist/service/mcp/tools.d.ts +13 -2
  59. package/dist/service/mcp/tools.js +165 -6
  60. package/dist/service/operations.d.ts +2 -1
  61. package/dist/service/operations.js +16 -6
  62. package/dist/service/plan.js +7 -1
  63. package/dist/service/replication.d.ts +5 -0
  64. package/dist/service/replication.js +25 -0
  65. package/dist/service/runtime-contract.d.ts +1 -0
  66. package/dist/service/schemas.d.ts +99 -1
  67. package/dist/service/schemas.js +150 -2
  68. package/dist/service/service.js +10 -2
  69. package/dist/user.d.ts +1 -1
  70. package/dist/warehouse-backed.js +222 -121
  71. package/package.json +1 -1
@@ -0,0 +1,99 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isValueMatched } from "../manifest.js";
3
+ import { queryFields } from "../query.js";
4
+ export function resolveReplicationSelection(manifest, exclusions = []) {
5
+ const entities = manifest.entities.filter((entity) => !entity.builtIn && !entity.source);
6
+ if (new Set(exclusions.map((item) => item.entityId)).size !== exclusions.length) {
7
+ throw new Error("Replication exclusions must name distinct entities.");
8
+ }
9
+ for (const item of exclusions) {
10
+ const entity = entities.find((candidate) => candidate.id === item.entityId);
11
+ if (!entity) {
12
+ throw new Error(`Unknown stored business entity in replication exclusions: ${item.entityId}.`);
13
+ }
14
+ const fields = new Set(queryFields(entity).map((field) => field.name));
15
+ const relationships = new Set(entity.relationships.map((relationship) => relationship.name));
16
+ if (item.fields?.some((field) => field === "id" || !fields.has(field))) {
17
+ throw new Error(`Unknown or required replication field on ${entity.name}.`);
18
+ }
19
+ if (item.relationships?.some((relationship) => !relationships.has(relationship))) {
20
+ throw new Error(`Unknown replication relationship on ${entity.name}.`);
21
+ }
22
+ // A value-matched relationship has no `<name>_id`: its reference is the property `on` names.
23
+ const matched = entity.relationships.find((relationship) => item.relationships?.includes(relationship.name) && isValueMatched(relationship));
24
+ if (matched !== undefined) {
25
+ throw new Error(`${entity.name}.${matched.name} is matched by value; exclude its property ` +
26
+ `${matched.on?.property ?? ""} instead.`);
27
+ }
28
+ }
29
+ const included = entities.filter((entity) => !exclusions.some((item) => item.entityId === entity.id && item.disabled));
30
+ const selection = included.map((entity) => {
31
+ const excluded = exclusions.find((item) => item.entityId === entity.id);
32
+ const keyed = entity.relationships.filter((relationship) => relationship.cardinality === "one" && !isValueMatched(relationship));
33
+ const excludedFields = new Set([
34
+ ...(excluded?.fields ?? []),
35
+ ...keyed
36
+ .filter((relationship) => excluded?.relationships?.includes(relationship.name))
37
+ .map((relationship) => `${relationship.name}_id`),
38
+ ]);
39
+ return {
40
+ entityId: entity.id,
41
+ fields: queryFields(entity)
42
+ .filter((field) => !excludedFields.has(field.name))
43
+ .map((field) => field.name),
44
+ relationships: entity.relationships
45
+ .filter((relationship) => relationship.cardinality === "many" &&
46
+ !excluded?.relationships?.includes(relationship.name) &&
47
+ included.some((target) => target.name === relationship.to))
48
+ .map((relationship) => relationship.name),
49
+ };
50
+ });
51
+ validateReplicationSelection(manifest, selection);
52
+ return selection;
53
+ }
54
+ export const REPLICATION_BATCH_SIZE = 100;
55
+ export const REPLICATION_BATCH_BYTES = 1_000_000;
56
+ // Leaves room for JSON escaping, the envelope and base64 inside a 1 MB publish request.
57
+ export const REPLICATION_RECORD_BYTES = 200_000;
58
+ export function validateReplicationSelection(manifest, selection) {
59
+ if (new Set(selection.map((s) => s.entityId)).size !== selection.length) {
60
+ throw new Error("Replicated entities must be distinct.");
61
+ }
62
+ const viewNames = new Set();
63
+ const addView = (name) => {
64
+ const key = name.toLowerCase();
65
+ if (viewNames.has(key)) {
66
+ throw new Error("Replicated entity and relationship view names collide.");
67
+ }
68
+ viewNames.add(key);
69
+ };
70
+ for (const item of selection) {
71
+ const entity = manifest.entities.find((e) => e.id === item.entityId);
72
+ if (!entity || entity.name === "user" || entity.source !== undefined) {
73
+ throw new Error(`Only stored business entities can be replicated: ${item.entityId}.`);
74
+ }
75
+ addView(entity.name);
76
+ const fields = new Set(queryFields(entity).map((f) => f.name));
77
+ if (item.fields.some((field) => !fields.has(field))) {
78
+ throw new Error(`Unknown replication field on ${entity.name}.`);
79
+ }
80
+ for (const name of item.relationships ?? []) {
81
+ const relationship = entity.relationships.find((r) => r.name === name);
82
+ const target = manifest.entities.find((e) => e.name === relationship?.to);
83
+ if (relationship?.cardinality !== "many" ||
84
+ !target ||
85
+ target.name === "user" ||
86
+ !selection.some((s) => s.entityId === target.id)) {
87
+ throw new Error(`Replication of ${entity.name}.${name} requires both business entities.`);
88
+ }
89
+ addView(`${entity.name}_${name}`);
90
+ }
91
+ }
92
+ }
93
+ export function replicationEventId(source, history, generation) {
94
+ return createHash("sha256").update(`${source}:${history}:${generation}`).digest("hex");
95
+ }
96
+ /** Includes base64 and JSON overhead, not just the original payload bytes. */
97
+ export function publishMessage(event) {
98
+ return { data: Buffer.from(JSON.stringify(event)).toString("base64") };
99
+ }
@@ -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);
@@ -0,0 +1,60 @@
1
+ import { Effect, Redacted } from "effect";
2
+ import { AggregateUpstreamUnavailable } from "./errors.js";
3
+ /**
4
+ * How long one aggregate may take end to end, the response included. BigQuery answers most within
5
+ * seconds; the margin covers a cold warehouse and the hops to and from SegmentStream.
6
+ */
7
+ export declare const AGGREGATE_TIMEOUT_MS = 60000;
8
+ /**
9
+ * The hash of the manifest the service resolved and checked the request against. SegmentStream
10
+ * answers `409 ontology_version_mismatch` when its deployed manifest differs, so a request checked
11
+ * against one ontology never runs against another.
12
+ */
13
+ export declare const MANIFEST_HASH_HEADER = "x-ontology-manifest-hash";
14
+ /**
15
+ * The entities the service checked `<entity>:read` on, sorted and comma-separated. SegmentStream
16
+ * answers `409 aggregate_scope_mismatch` when it would read any other, so the service's resolution
17
+ * and SegmentStream's can never drift apart unnoticed.
18
+ */
19
+ export declare const TOUCHED_HEADER = "x-ontology-touched";
20
+ /** SegmentStream would read beyond what the service checked: this deployment's SDK is behind. */
21
+ export declare const SCOPE_MISMATCH = "aggregate_scope_mismatch";
22
+ /** SegmentStream's answer: its status, its JSON body as sent, and that body parsed. */
23
+ export interface PlatformAnswer {
24
+ readonly status: number;
25
+ readonly text: string;
26
+ readonly body: Readonly<Record<string, unknown>>;
27
+ /** SegmentStream's `Retry-After`, on a 429 or a 503 that sent one. */
28
+ readonly retryAfter?: string;
29
+ }
30
+ /**
31
+ * Sends a checked aggregate to SegmentStream: model-level, or the per-entity shorthand of `entity`.
32
+ * `touched` is every entity the service required `<entity>:read` on.
33
+ */
34
+ export type AggregateTransport = (input: {
35
+ readonly entity?: string;
36
+ readonly body: unknown;
37
+ readonly requestId: string;
38
+ readonly touched: ReadonlyArray<string>;
39
+ }) => Effect.Effect<PlatformAnswer, AggregateUpstreamUnavailable>;
40
+ /**
41
+ * Where a deployment sends aggregates: its platform, its workspace and its app credential, with the
42
+ * hash of the manifest it serves.
43
+ */
44
+ export interface AggregateEnvironment {
45
+ readonly segmentstreamUrl: string;
46
+ readonly workspaceId: string;
47
+ readonly appCredential?: Redacted.Redacted<string>;
48
+ readonly manifestHash: string;
49
+ }
50
+ /**
51
+ * SegmentStream's `POST /api/v1/workspaces/{id}/aggregate`, and its per-entity
52
+ * `…/entities/{entity}/aggregate`, called with the workspace app credential. `undefined` when the deployment has none, or when SegmentStream's origin is plain
53
+ * http to another machine, which is logged once. Every request names the manifest it was checked
54
+ * against, and a redirect is refused, so the credential never leaves for another origin.
55
+ *
56
+ * Every answer but a 2xx is logged with both request ids, the status and the start of the body; a
57
+ * failed call with its message, stack and causes. The credential is redacted from all of them. A
58
+ * caller who goes away interrupts the call, which is not SegmentStream's failure.
59
+ */
60
+ export declare function platformAggregates(environment: AggregateEnvironment, fetchImpl?: typeof fetch): AggregateTransport | undefined;