@hops-ops/distributed 0.0.0 → 4.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 +26 -13
- package/dist/auth-headers.js +3 -3
- package/dist/internal/cache-engine/engine.d.ts +3 -1
- package/dist/internal/cache-engine/engine.js +137 -22
- package/dist/internal/cache-engine/errors.d.ts +4 -0
- package/dist/internal/cache-engine/errors.js +8 -0
- package/dist/internal/cache-engine/index.d.ts +2 -2
- package/dist/internal/cache-engine/index.js +1 -1
- package/dist/internal/cache-engine/types.d.ts +16 -0
- package/dist/internal/cache-engine.d.ts +2 -2
- package/dist/internal/cache-engine.js +1 -1
- package/dist/protocol.d.ts +9 -2
- package/dist/protocol.js +35 -7
- package/dist/replica/command-runtime/create.js +499 -57
- package/dist/replica/command-runtime/index.d.ts +1 -1
- package/dist/replica/command-runtime/index.js +1 -1
- package/dist/replica/command-runtime/lib/effects.d.ts +22 -9
- package/dist/replica/command-runtime/lib/effects.js +118 -20
- package/dist/replica/command-runtime/lib/projection.d.ts +2 -2
- package/dist/replica/command-runtime/lib/projection.js +14 -6
- package/dist/replica/command-runtime/lib/status.js +18 -10
- package/dist/replica/command-runtime/lib/transport.js +4 -0
- package/dist/replica/command-runtime/lib/util.js +8 -3
- package/dist/replica/command-runtime/symbols.d.ts +9 -0
- package/dist/replica/command-runtime/symbols.js +9 -0
- package/dist/replica/command-runtime/types.d.ts +15 -6
- package/dist/replica/command-runtime/types.js +1 -1
- package/dist/replica/command-runtime.d.ts +1 -1
- package/dist/replica/command-runtime.js +1 -1
- package/dist/replica/commands/clone.js +2 -1
- package/dist/replica/commands/index.d.ts +1 -0
- package/dist/replica/commands/prepare.js +7 -4
- package/dist/replica/commands/receipt.js +30 -16
- package/dist/replica/commands/types.d.ts +10 -5
- package/dist/replica/commands/validate.d.ts +5 -1
- package/dist/replica/commands/validate.js +129 -62
- package/dist/replica/diagnostics/inspect.js +51 -19
- package/dist/replica/diagnostics/types.d.ts +7 -7
- package/dist/replica/distributed-replica/helpers.js +22 -8
- package/dist/replica/distributed-replica/hydration.js +14 -2
- package/dist/replica/distributed-replica/impl-diagnostics.d.ts +1 -1
- package/dist/replica/distributed-replica/impl-diagnostics.js +4 -3
- package/dist/replica/distributed-replica/impl-hydration-orchestrate.d.ts +1 -1
- package/dist/replica/distributed-replica/impl-hydration-orchestrate.js +76 -42
- package/dist/replica/distributed-replica/impl-optimistic.d.ts +11 -2
- package/dist/replica/distributed-replica/impl-optimistic.js +22 -6
- package/dist/replica/distributed-replica/impl-protocol.js +4 -2
- package/dist/replica/distributed-replica/impl.d.ts +8 -2
- package/dist/replica/distributed-replica/impl.js +22 -5
- package/dist/replica/distributed-replica/optimistic.js +31 -3
- package/dist/replica/distributed-replica/types.d.ts +1 -0
- package/dist/replica/index-maintenance/engine.js +14 -6
- package/dist/replica/index.d.ts +4 -0
- package/dist/replica/index.js +2 -0
- package/dist/replica/materialize.js +25 -0
- package/dist/replica/mutation-cache.d.ts +62 -0
- package/dist/replica/mutation-cache.js +91 -0
- package/dist/replica/operation-binding.js +17 -6
- package/dist/replica/persistence/state.js +9 -1
- package/dist/replica/projection-delta/canonical.d.ts +6 -0
- package/dist/replica/projection-delta/canonical.js +29 -0
- package/dist/replica/projection-delta/index.d.ts +5 -0
- package/dist/replica/projection-delta/index.js +3 -0
- package/dist/replica/projection-delta/resolve.d.ts +8 -0
- package/dist/replica/projection-delta/resolve.js +266 -0
- package/dist/replica/projection-delta/types.d.ts +415 -0
- package/dist/replica/projection-delta/types.js +6 -0
- package/dist/replica/projection-delta/validate.d.ts +18 -0
- package/dist/replica/projection-delta/validate.js +1718 -0
- package/dist/replica/projection-delta/wasm-pure.d.ts +36 -0
- package/dist/replica/projection-delta/wasm-pure.js +71 -0
- package/dist/replica/query-plan/pagination.js +18 -8
- package/dist/replica/types.d.ts +13 -2
- package/dist/sveltekit/replica.d.ts +1 -1
- package/dist/sveltekit/replica.js +3 -0
- package/dist/sveltekit/vite.d.ts +6 -6
- package/dist/sveltekit/vite.js +6 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ The generated, end-to-end typed client for
|
|
|
4
4
|
[Distributed](https://github.com/hops-ops/distributed) services.
|
|
5
5
|
|
|
6
6
|
Rust table, relationship, role, and command definitions produce one authorized
|
|
7
|
-
client surface. `
|
|
7
|
+
client surface. `distributed client` combines that surface with application GraphQL
|
|
8
8
|
documents and emits typed operations, live companions, route-load plans, and
|
|
9
9
|
commands. This package executes those artifacts through one normalized,
|
|
10
10
|
causally consistent browser replica.
|
|
@@ -37,9 +37,9 @@ by the `/sveltekit` and `/react` entry points, respectively.
|
|
|
37
37
|
The service, not the browser, owns authorization and GraphQL semantics:
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
|
-
|
|
40
|
+
distributed client-manifest > target/distributed-client.json
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
distributed client \
|
|
43
43
|
--manifest target/distributed-client.json \
|
|
44
44
|
--role user \
|
|
45
45
|
--documents 'src/**/*.graphql' \
|
|
@@ -99,7 +99,7 @@ export const distributedClients = [
|
|
|
99
99
|
{
|
|
100
100
|
module: '$distributed',
|
|
101
101
|
manifest: { args: serviceManifestArgs },
|
|
102
|
-
surface: '
|
|
102
|
+
surface: 'e2e-ui',
|
|
103
103
|
documents: ['src/routes/(app)/**/*.graphql'],
|
|
104
104
|
out: 'src/lib/generated/distributed'
|
|
105
105
|
},
|
|
@@ -112,7 +112,7 @@ export const distributedClients = [
|
|
|
112
112
|
'service::distributed_admin_client_surface'
|
|
113
113
|
]
|
|
114
114
|
},
|
|
115
|
-
surface: '
|
|
115
|
+
surface: 'e2e-ui-admin',
|
|
116
116
|
documents: ['src/routes/admin/**/*.graphql'],
|
|
117
117
|
out: 'src/lib/generated/distributed-admin'
|
|
118
118
|
}
|
|
@@ -128,7 +128,7 @@ keeps ordinary application documents out of the admin tree; each trust boundary
|
|
|
128
128
|
has its own Rust manifest entrypoint, generated directory, virtual module, and
|
|
129
129
|
request-local replica. A single-surface application can omit the second entry.
|
|
130
130
|
|
|
131
|
-
The Vite integration runs `
|
|
131
|
+
The Vite integration runs `distributed client` at startup/build, watches GraphQL
|
|
132
132
|
documents, stages all surfaces, commits a rollback-capable multi-output
|
|
133
133
|
transaction, then triggers one reload. It exposes the generated Svelte wrapper
|
|
134
134
|
through the configured virtual module:
|
|
@@ -259,6 +259,11 @@ the server sends a separate authority value, and the adapter requires both
|
|
|
259
259
|
values to match. Session, token, tenant, or role changes abort HTTP and live
|
|
260
260
|
work, discard the old generation, and reconnect under server-issued scope.
|
|
261
261
|
|
|
262
|
+
Confirmed records and indexes under an active scope stay until auth/scope
|
|
263
|
+
change, stale+revalidate, or a newer authoritative write. Same-scope soft
|
|
264
|
+
navigation merges a route SSR seed into the warm client and does **not** wipe
|
|
265
|
+
keys the seed omitted (a page dehydrate is only the subset for that route).
|
|
266
|
+
|
|
262
267
|
Use a separate generated surface and replica for elevated routes. A normal
|
|
263
268
|
client cannot import or mix admin artifacts. Configure it as a separate virtual
|
|
264
269
|
module such as `$distributed/admin` and provide it only in the elevated layout.
|
|
@@ -289,7 +294,9 @@ const unsubscribe = todos.subscribe((snapshot) => {
|
|
|
289
294
|
`watch()` reads synchronously, fetches only missing or stale projections,
|
|
290
295
|
deduplicates work, and optionally maintains the generated live operation.
|
|
291
296
|
`read()` is side-effect-free. `dehydrate()` and `hydrate()` transfer confirmed
|
|
292
|
-
|
|
297
|
+
state without exposing a public storage schema. Cold `hydrate` seeds an empty
|
|
298
|
+
client; warm same-scope `hydrate` merges so soft navigation cannot discard
|
|
299
|
+
confirmed session data the next route did not re-dehydrate.
|
|
293
300
|
|
|
294
301
|
The replica stores normalized records and exact argument-sensitive indexes,
|
|
295
302
|
not GraphQL response blobs. Generated selection metadata reconstructs each
|
|
@@ -303,16 +310,22 @@ same replica and GraphQL transport. A command call:
|
|
|
303
310
|
|
|
304
311
|
1. validates and freezes its typed input;
|
|
305
312
|
2. fills generated UUIDv7, ULID, or literal defaults exactly once;
|
|
306
|
-
3. applies the generated optimistic effect transaction
|
|
313
|
+
3. applies the generated optimistic effect transaction (from `.applies` /
|
|
314
|
+
portable mutation IR — works for **Eventual and Direct** when fields are known);
|
|
307
315
|
4. dispatches the exact compiler-owned mutation;
|
|
308
316
|
5. keeps ambiguous commits recoverable by command ID;
|
|
309
317
|
6. confirms or rejects only its own optimistic layer;
|
|
310
|
-
7.
|
|
318
|
+
7. retires the layer on the path that placement allows:
|
|
319
|
+
- **Eventual** — wait for projection obligations (event handler ran
|
|
320
|
+
async; there is no authoritative row on the command response);
|
|
321
|
+
- **Atomic / Direct** — normalize the **returned** row
|
|
322
|
+
(`confirmDirectProjection`) before the call settles. The server waited in
|
|
323
|
+
the command handler because it could; an event handler cannot.
|
|
311
324
|
|
|
312
325
|
Applications do not provide list targets, merge functions, mutation update
|
|
313
|
-
callbacks, or invalidation maps. If the compiler cannot prove
|
|
314
|
-
the generated plan marks the affected projection stale and
|
|
315
|
-
one deduplicated revalidation.
|
|
326
|
+
callbacks, board simulators, or invalidation maps. If the compiler cannot prove
|
|
327
|
+
safe maintenance, the generated plan marks the affected projection stale and
|
|
328
|
+
the replica performs one deduplicated revalidation.
|
|
316
329
|
|
|
317
330
|
Callers may bound their own causal wait without inventing a rollback:
|
|
318
331
|
|
|
@@ -427,7 +440,7 @@ command pipeline, or package-owned codegen executable.
|
|
|
427
440
|
|
|
428
441
|
To move an existing pilot application:
|
|
429
442
|
|
|
430
|
-
1. rerun `
|
|
443
|
+
1. rerun `distributed client` and import its operation/command artifacts;
|
|
431
444
|
2. compose one replica through the framework adapter or core transport;
|
|
432
445
|
3. remove handwritten cache targets, merge/update callbacks, and invalidation
|
|
433
446
|
policies;
|
package/dist/auth-headers.js
CHANGED
|
@@ -7,7 +7,7 @@ export function buildAuthHeaders(auth = {}) {
|
|
|
7
7
|
}
|
|
8
8
|
else if (auth.userId) {
|
|
9
9
|
headers['x-user-id'] = auth.userId;
|
|
10
|
-
headers['x-
|
|
10
|
+
headers['x-roles'] = auth.role ?? 'user';
|
|
11
11
|
}
|
|
12
12
|
return headers;
|
|
13
13
|
}
|
|
@@ -21,7 +21,7 @@ export function wsConnectionInitPayload(auth = {}) {
|
|
|
21
21
|
}
|
|
22
22
|
else if (auth.userId) {
|
|
23
23
|
payload['x-user-id'] = auth.userId;
|
|
24
|
-
payload['x-
|
|
24
|
+
payload['x-roles'] = auth.role ?? 'user';
|
|
25
25
|
}
|
|
26
26
|
return payload;
|
|
27
27
|
}
|
|
@@ -31,5 +31,5 @@ export function applyWsDevHeaderParams(url, auth = {}) {
|
|
|
31
31
|
if (token || !auth.userId)
|
|
32
32
|
return;
|
|
33
33
|
url.searchParams.set('x-user-id', auth.userId);
|
|
34
|
-
url.searchParams.set('x-
|
|
34
|
+
url.searchParams.set('x-roles', auth.role ?? 'user');
|
|
35
35
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BaseCacheWriter, CacheEngine, CacheEngineOptions, CacheEngineSnapshot, CacheListener, CacheSelector, DerivedIndexReconciler, IndexKey, OptimisticCacheWriter, OptimisticLayerContext, OptimisticLayerState, RecordKey, WatchOptions } from './types.js';
|
|
1
|
+
import type { BaseCacheWriter, CacheEngine, CacheEngineOptions, CacheEngineSnapshot, CacheListener, CacheSelector, DerivedIndexReconciler, IndexKey, OptimisticCacheWriter, OptimisticLayerContext, OptimisticLayerReplacement, OptimisticLayerState, RecordKey, WatchOptions } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Minimum purpose-built implementation selected by the executable spike.
|
|
4
4
|
*
|
|
@@ -16,6 +16,7 @@ export declare class PurposeBuiltCacheEngine implements CacheEngine {
|
|
|
16
16
|
watch<T>(selector: CacheSelector<T>, listener: CacheListener<T>, options?: WatchOptions): () => void;
|
|
17
17
|
batch<T>(update: (writer: BaseCacheWriter) => T): T;
|
|
18
18
|
createOptimisticLayer(id: string, update: (writer: OptimisticCacheWriter) => void, context?: OptimisticLayerContext): void;
|
|
19
|
+
replaceOptimisticLayer(id: string, replacement: OptimisticLayerReplacement, context?: OptimisticLayerContext): boolean;
|
|
19
20
|
setDerivedIndexReconciler(reconciler: DerivedIndexReconciler | undefined): void;
|
|
20
21
|
markOptimisticLayerAccepted(id: string): boolean;
|
|
21
22
|
confirmOptimisticLayer<T>(id: string, update: (writer: BaseCacheWriter) => T): T;
|
|
@@ -25,6 +26,7 @@ export declare class PurposeBuiltCacheEngine implements CacheEngine {
|
|
|
25
26
|
extract(): CacheEngineSnapshot;
|
|
26
27
|
restore(snapshot: CacheEngineSnapshot): void;
|
|
27
28
|
restoreConfirmed(snapshot: CacheEngineSnapshot): void;
|
|
29
|
+
mergeConfirmed(snapshot: CacheEngineSnapshot): void;
|
|
28
30
|
discardIndexes(keys: readonly IndexKey[]): void;
|
|
29
31
|
retain(key: RecordKey): void;
|
|
30
32
|
release(key: RecordKey): void;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CacheRevisionConflictError } from './errors.js';
|
|
1
|
+
import { CacheRevisionConflictError, OptimisticLayerNotFoundError } from './errors.js';
|
|
2
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
3
|
/**
|
|
4
4
|
* Minimum purpose-built implementation selected by the executable spike.
|
|
@@ -106,6 +106,41 @@ export class PurposeBuiltCacheEngine {
|
|
|
106
106
|
this.#reconcileDerivedIndexes();
|
|
107
107
|
});
|
|
108
108
|
}
|
|
109
|
+
replaceOptimisticLayer(id, replacement, context) {
|
|
110
|
+
assertName(id, 'optimistic layer id');
|
|
111
|
+
if (typeof replacement !== 'function') {
|
|
112
|
+
throw new TypeError('optimistic layer replacement must be a function');
|
|
113
|
+
}
|
|
114
|
+
return this.#transaction(() => {
|
|
115
|
+
const targetIndex = this.#layers.findIndex((candidate) => candidate.id === id);
|
|
116
|
+
if (targetIndex === -1) {
|
|
117
|
+
throw new OptimisticLayerNotFoundError(id);
|
|
118
|
+
}
|
|
119
|
+
const target = this.#layers[targetIndex];
|
|
120
|
+
const before = this.#materialize();
|
|
121
|
+
const prefixLayers = this.#layers.slice(0, targetIndex);
|
|
122
|
+
const prefixDerivedIndexes = this.#deriveIndexOperations(prefixLayers);
|
|
123
|
+
const prefix = this.#materialize(true, prefixLayers, prefixDerivedIndexes);
|
|
124
|
+
const operations = [];
|
|
125
|
+
this.#runOptimisticReplacement(replacement, this.#readerFromGraph(prefix), operations);
|
|
126
|
+
const stableContext = context === undefined ? target.context : cloneCacheValue(context);
|
|
127
|
+
if (deepEqual(operations, target.operations) &&
|
|
128
|
+
deepEqual(stableContext, target.context))
|
|
129
|
+
return false;
|
|
130
|
+
const previousOperations = target.operations;
|
|
131
|
+
target.operations = operations;
|
|
132
|
+
if (stableContext === undefined)
|
|
133
|
+
delete target.context;
|
|
134
|
+
else
|
|
135
|
+
target.context = stableContext;
|
|
136
|
+
this.#reconcileDerivedIndexes();
|
|
137
|
+
const after = this.#materialize();
|
|
138
|
+
this.#markOverlayChanges([...previousOperations, ...operations], before, after);
|
|
139
|
+
this.#markIndexGraphChanges(before, after);
|
|
140
|
+
this.#dirty = true;
|
|
141
|
+
return true;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
109
144
|
setDerivedIndexReconciler(reconciler) {
|
|
110
145
|
if (reconciler !== undefined && typeof reconciler !== 'function') {
|
|
111
146
|
throw new TypeError('derived index reconciler must be a function');
|
|
@@ -267,6 +302,24 @@ export class PurposeBuiltCacheEngine {
|
|
|
267
302
|
this.#reconcileDerivedIndexes();
|
|
268
303
|
});
|
|
269
304
|
}
|
|
305
|
+
mergeConfirmed(snapshot) {
|
|
306
|
+
const restored = parseSnapshot(snapshot);
|
|
307
|
+
this.#transaction(() => {
|
|
308
|
+
// Seed is authoritative for keys it contains; keys it omits stay.
|
|
309
|
+
for (const [key, record] of restored.records) {
|
|
310
|
+
this.#records.set(key, record);
|
|
311
|
+
this.#changedDependencies.add(recordSeenDependency(key));
|
|
312
|
+
this.#changedDependencies.add(recordWildcardDependency(key));
|
|
313
|
+
}
|
|
314
|
+
for (const [key, index] of restored.indexes) {
|
|
315
|
+
this.#indexes.set(key, index);
|
|
316
|
+
this.#changedDependencies.add(indexDependency(key));
|
|
317
|
+
}
|
|
318
|
+
this.#derivedIndexOperations = [];
|
|
319
|
+
this.#dirty = true;
|
|
320
|
+
this.#reconcileDerivedIndexes();
|
|
321
|
+
});
|
|
322
|
+
}
|
|
270
323
|
discardIndexes(keys) {
|
|
271
324
|
const unique = new Set();
|
|
272
325
|
for (const key of keys) {
|
|
@@ -348,7 +401,9 @@ export class PurposeBuiltCacheEngine {
|
|
|
348
401
|
// O(records + indexes + overlay operations) per selector. The private seam
|
|
349
402
|
// keeps this replaceable with an incrementally indexed graph without
|
|
350
403
|
// changing generated artifacts or the public replica API.
|
|
351
|
-
|
|
404
|
+
return this.#readerFromGraph(this.#materialize(includeOptimistic), dependencies);
|
|
405
|
+
}
|
|
406
|
+
#readerFromGraph({ records, indexes }, dependencies) {
|
|
352
407
|
return Object.freeze({
|
|
353
408
|
recordMeta(key) {
|
|
354
409
|
dependencies?.add(recordSeenDependency(key));
|
|
@@ -496,7 +551,7 @@ export class PurposeBuiltCacheEngine {
|
|
|
496
551
|
}
|
|
497
552
|
return reachable;
|
|
498
553
|
}
|
|
499
|
-
#materialize(includeOptimistic = true) {
|
|
554
|
+
#materialize(includeOptimistic = true, layers = this.#layers, derivedIndexOperations = this.#derivedIndexOperations) {
|
|
500
555
|
const records = new Map();
|
|
501
556
|
const indexes = new Map();
|
|
502
557
|
for (const [key, record] of this.#records) {
|
|
@@ -519,12 +574,12 @@ export class PurposeBuiltCacheEngine {
|
|
|
519
574
|
});
|
|
520
575
|
}
|
|
521
576
|
if (includeOptimistic) {
|
|
522
|
-
for (const layer of
|
|
577
|
+
for (const layer of layers) {
|
|
523
578
|
for (const operation of layer.operations) {
|
|
524
579
|
this.#applyOverlay(records, indexes, layer.sequence, operation);
|
|
525
580
|
}
|
|
526
581
|
}
|
|
527
|
-
for (const operation of
|
|
582
|
+
for (const operation of derivedIndexOperations) {
|
|
528
583
|
this.#applyDerivedIndexOperation(records, indexes, operation);
|
|
529
584
|
}
|
|
530
585
|
}
|
|
@@ -538,6 +593,9 @@ export class PurposeBuiltCacheEngine {
|
|
|
538
593
|
for (const name of Object.keys(operation.write.fields ?? {})) {
|
|
539
594
|
this.#changedDependencies.add(recordFieldDependency(key, `field:${name}`));
|
|
540
595
|
}
|
|
596
|
+
for (const name of operation.write.unset ?? []) {
|
|
597
|
+
this.#changedDependencies.add(recordFieldDependency(key, `field:${name}`));
|
|
598
|
+
}
|
|
541
599
|
for (const name of Object.keys(operation.write.links ?? {})) {
|
|
542
600
|
this.#changedDependencies.add(recordFieldDependency(key, `link:${name}`));
|
|
543
601
|
}
|
|
@@ -558,10 +616,24 @@ export class PurposeBuiltCacheEngine {
|
|
|
558
616
|
this.#changedDependencies.add(indexDependency(operation.kind === 'write-index' ? operation.write.key : operation.key));
|
|
559
617
|
}
|
|
560
618
|
}
|
|
619
|
+
#markIndexGraphChanges(before, after) {
|
|
620
|
+
for (const key of new Set([
|
|
621
|
+
...before.indexes.keys(),
|
|
622
|
+
...after.indexes.keys()
|
|
623
|
+
])) {
|
|
624
|
+
if (deepEqual(before.indexes.get(key), after.indexes.get(key))) {
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
this.#changedDependencies.add(indexDependency(key));
|
|
628
|
+
}
|
|
629
|
+
}
|
|
561
630
|
#applyOverlay(records, indexes, sequence, operation) {
|
|
562
631
|
if (operation.kind === 'write-record') {
|
|
563
|
-
const { key, fields = {}, links = {} } = operation.write;
|
|
632
|
+
const { key, fields = {}, links = {}, unset = [] } = operation.write;
|
|
564
633
|
let record = records.get(key);
|
|
634
|
+
if (operation.write.ifPresent === true && (!record || record.tombstoned)) {
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
565
637
|
let wrote = false;
|
|
566
638
|
for (const [name, value] of Object.entries(fields)) {
|
|
567
639
|
if (sequence <= this.#recordFieldFloor(key, `field:${name}`))
|
|
@@ -579,6 +651,14 @@ export class PurposeBuiltCacheEngine {
|
|
|
579
651
|
record.links.set(name, value);
|
|
580
652
|
wrote = true;
|
|
581
653
|
}
|
|
654
|
+
for (const name of unset) {
|
|
655
|
+
if (sequence <= this.#recordFieldFloor(key, `field:${name}`))
|
|
656
|
+
continue;
|
|
657
|
+
if (!record || !record.fields.has(name))
|
|
658
|
+
continue;
|
|
659
|
+
record.fields.delete(name);
|
|
660
|
+
wrote = true;
|
|
661
|
+
}
|
|
582
662
|
if (wrote && record) {
|
|
583
663
|
record.tombstoned = false;
|
|
584
664
|
records.set(key, record);
|
|
@@ -707,6 +787,22 @@ export class PurposeBuiltCacheEngine {
|
|
|
707
787
|
});
|
|
708
788
|
}
|
|
709
789
|
#reconcileDerivedIndexes() {
|
|
790
|
+
const next = this.#deriveIndexOperations(this.#layers);
|
|
791
|
+
const before = this.#materialize();
|
|
792
|
+
const previous = this.#derivedIndexOperations;
|
|
793
|
+
this.#derivedIndexOperations = [...next];
|
|
794
|
+
const after = this.#materialize();
|
|
795
|
+
let changed = false;
|
|
796
|
+
for (const key of derivedIndexKeys([...previous, ...next])) {
|
|
797
|
+
if (deepEqual(before.indexes.get(key), after.indexes.get(key)))
|
|
798
|
+
continue;
|
|
799
|
+
this.#changedDependencies.add(indexDependency(key));
|
|
800
|
+
changed = true;
|
|
801
|
+
}
|
|
802
|
+
if (changed)
|
|
803
|
+
this.#dirty = true;
|
|
804
|
+
}
|
|
805
|
+
#deriveIndexOperations(layers) {
|
|
710
806
|
if (this.#reconcilingDerivedIndexes) {
|
|
711
807
|
throw new Error('derived index reconciliation cannot be re-entered');
|
|
712
808
|
}
|
|
@@ -717,7 +813,7 @@ export class PurposeBuiltCacheEngine {
|
|
|
717
813
|
next =
|
|
718
814
|
reconciler === undefined
|
|
719
815
|
? []
|
|
720
|
-
: cloneDerivedIndexOperations(runDerivedIndexReconciler(reconciler, this.extract(),
|
|
816
|
+
: cloneDerivedIndexOperations(runDerivedIndexReconciler(reconciler, this.extract(), layers.map((layer) => Object.freeze({
|
|
721
817
|
id: layer.id,
|
|
722
818
|
sequence: layer.sequence,
|
|
723
819
|
state: layer.state,
|
|
@@ -729,19 +825,7 @@ export class PurposeBuiltCacheEngine {
|
|
|
729
825
|
finally {
|
|
730
826
|
this.#reconcilingDerivedIndexes = false;
|
|
731
827
|
}
|
|
732
|
-
|
|
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;
|
|
828
|
+
return next;
|
|
745
829
|
}
|
|
746
830
|
#recordFieldFloor(key, field) {
|
|
747
831
|
return Math.max(this.#confirmedFloors.get(recordWildcardDependency(key)) ?? 0, this.#confirmedFloors.get(recordFieldDependency(key, field)) ?? 0);
|
|
@@ -793,9 +877,29 @@ export class PurposeBuiltCacheEngine {
|
|
|
793
877
|
validateRecordKey(write.key);
|
|
794
878
|
const fields = cloneFields(write.fields);
|
|
795
879
|
const links = cloneLinks(write.links);
|
|
796
|
-
|
|
880
|
+
const unset = Object.freeze([...(write.unset ?? [])]);
|
|
881
|
+
for (const name of unset)
|
|
882
|
+
assertName(name, 'record field');
|
|
883
|
+
if (new Set(unset).size !== unset.length) {
|
|
884
|
+
throw new TypeError('record unset fields must be unique');
|
|
885
|
+
}
|
|
886
|
+
if (unset.some((name) => Object.hasOwn(fields, name))) {
|
|
887
|
+
throw new TypeError('record field cannot be set and unset');
|
|
888
|
+
}
|
|
889
|
+
if (Object.keys(fields).length === 0 &&
|
|
890
|
+
Object.keys(links).length === 0 &&
|
|
891
|
+
unset.length === 0)
|
|
797
892
|
return;
|
|
798
|
-
operations.push({
|
|
893
|
+
operations.push({
|
|
894
|
+
kind: 'write-record',
|
|
895
|
+
write: {
|
|
896
|
+
key: write.key,
|
|
897
|
+
fields,
|
|
898
|
+
links,
|
|
899
|
+
...(unset.length === 0 ? {} : { unset }),
|
|
900
|
+
...(write.ifPresent === true ? { ifPresent: true } : {})
|
|
901
|
+
}
|
|
902
|
+
});
|
|
799
903
|
},
|
|
800
904
|
tombstoneRecord(key) {
|
|
801
905
|
assertWriterActive(isActive());
|
|
@@ -847,6 +951,17 @@ export class PurposeBuiltCacheEngine {
|
|
|
847
951
|
active = false;
|
|
848
952
|
}
|
|
849
953
|
}
|
|
954
|
+
#runOptimisticReplacement(replacement, reader, operations) {
|
|
955
|
+
let active = true;
|
|
956
|
+
const writer = this.#optimisticWriter(operations, () => active);
|
|
957
|
+
try {
|
|
958
|
+
const result = replacement(reader, writer);
|
|
959
|
+
assertSynchronousResult(result, 'optimistic layer replacement');
|
|
960
|
+
}
|
|
961
|
+
finally {
|
|
962
|
+
active = false;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
850
965
|
#writeBaseRecord(write, touched) {
|
|
851
966
|
validateRecordKey(write.key);
|
|
852
967
|
const revision = revisionToken(write.revision);
|
|
@@ -3,3 +3,7 @@ export declare class CacheRevisionConflictError extends Error {
|
|
|
3
3
|
readonly revision: string;
|
|
4
4
|
constructor(dependency: string, revision: bigint);
|
|
5
5
|
}
|
|
6
|
+
export declare class OptimisticLayerNotFoundError extends Error {
|
|
7
|
+
readonly layerId: string;
|
|
8
|
+
constructor(layerId: string);
|
|
9
|
+
}
|
|
@@ -8,3 +8,11 @@ export class CacheRevisionConflictError extends Error {
|
|
|
8
8
|
this.revision = revision.toString(10);
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
|
+
export class OptimisticLayerNotFoundError extends Error {
|
|
12
|
+
layerId;
|
|
13
|
+
constructor(layerId) {
|
|
14
|
+
super(`optimistic layer not found: ${layerId}`);
|
|
15
|
+
this.name = 'OptimisticLayerNotFoundError';
|
|
16
|
+
this.layerId = layerId;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export type { BaseCacheWriter, BaseRecordClock, CacheEngine, CacheEngineOptions, CacheEngineSnapshot, CacheIndex, CacheIndexCoverage, CacheIndexMetadata, CacheListener, CachePresence, CacheReader, CacheSelector, CacheValue, DerivedIndexMutation, DerivedIndexReconciler, IndexKey, IndexWrite, OptimisticCacheWriter, OptimisticIndexWrite, OptimisticLayerContext, OptimisticLayerState, OptimisticLayerView, OptimisticRecordWrite, RecordKey, RecordLink, RecordWrite, Revision, SparseRecord, SparseRecordMeta, WatchOptions } from './types.js';
|
|
2
|
-
export { CacheRevisionConflictError } from './errors.js';
|
|
1
|
+
export type { BaseCacheWriter, BaseRecordClock, CacheEngine, CacheEngineOptions, CacheEngineSnapshot, CacheIndex, CacheIndexCoverage, CacheIndexMetadata, CacheListener, CachePresence, CacheReader, CacheSelector, CacheValue, DerivedIndexMutation, DerivedIndexReconciler, IndexKey, IndexWrite, OptimisticCacheWriter, OptimisticIndexWrite, OptimisticLayerContext, OptimisticLayerReplacement, OptimisticLayerState, OptimisticLayerView, OptimisticRecordWrite, RecordKey, RecordLink, RecordWrite, Revision, SparseRecord, SparseRecordMeta, WatchOptions } from './types.js';
|
|
2
|
+
export { CacheRevisionConflictError, OptimisticLayerNotFoundError } from './errors.js';
|
|
3
3
|
export { cacheIndexKey, createCacheEngine } from './create.js';
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { CacheRevisionConflictError } from './errors.js';
|
|
1
|
+
export { CacheRevisionConflictError, OptimisticLayerNotFoundError } from './errors.js';
|
|
2
2
|
export { cacheIndexKey, createCacheEngine } from './create.js';
|
|
@@ -12,6 +12,10 @@ export type RecordWrite = {
|
|
|
12
12
|
incarnation?: Revision;
|
|
13
13
|
/** Omitted fields stay absent. A present `null` remains present. */
|
|
14
14
|
fields?: Readonly<Record<string, CacheValue>>;
|
|
15
|
+
/** Optimistic-only field removals; base writes never use this member. */
|
|
16
|
+
unset?: readonly string[];
|
|
17
|
+
/** Optimistic-only guard which suppresses the whole write when absent. */
|
|
18
|
+
ifPresent?: boolean;
|
|
15
19
|
/** Relationship identities are stored separately from scalar/JSON fields. */
|
|
16
20
|
links?: Readonly<Record<string, RecordLink>>;
|
|
17
21
|
};
|
|
@@ -113,6 +117,7 @@ export interface OptimisticCacheWriter {
|
|
|
113
117
|
writeIndex(write: OptimisticIndexWrite): void;
|
|
114
118
|
deleteIndex(key: IndexKey): void;
|
|
115
119
|
}
|
|
120
|
+
export type OptimisticLayerReplacement = (reader: CacheReader, writer: OptimisticCacheWriter) => void;
|
|
116
121
|
export type CacheSelector<T> = (reader: CacheReader) => T;
|
|
117
122
|
export type CacheListener<T> = (value: T, previous: T | undefined) => void;
|
|
118
123
|
export type WatchOptions = {
|
|
@@ -191,6 +196,11 @@ export interface CacheEngine {
|
|
|
191
196
|
watch<T>(selector: CacheSelector<T>, listener: CacheListener<T>, options?: WatchOptions): () => void;
|
|
192
197
|
batch<T>(update: (writer: BaseCacheWriter) => T): T;
|
|
193
198
|
createOptimisticLayer(id: string, update: (writer: OptimisticCacheWriter) => void, context?: OptimisticLayerContext): void;
|
|
199
|
+
/**
|
|
200
|
+
* Atomically replace a layer in place. The replacement evaluates against
|
|
201
|
+
* confirmed state plus only the optimistic layers below the target.
|
|
202
|
+
*/
|
|
203
|
+
replaceOptimisticLayer(id: string, replacement: OptimisticLayerReplacement, context?: OptimisticLayerContext): boolean;
|
|
194
204
|
setDerivedIndexReconciler(reconciler: DerivedIndexReconciler | undefined): void;
|
|
195
205
|
markOptimisticLayerAccepted(id: string): boolean;
|
|
196
206
|
confirmOptimisticLayer<T>(id: string, update: (writer: BaseCacheWriter) => T): T;
|
|
@@ -204,6 +214,12 @@ export interface CacheEngine {
|
|
|
204
214
|
* their acceptance state, and causal sequencing floors.
|
|
205
215
|
*/
|
|
206
216
|
restoreConfirmed(snapshot: CacheEngineSnapshot): void;
|
|
217
|
+
/**
|
|
218
|
+
* Upsert confirmed records/indexes from a same-scope seed without deleting
|
|
219
|
+
* keys the seed omitted. Used when a warm SPA client re-receives a
|
|
220
|
+
* route-local SSR dehydrate that is a subset of the session cache.
|
|
221
|
+
*/
|
|
222
|
+
mergeConfirmed(snapshot: CacheEngineSnapshot): void;
|
|
207
223
|
/**
|
|
208
224
|
* Drop incomparable/reset base indexes without assigning them a fabricated
|
|
209
225
|
* revision. Pending optimistic overlays remain layered above the new gap.
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
/** Private cache engine; implementation lives in ./cache-engine/. */
|
|
2
|
-
export { CacheRevisionConflictError, cacheIndexKey, createCacheEngine } from './cache-engine/index.js';
|
|
3
|
-
export type { BaseCacheWriter, BaseRecordClock, CacheEngine, CacheEngineOptions, CacheEngineSnapshot, CacheIndex, CacheIndexCoverage, CacheIndexMetadata, CacheListener, CachePresence, CacheReader, CacheSelector, CacheValue, DerivedIndexMutation, DerivedIndexReconciler, IndexKey, IndexWrite, OptimisticCacheWriter, OptimisticIndexWrite, OptimisticLayerContext, OptimisticLayerState, OptimisticLayerView, OptimisticRecordWrite, RecordKey, RecordLink, RecordWrite, Revision, SparseRecord, SparseRecordMeta, WatchOptions } from './cache-engine/index.js';
|
|
2
|
+
export { CacheRevisionConflictError, OptimisticLayerNotFoundError, cacheIndexKey, createCacheEngine } from './cache-engine/index.js';
|
|
3
|
+
export type { BaseCacheWriter, BaseRecordClock, CacheEngine, CacheEngineOptions, CacheEngineSnapshot, CacheIndex, CacheIndexCoverage, CacheIndexMetadata, CacheListener, CachePresence, CacheReader, CacheSelector, CacheValue, DerivedIndexMutation, DerivedIndexReconciler, IndexKey, IndexWrite, OptimisticCacheWriter, OptimisticIndexWrite, OptimisticLayerContext, OptimisticLayerReplacement, OptimisticLayerState, OptimisticLayerView, OptimisticRecordWrite, RecordKey, RecordLink, RecordWrite, Revision, SparseRecord, SparseRecordMeta, WatchOptions } from './cache-engine/index.js';
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Private cache engine; implementation lives in ./cache-engine/. */
|
|
2
|
-
export { CacheRevisionConflictError, cacheIndexKey, createCacheEngine } from './cache-engine/index.js';
|
|
2
|
+
export { CacheRevisionConflictError, OptimisticLayerNotFoundError, cacheIndexKey, createCacheEngine } from './cache-engine/index.js';
|
package/dist/protocol.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* positions remain opaque strings: the JavaScript client compares or returns
|
|
7
7
|
* them, but never parses them as numbers or reconstructs server scopes.
|
|
8
8
|
*/
|
|
9
|
+
import { type CommandProjectionMetadata } from './replica/projection-delta/index.js';
|
|
9
10
|
/** The only Distributed GraphQL protocol version understood by this package. */
|
|
10
11
|
export declare const DISTRIBUTED_PROTOCOL_VERSION: 1;
|
|
11
12
|
declare const opaqueDistributedString: unique symbol;
|
|
@@ -18,8 +19,10 @@ export type DistributedOpaqueString = string & {
|
|
|
18
19
|
export type DistributedDecimalString = string & {
|
|
19
20
|
readonly [distributedDecimalString]: true;
|
|
20
21
|
};
|
|
21
|
-
export type DistributedCommandState = 'in_progress' | '
|
|
22
|
-
export type DistributedCommandConsistency = '
|
|
22
|
+
export type DistributedCommandState = 'in_progress' | 'succeeded' | 'succeeded_pending_projection' | 'atomic' | 'rejected' | 'projection_failed' | 'expired' | 'unknown';
|
|
23
|
+
export type DistributedCommandConsistency = 'succeeded' | 'eventual' | 'atomic';
|
|
24
|
+
/** Current-scope handling for authenticated historical projector evidence. */
|
|
25
|
+
export type DistributedProjectionDisposition = 'revalidate';
|
|
23
26
|
/** Closed wire codecs for server-derived, client-visible trusted presets. */
|
|
24
27
|
export type DistributedTrustedPresetCodec = 'string' | 'string_unvalidated_timestamp' | 'base64' | 'boolean' | 'int32' | 'float64' | 'json_number_precision_limited' | 'json';
|
|
25
28
|
/** JSON value accepted by the Distributed protocol parser. */
|
|
@@ -107,16 +110,20 @@ export type DistributedCommandMetadata = Readonly<Record<string, unknown> & {
|
|
|
107
110
|
causationId: DistributedOpaqueString;
|
|
108
111
|
state: DistributedCommandState;
|
|
109
112
|
consistency: DistributedCommandConsistency;
|
|
113
|
+
projectionDisposition?: DistributedProjectionDisposition;
|
|
110
114
|
expects: readonly DistributedProjectionExpectation[];
|
|
111
115
|
/** Defaults to an empty array when omitted by the compact wire format. */
|
|
112
116
|
observations: readonly DistributedProjectionObservation[];
|
|
113
117
|
/** Defaults to an empty array when omitted by the compact wire format. */
|
|
114
118
|
records: readonly DistributedRecordRevision[];
|
|
119
|
+
/** Exact modeled projection delta and opaque observation obligations. */
|
|
120
|
+
projection?: CommandProjectionMetadata;
|
|
115
121
|
}>;
|
|
116
122
|
/** Canonical contents of top-level `extensions.distributed`. */
|
|
117
123
|
export type DistributedProtocolEnvelope = Readonly<Record<string, unknown> & {
|
|
118
124
|
protocolVersion: typeof DISTRIBUTED_PROTOCOL_VERSION;
|
|
119
125
|
schemaHash: string;
|
|
126
|
+
authorizationGeneration: string;
|
|
120
127
|
cacheScope: DistributedOpaqueString;
|
|
121
128
|
operation?: string;
|
|
122
129
|
command?: DistributedCommandMetadata;
|
package/dist/protocol.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* positions remain opaque strings: the JavaScript client compares or returns
|
|
7
7
|
* them, but never parses them as numbers or reconstructs server scopes.
|
|
8
8
|
*/
|
|
9
|
+
import { parseCommandProjectionMetadata } from './replica/projection-delta/index.js';
|
|
9
10
|
/** The only Distributed GraphQL protocol version understood by this package. */
|
|
10
11
|
export const DISTRIBUTED_PROTOCOL_VERSION = 1;
|
|
11
12
|
/** Safe parse failure that reports structure, never hidden server values. */
|
|
@@ -23,18 +24,18 @@ export class DistributedProtocolError extends Error {
|
|
|
23
24
|
}
|
|
24
25
|
const COMMAND_STATES = new Set([
|
|
25
26
|
'in_progress',
|
|
26
|
-
'
|
|
27
|
-
'
|
|
28
|
-
'
|
|
27
|
+
'succeeded',
|
|
28
|
+
'succeeded_pending_projection',
|
|
29
|
+
'atomic',
|
|
29
30
|
'rejected',
|
|
30
31
|
'projection_failed',
|
|
31
32
|
'expired',
|
|
32
33
|
'unknown'
|
|
33
34
|
]);
|
|
34
35
|
const COMMAND_CONSISTENCIES = new Set([
|
|
35
|
-
'
|
|
36
|
-
'
|
|
37
|
-
'
|
|
36
|
+
'succeeded',
|
|
37
|
+
'eventual',
|
|
38
|
+
'atomic'
|
|
38
39
|
]);
|
|
39
40
|
const MAX_PUBLIC_NAME_LENGTH = 512;
|
|
40
41
|
const MAX_OPAQUE_STRING_LENGTH = 16_384;
|
|
@@ -79,6 +80,7 @@ export function parseDistributedProtocolEnvelope(value) {
|
|
|
79
80
|
invalid('extensions.distributed.protocolVersion');
|
|
80
81
|
}
|
|
81
82
|
const schemaHash = publicString(envelope.schemaHash, 'extensions.distributed.schemaHash');
|
|
83
|
+
const authorizationGeneration = publicString(envelope.authorizationGeneration, 'extensions.distributed.authorizationGeneration');
|
|
82
84
|
const cacheScope = opaqueString(envelope.cacheScope, 'extensions.distributed.cacheScope');
|
|
83
85
|
const operation = envelope.operation === undefined
|
|
84
86
|
? undefined
|
|
@@ -98,6 +100,7 @@ export function parseDistributedProtocolEnvelope(value) {
|
|
|
98
100
|
...envelope,
|
|
99
101
|
protocolVersion: DISTRIBUTED_PROTOCOL_VERSION,
|
|
100
102
|
schemaHash,
|
|
103
|
+
authorizationGeneration,
|
|
101
104
|
cacheScope,
|
|
102
105
|
...(operation === undefined ? {} : { operation }),
|
|
103
106
|
...(command === undefined ? {} : { command }),
|
|
@@ -184,6 +187,14 @@ function parseCommand(value) {
|
|
|
184
187
|
});
|
|
185
188
|
const observations = parseOptionalEvidenceArray(command.observations, 'extensions.distributed.command.observations', parseProjectionObservation);
|
|
186
189
|
const records = parseOptionalEvidenceArray(command.records, 'extensions.distributed.command.records', parseRecordRevision);
|
|
190
|
+
const projection = command.projection === undefined
|
|
191
|
+
? undefined
|
|
192
|
+
: parseCommandProjectionMetadata(command.projection);
|
|
193
|
+
const projectionDisposition = command.projectionDisposition === undefined
|
|
194
|
+
? undefined
|
|
195
|
+
: command.projectionDisposition === 'revalidate'
|
|
196
|
+
? 'revalidate'
|
|
197
|
+
: invalid('extensions.distributed.command.projectionDisposition');
|
|
187
198
|
const commandId = opaqueString(command.commandId, 'extensions.distributed.command.commandId');
|
|
188
199
|
const causationId = opaqueString(command.causationId, 'extensions.distributed.command.causationId');
|
|
189
200
|
const expectationKeys = new Set(expects.map(projectionExpectationKey));
|
|
@@ -197,15 +208,32 @@ function parseCommand(value) {
|
|
|
197
208
|
}
|
|
198
209
|
seenObservations.add(key);
|
|
199
210
|
}
|
|
211
|
+
if (projectionDisposition === 'revalidate' &&
|
|
212
|
+
(projection !== undefined ||
|
|
213
|
+
expects.length !== 0 ||
|
|
214
|
+
observations.length !== 0 ||
|
|
215
|
+
records.length !== 0 ||
|
|
216
|
+
![
|
|
217
|
+
'succeeded',
|
|
218
|
+
'succeeded_pending_projection',
|
|
219
|
+
'atomic',
|
|
220
|
+
'projection_failed'
|
|
221
|
+
].includes(state))) {
|
|
222
|
+
invalid('extensions.distributed.command.projectionDisposition');
|
|
223
|
+
}
|
|
200
224
|
return Object.freeze({
|
|
201
225
|
...command,
|
|
202
226
|
commandId,
|
|
203
227
|
causationId,
|
|
204
228
|
state,
|
|
205
229
|
consistency,
|
|
230
|
+
...(projectionDisposition === undefined
|
|
231
|
+
? {}
|
|
232
|
+
: { projectionDisposition }),
|
|
206
233
|
expects: Object.freeze(expects),
|
|
207
234
|
observations,
|
|
208
|
-
records
|
|
235
|
+
records,
|
|
236
|
+
...(projection === undefined ? {} : { projection })
|
|
209
237
|
});
|
|
210
238
|
}
|
|
211
239
|
function parseSnapshot(value) {
|