@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.
Files changed (78) hide show
  1. package/README.md +26 -13
  2. package/dist/auth-headers.js +3 -3
  3. package/dist/internal/cache-engine/engine.d.ts +3 -1
  4. package/dist/internal/cache-engine/engine.js +137 -22
  5. package/dist/internal/cache-engine/errors.d.ts +4 -0
  6. package/dist/internal/cache-engine/errors.js +8 -0
  7. package/dist/internal/cache-engine/index.d.ts +2 -2
  8. package/dist/internal/cache-engine/index.js +1 -1
  9. package/dist/internal/cache-engine/types.d.ts +16 -0
  10. package/dist/internal/cache-engine.d.ts +2 -2
  11. package/dist/internal/cache-engine.js +1 -1
  12. package/dist/protocol.d.ts +9 -2
  13. package/dist/protocol.js +35 -7
  14. package/dist/replica/command-runtime/create.js +499 -57
  15. package/dist/replica/command-runtime/index.d.ts +1 -1
  16. package/dist/replica/command-runtime/index.js +1 -1
  17. package/dist/replica/command-runtime/lib/effects.d.ts +22 -9
  18. package/dist/replica/command-runtime/lib/effects.js +118 -20
  19. package/dist/replica/command-runtime/lib/projection.d.ts +2 -2
  20. package/dist/replica/command-runtime/lib/projection.js +14 -6
  21. package/dist/replica/command-runtime/lib/status.js +18 -10
  22. package/dist/replica/command-runtime/lib/transport.js +4 -0
  23. package/dist/replica/command-runtime/lib/util.js +8 -3
  24. package/dist/replica/command-runtime/symbols.d.ts +9 -0
  25. package/dist/replica/command-runtime/symbols.js +9 -0
  26. package/dist/replica/command-runtime/types.d.ts +15 -6
  27. package/dist/replica/command-runtime/types.js +1 -1
  28. package/dist/replica/command-runtime.d.ts +1 -1
  29. package/dist/replica/command-runtime.js +1 -1
  30. package/dist/replica/commands/clone.js +2 -1
  31. package/dist/replica/commands/index.d.ts +1 -0
  32. package/dist/replica/commands/prepare.js +7 -4
  33. package/dist/replica/commands/receipt.js +30 -16
  34. package/dist/replica/commands/types.d.ts +10 -5
  35. package/dist/replica/commands/validate.d.ts +5 -1
  36. package/dist/replica/commands/validate.js +129 -62
  37. package/dist/replica/diagnostics/inspect.js +51 -19
  38. package/dist/replica/diagnostics/types.d.ts +7 -7
  39. package/dist/replica/distributed-replica/helpers.js +22 -8
  40. package/dist/replica/distributed-replica/hydration.js +14 -2
  41. package/dist/replica/distributed-replica/impl-diagnostics.d.ts +1 -1
  42. package/dist/replica/distributed-replica/impl-diagnostics.js +4 -3
  43. package/dist/replica/distributed-replica/impl-hydration-orchestrate.d.ts +1 -1
  44. package/dist/replica/distributed-replica/impl-hydration-orchestrate.js +76 -42
  45. package/dist/replica/distributed-replica/impl-optimistic.d.ts +11 -2
  46. package/dist/replica/distributed-replica/impl-optimistic.js +22 -6
  47. package/dist/replica/distributed-replica/impl-protocol.js +4 -2
  48. package/dist/replica/distributed-replica/impl.d.ts +8 -2
  49. package/dist/replica/distributed-replica/impl.js +22 -5
  50. package/dist/replica/distributed-replica/optimistic.js +31 -3
  51. package/dist/replica/distributed-replica/types.d.ts +1 -0
  52. package/dist/replica/index-maintenance/engine.js +14 -6
  53. package/dist/replica/index.d.ts +4 -0
  54. package/dist/replica/index.js +2 -0
  55. package/dist/replica/materialize.js +25 -0
  56. package/dist/replica/mutation-cache.d.ts +62 -0
  57. package/dist/replica/mutation-cache.js +91 -0
  58. package/dist/replica/operation-binding.js +17 -6
  59. package/dist/replica/persistence/state.js +9 -1
  60. package/dist/replica/projection-delta/canonical.d.ts +6 -0
  61. package/dist/replica/projection-delta/canonical.js +29 -0
  62. package/dist/replica/projection-delta/index.d.ts +5 -0
  63. package/dist/replica/projection-delta/index.js +3 -0
  64. package/dist/replica/projection-delta/resolve.d.ts +8 -0
  65. package/dist/replica/projection-delta/resolve.js +266 -0
  66. package/dist/replica/projection-delta/types.d.ts +415 -0
  67. package/dist/replica/projection-delta/types.js +6 -0
  68. package/dist/replica/projection-delta/validate.d.ts +18 -0
  69. package/dist/replica/projection-delta/validate.js +1718 -0
  70. package/dist/replica/projection-delta/wasm-pure.d.ts +36 -0
  71. package/dist/replica/projection-delta/wasm-pure.js +71 -0
  72. package/dist/replica/query-plan/pagination.js +18 -8
  73. package/dist/replica/types.d.ts +13 -2
  74. package/dist/sveltekit/replica.d.ts +1 -1
  75. package/dist/sveltekit/replica.js +3 -0
  76. package/dist/sveltekit/vite.d.ts +6 -6
  77. package/dist/sveltekit/vite.js +6 -6
  78. 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. `dctl client` combines that surface with application GraphQL
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
- dctl client-manifest > target/distributed-client.json
40
+ distributed client-manifest > target/distributed-client.json
41
41
 
42
- dctl client \
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: 'fieldnote',
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: 'fieldnote-admin',
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 `dctl client` at startup/build, watches GraphQL
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
- request-local state without exposing a public storage schema.
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. resolves projected completion only after exact causal evidence arrives.
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 safe maintenance,
314
- the generated plan marks the affected projection stale and the replica performs
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 `dctl client` and import its operation/command artifacts;
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;
@@ -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-role'] = auth.role ?? 'user';
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-role'] = auth.role ?? 'user';
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-role', auth.role ?? 'user');
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
- const { records, indexes } = this.#materialize(includeOptimistic);
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 this.#layers) {
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 this.#derivedIndexOperations) {
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(), this.#layers.map((layer) => Object.freeze({
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
- 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;
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
- if (Object.keys(fields).length === 0 && Object.keys(links).length === 0)
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({ kind: 'write-record', write: { key: write.key, fields, links } });
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';
@@ -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' | 'accepted' | 'accepted_pending_projection' | 'projected' | 'rejected' | 'projection_failed' | 'expired' | 'unknown';
22
- export type DistributedCommandConsistency = 'accepted' | 'fact' | 'projected';
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
- 'accepted',
27
- 'accepted_pending_projection',
28
- 'projected',
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
- 'accepted',
36
- 'fact',
37
- 'projected'
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) {