@sekiban/dcb-runtime 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +86 -0
- package/README.md +13 -0
- package/dist/allocator/AllocatorDurableObject.d.ts +36 -0
- package/dist/allocator/AllocatorDurableObject.js +658 -0
- package/dist/allocator/IssuanceLedger.d.ts +188 -0
- package/dist/allocator/IssuanceLedger.js +608 -0
- package/dist/allocator/IssuanceReconciler.d.ts +41 -0
- package/dist/allocator/IssuanceReconciler.js +191 -0
- package/dist/allocator/OrderClock.d.ts +41 -0
- package/dist/allocator/OrderClock.js +92 -0
- package/dist/allocator/SortableUniqueId.d.ts +46 -0
- package/dist/allocator/SortableUniqueId.js +156 -0
- package/dist/allocator/types.d.ts +52 -0
- package/dist/allocator/types.js +1 -0
- package/dist/bootstrap/BootstrapCoordinatorDurableObject.d.ts +28 -0
- package/dist/bootstrap/BootstrapCoordinatorDurableObject.js +357 -0
- package/dist/bootstrap/BootstrapStoreAdapter.d.ts +51 -0
- package/dist/bootstrap/BootstrapStoreAdapter.js +162 -0
- package/dist/bootstrap/OperatorBootstrap.d.ts +20 -0
- package/dist/bootstrap/OperatorBootstrap.js +104 -0
- package/dist/bootstrap/manifest.d.ts +9 -0
- package/dist/bootstrap/manifest.js +115 -0
- package/dist/bootstrap/types.d.ts +75 -0
- package/dist/bootstrap/types.js +1 -0
- package/dist/cli/OperatorRepairCli.d.ts +11 -0
- package/dist/cli/OperatorRepairCli.js +108 -0
- package/dist/cloudflare.d.ts +164 -0
- package/dist/cloudflare.js +19487 -0
- package/dist/commit/CommitWorker.d.ts +101 -0
- package/dist/commit/CommitWorker.js +921 -0
- package/dist/commit/types.d.ts +25 -0
- package/dist/commit/types.js +13 -0
- package/dist/completeness/GlobalCompletenessReconciler.d.ts +77 -0
- package/dist/completeness/GlobalCompletenessReconciler.js +572 -0
- package/dist/completeness/types.d.ts +85 -0
- package/dist/completeness/types.js +6 -0
- package/dist/composition.d.ts +123 -0
- package/dist/composition.js +237 -0
- package/dist/cosmos.d.ts +11 -0
- package/dist/cosmos.js +1411 -0
- package/dist/d1-mv.d.ts +8 -0
- package/dist/d1-mv.js +2001 -0
- package/dist/d1.d.ts +16 -0
- package/dist/d1.js +3418 -0
- package/dist/diagnostics/G60DurableHop.d.ts +99 -0
- package/dist/diagnostics/G60DurableHop.js +248 -0
- package/dist/diagnostics/G65Admission.d.ts +23 -0
- package/dist/diagnostics/G65Admission.js +43 -0
- package/dist/diagnostics/G65DirectRing.d.ts +33 -0
- package/dist/diagnostics/G65DirectRing.js +85 -0
- package/dist/diagnostics/G69AdmissionAttempt.d.ts +43 -0
- package/dist/diagnostics/G69AdmissionAttempt.js +22 -0
- package/dist/downstream/DeliveryCore.d.ts +127 -0
- package/dist/downstream/DeliveryCore.js +304 -0
- package/dist/downstream/Doorbell.d.ts +43 -0
- package/dist/downstream/Doorbell.js +137 -0
- package/dist/downstream/DownstreamAdapter.d.ts +52 -0
- package/dist/downstream/DownstreamAdapter.js +299 -0
- package/dist/downstream/ExclusionLookup.d.ts +36 -0
- package/dist/downstream/ExclusionLookup.js +50 -0
- package/dist/downstream/InconsistencyDetector.d.ts +27 -0
- package/dist/downstream/InconsistencyDetector.js +82 -0
- package/dist/downstream/OutboxDrain.d.ts +35 -0
- package/dist/downstream/OutboxDrain.js +126 -0
- package/dist/downstream/types.d.ts +77 -0
- package/dist/downstream/types.js +64 -0
- package/dist/eventIdentity.d.ts +30 -0
- package/dist/eventIdentity.js +49 -0
- package/dist/eventRecord.d.ts +26 -0
- package/dist/eventRecord.js +53 -0
- package/dist/g42-probe.d.ts +9 -0
- package/dist/g42-probe.js +9 -0
- package/dist/http/SerializedQueryWorker.d.ts +58 -0
- package/dist/http/SerializedQueryWorker.js +497 -0
- package/dist/http/commitResponse.d.ts +46 -0
- package/dist/http/commitResponse.js +80 -0
- package/dist/http/testServiceId.d.ts +17 -0
- package/dist/http/testServiceId.js +14 -0
- package/dist/index.d.ts +112 -0
- package/dist/index.js +19926 -0
- package/dist/journal/JournalDurableObject.d.ts +30 -0
- package/dist/journal/JournalDurableObject.js +481 -0
- package/dist/journal/JournalFirstTouchProbe.d.ts +190 -0
- package/dist/journal/JournalFirstTouchProbe.js +375 -0
- package/dist/journal/types.d.ts +116 -0
- package/dist/journal/types.js +52 -0
- package/dist/mv/MaterializedViewCatchUp.d.ts +60 -0
- package/dist/mv/MaterializedViewCatchUp.js +353 -0
- package/dist/mv/MaterializedViewRuntime.d.ts +80 -0
- package/dist/mv/MaterializedViewRuntime.js +328 -0
- package/dist/mv/MaterializedViewStore.d.ts +257 -0
- package/dist/mv/MaterializedViewStore.js +875 -0
- package/dist/mv/UnsafeWindowMaterializedView.d.ts +112 -0
- package/dist/mv/UnsafeWindowMaterializedView.js +495 -0
- package/dist/mv/index.d.ts +8 -0
- package/dist/mv/index.js +2357 -0
- package/dist/projection/LiveProjectionWorker.d.ts +86 -0
- package/dist/projection/LiveProjectionWorker.js +240 -0
- package/dist/projection/ProjectionRuntime.d.ts +113 -0
- package/dist/projection/ProjectionRuntime.js +346 -0
- package/dist/projection/ProjectorRegistry.d.ts +51 -0
- package/dist/projection/ProjectorRegistry.js +116 -0
- package/dist/query/ProjectionQueryStore.d.ts +102 -0
- package/dist/query/ProjectionQueryStore.js +185 -0
- package/dist/query/QueryRegistry.d.ts +22 -0
- package/dist/query/QueryRegistry.js +36 -0
- package/dist/read/SerializedReadWorker.d.ts +36 -0
- package/dist/read/SerializedReadWorker.js +225 -0
- package/dist/repair/RepairWorker.d.ts +72 -0
- package/dist/repair/RepairWorker.js +461 -0
- package/dist/safeWindow.d.ts +20 -0
- package/dist/safeWindow.js +36 -0
- package/dist/scope/ControlRouteScope.d.ts +22 -0
- package/dist/scope/ControlRouteScope.js +36 -0
- package/dist/scope/ScopeName.d.ts +34 -0
- package/dist/scope/ScopeName.js +67 -0
- package/dist/service/ServiceIdentityProvider.d.ts +35 -0
- package/dist/service/ServiceIdentityProvider.js +56 -0
- package/dist/store/CosmosEventStore.d.ts +126 -0
- package/dist/store/CosmosEventStore.js +950 -0
- package/dist/store/D1EventStore.d.ts +111 -0
- package/dist/store/D1EventStore.js +1111 -0
- package/dist/store/PostgresEventStore.d.ts +35 -0
- package/dist/store/PostgresEventStore.js +811 -0
- package/dist/store/provider.d.ts +25 -0
- package/dist/store/provider.js +32 -0
- package/dist/store/types.d.ts +205 -0
- package/dist/store/types.js +18 -0
- package/dist/tag/EventDigest.d.ts +40 -0
- package/dist/tag/EventDigest.js +158 -0
- package/dist/tag/TagDurableObject.d.ts +286 -0
- package/dist/tag/TagDurableObject.js +4071 -0
- package/dist/tag/TagSqlMeasurement.d.ts +26 -0
- package/dist/tag/TagSqlMeasurement.js +44 -0
- package/dist/tag/TagSqlSchema.d.ts +29 -0
- package/dist/tag/TagSqlSchema.js +211 -0
- package/dist/tag/types.d.ts +173 -0
- package/dist/tag/types.js +3 -0
- package/dist/tagstate/TagStateDurableObject.d.ts +57 -0
- package/dist/tagstate/TagStateDurableObject.js +499 -0
- package/dist/trace/AttributionRatio.d.ts +12 -0
- package/dist/trace/AttributionRatio.js +68 -0
- package/dist/trace/CloudflareTracing.d.ts +7 -0
- package/dist/trace/CloudflareTracing.js +9 -0
- package/dist/trace/CommitTrace.d.ts +405 -0
- package/dist/trace/CommitTrace.js +1008 -0
- package/dist/trace/CommitTraceConsoleSink.d.ts +68 -0
- package/dist/trace/CommitTraceConsoleSink.js +114 -0
- package/dist/trace/CommitTraceVerifier.d.ts +22 -0
- package/dist/trace/CommitTraceVerifier.js +295 -0
- package/dist/trace/ObservationStream.d.ts +118 -0
- package/dist/trace/ObservationStream.js +148 -0
- package/package.json +71 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { projectionIdFor } from "../projection/ProjectionRuntime";
|
|
2
|
+
/** Select a query backing without allowing an HTTP request to provide a store. */
|
|
3
|
+
export function selectQueryBacking(options) {
|
|
4
|
+
if (options.backing === "memory") {
|
|
5
|
+
if (options.memory === undefined)
|
|
6
|
+
throw new Error("A memory query backing is required when backing=memory");
|
|
7
|
+
return { backing: "memory", store: options.memory };
|
|
8
|
+
}
|
|
9
|
+
if (options.materializedView === undefined) {
|
|
10
|
+
throw new Error("A D1 materialized-view query backing is required when backing=d1-mv");
|
|
11
|
+
}
|
|
12
|
+
return { backing: "d1-mv", store: options.materializedView };
|
|
13
|
+
}
|
|
14
|
+
export const chooseQueryBacking = selectQueryBacking;
|
|
15
|
+
function isObject(value) {
|
|
16
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
function isNonEmptyString(value) {
|
|
19
|
+
return typeof value === "string" && value.length > 0;
|
|
20
|
+
}
|
|
21
|
+
/** V1 SUID ordering is bytewise ordinal, not locale or parsed timestamp order. */
|
|
22
|
+
export function compareSuid(left, right) {
|
|
23
|
+
const leftBytes = new TextEncoder().encode(left);
|
|
24
|
+
const rightBytes = new TextEncoder().encode(right);
|
|
25
|
+
const sharedLength = Math.min(leftBytes.length, rightBytes.length);
|
|
26
|
+
for (let index = 0; index < sharedLength; index += 1) {
|
|
27
|
+
const difference = leftBytes[index] - rightBytes[index];
|
|
28
|
+
if (difference !== 0) {
|
|
29
|
+
return difference;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return leftBytes.length - rightBytes.length;
|
|
33
|
+
}
|
|
34
|
+
function tagIdentity(tag, definition) {
|
|
35
|
+
const parts = tag.split(":");
|
|
36
|
+
if (parts.length !== 2 || parts.some((part) => part.length === 0) || parts[0] !== definition.tagGroup) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
tag,
|
|
41
|
+
tagGroup: parts[0],
|
|
42
|
+
tagContent: parts[1],
|
|
43
|
+
tagProjector: definition.tagProjector,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Internal query entries retain JSON text rather than reconstructing base64. */
|
|
47
|
+
function jsonText(value) {
|
|
48
|
+
return JSON.stringify(value);
|
|
49
|
+
}
|
|
50
|
+
function stateEntries(checkpoint) {
|
|
51
|
+
const decoded = JSON.parse(checkpoint.stateJson);
|
|
52
|
+
if (Array.isArray(decoded)) {
|
|
53
|
+
if (!decoded.every((entry) => isObject(entry) &&
|
|
54
|
+
isNonEmptyString(entry.eventId) &&
|
|
55
|
+
isNonEmptyString(entry.suid) &&
|
|
56
|
+
typeof entry.payload === "string")) {
|
|
57
|
+
throw new Error("Projected event-history state was malformed");
|
|
58
|
+
}
|
|
59
|
+
return decoded.map((entry) => ({
|
|
60
|
+
eventId: entry.eventId,
|
|
61
|
+
suid: entry.suid,
|
|
62
|
+
payload: entry.payload,
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
// A composed consumer may register a domain projector whose durable state
|
|
66
|
+
// is not the test event-history array. Preserve the fixed V1 query shape by
|
|
67
|
+
// exposing one deterministic snapshot entry for that tag projection; the
|
|
68
|
+
// source SUID remains the checkpoint's opaque ordering fact.
|
|
69
|
+
if (isObject(decoded)) {
|
|
70
|
+
return [{
|
|
71
|
+
eventId: `projection:${checkpoint.projectionId}`,
|
|
72
|
+
suid: checkpoint.lastSuid,
|
|
73
|
+
payload: jsonText(decoded),
|
|
74
|
+
}];
|
|
75
|
+
}
|
|
76
|
+
throw new Error("Projected state was malformed");
|
|
77
|
+
}
|
|
78
|
+
function sameEntry(left, right) {
|
|
79
|
+
return left.suid === right.suid && left.payload === right.payload;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Builds a query snapshot exclusively from durable read-side checkpoints.
|
|
83
|
+
* A recently appended tag with no safe checkpoint is deliberately absent,
|
|
84
|
+
* which keeps an already-open page window stable while SafeWindow holds it.
|
|
85
|
+
*/
|
|
86
|
+
export async function readProjectedSnapshot(store, serviceId, definition) {
|
|
87
|
+
const entriesByEventId = new Map();
|
|
88
|
+
let readHead = "";
|
|
89
|
+
const tags = await store.listProjectionTags(serviceId);
|
|
90
|
+
for (const tag of tags) {
|
|
91
|
+
const identity = tagIdentity(tag, definition);
|
|
92
|
+
if (identity === undefined) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const checkpoint = await store.readProjectionCheckpoint(serviceId, projectionIdFor(identity));
|
|
96
|
+
if (checkpoint === undefined) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
// The checkpoint is the authority for both the page rows and its safe
|
|
100
|
+
// head. In particular, an empty state still contributes its lastSuid.
|
|
101
|
+
if (compareSuid(checkpoint.lastSuid, readHead) > 0) {
|
|
102
|
+
readHead = checkpoint.lastSuid;
|
|
103
|
+
}
|
|
104
|
+
for (const serialized of stateEntries(checkpoint)) {
|
|
105
|
+
const entry = serialized;
|
|
106
|
+
const existing = entriesByEventId.get(entry.eventId);
|
|
107
|
+
if (existing !== undefined && !sameEntry(existing, entry)) {
|
|
108
|
+
throw new Error(`Projected EventId ${entry.eventId} has contradictory state`);
|
|
109
|
+
}
|
|
110
|
+
entriesByEventId.set(entry.eventId, entry);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
entries: [...entriesByEventId.values()].sort((left, right) => {
|
|
115
|
+
const bySuid = compareSuid(left.suid, right.suid);
|
|
116
|
+
return bySuid === 0 ? compareSuid(left.eventId, right.eventId) : bySuid;
|
|
117
|
+
}),
|
|
118
|
+
readHead,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export async function readProjectedEntries(store, serviceId, definition) {
|
|
122
|
+
return [...(await readProjectedSnapshot(store, serviceId, definition)).entries];
|
|
123
|
+
}
|
|
124
|
+
/** A requested SUID is observed only after a relevant durable source event and
|
|
125
|
+
* its mapped tag projector checkpoint both prove that observation. A mere
|
|
126
|
+
* source-row arrival is not enough to fabricate a query result.
|
|
127
|
+
*/
|
|
128
|
+
export async function projectionHasObserved(store, serviceId, definition, requestedSuid) {
|
|
129
|
+
const source = await store.readAllEvents(serviceId, "");
|
|
130
|
+
const target = source.find((event) => event.suid === requestedSuid);
|
|
131
|
+
if (target === undefined) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
for (const tag of target.eventTags) {
|
|
135
|
+
const identity = tagIdentity(tag, definition);
|
|
136
|
+
if (identity === undefined) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const checkpoint = await store.readProjectionCheckpoint(serviceId, projectionIdFor(identity));
|
|
140
|
+
if (checkpoint !== undefined && compareSuid(checkpoint.lastSuid, requestedSuid) >= 0) {
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
/** Query rows through the selected backing while preserving typed paging inputs. */
|
|
147
|
+
export async function readRowsFromBacking(selection, serviceId, viewId, definition, options = {}) {
|
|
148
|
+
if (selection.backing === "d1-mv") {
|
|
149
|
+
const rows = await selection.store.queryRows(serviceId, viewId, options);
|
|
150
|
+
return rows.map((row) => {
|
|
151
|
+
const value = row.value;
|
|
152
|
+
const eventId = isObject(value) && typeof value.eventId === "string" && value.eventId.length > 0
|
|
153
|
+
? value.eventId
|
|
154
|
+
: row.rowKey;
|
|
155
|
+
return { eventId, suid: row.sourceSuid, payload: jsonText(value) };
|
|
156
|
+
}).sort((left, right) => {
|
|
157
|
+
const bySuid = compareSuid(left.suid, right.suid);
|
|
158
|
+
return bySuid === 0 ? compareSuid(left.eventId, right.eventId) : bySuid;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const entries = await readProjectedEntries(selection.store, serviceId, definition);
|
|
162
|
+
return options.descending === true ? [...entries].reverse() : entries;
|
|
163
|
+
}
|
|
164
|
+
/** Page in the backing when it supports composed SQL, otherwise preserve the legacy port. */
|
|
165
|
+
export async function readRowsPageFromBacking(selection, serviceId, viewId, definition, options) {
|
|
166
|
+
if (selection.backing === "d1-mv" && selection.store.queryRowsWithTotal !== undefined) {
|
|
167
|
+
const page = await selection.store.queryRowsWithTotal(serviceId, viewId, options);
|
|
168
|
+
return {
|
|
169
|
+
entries: page.rows.map((row) => ({
|
|
170
|
+
eventId: isObject(row.value) && typeof row.value.eventId === "string" && row.value.eventId.length > 0 ? row.value.eventId : row.rowKey,
|
|
171
|
+
suid: row.sourceSuid,
|
|
172
|
+
payload: jsonText(row.value),
|
|
173
|
+
})),
|
|
174
|
+
totalCount: page.totalCount,
|
|
175
|
+
serverPaged: true,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
if (selection.backing === "memory") {
|
|
179
|
+
const snapshot = await readProjectedSnapshot(selection.store, serviceId, definition);
|
|
180
|
+
const entries = options.descending === true ? [...snapshot.entries].reverse() : [...snapshot.entries];
|
|
181
|
+
return { entries, totalCount: entries.length, serverPaged: false, readHead: snapshot.readHead };
|
|
182
|
+
}
|
|
183
|
+
const entries = await readRowsFromBacking(selection, serviceId, viewId, definition, options);
|
|
184
|
+
return { entries, totalCount: entries.length, serverPaged: false };
|
|
185
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type QueryEndpoint = "query" | "list-query";
|
|
2
|
+
/**
|
|
3
|
+
* Query types are deployed values, just like tag-state projectors. HTTP input
|
|
4
|
+
* selects one of these definitions; it cannot provide a reducer or query
|
|
5
|
+
* implementation of its own.
|
|
6
|
+
*/
|
|
7
|
+
export interface QueryDefinition {
|
|
8
|
+
readonly queryType: string;
|
|
9
|
+
readonly endpoint: QueryEndpoint;
|
|
10
|
+
readonly tagGroup: string;
|
|
11
|
+
readonly tagProjector: string;
|
|
12
|
+
/** MV view id used when the query backing is d1-mv; defaults to the projector id. */
|
|
13
|
+
readonly materializedViewId?: string;
|
|
14
|
+
readonly enabled: boolean;
|
|
15
|
+
}
|
|
16
|
+
export declare class QueryRegistry {
|
|
17
|
+
private readonly definitions;
|
|
18
|
+
constructor(definitions: readonly QueryDefinition[]);
|
|
19
|
+
resolve(queryType: string): QueryDefinition | undefined;
|
|
20
|
+
}
|
|
21
|
+
/** The test-only default query mapping; production consumers compose their own domain. */
|
|
22
|
+
export declare const DEPLOYED_QUERY_REGISTRY: QueryRegistry;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export class QueryRegistry {
|
|
2
|
+
definitions;
|
|
3
|
+
constructor(definitions) {
|
|
4
|
+
const mapped = new Map();
|
|
5
|
+
for (const definition of definitions) {
|
|
6
|
+
if (definition.queryType.length === 0 || definition.tagGroup.length === 0 || definition.tagProjector.length === 0) {
|
|
7
|
+
throw new Error("Query definitions require non-empty queryType, tagGroup, and tagProjector");
|
|
8
|
+
}
|
|
9
|
+
if (mapped.has(definition.queryType)) {
|
|
10
|
+
throw new Error(`Query ${definition.queryType} was registered more than once`);
|
|
11
|
+
}
|
|
12
|
+
mapped.set(definition.queryType, definition);
|
|
13
|
+
}
|
|
14
|
+
this.definitions = mapped;
|
|
15
|
+
}
|
|
16
|
+
resolve(queryType) {
|
|
17
|
+
return this.definitions.get(queryType);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** The test-only default query mapping; production consumers compose their own domain. */
|
|
21
|
+
export const DEPLOYED_QUERY_REGISTRY = new QueryRegistry([
|
|
22
|
+
{
|
|
23
|
+
queryType: "GetTestCountQuery",
|
|
24
|
+
endpoint: "query",
|
|
25
|
+
tagGroup: "test",
|
|
26
|
+
tagProjector: "test-projector",
|
|
27
|
+
enabled: true,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
queryType: "GetTestListQuery",
|
|
31
|
+
endpoint: "list-query",
|
|
32
|
+
tagGroup: "test",
|
|
33
|
+
tagProjector: "test-projector",
|
|
34
|
+
enabled: true,
|
|
35
|
+
},
|
|
36
|
+
]);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type ProjectorRegistry } from "../projection/ProjectorRegistry";
|
|
2
|
+
import type { StoreProvider } from "../store/provider";
|
|
3
|
+
import { type ServiceIdentityProvider } from "../service/ServiceIdentityProvider";
|
|
4
|
+
export { TEST_TAG_STATE_PROJECTOR } from "../projection/ProjectorRegistry";
|
|
5
|
+
interface ReadWorkerEnv {
|
|
6
|
+
TAG: DurableObjectNamespace;
|
|
7
|
+
/** SQLite-backed cache/replay owner for (serviceId, tag, projector). */
|
|
8
|
+
TAG_STATE?: DurableObjectNamespace;
|
|
9
|
+
POSTGRES_URL?: string;
|
|
10
|
+
HYPERDRIVE?: Hyperdrive;
|
|
11
|
+
/** Optional explicit D1 provider binding; Postgres remains the default. */
|
|
12
|
+
D1?: D1Database;
|
|
13
|
+
/** Set only by an authenticated deployment-verification lane. */
|
|
14
|
+
G11_VERIFICATION_ENABLED?: string;
|
|
15
|
+
/** Non-secret service identity configured per deployment. */
|
|
16
|
+
SDT_SERVICE_ID?: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* V1 read surface. Tag-state validates a deploy-time projector and delegates
|
|
20
|
+
* its cache/replay to TagStateDO. It intentionally does not call the D1
|
|
21
|
+
* SafeWindow authority: the Tag-local G43 source RPC is the single source on
|
|
22
|
+
* this path, while latest-sortable retains its existing SafeWindow rule.
|
|
23
|
+
*/
|
|
24
|
+
export declare class SerializedReadWorker {
|
|
25
|
+
private readonly env;
|
|
26
|
+
private readonly serviceId;
|
|
27
|
+
private readonly registry;
|
|
28
|
+
private readonly storeProvider?;
|
|
29
|
+
constructor(env: ReadWorkerEnv, serviceId: string, registry?: ProjectorRegistry, storeProvider?: StoreProvider | undefined);
|
|
30
|
+
handle(request: Request): Promise<Response>;
|
|
31
|
+
private latestSortable;
|
|
32
|
+
private tagState;
|
|
33
|
+
private ensureWindowDeterminate;
|
|
34
|
+
private readTag;
|
|
35
|
+
}
|
|
36
|
+
export declare function handleSerializedRead(request: Request, env: ReadWorkerEnv, registry?: ProjectorRegistry, storeProvider?: StoreProvider, serviceIdentityProvider?: ServiceIdentityProvider): Promise<Response>;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { DEPLOYED_PROJECTOR_REGISTRY, tagStateIdentitySyntaxFrom, } from "../projection/ProjectorRegistry";
|
|
2
|
+
import { safeWindowCeilingExceeded } from "../projection/ProjectionRuntime";
|
|
3
|
+
import { scopeIdFor, tagStateScopeIdentity } from "../scope/ScopeName";
|
|
4
|
+
import { envServiceIdentity, requestServiceIdentity, } from "../service/ServiceIdentityProvider";
|
|
5
|
+
export { TEST_TAG_STATE_PROJECTOR } from "../projection/ProjectorRegistry";
|
|
6
|
+
class ReadFailure extends Error {
|
|
7
|
+
failingSubCall;
|
|
8
|
+
diagnostic;
|
|
9
|
+
constructor(failingSubCall, cause, diagnostic) {
|
|
10
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
11
|
+
super(message);
|
|
12
|
+
this.failingSubCall = failingSubCall;
|
|
13
|
+
this.diagnostic = diagnostic;
|
|
14
|
+
this.name = cause instanceof Error ? cause.name : "UnknownError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function json(body, status = 200) {
|
|
18
|
+
return new Response(JSON.stringify(body), {
|
|
19
|
+
status,
|
|
20
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function error(status, code, message) {
|
|
24
|
+
return json({ error: message, code }, status);
|
|
25
|
+
}
|
|
26
|
+
function isObject(value) {
|
|
27
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
28
|
+
}
|
|
29
|
+
function isNonEmptyString(value) {
|
|
30
|
+
return typeof value === "string" && value.length > 0;
|
|
31
|
+
}
|
|
32
|
+
function tagFromBody(value) {
|
|
33
|
+
return isObject(value) && isNonEmptyString(value.tag)
|
|
34
|
+
? { value: value.tag }
|
|
35
|
+
: { error: "tag must be a non-empty string" };
|
|
36
|
+
}
|
|
37
|
+
function tagStateIdentityFromBody(value) {
|
|
38
|
+
if (!isObject(value) || !isNonEmptyString(value.tagStateId)) {
|
|
39
|
+
return { error: "tagStateId must be a non-empty string" };
|
|
40
|
+
}
|
|
41
|
+
return tagStateIdentitySyntaxFrom(value.tagStateId);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* V1 read surface. Tag-state validates a deploy-time projector and delegates
|
|
45
|
+
* its cache/replay to TagStateDO. It intentionally does not call the D1
|
|
46
|
+
* SafeWindow authority: the Tag-local G43 source RPC is the single source on
|
|
47
|
+
* this path, while latest-sortable retains its existing SafeWindow rule.
|
|
48
|
+
*/
|
|
49
|
+
export class SerializedReadWorker {
|
|
50
|
+
env;
|
|
51
|
+
serviceId;
|
|
52
|
+
registry;
|
|
53
|
+
storeProvider;
|
|
54
|
+
constructor(env, serviceId, registry = DEPLOYED_PROJECTOR_REGISTRY, storeProvider) {
|
|
55
|
+
this.env = env;
|
|
56
|
+
this.serviceId = serviceId;
|
|
57
|
+
this.registry = registry;
|
|
58
|
+
this.storeProvider = storeProvider;
|
|
59
|
+
}
|
|
60
|
+
async handle(request) {
|
|
61
|
+
const path = new URL(request.url).pathname;
|
|
62
|
+
if (request.method !== "POST") {
|
|
63
|
+
return error(404, "read_route_not_found", "Read routes require POST");
|
|
64
|
+
}
|
|
65
|
+
let body;
|
|
66
|
+
try {
|
|
67
|
+
body = await request.json();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return error(400, "validation_error", "Read request must be JSON");
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
if (path === "/api/sekiban/serialized/tag-latest-sortable") {
|
|
74
|
+
return await this.latestSortable(body);
|
|
75
|
+
}
|
|
76
|
+
if (path === "/api/sekiban/serialized/tag-state") {
|
|
77
|
+
return await this.tagState(body);
|
|
78
|
+
}
|
|
79
|
+
return error(404, "read_route_not_found", "Read route was not found");
|
|
80
|
+
}
|
|
81
|
+
catch (caught) {
|
|
82
|
+
const body = { error: "Read could not determine the durable tag state", code: "internal_error" };
|
|
83
|
+
if (this.env.G11_VERIFICATION_ENABLED === "true" && caught instanceof ReadFailure) {
|
|
84
|
+
body.detail = {
|
|
85
|
+
errorClass: caught.name,
|
|
86
|
+
message: caught.message,
|
|
87
|
+
failingSubCall: caught.failingSubCall,
|
|
88
|
+
...caught.diagnostic,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return json(body, 500);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async latestSortable(body) {
|
|
95
|
+
const parsed = tagFromBody(body);
|
|
96
|
+
if (parsed.value === undefined) {
|
|
97
|
+
return error(400, "validation_error", parsed.error ?? "Invalid tag-latest-sortable request");
|
|
98
|
+
}
|
|
99
|
+
await this.ensureWindowDeterminate();
|
|
100
|
+
const record = await this.readTag(parsed.value);
|
|
101
|
+
return json({
|
|
102
|
+
// Tag existence is the durable Tag record's fact, not a proxy for the
|
|
103
|
+
// record having emitted an event. A created/identified tag can have an
|
|
104
|
+
// empty head and must still be observable as existing.
|
|
105
|
+
exists: record !== undefined,
|
|
106
|
+
lastSortableUniqueId: record?.head ?? "",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
async tagState(body) {
|
|
110
|
+
const parsed = tagStateIdentityFromBody(body);
|
|
111
|
+
if (parsed.value === undefined) {
|
|
112
|
+
return error(400, "validation_error", parsed.error ?? "Invalid tag-state request");
|
|
113
|
+
}
|
|
114
|
+
const identity = parsed.value;
|
|
115
|
+
let projector;
|
|
116
|
+
try {
|
|
117
|
+
projector = this.registry.resolve(identity.tagProjector);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return error(503, "tag_state_projector_registry_failure", "Tag-state projector registry is unavailable");
|
|
121
|
+
}
|
|
122
|
+
if (projector === undefined) {
|
|
123
|
+
return error(404, "tag_state_unknown_projector", "Tag-state projector is not registered");
|
|
124
|
+
}
|
|
125
|
+
if (this.env.TAG_STATE === undefined) {
|
|
126
|
+
return error(503, "tag_state_source_frontier_failure", "Tag-state cache binding is unavailable");
|
|
127
|
+
}
|
|
128
|
+
const stateObject = this.env.TAG_STATE.get(scopeIdFor(this.env.TAG_STATE, {
|
|
129
|
+
serviceId: this.serviceId,
|
|
130
|
+
doClass: "tag-state",
|
|
131
|
+
identity: tagStateScopeIdentity(identity.tag, identity.tagProjector),
|
|
132
|
+
}));
|
|
133
|
+
let cached;
|
|
134
|
+
try {
|
|
135
|
+
cached = await stateObject.fetch(new Request("https://tag-state.internal/read", {
|
|
136
|
+
method: "POST",
|
|
137
|
+
headers: { "content-type": "application/json" },
|
|
138
|
+
body: JSON.stringify({
|
|
139
|
+
serviceId: this.serviceId,
|
|
140
|
+
tag: identity.tag,
|
|
141
|
+
projectorId: identity.tagProjector,
|
|
142
|
+
}),
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return error(503, "tag_state_source_frontier_failure", "Tag-state source cache could not be reached");
|
|
147
|
+
}
|
|
148
|
+
if (cached.status !== 200)
|
|
149
|
+
return cached;
|
|
150
|
+
let projected;
|
|
151
|
+
try {
|
|
152
|
+
projected = await cached.json();
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return error(503, "tag_state_cache_corrupt", "Tag-state cache returned an invalid result");
|
|
156
|
+
}
|
|
157
|
+
if (projected.kind !== "ready" || typeof projected.payload !== "string" ||
|
|
158
|
+
!Number.isSafeInteger(projected.version) || typeof projected.lastSortedUniqueId !== "string" ||
|
|
159
|
+
projected.projectorVersion !== projector.projectorVersion) {
|
|
160
|
+
return error(503, "tag_state_cache_corrupt", "Tag-state cache returned an inconsistent result");
|
|
161
|
+
}
|
|
162
|
+
return json({
|
|
163
|
+
payload: projected.payload,
|
|
164
|
+
version: projected.version,
|
|
165
|
+
lastSortedUniqueId: projected.lastSortedUniqueId,
|
|
166
|
+
tagGroup: identity.tagGroup,
|
|
167
|
+
tagContent: identity.tagContent,
|
|
168
|
+
tagProjector: identity.tagProjector,
|
|
169
|
+
tagPayloadName: projector.tagPayloadName,
|
|
170
|
+
projectorVersion: projector.projectorVersion,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
async ensureWindowDeterminate() {
|
|
174
|
+
// Direct unit tests can exercise Tag DO determinacy with only TAG. The
|
|
175
|
+
// default provider has no backing store in that fixture. An explicit
|
|
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
|
+
try {
|
|
181
|
+
const store = this.storeProvider.create(this.env);
|
|
182
|
+
await store.initialize();
|
|
183
|
+
const nowMs = Date.now();
|
|
184
|
+
const diagnostic = await store.lagBoundDiagnostics?.(this.serviceId, nowMs);
|
|
185
|
+
const dynamicLagBoundMs = diagnostic?.dynamicLagBoundMs ?? await store.currentLagBound(this.serviceId, nowMs);
|
|
186
|
+
if (safeWindowCeilingExceeded(dynamicLagBoundMs)) {
|
|
187
|
+
throw new ReadFailure("ensureWindowDeterminate", new Error("Read-side SafeWindow ceiling exceeded; durable state is indeterminate"), diagnostic);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch (caught) {
|
|
191
|
+
if (caught instanceof ReadFailure)
|
|
192
|
+
throw caught;
|
|
193
|
+
throw new ReadFailure("ensureWindowDeterminate", caught);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async readTag(tag) {
|
|
197
|
+
const url = new URL("https://serialized-read.internal/state");
|
|
198
|
+
url.searchParams.set("__tag", tag);
|
|
199
|
+
url.searchParams.set("__serviceId", this.serviceId);
|
|
200
|
+
const tagObject = this.env.TAG.get(scopeIdFor(this.env.TAG, {
|
|
201
|
+
serviceId: this.serviceId,
|
|
202
|
+
doClass: "tag",
|
|
203
|
+
identity: tag,
|
|
204
|
+
}));
|
|
205
|
+
try {
|
|
206
|
+
const response = await tagObject.fetch(new Request(url));
|
|
207
|
+
if (response.status === 404)
|
|
208
|
+
return undefined;
|
|
209
|
+
if (response.status !== 200)
|
|
210
|
+
throw new Error("Tag state lookup failed");
|
|
211
|
+
const record = (await response.json());
|
|
212
|
+
if (record.tag !== tag)
|
|
213
|
+
throw new Error("Tag identity changed during read");
|
|
214
|
+
return record;
|
|
215
|
+
}
|
|
216
|
+
catch (caught) {
|
|
217
|
+
throw new ReadFailure("readTag", caught);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
export async function handleSerializedRead(request, env, registry = DEPLOYED_PROJECTOR_REGISTRY, storeProvider, serviceIdentityProvider) {
|
|
222
|
+
return new SerializedReadWorker(env, requestServiceIdentity(request, serviceIdentityProvider ?? envServiceIdentity(env), {
|
|
223
|
+
allowG11Verification: env.G11_VERIFICATION_ENABLED === "true",
|
|
224
|
+
}), registry, storeProvider).handle(request);
|
|
225
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ExclusionLookupPort } from "../downstream/ExclusionLookup";
|
|
2
|
+
import { type CommitTraceClock, type CommitTraceProviderAdapter, type CommitTraceSink, type NativeTracing } from "../trace/CommitTrace";
|
|
3
|
+
export type RepairMode = "dry-run" | "execute";
|
|
4
|
+
/** Private repair.test interruption points, one for each durable saga boundary. */
|
|
5
|
+
export type RepairFault = "after-lease-before-prepare" | "after-prepare-before-apply" | "after-apply-before-observation" | "after-verify-before-audit" | "after-audit-before-clear" | "after-clear-before-final-observation";
|
|
6
|
+
export interface RepairWorkerEnv {
|
|
7
|
+
JOURNAL: DurableObjectNamespace;
|
|
8
|
+
TAG: DurableObjectNamespace;
|
|
9
|
+
SDT_SERVICE_ID?: string;
|
|
10
|
+
}
|
|
11
|
+
/** Observation seams only; repair RPC and durable facts are unchanged. */
|
|
12
|
+
export interface RepairWorkerHooks {
|
|
13
|
+
commitTraceSink?: CommitTraceSink;
|
|
14
|
+
commitTraceClock?: CommitTraceClock;
|
|
15
|
+
commitTraceProvider?: CommitTraceProviderAdapter;
|
|
16
|
+
nativeTracing?: NativeTracing;
|
|
17
|
+
}
|
|
18
|
+
export interface RepairExecutionInput {
|
|
19
|
+
attemptIds: string[];
|
|
20
|
+
tags: string[];
|
|
21
|
+
actor: string;
|
|
22
|
+
owner: string;
|
|
23
|
+
mode: RepairMode;
|
|
24
|
+
maxItems: number;
|
|
25
|
+
checkpoint?: string;
|
|
26
|
+
fault?: RepairFault;
|
|
27
|
+
}
|
|
28
|
+
export interface RepairExecutionResult {
|
|
29
|
+
mode: RepairMode;
|
|
30
|
+
dryRun: boolean;
|
|
31
|
+
checkpoint: string | null;
|
|
32
|
+
processed: number;
|
|
33
|
+
rolledForward: number;
|
|
34
|
+
excludedAudited: number;
|
|
35
|
+
failedClosed: number;
|
|
36
|
+
cleared: number;
|
|
37
|
+
interrupted?: RepairFault;
|
|
38
|
+
pending: number;
|
|
39
|
+
}
|
|
40
|
+
declare class RepairWorkerFailure extends Error {
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Per-request repair coordinator. The only authority it trusts is a fresh
|
|
44
|
+
* Tag DO fact read; Journal records are append-only observations.
|
|
45
|
+
*/
|
|
46
|
+
export declare class RepairWorker {
|
|
47
|
+
private readonly env;
|
|
48
|
+
private readonly exclusions;
|
|
49
|
+
/** Tests and embedding hosts may supply a fixed identity explicitly. */
|
|
50
|
+
private readonly serviceId;
|
|
51
|
+
private readonly hooks;
|
|
52
|
+
constructor(env: RepairWorkerEnv, exclusions: ExclusionLookupPort,
|
|
53
|
+
/** Tests and embedding hosts may supply a fixed identity explicitly. */
|
|
54
|
+
serviceId?: string, hooks?: RepairWorkerHooks);
|
|
55
|
+
execute(input: RepairExecutionInput): Promise<RepairExecutionResult>;
|
|
56
|
+
private executeTraced;
|
|
57
|
+
private enumerate;
|
|
58
|
+
private ensureLease;
|
|
59
|
+
private activeScope;
|
|
60
|
+
private unionScope;
|
|
61
|
+
private apply;
|
|
62
|
+
private audit;
|
|
63
|
+
private observe;
|
|
64
|
+
private recordClearedObservations;
|
|
65
|
+
private journalFor;
|
|
66
|
+
private tagFor;
|
|
67
|
+
private journalGet;
|
|
68
|
+
private journalPost;
|
|
69
|
+
private tagFacts;
|
|
70
|
+
private tagPost;
|
|
71
|
+
}
|
|
72
|
+
export { RepairWorkerFailure };
|