@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,13 +1,14 @@
1
1
  import { a as typeOf, i as fileSize, r as IMPORT_BYTES_MAX, t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
2
2
  import { C as toStorable, c as ChannelPath, m as MESSAGE_TEXT_MAX } from "./schemas-Be08nyfW.mjs";
3
- import { o as derivedId } from "./watch-spec-W0c7ypHJ.mjs";
3
+ import { c as externalMessageKey, l as isThreadTarget, s as channelOf } from "./external-BV_cUncs.mjs";
4
4
  import { t as canonicalEmoji } from "./emoji-CTLpvH-A.mjs";
5
+ import { n as derivedId } from "./ids-C6AYo1bu.mjs";
5
6
  import { r as badRequest, s as notFound, t as HttpError } from "./errors-DX3hpwWt.mjs";
6
- import { s as replaceMentions } from "./actors-DjMeZpnU.mjs";
7
- import { i as DroppedError } from "./targets-yM5JBUgr.mjs";
8
- import { c as isThreadTarget, r as externalMessageKey } from "./reducers-mUPFuFJV.mjs";
9
- import { _ as waitUntil, o as channelOf, t as afterRetry } from "./attempts-Bxp9KgPf.mjs";
10
- import { i as UnreadableError, l as immutable, n as ResolvedTarget, o as SyncOverrides, t as ReceivedEvent } from "./provider-CuewcDNZ.mjs";
7
+ import { n as replaceMentions } from "./mentions-CB6ShY4R.mjs";
8
+ import { t as DroppedError } from "./middleware-CqaCwucP.mjs";
9
+ import { n as waitUntil, t as afterRetry } from "./attempts-CLG840Oj.mjs";
10
+ import { a as channelMissing, i as UnreadableError, n as ResolvedTarget, o as routesFor, t as ReceivedEvent } from "./provider-B3DpYSDq.mjs";
11
+ import { a as immutable, n as SyncOverrides } from "./events-CewD4kw7.mjs";
11
12
  import { t as displayText } from "./bots-CGkO76qH.mjs";
12
13
  //#region src/sync/engine.ts
13
14
  /** `files`: a message with files may have no text. */
@@ -169,38 +170,43 @@ function createSyncEngine(entries) {
169
170
  return state;
170
171
  }
171
172
  const linkedTo = (actors, person) => Object.entries(actors.links).filter(([, to]) => to === person).map(([actor]) => actor);
172
- /** How long a key that didn't resolve (say, a Slack channel that doesn't exist yet) waits before another try. */
173
+ /** How long a name that didn't resolve (say, a Slack channel that doesn't exist yet) waits before another try. */
173
174
  const RETRY_MS = 6e4;
174
- const resolved = /* @__PURE__ */ new Map();
175
+ const resolutions = /* @__PURE__ */ new Map();
175
176
  /**
176
- * External channel → board channel or inbound function, for one entry. Each key
177
- * resolves on its own: one that can't is logged and skipped, and the rest sync.
177
+ * A platform channel's ID from its name, on entry `key`: what sync functions route by. Each
178
+ * resolves once. One the platform says doesn't exist is logged and
179
+ * tried again a minute later; one it couldn't check is asked again on the next call, so a
180
+ * retried event asks again.
178
181
  */
179
- const mappings = (key) => {
180
- const cached = resolved.get(key);
181
- if (cached && (cached.complete || Date.now() - cached.at < RETRY_MS)) return cached.map;
182
- const platform = platformOf(key);
183
- const pairs = Object.entries(platform.mappings);
182
+ function resolve(key, name) {
183
+ const at = `${key}:${name}`;
184
+ const cached = resolutions.get(at);
185
+ if (cached && (!cached.failed || Date.now() - cached.at < RETRY_MS)) return cached.id;
184
186
  const entry = {
185
- complete: true,
186
- at: Date.now(),
187
- map: Promise.resolve(/* @__PURE__ */ new Map())
187
+ id: platformOf(key).resolveChannel(name),
188
+ failed: false,
189
+ at: Date.now()
188
190
  };
189
- entry.map = Promise.allSettled(pairs.map(([raw]) => platform.resolveChannel(raw))).then((results) => {
190
- const map = /* @__PURE__ */ new Map();
191
- results.forEach((result, i) => {
192
- const [raw, target] = pairs[i];
193
- if (result.status === "fulfilled") map.set(`${key}:${result.value}`, target);
194
- else {
195
- entry.complete = false;
196
- console.warn(`[board] sync ${key}: skipping ${raw}: ${result.reason.message}`);
197
- }
198
- });
199
- return map;
191
+ entry.id.catch((error) => {
192
+ entry.failed = true;
193
+ const missing = channelMissing(error);
194
+ if (!missing && resolutions.get(at) === entry) resolutions.delete(at);
195
+ console.warn(`[board] sync ${key}: ${missing ? "skipping" : "couldn't resolve"} ${name}: ${error.message}`);
200
196
  });
201
- resolved.set(key, entry);
202
- return entry.map;
203
- };
197
+ resolutions.set(at, entry);
198
+ return entry.id;
199
+ }
200
+ /** The platform channel a target is in, as the platform names it; null when its provider can't tell. */
201
+ function channelIn(key, target) {
202
+ if (!isThreadTarget(target)) return native(target);
203
+ try {
204
+ const channel = platformOf(key).inboxChannel(key, target);
205
+ return channel === null ? null : native(channel);
206
+ } catch {
207
+ return null;
208
+ }
209
+ }
204
210
  /** A synced platform's user with this email, for `@[ada@vercel.com]`. */
205
211
  async function findByEmail(email) {
206
212
  for (const platform of Object.values(entries)) {
@@ -215,18 +221,6 @@ function createSyncEngine(entries) {
215
221
  }
216
222
  return null;
217
223
  }
218
- /** External channels a new root in this board channel starts threads in. */
219
- async function configTargets(channel) {
220
- const targets = [];
221
- for (const [key, platform] of Object.entries(entries)) {
222
- if (!platform.canStartThreads) continue;
223
- for (const [external, target] of await mappings(key)) if (target.channel === channel) targets.push({
224
- target: external,
225
- kind: "channel"
226
- });
227
- }
228
- return targets;
229
- }
230
224
  /** Validates a requested target and resolves names (`slack:#prs`) to IDs. */
231
225
  async function normalizeTarget(target) {
232
226
  const at = target.indexOf(":");
@@ -254,7 +248,8 @@ function createSyncEngine(entries) {
254
248
  event.thread,
255
249
  "subthread" in event ? event.subthread : null
256
250
  ]) if (location !== null && !location.startsWith(`${key}:`)) throw badRequest(`provider locations must belong to sync entry ${key}`);
257
- if (!(await mappings(key)).has(event.channel) && !await syncsThread(event.thread)) return;
251
+ const routes = routesFor(platformOf(key));
252
+ if (routes && !await routes(native(event.channel), (name) => resolve(key, name)) && !await syncsThread(event.thread)) return;
258
253
  await call("inboxPush", {
259
254
  key,
260
255
  events: [{
@@ -458,11 +453,10 @@ function createSyncEngine(entries) {
458
453
  notes
459
454
  };
460
455
  }
461
- /** One platform event from the inbox, onto the board. `id` is its ID there, which its writes derive theirs from. */
456
+ /** One platform event from the inbox, through its entry's sync function. `id` is its ID there, which its writes derive theirs from. */
462
457
  async function inbound(key, item, meta) {
463
458
  const { event, raw } = item;
464
459
  const platform = platformOf(key);
465
- const mapping = (await mappings(key)).get(event.channel);
466
460
  const linked = (await find({ thread: event.thread })).thread ?? void 0;
467
461
  if (event.kind === "message") {
468
462
  const copied = (await find({
@@ -483,56 +477,68 @@ function createSyncEngine(entries) {
483
477
  avatar: null,
484
478
  isBot: true
485
479
  };
486
- const board = host().client({
487
- author,
488
- key,
489
- ...originOf(key, event)
490
- }, meta.id);
491
- let applied;
492
- let active = true;
493
- const context = {
480
+ let typed;
481
+ const typedEvent = () => typed ??= immutable(platform.event({
494
482
  id: meta.id,
495
483
  key,
496
484
  event,
497
- raw,
498
- board,
499
- apply(channel, overrides = {}) {
500
- if (!active) throw badRequest("the sync callback has finished");
501
- if (applied) throw badRequest("an event can only be imported once per callback");
502
- applied = applyIncoming(key, item, meta, ChannelPath.parse(channel), SyncOverrides.parse(overrides)).catch(async (error) => {
503
- if (!(error instanceof DroppedError)) throw error;
504
- const thread = (await find({ thread: event.thread })).thread;
505
- return {
506
- root: thread === null ? null : (await syncOf(thread))?.root ?? null,
507
- message: null
508
- };
485
+ raw
486
+ }));
487
+ let imported;
488
+ let link;
489
+ let active = true;
490
+ const sync = {
491
+ direction: "in",
492
+ key,
493
+ channel: native(event.channel),
494
+ get event() {
495
+ return typedEvent();
496
+ },
497
+ board: host().client({
498
+ author,
499
+ key,
500
+ ...originOf(key, event)
501
+ }, meta.id),
502
+ import(channel, overrides = {}) {
503
+ if (!active) throw badRequest("the sync function has finished");
504
+ imported ??= importEvent(key, item, meta, linked, channel, overrides);
505
+ return imported;
506
+ },
507
+ permalink() {
508
+ const promise = link ?? platform.permalink(typedEvent());
509
+ link = promise;
510
+ promise.catch(() => {
511
+ if (link === promise) link = void 0;
509
512
  });
510
- return applied;
513
+ return promise;
511
514
  },
512
- client(publicEvent) {
513
- async function sync(first, second, overrides) {
514
- if (!active) throw badRequest("the sync callback has finished");
515
- if (typeof second === "string") return board.sync(first, second);
516
- if (typeof first !== "string" || second !== publicEvent) throw badRequest("sync only accepts the current verified event");
517
- return platform.sync(context, first, overrides);
518
- }
519
- return {
520
- ...board,
521
- sync
522
- };
523
- }
515
+ resolve: (name) => resolve(key, name)
524
516
  };
525
517
  try {
526
- if (mapping?.run) {
527
- await mapping.run(context);
528
- return;
529
- }
530
- const channel = mapping?.channel ?? (linked ? (await syncOf(linked))?.channel : void 0);
531
- if (channel) await platform.sync(context, channel);
518
+ await platform.sync(sync);
519
+ await imported;
532
520
  } finally {
533
521
  active = false;
534
522
  }
535
523
  }
524
+ /** The import `sync.import` makes: into the event's board thread, or a new root in `channel`. */
525
+ async function importEvent(key, item, meta, linked, channel, overrides) {
526
+ const where = channel ?? (linked ? (await syncOf(linked))?.channel : void 0);
527
+ if (where === void 0) return {
528
+ root: null,
529
+ message: null
530
+ };
531
+ try {
532
+ return await applyIncoming(key, item, meta, ChannelPath.parse(where), SyncOverrides.parse(overrides));
533
+ } catch (error) {
534
+ if (!(error instanceof DroppedError)) throw error;
535
+ const thread = (await find({ thread: item.event.thread })).thread;
536
+ return {
537
+ root: thread === null ? null : (await syncOf(thread))?.root ?? null,
538
+ message: null
539
+ };
540
+ }
541
+ }
536
542
  /**
537
543
  * Where a platform event's writes come from, for the middleware, and how it answers
538
544
  * there: a reply in the event's own platform thread.
@@ -652,14 +658,6 @@ function createSyncEngine(entries) {
652
658
  throw new DroppedError(answer.dropped);
653
659
  };
654
660
  const post = async ({ attachments = [], ...input }) => {
655
- const where = input.channel ?? synced?.channel ?? channel;
656
- const caller = input.re === void 0 ? {
657
- ...me,
658
- origin: {
659
- ...me.origin,
660
- config: await configTargets(where)
661
- }
662
- } : me;
663
661
  return {
664
662
  id: await screened(await call("syncImport", {
665
663
  op: "post",
@@ -668,7 +666,7 @@ function createSyncEngine(entries) {
668
666
  ...input,
669
667
  origin: input.origin ?? null,
670
668
  ...attachments.length ? { attachments: attachments.map((file) => file.id) } : {}
671
- }, NEVER, caller)),
669
+ })),
672
670
  re: input.re ?? null
673
671
  };
674
672
  };
@@ -883,6 +881,24 @@ function createSyncEngine(entries) {
883
881
  const set = Object.fromEntries(Object.entries(desired).filter(([key, value]) => root?.labels[key] !== value));
884
882
  const unset = Object.keys(root?.labels ?? {}).filter((key) => !(key in desired) && (event.unset.includes(key) || overrides.unset?.includes(key) || owned(key)));
885
883
  if (root && !root.deleted && (Object.keys(set).length || unset.length)) await setLabels(event.author, set, unset);
884
+ if (event.retitled && root && !root.deleted && root.from === key && root.origin?.thread === event.thread) {
885
+ const text = clampText(overrides.text ?? rootDraft({
886
+ text: event.url ? `${event.title}\n${event.url}` : event.title,
887
+ labels: root.labels,
888
+ author: event.author,
889
+ raw
890
+ }).text);
891
+ if (root.text !== text) await screened(await call("syncImport", {
892
+ op: "edit",
893
+ author: event.author,
894
+ from: key,
895
+ origin: {
896
+ thread: root.origin.thread,
897
+ message: root.origin.message
898
+ },
899
+ text
900
+ }));
901
+ }
886
902
  if (event.reply) return result(await post({
887
903
  author: event.author,
888
904
  re: rootId,
@@ -1001,14 +1017,14 @@ function createSyncEngine(entries) {
1001
1017
  return null;
1002
1018
  }
1003
1019
  }
1004
- /** A board message as markdown for a platform, which converts it to its own format. */
1005
- function render(key, target, message, actors) {
1020
+ /** A board message as markdown for a platform, which converts it to its own format: by `renderer`, or the provider's own. */
1021
+ function render(key, target, message, actors, renderer) {
1006
1022
  const actor = actors.byId[message.by.actor];
1007
1023
  let name = actor?.name ?? message.by.actor;
1008
1024
  if (message.by.profile && actor?.handle.includes("/")) name = `${actor.name} via ${actor.handle.split("/")[0]}`;
1009
1025
  if (message.from) name = `${name} (${entries[message.from]?.platform ?? message.by.actor.slice(0, message.by.actor.indexOf(":"))})`;
1010
1026
  const platform = platformOf(key);
1011
- const body = platform.render(immutable({
1027
+ const body = (renderer ?? ((input) => platform.render(input)))(immutable({
1012
1028
  message: {
1013
1029
  ...structuredClone(message),
1014
1030
  text: displayText(message.text, message.by.actor)
@@ -1045,19 +1061,19 @@ function createSyncEngine(entries) {
1045
1061
  * `render`, with a line per file where the files won't follow: on a platform
1046
1062
  * that takes no uploads, or one that refused them (`noted`).
1047
1063
  */
1048
- const renderFor = (target, message, actors, noted = false) => {
1064
+ const renderFor = (target, message, actors, noted = false, renderer) => {
1049
1065
  const key = keyOf(target);
1050
- const { markdown, as } = render(key, target, message, actors);
1066
+ const { markdown, as } = render(key, target, message, actors, renderer);
1051
1067
  return {
1052
1068
  markdown: message.attachments.length > 0 && (noted || !platformOf(key).uploadsFiles) ? `${markdown}\n${attachmentNotes(message)}` : markdown,
1053
1069
  ...as ? { as } : {}
1054
1070
  };
1055
1071
  };
1056
- async function postReply(message, target, externalThread, actors) {
1072
+ async function postReply(message, target, externalThread, actors, renderer) {
1057
1073
  const replyCopy = message.replyTo === null ? void 0 : (await copiesOf([message.replyTo]))[message.replyTo]?.[target];
1058
1074
  const place = replyCopy !== void 0 && replyCopy.thread !== externalThread ? replyCopy.thread : externalThread;
1059
1075
  const platform = platformOf(keyOf(target));
1060
- let body = renderFor(target, message, actors);
1076
+ let body = renderFor(target, message, actors, false, renderer);
1061
1077
  let files;
1062
1078
  let sentId;
1063
1079
  if (!body.markdown.trim() && message.attachments.length && platform.uploadsFiles) try {
@@ -1066,7 +1082,7 @@ function createSyncEngine(entries) {
1066
1082
  files = sent.files;
1067
1083
  } catch (error) {
1068
1084
  if (!platform.refusesFiles(error) && !keepsNoFiles(error)) throw error;
1069
- body = renderFor(target, message, actors, true);
1085
+ body = renderFor(target, message, actors, true, renderer);
1070
1086
  sentId = (await platform.post(native(place), "thread", body)).message;
1071
1087
  files = null;
1072
1088
  }
@@ -1124,7 +1140,7 @@ function createSyncEngine(entries) {
1124
1140
  * receipt comes first, so a retry sends only what's missing. If the platform
1125
1141
  * refuses files, the text gets a line for each; any other failure retries.
1126
1142
  */
1127
- async function deliverFiles(message, target, copy, actors) {
1143
+ async function deliverFiles(message, target, copy, actors, renderer) {
1128
1144
  const platform = platformOf(keyOf(target));
1129
1145
  if (!platform.uploadsFiles || message.attachments.length === 0 || copy.files !== void 0) return;
1130
1146
  let ids;
@@ -1137,7 +1153,7 @@ function createSyncEngine(entries) {
1137
1153
  });
1138
1154
  console.warn(`[board] ${target} won't take #${message.id}'s files (${describe(error)}); noting them in its text`);
1139
1155
  ids = null;
1140
- await platform.edit(native(copy.thread), copy.message, renderFor(target, message, actors, true)).catch((edit) => console.warn(`[board] couldn't note #${message.id}'s files on ${target}:`, describe(edit)));
1156
+ await platform.edit(native(copy.thread), copy.message, renderFor(target, message, actors, true, renderer)).catch((edit) => console.warn(`[board] couldn't note #${message.id}'s files on ${target}:`, describe(edit)));
1141
1157
  }
1142
1158
  await call("syncReceipt", {
1143
1159
  kind: "files",
@@ -1146,12 +1162,20 @@ function createSyncEngine(entries) {
1146
1162
  files: ids
1147
1163
  });
1148
1164
  }
1165
+ /** Records a failure once: a delivery's own and the sync function that passed it on are one. */
1166
+ const failed = (failures, id, target, error) => {
1167
+ if (!failures.some((failure) => failure.error === error)) failures.push({
1168
+ id,
1169
+ target,
1170
+ error
1171
+ });
1172
+ };
1149
1173
  async function settle(attempt, failures) {
1150
1174
  if (failures.length === 0) return;
1151
1175
  if (attempt < DELIVERY_ATTEMPTS || afterRetry(attempt)) throw failures[0].error;
1152
1176
  for (const { id, target, error } of failures) {
1153
- console.error(`[board] gave up delivering #${id} to ${target}:`, describe(error));
1154
- await call("syncReceipt", {
1177
+ console.error(`[board] gave up ${target === null ? `syncing #${id}` : `delivering #${id} to ${target}`}:`, describe(error));
1178
+ if (target !== null) await call("syncReceipt", {
1155
1179
  kind: "failed",
1156
1180
  message: id,
1157
1181
  target,
@@ -1160,20 +1184,66 @@ function createSyncEngine(entries) {
1160
1184
  }
1161
1185
  }
1162
1186
  /**
1163
- * A new root: the targets its channel syncs with. A root that came through this board landed
1164
- * with them; one that came another way (straight to the board this one uses) gets them now.
1187
+ * Hands one board event, for one target, to its entry's sync function: `send` is what its
1188
+ * `deliver` does, at most once. A throw, the function's or the delivery's, comes back here.
1165
1189
  */
1166
- async function configure(message) {
1167
- if (message.re !== null || message.noSync) return;
1168
- const originChannel = message.origin ? message.origin.channel ?? channelOf(message.origin.thread) : null;
1169
- for (const target of await configTargets(message.channel)) if (target.target !== originChannel) await call("syncReceipt", {
1190
+ async function offer(key, event, message, target, send) {
1191
+ let sent;
1192
+ let active = true;
1193
+ const finished = () => badRequest("the sync function has finished");
1194
+ const sync = {
1195
+ direction: "out",
1196
+ key,
1197
+ channel: target === null ? null : channelIn(key, target),
1198
+ event: immutable(structuredClone(event)),
1199
+ message: immutable(structuredClone(message)),
1200
+ target,
1201
+ deliver(options = {}) {
1202
+ if (!active) throw finished();
1203
+ sent ??= target === null ? Promise.resolve() : send(options.render);
1204
+ return sent;
1205
+ },
1206
+ start(channel) {
1207
+ if (!active) throw finished();
1208
+ return startThread(key, message, channel);
1209
+ },
1210
+ resolve: (name) => resolve(key, name)
1211
+ };
1212
+ try {
1213
+ await platformOf(key).sync(sync);
1214
+ await sent;
1215
+ } finally {
1216
+ active = false;
1217
+ }
1218
+ }
1219
+ /** Syncs a message's thread with a platform channel, as a sync function asked: never the channel it came from. */
1220
+ async function startThread(key, message, channel) {
1221
+ const { target, kind } = await normalizeTarget(`${key}:${channel}`);
1222
+ const root = message.re === null ? message : await messageOf(message.re);
1223
+ if (!root || root.deleted || root.noSync) return;
1224
+ if (target === (root.origin ? root.origin.channel ?? channelOf(root.origin.thread) : null)) return;
1225
+ await call("syncReceipt", {
1170
1226
  kind: "thread",
1171
- message: message.id,
1172
- target: target.target,
1173
- targetKind: target.kind,
1227
+ message: root.id,
1228
+ target,
1229
+ targetKind: kind,
1174
1230
  cause: "config"
1175
1231
  });
1176
1232
  }
1233
+ /** A new root, offered to each platform that can start threads: its sync function may `start` one there. */
1234
+ async function deliverRoot(event, attempt) {
1235
+ const starters = Object.keys(entries).filter((key) => entries[key].canStartThreads);
1236
+ if (event.payload.noSync || starters.length === 0) return;
1237
+ const root = await messageOf(event.message.id);
1238
+ if (!root || root.deleted) return;
1239
+ const failures = [];
1240
+ for (const key of starters) try {
1241
+ await offer(key, event, root, null, async () => {});
1242
+ } catch (error) {
1243
+ failed(failures, root.id, null, error);
1244
+ }
1245
+ await settle(attempt, failures);
1246
+ }
1177
1247
  async function deliverThread(event, attempt) {
1178
1248
  const { thread, target } = event.payload;
1179
1249
  if (!ours(target)) return;
@@ -1181,12 +1251,33 @@ function createSyncEngine(entries) {
1181
1251
  const synced = await syncOf(thread);
1182
1252
  const [root, ...replies] = synced ? await threadOf(synced.root) : [];
1183
1253
  if (!synced || !root || root.id !== synced.root || root.deleted) return;
1254
+ const failures = [];
1255
+ try {
1256
+ await offer(keyOf(target), event, root, target, (renderer) => backfill(synced, root, replies, target, failures, renderer));
1257
+ } catch (error) {
1258
+ failed(failures, root.id, target, error);
1259
+ }
1260
+ await settle(attempt, failures);
1261
+ }
1262
+ /**
1263
+ * A thread's root into a platform channel, then what's been said in it, each once. A reply
1264
+ * waits for its root; every reply is tried, and the first failure is thrown.
1265
+ */
1266
+ async function backfill(synced, root, replies, target, failures, renderer) {
1184
1267
  const actors = await actorsNow();
1185
1268
  const copies = await copiesOf([root.id, ...replies.map((reply) => reply.id)]);
1269
+ const rootFailed = (error) => {
1270
+ failures.push({
1271
+ id: root.id,
1272
+ target,
1273
+ error
1274
+ });
1275
+ return error;
1276
+ };
1186
1277
  let externalThread = synced.external[target];
1187
1278
  if (!externalThread) try {
1188
1279
  const platform = platformOf(keyOf(target));
1189
- const body = renderFor(target, root, actors);
1280
+ const body = renderFor(target, root, actors, false, renderer);
1190
1281
  const mediaOnly = !body.markdown.trim() && root.attachments.length > 0 && platform.uploadsFiles;
1191
1282
  if (!body.markdown.trim() && !mediaOnly) throw badRequest("a renderer must return text unless the message has media");
1192
1283
  let uploaded;
@@ -1210,7 +1301,7 @@ function createSyncEngine(entries) {
1210
1301
  } catch (error) {
1211
1302
  if (!platform.refusesFiles(error) && !keepsNoFiles(error)) throw error;
1212
1303
  uploaded = null;
1213
- return platform.post(native(target), "channel", renderFor(target, root, actors, true));
1304
+ return platform.post(native(target), "channel", renderFor(target, root, actors, true, renderer));
1214
1305
  }
1215
1306
  };
1216
1307
  const sent = await post();
@@ -1222,46 +1313,39 @@ function createSyncEngine(entries) {
1222
1313
  ...postedAsPerson ? { asUser: true } : {}
1223
1314
  };
1224
1315
  await recordDelivery(root, target, external);
1225
- await deliverFiles(root, target, external, actors);
1316
+ await deliverFiles(root, target, external, actors, renderer);
1226
1317
  } catch (error) {
1227
- return settle(attempt, [{
1228
- id: root.id,
1229
- target,
1230
- error
1231
- }]);
1318
+ throw rootFailed(error);
1232
1319
  }
1233
1320
  else {
1234
1321
  const copy = copies[root.id]?.[target];
1235
1322
  if (copy) try {
1236
- await deliverFiles(root, target, copy, actors);
1323
+ await deliverFiles(root, target, copy, actors, renderer);
1237
1324
  } catch (error) {
1238
- return settle(attempt, [{
1239
- id: root.id,
1240
- target,
1241
- error
1242
- }]);
1325
+ throw rootFailed(error);
1243
1326
  }
1244
1327
  }
1245
- const failures = [];
1328
+ const mine = [];
1246
1329
  for (const reply of replies) {
1247
1330
  if (reply.deleted || reply.noSync || cameFrom(reply, target)) continue;
1248
1331
  try {
1249
- await deliverFiles(reply, target, copies[reply.id]?.[target] ?? await postReply(reply, target, externalThread, actors), actors);
1332
+ await deliverFiles(reply, target, copies[reply.id]?.[target] ?? await postReply(reply, target, externalThread, actors, renderer), actors, renderer);
1250
1333
  } catch (error) {
1251
- failures.push({
1334
+ mine.push({
1252
1335
  id: reply.id,
1253
1336
  target,
1254
1337
  error
1255
1338
  });
1256
1339
  }
1257
1340
  }
1258
- await settle(attempt, failures);
1341
+ failures.push(...mine);
1342
+ if (mine.length) throw mine[0].error;
1259
1343
  }
1260
1344
  /** Never deliver a message back to the platform it came from. */
1261
1345
  const cameFrom = (message, target) => message.origin?.thread === target || message.from !== null && keyOf(target) === message.from;
1262
1346
  async function deliverPost(event, attempt) {
1263
1347
  if (!event.message) return;
1264
- if (event.message.re === null) return configure(event.message);
1348
+ if (event.message.re === null) return deliverRoot(event, attempt);
1265
1349
  if (event.payload.noSync) return;
1266
1350
  const synced = await syncOf(event.payload.thread);
1267
1351
  if (!synced || synced.targets.length === 0) return;
@@ -1274,13 +1358,12 @@ function createSyncEngine(entries) {
1274
1358
  if (!ours(target) || !externalThread) continue;
1275
1359
  if (cameFrom(message, target)) continue;
1276
1360
  try {
1277
- await deliverFiles(message, target, (await copiesOf([message.id]))[message.id]?.[target] ?? await postReply(message, target, externalThread, actors), actors);
1278
- } catch (error) {
1279
- failures.push({
1280
- id: message.id,
1281
- target,
1282
- error
1361
+ await offer(keyOf(target), event, message, target, async (renderer) => {
1362
+ const copy = (await copiesOf([message.id]))[message.id]?.[target] ?? await postReply(message, target, externalThread, actors, renderer);
1363
+ await deliverFiles(message, target, copy, actors, renderer);
1283
1364
  });
1365
+ } catch (error) {
1366
+ failed(failures, message.id, target, error);
1284
1367
  }
1285
1368
  }
1286
1369
  await settle(attempt, failures);
@@ -1289,28 +1372,30 @@ function createSyncEngine(entries) {
1289
1372
  async function deliverChange(event) {
1290
1373
  const copies = (await copiesOf([event.payload.id]))[event.payload.id] ?? {};
1291
1374
  if (Object.keys(copies).length === 0) return;
1292
- const message = event.type === "message.edited" ? await messageOf(event.payload.id) : event.message;
1293
- if (event.type === "message.edited" && (!message || message.deleted)) return;
1375
+ const message = event.type === "message.edited" ? await messageOf(event.payload.id) : event.message ?? await messageOf(event.payload.id);
1376
+ if (!message || event.type === "message.edited" && message.deleted) return;
1294
1377
  const actors = await actorsNow();
1295
1378
  for (const [target, copy] of Object.entries(copies)) {
1296
1379
  if (!ours(target) || event.payload.from !== null && keyOf(target) === event.payload.from) continue;
1297
1380
  try {
1298
- const platform = platformOf(keyOf(target));
1299
- const thread = native(copy.thread);
1300
- const theirs = copy.asUser && message && (event.type === "message.edited" || event.type === "message.deleted");
1301
- const token = theirs ? await personToken(platform, message, false) : null;
1302
- if (theirs && !token) {
1303
- console.warn(`[board] skipped ${event.type} on ${target}: #${message.id} was posted as its author, and their token is gone`);
1304
- continue;
1305
- }
1306
- if (event.type === "message.edited" && message) if (token) await platform.editAs(token, thread, copy.message, renderAsPerson(target, message, actors));
1307
- else await platform.edit(thread, copy.message, renderFor(target, message, actors, copy.files === null));
1308
- else if (event.type === "message.deleted") {
1309
- if (token) await platform.deleteAs(token, thread, copy.message);
1310
- else await platform.delete(thread, copy.message);
1311
- if (copy.files?.length) await platform.deleteFiles(copy.files);
1312
- } else if (event.type === "reaction.added") await platform.react(thread, copy.message, canonicalEmoji(event.payload.emoji), true);
1313
- else if (event.type === "reaction.removed") await platform.react(thread, copy.message, canonicalEmoji(event.payload.emoji), false);
1381
+ await offer(keyOf(target), event, message, target, async (renderer) => {
1382
+ const platform = platformOf(keyOf(target));
1383
+ const thread = native(copy.thread);
1384
+ const theirs = copy.asUser && (event.type === "message.edited" || event.type === "message.deleted");
1385
+ const token = theirs ? await personToken(platform, message, false) : null;
1386
+ if (theirs && !token) {
1387
+ console.warn(`[board] skipped ${event.type} on ${target}: #${message.id} was posted as its author, and their token is gone`);
1388
+ return;
1389
+ }
1390
+ if (event.type === "message.edited") if (token) await platform.editAs(token, thread, copy.message, renderAsPerson(target, message, actors));
1391
+ else await platform.edit(thread, copy.message, renderFor(target, message, actors, copy.files === null, renderer));
1392
+ else if (event.type === "message.deleted") {
1393
+ if (token) await platform.deleteAs(token, thread, copy.message);
1394
+ else await platform.delete(thread, copy.message);
1395
+ if (copy.files?.length) await platform.deleteFiles(copy.files);
1396
+ } else if (event.type === "reaction.added") await platform.react(thread, copy.message, canonicalEmoji(event.payload.emoji), true);
1397
+ else await platform.react(thread, copy.message, canonicalEmoji(event.payload.emoji), false);
1398
+ });
1314
1399
  } catch (error) {
1315
1400
  console.warn(`[board] skipped ${event.type} on ${target}:`, error.message);
1316
1401
  }
@@ -1339,9 +1424,11 @@ function createSyncEngine(entries) {
1339
1424
  for (const [target, copy] of Object.entries(copies)) {
1340
1425
  if (!ours(target)) continue;
1341
1426
  try {
1342
- const body = renderFor(target, message, actors, copy.files === null);
1343
- if (body.markdown === renderFor(target, previous, actors, copy.files === null).markdown) continue;
1344
- await platformOf(keyOf(target)).edit(native(copy.thread), copy.message, body);
1427
+ await offer(keyOf(target), event, message, target, async (renderer) => {
1428
+ const body = renderFor(target, message, actors, copy.files === null, renderer);
1429
+ if (body.markdown === renderFor(target, previous, actors, copy.files === null, renderer).markdown) return;
1430
+ await platformOf(keyOf(target)).edit(native(copy.thread), copy.message, body);
1431
+ });
1345
1432
  } catch (error) {
1346
1433
  console.warn(`[board] skipped labels.changed on ${target}:`, describe(error));
1347
1434
  }
@@ -1432,7 +1519,6 @@ function createSyncEngine(entries) {
1432
1519
  deliver,
1433
1520
  receive: inbound,
1434
1521
  pull,
1435
- configTargets,
1436
1522
  normalizeTarget,
1437
1523
  findByEmail,
1438
1524
  handleWebhook,