@cplieger/web-terminal-ui 7.0.1 → 7.0.3
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/package.json +2 -2
- package/src/features/tabs/index.ts +24 -9
- package/src/kernel/kernel.ts +9 -5
- package/src/kernel/loading-status.ts +28 -10
- package/src/kernel/scrollback-storage.ts +10 -6
- package/src/kernel/scrollback.ts +105 -44
- package/src/kernel/types.ts +22 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/web-terminal-ui",
|
|
3
|
-
"version": "7.0.
|
|
3
|
+
"version": "7.0.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Reference touch-first browser UI for @cplieger/web-terminal-engine: a composable kernel + feature modules (tabs, activity monitor, mobile toolbar, context menu, IME, predictive echo, viewport handling) and CSS. One createTerminal() call.",
|
|
6
6
|
"license": "MPL-2.0",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"@vitest/coverage-v8": "4.1.11",
|
|
56
56
|
"eslint": "10.8.1",
|
|
57
57
|
"fast-check": "4.9.0",
|
|
58
|
-
"happy-dom": "20.11.
|
|
58
|
+
"happy-dom": "20.11.6",
|
|
59
59
|
"html-validate": "11.8.0",
|
|
60
60
|
"knip": "6.32.2",
|
|
61
61
|
"prettier": "3.9.6",
|
|
@@ -2873,13 +2873,20 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2873
2873
|
// still be), to the terminal otherwise. Recorded at entry because the exit
|
|
2874
2874
|
// paths cannot tell them apart.
|
|
2875
2875
|
let editFrom: "keyboard" | "pointer" = "pointer";
|
|
2876
|
-
// Tab ids with
|
|
2877
|
-
// record carrying a DIFFERENT value is applied for display
|
|
2878
|
-
// supersede the request: SSE delivery order and REST mutation
|
|
2879
|
-
// one total order, so a record sampled BEFORE our PUT can
|
|
2880
|
-
// and treating it as newer authority would suppress the
|
|
2881
|
-
// rollback and its failure toast.
|
|
2882
|
-
|
|
2876
|
+
// Tab ids with pinned-name requests in flight, COUNTED per id. While a count
|
|
2877
|
+
// is held, a status record carrying a DIFFERENT value is applied for display
|
|
2878
|
+
// but does not supersede the request: SSE delivery order and REST mutation
|
|
2879
|
+
// order are not one total order, so a record sampled BEFORE our PUT can
|
|
2880
|
+
// arrive after it, and treating it as newer authority would suppress the
|
|
2881
|
+
// request's own rollback and its failure toast.
|
|
2882
|
+
// Counted rather than a Set of ids, because two renames of one tab can be out
|
|
2883
|
+
// at once and the invariant is "no request of OURS is in flight", not "no id
|
|
2884
|
+
// is marked": a bare Set let the first request's completion clear the marker
|
|
2885
|
+
// while the second's PUT was still open, and the second rename then failed
|
|
2886
|
+
// silently. A per-request token would carry more information, but the only
|
|
2887
|
+
// reader asks whether ANY request is out for this id, and the failure handler
|
|
2888
|
+
// already distinguishes its own supersession by (born, nameSeq).
|
|
2889
|
+
const namesInFlight = new Map<string, number>();
|
|
2883
2890
|
|
|
2884
2891
|
/** restoreFocusAfterEdit sends focus where the entry path implies. The
|
|
2885
2892
|
* terminal branch is gated exactly like switchTo's focus-on-switch: on a
|
|
@@ -2949,8 +2956,11 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2949
2956
|
const { text, fallback } = baseLabel(t);
|
|
2950
2957
|
ctx.announce(fallback ? "Custom name removed" : `Using automatic name: ${text}`);
|
|
2951
2958
|
}
|
|
2952
|
-
namesInFlight.add(id);
|
|
2953
2959
|
const request = name === "" ? api.clearPinnedTitle(id) : api.setPinnedTitle(id, name);
|
|
2960
|
+
// Counted only once the request exists: an api implementation that throws
|
|
2961
|
+
// instead of rejecting would otherwise leave a count behind that no
|
|
2962
|
+
// .finally ever releases, and the id would read as in flight forever.
|
|
2963
|
+
namesInFlight.set(id, (namesInFlight.get(id) ?? 0) + 1);
|
|
2954
2964
|
void request
|
|
2955
2965
|
.catch(() => {
|
|
2956
2966
|
const cur = tabList.find((x) => x.id === id);
|
|
@@ -2962,7 +2972,12 @@ export function tabs(opts: TabsOptions = {}): TerminalFeature<TabsApi> {
|
|
|
2962
2972
|
ctx.toast("Couldn't save the terminal name");
|
|
2963
2973
|
})
|
|
2964
2974
|
.finally(() => {
|
|
2965
|
-
namesInFlight.
|
|
2975
|
+
const left = (namesInFlight.get(id) ?? 0) - 1;
|
|
2976
|
+
if (left > 0) {
|
|
2977
|
+
namesInFlight.set(id, left);
|
|
2978
|
+
} else {
|
|
2979
|
+
namesInFlight.delete(id);
|
|
2980
|
+
}
|
|
2966
2981
|
});
|
|
2967
2982
|
return true;
|
|
2968
2983
|
}
|
package/src/kernel/kernel.ts
CHANGED
|
@@ -274,17 +274,21 @@ function buildTerminal(
|
|
|
274
274
|
// Narrow = compact in EITHER dimension: skinny (a phone in portrait, a
|
|
275
275
|
// narrow embedded panel) or short (a phone in landscape). The one
|
|
276
276
|
// ResizeObserver below fires on both width and height changes.
|
|
277
|
+
//
|
|
278
|
+
// Constructed unconditionally: ResizeObserver is a hard requirement of this
|
|
279
|
+
// library, not an enhancement. viewport.init() and the tabs strip both
|
|
280
|
+
// construct one with no guard, and the same buildTerminal builds a WeakRef
|
|
281
|
+
// (whose browser floor is strictly higher), so a `typeof ResizeObserver ===
|
|
282
|
+
// "function"` guard here could only ever protect a browser that crashes
|
|
283
|
+
// moments later. It used to be here, and it was measured dead.
|
|
277
284
|
const isNarrow = (): boolean =>
|
|
278
285
|
root.clientWidth <= NARROW_MAX_PX || root.clientHeight <= SHORT_MAX_PX;
|
|
279
286
|
function updateNarrow(): void {
|
|
280
287
|
root.classList.toggle("wt-narrow", isNarrow());
|
|
281
288
|
}
|
|
282
289
|
updateNarrow();
|
|
283
|
-
let narrowObserver: ResizeObserver | null =
|
|
284
|
-
|
|
285
|
-
narrowObserver = new ResizeObserver(updateNarrow);
|
|
286
|
-
narrowObserver.observe(root);
|
|
287
|
-
}
|
|
290
|
+
let narrowObserver: ResizeObserver | null = new ResizeObserver(updateNarrow);
|
|
291
|
+
narrowObserver.observe(root);
|
|
288
292
|
|
|
289
293
|
// --- Document title (kernel-owned, composed from two inputs) ---
|
|
290
294
|
//
|
|
@@ -135,10 +135,10 @@ export function attachLoadingStatus(
|
|
|
135
135
|
// Swap the visible text through a fade so a change reads as deliberate rather
|
|
136
136
|
// than as a glitch. The first write skips the fade (there is nothing to fade
|
|
137
137
|
// out) so the line does not arrive half-transparent.
|
|
138
|
+
//
|
|
139
|
+
// No `stopped` check, here or in the deferred half below, and that is
|
|
140
|
+
// deliberate rather than an omission: see stop(), which owns the invariant.
|
|
138
141
|
const show = (text: string): void => {
|
|
139
|
-
if (stopped) {
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
142
|
// First write has nothing to fade out, so it must not arrive half-transparent.
|
|
143
143
|
if (visible.textContent === "") {
|
|
144
144
|
visible.textContent = text;
|
|
@@ -146,18 +146,13 @@ export function attachLoadingStatus(
|
|
|
146
146
|
}
|
|
147
147
|
visible.classList.add("wt-loading-text-out");
|
|
148
148
|
later(() => {
|
|
149
|
-
if (stopped) {
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
149
|
visible.textContent = text;
|
|
153
150
|
visible.classList.remove("wt-loading-text-out");
|
|
154
151
|
}, SWAP_FADE_MS);
|
|
155
152
|
};
|
|
156
153
|
|
|
157
154
|
const announce = (text: string): void => {
|
|
158
|
-
|
|
159
|
-
live.textContent = text;
|
|
160
|
-
}
|
|
155
|
+
live.textContent = text;
|
|
161
156
|
};
|
|
162
157
|
|
|
163
158
|
later(() => {
|
|
@@ -170,7 +165,13 @@ export function attachLoadingStatus(
|
|
|
170
165
|
|
|
171
166
|
later(() => {
|
|
172
167
|
const waiting = messages.waiting;
|
|
173
|
-
|
|
168
|
+
// `pinned` only. Not `stopped`: this callback is held by `timers`, so stop()
|
|
169
|
+
// has cancelled it before it could read the flag. That disjunct was here and
|
|
170
|
+
// was as dead as the ones deleted above — and invisible to the mutation
|
|
171
|
+
// report, because a mutator works on binary-expression nodes and never
|
|
172
|
+
// isolates a bare identifier operand, so `pinned || stopped` is mutated as a
|
|
173
|
+
// pair and the pair is killable through `pinned`.
|
|
174
|
+
if (pinned || waiting.length === 0) {
|
|
174
175
|
return;
|
|
175
176
|
}
|
|
176
177
|
// showAt reads through an undefined check rather than an index assertion:
|
|
@@ -219,6 +220,23 @@ export function attachLoadingStatus(
|
|
|
219
220
|
announce(text);
|
|
220
221
|
},
|
|
221
222
|
stop(): void {
|
|
223
|
+
// THE OWNER of "nothing further happens", and the only one.
|
|
224
|
+
//
|
|
225
|
+
// It discharges that by cancelling every callback this controller armed:
|
|
226
|
+
// `later()` is the sole way to schedule here, everything it schedules is in
|
|
227
|
+
// `timers`, and the rotation interval is the one handle held separately.
|
|
228
|
+
// So the only call that can arrive after this line is one from OUTSIDE a
|
|
229
|
+
// cancellable callback — which is `reason()`, and `reason()` is where
|
|
230
|
+
// `stopped` is read.
|
|
231
|
+
//
|
|
232
|
+
// `show()` and `announce()` used to re-test it as well, and the mutation
|
|
233
|
+
// pass measured what that was worth: five permanently unkillable mutants,
|
|
234
|
+
// because no input could reach either function with `stopped` true. They
|
|
235
|
+
// were not even protecting anything if they had — the two nodes are
|
|
236
|
+
// detached three lines below, so a late write goes nowhere. The invariant
|
|
237
|
+
// is pinned by a test instead ("stop() leaves no timer armed"), which,
|
|
238
|
+
// unlike a guard, fails for the change that would actually break it: a
|
|
239
|
+
// future timer armed outside `later()`.
|
|
222
240
|
if (stopped) {
|
|
223
241
|
return;
|
|
224
242
|
}
|
|
@@ -28,7 +28,6 @@
|
|
|
28
28
|
// caps rather than only writing.
|
|
29
29
|
|
|
30
30
|
import type { ScrollbackPersistence } from "./types.js";
|
|
31
|
-
import type { StoreSnapshot } from "@cplieger/web-terminal-engine";
|
|
32
31
|
import { DEFAULT_MAX_AGE_MS } from "./scrollback.js";
|
|
33
32
|
import { positiveIntOption } from "./options.js";
|
|
34
33
|
|
|
@@ -259,11 +258,16 @@ export function localScrollbackStorage(
|
|
|
259
258
|
return null;
|
|
260
259
|
}
|
|
261
260
|
try {
|
|
262
|
-
// The snapshot is returned
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
|
|
261
|
+
// The snapshot is returned unparsed on purpose: the kernel parses every
|
|
262
|
+
// field against the engine's snapshot contract, and duplicating that here
|
|
263
|
+
// would give two places to disagree about what a usable entry is. Typed
|
|
264
|
+
// `unknown` rather than asserted `as StoreSnapshot`, which is what this
|
|
265
|
+
// line used to do — and that cast was laundering `JSON.parse`'s `any` past
|
|
266
|
+
// no-unsafe-assignment while telling the compiler the value was verified,
|
|
267
|
+
// leaving the keeper to verify it anyway. That is how the keeper's
|
|
268
|
+
// boundary came to check the same thing twice.
|
|
269
|
+
const snapshot: unknown = JSON.parse(raw.slice(raw.indexOf("\n") + 1));
|
|
270
|
+
return { savedAt, snapshot };
|
|
267
271
|
} catch {
|
|
268
272
|
remove(keyFor(sessionId));
|
|
269
273
|
return null;
|
package/src/kernel/scrollback.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { LineStore, connection } from "@cplieger/web-terminal-engine";
|
|
20
20
|
import { positiveIntOption } from "./options.js";
|
|
21
|
-
import type {
|
|
21
|
+
import type { ScrollbackPersistence } from "./types.js";
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Newest lines persisted per session when the consumer names no bound.
|
|
@@ -69,33 +69,72 @@ export interface ScrollbackKeeper {
|
|
|
69
69
|
stop(): void;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
/**
|
|
73
|
-
*
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
72
|
+
/** A stored entry that has been read and proven: every field is what it says, so
|
|
73
|
+
* nothing downstream re-checks it. `readStoredEntry` is the only constructor,
|
|
74
|
+
* which is what makes that promise keepable. */
|
|
75
|
+
interface StoredEntry {
|
|
76
|
+
/** `Date.now()` at the write. Finite. */
|
|
77
|
+
readonly savedAt: number;
|
|
78
|
+
/** The snapshot's server epoch, or null when the entry names no server
|
|
79
|
+
* process.
|
|
80
|
+
*
|
|
81
|
+
* Stricter than "a finite number", because this value is adopted as a
|
|
82
|
+
* session's IDENTITY: the server reports a process-start timestamp in
|
|
83
|
+
* nanoseconds, so a negative or fractional value cannot have come from one
|
|
84
|
+
* and is a corrupt or hand-edited entry wearing the shape of a real one.
|
|
85
|
+
*
|
|
86
|
+
* NOT 0-for-unknown. The engine spells "unknown" as 0 on its own surface and
|
|
87
|
+
* that is its contract, but inheriting the spelling here put the rejection
|
|
88
|
+
* value inside the range of accepted values, so `epoch > 0` and `epoch >= 0`
|
|
89
|
+
* could not differ: at `epoch === 0` both yielded 0 and both were rejected
|
|
90
|
+
* downstream. Out-of-band restores the distinction, and 0 goes back to being
|
|
91
|
+
* a value the boundary refuses rather than the way it says no. */
|
|
92
|
+
readonly serverEpoch: number | null;
|
|
93
|
+
/** Still the engine's to parse. `LineStore.fromSnapshot` takes `unknown` and
|
|
94
|
+
* is the authority on a snapshot's contents (engine store.ts:1584), so a
|
|
95
|
+
* shape check here would be a second owner of one obligation. */
|
|
96
|
+
readonly snapshot: unknown;
|
|
80
97
|
}
|
|
81
98
|
|
|
82
|
-
/**
|
|
99
|
+
/**
|
|
100
|
+
* Read a stored entry, or null when it cannot be used.
|
|
83
101
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
102
|
+
* `unknown` in, because the value has been outside this program's memory and
|
|
103
|
+
* `ScrollbackPersistence.load`'s declared return type is an assumption about a
|
|
104
|
+
* consumer's storage rather than a fact about its contents — the library's own
|
|
105
|
+
* implementation produces one by `JSON.parse`. A `StoredEntry` out, so every
|
|
106
|
+
* later line reads fields instead of re-establishing them.
|
|
107
|
+
*
|
|
108
|
+
* The parameter type carries the caller's proof: `hydrate` must separate "no
|
|
109
|
+
* entry" from "bad entry" before calling, because those two have different
|
|
110
|
+
* handling, so non-nullishness is already established. That is why there is no
|
|
111
|
+
* object test here and why there must not be one — a property read on any other
|
|
112
|
+
* primitive yields undefined and the field checks below reject it, so a `typeof
|
|
113
|
+
* entry !== "object"` test would be a second statement of a thing the signature
|
|
114
|
+
* already guarantees. This function had two of them, one per field it read, and
|
|
115
|
+
* neither could be observed: 15 of the file's 19 surviving mutants were those
|
|
116
|
+
* two guards and their bodies.
|
|
117
|
+
*/
|
|
118
|
+
function readStoredEntry(entry: NonNullable<unknown>): StoredEntry | null {
|
|
119
|
+
const rec = entry as Record<string, unknown>;
|
|
120
|
+
const savedAt = rec["savedAt"];
|
|
121
|
+
// Finite, not merely a number: the age comparison below is a subtraction, and
|
|
122
|
+
// `Math.abs(Date.now() - NaN) > maxAgeMs` is false, so a NaN timestamp would
|
|
123
|
+
// read as freshly written forever.
|
|
124
|
+
if (typeof savedAt !== "number" || !Number.isFinite(savedAt)) {
|
|
125
|
+
return null;
|
|
96
126
|
}
|
|
97
|
-
|
|
98
|
-
|
|
127
|
+
// Read through a nullable cast rather than a typeof test: `?.` is safe on a
|
|
128
|
+
// primitive as well as on null, so one operator covers every shape a stored
|
|
129
|
+
// value can have, and `fromSnapshot` owns what a usable snapshot contains.
|
|
130
|
+
const snapshot = rec["snapshot"] as Record<string, unknown> | null | undefined;
|
|
131
|
+
const epoch = snapshot?.["serverEpoch"];
|
|
132
|
+
return {
|
|
133
|
+
savedAt,
|
|
134
|
+
serverEpoch:
|
|
135
|
+
typeof epoch === "number" && Number.isSafeInteger(epoch) && epoch > 0 ? epoch : null,
|
|
136
|
+
snapshot,
|
|
137
|
+
};
|
|
99
138
|
}
|
|
100
139
|
|
|
101
140
|
export function createScrollbackKeeper(
|
|
@@ -143,22 +182,41 @@ export function createScrollbackKeeper(
|
|
|
143
182
|
* Every rejection below has that same correct handling, which is why none of
|
|
144
183
|
* them throws or reports: the worst outcome of starting empty is one slow
|
|
145
184
|
* restore, and the worst outcome of hydrating something unsound is a terminal
|
|
146
|
-
* that shows content from a session that no longer exists.
|
|
185
|
+
* that shows content from a session that no longer exists.
|
|
186
|
+
*
|
|
187
|
+
* This function owns exactly two decisions and delegates the rest, which is
|
|
188
|
+
* the point of its shape. It decides whether there is an entry at all, and
|
|
189
|
+
* whether an entry that parsed may be USED (age, and verifiability). What an
|
|
190
|
+
* entry's envelope must look like is `readStoredEntry`'s; what a snapshot must
|
|
191
|
+
* contain is `LineStore.fromSnapshot`'s. Three owners, three depths, no
|
|
192
|
+
* overlap — the arrangement this module did not have when its two readers each
|
|
193
|
+
* re-established that the entry was an object. */
|
|
147
194
|
function hydrate(sessionId: string): LineStore | null {
|
|
148
|
-
let
|
|
195
|
+
let raw: unknown;
|
|
149
196
|
try {
|
|
150
|
-
|
|
197
|
+
raw = cfg.load(sessionId);
|
|
151
198
|
} catch {
|
|
199
|
+
// Deliberately empty: a seam that threw has told us there is no entry, and
|
|
200
|
+
// `raw` is still undefined, which is exactly what the check below concludes.
|
|
201
|
+
// A `return null` here would be a second owner of "there is no entry".
|
|
202
|
+
}
|
|
203
|
+
// "No entry" is silent and is not a rejection: nothing is dropped, because
|
|
204
|
+
// there is nothing to drop. Every path below this line has an entry to
|
|
205
|
+
// account for. This is also what lets `readStoredEntry` take a non-nullish
|
|
206
|
+
// parameter and therefore need no object test.
|
|
207
|
+
if (raw === null || raw === undefined) {
|
|
152
208
|
return null;
|
|
153
209
|
}
|
|
154
|
-
|
|
210
|
+
|
|
211
|
+
const entry = readStoredEntry(raw);
|
|
212
|
+
if (entry === null) {
|
|
213
|
+
drop(sessionId);
|
|
155
214
|
return null;
|
|
156
215
|
}
|
|
157
216
|
|
|
158
|
-
const savedAt = savedAtOf(entry);
|
|
159
217
|
// Distance in either direction: a clock that moved backwards would otherwise
|
|
160
218
|
// leave an entry whose age never reaches the bound.
|
|
161
|
-
if (
|
|
219
|
+
if (Math.abs(Date.now() - entry.savedAt) > maxAgeMs) {
|
|
162
220
|
drop(sessionId);
|
|
163
221
|
return null;
|
|
164
222
|
}
|
|
@@ -170,19 +228,22 @@ export function createScrollbackKeeper(
|
|
|
170
228
|
}
|
|
171
229
|
|
|
172
230
|
// Structure checked; now the one rejection that is a real decision rather
|
|
173
|
-
// than a validation
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
// detection was never available
|
|
184
|
-
|
|
185
|
-
|
|
231
|
+
// than a validation, and the reason the epoch is read at the boundary but
|
|
232
|
+
// JUDGED here. An entry with no server epoch cannot be verified against the
|
|
233
|
+
// server it is about to be shown next to, and absolute line indices only
|
|
234
|
+
// mean anything within one server process: a restarted server begins again
|
|
235
|
+
// near 0, so a hydrated store whose epoch cannot be compared would present
|
|
236
|
+
// old content as live AND then refuse the new session's output, because
|
|
237
|
+
// those low indices fall below what the hydrated store believes it evicted.
|
|
238
|
+
// A terminal that fills in slowly is a far better failure than one that is
|
|
239
|
+
// wrong and then permanently blank, so an unverifiable entry is discarded.
|
|
240
|
+
// In practice this never fires: an epoch is missing only from a server that
|
|
241
|
+
// reports none at all, in which case restart detection was never available
|
|
242
|
+
// to anyone.
|
|
243
|
+
//
|
|
244
|
+
// AFTER fromSnapshot, deliberately: reading it earlier would fire the warning
|
|
245
|
+
// below for a snapshot the engine was going to reject anyway.
|
|
246
|
+
if (entry.serverEpoch === null) {
|
|
186
247
|
if (!warnedNoEpoch) {
|
|
187
248
|
warnedNoEpoch = true;
|
|
188
249
|
console.warn(
|
|
@@ -196,7 +257,7 @@ export function createScrollbackKeeper(
|
|
|
196
257
|
// The epoch must reach the connection layer BEFORE this session's resume, or
|
|
197
258
|
// the first resumeAck records it with nothing to compare against and a
|
|
198
259
|
// restart goes undetected for exactly the content that needed checking.
|
|
199
|
-
connection.adoptPersistedEpoch(sessionId,
|
|
260
|
+
connection.adoptPersistedEpoch(sessionId, entry.serverEpoch);
|
|
200
261
|
return store;
|
|
201
262
|
}
|
|
202
263
|
|
package/src/kernel/types.ts
CHANGED
|
@@ -498,9 +498,28 @@ export interface PersistedScrollback {
|
|
|
498
498
|
* treats any failure as "nothing was restored" and takes a full resume, exactly
|
|
499
499
|
* as if persistence were switched off. */
|
|
500
500
|
export interface ScrollbackPersistence {
|
|
501
|
-
/** Return
|
|
502
|
-
*
|
|
503
|
-
|
|
501
|
+
/** Return whatever was stored for a session, or null/undefined when there is
|
|
502
|
+
* nothing. Called once per session, before that session connects.
|
|
503
|
+
*
|
|
504
|
+
* `unknown`, not `PersistedScrollback`, and the asymmetry with `save` below is
|
|
505
|
+
* the point: an entry going OUT was produced by the engine and is proven, while
|
|
506
|
+
* an entry coming BACK has been outside this program's memory and is a claim.
|
|
507
|
+
* Declaring the proven type on both sides told the compiler the returned value
|
|
508
|
+
* was verified — which no implementation of this seam can promise, this
|
|
509
|
+
* library's own `localScrollbackStorage` least of all, since it hands back a
|
|
510
|
+
* `JSON.parse` result. The kernel parsed it anyway, and because the type said
|
|
511
|
+
* otherwise it did so in helpers that each re-established the shape from
|
|
512
|
+
* scratch: measured 2026-08, 15 of `scrollback.ts`'s 19 permanently unkillable
|
|
513
|
+
* mutants were those duplicate checks.
|
|
514
|
+
*
|
|
515
|
+
* Nothing is lost by an implementation: a `load` annotated
|
|
516
|
+
* `PersistedScrollback | null` still satisfies this, so a consumer that holds
|
|
517
|
+
* real entries in memory keeps its own type checking. What goes is only the
|
|
518
|
+
* false guarantee at the boundary that cannot honour it.
|
|
519
|
+
*
|
|
520
|
+
* `PersistedScrollback` remains the shape to store and the shape you get back
|
|
521
|
+
* when storage is intact; see `save`. */
|
|
522
|
+
load(sessionId: string): unknown;
|
|
504
523
|
/** Store an entry, replacing any previous one for the session.
|
|
505
524
|
*
|
|
506
525
|
* THROW when nothing was persisted (over quota, storage revoked, an entry the
|