statewire 0.17.2 → 0.18.1
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 +158 -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 +23 -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 +232 -72
- package/src/client/storageError.ts +9 -0
- package/src/statewire.ts +25 -20
- 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 = (
|
|
@@ -697,7 +726,7 @@ const handleHello = (
|
|
|
697
726
|
void pump(s);
|
|
698
727
|
return;
|
|
699
728
|
}
|
|
700
|
-
|
|
729
|
+
transactBackground(s, reconcile, () => {
|
|
701
730
|
publishIngested(s);
|
|
702
731
|
void pump(s);
|
|
703
732
|
});
|
|
@@ -744,13 +773,17 @@ const handleRefusal = (s: Session, syn: StatewireSyn) => {
|
|
|
744
773
|
firstCtx !== undefined &&
|
|
745
774
|
firstCtx.seq > ctxSeq + 1;
|
|
746
775
|
if (cmdGap || ctxGap) {
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
+
);
|
|
754
787
|
return;
|
|
755
788
|
}
|
|
756
789
|
wake(s);
|
|
@@ -781,10 +814,16 @@ type Lanes = {
|
|
|
781
814
|
|
|
782
815
|
// A lane must be strictly consecutive within a frame: the first unposted run
|
|
783
816
|
// per lane rides now, later runs ride follow-up frames.
|
|
784
|
-
const buildLanes = (s: Session): Lanes | null => {
|
|
817
|
+
const buildLanes = (s: Session, persisted = false): Lanes | null => {
|
|
785
818
|
const ctxEntries: CtxPending[] = [];
|
|
786
819
|
for (const entry of s.ctxPending) {
|
|
787
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;
|
|
788
827
|
if (ctxEntries.length >= MAX_COMMANDS_PER_FRAME) break;
|
|
789
828
|
if (ctxEntries.length > 0 && entry.seq !== ctxEntries.at(-1)!.seq + 1)
|
|
790
829
|
break;
|
|
@@ -793,6 +832,12 @@ const buildLanes = (s: Session): Lanes | null => {
|
|
|
793
832
|
const cmdEntries: Pending[] = [];
|
|
794
833
|
for (const entry of s.pending) {
|
|
795
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;
|
|
796
841
|
if (cmdEntries.length >= MAX_COMMANDS_PER_FRAME) break;
|
|
797
842
|
if (cmdEntries.length > 0 && entry.seq !== cmdEntries.at(-1)!.seq + 1)
|
|
798
843
|
break;
|
|
@@ -822,7 +867,7 @@ const takeAttachLanes = (s: Session): StatewireFrame | null => {
|
|
|
822
867
|
s.lanesOnAttach = false;
|
|
823
868
|
if (!wanted || !s.sessionKnown || s.record.context?.recover !== undefined)
|
|
824
869
|
return null;
|
|
825
|
-
const lanes = buildLanes(s);
|
|
870
|
+
const lanes = buildLanes(s, true);
|
|
826
871
|
if (!lanes) return null;
|
|
827
872
|
s.attachLaneSeqs = [
|
|
828
873
|
new Set(lanes.cmdEntries.map((p) => p.seq)),
|
|
@@ -832,16 +877,38 @@ const takeAttachLanes = (s: Session): StatewireFrame | null => {
|
|
|
832
877
|
return lanes.frame;
|
|
833
878
|
};
|
|
834
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
|
+
|
|
835
901
|
const pump = async (s: Session) => {
|
|
836
902
|
if (s.pumping) return;
|
|
837
903
|
s.pumping = true;
|
|
838
904
|
try {
|
|
839
905
|
while (s.running && !isFinished(s)) {
|
|
906
|
+
if (!s.hydrated) await s.ready;
|
|
840
907
|
const transport = s.transport;
|
|
841
908
|
if (buildLanes(s) === null) return;
|
|
842
909
|
if (s.attach !== "attached" || !transport) {
|
|
843
910
|
if (
|
|
844
|
-
(transport?.connection.status === "
|
|
911
|
+
(transport?.connection.status === "standby" &&
|
|
845
912
|
transport.connection.reason === "idle") ||
|
|
846
913
|
(transport?.parked && s.pending.length > 0)
|
|
847
914
|
) {
|
|
@@ -858,6 +925,10 @@ const pump = async (s: Session) => {
|
|
|
858
925
|
const lanes = buildLanes(s);
|
|
859
926
|
if (!lanes) continue;
|
|
860
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;
|
|
861
932
|
for (const entry of lanes.cmdEntries) entry.attempted = true;
|
|
862
933
|
let result;
|
|
863
934
|
try {
|
|
@@ -904,23 +975,39 @@ const pump = async (s: Session) => {
|
|
|
904
975
|
}
|
|
905
976
|
}
|
|
906
977
|
}
|
|
978
|
+
} catch (error) {
|
|
979
|
+
storageFailed(s, error);
|
|
907
980
|
} finally {
|
|
908
981
|
s.pumping = false;
|
|
909
982
|
}
|
|
910
983
|
};
|
|
911
984
|
|
|
912
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
|
+
}
|
|
913
996
|
if (s.shared) {
|
|
914
997
|
s.shared.unsubscribe = s.store.subscribe!(() => {
|
|
915
|
-
|
|
998
|
+
transactBackground(s, () => consumeVerdicts(s));
|
|
916
999
|
});
|
|
917
1000
|
s.running = true;
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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
|
+
);
|
|
924
1011
|
return;
|
|
925
1012
|
}
|
|
926
1013
|
if (!s.recordPersisted) {
|
|
@@ -953,9 +1040,9 @@ const enqueueInner = (
|
|
|
953
1040
|
command: WireCommand,
|
|
954
1041
|
params: readonly unknown[],
|
|
955
1042
|
resolve: (payload: unknown) => void,
|
|
956
|
-
reject: (error:
|
|
1043
|
+
reject: (error: unknown) => void,
|
|
957
1044
|
) => {
|
|
958
|
-
const seq = s.record.nextSeq;
|
|
1045
|
+
const seq = Math.max(s.record.nextSeq, s.durableSeq + 1);
|
|
959
1046
|
const entry: Pending = {
|
|
960
1047
|
seq,
|
|
961
1048
|
command,
|
|
@@ -983,7 +1070,7 @@ const enqueue = (
|
|
|
983
1070
|
command: WireCommand,
|
|
984
1071
|
params: readonly unknown[],
|
|
985
1072
|
resolve: (payload: unknown) => void,
|
|
986
|
-
reject: (error:
|
|
1073
|
+
reject: (error: unknown) => void,
|
|
987
1074
|
) => {
|
|
988
1075
|
if (!s.shared) {
|
|
989
1076
|
enqueueInner(s, command, params, resolve, reject);
|
|
@@ -1004,7 +1091,7 @@ const enqueue = (
|
|
|
1004
1091
|
}).then(() => {
|
|
1005
1092
|
wake(s);
|
|
1006
1093
|
void pump(s);
|
|
1007
|
-
});
|
|
1094
|
+
}, reject);
|
|
1008
1095
|
};
|
|
1009
1096
|
|
|
1010
1097
|
// A stored command may have reached a server: attempted settles it lost,
|
|
@@ -1053,6 +1140,18 @@ const sendCommand = (
|
|
|
1053
1140
|
params: readonly unknown[],
|
|
1054
1141
|
protocol = s.options.protocol ?? DEFAULT_PROTOCOL,
|
|
1055
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
|
+
}
|
|
1056
1155
|
if (!s.running) {
|
|
1057
1156
|
return rejectedSend({ fate: "aborted", cause: "unmount" });
|
|
1058
1157
|
}
|
|
@@ -1118,7 +1217,7 @@ export const discardFailed = (
|
|
|
1118
1217
|
return;
|
|
1119
1218
|
}
|
|
1120
1219
|
if (findFailed(s, entry) === undefined) throw unknownFailed(entry);
|
|
1121
|
-
|
|
1220
|
+
transactBackground(s, () => {
|
|
1122
1221
|
if (findFailed(s, entry) !== undefined) takeFailed(s, entry);
|
|
1123
1222
|
});
|
|
1124
1223
|
};
|
|
@@ -1219,6 +1318,16 @@ const changeContext = (
|
|
|
1219
1318
|
s: Session,
|
|
1220
1319
|
build: (documents: StatewireDocuments.State) => readonly unknown[],
|
|
1221
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
|
+
}
|
|
1222
1331
|
if (!s.running || isFinished(s)) return;
|
|
1223
1332
|
const submit = () => {
|
|
1224
1333
|
if (!s.running || isFinished(s)) return;
|
|
@@ -1263,7 +1372,7 @@ const changeContext = (
|
|
|
1263
1372
|
wake(s);
|
|
1264
1373
|
void pump(s);
|
|
1265
1374
|
} else
|
|
1266
|
-
|
|
1375
|
+
transactBackground(s, submit, () => {
|
|
1267
1376
|
wake(s);
|
|
1268
1377
|
void pump(s);
|
|
1269
1378
|
});
|
|
@@ -1358,6 +1467,52 @@ const storedCommandCompatible = (
|
|
|
1358
1467
|
);
|
|
1359
1468
|
};
|
|
1360
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
|
+
|
|
1361
1516
|
export const useStatewireSession = (
|
|
1362
1517
|
options: StatewireSessionOptions,
|
|
1363
1518
|
): Session => {
|
|
@@ -1376,47 +1531,18 @@ export const useStatewireSession = (
|
|
|
1376
1531
|
);
|
|
1377
1532
|
}
|
|
1378
1533
|
const [s] = useState((): Session => {
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
storedCommandCompatible(command, current),
|
|
1392
|
-
)
|
|
1393
|
-
) {
|
|
1394
|
-
if (!store.migrate)
|
|
1395
|
-
throw new Error(
|
|
1396
|
-
"statewire: stored commands require a storage migration",
|
|
1397
|
-
);
|
|
1398
|
-
stored = store.migrate(stored, current);
|
|
1399
|
-
if (
|
|
1400
|
-
stored !== null &&
|
|
1401
|
-
!stored.commands.every((command) =>
|
|
1402
|
-
storedCommandCompatible(command, current),
|
|
1403
|
-
)
|
|
1404
|
-
)
|
|
1405
|
-
throw new Error("statewire: migrated commands are incompatible");
|
|
1406
|
-
if (stored !== null) store.save(stored);
|
|
1407
|
-
}
|
|
1408
|
-
if (stored !== null && !Array.isArray(stored.commands)) {
|
|
1409
|
-
throw new Error(
|
|
1410
|
-
"statewire: session storage loaded a record without a commands array",
|
|
1411
|
-
);
|
|
1412
|
-
}
|
|
1413
|
-
const record = stored ?? {
|
|
1414
|
-
clientId: newClientId(),
|
|
1415
|
-
nextSeq: 1,
|
|
1416
|
-
ctxNextSeq: 1,
|
|
1417
|
-
commands: [],
|
|
1418
|
-
};
|
|
1419
|
-
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
|
+
};
|
|
1420
1546
|
const session: Session = {
|
|
1421
1547
|
options,
|
|
1422
1548
|
selection: undefined,
|
|
@@ -1429,6 +1555,7 @@ export const useStatewireSession = (
|
|
|
1429
1555
|
),
|
|
1430
1556
|
record,
|
|
1431
1557
|
ctx: {
|
|
1558
|
+
beforeAttach: () => persistBeforeSend(session),
|
|
1432
1559
|
get clientId() {
|
|
1433
1560
|
return session.record.clientId;
|
|
1434
1561
|
},
|
|
@@ -1441,7 +1568,11 @@ export const useStatewireSession = (
|
|
|
1441
1568
|
onDetach: () => handleDetach(session),
|
|
1442
1569
|
onFinish: (args) => handleFinish(session, args),
|
|
1443
1570
|
},
|
|
1444
|
-
recordPersisted:
|
|
1571
|
+
recordPersisted: store.ready === undefined,
|
|
1572
|
+
hydrated: store.ready === undefined,
|
|
1573
|
+
ready: undefined,
|
|
1574
|
+
storageError: undefined,
|
|
1575
|
+
persistedRecord: store.ready === undefined ? record : null,
|
|
1445
1576
|
store,
|
|
1446
1577
|
documentStore,
|
|
1447
1578
|
shared:
|
|
@@ -1491,6 +1622,35 @@ export const useStatewireSession = (
|
|
|
1491
1622
|
sendCommand(session, method, params),
|
|
1492
1623
|
),
|
|
1493
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
|
+
}
|
|
1494
1654
|
bindCommandsHost(session.commands, {
|
|
1495
1655
|
setListener: (listener) => {
|
|
1496
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
|
|
|
@@ -459,8 +460,12 @@ export namespace StatewireClient {
|
|
|
459
460
|
meta?: unknown;
|
|
460
461
|
};
|
|
461
462
|
|
|
462
|
-
/** Session storage seam
|
|
463
|
+
/** Session storage seam with optional asynchronous hydration and persistence. */
|
|
463
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>;
|
|
464
469
|
load: () => SessionRecord | null;
|
|
465
470
|
save: (record: SessionRecord) => void;
|
|
466
471
|
/** Upgrade incompatible stored commands; null discards the old record and starts a new client identity. */
|
|
@@ -475,7 +480,7 @@ export namespace StatewireClient {
|
|
|
475
480
|
};
|
|
476
481
|
|
|
477
482
|
/** Static headers, or a resolver invoked per request/attach; `staleAuth` is true right after an auth rejection. */
|
|
478
|
-
export type ConnectionError = Error & { fin
|
|
483
|
+
export type ConnectionError = Error & { fin?: StatewireHost.Fin };
|
|
479
484
|
|
|
480
485
|
export type HeadersOption =
|
|
481
486
|
| HeadersInit
|