@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.
- package/dist/{a2-B0_VlbtC.mjs → a2-D_28Pdip.mjs} +197 -259
- package/dist/access-CQ39gqEV.mjs +226 -0
- package/dist/access.d.mts +1 -1
- package/dist/access.mjs +3 -3
- package/dist/{actors-BLXVp4oZ.mjs → actors-xPiamLWk.mjs} +3 -3
- package/dist/{auth-lTkOgvcE.mjs → auth-BPinwfN0.mjs} +3 -226
- package/dist/backend-a2/blob-vercel.d.mts +1 -1
- package/dist/backend-a2/contract.d.mts +2 -2
- package/dist/backend-a2/contract.mjs +3 -3
- package/dist/backend-a2/scheduler-local.d.mts +1 -1
- package/dist/backend-a2/scheduler-local.mjs +1 -1
- package/dist/backend-a2/scheduler-qstash.d.mts +3 -3
- package/dist/backend-a2/scheduler-qstash.mjs +8 -8
- package/dist/backend-a2/scheduler-vercel.d.mts +3 -3
- package/dist/backend-a2/scheduler-vercel.mjs +6 -6
- package/dist/backend-a2/store-memory.d.mts +2 -2
- package/dist/backend-a2/store-memory.mjs +3 -3
- package/dist/backend-a2/store-postgres.d.mts +1 -1
- package/dist/backend-a2/store-postgres.mjs +34 -15
- package/dist/backend-a2/store-redis-http.d.mts +1 -1
- package/dist/backend-a2/store-redis-http.mjs +6 -6
- package/dist/backend-a2/store-redis.d.mts +2 -2
- package/dist/backend-a2/store-redis.mjs +5 -5
- package/dist/backend-a2/store-sqlite.d.mts +1 -1
- package/dist/backend-a2/store-sqlite.mjs +3 -3
- package/dist/backend-a2.d.mts +20 -7
- package/dist/backend-a2.mjs +2 -1
- package/dist/{backend-http-BvNFrvcY.mjs → backend-http-16SbvnAK.mjs} +2 -2
- package/dist/{backend-http-E7YU0qhn.d.mts → backend-http-3PoBYQoo.d.mts} +15 -3
- package/dist/{backend-http-CfLVYqmj.mjs → backend-http-BPaNYLWj.mjs} +4 -5
- package/dist/backend-http.d.mts +1 -1
- package/dist/backend-http.mjs +1 -1
- package/dist/backend-http.node.d.mts +1 -1
- package/dist/backend-http.node.mjs +1 -1
- package/dist/{backend-ZKYTe3xa.d.mts → backend-n3hajfrD.d.mts} +12 -6
- package/dist/{backend-vercel-D87pIp2k.mjs → backend-vercel-C4Kee2-K.mjs} +260 -30
- package/dist/{backend-vercel-DTrDiSk5.mjs → backend-vercel-Kd8nvW5A.mjs} +3 -3
- package/dist/{backend-vercel-CIRObA0t.d.mts → backend-vercel-qznuDxnN.d.mts} +20 -5
- package/dist/backend-vercel.d.mts +1 -1
- package/dist/backend-vercel.mjs +1 -1
- package/dist/backend-vercel.node.d.mts +1 -1
- package/dist/backend-vercel.node.mjs +1 -1
- package/dist/{blob-B-9AvYis.d.mts → blob-CqILvdmr.d.mts} +2 -2
- package/dist/blob-DQK5prXT.mjs +182 -0
- package/dist/{chat-provider-DnHibqnM.mjs → chat-provider-COtsU5jB.mjs} +2 -2
- package/dist/{chat-provider-DsNEBQiA.d.mts → chat-provider-D6jVT4wS.d.mts} +5 -3
- package/dist/cli-commands.d.mts +1 -1
- package/dist/cli-commands.mjs +1 -1
- package/dist/cli-embedded.d.mts +2 -2
- package/dist/cli-embedded.mjs +1 -1
- package/dist/cli.mjs +5 -5
- package/dist/{client-xLxeDz0f.d.mts → client-CjAatpV4.d.mts} +67 -10
- package/dist/{commands-BFookFtn.mjs → commands-B5DHjZKK.mjs} +54 -10
- package/dist/{commands-D6gvrOh3.d.mts → commands-BtH69yqt.d.mts} +17 -0
- package/dist/{conformance-DlnfuzEB.mjs → conformance-C7fwIvmN.mjs} +258 -18
- package/dist/conformance.d.mts +1 -1
- package/dist/conformance.mjs +1 -1
- package/dist/{engine-Bfe6pcXb.mjs → engine-DkdkXQEy.mjs} +21 -13
- package/dist/{errors-CB4tnZGz.d.mts → errors-87TVtXeb.d.mts} +1 -1
- package/dist/{client-D0Px7KzF.mjs → errors-C_5rB8Iz.mjs} +104 -4
- package/dist/{events-CewD4kw7.mjs → events-qf5XG5VQ.mjs} +2 -2
- package/dist/{exports-CCzw2Vdw.mjs → exports-CG0P8Qi4.mjs} +59 -33
- package/dist/exports-CrGA5_yN.d.mts +5 -0
- package/dist/{external-BV_cUncs.mjs → external-D3p5T57R.mjs} +4 -2
- package/dist/{folds-jS9wlh9_.d.mts → folds-8wRRXjAt.d.mts} +4 -0
- package/dist/{folds-Dyjput8M.mjs → folds-DGYkMW19.mjs} +3 -2
- package/dist/{index-C05it0mC.d.mts → index-BygAXP7t.d.mts} +2 -2
- package/dist/index.d.mts +6 -6
- package/dist/{index.default-DmKxH_E6.mjs → index.default-DhkgpfR2.mjs} +1 -1
- package/dist/index.mjs +5 -6
- package/dist/index.node.d.mts +6 -6
- package/dist/index.node.mjs +3 -4
- package/dist/inspect/app.js +29 -29
- package/dist/inspect/engine.js +5 -5
- package/dist/{internal-CvsrprnU-CRY89I-b.mjs → internal-CvsrprnU-zZMpDiRY.mjs} +1 -1
- package/dist/{local-DhkrAqPP.mjs → local-C76Fy5ad.mjs} +1 -1
- package/dist/{main-eci7w-aP.mjs → main-D2s-QBOK.mjs} +76 -26
- package/dist/{middleware-ChKM1zuu.mjs → middleware-DlJQYiwV.mjs} +2 -2
- package/dist/{node-Zd_C-gxP.mjs → node-D8Rtg1su.mjs} +1 -1
- package/dist/{platform-B4TnJtWu-CwcTyKg2.mjs → platform-B4TnJtWu-Do3agKr5.mjs} +1 -1
- package/dist/{project-DMDST9kl.mjs → project-C_2xs57L.mjs} +1 -1
- package/dist/{provider-poIkXapd.mjs → provider-BaVmi4Hi.mjs} +3 -1
- package/dist/{provider-Cdg1qadh.d.mts → provider-Bm46LAdp.d.mts} +9 -2
- package/dist/{redis-tables-DN9PfIDi.mjs → redis-tables-BWJZHUGg.mjs} +41 -25
- package/dist/{reducer-CjmvZSAb-D_OSISmj.mjs → reducer-Bswr11ar-P324o9vn.mjs} +2 -2
- package/dist/{reducer-CYPHsSmd-BQIfozGi.d.mts → reducer-CYPHsSmd-BXjGiptm.d.mts} +1 -1
- package/dist/{reducers-BcmSs4qf.mjs → reducers-8jqLewgw.mjs} +5 -5
- package/dist/{retryable-lazy-DZWmHpii-Bye-W8q7.mjs → retryable-lazy-DZWmHpii-DpTc5PIP.mjs} +1 -1
- package/dist/{scheduler-task-X1SToDjP-BVoWuInI.mjs → scheduler-task-X1SToDjP-D2Il2rln.mjs} +1 -1
- package/dist/{schemas-Be08nyfW.mjs → schemas-DBmUO6pr.mjs} +2 -1
- package/dist/{serve-B09an9DQ.mjs → serve-lPoBDEds.mjs} +7 -6
- package/dist/{server-qgez0Xny-D5mLfqFQ.d.mts → server-B9gu0qi1-DI1WvcVK.d.mts} +10 -4
- package/dist/{server-DTX0prGr.mjs → server-D82a4uMe.mjs} +4 -4
- package/dist/{server-DtFrWxi3-Boa6gdWO.mjs → server-DX7mM6nm-D7N_7bug.mjs} +32 -23
- package/dist/{serving-CvOWEym2.mjs → serving-DKny38LK.mjs} +1 -1
- package/dist/{session-clear-wHd5GCpZ-BnKSxReK.mjs → session-clear-XJYpmUpg-2q8l_FJR.mjs} +14 -2
- package/dist/{sql-U9WTxrwJ.mjs → sql-Bj0fk6dn.mjs} +2 -2
- package/dist/{sqlite-db-Ds4majtT.mjs → sqlite-db-Dc8G78XV.mjs} +1 -1
- package/dist/{store-D0MhBvg6-4Z2L0Kqd.d.mts → store-DEzcHZOA-B-5vhxef.d.mts} +12 -2
- package/dist/store-N8PXxDAS-DWWgv-Vz.mjs +5 -0
- package/dist/{store-codec-DwrWlgQx-CYxGW0aD.mjs → store-codec-pAG2oPgQ-SqDXlPBR.mjs} +4 -4
- package/dist/store-memory-4IBl0Ztu.mjs +2 -0
- package/dist/{store-memory-Bas9Uq5C.mjs → store-memory-B0PkVSuQ.mjs} +14 -7
- package/dist/{store-polling-BZt2Rf3D-BiEM6_HB.mjs → store-polling-BZt2Rf3D-Div-bJ_D.mjs} +2 -2
- package/dist/{store-presence-polling-JuAy2Ck2-CYpuK75X.mjs → store-presence-polling-JuAy2Ck2-Dh-g_KYM.mjs} +3 -3
- package/dist/{store-sqlite-CxxPCcCT.mjs → store-sqlite-D85t3tE7.mjs} +14 -7
- package/dist/store-sqlite-iBTYYKBj.mjs +2 -0
- package/dist/{stream-buffer-DrTyiIuo-BKy-vI14.mjs → stream-buffer-DrTyiIuo-Be10yOBR.mjs} +2 -2
- package/dist/sync-github.d.mts +4 -2
- package/dist/sync-github.mjs +4 -14
- package/dist/sync-kapso.d.mts +3 -2
- package/dist/sync-kapso.mjs +3 -3
- package/dist/sync-linear.d.mts +12 -2
- package/dist/sync-linear.mjs +191 -9
- package/dist/sync-slack.d.mts +41 -5
- package/dist/sync-slack.mjs +70 -11
- package/dist/sync.chat.d.mts +1 -1
- package/dist/sync.chat.mjs +1 -1
- package/dist/sync.d.mts +2 -2
- package/dist/sync.mjs +3 -3
- package/dist/tags-BpFo6q0_.mjs +14 -0
- package/dist/{targets-C-wdR5wP.mjs → targets-Cgjs29T0.mjs} +1 -1
- package/dist/{tokens-BuCU-61R.mjs → tokens-C5_yGRlL.mjs} +2 -2
- package/dist/{types-aCAf6lGU.d.mts → types-shZmGZ-q.d.mts} +2 -2
- package/dist/{watchers-hash-7DJU9b-n.mjs → watchers-hash-B_5b0-ZG.mjs} +50 -19
- package/docs/01-board/04-messages.md +93 -0
- package/docs/01-board/08-middleware.md +5 -2
- package/docs/02-sync/03-slack.md +6 -2
- package/docs/02-sync/07-linear.md +28 -6
- package/docs/04-reference/01-cli.md +27 -12
- package/docs/04-reference/02-board.md +20 -5
- package/docs/04-reference/03-http.md +17 -6
- package/docs/04-reference/05-conformance.md +14 -3
- package/package.json +2 -2
- package/dist/errors-CusN4x2l.mjs +0 -88
- package/dist/exports-D22M68rk.d.mts +0 -5
- package/dist/store-N8PXxDAS-BWBgzBHp.mjs +0 -5
- package/dist/store-memory-ZLiZbH3R.mjs +0 -2
- 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-
|
|
3
|
-
import { a as Link, i as ExternalTarget, n as ExternalEvent, o as Origin, t as ExternalAuthor } from "./external-
|
|
4
|
-
import { r as badRequest, t as HttpError } from "./errors-
|
|
5
|
-
import { o as options } from "./commands-
|
|
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
|
+
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-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 });
|
|
@@ -172,7 +183,9 @@ const AckBody = z.object({
|
|
|
172
183
|
*/
|
|
173
184
|
const SourceReadQuery = z.object({
|
|
174
185
|
target: z.string().min(1).max(4e3),
|
|
175
|
-
limit: z.coerce.number().int().min(1).max(200).optional()
|
|
186
|
+
limit: z.coerce.number().int().min(1).max(200).optional(),
|
|
187
|
+
/** Where the last read left off: its `next`. */
|
|
188
|
+
after: z.string().min(1).max(400).optional()
|
|
176
189
|
});
|
|
177
190
|
/** A channel to add the board's app to, so it can be read on demand: `#name`, an ID, or a link into it. */
|
|
178
191
|
const SourceJoinBody = z.object({ target: z.string().min(1).max(4e3) });
|
|
@@ -234,19 +247,23 @@ const SyncImportBody = z.discriminatedUnion("op", [
|
|
|
234
247
|
noSync: z.boolean().optional(),
|
|
235
248
|
attachments: z.array(AttachmentId).max(10).optional(),
|
|
236
249
|
links: z.array(ImportLink).max(10).optional(),
|
|
237
|
-
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()
|
|
238
253
|
}),
|
|
239
254
|
z.object({
|
|
240
255
|
op: z.literal("edit"),
|
|
241
256
|
...Imported,
|
|
242
257
|
...Changed,
|
|
243
258
|
text: MessageText,
|
|
244
|
-
source: ImportSource.optional()
|
|
259
|
+
source: ImportSource.optional(),
|
|
260
|
+
revision: Revision.optional()
|
|
245
261
|
}),
|
|
246
262
|
z.object({
|
|
247
263
|
op: z.literal("delete"),
|
|
248
264
|
...Imported,
|
|
249
|
-
...Changed
|
|
265
|
+
...Changed,
|
|
266
|
+
revision: Revision.optional()
|
|
250
267
|
}),
|
|
251
268
|
z.object({
|
|
252
269
|
op: z.literal("react"),
|
|
@@ -260,7 +277,9 @@ const SyncImportBody = z.discriminatedUnion("op", [
|
|
|
260
277
|
...Imported,
|
|
261
278
|
...Changed,
|
|
262
279
|
set: Labels.optional(),
|
|
263
|
-
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()
|
|
264
283
|
}),
|
|
265
284
|
z.object({
|
|
266
285
|
op: z.literal("links"),
|
|
@@ -359,14 +378,21 @@ const SignInQuery = z.object({
|
|
|
359
378
|
code: z.string().min(1).max(64)
|
|
360
379
|
});
|
|
361
380
|
const SyncUnlinkBody = z.object({ account: ActorId });
|
|
362
|
-
/**
|
|
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
|
+
*/
|
|
363
385
|
const UploadStartBody = z.object({
|
|
364
386
|
name: z.string().max(1024).nullable().optional(),
|
|
365
387
|
type: z.string().max(1024).nullable().optional(),
|
|
366
|
-
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()
|
|
367
394
|
});
|
|
368
|
-
const
|
|
369
|
-
const DeleteBody = z.object({}).optional();
|
|
395
|
+
const DeleteBody = z.object({ revision: Revision.optional() }).optional();
|
|
370
396
|
/** `status`: what Slack shows in place of "Typing…" (WhatsApp and GitHub ignore it). */
|
|
371
397
|
const TypingBody = z.object({ status: z.string().trim().min(1).max(100).optional() }).optional();
|
|
372
398
|
const ReactBody = z.object({
|
|
@@ -375,7 +401,12 @@ const ReactBody = z.object({
|
|
|
375
401
|
});
|
|
376
402
|
const LabelBody = z.object({
|
|
377
403
|
set: Labels.optional(),
|
|
378
|
-
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()
|
|
379
410
|
}).refine((body) => body.set !== void 0 || body.unset !== void 0, { message: "nothing to change: pass `set` or `unset`" });
|
|
380
411
|
/** The route table: method, path, and the schemas that validate input. */
|
|
381
412
|
const routes = {
|
|
@@ -662,7 +693,7 @@ const routes = {
|
|
|
662
693
|
const SYNC = "@sync";
|
|
663
694
|
//#endregion
|
|
664
695
|
//#region src/version.ts
|
|
665
|
-
const VERSION = "0.
|
|
696
|
+
const VERSION = "0.30.0";
|
|
666
697
|
//#endregion
|
|
667
698
|
//#region src/server/routing.ts
|
|
668
699
|
/** board()'s own routes: the files it serves, and sync's, which run where board() does. */
|
|
@@ -1018,4 +1049,4 @@ function canonicalWatchers(watch, sync = []) {
|
|
|
1018
1049
|
return JSON.stringify(canonical);
|
|
1019
1050
|
}
|
|
1020
1051
|
//#endregion
|
|
1021
|
-
export {
|
|
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
|
|
package/docs/02-sync/03-slack.md
CHANGED
|
@@ -156,8 +156,12 @@ who can see it there:
|
|
|
156
156
|
📎 [shot.png](https://files.slack.com/…) (7 B) didn't come over: this board keeps no files
|
|
157
157
|
```
|
|
158
158
|
|
|
159
|
-
**To Slack:**
|
|
160
|
-
|
|
159
|
+
**To Slack:** a new thread's root goes out as one message, its files under its text
|
|
160
|
+
(it needs `files:write`, and `files:read` to find the message the upload made;
|
|
161
|
+
without `files:read`, the files follow in a message of their own). A
|
|
162
|
+
file upload can't set a username, so that root goes out as the app, with the
|
|
163
|
+
agent's name in front. A reply's text posts first, then its files upload into the
|
|
164
|
+
same thread as a message of their own. A failed upload retries alone, and
|
|
161
165
|
deleting the message on the board deletes the files in Slack. If Slack refuses
|
|
162
166
|
files, the text is edited to add a line per file:
|
|
163
167
|
|
|
@@ -46,13 +46,16 @@ in the map stays off the board, except threads someone syncs by hand.
|
|
|
46
46
|
## An issue's life in one thread
|
|
47
47
|
|
|
48
48
|
1. **An issue opens.** The board creates a root in the team's channel,
|
|
49
|
-
`ENG-123: <title>` with its link,
|
|
50
|
-
workflow state's type
|
|
49
|
+
`ENG-123: <title>` with its link and, under them, its description. It's
|
|
50
|
+
labeled `issue=ENG-123`, `state` with its workflow state's type, and the
|
|
51
|
+
issue's properties: `priority=high`, `project=<project>`, `tag.<label>` for
|
|
52
|
+
each Linear label, and `assignee.<handle>`.
|
|
51
53
|
2. **Comments** arrive as replies. A reply to a comment stays in that comment's
|
|
52
54
|
thread on Linear, and a board reply to one goes back into it. Edits and
|
|
53
55
|
deletes sync too.
|
|
54
|
-
3. **Moving the issue** to another state updates `state
|
|
55
|
-
|
|
56
|
+
3. **Moving the issue** to another state updates `state`, and changing a
|
|
57
|
+
property updates its label. A new title or description edits a root that
|
|
58
|
+
Linear opened to say it. An issue that was open before the mirror
|
|
56
59
|
gets its root the first time a person edits or comments on it. Linear's own
|
|
57
60
|
automations and archiving don't bring old issues in.
|
|
58
61
|
4. **A root posted on the board** in a mirrored channel opens an issue in that
|
|
@@ -79,8 +82,8 @@ the board follows the team it's in now. The old thread stays as it was.
|
|
|
79
82
|
|
|
80
83
|
| Event | What the mirror does |
|
|
81
84
|
| --- | --- |
|
|
82
|
-
| `issue.created` | A root labeled `issue=ENG-123
|
|
83
|
-
| `issue.updated` | A new state sets `state
|
|
85
|
+
| `issue.created` | A root with the description, labeled `issue=ENG-123`, `state=<type>`, and the issue's properties |
|
|
86
|
+
| `issue.updated` | A new state sets `state`, and properties update their labels; a new title or description edits a root Linear opened. A person's edit to an issue the board hasn't seen opens its root, so older issues come in when they're next touched (a bulk edit brings in a whole view). Automations and archiving don't |
|
|
84
87
|
| `comment` | A reply. Edits and deletes sync too |
|
|
85
88
|
|
|
86
89
|
`state` is the workflow state's type: `triage`, `backlog`, `unstarted`,
|
|
@@ -96,6 +99,25 @@ A comment on an issue that opened before you mapped its team still lands: the
|
|
|
96
99
|
board fetches the issue through Linear's API, creates its root, then posts the
|
|
97
100
|
comment as a reply.
|
|
98
101
|
|
|
102
|
+
## Read on demand
|
|
103
|
+
|
|
104
|
+
`vb sources <key> read` reads Linear without syncing anything, so an agent can
|
|
105
|
+
work through a project's issues without a thread for each one:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
vb sources linear read https://linear.app/acme/project/checkout-a1b2c3d4e5f6 # a project's issues
|
|
109
|
+
vb sources linear read ENG # a team's issues
|
|
110
|
+
vb sources linear read ENG-123 # one issue and its comments
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
A project or team read lists issues newest first, each with its state,
|
|
114
|
+
priority, assignee, project, labels, link, and description (cut at 1,500
|
|
115
|
+
characters). An issue read shows the whole description, then its comments,
|
|
116
|
+
oldest first. A page holds at most 100 issues or comments: when there are more,
|
|
117
|
+
the read ends with the `--after` cursor that reads the next page. It reads with
|
|
118
|
+
the Linear app's token, so it needs `manage` in every channel, as every source
|
|
119
|
+
read does.
|
|
120
|
+
|
|
99
121
|
## Reactions
|
|
100
122
|
|
|
101
123
|
A reaction on the board goes to its comment on Linear, while it's added: Linear
|
|
@@ -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
|
|
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
|
|
@@ -629,7 +638,7 @@ ways. Attaching twice prints `already synced with …`. See
|
|
|
629
638
|
|
|
630
639
|
```text
|
|
631
640
|
vb sources
|
|
632
|
-
vb sources <key> read <channel|thread|link> [--limit N]
|
|
641
|
+
vb sources <key> read <channel|thread|link> [--limit N] [--after <cursor>]
|
|
633
642
|
```
|
|
634
643
|
|
|
635
644
|
- **`sources`** lists the board's sync entries and their platforms.
|
|
@@ -639,7 +648,9 @@ vb sources <key> read <channel|thread|link> [--limit N]
|
|
|
639
648
|
what it can, so this needs `manage` in every channel. `--limit` is 50 by
|
|
640
649
|
default, at most 200. A board that doesn't run the entry says so:
|
|
641
650
|
`error: this board doesn't run sync entry slack: read it where its sync runs`.
|
|
642
|
-
|
|
651
|
+
A platform that pages past `--limit` prints where to pick up, for `--after`.
|
|
652
|
+
See [Read on demand](../02-sync/03-slack.md#read-on-demand) for Slack and
|
|
653
|
+
[Linear's](../02-sync/07-linear.md#read-on-demand).
|
|
643
654
|
|
|
644
655
|
### inbox
|
|
645
656
|
|
|
@@ -678,8 +689,12 @@ watchers
|
|
|
678
689
|
|
|
679
690
|
sync
|
|
680
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
|
|
681
696
|
|
|
682
|
-
|
|
697
|
+
24 passed
|
|
683
698
|
```
|
|
684
699
|
|
|
685
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
|
|
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 (`
|
|
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.
|
|
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
|
-
|
|
92
|
+
21 passed, 3 skipped
|
|
82
93
|
```
|
|
83
94
|
|
|
84
95
|
## The report
|