@hops-ops/distributed 0.0.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/README.md +454 -0
- package/dist/auth-headers.d.ts +8 -0
- package/dist/auth-headers.js +35 -0
- package/dist/diagnostics.d.ts +2 -0
- package/dist/diagnostics.js +1 -0
- package/dist/document.d.ts +8 -0
- package/dist/document.js +6 -0
- package/dist/identity.d.ts +23 -0
- package/dist/identity.js +73 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/internal/cache-engine/create.d.ts +5 -0
- package/dist/internal/cache-engine/create.js +7 -0
- package/dist/internal/cache-engine/engine.d.ts +32 -0
- package/dist/internal/cache-engine/engine.js +1287 -0
- package/dist/internal/cache-engine/errors.d.ts +5 -0
- package/dist/internal/cache-engine/errors.js +10 -0
- package/dist/internal/cache-engine/helpers.d.ts +55 -0
- package/dist/internal/cache-engine/helpers.js +545 -0
- package/dist/internal/cache-engine/index.d.ts +3 -0
- package/dist/internal/cache-engine/index.js +2 -0
- package/dist/internal/cache-engine/types.d.ts +300 -0
- package/dist/internal/cache-engine/types.js +1 -0
- package/dist/internal/cache-engine.d.ts +3 -0
- package/dist/internal/cache-engine.js +2 -0
- package/dist/lib/assert-name.d.ts +2 -0
- package/dist/lib/assert-name.js +6 -0
- package/dist/lib/compare-code-units.d.ts +2 -0
- package/dist/lib/compare-code-units.js +4 -0
- package/dist/lib/deep-equal.d.ts +2 -0
- package/dist/lib/deep-equal.js +24 -0
- package/dist/lib/freeze-record.d.ts +2 -0
- package/dist/lib/freeze-record.js +13 -0
- package/dist/lib/index.d.ts +6 -0
- package/dist/lib/index.js +6 -0
- package/dist/lib/is-plain-record.d.ts +2 -0
- package/dist/lib/is-plain-record.js +8 -0
- package/dist/lib/report.d.ts +7 -0
- package/dist/lib/report.js +26 -0
- package/dist/protocol.d.ts +179 -0
- package/dist/protocol.js +558 -0
- package/dist/react/context.d.ts +20 -0
- package/dist/react/context.js +24 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.js +3 -0
- package/dist/react/query.d.ts +20 -0
- package/dist/react/query.js +99 -0
- package/dist/replica/command-id.d.ts +2 -0
- package/dist/replica/command-id.js +19 -0
- package/dist/replica/command-runtime/constants.d.ts +5 -0
- package/dist/replica/command-runtime/constants.js +5 -0
- package/dist/replica/command-runtime/create.d.ts +2 -0
- package/dist/replica/command-runtime/create.js +644 -0
- package/dist/replica/command-runtime/errors.d.ts +16 -0
- package/dist/replica/command-runtime/errors.js +41 -0
- package/dist/replica/command-runtime/index.d.ts +5 -0
- package/dist/replica/command-runtime/index.js +4 -0
- package/dist/replica/command-runtime/lib/binding.d.ts +4 -0
- package/dist/replica/command-runtime/lib/binding.js +64 -0
- package/dist/replica/command-runtime/lib/effects.d.ts +11 -0
- package/dist/replica/command-runtime/lib/effects.js +89 -0
- package/dist/replica/command-runtime/lib/index.d.ts +9 -0
- package/dist/replica/command-runtime/lib/index.js +9 -0
- package/dist/replica/command-runtime/lib/inventory.d.ts +10 -0
- package/dist/replica/command-runtime/lib/inventory.js +141 -0
- package/dist/replica/command-runtime/lib/output.d.ts +13 -0
- package/dist/replica/command-runtime/lib/output.js +190 -0
- package/dist/replica/command-runtime/lib/projection.d.ts +20 -0
- package/dist/replica/command-runtime/lib/projection.js +255 -0
- package/dist/replica/command-runtime/lib/status.d.ts +7 -0
- package/dist/replica/command-runtime/lib/status.js +127 -0
- package/dist/replica/command-runtime/lib/transport.d.ts +16 -0
- package/dist/replica/command-runtime/lib/transport.js +112 -0
- package/dist/replica/command-runtime/lib/util.d.ts +16 -0
- package/dist/replica/command-runtime/lib/util.js +127 -0
- package/dist/replica/command-runtime/lifecycle.d.ts +11 -0
- package/dist/replica/command-runtime/lifecycle.js +13 -0
- package/dist/replica/command-runtime/symbols.d.ts +30 -0
- package/dist/replica/command-runtime/symbols.js +30 -0
- package/dist/replica/command-runtime/types.d.ts +222 -0
- package/dist/replica/command-runtime/types.js +1 -0
- package/dist/replica/command-runtime.d.ts +3 -0
- package/dist/replica/command-runtime.js +2 -0
- package/dist/replica/commands/clone.d.ts +12 -0
- package/dist/replica/commands/clone.js +187 -0
- package/dist/replica/commands/constants.d.ts +6 -0
- package/dist/replica/commands/constants.js +6 -0
- package/dist/replica/commands/errors.d.ts +7 -0
- package/dist/replica/commands/errors.js +22 -0
- package/dist/replica/commands/implementation.d.ts +8 -0
- package/dist/replica/commands/implementation.js +8 -0
- package/dist/replica/commands/index.d.ts +5 -0
- package/dist/replica/commands/index.js +4 -0
- package/dist/replica/commands/prepare.d.ts +27 -0
- package/dist/replica/commands/prepare.js +77 -0
- package/dist/replica/commands/presets.d.ts +12 -0
- package/dist/replica/commands/presets.js +84 -0
- package/dist/replica/commands/receipt.d.ts +3 -0
- package/dist/replica/commands/receipt.js +36 -0
- package/dist/replica/commands/resolve.d.ts +11 -0
- package/dist/replica/commands/resolve.js +165 -0
- package/dist/replica/commands/types.d.ts +278 -0
- package/dist/replica/commands/types.js +2 -0
- package/dist/replica/commands/util.d.ts +21 -0
- package/dist/replica/commands/util.js +96 -0
- package/dist/replica/commands/validate.d.ts +18 -0
- package/dist/replica/commands/validate.js +477 -0
- package/dist/replica/commands.d.ts +3 -0
- package/dist/replica/commands.js +2 -0
- package/dist/replica/diagnostics/event-log.d.ts +4 -0
- package/dist/replica/diagnostics/event-log.js +463 -0
- package/dist/replica/diagnostics/implementation.d.ts +4 -0
- package/dist/replica/diagnostics/implementation.js +4 -0
- package/dist/replica/diagnostics/index.d.ts +2 -0
- package/dist/replica/diagnostics/index.js +1 -0
- package/dist/replica/diagnostics/inspect.d.ts +6 -0
- package/dist/replica/diagnostics/inspect.js +170 -0
- package/dist/replica/diagnostics/types.d.ts +295 -0
- package/dist/replica/diagnostics/types.js +2 -0
- package/dist/replica/diagnostics.d.ts +3 -0
- package/dist/replica/diagnostics.js +2 -0
- package/dist/replica/distributed-replica/clocks.d.ts +23 -0
- package/dist/replica/distributed-replica/clocks.js +156 -0
- package/dist/replica/distributed-replica/constants.d.ts +12 -0
- package/dist/replica/distributed-replica/constants.js +10 -0
- package/dist/replica/distributed-replica/helpers.d.ts +48 -0
- package/dist/replica/distributed-replica/helpers.js +445 -0
- package/dist/replica/distributed-replica/hydration.d.ts +19 -0
- package/dist/replica/distributed-replica/hydration.js +380 -0
- package/dist/replica/distributed-replica/impl-diagnostics.d.ts +22 -0
- package/dist/replica/distributed-replica/impl-diagnostics.js +189 -0
- package/dist/replica/distributed-replica/impl-fetch-live.d.ts +39 -0
- package/dist/replica/distributed-replica/impl-fetch-live.js +395 -0
- package/dist/replica/distributed-replica/impl-hydration-orchestrate.d.ts +56 -0
- package/dist/replica/distributed-replica/impl-hydration-orchestrate.js +299 -0
- package/dist/replica/distributed-replica/impl-optimistic.d.ts +31 -0
- package/dist/replica/distributed-replica/impl-optimistic.js +172 -0
- package/dist/replica/distributed-replica/impl-protocol.d.ts +53 -0
- package/dist/replica/distributed-replica/impl-protocol.js +108 -0
- package/dist/replica/distributed-replica/impl.d.ts +44 -0
- package/dist/replica/distributed-replica/impl.js +1776 -0
- package/dist/replica/distributed-replica/index.d.ts +2 -0
- package/dist/replica/distributed-replica/index.js +4 -0
- package/dist/replica/distributed-replica/optimistic.d.ts +30 -0
- package/dist/replica/distributed-replica/optimistic.js +221 -0
- package/dist/replica/distributed-replica/types.d.ts +147 -0
- package/dist/replica/distributed-replica/types.js +1 -0
- package/dist/replica/distributed-replica/watch.d.ts +19 -0
- package/dist/replica/distributed-replica/watch.js +95 -0
- package/dist/replica/distributed-replica.d.ts +2 -0
- package/dist/replica/distributed-replica.js +2 -0
- package/dist/replica/graphql-transport.d.ts +24 -0
- package/dist/replica/graphql-transport.js +122 -0
- package/dist/replica/identity/clone.d.ts +6 -0
- package/dist/replica/identity/clone.js +54 -0
- package/dist/replica/identity/codec.d.ts +48 -0
- package/dist/replica/identity/codec.js +789 -0
- package/dist/replica/identity/constants.d.ts +3 -0
- package/dist/replica/identity/constants.js +18 -0
- package/dist/replica/identity/implementation.d.ts +5 -0
- package/dist/replica/identity/implementation.js +5 -0
- package/dist/replica/identity/index.d.ts +3 -0
- package/dist/replica/identity/index.js +3 -0
- package/dist/replica/identity/keys.d.ts +29 -0
- package/dist/replica/identity/keys.js +173 -0
- package/dist/replica/identity.d.ts +2 -0
- package/dist/replica/identity.js +2 -0
- package/dist/replica/index-maintenance/engine.d.ts +84 -0
- package/dist/replica/index-maintenance/engine.js +677 -0
- package/dist/replica/index-maintenance/implementation.d.ts +4 -0
- package/dist/replica/index-maintenance/implementation.js +4 -0
- package/dist/replica/index-maintenance/index.d.ts +2 -0
- package/dist/replica/index-maintenance/index.js +1 -0
- package/dist/replica/index-maintenance/registry.d.ts +2 -0
- package/dist/replica/index-maintenance/registry.js +49 -0
- package/dist/replica/index-maintenance/types.d.ts +58 -0
- package/dist/replica/index-maintenance/types.js +1 -0
- package/dist/replica/index-maintenance.d.ts +3 -0
- package/dist/replica/index-maintenance.js +2 -0
- package/dist/replica/index.d.ts +17 -0
- package/dist/replica/index.js +9 -0
- package/dist/replica/materialize.d.ts +10 -0
- package/dist/replica/materialize.js +163 -0
- package/dist/replica/normalize.d.ts +27 -0
- package/dist/replica/normalize.js +298 -0
- package/dist/replica/operation-binding.d.ts +14 -0
- package/dist/replica/operation-binding.js +76 -0
- package/dist/replica/persistence/idb.d.ts +20 -0
- package/dist/replica/persistence/idb.js +193 -0
- package/dist/replica/persistence/implementation.d.ts +4 -0
- package/dist/replica/persistence/implementation.js +4 -0
- package/dist/replica/persistence/index.d.ts +2 -0
- package/dist/replica/persistence/index.js +1 -0
- package/dist/replica/persistence/state.d.ts +104 -0
- package/dist/replica/persistence/state.js +734 -0
- package/dist/replica/persistence/types.d.ts +54 -0
- package/dist/replica/persistence/types.js +1 -0
- package/dist/replica/persistence.d.ts +3 -0
- package/dist/replica/persistence.js +2 -0
- package/dist/replica/query-plan/constants.d.ts +13 -0
- package/dist/replica/query-plan/constants.js +7 -0
- package/dist/replica/query-plan/filter.d.ts +39 -0
- package/dist/replica/query-plan/filter.js +544 -0
- package/dist/replica/query-plan/index.d.ts +4 -0
- package/dist/replica/query-plan/index.js +3 -0
- package/dist/replica/query-plan/order.d.ts +11 -0
- package/dist/replica/query-plan/order.js +158 -0
- package/dist/replica/query-plan/pagination.d.ts +6 -0
- package/dist/replica/query-plan/pagination.js +90 -0
- package/dist/replica/query-plan/resolve.d.ts +14 -0
- package/dist/replica/query-plan/resolve.js +163 -0
- package/dist/replica/query-plan/types.d.ts +89 -0
- package/dist/replica/query-plan/types.js +1 -0
- package/dist/replica/query-plan/util.d.ts +13 -0
- package/dist/replica/query-plan/util.js +62 -0
- package/dist/replica/query-plan.d.ts +3 -0
- package/dist/replica/query-plan.js +2 -0
- package/dist/replica/revalidation.d.ts +9 -0
- package/dist/replica/revalidation.js +86 -0
- package/dist/replica/selection.d.ts +20 -0
- package/dist/replica/selection.js +109 -0
- package/dist/replica/types.d.ts +657 -0
- package/dist/replica/types.js +1 -0
- package/dist/request.d.ts +17 -0
- package/dist/request.js +123 -0
- package/dist/sveltekit/auth.d.ts +18 -0
- package/dist/sveltekit/auth.js +9 -0
- package/dist/sveltekit/context.d.ts +28 -0
- package/dist/sveltekit/context.js +58 -0
- package/dist/sveltekit/index.d.ts +4 -0
- package/dist/sveltekit/index.js +4 -0
- package/dist/sveltekit/replica.d.ts +145 -0
- package/dist/sveltekit/replica.js +491 -0
- package/dist/sveltekit/server-replica.d.ts +52 -0
- package/dist/sveltekit/server-replica.js +176 -0
- package/dist/sveltekit/vite.d.ts +103 -0
- package/dist/sveltekit/vite.js +878 -0
- package/dist/types.d.ts +33 -0
- package/dist/types.js +1 -0
- package/dist/websocket.d.ts +41 -0
- package/dist/websocket.js +187 -0
- package/package.json +95 -0
|
@@ -0,0 +1,1287 @@
|
|
|
1
|
+
import { CacheRevisionConflictError } from './errors.js';
|
|
2
|
+
import { assertName, assertSynchronousResult, assertWriterActive, cloneCacheValue, cloneDerivedIndexOperations, cloneFields, cloneIndexMetadata, cloneLink, cloneLinks, compareRecordTuple, deepEqual, dependenciesChanged, derivedIndexKeys, emptyVisibleRecord, freezeRecord, indexDependency, indexMetadataWithoutStaleReason, isOrderedSubsequence, isVisibleRecordLive, linkKeys, operationDependencies, parseSnapshot, recordFieldDependency, recordSeenDependency, recordWildcardDependency, refinementMetadataCompatible, reportSafely, reportUnhandledWatcherError, revisionString, revisionToken, runDerivedIndexReconciler, validateIndexWrite, validateRecordKey } from './helpers.js';
|
|
3
|
+
/**
|
|
4
|
+
* Minimum purpose-built implementation selected by the executable spike.
|
|
5
|
+
*
|
|
6
|
+
* It stores authoritative sparse records and exact indexes, then materializes
|
|
7
|
+
* named optimistic operation layers above them. Confirmation advances a
|
|
8
|
+
* per-dependency causal floor before removing the layer, so an older pending
|
|
9
|
+
* layer cannot become visible again after a newer command confirms.
|
|
10
|
+
*/
|
|
11
|
+
export class PurposeBuiltCacheEngine {
|
|
12
|
+
#records = new Map();
|
|
13
|
+
#indexes = new Map();
|
|
14
|
+
#layers = [];
|
|
15
|
+
#derivedIndexOperations = [];
|
|
16
|
+
#derivedIndexReconciler;
|
|
17
|
+
#reconcilingDerivedIndexes = false;
|
|
18
|
+
#confirmedFloors = new Map();
|
|
19
|
+
#retained = new Map();
|
|
20
|
+
#nextLayerSequence = 0;
|
|
21
|
+
#watchers = new Set();
|
|
22
|
+
#transactionDepth = 0;
|
|
23
|
+
#transactionLifecycleIndexes = new Set();
|
|
24
|
+
#dirty = false;
|
|
25
|
+
#changedDependencies = new Set();
|
|
26
|
+
#reportWatcherError;
|
|
27
|
+
constructor(options = {}) {
|
|
28
|
+
this.#reportWatcherError = options.onWatcherError ?? reportUnhandledWatcherError;
|
|
29
|
+
}
|
|
30
|
+
read(selector) {
|
|
31
|
+
if (typeof selector !== 'function')
|
|
32
|
+
throw new TypeError('cache selector must be a function');
|
|
33
|
+
return selector(this.#reader());
|
|
34
|
+
}
|
|
35
|
+
readConfirmed(selector) {
|
|
36
|
+
if (typeof selector !== 'function')
|
|
37
|
+
throw new TypeError('cache selector must be a function');
|
|
38
|
+
return selector(this.#reader(undefined, false));
|
|
39
|
+
}
|
|
40
|
+
confirmedIndexFences(keys) {
|
|
41
|
+
const fences = new Map();
|
|
42
|
+
for (const key of new Set(keys)) {
|
|
43
|
+
assertName(key, 'index key');
|
|
44
|
+
const index = this.#indexes.get(key);
|
|
45
|
+
if (index === undefined)
|
|
46
|
+
continue;
|
|
47
|
+
const fence = index.staleRevision !== undefined &&
|
|
48
|
+
index.staleRevision > index.revision
|
|
49
|
+
? index.staleRevision
|
|
50
|
+
: index.revision;
|
|
51
|
+
fences.set(key, revisionString(fence));
|
|
52
|
+
}
|
|
53
|
+
return fences;
|
|
54
|
+
}
|
|
55
|
+
watch(selector, listener, options = {}) {
|
|
56
|
+
if (typeof listener !== 'function')
|
|
57
|
+
throw new TypeError('cache listener must be a function');
|
|
58
|
+
const { value, dependencies } = this.#select(selector);
|
|
59
|
+
const watcher = { selector, listener, value, dependencies };
|
|
60
|
+
this.#watchers.add(watcher);
|
|
61
|
+
if (options.immediate) {
|
|
62
|
+
try {
|
|
63
|
+
listener(value, undefined);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
this.#watchers.delete(watcher);
|
|
67
|
+
reportSafely(this.#reportWatcherError, new AggregateError([error], 'initial cache watcher delivery failed'));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return () => this.#watchers.delete(watcher);
|
|
71
|
+
}
|
|
72
|
+
batch(update) {
|
|
73
|
+
if (typeof update !== 'function')
|
|
74
|
+
throw new TypeError('cache update must be a function');
|
|
75
|
+
if (this.#transactionDepth !== 0) {
|
|
76
|
+
throw new Error('nested cache batches are not supported');
|
|
77
|
+
}
|
|
78
|
+
return this.#transaction(() => {
|
|
79
|
+
const result = this.#runBaseUpdate(update);
|
|
80
|
+
this.#reconcileDerivedIndexes();
|
|
81
|
+
return result;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
createOptimisticLayer(id, update, context) {
|
|
85
|
+
assertName(id, 'optimistic layer id');
|
|
86
|
+
if (this.#layers.some((layer) => layer.id === id)) {
|
|
87
|
+
throw new Error(`optimistic layer already exists: ${id}`);
|
|
88
|
+
}
|
|
89
|
+
if (typeof update !== 'function') {
|
|
90
|
+
throw new TypeError('optimistic layer update must be a function');
|
|
91
|
+
}
|
|
92
|
+
const operations = [];
|
|
93
|
+
this.#runOptimisticUpdate(update, operations);
|
|
94
|
+
const stableContext = context === undefined ? undefined : cloneCacheValue(context);
|
|
95
|
+
this.#transaction(() => {
|
|
96
|
+
const before = this.#materialize();
|
|
97
|
+
this.#layers.push({
|
|
98
|
+
id,
|
|
99
|
+
sequence: ++this.#nextLayerSequence,
|
|
100
|
+
state: 'optimistic',
|
|
101
|
+
operations,
|
|
102
|
+
...(stableContext === undefined ? {} : { context: stableContext })
|
|
103
|
+
});
|
|
104
|
+
this.#markOverlayChanges(operations, before, this.#materialize());
|
|
105
|
+
this.#dirty = true;
|
|
106
|
+
this.#reconcileDerivedIndexes();
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
setDerivedIndexReconciler(reconciler) {
|
|
110
|
+
if (reconciler !== undefined && typeof reconciler !== 'function') {
|
|
111
|
+
throw new TypeError('derived index reconciler must be a function');
|
|
112
|
+
}
|
|
113
|
+
const previous = this.#derivedIndexReconciler;
|
|
114
|
+
try {
|
|
115
|
+
this.#transaction(() => {
|
|
116
|
+
this.#derivedIndexReconciler = reconciler;
|
|
117
|
+
this.#reconcileDerivedIndexes();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
this.#derivedIndexReconciler = previous;
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
markOptimisticLayerAccepted(id) {
|
|
126
|
+
const layer = this.#layers.find((candidate) => candidate.id === id);
|
|
127
|
+
if (!layer)
|
|
128
|
+
return false;
|
|
129
|
+
this.#transaction(() => {
|
|
130
|
+
layer.state = 'accepted';
|
|
131
|
+
this.#reconcileDerivedIndexes();
|
|
132
|
+
});
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
confirmOptimisticLayer(id, update) {
|
|
136
|
+
return this.confirmOptimisticLayers([id], update);
|
|
137
|
+
}
|
|
138
|
+
confirmOptimisticLayers(ids, update) {
|
|
139
|
+
const unique = [...new Set(ids)];
|
|
140
|
+
if (unique.length !== ids.length) {
|
|
141
|
+
throw new Error('optimistic layer confirmation contains duplicate ids');
|
|
142
|
+
}
|
|
143
|
+
const layers = unique.map((id) => {
|
|
144
|
+
const layer = this.#layers.find((candidate) => candidate.id === id);
|
|
145
|
+
if (!layer)
|
|
146
|
+
throw new Error(`unknown optimistic layer: ${id}`);
|
|
147
|
+
return layer;
|
|
148
|
+
});
|
|
149
|
+
return this.#transaction(() => {
|
|
150
|
+
const before = this.#materialize();
|
|
151
|
+
const layerDependencies = new Map();
|
|
152
|
+
for (const layer of layers) {
|
|
153
|
+
for (const operation of layer.operations) {
|
|
154
|
+
for (const dependency of operationDependencies(operation)) {
|
|
155
|
+
layerDependencies.set(dependency, Math.max(layerDependencies.get(dependency) ?? 0, layer.sequence));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// A command result may contain a full row, but only fields owned by this
|
|
160
|
+
// optimistic layer are confirmed. Advancing floors for every field in the
|
|
161
|
+
// server payload would incorrectly erase independent, older overlays.
|
|
162
|
+
const result = this.#runBaseUpdate(update);
|
|
163
|
+
for (const [dependency, sequence] of layerDependencies) {
|
|
164
|
+
const previous = this.#confirmedFloors.get(dependency) ?? 0;
|
|
165
|
+
if (sequence > previous)
|
|
166
|
+
this.#confirmedFloors.set(dependency, sequence);
|
|
167
|
+
}
|
|
168
|
+
const removed = new Set(layers);
|
|
169
|
+
this.#layers = this.#layers.filter((candidate) => !removed.has(candidate));
|
|
170
|
+
for (const layer of layers) {
|
|
171
|
+
this.#markOverlayChanges(layer.operations, before, this.#materialize());
|
|
172
|
+
}
|
|
173
|
+
if (this.#layers.length === 0)
|
|
174
|
+
this.#confirmedFloors.clear();
|
|
175
|
+
this.#dirty = true;
|
|
176
|
+
this.#reconcileDerivedIndexes();
|
|
177
|
+
return result;
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
rejectOptimisticLayer(id) {
|
|
181
|
+
const layer = this.#layers.find((candidate) => candidate.id === id);
|
|
182
|
+
if (!layer)
|
|
183
|
+
return false;
|
|
184
|
+
this.#transaction(() => {
|
|
185
|
+
const before = this.#materialize();
|
|
186
|
+
this.#layers = this.#layers.filter((candidate) => candidate !== layer);
|
|
187
|
+
this.#markOverlayChanges(layer.operations, before, this.#materialize());
|
|
188
|
+
if (this.#layers.length === 0)
|
|
189
|
+
this.#confirmedFloors.clear();
|
|
190
|
+
this.#dirty = true;
|
|
191
|
+
this.#reconcileDerivedIndexes();
|
|
192
|
+
});
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
optimisticLayerState(id) {
|
|
196
|
+
return this.#layers.find((layer) => layer.id === id)?.state;
|
|
197
|
+
}
|
|
198
|
+
extract() {
|
|
199
|
+
return Object.freeze({
|
|
200
|
+
version: 1,
|
|
201
|
+
records: Object.freeze([...this.#records]
|
|
202
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
203
|
+
.map(([key, record]) => Object.freeze({
|
|
204
|
+
key,
|
|
205
|
+
revision: revisionString(record.revision),
|
|
206
|
+
incarnation: revisionString(record.incarnation),
|
|
207
|
+
...(record.tombstoneRevision === undefined
|
|
208
|
+
? {}
|
|
209
|
+
: { tombstoneRevision: revisionString(record.tombstoneRevision) }),
|
|
210
|
+
fields: freezeRecord([...record.fields]
|
|
211
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
212
|
+
.map(([name, field]) => [
|
|
213
|
+
name,
|
|
214
|
+
Object.freeze({
|
|
215
|
+
revision: revisionString(field.revision),
|
|
216
|
+
value: cloneCacheValue(field.value)
|
|
217
|
+
})
|
|
218
|
+
])),
|
|
219
|
+
links: freezeRecord([...record.links]
|
|
220
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
221
|
+
.map(([name, link]) => [
|
|
222
|
+
name,
|
|
223
|
+
Object.freeze({
|
|
224
|
+
revision: revisionString(link.revision),
|
|
225
|
+
value: cloneLink(link.value)
|
|
226
|
+
})
|
|
227
|
+
]))
|
|
228
|
+
}))),
|
|
229
|
+
indexes: Object.freeze([...this.#indexes]
|
|
230
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
231
|
+
.map(([key, index]) => Object.freeze({
|
|
232
|
+
key,
|
|
233
|
+
revision: revisionString(index.revision),
|
|
234
|
+
...(index.staleRevision === undefined
|
|
235
|
+
? {}
|
|
236
|
+
: { staleRevision: revisionString(index.staleRevision) }),
|
|
237
|
+
records: Object.freeze([...index.records]),
|
|
238
|
+
complete: index.complete,
|
|
239
|
+
deleted: index.deleted,
|
|
240
|
+
...(index.metadata === undefined
|
|
241
|
+
? {}
|
|
242
|
+
: { metadata: cloneIndexMetadata(index.metadata) })
|
|
243
|
+
})))
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
restore(snapshot) {
|
|
247
|
+
const restored = parseSnapshot(snapshot);
|
|
248
|
+
this.#transaction(() => {
|
|
249
|
+
this.#records = restored.records;
|
|
250
|
+
this.#indexes = restored.indexes;
|
|
251
|
+
this.#layers = [];
|
|
252
|
+
this.#derivedIndexOperations = [];
|
|
253
|
+
this.#confirmedFloors.clear();
|
|
254
|
+
this.#nextLayerSequence = 0;
|
|
255
|
+
this.#changedDependencies.add('*');
|
|
256
|
+
this.#dirty = true;
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
restoreConfirmed(snapshot) {
|
|
260
|
+
const restored = parseSnapshot(snapshot);
|
|
261
|
+
this.#transaction(() => {
|
|
262
|
+
this.#records = restored.records;
|
|
263
|
+
this.#indexes = restored.indexes;
|
|
264
|
+
this.#derivedIndexOperations = [];
|
|
265
|
+
this.#changedDependencies.add('*');
|
|
266
|
+
this.#dirty = true;
|
|
267
|
+
this.#reconcileDerivedIndexes();
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
discardIndexes(keys) {
|
|
271
|
+
const unique = new Set();
|
|
272
|
+
for (const key of keys) {
|
|
273
|
+
assertName(key, 'index key');
|
|
274
|
+
unique.add(key);
|
|
275
|
+
}
|
|
276
|
+
if (unique.size === 0)
|
|
277
|
+
return;
|
|
278
|
+
this.#transaction(() => {
|
|
279
|
+
let changed = false;
|
|
280
|
+
for (const key of unique) {
|
|
281
|
+
if (!this.#indexes.delete(key))
|
|
282
|
+
continue;
|
|
283
|
+
this.#changedDependencies.add(indexDependency(key));
|
|
284
|
+
changed = true;
|
|
285
|
+
}
|
|
286
|
+
if (changed)
|
|
287
|
+
this.#dirty = true;
|
|
288
|
+
this.#reconcileDerivedIndexes();
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
retain(key) {
|
|
292
|
+
assertName(key, 'record key');
|
|
293
|
+
this.#retained.set(key, (this.#retained.get(key) ?? 0) + 1);
|
|
294
|
+
}
|
|
295
|
+
release(key) {
|
|
296
|
+
const count = this.#retained.get(key);
|
|
297
|
+
if (count === undefined)
|
|
298
|
+
return;
|
|
299
|
+
if (count <= 1)
|
|
300
|
+
this.#retained.delete(key);
|
|
301
|
+
else
|
|
302
|
+
this.#retained.set(key, count - 1);
|
|
303
|
+
}
|
|
304
|
+
gc() {
|
|
305
|
+
return this.#transaction(() => {
|
|
306
|
+
// A destructive optimistic overlay must not make confirmed state
|
|
307
|
+
// collectible: rejecting that layer has to reveal the complete base
|
|
308
|
+
// graph again. Conversely, an optimistic index/link may be the only
|
|
309
|
+
// current root for an authoritative record. Preserve the union.
|
|
310
|
+
const optimisticRoots = this.#optimisticRecordRoots();
|
|
311
|
+
const baseGraph = this.#materialize(false);
|
|
312
|
+
const visibleGraph = this.#materialize(true);
|
|
313
|
+
const reachable = new Set([
|
|
314
|
+
...this.#reachableRecords(baseGraph, optimisticRoots),
|
|
315
|
+
...this.#reachableRecords(visibleGraph, optimisticRoots)
|
|
316
|
+
]);
|
|
317
|
+
const collected = [];
|
|
318
|
+
for (const [key, record] of this.#records) {
|
|
319
|
+
// Tombstones are revision fences and cannot be collected like data rows.
|
|
320
|
+
if (record.tombstoneRevision === undefined && !reachable.has(key)) {
|
|
321
|
+
this.#records.delete(key);
|
|
322
|
+
this.#changedDependencies.add(recordSeenDependency(key));
|
|
323
|
+
this.#changedDependencies.add(recordWildcardDependency(key));
|
|
324
|
+
collected.push(key);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
let indexesCollected = false;
|
|
328
|
+
for (const [key, index] of this.#indexes) {
|
|
329
|
+
const parent = index.metadata?.parent;
|
|
330
|
+
if (parent === undefined)
|
|
331
|
+
continue;
|
|
332
|
+
const parentIsLive = isVisibleRecordLive(baseGraph.records.get(parent)) ||
|
|
333
|
+
isVisibleRecordLive(visibleGraph.records.get(parent));
|
|
334
|
+
if (reachable.has(parent) && parentIsLive)
|
|
335
|
+
continue;
|
|
336
|
+
this.#indexes.delete(key);
|
|
337
|
+
this.#changedDependencies.add(indexDependency(key));
|
|
338
|
+
indexesCollected = true;
|
|
339
|
+
}
|
|
340
|
+
if (collected.length > 0 || indexesCollected)
|
|
341
|
+
this.#dirty = true;
|
|
342
|
+
this.#reconcileDerivedIndexes();
|
|
343
|
+
return Object.freeze(collected.sort());
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
#reader(dependencies, includeOptimistic = true) {
|
|
347
|
+
// V1 executable-spike tradeoff: materializing the visible graph is
|
|
348
|
+
// O(records + indexes + overlay operations) per selector. The private seam
|
|
349
|
+
// keeps this replaceable with an incrementally indexed graph without
|
|
350
|
+
// changing generated artifacts or the public replica API.
|
|
351
|
+
const { records, indexes } = this.#materialize(includeOptimistic);
|
|
352
|
+
return Object.freeze({
|
|
353
|
+
recordMeta(key) {
|
|
354
|
+
dependencies?.add(recordSeenDependency(key));
|
|
355
|
+
const record = records.get(key);
|
|
356
|
+
if (!record || record.tombstoned)
|
|
357
|
+
return undefined;
|
|
358
|
+
return Object.freeze({
|
|
359
|
+
key,
|
|
360
|
+
incarnation: revisionString(record.incarnation)
|
|
361
|
+
});
|
|
362
|
+
},
|
|
363
|
+
field(key, name) {
|
|
364
|
+
assertName(name, 'record field');
|
|
365
|
+
dependencies?.add(recordSeenDependency(key));
|
|
366
|
+
dependencies?.add(recordFieldDependency(key, `field:${name}`));
|
|
367
|
+
const record = records.get(key);
|
|
368
|
+
if (!record || record.tombstoned || !record.fields.has(name)) {
|
|
369
|
+
return Object.freeze({ present: false });
|
|
370
|
+
}
|
|
371
|
+
return Object.freeze({
|
|
372
|
+
present: true,
|
|
373
|
+
value: cloneCacheValue(record.fields.get(name))
|
|
374
|
+
});
|
|
375
|
+
},
|
|
376
|
+
link(key, name) {
|
|
377
|
+
assertName(name, 'record link');
|
|
378
|
+
dependencies?.add(recordSeenDependency(key));
|
|
379
|
+
dependencies?.add(recordFieldDependency(key, `link:${name}`));
|
|
380
|
+
const record = records.get(key);
|
|
381
|
+
if (!record || record.tombstoned || !record.links.has(name)) {
|
|
382
|
+
return Object.freeze({ present: false });
|
|
383
|
+
}
|
|
384
|
+
return Object.freeze({
|
|
385
|
+
present: true,
|
|
386
|
+
value: cloneLink(record.links.get(name))
|
|
387
|
+
});
|
|
388
|
+
},
|
|
389
|
+
record(key) {
|
|
390
|
+
dependencies?.add(recordWildcardDependency(key));
|
|
391
|
+
const record = records.get(key);
|
|
392
|
+
if (!record || record.tombstoned)
|
|
393
|
+
return undefined;
|
|
394
|
+
return Object.freeze({
|
|
395
|
+
key,
|
|
396
|
+
revision: revisionString(record.revision),
|
|
397
|
+
incarnation: revisionString(record.incarnation),
|
|
398
|
+
fields: freezeRecord([...record.fields].map(([name, value]) => [name, cloneCacheValue(value)])),
|
|
399
|
+
links: freezeRecord([...record.links].map(([name, value]) => [name, cloneLink(value)]))
|
|
400
|
+
});
|
|
401
|
+
},
|
|
402
|
+
index(key) {
|
|
403
|
+
dependencies?.add(indexDependency(key));
|
|
404
|
+
const index = indexes.get(key);
|
|
405
|
+
if (!index || index.deleted)
|
|
406
|
+
return undefined;
|
|
407
|
+
return Object.freeze({
|
|
408
|
+
key,
|
|
409
|
+
revision: revisionString(index.revision),
|
|
410
|
+
...(index.staleRevision === undefined
|
|
411
|
+
? {}
|
|
412
|
+
: { staleRevision: revisionString(index.staleRevision) }),
|
|
413
|
+
records: Object.freeze([...index.records]),
|
|
414
|
+
complete: index.complete,
|
|
415
|
+
...(index.metadata === undefined
|
|
416
|
+
? {}
|
|
417
|
+
: { metadata: cloneIndexMetadata(index.metadata) })
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
#select(selector) {
|
|
423
|
+
const dependencies = new Set();
|
|
424
|
+
return { value: selector(this.#reader(dependencies)), dependencies };
|
|
425
|
+
}
|
|
426
|
+
#optimisticRecordRoots() {
|
|
427
|
+
const roots = new Set();
|
|
428
|
+
for (const layer of this.#layers) {
|
|
429
|
+
for (const operation of layer.operations) {
|
|
430
|
+
if (operation.kind === 'write-record') {
|
|
431
|
+
roots.add(operation.write.key);
|
|
432
|
+
for (const link of Object.values(operation.write.links ?? {})) {
|
|
433
|
+
for (const key of linkKeys(link))
|
|
434
|
+
roots.add(key);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
else if (operation.kind === 'tombstone-record') {
|
|
438
|
+
roots.add(operation.key);
|
|
439
|
+
}
|
|
440
|
+
else if (operation.kind === 'write-index') {
|
|
441
|
+
for (const key of operation.write.records)
|
|
442
|
+
roots.add(key);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
for (const operation of this.#derivedIndexOperations) {
|
|
447
|
+
if (operation.kind !== 'write-index')
|
|
448
|
+
continue;
|
|
449
|
+
for (const key of operation.write.records)
|
|
450
|
+
roots.add(key);
|
|
451
|
+
}
|
|
452
|
+
return roots;
|
|
453
|
+
}
|
|
454
|
+
#reachableRecords(graph, extraRoots) {
|
|
455
|
+
const reachable = new Set([...this.#retained.keys(), ...extraRoots]);
|
|
456
|
+
const pending = [...reachable];
|
|
457
|
+
const relationshipIndexes = new Map();
|
|
458
|
+
for (const index of graph.indexes.values()) {
|
|
459
|
+
if (index.deleted)
|
|
460
|
+
continue;
|
|
461
|
+
const parent = index.metadata?.parent;
|
|
462
|
+
if (parent !== undefined) {
|
|
463
|
+
const indexes = relationshipIndexes.get(parent) ?? [];
|
|
464
|
+
indexes.push(index);
|
|
465
|
+
relationshipIndexes.set(parent, indexes);
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
for (const key of index.records) {
|
|
469
|
+
if (reachable.has(key))
|
|
470
|
+
continue;
|
|
471
|
+
reachable.add(key);
|
|
472
|
+
pending.push(key);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
while (pending.length > 0) {
|
|
476
|
+
const key = pending.pop();
|
|
477
|
+
const record = graph.records.get(key);
|
|
478
|
+
if (!record || record.tombstoned)
|
|
479
|
+
continue;
|
|
480
|
+
for (const link of record.links.values()) {
|
|
481
|
+
for (const key of linkKeys(link)) {
|
|
482
|
+
if (reachable.has(key))
|
|
483
|
+
continue;
|
|
484
|
+
reachable.add(key);
|
|
485
|
+
pending.push(key);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
for (const index of relationshipIndexes.get(key) ?? []) {
|
|
489
|
+
for (const child of index.records) {
|
|
490
|
+
if (reachable.has(child))
|
|
491
|
+
continue;
|
|
492
|
+
reachable.add(child);
|
|
493
|
+
pending.push(child);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return reachable;
|
|
498
|
+
}
|
|
499
|
+
#materialize(includeOptimistic = true) {
|
|
500
|
+
const records = new Map();
|
|
501
|
+
const indexes = new Map();
|
|
502
|
+
for (const [key, record] of this.#records) {
|
|
503
|
+
records.set(key, {
|
|
504
|
+
revision: record.revision,
|
|
505
|
+
incarnation: record.incarnation,
|
|
506
|
+
tombstoned: record.tombstoneRevision !== undefined,
|
|
507
|
+
fields: new Map([...record.fields].map(([name, field]) => [name, field.value])),
|
|
508
|
+
links: new Map([...record.links].map(([name, link]) => [name, link.value]))
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
for (const [key, index] of this.#indexes) {
|
|
512
|
+
indexes.set(key, {
|
|
513
|
+
revision: index.revision,
|
|
514
|
+
staleRevision: index.staleRevision,
|
|
515
|
+
records: [...index.records],
|
|
516
|
+
complete: index.complete,
|
|
517
|
+
deleted: index.deleted,
|
|
518
|
+
metadata: index.metadata
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
if (includeOptimistic) {
|
|
522
|
+
for (const layer of this.#layers) {
|
|
523
|
+
for (const operation of layer.operations) {
|
|
524
|
+
this.#applyOverlay(records, indexes, layer.sequence, operation);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
for (const operation of this.#derivedIndexOperations) {
|
|
528
|
+
this.#applyDerivedIndexOperation(records, indexes, operation);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
return { records, indexes };
|
|
532
|
+
}
|
|
533
|
+
#markOverlayChanges(operations, before, after) {
|
|
534
|
+
for (const operation of operations) {
|
|
535
|
+
if (operation.kind === 'write-record') {
|
|
536
|
+
const key = operation.write.key;
|
|
537
|
+
this.#changedDependencies.add(recordWildcardDependency(key));
|
|
538
|
+
for (const name of Object.keys(operation.write.fields ?? {})) {
|
|
539
|
+
this.#changedDependencies.add(recordFieldDependency(key, `field:${name}`));
|
|
540
|
+
}
|
|
541
|
+
for (const name of Object.keys(operation.write.links ?? {})) {
|
|
542
|
+
this.#changedDependencies.add(recordFieldDependency(key, `link:${name}`));
|
|
543
|
+
}
|
|
544
|
+
const previous = before.records.get(key);
|
|
545
|
+
const next = after.records.get(key);
|
|
546
|
+
if ((previous !== undefined && !previous.tombstoned) !==
|
|
547
|
+
(next !== undefined && !next.tombstoned) ||
|
|
548
|
+
previous?.incarnation !== next?.incarnation) {
|
|
549
|
+
this.#changedDependencies.add(recordSeenDependency(key));
|
|
550
|
+
}
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
if (operation.kind === 'tombstone-record') {
|
|
554
|
+
this.#changedDependencies.add(recordSeenDependency(operation.key));
|
|
555
|
+
this.#changedDependencies.add(recordWildcardDependency(operation.key));
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
this.#changedDependencies.add(indexDependency(operation.kind === 'write-index' ? operation.write.key : operation.key));
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
#applyOverlay(records, indexes, sequence, operation) {
|
|
562
|
+
if (operation.kind === 'write-record') {
|
|
563
|
+
const { key, fields = {}, links = {} } = operation.write;
|
|
564
|
+
let record = records.get(key);
|
|
565
|
+
let wrote = false;
|
|
566
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
567
|
+
if (sequence <= this.#recordFieldFloor(key, `field:${name}`))
|
|
568
|
+
continue;
|
|
569
|
+
if (!record)
|
|
570
|
+
record = emptyVisibleRecord();
|
|
571
|
+
record.fields.set(name, value);
|
|
572
|
+
wrote = true;
|
|
573
|
+
}
|
|
574
|
+
for (const [name, value] of Object.entries(links)) {
|
|
575
|
+
if (sequence <= this.#recordFieldFloor(key, `link:${name}`))
|
|
576
|
+
continue;
|
|
577
|
+
if (!record)
|
|
578
|
+
record = emptyVisibleRecord();
|
|
579
|
+
record.links.set(name, value);
|
|
580
|
+
wrote = true;
|
|
581
|
+
}
|
|
582
|
+
if (wrote && record) {
|
|
583
|
+
record.tombstoned = false;
|
|
584
|
+
records.set(key, record);
|
|
585
|
+
}
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (operation.kind === 'tombstone-record') {
|
|
589
|
+
if (sequence <= (this.#confirmedFloors.get(recordSeenDependency(operation.key)) ?? 0)) {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
const record = records.get(operation.key) ?? emptyVisibleRecord();
|
|
593
|
+
record.tombstoned = true;
|
|
594
|
+
record.fields.clear();
|
|
595
|
+
record.links.clear();
|
|
596
|
+
records.set(operation.key, record);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const key = operation.kind === 'write-index' ? operation.write.key : operation.key;
|
|
600
|
+
if (sequence <= (this.#confirmedFloors.get(indexDependency(key)) ?? 0))
|
|
601
|
+
return;
|
|
602
|
+
if (operation.kind === 'write-index') {
|
|
603
|
+
let metadata = operation.write.metadata;
|
|
604
|
+
if (metadata?.parent !== undefined) {
|
|
605
|
+
const parent = records.get(metadata.parent);
|
|
606
|
+
if (!parent || parent.tombstoned)
|
|
607
|
+
return;
|
|
608
|
+
if (metadata.parentRevision !== undefined &&
|
|
609
|
+
revisionToken(metadata.parentRevision) !== parent.revision) {
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (metadata.parentIncarnation !== undefined &&
|
|
613
|
+
revisionToken(metadata.parentIncarnation) !== parent.incarnation) {
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
metadata = cloneIndexMetadata({
|
|
617
|
+
...metadata,
|
|
618
|
+
parentIncarnation: revisionString(parent.incarnation)
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
indexes.set(key, {
|
|
622
|
+
revision: indexes.get(key)?.revision ?? 0n,
|
|
623
|
+
staleRevision: indexes.get(key)?.staleRevision,
|
|
624
|
+
records: [...operation.write.records],
|
|
625
|
+
complete: operation.write.complete ?? false,
|
|
626
|
+
deleted: false,
|
|
627
|
+
metadata
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
const index = indexes.get(key) ?? {
|
|
632
|
+
revision: 0n,
|
|
633
|
+
staleRevision: undefined,
|
|
634
|
+
records: [],
|
|
635
|
+
complete: false,
|
|
636
|
+
deleted: true,
|
|
637
|
+
metadata: undefined
|
|
638
|
+
};
|
|
639
|
+
index.deleted = true;
|
|
640
|
+
index.records = [];
|
|
641
|
+
indexes.set(key, index);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
#applyDerivedIndexOperation(records, indexes, operation) {
|
|
645
|
+
if (operation.kind === 'mark-index-stale') {
|
|
646
|
+
const index = indexes.get(operation.key);
|
|
647
|
+
if (!index || index.deleted)
|
|
648
|
+
return;
|
|
649
|
+
/*
|
|
650
|
+
* Staleness is a freshness claim, not structural data loss. Keep a
|
|
651
|
+
* previously complete visible index renderable while the owner
|
|
652
|
+
* revalidates it. Operations that actually remove membership or
|
|
653
|
+
* records explicitly clear `complete` in their own lifecycle path.
|
|
654
|
+
*/
|
|
655
|
+
if (index.staleRevision === undefined ||
|
|
656
|
+
index.staleRevision < index.revision) {
|
|
657
|
+
index.staleRevision = index.revision;
|
|
658
|
+
}
|
|
659
|
+
if (index.metadata !== undefined) {
|
|
660
|
+
index.metadata = cloneIndexMetadata({
|
|
661
|
+
...index.metadata,
|
|
662
|
+
staleReason: operation.reason
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const key = operation.kind === 'write-index' ? operation.write.key : operation.key;
|
|
668
|
+
if (operation.kind === 'delete-index') {
|
|
669
|
+
const index = indexes.get(key) ?? {
|
|
670
|
+
revision: 0n,
|
|
671
|
+
staleRevision: undefined,
|
|
672
|
+
records: [],
|
|
673
|
+
complete: false,
|
|
674
|
+
deleted: true,
|
|
675
|
+
metadata: undefined
|
|
676
|
+
};
|
|
677
|
+
index.deleted = true;
|
|
678
|
+
index.records = [];
|
|
679
|
+
indexes.set(key, index);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
let metadata = operation.write.metadata;
|
|
683
|
+
if (metadata?.parent !== undefined) {
|
|
684
|
+
const parent = records.get(metadata.parent);
|
|
685
|
+
if (!parent || parent.tombstoned)
|
|
686
|
+
return;
|
|
687
|
+
if (metadata.parentRevision !== undefined &&
|
|
688
|
+
revisionToken(metadata.parentRevision) !== parent.revision) {
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
if (metadata.parentIncarnation !== undefined &&
|
|
692
|
+
revisionToken(metadata.parentIncarnation) !== parent.incarnation) {
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
metadata = cloneIndexMetadata({
|
|
696
|
+
...metadata,
|
|
697
|
+
parentIncarnation: revisionString(parent.incarnation)
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
indexes.set(key, {
|
|
701
|
+
revision: indexes.get(key)?.revision ?? 0n,
|
|
702
|
+
staleRevision: indexes.get(key)?.staleRevision,
|
|
703
|
+
records: [...operation.write.records],
|
|
704
|
+
complete: operation.write.complete ?? false,
|
|
705
|
+
deleted: false,
|
|
706
|
+
metadata
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
#reconcileDerivedIndexes() {
|
|
710
|
+
if (this.#reconcilingDerivedIndexes) {
|
|
711
|
+
throw new Error('derived index reconciliation cannot be re-entered');
|
|
712
|
+
}
|
|
713
|
+
const reconciler = this.#derivedIndexReconciler;
|
|
714
|
+
let next;
|
|
715
|
+
this.#reconcilingDerivedIndexes = true;
|
|
716
|
+
try {
|
|
717
|
+
next =
|
|
718
|
+
reconciler === undefined
|
|
719
|
+
? []
|
|
720
|
+
: cloneDerivedIndexOperations(runDerivedIndexReconciler(reconciler, this.extract(), this.#layers.map((layer) => Object.freeze({
|
|
721
|
+
id: layer.id,
|
|
722
|
+
sequence: layer.sequence,
|
|
723
|
+
state: layer.state,
|
|
724
|
+
...(layer.context === undefined
|
|
725
|
+
? {}
|
|
726
|
+
: { context: layer.context })
|
|
727
|
+
}))));
|
|
728
|
+
}
|
|
729
|
+
finally {
|
|
730
|
+
this.#reconcilingDerivedIndexes = false;
|
|
731
|
+
}
|
|
732
|
+
const before = this.#materialize();
|
|
733
|
+
const previous = this.#derivedIndexOperations;
|
|
734
|
+
this.#derivedIndexOperations = [...next];
|
|
735
|
+
const after = this.#materialize();
|
|
736
|
+
let changed = false;
|
|
737
|
+
for (const key of derivedIndexKeys([...previous, ...next])) {
|
|
738
|
+
if (deepEqual(before.indexes.get(key), after.indexes.get(key)))
|
|
739
|
+
continue;
|
|
740
|
+
this.#changedDependencies.add(indexDependency(key));
|
|
741
|
+
changed = true;
|
|
742
|
+
}
|
|
743
|
+
if (changed)
|
|
744
|
+
this.#dirty = true;
|
|
745
|
+
}
|
|
746
|
+
#recordFieldFloor(key, field) {
|
|
747
|
+
return Math.max(this.#confirmedFloors.get(recordWildcardDependency(key)) ?? 0, this.#confirmedFloors.get(recordFieldDependency(key, field)) ?? 0);
|
|
748
|
+
}
|
|
749
|
+
#baseWriter(touched, isActive = () => true) {
|
|
750
|
+
return Object.freeze({
|
|
751
|
+
recordClock: (key) => {
|
|
752
|
+
assertWriterActive(isActive());
|
|
753
|
+
validateRecordKey(key);
|
|
754
|
+
const record = this.#records.get(key);
|
|
755
|
+
if (!record)
|
|
756
|
+
return undefined;
|
|
757
|
+
return Object.freeze({
|
|
758
|
+
revision: revisionString(record.revision),
|
|
759
|
+
incarnation: revisionString(record.incarnation),
|
|
760
|
+
tombstoned: record.tombstoneRevision !== undefined
|
|
761
|
+
});
|
|
762
|
+
},
|
|
763
|
+
writeRecord: (write) => {
|
|
764
|
+
assertWriterActive(isActive());
|
|
765
|
+
return this.#writeBaseRecord(write, touched);
|
|
766
|
+
},
|
|
767
|
+
tombstoneRecord: (key, revision, incarnation) => {
|
|
768
|
+
assertWriterActive(isActive());
|
|
769
|
+
return this.#tombstoneBaseRecord(key, revision, incarnation, touched);
|
|
770
|
+
},
|
|
771
|
+
discardRecord: (key) => {
|
|
772
|
+
assertWriterActive(isActive());
|
|
773
|
+
return this.#discardBaseRecord(key, touched);
|
|
774
|
+
},
|
|
775
|
+
writeIndex: (write) => {
|
|
776
|
+
assertWriterActive(isActive());
|
|
777
|
+
return this.#writeBaseIndex(write, touched);
|
|
778
|
+
},
|
|
779
|
+
markIndexStale: (key, reason, revision) => {
|
|
780
|
+
assertWriterActive(isActive());
|
|
781
|
+
return this.#markBaseIndexStale(key, reason, revision, touched);
|
|
782
|
+
},
|
|
783
|
+
deleteIndex: (key, revision) => {
|
|
784
|
+
assertWriterActive(isActive());
|
|
785
|
+
return this.#deleteBaseIndex(key, revision, touched);
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
#optimisticWriter(operations, isActive = () => true) {
|
|
790
|
+
return Object.freeze({
|
|
791
|
+
writeRecord(write) {
|
|
792
|
+
assertWriterActive(isActive());
|
|
793
|
+
validateRecordKey(write.key);
|
|
794
|
+
const fields = cloneFields(write.fields);
|
|
795
|
+
const links = cloneLinks(write.links);
|
|
796
|
+
if (Object.keys(fields).length === 0 && Object.keys(links).length === 0)
|
|
797
|
+
return;
|
|
798
|
+
operations.push({ kind: 'write-record', write: { key: write.key, fields, links } });
|
|
799
|
+
},
|
|
800
|
+
tombstoneRecord(key) {
|
|
801
|
+
assertWriterActive(isActive());
|
|
802
|
+
validateRecordKey(key);
|
|
803
|
+
operations.push({ kind: 'tombstone-record', key });
|
|
804
|
+
},
|
|
805
|
+
writeIndex(write) {
|
|
806
|
+
assertWriterActive(isActive());
|
|
807
|
+
validateIndexWrite(write);
|
|
808
|
+
operations.push({
|
|
809
|
+
kind: 'write-index',
|
|
810
|
+
write: {
|
|
811
|
+
key: write.key,
|
|
812
|
+
records: Object.freeze([...write.records]),
|
|
813
|
+
complete: write.complete ?? false,
|
|
814
|
+
...(write.metadata === undefined
|
|
815
|
+
? {}
|
|
816
|
+
: { metadata: cloneIndexMetadata(write.metadata) })
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
},
|
|
820
|
+
deleteIndex(key) {
|
|
821
|
+
assertWriterActive(isActive());
|
|
822
|
+
assertName(key, 'index key');
|
|
823
|
+
operations.push({ kind: 'delete-index', key });
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
#runBaseUpdate(update, touched) {
|
|
828
|
+
let active = true;
|
|
829
|
+
const writer = this.#baseWriter(touched, () => active);
|
|
830
|
+
try {
|
|
831
|
+
const result = update(writer);
|
|
832
|
+
assertSynchronousResult(result, 'cache update');
|
|
833
|
+
return result;
|
|
834
|
+
}
|
|
835
|
+
finally {
|
|
836
|
+
active = false;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
#runOptimisticUpdate(update, operations) {
|
|
840
|
+
let active = true;
|
|
841
|
+
const writer = this.#optimisticWriter(operations, () => active);
|
|
842
|
+
try {
|
|
843
|
+
const result = update(writer);
|
|
844
|
+
assertSynchronousResult(result, 'optimistic layer update');
|
|
845
|
+
}
|
|
846
|
+
finally {
|
|
847
|
+
active = false;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
#writeBaseRecord(write, touched) {
|
|
851
|
+
validateRecordKey(write.key);
|
|
852
|
+
const revision = revisionToken(write.revision);
|
|
853
|
+
const requestedIncarnation = write.incarnation === undefined ? undefined : revisionToken(write.incarnation);
|
|
854
|
+
const fields = cloneFields(write.fields);
|
|
855
|
+
const links = cloneLinks(write.links);
|
|
856
|
+
for (const name of Object.keys(fields)) {
|
|
857
|
+
touched?.add(recordFieldDependency(write.key, `field:${name}`));
|
|
858
|
+
}
|
|
859
|
+
for (const name of Object.keys(links)) {
|
|
860
|
+
touched?.add(recordFieldDependency(write.key, `link:${name}`));
|
|
861
|
+
}
|
|
862
|
+
if (Object.keys(fields).length > 0 || Object.keys(links).length > 0) {
|
|
863
|
+
touched?.add(recordSeenDependency(write.key));
|
|
864
|
+
}
|
|
865
|
+
let record = this.#records.get(write.key);
|
|
866
|
+
if (requestedIncarnation === undefined &&
|
|
867
|
+
record?.tombstoneRevision !== undefined) {
|
|
868
|
+
if (revision < record.revision)
|
|
869
|
+
return false;
|
|
870
|
+
if (revision === record.revision) {
|
|
871
|
+
throw new CacheRevisionConflictError(recordSeenDependency(write.key), revision);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
const incarnation = requestedIncarnation ??
|
|
875
|
+
(record === undefined
|
|
876
|
+
? revision
|
|
877
|
+
: record.tombstoneRevision === undefined
|
|
878
|
+
? record.incarnation
|
|
879
|
+
: revision);
|
|
880
|
+
if (record !== undefined) {
|
|
881
|
+
const comparison = compareRecordTuple(incarnation, revision, record.incarnation, record.revision);
|
|
882
|
+
if (comparison < 0)
|
|
883
|
+
return false;
|
|
884
|
+
if (comparison === 0 && record.tombstoneRevision !== undefined) {
|
|
885
|
+
throw new CacheRevisionConflictError(recordSeenDependency(write.key), revision);
|
|
886
|
+
}
|
|
887
|
+
if (comparison > 0 &&
|
|
888
|
+
record.tombstoneRevision !== undefined &&
|
|
889
|
+
incarnation === record.incarnation) {
|
|
890
|
+
throw new CacheRevisionConflictError(recordSeenDependency(write.key), revision);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
let changed = false;
|
|
894
|
+
let presenceChanged = false;
|
|
895
|
+
if (!record) {
|
|
896
|
+
this.#invalidateIndexesForRecordLifecycle(write.key, touched);
|
|
897
|
+
record = {
|
|
898
|
+
revision,
|
|
899
|
+
incarnation,
|
|
900
|
+
fields: new Map(),
|
|
901
|
+
links: new Map()
|
|
902
|
+
};
|
|
903
|
+
this.#records.set(write.key, record);
|
|
904
|
+
changed = true;
|
|
905
|
+
presenceChanged = true;
|
|
906
|
+
}
|
|
907
|
+
else if (incarnation > record.incarnation) {
|
|
908
|
+
this.#invalidateIndexesForRecordLifecycle(write.key, touched);
|
|
909
|
+
record.fields.clear();
|
|
910
|
+
record.links.clear();
|
|
911
|
+
record.tombstoneRevision = undefined;
|
|
912
|
+
record.incarnation = incarnation;
|
|
913
|
+
record.revision = revision;
|
|
914
|
+
changed = true;
|
|
915
|
+
presenceChanged = true;
|
|
916
|
+
}
|
|
917
|
+
for (const [name, value] of Object.entries(fields)) {
|
|
918
|
+
const current = record.fields.get(name);
|
|
919
|
+
if (current && revision < current.revision)
|
|
920
|
+
continue;
|
|
921
|
+
if (current && revision === current.revision) {
|
|
922
|
+
if (deepEqual(current.value, value))
|
|
923
|
+
continue;
|
|
924
|
+
throw new CacheRevisionConflictError(recordFieldDependency(write.key, `field:${name}`), revision);
|
|
925
|
+
}
|
|
926
|
+
record.fields.set(name, { revision, value });
|
|
927
|
+
changed = true;
|
|
928
|
+
}
|
|
929
|
+
for (const [name, value] of Object.entries(links)) {
|
|
930
|
+
const current = record.links.get(name);
|
|
931
|
+
if (current && revision < current.revision)
|
|
932
|
+
continue;
|
|
933
|
+
if (current && revision === current.revision) {
|
|
934
|
+
if (deepEqual(current.value, value))
|
|
935
|
+
continue;
|
|
936
|
+
throw new CacheRevisionConflictError(recordFieldDependency(write.key, `link:${name}`), revision);
|
|
937
|
+
}
|
|
938
|
+
record.links.set(name, { revision, value });
|
|
939
|
+
changed = true;
|
|
940
|
+
}
|
|
941
|
+
if (incarnation === record.incarnation && revision > record.revision) {
|
|
942
|
+
record.revision = revision;
|
|
943
|
+
changed = true;
|
|
944
|
+
}
|
|
945
|
+
if (changed) {
|
|
946
|
+
this.#changedDependencies.add(recordWildcardDependency(write.key));
|
|
947
|
+
if (presenceChanged)
|
|
948
|
+
this.#changedDependencies.add(recordSeenDependency(write.key));
|
|
949
|
+
for (const name of Object.keys(fields)) {
|
|
950
|
+
this.#changedDependencies.add(recordFieldDependency(write.key, `field:${name}`));
|
|
951
|
+
}
|
|
952
|
+
for (const name of Object.keys(links)) {
|
|
953
|
+
this.#changedDependencies.add(recordFieldDependency(write.key, `link:${name}`));
|
|
954
|
+
}
|
|
955
|
+
this.#dirty = true;
|
|
956
|
+
}
|
|
957
|
+
return changed;
|
|
958
|
+
}
|
|
959
|
+
#tombstoneBaseRecord(key, revisionValue, incarnationValue, touched) {
|
|
960
|
+
validateRecordKey(key);
|
|
961
|
+
const revision = revisionToken(revisionValue);
|
|
962
|
+
touched?.add(recordWildcardDependency(key));
|
|
963
|
+
touched?.add(recordSeenDependency(key));
|
|
964
|
+
const record = this.#records.get(key);
|
|
965
|
+
const incarnation = incarnationValue === undefined
|
|
966
|
+
? (record?.incarnation ?? revision)
|
|
967
|
+
: revisionToken(incarnationValue);
|
|
968
|
+
if (record) {
|
|
969
|
+
const comparison = compareRecordTuple(incarnation, revision, record.incarnation, record.revision);
|
|
970
|
+
if (comparison < 0)
|
|
971
|
+
return false;
|
|
972
|
+
if (comparison === 0) {
|
|
973
|
+
if (record.tombstoneRevision !== undefined)
|
|
974
|
+
return false;
|
|
975
|
+
throw new CacheRevisionConflictError(recordSeenDependency(key), revision);
|
|
976
|
+
}
|
|
977
|
+
record.incarnation = incarnation;
|
|
978
|
+
record.revision = revision;
|
|
979
|
+
record.tombstoneRevision = revision;
|
|
980
|
+
record.fields.clear();
|
|
981
|
+
record.links.clear();
|
|
982
|
+
}
|
|
983
|
+
else {
|
|
984
|
+
this.#records.set(key, {
|
|
985
|
+
revision,
|
|
986
|
+
incarnation,
|
|
987
|
+
tombstoneRevision: revision,
|
|
988
|
+
fields: new Map(),
|
|
989
|
+
links: new Map()
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
this.#invalidateIndexesForRecordLifecycle(key, touched);
|
|
993
|
+
this.#changedDependencies.add(recordSeenDependency(key));
|
|
994
|
+
this.#changedDependencies.add(recordWildcardDependency(key));
|
|
995
|
+
this.#dirty = true;
|
|
996
|
+
return true;
|
|
997
|
+
}
|
|
998
|
+
#discardBaseRecord(key, touched) {
|
|
999
|
+
validateRecordKey(key);
|
|
1000
|
+
const hadRecord = this.#records.has(key);
|
|
1001
|
+
const hasIndexReference = [...this.#indexes.values()].some((index) => !index.deleted &&
|
|
1002
|
+
(index.metadata?.parent === key || index.records.includes(key)));
|
|
1003
|
+
if (!hadRecord && !hasIndexReference)
|
|
1004
|
+
return false;
|
|
1005
|
+
this.#records.delete(key);
|
|
1006
|
+
touched?.add(recordSeenDependency(key));
|
|
1007
|
+
touched?.add(recordWildcardDependency(key));
|
|
1008
|
+
this.#invalidateIndexesForRecordLifecycle(key, touched);
|
|
1009
|
+
this.#changedDependencies.add(recordSeenDependency(key));
|
|
1010
|
+
this.#changedDependencies.add(recordWildcardDependency(key));
|
|
1011
|
+
this.#dirty = true;
|
|
1012
|
+
return true;
|
|
1013
|
+
}
|
|
1014
|
+
#invalidateIndexesForRecordLifecycle(recordKey, touched) {
|
|
1015
|
+
for (const [key, index] of this.#indexes) {
|
|
1016
|
+
if (index.deleted)
|
|
1017
|
+
continue;
|
|
1018
|
+
const ownedByRecord = index.metadata?.parent === recordKey;
|
|
1019
|
+
const referencesRecord = index.records.includes(recordKey);
|
|
1020
|
+
if (!ownedByRecord && !referencesRecord)
|
|
1021
|
+
continue;
|
|
1022
|
+
this.#transactionLifecycleIndexes.add(key);
|
|
1023
|
+
index.deleted = ownedByRecord;
|
|
1024
|
+
index.records = ownedByRecord
|
|
1025
|
+
? []
|
|
1026
|
+
: index.records.filter((candidate) => candidate !== recordKey);
|
|
1027
|
+
index.complete = false;
|
|
1028
|
+
if (index.metadata !== undefined) {
|
|
1029
|
+
index.metadata = cloneIndexMetadata({
|
|
1030
|
+
...index.metadata,
|
|
1031
|
+
staleReason: 'record-lifecycle-changed'
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
if (index.staleRevision === undefined || index.staleRevision < index.revision) {
|
|
1035
|
+
index.staleRevision = index.revision;
|
|
1036
|
+
}
|
|
1037
|
+
touched?.add(indexDependency(key));
|
|
1038
|
+
this.#changedDependencies.add(indexDependency(key));
|
|
1039
|
+
this.#dirty = true;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
#writeBaseIndex(write, touched) {
|
|
1043
|
+
validateIndexWrite(write);
|
|
1044
|
+
const revision = revisionToken(write.revision);
|
|
1045
|
+
touched?.add(indexDependency(write.key));
|
|
1046
|
+
const current = this.#indexes.get(write.key);
|
|
1047
|
+
if (current?.staleRevision !== undefined && revision < current.staleRevision) {
|
|
1048
|
+
return false;
|
|
1049
|
+
}
|
|
1050
|
+
if (current && revision < current.revision)
|
|
1051
|
+
return false;
|
|
1052
|
+
const records = [...write.records];
|
|
1053
|
+
const complete = write.complete ?? false;
|
|
1054
|
+
let metadata = write.metadata === undefined ? undefined : cloneIndexMetadata(write.metadata);
|
|
1055
|
+
if (metadata?.parent !== undefined) {
|
|
1056
|
+
const parent = this.#records.get(metadata.parent);
|
|
1057
|
+
if (!parent || parent.tombstoneRevision !== undefined)
|
|
1058
|
+
return false;
|
|
1059
|
+
if (metadata.parentRevision !== undefined &&
|
|
1060
|
+
revisionToken(metadata.parentRevision) !== parent.revision) {
|
|
1061
|
+
throw new CacheRevisionConflictError(recordSeenDependency(metadata.parent), revisionToken(metadata.parentRevision));
|
|
1062
|
+
}
|
|
1063
|
+
if (metadata.parentIncarnation !== undefined &&
|
|
1064
|
+
revisionToken(metadata.parentIncarnation) !== parent.incarnation) {
|
|
1065
|
+
throw new CacheRevisionConflictError(recordSeenDependency(metadata.parent), revisionToken(metadata.parentIncarnation));
|
|
1066
|
+
}
|
|
1067
|
+
metadata = cloneIndexMetadata({
|
|
1068
|
+
...metadata,
|
|
1069
|
+
parentIncarnation: revisionString(parent.incarnation)
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
const staleRevision = metadata?.staleReason === undefined ? undefined : revision;
|
|
1073
|
+
if (current && revision === current.revision) {
|
|
1074
|
+
if (current.deleted &&
|
|
1075
|
+
current.metadata === undefined &&
|
|
1076
|
+
current.records.length === 0 &&
|
|
1077
|
+
current.staleRevision === revision) {
|
|
1078
|
+
// A hidden fence uses an empty deleted index so it materializes no
|
|
1079
|
+
// membership. Revision zero is valid, so an equal-checkpoint success
|
|
1080
|
+
// must be able to replace that sentinel state.
|
|
1081
|
+
current.records = records;
|
|
1082
|
+
current.complete = complete;
|
|
1083
|
+
current.deleted = false;
|
|
1084
|
+
current.metadata = metadata;
|
|
1085
|
+
current.staleRevision = staleRevision;
|
|
1086
|
+
this.#changedDependencies.add(indexDependency(write.key));
|
|
1087
|
+
this.#dirty = true;
|
|
1088
|
+
return true;
|
|
1089
|
+
}
|
|
1090
|
+
if (complete &&
|
|
1091
|
+
metadata?.staleReason === undefined &&
|
|
1092
|
+
(this.#transactionLifecycleIndexes.has(write.key) ||
|
|
1093
|
+
(!current.deleted &&
|
|
1094
|
+
!current.complete &&
|
|
1095
|
+
current.metadata?.staleReason !== 'record-lifecycle-changed' &&
|
|
1096
|
+
isOrderedSubsequence(current.records, records) &&
|
|
1097
|
+
refinementMetadataCompatible(current.metadata, metadata)))) {
|
|
1098
|
+
// A partial GraphQL result may be retried without the underlying read
|
|
1099
|
+
// model advancing. Permit only the monotonic incomplete -> complete
|
|
1100
|
+
// refinement; an already-authoritative membership still conflicts on
|
|
1101
|
+
// any same-revision disagreement.
|
|
1102
|
+
current.records = records;
|
|
1103
|
+
current.complete = true;
|
|
1104
|
+
current.deleted = false;
|
|
1105
|
+
current.metadata = metadata;
|
|
1106
|
+
current.staleRevision = undefined;
|
|
1107
|
+
this.#changedDependencies.add(indexDependency(write.key));
|
|
1108
|
+
this.#dirty = true;
|
|
1109
|
+
return true;
|
|
1110
|
+
}
|
|
1111
|
+
const currentComparable = indexMetadataWithoutStaleReason(current.metadata);
|
|
1112
|
+
const nextComparable = indexMetadataWithoutStaleReason(metadata);
|
|
1113
|
+
if (!current.deleted &&
|
|
1114
|
+
current.complete === complete &&
|
|
1115
|
+
deepEqual(current.records, records) &&
|
|
1116
|
+
deepEqual(currentComparable, nextComparable)) {
|
|
1117
|
+
if (deepEqual(current.metadata, metadata) &&
|
|
1118
|
+
current.staleRevision === staleRevision) {
|
|
1119
|
+
return false;
|
|
1120
|
+
}
|
|
1121
|
+
current.metadata = metadata;
|
|
1122
|
+
current.staleRevision = staleRevision;
|
|
1123
|
+
this.#changedDependencies.add(indexDependency(write.key));
|
|
1124
|
+
this.#dirty = true;
|
|
1125
|
+
return true;
|
|
1126
|
+
}
|
|
1127
|
+
throw new CacheRevisionConflictError(indexDependency(write.key), revision);
|
|
1128
|
+
}
|
|
1129
|
+
this.#indexes.set(write.key, {
|
|
1130
|
+
revision,
|
|
1131
|
+
records,
|
|
1132
|
+
complete,
|
|
1133
|
+
deleted: false,
|
|
1134
|
+
metadata,
|
|
1135
|
+
staleRevision
|
|
1136
|
+
});
|
|
1137
|
+
this.#changedDependencies.add(indexDependency(write.key));
|
|
1138
|
+
this.#dirty = true;
|
|
1139
|
+
return true;
|
|
1140
|
+
}
|
|
1141
|
+
#markBaseIndexStale(key, reason, revisionValue, touched) {
|
|
1142
|
+
assertName(key, 'index key');
|
|
1143
|
+
assertName(reason, 'index stale reason');
|
|
1144
|
+
let current = this.#indexes.get(key);
|
|
1145
|
+
if (!current) {
|
|
1146
|
+
if (revisionValue === undefined)
|
|
1147
|
+
return false;
|
|
1148
|
+
const revision = revisionToken(revisionValue);
|
|
1149
|
+
this.#indexes.set(key, {
|
|
1150
|
+
revision: 0n,
|
|
1151
|
+
staleRevision: revision,
|
|
1152
|
+
records: [],
|
|
1153
|
+
complete: false,
|
|
1154
|
+
deleted: true,
|
|
1155
|
+
metadata: undefined
|
|
1156
|
+
});
|
|
1157
|
+
touched?.add(indexDependency(key));
|
|
1158
|
+
this.#changedDependencies.add(indexDependency(key));
|
|
1159
|
+
this.#dirty = true;
|
|
1160
|
+
return true;
|
|
1161
|
+
}
|
|
1162
|
+
if (current.deleted && revisionValue === undefined)
|
|
1163
|
+
return false;
|
|
1164
|
+
const revision = revisionValue === undefined ? current.revision : revisionToken(revisionValue);
|
|
1165
|
+
const fence = current.staleRevision !== undefined && current.staleRevision > current.revision
|
|
1166
|
+
? current.staleRevision
|
|
1167
|
+
: current.revision;
|
|
1168
|
+
if (revision < fence)
|
|
1169
|
+
return false;
|
|
1170
|
+
if (current.staleRevision === revision &&
|
|
1171
|
+
(current.metadata === undefined || current.metadata.staleReason === reason)) {
|
|
1172
|
+
return false;
|
|
1173
|
+
}
|
|
1174
|
+
touched?.add(indexDependency(key));
|
|
1175
|
+
if (current.metadata !== undefined) {
|
|
1176
|
+
current.metadata = cloneIndexMetadata({ ...current.metadata, staleReason: reason });
|
|
1177
|
+
}
|
|
1178
|
+
current.staleRevision = revision;
|
|
1179
|
+
this.#changedDependencies.add(indexDependency(key));
|
|
1180
|
+
this.#dirty = true;
|
|
1181
|
+
return true;
|
|
1182
|
+
}
|
|
1183
|
+
#deleteBaseIndex(key, revisionValue, touched) {
|
|
1184
|
+
assertName(key, 'index key');
|
|
1185
|
+
const revision = revisionToken(revisionValue);
|
|
1186
|
+
touched?.add(indexDependency(key));
|
|
1187
|
+
const current = this.#indexes.get(key);
|
|
1188
|
+
if (current?.staleRevision !== undefined && revision < current.staleRevision) {
|
|
1189
|
+
return false;
|
|
1190
|
+
}
|
|
1191
|
+
if (current && revision < current.revision)
|
|
1192
|
+
return false;
|
|
1193
|
+
if (current && revision === current.revision) {
|
|
1194
|
+
if (current.deleted)
|
|
1195
|
+
return false;
|
|
1196
|
+
throw new CacheRevisionConflictError(indexDependency(key), revision);
|
|
1197
|
+
}
|
|
1198
|
+
this.#indexes.set(key, {
|
|
1199
|
+
revision,
|
|
1200
|
+
records: [],
|
|
1201
|
+
complete: false,
|
|
1202
|
+
deleted: true,
|
|
1203
|
+
metadata: current?.metadata,
|
|
1204
|
+
staleRevision: undefined
|
|
1205
|
+
});
|
|
1206
|
+
this.#changedDependencies.add(indexDependency(key));
|
|
1207
|
+
this.#dirty = true;
|
|
1208
|
+
return true;
|
|
1209
|
+
}
|
|
1210
|
+
#transaction(update) {
|
|
1211
|
+
const outermost = this.#transactionDepth === 0;
|
|
1212
|
+
const backup = outermost ? this.#backup() : undefined;
|
|
1213
|
+
if (outermost)
|
|
1214
|
+
this.#transactionLifecycleIndexes = new Set();
|
|
1215
|
+
this.#transactionDepth += 1;
|
|
1216
|
+
let result;
|
|
1217
|
+
try {
|
|
1218
|
+
result = update();
|
|
1219
|
+
}
|
|
1220
|
+
catch (error) {
|
|
1221
|
+
this.#transactionDepth -= 1;
|
|
1222
|
+
if (outermost && backup) {
|
|
1223
|
+
this.#restoreBackup(backup);
|
|
1224
|
+
this.#dirty = false;
|
|
1225
|
+
this.#transactionLifecycleIndexes = new Set();
|
|
1226
|
+
}
|
|
1227
|
+
throw error;
|
|
1228
|
+
}
|
|
1229
|
+
this.#transactionDepth -= 1;
|
|
1230
|
+
if (outermost) {
|
|
1231
|
+
const shouldFlush = this.#dirty;
|
|
1232
|
+
const changedDependencies = this.#changedDependencies;
|
|
1233
|
+
this.#dirty = false;
|
|
1234
|
+
this.#changedDependencies = new Set();
|
|
1235
|
+
this.#transactionLifecycleIndexes = new Set();
|
|
1236
|
+
if (shouldFlush)
|
|
1237
|
+
this.#flushWatchers(changedDependencies);
|
|
1238
|
+
}
|
|
1239
|
+
return result;
|
|
1240
|
+
}
|
|
1241
|
+
#flushWatchers(changedDependencies) {
|
|
1242
|
+
const errors = [];
|
|
1243
|
+
for (const watcher of this.#watchers) {
|
|
1244
|
+
if (!dependenciesChanged(watcher.dependencies, changedDependencies))
|
|
1245
|
+
continue;
|
|
1246
|
+
try {
|
|
1247
|
+
const { value: next, dependencies } = this.#select(watcher.selector);
|
|
1248
|
+
watcher.dependencies = dependencies;
|
|
1249
|
+
if (deepEqual(next, watcher.value))
|
|
1250
|
+
continue;
|
|
1251
|
+
const previous = watcher.value;
|
|
1252
|
+
watcher.value = next;
|
|
1253
|
+
watcher.listener(next, previous);
|
|
1254
|
+
}
|
|
1255
|
+
catch (error) {
|
|
1256
|
+
errors.push(error);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
if (errors.length > 0) {
|
|
1260
|
+
reportSafely(this.#reportWatcherError, new AggregateError(errors, 'cache transaction committed, but watcher delivery failed'));
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
#backup() {
|
|
1264
|
+
const backup = structuredClone({
|
|
1265
|
+
records: this.#records,
|
|
1266
|
+
indexes: this.#indexes,
|
|
1267
|
+
layers: this.#layers,
|
|
1268
|
+
derivedIndexOperations: this.#derivedIndexOperations,
|
|
1269
|
+
confirmedFloors: this.#confirmedFloors,
|
|
1270
|
+
retained: this.#retained,
|
|
1271
|
+
nextLayerSequence: this.#nextLayerSequence,
|
|
1272
|
+
changedDependencies: new Set()
|
|
1273
|
+
});
|
|
1274
|
+
backup.changedDependencies = new Set(this.#changedDependencies);
|
|
1275
|
+
return backup;
|
|
1276
|
+
}
|
|
1277
|
+
#restoreBackup(backup) {
|
|
1278
|
+
this.#records = backup.records;
|
|
1279
|
+
this.#indexes = backup.indexes;
|
|
1280
|
+
this.#layers = backup.layers;
|
|
1281
|
+
this.#derivedIndexOperations = backup.derivedIndexOperations;
|
|
1282
|
+
this.#confirmedFloors = backup.confirmedFloors;
|
|
1283
|
+
this.#retained = backup.retained;
|
|
1284
|
+
this.#nextLayerSequence = backup.nextLayerSequence;
|
|
1285
|
+
this.#changedDependencies = new Set(backup.changedDependencies);
|
|
1286
|
+
}
|
|
1287
|
+
}
|