@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.
Files changed (153) hide show
  1. package/LICENSE +86 -0
  2. package/README.md +13 -0
  3. package/dist/allocator/AllocatorDurableObject.d.ts +36 -0
  4. package/dist/allocator/AllocatorDurableObject.js +658 -0
  5. package/dist/allocator/IssuanceLedger.d.ts +188 -0
  6. package/dist/allocator/IssuanceLedger.js +608 -0
  7. package/dist/allocator/IssuanceReconciler.d.ts +41 -0
  8. package/dist/allocator/IssuanceReconciler.js +191 -0
  9. package/dist/allocator/OrderClock.d.ts +41 -0
  10. package/dist/allocator/OrderClock.js +92 -0
  11. package/dist/allocator/SortableUniqueId.d.ts +46 -0
  12. package/dist/allocator/SortableUniqueId.js +156 -0
  13. package/dist/allocator/types.d.ts +52 -0
  14. package/dist/allocator/types.js +1 -0
  15. package/dist/bootstrap/BootstrapCoordinatorDurableObject.d.ts +28 -0
  16. package/dist/bootstrap/BootstrapCoordinatorDurableObject.js +357 -0
  17. package/dist/bootstrap/BootstrapStoreAdapter.d.ts +51 -0
  18. package/dist/bootstrap/BootstrapStoreAdapter.js +162 -0
  19. package/dist/bootstrap/OperatorBootstrap.d.ts +20 -0
  20. package/dist/bootstrap/OperatorBootstrap.js +104 -0
  21. package/dist/bootstrap/manifest.d.ts +9 -0
  22. package/dist/bootstrap/manifest.js +115 -0
  23. package/dist/bootstrap/types.d.ts +75 -0
  24. package/dist/bootstrap/types.js +1 -0
  25. package/dist/cli/OperatorRepairCli.d.ts +11 -0
  26. package/dist/cli/OperatorRepairCli.js +108 -0
  27. package/dist/cloudflare.d.ts +164 -0
  28. package/dist/cloudflare.js +19487 -0
  29. package/dist/commit/CommitWorker.d.ts +101 -0
  30. package/dist/commit/CommitWorker.js +921 -0
  31. package/dist/commit/types.d.ts +25 -0
  32. package/dist/commit/types.js +13 -0
  33. package/dist/completeness/GlobalCompletenessReconciler.d.ts +77 -0
  34. package/dist/completeness/GlobalCompletenessReconciler.js +572 -0
  35. package/dist/completeness/types.d.ts +85 -0
  36. package/dist/completeness/types.js +6 -0
  37. package/dist/composition.d.ts +123 -0
  38. package/dist/composition.js +237 -0
  39. package/dist/cosmos.d.ts +11 -0
  40. package/dist/cosmos.js +1411 -0
  41. package/dist/d1-mv.d.ts +8 -0
  42. package/dist/d1-mv.js +2001 -0
  43. package/dist/d1.d.ts +16 -0
  44. package/dist/d1.js +3418 -0
  45. package/dist/diagnostics/G60DurableHop.d.ts +99 -0
  46. package/dist/diagnostics/G60DurableHop.js +248 -0
  47. package/dist/diagnostics/G65Admission.d.ts +23 -0
  48. package/dist/diagnostics/G65Admission.js +43 -0
  49. package/dist/diagnostics/G65DirectRing.d.ts +33 -0
  50. package/dist/diagnostics/G65DirectRing.js +85 -0
  51. package/dist/diagnostics/G69AdmissionAttempt.d.ts +43 -0
  52. package/dist/diagnostics/G69AdmissionAttempt.js +22 -0
  53. package/dist/downstream/DeliveryCore.d.ts +127 -0
  54. package/dist/downstream/DeliveryCore.js +304 -0
  55. package/dist/downstream/Doorbell.d.ts +43 -0
  56. package/dist/downstream/Doorbell.js +137 -0
  57. package/dist/downstream/DownstreamAdapter.d.ts +52 -0
  58. package/dist/downstream/DownstreamAdapter.js +299 -0
  59. package/dist/downstream/ExclusionLookup.d.ts +36 -0
  60. package/dist/downstream/ExclusionLookup.js +50 -0
  61. package/dist/downstream/InconsistencyDetector.d.ts +27 -0
  62. package/dist/downstream/InconsistencyDetector.js +82 -0
  63. package/dist/downstream/OutboxDrain.d.ts +35 -0
  64. package/dist/downstream/OutboxDrain.js +126 -0
  65. package/dist/downstream/types.d.ts +77 -0
  66. package/dist/downstream/types.js +64 -0
  67. package/dist/eventIdentity.d.ts +30 -0
  68. package/dist/eventIdentity.js +49 -0
  69. package/dist/eventRecord.d.ts +26 -0
  70. package/dist/eventRecord.js +53 -0
  71. package/dist/g42-probe.d.ts +9 -0
  72. package/dist/g42-probe.js +9 -0
  73. package/dist/http/SerializedQueryWorker.d.ts +58 -0
  74. package/dist/http/SerializedQueryWorker.js +497 -0
  75. package/dist/http/commitResponse.d.ts +46 -0
  76. package/dist/http/commitResponse.js +80 -0
  77. package/dist/http/testServiceId.d.ts +17 -0
  78. package/dist/http/testServiceId.js +14 -0
  79. package/dist/index.d.ts +112 -0
  80. package/dist/index.js +19926 -0
  81. package/dist/journal/JournalDurableObject.d.ts +30 -0
  82. package/dist/journal/JournalDurableObject.js +481 -0
  83. package/dist/journal/JournalFirstTouchProbe.d.ts +190 -0
  84. package/dist/journal/JournalFirstTouchProbe.js +375 -0
  85. package/dist/journal/types.d.ts +116 -0
  86. package/dist/journal/types.js +52 -0
  87. package/dist/mv/MaterializedViewCatchUp.d.ts +60 -0
  88. package/dist/mv/MaterializedViewCatchUp.js +353 -0
  89. package/dist/mv/MaterializedViewRuntime.d.ts +80 -0
  90. package/dist/mv/MaterializedViewRuntime.js +328 -0
  91. package/dist/mv/MaterializedViewStore.d.ts +257 -0
  92. package/dist/mv/MaterializedViewStore.js +875 -0
  93. package/dist/mv/UnsafeWindowMaterializedView.d.ts +112 -0
  94. package/dist/mv/UnsafeWindowMaterializedView.js +495 -0
  95. package/dist/mv/index.d.ts +8 -0
  96. package/dist/mv/index.js +2357 -0
  97. package/dist/projection/LiveProjectionWorker.d.ts +86 -0
  98. package/dist/projection/LiveProjectionWorker.js +240 -0
  99. package/dist/projection/ProjectionRuntime.d.ts +113 -0
  100. package/dist/projection/ProjectionRuntime.js +346 -0
  101. package/dist/projection/ProjectorRegistry.d.ts +51 -0
  102. package/dist/projection/ProjectorRegistry.js +116 -0
  103. package/dist/query/ProjectionQueryStore.d.ts +102 -0
  104. package/dist/query/ProjectionQueryStore.js +185 -0
  105. package/dist/query/QueryRegistry.d.ts +22 -0
  106. package/dist/query/QueryRegistry.js +36 -0
  107. package/dist/read/SerializedReadWorker.d.ts +36 -0
  108. package/dist/read/SerializedReadWorker.js +225 -0
  109. package/dist/repair/RepairWorker.d.ts +72 -0
  110. package/dist/repair/RepairWorker.js +461 -0
  111. package/dist/safeWindow.d.ts +20 -0
  112. package/dist/safeWindow.js +36 -0
  113. package/dist/scope/ControlRouteScope.d.ts +22 -0
  114. package/dist/scope/ControlRouteScope.js +36 -0
  115. package/dist/scope/ScopeName.d.ts +34 -0
  116. package/dist/scope/ScopeName.js +67 -0
  117. package/dist/service/ServiceIdentityProvider.d.ts +35 -0
  118. package/dist/service/ServiceIdentityProvider.js +56 -0
  119. package/dist/store/CosmosEventStore.d.ts +126 -0
  120. package/dist/store/CosmosEventStore.js +950 -0
  121. package/dist/store/D1EventStore.d.ts +111 -0
  122. package/dist/store/D1EventStore.js +1111 -0
  123. package/dist/store/PostgresEventStore.d.ts +35 -0
  124. package/dist/store/PostgresEventStore.js +811 -0
  125. package/dist/store/provider.d.ts +25 -0
  126. package/dist/store/provider.js +32 -0
  127. package/dist/store/types.d.ts +205 -0
  128. package/dist/store/types.js +18 -0
  129. package/dist/tag/EventDigest.d.ts +40 -0
  130. package/dist/tag/EventDigest.js +158 -0
  131. package/dist/tag/TagDurableObject.d.ts +286 -0
  132. package/dist/tag/TagDurableObject.js +4071 -0
  133. package/dist/tag/TagSqlMeasurement.d.ts +26 -0
  134. package/dist/tag/TagSqlMeasurement.js +44 -0
  135. package/dist/tag/TagSqlSchema.d.ts +29 -0
  136. package/dist/tag/TagSqlSchema.js +211 -0
  137. package/dist/tag/types.d.ts +173 -0
  138. package/dist/tag/types.js +3 -0
  139. package/dist/tagstate/TagStateDurableObject.d.ts +57 -0
  140. package/dist/tagstate/TagStateDurableObject.js +499 -0
  141. package/dist/trace/AttributionRatio.d.ts +12 -0
  142. package/dist/trace/AttributionRatio.js +68 -0
  143. package/dist/trace/CloudflareTracing.d.ts +7 -0
  144. package/dist/trace/CloudflareTracing.js +9 -0
  145. package/dist/trace/CommitTrace.d.ts +405 -0
  146. package/dist/trace/CommitTrace.js +1008 -0
  147. package/dist/trace/CommitTraceConsoleSink.d.ts +68 -0
  148. package/dist/trace/CommitTraceConsoleSink.js +114 -0
  149. package/dist/trace/CommitTraceVerifier.d.ts +22 -0
  150. package/dist/trace/CommitTraceVerifier.js +295 -0
  151. package/dist/trace/ObservationStream.d.ts +118 -0
  152. package/dist/trace/ObservationStream.js +148 -0
  153. package/package.json +71 -0
