@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.
- package/README.md +27 -27
- package/dist/{a2-C41hD8SF.mjs → a2-ChRLRupa.mjs} +969 -25
- package/dist/access.d.mts +1 -1
- package/dist/access.mjs +3 -3
- package/dist/{actors-DjMeZpnU.mjs → actors-BOamkEuz.mjs} +3 -73
- package/dist/attempts-CLG840Oj.mjs +20 -0
- package/dist/{auth-COzc09sn.mjs → auth-Drg9FiC2.mjs} +225 -2
- package/dist/backend-B-SJ7p6p.d.mts +680 -0
- package/dist/backend-a2/contract.d.mts +873 -2
- package/dist/backend-a2/contract.mjs +2 -2
- 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 +78 -1
- package/dist/backend-a2/scheduler-qstash.mjs +521 -1
- package/dist/backend-a2/scheduler-vercel.d.mts +37 -1
- package/dist/backend-a2/scheduler-vercel.mjs +203 -1
- 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 +2850 -2
- package/dist/backend-a2/store-redis-http.d.mts +1 -1
- package/dist/backend-a2/store-redis-http.mjs +490 -2
- package/dist/backend-a2/store-redis.d.mts +10 -2
- package/dist/backend-a2/store-redis.mjs +51 -2
- 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 +2706 -3
- package/dist/backend-a2.mjs +1 -1
- package/dist/{backend-http-Bid7RIpC.d.mts → backend-http-7cNb8nJI.d.mts} +10 -3
- package/dist/{backend-http-0roEmjl0.mjs → backend-http-CMlL3yq3.mjs} +2 -2
- package/dist/{backend-http-BVRno-ca.mjs → backend-http-Ddzd5kTS.mjs} +32 -3
- 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/{chat-provider-XCnQlxq6.d.mts → chat-provider-DNJQyajS.d.mts} +16 -4
- package/dist/{chat-provider-jFSeyNmB.mjs → chat-provider-YHy-WbrB.mjs} +15 -9
- package/dist/cli.mjs +30 -10
- package/dist/{client-DGDPaN7U.mjs → client-BA-J7usH.mjs} +10 -2
- package/dist/{conformance-DRR23YNy.mjs → conformance-CT5EeHeC.mjs} +5 -2
- package/dist/conformance.d.mts +1 -1
- package/dist/conformance.mjs +1 -1
- package/dist/{engine-Bir5s8ts.mjs → engine-D0oBUBEK.mjs} +249 -163
- package/dist/events-CewD4kw7.mjs +75 -0
- package/dist/{exports-Dvc1o52I.mjs → exports-CgjfMrQ-.mjs} +35 -32
- package/dist/exports-ZsgjzEnM.d.mts +4 -0
- package/dist/external-BV_cUncs.mjs +151 -0
- package/dist/folds-CRYY14kc.d.mts +534 -0
- package/dist/{reducers-mUPFuFJV.mjs → folds-Dyjput8M.mjs} +6 -73
- package/dist/ids-C6AYo1bu.mjs +31 -0
- package/dist/index.d.mts +4 -4
- package/dist/{index.default-Bkg77Pxn.mjs → index.default-C5Cpz3sa.mjs} +1 -1
- package/dist/index.mjs +5 -5
- package/dist/index.node.d.mts +4 -4
- package/dist/index.node.mjs +3 -3
- package/dist/inspect/app.js +31 -31
- package/dist/inspect/engine.js +5 -5
- package/dist/internal-CvsrprnU-CRY89I-b.mjs +203 -0
- package/dist/{local-Cr_dSjvs.mjs → local-DhkrAqPP.mjs} +1 -1
- package/dist/mentions-CB6ShY4R.mjs +72 -0
- package/dist/{targets-yM5JBUgr.mjs → middleware-CqaCwucP.mjs} +1 -33
- package/dist/platform-B4TnJtWu-CwcTyKg2.mjs +32 -0
- package/dist/{project-CFiArraX.mjs → project-DMDST9kl.mjs} +1 -1
- package/dist/{provider-BGjz-mDa.d.mts → provider-B0b9Qxw4.d.mts} +170 -40
- package/dist/provider-B3DpYSDq.mjs +220 -0
- package/dist/redis-tables-DN9PfIDi.mjs +3897 -0
- package/dist/reducer-CYPHsSmd-BQIfozGi.d.mts +170 -0
- package/dist/reducer-CjmvZSAb-D_OSISmj.mjs +74 -0
- package/dist/{contract-CuJmlrhw.mjs → reducers-BFmIXilu.mjs} +260 -287
- package/dist/retryable-lazy-DZWmHpii-Bye-W8q7.mjs +19 -0
- package/dist/scheduler-task-X1SToDjP-BVoWuInI.mjs +69 -0
- package/dist/{serve-BsOOc-6_.mjs → serve-BGYw0uNL.mjs} +5 -5
- package/dist/{server-jwPY7_wi.mjs → server-DPxBSogx.mjs} +4 -4
- package/dist/server-DtFrWxi3-Boa6gdWO.mjs +3602 -0
- package/dist/server-qgez0Xny-D5mLfqFQ.d.mts +385 -0
- package/dist/{serving-BNbzc96L.mjs → serving-CNkgUPW1.mjs} +1 -1
- package/dist/session-clear-wHd5GCpZ-BnKSxReK.mjs +51 -0
- package/dist/{sql-DbiEnqM0.mjs → sql-U9WTxrwJ.mjs} +2 -2
- package/dist/{sqlite-db-Cbj03vmp.mjs → sqlite-db-Ds4majtT.mjs} +1 -1
- package/dist/store-D0MhBvg6-4Z2L0Kqd.d.mts +530 -0
- package/dist/store-N8PXxDAS-BWBgzBHp.mjs +5 -0
- package/dist/store-codec-DwrWlgQx-CYxGW0aD.mjs +61 -0
- package/dist/store-memory-Bas9Uq5C.mjs +872 -0
- package/dist/store-memory-ZLiZbH3R.mjs +2 -0
- package/dist/store-polling-BZt2Rf3D-BiEM6_HB.mjs +148 -0
- package/dist/store-presence-polling-JuAy2Ck2-CYpuK75X.mjs +109 -0
- package/dist/store-sqlite-BmwL3pUR.mjs +2 -0
- package/dist/store-sqlite-CxxPCcCT.mjs +1053 -0
- package/dist/stream-buffer-DrTyiIuo-BKy-vI14.mjs +53 -0
- package/dist/sync-github.d.mts +24 -17
- package/dist/sync-github.mjs +89 -22
- package/dist/sync-kapso.d.mts +15 -13
- package/dist/sync-kapso.mjs +17 -14
- package/dist/sync-linear.d.mts +219 -0
- package/dist/sync-linear.mjs +311 -0
- package/dist/sync-slack.d.mts +12 -17
- package/dist/sync-slack.mjs +54 -42
- package/dist/sync.chat.d.mts +1 -1
- package/dist/sync.chat.mjs +1 -1
- package/dist/sync.d.mts +3 -3
- package/dist/sync.mjs +5 -4
- package/dist/targets-C-wdR5wP.mjs +33 -0
- package/dist/{tokens-DKLn5P9B.mjs → tokens-DhuzKzAl.mjs} +1 -1
- package/dist/{types-BHCjTZdB.d.mts → types-8yq2VnKI.d.mts} +2 -2
- package/dist/{users-Dls7_ANV.mjs → users-yryHKFMo.mjs} +2 -2
- package/dist/{watch-spec-W0c7ypHJ.mjs → watchers-hash-RLtnSF-t.mjs} +233 -28
- package/docs/01-board/01-mounting.md +1 -1
- package/docs/02-sync/01-overview.md +72 -48
- package/docs/02-sync/02-functions.md +398 -0
- package/docs/02-sync/03-slack.md +30 -25
- package/docs/02-sync/04-github.md +62 -26
- package/docs/02-sync/05-whatsapp.md +23 -19
- package/docs/02-sync/06-providers.md +60 -27
- package/docs/02-sync/07-linear.md +129 -0
- package/docs/03-guides/04-triage.md +1 -1
- package/docs/04-reference/01-cli.md +26 -6
- package/docs/04-reference/02-board.md +18 -5
- package/docs/04-reference/03-http.md +5 -2
- package/docs/04-reference/05-conformance.md +1 -1
- package/docs/index.md +5 -10
- package/package.json +31 -6
- package/dist/a2-D24cvwpN.d.mts +0 -3359
- package/dist/access-CJU2-vaa.mjs +0 -226
- package/dist/attempts-Bxp9KgPf.mjs +0 -919
- package/dist/exports-DLiE7fLY.d.mts +0 -4
- package/dist/provider-CuewcDNZ.mjs +0 -205
- package/dist/redis-tables-B35vv4CZ.mjs +0 -842
- package/dist/reducers-B_vUGQRp.d.mts +0 -1172
- package/dist/watchers-hash-CLjsa6od.mjs +0 -225
- package/docs/02-sync/02-callbacks.md +0 -265
|
@@ -16,9 +16,10 @@ outbound vb reply ─▶ message.posted ─▶ sync ─▶ Slack ─▶ receipt
|
|
|
16
16
|
attach vb sync ─▶ receipt: thread.synced ─▶ sync ─▶ Slack (root + backfill)
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
Each platform takes one sync function, which board calls for every event in
|
|
20
|
+
both directions: it imports what comes in, and delivers what goes out. Helpers
|
|
21
|
+
build it from the platform's own things (Slack channels, GitHub repos, WhatsApp
|
|
22
|
+
numbers), each mapped to a board channel or to a function of your own.
|
|
22
23
|
|
|
23
24
|
Sync runs in `board()`, wherever the board lives. It keeps its records on the
|
|
24
25
|
backend through routes every backend answers: imports as platform authors,
|
|
@@ -33,8 +34,8 @@ Each entry under `sync` is one platform account, keyed by a name you choose:
|
|
|
33
34
|
```ts app/api/board/[[...path]]/route.ts
|
|
34
35
|
import { board } from '@vercel/board'
|
|
35
36
|
import { a2 } from '@vercel/board/backend-a2'
|
|
36
|
-
import { slack } from '@vercel/board/sync-slack'
|
|
37
|
-
import { github } from '@vercel/board/sync-github'
|
|
37
|
+
import { channels, slack } from '@vercel/board/sync-slack'
|
|
38
|
+
import { github, repos } from '@vercel/board/sync-github'
|
|
38
39
|
import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
|
|
39
40
|
import { postgres } from '@vercel/board/backend-a2/store-postgres'
|
|
40
41
|
|
|
@@ -46,19 +47,14 @@ const backend = a2({
|
|
|
46
47
|
const team = board({
|
|
47
48
|
backend,
|
|
48
49
|
sync: {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
adapter: 'github/acme',
|
|
58
|
-
repos: {
|
|
59
|
-
'acme/www': { pulls: 'prs', issues: 'issues' },
|
|
60
|
-
},
|
|
61
|
-
}),
|
|
50
|
+
// A Vercel Connect connector, and what each event does.
|
|
51
|
+
slack: slack('slack/acme', channels({
|
|
52
|
+
'#errors-www': 'errors-www',
|
|
53
|
+
'#prs': 'prs',
|
|
54
|
+
})),
|
|
55
|
+
github: github('github/acme', repos({
|
|
56
|
+
'acme/www': { pulls: 'prs', issues: 'issues' },
|
|
57
|
+
})),
|
|
62
58
|
},
|
|
63
59
|
})
|
|
64
60
|
export const GET = team.fetch, POST = team.fetch
|
|
@@ -82,50 +78,55 @@ covers the store, the scheduler, and their routes. To pass your own Chat SDK
|
|
|
82
78
|
adapter instead of a connector, see [Slack](./03-slack.md#your-own-adapter) and
|
|
83
79
|
[GitHub](./04-github.md#setup).
|
|
84
80
|
|
|
85
|
-
##
|
|
81
|
+
## Sync functions
|
|
86
82
|
|
|
87
|
-
|
|
83
|
+
A platform's first argument is its app: a connector name, or the options
|
|
84
|
+
(`{ adapter, … }`). The second is its sync function. The helpers make one from a
|
|
85
|
+
map, where a string is a board channel:
|
|
88
86
|
|
|
89
|
-
|
|
|
87
|
+
| Helper | A string means |
|
|
90
88
|
| --- | --- |
|
|
91
|
-
| `'
|
|
92
|
-
| `
|
|
93
|
-
| `{
|
|
89
|
+
| `channels({ '#prs': 'prs' })` (Slack) | `mirror('prs')`: a two-way mirror. What comes in lands in `prs`, and a root posted in `prs` starts a thread in #prs. |
|
|
90
|
+
| `repos({ 'acme/www': 'prs' })` (GitHub) | `into('prs')`: PRs and issues come in, and nothing goes back unless a function sends it. |
|
|
91
|
+
| `numbers({ '16315558151': 'support' })` (WhatsApp) | `mirror('support')`. |
|
|
94
92
|
|
|
95
|
-
|
|
93
|
+
A map's value can also be a function of your own, and the whole thing can be:
|
|
94
|
+
see [Sync functions](./02-functions.md).
|
|
96
95
|
|
|
97
96
|
**Entry keys** (`slack`, `github`, or `acme` for a second Slack workspace) are
|
|
98
97
|
names you choose; the adapter decides the platform. The key appears in external
|
|
99
98
|
IDs (`acme:C0999XYZ:1727.9`), in sync targets, in `--from` filters, and in the
|
|
100
99
|
webhook path (`/webhooks/acme`).
|
|
101
100
|
|
|
102
|
-
**
|
|
101
|
+
**Map keys** are the platform's names or IDs: `'#prs'` or `'C0123ABC'` for
|
|
103
102
|
Slack, `'owner/repo'` for GitHub, a phone number ID for WhatsApp.
|
|
104
103
|
|
|
105
104
|
- Slack names resolve through one cached channel listing. IDs survive renames;
|
|
106
105
|
a renamed channel stops matching by name on the next cold start.
|
|
107
|
-
- Each key resolves on its own.
|
|
108
|
-
|
|
109
|
-
|
|
106
|
+
- Each key resolves on its own. A channel that doesn't exist yet is logged and
|
|
107
|
+
skipped, retried at most once a minute, and the rest sync. One Slack couldn't
|
|
108
|
+
check (a failed listing) holds what it might be in; see
|
|
109
|
+
[Helpers](./02-functions.md#helpers).
|
|
110
110
|
|
|
111
111
|
## The rules
|
|
112
112
|
|
|
113
113
|
1. **Delivery.** Every message, edit, delete, and reaction in a thread goes to
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
skipped.
|
|
114
|
+
the sync function once for each external thread it's synced with, except the
|
|
115
|
+
one it came from, and `deliver()` sends it. Where a platform can't do
|
|
116
|
+
something (WhatsApp edits, GitHub typing), that delivery is skipped.
|
|
117
117
|
2. **Starting threads.** A root posted on the board, by anyone or any watcher,
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
118
|
+
goes to the sync function of each platform that can start threads in a
|
|
119
|
+
channel, which may `start` one; `mirror` does in its channel. Slack and
|
|
120
|
+
Linear can; on Linear a root opens an issue. GitHub and WhatsApp can't, so
|
|
121
|
+
roots never open an issue or a conversation there.
|
|
122
|
+
3. **Inbound.** A platform event goes to the sync function when its thread is
|
|
123
|
+
already synced, or when its channel is one the helpers route (their keys;
|
|
124
|
+
a function of your own at the top gets every channel). The function decides
|
|
125
|
+
whether it lands. Everything else is
|
|
126
|
+
dropped at the webhook, even in channels the bot belongs to.
|
|
127
|
+
4. **Threads start once,** when their root is posted, or when someone syncs
|
|
128
|
+
them by hand. A deploy that changes a function changes what it does from
|
|
129
|
+
then on: threads it started stay synced, and what reaches them is up to it.
|
|
129
130
|
5. **Attaching.** `vb sync <id> <target>` attaches an existing thread. A channel
|
|
130
131
|
target gets the root and a backfill of every reply, in order. A thread target
|
|
131
132
|
joins an existing external thread and receives only what's written after.
|
|
@@ -152,6 +153,28 @@ vb sync 812 slack:#incidents # escalate an exi
|
|
|
152
153
|
vb sync 812 github:acme/www:123 # join PR #123's conversation
|
|
153
154
|
```
|
|
154
155
|
|
|
156
|
+
`vb sync 812` with no target says where the thread is: each target, and the
|
|
157
|
+
platform's link to #812's copy there. `vb read <link>` goes the other way, from
|
|
158
|
+
a Slack permalink or a GitHub PR, issue, or comment to the board thread it's
|
|
159
|
+
synced with. Both answer from sync's records, never from the platform, and only
|
|
160
|
+
for messages you can read: a link to anything else is `no message on this board
|
|
161
|
+
for that link`. A handle has them as `board.sync(id)` and `board.linked(url)`.
|
|
162
|
+
|
|
163
|
+
```text
|
|
164
|
+
$ vb sync 812
|
|
165
|
+
slack C0C8XN8SQRG https://vercel.slack.com/archives/C0C8XN8SQRG/p1792780353701549
|
|
166
|
+
|
|
167
|
+
$ vb read https://vercel.slack.com/archives/C0C8XN8SQRG/p1792780353701549
|
|
168
|
+
#812 ada · 2h
|
|
169
|
+
checkout is down
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The links come from each provider: its `locate(url)` reads a link as the
|
|
173
|
+
platform's own IDs, and `url(thread, message)` makes one. A board with several
|
|
174
|
+
entries of one platform (two Slack workspaces, or a project's Slack sync
|
|
175
|
+
registered beside the team's) looks the IDs up under each of them, since any
|
|
176
|
+
may have synced it. See [Providers](./06-providers.md#links).
|
|
177
|
+
|
|
155
178
|
`--no-sync` keeps a message on the board: a root with it starts no threads in
|
|
156
179
|
mirrored channels, and a reply with it isn't delivered. `--sync` and `--no-sync`
|
|
157
180
|
together are a usage error. A handle's `post` takes `sync: [...]` and
|
|
@@ -165,12 +188,12 @@ its sync still runs here:
|
|
|
165
188
|
```ts app/api/board/[[...path]]/route.ts
|
|
166
189
|
import { board } from '@vercel/board'
|
|
167
190
|
import { http } from '@vercel/board/backend-http'
|
|
168
|
-
import { slack } from '@vercel/board/sync-slack'
|
|
191
|
+
import { channels, slack } from '@vercel/board/sync-slack'
|
|
169
192
|
|
|
170
193
|
const team = board({
|
|
171
194
|
backend: http('https://board.example.com/api/board'),
|
|
172
195
|
sync: {
|
|
173
|
-
slack: slack(
|
|
196
|
+
slack: slack('slack/acme', channels({ '#support': 'support' })),
|
|
174
197
|
},
|
|
175
198
|
})
|
|
176
199
|
export const GET = team.fetch, POST = team.fetch
|
|
@@ -187,7 +210,7 @@ export const GET = team.fetch, POST = team.fetch
|
|
|
187
210
|
and read them: `vercelProject({ project: 'prj_support', can: { support: 'all' } })`
|
|
188
211
|
in its `board({ access })`. See [what `sync` grants](../04-reference/03-http.md#what-sync-grants).
|
|
189
212
|
|
|
190
|
-
A [
|
|
213
|
+
A [sync function](./02-functions.md)'s writes land on the other board as the
|
|
191
214
|
platform user, and its reads are this deployment's. This board registers its
|
|
192
215
|
entries there, so the other board lists them and links accounts on them. People
|
|
193
216
|
link the other board, so `vb identity link slack` gets its code there; this
|
|
@@ -233,7 +256,8 @@ branch, a custom environment).
|
|
|
233
256
|
**Without Connect,** create the app on the platform yourself, point its webhook
|
|
234
257
|
at the same route, and pass the platform's own secrets to the Chat SDK adapter.
|
|
235
258
|
Each platform page has the steps: [Slack](./03-slack.md#setup),
|
|
236
|
-
[GitHub](./04-github.md#setup), [WhatsApp](./05-whatsapp.md#setup)
|
|
259
|
+
[GitHub](./04-github.md#setup), [WhatsApp](./05-whatsapp.md#setup),
|
|
260
|
+
[Linear](./07-linear.md#setup).
|
|
237
261
|
|
|
238
262
|
## When delivery fails
|
|
239
263
|
|
|
@@ -254,7 +278,7 @@ and logs why.
|
|
|
254
278
|
On the way in, the webhook stores each event in the inbox before it answers,
|
|
255
279
|
then sync imports it on the external thread's lane. If storing fails, the
|
|
256
280
|
webhook answers the platform with a 500 so it retries. On `a2()`, a
|
|
257
|
-
[
|
|
281
|
+
[sync function](./02-functions.md) that keeps throwing dead-letters after 10 tries,
|
|
258
282
|
which holds up only that thread's lane in that channel's inbox. A board on
|
|
259
283
|
`http()` gives the event up after 5 tries, as it does a watcher's match.
|
|
260
284
|
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Sync functions
|
|
3
|
+
description: One function per platform decides what each event does, coming in and going out.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## One function per platform
|
|
7
|
+
|
|
8
|
+
Each platform takes its app and a sync function. board calls the function for
|
|
9
|
+
every event, in both directions, and the function decides what happens:
|
|
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 { slack } from '@vercel/board/sync-slack'
|
|
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
|
+
slack: slack('slack/acme', async (sync) => {
|
|
27
|
+
// From Slack: #support (C0123ABC) comes onto the board, and threads it already has keep coming.
|
|
28
|
+
if (sync.direction === 'in') return void (await sync.import(sync.channel === 'C0123ABC' ? 'support' : undefined))
|
|
29
|
+
// A new root in `support`: start its thread in #support.
|
|
30
|
+
if (sync.target === null) return sync.message.channel === 'support' ? sync.start('C0123ABC') : undefined
|
|
31
|
+
// Anything said on the board, in a thread synced to Slack: send it.
|
|
32
|
+
await sync.deliver()
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
})
|
|
36
|
+
export const GET = team.fetch, POST = team.fetch
|
|
37
|
+
export const { scheduler } = backend
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
A function that calls nothing does nothing: nothing comes in, nothing goes out.
|
|
41
|
+
`import`, `deliver`, and `start` are the only things that move a message.
|
|
42
|
+
|
|
43
|
+
Most boards don't write that function by hand. `channels`, `repos`, and
|
|
44
|
+
`numbers` route each event by its platform channel, and `mirror`, `into`, and
|
|
45
|
+
`render` are what each channel does:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// in board({ sync: { … } }):
|
|
49
|
+
slack: slack('slack/acme', channels({ C0123ABC: 'support' })),
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
That's the function above, except that the webhook keeps only #support's events
|
|
53
|
+
and those of threads the board syncs; see [What the webhook keeps](#what-the-webhook-keeps)
|
|
54
|
+
and [Helpers](#helpers).
|
|
55
|
+
|
|
56
|
+
## Coming in
|
|
57
|
+
|
|
58
|
+
When an event comes from the platform, `sync.direction` is `'in'`:
|
|
59
|
+
|
|
60
|
+
| Field | Value |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| `key` | The entry's key: `slack` in `sync: { slack: … }`. |
|
|
63
|
+
| `channel` | The platform channel it happened in, as the platform names it: `C0123ABC`, `acme/www:pulls`, a phone number ID. |
|
|
64
|
+
| `event` | The event, validated and frozen. See [The event](#the-event). |
|
|
65
|
+
| `board` | The board, as the platform user the event came from. See [More than an import](#more-than-an-import). |
|
|
66
|
+
| `import(channel?, overrides?)` | Imports the event. See below. |
|
|
67
|
+
| `permalink()` | The platform's own link to the event's message. See [Permalinks](#permalinks). |
|
|
68
|
+
| `resolve(name)` | A platform channel's ID from its name: `'#support'` to `'C0123ABC'`. |
|
|
69
|
+
|
|
70
|
+
`import(channel)` applies the default import: provenance, files, links, state
|
|
71
|
+
labels, and retries. For an event in a thread the board already syncs, the
|
|
72
|
+
channel is ignored and the event lands in that thread; the channel only places
|
|
73
|
+
new roots. Without a channel, an event in a thread the board doesn't sync stays
|
|
74
|
+
off it, so `import()` alone means "only threads we already have".
|
|
75
|
+
|
|
76
|
+
It runs once per event: a second call answers what the first did. Don't call
|
|
77
|
+
it, and the event is skipped; its thread stays connected, so later events still
|
|
78
|
+
reach the function.
|
|
79
|
+
|
|
80
|
+
## Going out
|
|
81
|
+
|
|
82
|
+
When something happens on the board, `sync.direction` is `'out'`, once for each
|
|
83
|
+
platform thread the board thread is synced with:
|
|
84
|
+
|
|
85
|
+
| Field | Value |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `key` | The entry's key. |
|
|
88
|
+
| `target` | The platform thread or channel it goes to (`slack:C0123ABC:1727000000.123456`), or `null` for a new root. |
|
|
89
|
+
| `channel` | The platform channel `target` is in, as the platform names it; `null` for a new root. |
|
|
90
|
+
| `event` | The board event, as `vb tail` shows it. |
|
|
91
|
+
| `message` | The message the event is about, as it is now. |
|
|
92
|
+
| `deliver(options?)` | Sends the event to `target`. Takes `{ render }`; see [Render](#render). |
|
|
93
|
+
| `start(channel)` | Syncs the message's thread with a platform channel: `'#support'`, `'C0123ABC'`. |
|
|
94
|
+
| `resolve(name)` | A platform channel's ID from its name. |
|
|
95
|
+
|
|
96
|
+
What `deliver()` sends depends on the event:
|
|
97
|
+
|
|
98
|
+
| Event | `deliver()` sends |
|
|
99
|
+
| --- | --- |
|
|
100
|
+
| `thread.synced` | The root, then every reply, in order: a thread that just started there. |
|
|
101
|
+
| `message.posted` | The reply. |
|
|
102
|
+
| `message.edited`, `message.deleted` | The change to its copy. |
|
|
103
|
+
| `reaction.added`, `reaction.removed` | The reaction on its copy. |
|
|
104
|
+
| `labels.changed` | An edit of its copy, when the labels change what it renders to. |
|
|
105
|
+
|
|
106
|
+
A new root comes once more, with `target` and `channel` null, for each platform
|
|
107
|
+
that can start a thread in a channel (Slack can; GitHub and WhatsApp can't).
|
|
108
|
+
That's when `start` puts it there: the board records the channel, and the
|
|
109
|
+
`thread.synced` that follows comes back through the function to be delivered.
|
|
110
|
+
`start` never sends a thread back to the channel its root came from. A root
|
|
111
|
+
posted with `--no-sync` doesn't come at all.
|
|
112
|
+
|
|
113
|
+
The function is never called with a message's way back to where it came from:
|
|
114
|
+
a Slack message isn't offered to its own Slack thread. `deliver` runs once per
|
|
115
|
+
call, like `import`.
|
|
116
|
+
|
|
117
|
+
## Helpers
|
|
118
|
+
|
|
119
|
+
`channels`, `repos`, `numbers`, and `teams` take a map from platform channels to what
|
|
120
|
+
each one does: a sync function, or a board channel as a string.
|
|
121
|
+
|
|
122
|
+
| Helper | From | Keys | A string means |
|
|
123
|
+
| --- | --- | --- | --- |
|
|
124
|
+
| `channels` | `@vercel/board/sync-slack`, `@vercel/board/sync` | Slack channels, by name (`'#prs'`) or ID | `mirror` |
|
|
125
|
+
| `repos` | `@vercel/board/sync-github` | Repos (`'owner/repo'`), or `{ pulls, issues }` to route each | `into` |
|
|
126
|
+
| `numbers` | `@vercel/board/sync-kapso` | WhatsApp phone number IDs | `mirror` |
|
|
127
|
+
| `teams` | `@vercel/board/sync-linear` | Linear team keys (`'ENG'`) | `mirror` |
|
|
128
|
+
|
|
129
|
+
Each routes an event by its `channel`. A new root goes to every channel's
|
|
130
|
+
function, with `channel` set to that one. A thread the board syncs in a channel
|
|
131
|
+
that isn't in the map (someone ran `vb sync` on it) keeps syncing: both ways
|
|
132
|
+
under `channels`, `numbers`, and `teams`, and in only under `repos`.
|
|
133
|
+
|
|
134
|
+
The units are plain sync functions, from `@vercel/board/sync`:
|
|
135
|
+
|
|
136
|
+
| Unit | Does |
|
|
137
|
+
| --- | --- |
|
|
138
|
+
| `mirror(channel)` | Imports into the board channel, delivers what's said there, and starts a thread for a new root in it. |
|
|
139
|
+
| `into(channel)` | Imports into the board channel, and sends nothing back. |
|
|
140
|
+
| `render(renderer, fn)` | `fn`, delivering with `renderer`. |
|
|
141
|
+
|
|
142
|
+
They compose by calling each other, with the helper outermost (see [What the
|
|
143
|
+
webhook keeps](#what-the-webhook-keeps)). A GitHub repo that comes in, and gets
|
|
144
|
+
back only what's written on the board, never what was said in Slack:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
// in board({ sync: { … } }):
|
|
148
|
+
github: github('github/acme', repos({
|
|
149
|
+
'acme/www': async (sync) => {
|
|
150
|
+
if (sync.direction === 'in') return void (await sync.import('prs'))
|
|
151
|
+
if (sync.message.from === null) await sync.deliver()
|
|
152
|
+
},
|
|
153
|
+
})),
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
A PR's inline review threads stay on GitHub and the board, out of Slack:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
// in github('github/acme', repos({ … })):
|
|
160
|
+
'acme/www': async (sync) => {
|
|
161
|
+
if (sync.direction === 'in') await sync.import('prs', { noSync: sync.event.subthread !== null })
|
|
162
|
+
},
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Keys resolve on their own. A channel that doesn't exist yet is logged and
|
|
166
|
+
skipped, tried again a minute later, and the rest sync; so is a private
|
|
167
|
+
channel's name on an app without `groups:read`, which never lists (map it by
|
|
168
|
+
ID). A name the platform couldn't check, because its listing failed, might be
|
|
169
|
+
any channel, so until it resolves, an event outside the channels that did
|
|
170
|
+
throws and is retried, rather than reaching the fallback past that key's
|
|
171
|
+
function, and a new root starts only where its channel resolved. Each retry
|
|
172
|
+
asks again. Slack names resolve through one cached channel listing. IDs never
|
|
173
|
+
wait: a key like `'C0123ABC'` asks Slack nothing.
|
|
174
|
+
|
|
175
|
+
## What the webhook keeps
|
|
176
|
+
|
|
177
|
+
The webhook stores each event it keeps in the [inbox](./01-overview.md#debug-it),
|
|
178
|
+
then the function runs on it. It keeps an event when the board syncs its thread,
|
|
179
|
+
or when its channel is one that `channels`, `repos`, or `numbers` routes;
|
|
180
|
+
`render` keeps theirs. A function of your own at the top keeps every event the
|
|
181
|
+
platform sends the app, in every channel it's in, and each one is stored, even
|
|
182
|
+
if it only calls a helper inside:
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
// in board({ sync: { … } }): keeps #support's events, and threads synced by hand
|
|
186
|
+
slack: slack('slack/acme', channels({ '#support': 'support' })),
|
|
187
|
+
// the same on the board, but keeps every channel the app is in
|
|
188
|
+
slack: slack('slack/acme', (sync) => channels({ '#support': 'support' })(sync)),
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
So keep the helper outermost, and put your own code in its values.
|
|
192
|
+
|
|
193
|
+
## The event
|
|
194
|
+
|
|
195
|
+
Every event is an envelope, validated and frozen the first time your function
|
|
196
|
+
reads it. Fields every event has:
|
|
197
|
+
|
|
198
|
+
| Field | Value |
|
|
199
|
+
| --- | --- |
|
|
200
|
+
| `id` | The inbox event's ID. Stable across retries: use it as an idempotency key. |
|
|
201
|
+
| `platform`, `key` | `slack`, `github`, or `whatsapp`, and the entry's key. |
|
|
202
|
+
| `channel`, `thread` | External IDs with the key prefix: `slack:C0123ABC`, `slack:C0123ABC:1727000000.123456`. `thread` is what `vb sync` accepts as a target. |
|
|
203
|
+
| `subthread` | The precise sub-thread, when the adapter grouped it: a GitHub review thread. Otherwise `null`. |
|
|
204
|
+
| `kind` | `message`, `edited`, `deleted`, `reaction`, or `lifecycle`. |
|
|
205
|
+
| `type` | The narrower name: `message` (Slack, WhatsApp), `comment` (GitHub), `message.edited`, `message.deleted`, `reaction.added`, `reaction.removed`, or a lifecycle type like `pr.opened`. |
|
|
206
|
+
| `raw` | The platform's payload. |
|
|
207
|
+
|
|
208
|
+
Then, by kind:
|
|
209
|
+
|
|
210
|
+
- **`message`:** `message` (the platform's ID), `text` (markdown), `author`
|
|
211
|
+
(`{ id, handle, name, avatar, isBot }`), `isRoot`, `context` (a GitHub review
|
|
212
|
+
comment's `path:line`), and `files` and `links` when it has them.
|
|
213
|
+
- **`edited`:** `message`, `text`, `author`, `links`.
|
|
214
|
+
- **`deleted`:** `message`.
|
|
215
|
+
- **`reaction`:** `message`, `emoji`, `added`, `author`.
|
|
216
|
+
- **`lifecycle`:** `type`, `title`, `url`, `labels`, `reply`, and `author`. GitHub
|
|
217
|
+
and Linear send these: see GitHub's [events table](./04-github.md#events) and
|
|
218
|
+
Linear's [events table](./07-linear.md#events).
|
|
219
|
+
|
|
220
|
+
GitHub events also carry `subject: { type: 'pull_request' | 'issue', repo, number }`,
|
|
221
|
+
comments and reactions included. The types are exported: `SlackEvent` from
|
|
222
|
+
`@vercel/board/sync-slack`, `GitHubEvent` and `GitHubPayload` from
|
|
223
|
+
`@vercel/board/sync-github`, `WhatsAppEvent` from `@vercel/board/sync-kapso`, and `LinearEvent` from
|
|
224
|
+
`@vercel/board/sync-linear`.
|
|
225
|
+
|
|
226
|
+
## Permalinks
|
|
227
|
+
|
|
228
|
+
`permalink()` is the platform's own link to the event's message. It's fetched on
|
|
229
|
+
the first call and reused after, so a function that never asks doesn't cost an
|
|
230
|
+
API request:
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
// in slack('slack/acme', channels({ … })): copy a report elsewhere, with a link back
|
|
234
|
+
'#reports': async (sync) => {
|
|
235
|
+
if (sync.direction === 'in' && sync.event.kind === 'message' && sync.event.isRoot)
|
|
236
|
+
await sync.board.post('triage', `${sync.event.text}\n\n${await sync.permalink()}`)
|
|
237
|
+
},
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
On Slack it calls `chat.getPermalink` with the provider's client, so the URL has
|
|
241
|
+
the workspace's own host (Enterprise Grid included) and a reply's `thread_ts`. It
|
|
242
|
+
only reads the URL: the thread isn't synced and no channel is joined. It returns
|
|
243
|
+
`null` for a lifecycle event or a deleted message, and on platforms without
|
|
244
|
+
links, and throws when Slack refuses, so the event is retried.
|
|
245
|
+
|
|
246
|
+
## Overrides
|
|
247
|
+
|
|
248
|
+
`import`'s second argument changes the default import:
|
|
249
|
+
|
|
250
|
+
| Override | Effect |
|
|
251
|
+
| --- | --- |
|
|
252
|
+
| `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. |
|
|
253
|
+
| `labels` | Merged into the thread root's labels. |
|
|
254
|
+
| `unset` | Label keys removed from the root, applied last. |
|
|
255
|
+
| `root` | A synchronous function that shapes a new root. It receives the default draft (`text`, `labels`, `author`, `raw`) and returns `{ text?, labels? }`. |
|
|
256
|
+
| `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. |
|
|
257
|
+
|
|
258
|
+
`root` runs whenever an import creates a root: a PR opening, or a comment on a PR
|
|
259
|
+
the board hasn't seen, which recovers the root first. Its labels merge into the
|
|
260
|
+
draft, then `labels` takes precedence, then `unset` removes keys. It can't change
|
|
261
|
+
the author or other provenance:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
// in github('github/acme', repos({ … })):
|
|
265
|
+
'acme/www': async (sync) => {
|
|
266
|
+
if (sync.direction !== 'in') return
|
|
267
|
+
await sync.import(sync.event.subject.type === 'pull_request' ? 'prs' : 'issues', {
|
|
268
|
+
labels: { area: 'www' },
|
|
269
|
+
root: ({ raw }) => {
|
|
270
|
+
const payload = GitHubPayload.parse(raw)
|
|
271
|
+
const item = payload.pull_request ?? payload.issue
|
|
272
|
+
return { text: [item?.title, item?.body, item?.html_url].filter(Boolean).join('\n\n') }
|
|
273
|
+
},
|
|
274
|
+
})
|
|
275
|
+
},
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Platform reconciliation still applies on later events: GitHub's next event
|
|
279
|
+
rewrites `state`, `review`, `tag.*`, and `assignee.*` unless you override them
|
|
280
|
+
again.
|
|
281
|
+
|
|
282
|
+
## The result
|
|
283
|
+
|
|
284
|
+
`import` returns `{ root, message }`, the board IDs it touched:
|
|
285
|
+
|
|
286
|
+
- `root` is the thread's root.
|
|
287
|
+
- `message` is the imported message, the message an edit, delete, or reaction
|
|
288
|
+
changed, or a lifecycle event's reply. A lifecycle event that only creates a root
|
|
289
|
+
returns the root; one that only changes labels returns `null`.
|
|
290
|
+
- An event that can't apply (a reaction on a message the board never had) returns
|
|
291
|
+
`null` where no board message exists.
|
|
292
|
+
|
|
293
|
+
Retried after it succeeded, it returns the same IDs.
|
|
294
|
+
|
|
295
|
+
## More than an import
|
|
296
|
+
|
|
297
|
+
`sync.board` is a client of the board in this process, through `board()`, with
|
|
298
|
+
the [handle's](../04-reference/02-board.md) methods, so a function can also
|
|
299
|
+
reply, react, label, or attach:
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
// in slack('slack/acme', channels({ … })):
|
|
303
|
+
'#support': async (sync) => {
|
|
304
|
+
if (sync.direction === 'out') return sync.deliver()
|
|
305
|
+
const { root } = await sync.import('support')
|
|
306
|
+
if (sync.event.kind === 'message' && sync.event.isRoot && root !== null)
|
|
307
|
+
await sync.board.label(root, { set: { status: 'new' } })
|
|
308
|
+
},
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
Its writes (a reply, a label, a reaction, an edit or delete of the user's own
|
|
312
|
+
message) are authored by the platform actor who triggered the event, like
|
|
313
|
+
`slack:user:U0456`, with the same provenance as the import, and go through the
|
|
314
|
+
[middleware](../01-board/08-middleware.md) like it. Its reads, and anything else
|
|
315
|
+
it does, are sync's own. It holds no token: `board()` makes its calls. It works
|
|
316
|
+
the same on any backend, a board on `http()` included: there the writes land on
|
|
317
|
+
the board it uses as imports.
|
|
318
|
+
|
|
319
|
+
## Render
|
|
320
|
+
|
|
321
|
+
`deliver({ render })` decides the body of each board message it posts, and
|
|
322
|
+
`render(renderer, fn)` passes a renderer to every `deliver` in `fn`. A renderer
|
|
323
|
+
receives immutable copies of the message, as clients see it (its IDs are
|
|
324
|
+
strings), and its author, the default label, and the resolved destination, and
|
|
325
|
+
returns markdown:
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
// in board({ sync: { … } }): post the text alone, without the author label
|
|
329
|
+
kapso({ adapter, markRead }, render(({ message }) => message.text, numbers({ '16315558151': 'support' }))),
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
The default is `**${label}**: ${message.text}`, where the label is the author's
|
|
333
|
+
name: `claude via julian` for an agent profile, `ada (slack)` for a message that
|
|
334
|
+
came from a platform. board still adds a line per attachment the platform
|
|
335
|
+
didn't take, and converts mentions to the platform's own form after your
|
|
336
|
+
renderer runs.
|
|
337
|
+
|
|
338
|
+
Each channel can render its own way, since `render` wraps any function:
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
// in board({ sync: { … } }):
|
|
342
|
+
slack: slack('slack/acme', channels({
|
|
343
|
+
'#deploys': render(({ message }) => message.text, mirror('deploys')),
|
|
344
|
+
'#support': mirror('support'),
|
|
345
|
+
})),
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
A renderer can read labels. When a label change changes what a delivered copy
|
|
349
|
+
renders to, board edits that copy, so a PR's root in Slack can show its state:
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
// in slack('slack/acme', render(…, channels({ … }))):
|
|
353
|
+
({ message, label }) =>
|
|
354
|
+
message.re === null && message.labels.state === 'merged'
|
|
355
|
+
? `:pr-merged: ${message.text}`
|
|
356
|
+
: `**${label}**: ${message.text}`
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
`mentionOf(actor)` returns how the platform mentions an actor (`<@U0456>` in
|
|
360
|
+
Slack), through any account their person linked, or null. The default label is
|
|
361
|
+
the author's name, so nobody gets pinged unless a renderer asks:
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
// a renderer: a PR's root mentions its owner, or its author
|
|
365
|
+
({ message, label, mentionOf }) => {
|
|
366
|
+
const who = (message.labels.owner && mentionOf(message.labels.owner)) ?? mentionOf(message.by.actor)
|
|
367
|
+
return message.re === null && message.labels.pr && who
|
|
368
|
+
? `${message.text} · ${who}`
|
|
369
|
+
: `**${label}**: ${message.text}`
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
The default renderer reads no labels, so labels alone never edit a copy. Rendering
|
|
374
|
+
applies to roots, replies, backfills, and edits; changing it affects future
|
|
375
|
+
deliveries, not copies already sent. A subclass can override `render(input)`
|
|
376
|
+
instead; see [custom providers](./06-providers.md).
|
|
377
|
+
|
|
378
|
+
## Retries and idempotency
|
|
379
|
+
|
|
380
|
+
A function that throws is retried. Writes made through `sync.board` take IDs
|
|
381
|
+
derived from the inbox event and the order of calls, so a retry that writes in
|
|
382
|
+
the same order doesn't post twice, and an import that already succeeded returns
|
|
383
|
+
its existing message. Two rules follow:
|
|
384
|
+
|
|
385
|
+
- Writes made in parallel (`Promise.all`) need explicit `id`s.
|
|
386
|
+
- Your own side effects aren't covered. Key them on `event.id`:
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
// in a sync function, your side effect:
|
|
390
|
+
// await createTicket(sync.event, { idempotencyKey: sync.event.id })
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
On `a2()`, an event coming in that keeps throwing dead-letters after 10 tries.
|
|
394
|
+
That holds up only its thread's lane in that channel's inbox; other threads and
|
|
395
|
+
the board keep flowing. A board on `http()` gives the event up after 5. Going
|
|
396
|
+
out, a throw is a failed delivery: retried five times, then recorded, as in
|
|
397
|
+
[When delivery fails](./01-overview.md#when-delivery-fails). Calling `import`,
|
|
398
|
+
`deliver`, or `start` after the function returns throws.
|