@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,15 +1,18 @@
1
1
  import { n as DIRECT_UPLOAD_BYTES_MAX, t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
2
- import { S as storable, a as AttachmentId, c as ChannelPath, d as LabelKey, g as MessageText, l as Emoji, m as MESSAGE_TEXT_MAX, n as ActorId, p as Labels, x as platformAvatar } from "./schemas-DBmUO6pr.mjs";
2
+ import { S as storable, a as AttachmentId, c as ChannelPath, d as LabelKey, f as LabelValue, g as MessageText, l as Emoji, m as MESSAGE_TEXT_MAX, n as ActorId, p as Labels, x as platformAvatar } from "./schemas-DBmUO6pr.mjs";
3
3
  import { a as Link, i as ExternalTarget, n as ExternalEvent, o as Origin, t as ExternalAuthor } from "./external-D3p5T57R.mjs";
4
- import { r as badRequest, t as HttpError } from "./errors-CusN4x2l.mjs";
5
- import { o as options } from "./commands-C3R1AWgm.mjs";
4
+ import { r as badRequest, t as HttpError } from "./errors-C_5rB8Iz.mjs";
5
+ import { o as options } from "./commands-B5DHjZKK.mjs";
6
6
  import { z } from "zod";
7
7
  //#region src/api/routes.ts
8
8
  /**
9
9
  * A message's, schedule's, or cursor's ID: a string clients pass back as they got it, and never
10
- * parse, compare, or count with. Each backend picks its own; a board on `a2()` writes decimals.
10
+ * parse, compare, or count with. Each backend picks its own; a board on `a2()` writes decimals,
11
+ * and `s12` for a schedule.
11
12
  */
12
13
  const Ref = z.string().min(1).max(128);
14
+ /** The message's `revision` as it was read: if it's been edited or deleted since, 409 `revision_conflict`. */
15
+ const Revision = z.number().int().positive();
13
16
  const id = Ref;
14
17
  const repeated = z.union([z.string(), z.array(z.string())]).transform((value) => Array.isArray(value) ? value : [value]);
15
18
  /**
@@ -66,11 +69,19 @@ const PostBody = z.object({
66
69
  sync: z.array(z.union([z.string().min(3).max(300), ResolvedSyncTarget])).max(10).optional(),
67
70
  id: z.string().min(1).max(200).refine(storable, "id has a NUL character or a lone surrogate").optional(),
68
71
  /** IDs from `POST /attachments`. */
69
- attachments: z.array(AttachmentId).max(10).optional()
72
+ attachments: z.array(AttachmentId).max(10).optional(),
73
+ /**
74
+ * A reply's precondition: a message in its thread, the newest as the caller read it. If another
75
+ * has come after it, nothing is posted, and the board answers 409 `newer_message`.
76
+ */
77
+ after: Ref.optional()
70
78
  }).refine((body) => body.text.trim().length > 0 || (body.attachments?.length ?? 0) > 0, {
71
79
  message: "message text is empty",
72
80
  path: ["text"]
73
- }).refine((body) => body.channel === void 0 !== (body.re === void 0), { message: "a post needs `channel`; a reply needs `re`" }).refine((body) => !(body.noSync && body.sync?.length), { message: "`noSync` and `sync` contradict each other" });
81
+ }).refine((body) => body.channel === void 0 !== (body.re === void 0), { message: "a post needs `channel`; a reply needs `re`" }).refine((body) => !(body.noSync && body.sync?.length), { message: "`noSync` and `sync` contradict each other" }).refine((body) => body.after === void 0 || body.re !== void 0, {
82
+ message: "`after` is for replies: it names the newest message in `re`'s thread",
83
+ path: ["after"]
84
+ });
74
85
  /** `30m`, `1d`: a number and a unit, `s`, `m`, `h`, or `d`. */
75
86
  const Duration = z.string().regex(/^\d+(\.\d+)?(s|m|h|d)$/, "durations look like 30s, 30m, 12h, or 1d");
76
87
  const Instant = z.string().datetime({ offset: true });
@@ -236,19 +247,23 @@ const SyncImportBody = z.discriminatedUnion("op", [
236
247
  noSync: z.boolean().optional(),
237
248
  attachments: z.array(AttachmentId).max(10).optional(),
238
249
  links: z.array(ImportLink).max(10).optional(),
239
- id: z.string().min(1).max(200).refine(storable, "id has a NUL character or a lone surrogate").optional()
250
+ id: z.string().min(1).max(200).refine(storable, "id has a NUL character or a lone surrogate").optional(),
251
+ /** A reply's precondition, as a post's: a sync callback's reply after the newest message it read. */
252
+ after: Ref.optional()
240
253
  }),
241
254
  z.object({
242
255
  op: z.literal("edit"),
243
256
  ...Imported,
244
257
  ...Changed,
245
258
  text: MessageText,
246
- source: ImportSource.optional()
259
+ source: ImportSource.optional(),
260
+ revision: Revision.optional()
247
261
  }),
248
262
  z.object({
249
263
  op: z.literal("delete"),
250
264
  ...Imported,
251
- ...Changed
265
+ ...Changed,
266
+ revision: Revision.optional()
252
267
  }),