@@ -0,0 +1,811 @@
1
+ import postgres from "postgres";
2
+ import { decayedLagEstimateMs, } from "../safeWindow";
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
+ /** Coordinates first-use DDL across independently scheduled Worker isolates. */
8
+ const SCHEMA_BOOTSTRAP_LOCK = 84_736_291;
9
+ const SCHEMA = `
10
+ -- G32 is a new-store baseline. The logical event table intentionally mirrors
11
+ -- Sekiban.Dcb's EF model exactly; operational delivery facts are sidecar data.
12
+ CREATE TABLE IF NOT EXISTS dcb_events (
13
+ "ServiceId" varchar(64) NOT NULL,
14
+ "Id" uuid NOT NULL,
15
+ "SortableUniqueId" varchar(100) NOT NULL,
16
+ "EventType" text NOT NULL,
17
+ "Payload" json NOT NULL,
18
+ "Tags" jsonb NOT NULL,
19
+ "Timestamp" timestamptz NOT NULL,
20
+ "CausationId" text NULL,
21
+ "CorrelationId" text NULL,
22
+ "ExecutedUser" text NULL,
23
+ CONSTRAINT "PK_dcb_events" PRIMARY KEY ("ServiceId", "Id")
24
+ );
25
+ CREATE INDEX IF NOT EXISTS "IX_Events_ServiceId" ON dcb_events ("ServiceId");
26
+ CREATE INDEX IF NOT EXISTS "IX_Events_Service_SortableUniqueId" ON dcb_events ("ServiceId", "SortableUniqueId");
27
+ CREATE INDEX IF NOT EXISTS "IX_dcb_events_EventType" ON dcb_events ("EventType");
28
+ CREATE INDEX IF NOT EXISTS "IX_dcb_events_Timestamp" ON dcb_events ("Timestamp");
29
+
30
+ CREATE TABLE IF NOT EXISTS dcb_event_ops (
31
+ "ServiceId" varchar(64) NOT NULL,
32
+ "Id" uuid NOT NULL,
33
+ "AttemptId" text NULL,
34
+ "AllocatorLineageId" text NULL,
35
+ "FirstArrivedAt" bigint NOT NULL,
36
+ "LastArrivedAt" bigint NOT NULL,
37
+ "MaxDeliveryLagMs" bigint NOT NULL,
38
+ PRIMARY KEY ("ServiceId", "Id"),
39
+ FOREIGN KEY ("ServiceId", "Id") REFERENCES dcb_events ("ServiceId", "Id") ON DELETE CASCADE
40
+ );
41
+
42
+ CREATE TABLE IF NOT EXISTS serialized_dcb_allocator_bindings (
43
+ service_id TEXT PRIMARY KEY,
44
+ allocator_lineage_id TEXT NOT NULL,
45
+ bound_at BIGINT NOT NULL
46
+ );
47
+
48
+ CREATE TABLE IF NOT EXISTS serialized_dcb_event_arrivals (
49
+ service_id TEXT NOT NULL,
50
+ -- This sidecar participates in a foreign key to C#'s UUID logical Id;
51
+ -- retaining the old text type would make a clean G32 baseline impossible
52
+ -- to create and would mask the record/DDL mismatch.
53
+ event_id uuid NOT NULL,
54
+ tag TEXT NOT NULL,
55
+ enqueued_at BIGINT NOT NULL,
56
+ arrived_at BIGINT NOT NULL,
57
+ lag_ms BIGINT NOT NULL,
58
+ PRIMARY KEY (service_id, event_id, tag),
59
+ FOREIGN KEY (service_id, event_id)
60
+ REFERENCES dcb_events ("ServiceId", "Id")
61
+ ON DELETE CASCADE
62
+ );
63
+ CREATE TABLE IF NOT EXISTS serialized_dcb_lag_estimates (
64
+ service_id TEXT PRIMARY KEY,
65
+ estimate_ms BIGINT NOT NULL,
66
+ observed_at BIGINT NOT NULL
67
+ );
68
+ CREATE TABLE IF NOT EXISTS serialized_dcb_pending_arrivals (
69
+ service_id TEXT NOT NULL,
70
+ event_id TEXT NOT NULL,
71
+ attempt_id TEXT NOT NULL,
72
+ suid TEXT NOT NULL,
73
+ expected_paths JSONB NOT NULL,
74
+ observed_paths JSONB NOT NULL,
75
+ first_observed_at BIGINT NOT NULL,
76
+ lag_bound_ms BIGINT NOT NULL,
77
+ PRIMARY KEY (service_id, event_id)
78
+ );
79
+
80
+ CREATE TABLE IF NOT EXISTS serialized_dcb_inconsistency_findings (
81
+ sequence BIGSERIAL PRIMARY KEY,
82
+ service_id TEXT NOT NULL,
83
+ event_id TEXT NOT NULL,
84
+ path TEXT NOT NULL,
85
+ classification TEXT NOT NULL,
86
+ first_observed_at BIGINT NOT NULL,
87
+ lag_bound_ms BIGINT NOT NULL,
88
+ observed_at BIGINT NOT NULL,
89
+ UNIQUE (service_id, event_id, path, classification)
90
+ );
91
+
92
+ CREATE TABLE IF NOT EXISTS serialized_dcb_delivery_incidents (
93
+ sequence BIGSERIAL PRIMARY KEY,
94
+ service_id TEXT NOT NULL,
95
+ identity_key TEXT NOT NULL,
96
+ classification TEXT NOT NULL,
97
+ suid TEXT,
98
+ existing_event_id TEXT,
99
+ incoming_event_id TEXT,
100
+ event_id TEXT,
101
+ bound_lineage_id TEXT,
102
+ incoming_lineage_id TEXT,
103
+ observed_at BIGINT NOT NULL,
104
+ UNIQUE (service_id, identity_key)
105
+ );
106
+
107
+ CREATE TABLE IF NOT EXISTS serialized_dcb_projection_checkpoints (
108
+ service_id TEXT NOT NULL,
109
+ projection_id TEXT NOT NULL,
110
+ last_suid TEXT NOT NULL,
111
+ state_json TEXT NOT NULL,
112
+ version BIGINT NOT NULL,
113
+ updated_at BIGINT NOT NULL,
114
+ PRIMARY KEY (service_id, projection_id)
115
+ );
116
+ `;
117
+ function asString(value, name) {
118
+ if (typeof value !== "string") {
119
+ throw new Error(`Postgres ${name} was not a string`);
120
+ }
121
+ return value;
122
+ }
123
+ function nullableString(value, name) {
124
+ return value === null ? null : asString(value, name);
125
+ }
126
+ function sameUtcInstant(left, right) {
127
+ if (typeof left !== "string")
128
+ return false;
129
+ const leftMs = Date.parse(left);
130
+ const rightMs = Date.parse(right);
131
+ return Number.isFinite(leftMs) && Number.isFinite(rightMs) && leftMs === rightMs;
132
+ }
133
+ /**
134
+ * PostgreSQL renders timestamptz values as `YYYY-MM-DD HH:mm:ss+00`, while
135
+ * the durable DCB contract exposes canonical UTC ISO strings. Normalize at
136
+ * the provider boundary so an export can be admitted by another provider
137
+ * without treating equivalent timestamp renderings as distinct records.
138
+ */
139
+ function canonicalUtcTimestamp(value, name) {
140
+ const text = asString(value, name);
141
+ const milliseconds = Date.parse(text);
142
+ if (!Number.isFinite(milliseconds)) {
143
+ throw new Error(`Postgres ${name} was not a valid UTC timestamp`);
144
+ }
145
+ return new Date(milliseconds).toISOString();
146
+ }
147
+ function assertUtcTimestamp(value, eventId) {
148
+ if (!CANONICAL_UTC_TIMESTAMP_PATTERN.test(value) || !Number.isFinite(Date.parse(value))) {
149
+ throw new CanonicalEventIdentityConflictError("postgres", eventId, "PostgreSQL Timestamp must be canonical UTC ISO-8601");
150
+ }
151
+ }
152
+ function asNumber(value, name) {
153
+ const number = typeof value === "number" ? value : Number(value);
154
+ if (!Number.isSafeInteger(number)) {
155
+ throw new Error(`Postgres ${name} was not a safe integer`);
156
+ }
157
+ return number;
158
+ }
159
+ function asStringArray(value, name) {
160
+ const decoded = typeof value === "string" ? JSON.parse(value) : value;
161
+ if (!Array.isArray(decoded) || !decoded.every((entry) => typeof entry === "string")) {
162
+ throw new Error(`Postgres ${name} was not a string array`);
163
+ }
164
+ // dcb_events.Tags is an ordered C# payload field. Callers needing set
165
+ // semantics (pending paths, projection membership) normalize explicitly.
166
+ return [...decoded];
167
+ }
168
+ function sortedUnique(values) {
169
+ return [...new Set(values)].sort(binaryCompare);
170
+ }
171
+ function binaryCompare(left, right) {
172
+ const leftBytes = new TextEncoder().encode(left);
173
+ const rightBytes = new TextEncoder().encode(right);
174
+ const shared = Math.min(leftBytes.length, rightBytes.length);
175
+ for (let index = 0; index < shared; index += 1) {
176
+ const difference = leftBytes[index] - rightBytes[index];
177
+ if (difference !== 0)
178
+ return difference;
179
+ }
180
+ return leftBytes.length - rightBytes.length;
181
+ }
182
+ /** V1 SUIDs are opaque byte strings; lexical JavaScript order is not enough. */
183
+ function compareSuid(left, right) {
184
+ const leftBytes = new TextEncoder().encode(left);
185
+ const rightBytes = new TextEncoder().encode(right);
186
+ const shared = Math.min(leftBytes.length, rightBytes.length);
187
+ for (let index = 0; index < shared; index += 1) {
188
+ const difference = leftBytes[index] - rightBytes[index];
189
+ if (difference !== 0) {
190
+ return difference;
191
+ }
192
+ }
193
+ return leftBytes.length - rightBytes.length;
194
+ }
195
+ function pendingFrom(row) {
196
+ return {
197
+ serviceId: asString(row.service_id, "service_id"),
198
+ eventId: asString(row.event_id, "event_id"),
199
+ attemptId: asString(row.attempt_id, "attempt_id"),
200
+ suid: asString(row.suid, "suid"),
201
+ expectedPaths: sortedUnique(asStringArray(row.expected_paths, "expected_paths")),
202
+ observedPaths: sortedUnique(asStringArray(row.observed_paths, "observed_paths")),
203
+ firstObservedAt: asNumber(row.first_observed_at, "first_observed_at"),
204
+ lagBoundMs: asNumber(row.lag_bound_ms, "lag_bound_ms"),
205
+ };
206
+ }
207
+ function findingFrom(row) {
208
+ const classification = asString(row.classification, "classification");
209
+ if (classification !== "MISSING_STABLE" && classification !== "EXCLUDED_AUDITED" && classification !== "RESOLVED_LATE") {
210
+ throw new Error("Postgres classification was invalid");
211
+ }
212
+ return {
213
+ serviceId: asString(row.service_id, "service_id"),
214
+ eventId: asString(row.event_id, "event_id"),
215
+ path: asString(row.path, "path"),
216
+ classification,
217
+ firstObservedAt: asNumber(row.first_observed_at, "first_observed_at"),
218
+ lagBoundMs: asNumber(row.lag_bound_ms, "lag_bound_ms"),
219
+ observedAt: asNumber(row.observed_at, "observed_at"),
220
+ };
221
+ }
222
+ function incidentClassification(value) {
223
+ const classification = asString(value, "classification");
224
+ if (classification !== "SUID_COLLISION" && classification !== "ORDER_VIOLATION" && classification !== "LINEAGE_MISMATCH" && classification !== "ORDERING_DETECTOR_UNKNOWN") {
225
+ throw new Error("Postgres delivery incident classification was invalid");
226
+ }
227
+ return classification;
228
+ }
229
+ function optionalString(value, name) {
230
+ if (value === null || value === undefined)
231
+ return undefined;
232
+ return asString(value, name);
233
+ }
234
+ function incidentFrom(row) {
235
+ return {
236
+ serviceId: asString(row.service_id, "service_id"),
237
+ identityKey: asString(row.identity_key, "identity_key"),
238
+ classification: incidentClassification(row.classification),
239
+ suid: optionalString(row.suid, "suid"),
240
+ existingEventId: optionalString(row.existing_event_id, "existing_event_id"),
241
+ incomingEventId: optionalString(row.incoming_event_id, "incoming_event_id"),
242
+ eventId: optionalString(row.event_id, "event_id"),
243
+ boundLineageId: optionalString(row.bound_lineage_id, "bound_lineage_id"),
244
+ incomingLineageId: optionalString(row.incoming_lineage_id, "incoming_lineage_id"),
245
+ observedAt: asNumber(row.observed_at, "observed_at"),
246
+ };
247
+ }
248
+ function collisionIdentity(serviceId, suid, existingEventId, incomingEventId) {
249
+ const [first, second] = [existingEventId, incomingEventId].sort(binaryCompare);
250
+ return `SUID_COLLISION|${serviceId}|${suid}|${first}|${second}`;
251
+ }
252
+ function metadataForDelivery(message, deliverySource) {
253
+ const serialized = serializedEventMetadata(message.eventId);
254
+ if (deliverySource !== "import") {
255
+ if (message.causationId !== serialized.causationId ||
256
+ message.correlationId !== serialized.correlationId ||
257
+ message.executedUser !== serialized.executedUser) {
258
+ throw new CanonicalEventIdentityConflictError("postgres", message.eventId, "PostgreSQL metadata must use the serialized C# constants");
259
+ }
260
+ return serialized;
261
+ }
262
+ const values = [message.causationId, message.correlationId, message.executedUser];
263
+ if (!values.every((value) => value === null || typeof value === "string")) {
264
+ throw new CanonicalEventIdentityConflictError("postgres", message.eventId, "PostgreSQL import metadata must be string or null");
265
+ }
266
+ const allNull = values.every((value) => value === null);
267
+ const allSerialized = message.causationId === serialized.causationId &&
268
+ message.correlationId === serialized.correlationId && message.executedUser === serialized.executedUser;
269
+ if (!allNull && !allSerialized) {
270
+ throw new CanonicalEventIdentityConflictError("postgres", message.eventId, "PostgreSQL import metadata must be all null or serialized constants");
271
+ }
272
+ return {
273
+ causationId: message.causationId,
274
+ correlationId: message.correlationId,
275
+ executedUser: message.executedUser,
276
+ };
277
+ }
278
+ function lineageIdentity(serviceId, boundLineageId, incomingLineageId) {
279
+ return `LINEAGE_MISMATCH|${serviceId}|${boundLineageId}|${incomingLineageId}`;
280
+ }
281
+ function projectionCheckpointFrom(row) {
282
+ return {
283
+ serviceId: asString(row.service_id, "service_id"),
284
+ projectionId: asString(row.projection_id, "projection_id"),
285
+ lastSuid: asString(row.last_suid, "last_suid"),
286
+ stateJson: asString(row.state_json, "state_json"),
287
+ version: asNumber(row.version, "version"),
288
+ updatedAt: asNumber(row.updated_at, "updated_at"),
289
+ };
290
+ }
291
+ /**
292
+ * `postgres` exposes an ESM/workerd build. The deployed Worker passes a
293
+ * Hyperdrive connection string; local Miniflare passes Docker PostgreSQL.
294
+ */
295
+ export class PostgresEventStore {
296
+ connectionString;
297
+ sql;
298
+ constructor(connectionString) {
299
+ this.connectionString = connectionString;
300
+ }
301
+ async initialize() {
302
+ if (this.sql !== undefined) {
303
+ return;
304
+ }
305
+ const sql = postgres(this.connectionString, { fetch_types: false, max: 1, prepare: true });
306
+ try {
307
+ await sql.begin(async (transaction) => {
308
+ await transaction.unsafe("SELECT pg_advisory_xact_lock($1)", [SCHEMA_BOOTSTRAP_LOCK]);
309
+ await transaction.unsafe(SCHEMA, [], { prepare: false });
310
+ });
311
+ this.sql = sql;
312
+ }
313
+ catch (error) {
314
+ await sql.end({ timeout: 1 });
315
+ throw error;
316
+ }
317
+ }
318
+ /** Close an owned request/test client without touching durable records. */
319
+ async close() {
320
+ const sql = this.sql;
321
+ this.sql = undefined;
322
+ if (sql !== undefined)
323
+ await sql.end({ timeout: 5 });
324
+ }
325
+ async recordDelivery(message, arrivedAt, deliverySource = "queue") {
326
+ const lagMs = Math.max(0, arrivedAt - message.enqueuedAt);
327
+ const identity = resolveDeliveryIdentity(message, deliverySource);
328
+ assertSortableUniqueId(message.suid);
329
+ const acceptsImportedId = deliverySource === "import";
330
+ if (!(acceptsImportedId ? isRfc4122Uuid(message.eventId) : isUuidV7(message.eventId))) {
331
+ throw new CanonicalEventIdentityConflictError("postgres", message.eventId, `PostgreSQL EventId must be an ${acceptsImportedId ? "RFC 4122 UUID" : "UUID v7"}`);
332
+ }
333
+ try {
334
+ JSON.parse(message.payload);
335
+ }
336
+ catch {
337
+ throw new CanonicalEventIdentityConflictError("postgres", message.eventId, "PostgreSQL Payload must be UTF-8 JSON text");
338
+ }
339
+ const timestamp = message.timestamp ?? new Date(arrivedAt).toISOString();
340
+ assertUtcTimestamp(timestamp, message.eventId);
341
+ const metadata = metadataForDelivery(message, deliverySource);
342
+ const incomingEventType = identity.key;
343
+ // dcb_events.Tags is C#'s emission-order JSON array. It is not a set;
344
+ // set semantics belong only to projection membership/derivation.
345
+ const eventTags = [...message.eventTags];
346
+ const sql = this.requireSql();
347
+ let outcome = "stored";
348
+ let incident;
349
+ await sql.begin(async (transaction) => {
350
+ // Serialize all deliveries for a service. The unique index below is
351
+ // defense-in-depth, while this lock lets us persist the incident in the
352
+ // same transaction instead of leaking a unique-violation retry window.
353
+ await transaction.unsafe("SELECT pg_advisory_xact_lock(hashtext($1))", [message.serviceId]);
354
+ const binding = (await transaction.unsafe(`SELECT allocator_lineage_id
355
+ FROM serialized_dcb_allocator_bindings
356
+ WHERE service_id = $1
357
+ FOR UPDATE`, [message.serviceId]))[0];
358
+ const boundLineageId = binding === undefined
359
+ ? undefined
360
+ : asString(binding.allocator_lineage_id, "allocator_lineage_id");
361
+ if (boundLineageId !== undefined && boundLineageId !== message.allocatorLineageId) {
362
+ outcome = "lineage-mismatch";
363
+ incident = {
364
+ serviceId: message.serviceId,
365
+ identityKey: lineageIdentity(message.serviceId, boundLineageId, message.allocatorLineageId),
366
+ classification: "LINEAGE_MISMATCH",
367
+ boundLineageId,
368
+ incomingLineageId: message.allocatorLineageId,
369
+ observedAt: arrivedAt,
370
+ };
371
+ await this.insertIncident(transaction, incident);
372
+ return;
373
+ }
374
+ if (boundLineageId === undefined) {
375
+ await transaction.unsafe(`INSERT INTO serialized_dcb_allocator_bindings (service_id, allocator_lineage_id, bound_at)
376
+ VALUES ($1, $2, $3)
377
+ ON CONFLICT (service_id) DO NOTHING`, [message.serviceId, message.allocatorLineageId, arrivedAt]);
378
+ }
379
+ // A message whose opaque SUID is already behind the durable source head
380
+ // is recovery/backlog traffic. It remains observable in the arrival
381
+ // tables, but must not inflate the reordering estimate.
382
+ const headRow = (await transaction.unsafe(`SELECT MAX("SortableUniqueId") AS head_suid
383
+ FROM dcb_events
384
+ WHERE "ServiceId" = $1`, [message.serviceId]))[0];
385
+ const currentHead = headRow?.head_suid === null || headRow?.head_suid === undefined
386
+ ? undefined
387
+ : asString(headRow.head_suid, "head_suid");
388
+ const recoveryBacklogSample = currentHead !== undefined && compareSuid(message.suid, currentHead) < 0;
389
+ const existing = (await transaction.unsafe(`SELECT "SortableUniqueId" AS suid, "Payload"::text AS payload,
390
+ "EventType" AS event_type, "Tags" AS event_tags,
391
+ "Timestamp"::text AS timestamp, "CausationId" AS causation_id,
392
+ "CorrelationId" AS correlation_id, "ExecutedUser" AS executed_user
393
+ FROM dcb_events
394
+ WHERE "ServiceId" = $1 AND "Id" = $2::uuid
395
+ FOR UPDATE`, [message.serviceId, message.eventId]))[0];
396
+ const sameSuid = (await transaction.unsafe(`SELECT "Id"::text AS event_id
397
+ FROM dcb_events
398
+ WHERE "ServiceId" = $1 AND "SortableUniqueId" = $2
399
+ FOR UPDATE`, [message.serviceId, message.suid]))[0];
400
+ if (sameSuid !== undefined && asString(sameSuid.event_id, "event_id") !== message.eventId) {
401
+ outcome = "suid-collision";
402
+ incident = {
403
+ serviceId: message.serviceId,
404
+ identityKey: collisionIdentity(message.serviceId, message.suid, asString(sameSuid.event_id, "event_id"), message.eventId),
405
+ classification: "SUID_COLLISION",
406
+ suid: message.suid,
407
+ existingEventId: asString(sameSuid.event_id, "event_id"),
408
+ incomingEventId: message.eventId,
409
+ observedAt: arrivedAt,
410
+ };
411
+ await this.insertIncident(transaction, incident);
412
+ return;
413
+ }
414
+ if (existing === undefined) {
415
+ await transaction.unsafe(`INSERT INTO dcb_events
416
+ ("ServiceId", "Id", "SortableUniqueId", "EventType", "Payload", "Tags", "Timestamp", "CausationId", "CorrelationId", "ExecutedUser")
417
+ -- Bind raw JSON text as text first. The postgres driver otherwise
418
+ -- JSON-encodes a JavaScript string again, turning the logical C#
419
+ -- JSON payload/Tags array into scalar JSON strings.
420
+ VALUES ($1, $2::uuid, $3, $4, $5::text::json, $6::text::jsonb, $7::timestamptz, $8, $9, $10)`, [message.serviceId, message.eventId, message.suid, incomingEventType, message.payload, JSON.stringify(eventTags), timestamp, metadata.causationId, metadata.correlationId, metadata.executedUser]);
421
+ await transaction.unsafe(`INSERT INTO dcb_event_ops
422
+ ("ServiceId", "Id", "AttemptId", "AllocatorLineageId", "FirstArrivedAt", "LastArrivedAt", "MaxDeliveryLagMs")
423
+ VALUES ($1, $2::uuid, $3, $4, $5, $5, $6)`, [message.serviceId, message.eventId, message.attemptId, message.allocatorLineageId, arrivedAt, lagMs]);
424
+ }
425
+ else {
426
+ const existingEventType = optionalString(existing.event_type, "event_type");
427
+ if (existingEventType !== incomingEventType) {
428
+ throw new CanonicalEventIdentityConflictError("postgres", message.eventId);
429
+ }
430
+ if (asString(existing.suid, "suid") !== message.suid || asString(existing.payload, "payload") !== message.payload) {
431
+ throw new Error(`EventId ${message.eventId} conflicts with its durable PostgreSQL row`);
432
+ }
433
+ const storedTags = asStringArray(existing.event_tags, "event_tags");
434
+ if (JSON.stringify(storedTags) !== JSON.stringify(eventTags) ||
435
+ !sameUtcInstant(existing.timestamp, timestamp) ||
436
+ nullableString(existing.causation_id, "causation_id") !== metadata.causationId ||
437
+ nullableString(existing.correlation_id, "correlation_id") !== metadata.correlationId ||
438
+ nullableString(existing.executed_user, "executed_user") !== metadata.executedUser) {
439
+ throw new Error(`EventId ${message.eventId} conflicts with its durable tag membership`);
440
+ }
441
+ }
442
+ await transaction.unsafe(`INSERT INTO serialized_dcb_event_arrivals
443
+ (service_id, event_id, tag, enqueued_at, arrived_at, lag_ms)
444
+ VALUES ($1, $2, $3, $4, $5, $6)
445
+ ON CONFLICT (service_id, event_id, tag) DO UPDATE
446
+ SET enqueued_at = LEAST(serialized_dcb_event_arrivals.enqueued_at, EXCLUDED.enqueued_at),
447
+ arrived_at = GREATEST(serialized_dcb_event_arrivals.arrived_at, EXCLUDED.arrived_at),
448
+ lag_ms = GREATEST(serialized_dcb_event_arrivals.lag_ms, EXCLUDED.lag_ms)`, [message.serviceId, message.eventId, message.tag, message.enqueuedAt, arrivedAt, lagMs]);
449
+ await transaction.unsafe(`INSERT INTO dcb_event_ops
450
+ ("ServiceId", "Id", "AttemptId", "AllocatorLineageId", "FirstArrivedAt", "LastArrivedAt", "MaxDeliveryLagMs")
451
+ VALUES ($1, $2::uuid, $3, $4, $5, $5, $6)
452
+ ON CONFLICT ("ServiceId", "Id") DO UPDATE
453
+ SET "FirstArrivedAt" = LEAST(dcb_event_ops."FirstArrivedAt", EXCLUDED."FirstArrivedAt"),
454
+ "LastArrivedAt" = GREATEST(dcb_event_ops."LastArrivedAt", EXCLUDED."LastArrivedAt"),
455
+ "MaxDeliveryLagMs" = GREATEST(dcb_event_ops."MaxDeliveryLagMs", EXCLUDED."MaxDeliveryLagMs")`, [message.serviceId, message.eventId, message.attemptId, message.allocatorLineageId, arrivedAt, lagMs]);
456
+ if (!recoveryBacklogSample && deliverySource !== "fast") {
457
+ const estimator = (await transaction.unsafe(`SELECT estimate_ms, observed_at
458
+ FROM serialized_dcb_lag_estimates
459
+ WHERE service_id = $1
460
+ FOR UPDATE`, [message.serviceId]))[0];
461
+ const currentEstimate = estimator === undefined
462
+ ? 0
463
+ : decayedLagEstimateMs(asNumber(estimator.estimate_ms, "estimate_ms"), asNumber(estimator.observed_at, "observed_at"), arrivedAt);
464
+ const nextEstimate = Math.max(currentEstimate, lagMs);
465
+ await transaction.unsafe(`INSERT INTO serialized_dcb_lag_estimates (service_id, estimate_ms, observed_at)
466
+ VALUES ($1, $2, $3)
467
+ ON CONFLICT (service_id) DO UPDATE
468
+ SET estimate_ms = EXCLUDED.estimate_ms,
469
+ observed_at = EXCLUDED.observed_at`, [message.serviceId, nextEstimate, arrivedAt]);
470
+ }
471
+ });
472
+ if (outcome !== "stored") {
473
+ return { outcome, kind: outcome, incident: incident };
474
+ }
475
+ const stored = await this.eventById(message.serviceId, message.eventId);
476
+ if (stored === undefined) {
477
+ throw new Error("PostgreSQL event upsert did not produce a durable row");
478
+ }
479
+ return outcome === "stored"
480
+ ? { outcome, kind: "stored", event: stored }
481
+ : { outcome, kind: outcome, incident: incident };
482
+ }
483
+ async readAllEvents(serviceId, since) {
484
+ if (since.length !== 0)
485
+ assertSortableUniqueId(since);
486
+ const rows = await this.query(`SELECT e."ServiceId" AS service_id, e."Id"::text AS event_id,
487
+ e."SortableUniqueId" AS suid, e."Payload"::text AS payload,
488
+ e."EventType" AS event_type, e."Tags" AS event_tags,
489
+ e."Timestamp"::text AS timestamp, e."CausationId" AS causation_id,
490
+ e."CorrelationId" AS correlation_id, e."ExecutedUser" AS executed_user,
491
+ COALESCE(o."FirstArrivedAt", 0) AS first_arrived_at,
492
+ COALESCE(o."LastArrivedAt", 0) AS last_arrived_at,
493
+ COALESCE(o."MaxDeliveryLagMs", 0) AS max_delivery_lag_ms
494
+ FROM dcb_events e LEFT JOIN dcb_event_ops o
495
+ ON o."ServiceId" = e."ServiceId" AND o."Id" = e."Id"
496
+ WHERE e."ServiceId" = $1 AND e."SortableUniqueId" > $2
497
+ ORDER BY e."SortableUniqueId" ASC, e."Id" ASC`, [serviceId, since]);
498
+ const events = [];
499
+ for (const row of rows) {
500
+ events.push(await this.eventFromRow(row));
501
+ }
502
+ return events;
503
+ }
504
+ async currentLagBound(serviceId, nowMs) {
505
+ const rows = await this.query(`SELECT estimate_ms, observed_at
506
+ FROM serialized_dcb_lag_estimates
507
+ WHERE service_id = $1`, [serviceId]);
508
+ const row = rows[0];
509
+ if (row === undefined) {
510
+ return 0;
511
+ }
512
+ const estimate = asNumber(row.estimate_ms, "estimate_ms");
513
+ const observedAt = asNumber(row.observed_at, "observed_at");
514
+ // Production timestamps are Unix epoch milliseconds. The smaller logical
515
+ // clocks used by deterministic projection tests intentionally remain
516
+ // un-decayed so those tests can inspect the raw observed lag value.
517
+ const decayNow = nowMs !== undefined && nowMs >= 100_000_000_000 && observedAt >= 100_000_000_000
518
+ ? nowMs
519
+ : observedAt;
520
+ return decayedLagEstimateMs(estimate, observedAt, decayNow);
521
+ }
522
+ async listProjectionTags(serviceId) {
523
+ const rows = await this.query(`SELECT DISTINCT memberships.tag AS tag
524
+ FROM dcb_events
525
+ CROSS JOIN LATERAL jsonb_array_elements_text("Tags") AS memberships(tag)
526
+ WHERE "ServiceId" = $1
527
+ ORDER BY memberships.tag ASC`, [serviceId]);
528
+ return rows.map((row) => asString(row.tag, "tag"));
529
+ }
530
+ async readProjectionCheckpoint(serviceId, projectionId) {
531
+ const rows = await this.query(`SELECT service_id, projection_id, last_suid, state_json, version, updated_at
532
+ FROM serialized_dcb_projection_checkpoints
533
+ WHERE service_id = $1 AND projection_id = $2`, [serviceId, projectionId]);
534
+ const row = rows[0];
535
+ return row === undefined ? undefined : projectionCheckpointFrom(row);
536
+ }
537
+ async advanceProjectionCheckpoint(input) {
538
+ const sql = this.requireSql();
539
+ let advanced = false;
540
+ await sql.begin(async (transaction) => {
541
+ const existing = (await transaction.unsafe(`SELECT last_suid
542
+ FROM serialized_dcb_projection_checkpoints
543
+ WHERE service_id = $1 AND projection_id = $2
544
+ FOR UPDATE`, [input.serviceId, input.projectionId]))[0];
545
+ if (existing === undefined) {
546
+ if (input.expectedLastSuid !== null) {
547
+ return;
548
+ }
549
+ await transaction.unsafe(`INSERT INTO serialized_dcb_projection_checkpoints
550
+ (service_id, projection_id, last_suid, state_json, version, updated_at)
551
+ VALUES ($1, $2, $3, $4, $5, $6)`, [
552
+ input.serviceId,
553
+ input.projectionId,
554
+ input.lastSuid,
555
+ input.stateJson,
556
+ input.version,
557
+ input.updatedAt,
558
+ ]);
559
+ advanced = true;
560
+ return;
561
+ }
562
+ if (asString(existing.last_suid, "last_suid") !== input.expectedLastSuid) {
563
+ return;
564
+ }
565
+ await transaction.unsafe(`UPDATE serialized_dcb_projection_checkpoints
566
+ SET last_suid = $3,
567
+ state_json = $4,
568
+ version = $5,
569
+ updated_at = $6
570
+ WHERE service_id = $1 AND projection_id = $2`, [
571
+ input.serviceId,
572
+ input.projectionId,
573
+ input.lastSuid,
574
+ input.stateJson,
575
+ input.version,
576
+ input.updatedAt,
577
+ ]);
578
+ advanced = true;
579
+ });
580
+ return advanced;
581
+ }
582
+ async projectionLag(serviceId, projectionId, tag) {
583
+ const checkpoint = await this.readProjectionCheckpoint(serviceId, projectionId);
584
+ const checkpointSuid = checkpoint?.lastSuid ?? "";
585
+ const rows = await this.query(`SELECT COALESCE(MAX("SortableUniqueId"), '') AS head_suid,
586
+ COUNT(*) FILTER (WHERE "SortableUniqueId" > $3) AS behind_events
587
+ FROM dcb_events
588
+ WHERE "ServiceId" = $1 AND "Tags" ? $2`, [serviceId, tag, checkpointSuid]);
589
+ const row = rows[0] ?? {};
590
+ return {
591
+ serviceId,
592
+ projectionId,
593
+ tag,
594
+ checkpointSuid,
595
+ headSuid: asString(row.head_suid ?? "", "head_suid"),
596
+ behindEvents: asNumber(row.behind_events ?? 0, "behind_events"),
597
+ };
598
+ }
599
+ async upsertPending(message, firstObservedAt, lagBoundMs) {
600
+ const sql = this.requireSql();
601
+ await sql.begin(async (transaction) => {
602
+ const existing = (await transaction.unsafe(`SELECT service_id, event_id, attempt_id, suid, expected_paths, observed_paths, first_observed_at, lag_bound_ms
603
+ FROM serialized_dcb_pending_arrivals
604
+ WHERE service_id = $1 AND event_id = $2
605
+ FOR UPDATE`, [message.serviceId, message.eventId]))[0];
606
+ if (existing === undefined) {
607
+ await transaction.unsafe(`INSERT INTO serialized_dcb_pending_arrivals
608
+ (service_id, event_id, attempt_id, suid, expected_paths, observed_paths, first_observed_at, lag_bound_ms)
609
+ VALUES ($1, $2, $3, $4, $5::text::jsonb, $6::text::jsonb, $7, $8)`, [
610
+ message.serviceId,
611
+ message.eventId,
612
+ message.attemptId,
613
+ message.suid,
614
+ JSON.stringify(sortedUnique(message.eventTags)),
615
+ JSON.stringify([message.tag]),
616
+ firstObservedAt,
617
+ lagBoundMs,
618
+ ]);
619
+ }
620
+ else {
621
+ const pending = pendingFrom(existing);
622
+ if (pending.attemptId !== message.attemptId || pending.suid !== message.suid) {
623
+ throw new Error(`EventId ${message.eventId} has contradictory pending-arrival identity`);
624
+ }
625
+ await transaction.unsafe(`UPDATE serialized_dcb_pending_arrivals
626
+ SET expected_paths = $3::text::jsonb,
627
+ observed_paths = $4::text::jsonb,
628
+ lag_bound_ms = GREATEST(lag_bound_ms, $5)
629
+ WHERE service_id = $1 AND event_id = $2`, [
630
+ message.serviceId,
631
+ message.eventId,
632
+ JSON.stringify(sortedUnique([...pending.expectedPaths, ...message.eventTags])),
633
+ JSON.stringify(sortedUnique([...pending.observedPaths, message.tag])),
634
+ lagBoundMs,
635
+ ]);
636
+ }
637
+ });
638
+ const pending = await this.pendingById(message.serviceId, message.eventId);
639
+ if (pending === undefined) {
640
+ throw new Error("PostgreSQL pending record was not persisted");
641
+ }
642
+ return pending;
643
+ }
644
+ async listPending(serviceId) {
645
+ const rows = serviceId === undefined
646
+ ? await this.query(`SELECT service_id, event_id, attempt_id, suid, expected_paths, observed_paths, first_observed_at, lag_bound_ms
647
+ FROM serialized_dcb_pending_arrivals
648
+ ORDER BY service_id ASC, event_id ASC`)
649
+ : await this.query(`SELECT service_id, event_id, attempt_id, suid, expected_paths, observed_paths, first_observed_at, lag_bound_ms
650
+ FROM serialized_dcb_pending_arrivals
651
+ WHERE service_id = $1
652
+ ORDER BY event_id ASC`, [serviceId]);
653
+ return rows.map(pendingFrom);
654
+ }
655
+ async appendFinding(finding) {
656
+ await this.query(`INSERT INTO serialized_dcb_inconsistency_findings
657
+ (service_id, event_id, path, classification, first_observed_at, lag_bound_ms, observed_at)
658
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
659
+ ON CONFLICT (service_id, event_id, path, classification) DO NOTHING`, [
660
+ finding.serviceId,
661
+ finding.eventId,
662
+ finding.path,
663
+ finding.classification,
664
+ finding.firstObservedAt,
665
+ finding.lagBoundMs,
666
+ finding.observedAt,
667
+ ]);
668
+ }
669
+ async appendDeliveryIncident(incident) {
670
+ await this.query(`INSERT INTO serialized_dcb_delivery_incidents
671
+ (service_id, identity_key, classification, suid, existing_event_id, incoming_event_id, event_id,
672
+ bound_lineage_id, incoming_lineage_id, observed_at)
673
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
674
+ ON CONFLICT (service_id, identity_key) DO NOTHING`, [
675
+ incident.serviceId,
676
+ incident.identityKey,
677
+ incident.classification,
678
+ incident.suid ?? null,
679
+ incident.existingEventId ?? null,
680
+ incident.incomingEventId ?? null,
681
+ incident.eventId ?? null,
682
+ incident.boundLineageId ?? null,
683
+ incident.incomingLineageId ?? null,
684
+ incident.observedAt,
685
+ ]);
686
+ }
687
+ async hasDeliveryIncident(serviceId, identityKey) {
688
+ const rows = await this.query(`SELECT 1 FROM serialized_dcb_delivery_incidents WHERE service_id = $1 AND identity_key = $2`, [serviceId, identityKey]);
689
+ return rows.length !== 0;
690
+ }
691
+ async listDeliveryIncidents(serviceId) {
692
+ const rows = serviceId === undefined
693
+ ? await this.query(`SELECT service_id, identity_key, classification, suid, existing_event_id, incoming_event_id, event_id,
694
+ bound_lineage_id, incoming_lineage_id, observed_at
695
+ FROM serialized_dcb_delivery_incidents
696
+ ORDER BY sequence ASC`)
697
+ : await this.query(`SELECT service_id, identity_key, classification, suid, existing_event_id, incoming_event_id, event_id,
698
+ bound_lineage_id, incoming_lineage_id, observed_at
699
+ FROM serialized_dcb_delivery_incidents
700
+ WHERE service_id = $1
701
+ ORDER BY sequence ASC`, [serviceId]);
702
+ return rows.map(incidentFrom);
703
+ }
704
+ async insertIncident(transaction, incident) {
705
+ await transaction.unsafe(`INSERT INTO serialized_dcb_delivery_incidents
706
+ (service_id, identity_key, classification, suid, existing_event_id, incoming_event_id, event_id,
707
+ bound_lineage_id, incoming_lineage_id, observed_at)
708
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
709
+ ON CONFLICT (service_id, identity_key) DO NOTHING`, [
710
+ incident.serviceId,
711
+ incident.identityKey,
712
+ incident.classification,
713
+ incident.suid ?? null,
714
+ incident.existingEventId ?? null,
715
+ incident.incomingEventId ?? null,
716
+ incident.eventId ?? null,
717
+ incident.boundLineageId ?? null,
718
+ incident.incomingLineageId ?? null,
719
+ incident.observedAt,
720
+ ]);
721
+ }
722
+ async hasFinding(serviceId, eventId, path, classification) {
723
+ const rows = await this.query(`SELECT 1
724
+ FROM serialized_dcb_inconsistency_findings
725
+ WHERE service_id = $1 AND event_id = $2 AND path = $3 AND classification = $4`, [serviceId, eventId, path, classification]);
726
+ return rows.length !== 0;
727
+ }
728
+ async listFindings(serviceId, eventId) {
729
+ const values = [];
730
+ const predicates = [];
731
+ if (serviceId !== undefined) {
732
+ values.push(serviceId);
733
+ predicates.push(`service_id = $${values.length}`);
734
+ }
735
+ if (eventId !== undefined) {
736
+ values.push(eventId);
737
+ predicates.push(`event_id = $${values.length}`);
738
+ }
739
+ const rows = await this.query(`SELECT service_id, event_id, path, classification, first_observed_at, lag_bound_ms, observed_at
740
+ FROM serialized_dcb_inconsistency_findings
741
+ ${predicates.length === 0 ? "" : `WHERE ${predicates.join(" AND ")}`}
742
+ ORDER BY sequence ASC`, values);
743
+ return rows.map(findingFrom);
744
+ }
745
+ requireSql() {
746
+ if (this.sql === undefined) {
747
+ throw new Error("PostgresEventStore.initialize() must complete before use");
748
+ }
749
+ return this.sql;
750
+ }
751
+ async query(statement, parameters = []) {
752
+ return (await this.requireSql().unsafe(statement, parameters));
753
+ }
754
+ async eventById(serviceId, eventId) {
755
+ const rows = await this.query(`SELECT e."ServiceId" AS service_id, e."Id"::text AS event_id,
756
+ e."SortableUniqueId" AS suid, e."Payload"::text AS payload,
757
+ e."EventType" AS event_type, e."Tags" AS event_tags,
758
+ e."Timestamp"::text AS timestamp, e."CausationId" AS causation_id,
759
+ e."CorrelationId" AS correlation_id, e."ExecutedUser" AS executed_user,
760
+ COALESCE(o."FirstArrivedAt", 0) AS first_arrived_at,
761
+ COALESCE(o."LastArrivedAt", 0) AS last_arrived_at,
762
+ COALESCE(o."MaxDeliveryLagMs", 0) AS max_delivery_lag_ms
763
+ FROM dcb_events e LEFT JOIN dcb_event_ops o
764
+ ON o."ServiceId" = e."ServiceId" AND o."Id" = e."Id"
765
+ WHERE e."ServiceId" = $1 AND e."Id" = $2::uuid`, [serviceId, eventId]);
766
+ const row = rows[0];
767
+ return row === undefined ? undefined : this.eventFromRow(row);
768
+ }
769
+ async eventFromRow(row) {
770
+ const serviceId = asString(row.service_id, "service_id");
771
+ const eventId = asString(row.event_id, "event_id");
772
+ const arrivals = await this.query(`SELECT service_id, event_id, tag, enqueued_at, arrived_at, lag_ms
773
+ FROM serialized_dcb_event_arrivals
774
+ WHERE service_id = $1 AND event_id = $2
775
+ ORDER BY tag ASC`, [serviceId, eventId]);
776
+ return {
777
+ serviceId,
778
+ id: eventId,
779
+ eventId,
780
+ sortableUniqueId: asString(row.suid, "suid"),
781
+ suid: asString(row.suid, "suid"),
782
+ payload: asString(row.payload, "payload"),
783
+ tags: asStringArray(row.event_tags, "event_tags"),
784
+ eventTags: asStringArray(row.event_tags, "event_tags"),
785
+ eventType: asString(row.event_type, "event_type"),
786
+ timestamp: canonicalUtcTimestamp(row.timestamp, "timestamp"),
787
+ causationId: nullableString(row.causation_id, "causation_id"),
788
+ correlationId: nullableString(row.correlation_id, "correlation_id"),
789
+ executedUser: nullableString(row.executed_user, "executed_user"),
790
+ provenance: "g32",
791
+ firstArrivedAt: asNumber(row.first_arrived_at, "first_arrived_at"),
792
+ lastArrivedAt: asNumber(row.last_arrived_at, "last_arrived_at"),
793
+ maxDeliveryLagMs: asNumber(row.max_delivery_lag_ms, "max_delivery_lag_ms"),
794
+ arrivals: arrivals.map((arrival) => ({
795
+ serviceId: asString(arrival.service_id, "service_id"),
796
+ eventId: asString(arrival.event_id, "event_id"),
797
+ tag: asString(arrival.tag, "tag"),
798
+ enqueuedAt: asNumber(arrival.enqueued_at, "enqueued_at"),
799
+ arrivedAt: asNumber(arrival.arrived_at, "arrived_at"),
800
+ lagMs: asNumber(arrival.lag_ms, "lag_ms"),
801
+ })),
802
+ };
803
+ }
804
+ async pendingById(serviceId, eventId) {
805
+ const rows = await this.query(`SELECT service_id, event_id, attempt_id, suid, expected_paths, observed_paths, first_observed_at, lag_bound_ms
806
+ FROM serialized_dcb_pending_arrivals
807
+ WHERE service_id = $1 AND event_id = $2`, [serviceId, eventId]);
808
+ const row = rows[0];
809
+ return row === undefined ? undefined : pendingFrom(row);
810
+ }
811
+ }