agentic-comments 0.6.0 → 0.7.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.
@@ -180,350 +180,8 @@ var strictCopy = (value) => JSON.parse(
180
180
  })
181
181
  );
182
182
 
183
- // src/suggestions.ts
184
- import * as Y2 from "yjs";
185
- var ACCEPT_SCOPE = "comments:accept";
186
- var MAX_CHANGE = 64e3;
187
- var MAX_RECEIPT = 16e3;
188
- var MAX_SUMMARY = 1e3;
189
- var SUGGESTIONS = "comments.suggestions";
190
- var APPLIED = "comments.suggestion-applied";
191
- var REJECTED = "comments.suggestion-rejected";
192
- var decidesOwn = (author, decider) => author.name !== ERASED_NAME && author.kind !== "human" && decider.kind !== "human" && author.name === decider.name;
193
- var clientOf = (text) => /^(0|[1-9]\d{0,9})$/.test(text) && Number(text) <= 4294967295 ? Number(text) : void 0;
194
- var thenable = (value) => typeof value?.then === "function";
195
- var Suggestions = class {
196
- constructor(comments, host) {
197
- this.comments = comments;
198
- this.host = host;
199
- if (typeof host?.apply !== "function" || typeof host.revert !== "function" && host.idempotent !== true) {
200
- invalid("a host needs apply, and either revert or idempotent: true, so two accepts at once cannot apply a change twice");
201
- }
202
- void [this.map, this.applied, this.rejected];
203
- this.onTransaction = (tr) => {
204
- if (!tr.local && [this.applied, this.map].some((type) => tr.changedParentTypes.has(type))) this.settle();
205
- };
206
- this.ydoc.on("afterTransaction", this.onTransaction);
207
- this.settle();
208
- }
209
- comments;
210
- host;
211
- onTransaction;
212
- failures = [];
213
- /** Stop listening to the document. */
214
- close() {
215
- this.ydoc.off("afterTransaction", this.onTransaction);
216
- }
217
- get ydoc() {
218
- return this.comments.ydoc;
219
- }
220
- get map() {
221
- return this.ydoc.getMap(SUGGESTIONS);
222
- }
223
- /** The three Yjs maps that hold suggestions and their decisions. `Notifications` watches them. */
224
- get maps() {
225
- return { suggestions: this.map, applied: this.applied, rejected: this.rejected };
226
- }
227
- get applied() {
228
- return this.ydoc.getMap(APPLIED);
229
- }
230
- get rejected() {
231
- return this.ydoc.getMap(REJECTED);
232
- }
233
- change(raw, anchor) {
234
- let clean;
235
- try {
236
- clean = strictCopy(raw);
237
- if (this.host.parse) clean = strictCopy(this.host.parse(copy(clean), { anchor: copy(anchor) }));
238
- } catch (error) {
239
- return invalid(`the change is refused: ${safeText(error instanceof Error ? error.message : error)}`);
240
- }
241
- if (JSON.stringify(clean).length > MAX_CHANGE) invalid(`a change holds up to ${MAX_CHANGE} characters of JSON`);
242
- return clean;
243
- }
244
- /**
245
- * Records of one kind for one suggestion, in client order. A record that is not valid is not counted:
246
- * one with no author or time, a receipt that is not small JSON, or a decider who decided their own work.
247
- */
248
- records(entries, id2, author) {
249
- const out = [];
250
- for (const [key2, raw] of entries) {
251
- const client = clientOf(key2.slice(id2.length + 1));
252
- const r = raw ?? {};
253
- const by = readAuthor(r.by);
254
- if (client === void 0 || !by || !validDate(r.at) || !author || decidesOwn(author, by)) continue;
255
- let receipt = null;
256
- try {
257
- receipt = r.receipt === void 0 ? null : strictCopy(r.receipt);
258
- if (JSON.stringify(receipt).length > MAX_RECEIPT) continue;
259
- } catch {
260
- continue;
261
- }
262
- out.push({ client, by, at: r.at, receipt });
263
- }
264
- return out.sort((a, b) => Number(a.receipt !== null) - Number(b.receipt !== null) || a.client - b.client);
265
- }
266
- /** Group the entries of a decision map by suggestion id in one pass, so a read does not scan the map per suggestion. */
267
- index(map) {
268
- const groups = /* @__PURE__ */ new Map();
269
- for (const entry of map.entries()) {
270
- const id2 = entry[0].slice(0, entry[0].lastIndexOf(":"));
271
- const group = groups.get(id2);
272
- if (group) group.push(entry);
273
- else groups.set(id2, [entry]);
274
- }
275
- return groups;
276
- }
277
- read(id2, fields, indexes = { applied: this.index(this.applied), rejected: this.index(this.rejected) }) {
278
- try {
279
- if (!ID.test(id2)) throw new Error("the suggestion id is not short and plain");
280
- if (!(fields instanceof Y2.Map)) throw new Error("the suggestion is not a map");
281
- const author = readAuthor(fields.get("author"));
282
- const createdAt = fields.get("createdAt");
283
- const summary = fields.get("summary");
284
- const change = fields.get("change");
285
- if (!author || !validDate(createdAt)) throw new Error("the suggestion has no valid author or time");
286
- if (summary !== void 0 && !(typeof summary === "string" && summary.trim() !== "" && summary.length <= MAX_SUMMARY && !BODY_FORBIDDEN.test(summary))) {
287
- throw new Error("the summary is not valid");
288
- }
289
- if (change === void 0 || JSON.stringify(strictCopy(change)).length > MAX_CHANGE) throw new Error("the change is missing or too large");
290
- const won = this.records(indexes.applied.get(id2) ?? [], id2, author)[0];
291
- const refused = this.records(indexes.rejected.get(id2) ?? [], id2, author)[0];
292
- const decision = won ? { status: "accepted", ...won } : refused ? { status: "rejected", ...refused } : void 0;
293
- const anchor = this.comments.parseAnchor(fields.get("anchor"));
294
- return {
295
- id: id2,
296
- anchor,
297
- anchorState: this.comments.anchorState(anchor),
298
- change: copy(change),
299
- ...summary === void 0 ? {} : { summary },
300
- author,
301
- createdAt,
302
- status: decision?.status ?? "pending",
303
- ...decision ? { decidedBy: decision.by, decidedAt: decision.at } : {}
304
- };
305
- } catch (error) {
306
- throw new CommentsError("CORRUPT", `suggestion ${shown(id2)} is malformed: ${safeText(error instanceof Error ? error.message : error)}`);
307
- }
308
- }
309
- fields(id2) {
310
- if (typeof id2 !== "string" || !ID.test(id2) || !this.map.has(id2)) throw new CommentsError("NO_SUGGESTION", `no suggestion with id ${shown(id2)}`);
311
- return { id: id2, fields: this.map.get(id2) };
312
- }
313
- /**
314
- * The suggestions a person wrote, and the decisions they made, in this document. `unread` counts stored
315
- * suggestions and decision records with their name that this export cannot show: a suggestion that cannot
316
- * be read, a decision that lost a merge, a refused decision, or a decision with no suggestion. When it is not
317
- * 0 the export is not complete. A person may export their own data.
318
- */
319
- exportPerson(person, actor) {
320
- const { match } = this.comments.checkPerson(person, actor);
321
- const all = this.list();
322
- const authored = all.filter((s) => match(s.author));
323
- const decided = all.flatMap((s) => s.decidedBy && s.decidedAt && s.status !== "pending" && match(s.decidedBy) ? [{ id: s.id, status: s.status, at: s.decidedAt }] : []);
324
- let found = 0;
325
- for (const fields of this.map.values()) if (fields instanceof Y2.Map && this.named(fields.get("author"), match)) found++;
326
- for (const map of [this.applied, this.rejected]) for (const raw of map.values()) if (this.named(raw?.by, match)) found++;
327
- return { authored, decided, unread: Math.max(0, found - authored.length - decided.length) };
328
- }
329
- named(value, match) {
330
- const author = readAuthor(value);
331
- return author !== void 0 && match(author);
332
- }
333
- /**
334
- * Give every suggestion a person wrote a tombstone author and drop its summary. An accepted one keeps its
335
- * change, because that change is already part of the content. A pending or rejected one loses its change, and
336
- * can no longer be accepted. A decision they made gets a tombstone decider. A decision that a read refuses (a
337
- * principal that decided its own work) is deleted, so erase never turns it into a valid one. Returns how many
338
- * suggestions and decisions it changed.
339
- */
340
- erasePerson(person, actor) {
341
- const { match } = this.comments.checkPerson(person, actor);
342
- const out = { suggestions: 0, decisions: 0 };
343
- const authors = /* @__PURE__ */ new Map();
344
- for (const [id2, fields] of this.map.entries()) {
345
- const author = fields instanceof Y2.Map ? readAuthor(fields.get("author")) : void 0;
346
- if (author) authors.set(id2, author);
347
- }
348
- const status = new Map(this.list().map((s) => [s.id, s.status]));
349
- this.ydoc.transact(() => {
350
- for (const map of [this.applied, this.rejected]) {
351
- for (const [key2, raw] of [...map.entries()]) {
352
- const by = readAuthor(raw?.by);
353
- const author = authors.get(key2.slice(0, key2.lastIndexOf(":")));
354
- if (!by || !(match(by) || author && match(author))) continue;
355
- if (author && decidesOwn(author, by)) map.delete(key2);
356
- else if (match(by)) {
357
- map.set(key2, { ...raw, by: { kind: by.kind, name: ERASED_NAME } });
358
- out.decisions++;
359
- }
360
- }
361
- }
362
- for (const [id2, author] of authors) {
363
- if (!match(author)) continue;
364
- const fields = this.map.get(id2);
365
- fields.set("author", { kind: author.kind, name: ERASED_NAME });
366
- fields.delete("summary");
367
- if (status.get(id2) !== "accepted") fields.set("change", null);
368
- out.suggestions++;
369
- }
370
- });
371
- return out;
372
- }
373
- /** Propose a change. Anyone can suggest. The host stamps the author. */
374
- add(input, actor) {
375
- const who = assertActor(actor);
376
- const given = exact(input, ["anchor", "change", "summary"], "a new suggestion");
377
- const anchor = this.comments.checkAnchor(given.anchor);
378
- const change = this.change(given.change, anchor);
379
- const summary = given.summary;
380
- if (summary !== void 0 && !(typeof summary === "string" && summary.trim() !== "" && summary.length <= MAX_SUMMARY && !BODY_FORBIDDEN.test(summary))) {
381
- invalid(`a summary must be text of 1 to ${MAX_SUMMARY} characters, with no control or invisible characters`);
382
- }
383
- const id2 = globalThis.crypto.randomUUID();
384
- const createdAt = this.comments.time();
385
- this.ydoc.transact(() => {
386
- const fields = new Y2.Map();
387
- this.map.set(id2, fields);
388
- fields.set("anchor", anchor);
389
- fields.set("change", change);
390
- if (summary !== void 0) fields.set("summary", summary);
391
- fields.set("author", { kind: who.kind, name: who.name });
392
- fields.set("createdAt", createdAt);
393
- });
394
- return this.get(id2);
395
- }
396
- /** A person decides. An agent needs the accept scope, and never decides its own suggestion. */
397
- mayDecide(who, s, verb) {
398
- const ok = !decidesOwn(s.author, who) && (who.kind === "human" || who.scopes.includes(ACCEPT_SCOPE));
399
- 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`);
400
- if (verb === "accept" && s.author.name === ERASED_NAME && s.status !== "accepted") throw new CommentsError("FORBIDDEN", "an erased suggestion has no change left and cannot be accepted");
401
- }
402
- /**
403
- * Apply the change as the accepting author and record the decision, in one step. Accepting an
404
- * accepted suggestion changes nothing. When `apply` throws, the suggestion stays pending.
405
- */
406
- accept(suggestionId, actor) {
407
- const who = assertActor(actor);
408
- if (this.ydoc._transaction !== null) {
409
- throw new CommentsError("APPLY_FAILED", "accept cannot run inside another Yjs transaction, such as an observer or an apply hook");
410
- }
411
- const { id: id2, fields } = this.fields(suggestionId);
412
- const s = this.read(id2, fields);
413
- this.mayDecide(who, s, "accept");
414
- if (s.status === "accepted") return s;
415
- if (s.status === "rejected") throw new CommentsError("ALREADY_DECIDED", `suggestion ${shown(id2)} is already rejected`);
416
- const by = { kind: who.kind, name: who.name };
417
- const at = this.comments.time();
418
- this.ydoc.transact((tr) => {
419
- const edited = () => tr.changed.size > 0 || tr.deleteSet.clients.size > 0;
420
- let receipt = null;
421
- let failure;
422
- try {
423
- if (s.anchorState.state === "orphaned" || s.anchorState.state === "edited") {
424
- throw new Error(s.anchorState.state === "orphaned" ? "the anchored content is gone" : "the anchored content has changed since the suggestion was made");
425
- }
426
- this.comments.checkAnchor(s.anchor);
427
- const change = this.change(s.change, s.anchor);
428
- receipt = this.host.apply(change, { suggestionId: id2, anchor: copy(s.anchor), author: Object.freeze({ ...s.author }), by: Object.freeze({ ...by }), ydoc: this.ydoc });
429
- if (thenable(receipt)) throw new Error("apply must finish at once and cannot return a promise");
430
- receipt = receipt === void 0 ? null : strictCopy(receipt);
431
- if (JSON.stringify(receipt).length > MAX_RECEIPT) throw new Error(`a receipt holds up to ${MAX_RECEIPT} characters of JSON`);
432
- } catch (error) {
433
- failure = error;
434
- receipt = null;
435
- }
436
- if (failure !== void 0) {
437
- const reason = safeText(failure instanceof Error ? failure.message : failure);
438
- if (!edited()) throw new CommentsError("APPLY_FAILED", `the change could not be applied: ${reason}`);
439
- this.failures.push({ id: safeId(id2), reason: `apply failed after it edited the document, so the decision is recorded without a receipt: ${reason}` });
440
- }
441
- this.applied.set(`${id2}:${this.ydoc.clientID}`, { by, at, receipt });
442
- });
443
- return this.get(id2);
444
- }
445
- /** Same right as accept. A person can withdraw their own suggestion. */
446
- reject(suggestionId, actor) {
447
- const who = assertActor(actor);
448
- const { id: id2, fields } = this.fields(suggestionId);
449
- const s = this.read(id2, fields);
450
- this.mayDecide(who, s, "reject");
451
- if (s.status === "rejected") return s;
452
- if (s.status === "accepted") throw new CommentsError("ALREADY_DECIDED", `suggestion ${shown(id2)} is already accepted`);
453
- this.rejected.set(`${id2}:${this.ydoc.clientID}`, { by: { kind: who.kind, name: who.name }, at: this.comments.time() });
454
- return this.get(id2);
455
- }
456
- get(suggestionId) {
457
- const { id: id2, fields } = this.fields(suggestionId);
458
- return this.read(id2, fields);
459
- }
460
- /** In the order they were made. A malformed suggestion is skipped, and `problems()` names it. */
461
- list(filter = {}) {
462
- const given = exact(filter, ["status", "anchorState"], "a filter");
463
- if (given.anchorState !== void 0 && !["attached", "edited", "orphaned", "unknown"].includes(given.anchorState)) invalid("anchorState must be attached, edited, orphaned, or unknown");
464
- if (given.status !== void 0 && !["pending", "accepted", "rejected"].includes(given.status)) invalid("status must be pending, accepted, or rejected");
465
- 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));
466
- }
467
- /** Suggestions that could not be read, and reverts that failed. */
468
- problems() {
469
- return [...this.readAll().problems, ...this.failures];
470
- }
471
- readAll() {
472
- const suggestions = [];
473
- const problems = [];
474
- const indexes = { applied: this.index(this.applied), rejected: this.index(this.rejected) };
475
- for (const [id2, fields] of this.map.entries()) {
476
- try {
477
- suggestions.push(this.read(id2, fields, indexes));
478
- } catch (error) {
479
- problems.push({ id: safeId(id2), reason: error.message });
480
- }
481
- }
482
- return { suggestions, problems };
483
- }
484
- /**
485
- * Two replicas can accept one suggestion before they meet. After a merge, the application of the
486
- * lowest client id stays, and the host reverts each other one. Every replica reaches the same result.
487
- * A revert that throws leaves its record, so the next merge tries again.
488
- */
489
- settle() {
490
- const groups = this.index(this.applied);
491
- const indexes = { applied: groups, rejected: this.index(this.rejected) };
492
- this.failures = this.failures.filter((f) => f.reason.startsWith("apply failed") || f.reason.startsWith("a duplicate application has no receipt"));
493
- this.ydoc.transact(() => {
494
- for (const [id2, entries] of groups) {
495
- if (entries.length < 2 || !ID.test(id2) || !this.map.has(id2)) continue;
496
- let s;
497
- try {
498
- s = this.read(id2, this.map.get(id2), indexes);
499
- } catch {
500
- continue;
501
- }
502
- for (const loser of this.records(entries, id2, s.author).slice(1)) {
503
- const key2 = `${id2}:${loser.client}`;
504
- if (this.host.idempotent) {
505
- this.applied.delete(key2);
506
- continue;
507
- }
508
- if (loser.receipt === null) {
509
- this.failures.push({ id: safeId(id2), reason: "a duplicate application has no receipt, so its edit cannot be undone and stays in the document" });
510
- this.applied.delete(key2);
511
- continue;
512
- }
513
- try {
514
- this.host.revert?.(loser.receipt, { suggestionId: id2, anchor: copy(s.anchor), author: Object.freeze({ ...s.author }), by: Object.freeze({ ...loser.by }), ydoc: this.ydoc });
515
- this.applied.delete(key2);
516
- } catch (error) {
517
- this.failures.push({ id: safeId(id2), reason: `a duplicate application could not be reverted: ${safeText(error instanceof Error ? error.message : error)}` });
518
- }
519
- }
520
- }
521
- });
522
- }
523
- };
524
-
525
183
  // src/threads.ts
526
- import * as Y3 from "yjs";
184
+ import * as Y2 from "yjs";
527
185
  var MODERATE_SCOPE = "comments:moderate";
528
186
  var ERASED_BODY = "[erased]";
529
187
  var THREADS = "comments.threads";
@@ -636,7 +294,7 @@ var Comments = class {
636
294
  if (anchor.type !== "text") return;
637
295
  const at = (json) => {
638
296
  try {
639
- return Y3.createAbsolutePositionFromRelativePosition(Y3.createRelativePositionFromJSON(json), this.ydoc);
297
+ return Y2.createAbsolutePositionFromRelativePosition(Y2.createRelativePositionFromJSON(json), this.ydoc);
640
298
  } catch {
641
299
  return null;
642
300
  }
@@ -644,7 +302,7 @@ var Comments = class {
644
302
  const from = at(anchor.start);
645
303
  const to = at(anchor.end);
646
304
  const unit = from?.type;
647
- if (!(unit instanceof Y3.Text) || !from || !to || to.type !== unit || to.index <= from.index || textOf(unit).slice(from.index, to.index) !== anchor.text) {
305
+ if (!(unit instanceof Y2.Text) || !from || !to || to.type !== unit || to.index <= from.index || textOf(unit).slice(from.index, to.index) !== anchor.text) {
648
306
  throw new CommentsError("INVALID_ANCHOR", "a text anchor must name text in this document, in order, matching its quoted text");
649
307
  }
650
308
  }
@@ -689,12 +347,12 @@ var Comments = class {
689
347
  read(id2, fields) {
690
348
  try {
691
349
  if (!ID.test(id2)) throw new Error("the thread id is not short and plain");
692
- if (!(fields instanceof Y3.Map)) throw new Error("the thread is not a map");
350
+ if (!(fields instanceof Y2.Map)) throw new Error("the thread is not a map");
693
351
  const state = fields.get("state") ?? {};
694
352
  const by = readAuthor(state.by);
695
353
  if (!STATUSES.includes(state.status) || !by || !validDate(state.at)) throw new Error("the thread status is not valid");
696
354
  const list = fields.get("messages");
697
- if (!(list instanceof Y3.Array)) throw new Error("the thread has no message list");
355
+ if (!(list instanceof Y2.Array)) throw new Error("the thread has no message list");
698
356
  const messages = [];
699
357
  const issues = [];
700
358
  const raw = list.toArray();
@@ -753,11 +411,11 @@ var Comments = class {
753
411
  const first = this.message(body(given.body), given.mentions, who);
754
412
  const id2 = globalThis.crypto.randomUUID();
755
413
  this.ydoc.transact(() => {
756
- const fields = new Y3.Map();
414
+ const fields = new Y2.Map();
757
415
  this.map.set(id2, fields);
758
416
  fields.set("anchor", anchor);
759
417
  fields.set("state", { status: "open", by: copy(first.author), at: first.at });
760
- const messages = new Y3.Array();
418
+ const messages = new Y2.Array();
761
419
  fields.set("messages", messages);
762
420
  messages.push([copy(first)]);
763
421
  });
@@ -902,7 +560,7 @@ var Comments = class {
902
560
  }
903
561
  /** The raw stored threads, for the code that must see data a read hides. `list` is missing when the message field is bad. */
904
562
  stored() {
905
- return [...this.map.entries()].flatMap(([id2, fields]) => fields instanceof Y3.Map ? [{ id: id2, fields, list: fields.get("messages") instanceof Y3.Array ? fields.get("messages") : void 0 }] : []);
563
+ return [...this.map.entries()].flatMap(([id2, fields]) => fields instanceof Y2.Map ? [{ id: id2, fields, list: fields.get("messages") instanceof Y2.Array ? fields.get("messages") : void 0 }] : []);
906
564
  }
907
565
  /**
908
566
  * What a person wrote in this document: their messages, the status change of theirs that stands, and the
@@ -911,110 +569,452 @@ var Comments = class {
911
569
  * lost a merge. When it is not 0 the export is not complete. A person may export their own data.
912
570
  */
913
571
  exportPerson(person, actor) {
914
- const { name, match } = this.checkPerson(person, actor);
915
- const threads = [];
916
- const edits = [];
917
- let shownCount = 0;
918
- for (const thread of this.list()) {
919
- const messages = thread.messages.filter((m) => match(m.author));
920
- shownCount += messages.length;
921
- const status = match(thread.statusBy);
922
- if (status) shownCount++;
923
- for (const m of thread.messages) if (m.editedBy && match(m.editedBy) && !match(m.author)) edits.push({ threadId: thread.id, messageId: m.id, at: m.editedAt });
924
- if (messages.length === 0 && !status) continue;
925
- threads.push({
926
- threadId: thread.id,
927
- anchor: copy(thread.anchor),
928
- messages: copy(messages),
929
- ...status ? { statusChange: { status: thread.status, at: thread.statusAt, by: copy(thread.statusBy) } } : {}
930
- });
931
- }
572
+ const { name, match } = this.checkPerson(person, actor);
573
+ const threads = [];
574
+ const edits = [];
575
+ let shownCount = 0;
576
+ for (const thread of this.list()) {
577
+ const messages = thread.messages.filter((m) => match(m.author));
578
+ shownCount += messages.length;
579
+ const status = match(thread.statusBy);
580
+ if (status) shownCount++;
581
+ for (const m of thread.messages) if (m.editedBy && match(m.editedBy) && !match(m.author)) edits.push({ threadId: thread.id, messageId: m.id, at: m.editedAt });
582
+ if (messages.length === 0 && !status) continue;
583
+ threads.push({
584
+ threadId: thread.id,
585
+ anchor: copy(thread.anchor),
586
+ messages: copy(messages),
587
+ ...status ? { statusChange: { status: thread.status, at: thread.statusAt, by: copy(thread.statusBy) } } : {}
588
+ });
589
+ }
590
+ let found = 0;
591
+ const named = (value) => {
592
+ const author = readAuthor(value);
593
+ return author !== void 0 && match(author);
594
+ };
595
+ for (const { fields, list } of this.stored()) {
596
+ for (const raw of list?.toArray() ?? []) if (named(raw?.author) || named(raw?.editedBy)) found++;
597
+ if (named(fields.get("state")?.by)) found++;
598
+ }
599
+ return { person: name, exportedAt: this.time(), threads, edits, unread: Math.max(0, found - shownCount - edits.length) };
600
+ }
601
+ /**
602
+ * Replace every message a person wrote with a tombstone: the body becomes "[erased]", the author name
603
+ * becomes "erased", and the mentions go. The author kind stays, so machine-made work stays marked. Thread
604
+ * shape, order, and times stay. A status change of theirs, and their name as the editor of another person's
605
+ * message, get the tombstone name. It works on the stored entries, so it reaches a bad message, a message past
606
+ * the read limit, a thread that cannot be read, and every copy that a merge hid. Names inside other people's
607
+ * messages are not touched. Returns how many messages, status changes, and edit stamps it erased.
608
+ */
609
+ erasePerson(person, actor) {
610
+ const { match } = this.checkPerson(person, actor);
611
+ const tomb = (a) => ({ kind: a.kind, name: ERASED_NAME });
612
+ const out = { messages: 0, statuses: 0, edits: 0 };
613
+ this.ydoc.transact(() => {
614
+ for (const { fields, list } of this.stored()) {
615
+ const entries = list?.toArray() ?? [];
616
+ for (let i = entries.length - 1; i >= 0; i--) {
617
+ const raw = entries[i];
618
+ if (typeof raw !== "object" || raw === null) continue;
619
+ const author = readAuthor(raw.author);
620
+ const editor = readAuthor(raw.editedBy);
621
+ let next;
622
+ if (author && match(author)) {
623
+ const id2 = typeof raw.id === "string" && ID.test(raw.id) ? raw.id : globalThis.crypto.randomUUID();
624
+ const at = validDate(raw.at) ? raw.at : this.time();
625
+ next = { id: id2, at, author: tomb(author), body: ERASED_BODY, mentions: [], editedAt: this.time(), editedBy: tomb(author), erased: true };
626
+ out.messages++;
627
+ } else if (editor && match(editor)) {
628
+ next = { ...raw, editedBy: tomb(editor) };
629
+ out.edits++;
630
+ }
631
+ if (!next) continue;
632
+ list.delete(i, 1);
633
+ list.insert(i, [next]);
634
+ }
635
+ const state = fields.get("state");
636
+ const by = readAuthor(state?.by);
637
+ if (state && by && match(by)) {
638
+ fields.set("state", { ...state, by: tomb(by) });
639
+ out.statuses++;
640
+ }
641
+ }
642
+ });
643
+ return out;
644
+ }
645
+ get(threadId) {
646
+ const { id: id2, fields } = this.fields(threadId);
647
+ return this.read(id2, fields).thread;
648
+ }
649
+ /** Threads in the order they were started. A malformed thread is skipped; `problems()` names it. */
650
+ list(filter = {}) {
651
+ const given = exact(filter, ["status", "anchorType", "mentioning", "anchorState"], "a filter");
652
+ if (given.status !== void 0 && !STATUSES.includes(given.status)) invalid(`status must be ${STATUSES.join(" or ")}`);
653
+ if (given.anchorType !== void 0 && typeof given.anchorType !== "string") invalid("anchorType must be text");
654
+ if (given.mentioning !== void 0 && typeof given.mentioning !== "string") invalid("mentioning must be text");
655
+ if (given.anchorState !== void 0 && !ANCHOR_STATES.includes(given.anchorState)) invalid(`anchorState must be ${ANCHOR_STATES.join(", ")}`);
656
+ const wanted = given.mentioning === void 0 ? void 0 : handle(given.mentioning);
657
+ 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));
658
+ }
659
+ /** Threads or messages that could not be read, for example from a replica that wrote bad data. */
660
+ problems() {
661
+ return this.readAll().problems;
662
+ }
663
+ readAll() {
664
+ const threads = [];
665
+ const problems = [];
666
+ for (const [id2, fields] of this.map.entries()) {
667
+ try {
668
+ const { thread, issues } = this.read(id2, fields);
669
+ threads.push(thread);
670
+ for (const reason of issues) problems.push({ id: safeId(id2), reason });
671
+ } catch (error) {
672
+ problems.push({ id: safeId(id2), reason: error.message });
673
+ }
674
+ }
675
+ return { threads, problems };
676
+ }
677
+ };
678
+
679
+ // src/suggestions.ts
680
+ import * as Y3 from "yjs";
681
+ var ACCEPT_SCOPE = "comments:accept";
682
+ var MAX_CHANGE = 64e3;
683
+ var MAX_RECEIPT = 16e3;
684
+ var MAX_SUMMARY = 1e3;
685
+ var SUGGESTIONS = "comments.suggestions";
686
+ var APPLIED = "comments.suggestion-applied";
687
+ var REJECTED = "comments.suggestion-rejected";
688
+ var decidesOwn = (author, decider) => author.name !== ERASED_NAME && author.kind !== "human" && decider.kind !== "human" && author.name === decider.name;
689
+ var clientOf = (text) => /^(0|[1-9]\d{0,9})$/.test(text) && Number(text) <= 4294967295 ? Number(text) : void 0;
690
+ var thenable = (value) => typeof value?.then === "function";
691
+ var Suggestions = class {
692
+ constructor(comments, host) {
693
+ this.comments = comments;
694
+ this.host = host;
695
+ if (typeof host?.apply !== "function" || typeof host.revert !== "function" && host.idempotent !== true) {
696
+ invalid("a host needs apply, and either revert or idempotent: true, so two accepts at once cannot apply a change twice");
697
+ }
698
+ void [this.map, this.applied, this.rejected];
699
+ this.onTransaction = (tr) => {
700
+ if (!tr.local && [this.applied, this.map].some((type) => tr.changedParentTypes.has(type))) this.settle();
701
+ };
702
+ this.ydoc.on("afterTransaction", this.onTransaction);
703
+ this.settle();
704
+ }
705
+ comments;
706
+ host;
707
+ onTransaction;
708
+ failures = [];
709
+ /** Stop listening to the document. */
710
+ close() {
711
+ this.ydoc.off("afterTransaction", this.onTransaction);
712
+ }
713
+ get ydoc() {
714
+ return this.comments.ydoc;
715
+ }
716
+ get map() {
717
+ return this.ydoc.getMap(SUGGESTIONS);
718
+ }
719
+ /** The three Yjs maps that hold suggestions and their decisions. `Notifications` watches them. */
720
+ get maps() {
721
+ return { suggestions: this.map, applied: this.applied, rejected: this.rejected };
722
+ }
723
+ get applied() {
724
+ return this.ydoc.getMap(APPLIED);
725
+ }
726
+ get rejected() {
727
+ return this.ydoc.getMap(REJECTED);
728
+ }
729
+ change(raw, anchor) {
730
+ let clean;
731
+ try {
732
+ clean = strictCopy(raw);
733
+ if (this.host.parse) clean = strictCopy(this.host.parse(copy(clean), { anchor: copy(anchor) }));
734
+ } catch (error) {
735
+ return invalid(`the change is refused: ${safeText(error instanceof Error ? error.message : error)}`);
736
+ }
737
+ if (JSON.stringify(clean).length > MAX_CHANGE) invalid(`a change holds up to ${MAX_CHANGE} characters of JSON`);
738
+ return clean;
739
+ }
740
+ /**
741
+ * Records of one kind for one suggestion, in client order. A record that is not valid is not counted:
742
+ * one with no author or time, a receipt that is not small JSON, or a decider who decided their own work.
743
+ */
744
+ records(entries, id2, author) {
745
+ const out = [];
746
+ for (const [key2, raw] of entries) {
747
+ const client = clientOf(key2.slice(id2.length + 1));
748
+ const r = raw ?? {};
749
+ const by = readAuthor(r.by);
750
+ if (client === void 0 || !by || !validDate(r.at) || !author || decidesOwn(author, by)) continue;
751
+ let receipt = null;
752
+ try {
753
+ receipt = r.receipt === void 0 ? null : strictCopy(r.receipt);
754
+ if (JSON.stringify(receipt).length > MAX_RECEIPT) continue;
755
+ } catch {
756
+ continue;
757
+ }
758
+ out.push({ client, by, at: r.at, receipt });
759
+ }
760
+ return out.sort((a, b) => Number(a.receipt !== null) - Number(b.receipt !== null) || a.client - b.client);
761
+ }
762
+ /** Group the entries of a decision map by suggestion id in one pass, so a read does not scan the map per suggestion. */
763
+ index(map) {
764
+ const groups = /* @__PURE__ */ new Map();
765
+ for (const entry of map.entries()) {
766
+ const id2 = entry[0].slice(0, entry[0].lastIndexOf(":"));
767
+ const group = groups.get(id2);
768
+ if (group) group.push(entry);
769
+ else groups.set(id2, [entry]);
770
+ }
771
+ return groups;
772
+ }
773
+ read(id2, fields, indexes = { applied: this.index(this.applied), rejected: this.index(this.rejected) }) {
774
+ try {
775
+ if (!ID.test(id2)) throw new Error("the suggestion id is not short and plain");
776
+ if (!(fields instanceof Y3.Map)) throw new Error("the suggestion is not a map");
777
+ const author = readAuthor(fields.get("author"));
778
+ const createdAt = fields.get("createdAt");
779
+ const summary = fields.get("summary");
780
+ const change = fields.get("change");
781
+ if (!author || !validDate(createdAt)) throw new Error("the suggestion has no valid author or time");
782
+ if (summary !== void 0 && !(typeof summary === "string" && summary.trim() !== "" && summary.length <= MAX_SUMMARY && !BODY_FORBIDDEN.test(summary))) {
783
+ throw new Error("the summary is not valid");
784
+ }
785
+ if (change === void 0 || JSON.stringify(strictCopy(change)).length > MAX_CHANGE) throw new Error("the change is missing or too large");
786
+ const won = this.records(indexes.applied.get(id2) ?? [], id2, author)[0];
787
+ const refused = this.records(indexes.rejected.get(id2) ?? [], id2, author)[0];
788
+ const decision = won ? { status: "accepted", ...won } : refused ? { status: "rejected", ...refused } : void 0;
789
+ const anchor = this.comments.parseAnchor(fields.get("anchor"));
790
+ return {
791
+ id: id2,
792
+ anchor,
793
+ anchorState: this.comments.anchorState(anchor),
794
+ change: copy(change),
795
+ ...summary === void 0 ? {} : { summary },
796
+ author,
797
+ createdAt,
798
+ status: decision?.status ?? "pending",
799
+ ...decision ? { decidedBy: decision.by, decidedAt: decision.at } : {}
800
+ };
801
+ } catch (error) {
802
+ throw new CommentsError("CORRUPT", `suggestion ${shown(id2)} is malformed: ${safeText(error instanceof Error ? error.message : error)}`);
803
+ }
804
+ }
805
+ fields(id2) {
806
+ if (typeof id2 !== "string" || !ID.test(id2) || !this.map.has(id2)) throw new CommentsError("NO_SUGGESTION", `no suggestion with id ${shown(id2)}`);
807
+ return { id: id2, fields: this.map.get(id2) };
808
+ }
809
+ /**
810
+ * The suggestions a person wrote, and the decisions they made, in this document. `unread` counts stored
811
+ * suggestions and decision records with their name that this export cannot show: a suggestion that cannot
812
+ * be read, a decision that lost a merge, a refused decision, or a decision with no suggestion. When it is not
813
+ * 0 the export is not complete. A person may export their own data.
814
+ */
815
+ exportPerson(person, actor) {
816
+ const { match } = this.comments.checkPerson(person, actor);
817
+ const all = this.list();
818
+ const authored = all.filter((s) => match(s.author));
819
+ const decided = all.flatMap((s) => s.decidedBy && s.decidedAt && s.status !== "pending" && match(s.decidedBy) ? [{ id: s.id, status: s.status, at: s.decidedAt }] : []);
932
820
  let found = 0;
933
- const named = (value) => {
934
- const author = readAuthor(value);
935
- return author !== void 0 && match(author);
936
- };
937
- for (const { fields, list } of this.stored()) {
938
- for (const raw of list?.toArray() ?? []) if (named(raw?.author) || named(raw?.editedBy)) found++;
939
- if (named(fields.get("state")?.by)) found++;
940
- }
941
- return { person: name, exportedAt: this.time(), threads, edits, unread: Math.max(0, found - shownCount - edits.length) };
821
+ for (const fields of this.map.values()) if (fields instanceof Y3.Map && this.named(fields.get("author"), match)) found++;
822
+ for (const map of [this.applied, this.rejected]) for (const raw of map.values()) if (this.named(raw?.by, match)) found++;
823
+ return { authored, decided, unread: Math.max(0, found - authored.length - decided.length) };
824
+ }
825
+ named(value, match) {
826
+ const author = readAuthor(value);
827
+ return author !== void 0 && match(author);
942
828
  }
943
829
  /**
944
- * Replace every message a person wrote with a tombstone: the body becomes "[erased]", the author name
945
- * becomes "erased", and the mentions go. The author kind stays, so machine-made work stays marked. Thread
946
- * shape, order, and times stay. A status change of theirs, and their name as the editor of another person's
947
- * message, get the tombstone name. It works on the stored entries, so it reaches a bad message, a message past
948
- * the read limit, a thread that cannot be read, and every copy that a merge hid. Names inside other people's
949
- * messages are not touched. Returns how many messages, status changes, and edit stamps it erased.
830
+ * Give every suggestion a person wrote a tombstone author and drop its summary. An accepted one keeps its
831
+ * change, because that change is already part of the content. A pending or rejected one loses its change, and
832
+ * can no longer be accepted. A decision they made gets a tombstone decider. A decision that a read refuses (a
833
+ * principal that decided its own work) is deleted, so erase never turns it into a valid one. Returns how many
834
+ * suggestions and decisions it changed.
950
835
  */
951
836
  erasePerson(person, actor) {
952
- const { match } = this.checkPerson(person, actor);
953
- const tomb = (a) => ({ kind: a.kind, name: ERASED_NAME });
954
- const out = { messages: 0, statuses: 0, edits: 0 };
837
+ const { match } = this.comments.checkPerson(person, actor);
838
+ const out = { suggestions: 0, decisions: 0 };
839
+ const authors = /* @__PURE__ */ new Map();
840
+ for (const [id2, fields] of this.map.entries()) {
841
+ const author = fields instanceof Y3.Map ? readAuthor(fields.get("author")) : void 0;
842
+ if (author) authors.set(id2, author);
843
+ }
844
+ const status = new Map(this.list().map((s) => [s.id, s.status]));
955
845
  this.ydoc.transact(() => {
956
- for (const { fields, list } of this.stored()) {
957
- const entries = list?.toArray() ?? [];
958
- for (let i = entries.length - 1; i >= 0; i--) {
959
- const raw = entries[i];
960
- if (typeof raw !== "object" || raw === null) continue;
961
- const author = readAuthor(raw.author);
962
- const editor = readAuthor(raw.editedBy);
963
- let next;
964
- if (author && match(author)) {
965
- const id2 = typeof raw.id === "string" && ID.test(raw.id) ? raw.id : globalThis.crypto.randomUUID();
966
- const at = validDate(raw.at) ? raw.at : this.time();
967
- next = { id: id2, at, author: tomb(author), body: ERASED_BODY, mentions: [], editedAt: this.time(), editedBy: tomb(author), erased: true };
968
- out.messages++;
969
- } else if (editor && match(editor)) {
970
- next = { ...raw, editedBy: tomb(editor) };
971
- out.edits++;
846
+ for (const map of [this.applied, this.rejected]) {
847
+ for (const [key2, raw] of [...map.entries()]) {
848
+ const by = readAuthor(raw?.by);
849
+ const author = authors.get(key2.slice(0, key2.lastIndexOf(":")));
850
+ if (!by || !(match(by) || author && match(author))) continue;
851
+ if (author && decidesOwn(author, by)) map.delete(key2);
852
+ else if (match(by)) {
853
+ map.set(key2, { ...raw, by: { kind: by.kind, name: ERASED_NAME } });
854
+ out.decisions++;
972
855
  }
973
- if (!next) continue;
974
- list.delete(i, 1);
975
- list.insert(i, [next]);
976
- }
977
- const state = fields.get("state");
978
- const by = readAuthor(state?.by);
979
- if (state && by && match(by)) {
980
- fields.set("state", { ...state, by: tomb(by) });
981
- out.statuses++;
982
856
  }
983
857
  }
858
+ for (const [id2, author] of authors) {
859
+ if (!match(author)) continue;
860
+ const fields = this.map.get(id2);
861
+ fields.set("author", { kind: author.kind, name: ERASED_NAME });
862
+ fields.delete("summary");
863
+ if (status.get(id2) !== "accepted") fields.set("change", null);
864
+ out.suggestions++;
865
+ }
984
866
  });
985
867
  return out;
986
868
  }
987
- get(threadId) {
988
- const { id: id2, fields } = this.fields(threadId);
989
- return this.read(id2, fields).thread;
869
+ /** Propose a change. Anyone can suggest. The host stamps the author. */
870
+ add(input, actor) {
871
+ const who = assertActor(actor);
872
+ const given = exact(input, ["anchor", "change", "summary"], "a new suggestion");
873
+ const anchor = this.comments.checkAnchor(given.anchor);
874
+ const change = this.change(given.change, anchor);
875
+ const summary = given.summary;
876
+ if (summary !== void 0 && !(typeof summary === "string" && summary.trim() !== "" && summary.length <= MAX_SUMMARY && !BODY_FORBIDDEN.test(summary))) {
877
+ invalid(`a summary must be text of 1 to ${MAX_SUMMARY} characters, with no control or invisible characters`);
878
+ }
879
+ const id2 = globalThis.crypto.randomUUID();
880
+ const createdAt = this.comments.time();
881
+ this.ydoc.transact(() => {
882
+ const fields = new Y3.Map();
883
+ this.map.set(id2, fields);
884
+ fields.set("anchor", anchor);
885
+ fields.set("change", change);
886
+ if (summary !== void 0) fields.set("summary", summary);
887
+ fields.set("author", { kind: who.kind, name: who.name });
888
+ fields.set("createdAt", createdAt);
889
+ });
890
+ return this.get(id2);
990
891
  }
991
- /** Threads in the order they were started. A malformed thread is skipped; `problems()` names it. */
892
+ /** A person decides. An agent needs the accept scope, and never decides its own suggestion. */
893
+ mayDecide(who, s, verb) {
894
+ const ok = !decidesOwn(s.author, who) && (who.kind === "human" || who.scopes.includes(ACCEPT_SCOPE));
895
+ 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`);
896
+ if (verb === "accept" && s.author.name === ERASED_NAME && s.status !== "accepted") throw new CommentsError("FORBIDDEN", "an erased suggestion has no change left and cannot be accepted");
897
+ }
898
+ /**
899
+ * Apply the change as the accepting author and record the decision, in one step. Accepting an
900
+ * accepted suggestion changes nothing. When `apply` throws, the suggestion stays pending.
901
+ */
902
+ accept(suggestionId, actor) {
903
+ const who = assertActor(actor);
904
+ if (this.ydoc._transaction !== null) {
905
+ throw new CommentsError("APPLY_FAILED", "accept cannot run inside another Yjs transaction, such as an observer or an apply hook");
906
+ }
907
+ const { id: id2, fields } = this.fields(suggestionId);
908
+ const s = this.read(id2, fields);
909
+ this.mayDecide(who, s, "accept");
910
+ if (s.status === "accepted") return s;
911
+ if (s.status === "rejected") throw new CommentsError("ALREADY_DECIDED", `suggestion ${shown(id2)} is already rejected`);
912
+ const by = { kind: who.kind, name: who.name };
913
+ const at = this.comments.time();
914
+ this.ydoc.transact((tr) => {
915
+ const edited = () => tr.changed.size > 0 || tr.deleteSet.clients.size > 0;
916
+ let receipt = null;
917
+ let failure;
918
+ try {
919
+ if (s.anchorState.state === "orphaned" || s.anchorState.state === "edited") {
920
+ throw new Error(s.anchorState.state === "orphaned" ? "the anchored content is gone" : "the anchored content has changed since the suggestion was made");
921
+ }
922
+ this.comments.checkAnchor(s.anchor);
923
+ const change = this.change(s.change, s.anchor);
924
+ receipt = this.host.apply(change, { suggestionId: id2, anchor: copy(s.anchor), author: Object.freeze({ ...s.author }), by: Object.freeze({ ...by }), ydoc: this.ydoc });
925
+ if (thenable(receipt)) throw new Error("apply must finish at once and cannot return a promise");
926
+ receipt = receipt === void 0 ? null : strictCopy(receipt);
927
+ if (JSON.stringify(receipt).length > MAX_RECEIPT) throw new Error(`a receipt holds up to ${MAX_RECEIPT} characters of JSON`);
928
+ } catch (error) {
929
+ failure = error;
930
+ receipt = null;
931
+ }
932
+ if (failure !== void 0) {
933
+ const reason = safeText(failure instanceof Error ? failure.message : failure);
934
+ if (!edited()) throw new CommentsError("APPLY_FAILED", `the change could not be applied: ${reason}`);
935
+ this.failures.push({ id: safeId(id2), reason: `apply failed after it edited the document, so the decision is recorded without a receipt: ${reason}` });
936
+ }
937
+ this.applied.set(`${id2}:${this.ydoc.clientID}`, { by, at, receipt });
938
+ });
939
+ return this.get(id2);
940
+ }
941
+ /** Same right as accept. A person can withdraw their own suggestion. */
942
+ reject(suggestionId, actor) {
943
+ const who = assertActor(actor);
944
+ const { id: id2, fields } = this.fields(suggestionId);
945
+ const s = this.read(id2, fields);
946
+ this.mayDecide(who, s, "reject");
947
+ if (s.status === "rejected") return s;
948
+ if (s.status === "accepted") throw new CommentsError("ALREADY_DECIDED", `suggestion ${shown(id2)} is already accepted`);
949
+ this.rejected.set(`${id2}:${this.ydoc.clientID}`, { by: { kind: who.kind, name: who.name }, at: this.comments.time() });
950
+ return this.get(id2);
951
+ }
952
+ get(suggestionId) {
953
+ const { id: id2, fields } = this.fields(suggestionId);
954
+ return this.read(id2, fields);
955
+ }
956
+ /** In the order they were made. A malformed suggestion is skipped, and `problems()` names it. */
992
957
  list(filter = {}) {
993
- const given = exact(filter, ["status", "anchorType", "mentioning", "anchorState"], "a filter");
994
- if (given.status !== void 0 && !STATUSES.includes(given.status)) invalid(`status must be ${STATUSES.join(" or ")}`);
995
- if (given.anchorType !== void 0 && typeof given.anchorType !== "string") invalid("anchorType must be text");
996
- if (given.mentioning !== void 0 && typeof given.mentioning !== "string") invalid("mentioning must be text");
997
- if (given.anchorState !== void 0 && !ANCHOR_STATES.includes(given.anchorState)) invalid(`anchorState must be ${ANCHOR_STATES.join(", ")}`);
998
- const wanted = given.mentioning === void 0 ? void 0 : handle(given.mentioning);
999
- 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));
958
+ const given = exact(filter, ["status", "anchorState"], "a filter");
959
+ if (given.anchorState !== void 0 && !["attached", "edited", "orphaned", "unknown"].includes(given.anchorState)) invalid("anchorState must be attached, edited, orphaned, or unknown");
960
+ if (given.status !== void 0 && !["pending", "accepted", "rejected"].includes(given.status)) invalid("status must be pending, accepted, or rejected");
961
+ 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));
1000
962
  }
