@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.
- package/README.md +51 -15
- package/dist/aggregate-routes.d.ts +95 -0
- package/dist/aggregate-routes.js +332 -0
- package/dist/analyze.js +83 -16
- package/dist/entity.d.ts +31 -15
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/manifest.d.ts +57 -23
- package/dist/manifest.js +77 -30
- package/dist/measure.d.ts +11 -2
- package/dist/measure.js +14 -1
- package/dist/migrate/apply.d.ts +1 -1
- package/dist/migrate/apply.js +36 -3
- package/dist/migrate/plan.d.ts +1 -1
- package/dist/migrate/plan.js +38 -13
- package/dist/migrate/storage.d.ts +3 -2
- package/dist/migrate/storage.js +15 -5
- package/dist/names.d.ts +4 -2
- package/dist/names.js +4 -1
- package/dist/query.d.ts +5 -2
- package/dist/query.js +6 -2
- package/dist/relationship.d.ts +36 -5
- package/dist/relationship.js +5 -3
- package/dist/render.d.ts +47 -3
- package/dist/render.js +117 -11
- package/dist/replication/baseline.d.ts +19 -0
- package/dist/replication/baseline.js +97 -0
- package/dist/replication/bigquery.d.ts +23 -0
- package/dist/replication/bigquery.js +63 -0
- package/dist/replication/model.d.ts +38 -0
- package/dist/replication/model.js +99 -0
- package/dist/replication/schema.d.ts +7 -0
- package/dist/replication/schema.js +123 -0
- package/dist/replication/store.d.ts +35 -0
- package/dist/replication/store.js +88 -0
- package/dist/replication/transaction.d.ts +4 -0
- package/dist/replication/transaction.js +11 -0
- package/dist/runtime/pipeline.js +2 -1
- package/dist/runtime/principals.js +6 -0
- package/dist/runtime/service-binding.js +8 -0
- package/dist/runtime/sql-runtime.js +2 -1
- package/dist/service/aggregate-platform.d.ts +60 -0
- package/dist/service/aggregate-platform.js +189 -0
- package/dist/service/aggregate.d.ts +42 -0
- package/dist/service/aggregate.js +145 -0
- package/dist/service/api.d.ts +5 -3
- package/dist/service/api.js +52 -6
- package/dist/service/catalog.d.ts +10 -1
- package/dist/service/catalog.js +12 -3
- package/dist/service/config.d.ts +13 -1
- package/dist/service/config.js +11 -1
- package/dist/service/errors.d.ts +32 -0
- package/dist/service/errors.js +102 -0
- package/dist/service/handlers.d.ts +3 -1
- package/dist/service/handlers.js +67 -29
- package/dist/service/mcp/server.d.ts +5 -0
- package/dist/service/mcp/server.js +61 -9
- package/dist/service/mcp/tools.d.ts +13 -2
- package/dist/service/mcp/tools.js +165 -6
- package/dist/service/operations.d.ts +2 -1
- package/dist/service/operations.js +16 -6
- package/dist/service/plan.js +7 -1
- package/dist/service/replication.d.ts +5 -0
- package/dist/service/replication.js +25 -0
- package/dist/service/runtime-contract.d.ts +1 -0
- package/dist/service/schemas.d.ts +99 -1
- package/dist/service/schemas.js +150 -2
- package/dist/service/service.js +10 -2
- package/dist/user.d.ts +1 -1
- package/dist/warehouse-backed.js +222 -121
- 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
|
+
});
|
package/dist/runtime/pipeline.js
CHANGED
|
@@ -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;
|