@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,112 @@
|
|
|
1
|
+
import { type MaterializedViewMutationPlan } from "@sekiban/dcb-core";
|
|
2
|
+
import type { MaterializedViewRow } from "./MaterializedViewStore";
|
|
3
|
+
export type UnsafeOutcome = "applied" | "older" | "patch-not-found" | "delete-without-row" | "no-change";
|
|
4
|
+
export type UnsafeWindowErrorCode = "UNSAFE_ROW_CAS_MISMATCH" | "UNSAFE_DUPLICATE_RACE" | "UNSAFE_SUID_CONTRADICTION" | "UNSAFE_SAFE_AHEAD" | "UNSAFE_BEHIND_FRONTIER" | "UNSAFE_KICK_LEASE_HELD" | "UNSAFE_GC_GUARD_FAILED";
|
|
5
|
+
/** Typed failures are intentionally retryable only for the expected row CAS. */
|
|
6
|
+
export declare class UnsafeWindowMaterializedViewError extends Error {
|
|
7
|
+
readonly code: UnsafeWindowErrorCode;
|
|
8
|
+
readonly retryable: boolean;
|
|
9
|
+
constructor(code: UnsafeWindowErrorCode, retryable: boolean, message: string, options?: ErrorOptions);
|
|
10
|
+
}
|
|
11
|
+
export interface UnsafeWindowApplyInput {
|
|
12
|
+
readonly serviceId: string;
|
|
13
|
+
readonly viewId: string;
|
|
14
|
+
readonly generation: number;
|
|
15
|
+
readonly eventId: string;
|
|
16
|
+
readonly suid: string;
|
|
17
|
+
readonly safeHead: string;
|
|
18
|
+
readonly expectedRowVersion?: number | null;
|
|
19
|
+
readonly updatedAt: number;
|
|
20
|
+
/**
|
|
21
|
+
* The generic unsafe apply API deliberately does not imply an arrival
|
|
22
|
+
* observation; G23 keeps that receipt guard independent. Materializer
|
|
23
|
+
* adapters that fold the observation into their upsert batch opt in.
|
|
24
|
+
*/
|
|
25
|
+
readonly recordArrival?: boolean;
|
|
26
|
+
/** A plan is already the materializer's stored outcome: this port never folds in TypeScript. */
|
|
27
|
+
readonly mutations: MaterializedViewMutationPlan;
|
|
28
|
+
readonly targetSuid?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface UnsafeWindowApplyResult {
|
|
31
|
+
readonly outcome: UnsafeOutcome;
|
|
32
|
+
readonly duplicate: boolean;
|
|
33
|
+
}
|
|
34
|
+
export interface UnsafeWindowMaterializedViewStoreOptions {
|
|
35
|
+
/** Test-only barrier used by the SDT-G26 duplicate-race oracle. */
|
|
36
|
+
readonly beforeApplyBatch?: (input: UnsafeWindowApplyInput, statements?: readonly D1PreparedStatement[]) => Promise<void>;
|
|
37
|
+
/** Test-only post-commit seam for receipt-boundary fault oracles. */
|
|
38
|
+
readonly afterApplyBatch?: (input: UnsafeWindowApplyInput, result: UnsafeWindowApplyResult) => Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
export interface UnsafeReadMeta {
|
|
41
|
+
readonly safeContiguousHead: string;
|
|
42
|
+
readonly unsafeObservedMaxSuid: string;
|
|
43
|
+
readonly rebuildRequired: boolean;
|
|
44
|
+
}
|
|
45
|
+
export interface UnsafeKickLease {
|
|
46
|
+
readonly targetSuid: string;
|
|
47
|
+
readonly dirty: boolean;
|
|
48
|
+
}
|
|
49
|
+
export interface UnsafeComposedPage {
|
|
50
|
+
readonly rows: readonly MaterializedViewRow[];
|
|
51
|
+
readonly totalCount: number;
|
|
52
|
+
}
|
|
53
|
+
export interface UnsafeGcInput {
|
|
54
|
+
readonly serviceId: string;
|
|
55
|
+
readonly viewId: string;
|
|
56
|
+
readonly generation: number;
|
|
57
|
+
readonly definitionVersion: number;
|
|
58
|
+
readonly rowKey: string;
|
|
59
|
+
readonly expectedRowVersion: number;
|
|
60
|
+
readonly expectedSourceSuid: string;
|
|
61
|
+
readonly safeHead: string;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Provider-neutral unsafe-window D1 port. All mutations that make an event
|
|
65
|
+
* observable (row/index/receipt/marker/kick) are one D1 batch, with guard
|
|
66
|
+
* inserts first. This avoids the G18 partial-commit bug of inspecting changes
|
|
67
|
+
* after a batch has already committed other statements.
|
|
68
|
+
*/
|
|
69
|
+
export declare class UnsafeWindowMaterializedViewStore {
|
|
70
|
+
private readonly database;
|
|
71
|
+
private readonly options;
|
|
72
|
+
constructor(database: D1Database, options?: UnsafeWindowMaterializedViewStoreOptions);
|
|
73
|
+
/**
|
|
74
|
+
* The common row-materializer shape is one complete upsert. Keep its
|
|
75
|
+
* duplicate/order guards and all observable mutations in one D1 batch. A
|
|
76
|
+
* guard race falls back to apply(), which preserves the detailed older,
|
|
77
|
+
* duplicate, and contradiction classifications; only the uncontended fast
|
|
78
|
+
* path avoids the receipt/row pre-reads and separate arrival write.
|
|
79
|
+
*/
|
|
80
|
+
private applyUpsertFast;
|
|
81
|
+
apply(input: UnsafeWindowApplyInput, skipFastPath?: boolean): Promise<UnsafeWindowApplyResult>;
|
|
82
|
+
/** Advance safe head and remove markers only if this transaction observed its exact receipt. */
|
|
83
|
+
observeSafeReceipt(serviceId: string, viewId: string, generation: number, eventId: string, suid: string): Promise<void>;
|
|
84
|
+
/** Detects late first-arrivals independently of SafeWindow and fail-closes reads. */
|
|
85
|
+
observeArrival(serviceId: string, viewId: string, generation: number, eventId: string, suid: string): Promise<boolean>;
|
|
86
|
+
readMeta(serviceId: string, viewId: string, generation: number): Promise<UnsafeReadMeta>;
|
|
87
|
+
hasTargetReceipt(serviceId: string, viewId: string, eventId: string, suid: string): Promise<boolean>;
|
|
88
|
+
acquireKick(serviceId: string, viewId: string, owner: string, nowMs: number, leaseMs: number): Promise<UnsafeKickLease | undefined>;
|
|
89
|
+
/**
|
|
90
|
+
* Settle an unsafe kick at the checkpoint actually reached by its follow.
|
|
91
|
+
* A first-unsafe return is a successful bounded pass, not proof that the
|
|
92
|
+
* lease target was reached. When a reached checkpoint is supplied, keep the
|
|
93
|
+
* durable dirty bit set if the target remains ahead so the next scheduled
|
|
94
|
+
* tick can acquire the kick again. Requiring this checkpoint keeps every
|
|
95
|
+
* production settlement honest about whether its target was reached.
|
|
96
|
+
*/
|
|
97
|
+
finishKick(serviceId: string, viewId: string, owner: string, reachedSuid: string): Promise<boolean>;
|
|
98
|
+
/**
|
|
99
|
+
* Collect a repairable unsafe row only when every recovery predicate holds.
|
|
100
|
+
* Every predicate is a constraint-promotion guard ahead of the deletes; a
|
|
101
|
+
* rejected GC therefore cannot leak a partially removed index/receipt.
|
|
102
|
+
*/
|
|
103
|
+
garbageCollect(input: UnsafeGcInput): Promise<boolean>;
|
|
104
|
+
/**
|
|
105
|
+
* Idle catch-up calls this after it has advanced (or observed) the safe
|
|
106
|
+
* checkpoint. Candidates are deliberately rechecked by garbageCollect;
|
|
107
|
+
* this read is only work selection, never permission to delete.
|
|
108
|
+
*/
|
|
109
|
+
collectEligible(serviceId: string, viewId: string, generation: number, definitionVersion: number, safeHead: string): Promise<number>;
|
|
110
|
+
queryComposedPage(serviceId: string, viewId: string, generation: number, limit: number, offset: number, descending?: boolean): Promise<UnsafeComposedPage>;
|
|
111
|
+
private addIndex;
|
|
112
|
+
}
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
import { assertJsonValue } from "@sekiban/dcb-core";
|
|
2
|
+
import { assertSortableUniqueId } from "../allocator/SortableUniqueId";
|
|
3
|
+
/** Typed failures are intentionally retryable only for the expected row CAS. */
|
|
4
|
+
export class UnsafeWindowMaterializedViewError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
retryable;
|
|
7
|
+
constructor(code, retryable, message, options) {
|
|
8
|
+
super(message, options);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.retryable = retryable;
|
|
11
|
+
this.name = "UnsafeWindowMaterializedViewError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function compareSuid(left, right) {
|
|
15
|
+
const leftBytes = new TextEncoder().encode(left);
|
|
16
|
+
const rightBytes = new TextEncoder().encode(right);
|
|
17
|
+
for (let index = 0; index < Math.min(leftBytes.length, rightBytes.length); index += 1) {
|
|
18
|
+
const difference = leftBytes[index] - rightBytes[index];
|
|
19
|
+
if (difference !== 0)
|
|
20
|
+
return difference;
|
|
21
|
+
}
|
|
22
|
+
return leftBytes.length - rightBytes.length;
|
|
23
|
+
}
|
|
24
|
+
function string(row, key) {
|
|
25
|
+
if (typeof row[key] !== "string")
|
|
26
|
+
throw new Error(`Unsafe MV ${key} was not a string`);
|
|
27
|
+
return row[key];
|
|
28
|
+
}
|
|
29
|
+
function integer(row, key) {
|
|
30
|
+
const value = typeof row[key] === "number" ? row[key] : Number(row[key]);
|
|
31
|
+
if (!Number.isSafeInteger(value))
|
|
32
|
+
throw new Error(`Unsafe MV ${key} was not an integer`);
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
function operationId() {
|
|
36
|
+
return `mv-unsafe-${crypto.randomUUID()}`;
|
|
37
|
+
}
|
|
38
|
+
function errorFrom(error) {
|
|
39
|
+
const value = String(error);
|
|
40
|
+
if (value.includes("mv_unsafe_receipts") && (value.includes("UNIQUE") || value.includes("constraint"))) {
|
|
41
|
+
return new UnsafeWindowMaterializedViewError("UNSAFE_DUPLICATE_RACE", false, "Concurrent unsafe receipt was committed by another delivery", { cause: error });
|
|
42
|
+
}
|
|
43
|
+
if (value.includes("unsafe_row_cas"))
|
|
44
|
+
return new UnsafeWindowMaterializedViewError("UNSAFE_ROW_CAS_MISMATCH", true, "Unsafe row version changed before atomic apply", { cause: error });
|
|
45
|
+
if (value.includes("unsafe_suid_contradiction"))
|
|
46
|
+
return new UnsafeWindowMaterializedViewError("UNSAFE_SUID_CONTRADICTION", false, "Unsafe same-SUID payload contradiction", { cause: error });
|
|
47
|
+
if (value.includes("unsafe_safe_ahead"))
|
|
48
|
+
return new UnsafeWindowMaterializedViewError("UNSAFE_SAFE_AHEAD", false, "Safe head already covers unsafe event", { cause: error });
|
|
49
|
+
if (value.includes("unsafe_behind_frontier"))
|
|
50
|
+
return new UnsafeWindowMaterializedViewError("UNSAFE_BEHIND_FRONTIER", false, "Unsafe reads are fail-closed pending rebuild", { cause: error });
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Provider-neutral unsafe-window D1 port. All mutations that make an event
|
|
55
|
+
* observable (row/index/receipt/marker/kick) are one D1 batch, with guard
|
|
56
|
+
* inserts first. This avoids the G18 partial-commit bug of inspecting changes
|
|
57
|
+
* after a batch has already committed other statements.
|
|
58
|
+
*/
|
|
59
|
+
export class UnsafeWindowMaterializedViewStore {
|
|
60
|
+
database;
|
|
61
|
+
options;
|
|
62
|
+
constructor(database, options = {}) {
|
|
63
|
+
this.database = database;
|
|
64
|
+
this.options = options;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The common row-materializer shape is one complete upsert. Keep its
|
|
68
|
+
* duplicate/order guards and all observable mutations in one D1 batch. A
|
|
69
|
+
* guard race falls back to apply(), which preserves the detailed older,
|
|
70
|
+
* duplicate, and contradiction classifications; only the uncontended fast
|
|
71
|
+
* path avoids the receipt/row pre-reads and separate arrival write.
|
|
72
|
+
*/
|
|
73
|
+
async applyUpsertFast(input) {
|
|
74
|
+
const { rowUpserts, rowPatches, rowDeletes } = input.mutations;
|
|
75
|
+
if (rowUpserts.length !== 1 || rowPatches.length !== 0 || rowDeletes.length !== 0)
|
|
76
|
+
return undefined;
|
|
77
|
+
const upsert = rowUpserts[0];
|
|
78
|
+
if (upsert.sourceSuid !== input.suid)
|
|
79
|
+
return undefined;
|
|
80
|
+
const valueJson = JSON.stringify(assertJsonValue(upsert.value, "state-persistence"));
|
|
81
|
+
const operation = operationId();
|
|
82
|
+
const statements = [];
|
|
83
|
+
const guard = (name, predicate, values) => {
|
|
84
|
+
statements.push(this.database.prepare(`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
85
|
+
SELECT ?, CASE WHEN (${predicate}) THEN 1 ELSE NULL END`).bind(`${operation}:${name}`, ...values));
|
|
86
|
+
};
|
|
87
|
+
guard("receipt", "NOT EXISTS (SELECT 1 FROM mv_unsafe_receipts WHERE service_id = ? AND view_id = ? AND event_id = ?)", [input.serviceId, input.viewId, input.eventId]);
|
|
88
|
+
guard("safe-head", "? = '' OR ? COLLATE BINARY < ? COLLATE BINARY", [input.safeHead, input.safeHead, input.suid]);
|
|
89
|
+
guard("row-order", "NOT EXISTS (SELECT 1 FROM mv_unsafe_rows WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ? AND source_suid COLLATE BINARY > ? COLLATE BINARY)", [input.serviceId, input.viewId, input.generation, upsert.rowKey, input.suid]);
|
|
90
|
+
guard("same-suid", "NOT EXISTS (SELECT 1 FROM mv_unsafe_rows WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ? AND source_suid COLLATE BINARY = ? COLLATE BINARY AND value_json <> ?)", [input.serviceId, input.viewId, input.generation, upsert.rowKey, input.suid, valueJson]);
|
|
91
|
+
if (input.expectedRowVersion !== undefined && input.expectedRowVersion !== null) {
|
|
92
|
+
guard("row-cas", "NOT EXISTS (SELECT 1 FROM mv_unsafe_rows WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?) OR EXISTS (SELECT 1 FROM mv_unsafe_rows WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ? AND row_version = ?)", [
|
|
93
|
+
input.serviceId, input.viewId, input.generation, upsert.rowKey,
|
|
94
|
+
input.serviceId, input.viewId, input.generation, upsert.rowKey, input.expectedRowVersion,
|
|
95
|
+
]);
|
|
96
|
+
}
|
|
97
|
+
if (input.recordArrival === true) {
|
|
98
|
+
const behindFrontier = input.safeHead !== "" && compareSuid(input.suid, input.safeHead) <= 0;
|
|
99
|
+
statements.push(this.database.prepare(`INSERT INTO mv_unsafe_arrivals
|
|
100
|
+
(service_id, view_id, generation, safe_head, arrival_watermark, behind_frontier_event_id, behind_frontier_suid, rebuild_required)
|
|
101
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
102
|
+
ON CONFLICT (service_id, view_id, generation) DO UPDATE SET
|
|
103
|
+
arrival_watermark = CASE WHEN excluded.arrival_watermark COLLATE BINARY > mv_unsafe_arrivals.arrival_watermark COLLATE BINARY THEN excluded.arrival_watermark ELSE mv_unsafe_arrivals.arrival_watermark END,
|
|
104
|
+
behind_frontier_event_id = CASE WHEN mv_unsafe_arrivals.rebuild_required = 1 THEN mv_unsafe_arrivals.behind_frontier_event_id ELSE excluded.behind_frontier_event_id END,
|
|
105
|
+
behind_frontier_suid = CASE WHEN mv_unsafe_arrivals.rebuild_required = 1 THEN mv_unsafe_arrivals.behind_frontier_suid ELSE excluded.behind_frontier_suid END,
|
|
106
|
+
rebuild_required = MAX(mv_unsafe_arrivals.rebuild_required, excluded.rebuild_required)`).bind(input.serviceId, input.viewId, input.generation, input.safeHead, input.suid, behindFrontier ? input.eventId : null, behindFrontier ? input.suid : null, behindFrontier ? 1 : 0));
|
|
107
|
+
}
|
|
108
|
+
statements.push(this.database.prepare(`DELETE FROM mv_unsafe_index_entries WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`).bind(input.serviceId, input.viewId, input.generation, upsert.rowKey));
|
|
109
|
+
statements.push(this.database.prepare(`INSERT INTO mv_unsafe_rows (service_id, view_id, generation, row_key, value_json, row_version, source_suid, tombstone)
|
|
110
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0)
|
|
111
|
+
ON CONFLICT (service_id, view_id, generation, row_key) DO UPDATE SET
|
|
112
|
+
value_json = excluded.value_json, row_version = excluded.row_version, source_suid = excluded.source_suid, tombstone = 0`).bind(input.serviceId, input.viewId, input.generation, upsert.rowKey, valueJson, upsert.rowVersion, upsert.sourceSuid));
|
|
113
|
+
for (const entry of input.mutations.indexEntries)
|
|
114
|
+
this.addIndex(statements, "mv_unsafe_index_entries", input, entry);
|
|
115
|
+
statements.push(this.database.prepare(`INSERT INTO mv_unsafe_receipts (service_id, view_id, event_id, suid, outcome, observed_at)
|
|
116
|
+
VALUES (?, ?, ?, ?, 'applied', ?)`).bind(input.serviceId, input.viewId, input.eventId, input.suid, input.updatedAt));
|
|
117
|
+
// G31 keeps a separate receipt whose identity is bound to the active
|
|
118
|
+
// generation and definition. The historical unsafe receipt above remains
|
|
119
|
+
// cross-generation for G23/G26 duplicate and GC semantics.
|
|
120
|
+
statements.push(this.database.prepare(`INSERT INTO mv_wait_receipts
|
|
121
|
+
(service_id, view_id, generation, definition_version, event_id, suid, observed_at)
|
|
122
|
+
SELECT ?, ?, ?, instance.definition_version, ?, ?, ?
|
|
123
|
+
FROM mv_instances instance
|
|
124
|
+
JOIN mv_active_generations active
|
|
125
|
+
ON active.service_id = instance.service_id
|
|
126
|
+
AND active.view_id = instance.view_id
|
|
127
|
+
AND active.generation = instance.generation
|
|
128
|
+
WHERE instance.service_id = ? AND instance.view_id = ? AND instance.generation = ?
|
|
129
|
+
ON CONFLICT DO NOTHING`).bind(input.serviceId, input.viewId, input.generation, input.eventId, input.suid, input.updatedAt, input.serviceId, input.viewId, input.generation));
|
|
130
|
+
statements.push(this.database.prepare(`INSERT INTO mv_unsafe_kicks (service_id, view_id, target_suid, dirty)
|
|
131
|
+
VALUES (?, ?, ?, 1)
|
|
132
|
+
ON CONFLICT (service_id, view_id) DO UPDATE SET
|
|
133
|
+
target_suid = CASE WHEN excluded.target_suid COLLATE BINARY > mv_unsafe_kicks.target_suid COLLATE BINARY THEN excluded.target_suid ELSE mv_unsafe_kicks.target_suid END,
|
|
134
|
+
dirty = 1`).bind(input.serviceId, input.viewId, input.targetSuid ?? input.suid));
|
|
135
|
+
statements.push(this.database.prepare("DELETE FROM mv_atomic_guards WHERE operation_id LIKE ?").bind(`${operation}:%`));
|
|
136
|
+
await this.options.beforeApplyBatch?.(input, statements);
|
|
137
|
+
try {
|
|
138
|
+
await this.database.batch(statements);
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
// A NULL guard is the only expected fast-path miss. It has no durable
|
|
142
|
+
// side effects because D1 batches are atomic; the complete path can now
|
|
143
|
+
// classify the exact duplicate/older/contradiction outcome.
|
|
144
|
+
const text = String(error);
|
|
145
|
+
const guardFailure = text.includes("mv_atomic_guards") ||
|
|
146
|
+
text.includes("checkpoint_match") ||
|
|
147
|
+
(text.includes("mv_unsafe_receipts") && (text.includes("UNIQUE") || text.includes("constraint")));
|
|
148
|
+
if (guardFailure) {
|
|
149
|
+
const receipt = await this.database.prepare(`SELECT suid FROM mv_unsafe_receipts WHERE service_id = ? AND view_id = ? AND event_id = ? ORDER BY observed_at DESC LIMIT 1`).bind(input.serviceId, input.viewId, input.eventId).first();
|
|
150
|
+
if (receipt !== null && receipt !== undefined) {
|
|
151
|
+
if (string(receipt, "suid") !== input.suid) {
|
|
152
|
+
throw new UnsafeWindowMaterializedViewError("UNSAFE_SUID_CONTRADICTION", false, "Duplicate event identity has a contradictory SUID", { cause: error });
|
|
153
|
+
}
|
|
154
|
+
if (this.options.beforeApplyBatch !== undefined) {
|
|
155
|
+
throw new UnsafeWindowMaterializedViewError("UNSAFE_DUPLICATE_RACE", false, "Concurrent unsafe receipt was committed by another delivery", { cause: error });
|
|
156
|
+
}
|
|
157
|
+
return { outcome: "no-change", duplicate: true };
|
|
158
|
+
}
|
|
159
|
+
return this.apply(input, true);
|
|
160
|
+
}
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
const result = { outcome: "applied", duplicate: false };
|
|
164
|
+
await this.options.afterApplyBatch?.(input, result);
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
167
|
+
async apply(input, skipFastPath = false) {
|
|
168
|
+
// This is an ingress in its own right: direct materializer callers must
|
|
169
|
+
// not be able to bypass the Queue/doorbell SUID gate.
|
|
170
|
+
assertSortableUniqueId(input.suid);
|
|
171
|
+
if (input.safeHead.length > 0)
|
|
172
|
+
assertSortableUniqueId(input.safeHead);
|
|
173
|
+
if (input.targetSuid !== undefined)
|
|
174
|
+
assertSortableUniqueId(input.targetSuid);
|
|
175
|
+
for (const mutation of [...input.mutations.rowUpserts, ...input.mutations.rowPatches]) {
|
|
176
|
+
assertSortableUniqueId(mutation.sourceSuid);
|
|
177
|
+
}
|
|
178
|
+
const fast = skipFastPath ? undefined : await this.applyUpsertFast(input);
|
|
179
|
+
if (fast !== undefined)
|
|
180
|
+
return fast;
|
|
181
|
+
if (input.recordArrival === true)
|
|
182
|
+
await this.observeArrival(input.serviceId, input.viewId, input.generation, input.eventId, input.suid);
|
|
183
|
+
const receipt = await this.database.prepare(`SELECT suid FROM mv_unsafe_receipts WHERE service_id = ? AND view_id = ? AND event_id = ? ORDER BY observed_at DESC LIMIT 1`).bind(input.serviceId, input.viewId, input.eventId).first();
|
|
184
|
+
if (receipt !== null && receipt !== undefined) {
|
|
185
|
+
if (string(receipt, "suid") !== input.suid) {
|
|
186
|
+
throw new UnsafeWindowMaterializedViewError("UNSAFE_SUID_CONTRADICTION", false, "Duplicate event identity has a contradictory SUID");
|
|
187
|
+
}
|
|
188
|
+
return { outcome: "no-change", duplicate: true };
|
|
189
|
+
}
|
|
190
|
+
const rowKeys = [...new Set([
|
|
191
|
+
...input.mutations.rowUpserts.map((row) => row.rowKey),
|
|
192
|
+
...input.mutations.rowPatches.map((row) => row.rowKey),
|
|
193
|
+
...input.mutations.rowDeletes.map((row) => row.rowKey),
|
|
194
|
+
])];
|
|
195
|
+
const existing = new Map();
|
|
196
|
+
for (const rowKey of rowKeys) {
|
|
197
|
+
const row = await this.database.prepare(`SELECT row_key, value_json, row_version, source_suid FROM mv_unsafe_rows
|
|
198
|
+
WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`).bind(input.serviceId, input.viewId, input.generation, rowKey).first();
|
|
199
|
+
if (row !== null && row !== undefined)
|
|
200
|
+
existing.set(rowKey, row);
|
|
201
|
+
}
|
|
202
|
+
const older = [...existing.values()].some((row) => compareSuid(string(row, "source_suid"), input.suid) > 0);
|
|
203
|
+
const noRowForPatch = input.mutations.rowPatches.some((patch) => !existing.has(patch.rowKey));
|
|
204
|
+
const deleteWithoutRow = input.mutations.rowDeletes.some((row) => !existing.has(row.rowKey));
|
|
205
|
+
const outcome = older ? "older" : noRowForPatch ? "patch-not-found" : deleteWithoutRow ? "delete-without-row" :
|
|
206
|
+
(input.mutations.rowUpserts.length + input.mutations.rowPatches.length + input.mutations.rowDeletes.length === 0 ? "no-change" : "applied");
|
|
207
|
+
const operation = operationId();
|
|
208
|
+
const statements = [];
|
|
209
|
+
const safeAhead = input.safeHead !== "" && compareSuid(input.safeHead, input.suid) >= 0;
|
|
210
|
+
let rowCasMismatch = false;
|
|
211
|
+
let sameSuidContradiction = false;
|
|
212
|
+
const guard = (name, predicate, values) => {
|
|
213
|
+
statements.push(this.database.prepare(`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
214
|
+
SELECT ?, CASE WHEN (${predicate}) THEN 1 ELSE NULL END`).bind(`${operation}:${name}`, ...values));
|
|
215
|
+
};
|
|
216
|
+
// Safe-ahead, stale expected version, and same-SUID contradiction abort
|
|
217
|
+
// before any row/index/receipt/marker/kick statement can execute.
|
|
218
|
+
guard("unsafe_safe_ahead", "? = '' OR ? COLLATE BINARY < ? COLLATE BINARY", [input.safeHead, input.safeHead, input.suid]);
|
|
219
|
+
for (const [rowKey, row] of existing) {
|
|
220
|
+
if (input.expectedRowVersion !== undefined && input.expectedRowVersion !== null) {
|
|
221
|
+
rowCasMismatch ||= integer(row, "row_version") !== input.expectedRowVersion;
|
|
222
|
+
guard("unsafe_row_cas", "? = ?", [integer(row, "row_version"), input.expectedRowVersion]);
|
|
223
|
+
}
|
|
224
|
+
const upsert = input.mutations.rowUpserts.find((candidate) => candidate.rowKey === rowKey);
|
|
225
|
+
if (upsert !== undefined && string(row, "source_suid") === input.suid && string(row, "value_json") !== JSON.stringify(assertJsonValue(upsert.value, "state-persistence"))) {
|
|
226
|
+
sameSuidContradiction = true;
|
|
227
|
+
guard("unsafe_suid_contradiction", "0", []);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
// The legal older path converges only receipt + marker. It never rewrites
|
|
231
|
+
// the row, and is deliberately distinct from a CAS failure.
|
|
232
|
+
if (!older && outcome === "applied") {
|
|
233
|
+
for (const deletion of input.mutations.rowDeletes) {
|
|
234
|
+
statements.push(this.database.prepare(`DELETE FROM mv_unsafe_index_entries WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`).bind(input.serviceId, input.viewId, input.generation, deletion.rowKey));
|
|
235
|
+
statements.push(this.database.prepare(`DELETE FROM mv_unsafe_rows WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`).bind(input.serviceId, input.viewId, input.generation, deletion.rowKey));
|
|
236
|
+
}
|
|
237
|
+
for (const upsert of input.mutations.rowUpserts) {
|
|
238
|
+
statements.push(this.database.prepare(`DELETE FROM mv_unsafe_index_entries WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`).bind(input.serviceId, input.viewId, input.generation, upsert.rowKey));
|
|
239
|
+
statements.push(this.database.prepare(`INSERT INTO mv_unsafe_rows (service_id, view_id, generation, row_key, value_json, row_version, source_suid, tombstone)
|
|
240
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 0)
|
|
241
|
+
ON CONFLICT (service_id, view_id, generation, row_key) DO UPDATE SET
|
|
242
|
+
value_json = excluded.value_json, row_version = excluded.row_version, source_suid = excluded.source_suid, tombstone = 0`).bind(input.serviceId, input.viewId, input.generation, upsert.rowKey, JSON.stringify(assertJsonValue(upsert.value, "state-persistence")), upsert.rowVersion, upsert.sourceSuid));
|
|
243
|
+
}
|
|
244
|
+
for (const patch of input.mutations.rowPatches) {
|
|
245
|
+
statements.push(this.database.prepare(`UPDATE mv_unsafe_rows SET value_json = json_patch(value_json, ?), row_version = ?, source_suid = ?, tombstone = 0
|
|
246
|
+
WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`).bind(JSON.stringify(assertJsonValue(patch.patch, "state-persistence")), patch.rowVersion, patch.sourceSuid, input.serviceId, input.viewId, input.generation, patch.rowKey));
|
|
247
|
+
}
|
|
248
|
+
for (const entry of input.mutations.indexEntries)
|
|
249
|
+
this.addIndex(statements, "mv_unsafe_index_entries", input, entry);
|
|
250
|
+
for (const patch of input.mutations.rowPatches)
|
|
251
|
+
for (const entry of patch.indexEntries)
|
|
252
|
+
this.addIndex(statements, "mv_unsafe_index_entries", input, entry);
|
|
253
|
+
}
|
|
254
|
+
const markerReason = outcome === "applied" ? undefined : outcome;
|
|
255
|
+
if (markerReason !== undefined) {
|
|
256
|
+
for (const rowKey of rowKeys.length === 0 ? [input.eventId] : rowKeys) {
|
|
257
|
+
statements.push(this.database.prepare(`INSERT INTO mv_unsafe_markers (service_id, view_id, generation, row_key, event_id, suid, reason)
|
|
258
|
+
VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`).bind(input.serviceId, input.viewId, input.generation, rowKey, input.eventId, input.suid, markerReason));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
statements.push(this.database.prepare(`INSERT INTO mv_unsafe_receipts (service_id, view_id, event_id, suid, outcome, observed_at) VALUES (?, ?, ?, ?, ?, ?)`).bind(input.serviceId, input.viewId, input.eventId, input.suid, outcome, input.updatedAt));
|
|
262
|
+
statements.push(this.database.prepare(`INSERT INTO mv_wait_receipts
|
|
263
|
+
(service_id, view_id, generation, definition_version, event_id, suid, observed_at)
|
|
264
|
+
SELECT ?, ?, ?, instance.definition_version, ?, ?, ?
|
|
265
|
+
FROM mv_instances instance
|
|
266
|
+
JOIN mv_active_generations active
|
|
267
|
+
ON active.service_id = instance.service_id
|
|
268
|
+
AND active.view_id = instance.view_id
|
|
269
|
+
AND active.generation = instance.generation
|
|
270
|
+
WHERE instance.service_id = ? AND instance.view_id = ? AND instance.generation = ?
|
|
271
|
+
ON CONFLICT DO NOTHING`).bind(input.serviceId, input.viewId, input.generation, input.eventId, input.suid, input.updatedAt, input.serviceId, input.viewId, input.generation));
|
|
272
|
+
statements.push(this.database.prepare(`INSERT INTO mv_unsafe_kicks (service_id, view_id, target_suid, dirty)
|
|
273
|
+
VALUES (?, ?, ?, 1)
|
|
274
|
+
ON CONFLICT (service_id, view_id) DO UPDATE SET
|
|
275
|
+
target_suid = CASE WHEN excluded.target_suid COLLATE BINARY > mv_unsafe_kicks.target_suid COLLATE BINARY THEN excluded.target_suid ELSE mv_unsafe_kicks.target_suid END,
|
|
276
|
+
dirty = 1`).bind(input.serviceId, input.viewId, input.targetSuid ?? input.suid));
|
|
277
|
+
statements.push(this.database.prepare("DELETE FROM mv_atomic_guards WHERE operation_id LIKE ?").bind(`${operation}:%`));
|
|
278
|
+
// The hook is test-only and is intentionally after the receipt pre-read,
|
|
279
|
+
// which lets two invocations reach the same D1 batch barrier and prove
|
|
280
|
+
// that the unique receipt loser is typed rather than a generic retry.
|
|
281
|
+
await this.options.beforeApplyBatch?.(input, statements);
|
|
282
|
+
try {
|
|
283
|
+
await this.database.batch(statements);
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
if (sameSuidContradiction)
|
|
287
|
+
throw new UnsafeWindowMaterializedViewError("UNSAFE_SUID_CONTRADICTION", false, "Unsafe same-SUID payload contradiction", { cause: error });
|
|
288
|
+
if (rowCasMismatch)
|
|
289
|
+
throw new UnsafeWindowMaterializedViewError("UNSAFE_ROW_CAS_MISMATCH", true, "Unsafe row version changed before atomic apply", { cause: error });
|
|
290
|
+
if (safeAhead)
|
|
291
|
+
throw new UnsafeWindowMaterializedViewError("UNSAFE_SAFE_AHEAD", false, "Safe head already covers unsafe event", { cause: error });
|
|
292
|
+
const typed = errorFrom(error);
|
|
293
|
+
if (typed !== undefined)
|
|
294
|
+
throw typed;
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
const result = { outcome, duplicate: false };
|
|
298
|
+
await this.options.afterApplyBatch?.(input, result);
|
|
299
|
+
return result;
|
|
300
|
+
}
|
|
301
|
+
/** Advance safe head and remove markers only if this transaction observed its exact receipt. */
|
|
302
|
+
async observeSafeReceipt(serviceId, viewId, generation, eventId, suid) {
|
|
303
|
+
assertSortableUniqueId(suid);
|
|
304
|
+
const operation = operationId();
|
|
305
|
+
await this.database.batch([
|
|
306
|
+
this.database.prepare(`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
307
|
+
SELECT ?, CASE WHEN EXISTS (SELECT 1 FROM mv_unsafe_receipts WHERE service_id = ? AND view_id = ? AND event_id = ? AND suid = ?) THEN 1 ELSE NULL END`).bind(operation, serviceId, viewId, eventId, suid),
|
|
308
|
+
this.database.prepare(`INSERT INTO mv_unsafe_arrivals (service_id, view_id, generation, safe_head, arrival_watermark)
|
|
309
|
+
VALUES (?, ?, ?, ?, ?)
|
|
310
|
+
ON CONFLICT (service_id, view_id, generation) DO UPDATE SET safe_head = excluded.safe_head`).bind(serviceId, viewId, generation, suid, suid),
|
|
311
|
+
this.database.prepare(`DELETE FROM mv_unsafe_markers WHERE service_id = ? AND view_id = ? AND generation = ? AND event_id = ? AND suid = ?`).bind(serviceId, viewId, generation, eventId, suid),
|
|
312
|
+
this.database.prepare("DELETE FROM mv_atomic_guards WHERE operation_id = ?").bind(operation),
|
|
313
|
+
]);
|
|
314
|
+
}
|
|
315
|
+
/** Detects late first-arrivals independently of SafeWindow and fail-closes reads. */
|
|
316
|
+
async observeArrival(serviceId, viewId, generation, eventId, suid) {
|
|
317
|
+
assertSortableUniqueId(suid);
|
|
318
|
+
const existing = await this.database.prepare(`SELECT safe_head, arrival_watermark FROM mv_unsafe_arrivals WHERE service_id = ? AND view_id = ? AND generation = ?`).bind(serviceId, viewId, generation).first();
|
|
319
|
+
const safeHead = existing === null || existing === undefined ? "" : string(existing, "safe_head");
|
|
320
|
+
const behind = safeHead !== "" && compareSuid(suid, safeHead) <= 0;
|
|
321
|
+
await this.database.prepare(`INSERT INTO mv_unsafe_arrivals (service_id, view_id, generation, safe_head, arrival_watermark, behind_frontier_event_id, behind_frontier_suid, rebuild_required)
|
|
322
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
323
|
+
ON CONFLICT (service_id, view_id, generation) DO UPDATE SET
|
|
324
|
+
arrival_watermark = CASE WHEN excluded.arrival_watermark COLLATE BINARY > mv_unsafe_arrivals.arrival_watermark COLLATE BINARY THEN excluded.arrival_watermark ELSE mv_unsafe_arrivals.arrival_watermark END,
|
|
325
|
+
behind_frontier_event_id = CASE WHEN mv_unsafe_arrivals.rebuild_required = 1 THEN mv_unsafe_arrivals.behind_frontier_event_id ELSE excluded.behind_frontier_event_id END,
|
|
326
|
+
behind_frontier_suid = CASE WHEN mv_unsafe_arrivals.rebuild_required = 1 THEN mv_unsafe_arrivals.behind_frontier_suid ELSE excluded.behind_frontier_suid END,
|
|
327
|
+
rebuild_required = MAX(mv_unsafe_arrivals.rebuild_required, excluded.rebuild_required)`).bind(serviceId, viewId, generation, safeHead, suid, behind ? eventId : null, behind ? suid : null, behind ? 1 : 0).run();
|
|
328
|
+
return behind;
|
|
329
|
+
}
|
|
330
|
+
async readMeta(serviceId, viewId, generation) {
|
|
331
|
+
const arrival = await this.database.prepare(`SELECT safe_head, rebuild_required FROM mv_unsafe_arrivals WHERE service_id = ? AND view_id = ? AND generation = ?`).bind(serviceId, viewId, generation).first();
|
|
332
|
+
const row = await this.database.prepare(`SELECT COALESCE(MAX(suid), '') AS max_suid FROM mv_unsafe_receipts WHERE service_id = ? AND view_id = ?`).bind(serviceId, viewId).first();
|
|
333
|
+
return {
|
|
334
|
+
safeContiguousHead: arrival === null || arrival === undefined ? "" : string(arrival, "safe_head"),
|
|
335
|
+
unsafeObservedMaxSuid: row === null || row === undefined ? "" : string(row, "max_suid"),
|
|
336
|
+
rebuildRequired: arrival !== null && arrival !== undefined && integer(arrival, "rebuild_required") === 1,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
async hasTargetReceipt(serviceId, viewId, eventId, suid) {
|
|
340
|
+
const receipt = await this.database.prepare(`SELECT 1 FROM mv_unsafe_receipts WHERE service_id = ? AND view_id = ? AND event_id = ? AND suid = ?`).bind(serviceId, viewId, eventId, suid).first();
|
|
341
|
+
return receipt !== null && receipt !== undefined;
|
|
342
|
+
}
|
|
343
|
+
async acquireKick(serviceId, viewId, owner, nowMs, leaseMs) {
|
|
344
|
+
const operation = operationId();
|
|
345
|
+
try {
|
|
346
|
+
await this.database.batch([
|
|
347
|
+
this.database.prepare(`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
348
|
+
SELECT ?, CASE WHEN EXISTS (SELECT 1 FROM mv_unsafe_kicks WHERE service_id = ? AND view_id = ? AND dirty = 1 AND (lease_owner IS NULL OR lease_until <= ? OR lease_owner = ?)) THEN 1 ELSE NULL END`).bind(operation, serviceId, viewId, nowMs, owner),
|
|
349
|
+
this.database.prepare(`UPDATE mv_unsafe_kicks SET lease_owner = ?, lease_until = ?, dirty = 0 WHERE service_id = ? AND view_id = ?`).bind(owner, nowMs + leaseMs, serviceId, viewId),
|
|
350
|
+
this.database.prepare("DELETE FROM mv_atomic_guards WHERE operation_id = ?").bind(operation),
|
|
351
|
+
]);
|
|
352
|
+
}
|
|
353
|
+
catch (error) {
|
|
354
|
+
throw new UnsafeWindowMaterializedViewError("UNSAFE_KICK_LEASE_HELD", true, "Unsafe kick is held by another lease owner", { cause: error });
|
|
355
|
+
}
|
|
356
|
+
const row = await this.database.prepare(`SELECT target_suid, dirty FROM mv_unsafe_kicks WHERE service_id = ? AND view_id = ?`).bind(serviceId, viewId).first();
|
|
357
|
+
return row === null || row === undefined ? undefined : { targetSuid: string(row, "target_suid"), dirty: integer(row, "dirty") === 1 };
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Settle an unsafe kick at the checkpoint actually reached by its follow.
|
|
361
|
+
* A first-unsafe return is a successful bounded pass, not proof that the
|
|
362
|
+
* lease target was reached. When a reached checkpoint is supplied, keep the
|
|
363
|
+
* durable dirty bit set if the target remains ahead so the next scheduled
|
|
364
|
+
* tick can acquire the kick again. Requiring this checkpoint keeps every
|
|
365
|
+
* production settlement honest about whether its target was reached.
|
|
366
|
+
*/
|
|
367
|
+
async finishKick(serviceId, viewId, owner, reachedSuid) {
|
|
368
|
+
if (reachedSuid.length > 0)
|
|
369
|
+
assertSortableUniqueId(reachedSuid);
|
|
370
|
+
const result = await this.database.prepare(`UPDATE mv_unsafe_kicks
|
|
371
|
+
SET lease_owner = NULL,
|
|
372
|
+
lease_until = 0,
|
|
373
|
+
dirty = CASE
|
|
374
|
+
WHEN ? IS NOT NULL AND target_suid COLLATE BINARY > ? COLLATE BINARY THEN 1
|
|
375
|
+
ELSE 0
|
|
376
|
+
END
|
|
377
|
+
WHERE service_id = ? AND view_id = ? AND lease_owner = ? AND dirty = 0`).bind(reachedSuid, reachedSuid, serviceId, viewId, owner).run();
|
|
378
|
+
return result.meta.changes === 1;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Collect a repairable unsafe row only when every recovery predicate holds.
|
|
382
|
+
* Every predicate is a constraint-promotion guard ahead of the deletes; a
|
|
383
|
+
* rejected GC therefore cannot leak a partially removed index/receipt.
|
|
384
|
+
*/
|
|
385
|
+
async garbageCollect(input) {
|
|
386
|
+
const operation = operationId();
|
|
387
|
+
const statements = [
|
|
388
|
+
// safeHead >= row source, including the mandatory post-GC resurrection fence.
|
|
389
|
+
this.database.prepare(`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match) SELECT ?, CASE WHEN ? COLLATE BINARY >= ? COLLATE BINARY THEN 1 ELSE NULL END`).bind(`${operation}:safe-head`, input.safeHead, input.expectedSourceSuid),
|
|
390
|
+
// Arrival receipt/watermark is a separate condition from the
|
|
391
|
+
// fail-closed detector state; each has its own in-batch guard.
|
|
392
|
+
this.database.prepare(`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
393
|
+
SELECT ?, CASE WHEN EXISTS (SELECT 1 FROM mv_unsafe_arrivals WHERE service_id = ? AND view_id = ? AND generation = ? AND arrival_watermark COLLATE BINARY >= ? COLLATE BINARY) THEN 1 ELSE NULL END`).bind(`${operation}:arrival`, input.serviceId, input.viewId, input.generation, input.expectedSourceSuid),
|
|
394
|
+
this.database.prepare(`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
395
|
+
SELECT ?, CASE WHEN EXISTS (SELECT 1 FROM mv_unsafe_arrivals WHERE service_id = ? AND view_id = ? AND generation = ? AND rebuild_required = 0) THEN 1 ELSE NULL END`).bind(`${operation}:frontier`, input.serviceId, input.viewId, input.generation),
|
|
396
|
+
// Candidate/retired generations and changed materializer definitions are
|
|
397
|
+
// never GC targets. These are intentionally separate guards so a
|
|
398
|
+
// definition mutation cannot piggy-back on an active-generation check.
|
|
399
|
+
this.database.prepare(`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
400
|
+
SELECT ?, CASE WHEN EXISTS (SELECT 1 FROM mv_active_generations WHERE service_id = ? AND view_id = ? AND generation = ?) THEN 1 ELSE NULL END`).bind(`${operation}:active-generation`, input.serviceId, input.viewId, input.generation),
|
|
401
|
+
this.database.prepare(`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
402
|
+
SELECT ?, CASE WHEN EXISTS (SELECT 1 FROM mv_instances WHERE service_id = ? AND view_id = ? AND generation = ? AND definition_version = ?) THEN 1 ELSE NULL END`).bind(`${operation}:definition`, input.serviceId, input.viewId, input.generation, input.definitionVersion),
|
|
403
|
+
// Compare-and-delete is revalidated inside the atomic batch, never on a
|
|
404
|
+
// read-then-delete snapshot.
|
|
405
|
+
this.database.prepare(`INSERT INTO mv_atomic_guards (operation_id, checkpoint_match)
|
|
406
|
+
SELECT ?, CASE WHEN EXISTS (SELECT 1 FROM mv_unsafe_rows WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ? AND row_version = ? AND source_suid COLLATE BINARY = ? COLLATE BINARY) THEN 1 ELSE NULL END`).bind(`${operation}:row`, input.serviceId, input.viewId, input.generation, input.rowKey, input.expectedRowVersion, input.expectedSourceSuid),
|
|
407
|
+
this.database.prepare(`DELETE FROM mv_unsafe_index_entries WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`).bind(input.serviceId, input.viewId, input.generation, input.rowKey),
|
|
408
|
+
this.database.prepare(`DELETE FROM mv_unsafe_markers WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ?`).bind(input.serviceId, input.viewId, input.generation, input.rowKey),
|
|
409
|
+
this.database.prepare(`DELETE FROM mv_unsafe_receipts WHERE service_id = ? AND view_id = ? AND suid = ?`).bind(input.serviceId, input.viewId, input.expectedSourceSuid),
|
|
410
|
+
this.database.prepare(`DELETE FROM mv_wait_receipts WHERE service_id = ? AND view_id = ? AND generation = ? AND suid = ?`).bind(input.serviceId, input.viewId, input.generation, input.expectedSourceSuid),
|
|
411
|
+
this.database.prepare(`DELETE FROM mv_unsafe_rows WHERE service_id = ? AND view_id = ? AND generation = ? AND row_key = ? AND row_version = ? AND source_suid COLLATE BINARY = ? COLLATE BINARY`).bind(input.serviceId, input.viewId, input.generation, input.rowKey, input.expectedRowVersion, input.expectedSourceSuid),
|
|
412
|
+
this.database.prepare("DELETE FROM mv_atomic_guards WHERE operation_id LIKE ?").bind(`${operation}:%`),
|
|
413
|
+
];
|
|
414
|
+
try {
|
|
415
|
+
await this.database.batch(statements);
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
catch (error) {
|
|
419
|
+
// Guard failure is an expected no-op for idle catch-up and never a
|
|
420
|
+
// successful delete. Other D1 failures remain visible to the caller.
|
|
421
|
+
if (String(error).includes("mv_atomic_guards") || String(error).includes("checkpoint_match"))
|
|
422
|
+
return false;
|
|
423
|
+
throw error;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Idle catch-up calls this after it has advanced (or observed) the safe
|
|
428
|
+
* checkpoint. Candidates are deliberately rechecked by garbageCollect;
|
|
429
|
+
* this read is only work selection, never permission to delete.
|
|
430
|
+
*/
|
|
431
|
+
async collectEligible(serviceId, viewId, generation, definitionVersion, safeHead) {
|
|
432
|
+
if (safeHead === "")
|
|
433
|
+
return 0;
|
|
434
|
+
const candidates = await this.database.prepare(`SELECT row_key, row_version, source_suid FROM mv_unsafe_rows
|
|
435
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?
|
|
436
|
+
AND source_suid COLLATE BINARY <= ? COLLATE BINARY
|
|
437
|
+
ORDER BY source_suid COLLATE BINARY ASC, row_key COLLATE BINARY ASC`).bind(serviceId, viewId, generation, safeHead).all();
|
|
438
|
+
let collected = 0;
|
|
439
|
+
for (const row of candidates.results) {
|
|
440
|
+
const candidate = {
|
|
441
|
+
rowKey: string(row, "row_key"),
|
|
442
|
+
rowVersion: integer(row, "row_version"),
|
|
443
|
+
sourceSuid: string(row, "source_suid"),
|
|
444
|
+
};
|
|
445
|
+
if (await this.garbageCollect({
|
|
446
|
+
serviceId,
|
|
447
|
+
viewId,
|
|
448
|
+
generation,
|
|
449
|
+
definitionVersion,
|
|
450
|
+
rowKey: candidate.rowKey,
|
|
451
|
+
expectedRowVersion: candidate.rowVersion,
|
|
452
|
+
expectedSourceSuid: candidate.sourceSuid,
|
|
453
|
+
safeHead,
|
|
454
|
+
}))
|
|
455
|
+
collected += 1;
|
|
456
|
+
}
|
|
457
|
+
return collected;
|
|
458
|
+
}
|
|
459
|
+
async queryComposedPage(serviceId, viewId, generation, limit, offset, descending = false) {
|
|
460
|
+
const meta = await this.readMeta(serviceId, viewId, generation);
|
|
461
|
+
if (meta.rebuildRequired)
|
|
462
|
+
throw new UnsafeWindowMaterializedViewError("UNSAFE_BEHIND_FRONTIER", false, "Unsafe window is rebuilding after a behind-frontier arrival");
|
|
463
|
+
// One statement owns winner selection, tombstone exclusion, deterministic
|
|
464
|
+
// ordering and COUNT OVER. No Worker full-table materialization is needed.
|
|
465
|
+
const direction = descending ? "DESC" : "ASC";
|
|
466
|
+
const result = await this.database.prepare(`WITH candidates AS (
|
|
467
|
+
SELECT row_key, value_json, row_version, source_suid, 0 AS tombstone, 0 AS unsafe_layer FROM mv_rows
|
|
468
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?
|
|
469
|
+
UNION ALL
|
|
470
|
+
SELECT row_key, value_json, row_version, source_suid, tombstone, 1 AS unsafe_layer FROM mv_unsafe_rows
|
|
471
|
+
WHERE service_id = ? AND view_id = ? AND generation = ?
|
|
472
|
+
), ranked AS (
|
|
473
|
+
SELECT *, ROW_NUMBER() OVER (PARTITION BY row_key ORDER BY source_suid COLLATE BINARY DESC, unsafe_layer ASC) AS winner_rank FROM candidates
|
|
474
|
+
), live AS (
|
|
475
|
+
SELECT *, COUNT(*) OVER() AS total_count FROM ranked WHERE winner_rank = 1 AND tombstone = 0
|
|
476
|
+
)
|
|
477
|
+
SELECT row_key, value_json, row_version, source_suid, total_count FROM live
|
|
478
|
+
ORDER BY source_suid COLLATE BINARY ${direction}, row_key COLLATE BINARY ${direction} LIMIT ? OFFSET ?`).bind(serviceId, viewId, generation, serviceId, viewId, generation, limit, offset).all();
|
|
479
|
+
const rows = result.results.map((row) => ({
|
|
480
|
+
serviceId,
|
|
481
|
+
viewId,
|
|
482
|
+
generation,
|
|
483
|
+
rowKey: string(row, "row_key"),
|
|
484
|
+
value: assertJsonValue(JSON.parse(string(row, "value_json")), "state-persistence"),
|
|
485
|
+
rowVersion: integer(row, "row_version"),
|
|
486
|
+
sourceSuid: string(row, "source_suid"),
|
|
487
|
+
}));
|
|
488
|
+
return { rows, totalCount: result.results.length === 0 ? 0 : integer(result.results[0], "total_count") };
|
|
489
|
+
}
|
|
490
|
+
addIndex(statements, table, input, entry) {
|
|
491
|
+
const typed = entry.valueType === "text" ? [entry.value, null, null] : entry.valueType === "integer" ? [null, entry.value, null] : [null, null, entry.value];
|
|
492
|
+
statements.push(this.database.prepare(`INSERT INTO ${table} (service_id, view_id, generation, index_id, value_type, text_value, integer_value, real_value, row_key)
|
|
493
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING`).bind(input.serviceId, input.viewId, input.generation, entry.indexId, entry.valueType, ...typed, entry.rowKey));
|
|
494
|
+
}
|
|
495
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { MaterializedViewOperationError, MaterializedViewRuntime, materializedViewCandidateId, materializedViewId, } from "./MaterializedViewRuntime";
|
|
2
|
+
export { D1MaterializedViewStore, MaterializedViewCasError, MaterializedViewPatchError, MaterializedViewPromotionCasError, MaterializedViewStoreError, } from "./MaterializedViewStore";
|
|
3
|
+
export type { MaterializedViewApplyInput, MaterializedViewApplyResult, MaterializedViewCandidateInput, MaterializedViewCreateInput, MaterializedViewIndexEntry, MaterializedViewInstance, MaterializedViewOrderingQuarantine, MaterializedViewOrderingQuarantineClassification, MaterializedViewPromoteInput, MaterializedViewQueryOptions, MaterializedViewRow, MaterializedViewWaitForState, MaterializedViewStore, MaterializedViewStoreErrorCode, MaterializedViewStoreOperation, } from "./MaterializedViewStore";
|
|
4
|
+
export { MaterializedViewCatchUpRuntime } from "./MaterializedViewCatchUp";
|
|
5
|
+
export type { MaterializedViewCatchUpHooks, MaterializedViewCatchUpResult } from "./MaterializedViewCatchUp";
|
|
6
|
+
export { UnsafeWindowMaterializedViewError, UnsafeWindowMaterializedViewStore } from "./UnsafeWindowMaterializedView";
|
|
7
|
+
export type { UnsafeComposedPage, UnsafeGcInput, UnsafeKickLease, UnsafeOutcome, UnsafeReadMeta, UnsafeWindowApplyInput, UnsafeWindowApplyResult, UnsafeWindowErrorCode } from "./UnsafeWindowMaterializedView";
|
|
8
|
+
export type { MaterializedViewDefinition, MaterializedViewErrorCode, MaterializedViewFollowHooks, MaterializedViewFollowResult, MaterializedViewOperation, MaterializedViewRebuildResult, MaterializedViewSnapshot, } from "./MaterializedViewRuntime";
|