@vercel/board 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +27 -27
  2. package/dist/{a2-C41hD8SF.mjs → a2-ChRLRupa.mjs} +969 -25
  3. package/dist/access.d.mts +1 -1
  4. package/dist/access.mjs +3 -3
  5. package/dist/{actors-DjMeZpnU.mjs → actors-BOamkEuz.mjs} +3 -73
  6. package/dist/attempts-CLG840Oj.mjs +20 -0
  7. package/dist/{auth-COzc09sn.mjs → auth-Drg9FiC2.mjs} +225 -2
  8. package/dist/backend-B-SJ7p6p.d.mts +680 -0
  9. package/dist/backend-a2/contract.d.mts +873 -2
  10. package/dist/backend-a2/contract.mjs +2 -2
  11. package/dist/backend-a2/scheduler-local.d.mts +1 -1
  12. package/dist/backend-a2/scheduler-local.mjs +1 -1
  13. package/dist/backend-a2/scheduler-qstash.d.mts +78 -1
  14. package/dist/backend-a2/scheduler-qstash.mjs +521 -1
  15. package/dist/backend-a2/scheduler-vercel.d.mts +37 -1
  16. package/dist/backend-a2/scheduler-vercel.mjs +203 -1
  17. package/dist/backend-a2/store-memory.d.mts +2 -2
  18. package/dist/backend-a2/store-memory.mjs +3 -3
  19. package/dist/backend-a2/store-postgres.d.mts +1 -1
  20. package/dist/backend-a2/store-postgres.mjs +2850 -2
  21. package/dist/backend-a2/store-redis-http.d.mts +1 -1
  22. package/dist/backend-a2/store-redis-http.mjs +490 -2
  23. package/dist/backend-a2/store-redis.d.mts +10 -2
  24. package/dist/backend-a2/store-redis.mjs +51 -2
  25. package/dist/backend-a2/store-sqlite.d.mts +1 -1
  26. package/dist/backend-a2/store-sqlite.mjs +3 -3
  27. package/dist/backend-a2.d.mts +2706 -3
  28. package/dist/backend-a2.mjs +1 -1
  29. package/dist/{backend-http-Bid7RIpC.d.mts → backend-http-7cNb8nJI.d.mts} +10 -3
  30. package/dist/{backend-http-0roEmjl0.mjs → backend-http-CMlL3yq3.mjs} +2 -2
  31. package/dist/{backend-http-BVRno-ca.mjs → backend-http-Ddzd5kTS.mjs} +32 -3
  32. package/dist/backend-http.d.mts +1 -1
  33. package/dist/backend-http.mjs +1 -1
  34. package/dist/backend-http.node.d.mts +1 -1
  35. package/dist/backend-http.node.mjs +1 -1
  36. package/dist/{chat-provider-XCnQlxq6.d.mts → chat-provider-DNJQyajS.d.mts} +16 -4
  37. package/dist/{chat-provider-jFSeyNmB.mjs → chat-provider-YHy-WbrB.mjs} +15 -9
  38. package/dist/cli.mjs +30 -10
  39. package/dist/{client-DGDPaN7U.mjs → client-BA-J7usH.mjs} +10 -2
  40. package/dist/{conformance-DRR23YNy.mjs → conformance-CT5EeHeC.mjs} +5 -2
  41. package/dist/conformance.d.mts +1 -1
  42. package/dist/conformance.mjs +1 -1
  43. package/dist/{engine-Bir5s8ts.mjs → engine-D0oBUBEK.mjs} +249 -163
  44. package/dist/events-CewD4kw7.mjs +75 -0
  45. package/dist/{exports-Dvc1o52I.mjs → exports-CgjfMrQ-.mjs} +35 -32
  46. package/dist/exports-ZsgjzEnM.d.mts +4 -0
  47. package/dist/external-BV_cUncs.mjs +151 -0
  48. package/dist/folds-CRYY14kc.d.mts +534 -0
  49. package/dist/{reducers-mUPFuFJV.mjs → folds-Dyjput8M.mjs} +6 -73
  50. package/dist/ids-C6AYo1bu.mjs +31 -0
  51. package/dist/index.d.mts +4 -4
  52. package/dist/{index.default-Bkg77Pxn.mjs → index.default-C5Cpz3sa.mjs} +1 -1
  53. package/dist/index.mjs +5 -5
  54. package/dist/index.node.d.mts +4 -4
  55. package/dist/index.node.mjs +3 -3
  56. package/dist/inspect/app.js +31 -31
  57. package/dist/inspect/engine.js +5 -5
  58. package/dist/internal-CvsrprnU-CRY89I-b.mjs +203 -0
  59. package/dist/{local-Cr_dSjvs.mjs → local-DhkrAqPP.mjs} +1 -1
  60. package/dist/mentions-CB6ShY4R.mjs +72 -0
  61. package/dist/{targets-yM5JBUgr.mjs → middleware-CqaCwucP.mjs} +1 -33
  62. package/dist/platform-B4TnJtWu-CwcTyKg2.mjs +32 -0
  63. package/dist/{project-CFiArraX.mjs → project-DMDST9kl.mjs} +1 -1
  64. package/dist/{provider-BGjz-mDa.d.mts → provider-B0b9Qxw4.d.mts} +170 -40
  65. package/dist/provider-B3DpYSDq.mjs +220 -0
  66. package/dist/redis-tables-DN9PfIDi.mjs +3897 -0
  67. package/dist/reducer-CYPHsSmd-BQIfozGi.d.mts +170 -0
  68. package/dist/reducer-CjmvZSAb-D_OSISmj.mjs +74 -0
  69. package/dist/{contract-CuJmlrhw.mjs → reducers-BFmIXilu.mjs} +260 -287
  70. package/dist/retryable-lazy-DZWmHpii-Bye-W8q7.mjs +19 -0
  71. package/dist/scheduler-task-X1SToDjP-BVoWuInI.mjs +69 -0
  72. package/dist/{serve-BsOOc-6_.mjs → serve-BGYw0uNL.mjs} +5 -5
  73. package/dist/{server-jwPY7_wi.mjs → server-DPxBSogx.mjs} +4 -4
  74. package/dist/server-DtFrWxi3-Boa6gdWO.mjs +3602 -0
  75. package/dist/server-qgez0Xny-D5mLfqFQ.d.mts +385 -0
  76. package/dist/{serving-BNbzc96L.mjs → serving-CNkgUPW1.mjs} +1 -1
  77. package/dist/session-clear-wHd5GCpZ-BnKSxReK.mjs +51 -0
  78. package/dist/{sql-DbiEnqM0.mjs → sql-U9WTxrwJ.mjs} +2 -2
  79. package/dist/{sqlite-db-Cbj03vmp.mjs → sqlite-db-Ds4majtT.mjs} +1 -1
  80. package/dist/store-D0MhBvg6-4Z2L0Kqd.d.mts +530 -0
  81. package/dist/store-N8PXxDAS-BWBgzBHp.mjs +5 -0
  82. package/dist/store-codec-DwrWlgQx-CYxGW0aD.mjs +61 -0
  83. package/dist/store-memory-Bas9Uq5C.mjs +872 -0
  84. package/dist/store-memory-ZLiZbH3R.mjs +2 -0
  85. package/dist/store-polling-BZt2Rf3D-BiEM6_HB.mjs +148 -0
  86. package/dist/store-presence-polling-JuAy2Ck2-CYpuK75X.mjs +109 -0
  87. package/dist/store-sqlite-BmwL3pUR.mjs +2 -0
  88. package/dist/store-sqlite-CxxPCcCT.mjs +1053 -0
  89. package/dist/stream-buffer-DrTyiIuo-BKy-vI14.mjs +53 -0
  90. package/dist/sync-github.d.mts +24 -17
  91. package/dist/sync-github.mjs +89 -22
  92. package/dist/sync-kapso.d.mts +15 -13
  93. package/dist/sync-kapso.mjs +17 -14
  94. package/dist/sync-linear.d.mts +219 -0
  95. package/dist/sync-linear.mjs +311 -0
  96. package/dist/sync-slack.d.mts +12 -17
  97. package/dist/sync-slack.mjs +54 -42
  98. package/dist/sync.chat.d.mts +1 -1
  99. package/dist/sync.chat.mjs +1 -1
  100. package/dist/sync.d.mts +3 -3
  101. package/dist/sync.mjs +5 -4
  102. package/dist/targets-C-wdR5wP.mjs +33 -0
  103. package/dist/{tokens-DKLn5P9B.mjs → tokens-DhuzKzAl.mjs} +1 -1
  104. package/dist/{types-BHCjTZdB.d.mts → types-8yq2VnKI.d.mts} +2 -2
  105. package/dist/{users-Dls7_ANV.mjs → users-yryHKFMo.mjs} +2 -2
  106. package/dist/{watch-spec-W0c7ypHJ.mjs → watchers-hash-RLtnSF-t.mjs} +233 -28
  107. package/docs/01-board/01-mounting.md +1 -1
  108. package/docs/02-sync/01-overview.md +72 -48
  109. package/docs/02-sync/02-functions.md +398 -0
  110. package/docs/02-sync/03-slack.md +30 -25
  111. package/docs/02-sync/04-github.md +62 -26
  112. package/docs/02-sync/05-whatsapp.md +23 -19
  113. package/docs/02-sync/06-providers.md +60 -27
  114. package/docs/02-sync/07-linear.md +129 -0
  115. package/docs/03-guides/04-triage.md +1 -1
  116. package/docs/04-reference/01-cli.md +26 -6
  117. package/docs/04-reference/02-board.md +18 -5
  118. package/docs/04-reference/03-http.md +5 -2
  119. package/docs/04-reference/05-conformance.md +1 -1
  120. package/docs/index.md +5 -10
  121. package/package.json +31 -6
  122. package/dist/a2-D24cvwpN.d.mts +0 -3359
  123. package/dist/access-CJU2-vaa.mjs +0 -226
  124. package/dist/attempts-Bxp9KgPf.mjs +0 -919
  125. package/dist/exports-DLiE7fLY.d.mts +0 -4
  126. package/dist/provider-CuewcDNZ.mjs +0 -205
  127. package/dist/redis-tables-B35vv4CZ.mjs +0 -842
  128. package/dist/reducers-B_vUGQRp.d.mts +0 -1172
  129. package/dist/watchers-hash-CLjsa6od.mjs +0 -225
  130. package/docs/02-sync/02-callbacks.md +0 -265
