@vercel/board 0.28.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 (139) hide show
  1. package/dist/{a2-B0_VlbtC.mjs → a2-D_28Pdip.mjs} +197 -259
  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-BLXVp4oZ.mjs → actors-xPiamLWk.mjs} +3 -3
  6. package/dist/{auth-lTkOgvcE.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 +3 -3
  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 +20 -7
  27. package/dist/backend-a2.mjs +2 -1
  28. package/dist/{backend-http-BvNFrvcY.mjs → backend-http-16SbvnAK.mjs} +2 -2
  29. package/dist/{backend-http-E7YU0qhn.d.mts → backend-http-3PoBYQoo.d.mts} +15 -3
  30. package/dist/{backend-http-CfLVYqmj.mjs → backend-http-BPaNYLWj.mjs} +4 -5
  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-ZKYTe3xa.d.mts → backend-n3hajfrD.d.mts} +12 -6
  36. package/dist/{backend-vercel-D87pIp2k.mjs → backend-vercel-C4Kee2-K.mjs} +260 -30
  37. package/dist/{backend-vercel-DTrDiSk5.mjs → backend-vercel-Kd8nvW5A.mjs} +3 -3
  38. package/dist/{backend-vercel-CIRObA0t.d.mts → backend-vercel-qznuDxnN.d.mts} +20 -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-B-9AvYis.d.mts → blob-CqILvdmr.d.mts} +2 -2
  44. package/dist/blob-DQK5prXT.mjs +182 -0
  45. package/dist/{chat-provider-DnHibqnM.mjs → chat-provider-COtsU5jB.mjs} +2 -2
  46. package/dist/{chat-provider-DsNEBQiA.d.mts → chat-provider-D6jVT4wS.d.mts} +5 -3
  47. package/dist/cli-commands.d.mts +1 -1
  48. package/dist/cli-commands.mjs +1 -1
  49. package/dist/cli-embedded.d.mts +2 -2
  50. package/dist/cli-embedded.mjs +1 -1
  51. package/dist/cli.mjs +5 -5
  52. package/dist/{client-xLxeDz0f.d.mts → client-CjAatpV4.d.mts} +67 -10
  53. package/dist/{commands-BFookFtn.mjs → commands-B5DHjZKK.mjs} +54 -10
  54. package/dist/{commands-D6gvrOh3.d.mts → commands-BtH69yqt.d.mts} +17 -0
  55. package/dist/{conformance-DlnfuzEB.mjs → conformance-C7fwIvmN.mjs} +258 -18
  56. package/dist/conformance.d.mts +1 -1
  57. package/dist/conformance.mjs +1 -1
  58. package/dist/{engine-Bfe6pcXb.mjs → engine-DkdkXQEy.mjs} +21 -13
  59. package/dist/{errors-CB4tnZGz.d.mts → errors-87TVtXeb.d.mts} +1 -1
  60. package/dist/{client-D0Px7KzF.mjs → errors-C_5rB8Iz.mjs} +104 -4
  61. package/dist/{events-CewD4kw7.mjs → events-qf5XG5VQ.mjs} +2 -2
  62. package/dist/{exports-CCzw2Vdw.mjs → exports-CG0P8Qi4.mjs} +59 -33
  63. package/dist/exports-CrGA5_yN.d.mts +5 -0
  64. package/dist/{external-BV_cUncs.mjs → external-D3p5T57R.mjs} +4 -2
  65. package/dist/{folds-jS9wlh9_.d.mts → folds-8wRRXjAt.d.mts} +4 -0
  66. package/dist/{folds-Dyjput8M.mjs → folds-DGYkMW19.mjs} +3 -2
  67. package/dist/{index-C05it0mC.d.mts → index-BygAXP7t.d.mts} +2 -2
  68. package/dist/index.d.mts +6 -6
  69. package/dist/{index.default-DmKxH_E6.mjs → index.default-DhkgpfR2.mjs} +1 -1
  70. package/dist/index.mjs +5 -6
  71. package/dist/index.node.d.mts +6 -6
  72. package/dist/index.node.mjs +3 -4
  73. package/dist/inspect/app.js +29 -29
  74. package/dist/inspect/engine.js +5 -5
  75. package/dist/{internal-CvsrprnU-CRY89I-b.mjs → internal-CvsrprnU-zZMpDiRY.mjs} +1 -1
  76. package/dist/{local-DhkrAqPP.mjs → local-C76Fy5ad.mjs} +1 -1
  77. package/dist/{main-eci7w-aP.mjs → main-D2s-QBOK.mjs} +76 -26
  78. package/dist/{middleware-ChKM1zuu.mjs → middleware-DlJQYiwV.mjs} +2 -2
  79. package/dist/{node-Zd_C-gxP.mjs → node-D8Rtg1su.mjs} +1 -1
  80. package/dist/{platform-B4TnJtWu-CwcTyKg2.mjs → platform-B4TnJtWu-Do3agKr5.mjs} +1 -1
  81. package/dist/{project-DMDST9kl.mjs → project-C_2xs57L.mjs} +1 -1
  82. package/dist/{provider-poIkXapd.mjs → provider-BaVmi4Hi.mjs} +3 -1
  83. package/dist/{provider-Cdg1qadh.d.mts → provider-Bm46LAdp.d.mts} +9 -2
  84. package/dist/{redis-tables-DN9PfIDi.mjs → redis-tables-BWJZHUGg.mjs} +41 -25
  85. package/dist/{reducer-CjmvZSAb-D_OSISmj.mjs → reducer-Bswr11ar-P324o9vn.mjs} +2 -2
  86. package/dist/{reducer-CYPHsSmd-BQIfozGi.d.mts → reducer-CYPHsSmd-BXjGiptm.d.mts} +1 -1
  87. package/dist/{reducers-BcmSs4qf.mjs → reducers-8jqLewgw.mjs} +5 -5
  88. package/dist/{retryable-lazy-DZWmHpii-Bye-W8q7.mjs → retryable-lazy-DZWmHpii-DpTc5PIP.mjs} +1 -1
  89. package/dist/{scheduler-task-X1SToDjP-BVoWuInI.mjs → scheduler-task-X1SToDjP-D2Il2rln.mjs} +1 -1
  90. package/dist/{schemas-Be08nyfW.mjs → schemas-DBmUO6pr.mjs} +2 -1
  91. package/dist/{serve-B09an9DQ.mjs → serve-lPoBDEds.mjs} +7 -6
  92. package/dist/{server-qgez0Xny-D5mLfqFQ.d.mts → server-B9gu0qi1-DI1WvcVK.d.mts} +10 -4
  93. package/dist/{server-DTX0prGr.mjs → server-D82a4uMe.mjs} +4 -4
  94. package/dist/{server-DtFrWxi3-Boa6gdWO.mjs → server-DX7mM6nm-D7N_7bug.mjs} +32 -23
  95. package/dist/{serving-CvOWEym2.mjs → serving-DKny38LK.mjs} +1 -1
  96. package/dist/{session-clear-wHd5GCpZ-BnKSxReK.mjs → session-clear-XJYpmUpg-2q8l_FJR.mjs} +14 -2
  97. package/dist/{sql-U9WTxrwJ.mjs → sql-Bj0fk6dn.mjs} +2 -2
  98. package/dist/{sqlite-db-Ds4majtT.mjs → sqlite-db-Dc8G78XV.mjs} +1 -1
  99. package/dist/{store-D0MhBvg6-4Z2L0Kqd.d.mts → store-DEzcHZOA-B-5vhxef.d.mts} +12 -2
  100. package/dist/store-N8PXxDAS-DWWgv-Vz.mjs +5 -0
  101. package/dist/{store-codec-DwrWlgQx-CYxGW0aD.mjs → store-codec-pAG2oPgQ-SqDXlPBR.mjs} +4 -4
  102. package/dist/store-memory-4IBl0Ztu.mjs +2 -0
  103. package/dist/{store-memory-Bas9Uq5C.mjs → store-memory-B0PkVSuQ.mjs} +14 -7
  104. package/dist/{store-polling-BZt2Rf3D-BiEM6_HB.mjs → store-polling-BZt2Rf3D-Div-bJ_D.mjs} +2 -2
  105. package/dist/{store-presence-polling-JuAy2Ck2-CYpuK75X.mjs → store-presence-polling-JuAy2Ck2-Dh-g_KYM.mjs} +3 -3
  106. package/dist/{store-sqlite-CxxPCcCT.mjs → store-sqlite-D85t3tE7.mjs} +14 -7
  107. package/dist/store-sqlite-iBTYYKBj.mjs +2 -0
  108. package/dist/{stream-buffer-DrTyiIuo-BKy-vI14.mjs → stream-buffer-DrTyiIuo-Be10yOBR.mjs} +2 -2
  109. package/dist/sync-github.d.mts +4 -2
  110. package/dist/sync-github.mjs +4 -14
  111. package/dist/sync-kapso.d.mts +3 -2
  112. package/dist/sync-kapso.mjs +3 -3
  113. package/dist/sync-linear.d.mts +12 -2
  114. package/dist/sync-linear.mjs +191 -9
  115. package/dist/sync-slack.d.mts +41 -5
  116. package/dist/sync-slack.mjs +70 -11
  117. package/dist/sync.chat.d.mts +1 -1
  118. package/dist/sync.chat.mjs +1 -1
  119. package/dist/sync.d.mts +2 -2
  120. package/dist/sync.mjs +3 -3
  121. package/dist/tags-BpFo6q0_.mjs +14 -0
  122. package/dist/{targets-C-wdR5wP.mjs → targets-Cgjs29T0.mjs} +1 -1
  123. package/dist/{tokens-BuCU-61R.mjs → tokens-C5_yGRlL.mjs} +2 -2
  124. package/dist/{types-aCAf6lGU.d.mts → types-shZmGZ-q.d.mts} +2 -2
  125. package/dist/{watchers-hash-7DJU9b-n.mjs → watchers-hash-B_5b0-ZG.mjs} +50 -19
  126. package/docs/01-board/04-messages.md +93 -0
  127. package/docs/01-board/08-middleware.md +5 -2
  128. package/docs/02-sync/03-slack.md +6 -2
  129. package/docs/02-sync/07-linear.md +28 -6
  130. package/docs/04-reference/01-cli.md +27 -12
  131. package/docs/04-reference/02-board.md +20 -5
  132. package/docs/04-reference/03-http.md +17 -6
  133. package/docs/04-reference/05-conformance.md +14 -3
  134. package/package.json +2 -2
  135. package/dist/errors-CusN4x2l.mjs +0 -88
  136. package/dist/exports-D22M68rk.d.mts +0 -5
  137. package/dist/store-N8PXxDAS-BWBgzBHp.mjs +0 -5
  138. package/dist/store-memory-ZLiZbH3R.mjs +0 -2
  139. package/dist/store-sqlite-BmwL3pUR.mjs +0 -2
