@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,950 @@
|
|
|
1
|
+
import { resolveDeliveryIdentity } from "../eventIdentity";
|
|
2
|
+
import { assertSortableUniqueId } from "../allocator/SortableUniqueId";
|
|
3
|
+
import { CANONICAL_UTC_TIMESTAMP_PATTERN, isRfc4122Uuid, isUuidV7, serializedEventMetadata } from "../eventRecord";
|
|
4
|
+
import { decayedLagEstimateMs } from "../safeWindow";
|
|
5
|
+
import { CanonicalEventIdentityConflictError, } from "./types";
|
|
6
|
+
/** Sekiban.Dcb CosmosEvent's immutable logical-record partition. */
|
|
7
|
+
export const COSMOS_EVENT_PARTITION_KEY_PATH = "/pk";
|
|
8
|
+
/** TS-only auxiliary documents retain a service-local partition. */
|
|
9
|
+
export const COSMOS_AUXILIARY_PARTITION_KEY_PATH = "/serviceId";
|
|
10
|
+
/** @deprecated Kept as an alias for auxiliary-container callers. */
|
|
11
|
+
export const COSMOS_PARTITION_KEY_PATH = COSMOS_AUXILIARY_PARTITION_KEY_PATH;
|
|
12
|
+
export const DEFAULT_COSMOS_CONTAINERS = Object.freeze({
|
|
13
|
+
events: "dcb-events",
|
|
14
|
+
lagEstimates: "dcb-lag-estimates",
|
|
15
|
+
pendingArrivals: "dcb-pending-arrivals",
|
|
16
|
+
findings: "dcb-findings",
|
|
17
|
+
checkpoints: "dcb-projection-checkpoints",
|
|
18
|
+
});
|
|
19
|
+
/**
|
|
20
|
+
* The layout is data rather than a comment so every adapter initialization and
|
|
21
|
+
* contract fixture can inspect the same five service-partitioned containers.
|
|
22
|
+
*/
|
|
23
|
+
export function cosmosContainerDefinitions(containers = DEFAULT_COSMOS_CONTAINERS) {
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
name: containers.events,
|
|
27
|
+
partitionKeyPath: COSMOS_EVENT_PARTITION_KEY_PATH,
|
|
28
|
+
// The C# CosmosEvent is partitioned by the exact serviceId|id pair.
|
|
29
|
+
// Refuse a missing id here so an introspection fixture cannot mistake
|
|
30
|
+
// an auxiliary service partition for the logical event partition.
|
|
31
|
+
partitionKeyValue: (serviceId, id) => {
|
|
32
|
+
if (typeof id !== "string" || id.length === 0)
|
|
33
|
+
throw new Error("Cosmos event partition key requires an event id");
|
|
34
|
+
return eventPk(serviceId, id);
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
...[containers.lagEstimates, containers.pendingArrivals, containers.findings, containers.checkpoints].map((name) => ({
|
|
38
|
+
name,
|
|
39
|
+
partitionKeyPath: COSMOS_AUXILIARY_PARTITION_KEY_PATH,
|
|
40
|
+
partitionKeyValue: (serviceId) => serviceId,
|
|
41
|
+
})),
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
export class CosmosClientError extends Error {
|
|
45
|
+
status;
|
|
46
|
+
constructor(status, message) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.status = status;
|
|
49
|
+
this.name = "CosmosClientError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function asString(value, name) {
|
|
53
|
+
if (typeof value !== "string")
|
|
54
|
+
throw new Error(`Cosmos ${name} was not a string`);
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
function nullableString(value, name) {
|
|
58
|
+
return value === null ? null : asString(value, name);
|
|
59
|
+
}
|
|
60
|
+
function asNumber(value, name) {
|
|
61
|
+
const number = typeof value === "number" ? value : Number(value);
|
|
62
|
+
if (!Number.isSafeInteger(number))
|
|
63
|
+
throw new Error(`Cosmos ${name} was not a safe integer`);
|
|
64
|
+
return number;
|
|
65
|
+
}
|
|
66
|
+
function asStringArray(value, name) {
|
|
67
|
+
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) {
|
|
68
|
+
throw new Error(`Cosmos ${name} was not a string array`);
|
|
69
|
+
}
|
|
70
|
+
return [...value];
|
|
71
|
+
}
|
|
72
|
+
function sortedUnique(values) {
|
|
73
|
+
return [...new Set(values)].sort(binaryCompare);
|
|
74
|
+
}
|
|
75
|
+
function binaryCompare(left, right) {
|
|
76
|
+
const leftBytes = new TextEncoder().encode(left);
|
|
77
|
+
const rightBytes = new TextEncoder().encode(right);
|
|
78
|
+
const shared = Math.min(leftBytes.length, rightBytes.length);
|
|
79
|
+
for (let index = 0; index < shared; index += 1) {
|
|
80
|
+
const difference = leftBytes[index] - rightBytes[index];
|
|
81
|
+
if (difference !== 0)
|
|
82
|
+
return difference;
|
|
83
|
+
}
|
|
84
|
+
return leftBytes.length - rightBytes.length;
|
|
85
|
+
}
|
|
86
|
+
function assertUtcTimestamp(value, eventId) {
|
|
87
|
+
if (!CANONICAL_UTC_TIMESTAMP_PATTERN.test(value) || !Number.isFinite(Date.parse(value))) {
|
|
88
|
+
throw new CanonicalEventIdentityConflictError("cosmos", eventId, "Cosmos Timestamp must be canonical UTC ISO-8601");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** V1 SUIDs are opaque byte strings; never use locale or numeric ordering. */
|
|
92
|
+
export function compareCosmosSuid(left, right) {
|
|
93
|
+
const leftBytes = new TextEncoder().encode(left);
|
|
94
|
+
const rightBytes = new TextEncoder().encode(right);
|
|
95
|
+
const shared = Math.min(leftBytes.length, rightBytes.length);
|
|
96
|
+
for (let index = 0; index < shared; index += 1) {
|
|
97
|
+
const difference = leftBytes[index] - rightBytes[index];
|
|
98
|
+
if (difference !== 0)
|
|
99
|
+
return difference;
|
|
100
|
+
}
|
|
101
|
+
return leftBytes.length - rightBytes.length;
|
|
102
|
+
}
|
|
103
|
+
function safeId(...parts) {
|
|
104
|
+
return parts.map((part) => encodeURIComponent(part)).join("~");
|
|
105
|
+
}
|
|
106
|
+
/** The exact CosmosEvent partition-key derivation from Sekiban.Dcb. */
|
|
107
|
+
function eventPk(serviceId, id) {
|
|
108
|
+
return `${serviceId}|${id}`;
|
|
109
|
+
}
|
|
110
|
+
function auxiliaryPk(serviceId) {
|
|
111
|
+
return eventPk(serviceId, "__dcb_event_ops__");
|
|
112
|
+
}
|
|
113
|
+
function isStatus(error, status) {
|
|
114
|
+
return error instanceof CosmosClientError && error.status === status;
|
|
115
|
+
}
|
|
116
|
+
function eventFrom(document, ops) {
|
|
117
|
+
const arrivals = Array.isArray(ops?.arrivals) ? ops.arrivals : [];
|
|
118
|
+
const id = asString(document.id, "id");
|
|
119
|
+
const tags = asStringArray(document.tags, "tags");
|
|
120
|
+
return {
|
|
121
|
+
serviceId: asString(document.serviceId, "serviceId"),
|
|
122
|
+
id,
|
|
123
|
+
eventId: id,
|
|
124
|
+
sortableUniqueId: asString(document.sortableUniqueId, "sortableUniqueId"),
|
|
125
|
+
suid: asString(document.sortableUniqueId, "sortableUniqueId"),
|
|
126
|
+
payload: asString(document.payload, "payload"),
|
|
127
|
+
tags,
|
|
128
|
+
eventTags: tags,
|
|
129
|
+
eventType: asString(document.eventType, "eventType"),
|
|
130
|
+
timestamp: asString(document.timestamp, "timestamp"),
|
|
131
|
+
causationId: nullableString(document.causationId, "causationId"),
|
|
132
|
+
correlationId: nullableString(document.correlationId, "correlationId"),
|
|
133
|
+
executedUser: nullableString(document.executedUser, "executedUser"),
|
|
134
|
+
provenance: "g32",
|
|
135
|
+
firstArrivedAt: ops === undefined ? 0 : asNumber(ops.firstArrivedAt, "firstArrivedAt"),
|
|
136
|
+
lastArrivedAt: ops === undefined ? 0 : asNumber(ops.lastArrivedAt, "lastArrivedAt"),
|
|
137
|
+
maxDeliveryLagMs: ops === undefined ? 0 : asNumber(ops.maxDeliveryLagMs, "maxDeliveryLagMs"),
|
|
138
|
+
arrivals: arrivals.map((arrival) => {
|
|
139
|
+
if (typeof arrival !== "object" || arrival === null || Array.isArray(arrival)) {
|
|
140
|
+
throw new Error("Cosmos arrival was not an object");
|
|
141
|
+
}
|
|
142
|
+
const value = arrival;
|
|
143
|
+
return {
|
|
144
|
+
serviceId: asString(value.serviceId, "arrival.serviceId"),
|
|
145
|
+
eventId: asString(value.eventId, "arrival.eventId"),
|
|
146
|
+
tag: asString(value.tag, "arrival.tag"),
|
|
147
|
+
enqueuedAt: asNumber(value.enqueuedAt, "arrival.enqueuedAt"),
|
|
148
|
+
arrivedAt: asNumber(value.arrivedAt, "arrival.arrivedAt"),
|
|
149
|
+
lagMs: asNumber(value.lagMs, "arrival.lagMs"),
|
|
150
|
+
};
|
|
151
|
+
}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function pendingFrom(document) {
|
|
155
|
+
return {
|
|
156
|
+
serviceId: asString(document.serviceId, "serviceId"),
|
|
157
|
+
attemptId: asString(document.attemptId, "attemptId"),
|
|
158
|
+
eventId: asString(document.eventId, "eventId"),
|
|
159
|
+
suid: asString(document.suid, "suid"),
|
|
160
|
+
expectedPaths: sortedUnique(asStringArray(document.expectedPaths, "expectedPaths")),
|
|
161
|
+
observedPaths: sortedUnique(asStringArray(document.observedPaths, "observedPaths")),
|
|
162
|
+
firstObservedAt: asNumber(document.firstObservedAt, "firstObservedAt"),
|
|
163
|
+
lagBoundMs: asNumber(document.lagBoundMs, "lagBoundMs"),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function findingFrom(document) {
|
|
167
|
+
const classification = asString(document.classification, "classification");
|
|
168
|
+
if (classification !== "MISSING_STABLE" && classification !== "EXCLUDED_AUDITED" && classification !== "RESOLVED_LATE") {
|
|
169
|
+
throw new Error("Cosmos classification was invalid");
|
|
170
|
+
}
|
|
171
|
+
return {
|
|
172
|
+
serviceId: asString(document.serviceId, "serviceId"),
|
|
173
|
+
eventId: asString(document.eventId, "eventId"),
|
|
174
|
+
path: asString(document.path, "path"),
|
|
175
|
+
classification,
|
|
176
|
+
firstObservedAt: asNumber(document.firstObservedAt, "firstObservedAt"),
|
|
177
|
+
lagBoundMs: asNumber(document.lagBoundMs, "lagBoundMs"),
|
|
178
|
+
observedAt: asNumber(document.observedAt, "observedAt"),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function incidentClassification(value) {
|
|
182
|
+
const classification = asString(value, "classification");
|
|
183
|
+
if (classification !== "SUID_COLLISION" && classification !== "ORDER_VIOLATION" && classification !== "LINEAGE_MISMATCH" && classification !== "ORDERING_DETECTOR_UNKNOWN") {
|
|
184
|
+
throw new Error("Cosmos delivery incident classification was invalid");
|
|
185
|
+
}
|
|
186
|
+
return classification;
|
|
187
|
+
}
|
|
188
|
+
function optionalString(value, name) {
|
|
189
|
+
if (value === null || value === undefined || value === "")
|
|
190
|
+
return undefined;
|
|
191
|
+
return asString(value, name);
|
|
192
|
+
}
|
|
193
|
+
function incidentFrom(document) {
|
|
194
|
+
return {
|
|
195
|
+
serviceId: asString(document.serviceId, "serviceId"),
|
|
196
|
+
identityKey: asString(document.identityKey, "identityKey"),
|
|
197
|
+
classification: incidentClassification(document.classification),
|
|
198
|
+
suid: optionalString(document.suid, "suid"),
|
|
199
|
+
existingEventId: optionalString(document.existingEventId, "existingEventId"),
|
|
200
|
+
incomingEventId: optionalString(document.incomingEventId, "incomingEventId"),
|
|
201
|
+
eventId: optionalString(document.eventId, "eventId"),
|
|
202
|
+
boundLineageId: optionalString(document.boundLineageId, "boundLineageId"),
|
|
203
|
+
incomingLineageId: optionalString(document.incomingLineageId, "incomingLineageId"),
|
|
204
|
+
observedAt: asNumber(document.observedAt, "observedAt"),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
function collisionIdentity(serviceId, suid, existingEventId, incomingEventId) {
|
|
208
|
+
const [first, second] = [existingEventId, incomingEventId].sort(binaryCompare);
|
|
209
|
+
return `SUID_COLLISION|${serviceId}|${suid}|${first}|${second}`;
|
|
210
|
+
}
|
|
211
|
+
function lineageIdentity(serviceId, boundLineageId, incomingLineageId) {
|
|
212
|
+
return `LINEAGE_MISMATCH|${serviceId}|${boundLineageId}|${incomingLineageId}`;
|
|
213
|
+
}
|
|
214
|
+
function metadataForDelivery(message, deliverySource) {
|
|
215
|
+
const serialized = serializedEventMetadata(message.eventId);
|
|
216
|
+
if (deliverySource !== "import") {
|
|
217
|
+
if (message.causationId !== serialized.causationId ||
|
|
218
|
+
message.correlationId !== serialized.correlationId ||
|
|
219
|
+
message.executedUser !== serialized.executedUser) {
|
|
220
|
+
throw new CanonicalEventIdentityConflictError("cosmos", message.eventId, "Cosmos metadata must use the serialized C# constants");
|
|
221
|
+
}
|
|
222
|
+
return serialized;
|
|
223
|
+
}
|
|
224
|
+
const values = [message.causationId, message.correlationId, message.executedUser];
|
|
225
|
+
if (!values.every((value) => value === null || typeof value === "string")) {
|
|
226
|
+
throw new CanonicalEventIdentityConflictError("cosmos", message.eventId, "Cosmos import metadata must be string or null");
|
|
227
|
+
}
|
|
228
|
+
const allNull = values.every((value) => value === null);
|
|
229
|
+
const allSerialized = message.causationId === serialized.causationId &&
|
|
230
|
+
message.correlationId === serialized.correlationId && message.executedUser === serialized.executedUser;
|
|
231
|
+
if (!allNull && !allSerialized) {
|
|
232
|
+
throw new CanonicalEventIdentityConflictError("cosmos", message.eventId, "Cosmos import metadata must be all null or serialized constants");
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
causationId: message.causationId,
|
|
236
|
+
correlationId: message.correlationId,
|
|
237
|
+
executedUser: message.executedUser,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function checkpointFrom(document) {
|
|
241
|
+
return {
|
|
242
|
+
serviceId: asString(document.serviceId, "serviceId"),
|
|
243
|
+
projectionId: asString(document.projectionId, "projectionId"),
|
|
244
|
+
lastSuid: asString(document.lastSuid, "lastSuid"),
|
|
245
|
+
stateJson: asString(document.stateJson, "stateJson"),
|
|
246
|
+
version: asNumber(document.version, "version"),
|
|
247
|
+
updatedAt: asNumber(document.updatedAt, "updatedAt"),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
function emulatorKeyBytes(key) {
|
|
251
|
+
const binary = atob(key);
|
|
252
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
253
|
+
}
|
|
254
|
+
function base64(value) {
|
|
255
|
+
const bytes = new Uint8Array(value);
|
|
256
|
+
let binary = "";
|
|
257
|
+
for (const byte of bytes)
|
|
258
|
+
binary += String.fromCharCode(byte);
|
|
259
|
+
return btoa(binary);
|
|
260
|
+
}
|
|
261
|
+
/** Fetch-based Cosmos SQL API client; it uses only Web APIs available in workerd. */
|
|
262
|
+
export class CosmosRestClient {
|
|
263
|
+
initialized = false;
|
|
264
|
+
endpoint;
|
|
265
|
+
key;
|
|
266
|
+
database;
|
|
267
|
+
fetcher;
|
|
268
|
+
containers;
|
|
269
|
+
constructor(options) {
|
|
270
|
+
this.endpoint = options.endpoint.endsWith("/") ? options.endpoint : `${options.endpoint}/`;
|
|
271
|
+
this.key = options.key;
|
|
272
|
+
this.database = options.database;
|
|
273
|
+
this.fetcher = options.fetcher ?? fetch;
|
|
274
|
+
this.containers = { ...DEFAULT_COSMOS_CONTAINERS, ...options.containers };
|
|
275
|
+
}
|
|
276
|
+
async initialize() {
|
|
277
|
+
if (this.initialized)
|
|
278
|
+
return;
|
|
279
|
+
await this.ensureResource("dbs", { id: this.database });
|
|
280
|
+
for (const container of cosmosContainerDefinitions(this.containers)) {
|
|
281
|
+
await this.ensureResource(`dbs/${encodeURIComponent(this.database)}/colls`, {
|
|
282
|
+
id: container.name,
|
|
283
|
+
partitionKey: { paths: [container.partitionKeyPath], kind: "Hash" },
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
this.initialized = true;
|
|
287
|
+
}
|
|
288
|
+
async read(container, id, partitionKey) {
|
|
289
|
+
const response = await this.request("GET", `dbs/${encodeURIComponent(this.database)}/colls/${encodeURIComponent(container)}/docs/${encodeURIComponent(id)}`, undefined, { "x-ms-documentdb-partitionkey": JSON.stringify([partitionKey]) });
|
|
290
|
+
if (response.status === 404)
|
|
291
|
+
return undefined;
|
|
292
|
+
if (!response.ok)
|
|
293
|
+
throw await this.errorFrom(response, "Cosmos document read failed");
|
|
294
|
+
const document = await response.json();
|
|
295
|
+
return { document, etag: response.headers.get("etag") ?? undefined };
|
|
296
|
+
}
|
|
297
|
+
async create(container, document, partitionKey) {
|
|
298
|
+
const response = await this.request("POST", `dbs/${encodeURIComponent(this.database)}/colls/${encodeURIComponent(container)}/docs`, document, { "x-ms-documentdb-partitionkey": JSON.stringify([partitionKey]) });
|
|
299
|
+
if (!response.ok)
|
|
300
|
+
throw await this.errorFrom(response, "Cosmos document create failed");
|
|
301
|
+
return { document: await response.json(), etag: response.headers.get("etag") ?? undefined };
|
|
302
|
+
}
|
|
303
|
+
async replace(container, document, partitionKey, etag) {
|
|
304
|
+
const response = await this.request("PUT", `dbs/${encodeURIComponent(this.database)}/colls/${encodeURIComponent(container)}/docs/${encodeURIComponent(String(document.id))}`, document, {
|
|
305
|
+
"x-ms-documentdb-partitionkey": JSON.stringify([partitionKey]),
|
|
306
|
+
...(etag === undefined ? {} : { "if-match": etag }),
|
|
307
|
+
});
|
|
308
|
+
if (response.status === 412)
|
|
309
|
+
return false;
|
|
310
|
+
if (!response.ok)
|
|
311
|
+
throw await this.errorFrom(response, "Cosmos document replace failed");
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
async query(container, query, parameters, partitionKey) {
|
|
315
|
+
const results = [];
|
|
316
|
+
let continuation;
|
|
317
|
+
do {
|
|
318
|
+
const response = await this.request("POST", `dbs/${encodeURIComponent(this.database)}/colls/${encodeURIComponent(container)}/docs`, { query, parameters }, {
|
|
319
|
+
"x-ms-documentdb-isquery": "true",
|
|
320
|
+
"content-type": "application/query+json",
|
|
321
|
+
"x-ms-max-item-count": "1000",
|
|
322
|
+
...(partitionKey === undefined ? {} : { "x-ms-documentdb-partitionkey": JSON.stringify([partitionKey]) }),
|
|
323
|
+
...(continuation === undefined ? {} : { "x-ms-continuation": continuation }),
|
|
324
|
+
});
|
|
325
|
+
if (!response.ok)
|
|
326
|
+
throw await this.errorFrom(response, "Cosmos query failed");
|
|
327
|
+
const body = await response.json();
|
|
328
|
+
for (const document of body.Documents ?? []) {
|
|
329
|
+
results.push({ document: document, etag: response.headers.get("etag") ?? undefined });
|
|
330
|
+
}
|
|
331
|
+
continuation = response.headers.get("x-ms-continuation") ?? undefined;
|
|
332
|
+
} while (continuation !== undefined && continuation.length > 0);
|
|
333
|
+
return results;
|
|
334
|
+
}
|
|
335
|
+
async ensureResource(path, body) {
|
|
336
|
+
const response = await this.request("POST", path, body);
|
|
337
|
+
if (response.status !== 201 && response.status !== 409) {
|
|
338
|
+
throw await this.errorFrom(response, "Cosmos resource initialization failed");
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async request(method, path, body, extraHeaders = {}) {
|
|
342
|
+
const date = new Date().toUTCString().toLowerCase();
|
|
343
|
+
const resourceType = path.includes("/docs/") || path.endsWith("/docs") ? "docs" : path.includes("/colls/") ? "colls" : "dbs";
|
|
344
|
+
// Cosmos signs the identity of the addressed resource. Collection-level
|
|
345
|
+
// create/query requests sign their parent, while item reads/replacements
|
|
346
|
+
// sign the full document link (and database/container creation signs the
|
|
347
|
+
// parent resource). Using the request URI verbatim would produce a valid
|
|
348
|
+
// HMAC over the wrong resource and is rejected by the service.
|
|
349
|
+
const normalizedPath = path.replace(/^\//, "");
|
|
350
|
+
const resourceLink = resourceType === "dbs" && method === "POST" && normalizedPath === "dbs"
|
|
351
|
+
? ""
|
|
352
|
+
: resourceType === "colls" && method === "POST" && normalizedPath.endsWith("/colls")
|
|
353
|
+
? normalizedPath.slice(0, -"/colls".length)
|
|
354
|
+
: resourceType === "docs" && normalizedPath.endsWith("/docs")
|
|
355
|
+
? normalizedPath.slice(0, -"/docs".length)
|
|
356
|
+
: normalizedPath;
|
|
357
|
+
const signaturePayload = `${method.toLowerCase()}\n${resourceType}\n${resourceLink}\n${date}\n\n`;
|
|
358
|
+
const cryptoKey = await crypto.subtle.importKey("raw", (() => {
|
|
359
|
+
const bytes = emulatorKeyBytes(this.key);
|
|
360
|
+
const buffer = new ArrayBuffer(bytes.byteLength);
|
|
361
|
+
new Uint8Array(buffer).set(bytes);
|
|
362
|
+
return buffer;
|
|
363
|
+
})(), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
364
|
+
const signature = encodeURIComponent(`type=master&ver=1.0&sig=${base64(await crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(signaturePayload)))}`);
|
|
365
|
+
const response = await this.fetcher(new URL(path, this.endpoint), {
|
|
366
|
+
method,
|
|
367
|
+
headers: {
|
|
368
|
+
accept: "application/json",
|
|
369
|
+
"content-type": "application/json",
|
|
370
|
+
"x-ms-date": date,
|
|
371
|
+
"x-ms-version": "2018-12-31",
|
|
372
|
+
authorization: signature,
|
|
373
|
+
...extraHeaders,
|
|
374
|
+
},
|
|
375
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
376
|
+
});
|
|
377
|
+
return response;
|
|
378
|
+
}
|
|
379
|
+
async errorFrom(response, prefix) {
|
|
380
|
+
// Never include response bodies: Cosmos errors can echo account or query
|
|
381
|
+
// details and must not enter Worker logs or committed evidence.
|
|
382
|
+
return new CosmosClientError(response.status, `${prefix} (HTTP ${response.status})`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
export class CosmosEventStore {
|
|
386
|
+
initialized = false;
|
|
387
|
+
client;
|
|
388
|
+
containers;
|
|
389
|
+
beforeWrite;
|
|
390
|
+
constructor(options) {
|
|
391
|
+
if (options.client !== undefined) {
|
|
392
|
+
this.client = options.client;
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
if (options.endpoint === undefined || options.key === undefined || options.database === undefined) {
|
|
396
|
+
throw new Error("Cosmos endpoint, key, and database are required for the Cosmos store");
|
|
397
|
+
}
|
|
398
|
+
this.client = new CosmosRestClient({
|
|
399
|
+
endpoint: options.endpoint,
|
|
400
|
+
key: options.key,
|
|
401
|
+
database: options.database,
|
|
402
|
+
fetcher: options.fetcher,
|
|
403
|
+
containers: options.containers,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
this.containers = { ...DEFAULT_COSMOS_CONTAINERS, ...options.containers };
|
|
407
|
+
this.beforeWrite = options.beforeWrite;
|
|
408
|
+
}
|
|
409
|
+
async initialize() {
|
|
410
|
+
if (this.initialized)
|
|
411
|
+
return;
|
|
412
|
+
await this.client.initialize();
|
|
413
|
+
this.initialized = true;
|
|
414
|
+
}
|
|
415
|
+
async recordDelivery(message, arrivedAt, deliverySource = "queue") {
|
|
416
|
+
this.ready();
|
|
417
|
+
const identity = resolveDeliveryIdentity(message, deliverySource);
|
|
418
|
+
assertSortableUniqueId(message.suid);
|
|
419
|
+
const acceptsImportedId = deliverySource === "import";
|
|
420
|
+
if (!(acceptsImportedId ? isRfc4122Uuid(message.eventId) : isUuidV7(message.eventId))) {
|
|
421
|
+
throw new CanonicalEventIdentityConflictError("cosmos", message.eventId, `Cosmos EventId must be an ${acceptsImportedId ? "RFC 4122 UUID" : "UUID v7"}`);
|
|
422
|
+
}
|
|
423
|
+
try {
|
|
424
|
+
JSON.parse(message.payload);
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
throw new CanonicalEventIdentityConflictError("cosmos", message.eventId, "Cosmos Payload must be UTF-8 JSON text");
|
|
428
|
+
}
|
|
429
|
+
assertUtcTimestamp(message.timestamp ?? new Date(arrivedAt).toISOString(), message.eventId);
|
|
430
|
+
const metadata = metadataForDelivery(message, deliverySource);
|
|
431
|
+
const lagMs = Math.max(0, arrivedAt - message.enqueuedAt);
|
|
432
|
+
const eventTags = [...message.eventTags];
|
|
433
|
+
const binding = await this.readLineageBinding(message.serviceId);
|
|
434
|
+
if (binding !== undefined && binding.document.allocatorLineageId !== message.allocatorLineageId) {
|
|
435
|
+
const incident = {
|
|
436
|
+
serviceId: message.serviceId,
|
|
437
|
+
identityKey: lineageIdentity(message.serviceId, binding.document.allocatorLineageId, message.allocatorLineageId),
|
|
438
|
+
classification: "LINEAGE_MISMATCH",
|
|
439
|
+
boundLineageId: binding.document.allocatorLineageId,
|
|
440
|
+
incomingLineageId: message.allocatorLineageId,
|
|
441
|
+
observedAt: arrivedAt,
|
|
442
|
+
};
|
|
443
|
+
await this.persistIncident(incident);
|
|
444
|
+
return { outcome: "lineage-mismatch", kind: "lineage-mismatch", incident };
|
|
445
|
+
}
|
|
446
|
+
if (binding === undefined) {
|
|
447
|
+
await this.bindLineage(message.serviceId, message.allocatorLineageId, arrivedAt);
|
|
448
|
+
const racedBinding = await this.readLineageBinding(message.serviceId);
|
|
449
|
+
if (racedBinding === undefined) {
|
|
450
|
+
throw new Error("Cosmos allocator lineage binding was not durable");
|
|
451
|
+
}
|
|
452
|
+
if (racedBinding.document.allocatorLineageId !== message.allocatorLineageId) {
|
|
453
|
+
const incident = {
|
|
454
|
+
serviceId: message.serviceId,
|
|
455
|
+
identityKey: lineageIdentity(message.serviceId, racedBinding.document.allocatorLineageId, message.allocatorLineageId),
|
|
456
|
+
classification: "LINEAGE_MISMATCH",
|
|
457
|
+
boundLineageId: racedBinding.document.allocatorLineageId,
|
|
458
|
+
incomingLineageId: message.allocatorLineageId,
|
|
459
|
+
observedAt: arrivedAt,
|
|
460
|
+
};
|
|
461
|
+
await this.persistIncident(incident);
|
|
462
|
+
return { outcome: "lineage-mismatch", kind: "lineage-mismatch", incident };
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const currentEvents = await this.eventDocuments(message.serviceId);
|
|
466
|
+
const existingBySuid = currentEvents.find((entry) => entry.document.sortableUniqueId === message.suid);
|
|
467
|
+
if (existingBySuid !== undefined && existingBySuid.document.id !== message.eventId) {
|
|
468
|
+
const incident = {
|
|
469
|
+
serviceId: message.serviceId,
|
|
470
|
+
identityKey: collisionIdentity(message.serviceId, message.suid, existingBySuid.document.id, message.eventId),
|
|
471
|
+
classification: "SUID_COLLISION",
|
|
472
|
+
suid: message.suid,
|
|
473
|
+
existingEventId: existingBySuid.document.id,
|
|
474
|
+
incomingEventId: message.eventId,
|
|
475
|
+
observedAt: arrivedAt,
|
|
476
|
+
};
|
|
477
|
+
await this.persistIncident(incident);
|
|
478
|
+
return { outcome: "suid-collision", kind: "suid-collision", incident };
|
|
479
|
+
}
|
|
480
|
+
const reservedEventId = await this.reserveSuid(message.serviceId, message.suid, message.eventId);
|
|
481
|
+
if (reservedEventId !== undefined && reservedEventId !== message.eventId) {
|
|
482
|
+
const incident = {
|
|
483
|
+
serviceId: message.serviceId,
|
|
484
|
+
identityKey: collisionIdentity(message.serviceId, message.suid, reservedEventId, message.eventId),
|
|
485
|
+
classification: "SUID_COLLISION",
|
|
486
|
+
suid: message.suid,
|
|
487
|
+
existingEventId: reservedEventId,
|
|
488
|
+
incomingEventId: message.eventId,
|
|
489
|
+
observedAt: arrivedAt,
|
|
490
|
+
};
|
|
491
|
+
await this.persistIncident(incident);
|
|
492
|
+
return { outcome: "suid-collision", kind: "suid-collision", incident };
|
|
493
|
+
}
|
|
494
|
+
const currentHead = currentEvents.reduce((head, entry) => head === undefined || compareCosmosSuid(entry.document.sortableUniqueId, head) > 0 ? entry.document.sortableUniqueId : head, undefined);
|
|
495
|
+
const recoveryBacklogSample = currentHead !== undefined && compareCosmosSuid(message.suid, currentHead) < 0;
|
|
496
|
+
const event = await this.mutateEvent(message, eventTags, identity.key, message.timestamp ?? new Date(arrivedAt).toISOString(), metadata);
|
|
497
|
+
const ops = await this.mutateEventOps(message, arrivedAt, lagMs);
|
|
498
|
+
if (!recoveryBacklogSample && deliverySource !== "fast")
|
|
499
|
+
await this.updateLag(message.serviceId, lagMs, arrivedAt);
|
|
500
|
+
return { outcome: "stored", kind: "stored", event: eventFrom(event, ops) };
|
|
501
|
+
}
|
|
502
|
+
async readAllEvents(serviceId, since) {
|
|
503
|
+
this.ready();
|
|
504
|
+
if (since.length !== 0)
|
|
505
|
+
assertSortableUniqueId(since);
|
|
506
|
+
const documents = await this.eventDocuments(serviceId);
|
|
507
|
+
const events = await Promise.all(documents.map(async (entry) => eventFrom(entry.document, await this.eventOps(serviceId, entry.document.id))));
|
|
508
|
+
return events
|
|
509
|
+
.filter((event) => compareCosmosSuid(event.suid, since) > 0)
|
|
510
|
+
.sort((left, right) => compareCosmosSuid(left.suid, right.suid) || binaryCompare(left.eventId, right.eventId));
|
|
511
|
+
}
|
|
512
|
+
async currentLagBound(serviceId, nowMs) {
|
|
513
|
+
this.ready();
|
|
514
|
+
const row = await this.client.read(this.containers.lagEstimates, serviceId, serviceId);
|
|
515
|
+
if (row === undefined)
|
|
516
|
+
return 0;
|
|
517
|
+
const estimate = asNumber(row.document.estimateMs, "estimateMs");
|
|
518
|
+
const observedAt = asNumber(row.document.observedAt, "observedAt");
|
|
519
|
+
const decayNow = nowMs !== undefined && nowMs >= 100_000_000_000 && observedAt >= 100_000_000_000
|
|
520
|
+
? nowMs
|
|
521
|
+
: observedAt;
|
|
522
|
+
return decayedLagEstimateMs(estimate, observedAt, decayNow);
|
|
523
|
+
}
|
|
524
|
+
async listProjectionTags(serviceId) {
|
|
525
|
+
this.ready();
|
|
526
|
+
const tags = new Set();
|
|
527
|
+
for (const entry of await this.eventDocuments(serviceId)) {
|
|
528
|
+
for (const tag of asStringArray(entry.document.tags, "tags"))
|
|
529
|
+
tags.add(tag);
|
|
530
|
+
}
|
|
531
|
+
return [...tags].sort(binaryCompare);
|
|
532
|
+
}
|
|
533
|
+
async readProjectionCheckpoint(serviceId, projectionId) {
|
|
534
|
+
this.ready();
|
|
535
|
+
const row = await this.client.read(this.containers.checkpoints, safeId(projectionId), serviceId);
|
|
536
|
+
return row === undefined ? undefined : checkpointFrom(row.document);
|
|
537
|
+
}
|
|
538
|
+
async advanceProjectionCheckpoint(input) {
|
|
539
|
+
this.ready();
|
|
540
|
+
const id = safeId(input.projectionId);
|
|
541
|
+
const document = {
|
|
542
|
+
id,
|
|
543
|
+
serviceId: input.serviceId,
|
|
544
|
+
projectionId: input.projectionId,
|
|
545
|
+
lastSuid: input.lastSuid,
|
|
546
|
+
stateJson: input.stateJson,
|
|
547
|
+
version: input.version,
|
|
548
|
+
updatedAt: input.updatedAt,
|
|
549
|
+
};
|
|
550
|
+
const existing = await this.client.read(this.containers.checkpoints, id, input.serviceId);
|
|
551
|
+
if (existing === undefined) {
|
|
552
|
+
if (input.expectedLastSuid !== null)
|
|
553
|
+
return false;
|
|
554
|
+
await this.beforeWrite?.("checkpoint");
|
|
555
|
+
try {
|
|
556
|
+
await this.client.create(this.containers.checkpoints, document, input.serviceId);
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
if (isStatus(error, 409))
|
|
561
|
+
return false;
|
|
562
|
+
throw error;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
if (existing.document.lastSuid !== input.expectedLastSuid)
|
|
566
|
+
return false;
|
|
567
|
+
await this.beforeWrite?.("checkpoint");
|
|
568
|
+
return this.client.replace(this.containers.checkpoints, document, input.serviceId, existing.etag);
|
|
569
|
+
}
|
|
570
|
+
async projectionLag(serviceId, projectionId, tag) {
|
|
571
|
+
this.ready();
|
|
572
|
+
const checkpoint = await this.readProjectionCheckpoint(serviceId, projectionId);
|
|
573
|
+
const checkpointSuid = checkpoint?.lastSuid ?? "";
|
|
574
|
+
const candidates = await this.eventDocuments(serviceId);
|
|
575
|
+
const matching = (await Promise.all(candidates.map(async (entry) => eventFrom(entry.document, await this.eventOps(serviceId, entry.document.id))))).filter((event) => event.eventTags.includes(tag));
|
|
576
|
+
const headSuid = matching.reduce((head, event) => head === "" || compareCosmosSuid(event.suid, head) > 0 ? event.suid : head, "");
|
|
577
|
+
return {
|
|
578
|
+
serviceId,
|
|
579
|
+
projectionId,
|
|
580
|
+
tag,
|
|
581
|
+
checkpointSuid,
|
|
582
|
+
headSuid,
|
|
583
|
+
behindEvents: matching.filter((event) => compareCosmosSuid(event.suid, checkpointSuid) > 0).length,
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
async upsertPending(message, firstObservedAt, lagBoundMs) {
|
|
587
|
+
this.ready();
|
|
588
|
+
const id = safeId(message.eventId);
|
|
589
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
590
|
+
const existing = await this.client.read(this.containers.pendingArrivals, id, message.serviceId);
|
|
591
|
+
if (existing === undefined) {
|
|
592
|
+
const document = {
|
|
593
|
+
id,
|
|
594
|
+
serviceId: message.serviceId,
|
|
595
|
+
eventId: message.eventId,
|
|
596
|
+
attemptId: message.attemptId,
|
|
597
|
+
suid: message.suid,
|
|
598
|
+
expectedPaths: sortedUnique(message.eventTags),
|
|
599
|
+
observedPaths: [message.tag],
|
|
600
|
+
firstObservedAt,
|
|
601
|
+
lagBoundMs,
|
|
602
|
+
};
|
|
603
|
+
await this.beforeWrite?.("pending");
|
|
604
|
+
try {
|
|
605
|
+
await this.client.create(this.containers.pendingArrivals, document, message.serviceId);
|
|
606
|
+
return pendingFrom(document);
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
if (isStatus(error, 409))
|
|
610
|
+
continue;
|
|
611
|
+
throw error;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
const pending = pendingFrom(existing.document);
|
|
615
|
+
if (pending.attemptId !== message.attemptId || pending.suid !== message.suid) {
|
|
616
|
+
throw new Error(`EventId ${message.eventId} has contradictory pending-arrival identity`);
|
|
617
|
+
}
|
|
618
|
+
const document = {
|
|
619
|
+
...existing.document,
|
|
620
|
+
expectedPaths: sortedUnique([...pending.expectedPaths, ...message.eventTags]),
|
|
621
|
+
observedPaths: sortedUnique([...pending.observedPaths, message.tag]),
|
|
622
|
+
lagBoundMs: Math.max(pending.lagBoundMs, lagBoundMs),
|
|
623
|
+
};
|
|
624
|
+
await this.beforeWrite?.("pending");
|
|
625
|
+
if (await this.client.replace(this.containers.pendingArrivals, document, message.serviceId, existing.etag)) {
|
|
626
|
+
return pendingFrom(document);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
throw new Error("Cosmos pending-arrival CAS did not converge");
|
|
630
|
+
}
|
|
631
|
+
async listPending(serviceId) {
|
|
632
|
+
this.ready();
|
|
633
|
+
const rows = await this.client.query(this.containers.pendingArrivals, serviceId === undefined ? "SELECT * FROM c" : "SELECT * FROM c WHERE c.serviceId = @serviceId", serviceId === undefined ? [] : [{ name: "@serviceId", value: serviceId }], serviceId);
|
|
634
|
+
return rows.map((row) => pendingFrom(row.document)).sort((left, right) => binaryCompare(left.serviceId, right.serviceId) || binaryCompare(left.eventId, right.eventId));
|
|
635
|
+
}
|
|
636
|
+
async appendFinding(finding) {
|
|
637
|
+
this.ready();
|
|
638
|
+
const document = {
|
|
639
|
+
id: safeId(finding.eventId, finding.path, finding.classification),
|
|
640
|
+
...finding,
|
|
641
|
+
};
|
|
642
|
+
await this.beforeWrite?.("finding");
|
|
643
|
+
try {
|
|
644
|
+
await this.client.create(this.containers.findings, document, finding.serviceId);
|
|
645
|
+
}
|
|
646
|
+
catch (error) {
|
|
647
|
+
if (!isStatus(error, 409))
|
|
648
|
+
throw error;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
async appendDeliveryIncident(incident) {
|
|
652
|
+
this.ready();
|
|
653
|
+
await this.persistIncident(incident);
|
|
654
|
+
await this.projectIncident(incident);
|
|
655
|
+
}
|
|
656
|
+
async hasDeliveryIncident(serviceId, identityKey) {
|
|
657
|
+
this.ready();
|
|
658
|
+
return (await this.client.read(this.containers.events, safeId("incident", identityKey), auxiliaryPk(serviceId))) !== undefined;
|
|
659
|
+
}
|
|
660
|
+
async listDeliveryIncidents(serviceId) {
|
|
661
|
+
this.ready();
|
|
662
|
+
const rows = await this.client.query(this.containers.events, serviceId === undefined ? "SELECT * FROM c" : "SELECT * FROM c WHERE c.serviceId = @serviceId", serviceId === undefined ? [] : [{ name: "@serviceId", value: serviceId }]);
|
|
663
|
+
return rows
|
|
664
|
+
.filter((row) => row.document.kind === "delivery-incident")
|
|
665
|
+
.map((row) => incidentFrom(row.document))
|
|
666
|
+
.sort((left, right) => left.observedAt - right.observedAt || binaryCompare(left.identityKey, right.identityKey));
|
|
667
|
+
}
|
|
668
|
+
/** Retryable async projection from the same-partition incident landing. */
|
|
669
|
+
async projectDeliveryIncidents(serviceId) {
|
|
670
|
+
const incidents = await this.listDeliveryIncidents(serviceId);
|
|
671
|
+
let projected = 0;
|
|
672
|
+
for (const incident of incidents) {
|
|
673
|
+
await this.projectIncident(incident);
|
|
674
|
+
projected += 1;
|
|
675
|
+
}
|
|
676
|
+
return projected;
|
|
677
|
+
}
|
|
678
|
+
async readLineageBinding(serviceId) {
|
|
679
|
+
return this.client.read(this.containers.events, safeId("allocator-lineage-binding"), auxiliaryPk(serviceId));
|
|
680
|
+
}
|
|
681
|
+
async bindLineage(serviceId, allocatorLineageId, boundAt) {
|
|
682
|
+
const document = {
|
|
683
|
+
id: safeId("allocator-lineage-binding"),
|
|
684
|
+
pk: auxiliaryPk(serviceId),
|
|
685
|
+
serviceId,
|
|
686
|
+
kind: "allocator-lineage-binding",
|
|
687
|
+
allocatorLineageId,
|
|
688
|
+
boundAt,
|
|
689
|
+
};
|
|
690
|
+
try {
|
|
691
|
+
await this.client.create(this.containers.events, document, auxiliaryPk(serviceId));
|
|
692
|
+
}
|
|
693
|
+
catch (error) {
|
|
694
|
+
if (!isStatus(error, 409))
|
|
695
|
+
throw error;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Cosmos has no cross-document unique index. A same-partition SUID binding
|
|
700
|
+
* is the equivalent guard: the first EventId reserves the opaque SUID and
|
|
701
|
+
* every later delivery reuses or rejects that reservation before event
|
|
702
|
+
* mutation. Historical rows are still checked by eventDocuments above.
|
|
703
|
+
*/
|
|
704
|
+
async reserveSuid(serviceId, suid, eventId) {
|
|
705
|
+
const id = safeId("suid-binding", suid);
|
|
706
|
+
const existing = await this.client.read(this.containers.events, id, auxiliaryPk(serviceId));
|
|
707
|
+
if (existing !== undefined)
|
|
708
|
+
return existing.document.eventId;
|
|
709
|
+
const document = {
|
|
710
|
+
id,
|
|
711
|
+
pk: auxiliaryPk(serviceId),
|
|
712
|
+
serviceId,
|
|
713
|
+
kind: "suid-binding",
|
|
714
|
+
suid,
|
|
715
|
+
eventId,
|
|
716
|
+
};
|
|
717
|
+
await this.beforeWrite?.("event");
|
|
718
|
+
try {
|
|
719
|
+
await this.client.create(this.containers.events, document, auxiliaryPk(serviceId));
|
|
720
|
+
return eventId;
|
|
721
|
+
}
|
|
722
|
+
catch (error) {
|
|
723
|
+
if (!isStatus(error, 409))
|
|
724
|
+
throw error;
|
|
725
|
+
const raced = await this.client.read(this.containers.events, id, auxiliaryPk(serviceId));
|
|
726
|
+
return raced?.document.eventId;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
async persistIncident(incident) {
|
|
730
|
+
const document = {
|
|
731
|
+
id: safeId("incident", incident.identityKey),
|
|
732
|
+
pk: auxiliaryPk(incident.serviceId),
|
|
733
|
+
serviceId: incident.serviceId,
|
|
734
|
+
kind: "delivery-incident",
|
|
735
|
+
identityKey: incident.identityKey,
|
|
736
|
+
classification: incident.classification,
|
|
737
|
+
...(incident.suid === undefined ? {} : { suid: incident.suid }),
|
|
738
|
+
...(incident.existingEventId === undefined ? {} : { existingEventId: incident.existingEventId }),
|
|
739
|
+
...(incident.incomingEventId === undefined ? {} : { incomingEventId: incident.incomingEventId }),
|
|
740
|
+
...(incident.eventId === undefined ? {} : { eventId: incident.eventId }),
|
|
741
|
+
...(incident.boundLineageId === undefined ? {} : { boundLineageId: incident.boundLineageId }),
|
|
742
|
+
...(incident.incomingLineageId === undefined ? {} : { incomingLineageId: incident.incomingLineageId }),
|
|
743
|
+
observedAt: incident.observedAt,
|
|
744
|
+
};
|
|
745
|
+
await this.beforeWrite?.("incident");
|
|
746
|
+
try {
|
|
747
|
+
await this.client.create(this.containers.events, document, auxiliaryPk(incident.serviceId));
|
|
748
|
+
}
|
|
749
|
+
catch (error) {
|
|
750
|
+
if (!isStatus(error, 409))
|
|
751
|
+
throw error;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
async projectIncident(incident) {
|
|
755
|
+
const document = {
|
|
756
|
+
id: safeId("incident", incident.identityKey),
|
|
757
|
+
serviceId: incident.serviceId,
|
|
758
|
+
eventId: incident.eventId ?? incident.incomingEventId ?? incident.identityKey,
|
|
759
|
+
path: incident.classification,
|
|
760
|
+
classification: incident.classification,
|
|
761
|
+
firstObservedAt: incident.observedAt,
|
|
762
|
+
lagBoundMs: 0,
|
|
763
|
+
observedAt: incident.observedAt,
|
|
764
|
+
kind: "delivery-incident",
|
|
765
|
+
identityKey: incident.identityKey,
|
|
766
|
+
};
|
|
767
|
+
await this.beforeWrite?.("incident-projection");
|
|
768
|
+
try {
|
|
769
|
+
await this.client.create(this.containers.findings, document, incident.serviceId);
|
|
770
|
+
}
|
|
771
|
+
catch (error) {
|
|
772
|
+
if (!isStatus(error, 409))
|
|
773
|
+
throw error;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
async hasFinding(serviceId, eventId, path, classification) {
|
|
777
|
+
this.ready();
|
|
778
|
+
return (await this.client.read(this.containers.findings, safeId(eventId, path, classification), serviceId)) !== undefined;
|
|
779
|
+
}
|
|
780
|
+
async listFindings(serviceId, eventId) {
|
|
781
|
+
this.ready();
|
|
782
|
+
const predicates = [];
|
|
783
|
+
const parameters = [];
|
|
784
|
+
if (serviceId !== undefined) {
|
|
785
|
+
predicates.push("c.serviceId = @serviceId");
|
|
786
|
+
parameters.push({ name: "@serviceId", value: serviceId });
|
|
787
|
+
}
|
|
788
|
+
if (eventId !== undefined) {
|
|
789
|
+
predicates.push("c.eventId = @eventId");
|
|
790
|
+
parameters.push({ name: "@eventId", value: eventId });
|
|
791
|
+
}
|
|
792
|
+
const rows = await this.client.query(this.containers.findings, `SELECT * FROM c${predicates.length === 0 ? "" : ` WHERE ${predicates.join(" AND ")}`}`, parameters, serviceId);
|
|
793
|
+
return rows
|
|
794
|
+
.filter((row) => row.document.kind === undefined)
|
|
795
|
+
.map((row) => findingFrom(row.document)).sort((left, right) => left.observedAt - right.observedAt || binaryCompare(left.eventId, right.eventId) || binaryCompare(left.path, right.path));
|
|
796
|
+
}
|
|
797
|
+
async eventDocuments(serviceId) {
|
|
798
|
+
const rows = await this.client.query(this.containers.events, "SELECT * FROM c WHERE c.serviceId = @serviceId AND IS_DEFINED(c.sortableUniqueId)", [{ name: "@serviceId", value: serviceId }]);
|
|
799
|
+
return rows;
|
|
800
|
+
}
|
|
801
|
+
async eventOps(serviceId, eventId) {
|
|
802
|
+
const row = await this.client.read(this.containers.events, safeId("event-ops", eventId), eventPk(serviceId, eventId));
|
|
803
|
+
return row?.document;
|
|
804
|
+
}
|
|
805
|
+
async mutateEvent(message, eventTags, eventType, timestamp, metadata) {
|
|
806
|
+
const id = message.eventId;
|
|
807
|
+
const pk = eventPk(message.serviceId, id);
|
|
808
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
809
|
+
const existing = await this.client.read(this.containers.events, id, pk);
|
|
810
|
+
if (existing === undefined) {
|
|
811
|
+
const document = {
|
|
812
|
+
id,
|
|
813
|
+
pk,
|
|
814
|
+
serviceId: message.serviceId,
|
|
815
|
+
sortableUniqueId: message.suid,
|
|
816
|
+
eventType,
|
|
817
|
+
payload: message.payload,
|
|
818
|
+
tags: eventTags,
|
|
819
|
+
timestamp,
|
|
820
|
+
causationId: metadata.causationId,
|
|
821
|
+
correlationId: metadata.correlationId,
|
|
822
|
+
executedUser: metadata.executedUser,
|
|
823
|
+
};
|
|
824
|
+
await this.beforeWrite?.("event");
|
|
825
|
+
try {
|
|
826
|
+
await this.client.create(this.containers.events, document, pk);
|
|
827
|
+
return document;
|
|
828
|
+
}
|
|
829
|
+
catch (error) {
|
|
830
|
+
if (isStatus(error, 409))
|
|
831
|
+
continue;
|
|
832
|
+
throw error;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
const prior = eventFrom(existing.document);
|
|
836
|
+
if (prior.eventType !== eventType) {
|
|
837
|
+
throw new CanonicalEventIdentityConflictError("cosmos", message.eventId);
|
|
838
|
+
}
|
|
839
|
+
if (prior.suid !== message.suid || prior.payload !== message.payload ||
|
|
840
|
+
JSON.stringify(prior.eventTags) !== JSON.stringify(eventTags) ||
|
|
841
|
+
prior.timestamp !== timestamp || prior.causationId !== metadata.causationId ||
|
|
842
|
+
prior.correlationId !== metadata.correlationId || prior.executedUser !== metadata.executedUser) {
|
|
843
|
+
throw new Error(`EventId ${message.eventId} conflicts with its durable Cosmos row`);
|
|
844
|
+
}
|
|
845
|
+
return existing.document;
|
|
846
|
+
}
|
|
847
|
+
throw new Error("Cosmos event CAS did not converge");
|
|
848
|
+
}
|
|
849
|
+
async mutateEventOps(message, arrivedAt, lagMs) {
|
|
850
|
+
const id = safeId("event-ops", message.eventId);
|
|
851
|
+
const pk = eventPk(message.serviceId, message.eventId);
|
|
852
|
+
const nextArrival = {
|
|
853
|
+
serviceId: message.serviceId,
|
|
854
|
+
eventId: message.eventId,
|
|
855
|
+
tag: message.tag,
|
|
856
|
+
enqueuedAt: message.enqueuedAt,
|
|
857
|
+
arrivedAt,
|
|
858
|
+
lagMs,
|
|
859
|
+
};
|
|
860
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
861
|
+
const existing = await this.client.read(this.containers.events, id, pk);
|
|
862
|
+
if (existing === undefined) {
|
|
863
|
+
const document = {
|
|
864
|
+
id,
|
|
865
|
+
pk,
|
|
866
|
+
serviceId: message.serviceId,
|
|
867
|
+
kind: "event-ops",
|
|
868
|
+
eventId: message.eventId,
|
|
869
|
+
attemptId: message.attemptId,
|
|
870
|
+
allocatorLineageId: message.allocatorLineageId,
|
|
871
|
+
firstArrivedAt: arrivedAt,
|
|
872
|
+
lastArrivedAt: arrivedAt,
|
|
873
|
+
maxDeliveryLagMs: lagMs,
|
|
874
|
+
arrivals: [nextArrival],
|
|
875
|
+
};
|
|
876
|
+
await this.beforeWrite?.("event");
|
|
877
|
+
try {
|
|
878
|
+
await this.client.create(this.containers.events, document, pk);
|
|
879
|
+
return document;
|
|
880
|
+
}
|
|
881
|
+
catch (error) {
|
|
882
|
+
if (isStatus(error, 409))
|
|
883
|
+
continue;
|
|
884
|
+
throw error;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
if (existing.document.allocatorLineageId !== message.allocatorLineageId) {
|
|
888
|
+
throw new CanonicalEventIdentityConflictError("cosmos", message.eventId, "Cosmos event sidecar lineage conflicts");
|
|
889
|
+
}
|
|
890
|
+
const arrivals = [...existing.document.arrivals];
|
|
891
|
+
const index = arrivals.findIndex((arrival) => arrival.tag === message.tag);
|
|
892
|
+
if (index < 0)
|
|
893
|
+
arrivals.push(nextArrival);
|
|
894
|
+
else {
|
|
895
|
+
const prior = arrivals[index];
|
|
896
|
+
arrivals[index] = {
|
|
897
|
+
...prior,
|
|
898
|
+
enqueuedAt: Math.min(prior.enqueuedAt, nextArrival.enqueuedAt),
|
|
899
|
+
arrivedAt: Math.max(prior.arrivedAt, nextArrival.arrivedAt),
|
|
900
|
+
lagMs: Math.max(prior.lagMs, nextArrival.lagMs),
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
const document = {
|
|
904
|
+
...existing.document,
|
|
905
|
+
firstArrivedAt: Math.min(existing.document.firstArrivedAt, arrivedAt),
|
|
906
|
+
lastArrivedAt: Math.max(existing.document.lastArrivedAt, arrivedAt),
|
|
907
|
+
maxDeliveryLagMs: Math.max(existing.document.maxDeliveryLagMs, lagMs),
|
|
908
|
+
arrivals,
|
|
909
|
+
};
|
|
910
|
+
await this.beforeWrite?.("event");
|
|
911
|
+
if (await this.client.replace(this.containers.events, document, pk, existing.etag))
|
|
912
|
+
return document;
|
|
913
|
+
}
|
|
914
|
+
throw new Error("Cosmos event sidecar CAS did not converge");
|
|
915
|
+
}
|
|
916
|
+
async updateLag(serviceId, lagMs, observedAt) {
|
|
917
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
918
|
+
const existing = await this.client.read(this.containers.lagEstimates, serviceId, serviceId);
|
|
919
|
+
const currentEstimate = existing === undefined
|
|
920
|
+
? 0
|
|
921
|
+
: decayedLagEstimateMs(asNumber(existing.document.estimateMs, "estimateMs"), asNumber(existing.document.observedAt, "observedAt"), observedAt);
|
|
922
|
+
const document = {
|
|
923
|
+
id: serviceId,
|
|
924
|
+
serviceId,
|
|
925
|
+
estimateMs: Math.max(currentEstimate, lagMs),
|
|
926
|
+
observedAt,
|
|
927
|
+
};
|
|
928
|
+
await this.beforeWrite?.("lag");
|
|
929
|
+
if (existing === undefined) {
|
|
930
|
+
try {
|
|
931
|
+
await this.client.create(this.containers.lagEstimates, document, serviceId);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
catch (error) {
|
|
935
|
+
if (isStatus(error, 409))
|
|
936
|
+
continue;
|
|
937
|
+
throw error;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
else if (await this.client.replace(this.containers.lagEstimates, document, serviceId, existing.etag)) {
|
|
941
|
+
return;
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
throw new Error("Cosmos lag estimate CAS did not converge");
|
|
945
|
+
}
|
|
946
|
+
ready() {
|
|
947
|
+
if (!this.initialized)
|
|
948
|
+
throw new Error("CosmosEventStore.initialize() must complete before use");
|
|
949
|
+
}
|
|
950
|
+
}
|