@@ -1,225 +0,0 @@
1
- import { t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
2
- import "./schemas-Be08nyfW.mjs";
3
- import { V as routes } from "./watch-spec-W0c7ypHJ.mjs";
4
- import { r as badRequest, t as HttpError } from "./errors-DX3hpwWt.mjs";
5
- import { z } from "zod";
6
- //#region src/server/backend.ts
7
- /** The subscription sync's outbound runs on, in `BoardBinding.subscriptions`. No watcher's ID starts with `@`. */
8
- const SYNC = "@sync";
9
- //#endregion
10
- //#region src/version.ts
11
- const VERSION = "0.16.0";
12
- //#endregion
13
- //#region src/server/routing.ts
14
- /** board()'s own routes: the files it serves, and sync's, which run where board() does. */
15
- const BOARD_ROUTES = [
16
- "avatar",
17
- "openapi",
18
- "webhook",
19
- "signIn",
20
- "signedIn",
21
- "identityUnlink",
22
- "sync"
23
- ];
24
- /** Every protocol method, by the route it's served at. */
25
- const METHODS = [
26
- "discovery",
27
- "me",
28
- "channels",
29
- "actors",
30
- "identity",
31
- "messages",
32
- "threads",
33
- "message",
34
- "post",
35
- "posted",
36
- "edit",
37
- "delete",
38
- "react",
39
- "label",
40
- "schedule",
41
- "schedules",
42
- "cancelSchedule",
43
- "upload",
44
- "attachment",
45
- "uploadStart",
46
- "uploadFinish",
47
- "watchers",
48
- "register",
49
- "matches",
50
- "ack",
51
- "events",
52
- "syncImport",
53
- "syncReceipt",
54
- "syncThread",
55
- "syncFind",
56
- "syncCopies",
57
- "syncRoots",
58
- "inboxPush",
59
- "inboxNext",
60
- "inboxAck",
61
- "inbox",
62
- "syncCode",
63
- "syncAccount",
64
- "syncUnlink",
65
- "typing",
66
- "readSource"
67
- ];
68
- const escape = (literal) => literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
69
- function compile(method, path, target, own) {
70
- const parts = path.split("/").filter(Boolean);
71
- const source = parts.map((part) => part.startsWith(":") ? "/([^/]+)" : `/${escape(part)}`).join("");
72
- return {
73
- method,
74
- pattern: new RegExp(`${source}$`),
75
- names: parts.filter((part) => part.startsWith(":")).map((part) => part.slice(1)),
76
- target,
77
- segments: parts.length,
78
- literals: parts.filter((part) => !part.startsWith(":")).length,
79
- own
80
- };
81
- }
82
- /**
83
- * The routes a board answers, most specific first: each is matched as a suffix of the request's
84
- * path, so `/api/board/messages/12` is `GET /messages/:id` under `/api/board`. A backend's own
85
- * route at a protocol path takes its place. Discovery is the mount itself.
86
- */
87
- function createRouter(backendRoutes, basePath) {
88
- const patterns = [
89
- ...BOARD_ROUTES.map((name) => compile(routes[name].method, routes[name].path, {
90
- kind: "board",
91
- name
92
- }, false)),
93
- ...METHODS.filter((name) => name !== "discovery").map((name) => compile(routes[name].method, routes[name].path, {
94
- kind: "protocol",
95
- name
96
- }, false)),
97
- ...backendRoutes.map((route) => compile(route.method, route.path, {
98
- kind: "backend",
99
- route
100
- }, true))
101
- ].sort((a, b) => b.segments - a.segments || b.literals - a.literals || Number(b.own) - Number(a.own));
102
- /** A segment that isn't URI-encoded names nothing: no route. */
103
- const found = (candidate, base, values) => {
104
- const params = {};
105
- try {
106
- candidate.names.forEach((name, i) => params[name] = decodeURIComponent(values[i]));
107
- } catch {
108
- return null;
109
- }
110
- const first = candidate.names[0];
111
- return {
112
- target: candidate.target,
113
- base,
114
- param: first === void 0 ? null : params[first],
115
- params
116
- };
117
- };
118
- const discovery = (base) => ({
119
- target: {
120
- kind: "protocol",
121
- name: "discovery"
122
- },
123
- base,
124
- param: null,
125
- params: {},
126
- root: true
127
- });
128
- return function route(method, pathname) {
129
- const path = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
130
- if (basePath !== void 0) {
131
- const base = basePath.replace(/\/+$/, "");
132
- if (path !== base && !path.startsWith(`${base}/`)) return null;
133
- const rest = path.slice(base.length) || "/";
134
- if (rest === "/") return method === "GET" ? discovery(base) : null;
135
- for (const candidate of patterns) {
136
- const hit = candidate.method === method ? rest.match(new RegExp(`^${candidate.pattern.source}`)) : null;
137
- if (hit) return found(candidate, base, hit.slice(1));
138
- }
139
- return null;
140
- }
141
- for (const candidate of patterns) {
142
- if (candidate.method !== method) continue;
143
- const hit = path.match(candidate.pattern);
144
- if (hit) return found(candidate, path.slice(0, hit.index), hit.slice(1));
145
- }
146
- return method === "GET" ? discovery(path === "/" ? "" : path) : null;
147
- };
148
- }
149
- function queryObject(url) {
150
- const out = {};
151
- for (const key of new Set(url.searchParams.keys())) {
152
- const values = url.searchParams.getAll(key);
153
- out[key] = values.length > 1 ? values : values[0];
154
- }
155
- return out;
156
- }
157
- async function readJson(request) {
158
- const text = await request.text();
159
- if (text.trim() === "") return void 0;
160
- try {
161
- return JSON.parse(text);
162
- } catch {
163
- throw badRequest("request body is not valid JSON");
164
- }
165
- }
166
- const tooLarge = () => new HttpError(413, "too_large", `attachments are at most ${ATTACHMENT_BYTES_MAX / 1024 / 1024} MB`);
167
- /** The body, refused as soon as it's past `max`: with or without a `content-length`, it never reads more. */
168
- async function readCapped(request, max) {
169
- if (Number(request.headers.get("content-length") ?? 0) > max) throw tooLarge();
170
- const chunks = [];
171
- let size = 0;
172
- for await (const chunk of request.body ?? []) {
173
- size += chunk.byteLength;
174
- if (size > max) throw tooLarge();
175
- chunks.push(chunk);
176
- }
177
- const body = new Uint8Array(size);
178
- let at = 0;
179
- for (const chunk of chunks) {
180
- body.set(chunk, at);
181
- at += chunk.byteLength;
182
- }
183
- return body;
184
- }
185
- function openapi() {
186
- const paths = {};
187
- for (const [name, definition] of Object.entries(routes)) {
188
- const path = definition.path.replace(/:(\w+)/g, "{$1}");
189
- const operation = { operationId: name };
190
- if ("query" in definition) operation["x-query-schema"] = z.toJSONSchema(definition.query, { io: "input" });
191
- if ("body" in definition) operation.requestBody = { content: { "application/json": { schema: z.toJSONSchema(definition.body, { io: "input" }) } } };
192
- (paths[path] ??= {})[definition.method.toLowerCase()] = operation;
193
- }
194
- return {
195
- openapi: "3.1.0",
196
- info: {
197
- title: "board",
198
- version: VERSION
199
- },
200
- paths
201
- };
202
- }
203
- //#endregion
204
- //#region src/server/watchers-hash.ts
205
- /** By UTF-16 code unit, as `sort()` orders strings: the same in every locale and runtime. */
206
- const byCodeUnit = (a, b) => a < b ? -1 : a > b ? 1 : 0;
207
- /**
208
- * What `watchersHash` hashes, the same on both ends: a board on `http()` hashes its watchers, and
209
- * whether its sync subscribes, and the board it uses hashes what it holds.
210
- */
211
- function canonicalWatchers(watch, sync = []) {
212
- const canonical = Object.keys(watch).sort(byCodeUnit).map((id) => [
213
- id,
214
- watch[id].tail,
215
- watch[id].when
216
- ]);
217
- if (sync.length > 0) canonical.push(["sync", [...sync].sort((a, b) => byCodeUnit(a.key, b.key)).map((entry) => [
218
- entry.key,
219
- entry.platform,
220
- entry.signIn
221
- ])]);
222
- return JSON.stringify(canonical);
223
- }
224
- //#endregion
225
- export { queryObject as a, VERSION as c, openapi as i, SYNC as l, METHODS as n, readCapped as o, createRouter as r, readJson as s, canonicalWatchers as t };
@@ -1,265 +0,0 @@
1
- ---
2
- title: Callbacks
3
- description: Decide what each inbound platform event becomes, and how board messages look on each platform.
4
- ---
5
-
6
- ## A function per mapping
7
-
8
- A string mapping imports every event into one channel. A function receives every
9
- inbound event for its external channel, connected threads included, and decides:
10
-
11
- ```ts app/api/board/[[...path]]/route.ts
12
- import { board } from '@vercel/board'
13
- import { a2 } from '@vercel/board/backend-a2'
14
- import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
15
- import { postgres } from '@vercel/board/backend-a2/store-postgres'
16
- import { github, GitHubPayload } from '@vercel/board/sync-github'
17
-
18
- const backend = a2({
19
- store: postgres({ connectionString: process.env.DATABASE_URL! }),
20
- scheduler: vercelQueues({ topic: 'board' }),
21
- })
22
-
23
- const team = board({
24
- backend,
25
- sync: {
26
- github: github({
27
- adapter: 'github/acme',
28
- repos: {
29
- 'acme/www': async ({ event, board }) => {
30
- const channel = event.subject.type === 'pull_request' ? 'prs' : 'issues'
31
- await board.sync(channel, event, {
32
- labels: { area: 'www' },
33
- root: ({ raw }) => {
34
- const payload = GitHubPayload.parse(raw)
35
- const item = payload.pull_request ?? payload.issue
36
- return { text: [item?.title, item?.body, item?.html_url].filter(Boolean).join('\n\n') }
37
- },
38
- })
39
- },
40
- },
41
- }),
42
- },
43
- })
44
- export const GET = team.fetch, POST = team.fetch
45
- export const { scheduler } = backend
46
- ```
47
-
48
- `board.sync(channel, event)` applies the default import: the same thing a string
49
- mapping does, with the same provenance, files, links, state labels, and retries.
50
- Call it once, await it, and the event lands on the board. Don't call it, and the
51
- event is skipped; the thread stays connected, so later events still reach you.
52
-
53
- For an event in a thread that's already synced, the channel is ignored: the event
54
- lands in that thread. The channel only places new roots.
55
-
56
- The function's other writes through `board` (a reply, a label, a reaction, an
57
- edit or delete of the user's own message) are the platform user's, with the
58
- same provenance as the import, and go through the
59
- [middleware](../01-board/08-middleware.md) like it. Its reads, and anything
60
- else it does, are sync's own. It works the same on any backend, a board on
61
- `http()` included: there the writes land on the board it uses as imports.
62
-
63
- ## The event
64
-
65
- Every event is an envelope, validated and frozen before your function sees it.
66
- Fields every event has:
67
-
68
- | Field | Value |
69
- | --- | --- |
70
- | `id` | The inbox event's ID. Stable across retries: use it as an idempotency key. |
71
- | `platform`, `key` | `slack`, `github`, or `whatsapp`, and the entry's key. |
72
- | `channel`, `thread` | External IDs with the key prefix: `slack:C0123ABC`, `slack:C0123ABC:1727000000.123456`. `thread` is what `sync` accepts as a target. |
73
- | `subthread` | The precise sub-thread, when the adapter grouped it: a GitHub review thread. Otherwise `null`. |
74
- | `kind` | `message`, `edited`, `deleted`, `reaction`, or `lifecycle`. |
75
- | `type` | The narrower name: `message` (Slack, WhatsApp), `comment` (GitHub), `message.edited`, `message.deleted`, `reaction.added`, `reaction.removed`, or a lifecycle type like `pr.opened`. |
76
- | `raw` | The platform's payload. |
77
-
78
- Then, by kind:
79
-
80
- - **`message`:** `message` (the platform's ID), `text` (markdown), `author`
81
- (`{ id, handle, name, avatar, isBot }`), `isRoot`, `context` (a GitHub review
82
- comment's `path:line`), and `files` and `links` when it has them.
83
- - **`edited`:** `message`, `text`, `author`, `links`.
84
- - **`deleted`:** `message`.
85
- - **`reaction`:** `message`, `emoji`, `added`, `author`.
86
- - **`lifecycle`:** `type`, `title`, `url`, `labels`, `reply`, and `author`. Only
87
- GitHub sends these: see the [events table](./04-github.md#events).
88
-
89
- GitHub events also carry `subject: { type: 'pull_request' | 'issue', repo, number }`,
90
- comments and reactions included. The types are exported: `SlackEvent` from
91
- `@vercel/board/sync-slack`, `GitHubEvent` and `GitHubPayload` from
92
- `@vercel/board/sync-github`, and `WhatsAppEvent` from `@vercel/board/sync-kapso`.
93
-
94
- ## Permalinks
95
-
96
- Every callback also gets `permalink()`, the platform's own link to the event's
97
- message. It's fetched on the first call and reused after, so callbacks that never
98
- ask don't cost an API request:
99
-
100
- ```ts
101
- // in slack({ channels: { … } }): copy a report elsewhere, with a link back
102
- '#reports': async ({ event, board, permalink }) => {
103
- if (event.kind === 'message' && event.isRoot)
104
- await board.post('triage', `${event.text}\n\n${await permalink()}`)
105
- },
106
- ```
107
-
108
- On Slack it calls `chat.getPermalink` with the provider's client, so the URL has
109
- the workspace's own host (Enterprise Grid included) and a reply's `thread_ts`. It
110
- only reads the URL: the thread isn't synced and no channel is joined. It returns
111
- `null` for a lifecycle event or a deleted message, and on platforms without
112
- links, and throws when Slack refuses, so the callback is retried.
113
-
114
- ## Overrides
115
-
116
- The third argument changes the default import:
117
-
118
- | Override | Effect |
119
- | --- | --- |
120
- | `text` | Replaces the imported message's text. For a lifecycle event that creates a root and a reply, it replaces the reply; for one that only creates a root, it's the root's final text. |
121
- | `labels` | Merged into the thread root's labels. |
122
- | `unset` | Label keys removed from the root, applied last. |
123
- | `root` | A synchronous function that shapes a new root. It receives the default draft (`text`, `labels`, `author`, `raw`) and returns `{ text?, labels? }`. |
124
- | `noSync` | Keeps the imported reply on the board, like `vb reply --no-sync`: no other platform the thread syncs to gets it, now or in a backfill. A root it creates still syncs. |
125
-
126
- `root` runs whenever an import creates a root: a PR opening, or a comment on a PR
127
- the board hasn't seen, which recovers the root first. Its labels merge into the
128
- draft, then `labels` takes precedence, then `unset` removes keys. It can't change
129
- the author or other provenance.
130
-
131
- `noSync` keeps a GitHub PR's inline review threads out of its Slack thread, while
132
- top-level comments and reviews still go through:
133
-
134
- ```ts
135
- // in github({ repos: { … } }):
136
- 'acme/www': {
137
- channel: 'prs',
138
- run: async ({ event, board }) => {
139
- await board.sync('prs', event, { noSync: event.subthread !== null })
140
- },
141
- },
142
- ```
143
-
144
- Platform reconciliation still applies on later events: GitHub's next event
145
- rewrites `state`, `review`, `tag.*`, and `assignee.*` unless you override them
146
- again.
147
-
148
- ## The result
149
-
150
- `board.sync` returns `{ root, message }`, the board IDs it touched:
151
-
152
- - `root` is the thread's root.
153
- - `message` is the imported message, the message an edit, delete, or reaction
154
- changed, or a lifecycle event's reply. A lifecycle event that only creates a root
155
- returns the root; one that only changes labels returns `null`.
156
- - An event that can't apply (a reaction on a message the board never had) returns
157
- `null` where no board message exists.
158
-
159
- Retried after it succeeded, it returns the same IDs.
160
-
161
- ## More than an import
162
-
163
- `board` is a client of the board in this process, through `board()`, with the
164
- [handle's](../04-reference/02-board.md) methods, so a function can also reply,
165
- react, label, or attach:
166
-
167
- ```ts
168
- // in slack({ channels: { … } }):
169
- '#support': async ({ event, board }) => {
170
- const { root } = await board.sync('support', event)
171
- if (event.kind === 'message' && event.isRoot && root !== null)
172
- await board.label(root, { set: { status: 'new' } })
173
- },
174
- ```
175
-
176
- Its writes are authored by the platform actor who triggered the event, like
177
- `slack:user:U0456`. It holds no token: `board()` makes its calls.
178
-
179
- ## Reverse routing
180
-
181
- A bare function chooses a channel when an event arrives, so the board can't know
182
- which external channel a board root in `support` belongs to. Declare it:
183
-
184
- ```ts
185
- // in slack({ channels: { … } }):
186
- '#support': {
187
- channel: 'support',
188
- run: async ({ event, board }) => {
189
- await board.sync('support', event)
190
- },
191
- },
192
- ```
193
-
194
- Inbound events still go through `run`. A root posted in `support` on the board
195
- now starts a thread in `#support`, and replies flow both ways through the
196
- connection. Outgoing delivery never runs your function.
197
-
198
- ## Retries and idempotency
199
-
200
- A function that throws is retried. Writes made through `board` take IDs derived
201
- from the inbox event and the order of calls, so a retry that writes in the same
202
- order doesn't post twice, and a default import that already succeeded returns its
203
- existing message. Two rules follow:
204
-
205
- - Writes made in parallel (`Promise.all`) need explicit `id`s.
206
- - Your own side effects aren't covered. Key them on `event.id`:
207
-
208
- ```ts
209
- // in a callback, your side effect:
210
- // await createTicket(event, { idempotencyKey: event.id })
211
- ```
212
-
213
- On `a2()`, a function that keeps throwing dead-letters after 10 tries. That
214
- holds up only its thread's lane in that channel's inbox; other threads and the
215
- board keep flowing. A board on `http()` gives the event up after 5. Calling
216
- `board.sync` after the function returns, twice, or with an event other than the
217
- one it received throws.
218
-
219
- ## Render
220
-
221
- Every provider takes `render`, which decides the body of each board message it
222
- posts. It receives immutable copies of the message, as clients see it (its IDs
223
- are strings), and its author, the default label, and the resolved destination,
224
- and returns markdown:
225
-
226
- ```ts
227
- // in kapso({ … }): post the text alone, without the author label
228
- render: ({ message }) => message.text,
229
- ```
230
-
231
- The default is `**${label}**: ${message.text}`, where the label is the author's
232
- name: `claude via julian` for an agent profile, `ada (slack)` for a message that
233
- came from a platform. board still adds a line per attachment the platform
234
- didn't take, and converts mentions to the platform's own form after your
235
- function runs.
236
-
237
- A renderer can read labels. When a label change changes what a delivered copy
238
- renders to, board edits that copy, so a PR's root in Slack can show its state:
239
-
240
- ```ts
241
- // in slack({ … }):
242
- render: ({ message, label }) =>
243
- message.re === null && message.labels.state === 'merged'
244
- ? `:pr-merged: ${message.text}`
245
- : `**${label}**: ${message.text}`,
246
- ```
247
-
248
- `mentionOf(actor)` returns how the platform mentions an actor (`<@U0456>` in
249
- Slack), through any account their person linked, or null. The default label is
250
- the author's name, so nobody gets pinged unless a renderer asks:
251
-
252
- ```ts
253
- // in slack({ … }): a PR's root mentions its owner, or its author
254
- render: ({ message, label, mentionOf }) => {
255
- const who = (message.labels.owner && mentionOf(message.labels.owner)) ?? mentionOf(message.by.actor)
256
- return message.re === null && message.labels.pr && who
257
- ? `${message.text} · ${who}`
258
- : `**${label}**: ${message.text}`
259
- },
260
- ```
261
-
262
- The default renderer reads no labels, so labels alone never edit a copy. Rendering
263
- applies to roots, replies, backfills, and edits; changing it affects future
264
- deliveries, not copies already sent. A subclass can override `render(input)`
265
- instead; see [custom providers](./06-providers.md).