@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,875 @@
|
|
|
1
|
+
import { assertJsonValue, } from "@sekiban/dcb-core";
|
|
2
|
+
import { assertSortableUniqueId } from "../allocator/SortableUniqueId";
|
|
3
|
+
import { UnsafeWindowMaterializedViewStore } from "./UnsafeWindowMaterializedView";
|
|
4
|
+
export class MaterializedViewStoreError extends Error {
|
|
5
|
+
operation;
|
|
6
|
+
code;
|
|
7
|
+
constructor(operation, code, message, options) {
|
|
8
|
+
super(message, options);
|
|
9
|
+
this.operation = operation;
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.name = "MaterializedViewStoreError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** A stale MV checkpoint is a failed atomic call, never a successful no-op. */
|
|
15
|
+
export class MaterializedViewCasError extends MaterializedViewStoreError {
|
|
16
|
+
constructor(message = "Materialized-view checkpoint CAS did not match") {
|
|
17
|
+
super("apply", "MV_CAS_MISMATCH", message);
|
|
18
|
+
this.name = "MaterializedViewCasError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class MaterializedViewPromotionCasError extends MaterializedViewStoreError {
|
|
22
|
+
constructor(message = "Materialized-view active-generation CAS did not match") {
|
|
23
|
+
super("promote", "MV_PROMOTION_CAS_MISMATCH", message);
|
|
24
|
+
this.name = "MaterializedViewPromotionCasError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export class MaterializedViewPatchError extends MaterializedViewStoreError {
|
|
28
|
+
constructor(message = "Materialized-view JSON patch target row does not exist") {
|
|
29
|
+
super("apply", "MV_PATCH_ROW_MISSING", message);
|
|
30
|
+
this.name = "MaterializedViewPatchError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function asString(value, name) {
|
|
34
|
+
if (typeof value !== "string")
|
|
35
|
+
throw new Error(`MV ${name} was not a string`);
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
function asInteger(value, name) {
|
|
39
|
+
const number = typeof value === "number" ? value : Number(value);
|
|
40
|
+
if (!Number.isSafeInteger(number))
|
|
41
|
+
throw new Error(`MV ${name} was not a safe integer`);
|
|
42
|
+
return number;
|
|
43
|
+
}
|
|
44
|
+
function asStatus(value) {
|
|
45
|
+
if (value === "active" || value === "candidate" || value === "retired")
|
|
46
|
+
return value;
|
|
47
|
+
throw new Error("MV instance status was invalid");
|
|
48
|
+
}
|
|
49
|
+
/** V1 SUID comparison is bytewise ordinal, never locale-aware. */
|
|
50
|
+
function compareSuid(left, right) {
|
|
51
|
+
const leftBytes = new TextEncoder().encode(left);
|
|
52
|
+
const rightBytes = new TextEncoder().encode(right);
|
|
53
|
+
for (let index = 0; index < Math.min(leftBytes.length, rightBytes.length); index += 1) {
|
|
54
|
+
const difference = leftBytes[index] - rightBytes[index];
|
|
55
|
+
if (difference !== 0)
|
|
56
|
+
return difference;
|
|
57
|
+
}
|
|
58
|
+
return leftBytes.length - rightBytes.length;
|
|
59
|
+
}
|
|
60
|
+
function maxReflectedSuid(rows) {
|
|
61
|
+
let maximum = "";
|
|
62
|
+
for (const row of rows) {
|
|
63
|
+
if (maximum === "" || compareSuid(row.sourceSuid, maximum) > 0)
|
|
64
|
+
maximum = row.sourceSuid;
|
|
65
|
+
}
|
|
66
|
+
return maximum;
|
|
67
|
+
}
|
|
68
|
+
function instanceFrom(row) {
|
|
69
|
+
return {
|
|
70
|
+
serviceId: asString(row.service_id, "service_id"),
|
|
71
|
+
viewId: asString(row.view_id, "view_id"),
|
|
72
|
+
generation: asInteger(row.generation, "generation"),
|
|
73
|
+
status: asStatus(row.status),
|
|
74
|
+
lastSuid: asString(row.last_suid, "last_suid"),
|
|
75
|
+
definitionVersion: asInteger(row.definition_version, "definition_version"),
|
|
76
|
+
updatedAt: asInteger(row.updated_at, "updated_at"),
|
|
77
|
+
rebuildVerifiedAt: row.rebuild_verified_at === null || row.rebuild_verified_at === undefined
|
|
78
|
+
? null
|
|
79
|
+
: asInteger(row.rebuild_verified_at, "rebuild_verified_at"),
|
|
80
|
+
rebuildRecoveryId: row.rebuild_recovery_id === null || row.rebuild_recovery_id === undefined
|
|
81
|
+
? null
|
|
82
|
+
: asString(row.rebuild_recovery_id, "rebuild_recovery_id"),
|
|
83
|
+
rebuildIncidentIdentity: row.rebuild_incident_identity === null || row.rebuild_incident_identity === undefined
|
|
84
|
+
? null
|
|
85
|
+
: asString(row.rebuild_incident_identity, "rebuild_incident_identity"),
|
|
86
|
+
rebuildIncidentGeneration: row.rebuild_incident_generation === null || row.rebuild_incident_generation === undefined
|
|
87
|
+
? null
|
|
88
|
+
: asInteger(row.rebuild_incident_generation, "rebuild_incident_generation"),
|
|
89
|
+
rebuildSourceEventCount: row.rebuild_source_event_count === null || row.rebuild_source_event_count === undefined
|
|
90
|
+
? null
|
|
91
|
+
: asInteger(row.rebuild_source_event_count, "rebuild_source_event_count"),
|
|
92
|
+
rebuildSourceMaxSuid: row.rebuild_source_max_suid === null || row.rebuild_source_max_suid === undefined
|
|
93
|
+
? null
|
|
94
|
+
: asString(row.rebuild_source_max_suid, "rebuild_source_max_suid"),
|
|
95
|
+
rebuildSourceHistoryDigest: row.rebuild_source_history_digest === null || row.rebuild_source_history_digest === undefined
|
|
96
|
+
? null
|
|
97
|
+
: asString(row.rebuild_source_history_digest, "rebuild_source_history_digest"),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function valueType(value) {
|
|
101
|
+
if (value === "text" || value === "integer" || value === "real")
|
|
102
|
+
return value;
|
|
103
|
+
throw new Error("MV index value type was invalid");
|
|
104
|
+
}
|
|
105
|
+
function indexValue(row, type) {
|
|
106
|
+
if (type === "text")
|
|
107
|
+
return asString(row.text_value, "text_value");
|
|
108
|
+
const number = typeof row[`${type}_value`] === "number" ? row[`${type}_value`] : Number(row[`${type}_value`]);
|
|
109
|
+
if (!Number.isFinite(number) || (type === "integer" && !Number.isSafeInteger(number))) {
|
|
110
|
+
throw new Error(`MV ${type}_value was invalid`);
|
|
111
|
+
}
|
|
112
|
+
return number;
|
|
113
|
+
}
|
|
114
|
+
function operationId() {
|
|
115
|
+
return `mv-${crypto.randomUUID()}`;
|
|
116
|
+
}
|
|
117
|
+
function isCasFailure(error) {
|
|
118
|
+
const message = String(error);
|
|
119
|
+
return message.includes("mv_atomic_guards.checkpoint_match") || message.includes("MV_CAS_MISMATCH");
|
|
120
|
+
}
|
|
121
|
+
function isPatchFailure(error) {
|
|
122
|
+
return String(error).includes("CHECK constraint failed: checkpoint_match");
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Production D1 persistence for row-backed materialized views. Every apply
|
|
126
|
+
* call is a single predeclared D1 batch. The NOT NULL guard statement is
|
|
127
|
+
* deliberately first: if the expected checkpoint is stale it aborts the
|
|
128
|
+
* batch, so row/index/checkpoint regions cannot partially advance.
|
|
129
|
+
*/
|
|
130
|
+
export class D1MaterializedViewStore {
|
|
131
|
+
database;
|
|
132
|
+
initialized = false;
|
|
133
|
+
unsafe;
|
|
134
|
+
constructor(database) {
|
|
135
|
+
this.database = database;
|
|
136
|
+
this.unsafe = new UnsafeWindowMaterializedViewStore(database);
|
|
137
|
+
}
|
|
138
|
+
async initialize() {
|
|
139
|
+
if (this.initialized)
|
|
140
|
+
return;
|
|
141
|
+
await this.database.prepare("SELECT 1 FROM mv_instances LIMIT 1").all();
|
|
142
|
+
await this.database.prepare("SELECT 1 FROM mv_atomic_guards LIMIT 1").all();
|
|
143
|
+
await this.database.prepare("SELECT 1 FROM mv_checkpoint_ahead_findings LIMIT 1").all();
|
|
144
|
+
await this.database.prepare("SELECT 1 FROM mv_ordering_quarantines LIMIT 1").all();
|
|
145
|
+
this.initialized = true;
|
|
146
|
+
}
|
|
147
|
+
async createActive(input) {
|
|
148
|
+
this.ready("create-active");
|
|
149
|
+
const generation = input.generation ?? 0;
|
|
150
|
+
this.validateGeneration(generation, "create-active");
|
|
151
|
+
const lastSuid = input.lastSuid ?? "";
|
|
152
|
+
if (lastSuid.length > 0)
|
|
153
|
+
assertSortableUniqueId(lastSuid);
|
|
154
|
+
try {
|
|
155
|
+
await this.database.batch([
|
|
156
|
+
this.database.prepare(`INSERT INTO mv_instances
|
|
157
|
+
(service_id, view_id, generation, status, last_suid, definition_version, updated_at)
|
|
158
|
+
VALUES (?, ?, ?, 'active', ?, ?, ?)`).bind(input.serviceId, input.viewId, generation, lastSuid, input.definitionVersion, input.updatedAt),
|
|
159
|
+
this.database.prepare(`INSERT INTO mv_active_generations (service_id, view_id, generation, updated_at)
|
|
160
|
+
VALUES (?, ?, ?, ?)`).bind(input.serviceId, input.viewId, generation, input.updatedAt),
|
|
161
|
+
]);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
throw new MaterializedViewStoreError("create-active", "MV_INSTANCE_EXISTS", `Active materialized view already exists: ${String(error)}`, { cause: error });
|
|
165
|
+
}
|
|
166
|
+
return this.readInstance(input.serviceId, input.viewId, generation);
|
|
167
|
+
}
|
|
168
|
+
async createCandidate(input) {
|
|
169
|
+
this.ready("create-candidate");
|
|
170
|
+
this.validateGeneration(input.generation, "create-candidate");
|
|
171
|
+
if ((input.lastSuid ?? "").length > 0)
|
|
172
|
+
assertSortableUniqueId(input.lastSuid);
|
|
173
|
+
try {
|
|
174
|
+
await this.database.prepare(`INSERT INTO mv_instances
|
|
175
|
+
(service_id, view_id, generation, status, last_suid, definition_version, updated_at)
|
|
176
|
+
VALUES (?, ?, ?, 'candidate', ?, ?, ?)`).bind(input.serviceId, input.viewId, input.generation, input.lastSuid ?? "", input.definitionVersion, input.updatedAt).run();
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
throw new MaterializedViewStoreError("create-candidate", "MV_INSTANCE_EXISTS", `Candidate generation already exists: ${String(error)}`, { cause: error });
|
|
180
|
+
}
|
|
181
|
+
return this.readInstance(input.serviceId, input.viewId, input.generation);
|
|
182
|
+
}
|
|
183
|
+
async beginRebuild(input) {
|
|
184
|
+
this.ready("create-candidate");
|
|
185
|
+
const generation = input.generation ?? await this.nextGeneration(input.serviceId, input.viewId);
|
|
186
|
+
return this.createCandidate({ ...input, generation });
|
|
187
|
+
}
|
|
188
|
+
async nextGeneration(serviceId, viewId) {
|
|
189
|
+
this.ready("create-candidate");
|
|
190
|
+
const row = await this.database.prepare(`SELECT COALESCE(MAX(generation), -1) AS generation FROM mv_instances WHERE service_id = ? AND view_id = ?`).bind(serviceId, viewId).first();
|
|
191
|
+
return asInteger(row?.generation ?? -1, "generation") + 1;
|
|
192
|
+
}
|
|
193
|
+
async readInstance(serviceId, viewId, generation) {
|
|
194
|
+
this.ready("initialize");
|
|
195
|
+
const row = await this.database.prepare(`SELECT service_id, view_id, generation, status, last_suid, definition_version, updated_at,
|
|
196
|
+
rebuild_verified_at, rebuild_recovery_id, rebuild_incident_identity,
|
|
197
|
+
rebuild_incident_generation, rebuild_source_event_count,
|
|
198
|
+
rebuild_source_max_suid, rebuild_source_history_digest
|
|
199
|
+
FROM mv_instances WHERE service_id = ? AND view_id = ? AND generation = ?`).bind(serviceId, viewId, generation).first();
|
|
200
|
+
return row === null || row === undefined ? undefined : instanceFrom(row);
|
|
201
|
+
}
|
|
202
|
+
async readActive(serviceId, viewId) {
|
|
203
|
+
this.ready("initialize");
|
|
204
|
+
const row = await this.database.prepare(`SELECT instance.service_id, instance.view_id, instance.generation, instance.status,
|
|
205
|
+
instance.last_suid, instance.definition_version, instance.updated_at,
|
|
206
|
+
instance.rebuild_verified_at, instance.rebuild_recovery_id,
|
|
207
|
+
instance.rebuild_incident_identity, instance.rebuild_incident_generation,
|
|
208
|
+
instance.rebuild_source_event_count, instance.rebuild_source_max_suid,
|
|
209
|
+
instance.rebuild_source_history_digest
|
|
210
|
+
FROM mv_active_generations pointer
|
|
211
|
+
JOIN mv_instances instance
|
|
212
|
+
ON instance.service_id = pointer.service_id
|
|
213
|
+
AND instance.view_id = pointer.view_id
|
|
214
|
+
AND instance.generation = pointer.generation
|
|
215
|
+
WHERE pointer.service_id = ? AND pointer.view_id = ?`).bind(serviceId, viewId).first();
|
|
216
|
+
return row === null || row === undefined ? undefined : instanceFrom(row);
|
|
217
|
+
}
|
|
218
|
+
async readRows(serviceId, viewId, generation) {
|
|
219
|
+
this.ready("initialize");
|
|
220
|
+
const selected = generation ?? (await this.readActive(serviceId, viewId))?.generation;
|
|
221
|
+
if (selected === undefined)
|
|
222
|
+
return [];
|
|
223
|
+
const result = await this.database.prepare(`SELECT service_id, view_id, generation, row_key, value_json, row_version, source_suid
|
|
224
|
+
FROM mv_rows
|
|
225
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?
|
|
226
|
+
ORDER BY row_key COLLATE BINARY ASC`).bind(serviceId, viewId, selected).all();
|
|
227
|
+
return result.results.map((row) => this.rowFrom(row));
|
|
228
|
+
}
|
|
229
|
+
async readIndexEntries(serviceId, viewId, generation) {
|
|
230
|
+
this.ready("initialize");
|
|
231
|
+
const selected = generation ?? (await this.readActive(serviceId, viewId))?.generation;
|
|
232
|
+
if (selected === undefined)
|
|
233
|
+
return [];
|
|
234
|
+
const result = await this.database.prepare(`SELECT service_id, view_id, generation, index_id, value_type,
|
|
235
|
+
text_value, integer_value, real_value, row_key
|
|
236
|
+
FROM mv_index_entries
|
|
237
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?
|
|
238
|
+
ORDER BY index_id COLLATE BINARY ASC, value_type COLLATE BINARY ASC,
|
|
239
|
+
COALESCE(text_value, CAST(integer_value AS TEXT), CAST(real_value AS TEXT)) COLLATE BINARY ASC,
|
|
240
|
+
row_key COLLATE BINARY ASC`).bind(serviceId, viewId, selected).all();
|
|
241
|
+
return result.results.map((row) => this.indexFrom(row));
|
|
242
|
+
}
|
|
243
|
+
/** Read rows through a declared index without exposing SQL identifiers to callers. */
|
|
244
|
+
async queryRows(serviceId, viewId, options = {}) {
|
|
245
|
+
this.ready("initialize");
|
|
246
|
+
const selected = options.generation ?? (await this.readActive(serviceId, viewId))?.generation;
|
|
247
|
+
if (selected === undefined)
|
|
248
|
+
return [];
|
|
249
|
+
const limit = options.limit === undefined ? 100 : options.limit;
|
|
250
|
+
const offset = options.offset ?? 0;
|
|
251
|
+
if ((limit !== null && (!Number.isSafeInteger(limit) || limit < 0)) || !Number.isSafeInteger(offset) || offset < 0) {
|
|
252
|
+
throw new MaterializedViewStoreError("initialize", "MV_VALUE_INVALID", "MV query limit/offset must be non-negative integers");
|
|
253
|
+
}
|
|
254
|
+
if (options.indexId === undefined) {
|
|
255
|
+
const limitClause = limit === null ? "" : " LIMIT ? OFFSET ?";
|
|
256
|
+
const direction = options.descending === true ? "DESC" : "ASC";
|
|
257
|
+
const values = [serviceId, viewId, selected];
|
|
258
|
+
if (limit !== null)
|
|
259
|
+
values.push(limit, offset);
|
|
260
|
+
const result = await this.database.prepare(`SELECT service_id, view_id, generation, row_key, value_json, row_version, source_suid
|
|
261
|
+
FROM mv_rows
|
|
262
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?
|
|
263
|
+
ORDER BY row_key COLLATE BINARY ${direction}${limitClause}`).bind(...values).all();
|
|
264
|
+
return result.results.map((row) => this.rowFrom(row));
|
|
265
|
+
}
|
|
266
|
+
const valueType = options.valueType;
|
|
267
|
+
// COLLATE BINARY is meaningful for text and row-key tie breakers. SQLite
|
|
268
|
+
// keeps integer/real columns numerically ordered; the closed expression
|
|
269
|
+
// below remains static so request values never become SQL identifiers.
|
|
270
|
+
const orderColumn = valueType === "text"
|
|
271
|
+
? "index_entry.text_value"
|
|
272
|
+
: valueType === "integer"
|
|
273
|
+
? "index_entry.integer_value"
|
|
274
|
+
: valueType === "real"
|
|
275
|
+
? "index_entry.real_value"
|
|
276
|
+
: "index_entry.row_key";
|
|
277
|
+
const direction = options.descending === true ? "DESC" : "ASC";
|
|
278
|
+
const indexPredicate = options.indexId === undefined ? "" : " AND index_entry.index_id = ?";
|
|
279
|
+
const typePredicate = valueType === undefined ? "" : " AND index_entry.value_type = ?";
|
|
280
|
+
const values = [serviceId, viewId, selected];
|
|
281
|
+
if (options.indexId !== undefined)
|
|
282
|
+
values.push(options.indexId);
|
|
283
|
+
if (valueType !== undefined)
|
|
284
|
+
values.push(valueType);
|
|
285
|
+
if (limit !== null)
|
|
286
|
+
values.push(limit, offset);
|
|
287
|
+
const limitClause = limit === null ? "" : " LIMIT ? OFFSET ?";
|
|
288
|
+
const result = await this.database.prepare(`SELECT row.service_id, row.view_id, row.generation, row.row_key,
|
|
289
|
+
row.value_json, row.row_version, row.source_suid
|
|
290
|
+
FROM mv_index_entries index_entry
|
|
291
|
+
JOIN mv_rows row
|
|
292
|
+
ON row.service_id = index_entry.service_id
|
|
293
|
+
AND row.view_id = index_entry.view_id
|
|
294
|
+
AND row.generation = index_entry.generation
|
|
295
|
+
AND row.row_key = index_entry.row_key
|
|
296
|
+
WHERE row.service_id = ? AND row.view_id = ? AND row.generation = ?${indexPredicate}${typePredicate}
|
|
297
|
+
ORDER BY ${orderColumn} COLLATE BINARY ${direction}, row.row_key COLLATE BINARY ${direction}${limitClause}`).bind(...values).all();
|
|
298
|
+
return result.results.map((row) => this.rowFrom(row));
|
|
299
|
+
}
|
|
300
|
+
/** SDT-G23 composed server page; count and page rows share one statement. */
|
|
301
|
+
async queryRowsWithTotal(serviceId, viewId, options = {}) {
|
|
302
|
+
this.ready("initialize");
|
|
303
|
+
const selected = options.generation ?? (await this.readActive(serviceId, viewId))?.generation;
|
|
304
|
+
if (selected === undefined)
|
|
305
|
+
return { rows: [], totalCount: 0 };
|
|
306
|
+
// Scalar V1 queries pass `limit: null` to retain their legacy unpaged
|
|
307
|
+
// result. SQLite uses LIMIT -1 for that one-statement composed read.
|
|
308
|
+
const limit = options.limit === null ? -1 : options.limit === undefined ? 100 : options.limit;
|
|
309
|
+
const offset = options.offset ?? 0;
|
|
310
|
+
if (!Number.isSafeInteger(limit) || limit < -1 || !Number.isSafeInteger(offset) || offset < 0) {
|
|
311
|
+
throw new MaterializedViewStoreError("initialize", "MV_VALUE_INVALID", "MV composed query needs limit -1 or a non-negative limit/offset");
|
|
312
|
+
}
|
|
313
|
+
return this.unsafe.queryComposedPage(serviceId, viewId, selected, limit, offset, options.descending === true);
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* SDT-G55's explicit list-read port. Existing composed reads intentionally
|
|
317
|
+
* keep their G23 semantics; only this port can include unsafe rows, and it
|
|
318
|
+
* can include only rows newer than the active safe checkpoint.
|
|
319
|
+
*/
|
|
320
|
+
async readListPage(serviceId, viewId, options = {}) {
|
|
321
|
+
this.ready("initialize");
|
|
322
|
+
const active = await this.readActive(serviceId, viewId);
|
|
323
|
+
if (active === undefined)
|
|
324
|
+
return { rows: [], totalCount: 0, readHead: "" };
|
|
325
|
+
const selected = options.generation ?? active.generation;
|
|
326
|
+
const selectedInstance = selected === active.generation
|
|
327
|
+
? active
|
|
328
|
+
: await this.readInstance(serviceId, viewId, selected);
|
|
329
|
+
if (selectedInstance === undefined) {
|
|
330
|
+
throw new MaterializedViewStoreError("initialize", "MV_INSTANCE_MISSING", "Requested materialized-view generation is unavailable");
|
|
331
|
+
}
|
|
332
|
+
const limit = options.limit === null ? -1 : options.limit === undefined ? 100 : options.limit;
|
|
333
|
+
const offset = options.offset ?? 0;
|
|
334
|
+
if (!Number.isSafeInteger(limit) || limit < -1 || !Number.isSafeInteger(offset) || offset < 0) {
|
|
335
|
+
throw new MaterializedViewStoreError("initialize", "MV_VALUE_INVALID", "MV list query needs limit -1 or a non-negative limit/offset");
|
|
336
|
+
}
|
|
337
|
+
if (options.indexId !== undefined || options.valueType !== undefined) {
|
|
338
|
+
throw new MaterializedViewStoreError("initialize", "MV_VALUE_INVALID", "MV list query does not support an index selector");
|
|
339
|
+
}
|
|
340
|
+
const direction = options.descending === true ? "DESC" : "ASC";
|
|
341
|
+
const safeRows = `SELECT row_key, value_json, row_version, source_suid, 0 AS tombstone, 0 AS unsafe_layer FROM mv_rows
|
|
342
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?`;
|
|
343
|
+
const result = options.consistency === "unsafe"
|
|
344
|
+
? await this.database.prepare(`WITH candidates AS (
|
|
345
|
+
${safeRows}
|
|
346
|
+
UNION ALL
|
|
347
|
+
SELECT row_key, value_json, row_version, source_suid, tombstone, 1 AS unsafe_layer FROM mv_unsafe_rows
|
|
348
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?
|
|
349
|
+
AND source_suid COLLATE BINARY > ? COLLATE BINARY
|
|
350
|
+
), ranked AS (
|
|
351
|
+
SELECT *, ROW_NUMBER() OVER (PARTITION BY row_key ORDER BY source_suid COLLATE BINARY DESC, unsafe_layer ASC) AS winner_rank FROM candidates
|
|
352
|
+
), live AS (
|
|
353
|
+
SELECT *, COUNT(*) OVER() AS total_count FROM ranked WHERE winner_rank = 1 AND tombstone = 0
|
|
354
|
+
)
|
|
355
|
+
SELECT row_key, value_json, row_version, source_suid, total_count FROM live
|
|
356
|
+
ORDER BY source_suid COLLATE BINARY ${direction}, row_key COLLATE BINARY ${direction} LIMIT ? OFFSET ?`).bind(serviceId, viewId, selected, serviceId, viewId, selected, selectedInstance.lastSuid, limit, offset).all()
|
|
357
|
+
: await this.database.prepare(`WITH live AS (
|
|
358
|
+
SELECT row_key, value_json, row_version, source_suid, COUNT(*) OVER() AS total_count FROM mv_rows
|
|
359
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?
|
|
360
|
+
)
|
|
361
|
+
SELECT row_key, value_json, row_version, source_suid, total_count FROM live
|
|
362
|
+
ORDER BY source_suid COLLATE BINARY ${direction}, row_key COLLATE BINARY ${direction} LIMIT ? OFFSET ?`).bind(serviceId, viewId, selected, limit, offset).all();
|
|
363
|
+
const rows = result.results.map((row) => ({
|
|
364
|
+
serviceId,
|
|
365
|
+
viewId,
|
|
366
|
+
generation: selected,
|
|
367
|
+
rowKey: asString(row.row_key, "row_key"),
|
|
368
|
+
value: assertJsonValue(JSON.parse(asString(row.value_json, "value_json")), "state-persistence"),
|
|
369
|
+
rowVersion: asInteger(row.row_version, "row_version"),
|
|
370
|
+
sourceSuid: asString(row.source_suid, "source_suid"),
|
|
371
|
+
}));
|
|
372
|
+
return {
|
|
373
|
+
rows,
|
|
374
|
+
totalCount: result.results.length === 0 ? 0 : asInteger(result.results[0].total_count, "total_count"),
|
|
375
|
+
// Safe reads report the active checkpoint even for an empty page. Unsafe
|
|
376
|
+
// reads instead report only what this page actually reflected.
|
|
377
|
+
readHead: options.consistency === "unsafe" ? maxReflectedSuid(rows) : selectedInstance.lastSuid,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
/** Explicit unsafe port: callers must choose this exceptional immediate lane. */
|
|
381
|
+
unsafeWindow() {
|
|
382
|
+
this.ready("apply");
|
|
383
|
+
return this.unsafe;
|
|
384
|
+
}
|
|
385
|
+
/** Runs repairable unsafe GC after an idle or advancing safe catch-up pass. */
|
|
386
|
+
async collectUnsafeGarbage(serviceId, viewId, generation, definitionVersion, safeHead) {
|
|
387
|
+
this.ready("apply");
|
|
388
|
+
return this.unsafe.collectEligible(serviceId, viewId, generation, definitionVersion, safeHead);
|
|
389
|
+
}
|
|
390
|
+
async hasTargetReceipt(serviceId, viewId, eventId, suid) {
|
|
391
|
+
this.ready("initialize");
|
|
392
|
+
return this.unsafe.hasTargetReceipt(serviceId, viewId, eventId, suid);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Read all G31 wait facts in one static D1 statement. Every table access is
|
|
396
|
+
* keyed by service/view/active generation (and target identity where
|
|
397
|
+
* applicable); no row or source-history scan is permitted on this path.
|
|
398
|
+
*/
|
|
399
|
+
async readWaitForState(serviceId, viewId, target) {
|
|
400
|
+
this.ready("initialize");
|
|
401
|
+
const row = await this.database.prepare(`WITH active AS (
|
|
402
|
+
SELECT instance.generation, instance.definition_version, instance.last_suid
|
|
403
|
+
FROM mv_active_generations pointer
|
|
404
|
+
JOIN mv_instances instance
|
|
405
|
+
ON instance.service_id = pointer.service_id
|
|
406
|
+
AND instance.view_id = pointer.view_id
|
|
407
|
+
AND instance.generation = pointer.generation
|
|
408
|
+
WHERE pointer.service_id = ? AND pointer.view_id = ?
|
|
409
|
+
)
|
|
410
|
+
SELECT
|
|
411
|
+
(SELECT generation FROM active) AS active_generation,
|
|
412
|
+
(SELECT definition_version FROM active) AS active_definition_version,
|
|
413
|
+
COALESCE((SELECT last_suid FROM active), '') AS safe_contiguous_head,
|
|
414
|
+
CASE WHEN EXISTS (
|
|
415
|
+
SELECT 1
|
|
416
|
+
FROM mv_wait_receipts receipt
|
|
417
|
+
JOIN active
|
|
418
|
+
ON active.generation = receipt.generation
|
|
419
|
+
AND active.definition_version = receipt.definition_version
|
|
420
|
+
WHERE receipt.service_id = ? AND receipt.view_id = ?
|
|
421
|
+
AND receipt.event_id = ? AND receipt.suid = ?
|
|
422
|
+
) THEN 1 ELSE 0 END AS target_receipt,
|
|
423
|
+
CASE WHEN EXISTS (
|
|
424
|
+
SELECT 1
|
|
425
|
+
FROM mv_checkpoint_ahead_findings finding
|
|
426
|
+
JOIN active
|
|
427
|
+
ON active.generation = finding.generation
|
|
428
|
+
WHERE finding.service_id = ? AND finding.view_id = ?
|
|
429
|
+
) THEN 1 ELSE 0 END AS checkpoint_ahead,
|
|
430
|
+
COALESCE((
|
|
431
|
+
SELECT arrival.rebuild_required
|
|
432
|
+
FROM mv_unsafe_arrivals arrival
|
|
433
|
+
JOIN active
|
|
434
|
+
ON active.generation = arrival.generation
|
|
435
|
+
WHERE arrival.service_id = ? AND arrival.view_id = ?
|
|
436
|
+
), 0) AS rebuild_required,
|
|
437
|
+
CASE WHEN EXISTS (
|
|
438
|
+
SELECT 1
|
|
439
|
+
FROM mv_wait_target_poison finding
|
|
440
|
+
JOIN active
|
|
441
|
+
ON active.generation = finding.generation
|
|
442
|
+
AND active.definition_version = finding.definition_version
|
|
443
|
+
WHERE finding.service_id = ? AND finding.view_id = ?
|
|
444
|
+
AND finding.suid = ?
|
|
445
|
+
AND (? IS NULL OR finding.event_id = ?)
|
|
446
|
+
) THEN 1 ELSE 0 END AS poison`).bind(serviceId, viewId, serviceId, viewId, target.eventId ?? "", target.suid, serviceId, viewId, serviceId, viewId, serviceId, viewId, target.suid, target.eventId ?? null, target.eventId ?? null).first();
|
|
447
|
+
if (row === null || row === undefined) {
|
|
448
|
+
throw new MaterializedViewStoreError("initialize", "MV_STORE_OPERATION_FAILED", "D1 wait-state query returned no row");
|
|
449
|
+
}
|
|
450
|
+
const generation = row.active_generation === null || row.active_generation === undefined
|
|
451
|
+
? undefined
|
|
452
|
+
: asInteger(row.active_generation, "active_generation");
|
|
453
|
+
const definitionVersion = row.active_definition_version === null || row.active_definition_version === undefined
|
|
454
|
+
? undefined
|
|
455
|
+
: asInteger(row.active_definition_version, "active_definition_version");
|
|
456
|
+
return {
|
|
457
|
+
activeGeneration: generation,
|
|
458
|
+
activeDefinitionVersion: definitionVersion,
|
|
459
|
+
safeContiguousHead: asString(row.safe_contiguous_head, "safe_contiguous_head"),
|
|
460
|
+
targetReceipt: asInteger(row.target_receipt, "target_receipt") === 1,
|
|
461
|
+
checkpointAhead: asInteger(row.checkpoint_ahead, "checkpoint_ahead") === 1,
|
|
462
|
+
rebuildRequired: asInteger(row.rebuild_required, "rebuild_required") === 1,
|
|
463
|
+
poison: asInteger(row.poison, "poison") === 1,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
/** Idempotent operational fact: a stored Queue event needs unsafe retry/DLQ attention. */
|
|
467
|
+
async recordUnsafeFailureFinding(input) {
|
|
468
|
+
this.ready("apply");
|
|
469
|
+
const legacyFinding = this.database.prepare(`INSERT INTO mv_unsafe_failure_findings
|
|
470
|
+
(service_id, view_id, event_id, suid, classification, observed_at)
|
|
471
|
+
VALUES (?, ?, ?, ?, 'UNSAFE_APPLY_RETRY', ?)
|
|
472
|
+
ON CONFLICT (service_id, view_id, event_id, suid, classification) DO NOTHING`).bind(input.serviceId, input.viewId, input.eventId, input.suid, input.observedAt);
|
|
473
|
+
const targetPoison = input.generation === undefined
|
|
474
|
+
? this.database.prepare(`INSERT INTO mv_wait_target_poison
|
|
475
|
+
(service_id, view_id, generation, definition_version, event_id, suid, classification, observed_at)
|
|
476
|
+
SELECT instance.service_id, instance.view_id, instance.generation, instance.definition_version, ?, ?, 'UNSAFE_APPLY_RETRY', ?
|
|
477
|
+
FROM mv_active_generations active
|
|
478
|
+
JOIN mv_instances instance
|
|
479
|
+
ON instance.service_id = active.service_id
|
|
480
|
+
AND instance.view_id = active.view_id
|
|
481
|
+
AND instance.generation = active.generation
|
|
482
|
+
WHERE active.service_id = ? AND active.view_id = ?
|
|
483
|
+
ON CONFLICT DO NOTHING`).bind(input.eventId, input.suid, input.observedAt, input.serviceId, input.viewId)
|
|
484
|
+
: this.database.prepare(`INSERT INTO mv_wait_target_poison
|
|
485
|
+
(service_id, view_id, generation, definition_version, event_id, suid, classification, observed_at)
|
|
486
|
+
SELECT instance.service_id, instance.view_id, instance.generation, instance.definition_version, ?, ?, 'UNSAFE_APPLY_RETRY', ?
|
|
487
|
+
FROM mv_instances instance
|
|
488
|
+
WHERE instance.service_id = ? AND instance.view_id = ? AND instance.generation = ?
|
|
489
|
+
ON CONFLICT DO NOTHING`).bind(input.eventId, input.suid, input.observedAt, input.serviceId, input.viewId, input.generation);
|
|
490
|
+
await this.database.batch([legacyFinding, targetPoison]);
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Persist the source-reset observation exactly once for the affected
|
|
494
|
+
* generation. This deliberately never rewinds `mv_instances.last_suid`.
|
|
495
|
+
*/
|
|
496
|
+
async recordCheckpointAhead(input) {
|
|
497
|
+
this.ready("apply");
|
|
498
|
+
await this.database.prepare(`INSERT INTO mv_checkpoint_ahead_findings
|
|
499
|
+
(service_id, view_id, generation, checkpoint_suid, store_max_suid, observed_at)
|
|
500
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
501
|
+
ON CONFLICT (service_id, view_id, generation) DO NOTHING`).bind(input.serviceId, input.viewId, input.generation, input.checkpointSuid, input.storeMaxSuid, input.observedAt).run();
|
|
502
|
+
}
|
|
503
|
+
/** An open finding on the active generation fail-closes composed MV reads. */
|
|
504
|
+
async hasCheckpointAheadFinding(serviceId, viewId) {
|
|
505
|
+
this.ready("initialize");
|
|
506
|
+
const result = await this.database.prepare(`SELECT 1 AS present
|
|
507
|
+
FROM mv_checkpoint_ahead_findings finding
|
|
508
|
+
JOIN mv_active_generations active
|
|
509
|
+
ON active.service_id = finding.service_id
|
|
510
|
+
AND active.view_id = finding.view_id
|
|
511
|
+
AND active.generation = finding.generation
|
|
512
|
+
WHERE finding.service_id = ? AND finding.view_id = ?
|
|
513
|
+
LIMIT 1`).bind(serviceId, viewId).first();
|
|
514
|
+
return result?.present === 1;
|
|
515
|
+
}
|
|
516
|
+
/** Open quarantine is keyed to the active generation; old generations are not a public read gate. */
|
|
517
|
+
async readOrderingQuarantine(serviceId, viewId) {
|
|
518
|
+
this.ready("initialize");
|
|
519
|
+
const row = await this.database.prepare(`SELECT quarantine.service_id, quarantine.view_id, quarantine.generation,
|
|
520
|
+
quarantine.checkpoint_suid, quarantine.late_suid, quarantine.event_id,
|
|
521
|
+
quarantine.classification, quarantine.status, quarantine.observed_at,
|
|
522
|
+
quarantine.resolved_at, quarantine.incident_identity
|
|
523
|
+
FROM mv_ordering_quarantines quarantine
|
|
524
|
+
JOIN mv_active_generations active
|
|
525
|
+
ON active.service_id = quarantine.service_id
|
|
526
|
+
AND active.view_id = quarantine.view_id
|
|
527
|
+
AND active.generation = quarantine.generation
|
|
528
|
+
WHERE quarantine.service_id = ? AND quarantine.view_id = ? AND quarantine.status = 'open'
|
|
529
|
+
LIMIT 1`).bind(serviceId, viewId).first();
|
|
530
|
+
if (row === null || row === undefined)
|
|
531
|
+
return undefined;
|
|
532
|
+
const classification = asString(row.classification, "ordering_quarantine.classification");
|
|
533
|
+
if (classification !== "LATE_LOWER_SUID" && classification !== "ORDER_VIOLATION") {
|
|
534
|
+
throw new MaterializedViewStoreError("initialize", "MV_STORE_OPERATION_FAILED", "Materialized-view ordering quarantine classification was invalid");
|
|
535
|
+
}
|
|
536
|
+
const status = asString(row.status, "ordering_quarantine.status");
|
|
537
|
+
if (status !== "open" && status !== "resolved") {
|
|
538
|
+
throw new MaterializedViewStoreError("initialize", "MV_STORE_OPERATION_FAILED", "Materialized-view ordering quarantine status was invalid");
|
|
539
|
+
}
|
|
540
|
+
return {
|
|
541
|
+
serviceId: asString(row.service_id, "ordering_quarantine.service_id"),
|
|
542
|
+
viewId: asString(row.view_id, "ordering_quarantine.view_id"),
|
|
543
|
+
generation: asInteger(row.generation, "ordering_quarantine.generation"),
|
|
544
|
+
checkpointSuid: asString(row.checkpoint_suid, "ordering_quarantine.checkpoint_suid"),
|
|
545
|
+
lateSuid: asString(row.late_suid, "ordering_quarantine.late_suid"),
|
|
546
|
+
eventId: asString(row.event_id, "ordering_quarantine.event_id"),
|
|
547
|
+
classification,
|
|
548
|
+
status,
|
|
549
|
+
observedAt: asInteger(row.observed_at, "ordering_quarantine.observed_at"),
|
|
550
|
+
resolvedAt: row.resolved_at === null || row.resolved_at === undefined
|
|
551
|
+
? null
|
|
552
|
+
: asInteger(row.resolved_at, "ordering_quarantine.resolved_at"),
|
|
553
|
+
incidentIdentity: row.incident_identity === null || row.incident_identity === undefined
|
|
554
|
+
? null
|
|
555
|
+
: asString(row.incident_identity, "ordering_quarantine.incident_identity"),
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
async readActiveGeneration(serviceId, viewId) {
|
|
559
|
+
this.ready("initialize");
|
|
560
|
+
return (await this.readActive(serviceId, viewId))?.generation;
|
|
561
|
+
}
|
|
562
|
+
/** Persist the detector result before stopping the safe pass. Repeated observations are idempotent. */
|
|
563
|
+
async recordOrderingQuarantine(input) {
|
|
564
|
+
this.ready("apply");
|
|
565
|
+
this.validateGeneration(input.generation, "apply");
|
|
566
|
+
const incidentIdentity = `${input.classification}|${input.serviceId}|${input.viewId}|${input.checkpointSuid}|${input.lateSuid}|${input.eventId}`;
|
|
567
|
+
await this.database.prepare(`INSERT INTO mv_ordering_quarantines
|
|
568
|
+
(service_id, view_id, generation, checkpoint_suid, late_suid, event_id, classification, status, observed_at, resolved_at, incident_identity)
|
|
569
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, NULL, ?)
|
|
570
|
+
ON CONFLICT (service_id, view_id, generation) DO UPDATE SET
|
|
571
|
+
checkpoint_suid = excluded.checkpoint_suid,
|
|
572
|
+
late_suid = excluded.late_suid,
|
|
573
|
+
event_id = excluded.event_id,
|
|
574
|
+
classification = excluded.classification,
|
|
575
|
+
status = 'open',
|
|
576
|
+
observed_at = MIN(mv_ordering_quarantines.observed_at, excluded.observed_at),
|
|
577
|
+
resolved_at = NULL,
|
|
578
|
+
incident_identity = excluded.incident_identity`).bind(input.serviceId, input.viewId, input.generation, input.checkpointSuid, input.lateSuid, input.eventId, input.classification, input.observedAt, incidentIdentity).run();
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Mark a candidate only after the real rebuild path observed the complete
|
|
582
|
+
* source history. The proof is incident-bound when an ordering quarantine is
|
|
583
|
+
* open, and an empty row set is valid when the source history itself is
|
|
584
|
+
* complete (for example, a rebuild whose materializer deletes every row).
|
|
585
|
+
*/
|
|
586
|
+
async markGenerationRebuilt(input) {
|
|
587
|
+
this.ready("promote");
|
|
588
|
+
this.validateGeneration(input.generation, "promote");
|
|
589
|
+
if (input.rebuildId.length === 0 ||
|
|
590
|
+
!Number.isSafeInteger(input.sourceEventCount) || input.sourceEventCount < 0 ||
|
|
591
|
+
input.sourceEventIds.length !== input.sourceEventCount ||
|
|
592
|
+
input.sourceSuids.length !== input.sourceEventCount ||
|
|
593
|
+
(input.sourceEventCount === 0 ? input.sourceMaxSuid !== "" : input.sourceMaxSuid.length === 0) ||
|
|
594
|
+
input.sourceHistoryDigest.length === 0) {
|
|
595
|
+
throw new MaterializedViewStoreError("promote", "MV_REBUILD_NOT_VERIFIED", "Rebuild proof does not describe a complete source history");
|
|
596
|
+
}
|
|
597
|
+
const quarantine = await this.database.prepare(`SELECT generation, incident_identity, event_id, late_suid
|
|
598
|
+
FROM mv_ordering_quarantines
|
|
599
|
+
WHERE service_id = ? AND view_id = ? AND status = 'open'
|
|
600
|
+
ORDER BY generation DESC LIMIT 1`).bind(input.serviceId, input.viewId).first();
|
|
601
|
+
const incidentGeneration = quarantine === null || quarantine === undefined
|
|
602
|
+
? null
|
|
603
|
+
: asInteger(quarantine.generation, "rebuild.incident_generation");
|
|
604
|
+
const incidentIdentity = quarantine === null || quarantine === undefined
|
|
605
|
+
? null
|
|
606
|
+
: typeof quarantine.incident_identity === "string" && quarantine.incident_identity.length > 0
|
|
607
|
+
? quarantine.incident_identity
|
|
608
|
+
: null;
|
|
609
|
+
if (quarantine !== null && quarantine !== undefined) {
|
|
610
|
+
if (incidentIdentity === null) {
|
|
611
|
+
throw new MaterializedViewStoreError("promote", "MV_REBUILD_NOT_VERIFIED", "Ordering quarantine has no durable incident identity");
|
|
612
|
+
}
|
|
613
|
+
const incidentEventId = asString(quarantine.event_id, "rebuild.incident_event_id");
|
|
614
|
+
const incidentLateSuid = asString(quarantine.late_suid, "rebuild.incident_late_suid");
|
|
615
|
+
if (!input.sourceEventIds.includes(incidentEventId) || !input.sourceSuids.includes(incidentLateSuid)) {
|
|
616
|
+
throw new MaterializedViewStoreError("promote", "MV_REBUILD_NOT_VERIFIED", "Rebuild source history does not include the quarantined event");
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const result = await this.database.prepare(`UPDATE mv_instances
|
|
620
|
+
SET rebuild_verified_at = ?,
|
|
621
|
+
rebuild_recovery_id = ?,
|
|
622
|
+
rebuild_incident_identity = ?,
|
|
623
|
+
rebuild_incident_generation = ?,
|
|
624
|
+
rebuild_source_event_count = ?,
|
|
625
|
+
rebuild_source_max_suid = ?,
|
|
626
|
+
rebuild_source_history_digest = ?
|
|
627
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?
|
|
628
|
+
AND status = 'candidate'
|
|
629
|
+
AND last_suid COLLATE BINARY = ? COLLATE BINARY`).bind(input.verifiedAt, input.rebuildId, incidentIdentity, incidentGeneration, input.sourceEventCount, input.sourceMaxSuid, input.sourceHistoryDigest, input.serviceId, input.viewId, input.generation, input.sourceMaxSuid).run();
|
|
630
|
+
if ((result.meta?.changes ?? 0) !== 1) {
|
|
631
|
+
throw new MaterializedViewStoreError("promote", "MV_REBUILD_NOT_VERIFIED", "Candidate generation does not match the complete rebuild source history");
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Apply rows/index entries and advance one generation checkpoint atomically.
|
|
636
|
+
* The guard row intentionally uses a NOT NULL failure to abort a stale CAS.
|
|
637
|
+
*/
|
|
638
|
+
async applyMutationsAndAdvanceCheckpoint(input) {
|
|
639
|
+
assertSortableUniqueId(input.lastSuid);
|
|
640
|
+
if (input.expectedLastSuid !== null)
|
|
641
|
+
assertSortableUniqueId(input.expectedLastSuid);
|
|
642
|
+
for (const mutation of [...input.mutations.rowUpserts, ...input.mutations.rowPatches]) {
|
|
643
|
+
assertSortableUniqueId(mutation.sourceSuid);
|
|
644
|
+
}
|
|
645
|
+
this.ready("apply");
|
|
646
|
+
this.validateGeneration(input.generation, "apply");
|
|
647
|
+
const operation = operationId();
|
|
648
|
+
const statements = [
|
|
649
|
+
this.database.prepare(`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
650
|
+
SELECT ?, CASE WHEN EXISTS (
|
|
651
|
+
SELECT 1 FROM mv_instances
|
|
652
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?
|
|
653
|
+
AND ((? IS NULL AND last_suid = '') OR last_suid COLLATE BINARY = ? COLLATE BINARY)
|
|
654
|
+
) THEN 1 ELSE NULL END`).bind(operation, input.serviceId, input.viewId, input.generation, input.expectedLastSuid, input.expectedLastSuid),
|
|
655
|
+
];
|
|
656
|
+
// Patch guards are evaluated before any write. A missing target selects 0
|
|
657
|
+
// and violates the CHECK constraint, aborting the entire D1 batch.
|
|
658
|
+
for (const patch of input.mutations.rowPatches) {
|
|
659
|
+
statements.push(this.database.prepare(`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
660
|
+
SELECT ?, CASE WHEN EXISTS (
|
|
661
|
+
SELECT 1 FROM mv_rows
|
|
662
|
+
WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key COLLATE BINARY = ? COLLATE BINARY
|
|
663
|
+
) THEN 1 ELSE 0 END`).bind(`${operation}:patch:${patch.rowKey}`, input.serviceId, input.viewId, input.generation, patch.rowKey));
|
|
664
|
+
}
|
|
665
|
+
for (const deletion of input.mutations.rowDeletes) {
|
|
666
|
+
statements.push(this.database.prepare(`DELETE FROM mv_index_entries WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`).bind(input.serviceId, input.viewId, input.generation, deletion.rowKey));
|
|
667
|
+
statements.push(this.database.prepare(`DELETE FROM mv_rows WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`).bind(input.serviceId, input.viewId, input.generation, deletion.rowKey));
|
|
668
|
+
}
|
|
669
|
+
for (const upsert of input.mutations.rowUpserts) {
|
|
670
|
+
// An upsert replaces all indexes for that row before inserting the
|
|
671
|
+
// finite descriptor set below; stale index entries cannot survive.
|
|
672
|
+
statements.push(this.database.prepare(`DELETE FROM mv_index_entries WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`).bind(input.serviceId, input.viewId, input.generation, upsert.rowKey));
|
|
673
|
+
statements.push(this.database.prepare(`INSERT INTO mv_rows
|
|
674
|
+
(service_id, view_id, generation, row_key, value_json, row_version, source_suid)
|
|
675
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
676
|
+
ON CONFLICT (service_id, view_id, generation, row_key) DO UPDATE SET
|
|
677
|
+
value_json = excluded.value_json,
|
|
678
|
+
row_version = excluded.row_version,
|
|
679
|
+
source_suid = excluded.source_suid`).bind(input.serviceId, input.viewId, input.generation, upsert.rowKey, JSON.stringify(assertJsonValue(upsert.value, "state-persistence")), upsert.rowVersion, upsert.sourceSuid));
|
|
680
|
+
}
|
|
681
|
+
for (const patch of input.mutations.rowPatches) {
|
|
682
|
+
// A patch carrying index entries declares the complete replacement set
|
|
683
|
+
// for this row. With no entries the prior index rows remain intact.
|
|
684
|
+
if (patch.indexEntries.length > 0) {
|
|
685
|
+
statements.push(this.database.prepare(`DELETE FROM mv_index_entries WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`).bind(input.serviceId, input.viewId, input.generation, patch.rowKey));
|
|
686
|
+
}
|
|
687
|
+
// json_patch performs the partial merge in SQLite as one prepared
|
|
688
|
+
// statement. No current row is read or merged by the runtime.
|
|
689
|
+
statements.push(this.database.prepare(`UPDATE mv_rows
|
|
690
|
+
SET value_json = json_patch(value_json, ?), row_version = ?, source_suid = ?
|
|
691
|
+
WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key COLLATE BINARY = ? COLLATE BINARY`).bind(JSON.stringify(assertJsonValue(patch.patch, "state-persistence")), patch.rowVersion, patch.sourceSuid, input.serviceId, input.viewId, input.generation, patch.rowKey));
|
|
692
|
+
}
|
|
693
|
+
for (const deletion of input.mutations.indexDeletes) {
|
|
694
|
+
const indexPredicate = deletion.indexId === undefined ? "" : " AND index_id = ?";
|
|
695
|
+
const values = [input.serviceId, input.viewId, input.generation, deletion.rowKey];
|
|
696
|
+
if (deletion.indexId !== undefined)
|
|
697
|
+
values.push(deletion.indexId);
|
|
698
|
+
statements.push(this.database.prepare(`DELETE FROM mv_index_entries
|
|
699
|
+
WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?${indexPredicate}`).bind(...values));
|
|
700
|
+
}
|
|
701
|
+
for (const entry of input.mutations.indexEntries) {
|
|
702
|
+
const typed = entry.valueType === "text"
|
|
703
|
+
? [entry.value, null, null]
|
|
704
|
+
: entry.valueType === "integer"
|
|
705
|
+
? [null, entry.value, null]
|
|
706
|
+
: [null, null, entry.value];
|
|
707
|
+
statements.push(this.database.prepare(`INSERT INTO mv_index_entries
|
|
708
|
+
(service_id, view_id, generation, index_id, value_type, text_value, integer_value, real_value, row_key)
|
|
709
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
710
|
+
ON CONFLICT (service_id, view_id, generation, index_id, value_type, text_value, integer_value, real_value, row_key)
|
|
711
|
+
DO NOTHING`).bind(input.serviceId, input.viewId, input.generation, entry.indexId, entry.valueType, ...typed, entry.rowKey));
|
|
712
|
+
}
|
|
713
|
+
for (const patch of input.mutations.rowPatches) {
|
|
714
|
+
for (const entry of patch.indexEntries) {
|
|
715
|
+
const typed = entry.valueType === "text"
|
|
716
|
+
? [entry.value, null, null]
|
|
717
|
+
: entry.valueType === "integer"
|
|
718
|
+
? [null, entry.value, null]
|
|
719
|
+
: [null, null, entry.value];
|
|
720
|
+
statements.push(this.database.prepare(`INSERT INTO mv_index_entries
|
|
721
|
+
(service_id, view_id, generation, index_id, value_type, text_value, integer_value, real_value, row_key)
|
|
722
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
723
|
+
ON CONFLICT (service_id, view_id, generation, index_id, value_type, text_value, integer_value, real_value, row_key)
|
|
724
|
+
DO NOTHING`).bind(input.serviceId, input.viewId, input.generation, entry.indexId, entry.valueType, ...typed, entry.rowKey));
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
statements.push(this.database.prepare(`UPDATE mv_instances
|
|
728
|
+
SET last_suid = ?, definition_version = ?, updated_at = MAX(updated_at, ?),
|
|
729
|
+
rebuild_verified_at = NULL,
|
|
730
|
+
rebuild_recovery_id = NULL,
|
|
731
|
+
rebuild_incident_identity = NULL,
|
|
732
|
+
rebuild_incident_generation = NULL,
|
|
733
|
+
rebuild_source_event_count = NULL,
|
|
734
|
+
rebuild_source_max_suid = NULL,
|
|
735
|
+
rebuild_source_history_digest = NULL
|
|
736
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?`).bind(input.lastSuid, input.definitionVersion, input.updatedAt, input.serviceId, input.viewId, input.generation));
|
|
737
|
+
statements.push(this.database.prepare("DELETE FROM mv_atomic_guards WHERE operation_id = ? OR operation_id LIKE ?").bind(operation, `${operation}:%`));
|
|
738
|
+
try {
|
|
739
|
+
await this.database.batch(statements);
|
|
740
|
+
}
|
|
741
|
+
catch (error) {
|
|
742
|
+
if (isPatchFailure(error))
|
|
743
|
+
throw new MaterializedViewPatchError();
|
|
744
|
+
if (isCasFailure(error))
|
|
745
|
+
throw new MaterializedViewCasError();
|
|
746
|
+
throw new MaterializedViewStoreError("apply", "MV_STORE_OPERATION_FAILED", `Materialized-view atomic apply failed: ${String(error)}`, { cause: error });
|
|
747
|
+
}
|
|
748
|
+
const instance = await this.readInstance(input.serviceId, input.viewId, input.generation);
|
|
749
|
+
if (instance === undefined)
|
|
750
|
+
throw new MaterializedViewStoreError("apply", "MV_INSTANCE_MISSING", "Materialized-view instance disappeared after apply");
|
|
751
|
+
return {
|
|
752
|
+
instance,
|
|
753
|
+
rowUpserts: input.mutations.rowUpserts.length,
|
|
754
|
+
rowDeletes: input.mutations.rowDeletes.length,
|
|
755
|
+
rowPatches: input.mutations.rowPatches.length,
|
|
756
|
+
indexEntries: input.mutations.indexEntries.length,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
async promoteGeneration(input) {
|
|
760
|
+
this.ready("promote");
|
|
761
|
+
this.validateGeneration(input.candidateGeneration, "promote");
|
|
762
|
+
const operation = operationId();
|
|
763
|
+
const statements = [
|
|
764
|
+
this.database.prepare(`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
765
|
+
SELECT ?, CASE WHEN EXISTS (
|
|
766
|
+
SELECT 1 FROM mv_instances candidate
|
|
767
|
+
WHERE candidate.service_id = ? AND candidate.view_id = ?
|
|
768
|
+
AND candidate.generation = ? AND candidate.status = 'candidate'
|
|
769
|
+
AND (
|
|
770
|
+
NOT EXISTS (
|
|
771
|
+
SELECT 1 FROM mv_ordering_quarantines open_quarantine
|
|
772
|
+
WHERE open_quarantine.service_id = candidate.service_id
|
|
773
|
+
AND open_quarantine.view_id = candidate.view_id
|
|
774
|
+
AND open_quarantine.status = 'open'
|
|
775
|
+
)
|
|
776
|
+
OR EXISTS (
|
|
777
|
+
SELECT 1 FROM mv_ordering_quarantines open_quarantine
|
|
778
|
+
WHERE open_quarantine.service_id = candidate.service_id
|
|
779
|
+
AND open_quarantine.view_id = candidate.view_id
|
|
780
|
+
AND open_quarantine.status = 'open'
|
|
781
|
+
AND open_quarantine.incident_identity IS NOT NULL
|
|
782
|
+
AND candidate.rebuild_verified_at IS NOT NULL
|
|
783
|
+
AND candidate.rebuild_incident_generation = open_quarantine.generation
|
|
784
|
+
AND candidate.rebuild_incident_identity = open_quarantine.incident_identity
|
|
785
|
+
)
|
|
786
|
+
)
|
|
787
|
+
) AND (
|
|
788
|
+
(? IS NULL AND NOT EXISTS (
|
|
789
|
+
SELECT 1 FROM mv_active_generations existing
|
|
790
|
+
WHERE existing.service_id = ? AND existing.view_id = ?
|
|
791
|
+
))
|
|
792
|
+
OR (? IS NOT NULL AND EXISTS (
|
|
793
|
+
SELECT 1 FROM mv_active_generations pointer
|
|
794
|
+
WHERE pointer.service_id = ? AND pointer.view_id = ?
|
|
795
|
+
AND pointer.generation = ?
|
|
796
|
+
))
|
|
797
|
+
) THEN 1 ELSE NULL END`).bind(operation, input.serviceId, input.viewId, input.candidateGeneration, input.expectedActiveGeneration, input.serviceId, input.viewId, input.expectedActiveGeneration, input.serviceId, input.viewId, input.expectedActiveGeneration),
|
|
798
|
+
this.database.prepare(`UPDATE mv_instances SET status = 'retired', updated_at = ?
|
|
799
|
+
WHERE service_id = ? AND view_id = ? AND status = 'active'`).bind(input.updatedAt, input.serviceId, input.viewId),
|
|
800
|
+
this.database.prepare(`UPDATE mv_instances SET status = 'active', updated_at = ?
|
|
801
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?`).bind(input.updatedAt, input.serviceId, input.viewId, input.candidateGeneration),
|
|
802
|
+
this.database.prepare(`INSERT INTO mv_active_generations (service_id, view_id, generation, updated_at)
|
|
803
|
+
VALUES (?, ?, ?, ?)
|
|
804
|
+
ON CONFLICT (service_id, view_id) DO UPDATE SET generation = excluded.generation, updated_at = excluded.updated_at`).bind(input.serviceId, input.viewId, input.candidateGeneration, input.updatedAt),
|
|
805
|
+
// A successful rebuild/promotion is the explicit recovery action for an
|
|
806
|
+
// open ordering quarantine. The active-generation pointer changes in the
|
|
807
|
+
// same batch, so a safe read never observes a cleared gate on the old
|
|
808
|
+
// generation with the new generation still unproven.
|
|
809
|
+
this.database.prepare(`UPDATE mv_ordering_quarantines
|
|
810
|
+
SET status = 'resolved', resolved_at = ?
|
|
811
|
+
WHERE service_id = ? AND view_id = ? AND status = 'open' AND generation <> ?
|
|
812
|
+
AND incident_identity = (
|
|
813
|
+
SELECT candidate.rebuild_incident_identity
|
|
814
|
+
FROM mv_instances candidate
|
|
815
|
+
WHERE candidate.service_id = ? AND candidate.view_id = ?
|
|
816
|
+
AND candidate.generation = ? AND candidate.status = 'active'
|
|
817
|
+
)`).bind(input.updatedAt, input.serviceId, input.viewId, input.candidateGeneration, input.serviceId, input.viewId, input.candidateGeneration),
|
|
818
|
+
this.database.prepare("DELETE FROM mv_atomic_guards WHERE operation_id = ?").bind(operation),
|
|
819
|
+
];
|
|
820
|
+
try {
|
|
821
|
+
await this.database.batch(statements);
|
|
822
|
+
}
|
|
823
|
+
catch (error) {
|
|
824
|
+
if (isCasFailure(error))
|
|
825
|
+
throw new MaterializedViewPromotionCasError();
|
|
826
|
+
throw new MaterializedViewStoreError("promote", "MV_STORE_OPERATION_FAILED", `Materialized-view promotion failed: ${String(error)}`, { cause: error });
|
|
827
|
+
}
|
|
828
|
+
const active = await this.readActive(input.serviceId, input.viewId);
|
|
829
|
+
if (active === undefined)
|
|
830
|
+
throw new MaterializedViewStoreError("promote", "MV_INSTANCE_MISSING", "Active generation disappeared after promotion");
|
|
831
|
+
return active;
|
|
832
|
+
}
|
|
833
|
+
/** Short aliases make the port convenient for runtime composition and tests. */
|
|
834
|
+
apply = this.applyMutationsAndAdvanceCheckpoint.bind(this);
|
|
835
|
+
promote = this.promoteGeneration.bind(this);
|
|
836
|
+
rowFrom(row) {
|
|
837
|
+
let value;
|
|
838
|
+
try {
|
|
839
|
+
value = assertJsonValue(JSON.parse(asString(row.value_json, "value_json")), "state-persistence");
|
|
840
|
+
}
|
|
841
|
+
catch (error) {
|
|
842
|
+
throw new MaterializedViewStoreError("initialize", "MV_VALUE_INVALID", `Materialized-view row JSON was invalid: ${String(error)}`, { cause: error });
|
|
843
|
+
}
|
|
844
|
+
return {
|
|
845
|
+
serviceId: asString(row.service_id, "service_id"),
|
|
846
|
+
viewId: asString(row.view_id, "view_id"),
|
|
847
|
+
generation: asInteger(row.generation, "generation"),
|
|
848
|
+
rowKey: asString(row.row_key, "row_key"),
|
|
849
|
+
value,
|
|
850
|
+
rowVersion: asInteger(row.row_version, "row_version"),
|
|
851
|
+
sourceSuid: asString(row.source_suid, "source_suid"),
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
indexFrom(row) {
|
|
855
|
+
const type = valueType(row.value_type);
|
|
856
|
+
return {
|
|
857
|
+
serviceId: asString(row.service_id, "service_id"),
|
|
858
|
+
viewId: asString(row.view_id, "view_id"),
|
|
859
|
+
generation: asInteger(row.generation, "generation"),
|
|
860
|
+
indexId: asString(row.index_id, "index_id"),
|
|
861
|
+
valueType: type,
|
|
862
|
+
value: indexValue(row, type),
|
|
863
|
+
rowKey: asString(row.row_key, "row_key"),
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
validateGeneration(generation, operation) {
|
|
867
|
+
if (!Number.isSafeInteger(generation) || generation < 0) {
|
|
868
|
+
throw new MaterializedViewStoreError(operation, "MV_GENERATION_INVALID", "Materialized-view generation must be a non-negative integer");
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
ready(operation) {
|
|
872
|
+
if (!this.initialized)
|
|
873
|
+
throw new MaterializedViewStoreError(operation, "MV_STORE_NOT_INITIALIZED", "D1MaterializedViewStore.initialize() must complete before use");
|
|
874
|
+
}
|
|
875
|
+
}
|