@@ -1,32 +1,31 @@
1
- import { n as DIRECT_UPLOAD_BYTES_MAX, r as IMPORT_BYTES_MAX, t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
2
- import { _ as RemoteWatcherId, b as actorKind, i as Attachment, x as platformAvatar } from "./schemas-Be08nyfW.mjs";
3
- import "./external-BV_cUncs.mjs";
4
- import { a as METHODS, d as VERSION, f as SYNC, n as compileWatcher, r as hasCriteria, t as canonicalWatchers } from "./watchers-hash-7DJU9b-n.mjs";
5
- import { r as duration, t as BoardError } from "./client-D0Px7KzF.mjs";
1
+ import { r as IMPORT_BYTES_MAX, t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
2
+ import { _ as RemoteWatcherId, b as actorKind, x as platformAvatar } from "./schemas-DBmUO6pr.mjs";
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-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-lTkOgvcE.mjs";
11
- import { a as resolveMentions, n as actorSeen } from "./actors-BLXVp4oZ.mjs";
12
- import { n as MIDDLEWARE_ACTOR, t as DroppedError } from "./middleware-ChKM1zuu.mjs";
13
- import { n as syncTarget, r as withEmailMentions } from "./targets-C-wdR5wP.mjs";
14
- import { t as A2Error } from "./session-clear-wHd5GCpZ-BnKSxReK.mjs";
15
- import { a as reducer } from "./reducer-CjmvZSAb-D_OSISmj.mjs";
16
- import { _ as BOARD_SESSION, a as IDENTITY_SESSION, c as SCHEDULES_SESSION, d as inboxContract, f as inboxSession, g as watchSession, h as watchContract, l as boardContract, m as schedulesContract, n as channelsReducer, o as INBOXES_SESSION, p as registryContract, r as identityCodesReducer, s as REGISTRY_SESSION, t as actorsReducer, u as identityContract } from "./reducers-BcmSs4qf.mjs";
17
- import { n as createServer, t as consumer } from "./server-DtFrWxi3-Boa6gdWO.mjs";
18
- import { a as linkedTo, i as iso, n as foldMessages, o as personOf, t as foldActors } from "./folds-Dyjput8M.mjs";
19
- import { t as concernedMessageId } from "./project-DMDST9kl.mjs";
20
- import { t as TABLES_CONSUMER } from "./sql-U9WTxrwJ.mjs";
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
+ import { n as syncTarget, r as withEmailMentions } from "./targets-Cgjs29T0.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";
21
22
  import { t as afterRetry } from "./attempts-HXcskN1w.mjs";
22
23
  import { createHash, randomBytes } from "node:crypto";
23
- import { mkdir, readFile, writeFile } from "node:fs/promises";
24
- import { dirname, join } from "node:path";
25
24
  import { AsyncLocalStorage } from "node:async_hooks";
26
25
  import { lookup } from "node:dns";
27
26
  import { request } from "node:https";
28
27
  import { BlockList, isIP } from "node:net";
29
- //#region ../../node_modules/.pnpm/experimental-a2@0.22.0_@upstash+qstash@2.12.0_@vercel+queue@0.7.0_ai@7.0.130_zod@4.6.5__52060f00b21aecfab049c27fa3abd2f9/node_modules/experimental-a2/dist/idempotent-id-BRJVeylj.js
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
30
29
  async function idempotentId(...parts) {
31
30
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(parts)));
32
31
  const bytes = new Uint8Array(digest).slice(0, 16);
@@ -36,181 +35,6 @@ async function idempotentId(...parts) {
36
35
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
37
36
  }
38
37
  //#endregion
39
- //#region src/server/blob.ts
40
- /** Files in memory, for tests: forgets everything when the process ends. */
41
- function memoryBlob() {
42
- const files = /* @__PURE__ */ new Map();
43
- return {
44
- put: async (key, data, contentType) => void files.set(key, {
45
- data: data.slice(),
46
- contentType
47
- }),
48
- get: async (key) => files.get(key) ?? null
49
- };
50
- }
51
- /** Files on disk, for development and `vb serve`: under `dir`, `.a2/attachments` by default. */
52
- function fsBlob(dir = ".a2/attachments") {
53
- const path = (key) => join(dir, ...key.split("/"));
54
- return {
55
- async put(key, data, contentType) {
56
- await mkdir(dirname(path(key)), { recursive: true });
57
- await writeFile(path(key), data);
58
- await writeFile(`${path(key)}.type`, contentType);
59
- },
60
- async get(key) {
61
- try {
62
- const [data, contentType] = await Promise.all([readFile(path(key)), readFile(`${path(key)}.type`, "utf8")]);
63
- return {
64
- data: new Uint8Array(data),
65
- contentType
66
- };
67
- } catch {
68
- return null;
69
- }
70
- }
71
- };
72
- }
73
- const dataKey = (id) => `board/attachments/${id}`;
74
- /** A direct upload the board signed, until it's finished: what it's called, and who may finish it. */
75
- const ticketKey = (id) => `board/uploads/${id}.json`;
76
- const metaKey = (id) => `board/attachments/${id}.json`;
77
- /** `shot.png`, as the board stores a name: no paths, no control characters. */
78
- function fileName(name) {
79
- return (name ?? "").split(/[\\/]/).pop().replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 255) || "file";
80
- }
81
- /** `image/png`, or `application/octet-stream` when the type isn't one. */
82
- function mediaType(type) {
83
- const value = (type ?? "").split(";")[0].trim().toLowerCase();
84
- return /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(value) && value.length <= 255 ? value : "application/octet-stream";
85
- }
86
- /** A board without a blob store keeps no files: every file route says so. */
87
- const noBlob = () => new HttpError(501, "no_files", "this board keeps no files: pass `blob` to `a2()`");
88
- /** 128 random bits, base64url, drawn again when they'd start with `-`, which a command line reads as a flag. */
89
- function attachmentId() {
90
- for (;;) {
91
- const id = randomBytes(16).toString("base64url");
92
- if (!id.startsWith("-")) return id;
93
- }
94
- }
95
- /**
96
- * Stores an upload under a fresh ID, with its metadata beside it. `uploadedBy`
97
- * is the principal that sent it, who can read it before any message carries it.
98
- * `max` is the most it can be: `ATTACHMENT_BYTES_MAX` unless the board's own sync imports it.
99
- */
100
- async function writeUpload(blob, input, max = ATTACHMENT_BYTES_MAX) {
101
- if (!blob) throw noBlob();
102
- if (input.data.byteLength === 0) throw badRequest("the file is empty");
103
- if (input.data.byteLength > max) throw new HttpError(413, "too_large", `attachments are at most ${max / 1024 / 1024} MB`);
104
- const attachment = {
105
- id: attachmentId(),
106
- name: fileName(input.name),
107
- type: mediaType(input.type),
108
- size: input.data.byteLength
109
- };
110
- await blob.put(dataKey(attachment.id), input.data, attachment.type);
111
- const meta = input.uploadedBy === void 0 ? attachment : {
112
- ...attachment,
113
- uploadedBy: input.uploadedBy
114
- };
115
- await blob.put(metaKey(attachment.id), new TextEncoder().encode(JSON.stringify(meta)), "application/json");
116
- return attachment;
117
- }
118
- async function bytes(data) {
119
- return data instanceof Uint8Array ? data : new Uint8Array(await new Response(data).arrayBuffer());
120
- }
121
- const attachmentNotFound = (id) => notFound(`no attachment: ${id}`, "attachment_not_found");
122
- /**
123
- * An upload's metadata, as the board recorded it, never what a request says,
124
- * and who uploaded it: null for files the board imported itself, or stored
125
- * before it recorded that.
126
- */
127
- async function readUpload(blob, id) {
128
- if (!blob) throw noBlob();
129
- const found = Attachment.shape.id.safeParse(id).success ? await blob.get(metaKey(id)) : null;
130
- if (!found) throw attachmentNotFound(id);
131
- const meta = JSON.parse(new TextDecoder().decode(await bytes(found.data)));
132
- return {
133
- attachment: Attachment.parse(meta),
134
- uploadedBy: typeof meta.uploadedBy === "string" ? meta.uploadedBy : null
135
- };
136
- }
137
- /** An attachment's bytes as the store keeps them, a stream or the bytes, for board() to serve. */
138
- async function attachmentBody(blob, id) {
139
- if (!blob) throw noBlob();
140
- const found = await blob.get(dataKey(id));
141
- if (!found) throw attachmentNotFound(id);
142
- return found.data;
143
- }
144
- /** How long a direct upload's URL takes bytes: long enough for 100 MB on a slow link. */
145
- const DIRECT_UPLOAD_TTL_MS = 18e5;
146
- const noDirectUploads = () => new HttpError(501, "no_direct_uploads", `this board's blob store can't take direct uploads, so files are at most ${ATTACHMENT_BYTES_MAX / 1024 / 1024} MB`);
147
- /**
148
- * Starts a direct upload: a fresh ID, and a URL in the store to `PUT` its bytes to. The board
149
- * keeps what the request said the file is until `finishUpload` checks the store has it.
150
- */
151
- async function startUpload(blob, input) {
152
- if (!blob) throw noBlob();
153
- if (!blob.uploadUrl || !blob.head) throw noDirectUploads();
154
- if (input.size > 104857600) throw new HttpError(413, "too_large", `direct uploads are at most ${DIRECT_UPLOAD_BYTES_MAX / 1024 / 1024} MB`);
155
- const expires = new Date(Date.now() + DIRECT_UPLOAD_TTL_MS);
156
- const ticket = {
157
- id: attachmentId(),
158
- name: fileName(input.name),
159
- type: mediaType(input.type),
160
- size: input.size,
161
- uploadedBy: input.uploadedBy,
162
- expiresAt: expires.toISOString()
163
- };
164
- await blob.put(ticketKey(ticket.id), new TextEncoder().encode(JSON.stringify(ticket)), "application/json");
165
- const { url, headers } = await blob.uploadUrl(dataKey(ticket.id), {
166
- contentType: ticket.type,
167
- maxBytes: ticket.size,
168
- expiresAt: expires
169
- });
170
- return {
171
- id: ticket.id,
172
- url,
173
- headers: {
174
- ...headers,
175
- "content-type": ticket.type
176
- },
177
- expiresAt: ticket.expiresAt
178
- };
179
- }
180
- /**
181
- * Finishes a direct upload: once the store has exactly the bytes its start said, the board records
182
- * the attachment, as `writeUpload` does. Only its uploader may finish it; a second finish answers
183
- * the same attachment. It never reads the record before writing it: a store with a cache (Vercel
184
- * Blob's CDN) would keep that miss, and the post that names the file would find nothing.
185
- */
186
- async function finishUpload(blob, id, principal) {
187
- if (!blob) throw noBlob();
188
- const found = Attachment.shape.id.safeParse(id).success ? await blob.get(ticketKey(id)) : null;
189
- const ticket = found ? JSON.parse(new TextDecoder().decode(await bytes(found.data))) : null;
190
- if (!ticket || ticket.uploadedBy !== principal) throw notFound(`no upload: ${id}`, "upload_not_found");
191
- if (!blob.head) throw noDirectUploads();
192
- const stored = await blob.head(dataKey(id));
193
- if (!stored) throw new HttpError(409, "not_uploaded", `nothing's been uploaded for ${id} yet: PUT the file to its URL first`);
194
- if (stored.size !== ticket.size) throw badRequest(`the store has ${stored.size} bytes for ${id}, but its upload said ${ticket.size}`, "size_mismatch");
195
- const attachment = {
196
- id,
197
- name: ticket.name,
198
- type: ticket.type,
199
- size: stored.size
200
- };
201
- try {
202
- await blob.put(metaKey(id), new TextEncoder().encode(JSON.stringify({
203
- ...attachment,
204
- uploadedBy: principal
205
- })), "application/json");
206
- } catch (error) {
207
- const done = await blob.get(metaKey(id)).catch(() => null);
208
- if (!done) throw error;
209
- return Attachment.parse(JSON.parse(new TextDecoder().decode(await bytes(done.data))));
210
- }
211
- return attachment;
212
- }
213
- //#endregion
214
38
  //#region src/server/feed.ts
