agentic-comments 0.6.1 → 0.8.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.
@@ -1,16 +1,13 @@
1
1
  // src/errors.ts
2
2
  var CommentsError = class extends Error {
3
- constructor(code, message) {
4
- super(message);
3
+ constructor(code, message, options) {
4
+ super(message, options);
5
5
  this.code = code;
6
6
  this.name = "CommentsError";
7
7
  }
8
8
  code;
9
9
  };
10
10
 
11
- // src/anchor.ts
12
- import * as Y from "yjs";
13
-
14
11
  // src/text.ts
15
12
  var HIDDEN = /[\p{Cc}\p{Cf}]/u;
16
13
  var ID = /^[\w-]{1,64}$/;
@@ -21,6 +18,7 @@ var shown = (value) => JSON.stringify(safeId(value));
21
18
  var safeText = (value) => String(value).replace(/[^\p{L}\p{N}\p{P}\p{S} ]/gu, "?").slice(0, 200);
22
19
 
23
20
  // src/anchor.ts
21
+ import * as Y from "yjs";
24
22
  var MAX_QUOTE = 2e3;
25
23
  var MAX_ANCHOR = 4096;
26
24
  var MAX_KEY = 256;
@@ -180,521 +178,179 @@ var strictCopy = (value) => JSON.parse(
180
178
  })
181
179
  );
182
180
 
183
- // src/suggestions.ts
181
+ // src/threads.ts
184
182
  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();
183
+ var MODERATE_SCOPE = "comments:moderate";
184
+ var ERASED_BODY = "[erased]";
185
+ var THREADS = "comments.threads";
186
+ var MAX_BODY = 1e4;
187
+ var MAX_MENTIONS = 50;
188
+ var MAX_MESSAGES = 1e3;
189
+ var READ_LIMIT = 2 * MAX_MESSAGES;
190
+ var STATUSES = ["open", "resolved"];
191
+ var ANCHOR_STATES = ["attached", "edited", "orphaned", "unknown"];
192
+ var HANDLE = /^[\p{L}\p{N}](?:[\p{L}\p{N}\p{M}_.-]{0,62}[\p{L}\p{N}\p{M}])?$/u;
193
+ var MENTION = /(?<![\p{L}\p{N}_@.])@([\p{L}\p{N}][\p{L}\p{N}\p{M}_.-]*)(?![\p{L}\p{N}\p{M}_.-]|\/)/gu;
194
+ function body(value) {
195
+ if (typeof value !== "string" || value.trim() === "" || value.length > MAX_BODY || BODY_FORBIDDEN.test(value)) {
196
+ invalid(`a body must be text of 1 to ${MAX_BODY} characters, with no control, invisible, or direction characters`);
208
197
  }
209
- comments;
210
- host;
211
- onTransaction;
212
- failures = [];
213
- /** Stop listening to the document. */
214
- close() {
215
- this.ydoc.off("afterTransaction", this.onTransaction);
198
+ return value;
199
+ }
200
+ var handle = (value) => value.normalize("NFC").replace(/^@+/, "").toLowerCase().normalize("NFC");
201
+ var okHandle = (value) => HANDLE.test(value) && !VARIATION.test(value);
202
+ var validHandle = (value) => typeof value === "string" && okHandle(value) && handle(value) === value;
203
+ function mentionsOf(text, explicit) {
204
+ const prose = text.replace(/`[^`]*`/g, " ");
205
+ const found = [...prose.matchAll(MENTION)].map((m) => {
206
+ const name = m[1].replace(/[._-]+$/, "");
207
+ if (!okHandle(handle(name))) invalid(`"@${name.slice(0, 20)}" is not a valid handle: use up to 64 characters`);
208
+ return handle(name);
209
+ });
210
+ let listed = [];
211
+ if (explicit !== void 0) {
212
+ const snapshot = Array.isArray(explicit) ? Array.from(explicit) : [];
213
+ const ok = Array.isArray(explicit) && snapshot.length <= MAX_MENTIONS && snapshot.every((m) => typeof m === "string" && okHandle(handle(m)));
214
+ if (!ok) invalid(`mentions must be a list of up to ${MAX_MENTIONS} handles of letters, digits, and . _ -`);
215
+ listed = snapshot.map(handle);
216
216
  }
217
- get ydoc() {
218
- return this.comments.ydoc;
217
+ const all = [.../* @__PURE__ */ new Set([...found, ...listed])];
218
+ if (all.length > MAX_MENTIONS) invalid(`a message can mention up to ${MAX_MENTIONS} handles`);
219
+ return all;
220
+ }
221
+ var Comments = class {
222
+ /** Every replica of a document must register the same custom anchor kinds. Others read those threads as malformed. */
223
+ constructor(ydoc, options = {}) {
224
+ this.ydoc = ydoc;
225
+ 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") {
226
+ invalid("options must be an object with an optional now function, anchors list, and exists function");
227
+ }
228
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
229
+ this.exists = options.exists;
230
+ for (const kind of [...BUILT_IN_KINDS, ...options.anchors ?? []]) {
231
+ if (typeof kind?.type !== "string" || kind.type === "" || typeof kind.parse !== "function" || this.kinds.has(kind.type)) {
232
+ throw new CommentsError("INVALID_ANCHOR", `the anchor type "${String(kind?.type)}" is empty, has no parse, or is already defined`);
233
+ }
234
+ this.kinds.set(kind.type, kind);
235
+ }
219
236
  }
237
+ ydoc;
238
+ kinds = /* @__PURE__ */ new Map();
239
+ now;
240
+ exists;
220
241
  get map() {
221
- return this.ydoc.getMap(SUGGESTIONS);
242
+ return this.ydoc.getMap(THREADS);
222
243
  }
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 };
244
+ /** The Yjs map that holds the threads. `Notifications` watches it. */
245
+ get threadsMap() {
246
+ return this.map;
226
247
  }
227
- get applied() {
228
- return this.ydoc.getMap(APPLIED);
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;
229
254
  }
230
- get rejected() {
231
- return this.ydoc.getMap(REJECTED);
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
+ }
232
265
  }
233
- change(raw, anchor) {
234
- let clean;
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(", ")}`);
235
271
  try {
236
- clean = strictCopy(raw);
237
- if (this.host.parse) clean = strictCopy(this.host.parse(copy(clean), { anchor: copy(anchor) }));
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;
238
276
  } catch (error) {
239
- return invalid(`the change is refused: ${safeText(error instanceof Error ? error.message : error)}`);
277
+ if (error instanceof CommentsError) throw error;
278
+ throw new CommentsError("INVALID_ANCHOR", error instanceof Error ? error.message : String(error));
240
279
  }
241
- if (JSON.stringify(clean).length > MAX_CHANGE) invalid(`a change holds up to ${MAX_CHANGE} characters of JSON`);
242
- return clean;
243
280
  }
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;
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) => {
256
294
  try {
257
- receipt = r.receipt === void 0 ? null : strictCopy(r.receipt);
258
- if (JSON.stringify(receipt).length > MAX_RECEIPT) continue;
295
+ return Y2.createAbsolutePositionFromRelativePosition(Y2.createRelativePositionFromJSON(json), this.ydoc);
259
296
  } catch {
260
- continue;
297
+ return null;
261
298
  }
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]);
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");
274
305
  }
