@vercel/board 0.29.0 → 0.30.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 (124) hide show
  1. package/dist/{a2-HUbQJ0RD.mjs → a2-D_28Pdip.mjs} +190 -76
  2. package/dist/access-CQ39gqEV.mjs +226 -0
  3. package/dist/access.d.mts +1 -1
  4. package/dist/access.mjs +3 -3
  5. package/dist/{actors-DcJhFbwJ.mjs → actors-xPiamLWk.mjs} +2 -2
  6. package/dist/{auth-JO22iIwr.mjs → auth-BPinwfN0.mjs} +3 -226
  7. package/dist/backend-a2/blob-vercel.d.mts +1 -1
  8. package/dist/backend-a2/contract.d.mts +2 -2
  9. package/dist/backend-a2/contract.mjs +1 -1
  10. package/dist/backend-a2/scheduler-local.d.mts +1 -1
  11. package/dist/backend-a2/scheduler-local.mjs +1 -1
  12. package/dist/backend-a2/scheduler-qstash.d.mts +3 -3
  13. package/dist/backend-a2/scheduler-qstash.mjs +8 -8
  14. package/dist/backend-a2/scheduler-vercel.d.mts +3 -3
  15. package/dist/backend-a2/scheduler-vercel.mjs +6 -6
  16. package/dist/backend-a2/store-memory.d.mts +2 -2
  17. package/dist/backend-a2/store-memory.mjs +3 -3
  18. package/dist/backend-a2/store-postgres.d.mts +1 -1
  19. package/dist/backend-a2/store-postgres.mjs +34 -15
  20. package/dist/backend-a2/store-redis-http.d.mts +1 -1
  21. package/dist/backend-a2/store-redis-http.mjs +6 -6
  22. package/dist/backend-a2/store-redis.d.mts +2 -2
  23. package/dist/backend-a2/store-redis.mjs +5 -5
  24. package/dist/backend-a2/store-sqlite.d.mts +1 -1
  25. package/dist/backend-a2/store-sqlite.mjs +3 -3
  26. package/dist/backend-a2.d.mts +17 -7
  27. package/dist/backend-a2.mjs +2 -2
  28. package/dist/{backend-http-Bmp0IgD3.mjs → backend-http-16SbvnAK.mjs} +2 -2
  29. package/dist/{backend-http-CqfKhrnn.d.mts → backend-http-3PoBYQoo.d.mts} +13 -3
  30. package/dist/{backend-http-CzIJMm58.mjs → backend-http-BPaNYLWj.mjs} +2 -3
  31. package/dist/backend-http.d.mts +1 -1
  32. package/dist/backend-http.mjs +1 -1
  33. package/dist/backend-http.node.d.mts +1 -1
  34. package/dist/backend-http.node.mjs +1 -1
  35. package/dist/{backend-CPC_vyOo.d.mts → backend-n3hajfrD.d.mts} +11 -5
  36. package/dist/{backend-vercel-BNauAs7w.mjs → backend-vercel-C4Kee2-K.mjs} +203 -31
  37. package/dist/{backend-vercel-DQc_TaHp.mjs → backend-vercel-Kd8nvW5A.mjs} +3 -3
  38. package/dist/{backend-vercel-4tvcPKqX.d.mts → backend-vercel-qznuDxnN.d.mts} +16 -5
  39. package/dist/backend-vercel.d.mts +1 -1
  40. package/dist/backend-vercel.mjs +1 -1
  41. package/dist/backend-vercel.node.d.mts +1 -1
  42. package/dist/backend-vercel.node.mjs +1 -1
  43. package/dist/{blob-DmiQjrYy.d.mts → blob-CqILvdmr.d.mts} +2 -2
  44. package/dist/{blob-DdbpJqN1.mjs → blob-DQK5prXT.mjs} +1 -1
  45. package/dist/{chat-provider-BWHbChcQ.d.mts → chat-provider-D6jVT4wS.d.mts} +3 -3
  46. package/dist/cli-commands.d.mts +1 -1
  47. package/dist/cli-commands.mjs +1 -1
  48. package/dist/cli-embedded.d.mts +2 -2
  49. package/dist/cli-embedded.mjs +1 -1
  50. package/dist/cli.mjs +4 -4
  51. package/dist/{client-BqAjk0Q4.d.mts → client-CjAatpV4.d.mts} +60 -10
  52. package/dist/{commands-C3R1AWgm.mjs → commands-B5DHjZKK.mjs} +50 -9
  53. package/dist/{commands-D6gvrOh3.d.mts → commands-BtH69yqt.d.mts} +17 -0
  54. package/dist/{conformance-DlnfuzEB.mjs → conformance-C7fwIvmN.mjs} +258 -18
  55. package/dist/conformance.d.mts +1 -1
  56. package/dist/conformance.mjs +1 -1
  57. package/dist/{engine-DQxXk7dX.mjs → engine-DkdkXQEy.mjs} +2 -2
  58. package/dist/{errors-DDktVeUa.d.mts → errors-87TVtXeb.d.mts} +1 -1
  59. package/dist/{client-D0Px7KzF.mjs → errors-C_5rB8Iz.mjs} +104 -4
  60. package/dist/{exports-BG-0Wyn-.mjs → exports-CG0P8Qi4.mjs} +54 -28
  61. package/dist/exports-CrGA5_yN.d.mts +5 -0
  62. package/dist/{folds-ZkHM4M-v.d.mts → folds-8wRRXjAt.d.mts} +2 -0
  63. package/dist/{folds-BDMSxBUf.mjs → folds-DGYkMW19.mjs} +1 -0
  64. package/dist/{index-D3VvNfh_.d.mts → index-BygAXP7t.d.mts} +2 -2
  65. package/dist/index.d.mts +6 -6
  66. package/dist/{index.default-rB8sJ9RX.mjs → index.default-DhkgpfR2.mjs} +1 -1
  67. package/dist/index.mjs +5 -6
  68. package/dist/index.node.d.mts +6 -6
  69. package/dist/index.node.mjs +3 -4
  70. package/dist/inspect/app.js +29 -29
  71. package/dist/{internal-CvsrprnU-CRY89I-b.mjs → internal-CvsrprnU-zZMpDiRY.mjs} +1 -1
  72. package/dist/{local-DhkrAqPP.mjs → local-C76Fy5ad.mjs} +1 -1
  73. package/dist/{main-CVBqe3VO.mjs → main-D2s-QBOK.mjs} +70 -23
  74. package/dist/{middleware-CXjEeef4.mjs → middleware-DlJQYiwV.mjs} +1 -1
  75. package/dist/{node-Zd_C-gxP.mjs → node-D8Rtg1su.mjs} +1 -1
  76. package/dist/{platform-B4TnJtWu-CwcTyKg2.mjs → platform-B4TnJtWu-Do3agKr5.mjs} +1 -1
  77. package/dist/{project-CBXlLftU.mjs → project-C_2xs57L.mjs} +1 -1
  78. package/dist/{provider-DmSTp9lI.d.mts → provider-Bm46LAdp.d.mts} +2 -2
  79. package/dist/{redis-tables-BJQVUspu.mjs → redis-tables-BWJZHUGg.mjs} +40 -24
  80. package/dist/{reducer-CjmvZSAb-D_OSISmj.mjs → reducer-Bswr11ar-P324o9vn.mjs} +2 -2
  81. package/dist/{reducer-CYPHsSmd-BQIfozGi.d.mts → reducer-CYPHsSmd-BXjGiptm.d.mts} +1 -1
  82. package/dist/{reducers-DJzVpXwM.mjs → reducers-8jqLewgw.mjs} +3 -3
  83. package/dist/{retryable-lazy-DZWmHpii-Bye-W8q7.mjs → retryable-lazy-DZWmHpii-DpTc5PIP.mjs} +1 -1
  84. package/dist/{scheduler-task-X1SToDjP-BVoWuInI.mjs → scheduler-task-X1SToDjP-D2Il2rln.mjs} +1 -1
  85. package/dist/{serve-G9RuaDSr.mjs → serve-lPoBDEds.mjs} +7 -7
  86. package/dist/{server-qgez0Xny-D5mLfqFQ.d.mts → server-B9gu0qi1-DI1WvcVK.d.mts} +10 -4
  87. package/dist/{server-BixV_byP.mjs → server-D82a4uMe.mjs} +4 -4
  88. package/dist/{server-DtFrWxi3-Boa6gdWO.mjs → server-DX7mM6nm-D7N_7bug.mjs} +32 -23
  89. package/dist/{serving-B75Y4BF-.mjs → serving-DKny38LK.mjs} +1 -1
  90. package/dist/{session-clear-wHd5GCpZ-BnKSxReK.mjs → session-clear-XJYpmUpg-2q8l_FJR.mjs} +14 -2
  91. package/dist/{sql-D4Z_efJX.mjs → sql-Bj0fk6dn.mjs} +1 -1
  92. package/dist/{sqlite-db-Bz2JkfCU.mjs → sqlite-db-Dc8G78XV.mjs} +1 -1
  93. package/dist/{store-D0MhBvg6-4Z2L0Kqd.d.mts → store-DEzcHZOA-B-5vhxef.d.mts} +12 -2
  94. package/dist/store-N8PXxDAS-DWWgv-Vz.mjs +5 -0
  95. package/dist/{store-codec-DwrWlgQx-CYxGW0aD.mjs → store-codec-pAG2oPgQ-SqDXlPBR.mjs} +4 -4
  96. package/dist/store-memory-4IBl0Ztu.mjs +2 -0
  97. package/dist/{store-memory-Bas9Uq5C.mjs → store-memory-B0PkVSuQ.mjs} +14 -7
  98. package/dist/{store-polling-BZt2Rf3D-BiEM6_HB.mjs → store-polling-BZt2Rf3D-Div-bJ_D.mjs} +2 -2
  99. package/dist/{store-presence-polling-JuAy2Ck2-CYpuK75X.mjs → store-presence-polling-JuAy2Ck2-Dh-g_KYM.mjs} +3 -3
  100. package/dist/{store-sqlite-CxxPCcCT.mjs → store-sqlite-D85t3tE7.mjs} +14 -7
  101. package/dist/store-sqlite-iBTYYKBj.mjs +2 -0
  102. package/dist/{stream-buffer-DrTyiIuo-BKy-vI14.mjs → stream-buffer-DrTyiIuo-Be10yOBR.mjs} +2 -2
  103. package/dist/sync-github.d.mts +2 -2
  104. package/dist/sync-kapso.d.mts +2 -2
  105. package/dist/sync-linear.d.mts +3 -3
  106. package/dist/sync-linear.mjs +4 -2
  107. package/dist/sync-slack.d.mts +3 -3
  108. package/dist/sync.chat.d.mts +1 -1
  109. package/dist/sync.d.mts +2 -2
  110. package/dist/{tokens-CC3izSNr.mjs → tokens-C5_yGRlL.mjs} +2 -2
  111. package/dist/{types-No0WdhE5.d.mts → types-shZmGZ-q.d.mts} +2 -2
  112. package/dist/{watchers-hash-BDhLVkV5.mjs → watchers-hash-B_5b0-ZG.mjs} +46 -17
  113. package/docs/01-board/04-messages.md +93 -0
  114. package/docs/01-board/08-middleware.md +5 -2
  115. package/docs/04-reference/01-cli.md +23 -10
  116. package/docs/04-reference/02-board.md +20 -5
  117. package/docs/04-reference/03-http.md +17 -6
  118. package/docs/04-reference/05-conformance.md +14 -3
  119. package/package.json +2 -2
  120. package/dist/errors-CusN4x2l.mjs +0 -88
  121. package/dist/exports-mhAYiU4i.d.mts +0 -5
  122. package/dist/store-N8PXxDAS-BWBgzBHp.mjs +0 -5
  123. package/dist/store-memory-ZLiZbH3R.mjs +0 -2
  124. package/dist/store-sqlite-BmwL3pUR.mjs +0 -2