215
39
  /** Bookkeeping events, shown only with `all`: who's who, and delivery receipts. */
216
40
  const HIDDEN = /* @__PURE__ */ new Set([
@@ -301,11 +125,13 @@ function viewedBy(view, reader) {
301
125
  }
302
126
  /** A message's or a cursor's ID as clients see it. On this board it's the log index, in decimal. */
303
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}`;
304
130
  /** The log index a ref names on this board, or null for a string that names nothing here. */
305
131
  const indexOf = (value) => /^(0|[1-9]\d{0,15})$/.test(value) ? Number(value) : null;
306
132
  /** A message as `reader` sees it. Every view names its reader, so none shows a link's snapshot by default. */
307
133
  function view(message, actors, reader) {
308
- const { blind, version, schedule, ...shown } = message;
134
+ const { blind, version, revision, schedule, ...shown } = message;
309
135
  return {
310
136
  ...shown,
311
137
  ...blind ? { blind } : {},
@@ -313,8 +139,9 @@ function view(message, actors, reader) {
313
139
  re: message.re === null ? null : ref(message.re),
314
140
  replyTo: message.replyTo === null ? null : ref(message.replyTo),
315
141
  version: version ?? message.id,
142
+ revision: revision ?? message.id,
316
143
  ...schedule ? { schedule: {
317
- id: ref(schedule.id),
144
+ id: scheduleRef(schedule.id),
318
145
  n: schedule.n
319
146
  } } : {},
320
147
  links: readableLinks(message.links, reader),
@@ -501,7 +328,7 @@ function payloadView(payload, links) {
501
328
  ]) if (typeof shown[key] === "number") shown[key] = ref(shown[key]);
502
329
  const schedule = shown.schedule;
503
330
  if (schedule) shown.schedule = {
504
- id: ref(schedule.id),
331
+ id: scheduleRef(schedule.id),
505
332
  n: schedule.n
506
333
  };
507
334
  if (links) shown.links = links;
@@ -926,19 +753,20 @@ async function writePost(ctx, input) {
926
753
  if (input.sync?.length) throw badRequest("sync attaches whole threads: use `board sync <id> <target>`");
927
754
  } else {
928
755
  if (!channel) throw badRequest("a post needs a channel");
756
+ if (input.after !== void 0) throw badRequest("`after` is for replies", "invalid_after");
929
757
  thread = id;
930
758
  }
931
759
  spend(input.proofs.post, "post", { channel });
932
760
  if (hasLabels(input.labels)) spend(input.proofs.label, "label", { channel });
933
761
  if (input.sync?.length) spend(input.proofs.sync, "sync", { channel });
934
- if (input.id !== void 0) {
935
- const landed = await ctx.reads.byEventId(input.id);
936
- if (landed) {
937
- if (landed.channel !== channel || landed.re !== re || landed.replyTo !== replyTo) throw duplicateId();
938
- const shown = await landedFor(ctx, identity, landed);
939
- if (!shown) throw duplicateId();
940
- return shownTo(shown, identity.principal.access, sent);
941
- }
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");
942
770
  }
943
771
  return commitPost(ctx, input, {
944
772
  channel,
@@ -949,6 +777,16 @@ async function writePost(ctx, input) {
949
777
  sent
950
778
  });
951
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
+ }
952
790
  /**
953
791
  * A post that landed, as its author may have it back: only the same author's, and one in a
954
792
  * channel it can't read only if it posted without reading too. A caller that can't read gets back
@@ -1009,28 +847,45 @@ async function commitPost(ctx, input, placed) {
1009
847
  by: identity.author
1010
848
  }
1011
849
  })));
1012
- const rows = await ctx.session.append(...seen ? [seen] : [], ...named, {
1013
- type: "message.posted",
1014
- ...id ? { id } : {},
1015
- 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, {
1016
882
  channel,
1017
- text,
1018
883
  re,
1019
- replyTo,
1020
- thread,
1021
- labels: input.labels ?? {},
1022
- noSync: input.noSync ?? false,
1023
- mentions,
1024
- by: identity.author,
1025
- origin: input.origin ?? null,
1026
- from: input.from ?? null,
1027
- ...input.attachments?.length ? { attachments: input.attachments } : {},
1028
- ...input.links?.length ? { links: input.links } : {},
1029
- ...input.schedule ? { schedule: input.schedule } : {},
1030
- ...identity.principal.access.readable(channel) ? {} : { blind: true },
1031
- ...input.importedBy !== void 0 ? { importedBy: input.importedBy } : {}
1032
- }
1033
- }, ...synced);
884
+ replyTo
885
+ }, sent) : null;
886
+ if (replayed) return replayed;
887
+ throw error;
888
+ }
1034
889
  const posted = rows.find((row) => row.type === "message.posted");
1035
890
  await untilApplied(ctx, rows);
1036
891
  const message = foldMessages({
@@ -1099,6 +954,36 @@ async function untilApplied(ctx, rows) {
1099
954
  }
1100
955
  /** How long a write waits for the tables before it answers anyway. */
1101
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
+ };
1102
987
  /** The message a by-ID write targets, as the board looked it up, once its proof is spent on the message's channel. */
1103
988
  function spent(authorized, permission) {
1104
989
  spend(authorized.proof, permission, authorized.message);
@@ -1107,7 +992,10 @@ function spent(authorized, permission) {
1107
992
  async function writeEdit(ctx, input) {
1108
993
  const message = spent(input.authorized, "post");
1109
994
  if (input.asAuthor !== false && message.by.principal !== input.identity.principal.id) throw forbidden("only the author can edit a message");
1110
- 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
+ }
1111
999
  return commitEdit(ctx, input, message);
1112
1000
  }
1113
1001
  async function commitEdit(ctx, input, message) {
@@ -1116,17 +1004,22 @@ async function commitEdit(ctx, input, message) {
1116
1004
  const { text, found } = await withEmailMentions(input.text);
1117
1005
  const named = found.map((actor) => actorSeen(actor, actors)).filter((event) => event !== null);
1118
1006
  const { mentions, unknown } = resolveMentions(text, knownWith(actors, found), seen ? input.identity.actor : void 0, input.from != null, input.identity.principal.access.readsAll);
1119
- await untilApplied(ctx, await ctx.session.append(...seen ? [seen] : [], ...named, {
1120
- type: "message.edited",
1121
- payload: {
1122
- id: message.id,
1123
- thread: message.thread,
1124
- text,
1125
- mentions,
1126
- by: input.identity.author,
1127
- 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
+ }
1128
1020
  }
1129
- }));
1021
+ ];
1022
+ await untilApplied(ctx, input.revision === void 0 ? await ctx.session.append(...events) : await appendWhile(ctx, revisionIs(ctx, message.id, input.revision), events));
1130
1023
  return unknown.length && input.identity.principal.access.readable(message.channel) ? { unknownMentions: unknown } : {};
1131
1024
  }
1132
1025
  /** A message's links, replaced: a platform edit or unfurl changed what it links to. */
@@ -1147,8 +1040,11 @@ async function writeLinks(ctx, input) {
1147
1040
  async function writeDelete(ctx, input) {
1148
1041
  const message = spent(input.authorized, "post");
1149
1042
  if (input.asAuthor !== false && message.by.principal !== input.identity.principal.id) throw forbidden("only the author can delete a message");
1150
- if (message.deleted) return;
1151
- 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 = [{
1152
1048
  type: "message.deleted",
1153
1049
  payload: {
1154
1050
  id: message.id,
@@ -1156,8 +1052,17 @@ async function writeDelete(ctx, input) {
1156
1052
  by: input.identity.author,
1157
1053
  from: input.from ?? null
1158
1054
  }
1159
- }));
1160
- }
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
+ };
1161
1066
  async function writeReaction(ctx, input) {
1162
1067
  const message = spent(input.authorized, "react");
1163
1068
  if (message.deleted) throw badRequest("message was deleted", "message_deleted");
@@ -1179,7 +1084,7 @@ async function writeLabels(ctx, input) {
1179
1084
  if (message.deleted) throw badRequest("message was deleted", "message_deleted");
1180
1085
  const { state: actors } = await ctx.session.state(actorsReducer);
1181
1086
  const seen = seenFor(input.identity, actors);
1182
- await untilApplied(ctx, await ctx.session.append(...seen ? [seen] : [], {
1087
+ const events = [...seen ? [seen] : [], {
1183
1088
  type: "labels.changed",
1184
1089
  payload: {
1185
1090
  id: message.id,
@@ -1188,8 +1093,21 @@ async function writeLabels(ctx, input) {
1188
1093
  unset: input.unset ?? [],
1189
1094
  by: input.identity.author
1190
1095
  }
1191
- }));
1192
- }
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
+ };
1193
1111
  /**
1194
1112
  * Syncs a message's thread with a target, resolved by the sync that delivers it. Attaching twice
1195
1113
  * is a no-op; a platform thread another board thread is synced with is a 409.
@@ -1213,6 +1131,10 @@ async function writeAttach(ctx, input) {
1213
1131
  }));
1214
1132
  return { attached: true };
1215
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";
1216
1138
  //#endregion
1217
1139
  //#region src/server/schedules.ts
1218
1140
  /** Vercel Queues delays at most 6 days: a send further out than this goes in hops. */
@@ -1313,7 +1235,7 @@ const createdBy = (schedule, principal) => schedule.as.principal.id === principa
1313
1235
  const scheduleView = (schedule, actors) => {
1314
1236
  const author = actors.byId[schedule.as.actor.id] ?? schedule.as.actor;
1315
1237
  return {
1316
- id: ref(schedule.id),
1238
+ id: scheduleRef(schedule.id),
1317
1239
  channel: schedule.channel,
1318
1240
  re: schedule.re === null ? null : ref(schedule.re),
1319
1241
  text: schedule.text,
@@ -2525,9 +2447,17 @@ function messageAt(value) {
2525
2447
  if (index === null || index === 0) throw notFound(`message not found: #${value}`, "message_not_found");
2526
2448
  return index;
2527
2449
  }