275
- return groups;
276
306
  }
277
- read(id2, fields, indexes = { applied: this.index(this.applied), rejected: this.index(this.rejected) }) {
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
+ };
278
316
  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
- };
317
+ return this.readMessage(copy(message));
305
318
  } catch (error) {
306
- throw new CommentsError("CORRUPT", `suggestion ${shown(id2)} is malformed: ${safeText(error instanceof Error ? error.message : error)}`);
319
+ return invalid(`the message cannot be stored: ${error.message}`);
307
320
  }
308
321
  }
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
- // src/threads.ts
526
- import * as Y3 from "yjs";
527
- var MODERATE_SCOPE = "comments:moderate";
528
- var ERASED_BODY = "[erased]";
529
- var THREADS = "comments.threads";
530
- var MAX_BODY = 1e4;
531
- var MAX_MENTIONS = 50;
532
- var MAX_MESSAGES = 1e3;
533
- var READ_LIMIT = 2 * MAX_MESSAGES;
534
- var STATUSES = ["open", "resolved"];
535
- var ANCHOR_STATES = ["attached", "edited", "orphaned", "unknown"];
536
- var HANDLE = /^[\p{L}\p{N}](?:[\p{L}\p{N}\p{M}_.-]{0,62}[\p{L}\p{N}\p{M}])?$/u;
537
- var MENTION = /(?<![\p{L}\p{N}_@.])@([\p{L}\p{N}][\p{L}\p{N}\p{M}_.-]*)(?![\p{L}\p{N}\p{M}_.-]|\/)/gu;
538
- function body(value) {
539
- if (typeof value !== "string" || value.trim() === "" || value.length > MAX_BODY || BODY_FORBIDDEN.test(value)) {
540
- invalid(`a body must be text of 1 to ${MAX_BODY} characters, with no control, invisible, or direction characters`);
541
- }
542
- return value;
543
- }
544
- var handle = (value) => value.normalize("NFC").replace(/^@+/, "").toLowerCase().normalize("NFC");
545
- var okHandle = (value) => HANDLE.test(value) && !VARIATION.test(value);
546
- var validHandle = (value) => typeof value === "string" && okHandle(value) && handle(value) === value;
547
- function mentionsOf(text, explicit) {
548
- const prose = text.replace(/`[^`]*`/g, " ");
549
- const found = [...prose.matchAll(MENTION)].map((m) => {
550
- const name = m[1].replace(/[._-]+$/, "");
551
- if (!okHandle(handle(name))) invalid(`"@${name.slice(0, 20)}" is not a valid handle: use up to 64 characters`);
552
- return handle(name);
553
- });
554
- let listed = [];
555
- if (explicit !== void 0) {
556
- const snapshot = Array.isArray(explicit) ? Array.from(explicit) : [];
557
- const ok = Array.isArray(explicit) && snapshot.length <= MAX_MENTIONS && snapshot.every((m) => typeof m === "string" && okHandle(handle(m)));
558
- if (!ok) invalid(`mentions must be a list of up to ${MAX_MENTIONS} handles of letters, digits, and . _ -`);
559
- listed = snapshot.map(handle);
560
- }
561
- const all = [.../* @__PURE__ */ new Set([...found, ...listed])];
562
- if (all.length > MAX_MENTIONS) invalid(`a message can mention up to ${MAX_MENTIONS} handles`);
563
- return all;
564
- }
565
- var Comments = class {
566
- /** Every replica of a document must register the same custom anchor kinds. Others read those threads as malformed. */
567
- constructor(ydoc, options = {}) {
568
- this.ydoc = ydoc;
569
- 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") {
570
- invalid("options must be an object with an optional now function, anchors list, and exists function");
571
- }
572
- this.now = options.now ?? (() => /* @__PURE__ */ new Date());
573
- this.exists = options.exists;
574
- for (const kind of [...BUILT_IN_KINDS, ...options.anchors ?? []]) {
575
- if (typeof kind?.type !== "string" || kind.type === "" || typeof kind.parse !== "function" || this.kinds.has(kind.type)) {
576
- throw new CommentsError("INVALID_ANCHOR", `the anchor type "${String(kind?.type)}" is empty, has no parse, or is already defined`);
577
- }
578
- this.kinds.set(kind.type, kind);
579
- }
580
- }
581
- ydoc;
582
- kinds = /* @__PURE__ */ new Map();
583
- now;
584
- exists;
585
- get map() {
586
- return this.ydoc.getMap(THREADS);
587
- }
588
- /** The Yjs map that holds the threads. `Notifications` watches it. */
589
- get threadsMap() {
590
- return this.map;
591
- }
592
- /** The clock, as an ISO time. Used by `Suggestions`. */
593
- time() {
594
- const at = this.now();
595
- const text = at instanceof Date && !Number.isNaN(at.getTime()) ? at.toISOString() : "";
596
- if (!ISO_TIME.test(text)) invalid("the clock must return a date between the years 0000 and 9999");
597
- return text;
598
- }
599
- /** Whether the content under an anchor is still there. This never throws. */
600
- anchorState(anchor) {
601
- if (anchor.type === "text") return textState(anchor, this.ydoc);
602
- if (!this.exists) return { state: "unknown" };
603
- try {
604
- const answer = this.exists(anchor);
605
- return answer === true ? { state: "attached" } : answer === false ? { state: "orphaned" } : { state: "unknown" };
606
- } catch {
607
- return { state: "unknown" };
608
- }
609
- }
610
- /** Read an anchor from stored data. This does not check the document. */
611
- parseAnchor(raw) {
612
- const type = raw?.type;
613
- const kind = typeof raw === "object" && raw !== null && !Array.isArray(raw) && typeof type === "string" ? this.kinds.get(type) : void 0;
614
- if (!kind) throw new CommentsError("INVALID_ANCHOR", `an anchor needs a type: ${[...this.kinds.keys()].join(", ")}`);
615
- try {
616
- const parsed = copy(kind.parse(strictCopy(raw)));
617
- if (JSON.stringify(parsed).length > MAX_ANCHOR) throw new Error(`an anchor holds up to ${MAX_ANCHOR} characters of JSON`);
618
- if (parsed?.type !== kind.type) throw new Error(`the "${kind.type}" kind must return an anchor of its own type`);
619
- return parsed;
620
- } catch (error) {
621
- if (error instanceof CommentsError) throw error;
622
- throw new CommentsError("INVALID_ANCHOR", error instanceof Error ? error.message : String(error));
623
- }
624
- }
625
- /** Parse an anchor from input and check it against this document. Throws `INVALID_ANCHOR` when it fails. */
626
- checkAnchor(raw) {
627
- const anchor = this.parseAnchor(raw);
628
- if (JSON.stringify(this.parseAnchor(anchor)) !== JSON.stringify(anchor)) {
629
- throw new CommentsError("INVALID_ANCHOR", "an anchor kind must parse its own output to the same anchor");
630
- }
631
- this.checkInDocument(anchor);
632
- return anchor;
633
- }
634
- /** A text anchor must name text in this document, in order, and match its quote. */
635
- checkInDocument(anchor) {
636
- if (anchor.type !== "text") return;
637
- const at = (json) => {
638
- try {
639
- return Y3.createAbsolutePositionFromRelativePosition(Y3.createRelativePositionFromJSON(json), this.ydoc);
640
- } catch {
641
- return null;
642
- }
643
- };
644
- const from = at(anchor.start);
645
- const to = at(anchor.end);
646
- 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) {
648
- throw new CommentsError("INVALID_ANCHOR", "a text anchor must name text in this document, in order, matching its quoted text");
649
- }
650
- }
651
- /** Build a message, and refuse it unless a read would show it back. Nothing is written yet. */
652
- message(text, explicit, actor) {
653
- const message = {
654
- id: globalThis.crypto.randomUUID(),
655
- at: this.time(),
656
- author: { kind: actor.kind, name: actor.name },
657
- body: text,
658
- mentions: mentionsOf(text, explicit)
659
- };
660
- try {
661
- return this.readMessage(copy(message));
662
- } catch (error) {
663
- return invalid(`the message cannot be stored: ${error.message}`);
664
- }
665
- }
666
- readMessage(raw) {
667
- const m = raw ?? {};
668
- const author = readAuthor(m.author);
669
- 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)) {
670
- throw new Error("a message has no id, ISO time, valid author, or body");
671
- }
672
- if (!Array.isArray(m.mentions) || m.mentions.length > MAX_MENTIONS || !m.mentions.every(validHandle)) throw new Error("a message has bad mentions");
673
- const editor = m.editedBy === void 0 ? void 0 : readAuthor(m.editedBy);
674
- if (m.editedAt === void 0 !== (m.editedBy === void 0) || m.editedAt !== void 0 && !validDate(m.editedAt) || m.editedBy !== void 0 && !editor) {
675
- throw new Error("a message has a bad edit stamp");
676
- }
677
- if (m.erased !== void 0 && (m.erased !== true || m.body !== ERASED_BODY || author.name !== ERASED_NAME || m.mentions.length > 0)) throw new Error("a message has a bad erased flag");
678
- return {
679
- id: m.id,
680
- at: m.at,
681
- author,
682
- body: m.body,
683
- mentions: [...m.mentions],
684
- ...editor ? { editedAt: m.editedAt, editedBy: editor } : {},
685
- ...m.erased ? { erased: true } : {}
686
- };
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
+ const editor = m.editedBy === void 0 ? void 0 : readAuthor(m.editedBy);
330
+ if (m.editedAt === void 0 !== (m.editedBy === void 0) || m.editedAt !== void 0 && !validDate(m.editedAt) || m.editedBy !== void 0 && !editor) {
331
+ throw new Error("a message has a bad edit stamp");
332
+ }
333
+ if (m.erased !== void 0 && (m.erased !== true || m.body !== ERASED_BODY || author.name !== ERASED_NAME || m.mentions.length > 0)) throw new Error("a message has a bad erased flag");
334
+ return {
335
+ id: m.id,
336
+ at: m.at,
337
+ author,
338
+ body: m.body,
339
+ mentions: [...m.mentions],
340
+ ...editor ? { editedAt: m.editedAt, editedBy: editor } : {},
341
+ ...m.erased ? { erased: true } : {}
342
+ };
687
343
  }
688
344
  /** A thread with a bad anchor or status is malformed. A bad message is skipped and named in `issues`. */
689
345
  read(id2, fields) {
690
346
  try {
691
347
  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");
348
+ if (!(fields instanceof Y2.Map)) throw new Error("the thread is not a map");
693
349
  const state = fields.get("state") ?? {};
694
350
  const by = readAuthor(state.by);
695
351
  if (!STATUSES.includes(state.status) || !by || !validDate(state.at)) throw new Error("the thread status is not valid");
696
352
  const list = fields.get("messages");
697
- if (!(list instanceof Y3.Array)) throw new Error("the thread has no message list");
353
+ if (!(list instanceof Y2.Array)) throw new Error("the thread has no message list");
698
354
  const messages = [];
699
355
  const issues = [];
700
356
  const raw = list.toArray();
@@ -753,11 +409,11 @@ var Comments = class {
753
409
  const first = this.message(body(given.body), given.mentions, who);
754
410
  const id2 = globalThis.crypto.randomUUID();
755
411
  this.ydoc.transact(() => {
756
- const fields = new Y3.Map();
412
+ const fields = new Y2.Map();
757
413
  this.map.set(id2, fields);
758
414
  fields.set("anchor", anchor);
759
415
  fields.set("state", { status: "open", by: copy(first.author), at: first.at });
760
- const messages = new Y3.Array();
416
+ const messages = new Y2.Array();
761
417
  fields.set("messages", messages);
762
418
  messages.push([copy(first)]);
763
419
  });
@@ -902,7 +558,7 @@ var Comments = class {
902
558
  }
903
559
  /** The raw stored threads, for the code that must see data a read hides. `list` is missing when the message field is bad. */
904
560
  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 }] : []);
561
+ 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
562
  }
907
563
  /**
908
564
  * What a person wrote in this document: their messages, the status change of theirs that stands, and the
@@ -911,115 +567,458 @@ var Comments = class {
911
567
  * lost a merge. When it is not 0 the export is not complete. A person may export their own data.
912
568
  */
913
569
  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
- }
570
+ const { name, match } = this.checkPerson(person, actor);
571
+ const threads = [];
572
+ const edits = [];
573
+ let shownCount = 0;
574
+ for (const thread of this.list()) {
575
+ const messages = thread.messages.filter((m) => match(m.author));
576
+ shownCount += messages.length;
577
+ const status = match(thread.statusBy);
578
+ if (status) shownCount++;
579
+ 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 });
580
+ if (messages.length === 0 && !status) continue;
581
+ threads.push({
582
+ threadId: thread.id,
583
+ anchor: copy(thread.anchor),
584
+ messages: copy(messages),
585
+ ...status ? { statusChange: { status: thread.status, at: thread.statusAt, by: copy(thread.statusBy) } } : {}
586
+ });
587
+ }
588
+ let found = 0;
589
+ const named = (value) => {
590
+ const author = readAuthor(value);
591
+ return author !== void 0 && match(author);
592
+ };
593
+ for (const { fields, list } of this.stored()) {
594
+ for (const raw of list?.toArray() ?? []) if (named(raw?.author) || named(raw?.editedBy)) found++;
595
+ if (named(fields.get("state")?.by)) found++;
596
+ }
597
+ return { person: name, exportedAt: this.time(), threads, edits, unread: Math.max(0, found - shownCount - edits.length) };
598
+ }
599
+ /**
600
+ * Replace every message a person wrote with a tombstone: the body becomes "[erased]", the author name
601
+ * becomes "erased", and the mentions go. The author kind stays, so machine-made work stays marked. Thread
602
+ * shape, order, and times stay. A status change of theirs, and their name as the editor of another person's
603
+ * message, get the tombstone name. It works on the stored entries, so it reaches a bad message, a message past
604
+ * the read limit, a thread that cannot be read, and every copy that a merge hid. Names inside other people's
605
+ * messages are not touched. Returns how many messages, status changes, and edit stamps it erased.
606
+ */
607
+ erasePerson(person, actor) {
608
+ const { match } = this.checkPerson(person, actor);
609
+ const tomb = (a) => ({ kind: a.kind, name: ERASED_NAME });
610
+ const out = { messages: 0, statuses: 0, edits: 0 };
611
+ this.ydoc.transact(() => {
612
+ for (const { fields, list } of this.stored()) {
613
+ const entries = list?.toArray() ?? [];
614
+ for (let i = entries.length - 1; i >= 0; i--) {
615
+ const raw = entries[i];
616
+ if (typeof raw !== "object" || raw === null) continue;
617
+ const author = readAuthor(raw.author);
618
+ const editor = readAuthor(raw.editedBy);
619
+ let next;
620
+ if (author && match(author)) {
621
+ const id2 = typeof raw.id === "string" && ID.test(raw.id) ? raw.id : globalThis.crypto.randomUUID();
622
+ const at = validDate(raw.at) ? raw.at : this.time();
623
+ next = { id: id2, at, author: tomb(author), body: ERASED_BODY, mentions: [], editedAt: this.time(), editedBy: tomb(author), erased: true };
624
+ out.messages++;
625
+ } else if (editor && match(editor)) {
626
+ next = { ...raw, editedBy: tomb(editor) };
627
+ out.edits++;
628
+ }
629
+ if (!next) continue;
630
+ list.delete(i, 1);
631
+ list.insert(i, [next]);
632
+ }
633
+ const state = fields.get("state");
634
+ const by = readAuthor(state?.by);
635
+ if (state && by && match(by)) {
636
+ fields.set("state", { ...state, by: tomb(by) });
637
+ out.statuses++;
638
+ }
639
+ }
640
+ });
641
+ return out;
642
+ }
643
+ get(threadId) {
644
+ const { id: id2, fields } = this.fields(threadId);
645
+ return this.read(id2, fields).thread;
646
+ }
647
+ /** Threads in the order they were started. A malformed thread is skipped; `problems()` names it. */
648
+ list(filter = {}) {
649
+ const given = exact(filter, ["status", "anchorType", "mentioning", "anchorState"], "a filter");
650
+ if (given.status !== void 0 && !STATUSES.includes(given.status)) invalid(`status must be ${STATUSES.join(" or ")}`);
651
+ if (given.anchorType !== void 0 && typeof given.anchorType !== "string") invalid("anchorType must be text");
652
+ if (given.mentioning !== void 0 && typeof given.mentioning !== "string") invalid("mentioning must be text");
653
+ if (given.anchorState !== void 0 && !ANCHOR_STATES.includes(given.anchorState)) invalid(`anchorState must be ${ANCHOR_STATES.join(", ")}`);
654
+ const wanted = given.mentioning === void 0 ? void 0 : handle(given.mentioning);
655
+ 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));
656
+ }
657
+ /** Threads or messages that could not be read, for example from a replica that wrote bad data. */
658
+ problems() {
659
+ return this.readAll().problems;
660
+ }
661
+ readAll() {
662
+ const threads = [];
663
+ const problems = [];
664
+ for (const [id2, fields] of this.map.entries()) {
665
+ try {
666
+ const { thread, issues } = this.read(id2, fields);
667
+ threads.push(thread);
668
+ for (const reason of issues) problems.push({ id: safeId(id2), reason });
669
+ } catch (error) {
670
+ problems.push({ id: safeId(id2), reason: error.message });
671
+ }
672
+ }
673
+ return { threads, problems };
674
+ }
675
+ };
676
+
677
+ // src/suggestions.ts
678
+ import * as Y3 from "yjs";
679
+ var ACCEPT_SCOPE = "comments:accept";
680
+ var MAX_CHANGE = 64e3;
681
+ var MAX_RECEIPT = 16e3;
682
+ var MAX_SUMMARY = 1e3;
683
+ var SUGGESTIONS = "comments.suggestions";
684
+ var APPLIED = "comments.suggestion-applied";
685
+ var REJECTED = "comments.suggestion-rejected";
686
+ var decidesOwn = (author, decider) => author.name !== ERASED_NAME && author.kind !== "human" && decider.kind !== "human" && author.name === decider.name;
687
+ var clientOf = (text) => /^(0|[1-9]\d{0,9})$/.test(text) && Number(text) <= 4294967295 ? Number(text) : void 0;
688
+ var thenable = (value) => typeof value?.then === "function";
689
+ var Suggestions = class {
690
+ constructor(comments, host) {
691
+ this.comments = comments;
692
+ this.host = host;
693
+ if (typeof host?.apply !== "function" || typeof host.revert !== "function" && host.idempotent !== true) {
694
+ invalid("a host needs apply, and either revert or idempotent: true, so two accepts at once cannot apply a change twice");
695
+ }
696
+ void [this.map, this.applied, this.rejected];
697
+ this.onTransaction = (tr) => {
698
+ if (!tr.local && [this.applied, this.map].some((type) => tr.changedParentTypes.has(type))) this.settle();
699
+ };
700
+ this.ydoc.on("afterTransaction", this.onTransaction);
701
+ this.settle();
702
+ }
703
+ comments;
704
+ host;
705
+ onTransaction;
706
+ failures = [];
707
+ /** Stop listening to the document. */
708
+ close() {
709
+ this.ydoc.off("afterTransaction", this.onTransaction);
710
+ }
711
+ get ydoc() {
712
+ return this.comments.ydoc;
713
+ }
714
+ get map() {
715
+ return this.ydoc.getMap(SUGGESTIONS);
716
+ }
717
+ /** The three Yjs maps that hold suggestions and their decisions. `Notifications` watches them. */
718
+ get maps() {
719
+ return { suggestions: this.map, applied: this.applied, rejected: this.rejected };
720
+ }
721
+ get applied() {
722
+ return this.ydoc.getMap(APPLIED);
723
+ }
724
+ get rejected() {
725
+ return this.ydoc.getMap(REJECTED);
726
+ }
727
+ change(raw, anchor) {
728
+ let clean;
729
+ try {
730
+ clean = strictCopy(raw);
731
+ if (this.host.parse) clean = strictCopy(this.host.parse(copy(clean), { anchor: copy(anchor) }));
732
+ } catch (error) {
733
+ return invalid(`the change is refused: ${safeText(error instanceof Error ? error.message : error)}`);
734
+ }
735
+ if (JSON.stringify(clean).length > MAX_CHANGE) invalid(`a change holds up to ${MAX_CHANGE} characters of JSON`);
736
+ return clean;
737
+ }
738
+ /**
739
+ * Records of one kind for one suggestion, in client order. A record that is not valid is not counted:
740
+ * one with no author or time, a receipt that is not small JSON, or a decider who decided their own work.
741
+ */
742
+ records(entries, id2, author) {
743
+ const out = [];
744
+ for (const [key2, raw] of entries) {
745
+ const client = clientOf(key2.slice(id2.length + 1));
746
+ const r = raw ?? {};
747
+ const by = readAuthor(r.by);
748
+ if (client === void 0 || !by || !validDate(r.at) || !author || decidesOwn(author, by)) continue;
749
+ let receipt = null;
750
+ try {
751
+ receipt = r.receipt === void 0 ? null : strictCopy(r.receipt);
752
+ if (JSON.stringify(receipt).length > MAX_RECEIPT) continue;
753
+ } catch {
754
+ continue;
755
+ }
756
+ out.push({ client, by, at: r.at, receipt });
757
+ }
758
+ return out.sort((a, b) => Number(a.receipt !== null) - Number(b.receipt !== null) || a.client - b.client);
759
+ }
760
+ /** Group the entries of a decision map by suggestion id in one pass, so a read does not scan the map per suggestion. */
761
+ index(map) {
762
+ const groups = /* @__PURE__ */ new Map();
763
+ for (const entry of map.entries()) {
764
+ const id2 = entry[0].slice(0, entry[0].lastIndexOf(":"));
765
+ const group = groups.get(id2);
766
+ if (group) group.push(entry);
767
+ else groups.set(id2, [entry]);
768
+ }
769
+ return groups;
770
+ }
771
+ read(id2, fields, indexes = { applied: this.index(this.applied), rejected: this.index(this.rejected) }) {
772
+ try {
773
+ if (!ID.test(id2)) throw new Error("the suggestion id is not short and plain");
774
+ if (!(fields instanceof Y3.Map)) throw new Error("the suggestion is not a map");
775
+ const author = readAuthor(fields.get("author"));
776
+ const createdAt = fields.get("createdAt");
777
+ const summary = fields.get("summary");
778
+ const change = fields.get("change");
779
+ if (!author || !validDate(createdAt)) throw new Error("the suggestion has no valid author or time");
780
+ if (summary !== void 0 && !(typeof summary === "string" && summary.trim() !== "" && summary.length <= MAX_SUMMARY && !BODY_FORBIDDEN.test(summary))) {
781
+ throw new Error("the summary is not valid");
782
+ }
783
+ if (change === void 0 || JSON.stringify(strictCopy(change)).length > MAX_CHANGE) throw new Error("the change is missing or too large");
784
+ const won = this.records(indexes.applied.get(id2) ?? [], id2, author)[0];
785
+ const refused = this.records(indexes.rejected.get(id2) ?? [], id2, author)[0];
786
+ const decision = won ? { status: "accepted", ...won } : refused ? { status: "rejected", ...refused } : void 0;
787
+ const anchor = this.comments.parseAnchor(fields.get("anchor"));
788
+ return {
789
+ id: id2,
790
+ anchor,
791
+ anchorState: this.comments.anchorState(anchor),
792
+ change: copy(change),
793
+ ...summary === void 0 ? {} : { summary },
794
+ author,
795
+ createdAt,
796
+ status: decision?.status ?? "pending",
797
+ ...decision ? { decidedBy: decision.by, decidedAt: decision.at } : {}
798
+ };
799
+ } catch (error) {
800
+ throw new CommentsError("CORRUPT", `suggestion ${shown(id2)} is malformed: ${safeText(error instanceof Error ? error.message : error)}`);
801
+ }
802
+ }
803
+ fields(id2) {
804
+ if (typeof id2 !== "string" || !ID.test(id2) || !this.map.has(id2)) throw new CommentsError("NO_SUGGESTION", `no suggestion with id ${shown(id2)}`);
805
+ return { id: id2, fields: this.map.get(id2) };
806
+ }
807
+ /**
808
+ * The suggestions a person wrote, and the decisions they made, in this document. `unread` counts stored
809
+ * suggestions and decision records with their name that this export cannot show: a suggestion that cannot
810
+ * be read, a decision that lost a merge, a refused decision, or a decision with no suggestion. When it is not
811
+ * 0 the export is not complete. A person may export their own data.
812
+ */
813
+ exportPerson(person, actor) {
814
+ const { match } = this.comments.checkPerson(person, actor);
815
+ const all = this.list();
816
+ const authored = all.filter((s) => match(s.author));
817
+ 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
818
  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) };
819
+ for (const fields of this.map.values()) if (fields instanceof Y3.Map && this.named(fields.get("author"), match)) found++;
820
+ for (const map of [this.applied, this.rejected]) for (const raw of map.values()) if (this.named(raw?.by, match)) found++;
821
+ return { authored, decided, unread: Math.max(0, found - authored.length - decided.length) };
822
+ }
823
+ named(value, match) {
824
+ const author = readAuthor(value);
825
+ return author !== void 0 && match(author);
942
826
  }
943
827
  /**
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.
828
+ * Give every suggestion a person wrote a tombstone author and drop its summary. An accepted one keeps its
829
+ * change, because that change is already part of the content. A pending or rejected one loses its change, and
830
+ * can no longer be accepted. A decision they made gets a tombstone decider. A decision that a read refuses (a
831
+ * principal that decided its own work) is deleted, so erase never turns it into a valid one. Returns how many
832
+ * suggestions and decisions it changed.
950
833
  */
951
834
  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 };
835
+ const { match } = this.comments.checkPerson(person, actor);
836
+ const out = { suggestions: 0, decisions: 0 };
837
+ const authors = /* @__PURE__ */ new Map();
838
+ for (const [id2, fields] of this.map.entries()) {
839
+ const author = fields instanceof Y3.Map ? readAuthor(fields.get("author")) : void 0;
840
+ if (author) authors.set(id2, author);
841
+ }
842
+ const status = new Map(this.list().map((s) => [s.id, s.status]));
955
843
  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++;
844
+ for (const map of [this.applied, this.rejected]) {
845
+ for (const [key2, raw] of [...map.entries()]) {
846
+ const by = readAuthor(raw?.by);
847
+ const author = authors.get(key2.slice(0, key2.lastIndexOf(":")));
848
+ if (!by || !(match(by) || author && match(author))) continue;
849
+ if (author && decidesOwn(author, by)) map.delete(key2);
850
+ else if (match(by)) {
851
+ map.set(key2, { ...raw, by: { kind: by.kind, name: ERASED_NAME } });
852
+ out.decisions++;
972
853
  }
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
854
  }
983
855
  }
856
+ for (const [id2, author] of authors) {
857
+ if (!match(author)) continue;
858
+ const fields = this.map.get(id2);
859
+ fields.set("author", { kind: author.kind, name: ERASED_NAME });
860
+ fields.delete("summary");
861
+ if (status.get(id2) !== "accepted") fields.set("change", null);
862
+ out.suggestions++;
863
+ }
984
864
  });
985
865
  return out;
986
866
  }
987
- get(threadId) {
988
- const { id: id2, fields } = this.fields(threadId);
989
- return this.read(id2, fields).thread;
867
+ /** Propose a change. Anyone can suggest. The host stamps the author. */
868
+ add(input, actor) {
869
+ const who = assertActor(actor);
870
+ const given = exact(input, ["anchor", "change", "summary"], "a new suggestion");
871
+ const anchor = this.comments.checkAnchor(given.anchor);
872
+ const change = this.change(given.change, anchor);
873
+ const summary = given.summary;
874
+ if (summary !== void 0 && !(typeof summary === "string" && summary.trim() !== "" && summary.length <= MAX_SUMMARY && !BODY_FORBIDDEN.test(summary))) {
875
+ invalid(`a summary must be text of 1 to ${MAX_SUMMARY} characters, with no control or invisible characters`);
876
+ }
877
+ const id2 = globalThis.crypto.randomUUID();
878
+ const createdAt = this.comments.time();
879
+ this.ydoc.transact(() => {
880
+ const fields = new Y3.Map();
881
+ this.map.set(id2, fields);
882
+ fields.set("anchor", anchor);
883
+ fields.set("change", change);
884
+ if (summary !== void 0) fields.set("summary", summary);
885
+ fields.set("author", { kind: who.kind, name: who.name });
886
+ fields.set("createdAt", createdAt);
887
+ });
888
+ return this.get(id2);
990
889
  }
991
- /** Threads in the order they were started. A malformed thread is skipped; `problems()` names it. */
890
+ /** A person decides. An agent needs the accept scope, and never decides its own suggestion. */
891
+ mayDecide(who, s, verb) {
892
+ const ok = !decidesOwn(s.author, who) && (who.kind === "human" || who.scopes.includes(ACCEPT_SCOPE));
893
+ 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`);
894
+ 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");
895
+ }
896
+ /**
897
+ * Apply the change as the accepting author and record the decision, in one step. Accepting an
898
+ * accepted suggestion changes nothing. When `apply` throws, the suggestion stays pending.
899
+ */
900
+ accept(suggestionId, actor) {
901
+ const who = assertActor(actor);
902
+ if (this.ydoc._transaction !== null) {
903
+ throw new CommentsError("APPLY_FAILED", "accept cannot run inside another Yjs transaction, such as an observer or an apply hook");
904
+ }
905
+ const { id: id2, fields } = this.fields(suggestionId);
906
+ const s = this.read(id2, fields);
907
+ this.mayDecide(who, s, "accept");
908
+ if (s.status === "accepted") return s;
909
+ if (s.status === "rejected") throw new CommentsError("ALREADY_DECIDED", `suggestion ${shown(id2)} is already rejected`);
910
+ const by = { kind: who.kind, name: who.name };
911
+ const at = this.comments.time();
912
+ this.ydoc.transact((tr) => {
913
+ const edited = () => tr.changed.size > 0 || tr.deleteSet.clients.size > 0;
914
+ let receipt = null;
915
+ let failure;
916
+ try {
917
+ if (s.anchorState.state === "orphaned" || s.anchorState.state === "edited") {
918
+ throw new Error(s.anchorState.state === "orphaned" ? "the anchored content is gone" : "the anchored content has changed since the suggestion was made");
919
+ }
920
+ this.comments.checkAnchor(s.anchor);
921
+ const change = this.change(s.change, s.anchor);
922
+ receipt = this.host.apply(change, { suggestionId: id2, anchor: copy(s.anchor), author: Object.freeze({ ...s.author }), by: Object.freeze({ ...by }), ydoc: this.ydoc });
923
+ if (thenable(receipt)) throw new Error("apply must finish at once and cannot return a promise");
924
+ receipt = receipt === void 0 ? null : strictCopy(receipt);
925
+ if (JSON.stringify(receipt).length > MAX_RECEIPT) throw new Error(`a receipt holds up to ${MAX_RECEIPT} characters of JSON`);
926
+ } catch (error) {
927
+ failure = error;
928
+ receipt = null;
929
+ }
930
+ if (failure !== void 0) {
931
+ const reason = safeText(failure instanceof Error ? failure.message : failure);
932
+ if (!edited()) throw new CommentsError("APPLY_FAILED", `the change could not be applied: ${reason}`);
933
+ this.failures.push({ id: safeId(id2), reason: `apply failed after it edited the document, so the decision is recorded without a receipt: ${reason}` });
934
+ }
935
+ this.applied.set(`${id2}:${this.ydoc.clientID}`, { by, at, receipt });
936
+ });
937
+ return this.get(id2);
938
+ }
939
+ /** Same right as accept. A person can withdraw their own suggestion. */
940
+ reject(suggestionId, actor) {
941
+ const who = assertActor(actor);
942
+ const { id: id2, fields } = this.fields(suggestionId);
943
+ const s = this.read(id2, fields);
944
+ this.mayDecide(who, s, "reject");
945
+ if (s.status === "rejected") return s;
946
+ if (s.status === "accepted") throw new CommentsError("ALREADY_DECIDED", `suggestion ${shown(id2)} is already accepted`);
947
+ this.rejected.set(`${id2}:${this.ydoc.clientID}`, { by: { kind: who.kind, name: who.name }, at: this.comments.time() });
948
+ return this.get(id2);
949
+ }
950
+ get(suggestionId) {
951
+ const { id: id2, fields } = this.fields(suggestionId);
952
+ return this.read(id2, fields);
953
+ }
954
+ /** In the order they were made. A malformed suggestion is skipped, and `problems()` names it. */
992
955
  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));
