effect-browser 0.2.0-beta.9 → 0.3.0-beta.11

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.
Files changed (205) hide show
  1. package/README.md +861 -1161
  2. package/dist/Browser-B4qncxe7.mjs +628 -0
  3. package/dist/Browser-B4qncxe7.mjs.map +1 -0
  4. package/dist/Browser.d.mts +129 -3
  5. package/dist/Browser.mjs +2 -22
  6. package/dist/BrowserError-Coy7EW1N.d.mts +140 -0
  7. package/dist/BrowserError.d.mts +2 -0
  8. package/dist/BrowserError.mjs +211 -0
  9. package/dist/BrowserError.mjs.map +1 -0
  10. package/dist/BrowserEvent-BRu2juEz.d.mts +279 -0
  11. package/dist/BrowserEvent.d.mts +2 -0
  12. package/dist/BrowserEvent.mjs +297 -0
  13. package/dist/BrowserEvent.mjs.map +1 -0
  14. package/dist/Cdp.d.mts +32 -0
  15. package/dist/Cdp.mjs +61 -0
  16. package/dist/Cdp.mjs.map +1 -0
  17. package/dist/Change-DNvztNLu.mjs +138 -0
  18. package/dist/Change-DNvztNLu.mjs.map +1 -0
  19. package/dist/Change-iMlcuzVD.d.mts +71 -0
  20. package/dist/Change.d.mts +2 -0
  21. package/dist/Change.mjs +2 -0
  22. package/dist/Chromium.d.mts +29 -81
  23. package/dist/Chromium.mjs +57 -392
  24. package/dist/Chromium.mjs.map +1 -1
  25. package/dist/Expect.d.mts +62 -0
  26. package/dist/Expect.mjs +114 -0
  27. package/dist/Expect.mjs.map +1 -0
  28. package/dist/Frame-Di2PxyF7.d.mts +119 -0
  29. package/dist/Frame.d.mts +2 -0
  30. package/dist/Frame.mjs +125 -0
  31. package/dist/Frame.mjs.map +1 -0
  32. package/dist/Moment.d.mts +2 -0
  33. package/dist/Moment.mjs +288 -0
  34. package/dist/Moment.mjs.map +1 -0
  35. package/dist/Motion.d.mts +40 -0
  36. package/dist/Motion.mjs +122 -0
  37. package/dist/Motion.mjs.map +1 -0
  38. package/dist/Page-B5cjaUp5.mjs +618 -0
  39. package/dist/Page-B5cjaUp5.mjs.map +1 -0
  40. package/dist/Page-B7X6DQUn.d.mts +595 -0
  41. package/dist/Page.d.mts +2 -0
  42. package/dist/Page.mjs +2 -0
  43. package/dist/Plan-Akcac63g.d.mts +209 -0
  44. package/dist/Plan-D1wrJVs8.mjs +508 -0
  45. package/dist/Plan-D1wrJVs8.mjs.map +1 -0
  46. package/dist/Plan.d.mts +2 -3
  47. package/dist/Plan.mjs +2 -2
  48. package/dist/Policy.d.mts +76 -0
  49. package/dist/Policy.mjs +237 -0
  50. package/dist/Policy.mjs.map +1 -0
  51. package/dist/Presentation.d.mts +48 -0
  52. package/dist/Presentation.mjs +117 -0
  53. package/dist/Presentation.mjs.map +1 -0
  54. package/dist/Snapshot-B34hZ4Jl.d.mts +68 -0
  55. package/dist/Snapshot.d.mts +2 -0
  56. package/dist/Snapshot.mjs +79 -0
  57. package/dist/Snapshot.mjs.map +1 -0
  58. package/dist/Stage.d.mts +64 -0
  59. package/dist/Stage.mjs +237 -0
  60. package/dist/Stage.mjs.map +1 -0
  61. package/dist/Supervisor-CYWFK3vM.d.mts +280 -0
  62. package/dist/Supervisor-TNKNOVzu.mjs +599 -0
  63. package/dist/Supervisor-TNKNOVzu.mjs.map +1 -0
  64. package/dist/Supervisor.d.mts +2 -0
  65. package/dist/Supervisor.mjs +2 -0
  66. package/dist/context-B6xr8nVi.mjs +261 -0
  67. package/dist/context-B6xr8nVi.mjs.map +1 -0
  68. package/dist/index.d.mts +17 -14
  69. package/dist/index.mjs +18 -13
  70. package/dist/match.inpage-xOe9LNO0.mjs +48 -0
  71. package/dist/match.inpage-xOe9LNO0.mjs.map +1 -0
  72. package/dist/page-BAf__WKX.mjs +5075 -0
  73. package/dist/page-BAf__WKX.mjs.map +1 -0
  74. package/dist/rolldown-runtime-D7D4PA-g.mjs +13 -0
  75. package/dist/url-Cd04Okid.mjs +264 -0
  76. package/dist/url-Cd04Okid.mjs.map +1 -0
  77. package/package.json +96 -61
  78. package/src/Browser.ts +672 -0
  79. package/src/BrowserError.ts +234 -0
  80. package/src/BrowserEvent.ts +296 -0
  81. package/src/Cdp.ts +101 -0
  82. package/src/Change.ts +88 -0
  83. package/src/Chromium.ts +102 -0
  84. package/src/Expect.ts +180 -0
  85. package/src/Frame.ts +132 -0
  86. package/src/Moment.ts +479 -0
  87. package/src/Motion.ts +166 -0
  88. package/src/Page.ts +522 -0
  89. package/src/Plan.ts +663 -0
  90. package/src/Policy.ts +325 -0
  91. package/src/Presentation.ts +195 -0
  92. package/src/Snapshot.ts +89 -0
  93. package/src/Stage.ts +467 -0
  94. package/src/Supervisor.ts +599 -0
  95. package/src/index.ts +20 -0
  96. package/src/internal/input/actions.ts +610 -0
  97. package/src/internal/input/dispatch.ts +161 -0
  98. package/src/internal/input/edit.inpage.ts +162 -0
  99. package/src/internal/input/evidence.inpage.ts +164 -0
  100. package/src/internal/input/gate.ts +224 -0
  101. package/src/internal/input/guard.inpage.ts +605 -0
  102. package/src/internal/input/guard.ts +184 -0
  103. package/src/internal/input/keyboard.ts +112 -0
  104. package/src/internal/input/keys.ts +127 -0
  105. package/src/internal/input/perform.ts +376 -0
  106. package/src/internal/input/pointer.ts +153 -0
  107. package/src/internal/input/replies.ts +195 -0
  108. package/src/internal/input/settle.ts +50 -0
  109. package/src/internal/input/style.ts +186 -0
  110. package/src/internal/input/targets.inpage.ts +293 -0
  111. package/src/internal/input/targets.ts +148 -0
  112. package/src/internal/page/bridge.ts +391 -0
  113. package/src/internal/page/context.ts +314 -0
  114. package/src/internal/page/identity.ts +35 -0
  115. package/src/internal/page/lane.ts +311 -0
  116. package/src/internal/page/navigation.ts +183 -0
  117. package/src/internal/page/openings.ts +87 -0
  118. package/src/internal/page/page.ts +165 -0
  119. package/src/internal/page/url.ts +100 -0
  120. package/src/internal/page/viewport.ts +43 -0
  121. package/src/internal/pictures/capture.ts +776 -0
  122. package/src/internal/pictures/clock.ts +141 -0
  123. package/src/internal/pictures/counts.ts +92 -0
  124. package/src/internal/pictures/pictures.ts +521 -0
  125. package/src/internal/pictures/transport.ts +243 -0
  126. package/src/internal/reading/choose.ts +100 -0
  127. package/src/internal/reading/context.inpage.ts +201 -0
  128. package/src/internal/reading/controls.inpage.ts +158 -0
  129. package/src/internal/reading/expect.inpage.ts +70 -0
  130. package/src/internal/reading/match.inpage.ts +92 -0
  131. package/src/internal/reading/names.inpage.ts +306 -0
  132. package/src/internal/reading/outline.inpage.ts +260 -0
  133. package/src/internal/reading/reading.ts +223 -0
  134. package/src/internal/reading/ready.inpage.ts +164 -0
  135. package/src/internal/reading/ready.ts +111 -0
  136. package/src/internal/reading/subjects.inpage.ts +188 -0
  137. package/src/internal/reading/text.inpage.ts +135 -0
  138. package/src/internal/reading/walk.inpage.ts +220 -0
  139. package/src/internal/supervisor/lifecycle.ts +416 -0
  140. package/src/internal/timeline/changes.inpage.ts +216 -0
  141. package/src/internal/timeline/changes.ts +100 -0
  142. package/src/internal/timeline/events.ts +94 -0
  143. package/src/internal/timeline/fold.inpage.ts +123 -0
  144. package/src/internal/timeline/history.inpage.ts +229 -0
  145. package/src/internal/timeline/marks.inpage.ts +207 -0
  146. package/src/internal/timeline/record.inpage.ts +133 -0
  147. package/src/internal/timeline/sight.inpage.ts +122 -0
  148. package/src/internal/timeline/window.ts +96 -0
  149. package/src/internal/timeline/words.ts +65 -0
  150. package/dist/Bootstrap-B9UUXsvF.d.mts +0 -183
  151. package/dist/Bootstrap-CeQOWBy6.mjs +0 -189
  152. package/dist/Bootstrap-CeQOWBy6.mjs.map +0 -1
  153. package/dist/Bootstrap.d.mts +0 -3
  154. package/dist/Bootstrap.mjs +0 -2
  155. package/dist/Browser-BF9_sORP.d.mts +0 -388
  156. package/dist/Browser.mjs.map +0 -1
  157. package/dist/BrowserData-CPIIIHol.d.mts +0 -585
  158. package/dist/BrowserData.d.mts +0 -3
  159. package/dist/BrowserData.mjs +0 -695
  160. package/dist/BrowserData.mjs.map +0 -1
  161. package/dist/BrowserRuntime-B6nT1G4X.d.mts +0 -148
  162. package/dist/BrowserRuntime-C1r2aSRK.mjs +0 -6820
  163. package/dist/BrowserRuntime-C1r2aSRK.mjs.map +0 -1
  164. package/dist/BrowserRuntime.d.mts +0 -3
  165. package/dist/BrowserRuntime.mjs +0 -3
  166. package/dist/Capture.d.mts +0 -56
  167. package/dist/Capture.mjs +0 -678
  168. package/dist/Capture.mjs.map +0 -1
  169. package/dist/CaptureData-CYriZHX1.mjs +0 -201
  170. package/dist/CaptureData-CYriZHX1.mjs.map +0 -1
  171. package/dist/CaptureData.d.mts +0 -219
  172. package/dist/CaptureData.mjs +0 -2
  173. package/dist/Errors.d.mts +0 -158
  174. package/dist/Errors.mjs +0 -207
  175. package/dist/Errors.mjs.map +0 -1
  176. package/dist/Images-yCG9BmRU.mjs +0 -76
  177. package/dist/Images-yCG9BmRU.mjs.map +0 -1
  178. package/dist/OperationOptions-DAeRS6O9.mjs +0 -55
  179. package/dist/OperationOptions-DAeRS6O9.mjs.map +0 -1
  180. package/dist/PageControl.d.mts +0 -15
  181. package/dist/PageControl.mjs +0 -34
  182. package/dist/PageControl.mjs.map +0 -1
  183. package/dist/PageRegistry-B3q9DS3g.mjs +0 -164
  184. package/dist/PageRegistry-B3q9DS3g.mjs.map +0 -1
  185. package/dist/Plan-Dyxu2q8C.mjs +0 -543
  186. package/dist/Plan-Dyxu2q8C.mjs.map +0 -1
  187. package/dist/PlanData-B668RxbQ.d.mts +0 -4831
  188. package/dist/PlanData.d.mts +0 -3
  189. package/dist/PlanData.mjs +0 -268
  190. package/dist/PlanData.mjs.map +0 -1
  191. package/dist/PlanInput-BJcPPncs.mjs +0 -75
  192. package/dist/PlanInput-BJcPPncs.mjs.map +0 -1
  193. package/dist/PublicSession-DCSMMqOj.mjs +0 -5602
  194. package/dist/PublicSession-DCSMMqOj.mjs.map +0 -1
  195. package/dist/Testing.d.mts +0 -479
  196. package/dist/Testing.mjs +0 -2147
  197. package/dist/Testing.mjs.map +0 -1
  198. package/dist/Timeline-fV22Xk-S.d.mts +0 -29
  199. package/dist/Timeline.d.mts +0 -4
  200. package/dist/Timeline.mjs +0 -20
  201. package/dist/Timeline.mjs.map +0 -1
  202. package/dist/TimelineData-BsOn-YGZ.d.mts +0 -3818
  203. package/dist/TimelineData.d.mts +0 -3
  204. package/dist/TimelineData.mjs +0 -482
  205. package/dist/TimelineData.mjs.map +0 -1
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Reading a page's record of what changed, in one call to the page. The first read also registers
3
+ * the recorder with the page's own session, in the same round trip, so that every later document
4
+ * records from its start. The page's times map to the owner's clock through the browser's one
5
+ * clock mapping, as frames' do, which a browser's first read measures, once, where no capture has;
6
+ * and a window can begin on the page's own clock where the previous one ended, so consecutive
7
+ * reads neither miss nor repeat a change. A read takes its turn on the page as other reads do, and
8
+ * identical ones in flight share it; none is kept for a later caller, since its window ends when
9
+ * it was read.
10
+ */
11
+ import { Effect, Schema } from "effect";
12
+
13
+ import type { BrowserError } from "../../BrowserError.ts";
14
+ import { Change, Changes } from "../../Change.ts";
15
+ import type { Frame } from "../../Frame.ts";
16
+ import type { ChangesOptions } from "../../Page.ts";
17
+ import { type Bridge, scriptCall } from "../page/bridge.ts";
18
+ import { decodeWith, type PageContext } from "../page/context.ts";
19
+ import * as BrowserClock from "../pictures/clock.ts";
20
+ import { type ChangesRequest, ChangesResultSchema } from "./changes.inpage.ts";
21
+
22
+ const isChanges = Schema.is(Changes);
23
+
24
+ // Where a window starts or ends as its caller gave it, which tells identical reads apart.
25
+ const markOf = (time: Changes | Frame | number | undefined) =>
26
+ time === undefined || typeof time === "number"
27
+ ? time
28
+ : isChanges(time)
29
+ ? `cursor ${time.cursor}`
30
+ : `frame ${time.timestamp ?? time.hostTime}`;
31
+
32
+ export const make = (
33
+ page: PageContext,
34
+ bridge: Bridge,
35
+ estimate: Effect.Effect<BrowserClock.Estimate, BrowserError>,
36
+ ) => {
37
+ const reads = page.lane.shared<Changes>("changes", false);
38
+
39
+ return (options: ChangesOptions = {}) =>
40
+ reads(
41
+ JSON.stringify([markOf(options.since), markOf(options.until), options.unmask ?? false]),
42
+ Effect.gen(function* () {
43
+ const mapping = yield* estimate;
44
+ const host = (epoch: number) => BrowserClock.toHostTime(mapping, epoch);
45
+
46
+ // A frame's paint is on the page's clock already; a screenshot has only its host time.
47
+ const onPage = (time: Frame | number) =>
48
+ typeof time === "number"
49
+ ? time + mapping.offsetMillis
50
+ : (time.timestamp ?? time.hostTime + mapping.offsetMillis);
51
+
52
+ const request: ChangesRequest = {
53
+ since:
54
+ options.since === undefined
55
+ ? null
56
+ : isChanges(options.since)
57
+ ? options.since.cursor
58
+ : onPage(options.since),
59
+ until: options.until === undefined ? null : onPage(options.until),
60
+ unmask: options.unmask ?? false,
61
+ };
62
+
63
+ const [, result] = yield* Effect.all(
64
+ [
65
+ bridge.registerRecorder("changes"),
66
+ bridge
67
+ .evaluate("changes", scriptCall("changes", request))
68
+ .pipe(Effect.flatMap(decodeWith("changes", ChangesResultSchema))),
69
+ ],
70
+ { concurrency: "unbounded" },
71
+ );
72
+
73
+ yield* Effect.annotateCurrentSpan({
74
+ changes: result.records.length,
75
+ dropped: result.dropped,
76
+ });
77
+
78
+ const later = (epoch: number | undefined) =>
79
+ epoch === undefined ? undefined : host(epoch);
80
+
81
+ return new Changes({
82
+ document: bridge.frameTag().document,
83
+ from: host(result.from),
84
+ until: host(result.until),
85
+ cursor: result.until,
86
+ dropped: result.dropped,
87
+ changes: result.records.map(
88
+ (one) =>
89
+ new Change({
90
+ ...one,
91
+ startedAt: host(one.startedAt),
92
+ at: host(one.at),
93
+ earlier: later(one.earlier),
94
+ cause: later(one.cause),
95
+ }),
96
+ ),
97
+ });
98
+ }).pipe(page.within("changes")),
99
+ ).pipe(page.span("Page.changes"), page.owned);
100
+ };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * The browser's bounded event history and replay cursors. Readers share the same records and
3
+ * wakeup generation, so a delayed reader cannot block input or retain an unbounded private queue.
4
+ */
5
+ import { Cause, Deferred, Effect, Stream } from "effect";
6
+
7
+ import { type BrowserError, EventHistoryExpired, InvalidRequest } from "../../BrowserError.ts";
8
+ import { type BrowserEvent, RecordedEvent } from "../../BrowserEvent.ts";
9
+ import { undispatched } from "../page/context.ts";
10
+
11
+ /** The owning Browser validates capacity and closes this timeline with its scope. */
12
+ export const make = (capacity: number) => {
13
+ let records: ReadonlyArray<RecordedEvent> = [];
14
+ let sequence = 0;
15
+ let closed = false;
16
+ let changed = Deferred.makeUnsafe<void>();
17
+
18
+ const wake = () => {
19
+ const previous = changed;
20
+
21
+ changed = Deferred.makeUnsafe<void>();
22
+ Deferred.doneUnsafe(previous, Effect.void);
23
+ };
24
+
25
+ const publish = (event: BrowserEvent): number => {
26
+ // Native callbacks may finish after their owning scope. They must not revive closed readers.
27
+ if (closed) return sequence;
28
+ sequence += 1;
29
+ const recorded = new RecordedEvent({ sequence, event });
30
+
31
+ records = records.length < capacity ? [...records, recorded] : [...records.slice(1), recorded];
32
+ wake();
33
+
34
+ return sequence;
35
+ };
36
+
37
+ const stream = (after?: number): Stream.Stream<RecordedEvent, BrowserError> =>
38
+ Stream.unwrap(
39
+ Effect.sync(() => {
40
+ // This runs for each subscription, rather than when the reusable stream is constructed.
41
+ let cursor = after ?? sequence;
42
+
43
+ if (!Number.isSafeInteger(cursor) || cursor < 0 || cursor > sequence)
44
+ return Stream.fail(
45
+ undispatched(
46
+ "events",
47
+ new InvalidRequest({
48
+ detail:
49
+ "the event cursor must be a nonnegative integer no greater than the latest sequence",
50
+ }),
51
+ ),
52
+ );
53
+
54
+ return Stream.fromEffectRepeat(
55
+ Effect.gen(function* () {
56
+ while (true) {
57
+ const oldest = records[0]?.sequence ?? sequence + 1;
58
+
59
+ if (cursor < oldest - 1)
60
+ return yield* undispatched(
61
+ "events",
62
+ new EventHistoryExpired({ after: cursor, oldest, latest: sequence }),
63
+ );
64
+ const next = records[cursor - oldest + 1];
65
+
66
+ if (next !== undefined) {
67
+ cursor = next.sequence;
68
+
69
+ return next;
70
+ }
71
+ if (closed) return yield* Cause.done();
72
+ // Capture the generation in the same synchronous read as the empty history check.
73
+ // A publish before await completes this exact Deferred, so no wakeup can be missed.
74
+ const waiting = changed;
75
+
76
+ yield* Deferred.await(waiting);
77
+ }
78
+ }),
79
+ );
80
+ }),
81
+ );
82
+
83
+ return {
84
+ publish,
85
+ sequence: () => sequence,
86
+ recent: Effect.sync(() => records),
87
+ stream,
88
+ close: Effect.sync(() => {
89
+ if (closed) return;
90
+ closed = true;
91
+ wake();
92
+ }),
93
+ };
94
+ };
@@ -0,0 +1,123 @@
1
+ /**
2
+ * In the page: one element's history in the change record, and its fold over a window. A fold gives
3
+ * what the element showed at the window's start and at its end, how often that changed between,
4
+ * the lowest and highest of a number that changed more than once, and when it last changed before
5
+ * the window. Only what was seen in view as it changed is told. It touches no DOM, so it also runs
6
+ * outside a page. See `names.inpage.ts` for what a page-side part may use.
7
+ */
8
+
9
+ /** What an element shows: its words, `null` while it shows nothing, `undefined` until judged. */
10
+ export type Shown = string | null | undefined;
11
+
12
+ export interface Sample {
13
+ readonly at: number;
14
+ shown: Shown;
15
+ /** Whether it was in view as the page rendered it; undefined until then. */
16
+ seen: boolean | undefined;
17
+ }
18
+
19
+ /** One element's history, oldest change first. */
20
+ export interface Track {
21
+ key: object;
22
+ readonly kind: "content" | "value" | "title";
23
+ /** What it showed before its oldest kept change; undefined when that is unknown. */
24
+ initial: Shown;
25
+ /** Before this, the record let some of its changes go. */
26
+ known: number;
27
+ readonly samples: Array<Sample>;
28
+ }
29
+
30
+ export type Kind = "text" | "appeared" | "disappeared" | "brief" | "value" | "title";
31
+
32
+ /** One element's changes over a window, before the page names the element. */
33
+ export interface Folded {
34
+ readonly track: Track;
35
+ readonly kind: Kind;
36
+ readonly startedAt: number;
37
+ readonly at: number;
38
+ readonly before: string | undefined;
39
+ readonly after: string | undefined;
40
+ readonly count: number;
41
+ readonly lowest: string | undefined;
42
+ readonly highest: string | undefined;
43
+ readonly earlier: number | undefined;
44
+ }
45
+
46
+ export const fold = () => {
47
+ /** The one number some words show, such as 61240 in "$61,240"; none for none or several. */
48
+ const number = (words: string): ReadonlyArray<number> => {
49
+ const found = words.match(/[-+−]?\d[\d,]*(?:\.\d+)?/g);
50
+
51
+ const value = Number(
52
+ found?.length === 1 ? found[0]?.replaceAll(",", "").replace("−", "-") : "",
53
+ );
54
+
55
+ return found?.length === 1 && Number.isFinite(value) ? [value] : [];
56
+ };
57
+
58
+ /** A track's changes after `since`, up to `until`, if one was seen in view. */
59
+ const over = (track: Track, since: number, until: number): Folded | undefined => {
60
+ const inside = track.samples.filter((sample) => sample.at > since && sample.at <= until);
61
+ const first = inside[0];
62
+ const last = inside.at(-1);
63
+
64
+ if (first === undefined || last === undefined || !inside.some((sample) => sample.seen === true))
65
+ return undefined;
66
+ const prior = track.samples[track.samples.indexOf(first) - 1];
67
+ // What it showed at `since` is lost when the record let go of changes after it.
68
+ const lost = track.known <= since ? track.known : undefined;
69
+
70
+ const start =
71
+ prior === undefined ? (lost === undefined ? undefined : track.initial) : prior.shown;
72
+
73
+ const words = [start, ...inside.map((sample) => sample.shown)].filter(
74
+ (each) => typeof each === "string",
75
+ );
76
+
77
+ const numbers = words.flatMap(number);
78
+ const ranged = inside.length > 1 && numbers.length === words.length;
79
+
80
+ const kind: Kind =
81
+ track.kind !== "content"
82
+ ? track.kind
83
+ : last.shown === null
84
+ ? start === null
85
+ ? "brief"
86
+ : "disappeared"
87
+ : start === null
88
+ ? "appeared"
89
+ : "text";
90
+
91
+ return {
92
+ track,
93
+ kind,
94
+ startedAt: first.at,
95
+ at: last.at,
96
+ before: kind === "disappeared" ? words.at(-1) : (start ?? undefined),
97
+ after: kind === "brief" ? words.at(-1) : (last.shown ?? undefined),
98
+ count: inside.length,
99
+ lowest: ranged ? words[numbers.indexOf(Math.min(...numbers))] : undefined,
100
+ highest: ranged ? words[numbers.indexOf(Math.max(...numbers))] : undefined,
101
+ earlier: prior?.at ?? (lost === Number.NEGATIVE_INFINITY ? undefined : lost),
102
+ };
103
+ };
104
+
105
+ /**
106
+ * How readily a track gives way when the record is full: one that holds no change first, then
107
+ * one that keeps changing, one never seen in view and one that has gone; news never does.
108
+ */
109
+ const rank = ({ samples: [only, ...more] }: Track): number =>
110
+ only === undefined
111
+ ? 4
112
+ : more.length > 0
113
+ ? 3
114
+ : only.seen === false
115
+ ? 2
116
+ : only.shown === null
117
+ ? 1
118
+ : 0;
119
+
120
+ return { number, over, rank };
121
+ };
122
+
123
+ export type Fold = ReturnType<typeof fold>;
@@ -0,0 +1,229 @@
1
+ /**
2
+ * In the page: a document's record of what changed, element by element, within fixed bounds. It
3
+ * touches no DOM, so it also runs outside a page.
4
+ *
5
+ * - **A change** is a new state: other words, or presence gained or lost. Within one batch of
6
+ * mutations the latest state stands, so an element moved in one step has not changed.
7
+ * - **Bounds.** 256 elements, each with its latest 32 changes, kept a minute. On a busier page an
8
+ * element that keeps changing gives way first, then one never seen in view, then one that has
9
+ * gone, so news stays. What gives way, and what finds no room, is counted, and the record says it
10
+ * is whole only after the latest such loss.
11
+ *
12
+ * Each element's history is folded over a window by `fold.inpage.ts`. See `names.inpage.ts` for
13
+ * what a page-side part may use.
14
+ */
15
+ import type { Fold, Sample, Shown, Track } from "./fold.inpage.ts";
16
+
17
+ /** How much the record keeps: elements, changes of each, and for how long. */
18
+ export interface Bounds {
19
+ readonly tracks: number;
20
+ readonly samples: number;
21
+ readonly retention: number;
22
+ }
23
+
24
+ export const history = (
25
+ folding: Fold,
26
+ bounds: Bounds = { tracks: 256, samples: 32, retention: 60_000 },
27
+ ) => {
28
+ const { tracks: maxTracks, samples: maxSamples, retention } = bounds;
29
+ const tracks = new Map<object, Track>();
30
+ // Keys that gave way or found no room: their later changes are counted, not kept.
31
+ let refused = new WeakSet<object>();
32
+ // When the record let changes go, and how many, and the latest such time; keys whose tracks went.
33
+ let losses: Array<[number, number]> = [];
34
+ let lastLoss = Number.NEGATIVE_INFINITY;
35
+ const gone: Array<object> = [];
36
+ let begun: number | undefined;
37
+
38
+ // Losses at one time count together. Beyond 256 times the first noted go, so a count falls short
39
+ // rather than over.
40
+ const lose = (at: number, count: number) => {
41
+ const last = losses.at(-1);
42
+
43
+ if (count <= 0) return;
44
+ lastLoss = Math.max(lastLoss, at);
45
+ if (last?.[0] === at) last[1] += count;
46
+ else losses.push([at, count]);
47
+ if (losses.length > 256) losses.shift();
48
+ };
49
+
50
+ const latest = (track: Track) => track.samples.at(-1)?.at ?? Number.NEGATIVE_INFINITY;
51
+
52
+ const drop = (track: Track) => {
53
+ tracks.delete(track.key);
54
+ gone.push(track.key);
55
+ };
56
+
57
+ // How often the tracks' samples have changed, and when a search found none to give way, which
58
+ // holds until they change again, so a burst of newcomers pays for one search.
59
+ let version = 0;
60
+ let barren = -1;
61
+
62
+ /** What gives way for a newcomer: one that holds no change, then one that keeps changing. */
63
+ const victim = (): Track | undefined => {
64
+ let chosen: Track | undefined;
65
+ let rank = 0;
66
+
67
+ for (const track of barren === version ? [] : tracks.values()) {
68
+ const next = folding.rank(track);
69
+
70
+ if (next > rank) [chosen, rank] = [track, next];
71
+ if (rank === 4) break;
72
+ }
73
+ if (chosen === undefined) barren = version;
74
+
75
+ return chosen;
76
+ };
77
+
78
+ const evict = (track: Track) => {
79
+ drop(track);
80
+ if (track.samples.length > 0) refused.add(track.key);
81
+ for (const sample of track.samples) lose(sample.at, 1);
82
+ };
83
+
84
+ /** A key the record cannot keep: its change is counted. */
85
+ const refuse = (key: object, at: number): undefined => {
86
+ refused.add(key);
87
+ lose(at, 1);
88
+
89
+ return undefined;
90
+ };
91
+
92
+ /**
93
+ * A new state for `key` at `at`: its sample, or none when it is no change or finds no room.
94
+ * `initial` says what a key not yet kept showed before.
95
+ */
96
+ const note = (
97
+ key: object,
98
+ kind: Track["kind"],
99
+ at: number,
100
+ shown: Shown,
101
+ initial: () => Shown,
102
+ ): Sample | undefined => {
103
+ let track = tracks.get(key);
104
+
105
+ if (track === undefined) {
106
+ const full = !refused.has(key) && tracks.size >= maxTracks;
107
+ const out = full ? victim() : undefined;
108
+
109
+ if (refused.has(key) || (full && out === undefined)) return refuse(key, at);
110
+ const before = initial();
111
+
112
+ if (shown !== undefined && shown === before) return undefined;
113
+ if (out !== undefined) evict(out);
114
+ track = { key, kind, initial: before, known: Number.NEGATIVE_INFINITY, samples: [] };
115
+ tracks.set(key, track);
116
+ }
117
+ const { samples } = track;
118
+
119
+ version++;
120
+
121
+ if (samples.at(-1)?.at === at) samples.pop();
122
+ const previous = samples.length === 0 ? track.initial : samples.at(-1)?.shown;
123
+
124
+ if (shown !== undefined && shown === previous) return undefined;
125
+ const oldest = samples.length >= maxSamples ? samples.shift() : undefined;
126
+
127
+ if (oldest !== undefined) {
128
+ track.initial = oldest.shown;
129
+ track.known = oldest.at;
130
+ lose(oldest.at, 1);
131
+ }
132
+ const sample: Sample = { at, shown, seen: undefined };
133
+
134
+ samples.push(sample);
135
+
136
+ return sample;
137
+ };
138
+
139
+ /** Judge a sample as the page rendered it; a state that repeats the one before is no change. */
140
+ const settle = (key: object, sample: Sample, shown: Shown, seen: boolean) => {
141
+ const track = tracks.get(key);
142
+
143
+ // `null` is a state, what shows nothing, so only `undefined` is unjudged.
144
+ version++;
145
+ if (sample.shown === undefined) sample.shown = shown;
146
+ if (sample.seen === undefined) sample.seen = seen;
147
+ if (track === undefined) return;
148
+ let previous = track.initial;
149
+
150
+ const changes = track.samples.filter((each) => {
151
+ const same = each.shown !== undefined && each.shown === previous;
152
+
153
+ if (!same) previous = each.shown;
154
+
155
+ return !same;
156
+ });
157
+
158
+ track.samples.splice(0, track.samples.length, ...changes);
159
+ };
160
+
161
+ /**
162
+ * The window after `since`, up to `until`: where the record is whole, how many changes in it the
163
+ * record let go, and each element's changes, oldest first by when they began.
164
+ */
165
+ const read = (since: number, until: number, now: number) => {
166
+ for (const track of tracks.values()) if (latest(track) < now - retention) drop(track);
167
+
168
+ return {
169
+ from: Math.min(until, Math.max(begun ?? now, now - retention, lastLoss)),
170
+ dropped: losses.reduce(
171
+ (sum, [at, count]) => (at > since && at <= until ? sum + count : sum),
172
+ 0,
173
+ ),
174
+ folded: [...tracks.values()]
175
+ .flatMap((track) => folding.over(track, since, until) ?? [])
176
+ .toSorted((left, right) => left.startedAt - right.startedAt),
177
+ };
178
+ };
179
+
180
+ /** When something in view last changed, or something not yet judged; for a wait for stillness. */
181
+ const changing = (): number =>
182
+ Math.max(
183
+ Number.NEGATIVE_INFINITY,
184
+ ...Array.from(
185
+ tracks.values(),
186
+ (track) =>
187
+ track.samples.findLast((sample) => sample.seen !== false)?.at ?? Number.NEGATIVE_INFINITY,
188
+ ),
189
+ );
190
+
191
+ return {
192
+ begin: (at: number) => {
193
+ begun = at;
194
+ },
195
+ begun: () => begun !== undefined,
196
+ clear: () => {
197
+ for (const track of tracks.values()) drop(track);
198
+ refused = new WeakSet();
199
+ losses = [];
200
+ lastLoss = Number.NEGATIVE_INFINITY;
201
+ begun = undefined;
202
+ },
203
+ changing,
204
+ /** Keys whose tracks went since this was last asked, for the recorder to stop watching. */
205
+ forgotten: (): ReadonlyArray<object> => gone.splice(0),
206
+ has: (key: object) => tracks.has(key),
207
+ /** Whether the record has let `key` go: its later changes are counted, never kept. */
208
+ refuses: (key: object) => refused.has(key),
209
+ lose,
210
+ note,
211
+ read,
212
+ /** Each element's history, and when the record let changes go, for a read to judge causes. */
213
+ recent: () => ({ tracks: [...tracks.values()], losses: losses.map(([at]) => at) }),
214
+ /** A track moves to the element that took its key's place, as a re-rendered price does. */
215
+ rekey: (from: object, to: object): boolean => {
216
+ const track = tracks.get(from);
217
+
218
+ if (track === undefined || tracks.has(to)) return false;
219
+ tracks.delete(from);
220
+ track.key = to;
221
+ tracks.set(to, track);
222
+
223
+ return true;
224
+ },
225
+ settle,
226
+ };
227
+ };
228
+
229
+ export type History = ReturnType<typeof history>;