@sekiban/dcb-runtime 0.2.0 → 0.2.1

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 (37) hide show
  1. package/README.md +5 -1
  2. package/dist/allocator/AllocatorDurableObject.js +25 -2
  3. package/dist/bootstrap/BootstrapCoordinatorDurableObject.d.ts +2 -0
  4. package/dist/bootstrap/BootstrapCoordinatorDurableObject.js +64 -1
  5. package/dist/bootstrap/types.d.ts +6 -0
  6. package/dist/cloudflare.d.ts +3 -0
  7. package/dist/cloudflare.js +1493 -451
  8. package/dist/commit/CommitWorker.d.ts +19 -0
  9. package/dist/commit/CommitWorker.js +176 -27
  10. package/dist/completeness/IncidentLifecycle.d.ts +75 -0
  11. package/dist/completeness/IncidentLifecycle.js +503 -0
  12. package/dist/completeness/types.d.ts +67 -0
  13. package/dist/cosmos.d.ts +34 -4
  14. package/dist/cosmos.js +214 -49
  15. package/dist/downstream/OutboxDrain.d.ts +4 -1
  16. package/dist/downstream/OutboxDrain.js +24 -2
  17. package/dist/generated/cosmos-layout.d.ts +87 -0
  18. package/dist/generated/cosmos-layout.js +99 -0
  19. package/dist/http/IncidentMaintenance.d.ts +8 -0
  20. package/dist/http/IncidentMaintenance.js +145 -0
  21. package/dist/index.d.ts +11 -0
  22. package/dist/index.js +1548 -451
  23. package/dist/read/SerializedReadWorker.d.ts +3 -0
  24. package/dist/read/SerializedReadWorker.js +3 -3
  25. package/dist/shard/ShardRotation.d.ts +48 -0
  26. package/dist/shard/ShardRotation.js +120 -0
  27. package/dist/store/CosmosEventStore.js +56 -46
  28. package/dist/store/PostgresEventStore.d.ts +20 -0
  29. package/dist/store/PostgresEventStore.js +53 -0
  30. package/dist/store/provider.d.ts +4 -0
  31. package/dist/tag/TagDurableObject.js +66 -14
  32. package/dist/tag/TagSqlSchema.d.ts +6 -1
  33. package/dist/tag/TagSqlSchema.js +15 -0
  34. package/dist/tag/types.d.ts +9 -0
  35. package/dist/trace/CommitTrace.d.ts +3 -3
  36. package/dist/trace/CommitTraceVerifier.js +1 -1
  37. package/package.json +6 -15