956
+ const given = exact(filter, ["status", "anchorState"], "a filter");
957
+ if (given.anchorState !== void 0 && !["attached", "edited", "orphaned", "unknown"].includes(given.anchorState)) invalid("anchorState must be attached, edited, orphaned, or unknown");
958
+ if (given.status !== void 0 && !["pending", "accepted", "rejected"].includes(given.status)) invalid("status must be pending, accepted, or rejected");
959
+ 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
960
  }
1001
- /** Threads or messages that could not be read, for example from a replica that wrote bad data. */
961
+ /** Suggestions that could not be read, and reverts that failed. */
1002
962
  problems() {
1003
- return this.readAll().problems;
963
+ return [...this.readAll().problems, ...this.failures];
1004
964
  }
1005
965
  readAll() {
1006
- const threads = [];
966
+ const suggestions = [];
1007
967
  const problems = [];
968
+ const indexes = { applied: this.index(this.applied), rejected: this.index(this.rejected) };
1008
969
  for (const [id2, fields] of this.map.entries()) {
1009
970
  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 });
971
+ suggestions.push(this.read(id2, fields, indexes));
1013
972
  } catch (error) {
1014
973
  problems.push({ id: safeId(id2), reason: error.message });
1015
974
  }
1016
975
  }
1017
- return { threads, problems };
976
+ return { suggestions, problems };
977
+ }
978
+ /**
979
+ * Two replicas can accept one suggestion before they meet. After a merge, the application of the
980
+ * lowest client id stays, and the host reverts each other one. Every replica reaches the same result.
981
+ * A revert that throws leaves its record, so the next merge tries again.
982
+ */
983
+ settle() {
984
+ const groups = this.index(this.applied);
985
+ const indexes = { applied: groups, rejected: this.index(this.rejected) };
986
+ this.failures = this.failures.filter((f) => f.reason.startsWith("apply failed") || f.reason.startsWith("a duplicate application has no receipt"));
987
+ this.ydoc.transact(() => {
988
+ for (const [id2, entries] of groups) {
989
+ if (entries.length < 2 || !ID.test(id2) || !this.map.has(id2)) continue;
990
+ let s;
991
+ try {
992
+ s = this.read(id2, this.map.get(id2), indexes);
993
+ } catch {
994
+ continue;
995
+ }
996
+ for (const loser of this.records(entries, id2, s.author).slice(1)) {
997
+ const key2 = `${id2}:${loser.client}`;
998
+ if (this.host.idempotent) {
999
+ this.applied.delete(key2);
1000
+ continue;
1001
+ }
1002
+ if (loser.receipt === null) {
1003
+ this.failures.push({ id: safeId(id2), reason: "a duplicate application has no receipt, so its edit cannot be undone and stays in the document" });
1004
+ this.applied.delete(key2);
1005
+ continue;
1006
+ }
1007
+ try {
1008
+ this.host.revert?.(loser.receipt, { suggestionId: id2, anchor: copy(s.anchor), author: Object.freeze({ ...s.author }), by: Object.freeze({ ...loser.by }), ydoc: this.ydoc });
1009
+ this.applied.delete(key2);
1010
+ } catch (error) {
1011
+ this.failures.push({ id: safeId(id2), reason: `a duplicate application could not be reverted: ${safeText(error instanceof Error ? error.message : error)}` });
1012
+ }
1013
+ }
1014
+ }
1015
+ });
1018
1016
  }
1019
1017
  };
1020
1018
 
1021
1019
  export {
1022
1020
  CommentsError,
1021
+ shown,
1023
1022
  textOf,
1024
1023
  textAnchor,
1025
1024
  textRange,
@@ -1027,14 +1026,15 @@ export {
1027
1026
  ERASED_NAME,
1028
1027
  Actor,
1029
1028
  stamp,
1029
+ assertActor,
1030
1030
  readAuthor,
1031
1031
  authorLabel,
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
  };