@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
package/dist/d1.js
ADDED
|
@@ -0,0 +1,3418 @@
|
|
|
1
|
+
// src/allocator/SortableUniqueId.ts
|
|
2
|
+
var SORTABLE_UNIQUE_ID_TICKS_DIGITS = 19;
|
|
3
|
+
var SORTABLE_UNIQUE_ID_RANDOM_DIGITS = 11;
|
|
4
|
+
var SORTABLE_UNIQUE_ID_DIGITS = SORTABLE_UNIQUE_ID_TICKS_DIGITS + SORTABLE_UNIQUE_ID_RANDOM_DIGITS;
|
|
5
|
+
var DOTNET_UNIX_EPOCH_TICKS = 621355968000000000n;
|
|
6
|
+
var DOTNET_MAX_TICKS = 3155378975999999999n;
|
|
7
|
+
var DOTNET_TICKS_PER_MILLISECOND = 10000n;
|
|
8
|
+
var SORTABLE_UNIQUE_ID_RANDOM_LIMIT = 10n ** BigInt(SORTABLE_UNIQUE_ID_RANDOM_DIGITS);
|
|
9
|
+
var MAX_UNIX_MILLISECONDS = (DOTNET_MAX_TICKS - DOTNET_UNIX_EPOCH_TICKS) / DOTNET_TICKS_PER_MILLISECOND;
|
|
10
|
+
var SortableUniqueIdError = class extends Error {
|
|
11
|
+
constructor(code, message, options) {
|
|
12
|
+
super(message, options);
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.name = "SortableUniqueIdError";
|
|
15
|
+
}
|
|
16
|
+
code;
|
|
17
|
+
};
|
|
18
|
+
var LEGACY_PREFIXED_SUID = /^suid-[0-9]+$/;
|
|
19
|
+
var LEGACY_DECISION_OBSERVER = /* @__PURE__ */ Symbol.for("@sekiban/dcb-runtime/legacy-sortable-unique-id-observer");
|
|
20
|
+
function legacyDecisionObserver() {
|
|
21
|
+
return globalThis[LEGACY_DECISION_OBSERVER];
|
|
22
|
+
}
|
|
23
|
+
function assertDotNetTicks(ticks) {
|
|
24
|
+
if (ticks < 0n || ticks > DOTNET_MAX_TICKS) {
|
|
25
|
+
throw new SortableUniqueIdError("SUID_TICKS_INVALID", "SortableUniqueId ticks are outside the .NET DateTime range");
|
|
26
|
+
}
|
|
27
|
+
return ticks;
|
|
28
|
+
}
|
|
29
|
+
function assertSortableUniqueId(value) {
|
|
30
|
+
if (typeof value === "string" && LEGACY_PREFIXED_SUID.test(value)) {
|
|
31
|
+
legacyDecisionObserver()?.(value);
|
|
32
|
+
throw new SortableUniqueIdError(
|
|
33
|
+
"SUID_LEGACY_FORMAT_RETIRED",
|
|
34
|
+
"Legacy prefixed SortableUniqueId is retired; G32 requires exactly 30 ASCII decimal digits"
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
if (typeof value !== "string" || !new RegExp(`^[0-9]{${SORTABLE_UNIQUE_ID_DIGITS}}$`).test(value)) {
|
|
38
|
+
throw new SortableUniqueIdError("SUID_INVALID", "SortableUniqueId must be exactly 30 ASCII decimal digits");
|
|
39
|
+
}
|
|
40
|
+
const ticksText = value.slice(0, SORTABLE_UNIQUE_ID_TICKS_DIGITS);
|
|
41
|
+
const randomText = value.slice(SORTABLE_UNIQUE_ID_TICKS_DIGITS);
|
|
42
|
+
const ticks = assertDotNetTicks(BigInt(ticksText));
|
|
43
|
+
const random = BigInt(randomText);
|
|
44
|
+
if (random < 0n || random >= SORTABLE_UNIQUE_ID_RANDOM_LIMIT) {
|
|
45
|
+
throw new SortableUniqueIdError("SUID_RANDOM_INVALID", "SortableUniqueId random suffix is outside its 11-digit range");
|
|
46
|
+
}
|
|
47
|
+
return Object.freeze({ value, ticks, random });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// src/safeWindow.ts
|
|
51
|
+
var PUBLISHED_SAFE_WINDOW_MS = 2e4;
|
|
52
|
+
var MAX_PUBLISHED_SAFE_WINDOW_MS = 12e4;
|
|
53
|
+
function decayedLagEstimateMs(estimateMs, observedAtMs, nowMs) {
|
|
54
|
+
const elapsed = Math.max(0, nowMs - observedAtMs);
|
|
55
|
+
return Math.max(0, estimateMs - elapsed);
|
|
56
|
+
}
|
|
57
|
+
function safeWindowMs(dynamicLagBoundMs) {
|
|
58
|
+
return Math.min(MAX_PUBLISHED_SAFE_WINDOW_MS, Math.max(PUBLISHED_SAFE_WINDOW_MS, dynamicLagBoundMs));
|
|
59
|
+
}
|
|
60
|
+
function safeWindowCeilingExceeded(dynamicLagBoundMs) {
|
|
61
|
+
return dynamicLagBoundMs > MAX_PUBLISHED_SAFE_WINDOW_MS;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/diagnostics/G69AdmissionAttempt.ts
|
|
65
|
+
async function appendG69AdmissionAttempt(database, receipt) {
|
|
66
|
+
const insert = database.prepare(
|
|
67
|
+
`INSERT INTO serialized_dcb_g69_admission_attempts
|
|
68
|
+
(service_id, event_id, suid, partition_tag, delivery_source,
|
|
69
|
+
queue_message_id, attempt_id, allocator_lineage_id, obligation_sequence,
|
|
70
|
+
enqueued_at, observed_at, arrived_at,
|
|
71
|
+
first_arrived_at_before, last_arrived_at_before,
|
|
72
|
+
first_arrived_at_after, last_arrived_at_after,
|
|
73
|
+
before_observed_at, after_observed_at, observation_consistency,
|
|
74
|
+
mutation_evidence, diagnostic_duration_ms, receipt_status, retry_reason, clock_origin)
|
|
75
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
76
|
+
).bind(
|
|
77
|
+
receipt.serviceId,
|
|
78
|
+
receipt.eventId,
|
|
79
|
+
receipt.suid,
|
|
80
|
+
receipt.tag,
|
|
81
|
+
receipt.deliverySource,
|
|
82
|
+
receipt.queueMessageId,
|
|
83
|
+
receipt.attemptId,
|
|
84
|
+
receipt.allocatorLineageId,
|
|
85
|
+
receipt.obligationSequence,
|
|
86
|
+
receipt.enqueuedAt,
|
|
87
|
+
receipt.observedAt,
|
|
88
|
+
receipt.arrivedAt,
|
|
89
|
+
receipt.firstArrivedAtBefore,
|
|
90
|
+
receipt.lastArrivedAtBefore,
|
|
91
|
+
receipt.firstArrivedAtAfter,
|
|
92
|
+
receipt.lastArrivedAtAfter,
|
|
93
|
+
receipt.beforeObservedAt,
|
|
94
|
+
receipt.afterObservedAt,
|
|
95
|
+
receipt.observationConsistency,
|
|
96
|
+
receipt.mutationEvidence,
|
|
97
|
+
receipt.diagnosticDurationMs,
|
|
98
|
+
receipt.status,
|
|
99
|
+
receipt.retryReason,
|
|
100
|
+
"Date.now epoch ms"
|
|
101
|
+
);
|
|
102
|
+
const trim = database.prepare(
|
|
103
|
+
`DELETE FROM serialized_dcb_g69_admission_attempts
|
|
104
|
+
WHERE service_id = ?
|
|
105
|
+
AND sequence NOT IN (
|
|
106
|
+
SELECT sequence FROM serialized_dcb_g69_admission_attempts
|
|
107
|
+
WHERE service_id = ? ORDER BY sequence DESC LIMIT 512
|
|
108
|
+
)`
|
|
109
|
+
).bind(receipt.serviceId, receipt.serviceId);
|
|
110
|
+
await database.batch([insert, trim]);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ../dcb-core/dist/index.js
|
|
114
|
+
var JsonValidationError = class extends Error {
|
|
115
|
+
code = "invalid_json_value";
|
|
116
|
+
boundary;
|
|
117
|
+
path;
|
|
118
|
+
constructor(message, boundary = "value", path = "$", options) {
|
|
119
|
+
super(message, options);
|
|
120
|
+
this.name = "JsonValidationError";
|
|
121
|
+
this.boundary = boundary;
|
|
122
|
+
this.path = path;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
var isPlainRecord = (value) => {
|
|
126
|
+
const prototype = Object.getPrototypeOf(value);
|
|
127
|
+
return prototype === Object.prototype || prototype === null;
|
|
128
|
+
};
|
|
129
|
+
function assertJsonValue(value, boundary = "value") {
|
|
130
|
+
const active = /* @__PURE__ */ new WeakSet();
|
|
131
|
+
const visit = (candidate, path) => {
|
|
132
|
+
if (candidate === null || typeof candidate === "string" || typeof candidate === "boolean") {
|
|
133
|
+
return candidate;
|
|
134
|
+
}
|
|
135
|
+
if (typeof candidate === "number") {
|
|
136
|
+
if (Number.isFinite(candidate)) return candidate;
|
|
137
|
+
throw new JsonValidationError("JSON numbers must be finite", boundary, path);
|
|
138
|
+
}
|
|
139
|
+
if (typeof candidate !== "object") {
|
|
140
|
+
throw new JsonValidationError(`Value at ${path} is not JSON serializable`, boundary, path);
|
|
141
|
+
}
|
|
142
|
+
if (active.has(candidate)) {
|
|
143
|
+
throw new JsonValidationError(`Cyclic JSON value at ${path}`, boundary, path);
|
|
144
|
+
}
|
|
145
|
+
active.add(candidate);
|
|
146
|
+
try {
|
|
147
|
+
if (Array.isArray(candidate)) {
|
|
148
|
+
return candidate.map((item, index) => visit(item, `${path}[${index}]`));
|
|
149
|
+
}
|
|
150
|
+
if (!isPlainRecord(candidate)) {
|
|
151
|
+
throw new JsonValidationError(`Value at ${path} must be a plain JSON object`, boundary, path);
|
|
152
|
+
}
|
|
153
|
+
const result = {};
|
|
154
|
+
for (const [key, item] of Object.entries(candidate)) {
|
|
155
|
+
result[key] = visit(item, `${path}.${key}`);
|
|
156
|
+
}
|
|
157
|
+
return result;
|
|
158
|
+
} finally {
|
|
159
|
+
active.delete(candidate);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
return visit(value, "$");
|
|
163
|
+
}
|
|
164
|
+
var DcbDefinitionError = class extends Error {
|
|
165
|
+
code;
|
|
166
|
+
constructor(code, message, options) {
|
|
167
|
+
super(message, options);
|
|
168
|
+
this.name = "DcbDefinitionError";
|
|
169
|
+
this.code = code;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
var CanonicalEventIdentityError = class extends DcbDefinitionError {
|
|
173
|
+
code = "CANONICAL_EVENT_IDENTITY_INVALID";
|
|
174
|
+
constructor(message) {
|
|
175
|
+
super("CANONICAL_EVENT_IDENTITY_INVALID", message);
|
|
176
|
+
this.name = "CanonicalEventIdentityError";
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
function canonicalEventKey(eventPayloadName) {
|
|
180
|
+
if (typeof eventPayloadName !== "string" || eventPayloadName.length === 0 || eventPayloadName.includes(":")) {
|
|
181
|
+
throw new CanonicalEventIdentityError("Event payload names must be non-empty and must not contain ':'");
|
|
182
|
+
}
|
|
183
|
+
return eventPayloadName;
|
|
184
|
+
}
|
|
185
|
+
function parseCanonicalEventKey(key) {
|
|
186
|
+
const canonical2 = canonicalEventKey(key);
|
|
187
|
+
if (canonical2 !== key) {
|
|
188
|
+
throw new CanonicalEventIdentityError("Event identity key is not canonical");
|
|
189
|
+
}
|
|
190
|
+
return Object.freeze({ eventPayloadName: key, key });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/eventIdentity.ts
|
|
194
|
+
var DeliveryIdentityError = class extends Error {
|
|
195
|
+
code = "DELIVERY_IDENTITY_INVALID";
|
|
196
|
+
retryable = false;
|
|
197
|
+
constructor(message) {
|
|
198
|
+
super(message);
|
|
199
|
+
this.name = "DeliveryIdentityError";
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
var MissingCanonicalEventIdentityError = class extends DeliveryIdentityError {
|
|
203
|
+
code = "MISSING_CANONICAL_EVENT_IDENTITY";
|
|
204
|
+
constructor(source) {
|
|
205
|
+
super(`G32 ${source} delivery is missing canonical event identity`);
|
|
206
|
+
this.name = "MissingCanonicalEventIdentityError";
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
function provenanceFrom(value) {
|
|
210
|
+
if (value === void 0) return void 0;
|
|
211
|
+
if (value === "g32") return value;
|
|
212
|
+
throw new DeliveryIdentityError("Unknown event identity provenance");
|
|
213
|
+
}
|
|
214
|
+
function resolveDeliveryIdentity(message, source) {
|
|
215
|
+
const provenance = provenanceFrom(message.provenance);
|
|
216
|
+
if (message.eventType === void 0) {
|
|
217
|
+
throw new MissingCanonicalEventIdentityError(source);
|
|
218
|
+
}
|
|
219
|
+
const identity = parseCanonicalEventKey(message.eventType);
|
|
220
|
+
if (provenance !== "g32") {
|
|
221
|
+
throw new DeliveryIdentityError("G32 delivery provenance is required");
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
...identity,
|
|
225
|
+
provenance: "g32",
|
|
226
|
+
legacy: false
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// src/eventRecord.ts
|
|
231
|
+
var SERIALIZED_COMMIT_CORRELATION_ID = "SerializedCommit";
|
|
232
|
+
var SERIALIZED_SEKIBAN_EXECUTOR = "SerializedSekibanExecutor";
|
|
233
|
+
var CANONICAL_UTC_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{1,7}Z$/;
|
|
234
|
+
function serializedEventMetadata(eventId) {
|
|
235
|
+
return Object.freeze({
|
|
236
|
+
causationId: eventId,
|
|
237
|
+
correlationId: SERIALIZED_COMMIT_CORRELATION_ID,
|
|
238
|
+
executedUser: SERIALIZED_SEKIBAN_EXECUTOR
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
function isRfc4122Uuid(value) {
|
|
242
|
+
return typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
|
243
|
+
}
|
|
244
|
+
function isUuidV7(value) {
|
|
245
|
+
return typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// src/store/types.ts
|
|
249
|
+
var CanonicalEventIdentityConflictError = class extends Error {
|
|
250
|
+
constructor(provider, eventId, message) {
|
|
251
|
+
super(message ?? `${provider} EventId ${eventId} conflicts with its canonical event identity`);
|
|
252
|
+
this.provider = provider;
|
|
253
|
+
this.eventId = eventId;
|
|
254
|
+
this.name = "CanonicalEventIdentityConflictError";
|
|
255
|
+
}
|
|
256
|
+
provider;
|
|
257
|
+
eventId;
|
|
258
|
+
code = "CANONICAL_EVENT_IDENTITY_CONFLICT";
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// src/store/D1EventStore.ts
|
|
262
|
+
var D1IdentityConflictError = class extends CanonicalEventIdentityConflictError {
|
|
263
|
+
beforeMutation;
|
|
264
|
+
constructor(message, beforeMutation = false) {
|
|
265
|
+
super("d1", "", message);
|
|
266
|
+
this.name = "D1IdentityConflictError";
|
|
267
|
+
this.beforeMutation = beforeMutation;
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
function asString(value, name) {
|
|
271
|
+
if (typeof value !== "string") throw new Error(`D1 ${name} was not a string`);
|
|
272
|
+
return value;
|
|
273
|
+
}
|
|
274
|
+
function nullableString(value, name) {
|
|
275
|
+
return value === null ? null : asString(value, name);
|
|
276
|
+
}
|
|
277
|
+
function asNumber(value, name) {
|
|
278
|
+
const number = typeof value === "number" ? value : Number(value);
|
|
279
|
+
if (!Number.isSafeInteger(number)) throw new Error(`D1 ${name} was not a safe integer`);
|
|
280
|
+
return number;
|
|
281
|
+
}
|
|
282
|
+
function errorText(error) {
|
|
283
|
+
return (error instanceof Error ? error.message : String(error)).slice(0, 256);
|
|
284
|
+
}
|
|
285
|
+
function sortedUnique(values) {
|
|
286
|
+
return [...new Set(values)].sort((left, right) => binaryCompare(left, right));
|
|
287
|
+
}
|
|
288
|
+
function equalStrings(left, right) {
|
|
289
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
290
|
+
}
|
|
291
|
+
function assertCompletenessEnvelope(message) {
|
|
292
|
+
const facts = message.completeness;
|
|
293
|
+
const declared = sortedUnique(message.eventTags);
|
|
294
|
+
if (!equalStrings(declared, sortedUnique(facts.declaredTagSet))) {
|
|
295
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} declared tag set differs from its source obligation`);
|
|
296
|
+
}
|
|
297
|
+
if (facts.localCommittedMembership.length !== 1) {
|
|
298
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} source obligation has no exact local membership`);
|
|
299
|
+
}
|
|
300
|
+
const membership = facts.localCommittedMembership[0];
|
|
301
|
+
if (membership.serviceId !== message.serviceId || membership.eventId !== message.eventId || membership.tag !== message.tag) {
|
|
302
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} source obligation membership differs from its delivery envelope`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function binaryCompare(left, right) {
|
|
306
|
+
const leftBytes = new TextEncoder().encode(left);
|
|
307
|
+
const rightBytes = new TextEncoder().encode(right);
|
|
308
|
+
const shared = Math.min(leftBytes.length, rightBytes.length);
|
|
309
|
+
for (let index = 0; index < shared; index += 1) {
|
|
310
|
+
const difference = leftBytes[index] - rightBytes[index];
|
|
311
|
+
if (difference !== 0) return difference;
|
|
312
|
+
}
|
|
313
|
+
return leftBytes.length - rightBytes.length;
|
|
314
|
+
}
|
|
315
|
+
function asStringArray(value, name) {
|
|
316
|
+
const decoded = typeof value === "string" ? JSON.parse(value) : value;
|
|
317
|
+
if (!Array.isArray(decoded) || !decoded.every((entry) => typeof entry === "string")) {
|
|
318
|
+
throw new Error(`D1 ${name} was not a string array`);
|
|
319
|
+
}
|
|
320
|
+
return [...decoded];
|
|
321
|
+
}
|
|
322
|
+
function eventFrom(row, arrivals) {
|
|
323
|
+
const serviceId = asString(row.service_id, "service_id");
|
|
324
|
+
const eventId = asString(row.event_id, "event_id");
|
|
325
|
+
const tags = asStringArray(row.event_tags, "event_tags");
|
|
326
|
+
return {
|
|
327
|
+
serviceId,
|
|
328
|
+
id: eventId,
|
|
329
|
+
eventId,
|
|
330
|
+
sortableUniqueId: asString(row.suid, "suid"),
|
|
331
|
+
suid: asString(row.suid, "suid"),
|
|
332
|
+
payload: asString(row.payload, "payload"),
|
|
333
|
+
tags,
|
|
334
|
+
eventTags: tags,
|
|
335
|
+
eventType: asString(row.event_type, "event_type"),
|
|
336
|
+
timestamp: asString(row.timestamp, "timestamp"),
|
|
337
|
+
causationId: nullableString(row.causation_id, "causation_id"),
|
|
338
|
+
correlationId: nullableString(row.correlation_id, "correlation_id"),
|
|
339
|
+
executedUser: nullableString(row.executed_user, "executed_user"),
|
|
340
|
+
provenance: "g32",
|
|
341
|
+
...typeof row.event_digest === "string" && row.event_digest.length > 0 ? { eventDigest: row.event_digest } : {},
|
|
342
|
+
firstArrivedAt: asNumber(row.first_arrived_at, "first_arrived_at"),
|
|
343
|
+
lastArrivedAt: asNumber(row.last_arrived_at, "last_arrived_at"),
|
|
344
|
+
maxDeliveryLagMs: asNumber(row.max_delivery_lag_ms, "max_delivery_lag_ms"),
|
|
345
|
+
arrivals: arrivals.map((arrival) => ({
|
|
346
|
+
serviceId: asString(arrival.service_id, "arrival.service_id"),
|
|
347
|
+
eventId: asString(arrival.event_id, "arrival.event_id"),
|
|
348
|
+
tag: asString(arrival.tag, "arrival.tag"),
|
|
349
|
+
enqueuedAt: asNumber(arrival.enqueued_at, "arrival.enqueued_at"),
|
|
350
|
+
arrivedAt: asNumber(arrival.arrived_at, "arrival.arrived_at"),
|
|
351
|
+
lagMs: asNumber(arrival.lag_ms, "arrival.lag_ms")
|
|
352
|
+
}))
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
function pendingFrom(row) {
|
|
356
|
+
return {
|
|
357
|
+
serviceId: asString(row.service_id, "service_id"),
|
|
358
|
+
eventId: asString(row.event_id, "event_id"),
|
|
359
|
+
attemptId: asString(row.attempt_id, "attempt_id"),
|
|
360
|
+
suid: asString(row.suid, "suid"),
|
|
361
|
+
expectedPaths: asStringArray(row.expected_paths, "expected_paths"),
|
|
362
|
+
observedPaths: asStringArray(row.observed_paths, "observed_paths"),
|
|
363
|
+
firstObservedAt: asNumber(row.first_observed_at, "first_observed_at"),
|
|
364
|
+
lagBoundMs: asNumber(row.lag_bound_ms, "lag_bound_ms")
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
function findingFrom(row) {
|
|
368
|
+
const classification = asString(row.classification, "classification");
|
|
369
|
+
if (classification !== "MISSING_STABLE" && classification !== "EXCLUDED_AUDITED" && classification !== "RESOLVED_LATE") {
|
|
370
|
+
throw new Error("D1 finding classification was invalid");
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
serviceId: asString(row.service_id, "service_id"),
|
|
374
|
+
eventId: asString(row.event_id, "event_id"),
|
|
375
|
+
path: asString(row.path, "path"),
|
|
376
|
+
classification,
|
|
377
|
+
firstObservedAt: asNumber(row.first_observed_at, "first_observed_at"),
|
|
378
|
+
lagBoundMs: asNumber(row.lag_bound_ms, "lag_bound_ms"),
|
|
379
|
+
observedAt: asNumber(row.observed_at, "observed_at")
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
function incidentClassification(value) {
|
|
383
|
+
const classification = asString(value, "classification");
|
|
384
|
+
if (classification !== "SUID_COLLISION" && classification !== "ORDER_VIOLATION" && classification !== "LINEAGE_MISMATCH" && classification !== "ORDERING_DETECTOR_UNKNOWN") {
|
|
385
|
+
throw new Error("D1 delivery incident classification was invalid");
|
|
386
|
+
}
|
|
387
|
+
return classification;
|
|
388
|
+
}
|
|
389
|
+
function optionalString(value, name) {
|
|
390
|
+
if (value === null || value === void 0) return void 0;
|
|
391
|
+
return asString(value, name);
|
|
392
|
+
}
|
|
393
|
+
function incidentFrom(row) {
|
|
394
|
+
return {
|
|
395
|
+
serviceId: asString(row.service_id, "service_id"),
|
|
396
|
+
identityKey: asString(row.identity_key, "identity_key"),
|
|
397
|
+
classification: incidentClassification(row.classification),
|
|
398
|
+
suid: optionalString(row.suid, "suid"),
|
|
399
|
+
existingEventId: optionalString(row.existing_event_id, "existing_event_id"),
|
|
400
|
+
incomingEventId: optionalString(row.incoming_event_id, "incoming_event_id"),
|
|
401
|
+
eventId: optionalString(row.event_id, "event_id"),
|
|
402
|
+
boundLineageId: optionalString(row.bound_lineage_id, "bound_lineage_id"),
|
|
403
|
+
incomingLineageId: optionalString(row.incoming_lineage_id, "incoming_lineage_id"),
|
|
404
|
+
observedAt: asNumber(row.observed_at, "observed_at")
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
function checkpointFrom(row) {
|
|
408
|
+
return {
|
|
409
|
+
serviceId: asString(row.service_id, "service_id"),
|
|
410
|
+
projectionId: asString(row.projection_id, "projection_id"),
|
|
411
|
+
lastSuid: asString(row.last_suid, "last_suid"),
|
|
412
|
+
stateJson: asString(row.state_json, "state_json"),
|
|
413
|
+
version: asNumber(row.version, "version"),
|
|
414
|
+
updatedAt: asNumber(row.updated_at, "updated_at")
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
function collisionIdentity(serviceId, suid, existingEventId, incomingEventId) {
|
|
418
|
+
const [first, second] = [existingEventId, incomingEventId].sort(binaryCompare);
|
|
419
|
+
return `SUID_COLLISION|${serviceId}|${suid}|${first}|${second}`;
|
|
420
|
+
}
|
|
421
|
+
function lineageIdentity(serviceId, boundLineageId, incomingLineageId) {
|
|
422
|
+
return `LINEAGE_MISMATCH|${serviceId}|${boundLineageId}|${incomingLineageId}`;
|
|
423
|
+
}
|
|
424
|
+
function jsonArray(values) {
|
|
425
|
+
return JSON.stringify(sortedUnique(values));
|
|
426
|
+
}
|
|
427
|
+
function metadataForDelivery(message, deliverySource) {
|
|
428
|
+
const serialized = serializedEventMetadata(message.eventId);
|
|
429
|
+
if (deliverySource !== "import") {
|
|
430
|
+
if (message.causationId !== serialized.causationId || message.correlationId !== serialized.correlationId || message.executedUser !== serialized.executedUser) {
|
|
431
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} metadata must use the serialized C# constants`);
|
|
432
|
+
}
|
|
433
|
+
return serialized;
|
|
434
|
+
}
|
|
435
|
+
const values = [message.causationId, message.correlationId, message.executedUser];
|
|
436
|
+
if (!values.every((value) => value === null || typeof value === "string")) {
|
|
437
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} import metadata must be string or null`);
|
|
438
|
+
}
|
|
439
|
+
const allNull = values.every((value) => value === null);
|
|
440
|
+
const allSerialized = message.causationId === serialized.causationId && message.correlationId === serialized.correlationId && message.executedUser === serialized.executedUser;
|
|
441
|
+
if (!allNull && !allSerialized) {
|
|
442
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} import metadata must be all null or serialized constants`);
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
causationId: message.causationId,
|
|
446
|
+
correlationId: message.correlationId,
|
|
447
|
+
executedUser: message.executedUser
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
var D1EventStore = class {
|
|
451
|
+
constructor(database, options = {}) {
|
|
452
|
+
this.database = database;
|
|
453
|
+
this.options = options;
|
|
454
|
+
}
|
|
455
|
+
database;
|
|
456
|
+
options;
|
|
457
|
+
initialized = false;
|
|
458
|
+
async initialize() {
|
|
459
|
+
if (this.initialized) return;
|
|
460
|
+
await this.database.prepare("SELECT 1 AS migration_binding").all();
|
|
461
|
+
this.initialized = true;
|
|
462
|
+
}
|
|
463
|
+
async recordDelivery(message, arrivedAt, deliverySource = "queue", attemptContext) {
|
|
464
|
+
this.ready();
|
|
465
|
+
const mutationAttemptId = `admission-${crypto.randomUUID()}`;
|
|
466
|
+
let result;
|
|
467
|
+
let failure;
|
|
468
|
+
try {
|
|
469
|
+
result = await this.recordDeliveryCore(message, arrivedAt, deliverySource, mutationAttemptId);
|
|
470
|
+
} catch (error) {
|
|
471
|
+
failure = { error };
|
|
472
|
+
}
|
|
473
|
+
const retryReason = failure === void 0 ? null : errorText(failure.error);
|
|
474
|
+
const suppressDiagnostic = failure?.error instanceof D1IdentityConflictError && failure.error.beforeMutation;
|
|
475
|
+
if (!suppressDiagnostic) {
|
|
476
|
+
const diagnostic = Promise.resolve().then(() => this.bestEffortG69AdmissionAttempt(
|
|
477
|
+
message,
|
|
478
|
+
arrivedAt,
|
|
479
|
+
deliverySource,
|
|
480
|
+
attemptContext,
|
|
481
|
+
mutationAttemptId,
|
|
482
|
+
result,
|
|
483
|
+
failure,
|
|
484
|
+
retryReason
|
|
485
|
+
));
|
|
486
|
+
if (attemptContext?.waitUntil !== void 0) attemptContext.waitUntil(diagnostic);
|
|
487
|
+
else void diagnostic;
|
|
488
|
+
}
|
|
489
|
+
if (failure !== void 0) throw failure.error;
|
|
490
|
+
return result;
|
|
491
|
+
}
|
|
492
|
+
async recordDeliveryCore(message, arrivedAt, deliverySource = "queue", mutationAttemptId = `admission-${crypto.randomUUID()}`) {
|
|
493
|
+
this.ready();
|
|
494
|
+
const requiresGlobalReceipt = deliverySource !== "import";
|
|
495
|
+
if (requiresGlobalReceipt) assertCompletenessEnvelope(message);
|
|
496
|
+
const identity = resolveDeliveryIdentity(message, deliverySource);
|
|
497
|
+
assertSortableUniqueId(message.suid);
|
|
498
|
+
const acceptsImportedId = deliverySource === "import";
|
|
499
|
+
if (!(acceptsImportedId ? isRfc4122Uuid(message.eventId) : isUuidV7(message.eventId))) {
|
|
500
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} is not an ${acceptsImportedId ? "RFC 4122 UUID" : "UUID v7"}`);
|
|
501
|
+
}
|
|
502
|
+
try {
|
|
503
|
+
JSON.parse(message.payload);
|
|
504
|
+
} catch {
|
|
505
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} payload is not JSON text`);
|
|
506
|
+
}
|
|
507
|
+
const timestamp = message.timestamp ?? new Date(arrivedAt).toISOString();
|
|
508
|
+
if (!CANONICAL_UTC_TIMESTAMP_PATTERN.test(timestamp)) {
|
|
509
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} timestamp is not canonical UTC ISO-8601`);
|
|
510
|
+
}
|
|
511
|
+
const metadata = metadataForDelivery(message, deliverySource);
|
|
512
|
+
const storedBefore = await this.eventById(message.serviceId, message.eventId);
|
|
513
|
+
const incomingEventType = identity.key;
|
|
514
|
+
const eventTags = [...message.eventTags];
|
|
515
|
+
const tagsJson = JSON.stringify(eventTags);
|
|
516
|
+
if (storedBefore !== void 0 && (storedBefore.eventType !== incomingEventType || storedBefore.suid !== message.suid || storedBefore.payload !== message.payload || JSON.stringify(storedBefore.eventTags) !== tagsJson || requiresGlobalReceipt && storedBefore.eventDigest !== void 0 && storedBefore.eventDigest !== message.completeness.eventDigest || storedBefore.timestamp !== timestamp || storedBefore.causationId !== metadata.causationId || storedBefore.correlationId !== metadata.correlationId || storedBefore.executedUser !== metadata.executedUser)) {
|
|
517
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} conflicts with its canonical event identity`, true);
|
|
518
|
+
}
|
|
519
|
+
const lagMs = Math.max(0, arrivedAt - message.enqueuedAt);
|
|
520
|
+
const statements = [
|
|
521
|
+
this.database.prepare(
|
|
522
|
+
`INSERT INTO serialized_dcb_allocator_bindings (service_id, allocator_lineage_id, bound_at)
|
|
523
|
+
VALUES (?, ?, ?)
|
|
524
|
+
ON CONFLICT (service_id) DO NOTHING`
|
|
525
|
+
).bind(message.serviceId, message.allocatorLineageId, arrivedAt),
|
|
526
|
+
// Binding mismatch is recorded in the same batch before any event path.
|
|
527
|
+
this.database.prepare(
|
|
528
|
+
`INSERT INTO serialized_dcb_delivery_incidents
|
|
529
|
+
(service_id, identity_key, classification, bound_lineage_id, incoming_lineage_id, observed_at)
|
|
530
|
+
SELECT service_id,
|
|
531
|
+
'LINEAGE_MISMATCH|' || service_id || '|' || allocator_lineage_id || '|' || ?,
|
|
532
|
+
'LINEAGE_MISMATCH', allocator_lineage_id, ?, ?
|
|
533
|
+
FROM serialized_dcb_allocator_bindings
|
|
534
|
+
WHERE service_id = ? AND allocator_lineage_id <> ?
|
|
535
|
+
ON CONFLICT (service_id, identity_key) DO NOTHING`
|
|
536
|
+
).bind(
|
|
537
|
+
message.allocatorLineageId,
|
|
538
|
+
message.allocatorLineageId,
|
|
539
|
+
arrivedAt,
|
|
540
|
+
message.serviceId,
|
|
541
|
+
message.allocatorLineageId
|
|
542
|
+
),
|
|
543
|
+
// G31 needs a target-SUID point gate even though a rejected lineage
|
|
544
|
+
// delivery has no dcb_events row. Keep this compact alias
|
|
545
|
+
// separate from the historical incident identity used by operators.
|
|
546
|
+
this.database.prepare(
|
|
547
|
+
`INSERT INTO serialized_dcb_wait_target_incidents
|
|
548
|
+
(service_id, suid, classification, event_id, observed_at)
|
|
549
|
+
SELECT ?, ?, 'LINEAGE_MISMATCH', ?, ?
|
|
550
|
+
WHERE EXISTS (
|
|
551
|
+
SELECT 1 FROM serialized_dcb_allocator_bindings
|
|
552
|
+
WHERE service_id = ? AND allocator_lineage_id <> ?
|
|
553
|
+
)
|
|
554
|
+
ON CONFLICT (service_id, suid, classification) DO NOTHING`
|
|
555
|
+
).bind(
|
|
556
|
+
message.serviceId,
|
|
557
|
+
message.suid,
|
|
558
|
+
message.eventId,
|
|
559
|
+
arrivedAt,
|
|
560
|
+
message.serviceId,
|
|
561
|
+
message.allocatorLineageId
|
|
562
|
+
),
|
|
563
|
+
// SUID collision is a typed incident rather than a UNIQUE exception.
|
|
564
|
+
this.database.prepare(
|
|
565
|
+
`INSERT INTO serialized_dcb_delivery_incidents
|
|
566
|
+
(service_id, identity_key, classification, suid, existing_event_id, incoming_event_id, observed_at)
|
|
567
|
+
SELECT "ServiceId",
|
|
568
|
+
'SUID_COLLISION|' || "ServiceId" || '|' || "SortableUniqueId" || '|' ||
|
|
569
|
+
CASE WHEN "Id" < ? THEN "Id" ELSE ? END || '|' ||
|
|
570
|
+
CASE WHEN "Id" < ? THEN ? ELSE "Id" END,
|
|
571
|
+
'SUID_COLLISION', "SortableUniqueId", "Id", ?, ?
|
|
572
|
+
FROM dcb_events
|
|
573
|
+
WHERE "ServiceId" = ? AND "SortableUniqueId" COLLATE BINARY = ? COLLATE BINARY AND "Id" <> ?
|
|
574
|
+
AND NOT EXISTS (
|
|
575
|
+
SELECT 1 FROM serialized_dcb_allocator_bindings
|
|
576
|
+
WHERE service_id = ? AND allocator_lineage_id <> ?
|
|
577
|
+
)
|
|
578
|
+
LIMIT 1
|
|
579
|
+
ON CONFLICT (service_id, identity_key) DO NOTHING`
|
|
580
|
+
).bind(
|
|
581
|
+
message.eventId,
|
|
582
|
+
message.eventId,
|
|
583
|
+
message.eventId,
|
|
584
|
+
message.eventId,
|
|
585
|
+
message.eventId,
|
|
586
|
+
arrivedAt,
|
|
587
|
+
message.serviceId,
|
|
588
|
+
message.suid,
|
|
589
|
+
message.eventId,
|
|
590
|
+
message.serviceId,
|
|
591
|
+
message.allocatorLineageId
|
|
592
|
+
),
|
|
593
|
+
this.database.prepare(
|
|
594
|
+
`INSERT INTO serialized_dcb_wait_target_incidents
|
|
595
|
+
(service_id, suid, classification, event_id, observed_at)
|
|
596
|
+
SELECT ?, ?, 'SUID_COLLISION', ?, ?
|
|
597
|
+
WHERE EXISTS (
|
|
598
|
+
SELECT 1 FROM dcb_events
|
|
599
|
+
WHERE "ServiceId" = ? AND "SortableUniqueId" COLLATE BINARY = ? COLLATE BINARY
|
|
600
|
+
AND "Id" <> ?
|
|
601
|
+
)
|
|
602
|
+
AND NOT EXISTS (
|
|
603
|
+
SELECT 1 FROM serialized_dcb_allocator_bindings
|
|
604
|
+
WHERE service_id = ? AND allocator_lineage_id <> ?
|
|
605
|
+
)
|
|
606
|
+
ON CONFLICT (service_id, suid, classification) DO NOTHING`
|
|
607
|
+
).bind(
|
|
608
|
+
message.serviceId,
|
|
609
|
+
message.suid,
|
|
610
|
+
message.eventId,
|
|
611
|
+
arrivedAt,
|
|
612
|
+
message.serviceId,
|
|
613
|
+
message.suid,
|
|
614
|
+
message.eventId,
|
|
615
|
+
message.serviceId,
|
|
616
|
+
message.allocatorLineageId
|
|
617
|
+
),
|
|
618
|
+
this.database.prepare(
|
|
619
|
+
`INSERT INTO dcb_events
|
|
620
|
+
("ServiceId", "Id", "SortableUniqueId", "EventType", "Payload", "Tags", "Timestamp", "CausationId", "CorrelationId", "ExecutedUser", "EventDigest")
|
|
621
|
+
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
622
|
+
WHERE NOT EXISTS (
|
|
623
|
+
SELECT 1 FROM serialized_dcb_allocator_bindings
|
|
624
|
+
WHERE service_id = ? AND allocator_lineage_id <> ?
|
|
625
|
+
)
|
|
626
|
+
AND NOT EXISTS (
|
|
627
|
+
SELECT 1 FROM dcb_events
|
|
628
|
+
WHERE "ServiceId" = ? AND "SortableUniqueId" COLLATE BINARY = ? COLLATE BINARY AND "Id" <> ?
|
|
629
|
+
)
|
|
630
|
+
ON CONFLICT ("ServiceId", "Id") DO NOTHING`
|
|
631
|
+
).bind(
|
|
632
|
+
message.serviceId,
|
|
633
|
+
message.eventId,
|
|
634
|
+
message.suid,
|
|
635
|
+
incomingEventType,
|
|
636
|
+
message.payload,
|
|
637
|
+
tagsJson,
|
|
638
|
+
timestamp,
|
|
639
|
+
metadata.causationId,
|
|
640
|
+
metadata.correlationId,
|
|
641
|
+
metadata.executedUser,
|
|
642
|
+
requiresGlobalReceipt ? message.completeness.eventDigest : null,
|
|
643
|
+
message.serviceId,
|
|
644
|
+
message.allocatorLineageId,
|
|
645
|
+
message.serviceId,
|
|
646
|
+
message.suid,
|
|
647
|
+
message.eventId
|
|
648
|
+
),
|
|
649
|
+
// Source discoverability is part of the same atomic global admission
|
|
650
|
+
// batch as the event, membership, and receipt. A local Tag commit can
|
|
651
|
+
// therefore return without a D1 registry probe; until this batch
|
|
652
|
+
// succeeds, G44 has no source authority for the obligation and remains
|
|
653
|
+
// fail-closed. Import delivery deliberately does not manufacture a
|
|
654
|
+
// Tag source partition.
|
|
655
|
+
this.database.prepare(
|
|
656
|
+
`INSERT INTO serialized_dcb_source_partitions
|
|
657
|
+
(service_id, partition_tag, last_obligation_sequence, registered_at)
|
|
658
|
+
SELECT ?, ?, ?, ?
|
|
659
|
+
WHERE ? = 1 AND EXISTS (
|
|
660
|
+
SELECT 1 FROM dcb_events
|
|
661
|
+
WHERE "ServiceId" = ? AND "Id" = ? AND "EventDigest" = ?
|
|
662
|
+
)
|
|
663
|
+
ON CONFLICT (service_id, partition_tag) DO UPDATE
|
|
664
|
+
SET last_obligation_sequence = MAX(
|
|
665
|
+
serialized_dcb_source_partitions.last_obligation_sequence,
|
|
666
|
+
excluded.last_obligation_sequence
|
|
667
|
+
),
|
|
668
|
+
registered_at = excluded.registered_at`
|
|
669
|
+
).bind(
|
|
670
|
+
message.serviceId,
|
|
671
|
+
message.tag,
|
|
672
|
+
message.completeness.obligationSequence,
|
|
673
|
+
arrivedAt,
|
|
674
|
+
requiresGlobalReceipt ? 1 : 0,
|
|
675
|
+
message.serviceId,
|
|
676
|
+
message.eventId,
|
|
677
|
+
message.completeness.eventDigest
|
|
678
|
+
),
|
|
679
|
+
// The global declared event, this tag-side committed membership, and
|
|
680
|
+
// the source-obligation receipt are one D1 atomic batch. A Queue ack or
|
|
681
|
+
// a receipt without this join never becomes source acknowledgement.
|
|
682
|
+
this.database.prepare(
|
|
683
|
+
`INSERT INTO serialized_dcb_global_memberships
|
|
684
|
+
(service_id, event_id, partition_tag, event_digest, committed_at)
|
|
685
|
+
SELECT ?, ?, ?, ?, ?
|
|
686
|
+
WHERE ? = 1 AND EXISTS (
|
|
687
|
+
SELECT 1 FROM dcb_events
|
|
688
|
+
WHERE "ServiceId" = ? AND "Id" = ? AND "EventDigest" = ?
|
|
689
|
+
)
|
|
690
|
+
ON CONFLICT (service_id, event_id, partition_tag) DO NOTHING`
|
|
691
|
+
).bind(
|
|
692
|
+
message.serviceId,
|
|
693
|
+
message.eventId,
|
|
694
|
+
message.tag,
|
|
695
|
+
message.completeness.eventDigest,
|
|
696
|
+
arrivedAt,
|
|
697
|
+
requiresGlobalReceipt ? 1 : 0,
|
|
698
|
+
message.serviceId,
|
|
699
|
+
message.eventId,
|
|
700
|
+
message.completeness.eventDigest
|
|
701
|
+
),
|
|
702
|
+
this.database.prepare(
|
|
703
|
+
`INSERT INTO serialized_dcb_global_receipts
|
|
704
|
+
(service_id, partition_tag, obligation_sequence, event_id, event_digest, membership_tag, received_at)
|
|
705
|
+
SELECT ?, ?, ?, ?, ?, ?, ?
|
|
706
|
+
WHERE ? = 1 AND EXISTS (
|
|
707
|
+
SELECT 1 FROM serialized_dcb_global_memberships
|
|
708
|
+
WHERE service_id = ? AND event_id = ? AND partition_tag = ? AND event_digest = ?
|
|
709
|
+
)
|
|
710
|
+
ON CONFLICT (service_id, partition_tag, obligation_sequence) DO NOTHING`
|
|
711
|
+
).bind(
|
|
712
|
+
message.serviceId,
|
|
713
|
+
message.tag,
|
|
714
|
+
message.completeness.obligationSequence,
|
|
715
|
+
message.eventId,
|
|
716
|
+
message.completeness.eventDigest,
|
|
717
|
+
message.tag,
|
|
718
|
+
arrivedAt,
|
|
719
|
+
requiresGlobalReceipt ? 1 : 0,
|
|
720
|
+
message.serviceId,
|
|
721
|
+
message.eventId,
|
|
722
|
+
message.tag,
|
|
723
|
+
message.completeness.eventDigest
|
|
724
|
+
),
|
|
725
|
+
this.database.prepare(
|
|
726
|
+
`INSERT INTO dcb_event_ops
|
|
727
|
+
("ServiceId", "Id", "AttemptId", "FirstAdmissionAttemptId", "LastAdmissionAttemptId", "AllocatorLineageId", "FirstArrivedAt", "LastArrivedAt", "MaxDeliveryLagMs", "FirstArrivedSource", "LastArrivedSource")
|
|
728
|
+
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
729
|
+
WHERE EXISTS (
|
|
730
|
+
SELECT 1 FROM dcb_events
|
|
731
|
+
WHERE "ServiceId" = ? AND "Id" = ?
|
|
732
|
+
AND "SortableUniqueId" COLLATE BINARY = ? COLLATE BINARY
|
|
733
|
+
AND "Payload" = ? AND "EventType" = ? AND "Tags" = ?
|
|
734
|
+
)
|
|
735
|
+
ON CONFLICT ("ServiceId", "Id") DO UPDATE
|
|
736
|
+
SET "FirstArrivedAt" = MIN(dcb_event_ops."FirstArrivedAt", excluded."FirstArrivedAt"),
|
|
737
|
+
"LastArrivedAt" = MAX(dcb_event_ops."LastArrivedAt", excluded."LastArrivedAt"),
|
|
738
|
+
"MaxDeliveryLagMs" = MAX(dcb_event_ops."MaxDeliveryLagMs", excluded."MaxDeliveryLagMs"),
|
|
739
|
+
"FirstAdmissionAttemptId" = COALESCE(dcb_event_ops."FirstAdmissionAttemptId", excluded."FirstAdmissionAttemptId"),
|
|
740
|
+
"LastAdmissionAttemptId" = excluded."LastAdmissionAttemptId",
|
|
741
|
+
"FirstArrivedSource" = dcb_event_ops."FirstArrivedSource",
|
|
742
|
+
"LastArrivedSource" = CASE
|
|
743
|
+
WHEN excluded."LastArrivedAt" > dcb_event_ops."LastArrivedAt" THEN excluded."LastArrivedSource"
|
|
744
|
+
ELSE dcb_event_ops."LastArrivedSource"
|
|
745
|
+
END`
|
|
746
|
+
).bind(
|
|
747
|
+
message.serviceId,
|
|
748
|
+
message.eventId,
|
|
749
|
+
message.attemptId,
|
|
750
|
+
mutationAttemptId,
|
|
751
|
+
mutationAttemptId,
|
|
752
|
+
message.allocatorLineageId,
|
|
753
|
+
arrivedAt,
|
|
754
|
+
arrivedAt,
|
|
755
|
+
lagMs,
|
|
756
|
+
deliverySource,
|
|
757
|
+
deliverySource,
|
|
758
|
+
message.serviceId,
|
|
759
|
+
message.eventId,
|
|
760
|
+
message.suid,
|
|
761
|
+
message.payload,
|
|
762
|
+
incomingEventType,
|
|
763
|
+
tagsJson
|
|
764
|
+
),
|
|
765
|
+
this.database.prepare(
|
|
766
|
+
`INSERT INTO serialized_dcb_event_arrivals
|
|
767
|
+
(service_id, event_id, tag, enqueued_at, arrived_at, lag_ms)
|
|
768
|
+
SELECT ?, ?, ?, ?, ?, ?
|
|
769
|
+
WHERE EXISTS (
|
|
770
|
+
SELECT 1 FROM dcb_events
|
|
771
|
+
WHERE "ServiceId" = ? AND "Id" = ? AND "SortableUniqueId" COLLATE BINARY = ? COLLATE BINARY
|
|
772
|
+
AND "Payload" = ? AND "EventType" = ? AND "Tags" = ?
|
|
773
|
+
)
|
|
774
|
+
AND NOT EXISTS (
|
|
775
|
+
SELECT 1 FROM serialized_dcb_allocator_bindings
|
|
776
|
+
WHERE service_id = ? AND allocator_lineage_id <> ?
|
|
777
|
+
)
|
|
778
|
+
AND NOT EXISTS (
|
|
779
|
+
SELECT 1 FROM dcb_events collision
|
|
780
|
+
WHERE collision."ServiceId" = ? AND collision."SortableUniqueId" COLLATE BINARY = ? COLLATE BINARY
|
|
781
|
+
AND collision."Id" <> ?
|
|
782
|
+
)
|
|
783
|
+
ON CONFLICT (service_id, event_id, tag) DO UPDATE
|
|
784
|
+
SET enqueued_at = MIN(serialized_dcb_event_arrivals.enqueued_at, excluded.enqueued_at),
|
|
785
|
+
arrived_at = MAX(serialized_dcb_event_arrivals.arrived_at, excluded.arrived_at),
|
|
786
|
+
lag_ms = MAX(serialized_dcb_event_arrivals.lag_ms, excluded.lag_ms)`
|
|
787
|
+
).bind(
|
|
788
|
+
message.serviceId,
|
|
789
|
+
message.eventId,
|
|
790
|
+
message.tag,
|
|
791
|
+
message.enqueuedAt,
|
|
792
|
+
arrivedAt,
|
|
793
|
+
lagMs,
|
|
794
|
+
message.serviceId,
|
|
795
|
+
message.eventId,
|
|
796
|
+
message.suid,
|
|
797
|
+
message.payload,
|
|
798
|
+
incomingEventType,
|
|
799
|
+
tagsJson,
|
|
800
|
+
message.serviceId,
|
|
801
|
+
message.allocatorLineageId,
|
|
802
|
+
message.serviceId,
|
|
803
|
+
message.suid,
|
|
804
|
+
message.eventId
|
|
805
|
+
),
|
|
806
|
+
this.database.prepare(
|
|
807
|
+
`INSERT INTO serialized_dcb_lag_estimates (service_id, estimate_ms, observed_at)
|
|
808
|
+
SELECT ?, ?, ?
|
|
809
|
+
WHERE ? = 'queue'
|
|
810
|
+
AND NOT EXISTS (
|
|
811
|
+
SELECT 1 FROM serialized_dcb_allocator_bindings
|
|
812
|
+
WHERE service_id = ? AND allocator_lineage_id <> ?
|
|
813
|
+
)
|
|
814
|
+
AND NOT EXISTS (
|
|
815
|
+
SELECT 1 FROM dcb_events collision
|
|
816
|
+
WHERE collision."ServiceId" = ? AND collision."SortableUniqueId" COLLATE BINARY = ? COLLATE BINARY
|
|
817
|
+
AND collision."Id" <> ?
|
|
818
|
+
)
|
|
819
|
+
AND NOT EXISTS (
|
|
820
|
+
SELECT 1 FROM dcb_events contradictory
|
|
821
|
+
WHERE contradictory."ServiceId" = ? AND contradictory."Id" = ?
|
|
822
|
+
AND (contradictory."SortableUniqueId" COLLATE BINARY <> ? COLLATE BINARY
|
|
823
|
+
OR contradictory."Payload" <> ? OR contradictory."EventType" <> ?
|
|
824
|
+
OR contradictory."Tags" <> ?)
|
|
825
|
+
)
|
|
826
|
+
AND NOT EXISTS (
|
|
827
|
+
SELECT 1 FROM dcb_events prior
|
|
828
|
+
WHERE prior."ServiceId" = ? AND prior."SortableUniqueId" COLLATE BINARY > ? COLLATE BINARY
|
|
829
|
+
)
|
|
830
|
+
ON CONFLICT (service_id) DO UPDATE
|
|
831
|
+
SET estimate_ms = MAX(
|
|
832
|
+
MAX(serialized_dcb_lag_estimates.estimate_ms -
|
|
833
|
+
MAX(0, excluded.observed_at - serialized_dcb_lag_estimates.observed_at), 0),
|
|
834
|
+
excluded.estimate_ms
|
|
835
|
+
),
|
|
836
|
+
observed_at = excluded.observed_at`
|
|
837
|
+
).bind(
|
|
838
|
+
message.serviceId,
|
|
839
|
+
lagMs,
|
|
840
|
+
arrivedAt,
|
|
841
|
+
deliverySource,
|
|
842
|
+
message.serviceId,
|
|
843
|
+
message.allocatorLineageId,
|
|
844
|
+
message.serviceId,
|
|
845
|
+
message.suid,
|
|
846
|
+
message.eventId,
|
|
847
|
+
message.serviceId,
|
|
848
|
+
message.eventId,
|
|
849
|
+
message.suid,
|
|
850
|
+
message.payload,
|
|
851
|
+
incomingEventType,
|
|
852
|
+
tagsJson,
|
|
853
|
+
message.serviceId,
|
|
854
|
+
message.suid
|
|
855
|
+
)
|
|
856
|
+
];
|
|
857
|
+
await this.batch("recordDelivery", statements);
|
|
858
|
+
const binding = await this.lineageBinding(message.serviceId);
|
|
859
|
+
if (binding !== void 0 && binding !== message.allocatorLineageId) {
|
|
860
|
+
const incident = await this.incidentByIdentity(
|
|
861
|
+
message.serviceId,
|
|
862
|
+
lineageIdentity(message.serviceId, binding, message.allocatorLineageId)
|
|
863
|
+
);
|
|
864
|
+
if (incident === void 0) throw new Error("D1 lineage incident was not durable");
|
|
865
|
+
return { outcome: "lineage-mismatch", kind: "lineage-mismatch", incident };
|
|
866
|
+
}
|
|
867
|
+
const collision = await this.collisionEvent(message.serviceId, message.suid, message.eventId);
|
|
868
|
+
if (collision !== void 0) {
|
|
869
|
+
const identity2 = collisionIdentity(message.serviceId, message.suid, collision, message.eventId);
|
|
870
|
+
const incident = await this.incidentByIdentity(message.serviceId, identity2);
|
|
871
|
+
if (incident === void 0) throw new Error("D1 SUID collision incident was not durable");
|
|
872
|
+
return { outcome: "suid-collision", kind: "suid-collision", incident };
|
|
873
|
+
}
|
|
874
|
+
const stored = await this.eventById(message.serviceId, message.eventId);
|
|
875
|
+
if (stored === void 0) throw new Error("D1 event delivery did not produce a durable row");
|
|
876
|
+
if (stored.suid !== message.suid || stored.payload !== message.payload || JSON.stringify(stored.eventTags) !== tagsJson || stored.eventType !== incomingEventType || requiresGlobalReceipt && stored.eventDigest !== message.completeness.eventDigest || stored.timestamp !== timestamp || stored.causationId !== metadata.causationId || stored.correlationId !== metadata.correlationId || stored.executedUser !== metadata.executedUser) {
|
|
877
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} conflicts with its durable D1 identity`);
|
|
878
|
+
}
|
|
879
|
+
if (requiresGlobalReceipt) {
|
|
880
|
+
const receipt = await this.readGlobalReceiptJoin(message);
|
|
881
|
+
if (receipt === void 0) {
|
|
882
|
+
throw new Error(`D1 event ${message.eventId} has no readable global receipt/membership join`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
return {
|
|
886
|
+
outcome: "stored",
|
|
887
|
+
kind: "stored",
|
|
888
|
+
event: stored,
|
|
889
|
+
mutationEvidence: "unverified"
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
async readGlobalReceiptJoin(message) {
|
|
893
|
+
this.ready();
|
|
894
|
+
const row = await this.database.prepare(
|
|
895
|
+
`SELECT receipt.service_id, receipt.partition_tag, receipt.obligation_sequence,
|
|
896
|
+
receipt.event_id, receipt.event_digest, receipt.membership_tag, receipt.received_at
|
|
897
|
+
FROM serialized_dcb_global_receipts AS receipt
|
|
898
|
+
JOIN serialized_dcb_global_memberships AS membership
|
|
899
|
+
ON membership.service_id = receipt.service_id
|
|
900
|
+
AND membership.event_id = receipt.event_id
|
|
901
|
+
AND membership.partition_tag = receipt.membership_tag
|
|
902
|
+
AND membership.event_digest = receipt.event_digest
|
|
903
|
+
JOIN dcb_events AS event
|
|
904
|
+
ON event."ServiceId" = receipt.service_id
|
|
905
|
+
AND event."Id" = receipt.event_id
|
|
906
|
+
AND event."EventDigest" = receipt.event_digest
|
|
907
|
+
WHERE receipt.service_id = ?
|
|
908
|
+
AND receipt.partition_tag = ?
|
|
909
|
+
AND receipt.obligation_sequence = ?
|
|
910
|
+
AND receipt.event_id = ?
|
|
911
|
+
AND receipt.event_digest = ?
|
|
912
|
+
AND receipt.membership_tag = ?`
|
|
913
|
+
).bind(
|
|
914
|
+
message.serviceId,
|
|
915
|
+
message.tag,
|
|
916
|
+
message.completeness.obligationSequence,
|
|
917
|
+
message.eventId,
|
|
918
|
+
message.completeness.eventDigest,
|
|
919
|
+
message.tag
|
|
920
|
+
).first();
|
|
921
|
+
if (row === null || row === void 0) return void 0;
|
|
922
|
+
return {
|
|
923
|
+
serviceId: asString(row.service_id, "global_receipt.service_id"),
|
|
924
|
+
partitionTag: asString(row.partition_tag, "global_receipt.partition_tag"),
|
|
925
|
+
obligationSequence: asNumber(row.obligation_sequence, "global_receipt.obligation_sequence"),
|
|
926
|
+
eventId: asString(row.event_id, "global_receipt.event_id"),
|
|
927
|
+
eventDigest: asString(row.event_digest, "global_receipt.event_digest"),
|
|
928
|
+
membershipTag: asString(row.membership_tag, "global_receipt.membership_tag"),
|
|
929
|
+
receivedAt: asNumber(row.received_at, "global_receipt.received_at")
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
async readAllEvents(serviceId, since) {
|
|
933
|
+
this.ready();
|
|
934
|
+
if (since.length !== 0) assertSortableUniqueId(since);
|
|
935
|
+
const rows = await this.rows(
|
|
936
|
+
`SELECT e."ServiceId" AS service_id, e."Id" AS event_id,
|
|
937
|
+
e."SortableUniqueId" AS suid, e."Payload" AS payload,
|
|
938
|
+
e."EventType" AS event_type, e."Tags" AS event_tags,
|
|
939
|
+
e."EventDigest" AS event_digest,
|
|
940
|
+
e."Timestamp" AS timestamp, e."CausationId" AS causation_id,
|
|
941
|
+
e."CorrelationId" AS correlation_id, e."ExecutedUser" AS executed_user,
|
|
942
|
+
COALESCE(o."FirstArrivedAt", 0) AS first_arrived_at,
|
|
943
|
+
COALESCE(o."LastArrivedAt", 0) AS last_arrived_at,
|
|
944
|
+
COALESCE(o."MaxDeliveryLagMs", 0) AS max_delivery_lag_ms
|
|
945
|
+
FROM dcb_events e LEFT JOIN dcb_event_ops o
|
|
946
|
+
ON o."ServiceId" = e."ServiceId" AND o."Id" = e."Id"
|
|
947
|
+
WHERE e."ServiceId" = ? AND e."SortableUniqueId" COLLATE BINARY > ? COLLATE BINARY
|
|
948
|
+
ORDER BY e."SortableUniqueId" COLLATE BINARY ASC, e."Id" COLLATE BINARY ASC`,
|
|
949
|
+
serviceId,
|
|
950
|
+
since
|
|
951
|
+
);
|
|
952
|
+
return Promise.all(rows.map((row) => this.eventFromRow(row)));
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* G69 strict-order detector: find an event admitted after a projection
|
|
956
|
+
* checkpoint whose opaque SUID is lower than that checkpoint. A replay of
|
|
957
|
+
* an event already known before the checkpoint is deliberately excluded by
|
|
958
|
+
* FirstArrivedAt, so the detector reports new lower-SUID admission rather
|
|
959
|
+
* than ordinary Queue redelivery.
|
|
960
|
+
*/
|
|
961
|
+
async findLateLowerSuid(serviceId, checkpointSuid, checkpointUpdatedAt) {
|
|
962
|
+
const evidence = await this.findLateLowerSuidEvidence(serviceId, checkpointSuid, checkpointUpdatedAt);
|
|
963
|
+
return evidence.kind === "late-lower-suid" ? evidence.event : void 0;
|
|
964
|
+
}
|
|
965
|
+
async findLateLowerSuidEvidence(serviceId, checkpointSuid, checkpointUpdatedAt, generation) {
|
|
966
|
+
const detectorStartedAt = Date.now();
|
|
967
|
+
const finish = (result) => ({ ...result, durationMs: Math.max(0, Date.now() - detectorStartedAt) });
|
|
968
|
+
this.ready();
|
|
969
|
+
if (generation !== void 0 && (!Number.isSafeInteger(generation) || generation < 0)) {
|
|
970
|
+
return finish({ kind: "unknown", reason: "generation-invalid" });
|
|
971
|
+
}
|
|
972
|
+
if (checkpointSuid.length === 0) return finish({ kind: "miss" });
|
|
973
|
+
if (!Number.isSafeInteger(checkpointUpdatedAt) || checkpointUpdatedAt < 0) {
|
|
974
|
+
return finish({ kind: "unknown", reason: "checkpoint-clock-invalid" });
|
|
975
|
+
}
|
|
976
|
+
try {
|
|
977
|
+
assertSortableUniqueId(checkpointSuid);
|
|
978
|
+
const proven = await this.database.prepare(
|
|
979
|
+
`SELECT e."Id" AS event_id
|
|
980
|
+
FROM dcb_events e
|
|
981
|
+
JOIN dcb_event_ops o
|
|
982
|
+
ON o."ServiceId" = e."ServiceId" AND o."Id" = e."Id"
|
|
983
|
+
WHERE e."ServiceId" = ?
|
|
984
|
+
AND e."SortableUniqueId" COLLATE BINARY < ? COLLATE BINARY
|
|
985
|
+
AND o."FirstArrivedAt" > ?
|
|
986
|
+
AND o."FirstArrivedAt" >= 0
|
|
987
|
+
AND o."LastArrivedAt" >= o."FirstArrivedAt"
|
|
988
|
+
AND o."FirstArrivedSource" IN ('queue', 'fast')
|
|
989
|
+
ORDER BY e."SortableUniqueId" COLLATE BINARY ASC, e."Id" COLLATE BINARY ASC
|
|
990
|
+
LIMIT 1`
|
|
991
|
+
).bind(serviceId, checkpointSuid, checkpointUpdatedAt).first();
|
|
992
|
+
if (proven !== null && proven !== void 0) {
|
|
993
|
+
const event2 = await this.eventById(serviceId, asString(proven.event_id, "late_lower_suid.event_id"));
|
|
994
|
+
return event2 === void 0 ? finish({ kind: "unknown", reason: "late-event-row-disappeared" }) : finish({ kind: "late-lower-suid", event: event2 });
|
|
995
|
+
}
|
|
996
|
+
const uncertain = await this.database.prepare(
|
|
997
|
+
`SELECT o."FirstArrivedAt" AS first_arrived_at,
|
|
998
|
+
o."LastArrivedAt" AS last_arrived_at,
|
|
999
|
+
o."FirstArrivedSource" AS first_arrived_source
|
|
1000
|
+
FROM dcb_events e
|
|
1001
|
+
JOIN dcb_event_ops o
|
|
1002
|
+
ON o."ServiceId" = e."ServiceId" AND o."Id" = e."Id"
|
|
1003
|
+
WHERE e."ServiceId" = ?
|
|
1004
|
+
AND e."SortableUniqueId" COLLATE BINARY < ? COLLATE BINARY
|
|
1005
|
+
AND (
|
|
1006
|
+
o."FirstArrivedAt" < 0
|
|
1007
|
+
OR o."LastArrivedAt" < o."FirstArrivedAt"
|
|
1008
|
+
OR (
|
|
1009
|
+
o."FirstArrivedAt" > ?
|
|
1010
|
+
AND COALESCE(o."FirstArrivedSource", 'unknown') NOT IN ('queue', 'fast')
|
|
1011
|
+
)
|
|
1012
|
+
)
|
|
1013
|
+
ORDER BY e."SortableUniqueId" COLLATE BINARY ASC, e."Id" COLLATE BINARY ASC
|
|
1014
|
+
LIMIT 1`
|
|
1015
|
+
).bind(serviceId, checkpointSuid, checkpointUpdatedAt).first();
|
|
1016
|
+
if (uncertain !== null && uncertain !== void 0) {
|
|
1017
|
+
const first = asNumber(uncertain.first_arrived_at, "late_lower_suid.first_arrived_at");
|
|
1018
|
+
const last = asNumber(uncertain.last_arrived_at, "late_lower_suid.last_arrived_at");
|
|
1019
|
+
return finish({
|
|
1020
|
+
kind: "unknown",
|
|
1021
|
+
reason: first < 0 || last < first ? "arrival-clock-rollback" : "arrival-provenance-untrusted"
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
const replay = await this.database.prepare(
|
|
1025
|
+
`SELECT e."Id" AS event_id
|
|
1026
|
+
FROM dcb_events e
|
|
1027
|
+
JOIN dcb_event_ops o
|
|
1028
|
+
ON o."ServiceId" = e."ServiceId" AND o."Id" = e."Id"
|
|
1029
|
+
WHERE e."ServiceId" = ?
|
|
1030
|
+
AND e."SortableUniqueId" COLLATE BINARY < ? COLLATE BINARY
|
|
1031
|
+
AND o."FirstArrivedAt" <= ?
|
|
1032
|
+
AND o."LastArrivedAt" > ?
|
|
1033
|
+
AND o."LastArrivedSource" IN ('queue', 'fast')
|
|
1034
|
+
ORDER BY o."LastArrivedAt" DESC,
|
|
1035
|
+
e."SortableUniqueId" COLLATE BINARY ASC,
|
|
1036
|
+
e."Id" COLLATE BINARY ASC
|
|
1037
|
+
LIMIT 1`
|
|
1038
|
+
).bind(serviceId, checkpointSuid, checkpointUpdatedAt, checkpointUpdatedAt).first();
|
|
1039
|
+
if (replay === null || replay === void 0) return finish({ kind: "miss" });
|
|
1040
|
+
const event = await this.eventById(serviceId, asString(replay.event_id, "replay.event_id"));
|
|
1041
|
+
return event === void 0 ? finish({ kind: "unknown", reason: "replay-event-row-disappeared" }) : finish({ kind: "replay", event });
|
|
1042
|
+
} catch (error) {
|
|
1043
|
+
return finish({ kind: "unknown", reason: errorText(error) });
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* G31 source proof: one indexed target-SUID lookup distinguishes pending,
|
|
1048
|
+
* unique stored, and contradictory/incident states. It intentionally never
|
|
1049
|
+
* delegates to readAllEvents, which would make waitFor history-size bound.
|
|
1050
|
+
*/
|
|
1051
|
+
async readWaitForTarget(serviceId, suid) {
|
|
1052
|
+
this.ready();
|
|
1053
|
+
assertSortableUniqueId(suid);
|
|
1054
|
+
const row = await this.database.prepare(
|
|
1055
|
+
`WITH target AS (
|
|
1056
|
+
SELECT "Id" AS event_id
|
|
1057
|
+
FROM dcb_events
|
|
1058
|
+
WHERE "ServiceId" = ? AND "SortableUniqueId" COLLATE BINARY = ? COLLATE BINARY
|
|
1059
|
+
ORDER BY "Id" COLLATE BINARY ASC
|
|
1060
|
+
LIMIT 2
|
|
1061
|
+
)
|
|
1062
|
+
SELECT
|
|
1063
|
+
(SELECT COUNT(*) FROM target) AS target_count,
|
|
1064
|
+
(SELECT event_id FROM target ORDER BY event_id COLLATE BINARY ASC LIMIT 1) AS event_id,
|
|
1065
|
+
CASE WHEN EXISTS (
|
|
1066
|
+
SELECT 1
|
|
1067
|
+
FROM serialized_dcb_wait_target_incidents incident
|
|
1068
|
+
WHERE incident.service_id = ? AND incident.suid COLLATE BINARY = ? COLLATE BINARY
|
|
1069
|
+
AND incident.classification IN ('SUID_COLLISION', 'LINEAGE_MISMATCH')
|
|
1070
|
+
) OR EXISTS (
|
|
1071
|
+
SELECT 1
|
|
1072
|
+
FROM serialized_dcb_delivery_incidents incident
|
|
1073
|
+
WHERE incident.service_id = ? AND incident.suid COLLATE BINARY = ? COLLATE BINARY
|
|
1074
|
+
AND incident.classification = 'SUID_COLLISION'
|
|
1075
|
+
) THEN 1 ELSE 0 END AS target_incident`
|
|
1076
|
+
).bind(serviceId, suid, serviceId, suid, serviceId, suid).first();
|
|
1077
|
+
if (row === null || row === void 0) throw new Error("D1 wait target lookup returned no row");
|
|
1078
|
+
if (asNumber(row.target_incident, "target_incident") === 1) {
|
|
1079
|
+
return { kind: "unavailable", reason: "incident" };
|
|
1080
|
+
}
|
|
1081
|
+
const count = asNumber(row.target_count, "target_count");
|
|
1082
|
+
if (count === 0) return { kind: "pending" };
|
|
1083
|
+
if (count !== 1 || typeof row.event_id !== "string" || row.event_id.length === 0) {
|
|
1084
|
+
return { kind: "unavailable", reason: "suid-contradiction" };
|
|
1085
|
+
}
|
|
1086
|
+
return { kind: "stored", eventId: row.event_id, suid };
|
|
1087
|
+
}
|
|
1088
|
+
async currentLagBound(serviceId, nowMs) {
|
|
1089
|
+
this.ready();
|
|
1090
|
+
const rows = await this.rows(
|
|
1091
|
+
`SELECT estimate_ms, observed_at FROM serialized_dcb_lag_estimates WHERE service_id = ?`,
|
|
1092
|
+
serviceId
|
|
1093
|
+
);
|
|
1094
|
+
const row = rows[0];
|
|
1095
|
+
if (row === void 0) return 0;
|
|
1096
|
+
const estimate = asNumber(row.estimate_ms, "estimate_ms");
|
|
1097
|
+
const observedAt = asNumber(row.observed_at, "observed_at");
|
|
1098
|
+
const decayNow = nowMs !== void 0 && nowMs >= 1e11 && observedAt >= 1e11 ? nowMs : observedAt;
|
|
1099
|
+
return decayedLagEstimateMs(estimate, observedAt, decayNow);
|
|
1100
|
+
}
|
|
1101
|
+
async lagBoundDiagnostics(serviceId, nowMs) {
|
|
1102
|
+
this.ready();
|
|
1103
|
+
const rows = await this.rows(
|
|
1104
|
+
`SELECT estimate_ms, observed_at FROM serialized_dcb_lag_estimates WHERE service_id = ?`,
|
|
1105
|
+
serviceId
|
|
1106
|
+
);
|
|
1107
|
+
const row = rows[0];
|
|
1108
|
+
if (row === void 0) {
|
|
1109
|
+
return { serviceIdUsed: serviceId, dynamicLagBoundMs: 0, rowFound: false, rawEstimateMs: null, rawObservedAt: null, nowMs };
|
|
1110
|
+
}
|
|
1111
|
+
const rawEstimateMs = asNumber(row.estimate_ms, "estimate_ms");
|
|
1112
|
+
const rawObservedAt = asNumber(row.observed_at, "observed_at");
|
|
1113
|
+
const decayNow = nowMs >= 1e11 && rawObservedAt >= 1e11 ? nowMs : rawObservedAt;
|
|
1114
|
+
return {
|
|
1115
|
+
serviceIdUsed: serviceId,
|
|
1116
|
+
dynamicLagBoundMs: decayedLagEstimateMs(rawEstimateMs, rawObservedAt, decayNow),
|
|
1117
|
+
rowFound: true,
|
|
1118
|
+
rawEstimateMs,
|
|
1119
|
+
rawObservedAt,
|
|
1120
|
+
nowMs
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
async listProjectionTags(serviceId) {
|
|
1124
|
+
this.ready();
|
|
1125
|
+
const rows = await this.rows(
|
|
1126
|
+
`SELECT DISTINCT membership.value AS tag
|
|
1127
|
+
FROM dcb_events event, json_each(event."Tags") membership
|
|
1128
|
+
WHERE event."ServiceId" = ?
|
|
1129
|
+
ORDER BY tag COLLATE BINARY ASC`,
|
|
1130
|
+
serviceId
|
|
1131
|
+
);
|
|
1132
|
+
return rows.map((row) => asString(row.tag, "tag"));
|
|
1133
|
+
}
|
|
1134
|
+
async readProjectionCheckpoint(serviceId, projectionId) {
|
|
1135
|
+
this.ready();
|
|
1136
|
+
const rows = await this.rows(
|
|
1137
|
+
`SELECT service_id, projection_id, last_suid, state_json, version, updated_at
|
|
1138
|
+
FROM serialized_dcb_projection_checkpoints
|
|
1139
|
+
WHERE service_id = ? AND projection_id = ?`,
|
|
1140
|
+
serviceId,
|
|
1141
|
+
projectionId
|
|
1142
|
+
);
|
|
1143
|
+
return rows[0] === void 0 ? void 0 : checkpointFrom(rows[0]);
|
|
1144
|
+
}
|
|
1145
|
+
async advanceProjectionCheckpoint(input) {
|
|
1146
|
+
this.ready();
|
|
1147
|
+
const statement = this.database.prepare(
|
|
1148
|
+
`INSERT INTO serialized_dcb_projection_checkpoints
|
|
1149
|
+
(service_id, projection_id, last_suid, state_json, version, updated_at)
|
|
1150
|
+
SELECT ?, ?, ?, ?, ?, ?
|
|
1151
|
+
WHERE ? IS NULL OR EXISTS (
|
|
1152
|
+
SELECT 1 FROM serialized_dcb_projection_checkpoints current
|
|
1153
|
+
WHERE current.service_id = ? AND current.projection_id = ?
|
|
1154
|
+
AND current.last_suid COLLATE BINARY = ? COLLATE BINARY
|
|
1155
|
+
)
|
|
1156
|
+
ON CONFLICT (service_id, projection_id) DO UPDATE
|
|
1157
|
+
SET last_suid = excluded.last_suid,
|
|
1158
|
+
state_json = excluded.state_json,
|
|
1159
|
+
version = excluded.version,
|
|
1160
|
+
updated_at = excluded.updated_at
|
|
1161
|
+
WHERE serialized_dcb_projection_checkpoints.last_suid COLLATE BINARY = ? COLLATE BINARY`
|
|
1162
|
+
).bind(
|
|
1163
|
+
input.serviceId,
|
|
1164
|
+
input.projectionId,
|
|
1165
|
+
input.lastSuid,
|
|
1166
|
+
input.stateJson,
|
|
1167
|
+
input.version,
|
|
1168
|
+
input.updatedAt,
|
|
1169
|
+
input.expectedLastSuid,
|
|
1170
|
+
input.serviceId,
|
|
1171
|
+
input.projectionId,
|
|
1172
|
+
input.expectedLastSuid,
|
|
1173
|
+
input.expectedLastSuid
|
|
1174
|
+
);
|
|
1175
|
+
const result = await this.write("advanceProjectionCheckpoint", statement).run();
|
|
1176
|
+
return result.meta.changes === 1;
|
|
1177
|
+
}
|
|
1178
|
+
async projectionLag(serviceId, projectionId, tag) {
|
|
1179
|
+
this.ready();
|
|
1180
|
+
const checkpoint = await this.readProjectionCheckpoint(serviceId, projectionId);
|
|
1181
|
+
const checkpointSuid = checkpoint?.lastSuid ?? "";
|
|
1182
|
+
const rows = await this.rows(
|
|
1183
|
+
`SELECT
|
|
1184
|
+
COALESCE((SELECT event."SortableUniqueId"
|
|
1185
|
+
FROM dcb_events event, json_each(event."Tags") membership
|
|
1186
|
+
WHERE event."ServiceId" = ? AND membership.value = ?
|
|
1187
|
+
ORDER BY event."SortableUniqueId" COLLATE BINARY DESC LIMIT 1), '') AS head_suid,
|
|
1188
|
+
(SELECT COUNT(*)
|
|
1189
|
+
FROM dcb_events event, json_each(event."Tags") membership
|
|
1190
|
+
WHERE event."ServiceId" = ? AND membership.value = ?
|
|
1191
|
+
AND event."SortableUniqueId" COLLATE BINARY > ? COLLATE BINARY) AS behind_events`,
|
|
1192
|
+
serviceId,
|
|
1193
|
+
tag,
|
|
1194
|
+
serviceId,
|
|
1195
|
+
tag,
|
|
1196
|
+
checkpointSuid
|
|
1197
|
+
);
|
|
1198
|
+
const row = rows[0] ?? {};
|
|
1199
|
+
return {
|
|
1200
|
+
serviceId,
|
|
1201
|
+
projectionId,
|
|
1202
|
+
tag,
|
|
1203
|
+
checkpointSuid,
|
|
1204
|
+
headSuid: asString(row.head_suid ?? "", "head_suid"),
|
|
1205
|
+
behindEvents: asNumber(row.behind_events ?? 0, "behind_events")
|
|
1206
|
+
};
|
|
1207
|
+
}
|
|
1208
|
+
async upsertPending(message, firstObservedAt, lagBoundMs) {
|
|
1209
|
+
this.ready();
|
|
1210
|
+
const expectedJson = jsonArray(message.eventTags);
|
|
1211
|
+
const observedJson = jsonArray([message.tag]);
|
|
1212
|
+
const statements = [
|
|
1213
|
+
this.database.prepare(
|
|
1214
|
+
`INSERT INTO serialized_dcb_pending_arrivals
|
|
1215
|
+
(service_id, event_id, attempt_id, suid, expected_paths, observed_paths, first_observed_at, lag_bound_ms)
|
|
1216
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1217
|
+
ON CONFLICT (service_id, event_id) DO NOTHING`
|
|
1218
|
+
).bind(
|
|
1219
|
+
message.serviceId,
|
|
1220
|
+
message.eventId,
|
|
1221
|
+
message.attemptId,
|
|
1222
|
+
message.suid,
|
|
1223
|
+
expectedJson,
|
|
1224
|
+
observedJson,
|
|
1225
|
+
firstObservedAt,
|
|
1226
|
+
lagBoundMs
|
|
1227
|
+
),
|
|
1228
|
+
// JSON1 performs the set union inside the atomic UPDATE. There is no
|
|
1229
|
+
// TypeScript read/merge/write window under concurrent deliveries.
|
|
1230
|
+
this.database.prepare(
|
|
1231
|
+
`UPDATE serialized_dcb_pending_arrivals AS pending
|
|
1232
|
+
SET expected_paths = COALESCE((
|
|
1233
|
+
SELECT json_group_array(value) FROM (
|
|
1234
|
+
SELECT value FROM json_each(pending.expected_paths)
|
|
1235
|
+
UNION
|
|
1236
|
+
SELECT value FROM json_each(?)
|
|
1237
|
+
ORDER BY value COLLATE BINARY
|
|
1238
|
+
)
|
|
1239
|
+
), '[]'),
|
|
1240
|
+
observed_paths = COALESCE((
|
|
1241
|
+
SELECT json_group_array(value) FROM (
|
|
1242
|
+
SELECT value FROM json_each(pending.observed_paths)
|
|
1243
|
+
UNION
|
|
1244
|
+
SELECT value FROM json_each(?)
|
|
1245
|
+
ORDER BY value COLLATE BINARY
|
|
1246
|
+
)
|
|
1247
|
+
), '[]'),
|
|
1248
|
+
lag_bound_ms = MAX(lag_bound_ms, ?)
|
|
1249
|
+
WHERE service_id = ? AND event_id = ? AND attempt_id = ?
|
|
1250
|
+
AND suid COLLATE BINARY = ? COLLATE BINARY`
|
|
1251
|
+
).bind(
|
|
1252
|
+
expectedJson,
|
|
1253
|
+
observedJson,
|
|
1254
|
+
lagBoundMs,
|
|
1255
|
+
message.serviceId,
|
|
1256
|
+
message.eventId,
|
|
1257
|
+
message.attemptId,
|
|
1258
|
+
message.suid
|
|
1259
|
+
)
|
|
1260
|
+
];
|
|
1261
|
+
await this.batch("upsertPending", statements);
|
|
1262
|
+
const pending = await this.pendingById(message.serviceId, message.eventId);
|
|
1263
|
+
if (pending === void 0) throw new Error("D1 pending arrival was not persisted");
|
|
1264
|
+
if (pending.attemptId !== message.attemptId || pending.suid !== message.suid) {
|
|
1265
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} has contradictory pending-arrival identity`);
|
|
1266
|
+
}
|
|
1267
|
+
return pending;
|
|
1268
|
+
}
|
|
1269
|
+
async listPending(serviceId) {
|
|
1270
|
+
this.ready();
|
|
1271
|
+
const rows = serviceId === void 0 ? await this.rows(
|
|
1272
|
+
`SELECT service_id, event_id, attempt_id, suid, expected_paths, observed_paths, first_observed_at, lag_bound_ms
|
|
1273
|
+
FROM serialized_dcb_pending_arrivals
|
|
1274
|
+
ORDER BY service_id COLLATE BINARY ASC, event_id COLLATE BINARY ASC`
|
|
1275
|
+
) : await this.rows(
|
|
1276
|
+
`SELECT service_id, event_id, attempt_id, suid, expected_paths, observed_paths, first_observed_at, lag_bound_ms
|
|
1277
|
+
FROM serialized_dcb_pending_arrivals
|
|
1278
|
+
WHERE service_id = ?
|
|
1279
|
+
ORDER BY event_id COLLATE BINARY ASC`,
|
|
1280
|
+
serviceId
|
|
1281
|
+
);
|
|
1282
|
+
return rows.map(pendingFrom);
|
|
1283
|
+
}
|
|
1284
|
+
async appendFinding(finding) {
|
|
1285
|
+
this.ready();
|
|
1286
|
+
const statement = this.database.prepare(
|
|
1287
|
+
`INSERT INTO serialized_dcb_inconsistency_findings
|
|
1288
|
+
(service_id, event_id, path, classification, first_observed_at, lag_bound_ms, observed_at)
|
|
1289
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1290
|
+
ON CONFLICT (service_id, event_id, path, classification) DO NOTHING`
|
|
1291
|
+
).bind(
|
|
1292
|
+
finding.serviceId,
|
|
1293
|
+
finding.eventId,
|
|
1294
|
+
finding.path,
|
|
1295
|
+
finding.classification,
|
|
1296
|
+
finding.firstObservedAt,
|
|
1297
|
+
finding.lagBoundMs,
|
|
1298
|
+
finding.observedAt
|
|
1299
|
+
);
|
|
1300
|
+
await this.write("appendFinding", statement).run();
|
|
1301
|
+
}
|
|
1302
|
+
async hasFinding(serviceId, eventId, path, classification) {
|
|
1303
|
+
this.ready();
|
|
1304
|
+
const rows = await this.rows(
|
|
1305
|
+
`SELECT 1 AS present FROM serialized_dcb_inconsistency_findings
|
|
1306
|
+
WHERE service_id = ? AND event_id = ? AND path = ? AND classification = ?`,
|
|
1307
|
+
serviceId,
|
|
1308
|
+
eventId,
|
|
1309
|
+
path,
|
|
1310
|
+
classification
|
|
1311
|
+
);
|
|
1312
|
+
return rows.length !== 0;
|
|
1313
|
+
}
|
|
1314
|
+
async listFindings(serviceId, eventId) {
|
|
1315
|
+
this.ready();
|
|
1316
|
+
const conditions = [];
|
|
1317
|
+
const values = [];
|
|
1318
|
+
if (serviceId !== void 0) {
|
|
1319
|
+
conditions.push("service_id = ?");
|
|
1320
|
+
values.push(serviceId);
|
|
1321
|
+
}
|
|
1322
|
+
if (eventId !== void 0) {
|
|
1323
|
+
conditions.push("event_id = ?");
|
|
1324
|
+
values.push(eventId);
|
|
1325
|
+
}
|
|
1326
|
+
const rows = await this.rows(
|
|
1327
|
+
`SELECT service_id, event_id, path, classification, first_observed_at, lag_bound_ms, observed_at
|
|
1328
|
+
FROM serialized_dcb_inconsistency_findings
|
|
1329
|
+
${conditions.length === 0 ? "" : `WHERE ${conditions.join(" AND ")}`}
|
|
1330
|
+
ORDER BY sequence ASC`,
|
|
1331
|
+
...values
|
|
1332
|
+
);
|
|
1333
|
+
return rows.map(findingFrom);
|
|
1334
|
+
}
|
|
1335
|
+
async appendDeliveryIncident(incident) {
|
|
1336
|
+
this.ready();
|
|
1337
|
+
const statement = this.database.prepare(
|
|
1338
|
+
`INSERT INTO serialized_dcb_delivery_incidents
|
|
1339
|
+
(service_id, identity_key, classification, suid, existing_event_id, incoming_event_id, event_id,
|
|
1340
|
+
bound_lineage_id, incoming_lineage_id, observed_at)
|
|
1341
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1342
|
+
ON CONFLICT (service_id, identity_key) DO NOTHING`
|
|
1343
|
+
).bind(
|
|
1344
|
+
incident.serviceId,
|
|
1345
|
+
incident.identityKey,
|
|
1346
|
+
incident.classification,
|
|
1347
|
+
incident.suid ?? null,
|
|
1348
|
+
incident.existingEventId ?? null,
|
|
1349
|
+
incident.incomingEventId ?? null,
|
|
1350
|
+
incident.eventId ?? null,
|
|
1351
|
+
incident.boundLineageId ?? null,
|
|
1352
|
+
incident.incomingLineageId ?? null,
|
|
1353
|
+
incident.observedAt
|
|
1354
|
+
);
|
|
1355
|
+
await this.write("appendDeliveryIncident", statement).run();
|
|
1356
|
+
}
|
|
1357
|
+
async hasDeliveryIncident(serviceId, identityKey) {
|
|
1358
|
+
this.ready();
|
|
1359
|
+
return await this.incidentByIdentity(serviceId, identityKey) !== void 0;
|
|
1360
|
+
}
|
|
1361
|
+
async listDeliveryIncidents(serviceId) {
|
|
1362
|
+
this.ready();
|
|
1363
|
+
const rows = serviceId === void 0 ? await this.rows(
|
|
1364
|
+
`SELECT service_id, identity_key, classification, suid, existing_event_id, incoming_event_id, event_id,
|
|
1365
|
+
bound_lineage_id, incoming_lineage_id, observed_at
|
|
1366
|
+
FROM serialized_dcb_delivery_incidents
|
|
1367
|
+
ORDER BY sequence ASC`
|
|
1368
|
+
) : await this.rows(
|
|
1369
|
+
`SELECT service_id, identity_key, classification, suid, existing_event_id, incoming_event_id, event_id,
|
|
1370
|
+
bound_lineage_id, incoming_lineage_id, observed_at
|
|
1371
|
+
FROM serialized_dcb_delivery_incidents
|
|
1372
|
+
WHERE service_id = ?
|
|
1373
|
+
ORDER BY sequence ASC`,
|
|
1374
|
+
serviceId
|
|
1375
|
+
);
|
|
1376
|
+
return rows.map(incidentFrom);
|
|
1377
|
+
}
|
|
1378
|
+
async batch(operation, statements) {
|
|
1379
|
+
const transformed = await this.options.beforeBatch?.(operation, statements, this.database) ?? statements;
|
|
1380
|
+
await this.database.batch([...transformed]);
|
|
1381
|
+
}
|
|
1382
|
+
write(operation, statement) {
|
|
1383
|
+
return this.options.beforeWrite?.(operation, statement, this.database) ?? statement;
|
|
1384
|
+
}
|
|
1385
|
+
async rows(statement, ...values) {
|
|
1386
|
+
const result = await this.database.prepare(statement).bind(...values).all();
|
|
1387
|
+
return result.results;
|
|
1388
|
+
}
|
|
1389
|
+
async eventById(serviceId, eventId) {
|
|
1390
|
+
const rows = await this.rows(
|
|
1391
|
+
`SELECT e."ServiceId" AS service_id, e."Id" AS event_id,
|
|
1392
|
+
e."SortableUniqueId" AS suid, e."Payload" AS payload,
|
|
1393
|
+
e."EventType" AS event_type, e."Tags" AS event_tags,
|
|
1394
|
+
e."EventDigest" AS event_digest,
|
|
1395
|
+
e."Timestamp" AS timestamp, e."CausationId" AS causation_id,
|
|
1396
|
+
e."CorrelationId" AS correlation_id, e."ExecutedUser" AS executed_user,
|
|
1397
|
+
COALESCE(o."FirstArrivedAt", 0) AS first_arrived_at,
|
|
1398
|
+
COALESCE(o."LastArrivedAt", 0) AS last_arrived_at,
|
|
1399
|
+
COALESCE(o."MaxDeliveryLagMs", 0) AS max_delivery_lag_ms
|
|
1400
|
+
FROM dcb_events e LEFT JOIN dcb_event_ops o
|
|
1401
|
+
ON o."ServiceId" = e."ServiceId" AND o."Id" = e."Id"
|
|
1402
|
+
WHERE e."ServiceId" = ? AND e."Id" = ?`,
|
|
1403
|
+
serviceId,
|
|
1404
|
+
eventId
|
|
1405
|
+
);
|
|
1406
|
+
return rows[0] === void 0 ? void 0 : this.eventFromRow(rows[0]);
|
|
1407
|
+
}
|
|
1408
|
+
async eventFromRow(row) {
|
|
1409
|
+
const arrivals = await this.rows(
|
|
1410
|
+
`SELECT service_id, event_id, tag, enqueued_at, arrived_at, lag_ms
|
|
1411
|
+
FROM serialized_dcb_event_arrivals
|
|
1412
|
+
WHERE service_id = ? AND event_id = ?
|
|
1413
|
+
ORDER BY tag COLLATE BINARY ASC`,
|
|
1414
|
+
asString(row.service_id, "service_id"),
|
|
1415
|
+
asString(row.event_id, "event_id")
|
|
1416
|
+
);
|
|
1417
|
+
return eventFrom(row, arrivals);
|
|
1418
|
+
}
|
|
1419
|
+
async pendingById(serviceId, eventId) {
|
|
1420
|
+
const rows = await this.rows(
|
|
1421
|
+
`SELECT service_id, event_id, attempt_id, suid, expected_paths, observed_paths, first_observed_at, lag_bound_ms
|
|
1422
|
+
FROM serialized_dcb_pending_arrivals WHERE service_id = ? AND event_id = ?`,
|
|
1423
|
+
serviceId,
|
|
1424
|
+
eventId
|
|
1425
|
+
);
|
|
1426
|
+
return rows[0] === void 0 ? void 0 : pendingFrom(rows[0]);
|
|
1427
|
+
}
|
|
1428
|
+
async lineageBinding(serviceId) {
|
|
1429
|
+
const rows = await this.rows(
|
|
1430
|
+
`SELECT allocator_lineage_id FROM serialized_dcb_allocator_bindings WHERE service_id = ?`,
|
|
1431
|
+
serviceId
|
|
1432
|
+
);
|
|
1433
|
+
return rows[0] === void 0 ? void 0 : asString(rows[0].allocator_lineage_id, "allocator_lineage_id");
|
|
1434
|
+
}
|
|
1435
|
+
async collisionEvent(serviceId, suid, eventId) {
|
|
1436
|
+
const rows = await this.rows(
|
|
1437
|
+
`SELECT "Id" AS event_id FROM dcb_events
|
|
1438
|
+
WHERE "ServiceId" = ? AND "SortableUniqueId" COLLATE BINARY = ? COLLATE BINARY AND "Id" <> ?
|
|
1439
|
+
ORDER BY "Id" COLLATE BINARY ASC LIMIT 1`,
|
|
1440
|
+
serviceId,
|
|
1441
|
+
suid,
|
|
1442
|
+
eventId
|
|
1443
|
+
);
|
|
1444
|
+
return rows[0] === void 0 ? void 0 : asString(rows[0].event_id, "event_id");
|
|
1445
|
+
}
|
|
1446
|
+
async incidentByIdentity(serviceId, identityKey) {
|
|
1447
|
+
const rows = await this.rows(
|
|
1448
|
+
`SELECT service_id, identity_key, classification, suid, existing_event_id, incoming_event_id, event_id,
|
|
1449
|
+
bound_lineage_id, incoming_lineage_id, observed_at
|
|
1450
|
+
FROM serialized_dcb_delivery_incidents
|
|
1451
|
+
WHERE service_id = ? AND identity_key = ?`,
|
|
1452
|
+
serviceId,
|
|
1453
|
+
identityKey
|
|
1454
|
+
);
|
|
1455
|
+
return rows[0] === void 0 ? void 0 : incidentFrom(rows[0]);
|
|
1456
|
+
}
|
|
1457
|
+
async eventArrivalOps(serviceId, eventId) {
|
|
1458
|
+
const row = await this.database.prepare(
|
|
1459
|
+
`SELECT "FirstArrivedAt" AS first_arrived_at, "LastArrivedAt" AS last_arrived_at
|
|
1460
|
+
FROM dcb_event_ops
|
|
1461
|
+
WHERE "ServiceId" = ? AND "Id" = ?`
|
|
1462
|
+
).bind(serviceId, eventId).first();
|
|
1463
|
+
if (row === null || row === void 0) return void 0;
|
|
1464
|
+
return {
|
|
1465
|
+
firstArrivedAt: asNumber(row.first_arrived_at, "event_ops.first_arrived_at"),
|
|
1466
|
+
lastArrivedAt: asNumber(row.last_arrived_at, "event_ops.last_arrived_at")
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
async admissionMutationEvidence(serviceId, eventId, mutationAttemptId) {
|
|
1470
|
+
const row = await this.database.prepare(
|
|
1471
|
+
`SELECT "FirstAdmissionAttemptId" AS first_admission_attempt_id,
|
|
1472
|
+
"LastAdmissionAttemptId" AS last_admission_attempt_id
|
|
1473
|
+
FROM dcb_event_ops
|
|
1474
|
+
WHERE "ServiceId" = ? AND "Id" = ?`
|
|
1475
|
+
).bind(serviceId, eventId).first();
|
|
1476
|
+
const first = row?.first_admission_attempt_id === null || row?.first_admission_attempt_id === void 0 ? void 0 : asString(row.first_admission_attempt_id, "event_ops.first_admission_attempt_id");
|
|
1477
|
+
const last = row?.last_admission_attempt_id === null || row?.last_admission_attempt_id === void 0 ? void 0 : asString(row.last_admission_attempt_id, "event_ops.last_admission_attempt_id");
|
|
1478
|
+
if (first === mutationAttemptId) return "first-admission";
|
|
1479
|
+
if (last === mutationAttemptId) return "duplicate-admission";
|
|
1480
|
+
return "unverified";
|
|
1481
|
+
}
|
|
1482
|
+
async bestEffortG69AdmissionAttempt(message, arrivedAt, deliverySource, attemptContext, mutationAttemptId, result, failure, retryReason) {
|
|
1483
|
+
const diagnosticStartedAt = Date.now();
|
|
1484
|
+
const receiptCostMs = () => Math.max(0, Date.now() - diagnosticStartedAt);
|
|
1485
|
+
const appendFailedDiagnostic = async (reason) => {
|
|
1486
|
+
try {
|
|
1487
|
+
await appendG69AdmissionAttempt(this.database, {
|
|
1488
|
+
serviceId: message.serviceId,
|
|
1489
|
+
eventId: message.eventId,
|
|
1490
|
+
suid: message.suid,
|
|
1491
|
+
tag: message.tag,
|
|
1492
|
+
deliverySource,
|
|
1493
|
+
queueMessageId: attemptContext?.queueMessageId ?? null,
|
|
1494
|
+
attemptId: message.attemptId,
|
|
1495
|
+
allocatorLineageId: message.allocatorLineageId,
|
|
1496
|
+
obligationSequence: message.completeness.obligationSequence,
|
|
1497
|
+
enqueuedAt: message.enqueuedAt,
|
|
1498
|
+
observedAt: Date.now(),
|
|
1499
|
+
arrivedAt,
|
|
1500
|
+
firstArrivedAtBefore: null,
|
|
1501
|
+
lastArrivedAtBefore: null,
|
|
1502
|
+
firstArrivedAtAfter: null,
|
|
1503
|
+
lastArrivedAtAfter: null,
|
|
1504
|
+
beforeObservedAt: null,
|
|
1505
|
+
afterObservedAt: null,
|
|
1506
|
+
observationConsistency: "unverified",
|
|
1507
|
+
mutationEvidence: "unverified",
|
|
1508
|
+
diagnosticDurationMs: receiptCostMs(),
|
|
1509
|
+
status: "failed",
|
|
1510
|
+
retryReason: reason
|
|
1511
|
+
});
|
|
1512
|
+
} catch {
|
|
1513
|
+
}
|
|
1514
|
+
};
|
|
1515
|
+
try {
|
|
1516
|
+
try {
|
|
1517
|
+
await this.options.beforeG69AdmissionAttempt?.();
|
|
1518
|
+
} catch (error) {
|
|
1519
|
+
await appendFailedDiagnostic(errorText(error));
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
const afterObservedAt = Date.now();
|
|
1523
|
+
const afterResult = this.eventArrivalOps(message.serviceId, message.eventId).then((after2) => ({ after: after2, failed: false })).catch(() => ({ after: void 0, failed: true }));
|
|
1524
|
+
const mutationResult = this.admissionMutationEvidence(message.serviceId, message.eventId, mutationAttemptId).then((evidence) => ({ evidence, failed: false })).catch(() => ({ evidence: "unverified", failed: true }));
|
|
1525
|
+
const [{ after, failed: afterReadFailed }, { evidence: observedMutationEvidence }] = await Promise.all([afterResult, mutationResult]);
|
|
1526
|
+
const status = failure !== void 0 ? "failed" : result?.outcome === "stored" ? observedMutationEvidence === "duplicate-admission" ? "duplicate" : "stored" : result?.outcome ?? "failed";
|
|
1527
|
+
const finalObservationConsistency = afterReadFailed ? "after-read-failed" : after === void 0 ? "unverified" : "after-admission";
|
|
1528
|
+
await appendG69AdmissionAttempt(this.database, {
|
|
1529
|
+
serviceId: message.serviceId,
|
|
1530
|
+
eventId: message.eventId,
|
|
1531
|
+
suid: message.suid,
|
|
1532
|
+
tag: message.tag,
|
|
1533
|
+
deliverySource,
|
|
1534
|
+
queueMessageId: attemptContext?.queueMessageId ?? null,
|
|
1535
|
+
attemptId: message.attemptId,
|
|
1536
|
+
allocatorLineageId: message.allocatorLineageId,
|
|
1537
|
+
obligationSequence: message.completeness.obligationSequence,
|
|
1538
|
+
enqueuedAt: message.enqueuedAt,
|
|
1539
|
+
observedAt: afterObservedAt,
|
|
1540
|
+
arrivedAt,
|
|
1541
|
+
firstArrivedAtBefore: null,
|
|
1542
|
+
lastArrivedAtBefore: null,
|
|
1543
|
+
firstArrivedAtAfter: after?.firstArrivedAt ?? null,
|
|
1544
|
+
lastArrivedAtAfter: after?.lastArrivedAt ?? null,
|
|
1545
|
+
beforeObservedAt: null,
|
|
1546
|
+
afterObservedAt,
|
|
1547
|
+
observationConsistency: finalObservationConsistency,
|
|
1548
|
+
mutationEvidence: observedMutationEvidence,
|
|
1549
|
+
diagnosticDurationMs: receiptCostMs(),
|
|
1550
|
+
status,
|
|
1551
|
+
retryReason
|
|
1552
|
+
});
|
|
1553
|
+
} catch {
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
ready() {
|
|
1557
|
+
if (!this.initialized) throw new Error("D1EventStore.initialize() must complete before use");
|
|
1558
|
+
}
|
|
1559
|
+
};
|
|
1560
|
+
|
|
1561
|
+
// src/mv/UnsafeWindowMaterializedView.ts
|
|
1562
|
+
var UnsafeWindowMaterializedViewError = class extends Error {
|
|
1563
|
+
constructor(code, retryable, message, options) {
|
|
1564
|
+
super(message, options);
|
|
1565
|
+
this.code = code;
|
|
1566
|
+
this.retryable = retryable;
|
|
1567
|
+
this.name = "UnsafeWindowMaterializedViewError";
|
|
1568
|
+
}
|
|
1569
|
+
code;
|
|
1570
|
+
retryable;
|
|
1571
|
+
};
|
|
1572
|
+
function compareSuid(left, right) {
|
|
1573
|
+
const leftBytes = new TextEncoder().encode(left);
|
|
1574
|
+
const rightBytes = new TextEncoder().encode(right);
|
|
1575
|
+
for (let index = 0; index < Math.min(leftBytes.length, rightBytes.length); index += 1) {
|
|
1576
|
+
const difference = leftBytes[index] - rightBytes[index];
|
|
1577
|
+
if (difference !== 0) return difference;
|
|
1578
|
+
}
|
|
1579
|
+
return leftBytes.length - rightBytes.length;
|
|
1580
|
+
}
|
|
1581
|
+
function string(row, key) {
|
|
1582
|
+
if (typeof row[key] !== "string") throw new Error(`Unsafe MV ${key} was not a string`);
|
|
1583
|
+
return row[key];
|
|
1584
|
+
}
|
|
1585
|
+
function integer(row, key) {
|
|
1586
|
+
const value = typeof row[key] === "number" ? row[key] : Number(row[key]);
|
|
1587
|
+
if (!Number.isSafeInteger(value)) throw new Error(`Unsafe MV ${key} was not an integer`);
|
|
1588
|
+
return value;
|
|
1589
|
+
}
|
|
1590
|
+
function operationId() {
|
|
1591
|
+
return `mv-unsafe-${crypto.randomUUID()}`;
|
|
1592
|
+
}
|
|
1593
|
+
function errorFrom(error) {
|
|
1594
|
+
const value = String(error);
|
|
1595
|
+
if (value.includes("mv_unsafe_receipts") && (value.includes("UNIQUE") || value.includes("constraint"))) {
|
|
1596
|
+
return new UnsafeWindowMaterializedViewError("UNSAFE_DUPLICATE_RACE", false, "Concurrent unsafe receipt was committed by another delivery", { cause: error });
|
|
1597
|
+
}
|
|
1598
|
+
if (value.includes("unsafe_row_cas")) return new UnsafeWindowMaterializedViewError("UNSAFE_ROW_CAS_MISMATCH", true, "Unsafe row version changed before atomic apply", { cause: error });
|
|
1599
|
+
if (value.includes("unsafe_suid_contradiction")) return new UnsafeWindowMaterializedViewError("UNSAFE_SUID_CONTRADICTION", false, "Unsafe same-SUID payload contradiction", { cause: error });
|
|
1600
|
+
if (value.includes("unsafe_safe_ahead")) return new UnsafeWindowMaterializedViewError("UNSAFE_SAFE_AHEAD", false, "Safe head already covers unsafe event", { cause: error });
|
|
1601
|
+
if (value.includes("unsafe_behind_frontier")) return new UnsafeWindowMaterializedViewError("UNSAFE_BEHIND_FRONTIER", false, "Unsafe reads are fail-closed pending rebuild", { cause: error });
|
|
1602
|
+
return void 0;
|
|
1603
|
+
}
|
|
1604
|
+
var UnsafeWindowMaterializedViewStore = class {
|
|
1605
|
+
constructor(database, options = {}) {
|
|
1606
|
+
this.database = database;
|
|
1607
|
+
this.options = options;
|
|
1608
|
+
}
|
|
1609
|
+
database;
|
|
1610
|
+
options;
|
|
1611
|
+
/**
|
|
1612
|
+
* The common row-materializer shape is one complete upsert. Keep its
|
|
1613
|
+
* duplicate/order guards and all observable mutations in one D1 batch. A
|
|
1614
|
+
* guard race falls back to apply(), which preserves the detailed older,
|
|
1615
|
+
* duplicate, and contradiction classifications; only the uncontended fast
|
|
1616
|
+
* path avoids the receipt/row pre-reads and separate arrival write.
|
|
1617
|
+
*/
|
|
1618
|
+
async applyUpsertFast(input) {
|
|
1619
|
+
const { rowUpserts, rowPatches, rowDeletes } = input.mutations;
|
|
1620
|
+
if (rowUpserts.length !== 1 || rowPatches.length !== 0 || rowDeletes.length !== 0) return void 0;
|
|
1621
|
+
const upsert = rowUpserts[0];
|
|
1622
|
+
if (upsert.sourceSuid !== input.suid) return void 0;
|
|
1623
|
+
const valueJson = JSON.stringify(assertJsonValue(upsert.value, "state-persistence"));
|
|
1624
|
+
const operation = operationId();
|
|
1625
|
+
const statements = [];
|
|
1626
|
+
const guard = (name, predicate, values) => {
|
|
1627
|
+
statements.push(this.database.prepare(
|
|
1628
|
+
`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
1629
|
+
SELECT ?, CASE WHEN (${predicate}) THEN 1 ELSE NULL END`
|
|
1630
|
+
).bind(`${operation}:${name}`, ...values));
|
|
1631
|
+
};
|
|
1632
|
+
guard(
|
|
1633
|
+
"receipt",
|
|
1634
|
+
"NOT EXISTS (SELECT 1 FROM mv_unsafe_receipts WHERE service_id = ? AND view_id = ? AND event_id = ?)",
|
|
1635
|
+
[input.serviceId, input.viewId, input.eventId]
|
|
1636
|
+
);
|
|
1637
|
+
guard("safe-head", "? = '' OR ? COLLATE BINARY < ? COLLATE BINARY", [input.safeHead, input.safeHead, input.suid]);
|
|
1638
|
+
guard(
|
|
1639
|
+
"row-order",
|
|
1640
|
+
"NOT EXISTS (SELECT 1 FROM mv_unsafe_rows WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ? AND source_suid COLLATE BINARY > ? COLLATE BINARY)",
|
|
1641
|
+
[input.serviceId, input.viewId, input.generation, upsert.rowKey, input.suid]
|
|
1642
|
+
);
|
|
1643
|
+
guard(
|
|
1644
|
+
"same-suid",
|
|
1645
|
+
"NOT EXISTS (SELECT 1 FROM mv_unsafe_rows WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ? AND source_suid COLLATE BINARY = ? COLLATE BINARY AND value_json <> ?)",
|
|
1646
|
+
[input.serviceId, input.viewId, input.generation, upsert.rowKey, input.suid, valueJson]
|
|
1647
|
+
);
|
|
1648
|
+
if (input.expectedRowVersion !== void 0 && input.expectedRowVersion !== null) {
|
|
1649
|
+
guard(
|
|
1650
|
+
"row-cas",
|
|
1651
|
+
"NOT EXISTS (SELECT 1 FROM mv_unsafe_rows WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?) OR EXISTS (SELECT 1 FROM mv_unsafe_rows WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ? AND row_version = ?)",
|
|
1652
|
+
[
|
|
1653
|
+
input.serviceId,
|
|
1654
|
+
input.viewId,
|
|
1655
|
+
input.generation,
|
|
1656
|
+
upsert.rowKey,
|
|
1657
|
+
input.serviceId,
|
|
1658
|
+
input.viewId,
|
|
1659
|
+
input.generation,
|
|
1660
|
+
upsert.rowKey,
|
|
1661
|
+
input.expectedRowVersion
|
|
1662
|
+
]
|
|
1663
|
+
);
|
|
1664
|
+
}
|
|
1665
|
+
if (input.recordArrival === true) {
|
|
1666
|
+
const behindFrontier = input.safeHead !== "" && compareSuid(input.suid, input.safeHead) <= 0;
|
|
1667
|
+
statements.push(this.database.prepare(
|
|
1668
|
+
`INSERT INTO mv_unsafe_arrivals
|
|
1669
|
+
(service_id, view_id, generation, safe_head, arrival_watermark, behind_frontier_event_id, behind_frontier_suid, rebuild_required)
|
|
1670
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1671
|
+
ON CONFLICT (service_id, view_id, generation) DO UPDATE SET
|
|
1672
|
+
arrival_watermark = CASE WHEN excluded.arrival_watermark COLLATE BINARY > mv_unsafe_arrivals.arrival_watermark COLLATE BINARY THEN excluded.arrival_watermark ELSE mv_unsafe_arrivals.arrival_watermark END,
|
|
1673
|
+
behind_frontier_event_id = CASE WHEN mv_unsafe_arrivals.rebuild_required = 1 THEN mv_unsafe_arrivals.behind_frontier_event_id ELSE excluded.behind_frontier_event_id END,
|
|
1674
|
+
behind_frontier_suid = CASE WHEN mv_unsafe_arrivals.rebuild_required = 1 THEN mv_unsafe_arrivals.behind_frontier_suid ELSE excluded.behind_frontier_suid END,
|
|
1675
|
+
rebuild_required = MAX(mv_unsafe_arrivals.rebuild_required, excluded.rebuild_required)`
|
|
1676
|
+
).bind(
|
|
1677
|
+
input.serviceId,
|
|
1678
|
+
input.viewId,
|
|
1679
|
+
input.generation,
|
|
1680
|
+
input.safeHead,
|
|
1681
|
+
input.suid,
|
|
1682
|
+
behindFrontier ? input.eventId : null,
|
|
1683
|
+
behindFrontier ? input.suid : null,
|
|
1684
|
+
behindFrontier ? 1 : 0
|
|
1685
|
+
));
|
|
1686
|
+
}
|
|
1687
|
+
statements.push(this.database.prepare(
|
|
1688
|
+
`DELETE FROM mv_unsafe_index_entries WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`
|
|
1689
|
+
).bind(input.serviceId, input.viewId, input.generation, upsert.rowKey));
|
|
1690
|
+
statements.push(this.database.prepare(
|
|
1691
|
+
`INSERT INTO mv_unsafe_rows (service_id, view_id, generation, row_key, value_json, row_version, source_suid, tombstone)
|
|
1692
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0)
|
|
1693
|
+
ON CONFLICT (service_id, view_id, generation, row_key) DO UPDATE SET
|
|
1694
|
+
value_json = excluded.value_json, row_version = excluded.row_version, source_suid = excluded.source_suid, tombstone = 0`
|
|
1695
|
+
).bind(input.serviceId, input.viewId, input.generation, upsert.rowKey, valueJson, upsert.rowVersion, upsert.sourceSuid));
|
|
1696
|
+
for (const entry of input.mutations.indexEntries) this.addIndex(statements, "mv_unsafe_index_entries", input, entry);
|
|
1697
|
+
statements.push(this.database.prepare(
|
|
1698
|
+
`INSERT INTO mv_unsafe_receipts (service_id, view_id, event_id, suid, outcome, observed_at)
|
|
1699
|
+
VALUES (?, ?, ?, ?, 'applied', ?)`
|
|
1700
|
+
).bind(input.serviceId, input.viewId, input.eventId, input.suid, input.updatedAt));
|
|
1701
|
+
statements.push(this.database.prepare(
|
|
1702
|
+
`INSERT INTO mv_wait_receipts
|
|
1703
|
+
(service_id, view_id, generation, definition_version, event_id, suid, observed_at)
|
|
1704
|
+
SELECT ?, ?, ?, instance.definition_version, ?, ?, ?
|
|
1705
|
+
FROM mv_instances instance
|
|
1706
|
+
JOIN mv_active_generations active
|
|
1707
|
+
ON active.service_id = instance.service_id
|
|
1708
|
+
AND active.view_id = instance.view_id
|
|
1709
|
+
AND active.generation = instance.generation
|
|
1710
|
+
WHERE instance.service_id = ? AND instance.view_id = ? AND instance.generation = ?
|
|
1711
|
+
ON CONFLICT DO NOTHING`
|
|
1712
|
+
).bind(
|
|
1713
|
+
input.serviceId,
|
|
1714
|
+
input.viewId,
|
|
1715
|
+
input.generation,
|
|
1716
|
+
input.eventId,
|
|
1717
|
+
input.suid,
|
|
1718
|
+
input.updatedAt,
|
|
1719
|
+
input.serviceId,
|
|
1720
|
+
input.viewId,
|
|
1721
|
+
input.generation
|
|
1722
|
+
));
|
|
1723
|
+
statements.push(this.database.prepare(
|
|
1724
|
+
`INSERT INTO mv_unsafe_kicks (service_id, view_id, target_suid, dirty)
|
|
1725
|
+
VALUES (?, ?, ?, 1)
|
|
1726
|
+
ON CONFLICT (service_id, view_id) DO UPDATE SET
|
|
1727
|
+
target_suid = CASE WHEN excluded.target_suid COLLATE BINARY > mv_unsafe_kicks.target_suid COLLATE BINARY THEN excluded.target_suid ELSE mv_unsafe_kicks.target_suid END,
|
|
1728
|
+
dirty = 1`
|
|
1729
|
+
).bind(input.serviceId, input.viewId, input.targetSuid ?? input.suid));
|
|
1730
|
+
statements.push(this.database.prepare("DELETE FROM mv_atomic_guards WHERE operation_id LIKE ?").bind(`${operation}:%`));
|
|
1731
|
+
await this.options.beforeApplyBatch?.(input, statements);
|
|
1732
|
+
try {
|
|
1733
|
+
await this.database.batch(statements);
|
|
1734
|
+
} catch (error) {
|
|
1735
|
+
const text = String(error);
|
|
1736
|
+
const guardFailure = text.includes("mv_atomic_guards") || text.includes("checkpoint_match") || text.includes("mv_unsafe_receipts") && (text.includes("UNIQUE") || text.includes("constraint"));
|
|
1737
|
+
if (guardFailure) {
|
|
1738
|
+
const receipt = await this.database.prepare(
|
|
1739
|
+
`SELECT suid FROM mv_unsafe_receipts WHERE service_id = ? AND view_id = ? AND event_id = ? ORDER BY observed_at DESC LIMIT 1`
|
|
1740
|
+
).bind(input.serviceId, input.viewId, input.eventId).first();
|
|
1741
|
+
if (receipt !== null && receipt !== void 0) {
|
|
1742
|
+
if (string(receipt, "suid") !== input.suid) {
|
|
1743
|
+
throw new UnsafeWindowMaterializedViewError("UNSAFE_SUID_CONTRADICTION", false, "Duplicate event identity has a contradictory SUID", { cause: error });
|
|
1744
|
+
}
|
|
1745
|
+
if (this.options.beforeApplyBatch !== void 0) {
|
|
1746
|
+
throw new UnsafeWindowMaterializedViewError("UNSAFE_DUPLICATE_RACE", false, "Concurrent unsafe receipt was committed by another delivery", { cause: error });
|
|
1747
|
+
}
|
|
1748
|
+
return { outcome: "no-change", duplicate: true };
|
|
1749
|
+
}
|
|
1750
|
+
return this.apply(input, true);
|
|
1751
|
+
}
|
|
1752
|
+
throw error;
|
|
1753
|
+
}
|
|
1754
|
+
const result = { outcome: "applied", duplicate: false };
|
|
1755
|
+
await this.options.afterApplyBatch?.(input, result);
|
|
1756
|
+
return result;
|
|
1757
|
+
}
|
|
1758
|
+
async apply(input, skipFastPath = false) {
|
|
1759
|
+
assertSortableUniqueId(input.suid);
|
|
1760
|
+
if (input.safeHead.length > 0) assertSortableUniqueId(input.safeHead);
|
|
1761
|
+
if (input.targetSuid !== void 0) assertSortableUniqueId(input.targetSuid);
|
|
1762
|
+
for (const mutation of [...input.mutations.rowUpserts, ...input.mutations.rowPatches]) {
|
|
1763
|
+
assertSortableUniqueId(mutation.sourceSuid);
|
|
1764
|
+
}
|
|
1765
|
+
const fast = skipFastPath ? void 0 : await this.applyUpsertFast(input);
|
|
1766
|
+
if (fast !== void 0) return fast;
|
|
1767
|
+
if (input.recordArrival === true) await this.observeArrival(input.serviceId, input.viewId, input.generation, input.eventId, input.suid);
|
|
1768
|
+
const receipt = await this.database.prepare(
|
|
1769
|
+
`SELECT suid FROM mv_unsafe_receipts WHERE service_id = ? AND view_id = ? AND event_id = ? ORDER BY observed_at DESC LIMIT 1`
|
|
1770
|
+
).bind(input.serviceId, input.viewId, input.eventId).first();
|
|
1771
|
+
if (receipt !== null && receipt !== void 0) {
|
|
1772
|
+
if (string(receipt, "suid") !== input.suid) {
|
|
1773
|
+
throw new UnsafeWindowMaterializedViewError("UNSAFE_SUID_CONTRADICTION", false, "Duplicate event identity has a contradictory SUID");
|
|
1774
|
+
}
|
|
1775
|
+
return { outcome: "no-change", duplicate: true };
|
|
1776
|
+
}
|
|
1777
|
+
const rowKeys = [.../* @__PURE__ */ new Set([
|
|
1778
|
+
...input.mutations.rowUpserts.map((row) => row.rowKey),
|
|
1779
|
+
...input.mutations.rowPatches.map((row) => row.rowKey),
|
|
1780
|
+
...input.mutations.rowDeletes.map((row) => row.rowKey)
|
|
1781
|
+
])];
|
|
1782
|
+
const existing = /* @__PURE__ */ new Map();
|
|
1783
|
+
for (const rowKey of rowKeys) {
|
|
1784
|
+
const row = await this.database.prepare(
|
|
1785
|
+
`SELECT row_key, value_json, row_version, source_suid FROM mv_unsafe_rows
|
|
1786
|
+
WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`
|
|
1787
|
+
).bind(input.serviceId, input.viewId, input.generation, rowKey).first();
|
|
1788
|
+
if (row !== null && row !== void 0) existing.set(rowKey, row);
|
|
1789
|
+
}
|
|
1790
|
+
const older = [...existing.values()].some((row) => compareSuid(string(row, "source_suid"), input.suid) > 0);
|
|
1791
|
+
const noRowForPatch = input.mutations.rowPatches.some((patch) => !existing.has(patch.rowKey));
|
|
1792
|
+
const deleteWithoutRow = input.mutations.rowDeletes.some((row) => !existing.has(row.rowKey));
|
|
1793
|
+
const outcome = older ? "older" : noRowForPatch ? "patch-not-found" : deleteWithoutRow ? "delete-without-row" : input.mutations.rowUpserts.length + input.mutations.rowPatches.length + input.mutations.rowDeletes.length === 0 ? "no-change" : "applied";
|
|
1794
|
+
const operation = operationId();
|
|
1795
|
+
const statements = [];
|
|
1796
|
+
const safeAhead = input.safeHead !== "" && compareSuid(input.safeHead, input.suid) >= 0;
|
|
1797
|
+
let rowCasMismatch = false;
|
|
1798
|
+
let sameSuidContradiction = false;
|
|
1799
|
+
const guard = (name, predicate, values) => {
|
|
1800
|
+
statements.push(this.database.prepare(
|
|
1801
|
+
`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
1802
|
+
SELECT ?, CASE WHEN (${predicate}) THEN 1 ELSE NULL END`
|
|
1803
|
+
).bind(`${operation}:${name}`, ...values));
|
|
1804
|
+
};
|
|
1805
|
+
guard("unsafe_safe_ahead", "? = '' OR ? COLLATE BINARY < ? COLLATE BINARY", [input.safeHead, input.safeHead, input.suid]);
|
|
1806
|
+
for (const [rowKey, row] of existing) {
|
|
1807
|
+
if (input.expectedRowVersion !== void 0 && input.expectedRowVersion !== null) {
|
|
1808
|
+
rowCasMismatch ||= integer(row, "row_version") !== input.expectedRowVersion;
|
|
1809
|
+
guard("unsafe_row_cas", "? = ?", [integer(row, "row_version"), input.expectedRowVersion]);
|
|
1810
|
+
}
|
|
1811
|
+
const upsert = input.mutations.rowUpserts.find((candidate) => candidate.rowKey === rowKey);
|
|
1812
|
+
if (upsert !== void 0 && string(row, "source_suid") === input.suid && string(row, "value_json") !== JSON.stringify(assertJsonValue(upsert.value, "state-persistence"))) {
|
|
1813
|
+
sameSuidContradiction = true;
|
|
1814
|
+
guard("unsafe_suid_contradiction", "0", []);
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
if (!older && outcome === "applied") {
|
|
1818
|
+
for (const deletion of input.mutations.rowDeletes) {
|
|
1819
|
+
statements.push(this.database.prepare(`DELETE FROM mv_unsafe_index_entries WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`).bind(input.serviceId, input.viewId, input.generation, deletion.rowKey));
|
|
1820
|
+
statements.push(this.database.prepare(`DELETE FROM mv_unsafe_rows WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`).bind(input.serviceId, input.viewId, input.generation, deletion.rowKey));
|
|
1821
|
+
}
|
|
1822
|
+
for (const upsert of input.mutations.rowUpserts) {
|
|
1823
|
+
statements.push(this.database.prepare(`DELETE FROM mv_unsafe_index_entries WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`).bind(input.serviceId, input.viewId, input.generation, upsert.rowKey));
|
|
1824
|
+
statements.push(this.database.prepare(
|
|
1825
|
+
`INSERT INTO mv_unsafe_rows (service_id, view_id, generation, row_key, value_json, row_version, source_suid, tombstone)
|
|
1826
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0)
|
|
1827
|
+
ON CONFLICT (service_id, view_id, generation, row_key) DO UPDATE SET
|
|
1828
|
+
value_json = excluded.value_json, row_version = excluded.row_version, source_suid = excluded.source_suid, tombstone = 0`
|
|
1829
|
+
).bind(input.serviceId, input.viewId, input.generation, upsert.rowKey, JSON.stringify(assertJsonValue(upsert.value, "state-persistence")), upsert.rowVersion, upsert.sourceSuid));
|
|
1830
|
+
}
|
|
1831
|
+
for (const patch of input.mutations.rowPatches) {
|
|
1832
|
+
statements.push(this.database.prepare(
|
|
1833
|
+
`UPDATE mv_unsafe_rows SET value_json = json_patch(value_json, ?), row_version = ?, source_suid = ?, tombstone = 0
|
|
1834
|
+
WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`
|
|
1835
|
+
).bind(JSON.stringify(assertJsonValue(patch.patch, "state-persistence")), patch.rowVersion, patch.sourceSuid, input.serviceId, input.viewId, input.generation, patch.rowKey));
|
|
1836
|
+
}
|
|
1837
|
+
for (const entry of input.mutations.indexEntries) this.addIndex(statements, "mv_unsafe_index_entries", input, entry);
|
|
1838
|
+
for (const patch of input.mutations.rowPatches) for (const entry of patch.indexEntries) this.addIndex(statements, "mv_unsafe_index_entries", input, entry);
|
|
1839
|
+
}
|
|
1840
|
+
const markerReason = outcome === "applied" ? void 0 : outcome;
|
|
1841
|
+
if (markerReason !== void 0) {
|
|
1842
|
+
for (const rowKey of rowKeys.length === 0 ? [input.eventId] : rowKeys) {
|
|
1843
|
+
statements.push(this.database.prepare(
|
|
1844
|
+
`INSERT INTO mv_unsafe_markers (service_id, view_id, generation, row_key, event_id, suid, reason)
|
|
1845
|
+
VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`
|
|
1846
|
+
).bind(input.serviceId, input.viewId, input.generation, rowKey, input.eventId, input.suid, markerReason));
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
statements.push(this.database.prepare(
|
|
1850
|
+
`INSERT INTO mv_unsafe_receipts (service_id, view_id, event_id, suid, outcome, observed_at) VALUES (?, ?, ?, ?, ?, ?)`
|
|
1851
|
+
).bind(input.serviceId, input.viewId, input.eventId, input.suid, outcome, input.updatedAt));
|
|
1852
|
+
statements.push(this.database.prepare(
|
|
1853
|
+
`INSERT INTO mv_wait_receipts
|
|
1854
|
+
(service_id, view_id, generation, definition_version, event_id, suid, observed_at)
|
|
1855
|
+
SELECT ?, ?, ?, instance.definition_version, ?, ?, ?
|
|
1856
|
+
FROM mv_instances instance
|
|
1857
|
+
JOIN mv_active_generations active
|
|
1858
|
+
ON active.service_id = instance.service_id
|
|
1859
|
+
AND active.view_id = instance.view_id
|
|
1860
|
+
AND active.generation = instance.generation
|
|
1861
|
+
WHERE instance.service_id = ? AND instance.view_id = ? AND instance.generation = ?
|
|
1862
|
+
ON CONFLICT DO NOTHING`
|
|
1863
|
+
).bind(
|
|
1864
|
+
input.serviceId,
|
|
1865
|
+
input.viewId,
|
|
1866
|
+
input.generation,
|
|
1867
|
+
input.eventId,
|
|
1868
|
+
input.suid,
|
|
1869
|
+
input.updatedAt,
|
|
1870
|
+
input.serviceId,
|
|
1871
|
+
input.viewId,
|
|
1872
|
+
input.generation
|
|
1873
|
+
));
|
|
1874
|
+
statements.push(this.database.prepare(
|
|
1875
|
+
`INSERT INTO mv_unsafe_kicks (service_id, view_id, target_suid, dirty)
|
|
1876
|
+
VALUES (?, ?, ?, 1)
|
|
1877
|
+
ON CONFLICT (service_id, view_id) DO UPDATE SET
|
|
1878
|
+
target_suid = CASE WHEN excluded.target_suid COLLATE BINARY > mv_unsafe_kicks.target_suid COLLATE BINARY THEN excluded.target_suid ELSE mv_unsafe_kicks.target_suid END,
|
|
1879
|
+
dirty = 1`
|
|
1880
|
+
).bind(input.serviceId, input.viewId, input.targetSuid ?? input.suid));
|
|
1881
|
+
statements.push(this.database.prepare("DELETE FROM mv_atomic_guards WHERE operation_id LIKE ?").bind(`${operation}:%`));
|
|
1882
|
+
await this.options.beforeApplyBatch?.(input, statements);
|
|
1883
|
+
try {
|
|
1884
|
+
await this.database.batch(statements);
|
|
1885
|
+
} catch (error) {
|
|
1886
|
+
if (sameSuidContradiction) throw new UnsafeWindowMaterializedViewError("UNSAFE_SUID_CONTRADICTION", false, "Unsafe same-SUID payload contradiction", { cause: error });
|
|
1887
|
+
if (rowCasMismatch) throw new UnsafeWindowMaterializedViewError("UNSAFE_ROW_CAS_MISMATCH", true, "Unsafe row version changed before atomic apply", { cause: error });
|
|
1888
|
+
if (safeAhead) throw new UnsafeWindowMaterializedViewError("UNSAFE_SAFE_AHEAD", false, "Safe head already covers unsafe event", { cause: error });
|
|
1889
|
+
const typed = errorFrom(error);
|
|
1890
|
+
if (typed !== void 0) throw typed;
|
|
1891
|
+
throw error;
|
|
1892
|
+
}
|
|
1893
|
+
const result = { outcome, duplicate: false };
|
|
1894
|
+
await this.options.afterApplyBatch?.(input, result);
|
|
1895
|
+
return result;
|
|
1896
|
+
}
|
|
1897
|
+
/** Advance safe head and remove markers only if this transaction observed its exact receipt. */
|
|
1898
|
+
async observeSafeReceipt(serviceId, viewId, generation, eventId, suid) {
|
|
1899
|
+
assertSortableUniqueId(suid);
|
|
1900
|
+
const operation = operationId();
|
|
1901
|
+
await this.database.batch([
|
|
1902
|
+
this.database.prepare(
|
|
1903
|
+
`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
1904
|
+
SELECT ?, CASE WHEN EXISTS (SELECT 1 FROM mv_unsafe_receipts WHERE service_id = ? AND view_id = ? AND event_id = ? AND suid = ?) THEN 1 ELSE NULL END`
|
|
1905
|
+
).bind(operation, serviceId, viewId, eventId, suid),
|
|
1906
|
+
this.database.prepare(
|
|
1907
|
+
`INSERT INTO mv_unsafe_arrivals (service_id, view_id, generation, safe_head, arrival_watermark)
|
|
1908
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1909
|
+
ON CONFLICT (service_id, view_id, generation) DO UPDATE SET safe_head = excluded.safe_head`
|
|
1910
|
+
).bind(serviceId, viewId, generation, suid, suid),
|
|
1911
|
+
this.database.prepare(`DELETE FROM mv_unsafe_markers WHERE service_id = ? AND view_id = ? AND generation = ? AND event_id = ? AND suid = ?`).bind(serviceId, viewId, generation, eventId, suid),
|
|
1912
|
+
this.database.prepare("DELETE FROM mv_atomic_guards WHERE operation_id = ?").bind(operation)
|
|
1913
|
+
]);
|
|
1914
|
+
}
|
|
1915
|
+
/** Detects late first-arrivals independently of SafeWindow and fail-closes reads. */
|
|
1916
|
+
async observeArrival(serviceId, viewId, generation, eventId, suid) {
|
|
1917
|
+
assertSortableUniqueId(suid);
|
|
1918
|
+
const existing = await this.database.prepare(
|
|
1919
|
+
`SELECT safe_head, arrival_watermark FROM mv_unsafe_arrivals WHERE service_id = ? AND view_id = ? AND generation = ?`
|
|
1920
|
+
).bind(serviceId, viewId, generation).first();
|
|
1921
|
+
const safeHead = existing === null || existing === void 0 ? "" : string(existing, "safe_head");
|
|
1922
|
+
const behind = safeHead !== "" && compareSuid(suid, safeHead) <= 0;
|
|
1923
|
+
await this.database.prepare(
|
|
1924
|
+
`INSERT INTO mv_unsafe_arrivals (service_id, view_id, generation, safe_head, arrival_watermark, behind_frontier_event_id, behind_frontier_suid, rebuild_required)
|
|
1925
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1926
|
+
ON CONFLICT (service_id, view_id, generation) DO UPDATE SET
|
|
1927
|
+
arrival_watermark = CASE WHEN excluded.arrival_watermark COLLATE BINARY > mv_unsafe_arrivals.arrival_watermark COLLATE BINARY THEN excluded.arrival_watermark ELSE mv_unsafe_arrivals.arrival_watermark END,
|
|
1928
|
+
behind_frontier_event_id = CASE WHEN mv_unsafe_arrivals.rebuild_required = 1 THEN mv_unsafe_arrivals.behind_frontier_event_id ELSE excluded.behind_frontier_event_id END,
|
|
1929
|
+
behind_frontier_suid = CASE WHEN mv_unsafe_arrivals.rebuild_required = 1 THEN mv_unsafe_arrivals.behind_frontier_suid ELSE excluded.behind_frontier_suid END,
|
|
1930
|
+
rebuild_required = MAX(mv_unsafe_arrivals.rebuild_required, excluded.rebuild_required)`
|
|
1931
|
+
).bind(serviceId, viewId, generation, safeHead, suid, behind ? eventId : null, behind ? suid : null, behind ? 1 : 0).run();
|
|
1932
|
+
return behind;
|
|
1933
|
+
}
|
|
1934
|
+
async readMeta(serviceId, viewId, generation) {
|
|
1935
|
+
const arrival = await this.database.prepare(`SELECT safe_head, rebuild_required FROM mv_unsafe_arrivals WHERE service_id = ? AND view_id = ? AND generation = ?`).bind(serviceId, viewId, generation).first();
|
|
1936
|
+
const row = await this.database.prepare(`SELECT COALESCE(MAX(suid), '') AS max_suid FROM mv_unsafe_receipts WHERE service_id = ? AND view_id = ?`).bind(serviceId, viewId).first();
|
|
1937
|
+
return {
|
|
1938
|
+
safeContiguousHead: arrival === null || arrival === void 0 ? "" : string(arrival, "safe_head"),
|
|
1939
|
+
unsafeObservedMaxSuid: row === null || row === void 0 ? "" : string(row, "max_suid"),
|
|
1940
|
+
rebuildRequired: arrival !== null && arrival !== void 0 && integer(arrival, "rebuild_required") === 1
|
|
1941
|
+
};
|
|
1942
|
+
}
|
|
1943
|
+
async hasTargetReceipt(serviceId, viewId, eventId, suid) {
|
|
1944
|
+
const receipt = await this.database.prepare(`SELECT 1 FROM mv_unsafe_receipts WHERE service_id = ? AND view_id = ? AND event_id = ? AND suid = ?`).bind(serviceId, viewId, eventId, suid).first();
|
|
1945
|
+
return receipt !== null && receipt !== void 0;
|
|
1946
|
+
}
|
|
1947
|
+
async acquireKick(serviceId, viewId, owner, nowMs, leaseMs) {
|
|
1948
|
+
const operation = operationId();
|
|
1949
|
+
try {
|
|
1950
|
+
await this.database.batch([
|
|
1951
|
+
this.database.prepare(
|
|
1952
|
+
`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
1953
|
+
SELECT ?, CASE WHEN EXISTS (SELECT 1 FROM mv_unsafe_kicks WHERE service_id = ? AND view_id = ? AND dirty = 1 AND (lease_owner IS NULL OR lease_until <= ? OR lease_owner = ?)) THEN 1 ELSE NULL END`
|
|
1954
|
+
).bind(operation, serviceId, viewId, nowMs, owner),
|
|
1955
|
+
this.database.prepare(`UPDATE mv_unsafe_kicks SET lease_owner = ?, lease_until = ?, dirty = 0 WHERE service_id = ? AND view_id = ?`).bind(owner, nowMs + leaseMs, serviceId, viewId),
|
|
1956
|
+
this.database.prepare("DELETE FROM mv_atomic_guards WHERE operation_id = ?").bind(operation)
|
|
1957
|
+
]);
|
|
1958
|
+
} catch (error) {
|
|
1959
|
+
throw new UnsafeWindowMaterializedViewError("UNSAFE_KICK_LEASE_HELD", true, "Unsafe kick is held by another lease owner", { cause: error });
|
|
1960
|
+
}
|
|
1961
|
+
const row = await this.database.prepare(`SELECT target_suid, dirty FROM mv_unsafe_kicks WHERE service_id = ? AND view_id = ?`).bind(serviceId, viewId).first();
|
|
1962
|
+
return row === null || row === void 0 ? void 0 : { targetSuid: string(row, "target_suid"), dirty: integer(row, "dirty") === 1 };
|
|
1963
|
+
}
|
|
1964
|
+
/**
|
|
1965
|
+
* Settle an unsafe kick at the checkpoint actually reached by its follow.
|
|
1966
|
+
* A first-unsafe return is a successful bounded pass, not proof that the
|
|
1967
|
+
* lease target was reached. When a reached checkpoint is supplied, keep the
|
|
1968
|
+
* durable dirty bit set if the target remains ahead so the next scheduled
|
|
1969
|
+
* tick can acquire the kick again. Requiring this checkpoint keeps every
|
|
1970
|
+
* production settlement honest about whether its target was reached.
|
|
1971
|
+
*/
|
|
1972
|
+
async finishKick(serviceId, viewId, owner, reachedSuid) {
|
|
1973
|
+
if (reachedSuid.length > 0) assertSortableUniqueId(reachedSuid);
|
|
1974
|
+
const result = await this.database.prepare(
|
|
1975
|
+
`UPDATE mv_unsafe_kicks
|
|
1976
|
+
SET lease_owner = NULL,
|
|
1977
|
+
lease_until = 0,
|
|
1978
|
+
dirty = CASE
|
|
1979
|
+
WHEN ? IS NOT NULL AND target_suid COLLATE BINARY > ? COLLATE BINARY THEN 1
|
|
1980
|
+
ELSE 0
|
|
1981
|
+
END
|
|
1982
|
+
WHERE service_id = ? AND view_id = ? AND lease_owner = ? AND dirty = 0`
|
|
1983
|
+
).bind(reachedSuid, reachedSuid, serviceId, viewId, owner).run();
|
|
1984
|
+
return result.meta.changes === 1;
|
|
1985
|
+
}
|
|
1986
|
+
/**
|
|
1987
|
+
* Collect a repairable unsafe row only when every recovery predicate holds.
|
|
1988
|
+
* Every predicate is a constraint-promotion guard ahead of the deletes; a
|
|
1989
|
+
* rejected GC therefore cannot leak a partially removed index/receipt.
|
|
1990
|
+
*/
|
|
1991
|
+
async garbageCollect(input) {
|
|
1992
|
+
const operation = operationId();
|
|
1993
|
+
const statements = [
|
|
1994
|
+
// safeHead >= row source, including the mandatory post-GC resurrection fence.
|
|
1995
|
+
this.database.prepare(`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match) SELECT ?, CASE WHEN ? COLLATE BINARY >= ? COLLATE BINARY THEN 1 ELSE NULL END`).bind(`${operation}:safe-head`, input.safeHead, input.expectedSourceSuid),
|
|
1996
|
+
// Arrival receipt/watermark is a separate condition from the
|
|
1997
|
+
// fail-closed detector state; each has its own in-batch guard.
|
|
1998
|
+
this.database.prepare(
|
|
1999
|
+
`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
2000
|
+
SELECT ?, CASE WHEN EXISTS (SELECT 1 FROM mv_unsafe_arrivals WHERE service_id = ? AND view_id = ? AND generation = ? AND arrival_watermark COLLATE BINARY >= ? COLLATE BINARY) THEN 1 ELSE NULL END`
|
|
2001
|
+
).bind(`${operation}:arrival`, input.serviceId, input.viewId, input.generation, input.expectedSourceSuid),
|
|
2002
|
+
this.database.prepare(
|
|
2003
|
+
`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
2004
|
+
SELECT ?, CASE WHEN EXISTS (SELECT 1 FROM mv_unsafe_arrivals WHERE service_id = ? AND view_id = ? AND generation = ? AND rebuild_required = 0) THEN 1 ELSE NULL END`
|
|
2005
|
+
).bind(`${operation}:frontier`, input.serviceId, input.viewId, input.generation),
|
|
2006
|
+
// Candidate/retired generations and changed materializer definitions are
|
|
2007
|
+
// never GC targets. These are intentionally separate guards so a
|
|
2008
|
+
// definition mutation cannot piggy-back on an active-generation check.
|
|
2009
|
+
this.database.prepare(
|
|
2010
|
+
`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
2011
|
+
SELECT ?, CASE WHEN EXISTS (SELECT 1 FROM mv_active_generations WHERE service_id = ? AND view_id = ? AND generation = ?) THEN 1 ELSE NULL END`
|
|
2012
|
+
).bind(`${operation}:active-generation`, input.serviceId, input.viewId, input.generation),
|
|
2013
|
+
this.database.prepare(
|
|
2014
|
+
`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
2015
|
+
SELECT ?, CASE WHEN EXISTS (SELECT 1 FROM mv_instances WHERE service_id = ? AND view_id = ? AND generation = ? AND definition_version = ?) THEN 1 ELSE NULL END`
|
|
2016
|
+
).bind(`${operation}:definition`, input.serviceId, input.viewId, input.generation, input.definitionVersion),
|
|
2017
|
+
// Compare-and-delete is revalidated inside the atomic batch, never on a
|
|
2018
|
+
// read-then-delete snapshot.
|
|
2019
|
+
this.database.prepare(
|
|
2020
|
+
`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
2021
|
+
SELECT ?, CASE WHEN EXISTS (SELECT 1 FROM mv_unsafe_rows WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ? AND row_version = ? AND source_suid COLLATE BINARY = ? COLLATE BINARY) THEN 1 ELSE NULL END`
|
|
2022
|
+
).bind(`${operation}:row`, input.serviceId, input.viewId, input.generation, input.rowKey, input.expectedRowVersion, input.expectedSourceSuid),
|
|
2023
|
+
this.database.prepare(`DELETE FROM mv_unsafe_index_entries WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`).bind(input.serviceId, input.viewId, input.generation, input.rowKey),
|
|
2024
|
+
this.database.prepare(`DELETE FROM mv_unsafe_markers WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`).bind(input.serviceId, input.viewId, input.generation, input.rowKey),
|
|
2025
|
+
this.database.prepare(`DELETE FROM mv_unsafe_receipts WHERE service_id = ? AND view_id = ? AND suid = ?`).bind(input.serviceId, input.viewId, input.expectedSourceSuid),
|
|
2026
|
+
this.database.prepare(`DELETE FROM mv_wait_receipts WHERE service_id = ? AND view_id = ? AND generation = ? AND suid = ?`).bind(input.serviceId, input.viewId, input.generation, input.expectedSourceSuid),
|
|
2027
|
+
this.database.prepare(`DELETE FROM mv_unsafe_rows WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ? AND row_version = ? AND source_suid COLLATE BINARY = ? COLLATE BINARY`).bind(input.serviceId, input.viewId, input.generation, input.rowKey, input.expectedRowVersion, input.expectedSourceSuid),
|
|
2028
|
+
this.database.prepare("DELETE FROM mv_atomic_guards WHERE operation_id LIKE ?").bind(`${operation}:%`)
|
|
2029
|
+
];
|
|
2030
|
+
try {
|
|
2031
|
+
await this.database.batch(statements);
|
|
2032
|
+
return true;
|
|
2033
|
+
} catch (error) {
|
|
2034
|
+
if (String(error).includes("mv_atomic_guards") || String(error).includes("checkpoint_match")) return false;
|
|
2035
|
+
throw error;
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
/**
|
|
2039
|
+
* Idle catch-up calls this after it has advanced (or observed) the safe
|
|
2040
|
+
* checkpoint. Candidates are deliberately rechecked by garbageCollect;
|
|
2041
|
+
* this read is only work selection, never permission to delete.
|
|
2042
|
+
*/
|
|
2043
|
+
async collectEligible(serviceId, viewId, generation, definitionVersion, safeHead) {
|
|
2044
|
+
if (safeHead === "") return 0;
|
|
2045
|
+
const candidates = await this.database.prepare(
|
|
2046
|
+
`SELECT row_key, row_version, source_suid FROM mv_unsafe_rows
|
|
2047
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?
|
|
2048
|
+
AND source_suid COLLATE BINARY <= ? COLLATE BINARY
|
|
2049
|
+
ORDER BY source_suid COLLATE BINARY ASC, row_key COLLATE BINARY ASC`
|
|
2050
|
+
).bind(serviceId, viewId, generation, safeHead).all();
|
|
2051
|
+
let collected = 0;
|
|
2052
|
+
for (const row of candidates.results) {
|
|
2053
|
+
const candidate = {
|
|
2054
|
+
rowKey: string(row, "row_key"),
|
|
2055
|
+
rowVersion: integer(row, "row_version"),
|
|
2056
|
+
sourceSuid: string(row, "source_suid")
|
|
2057
|
+
};
|
|
2058
|
+
if (await this.garbageCollect({
|
|
2059
|
+
serviceId,
|
|
2060
|
+
viewId,
|
|
2061
|
+
generation,
|
|
2062
|
+
definitionVersion,
|
|
2063
|
+
rowKey: candidate.rowKey,
|
|
2064
|
+
expectedRowVersion: candidate.rowVersion,
|
|
2065
|
+
expectedSourceSuid: candidate.sourceSuid,
|
|
2066
|
+
safeHead
|
|
2067
|
+
})) collected += 1;
|
|
2068
|
+
}
|
|
2069
|
+
return collected;
|
|
2070
|
+
}
|
|
2071
|
+
async queryComposedPage(serviceId, viewId, generation, limit, offset, descending = false) {
|
|
2072
|
+
const meta = await this.readMeta(serviceId, viewId, generation);
|
|
2073
|
+
if (meta.rebuildRequired) throw new UnsafeWindowMaterializedViewError("UNSAFE_BEHIND_FRONTIER", false, "Unsafe window is rebuilding after a behind-frontier arrival");
|
|
2074
|
+
const direction = descending ? "DESC" : "ASC";
|
|
2075
|
+
const result = await this.database.prepare(
|
|
2076
|
+
`WITH candidates AS (
|
|
2077
|
+
SELECT row_key, value_json, row_version, source_suid, 0 AS tombstone, 0 AS unsafe_layer FROM mv_rows
|
|
2078
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?
|
|
2079
|
+
UNION ALL
|
|
2080
|
+
SELECT row_key, value_json, row_version, source_suid, tombstone, 1 AS unsafe_layer FROM mv_unsafe_rows
|
|
2081
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?
|
|
2082
|
+
), ranked AS (
|
|
2083
|
+
SELECT *, ROW_NUMBER() OVER (PARTITION BY row_key ORDER BY source_suid COLLATE BINARY DESC, unsafe_layer ASC) AS winner_rank FROM candidates
|
|
2084
|
+
), live AS (
|
|
2085
|
+
SELECT *, COUNT(*) OVER() AS total_count FROM ranked WHERE winner_rank = 1 AND tombstone = 0
|
|
2086
|
+
)
|
|
2087
|
+
SELECT row_key, value_json, row_version, source_suid, total_count FROM live
|
|
2088
|
+
ORDER BY source_suid COLLATE BINARY ${direction}, row_key COLLATE BINARY ${direction} LIMIT ? OFFSET ?`
|
|
2089
|
+
).bind(serviceId, viewId, generation, serviceId, viewId, generation, limit, offset).all();
|
|
2090
|
+
const rows = result.results.map((row) => ({
|
|
2091
|
+
serviceId,
|
|
2092
|
+
viewId,
|
|
2093
|
+
generation,
|
|
2094
|
+
rowKey: string(row, "row_key"),
|
|
2095
|
+
value: assertJsonValue(JSON.parse(string(row, "value_json")), "state-persistence"),
|
|
2096
|
+
rowVersion: integer(row, "row_version"),
|
|
2097
|
+
sourceSuid: string(row, "source_suid")
|
|
2098
|
+
}));
|
|
2099
|
+
return { rows, totalCount: result.results.length === 0 ? 0 : integer(result.results[0], "total_count") };
|
|
2100
|
+
}
|
|
2101
|
+
addIndex(statements, table, input, entry) {
|
|
2102
|
+
const typed = entry.valueType === "text" ? [entry.value, null, null] : entry.valueType === "integer" ? [null, entry.value, null] : [null, null, entry.value];
|
|
2103
|
+
statements.push(this.database.prepare(
|
|
2104
|
+
`INSERT INTO ${table} (service_id, view_id, generation, index_id, value_type, text_value, integer_value, real_value, row_key)
|
|
2105
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`
|
|
2106
|
+
).bind(input.serviceId, input.viewId, input.generation, entry.indexId, entry.valueType, ...typed, entry.rowKey));
|
|
2107
|
+
}
|
|
2108
|
+
};
|
|
2109
|
+
|
|
2110
|
+
// src/mv/MaterializedViewStore.ts
|
|
2111
|
+
var MaterializedViewStoreError = class extends Error {
|
|
2112
|
+
constructor(operation, code, message, options) {
|
|
2113
|
+
super(message, options);
|
|
2114
|
+
this.operation = operation;
|
|
2115
|
+
this.code = code;
|
|
2116
|
+
this.name = "MaterializedViewStoreError";
|
|
2117
|
+
}
|
|
2118
|
+
operation;
|
|
2119
|
+
code;
|
|
2120
|
+
};
|
|
2121
|
+
var MaterializedViewCasError = class extends MaterializedViewStoreError {
|
|
2122
|
+
constructor(message = "Materialized-view checkpoint CAS did not match") {
|
|
2123
|
+
super("apply", "MV_CAS_MISMATCH", message);
|
|
2124
|
+
this.name = "MaterializedViewCasError";
|
|
2125
|
+
}
|
|
2126
|
+
};
|
|
2127
|
+
var MaterializedViewPromotionCasError = class extends MaterializedViewStoreError {
|
|
2128
|
+
constructor(message = "Materialized-view active-generation CAS did not match") {
|
|
2129
|
+
super("promote", "MV_PROMOTION_CAS_MISMATCH", message);
|
|
2130
|
+
this.name = "MaterializedViewPromotionCasError";
|
|
2131
|
+
}
|
|
2132
|
+
};
|
|
2133
|
+
var MaterializedViewPatchError = class extends MaterializedViewStoreError {
|
|
2134
|
+
constructor(message = "Materialized-view JSON patch target row does not exist") {
|
|
2135
|
+
super("apply", "MV_PATCH_ROW_MISSING", message);
|
|
2136
|
+
this.name = "MaterializedViewPatchError";
|
|
2137
|
+
}
|
|
2138
|
+
};
|
|
2139
|
+
function asString2(value, name) {
|
|
2140
|
+
if (typeof value !== "string") throw new Error(`MV ${name} was not a string`);
|
|
2141
|
+
return value;
|
|
2142
|
+
}
|
|
2143
|
+
function asInteger(value, name) {
|
|
2144
|
+
const number = typeof value === "number" ? value : Number(value);
|
|
2145
|
+
if (!Number.isSafeInteger(number)) throw new Error(`MV ${name} was not a safe integer`);
|
|
2146
|
+
return number;
|
|
2147
|
+
}
|
|
2148
|
+
function asStatus(value) {
|
|
2149
|
+
if (value === "active" || value === "candidate" || value === "retired") return value;
|
|
2150
|
+
throw new Error("MV instance status was invalid");
|
|
2151
|
+
}
|
|
2152
|
+
function compareSuid2(left, right) {
|
|
2153
|
+
const leftBytes = new TextEncoder().encode(left);
|
|
2154
|
+
const rightBytes = new TextEncoder().encode(right);
|
|
2155
|
+
for (let index = 0; index < Math.min(leftBytes.length, rightBytes.length); index += 1) {
|
|
2156
|
+
const difference = leftBytes[index] - rightBytes[index];
|
|
2157
|
+
if (difference !== 0) return difference;
|
|
2158
|
+
}
|
|
2159
|
+
return leftBytes.length - rightBytes.length;
|
|
2160
|
+
}
|
|
2161
|
+
function maxReflectedSuid(rows) {
|
|
2162
|
+
let maximum = "";
|
|
2163
|
+
for (const row of rows) {
|
|
2164
|
+
if (maximum === "" || compareSuid2(row.sourceSuid, maximum) > 0) maximum = row.sourceSuid;
|
|
2165
|
+
}
|
|
2166
|
+
return maximum;
|
|
2167
|
+
}
|
|
2168
|
+
function instanceFrom(row) {
|
|
2169
|
+
return {
|
|
2170
|
+
serviceId: asString2(row.service_id, "service_id"),
|
|
2171
|
+
viewId: asString2(row.view_id, "view_id"),
|
|
2172
|
+
generation: asInteger(row.generation, "generation"),
|
|
2173
|
+
status: asStatus(row.status),
|
|
2174
|
+
lastSuid: asString2(row.last_suid, "last_suid"),
|
|
2175
|
+
definitionVersion: asInteger(row.definition_version, "definition_version"),
|
|
2176
|
+
updatedAt: asInteger(row.updated_at, "updated_at"),
|
|
2177
|
+
rebuildVerifiedAt: row.rebuild_verified_at === null || row.rebuild_verified_at === void 0 ? null : asInteger(row.rebuild_verified_at, "rebuild_verified_at"),
|
|
2178
|
+
rebuildRecoveryId: row.rebuild_recovery_id === null || row.rebuild_recovery_id === void 0 ? null : asString2(row.rebuild_recovery_id, "rebuild_recovery_id"),
|
|
2179
|
+
rebuildIncidentIdentity: row.rebuild_incident_identity === null || row.rebuild_incident_identity === void 0 ? null : asString2(row.rebuild_incident_identity, "rebuild_incident_identity"),
|
|
2180
|
+
rebuildIncidentGeneration: row.rebuild_incident_generation === null || row.rebuild_incident_generation === void 0 ? null : asInteger(row.rebuild_incident_generation, "rebuild_incident_generation"),
|
|
2181
|
+
rebuildSourceEventCount: row.rebuild_source_event_count === null || row.rebuild_source_event_count === void 0 ? null : asInteger(row.rebuild_source_event_count, "rebuild_source_event_count"),
|
|
2182
|
+
rebuildSourceMaxSuid: row.rebuild_source_max_suid === null || row.rebuild_source_max_suid === void 0 ? null : asString2(row.rebuild_source_max_suid, "rebuild_source_max_suid"),
|
|
2183
|
+
rebuildSourceHistoryDigest: row.rebuild_source_history_digest === null || row.rebuild_source_history_digest === void 0 ? null : asString2(row.rebuild_source_history_digest, "rebuild_source_history_digest")
|
|
2184
|
+
};
|
|
2185
|
+
}
|
|
2186
|
+
function valueType(value) {
|
|
2187
|
+
if (value === "text" || value === "integer" || value === "real") return value;
|
|
2188
|
+
throw new Error("MV index value type was invalid");
|
|
2189
|
+
}
|
|
2190
|
+
function indexValue(row, type) {
|
|
2191
|
+
if (type === "text") return asString2(row.text_value, "text_value");
|
|
2192
|
+
const number = typeof row[`${type}_value`] === "number" ? row[`${type}_value`] : Number(row[`${type}_value`]);
|
|
2193
|
+
if (!Number.isFinite(number) || type === "integer" && !Number.isSafeInteger(number)) {
|
|
2194
|
+
throw new Error(`MV ${type}_value was invalid`);
|
|
2195
|
+
}
|
|
2196
|
+
return number;
|
|
2197
|
+
}
|
|
2198
|
+
function operationId2() {
|
|
2199
|
+
return `mv-${crypto.randomUUID()}`;
|
|
2200
|
+
}
|
|
2201
|
+
function isCasFailure(error) {
|
|
2202
|
+
const message = String(error);
|
|
2203
|
+
return message.includes("mv_atomic_guards.checkpoint_match") || message.includes("MV_CAS_MISMATCH");
|
|
2204
|
+
}
|
|
2205
|
+
function isPatchFailure(error) {
|
|
2206
|
+
return String(error).includes("CHECK constraint failed: checkpoint_match");
|
|
2207
|
+
}
|
|
2208
|
+
var D1MaterializedViewStore = class {
|
|
2209
|
+
constructor(database) {
|
|
2210
|
+
this.database = database;
|
|
2211
|
+
this.unsafe = new UnsafeWindowMaterializedViewStore(database);
|
|
2212
|
+
}
|
|
2213
|
+
database;
|
|
2214
|
+
initialized = false;
|
|
2215
|
+
unsafe;
|
|
2216
|
+
async initialize() {
|
|
2217
|
+
if (this.initialized) return;
|
|
2218
|
+
await this.database.prepare("SELECT 1 FROM mv_instances LIMIT 1").all();
|
|
2219
|
+
await this.database.prepare("SELECT 1 FROM mv_atomic_guards LIMIT 1").all();
|
|
2220
|
+
await this.database.prepare("SELECT 1 FROM mv_checkpoint_ahead_findings LIMIT 1").all();
|
|
2221
|
+
await this.database.prepare("SELECT 1 FROM mv_ordering_quarantines LIMIT 1").all();
|
|
2222
|
+
this.initialized = true;
|
|
2223
|
+
}
|
|
2224
|
+
async createActive(input) {
|
|
2225
|
+
this.ready("create-active");
|
|
2226
|
+
const generation = input.generation ?? 0;
|
|
2227
|
+
this.validateGeneration(generation, "create-active");
|
|
2228
|
+
const lastSuid = input.lastSuid ?? "";
|
|
2229
|
+
if (lastSuid.length > 0) assertSortableUniqueId(lastSuid);
|
|
2230
|
+
try {
|
|
2231
|
+
await this.database.batch([
|
|
2232
|
+
this.database.prepare(
|
|
2233
|
+
`INSERT INTO mv_instances
|
|
2234
|
+
(service_id, view_id, generation, status, last_suid, definition_version, updated_at)
|
|
2235
|
+
VALUES (?, ?, ?, 'active', ?, ?, ?)`
|
|
2236
|
+
).bind(input.serviceId, input.viewId, generation, lastSuid, input.definitionVersion, input.updatedAt),
|
|
2237
|
+
this.database.prepare(
|
|
2238
|
+
`INSERT INTO mv_active_generations (service_id, view_id, generation, updated_at)
|
|
2239
|
+
VALUES (?, ?, ?, ?)`
|
|
2240
|
+
).bind(input.serviceId, input.viewId, generation, input.updatedAt)
|
|
2241
|
+
]);
|
|
2242
|
+
} catch (error) {
|
|
2243
|
+
throw new MaterializedViewStoreError("create-active", "MV_INSTANCE_EXISTS", `Active materialized view already exists: ${String(error)}`, { cause: error });
|
|
2244
|
+
}
|
|
2245
|
+
return this.readInstance(input.serviceId, input.viewId, generation);
|
|
2246
|
+
}
|
|
2247
|
+
async createCandidate(input) {
|
|
2248
|
+
this.ready("create-candidate");
|
|
2249
|
+
this.validateGeneration(input.generation, "create-candidate");
|
|
2250
|
+
if ((input.lastSuid ?? "").length > 0) assertSortableUniqueId(input.lastSuid);
|
|
2251
|
+
try {
|
|
2252
|
+
await this.database.prepare(
|
|
2253
|
+
`INSERT INTO mv_instances
|
|
2254
|
+
(service_id, view_id, generation, status, last_suid, definition_version, updated_at)
|
|
2255
|
+
VALUES (?, ?, ?, 'candidate', ?, ?, ?)`
|
|
2256
|
+
).bind(
|
|
2257
|
+
input.serviceId,
|
|
2258
|
+
input.viewId,
|
|
2259
|
+
input.generation,
|
|
2260
|
+
input.lastSuid ?? "",
|
|
2261
|
+
input.definitionVersion,
|
|
2262
|
+
input.updatedAt
|
|
2263
|
+
).run();
|
|
2264
|
+
} catch (error) {
|
|
2265
|
+
throw new MaterializedViewStoreError("create-candidate", "MV_INSTANCE_EXISTS", `Candidate generation already exists: ${String(error)}`, { cause: error });
|
|
2266
|
+
}
|
|
2267
|
+
return this.readInstance(input.serviceId, input.viewId, input.generation);
|
|
2268
|
+
}
|
|
2269
|
+
async beginRebuild(input) {
|
|
2270
|
+
this.ready("create-candidate");
|
|
2271
|
+
const generation = input.generation ?? await this.nextGeneration(input.serviceId, input.viewId);
|
|
2272
|
+
return this.createCandidate({ ...input, generation });
|
|
2273
|
+
}
|
|
2274
|
+
async nextGeneration(serviceId, viewId) {
|
|
2275
|
+
this.ready("create-candidate");
|
|
2276
|
+
const row = await this.database.prepare(
|
|
2277
|
+
`SELECT COALESCE(MAX(generation), -1) AS generation FROM mv_instances WHERE service_id = ? AND view_id = ?`
|
|
2278
|
+
).bind(serviceId, viewId).first();
|
|
2279
|
+
return asInteger(row?.generation ?? -1, "generation") + 1;
|
|
2280
|
+
}
|
|
2281
|
+
async readInstance(serviceId, viewId, generation) {
|
|
2282
|
+
this.ready("initialize");
|
|
2283
|
+
const row = await this.database.prepare(
|
|
2284
|
+
`SELECT service_id, view_id, generation, status, last_suid, definition_version, updated_at,
|
|
2285
|
+
rebuild_verified_at, rebuild_recovery_id, rebuild_incident_identity,
|
|
2286
|
+
rebuild_incident_generation, rebuild_source_event_count,
|
|
2287
|
+
rebuild_source_max_suid, rebuild_source_history_digest
|
|
2288
|
+
FROM mv_instances WHERE service_id = ? AND view_id = ? AND generation = ?`
|
|
2289
|
+
).bind(serviceId, viewId, generation).first();
|
|
2290
|
+
return row === null || row === void 0 ? void 0 : instanceFrom(row);
|
|
2291
|
+
}
|
|
2292
|
+
async readActive(serviceId, viewId) {
|
|
2293
|
+
this.ready("initialize");
|
|
2294
|
+
const row = await this.database.prepare(
|
|
2295
|
+
`SELECT instance.service_id, instance.view_id, instance.generation, instance.status,
|
|
2296
|
+
instance.last_suid, instance.definition_version, instance.updated_at,
|
|
2297
|
+
instance.rebuild_verified_at, instance.rebuild_recovery_id,
|
|
2298
|
+
instance.rebuild_incident_identity, instance.rebuild_incident_generation,
|
|
2299
|
+
instance.rebuild_source_event_count, instance.rebuild_source_max_suid,
|
|
2300
|
+
instance.rebuild_source_history_digest
|
|
2301
|
+
FROM mv_active_generations pointer
|
|
2302
|
+
JOIN mv_instances instance
|
|
2303
|
+
ON instance.service_id = pointer.service_id
|
|
2304
|
+
AND instance.view_id = pointer.view_id
|
|
2305
|
+
AND instance.generation = pointer.generation
|
|
2306
|
+
WHERE pointer.service_id = ? AND pointer.view_id = ?`
|
|
2307
|
+
).bind(serviceId, viewId).first();
|
|
2308
|
+
return row === null || row === void 0 ? void 0 : instanceFrom(row);
|
|
2309
|
+
}
|
|
2310
|
+
async readRows(serviceId, viewId, generation) {
|
|
2311
|
+
this.ready("initialize");
|
|
2312
|
+
const selected = generation ?? (await this.readActive(serviceId, viewId))?.generation;
|
|
2313
|
+
if (selected === void 0) return [];
|
|
2314
|
+
const result = await this.database.prepare(
|
|
2315
|
+
`SELECT service_id, view_id, generation, row_key, value_json, row_version, source_suid
|
|
2316
|
+
FROM mv_rows
|
|
2317
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?
|
|
2318
|
+
ORDER BY row_key COLLATE BINARY ASC`
|
|
2319
|
+
).bind(serviceId, viewId, selected).all();
|
|
2320
|
+
return result.results.map((row) => this.rowFrom(row));
|
|
2321
|
+
}
|
|
2322
|
+
async readIndexEntries(serviceId, viewId, generation) {
|
|
2323
|
+
this.ready("initialize");
|
|
2324
|
+
const selected = generation ?? (await this.readActive(serviceId, viewId))?.generation;
|
|
2325
|
+
if (selected === void 0) return [];
|
|
2326
|
+
const result = await this.database.prepare(
|
|
2327
|
+
`SELECT service_id, view_id, generation, index_id, value_type,
|
|
2328
|
+
text_value, integer_value, real_value, row_key
|
|
2329
|
+
FROM mv_index_entries
|
|
2330
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?
|
|
2331
|
+
ORDER BY index_id COLLATE BINARY ASC, value_type COLLATE BINARY ASC,
|
|
2332
|
+
COALESCE(text_value, CAST(integer_value AS TEXT), CAST(real_value AS TEXT)) COLLATE BINARY ASC,
|
|
2333
|
+
row_key COLLATE BINARY ASC`
|
|
2334
|
+
).bind(serviceId, viewId, selected).all();
|
|
2335
|
+
return result.results.map((row) => this.indexFrom(row));
|
|
2336
|
+
}
|
|
2337
|
+
/** Read rows through a declared index without exposing SQL identifiers to callers. */
|
|
2338
|
+
async queryRows(serviceId, viewId, options = {}) {
|
|
2339
|
+
this.ready("initialize");
|
|
2340
|
+
const selected = options.generation ?? (await this.readActive(serviceId, viewId))?.generation;
|
|
2341
|
+
if (selected === void 0) return [];
|
|
2342
|
+
const limit = options.limit === void 0 ? 100 : options.limit;
|
|
2343
|
+
const offset = options.offset ?? 0;
|
|
2344
|
+
if (limit !== null && (!Number.isSafeInteger(limit) || limit < 0) || !Number.isSafeInteger(offset) || offset < 0) {
|
|
2345
|
+
throw new MaterializedViewStoreError("initialize", "MV_VALUE_INVALID", "MV query limit/offset must be non-negative integers");
|
|
2346
|
+
}
|
|
2347
|
+
if (options.indexId === void 0) {
|
|
2348
|
+
const limitClause2 = limit === null ? "" : " LIMIT ? OFFSET ?";
|
|
2349
|
+
const direction2 = options.descending === true ? "DESC" : "ASC";
|
|
2350
|
+
const values2 = [serviceId, viewId, selected];
|
|
2351
|
+
if (limit !== null) values2.push(limit, offset);
|
|
2352
|
+
const result2 = await this.database.prepare(
|
|
2353
|
+
`SELECT service_id, view_id, generation, row_key, value_json, row_version, source_suid
|
|
2354
|
+
FROM mv_rows
|
|
2355
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?
|
|
2356
|
+
ORDER BY row_key COLLATE BINARY ${direction2}${limitClause2}`
|
|
2357
|
+
).bind(...values2).all();
|
|
2358
|
+
return result2.results.map((row) => this.rowFrom(row));
|
|
2359
|
+
}
|
|
2360
|
+
const valueType2 = options.valueType;
|
|
2361
|
+
const orderColumn = valueType2 === "text" ? "index_entry.text_value" : valueType2 === "integer" ? "index_entry.integer_value" : valueType2 === "real" ? "index_entry.real_value" : "index_entry.row_key";
|
|
2362
|
+
const direction = options.descending === true ? "DESC" : "ASC";
|
|
2363
|
+
const indexPredicate = options.indexId === void 0 ? "" : " AND index_entry.index_id = ?";
|
|
2364
|
+
const typePredicate = valueType2 === void 0 ? "" : " AND index_entry.value_type = ?";
|
|
2365
|
+
const values = [serviceId, viewId, selected];
|
|
2366
|
+
if (options.indexId !== void 0) values.push(options.indexId);
|
|
2367
|
+
if (valueType2 !== void 0) values.push(valueType2);
|
|
2368
|
+
if (limit !== null) values.push(limit, offset);
|
|
2369
|
+
const limitClause = limit === null ? "" : " LIMIT ? OFFSET ?";
|
|
2370
|
+
const result = await this.database.prepare(
|
|
2371
|
+
`SELECT row.service_id, row.view_id, row.generation, row.row_key,
|
|
2372
|
+
row.value_json, row.row_version, row.source_suid
|
|
2373
|
+
FROM mv_index_entries index_entry
|
|
2374
|
+
JOIN mv_rows row
|
|
2375
|
+
ON row.service_id = index_entry.service_id
|
|
2376
|
+
AND row.view_id = index_entry.view_id
|
|
2377
|
+
AND row.generation = index_entry.generation
|
|
2378
|
+
AND row.row_key = index_entry.row_key
|
|
2379
|
+
WHERE row.service_id = ? AND row.view_id = ? AND row.generation = ?${indexPredicate}${typePredicate}
|
|
2380
|
+
ORDER BY ${orderColumn} COLLATE BINARY ${direction}, row.row_key COLLATE BINARY ${direction}${limitClause}`
|
|
2381
|
+
).bind(...values).all();
|
|
2382
|
+
return result.results.map((row) => this.rowFrom(row));
|
|
2383
|
+
}
|
|
2384
|
+
/** SDT-G23 composed server page; count and page rows share one statement. */
|
|
2385
|
+
async queryRowsWithTotal(serviceId, viewId, options = {}) {
|
|
2386
|
+
this.ready("initialize");
|
|
2387
|
+
const selected = options.generation ?? (await this.readActive(serviceId, viewId))?.generation;
|
|
2388
|
+
if (selected === void 0) return { rows: [], totalCount: 0 };
|
|
2389
|
+
const limit = options.limit === null ? -1 : options.limit === void 0 ? 100 : options.limit;
|
|
2390
|
+
const offset = options.offset ?? 0;
|
|
2391
|
+
if (!Number.isSafeInteger(limit) || limit < -1 || !Number.isSafeInteger(offset) || offset < 0) {
|
|
2392
|
+
throw new MaterializedViewStoreError("initialize", "MV_VALUE_INVALID", "MV composed query needs limit -1 or a non-negative limit/offset");
|
|
2393
|
+
}
|
|
2394
|
+
return this.unsafe.queryComposedPage(serviceId, viewId, selected, limit, offset, options.descending === true);
|
|
2395
|
+
}
|
|
2396
|
+
/**
|
|
2397
|
+
* SDT-G55's explicit list-read port. Existing composed reads intentionally
|
|
2398
|
+
* keep their G23 semantics; only this port can include unsafe rows, and it
|
|
2399
|
+
* can include only rows newer than the active safe checkpoint.
|
|
2400
|
+
*/
|
|
2401
|
+
async readListPage(serviceId, viewId, options = {}) {
|
|
2402
|
+
this.ready("initialize");
|
|
2403
|
+
const active = await this.readActive(serviceId, viewId);
|
|
2404
|
+
if (active === void 0) return { rows: [], totalCount: 0, readHead: "" };
|
|
2405
|
+
const selected = options.generation ?? active.generation;
|
|
2406
|
+
const selectedInstance = selected === active.generation ? active : await this.readInstance(serviceId, viewId, selected);
|
|
2407
|
+
if (selectedInstance === void 0) {
|
|
2408
|
+
throw new MaterializedViewStoreError("initialize", "MV_INSTANCE_MISSING", "Requested materialized-view generation is unavailable");
|
|
2409
|
+
}
|
|
2410
|
+
const limit = options.limit === null ? -1 : options.limit === void 0 ? 100 : options.limit;
|
|
2411
|
+
const offset = options.offset ?? 0;
|
|
2412
|
+
if (!Number.isSafeInteger(limit) || limit < -1 || !Number.isSafeInteger(offset) || offset < 0) {
|
|
2413
|
+
throw new MaterializedViewStoreError("initialize", "MV_VALUE_INVALID", "MV list query needs limit -1 or a non-negative limit/offset");
|
|
2414
|
+
}
|
|
2415
|
+
if (options.indexId !== void 0 || options.valueType !== void 0) {
|
|
2416
|
+
throw new MaterializedViewStoreError("initialize", "MV_VALUE_INVALID", "MV list query does not support an index selector");
|
|
2417
|
+
}
|
|
2418
|
+
const direction = options.descending === true ? "DESC" : "ASC";
|
|
2419
|
+
const safeRows = `SELECT row_key, value_json, row_version, source_suid, 0 AS tombstone, 0 AS unsafe_layer FROM mv_rows
|
|
2420
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?`;
|
|
2421
|
+
const result = options.consistency === "unsafe" ? await this.database.prepare(
|
|
2422
|
+
`WITH candidates AS (
|
|
2423
|
+
${safeRows}
|
|
2424
|
+
UNION ALL
|
|
2425
|
+
SELECT row_key, value_json, row_version, source_suid, tombstone, 1 AS unsafe_layer FROM mv_unsafe_rows
|
|
2426
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?
|
|
2427
|
+
AND source_suid COLLATE BINARY > ? COLLATE BINARY
|
|
2428
|
+
), ranked AS (
|
|
2429
|
+
SELECT *, ROW_NUMBER() OVER (PARTITION BY row_key ORDER BY source_suid COLLATE BINARY DESC, unsafe_layer ASC) AS winner_rank FROM candidates
|
|
2430
|
+
), live AS (
|
|
2431
|
+
SELECT *, COUNT(*) OVER() AS total_count FROM ranked WHERE winner_rank = 1 AND tombstone = 0
|
|
2432
|
+
)
|
|
2433
|
+
SELECT row_key, value_json, row_version, source_suid, total_count FROM live
|
|
2434
|
+
ORDER BY source_suid COLLATE BINARY ${direction}, row_key COLLATE BINARY ${direction} LIMIT ? OFFSET ?`
|
|
2435
|
+
).bind(serviceId, viewId, selected, serviceId, viewId, selected, selectedInstance.lastSuid, limit, offset).all() : await this.database.prepare(
|
|
2436
|
+
`WITH live AS (
|
|
2437
|
+
SELECT row_key, value_json, row_version, source_suid, COUNT(*) OVER() AS total_count FROM mv_rows
|
|
2438
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?
|
|
2439
|
+
)
|
|
2440
|
+
SELECT row_key, value_json, row_version, source_suid, total_count FROM live
|
|
2441
|
+
ORDER BY source_suid COLLATE BINARY ${direction}, row_key COLLATE BINARY ${direction} LIMIT ? OFFSET ?`
|
|
2442
|
+
).bind(serviceId, viewId, selected, limit, offset).all();
|
|
2443
|
+
const rows = result.results.map((row) => ({
|
|
2444
|
+
serviceId,
|
|
2445
|
+
viewId,
|
|
2446
|
+
generation: selected,
|
|
2447
|
+
rowKey: asString2(row.row_key, "row_key"),
|
|
2448
|
+
value: assertJsonValue(JSON.parse(asString2(row.value_json, "value_json")), "state-persistence"),
|
|
2449
|
+
rowVersion: asInteger(row.row_version, "row_version"),
|
|
2450
|
+
sourceSuid: asString2(row.source_suid, "source_suid")
|
|
2451
|
+
}));
|
|
2452
|
+
return {
|
|
2453
|
+
rows,
|
|
2454
|
+
totalCount: result.results.length === 0 ? 0 : asInteger(result.results[0].total_count, "total_count"),
|
|
2455
|
+
// Safe reads report the active checkpoint even for an empty page. Unsafe
|
|
2456
|
+
// reads instead report only what this page actually reflected.
|
|
2457
|
+
readHead: options.consistency === "unsafe" ? maxReflectedSuid(rows) : selectedInstance.lastSuid
|
|
2458
|
+
};
|
|
2459
|
+
}
|
|
2460
|
+
/** Explicit unsafe port: callers must choose this exceptional immediate lane. */
|
|
2461
|
+
unsafeWindow() {
|
|
2462
|
+
this.ready("apply");
|
|
2463
|
+
return this.unsafe;
|
|
2464
|
+
}
|
|
2465
|
+
/** Runs repairable unsafe GC after an idle or advancing safe catch-up pass. */
|
|
2466
|
+
async collectUnsafeGarbage(serviceId, viewId, generation, definitionVersion, safeHead) {
|
|
2467
|
+
this.ready("apply");
|
|
2468
|
+
return this.unsafe.collectEligible(serviceId, viewId, generation, definitionVersion, safeHead);
|
|
2469
|
+
}
|
|
2470
|
+
async hasTargetReceipt(serviceId, viewId, eventId, suid) {
|
|
2471
|
+
this.ready("initialize");
|
|
2472
|
+
return this.unsafe.hasTargetReceipt(serviceId, viewId, eventId, suid);
|
|
2473
|
+
}
|
|
2474
|
+
/**
|
|
2475
|
+
* Read all G31 wait facts in one static D1 statement. Every table access is
|
|
2476
|
+
* keyed by service/view/active generation (and target identity where
|
|
2477
|
+
* applicable); no row or source-history scan is permitted on this path.
|
|
2478
|
+
*/
|
|
2479
|
+
async readWaitForState(serviceId, viewId, target) {
|
|
2480
|
+
this.ready("initialize");
|
|
2481
|
+
const row = await this.database.prepare(
|
|
2482
|
+
`WITH active AS (
|
|
2483
|
+
SELECT instance.generation, instance.definition_version, instance.last_suid
|
|
2484
|
+
FROM mv_active_generations pointer
|
|
2485
|
+
JOIN mv_instances instance
|
|
2486
|
+
ON instance.service_id = pointer.service_id
|
|
2487
|
+
AND instance.view_id = pointer.view_id
|
|
2488
|
+
AND instance.generation = pointer.generation
|
|
2489
|
+
WHERE pointer.service_id = ? AND pointer.view_id = ?
|
|
2490
|
+
)
|
|
2491
|
+
SELECT
|
|
2492
|
+
(SELECT generation FROM active) AS active_generation,
|
|
2493
|
+
(SELECT definition_version FROM active) AS active_definition_version,
|
|
2494
|
+
COALESCE((SELECT last_suid FROM active), '') AS safe_contiguous_head,
|
|
2495
|
+
CASE WHEN EXISTS (
|
|
2496
|
+
SELECT 1
|
|
2497
|
+
FROM mv_wait_receipts receipt
|
|
2498
|
+
JOIN active
|
|
2499
|
+
ON active.generation = receipt.generation
|
|
2500
|
+
AND active.definition_version = receipt.definition_version
|
|
2501
|
+
WHERE receipt.service_id = ? AND receipt.view_id = ?
|
|
2502
|
+
AND receipt.event_id = ? AND receipt.suid = ?
|
|
2503
|
+
) THEN 1 ELSE 0 END AS target_receipt,
|
|
2504
|
+
CASE WHEN EXISTS (
|
|
2505
|
+
SELECT 1
|
|
2506
|
+
FROM mv_checkpoint_ahead_findings finding
|
|
2507
|
+
JOIN active
|
|
2508
|
+
ON active.generation = finding.generation
|
|
2509
|
+
WHERE finding.service_id = ? AND finding.view_id = ?
|
|
2510
|
+
) THEN 1 ELSE 0 END AS checkpoint_ahead,
|
|
2511
|
+
COALESCE((
|
|
2512
|
+
SELECT arrival.rebuild_required
|
|
2513
|
+
FROM mv_unsafe_arrivals arrival
|
|
2514
|
+
JOIN active
|
|
2515
|
+
ON active.generation = arrival.generation
|
|
2516
|
+
WHERE arrival.service_id = ? AND arrival.view_id = ?
|
|
2517
|
+
), 0) AS rebuild_required,
|
|
2518
|
+
CASE WHEN EXISTS (
|
|
2519
|
+
SELECT 1
|
|
2520
|
+
FROM mv_wait_target_poison finding
|
|
2521
|
+
JOIN active
|
|
2522
|
+
ON active.generation = finding.generation
|
|
2523
|
+
AND active.definition_version = finding.definition_version
|
|
2524
|
+
WHERE finding.service_id = ? AND finding.view_id = ?
|
|
2525
|
+
AND finding.suid = ?
|
|
2526
|
+
AND (? IS NULL OR finding.event_id = ?)
|
|
2527
|
+
) THEN 1 ELSE 0 END AS poison`
|
|
2528
|
+
).bind(
|
|
2529
|
+
serviceId,
|
|
2530
|
+
viewId,
|
|
2531
|
+
serviceId,
|
|
2532
|
+
viewId,
|
|
2533
|
+
target.eventId ?? "",
|
|
2534
|
+
target.suid,
|
|
2535
|
+
serviceId,
|
|
2536
|
+
viewId,
|
|
2537
|
+
serviceId,
|
|
2538
|
+
viewId,
|
|
2539
|
+
serviceId,
|
|
2540
|
+
viewId,
|
|
2541
|
+
target.suid,
|
|
2542
|
+
target.eventId ?? null,
|
|
2543
|
+
target.eventId ?? null
|
|
2544
|
+
).first();
|
|
2545
|
+
if (row === null || row === void 0) {
|
|
2546
|
+
throw new MaterializedViewStoreError("initialize", "MV_STORE_OPERATION_FAILED", "D1 wait-state query returned no row");
|
|
2547
|
+
}
|
|
2548
|
+
const generation = row.active_generation === null || row.active_generation === void 0 ? void 0 : asInteger(row.active_generation, "active_generation");
|
|
2549
|
+
const definitionVersion = row.active_definition_version === null || row.active_definition_version === void 0 ? void 0 : asInteger(row.active_definition_version, "active_definition_version");
|
|
2550
|
+
return {
|
|
2551
|
+
activeGeneration: generation,
|
|
2552
|
+
activeDefinitionVersion: definitionVersion,
|
|
2553
|
+
safeContiguousHead: asString2(row.safe_contiguous_head, "safe_contiguous_head"),
|
|
2554
|
+
targetReceipt: asInteger(row.target_receipt, "target_receipt") === 1,
|
|
2555
|
+
checkpointAhead: asInteger(row.checkpoint_ahead, "checkpoint_ahead") === 1,
|
|
2556
|
+
rebuildRequired: asInteger(row.rebuild_required, "rebuild_required") === 1,
|
|
2557
|
+
poison: asInteger(row.poison, "poison") === 1
|
|
2558
|
+
};
|
|
2559
|
+
}
|
|
2560
|
+
/** Idempotent operational fact: a stored Queue event needs unsafe retry/DLQ attention. */
|
|
2561
|
+
async recordUnsafeFailureFinding(input) {
|
|
2562
|
+
this.ready("apply");
|
|
2563
|
+
const legacyFinding = this.database.prepare(
|
|
2564
|
+
`INSERT INTO mv_unsafe_failure_findings
|
|
2565
|
+
(service_id, view_id, event_id, suid, classification, observed_at)
|
|
2566
|
+
VALUES (?, ?, ?, ?, 'UNSAFE_APPLY_RETRY', ?)
|
|
2567
|
+
ON CONFLICT (service_id, view_id, event_id, suid, classification) DO NOTHING`
|
|
2568
|
+
).bind(input.serviceId, input.viewId, input.eventId, input.suid, input.observedAt);
|
|
2569
|
+
const targetPoison = input.generation === void 0 ? this.database.prepare(
|
|
2570
|
+
`INSERT INTO mv_wait_target_poison
|
|
2571
|
+
(service_id, view_id, generation, definition_version, event_id, suid, classification, observed_at)
|
|
2572
|
+
SELECT instance.service_id, instance.view_id, instance.generation, instance.definition_version, ?, ?, 'UNSAFE_APPLY_RETRY', ?
|
|
2573
|
+
FROM mv_active_generations active
|
|
2574
|
+
JOIN mv_instances instance
|
|
2575
|
+
ON instance.service_id = active.service_id
|
|
2576
|
+
AND instance.view_id = active.view_id
|
|
2577
|
+
AND instance.generation = active.generation
|
|
2578
|
+
WHERE active.service_id = ? AND active.view_id = ?
|
|
2579
|
+
ON CONFLICT DO NOTHING`
|
|
2580
|
+
).bind(input.eventId, input.suid, input.observedAt, input.serviceId, input.viewId) : this.database.prepare(
|
|
2581
|
+
`INSERT INTO mv_wait_target_poison
|
|
2582
|
+
(service_id, view_id, generation, definition_version, event_id, suid, classification, observed_at)
|
|
2583
|
+
SELECT instance.service_id, instance.view_id, instance.generation, instance.definition_version, ?, ?, 'UNSAFE_APPLY_RETRY', ?
|
|
2584
|
+
FROM mv_instances instance
|
|
2585
|
+
WHERE instance.service_id = ? AND instance.view_id = ? AND instance.generation = ?
|
|
2586
|
+
ON CONFLICT DO NOTHING`
|
|
2587
|
+
).bind(input.eventId, input.suid, input.observedAt, input.serviceId, input.viewId, input.generation);
|
|
2588
|
+
await this.database.batch([legacyFinding, targetPoison]);
|
|
2589
|
+
}
|
|
2590
|
+
/**
|
|
2591
|
+
* Persist the source-reset observation exactly once for the affected
|
|
2592
|
+
* generation. This deliberately never rewinds `mv_instances.last_suid`.
|
|
2593
|
+
*/
|
|
2594
|
+
async recordCheckpointAhead(input) {
|
|
2595
|
+
this.ready("apply");
|
|
2596
|
+
await this.database.prepare(
|
|
2597
|
+
`INSERT INTO mv_checkpoint_ahead_findings
|
|
2598
|
+
(service_id, view_id, generation, checkpoint_suid, store_max_suid, observed_at)
|
|
2599
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
2600
|
+
ON CONFLICT (service_id, view_id, generation) DO NOTHING`
|
|
2601
|
+
).bind(
|
|
2602
|
+
input.serviceId,
|
|
2603
|
+
input.viewId,
|
|
2604
|
+
input.generation,
|
|
2605
|
+
input.checkpointSuid,
|
|
2606
|
+
input.storeMaxSuid,
|
|
2607
|
+
input.observedAt
|
|
2608
|
+
).run();
|
|
2609
|
+
}
|
|
2610
|
+
/** An open finding on the active generation fail-closes composed MV reads. */
|
|
2611
|
+
async hasCheckpointAheadFinding(serviceId, viewId) {
|
|
2612
|
+
this.ready("initialize");
|
|
2613
|
+
const result = await this.database.prepare(
|
|
2614
|
+
`SELECT 1 AS present
|
|
2615
|
+
FROM mv_checkpoint_ahead_findings finding
|
|
2616
|
+
JOIN mv_active_generations active
|
|
2617
|
+
ON active.service_id = finding.service_id
|
|
2618
|
+
AND active.view_id = finding.view_id
|
|
2619
|
+
AND active.generation = finding.generation
|
|
2620
|
+
WHERE finding.service_id = ? AND finding.view_id = ?
|
|
2621
|
+
LIMIT 1`
|
|
2622
|
+
).bind(serviceId, viewId).first();
|
|
2623
|
+
return result?.present === 1;
|
|
2624
|
+
}
|
|
2625
|
+
/** Open quarantine is keyed to the active generation; old generations are not a public read gate. */
|
|
2626
|
+
async readOrderingQuarantine(serviceId, viewId) {
|
|
2627
|
+
this.ready("initialize");
|
|
2628
|
+
const row = await this.database.prepare(
|
|
2629
|
+
`SELECT quarantine.service_id, quarantine.view_id, quarantine.generation,
|
|
2630
|
+
quarantine.checkpoint_suid, quarantine.late_suid, quarantine.event_id,
|
|
2631
|
+
quarantine.classification, quarantine.status, quarantine.observed_at,
|
|
2632
|
+
quarantine.resolved_at, quarantine.incident_identity
|
|
2633
|
+
FROM mv_ordering_quarantines quarantine
|
|
2634
|
+
JOIN mv_active_generations active
|
|
2635
|
+
ON active.service_id = quarantine.service_id
|
|
2636
|
+
AND active.view_id = quarantine.view_id
|
|
2637
|
+
AND active.generation = quarantine.generation
|
|
2638
|
+
WHERE quarantine.service_id = ? AND quarantine.view_id = ? AND quarantine.status = 'open'
|
|
2639
|
+
LIMIT 1`
|
|
2640
|
+
).bind(serviceId, viewId).first();
|
|
2641
|
+
if (row === null || row === void 0) return void 0;
|
|
2642
|
+
const classification = asString2(row.classification, "ordering_quarantine.classification");
|
|
2643
|
+
if (classification !== "LATE_LOWER_SUID" && classification !== "ORDER_VIOLATION") {
|
|
2644
|
+
throw new MaterializedViewStoreError("initialize", "MV_STORE_OPERATION_FAILED", "Materialized-view ordering quarantine classification was invalid");
|
|
2645
|
+
}
|
|
2646
|
+
const status = asString2(row.status, "ordering_quarantine.status");
|
|
2647
|
+
if (status !== "open" && status !== "resolved") {
|
|
2648
|
+
throw new MaterializedViewStoreError("initialize", "MV_STORE_OPERATION_FAILED", "Materialized-view ordering quarantine status was invalid");
|
|
2649
|
+
}
|
|
2650
|
+
return {
|
|
2651
|
+
serviceId: asString2(row.service_id, "ordering_quarantine.service_id"),
|
|
2652
|
+
viewId: asString2(row.view_id, "ordering_quarantine.view_id"),
|
|
2653
|
+
generation: asInteger(row.generation, "ordering_quarantine.generation"),
|
|
2654
|
+
checkpointSuid: asString2(row.checkpoint_suid, "ordering_quarantine.checkpoint_suid"),
|
|
2655
|
+
lateSuid: asString2(row.late_suid, "ordering_quarantine.late_suid"),
|
|
2656
|
+
eventId: asString2(row.event_id, "ordering_quarantine.event_id"),
|
|
2657
|
+
classification,
|
|
2658
|
+
status,
|
|
2659
|
+
observedAt: asInteger(row.observed_at, "ordering_quarantine.observed_at"),
|
|
2660
|
+
resolvedAt: row.resolved_at === null || row.resolved_at === void 0 ? null : asInteger(row.resolved_at, "ordering_quarantine.resolved_at"),
|
|
2661
|
+
incidentIdentity: row.incident_identity === null || row.incident_identity === void 0 ? null : asString2(row.incident_identity, "ordering_quarantine.incident_identity")
|
|
2662
|
+
};
|
|
2663
|
+
}
|
|
2664
|
+
async readActiveGeneration(serviceId, viewId) {
|
|
2665
|
+
this.ready("initialize");
|
|
2666
|
+
return (await this.readActive(serviceId, viewId))?.generation;
|
|
2667
|
+
}
|
|
2668
|
+
/** Persist the detector result before stopping the safe pass. Repeated observations are idempotent. */
|
|
2669
|
+
async recordOrderingQuarantine(input) {
|
|
2670
|
+
this.ready("apply");
|
|
2671
|
+
this.validateGeneration(input.generation, "apply");
|
|
2672
|
+
const incidentIdentity = `${input.classification}|${input.serviceId}|${input.viewId}|${input.checkpointSuid}|${input.lateSuid}|${input.eventId}`;
|
|
2673
|
+
await this.database.prepare(
|
|
2674
|
+
`INSERT INTO mv_ordering_quarantines
|
|
2675
|
+
(service_id, view_id, generation, checkpoint_suid, late_suid, event_id, classification, status, observed_at, resolved_at, incident_identity)
|
|
2676
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, NULL, ?)
|
|
2677
|
+
ON CONFLICT (service_id, view_id, generation) DO UPDATE SET
|
|
2678
|
+
checkpoint_suid = excluded.checkpoint_suid,
|
|
2679
|
+
late_suid = excluded.late_suid,
|
|
2680
|
+
event_id = excluded.event_id,
|
|
2681
|
+
classification = excluded.classification,
|
|
2682
|
+
status = 'open',
|
|
2683
|
+
observed_at = MIN(mv_ordering_quarantines.observed_at, excluded.observed_at),
|
|
2684
|
+
resolved_at = NULL,
|
|
2685
|
+
incident_identity = excluded.incident_identity`
|
|
2686
|
+
).bind(
|
|
2687
|
+
input.serviceId,
|
|
2688
|
+
input.viewId,
|
|
2689
|
+
input.generation,
|
|
2690
|
+
input.checkpointSuid,
|
|
2691
|
+
input.lateSuid,
|
|
2692
|
+
input.eventId,
|
|
2693
|
+
input.classification,
|
|
2694
|
+
input.observedAt,
|
|
2695
|
+
incidentIdentity
|
|
2696
|
+
).run();
|
|
2697
|
+
}
|
|
2698
|
+
/**
|
|
2699
|
+
* Mark a candidate only after the real rebuild path observed the complete
|
|
2700
|
+
* source history. The proof is incident-bound when an ordering quarantine is
|
|
2701
|
+
* open, and an empty row set is valid when the source history itself is
|
|
2702
|
+
* complete (for example, a rebuild whose materializer deletes every row).
|
|
2703
|
+
*/
|
|
2704
|
+
async markGenerationRebuilt(input) {
|
|
2705
|
+
this.ready("promote");
|
|
2706
|
+
this.validateGeneration(input.generation, "promote");
|
|
2707
|
+
if (input.rebuildId.length === 0 || !Number.isSafeInteger(input.sourceEventCount) || input.sourceEventCount < 0 || input.sourceEventIds.length !== input.sourceEventCount || input.sourceSuids.length !== input.sourceEventCount || (input.sourceEventCount === 0 ? input.sourceMaxSuid !== "" : input.sourceMaxSuid.length === 0) || input.sourceHistoryDigest.length === 0) {
|
|
2708
|
+
throw new MaterializedViewStoreError("promote", "MV_REBUILD_NOT_VERIFIED", "Rebuild proof does not describe a complete source history");
|
|
2709
|
+
}
|
|
2710
|
+
const quarantine = await this.database.prepare(
|
|
2711
|
+
`SELECT generation, incident_identity, event_id, late_suid
|
|
2712
|
+
FROM mv_ordering_quarantines
|
|
2713
|
+
WHERE service_id = ? AND view_id = ? AND status = 'open'
|
|
2714
|
+
ORDER BY generation DESC LIMIT 1`
|
|
2715
|
+
).bind(input.serviceId, input.viewId).first();
|
|
2716
|
+
const incidentGeneration = quarantine === null || quarantine === void 0 ? null : asInteger(quarantine.generation, "rebuild.incident_generation");
|
|
2717
|
+
const incidentIdentity = quarantine === null || quarantine === void 0 ? null : typeof quarantine.incident_identity === "string" && quarantine.incident_identity.length > 0 ? quarantine.incident_identity : null;
|
|
2718
|
+
if (quarantine !== null && quarantine !== void 0) {
|
|
2719
|
+
if (incidentIdentity === null) {
|
|
2720
|
+
throw new MaterializedViewStoreError("promote", "MV_REBUILD_NOT_VERIFIED", "Ordering quarantine has no durable incident identity");
|
|
2721
|
+
}
|
|
2722
|
+
const incidentEventId = asString2(quarantine.event_id, "rebuild.incident_event_id");
|
|
2723
|
+
const incidentLateSuid = asString2(quarantine.late_suid, "rebuild.incident_late_suid");
|
|
2724
|
+
if (!input.sourceEventIds.includes(incidentEventId) || !input.sourceSuids.includes(incidentLateSuid)) {
|
|
2725
|
+
throw new MaterializedViewStoreError("promote", "MV_REBUILD_NOT_VERIFIED", "Rebuild source history does not include the quarantined event");
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
const result = await this.database.prepare(
|
|
2729
|
+
`UPDATE mv_instances
|
|
2730
|
+
SET rebuild_verified_at = ?,
|
|
2731
|
+
rebuild_recovery_id = ?,
|
|
2732
|
+
rebuild_incident_identity = ?,
|
|
2733
|
+
rebuild_incident_generation = ?,
|
|
2734
|
+
rebuild_source_event_count = ?,
|
|
2735
|
+
rebuild_source_max_suid = ?,
|
|
2736
|
+
rebuild_source_history_digest = ?
|
|
2737
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?
|
|
2738
|
+
AND status = 'candidate'
|
|
2739
|
+
AND last_suid COLLATE BINARY = ? COLLATE BINARY`
|
|
2740
|
+
).bind(
|
|
2741
|
+
input.verifiedAt,
|
|
2742
|
+
input.rebuildId,
|
|
2743
|
+
incidentIdentity,
|
|
2744
|
+
incidentGeneration,
|
|
2745
|
+
input.sourceEventCount,
|
|
2746
|
+
input.sourceMaxSuid,
|
|
2747
|
+
input.sourceHistoryDigest,
|
|
2748
|
+
input.serviceId,
|
|
2749
|
+
input.viewId,
|
|
2750
|
+
input.generation,
|
|
2751
|
+
input.sourceMaxSuid
|
|
2752
|
+
).run();
|
|
2753
|
+
if ((result.meta?.changes ?? 0) !== 1) {
|
|
2754
|
+
throw new MaterializedViewStoreError("promote", "MV_REBUILD_NOT_VERIFIED", "Candidate generation does not match the complete rebuild source history");
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
/**
|
|
2758
|
+
* Apply rows/index entries and advance one generation checkpoint atomically.
|
|
2759
|
+
* The guard row intentionally uses a NOT NULL failure to abort a stale CAS.
|
|
2760
|
+
*/
|
|
2761
|
+
async applyMutationsAndAdvanceCheckpoint(input) {
|
|
2762
|
+
assertSortableUniqueId(input.lastSuid);
|
|
2763
|
+
if (input.expectedLastSuid !== null) assertSortableUniqueId(input.expectedLastSuid);
|
|
2764
|
+
for (const mutation of [...input.mutations.rowUpserts, ...input.mutations.rowPatches]) {
|
|
2765
|
+
assertSortableUniqueId(mutation.sourceSuid);
|
|
2766
|
+
}
|
|
2767
|
+
this.ready("apply");
|
|
2768
|
+
this.validateGeneration(input.generation, "apply");
|
|
2769
|
+
const operation = operationId2();
|
|
2770
|
+
const statements = [
|
|
2771
|
+
this.database.prepare(
|
|
2772
|
+
`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
2773
|
+
SELECT ?, CASE WHEN EXISTS (
|
|
2774
|
+
SELECT 1 FROM mv_instances
|
|
2775
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?
|
|
2776
|
+
AND ((? IS NULL AND last_suid = '') OR last_suid COLLATE BINARY = ? COLLATE BINARY)
|
|
2777
|
+
) THEN 1 ELSE NULL END`
|
|
2778
|
+
).bind(
|
|
2779
|
+
operation,
|
|
2780
|
+
input.serviceId,
|
|
2781
|
+
input.viewId,
|
|
2782
|
+
input.generation,
|
|
2783
|
+
input.expectedLastSuid,
|
|
2784
|
+
input.expectedLastSuid
|
|
2785
|
+
)
|
|
2786
|
+
];
|
|
2787
|
+
for (const patch of input.mutations.rowPatches) {
|
|
2788
|
+
statements.push(this.database.prepare(
|
|
2789
|
+
`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
2790
|
+
SELECT ?, CASE WHEN EXISTS (
|
|
2791
|
+
SELECT 1 FROM mv_rows
|
|
2792
|
+
WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key COLLATE BINARY = ? COLLATE BINARY
|
|
2793
|
+
) THEN 1 ELSE 0 END`
|
|
2794
|
+
).bind(
|
|
2795
|
+
`${operation}:patch:${patch.rowKey}`,
|
|
2796
|
+
input.serviceId,
|
|
2797
|
+
input.viewId,
|
|
2798
|
+
input.generation,
|
|
2799
|
+
patch.rowKey
|
|
2800
|
+
));
|
|
2801
|
+
}
|
|
2802
|
+
for (const deletion of input.mutations.rowDeletes) {
|
|
2803
|
+
statements.push(this.database.prepare(
|
|
2804
|
+
`DELETE FROM mv_index_entries WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`
|
|
2805
|
+
).bind(input.serviceId, input.viewId, input.generation, deletion.rowKey));
|
|
2806
|
+
statements.push(this.database.prepare(
|
|
2807
|
+
`DELETE FROM mv_rows WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`
|
|
2808
|
+
).bind(input.serviceId, input.viewId, input.generation, deletion.rowKey));
|
|
2809
|
+
}
|
|
2810
|
+
for (const upsert of input.mutations.rowUpserts) {
|
|
2811
|
+
statements.push(this.database.prepare(
|
|
2812
|
+
`DELETE FROM mv_index_entries WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`
|
|
2813
|
+
).bind(input.serviceId, input.viewId, input.generation, upsert.rowKey));
|
|
2814
|
+
statements.push(this.database.prepare(
|
|
2815
|
+
`INSERT INTO mv_rows
|
|
2816
|
+
(service_id, view_id, generation, row_key, value_json, row_version, source_suid)
|
|
2817
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
2818
|
+
ON CONFLICT (service_id, view_id, generation, row_key) DO UPDATE SET
|
|
2819
|
+
value_json = excluded.value_json,
|
|
2820
|
+
row_version = excluded.row_version,
|
|
2821
|
+
source_suid = excluded.source_suid`
|
|
2822
|
+
).bind(
|
|
2823
|
+
input.serviceId,
|
|
2824
|
+
input.viewId,
|
|
2825
|
+
input.generation,
|
|
2826
|
+
upsert.rowKey,
|
|
2827
|
+
JSON.stringify(assertJsonValue(upsert.value, "state-persistence")),
|
|
2828
|
+
upsert.rowVersion,
|
|
2829
|
+
upsert.sourceSuid
|
|
2830
|
+
));
|
|
2831
|
+
}
|
|
2832
|
+
for (const patch of input.mutations.rowPatches) {
|
|
2833
|
+
if (patch.indexEntries.length > 0) {
|
|
2834
|
+
statements.push(this.database.prepare(
|
|
2835
|
+
`DELETE FROM mv_index_entries WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`
|
|
2836
|
+
).bind(input.serviceId, input.viewId, input.generation, patch.rowKey));
|
|
2837
|
+
}
|
|
2838
|
+
statements.push(this.database.prepare(
|
|
2839
|
+
`UPDATE mv_rows
|
|
2840
|
+
SET value_json = json_patch(value_json, ?), row_version = ?, source_suid = ?
|
|
2841
|
+
WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key COLLATE BINARY = ? COLLATE BINARY`
|
|
2842
|
+
).bind(
|
|
2843
|
+
JSON.stringify(assertJsonValue(patch.patch, "state-persistence")),
|
|
2844
|
+
patch.rowVersion,
|
|
2845
|
+
patch.sourceSuid,
|
|
2846
|
+
input.serviceId,
|
|
2847
|
+
input.viewId,
|
|
2848
|
+
input.generation,
|
|
2849
|
+
patch.rowKey
|
|
2850
|
+
));
|
|
2851
|
+
}
|
|
2852
|
+
for (const deletion of input.mutations.indexDeletes) {
|
|
2853
|
+
const indexPredicate = deletion.indexId === void 0 ? "" : " AND index_id = ?";
|
|
2854
|
+
const values = [input.serviceId, input.viewId, input.generation, deletion.rowKey];
|
|
2855
|
+
if (deletion.indexId !== void 0) values.push(deletion.indexId);
|
|
2856
|
+
statements.push(this.database.prepare(
|
|
2857
|
+
`DELETE FROM mv_index_entries
|
|
2858
|
+
WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?${indexPredicate}`
|
|
2859
|
+
).bind(...values));
|
|
2860
|
+
}
|
|
2861
|
+
for (const entry of input.mutations.indexEntries) {
|
|
2862
|
+
const typed = entry.valueType === "text" ? [entry.value, null, null] : entry.valueType === "integer" ? [null, entry.value, null] : [null, null, entry.value];
|
|
2863
|
+
statements.push(this.database.prepare(
|
|
2864
|
+
`INSERT INTO mv_index_entries
|
|
2865
|
+
(service_id, view_id, generation, index_id, value_type, text_value, integer_value, real_value, row_key)
|
|
2866
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2867
|
+
ON CONFLICT (service_id, view_id, generation, index_id, value_type, text_value, integer_value, real_value, row_key)
|
|
2868
|
+
DO NOTHING`
|
|
2869
|
+
).bind(
|
|
2870
|
+
input.serviceId,
|
|
2871
|
+
input.viewId,
|
|
2872
|
+
input.generation,
|
|
2873
|
+
entry.indexId,
|
|
2874
|
+
entry.valueType,
|
|
2875
|
+
...typed,
|
|
2876
|
+
entry.rowKey
|
|
2877
|
+
));
|
|
2878
|
+
}
|
|
2879
|
+
for (const patch of input.mutations.rowPatches) {
|
|
2880
|
+
for (const entry of patch.indexEntries) {
|
|
2881
|
+
const typed = entry.valueType === "text" ? [entry.value, null, null] : entry.valueType === "integer" ? [null, entry.value, null] : [null, null, entry.value];
|
|
2882
|
+
statements.push(this.database.prepare(
|
|
2883
|
+
`INSERT INTO mv_index_entries
|
|
2884
|
+
(service_id, view_id, generation, index_id, value_type, text_value, integer_value, real_value, row_key)
|
|
2885
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2886
|
+
ON CONFLICT (service_id, view_id, generation, index_id, value_type, text_value, integer_value, real_value, row_key)
|
|
2887
|
+
DO NOTHING`
|
|
2888
|
+
).bind(
|
|
2889
|
+
input.serviceId,
|
|
2890
|
+
input.viewId,
|
|
2891
|
+
input.generation,
|
|
2892
|
+
entry.indexId,
|
|
2893
|
+
entry.valueType,
|
|
2894
|
+
...typed,
|
|
2895
|
+
entry.rowKey
|
|
2896
|
+
));
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
statements.push(this.database.prepare(
|
|
2900
|
+
`UPDATE mv_instances
|
|
2901
|
+
SET last_suid = ?, definition_version = ?, updated_at = MAX(updated_at, ?),
|
|
2902
|
+
rebuild_verified_at = NULL,
|
|
2903
|
+
rebuild_recovery_id = NULL,
|
|
2904
|
+
rebuild_incident_identity = NULL,
|
|
2905
|
+
rebuild_incident_generation = NULL,
|
|
2906
|
+
rebuild_source_event_count = NULL,
|
|
2907
|
+
rebuild_source_max_suid = NULL,
|
|
2908
|
+
rebuild_source_history_digest = NULL
|
|
2909
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?`
|
|
2910
|
+
).bind(
|
|
2911
|
+
input.lastSuid,
|
|
2912
|
+
input.definitionVersion,
|
|
2913
|
+
input.updatedAt,
|
|
2914
|
+
input.serviceId,
|
|
2915
|
+
input.viewId,
|
|
2916
|
+
input.generation
|
|
2917
|
+
));
|
|
2918
|
+
statements.push(this.database.prepare(
|
|
2919
|
+
"DELETE FROM mv_atomic_guards WHERE operation_id = ? OR operation_id LIKE ?"
|
|
2920
|
+
).bind(operation, `${operation}:%`));
|
|
2921
|
+
try {
|
|
2922
|
+
await this.database.batch(statements);
|
|
2923
|
+
} catch (error) {
|
|
2924
|
+
if (isPatchFailure(error)) throw new MaterializedViewPatchError();
|
|
2925
|
+
if (isCasFailure(error)) throw new MaterializedViewCasError();
|
|
2926
|
+
throw new MaterializedViewStoreError("apply", "MV_STORE_OPERATION_FAILED", `Materialized-view atomic apply failed: ${String(error)}`, { cause: error });
|
|
2927
|
+
}
|
|
2928
|
+
const instance = await this.readInstance(input.serviceId, input.viewId, input.generation);
|
|
2929
|
+
if (instance === void 0) throw new MaterializedViewStoreError("apply", "MV_INSTANCE_MISSING", "Materialized-view instance disappeared after apply");
|
|
2930
|
+
return {
|
|
2931
|
+
instance,
|
|
2932
|
+
rowUpserts: input.mutations.rowUpserts.length,
|
|
2933
|
+
rowDeletes: input.mutations.rowDeletes.length,
|
|
2934
|
+
rowPatches: input.mutations.rowPatches.length,
|
|
2935
|
+
indexEntries: input.mutations.indexEntries.length
|
|
2936
|
+
};
|
|
2937
|
+
}
|
|
2938
|
+
async promoteGeneration(input) {
|
|
2939
|
+
this.ready("promote");
|
|
2940
|
+
this.validateGeneration(input.candidateGeneration, "promote");
|
|
2941
|
+
const operation = operationId2();
|
|
2942
|
+
const statements = [
|
|
2943
|
+
this.database.prepare(
|
|
2944
|
+
`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
2945
|
+
SELECT ?, CASE WHEN EXISTS (
|
|
2946
|
+
SELECT 1 FROM mv_instances candidate
|
|
2947
|
+
WHERE candidate.service_id = ? AND candidate.view_id = ?
|
|
2948
|
+
AND candidate.generation = ? AND candidate.status = 'candidate'
|
|
2949
|
+
AND (
|
|
2950
|
+
NOT EXISTS (
|
|
2951
|
+
SELECT 1 FROM mv_ordering_quarantines open_quarantine
|
|
2952
|
+
WHERE open_quarantine.service_id = candidate.service_id
|
|
2953
|
+
AND open_quarantine.view_id = candidate.view_id
|
|
2954
|
+
AND open_quarantine.status = 'open'
|
|
2955
|
+
)
|
|
2956
|
+
OR EXISTS (
|
|
2957
|
+
SELECT 1 FROM mv_ordering_quarantines open_quarantine
|
|
2958
|
+
WHERE open_quarantine.service_id = candidate.service_id
|
|
2959
|
+
AND open_quarantine.view_id = candidate.view_id
|
|
2960
|
+
AND open_quarantine.status = 'open'
|
|
2961
|
+
AND open_quarantine.incident_identity IS NOT NULL
|
|
2962
|
+
AND candidate.rebuild_verified_at IS NOT NULL
|
|
2963
|
+
AND candidate.rebuild_incident_generation = open_quarantine.generation
|
|
2964
|
+
AND candidate.rebuild_incident_identity = open_quarantine.incident_identity
|
|
2965
|
+
)
|
|
2966
|
+
)
|
|
2967
|
+
) AND (
|
|
2968
|
+
(? IS NULL AND NOT EXISTS (
|
|
2969
|
+
SELECT 1 FROM mv_active_generations existing
|
|
2970
|
+
WHERE existing.service_id = ? AND existing.view_id = ?
|
|
2971
|
+
))
|
|
2972
|
+
OR (? IS NOT NULL AND EXISTS (
|
|
2973
|
+
SELECT 1 FROM mv_active_generations pointer
|
|
2974
|
+
WHERE pointer.service_id = ? AND pointer.view_id = ?
|
|
2975
|
+
AND pointer.generation = ?
|
|
2976
|
+
))
|
|
2977
|
+
) THEN 1 ELSE NULL END`
|
|
2978
|
+
).bind(
|
|
2979
|
+
operation,
|
|
2980
|
+
input.serviceId,
|
|
2981
|
+
input.viewId,
|
|
2982
|
+
input.candidateGeneration,
|
|
2983
|
+
input.expectedActiveGeneration,
|
|
2984
|
+
input.serviceId,
|
|
2985
|
+
input.viewId,
|
|
2986
|
+
input.expectedActiveGeneration,
|
|
2987
|
+
input.serviceId,
|
|
2988
|
+
input.viewId,
|
|
2989
|
+
input.expectedActiveGeneration
|
|
2990
|
+
),
|
|
2991
|
+
this.database.prepare(
|
|
2992
|
+
`UPDATE mv_instances SET status = 'retired', updated_at = ?
|
|
2993
|
+
WHERE service_id = ? AND view_id = ? AND status = 'active'`
|
|
2994
|
+
).bind(input.updatedAt, input.serviceId, input.viewId),
|
|
2995
|
+
this.database.prepare(
|
|
2996
|
+
`UPDATE mv_instances SET status = 'active', updated_at = ?
|
|
2997
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?`
|
|
2998
|
+
).bind(input.updatedAt, input.serviceId, input.viewId, input.candidateGeneration),
|
|
2999
|
+
this.database.prepare(
|
|
3000
|
+
`INSERT INTO mv_active_generations (service_id, view_id, generation, updated_at)
|
|
3001
|
+
VALUES (?, ?, ?, ?)
|
|
3002
|
+
ON CONFLICT (service_id, view_id) DO UPDATE SET generation = excluded.generation, updated_at = excluded.updated_at`
|
|
3003
|
+
).bind(input.serviceId, input.viewId, input.candidateGeneration, input.updatedAt),
|
|
3004
|
+
// A successful rebuild/promotion is the explicit recovery action for an
|
|
3005
|
+
// open ordering quarantine. The active-generation pointer changes in the
|
|
3006
|
+
// same batch, so a safe read never observes a cleared gate on the old
|
|
3007
|
+
// generation with the new generation still unproven.
|
|
3008
|
+
this.database.prepare(
|
|
3009
|
+
`UPDATE mv_ordering_quarantines
|
|
3010
|
+
SET status = 'resolved', resolved_at = ?
|
|
3011
|
+
WHERE service_id = ? AND view_id = ? AND status = 'open' AND generation <> ?
|
|
3012
|
+
AND incident_identity = (
|
|
3013
|
+
SELECT candidate.rebuild_incident_identity
|
|
3014
|
+
FROM mv_instances candidate
|
|
3015
|
+
WHERE candidate.service_id = ? AND candidate.view_id = ?
|
|
3016
|
+
AND candidate.generation = ? AND candidate.status = 'active'
|
|
3017
|
+
)`
|
|
3018
|
+
).bind(
|
|
3019
|
+
input.updatedAt,
|
|
3020
|
+
input.serviceId,
|
|
3021
|
+
input.viewId,
|
|
3022
|
+
input.candidateGeneration,
|
|
3023
|
+
input.serviceId,
|
|
3024
|
+
input.viewId,
|
|
3025
|
+
input.candidateGeneration
|
|
3026
|
+
),
|
|
3027
|
+
this.database.prepare("DELETE FROM mv_atomic_guards WHERE operation_id = ?").bind(operation)
|
|
3028
|
+
];
|
|
3029
|
+
try {
|
|
3030
|
+
await this.database.batch(statements);
|
|
3031
|
+
} catch (error) {
|
|
3032
|
+
if (isCasFailure(error)) throw new MaterializedViewPromotionCasError();
|
|
3033
|
+
throw new MaterializedViewStoreError("promote", "MV_STORE_OPERATION_FAILED", `Materialized-view promotion failed: ${String(error)}`, { cause: error });
|
|
3034
|
+
}
|
|
3035
|
+
const active = await this.readActive(input.serviceId, input.viewId);
|
|
3036
|
+
if (active === void 0) throw new MaterializedViewStoreError("promote", "MV_INSTANCE_MISSING", "Active generation disappeared after promotion");
|
|
3037
|
+
return active;
|
|
3038
|
+
}
|
|
3039
|
+
/** Short aliases make the port convenient for runtime composition and tests. */
|
|
3040
|
+
apply = this.applyMutationsAndAdvanceCheckpoint.bind(this);
|
|
3041
|
+
promote = this.promoteGeneration.bind(this);
|
|
3042
|
+
rowFrom(row) {
|
|
3043
|
+
let value;
|
|
3044
|
+
try {
|
|
3045
|
+
value = assertJsonValue(JSON.parse(asString2(row.value_json, "value_json")), "state-persistence");
|
|
3046
|
+
} catch (error) {
|
|
3047
|
+
throw new MaterializedViewStoreError("initialize", "MV_VALUE_INVALID", `Materialized-view row JSON was invalid: ${String(error)}`, { cause: error });
|
|
3048
|
+
}
|
|
3049
|
+
return {
|
|
3050
|
+
serviceId: asString2(row.service_id, "service_id"),
|
|
3051
|
+
viewId: asString2(row.view_id, "view_id"),
|
|
3052
|
+
generation: asInteger(row.generation, "generation"),
|
|
3053
|
+
rowKey: asString2(row.row_key, "row_key"),
|
|
3054
|
+
value,
|
|
3055
|
+
rowVersion: asInteger(row.row_version, "row_version"),
|
|
3056
|
+
sourceSuid: asString2(row.source_suid, "source_suid")
|
|
3057
|
+
};
|
|
3058
|
+
}
|
|
3059
|
+
indexFrom(row) {
|
|
3060
|
+
const type = valueType(row.value_type);
|
|
3061
|
+
return {
|
|
3062
|
+
serviceId: asString2(row.service_id, "service_id"),
|
|
3063
|
+
viewId: asString2(row.view_id, "view_id"),
|
|
3064
|
+
generation: asInteger(row.generation, "generation"),
|
|
3065
|
+
indexId: asString2(row.index_id, "index_id"),
|
|
3066
|
+
valueType: type,
|
|
3067
|
+
value: indexValue(row, type),
|
|
3068
|
+
rowKey: asString2(row.row_key, "row_key")
|
|
3069
|
+
};
|
|
3070
|
+
}
|
|
3071
|
+
validateGeneration(generation, operation) {
|
|
3072
|
+
if (!Number.isSafeInteger(generation) || generation < 0) {
|
|
3073
|
+
throw new MaterializedViewStoreError(operation, "MV_GENERATION_INVALID", "Materialized-view generation must be a non-negative integer");
|
|
3074
|
+
}
|
|
3075
|
+
}
|
|
3076
|
+
ready(operation) {
|
|
3077
|
+
if (!this.initialized) throw new MaterializedViewStoreError(operation, "MV_STORE_NOT_INITIALIZED", "D1MaterializedViewStore.initialize() must complete before use");
|
|
3078
|
+
}
|
|
3079
|
+
};
|
|
3080
|
+
|
|
3081
|
+
// src/bootstrap/manifest.ts
|
|
3082
|
+
var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3083
|
+
var canonical = (value) => {
|
|
3084
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
3085
|
+
if (isObject(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}`;
|
|
3086
|
+
return JSON.stringify(value);
|
|
3087
|
+
};
|
|
3088
|
+
function bootstrapDigest(value) {
|
|
3089
|
+
let hash = 2166136261;
|
|
3090
|
+
for (const char of canonical(value)) {
|
|
3091
|
+
hash ^= char.charCodeAt(0);
|
|
3092
|
+
hash = Math.imul(hash, 16777619);
|
|
3093
|
+
}
|
|
3094
|
+
return `fnv1a32:${(hash >>> 0).toString(16).padStart(8, "0")}`;
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3097
|
+
// src/tag/EventDigest.ts
|
|
3098
|
+
var EVENT_DIGEST_DOMAIN_SEPARATOR = "sekiban-dcb-ts/eventDigest/v2";
|
|
3099
|
+
var EventDigestValidationError = class extends TypeError {
|
|
3100
|
+
constructor(message) {
|
|
3101
|
+
super(message);
|
|
3102
|
+
this.name = "EventDigestValidationError";
|
|
3103
|
+
}
|
|
3104
|
+
};
|
|
3105
|
+
var encoder = new TextEncoder();
|
|
3106
|
+
var requiredFieldNames = [
|
|
3107
|
+
"serviceId",
|
|
3108
|
+
"eventId",
|
|
3109
|
+
"sortableUniqueId",
|
|
3110
|
+
"eventType",
|
|
3111
|
+
"timestamp",
|
|
3112
|
+
"attemptId",
|
|
3113
|
+
"declaredTagSet",
|
|
3114
|
+
"payload"
|
|
3115
|
+
];
|
|
3116
|
+
var optionalFieldNames = ["allocatorLineageId"];
|
|
3117
|
+
var excludedFieldNames = ["causationId", "correlationId", "executedUser"];
|
|
3118
|
+
var allowedFieldNames = /* @__PURE__ */ new Set([...requiredFieldNames, ...optionalFieldNames, ...excludedFieldNames]);
|
|
3119
|
+
function byteCompare(left, right) {
|
|
3120
|
+
const length = Math.min(left.byteLength, right.byteLength);
|
|
3121
|
+
for (let index = 0; index < length; index += 1) {
|
|
3122
|
+
const delta = left[index] - right[index];
|
|
3123
|
+
if (delta !== 0) return delta;
|
|
3124
|
+
}
|
|
3125
|
+
return left.byteLength - right.byteLength;
|
|
3126
|
+
}
|
|
3127
|
+
function concat(parts) {
|
|
3128
|
+
const length = parts.reduce((total, part) => total + part.byteLength, 0);
|
|
3129
|
+
const bytes = new Uint8Array(length);
|
|
3130
|
+
let offset = 0;
|
|
3131
|
+
for (const part of parts) {
|
|
3132
|
+
bytes.set(part, offset);
|
|
3133
|
+
offset += part.byteLength;
|
|
3134
|
+
}
|
|
3135
|
+
return bytes;
|
|
3136
|
+
}
|
|
3137
|
+
function lengthPrefix(length) {
|
|
3138
|
+
if (!Number.isSafeInteger(length) || length < 0 || length > 4294967295) {
|
|
3139
|
+
throw new EventDigestValidationError("eventDigest field length is outside uint32 range");
|
|
3140
|
+
}
|
|
3141
|
+
return Uint8Array.of(
|
|
3142
|
+
length >>> 24 & 255,
|
|
3143
|
+
length >>> 16 & 255,
|
|
3144
|
+
length >>> 8 & 255,
|
|
3145
|
+
length & 255
|
|
3146
|
+
);
|
|
3147
|
+
}
|
|
3148
|
+
function utf8(value) {
|
|
3149
|
+
return encoder.encode(value);
|
|
3150
|
+
}
|
|
3151
|
+
function hasOwn(input, name) {
|
|
3152
|
+
return Object.prototype.hasOwnProperty.call(input, name);
|
|
3153
|
+
}
|
|
3154
|
+
function requiredUtf8(input, name) {
|
|
3155
|
+
const value = input[name];
|
|
3156
|
+
if (typeof value !== "string") {
|
|
3157
|
+
throw new EventDigestValidationError(`eventDigest required field ${name} is absent or not a string`);
|
|
3158
|
+
}
|
|
3159
|
+
return utf8(value);
|
|
3160
|
+
}
|
|
3161
|
+
function tagSetBytes(tags) {
|
|
3162
|
+
const canonical2 = canonicalDeclaredTagSet(tags);
|
|
3163
|
+
return concat(canonical2.map((tag) => {
|
|
3164
|
+
const bytes = utf8(tag);
|
|
3165
|
+
return concat([lengthPrefix(bytes.byteLength), bytes]);
|
|
3166
|
+
}));
|
|
3167
|
+
}
|
|
3168
|
+
function canonicalDeclaredTagSet(tags) {
|
|
3169
|
+
if (!Array.isArray(tags) || !tags.every((tag) => typeof tag === "string")) {
|
|
3170
|
+
throw new EventDigestValidationError("eventDigest declaredTagSet must contain only strings");
|
|
3171
|
+
}
|
|
3172
|
+
const encoded = tags.map((tag) => ({ tag, bytes: utf8(tag) })).sort((left, right) => byteCompare(left.bytes, right.bytes));
|
|
3173
|
+
const unique = [];
|
|
3174
|
+
let previous;
|
|
3175
|
+
for (const item of encoded) {
|
|
3176
|
+
if (previous !== void 0 && byteCompare(previous, item.bytes) === 0) continue;
|
|
3177
|
+
unique.push(item.tag);
|
|
3178
|
+
previous = item.bytes;
|
|
3179
|
+
}
|
|
3180
|
+
return Object.freeze(unique);
|
|
3181
|
+
}
|
|
3182
|
+
function assertClosedProjection(input) {
|
|
3183
|
+
for (const name of Object.keys(input)) {
|
|
3184
|
+
if (!allowedFieldNames.has(name)) {
|
|
3185
|
+
throw new EventDigestValidationError(`eventDigest field ${name} is neither included nor explicitly excluded`);
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
for (const name of requiredFieldNames) {
|
|
3189
|
+
if (!hasOwn(input, name)) {
|
|
3190
|
+
throw new EventDigestValidationError(`eventDigest required field ${name} is absent`);
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
if (!Array.isArray(input.declaredTagSet)) {
|
|
3194
|
+
throw new EventDigestValidationError("eventDigest required field declaredTagSet is absent or invalid");
|
|
3195
|
+
}
|
|
3196
|
+
if (!(input.payload instanceof Uint8Array)) {
|
|
3197
|
+
throw new EventDigestValidationError("eventDigest required field payload is absent or not raw bytes");
|
|
3198
|
+
}
|
|
3199
|
+
if (hasOwn(input, "allocatorLineageId") && typeof input.allocatorLineageId !== "string") {
|
|
3200
|
+
throw new EventDigestValidationError("eventDigest optional field allocatorLineageId must be absent or a string");
|
|
3201
|
+
}
|
|
3202
|
+
}
|
|
3203
|
+
function encodeField(name, value) {
|
|
3204
|
+
const nameBytes = utf8(name);
|
|
3205
|
+
const present = value === void 0 ? Uint8Array.of(0) : Uint8Array.of(1);
|
|
3206
|
+
const valueBytes = value ?? new Uint8Array();
|
|
3207
|
+
return concat([
|
|
3208
|
+
present,
|
|
3209
|
+
lengthPrefix(nameBytes.byteLength),
|
|
3210
|
+
nameBytes,
|
|
3211
|
+
lengthPrefix(valueBytes.byteLength),
|
|
3212
|
+
valueBytes
|
|
3213
|
+
]);
|
|
3214
|
+
}
|
|
3215
|
+
function eventDigestBytes(input) {
|
|
3216
|
+
assertClosedProjection(input);
|
|
3217
|
+
const fields = [
|
|
3218
|
+
["serviceId", requiredUtf8(input, "serviceId")],
|
|
3219
|
+
["eventId", requiredUtf8(input, "eventId")],
|
|
3220
|
+
["sortableUniqueId", requiredUtf8(input, "sortableUniqueId")],
|
|
3221
|
+
["eventType", requiredUtf8(input, "eventType")],
|
|
3222
|
+
["timestamp", requiredUtf8(input, "timestamp")],
|
|
3223
|
+
["allocatorLineageId", hasOwn(input, "allocatorLineageId") ? utf8(input.allocatorLineageId) : void 0],
|
|
3224
|
+
["attemptId", requiredUtf8(input, "attemptId")],
|
|
3225
|
+
["declaredTagSet", tagSetBytes(input.declaredTagSet)],
|
|
3226
|
+
["payload", input.payload]
|
|
3227
|
+
];
|
|
3228
|
+
return concat([
|
|
3229
|
+
utf8(EVENT_DIGEST_DOMAIN_SEPARATOR),
|
|
3230
|
+
Uint8Array.of(0),
|
|
3231
|
+
...fields.map(([name, value]) => encodeField(name, value))
|
|
3232
|
+
]);
|
|
3233
|
+
}
|
|
3234
|
+
async function eventDigestHex(input) {
|
|
3235
|
+
const bytes = eventDigestBytes(input);
|
|
3236
|
+
const source = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
3237
|
+
const digest = await crypto.subtle.digest("SHA-256", source);
|
|
3238
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
// src/bootstrap/BootstrapStoreAdapter.ts
|
|
3242
|
+
function arrayBufferBase64(bytes) {
|
|
3243
|
+
let binary = "";
|
|
3244
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
3245
|
+
return btoa(binary);
|
|
3246
|
+
}
|
|
3247
|
+
var BootstrapIdentityConflictError = class extends Error {
|
|
3248
|
+
constructor(provider, eventId) {
|
|
3249
|
+
super(`${provider} bootstrap EventId ${eventId} conflicts with the target identity`);
|
|
3250
|
+
this.provider = provider;
|
|
3251
|
+
this.eventId = eventId;
|
|
3252
|
+
this.name = "BootstrapIdentityConflictError";
|
|
3253
|
+
}
|
|
3254
|
+
provider;
|
|
3255
|
+
eventId;
|
|
3256
|
+
code = "BOOTSTRAP_EVENT_IDENTITY_CONFLICT";
|
|
3257
|
+
};
|
|
3258
|
+
function compareOpaque(left, right) {
|
|
3259
|
+
const a = new TextEncoder().encode(left);
|
|
3260
|
+
const b = new TextEncoder().encode(right);
|
|
3261
|
+
for (let index = 0; index < Math.min(a.length, b.length); index += 1) if (a[index] !== b[index]) return a[index] - b[index];
|
|
3262
|
+
return a.length - b.length;
|
|
3263
|
+
}
|
|
3264
|
+
function bootstrapEventIdentityMatches(event, record) {
|
|
3265
|
+
return event.suid === record.suid && event.payload === record.payload && event.eventType === record.eventType && event.timestamp === record.timestamp && event.causationId === record.causationId && event.correlationId === record.correlationId && event.executedUser === record.executedUser && JSON.stringify(event.eventTags) === JSON.stringify(record.eventTags);
|
|
3266
|
+
}
|
|
3267
|
+
function toRecord(event) {
|
|
3268
|
+
return {
|
|
3269
|
+
eventId: event.eventId,
|
|
3270
|
+
suid: event.suid,
|
|
3271
|
+
payload: event.payload,
|
|
3272
|
+
eventTags: [...event.eventTags],
|
|
3273
|
+
eventType: event.eventType,
|
|
3274
|
+
provenance: { origin: "g32" },
|
|
3275
|
+
timestamp: event.timestamp,
|
|
3276
|
+
causationId: event.causationId,
|
|
3277
|
+
correlationId: event.correlationId,
|
|
3278
|
+
executedUser: event.executedUser
|
|
3279
|
+
};
|
|
3280
|
+
}
|
|
3281
|
+
var BootstrapStoreAdapter = class {
|
|
3282
|
+
constructor(provider, store, identityGuard = bootstrapEventIdentityMatches) {
|
|
3283
|
+
this.provider = provider;
|
|
3284
|
+
this.store = store;
|
|
3285
|
+
this.identityGuard = identityGuard;
|
|
3286
|
+
}
|
|
3287
|
+
provider;
|
|
3288
|
+
store;
|
|
3289
|
+
identityGuard;
|
|
3290
|
+
async exportPage(input) {
|
|
3291
|
+
if (!Number.isSafeInteger(input.pageSize) || input.pageSize <= 0) throw new Error("bootstrap export pageSize must be positive");
|
|
3292
|
+
await this.store.initialize();
|
|
3293
|
+
const observed = await this.store.readAllEvents(input.sourceServiceId, "");
|
|
3294
|
+
const highWatermark = input.cursor?.highWatermark ?? observed.at(-1)?.suid ?? null;
|
|
3295
|
+
const snapshot = observed.filter((event) => highWatermark === null || compareOpaque(event.suid, highWatermark) <= 0).map(toRecord);
|
|
3296
|
+
const start = input.cursor?.nextEventId === null || input.cursor?.nextEventId === void 0 ? 0 : snapshot.findIndex((event) => event.eventId === input.cursor.nextEventId) + 1;
|
|
3297
|
+
const page = snapshot.slice(Math.max(0, start), Math.max(0, start) + input.pageSize);
|
|
3298
|
+
const tagCounts = {};
|
|
3299
|
+
for (const event of snapshot) for (const tag of event.eventTags) tagCounts[tag] = (tagCounts[tag] ?? 0) + 1;
|
|
3300
|
+
const draft = {
|
|
3301
|
+
format: "sekiban-dcb-bootstrap",
|
|
3302
|
+
version: 1,
|
|
3303
|
+
source: { serviceId: input.sourceServiceId, lineageId: input.sourceAllocatorLineageId },
|
|
3304
|
+
target: { serviceId: input.targetServiceId, allocatorLineageId: input.allocatorLineageId },
|
|
3305
|
+
highWatermark,
|
|
3306
|
+
eventCount: snapshot.length,
|
|
3307
|
+
tagCounts,
|
|
3308
|
+
canonicalization: "utf8-json-sorted-keys-v1"
|
|
3309
|
+
};
|
|
3310
|
+
const fullDump = { manifest: { ...draft, contentDigest: "" }, events: snapshot };
|
|
3311
|
+
const manifest = { ...draft, contentDigest: bootstrapDigest(fullDump) };
|
|
3312
|
+
const pageEnd = Math.max(0, start) + page.length;
|
|
3313
|
+
return {
|
|
3314
|
+
dump: { manifest, events: snapshot },
|
|
3315
|
+
page,
|
|
3316
|
+
cursor: { highWatermark, nextEventId: pageEnd >= snapshot.length ? null : snapshot[pageEnd - 1].eventId },
|
|
3317
|
+
complete: pageEnd >= snapshot.length
|
|
3318
|
+
};
|
|
3319
|
+
}
|
|
3320
|
+
async admitBootstrap(input) {
|
|
3321
|
+
await this.store.initialize();
|
|
3322
|
+
const existing = new Map((await this.store.readAllEvents(input.manifest.target.serviceId, "")).map((event) => [event.eventId, event]));
|
|
3323
|
+
for (const record of input.events) {
|
|
3324
|
+
const prior = existing.get(record.eventId);
|
|
3325
|
+
if (prior !== void 0 && !this.identityGuard(prior, record)) throw new BootstrapIdentityConflictError(this.provider, record.eventId);
|
|
3326
|
+
for (const tag of record.eventTags) {
|
|
3327
|
+
const attemptId = `bootstrap:${input.importId}:${input.leaseEpoch}`;
|
|
3328
|
+
const digestInput = {
|
|
3329
|
+
serviceId: input.manifest.target.serviceId,
|
|
3330
|
+
eventId: record.eventId,
|
|
3331
|
+
sortableUniqueId: record.suid,
|
|
3332
|
+
eventType: record.eventType,
|
|
3333
|
+
timestamp: record.timestamp,
|
|
3334
|
+
allocatorLineageId: input.manifest.target.allocatorLineageId,
|
|
3335
|
+
attemptId,
|
|
3336
|
+
declaredTagSet: canonicalDeclaredTagSet(record.eventTags),
|
|
3337
|
+
payload: new TextEncoder().encode(record.payload)
|
|
3338
|
+
};
|
|
3339
|
+
const delivered = await this.store.recordDelivery({
|
|
3340
|
+
version: 1,
|
|
3341
|
+
serviceId: input.manifest.target.serviceId,
|
|
3342
|
+
allocatorLineageId: input.manifest.target.allocatorLineageId,
|
|
3343
|
+
tag,
|
|
3344
|
+
attemptId,
|
|
3345
|
+
eventId: record.eventId,
|
|
3346
|
+
suid: record.suid,
|
|
3347
|
+
payload: record.payload,
|
|
3348
|
+
eventTags: [...record.eventTags],
|
|
3349
|
+
eventType: record.eventType,
|
|
3350
|
+
provenance: "g32",
|
|
3351
|
+
timestamp: record.timestamp,
|
|
3352
|
+
causationId: record.causationId,
|
|
3353
|
+
correlationId: record.correlationId,
|
|
3354
|
+
executedUser: record.executedUser,
|
|
3355
|
+
enqueuedAt: 0,
|
|
3356
|
+
completeness: {
|
|
3357
|
+
canonicalBytesBase64: arrayBufferBase64(eventDigestBytes(digestInput)),
|
|
3358
|
+
eventDigest: await eventDigestHex(digestInput),
|
|
3359
|
+
declaredTagSet: [...digestInput.declaredTagSet],
|
|
3360
|
+
localCommittedMembership: [{ serviceId: input.manifest.target.serviceId, eventId: record.eventId, tag }],
|
|
3361
|
+
// Bootstrap is not a source outbox replay path. The deterministic
|
|
3362
|
+
// per-event/tag ordinal is only enough to satisfy the D1 receipt
|
|
3363
|
+
// key while G44's source scanner remains explicitly out of scope.
|
|
3364
|
+
obligationSequence: Math.max(1, [...new TextEncoder().encode(`${record.eventId}|${tag}`)].reduce((total, byte) => (total * 33 + byte) % 1e9, 5381))
|
|
3365
|
+
}
|
|
3366
|
+
// A C# import may contain a historical RFC 4122 Id. It bypasses
|
|
3367
|
+
// Queue/doorbell and is admitted only through this fenced path;
|
|
3368
|
+
// normal deliveries still require UUID v7.
|
|
3369
|
+
}, 0, "import");
|
|
3370
|
+
if (delivered.outcome !== "stored") throw new Error(`${this.provider} bootstrap admission rejected ${record.eventId}`);
|
|
3371
|
+
}
|
|
3372
|
+
}
|
|
3373
|
+
}
|
|
3374
|
+
async verifyBootstrap(input) {
|
|
3375
|
+
await this.store.initialize();
|
|
3376
|
+
const actual = await this.store.readAllEvents(input.manifest.target.serviceId, "");
|
|
3377
|
+
if (actual.length !== input.manifest.eventCount) throw new Error(`${this.provider} bootstrap store count differs for ${input.importId}`);
|
|
3378
|
+
}
|
|
3379
|
+
};
|
|
3380
|
+
function createBootstrapStoreAdapter(provider, store, identityGuard) {
|
|
3381
|
+
return new BootstrapStoreAdapter(provider, store, identityGuard);
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
// src/d1.ts
|
|
3385
|
+
function createD1StoreProvider(config = {}) {
|
|
3386
|
+
return Object.freeze({
|
|
3387
|
+
name: "d1",
|
|
3388
|
+
isConfigured: (env) => env.D1 !== void 0,
|
|
3389
|
+
create: (env) => {
|
|
3390
|
+
if (env.D1 === void 0) throw new Error("A D1 binding is required for the D1 store");
|
|
3391
|
+
return new D1EventStore(env.D1, config.storeOptions);
|
|
3392
|
+
},
|
|
3393
|
+
createBootstrapAdapter: (env) => {
|
|
3394
|
+
if (env.D1 === void 0) throw new Error("A D1 binding is required for the D1 store");
|
|
3395
|
+
return createD1BootstrapAdapter(new D1EventStore(env.D1, config.storeOptions));
|
|
3396
|
+
}
|
|
3397
|
+
});
|
|
3398
|
+
}
|
|
3399
|
+
function createD1MaterializedViewStore(database) {
|
|
3400
|
+
return new D1MaterializedViewStore(database);
|
|
3401
|
+
}
|
|
3402
|
+
function createD1BootstrapAdapter(store) {
|
|
3403
|
+
return createBootstrapStoreAdapter("d1", store, (event, record) => bootstrapEventIdentityMatches(event, record));
|
|
3404
|
+
}
|
|
3405
|
+
export {
|
|
3406
|
+
D1EventStore,
|
|
3407
|
+
D1IdentityConflictError,
|
|
3408
|
+
D1MaterializedViewStore,
|
|
3409
|
+
MaterializedViewCasError,
|
|
3410
|
+
MaterializedViewPatchError,
|
|
3411
|
+
MaterializedViewPromotionCasError,
|
|
3412
|
+
MaterializedViewStoreError,
|
|
3413
|
+
createD1BootstrapAdapter,
|
|
3414
|
+
createD1MaterializedViewStore,
|
|
3415
|
+
createD1StoreProvider,
|
|
3416
|
+
safeWindowCeilingExceeded,
|
|
3417
|
+
safeWindowMs
|
|
3418
|
+
};
|