@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
|
@@ -1,919 +0,0 @@
|
|
|
1
|
-
import { n as hasCriteria } from "./watch-spec-W0c7ypHJ.mjs";
|
|
2
|
-
import { a as forbidden, i as duplicateId, r as badRequest, s as notFound, t as HttpError } from "./errors-DX3hpwWt.mjs";
|
|
3
|
-
import { t as own } from "./own-OwgPWdmh.mjs";
|
|
4
|
-
import { a as resolveMentions, n as actorSeen, s as replaceMentions } from "./actors-DjMeZpnU.mjs";
|
|
5
|
-
import { n as authorize, t as FULL_ACCESS } from "./access-CJU2-vaa.mjs";
|
|
6
|
-
import { n as syncTarget, r as withEmailMentions } from "./targets-yM5JBUgr.mjs";
|
|
7
|
-
import { a as foldMessages, f as personOf, i as foldActors, l as iso, n as channelsReducer, t as actorsReducer, u as linkedTo } from "./reducers-mUPFuFJV.mjs";
|
|
8
|
-
import { t as concernedMessageId } from "./project-CFiArraX.mjs";
|
|
9
|
-
import { idempotentId } from "experimental-a2";
|
|
10
|
-
//#region src/server/feed.ts
|
|
11
|
-
/** Bookkeeping events, shown only with `all`: who's who, and delivery receipts. */
|
|
12
|
-
const HIDDEN = /* @__PURE__ */ new Set([
|
|
13
|
-
"actor.seen",
|
|
14
|
-
"message.synced",
|
|
15
|
-
"message.files_synced"
|
|
16
|
-
]);
|
|
17
|
-
/** The person a linked platform actor is, as views show it. */
|
|
18
|
-
function personView(actors, actor) {
|
|
19
|
-
const link = own(actors.links, actor);
|
|
20
|
-
const person = link ? own(actors.byId, link.to) : void 0;
|
|
21
|
-
return link ? {
|
|
22
|
-
id: link.to,
|
|
23
|
-
handle: person?.handle ?? link.to,
|
|
24
|
-
name: person?.name ?? link.to,
|
|
25
|
-
avatar: person?.avatar ?? null
|
|
26
|
-
} : void 0;
|
|
27
|
-
}
|
|
28
|
-
function actorView(actors, id) {
|
|
29
|
-
const author = own(actors.byId, id);
|
|
30
|
-
if (!author) return null;
|
|
31
|
-
const person = personView(actors, id);
|
|
32
|
-
return {
|
|
33
|
-
kind: author.kind,
|
|
34
|
-
handle: author.handle,
|
|
35
|
-
name: author.name,
|
|
36
|
-
avatar: author.avatar,
|
|
37
|
-
...person ? { person } : {}
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
/** The board's own code, which reads every channel: it needs no messages to say so. */
|
|
41
|
-
const OWN_CODE = {
|
|
42
|
-
access: FULL_ACCESS,
|
|
43
|
-
channelOf: () => void 0
|
|
44
|
-
};
|
|
45
|
-
/** The board messages `links` point to, for `Reader.channelOf`. */
|
|
46
|
-
const linkedIds = (links) => links.flatMap((link) => link.message === null ? [] : [link.message]);
|
|
47
|
-
/**
|
|
48
|
-
* Links as `reader` may see them. The log keeps each snapshot; it's hidden when
|
|
49
|
-
* it's read. A linked message the reader's lookup doesn't have hides too: its
|
|
50
|
-
* snapshot shows only where it's known to be readable.
|
|
51
|
-
*/
|
|
52
|
-
function readableLinks(links, reader) {
|
|
53
|
-
return links.map((link) => {
|
|
54
|
-
if (link.message === null) return {
|
|
55
|
-
...link,
|
|
56
|
-
message: null
|
|
57
|
-
};
|
|
58
|
-
const channel = reader.access.readsAll ? void 0 : reader.channelOf(link.message);
|
|
59
|
-
return reader.access.readsAll || channel !== void 0 && reader.access.readable(channel) ? {
|
|
60
|
-
...link,
|
|
61
|
-
message: ref(link.message)
|
|
62
|
-
} : {
|
|
63
|
-
url: link.url,
|
|
64
|
-
message: null,
|
|
65
|
-
snapshot: null,
|
|
66
|
-
unavailable: "not_readable"
|
|
67
|
-
};
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
/** The links in an event view: its payload's, its message's, and the message before a label change. */
|
|
71
|
-
const viewLinks = (view) => {
|
|
72
|
-
const { payload, message, before } = view;
|
|
73
|
-
return [
|
|
74
|
-
payload?.links,
|
|
75
|
-
message?.links,
|
|
76
|
-
before?.links
|
|
77
|
-
].filter((links) => links !== void 0);
|
|
78
|
-
};
|
|
79
|
-
/** The board messages an event view's links point to, for a reader of them. */
|
|
80
|
-
const viewLinkedIds = (view) => viewLinks(view).flatMap((links) => links.flatMap((link) => link.message === null ? [] : [indexOf(link.message) ?? 0]));
|
|
81
|
-
/**
|
|
82
|
-
* An event view made for one reader, as `reader` sees it: a link to a message it can't read shows
|
|
83
|
-
* nothing of it. A watcher's match is made when its event lands and pulled later, by whatever its
|
|
84
|
-
* registrant may read then.
|
|
85
|
-
*/
|
|
86
|
-
function viewedBy(view, reader) {
|
|
87
|
-
if (reader.access.readsAll) return view;
|
|
88
|
-
const shown = structuredClone(view);
|
|
89
|
-
const reread = (links) => readableLinks(links.map((link) => ({
|
|
90
|
-
...link,
|
|
91
|
-
message: link.message === null ? null : indexOf(link.message) ?? 0
|
|
92
|
-
})), reader);
|
|
93
|
-
if (shown.payload?.links) shown.payload.links = reread(shown.payload.links);
|
|
94
|
-
if (shown.message) shown.message.links = reread(shown.message.links);
|
|
95
|
-
if (shown.before) shown.before.links = reread(shown.before.links);
|
|
96
|
-
return shown;
|
|
97
|
-
}
|
|
98
|
-
/** A message's or a cursor's ID as clients see it. On this board it's the log index, in decimal. */
|
|
99
|
-
const ref = (index) => String(index);
|
|
100
|
-
/** The log index a ref names on this board, or null for a string that names nothing here. */
|
|
101
|
-
const indexOf = (value) => /^(0|[1-9]\d{0,15})$/.test(value) ? Number(value) : null;
|
|
102
|
-
/** A message as `reader` sees it. Every view names its reader, so none shows a link's snapshot by default. */
|
|
103
|
-
function view(message, actors, reader) {
|
|
104
|
-
const { blind, version, schedule, ...shown } = message;
|
|
105
|
-
return {
|
|
106
|
-
...shown,
|
|
107
|
-
...blind ? { blind } : {},
|
|
108
|
-
id: ref(message.id),
|
|
109
|
-
re: message.re === null ? null : ref(message.re),
|
|
110
|
-
replyTo: message.replyTo === null ? null : ref(message.replyTo),
|
|
111
|
-
version: version ?? message.id,
|
|
112
|
-
...schedule ? { schedule: {
|
|
113
|
-
id: ref(schedule.id),
|
|
114
|
-
n: schedule.n
|
|
115
|
-
} } : {},
|
|
116
|
-
links: readableLinks(message.links, reader),
|
|
117
|
-
labels: { ...message.labels },
|
|
118
|
-
reactions: Object.fromEntries(Object.entries(message.reactions).map(([emoji, ids]) => [emoji, [...ids]])),
|
|
119
|
-
author: actorView(actors, message.by.actor)
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
/** Whether accounts are linked to `id`: then it's a person, even one who never wrote on the board. */
|
|
123
|
-
const hasLinks = (actors, id) => Object.values(actors.links).some((link) => link.to === id);
|
|
124
|
-
const actorSet = (value, actors) => own(actors.byId, value) || own(actors.links, value) || hasLinks(actors, value) ? /* @__PURE__ */ new Set([value]) : new Set(own(actors.byHandle, value.toLowerCase()) ?? []);
|
|
125
|
-
/**
|
|
126
|
-
* Who a `--by` or `--mentions` value covers. A person (`julian`, or one of their
|
|
127
|
-
* linked accounts, `@julian` on Slack) is everyone they are: their own actor,
|
|
128
|
-
* their agents (`julian/claude`), and every account linked to them. An agent
|
|
129
|
-
* or an unlinked platform actor is just itself.
|
|
130
|
-
*/
|
|
131
|
-
function whoIs(value, actors) {
|
|
132
|
-
const ids = actorSet(value, actors);
|
|
133
|
-
const out = new Set(ids);
|
|
134
|
-
const principals = /* @__PURE__ */ new Set();
|
|
135
|
-
for (const id of ids) {
|
|
136
|
-
const kind = own(actors.byId, id)?.kind;
|
|
137
|
-
const person = own(actors.links, id) ? personOf(actors, id) : kind === "person" || hasLinks(actors, id) ? id : null;
|
|
138
|
-
if (person === null) continue;
|
|
139
|
-
principals.add(person);
|
|
140
|
-
out.add(person);
|
|
141
|
-
for (const linked of linkedTo(actors, person)) out.add(linked);
|
|
142
|
-
}
|
|
143
|
-
return {
|
|
144
|
-
actors: out,
|
|
145
|
-
principals
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
/** Whether `author` is one of `who`: its actor, or an agent of one of its people. */
|
|
149
|
-
const covers = (who, actor, principal) => who.actors.has(actor) || principal !== void 0 && who.principals.has(principal);
|
|
150
|
-
const hasLabel = (labels, [key, values]) => Object.hasOwn(labels, key) && (values === null || values.includes(labels[key]));
|
|
151
|
-
/**
|
|
152
|
-
* Compiles filters once; handles resolve against the actors known at that
|
|
153
|
-
* moment. A label matches on the message or its thread's root, where sync puts
|
|
154
|
-
* labels, so `pr=…` means the PR's whole conversation. `messages` is the live
|
|
155
|
-
* state for that lookup. A message in a channel `access` can't read never
|
|
156
|
-
* matches, whatever the filters say.
|
|
157
|
-
*/
|
|
158
|
-
function compileFilter(filters, context) {
|
|
159
|
-
const { access } = context;
|
|
160
|
-
const text = filters.text?.toLowerCase();
|
|
161
|
-
const labels = filters.label ?? [];
|
|
162
|
-
const channel = filters.channel;
|
|
163
|
-
const subtree = channel?.endsWith("/") ? channel.slice(0, -1) : null;
|
|
164
|
-
const by = filters.by === void 0 ? null : whoIs(filters.by, context.actors);
|
|
165
|
-
const re = filters.re === void 0 ? void 0 : indexOf(filters.re) ?? -1;
|
|
166
|
-
const mentions = filters.mentions === void 0 ? null : filters.mentions === "me" ? new Set(context.me === null ? [] : [context.me, ...linkedTo(context.actors, context.me)]) : whoIs(filters.mentions, context.actors).actors;
|
|
167
|
-
return (message) => {
|
|
168
|
-
if (!message || !access.readable(message.channel)) return false;
|
|
169
|
-
if (channel !== void 0) {
|
|
170
|
-
if (subtree !== null) {
|
|
171
|
-
if (message.channel !== subtree && !message.channel.startsWith(`${subtree}/`)) return false;
|
|
172
|
-
} else if (message.channel !== channel) return false;
|
|
173
|
-
}
|
|
174
|
-
if (re !== void 0 && message.id !== re && message.re !== re) return false;
|
|
175
|
-
if (by !== null && !covers(by, message.by.actor, message.by.principal)) return false;
|
|
176
|
-
if (mentions !== null && !message.mentions.some((id) => mentions.has(id))) return false;
|
|
177
|
-
if (filters.from !== void 0 && (message.from ?? "board") !== filters.from) return false;
|
|
178
|
-
if (text !== void 0 && !message.text.toLowerCase().includes(text)) return false;
|
|
179
|
-
if (labels.length > 0) {
|
|
180
|
-
const root = message.re === null ? void 0 : context.messages?.byId[message.re];
|
|
181
|
-
for (const label of labels) if (!hasLabel(message.labels, label) && !(root && hasLabel(root.labels, label))) return false;
|
|
182
|
-
}
|
|
183
|
-
return true;
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
|
-
/**
|
|
187
|
-
* `filter`, narrowed to the channels `access` reads among `known`, as the tables
|
|
188
|
-
* take it: a reader of every channel needs nothing more. A channel the caller
|
|
189
|
-
* didn't know of is left out, never let in.
|
|
190
|
-
*/
|
|
191
|
-
function readableFilter(filter, known, access) {
|
|
192
|
-
if (access.readsAll) return filter;
|
|
193
|
-
const { channel, ...rest } = filter;
|
|
194
|
-
const within = (path) => !channel || path === channel.path || channel.subtree && path.startsWith(`${channel.path}/`);
|
|
195
|
-
return {
|
|
196
|
-
...rest,
|
|
197
|
-
channels: [...known].filter((path) => within(path) && access.readable(path))
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
/** The filters as the tables take them: handles resolved to who they cover, `me` to the caller. */
|
|
201
|
-
function toMessageFilter(filters, context) {
|
|
202
|
-
const out = {};
|
|
203
|
-
const channel = filters.channel;
|
|
204
|
-
if (channel !== void 0) out.channel = channel.endsWith("/") ? {
|
|
205
|
-
path: channel.slice(0, -1),
|
|
206
|
-
subtree: true
|
|
207
|
-
} : {
|
|
208
|
-
path: channel,
|
|
209
|
-
subtree: false
|
|
210
|
-
};
|
|
211
|
-
if (filters.re !== void 0) out.re = indexOf(filters.re) ?? -1;
|
|
212
|
-
if (filters.by !== void 0) {
|
|
213
|
-
const who = whoIs(filters.by, context.actors);
|
|
214
|
-
out.by = {
|
|
215
|
-
actors: [...who.actors],
|
|
216
|
-
principals: [...who.principals]
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
if (filters.mentions !== void 0) out.mentions = filters.mentions === "me" ? context.me === null ? [] : [context.me, ...linkedTo(context.actors, context.me)] : [...whoIs(filters.mentions, context.actors).actors];
|
|
220
|
-
if (filters.from !== void 0) out.from = filters.from;
|
|
221
|
-
if (filters.text !== void 0) out.text = filters.text.toLowerCase();
|
|
222
|
-
if (filters.label?.length) out.labels = filters.label.map(([key, values]) => ({
|
|
223
|
-
key,
|
|
224
|
-
values
|
|
225
|
-
}));
|
|
226
|
-
return out;
|
|
227
|
-
}
|
|
228
|
-
/**
|
|
229
|
-
* Loads into `messages`, as they were just before `event`, the message it
|
|
230
|
-
* concerns and that message's root: what folding it and matching filters need.
|
|
231
|
-
* `loaded` remembers what was asked for already, found or not.
|
|
232
|
-
*/
|
|
233
|
-
async function loadInto(reads, messages, loaded, event, signal) {
|
|
234
|
-
const id = concernedMessageId(event);
|
|
235
|
-
if (id === null) return;
|
|
236
|
-
const through = event.index - 1;
|
|
237
|
-
const load = async (ids) => {
|
|
238
|
-
const wanted = ids.filter((want) => !loaded.has(want));
|
|
239
|
-
if (wanted.length === 0) return;
|
|
240
|
-
const found = await reads.messagesAt(wanted, through, signal);
|
|
241
|
-
for (const want of wanted) {
|
|
242
|
-
loaded.add(want);
|
|
243
|
-
const message = found.get(want);
|
|
244
|
-
if (message) messages.byId[want] = message;
|
|
245
|
-
}
|
|
246
|
-
};
|
|
247
|
-
if (event.type === "message.posted") loaded.add(id);
|
|
248
|
-
else await load([id]);
|
|
249
|
-
const re = event.type === "message.posted" ? event.payload.re : messages.byId[id]?.re ?? null;
|
|
250
|
-
if (re !== null) await load([re]);
|
|
251
|
-
}
|
|
252
|
-
/** The message `event` concerns and its root, as they were just before it. */
|
|
253
|
-
async function loadBefore(reads, event, signal) {
|
|
254
|
-
const messages = {
|
|
255
|
-
byId: {},
|
|
256
|
-
replies: {}
|
|
257
|
-
};
|
|
258
|
-
await loadInto(reads, messages, /* @__PURE__ */ new Set(), event, signal);
|
|
259
|
-
return messages;
|
|
260
|
-
}
|
|
261
|
-
/** Folds one event into `messages` and `actors`, and returns the message it concerns before and after. */
|
|
262
|
-
function foldEvent(messages, actors, event) {
|
|
263
|
-
const id = concernedMessageId(event);
|
|
264
|
-
const before = id === null ? void 0 : structuredClone(messages.byId[id]);
|
|
265
|
-
foldMessages(messages, event);
|
|
266
|
-
foldActors(actors, event);
|
|
267
|
-
return {
|
|
268
|
-
id,
|
|
269
|
-
before,
|
|
270
|
-
after: id === null ? void 0 : messages.byId[id]
|
|
271
|
-
};
|
|
272
|
-
}
|
|
273
|
-
/** An event as subscribers receive it, with its message as it is afterwards. */
|
|
274
|
-
function eventView(event, after, actors, reader) {
|
|
275
|
-
const by = event.payload.by?.actor;
|
|
276
|
-
const actor = by ? own(actors.byId, by) : void 0;
|
|
277
|
-
const { links } = event.payload;
|
|
278
|
-
return {
|
|
279
|
-
cursor: ref(event.index),
|
|
280
|
-
type: event.type,
|
|
281
|
-
createdAt: iso(event.createdAt),
|
|
282
|
-
payload: payloadView(event.payload, links ? readableLinks(links, reader) : void 0),
|
|
283
|
-
...after ? { message: view(after, actors, reader) } : {},
|
|
284
|
-
...actor ? { actor: {
|
|
285
|
-
id: actor.id,
|
|
286
|
-
...actorView(actors, actor.id)
|
|
287
|
-
} } : {}
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
/** An event's payload as clients see it: the message IDs in it are refs. */
|
|
291
|
-
function payloadView(payload, links) {
|
|
292
|
-
const shown = { ...payload };
|
|
293
|
-
for (const key of [
|
|
294
|
-
"id",
|
|
295
|
-
"re",
|
|
296
|
-
"replyTo"
|
|
297
|
-
]) if (typeof shown[key] === "number") shown[key] = ref(shown[key]);
|
|
298
|
-
const schedule = shown.schedule;
|
|
299
|
-
if (schedule) shown.schedule = {
|
|
300
|
-
id: ref(schedule.id),
|
|
301
|
-
n: schedule.n
|
|
302
|
-
};
|
|
303
|
-
if (links) shown.links = links;
|
|
304
|
-
return shown;
|
|
305
|
-
}
|
|
306
|
-
/**
|
|
307
|
-
* Folds events one at a time and decides what a subscriber sees. It holds only
|
|
308
|
-
* the messages events have touched, each loaded from the tables as it was just
|
|
309
|
-
* before the first event that touched it. A label or reaction change is
|
|
310
|
-
* delivered when its message matched the filter before *or* after it, so
|
|
311
|
-
* subscribers see messages leave a view too.
|
|
312
|
-
*
|
|
313
|
-
* It's the one choke point for `/events` (tail, wait, and poll): an event about
|
|
314
|
-
* a message shows only where `access` reads its channel, and one about no
|
|
315
|
-
* message (who's who, a thread's sync) only to a reader of every channel.
|
|
316
|
-
*/
|
|
317
|
-
var FeedProjection = class {
|
|
318
|
-
messages = {
|
|
319
|
-
byId: {},
|
|
320
|
-
replies: {}
|
|
321
|
-
};
|
|
322
|
-
loaded = /* @__PURE__ */ new Set();
|
|
323
|
-
reads;
|
|
324
|
-
actors;
|
|
325
|
-
match;
|
|
326
|
-
criteria;
|
|
327
|
-
all;
|
|
328
|
-
access;
|
|
329
|
-
/** Where each board message a view links to lives, for a reader that can't read every channel. */
|
|
330
|
-
linked = /* @__PURE__ */ new Map();
|
|
331
|
-
constructor(options) {
|
|
332
|
-
this.reads = options.reads;
|
|
333
|
-
this.actors = options.actors;
|
|
334
|
-
this.access = options.access;
|
|
335
|
-
this.match = compileFilter(options.filters, {
|
|
336
|
-
me: options.me,
|
|
337
|
-
actors: options.actors,
|
|
338
|
-
messages: this.messages,
|
|
339
|
-
access: options.access
|
|
340
|
-
});
|
|
341
|
-
this.criteria = hasCriteria(options.filters);
|
|
342
|
-
this.all = options.all;
|
|
343
|
-
}
|
|
344
|
-
/** Loads what `events` touch, all as of `index`, in two queries rather than two per event. */
|
|
345
|
-
async preload(events, index) {
|
|
346
|
-
const ids = /* @__PURE__ */ new Set();
|
|
347
|
-
for (const event of events) {
|
|
348
|
-
const id = concernedMessageId(event);
|
|
349
|
-
if (id === null) continue;
|
|
350
|
-
if (event.type !== "message.posted") ids.add(id);
|
|
351
|
-
else if (event.payload.re !== null) ids.add(event.payload.re);
|
|
352
|
-
}
|
|
353
|
-
const load = async (wanted) => {
|
|
354
|
-
if (wanted.length === 0) return;
|
|
355
|
-
const found = await this.reads.messagesAt(wanted, index);
|
|
356
|
-
for (const id of wanted) {
|
|
357
|
-
this.loaded.add(id);
|
|
358
|
-
const message = found.get(id);
|
|
359
|
-
if (message) this.messages.byId[id] = message;
|
|
360
|
-
}
|
|
361
|
-
};
|
|
362
|
-
await load([...ids].filter((id) => !this.loaded.has(id) && id <= index));
|
|
363
|
-
const roots = Object.values(this.messages.byId).flatMap((message) => message.re === null ? [] : [message.re]);
|
|
364
|
-
await load([...new Set(roots)].filter((id) => !this.loaded.has(id)));
|
|
365
|
-
}
|
|
366
|
-
async step(event, signal) {
|
|
367
|
-
await loadInto(this.reads, this.messages, this.loaded, event, signal);
|
|
368
|
-
const { id, before, after } = foldEvent(this.messages, this.actors, event);
|
|
369
|
-
const message = after ?? before;
|
|
370
|
-
const readable = id === null ? this.access.readsAll : message !== void 0 && this.access.readable(message.channel);
|
|
371
|
-
const hidden = HIDDEN.has(event.type) && !this.all;
|
|
372
|
-
if (!(readable && !hidden && (this.criteria ? this.match(before) || this.match(after) : id !== null || this.all))) return {
|
|
373
|
-
index: event.index,
|
|
374
|
-
view: null
|
|
375
|
-
};
|
|
376
|
-
await this.loadLinked([...after?.links ?? [], ...event.payload.links ?? []], event.index, signal);
|
|
377
|
-
const reader = {
|
|
378
|
-
access: this.access,
|
|
379
|
-
channelOf: (message) => this.messages.byId[message]?.channel ?? this.linked.get(message)
|
|
380
|
-
};
|
|
381
|
-
return {
|
|
382
|
-
index: event.index,
|
|
383
|
-
view: eventView(event, after, this.actors, reader)
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
/** Where the messages `links` point to live; a message never moves, so any version says. */
|
|
387
|
-
async loadLinked(links, index, signal) {
|
|
388
|
-
if (this.access.readsAll) return;
|
|
389
|
-
const wanted = [...new Set(linkedIds(links))].filter((id) => !this.messages.byId[id] && !this.linked.has(id));
|
|
390
|
-
if (wanted.length === 0) return;
|
|
391
|
-
const found = await this.reads.messagesAt(wanted, index, signal);
|
|
392
|
-
for (const id of wanted) this.linked.set(id, found.get(id)?.channel);
|
|
393
|
-
}
|
|
394
|
-
};
|
|
395
|
-
/** Resolves when the iterator yields, or with null once the signal aborts. */
|
|
396
|
-
function nextOrAbort(iterator, signal) {
|
|
397
|
-
if (signal.aborted) return Promise.resolve(null);
|
|
398
|
-
return new Promise((resolve, reject) => {
|
|
399
|
-
const onAbort = () => resolve(null);
|
|
400
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
401
|
-
iterator.next().then((result) => {
|
|
402
|
-
signal.removeEventListener("abort", onAbort);
|
|
403
|
-
resolve(result);
|
|
404
|
-
}, (error) => {
|
|
405
|
-
signal.removeEventListener("abort", onAbort);
|
|
406
|
-
reject(error);
|
|
407
|
-
});
|
|
408
|
-
});
|
|
409
|
-
}
|
|
410
|
-
/** Where a feed starts: `after`, or the head when it's omitted or past it. */
|
|
411
|
-
async function start(options) {
|
|
412
|
-
const { index: head } = await options.session.state(channelsReducer);
|
|
413
|
-
return options.after === void 0 || options.after >= head ? head : options.after;
|
|
414
|
-
}
|
|
415
|
-
const PAGE = 200;
|
|
416
|
-
/**
|
|
417
|
-
* One long-poll: matching events already in the log return at once; otherwise
|
|
418
|
-
* it waits up to `waitMs` for the first new match. The cursor is the last
|
|
419
|
-
* event examined, matched or not, so the next poll never rescans.
|
|
420
|
-
*/
|
|
421
|
-
async function pollFeed(options, waitMs, signal) {
|
|
422
|
-
const index = await start(options);
|
|
423
|
-
const feed = new FeedProjection(options);
|
|
424
|
-
const events = [];
|
|
425
|
-
let cursor = index;
|
|
426
|
-
const backlog = await options.session.history({
|
|
427
|
-
gte: index + 1,
|
|
428
|
-
lte: index + PAGE
|
|
429
|
-
});
|
|
430
|
-
await feed.preload(backlog, index);
|
|
431
|
-
for (const event of backlog) {
|
|
432
|
-
const item = await feed.step(event, signal);
|
|
433
|
-
cursor = item.index;
|
|
434
|
-
if (item.view) events.push(item.view);
|
|
435
|
-
}
|
|
436
|
-
if (events.length > 0 || backlog.length === PAGE || waitMs <= 0) return {
|
|
437
|
-
events,
|
|
438
|
-
cursor
|
|
439
|
-
};
|
|
440
|
-
const timeout = AbortSignal.timeout(waitMs);
|
|
441
|
-
const until = AbortSignal.any([signal, timeout]);
|
|
442
|
-
const iterator = options.session.stream({ startAfter: cursor })[Symbol.asyncIterator]();
|
|
443
|
-
try {
|
|
444
|
-
while (true) {
|
|
445
|
-
const next = await nextOrAbort(iterator, until);
|
|
446
|
-
if (next === null || next.done) return {
|
|
447
|
-
events,
|
|
448
|
-
cursor
|
|
449
|
-
};
|
|
450
|
-
const item = await feed.step(next.value, until);
|
|
451
|
-
cursor = item.index;
|
|
452
|
-
if (item.view) {
|
|
453
|
-
events.push(item.view);
|
|
454
|
-
return {
|
|
455
|
-
events,
|
|
456
|
-
cursor
|
|
457
|
-
};
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
} finally {
|
|
461
|
-
iterator.return?.();
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
//#endregion
|
|
465
|
-
//#region src/server/wait-until.ts
|
|
466
|
-
/**
|
|
467
|
-
* Keeps `task` running after the response, where the platform offers it: Vercel's request context, as A2
|
|
468
|
-
* reads it too. Elsewhere it runs on in the process.
|
|
469
|
-
*/
|
|
470
|
-
function waitUntil(task) {
|
|
471
|
-
try {
|
|
472
|
-
Reflect.get(globalThis, Symbol.for("@vercel/request-context"))?.get?.()?.waitUntil?.(task);
|
|
473
|
-
} catch {}
|
|
474
|
-
}
|
|
475
|
-
//#endregion
|
|
476
|
-
//#region src/server/writes.ts
|
|
477
|
-
/** A piece of the board's own deployment, writing as `actor` with full access. */
|
|
478
|
-
function deploymentIdentity(actor, handle) {
|
|
479
|
-
return {
|
|
480
|
-
principal: {
|
|
481
|
-
id: actor,
|
|
482
|
-
kind: "deployment",
|
|
483
|
-
handle,
|
|
484
|
-
access: FULL_ACCESS,
|
|
485
|
-
expiresAt: null
|
|
486
|
-
},
|
|
487
|
-
author: {
|
|
488
|
-
actor,
|
|
489
|
-
principal: actor,
|
|
490
|
-
profile: null,
|
|
491
|
-
session: null
|
|
492
|
-
},
|
|
493
|
-
actor: {
|
|
494
|
-
id: actor,
|
|
495
|
-
kind: "deployment",
|
|
496
|
-
handle,
|
|
497
|
-
name: handle,
|
|
498
|
-
avatar: null
|
|
499
|
-
}
|
|
500
|
-
};
|
|
501
|
-
}
|
|
502
|
-
/**
|
|
503
|
-
* The `actor.seen` a write carries, or null: who its author is, as the board's record of them
|
|
504
|
-
* says, unless the board knows that already. A platform user's record is board-wide, so a write
|
|
505
|
-
* whose identity doesn't keep it (`records: false`) leaves it as it is. A host's principal and its
|
|
506
|
-
* profiles are recorded as the host's, which sync never writes as.
|
|
507
|
-
*/
|
|
508
|
-
function seenFor(identity, actors) {
|
|
509
|
-
if (identity.records === false) return null;
|
|
510
|
-
return actorSeen(identity.actor, actors, identity.principal.facts?.issuer === "host");
|
|
511
|
-
}
|
|
512
|
-
/** The actors a write can mention: the ones the board knows, plus the ones an email named. */
|
|
513
|
-
const knownWith = (actors, found) => found.length === 0 ? actors : {
|
|
514
|
-
...actors,
|
|
515
|
-
byId: {
|
|
516
|
-
...actors.byId,
|
|
517
|
-
...Object.fromEntries(found.map((a) => [a.id, a]))
|
|
518
|
-
}
|
|
519
|
-
};
|
|
520
|
-
const messageNotFound = (id) => () => notFound(`message not found: #${id}`, "message_not_found");
|
|
521
|
-
/**
|
|
522
|
-
* A message the board looked up and the caller may act on with `permission`,
|
|
523
|
-
* proved against its own channel. A message they can't read answers 404, as if
|
|
524
|
-
* it didn't exist: IDs are sequential, so a 403 would confirm it does.
|
|
525
|
-
*/
|
|
526
|
-
async function authorizeMessage(ctx, access, permission, id) {
|
|
527
|
-
const message = await ctx.reads.message(id);
|
|
528
|
-
if (!message) throw messageNotFound(id)();
|
|
529
|
-
return {
|
|
530
|
-
message,
|
|
531
|
-
proof: authorize(access, permission, message.channel, messageNotFound(id))
|
|
532
|
-
};
|
|
533
|
-
}
|
|
534
|
-
/**
|
|
535
|
-
* Your own message, to edit or delete: `post` in its channel. Where you can't
|
|
536
|
-
* read, it's yours only if it was posted without reading too: a write-only
|
|
537
|
-
* source fixes what it wrote, but a principal ID it shares with a caller that
|
|
538
|
-
* reads (a repo's workflows share one) doesn't reach that caller's messages.
|
|
539
|
-
* Someone else's answers 404 where you can't read it, and 403 where you can.
|
|
540
|
-
*/
|
|
541
|
-
async function authorizeOwn(ctx, principal, id, verb) {
|
|
542
|
-
const message = await ctx.reads.message(id);
|
|
543
|
-
if (!message) throw messageNotFound(id)();
|
|
544
|
-
const readable = principal.access.readable(message.channel);
|
|
545
|
-
if (message.by.principal !== principal.id || !readable && !message.blind) {
|
|
546
|
-
if (!readable) throw messageNotFound(id)();
|
|
547
|
-
throw forbidden(`only the author can ${verb} a message`);
|
|
548
|
-
}
|
|
549
|
-
return {
|
|
550
|
-
message,
|
|
551
|
-
proof: authorize(principal.access, "post", message.channel)
|
|
552
|
-
};
|
|
553
|
-
}
|
|
554
|
-
const hasLabels = (labels) => Object.keys(labels ?? {}).length > 0;
|
|
555
|
-
/**
|
|
556
|
-
* Proofs for a post. A root lands in the channel it names, which the caller
|
|
557
|
-
* needn't read: write-only is `['post']`. A reply lands in its root's channel,
|
|
558
|
-
* looked up here and never taken from the request, and needs `read` there too,
|
|
559
|
-
* since addressing a thread you can't see would confirm it exists.
|
|
560
|
-
*/
|
|
561
|
-
async function authorizePost(ctx, access, input) {
|
|
562
|
-
let channel;
|
|
563
|
-
let re;
|
|
564
|
-
if (input.re !== void 0) {
|
|
565
|
-
re = (await authorizeMessage(ctx, access, "read", input.re)).message;
|
|
566
|
-
channel = re.channel;
|
|
567
|
-
} else {
|
|
568
|
-
if (!input.channel) throw badRequest("a post needs a channel");
|
|
569
|
-
channel = input.channel;
|
|
570
|
-
}
|
|
571
|
-
return {
|
|
572
|
-
post: authorize(access, "post", channel),
|
|
573
|
-
...re ? { re } : {},
|
|
574
|
-
...hasLabels(input.labels) ? { label: authorize(access, "label", channel) } : {},
|
|
575
|
-
...input.sync?.length ? { sync: authorize(access, "sync", channel) } : {}
|
|
576
|
-
};
|
|
577
|
-
}
|
|
578
|
-
/**
|
|
579
|
-
* Spends a proof on its target: a write checked against one channel can't land
|
|
580
|
-
* in another. A mismatch is the board's own bug, never the caller's.
|
|
581
|
-
*/
|
|
582
|
-
function spend(proof, permission, target) {
|
|
583
|
-
if (!proof || proof.permission !== permission || proof.channel !== target.channel) throw new Error(`a ${permission} write in ${target.channel} needs a proof for that channel`);
|
|
584
|
-
}
|
|
585
|
-
/** Full-access proofs for the board's own code (sync), still checked against the channel each write lands in. */
|
|
586
|
-
const trusted = {
|
|
587
|
-
message: (ctx, permission, id) => authorizeMessage(ctx, FULL_ACCESS, permission, id),
|
|
588
|
-
post: (ctx, input) => authorizePost(ctx, FULL_ACCESS, input)
|
|
589
|
-
};
|
|
590
|
-
async function writePost(ctx, input) {
|
|
591
|
-
const { identity } = input;
|
|
592
|
-
const sent = input.text;
|
|
593
|
-
let channel = input.channel;
|
|
594
|
-
let re = null;
|
|
595
|
-
let replyTo = null;
|
|
596
|
-
let thread;
|
|
597
|
-
const id = input.id ?? (input.re === void 0 ? crypto.randomUUID() : void 0);
|
|
598
|
-
if (input.re !== void 0) {
|
|
599
|
-
const target = input.proofs.re;
|
|
600
|
-
if (target?.id !== input.re) throw new Error(`a reply to #${input.re} needs proofs for that message`);
|
|
601
|
-
re = target.re ?? target.id;
|
|
602
|
-
replyTo = target.id === re ? null : target.id;
|
|
603
|
-
channel = target.channel;
|
|
604
|
-
thread = target.thread;
|
|
605
|
-
if (input.sync?.length) throw badRequest("sync attaches whole threads: use `board sync <id> <target>`");
|
|
606
|
-
} else {
|
|
607
|
-
if (!channel) throw badRequest("a post needs a channel");
|
|
608
|
-
thread = id;
|
|
609
|
-
}
|
|
610
|
-
spend(input.proofs.post, "post", { channel });
|
|
611
|
-
if (hasLabels(input.labels)) spend(input.proofs.label, "label", { channel });
|
|
612
|
-
if (input.sync?.length) spend(input.proofs.sync, "sync", { channel });
|
|
613
|
-
if (input.id !== void 0) {
|
|
614
|
-
const landed = await ctx.reads.byEventId(input.id);
|
|
615
|
-
if (landed) {
|
|
616
|
-
if (landed.channel !== channel || landed.re !== re || landed.replyTo !== replyTo) throw duplicateId();
|
|
617
|
-
const shown = await landedFor(ctx, identity, landed);
|
|
618
|
-
if (!shown) throw duplicateId();
|
|
619
|
-
return shownTo(shown, identity.principal.access, sent);
|
|
620
|
-
}
|
|
621
|
-
}
|
|
622
|
-
return commitPost(ctx, input, {
|
|
623
|
-
channel,
|
|
624
|
-
re,
|
|
625
|
-
replyTo,
|
|
626
|
-
thread,
|
|
627
|
-
id,
|
|
628
|
-
sent
|
|
629
|
-
});
|
|
630
|
-
}
|
|
631
|
-
/**
|
|
632
|
-
* A post that landed, as its author may have it back: only the same author's, and one in a
|
|
633
|
-
* channel it can't read only if it posted without reading too. A caller that can't read gets back
|
|
634
|
-
* what it sent, as it landed: never what others have added since. Null for anyone else.
|
|
635
|
-
*/
|
|
636
|
-
async function landedFor(ctx, identity, landed) {
|
|
637
|
-
if (!sameAuthor(landed.by, identity.author)) return null;
|
|
638
|
-
const { access } = identity.principal;
|
|
639
|
-
const { state: actors } = await ctx.session.state(actorsReducer);
|
|
640
|
-
if (access.readable(landed.channel)) return view(landed, actors, await readerFor(ctx, access, landed));
|
|
641
|
-
if (!landed.blind) return null;
|
|
642
|
-
const [event] = await ctx.session.history({
|
|
643
|
-
gte: landed.id,
|
|
644
|
-
lte: landed.id
|
|
645
|
-
});
|
|
646
|
-
const posted = foldMessages({
|
|
647
|
-
byId: {},
|
|
648
|
-
replies: {}
|
|
649
|
-
}, event).byId[landed.id];
|
|
650
|
-
return {
|
|
651
|
-
...view(posted, actors, await readerFor(ctx, access, posted)),
|
|
652
|
-
text: blindText(posted.text, identity.actor.id),
|
|
653
|
-
mentions: []
|
|
654
|
-
};
|
|
655
|
-
}
|
|
656
|
-
/**
|
|
657
|
-
* A message's text for its writer where it can't read: each exact mention but of itself shows as
|
|
658
|
-
* `@…`. The board may have turned an email it wrote into an actor ID, and who that is, `/actors`
|
|
659
|
-
* doesn't show it either.
|
|
660
|
-
*/
|
|
661
|
-
const blindText = (text, own) => replaceMentions(text, (token) => token.kind === "actor" && token.actor !== own ? "@…" : null);
|
|
662
|
-
const sameAuthor = (a, b) => a.actor === b.actor && a.principal === b.principal && a.profile === b.profile && a.session === b.session;
|
|
663
|
-
async function commitPost(ctx, input, placed) {
|
|
664
|
-
const { identity } = input;
|
|
665
|
-
const { channel, re, replyTo, thread, id, sent } = placed;
|
|
666
|
-
const { state: actors } = await ctx.session.state(actorsReducer);
|
|
667
|
-
const targets = [];
|
|
668
|
-
if (re === null) {
|
|
669
|
-
const originChannel = input.origin ? input.origin.channel ?? channelOf(input.origin.thread) : null;
|
|
670
|
-
if (!input.noSync) {
|
|
671
|
-
for (const target of input.config ?? []) if (target.target !== originChannel) targets.push({
|
|
672
|
-
...target,
|
|
673
|
-
cause: "config"
|
|
674
|
-
});
|
|
675
|
-
}
|
|
676
|
-
for (const requested of input.sync ?? []) {
|
|
677
|
-
const target = syncTarget(requested);
|
|
678
|
-
await unclaimed(ctx, target, thread, identity.principal.access);
|
|
679
|
-
if (!targets.some((t) => t.target === target.target)) targets.push({
|
|
680
|
-
...target,
|
|
681
|
-
cause: "request"
|
|
682
|
-
});
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
const seen = seenFor(identity, actors);
|
|
686
|
-
const { text, found } = await withEmailMentions(input.text);
|
|
687
|
-
const named = found.map((actor) => actorSeen(actor, actors)).filter((event) => event !== null);
|
|
688
|
-
const { mentions, unknown } = resolveMentions(text, knownWith(actors, found), seen ? identity.actor : void 0, input.lenientMentions ?? false, identity.principal.access.readsAll);
|
|
689
|
-
const synced = await Promise.all(targets.map(async ({ target, kind, cause }) => ({
|
|
690
|
-
type: "thread.synced",
|
|
691
|
-
id: await idempotentId("board", thread, "sync", target),
|
|
692
|
-
payload: {
|
|
693
|
-
thread,
|
|
694
|
-
target,
|
|
695
|
-
kind,
|
|
696
|
-
cause,
|
|
697
|
-
by: identity.author
|
|
698
|
-
}
|
|
699
|
-
})));
|
|
700
|
-
const rows = await ctx.session.append(...seen ? [seen] : [], ...named, {
|
|
701
|
-
type: "message.posted",
|
|
702
|
-
...id ? { id } : {},
|
|
703
|
-
payload: {
|
|
704
|
-
channel,
|
|
705
|
-
text,
|
|
706
|
-
re,
|
|
707
|
-
replyTo,
|
|
708
|
-
thread,
|
|
709
|
-
labels: input.labels ?? {},
|
|
710
|
-
noSync: input.noSync ?? false,
|
|
711
|
-
mentions,
|
|
712
|
-
by: identity.author,
|
|
713
|
-
origin: input.origin ?? null,
|
|
714
|
-
from: input.from ?? null,
|
|
715
|
-
...input.attachments?.length ? { attachments: input.attachments } : {},
|
|
716
|
-
...input.links?.length ? { links: input.links } : {},
|
|
717
|
-
...input.schedule ? { schedule: input.schedule } : {},
|
|
718
|
-
...identity.principal.access.readable(channel) ? {} : { blind: true },
|
|
719
|
-
...input.importedBy !== void 0 ? { importedBy: input.importedBy } : {}
|
|
720
|
-
}
|
|
721
|
-
}, ...synced);
|
|
722
|
-
const posted = rows.find((row) => row.type === "message.posted");
|
|
723
|
-
await untilApplied(ctx, rows);
|
|
724
|
-
const message = foldMessages({
|
|
725
|
-
byId: {},
|
|
726
|
-
replies: {}
|
|
727
|
-
}, posted).byId[posted.index];
|
|
728
|
-
const known = seen ? {
|
|
729
|
-
...actors,
|
|
730
|
-
byId: {
|
|
731
|
-
...actors.byId,
|
|
732
|
-
[identity.actor.id]: identity.actor
|
|
733
|
-
}
|
|
734
|
-
} : actors;
|
|
735
|
-
return shownTo(view(message, known, await readerFor(ctx, identity.principal.access, message)), identity.principal.access, sent, unknown);
|
|
736
|
-
}
|
|
737
|
-
/**
|
|
738
|
-
* A post's view for its writer. One that can't read the channel gets back the
|
|
739
|
-
* text it sent, no mentions, and no `unknownMentions`: the board resolved its
|
|
740
|
-
* handles and emails to actor IDs, which `/actors` doesn't show it, and which
|
|
741
|
-
* handles matched no one would say which ones exist. The board keeps the
|
|
742
|
-
* mentions, so they still notify.
|
|
743
|
-
*/
|
|
744
|
-
const shownTo = (shown, access, sent, unknown = []) => !access.readable(shown.channel) ? {
|
|
745
|
-
...shown,
|
|
746
|
-
text: sent,
|
|
747
|
-
mentions: []
|
|
748
|
-
} : unknown.length ? {
|
|
749
|
-
...shown,
|
|
750
|
-
unknownMentions: unknown
|
|
751
|
-
} : shown;
|
|
752
|
-
/** The writer, as the reader of the view its write returns. Only a link to a board message needs a lookup. */
|
|
753
|
-
const readerFor = (ctx, access, message) => ctx.reads.reader(access, [message]);
|
|
754
|
-
/**
|
|
755
|
-
* Refuses an external thread another board thread is synced with: taking it
|
|
756
|
-
* over would send its replies away from that thread, to a caller who may not
|
|
757
|
-
* read it. A reader of that thread's channel learns which thread it is. It
|
|
758
|
-
* reads the tables once they've applied the whole log, so a sync just made
|
|
759
|
-
* counts.
|
|
760
|
-
*/
|
|
761
|
-
async function unclaimed(ctx, target, thread, access) {
|
|
762
|
-
if (target.kind !== "thread") return;
|
|
763
|
-
await ctx.reads.ensure();
|
|
764
|
-
const owner = await ctx.reads.tables.threadOf(target.target);
|
|
765
|
-
if (owner === null || owner === thread) return;
|
|
766
|
-
const synced = await ctx.reads.tables.syncOf(owner);
|
|
767
|
-
const which = synced && access.readable(synced.channel) ? `#${synced.root}` : "another thread";
|
|
768
|
-
throw new HttpError(409, "already_synced", `${target.target} is already synced with ${which}`);
|
|
769
|
-
}
|
|
770
|
-
/** `slack:C0123ABC:1727.1` → `slack:C0123ABC`; `github:o/r:12` → `github:o/r`. */
|
|
771
|
-
const channelOf = (externalThread) => {
|
|
772
|
-
const at = externalThread.indexOf(":");
|
|
773
|
-
const colon = externalThread.slice(at + 1).indexOf(":");
|
|
774
|
-
return colon === -1 ? externalThread : externalThread.slice(0, at + 1 + colon);
|
|
775
|
-
};
|
|
776
|
-
/**
|
|
777
|
-
* Waits until the tables hold a write, so any read after it includes it. Most often
|
|
778
|
-
* nothing was written in between, and the writer applies its own events itself; else
|
|
779
|
-
* it waits for the tables' consumer. The write landed either way: if the tables are
|
|
780
|
-
* slow or stuck, it still succeeds.
|
|
781
|
-
*/
|
|
782
|
-
async function untilApplied(ctx, rows) {
|
|
783
|
-
const events = [...rows].sort((a, b) => a.index - b.index);
|
|
784
|
-
const last = events.at(-1).index;
|
|
785
|
-
try {
|
|
786
|
-
let caughtUp = true;
|
|
787
|
-
for (const event of events) if (!(caughtUp = await ctx.reads.tables.applyNext(event))) break;
|
|
788
|
-
if (caughtUp) return;
|
|
789
|
-
} catch {}
|
|
790
|
-
await ctx.reads.ensure(last, AbortSignal.timeout(LANDED_WAIT_MS)).catch((error) => {
|
|
791
|
-
console.warn(`[board] the tables haven't applied #${last} yet:`, error.message);
|
|
792
|
-
});
|
|
793
|
-
}
|
|
794
|
-
/** How long a write waits for the tables before it answers anyway. */
|
|
795
|
-
const LANDED_WAIT_MS = 1e4;
|
|
796
|
-
/** The message a by-ID write targets, as the board looked it up, once its proof is spent on the message's channel. */
|
|
797
|
-
function spent(authorized, permission) {
|
|
798
|
-
spend(authorized.proof, permission, authorized.message);
|
|
799
|
-
return authorized.message;
|
|
800
|
-
}
|
|
801
|
-
async function writeEdit(ctx, input) {
|
|
802
|
-
const message = spent(input.authorized, "post");
|
|
803
|
-
if (input.asAuthor !== false && message.by.principal !== input.identity.principal.id) throw forbidden("only the author can edit a message");
|
|
804
|
-
if (message.deleted) throw badRequest("message was deleted", "message_deleted");
|
|
805
|
-
return commitEdit(ctx, input, message);
|
|
806
|
-
}
|
|
807
|
-
async function commitEdit(ctx, input, message) {
|
|
808
|
-
const { state: actors } = await ctx.session.state(actorsReducer);
|
|
809
|
-
const seen = seenFor(input.identity, actors);
|
|
810
|
-
const { text, found } = await withEmailMentions(input.text);
|
|
811
|
-
const named = found.map((actor) => actorSeen(actor, actors)).filter((event) => event !== null);
|
|
812
|
-
const { mentions, unknown } = resolveMentions(text, knownWith(actors, found), seen ? input.identity.actor : void 0, input.from != null, input.identity.principal.access.readsAll);
|
|
813
|
-
await untilApplied(ctx, await ctx.session.append(...seen ? [seen] : [], ...named, {
|
|
814
|
-
type: "message.edited",
|
|
815
|
-
payload: {
|
|
816
|
-
id: message.id,
|
|
817
|
-
thread: message.thread,
|
|
818
|
-
text,
|
|
819
|
-
mentions,
|
|
820
|
-
by: input.identity.author,
|
|
821
|
-
from: input.from ?? null
|
|
822
|
-
}
|
|
823
|
-
}));
|
|
824
|
-
return unknown.length && input.identity.principal.access.readable(message.channel) ? { unknownMentions: unknown } : {};
|
|
825
|
-
}
|
|
826
|
-
/** A message's links, replaced: a platform edit or unfurl changed what it links to. */
|
|
827
|
-
async function writeLinks(ctx, input) {
|
|
828
|
-
const message = spent(input.authorized, "post");
|
|
829
|
-
if (message.deleted) return;
|
|
830
|
-
await untilApplied(ctx, await ctx.session.append({
|
|
831
|
-
type: "message.linked",
|
|
832
|
-
payload: {
|
|
833
|
-
id: message.id,
|
|
834
|
-
thread: message.thread,
|
|
835
|
-
links: input.links,
|
|
836
|
-
by: input.identity.author,
|
|
837
|
-
from: input.from
|
|
838
|
-
}
|
|
839
|
-
}));
|
|
840
|
-
}
|
|
841
|
-
async function writeDelete(ctx, input) {
|
|
842
|
-
const message = spent(input.authorized, "post");
|
|
843
|
-
if (input.asAuthor !== false && message.by.principal !== input.identity.principal.id) throw forbidden("only the author can delete a message");
|
|
844
|
-
if (message.deleted) return;
|
|
845
|
-
await untilApplied(ctx, await ctx.session.append({
|
|
846
|
-
type: "message.deleted",
|
|
847
|
-
payload: {
|
|
848
|
-
id: message.id,
|
|
849
|
-
thread: message.thread,
|
|
850
|
-
by: input.identity.author,
|
|
851
|
-
from: input.from ?? null
|
|
852
|
-
}
|
|
853
|
-
}));
|
|
854
|
-
}
|
|
855
|
-
async function writeReaction(ctx, input) {
|
|
856
|
-
const message = spent(input.authorized, "react");
|
|
857
|
-
if (message.deleted) throw badRequest("message was deleted", "message_deleted");
|
|
858
|
-
const { state: actors } = await ctx.session.state(actorsReducer);
|
|
859
|
-
const seen = seenFor(input.identity, actors);
|
|
860
|
-
await untilApplied(ctx, await ctx.session.append(...seen ? [seen] : [], {
|
|
861
|
-
type: input.remove ? "reaction.removed" : "reaction.added",
|
|
862
|
-
payload: {
|
|
863
|
-
id: message.id,
|
|
864
|
-
thread: message.thread,
|
|
865
|
-
emoji: input.emoji,
|
|
866
|
-
by: input.identity.author,
|
|
867
|
-
from: input.from ?? null
|
|
868
|
-
}
|
|
869
|
-
}));
|
|
870
|
-
}
|
|
871
|
-
async function writeLabels(ctx, input) {
|
|
872
|
-
const message = spent(input.authorized, "label");
|
|
873
|
-
if (message.deleted) throw badRequest("message was deleted", "message_deleted");
|
|
874
|
-
const { state: actors } = await ctx.session.state(actorsReducer);
|
|
875
|
-
const seen = seenFor(input.identity, actors);
|
|
876
|
-
await untilApplied(ctx, await ctx.session.append(...seen ? [seen] : [], {
|
|
877
|
-
type: "labels.changed",
|
|
878
|
-
payload: {
|
|
879
|
-
id: message.id,
|
|
880
|
-
thread: message.thread,
|
|
881
|
-
set: input.set ?? {},
|
|
882
|
-
unset: input.unset ?? [],
|
|
883
|
-
by: input.identity.author
|
|
884
|
-
}
|
|
885
|
-
}));
|
|
886
|
-
}
|
|
887
|
-
/**
|
|
888
|
-
* Syncs a message's thread with a target, resolved by the sync that delivers it. Attaching twice
|
|
889
|
-
* is a no-op; a platform thread another board thread is synced with is a 409.
|
|
890
|
-
*/
|
|
891
|
-
async function writeAttach(ctx, input) {
|
|
892
|
-
const message = spent(input.authorized, "sync");
|
|
893
|
-
const { target, kind } = input.target;
|
|
894
|
-
await ctx.reads.ensure();
|
|
895
|
-
if ((await ctx.reads.tables.syncOf(message.thread))?.targets.includes(target)) return { attached: false };
|
|
896
|
-
await unclaimed(ctx, input.target, message.thread, input.identity.principal.access);
|
|
897
|
-
await untilApplied(ctx, await ctx.session.append({
|
|
898
|
-
type: "thread.synced",
|
|
899
|
-
id: await idempotentId("board", message.thread, "sync", target),
|
|
900
|
-
payload: {
|
|
901
|
-
thread: message.thread,
|
|
902
|
-
target,
|
|
903
|
-
kind,
|
|
904
|
-
cause: input.cause,
|
|
905
|
-
by: input.identity.author
|
|
906
|
-
}
|
|
907
|
-
}));
|
|
908
|
-
return { attached: true };
|
|
909
|
-
}
|
|
910
|
-
/**
|
|
911
|
-
* Whether a run comes after a retry from a dead letter. A2 keeps counting
|
|
912
|
-
* `ctx.attempt` across one, and no earlier run sees an attempt past A2_TRIES:
|
|
913
|
-
* the event is dead by then. board gives up early, and records why, only in an
|
|
914
|
-
* event's first tries; a retried one gets A2's fresh tries, and dead-letters
|
|
915
|
-
* again if they all fail.
|
|
916
|
-
*/
|
|
917
|
-
const afterRetry = (attempt) => attempt > 10;
|
|
918
|
-
//#endregion
|
|
919
|
-
export { view as A, linkedIds as C, readableFilter as D, pollFeed as E, viewedBy as M, ref as O, indexOf as S, personView as T, waitUntil as _, blindText as a, eventView as b, landedFor as c, writeDelete as d, writeEdit as f, writeReaction as g, writePost as h, authorizePost as i, viewLinkedIds as j, toMessageFilter as k, trusted as l, writeLinks as m, authorizeMessage as n, channelOf as o, writeLabels as p, authorizeOwn as r, deploymentIdentity as s, afterRetry as t, writeAttach as u, OWN_CODE as v, loadBefore as w, foldEvent as x, compileFilter as y };
|