@@ -1,31 +1,31 @@
1
1
  import { r as IMPORT_BYTES_MAX, t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
2
2
  import { _ as RemoteWatcherId, b as actorKind, x as platformAvatar } from "./schemas-DBmUO6pr.mjs";
3
3
  import "./external-D3p5T57R.mjs";
4
- import { a as METHODS, d as VERSION, f as SYNC, n as compileWatcher, r as hasCriteria, t as canonicalWatchers } from "./watchers-hash-BDhLVkV5.mjs";
5
- import { r as duration, t as BoardError } from "./client-D0Px7KzF.mjs";
4
+ import { a as METHODS, d as VERSION, f as SYNC, n as compileWatcher, r as hasCriteria, t as canonicalWatchers } from "./watchers-hash-B_5b0-ZG.mjs";
5
+ import { a as forbidden, c as toErrorResponse, f as duration, i as duplicateId, r as badRequest, s as notFound, t as HttpError, u as BoardError } from "./errors-C_5rB8Iz.mjs";
6
6
  import { i as isKeyIdOf, r as derivedId, t as waitUntil } from "./wait-until-Dw4XrIDQ.mjs";
7
- import { a as forbidden, c as toErrorResponse, i as duplicateId, r as badRequest, s as notFound, t as HttpError } from "./errors-CusN4x2l.mjs";
8
7
  import { t as own } from "./own-OwgPWdmh.mjs";
9
8
  import { n as replaceMentions } from "./mentions-CB6ShY4R.mjs";
10
- import { _ as FULL_ACCESS, i as authenticates, v as authorize } from "./auth-JO22iIwr.mjs";
11
- import { a as resolveMentions, n as actorSeen } from "./actors-DcJhFbwJ.mjs";
12
- import { n as MIDDLEWARE_ACTOR, t as DroppedError } from "./middleware-CXjEeef4.mjs";
9
+ import { n as authorize, t as FULL_ACCESS } from "./access-CQ39gqEV.mjs";
10
+ import { a as resolveMentions, n as actorSeen } from "./actors-xPiamLWk.mjs";
11
+ import { i as authenticates } from "./auth-BPinwfN0.mjs";
12
+ import { n as MIDDLEWARE_ACTOR, t as DroppedError } from "./middleware-DlJQYiwV.mjs";
13
13
  import { n as syncTarget, r as withEmailMentions } from "./targets-Cgjs29T0.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-DJzVpXwM.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-BDMSxBUf.mjs";
19
- import { c as readUpload, i as finishUpload, l as startUpload, n as attachmentNotFound, t as attachmentBody, u as writeUpload } from "./blob-DdbpJqN1.mjs";
20
- import { t as concernedMessageId } from "./project-CBXlLftU.mjs";
21
- import { t as TABLES_CONSUMER } from "./sql-D4Z_efJX.mjs";
14
+ import { t as A2Error } from "./session-clear-XJYpmUpg-2q8l_FJR.mjs";
15
+ import { a as reducer } from "./reducer-Bswr11ar-P324o9vn.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-8jqLewgw.mjs";
17
+ import { n as createServer, t as consumer } from "./server-DX7mM6nm-D7N_7bug.mjs";
18
+ import { a as linkedTo, i as iso, n as foldMessages, o as personOf, t as foldActors } from "./folds-DGYkMW19.mjs";
19
+ import { c as readUpload, i as finishUpload, l as startUpload, n as attachmentNotFound, t as attachmentBody, u as writeUpload } from "./blob-DQK5prXT.mjs";
20
+ import { t as concernedMessageId } from "./project-C_2xs57L.mjs";
21
+ import { t as TABLES_CONSUMER } from "./sql-Bj0fk6dn.mjs";
22
22
  import { t as afterRetry } from "./attempts-HXcskN1w.mjs";
23
23
  import { createHash, randomBytes } from "node:crypto";
24
24
  import { AsyncLocalStorage } from "node:async_hooks";
25
25
  import { lookup } from "node:dns";
26
26
  import { request } from "node:https";
27
27
  import { BlockList, isIP } from "node:net";
28
- //#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
28
+ //#region ../../node_modules/.pnpm/experimental-a2@0.24.0_@upstash+qstash@2.12.0_@vercel+queue@0.7.0_ai@7.0.130_zod@4.6.5__b1e2c243a963f2620a5d202817417c26/node_modules/experimental-a2/dist/idempotent-id-BRJVeylj.js
29
29
  async function idempotentId(...parts) {
30
30
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(parts)));
31
31
  const bytes = new Uint8Array(digest).slice(0, 16);
