statewire 0.17.1 → 0.18.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/client/connection.d.ts +2 -3
- package/dist/client/connection.d.ts.map +1 -1
- package/dist/client/connection.js +61 -25
- package/dist/client/connection.js.map +1 -1
- package/dist/client/session.d.ts +6 -2
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +189 -40
- package/dist/client/session.js.map +1 -1
- package/dist/client/storageError.d.ts +6 -0
- package/dist/client/storageError.d.ts.map +1 -0
- package/dist/client/storageError.js +12 -0
- package/dist/client/storageError.js.map +1 -0
- package/dist/statewire.d.ts +25 -11
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js.map +1 -1
- package/dist/storage.d.ts +33 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +371 -0
- package/dist/storage.js.map +1 -0
- package/dist/transports/sse.d.ts +2 -0
- package/dist/transports/sse.d.ts.map +1 -1
- package/dist/transports/sse.js +3 -0
- package/dist/transports/sse.js.map +1 -1
- package/package.json +5 -1
- package/src/client/connection.ts +57 -28
- package/src/client/session.ts +262 -72
- package/src/client/storageError.ts +9 -0
- package/src/statewire.ts +31 -21
- package/src/storage.ts +601 -0
- package/src/transports/sse.ts +5 -0
package/src/client/session.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { encodeDocument } from "../document";
|
|
2
|
+
import { wrapStorageError } from "./storageError";
|
|
2
3
|
import {
|
|
3
4
|
DEFAULT_PROTOCOL,
|
|
4
5
|
WIRE_VERSION,
|
|
@@ -58,7 +59,7 @@ type Pending = {
|
|
|
58
59
|
/** The server acknowledged admission; the terminal verdict is still due. */
|
|
59
60
|
applied: boolean;
|
|
60
61
|
resolve: (payload: unknown) => void;
|
|
61
|
-
reject: (error:
|
|
62
|
+
reject: (error: unknown) => void;
|
|
62
63
|
};
|
|
63
64
|
|
|
64
65
|
type CtxPending = {
|
|
@@ -100,6 +101,10 @@ type Session = {
|
|
|
100
101
|
record: StatewireClient.SessionRecord;
|
|
101
102
|
ctx: StatewireRemoteProps;
|
|
102
103
|
recordPersisted: boolean;
|
|
104
|
+
hydrated: boolean;
|
|
105
|
+
ready: Promise<void> | undefined;
|
|
106
|
+
storageError: unknown;
|
|
107
|
+
persistedRecord: StatewireClient.SessionRecord | null;
|
|
103
108
|
store: StatewireClient.SessionStorage;
|
|
104
109
|
documentStore: StatewireDocuments.Storage | undefined;
|
|
105
110
|
shared: Shared | false;
|
|
@@ -162,9 +167,23 @@ const sleepUntilWake = (s: Session, delayMs: number) =>
|
|
|
162
167
|
s.waiters.push(waiter);
|
|
163
168
|
});
|
|
164
169
|
|
|
170
|
+
const storageFailed = (s: Session, cause: unknown) => {
|
|
171
|
+
const error = wrapStorageError(cause);
|
|
172
|
+
s.storageError = error;
|
|
173
|
+
for (const entry of s.pending) entry.reject(error);
|
|
174
|
+
s.transport?.storageFailed(error);
|
|
175
|
+
};
|
|
176
|
+
|
|
165
177
|
const setRecord = (s: Session, record: StatewireClient.SessionRecord) => {
|
|
166
178
|
s.record = record;
|
|
167
|
-
if (!s.deactivated)
|
|
179
|
+
if (!s.deactivated) {
|
|
180
|
+
try {
|
|
181
|
+
s.store.save(record);
|
|
182
|
+
} catch (cause) {
|
|
183
|
+
storageFailed(s, cause);
|
|
184
|
+
throw s.storageError;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
168
187
|
};
|
|
169
188
|
|
|
170
189
|
const backlog = (s: Session): StatewireClient.SessionCommand[] =>
|
|
@@ -208,7 +227,7 @@ const syncBacklogInner = (s: Session) => {
|
|
|
208
227
|
|
|
209
228
|
const syncBacklog = (s: Session) => {
|
|
210
229
|
if (!s.shared) return syncBacklogInner(s);
|
|
211
|
-
|
|
230
|
+
transactBackground(s, () => syncBacklogInner(s));
|
|
212
231
|
};
|
|
213
232
|
|
|
214
233
|
// Every record mutation over shared storage is a locked read-modify-write.
|
|
@@ -241,13 +260,23 @@ const transact = (s: Session, fn: () => void): Promise<void> => {
|
|
|
241
260
|
}
|
|
242
261
|
}
|
|
243
262
|
fn();
|
|
263
|
+
const persistence = s.store.flush?.();
|
|
264
|
+
if (persistence) await persistence;
|
|
244
265
|
}),
|
|
245
266
|
);
|
|
246
|
-
|
|
267
|
+
const result = run.catch((cause: unknown) => {
|
|
268
|
+
storageFailed(s, cause);
|
|
269
|
+
throw s.storageError;
|
|
270
|
+
});
|
|
271
|
+
shared.txq = result.then(
|
|
247
272
|
() => {},
|
|
248
273
|
() => {},
|
|
249
274
|
);
|
|
250
|
-
return
|
|
275
|
+
return result;
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const transactBackground = (s: Session, fn: () => void, done?: () => void) => {
|
|
279
|
+
void transact(s, fn).then(done, () => {});
|
|
251
280
|
};
|
|
252
281
|
|
|
253
282
|
const setIdle = (s: Session, level: boolean) => {
|
|
@@ -305,7 +334,7 @@ const retainFailed = (
|
|
|
305
334
|
retainFailedInner(s, entry, failure);
|
|
306
335
|
return;
|
|
307
336
|
}
|
|
308
|
-
|
|
337
|
+
transactBackground(s, () => retainFailedInner(s, entry, failure));
|
|
309
338
|
};
|
|
310
339
|
|
|
311
340
|
const settleEntry = (
|
|
@@ -580,6 +609,32 @@ const publishIngested = (s: Session) => {
|
|
|
580
609
|
// unloaded): held seqs resend as-is and the frame's answer arbitrates —
|
|
581
610
|
// absorption where the record survived, a gap refusal where it did not.
|
|
582
611
|
const snapCtxClock = (s: Session, ctxWm: number) => {
|
|
612
|
+
if (s.record.context?.recover === "version-1") {
|
|
613
|
+
const document = s.contextDocuments.documents.get(0);
|
|
614
|
+
if (s.contextDocuments.documents.size !== 1 || !document?.main)
|
|
615
|
+
throw new Error(
|
|
616
|
+
"statewire: version-1 context recovery requires one main document at doc 0",
|
|
617
|
+
);
|
|
618
|
+
const encoded = encodeDocument(document.value);
|
|
619
|
+
const ops = [
|
|
620
|
+
{
|
|
621
|
+
...(ctxWm === 0
|
|
622
|
+
? { op: "mount", protocol: document.protocol, main: true }
|
|
623
|
+
: { op: "replace", path: [] }),
|
|
624
|
+
...encoded,
|
|
625
|
+
},
|
|
626
|
+
];
|
|
627
|
+
s.ctxPending = [{ seq: ctxWm + 1, ops, posted: false }];
|
|
628
|
+
setRecord(s, {
|
|
629
|
+
...s.record,
|
|
630
|
+
ctxNextSeq: ctxWm + 2,
|
|
631
|
+
context: {
|
|
632
|
+
ops: contextSnapshot(s.contextDocuments),
|
|
633
|
+
pending: s.ctxPending,
|
|
634
|
+
},
|
|
635
|
+
});
|
|
636
|
+
return;
|
|
637
|
+
}
|
|
583
638
|
const ctxAligned =
|
|
584
639
|
s.ctxPending.every((c, i) => c.seq === ctxWm + 1 + i) &&
|
|
585
640
|
s.record.ctxNextSeq === ctxWm + s.ctxPending.length + 1;
|
|
@@ -671,7 +726,7 @@ const handleHello = (
|
|
|
671
726
|
void pump(s);
|
|
672
727
|
return;
|
|
673
728
|
}
|
|
674
|
-
|
|
729
|
+
transactBackground(s, reconcile, () => {
|
|
675
730
|
publishIngested(s);
|
|
676
731
|
void pump(s);
|
|
677
732
|
});
|
|
@@ -718,13 +773,17 @@ const handleRefusal = (s: Session, syn: StatewireSyn) => {
|
|
|
718
773
|
firstCtx !== undefined &&
|
|
719
774
|
firstCtx.seq > ctxSeq + 1;
|
|
720
775
|
if (cmdGap || ctxGap) {
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
776
|
+
transactBackground(
|
|
777
|
+
s,
|
|
778
|
+
() => {
|
|
779
|
+
if (cmdGap) sessionLostInner(s, seq!, ctxSeq ?? 0);
|
|
780
|
+
else snapCtxClock(s, ctxSeq!);
|
|
781
|
+
},
|
|
782
|
+
() => {
|
|
783
|
+
wake(s);
|
|
784
|
+
void pump(s);
|
|
785
|
+
},
|
|
786
|
+
);
|
|
728
787
|
return;
|
|
729
788
|
}
|
|
730
789
|
wake(s);
|
|
@@ -755,10 +814,16 @@ type Lanes = {
|
|
|
755
814
|
|
|
756
815
|
// A lane must be strictly consecutive within a frame: the first unposted run
|
|
757
816
|
// per lane rides now, later runs ride follow-up frames.
|
|
758
|
-
const buildLanes = (s: Session): Lanes | null => {
|
|
817
|
+
const buildLanes = (s: Session, persisted = false): Lanes | null => {
|
|
759
818
|
const ctxEntries: CtxPending[] = [];
|
|
760
819
|
for (const entry of s.ctxPending) {
|
|
761
820
|
if (entry.posted) continue;
|
|
821
|
+
if (
|
|
822
|
+
persisted &&
|
|
823
|
+
(s.persistedRecord?.clientId !== s.record.clientId ||
|
|
824
|
+
entry.seq >= s.persistedRecord.ctxNextSeq)
|
|
825
|
+
)
|
|
826
|
+
break;
|
|
762
827
|
if (ctxEntries.length >= MAX_COMMANDS_PER_FRAME) break;
|
|
763
828
|
if (ctxEntries.length > 0 && entry.seq !== ctxEntries.at(-1)!.seq + 1)
|
|
764
829
|
break;
|
|
@@ -767,6 +832,12 @@ const buildLanes = (s: Session): Lanes | null => {
|
|
|
767
832
|
const cmdEntries: Pending[] = [];
|
|
768
833
|
for (const entry of s.pending) {
|
|
769
834
|
if (entry.posted) continue;
|
|
835
|
+
if (
|
|
836
|
+
persisted &&
|
|
837
|
+
(s.persistedRecord?.clientId !== s.record.clientId ||
|
|
838
|
+
entry.seq >= s.persistedRecord.nextSeq)
|
|
839
|
+
)
|
|
840
|
+
break;
|
|
770
841
|
if (cmdEntries.length >= MAX_COMMANDS_PER_FRAME) break;
|
|
771
842
|
if (cmdEntries.length > 0 && entry.seq !== cmdEntries.at(-1)!.seq + 1)
|
|
772
843
|
break;
|
|
@@ -794,8 +865,9 @@ const takeAttachLanes = (s: Session): StatewireFrame | null => {
|
|
|
794
865
|
s.attachLaneSeqs = [new Set(), new Set()];
|
|
795
866
|
const wanted = s.lanesOnAttach;
|
|
796
867
|
s.lanesOnAttach = false;
|
|
797
|
-
if (!wanted || !s.sessionKnown
|
|
798
|
-
|
|
868
|
+
if (!wanted || !s.sessionKnown || s.record.context?.recover !== undefined)
|
|
869
|
+
return null;
|
|
870
|
+
const lanes = buildLanes(s, true);
|
|
799
871
|
if (!lanes) return null;
|
|
800
872
|
s.attachLaneSeqs = [
|
|
801
873
|
new Set(lanes.cmdEntries.map((p) => p.seq)),
|
|
@@ -805,16 +877,38 @@ const takeAttachLanes = (s: Session): StatewireFrame | null => {
|
|
|
805
877
|
return lanes.frame;
|
|
806
878
|
};
|
|
807
879
|
|
|
880
|
+
const persistBeforeSend = (s: Session): void | Promise<void> => {
|
|
881
|
+
if (s.storageError !== undefined) throw s.storageError;
|
|
882
|
+
if (!s.hydrated) return s.ready!.then(() => persistBeforeSend(s));
|
|
883
|
+
const record = s.record;
|
|
884
|
+
try {
|
|
885
|
+
const pending = s.store.flush?.();
|
|
886
|
+
if (pending)
|
|
887
|
+
return pending.then(
|
|
888
|
+
() => {
|
|
889
|
+
s.persistedRecord = record;
|
|
890
|
+
},
|
|
891
|
+
(cause: unknown) => {
|
|
892
|
+
throw wrapStorageError(cause);
|
|
893
|
+
},
|
|
894
|
+
);
|
|
895
|
+
s.persistedRecord = record;
|
|
896
|
+
} catch (cause) {
|
|
897
|
+
throw wrapStorageError(cause);
|
|
898
|
+
}
|
|
899
|
+
};
|
|
900
|
+
|
|
808
901
|
const pump = async (s: Session) => {
|
|
809
902
|
if (s.pumping) return;
|
|
810
903
|
s.pumping = true;
|
|
811
904
|
try {
|
|
812
905
|
while (s.running && !isFinished(s)) {
|
|
906
|
+
if (!s.hydrated) await s.ready;
|
|
813
907
|
const transport = s.transport;
|
|
814
908
|
if (buildLanes(s) === null) return;
|
|
815
909
|
if (s.attach !== "attached" || !transport) {
|
|
816
910
|
if (
|
|
817
|
-
(transport?.connection.status === "
|
|
911
|
+
(transport?.connection.status === "standby" &&
|
|
818
912
|
transport.connection.reason === "idle") ||
|
|
819
913
|
(transport?.parked && s.pending.length > 0)
|
|
820
914
|
) {
|
|
@@ -831,6 +925,10 @@ const pump = async (s: Session) => {
|
|
|
831
925
|
const lanes = buildLanes(s);
|
|
832
926
|
if (!lanes) continue;
|
|
833
927
|
const epoch = s.attachEpoch;
|
|
928
|
+
const persistence = persistBeforeSend(s);
|
|
929
|
+
if (persistence) await persistence;
|
|
930
|
+
if (!s.running || isFinished(s)) return;
|
|
931
|
+
if (epoch !== s.attachEpoch || s.attach !== "attached") continue;
|
|
834
932
|
for (const entry of lanes.cmdEntries) entry.attempted = true;
|
|
835
933
|
let result;
|
|
836
934
|
try {
|
|
@@ -877,23 +975,39 @@ const pump = async (s: Session) => {
|
|
|
877
975
|
}
|
|
878
976
|
}
|
|
879
977
|
}
|
|
978
|
+
} catch (error) {
|
|
979
|
+
storageFailed(s, error);
|
|
880
980
|
} finally {
|
|
881
981
|
s.pumping = false;
|
|
882
982
|
}
|
|
883
983
|
};
|
|
884
984
|
|
|
885
985
|
const start = (s: Session) => {
|
|
986
|
+
s.running = true;
|
|
987
|
+
if (!s.hydrated) {
|
|
988
|
+
void s.ready!.then(
|
|
989
|
+
() => {
|
|
990
|
+
if (s.running) start(s);
|
|
991
|
+
},
|
|
992
|
+
() => {},
|
|
993
|
+
);
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
886
996
|
if (s.shared) {
|
|
887
997
|
s.shared.unsubscribe = s.store.subscribe!(() => {
|
|
888
|
-
|
|
998
|
+
transactBackground(s, () => consumeVerdicts(s));
|
|
889
999
|
});
|
|
890
1000
|
s.running = true;
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
1001
|
+
transactBackground(
|
|
1002
|
+
s,
|
|
1003
|
+
() => {
|
|
1004
|
+
if (s.store.load() === null) s.store.save(s.record);
|
|
1005
|
+
s.recordPersisted = true;
|
|
1006
|
+
adoptStored(s);
|
|
1007
|
+
consumeVerdicts(s);
|
|
1008
|
+
},
|
|
1009
|
+
() => void pump(s),
|
|
1010
|
+
);
|
|
897
1011
|
return;
|
|
898
1012
|
}
|
|
899
1013
|
if (!s.recordPersisted) {
|
|
@@ -926,7 +1040,7 @@ const enqueueInner = (
|
|
|
926
1040
|
command: WireCommand,
|
|
927
1041
|
params: readonly unknown[],
|
|
928
1042
|
resolve: (payload: unknown) => void,
|
|
929
|
-
reject: (error:
|
|
1043
|
+
reject: (error: unknown) => void,
|
|
930
1044
|
) => {
|
|
931
1045
|
const seq = s.record.nextSeq;
|
|
932
1046
|
const entry: Pending = {
|
|
@@ -956,7 +1070,7 @@ const enqueue = (
|
|
|
956
1070
|
command: WireCommand,
|
|
957
1071
|
params: readonly unknown[],
|
|
958
1072
|
resolve: (payload: unknown) => void,
|
|
959
|
-
reject: (error:
|
|
1073
|
+
reject: (error: unknown) => void,
|
|
960
1074
|
) => {
|
|
961
1075
|
if (!s.shared) {
|
|
962
1076
|
enqueueInner(s, command, params, resolve, reject);
|
|
@@ -977,7 +1091,7 @@ const enqueue = (
|
|
|
977
1091
|
}).then(() => {
|
|
978
1092
|
wake(s);
|
|
979
1093
|
void pump(s);
|
|
980
|
-
});
|
|
1094
|
+
}, reject);
|
|
981
1095
|
};
|
|
982
1096
|
|
|
983
1097
|
// A stored command may have reached a server: attempted settles it lost,
|
|
@@ -1026,6 +1140,18 @@ const sendCommand = (
|
|
|
1026
1140
|
params: readonly unknown[],
|
|
1027
1141
|
protocol = s.options.protocol ?? DEFAULT_PROTOCOL,
|
|
1028
1142
|
): Promise<unknown> => {
|
|
1143
|
+
if (!s.hydrated) {
|
|
1144
|
+
const result = s.ready!.then(() =>
|
|
1145
|
+
sendCommand(s, method, params, protocol),
|
|
1146
|
+
);
|
|
1147
|
+
result.catch(() => {});
|
|
1148
|
+
return result;
|
|
1149
|
+
}
|
|
1150
|
+
if (s.storageError !== undefined) {
|
|
1151
|
+
const result = Promise.reject(s.storageError);
|
|
1152
|
+
result.catch(() => {});
|
|
1153
|
+
return result;
|
|
1154
|
+
}
|
|
1029
1155
|
if (!s.running) {
|
|
1030
1156
|
return rejectedSend({ fate: "aborted", cause: "unmount" });
|
|
1031
1157
|
}
|
|
@@ -1091,7 +1217,7 @@ export const discardFailed = (
|
|
|
1091
1217
|
return;
|
|
1092
1218
|
}
|
|
1093
1219
|
if (findFailed(s, entry) === undefined) throw unknownFailed(entry);
|
|
1094
|
-
|
|
1220
|
+
transactBackground(s, () => {
|
|
1095
1221
|
if (findFailed(s, entry) !== undefined) takeFailed(s, entry);
|
|
1096
1222
|
});
|
|
1097
1223
|
};
|
|
@@ -1180,6 +1306,9 @@ const persistContext = (s: Session) => {
|
|
|
1180
1306
|
...s.record,
|
|
1181
1307
|
context: {
|
|
1182
1308
|
ops,
|
|
1309
|
+
...(s.record.context?.recover === undefined
|
|
1310
|
+
? {}
|
|
1311
|
+
: { recover: s.record.context.recover }),
|
|
1183
1312
|
pending: s.ctxPending.map(({ seq, ops }) => ({ seq, ops })),
|
|
1184
1313
|
},
|
|
1185
1314
|
});
|
|
@@ -1189,6 +1318,16 @@ const changeContext = (
|
|
|
1189
1318
|
s: Session,
|
|
1190
1319
|
build: (documents: StatewireDocuments.State) => readonly unknown[],
|
|
1191
1320
|
): void => {
|
|
1321
|
+
if (!s.hydrated) {
|
|
1322
|
+
void s
|
|
1323
|
+
.ready!.then(() => changeContext(s, build))
|
|
1324
|
+
.catch((cause: unknown) => {
|
|
1325
|
+
const error = wrapStorageError(cause);
|
|
1326
|
+
s.storageError = error;
|
|
1327
|
+
s.transport?.storageFailed(error);
|
|
1328
|
+
});
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1192
1331
|
if (!s.running || isFinished(s)) return;
|
|
1193
1332
|
const submit = () => {
|
|
1194
1333
|
if (!s.running || isFinished(s)) return;
|
|
@@ -1233,7 +1372,7 @@ const changeContext = (
|
|
|
1233
1372
|
wake(s);
|
|
1234
1373
|
void pump(s);
|
|
1235
1374
|
} else
|
|
1236
|
-
|
|
1375
|
+
transactBackground(s, submit, () => {
|
|
1237
1376
|
wake(s);
|
|
1238
1377
|
void pump(s);
|
|
1239
1378
|
});
|
|
@@ -1328,6 +1467,52 @@ const storedCommandCompatible = (
|
|
|
1328
1467
|
);
|
|
1329
1468
|
};
|
|
1330
1469
|
|
|
1470
|
+
const loadSessionRecord = (
|
|
1471
|
+
store: StatewireClient.SessionStorage,
|
|
1472
|
+
options: StatewireSessionOptions,
|
|
1473
|
+
) => {
|
|
1474
|
+
let stored = store.load();
|
|
1475
|
+
if (stored !== null && !Array.isArray(stored.commands))
|
|
1476
|
+
throw new Error(
|
|
1477
|
+
"statewire: session storage loaded a record without a commands array",
|
|
1478
|
+
);
|
|
1479
|
+
const current = {
|
|
1480
|
+
wire: options.wireVersion ?? { version: WIRE_VERSION },
|
|
1481
|
+
protocols: protocolOffers(options),
|
|
1482
|
+
};
|
|
1483
|
+
if (
|
|
1484
|
+
stored !== null &&
|
|
1485
|
+
!stored.commands.every((command) =>
|
|
1486
|
+
storedCommandCompatible(command, current),
|
|
1487
|
+
)
|
|
1488
|
+
) {
|
|
1489
|
+
if (!store.migrate)
|
|
1490
|
+
throw new Error("statewire: stored commands require a storage migration");
|
|
1491
|
+
stored = store.migrate(stored, current);
|
|
1492
|
+
if (
|
|
1493
|
+
stored !== null &&
|
|
1494
|
+
!stored.commands.every((command) =>
|
|
1495
|
+
storedCommandCompatible(command, current),
|
|
1496
|
+
)
|
|
1497
|
+
)
|
|
1498
|
+
throw new Error("statewire: migrated commands are incompatible");
|
|
1499
|
+
if (stored !== null) store.save(stored);
|
|
1500
|
+
}
|
|
1501
|
+
if (stored !== null && !Array.isArray(stored.commands)) {
|
|
1502
|
+
throw new Error(
|
|
1503
|
+
"statewire: session storage loaded a record without a commands array",
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1506
|
+
const record = stored ?? {
|
|
1507
|
+
clientId: newClientId(),
|
|
1508
|
+
nextSeq: 1,
|
|
1509
|
+
ctxNextSeq: 1,
|
|
1510
|
+
commands: [],
|
|
1511
|
+
};
|
|
1512
|
+
if (stored === null) store.save(record);
|
|
1513
|
+
return { record, stored };
|
|
1514
|
+
};
|
|
1515
|
+
|
|
1331
1516
|
export const useStatewireSession = (
|
|
1332
1517
|
options: StatewireSessionOptions,
|
|
1333
1518
|
): Session => {
|
|
@@ -1346,47 +1531,18 @@ export const useStatewireSession = (
|
|
|
1346
1531
|
);
|
|
1347
1532
|
}
|
|
1348
1533
|
const [s] = useState((): Session => {
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
storedCommandCompatible(command, current),
|
|
1362
|
-
)
|
|
1363
|
-
) {
|
|
1364
|
-
if (!store.migrate)
|
|
1365
|
-
throw new Error(
|
|
1366
|
-
"statewire: stored commands require a storage migration",
|
|
1367
|
-
);
|
|
1368
|
-
stored = store.migrate(stored, current);
|
|
1369
|
-
if (
|
|
1370
|
-
stored !== null &&
|
|
1371
|
-
!stored.commands.every((command) =>
|
|
1372
|
-
storedCommandCompatible(command, current),
|
|
1373
|
-
)
|
|
1374
|
-
)
|
|
1375
|
-
throw new Error("statewire: migrated commands are incompatible");
|
|
1376
|
-
if (stored !== null) store.save(stored);
|
|
1377
|
-
}
|
|
1378
|
-
if (stored !== null && !Array.isArray(stored.commands)) {
|
|
1379
|
-
throw new Error(
|
|
1380
|
-
"statewire: session storage loaded a record without a commands array",
|
|
1381
|
-
);
|
|
1382
|
-
}
|
|
1383
|
-
const record = stored ?? {
|
|
1384
|
-
clientId: newClientId(),
|
|
1385
|
-
nextSeq: 1,
|
|
1386
|
-
ctxNextSeq: 1,
|
|
1387
|
-
commands: [],
|
|
1388
|
-
};
|
|
1389
|
-
if (stored === null) store.save(record);
|
|
1534
|
+
const { record, stored } =
|
|
1535
|
+
store.ready === undefined
|
|
1536
|
+
? loadSessionRecord(store, options)
|
|
1537
|
+
: {
|
|
1538
|
+
record: {
|
|
1539
|
+
clientId: newClientId(),
|
|
1540
|
+
nextSeq: 1,
|
|
1541
|
+
ctxNextSeq: 1,
|
|
1542
|
+
commands: [],
|
|
1543
|
+
},
|
|
1544
|
+
stored: null,
|
|
1545
|
+
};
|
|
1390
1546
|
const session: Session = {
|
|
1391
1547
|
options,
|
|
1392
1548
|
selection: undefined,
|
|
@@ -1399,6 +1555,7 @@ export const useStatewireSession = (
|
|
|
1399
1555
|
),
|
|
1400
1556
|
record,
|
|
1401
1557
|
ctx: {
|
|
1558
|
+
beforeAttach: () => persistBeforeSend(session),
|
|
1402
1559
|
get clientId() {
|
|
1403
1560
|
return session.record.clientId;
|
|
1404
1561
|
},
|
|
@@ -1411,7 +1568,11 @@ export const useStatewireSession = (
|
|
|
1411
1568
|
onDetach: () => handleDetach(session),
|
|
1412
1569
|
onFinish: (args) => handleFinish(session, args),
|
|
1413
1570
|
},
|
|
1414
|
-
recordPersisted:
|
|
1571
|
+
recordPersisted: store.ready === undefined,
|
|
1572
|
+
hydrated: store.ready === undefined,
|
|
1573
|
+
ready: undefined,
|
|
1574
|
+
storageError: undefined,
|
|
1575
|
+
persistedRecord: store.ready === undefined ? record : null,
|
|
1415
1576
|
store,
|
|
1416
1577
|
documentStore,
|
|
1417
1578
|
shared:
|
|
@@ -1461,6 +1622,35 @@ export const useStatewireSession = (
|
|
|
1461
1622
|
sendCommand(session, method, params),
|
|
1462
1623
|
),
|
|
1463
1624
|
};
|
|
1625
|
+
if (store.ready !== undefined) {
|
|
1626
|
+
session.ready = store.ready.then(() => {
|
|
1627
|
+
const { record } = loadSessionRecord(store, session.options);
|
|
1628
|
+
session.record = record;
|
|
1629
|
+
session.contextDocuments =
|
|
1630
|
+
record.context === undefined
|
|
1631
|
+
? { mode: "mounted", documents: new Map() }
|
|
1632
|
+
: applyDocumentOps(
|
|
1633
|
+
{ mode: "mounted", documents: new Map() },
|
|
1634
|
+
record.context.ops,
|
|
1635
|
+
);
|
|
1636
|
+
session.ctxOps = record.context?.ops;
|
|
1637
|
+
session.ctxPending =
|
|
1638
|
+
record.context?.pending.map((entry) => ({
|
|
1639
|
+
...entry,
|
|
1640
|
+
posted: false,
|
|
1641
|
+
})) ?? [];
|
|
1642
|
+
session.recordPersisted = true;
|
|
1643
|
+
session.hydrated = true;
|
|
1644
|
+
if (session.running) session.notify();
|
|
1645
|
+
});
|
|
1646
|
+
session.ready = session.ready.catch((cause: unknown) => {
|
|
1647
|
+
const error = wrapStorageError(cause);
|
|
1648
|
+
session.storageError = error;
|
|
1649
|
+
session.transport?.storageFailed(error);
|
|
1650
|
+
throw error;
|
|
1651
|
+
});
|
|
1652
|
+
session.ready.catch(() => {});
|
|
1653
|
+
}
|
|
1464
1654
|
bindCommandsHost(session.commands, {
|
|
1465
1655
|
setListener: (listener) => {
|
|
1466
1656
|
session.lifecycle = listener;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export const isStorageError = (error: unknown): error is Error =>
|
|
2
|
+
error instanceof Error && error.name === "StatewireStorageError";
|
|
3
|
+
|
|
4
|
+
export const wrapStorageError = (cause: unknown): Error => {
|
|
5
|
+
if (isStorageError(cause)) return cause;
|
|
6
|
+
const error = new Error("statewire: session storage failed", { cause });
|
|
7
|
+
error.name = "StatewireStorageError";
|
|
8
|
+
return error;
|
|
9
|
+
};
|
package/src/statewire.ts
CHANGED
|
@@ -303,36 +303,37 @@ export namespace StatewireClient {
|
|
|
303
303
|
discardFailed: (entry: FailedCommand) => void;
|
|
304
304
|
};
|
|
305
305
|
|
|
306
|
-
/**
|
|
306
|
+
/** Connection lifecycle and whether its current state warrants a problem indicator. */
|
|
307
307
|
export type ConnectionState =
|
|
308
|
+
| { status: "standby"; reason: "deferred" | "idle"; degraded: false }
|
|
309
|
+
| { status: "connecting"; degraded: false }
|
|
310
|
+
| { status: "connected"; degraded: false }
|
|
308
311
|
| {
|
|
309
|
-
status: "
|
|
310
|
-
|
|
311
|
-
| "deferred"
|
|
312
|
-
| "idle"
|
|
313
|
-
| "gone"
|
|
314
|
-
| "forbidden"
|
|
315
|
-
| "protocol-error"
|
|
316
|
-
| "unauthorized";
|
|
317
|
-
message?: string;
|
|
318
|
-
payload?: unknown;
|
|
319
|
-
}
|
|
320
|
-
| {
|
|
321
|
-
status: "connecting";
|
|
322
|
-
/** 0 = the episode's first dial, >0 = retry. */
|
|
312
|
+
status: "retrying";
|
|
313
|
+
degraded: true;
|
|
323
314
|
attempt: number;
|
|
315
|
+
/** Backoff details are absent while the retry dial is in flight. */
|
|
324
316
|
cause?: "dropped" | "reconnect" | "error" | "unauthorized";
|
|
325
317
|
code?: string;
|
|
326
318
|
nextRetryAt?: number;
|
|
327
319
|
lastError?: string;
|
|
328
320
|
message?: string;
|
|
329
321
|
}
|
|
330
|
-
| {
|
|
322
|
+
| {
|
|
323
|
+
status: "stopped";
|
|
324
|
+
degraded: true;
|
|
325
|
+
reason:
|
|
326
|
+
| "gone"
|
|
327
|
+
| "forbidden"
|
|
328
|
+
| "protocol-error"
|
|
329
|
+
| "unauthorized"
|
|
330
|
+
| "storage-error";
|
|
331
|
+
message?: string;
|
|
332
|
+
payload?: unknown;
|
|
333
|
+
};
|
|
331
334
|
export type Connection = ConnectionState & {
|
|
332
335
|
error?: ConnectionError;
|
|
333
336
|
reconnect: () => void;
|
|
334
|
-
/** True while retrying (`connecting` with `attempt > 0`) or parked `gone`. */
|
|
335
|
-
degraded: boolean;
|
|
336
337
|
};
|
|
337
338
|
export type ConnectionStatus = Connection["status"];
|
|
338
339
|
|
|
@@ -436,7 +437,12 @@ export namespace StatewireClient {
|
|
|
436
437
|
clientId: string;
|
|
437
438
|
nextSeq: number;
|
|
438
439
|
ctxNextSeq: number;
|
|
439
|
-
context?: {
|
|
440
|
+
context?: {
|
|
441
|
+
ops: unknown[];
|
|
442
|
+
pending: { seq: number; ops: unknown[] }[];
|
|
443
|
+
/** Recover the original wire's unpersisted single-document context after the next hello. */
|
|
444
|
+
recover?: "version-1";
|
|
445
|
+
};
|
|
440
446
|
commands: readonly SessionCommand[];
|
|
441
447
|
/** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */
|
|
442
448
|
verdicts?: readonly SessionVerdict[];
|
|
@@ -454,8 +460,12 @@ export namespace StatewireClient {
|
|
|
454
460
|
meta?: unknown;
|
|
455
461
|
};
|
|
456
462
|
|
|
457
|
-
/** Session storage seam
|
|
463
|
+
/** Session storage seam with optional asynchronous hydration and persistence. */
|
|
458
464
|
export type SessionStorage = {
|
|
465
|
+
/** Resolves when a synchronous load can return the hydrated record. */
|
|
466
|
+
ready?: Promise<void>;
|
|
467
|
+
/** Resolves after all queued saves are persisted; awaited before sending statements. */
|
|
468
|
+
flush?: () => void | Promise<void>;
|
|
459
469
|
load: () => SessionRecord | null;
|
|
460
470
|
save: (record: SessionRecord) => void;
|
|
461
471
|
/** Upgrade incompatible stored commands; null discards the old record and starts a new client identity. */
|
|
@@ -470,7 +480,7 @@ export namespace StatewireClient {
|
|
|
470
480
|
};
|
|
471
481
|
|
|
472
482
|
/** Static headers, or a resolver invoked per request/attach; `staleAuth` is true right after an auth rejection. */
|
|
473
|
-
export type ConnectionError = Error & { fin
|
|
483
|
+
export type ConnectionError = Error & { fin?: StatewireHost.Fin };
|
|
474
484
|
|
|
475
485
|
export type HeadersOption =
|
|
476
486
|
| HeadersInit
|