@sekiban/dcb-runtime 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +86 -0
- package/README.md +13 -0
- package/dist/allocator/AllocatorDurableObject.d.ts +36 -0
- package/dist/allocator/AllocatorDurableObject.js +658 -0
- package/dist/allocator/IssuanceLedger.d.ts +188 -0
- package/dist/allocator/IssuanceLedger.js +608 -0
- package/dist/allocator/IssuanceReconciler.d.ts +41 -0
- package/dist/allocator/IssuanceReconciler.js +191 -0
- package/dist/allocator/OrderClock.d.ts +41 -0
- package/dist/allocator/OrderClock.js +92 -0
- package/dist/allocator/SortableUniqueId.d.ts +46 -0
- package/dist/allocator/SortableUniqueId.js +156 -0
- package/dist/allocator/types.d.ts +52 -0
- package/dist/allocator/types.js +1 -0
- package/dist/bootstrap/BootstrapCoordinatorDurableObject.d.ts +28 -0
- package/dist/bootstrap/BootstrapCoordinatorDurableObject.js +357 -0
- package/dist/bootstrap/BootstrapStoreAdapter.d.ts +51 -0
- package/dist/bootstrap/BootstrapStoreAdapter.js +162 -0
- package/dist/bootstrap/OperatorBootstrap.d.ts +20 -0
- package/dist/bootstrap/OperatorBootstrap.js +104 -0
- package/dist/bootstrap/manifest.d.ts +9 -0
- package/dist/bootstrap/manifest.js +115 -0
- package/dist/bootstrap/types.d.ts +75 -0
- package/dist/bootstrap/types.js +1 -0
- package/dist/cli/OperatorRepairCli.d.ts +11 -0
- package/dist/cli/OperatorRepairCli.js +108 -0
- package/dist/cloudflare.d.ts +164 -0
- package/dist/cloudflare.js +19487 -0
- package/dist/commit/CommitWorker.d.ts +101 -0
- package/dist/commit/CommitWorker.js +921 -0
- package/dist/commit/types.d.ts +25 -0
- package/dist/commit/types.js +13 -0
- package/dist/completeness/GlobalCompletenessReconciler.d.ts +77 -0
- package/dist/completeness/GlobalCompletenessReconciler.js +572 -0
- package/dist/completeness/types.d.ts +85 -0
- package/dist/completeness/types.js +6 -0
- package/dist/composition.d.ts +123 -0
- package/dist/composition.js +237 -0
- package/dist/cosmos.d.ts +11 -0
- package/dist/cosmos.js +1411 -0
- package/dist/d1-mv.d.ts +8 -0
- package/dist/d1-mv.js +2001 -0
- package/dist/d1.d.ts +16 -0
- package/dist/d1.js +3418 -0
- package/dist/diagnostics/G60DurableHop.d.ts +99 -0
- package/dist/diagnostics/G60DurableHop.js +248 -0
- package/dist/diagnostics/G65Admission.d.ts +23 -0
- package/dist/diagnostics/G65Admission.js +43 -0
- package/dist/diagnostics/G65DirectRing.d.ts +33 -0
- package/dist/diagnostics/G65DirectRing.js +85 -0
- package/dist/diagnostics/G69AdmissionAttempt.d.ts +43 -0
- package/dist/diagnostics/G69AdmissionAttempt.js +22 -0
- package/dist/downstream/DeliveryCore.d.ts +127 -0
- package/dist/downstream/DeliveryCore.js +304 -0
- package/dist/downstream/Doorbell.d.ts +43 -0
- package/dist/downstream/Doorbell.js +137 -0
- package/dist/downstream/DownstreamAdapter.d.ts +52 -0
- package/dist/downstream/DownstreamAdapter.js +299 -0
- package/dist/downstream/ExclusionLookup.d.ts +36 -0
- package/dist/downstream/ExclusionLookup.js +50 -0
- package/dist/downstream/InconsistencyDetector.d.ts +27 -0
- package/dist/downstream/InconsistencyDetector.js +82 -0
- package/dist/downstream/OutboxDrain.d.ts +35 -0
- package/dist/downstream/OutboxDrain.js +126 -0
- package/dist/downstream/types.d.ts +77 -0
- package/dist/downstream/types.js +64 -0
- package/dist/eventIdentity.d.ts +30 -0
- package/dist/eventIdentity.js +49 -0
- package/dist/eventRecord.d.ts +26 -0
- package/dist/eventRecord.js +53 -0
- package/dist/g42-probe.d.ts +9 -0
- package/dist/g42-probe.js +9 -0
- package/dist/http/SerializedQueryWorker.d.ts +58 -0
- package/dist/http/SerializedQueryWorker.js +497 -0
- package/dist/http/commitResponse.d.ts +46 -0
- package/dist/http/commitResponse.js +80 -0
- package/dist/http/testServiceId.d.ts +17 -0
- package/dist/http/testServiceId.js +14 -0
- package/dist/index.d.ts +112 -0
- package/dist/index.js +19926 -0
- package/dist/journal/JournalDurableObject.d.ts +30 -0
- package/dist/journal/JournalDurableObject.js +481 -0
- package/dist/journal/JournalFirstTouchProbe.d.ts +190 -0
- package/dist/journal/JournalFirstTouchProbe.js +375 -0
- package/dist/journal/types.d.ts +116 -0
- package/dist/journal/types.js +52 -0
- package/dist/mv/MaterializedViewCatchUp.d.ts +60 -0
- package/dist/mv/MaterializedViewCatchUp.js +353 -0
- package/dist/mv/MaterializedViewRuntime.d.ts +80 -0
- package/dist/mv/MaterializedViewRuntime.js +328 -0
- package/dist/mv/MaterializedViewStore.d.ts +257 -0
- package/dist/mv/MaterializedViewStore.js +875 -0
- package/dist/mv/UnsafeWindowMaterializedView.d.ts +112 -0
- package/dist/mv/UnsafeWindowMaterializedView.js +495 -0
- package/dist/mv/index.d.ts +8 -0
- package/dist/mv/index.js +2357 -0
- package/dist/projection/LiveProjectionWorker.d.ts +86 -0
- package/dist/projection/LiveProjectionWorker.js +240 -0
- package/dist/projection/ProjectionRuntime.d.ts +113 -0
- package/dist/projection/ProjectionRuntime.js +346 -0
- package/dist/projection/ProjectorRegistry.d.ts +51 -0
- package/dist/projection/ProjectorRegistry.js +116 -0
- package/dist/query/ProjectionQueryStore.d.ts +102 -0
- package/dist/query/ProjectionQueryStore.js +185 -0
- package/dist/query/QueryRegistry.d.ts +22 -0
- package/dist/query/QueryRegistry.js +36 -0
- package/dist/read/SerializedReadWorker.d.ts +36 -0
- package/dist/read/SerializedReadWorker.js +225 -0
- package/dist/repair/RepairWorker.d.ts +72 -0
- package/dist/repair/RepairWorker.js +461 -0
- package/dist/safeWindow.d.ts +20 -0
- package/dist/safeWindow.js +36 -0
- package/dist/scope/ControlRouteScope.d.ts +22 -0
- package/dist/scope/ControlRouteScope.js +36 -0
- package/dist/scope/ScopeName.d.ts +34 -0
- package/dist/scope/ScopeName.js +67 -0
- package/dist/service/ServiceIdentityProvider.d.ts +35 -0
- package/dist/service/ServiceIdentityProvider.js +56 -0
- package/dist/store/CosmosEventStore.d.ts +126 -0
- package/dist/store/CosmosEventStore.js +950 -0
- package/dist/store/D1EventStore.d.ts +111 -0
- package/dist/store/D1EventStore.js +1111 -0
- package/dist/store/PostgresEventStore.d.ts +35 -0
- package/dist/store/PostgresEventStore.js +811 -0
- package/dist/store/provider.d.ts +25 -0
- package/dist/store/provider.js +32 -0
- package/dist/store/types.d.ts +205 -0
- package/dist/store/types.js +18 -0
- package/dist/tag/EventDigest.d.ts +40 -0
- package/dist/tag/EventDigest.js +158 -0
- package/dist/tag/TagDurableObject.d.ts +286 -0
- package/dist/tag/TagDurableObject.js +4071 -0
- package/dist/tag/TagSqlMeasurement.d.ts +26 -0
- package/dist/tag/TagSqlMeasurement.js +44 -0
- package/dist/tag/TagSqlSchema.d.ts +29 -0
- package/dist/tag/TagSqlSchema.js +211 -0
- package/dist/tag/types.d.ts +173 -0
- package/dist/tag/types.js +3 -0
- package/dist/tagstate/TagStateDurableObject.d.ts +57 -0
- package/dist/tagstate/TagStateDurableObject.js +499 -0
- package/dist/trace/AttributionRatio.d.ts +12 -0
- package/dist/trace/AttributionRatio.js +68 -0
- package/dist/trace/CloudflareTracing.d.ts +7 -0
- package/dist/trace/CloudflareTracing.js +9 -0
- package/dist/trace/CommitTrace.d.ts +405 -0
- package/dist/trace/CommitTrace.js +1008 -0
- package/dist/trace/CommitTraceConsoleSink.d.ts +68 -0
- package/dist/trace/CommitTraceConsoleSink.js +114 -0
- package/dist/trace/CommitTraceVerifier.d.ts +22 -0
- package/dist/trace/CommitTraceVerifier.js +295 -0
- package/dist/trace/ObservationStream.d.ts +118 -0
- package/dist/trace/ObservationStream.js +148 -0
- package/package.json +71 -0
|
@@ -0,0 +1,1111 @@
|
|
|
1
|
+
import { decayedLagEstimateMs } from "../safeWindow";
|
|
2
|
+
import { appendG69AdmissionAttempt } from "../diagnostics/G69AdmissionAttempt";
|
|
3
|
+
import { resolveDeliveryIdentity } from "../eventIdentity";
|
|
4
|
+
import { assertSortableUniqueId } from "../allocator/SortableUniqueId";
|
|
5
|
+
import { CANONICAL_UTC_TIMESTAMP_PATTERN, isRfc4122Uuid, isUuidV7, serializedEventMetadata } from "../eventRecord";
|
|
6
|
+
import { CanonicalEventIdentityConflictError, } from "./types";
|
|
7
|
+
/** A contradictory EventId identity is a typed fail-closed outcome. */
|
|
8
|
+
export class D1IdentityConflictError extends CanonicalEventIdentityConflictError {
|
|
9
|
+
beforeMutation;
|
|
10
|
+
constructor(message, beforeMutation = false) {
|
|
11
|
+
super("d1", "", message);
|
|
12
|
+
this.name = "D1IdentityConflictError";
|
|
13
|
+
this.beforeMutation = beforeMutation;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function asString(value, name) {
|
|
17
|
+
if (typeof value !== "string")
|
|
18
|
+
throw new Error(`D1 ${name} was not a string`);
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
function nullableString(value, name) {
|
|
22
|
+
return value === null ? null : asString(value, name);
|
|
23
|
+
}
|
|
24
|
+
function asNumber(value, name) {
|
|
25
|
+
const number = typeof value === "number" ? value : Number(value);
|
|
26
|
+
if (!Number.isSafeInteger(number))
|
|
27
|
+
throw new Error(`D1 ${name} was not a safe integer`);
|
|
28
|
+
return number;
|
|
29
|
+
}
|
|
30
|
+
function errorText(error) {
|
|
31
|
+
return (error instanceof Error ? error.message : String(error)).slice(0, 256);
|
|
32
|
+
}
|
|
33
|
+
function sortedUnique(values) {
|
|
34
|
+
return [...new Set(values)].sort((left, right) => binaryCompare(left, right));
|
|
35
|
+
}
|
|
36
|
+
function equalStrings(left, right) {
|
|
37
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
38
|
+
}
|
|
39
|
+
/** Reject an envelope whose declared source facts cannot describe this tag copy. */
|
|
40
|
+
function assertCompletenessEnvelope(message) {
|
|
41
|
+
const facts = message.completeness;
|
|
42
|
+
const declared = sortedUnique(message.eventTags);
|
|
43
|
+
if (!equalStrings(declared, sortedUnique(facts.declaredTagSet))) {
|
|
44
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} declared tag set differs from its source obligation`);
|
|
45
|
+
}
|
|
46
|
+
if (facts.localCommittedMembership.length !== 1) {
|
|
47
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} source obligation has no exact local membership`);
|
|
48
|
+
}
|
|
49
|
+
const membership = facts.localCommittedMembership[0];
|
|
50
|
+
if (membership.serviceId !== message.serviceId || membership.eventId !== message.eventId || membership.tag !== message.tag) {
|
|
51
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} source obligation membership differs from its delivery envelope`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** V1 SUIDs and path names are opaque bytewise ordinals, never locale/numeric values. */
|
|
55
|
+
export function binaryCompare(left, right) {
|
|
56
|
+
const leftBytes = new TextEncoder().encode(left);
|
|
57
|
+
const rightBytes = new TextEncoder().encode(right);
|
|
58
|
+
const shared = Math.min(leftBytes.length, rightBytes.length);
|
|
59
|
+
for (let index = 0; index < shared; index += 1) {
|
|
60
|
+
const difference = leftBytes[index] - rightBytes[index];
|
|
61
|
+
if (difference !== 0)
|
|
62
|
+
return difference;
|
|
63
|
+
}
|
|
64
|
+
return leftBytes.length - rightBytes.length;
|
|
65
|
+
}
|
|
66
|
+
function asStringArray(value, name) {
|
|
67
|
+
const decoded = typeof value === "string" ? JSON.parse(value) : value;
|
|
68
|
+
if (!Array.isArray(decoded) || !decoded.every((entry) => typeof entry === "string")) {
|
|
69
|
+
throw new Error(`D1 ${name} was not a string array`);
|
|
70
|
+
}
|
|
71
|
+
return [...decoded];
|
|
72
|
+
}
|
|
73
|
+
function eventFrom(row, arrivals) {
|
|
74
|
+
const serviceId = asString(row.service_id, "service_id");
|
|
75
|
+
const eventId = asString(row.event_id, "event_id");
|
|
76
|
+
const tags = asStringArray(row.event_tags, "event_tags");
|
|
77
|
+
return {
|
|
78
|
+
serviceId,
|
|
79
|
+
id: eventId,
|
|
80
|
+
eventId,
|
|
81
|
+
sortableUniqueId: asString(row.suid, "suid"),
|
|
82
|
+
suid: asString(row.suid, "suid"),
|
|
83
|
+
payload: asString(row.payload, "payload"),
|
|
84
|
+
tags,
|
|
85
|
+
eventTags: tags,
|
|
86
|
+
eventType: asString(row.event_type, "event_type"),
|
|
87
|
+
timestamp: asString(row.timestamp, "timestamp"),
|
|
88
|
+
causationId: nullableString(row.causation_id, "causation_id"),
|
|
89
|
+
correlationId: nullableString(row.correlation_id, "correlation_id"),
|
|
90
|
+
executedUser: nullableString(row.executed_user, "executed_user"),
|
|
91
|
+
provenance: "g32",
|
|
92
|
+
...(typeof row.event_digest === "string" && row.event_digest.length > 0 ? { eventDigest: row.event_digest } : {}),
|
|
93
|
+
firstArrivedAt: asNumber(row.first_arrived_at, "first_arrived_at"),
|
|
94
|
+
lastArrivedAt: asNumber(row.last_arrived_at, "last_arrived_at"),
|
|
95
|
+
maxDeliveryLagMs: asNumber(row.max_delivery_lag_ms, "max_delivery_lag_ms"),
|
|
96
|
+
arrivals: arrivals.map((arrival) => ({
|
|
97
|
+
serviceId: asString(arrival.service_id, "arrival.service_id"),
|
|
98
|
+
eventId: asString(arrival.event_id, "arrival.event_id"),
|
|
99
|
+
tag: asString(arrival.tag, "arrival.tag"),
|
|
100
|
+
enqueuedAt: asNumber(arrival.enqueued_at, "arrival.enqueued_at"),
|
|
101
|
+
arrivedAt: asNumber(arrival.arrived_at, "arrival.arrived_at"),
|
|
102
|
+
lagMs: asNumber(arrival.lag_ms, "arrival.lag_ms"),
|
|
103
|
+
})),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function pendingFrom(row) {
|
|
107
|
+
return {
|
|
108
|
+
serviceId: asString(row.service_id, "service_id"),
|
|
109
|
+
eventId: asString(row.event_id, "event_id"),
|
|
110
|
+
attemptId: asString(row.attempt_id, "attempt_id"),
|
|
111
|
+
suid: asString(row.suid, "suid"),
|
|
112
|
+
expectedPaths: asStringArray(row.expected_paths, "expected_paths"),
|
|
113
|
+
observedPaths: asStringArray(row.observed_paths, "observed_paths"),
|
|
114
|
+
firstObservedAt: asNumber(row.first_observed_at, "first_observed_at"),
|
|
115
|
+
lagBoundMs: asNumber(row.lag_bound_ms, "lag_bound_ms"),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function findingFrom(row) {
|
|
119
|
+
const classification = asString(row.classification, "classification");
|
|
120
|
+
if (classification !== "MISSING_STABLE" && classification !== "EXCLUDED_AUDITED" && classification !== "RESOLVED_LATE") {
|
|
121
|
+
throw new Error("D1 finding classification was invalid");
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
serviceId: asString(row.service_id, "service_id"),
|
|
125
|
+
eventId: asString(row.event_id, "event_id"),
|
|
126
|
+
path: asString(row.path, "path"),
|
|
127
|
+
classification,
|
|
128
|
+
firstObservedAt: asNumber(row.first_observed_at, "first_observed_at"),
|
|
129
|
+
lagBoundMs: asNumber(row.lag_bound_ms, "lag_bound_ms"),
|
|
130
|
+
observedAt: asNumber(row.observed_at, "observed_at"),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function incidentClassification(value) {
|
|
134
|
+
const classification = asString(value, "classification");
|
|
135
|
+
if (classification !== "SUID_COLLISION" && classification !== "ORDER_VIOLATION" && classification !== "LINEAGE_MISMATCH" && classification !== "ORDERING_DETECTOR_UNKNOWN") {
|
|
136
|
+
throw new Error("D1 delivery incident classification was invalid");
|
|
137
|
+
}
|
|
138
|
+
return classification;
|
|
139
|
+
}
|
|
140
|
+
function optionalString(value, name) {
|
|
141
|
+
if (value === null || value === undefined)
|
|
142
|
+
return undefined;
|
|
143
|
+
return asString(value, name);
|
|
144
|
+
}
|
|
145
|
+
function incidentFrom(row) {
|
|
146
|
+
return {
|
|
147
|
+
serviceId: asString(row.service_id, "service_id"),
|
|
148
|
+
identityKey: asString(row.identity_key, "identity_key"),
|
|
149
|
+
classification: incidentClassification(row.classification),
|
|
150
|
+
suid: optionalString(row.suid, "suid"),
|
|
151
|
+
existingEventId: optionalString(row.existing_event_id, "existing_event_id"),
|
|
152
|
+
incomingEventId: optionalString(row.incoming_event_id, "incoming_event_id"),
|
|
153
|
+
eventId: optionalString(row.event_id, "event_id"),
|
|
154
|
+
boundLineageId: optionalString(row.bound_lineage_id, "bound_lineage_id"),
|
|
155
|
+
incomingLineageId: optionalString(row.incoming_lineage_id, "incoming_lineage_id"),
|
|
156
|
+
observedAt: asNumber(row.observed_at, "observed_at"),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function checkpointFrom(row) {
|
|
160
|
+
return {
|
|
161
|
+
serviceId: asString(row.service_id, "service_id"),
|
|
162
|
+
projectionId: asString(row.projection_id, "projection_id"),
|
|
163
|
+
lastSuid: asString(row.last_suid, "last_suid"),
|
|
164
|
+
stateJson: asString(row.state_json, "state_json"),
|
|
165
|
+
version: asNumber(row.version, "version"),
|
|
166
|
+
updatedAt: asNumber(row.updated_at, "updated_at"),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function collisionIdentity(serviceId, suid, existingEventId, incomingEventId) {
|
|
170
|
+
const [first, second] = [existingEventId, incomingEventId].sort(binaryCompare);
|
|
171
|
+
return `SUID_COLLISION|${serviceId}|${suid}|${first}|${second}`;
|
|
172
|
+
}
|
|
173
|
+
function lineageIdentity(serviceId, boundLineageId, incomingLineageId) {
|
|
174
|
+
return `LINEAGE_MISMATCH|${serviceId}|${boundLineageId}|${incomingLineageId}`;
|
|
175
|
+
}
|
|
176
|
+
function jsonArray(values) {
|
|
177
|
+
return JSON.stringify(sortedUnique(values));
|
|
178
|
+
}
|
|
179
|
+
/** Normal delivery has fixed serialized metadata; the fenced import lane may retain C# nulls. */
|
|
180
|
+
function metadataForDelivery(message, deliverySource) {
|
|
181
|
+
const serialized = serializedEventMetadata(message.eventId);
|
|
182
|
+
if (deliverySource !== "import") {
|
|
183
|
+
if (message.causationId !== serialized.causationId ||
|
|
184
|
+
message.correlationId !== serialized.correlationId ||
|
|
185
|
+
message.executedUser !== serialized.executedUser) {
|
|
186
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} metadata must use the serialized C# constants`);
|
|
187
|
+
}
|
|
188
|
+
return serialized;
|
|
189
|
+
}
|
|
190
|
+
const values = [message.causationId, message.correlationId, message.executedUser];
|
|
191
|
+
if (!values.every((value) => value === null || typeof value === "string")) {
|
|
192
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} import metadata must be string or null`);
|
|
193
|
+
}
|
|
194
|
+
const allNull = values.every((value) => value === null);
|
|
195
|
+
const allSerialized = message.causationId === serialized.causationId &&
|
|
196
|
+
message.correlationId === serialized.correlationId && message.executedUser === serialized.executedUser;
|
|
197
|
+
if (!allNull && !allSerialized) {
|
|
198
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} import metadata must be all null or serialized constants`);
|
|
199
|
+
}
|
|
200
|
+
return {
|
|
201
|
+
causationId: message.causationId,
|
|
202
|
+
correlationId: message.correlationId,
|
|
203
|
+
executedUser: message.executedUser,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Cloudflare D1 PipelineStore implementation.
|
|
208
|
+
*
|
|
209
|
+
* All schema changes live in migrations/d1. Writes that span several tables
|
|
210
|
+
* are one predeclared D1 batch; there is no interactive transaction or
|
|
211
|
+
* TypeScript read/branch between statements in the durable mutation.
|
|
212
|
+
*/
|
|
213
|
+
export class D1EventStore {
|
|
214
|
+
database;
|
|
215
|
+
options;
|
|
216
|
+
initialized = false;
|
|
217
|
+
constructor(database, options = {}) {
|
|
218
|
+
this.database = database;
|
|
219
|
+
this.options = options;
|
|
220
|
+
}
|
|
221
|
+
async initialize() {
|
|
222
|
+
if (this.initialized)
|
|
223
|
+
return;
|
|
224
|
+
// A read verifies the binding without attempting runtime DDL. The
|
|
225
|
+
// versioned migration is the only schema authority.
|
|
226
|
+
await this.database.prepare("SELECT 1 AS migration_binding").all();
|
|
227
|
+
this.initialized = true;
|
|
228
|
+
}
|
|
229
|
+
async recordDelivery(message, arrivedAt, deliverySource = "queue", attemptContext) {
|
|
230
|
+
this.ready();
|
|
231
|
+
const mutationAttemptId = `admission-${crypto.randomUUID()}`;
|
|
232
|
+
let result;
|
|
233
|
+
let failure;
|
|
234
|
+
try {
|
|
235
|
+
result = await this.recordDeliveryCore(message, arrivedAt, deliverySource, mutationAttemptId);
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
failure = { error };
|
|
239
|
+
}
|
|
240
|
+
const retryReason = failure === undefined ? null : errorText(failure.error);
|
|
241
|
+
// A canonical identity rejection is a complete fail-before-batch
|
|
242
|
+
// boundary: not even diagnostic-table INSERT/trim work may be scheduled
|
|
243
|
+
// for this invocation. Other outcomes retain the diagnostic-only receipt,
|
|
244
|
+
// but it remains outside the awaited delivery turn and invocation-owned.
|
|
245
|
+
const suppressDiagnostic = failure?.error instanceof D1IdentityConflictError && failure.error.beforeMutation;
|
|
246
|
+
if (!suppressDiagnostic) {
|
|
247
|
+
const diagnostic = Promise.resolve().then(() => this.bestEffortG69AdmissionAttempt(message, arrivedAt, deliverySource, attemptContext, mutationAttemptId, result, failure, retryReason));
|
|
248
|
+
if (attemptContext?.waitUntil !== undefined)
|
|
249
|
+
attemptContext.waitUntil(diagnostic);
|
|
250
|
+
else
|
|
251
|
+
void diagnostic;
|
|
252
|
+
}
|
|
253
|
+
if (failure !== undefined)
|
|
254
|
+
throw failure.error;
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
async recordDeliveryCore(message, arrivedAt, deliverySource = "queue", mutationAttemptId = `admission-${crypto.randomUUID()}`) {
|
|
258
|
+
this.ready();
|
|
259
|
+
// G44 source facts belong to a Tag outbox obligation. The fenced G22
|
|
260
|
+
// bootstrap import path has no Tag source partition to acknowledge, so it
|
|
261
|
+
// must not manufacture a receipt or turn an import record into scanner
|
|
262
|
+
// authority. Normal Queue/fast delivery remains fail-closed on the full
|
|
263
|
+
// source envelope.
|
|
264
|
+
const requiresGlobalReceipt = deliverySource !== "import";
|
|
265
|
+
if (requiresGlobalReceipt)
|
|
266
|
+
assertCompletenessEnvelope(message);
|
|
267
|
+
const identity = resolveDeliveryIdentity(message, deliverySource);
|
|
268
|
+
assertSortableUniqueId(message.suid);
|
|
269
|
+
const acceptsImportedId = deliverySource === "import";
|
|
270
|
+
if (!(acceptsImportedId ? isRfc4122Uuid(message.eventId) : isUuidV7(message.eventId))) {
|
|
271
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} is not an ${acceptsImportedId ? "RFC 4122 UUID" : "UUID v7"}`);
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
JSON.parse(message.payload);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} payload is not JSON text`);
|
|
278
|
+
}
|
|
279
|
+
const timestamp = message.timestamp ?? new Date(arrivedAt).toISOString();
|
|
280
|
+
// C# DateTimeOffset's round-trip representation carries seven fractional
|
|
281
|
+
// digits. Values authored by the TS commit worker use milliseconds, but
|
|
282
|
+
// imported C# records must retain their exact UTC representation too.
|
|
283
|
+
if (!CANONICAL_UTC_TIMESTAMP_PATTERN.test(timestamp)) {
|
|
284
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} timestamp is not canonical UTC ISO-8601`);
|
|
285
|
+
}
|
|
286
|
+
const metadata = metadataForDelivery(message, deliverySource);
|
|
287
|
+
// This is the pre-existing correctness preflight for canonical identity
|
|
288
|
+
// conflicts. It is not G69 diagnostic work: it must remain before the
|
|
289
|
+
// durable batch so a conflicting replay cannot write incidents, receipts,
|
|
290
|
+
// or event-arrival state.
|
|
291
|
+
const storedBefore = await this.eventById(message.serviceId, message.eventId);
|
|
292
|
+
const incomingEventType = identity.key;
|
|
293
|
+
const eventTags = [...message.eventTags];
|
|
294
|
+
const tagsJson = JSON.stringify(eventTags);
|
|
295
|
+
if (storedBefore !== undefined && (storedBefore.eventType !== incomingEventType ||
|
|
296
|
+
storedBefore.suid !== message.suid ||
|
|
297
|
+
storedBefore.payload !== message.payload ||
|
|
298
|
+
JSON.stringify(storedBefore.eventTags) !== tagsJson ||
|
|
299
|
+
(requiresGlobalReceipt && storedBefore.eventDigest !== undefined && storedBefore.eventDigest !== message.completeness.eventDigest) ||
|
|
300
|
+
storedBefore.timestamp !== timestamp ||
|
|
301
|
+
storedBefore.causationId !== metadata.causationId ||
|
|
302
|
+
storedBefore.correlationId !== metadata.correlationId ||
|
|
303
|
+
storedBefore.executedUser !== metadata.executedUser)) {
|
|
304
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} conflicts with its canonical event identity`, true);
|
|
305
|
+
}
|
|
306
|
+
const lagMs = Math.max(0, arrivedAt - message.enqueuedAt);
|
|
307
|
+
const statements = [
|
|
308
|
+
this.database.prepare(`INSERT INTO serialized_dcb_allocator_bindings (service_id, allocator_lineage_id, bound_at)
|
|
309
|
+
VALUES (?, ?, ?)
|
|
310
|
+
ON CONFLICT (service_id) DO NOTHING`).bind(message.serviceId, message.allocatorLineageId, arrivedAt),
|
|
311
|
+
// Binding mismatch is recorded in the same batch before any event path.
|
|
312
|
+
this.database.prepare(`INSERT INTO serialized_dcb_delivery_incidents
|
|
313
|
+
(service_id, identity_key, classification, bound_lineage_id, incoming_lineage_id, observed_at)
|
|
314
|
+
SELECT service_id,
|
|
315
|
+
'LINEAGE_MISMATCH|' || service_id || '|' || allocator_lineage_id || '|' || ?,
|
|
316
|
+
'LINEAGE_MISMATCH', allocator_lineage_id, ?, ?
|
|
317
|
+
FROM serialized_dcb_allocator_bindings
|
|
318
|
+
WHERE service_id = ? AND allocator_lineage_id <> ?
|
|
319
|
+
ON CONFLICT (service_id, identity_key) DO NOTHING`).bind(message.allocatorLineageId, message.allocatorLineageId, arrivedAt, message.serviceId, message.allocatorLineageId),
|
|
320
|
+
// G31 needs a target-SUID point gate even though a rejected lineage
|
|
321
|
+
// delivery has no dcb_events row. Keep this compact alias
|
|
322
|
+
// separate from the historical incident identity used by operators.
|
|
323
|
+
this.database.prepare(`INSERT INTO serialized_dcb_wait_target_incidents
|
|
324
|
+
(service_id, suid, classification, event_id, observed_at)
|
|
325
|
+
SELECT ?, ?, 'LINEAGE_MISMATCH', ?, ?
|
|
326
|
+
WHERE EXISTS (
|
|
327
|
+
SELECT 1 FROM serialized_dcb_allocator_bindings
|
|
328
|
+
WHERE service_id = ? AND allocator_lineage_id <> ?
|
|
329
|
+
)
|
|
330
|
+
ON CONFLICT (service_id, suid, classification) DO NOTHING`).bind(message.serviceId, message.suid, message.eventId, arrivedAt, message.serviceId, message.allocatorLineageId),
|
|
331
|
+
// SUID collision is a typed incident rather than a UNIQUE exception.
|
|
332
|
+
this.database.prepare(`INSERT INTO serialized_dcb_delivery_incidents
|
|
333
|
+
(service_id, identity_key, classification, suid, existing_event_id, incoming_event_id, observed_at)
|
|
334
|
+
SELECT "ServiceId",
|
|
335
|
+
'SUID_COLLISION|' || "ServiceId" || '|' || "SortableUniqueId" || '|' ||
|
|
336
|
+
CASE WHEN "Id" < ? THEN "Id" ELSE ? END || '|' ||
|
|
337
|
+
CASE WHEN "Id" < ? THEN ? ELSE "Id" END,
|
|
338
|
+
'SUID_COLLISION', "SortableUniqueId", "Id", ?, ?
|
|
339
|
+
FROM dcb_events
|
|
340
|
+
WHERE "ServiceId" = ? AND "SortableUniqueId" COLLATE BINARY = ? COLLATE BINARY AND "Id" <> ?
|
|
341
|
+
AND NOT EXISTS (
|
|
342
|
+
SELECT 1 FROM serialized_dcb_allocator_bindings
|
|
343
|
+
WHERE service_id = ? AND allocator_lineage_id <> ?
|
|
344
|
+
)
|
|
345
|
+
LIMIT 1
|
|
346
|
+
ON CONFLICT (service_id, identity_key) DO NOTHING`).bind(message.eventId, message.eventId, message.eventId, message.eventId, message.eventId, arrivedAt, message.serviceId, message.suid, message.eventId, message.serviceId, message.allocatorLineageId),
|
|
347
|
+
this.database.prepare(`INSERT INTO serialized_dcb_wait_target_incidents
|
|
348
|
+
(service_id, suid, classification, event_id, observed_at)
|
|
349
|
+
SELECT ?, ?, 'SUID_COLLISION', ?, ?
|
|
350
|
+
WHERE EXISTS (
|
|
351
|
+
SELECT 1 FROM dcb_events
|
|
352
|
+
WHERE "ServiceId" = ? AND "SortableUniqueId" COLLATE BINARY = ? COLLATE BINARY
|
|
353
|
+
AND "Id" <> ?
|
|
354
|
+
)
|
|
355
|
+
AND NOT EXISTS (
|
|
356
|
+
SELECT 1 FROM serialized_dcb_allocator_bindings
|
|
357
|
+
WHERE service_id = ? AND allocator_lineage_id <> ?
|
|
358
|
+
)
|
|
359
|
+
ON CONFLICT (service_id, suid, classification) DO NOTHING`).bind(message.serviceId, message.suid, message.eventId, arrivedAt, message.serviceId, message.suid, message.eventId, message.serviceId, message.allocatorLineageId),
|
|
360
|
+
this.database.prepare(`INSERT INTO dcb_events
|
|
361
|
+
("ServiceId", "Id", "SortableUniqueId", "EventType", "Payload", "Tags", "Timestamp", "CausationId", "CorrelationId", "ExecutedUser", "EventDigest")
|
|
362
|
+
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
363
|
+
WHERE NOT EXISTS (
|
|
364
|
+
SELECT 1 FROM serialized_dcb_allocator_bindings
|
|
365
|
+
WHERE service_id = ? AND allocator_lineage_id <> ?
|
|
366
|
+
)
|
|
367
|
+
AND NOT EXISTS (
|
|
368
|
+
SELECT 1 FROM dcb_events
|
|
369
|
+
WHERE "ServiceId" = ? AND "SortableUniqueId" COLLATE BINARY = ? COLLATE BINARY AND "Id" <> ?
|
|
370
|
+
)
|
|
371
|
+
ON CONFLICT ("ServiceId", "Id") DO NOTHING`).bind(message.serviceId, message.eventId, message.suid, incomingEventType, message.payload, tagsJson, timestamp, metadata.causationId, metadata.correlationId, metadata.executedUser, requiresGlobalReceipt ? message.completeness.eventDigest : null, message.serviceId, message.allocatorLineageId, message.serviceId, message.suid, message.eventId),
|
|
372
|
+
// Source discoverability is part of the same atomic global admission
|
|
373
|
+
// batch as the event, membership, and receipt. A local Tag commit can
|
|
374
|
+
// therefore return without a D1 registry probe; until this batch
|
|
375
|
+
// succeeds, G44 has no source authority for the obligation and remains
|
|
376
|
+
// fail-closed. Import delivery deliberately does not manufacture a
|
|
377
|
+
// Tag source partition.
|
|
378
|
+
this.database.prepare(`INSERT INTO serialized_dcb_source_partitions
|
|
379
|
+
(service_id, partition_tag, last_obligation_sequence, registered_at)
|
|
380
|
+
SELECT ?, ?, ?, ?
|
|
381
|
+
WHERE ? = 1 AND EXISTS (
|
|
382
|
+
SELECT 1 FROM dcb_events
|
|
383
|
+
WHERE "ServiceId" = ? AND "Id" = ? AND "EventDigest" = ?
|
|
384
|
+
)
|
|
385
|
+
ON CONFLICT (service_id, partition_tag) DO UPDATE
|
|
386
|
+
SET last_obligation_sequence = MAX(
|
|
387
|
+
serialized_dcb_source_partitions.last_obligation_sequence,
|
|
388
|
+
excluded.last_obligation_sequence
|
|
389
|
+
),
|
|
390
|
+
registered_at = excluded.registered_at`).bind(message.serviceId, message.tag, message.completeness.obligationSequence, arrivedAt, requiresGlobalReceipt ? 1 : 0, message.serviceId, message.eventId, message.completeness.eventDigest),
|
|
391
|
+
// The global declared event, this tag-side committed membership, and
|
|
392
|
+
// the source-obligation receipt are one D1 atomic batch. A Queue ack or
|
|
393
|
+
// a receipt without this join never becomes source acknowledgement.
|
|
394
|
+
this.database.prepare(`INSERT INTO serialized_dcb_global_memberships
|
|
395
|
+
(service_id, event_id, partition_tag, event_digest, committed_at)
|
|
396
|
+
SELECT ?, ?, ?, ?, ?
|
|
397
|
+
WHERE ? = 1 AND EXISTS (
|
|
398
|
+
SELECT 1 FROM dcb_events
|
|
399
|
+
WHERE "ServiceId" = ? AND "Id" = ? AND "EventDigest" = ?
|
|
400
|
+
)
|
|
401
|
+
ON CONFLICT (service_id, event_id, partition_tag) DO NOTHING`).bind(message.serviceId, message.eventId, message.tag, message.completeness.eventDigest, arrivedAt, requiresGlobalReceipt ? 1 : 0, message.serviceId, message.eventId, message.completeness.eventDigest),
|
|
402
|
+
this.database.prepare(`INSERT INTO serialized_dcb_global_receipts
|
|
403
|
+
(service_id, partition_tag, obligation_sequence, event_id, event_digest, membership_tag, received_at)
|
|
404
|
+
SELECT ?, ?, ?, ?, ?, ?, ?
|
|
405
|
+
WHERE ? = 1 AND EXISTS (
|
|
406
|
+
SELECT 1 FROM serialized_dcb_global_memberships
|
|
407
|
+
WHERE service_id = ? AND event_id = ? AND partition_tag = ? AND event_digest = ?
|
|
408
|
+
)
|
|
409
|
+
ON CONFLICT (service_id, partition_tag, obligation_sequence) DO NOTHING`).bind(message.serviceId, message.tag, message.completeness.obligationSequence, message.eventId, message.completeness.eventDigest, message.tag, arrivedAt, requiresGlobalReceipt ? 1 : 0, message.serviceId, message.eventId, message.tag, message.completeness.eventDigest),
|
|
410
|
+
this.database.prepare(`INSERT INTO dcb_event_ops
|
|
411
|
+
("ServiceId", "Id", "AttemptId", "FirstAdmissionAttemptId", "LastAdmissionAttemptId", "AllocatorLineageId", "FirstArrivedAt", "LastArrivedAt", "MaxDeliveryLagMs", "FirstArrivedSource", "LastArrivedSource")
|
|
412
|
+
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
413
|
+
WHERE EXISTS (
|
|
414
|
+
SELECT 1 FROM dcb_events
|
|
415
|
+
WHERE "ServiceId" = ? AND "Id" = ?
|
|
416
|
+
AND "SortableUniqueId" COLLATE BINARY = ? COLLATE BINARY
|
|
417
|
+
AND "Payload" = ? AND "EventType" = ? AND "Tags" = ?
|
|
418
|
+
)
|
|
419
|
+
ON CONFLICT ("ServiceId", "Id") DO UPDATE
|
|
420
|
+
SET "FirstArrivedAt" = MIN(dcb_event_ops."FirstArrivedAt", excluded."FirstArrivedAt"),
|
|
421
|
+
"LastArrivedAt" = MAX(dcb_event_ops."LastArrivedAt", excluded."LastArrivedAt"),
|
|
422
|
+
"MaxDeliveryLagMs" = MAX(dcb_event_ops."MaxDeliveryLagMs", excluded."MaxDeliveryLagMs"),
|
|
423
|
+
"FirstAdmissionAttemptId" = COALESCE(dcb_event_ops."FirstAdmissionAttemptId", excluded."FirstAdmissionAttemptId"),
|
|
424
|
+
"LastAdmissionAttemptId" = excluded."LastAdmissionAttemptId",
|
|
425
|
+
"FirstArrivedSource" = dcb_event_ops."FirstArrivedSource",
|
|
426
|
+
"LastArrivedSource" = CASE
|
|
427
|
+
WHEN excluded."LastArrivedAt" > dcb_event_ops."LastArrivedAt" THEN excluded."LastArrivedSource"
|
|
428
|
+
ELSE dcb_event_ops."LastArrivedSource"
|
|
429
|
+
END`).bind(message.serviceId, message.eventId, message.attemptId, mutationAttemptId, mutationAttemptId, message.allocatorLineageId, arrivedAt, arrivedAt, lagMs, deliverySource, deliverySource, message.serviceId, message.eventId, message.suid, message.payload, incomingEventType, tagsJson),
|
|
430
|
+
this.database.prepare(`INSERT INTO serialized_dcb_event_arrivals
|
|
431
|
+
(service_id, event_id, tag, enqueued_at, arrived_at, lag_ms)
|
|
432
|
+
SELECT ?, ?, ?, ?, ?, ?
|
|
433
|
+
WHERE EXISTS (
|
|
434
|
+
SELECT 1 FROM dcb_events
|
|
435
|
+
WHERE "ServiceId" = ? AND "Id" = ? AND "SortableUniqueId" COLLATE BINARY = ? COLLATE BINARY
|
|
436
|
+
AND "Payload" = ? AND "EventType" = ? AND "Tags" = ?
|
|
437
|
+
)
|
|
438
|
+
AND NOT EXISTS (
|
|
439
|
+
SELECT 1 FROM serialized_dcb_allocator_bindings
|
|
440
|
+
WHERE service_id = ? AND allocator_lineage_id <> ?
|
|
441
|
+
)
|
|
442
|
+
AND NOT EXISTS (
|
|
443
|
+
SELECT 1 FROM dcb_events collision
|
|
444
|
+
WHERE collision."ServiceId" = ? AND collision."SortableUniqueId" COLLATE BINARY = ? COLLATE BINARY
|
|
445
|
+
AND collision."Id" <> ?
|
|
446
|
+
)
|
|
447
|
+
ON CONFLICT (service_id, event_id, tag) DO UPDATE
|
|
448
|
+
SET enqueued_at = MIN(serialized_dcb_event_arrivals.enqueued_at, excluded.enqueued_at),
|
|
449
|
+
arrived_at = MAX(serialized_dcb_event_arrivals.arrived_at, excluded.arrived_at),
|
|
450
|
+
lag_ms = MAX(serialized_dcb_event_arrivals.lag_ms, excluded.lag_ms)`).bind(message.serviceId, message.eventId, message.tag, message.enqueuedAt, arrivedAt, lagMs, message.serviceId, message.eventId, message.suid, message.payload, incomingEventType, tagsJson, message.serviceId, message.allocatorLineageId, message.serviceId, message.suid, message.eventId),
|
|
451
|
+
this.database.prepare(`INSERT INTO serialized_dcb_lag_estimates (service_id, estimate_ms, observed_at)
|
|
452
|
+
SELECT ?, ?, ?
|
|
453
|
+
WHERE ? = 'queue'
|
|
454
|
+
AND NOT EXISTS (
|
|
455
|
+
SELECT 1 FROM serialized_dcb_allocator_bindings
|
|
456
|
+
WHERE service_id = ? AND allocator_lineage_id <> ?
|
|
457
|
+
)
|
|
458
|
+
AND NOT EXISTS (
|
|
459
|
+
SELECT 1 FROM dcb_events collision
|
|
460
|
+
WHERE collision."ServiceId" = ? AND collision."SortableUniqueId" COLLATE BINARY = ? COLLATE BINARY
|
|
461
|
+
AND collision."Id" <> ?
|
|
462
|
+
)
|
|
463
|
+
AND NOT EXISTS (
|
|
464
|
+
SELECT 1 FROM dcb_events contradictory
|
|
465
|
+
WHERE contradictory."ServiceId" = ? AND contradictory."Id" = ?
|
|
466
|
+
AND (contradictory."SortableUniqueId" COLLATE BINARY <> ? COLLATE BINARY
|
|
467
|
+
OR contradictory."Payload" <> ? OR contradictory."EventType" <> ?
|
|
468
|
+
OR contradictory."Tags" <> ?)
|
|
469
|
+
)
|
|
470
|
+
AND NOT EXISTS (
|
|
471
|
+
SELECT 1 FROM dcb_events prior
|
|
472
|
+
WHERE prior."ServiceId" = ? AND prior."SortableUniqueId" COLLATE BINARY > ? COLLATE BINARY
|
|
473
|
+
)
|
|
474
|
+
ON CONFLICT (service_id) DO UPDATE
|
|
475
|
+
SET estimate_ms = MAX(
|
|
476
|
+
MAX(serialized_dcb_lag_estimates.estimate_ms -
|
|
477
|
+
MAX(0, excluded.observed_at - serialized_dcb_lag_estimates.observed_at), 0),
|
|
478
|
+
excluded.estimate_ms
|
|
479
|
+
),
|
|
480
|
+
observed_at = excluded.observed_at`).bind(message.serviceId, lagMs, arrivedAt, deliverySource, message.serviceId, message.allocatorLineageId, message.serviceId, message.suid, message.eventId, message.serviceId, message.eventId, message.suid, message.payload, incomingEventType, tagsJson, message.serviceId, message.suid),
|
|
481
|
+
];
|
|
482
|
+
await this.batch("recordDelivery", statements);
|
|
483
|
+
const binding = await this.lineageBinding(message.serviceId);
|
|
484
|
+
if (binding !== undefined && binding !== message.allocatorLineageId) {
|
|
485
|
+
const incident = await this.incidentByIdentity(message.serviceId, lineageIdentity(message.serviceId, binding, message.allocatorLineageId));
|
|
486
|
+
if (incident === undefined)
|
|
487
|
+
throw new Error("D1 lineage incident was not durable");
|
|
488
|
+
return { outcome: "lineage-mismatch", kind: "lineage-mismatch", incident };
|
|
489
|
+
}
|
|
490
|
+
const collision = await this.collisionEvent(message.serviceId, message.suid, message.eventId);
|
|
491
|
+
if (collision !== undefined) {
|
|
492
|
+
const identity = collisionIdentity(message.serviceId, message.suid, collision, message.eventId);
|
|
493
|
+
const incident = await this.incidentByIdentity(message.serviceId, identity);
|
|
494
|
+
if (incident === undefined)
|
|
495
|
+
throw new Error("D1 SUID collision incident was not durable");
|
|
496
|
+
return { outcome: "suid-collision", kind: "suid-collision", incident };
|
|
497
|
+
}
|
|
498
|
+
const stored = await this.eventById(message.serviceId, message.eventId);
|
|
499
|
+
if (stored === undefined)
|
|
500
|
+
throw new Error("D1 event delivery did not produce a durable row");
|
|
501
|
+
if (stored.suid !== message.suid ||
|
|
502
|
+
stored.payload !== message.payload ||
|
|
503
|
+
JSON.stringify(stored.eventTags) !== tagsJson ||
|
|
504
|
+
stored.eventType !== incomingEventType ||
|
|
505
|
+
(requiresGlobalReceipt && stored.eventDigest !== message.completeness.eventDigest) ||
|
|
506
|
+
stored.timestamp !== timestamp ||
|
|
507
|
+
stored.causationId !== metadata.causationId ||
|
|
508
|
+
stored.correlationId !== metadata.correlationId ||
|
|
509
|
+
stored.executedUser !== metadata.executedUser) {
|
|
510
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} conflicts with its durable D1 identity`);
|
|
511
|
+
}
|
|
512
|
+
if (requiresGlobalReceipt) {
|
|
513
|
+
const receipt = await this.readGlobalReceiptJoin(message);
|
|
514
|
+
if (receipt === undefined) {
|
|
515
|
+
throw new Error(`D1 event ${message.eventId} has no readable global receipt/membership join`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
// Mutation-owned admission evidence is diagnostic. It is read by the
|
|
519
|
+
// invocation-lifetime receipt after this core result is returned, never
|
|
520
|
+
// on the awaited delivery path. Without a pre-read, duplicate status is
|
|
521
|
+
// deliberately unverified here; the off-path receipt may classify it
|
|
522
|
+
// from mutation-owned attempt identities.
|
|
523
|
+
return {
|
|
524
|
+
outcome: "stored",
|
|
525
|
+
kind: "stored",
|
|
526
|
+
event: stored,
|
|
527
|
+
mutationEvidence: "unverified",
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
async readGlobalReceiptJoin(message) {
|
|
531
|
+
this.ready();
|
|
532
|
+
const row = await this.database.prepare(`SELECT receipt.service_id, receipt.partition_tag, receipt.obligation_sequence,
|
|
533
|
+
receipt.event_id, receipt.event_digest, receipt.membership_tag, receipt.received_at
|
|
534
|
+
FROM serialized_dcb_global_receipts AS receipt
|
|
535
|
+
JOIN serialized_dcb_global_memberships AS membership
|
|
536
|
+
ON membership.service_id = receipt.service_id
|
|
537
|
+
AND membership.event_id = receipt.event_id
|
|
538
|
+
AND membership.partition_tag = receipt.membership_tag
|
|
539
|
+
AND membership.event_digest = receipt.event_digest
|
|
540
|
+
JOIN dcb_events AS event
|
|
541
|
+
ON event."ServiceId" = receipt.service_id
|
|
542
|
+
AND event."Id" = receipt.event_id
|
|
543
|
+
AND event."EventDigest" = receipt.event_digest
|
|
544
|
+
WHERE receipt.service_id = ?
|
|
545
|
+
AND receipt.partition_tag = ?
|
|
546
|
+
AND receipt.obligation_sequence = ?
|
|
547
|
+
AND receipt.event_id = ?
|
|
548
|
+
AND receipt.event_digest = ?
|
|
549
|
+
AND receipt.membership_tag = ?`).bind(message.serviceId, message.tag, message.completeness.obligationSequence, message.eventId, message.completeness.eventDigest, message.tag).first();
|
|
550
|
+
if (row === null || row === undefined)
|
|
551
|
+
return undefined;
|
|
552
|
+
return {
|
|
553
|
+
serviceId: asString(row.service_id, "global_receipt.service_id"),
|
|
554
|
+
partitionTag: asString(row.partition_tag, "global_receipt.partition_tag"),
|
|
555
|
+
obligationSequence: asNumber(row.obligation_sequence, "global_receipt.obligation_sequence"),
|
|
556
|
+
eventId: asString(row.event_id, "global_receipt.event_id"),
|
|
557
|
+
eventDigest: asString(row.event_digest, "global_receipt.event_digest"),
|
|
558
|
+
membershipTag: asString(row.membership_tag, "global_receipt.membership_tag"),
|
|
559
|
+
receivedAt: asNumber(row.received_at, "global_receipt.received_at"),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
async readAllEvents(serviceId, since) {
|
|
563
|
+
this.ready();
|
|
564
|
+
// The empty cursor is the internal "before the first event" sentinel.
|
|
565
|
+
// Every externally supplied cursor is a C#-shape SUID.
|
|
566
|
+
if (since.length !== 0)
|
|
567
|
+
assertSortableUniqueId(since);
|
|
568
|
+
const rows = await this.rows(`SELECT e."ServiceId" AS service_id, e."Id" AS event_id,
|
|
569
|
+
e."SortableUniqueId" AS suid, e."Payload" AS payload,
|
|
570
|
+
e."EventType" AS event_type, e."Tags" AS event_tags,
|
|
571
|
+
e."EventDigest" AS event_digest,
|
|
572
|
+
e."Timestamp" AS timestamp, e."CausationId" AS causation_id,
|
|
573
|
+
e."CorrelationId" AS correlation_id, e."ExecutedUser" AS executed_user,
|
|
574
|
+
COALESCE(o."FirstArrivedAt", 0) AS first_arrived_at,
|
|
575
|
+
COALESCE(o."LastArrivedAt", 0) AS last_arrived_at,
|
|
576
|
+
COALESCE(o."MaxDeliveryLagMs", 0) AS max_delivery_lag_ms
|
|
577
|
+
FROM dcb_events e LEFT JOIN dcb_event_ops o
|
|
578
|
+
ON o."ServiceId" = e."ServiceId" AND o."Id" = e."Id"
|
|
579
|
+
WHERE e."ServiceId" = ? AND e."SortableUniqueId" COLLATE BINARY > ? COLLATE BINARY
|
|
580
|
+
ORDER BY e."SortableUniqueId" COLLATE BINARY ASC, e."Id" COLLATE BINARY ASC`, serviceId, since);
|
|
581
|
+
return Promise.all(rows.map((row) => this.eventFromRow(row)));
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* G69 strict-order detector: find an event admitted after a projection
|
|
585
|
+
* checkpoint whose opaque SUID is lower than that checkpoint. A replay of
|
|
586
|
+
* an event already known before the checkpoint is deliberately excluded by
|
|
587
|
+
* FirstArrivedAt, so the detector reports new lower-SUID admission rather
|
|
588
|
+
* than ordinary Queue redelivery.
|
|
589
|
+
*/
|
|
590
|
+
async findLateLowerSuid(serviceId, checkpointSuid, checkpointUpdatedAt) {
|
|
591
|
+
const evidence = await this.findLateLowerSuidEvidence(serviceId, checkpointSuid, checkpointUpdatedAt);
|
|
592
|
+
return evidence.kind === "late-lower-suid" ? evidence.event : undefined;
|
|
593
|
+
}
|
|
594
|
+
async findLateLowerSuidEvidence(serviceId, checkpointSuid, checkpointUpdatedAt, generation) {
|
|
595
|
+
const detectorStartedAt = Date.now();
|
|
596
|
+
const finish = (result) => ({ ...result, durationMs: Math.max(0, Date.now() - detectorStartedAt) });
|
|
597
|
+
this.ready();
|
|
598
|
+
// Generation ownership is enforced at the MV/quarantine boundary; the
|
|
599
|
+
// source evidence itself is generation-independent and must remain
|
|
600
|
+
// auditable across a generation transition. A malformed generation is
|
|
601
|
+
// still an untrusted clock/context observation, never a reason to open a
|
|
602
|
+
// quarantine.
|
|
603
|
+
if (generation !== undefined && (!Number.isSafeInteger(generation) || generation < 0)) {
|
|
604
|
+
return finish({ kind: "unknown", reason: "generation-invalid" });
|
|
605
|
+
}
|
|
606
|
+
if (checkpointSuid.length === 0)
|
|
607
|
+
return finish({ kind: "miss" });
|
|
608
|
+
if (!Number.isSafeInteger(checkpointUpdatedAt) || checkpointUpdatedAt < 0) {
|
|
609
|
+
return finish({ kind: "unknown", reason: "checkpoint-clock-invalid" });
|
|
610
|
+
}
|
|
611
|
+
try {
|
|
612
|
+
assertSortableUniqueId(checkpointSuid);
|
|
613
|
+
// The old detector materialized every lower-SUID row and then issued an
|
|
614
|
+
// event lookup for each candidate. Keep the proven-wins ordering, but
|
|
615
|
+
// make the normal path bounded: each classification is a single LIMIT 1
|
|
616
|
+
// indexed source/arrival probe, followed by at most one event read.
|
|
617
|
+
const proven = await this.database.prepare(`SELECT e."Id" AS event_id
|
|
618
|
+
FROM dcb_events e
|
|
619
|
+
JOIN dcb_event_ops o
|
|
620
|
+
ON o."ServiceId" = e."ServiceId" AND o."Id" = e."Id"
|
|
621
|
+
WHERE e."ServiceId" = ?
|
|
622
|
+
AND e."SortableUniqueId" COLLATE BINARY < ? COLLATE BINARY
|
|
623
|
+
AND o."FirstArrivedAt" > ?
|
|
624
|
+
AND o."FirstArrivedAt" >= 0
|
|
625
|
+
AND o."LastArrivedAt" >= o."FirstArrivedAt"
|
|
626
|
+
AND o."FirstArrivedSource" IN ('queue', 'fast')
|
|
627
|
+
ORDER BY e."SortableUniqueId" COLLATE BINARY ASC, e."Id" COLLATE BINARY ASC
|
|
628
|
+
LIMIT 1`).bind(serviceId, checkpointSuid, checkpointUpdatedAt).first();
|
|
629
|
+
if (proven !== null && proven !== undefined) {
|
|
630
|
+
const event = await this.eventById(serviceId, asString(proven.event_id, "late_lower_suid.event_id"));
|
|
631
|
+
return event === undefined
|
|
632
|
+
? finish({ kind: "unknown", reason: "late-event-row-disappeared" })
|
|
633
|
+
: finish({ kind: "late-lower-suid", event });
|
|
634
|
+
}
|
|
635
|
+
// Unknown evidence is checked after proven evidence so an uncertain
|
|
636
|
+
// row cannot hide a later independently proven violation.
|
|
637
|
+
const uncertain = await this.database.prepare(`SELECT o."FirstArrivedAt" AS first_arrived_at,
|
|
638
|
+
o."LastArrivedAt" AS last_arrived_at,
|
|
639
|
+
o."FirstArrivedSource" AS first_arrived_source
|
|
640
|
+
FROM dcb_events e
|
|
641
|
+
JOIN dcb_event_ops o
|
|
642
|
+
ON o."ServiceId" = e."ServiceId" AND o."Id" = e."Id"
|
|
643
|
+
WHERE e."ServiceId" = ?
|
|
644
|
+
AND e."SortableUniqueId" COLLATE BINARY < ? COLLATE BINARY
|
|
645
|
+
AND (
|
|
646
|
+
o."FirstArrivedAt" < 0
|
|
647
|
+
OR o."LastArrivedAt" < o."FirstArrivedAt"
|
|
648
|
+
OR (
|
|
649
|
+
o."FirstArrivedAt" > ?
|
|
650
|
+
AND COALESCE(o."FirstArrivedSource", 'unknown') NOT IN ('queue', 'fast')
|
|
651
|
+
)
|
|
652
|
+
)
|
|
653
|
+
ORDER BY e."SortableUniqueId" COLLATE BINARY ASC, e."Id" COLLATE BINARY ASC
|
|
654
|
+
LIMIT 1`).bind(serviceId, checkpointSuid, checkpointUpdatedAt).first();
|
|
655
|
+
if (uncertain !== null && uncertain !== undefined) {
|
|
656
|
+
const first = asNumber(uncertain.first_arrived_at, "late_lower_suid.first_arrived_at");
|
|
657
|
+
const last = asNumber(uncertain.last_arrived_at, "late_lower_suid.last_arrived_at");
|
|
658
|
+
return finish({
|
|
659
|
+
kind: "unknown",
|
|
660
|
+
reason: first < 0 || last < first ? "arrival-clock-rollback" : "arrival-provenance-untrusted",
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
const replay = await this.database.prepare(`SELECT e."Id" AS event_id
|
|
664
|
+
FROM dcb_events e
|
|
665
|
+
JOIN dcb_event_ops o
|
|
666
|
+
ON o."ServiceId" = e."ServiceId" AND o."Id" = e."Id"
|
|
667
|
+
WHERE e."ServiceId" = ?
|
|
668
|
+
AND e."SortableUniqueId" COLLATE BINARY < ? COLLATE BINARY
|
|
669
|
+
AND o."FirstArrivedAt" <= ?
|
|
670
|
+
AND o."LastArrivedAt" > ?
|
|
671
|
+
AND o."LastArrivedSource" IN ('queue', 'fast')
|
|
672
|
+
ORDER BY o."LastArrivedAt" DESC,
|
|
673
|
+
e."SortableUniqueId" COLLATE BINARY ASC,
|
|
674
|
+
e."Id" COLLATE BINARY ASC
|
|
675
|
+
LIMIT 1`).bind(serviceId, checkpointSuid, checkpointUpdatedAt, checkpointUpdatedAt).first();
|
|
676
|
+
if (replay === null || replay === undefined)
|
|
677
|
+
return finish({ kind: "miss" });
|
|
678
|
+
const event = await this.eventById(serviceId, asString(replay.event_id, "replay.event_id"));
|
|
679
|
+
return event === undefined
|
|
680
|
+
? finish({ kind: "unknown", reason: "replay-event-row-disappeared" })
|
|
681
|
+
: finish({ kind: "replay", event });
|
|
682
|
+
}
|
|
683
|
+
catch (error) {
|
|
684
|
+
return finish({ kind: "unknown", reason: errorText(error) });
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* G31 source proof: one indexed target-SUID lookup distinguishes pending,
|
|
689
|
+
* unique stored, and contradictory/incident states. It intentionally never
|
|
690
|
+
* delegates to readAllEvents, which would make waitFor history-size bound.
|
|
691
|
+
*/
|
|
692
|
+
async readWaitForTarget(serviceId, suid) {
|
|
693
|
+
this.ready();
|
|
694
|
+
assertSortableUniqueId(suid);
|
|
695
|
+
const row = await this.database.prepare(`WITH target AS (
|
|
696
|
+
SELECT "Id" AS event_id
|
|
697
|
+
FROM dcb_events
|
|
698
|
+
WHERE "ServiceId" = ? AND "SortableUniqueId" COLLATE BINARY = ? COLLATE BINARY
|
|
699
|
+
ORDER BY "Id" COLLATE BINARY ASC
|
|
700
|
+
LIMIT 2
|
|
701
|
+
)
|
|
702
|
+
SELECT
|
|
703
|
+
(SELECT COUNT(*) FROM target) AS target_count,
|
|
704
|
+
(SELECT event_id FROM target ORDER BY event_id COLLATE BINARY ASC LIMIT 1) AS event_id,
|
|
705
|
+
CASE WHEN EXISTS (
|
|
706
|
+
SELECT 1
|
|
707
|
+
FROM serialized_dcb_wait_target_incidents incident
|
|
708
|
+
WHERE incident.service_id = ? AND incident.suid COLLATE BINARY = ? COLLATE BINARY
|
|
709
|
+
AND incident.classification IN ('SUID_COLLISION', 'LINEAGE_MISMATCH')
|
|
710
|
+
) OR EXISTS (
|
|
711
|
+
SELECT 1
|
|
712
|
+
FROM serialized_dcb_delivery_incidents incident
|
|
713
|
+
WHERE incident.service_id = ? AND incident.suid COLLATE BINARY = ? COLLATE BINARY
|
|
714
|
+
AND incident.classification = 'SUID_COLLISION'
|
|
715
|
+
) THEN 1 ELSE 0 END AS target_incident`).bind(serviceId, suid, serviceId, suid, serviceId, suid).first();
|
|
716
|
+
if (row === null || row === undefined)
|
|
717
|
+
throw new Error("D1 wait target lookup returned no row");
|
|
718
|
+
if (asNumber(row.target_incident, "target_incident") === 1) {
|
|
719
|
+
return { kind: "unavailable", reason: "incident" };
|
|
720
|
+
}
|
|
721
|
+
const count = asNumber(row.target_count, "target_count");
|
|
722
|
+
if (count === 0)
|
|
723
|
+
return { kind: "pending" };
|
|
724
|
+
if (count !== 1 || typeof row.event_id !== "string" || row.event_id.length === 0) {
|
|
725
|
+
return { kind: "unavailable", reason: "suid-contradiction" };
|
|
726
|
+
}
|
|
727
|
+
return { kind: "stored", eventId: row.event_id, suid };
|
|
728
|
+
}
|
|
729
|
+
async currentLagBound(serviceId, nowMs) {
|
|
730
|
+
this.ready();
|
|
731
|
+
const rows = await this.rows(`SELECT estimate_ms, observed_at FROM serialized_dcb_lag_estimates WHERE service_id = ?`, serviceId);
|
|
732
|
+
const row = rows[0];
|
|
733
|
+
if (row === undefined)
|
|
734
|
+
return 0;
|
|
735
|
+
const estimate = asNumber(row.estimate_ms, "estimate_ms");
|
|
736
|
+
const observedAt = asNumber(row.observed_at, "observed_at");
|
|
737
|
+
const decayNow = nowMs !== undefined && nowMs >= 100_000_000_000 && observedAt >= 100_000_000_000
|
|
738
|
+
? nowMs
|
|
739
|
+
: observedAt;
|
|
740
|
+
return decayedLagEstimateMs(estimate, observedAt, decayNow);
|
|
741
|
+
}
|
|
742
|
+
async lagBoundDiagnostics(serviceId, nowMs) {
|
|
743
|
+
this.ready();
|
|
744
|
+
const rows = await this.rows(`SELECT estimate_ms, observed_at FROM serialized_dcb_lag_estimates WHERE service_id = ?`, serviceId);
|
|
745
|
+
const row = rows[0];
|
|
746
|
+
if (row === undefined) {
|
|
747
|
+
return { serviceIdUsed: serviceId, dynamicLagBoundMs: 0, rowFound: false, rawEstimateMs: null, rawObservedAt: null, nowMs };
|
|
748
|
+
}
|
|
749
|
+
const rawEstimateMs = asNumber(row.estimate_ms, "estimate_ms");
|
|
750
|
+
const rawObservedAt = asNumber(row.observed_at, "observed_at");
|
|
751
|
+
const decayNow = nowMs >= 100_000_000_000 && rawObservedAt >= 100_000_000_000 ? nowMs : rawObservedAt;
|
|
752
|
+
return {
|
|
753
|
+
serviceIdUsed: serviceId,
|
|
754
|
+
dynamicLagBoundMs: decayedLagEstimateMs(rawEstimateMs, rawObservedAt, decayNow),
|
|
755
|
+
rowFound: true,
|
|
756
|
+
rawEstimateMs,
|
|
757
|
+
rawObservedAt,
|
|
758
|
+
nowMs,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
async listProjectionTags(serviceId) {
|
|
762
|
+
this.ready();
|
|
763
|
+
const rows = await this.rows(`SELECT DISTINCT membership.value AS tag
|
|
764
|
+
FROM dcb_events event, json_each(event."Tags") membership
|
|
765
|
+
WHERE event."ServiceId" = ?
|
|
766
|
+
ORDER BY tag COLLATE BINARY ASC`, serviceId);
|
|
767
|
+
return rows.map((row) => asString(row.tag, "tag"));
|
|
768
|
+
}
|
|
769
|
+
async readProjectionCheckpoint(serviceId, projectionId) {
|
|
770
|
+
this.ready();
|
|
771
|
+
const rows = await this.rows(`SELECT service_id, projection_id, last_suid, state_json, version, updated_at
|
|
772
|
+
FROM serialized_dcb_projection_checkpoints
|
|
773
|
+
WHERE service_id = ? AND projection_id = ?`, serviceId, projectionId);
|
|
774
|
+
return rows[0] === undefined ? undefined : checkpointFrom(rows[0]);
|
|
775
|
+
}
|
|
776
|
+
async advanceProjectionCheckpoint(input) {
|
|
777
|
+
this.ready();
|
|
778
|
+
const statement = this.database.prepare(`INSERT INTO serialized_dcb_projection_checkpoints
|
|
779
|
+
(service_id, projection_id, last_suid, state_json, version, updated_at)
|
|
780
|
+
SELECT ?, ?, ?, ?, ?, ?
|
|
781
|
+
WHERE ? IS NULL OR EXISTS (
|
|
782
|
+
SELECT 1 FROM serialized_dcb_projection_checkpoints current
|
|
783
|
+
WHERE current.service_id = ? AND current.projection_id = ?
|
|
784
|
+
AND current.last_suid COLLATE BINARY = ? COLLATE BINARY
|
|
785
|
+
)
|
|
786
|
+
ON CONFLICT (service_id, projection_id) DO UPDATE
|
|
787
|
+
SET last_suid = excluded.last_suid,
|
|
788
|
+
state_json = excluded.state_json,
|
|
789
|
+
version = excluded.version,
|
|
790
|
+
updated_at = excluded.updated_at
|
|
791
|
+
WHERE serialized_dcb_projection_checkpoints.last_suid COLLATE BINARY = ? COLLATE BINARY`).bind(input.serviceId, input.projectionId, input.lastSuid, input.stateJson, input.version, input.updatedAt, input.expectedLastSuid, input.serviceId, input.projectionId, input.expectedLastSuid, input.expectedLastSuid);
|
|
792
|
+
const result = await this.write("advanceProjectionCheckpoint", statement).run();
|
|
793
|
+
// D1's meta.changes is the CAS oracle; no read-then-write race exists.
|
|
794
|
+
return result.meta.changes === 1;
|
|
795
|
+
}
|
|
796
|
+
async projectionLag(serviceId, projectionId, tag) {
|
|
797
|
+
this.ready();
|
|
798
|
+
const checkpoint = await this.readProjectionCheckpoint(serviceId, projectionId);
|
|
799
|
+
const checkpointSuid = checkpoint?.lastSuid ?? "";
|
|
800
|
+
const rows = await this.rows(`SELECT
|
|
801
|
+
COALESCE((SELECT event."SortableUniqueId"
|
|
802
|
+
FROM dcb_events event, json_each(event."Tags") membership
|
|
803
|
+
WHERE event."ServiceId" = ? AND membership.value = ?
|
|
804
|
+
ORDER BY event."SortableUniqueId" COLLATE BINARY DESC LIMIT 1), '') AS head_suid,
|
|
805
|
+
(SELECT COUNT(*)
|
|
806
|
+
FROM dcb_events event, json_each(event."Tags") membership
|
|
807
|
+
WHERE event."ServiceId" = ? AND membership.value = ?
|
|
808
|
+
AND event."SortableUniqueId" COLLATE BINARY > ? COLLATE BINARY) AS behind_events`, serviceId, tag, serviceId, tag, checkpointSuid);
|
|
809
|
+
const row = rows[0] ?? {};
|
|
810
|
+
return {
|
|
811
|
+
serviceId,
|
|
812
|
+
projectionId,
|
|
813
|
+
tag,
|
|
814
|
+
checkpointSuid,
|
|
815
|
+
headSuid: asString(row.head_suid ?? "", "head_suid"),
|
|
816
|
+
behindEvents: asNumber(row.behind_events ?? 0, "behind_events"),
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
async upsertPending(message, firstObservedAt, lagBoundMs) {
|
|
820
|
+
this.ready();
|
|
821
|
+
const expectedJson = jsonArray(message.eventTags);
|
|
822
|
+
const observedJson = jsonArray([message.tag]);
|
|
823
|
+
const statements = [
|
|
824
|
+
this.database.prepare(`INSERT INTO serialized_dcb_pending_arrivals
|
|
825
|
+
(service_id, event_id, attempt_id, suid, expected_paths, observed_paths, first_observed_at, lag_bound_ms)
|
|
826
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
827
|
+
ON CONFLICT (service_id, event_id) DO NOTHING`).bind(message.serviceId, message.eventId, message.attemptId, message.suid, expectedJson, observedJson, firstObservedAt, lagBoundMs),
|
|
828
|
+
// JSON1 performs the set union inside the atomic UPDATE. There is no
|
|
829
|
+
// TypeScript read/merge/write window under concurrent deliveries.
|
|
830
|
+
this.database.prepare(`UPDATE serialized_dcb_pending_arrivals AS pending
|
|
831
|
+
SET expected_paths = COALESCE((
|
|
832
|
+
SELECT json_group_array(value) FROM (
|
|
833
|
+
SELECT value FROM json_each(pending.expected_paths)
|
|
834
|
+
UNION
|
|
835
|
+
SELECT value FROM json_each(?)
|
|
836
|
+
ORDER BY value COLLATE BINARY
|
|
837
|
+
)
|
|
838
|
+
), '[]'),
|
|
839
|
+
observed_paths = COALESCE((
|
|
840
|
+
SELECT json_group_array(value) FROM (
|
|
841
|
+
SELECT value FROM json_each(pending.observed_paths)
|
|
842
|
+
UNION
|
|
843
|
+
SELECT value FROM json_each(?)
|
|
844
|
+
ORDER BY value COLLATE BINARY
|
|
845
|
+
)
|
|
846
|
+
), '[]'),
|
|
847
|
+
lag_bound_ms = MAX(lag_bound_ms, ?)
|
|
848
|
+
WHERE service_id = ? AND event_id = ? AND attempt_id = ?
|
|
849
|
+
AND suid COLLATE BINARY = ? COLLATE BINARY`).bind(expectedJson, observedJson, lagBoundMs, message.serviceId, message.eventId, message.attemptId, message.suid),
|
|
850
|
+
];
|
|
851
|
+
await this.batch("upsertPending", statements);
|
|
852
|
+
const pending = await this.pendingById(message.serviceId, message.eventId);
|
|
853
|
+
if (pending === undefined)
|
|
854
|
+
throw new Error("D1 pending arrival was not persisted");
|
|
855
|
+
if (pending.attemptId !== message.attemptId || pending.suid !== message.suid) {
|
|
856
|
+
throw new D1IdentityConflictError(`EventId ${message.eventId} has contradictory pending-arrival identity`);
|
|
857
|
+
}
|
|
858
|
+
return pending;
|
|
859
|
+
}
|
|
860
|
+
async listPending(serviceId) {
|
|
861
|
+
this.ready();
|
|
862
|
+
const rows = serviceId === undefined
|
|
863
|
+
? await this.rows(`SELECT service_id, event_id, attempt_id, suid, expected_paths, observed_paths, first_observed_at, lag_bound_ms
|
|
864
|
+
FROM serialized_dcb_pending_arrivals
|
|
865
|
+
ORDER BY service_id COLLATE BINARY ASC, event_id COLLATE BINARY ASC`)
|
|
866
|
+
: await this.rows(`SELECT service_id, event_id, attempt_id, suid, expected_paths, observed_paths, first_observed_at, lag_bound_ms
|
|
867
|
+
FROM serialized_dcb_pending_arrivals
|
|
868
|
+
WHERE service_id = ?
|
|
869
|
+
ORDER BY event_id COLLATE BINARY ASC`, serviceId);
|
|
870
|
+
return rows.map(pendingFrom);
|
|
871
|
+
}
|
|
872
|
+
async appendFinding(finding) {
|
|
873
|
+
this.ready();
|
|
874
|
+
const statement = this.database.prepare(`INSERT INTO serialized_dcb_inconsistency_findings
|
|
875
|
+
(service_id, event_id, path, classification, first_observed_at, lag_bound_ms, observed_at)
|
|
876
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
877
|
+
ON CONFLICT (service_id, event_id, path, classification) DO NOTHING`).bind(finding.serviceId, finding.eventId, finding.path, finding.classification, finding.firstObservedAt, finding.lagBoundMs, finding.observedAt);
|
|
878
|
+
await this.write("appendFinding", statement).run();
|
|
879
|
+
}
|
|
880
|
+
async hasFinding(serviceId, eventId, path, classification) {
|
|
881
|
+
this.ready();
|
|
882
|
+
const rows = await this.rows(`SELECT 1 AS present FROM serialized_dcb_inconsistency_findings
|
|
883
|
+
WHERE service_id = ? AND event_id = ? AND path = ? AND classification = ?`, serviceId, eventId, path, classification);
|
|
884
|
+
return rows.length !== 0;
|
|
885
|
+
}
|
|
886
|
+
async listFindings(serviceId, eventId) {
|
|
887
|
+
this.ready();
|
|
888
|
+
const conditions = [];
|
|
889
|
+
const values = [];
|
|
890
|
+
if (serviceId !== undefined) {
|
|
891
|
+
conditions.push("service_id = ?");
|
|
892
|
+
values.push(serviceId);
|
|
893
|
+
}
|
|
894
|
+
if (eventId !== undefined) {
|
|
895
|
+
conditions.push("event_id = ?");
|
|
896
|
+
values.push(eventId);
|
|
897
|
+
}
|
|
898
|
+
const rows = await this.rows(`SELECT service_id, event_id, path, classification, first_observed_at, lag_bound_ms, observed_at
|
|
899
|
+
FROM serialized_dcb_inconsistency_findings
|
|
900
|
+
${conditions.length === 0 ? "" : `WHERE ${conditions.join(" AND ")}`}
|
|
901
|
+
ORDER BY sequence ASC`, ...values);
|
|
902
|
+
return rows.map(findingFrom);
|
|
903
|
+
}
|
|
904
|
+
async appendDeliveryIncident(incident) {
|
|
905
|
+
this.ready();
|
|
906
|
+
const statement = this.database.prepare(`INSERT INTO serialized_dcb_delivery_incidents
|
|
907
|
+
(service_id, identity_key, classification, suid, existing_event_id, incoming_event_id, event_id,
|
|
908
|
+
bound_lineage_id, incoming_lineage_id, observed_at)
|
|
909
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
910
|
+
ON CONFLICT (service_id, identity_key) DO NOTHING`).bind(incident.serviceId, incident.identityKey, incident.classification, incident.suid ?? null, incident.existingEventId ?? null, incident.incomingEventId ?? null, incident.eventId ?? null, incident.boundLineageId ?? null, incident.incomingLineageId ?? null, incident.observedAt);
|
|
911
|
+
await this.write("appendDeliveryIncident", statement).run();
|
|
912
|
+
}
|
|
913
|
+
async hasDeliveryIncident(serviceId, identityKey) {
|
|
914
|
+
this.ready();
|
|
915
|
+
return (await this.incidentByIdentity(serviceId, identityKey)) !== undefined;
|
|
916
|
+
}
|
|
917
|
+
async listDeliveryIncidents(serviceId) {
|
|
918
|
+
this.ready();
|
|
919
|
+
const rows = serviceId === undefined
|
|
920
|
+
? await this.rows(`SELECT service_id, identity_key, classification, suid, existing_event_id, incoming_event_id, event_id,
|
|
921
|
+
bound_lineage_id, incoming_lineage_id, observed_at
|
|
922
|
+
FROM serialized_dcb_delivery_incidents
|
|
923
|
+
ORDER BY sequence ASC`)
|
|
924
|
+
: await this.rows(`SELECT service_id, identity_key, classification, suid, existing_event_id, incoming_event_id, event_id,
|
|
925
|
+
bound_lineage_id, incoming_lineage_id, observed_at
|
|
926
|
+
FROM serialized_dcb_delivery_incidents
|
|
927
|
+
WHERE service_id = ?
|
|
928
|
+
ORDER BY sequence ASC`, serviceId);
|
|
929
|
+
return rows.map(incidentFrom);
|
|
930
|
+
}
|
|
931
|
+
async batch(operation, statements) {
|
|
932
|
+
const transformed = await this.options.beforeBatch?.(operation, statements, this.database) ?? statements;
|
|
933
|
+
await this.database.batch([...transformed]);
|
|
934
|
+
}
|
|
935
|
+
write(operation, statement) {
|
|
936
|
+
return this.options.beforeWrite?.(operation, statement, this.database) ?? statement;
|
|
937
|
+
}
|
|
938
|
+
async rows(statement, ...values) {
|
|
939
|
+
const result = await this.database.prepare(statement).bind(...values).all();
|
|
940
|
+
return result.results;
|
|
941
|
+
}
|
|
942
|
+
async eventById(serviceId, eventId) {
|
|
943
|
+
const rows = await this.rows(`SELECT e."ServiceId" AS service_id, e."Id" AS event_id,
|
|
944
|
+
e."SortableUniqueId" AS suid, e."Payload" AS payload,
|
|
945
|
+
e."EventType" AS event_type, e."Tags" AS event_tags,
|
|
946
|
+
e."EventDigest" AS event_digest,
|
|
947
|
+
e."Timestamp" AS timestamp, e."CausationId" AS causation_id,
|
|
948
|
+
e."CorrelationId" AS correlation_id, e."ExecutedUser" AS executed_user,
|
|
949
|
+
COALESCE(o."FirstArrivedAt", 0) AS first_arrived_at,
|
|
950
|
+
COALESCE(o."LastArrivedAt", 0) AS last_arrived_at,
|
|
951
|
+
COALESCE(o."MaxDeliveryLagMs", 0) AS max_delivery_lag_ms
|
|
952
|
+
FROM dcb_events e LEFT JOIN dcb_event_ops o
|
|
953
|
+
ON o."ServiceId" = e."ServiceId" AND o."Id" = e."Id"
|
|
954
|
+
WHERE e."ServiceId" = ? AND e."Id" = ?`, serviceId, eventId);
|
|
955
|
+
return rows[0] === undefined ? undefined : this.eventFromRow(rows[0]);
|
|
956
|
+
}
|
|
957
|
+
async eventFromRow(row) {
|
|
958
|
+
const arrivals = await this.rows(`SELECT service_id, event_id, tag, enqueued_at, arrived_at, lag_ms
|
|
959
|
+
FROM serialized_dcb_event_arrivals
|
|
960
|
+
WHERE service_id = ? AND event_id = ?
|
|
961
|
+
ORDER BY tag COLLATE BINARY ASC`, asString(row.service_id, "service_id"), asString(row.event_id, "event_id"));
|
|
962
|
+
return eventFrom(row, arrivals);
|
|
963
|
+
}
|
|
964
|
+
async pendingById(serviceId, eventId) {
|
|
965
|
+
const rows = await this.rows(`SELECT service_id, event_id, attempt_id, suid, expected_paths, observed_paths, first_observed_at, lag_bound_ms
|
|
966
|
+
FROM serialized_dcb_pending_arrivals WHERE service_id = ? AND event_id = ?`, serviceId, eventId);
|
|
967
|
+
return rows[0] === undefined ? undefined : pendingFrom(rows[0]);
|
|
968
|
+
}
|
|
969
|
+
async lineageBinding(serviceId) {
|
|
970
|
+
const rows = await this.rows(`SELECT allocator_lineage_id FROM serialized_dcb_allocator_bindings WHERE service_id = ?`, serviceId);
|
|
971
|
+
return rows[0] === undefined ? undefined : asString(rows[0].allocator_lineage_id, "allocator_lineage_id");
|
|
972
|
+
}
|
|
973
|
+
async collisionEvent(serviceId, suid, eventId) {
|
|
974
|
+
const rows = await this.rows(`SELECT "Id" AS event_id FROM dcb_events
|
|
975
|
+
WHERE "ServiceId" = ? AND "SortableUniqueId" COLLATE BINARY = ? COLLATE BINARY AND "Id" <> ?
|
|
976
|
+
ORDER BY "Id" COLLATE BINARY ASC LIMIT 1`, serviceId, suid, eventId);
|
|
977
|
+
return rows[0] === undefined ? undefined : asString(rows[0].event_id, "event_id");
|
|
978
|
+
}
|
|
979
|
+
async incidentByIdentity(serviceId, identityKey) {
|
|
980
|
+
const rows = await this.rows(`SELECT service_id, identity_key, classification, suid, existing_event_id, incoming_event_id, event_id,
|
|
981
|
+
bound_lineage_id, incoming_lineage_id, observed_at
|
|
982
|
+
FROM serialized_dcb_delivery_incidents
|
|
983
|
+
WHERE service_id = ? AND identity_key = ?`, serviceId, identityKey);
|
|
984
|
+
return rows[0] === undefined ? undefined : incidentFrom(rows[0]);
|
|
985
|
+
}
|
|
986
|
+
async eventArrivalOps(serviceId, eventId) {
|
|
987
|
+
const row = await this.database.prepare(`SELECT "FirstArrivedAt" AS first_arrived_at, "LastArrivedAt" AS last_arrived_at
|
|
988
|
+
FROM dcb_event_ops
|
|
989
|
+
WHERE "ServiceId" = ? AND "Id" = ?`).bind(serviceId, eventId).first();
|
|
990
|
+
if (row === null || row === undefined)
|
|
991
|
+
return undefined;
|
|
992
|
+
return {
|
|
993
|
+
firstArrivedAt: asNumber(row.first_arrived_at, "event_ops.first_arrived_at"),
|
|
994
|
+
lastArrivedAt: asNumber(row.last_arrived_at, "event_ops.last_arrived_at"),
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
async admissionMutationEvidence(serviceId, eventId, mutationAttemptId) {
|
|
998
|
+
const row = await this.database.prepare(`SELECT "FirstAdmissionAttemptId" AS first_admission_attempt_id,
|
|
999
|
+
"LastAdmissionAttemptId" AS last_admission_attempt_id
|
|
1000
|
+
FROM dcb_event_ops
|
|
1001
|
+
WHERE "ServiceId" = ? AND "Id" = ?`).bind(serviceId, eventId).first();
|
|
1002
|
+
const first = row?.first_admission_attempt_id === null || row?.first_admission_attempt_id === undefined
|
|
1003
|
+
? undefined
|
|
1004
|
+
: asString(row.first_admission_attempt_id, "event_ops.first_admission_attempt_id");
|
|
1005
|
+
const last = row?.last_admission_attempt_id === null || row?.last_admission_attempt_id === undefined
|
|
1006
|
+
? undefined
|
|
1007
|
+
: asString(row.last_admission_attempt_id, "event_ops.last_admission_attempt_id");
|
|
1008
|
+
if (first === mutationAttemptId)
|
|
1009
|
+
return "first-admission";
|
|
1010
|
+
if (last === mutationAttemptId)
|
|
1011
|
+
return "duplicate-admission";
|
|
1012
|
+
return "unverified";
|
|
1013
|
+
}
|
|
1014
|
+
async bestEffortG69AdmissionAttempt(message, arrivedAt, deliverySource, attemptContext, mutationAttemptId, result, failure, retryReason) {
|
|
1015
|
+
const diagnosticStartedAt = Date.now();
|
|
1016
|
+
const receiptCostMs = () => Math.max(0, Date.now() - diagnosticStartedAt);
|
|
1017
|
+
const appendFailedDiagnostic = async (reason) => {
|
|
1018
|
+
try {
|
|
1019
|
+
await appendG69AdmissionAttempt(this.database, {
|
|
1020
|
+
serviceId: message.serviceId,
|
|
1021
|
+
eventId: message.eventId,
|
|
1022
|
+
suid: message.suid,
|
|
1023
|
+
tag: message.tag,
|
|
1024
|
+
deliverySource,
|
|
1025
|
+
queueMessageId: attemptContext?.queueMessageId ?? null,
|
|
1026
|
+
attemptId: message.attemptId,
|
|
1027
|
+
allocatorLineageId: message.allocatorLineageId,
|
|
1028
|
+
obligationSequence: message.completeness.obligationSequence,
|
|
1029
|
+
enqueuedAt: message.enqueuedAt,
|
|
1030
|
+
observedAt: Date.now(),
|
|
1031
|
+
arrivedAt,
|
|
1032
|
+
firstArrivedAtBefore: null,
|
|
1033
|
+
lastArrivedAtBefore: null,
|
|
1034
|
+
firstArrivedAtAfter: null,
|
|
1035
|
+
lastArrivedAtAfter: null,
|
|
1036
|
+
beforeObservedAt: null,
|
|
1037
|
+
afterObservedAt: null,
|
|
1038
|
+
observationConsistency: "unverified",
|
|
1039
|
+
mutationEvidence: "unverified",
|
|
1040
|
+
diagnosticDurationMs: receiptCostMs(),
|
|
1041
|
+
status: "failed",
|
|
1042
|
+
retryReason: reason,
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
catch {
|
|
1046
|
+
// The diagnostic table remains best effort even when its own failure
|
|
1047
|
+
// prevents a failed-observation row from being retained.
|
|
1048
|
+
}
|
|
1049
|
+
};
|
|
1050
|
+
try {
|
|
1051
|
+
try {
|
|
1052
|
+
await this.options.beforeG69AdmissionAttempt?.();
|
|
1053
|
+
}
|
|
1054
|
+
catch (error) {
|
|
1055
|
+
await appendFailedDiagnostic(errorText(error));
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
const afterObservedAt = Date.now();
|
|
1059
|
+
const afterResult = this.eventArrivalOps(message.serviceId, message.eventId)
|
|
1060
|
+
.then((after) => ({ after, failed: false }))
|
|
1061
|
+
.catch(() => ({ after: undefined, failed: true }));
|
|
1062
|
+
const mutationResult = this.admissionMutationEvidence(message.serviceId, message.eventId, mutationAttemptId)
|
|
1063
|
+
.then((evidence) => ({ evidence, failed: false }))
|
|
1064
|
+
.catch(() => ({ evidence: "unverified", failed: true }));
|
|
1065
|
+
const [{ after, failed: afterReadFailed }, { evidence: observedMutationEvidence }] = await Promise.all([afterResult, mutationResult]);
|
|
1066
|
+
const status = failure !== undefined
|
|
1067
|
+
? "failed"
|
|
1068
|
+
: result?.outcome === "stored"
|
|
1069
|
+
? observedMutationEvidence === "duplicate-admission" ? "duplicate" : "stored"
|
|
1070
|
+
: result?.outcome ?? "failed";
|
|
1071
|
+
const finalObservationConsistency = afterReadFailed
|
|
1072
|
+
? "after-read-failed"
|
|
1073
|
+
: after === undefined
|
|
1074
|
+
? "unverified"
|
|
1075
|
+
: "after-admission";
|
|
1076
|
+
await appendG69AdmissionAttempt(this.database, {
|
|
1077
|
+
serviceId: message.serviceId,
|
|
1078
|
+
eventId: message.eventId,
|
|
1079
|
+
suid: message.suid,
|
|
1080
|
+
tag: message.tag,
|
|
1081
|
+
deliverySource,
|
|
1082
|
+
queueMessageId: attemptContext?.queueMessageId ?? null,
|
|
1083
|
+
attemptId: message.attemptId,
|
|
1084
|
+
allocatorLineageId: message.allocatorLineageId,
|
|
1085
|
+
obligationSequence: message.completeness.obligationSequence,
|
|
1086
|
+
enqueuedAt: message.enqueuedAt,
|
|
1087
|
+
observedAt: afterObservedAt,
|
|
1088
|
+
arrivedAt,
|
|
1089
|
+
firstArrivedAtBefore: null,
|
|
1090
|
+
lastArrivedAtBefore: null,
|
|
1091
|
+
firstArrivedAtAfter: after?.firstArrivedAt ?? null,
|
|
1092
|
+
lastArrivedAtAfter: after?.lastArrivedAt ?? null,
|
|
1093
|
+
beforeObservedAt: null,
|
|
1094
|
+
afterObservedAt,
|
|
1095
|
+
observationConsistency: finalObservationConsistency,
|
|
1096
|
+
mutationEvidence: observedMutationEvidence,
|
|
1097
|
+
diagnosticDurationMs: receiptCostMs(),
|
|
1098
|
+
status,
|
|
1099
|
+
retryReason,
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
catch {
|
|
1103
|
+
// Diagnostic evidence is strictly best effort. It must not alter the
|
|
1104
|
+
// durable event, Queue disposition, G44 receipt, or safe frontier.
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
ready() {
|
|
1108
|
+
if (!this.initialized)
|
|
1109
|
+
throw new Error("D1EventStore.initialize() must complete before use");
|
|
1110
|
+
}
|
|
1111
|
+
}
|