@loomwork/ui-changes 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/NOTICE +8 -0
- package/README.md +134 -2
- package/dist/conformance.d.ts +18 -0
- package/dist/conformance.d.ts.map +1 -0
- package/dist/conformance.js +243 -0
- package/dist/conformance.js.map +1 -0
- package/dist/cursor.d.ts +43 -0
- package/dist/cursor.d.ts.map +1 -0
- package/dist/cursor.js +50 -0
- package/dist/cursor.js.map +1 -0
- package/dist/esm/conformance.js +239 -0
- package/dist/esm/conformance.js.map +1 -0
- package/dist/esm/cursor.js +44 -0
- package/dist/esm/cursor.js.map +1 -0
- package/dist/esm/feed.js +301 -0
- package/dist/esm/feed.js.map +1 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/signal.js +141 -0
- package/dist/esm/signal.js.map +1 -0
- package/dist/esm/untrusted.js +14 -0
- package/dist/esm/untrusted.js.map +1 -0
- package/dist/feed.d.ts +125 -0
- package/dist/feed.d.ts.map +1 -0
- package/dist/feed.js +305 -0
- package/dist/feed.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/signal.d.ts +33 -0
- package/dist/signal.d.ts.map +1 -0
- package/dist/signal.js +145 -0
- package/dist/signal.js.map +1 -0
- package/dist/untrusted.d.ts +5 -0
- package/dist/untrusted.d.ts.map +1 -0
- package/dist/untrusted.js +17 -0
- package/dist/untrusted.js.map +1 -0
- package/package.json +60 -3
- package/src/conformance.ts +248 -0
- package/src/cursor.ts +89 -0
- package/src/feed.ts +446 -0
- package/src/index.ts +18 -0
- package/src/signal.ts +200 -0
- package/src/untrusted.ts +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loomwork/ui-changes",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Ambient change delivery for loom-ui: a ChangeFeed over lineage events (resume from a cursor, at-least-once, idempotent ids), viewer-safe change descriptions, a per-viewer unread cursor seam, and polling / push head signals",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"module": "./dist/esm/index.js",
|
|
8
|
+
"sideEffects": false,
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"node": "./dist/index.js",
|
|
13
|
+
"import": "./dist/esm/index.js",
|
|
14
|
+
"default": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@loomwork/classify": "^0.1.0",
|
|
19
|
+
"@loomwork/event-runtime": "^0.1.0",
|
|
20
|
+
"@loomwork/ui-core": "^0.1.1",
|
|
21
|
+
"@loomwork/ui-lineage": "^0.1.1"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@loomwork/plugin-content": "^0.1.0",
|
|
25
|
+
"@loomwork/plugin-crypto": "^0.1.0",
|
|
26
|
+
"@types/node": "^22.0.0",
|
|
27
|
+
"typescript": "^5.6.3",
|
|
28
|
+
"vitest": "^2.1.8"
|
|
29
|
+
},
|
|
30
|
+
"license": "Apache-2.0",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/chhetrisushil/loom-ui.git",
|
|
34
|
+
"directory": "packages/changes"
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"src",
|
|
39
|
+
"!src/**/*.test.ts",
|
|
40
|
+
"!src/**/*.test.tsx",
|
|
41
|
+
"!src/**/*.test-support.ts",
|
|
42
|
+
"!src/**/__tests__",
|
|
43
|
+
"!src/**/__fixtures__",
|
|
44
|
+
"!src/**/__snapshots__",
|
|
45
|
+
"!src/**/__golden__",
|
|
46
|
+
"LICENSE",
|
|
47
|
+
"NOTICE"
|
|
48
|
+
],
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public",
|
|
51
|
+
"tag": "next"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=20.0.0"
|
|
55
|
+
},
|
|
56
|
+
"scripts": {
|
|
57
|
+
"build": "tsc --build && rm -rf dist/esm && tsc -p tsconfig.esm.json",
|
|
58
|
+
"typecheck": "tsc --noEmit",
|
|
59
|
+
"typecheck:tests": "tsc -p tsconfig.test.json",
|
|
60
|
+
"test": "vitest run",
|
|
61
|
+
"clean": "rm -rf dist tsconfig.tsbuildinfo"
|
|
62
|
+
}
|
|
6
63
|
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { Finding } from "@loomwork/ui-core";
|
|
2
|
+
import type { UnreadStore } from "./cursor";
|
|
3
|
+
import type { ChangeEvent, ChangeFeed } from "./feed";
|
|
4
|
+
|
|
5
|
+
// ── Conformance kits ──────────────────────────────────────────────────────────
|
|
6
|
+
// Findings, never throws (a feed or store that throws on a case is reported as a finding), like
|
|
7
|
+
// `checkLineageStoreConformance`.
|
|
8
|
+
|
|
9
|
+
export interface FeedHarness {
|
|
10
|
+
readonly feed: ChangeFeed;
|
|
11
|
+
/** Append one new version to `experienceId`'s line (the first call starts the line). */
|
|
12
|
+
compose(experienceId: string): Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* Force one wake of the feed's signal, for a deterministic harness (a manual-tick signal). Omit
|
|
15
|
+
* for a real timer/push signal: the kit then waits (up to `waitMs`) for delivery.
|
|
16
|
+
*/
|
|
17
|
+
tick?(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
21
|
+
|
|
22
|
+
async function until(cond: () => boolean, tick: (() => Promise<void>) | undefined, waitMs: number) {
|
|
23
|
+
const end = Date.now() + waitMs;
|
|
24
|
+
for (;;) {
|
|
25
|
+
if (tick !== undefined) await tick();
|
|
26
|
+
if (cond()) return true;
|
|
27
|
+
if (Date.now() > end) return false;
|
|
28
|
+
await sleep(5);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function checkChangeFeedConformance(
|
|
33
|
+
make: () => FeedHarness | Promise<FeedHarness>,
|
|
34
|
+
opts: { waitMs?: number } = {}
|
|
35
|
+
): Promise<Finding[]> {
|
|
36
|
+
const findings: Finding[] = [];
|
|
37
|
+
const fail = (code: string, message: string) =>
|
|
38
|
+
findings.push({ code, severity: "error", message });
|
|
39
|
+
const waitMs = opts.waitMs ?? 1500;
|
|
40
|
+
const h = await make();
|
|
41
|
+
const read = async (id: string, o?: { after?: number; limit?: number }) => {
|
|
42
|
+
const r = await h.feed.read(id, o);
|
|
43
|
+
if (!r.ok) throw new Error(`read failed: ${r.finding.message}`);
|
|
44
|
+
return r;
|
|
45
|
+
};
|
|
46
|
+
const cases: Array<[string, () => Promise<void>]> = [
|
|
47
|
+
[
|
|
48
|
+
"an unknown line reads empty with tip 0",
|
|
49
|
+
async () => {
|
|
50
|
+
const r = await read("f-empty");
|
|
51
|
+
if (r.events.length !== 0 || r.cursor !== 0 || r.tip !== 0 || r.more)
|
|
52
|
+
fail("read-empty", `an unknown line read ${JSON.stringify(r)}`);
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
[
|
|
56
|
+
"events are dense, ordered, with unique ids that are stable across reads",
|
|
57
|
+
async () => {
|
|
58
|
+
for (let i = 0; i < 3; i++) await h.compose("f-ids");
|
|
59
|
+
const a = await read("f-ids");
|
|
60
|
+
const b = await read("f-ids");
|
|
61
|
+
if (a.events.map((e) => e.sequence).join() !== "1,2,3")
|
|
62
|
+
fail("sequence", `sequences ${a.events.map((e) => e.sequence).join()} are not 1,2,3`);
|
|
63
|
+
if (new Set(a.events.map((e) => e.id)).size !== a.events.length)
|
|
64
|
+
fail("ids-unique", "two events share an id");
|
|
65
|
+
if (a.events.map((e) => e.id).join() !== b.events.map((e) => e.id).join())
|
|
66
|
+
fail("ids-stable", "re-reading the same range changed an id");
|
|
67
|
+
if (a.cursor !== 3 || a.tip !== 3) fail("cursor", `cursor/tip were ${a.cursor}/${a.tip}`);
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
[
|
|
71
|
+
"reading after a cursor returns only later events",
|
|
72
|
+
async () => {
|
|
73
|
+
for (let i = 0; i < 3; i++) await h.compose("f-resume");
|
|
74
|
+
const r = await read("f-resume", { after: 1 });
|
|
75
|
+
if (r.events.map((e) => e.sequence).join() !== "2,3")
|
|
76
|
+
fail("resume", `after 1 returned ${r.events.map((e) => e.sequence).join()}`);
|
|
77
|
+
const none = await read("f-resume", { after: 3 });
|
|
78
|
+
if (none.events.length !== 0 || none.cursor !== 3)
|
|
79
|
+
fail("resume-end", `after the tip returned ${JSON.stringify(none)}`);
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
[
|
|
83
|
+
"paging by limit visits every event once, in order",
|
|
84
|
+
async () => {
|
|
85
|
+
for (let i = 0; i < 5; i++) await h.compose("f-page");
|
|
86
|
+
const seen: number[] = [];
|
|
87
|
+
let cursor = 0;
|
|
88
|
+
for (let guard = 0; guard < 10; guard++) {
|
|
89
|
+
const r = await read("f-page", { after: cursor, limit: 2 });
|
|
90
|
+
if (r.events.length > 2) fail("limit", `limit 2 returned ${r.events.length}`);
|
|
91
|
+
seen.push(...r.events.map((e) => e.sequence));
|
|
92
|
+
cursor = r.cursor;
|
|
93
|
+
if (!r.more) break;
|
|
94
|
+
}
|
|
95
|
+
if (seen.join() !== "1,2,3,4,5") fail("paging", `paging saw ${seen.join()}`);
|
|
96
|
+
const bad = await h.feed.read("f-page", { after: -1 });
|
|
97
|
+
if (bad.ok) fail("cursor-invalid", "a negative cursor was accepted");
|
|
98
|
+
const badLimit = await h.feed.read("f-page", { limit: 0 });
|
|
99
|
+
if (badLimit.ok) fail("limit-invalid", "a zero limit was accepted");
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
[
|
|
103
|
+
"subscribe catches up from a cursor, then follows live, in order",
|
|
104
|
+
async () => {
|
|
105
|
+
await h.compose("f-sub");
|
|
106
|
+
await h.compose("f-sub");
|
|
107
|
+
const got: ChangeEvent[] = [];
|
|
108
|
+
const off = h.feed.subscribe("f-sub", (e) => void got.push(e), { after: 1 });
|
|
109
|
+
const caught = await until(() => got.length >= 1, h.tick, waitMs);
|
|
110
|
+
if (!caught || got[0]?.sequence !== 2)
|
|
111
|
+
fail("sub-catchup", `catch-up from 1 delivered ${got.map((e) => e.sequence).join()}`);
|
|
112
|
+
await h.compose("f-sub");
|
|
113
|
+
const live = await until(() => got.length >= 2, h.tick, waitMs);
|
|
114
|
+
if (!live || got.map((e) => e.sequence).join() !== "2,3")
|
|
115
|
+
fail("sub-live", `live delivery gave ${got.map((e) => e.sequence).join()}`);
|
|
116
|
+
off();
|
|
117
|
+
await h.compose("f-sub");
|
|
118
|
+
await sleep(40);
|
|
119
|
+
if (h.tick) await h.tick();
|
|
120
|
+
await sleep(20);
|
|
121
|
+
if (got.length !== 2) fail("sub-off", "an event was delivered after unsubscribe");
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
[
|
|
125
|
+
"resubscribing from an older cursor redelivers the SAME ids (at-least-once, idempotent)",
|
|
126
|
+
async () => {
|
|
127
|
+
await h.compose("f-redo");
|
|
128
|
+
await h.compose("f-redo");
|
|
129
|
+
const first: string[] = [];
|
|
130
|
+
const offA = h.feed.subscribe("f-redo", (e) => void first.push(e.id));
|
|
131
|
+
await until(() => first.length >= 2, h.tick, waitMs);
|
|
132
|
+
offA();
|
|
133
|
+
const again: string[] = [];
|
|
134
|
+
const offB = h.feed.subscribe("f-redo", (e) => void again.push(e.id), { after: 0 });
|
|
135
|
+
await until(() => again.length >= 2, h.tick, waitMs);
|
|
136
|
+
offB();
|
|
137
|
+
if (first.join() !== again.join() || first.length !== 2)
|
|
138
|
+
fail("redelivery", `first ${first.join()} vs again ${again.join()}`);
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
[
|
|
142
|
+
"a throwing handler is reported, not advanced past, and the event is delivered again",
|
|
143
|
+
async () => {
|
|
144
|
+
await h.compose("f-throw");
|
|
145
|
+
const errors: unknown[] = [];
|
|
146
|
+
const ids: string[] = [];
|
|
147
|
+
let attempts = 0;
|
|
148
|
+
const off = h.feed.subscribe(
|
|
149
|
+
"f-throw",
|
|
150
|
+
(e) => {
|
|
151
|
+
attempts += 1;
|
|
152
|
+
if (attempts === 1) throw new Error("handler down");
|
|
153
|
+
ids.push(e.id);
|
|
154
|
+
},
|
|
155
|
+
{ onError: (e) => void errors.push(e), retry: { baseMs: 5, maxMs: 20 } }
|
|
156
|
+
);
|
|
157
|
+
await until(() => errors.length >= 1, h.tick, waitMs);
|
|
158
|
+
if (errors.length < 1) fail("handler-error", "a throwing handler was not reported");
|
|
159
|
+
// NO append and NO signal from here: a quiet line must still redeliver the failed event
|
|
160
|
+
// (a feed that retries only when the head moves leaves it stuck).
|
|
161
|
+
await until(() => ids.length >= 1, undefined, waitMs);
|
|
162
|
+
off();
|
|
163
|
+
if (ids.join() !== "f-throw#1")
|
|
164
|
+
fail("handler-retry", `after a throw on a quiet line the handler saw ${ids.join()}`);
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
];
|
|
168
|
+
for (const [name, run] of cases) {
|
|
169
|
+
try {
|
|
170
|
+
await run();
|
|
171
|
+
} catch (e) {
|
|
172
|
+
fail("feed-threw", `${name}: ${e instanceof Error ? e.message : String(e)}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return findings;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function checkUnreadStoreConformance(
|
|
179
|
+
make: () => UnreadStore | Promise<UnreadStore>
|
|
180
|
+
): Promise<Finding[]> {
|
|
181
|
+
const findings: Finding[] = [];
|
|
182
|
+
const fail = (code: string, message: string) =>
|
|
183
|
+
findings.push({ code, severity: "error", message });
|
|
184
|
+
const s = await make();
|
|
185
|
+
const cases: Array<[string, () => Promise<void>]> = [
|
|
186
|
+
[
|
|
187
|
+
"an unseen (viewer, line) is 0",
|
|
188
|
+
async () => {
|
|
189
|
+
if ((await s.get("u-none", "e")) !== 0) fail("default", "an unseen cursor was not 0");
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
[
|
|
193
|
+
"advance moves forward, never back, and is idempotent",
|
|
194
|
+
async () => {
|
|
195
|
+
const a = await s.advance("u-fwd", "e", 5);
|
|
196
|
+
const b = await s.advance("u-fwd", "e", 3);
|
|
197
|
+
const c = await s.advance("u-fwd", "e", 5);
|
|
198
|
+
if (a !== 5 || b !== 5 || c !== 5 || (await s.get("u-fwd", "e")) !== 5)
|
|
199
|
+
fail("monotone", `advance returned ${[a, b, c].join()}`);
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
[
|
|
203
|
+
"invalid positions change nothing",
|
|
204
|
+
async () => {
|
|
205
|
+
await s.advance("u-bad", "e", 2);
|
|
206
|
+
for (const bad of [
|
|
207
|
+
-1,
|
|
208
|
+
1.5,
|
|
209
|
+
Number.NaN,
|
|
210
|
+
Number.POSITIVE_INFINITY,
|
|
211
|
+
"9" as unknown as number,
|
|
212
|
+
]) {
|
|
213
|
+
const r = await s.advance("u-bad", "e", bad);
|
|
214
|
+
if (r !== 2) fail("invalid", `advance(${String(bad)}) returned ${r}`);
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
],
|
|
218
|
+
[
|
|
219
|
+
"cursors are per viewer and per line, with no id collisions",
|
|
220
|
+
async () => {
|
|
221
|
+
await s.advance("u-a", "e1", 4);
|
|
222
|
+
await s.advance("u-c", "a:b", 9);
|
|
223
|
+
if ((await s.get("u-c:a", "b")) !== 0)
|
|
224
|
+
fail("collision", "ids that join to the same string share a cursor");
|
|
225
|
+
if ((await s.get("u-a", "e2")) !== 0) fail("per-line", "another line shared a cursor");
|
|
226
|
+
if ((await s.get("u-b", "e1")) !== 0) fail("per-viewer", "another viewer shared a cursor");
|
|
227
|
+
if ((await s.get("u-a", "e1")) !== 4)
|
|
228
|
+
fail("collision", "a joined-looking id disturbed a cursor");
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
[
|
|
232
|
+
"concurrent advances land on the maximum",
|
|
233
|
+
async () => {
|
|
234
|
+
await Promise.all([1, 7, 3, 5, 2].map((n) => s.advance("u-race", "e", n)));
|
|
235
|
+
if ((await s.get("u-race", "e")) !== 7)
|
|
236
|
+
fail("race", "concurrent advances did not end at the max");
|
|
237
|
+
},
|
|
238
|
+
],
|
|
239
|
+
];
|
|
240
|
+
for (const [name, run] of cases) {
|
|
241
|
+
try {
|
|
242
|
+
await run();
|
|
243
|
+
} catch (e) {
|
|
244
|
+
fail("store-threw", `${name}: ${e instanceof Error ? e.message : String(e)}`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return findings;
|
|
248
|
+
}
|
package/src/cursor.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Finding } from "@loomwork/ui-core";
|
|
2
|
+
import type { ChangeEvent, ChangeFeed } from "./feed";
|
|
3
|
+
|
|
4
|
+
// ── Unread: what a viewer has not yet seen ────────────────────────────────────
|
|
5
|
+
// A cursor per (viewer, experience): the last feed `sequence` the viewer has acknowledged. Unread
|
|
6
|
+
// is everything after it. The cursor only moves FORWARD and is clamped to the log's tip, so a
|
|
7
|
+
// replayed acknowledgement is harmless (idempotent) and a viewer cannot pre-read the future.
|
|
8
|
+
//
|
|
9
|
+
// `viewerId` is the caller's claim: this package performs no authentication or authorization (the
|
|
10
|
+
// host resolves who the viewer is, and what they may see, before it calls here).
|
|
11
|
+
|
|
12
|
+
export interface UnreadStore {
|
|
13
|
+
/** The viewer's cursor for the line; 0 when none was ever recorded. */
|
|
14
|
+
get(viewerId: string, experienceId: string): Promise<number>;
|
|
15
|
+
/**
|
|
16
|
+
* Move the cursor to `max(current, to)` and return the result. A `to` that is not a non-negative
|
|
17
|
+
* safe integer changes nothing. Atomic against concurrent advances: the cursor never goes back.
|
|
18
|
+
*/
|
|
19
|
+
advance(viewerId: string, experienceId: string, to: number): Promise<number>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class InMemoryUnreadStore implements UnreadStore {
|
|
23
|
+
// Nested maps, not a joined key: no separator a viewer or experience id could contain.
|
|
24
|
+
private readonly cursors = new Map<string, Map<string, number>>();
|
|
25
|
+
|
|
26
|
+
async get(viewerId: string, experienceId: string): Promise<number> {
|
|
27
|
+
return this.cursors.get(viewerId)?.get(experienceId) ?? 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async advance(viewerId: string, experienceId: string, to: number): Promise<number> {
|
|
31
|
+
const current = this.cursors.get(viewerId)?.get(experienceId) ?? 0;
|
|
32
|
+
if (!Number.isSafeInteger(to) || to <= current) return current;
|
|
33
|
+
let mine = this.cursors.get(viewerId);
|
|
34
|
+
if (mine === undefined) {
|
|
35
|
+
mine = new Map();
|
|
36
|
+
this.cursors.set(viewerId, mine);
|
|
37
|
+
}
|
|
38
|
+
mine.set(experienceId, to);
|
|
39
|
+
return to;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type UnreadResult =
|
|
44
|
+
| {
|
|
45
|
+
readonly ok: true;
|
|
46
|
+
readonly events: readonly ChangeEvent[];
|
|
47
|
+
/** The viewer's cursor when read. */
|
|
48
|
+
readonly cursor: number;
|
|
49
|
+
/** More unread events exist past this page. */
|
|
50
|
+
readonly more: boolean;
|
|
51
|
+
}
|
|
52
|
+
| { readonly ok: false; readonly finding: Finding };
|
|
53
|
+
|
|
54
|
+
/** The events the viewer has not acknowledged, oldest first, one page. */
|
|
55
|
+
export async function unreadChanges(
|
|
56
|
+
feed: ChangeFeed,
|
|
57
|
+
store: UnreadStore,
|
|
58
|
+
viewerId: string,
|
|
59
|
+
experienceId: string,
|
|
60
|
+
opts: { limit?: number } = {}
|
|
61
|
+
): Promise<UnreadResult> {
|
|
62
|
+
const cursor = await store.get(viewerId, experienceId);
|
|
63
|
+
const r = await feed.read(experienceId, {
|
|
64
|
+
after: cursor,
|
|
65
|
+
...(opts.limit !== undefined && { limit: opts.limit }),
|
|
66
|
+
});
|
|
67
|
+
return r.ok ? { ok: true, events: r.events, cursor, more: r.more } : r;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Acknowledge everything through `through` (clamped to the log's tip). Returns the new cursor, or
|
|
72
|
+
* the finding when the line could not be read (then nothing moves).
|
|
73
|
+
*/
|
|
74
|
+
export async function markRead(
|
|
75
|
+
feed: ChangeFeed,
|
|
76
|
+
store: UnreadStore,
|
|
77
|
+
viewerId: string,
|
|
78
|
+
experienceId: string,
|
|
79
|
+
through: number
|
|
80
|
+
): Promise<{ ok: true; cursor: number } | { ok: false; finding: Finding }> {
|
|
81
|
+
const r = await feed.read(experienceId, { limit: 1, after: Number.MAX_SAFE_INTEGER });
|
|
82
|
+
if (!r.ok) return r;
|
|
83
|
+
if (!Number.isSafeInteger(through) || through < 0)
|
|
84
|
+
return { ok: true, cursor: await store.get(viewerId, experienceId) };
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
cursor: await store.advance(viewerId, experienceId, Math.min(through, r.tip)),
|
|
88
|
+
};
|
|
89
|
+
}
|