@sekiban/dcb-runtime 0.2.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/LICENSE +86 -0
- package/README.md +13 -0
- package/dist/allocator/AllocatorDurableObject.d.ts +36 -0
- package/dist/allocator/AllocatorDurableObject.js +658 -0
- package/dist/allocator/IssuanceLedger.d.ts +188 -0
- package/dist/allocator/IssuanceLedger.js +608 -0
- package/dist/allocator/IssuanceReconciler.d.ts +41 -0
- package/dist/allocator/IssuanceReconciler.js +191 -0
- package/dist/allocator/OrderClock.d.ts +41 -0
- package/dist/allocator/OrderClock.js +92 -0
- package/dist/allocator/SortableUniqueId.d.ts +46 -0
- package/dist/allocator/SortableUniqueId.js +156 -0
- package/dist/allocator/types.d.ts +52 -0
- package/dist/allocator/types.js +1 -0
- package/dist/bootstrap/BootstrapCoordinatorDurableObject.d.ts +28 -0
- package/dist/bootstrap/BootstrapCoordinatorDurableObject.js +357 -0
- package/dist/bootstrap/BootstrapStoreAdapter.d.ts +51 -0
- package/dist/bootstrap/BootstrapStoreAdapter.js +162 -0
- package/dist/bootstrap/OperatorBootstrap.d.ts +20 -0
- package/dist/bootstrap/OperatorBootstrap.js +104 -0
- package/dist/bootstrap/manifest.d.ts +9 -0
- package/dist/bootstrap/manifest.js +115 -0
- package/dist/bootstrap/types.d.ts +75 -0
- package/dist/bootstrap/types.js +1 -0
- package/dist/cli/OperatorRepairCli.d.ts +11 -0
- package/dist/cli/OperatorRepairCli.js +108 -0
- package/dist/cloudflare.d.ts +164 -0
- package/dist/cloudflare.js +19487 -0
- package/dist/commit/CommitWorker.d.ts +101 -0
- package/dist/commit/CommitWorker.js +921 -0
- package/dist/commit/types.d.ts +25 -0
- package/dist/commit/types.js +13 -0
- package/dist/completeness/GlobalCompletenessReconciler.d.ts +77 -0
- package/dist/completeness/GlobalCompletenessReconciler.js +572 -0
- package/dist/completeness/types.d.ts +85 -0
- package/dist/completeness/types.js +6 -0
- package/dist/composition.d.ts +123 -0
- package/dist/composition.js +237 -0
- package/dist/cosmos.d.ts +11 -0
- package/dist/cosmos.js +1411 -0
- package/dist/d1-mv.d.ts +8 -0
- package/dist/d1-mv.js +2001 -0
- package/dist/d1.d.ts +16 -0
- package/dist/d1.js +3418 -0
- package/dist/diagnostics/G60DurableHop.d.ts +99 -0
- package/dist/diagnostics/G60DurableHop.js +248 -0
- package/dist/diagnostics/G65Admission.d.ts +23 -0
- package/dist/diagnostics/G65Admission.js +43 -0
- package/dist/diagnostics/G65DirectRing.d.ts +33 -0
- package/dist/diagnostics/G65DirectRing.js +85 -0
- package/dist/diagnostics/G69AdmissionAttempt.d.ts +43 -0
- package/dist/diagnostics/G69AdmissionAttempt.js +22 -0
- package/dist/downstream/DeliveryCore.d.ts +127 -0
- package/dist/downstream/DeliveryCore.js +304 -0
- package/dist/downstream/Doorbell.d.ts +43 -0
- package/dist/downstream/Doorbell.js +137 -0
- package/dist/downstream/DownstreamAdapter.d.ts +52 -0
- package/dist/downstream/DownstreamAdapter.js +299 -0
- package/dist/downstream/ExclusionLookup.d.ts +36 -0
- package/dist/downstream/ExclusionLookup.js +50 -0
- package/dist/downstream/InconsistencyDetector.d.ts +27 -0
- package/dist/downstream/InconsistencyDetector.js +82 -0
- package/dist/downstream/OutboxDrain.d.ts +35 -0
- package/dist/downstream/OutboxDrain.js +126 -0
- package/dist/downstream/types.d.ts +77 -0
- package/dist/downstream/types.js +64 -0
- package/dist/eventIdentity.d.ts +30 -0
- package/dist/eventIdentity.js +49 -0
- package/dist/eventRecord.d.ts +26 -0
- package/dist/eventRecord.js +53 -0
- package/dist/g42-probe.d.ts +9 -0
- package/dist/g42-probe.js +9 -0
- package/dist/http/SerializedQueryWorker.d.ts +58 -0
- package/dist/http/SerializedQueryWorker.js +497 -0
- package/dist/http/commitResponse.d.ts +46 -0
- package/dist/http/commitResponse.js +80 -0
- package/dist/http/testServiceId.d.ts +17 -0
- package/dist/http/testServiceId.js +14 -0
- package/dist/index.d.ts +112 -0
- package/dist/index.js +19926 -0
- package/dist/journal/JournalDurableObject.d.ts +30 -0
- package/dist/journal/JournalDurableObject.js +481 -0
- package/dist/journal/JournalFirstTouchProbe.d.ts +190 -0
- package/dist/journal/JournalFirstTouchProbe.js +375 -0
- package/dist/journal/types.d.ts +116 -0
- package/dist/journal/types.js +52 -0
- package/dist/mv/MaterializedViewCatchUp.d.ts +60 -0
- package/dist/mv/MaterializedViewCatchUp.js +353 -0
- package/dist/mv/MaterializedViewRuntime.d.ts +80 -0
- package/dist/mv/MaterializedViewRuntime.js +328 -0
- package/dist/mv/MaterializedViewStore.d.ts +257 -0
- package/dist/mv/MaterializedViewStore.js +875 -0
- package/dist/mv/UnsafeWindowMaterializedView.d.ts +112 -0
- package/dist/mv/UnsafeWindowMaterializedView.js +495 -0
- package/dist/mv/index.d.ts +8 -0
- package/dist/mv/index.js +2357 -0
- package/dist/projection/LiveProjectionWorker.d.ts +86 -0
- package/dist/projection/LiveProjectionWorker.js +240 -0
- package/dist/projection/ProjectionRuntime.d.ts +113 -0
- package/dist/projection/ProjectionRuntime.js +346 -0
- package/dist/projection/ProjectorRegistry.d.ts +51 -0
- package/dist/projection/ProjectorRegistry.js +116 -0
- package/dist/query/ProjectionQueryStore.d.ts +102 -0
- package/dist/query/ProjectionQueryStore.js +185 -0
- package/dist/query/QueryRegistry.d.ts +22 -0
- package/dist/query/QueryRegistry.js +36 -0
- package/dist/read/SerializedReadWorker.d.ts +36 -0
- package/dist/read/SerializedReadWorker.js +225 -0
- package/dist/repair/RepairWorker.d.ts +72 -0
- package/dist/repair/RepairWorker.js +461 -0
- package/dist/safeWindow.d.ts +20 -0
- package/dist/safeWindow.js +36 -0
- package/dist/scope/ControlRouteScope.d.ts +22 -0
- package/dist/scope/ControlRouteScope.js +36 -0
- package/dist/scope/ScopeName.d.ts +34 -0
- package/dist/scope/ScopeName.js +67 -0
- package/dist/service/ServiceIdentityProvider.d.ts +35 -0
- package/dist/service/ServiceIdentityProvider.js +56 -0
- package/dist/store/CosmosEventStore.d.ts +126 -0
- package/dist/store/CosmosEventStore.js +950 -0
- package/dist/store/D1EventStore.d.ts +111 -0
- package/dist/store/D1EventStore.js +1111 -0
- package/dist/store/PostgresEventStore.d.ts +35 -0
- package/dist/store/PostgresEventStore.js +811 -0
- package/dist/store/provider.d.ts +25 -0
- package/dist/store/provider.js +32 -0
- package/dist/store/types.d.ts +205 -0
- package/dist/store/types.js +18 -0
- package/dist/tag/EventDigest.d.ts +40 -0
- package/dist/tag/EventDigest.js +158 -0
- package/dist/tag/TagDurableObject.d.ts +286 -0
- package/dist/tag/TagDurableObject.js +4071 -0
- package/dist/tag/TagSqlMeasurement.d.ts +26 -0
- package/dist/tag/TagSqlMeasurement.js +44 -0
- package/dist/tag/TagSqlSchema.d.ts +29 -0
- package/dist/tag/TagSqlSchema.js +211 -0
- package/dist/tag/types.d.ts +173 -0
- package/dist/tag/types.js +3 -0
- package/dist/tagstate/TagStateDurableObject.d.ts +57 -0
- package/dist/tagstate/TagStateDurableObject.js +499 -0
- package/dist/trace/AttributionRatio.d.ts +12 -0
- package/dist/trace/AttributionRatio.js +68 -0
- package/dist/trace/CloudflareTracing.d.ts +7 -0
- package/dist/trace/CloudflareTracing.js +9 -0
- package/dist/trace/CommitTrace.d.ts +405 -0
- package/dist/trace/CommitTrace.js +1008 -0
- package/dist/trace/CommitTraceConsoleSink.d.ts +68 -0
- package/dist/trace/CommitTraceConsoleSink.js +114 -0
- package/dist/trace/CommitTraceVerifier.d.ts +22 -0
- package/dist/trace/CommitTraceVerifier.js +295 -0
- package/dist/trace/ObservationStream.d.ts +118 -0
- package/dist/trace/ObservationStream.js +148 -0
- package/package.json +71 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { parseBootstrapDump } from "./manifest";
|
|
2
|
+
import { scopeIdFor } from "../scope/ScopeName";
|
|
3
|
+
import { DurableObjectActivation, enterNativeActorHandleSpan, noOpNativeTracing, } from "../trace/CommitTrace";
|
|
4
|
+
import { beginDurableObjectHandlerObservation } from "../trace/ObservationStream";
|
|
5
|
+
const CONTROL = "bootstrap-control";
|
|
6
|
+
const DUMP = "bootstrap-dump";
|
|
7
|
+
const MAX_CHUNK_EVENTS = 128;
|
|
8
|
+
const MAX_CHUNK_BYTES = 192 * 1024;
|
|
9
|
+
const MAX_RELEASED_COMMANDS = 512;
|
|
10
|
+
const object = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11
|
+
const string = (value) => typeof value === "string" && value.length > 0;
|
|
12
|
+
const epoch = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
13
|
+
const response = (body, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8" } });
|
|
14
|
+
const reject = (code, message, status = 409) => response({ code, error: message }, status);
|
|
15
|
+
function empty(serviceId) {
|
|
16
|
+
return { schemaVersion: 1, status: "EMPTY", importId: null, targetServiceId: serviceId, allocatorLineageId: null, source: null, digest: null, manifest: null, progress: {}, leaseEpoch: 0, leaseUntil: null, failure: null, readyAt: null, normalInFlight: 0, normalCommands: {}, releasedCommands: {}, verifiedImportId: null, verifiedLeaseEpoch: null, storeCompletion: null };
|
|
17
|
+
}
|
|
18
|
+
function expired(control) { return control.leaseUntil !== null && control.leaseUntil <= Date.now(); }
|
|
19
|
+
function commands(control) { return { ...(control.normalCommands ?? {}) }; }
|
|
20
|
+
function released(control) { return { ...(control.releasedCommands ?? {}) }; }
|
|
21
|
+
function fault(body, point) { return object(body) && body.faultAt === point; }
|
|
22
|
+
function encodedBytes(value) { return new TextEncoder().encode(JSON.stringify(value)).byteLength; }
|
|
23
|
+
/** Per-service authority and linearization point for bootstrap and normal writes. */
|
|
24
|
+
export class BootstrapCoordinatorDurableObject {
|
|
25
|
+
ctx;
|
|
26
|
+
env;
|
|
27
|
+
nativeTracing;
|
|
28
|
+
/** Constructor-scoped observation only; never persisted or used for control. */
|
|
29
|
+
activation = new DurableObjectActivation();
|
|
30
|
+
constructor(ctx, env, nativeTracing = noOpNativeTracing) {
|
|
31
|
+
this.ctx = ctx;
|
|
32
|
+
this.env = env;
|
|
33
|
+
this.nativeTracing = nativeTracing;
|
|
34
|
+
}
|
|
35
|
+
async fetch(request) {
|
|
36
|
+
// Flip before this handler performs its first await.
|
|
37
|
+
const activation = this.activation.beginHandler();
|
|
38
|
+
const observation = beginDurableObjectHandlerObservation("BOOTSTRAP", activation);
|
|
39
|
+
const url = new URL(request.url);
|
|
40
|
+
const serviceId = url.searchParams.get("__serviceId");
|
|
41
|
+
if (!string(serviceId))
|
|
42
|
+
return reject("bootstrap_service_required", "target service identity is required", 400);
|
|
43
|
+
if (request.method === "GET" && url.pathname === "/state")
|
|
44
|
+
return response(await this.control(serviceId));
|
|
45
|
+
if (request.method === "POST" && url.pathname === "/command/admit")
|
|
46
|
+
return this.traceCommand(request, serviceId, activation, observation, "admit");
|
|
47
|
+
if (request.method === "POST" && url.pathname === "/command/finalize")
|
|
48
|
+
return this.traceCommand(request, serviceId, activation, observation, "finalize");
|
|
49
|
+
if (request.method === "POST" && url.pathname === "/command/release")
|
|
50
|
+
return this.traceCommand(request, serviceId, activation, observation, "release");
|
|
51
|
+
let body;
|
|
52
|
+
try {
|
|
53
|
+
body = await request.json();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return reject("bootstrap_body_invalid", "JSON body is required", 400);
|
|
57
|
+
}
|
|
58
|
+
if (request.method !== "POST")
|
|
59
|
+
return reject("bootstrap_route_not_found", "bootstrap route was not found", 404);
|
|
60
|
+
if (url.pathname === "/plan")
|
|
61
|
+
return this.plan(serviceId, body);
|
|
62
|
+
if (url.pathname === "/import")
|
|
63
|
+
return this.import(serviceId, body);
|
|
64
|
+
if (url.pathname === "/verify")
|
|
65
|
+
return this.verify(serviceId, body);
|
|
66
|
+
if (url.pathname === "/ready")
|
|
67
|
+
return this.ready(serviceId, body);
|
|
68
|
+
if (url.pathname === "/abort")
|
|
69
|
+
return this.abort(serviceId, body);
|
|
70
|
+
if (url.pathname === "/route/check")
|
|
71
|
+
return this.route(serviceId, body);
|
|
72
|
+
return reject("bootstrap_route_not_found", "bootstrap route was not found", 404);
|
|
73
|
+
}
|
|
74
|
+
async traceCommand(request, serviceId, activation, observation, action) {
|
|
75
|
+
// Identity attribution and command execution must share one consumed
|
|
76
|
+
// request body. A tracing-only clone can remain readable after a DO
|
|
77
|
+
// response has completed when several workerd isolates are active.
|
|
78
|
+
let body;
|
|
79
|
+
return enterNativeActorHandleSpan(this.nativeTracing, { actorClass: "BOOTSTRAP", actorKey: `bootstrap:${serviceId}`, activation, observation }, async () => {
|
|
80
|
+
try {
|
|
81
|
+
body = await request.json();
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
const commandId = object(body) && string(body.commandId) ? body.commandId : undefined;
|
|
87
|
+
return commandId === undefined ? undefined : { attemptId: commandId, serviceId };
|
|
88
|
+
}, async () => body === undefined
|
|
89
|
+
? reject("bootstrap_body_invalid", "JSON body is required", 400)
|
|
90
|
+
: this.command(serviceId, body, action, observation));
|
|
91
|
+
}
|
|
92
|
+
async control(serviceId) { return (await this.ctx.storage.get(CONTROL)) ?? empty(serviceId); }
|
|
93
|
+
valid(control, body) {
|
|
94
|
+
if (!object(body) || !string(body.importId) || !epoch(body.leaseEpoch))
|
|
95
|
+
return "bootstrap importId and leaseEpoch are required";
|
|
96
|
+
if (control.importId !== body.importId || control.leaseEpoch !== body.leaseEpoch)
|
|
97
|
+
return "bootstrap fencing epoch does not match";
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
async plan(serviceId, body) {
|
|
101
|
+
if (!object(body) || !string(body.importId) || !object(body.dump) || !object(body.targetEvidence) || typeof body.targetEvidence.bindingExists !== "boolean" || typeof body.targetEvidence.eventsExist !== "boolean")
|
|
102
|
+
return reject("bootstrap_plan_invalid", "importId, dump, and explicit target evidence are required", 400);
|
|
103
|
+
const importId = body.importId;
|
|
104
|
+
let dump;
|
|
105
|
+
try {
|
|
106
|
+
dump = parseBootstrapDump(body.dump);
|
|
107
|
+
}
|
|
108
|
+
catch (failure) {
|
|
109
|
+
const e = failure;
|
|
110
|
+
return reject(e.code ?? "bootstrap_dump_invalid", e.message ?? "invalid dump", 400);
|
|
111
|
+
}
|
|
112
|
+
if (dump.manifest.target.serviceId !== serviceId)
|
|
113
|
+
return reject("bootstrap_target_mismatch", "manifest target is not this coordinator service");
|
|
114
|
+
if (body.targetEvidence.bindingExists || body.targetEvidence.eventsExist)
|
|
115
|
+
return reject("bootstrap_target_not_fresh", "existing binding or events forbid bootstrap without writes");
|
|
116
|
+
const planned = await this.ctx.storage.transaction(async (txn) => {
|
|
117
|
+
const control = (await txn.get(CONTROL)) ?? empty(serviceId);
|
|
118
|
+
if (control.status === "READY")
|
|
119
|
+
return { error: "bootstrap_ready_permanent" };
|
|
120
|
+
if (control.status !== "EMPTY")
|
|
121
|
+
return control.importId === importId && control.digest === dump.manifest.contentDigest ? { control } : { error: "bootstrap_plan_conflict" };
|
|
122
|
+
if (control.normalInFlight !== 0 || Object.keys(commands(control)).length !== 0)
|
|
123
|
+
return { error: "bootstrap_normal_command_in_flight" };
|
|
124
|
+
const next = { ...control, status: "PLANNED", importId, allocatorLineageId: dump.manifest.target.allocatorLineageId, source: dump.manifest.source, digest: dump.manifest.contentDigest, manifest: dump.manifest, leaseEpoch: control.leaseEpoch + 1, leaseUntil: Date.now() + 30_000, failure: null, progress: {}, verifiedImportId: null, verifiedLeaseEpoch: null, storeCompletion: null };
|
|
125
|
+
await txn.put(CONTROL, next);
|
|
126
|
+
await txn.put(DUMP, dump);
|
|
127
|
+
return { control: next };
|
|
128
|
+
});
|
|
129
|
+
if (fault(body, "mode-record") && !("error" in planned))
|
|
130
|
+
return reject("bootstrap_simulated_crash", "simulated crash after mode record", 503);
|
|
131
|
+
return "error" in planned ? reject(planned.error ?? "bootstrap_plan_rejected", planned.error ?? "bootstrap plan rejected") : response(planned.control, planned.control.status === "PLANNED" ? 201 : 200);
|
|
132
|
+
}
|
|
133
|
+
chunks(events) {
|
|
134
|
+
const chunks = [];
|
|
135
|
+
let current = [];
|
|
136
|
+
let bytes = 0;
|
|
137
|
+
for (const event of events) {
|
|
138
|
+
const eventBytes = encodedBytes(event);
|
|
139
|
+
if (eventBytes > MAX_CHUNK_BYTES)
|
|
140
|
+
throw new Error("bootstrap event exceeds encoded chunk bound");
|
|
141
|
+
if (current.length === MAX_CHUNK_EVENTS || bytes + eventBytes > MAX_CHUNK_BYTES) {
|
|
142
|
+
chunks.push(current);
|
|
143
|
+
current = [];
|
|
144
|
+
bytes = 0;
|
|
145
|
+
}
|
|
146
|
+
current.push(event);
|
|
147
|
+
bytes += eventBytes;
|
|
148
|
+
}
|
|
149
|
+
if (current.length > 0)
|
|
150
|
+
chunks.push(current);
|
|
151
|
+
return chunks;
|
|
152
|
+
}
|
|
153
|
+
async import(serviceId, body) {
|
|
154
|
+
const control = await this.control(serviceId);
|
|
155
|
+
const invalid = this.valid(control, body);
|
|
156
|
+
if (invalid !== undefined)
|
|
157
|
+
return reject("bootstrap_epoch_rejected", invalid);
|
|
158
|
+
if (control.status !== "PLANNED" && control.status !== "IMPORTING" && control.status !== "FAILED")
|
|
159
|
+
return reject("bootstrap_state_rejected", "import is not permitted in this state");
|
|
160
|
+
if (expired(control))
|
|
161
|
+
return reject("bootstrap_lease_expired", "only a same-plan takeover may continue an expired lease");
|
|
162
|
+
const dump = await this.ctx.storage.get(DUMP);
|
|
163
|
+
if (dump === undefined || control.manifest === null)
|
|
164
|
+
return reject("bootstrap_plan_missing", "planned dump is unavailable", 500);
|
|
165
|
+
const byTag = new Map();
|
|
166
|
+
for (const event of dump.events)
|
|
167
|
+
for (const tag of event.eventTags)
|
|
168
|
+
byTag.set(tag, [...(byTag.get(tag) ?? []), event]);
|
|
169
|
+
await this.ctx.storage.transaction(async (txn) => { const current = (await txn.get(CONTROL)) ?? empty(serviceId); if (current.importId !== control.importId || current.leaseEpoch !== control.leaseEpoch)
|
|
170
|
+
throw new Error("bootstrap epoch advanced"); await txn.put(CONTROL, { ...current, status: "IMPORTING" }); });
|
|
171
|
+
// Sequential tags give strict same-tag ordering and bounded cross-tag concurrency (one).
|
|
172
|
+
for (const [tag, events] of [...byTag.entries()].sort(([left], [right]) => left.localeCompare(right))) {
|
|
173
|
+
for (const chunk of this.chunks(events)) {
|
|
174
|
+
const stub = this.env.TAG.get(scopeIdFor(this.env.TAG, { serviceId, doClass: "tag", identity: tag }));
|
|
175
|
+
const tagUrl = new URL("https://bootstrap.internal/bootstrap/admit");
|
|
176
|
+
tagUrl.searchParams.set("__tag", tag);
|
|
177
|
+
tagUrl.searchParams.set("__serviceId", serviceId);
|
|
178
|
+
// Bootstrap dumps model C# provenance as an object, while Tag's
|
|
179
|
+
// durable internal envelope uses the fixed G32 discriminator. This
|
|
180
|
+
// is a representation boundary, not a legacy fallback.
|
|
181
|
+
const candidates = chunk.map((event) => {
|
|
182
|
+
if (event.provenance.origin !== "g32")
|
|
183
|
+
throw new Error("bootstrap dump provenance is not G32");
|
|
184
|
+
return {
|
|
185
|
+
eventId: event.eventId,
|
|
186
|
+
suid: event.suid,
|
|
187
|
+
payload: event.payload,
|
|
188
|
+
eventTags: event.eventTags,
|
|
189
|
+
eventType: event.eventType,
|
|
190
|
+
timestamp: event.timestamp,
|
|
191
|
+
causationId: event.causationId,
|
|
192
|
+
correlationId: event.correlationId,
|
|
193
|
+
executedUser: event.executedUser,
|
|
194
|
+
provenance: "g32",
|
|
195
|
+
allocatorLineageId: control.allocatorLineageId,
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
const admitted = await stub.fetch(new Request(tagUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ importId: control.importId, leaseEpoch: control.leaseEpoch, manifestDigest: control.digest, targetServiceId: serviceId, candidates }) }));
|
|
199
|
+
if (!admitted.ok)
|
|
200
|
+
return reject("bootstrap_tag_admission_failed", `tag ${tag} rejected bootstrap admission`, admitted.status);
|
|
201
|
+
if (fault(body, "tag-chunk"))
|
|
202
|
+
return reject("bootstrap_simulated_crash", "simulated crash after tag chunk", 503);
|
|
203
|
+
await this.ctx.storage.transaction(async (txn) => { const current = (await txn.get(CONTROL)) ?? empty(serviceId); if (current.importId !== control.importId || current.leaseEpoch !== control.leaseEpoch)
|
|
204
|
+
throw new Error("bootstrap epoch advanced"); await txn.put(CONTROL, { ...current, progress: { ...current.progress, [tag]: chunk.at(-1).suid } }); });
|
|
205
|
+
if (fault(body, "store-progress-gap"))
|
|
206
|
+
return reject("bootstrap_simulated_crash", "simulated crash after durable tag write", 503);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// Seed the exact allocator used by normal commits for this service. Its
|
|
210
|
+
// durable lineage is the target store binding.
|
|
211
|
+
// An empty, fresh G32 dump has no predecessor watermark. Do not invent
|
|
212
|
+
// an old `suid-` sentinel: the first normal allocation must establish the
|
|
213
|
+
// new 30-digit domain itself. A non-empty dump is seeded exactly once.
|
|
214
|
+
if (control.manifest.highWatermark !== null) {
|
|
215
|
+
const allocator = this.env.ALLOCATOR.get(scopeIdFor(this.env.ALLOCATOR, { serviceId, doClass: "allocator", identity: "allocator" }));
|
|
216
|
+
const seeded = await allocator.fetch(new Request("https://bootstrap.internal/seed-after", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ importId: control.importId, leaseEpoch: control.leaseEpoch, highWatermark: control.manifest.highWatermark }) }));
|
|
217
|
+
if (!seeded.ok)
|
|
218
|
+
return reject("bootstrap_allocator_seed_failed", "allocator seedAfter rejected bootstrap", seeded.status);
|
|
219
|
+
}
|
|
220
|
+
if (fault(body, "allocator-seed"))
|
|
221
|
+
return reject("bootstrap_simulated_crash", "simulated crash after allocator seed", 503);
|
|
222
|
+
const next = await this.ctx.storage.transaction(async (txn) => { const current = (await txn.get(CONTROL)) ?? empty(serviceId); if (current.importId !== control.importId || current.leaseEpoch !== control.leaseEpoch)
|
|
223
|
+
throw new Error("bootstrap epoch advanced"); const updated = { ...current, status: "VERIFYING" }; await txn.put(CONTROL, updated); return updated; });
|
|
224
|
+
return response(next);
|
|
225
|
+
}
|
|
226
|
+
async verify(serviceId, body) {
|
|
227
|
+
const control = await this.control(serviceId);
|
|
228
|
+
const invalid = this.valid(control, body);
|
|
229
|
+
if (invalid !== undefined)
|
|
230
|
+
return reject("bootstrap_epoch_rejected", invalid);
|
|
231
|
+
if (control.status !== "VERIFYING" || control.manifest === null)
|
|
232
|
+
return reject("bootstrap_state_rejected", "verify requires VERIFYING");
|
|
233
|
+
const dump = await this.ctx.storage.get(DUMP);
|
|
234
|
+
if (dump === undefined)
|
|
235
|
+
return reject("bootstrap_plan_missing", "planned dump is unavailable", 500);
|
|
236
|
+
const expectedByTag = new Map();
|
|
237
|
+
for (const event of dump.events)
|
|
238
|
+
for (const tag of event.eventTags)
|
|
239
|
+
expectedByTag.set(tag, [...(expectedByTag.get(tag) ?? []), event]);
|
|
240
|
+
for (const [tag, expected] of expectedByTag) {
|
|
241
|
+
const url = new URL("https://bootstrap.internal/state");
|
|
242
|
+
url.searchParams.set("__tag", tag);
|
|
243
|
+
const actual = await this.env.TAG.get(scopeIdFor(this.env.TAG, { serviceId, doClass: "tag", identity: tag })).fetch(new Request(url));
|
|
244
|
+
if (!actual.ok)
|
|
245
|
+
return reject("bootstrap_verify_tag_missing", `tag ${tag} is missing`, actual.status);
|
|
246
|
+
const state = await actual.json();
|
|
247
|
+
const actualEvents = Array.isArray(state.events) ? state.events : [];
|
|
248
|
+
const expectedHead = expected.at(-1)?.suid ?? null;
|
|
249
|
+
if (state.head !== expectedHead || actualEvents.length !== expected.length || actualEvents.some((value, index) => {
|
|
250
|
+
const event = value;
|
|
251
|
+
const source = expected[index];
|
|
252
|
+
return event.eventId !== source.eventId ||
|
|
253
|
+
event.suid !== source.suid ||
|
|
254
|
+
event.payload !== source.payload ||
|
|
255
|
+
event.eventType !== source.eventType ||
|
|
256
|
+
event.provenance !== "g32" ||
|
|
257
|
+
source.provenance.origin !== "g32" ||
|
|
258
|
+
JSON.stringify(event.eventTags) !== JSON.stringify(source.eventTags);
|
|
259
|
+
}))
|
|
260
|
+
return reject("bootstrap_verify_tag_mismatch", `tag ${tag} does not match manifest`);
|
|
261
|
+
}
|
|
262
|
+
// Permitted core completion branch: immutable pre-populated manifest snapshot.
|
|
263
|
+
const ids = new Set(dump.events.map((event) => event.eventId));
|
|
264
|
+
if (ids.size !== dump.events.length || dump.events.length !== control.manifest.eventCount)
|
|
265
|
+
return reject("bootstrap_verify_store_mismatch", "prepopulated store snapshot does not match manifest");
|
|
266
|
+
if (fault(body, "verifying"))
|
|
267
|
+
return reject("bootstrap_simulated_crash", "simulated crash during verification", 503);
|
|
268
|
+
const verified = await this.ctx.storage.transaction(async (txn) => { const current = (await txn.get(CONTROL)) ?? empty(serviceId); if (current.status !== "VERIFYING" || current.importId !== control.importId || current.leaseEpoch !== control.leaseEpoch)
|
|
269
|
+
throw new Error("bootstrap verification CAS failed"); const updated = { ...current, verifiedImportId: control.importId, verifiedLeaseEpoch: control.leaseEpoch, storeCompletion: "prepopulated-manifest" }; await txn.put(CONTROL, updated); return updated; });
|
|
270
|
+
return response(verified);
|
|
271
|
+
}
|
|
272
|
+
async ready(serviceId, body) {
|
|
273
|
+
const control = await this.control(serviceId);
|
|
274
|
+
const invalid = this.valid(control, body);
|
|
275
|
+
if (invalid !== undefined)
|
|
276
|
+
return reject("bootstrap_epoch_rejected", invalid);
|
|
277
|
+
if (control.status !== "VERIFYING" || control.verifiedImportId !== control.importId || control.verifiedLeaseEpoch !== control.leaseEpoch || control.storeCompletion === null)
|
|
278
|
+
return reject("bootstrap_verification_required", "READY requires same importId and epoch verification");
|
|
279
|
+
for (const tag of Object.keys(control.manifest.tagCounts)) {
|
|
280
|
+
const stub = this.env.TAG.get(scopeIdFor(this.env.TAG, { serviceId, doClass: "tag", identity: tag }));
|
|
281
|
+
const url = new URL("https://bootstrap.internal/bootstrap/close");
|
|
282
|
+
url.searchParams.set("__tag", tag);
|
|
283
|
+
const closed = await stub.fetch(new Request(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ importId: control.importId, leaseEpoch: control.leaseEpoch }) }));
|
|
284
|
+
if (!closed.ok)
|
|
285
|
+
return reject("bootstrap_tag_close_failed", `tag ${tag} refused READY close`, closed.status);
|
|
286
|
+
}
|
|
287
|
+
if (fault(body, "ready-cas"))
|
|
288
|
+
return reject("bootstrap_simulated_crash", "simulated crash before READY CAS", 503);
|
|
289
|
+
const ready = await this.ctx.storage.transaction(async (txn) => { const current = (await txn.get(CONTROL)) ?? empty(serviceId); if (current.status === "READY")
|
|
290
|
+
return current; if (current.status !== "VERIFYING" || current.leaseEpoch !== control.leaseEpoch || current.verifiedImportId !== current.importId || current.verifiedLeaseEpoch !== current.leaseEpoch)
|
|
291
|
+
throw new Error("bootstrap READY CAS failed"); const updated = { ...current, status: "READY", leaseUntil: null, readyAt: new Date().toISOString() }; await txn.put(CONTROL, updated); return updated; });
|
|
292
|
+
return response(ready);
|
|
293
|
+
}
|
|
294
|
+
/** Operator abort retains the durable failed plan for audit/retry; it never clears target state. */
|
|
295
|
+
async abort(serviceId, body) {
|
|
296
|
+
const control = await this.control(serviceId);
|
|
297
|
+
const invalid = this.valid(control, body);
|
|
298
|
+
if (invalid !== undefined)
|
|
299
|
+
return reject("bootstrap_epoch_rejected", invalid);
|
|
300
|
+
if (control.status === "EMPTY" || control.status === "READY")
|
|
301
|
+
return reject("bootstrap_state_rejected", "abort requires an active bootstrap");
|
|
302
|
+
const aborted = await this.ctx.storage.transaction(async (txn) => {
|
|
303
|
+
const current = (await txn.get(CONTROL)) ?? empty(serviceId);
|
|
304
|
+
if (current.importId !== control.importId || current.leaseEpoch !== control.leaseEpoch)
|
|
305
|
+
throw new Error("bootstrap abort CAS failed");
|
|
306
|
+
const next = { ...current, status: "FAILED", failure: "operator_abort", leaseUntil: null };
|
|
307
|
+
await txn.put(CONTROL, next);
|
|
308
|
+
return next;
|
|
309
|
+
});
|
|
310
|
+
return response(aborted);
|
|
311
|
+
}
|
|
312
|
+
async route(serviceId, body) {
|
|
313
|
+
if (!object(body) || !string(body.route))
|
|
314
|
+
return reject("bootstrap_route_invalid", "route is required", 400);
|
|
315
|
+
const control = await this.control(serviceId);
|
|
316
|
+
return control.status === "EMPTY" || control.status === "READY" ? response({ admitted: true, leaseEpoch: control.leaseEpoch, route: body.route }) : reject("bootstrap_route_rejected", `${body.route} is unavailable during bootstrap`);
|
|
317
|
+
}
|
|
318
|
+
async command(serviceId, body, action, observation) {
|
|
319
|
+
if (!object(body) || !string(body.commandId))
|
|
320
|
+
return reject("bootstrap_command_invalid", "commandId is required", 400);
|
|
321
|
+
const commandId = body.commandId;
|
|
322
|
+
observation?.markFirstStorageRead();
|
|
323
|
+
const result = await this.ctx.storage.transaction(async (txn) => {
|
|
324
|
+
const control = (await txn.get(CONTROL)) ?? empty(serviceId);
|
|
325
|
+
const current = commands(control);
|
|
326
|
+
const completed = released(control);
|
|
327
|
+
if (action === "admit") {
|
|
328
|
+
if (control.status !== "EMPTY" && control.status !== "READY")
|
|
329
|
+
return { rejected: true };
|
|
330
|
+
if (current[commandId] !== undefined)
|
|
331
|
+
return { control };
|
|
332
|
+
current[commandId] = control.leaseEpoch;
|
|
333
|
+
const updated = { ...control, normalInFlight: Object.keys(current).length, normalCommands: current };
|
|
334
|
+
await txn.put(CONTROL, updated);
|
|
335
|
+
return { control: updated };
|
|
336
|
+
}
|
|
337
|
+
const expectedEpoch = body.leaseEpoch;
|
|
338
|
+
// Release is cleanup only and may be retried after a lost response; it
|
|
339
|
+
// may never authorize a mutation. Finalize retains the strict epoch CAS.
|
|
340
|
+
if (action === "release") {
|
|
341
|
+
if (current[commandId] === undefined || (expectedEpoch !== undefined && (!epoch(expectedEpoch) || current[commandId] !== expectedEpoch)))
|
|
342
|
+
return { rejected: true };
|
|
343
|
+
completed[commandId] = current[commandId];
|
|
344
|
+
delete current[commandId];
|
|
345
|
+
for (const stale of Object.keys(completed).slice(0, Math.max(0, Object.keys(completed).length - MAX_RELEASED_COMMANDS)))
|
|
346
|
+
delete completed[stale];
|
|
347
|
+
const updated = { ...control, normalInFlight: Object.keys(current).length, normalCommands: current, releasedCommands: completed };
|
|
348
|
+
await txn.put(CONTROL, updated);
|
|
349
|
+
return { control: updated };
|
|
350
|
+
}
|
|
351
|
+
if (!epoch(expectedEpoch) || (current[commandId] !== expectedEpoch && completed[commandId] !== expectedEpoch) || control.leaseEpoch !== expectedEpoch || (control.status !== "EMPTY" && control.status !== "READY"))
|
|
352
|
+
return { rejected: true };
|
|
353
|
+
return { control };
|
|
354
|
+
});
|
|
355
|
+
return "rejected" in result ? reject("bootstrap_command_rejected", "bootstrap epoch rejects normal durable mutation") : response({ leaseEpoch: result.control.leaseEpoch, admitted: true });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { BootstrapDump, BootstrapEventRecord, BootstrapManifest, BootstrapStoreAdmissionPort } from "./types";
|
|
2
|
+
import type { PipelineStore, StoredEvent } from "../store/types";
|
|
3
|
+
export interface BootstrapExportCursor {
|
|
4
|
+
readonly highWatermark: string | null;
|
|
5
|
+
readonly nextEventId: string | null;
|
|
6
|
+
}
|
|
7
|
+
export interface BootstrapExportPage {
|
|
8
|
+
readonly dump: BootstrapDump;
|
|
9
|
+
readonly page: readonly BootstrapEventRecord[];
|
|
10
|
+
readonly cursor: BootstrapExportCursor;
|
|
11
|
+
readonly complete: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare class BootstrapIdentityConflictError extends Error {
|
|
14
|
+
readonly provider: string;
|
|
15
|
+
readonly eventId: string;
|
|
16
|
+
readonly code: "BOOTSTRAP_EVENT_IDENTITY_CONFLICT";
|
|
17
|
+
constructor(provider: string, eventId: string);
|
|
18
|
+
}
|
|
19
|
+
export declare function bootstrapEventIdentityMatches(event: StoredEvent, record: BootstrapEventRecord): boolean;
|
|
20
|
+
export type BootstrapIdentityGuard = (event: StoredEvent, record: BootstrapEventRecord) => boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Provider-neutral operator adapter. A snapshot is anchored at the first
|
|
23
|
+
* observed high watermark; later pages always re-apply that predicate, so an
|
|
24
|
+
* append after a crash/resume cannot enter the export.
|
|
25
|
+
*/
|
|
26
|
+
export declare class BootstrapStoreAdapter implements BootstrapStoreAdmissionPort {
|
|
27
|
+
readonly provider: string;
|
|
28
|
+
private readonly store;
|
|
29
|
+
private readonly identityGuard;
|
|
30
|
+
constructor(provider: string, store: PipelineStore, identityGuard?: BootstrapIdentityGuard);
|
|
31
|
+
exportPage(input: {
|
|
32
|
+
readonly sourceServiceId: string;
|
|
33
|
+
/** Source lineage is explicit; G32 never labels a source as legacy. */
|
|
34
|
+
readonly sourceAllocatorLineageId: string;
|
|
35
|
+
readonly targetServiceId: string;
|
|
36
|
+
readonly allocatorLineageId: string;
|
|
37
|
+
readonly pageSize: number;
|
|
38
|
+
readonly cursor?: BootstrapExportCursor;
|
|
39
|
+
}): Promise<BootstrapExportPage>;
|
|
40
|
+
admitBootstrap(input: {
|
|
41
|
+
readonly importId: string;
|
|
42
|
+
readonly leaseEpoch: number;
|
|
43
|
+
readonly manifest: BootstrapManifest;
|
|
44
|
+
readonly events: readonly BootstrapEventRecord[];
|
|
45
|
+
}): Promise<void>;
|
|
46
|
+
verifyBootstrap(input: {
|
|
47
|
+
readonly importId: string;
|
|
48
|
+
readonly manifest: BootstrapManifest;
|
|
49
|
+
}): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
export declare function createBootstrapStoreAdapter(provider: string, store: PipelineStore, identityGuard?: BootstrapIdentityGuard): BootstrapStoreAdapter;
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { bootstrapDigest } from "./manifest";
|
|
2
|
+
import { canonicalDeclaredTagSet, eventDigestBytes, eventDigestHex } from "../tag/EventDigest";
|
|
3
|
+
function arrayBufferBase64(bytes) {
|
|
4
|
+
let binary = "";
|
|
5
|
+
for (const byte of bytes)
|
|
6
|
+
binary += String.fromCharCode(byte);
|
|
7
|
+
return btoa(binary);
|
|
8
|
+
}
|
|
9
|
+
export class BootstrapIdentityConflictError extends Error {
|
|
10
|
+
provider;
|
|
11
|
+
eventId;
|
|
12
|
+
code = "BOOTSTRAP_EVENT_IDENTITY_CONFLICT";
|
|
13
|
+
constructor(provider, eventId) {
|
|
14
|
+
super(`${provider} bootstrap EventId ${eventId} conflicts with the target identity`);
|
|
15
|
+
this.provider = provider;
|
|
16
|
+
this.eventId = eventId;
|
|
17
|
+
this.name = "BootstrapIdentityConflictError";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function compareOpaque(left, right) {
|
|
21
|
+
const a = new TextEncoder().encode(left);
|
|
22
|
+
const b = new TextEncoder().encode(right);
|
|
23
|
+
for (let index = 0; index < Math.min(a.length, b.length); index += 1)
|
|
24
|
+
if (a[index] !== b[index])
|
|
25
|
+
return a[index] - b[index];
|
|
26
|
+
return a.length - b.length;
|
|
27
|
+
}
|
|
28
|
+
export function bootstrapEventIdentityMatches(event, record) {
|
|
29
|
+
return event.suid === record.suid &&
|
|
30
|
+
event.payload === record.payload &&
|
|
31
|
+
event.eventType === record.eventType &&
|
|
32
|
+
event.timestamp === record.timestamp &&
|
|
33
|
+
event.causationId === record.causationId &&
|
|
34
|
+
event.correlationId === record.correlationId &&
|
|
35
|
+
event.executedUser === record.executedUser &&
|
|
36
|
+
JSON.stringify(event.eventTags) === JSON.stringify(record.eventTags);
|
|
37
|
+
}
|
|
38
|
+
function toRecord(event) {
|
|
39
|
+
return {
|
|
40
|
+
eventId: event.eventId,
|
|
41
|
+
suid: event.suid,
|
|
42
|
+
payload: event.payload,
|
|
43
|
+
eventTags: [...event.eventTags],
|
|
44
|
+
eventType: event.eventType,
|
|
45
|
+
provenance: { origin: "g32" },
|
|
46
|
+
timestamp: event.timestamp,
|
|
47
|
+
causationId: event.causationId,
|
|
48
|
+
correlationId: event.correlationId,
|
|
49
|
+
executedUser: event.executedUser,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Provider-neutral operator adapter. A snapshot is anchored at the first
|
|
54
|
+
* observed high watermark; later pages always re-apply that predicate, so an
|
|
55
|
+
* append after a crash/resume cannot enter the export.
|
|
56
|
+
*/
|
|
57
|
+
export class BootstrapStoreAdapter {
|
|
58
|
+
provider;
|
|
59
|
+
store;
|
|
60
|
+
identityGuard;
|
|
61
|
+
constructor(provider, store, identityGuard = bootstrapEventIdentityMatches) {
|
|
62
|
+
this.provider = provider;
|
|
63
|
+
this.store = store;
|
|
64
|
+
this.identityGuard = identityGuard;
|
|
65
|
+
}
|
|
66
|
+
async exportPage(input) {
|
|
67
|
+
if (!Number.isSafeInteger(input.pageSize) || input.pageSize <= 0)
|
|
68
|
+
throw new Error("bootstrap export pageSize must be positive");
|
|
69
|
+
await this.store.initialize();
|
|
70
|
+
const observed = await this.store.readAllEvents(input.sourceServiceId, "");
|
|
71
|
+
const highWatermark = input.cursor?.highWatermark ?? observed.at(-1)?.suid ?? null;
|
|
72
|
+
// This predicate is the snapshot boundary. Do not replace it with a cursor-only page query.
|
|
73
|
+
const snapshot = observed.filter((event) => highWatermark === null || compareOpaque(event.suid, highWatermark) <= 0).map(toRecord);
|
|
74
|
+
const start = input.cursor?.nextEventId === null || input.cursor?.nextEventId === undefined ? 0 : snapshot.findIndex((event) => event.eventId === input.cursor.nextEventId) + 1;
|
|
75
|
+
const page = snapshot.slice(Math.max(0, start), Math.max(0, start) + input.pageSize);
|
|
76
|
+
const tagCounts = {};
|
|
77
|
+
for (const event of snapshot)
|
|
78
|
+
for (const tag of event.eventTags)
|
|
79
|
+
tagCounts[tag] = (tagCounts[tag] ?? 0) + 1;
|
|
80
|
+
const draft = {
|
|
81
|
+
format: "sekiban-dcb-bootstrap", version: 1,
|
|
82
|
+
source: { serviceId: input.sourceServiceId, lineageId: input.sourceAllocatorLineageId },
|
|
83
|
+
target: { serviceId: input.targetServiceId, allocatorLineageId: input.allocatorLineageId },
|
|
84
|
+
highWatermark, eventCount: snapshot.length, tagCounts,
|
|
85
|
+
canonicalization: "utf8-json-sorted-keys-v1",
|
|
86
|
+
};
|
|
87
|
+
const fullDump = { manifest: { ...draft, contentDigest: "" }, events: snapshot };
|
|
88
|
+
const manifest = { ...draft, contentDigest: bootstrapDigest(fullDump) };
|
|
89
|
+
const pageEnd = Math.max(0, start) + page.length;
|
|
90
|
+
return {
|
|
91
|
+
dump: { manifest, events: snapshot }, page,
|
|
92
|
+
cursor: { highWatermark, nextEventId: pageEnd >= snapshot.length ? null : snapshot[pageEnd - 1].eventId },
|
|
93
|
+
complete: pageEnd >= snapshot.length,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
async admitBootstrap(input) {
|
|
97
|
+
await this.store.initialize();
|
|
98
|
+
const existing = new Map((await this.store.readAllEvents(input.manifest.target.serviceId, "")).map((event) => [event.eventId, event]));
|
|
99
|
+
for (const record of input.events) {
|
|
100
|
+
const prior = existing.get(record.eventId);
|
|
101
|
+
// The provider-level identity guard deliberately fires before any write.
|
|
102
|
+
if (prior !== undefined && !this.identityGuard(prior, record))
|
|
103
|
+
throw new BootstrapIdentityConflictError(this.provider, record.eventId);
|
|
104
|
+
for (const tag of record.eventTags) {
|
|
105
|
+
const attemptId = `bootstrap:${input.importId}:${input.leaseEpoch}`;
|
|
106
|
+
const digestInput = {
|
|
107
|
+
serviceId: input.manifest.target.serviceId,
|
|
108
|
+
eventId: record.eventId,
|
|
109
|
+
sortableUniqueId: record.suid,
|
|
110
|
+
eventType: record.eventType,
|
|
111
|
+
timestamp: record.timestamp,
|
|
112
|
+
allocatorLineageId: input.manifest.target.allocatorLineageId,
|
|
113
|
+
attemptId,
|
|
114
|
+
declaredTagSet: canonicalDeclaredTagSet(record.eventTags),
|
|
115
|
+
payload: new TextEncoder().encode(record.payload),
|
|
116
|
+
};
|
|
117
|
+
const delivered = await this.store.recordDelivery({
|
|
118
|
+
version: 1,
|
|
119
|
+
serviceId: input.manifest.target.serviceId,
|
|
120
|
+
allocatorLineageId: input.manifest.target.allocatorLineageId,
|
|
121
|
+
tag,
|
|
122
|
+
attemptId,
|
|
123
|
+
eventId: record.eventId,
|
|
124
|
+
suid: record.suid,
|
|
125
|
+
payload: record.payload,
|
|
126
|
+
eventTags: [...record.eventTags],
|
|
127
|
+
eventType: record.eventType,
|
|
128
|
+
provenance: "g32",
|
|
129
|
+
timestamp: record.timestamp,
|
|
130
|
+
causationId: record.causationId,
|
|
131
|
+
correlationId: record.correlationId,
|
|
132
|
+
executedUser: record.executedUser,
|
|
133
|
+
enqueuedAt: 0,
|
|
134
|
+
completeness: {
|
|
135
|
+
canonicalBytesBase64: arrayBufferBase64(eventDigestBytes(digestInput)),
|
|
136
|
+
eventDigest: await eventDigestHex(digestInput),
|
|
137
|
+
declaredTagSet: [...digestInput.declaredTagSet],
|
|
138
|
+
localCommittedMembership: [{ serviceId: input.manifest.target.serviceId, eventId: record.eventId, tag }],
|
|
139
|
+
// Bootstrap is not a source outbox replay path. The deterministic
|
|
140
|
+
// per-event/tag ordinal is only enough to satisfy the D1 receipt
|
|
141
|
+
// key while G44's source scanner remains explicitly out of scope.
|
|
142
|
+
obligationSequence: Math.max(1, [...new TextEncoder().encode(`${record.eventId}|${tag}`)].reduce((total, byte) => (total * 33 + byte) % 1_000_000_000, 5381)),
|
|
143
|
+
},
|
|
144
|
+
// A C# import may contain a historical RFC 4122 Id. It bypasses
|
|
145
|
+
// Queue/doorbell and is admitted only through this fenced path;
|
|
146
|
+
// normal deliveries still require UUID v7.
|
|
147
|
+
}, 0, "import");
|
|
148
|
+
if (delivered.outcome !== "stored")
|
|
149
|
+
throw new Error(`${this.provider} bootstrap admission rejected ${record.eventId}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async verifyBootstrap(input) {
|
|
154
|
+
await this.store.initialize();
|
|
155
|
+
const actual = await this.store.readAllEvents(input.manifest.target.serviceId, "");
|
|
156
|
+
if (actual.length !== input.manifest.eventCount)
|
|
157
|
+
throw new Error(`${this.provider} bootstrap store count differs for ${input.importId}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
export function createBootstrapStoreAdapter(provider, store, identityGuard) {
|
|
161
|
+
return new BootstrapStoreAdapter(provider, store, identityGuard);
|
|
162
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { BootstrapManifest } from "./types";
|
|
2
|
+
import type { StoreProvider, StoreProviderEnvironment } from "../store/provider";
|
|
3
|
+
export interface OperatorBootstrapEnv extends StoreProviderEnvironment {
|
|
4
|
+
readonly BOOTSTRAP: DurableObjectNamespace;
|
|
5
|
+
readonly ALLOCATOR: DurableObjectNamespace;
|
|
6
|
+
readonly REPAIR_OPERATOR_TOKEN: string;
|
|
7
|
+
readonly SDT_SERVICE_ID?: string;
|
|
8
|
+
}
|
|
9
|
+
/** Deployment composition may rebuild provider-specific read models after the
|
|
10
|
+
* durable store and tag verification, but before the coordinator becomes READY. */
|
|
11
|
+
export interface OperatorBootstrapHooks {
|
|
12
|
+
readonly afterVerifyBeforeReady?: (input: {
|
|
13
|
+
readonly serviceId: string;
|
|
14
|
+
readonly importId: string;
|
|
15
|
+
readonly leaseEpoch: number;
|
|
16
|
+
readonly manifest: BootstrapManifest;
|
|
17
|
+
}) => Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
/** Bearer-only operator lane. Incoming headers are never forwarded to a DO. */
|
|
20
|
+
export declare function handleOperatorBootstrap(request: Request, env: OperatorBootstrapEnv, storeProvider: StoreProvider, hooks?: OperatorBootstrapHooks): Promise<Response>;
|