@segmentstream-cloud/ontology 0.24.1 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/migrate/apply.js +34 -1
- package/dist/replication/baseline.d.ts +18 -4
- package/dist/replication/baseline.js +153 -62
- package/dist/replication/legacy-baseline.d.ts +27 -0
- package/dist/replication/legacy-baseline.js +106 -0
- package/dist/replication/legacy-store.d.ts +23 -0
- package/dist/replication/legacy-store.js +61 -0
- package/dist/replication/schema.d.ts +1 -1
- package/dist/replication/schema.js +51 -5
- package/dist/replication/store.d.ts +14 -11
- package/dist/replication/store.js +251 -62
- package/dist/replication/transaction.d.ts +4 -0
- package/dist/replication/transaction.js +23 -4
- package/dist/runtime/action-transactions.d.ts +35 -0
- package/dist/runtime/action-transactions.js +54 -0
- package/dist/runtime/pipeline.js +17 -3
- package/dist/runtime/service-binding.js +4 -1
- package/dist/runtime/sql-runtime.d.ts +2 -0
- package/dist/runtime/sql-runtime.js +4 -2
- package/dist/service/replication.d.ts +1 -1
- package/dist/service/replication.js +27 -16
- package/package.json +1 -1
package/dist/migrate/apply.js
CHANGED
|
@@ -4,10 +4,11 @@ import { CatalogInvalid, CatalogMismatch, MigrationFailed, MigrationRefused } fr
|
|
|
4
4
|
import { isNoopMigration } from "./model.js";
|
|
5
5
|
import { planMigration } from "./plan.js";
|
|
6
6
|
import { REPLICATION_DDL, REPLICATION_SCHEMA_VERSION, replicationEntityDdl, } from "../replication/schema.js";
|
|
7
|
+
import { lockReplicationLifecycle } from "../replication/transaction.js";
|
|
7
8
|
import { replicationMigrationRefusals } from "../replication/migrate.js";
|
|
8
9
|
import { ATTACHMENTS_TABLE } from "../storage/tables.js";
|
|
9
10
|
import { resolveReplicationSelection, } from "../replication/model.js";
|
|
10
|
-
import { ACTION_ATTRIBUTION_TABLE, AGENT_TABLES, API_KEYS_TABLE, CATALOG_TABLE, OAUTH_TABLES, USER_ROLES_TABLE, quoteIdentifier, quoteLiteral, } from "./storage.js";
|
|
11
|
+
import { ACTION_ATTRIBUTION_TABLE, AGENT_TABLES, API_KEYS_TABLE, CATALOG_TABLE, OAUTH_TABLES, USER_ROLES_TABLE, quoteIdentifier, quoteLiteral, historyTableName, } from "./storage.js";
|
|
11
12
|
const CATALOG = quoteIdentifier(CATALOG_TABLE);
|
|
12
13
|
/** Serializes migrations of one database; transaction-scoped, so it ends with the transaction. */
|
|
13
14
|
const MIGRATION_LOCK = "segmentstream.ontology.migration";
|
|
@@ -117,8 +118,10 @@ export const applyMigration = (plan, options) => Effect.gen(function* () {
|
|
|
117
118
|
MIGRATION_LOCK,
|
|
118
119
|
]);
|
|
119
120
|
yield* verifyCatalog(sql, plan, { forUpdate: true });
|
|
121
|
+
const previousManifest = yield* readCatalog({ sql });
|
|
120
122
|
let refresh;
|
|
121
123
|
if (presence?.present) {
|
|
124
|
+
yield* lockReplicationLifecycle(sql);
|
|
122
125
|
const [state] = yield* sql.unsafe("SELECT * FROM _ontology_replication WHERE id FOR UPDATE");
|
|
123
126
|
if (state?.enabled) {
|
|
124
127
|
refresh = yield* Effect.try({
|
|
@@ -132,6 +135,28 @@ export const applyMigration = (plan, options) => Effect.gen(function* () {
|
|
|
132
135
|
cause: { generation: state.active_generation },
|
|
133
136
|
});
|
|
134
137
|
}
|
|
138
|
+
const incompleteScan = target.baseline_complete === false
|
|
139
|
+
? "baseline"
|
|
140
|
+
: target.backfill_complete === false
|
|
141
|
+
? "backfill"
|
|
142
|
+
: undefined;
|
|
143
|
+
if (incompleteScan && target.manifest.hash !== plan.target.hash) {
|
|
144
|
+
return yield* new MigrationRefused({
|
|
145
|
+
message: `Finish the replication ${incompleteScan} before changing its schema, ` +
|
|
146
|
+
"or pause and rebuild replication.",
|
|
147
|
+
refusals: [
|
|
148
|
+
{
|
|
149
|
+
code: "replication-rebuild-required",
|
|
150
|
+
path: `replication.${incompleteScan}`,
|
|
151
|
+
message: "An unfinished replication scan must retain its manifest and keyset cursors.",
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
// A runtime-only upgrade preserves an explicit projection as well as exclusions.
|
|
157
|
+
if (target.manifest.hash === plan.target.hash) {
|
|
158
|
+
refresh = target.selection;
|
|
159
|
+
}
|
|
135
160
|
const refusals = replicationMigrationRefusals(target, plan.target, refresh);
|
|
136
161
|
if (refusals.length > 0) {
|
|
137
162
|
return yield* new MigrationRefused({
|
|
@@ -151,9 +176,17 @@ export const applyMigration = (plan, options) => Effect.gen(function* () {
|
|
|
151
176
|
for (const statement of [...REPLICATION_DDL, ...replicationEntityDdl(plan.target)]) {
|
|
152
177
|
yield* sql.unsafe(statement).unprepared;
|
|
153
178
|
}
|
|
179
|
+
// References survive entity/history table renames, including receipts for paused generations.
|
|
180
|
+
for (const entity of plan.target.entities) {
|
|
181
|
+
const previous = previousManifest?.entities.find((item) => item.id === entity.id);
|
|
182
|
+
if (previous && previous.name !== entity.name) {
|
|
183
|
+
yield* sql.unsafe("UPDATE _ontology_replication_delivery SET history_table = $2 WHERE history_table = $1", [historyTableName(previous.name), historyTableName(entity.name)]);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
154
186
|
yield* sql.unsafe("UPDATE _ontology_replication SET version = $1 WHERE id", [
|
|
155
187
|
REPLICATION_SCHEMA_VERSION,
|
|
156
188
|
]);
|
|
189
|
+
yield* sql.unsafe("ALTER TABLE _ontology_replication VALIDATE CONSTRAINT _ontology_replication_protocol_floor");
|
|
157
190
|
yield* sql.unsafe(`
|
|
158
191
|
INSERT INTO ${CATALOG} ("id", "version", "hash", "manifest")
|
|
159
192
|
VALUES (true, $1, $2, $3::jsonb)
|
|
@@ -8,20 +8,34 @@ export interface ReplicationTarget {
|
|
|
8
8
|
readonly from_revision: string;
|
|
9
9
|
readonly manifest: OntologyManifest;
|
|
10
10
|
readonly selection: ReadonlyArray<ReplicationSelection>;
|
|
11
|
+
readonly protocol?: number;
|
|
12
|
+
readonly baseline_complete?: boolean;
|
|
13
|
+
readonly backfill_complete?: boolean;
|
|
14
|
+
readonly initial_revision?: string | null;
|
|
15
|
+
readonly replay_complete?: boolean;
|
|
11
16
|
}
|
|
12
17
|
declare const ReplicationBaselineFailed_base: Schema.Class<ReplicationBaselineFailed, Schema.TaggedStruct<"ReplicationBaselineFailed", {
|
|
13
18
|
readonly message: Schema.String;
|
|
14
19
|
readonly cause: Schema.optionalKey<Schema.Unknown>;
|
|
15
20
|
}>, import("effect/Cause").YieldableError>;
|
|
16
|
-
/** An initial
|
|
21
|
+
/** An initial scan cannot be created with the requested state or record projection. */
|
|
17
22
|
export declare class ReplicationBaselineFailed extends ReplicationBaselineFailed_base {
|
|
18
23
|
}
|
|
19
|
-
|
|
20
|
-
export declare const createReplicationBaseline: (sql: SqlClient.SqlClient, input: {
|
|
24
|
+
interface BaselineInput {
|
|
21
25
|
readonly generation: string;
|
|
22
26
|
readonly manifest: OntologyManifest;
|
|
23
27
|
readonly exclusions?: ReadonlyArray<ReplicationExclusion>;
|
|
24
28
|
readonly selection: ReadonlyArray<ReplicationSelection>;
|
|
25
29
|
readonly newSource?: boolean;
|
|
26
|
-
}
|
|
30
|
+
}
|
|
31
|
+
/** Enable capture in a short fenced transaction before scanning any business records. */
|
|
32
|
+
export declare const prepareReplicationBaseline: (sql: SqlClient.SqlClient, input: BaselineInput) => Effect.Effect<ReplicationTarget, import("effect/unstable/sql/SqlError").SqlError | ReplicationBaselineFailed, never>;
|
|
33
|
+
/**
|
|
34
|
+
* Commit one keyset page and its cursor together. Every concurrent mutation has a captured
|
|
35
|
+
* revision above zero, so it supersedes even a later scanned baseline row. A failed page retries
|
|
36
|
+
* without changing earlier pages, and the capture stream covers inserts/deletes across pages.
|
|
37
|
+
*/
|
|
38
|
+
export declare const advanceReplicationBaseline: (sql: SqlClient.SqlClient, generation: string) => Effect.Effect<ReplicationTarget, import("effect/unstable/sql/SqlError").SqlError | ReplicationBaselineFailed, never>;
|
|
39
|
+
/** Prepare once, then advance a bounded page; subsequent calls resume the same durable target. */
|
|
40
|
+
export declare const createReplicationBaseline: (sql: SqlClient.SqlClient, input: BaselineInput) => Effect.Effect<ReplicationTarget, import("effect/unstable/sql/SqlError").SqlError | ReplicationBaselineFailed, never>;
|
|
27
41
|
export {};
|
|
@@ -3,35 +3,45 @@ import { Effect, Schema } from "effect";
|
|
|
3
3
|
import { joinColumnNames, joinTableName, quoteIdentifier as qi, quoteLiteral as ql, tableName, } from "../migrate/storage.js";
|
|
4
4
|
import { REPLICATION_RECORD_BYTES, validateReplicationSelection, } from "./model.js";
|
|
5
5
|
import { snapshotExpression } from "./schema.js";
|
|
6
|
-
|
|
6
|
+
import { lockReplicationLifecycle, shareReplicationLifecycle } from "./transaction.js";
|
|
7
|
+
/** An initial scan cannot be created with the requested state or record projection. */
|
|
7
8
|
export class ReplicationBaselineFailed extends Schema.TaggedError()("ReplicationBaselineFailed", { message: Schema.String, cause: Schema.optionalKey(Schema.Unknown) }) {
|
|
8
9
|
}
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
const targetSql = `SELECT generation::text, source_id::text, from_revision::text, manifest,
|
|
11
|
+
selection, protocol, baseline_complete, backfill_complete, initial_revision::text,
|
|
12
|
+
replay_complete FROM _ontology_replication_targets WHERE generation = $1::uuid`;
|
|
13
|
+
/** Enable capture in a short fenced transaction before scanning any business records. */
|
|
14
|
+
export const prepareReplicationBaseline = (sql, input) => sql.withTransaction(Effect.gen(function* () {
|
|
11
15
|
yield* sql.unsafe("SET LOCAL statement_timeout = '30s'");
|
|
12
16
|
yield* sql.unsafe("SET LOCAL lock_timeout = '5s'");
|
|
13
|
-
|
|
14
|
-
|
|
17
|
+
yield* lockReplicationLifecycle(sql);
|
|
18
|
+
const [state] = yield* sql.unsafe("SELECT source_id::text, revision::text, enabled, version FROM _ontology_replication WHERE id");
|
|
19
|
+
if (!state || state.version < 4) {
|
|
15
20
|
return yield* new ReplicationBaselineFailed({
|
|
16
21
|
message: "Run the ontology SDK migration before enabling replication.",
|
|
17
22
|
});
|
|
18
23
|
}
|
|
19
|
-
const existing = yield* sql.unsafe(
|
|
20
|
-
if (existing
|
|
21
|
-
return existing
|
|
24
|
+
const [existing] = yield* sql.unsafe(targetSql, [input.generation]);
|
|
25
|
+
if (existing) {
|
|
26
|
+
return existing;
|
|
27
|
+
}
|
|
28
|
+
const [catalog] = yield* sql.unsafe("SELECT hash FROM _ontology WHERE id");
|
|
29
|
+
if (catalog?.hash !== input.manifest.hash) {
|
|
30
|
+
return yield* new ReplicationBaselineFailed({
|
|
31
|
+
message: "The ontology changed before replication started; retry with the current manifest.",
|
|
32
|
+
});
|
|
22
33
|
}
|
|
23
34
|
if (state.enabled) {
|
|
24
35
|
return yield* new ReplicationBaselineFailed({
|
|
25
36
|
message: "Pause replication before starting a new generation.",
|
|
26
37
|
});
|
|
27
38
|
}
|
|
28
|
-
const manifest = input.manifest;
|
|
29
39
|
yield* Effect.try({
|
|
30
|
-
try: () => validateReplicationSelection(manifest, input.selection),
|
|
40
|
+
try: () => validateReplicationSelection(input.manifest, input.selection),
|
|
31
41
|
catch: (cause) => new ReplicationBaselineFailed({ message: "Invalid replication projection.", cause }),
|
|
32
42
|
});
|
|
33
|
-
// Drain
|
|
34
|
-
const tables = manifest.entities
|
|
43
|
+
// Drain raw/pre-upgrade writers only at enablement; online pages never lock business tables.
|
|
44
|
+
const tables = input.manifest.entities
|
|
35
45
|
.filter((e) => e.source === undefined && !e.builtIn)
|
|
36
46
|
.flatMap((e) => [
|
|
37
47
|
tableName(e.name),
|
|
@@ -43,64 +53,145 @@ export const createReplicationBaseline = (sql, input) => sql.withTransaction(Eff
|
|
|
43
53
|
yield* sql.unsafe(`LOCK TABLE ${tables.map(qi).join(", ")} IN SHARE MODE`);
|
|
44
54
|
}
|
|
45
55
|
const source = input.newSource ? randomUUID() : state.source_id;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
56
|
+
yield* sql.unsafe(`INSERT INTO _ontology_replication_targets
|
|
57
|
+
(generation, source_id, from_revision, manifest, selection, protocol, baseline_complete)
|
|
58
|
+
VALUES ($1::uuid, $2::uuid, 0, $3::jsonb, $4::jsonb, 4, false)`, [input.generation, source, JSON.stringify(input.manifest), JSON.stringify(input.selection)]);
|
|
59
|
+
yield* sql.unsafe(`UPDATE _ontology_replication SET source_id = $1::uuid, selection = $2::jsonb,
|
|
60
|
+
enabled = true, exclusions = $3::jsonb, active_generation = $4::uuid WHERE id`, [
|
|
49
61
|
source,
|
|
50
|
-
JSON.stringify(selection),
|
|
62
|
+
JSON.stringify(Object.fromEntries(input.selection.map((s) => [s.entityId, s]))),
|
|
51
63
|
JSON.stringify(input.exclusions ?? []),
|
|
52
64
|
input.generation,
|
|
53
65
|
]);
|
|
54
|
-
const target =
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
66
|
+
const [target] = yield* sql.unsafe(targetSql, [input.generation]);
|
|
67
|
+
return target;
|
|
68
|
+
}));
|
|
69
|
+
const BASELINE_PAGE_SIZE = 250;
|
|
70
|
+
function baselineSources(target) {
|
|
71
|
+
return target.selection.flatMap((item) => {
|
|
72
|
+
const entity = target.manifest.entities.find((e) => e.id === item.entityId);
|
|
73
|
+
return [
|
|
74
|
+
{
|
|
75
|
+
entityId: entity.id,
|
|
76
|
+
kind: "entity",
|
|
77
|
+
records: `SELECT r.id::text AS record_key,
|
|
78
|
+
(SELECT jsonb_object_agg(key, value) FROM jsonb_each(${snapshotExpression(entity, "r")})
|
|
79
|
+
WHERE key = 'id' OR ${ql(JSON.stringify(item.fields))}::jsonb ? key) AS payload
|
|
80
|
+
FROM ${qi(tableName(entity.name))} r
|
|
81
|
+
WHERE r.id >= COALESCE(NULLIF($2::text, '')::uuid, '00000000-0000-0000-0000-000000000000'::uuid)
|
|
82
|
+
AND ($2::text = '' OR r.id > NULLIF($2::text, '')::uuid)
|
|
83
|
+
ORDER BY r.id LIMIT ${BASELINE_PAGE_SIZE}`,
|
|
84
|
+
},
|
|
85
|
+
...entity.relationships
|
|
86
|
+
.filter((r) => item.relationships?.includes(r.name))
|
|
87
|
+
.map((relationship) => {
|
|
88
|
+
const columns = joinColumnNames({
|
|
89
|
+
entity: entity.name,
|
|
90
|
+
relationship: relationship.name,
|
|
91
|
+
target: relationship.to,
|
|
92
|
+
});
|
|
93
|
+
return {
|
|
94
|
+
entityId: entity.id,
|
|
95
|
+
kind: `edge:${relationship.name}`,
|
|
96
|
+
records: `SELECT ${qi(columns.source)}::text || ':' || ${qi(columns.target)}::text AS record_key,
|
|
97
|
+
jsonb_build_object('source_id', ${qi(columns.source)}::text,
|
|
98
|
+
'target_id', ${qi(columns.target)}::text) AS payload
|
|
99
|
+
FROM ${qi(joinTableName(entity.name, relationship.name))}
|
|
100
|
+
WHERE (${qi(columns.source)}, ${qi(columns.target)}) >= (
|
|
101
|
+
COALESCE(NULLIF(split_part($2::text, ':', 1), '')::uuid, '00000000-0000-0000-0000-000000000000'::uuid),
|
|
102
|
+
COALESCE(NULLIF(split_part($2::text, ':', 2), '')::uuid, '00000000-0000-0000-0000-000000000000'::uuid))
|
|
103
|
+
AND ($2::text = '' OR (${qi(columns.source)}, ${qi(columns.target)}) > (
|
|
104
|
+
NULLIF(split_part($2::text, ':', 1), '')::uuid, NULLIF(split_part($2::text, ':', 2), '')::uuid))
|
|
105
|
+
ORDER BY ${qi(columns.source)}, ${qi(columns.target)} LIMIT ${BASELINE_PAGE_SIZE}`,
|
|
106
|
+
};
|
|
107
|
+
}),
|
|
108
|
+
];
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Commit one keyset page and its cursor together. Every concurrent mutation has a captured
|
|
113
|
+
* revision above zero, so it supersedes even a later scanned baseline row. A failed page retries
|
|
114
|
+
* without changing earlier pages, and the capture stream covers inserts/deletes across pages.
|
|
115
|
+
*/
|
|
116
|
+
export const advanceReplicationBaseline = (sql, generation) => Effect.gen(function* () {
|
|
117
|
+
const progress = yield* sql.withTransaction(Effect.gen(function* () {
|
|
118
|
+
yield* sql.unsafe("SET LOCAL statement_timeout = '30s'");
|
|
119
|
+
yield* sql.unsafe("SET LOCAL lock_timeout = '5s'");
|
|
120
|
+
yield* shareReplicationLifecycle(sql);
|
|
121
|
+
const [target] = yield* sql.unsafe(`${targetSql.replace("replay_complete FROM", "replay_complete, baseline_cursor FROM")} FOR NO KEY UPDATE`, [generation]);
|
|
122
|
+
if (!target) {
|
|
123
|
+
return yield* new ReplicationBaselineFailed({
|
|
124
|
+
message: "The replication target is missing.",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (target.baseline_complete !== false) {
|
|
128
|
+
return { target, finish: false };
|
|
129
|
+
}
|
|
130
|
+
const [state] = yield* sql.unsafe("SELECT enabled, active_generation::text FROM _ontology_replication WHERE id");
|
|
131
|
+
if (!state?.enabled || state.active_generation !== generation) {
|
|
132
|
+
return yield* new ReplicationBaselineFailed({
|
|
133
|
+
message: "Resume the active replication generation before scanning its baseline.",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const index = target.baseline_cursor.index ?? 0;
|
|
137
|
+
const source = baselineSources(target)[index];
|
|
138
|
+
if (!source) {
|
|
139
|
+
return { target, finish: true };
|
|
140
|
+
}
|
|
141
|
+
// Materialize once in PostgreSQL; only the small progress result crosses the transport.
|
|
142
|
+
const [page] = yield* sql.unsafe(`WITH page AS MATERIALIZED (
|
|
143
|
+
SELECT * FROM (${source.records}) records
|
|
144
|
+
), valid AS (
|
|
145
|
+
SELECT NOT EXISTS(SELECT 1 FROM page WHERE octet_length(payload::text) > ${REPLICATION_RECORD_BYTES}) AS ok
|
|
146
|
+
), inserted AS (
|
|
147
|
+
INSERT INTO _ontology_replication_baselines (generation, event)
|
|
148
|
+
SELECT $1::uuid, jsonb_build_object('source_id', $3::text, 'entity_id', $4::text,
|
|
149
|
+
'record_key', record_key, 'record_kind', $5::text, 'revision', '0',
|
|
150
|
+
'deleted', false, 'schema_hash', $6::text, 'payload', payload::text)
|
|
151
|
+
FROM page WHERE (SELECT ok FROM valid) RETURNING id
|
|
152
|
+
), queued AS (
|
|
153
|
+
INSERT INTO _ontology_replication_delivery (generation, event_id, revision, is_baseline)
|
|
154
|
+
SELECT $1::uuid, id, 0, true FROM inserted RETURNING event_id
|
|
155
|
+
) SELECT (SELECT count(*)::integer FROM queued) AS count,
|
|
156
|
+
(SELECT max(record_key) FROM page) AS key, NOT (SELECT ok FROM valid) AS oversized`, [
|
|
157
|
+
generation,
|
|
158
|
+
target.baseline_cursor.key ?? "",
|
|
159
|
+
target.source_id,
|
|
160
|
+
source.entityId,
|
|
161
|
+
source.kind,
|
|
162
|
+
target.manifest.hash,
|
|
163
|
+
]);
|
|
164
|
+
if (page.oversized) {
|
|
76
165
|
return yield* new ReplicationBaselineFailed({
|
|
77
166
|
message: `A projected baseline record exceeds ${REPLICATION_RECORD_BYTES} bytes; ` +
|
|
78
167
|
"exclude large fields before enabling replication.",
|
|
79
168
|
});
|
|
80
169
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
relationship: relationship.name,
|
|
97
|
-
target: relationship.to,
|
|
170
|
+
const cursor = page.count < BASELINE_PAGE_SIZE ? { index: index + 1 } : { index, key: page.key };
|
|
171
|
+
yield* sql.unsafe("UPDATE _ontology_replication_targets SET baseline_cursor = $2::jsonb WHERE generation = $1::uuid", [generation, JSON.stringify(cursor)]);
|
|
172
|
+
return { target, finish: false };
|
|
173
|
+
}));
|
|
174
|
+
if (!progress.finish) {
|
|
175
|
+
return progress.target;
|
|
176
|
+
}
|
|
177
|
+
// Do not upgrade a shared lock: take the same legacy-row/exclusive-lock order as pause/migrate.
|
|
178
|
+
return yield* sql.withTransaction(Effect.gen(function* () {
|
|
179
|
+
yield* sql.unsafe("SET LOCAL lock_timeout = '5s'");
|
|
180
|
+
yield* lockReplicationLifecycle(sql);
|
|
181
|
+
const [state] = yield* sql.unsafe("SELECT enabled, active_generation::text FROM _ontology_replication WHERE id");
|
|
182
|
+
if (!state?.enabled || state.active_generation !== generation) {
|
|
183
|
+
return yield* new ReplicationBaselineFailed({
|
|
184
|
+
message: "Resume the active replication generation before completing its baseline.",
|
|
98
185
|
});
|
|
99
|
-
yield* baseline(entity.id, `edge:${relationship.name}`, `SELECT ${qi(columns.source)}::text || ':' || ${qi(columns.target)}::text AS record_key,
|
|
100
|
-
jsonb_build_object('source_id', ${qi(columns.source)}::text,
|
|
101
|
-
'target_id', ${qi(columns.target)}::text) AS payload
|
|
102
|
-
FROM ${qi(joinTableName(entity.name, relationship.name))}`);
|
|
103
186
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
187
|
+
yield* sql.unsafe(`UPDATE _ontology_replication_targets SET baseline_complete = true,
|
|
188
|
+
initial_revision = (SELECT CASE WHEN is_called THEN last_value ELSE 0 END FROM _ontology_replication_revision)
|
|
189
|
+
WHERE generation = $1::uuid AND NOT baseline_complete`, [generation]);
|
|
190
|
+
const [target] = yield* sql.unsafe(targetSql, [generation]);
|
|
191
|
+
return target;
|
|
192
|
+
}));
|
|
193
|
+
});
|
|
194
|
+
/** Prepare once, then advance a bounded page; subsequent calls resume the same durable target. */
|
|
195
|
+
export const createReplicationBaseline = (sql, input) => prepareReplicationBaseline(sql, input).pipe(Effect.flatMap((target) => target.baseline_complete === false
|
|
196
|
+
? advanceReplicationBaseline(sql, target.generation)
|
|
197
|
+
: Effect.succeed(target)));
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Effect, Schema } 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
|
+
declare const ReplicationBaselineFailed_base: Schema.Class<ReplicationBaselineFailed, Schema.TaggedStruct<"ReplicationBaselineFailed", {
|
|
13
|
+
readonly message: Schema.String;
|
|
14
|
+
readonly cause: Schema.optionalKey<Schema.Unknown>;
|
|
15
|
+
}>, import("effect/Cause").YieldableError>;
|
|
16
|
+
/** An initial snapshot cannot be created with the requested state or record projection. */
|
|
17
|
+
export declare class ReplicationBaselineFailed extends ReplicationBaselineFailed_base {
|
|
18
|
+
}
|
|
19
|
+
/** All baseline rows are committed with capture enablement; interrupted setup can safely resume. */
|
|
20
|
+
export declare const createReplicationBaseline: (sql: SqlClient.SqlClient, input: {
|
|
21
|
+
readonly generation: string;
|
|
22
|
+
readonly manifest: OntologyManifest;
|
|
23
|
+
readonly exclusions?: ReadonlyArray<ReplicationExclusion>;
|
|
24
|
+
readonly selection: ReadonlyArray<ReplicationSelection>;
|
|
25
|
+
readonly newSource?: boolean;
|
|
26
|
+
}) => Effect.Effect<ReplicationTarget, import("effect/unstable/sql/SqlError").SqlError | ReplicationBaselineFailed, never>;
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Effect, Schema } 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
|
+
/** An initial snapshot cannot be created with the requested state or record projection. */
|
|
7
|
+
export class ReplicationBaselineFailed extends Schema.TaggedError()("ReplicationBaselineFailed", { message: Schema.String, cause: Schema.optionalKey(Schema.Unknown) }) {
|
|
8
|
+
}
|
|
9
|
+
/** All baseline rows are committed with capture enablement; interrupted setup can safely resume. */
|
|
10
|
+
export const createReplicationBaseline = (sql, input) => sql.withTransaction(Effect.gen(function* () {
|
|
11
|
+
yield* sql.unsafe("SET LOCAL statement_timeout = '30s'");
|
|
12
|
+
yield* sql.unsafe("SET LOCAL lock_timeout = '5s'");
|
|
13
|
+
const [state] = yield* sql.unsafe("SELECT source_id::text, revision::text, enabled FROM _ontology_replication WHERE id FOR UPDATE");
|
|
14
|
+
if (!state) {
|
|
15
|
+
return yield* new ReplicationBaselineFailed({
|
|
16
|
+
message: "Run the ontology SDK migration before enabling replication.",
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
const existing = yield* sql.unsafe("SELECT * FROM _ontology_replication_targets WHERE generation = $1::uuid", [input.generation]);
|
|
20
|
+
if (existing[0]) {
|
|
21
|
+
return existing[0];
|
|
22
|
+
}
|
|
23
|
+
if (state.enabled) {
|
|
24
|
+
return yield* new ReplicationBaselineFailed({
|
|
25
|
+
message: "Pause replication before starting a new generation.",
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const manifest = input.manifest;
|
|
29
|
+
yield* Effect.try({
|
|
30
|
+
try: () => validateReplicationSelection(manifest, input.selection),
|
|
31
|
+
catch: (cause) => new ReplicationBaselineFailed({ message: "Invalid replication projection.", cause }),
|
|
32
|
+
});
|
|
33
|
+
// Drain older SDK transactions that started before the compatibility fence was enabled.
|
|
34
|
+
const tables = manifest.entities
|
|
35
|
+
.filter((e) => e.source === undefined && !e.builtIn)
|
|
36
|
+
.flatMap((e) => [
|
|
37
|
+
tableName(e.name),
|
|
38
|
+
...e.relationships
|
|
39
|
+
.filter((r) => r.cardinality === "many")
|
|
40
|
+
.map((r) => joinTableName(e.name, r.name)),
|
|
41
|
+
]);
|
|
42
|
+
if (tables.length > 0) {
|
|
43
|
+
yield* sql.unsafe(`LOCK TABLE ${tables.map(qi).join(", ")} IN SHARE MODE`);
|
|
44
|
+
}
|
|
45
|
+
const source = input.newSource ? randomUUID() : state.source_id;
|
|
46
|
+
const selection = Object.fromEntries(input.selection.map((s) => [s.entityId, s]));
|
|
47
|
+
yield* sql.unsafe(`UPDATE _ontology_replication SET source_id = $1::uuid, selection = $2::jsonb, enabled = true,
|
|
48
|
+
exclusions = $3::jsonb, active_generation = $4::uuid WHERE id`, [
|
|
49
|
+
source,
|
|
50
|
+
JSON.stringify(selection),
|
|
51
|
+
JSON.stringify(input.exclusions ?? []),
|
|
52
|
+
input.generation,
|
|
53
|
+
]);
|
|
54
|
+
const target = {
|
|
55
|
+
generation: input.generation,
|
|
56
|
+
source_id: source,
|
|
57
|
+
from_revision: state.revision,
|
|
58
|
+
manifest,
|
|
59
|
+
selection: input.selection,
|
|
60
|
+
};
|
|
61
|
+
yield* sql.unsafe(`INSERT INTO _ontology_replication_targets (generation, source_id, from_revision, manifest, selection)
|
|
62
|
+
VALUES ($1::uuid, $2::uuid, $3::bigint, $4::jsonb, $5::jsonb)`, [
|
|
63
|
+
target.generation,
|
|
64
|
+
source,
|
|
65
|
+
state.revision,
|
|
66
|
+
JSON.stringify(manifest),
|
|
67
|
+
JSON.stringify(input.selection),
|
|
68
|
+
]);
|
|
69
|
+
const baseline = (entityId, kind, records) => Effect.gen(function* () {
|
|
70
|
+
// Keep the snapshot in PostgreSQL; large baselines must not fill a serverless process's heap.
|
|
71
|
+
const [size] = yield* sql.unsafe(`SELECT EXISTS (
|
|
72
|
+
SELECT 1 FROM (${records}) snapshot
|
|
73
|
+
WHERE octet_length(payload::text) > ${REPLICATION_RECORD_BYTES}
|
|
74
|
+
) AS oversized`);
|
|
75
|
+
if (size.oversized) {
|
|
76
|
+
return yield* new ReplicationBaselineFailed({
|
|
77
|
+
message: `A projected baseline record exceeds ${REPLICATION_RECORD_BYTES} bytes; ` +
|
|
78
|
+
"exclude large fields before enabling replication.",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
yield* sql.unsafe(`INSERT INTO _ontology_replication_baselines (generation, event)
|
|
82
|
+
SELECT $1::uuid, jsonb_build_object('source_id', $2::text, 'entity_id', $3::text,
|
|
83
|
+
'record_key', record_key, 'record_kind', $4::text, 'revision', $5::text,
|
|
84
|
+
'deleted', false, 'schema_hash', $6::text, 'payload', payload::text)
|
|
85
|
+
FROM (${records}) snapshot`, [target.generation, source, entityId, kind, state.revision, manifest.hash]);
|
|
86
|
+
});
|
|
87
|
+
for (const item of input.selection) {
|
|
88
|
+
const entity = manifest.entities.find((e) => e.id === item.entityId);
|
|
89
|
+
yield* baseline(entity.id, "entity", `SELECT r.id::text AS record_key,
|
|
90
|
+
(SELECT jsonb_object_agg(key, value) FROM jsonb_each(${snapshotExpression(entity, "r")})
|
|
91
|
+
WHERE key = 'id' OR ${ql(JSON.stringify(item.fields))}::jsonb ? key) AS payload
|
|
92
|
+
FROM ${qi(tableName(entity.name))} r`);
|
|
93
|
+
for (const relationship of entity.relationships.filter((r) => item.relationships?.includes(r.name))) {
|
|
94
|
+
const columns = joinColumnNames({
|
|
95
|
+
entity: entity.name,
|
|
96
|
+
relationship: relationship.name,
|
|
97
|
+
target: relationship.to,
|
|
98
|
+
});
|
|
99
|
+
yield* baseline(entity.id, `edge:${relationship.name}`, `SELECT ${qi(columns.source)}::text || ':' || ${qi(columns.target)}::text AS record_key,
|
|
100
|
+
jsonb_build_object('source_id', ${qi(columns.source)}::text,
|
|
101
|
+
'target_id', ${qi(columns.target)}::text) AS payload
|
|
102
|
+
FROM ${qi(joinTableName(entity.name, relationship.name))}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return target;
|
|
106
|
+
}));
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import type { SqlClient } from "effect/unstable/sql";
|
|
3
|
+
import { type ReplicationEvent } from "./model.js";
|
|
4
|
+
import type { ReplicationTarget } from "./baseline.js";
|
|
5
|
+
/** No timestamp/high-water cursor: every unacknowledged immutable event remains discoverable. */
|
|
6
|
+
export declare function replicationEventsSql(target: ReplicationTarget): string;
|
|
7
|
+
export declare const pendingReplicationEvents: (sql: SqlClient.SqlClient, target: ReplicationTarget) => Effect.Effect<{
|
|
8
|
+
readonly historyId: string;
|
|
9
|
+
readonly event: ReplicationEvent;
|
|
10
|
+
}[], import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
11
|
+
/** Call only after Pub/Sub accepts the complete batch. Lost responses deliberately produce duplicates. */
|
|
12
|
+
export declare const acknowledgeReplication: (sql: SqlClient.SqlClient, generation: string, ids: ReadonlyArray<string>) => import("effect/unstable/sql/Statement").Statement<object>;
|
|
13
|
+
export declare const replayReplication: (sql: SqlClient.SqlClient, generation: string) => import("effect/unstable/sql/Statement").Statement<object>;
|
|
14
|
+
export declare const replicationStatus: (sql: SqlClient.SqlClient, target: ReplicationTarget) => Effect.Effect<{
|
|
15
|
+
total: string;
|
|
16
|
+
accepted: string;
|
|
17
|
+
verified: string;
|
|
18
|
+
}, import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
19
|
+
export declare const unverifiedReplicationEvents: (sql: SqlClient.SqlClient, target: ReplicationTarget) => Effect.Effect<{
|
|
20
|
+
historyId: string;
|
|
21
|
+
eventId: string;
|
|
22
|
+
}[], import("effect/unstable/sql/SqlError").SqlError, never>;
|
|
23
|
+
export declare const verifyReplicationEvents: (sql: SqlClient.SqlClient, generation: string, ids: ReadonlyArray<string>) => import("effect/unstable/sql/Statement").Statement<object>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { historyTableName, quoteIdentifier as qi } from "../migrate/storage.js";
|
|
3
|
+
import { REPLICATION_BATCH_BYTES, REPLICATION_BATCH_SIZE, publishMessage, replicationEventId, } from "./model.js";
|
|
4
|
+
/** No timestamp/high-water cursor: every unacknowledged immutable event remains discoverable. */
|
|
5
|
+
export function replicationEventsSql(target) {
|
|
6
|
+
const histories = target.selection.map((item) => {
|
|
7
|
+
const entity = target.manifest.entities.find((e) => e.id === item.entityId);
|
|
8
|
+
return `SELECT id, replication AS event FROM ${qi(historyTableName(entity.name))}
|
|
9
|
+
WHERE replication IS NOT NULL AND replication->>'source_id' = $2
|
|
10
|
+
AND (replication->>'revision')::bigint > $3::bigint`;
|
|
11
|
+
});
|
|
12
|
+
const baseline = `SELECT id, event FROM _ontology_replication_baselines WHERE generation = $1::uuid
|
|
13
|
+
AND $2::text IS NOT NULL AND $3::bigint IS NOT NULL`;
|
|
14
|
+
return [baseline, ...histories].join(" UNION ALL ");
|
|
15
|
+
}
|
|
16
|
+
export const pendingReplicationEvents = (sql, target) => sql
|
|
17
|
+
.unsafe(`SELECT e.id::text, e.event FROM (${replicationEventsSql(target)}) e
|
|
18
|
+
WHERE NOT EXISTS (SELECT 1 FROM _ontology_replication_receipts r
|
|
19
|
+
WHERE r.generation = $1::uuid AND r.event_id = e.id)
|
|
20
|
+
ORDER BY (event->>'revision')::bigint, id LIMIT ${REPLICATION_BATCH_SIZE}`, [target.generation, target.source_id, target.from_revision])
|
|
21
|
+
.pipe(Effect.map((rows) => {
|
|
22
|
+
let size = 32;
|
|
23
|
+
const result = [];
|
|
24
|
+
for (const row of rows) {
|
|
25
|
+
const event = {
|
|
26
|
+
...row.event,
|
|
27
|
+
generation: target.generation,
|
|
28
|
+
event_id: replicationEventId(target.source_id, row.id, target.generation),
|
|
29
|
+
};
|
|
30
|
+
const bytes = Buffer.byteLength(JSON.stringify(publishMessage(event))) + 1;
|
|
31
|
+
if (bytes + 32 > REPLICATION_BATCH_BYTES) {
|
|
32
|
+
throw new Error("A replication event exceeds the publish request limit.");
|
|
33
|
+
}
|
|
34
|
+
if (size + bytes > REPLICATION_BATCH_BYTES) {
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
size += bytes;
|
|
38
|
+
result.push({ historyId: row.id, event });
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}));
|
|
42
|
+
/** Call only after Pub/Sub accepts the complete batch. Lost responses deliberately produce duplicates. */
|
|
43
|
+
export const acknowledgeReplication = (sql, generation, ids) => sql.unsafe(`INSERT INTO _ontology_replication_receipts (generation, event_id)
|
|
44
|
+
SELECT $1::uuid, value::uuid FROM jsonb_array_elements_text($2::jsonb) ON CONFLICT DO NOTHING`, [generation, JSON.stringify(ids)]);
|
|
45
|
+
export const replayReplication = (sql, generation) => sql.unsafe("DELETE FROM _ontology_replication_receipts WHERE generation = $1::uuid", [
|
|
46
|
+
generation,
|
|
47
|
+
]);
|
|
48
|
+
export const replicationStatus = (sql, target) => sql
|
|
49
|
+
.unsafe(`SELECT count(*)::text AS total, count(r.event_id)::text AS accepted, count(r.verified_at)::text AS verified
|
|
50
|
+
FROM (${replicationEventsSql(target)}) e
|
|
51
|
+
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])
|
|
52
|
+
.pipe(Effect.map((rows) => rows[0]));
|
|
53
|
+
export const unverifiedReplicationEvents = (sql, target) => sql
|
|
54
|
+
.unsafe(`SELECT event_id::text AS id FROM _ontology_replication_receipts
|
|
55
|
+
WHERE generation = $1::uuid AND verified_at IS NULL ORDER BY accepted_at, event_id LIMIT 1000`, [target.generation])
|
|
56
|
+
.pipe(Effect.map((rows) => rows.map(({ id }) => ({
|
|
57
|
+
historyId: id,
|
|
58
|
+
eventId: replicationEventId(target.source_id, id, target.generation),
|
|
59
|
+
}))));
|
|
60
|
+
export const verifyReplicationEvents = (sql, generation, ids) => sql.unsafe(`UPDATE _ontology_replication_receipts SET verified_at = now()
|
|
61
|
+
WHERE generation = $1::uuid AND event_id IN (SELECT value::uuid FROM jsonb_array_elements_text($2::jsonb))`, [generation, JSON.stringify(ids)]);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { EntityManifest, OntologyManifest } from "../manifest.js";
|
|
2
|
-
export declare const REPLICATION_SCHEMA_VERSION =
|
|
2
|
+
export declare const REPLICATION_SCHEMA_VERSION = 4;
|
|
3
3
|
/** Installed only by the SDK migration runner, never at service startup. */
|
|
4
4
|
export declare const REPLICATION_DDL: string[];
|
|
5
5
|
export declare function snapshotExpression(entity: EntityManifest, alias: string): string;
|