@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.
- package/dist/replica/command-id.d.ts +4 -2
- package/dist/replica/command-id.js +4 -2
- package/dist/replica/command-runtime/create.js +22 -4
- package/dist/replica/distributed-replica/impl-optimistic.d.ts +3 -1
- package/dist/replica/distributed-replica/impl-optimistic.js +5 -1
- package/dist/replica/distributed-replica/impl-protocol.d.ts +2 -0
- package/dist/replica/distributed-replica/impl-protocol.js +2 -0
- package/dist/replica/distributed-replica/impl.js +212 -8
- package/dist/replica/index.d.ts +1 -0
- package/dist/replica/index.js +1 -0
- package/dist/replica/projection-delta/resolve.js +1 -1
- package/dist/sveltekit/context.js +5 -0
- package/dist/sveltekit/index.d.ts +1 -1
- package/dist/sveltekit/index.js +1 -1
- package/dist/sveltekit/replica.d.ts +3 -0
- package/dist/sveltekit/replica.js +12 -1
- package/dist/sveltekit/server-replica.d.ts +10 -0
- package/dist/sveltekit/server-replica.js +68 -0
- package/package.json +1 -1
|
@@ -1,2 +1,4 @@
|
|
|
1
|
-
/** Create a browser/Node UUIDv7
|
|
2
|
-
export declare function
|
|
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
|
|
2
|
-
export function
|
|
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
|
|
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
|
|
303
|
+
revalidation: actual.revalidate
|
|
304
304
|
? actualProjectionRevalidation(prepared.revalidation, actual.delta)
|
|
305
305
|
: undefined,
|
|
306
|
-
requiresRevalidation: actual.revalidate
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.#
|
|
886
|
-
|
|
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(
|
|
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) {
|
package/dist/replica/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/replica/index.js
CHANGED
|
@@ -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.
|
|
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';
|
package/dist/sveltekit/index.js
CHANGED
|
@@ -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