@@ -125,11 +125,13 @@ function viewedBy(view, reader) {
125
125
  }
126
126
  /** A message's or a cursor's ID as clients see it. On this board it's the log index, in decimal. */
127
127
  const ref = (index) => String(index);
128
+ /** A schedule's ID as clients see it: its index in session `schedules`, as `s12`, so it reads apart from message `12`. */
129
+ const scheduleRef = (index) => `s${index}`;
128
130
  /** The log index a ref names on this board, or null for a string that names nothing here. */
129
131
  const indexOf = (value) => /^(0|[1-9]\d{0,15})$/.test(value) ? Number(value) : null;
130
132
  /** A message as `reader` sees it. Every view names its reader, so none shows a link's snapshot by default. */
131
133
  function view(message, actors, reader) {
132
- const { blind, version, schedule, ...shown } = message;
134
+ const { blind, version, revision, schedule, ...shown } = message;
133
135
  return {
134
136
  ...shown,
135
137
  ...blind ? { blind } : {},
@@ -137,8 +139,9 @@ function view(message, actors, reader) {
137
139
  re: message.re === null ? null : ref(message.re),
138
140
  replyTo: message.replyTo === null ? null : ref(message.replyTo),
139
141
  version: version ?? message.id,
142
+ revision: revision ?? message.id,
140
143
  ...schedule ? { schedule: {
141
- id: ref(schedule.id),
144
+ id: scheduleRef(schedule.id),
142
145
  n: schedule.n
143
146
  } } : {},
144
147
  links: readableLinks(message.links, reader),
@@ -325,7 +328,7 @@ function payloadView(payload, links) {
325
328
  ]) if (typeof shown[key] === "number") shown[key] = ref(shown[key]);
326
329
  const schedule = shown.schedule;
327
330
  if (schedule) shown.schedule = {
328
- id: ref(schedule.id),
331
+ id: scheduleRef(schedule.id),
329
332
  n: schedule.n
330
333
  };
331
334
  if (links) shown.links = links;
@@ -750,19 +753,20 @@ async function writePost(ctx, input) {
750
753
  if (input.sync?.length) throw badRequest("sync attaches whole threads: use `board sync <id> <target>`");
751
754
  } else {
752
755
  if (!channel) throw badRequest("a post needs a channel");
756
+ if (input.after !== void 0) throw badRequest("`after` is for replies", "invalid_after");
753
757
  thread = id;
754
758
  }
755
759
  spend(input.proofs.post, "post", { channel });
756
760
  if (hasLabels(input.labels)) spend(input.proofs.label, "label", { channel });
757
761
  if (input.sync?.length) spend(input.proofs.sync, "sync", { channel });
758
- if (input.id !== void 0) {
759
- const landed = await ctx.reads.byEventId(input.id);
760
- if (landed) {
761
- if (landed.channel !== channel || landed.re !== re || landed.replyTo !== replyTo) throw duplicateId();
762
- const shown = await landedFor(ctx, identity, landed);
763
- if (!shown) throw duplicateId();
764
- return shownTo(shown, identity.principal.access, sent);
765
- }
762
+ const replayed = await replayOf(ctx, input, {
763
+ channel,
764
+ re,
765
+ replyTo
766
+ }, sent);
767
+ if (replayed) return replayed;
768
+ if (input.after !== void 0) {
769
+ if ((await ctx.reads.message(input.after))?.thread !== thread) throw badRequest(`after names no message in #${re}'s thread`, "invalid_after");
766
770
  }
767
771
  return commitPost(ctx, input, {
768
772
  channel,
@@ -773,6 +777,16 @@ async function writePost(ctx, input) {
773
777
  sent
774
778
  });
775
779
  }
780
+ /** The post a caller's own ID already landed, as its retry gets it back: null when there's none. */
781
+ async function replayOf(ctx, input, place, sent) {
782
+ if (input.id === void 0) return null;
783
+ const landed = await ctx.reads.byEventId(input.id);
784
+ if (!landed) return null;
785
+ if (landed.channel !== place.channel || landed.re !== place.re || landed.replyTo !== place.replyTo) throw duplicateId();
786
+ const shown = await landedFor(ctx, input.identity, landed);
787
+ if (!shown) throw duplicateId();
788
+ return shownTo(shown, input.identity.principal.access, sent);
789
+ }
776
790
  /**
777
791
  * A post that landed, as its author may have it back: only the same author's, and one in a
778
792
  * channel it can't read only if it posted without reading too. A caller that can't read gets back
@@ -833,28 +847,45 @@ async function commitPost(ctx, input, placed) {
833
847
  by: identity.author
834
848
  }
835
849
  })));
836
- const rows = await ctx.session.append(...seen ? [seen] : [], ...named, {
837
- type: "message.posted",
838
- ...id ? { id } : {},
839
- payload: {
850
+ const events = [
851
+ ...seen ? [seen] : [],
852
+ ...named,
853
+ {
854
+ type: "message.posted",
855
+ ...id ? { id } : {},
856
+ payload: {
857
+ channel,
858
+ text,
859
+ re,
860
+ replyTo,
861
+ thread,
862
+ labels: input.labels ?? {},
863
+ noSync: input.noSync ?? false,
864
+ mentions,
865
+ by: identity.author,
866
+ origin: input.origin ?? null,
867
+ from: input.from ?? null,
868
+ ...input.attachments?.length ? { attachments: input.attachments } : {},
869
+ ...input.links?.length ? { links: input.links } : {},
870
+ ...input.schedule ? { schedule: input.schedule } : {},
871
+ ...identity.principal.access.readable(channel) ? {} : { blind: true },
872
+ ...input.importedBy !== void 0 ? { importedBy: input.importedBy } : {}
873
+ }
874
+ },
875
+ ...synced
876
+ ];
877
+ let rows;
878
+ try {
879
+ rows = input.after === void 0 || re === null ? await ctx.session.append(...events) : await appendWhile(ctx, nothingAfter(ctx, re, input.after), events);
880
+ } catch (error) {
881
+ const replayed = error instanceof HttpError && error.code === "newer_message" ? await replayOf(ctx, input, {
840
882
  channel,
841
- text,
842
883
  re,
843
- replyTo,
844
- thread,
845
- labels: input.labels ?? {},
846
- noSync: input.noSync ?? false,
847
- mentions,
848
- by: identity.author,
849
- origin: input.origin ?? null,
850
- from: input.from ?? null,
851
- ...input.attachments?.length ? { attachments: input.attachments } : {},
852
- ...input.links?.length ? { links: input.links } : {},
853
- ...input.schedule ? { schedule: input.schedule } : {},
854
- ...identity.principal.access.readable(channel) ? {} : { blind: true },
855
- ...input.importedBy !== void 0 ? { importedBy: input.importedBy } : {}
856
- }
857
- }, ...synced);
884
+ replyTo
885
+ }, sent) : null;
886
+ if (replayed) return replayed;
887
+ throw error;
888
+ }
858
889
  const posted = rows.find((row) => row.type === "message.posted");
859
890
  await untilApplied(ctx, rows);
860
891
  const message = foldMessages({
@@ -923,6 +954,36 @@ async function untilApplied(ctx, rows) {
923
954
  }
924
955
  /** How long a write waits for the tables before it answers anyway. */
925
956
  const LANDED_WAIT_MS = 1e4;
957
+ /**
958
+ * Appends `events` only while the board, as `holds` reads it, still allows them: `holds` checks
959
+ * the tables, throws what refuses the write, and returns the index its reads reflect. The append
960
+ * holds only while that index is still the log's last, so anything that lands in between,
961
+ * anywhere on the board, moves it, and it looks again from there.
962
+ */
963
+ async function appendWhile(ctx, holds, events) {
964
+ for (let attempt = 1;; attempt++) {
965
+ await ctx.reads.ensure();
966
+ const position = await holds();
967
+ try {
968
+ return await appendIfLast(ctx.session, position, events);
969
+ } catch (error) {
970
+ if (!headMoved(error)) throw error;
971
+ if (attempt === CONDITIONAL_TRIES) throw new HttpError(503, "unavailable", "the board kept changing while it checked: try again");
972
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * 5 * attempt));
973
+ }
974
+ }
975
+ }
976
+ /** How many times a conditional write looks again, as the board moves on, before it gives up. */
977
+ const CONDITIONAL_TRIES = 10;
978
+ /** A reply's `after`: nothing came after it in the thread, as of the index the read reflects. */
979
+ const nothingAfter = (ctx, root, after) => async () => {
980
+ const { messages: [newer], position } = await ctx.reads.tables.messages({ re: root }, {
981
+ after,
982
+ limit: 1
983
+ });
984
+ if (newer) throw new HttpError(409, "newer_message", `#${newer.id} came after #${after}`);
985
+ return position;
986
+ };
926
987
  /** The message a by-ID write targets, as the board looked it up, once its proof is spent on the message's channel. */
927
988
  function spent(authorized, permission) {
928
989
  spend(authorized.proof, permission, authorized.message);
@@ -931,7 +992,10 @@ function spent(authorized, permission) {
931
992
  async function writeEdit(ctx, input) {
932
993
  const message = spent(input.authorized, "post");
933
994
  if (input.asAuthor !== false && message.by.principal !== input.identity.principal.id) throw forbidden("only the author can edit a message");
934
- if (message.deleted) throw badRequest("message was deleted", "message_deleted");
995
+ if (message.deleted) {
996
+ if (input.revision !== void 0 && input.revision !== (message.revision ?? message.id)) throw new HttpError(409, "revision_conflict", `#${message.id} was deleted since revision ${input.revision}`);
997
+ throw badRequest("message was deleted", "message_deleted");
998
+ }
935
999
  return commitEdit(ctx, input, message);
936
1000
  }
937
1001
  async function commitEdit(ctx, input, message) {
@@ -940,17 +1004,22 @@ async function commitEdit(ctx, input, message) {
940
1004
  const { text, found } = await withEmailMentions(input.text);
941
1005
  const named = found.map((actor) => actorSeen(actor, actors)).filter((event) => event !== null);
942
1006
  const { mentions, unknown } = resolveMentions(text, knownWith(actors, found), seen ? input.identity.actor : void 0, input.from != null, input.identity.principal.access.readsAll);
943
- await untilApplied(ctx, await ctx.session.append(...seen ? [seen] : [], ...named, {
944
- type: "message.edited",
945
- payload: {
946
- id: message.id,
947
- thread: message.thread,
948
- text,
949
- mentions,
950
- by: input.identity.author,
951
- from: input.from ?? null
1007
+ const events = [
1008
+ ...seen ? [seen] : [],
1009
+ ...named,
1010
+ {
1011
+ type: "message.edited",
1012
+ payload: {
1013
+ id: message.id,
1014
+ thread: message.thread,
1015
+ text,
1016
+ mentions,
1017
+ by: input.identity.author,
1018
+ from: input.from ?? null
1019
+ }
952
1020
  }
953
- }));
1021
+ ];
1022
+ await untilApplied(ctx, input.revision === void 0 ? await ctx.session.append(...events) : await appendWhile(ctx, revisionIs(ctx, message.id, input.revision), events));
954
1023
  return unknown.length && input.identity.principal.access.readable(message.channel) ? { unknownMentions: unknown } : {};
955
1024
  }
956
1025
  /** A message's links, replaced: a platform edit or unfurl changed what it links to. */
@@ -971,8 +1040,11 @@ async function writeLinks(ctx, input) {
971
1040
  async function writeDelete(ctx, input) {
972
1041
  const message = spent(input.authorized, "post");
973
1042
  if (input.asAuthor !== false && message.by.principal !== input.identity.principal.id) throw forbidden("only the author can delete a message");
974
- if (message.deleted) return;
975
- await untilApplied(ctx, await ctx.session.append({
1043
+ if (message.deleted) {
1044
+ if (input.revision !== void 0 && input.revision !== (message.revision ?? message.id)) throw new HttpError(409, "revision_conflict", `#${message.id} was deleted since revision ${input.revision}`);
1045
+ return;
1046
+ }
1047
+ const deleted = [{
976
1048
  type: "message.deleted",
977
1049
  payload: {
978
1050
  id: message.id,
@@ -980,8 +1052,17 @@ async function writeDelete(ctx, input) {
980
1052
  by: input.identity.author,
981
1053
  from: input.from ?? null
982
1054
  }
983
- }));
984
- }
1055
+ }];
1056
+ await untilApplied(ctx, input.revision === void 0 ? await ctx.session.append(...deleted) : await appendWhile(ctx, revisionIs(ctx, message.id, input.revision), deleted));
1057
+ }
1058
+ /** An edit's or a delete's `revision`: no edit or delete since, as of the index the read reflects. */
1059
+ const revisionIs = (ctx, id, revision) => async () => {
1060
+ const position = await ctx.reads.tables.position();
1061
+ const message = (await ctx.reads.tables.messagesAt([id], position)).get(id);
1062
+ const now = message ? message.revision ?? message.id : null;
1063
+ if (now !== revision) throw new HttpError(409, "revision_conflict", message?.deleted ? `#${id} was deleted since revision ${revision}` : `#${id} was edited since revision ${revision}: it's at ${now}`);
1064
+ return position;
1065
+ };
985
1066
  async function writeReaction(ctx, input) {
986
1067
  const message = spent(input.authorized, "react");
987
1068
  if (message.deleted) throw badRequest("message was deleted", "message_deleted");
@@ -1003,7 +1084,7 @@ async function writeLabels(ctx, input) {
1003
1084
  if (message.deleted) throw badRequest("message was deleted", "message_deleted");
1004
1085
  const { state: actors } = await ctx.session.state(actorsReducer);
1005
1086
  const seen = seenFor(input.identity, actors);
1006
- await untilApplied(ctx, await ctx.session.append(...seen ? [seen] : [], {
1087
+ const events = [...seen ? [seen] : [], {
1007
1088
  type: "labels.changed",
1008
1089
  payload: {
1009
1090
  id: message.id,
@@ -1012,8 +1093,21 @@ async function writeLabels(ctx, input) {
1012
1093
  unset: input.unset ?? [],
1013
1094
  by: input.identity.author
1014
1095
  }
1015
- }));
1016
- }
1096
+ }];
1097
+ await untilApplied(ctx, input.if !== void 0 && Object.keys(input.if).length > 0 ? await appendWhile(ctx, labelsAre(ctx, message.id, input.if), events) : await ctx.session.append(...events));
1098
+ }
1099
+ /** A label write's `if`: each label is as it says, as of the index the read reflects. */
1100
+ const labelsAre = (ctx, id, expected) => async () => {
1101
+ const position = await ctx.reads.tables.position();
1102
+ const labels = (await ctx.reads.tables.messagesAt([id], position)).get(id)?.labels ?? {};
1103
+ for (const key of Object.keys(expected).sort()) {
1104
+ const now = own(labels, key);
1105
+ if (now === expected[key] || now === void 0 && expected[key] === null) continue;
1106
+ const wanted = expected[key];
1107
+ throw new HttpError(409, "label_conflict", wanted === null ? `${key} is set` : now === void 0 ? `${key} isn't set` : `${key} isn't ${wanted}`);
1108
+ }
1109
+ return position;
1110
+ };
1017
1111
  /**
1018
1112
  * Syncs a message's thread with a target, resolved by the sync that delivers it. Attaching twice
1019
1113
  * is a no-op; a platform thread another board thread is synced with is a 409.
@@ -1037,6 +1131,10 @@ async function writeAttach(ctx, input) {
1037
1131
  }));
1038
1132
  return { attached: true };
1039
1133
  }
1134
+ /** The batch, only while `index` is still the session's last: A2's conditional append. */
1135
+ const appendIfLast = (session, index, events) => session.append.ifLastIndex(index, ...events);
1136
+ /** What A2 throws when the session's last index isn't the one a conditional append named. */
1137
+ const headMoved = (error) => error?.code === "LAST_INDEX_MISMATCH";
1040
1138
  //#endregion
1041
1139
  //#region src/server/schedules.ts
1042
1140
  /** Vercel Queues delays at most 6 days: a send further out than this goes in hops. */
@@ -1137,7 +1235,7 @@ const createdBy = (schedule, principal) => schedule.as.principal.id === principa
1137
1235
  const scheduleView = (schedule, actors) => {
1138
1236
  const author = actors.byId[schedule.as.actor.id] ?? schedule.as.actor;
1139
1237
  return {
1140
- id: ref(schedule.id),
1238
+ id: scheduleRef(schedule.id),
1141
1239
  channel: schedule.channel,
1142
1240
  re: schedule.re === null ? null : ref(schedule.re),
1143
1241
  text: schedule.text,
@@ -2349,9 +2447,17 @@ function messageAt(value) {
2349
2447
  if (index === null || index === 0) throw notFound(`message not found: #${value}`, "message_not_found");
2350
2448
  return index;
2351
2449
  }
2352
- /** A schedule's ID, `12` or as the CLI shows it, `s12`, as its index in session `schedules`. */
2450
+ /**
2451
+ * A reply's `after`, as the index of a message. One that isn't one is 0, which names no message:
2452
+ * the post refuses it, after a retry that landed gets its reply back.
2453
+ */
2454
+ const afterAt = (value) => value === void 0 ? void 0 : indexOf(value) ?? 0;
2455
+ /**
2456
+ * A schedule's ID, `s12`, as its index in session `schedules`. `12` from a client that strips the
2457
+ * `s`, and `ss12` from an older CLI that adds one to what this board hands out, name it too.
2458
+ */
2353
2459
  function scheduleAt(value) {
2354
- const bare = value.replace(/^s/, "");
2460
+ const bare = value.replace(/^s{1,2}/, "");
2355
2461
  const index = indexOf(bare);
2356
2462
  if (index === null || index === 0) throw notFound(`schedule not found: s${bare}`, "schedule_not_found");
2357
2463
  return index;
@@ -2722,6 +2828,7 @@ function build(options, binding) {
2722
2828
  noSync: body.noSync,
2723
2829
  id: body.id,
2724
2830
  sync: body.sync,
2831
+ after: afterAt(body.after),
2725
2832
  ...schedule && !fromRequest(caller) ? {
2726
2833
  schedule,
2727
2834
  lenientMentions: true
@@ -2880,25 +2987,27 @@ function build(options, binding) {
2880
2987
  async post(caller, parsed) {
2881
2988
  return postNow(caller, await identityOf(caller), parsed);
2882
2989
  },
2883
- async edit(caller, { id, text }) {
2990
+ async edit(caller, { id, text, revision }) {
2884
2991
  const identity = await identityOf(caller);
2885
2992
  const authorized = await authorizeOwn(writes, identity.principal, messageAt(id), "edit");
2886
2993
  const { unknownMentions } = await writeEdit(writes, {
2887
2994
  identity,
2888
2995
  authorized,
2889
- text
2996
+ text,
2997
+ revision
2890
2998
  });
2891
2999
  return {
2892
3000
  ok: true,
2893
3001
  ...unknownMentions ? { unknownMentions } : {}
2894
3002
  };
2895
3003
  },
2896
- async delete(caller, { id }) {
3004
+ async delete(caller, { id, revision }) {
2897
3005
  const identity = await identityOf(caller);
2898
3006
  const authorized = await authorizeOwn(writes, identity.principal, messageAt(id), "delete");
2899
3007
  await writeDelete(writes, {
2900
3008
  identity,
2901
- authorized
3009
+ authorized,
3010
+ revision
2902
3011
  });
2903
3012
  return { ok: true };
2904
3013
  },
@@ -2913,14 +3022,15 @@ function build(options, binding) {
2913
3022
  });
2914
3023
  return { ok: true };
2915
3024
  },
2916
- async label(caller, { id, set, unset }) {
3025
+ async label(caller, { id, set, unset, if: expected }) {
2917
3026
  const identity = await identityOf(caller);
2918
3027
  const authorized = await authorizeMessage(writes, identity.principal.access, "label", messageAt(id));
2919
3028
  await writeLabels(writes, {
2920
3029
  identity,
2921
3030
  authorized,
2922
3031
  set,
2923
- unset
3032
+ unset,
3033
+ if: expected
2924
3034
  });
2925
3035
  return { ok: true };
2926
3036
  },
@@ -3194,6 +3304,7 @@ function build(options, binding) {
3194
3304
  origin: body.origin,
3195
3305
  from: body.from,
3196
3306
  ...body.origin && !ownSync(caller) ? { importedBy: principal.id } : {},
3307
+ after: afterAt(body.after),
3197
3308
  lenientMentions: true,
3198
3309
  attachments,
3199
3310
  links: await linksOf(principal, body.links)
@@ -3222,7 +3333,8 @@ function build(options, binding) {
3222
3333
  authorized: proof,
3223
3334
  text,
3224
3335
  from: body.from,
3225
- asAuthor
3336
+ asAuthor,
3337
+ revision: body.revision
3226
3338
  });
3227
3339
  return ref(found.id);
3228
3340
  };
@@ -3240,7 +3352,8 @@ function build(options, binding) {
3240
3352
  identity: author,
3241
3353
  authorized: await authorized("post"),
3242
3354
  from: body.from,
3243
- asAuthor
3355
+ asAuthor,
3356
+ revision: body.revision
3244
3357
  });
3245
3358
  else if (body.op === "react") await writeReaction(writes, {
3246
3359
  identity: author,
@@ -3253,7 +3366,8 @@ function build(options, binding) {
3253
3366
  identity: author,
3254
3367
  authorized: await authorized("label"),
3255
3368
  set: body.set,
3256
- unset: body.unset
3369
+ unset: body.unset,
3370
+ if: body.if
3257
3371
  });
3258
3372
  else await writeLinks(writes, {
3259
3373
  identity: author,