@vercel/board 0.16.0 → 0.18.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.
Files changed (130) hide show
  1. package/README.md +27 -27
  2. package/dist/{a2-C41hD8SF.mjs → a2-ChRLRupa.mjs} +969 -25
  3. package/dist/access.d.mts +1 -1
  4. package/dist/access.mjs +3 -3
  5. package/dist/{actors-DjMeZpnU.mjs → actors-BOamkEuz.mjs} +3 -73
  6. package/dist/attempts-CLG840Oj.mjs +20 -0
  7. package/dist/{auth-COzc09sn.mjs → auth-Drg9FiC2.mjs} +225 -2
  8. package/dist/backend-B-SJ7p6p.d.mts +680 -0
  9. package/dist/backend-a2/contract.d.mts +873 -2
  10. package/dist/backend-a2/contract.mjs +2 -2
  11. package/dist/backend-a2/scheduler-local.d.mts +1 -1
  12. package/dist/backend-a2/scheduler-local.mjs +1 -1
  13. package/dist/backend-a2/scheduler-qstash.d.mts +78 -1
  14. package/dist/backend-a2/scheduler-qstash.mjs +521 -1
  15. package/dist/backend-a2/scheduler-vercel.d.mts +37 -1
  16. package/dist/backend-a2/scheduler-vercel.mjs +203 -1
  17. package/dist/backend-a2/store-memory.d.mts +2 -2
  18. package/dist/backend-a2/store-memory.mjs +3 -3
  19. package/dist/backend-a2/store-postgres.d.mts +1 -1
  20. package/dist/backend-a2/store-postgres.mjs +2850 -2
  21. package/dist/backend-a2/store-redis-http.d.mts +1 -1
  22. package/dist/backend-a2/store-redis-http.mjs +490 -2
  23. package/dist/backend-a2/store-redis.d.mts +10 -2
  24. package/dist/backend-a2/store-redis.mjs +51 -2
  25. package/dist/backend-a2/store-sqlite.d.mts +1 -1
  26. package/dist/backend-a2/store-sqlite.mjs +3 -3
  27. package/dist/backend-a2.d.mts +2706 -3
  28. package/dist/backend-a2.mjs +1 -1
  29. package/dist/{backend-http-Bid7RIpC.d.mts → backend-http-7cNb8nJI.d.mts} +10 -3
  30. package/dist/{backend-http-0roEmjl0.mjs → backend-http-CMlL3yq3.mjs} +2 -2
  31. package/dist/{backend-http-BVRno-ca.mjs → backend-http-Ddzd5kTS.mjs} +32 -3
  32. package/dist/backend-http.d.mts +1 -1
  33. package/dist/backend-http.mjs +1 -1
  34. package/dist/backend-http.node.d.mts +1 -1
  35. package/dist/backend-http.node.mjs +1 -1
  36. package/dist/{chat-provider-XCnQlxq6.d.mts → chat-provider-DNJQyajS.d.mts} +16 -4
  37. package/dist/{chat-provider-jFSeyNmB.mjs → chat-provider-YHy-WbrB.mjs} +15 -9
  38. package/dist/cli.mjs +30 -10
  39. package/dist/{client-DGDPaN7U.mjs → client-BA-J7usH.mjs} +10 -2
  40. package/dist/{conformance-DRR23YNy.mjs → conformance-CT5EeHeC.mjs} +5 -2
  41. package/dist/conformance.d.mts +1 -1
  42. package/dist/conformance.mjs +1 -1
  43. package/dist/{engine-Bir5s8ts.mjs → engine-D0oBUBEK.mjs} +249 -163
  44. package/dist/events-CewD4kw7.mjs +75 -0
  45. package/dist/{exports-Dvc1o52I.mjs → exports-CgjfMrQ-.mjs} +35 -32
  46. package/dist/exports-ZsgjzEnM.d.mts +4 -0
  47. package/dist/external-BV_cUncs.mjs +151 -0
  48. package/dist/folds-CRYY14kc.d.mts +534 -0
  49. package/dist/{reducers-mUPFuFJV.mjs → folds-Dyjput8M.mjs} +6 -73
  50. package/dist/ids-C6AYo1bu.mjs +31 -0
  51. package/dist/index.d.mts +4 -4
  52. package/dist/{index.default-Bkg77Pxn.mjs → index.default-C5Cpz3sa.mjs} +1 -1
  53. package/dist/index.mjs +5 -5
  54. package/dist/index.node.d.mts +4 -4
  55. package/dist/index.node.mjs +3 -3
  56. package/dist/inspect/app.js +31 -31
  57. package/dist/inspect/engine.js +5 -5
  58. package/dist/internal-CvsrprnU-CRY89I-b.mjs +203 -0
  59. package/dist/{local-Cr_dSjvs.mjs → local-DhkrAqPP.mjs} +1 -1
  60. package/dist/mentions-CB6ShY4R.mjs +72 -0
  61. package/dist/{targets-yM5JBUgr.mjs → middleware-CqaCwucP.mjs} +1 -33
  62. package/dist/platform-B4TnJtWu-CwcTyKg2.mjs +32 -0
  63. package/dist/{project-CFiArraX.mjs → project-DMDST9kl.mjs} +1 -1
  64. package/dist/{provider-BGjz-mDa.d.mts → provider-B0b9Qxw4.d.mts} +170 -40
  65. package/dist/provider-B3DpYSDq.mjs +220 -0
  66. package/dist/redis-tables-DN9PfIDi.mjs +3897 -0
  67. package/dist/reducer-CYPHsSmd-BQIfozGi.d.mts +170 -0
  68. package/dist/reducer-CjmvZSAb-D_OSISmj.mjs +74 -0
  69. package/dist/{contract-CuJmlrhw.mjs → reducers-BFmIXilu.mjs} +260 -287
  70. package/dist/retryable-lazy-DZWmHpii-Bye-W8q7.mjs +19 -0
  71. package/dist/scheduler-task-X1SToDjP-BVoWuInI.mjs +69 -0
  72. package/dist/{serve-BsOOc-6_.mjs → serve-BGYw0uNL.mjs} +5 -5
  73. package/dist/{server-jwPY7_wi.mjs → server-DPxBSogx.mjs} +4 -4
  74. package/dist/server-DtFrWxi3-Boa6gdWO.mjs +3602 -0
  75. package/dist/server-qgez0Xny-D5mLfqFQ.d.mts +385 -0
  76. package/dist/{serving-BNbzc96L.mjs → serving-CNkgUPW1.mjs} +1 -1
  77. package/dist/session-clear-wHd5GCpZ-BnKSxReK.mjs +51 -0
  78. package/dist/{sql-DbiEnqM0.mjs → sql-U9WTxrwJ.mjs} +2 -2
  79. package/dist/{sqlite-db-Cbj03vmp.mjs → sqlite-db-Ds4majtT.mjs} +1 -1
  80. package/dist/store-D0MhBvg6-4Z2L0Kqd.d.mts +530 -0
  81. package/dist/store-N8PXxDAS-BWBgzBHp.mjs +5 -0
  82. package/dist/store-codec-DwrWlgQx-CYxGW0aD.mjs +61 -0
  83. package/dist/store-memory-Bas9Uq5C.mjs +872 -0
  84. package/dist/store-memory-ZLiZbH3R.mjs +2 -0
  85. package/dist/store-polling-BZt2Rf3D-BiEM6_HB.mjs +148 -0
  86. package/dist/store-presence-polling-JuAy2Ck2-CYpuK75X.mjs +109 -0
  87. package/dist/store-sqlite-BmwL3pUR.mjs +2 -0
  88. package/dist/store-sqlite-CxxPCcCT.mjs +1053 -0
  89. package/dist/stream-buffer-DrTyiIuo-BKy-vI14.mjs +53 -0
  90. package/dist/sync-github.d.mts +24 -17
  91. package/dist/sync-github.mjs +89 -22
  92. package/dist/sync-kapso.d.mts +15 -13
  93. package/dist/sync-kapso.mjs +17 -14
  94. package/dist/sync-linear.d.mts +219 -0
  95. package/dist/sync-linear.mjs +311 -0
  96. package/dist/sync-slack.d.mts +12 -17
  97. package/dist/sync-slack.mjs +54 -42
  98. package/dist/sync.chat.d.mts +1 -1
  99. package/dist/sync.chat.mjs +1 -1
  100. package/dist/sync.d.mts +3 -3
  101. package/dist/sync.mjs +5 -4
  102. package/dist/targets-C-wdR5wP.mjs +33 -0
  103. package/dist/{tokens-DKLn5P9B.mjs → tokens-DhuzKzAl.mjs} +1 -1
  104. package/dist/{types-BHCjTZdB.d.mts → types-8yq2VnKI.d.mts} +2 -2
  105. package/dist/{users-Dls7_ANV.mjs → users-yryHKFMo.mjs} +2 -2
  106. package/dist/{watch-spec-W0c7ypHJ.mjs → watchers-hash-RLtnSF-t.mjs} +233 -28
  107. package/docs/01-board/01-mounting.md +1 -1
  108. package/docs/02-sync/01-overview.md +72 -48
  109. package/docs/02-sync/02-functions.md +398 -0
  110. package/docs/02-sync/03-slack.md +30 -25
  111. package/docs/02-sync/04-github.md +62 -26
  112. package/docs/02-sync/05-whatsapp.md +23 -19
  113. package/docs/02-sync/06-providers.md +60 -27
  114. package/docs/02-sync/07-linear.md +129 -0
  115. package/docs/03-guides/04-triage.md +1 -1
  116. package/docs/04-reference/01-cli.md +26 -6
  117. package/docs/04-reference/02-board.md +18 -5
  118. package/docs/04-reference/03-http.md +5 -2
  119. package/docs/04-reference/05-conformance.md +1 -1
  120. package/docs/index.md +5 -10
  121. package/package.json +31 -6
  122. package/dist/a2-D24cvwpN.d.mts +0 -3359
  123. package/dist/access-CJU2-vaa.mjs +0 -226
  124. package/dist/attempts-Bxp9KgPf.mjs +0 -919
  125. package/dist/exports-DLiE7fLY.d.mts +0 -4
  126. package/dist/provider-CuewcDNZ.mjs +0 -205
  127. package/dist/redis-tables-B35vv4CZ.mjs +0 -842
  128. package/dist/reducers-B_vUGQRp.d.mts +0 -1172
  129. package/dist/watchers-hash-CLjsa6od.mjs +0 -225
  130. package/docs/02-sync/02-callbacks.md +0 -265