@@ -8,6 +8,9 @@ interface ReadWorkerEnv {
8
8
  TAG_STATE?: DurableObjectNamespace;
9
9
  POSTGRES_URL?: string;
10
10
  HYPERDRIVE?: Hyperdrive;
11
+ COSMOS_ENDPOINT?: string;
12
+ COSMOS_DATABASE?: string;
13
+ COSMOS_KEY?: string;
11
14
  /** Optional explicit D1 provider binding; Postgres remains the default. */
12
15
  D1?: D1Database;
13
16
  /** Set only by an authenticated deployment-verification lane. */
@@ -174,10 +174,10 @@ export class SerializedReadWorker {
174
174
  // Direct unit tests can exercise Tag DO determinacy with only TAG. The
175
175
  // default provider has no backing store in that fixture. An explicit
176
176
  // provider (including Cosmos) is always checked when it is configured.
177
- if (this.storeProvider === undefined || this.storeProvider.isConfigured?.(this.env) === false) {
178
- return;
179
- }
180
177
  try {
178
+ if (this.storeProvider === undefined || this.storeProvider.isConfigured?.(this.env) === false) {
179
+ return;
180
+ }
181
181
  const store = this.storeProvider.create(this.env);
182
182
  await store.initialize();
183
183
  const nowMs = Date.now();
@@ -0,0 +1,48 @@
1
+ export type SealedShard = Readonly<{
2
+ id: string;
3
+ lastSuid: string;
4
+ }>;
5
+ export type LateArrival = Readonly<{
6
+ shardId: string;
7
+ suid: string;
8
+ reason: "sealed-range";
9
+ }>;
10
+ export type ShardRotationState = Readonly<{
11
+ sealed: readonly SealedShard[];
12
+ active: string;
13
+ ledger: readonly LateArrival[];
14
+ }>;
15
+ export type ShardRotationDiagnostic = Readonly<{
16
+ code: "ORDER_INVARIANT" | "SEALED_WRITE" | "IDENTITY_SWAP" | "ACTIVE_COUNT" | "SEAL_MAX_MISSING" | "UNKNOWN_SHARD" | "SILENT_MERGE" | "SAFEWINDOW_SEAL";
17
+ path: string;
18
+ reason: string;
19
+ }>;
20
+ export declare class ShardRotationError extends Error {
21
+ readonly diagnostic: ShardRotationDiagnostic;
22
+ constructor(diagnostic: ShardRotationDiagnostic);
23
+ }
24
+ export declare function createRotationState(active: string): ShardRotationState;
25
+ /** Fail closed unless last(D1-1) < first(D1-2). */
26
+ export declare function assertStrictlyBefore(lastD1_1: string, firstD1_2: string): void;
27
+ export declare function maxSortableUniqueId(values: readonly string[]): string;
28
+ /**
29
+ * Normal rotation: derive last(D1-1) as the maximum SortableUniqueId present in
30
+ * the shard being sealed, append that sealed entry in order, then point active
31
+ * at exactly one new shard id that is not already sealed.
32
+ */
33
+ export declare function rotateAppend(state: ShardRotationState, sealedId: string, shardSuids: readonly string[], nextActiveId: string): ShardRotationState;
34
+ export declare function replaceSealedIdentity(state: ShardRotationState, index: number, nextId: string): never;
35
+ export declare function refuseSealedWrite(state: ShardRotationState, shardId: string): void;
36
+ /**
37
+ * First or later write into the active shard after a seal. Requires
38
+ * last(D1-1) < candidate. Does not write when the gate fails.
39
+ */
40
+ export declare function admitActiveWrite(state: ShardRotationState, shardId: string, firstOrNextSuid: string): void;
41
+ /**
42
+ * Late arrival under a sealed range. Appended only to the side ledger.
43
+ * Never writes back into the sealed shard.
44
+ */
45
+ export declare function recordLateArrival(state: ShardRotationState, sealedShardId: string, suid: string): ShardRotationState;
46
+ export declare function assertNoSilentLedgerMerge(source: string): void;
47
+ export declare function refuseSilentLedgerMerge(): never;
48
+ export declare function refuseSafeWindowSeal(): never;
@@ -0,0 +1,120 @@
1
+ import { assertSortableUniqueId, compareSortableUniqueId } from "../allocator/SortableUniqueId";
2
+ export class ShardRotationError extends Error {
3
+ diagnostic;
4
+ constructor(diagnostic) {
5
+ super(diagnostic.code);
6
+ this.name = "ShardRotationError";
7
+ this.diagnostic = diagnostic;
8
+ }
9
+ }
10
+ function fail(code, path, reason) {
11
+ throw new ShardRotationError({ code, path, reason });
12
+ }
13
+ export function createRotationState(active) {
14
+ if (typeof active !== "string" || active.length === 0)
15
+ fail("ACTIVE_COUNT", "active", "active-missing");
16
+ return { sealed: [], active, ledger: [] };
17
+ }
18
+ /** Fail closed unless last(D1-1) < first(D1-2). */
19
+ export function assertStrictlyBefore(lastD1_1, firstD1_2) {
20
+ assertSortableUniqueId(lastD1_1);
21
+ assertSortableUniqueId(firstD1_2);
22
+ if (compareSortableUniqueId(lastD1_1, firstD1_2) >= 0) {
23
+ fail("ORDER_INVARIANT", "last(D1-1)<first(D1-2)", "order-invariant");
24
+ }
25
+ }
26
+ export function maxSortableUniqueId(values) {
27
+ if (values.length === 0)
28
+ fail("SEAL_MAX_MISSING", "d1-1", "seal-max-missing");
29
+ let max = assertSortableUniqueId(values[0]).value;
30
+ for (let i = 1; i < values.length; i += 1) {
31
+ const next = assertSortableUniqueId(values[i]).value;
32
+ if (next > max)
33
+ max = next;
34
+ }
35
+ return max;
36
+ }
37
+ /**
38
+ * Normal rotation: derive last(D1-1) as the maximum SortableUniqueId present in
39
+ * the shard being sealed, append that sealed entry in order, then point active
40
+ * at exactly one new shard id that is not already sealed.
41
+ */
42
+ export function rotateAppend(state, sealedId, shardSuids, nextActiveId) {
43
+ if (sealedId !== state.active)
44
+ fail("UNKNOWN_SHARD", sealedId, "unknown-shard");
45
+ if (typeof nextActiveId !== "string" || nextActiveId.length === 0 || nextActiveId === sealedId) {
46
+ fail("ACTIVE_COUNT", "active", "active-count");
47
+ }
48
+ if (state.sealed.some((entry) => entry.id === nextActiveId)) {
49
+ fail("ACTIVE_COUNT", nextActiveId, "active-already-sealed");
50
+ }
51
+ if (state.sealed.some((entry) => entry.id === sealedId))
52
+ fail("IDENTITY_SWAP", sealedId, "identity-swap");
53
+ const lastSuid = maxSortableUniqueId(shardSuids);
54
+ return {
55
+ sealed: [...state.sealed, { id: sealedId, lastSuid }],
56
+ active: nextActiveId,
57
+ ledger: state.ledger,
58
+ };
59
+ }
60
+ export function replaceSealedIdentity(state, index, nextId) {
61
+ if (index < 0 || index >= state.sealed.length)
62
+ fail("UNKNOWN_SHARD", "sealed", "unknown-shard");
63
+ fail("IDENTITY_SWAP", state.sealed[index].id, `identity-swap:${nextId}`);
64
+ }
65
+ export function refuseSealedWrite(state, shardId) {
66
+ if (state.sealed.some((entry) => entry.id === shardId))
67
+ fail("SEALED_WRITE", shardId, "sealed-write");
68
+ }
69
+ /**
70
+ * First or later write into the active shard after a seal. Requires
71
+ * last(D1-1) < candidate. Does not write when the gate fails.
72
+ */
73
+ export function admitActiveWrite(state, shardId, firstOrNextSuid) {
74
+ assertSortableUniqueId(firstOrNextSuid);
75
+ refuseSealedWrite(state, shardId);
76
+ if (shardId !== state.active)
77
+ fail("UNKNOWN_SHARD", shardId, "unknown-shard");
78
+ if (state.sealed.length === 0)
79
+ return;
80
+ const previous = state.sealed[state.sealed.length - 1];
81
+ assertStrictlyBefore(previous.lastSuid, firstOrNextSuid);
82
+ }
83
+ /**
84
+ * Late arrival under a sealed range. Appended only to the side ledger.
85
+ * Never writes back into the sealed shard.
86
+ */
87
+ export function recordLateArrival(state, sealedShardId, suid) {
88
+ assertSortableUniqueId(suid);
89
+ const sealed = state.sealed.find((entry) => entry.id === sealedShardId);
90
+ if (sealed === undefined)
91
+ fail("UNKNOWN_SHARD", sealedShardId, "unknown-shard");
92
+ if (compareSortableUniqueId(suid, sealed.lastSuid) > 0) {
93
+ fail("ORDER_INVARIANT", sealedShardId, "not-late-arrival");
94
+ }
95
+ return {
96
+ ...state,
97
+ ledger: [...state.ledger, { shardId: sealed.id, suid, reason: "sealed-range" }],
98
+ };
99
+ }
100
+ const SILENT_MERGE_TOKENS = [
101
+ ["merge", "Ledger", "Into", "Sealed"],
102
+ ["merge", "OnRead", "Sealed"],
103
+ ["silent", "Merge", "Ledger"],
104
+ ["ledger", ".concat(", "sealed"],
105
+ ["sealed", ".concat(", "ledger"],
106
+ ];
107
+ export function assertNoSilentLedgerMerge(source) {
108
+ const lowered = source.toLowerCase();
109
+ for (const token of SILENT_MERGE_TOKENS) {
110
+ const needle = token.join("").toLowerCase();
111
+ if (lowered.includes(needle))
112
+ fail("SILENT_MERGE", "ledger", "silent-merge");
113
+ }
114
+ }
115
+ export function refuseSilentLedgerMerge() {
116
+ fail("SILENT_MERGE", "ledger", "silent-merge");
117
+ }
118
+ export function refuseSafeWindowSeal() {
119
+ fail("SAFEWINDOW_SEAL", "safe-window", "safewindow-seal");
120
+ }
@@ -2,44 +2,43 @@ import { resolveDeliveryIdentity } from "../eventIdentity";
2
2
  import { assertSortableUniqueId } from "../allocator/SortableUniqueId";
3
3
  import { CANONICAL_UTC_TIMESTAMP_PATTERN, isRfc4122Uuid, isUuidV7, serializedEventMetadata } from "../eventRecord";
4
4
  import { decayedLagEstimateMs } from "../safeWindow";
5
+ import { COSMOS_CONTAINER_LAYOUT, COSMOS_CONTAINER_NAMES, COSMOS_DOCUMENT_ID_RULES, } from "../generated/cosmos-layout";
5
6
  import { CanonicalEventIdentityConflictError, } from "./types";
6
7
  /** Sekiban.Dcb CosmosEvent's immutable logical-record partition. */
7
- export const COSMOS_EVENT_PARTITION_KEY_PATH = "/pk";
8
+ export const COSMOS_EVENT_PARTITION_KEY_PATH = COSMOS_CONTAINER_LAYOUT.events.partitionKeyPath;
8
9
  /** TS-only auxiliary documents retain a service-local partition. */
9
- export const COSMOS_AUXILIARY_PARTITION_KEY_PATH = "/serviceId";
10
+ export const COSMOS_AUXILIARY_PARTITION_KEY_PATH = COSMOS_CONTAINER_LAYOUT.lagEstimates.partitionKeyPath;
10
11
  /** @deprecated Kept as an alias for auxiliary-container callers. */
11
12
  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
- });
13
+ export const DEFAULT_COSMOS_CONTAINERS = COSMOS_CONTAINER_NAMES;
19
14
  /**
20
15
  * The layout is data rather than a comment so every adapter initialization and
21
16
  * contract fixture can inspect the same five service-partitioned containers.
22
17
  */
23
18
  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) => ({
19
+ return Object.keys(COSMOS_CONTAINER_LAYOUT).map((key) => {
20
+ const layout = COSMOS_CONTAINER_LAYOUT[key];
21
+ const name = containers[key];
22
+ if (key === "events") {
23
+ return {
24
+ name,
25
+ partitionKeyPath: layout.partitionKeyPath,
26
+ // The C# CosmosEvent is partitioned by the exact serviceId|id pair.
27
+ // Refuse a missing id here so an introspection fixture cannot mistake
28
+ // an auxiliary service partition for the logical event partition.
29
+ partitionKeyValue: (serviceId, id) => {
30
+ if (typeof id !== "string" || id.length === 0)
31
+ throw new Error("Cosmos event partition key requires an event id");
32
+ return eventPk(serviceId, id);
33
+ },
34
+ };
35
+ }
36
+ return {
38
37
  name,
39
- partitionKeyPath: COSMOS_AUXILIARY_PARTITION_KEY_PATH,
38
+ partitionKeyPath: layout.partitionKeyPath,
40
39
  partitionKeyValue: (serviceId) => serviceId,
41
- })),
42
- ];
40
+ };
41
+ });
43
42
  }
