statewire 0.12.0 → 0.13.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/core.d.ts +2 -0
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +21 -3
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts +4 -1
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +60 -2
- package/dist/client/session.js.map +1 -1
- package/dist/host/draft.d.ts.map +1 -1
- package/dist/host/draft.js +9 -1
- package/dist/host/draft.js.map +1 -1
- package/dist/statewire.d.ts +22 -0
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +31 -11
- package/dist/statewire.js.map +1 -1
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +7 -2
- package/dist/transports/remote.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/wire/ops.d.ts.map +1 -1
- package/dist/wire/ops.js +26 -2
- package/dist/wire/ops.js.map +1 -1
- package/package.json +1 -1
- package/src/client/core.ts +31 -3
- package/src/client/session.ts +100 -0
- package/src/host/draft.ts +15 -1
- package/src/statewire.ts +55 -5
- package/src/transports/remote.ts +8 -0
- package/src/types.ts +6 -0
- package/src/wire/ops.ts +30 -3
package/src/client/core.ts
CHANGED
|
@@ -15,6 +15,8 @@ export const LIVE_CONNECTION: StatewireClient.Connection = {
|
|
|
15
15
|
reconnect: () => {},
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
const NO_FAILED: readonly StatewireClient.FailedCommand[] = [];
|
|
19
|
+
|
|
18
20
|
const failureOf = (error: unknown): StatewireClient.SendFailure =>
|
|
19
21
|
error instanceof StatewireSendError
|
|
20
22
|
? error.failure
|
|
@@ -40,6 +42,7 @@ type Core = {
|
|
|
40
42
|
notifiedOverlayPaths: readonly StatewirePath[];
|
|
41
43
|
notifiedConnection: StatewireClient.Connection | null;
|
|
42
44
|
notifiedBusy: boolean;
|
|
45
|
+
notifiedFailed: readonly StatewireClient.FailedCommand[];
|
|
43
46
|
stale: boolean;
|
|
44
47
|
everAttached: boolean;
|
|
45
48
|
overlaysDirty: boolean;
|
|
@@ -50,6 +53,7 @@ type Core = {
|
|
|
50
53
|
commandsListener: CommandsListener;
|
|
51
54
|
getState: () => unknown;
|
|
52
55
|
getBusy: () => boolean;
|
|
56
|
+
getFailed: () => readonly StatewireClient.FailedCommand[];
|
|
53
57
|
commands: Statewire.CommandsProxy<any>;
|
|
54
58
|
subscribe: (listener: () => void) => () => void;
|
|
55
59
|
};
|
|
@@ -89,11 +93,21 @@ const drainCanonical = (c: Core) => {
|
|
|
89
93
|
}
|
|
90
94
|
};
|
|
91
95
|
|
|
96
|
+
const dropCovered = (c: Core) => {
|
|
97
|
+
const { failureCovered } = c.getOptions();
|
|
98
|
+
const failures = c.value?.failures;
|
|
99
|
+
if (!failureCovered || !failures || c.canonical === undefined) return;
|
|
100
|
+
for (const entry of failures.list()) {
|
|
101
|
+
if (failureCovered(c.canonical as never, entry)) failures.discard(entry);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
92
105
|
const recompute = (c: Core) => {
|
|
93
106
|
if (!c.stale || !c.value) return;
|
|
94
107
|
c.stale = false;
|
|
95
108
|
const prevCanonical = c.canonical;
|
|
96
109
|
drainCanonical(c);
|
|
110
|
+
if (c.canonical !== prevCanonical) dropCovered(c);
|
|
97
111
|
if (c.canonical === prevCanonical && !c.overlaysDirty) return;
|
|
98
112
|
c.overlaysDirty = false;
|
|
99
113
|
const { optimistic, materialize } = c.getOptions();
|
|
@@ -148,7 +162,7 @@ const recompute = (c: Core) => {
|
|
|
148
162
|
const getConnection = (c: Core): StatewireClient.Connection =>
|
|
149
163
|
c.value?.connection ?? LIVE_CONNECTION;
|
|
150
164
|
|
|
151
|
-
const flush = (c: Core) => {
|
|
165
|
+
const flush = (c: Core, scheduled = false) => {
|
|
152
166
|
if (!c.value) return;
|
|
153
167
|
if (c.flushing) {
|
|
154
168
|
schedule(c);
|
|
@@ -187,7 +201,19 @@ const flush = (c: Core) => {
|
|
|
187
201
|
const busy = c.getBusy();
|
|
188
202
|
const busyChanged = busy !== c.notifiedBusy;
|
|
189
203
|
c.notifiedBusy = busy;
|
|
190
|
-
|
|
204
|
+
// The failed snapshot advances only on the scheduled pass: settles land
|
|
205
|
+
// mid-unmount, where a changed snapshot re-renders the unmounting tree.
|
|
206
|
+
const failed = c.value.failures?.list() ?? NO_FAILED;
|
|
207
|
+
let failedChanged = false;
|
|
208
|
+
if (failed !== c.notifiedFailed) {
|
|
209
|
+
if (scheduled) {
|
|
210
|
+
c.notifiedFailed = failed;
|
|
211
|
+
failedChanged = true;
|
|
212
|
+
} else {
|
|
213
|
+
schedule(c);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (stateChanged || connectionChanged || busyChanged || failedChanged) {
|
|
191
217
|
for (const listener of [...c.listeners]) listener();
|
|
192
218
|
}
|
|
193
219
|
} finally {
|
|
@@ -271,6 +297,7 @@ export const useStatewireCore = (
|
|
|
271
297
|
notifiedOverlayPaths: [],
|
|
272
298
|
notifiedConnection: null,
|
|
273
299
|
notifiedBusy: true,
|
|
300
|
+
notifiedFailed: NO_FAILED,
|
|
274
301
|
stale: false,
|
|
275
302
|
everAttached: false,
|
|
276
303
|
overlaysDirty: false,
|
|
@@ -279,7 +306,7 @@ export const useStatewireCore = (
|
|
|
279
306
|
listeners: new Set(),
|
|
280
307
|
post: createMacrotaskPoster(() => {
|
|
281
308
|
c.scheduled = false;
|
|
282
|
-
flush(c);
|
|
309
|
+
flush(c, true);
|
|
283
310
|
}),
|
|
284
311
|
commandsListener: {
|
|
285
312
|
onStart: (inv) => {
|
|
@@ -313,6 +340,7 @@ export const useStatewireCore = (
|
|
|
313
340
|
return c.published;
|
|
314
341
|
},
|
|
315
342
|
getBusy: () => c.value?.isBusy ?? true,
|
|
343
|
+
getFailed: () => c.notifiedFailed,
|
|
316
344
|
commands: createMethodProxy<Statewire.CommandsProxy<any>>(
|
|
317
345
|
(method, params) => {
|
|
318
346
|
if (!c.value) {
|
package/src/client/session.ts
CHANGED
|
@@ -232,6 +232,34 @@ const cover = (s: Session, entry: Pending) => {
|
|
|
232
232
|
s.lifecycle?.onCovered?.(entry.inv);
|
|
233
233
|
};
|
|
234
234
|
|
|
235
|
+
const NO_FAILED: readonly StatewireClient.FailedCommand[] = [];
|
|
236
|
+
|
|
237
|
+
export const failedCommands = (
|
|
238
|
+
s: Session,
|
|
239
|
+
): readonly StatewireClient.FailedCommand[] => s.record.failed ?? NO_FAILED;
|
|
240
|
+
|
|
241
|
+
const retainFailed = (
|
|
242
|
+
s: Session,
|
|
243
|
+
entry: Pending,
|
|
244
|
+
failure: StatewireClient.SendFailure,
|
|
245
|
+
) => {
|
|
246
|
+
setRecord(s, {
|
|
247
|
+
...s.record,
|
|
248
|
+
failed: [
|
|
249
|
+
...(s.record.failed ?? []),
|
|
250
|
+
{
|
|
251
|
+
command: entry.command.method,
|
|
252
|
+
params: entry.command.params,
|
|
253
|
+
...(entry.command.meta !== undefined && { meta: entry.command.meta }),
|
|
254
|
+
failure,
|
|
255
|
+
ts: entry.inv.ts,
|
|
256
|
+
seq: entry.seq,
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
});
|
|
260
|
+
s.notify();
|
|
261
|
+
};
|
|
262
|
+
|
|
235
263
|
const settleEntry = (
|
|
236
264
|
s: Session,
|
|
237
265
|
entry: Pending,
|
|
@@ -247,6 +275,13 @@ const settleEntry = (
|
|
|
247
275
|
payload: outcome.payload,
|
|
248
276
|
});
|
|
249
277
|
} else {
|
|
278
|
+
// A statement the record still carries resends on the next attach; only
|
|
279
|
+
// invocations the record no longer owns become failed history.
|
|
280
|
+
const rides =
|
|
281
|
+
(outcome.failure.cause === "unmount" ||
|
|
282
|
+
outcome.failure.cause === "stream-gone") &&
|
|
283
|
+
entry.seq > s.durableSeq;
|
|
284
|
+
if (!rides) retainFailed(s, entry, outcome.failure);
|
|
250
285
|
entry.reject(new StatewireSendError(outcome.failure));
|
|
251
286
|
s.lifecycle?.onSettle(entry.inv, {
|
|
252
287
|
type: "rejected",
|
|
@@ -307,7 +342,15 @@ const rebaseRecord = (s: Session, adopt: () => void) => {
|
|
|
307
342
|
);
|
|
308
343
|
s.pending = [];
|
|
309
344
|
s.durableSeq = 0;
|
|
345
|
+
// Failed entries are user-owned history: they ride through every record replacement.
|
|
346
|
+
const failed = s.record.failed;
|
|
310
347
|
adopt();
|
|
348
|
+
if (failed !== undefined && s.record.failed !== failed) {
|
|
349
|
+
setRecord(s, {
|
|
350
|
+
...s.record,
|
|
351
|
+
failed: [...(s.record.failed ?? []), ...failed],
|
|
352
|
+
});
|
|
353
|
+
}
|
|
311
354
|
for (const old of survivors) {
|
|
312
355
|
s.lifecycle?.onCovered?.(old.inv);
|
|
313
356
|
enqueueInner(s, old.command, old.inv.params, old.resolve, old.reject);
|
|
@@ -475,6 +518,7 @@ const sessionLostInner = (s: Session, seqWm: number, ctxWm: number) => {
|
|
|
475
518
|
nextSeq: seqWm + 1,
|
|
476
519
|
ctxNextSeq: ctxWm + 1,
|
|
477
520
|
commands: [],
|
|
521
|
+
...(s.record.failed !== undefined && { failed: s.record.failed }),
|
|
478
522
|
});
|
|
479
523
|
restateContext(s);
|
|
480
524
|
if (
|
|
@@ -876,6 +920,62 @@ const sendCommand = (
|
|
|
876
920
|
return result;
|
|
877
921
|
};
|
|
878
922
|
|
|
923
|
+
const takeFailed = (
|
|
924
|
+
s: Session,
|
|
925
|
+
entry: StatewireClient.FailedCommand,
|
|
926
|
+
): StatewireClient.FailedCommand => {
|
|
927
|
+
const failed = s.record.failed ?? [];
|
|
928
|
+
const found = failed.find(
|
|
929
|
+
(f) =>
|
|
930
|
+
f.seq === entry.seq && f.ts === entry.ts && f.command === entry.command,
|
|
931
|
+
);
|
|
932
|
+
if (found === undefined) {
|
|
933
|
+
throw new Error(`statewire: unknown failed command "${entry.command}"`);
|
|
934
|
+
}
|
|
935
|
+
setRecord(s, { ...s.record, failed: failed.filter((f) => f !== found) });
|
|
936
|
+
s.notify();
|
|
937
|
+
return found;
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
export const discardFailed = (
|
|
941
|
+
s: Session,
|
|
942
|
+
entry: StatewireClient.FailedCommand,
|
|
943
|
+
): void => {
|
|
944
|
+
takeFailed(s, entry);
|
|
945
|
+
};
|
|
946
|
+
|
|
947
|
+
export const retryFailed = (
|
|
948
|
+
s: Session,
|
|
949
|
+
entry: StatewireClient.FailedCommand,
|
|
950
|
+
): Promise<unknown> => {
|
|
951
|
+
if (!s.running) {
|
|
952
|
+
return rejectedSend({ fate: "aborted", cause: "unmount" });
|
|
953
|
+
}
|
|
954
|
+
if (isFinished(s)) {
|
|
955
|
+
return rejectedSend({ fate: "aborted", cause: "stream-gone" });
|
|
956
|
+
}
|
|
957
|
+
const found = takeFailed(s, entry);
|
|
958
|
+
const params =
|
|
959
|
+
found.meta === undefined
|
|
960
|
+
? found.params
|
|
961
|
+
: (reviveValue([...found.params], found.meta) as unknown[]);
|
|
962
|
+
const result = new Promise<unknown>((resolve, reject) => {
|
|
963
|
+
enqueue(
|
|
964
|
+
s,
|
|
965
|
+
{
|
|
966
|
+
method: found.command,
|
|
967
|
+
params: [...found.params],
|
|
968
|
+
...(found.meta !== undefined && { meta: found.meta }),
|
|
969
|
+
},
|
|
970
|
+
params,
|
|
971
|
+
resolve,
|
|
972
|
+
reject,
|
|
973
|
+
);
|
|
974
|
+
});
|
|
975
|
+
result.catch(() => {});
|
|
976
|
+
return result;
|
|
977
|
+
};
|
|
978
|
+
|
|
879
979
|
const setContext = (s: Session, value: Record<string, unknown>): void => {
|
|
880
980
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
881
981
|
throw new Error("statewire: context must be a plain object");
|
package/src/host/draft.ts
CHANGED
|
@@ -30,6 +30,16 @@ const invalid = (path: StatewirePath, message: string) =>
|
|
|
30
30
|
|
|
31
31
|
type Container = Record<string | number, unknown>;
|
|
32
32
|
|
|
33
|
+
// Text-insert offsets are Unicode code points on the wire, not UTF-16 units.
|
|
34
|
+
const codePointLength = (text: string): number => {
|
|
35
|
+
let count = 0;
|
|
36
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
37
|
+
if (text.codePointAt(index)! > 0xffff) index += 1;
|
|
38
|
+
count += 1;
|
|
39
|
+
}
|
|
40
|
+
return count;
|
|
41
|
+
};
|
|
42
|
+
|
|
33
43
|
type Node = {
|
|
34
44
|
parent: Node | null;
|
|
35
45
|
key: string | number;
|
|
@@ -96,7 +106,11 @@ export const createStateDraft = <T>(host: DraftHost): StateDraft<T> => {
|
|
|
96
106
|
if (suffix === "") return;
|
|
97
107
|
prepareForWrite(node)[key] = existing + suffix;
|
|
98
108
|
host.record(
|
|
99
|
-
{
|
|
109
|
+
{
|
|
110
|
+
op: "add",
|
|
111
|
+
path: [...node.path, key, codePointLength(existing)],
|
|
112
|
+
value: suffix,
|
|
113
|
+
},
|
|
100
114
|
changedPathFor(node, key),
|
|
101
115
|
);
|
|
102
116
|
};
|
package/src/statewire.ts
CHANGED
|
@@ -59,10 +59,21 @@ export const useStatewire = <
|
|
|
59
59
|
core.getState,
|
|
60
60
|
) as TState;
|
|
61
61
|
const isBusy = useSyncExternalStore(subscribe, core.getBusy, core.getBusy);
|
|
62
|
+
const failed = useSyncExternalStore(
|
|
63
|
+
subscribe,
|
|
64
|
+
core.getFailed,
|
|
65
|
+
core.getFailed,
|
|
66
|
+
);
|
|
62
67
|
const connection = value.connection ?? LIVE_CONNECTION;
|
|
63
68
|
|
|
64
|
-
return useMemo(
|
|
65
|
-
() =>
|
|
69
|
+
return useMemo(() => {
|
|
70
|
+
const failures = (): NonNullable<typeof value.failures> => {
|
|
71
|
+
if (!value.failures) {
|
|
72
|
+
throw new Error("statewire: this transport has no failed-send lane");
|
|
73
|
+
}
|
|
74
|
+
return value.failures;
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
66
77
|
state,
|
|
67
78
|
getState: core.getState as () => TState,
|
|
68
79
|
connection,
|
|
@@ -76,9 +87,13 @@ export const useStatewire = <
|
|
|
76
87
|
}
|
|
77
88
|
value.setContext(contextValue);
|
|
78
89
|
},
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
90
|
+
failed,
|
|
91
|
+
retryFailed: (entry: StatewireClient.FailedCommand) =>
|
|
92
|
+
failures().retry(entry),
|
|
93
|
+
discardFailed: (entry: StatewireClient.FailedCommand) =>
|
|
94
|
+
failures().discard(entry),
|
|
95
|
+
};
|
|
96
|
+
}, [state, isBusy, failed, connection, core, value]);
|
|
82
97
|
};
|
|
83
98
|
|
|
84
99
|
export const StatewireResource = <
|
|
@@ -121,6 +136,18 @@ export class StatewireClient<
|
|
|
121
136
|
this.root.getValue().setContext(value);
|
|
122
137
|
}
|
|
123
138
|
|
|
139
|
+
get failed(): readonly StatewireClient.FailedCommand[] {
|
|
140
|
+
return this.root.getValue().failed;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
retryFailed(entry: StatewireClient.FailedCommand): Promise<unknown> {
|
|
144
|
+
return this.root.getValue().retryFailed(entry);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
discardFailed(entry: StatewireClient.FailedCommand): void {
|
|
148
|
+
this.root.getValue().discardFailed(entry);
|
|
149
|
+
}
|
|
150
|
+
|
|
124
151
|
subscribe(listener: () => void): () => void {
|
|
125
152
|
return this.root.subscribe(listener);
|
|
126
153
|
}
|
|
@@ -155,6 +182,11 @@ export namespace StatewireClient {
|
|
|
155
182
|
ctx: { state: NonNullable<TState> },
|
|
156
183
|
) => void;
|
|
157
184
|
onConnectionChange?: (connection: Connection) => void;
|
|
185
|
+
/** Drops a retained failed entry once a canonical snapshot shows its effect landed; checked against each new snapshot. */
|
|
186
|
+
failureCovered?: (
|
|
187
|
+
state: NonNullable<TState>,
|
|
188
|
+
entry: FailedCommand,
|
|
189
|
+
) => boolean;
|
|
158
190
|
};
|
|
159
191
|
|
|
160
192
|
export type Helpers<
|
|
@@ -171,6 +203,12 @@ export namespace StatewireClient {
|
|
|
171
203
|
isBusy: boolean;
|
|
172
204
|
/** Replace this client's ambient context on the server; throws on transports without a client context channel. */
|
|
173
205
|
setContext: (value: Record<string, unknown>) => void;
|
|
206
|
+
/** Non-accepted sends retained in the session record; reload with it. */
|
|
207
|
+
failed: readonly FailedCommand[];
|
|
208
|
+
/** Re-enqueues the entry's command on fresh clocks and removes the entry. */
|
|
209
|
+
retryFailed: (entry: FailedCommand) => Promise<unknown>;
|
|
210
|
+
/** Removes the entry. */
|
|
211
|
+
discardFailed: (entry: FailedCommand) => void;
|
|
174
212
|
};
|
|
175
213
|
|
|
176
214
|
export type ConnectionState =
|
|
@@ -259,6 +297,16 @@ export namespace StatewireClient {
|
|
|
259
297
|
}>,
|
|
260
298
|
) => "requeue" | "drop";
|
|
261
299
|
|
|
300
|
+
/** A non-accepted send retained for the user: the wire statement, its failure, and its original clocks. */
|
|
301
|
+
export type FailedCommand = {
|
|
302
|
+
readonly command: string;
|
|
303
|
+
readonly params: readonly unknown[];
|
|
304
|
+
readonly meta?: unknown;
|
|
305
|
+
readonly failure: SendFailure;
|
|
306
|
+
readonly ts: number;
|
|
307
|
+
readonly seq: number;
|
|
308
|
+
};
|
|
309
|
+
|
|
262
310
|
/** A command as persisted while un-durable: its lane seq plus the wire statement. */
|
|
263
311
|
export type SessionCommand = {
|
|
264
312
|
seq: number;
|
|
@@ -275,6 +323,8 @@ export namespace StatewireClient {
|
|
|
275
323
|
commands: readonly SessionCommand[];
|
|
276
324
|
/** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */
|
|
277
325
|
verdicts?: readonly SessionVerdict[];
|
|
326
|
+
/** Non-accepted sends retained for the user; survive record rebases and resets. */
|
|
327
|
+
failed?: readonly FailedCommand[];
|
|
278
328
|
};
|
|
279
329
|
|
|
280
330
|
/** A terminal verdict one sharer's connection carried, parked for the sharer awaiting the seq. */
|
package/src/transports/remote.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { useEffect } from "react";
|
|
2
2
|
import type { Statewire } from "../types";
|
|
3
3
|
import {
|
|
4
|
+
discardFailed,
|
|
5
|
+
failedCommands,
|
|
6
|
+
retryFailed,
|
|
4
7
|
setContext,
|
|
5
8
|
setTransport,
|
|
6
9
|
useStatewireSession,
|
|
@@ -39,5 +42,10 @@ export const useStatewireRemote = <
|
|
|
39
42
|
return !s.idle;
|
|
40
43
|
},
|
|
41
44
|
setContext: (value) => setContext(s, value),
|
|
45
|
+
failures: {
|
|
46
|
+
list: () => failedCommands(s),
|
|
47
|
+
retry: (entry) => retryFailed(s, entry),
|
|
48
|
+
discard: (entry) => discardFailed(s, entry),
|
|
49
|
+
},
|
|
42
50
|
};
|
|
43
51
|
};
|
package/src/types.ts
CHANGED
|
@@ -59,6 +59,12 @@ export interface Statewire<
|
|
|
59
59
|
isBusy?: boolean;
|
|
60
60
|
/** Replace this client's ambient context on the server; it rides the ctx lane (no verdict). */
|
|
61
61
|
setContext?: (value: Record<string, unknown>) => void;
|
|
62
|
+
/** The retained failed-send lane; present on transports with a durable session. */
|
|
63
|
+
failures?: {
|
|
64
|
+
list: () => readonly StatewireClient.FailedCommand[];
|
|
65
|
+
retry: (entry: StatewireClient.FailedCommand) => Promise<unknown>;
|
|
66
|
+
discard: (entry: StatewireClient.FailedCommand) => void;
|
|
67
|
+
};
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
const describe = (failure: StatewireClient.SendFailure): string => {
|
package/src/wire/ops.ts
CHANGED
|
@@ -64,15 +64,42 @@ export const sanitizeValue = (value: unknown, parent?: string): unknown =>
|
|
|
64
64
|
const invalid = (op: Statewire.Op) =>
|
|
65
65
|
new Error(`Statewire cannot apply ${op.op} at path: ${op.path.join(".")}`);
|
|
66
66
|
|
|
67
|
+
// Text-insert offsets are Unicode code points on the wire, not UTF-16 units.
|
|
68
|
+
const utf16Index = (text: string, offset: number): number => {
|
|
69
|
+
let index = 0;
|
|
70
|
+
for (let remaining = offset; remaining > 0; remaining -= 1) {
|
|
71
|
+
if (index >= text.length) return -1;
|
|
72
|
+
index += text.codePointAt(index)! > 0xffff ? 2 : 1;
|
|
73
|
+
}
|
|
74
|
+
return index;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const countCodePoints = (text: string): number => {
|
|
78
|
+
let count = 0;
|
|
79
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
80
|
+
if (text.codePointAt(index)! > 0xffff) index += 1;
|
|
81
|
+
count += 1;
|
|
82
|
+
}
|
|
83
|
+
return count;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
let last: { str: string; count: number } | null = null;
|
|
87
|
+
|
|
67
88
|
const insertedText = (
|
|
68
89
|
current: string,
|
|
69
90
|
op: Extract<Statewire.Op, { op: "add" }>,
|
|
70
91
|
offset: number,
|
|
71
92
|
): string => {
|
|
72
|
-
if (typeof op.value !== "string"
|
|
73
|
-
|
|
93
|
+
if (typeof op.value !== "string") throw invalid(op);
|
|
94
|
+
const memoized =
|
|
95
|
+
last !== null && last.str === current && last.count === offset;
|
|
96
|
+
const index = memoized ? current.length : utf16Index(current, offset);
|
|
97
|
+
if (index < 0) throw invalid(op);
|
|
98
|
+
const next = current.slice(0, index) + op.value + current.slice(index);
|
|
99
|
+
if (index === current.length) {
|
|
100
|
+
last = { str: next, count: offset + countCodePoints(op.value) };
|
|
74
101
|
}
|
|
75
|
-
return
|
|
102
|
+
return next;
|
|
76
103
|
};
|
|
77
104
|
|
|
78
105
|
const applyAdd = (
|