@@ -1,26 +1,41 @@
1
1
  import { n as DIRECT_UPLOAD_BYTES_MAX, r as IMPORT_BYTES_MAX, t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
2
2
  import { _ as RemoteWatcherId, b as actorKind, i as Attachment, x as platformAvatar } from "./schemas-Be08nyfW.mjs";
3
- import { _ as schedulesContract, d as SCHEDULES_SESSION, f as boardContract, g as registryContract, h as inboxSession, m as inboxContract, o as IDENTITY_SESSION, p as identityContract, s as INBOXES_SESSION, t as BOARD_SESSION, u as REGISTRY_SESSION, v as watchContract, y as watchSession } from "./contract-CuJmlrhw.mjs";
4
- import { s as isKeyIdOf, t as compileWatcher } from "./watch-spec-W0c7ypHJ.mjs";
5
- import { r as duration, t as BoardError } from "./client-DGDPaN7U.mjs";
6
- import { c as VERSION, l as SYNC, n as METHODS, t as canonicalWatchers } from "./watchers-hash-CLjsa6od.mjs";
7
- import { a as forbidden, c as toErrorResponse, r as badRequest, s as notFound, t as HttpError } from "./errors-DX3hpwWt.mjs";
8
- import { a as resolveMentions, n as actorSeen, s as replaceMentions } from "./actors-DjMeZpnU.mjs";
9
- import { n as authorize, t as FULL_ACCESS } from "./access-CJU2-vaa.mjs";
10
- import { i as authenticates } from "./auth-COzc09sn.mjs";
11
- import { a as MIDDLEWARE_ACTOR, i as DroppedError } from "./targets-yM5JBUgr.mjs";
12
- import { n as channelsReducer, s as identityCodesReducer, t as actorsReducer, u as linkedTo } from "./reducers-mUPFuFJV.mjs";
13
- import { A as view, C as linkedIds, D as readableFilter, E as pollFeed, M as viewedBy, O as ref, S as indexOf, T as personView, _ as waitUntil, a as blindText, b as eventView, c as landedFor, d as writeDelete, f as writeEdit, g as writeReaction, h as writePost, i as authorizePost, j as viewLinkedIds, k as toMessageFilter, l as trusted, m as writeLinks, n as authorizeMessage, p as writeLabels, r as authorizeOwn, s as deploymentIdentity, t as afterRetry, u as writeAttach, v as OWN_CODE, w as loadBefore, x as foldEvent, y as compileFilter } from "./attempts-Bxp9KgPf.mjs";
14
- import { t as TABLES_CONSUMER } from "./sql-DbiEnqM0.mjs";
15
- import { A2Error, idempotentId, reducer } from "experimental-a2";
3
+ import "./external-BV_cUncs.mjs";
4
+ import { f as VERSION, n as compileWatcher, o as METHODS, p as SYNC, r as hasCriteria, t as canonicalWatchers } from "./watchers-hash-RLtnSF-t.mjs";
5
+ import { r as duration, t as BoardError } from "./client-BA-J7usH.mjs";
6
+ import { n as derivedId, r as isKeyIdOf } from "./ids-C6AYo1bu.mjs";
7
+ import { a as forbidden, c as toErrorResponse, i as duplicateId, r as badRequest, s as notFound, t as HttpError } from "./errors-DX3hpwWt.mjs";
8
+ import { t as own } from "./own-OwgPWdmh.mjs";
9
+ import { n as replaceMentions } from "./mentions-CB6ShY4R.mjs";
10
+ import { _ as FULL_ACCESS, i as authenticates, v as authorize } from "./auth-Drg9FiC2.mjs";
11
+ import { a as resolveMentions, n as actorSeen } from "./actors-BOamkEuz.mjs";
12
+ import { n as MIDDLEWARE_ACTOR, t as DroppedError } from "./middleware-CqaCwucP.mjs";
13
+ import { n as syncTarget, r as withEmailMentions } from "./targets-C-wdR5wP.mjs";
14
+ import { t as A2Error } from "./session-clear-wHd5GCpZ-BnKSxReK.mjs";
15
+ import { a as reducer } from "./reducer-CjmvZSAb-D_OSISmj.mjs";
16
+ import { _ as BOARD_SESSION, a as IDENTITY_SESSION, c as SCHEDULES_SESSION, d as inboxContract, f as inboxSession, g as watchSession, h as watchContract, l as boardContract, m as schedulesContract, n as channelsReducer, o as INBOXES_SESSION, p as registryContract, r as identityCodesReducer, s as REGISTRY_SESSION, t as actorsReducer, u as identityContract } from "./reducers-BFmIXilu.mjs";
17
+ import { n as createServer, t as consumer } from "./server-DtFrWxi3-Boa6gdWO.mjs";
18
+ import { a as linkedTo, i as iso, n as foldMessages, o as personOf, t as foldActors } from "./folds-Dyjput8M.mjs";
19
+ import { t as concernedMessageId } from "./project-DMDST9kl.mjs";
20
+ import { t as TABLES_CONSUMER } from "./sql-U9WTxrwJ.mjs";
21
+ import { n as waitUntil, t as afterRetry } from "./attempts-CLG840Oj.mjs";
16
22
  import { createHash, randomBytes } from "node:crypto";
17
23
  import { mkdir, readFile, writeFile } from "node:fs/promises";
18
24
  import { dirname, join } from "node:path";
19
25
  import { AsyncLocalStorage } from "node:async_hooks";
20
- import { consumer, createServer } from "experimental-a2/server";
21
26
  import { lookup } from "node:dns";
22
27
  import { request } from "node:https";
23
28
  import { BlockList, isIP } from "node:net";
29
+ //#region ../../node_modules/.pnpm/experimental-a2@0.22.0_@upstash+qstash@2.12.0_@vercel+queue@0.7.0_ai@7.0.130_zod@4.6.5__52060f00b21aecfab049c27fa3abd2f9/node_modules/experimental-a2/dist/idempotent-id-BRJVeylj.js
30
+ async function idempotentId(...parts) {
31
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(parts)));
32
+ const bytes = new Uint8Array(digest).slice(0, 16);
33
+ bytes[6] = bytes[6] & 15 | 128;
34
+ bytes[8] = bytes[8] & 63 | 128;
35
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
36
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
37
+ }
38
+ //#endregion
24
39
  //#region src/server/blob.ts