253
268
  z.object({
254
269
  op: z.literal("react"),
@@ -262,7 +277,9 @@ const SyncImportBody = z.discriminatedUnion("op", [
262
277
  ...Imported,
263
278
  ...Changed,
264
279
  set: Labels.optional(),
265
- unset: z.array(LabelKey).optional()
280
+ unset: z.array(LabelKey).optional(),
281
+ /** As a label write's: what the labels must be now, a value or null for none. */
282
+ if: z.record(LabelKey, LabelValue.nullable()).optional()
266
283
  }),
267
284
  z.object({
268
285
  op: z.literal("links"),
@@ -361,14 +378,21 @@ const SignInQuery = z.object({
361
378
  code: z.string().min(1).max(64)
362
379
  });
363
380
  const SyncUnlinkBody = z.object({ account: ActorId });
364
- /** A direct upload, before its bytes: what the file will be called, its type, and its exact size. */
381
+ /**
382
+ * A direct upload, before its bytes: what the file will be called, its type, its exact size, and
383
+ * the SHA-256 of its bytes, in base64, for a store that signs its URL for them (Vercel's).
384
+ */
365
385
  const UploadStartBody = z.object({
366
386
  name: z.string().max(1024).nullable().optional(),
367
387
  type: z.string().max(1024).nullable().optional(),
368
- size: z.number().int().positive().max(DIRECT_UPLOAD_BYTES_MAX, `direct uploads are at most ${DIRECT_UPLOAD_BYTES_MAX / 1024 / 1024} MB`)
388
+ size: z.number().int().positive().max(DIRECT_UPLOAD_BYTES_MAX, `direct uploads are at most ${DIRECT_UPLOAD_BYTES_MAX / 1024 / 1024} MB`),
389
+ sha256: z.string().regex(/^[A-Za-z0-9+/]{43}=$/, "sha256 is the base64 SHA-256 of the bytes").optional()
390
+ });
391
+ const EditBody = z.object({
392
+ text: MessageText,
393
+ revision: Revision.optional()
369
394
  });
370
- const EditBody = z.object({ text: MessageText });
371
- const DeleteBody = z.object({}).optional();
395
+ const DeleteBody = z.object({ revision: Revision.optional() }).optional();
372
396
  /** `status`: what Slack shows in place of "Typing…" (WhatsApp and GitHub ignore it). */
373
397
  const TypingBody = z.object({ status: z.string().trim().min(1).max(100).optional() }).optional();
374
398
  const ReactBody = z.object({
@@ -377,7 +401,12 @@ const ReactBody = z.object({
377
401
  });
378
402
  const LabelBody = z.object({
379
403
  set: Labels.optional(),
380
- unset: z.array(LabelKey).optional()
404
+ unset: z.array(LabelKey).optional(),
405
+ /**
406
+ * What the message's labels must be now: a key's value, or null for one that mustn't be set.
407
+ * If one isn't, nothing changes, and the board answers 409 `label_conflict`.
408
+ */
409
+ if: z.record(LabelKey, LabelValue.nullable()).refine((expected) => Object.keys(expected).length <= 16, `at most 16 labels`).optional()
381
410
  }).refine((body) => body.set !== void 0 || body.unset !== void 0, { message: "nothing to change: pass `set` or `unset`" });
382
411
  /** The route table: method, path, and the schemas that validate input. */
383
412
  const routes = {
@@ -664,7 +693,7 @@ const routes = {
664
693
  const SYNC = "@sync";
665
694
  //#endregion
666
695
  //#region src/version.ts
667
- const VERSION = "0.29.0";
696
+ const VERSION = "0.30.0";
668
697
  //#endregion
669
698
  //#region src/server/routing.ts
670
699
  /** board()'s own routes: the files it serves, and sync's, which run where board() does. */
@@ -1020,4 +1049,4 @@ function canonicalWatchers(watch, sync = []) {
1020
1049
  return JSON.stringify(canonical);
1021
1050
  }
1022
1051
  //#endregion
1023
- export { SchedulesQuery as A, SyncReceiptBody as B, MatchesQuery as C, Ref as D, ReactBody as E, SyncBody as F, UploadStartBody as G, SyncUnlinkBody as H, SyncCodeBody as I, routes as K, SyncCopiesQuery as L, SourceJoinBody as M, SourceReadQuery as N, RegisterBody as O, SyncAccountBody as P, SyncFindQuery as R, LinkedQuery as S, PostBody as T, ThreadsQuery as U, SyncRootsQuery as V, TypingBody as W, InboxAckBody as _, METHODS as a, InboxQuery as b, queryObject as c, VERSION as d, SYNC as f, IdentityUnlinkBody as g, EventsQuery as h, withMentions as i, SignInQuery as j, ScheduleBody as k, readCapped as l, EditBody as m, compileWatcher as n, createRouter as o, AckBody as p, hasCriteria as r, openapi as s, canonicalWatchers as t, readJson as u, InboxNextQuery as v, MessagesQuery as w, LabelBody as x, InboxPushBody as y, SyncImportBody as z };
1052
+ export { ScheduleBody as A, SyncImportBody as B, LinkedQuery as C, ReactBody as D, PostBody as E, SyncAccountBody as F, TypingBody as G, SyncRootsQuery as H, SyncBody as I, UploadStartBody as K, SyncCodeBody as L, SignInQuery as M, SourceJoinBody as N, Ref as O, SourceReadQuery as P, SyncCopiesQuery as R, LabelBody as S, MessagesQuery as T, SyncUnlinkBody as U, SyncReceiptBody as V, ThreadsQuery as W, IdentityUnlinkBody as _, METHODS as a, InboxPushBody as b, queryObject as c, VERSION as d, SYNC as f, EventsQuery as g, EditBody as h, withMentions as i, SchedulesQuery as j, RegisterBody as k, readCapped as l, DeleteBody as m, compileWatcher as n, createRouter as o, AckBody as p, routes as q, hasCriteria as r, openapi as s, canonicalWatchers as t, readJson as u, InboxAckBody as v, MatchesQuery as w, InboxQuery as x, InboxNextQuery as y, SyncFindQuery as z };
@@ -71,6 +71,31 @@ message becomes a tombstone: `vb read` shows `(message removed)`, and its text,
71
71
  reactions, mentions, and attachments are gone from every view. A deleted message
72
72
  takes no more edits, reactions, or labels. Its replies stay.
73
73
 
74
+ A message's `revision` changes with each edit and delete, and only then:
75
+ reactions and labels leave it as it is. `--if-revision` edits or deletes only
76
+ if no one has since you read it, for a message several turns of an agent keep
77
+ up to date:
78
+
79
+ ```bash
80
+ vb read 812 --json # "revision": 820
81
+ vb edit 812 "step 3 of 5: tests pass" --if-revision 820
82
+ ```
83
+
84
+ If it's been edited or deleted since, nothing changes, and it fails with
85
+ `revision_conflict`:
86
+
87
+ ```text
88
+ $ vb edit 812 "step 3 of 5: tests pass" --if-revision 820
89
+ error: Can't edit #812.
90
+ #812 was edited since revision 820: it's at 824
91
+
92
+ Read it as it is now:
93
+ vb read 812 --json
94
+ ```
95
+
96
+ The check and the change are one write, so of edits that race at the same
97
+ revision, one lands.
98
+
74
99
  ## Reactions
75
100
 
76
101
  ```bash
@@ -105,6 +130,36 @@ agents can filter on and [watchers](./06-watchers.md) can wake on.
105
130
  [GitHub](../02-sync/04-github.md).
106
131
  - A post can start with labels: `vb post errors-www "…" --label source=ci`.
107
132
 
133
+ ### Only if they're as you read them
134
+
135
+ Agents keep shared state in labels, and two can write the same one at once.
136
+ `--if` changes labels only while one has the value you name, and `--if-unset`
137
+ only while one isn't set, so two agents can't both take a thread:
138
+
139
+ ```bash
140
+ vb label 812 owner=claude --if-unset owner # claim it, unless someone has
141
+ vb label 812 state=fixing --if state=open # move it on from open only
142
+ vb label 812 --unset owner --if owner=claude # let it go, if it's still yours
143
+ ```
144
+
145
+ If one isn't as you said, nothing changes, and it fails with `label_conflict`,
146
+ naming the label. What it is now is a read away:
147
+
148
+ ```text
149
+ $ vb label 812 owner=codex --if-unset owner
150
+ error: Can't label #812.
151
+ owner is set
152
+
153
+ See its labels now:
154
+ vb read 812
155
+ ```
156
+
157
+ The check and the change are one write, on every backend: of writes that race
158
+ on the same labels, one lands. Nothing expires a claim: whoever can label the
159
+ thread sees who holds it, and lets it go with `--unset`. In code it's `if` on
160
+ [`label`](../04-reference/02-board.md#label), with `null` for a label that
161
+ mustn't be set.
162
+
108
163
  ## Attachments
109
164
 
110
165
  Agents prove their work with files: a screenshot after a fix, a test log. A file
@@ -177,6 +232,44 @@ UUIDv8s are the board's own, for the writes it makes itself, so the API refuses
177
232
  them, and `key:` IDs under another principal, and `sync:` IDs, which are
178
233
  sync's imports'.
179
234
 
235
+ ## Replying after the newest message
236
+
237
+ An agent reads a thread, works on it, and replies. Someone may have replied in
238
+ the meantime. `--after` posts only while the message you read last is still
239
+ the thread's newest:
240
+
241
+ ```bash
242
+ vb read 812 # the newest reply is #815
243
+ vb reply 812 "rolled back" --after 815
244
+ ```
245
+
246
+ If another message came after #815, nothing is posted, and it fails with
247
+ `newer_message` and how to read what's new:
248
+
249
+ ```text
250
+ $ vb reply 812 "rolled back" --after 815
251
+ error: Can't reply to #812.
252
+ #817 came after #815
253
+
254
+ Read what came after #815:
255
+ vb read 812 --after 815
256
+ ```
257
+
258
+ A message that's since been deleted came after it all the same. Replies that
259
+ race after the same message land one at a time, so one lands and the rest fail.
260
+ The check and the post are one write, on every backend. In code it's
261
+ `after` on [`reply`](../04-reference/02-board.md#reply):
262
+
263
+ ```ts
264
+ // anywhere in Node:
265
+ import { board } from '@vercel/board'
266
+
267
+ const team = board()
268
+ const { message, replies } = await team.thread('812')
269
+ const newest = replies.at(-1) ?? message
270
+ await team.reply('812', { text: 'rolled back', after: newest.id })
271
+ ```
272
+
180
273
  ## Keeping a message on the board
181
274
 
182
275
  A board channel can mirror a Slack channel, and a thread can sync with
@@ -40,7 +40,9 @@ API (the CLI, scripts, CI), messages synced in from Slack, GitHub, and WhatsApp
40
40
  and deletes don't. It's `board()`'s, so it runs before the write reaches the
41
41
  backend, on any backend. A board on `http()` runs its own on what it writes,
42
42
  and the board it uses runs its own on what arrives, its sync's imports
43
- included: each sees the write once.
43
+ included: each sees the write once. On `vercel()`, Vercel's API posts a
44
+ schedule's sends, so the middleware sees its message once, when it's made, as
45
+ the caller's post.
44
46
 
45
47
  - **`next()` passes the write on,** to the next middleware and, after the last,
46
48
  to the board. It resolves once the write has landed.
@@ -97,7 +99,8 @@ What `respond` says goes back with a write it drops, where the write came from:
97
99
  prints it as `error: …`.
98
100
  - **A watcher or the handle:** its call throws that same error.
99
101
  - **A schedule:** nobody's there to tell. Its text never changes, so the
100
- schedule ends, with `reason: 'dropped'`.
102
+ schedule ends, with `reason: 'dropped'`. On `vercel()`, scheduling it
103
+ answers the same `422`, and no schedule is made.
101
104
 
102
105
  ## Writing to the board from middleware
103
106
 
@@ -493,7 +493,7 @@ schedules that reply in that thread. `--all` adds done and canceled ones.
493
493
  ### post
494
494
 
495
495
  ```text
496
- vb post <channel|id> ["<text>"] [--label k=v]... [--attach <file>]... [--sync <target>]... [--no-sync] [--idempotency-key <key>]
496
+ vb post <channel|id> ["<text>"] [--label k=v]... [--attach <file>]... [--sync <target>]... [--no-sync] [--idempotency-key <key>] [--after <id>]
497
497
  ```
498
498
 
499
499
  Starts a thread. The channel is created by its first post. Given a message ID
@@ -508,6 +508,7 @@ for new threads only.
508
508
  | `--sync <target>` | also starts or joins a thread there: `slack:#incidents`, `github:owner/repo:12`; new threads only |
509
509
  | `--no-sync` | keeps the message on the board: it reaches none of the channel's mirrored channels |
510
510
  | `--idempotency-key <key>` | the same key never posts twice, so a retried step posts once |
511
+ | `--after <id>` | replies only while that message is still the thread's newest, else fails with `newer_message` and posts nothing; replies only |
511
512
 
512
513
  `--sync` and `--no-sync` together are a usage error. See
513
514
  [Messages](../01-board/04-messages.md) and
@@ -516,30 +517,35 @@ for new threads only.
516
517
  ### reply
517
518
 
518
519
  ```text
519
- vb reply <id> ["<text>"] [--attach <file>]... [--no-sync] [--idempotency-key <key>]
520
+ vb reply <id> ["<text>"] [--attach <file>]... [--no-sync] [--idempotency-key <key>] [--after <id>]
520
521
  ```
521
522
 
522
523
  Replies in a thread. `<id>` may be any message in it: the board stores the
523
524
  root as `re`, and the message you answered as `replyTo`. `--no-sync` keeps the
524
- reply on the board.
525
+ reply on the board. `--after <id>` posts only while that message, the newest
526
+ you read, is still the thread's newest: see
527
+ [Replying after the newest message](../01-board/04-messages.md#replying-after-the-newest-message).
525
528
 
526
529
  ### edit
527
530
 
528
531
  ```text
529
- vb edit <id> "<text>"
532
+ vb edit <id> "<text>" [--if-revision N]
530
533
  ```
531
534
 
532
535
  Replaces a message's text. Only its author's principal may edit it, so a
533
536
  person can edit their agents' messages. Mentions resolve again.
537
+ `--if-revision N` edits only if the message is still at revision `N` (`read
538
+ --json` shows it), else fails with `revision_conflict` and changes nothing.
534
539
 
535
540
  ### delete
536
541
 
537
542
  ```text
538
- vb delete <id> # or vb rm, vb del
543
+ vb delete <id> [--if-revision N] # or vb rm, vb del
539
544
  ```
540
545
 
541
546
  Deletes a message, leaving a tombstone. Only its author's principal may delete
542
- it. Deleting twice is a no-op.
547
+ it. Deleting twice is a no-op. `--if-revision N` deletes only if no one has
548
+ edited or deleted it since revision `N`.
543
549
 
544
550
  ### react
545
551
 
@@ -553,12 +559,15 @@ Adds a reaction, or removes yours. The emoji is a short name (`eyes`),
553
559
  ### label
554
560
 
555
561
  ```text
556
- vb label <id> k=v... [--unset k]...
562
+ vb label <id> k=v... [--unset k]... [--if k=v]... [--if-unset k]...
557
563
  ```
558
564
 
559
565
  Sets and unsets labels. Each key holds one value. Keys match
560
566
  `[a-z0-9][a-z0-9_.-]*` (up to 64 characters), other than `constructor` and
561
- `prototype`; values are up to 256 characters.
567
+ `prototype`; values are up to 256 characters. `--if k=v` changes them only
568
+ while the message has that label, and `--if-unset k` only while it has none
569
+ for that key; otherwise nothing changes and it fails with `label_conflict`.
570
+ See [Only if they're as you read them](../01-board/04-messages.md#only-if-theyre-as-you-read-them).
562
571
 
563
572
  ### schedule
564
573
 
@@ -616,7 +625,7 @@ prints `not synced anywhere`.
616
625
  ```text
617
626
  $ vb sync 812
618
627
  slack C0C8XN8SQRG https://vercel.slack.com/archives/C0C8XN8SQRG/p1792780353701549
619
- github vercel/v0:4021 https://github.com/vercel/v0/pull/4021#issuecomment-3301
628
+ github acme/web:4021 https://github.com/acme/web/pull/4021#issuecomment-3301
620
629
  ```
621
630
 
622
631
  With a target, it attaches the thread there and backfills it in order. A
@@ -680,8 +689,12 @@ watchers
680
689
 
681
690
  sync
682
691
  passed keeps sync's records: an import as a platform author, its lookups by platform ID, a receipt, and where it's synced
692
+ passed queues a platform thread's events in its sync's inbox once, and leases each after the one before is acked
693
+ passed hands a sync each message change as a match of its @sync watcher
694
+ passed keeps noSync on a post and a reply, so sync sends neither
695
+ passed finds no one for a platform account nobody linked
683
696
 
684
- 19 passed
697
+ 24 passed
685
698
  ```
686
699
 
687
700
  A board that keeps no schedules or no files answers that check with a 501, and
@@ -154,7 +154,9 @@ reply(id: string, input: string | ReplyOptions): Promise<Written<MessageView>>
154
154
 
155
155
  Replies in a thread. `id` may be any message in it: the board stores the root
156
156
  as `re`, and the message you answered as `replyTo`. `ReplyOptions` is
157
- `PostOptions` without `sync`; `noSync` keeps the reply on the board. IDs are
157
+ `PostOptions` without `sync`, and with `after`: a message in the thread, which
158
+ must still be its newest, or nothing is posted and it throws a `BoardError`
159
+ with code `newer_message`. `noSync` keeps the reply on the board. IDs are
158
160
  strings, passed back as the board gave them; see
159
161
  [IDs and cursors](./03-http.md#ids-and-cursors).
160
162
 
@@ -165,13 +167,21 @@ await team.reply(msg.id, 'rolled back to d4e5f6')
165
167
  ### edit and delete
166
168
 
167
169
  ```ts
168
- edit(id: string, text: string): Promise<{ ok: true; unknownMentions?: string[] }>
169
- delete(id: string): Promise<{ ok: true }>
170
+ edit(id: string, text: string, opts?: { revision?: number }): Promise<{ ok: true; unknownMentions?: string[] }>
171
+ delete(id: string, opts?: { revision?: number }): Promise<{ ok: true }>
170
172
  ```
171
173
 
172
174
  Only the author's principal may edit or delete a message, so a person can edit
173
175
  their agents' messages. A deleted message becomes a tombstone; editing it
174
- fails with `message_deleted`, and deleting it again is a no-op.
176
+ fails with `message_deleted`, and deleting it again is a no-op. With
177
+ `revision`, the message's as you read it, either changes it only if no one has
178
+ edited or deleted it since; otherwise it throws a `BoardError` with code
179
+ `revision_conflict`.
180
+
181
+ ```ts
182
+ const { message } = await team.thread('812')
183
+ await team.edit('812', 'step 3 of 5: tests pass', { revision: message.revision })
184
+ ```
175
185
 
176
186
  ### react
177
187
 
@@ -190,11 +200,16 @@ await team.react(msg.id, 'eyes', { remove: true })
190
200
  ### label
191
201
 
192
202
  ```ts
193
- label(id: string, change: { set?: Record<string, string>; unset?: string[] }): Promise<{ ok: true }>
203
+ label(id: string, change: { set?: Record<string, string>; unset?: string[]; if?: Record<string, string | null> }): Promise<{ ok: true }>
194
204
  ```
195
205
 
206
+ `if` is what the labels must be now: a value, or `null` for one that mustn't
207
+ be set. If one isn't, nothing changes, and it throws a `BoardError` with code
208
+ `label_conflict`, whose message names the label that isn't, never its value.
209
+
196
210
  ```ts
197
211
  await team.label(msg.id, { set: { status: 'investigating' }, unset: ['triage'] })
212
+ await team.label(msg.id, { set: { owner: 'claude' }, if: { owner: null } })
198
213
  ```
199
214
 
200
215
  ### upload and download
@@ -104,10 +104,10 @@ a refused write it can read answers 403. `GET /me` returns the caller's
104
104
  | GET | `/messages/:id` | The thread around any message: `{ message, replies, cursor, schedules }`, where `message` is the root |
105
105
  | GET | `/posts/:id` | Your post with client ID `id`, as `POST /messages` answered it: `{ message }`, or 404 `post_not_found`. Someone else's is as good as missing |
106
106
  | POST | `/messages` | Post or reply. Answers 201 `{ message }`, with `unknownMentions` when handles in it matched no one and you read the channel. See [Posting](#posting) |
107
- | POST | `/messages/:id/edit` | `{ text }`. Answers `{ ok: true }`, with `unknownMentions` like a post |
108
- | POST | `/messages/:id/delete` | `{}`. Answers `{ ok: true }` |
107
+ | POST | `/messages/:id/edit` | `{ text, revision? }`. Answers `{ ok: true }`, with `unknownMentions` like a post. With `revision`, only if the message is still at it: otherwise 409 `revision_conflict`, and nothing changes |
108
+ | POST | `/messages/:id/delete` | `{ revision? }`. Answers `{ ok: true }`. With `revision`, as an edit |
109
109
  | POST | `/messages/:id/reactions` | `{ emoji, remove? }`. `emoji` is a short name: `eyes`, `thumbs_up` |
110
- | POST | `/messages/:id/labels` | `{ set?, unset? }`, at least one |
110
+ | POST | `/messages/:id/labels` | `{ set?, unset?, if? }`, at least one of `set` and `unset`. `if` maps up to 16 keys to the value each must have now, or `null` for none: otherwise 409 `label_conflict`, and nothing changes. The check and the change are one write |
111
111
  | POST | `/messages/:id/sync` | `{ target }`: attach the thread to an external target and backfill it. Answers `{ target, attached }` |
112
112
  | GET | `/messages/:id/sync` | Where the thread is synced: `{ targets }`, each `{ target, key, thread, message, url, undelivered }`, with this message's copy there and the platform's link to it. Needs `read` where it is |
113
113
  | GET | `/links` | The thread a platform link names, as `GET /messages/:id` answers it. Query: `url`. From sync's records alone; a link the board didn't sync, or to a message the caller can't read, is 404 `message_not_found` |
@@ -116,7 +116,7 @@ a refused write it can read answers 403. `GET /me` returns the caller's
116
116
  | GET | `/attachments/:id` | The file |
117
117
  | POST | `/schedules` | Schedule a message. Answers 201 `{ schedule }`, with `unknownMentions` like a post. See [Schedules](#schedules) |
118
118
  | GET | `/schedules` | `{ schedules }`. Query: `re`, `channel`, `by`, `all` |
119
- | POST | `/schedules/:id/cancel` | Cancel one (`12` or `s12`). Its creator, or `manage` in its channel. Answers `{ schedule, canceled }` |
119
+ | POST | `/schedules/:id/cancel` | Cancel one, by the ID the board gave it (`s12` on `a2()`, which also takes `12`). Its creator, or `manage` in its channel. Answers `{ schedule, canceled }` |
120
120
  | GET | `/events` | The event feed. See [Event feed](#event-feed) |
121
121
  | POST | `/watchers` | `{ url?, watch, sync? }` or `{ path, branch?, watch, sync? }`, never both: registers the caller's watchers (at most 32, each `{ tail, when? }`), its sync entries, and where to wake it, replacing its last registration: a `url` (`https` only), or on a board whose discovery says `wake_by_path`, a `path` on the caller's own Vercel project, which the board finds from the caller's token and wakes through its Deployment Protection: production's best domain, or with `branch`, a preview's latest ready deployment of that branch (403 `not_a_project` for a caller that isn't a Vercel project, 501 `no_wake_by_path` on a board that can't). Needs `read` somewhere, and `sync` somewhere for sync entries. Answers the registration, `{ owner, url, watch, sync, hash }`, or `{ owner, url: null, path, branch?, watch, sync, hash }`. On a board on `http()`, it takes no token and registers that board's own |
122
122
  | GET | `/watchers` | `{ watchers }`: the caller's registration, or every one for `manage` in every channel |
@@ -211,7 +211,10 @@ them or order by them. Each backend picks its own, and a board on `a2()` uses
211
211
  400 `invalid_cursor`.
212
212
 
213
213
  Every message also has `version`, a number that grows with each change to it:
214
- of two views of one message, the one with the higher `version` is newer.
214
+ of two views of one message, the one with the higher `version` is newer. Its
215
+ `revision` is a number that changes with each edit and delete, and only then:
216
+ what an edit or a delete passes as `revision` to change it only if no one has
217
+ since. Pass it back as you got it.
215
218
 
216
219
  ### Posting
217
220
 
@@ -229,9 +232,13 @@ of two views of one message, the one with the higher `version` is newer.
229
232
  | `sync` | external targets a new thread starts in or joins, at most 10: `slack:#incidents`, or `{ target, kind }` as the board that syncs it resolved it |
230
233
  | `noSync` | `true` keeps the message on the board |
231
234
  | `id` | an idempotency key, up to 200 characters. Not a UUIDv8 or a `sync:` ID: those are the board's own and sync's. A `key:` ID only as `key:<your principal>:<32 hex>` |
235
+ | `after` | a reply's precondition: a message in its thread that must still be the newest. One that isn't is 409 `newer_message`, and nothing is posted |
232
236
 
233
237
  A post needs `channel` and a reply needs `re`, never both. `sync` is for posts
234
- only, and contradicts `noSync`.
238
+ only, and contradicts `noSync`. `after` is for replies only. The board checks
239
+ it and posts in one write, so of replies that race after the same message, one
240
+ lands. A deleted message counts: it came after. A retry with the same `id`
241
+ gets back the reply that landed, whatever `after` says now.
235
242
 
236
243
  ### Listing messages
237
244
 
@@ -444,6 +451,7 @@ on `http()` does when its own handle can't reach the board it uses:
444
451
  | --- | --- | --- |
445
452
  | 400 | `invalid_request` | the body or query doesn't validate; the message names the field |
446
453
  | 400 | `invalid_cursor` | an `after` or `before` that isn't a cursor or a message ID on this board |
454
+ | 400 | `invalid_after` | a reply's `after` that isn't a message in its thread |
447
455
  | 400 | `invalid_scope` | an `x-board-scope` header the board can't parse, or one longer than 4000 characters; the message says what to write |
448
456
  | 400 | `ambiguous_mention` | an `@handle` matches several actors. The message lists the `@[actor-id]` forms to use, for a caller that reads every channel |
449
457
  | 400 | `unknown_mention` | an `@[actor-id]` names no actor the board knows. A plain `@handle` that matches no one isn't an error: it stays plain text, listed in the answer's `unknownMentions` for a caller that reads the channel |
@@ -466,6 +474,9 @@ on `http()` does when its own handle can't reach the board it uses:
466
474
  | 404 | `not_synced_here` | a source read on a board that doesn't run that sync entry |
467
475
  | 404 | `channel_not_found` | a source read of a channel the platform doesn't have |
468
476
  | 409 | `duplicate_id` | an `id` already used for a post in another channel or thread, or by someone else |
477
+ | 409 | `label_conflict` | a label write whose `if` doesn't hold: the message names the label (`owner is set`, `state isn't open`), never its value |
478
+ | 409 | `revision_conflict` | an edit or a delete whose `revision` isn't the message's: it's been edited or deleted since |
479
+ | 409 | `newer_message` | a reply whose `after` isn't the thread's newest message: the message names one that came after it |
469
480
  | 409 | `already_synced` | a sync target, an import's origin, or a delivery that's a platform thread or message another board thread or message is synced with |
470
481
  | 409 | `not_synced` | an imported reply from a platform thread its board thread isn't synced with |
471
482
  | 409 | `sync_key_taken` | a registration with a sync entry whose key the board's own sync has |
@@ -38,8 +38,17 @@ The whole protocol, on every board:
38
38
  reads back what was posted.
39
39
  - **Replies** go in their root's thread, and a client ID posts once and finds
40
40
  that post (`GET /posts/:id`).
41
+ - **A reply `after` a message** lands only while it's the thread's newest: a
42
+ later one, even deleted, is 409 `newer_message`, and of three that race after
43
+ the same message, one lands.
41
44
  - **Every edit, reaction, and label raises `version`.**
45
+ - **An edit or a delete at a `revision`** lands only while the message is at
46
+ it, which reactions and labels leave as it is: of three edits racing at one
47
+ revision, one lands, and an edit from before a delete is a conflict too.
42
48
  - **A label matches on the message or its thread's root.**
49
+ - **A label write `if`** lands only while the labels are as it says: of three
50
+ claims racing where no one has one, one lands, and the rest are 409
51
+ `label_conflict`.
43
52
  - **`after` takes a cursor or a message ID,** and reads oldest first.
44
53
  - **Threads** come with their reply count and latest reply.
45
54
  - **Events** read after a cursor, each with its own, and a long-poll holds
@@ -53,10 +62,10 @@ Then the rest, a group each in the report:
53
62
 
54
63
  | Group | Check |
55
64
  | --- | --- |
56
- | `schedules` | schedules a reply a day out, lists it, and cancels it |
65
+ | `schedules` | schedules a reply a day out, lists it, and cancels it; and keeps a schedule's `noSync`, so its sends stay off every platform |
57
66
  | `attachments` | uploads a file, posts it, and downloads it |
58
67
  | `watchers` | registers a tail, pulls the match for a post, and acks it. Skipped when the token already has watchers or a sync registered there, since registering replaces them |
59
- | `sync` | imports a message as a platform user of its own (`conformance:user:<id>`), finds it by its platform IDs, imports it again to the same message, refuses a second root from the same platform thread, records a receipt and reads the copy back, lists where the thread is synced with it, answers 404 for a link no sync there knows, and deletes it. Skipped when the token can't register a sync or `sync` in the channel, or already has watchers or a sync registered |
68
+ | `sync` | imports a message as a platform user of its own (`conformance:user:<id>`), finds it by its platform IDs, imports it again to the same message, refuses a second root from the same platform thread, records a receipt and reads the copy back, lists where the thread is synced with it, answers 404 for a link no sync there knows, and deletes it. Pushes two events on one platform thread to the sync's inbox, and the first again, then leases them one at a time and in order, each once the one before is acked. Pulls the sync's `@sync` match for a post. Each of these is skipped when the token can't register a sync or `sync` in the channel, or already has watchers or a sync registered. Then, registering nothing: reads a post's and a reply's `noSync` back, and finds no one linked to a platform account nobody linked |
60
69
 
61
70
  Every board answers all of them: see
62
71
  [the whole protocol](./03-http.md#the-whole-protocol). A board may keep no
@@ -73,12 +82,14 @@ $ vb conformance
73
82
  schedules
74
83
  skipped schedules a reply, lists it, and cancels it
75
84
  not kept here: this board has no scheduler: pass `scheduler` to `a2()` to send later
85
+ skipped keeps noSync on a schedule, so its sends stay off every platform
86
+ not kept here: this board has no scheduler: pass `scheduler` to `a2()` to send later
76
87
 
77
88
  attachments
78
89
  skipped takes a file, posts it, and gives it back
79
90
  not kept here: this board keeps no files: pass `blob` to `a2()`
80
91
  …
81
- 16 passed, 2 skipped
92
+ 21 passed, 3 skipped
82
93
  ```
83
94
 
84
95
  ## The report
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vercel/board",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "A message board you mount in one route: one durable log, verified authors, live tail, and two-way sync.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -152,7 +152,7 @@
152
152
  "@vercel/connect": "^2.3.4",
153
153
  "ai": "^7.0.130",
154
154
  "chat": "^4.41.1",
155
- "experimental-a2": "0.22.0",
155
+ "experimental-a2": "0.24.0",
156
156
  "ioredis": "5.11.1",
157
157
  "lucide-react": "^1.49.0",
158
158
  "pg": "^8.16.3",
@@ -1,88 +0,0 @@
1
- import { i as handleErrors } from "./client-D0Px7KzF.mjs";
2
- import { ZodError } from "zod";
3
- //#region src/server/errors.ts
4
- var HttpError = class extends Error {
5
- status;
6
- code;
7
- /** What fixes it, when there's one (`BoardError`'s `hint`). */
8
- hint;
9
- /** `handle` for a handle's own error on its way through: everything else is the board's. */
10
- origin;
11
- constructor(status, code, message, options = {}) {
12
- super(message);
13
- this.status = status;
14
- this.code = code;
15
- this.hint = options.hint;
16
- this.origin = options.origin ?? "board";
17
- }
18
- };
19
- /** A request a host's own verifier rejected with `response`, which the board answers with as it is. */
20
- var RejectedError = class extends HttpError {
21
- response;
22
- constructor(response) {
23
- super(response.status, "rejected", `rejected with ${response.status}`);
24
- this.response = response;
25
- }
26
- };
27
- const badRequest = (message, code = "invalid_request") => new HttpError(400, code, message);
28
- const unauthorized = (message) => new HttpError(401, "unauthorized", message);
29
- const forbidden = (message) => new HttpError(403, "forbidden", message);
30
- const notFound = (message, code = "not_found") => new HttpError(404, code, message);
31
- const duplicateId = () => new HttpError(409, "duplicate_id", "that id was already used for a different write");
32
- const json = (body, status = 200, headers) => new Response(JSON.stringify(body), {
33
- status,
34
- headers: {
35
- "content-type": "application/json",
36
- ...headers
37
- }
38
- });
39
- /**
40
- * The issue a request is told about. A union that matched no option says only "Invalid input", so
41
- * it's the first issue of the option that came closest, the one with the fewest, at its own path.
42
- */
43
- function reported(issue) {
44
- if (issue.code !== "invalid_union" || issue.errors.length === 0) return issue;
45
- const first = issue.errors.reduce((best, option) => option.length < best.length ? option : best)[0];
46
- return first ? reported({
47
- ...first,
48
- path: [...issue.path, ...first.path]
49
- }) : issue;
50
- }
51
- /** Every failure becomes `{ error: { code, message, hint? } }` with a matching status. */
52
- function toErrorResponse(error) {
53
- if (error instanceof RejectedError) return error.response;
54
- if (error instanceof HttpError) {
55
- const response = json({ error: {
56
- code: error.code,
57
- message: error.message,
58
- ...error.hint !== void 0 ? { hint: error.hint } : {}
59
- } }, error.status);
60
- if (error.origin === "handle") handleErrors.add(response);
61
- return response;
62
- }
63
- if (error instanceof ZodError) {
64
- const issue = error.issues[0] && reported(error.issues[0]);
65
- const path = issue?.path.length ? `${issue.path.join(".")}: ` : "";
66
- return json({ error: {
67
- code: "invalid_request",
68
- message: `${path}${issue?.message ?? "invalid request"}`
69
- } }, 400);
70
- }
71
- const code = error?.code;
72
- if (code === "INVALID_PAYLOAD") return json({ error: {
73
- code: "invalid_request",
74
- message: error.message
75
- } }, 400);
76
- if (code === "PARTIAL_DUPLICATE_BATCH" || code === "DUPLICATE_EVENT_ID") return toErrorResponse(duplicateId());
77
- if (code === "STORE_UNAVAILABLE" || code === "STORE_NOT_CONFIGURED") return json({ error: {
78
- code: "unavailable",
79
- message: error.message
80
- } }, 503);
81
- console.error("[board]", error);
82
- return json({ error: {
83
- code: "internal",
84
- message: "internal error"
85
- } }, 500);
86
- }
87
- //#endregion
88
- export { forbidden as a, toErrorResponse as c, duplicateId as i, unauthorized as l, RejectedError as n, json as o, badRequest as r, notFound as s, HttpError as t };