44
43
  export class CosmosClientError extends Error {
45
44
  status;
@@ -103,6 +102,13 @@ export function compareCosmosSuid(left, right) {
103
102
  function safeId(...parts) {
104
103
  return parts.map((part) => encodeURIComponent(part)).join("~");
105
104
  }
105
+ function rulePrefix(rule, fallback) {
106
+ return rule.match(/^safeId\("([^"]+)"/)?.[1] ?? fallback;
107
+ }
108
+ const EVENT_OPS_ID_PREFIX = rulePrefix(COSMOS_DOCUMENT_ID_RULES.events.eventSidecar, "event-ops");
109
+ const LINEAGE_ID_PREFIX = rulePrefix(COSMOS_DOCUMENT_ID_RULES.events.serviceGuards.allocatorLineageBinding, "allocator-lineage-binding");
110
+ const SUID_ID_PREFIX = rulePrefix(COSMOS_DOCUMENT_ID_RULES.events.serviceGuards.suidBinding, "suid-binding");
111
+ const INCIDENT_ID_PREFIX = rulePrefix(COSMOS_DOCUMENT_ID_RULES.events.serviceGuards.deliveryIncident, "incident");
106
112
  /** The exact CosmosEvent partition-key derivation from Sekiban.Dcb. */
107
113
  function eventPk(serviceId, id) {
108
114
  return `${serviceId}|${id}`;
@@ -362,19 +368,23 @@ export class CosmosRestClient {
362
368
  return buffer;
363
369
  })(), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
364
370
  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;
371
+ try {
372
+ return await this.fetcher(new URL(path, this.endpoint), {
373
+ method,
374
+ headers: {
375
+ accept: "application/json",
376
+ "content-type": "application/json",
377
+ "x-ms-date": date,
378
+ "x-ms-version": "2018-12-31",
379
+ authorization: signature,
380
+ ...extraHeaders,
381
+ },
382
+ body: body === undefined ? undefined : JSON.stringify(body),
383
+ });
384
+ }
385
+ catch {
386
+ throw new CosmosClientError(0, "Cosmos request failed before a response was received");
387
+ }
378
388
  }
379
389
  async errorFrom(response, prefix) {
380
390
  // Never include response bodies: Cosmos errors can echo account or query
@@ -655,7 +665,7 @@ export class CosmosEventStore {
655
665
  }
656
666
  async hasDeliveryIncident(serviceId, identityKey) {
657
667
  this.ready();
658
- return (await this.client.read(this.containers.events, safeId("incident", identityKey), auxiliaryPk(serviceId))) !== undefined;
668
+ return (await this.client.read(this.containers.events, safeId(INCIDENT_ID_PREFIX, identityKey), auxiliaryPk(serviceId))) !== undefined;
659
669
  }
660
670
  async listDeliveryIncidents(serviceId) {
661
671
  this.ready();
@@ -680,7 +690,7 @@ export class CosmosEventStore {
680
690
  }
681
691
  async bindLineage(serviceId, allocatorLineageId, boundAt) {
682
692
  const document = {
683
- id: safeId("allocator-lineage-binding"),
693
+ id: safeId(LINEAGE_ID_PREFIX),
684
694
  pk: auxiliaryPk(serviceId),
685
695
  serviceId,
686
696
  kind: "allocator-lineage-binding",
@@ -702,7 +712,7 @@ export class CosmosEventStore {
702
712
  * mutation. Historical rows are still checked by eventDocuments above.
703
713
  */
704
714
  async reserveSuid(serviceId, suid, eventId) {
705
- const id = safeId("suid-binding", suid);
715
+ const id = safeId(SUID_ID_PREFIX, suid);
706
716
  const existing = await this.client.read(this.containers.events, id, auxiliaryPk(serviceId));
707
717
  if (existing !== undefined)
708
718
  return existing.document.eventId;
@@ -728,7 +738,7 @@ export class CosmosEventStore {
728
738
  }
729
739
  async persistIncident(incident) {
730
740
  const document = {
731
- id: safeId("incident", incident.identityKey),
741
+ id: safeId(INCIDENT_ID_PREFIX, incident.identityKey),
732
742
  pk: auxiliaryPk(incident.serviceId),
733
743
  serviceId: incident.serviceId,
734
744
  kind: "delivery-incident",
@@ -753,7 +763,7 @@ export class CosmosEventStore {
753
763
  }
754
764
  async projectIncident(incident) {
755
765
  const document = {
756
- id: safeId("incident", incident.identityKey),
766
+ id: safeId(INCIDENT_ID_PREFIX, incident.identityKey),
757
767
  serviceId: incident.serviceId,
758
768
  eventId: incident.eventId ?? incident.incomingEventId ?? incident.identityKey,
759
769
  path: incident.classification,
@@ -799,7 +809,7 @@ export class CosmosEventStore {
799
809
  return rows;
800
810
  }
801
811
  async eventOps(serviceId, eventId) {
802
- const row = await this.client.read(this.containers.events, safeId("event-ops", eventId), eventPk(serviceId, eventId));
812
+ const row = await this.client.read(this.containers.events, safeId(EVENT_OPS_ID_PREFIX, eventId), eventPk(serviceId, eventId));
803
813
  return row?.document;
804
814
  }
805
815
  async mutateEvent(message, eventTags, eventType, timestamp, metadata) {
@@ -847,7 +857,7 @@ export class CosmosEventStore {
847
857
  throw new Error("Cosmos event CAS did not converge");
848
858
  }
849
859
  async mutateEventOps(message, arrivedAt, lagMs) {
850
- const id = safeId("event-ops", message.eventId);
860
+ const id = safeId(EVENT_OPS_ID_PREFIX, message.eventId);
851
861
  const pk = eventPk(message.serviceId, message.eventId);
852
862
  const nextArrival = {
853
863
  serviceId: message.serviceId,
@@ -1,5 +1,17 @@
1
1
  import type { DeliverySource, DownstreamOutboxMessage } from "../downstream/types";
2
2
  import { type DeliveryIncident, type DeliveryOutcome, type DetectorStore, type EventStore, type InconsistencyClassification, type InconsistencyFinding, type PendingArrivalRecord, type ProjectionCheckpoint, type ProjectionCheckpointAdvance, type ProjectionLag, type ProjectionStore, type StoredEvent } from "./types";
3
+ export interface LogicalProviderEvent {
4
+ readonly serviceId: string;
5
+ readonly id: string;
6
+ readonly sortableUniqueId: string;
7
+ readonly eventType: string;
8
+ readonly payload: string;
9
+ readonly tags: readonly string[];
10
+ readonly timestamp: string;
11
+ readonly causationId: string | null;
12
+ readonly correlationId: string | null;
13
+ readonly executedUser: string | null;
14
+ }
3
15
  /**
4
16
  * `postgres` exposes an ESM/workerd build. The deployed Worker passes a
5
17
  * Hyperdrive connection string; local Miniflare passes Docker PostgreSQL.
@@ -12,6 +24,14 @@ export declare class PostgresEventStore implements EventStore, DetectorStore, Pr
12
24
  /** Close an owned request/test client without touching durable records. */
13
25
  close(): Promise<void>;
14
26
  recordDelivery(message: DownstreamOutboxMessage, arrivedAt: number, deliverySource?: DeliverySource): Promise<DeliveryOutcome>;
27
+ /**
28
+ * DDL columns only. C# rows have no provenance and no allocator lineage;
29
+ * this read must not invent either, and it must not treat Tags as
30
+ * committed membership.
31
+ */
32
+ readLogicalEvents(serviceId: string): Promise<LogicalProviderEvent[]>;
33
+ /** Insert one DDL row. Does not write dcb_event_ops or a provenance stamp. */
34
+ appendLogicalEvent(event: LogicalProviderEvent): Promise<void>;
15
35
  readAllEvents(serviceId: string, since: string): Promise<StoredEvent[]>;
16
36
  currentLagBound(serviceId: string, nowMs?: number): Promise<number>;
17
37
  listProjectionTags(serviceId: string): Promise<string[]>;
@@ -480,6 +480,59 @@ export class PostgresEventStore {
480
480
  ? { outcome, kind: "stored", event: stored }
481
481
  : { outcome, kind: outcome, incident: incident };
482
482
  }
483
+ /**
484
+ * DDL columns only. C# rows have no provenance and no allocator lineage;
485
+ * this read must not invent either, and it must not treat Tags as
486
+ * committed membership.
487
+ */
488
+ async readLogicalEvents(serviceId) {
489
+ const rows = await this.query(`SELECT "ServiceId" AS service_id, "Id"::text AS event_id,
490
+ "SortableUniqueId" AS suid, "Payload"::text AS payload,
491
+ "EventType" AS event_type, "Tags" AS event_tags,
492
+ "Timestamp"::text AS timestamp, "CausationId" AS causation_id,
493
+ "CorrelationId" AS correlation_id, "ExecutedUser" AS executed_user
494
+ FROM dcb_events
495
+ WHERE "ServiceId" = $1
496
+ ORDER BY "SortableUniqueId" ASC, "Id" ASC`, [serviceId]);
497
+ return rows.map((row) => ({
498
+ serviceId: asString(row.service_id, "service_id"),
499
+ id: asString(row.event_id, "event_id"),
500
+ sortableUniqueId: asString(row.suid, "suid"),
501
+ eventType: asString(row.event_type, "event_type"),
502
+ payload: asString(row.payload, "payload"),
503
+ tags: asStringArray(row.event_tags, "event_tags"),
504
+ timestamp: canonicalUtcTimestamp(row.timestamp, "timestamp"),
505
+ causationId: nullableString(row.causation_id, "causation_id"),
506
+ correlationId: nullableString(row.correlation_id, "correlation_id"),
507
+ executedUser: nullableString(row.executed_user, "executed_user"),
508
+ }));
509
+ }
510
+ /** Insert one DDL row. Does not write dcb_event_ops or a provenance stamp. */
511
+ async appendLogicalEvent(event) {
512
+ JSON.parse(event.payload);
513
+ assertSortableUniqueId(event.sortableUniqueId);
514
+ if (!isRfc4122Uuid(event.id)) {
515
+ throw new CanonicalEventIdentityConflictError("postgres", event.id, "logical event id must be an RFC 4122 UUID");
516
+ }
517
+ assertUtcTimestamp(event.timestamp, event.id);
518
+ const sql = this.requireSql();
519
+ await sql.begin(async (transaction) => {
520
+ await transaction.unsafe(`INSERT INTO dcb_events
521
+ ("ServiceId", "Id", "SortableUniqueId", "EventType", "Payload", "Tags", "Timestamp", "CausationId", "CorrelationId", "ExecutedUser")
522
+ VALUES ($1, $2::uuid, $3, $4, $5::text::json, $6::text::jsonb, $7::timestamptz, $8, $9, $10)`, [
523
+ event.serviceId,
524
+ event.id,
525
+ event.sortableUniqueId,
526
+ event.eventType,
527
+ event.payload,
528
+ JSON.stringify(event.tags),
529
+ event.timestamp,
530
+ event.causationId,
531
+ event.correlationId,
532
+ event.executedUser,
533
+ ]);
534
+ });
535
+ }
483
536
  async readAllEvents(serviceId, since) {
484
537
  if (since.length !== 0)
485
538
  assertSortableUniqueId(since);
@@ -6,6 +6,10 @@ export interface StoreProviderEnvironment {
6
6
  readonly HYPERDRIVE?: {
7
7
  readonly connectionString?: string;
8
8
  };
9
+ /** Experimental Cosmos deployment values; the key is a secret binding. */
10
+ readonly COSMOS_ENDPOINT?: string;
11
+ readonly COSMOS_DATABASE?: string;
12
+ readonly COSMOS_KEY?: string;
9
13
  /** Explicit opt-in D1 binding; the default provider never requires it. */
10
14
  readonly D1?: D1Database;
11
15
  /** Separate opt-in MV D1 binding; never used by the PipelineStore provider. */
@@ -7,7 +7,7 @@ import { CANONICAL_UTC_TIMESTAMP_PATTERN, isRfc4122Uuid, isUuidV7, serializedEve
7
7
  import { DurableObjectActivation, enterNativeActorHandleSpan, noOpNativeTracing, } from "../trace/CommitTrace";
8
8
  import { beginDurableObjectHandlerObservation, } from "../trace/ObservationStream";
9
9
  import { canonicalDeclaredTagSet, eventDigestBytes, eventDigestHex } from "./EventDigest";
10
- import { hasTagSqlStorage, initializeTagSqlSchema, TAG_READ_AFTER_SQL, TAG_READ_AFTER_THROUGH_SQL, } from "./TagSqlSchema";
10
+ import { ensureTagCommitReceiptWrittenVersion, hasTagSqlStorage, initializeTagSqlSchema, TAG_READ_AFTER_SQL, TAG_READ_AFTER_THROUGH_SQL, } from "./TagSqlSchema";
11
11
  import { G43SqlMeasurement } from "./TagSqlMeasurement";
12
12
  import { recordDurableHop } from "../diagnostics/G60DurableHop";
13
13
  import { recordG65AdmissionAttempt } from "../diagnostics/G65Admission";
@@ -1723,6 +1723,7 @@ export class TagDurableObject {
1723
1723
  const sql = this.sqlStorage();
1724
1724
  if (sql === undefined)
1725
1725
  throw new Error("Tag SQL storage is unavailable");
1726
+ ensureTagCommitReceiptWrittenVersion(sql);
1726
1727
  const existing = sql.exec("SELECT tag FROM tag_identity WHERE singleton = 1").toArray()[0];
1727
1728
  if (existing !== undefined) {
1728
1729
  if (sqlString(existing.tag, "tag_identity.tag") !== tag)
@@ -1807,17 +1808,18 @@ export class TagDurableObject {
1807
1808
  sql.exec("UPDATE tag_control SET head_suid = ?, version = ?, updated_at = ? WHERE singleton = 1", head, version, committedAt);
1808
1809
  sql.exec("UPDATE tag_head SET service_id = ?, head_suid = ? WHERE singleton = 1", serviceId, head);
1809
1810
  }
1810
- writeCommittedSqlReceipt(sql, input, committedAt, eventCount, head, confirmsReservation) {
1811
+ writeCommittedSqlReceipt(sql, input, committedAt, eventCount, head, confirmsReservation, writtenVersion) {
1811
1812
  sql.exec(`
1812
1813
  INSERT INTO tag_commit_receipt
1813
- (attempt_id, epoch, committed_at, committed_event_count, head_suid, reservation_confirmed)
1814
- VALUES (?, ?, ?, ?, ?, ?)
1814
+ (attempt_id, epoch, committed_at, committed_event_count, head_suid, reservation_confirmed, written_version)
1815
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1815
1816
  ON CONFLICT(attempt_id, epoch) DO UPDATE SET
1816
1817
  committed_at = excluded.committed_at,
1817
1818
  committed_event_count = excluded.committed_event_count,
1818
1819
  head_suid = excluded.head_suid,
1819
- reservation_confirmed = excluded.reservation_confirmed
1820
- `, input.attemptId, input.epoch, committedAt, eventCount, head, confirmsReservation ? 1 : 0);
1820
+ reservation_confirmed = excluded.reservation_confirmed,
1821
+ written_version = excluded.written_version
1822
+ `, input.attemptId, input.epoch, committedAt, eventCount, head, confirmsReservation ? 1 : 0, writtenVersion);
1821
1823
  }
1822
1824
  /**
1823
1825
  * The normal append path deliberately probes only the indexed rows it needs
@@ -1868,8 +1870,35 @@ export class TagDurableObject {
1868
1870
  return { event, obligation, exact };
1869
1871
  });
1870
1872
  // Exact duplicate replay precedes all epoch/token checks by contract.
1873
+ // SDT-G36: replay the stored write transaction's own pair. Never mint
1874
+ // a timestamp and never substitute the current head version.
1871
1875
  if (existingCandidates.every(({ exact }) => exact)) {
1872
- return { status: 200, body: { status: "duplicate", version } };
1876
+ // Exact duplicate is before the epoch check, so a replay may name a
1877
+ // different epoch than the write that stored the receipt. Prefer the
1878
+ // caller's epoch, then the receipt whose head is this batch, then the
1879
+ // sole receipt for the attempt. A missing or NULL written_version
1880
+ // still fails closed.
1881
+ const receipts = sql.exec(`
1882
+ SELECT epoch, committed_at, written_version, head_suid FROM tag_commit_receipt
1883
+ WHERE attempt_id = ?
1884
+ `, input.attemptId).toArray();
1885
+ const sameEpoch = receipts.find((row) => sqlNumber(row.epoch, "tag_commit_receipt.epoch") === input.epoch);
1886
+ const replayHead = input.candidates.at(-1)?.suid;
1887
+ const byHead = replayHead === undefined
1888
+ ? []
1889
+ : receipts.filter((row) => sqlString(row.head_suid, "tag_commit_receipt.head_suid") === replayHead);
1890
+ const receipt = sameEpoch ?? (byHead.length === 1 ? byHead[0] : undefined) ?? (receipts.length === 1 ? receipts[0] : undefined);
1891
+ if (receipt === undefined) {
1892
+ await this.rearmScheduler(txn);
1893
+ return { ...rejected("duplicate_receipt_missing"), body: { ...rejected("duplicate_receipt_missing").body, version } };
1894
+ }
1895
+ const storedAt = sqlNullableString(receipt.committed_at, "tag_commit_receipt.committed_at");
1896
+ const storedVersion = sqlNullableNumber(receipt.written_version, "tag_commit_receipt.written_version");
1897
+ if (storedAt === null || storedVersion === null) {
1898
+ await this.rearmScheduler(txn);
1899
+ return { ...rejected("duplicate_receipt_unknown"), body: { ...rejected("duplicate_receipt_unknown").body, version } };
1900
+ }
1901
+ return { status: 200, body: { status: "duplicate", version: storedVersion, updatedAt: storedAt } };
1873
1902
  }
1874
1903
  const active = sql.exec("SELECT * FROM tag_reservation WHERE singleton = 1").toArray()[0];
1875
1904
  const now = sqlNullableNumber(control.clock_now_ms, "tag_control.clock_now_ms") ??
@@ -1976,7 +2005,7 @@ export class TagDurableObject {
1976
2005
  if (input.faultInjection === "after-append-before-confirm") {
1977
2006
  throw new AppendTransactionFault("Simulated interruption before atomic commit receipt");
1978
2007
  }
1979
- this.writeCommittedSqlReceipt(sql, input, committedAt, events.length, head, confirmsReservation);
2008
+ this.writeCommittedSqlReceipt(sql, input, committedAt, events.length, head, confirmsReservation, version + 1);
1980
2009
  if (confirmsReservation)
1981
2010
  sql.exec("DELETE FROM tag_reservation WHERE singleton = 1");
1982
2011
  await this.rearmScheduler(txn);
@@ -1986,6 +2015,7 @@ export class TagDurableObject {
1986
2015
  status: "appended",
1987
2016
  fenceGate: { checked: true, activeFenceCount: 0 },
1988
2017
  version: version + 1,
2018
+ updatedAt: committedAt,
1989
2019
  },
1990
2020
  hopFacts,
1991
2021
  };
@@ -2360,12 +2390,13 @@ export class TagDurableObject {
2360
2390
  `, eventServiceId, outbox.eventId, outbox.attemptId, outbox.suid, outbox.payload, outbox.allocatorLineageId, outbox.eventType, outbox.provenance, outbox.timestamp, artifact.canonicalBytes, artifact.eventDigest, artifact.declaredTagSet, artifact.localMembership, Date.now());
2361
2391
  this.upsertSourcePartitionRegistration(sql, eventServiceId, record.tag, outbox.eventId);
2362
2392
  }
2393
+ ensureTagCommitReceiptWrittenVersion(sql);
2363
2394
  for (const confirmation of record.confirmations) {
2364
2395
  sql.exec(`
2365
2396
  INSERT OR IGNORE INTO tag_commit_receipt
2366
- (attempt_id, epoch, committed_at, committed_event_count, head_suid, reservation_confirmed)
2367
- VALUES (?, ?, ?, ?, ?, 1)
2368
- `, confirmation.attemptId, confirmation.epoch, record.updatedAt, record.events.filter((event) => event.attemptId === confirmation.attemptId).length, record.head);
2397
+ (attempt_id, epoch, committed_at, committed_event_count, head_suid, reservation_confirmed, written_version)
2398
+ VALUES (?, ?, ?, ?, ?, 1, ?)
2399
+ `, confirmation.attemptId, confirmation.epoch, record.updatedAt, record.events.filter((event) => event.attemptId === confirmation.attemptId).length, record.head, record.version);
2369
2400
  }
2370
2401
  }
2371
2402
  nextSqlAlarmDue() {
@@ -2764,7 +2795,21 @@ export class TagDurableObject {
2764
2795
  let record = loaded.record;
2765
2796
  // Required order begins here: exact duplicate is before any epoch or token check.
2766
2797
  if (batchIsExactDuplicate(record, input)) {
2767
- return { status: 200, body: { status: "duplicate", version: record.version } };
2798
+ const receipts = record.writeReceipts ?? {};
2799
+ const sameEpoch = receipts[`${input.attemptId}:${input.epoch}`];
2800
+ const forAttempt = Object.entries(receipts).filter(([key]) => key.startsWith(`${input.attemptId}:`));
2801
+ const replayHead = input.candidates.at(-1)?.suid;
2802
+ const byHead = replayHead === undefined
2803
+ ? []
2804
+ : forAttempt.filter(([, value]) => value.headSuid === replayHead);
2805
+ const receipt = sameEpoch ?? (byHead.length === 1 ? byHead[0]?.[1] : undefined) ?? (forAttempt.length === 1 ? forAttempt[0]?.[1] : undefined);
2806
+ if (receipt === undefined) {
2807
+ return {
2808
+ ...rejected("duplicate_receipt_unknown"),
2809
+ body: { ...rejected("duplicate_receipt_unknown").body, version: record.version },
2810
+ };
2811
+ }
2812
+ return { status: 200, body: { status: "duplicate", version: receipt.version, updatedAt: receipt.updatedAt } };
2768
2813
  }
2769
2814
  const expiry = expireReservation(record);
2770
2815
  record = expiry.record;
@@ -2852,10 +2897,17 @@ export class TagDurableObject {
2852
2897
  ? withMaxEpoch(record.confirmations, input.attemptId, input.epoch)
2853
2898
  : record.confirmations,
2854
2899
  });
2855
- await this.write(txn, updated, serviceId ?? "");
2900
+ const recorded = {
2901
+ ...updated,
2902
+ writeReceipts: {
2903
+ ...(record.writeReceipts ?? {}),
2904
+ [`${input.attemptId}:${input.epoch}`]: { version: updated.version, updatedAt: updated.updatedAt, headSuid: updated.head },
2905
+ },
2906
+ };
2907
+ await this.write(txn, recorded, serviceId ?? "");
2856
2908
  return {
2857
2909
  status: 201,
2858
- body: { status: "appended", fenceGate, version: updated.version },
2910
+ body: { status: "appended", fenceGate, version: recorded.version, updatedAt: recorded.updatedAt },
2859
2911
  };
2860
2912
  });
2861
2913
  const response = json(result.body, result.status);