@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
@@ -5,16 +5,21 @@ description: Mirror Slack channels into board channels, with threads, reactions,
5
5
 
6
6
  ## Configure it
7
7
 
8
- `slack({ adapter, channels })` takes a [Vercel Connect](./01-overview.md#set-up-webhooks)
9
- connector and a map of Slack channels, by name (`'#prs'`) or ID (`'C0123ABC'`), to
10
- [mappings](./01-overview.md#mappings):
8
+ ```package-install
9
+ npm i chat @chat-adapter/slack @vercel/connect
10
+ ```
11
+
12
+ `slack(app, sync)` takes a [Vercel Connect](./01-overview.md#set-up-webhooks)
13
+ connector and a [sync function](./02-functions.md). `channels` makes one from a map
14
+ of Slack channels, by name (`'#prs'`) or ID (`'C0123ABC'`), to board channels
15
+ (two-way mirrors) or functions of your own:
11
16
 
12
17
  ```ts app/api/board/[[...path]]/route.ts
13
18
  import { board } from '@vercel/board'
14
19
  import { a2 } from '@vercel/board/backend-a2'
15
20
  import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
16
21
  import { postgres } from '@vercel/board/backend-a2/store-postgres'
17
- import { slack } from '@vercel/board/sync-slack'
22
+ import { channels, slack } from '@vercel/board/sync-slack'
18
23
 
19
24
  const backend = a2({
20
25
  store: postgres({ connectionString: process.env.DATABASE_URL! }),
@@ -24,13 +29,10 @@ const backend = a2({
24
29
  const team = board({
25
30
  backend,
26
31
  sync: {
27
- slack: slack({
28
- adapter: 'slack/acme',
29
- channels: {
30
- '#errors-www': 'errors-www',
31
- C0123ABC: 'dev',
32
- },
33
- }),
32
+ slack: slack('slack/acme', channels({
33
+ '#errors-www': 'errors-www',
34
+ C0123ABC: 'dev',
35
+ })),
34
36
  },
35
37
  })
36
38
  export const GET = team.fetch, POST = team.fetch
@@ -51,8 +53,8 @@ The prefix is the entry's key, so a second workspace keyed `acme` reads
51
53
 
52
54
  ### Your own adapter
53
55
 
54
- Without Connect, pass a Chat SDK Slack adapter with the app's own secrets
55
- (`npm i chat @chat-adapter/slack`):
56
+ Without Connect, pass the options, with a Chat SDK Slack adapter that has the
57
+ app's own secrets (`npm i chat @chat-adapter/slack`):
56
58
 
57
59
  ```ts app/api/board/[[...path]]/route.ts
58
60
  import { createSlackAdapter } from '@chat-adapter/slack'
@@ -60,7 +62,7 @@ import { board } from '@vercel/board'
60
62
  import { a2 } from '@vercel/board/backend-a2'
61
63
  import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
62
64
  import { postgres } from '@vercel/board/backend-a2/store-postgres'
63
- import { slack } from '@vercel/board/sync-slack'
65
+ import { channels, slack } from '@vercel/board/sync-slack'
64
66
 
65
67
  const backend = a2({
66
68
  store: postgres({ connectionString: process.env.DATABASE_URL! }),
@@ -70,13 +72,15 @@ const backend = a2({
70
72
  const team = board({
71
73
  backend,
72
74
  sync: {
73
- slack: slack({
74
- adapter: createSlackAdapter({
75
- botToken: process.env.SLACK_BOT_TOKEN!,
76
- signingSecret: process.env.SLACK_SIGNING_SECRET!,
77
- }),
78
- channels: { '#prs': 'prs' },
79
- }),
75
+ slack: slack(
76
+ {
77
+ adapter: createSlackAdapter({
78
+ botToken: process.env.SLACK_BOT_TOKEN!,
79
+ signingSecret: process.env.SLACK_SIGNING_SECRET!,
80
+ }),
81
+ },
82
+ channels({ '#prs': 'prs' }),
83
+ ),
80
84
  },
81
85
  })
82
86
  export const GET = team.fetch, POST = team.fetch
@@ -89,9 +93,11 @@ A static-token adapter can leave it out; board uses the adapter's own web client
89
93
 
90
94
  ## Channels
91
95
 
92
- Slack only sends a channel's events to apps in it. When a mapped channel resolves,
93
- the app joins it if it's public. A private channel needs someone to `/invite` the
94
- app, and the log names each one that does.
96
+ Slack only sends a channel's events to apps in it, and the app never joins one
97
+ on its own: `/invite` it to each channel you map. Until then that channel's
98
+ events don't arrive, and a post there fails with Slack's `not_in_channel`. The
99
+ log names each channel mapped by name that the app isn't in; one mapped by ID
100
+ isn't checked, since that would ask Slack about it.
95
101
 
96
102
  Name lookups list channels once and cache the result. Private channels show up by
97
103
  name only with `groups:read`; Vercel Connect's Slack app doesn't request it, so map
@@ -233,7 +239,6 @@ To create the app yourself:
233
239
  | `channels:history`, `groups:history` | Reading messages, links, and threads |
234
240
  | `channels:read` | Looking up channels by name |
235
241
  | `groups:read` | Looking up private channels by name |
236
- | `channels:join` | Joining mapped public channels |
237
242
  | `chat:write` | Posting |
238
243
  | `chat:write.customize` | Agents posting under their own names |
239
244
  | `reactions:read`, `reactions:write` | Reactions both ways |
@@ -5,16 +5,21 @@ description: Turn each PR and issue into a board thread, with its comments, revi
5
5
 
6
6
  ## Configure it
7
7
 
8
- `github({ adapter, repos })` takes a [Vercel Connect](./01-overview.md#set-up-webhooks)
9
- connector and a map of repos to [mappings](./01-overview.md#mappings). board
10
- builds the Chat SDK adapter and people's sign-in from the connector name:
8
+ ```package-install
9
+ npm i chat @chat-adapter/github @vercel/connect
10
+ ```
11
+
12
+ `github(app, sync)` takes a [Vercel Connect](./01-overview.md#set-up-webhooks)
13
+ connector and a [sync function](./02-functions.md). `repos` makes one from a map
14
+ of repos to board channels. board builds the Chat SDK adapter and people's
15
+ sign-in from the connector name:
11
16
 
12
17
  ```ts app/api/board/[[...path]]/route.ts
13
18
  import { board } from '@vercel/board'
14
19
  import { a2 } from '@vercel/board/backend-a2'
15
20
  import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
16
21
  import { postgres } from '@vercel/board/backend-a2/store-postgres'
17
- import { github } from '@vercel/board/sync-github'
22
+ import { github, repos } from '@vercel/board/sync-github'
18
23
 
19
24
  const backend = a2({
20
25
  store: postgres({ connectionString: process.env.DATABASE_URL! }),
@@ -24,24 +29,43 @@ const backend = a2({
24
29
  const team = board({
25
30
  backend,
26
31
  sync: {
27
- github: github({
28
- adapter: 'github/acme',
29
- repos: {
30
- 'acme/www': 'dev', // PRs and issues, both in dev
31
- 'acme/docs': { pulls: 'docs-prs' }, // PRs only; issues aren't synced
32
- 'acme/api': { pulls: 'prs', issues: 'issues' },
33
- },
34
- }),
32
+ github: github('github/acme', repos({
33
+ 'acme/www': 'dev', // PRs and issues, both in dev
34
+ 'acme/docs': { pulls: 'docs-prs' }, // PRs only; issues aren't synced
35
+ 'acme/api': { pulls: 'prs', issues: 'issues' },
36
+ })),
35
37
  },
36
38
  })
37
39
  export const GET = team.fetch, POST = team.fetch
38
40
  export const { scheduler } = backend
39
41
  ```
40
42
 
41
- A value is a board channel or a [function](./02-callbacks.md) for the repo's PRs
43
+ A value is a board channel or a [function](./02-functions.md) for the repo's PRs
42
44
  and issues alike, or `{ pulls, issues }` to send each somewhere else. Leave one out
43
45
  to skip it.
44
46
 
47
+ ## What goes back to GitHub
48
+
49
+ A board channel brings PRs and issues in, and sends nothing back: GitHub is often
50
+ public, and what's said in Slack or on the board may not be. To send some of it,
51
+ deliver it from the function. This sends what's written on the board, by people
52
+ and their agents, and keeps what was said in Slack out:
53
+
54
+ ```ts
55
+ // in board({ sync: { … } }):
56
+ github: github('github/acme', repos({
57
+ 'acme/www': async (sync) => {
58
+ if (sync.direction === 'in') return void (await sync.import('dev'))
59
+ if (sync.message.from === null) await sync.deliver()
60
+ },
61
+ })),
62
+ ```
63
+
64
+ `mirror('dev')` as a repo's value sends everything back, Slack included. A
65
+ thread someone syncs by hand to a PR or issue in a repo the map doesn't name
66
+ comes in the same way, and gets nothing back either: on Slack, a thread synced
67
+ by hand outside the map syncs both ways.
68
+
45
69
  | Thing | ID |
46
70
  | --- | --- |
47
71
  | A repo's PRs, the channel | `github:acme/www:pulls` |
@@ -50,13 +74,22 @@ to skip it.
50
74
  | An issue | `github:acme/www:issue:45` |
51
75
  | An inline review thread, grouped under its PR | `github:acme/www:123:rc:<commentId>` |
52
76
 
77
+ A review thread isn't a sync target: `vb sync 812 github:acme/www:123:rc:345`
78
+ says to sync its PR, `github:acme/www:123`. A board thread synced with the PR
79
+ gets its review comments, and its replies to them go back into their review
80
+ threads. A link to a review comment (`…/pull/123#discussion_r346`) finds it on
81
+ the board, whether it came from GitHub or the board posted it, for comments
82
+ synced on this release or later: earlier ones were recorded without the PR's
83
+ thread, so their links answer 404.
84
+
53
85
  ## A PR's whole life in one thread
54
86
 
55
87
  1. **A PR opens.** The board creates a root in the mapped channel, `PR #123: <title>`
56
88
  with its link, labeled `pr=acme/www#123` and `state=open` (or `state=draft`).
57
89
  If the channel also mirrors a Slack channel, the root starts a Slack thread
58
90
  there too.
59
- 2. **Comments, Slack replies, and board replies** all reach each other.
91
+ 2. **Comments** reach the board and its Slack thread. Board and Slack replies
92
+ reach GitHub only as [the function sends them](#what-goes-back-to-github).
60
93
  3. **Inline review comments** arrive as replies in the same thread, with their
61
94
  file and line (`src/auth.ts:42`). A board reply to one goes back into that
62
95
  review thread on GitHub, and into the PR's Slack thread as written.
@@ -77,6 +110,7 @@ issue. Resolving review conversations isn't synced.
77
110
  | Event | What the mirror does |
78
111
  | --- | --- |
79
112
  | `pr.opened` | A root labeled `pr=owner/repo#123` and `state=open` or `state=draft` |
113
+ | `pr.edited`, `issue.edited` | A new title edits the root to say it, and its copies elsewhere follow. A new description changes nothing |
80
114
  | `pr.ready`, `pr.draft` | Sets `state` to `open` or `draft` |
81
115
  | `pr.reopened` | A reply ("reopened by julian") and `state=open`, or `state=draft` for a draft |
82
116
  | `pr.closed` | A reply ("merged by julian" or "closed by julian") and `state=merged` or `state=closed` |
@@ -134,8 +168,8 @@ is the PR's whole conversation. Label changes are ordinary events: a PR leaving
134
168
  A comment on a PR or issue that opened before you mapped the repo still lands.
135
169
  The board fetches the PR or issue through the adapter's `octokit`, creates its root
136
170
  with its author, state, labels, and assignees, then posts the comment as a reply. A
137
- failed fetch retries the event. A [function](./02-callbacks.md) that doesn't call
138
- `board.sync` skips the fetch too.
171
+ failed fetch retries the event. A [function](./02-functions.md) that doesn't call
172
+ `import` skips the fetch too.
139
173
 
140
174
  ## Vercel's deployment comment
141
175
 
@@ -172,7 +206,7 @@ import { board } from '@vercel/board'
172
206
  import { a2 } from '@vercel/board/backend-a2'
173
207
  import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
174
208
  import { postgres } from '@vercel/board/backend-a2/store-postgres'
175
- import { github } from '@vercel/board/sync-github'
209
+ import { github, repos } from '@vercel/board/sync-github'
176
210
 
177
211
  const backend = a2({
178
212
  store: postgres({ connectionString: process.env.DATABASE_URL! }),
@@ -182,15 +216,17 @@ const backend = a2({
182
216
  const team = board({
183
217
  backend,
184
218
  sync: {
185
- github: github({
186
- adapter: createGitHubAdapter({
187
- appId: process.env.GITHUB_APP_ID!,
188
- privateKey: process.env.GITHUB_PRIVATE_KEY!,
189
- installationId: Number(process.env.GITHUB_INSTALLATION_ID),
190
- webhookSecret: process.env.GITHUB_WEBHOOK_SECRET!,
191
- }),
192
- repos: { 'acme/www': 'dev' },
193
- }),
219
+ github: github(
220
+ {
221
+ adapter: createGitHubAdapter({
222
+ appId: process.env.GITHUB_APP_ID!,
223
+ privateKey: process.env.GITHUB_PRIVATE_KEY!,
224
+ installationId: Number(process.env.GITHUB_INSTALLATION_ID),
225
+ webhookSecret: process.env.GITHUB_WEBHOOK_SECRET!,
226
+ }),
227
+ },
228
+ repos({ 'acme/www': 'dev' }),
229
+ ),
194
230
  },
195
231
  })
196
232
  export const GET = team.fetch, POST = team.fetch
@@ -9,17 +9,18 @@ description: Sync WhatsApp conversations on a business number with board threads
9
9
  npm i chat @kapso/chat-adapter @kapso/whatsapp-cloud-api
10
10
  ```
11
11
 
12
- `kapso({ adapter, markRead, numbers })` takes
12
+ `kapso({ adapter, markRead }, sync)` takes
13
13
  [Kapso's Chat SDK adapter](https://chat-sdk.dev/adapters/vendor-official/kapso),
14
- a read operation, and a map of WhatsApp phone number IDs to
15
- [mappings](./01-overview.md#mappings):
14
+ a read operation, and a [sync function](./02-functions.md). `numbers` makes one
15
+ from a map of WhatsApp phone number IDs to board channels (two-way mirrors) or
16
+ functions of your own:
16
17
 
17
18
  ```ts app/api/board/[[...path]]/route.ts
18
19
  import { board } from '@vercel/board'
19
20
  import { a2 } from '@vercel/board/backend-a2'
20
21
  import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
21
22
  import { postgres } from '@vercel/board/backend-a2/store-postgres'
22
- import { kapso } from '@vercel/board/sync-kapso'
23
+ import { kapso, numbers } from '@vercel/board/sync-kapso'
23
24
  import { createKapsoAdapter } from '@kapso/chat-adapter'
24
25
  import { WhatsAppClient } from '@kapso/whatsapp-cloud-api'
25
26
 
@@ -36,11 +37,13 @@ const backend = a2({
36
37
  const team = board({
37
38
  backend,
38
39
  sync: {
39
- kapso: kapso({
40
- adapter: createKapsoAdapter({ client }),
41
- markRead: (input) => client.messages.markRead(input),
42
- numbers: { '16315558151': 'support' },
43
- }),
40
+ kapso: kapso(
41
+ {
42
+ adapter: createKapsoAdapter({ client }),
43
+ markRead: (input) => client.messages.markRead(input),
44
+ },
45
+ numbers({ '16315558151': 'support' }),
46
+ ),
44
47
  },
45
48
  })
46
49
  export const GET = team.fetch, POST = team.fetch
@@ -73,8 +76,8 @@ in Kapso. Writing again after that starts a new board thread.
73
76
  - **The board never starts a conversation.** A number has no threads to start, so
74
77
  a root posted in a mirrored channel stays on the board.
75
78
  - **Board messages post from the business number,** with the author's name in bold
76
- (`*julian*: refunded`). To post the text alone, pass
77
- `render: ({ message }) => message.text`; see [render](./02-callbacks.md#render).
79
+ (`*julian*: refunded`). To post the text alone, wrap the function in
80
+ `render(({ message }) => message.text, …)`; see [render](./02-functions.md#render).
78
81
 
79
82
  ## Reply within the window
80
83
 
@@ -117,19 +120,20 @@ WhatsApp shows no status text, so a status is ignored.
117
120
 
118
121
  ## Route by function
119
122
 
120
- A function mapping receives every event for its number. Importing a message into a
121
- channel connects its conversation, so board replies in that thread reach the
122
- contact:
123
+ A function for a number receives every event for it, both ways. Importing a
124
+ message into a channel connects its conversation, and delivering sends board
125
+ replies in that thread to the contact:
123
126
 
124
127
  ```ts
125
- // in kapso({ numbers: { … } }):
126
- '16315558151': async ({ event, board }) => {
127
- if (event.kind !== 'message') return
128
- await board.sync('leads', event, { text: `${event.author.name}: ${event.text}` })
128
+ // in kapso({ … }, numbers({ … })):
129
+ '16315558151': async (sync) => {
130
+ if (sync.direction === 'out') return sync.deliver()
131
+ if (sync.event.kind !== 'message') return
132
+ await sync.import('leads', { text: `${sync.event.author.name}: ${sync.event.text}` })
129
133
  },
130
134
  ```
131
135
 
132
- See [callbacks](./02-callbacks.md) for the event and the overrides.
136
+ See [sync functions](./02-functions.md) for the event and the overrides.
133
137
 
134
138
  ## What doesn't sync
135
139
 
@@ -14,7 +14,7 @@ Either way, a provider translates between the platform and the board, and the
14
14
  board does the rest: it validates and stores every incoming event before
15
15
  answering, orders imports per external thread and deliveries per board thread,
16
16
  retries, skips echoes, records copy and file receipts, and runs the default
17
- imports and your [callbacks](./02-callbacks.md). Durable state lives on the
17
+ imports and your [sync functions](./02-functions.md). Durable state lives on the
18
18
  board's backend; a provider keeps only SDK caches and credentials.
19
19
 
20
20
  ## A native provider
@@ -25,11 +25,11 @@ import {
25
25
  SyncProvider,
26
26
  envelope,
27
27
  eventSchema,
28
- type EventMapping,
29
28
  type ProviderPost,
30
29
  type Receive,
31
30
  type ResolvedTarget,
32
31
  type SyncContext,
32
+ type SyncFn,
33
33
  } from '@vercel/board/sync'
34
34
 
35
35
  export const DeskEvent = eventSchema('desk', 'message')
@@ -47,10 +47,9 @@ type Delivery = {
47
47
  export class DeskProvider extends SyncProvider<DeskEvent> {
48
48
  private readonly secret: string
49
49
 
50
- constructor(options: { secret: string; inboxes: Record<string, EventMapping<DeskEvent>> }) {
51
- super({ platform: 'desk', canStartThreads: true })
50
+ constructor(options: { secret: string }, sync: SyncFn<DeskEvent>) {
51
+ super({ platform: 'desk', canStartThreads: true }, sync)
52
52
  this.secret = options.secret
53
- for (const [inbox, mapping] of Object.entries(options.inboxes)) this.map(inbox, mapping)
54
53
  }
55
54
 
56
55
  override async receive(key: string, request: Request, emit: Receive): Promise<Response> {
@@ -91,24 +90,27 @@ export class DeskProvider extends SyncProvider<DeskEvent> {
91
90
  return { thread: kind === 'channel' ? `${target}:${sent.ticket}` : target, message: sent.message }
92
91
  }
93
92
 
94
- override event(context: Pick<SyncContext, 'id' | 'key' | 'event' | 'raw'>): DeskEvent {
93
+ override event(context: SyncContext): DeskEvent {
95
94
  return DeskEvent.parse(envelope(this.platform, context.key, context.event, context.raw, context.id))
96
95
  }
96
+
97
+ // The platform channel a thread is in, so `channels` can route what goes out to it.
98
+ override inboxChannel(key: string, thread: string): string {
99
+ return thread.split(':').slice(0, 2).join(':')
100
+ }
97
101
  }
98
102
  ```
99
103
 
100
- Then use it like any built-in entry. Its webhook is `/webhooks/desk`:
104
+ Then use it like any built-in entry, with any [sync function](./02-functions.md).
105
+ Its webhook is `/webhooks/desk`:
101
106
 
102
107
  ```ts
103
- // in board():
108
+ // in board(), with channels from '@vercel/board/sync':
104
109
  sync: {
105
- desk: new DeskProvider({ secret: process.env.DESK_SECRET!, inboxes: { support: 'support' } }),
110
+ desk: new DeskProvider({ secret: process.env.DESK_SECRET! }, channels({ support: 'support' })),
106
111
  },
107
112
  ```
108
113
 
109
- `this.map(key, mapping)` registers a mapping, so your provider takes the same
110
- string, function, and `{ channel, run }` values as the built-in ones.
111
-
112
114
  ## The contract
113
115
 
114
116
  **Required methods:**
@@ -116,16 +118,17 @@ string, function, and `{ channel, run }` values as the built-in ones.
116
118
  | Method | Does |
117
119
  | --- | --- |
118
120
  | `receive(key, request, emit)` | Authenticates the request, then emits normalized `{ event, raw, dedupe }` records, and answers what the platform expects. |
119
- | `resolveChannel(key)` | Turns a mapping key (`'#prs'`, `'support'`) into the platform's channel ID. |
121
+ | `resolveChannel(name)` | Turns a channel's name (`'#prs'`, `'support'`) into the platform's channel ID: what `sync.resolve` and `channels` use. Throw `UnreadableError('channel_not_found', …)` (from `@vercel/board/sync`) when there's no such channel; any other throw means it couldn't tell, and `channels` holds what that channel might be in. |
120
122
  | `resolveTarget(target)` | Turns a sync target into `{ target, channel, kind }`, where `kind` is `channel` or `thread`. |
121
123
  | `post(target, kind, body)` | Posts `body` (`{ markdown, files?, as? }`) and returns `{ thread, message, files? }`. |
122
- | `event(context)` | Builds the typed envelope your callbacks receive, usually `envelope(…)` parsed by an `eventSchema`. |
124
+ | `event(context)` | Builds the typed envelope sync functions read as `sync.event`, usually `envelope(…)` parsed by an `eventSchema`. |
123
125
 
124
126
  **IDs.** Incoming locations (`channel`, `thread`, `subthread`) carry the entry's key
125
127
  as a prefix: `desk:support:t_812`. A `subthread` extends its `thread`'s ID
126
128
  (`desk:support:t_812:r_3` in `desk:support:t_812`), so `groupThread` returns a
127
129
  prefix of the ID it's given: the board refuses an import whose subthread isn't
128
- in its thread. Targets handed to `post`, `resolveTarget`, and the
130
+ in its thread. A subthread's messages are found by their thread too, so a
131
+ platform message ID is unique within its thread, as GitHub's comment IDs are. Targets handed to `post`, `resolveTarget`, and the
129
132
  optional methods are native, without it: `support:t_812`. The board records each
130
133
  target's `kind` from `resolveTarget`, so it never parses your ID syntax, and a
131
134
  channel target on a provider whose `canStartThreads` is false is refused.
@@ -136,8 +139,9 @@ identical action (a reaction added, removed, and added again) must produce a new
136
139
  one. Use the platform's delivery or event ID.
137
140
 
138
141
  **Intake.** The board validates each record and stores it before your `receive`
139
- returns. Events for channels you don't map and threads that aren't synced are
140
- dropped. If storing fails, the webhook answers 500 so the platform retries.
142
+ returns. Events outside the channels the sync function's helpers route, and
143
+ outside threads the board syncs, are dropped; see [What the webhook keeps](./02-functions.md#what-the-webhook-keeps).
144
+ If storing fails, the webhook answers 500 so the platform retries.
141
145
 
142
146
  **Options** passed to `super()`:
143
147
 
@@ -147,7 +151,6 @@ dropped. If storing fails, the webhook answers 500 so the platform retries.
147
151
  | `canStartThreads` | `false` | A board root can start a thread in a mirrored channel. |
148
152
  | `singleReaction` | `false` | One reaction per person per message, as on WhatsApp. |
149
153
  | `uploadsFiles` | `false` | Files go to the platform with `uploadFiles`; otherwise each gets a line in the text. |
150
- | `render` | the label and text | The outgoing body; see [render](./02-callbacks.md#render). |
151
154
  | `users` | `null` | People's own tokens, `{ authorize, token }` (`UserTokens` from `@vercel/board/sync`), to post as them and link accounts by signing in. |
152
155
 
153
156
  ## Optional methods
@@ -162,14 +165,44 @@ Each has a default that does nothing, or says the platform can't:
162
165
  | `fetchFile(file)` | Files in |
163
166
  | `fetchRoot(thread)` | Recovering a root when a reply arrives in a thread the board hasn't seen |
164
167
  | `fetchLink(link)` | Reading a linked message the platform didn't describe |
165
- | `permalink(event)` | A callback's `permalink()`: the platform's own link to the event's message |
168
+ | `permalink(event)` | A sync function's `permalink()`: the platform's own link to the event's message |
169
+ | `inboxChannel(key, thread)` | The platform channel a thread is in (`desk:support` for `desk:support:t_812`), without calling the platform: `sync.channel` going out, which `channels` routes by |
166
170
  | `whoAmI(token)`, `postAs(token, target, kind, body)`, `editAs(…)`, `deleteAs(…)` | Posting as a person, with `users` |
167
171
  | `read(target, { limit })` | A source read on the board that runs the entry: `readSource(key, target)`, `GET /sources/:key/read` |
168
172
  | `mention(actor)` | A board mention in the platform's native form |
169
173
  | `findByEmail(email)` | `@[ada@acme.com]` mentions |
174
+ | `locate(url)`, `url(thread, message)` | Links both ways: `vb read <link>` and `vb sync <id>`. See [Links](#links) |
175
+
176
+ `render(input)` builds the outgoing body when `deliver` gets no renderer;
177
+ override it in a subclass.
170
178
 
171
- `sync(context, channel, overrides)` applies the default import and `render(input)`
172
- builds the outgoing body; override either in a subclass.
179
+ ## Links
180
+
181
+ Two methods let the board go between its messages and the platform's links to
182
+ them. Both use the platform's own thread and message IDs, without the sync key:
183
+
184
+ ```ts
185
+ // in your provider, beside post():
186
+ override locate(url: string) {
187
+ // https://tickets.example.com/t/42#c7 → thread t/42, message c7
188
+ const found = /^https:\/\/tickets\.example\.com\/(t\/\d+)(?:#(c\d+))?$/.exec(url)
189
+ return found ? { thread: found[1]!, message: found[2] ?? null } : null
190
+ }
191
+ override async url(thread: string, message: string) {
192
+ return `https://tickets.example.com/${thread}#${message}`
193
+ }
194
+ ```
195
+
196
+ - **`locate(url)`** reads a link as the platform's thread and message, or
197
+ `message: null` for a link to a thread itself, and null for a link that
198
+ isn't the platform's. It asks the platform nothing: the board looks the IDs
199
+ up in what it synced. `vb read <link>` and `board.linked(url)` use it.
200
+ - **`url(thread, message)`** is the platform's link to one of its messages, or
201
+ null. `vb sync <id>` and `board.sync(id)` show it for each copy.
202
+
203
+ A provider without them still syncs: its links just don't resolve, and its
204
+ copies show their IDs. Slack reads permalinks and asks `chat.getPermalink` for
205
+ one; GitHub reads and makes a PR's, an issue's, and a comment's URLs.
173
206
 
174
207
  ## Through Chat SDK
175
208
 
@@ -181,14 +214,14 @@ and reactions into records, and fetches files with the adapter's
181
214
  ```ts lib/forum.ts
182
215
  import { z } from 'zod'
183
216
  import type { Adapter } from 'chat'
184
- import { envelope, eventSchema, type EventMapping, type ResolvedTarget, type SyncContext } from '@vercel/board/sync'
217
+ import { envelope, eventSchema, type ResolvedTarget, type SyncContext, type SyncFn } from '@vercel/board/sync'
185
218
  import { ChatSyncProvider, type ChatInput } from '@vercel/board/sync/chat'
186
219
 
187
220
  export const ForumEvent = eventSchema('forum', 'message')
188
221
  export type ForumEvent = z.infer<typeof ForumEvent>
189
222
 
190
223
  export class ForumProvider extends ChatSyncProvider<ForumEvent> {
191
- constructor(options: { adapter: Adapter; channels: Record<string, EventMapping<ForumEvent>> }) {
224
+ constructor(options: { adapter: Adapter }, sync: SyncFn<ForumEvent>) {
192
225
  super({
193
226
  platform: 'forum',
194
227
  adapter: options.adapter,
@@ -198,8 +231,7 @@ export class ForumProvider extends ChatSyncProvider<ForumEvent> {
198
231
  const at = target.indexOf(':')
199
232
  return { target, channel: at === -1 ? target : target.slice(0, at), kind: at === -1 ? 'channel' : 'thread' }
200
233
  },
201
- })
202
- for (const [channel, mapping] of Object.entries(options.channels)) this.map(channel, mapping)
234
+ }, sync)
203
235
  }
204
236
 
205
237
  // A reaction has no stable identity in general: use the platform's ID for this occurrence.
@@ -208,7 +240,7 @@ export class ForumProvider extends ChatSyncProvider<ForumEvent> {
208
240
  return `reaction:${key}:${z.object({ id: z.string() }).parse(input.reaction.raw).id}`
209
241
  }
210
242
 
211
- override event(context: Pick<SyncContext, 'id' | 'key' | 'event' | 'raw'>): ForumEvent {
243
+ override event(context: SyncContext): ForumEvent {
212
244
  return ForumEvent.parse(envelope(this.platform, context.key, context.event, context.raw, context.id))
213
245
  }
214
246
  }
@@ -224,5 +256,6 @@ review threads), `channelOf`, `toChatId` and `fromChatId` (ID encodings),
224
256
  `markdown` and `author` (reading a message), `context`, `fetchRoot`, `links`,
225
257
  `read`, `uploadFiles`, `refusesFiles`, `startTyping`, and `intercept` and `missed`
226
258
  (webhook events the adapter doesn't emit). The built-in providers in
227
- `@vercel/board/sync-slack`, `@vercel/board/sync-github`, and `@vercel/board/sync-kapso` are
259
+ `@vercel/board/sync-slack`, `@vercel/board/sync-github`, `@vercel/board/sync-kapso`, and
260
+ `@vercel/board/sync-linear` are
228
261
  working examples of each.