@hops-ops/distributed 4.1.1 → 4.3.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.
@@ -1,2 +1,4 @@
1
- /** Create a browser/Node UUIDv7 command identity. */
2
- export declare function createReplicaCommandId(): string;
1
+ /** Create a browser/Node UUIDv7 identity for commands or generated record IDs. */
2
+ export declare function createReplicaUuidV7(): string;
3
+ /** Package-internal semantic alias used while preparing command envelopes. */
4
+ export declare const createReplicaCommandId: typeof createReplicaUuidV7;
@@ -1,5 +1,5 @@
1
- /** Create a browser/Node UUIDv7 command identity. */
2
- export function createReplicaCommandId() {
1
+ /** Create a browser/Node UUIDv7 identity for commands or generated record IDs. */
2
+ export function createReplicaUuidV7() {
3
3
  const crypto = globalThis.crypto;
4
4
  if (!crypto || typeof crypto.getRandomValues !== 'function') {
5
5
  throw new Error('replica commands require crypto.getRandomValues');
@@ -17,3 +17,5 @@ export function createReplicaCommandId() {
17
17
  .slice(6, 8)
18
18
  .join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}`;
19
19
  }
20
+ /** Package-internal semantic alias used while preparing command envelopes. */
21
+ export const createReplicaCommandId = createReplicaUuidV7;
@@ -288,7 +288,7 @@ export function createReplicaCommandRuntime(replica, transport, entries, options
288
288
  throw new Error('projection delta changed during command replay');
289
289
  }
290
290
  return Object.freeze({
291
- requiresRevalidation: actual.revalidate || actual.obligations.length === 0
291
+ requiresRevalidation: actual.revalidate
292
292
  });
293
293
  }
294
294
  assertActualProjectionCapabilities(prepared.projection.contract, actual.delta);
@@ -300,10 +300,10 @@ export function createReplicaCommandRuntime(replica, transport, entries, options
300
300
  return Object.freeze({
301
301
  canonical,
302
302
  operations,
303
- revalidation: actual.revalidate || actual.obligations.length === 0
303
+ revalidation: actual.revalidate
304
304
  ? actualProjectionRevalidation(prepared.revalidation, actual.delta)
305
305
  : undefined,
306
- requiresRevalidation: actual.revalidate || actual.obligations.length === 0
306
+ requiresRevalidation: actual.revalidate
307
307
  });
308
308
  };
309
309
  const commitActualProjection = (prepared, validated) => {
@@ -595,6 +595,17 @@ export function createReplicaCommandRuntime(replica, transport, entries, options
595
595
  settleTrackedProjection(tracker, pending);
596
596
  }
597
597
  }
598
+ else if (metadata.state === 'atomic' &&
599
+ !prepared.revalidation.required &&
600
+ !statusRequiresRevalidation) {
601
+ /*
602
+ * An exact terminal delta proves delivery but carries no
603
+ * canonical revision. Keep its accepted overlay until a later
604
+ * comparable authoritative result seals it, without racing
605
+ * sibling commands with a command-triggered query.
606
+ */
607
+ settleTrackedProjection(tracker, pending);
608
+ }
598
609
  else if (metadata.state === 'atomic' ||
599
610
  (metadata.state === 'succeeded' &&
600
611
  metadata.expects.length === 0 &&
@@ -1051,9 +1062,16 @@ export function createReplicaCommandRuntime(replica, transport, entries, options
1051
1062
  * DistributedReplica is the authority on whether this frame's
1052
1063
  * snapshot/observations were admissible. This callback runs only
1053
1064
  * after that exact frame committed.
1065
+ *
1066
+ * Eventual list membership fences may keep the optimistic overlay
1067
+ * until @live includes the new row. `projected` is delivery, not
1068
+ * overlay retirement. Query/live frames have no command payload;
1069
+ * settle those so Send/busy can clear. Frames that name a command
1070
+ * still wait for overlay retirement or the command-state paths
1071
+ * above (status regression must be able to reject `projected`).
1054
1072
  */
1055
1073
  const remainsPending = replica.markOptimisticLayerAccepted(commandId);
1056
- if (!remainsPending) {
1074
+ if (!remainsPending || command === undefined) {
1057
1075
  settleProjectionSuccess(controller);
1058
1076
  pending.delete(commandId);
1059
1077
  }
@@ -1,4 +1,4 @@
1
- import type { CacheEngine, OptimisticLayerReplacement } from '../../internal/cache-engine.js';
1
+ import type { BaseCacheWriter, CacheEngine, OptimisticLayerReplacement } from '../../internal/cache-engine.js';
2
2
  import { type DistributedCommandMetadata, type DistributedProjectionObservation } from '../../protocol.js';
3
3
  import type { ReplicaDiagnosticEventInput, ReplicaDiagnosticLayerInput } from '../diagnostics.js';
4
4
  import type { ReplicaIndexSemanticChange } from '../index-maintenance.js';
@@ -32,6 +32,8 @@ export declare function replaceOptimisticLayerOn(host: OptimisticHost, id: strin
32
32
  export declare function replaceReplicaOptimisticLayerOn(host: OptimisticHost, id: string, update: (writer: ReplicaOptimisticWriter) => void, semanticChanges: readonly ReplicaIndexSemanticChange[]): boolean;
33
33
  export declare function markOptimisticLayerAcceptedOn(host: OptimisticHost, id: string, receipt?: DistributedCommandMetadata): boolean;
34
34
  export declare function confirmOptimisticLayerOn<T>(host: OptimisticHost, id: string, update: (writer: ReplicaBaseWriter) => T): T;
35
+ /** Internal confirmation seam for protocol code that must atomically seal indexes. */
36
+ export declare function confirmOptimisticLayerWithCacheWriterOn<T>(host: OptimisticHost, id: string, update: (writer: BaseCacheWriter) => T): T;
35
37
  export declare function rejectOptimisticLayerOn(host: OptimisticHost, id: string): boolean;
36
38
  export declare function planOptimisticReceipts(host: OptimisticHost, command: DistributedCommandMetadata | undefined, observations: readonly DistributedProjectionObservation[], satisfactionAdmissible: boolean): {
37
39
  updates: Map<string, OptimisticReceiptState>;
@@ -113,7 +113,11 @@ export function markOptimisticLayerAcceptedOn(host, id, receipt) {
113
113
  return true;
114
114
  }
115
115
  export function confirmOptimisticLayerOn(host, id, update) {
116
- const result = host.engine.confirmOptimisticLayer(id, (writer) => update(baseWriter(writer)));
116
+ return confirmOptimisticLayerWithCacheWriterOn(host, id, (writer) => update(baseWriter(writer)));
117
+ }
118
+ /** Internal confirmation seam for protocol code that must atomically seal indexes. */
119
+ export function confirmOptimisticLayerWithCacheWriterOn(host, id, update) {
120
+ const result = host.engine.confirmOptimisticLayer(id, update);
117
121
  host.retireDiagnosticLayer(id, 'retired', 'atomic');
118
122
  host.optimisticReceipts.delete(id);
119
123
  host.syncDiagnostics();
@@ -17,6 +17,8 @@ export type ProtocolHost = {
17
17
  readonly recordClocks: Map<string, RecordProtocolClock>;
18
18
  readonly recordKeysByScope: Map<DistributedOpaqueString, string>;
19
19
  readonly projectedRecordFences: Map<string, ProjectedRecordFence>;
20
+ readonly membershipFences: Map<string, Map<string, Set<string>>>;
21
+ readonly deferredMembershipConfirms: Set<string>;
20
22
  readonly anonymousRecordClocks: Map<DistributedOpaqueString, AnonymousRecordProtocolClock>;
21
23
  readonly optimisticReceipts: Map<string, OptimisticReceiptState>;
22
24
  readonly diagnosticLayers: Map<string, ReplicaDiagnosticLayerInput> | undefined;
@@ -73,6 +73,8 @@ export function purgeProtocolGeneration(host) {
73
73
  host.recordClocks.clear();
74
74
  host.recordKeysByScope.clear();
75
75
  host.projectedRecordFences.clear();
76
+ host.membershipFences.clear();
77
+ host.deferredMembershipConfirms.clear();
76
78
  host.anonymousRecordClocks.clear();
77
79
  host.optimisticReceipts.clear();
78
80
  host.diagnosticLayers?.clear();
@@ -11,11 +11,11 @@ import { createReplicaIndexMaintenanceRegistry, formatReplicaIndexStaleReason }
11
11
  import { EMPTY_ERRORS, EMPTY_TRUSTED_PRESETS, MAX_ANONYMOUS_RECORD_CLOCKS } from './constants.js';
12
12
  import { compareCanonicalDecimalStrings, compareEvidenceToProjectedFence, compareIndexVector, compareProjectedRecordFields, compareRecordClock, compareSnapshotToOperationState, incrementCanonicalDecimal, indexClockMap, isComparableHandoffDisposition, latestCursors, protocolInvalid, recordKeyMatchesModel, responsePathKey, sameRecordClock, sameRecordRevision } from './clocks.js';
13
13
  import { diagnosticReceiptCounts } from './optimistic.js';
14
- import { assertWriteSource, indexKeyFromTarget, indexMaintenanceSnapshot, indexSemanticLayer, operationKey, prepareRecordEvidence, protocolOperationSource, replicaResultIndexKeys, reportSafely, reportUnhandledObserverError, snapshotFrom, stableErrors, trustedPresetDescriptorFingerprint, validatedCommandAuthorityContract } from './helpers.js';
14
+ import { assertWriteSource, baseWriter, indexKeyFromTarget, indexMaintenanceSnapshot, indexSemanticLayer, operationKey, prepareRecordEvidence, protocolOperationSource, replicaResultIndexKeys, reportSafely, reportUnhandledObserverError, snapshotFrom, stableErrors, trustedPresetDescriptorFingerprint, validatedCommandAuthorityContract } from './helpers.js';
15
15
  import { ReplicaWatchState } from './watch.js';
16
16
  import { closeActiveTransports as closeActiveTransportsOn, emitWatchState, fetchWatch, releaseLive as releaseLiveOn, restartLive as restartLiveOn, retainLive as retainLiveOn, resumeLiveWatches as resumeLiveWatchesOn } from './impl-fetch-live.js';
17
17
  import { closeAuthorizationGeneration as closeAuthorizationGenerationOn, purgeProtocolGeneration as purgeProtocolGenerationOn, stageProtocolGeneration as stageProtocolGenerationOn, stageTrustedPresets as stageTrustedPresetsOn, validateProtocolBinding as validateProtocolBindingOn } from './impl-protocol.js';
18
- import { applyReceiptOnly as applyReceiptOnlyOn, confirmOptimisticLayerOn, createOptimisticLayerOn, markOptimisticLayerAcceptedOn, planOptimisticReceipts as planOptimisticReceiptsOn, rejectOptimisticLayerOn, replaceReplicaOptimisticLayerOn } from './impl-optimistic.js';
18
+ import { applyReceiptOnly as applyReceiptOnlyOn, confirmOptimisticLayerOn, confirmOptimisticLayerWithCacheWriterOn, createOptimisticLayerOn, markOptimisticLayerAcceptedOn, planOptimisticReceipts as planOptimisticReceiptsOn, rejectOptimisticLayerOn, replaceReplicaOptimisticLayerOn } from './impl-optimistic.js';
19
19
  import { dehydrateReplica, finishDehydration as finishDehydrationOn, hydrateReplica } from './impl-hydration-orchestrate.js';
20
20
  import { diagnosticEvent as diagnosticEventOn, diagnosticOperation as diagnosticOperationOn, diagnosticScopeTransition as diagnosticScopeTransitionOn, retireDiagnosticLayer as retireDiagnosticLayerOn, syncDiagnostics as syncDiagnosticsOn } from './impl-diagnostics.js';
21
21
  export class DistributedReplicaImpl {
@@ -34,6 +34,14 @@ export class DistributedReplicaImpl {
34
34
  #recordClocks = new Map();
35
35
  #recordKeysByScope = new Map();
36
36
  #projectedRecordFences = new Map();
37
+ /**
38
+ * Record keys inserted by Eventual projection-delta. A later complete
39
+ * query/live index that omits them is behind command confirmation and
40
+ * must not shrink the visible list. Atomic rows use projected-record
41
+ * fences only — they have no @live that would otherwise clear this map.
42
+ */
43
+ #membershipFences = new Map();
44
+ #deferredMembershipConfirms = new Set();
37
45
  #anonymousRecordClocks = new Map();
38
46
  #optimisticReceipts = new Map();
39
47
  #renderedOperations = new Map();
@@ -160,6 +168,12 @@ export class DistributedReplicaImpl {
160
168
  get projectedRecordFences() {
161
169
  return self.#projectedRecordFences;
162
170
  },
171
+ get membershipFences() {
172
+ return self.#membershipFences;
173
+ },
174
+ get deferredMembershipConfirms() {
175
+ return self.#deferredMembershipConfirms;
176
+ },
163
177
  get anonymousRecordClocks() {
164
178
  return self.#anonymousRecordClocks;
165
179
  },
@@ -435,13 +449,46 @@ export class DistributedReplicaImpl {
435
449
  const pendingAnonymousRecordClocks = new Map();
436
450
  const consumedAnonymousRecordClocks = new Set();
437
451
  const apply = this.#resolveRecordEvidence(recordKey, evidence, pendingRecordClocks, pendingRecordScopes, pendingAnonymousRecordClocks, consumedAnonymousRecordClocks);
438
- this.confirmOptimisticLayer(commandId, (writer) => {
452
+ /*
453
+ * The Atomic output is a complete authoritative row. Seal every active
454
+ * collection membership that the compiler plan can prove from that row in
455
+ * the same base transaction; otherwise the record exists by key while a
456
+ * warm @load index still omits it until refresh.
457
+ */
458
+ const indexMutations = apply
459
+ ? this.#directProjectionIndexMutations(commandId, model, recordKey, fields)
460
+ : Object.freeze([]);
461
+ const indexRevision = indexMutations.length === 0
462
+ ? undefined
463
+ : this.#allocateIndexRevision();
464
+ confirmOptimisticLayerWithCacheWriterOn(this.#optimisticHost(), commandId, (writer) => {
439
465
  if (!apply)
440
466
  return false;
441
- return writer.writeRecord(model, identity, evidence.revision, {
467
+ const wrote = writer.writeRecord({
468
+ key: recordKey,
469
+ revision: evidence.revision,
442
470
  incarnation: evidence.incarnation,
443
471
  fields
444
472
  });
473
+ if (indexRevision !== undefined) {
474
+ for (const mutation of indexMutations) {
475
+ switch (mutation.kind) {
476
+ case 'write':
477
+ writer.writeIndex({
478
+ ...mutation.write,
479
+ revision: indexRevision
480
+ });
481
+ break;
482
+ case 'stale':
483
+ writer.markIndexStale(mutation.key, mutation.reason, indexRevision);
484
+ break;
485
+ case 'delete':
486
+ writer.deleteIndex(mutation.key, indexRevision);
487
+ break;
488
+ }
489
+ }
490
+ }
491
+ return wrote;
445
492
  });
446
493
  for (const [key, clock] of pendingRecordClocks) {
447
494
  this.#recordClocks.set(key, clock);
@@ -465,6 +512,11 @@ export class DistributedReplicaImpl {
465
512
  * model necessarily catches up. Retain its complete row and causal
466
513
  * clock as a write fence until a query acknowledges it or a newer
467
514
  * record/tombstone supersedes it.
515
+ *
516
+ * Do not also take a membership fence: Atomic lists are @load, not
517
+ * @live. A membership fence would reject later complete snapshots
518
+ * until a live frame that never comes, stalling blob/new games and
519
+ * client-side navigations.
468
520
  */
469
521
  this.#projectedRecordFences.set(recordKey, Object.freeze({
470
522
  fields: Object.freeze({ ...fields }),
@@ -479,7 +531,7 @@ export class DistributedReplicaImpl {
479
531
  }
480
532
  }
481
533
  [replicaCommandProjectionDelta](commandId, update, semanticChanges) {
482
- return replaceReplicaOptimisticLayerOn(this.#optimisticHost(), commandId, update, semanticChanges);
534
+ return replaceReplicaOptimisticLayerOn(this.#optimisticHost(), commandId, (writer) => update(this.#capturingOptimisticWriter(commandId, writer)), semanticChanges);
483
535
  }
484
536
  read(artifact, variables) {
485
537
  this.#bindArtifact(artifact);
@@ -882,14 +934,15 @@ export class DistributedReplicaImpl {
882
934
  let summary;
883
935
  try {
884
936
  const update = (writer) => {
885
- this.#applyTombstoneEvidence(writer, recordEvidence.tombstones, operationState, pendingRecordClocks, pendingRecordScopes, pendingAnonymousRecordClocks, consumedAnonymousRecordClocks, consumedRecordPaths, pendingProjectedRecordFenceClears);
886
- const normalized = normalizeReplicaResult(writer, artifact, stableVariables, envelope, normalizationProtocol);
937
+ const guarded = this.#guardIndexWriter(writer);
938
+ this.#applyTombstoneEvidence(guarded, recordEvidence.tombstones, operationState, pendingRecordClocks, pendingRecordScopes, pendingAnonymousRecordClocks, consumedAnonymousRecordClocks, consumedRecordPaths, pendingProjectedRecordFenceClears);
939
+ const normalized = normalizeReplicaResult(guarded, artifact, stableVariables, envelope, normalizationProtocol);
887
940
  for (const path of recordEvidence.livePaths) {
888
941
  if (!consumedRecordPaths.has(path)) {
889
942
  protocolInvalid('extensions.distributed.snapshot.records.path');
890
943
  }
891
944
  }
892
- this.#applyPathlessEvidence(writer, recordEvidence.pathless, recordEvidence.byPath, consumedRecordPaths, pendingRecordClocks, pendingRecordScopes, pendingAnonymousRecordClocks, consumedAnonymousRecordClocks, pendingProjectedRecordFenceClears);
945
+ this.#applyPathlessEvidence(guarded, recordEvidence.pathless, recordEvidence.byPath, consumedRecordPaths, pendingRecordClocks, pendingRecordScopes, pendingAnonymousRecordClocks, consumedAnonymousRecordClocks, pendingProjectedRecordFenceClears);
893
946
  return normalized;
894
947
  };
895
948
  summary =
@@ -986,6 +1039,7 @@ export class DistributedReplicaImpl {
986
1039
  this.#protocolGeneration = nextProtocolGeneration;
987
1040
  this.#resumeLiveWatches();
988
1041
  this.#emitState(key, false);
1042
+ this.#flushDeferredMembershipConfirms();
989
1043
  if (this.#diagnostics !== undefined) {
990
1044
  const cache = this.#engine.extract();
991
1045
  this.#diagnosticEvent(Object.freeze({
@@ -1051,11 +1105,143 @@ export class DistributedReplicaImpl {
1051
1105
  return markOptimisticLayerAcceptedOn(this.#optimisticHost(), id, receipt);
1052
1106
  }
1053
1107
  confirmOptimisticLayer(id, update) {
1108
+ if (this.#commandHasMembershipFence(id)) {
1109
+ this.#deferredMembershipConfirms.add(id);
1110
+ return this.#engine.batch((writer) => update(baseWriter(writer)));
1111
+ }
1054
1112
  return confirmOptimisticLayerOn(this.#optimisticHost(), id, update);
1055
1113
  }
1056
1114
  rejectOptimisticLayer(id) {
1115
+ this.#clearMembershipFencesForCommand(id);
1116
+ this.#deferredMembershipConfirms.delete(id);
1057
1117
  return rejectOptimisticLayerOn(this.#optimisticHost(), id);
1058
1118
  }
1119
+ #capturingOptimisticWriter(commandId, writer) {
1120
+ const touchedRecords = new Set();
1121
+ return {
1122
+ writeRecord: (model, identity, patch) => {
1123
+ touchedRecords.add(replicaRecordKey(model, identity));
1124
+ writer.writeRecord(model, identity, patch);
1125
+ },
1126
+ tombstoneRecord: (model, identity) => {
1127
+ const recordKey = replicaRecordKey(model, identity);
1128
+ touchedRecords.delete(recordKey);
1129
+ this.#clearMembershipFenceOwner(commandId, recordKey);
1130
+ writer.tombstoneRecord(model, identity);
1131
+ },
1132
+ writeIndex: (target, records) => {
1133
+ const indexKey = indexKeyFromTarget(target);
1134
+ for (const recordKey of records) {
1135
+ if (!touchedRecords.has(recordKey))
1136
+ continue;
1137
+ let recordsForIndex = this.#membershipFences.get(indexKey);
1138
+ if (recordsForIndex === undefined) {
1139
+ recordsForIndex = new Map();
1140
+ this.#membershipFences.set(indexKey, recordsForIndex);
1141
+ }
1142
+ let owners = recordsForIndex.get(recordKey);
1143
+ if (owners === undefined) {
1144
+ owners = new Set();
1145
+ recordsForIndex.set(recordKey, owners);
1146
+ }
1147
+ owners.add(commandId);
1148
+ }
1149
+ writer.writeIndex(target, records);
1150
+ },
1151
+ deleteIndex: (target) => {
1152
+ const indexKey = indexKeyFromTarget(target);
1153
+ const recordsForIndex = this.#membershipFences.get(indexKey);
1154
+ if (recordsForIndex !== undefined) {
1155
+ for (const [recordKey, owners] of recordsForIndex) {
1156
+ owners.delete(commandId);
1157
+ if (owners.size === 0)
1158
+ recordsForIndex.delete(recordKey);
1159
+ }
1160
+ if (recordsForIndex.size === 0) {
1161
+ this.#membershipFences.delete(indexKey);
1162
+ }
1163
+ }
1164
+ writer.deleteIndex(target);
1165
+ }
1166
+ };
1167
+ }
1168
+ #commandHasMembershipFence(commandId) {
1169
+ for (const recordsForIndex of this.#membershipFences.values()) {
1170
+ for (const owners of recordsForIndex.values()) {
1171
+ if (owners.has(commandId))
1172
+ return true;
1173
+ }
1174
+ }
1175
+ return false;
1176
+ }
1177
+ #clearMembershipFencesForCommand(commandId) {
1178
+ for (const [indexKey, recordsForIndex] of this.#membershipFences) {
1179
+ for (const [recordKey, owners] of recordsForIndex) {
1180
+ owners.delete(commandId);
1181
+ if (owners.size === 0)
1182
+ recordsForIndex.delete(recordKey);
1183
+ }
1184
+ if (recordsForIndex.size === 0) {
1185
+ this.#membershipFences.delete(indexKey);
1186
+ }
1187
+ }
1188
+ }
1189
+ #clearMembershipFenceOwner(commandId, recordKey) {
1190
+ for (const [indexKey, recordsForIndex] of this.#membershipFences) {
1191
+ const owners = recordsForIndex.get(recordKey);
1192
+ if (owners === undefined)
1193
+ continue;
1194
+ owners.delete(commandId);
1195
+ if (owners.size === 0)
1196
+ recordsForIndex.delete(recordKey);
1197
+ if (recordsForIndex.size === 0) {
1198
+ this.#membershipFences.delete(indexKey);
1199
+ }
1200
+ }
1201
+ }
1202
+ #guardIndexWriter(writer) {
1203
+ return {
1204
+ recordClock: (key) => writer.recordClock(key),
1205
+ writeRecord: (write) => writer.writeRecord(write),
1206
+ tombstoneRecord: (key, revision, incarnation) => writer.tombstoneRecord(key, revision, incarnation),
1207
+ discardRecord: (key) => writer.discardRecord(key),
1208
+ writeIndex: (write) => {
1209
+ const recordsForIndex = this.#membershipFences.get(write.key);
1210
+ if (write.complete === true && recordsForIndex !== undefined) {
1211
+ const visible = this.#engine.read((reader) => reader.index(write.key)?.records) ?? [];
1212
+ for (const recordKey of recordsForIndex.keys()) {
1213
+ if (visible.includes(recordKey) &&
1214
+ !write.records.includes(recordKey)) {
1215
+ return false;
1216
+ }
1217
+ }
1218
+ }
1219
+ const wrote = writer.writeIndex(write);
1220
+ if (wrote && recordsForIndex !== undefined) {
1221
+ for (const recordKey of write.records) {
1222
+ recordsForIndex.delete(recordKey);
1223
+ }
1224
+ if (recordsForIndex.size === 0) {
1225
+ this.#membershipFences.delete(write.key);
1226
+ }
1227
+ }
1228
+ return wrote;
1229
+ },
1230
+ markIndexStale: (key, reason, revision) => writer.markIndexStale(key, reason, revision),
1231
+ deleteIndex: (key, revision) => writer.deleteIndex(key, revision)
1232
+ };
1233
+ }
1234
+ #flushDeferredMembershipConfirms() {
1235
+ for (const commandId of [...this.#deferredMembershipConfirms]) {
1236
+ if (this.#commandHasMembershipFence(commandId))
1237
+ continue;
1238
+ this.#deferredMembershipConfirms.delete(commandId);
1239
+ if (this.#engine.optimisticLayerState(commandId) === undefined) {
1240
+ continue;
1241
+ }
1242
+ confirmOptimisticLayerOn(this.#optimisticHost(), commandId, () => undefined);
1243
+ }
1244
+ }
1059
1245
  tombstoneRecord(model, identity, revision) {
1060
1246
  const wrote = this.#engine.batch((writer) => writer.tombstoneRecord(replicaRecordKey(model, identity), revision));
1061
1247
  if (wrote)
@@ -1268,6 +1454,24 @@ export class DistributedReplicaImpl {
1268
1454
  }
1269
1455
  return Object.freeze(mutations);
1270
1456
  }
1457
+ #directProjectionIndexMutations(commandId, model, recordKey, fields) {
1458
+ const change = Object.freeze({
1459
+ kind: 'upsert',
1460
+ model: model.id,
1461
+ key: recordKey,
1462
+ fields
1463
+ });
1464
+ const layer = Object.freeze({
1465
+ id: commandId,
1466
+ sequence: Number.MAX_SAFE_INTEGER,
1467
+ state: 'accepted',
1468
+ context: Object.freeze({
1469
+ id: commandId,
1470
+ changes: Object.freeze([change])
1471
+ })
1472
+ });
1473
+ return this.#deriveMaintainedIndexes(this.#engine.extract(), [layer]);
1474
+ }
1271
1475
  #operationProtocol(key, operation, source) {
1272
1476
  let group = this.#operationProtocols.get(key);
1273
1477
  if (group === undefined) {
@@ -2,6 +2,7 @@ export { createDistributedReplica } from './distributed-replica.js';
2
2
  export { createReplicaDevelopmentCapability, createReplicaDiagnostics, inspectReplicaCommandArtifact, inspectReplicaOperationArtifact } from './diagnostics.js';
3
3
  export type { ReplicaArtifactSourceLocation, ReplicaCommandArtifactInspection, ReplicaCommandEffectInspection, ReplicaDevelopmentCapability, ReplicaDiagnosticEvent, ReplicaDiagnosticEventInput, ReplicaDiagnosticFieldValueContext, ReplicaDiagnosticFieldValuePolicy, ReplicaDiagnosticIndex, ReplicaDiagnosticIndexInput, ReplicaDiagnosticLayer, ReplicaDiagnosticLayerInput, ReplicaDiagnosticReceipt, ReplicaDiagnosticReceiptExpectationInput, ReplicaDiagnosticReceiptInput, ReplicaDiagnosticRecord, ReplicaDiagnosticRecordInput, ReplicaDiagnosticReasonContext, ReplicaDiagnosticReasonPolicy, ReplicaDiagnostics, ReplicaDiagnosticsOptions, ReplicaDiagnosticsSink, ReplicaDiagnosticsSnapshot, ReplicaDiagnosticScopeInput, ReplicaDiagnosticStateInput, ReplicaOperationArtifactInspection, ReplicaOperationIndexInspection, ReplicaOperationInjectedFieldInspection } from './diagnostics.js';
4
4
  export { createReplicaGraphqlTransport } from './graphql-transport.js';
5
+ export { createReplicaUuidV7 } from './command-id.js';
5
6
  export type { ReplicaGraphqlTransport, ReplicaGraphqlTransportOptions } from './graphql-transport.js';
6
7
  export { canonicalizeOperationVariables, replicaIndexKey, replicaRecordKey } from './identity.js';
7
8
  export { prepareReplicaCommand, ReplicaCommandContractError, verifyReplicaCommandReceipt } from './commands.js';
@@ -1,6 +1,7 @@
1
1
  export { createDistributedReplica } from './distributed-replica.js';
2
2
  export { createReplicaDevelopmentCapability, createReplicaDiagnostics, inspectReplicaCommandArtifact, inspectReplicaOperationArtifact } from './diagnostics.js';
3
3
  export { createReplicaGraphqlTransport } from './graphql-transport.js';
4
+ export { createReplicaUuidV7 } from './command-id.js';
4
5
  export { canonicalizeOperationVariables, replicaIndexKey, replicaRecordKey } from './identity.js';
5
6
  export { prepareReplicaCommand, ReplicaCommandContractError, verifyReplicaCommandReceipt } from './commands.js';
6
7
  export { createReplicaCommandRuntime, ReplicaCommandRuntimeError } from './command-runtime.js';
@@ -8,7 +8,7 @@ export function prepareCommandProjection(contract, input, trustedPresets) {
8
8
  return Object.freeze({
9
9
  contract,
10
10
  preview: Object.freeze([...preview, ...pure]),
11
- revalidate: contract.preview.recoveries.length !== 0
11
+ revalidate: contract.preview.recoveries.some((recovery) => recovery.condition === 'always')
12
12
  });
13
13
  }
14
14
  catch {
@@ -53,6 +53,11 @@ export function defineDistributedSvelteKitOperation(artifact) {
53
53
  return useDistributedSvelteKitClient()
54
54
  .operation(artifact)
55
55
  .read(variables);
56
+ },
57
+ prefetch(variables) {
58
+ return useDistributedSvelteKitClient()
59
+ .operation(artifact)
60
+ .prefetch(variables);
56
61
  }
57
62
  });
58
63
  }
@@ -1,4 +1,4 @@
1
1
  export { authFromPageData, type PageGraphqlData } from './auth.js';
2
2
  export { defineDistributedSvelteKitOperation, provideDistributedSvelteKitClient, useDistributedSvelteKitClient, useDistributedSvelteKitCommands } from './context.js';
3
3
  export { bindSveltekitOperation, createPageDataSessionSource, createDistributedSvelteKit, sessionSourceFromPageData, type CreateDistributedSvelteKitOptions, type DistributedSvelteKitClient, type SveltekitBoundOperation, type SveltekitCommandRuntimeFactory, type SveltekitCommandRuntimeFactoryOptions, type SveltekitCommandRuntimeLike, type SveltekitDistributedPageData, type SveltekitPageDataSessionSource, type SveltekitPageDataSource, type SveltekitQuerySnapshot, type SveltekitQueryStore, type SveltekitReplicaAuthority, type SveltekitReplicaHydration, type SveltekitSessionSource, type UseSveltekitOperationOptions } from './replica.js';
4
- export { createDistributedSvelteKitServer, registerDistributedRoute, type CreateDistributedSvelteKitServerOptions, type DistributedRouteOperation, type DistributedRoutePlan, type DistributedRouteVariables, type DistributedSvelteKitServer, type SveltekitServerLoadEventLike } from './server-replica.js';
4
+ export { createDistributedSvelteKitServer, matchDistributedRoute, registerDistributedRoute, type CreateDistributedSvelteKitServerOptions, type DistributedRouteOperation, type DistributedRoutePlan, type DistributedRouteVariables, type DistributedSvelteKitServer, type SveltekitServerLoadEventLike } from './server-replica.js';
@@ -1,4 +1,4 @@
1
1
  export { authFromPageData } from './auth.js';
2
2
  export { defineDistributedSvelteKitOperation, provideDistributedSvelteKitClient, useDistributedSvelteKitClient, useDistributedSvelteKitCommands } from './context.js';
3
3
  export { bindSveltekitOperation, createPageDataSessionSource, createDistributedSvelteKit, sessionSourceFromPageData } from './replica.js';
4
- export { createDistributedSvelteKitServer, registerDistributedRoute } from './server-replica.js';
4
+ export { createDistributedSvelteKitServer, matchDistributedRoute, registerDistributedRoute } from './server-replica.js';
@@ -105,6 +105,8 @@ export type SveltekitBoundOperation<TData, TVariables extends GraphqlVariables>
105
105
  artifact: ReplicaOperationArtifact<TData, TVariables>;
106
106
  use(...args: UseOperationArguments<TVariables>): SveltekitQueryStore<TData>;
107
107
  read(variables: TVariables): ReplicaSnapshot<TData>;
108
+ /** Client-side hover/nav warmup; no-ops when the replica already has a complete snapshot. */
109
+ prefetch(variables: TVariables): Promise<void>;
108
110
  }>;
109
111
  export type DistributedSvelteKitClient<TCommands> = Readonly<{
110
112
  replica: DistributedReplica;
@@ -116,6 +118,7 @@ export type DistributedSvelteKitClient<TCommands> = Readonly<{
116
118
  * generation and returns false so the bound operation refetches.
117
119
  */
118
120
  hydrate(hydration: SveltekitReplicaHydration, authority: SveltekitReplicaAuthority): boolean;
121
+ prefetch(artifact: ReplicaOperationArtifact<unknown, GraphqlVariables>, variables: GraphqlVariables): Promise<void>;
119
122
  invalidateAuthorization(): void;
120
123
  destroy(): void;
121
124
  }>;
@@ -86,6 +86,9 @@ export function createDistributedSvelteKit(options) {
86
86
  commands,
87
87
  operation,
88
88
  hydrate,
89
+ prefetch(artifact, variables) {
90
+ return prefetchReplicaOperation(replica, artifact, variables);
91
+ },
89
92
  invalidateAuthorization() {
90
93
  if (!destroyed)
91
94
  replica.invalidateAuthorization();
@@ -171,9 +174,17 @@ function bindOperation(replica, artifact, pending, lifecycle) {
171
174
  return Object.freeze({
172
175
  artifact,
173
176
  use,
174
- read: (variables) => replica.read(artifact, variables)
177
+ read: (variables) => replica.read(artifact, variables),
178
+ prefetch: (variables) => prefetchReplicaOperation(replica, artifact, variables)
175
179
  });
176
180
  }
181
+ function prefetchReplicaOperation(replica, artifact, variables) {
182
+ const snapshot = replica.read(artifact, variables);
183
+ if (snapshot.complete && !snapshot.stale)
184
+ return Promise.resolve();
185
+ const watch = replica.watch(artifact, variables, { live: false });
186
+ return watch.refresh().finally(() => watch.destroy());
187
+ }
177
188
  class SveltekitQueryStoreImpl {
178
189
  #replica;
179
190
  #artifact;
@@ -20,6 +20,12 @@ export type SveltekitServerLoadEventLike<TLocals = unknown> = Readonly<{
20
20
  }>;
21
21
  url?: URL;
22
22
  fetch?: FetchLike;
23
+ /**
24
+ * SvelteKit client-side navigation and hover preload (`__data.json`).
25
+ * Document SSR is `false`/`undefined`; those navigations must not wait on
26
+ * a fresh GraphQL replica — the browser replica already owns the cache.
27
+ */
28
+ isDataRequest?: boolean;
23
29
  }>;
24
30
  export type DistributedRouteVariables<TEvent extends SveltekitServerLoadEventLike> = Readonly<Record<string, (event: TEvent) => GraphqlVariables | Promise<GraphqlVariables>>>;
25
31
  export type CreateDistributedSvelteKitServerOptions<TSession extends NonNullable<PageGraphqlData['session']>, TEvent extends SveltekitServerLoadEventLike = SveltekitServerLoadEventLike> = Readonly<{
@@ -50,3 +56,7 @@ export declare function createDistributedSvelteKitServer<TSession extends NonNul
50
56
  * equivalent `--route Operation=/route-id` registration.
51
57
  */
52
58
  export declare function registerDistributedRoute<TData, TVariables extends GraphqlVariables>(route: string, operation: string, artifact: ReplicaOperationArtifact<TData, TVariables>): DistributedRouteOperation;
59
+ /**
60
+ * Match a SvelteKit route id (`/blob/[[gameId]]`) to a browser pathname.
61
+ */
62
+ export declare function matchDistributedRoute(routeId: string, pathname: string): boolean;
@@ -17,6 +17,12 @@ export function createDistributedSvelteKitServer(options) {
17
17
  accessToken,
18
18
  engineRole
19
19
  };
20
+ if (event.isDataRequest === true) {
21
+ return {
22
+ ...pageData,
23
+ gqlError: null
24
+ };
25
+ }
20
26
  const auth = options.getAuth?.(pageData, event) ?? authFromPageData(pageData);
21
27
  const routeId = routeIdentity(event);
22
28
  const selected = routes.filter(({ plan }) => plan.route === routeId);
@@ -117,6 +123,68 @@ function hydrationTransfer(state, operations) {
117
123
  })
118
124
  });
119
125
  }
126
+ /**
127
+ * Match a SvelteKit route id (`/blob/[[gameId]]`) to a browser pathname.
128
+ */
129
+ export function matchDistributedRoute(routeId, pathname) {
130
+ const route = normalizeRoute(routeId);
131
+ const path = normalizePathname(pathname);
132
+ if (route === path)
133
+ return true;
134
+ const routeParts = route
135
+ .split('/')
136
+ .filter(Boolean)
137
+ .filter((part) => !(part.startsWith('(') && part.endsWith(')')));
138
+ const pathParts = path.split('/').filter(Boolean);
139
+ const failed = new Set();
140
+ const matches = (routeIndex, pathIndex) => {
141
+ const state = routeIndex + ':' + pathIndex;
142
+ if (failed.has(state))
143
+ return false;
144
+ if (routeIndex === routeParts.length) {
145
+ return pathIndex === pathParts.length;
146
+ }
147
+ const part = routeParts[routeIndex];
148
+ const optionalRest = part.startsWith('[[...') && part.endsWith(']]');
149
+ const rest = part.startsWith('[...') && part.endsWith(']');
150
+ if (optionalRest || rest) {
151
+ for (let next = pathIndex; next <= pathParts.length; next += 1) {
152
+ if (matches(routeIndex + 1, next))
153
+ return true;
154
+ }
155
+ failed.add(state);
156
+ return false;
157
+ }
158
+ const optional = part.startsWith('[[') && part.endsWith(']]');
159
+ if (optional) {
160
+ if (matches(routeIndex + 1, pathIndex))
161
+ return true;
162
+ if (pathIndex < pathParts.length &&
163
+ matches(routeIndex + 1, pathIndex + 1)) {
164
+ return true;
165
+ }
166
+ failed.add(state);
167
+ return false;
168
+ }
169
+ const parameter = part.startsWith('[') && part.endsWith(']');
170
+ if ((parameter && pathIndex < pathParts.length) ||
171
+ (!parameter &&
172
+ pathIndex < pathParts.length &&
173
+ pathParts[pathIndex] === part)) {
174
+ if (matches(routeIndex + 1, pathIndex + 1))
175
+ return true;
176
+ }
177
+ failed.add(state);
178
+ return false;
179
+ };
180
+ return matches(0, 0);
181
+ }
182
+ function normalizePathname(pathname) {
183
+ if (typeof pathname !== 'string' || pathname.length === 0)
184
+ return '/';
185
+ const trimmed = pathname.replace(/\/+$/, '');
186
+ return trimmed.length === 0 ? '/' : trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
187
+ }
120
188
  function validateRoutes(value) {
121
189
  if (!Array.isArray(value)) {
122
190
  throw new TypeError('createDistributedSvelteKitServer requires generated DISTRIBUTED_ROUTE_OPERATIONS');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hops-ops/distributed",
3
- "version": "4.1.1",
3
+ "version": "4.3.0",
4
4
  "description": "Typed GraphQL client, causal replica, command runtime, and framework adapters for Distributed services",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",