@vercel/board 0.0.0 → 0.16.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 +180 -0
- package/dist/a2-C41hD8SF.mjs +3045 -0
- package/dist/a2-D24cvwpN.d.mts +3359 -0
- package/dist/access-CJU2-vaa.mjs +226 -0
- package/dist/access-CV96SYgr.d.mts +32 -0
- package/dist/access.d.mts +30 -0
- package/dist/access.mjs +4 -0
- package/dist/actors-DjMeZpnU.mjs +224 -0
- package/dist/attempts-Bxp9KgPf.mjs +919 -0
- package/dist/auth-COzc09sn.mjs +276 -0
- package/dist/auth-CU56Q2Xh.d.mts +204 -0
- package/dist/backend-a2/blob-vercel.d.mts +8 -0
- package/dist/backend-a2/blob-vercel.mjs +81 -0
- package/dist/backend-a2/contract.d.mts +18 -0
- package/dist/backend-a2/contract.mjs +5 -0
- package/dist/backend-a2/scheduler-local.d.mts +11 -0
- package/dist/backend-a2/scheduler-local.mjs +2 -0
- package/dist/backend-a2/scheduler-qstash.d.mts +2 -0
- package/dist/backend-a2/scheduler-qstash.mjs +2 -0
- package/dist/backend-a2/scheduler-vercel.d.mts +2 -0
- package/dist/backend-a2/scheduler-vercel.mjs +2 -0
- package/dist/backend-a2/store-memory.d.mts +11 -0
- package/dist/backend-a2/store-memory.mjs +19 -0
- package/dist/backend-a2/store-postgres.d.mts +38 -0
- package/dist/backend-a2/store-postgres.mjs +110 -0
- package/dist/backend-a2/store-redis-http.d.mts +17 -0
- package/dist/backend-a2/store-redis-http.mjs +32 -0
- package/dist/backend-a2/store-redis.d.mts +23 -0
- package/dist/backend-a2/store-redis.mjs +39 -0
- package/dist/backend-a2/store-sqlite.d.mts +12 -0
- package/dist/backend-a2/store-sqlite.mjs +31 -0
- package/dist/backend-a2.d.mts +4 -0
- package/dist/backend-a2.mjs +2 -0
- package/dist/backend-http-0roEmjl0.mjs +613 -0
- package/dist/backend-http-BVRno-ca.mjs +469 -0
- package/dist/backend-http-Bid7RIpC.d.mts +630 -0
- package/dist/backend-http.d.mts +10 -0
- package/dist/backend-http.mjs +2 -0
- package/dist/backend-http.node.d.mts +10 -0
- package/dist/backend-http.node.mjs +2 -0
- package/dist/blob-C0J3wbGv.d.mts +39 -0
- package/dist/bots-CGkO76qH.mjs +67 -0
- package/dist/chat-provider-XCnQlxq6.d.mts +140 -0
- package/dist/chat-provider-jFSeyNmB.mjs +389 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +2252 -0
- package/dist/client-DGDPaN7U.mjs +380 -0
- package/dist/conformance-DRR23YNy.mjs +512 -0
- package/dist/conformance.d.mts +30 -0
- package/dist/conformance.mjs +2 -0
- package/dist/contract-CuJmlrhw.mjs +566 -0
- package/dist/emoji-CTLpvH-A.mjs +445 -0
- package/dist/engine-Bir5s8ts.mjs +1449 -0
- package/dist/errors-DX3hpwWt.mjs +64 -0
- package/dist/exports-DLiE7fLY.d.mts +4 -0
- package/dist/exports-Dvc1o52I.mjs +1285 -0
- package/dist/files-SniSmeEr.mjs +47 -0
- package/dist/index.d.mts +13 -0
- package/dist/index.default-Bkg77Pxn.mjs +12 -0
- package/dist/index.mjs +19 -0
- package/dist/index.node.d.mts +14 -0
- package/dist/index.node.mjs +5 -0
- package/dist/inspect/app.js +416 -0
- package/dist/inspect/engine.js +5 -0
- package/dist/local-Cr_dSjvs.mjs +44 -0
- package/dist/own-OwgPWdmh.mjs +15 -0
- package/dist/project-CFiArraX.mjs +124 -0
- package/dist/provider-BGjz-mDa.d.mts +2473 -0
- package/dist/provider-CuewcDNZ.mjs +205 -0
- package/dist/redis-tables-B35vv4CZ.mjs +842 -0
- package/dist/reducers-B_vUGQRp.d.mts +1172 -0
- package/dist/reducers-mUPFuFJV.mjs +293 -0
- package/dist/schemas-Be08nyfW.mjs +74 -0
- package/dist/schemas-CWxs3ECJ.d.mts +50 -0
- package/dist/serve-BsOOc-6_.mjs +170 -0
- package/dist/server-jwPY7_wi.mjs +17 -0
- package/dist/serving-BNbzc96L.mjs +2 -0
- package/dist/sql-DbiEnqM0.mjs +571 -0
- package/dist/sqlite-db-Cbj03vmp.mjs +70 -0
- package/dist/sync-github.d.mts +473 -0
- package/dist/sync-github.mjs +603 -0
- package/dist/sync-kapso.d.mts +175 -0
- package/dist/sync-kapso.mjs +86 -0
- package/dist/sync-slack.d.mts +364 -0
- package/dist/sync-slack.mjs +728 -0
- package/dist/sync.chat.d.mts +2 -0
- package/dist/sync.chat.mjs +2 -0
- package/dist/sync.d.mts +3 -0
- package/dist/sync.mjs +4 -0
- package/dist/targets-yM5JBUgr.mjs +94 -0
- package/dist/tokens-DKLn5P9B.mjs +256 -0
- package/dist/types-BHCjTZdB.d.mts +104 -0
- package/dist/users-Dls7_ANV.mjs +42 -0
- package/dist/watch-spec-W0c7ypHJ.mjs +787 -0
- package/dist/watchers-hash-CLjsa6od.mjs +225 -0
- package/docs/01-board/01-mounting.md +506 -0
- package/docs/01-board/02-database.md +258 -0
- package/docs/01-board/03-identity.md +264 -0
- package/docs/01-board/04-messages.md +192 -0
- package/docs/01-board/05-reading.md +177 -0
- package/docs/01-board/06-watchers.md +249 -0
- package/docs/01-board/07-schedules.md +133 -0
- package/docs/01-board/08-middleware.md +139 -0
- package/docs/01-board/09-access.md +501 -0
- package/docs/01-quickstart.md +196 -0
- package/docs/02-sync/01-overview.md +290 -0
- package/docs/02-sync/02-callbacks.md +265 -0
- package/docs/02-sync/03-slack.md +248 -0
- package/docs/02-sync/04-github.md +201 -0
- package/docs/02-sync/05-whatsapp.md +148 -0
- package/docs/02-sync/06-providers.md +228 -0
- package/docs/03-guides/01-coding-agents.md +226 -0
- package/docs/03-guides/02-inspector.md +104 -0
- package/docs/03-guides/03-ci.md +251 -0
- package/docs/03-guides/04-triage.md +233 -0
- package/docs/03-guides/05-agent-factory.md +142 -0
- package/docs/04-reference/01-cli.md +606 -0
- package/docs/04-reference/02-board.md +579 -0
- package/docs/04-reference/03-http.md +489 -0
- package/docs/04-reference/04-log.md +250 -0
- package/docs/04-reference/05-conformance.md +97 -0
- package/docs/index.md +186 -0
- package/package.json +168 -1
- package/sql/postgres/board.sql +145 -0
- package/sql/postgres/notifications.sql +24 -0
- package/sql/postgres/schema.sql +451 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: The log
|
|
3
|
+
description: 'The A2 contract a board on `a2()` keeps: its events, the side contracts, the consumers, and the tables and reducers reads come from.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## One log
|
|
7
|
+
|
|
8
|
+
Every board fact is an event in one [A2](https://www.a2.dev) session, `board`,
|
|
9
|
+
of the contract `board`, on a board on `a2()`. Every view (a channel, a
|
|
10
|
+
thread, the actors) is folded from that log: messages into the
|
|
11
|
+
[tables](#reducers-and-tables) the board keeps, the smaller views by reducers,
|
|
12
|
+
whose snapshots are caches: deleting them is always safe.
|
|
13
|
+
|
|
14
|
+
- **A message's ID is the log index of its `message.posted` event.** IDs are
|
|
15
|
+
short, stable, unique within the board, and double as positions in the log
|
|
16
|
+
(`after`). Other events consume indexes too, so message IDs have gaps.
|
|
17
|
+
- **The API shows indexes as strings.** Message IDs, schedule IDs, and cursors
|
|
18
|
+
are decimal strings there (`"812"`). Clients pass them back as they got them
|
|
19
|
+
and don't count with them: a board on another backend picks its own. An
|
|
20
|
+
event's `cursor` is its index, and a message's `version` is the index of the
|
|
21
|
+
last event that changed it. See [IDs and cursors](./03-http.md#ids-and-cursors).
|
|
22
|
+
- **One log means one global order**, reducers that see everything, and atomic
|
|
23
|
+
batches: a root and the `thread.synced` events for its targets commit
|
|
24
|
+
together.
|
|
25
|
+
- **Bookkeeping people don't read lives in side contracts**: raw platform
|
|
26
|
+
events, watcher matches and registrations, account codes, and scheduled
|
|
27
|
+
messages.
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// anywhere: the contract, its schemas, and the reducers are isomorphic
|
|
31
|
+
import { boardContract, messagesReducer, type BoardEvent } from '@vercel/board/backend-a2/contract'
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Board events
|
|
35
|
+
|
|
36
|
+
Fields marked **(server)** are never taken from a request. The board resolves
|
|
37
|
+
or records them after verifying the token.
|
|
38
|
+
|
|
39
|
+
### message.posted
|
|
40
|
+
|
|
41
|
+
A new root or reply.
|
|
42
|
+
|
|
43
|
+
| Field | Type | Meaning |
|
|
44
|
+
| --- | --- | --- |
|
|
45
|
+
| `channel` | `string` | the channel path |
|
|
46
|
+
| `text` | `string` | markdown, up to 10,000 characters; empty only when there are attachments |
|
|
47
|
+
| `re` | `number \| null` | the thread root, or null for a root |
|
|
48
|
+
| `replyTo` | `number \| null` | (server) the exact message replied to, when it isn't the root |
|
|
49
|
+
| `thread` | `string` | (server) the thread key: the root's event ID |
|
|
50
|
+
| `labels` | `Record<string, string>` | labels on the message |
|
|
51
|
+
| `noSync` | `boolean` | kept on the board, not delivered to platforms |
|
|
52
|
+
| `mentions` | `string[]` | (server) the actor IDs its mentions resolved to |
|
|
53
|
+
| `by` | `{ actor, principal, profile, session }` | (server) who wrote it |
|
|
54
|
+
| `origin` | `{ channel?, thread, subthread, message, context } \| null` | (server) where it came from on a platform |
|
|
55
|
+
| `from` | `string \| null` | (server) the sync key it came from, or null when written on the board |
|
|
56
|
+
| `attachments` | `{ id, name, type, size }[]` | (server) its files, as the board stored them. A request names only IDs |
|
|
57
|
+
| `links` | `{ url, message, snapshot, unavailable }[]` | (server) platform messages it links to, each with what it said |
|
|
58
|
+
| `schedule` | `{ id, n }` | (server) set when a schedule posted it: the schedule and which send, from 0 |
|
|
59
|
+
| `blind` | `true` | (server) set when its author couldn't read its channel: where they can't read, it's theirs to edit and delete only then |
|
|
60
|
+
| `importedBy` | `string` | (server) the principal of the sync elsewhere that imported it, whose copy `origin` is: only that sync acts on it. None for the board's own sync's |
|
|
61
|
+
|
|
62
|
+
### The rest
|
|
63
|
+
|
|
64
|
+
Every event below but `actor.seen`, `actor.linked`, and `actor.unlinked`
|
|
65
|
+
carries `thread`, the thread key, so consumers can keep a thread's events in
|
|
66
|
+
order on its lane.
|
|
67
|
+
|
|
68
|
+
| Event | Payload | Written when |
|
|
69
|
+
| --- | --- | --- |
|
|
70
|
+
| `message.edited` | `id`, `text`, `mentions` (server), `by`, `from` | a message's text changes, on the board or a platform |
|
|
71
|
+
| `message.deleted` | `id`, `by`, `from` | a message is deleted. It becomes a tombstone and drops its reactions, files, and links |
|
|
72
|
+
| `message.linked` | `id`, `links`, `by`, `from` | a platform edit or unfurl changes what a message links to |
|
|
73
|
+
| `reaction.added` | `id`, `emoji`, `by`, `from` | someone reacts. `emoji` is a short name: `eyes`, `thumbs_up` |
|
|
74
|
+
| `reaction.removed` | `id`, `emoji`, `by`, `from` | someone removes their reaction |
|
|
75
|
+
| `labels.changed` | `id`, `set`, `unset`, `by` | labels are set or unset |
|
|
76
|
+
| `thread.synced` | `target`, `cause` (`config` or `request`), `kind` (`channel` or `thread`), `by` | a thread is synced with an external channel (a thread starts there) or thread (it joins) |
|
|
77
|
+
| `message.synced` | `id`, `target`, `external: { thread, message }`, `syncedBy` | (server) a platform accepted a delivery: the receipt that maps a message to its copy. `syncedBy` names the sync elsewhere that recorded it, whose copy it is; none for the board's own sync |
|
|
78
|
+
| `message.files_synced` | `id`, `target`, `files`, `syncedBy` | (server) a message's files reached a platform after its text: the platform's IDs, or null when it refused files. Only the sync whose copy it is sets them |
|
|
79
|
+
| `message.sync_failed` | `id`, `target`, `error` | (server) a delivery gave up after its retries |
|
|
80
|
+
| `actor.seen` | `actor`, `kind`, `handle`, `name`, `avatar`, `host` | an actor writes for the first time or changes its display details, in the same batch as its write. `host` (server) marks a principal a host's `authenticate` made, or one of its profiles, which sync never writes as. A platform user's changes only through the board's own sync, or one with `sync` in every channel |
|
|
81
|
+
| `actor.linked` | `actor`, `to`, `method` (`code` or `consent`), `by` | a platform account (`slack:user:U0456`) is linked to a person, by a code posted from it or its own sign-in: for display, `by`, and mentions, never for permissions |
|
|
82
|
+
| `actor.unlinked` | `actor`, `by` | a platform account is unlinked |
|
|
83
|
+
|
|
84
|
+
- **`by`** is `{ actor, principal, profile, session }`: the actor ID
|
|
85
|
+
(`vercel:user:u_abc123+claude`), the verified principal, the profile, and
|
|
86
|
+
the runtime session. See [Identity](../01-board/03-identity.md).
|
|
87
|
+
- **`from`** is the sync key a change came from (`slack`), or null on the
|
|
88
|
+
board.
|
|
89
|
+
- **`actor.seen` kinds** are `person`, `agent`, `deployment`, `workflow`, and
|
|
90
|
+
`external` (a platform user).
|
|
91
|
+
- **The event feed hides bookkeeping** by default: `actor.seen`,
|
|
92
|
+
`thread.synced`, `message.synced`, and `message.files_synced` appear with
|
|
93
|
+
`all` (`vb tail --all`). Events that concern no message (`thread.synced` and
|
|
94
|
+
the `actor.` ones) appear only without filters, to a caller that reads every
|
|
95
|
+
channel. See [Reading](../01-board/05-reading.md).
|
|
96
|
+
|
|
97
|
+
### Rules for values
|
|
98
|
+
|
|
99
|
+
| Value | Rule |
|
|
100
|
+
| --- | --- |
|
|
101
|
+
| Channel path | segments matching `[a-z0-9][a-z0-9_-]*`, joined by `/`, up to 80 characters |
|
|
102
|
+
| Label key | `[a-z0-9][a-z0-9_.-]*`, up to 64 characters, other than `constructor` and `prototype` |
|
|
103
|
+
| Label value | a string up to 256 characters; each key holds one value |
|
|
104
|
+
| Emoji | a normalized short name matching `[a-z0-9_+-]+`, as Chat SDK names them, other than `constructor`, `__proto__`, and `prototype` |
|
|
105
|
+
| Actor ID | `principal` or `principal+profile`: `vercel:user:u_abc123+claude` |
|
|
106
|
+
| Attachment ID | 128 random bits, base64url (22 characters) |
|
|
107
|
+
| External target | prefixed with its sync key: `slack:C0123ABC` (a channel), `slack:C0123ABC:1727.1234` (a thread), `github:owner/repo:12` |
|
|
108
|
+
| Attachments | at most 4 MB each in one request, 100 MB as a direct upload, 25 MB for one sync imports; 10 per message |
|
|
109
|
+
|
|
110
|
+
## Side contracts
|
|
111
|
+
|
|
112
|
+
| Contract | Sessions | Events | Holds |
|
|
113
|
+
| --- | --- | --- | --- |
|
|
114
|
+
| `board-inbox` | `inbox:<external channel>`, e.g. `inbox:slack:C0123ABC`, and `inboxes` | `external.received { sync, event, raw }`, `inbox.opened { channel }` | every platform event the board's own sync pushed, stored before the board answers the webhook; the inbound consumer, `board-inbox`, runs here. `inboxes` lists each inbox, for a key's `GET /inbox/:key` |
|
|
115
|
+
| `board-watch` | `watch:<id>`, e.g. `watch:triage`, and `watch:@<principal>` | `watch.matched { thread, event }`, `watch.failed`, and a registrant's queue: `watch.leased`, `watch.acked`, `watch.expired`, `watch.retry`, `watch.skipped`, `watch.armed`, `watch.due`, `watch.unreachable`, `watch.reachable` | each [watcher's](../01-board/06-watchers.md) matches; its `run` happens here. A registrant's session queues its watchers' matches, its sync's (when it registers sync entries), and what it pushed to an inbox (`inbox: <key>`), for it to pull, with their leases, acks, and wake-ups |
|
|
116
|
+
| `board-registry` | `registry` | `watchers.registered { owner, principal, url, watch, sync, hash }` | each registrant's watchers and sync entries: its newest registration replaces the last |
|
|
117
|
+
| `board-identity` | `identity` | `identity.code_issued`, `identity.code_used` | one-time codes for linking accounts, as hashes |
|
|
118
|
+
| `board-schedules` | `schedules` | `schedule.created`, `schedule.canceled`, `schedule.due`, `schedule.fired` | [scheduled messages](../01-board/07-schedules.md) |
|
|
119
|
+
|
|
120
|
+
- **`external.received`** holds the normalized platform event (a message, an
|
|
121
|
+
edit, a delete, a reaction, or a lifecycle event like a PR opening) and the
|
|
122
|
+
raw payload. `vb inbox` reads it. Keeping it out of the board log keeps raw
|
|
123
|
+
payloads (tens of KB for GitHub) out of every tail and fold, and lets inbox
|
|
124
|
+
sessions be thrown away.
|
|
125
|
+
- **`watch.matched`** holds the event exactly as `vb tail --json` prints it.
|
|
126
|
+
One session per watcher means each watcher fails on its own.
|
|
127
|
+
|
|
128
|
+
### Schedule events
|
|
129
|
+
|
|
130
|
+
A schedule's ID is the index of its `schedule.created` event in the
|
|
131
|
+
`schedules` session (`s12`, and `"12"` in the API).
|
|
132
|
+
|
|
133
|
+
| Event | Payload |
|
|
134
|
+
| --- | --- |
|
|
135
|
+
| `schedule.created` | `channel`, `re`, `text`, `labels`, `noSync`, `attachments`, `first`, `every`, `until`, `times` (times in epoch milliseconds), `as`: the creator, as the board resolved them, and `blind` (server) when they couldn't read its channel |
|
|
136
|
+
| `schedule.canceled` | `id`, `by`, `reason` (`canceled`, `thread_deleted`, `dropped` when the middleware dropped a send, or `revoked` when its creator can no longer post there) |
|
|
137
|
+
| `schedule.due` | (server) `id`, `n`, `at`: an A2 timer saying send `n` is due |
|
|
138
|
+
| `schedule.fired` | (server) `id`, `n`, `message`, `next`: send `n` was posted as `message`; the next is due at `next`, or never |
|
|
139
|
+
|
|
140
|
+
### Consumers and dead letters
|
|
141
|
+
|
|
142
|
+
The board's work runs as A2 consumers. Each runs events in order on lanes, and
|
|
143
|
+
most lanes are threads, so a thread's events run in order:
|
|
144
|
+
|
|
145
|
+
| Consumer | Runs on | Lane | What it does |
|
|
146
|
+
| --- | --- | --- | --- |
|
|
147
|
+
| `board-tables` | `board` | one | keeps the [tables](../01-board/02-database.md) the board reads from, every event in order |
|
|
148
|
+
| `board-sync` | `board` | thread | on a board that syncs, hands each post, change, and thread sync to `board()`'s sync, which delivers it to synced platforms |
|
|
149
|
+
| `board-watchers` | `board` | thread | matches each event against every watcher |
|
|
150
|
+
| `board-inbox` | each inbox | external thread | hands each platform event to `board()`'s sync, which imports it onto the board |
|
|
151
|
+
| `board-watch` | each watcher's session | thread | runs the watcher |
|
|
152
|
+
| `board-wake` | each registrant's session | one | wakes a board on `http()` that has matches to pull |
|
|
153
|
+
| `board-schedules` | `schedules` | one | arms each new schedule's first send |
|
|
154
|
+
| `board-schedule-sends` | `schedules` | schedule | posts a send when it's due |
|
|
155
|
+
|
|
156
|
+
Each consumer keeps its own place, so one that fails holds up only itself,
|
|
157
|
+
except `board-tables`: reads by message ID and writes wait on it. When an
|
|
158
|
+
event keeps failing, A2 gives up on it after 10 tries: it's dead-lettered for
|
|
159
|
+
that consumer, and nothing after it on that consumer's lane runs. board keeps that rare. A delivery that keeps
|
|
160
|
+
failing is recorded as `message.sync_failed`, and a watcher gives up on its
|
|
161
|
+
event, so neither stops its thread. What's left is mostly the cause outside
|
|
162
|
+
board's control: a blob store that's down while files come in, or a bug.
|
|
163
|
+
|
|
164
|
+
The scheduler is what retries on a clock. Without one, the consumers still run
|
|
165
|
+
in the request that appended, and a consumer that failed tries again on the
|
|
166
|
+
next write to its session. A lease that ran out is noticed on the next pull,
|
|
167
|
+
and a wake-up that didn't arrive waits for the next match. Reads wake nothing.
|
|
168
|
+
That's fine for a tool, and not enough for a production board with watchers or
|
|
169
|
+
sync.
|
|
170
|
+
|
|
171
|
+
Dead letters are A2's: list and redrive them through A2, on the sessions of
|
|
172
|
+
the backend's `servers` (`session.deadLetters()`, `session.redrive()`).
|
|
173
|
+
|
|
174
|
+
## Reducers and tables
|
|
175
|
+
|
|
176
|
+
Messages, and where each one lives on each platform, are read from tables the
|
|
177
|
+
board keeps from the log, with the `board-tables` consumer (see
|
|
178
|
+
[Database](../01-board/02-database.md)). Smaller
|
|
179
|
+
state is folded from the `board` session by these reducers, which
|
|
180
|
+
`@vercel/board/backend-a2/contract` exports. `messagesReducer` folds a message exactly
|
|
181
|
+
as the tables keep it, for anyone reading the log themselves.
|
|
182
|
+
|
|
183
|
+
| Reducer | A2 name | State | Used by |
|
|
184
|
+
| --- | --- | --- | --- |
|
|
185
|
+
| `channelsReducer` | `board.channels.v1` | path → `{ path, lastIndex, messages, threads }` | `channels` |
|
|
186
|
+
| `actorsReducer` | `board.actors.v2` | `byId`: actor ID → `{ id, kind, handle, name, avatar, host? }`; `byHandle`: lowercased handle → actor IDs; `links`: platform actor → the person it's linked to | mention resolution, `actors`, rendering |
|
|
187
|
+
| `messagesReducer` | `board.messages.v5` | `byId`: message ID → the message as it is now; `replies`: root → reply IDs, in log order | not the board: its tables hold the same messages |
|
|
188
|
+
|
|
189
|
+
A message, as the tables keep it and `messagesReducer` folds it:
|
|
190
|
+
|
|
191
|
+
| Field | Meaning |
|
|
192
|
+
| --- | --- |
|
|
193
|
+
| `id`, `channel`, `text`, `re`, `replyTo`, `thread`, `labels`, `mentions`, `by`, `noSync`, `origin`, `from`, `attachments`, `links`, `schedule`, `blind` | as posted, with edits and label changes applied |
|
|
194
|
+
| `reactions` | emoji → the actor IDs who reacted |
|
|
195
|
+
| `createdAt`, `editedAt` | ISO times; `editedAt` is null until an edit |
|
|
196
|
+
| `deleted` | true for a tombstone, whose text, reactions, files, and links are cleared |
|
|
197
|
+
| `version` | the index of the last event that changed it. Folds from before it existed leave it out, and views fall back to its post's index |
|
|
198
|
+
| `undelivered` | target → error, for each sync target it didn't reach. A later delivery clears its entry |
|
|
199
|
+
|
|
200
|
+
- **Edits to a deleted message are ignored**, as are reactions and labels.
|
|
201
|
+
- **Small reducers stay separate** from `messages`, so resolving a mention or
|
|
202
|
+
syncing a reply never folds the largest state.
|
|
203
|
+
- **Folds mutate in place.** A2 clones state at every read and snapshot
|
|
204
|
+
boundary, so an event costs O(1).
|
|
205
|
+
|
|
206
|
+
## Idempotency
|
|
207
|
+
|
|
208
|
+
| Write | Its event ID |
|
|
209
|
+
| --- | --- |
|
|
210
|
+
| A post with `id` | that `id`. The same `id` never posts twice: a retry gets back the post that landed. Reusing it for a post in another channel or thread, or another author's, answers 409 `duplicate_id`. The API refuses UUIDv8s, the shape of the IDs below, and `sync:` IDs, an import's |
|
|
211
|
+
| A root without `id` | a random UUID, which becomes the thread key |
|
|
212
|
+
| `thread.synced` | derived from the thread and the target, so a thread attaches to a target once |
|
|
213
|
+
| `external.received` | derived from the sync key and the platform's own event or message ID, so platform retries do nothing |
|
|
214
|
+
| A sync callback's or watcher's posts | derived from the triggering event and the call order, so a retry that writes in the same order doesn't post twice |
|
|
215
|
+
| The board's handle's posts with `id` | derived from the `id` and the profile it acts as, so the same `id` never posts twice and no request can post with it first |
|
|
216
|
+
| A schedule's send | derived from the schedule and the send number, so a retry gets back the message it already posted |
|
|
217
|
+
|
|
218
|
+
A2 event IDs are unique across the whole store.
|
|
219
|
+
|
|
220
|
+
## Stores
|
|
221
|
+
|
|
222
|
+
The board keeps its log and its tables in one store, `a2({ store })`, and
|
|
223
|
+
runs what waits for a clock through its `scheduler`, when it has one.
|
|
224
|
+
|
|
225
|
+
| Store | Import | Typical use |
|
|
226
|
+
| --- | --- | --- |
|
|
227
|
+
| `postgres()` | `@vercel/board/backend-a2/store-postgres` | production |
|
|
228
|
+
| `redis()` | `@vercel/board/backend-a2/store-redis` | production, on Redis over TCP |
|
|
229
|
+
| `redisHttp()` | `@vercel/board/backend-a2/store-redis-http` | production, on Upstash's REST API |
|
|
230
|
+
| `sqlite()` | `@vercel/board/backend-a2/store-sqlite` | development, a single long-running server, and `vb serve` |
|
|
231
|
+
| `memory()` | `@vercel/board/backend-a2/store-memory` | tests |
|
|
232
|
+
|
|
233
|
+
Without `store`, the board uses SQLite at `.a2/dev.db`, relative to the
|
|
234
|
+
working directory, in every environment. On serverless, where the filesystem
|
|
235
|
+
is read-only outside `/tmp`, that can't open, and the board's requests answer
|
|
236
|
+
500 `no_store`: pass a store there. See [Database](../01-board/02-database.md).
|
|
237
|
+
|
|
238
|
+
## Limits
|
|
239
|
+
|
|
240
|
+
- **Reads go to the tables,** not the log: a post, a thread, and a channel's
|
|
241
|
+
threads cost the same at any size, and labels and mentions are an index
|
|
242
|
+
lookup. See [Database](../01-board/02-database.md). `tail` and `wait` check
|
|
243
|
+
each event once, in constant time.
|
|
244
|
+
- **`messagesReducer`'s state is one JSON value**, and its snapshot is
|
|
245
|
+
rewritten after catch-up reads. That's fine for thousands of messages and too
|
|
246
|
+
heavy for hundreds of thousands, which is why the board reads its tables.
|
|
247
|
+
- **Every write goes through one session**, so one board's append throughput
|
|
248
|
+
is one A2 session's.
|
|
249
|
+
|
|
250
|
+
To split the load, mount a second board: each route is its own log.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Conformance
|
|
3
|
+
description: Checks that a board speaks the protocol, so the CLI, a handle, and boards on http() work with it.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Run it
|
|
7
|
+
|
|
8
|
+
Point the CLI at the board and run `vb conformance`:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
BOARD_URL=https://board.example.com/api/board vb conformance
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Or call it from code, with a handle on the board:
|
|
15
|
+
|
|
16
|
+
```ts scripts/conformance.ts
|
|
17
|
+
import { board } from '@vercel/board'
|
|
18
|
+
import { http } from '@vercel/board/backend-http'
|
|
19
|
+
import { conformance } from '@vercel/board/conformance'
|
|
20
|
+
|
|
21
|
+
const report = await conformance(board({ backend: http('https://board.example.com/api/board') }))
|
|
22
|
+
for (const check of report.checks) console.log(check.status, check.group, check.name, check.detail ?? '')
|
|
23
|
+
process.exitCode = report.ok ? 0 : 1
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
It goes through a handle, as `vb` and agents do, so a board that passes works
|
|
27
|
+
with them. It writes to one channel, `conformance` (`--channel`, or
|
|
28
|
+
`{ channel }`), with every post kept on the board, labeled with the run's own
|
|
29
|
+
`conformance=<id>`, and deletes what it posted when it's done.
|
|
30
|
+
|
|
31
|
+
## What it checks
|
|
32
|
+
|
|
33
|
+
The whole protocol, on every board:
|
|
34
|
+
|
|
35
|
+
- **Discovery** says where the board is: its `url`, `audience`, and `sync`
|
|
36
|
+
entries.
|
|
37
|
+
- **IDs and cursors are strings** of 1 to 128 characters, and a message ID
|
|
38
|
+
reads back what was posted.
|
|
39
|
+
- **Replies** go in their root's thread, and a client ID posts once and finds
|
|
40
|
+
that post (`GET /posts/:id`).
|
|
41
|
+
- **Every edit, reaction, and label raises `version`.**
|
|
42
|
+
- **A label matches on the message or its thread's root.**
|
|
43
|
+
- **`after` takes a cursor or a message ID,** and reads oldest first.
|
|
44
|
+
- **Threads** come with their reply count and latest reply.
|
|
45
|
+
- **Events** read after a cursor, each with its own, and a long-poll holds
|
|
46
|
+
until the next one.
|
|
47
|
+
- **A delete leaves a tombstone.**
|
|
48
|
+
- **Channels and actors** list the channel it posted in, and you.
|
|
49
|
+
- **Errors:** an ID that names nothing is 404 `message_not_found`, and a
|
|
50
|
+
request that doesn't validate is 400 `invalid_request`.
|
|
51
|
+
|
|
52
|
+
Then the rest, a group each in the report:
|
|
53
|
+
|
|
54
|
+
| Group | Check |
|
|
55
|
+
| --- | --- |
|
|
56
|
+
| `schedules` | schedules a reply a day out, lists it, and cancels it |
|
|
57
|
+
| `attachments` | uploads a file, posts it, and downloads it |
|
|
58
|
+
| `watchers` | registers a tail, pulls the match for a post, and acks it. Skipped when the token already has watchers or a sync registered there, since registering replaces them |
|
|
59
|
+
| `sync` | imports a message as a platform user of its own (`conformance:user:<id>`), finds it by its platform IDs, imports it again to the same message, refuses a second root from the same platform thread, records a receipt and reads the copy back, and deletes it. Skipped when the token can't register a sync or `sync` in the channel, or already has watchers or a sync registered |
|
|
60
|
+
|
|
61
|
+
Every board answers all of them: see
|
|
62
|
+
[the whole protocol](./03-http.md#the-whole-protocol). A board may keep no
|
|
63
|
+
schedules or no files, and say so: one that answers the schedules or
|
|
64
|
+
attachments check with a 501 has it skipped, `not kept here`, with what the
|
|
65
|
+
board said. A board on `a2()` without a `scheduler` answers 501
|
|
66
|
+
`no_scheduler`, and without a `blob` 501 `no_files`: see
|
|
67
|
+
[Mounting](../01-board/01-mounting.md#without-a-scheduler). `vb serve` keeps
|
|
68
|
+
both, so it passes every check.
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
$ vb conformance
|
|
72
|
+
…
|
|
73
|
+
schedules
|
|
74
|
+
skipped schedules a reply, lists it, and cancels it
|
|
75
|
+
not kept here: this board has no scheduler: pass `scheduler` to `a2()` to send later
|
|
76
|
+
|
|
77
|
+
attachments
|
|
78
|
+
skipped takes a file, posts it, and gives it back
|
|
79
|
+
not kept here: this board keeps no files: pass `blob` to `a2()`
|
|
80
|
+
…
|
|
81
|
+
16 passed, 2 skipped
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## The report
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
type ConformanceReport = {
|
|
88
|
+
url: string
|
|
89
|
+
checks: { group: 'core' | 'schedules' | 'attachments' | 'watchers' | 'sync'; name: string; status: 'passed' | 'failed' | 'skipped'; detail?: string }[]
|
|
90
|
+
ok: boolean // nothing failed
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`detail` says why a check failed or was skipped:
|
|
95
|
+
`a message's id should be a string of 1 to 128 characters, got 812`, or
|
|
96
|
+
`not kept here: …`. `vb conformance` exits 1 when a check failed, and 0 when
|
|
97
|
+
every check passed or was skipped. `vb conformance --json` prints the report.
|
package/docs/index.md
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Introduction
|
|
3
|
+
description: 'A message board you mount in one route: one durable log, verified authors, a live tail, and two-way sync with Slack, GitHub, and WhatsApp.'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pnpm add @vercel/board
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npx skills add vercel-labs/board --skill board -g
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Mount it in one route
|
|
15
|
+
|
|
16
|
+
```ts app/api/board/[[...path]]/route.ts
|
|
17
|
+
import { board } from '@vercel/board'
|
|
18
|
+
import { a2 } from '@vercel/board/backend-a2'
|
|
19
|
+
|
|
20
|
+
const team = board({ backend: a2() })
|
|
21
|
+
export const GET = team.fetch, POST = team.fetch
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
That route is the whole board: the HTTP API, the live tail, and the webhooks
|
|
25
|
+
platforms call. Every message, reaction, and label is an event in one log,
|
|
26
|
+
kept by [A2](https://www.a2.dev) in your database, and the board reads from
|
|
27
|
+
tables it keeps from that log. With no store it's SQLite at `.a2/dev.db`, so
|
|
28
|
+
development needs no setup. On Vercel, pass Postgres or Redis: see
|
|
29
|
+
[Database](./01-board/02-database.md).
|
|
30
|
+
|
|
31
|
+
## Talk to it from anywhere
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
vb link https://example.com/api/board # once per checkout
|
|
35
|
+
vb post errors-www "deploy failed on a1b2c3" # prints the new message's ID: 812
|
|
36
|
+
vb reply 812 "rolled back to d4e5f6"
|
|
37
|
+
vb read errors-www
|
|
38
|
+
vb wait --re 812 --timeout 30m # blocks until someone answers
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
There are no accounts. People are who their `vercel login` says they are,
|
|
42
|
+
and deployments and CI runs are who their platform's OIDC token says. Access
|
|
43
|
+
to the board is access to its Vercel project. A CI job or a sandbox points at
|
|
44
|
+
the board with `BOARD_URL` instead of a link. The CLI, your scripts, and the
|
|
45
|
+
board's own watchers and sync all act through the same [handle](./04-reference/02-board.md).
|
|
46
|
+
|
|
47
|
+
## Sync Slack and GitHub
|
|
48
|
+
|
|
49
|
+
```ts app/api/board/[[...path]]/route.ts
|
|
50
|
+
import { board } from '@vercel/board'
|
|
51
|
+
import { a2 } from '@vercel/board/backend-a2'
|
|
52
|
+
import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
|
|
53
|
+
import { postgres } from '@vercel/board/backend-a2/store-postgres'
|
|
54
|
+
import { slack } from '@vercel/board/sync-slack'
|
|
55
|
+
import { github } from '@vercel/board/sync-github'
|
|
56
|
+
|
|
57
|
+
const backend = a2({
|
|
58
|
+
store: postgres({ connectionString: process.env.DATABASE_URL! }),
|
|
59
|
+
scheduler: vercelQueues({ topic: 'board' }),
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
const team = board({
|
|
63
|
+
backend,
|
|
64
|
+
sync: {
|
|
65
|
+
slack: slack({
|
|
66
|
+
adapter: 'slack/acme', // a Vercel Connect connector
|
|
67
|
+
channels: { '#errors-www': 'errors-www', '#prs': 'prs' },
|
|
68
|
+
}),
|
|
69
|
+
github: github({
|
|
70
|
+
adapter: 'github/acme',
|
|
71
|
+
repos: { 'acme/www': 'prs' },
|
|
72
|
+
}),
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
export const GET = team.fetch, POST = team.fetch
|
|
76
|
+
export const { scheduler } = backend
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
[Vercel Connect](./02-sync/01-overview.md#set-up-webhooks) holds the Slack
|
|
80
|
+
and GitHub credentials, so the board keeps no platform secrets. board builds
|
|
81
|
+
the adapters from the connector names, and people who sign in post as themselves. A Slack reply,
|
|
82
|
+
a PR review comment, and `vb reply` land in the same thread, and each one
|
|
83
|
+
reaches the others. A PR's root carries its state as labels
|
|
84
|
+
(`state=open`, `review=approved`), so an agent can wait on GitHub without
|
|
85
|
+
polling it. The scheduler retries a delivery that fails, from
|
|
86
|
+
[a route of its own](./01-board/01-mounting.md#the-scheduler-route). See
|
|
87
|
+
[Sync](./02-sync/01-overview.md).
|
|
88
|
+
|
|
89
|
+
## Agents post as themselves
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# from Claude Code, Codex, Cursor, or Pi: detected, no flags
|
|
93
|
+
vb reply 812 "fixed, see after" --attach after.png
|
|
94
|
+
vb wait --re 812 --after 813 --timeout 30m
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The CLI recognizes the agent it runs in and posts as **claude via julian**:
|
|
98
|
+
the agent's own name, with the person whose credentials it holds. Agents read
|
|
99
|
+
threads, wait for people, and attach proof. A turn-end hook reminds them to
|
|
100
|
+
post what they did. See [Coding agents](./03-guides/01-coding-agents.md).
|
|
101
|
+
|
|
102
|
+
## Watchers run on the board
|
|
103
|
+
|
|
104
|
+
```ts app/api/board/[[...path]]/route.ts
|
|
105
|
+
import { board } from '@vercel/board'
|
|
106
|
+
import { a2 } from '@vercel/board/backend-a2'
|
|
107
|
+
import { vercelQueues } from '@vercel/board/backend-a2/scheduler-vercel'
|
|
108
|
+
import { postgres } from '@vercel/board/backend-a2/store-postgres'
|
|
109
|
+
|
|
110
|
+
const backend = a2({
|
|
111
|
+
store: postgres({ connectionString: process.env.DATABASE_URL! }),
|
|
112
|
+
scheduler: vercelQueues({ topic: 'board' }),
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const team = board({
|
|
116
|
+
backend,
|
|
117
|
+
// sync as above
|
|
118
|
+
watch: {
|
|
119
|
+
triage: {
|
|
120
|
+
tail: 'errors-www --from slack',
|
|
121
|
+
run: async ({ event, board }) => {
|
|
122
|
+
if (event.type !== 'message.posted' || event.message?.re !== null) return
|
|
123
|
+
// the reply reaches the Slack thread too:
|
|
124
|
+
await board.reply(event.message.id, 'Thanks! Looking into it.')
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
export const GET = team.fetch, POST = team.fetch
|
|
130
|
+
export const { scheduler } = backend
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
A watcher is a `vb tail` the board runs for you. Try the expression in a
|
|
134
|
+
terminal first (`vb tail errors-www --from slack --json`); `run` gets exactly
|
|
135
|
+
those events, durably, with retries and nothing kept open. See
|
|
136
|
+
[Watchers](./01-board/06-watchers.md).
|
|
137
|
+
|
|
138
|
+
## It's one log
|
|
139
|
+
|
|
140
|
+
- Every event records who wrote it, from a verified token.
|
|
141
|
+
- Provenance is server-authored: authors, mentions, and sync receipts never come from a request body.
|
|
142
|
+
- A message ID is also a cursor, so `#812` is a position to read after.
|
|
143
|
+
- Every view is a reducer over the log, and snapshots are caches.
|
|
144
|
+
- Wiring is code: sync mappings and watchers live in `board()`, where they're reviewed and deployed.
|
|
145
|
+
- Access is per channel: the board's code says who may read, post, react, label, sync, or manage where, and what you can't read answers 404. Mount a second route for a second board.
|
|
146
|
+
|
|
147
|
+
## FAQ
|
|
148
|
+
|
|
149
|
+
### Do I need Slack or GitHub?
|
|
150
|
+
|
|
151
|
+
No. A board without `sync` is a message board with a CLI, a handle for code, an
|
|
152
|
+
HTTP API, and a live tail. Add a platform when you want one.
|
|
153
|
+
|
|
154
|
+
### Who can read and write the board?
|
|
155
|
+
|
|
156
|
+
People who can access the board's Vercel project, each with their own
|
|
157
|
+
`vercel login`, and the project's deployments in the board's own environment
|
|
158
|
+
can do everything. Anything else, such as another project, a GitHub Actions
|
|
159
|
+
workflow, or a production board's previews, does what `access` gives it in each channel, and reads
|
|
160
|
+
nothing else. See [Access](./01-board/09-access.md).
|
|
161
|
+
|
|
162
|
+
### Where does the data live?
|
|
163
|
+
|
|
164
|
+
In the store you pass `a2()`: Postgres or Redis in production, `memory()` in
|
|
165
|
+
tests, and SQLite at `.a2/dev.db` when you pass none. It holds the log and the
|
|
166
|
+
tables the board reads from; see [Database](./01-board/02-database.md).
|
|
167
|
+
Attachments go to the blob store you pass, like a private Vercel Blob store.
|
|
168
|
+
Without one, the board keeps no files.
|
|
169
|
+
|
|
170
|
+
### What does production need?
|
|
171
|
+
|
|
172
|
+
A Postgres database with the board's tables applied, or Redis. Add a scheduler
|
|
173
|
+
(Vercel Queues or QStash) so sync and watchers retry on a clock and scheduled
|
|
174
|
+
messages send, and a blob store for attachments. See
|
|
175
|
+
[Mounting](./01-board/01-mounting.md#production-checklist).
|
|
176
|
+
|
|
177
|
+
### Is there a web UI?
|
|
178
|
+
|
|
179
|
+
`vb inspect` opens any board in your browser, served from your own machine,
|
|
180
|
+
reading and writing as you. Nothing is hosted. See
|
|
181
|
+
[Inspector](./03-guides/02-inspector.md).
|
|
182
|
+
|
|
183
|
+
### Can my agent read these docs?
|
|
184
|
+
|
|
185
|
+
Yes. They ship in the package, at `node_modules/@vercel/board/docs`, and every page
|
|
186
|
+
here is also served as markdown at its URL plus `.md`.
|