agentic-comments 0.1.0 → 0.3.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/index.js CHANGED
@@ -1,749 +1,210 @@
1
- // src/version.ts
2
- import { createRequire } from "module";
3
- var VERSION = true ? "0.1.0" : readPackageVersion();
4
-
5
- // src/threads.ts
6
- import * as Y2 from "yjs";
1
+ import {
2
+ VERSION
3
+ } from "./chunk-GMA5I5L6.js";
4
+ import {
5
+ ACCEPT_SCOPE,
6
+ Actor,
7
+ Comments,
8
+ CommentsError,
9
+ PRINCIPAL_KINDS,
10
+ Suggestions,
11
+ authorLabel,
12
+ readAuthor,
13
+ stamp,
14
+ textAnchor,
15
+ textOf,
16
+ validDate
17
+ } from "./chunk-GSAJOCDV.js";
7
18
 
8
- // src/anchor.ts
19
+ // src/notifications.ts
9
20
  import * as Y from "yjs";
10
-
11
- // src/errors.ts
12
- var CommentsError = class extends Error {
13
- constructor(code, message) {
14
- super(message);
15
- this.code = code;
16
- this.name = "CommentsError";
17
- }
18
- code;
19
- };
20
-
21
- // src/text.ts
22
- var HIDDEN = /[\p{Cc}\p{Cf}]/u;
23
- var ID = /^[\w-]{1,64}$/;
24
- var BODY_FORBIDDEN = /(?![\n\r\t\u200C\u200D])[\p{Cc}\p{Cf}]/u;
25
- var VARIATION = /[\uFE00-\uFE0F\u{E0100}-\u{E01EF}]/u;
26
- var safeId = (value) => String(value).slice(0, 64).replace(/[^\p{L}\p{N}_-]/gu, "?");
27
- var shown = (value) => JSON.stringify(safeId(value));
28
- var safeText = (value) => String(value).replace(/[^\p{L}\p{N}\p{P}\p{S} ]/gu, "?").slice(0, 200);
29
-
30
- // src/anchor.ts
31
- var MAX_QUOTE = 2e3;
32
- var MAX_ANCHOR = 4096;
33
- var MAX_KEY = 256;
34
- var bad = (message) => {
35
- throw new CommentsError("INVALID_ANCHOR", message);
36
- };
37
- function key(raw, field) {
38
- if (typeof raw !== "string" || raw.trim() === "" || raw.length > MAX_KEY || HIDDEN.test(raw)) bad(`${field} must be text of 1 to ${MAX_KEY} characters, with no control or invisible characters`);
39
- return raw;
40
- }
41
- function id(raw, field) {
42
- const { client, clock } = raw ?? {};
43
- if (!Number.isSafeInteger(client) || !Number.isSafeInteger(clock) || client < 0 || clock < 0) {
44
- bad(`${field} must hold a client and a clock that are whole numbers of 0 or more`);
45
- }
46
- return { client, clock };
47
- }
48
- function position(raw, field) {
49
- if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return bad(`${field} must be a relative position`);
50
- const p = raw;
51
- const out = {};
52
- if (p.type !== void 0) out.type = id(p.type, `${field}.type`);
53
- if (p.item !== void 0) out.item = id(p.item, `${field}.item`);
54
- if (p.tname !== void 0) out.tname = key(p.tname, `${field}.tname`);
55
- if (p.assoc !== void 0) {
56
- if (p.assoc !== 0 && p.assoc !== -1) bad(`${field}.assoc must be 0 or -1`);
57
- out.assoc = p.assoc;
58
- }
59
- if (out.type === void 0 && out.item === void 0 && out.tname === void 0) bad(`${field} must name an item or a type`);
60
- return out;
61
- }
62
- var parseText = (raw) => {
63
- if (typeof raw.text !== "string" || raw.text === "" || raw.text.length > MAX_QUOTE) bad(`text must be 1 to ${MAX_QUOTE} characters`);
64
- const start = position(raw.start, "start");
65
- const end = position(raw.end, "end");
66
- const owner = (p) => JSON.stringify([p.type ?? null, p.tname ?? null]);
67
- if (start.item === void 0 || end.item === void 0) bad("a text range needs an item position at each end");
68
- if (start.type && start.tname || end.type && end.tname || owner(start) !== owner(end)) bad("both ends of a text range must name the same Yjs type");
69
- const anchor = { type: "text", text: raw.text, start, end };
70
- if (JSON.stringify(anchor).length > MAX_ANCHOR) bad(`an anchor holds up to ${MAX_ANCHOR} characters of JSON, so the quoted text is too long`);
71
- return anchor;
72
- };
73
- var BUILT_IN_KINDS = [
74
- { type: "text", parse: parseText },
75
- { type: "cell", parse: (raw) => ({ type: "cell", key: key(raw.key, "key") }) },
76
- { type: "slide", parse: (raw) => ({ type: "slide", elementId: key(raw.elementId, "elementId") }) },
77
- { type: "canvas", parse: (raw) => ({ type: "canvas", elementId: key(raw.elementId, "elementId") }) }
78
- ];
79
- function textOf(type) {
80
- return type.toDelta().map((d) => typeof d.insert === "string" ? d.insert : "\uFFFC").join("");
81
- }
82
- function textAnchor(type, from, to) {
83
- try {
84
- const value = textOf(type);
85
- if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to) || from < 0 || to <= from || to > value.length) {
86
- bad(`the range ${from}..${to} is not inside the text, which has ${value.length} characters`);
87
- }
88
- return parseText({
89
- text: value.slice(from, to),
90
- start: Y.relativePositionToJSON(Y.createRelativePositionFromTypeIndex(type, from, 0)),
91
- end: Y.relativePositionToJSON(Y.createRelativePositionFromTypeIndex(type, to, -1))
92
- });
93
- } catch (error) {
94
- if (error instanceof CommentsError) throw error;
95
- throw new CommentsError("INVALID_ANCHOR", `the text cannot be anchored: ${error instanceof Error ? error.message : String(error)}`);
96
- }
97
- }
98
- function place(json, ydoc) {
99
- try {
100
- return Y.createAbsolutePositionFromRelativePosition(Y.createRelativePositionFromJSON(json), ydoc);
101
- } catch {
102
- return null;
103
- }
104
- }
105
- function textRange(anchor, ydoc) {
106
- const from = place(anchor.start, ydoc);
107
- const to = place(anchor.end, ydoc);
108
- if (!from || !to || !(from.type instanceof Y.Text) || to.type !== from.type) return null;
109
- return { type: from.type, from: from.index, to: to.index };
110
- }
111
- function textState(anchor, ydoc) {
112
- const range = textRange(anchor, ydoc);
113
- if (!range || range.to <= range.from) return { state: "orphaned" };
114
- const current = textOf(range.type).slice(range.from, range.to);
115
- return current === anchor.text ? { state: "attached" } : { state: "edited", currentText: current.slice(0, MAX_QUOTE) };
116
- }
117
- function onlyOccurrence(type, quote) {
118
- const value = textOf(type);
119
- const first = value.indexOf(quote);
120
- return first >= 0 && value.indexOf(quote, first + 1) < 0 ? first : void 0;
121
- }
122
-
123
- // src/principal.ts
124
- var PRINCIPAL_KINDS = ["human", "agent_of_human", "agent_of_agent", "agent", "user_token"];
125
- var MAX_NAME = 128;
126
- var issued = /* @__PURE__ */ new WeakSet();
127
- var Actor = class _Actor {
128
- kind;
129
- name;
130
- scopes;
131
- constructor(kind, name, scopes) {
132
- this.kind = kind;
133
- this.name = name;
134
- this.scopes = Object.freeze([...scopes]);
135
- Object.freeze(this);
136
- }
137
- static of(input) {
138
- const author = readAuthor(input);
139
- const given = author ? input.scopes : void 0;
140
- const scopes = given === void 0 ? [] : Array.isArray(given) ? Array.from(given) : void 0;
141
- if (!author || !scopes || !scopes.every((s) => typeof s === "string" && s !== "")) {
142
- throw new CommentsError("INVALID_AUTHOR", `an author needs a kind (${PRINCIPAL_KINDS.join(", ")}), a name, and scopes that are a list of text`);
143
- }
144
- const actor = new _Actor(author.kind, author.name, scopes);
145
- issued.add(actor);
146
- return actor;
147
- }
148
- static is(value) {
149
- return typeof value === "object" && value !== null && issued.has(value);
150
- }
151
- };
152
- var stamp = (input) => Actor.of(input);
153
- function assertActor(value) {
154
- if (!Actor.is(value) || !readAuthor(value)) throw new CommentsError("INVALID_AUTHOR", "the host must pass an author made with stamp()");
155
- return value;
156
- }
157
- function readAuthor(value) {
158
- if (typeof value !== "object" || value === null) return void 0;
159
- const { kind, name } = value;
160
- if (!PRINCIPAL_KINDS.includes(kind)) return void 0;
161
- if (typeof name !== "string" || name === "" || name !== name.trim() || name.length > MAX_NAME || HIDDEN.test(name) || VARIATION.test(name) || name !== name.normalize("NFC")) return void 0;
162
- return { kind, name };
163
- }
164
- function authorLabel(author) {
165
- return author.kind === "human" ? "person" : author.kind === "user_token" ? "person via agent" : "agent";
166
- }
167
-
168
- // src/validate.ts
169
- var invalid = (message) => {
170
- throw new CommentsError("INVALID_INPUT", message);
171
- };
172
- function exact(input, allowed, what) {
173
- if (typeof input !== "object" || input === null || Array.isArray(input)) return invalid(`${what} must be an object`);
174
- const extra = Object.keys(input).filter((k) => !allowed.includes(k));
175
- if (extra.length > 0) invalid(`${what} does not accept ${extra.join(", ")}`);
176
- return input;
177
- }
178
- var ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
179
- var validDate = (value) => typeof value === "string" && ISO_TIME.test(value) && !Number.isNaN(Date.parse(value)) && new Date(value).toISOString() === value;
180
- var copy = (value) => JSON.parse(JSON.stringify(value));
181
- var strictCopy = (value) => JSON.parse(
182
- JSON.stringify(value, (_key, v) => {
183
- if (v === void 0 || typeof v === "number" && !Number.isFinite(v)) throw new Error("a value is not valid JSON data");
184
- return v;
185
- })
186
- );
187
-
188
- // src/threads.ts
189
- var THREADS = "comments.threads";
190
- var MAX_BODY = 1e4;
191
- var MAX_MENTIONS = 50;
192
- var MAX_MESSAGES = 1e3;
193
- var READ_LIMIT = 2 * MAX_MESSAGES;
194
- var STATUSES = ["open", "resolved"];
195
- var ANCHOR_STATES = ["attached", "edited", "orphaned", "unknown"];
196
- var HANDLE = /^[\p{L}\p{N}](?:[\p{L}\p{N}\p{M}_.-]{0,62}[\p{L}\p{N}\p{M}])?$/u;
197
- var MENTION = /(?<![\p{L}\p{N}_@.])@([\p{L}\p{N}][\p{L}\p{N}\p{M}_.-]*)(?![\p{L}\p{N}\p{M}_.-]|\/)/gu;
198
- function body(value) {
199
- if (typeof value !== "string" || value.trim() === "" || value.length > MAX_BODY || BODY_FORBIDDEN.test(value)) {
200
- invalid(`a body must be text of 1 to ${MAX_BODY} characters, with no control, invisible, or direction characters`);
201
- }
202
- return value;
203
- }
204
- var handle = (value) => value.normalize("NFC").replace(/^@+/, "").toLowerCase().normalize("NFC");
205
- var okHandle = (value) => HANDLE.test(value) && !VARIATION.test(value);
206
- var validHandle = (value) => typeof value === "string" && okHandle(value) && handle(value) === value;
207
- function mentionsOf(text, explicit) {
208
- const prose = text.replace(/`[^`]*`/g, " ");
209
- const found = [...prose.matchAll(MENTION)].map((m) => {
210
- const name = m[1].replace(/[._-]+$/, "");
211
- if (!okHandle(handle(name))) invalid(`"@${name.slice(0, 20)}" is not a valid handle: use up to 64 characters`);
212
- return handle(name);
213
- });
214
- let listed = [];
215
- if (explicit !== void 0) {
216
- const snapshot = Array.isArray(explicit) ? Array.from(explicit) : [];
217
- const ok = Array.isArray(explicit) && snapshot.length <= MAX_MENTIONS && snapshot.every((m) => typeof m === "string" && okHandle(handle(m)));
218
- if (!ok) invalid(`mentions must be a list of up to ${MAX_MENTIONS} handles of letters, digits, and . _ -`);
219
- listed = snapshot.map(handle);
220
- }
221
- const all = [.../* @__PURE__ */ new Set([...found, ...listed])];
222
- if (all.length > MAX_MENTIONS) invalid(`a message can mention up to ${MAX_MENTIONS} handles`);
223
- return all;
224
- }
225
- var Comments = class {
226
- /** Every replica of a document must register the same custom anchor kinds. Others read those threads as malformed. */
227
- constructor(ydoc, options = {}) {
228
- this.ydoc = ydoc;
229
- if (typeof options !== "object" || options === null || options.now !== void 0 && typeof options.now !== "function" || options.anchors !== void 0 && !Array.isArray(options.anchors) || options.exists !== void 0 && typeof options.exists !== "function") {
230
- invalid("options must be an object with an optional now function, anchors list, and exists function");
231
- }
232
- this.now = options.now ?? (() => /* @__PURE__ */ new Date());
233
- this.exists = options.exists;
234
- for (const kind of [...BUILT_IN_KINDS, ...options.anchors ?? []]) {
235
- if (typeof kind?.type !== "string" || kind.type === "" || typeof kind.parse !== "function" || this.kinds.has(kind.type)) {
236
- throw new CommentsError("INVALID_ANCHOR", `the anchor type "${String(kind?.type)}" is empty, has no parse, or is already defined`);
237
- }
238
- this.kinds.set(kind.type, kind);
239
- }
240
- }
241
- ydoc;
242
- kinds = /* @__PURE__ */ new Map();
243
- now;
244
- exists;
245
- get map() {
246
- return this.ydoc.getMap(THREADS);
247
- }
248
- /** The clock, as an ISO time. Used by `Suggestions`. */
249
- time() {
250
- const at = this.now();
251
- const text = at instanceof Date && !Number.isNaN(at.getTime()) ? at.toISOString() : "";
252
- if (!ISO_TIME.test(text)) invalid("the clock must return a date between the years 0000 and 9999");
253
- return text;
254
- }
255
- /** Whether the content under an anchor is still there. This never throws. */
256
- anchorState(anchor) {
257
- if (anchor.type === "text") return textState(anchor, this.ydoc);
258
- if (!this.exists) return { state: "unknown" };
259
- try {
260
- const answer = this.exists(anchor);
261
- return answer === true ? { state: "attached" } : answer === false ? { state: "orphaned" } : { state: "unknown" };
262
- } catch {
263
- return { state: "unknown" };
264
- }
265
- }
266
- /** Read an anchor from stored data. This does not check the document. */
267
- parseAnchor(raw) {
268
- const type = raw?.type;
269
- const kind = typeof raw === "object" && raw !== null && !Array.isArray(raw) && typeof type === "string" ? this.kinds.get(type) : void 0;
270
- if (!kind) throw new CommentsError("INVALID_ANCHOR", `an anchor needs a type: ${[...this.kinds.keys()].join(", ")}`);
271
- try {
272
- const parsed = copy(kind.parse(strictCopy(raw)));
273
- if (JSON.stringify(parsed).length > MAX_ANCHOR) throw new Error(`an anchor holds up to ${MAX_ANCHOR} characters of JSON`);
274
- if (parsed?.type !== kind.type) throw new Error(`the "${kind.type}" kind must return an anchor of its own type`);
275
- return parsed;
276
- } catch (error) {
277
- if (error instanceof CommentsError) throw error;
278
- throw new CommentsError("INVALID_ANCHOR", error instanceof Error ? error.message : String(error));
279
- }
280
- }
281
- /** Parse an anchor from input and check it against this document. Throws `INVALID_ANCHOR` when it fails. */
282
- checkAnchor(raw) {
283
- const anchor = this.parseAnchor(raw);
284
- if (JSON.stringify(this.parseAnchor(anchor)) !== JSON.stringify(anchor)) {
285
- throw new CommentsError("INVALID_ANCHOR", "an anchor kind must parse its own output to the same anchor");
286
- }
287
- this.checkInDocument(anchor);
288
- return anchor;
289
- }
290
- /** A text anchor must name text in this document, in order, and match its quote. */
291
- checkInDocument(anchor) {
292
- if (anchor.type !== "text") return;
293
- const at = (json) => {
294
- try {
295
- return Y2.createAbsolutePositionFromRelativePosition(Y2.createRelativePositionFromJSON(json), this.ydoc);
296
- } catch {
297
- return null;
298
- }
299
- };
300
- const from = at(anchor.start);
301
- const to = at(anchor.end);
302
- const unit = from?.type;
303
- if (!(unit instanceof Y2.Text) || !from || !to || to.type !== unit || to.index <= from.index || textOf(unit).slice(from.index, to.index) !== anchor.text) {
304
- throw new CommentsError("INVALID_ANCHOR", "a text anchor must name text in this document, in order, matching its quoted text");
305
- }
306
- }
307
- /** Build a message, and refuse it unless a read would show it back. Nothing is written yet. */
308
- message(text, explicit, actor) {
309
- const message = {
310
- id: globalThis.crypto.randomUUID(),
311
- at: this.time(),
312
- author: { kind: actor.kind, name: actor.name },
313
- body: text,
314
- mentions: mentionsOf(text, explicit)
315
- };
316
- try {
317
- return this.readMessage(copy(message));
318
- } catch (error) {
319
- return invalid(`the message cannot be stored: ${error.message}`);
320
- }
321
- }
322
- readMessage(raw) {
323
- const m = raw ?? {};
324
- const author = readAuthor(m.author);
325
- if (typeof m.id !== "string" || !ID.test(m.id) || !validDate(m.at) || !author || typeof m.body !== "string" || m.body.trim() === "" || m.body.length > MAX_BODY || BODY_FORBIDDEN.test(m.body)) {
326
- throw new Error("a message has no id, ISO time, valid author, or body");
327
- }
328
- if (!Array.isArray(m.mentions) || m.mentions.length > MAX_MENTIONS || !m.mentions.every(validHandle)) throw new Error("a message has bad mentions");
329
- return { id: m.id, at: m.at, author, body: m.body, mentions: [...m.mentions] };
330
- }
331
- /** A thread with a bad anchor or status is malformed. A bad message is skipped and named in `issues`. */
332
- read(id2, fields) {
333
- try {
334
- if (!ID.test(id2)) throw new Error("the thread id is not short and plain");
335
- if (!(fields instanceof Y2.Map)) throw new Error("the thread is not a map");
336
- const state = fields.get("state") ?? {};
337
- const by = readAuthor(state.by);
338
- if (!STATUSES.includes(state.status) || !by || !validDate(state.at)) throw new Error("the thread status is not valid");
339
- const list = fields.get("messages");
340
- if (!(list instanceof Y2.Array)) throw new Error("the thread has no message list");
341
- const messages = [];
342
- const issues = [];
343
- const raw = list.toArray();
344
- const counts = /* @__PURE__ */ new Map();
345
- for (const m of raw) counts.set(m?.id, (counts.get(m?.id) ?? 0) + 1);
346
- let past = 0;
347
- raw.forEach((entry, index) => {
348
- try {
349
- const message = this.readMessage(entry);
350
- if (counts.get(message.id) > 1) throw new Error("a message id repeats");
351
- if (messages.length >= READ_LIMIT) past++;
352
- else messages.push(message);
353
- } catch (error) {
354
- issues.push(`message ${index} skipped: ${safeText(error.message)}`);
355
- }
356
- });
357
- if (past > 0) issues.push(`${past} valid messages past the read limit are not shown`);
358
- if (messages.length === 0) throw new Error("the thread has no valid message");
359
- const anchor = this.parseAnchor(fields.get("anchor"));
360
- const thread = { id: id2, anchor, anchorState: this.anchorState(anchor), status: state.status, statusBy: by, statusAt: state.at, messages, skipped: raw.length - messages.length };
361
- return { thread, issues };
362
- } catch (error) {
363
- throw new CommentsError("CORRUPT", `thread ${shown(id2)} is malformed: ${safeText(error instanceof Error ? error.message : error)}`);
364
- }
365
- }
366
- fields(id2) {
367
- if (typeof id2 !== "string" || !ID.test(id2) || !this.map.has(id2)) throw new CommentsError("NO_THREAD", `no thread with id ${shown(id2)}`);
368
- return { id: id2, fields: this.map.get(id2) };
369
- }
370
- /** Start a thread with its first message. The anchor and body are checked before anything is written. */
371
- add(input, actor) {
372
- const who = assertActor(actor);
373
- const given = exact(input, ["anchor", "body", "mentions"], "a new thread");
374
- const anchor = this.checkAnchor(given.anchor);
375
- const first = this.message(body(given.body), given.mentions, who);
376
- const id2 = globalThis.crypto.randomUUID();
377
- this.ydoc.transact(() => {
378
- const fields = new Y2.Map();
379
- this.map.set(id2, fields);
380
- fields.set("anchor", anchor);
381
- fields.set("state", { status: "open", by: copy(first.author), at: first.at });
382
- const messages = new Y2.Array();
383
- fields.set("messages", messages);
384
- messages.push([copy(first)]);
385
- });
386
- return this.get(id2);
387
- }
388
- /**
389
- * Add a message. The resolved check runs on this replica only. A reply and a resolve made at once
390
- * on two replicas both stay, and the merged thread is resolved with the reply in it.
391
- */
392
- reply(threadId, input, actor) {
393
- const who = assertActor(actor);
394
- const given = exact(input, ["body", "mentions"], "a reply");
395
- const { id: id2, fields } = this.fields(threadId);
396
- const text = body(given.body);
397
- const { thread } = this.read(id2, fields);
398
- if (thread.status === "resolved") throw new CommentsError("THREAD_RESOLVED", `thread ${shown(id2)} is resolved; reopen it to reply`);
399
- if (thread.messages.length >= MAX_MESSAGES) invalid(`thread ${shown(id2)} shows ${MAX_MESSAGES} messages and takes no more`);
400
- const list = fields.get("messages");
401
- const message = this.message(text, given.mentions, who);
402
- list.push([copy(message)]);
403
- return message;
404
- }
405
- setStatus(threadId, status, actor) {
406
- const who = assertActor(actor);
407
- const { id: id2, fields } = this.fields(threadId);
408
- const { thread } = this.read(id2, fields);
409
- if (thread.status === status) return thread;
410
- fields.set("state", { status, by: { kind: who.kind, name: who.name }, at: this.time() });
411
- return this.get(id2);
412
- }
413
- /**
414
- * Move a text anchor whose range is gone or edited to its quote, when the quote occurs exactly once in
415
- * the same text. There is no fuzzy match. Throws `RELOCATE_FAILED` when it cannot say where. An attached
416
- * thread is returned as it is.
417
- */
418
- relocate(threadId, actor) {
419
- assertActor(actor);
420
- const { id: id2, fields } = this.fields(threadId);
421
- const { thread } = this.read(id2, fields);
422
- const anchor = thread.anchor;
423
- if (anchor.type !== "text") return invalid("only a text anchor can be relocated");
424
- if (thread.anchorState.state === "attached") return thread;
425
- const range = textRange(anchor, this.ydoc);
426
- const at = range ? onlyOccurrence(range.type, anchor.text) : void 0;
427
- if (!range || at === void 0) {
428
- throw new CommentsError("RELOCATE_FAILED", `the quoted text does not occur exactly once, so thread ${shown(id2)} cannot be moved`);
429
- }
430
- fields.set("anchor", textAnchor(range.type, at, at + anchor.text.length));
431
- return this.get(id2);
432
- }
433
- resolve(threadId, actor) {
434
- return this.setStatus(threadId, "resolved", actor);
435
- }
436
- reopen(threadId, actor) {
437
- return this.setStatus(threadId, "open", actor);
438
- }
439
- get(threadId) {
440
- const { id: id2, fields } = this.fields(threadId);
441
- return this.read(id2, fields).thread;
442
- }
443
- /** Threads in the order they were started. A malformed thread is skipped; `problems()` names it. */
444
- list(filter = {}) {
445
- const given = exact(filter, ["status", "anchorType", "mentioning", "anchorState"], "a filter");
446
- if (given.status !== void 0 && !STATUSES.includes(given.status)) invalid(`status must be ${STATUSES.join(" or ")}`);
447
- if (given.anchorType !== void 0 && typeof given.anchorType !== "string") invalid("anchorType must be text");
448
- if (given.mentioning !== void 0 && typeof given.mentioning !== "string") invalid("mentioning must be text");
449
- if (given.anchorState !== void 0 && !ANCHOR_STATES.includes(given.anchorState)) invalid(`anchorState must be ${ANCHOR_STATES.join(", ")}`);
450
- const wanted = given.mentioning === void 0 ? void 0 : handle(given.mentioning);
451
- return this.readAll().threads.filter((t) => given.status === void 0 || t.status === given.status).filter((t) => given.anchorType === void 0 || t.anchor.type === given.anchorType).filter((t) => given.anchorState === void 0 || t.anchorState.state === given.anchorState).filter((t) => wanted === void 0 || t.messages.some((m) => m.mentions.includes(wanted))).sort((a, b) => Date.parse(a.messages[0].at) - Date.parse(b.messages[0].at) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
452
- }
453
- /** Threads or messages that could not be read, for example from a replica that wrote bad data. */
454
- problems() {
455
- return this.readAll().problems;
456
- }
457
- readAll() {
458
- const threads = [];
459
- const problems = [];
460
- for (const [id2, fields] of this.map.entries()) {
461
- try {
462
- const { thread, issues } = this.read(id2, fields);
463
- threads.push(thread);
464
- for (const reason of issues) problems.push({ id: safeId(id2), reason });
465
- } catch (error) {
466
- problems.push({ id: safeId(id2), reason: error.message });
467
- }
468
- }
469
- return { threads, problems };
470
- }
471
- };
472
-
473
- // src/suggestions.ts
474
- import * as Y3 from "yjs";
475
- var ACCEPT_SCOPE = "comments:accept";
476
- var MAX_CHANGE = 64e3;
477
- var MAX_RECEIPT = 16e3;
478
- var MAX_SUMMARY = 1e3;
479
- var SUGGESTIONS = "comments.suggestions";
480
- var APPLIED = "comments.suggestion-applied";
481
- var REJECTED = "comments.suggestion-rejected";
482
- var decidesOwn = (author, decider) => author.kind !== "human" && decider.kind !== "human" && author.name === decider.name;
483
- var clientOf = (text) => /^(0|[1-9]\d{0,9})$/.test(text) && Number(text) <= 4294967295 ? Number(text) : void 0;
484
- var thenable = (value) => typeof value?.then === "function";
485
- var Suggestions = class {
486
- constructor(comments, host) {
21
+ var DEFAULT_SEEN = 1e4;
22
+ var ERROR_LIMIT = 100;
23
+ var actorOf = (author) => ({ kind: author.kind, name: author.name, machineMade: author.kind !== "human" });
24
+ var Notifications = class {
25
+ constructor(comments, suggestions, options = {}) {
487
26
  this.comments = comments;
488
- this.host = host;
489
- if (typeof host?.apply !== "function" || typeof host.revert !== "function" && host.idempotent !== true) {
490
- invalid("a host needs apply, and either revert or idempotent: true, so two accepts at once cannot apply a change twice");
27
+ this.suggestions = suggestions;
28
+ this.options = options;
29
+ this.seenLimit = options.seenLimit ?? DEFAULT_SEEN;
30
+ if (!Number.isSafeInteger(this.seenLimit) || this.seenLimit < 1) throw new TypeError("seenLimit must be a whole number, 1 or more");
31
+ this.muted = options.muted === true;
32
+ const flush = () => this.flush();
33
+ comments.ydoc.on("afterAllTransactions", flush);
34
+ this.stops.push(() => comments.ydoc.off("afterAllTransactions", flush));
35
+ const threads = comments.threadsMap;
36
+ const onThreads = (events, tr) => this.threadEvents(events, tr.local ? "local" : "remote");
37
+ threads.observeDeep(onThreads);
38
+ this.stops.push(() => threads.unobserveDeep(onThreads));
39
+ if (suggestions) {
40
+ const { suggestions: made, applied, rejected } = suggestions.maps;
41
+ const onMade = (event, tr) => this.created(event, tr.local ? "local" : "remote");
42
+ const onApplied = (event, tr) => this.decided(event, tr.local ? "local" : "remote", "suggestion.accepted");
43
+ const onRejected = (event, tr) => this.decided(event, tr.local ? "local" : "remote", "suggestion.rejected");
44
+ made.observe(onMade);
45
+ applied.observe(onApplied);
46
+ rejected.observe(onRejected);
47
+ this.stops.push(() => made.unobserve(onMade), () => applied.unobserve(onApplied), () => rejected.unobserve(onRejected));
491
48
  }
492
- void [this.map, this.applied, this.rejected];
493
- this.onTransaction = (tr) => {
494
- if (!tr.local && [this.applied, this.map].some((type) => tr.changedParentTypes.has(type))) this.settle();
495
- };
496
- this.ydoc.on("afterTransaction", this.onTransaction);
497
- this.settle();
498
49
  }
499
50
  comments;
500
- host;
501
- onTransaction;
51
+ suggestions;
52
+ options;
53
+ listeners = /* @__PURE__ */ new Set();
54
+ seen = /* @__PURE__ */ new Set();
502
55
  failures = [];
503
- /** Stop listening to the document. */
56
+ stops = [];
57
+ queue = [];
58
+ flushing = false;
59
+ closed = false;
60
+ seenLimit;
61
+ muted;
62
+ /** Receive every event from now on. Returns a function that stops it. */
63
+ subscribe(listener) {
64
+ if (typeof listener !== "function") throw new TypeError("a listener must be a function");
65
+ this.listeners.add(listener);
66
+ return () => void this.listeners.delete(listener);
67
+ }
68
+ /** Start sending events, for a stream created with `muted: true`. */
69
+ unmute() {
70
+ this.muted = false;
71
+ }
72
+ /** Stop watching the document and drop every listener. */
504
73
  close() {
505
- this.ydoc.off("afterTransaction", this.onTransaction);
506
- }
507
- get ydoc() {
508
- return this.comments.ydoc;
509
- }
510
- get map() {
511
- return this.ydoc.getMap(SUGGESTIONS);
74
+ this.closed = true;
75
+ for (const stop of this.stops.splice(0)) stop();
76
+ this.listeners.clear();
77
+ this.queue.length = 0;
512
78
  }
513
- get applied() {
514
- return this.ydoc.getMap(APPLIED);
79
+ /** The most recent errors, when no `onError` was given. */
80
+ errors() {
81
+ return [...this.failures];
515
82
  }
516
- get rejected() {
517
- return this.ydoc.getMap(REJECTED);
518
- }
519
- change(raw) {
520
- let clean;
83
+ fail(error, source) {
521
84
  try {
522
- clean = strictCopy(raw);
523
- if (this.host.parse) clean = strictCopy(this.host.parse(copy(clean)));
524
- } catch (error) {
525
- return invalid(`the change is refused: ${safeText(error instanceof Error ? error.message : error)}`);
526
- }
527
- if (JSON.stringify(clean).length > MAX_CHANGE) invalid(`a change holds up to ${MAX_CHANGE} characters of JSON`);
528
- return clean;
529
- }
530
- /**
531
- * Records of one kind for one suggestion, in client order. A record that is not valid is not counted:
532
- * one with no author or time, a receipt that is not small JSON, or a decider who decided their own work.
533
- */
534
- records(entries, id2, author) {
535
- const out = [];
536
- for (const [key2, raw] of entries) {
537
- const client = clientOf(key2.slice(id2.length + 1));
538
- const r = raw ?? {};
539
- const by = readAuthor(r.by);
540
- if (client === void 0 || !by || !validDate(r.at) || !author || decidesOwn(author, by)) continue;
541
- let receipt = null;
542
- try {
543
- receipt = r.receipt === void 0 ? null : strictCopy(r.receipt);
544
- if (JSON.stringify(receipt).length > MAX_RECEIPT) continue;
545
- } catch {
546
- continue;
547
- }
548
- out.push({ client, by, at: r.at, receipt });
549
- }
550
- return out.sort((a, b) => Number(a.receipt !== null) - Number(b.receipt !== null) || a.client - b.client);
551
- }
552
- /** Group the entries of a decision map by suggestion id in one pass, so a read does not scan the map per suggestion. */
553
- index(map) {
554
- const groups = /* @__PURE__ */ new Map();
555
- for (const entry of map.entries()) {
556
- const id2 = entry[0].slice(0, entry[0].lastIndexOf(":"));
557
- const group = groups.get(id2);
558
- if (group) group.push(entry);
559
- else groups.set(id2, [entry]);
85
+ if (this.options.onError) this.options.onError(error, source);
86
+ else if (this.failures.push({ source, error }) > ERROR_LIMIT) this.failures.shift();
87
+ } catch {
560
88
  }
561
- return groups;
562
89
  }
563
- read(id2, fields, indexes = { applied: this.index(this.applied), rejected: this.index(this.rejected) }) {
90
+ /** Data from a peer can be bad. A bad record makes no event, and never stops a write or the records after it. */
91
+ guard(fn) {
564
92
  try {
565
- if (!ID.test(id2)) throw new Error("the suggestion id is not short and plain");
566
- if (!(fields instanceof Y3.Map)) throw new Error("the suggestion is not a map");
567
- const author = readAuthor(fields.get("author"));
568
- const createdAt = fields.get("createdAt");
569
- const summary = fields.get("summary");
570
- const change = fields.get("change");
571
- if (!author || !validDate(createdAt)) throw new Error("the suggestion has no valid author or time");
572
- if (summary !== void 0 && !(typeof summary === "string" && summary.trim() !== "" && summary.length <= MAX_SUMMARY && !BODY_FORBIDDEN.test(summary))) {
573
- throw new Error("the summary is not valid");
574
- }
575
- if (change === void 0 || JSON.stringify(strictCopy(change)).length > MAX_CHANGE) throw new Error("the change is missing or too large");
576
- const won = this.records(indexes.applied.get(id2) ?? [], id2, author)[0];
577
- const refused = this.records(indexes.rejected.get(id2) ?? [], id2, author)[0];
578
- const decision = won ? { status: "accepted", ...won } : refused ? { status: "rejected", ...refused } : void 0;
579
- const anchor = this.comments.parseAnchor(fields.get("anchor"));
580
- return {
581
- id: id2,
582
- anchor,
583
- anchorState: this.comments.anchorState(anchor),
584
- change: copy(change),
585
- ...summary === void 0 ? {} : { summary },
586
- author,
587
- createdAt,
588
- status: decision?.status ?? "pending",
589
- ...decision ? { decidedBy: decision.by, decidedAt: decision.at } : {}
590
- };
93
+ fn();
591
94
  } catch (error) {
592
- throw new CommentsError("CORRUPT", `suggestion ${shown(id2)} is malformed: ${safeText(error instanceof Error ? error.message : error)}`);
593
- }
594
- }
595
- fields(id2) {
596
- if (typeof id2 !== "string" || !ID.test(id2) || !this.map.has(id2)) throw new CommentsError("NO_SUGGESTION", `no suggestion with id ${shown(id2)}`);
597
- return { id: id2, fields: this.map.get(id2) };
598
- }
599
- /** Propose a change. Anyone can suggest. The host stamps the author. */
600
- add(input, actor) {
601
- const who = assertActor(actor);
602
- const given = exact(input, ["anchor", "change", "summary"], "a new suggestion");
603
- const anchor = this.comments.checkAnchor(given.anchor);
604
- const change = this.change(given.change);
605
- const summary = given.summary;
606
- if (summary !== void 0 && !(typeof summary === "string" && summary.trim() !== "" && summary.length <= MAX_SUMMARY && !BODY_FORBIDDEN.test(summary))) {
607
- invalid(`a summary must be text of 1 to ${MAX_SUMMARY} characters, with no control or invisible characters`);
95
+ this.fail(error, "data");
608
96
  }
609
- const id2 = globalThis.crypto.randomUUID();
610
- const createdAt = this.comments.time();
611
- this.ydoc.transact(() => {
612
- const fields = new Y3.Map();
613
- this.map.set(id2, fields);
614
- fields.set("anchor", anchor);
615
- fields.set("change", change);
616
- if (summary !== void 0) fields.set("summary", summary);
617
- fields.set("author", { kind: who.kind, name: who.name });
618
- fields.set("createdAt", createdAt);
619
- });
620
- return this.get(id2);
621
97
  }
622
- /** A person decides. An agent needs the accept scope, and never decides its own suggestion. */
623
- mayDecide(who, s, verb) {
624
- const ok = !decidesOwn(s.author, who) && (who.kind === "human" || who.scopes.includes(ACCEPT_SCOPE));
625
- if (!ok) throw new CommentsError("FORBIDDEN", `${who.kind}:${shown(who.name)} cannot ${verb} this suggestion: a signed-in person, or a principal with the ${ACCEPT_SCOPE} scope that did not write it, can`);
98
+ emit(event) {
99
+ if (this.seen.has(event.id)) return;
100
+ this.seen.add(event.id);
101
+ if (this.seen.size > this.seenLimit) this.seen.delete(this.seen.values().next().value);
102
+ if (!this.muted) this.queue.push(event);
626
103
  }
627
- /**
628
- * Apply the change as the accepting author and record the decision, in one step. Accepting an
629
- * accepted suggestion changes nothing. When `apply` throws, the suggestion stays pending.
630
- */
631
- accept(suggestionId, actor) {
632
- const who = assertActor(actor);
633
- if (this.ydoc._transaction !== null) {
634
- throw new CommentsError("APPLY_FAILED", "accept cannot run inside another Yjs transaction, such as an observer or an apply hook");
635
- }
636
- const { id: id2, fields } = this.fields(suggestionId);
637
- const s = this.read(id2, fields);
638
- this.mayDecide(who, s, "accept");
639
- if (s.status === "accepted") return s;
640
- if (s.status === "rejected") throw new CommentsError("ALREADY_DECIDED", `suggestion ${shown(id2)} is already rejected`);
641
- const by = { kind: who.kind, name: who.name };
642
- const at = this.comments.time();
643
- this.ydoc.transact((tr) => {
644
- const edited = () => tr.changed.size > 0 || tr.deleteSet.clients.size > 0;
645
- let receipt = null;
646
- let failure;
647
- try {
648
- if (s.anchorState.state === "orphaned" || s.anchorState.state === "edited") {
649
- throw new Error(s.anchorState.state === "orphaned" ? "the anchored content is gone" : "the anchored content has changed since the suggestion was made");
104
+ flush() {
105
+ if (this.flushing) return;
106
+ this.flushing = true;
107
+ try {
108
+ for (let event = this.queue.shift(); event; event = this.queue.shift()) {
109
+ for (const listener of [...this.listeners]) {
110
+ if (this.closed) return;
111
+ try {
112
+ const result = listener(event);
113
+ if (typeof result?.then === "function") Promise.resolve(result).catch((error) => this.fail(error, "listener"));
114
+ } catch (error) {
115
+ this.fail(error, "listener");
116
+ }
650
117
  }
651
- this.comments.checkAnchor(s.anchor);
652
- const change = this.change(s.change);
653
- receipt = this.host.apply(change, { suggestionId: id2, anchor: copy(s.anchor), author: Object.freeze({ ...s.author }), by: Object.freeze({ ...by }), ydoc: this.ydoc });
654
- if (thenable(receipt)) throw new Error("apply must finish at once and cannot return a promise");
655
- receipt = receipt === void 0 ? null : strictCopy(receipt);
656
- if (JSON.stringify(receipt).length > MAX_RECEIPT) throw new Error(`a receipt holds up to ${MAX_RECEIPT} characters of JSON`);
657
- } catch (error) {
658
- failure = error;
659
- receipt = null;
660
- }
661
- if (failure !== void 0) {
662
- const reason = safeText(failure instanceof Error ? failure.message : failure);
663
- if (!edited()) throw new CommentsError("APPLY_FAILED", `the change could not be applied: ${reason}`);
664
- this.failures.push({ id: safeId(id2), reason: `apply failed after it edited the document, so the decision is recorded without a receipt: ${reason}` });
665
118
  }
666
- this.applied.set(`${id2}:${this.ydoc.clientID}`, { by, at, receipt });
667
- });
668
- return this.get(id2);
669
- }
670
- /** Same right as accept. A person can withdraw their own suggestion. */
671
- reject(suggestionId, actor) {
672
- const who = assertActor(actor);
673
- const { id: id2, fields } = this.fields(suggestionId);
674
- const s = this.read(id2, fields);
675
- this.mayDecide(who, s, "reject");
676
- if (s.status === "rejected") return s;
677
- if (s.status === "accepted") throw new CommentsError("ALREADY_DECIDED", `suggestion ${shown(id2)} is already accepted`);
678
- this.rejected.set(`${id2}:${this.ydoc.clientID}`, { by: { kind: who.kind, name: who.name }, at: this.comments.time() });
679
- return this.get(id2);
680
- }
681
- get(suggestionId) {
682
- const { id: id2, fields } = this.fields(suggestionId);
683
- return this.read(id2, fields);
119
+ } finally {
120
+ this.flushing = false;
121
+ }
122
+ }
123
+ threadEvents(events, source) {
124
+ const threads = this.comments.threadsMap;
125
+ for (const event of events) {
126
+ this.guard(() => {
127
+ const path = event.path;
128
+ if (event.target === threads) {
129
+ for (const [id, change] of event.keys) if (change.action === "add") this.guard(() => this.threadCreated(id, source));
130
+ } else if (path.length === 1 && event.target instanceof Y.Map) {
131
+ const keys = event.keys;
132
+ if (keys.get("messages")?.action === "add") this.threadCreated(String(path[0]), source);
133
+ const change = keys.get("state");
134
+ if (change) this.statusChanged(String(path[0]), change.oldValue, source);
135
+ } else if (path.length === 2 && path[1] === "messages" && event.target instanceof Y.Array) {
136
+ const ids = /* @__PURE__ */ new Set();
137
+ for (const item of event.changes.added) for (const raw of item.content.getContent()) ids.add(raw?.id);
138
+ this.messagesAdded(String(path[0]), ids, source);
139
+ }
140
+ });
141
+ }
684
142
  }
685
- /** In the order they were made. A malformed suggestion is skipped, and `problems()` names it. */
686
- list(filter = {}) {
687
- const given = exact(filter, ["status", "anchorState"], "a filter");
688
- if (given.anchorState !== void 0 && !["attached", "edited", "orphaned", "unknown"].includes(given.anchorState)) invalid("anchorState must be attached, edited, orphaned, or unknown");
689
- if (given.status !== void 0 && !["pending", "accepted", "rejected"].includes(given.status)) invalid("status must be pending, accepted, or rejected");
690
- return this.readAll().suggestions.filter((s) => given.status === void 0 || s.status === given.status).filter((s) => given.anchorState === void 0 || s.anchorState.state === given.anchorState).sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
143
+ mentions(thread, message, source) {
144
+ for (const handle of message.mentions) {
145
+ this.emit({ id: `mention:${thread.id}:${message.id}:${handle}`, type: "mention", at: message.at, source, actor: actorOf(message.author), anchorType: thread.anchor.type, threadId: thread.id, messageId: message.id, mention: handle });
146
+ }
147
+ }
148
+ messageEvent(thread, message, source) {
149
+ this.emit({ id: `message.added:${thread.id}:${message.id}`, type: "message.added", at: message.at, source, actor: actorOf(message.author), anchorType: thread.anchor.type, threadId: thread.id, messageId: message.id });
150
+ this.mentions(thread, message, source);
151
+ }
152
+ statusEvent(thread, source) {
153
+ const type = thread.status === "resolved" ? "thread.resolved" : "thread.reopened";
154
+ this.emit({ id: `${type}:${thread.id}:${thread.statusAt}:${thread.statusBy.kind}:${thread.statusBy.name}`, type, at: thread.statusAt, source, actor: actorOf(thread.statusBy), anchorType: thread.anchor.type, threadId: thread.id });
155
+ }
156
+ threadCreated(id, source) {
157
+ const thread = this.comments.get(id);
158
+ const stored = this.comments.threadsMap.get(id)?.get("messages");
159
+ const firstId = stored instanceof Y.Array ? stored.get(0)?.id : void 0;
160
+ const first = thread.messages.find((m) => m.id === firstId);
161
+ if (first) {
162
+ this.emit({ id: `thread.created:${id}`, type: "thread.created", at: first.at, source, actor: actorOf(first.author), anchorType: thread.anchor.type, threadId: id, messageId: first.id });
163
+ this.mentions(thread, first, source);
164
+ } else {
165
+ this.fail(new Error(`thread ${JSON.stringify(id.slice(0, 64))} has no valid first message, so it makes no thread.created event`), "data");
166
+ }
167
+ for (const message of thread.messages) if (message !== first) this.messageEvent(thread, message, source);
168
+ if (thread.status === "resolved") this.statusEvent(thread, source);
169
+ }
170
+ messagesAdded(threadId, ids, source) {
171
+ const thread = this.comments.get(threadId);
172
+ for (const message of thread.messages) if (ids.has(message.id)) this.messageEvent(thread, message, source);
173
+ }
174
+ statusChanged(threadId, before, source) {
175
+ const thread = this.comments.get(threadId);
176
+ const old = before ?? {};
177
+ const real = (old.status === "open" || old.status === "resolved") && readAuthor(old.by) !== void 0;
178
+ if (real && old.status !== thread.status) this.statusEvent(thread, source);
179
+ }
180
+ created(event, source) {
181
+ for (const [id, change] of event.keys) {
182
+ if (change.action !== "add") continue;
183
+ this.guard(() => {
184
+ const s = this.suggestions.get(id);
185
+ this.emit({ id: `suggestion.created:${id}`, type: "suggestion.created", at: s.createdAt, source, actor: actorOf(s.author), anchorType: s.anchor.type, suggestionId: id });
186
+ if (s.status !== "pending") this.decisionEvent(s, source);
187
+ });
188
+ }
691
189
  }
692
- /** Suggestions that could not be read, and reverts that failed. */
693
- problems() {
694
- return [...this.readAll().problems, ...this.failures];
190
+ decisionEvent(s, source) {
191
+ if (s.status === "pending" || !s.decidedBy || !s.decidedAt) return;
192
+ const type = s.status === "accepted" ? "suggestion.accepted" : "suggestion.rejected";
193
+ this.emit({ id: `${type}:${s.id}`, type, at: s.decidedAt, source, actor: actorOf(s.decidedBy), anchorType: s.anchor.type, suggestionId: s.id });
695
194
  }
696
- readAll() {
697
- const suggestions = [];
698
- const problems = [];
699
- const indexes = { applied: this.index(this.applied), rejected: this.index(this.rejected) };
700
- for (const [id2, fields] of this.map.entries()) {
195
+ decided(event, source, type) {
196
+ for (const [key, change] of event.keys) {
197
+ if (change.action !== "add") continue;
701
198
  try {
702
- suggestions.push(this.read(id2, fields, indexes));
199
+ const raw = event.target.get(key);
200
+ if (!raw || readAuthor(raw.by) === void 0 || !validDate(raw.at)) throw new Error("a decision record is not valid");
201
+ const id = key.slice(0, key.lastIndexOf(":"));
202
+ const s = this.suggestions.get(id);
203
+ if (s.status === (type === "suggestion.accepted" ? "accepted" : "rejected")) this.decisionEvent(s, source);
703
204
  } catch (error) {
704
- problems.push({ id: safeId(id2), reason: error.message });
205
+ this.fail(error, "data");
705
206
  }
706
207
  }
707
- return { suggestions, problems };
708
- }
709
- /**
710
- * Two replicas can accept one suggestion before they meet. After a merge, the application of the
711
- * lowest client id stays, and the host reverts each other one. Every replica reaches the same result.
712
- * A revert that throws leaves its record, so the next merge tries again.
713
- */
714
- settle() {
715
- const groups = this.index(this.applied);
716
- const indexes = { applied: groups, rejected: this.index(this.rejected) };
717
- this.failures = this.failures.filter((f) => f.reason.startsWith("apply failed") || f.reason.startsWith("a duplicate application has no receipt"));
718
- this.ydoc.transact(() => {
719
- for (const [id2, entries] of groups) {
720
- if (entries.length < 2 || !ID.test(id2) || !this.map.has(id2)) continue;
721
- let s;
722
- try {
723
- s = this.read(id2, this.map.get(id2), indexes);
724
- } catch {
725
- continue;
726
- }
727
- for (const loser of this.records(entries, id2, s.author).slice(1)) {
728
- const key2 = `${id2}:${loser.client}`;
729
- if (this.host.idempotent) {
730
- this.applied.delete(key2);
731
- continue;
732
- }
733
- if (loser.receipt === null) {
734
- this.failures.push({ id: safeId(id2), reason: "a duplicate application has no receipt, so its edit cannot be undone and stays in the document" });
735
- this.applied.delete(key2);
736
- continue;
737
- }
738
- try {
739
- this.host.revert?.(loser.receipt, { suggestionId: id2, anchor: copy(s.anchor), author: Object.freeze({ ...s.author }), by: Object.freeze({ ...loser.by }), ydoc: this.ydoc });
740
- this.applied.delete(key2);
741
- } catch (error) {
742
- this.failures.push({ id: safeId(id2), reason: `a duplicate application could not be reverted: ${safeText(error instanceof Error ? error.message : error)}` });
743
- }
744
- }
745
- }
746
- });
747
208
  }
748
209
  };
749
210
  export {
@@ -751,6 +212,7 @@ export {
751
212
  Actor,
752
213
  Comments,
753
214
  CommentsError,
215
+ Notifications,
754
216
  PRINCIPAL_KINDS,
755
217
  Suggestions,
756
218
  VERSION,