@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,299 @@
1
+ import { BindingExclusionLedgerClient } from "./ExclusionLookup";
2
+ import { InconsistencyDetector } from "./InconsistencyDetector";
3
+ import { processDeliveryCore, } from "./DeliveryCore";
4
+ import { isDownstreamOutboxMessage, systemPipelineClock, } from "./types";
5
+ import { GlobalCompletenessReconciler, globalReceiptAcknowledgement } from "../completeness/GlobalCompletenessReconciler";
6
+ import { observeG60PostAdmission } from "../diagnostics/G60DurableHop";
7
+ import { scopeIdFor } from "../scope/ScopeName";
8
+ import { envServiceIdentity, requireServiceIdentity } from "../service/ServiceIdentityProvider";
9
+ function observeSourceSubstep(observer, input) {
10
+ observeG60PostAdmission(observer, {
11
+ stage: "source-tag-acknowledgement",
12
+ boundary: input.boundary,
13
+ outcome: input.outcome,
14
+ serviceId: input.message.serviceId,
15
+ eventId: input.message.eventId,
16
+ suid: input.message.suid,
17
+ attemptId: input.message.attemptId,
18
+ partitionTag: input.message.tag,
19
+ transport: input.source,
20
+ observedAt: Date.now(),
21
+ });
22
+ }
23
+ function sourceAcknowledgementOptions(env, options) {
24
+ // D1 itself is the G44 global-array authority. Do not add a rollout flag:
25
+ // this is an in-place development schema, not a mixed-version protocol.
26
+ const hasG44Authority = env.D1 !== undefined && env.TAG !== undefined;
27
+ const sourceAcknowledgement = options.afterGlobalReceipt !== undefined || !hasG44Authority
28
+ ? options.afterGlobalReceipt
29
+ : async ({ message, receipt, arrivedAt }) => {
30
+ const tag = env.TAG.get(scopeIdFor(env.TAG, {
31
+ serviceId: message.serviceId,
32
+ doClass: "tag",
33
+ identity: message.tag,
34
+ }));
35
+ const url = new URL("https://downstream.internal/outbox/mark-delivered");
36
+ url.searchParams.set("__tag", message.tag);
37
+ url.searchParams.set("__serviceId", message.serviceId);
38
+ const response = await tag.fetch(new Request(url, {
39
+ method: "POST",
40
+ headers: { "content-type": "application/json" },
41
+ body: JSON.stringify(globalReceiptAcknowledgement(message, receipt.receivedAt || arrivedAt)),
42
+ }));
43
+ if (!response.ok)
44
+ throw new Error(`source_outbox_receipt_ack_failed:${response.status}`);
45
+ };
46
+ const coverage = hasG44Authority
47
+ ? async ({ message, event, arrivedAt, source }) => {
48
+ let completed = false;
49
+ await options.beforeViews?.({ message, event, arrivedAt, source });
50
+ observeG60PostAdmission(options.durableHopObserver, {
51
+ stage: "completeness-coverage",
52
+ boundary: "start",
53
+ outcome: "started",
54
+ serviceId: message.serviceId,
55
+ eventId: message.eventId,
56
+ suid: message.suid,
57
+ attemptId: message.attemptId,
58
+ partitionTag: message.tag,
59
+ transport: source,
60
+ observedAt: Date.now(),
61
+ });
62
+ try {
63
+ const decision = await new GlobalCompletenessReconciler(env.D1, env.TAG).coverageForObligation(message.serviceId, message.tag, message.completeness.obligationSequence, arrivedAt);
64
+ observeG60PostAdmission(options.durableHopObserver, {
65
+ stage: "completeness-coverage",
66
+ boundary: "end",
67
+ outcome: decision.kind,
68
+ serviceId: message.serviceId,
69
+ eventId: message.eventId,
70
+ suid: message.suid,
71
+ attemptId: message.attemptId,
72
+ partitionTag: message.tag,
73
+ transport: source,
74
+ observedAt: Date.now(),
75
+ });
76
+ completed = true;
77
+ if (decision.kind !== "SETTLED") {
78
+ throw new Error(`global_completeness_${decision.kind}:${decision.health.status}`);
79
+ }
80
+ }
81
+ catch (error) {
82
+ if (!completed) {
83
+ observeG60PostAdmission(options.durableHopObserver, {
84
+ stage: "completeness-coverage",
85
+ boundary: "end",
86
+ outcome: "error",
87
+ serviceId: message.serviceId,
88
+ eventId: message.eventId,
89
+ suid: message.suid,
90
+ attemptId: message.attemptId,
91
+ partitionTag: message.tag,
92
+ transport: source,
93
+ observedAt: Date.now(),
94
+ });
95
+ }
96
+ throw error;
97
+ }
98
+ }
99
+ : options.beforeViews;
100
+ const observedSourceAcknowledgement = sourceAcknowledgement === undefined
101
+ ? undefined
102
+ : async (input) => {
103
+ observeSourceSubstep(options.durableHopObserver, { ...input, boundary: "start", outcome: "started" });
104
+ try {
105
+ await sourceAcknowledgement(input);
106
+ observeSourceSubstep(options.durableHopObserver, { ...input, boundary: "end", outcome: "acknowledged" });
107
+ }
108
+ catch (error) {
109
+ observeSourceSubstep(options.durableHopObserver, { ...input, boundary: "end", outcome: "error" });
110
+ throw error;
111
+ }
112
+ };
113
+ const detectorFailure = hasG44Authority
114
+ ? async ({ message, arrivedAt, error, source }) => {
115
+ await options.onDetectorFailure?.({ message, arrivedAt, error, source });
116
+ await new GlobalCompletenessReconciler(env.D1, env.TAG).recordDetectorFailure(message.serviceId, error, arrivedAt);
117
+ }
118
+ : options.onDetectorFailure;
119
+ return {
120
+ ...options,
121
+ ...(observedSourceAcknowledgement === undefined ? {} : { afterGlobalReceipt: observedSourceAcknowledgement }),
122
+ ...(coverage === undefined ? {} : { beforeViews: coverage }),
123
+ ...(detectorFailure === undefined ? {} : { onDetectorFailure: detectorFailure }),
124
+ };
125
+ }
126
+ function sharedStore(env, provider) {
127
+ // The provider creates a request-scoped client. Never retain a client across
128
+ // Queue or scheduled invocations, or a later handler can attempt I/O on a
129
+ // stream owned by an earlier request.
130
+ return provider.create(env);
131
+ }
132
+ async function admitBootstrapRoute(env, serviceId, route) {
133
+ if (env.BOOTSTRAP === undefined) {
134
+ if (env.G38_DOORBELL_DELIVERY_ROLE === "receiver") {
135
+ const error = new Error(`bootstrap_route_binding_missing:${route}`);
136
+ error.name = "BootstrapRouteBindingMissingError";
137
+ throw error;
138
+ }
139
+ return;
140
+ }
141
+ const url = new URL("https://downstream.internal/route/check");
142
+ url.searchParams.set("__serviceId", serviceId);
143
+ const admitted = await env.BOOTSTRAP.get(scopeIdFor(env.BOOTSTRAP, {
144
+ serviceId,
145
+ doClass: "bootstrap",
146
+ identity: "coordinator",
147
+ })).fetch(new Request(url, {
148
+ method: "POST",
149
+ headers: { "content-type": "application/json" },
150
+ body: JSON.stringify({ route }),
151
+ }));
152
+ if (!admitted.ok)
153
+ throw new Error(`bootstrap_route_rejected:${route}`);
154
+ }
155
+ async function withStore(env, options, operation) {
156
+ if (options.store === undefined && options.storeProvider === undefined) {
157
+ throw new Error("A downstream store provider is not configured");
158
+ }
159
+ const store = options.store ?? sharedStore(env, options.storeProvider);
160
+ await store.initialize();
161
+ return operation(store, options.clock ?? systemPipelineClock);
162
+ }
163
+ /**
164
+ * Processes one direct delivery through the shared core. The caller receives a
165
+ * typed result; this function never acknowledges, retries, or marks an
166
+ * outbox row.
167
+ */
168
+ export async function processDownstreamDoorbell(message, env, options = {}) {
169
+ if (!isDownstreamOutboxMessage(message)) {
170
+ throw new Error("Doorbell contained an invalid outbox message");
171
+ }
172
+ options.durableHopObserver?.observe({
173
+ stage: "consumer-invocation-started",
174
+ serviceId: message.serviceId,
175
+ eventId: message.eventId,
176
+ suid: message.suid,
177
+ attemptId: message.attemptId,
178
+ partitionTag: message.tag,
179
+ transport: "fast",
180
+ observedAt: Date.now(),
181
+ });
182
+ await admitBootstrapRoute(env, message.serviceId, "fast");
183
+ return processDeliveryCore(message, "fast", env, sourceAcknowledgementOptions(env, options));
184
+ }
185
+ /** Processes one Queue delivery without exposing Queue state to the core. */
186
+ export async function processDownstreamDelivery(message, env, options = {}) {
187
+ if (!isDownstreamOutboxMessage(message)) {
188
+ throw new Error("Downstream Queue contained an invalid outbox message");
189
+ }
190
+ options.durableHopObserver?.observe({
191
+ stage: "consumer-invocation-started",
192
+ serviceId: message.serviceId,
193
+ eventId: message.eventId,
194
+ suid: message.suid,
195
+ attemptId: message.attemptId,
196
+ partitionTag: message.tag,
197
+ transport: "queue",
198
+ observedAt: Date.now(),
199
+ });
200
+ await admitBootstrapRoute(env, message.serviceId, "queue");
201
+ const outcome = await processDeliveryCore(message, "queue", env, sourceAcknowledgementOptions(env, options));
202
+ if (outcome.queueDisposition !== "ack") {
203
+ throw new Error(`downstream_delivery_retry:${outcome.correlationId}`);
204
+ }
205
+ }
206
+ /**
207
+ * Queue policy is deliberately limited to the wrapper: the shared core
208
+ * returns a disposition, and this is the only place that calls ack/retry.
209
+ */
210
+ export async function handleDownstreamQueue(batch, env, options = {}) {
211
+ // Validate each Queue ingress before acquiring a provider. In particular a
212
+ // non-G32 SUID must not even initialize the downstream store; this keeps
213
+ // the cutover's abort-before-write guarantee observable for Queue traffic.
214
+ const valid = [];
215
+ for (const queued of batch.messages) {
216
+ if (!isDownstreamOutboxMessage(queued.body)) {
217
+ console.warn("downstream_queue_delivery", { disposition: "retry-to-dlq" });
218
+ queued.retry();
219
+ continue;
220
+ }
221
+ valid.push(queued);
222
+ }
223
+ if (valid.length === 0)
224
+ return;
225
+ await withStore(env, options, async (store, clock) => {
226
+ for (const queued of valid) {
227
+ try {
228
+ options.durableHopObserver?.observe({
229
+ stage: "consumer-invocation-started",
230
+ serviceId: queued.body.serviceId,
231
+ eventId: queued.body.eventId,
232
+ suid: queued.body.suid,
233
+ attemptId: queued.body.attemptId,
234
+ partitionTag: queued.body.tag,
235
+ transport: "queue",
236
+ observedAt: Date.now(),
237
+ });
238
+ await admitBootstrapRoute(env, queued.body.serviceId, "queue");
239
+ const outcome = await processDeliveryCore(queued.body, "queue", env, {
240
+ ...sourceAcknowledgementOptions(env, options),
241
+ store,
242
+ clock,
243
+ g69AdmissionAttempt: {
244
+ queueMessageId: queued.id,
245
+ waitUntil: options.g69AdmissionAttemptWaitUntil,
246
+ },
247
+ });
248
+ console.log("downstream_queue_delivery", {
249
+ correlationId: outcome.correlationId,
250
+ coreDurationMs: outcome.coreDurationMs,
251
+ viewDurationsMs: outcome.views.map((view) => ({ id: view.id, durationMs: view.durationMs, status: view.status })),
252
+ disposition: outcome.queueDisposition,
253
+ failures: outcome.failures.map((failure) => ({ phase: failure.phase, class: failure.class, viewId: failure.viewId, error: failure.error })),
254
+ });
255
+ if (outcome.queueDisposition === "ack")
256
+ queued.ack();
257
+ else if (outcome.queueDisposition === "retry-once")
258
+ queued.retry();
259
+ else
260
+ queued.retry();
261
+ // A committed event still needs an event-driven safe-lane kick when
262
+ // G44 deliberately holds the ordinary views. A stored recordDelivery
263
+ // result includes idempotent Queue replays; each successful batch is
264
+ // eligible, while a recordDelivery failure is not. The hook is
265
+ // notification-only: it is never awaited, so its caller must
266
+ // register work through waitUntil and return.
267
+ if (outcome.outcome === "stored" && !outcome.failures.some((failure) => failure.phase === "recordDelivery")) {
268
+ try {
269
+ options.afterStoredQueueDelivery?.({ message: queued.body, result: outcome });
270
+ }
271
+ catch (error) {
272
+ // A lost kick is recoverable by cron and must never change the
273
+ // durable Queue ack/retry decision already made above.
274
+ console.warn("safe_lane_kick", {
275
+ status: "failed-to-schedule",
276
+ error: error instanceof Error ? error.message : String(error),
277
+ serviceId: queued.body.serviceId,
278
+ eventId: queued.body.eventId,
279
+ attemptId: queued.body.attemptId,
280
+ });
281
+ }
282
+ }
283
+ }
284
+ catch {
285
+ console.warn("downstream_queue_delivery", { disposition: "retry-to-dlq" });
286
+ queued.retry();
287
+ }
288
+ }
289
+ });
290
+ }
291
+ /** A scheduled/operator invocation can evaluate a stable pending record without new traffic. */
292
+ export async function stabilizeDownstream(env, options = {}, serviceId, serviceIdentityProvider) {
293
+ await admitBootstrapRoute(env, serviceId ?? requireServiceIdentity(serviceIdentityProvider ?? envServiceIdentity(env)), "scheduled");
294
+ await withStore(env, options, async (store, clock) => {
295
+ const detector = new InconsistencyDetector(store, new BindingExclusionLedgerClient(env.REPAIR_EXCLUSION_LOOKUP));
296
+ await detector.stabilize(clock, serviceId);
297
+ });
298
+ }
299
+ export { processDeliveryCore } from "./DeliveryCore";
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Provider-internal port used by SDT-G6 Branch B. It is intentionally a
3
+ * narrow binding client, not a Queue/Cosmos/Postgres adapter.
4
+ */
5
+ export interface ExclusionLookupInput {
6
+ attemptId: string;
7
+ tag: string;
8
+ eventId: string;
9
+ suid: string;
10
+ actor: string;
11
+ repairEpoch: number;
12
+ }
13
+ export interface ExclusionLookupPort {
14
+ recordExclusion(input: ExclusionLookupInput): Promise<void>;
15
+ }
16
+ export interface ExclusionLedgerLookupInput {
17
+ serviceId: string;
18
+ attemptId: string;
19
+ tag: string;
20
+ eventId: string;
21
+ suid: string;
22
+ }
23
+ /**
24
+ * Reads the same provider-internal exclusion ledger that SDT-G6 writes. A
25
+ * missing binding is an unknown path, never an invented exclusion.
26
+ */
27
+ export declare class BindingExclusionLedgerClient {
28
+ private readonly binding;
29
+ constructor(binding: Fetcher | undefined);
30
+ isExcludedAudited(input: ExclusionLedgerLookupInput): Promise<boolean>;
31
+ }
32
+ export declare class BindingExclusionLookupClient implements ExclusionLookupPort {
33
+ private readonly binding;
34
+ constructor(binding: Fetcher | undefined);
35
+ recordExclusion(input: ExclusionLookupInput): Promise<void>;
36
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Reads the same provider-internal exclusion ledger that SDT-G6 writes. A
3
+ * missing binding is an unknown path, never an invented exclusion.
4
+ */
5
+ export class BindingExclusionLedgerClient {
6
+ binding;
7
+ constructor(binding) {
8
+ this.binding = binding;
9
+ }
10
+ async isExcludedAudited(input) {
11
+ if (this.binding === undefined) {
12
+ return false;
13
+ }
14
+ const response = await this.binding.fetch("https://repair-exclusion.internal/exclusions/lookup", {
15
+ method: "POST",
16
+ headers: { "content-type": "application/json" },
17
+ body: JSON.stringify(input),
18
+ });
19
+ if (response.status === 404) {
20
+ return false;
21
+ }
22
+ if (!response.ok) {
23
+ throw new Error(`Repair exclusion lookup rejected detector with ${response.status}`);
24
+ }
25
+ const body = await response.json();
26
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
27
+ throw new Error("Repair exclusion lookup returned an invalid response");
28
+ }
29
+ return body.classification === "EXCLUDED_AUDITED";
30
+ }
31
+ }
32
+ export class BindingExclusionLookupClient {
33
+ binding;
34
+ constructor(binding) {
35
+ this.binding = binding;
36
+ }
37
+ async recordExclusion(input) {
38
+ if (this.binding === undefined) {
39
+ throw new Error("Repair exclusion lookup binding is not configured");
40
+ }
41
+ const response = await this.binding.fetch("https://repair-exclusion.internal/exclusions", {
42
+ method: "POST",
43
+ headers: { "content-type": "application/json" },
44
+ body: JSON.stringify(input),
45
+ });
46
+ if (!response.ok) {
47
+ throw new Error(`Repair exclusion lookup rejected Branch B with ${response.status}`);
48
+ }
49
+ }
50
+ }
@@ -0,0 +1,27 @@
1
+ import type { DownstreamOutboxMessage, PipelineClock } from "./types";
2
+ import type { DetectorStore } from "../store/types";
3
+ export declare const MIN_STABILITY_HORIZON_MS = 20000;
4
+ export interface ExclusionLedgerInput {
5
+ serviceId: string;
6
+ attemptId: string;
7
+ eventId: string;
8
+ suid: string;
9
+ tag: string;
10
+ }
11
+ export interface ExclusionLedgerPort {
12
+ isExcludedAudited(input: ExclusionLedgerInput): Promise<boolean>;
13
+ }
14
+ /**
15
+ * This component owns only pending/finding facts. Event rows are written by
16
+ * the adapter before this detector is invoked and are intentionally absent
17
+ * from DetectorStore.
18
+ */
19
+ export declare class InconsistencyDetector {
20
+ private readonly store;
21
+ private readonly exclusions;
22
+ constructor(store: DetectorStore, exclusions: ExclusionLedgerPort);
23
+ observe(message: DownstreamOutboxMessage, arrivedAt: number, dynamicLagBoundMs: number): Promise<void>;
24
+ stabilize(clock: PipelineClock, serviceId?: string): Promise<void>;
25
+ private classifyKnownExclusions;
26
+ private resolveLateArrivals;
27
+ }
@@ -0,0 +1,82 @@
1
+ export const MIN_STABILITY_HORIZON_MS = 20_000;
2
+ function sortedUnique(values) {
3
+ return [...new Set(values)].sort((left, right) => left.localeCompare(right));
4
+ }
5
+ function missingPaths(pending) {
6
+ const observed = new Set(pending.observedPaths);
7
+ return pending.expectedPaths.filter((path) => !observed.has(path));
8
+ }
9
+ function finding(pending, path, classification, observedAt) {
10
+ return {
11
+ serviceId: pending.serviceId,
12
+ eventId: pending.eventId,
13
+ path,
14
+ classification,
15
+ firstObservedAt: pending.firstObservedAt,
16
+ lagBoundMs: pending.lagBoundMs,
17
+ observedAt,
18
+ };
19
+ }
20
+ /**
21
+ * This component owns only pending/finding facts. Event rows are written by
22
+ * the adapter before this detector is invoked and are intentionally absent
23
+ * from DetectorStore.
24
+ */
25
+ export class InconsistencyDetector {
26
+ store;
27
+ exclusions;
28
+ constructor(store, exclusions) {
29
+ this.store = store;
30
+ this.exclusions = exclusions;
31
+ }
32
+ async observe(message, arrivedAt, dynamicLagBoundMs) {
33
+ const pending = await this.store.upsertPending({ ...message, eventTags: sortedUnique(message.eventTags) }, arrivedAt, Math.max(MIN_STABILITY_HORIZON_MS, dynamicLagBoundMs));
34
+ const excluded = await this.classifyKnownExclusions(pending, arrivedAt);
35
+ await this.resolveLateArrivals(pending, arrivedAt, excluded);
36
+ }
37
+ async stabilize(clock, serviceId) {
38
+ const now = clock.now();
39
+ for (const pending of await this.store.listPending(serviceId)) {
40
+ const excluded = await this.classifyKnownExclusions(pending, now);
41
+ const missing = missingPaths(pending);
42
+ if (missing.length === 0) {
43
+ await this.resolveLateArrivals(pending, now, excluded);
44
+ continue;
45
+ }
46
+ if (now < pending.firstObservedAt + Math.max(MIN_STABILITY_HORIZON_MS, pending.lagBoundMs)) {
47
+ continue;
48
+ }
49
+ for (const path of missing) {
50
+ if (!excluded.has(path)) {
51
+ await this.store.appendFinding(finding(pending, path, "MISSING_STABLE", now));
52
+ }
53
+ }
54
+ }
55
+ }
56
+ async classifyKnownExclusions(pending, observedAt) {
57
+ const excluded = new Set();
58
+ for (const path of missingPaths(pending)) {
59
+ if (await this.exclusions.isExcludedAudited({
60
+ serviceId: pending.serviceId,
61
+ attemptId: pending.attemptId,
62
+ eventId: pending.eventId,
63
+ suid: pending.suid,
64
+ tag: path,
65
+ })) {
66
+ excluded.add(path);
67
+ await this.store.appendFinding(finding(pending, path, "EXCLUDED_AUDITED", observedAt));
68
+ }
69
+ }
70
+ return excluded;
71
+ }
72
+ async resolveLateArrivals(pending, observedAt, excluded) {
73
+ if (missingPaths(pending).length !== 0) {
74
+ return;
75
+ }
76
+ for (const path of pending.expectedPaths) {
77
+ if (!excluded.has(path) && await this.store.hasFinding(pending.serviceId, pending.eventId, path, "MISSING_STABLE")) {
78
+ await this.store.appendFinding(finding(pending, path, "RESOLVED_LATE", observedAt));
79
+ }
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,35 @@
1
+ import { type DownstreamOutboxMessage, type PipelineClock } from "./types";
2
+ import type { G60DurableHopObserver } from "../diagnostics/G60DurableHop";
3
+ export interface OutboxDrainEnv {
4
+ TAG: DurableObjectNamespace;
5
+ DOWNSTREAM_QUEUE: Queue<DownstreamOutboxMessage>;
6
+ }
7
+ export interface DrainTagInput {
8
+ serviceId: string;
9
+ tag: string;
10
+ }
11
+ /**
12
+ * The ordinary runtime predates the G44 D1 global-array authority and keeps
13
+ * its historical transport acknowledgement for its private operational
14
+ * endpoint. The Cloudflare-only runtime opts into `global-receipt` below;
15
+ * that branch is deliberately not selected by a rollout flag.
16
+ */
17
+ export interface OutboxDrainOptions {
18
+ readonly acknowledgement?: "transport" | "global-receipt";
19
+ /** G60 observation is scheduled by the active Worker context. */
20
+ readonly durableHopObserver?: G60DurableHopObserver;
21
+ }
22
+ export interface DrainResult {
23
+ serviceId: string;
24
+ tag: string;
25
+ delivered: number;
26
+ }
27
+ /**
28
+ * A row gains its durable enqueuedAt fact before the first send. Queue send
29
+ * is only transport acceptance: G44 deliberately leaves the source pending
30
+ * until the receiver has atomically written and read back the global receipt
31
+ * plus committed-membership join. An interruption therefore replays safely.
32
+ */
33
+ export declare function drainTagOutbox(input: DrainTagInput, env: OutboxDrainEnv, clock?: PipelineClock, options?: OutboxDrainOptions): Promise<DrainResult>;
34
+ /** Internal operational trigger; it does not alter any V1 public endpoint. */
35
+ export declare function handleOutboxDrainRequest(request: Request, env: OutboxDrainEnv, options?: OutboxDrainOptions): Promise<Response>;
@@ -0,0 +1,126 @@
1
+ import { outboxIdentity, systemPipelineClock } from "./types";
2
+ import { scopeIdFor } from "../scope/ScopeName";
3
+ function json(body, status = 200) {
4
+ return new Response(JSON.stringify(body), {
5
+ status,
6
+ headers: { "content-type": "application/json; charset=utf-8" },
7
+ });
8
+ }
9
+ function isNonEmptyString(value) {
10
+ return typeof value === "string" && value.length > 0;
11
+ }
12
+ function isObject(value) {
13
+ return typeof value === "object" && value !== null && !Array.isArray(value);
14
+ }
15
+ function isDrainRequest(value) {
16
+ return isObject(value) && isNonEmptyString(value.serviceId) && Array.isArray(value.tags) &&
17
+ value.tags.length > 0 && value.tags.every(isNonEmptyString) && new Set(value.tags).size === value.tags.length;
18
+ }
19
+ function identity(message) {
20
+ return {
21
+ attemptId: message.attemptId,
22
+ eventId: message.eventId,
23
+ suid: message.suid,
24
+ allocatorLineageId: message.allocatorLineageId,
25
+ payload: message.payload,
26
+ eventType: message.eventType,
27
+ provenance: message.provenance,
28
+ timestamp: message.timestamp,
29
+ causationId: message.causationId,
30
+ correlationId: message.correlationId,
31
+ executedUser: message.executedUser,
32
+ // Legacy transport acknowledgement still has to present the full G43
33
+ // source fact because TagDurableObject rejects a lossy identity.
34
+ completeness: message.completeness,
35
+ enqueuedAt: message.enqueuedAt,
36
+ deliveredAt: null,
37
+ };
38
+ }
39
+ async function tagPost(env, input, path, body) {
40
+ const stub = env.TAG.get(scopeIdFor(env.TAG, {
41
+ serviceId: input.serviceId,
42
+ doClass: "tag",
43
+ identity: input.tag,
44
+ }));
45
+ const url = new URL(`https://outbox-drain.internal${path}`);
46
+ url.searchParams.set("__tag", input.tag);
47
+ url.searchParams.set("__serviceId", input.serviceId);
48
+ return stub.fetch(new Request(url, {
49
+ method: "POST",
50
+ headers: { "content-type": "application/json" },
51
+ body: JSON.stringify(body),
52
+ }));
53
+ }
54
+ /**
55
+ * A row gains its durable enqueuedAt fact before the first send. Queue send
56
+ * is only transport acceptance: G44 deliberately leaves the source pending
57
+ * until the receiver has atomically written and read back the global receipt
58
+ * plus committed-membership join. An interruption therefore replays safely.
59
+ */
60
+ export async function drainTagOutbox(input, env, clock = systemPipelineClock, options = {}) {
61
+ // Queue-triggered draining is an explicit retry attempt. The source's
62
+ // `next_attempt_at` controls automatic alarm wakes, but must not suppress a
63
+ // caller that is already actively draining the durable obligation.
64
+ const pending = await tagPost(env, input, "/outbox/pending", { nowMs: clock.now(), force: true });
65
+ if (!pending.ok) {
66
+ throw new Error(`Tag outbox pending read failed with ${pending.status}`);
67
+ }
68
+ const body = await pending.json();
69
+ let delivered = 0;
70
+ for (const row of body.rows) {
71
+ await env.DOWNSTREAM_QUEUE.send(row, { contentType: "json" });
72
+ options.durableHopObserver?.observe({
73
+ stage: "queue-send-returned",
74
+ serviceId: input.serviceId,
75
+ eventId: row.eventId,
76
+ suid: row.suid,
77
+ attemptId: row.attemptId,
78
+ partitionTag: input.tag,
79
+ transport: "queue",
80
+ observedAt: clock.now(),
81
+ });
82
+ if (options.acknowledgement === "global-receipt") {
83
+ // G44 Queue handoff is only transport acceptance. The D1 receiver
84
+ // performs the source acknowledgement after its atomic event,
85
+ // membership, and receipt join has been read back.
86
+ delivered += 1;
87
+ continue;
88
+ }
89
+ const mark = await tagPost(env, input, "/outbox/mark-delivered", {
90
+ deliveries: [identity(row)],
91
+ nowMs: clock.now(),
92
+ });
93
+ if (!mark.ok) {
94
+ throw new Error(`Tag outbox delivered mark failed with ${mark.status} for ${outboxIdentity(row)}`);
95
+ }
96
+ const markBody = await mark.json();
97
+ delivered += markBody.marked;
98
+ }
99
+ return { ...input, delivered };
100
+ }
101
+ /** Internal operational trigger; it does not alter any V1 public endpoint. */
102
+ export async function handleOutboxDrainRequest(request, env, options = {}) {
103
+ let body;
104
+ try {
105
+ body = await request.json();
106
+ }
107
+ catch {
108
+ return json({ error: "Outbox drain request must be JSON", code: "invalid_outbox_drain_request" }, 400);
109
+ }
110
+ if (!isDrainRequest(body)) {
111
+ return json({ error: "serviceId and unique non-empty tags are required", code: "invalid_outbox_drain_request" }, 400);
112
+ }
113
+ try {
114
+ const results = [];
115
+ for (const tag of body.tags) {
116
+ results.push(await drainTagOutbox({ serviceId: body.serviceId, tag }, env, systemPipelineClock, options));
117
+ }
118
+ return json({ results });
119
+ }
120
+ catch (error) {
121
+ return json({
122
+ error: error instanceof Error ? error.message : "Outbox drain failed",
123
+ code: "outbox_drain_failed",
124
+ }, 503);
125
+ }
126
+ }