2528
- /** 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
+ */
2529
2459
  function scheduleAt(value) {
2530
- const bare = value.replace(/^s/, "");
2460
+ const bare = value.replace(/^s{1,2}/, "");
2531
2461
  const index = indexOf(bare);
2532
2462
  if (index === null || index === 0) throw notFound(`schedule not found: s${bare}`, "schedule_not_found");
2533
2463
  return index;
@@ -2898,6 +2828,7 @@ function build(options, binding) {
2898
2828
  noSync: body.noSync,
2899
2829
  id: body.id,
2900
2830
  sync: body.sync,
2831
+ after: afterAt(body.after),
2901
2832
  ...schedule && !fromRequest(caller) ? {
2902
2833
  schedule,
2903
2834
  lenientMentions: true
@@ -3056,25 +2987,27 @@ function build(options, binding) {
3056
2987
  async post(caller, parsed) {
3057
2988
  return postNow(caller, await identityOf(caller), parsed);
3058
2989
  },
3059
- async edit(caller, { id, text }) {
2990
+ async edit(caller, { id, text, revision }) {
3060
2991
  const identity = await identityOf(caller);
3061
2992
  const authorized = await authorizeOwn(writes, identity.principal, messageAt(id), "edit");
3062
2993
  const { unknownMentions } = await writeEdit(writes, {
3063
2994
  identity,
3064
2995
  authorized,
3065
- text
2996
+ text,
2997
+ revision
3066
2998
  });
3067
2999
  return {
3068
3000
  ok: true,
3069
3001
  ...unknownMentions ? { unknownMentions } : {}
3070
3002
  };
3071
3003
  },
3072
- async delete(caller, { id }) {
3004
+ async delete(caller, { id, revision }) {
3073
3005
  const identity = await identityOf(caller);
3074
3006
  const authorized = await authorizeOwn(writes, identity.principal, messageAt(id), "delete");
3075
3007
  await writeDelete(writes, {
3076
3008
  identity,
3077
- authorized
3009
+ authorized,
3010
+ revision
3078
3011
  });
3079
3012
  return { ok: true };
3080
3013
  },
@@ -3089,14 +3022,15 @@ function build(options, binding) {
3089
3022
  });
3090
3023
  return { ok: true };
3091
3024
  },
3092
- async label(caller, { id, set, unset }) {
3025
+ async label(caller, { id, set, unset, if: expected }) {
3093
3026
  const identity = await identityOf(caller);
3094
3027
  const authorized = await authorizeMessage(writes, identity.principal.access, "label", messageAt(id));
3095
3028
  await writeLabels(writes, {
3096
3029
  identity,
3097
3030
  authorized,
3098
3031
  set,
3099
- unset
3032
+ unset,
3033
+ if: expected
3100
3034
  });
3101
3035
  return { ok: true };
3102
3036
  },
@@ -3264,11 +3198,11 @@ function build(options, binding) {
3264
3198
  const { message, replies } = await methods.message(caller, { id }, signal);
3265
3199
  return { targets: await binding.typing([message, ...replies], status) };
3266
3200
  },
3267
- async readSource(caller, { key, target, limit }) {
3201
+ async readSource(caller, { key, target, limit, after }) {
3268
3202
  if (!binding.sync.some((entry) => entry.key === key)) throw notFound(`this board doesn't run sync entry ${key}: read it where its sync runs`, "not_synced_here");
3269
3203
  const { principal } = await identityOf(caller);
3270
3204
  if (!principal.access.everywhere("manage")) throw forbidden("this token can't read a source: it needs manage in every channel");
3271
- return binding.readSource(key, target, limit ?? 50);
3205
+ return binding.readSource(key, target, limit ?? 50, after ?? null);
3272
3206
  },
3273
3207
  async joinSource(caller, { key, target }) {
3274
3208
  if (!binding.sync.some((entry) => entry.key === key)) throw notFound(`this board doesn't run sync entry ${key}: join it where its sync runs`, "not_synced_here");
@@ -3370,6 +3304,7 @@ function build(options, binding) {
3370
3304
  origin: body.origin,
3371
3305
  from: body.from,
3372
3306
  ...body.origin && !ownSync(caller) ? { importedBy: principal.id } : {},
3307
+ after: afterAt(body.after),
3373
3308
  lenientMentions: true,
3374
3309
  attachments,
3375
3310
  links: await linksOf(principal, body.links)
@@ -3398,7 +3333,8 @@ function build(options, binding) {
3398
3333
  authorized: proof,
3399
3334
  text,
3400
3335
  from: body.from,
3401
- asAuthor
3336
+ asAuthor,
3337
+ revision: body.revision
3402
3338
  });
3403
3339
  return ref(found.id);
3404
3340
  };
@@ -3416,7 +3352,8 @@ function build(options, binding) {
3416
3352
  identity: author,
3417
3353
  authorized: await authorized("post"),
3418
3354
  from: body.from,
3419
- asAuthor
3355
+ asAuthor,
3356
+ revision: body.revision
3420
3357
  });
3421
3358
  else if (body.op === "react") await writeReaction(writes, {
3422
3359
  identity: author,
@@ -3429,7 +3366,8 @@ function build(options, binding) {
3429
3366
  identity: author,
3430
3367
  authorized: await authorized("label"),
3431
3368
  set: body.set,
3432
- unset: body.unset
3369
+ unset: body.unset,
3370
+ if: body.if
3433
3371
  });
3434
3372
  else await writeLinks(writes, {
3435
3373
  identity: author,
@@ -4085,4 +4023,4 @@ function a2(options = {}) {
4085
4023
  return backend;
4086
4024
  }
4087
4025
  //#endregion
4088
- export { fsBlob as n, memoryBlob as r, a2 as t };
4026
+ export { a2 as t };