1001
- /** Threads or messages that could not be read, for example from a replica that wrote bad data. */
963
+ /** Suggestions that could not be read, and reverts that failed. */
1002
964
  problems() {
1003
- return this.readAll().problems;
965
+ return [...this.readAll().problems, ...this.failures];
1004
966
  }
1005
967
  readAll() {
1006
- const threads = [];
968
+ const suggestions = [];
1007
969
  const problems = [];
970
+ const indexes = { applied: this.index(this.applied), rejected: this.index(this.rejected) };
1008
971
  for (const [id2, fields] of this.map.entries()) {
1009
972
  try {
1010
- const { thread, issues } = this.read(id2, fields);
1011
- threads.push(thread);
1012
- for (const reason of issues) problems.push({ id: safeId(id2), reason });
973
+ suggestions.push(this.read(id2, fields, indexes));
1013
974
  } catch (error) {
1014
975
  problems.push({ id: safeId(id2), reason: error.message });
1015
976
  }
1016
977
  }
1017
- return { threads, problems };
978
+ return { suggestions, problems };
979
+ }
980
+ /**
981
+ * Two replicas can accept one suggestion before they meet. After a merge, the application of the
982
+ * lowest client id stays, and the host reverts each other one. Every replica reaches the same result.
983
+ * A revert that throws leaves its record, so the next merge tries again.
984
+ */
985
+ settle() {
986
+ const groups = this.index(this.applied);
987
+ const indexes = { applied: groups, rejected: this.index(this.rejected) };
988
+ this.failures = this.failures.filter((f) => f.reason.startsWith("apply failed") || f.reason.startsWith("a duplicate application has no receipt"));
989
+ this.ydoc.transact(() => {
990
+ for (const [id2, entries] of groups) {
991
+ if (entries.length < 2 || !ID.test(id2) || !this.map.has(id2)) continue;
992
+ let s;
993
+ try {
994
+ s = this.read(id2, this.map.get(id2), indexes);
995
+ } catch {
996
+ continue;
997
+ }
998
+ for (const loser of this.records(entries, id2, s.author).slice(1)) {
999
+ const key2 = `${id2}:${loser.client}`;
1000
+ if (this.host.idempotent) {
1001
+ this.applied.delete(key2);
1002
+ continue;
1003
+ }
1004
+ if (loser.receipt === null) {
1005
+ this.failures.push({ id: safeId(id2), reason: "a duplicate application has no receipt, so its edit cannot be undone and stays in the document" });
1006
+ this.applied.delete(key2);
1007
+ continue;
1008
+ }
1009
+ try {
1010
+ this.host.revert?.(loser.receipt, { suggestionId: id2, anchor: copy(s.anchor), author: Object.freeze({ ...s.author }), by: Object.freeze({ ...loser.by }), ydoc: this.ydoc });
1011
+ this.applied.delete(key2);
1012
+ } catch (error) {
1013
+ this.failures.push({ id: safeId(id2), reason: `a duplicate application could not be reverted: ${safeText(error instanceof Error ? error.message : error)}` });
1014
+ }
1015
+ }
1016
+ }
1017
+ });
1018
1018
  }
1019
1019
  };
1020
1020
 
@@ -1032,9 +1032,9 @@ export {
1032
1032
  invalid,
1033
1033
  exact,
1034
1034
  validDate,
1035
- ACCEPT_SCOPE,
1036
- Suggestions,
1037
1035
  MODERATE_SCOPE,
1038
1036
  ERASED_BODY,
1039
- Comments
1037
+ Comments,
1038
+ ACCEPT_SCOPE,
1039
+ Suggestions
1040
1040
  };