25
40
  /** Files in memory, for tests: forgets everything when the process ends. */
26
41
  function memoryBlob() {
@@ -196,6 +211,461 @@ async function finishUpload(blob, id, principal) {
196
211
  return attachment;
197
212
  }
198
213
  //#endregion
214
+ //#region src/server/feed.ts
215
+ /** Bookkeeping events, shown only with `all`: who's who, and delivery receipts. */
216
+ const HIDDEN = /* @__PURE__ */ new Set([
217
+ "actor.seen",
218
+ "message.synced",
219
+ "message.files_synced"
220
+ ]);
221
+ /** The person a linked platform actor is, as views show it. */
222
+ function personView(actors, actor) {
223
+ const link = own(actors.links, actor);
224
+ const person = link ? own(actors.byId, link.to) : void 0;
225
+ return link ? {
226
+ id: link.to,
227
+ handle: person?.handle ?? link.to,
228
+ name: person?.name ?? link.to,
229
+ avatar: person?.avatar ?? null
230
+ } : void 0;
231
+ }
232
+ function actorView(actors, id) {
233
+ const author = own(actors.byId, id);
234
+ if (!author) return null;
235
+ const person = personView(actors, id);
236
+ return {
237
+ kind: author.kind,
238
+ handle: author.handle,
239
+ name: author.name,
240
+ avatar: author.avatar,
241
+ ...person ? { person } : {}
242
+ };
243
+ }
244
+ /** The board's own code, which reads every channel: it needs no messages to say so. */
245
+ const OWN_CODE = {
246
+ access: FULL_ACCESS,
247
+ channelOf: () => void 0
248
+ };
249
+ /** The board messages `links` point to, for `Reader.channelOf`. */
250
+ const linkedIds = (links) => links.flatMap((link) => link.message === null ? [] : [link.message]);
251
+ /**
252
+ * Links as `reader` may see them. The log keeps each snapshot; it's hidden when
253
+ * it's read. A linked message the reader's lookup doesn't have hides too: its
254
+ * snapshot shows only where it's known to be readable.
255
+ */
256
+ function readableLinks(links, reader) {
257
+ return links.map((link) => {
258
+ if (link.message === null) return {
259
+ ...link,
260
+ message: null
261
+ };
262
+ const channel = reader.access.readsAll ? void 0 : reader.channelOf(link.message);
263
+ return reader.access.readsAll || channel !== void 0 && reader.access.readable(channel) ? {
264
+ ...link,
265
+ message: ref(link.message)
266
+ } : {
267
+ url: link.url,
268
+ message: null,
269
+ snapshot: null,
270
+ unavailable: "not_readable"
271
+ };
272
+ });
273
+ }
274
+ /** The links in an event view: its payload's, its message's, and the message before a label change. */
275
+ const viewLinks = (view) => {
276
+ const { payload, message, before } = view;
277
+ return [
278
+ payload?.links,
279
+ message?.links,
280
+ before?.links
281
+ ].filter((links) => links !== void 0);
282
+ };
283
+ /** The board messages an event view's links point to, for a reader of them. */
284
+ const viewLinkedIds = (view) => viewLinks(view).flatMap((links) => links.flatMap((link) => link.message === null ? [] : [indexOf(link.message) ?? 0]));
285
+ /**
286
+ * An event view made for one reader, as `reader` sees it: a link to a message it can't read shows
287
+ * nothing of it. A watcher's match is made when its event lands and pulled later, by whatever its
288
+ * registrant may read then.
289
+ */
290
+ function viewedBy(view, reader) {
291
+ if (reader.access.readsAll) return view;
292
+ const shown = structuredClone(view);
293
+ const reread = (links) => readableLinks(links.map((link) => ({
294
+ ...link,
295
+ message: link.message === null ? null : indexOf(link.message) ?? 0
296
+ })), reader);
297
+ if (shown.payload?.links) shown.payload.links = reread(shown.payload.links);
298
+ if (shown.message) shown.message.links = reread(shown.message.links);
299
+ if (shown.before) shown.before.links = reread(shown.before.links);
300
+ return shown;
301
+ }
302
+ /** A message's or a cursor's ID as clients see it. On this board it's the log index, in decimal. */
303
+ const ref = (index) => String(index);
304
+ /** The log index a ref names on this board, or null for a string that names nothing here. */
305
+ const indexOf = (value) => /^(0|[1-9]\d{0,15})$/.test(value) ? Number(value) : null;
306
+ /** A message as `reader` sees it. Every view names its reader, so none shows a link's snapshot by default. */
307
+ function view(message, actors, reader) {
308
+ const { blind, version, schedule, ...shown } = message;
309
+ return {
310
+ ...shown,
311
+ ...blind ? { blind } : {},
312
+ id: ref(message.id),
313
+ re: message.re === null ? null : ref(message.re),
314
+ replyTo: message.replyTo === null ? null : ref(message.replyTo),
315
+ version: version ?? message.id,
316
+ ...schedule ? { schedule: {
317
+ id: ref(schedule.id),
318
+ n: schedule.n
319
+ } } : {},
320
+ links: readableLinks(message.links, reader),
321
+ labels: { ...message.labels },
322
+ reactions: Object.fromEntries(Object.entries(message.reactions).map(([emoji, ids]) => [emoji, [...ids]])),
323
+ author: actorView(actors, message.by.actor)
324
+ };
325
+ }
326
+ /** Whether accounts are linked to `id`: then it's a person, even one who never wrote on the board. */
327
+ const hasLinks = (actors, id) => Object.values(actors.links).some((link) => link.to === id);
328
+ const actorSet = (value, actors) => own(actors.byId, value) || own(actors.links, value) || hasLinks(actors, value) ? /* @__PURE__ */ new Set([value]) : new Set(own(actors.byHandle, value.toLowerCase()) ?? []);
329
+ /**
330
+ * Who a `--by` or `--mentions` value covers. A person (`julian`, or one of their
331
+ * linked accounts, `@julian` on Slack) is everyone they are: their own actor,
332
+ * their agents (`julian/claude`), and every account linked to them. An agent
333
+ * or an unlinked platform actor is just itself.
334
+ */
335
+ function whoIs(value, actors) {
336
+ const ids = actorSet(value, actors);
337
+ const out = new Set(ids);
338
+ const principals = /* @__PURE__ */ new Set();
339
+ for (const id of ids) {
340
+ const kind = own(actors.byId, id)?.kind;
341
+ const person = own(actors.links, id) ? personOf(actors, id) : kind === "person" || hasLinks(actors, id) ? id : null;
342
+ if (person === null) continue;
343
+ principals.add(person);
344
+ out.add(person);
345
+ for (const linked of linkedTo(actors, person)) out.add(linked);
346
+ }
347
+ return {
348
+ actors: out,
349
+ principals
350
+ };
351
+ }
352
+ /** Whether `author` is one of `who`: its actor, or an agent of one of its people. */
353
+ const covers = (who, actor, principal) => who.actors.has(actor) || principal !== void 0 && who.principals.has(principal);
354
+ const hasLabel = (labels, [key, values]) => Object.hasOwn(labels, key) && (values === null || values.includes(labels[key]));
355
+ /**
356
+ * Compiles filters once; handles resolve against the actors known at that
357
+ * moment. A label matches on the message or its thread's root, where sync puts
358
+ * labels, so `pr=…` means the PR's whole conversation. `messages` is the live
359
+ * state for that lookup. A message in a channel `access` can't read never
360
+ * matches, whatever the filters say.
361
+ */
362
+ function compileFilter(filters, context) {
363
+ const { access } = context;
364
+ const text = filters.text?.toLowerCase();
365
+ const labels = filters.label ?? [];
366
+ const channel = filters.channel;
367
+ const subtree = channel?.endsWith("/") ? channel.slice(0, -1) : null;
368
+ const by = filters.by === void 0 ? null : whoIs(filters.by, context.actors);
369
+ const re = filters.re === void 0 ? void 0 : indexOf(filters.re) ?? -1;
370
+ const mentions = filters.mentions === void 0 ? null : filters.mentions === "me" ? new Set(context.me === null ? [] : [context.me, ...linkedTo(context.actors, context.me)]) : whoIs(filters.mentions, context.actors).actors;
371
+ return (message) => {
372
+ if (!message || !access.readable(message.channel)) return false;
373
+ if (channel !== void 0) {
374
+ if (subtree !== null) {
375
+ if (message.channel !== subtree && !message.channel.startsWith(`${subtree}/`)) return false;
376
+ } else if (message.channel !== channel) return false;
377
+ }
378
+ if (re !== void 0 && message.id !== re && message.re !== re) return false;
379
+ if (by !== null && !covers(by, message.by.actor, message.by.principal)) return false;
380
+ if (mentions !== null && !message.mentions.some((id) => mentions.has(id))) return false;
381
+ if (filters.from !== void 0 && (message.from ?? "board") !== filters.from) return false;
382
+ if (text !== void 0 && !message.text.toLowerCase().includes(text)) return false;
383
+ if (labels.length > 0) {
384
+ const root = message.re === null ? void 0 : context.messages?.byId[message.re];
385
+ for (const label of labels) if (!hasLabel(message.labels, label) && !(root && hasLabel(root.labels, label))) return false;
386
+ }
387
+ return true;
388
+ };
389
+ }
390
+ /**
391
+ * `filter`, narrowed to the channels `access` reads among `known`, as the tables
392
+ * take it: a reader of every channel needs nothing more. A channel the caller
393
+ * didn't know of is left out, never let in.
394
+ */
395
+ function readableFilter(filter, known, access) {
396
+ if (access.readsAll) return filter;
397
+ const { channel, ...rest } = filter;
398
+ const within = (path) => !channel || path === channel.path || channel.subtree && path.startsWith(`${channel.path}/`);
399
+ return {
400
+ ...rest,
401
+ channels: [...known].filter((path) => within(path) && access.readable(path))
402
+ };
403
+ }
404
+ /** The filters as the tables take them: handles resolved to who they cover, `me` to the caller. */
405
+ function toMessageFilter(filters, context) {
406
+ const out = {};
407
+ const channel = filters.channel;
408
+ if (channel !== void 0) out.channel = channel.endsWith("/") ? {
409
+ path: channel.slice(0, -1),
410
+ subtree: true
411
+ } : {
412
+ path: channel,
413
+ subtree: false
414
+ };
415
+ if (filters.re !== void 0) out.re = indexOf(filters.re) ?? -1;
416
+ if (filters.by !== void 0) {
417
+ const who = whoIs(filters.by, context.actors);
418
+ out.by = {
419
+ actors: [...who.actors],
420
+ principals: [...who.principals]
421
+ };
422
+ }
423
+ if (filters.mentions !== void 0) out.mentions = filters.mentions === "me" ? context.me === null ? [] : [context.me, ...linkedTo(context.actors, context.me)] : [...whoIs(filters.mentions, context.actors).actors];
424
+ if (filters.from !== void 0) out.from = filters.from;
425
+ if (filters.text !== void 0) out.text = filters.text.toLowerCase();
426
+ if (filters.label?.length) out.labels = filters.label.map(([key, values]) => ({
427
+ key,
428
+ values
429
+ }));
430
+ return out;
431
+ }
432
+ /**
433
+ * Loads into `messages`, as they were just before `event`, the message it
434
+ * concerns and that message's root: what folding it and matching filters need.
435
+ * `loaded` remembers what was asked for already, found or not.
436
+ */
437
+ async function loadInto(reads, messages, loaded, event, signal) {
438
+ const id = concernedMessageId(event);
439
+ if (id === null) return;
440
+ const through = event.index - 1;
441
+ const load = async (ids) => {
442
+ const wanted = ids.filter((want) => !loaded.has(want));
443
+ if (wanted.length === 0) return;
444
+ const found = await reads.messagesAt(wanted, through, signal);
445
+ for (const want of wanted) {
446
+ loaded.add(want);
447
+ const message = found.get(want);
448
+ if (message) messages.byId[want] = message;
449
+ }
450
+ };
451
+ if (event.type === "message.posted") loaded.add(id);
452
+ else await load([id]);
453
+ const re = event.type === "message.posted" ? event.payload.re : messages.byId[id]?.re ?? null;
454
+ if (re !== null) await load([re]);
455
+ }
456
+ /** The message `event` concerns and its root, as they were just before it. */
457
+ async function loadBefore(reads, event, signal) {
458
+ const messages = {
459
+ byId: {},
460
+ replies: {}
461
+ };
462
+ await loadInto(reads, messages, /* @__PURE__ */ new Set(), event, signal);
463
+ return messages;
464
+ }
465
+ /** Folds one event into `messages` and `actors`, and returns the message it concerns before and after. */
466
+ function foldEvent(messages, actors, event) {
467
+ const id = concernedMessageId(event);
468
+ const before = id === null ? void 0 : structuredClone(messages.byId[id]);
469
+ foldMessages(messages, event);
470
+ foldActors(actors, event);
471
+ return {
472
+ id,
473
+ before,
474
+ after: id === null ? void 0 : messages.byId[id]
475
+ };
476
+ }
477
+ /** An event as subscribers receive it, with its message as it is afterwards. */
478
+ function eventView(event, after, actors, reader) {
479
+ const by = event.payload.by?.actor;
480
+ const actor = by ? own(actors.byId, by) : void 0;
481
+ const { links } = event.payload;
482
+ return {
483
+ cursor: ref(event.index),
484
+ type: event.type,
485
+ createdAt: iso(event.createdAt),
486
+ payload: payloadView(event.payload, links ? readableLinks(links, reader) : void 0),
487
+ ...after ? { message: view(after, actors, reader) } : {},
488
+ ...actor ? { actor: {
489
+ id: actor.id,
490
+ ...actorView(actors, actor.id)
491
+ } } : {}
492
+ };
493
+ }
494
+ /** An event's payload as clients see it: the message IDs in it are refs. */
495
+ function payloadView(payload, links) {
496
+ const shown = { ...payload };
497
+ for (const key of [
498
+ "id",
499
+ "re",
500
+ "replyTo"
501
+ ]) if (typeof shown[key] === "number") shown[key] = ref(shown[key]);
502
+ const schedule = shown.schedule;
503
+ if (schedule) shown.schedule = {
504
+ id: ref(schedule.id),
505
+ n: schedule.n
506
+ };
507
+ if (links) shown.links = links;
508
+ return shown;
509
+ }
510
+ /**
511
+ * Folds events one at a time and decides what a subscriber sees. It holds only
512
+ * the messages events have touched, each loaded from the tables as it was just
513
+ * before the first event that touched it. A label or reaction change is
514
+ * delivered when its message matched the filter before *or* after it, so
515
+ * subscribers see messages leave a view too.
516
+ *
517
+ * It's the one choke point for `/events` (tail, wait, and poll): an event about
518
+ * a message shows only where `access` reads its channel, and one about no
519
+ * message (who's who, a thread's sync) only to a reader of every channel.
520
+ */
521
+ var FeedProjection = class {
522
+ messages = {
523
+ byId: {},
524
+ replies: {}
525
+ };
526
+ loaded = /* @__PURE__ */ new Set();
527
+ reads;
528
+ actors;
529
+ match;
530
+ criteria;
531
+ all;
532
+ access;
533
+ /** Where each board message a view links to lives, for a reader that can't read every channel. */
534
+ linked = /* @__PURE__ */ new Map();
535
+ constructor(options) {
536
+ this.reads = options.reads;
537
+ this.actors = options.actors;
538
+ this.access = options.access;
539
+ this.match = compileFilter(options.filters, {
540
+ me: options.me,
541
+ actors: options.actors,
542
+ messages: this.messages,
543
+ access: options.access
544
+ });
545
+ this.criteria = hasCriteria(options.filters);
546
+ this.all = options.all;
547
+ }
548
+ /** Loads what `events` touch, all as of `index`, in two queries rather than two per event. */
549
+ async preload(events, index) {
550
+ const ids = /* @__PURE__ */ new Set();
551
+ for (const event of events) {
552
+ const id = concernedMessageId(event);
553
+ if (id === null) continue;
554
+ if (event.type !== "message.posted") ids.add(id);
555
+ else if (event.payload.re !== null) ids.add(event.payload.re);
556
+ }
557
+ const load = async (wanted) => {
558
+ if (wanted.length === 0) return;
559
+ const found = await this.reads.messagesAt(wanted, index);
560
+ for (const id of wanted) {
561
+ this.loaded.add(id);
562
+ const message = found.get(id);
563
+ if (message) this.messages.byId[id] = message;
564
+ }
565
+ };
566
+ await load([...ids].filter((id) => !this.loaded.has(id) && id <= index));
567
+ const roots = Object.values(this.messages.byId).flatMap((message) => message.re === null ? [] : [message.re]);
568
+ await load([...new Set(roots)].filter((id) => !this.loaded.has(id)));
569
+ }
570
+ async step(event, signal) {
571
+ await loadInto(this.reads, this.messages, this.loaded, event, signal);
572
+ const { id, before, after } = foldEvent(this.messages, this.actors, event);
573
+ const message = after ?? before;
574
+ const readable = id === null ? this.access.readsAll : message !== void 0 && this.access.readable(message.channel);
575
+ const hidden = HIDDEN.has(event.type) && !this.all;
576
+ if (!(readable && !hidden && (this.criteria ? this.match(before) || this.match(after) : id !== null || this.all))) return {
577
+ index: event.index,
578
+ view: null
579
+ };
580
+ await this.loadLinked([...after?.links ?? [], ...event.payload.links ?? []], event.index, signal);
581
+ const reader = {
582
+ access: this.access,
583
+ channelOf: (message) => this.messages.byId[message]?.channel ?? this.linked.get(message)
584
+ };
585
+ return {
586
+ index: event.index,
587
+ view: eventView(event, after, this.actors, reader)
588
+ };
589
+ }
590
+ /** Where the messages `links` point to live; a message never moves, so any version says. */
591
+ async loadLinked(links, index, signal) {
592
+ if (this.access.readsAll) return;
593
+ const wanted = [...new Set(linkedIds(links))].filter((id) => !this.messages.byId[id] && !this.linked.has(id));
594
+ if (wanted.length === 0) return;
595
+ const found = await this.reads.messagesAt(wanted, index, signal);
596
+ for (const id of wanted) this.linked.set(id, found.get(id)?.channel);
597
+ }
598
+ };
599
+ /** Resolves when the iterator yields, or with null once the signal aborts. */
600
+ function nextOrAbort(iterator, signal) {
601
+ if (signal.aborted) return Promise.resolve(null);
602
+ return new Promise((resolve, reject) => {
603
+ const onAbort = () => resolve(null);
604
+ signal.addEventListener("abort", onAbort, { once: true });
605
+ iterator.next().then((result) => {
606
+ signal.removeEventListener("abort", onAbort);
607
+ resolve(result);
608
+ }, (error) => {
609
+ signal.removeEventListener("abort", onAbort);
610
+ reject(error);
611
+ });
612
+ });
613
+ }
614
+ /** Where a feed starts: `after`, or the head when it's omitted or past it. */
615
+ async function start(options) {
616
+ const { index: head } = await options.session.state(channelsReducer);
617
+ return options.after === void 0 || options.after >= head ? head : options.after;
618
+ }
619
+ const PAGE = 200;
620
+ /**
621
+ * One long-poll: matching events already in the log return at once; otherwise
622
+ * it waits up to `waitMs` for the first new match. The cursor is the last
623
+ * event examined, matched or not, so the next poll never rescans.
624
+ */
625
+ async function pollFeed(options, waitMs, signal) {
626
+ const index = await start(options);
627
+ const feed = new FeedProjection(options);
628
+ const events = [];
629
+ let cursor = index;
630
+ const backlog = await options.session.history({
631
+ gte: index + 1,
632
+ lte: index + PAGE
633
+ });
634
+ await feed.preload(backlog, index);
635
+ for (const event of backlog) {
636
+ const item = await feed.step(event, signal);
637
+ cursor = item.index;
638
+ if (item.view) events.push(item.view);
639
+ }
640
+ if (events.length > 0 || backlog.length === PAGE || waitMs <= 0) return {
641
+ events,
642
+ cursor
643
+ };
644
+ const timeout = AbortSignal.timeout(waitMs);
645
+ const until = AbortSignal.any([signal, timeout]);
646
+ const iterator = options.session.stream({ startAfter: cursor })[Symbol.asyncIterator]();
647
+ try {
648
+ while (true) {
649
+ const next = await nextOrAbort(iterator, until);
650
+ if (next === null || next.done) return {
651
+ events,
652
+ cursor
653
+ };
654
+ const item = await feed.step(next.value, until);
655
+ cursor = item.index;
656
+ if (item.view) {
657
+ events.push(item.view);
658
+ return {
659
+ events,
660
+ cursor
661
+ };
662
+ }
663
+ }
664
+ } finally {
665
+ iterator.return?.();
666
+ }
667
+ }
668
+ //#endregion
199
669
  //#region src/server/reads.ts
200
670
  /** Keeps the tables from the board's log, one event at a time, from the first. */
201
671
  function tablesConsumer(tables) {
@@ -300,6 +770,426 @@ function createReads(tables, session, projection) {
300
770
  };
301
771
  }
302
772
  //#endregion
773
+ //#region src/server/writes.ts
774
+ /** A piece of the board's own deployment, writing as `actor` with full access. */
775
+ function deploymentIdentity(actor, handle) {
776
+ return {
777
+ principal: {
778
+ id: actor,
779
+ kind: "deployment",
780
+ handle,
781
+ access: FULL_ACCESS,
782
+ expiresAt: null
783
+ },
784
+ author: {
785
+ actor,
786
+ principal: actor,
787
+ profile: null,
788
+ session: null
789
+ },
790
+ actor: {
791
+ id: actor,
792
+ kind: "deployment",
793
+ handle,
794
+ name: handle,
795
+ avatar: null
796
+ }
797
+ };
798
+ }
799
+ /**
800
+ * The `actor.seen` a write carries, or null: who its author is, as the board's record of them
801
+ * says, unless the board knows that already. A platform user's record is board-wide, so a write
802
+ * whose identity doesn't keep it (`records: false`) leaves it as it is. A host's principal and its
803
+ * profiles are recorded as the host's, which sync never writes as.
804
+ */
805
+ function seenFor(identity, actors) {
806
+ if (identity.records === false) return null;
807
+ return actorSeen(identity.actor, actors, identity.principal.facts?.issuer === "host");
808
+ }
809
+ /** The actors a write can mention: the ones the board knows, plus the ones an email named. */
810
+ const knownWith = (actors, found) => found.length === 0 ? actors : {
811
+ ...actors,
812
+ byId: {
813
+ ...actors.byId,
814
+ ...Object.fromEntries(found.map((a) => [a.id, a]))
815
+ }
816
+ };
817
+ const messageNotFound = (id) => () => notFound(`message not found: #${id}`, "message_not_found");
818
+ /**
819
+ * A message the board looked up and the caller may act on with `permission`,
820
+ * proved against its own channel. A message they can't read answers 404, as if
821
+ * it didn't exist: IDs are sequential, so a 403 would confirm it does.
822
+ */
823
+ async function authorizeMessage(ctx, access, permission, id) {
824
+ const message = await ctx.reads.message(id);
825
+ if (!message) throw messageNotFound(id)();
826
+ return {
827
+ message,
828
+ proof: authorize(access, permission, message.channel, messageNotFound(id))
829
+ };
830
+ }
831
+ /**
832
+ * Your own message, to edit or delete: `post` in its channel. Where you can't
833
+ * read, it's yours only if it was posted without reading too: a write-only
834
+ * source fixes what it wrote, but a principal ID it shares with a caller that
835
+ * reads (a repo's workflows share one) doesn't reach that caller's messages.
836
+ * Someone else's answers 404 where you can't read it, and 403 where you can.
837
+ */
838
+ async function authorizeOwn(ctx, principal, id, verb) {
839
+ const message = await ctx.reads.message(id);
840
+ if (!message) throw messageNotFound(id)();
841
+ const readable = principal.access.readable(message.channel);
842
+ if (message.by.principal !== principal.id || !readable && !message.blind) {
843
+ if (!readable) throw messageNotFound(id)();
844
+ throw forbidden(`only the author can ${verb} a message`);
845
+ }
846
+ return {
847
+ message,
848
+ proof: authorize(principal.access, "post", message.channel)
849
+ };
850
+ }
851
+ const hasLabels = (labels) => Object.keys(labels ?? {}).length > 0;
852
+ /**
853
+ * Proofs for a post. A root lands in the channel it names, which the caller
854
+ * needn't read: write-only is `['post']`. A reply lands in its root's channel,
855
+ * looked up here and never taken from the request, and needs `read` there too,
856
+ * since addressing a thread you can't see would confirm it exists.
857
+ */
858
+ async function authorizePost(ctx, access, input) {
859
+ let channel;
860
+ let re;
861
+ if (input.re !== void 0) {
862
+ re = (await authorizeMessage(ctx, access, "read", input.re)).message;
863
+ channel = re.channel;
864
+ } else {
865
+ if (!input.channel) throw badRequest("a post needs a channel");
866
+ channel = input.channel;
867
+ }
868
+ return {
869
+ post: authorize(access, "post", channel),
870
+ ...re ? { re } : {},
871
+ ...hasLabels(input.labels) ? { label: authorize(access, "label", channel) } : {},
872
+ ...input.sync?.length ? { sync: authorize(access, "sync", channel) } : {}
873
+ };
874
+ }
875
+ /**
876
+ * Spends a proof on its target: a write checked against one channel can't land
877
+ * in another. A mismatch is the board's own bug, never the caller's.
878
+ */
879
+ function spend(proof, permission, target) {
880
+ if (!proof || proof.permission !== permission || proof.channel !== target.channel) throw new Error(`a ${permission} write in ${target.channel} needs a proof for that channel`);
881
+ }
882
+ /** Full-access proofs for the board's own code (sync), still checked against the channel each write lands in. */
883
+ const trusted = {
884
+ message: (ctx, permission, id) => authorizeMessage(ctx, FULL_ACCESS, permission, id),
885
+ post: (ctx, input) => authorizePost(ctx, FULL_ACCESS, input)
886
+ };
887
+ async function writePost(ctx, input) {
888
+ const { identity } = input;
889
+ const sent = input.text;
890
+ let channel = input.channel;
891
+ let re = null;
892
+ let replyTo = null;
893
+ let thread;
894
+ const id = input.id ?? (input.re === void 0 ? crypto.randomUUID() : void 0);
895
+ if (input.re !== void 0) {
896
+ const target = input.proofs.re;
897
+ if (target?.id !== input.re) throw new Error(`a reply to #${input.re} needs proofs for that message`);
898
+ re = target.re ?? target.id;
899
+ replyTo = target.id === re ? null : target.id;
900
+ channel = target.channel;
901
+ thread = target.thread;
902
+ if (input.sync?.length) throw badRequest("sync attaches whole threads: use `board sync <id> <target>`");
903
+ } else {
904
+ if (!channel) throw badRequest("a post needs a channel");
905
+ thread = id;
906
+ }
907
+ spend(input.proofs.post, "post", { channel });
908
+ if (hasLabels(input.labels)) spend(input.proofs.label, "label", { channel });
909
+ if (input.sync?.length) spend(input.proofs.sync, "sync", { channel });
910
+ if (input.id !== void 0) {
911
+ const landed = await ctx.reads.byEventId(input.id);
912
+ if (landed) {
913
+ if (landed.channel !== channel || landed.re !== re || landed.replyTo !== replyTo) throw duplicateId();
914
+ const shown = await landedFor(ctx, identity, landed);
915
+ if (!shown) throw duplicateId();
916
+ return shownTo(shown, identity.principal.access, sent);
917
+ }
918
+ }
919
+ return commitPost(ctx, input, {
920
+ channel,
921
+ re,
922
+ replyTo,
923
+ thread,
924
+ id,
925
+ sent
926
+ });
927
+ }
928
+ /**
929
+ * A post that landed, as its author may have it back: only the same author's, and one in a
930
+ * channel it can't read only if it posted without reading too. A caller that can't read gets back
931
+ * what it sent, as it landed: never what others have added since. Null for anyone else.
932
+ */
933
+ async function landedFor(ctx, identity, landed) {
934
+ if (!sameAuthor(landed.by, identity.author)) return null;
935
+ const { access } = identity.principal;
936
+ const { state: actors } = await ctx.session.state(actorsReducer);
937
+ if (access.readable(landed.channel)) return view(landed, actors, await readerFor(ctx, access, landed));
938
+ if (!landed.blind) return null;
939
+ const [event] = await ctx.session.history({
940
+ gte: landed.id,
941
+ lte: landed.id
942
+ });
943
+ const posted = foldMessages({
944
+ byId: {},
945
+ replies: {}
946
+ }, event).byId[landed.id];
947
+ return {
948
+ ...view(posted, actors, await readerFor(ctx, access, posted)),
949
+ text: blindText(posted.text, identity.actor.id),
950
+ mentions: []
951
+ };
952
+ }
953
+ /**
954
+ * A message's text for its writer where it can't read: each exact mention but of itself shows as
955
+ * `@…`. The board may have turned an email it wrote into an actor ID, and who that is, `/actors`
956
+ * doesn't show it either.
957
+ */
958
+ const blindText = (text, own) => replaceMentions(text, (token) => token.kind === "actor" && token.actor !== own ? "@…" : null);
959
+ const sameAuthor = (a, b) => a.actor === b.actor && a.principal === b.principal && a.profile === b.profile && a.session === b.session;
960
+ async function commitPost(ctx, input, placed) {
961
+ const { identity } = input;
962
+ const { channel, re, replyTo, thread, id, sent } = placed;
963
+ const { state: actors } = await ctx.session.state(actorsReducer);
964
+ const targets = [];
965
+ if (re === null) for (const requested of input.sync ?? []) {
966
+ const target = syncTarget(requested);
967
+ await unclaimed(ctx, target, thread, identity.principal.access);
968
+ if (!targets.some((t) => t.target === target.target)) targets.push({
969
+ ...target,
970
+ cause: "request"
971
+ });
972
+ }
973
+ const seen = seenFor(identity, actors);
974
+ const { text, found } = await withEmailMentions(input.text);
975
+ const named = found.map((actor) => actorSeen(actor, actors)).filter((event) => event !== null);
976
+ const { mentions, unknown } = resolveMentions(text, knownWith(actors, found), seen ? identity.actor : void 0, input.lenientMentions ?? false, identity.principal.access.readsAll);
977
+ const synced = await Promise.all(targets.map(async ({ target, kind, cause }) => ({
978
+ type: "thread.synced",
979
+ id: await derivedId("board", thread, "sync", target),
980
+ payload: {
981
+ thread,
982
+ target,
983
+ kind,
984
+ cause,
985
+ by: identity.author
986
+ }
987
+ })));
988
+ const rows = await ctx.session.append(...seen ? [seen] : [], ...named, {
989
+ type: "message.posted",
990
+ ...id ? { id } : {},
991
+ payload: {
992
+ channel,
993
+ text,
994
+ re,
995
+ replyTo,
996
+ thread,
997
+ labels: input.labels ?? {},
998
+ noSync: input.noSync ?? false,
999
+ mentions,
1000
+ by: identity.author,
1001
+ origin: input.origin ?? null,
1002
+ from: input.from ?? null,
1003
+ ...input.attachments?.length ? { attachments: input.attachments } : {},
1004
+ ...input.links?.length ? { links: input.links } : {},
1005
+ ...input.schedule ? { schedule: input.schedule } : {},
1006
+ ...identity.principal.access.readable(channel) ? {} : { blind: true },
1007
+ ...input.importedBy !== void 0 ? { importedBy: input.importedBy } : {}
1008
+ }
1009
+ }, ...synced);
1010
+ const posted = rows.find((row) => row.type === "message.posted");
1011
+ await untilApplied(ctx, rows);
1012
+ const message = foldMessages({
1013
+ byId: {},
1014
+ replies: {}
1015
+ }, posted).byId[posted.index];
1016
+ const known = seen ? {
1017
+ ...actors,
1018
+ byId: {
1019
+ ...actors.byId,
1020
+ [identity.actor.id]: identity.actor
1021
+ }
1022
+ } : actors;
1023
+ return shownTo(view(message, known, await readerFor(ctx, identity.principal.access, message)), identity.principal.access, sent, unknown);
1024
+ }
1025
+ /**
1026
+ * A post's view for its writer. One that can't read the channel gets back the
1027
+ * text it sent, no mentions, and no `unknownMentions`: the board resolved its
1028
+ * handles and emails to actor IDs, which `/actors` doesn't show it, and which
1029
+ * handles matched no one would say which ones exist. The board keeps the
1030
+ * mentions, so they still notify.
1031
+ */
1032
+ const shownTo = (shown, access, sent, unknown = []) => !access.readable(shown.channel) ? {
1033
+ ...shown,
1034
+ text: sent,
1035
+ mentions: []
1036
+ } : unknown.length ? {
1037
+ ...shown,
1038
+ unknownMentions: unknown
1039
+ } : shown;
1040
+ /** The writer, as the reader of the view its write returns. Only a link to a board message needs a lookup. */
1041
+ const readerFor = (ctx, access, message) => ctx.reads.reader(access, [message]);
1042
+ /**
1043
+ * Refuses an external thread another board thread is synced with: taking it
1044
+ * over would send its replies away from that thread, to a caller who may not
1045
+ * read it. A reader of that thread's channel learns which thread it is. It
1046
+ * reads the tables once they've applied the whole log, so a sync just made
1047
+ * counts.
1048
+ */
1049
+ async function unclaimed(ctx, target, thread, access) {
1050
+ if (target.kind !== "thread") return;
1051
+ await ctx.reads.ensure();
1052
+ const owner = await ctx.reads.tables.threadOf(target.target);
1053
+ if (owner === null || owner === thread) return;
1054
+ const synced = await ctx.reads.tables.syncOf(owner);
1055
+ const which = synced && access.readable(synced.channel) ? `#${synced.root}` : "another thread";
1056
+ throw new HttpError(409, "already_synced", `${target.target} is already synced with ${which}`);
1057
+ }
1058
+ /**
1059
+ * Waits until the tables hold a write, so any read after it includes it. Most often
1060
+ * nothing was written in between, and the writer applies its own events itself; else
1061
+ * it waits for the tables' consumer. The write landed either way: if the tables are
1062
+ * slow or stuck, it still succeeds.
1063
+ */
1064
+ async function untilApplied(ctx, rows) {
1065
+ const events = [...rows].sort((a, b) => a.index - b.index);
1066
+ const last = events.at(-1).index;
1067
+ try {
1068
+ let caughtUp = true;
1069
+ for (const event of events) if (!(caughtUp = await ctx.reads.tables.applyNext(event))) break;
1070
+ if (caughtUp) return;
1071
+ } catch {}
1072
+ await ctx.reads.ensure(last, AbortSignal.timeout(LANDED_WAIT_MS)).catch((error) => {
1073
+ console.warn(`[board] the tables haven't applied #${last} yet:`, error.message);
1074
+ });
1075
+ }
1076
+ /** How long a write waits for the tables before it answers anyway. */
1077
+ const LANDED_WAIT_MS = 1e4;
1078
+ /** The message a by-ID write targets, as the board looked it up, once its proof is spent on the message's channel. */
1079
+ function spent(authorized, permission) {
1080
+ spend(authorized.proof, permission, authorized.message);
1081
+ return authorized.message;
1082
+ }
1083
+ async function writeEdit(ctx, input) {
1084
+ const message = spent(input.authorized, "post");
1085
+ if (input.asAuthor !== false && message.by.principal !== input.identity.principal.id) throw forbidden("only the author can edit a message");
1086
+ if (message.deleted) throw badRequest("message was deleted", "message_deleted");
1087
+ return commitEdit(ctx, input, message);
1088
+ }
1089
+ async function commitEdit(ctx, input, message) {
1090
+ const { state: actors } = await ctx.session.state(actorsReducer);
1091
+ const seen = seenFor(input.identity, actors);
1092
+ const { text, found } = await withEmailMentions(input.text);
1093
+ const named = found.map((actor) => actorSeen(actor, actors)).filter((event) => event !== null);
1094
+ const { mentions, unknown } = resolveMentions(text, knownWith(actors, found), seen ? input.identity.actor : void 0, input.from != null, input.identity.principal.access.readsAll);
1095
+ await untilApplied(ctx, await ctx.session.append(...seen ? [seen] : [], ...named, {
1096
+ type: "message.edited",
1097
+ payload: {
1098
+ id: message.id,
1099
+ thread: message.thread,
1100
+ text,
1101
+ mentions,
1102
+ by: input.identity.author,
1103
+ from: input.from ?? null
1104
+ }
1105
+ }));
1106
+ return unknown.length && input.identity.principal.access.readable(message.channel) ? { unknownMentions: unknown } : {};
1107
+ }
1108
+ /** A message's links, replaced: a platform edit or unfurl changed what it links to. */
1109
+ async function writeLinks(ctx, input) {
1110
+ const message = spent(input.authorized, "post");
1111
+ if (message.deleted) return;
1112
+ await untilApplied(ctx, await ctx.session.append({
1113
+ type: "message.linked",
1114
+ payload: {
1115
+ id: message.id,
1116
+ thread: message.thread,
1117
+ links: input.links,
1118
+ by: input.identity.author,
1119
+ from: input.from
1120
+ }
1121
+ }));
1122
+ }
1123
+ async function writeDelete(ctx, input) {
1124
+ const message = spent(input.authorized, "post");
1125
+ if (input.asAuthor !== false && message.by.principal !== input.identity.principal.id) throw forbidden("only the author can delete a message");
1126
+ if (message.deleted) return;
1127
+ await untilApplied(ctx, await ctx.session.append({
1128
+ type: "message.deleted",
1129
+ payload: {
1130
+ id: message.id,
1131
+ thread: message.thread,
1132
+ by: input.identity.author,
1133
+ from: input.from ?? null
1134
+ }
1135
+ }));
1136
+ }
1137
+ async function writeReaction(ctx, input) {
1138
+ const message = spent(input.authorized, "react");
1139
+ if (message.deleted) throw badRequest("message was deleted", "message_deleted");
1140
+ const { state: actors } = await ctx.session.state(actorsReducer);
1141
+ const seen = seenFor(input.identity, actors);
1142
+ await untilApplied(ctx, await ctx.session.append(...seen ? [seen] : [], {
1143
+ type: input.remove ? "reaction.removed" : "reaction.added",
1144
+ payload: {
1145
+ id: message.id,
1146
+ thread: message.thread,
1147
+ emoji: input.emoji,
1148
+ by: input.identity.author,
1149
+ from: input.from ?? null
1150
+ }
1151
+ }));
1152
+ }
1153
+ async function writeLabels(ctx, input) {
1154
+ const message = spent(input.authorized, "label");
1155
+ if (message.deleted) throw badRequest("message was deleted", "message_deleted");
1156
+ const { state: actors } = await ctx.session.state(actorsReducer);
1157
+ const seen = seenFor(input.identity, actors);
1158
+ await untilApplied(ctx, await ctx.session.append(...seen ? [seen] : [], {
1159
+ type: "labels.changed",
1160
+ payload: {
1161
+ id: message.id,
1162
+ thread: message.thread,
1163
+ set: input.set ?? {},
1164
+ unset: input.unset ?? [],
1165
+ by: input.identity.author
1166
+ }
1167
+ }));
1168
+ }
1169
+ /**
1170
+ * Syncs a message's thread with a target, resolved by the sync that delivers it. Attaching twice
1171
+ * is a no-op; a platform thread another board thread is synced with is a 409.
1172
+ */
1173
+ async function writeAttach(ctx, input) {
1174
+ const message = spent(input.authorized, "sync");
1175
+ const { target, kind } = input.target;
1176
+ await ctx.reads.ensure();
1177
+ if ((await ctx.reads.tables.syncOf(message.thread))?.targets.includes(target)) return { attached: false };
1178
+ await unclaimed(ctx, input.target, message.thread, input.identity.principal.access);
1179
+ await untilApplied(ctx, await ctx.session.append({
1180
+ type: "thread.synced",
1181
+ id: await derivedId("board", message.thread, "sync", target),
1182
+ payload: {
1183
+ thread: message.thread,
1184
+ target,
1185
+ kind,
1186
+ cause: input.cause,
1187
+ by: input.identity.author
1188
+ }
1189
+ }));
1190
+ return { attached: true };
1191
+ }
1192
+ //#endregion
303
1193
  //#region src/server/schedules.ts
304
1194
  /** Vercel Queues delays at most 6 days: a send further out than this goes in hops. */
305
1195
  const MAX_HOP_MS = 432e6;
@@ -1654,6 +2544,13 @@ const inboxesReducer = reducer({
1654
2544
  });
1655
2545
  /** An append that holds an event ID already in the log. */
1656
2546
  const duplicate = (error) => error instanceof A2Error && error.code === "PARTIAL_DUPLICATE_BATCH";
2547
+ /**
2548
+ * A platform event whose ID is already in an inbox: the platform's retry, which can read differently
2549
+ * (another instance, a name changed since), so the copy already there stands.
2550
+ */
2551
+ const alreadyReceived = (error) => {
2552
+ if (!duplicate(error)) throw error;
2553
+ };
1657
2554
  /** Where a board given no store keeps it, in the working directory, in every environment. */
1658
2555
  const DEFAULT_STORE_PATH = ".a2/dev.db";
1659
2556
  /** Why a file couldn't be opened, in a few words: a read-only filesystem, a serverless function's say. */
@@ -1729,9 +2626,8 @@ function build(options, binding) {
1729
2626
  const event = ctx.event;
1730
2627
  const [messages, { state: actors }] = await Promise.all([loadBefore(reads, event, ctx.signal), ctx.session.state(actorsReducer, { through: event.index - 1 })]);
1731
2628
  const { before, after } = foldEvent(messages, actors, event);
1732
- const about = event.type === "thread.synced" ? (await reads.ensure(event.index - 1, ctx.signal), await store.tables.byEventId(event.payload.thread)) ?? void 0 : after;
1733
2629
  const shown = {
1734
- ...eventView(event, about, actors, OWN_CODE),
2630
+ ...eventView(event, event.type === "thread.synced" ? (await reads.ensure(event.index - 1, ctx.signal), await store.tables.byEventId(event.payload.thread)) ?? void 0 : after, actors, OWN_CODE),
1735
2631
  ...event.type === "labels.changed" && before ? { before: view(before, actors, OWN_CODE) } : {}
1736
2632
  };
1737
2633
  await delivering.run(ctx, () => binding.run(SYNC, shown, {
@@ -1900,7 +2796,7 @@ function build(options, binding) {
1900
2796
  if (fromRequest(caller) && body.id?.startsWith("key:") && !isKeyIdOf(body.id, principal.id)) throw badRequest(`id: key:… IDs derive from a client's key, and this token's are key:${principal.id}:…`);
1901
2797
  if (fromRequest(caller) && body.id?.startsWith("sync:")) throw badRequest("id: sync:… IDs are what sync imports, under the principal that imports them");
1902
2798
  const attachments = await attachmentsFor(principal, body.attachments);
1903
- const { schedule, config } = caller.origin;
2799
+ const { schedule } = caller.origin;
1904
2800
  const { unknownMentions, ...message } = await writePost(writes, {
1905
2801
  attachments,
1906
2802
  identity,
@@ -1912,7 +2808,6 @@ function build(options, binding) {
1912
2808
  noSync: body.noSync,
1913
2809
  id: body.id,
1914
2810
  sync: body.sync,
1915
- config,
1916
2811
  ...schedule && !fromRequest(caller) ? {
1917
2812
  schedule,
1918
2813
  lenientMentions: true
@@ -2255,7 +3150,7 @@ function build(options, binding) {
2255
3150
  const { principal, actor } = await identityOf(caller);
2256
3151
  const after = query.after !== void 0 ? cursorAt(query.after) : void 0;
2257
3152
  const { state: actors } = await session.state(actorsReducer);
2258
- const feed = {
3153
+ const { events, cursor } = await pollFeed({
2259
3154
  session,
2260
3155
  reads,
2261
3156
  after,
@@ -2264,8 +3159,7 @@ function build(options, binding) {
2264
3159
  me: actor.id,
2265
3160
  actors,
2266
3161
  access: principal.access
2267
- };
2268
- const { events, cursor } = await pollFeed(feed, (query.wait ?? 0) * 1e3, signal);
3162
+ }, (query.wait ?? 0) * 1e3, signal);
2269
3163
  return {
2270
3164
  events,
2271
3165
  cursor: ref(cursor)
@@ -2285,6 +3179,57 @@ function build(options, binding) {
2285
3179
  if (!principal.access.everywhere("manage")) throw forbidden("this token can't read a source: it needs manage in every channel");
2286
3180
  return binding.readSource(key, target, limit ?? 50);
2287
3181
  },
3182
+ async synced(caller, { id }, signal) {
3183
+ const { principal } = await identityOf(caller);
3184
+ const message = await reads.message(messageAt(id), signal);
3185
+ if (!message || !principal.access.readable(message.channel)) throw notFound(`message not found: #${id}`, "message_not_found");
3186
+ await reads.ensure(void 0, signal);
3187
+ const { tables } = store;
3188
+ const [thread, copies] = await Promise.all([tables.syncOf(message.thread), tables.copiesOf([message.id])]);
3189
+ const mine = copies.get(message.id) ?? {};
3190
+ return { targets: await Promise.all((thread?.targets ?? []).map(async (target) => {
3191
+ const key = target.slice(0, target.indexOf(":"));
3192
+ const external = (thread && Object.hasOwn(thread.external, target) ? thread.external[target] : void 0) ?? null;
3193
+ const copy = (Object.hasOwn(mine, target) ? mine[target] : external !== null && Object.hasOwn(mine, external) ? mine[external] : void 0) ?? null;
3194
+ return {
3195
+ target,
3196
+ key,
3197
+ thread: copy?.thread ?? external,
3198
+ message: copy?.message ?? null,
3199
+ url: copy ? await binding.linkOf(key, copy.thread, copy.message) : null,
3200
+ undelivered: Object.hasOwn(message.undelivered, target) ? message.undelivered[target] : null
3201
+ };
3202
+ })) };
3203
+ },
3204
+ async linked(caller, { url }, signal) {
3205
+ const missing = () => notFound("no message on this board for that link", "message_not_found");
3206
+ const read = binding.locate(url);
3207
+ if (read.length === 0) throw missing();
3208
+ await reads.ensure(void 0, signal);
3209
+ const { tables } = store;
3210
+ const registered = Object.values((await registry.state()).byOwner).flatMap((registration) => registration.sync);
3211
+ const keysOf = (platform) => new Set([...binding.sync, ...registered].filter((entry) => entry.platform === platform).map((entry) => entry.key));
3212
+ const first = async () => {
3213
+ for (const { platform, thread, message } of read) for (const key of keysOf(platform)) {
3214
+ let found;
3215
+ if (message !== null) found = await tables.messageOf(`${key}:${thread}`, message);
3216
+ else {
3217
+ const at = await tables.threadOf(`${key}:${thread}`);
3218
+ found = at === null ? null : (await tables.syncOf(at))?.root ?? null;
3219
+ }
3220
+ if (found) return found;
3221
+ }
3222
+ return null;
3223
+ };
3224
+ const id = await first();
3225
+ if (id === null) throw missing();
3226
+ try {
3227
+ return await methods.message(caller, { id: ref(id) }, signal);
3228
+ } catch (error) {
3229
+ if (error instanceof HttpError && error.status === 404) throw missing();
3230
+ throw error;
3231
+ }
3232
+ },
2288
3233
  async syncImport(caller, body, signal) {
2289
3234
  const { principal } = await needSync(caller, "import", true);
2290
3235
  await speaksFor(caller, principal.id, body.author, body.op === "seen" ? null : body.from);
@@ -2330,8 +3275,7 @@ function build(options, binding) {
2330
3275
  ...body.origin && !ownSync(caller) ? { importedBy: principal.id } : {},
2331
3276
  lenientMentions: true,
2332
3277
  attachments,
2333
- links: await linksOf(principal, body.links),
2334
- config: caller.origin.config
3278
+ links: await linksOf(principal, body.links)
2335
3279
  })).id;
2336
3280
  };
2337
3281
  const write = {
@@ -2555,7 +3499,7 @@ function build(options, binding) {
2555
3499
  event,
2556
3500
  raw
2557
3501
  }
2558
- });
3502
+ }).catch(alreadyReceived);
2559
3503
  }
2560
3504
  return { ok: true };
2561
3505
  }
@@ -2575,7 +3519,7 @@ function build(options, binding) {
2575
3519
  })));
2576
3520
  await queue.append(...matched).catch(async (error) => {
2577
3521
  if (!duplicate(error)) throw error;
2578
- for (const one of matched) await queue.append(one);
3522
+ for (const one of matched) await queue.append(one).catch(alreadyReceived);
2579
3523
  });
2580
3524
  return { ok: true };
2581
3525
  },