@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,26 +1,41 @@
|
|
|
1
1
|
import { n as DIRECT_UPLOAD_BYTES_MAX, r as IMPORT_BYTES_MAX, t as ATTACHMENT_BYTES_MAX } from "./files-SniSmeEr.mjs";
|
|
2
2
|
import { _ as RemoteWatcherId, b as actorKind, i as Attachment, x as platformAvatar } from "./schemas-Be08nyfW.mjs";
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import { r as duration, t as BoardError } from "./client-
|
|
6
|
-
import {
|
|
7
|
-
import { a as forbidden, c as toErrorResponse, r as badRequest, s as notFound, t as HttpError } from "./errors-DX3hpwWt.mjs";
|
|
8
|
-
import {
|
|
9
|
-
import { n as
|
|
10
|
-
import { i as authenticates } from "./auth-
|
|
11
|
-
import { a as
|
|
12
|
-
import { n as
|
|
13
|
-
import {
|
|
14
|
-
import { t as
|
|
15
|
-
import {
|
|
3
|
+
import "./external-BV_cUncs.mjs";
|
|
4
|
+
import { f as VERSION, n as compileWatcher, o as METHODS, p as SYNC, r as hasCriteria, t as canonicalWatchers } from "./watchers-hash-RLtnSF-t.mjs";
|
|
5
|
+
import { r as duration, t as BoardError } from "./client-BA-J7usH.mjs";
|
|
6
|
+
import { n as derivedId, r as isKeyIdOf } from "./ids-C6AYo1bu.mjs";
|
|
7
|
+
import { a as forbidden, c as toErrorResponse, i as duplicateId, r as badRequest, s as notFound, t as HttpError } from "./errors-DX3hpwWt.mjs";
|
|
8
|
+
import { t as own } from "./own-OwgPWdmh.mjs";
|
|
9
|
+
import { n as replaceMentions } from "./mentions-CB6ShY4R.mjs";
|
|
10
|
+
import { _ as FULL_ACCESS, i as authenticates, v as authorize } from "./auth-Drg9FiC2.mjs";
|
|
11
|
+
import { a as resolveMentions, n as actorSeen } from "./actors-BOamkEuz.mjs";
|
|
12
|
+
import { n as MIDDLEWARE_ACTOR, t as DroppedError } from "./middleware-CqaCwucP.mjs";
|
|
13
|
+
import { n as syncTarget, r as withEmailMentions } from "./targets-C-wdR5wP.mjs";
|
|
14
|
+
import { t as A2Error } from "./session-clear-wHd5GCpZ-BnKSxReK.mjs";
|
|
15
|
+
import { a as reducer } from "./reducer-CjmvZSAb-D_OSISmj.mjs";
|
|
16
|
+
import { _ as BOARD_SESSION, a as IDENTITY_SESSION, c as SCHEDULES_SESSION, d as inboxContract, f as inboxSession, g as watchSession, h as watchContract, l as boardContract, m as schedulesContract, n as channelsReducer, o as INBOXES_SESSION, p as registryContract, r as identityCodesReducer, s as REGISTRY_SESSION, t as actorsReducer, u as identityContract } from "./reducers-BFmIXilu.mjs";
|
|
17
|
+
import { n as createServer, t as consumer } from "./server-DtFrWxi3-Boa6gdWO.mjs";
|
|
18
|
+
import { a as linkedTo, i as iso, n as foldMessages, o as personOf, t as foldActors } from "./folds-Dyjput8M.mjs";
|
|
19
|
+
import { t as concernedMessageId } from "./project-DMDST9kl.mjs";
|
|
20
|
+
import { t as TABLES_CONSUMER } from "./sql-U9WTxrwJ.mjs";
|
|
21
|
+
import { n as waitUntil, t as afterRetry } from "./attempts-CLG840Oj.mjs";
|
|
16
22
|
import { createHash, randomBytes } from "node:crypto";
|
|
17
23
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
18
24
|
import { dirname, join } from "node:path";
|
|
19
25
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
20
|
-
import { consumer, createServer } from "experimental-a2/server";
|
|
21
26
|
import { lookup } from "node:dns";
|
|
22
27
|
import { request } from "node:https";
|
|
23
28
|
import { BlockList, isIP } from "node:net";
|
|
29
|
+
//#region ../../node_modules/.pnpm/experimental-a2@0.22.0_@upstash+qstash@2.12.0_@vercel+queue@0.7.0_ai@7.0.130_zod@4.6.5__52060f00b21aecfab049c27fa3abd2f9/node_modules/experimental-a2/dist/idempotent-id-BRJVeylj.js
|
|
30
|
+
async function idempotentId(...parts) {
|
|
31
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(parts)));
|
|
32
|
+
const bytes = new Uint8Array(digest).slice(0, 16);
|
|
33
|
+
bytes[6] = bytes[6] & 15 | 128;
|
|
34
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
35
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
|
36
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
24
39
|
//#region src/server/blob.ts
|
|
25
40
|
/** Files in memory, for tests: forgets everything when the process ends. */
|
|
26
41
|
function memoryBlob() {
|
|
@@ -196,6 +211,461 @@ async function finishUpload(blob, id, principal) {
|
|
|
196
211
|
return attachment;
|
|
197
212
|
}
|
|
198
213
|
//#endregion
|
|
214
|
+
//#region src/server/feed.ts
|
|
215
|
+
/** Bookkeeping events, shown only with `all`: who's who, and delivery receipts. */
|
|
216
|
+
const HIDDEN = /* @__PURE__ */ new Set([
|
|
217
|
+
"actor.seen",
|
|
218
|
+
"message.synced",
|
|
219
|
+
"message.files_synced"
|
|
220
|
+
]);
|
|
221
|
+
/** The person a linked platform actor is, as views show it. */
|
|
222
|
+
function personView(actors, actor) {
|
|
223
|
+
const link = own(actors.links, actor);
|
|
224
|
+
const person = link ? own(actors.byId, link.to) : void 0;
|
|
225
|
+
return link ? {
|
|
226
|
+
id: link.to,
|
|
227
|
+
handle: person?.handle ?? link.to,
|
|
228
|
+
name: person?.name ?? link.to,
|
|
229
|
+
avatar: person?.avatar ?? null
|
|
230
|
+
} : void 0;
|
|
231
|
+
}
|
|
232
|
+
function actorView(actors, id) {
|
|
233
|
+
const author = own(actors.byId, id);
|
|
234
|
+
if (!author) return null;
|
|
235
|
+
const person = personView(actors, id);
|
|
236
|
+
return {
|
|
237
|
+
kind: author.kind,
|
|
238
|
+
handle: author.handle,
|
|
239
|
+
name: author.name,
|
|
240
|
+
avatar: author.avatar,
|
|
241
|
+
...person ? { person } : {}
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/** The board's own code, which reads every channel: it needs no messages to say so. */
|
|
245
|
+
const OWN_CODE = {
|
|
246
|
+
access: FULL_ACCESS,
|
|
247
|
+
channelOf: () => void 0
|
|
248
|
+
};
|
|
249
|
+
/** The board messages `links` point to, for `Reader.channelOf`. */
|
|
250
|
+
const linkedIds = (links) => links.flatMap((link) => link.message === null ? [] : [link.message]);
|
|
251
|
+
/**
|
|
252
|
+
* Links as `reader` may see them. The log keeps each snapshot; it's hidden when
|
|
253
|
+
* it's read. A linked message the reader's lookup doesn't have hides too: its
|
|
254
|
+
* snapshot shows only where it's known to be readable.
|
|
255
|
+
*/
|
|
256
|
+
function readableLinks(links, reader) {
|
|
257
|
+
return links.map((link) => {
|
|
258
|
+
if (link.message === null) return {
|
|
259
|
+
...link,
|
|
260
|
+
message: null
|
|
261
|
+
};
|
|
262
|
+
const channel = reader.access.readsAll ? void 0 : reader.channelOf(link.message);
|
|
263
|
+
return reader.access.readsAll || channel !== void 0 && reader.access.readable(channel) ? {
|
|
264
|
+
...link,
|
|
265
|
+
message: ref(link.message)
|
|
266
|
+
} : {
|
|
267
|
+
url: link.url,
|
|
268
|
+
message: null,
|
|
269
|
+
snapshot: null,
|
|
270
|
+
unavailable: "not_readable"
|
|
271
|
+
};
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
/** The links in an event view: its payload's, its message's, and the message before a label change. */
|
|
275
|
+
const viewLinks = (view) => {
|
|
276
|
+
const { payload, message, before } = view;
|
|
277
|
+
return [
|
|
278
|
+
payload?.links,
|
|
279
|
+
message?.links,
|
|
280
|
+
before?.links
|
|
281
|
+
].filter((links) => links !== void 0);
|
|
282
|
+
};
|
|
283
|
+
/** The board messages an event view's links point to, for a reader of them. */
|
|
284
|
+
const viewLinkedIds = (view) => viewLinks(view).flatMap((links) => links.flatMap((link) => link.message === null ? [] : [indexOf(link.message) ?? 0]));
|
|
285
|
+
/**
|
|
286
|
+
* An event view made for one reader, as `reader` sees it: a link to a message it can't read shows
|
|
287
|
+
* nothing of it. A watcher's match is made when its event lands and pulled later, by whatever its
|
|
288
|
+
* registrant may read then.
|
|
289
|
+
*/
|
|
290
|
+
function viewedBy(view, reader) {
|
|
291
|
+
if (reader.access.readsAll) return view;
|
|
292
|
+
const shown = structuredClone(view);
|
|
293
|
+
const reread = (links) => readableLinks(links.map((link) => ({
|
|
294
|
+
...link,
|
|
295
|
+
message: link.message === null ? null : indexOf(link.message) ?? 0
|
|
296
|
+
})), reader);
|
|
297
|
+
if (shown.payload?.links) shown.payload.links = reread(shown.payload.links);
|
|
298
|
+
if (shown.message) shown.message.links = reread(shown.message.links);
|
|
299
|
+
if (shown.before) shown.before.links = reread(shown.before.links);
|
|
300
|
+
return shown;
|
|
301
|
+
}
|
|
302
|
+
/** A message's or a cursor's ID as clients see it. On this board it's the log index, in decimal. */
|
|
303
|
+
const ref = (index) => String(index);
|
|
304
|
+
/** The log index a ref names on this board, or null for a string that names nothing here. */
|
|
305
|
+
const indexOf = (value) => /^(0|[1-9]\d{0,15})$/.test(value) ? Number(value) : null;
|
|
306
|
+
/** A message as `reader` sees it. Every view names its reader, so none shows a link's snapshot by default. */
|
|
307
|
+
function view(message, actors, reader) {
|
|
308
|
+
const { blind, version, schedule, ...shown } = message;
|
|
309
|
+
return {
|
|
310
|
+
...shown,
|
|
311
|
+
...blind ? { blind } : {},
|
|
312
|
+
id: ref(message.id),
|
|
313
|
+
re: message.re === null ? null : ref(message.re),
|
|
314
|
+
replyTo: message.replyTo === null ? null : ref(message.replyTo),
|
|
315
|
+
version: version ?? message.id,
|
|
316
|
+
...schedule ? { schedule: {
|
|
317
|
+
id: ref(schedule.id),
|
|
318
|
+
n: schedule.n
|
|
319
|
+
} } : {},
|
|
320
|
+
links: readableLinks(message.links, reader),
|
|
321
|
+
labels: { ...message.labels },
|
|
322
|
+
reactions: Object.fromEntries(Object.entries(message.reactions).map(([emoji, ids]) => [emoji, [...ids]])),
|
|
323
|
+
author: actorView(actors, message.by.actor)
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/** Whether accounts are linked to `id`: then it's a person, even one who never wrote on the board. */
|
|
327
|
+
const hasLinks = (actors, id) => Object.values(actors.links).some((link) => link.to === id);
|
|
328
|
+
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()) ?? []);
|
|
329
|
+
/**
|
|
330
|
+
* Who a `--by` or `--mentions` value covers. A person (`julian`, or one of their
|
|
331
|
+
* linked accounts, `@julian` on Slack) is everyone they are: their own actor,
|
|
332
|
+
* their agents (`julian/claude`), and every account linked to them. An agent
|
|
333
|
+
* or an unlinked platform actor is just itself.
|
|
334
|
+
*/
|
|
335
|
+
function whoIs(value, actors) {
|
|
336
|
+
const ids = actorSet(value, actors);
|
|
337
|
+
const out = new Set(ids);
|
|
338
|
+
const principals = /* @__PURE__ */ new Set();
|
|
339
|
+
for (const id of ids) {
|
|
340
|
+
const kind = own(actors.byId, id)?.kind;
|
|
341
|
+
const person = own(actors.links, id) ? personOf(actors, id) : kind === "person" || hasLinks(actors, id) ? id : null;
|
|
342
|
+
if (person === null) continue;
|
|
343
|
+
principals.add(person);
|
|
344
|
+
out.add(person);
|
|
345
|
+
for (const linked of linkedTo(actors, person)) out.add(linked);
|
|
346
|
+
}
|
|
347
|
+
return {
|
|
348
|
+
actors: out,
|
|
349
|
+
principals
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/** Whether `author` is one of `who`: its actor, or an agent of one of its people. */
|
|
353
|
+
const covers = (who, actor, principal) => who.actors.has(actor) || principal !== void 0 && who.principals.has(principal);
|
|
354
|
+
const hasLabel = (labels, [key, values]) => Object.hasOwn(labels, key) && (values === null || values.includes(labels[key]));
|
|
355
|
+
/**
|
|
356
|
+
* Compiles filters once; handles resolve against the actors known at that
|
|
357
|
+
* moment. A label matches on the message or its thread's root, where sync puts
|
|
358
|
+
* labels, so `pr=…` means the PR's whole conversation. `messages` is the live
|
|
359
|
+
* state for that lookup. A message in a channel `access` can't read never
|
|
360
|
+
* matches, whatever the filters say.
|
|
361
|
+
*/
|
|
362
|
+
function compileFilter(filters, context) {
|
|
363
|
+
const { access } = context;
|
|
364
|
+
const text = filters.text?.toLowerCase();
|
|
365
|
+
const labels = filters.label ?? [];
|
|
366
|
+
const channel = filters.channel;
|
|
367
|
+
const subtree = channel?.endsWith("/") ? channel.slice(0, -1) : null;
|
|
368
|
+
const by = filters.by === void 0 ? null : whoIs(filters.by, context.actors);
|
|
369
|
+
const re = filters.re === void 0 ? void 0 : indexOf(filters.re) ?? -1;
|
|
370
|
+
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;
|
|
371
|
+
return (message) => {
|
|
372
|
+
if (!message || !access.readable(message.channel)) return false;
|
|
373
|
+
if (channel !== void 0) {
|
|
374
|
+
if (subtree !== null) {
|
|
375
|
+
if (message.channel !== subtree && !message.channel.startsWith(`${subtree}/`)) return false;
|
|
376
|
+
} else if (message.channel !== channel) return false;
|
|
377
|
+
}
|
|
378
|
+
if (re !== void 0 && message.id !== re && message.re !== re) return false;
|
|
379
|
+
if (by !== null && !covers(by, message.by.actor, message.by.principal)) return false;
|
|
380
|
+
if (mentions !== null && !message.mentions.some((id) => mentions.has(id))) return false;
|
|
381
|
+
if (filters.from !== void 0 && (message.from ?? "board") !== filters.from) return false;
|
|
382
|
+
if (text !== void 0 && !message.text.toLowerCase().includes(text)) return false;
|
|
383
|
+
if (labels.length > 0) {
|
|
384
|
+
const root = message.re === null ? void 0 : context.messages?.byId[message.re];
|
|
385
|
+
for (const label of labels) if (!hasLabel(message.labels, label) && !(root && hasLabel(root.labels, label))) return false;
|
|
386
|
+
}
|
|
387
|
+
return true;
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* `filter`, narrowed to the channels `access` reads among `known`, as the tables
|
|
392
|
+
* take it: a reader of every channel needs nothing more. A channel the caller
|
|
393
|
+
* didn't know of is left out, never let in.
|
|
394
|
+
*/
|
|
395
|
+
function readableFilter(filter, known, access) {
|
|
396
|
+
if (access.readsAll) return filter;
|
|
397
|
+
const { channel, ...rest } = filter;
|
|
398
|
+
const within = (path) => !channel || path === channel.path || channel.subtree && path.startsWith(`${channel.path}/`);
|
|
399
|
+
return {
|
|
400
|
+
...rest,
|
|
401
|
+
channels: [...known].filter((path) => within(path) && access.readable(path))
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
/** The filters as the tables take them: handles resolved to who they cover, `me` to the caller. */
|
|
405
|
+
function toMessageFilter(filters, context) {
|
|
406
|
+
const out = {};
|
|
407
|
+
const channel = filters.channel;
|
|
408
|
+
if (channel !== void 0) out.channel = channel.endsWith("/") ? {
|
|
409
|
+
path: channel.slice(0, -1),
|
|
410
|
+
subtree: true
|
|
411
|
+
} : {
|
|
412
|
+
path: channel,
|
|
413
|
+
subtree: false
|
|
414
|
+
};
|
|
415
|
+
if (filters.re !== void 0) out.re = indexOf(filters.re) ?? -1;
|
|
416
|
+
if (filters.by !== void 0) {
|
|
417
|
+
const who = whoIs(filters.by, context.actors);
|
|
418
|
+
out.by = {
|
|
419
|
+
actors: [...who.actors],
|
|
420
|
+
principals: [...who.principals]
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
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];
|
|
424
|
+
if (filters.from !== void 0) out.from = filters.from;
|
|
425
|
+
if (filters.text !== void 0) out.text = filters.text.toLowerCase();
|
|
426
|
+
if (filters.label?.length) out.labels = filters.label.map(([key, values]) => ({
|
|
427
|
+
key,
|
|
428
|
+
values
|
|
429
|
+
}));
|
|
430
|
+
return out;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Loads into `messages`, as they were just before `event`, the message it
|
|
434
|
+
* concerns and that message's root: what folding it and matching filters need.
|
|
435
|
+
* `loaded` remembers what was asked for already, found or not.
|
|
436
|
+
*/
|
|
437
|
+
async function loadInto(reads, messages, loaded, event, signal) {
|
|
438
|
+
const id = concernedMessageId(event);
|
|
439
|
+
if (id === null) return;
|
|
440
|
+
const through = event.index - 1;
|
|
441
|
+
const load = async (ids) => {
|
|
442
|
+
const wanted = ids.filter((want) => !loaded.has(want));
|
|
443
|
+
if (wanted.length === 0) return;
|
|
444
|
+
const found = await reads.messagesAt(wanted, through, signal);
|
|
445
|
+
for (const want of wanted) {
|
|
446
|
+
loaded.add(want);
|
|
447
|
+
const message = found.get(want);
|
|
448
|
+
if (message) messages.byId[want] = message;
|
|
449
|
+
}
|
|
450
|
+
};
|
|
451
|
+
if (event.type === "message.posted") loaded.add(id);
|
|
452
|
+
else await load([id]);
|
|
453
|
+
const re = event.type === "message.posted" ? event.payload.re : messages.byId[id]?.re ?? null;
|
|
454
|
+
if (re !== null) await load([re]);
|
|
455
|
+
}
|
|
456
|
+
/** The message `event` concerns and its root, as they were just before it. */
|
|
457
|
+
async function loadBefore(reads, event, signal) {
|
|
458
|
+
const messages = {
|
|
459
|
+
byId: {},
|
|
460
|
+
replies: {}
|
|
461
|
+
};
|
|
462
|
+
await loadInto(reads, messages, /* @__PURE__ */ new Set(), event, signal);
|
|
463
|
+
return messages;
|
|
464
|
+
}
|
|
465
|
+
/** Folds one event into `messages` and `actors`, and returns the message it concerns before and after. */
|
|
466
|
+
function foldEvent(messages, actors, event) {
|
|
467
|
+
const id = concernedMessageId(event);
|
|
468
|
+
const before = id === null ? void 0 : structuredClone(messages.byId[id]);
|
|
469
|
+
foldMessages(messages, event);
|
|
470
|
+
foldActors(actors, event);
|
|
471
|
+
return {
|
|
472
|
+
id,
|
|
473
|
+
before,
|
|
474
|
+
after: id === null ? void 0 : messages.byId[id]
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
/** An event as subscribers receive it, with its message as it is afterwards. */
|
|
478
|
+
function eventView(event, after, actors, reader) {
|
|
479
|
+
const by = event.payload.by?.actor;
|
|
480
|
+
const actor = by ? own(actors.byId, by) : void 0;
|
|
481
|
+
const { links } = event.payload;
|
|
482
|
+
return {
|
|
483
|
+
cursor: ref(event.index),
|
|
484
|
+
type: event.type,
|
|
485
|
+
createdAt: iso(event.createdAt),
|
|
486
|
+
payload: payloadView(event.payload, links ? readableLinks(links, reader) : void 0),
|
|
487
|
+
...after ? { message: view(after, actors, reader) } : {},
|
|
488
|
+
...actor ? { actor: {
|
|
489
|
+
id: actor.id,
|
|
490
|
+
...actorView(actors, actor.id)
|
|
491
|
+
} } : {}
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
/** An event's payload as clients see it: the message IDs in it are refs. */
|
|
495
|
+
function payloadView(payload, links) {
|
|
496
|
+
const shown = { ...payload };
|
|
497
|
+
for (const key of [
|
|
498
|
+
"id",
|
|
499
|
+
"re",
|
|
500
|
+
"replyTo"
|
|
501
|
+
]) if (typeof shown[key] === "number") shown[key] = ref(shown[key]);
|
|
502
|
+
const schedule = shown.schedule;
|
|
503
|
+
if (schedule) shown.schedule = {
|
|
504
|
+
id: ref(schedule.id),
|
|
505
|
+
n: schedule.n
|
|
506
|
+
};
|
|
507
|
+
if (links) shown.links = links;
|
|
508
|
+
return shown;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Folds events one at a time and decides what a subscriber sees. It holds only
|
|
512
|
+
* the messages events have touched, each loaded from the tables as it was just
|
|
513
|
+
* before the first event that touched it. A label or reaction change is
|
|
514
|
+
* delivered when its message matched the filter before *or* after it, so
|
|
515
|
+
* subscribers see messages leave a view too.
|
|
516
|
+
*
|
|
517
|
+
* It's the one choke point for `/events` (tail, wait, and poll): an event about
|
|
518
|
+
* a message shows only where `access` reads its channel, and one about no
|
|
519
|
+
* message (who's who, a thread's sync) only to a reader of every channel.
|
|
520
|
+
*/
|
|
521
|
+
var FeedProjection = class {
|
|
522
|
+
messages = {
|
|
523
|
+
byId: {},
|
|
524
|
+
replies: {}
|
|
525
|
+
};
|
|
526
|
+
loaded = /* @__PURE__ */ new Set();
|
|
527
|
+
reads;
|
|
528
|
+
actors;
|
|
529
|
+
match;
|
|
530
|
+
criteria;
|
|
531
|
+
all;
|
|
532
|
+
access;
|
|
533
|
+
/** Where each board message a view links to lives, for a reader that can't read every channel. */
|
|
534
|
+
linked = /* @__PURE__ */ new Map();
|
|
535
|
+
constructor(options) {
|
|
536
|
+
this.reads = options.reads;
|
|
537
|
+
this.actors = options.actors;
|
|
538
|
+
this.access = options.access;
|
|
539
|
+
this.match = compileFilter(options.filters, {
|
|
540
|
+
me: options.me,
|
|
541
|
+
actors: options.actors,
|
|
542
|
+
messages: this.messages,
|
|
543
|
+
access: options.access
|
|
544
|
+
});
|
|
545
|
+
this.criteria = hasCriteria(options.filters);
|
|
546
|
+
this.all = options.all;
|
|
547
|
+
}
|
|
548
|
+
/** Loads what `events` touch, all as of `index`, in two queries rather than two per event. */
|
|
549
|
+
async preload(events, index) {
|
|
550
|
+
const ids = /* @__PURE__ */ new Set();
|
|
551
|
+
for (const event of events) {
|
|
552
|
+
const id = concernedMessageId(event);
|
|
553
|
+
if (id === null) continue;
|
|
554
|
+
if (event.type !== "message.posted") ids.add(id);
|
|
555
|
+
else if (event.payload.re !== null) ids.add(event.payload.re);
|
|
556
|
+
}
|
|
557
|
+
const load = async (wanted) => {
|
|
558
|
+
if (wanted.length === 0) return;
|
|
559
|
+
const found = await this.reads.messagesAt(wanted, index);
|
|
560
|
+
for (const id of wanted) {
|
|
561
|
+
this.loaded.add(id);
|
|
562
|
+
const message = found.get(id);
|
|
563
|
+
if (message) this.messages.byId[id] = message;
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
await load([...ids].filter((id) => !this.loaded.has(id) && id <= index));
|
|
567
|
+
const roots = Object.values(this.messages.byId).flatMap((message) => message.re === null ? [] : [message.re]);
|
|
568
|
+
await load([...new Set(roots)].filter((id) => !this.loaded.has(id)));
|
|
569
|
+
}
|
|
570
|
+
async step(event, signal) {
|
|
571
|
+
await loadInto(this.reads, this.messages, this.loaded, event, signal);
|
|
572
|
+
const { id, before, after } = foldEvent(this.messages, this.actors, event);
|
|
573
|
+
const message = after ?? before;
|
|
574
|
+
const readable = id === null ? this.access.readsAll : message !== void 0 && this.access.readable(message.channel);
|
|
575
|
+
const hidden = HIDDEN.has(event.type) && !this.all;
|
|
576
|
+
if (!(readable && !hidden && (this.criteria ? this.match(before) || this.match(after) : id !== null || this.all))) return {
|
|
577
|
+
index: event.index,
|
|
578
|
+
view: null
|
|
579
|
+
};
|
|
580
|
+
await this.loadLinked([...after?.links ?? [], ...event.payload.links ?? []], event.index, signal);
|
|
581
|
+
const reader = {
|
|
582
|
+
access: this.access,
|
|
583
|
+
channelOf: (message) => this.messages.byId[message]?.channel ?? this.linked.get(message)
|
|
584
|
+
};
|
|
585
|
+
return {
|
|
586
|
+
index: event.index,
|
|
587
|
+
view: eventView(event, after, this.actors, reader)
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
/** Where the messages `links` point to live; a message never moves, so any version says. */
|
|
591
|
+
async loadLinked(links, index, signal) {
|
|
592
|
+
if (this.access.readsAll) return;
|
|
593
|
+
const wanted = [...new Set(linkedIds(links))].filter((id) => !this.messages.byId[id] && !this.linked.has(id));
|
|
594
|
+
if (wanted.length === 0) return;
|
|
595
|
+
const found = await this.reads.messagesAt(wanted, index, signal);
|
|
596
|
+
for (const id of wanted) this.linked.set(id, found.get(id)?.channel);
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
/** Resolves when the iterator yields, or with null once the signal aborts. */
|
|
600
|
+
function nextOrAbort(iterator, signal) {
|
|
601
|
+
if (signal.aborted) return Promise.resolve(null);
|
|
602
|
+
return new Promise((resolve, reject) => {
|
|
603
|
+
const onAbort = () => resolve(null);
|
|
604
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
605
|
+
iterator.next().then((result) => {
|
|
606
|
+
signal.removeEventListener("abort", onAbort);
|
|
607
|
+
resolve(result);
|
|
608
|
+
}, (error) => {
|
|
609
|
+
signal.removeEventListener("abort", onAbort);
|
|
610
|
+
reject(error);
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
/** Where a feed starts: `after`, or the head when it's omitted or past it. */
|
|
615
|
+
async function start(options) {
|
|
616
|
+
const { index: head } = await options.session.state(channelsReducer);
|
|
617
|
+
return options.after === void 0 || options.after >= head ? head : options.after;
|
|
618
|
+
}
|
|
619
|
+
const PAGE = 200;
|
|
620
|
+
/**
|
|
621
|
+
* One long-poll: matching events already in the log return at once; otherwise
|
|
622
|
+
* it waits up to `waitMs` for the first new match. The cursor is the last
|
|
623
|
+
* event examined, matched or not, so the next poll never rescans.
|
|
624
|
+
*/
|
|
625
|
+
async function pollFeed(options, waitMs, signal) {
|
|
626
|
+
const index = await start(options);
|
|
627
|
+
const feed = new FeedProjection(options);
|
|
628
|
+
const events = [];
|
|
629
|
+
let cursor = index;
|
|
630
|
+
const backlog = await options.session.history({
|
|
631
|
+
gte: index + 1,
|
|
632
|
+
lte: index + PAGE
|
|
633
|
+
});
|
|
634
|
+
await feed.preload(backlog, index);
|
|
635
|
+
for (const event of backlog) {
|
|
636
|
+
const item = await feed.step(event, signal);
|
|
637
|
+
cursor = item.index;
|
|
638
|
+
if (item.view) events.push(item.view);
|
|
639
|
+
}
|
|
640
|
+
if (events.length > 0 || backlog.length === PAGE || waitMs <= 0) return {
|
|
641
|
+
events,
|
|
642
|
+
cursor
|
|
643
|
+
};
|
|
644
|
+
const timeout = AbortSignal.timeout(waitMs);
|
|
645
|
+
const until = AbortSignal.any([signal, timeout]);
|
|
646
|
+
const iterator = options.session.stream({ startAfter: cursor })[Symbol.asyncIterator]();
|
|
647
|
+
try {
|
|
648
|
+
while (true) {
|
|
649
|
+
const next = await nextOrAbort(iterator, until);
|
|
650
|
+
if (next === null || next.done) return {
|
|
651
|
+
events,
|
|
652
|
+
cursor
|
|
653
|
+
};
|
|
654
|
+
const item = await feed.step(next.value, until);
|
|
655
|
+
cursor = item.index;
|
|
656
|
+
if (item.view) {
|
|
657
|
+
events.push(item.view);
|
|
658
|
+
return {
|
|
659
|
+
events,
|
|
660
|
+
cursor
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
} finally {
|
|
665
|
+
iterator.return?.();
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
//#endregion
|
|
199
669
|
//#region src/server/reads.ts
|
|
200
670
|
/** Keeps the tables from the board's log, one event at a time, from the first. */
|
|
201
671
|
function tablesConsumer(tables) {
|
|
@@ -300,6 +770,426 @@ function createReads(tables, session, projection) {
|
|
|
300
770
|
};
|
|
301
771
|
}
|
|
302
772
|
//#endregion
|
|
773
|
+
//#region src/server/writes.ts
|
|
774
|
+
/** A piece of the board's own deployment, writing as `actor` with full access. */
|
|
775
|
+
function deploymentIdentity(actor, handle) {
|
|
776
|
+
return {
|
|
777
|
+
principal: {
|
|
778
|
+
id: actor,
|
|
779
|
+
kind: "deployment",
|
|
780
|
+
handle,
|
|
781
|
+
access: FULL_ACCESS,
|
|
782
|
+
expiresAt: null
|
|
783
|
+
},
|
|
784
|
+
author: {
|
|
785
|
+
actor,
|
|
786
|
+
principal: actor,
|
|
787
|
+
profile: null,
|
|
788
|
+
session: null
|
|
789
|
+
},
|
|
790
|
+
actor: {
|
|
791
|
+
id: actor,
|
|
792
|
+
kind: "deployment",
|
|
793
|
+
handle,
|
|
794
|
+
name: handle,
|
|
795
|
+
avatar: null
|
|
796
|
+
}
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* The `actor.seen` a write carries, or null: who its author is, as the board's record of them
|
|
801
|
+
* says, unless the board knows that already. A platform user's record is board-wide, so a write
|
|
802
|
+
* whose identity doesn't keep it (`records: false`) leaves it as it is. A host's principal and its
|
|
803
|
+
* profiles are recorded as the host's, which sync never writes as.
|
|
804
|
+
*/
|
|
805
|
+
function seenFor(identity, actors) {
|
|
806
|
+
if (identity.records === false) return null;
|
|
807
|
+
return actorSeen(identity.actor, actors, identity.principal.facts?.issuer === "host");
|
|
808
|
+
}
|
|
809
|
+
/** The actors a write can mention: the ones the board knows, plus the ones an email named. */
|
|
810
|
+
const knownWith = (actors, found) => found.length === 0 ? actors : {
|
|
811
|
+
...actors,
|
|
812
|
+
byId: {
|
|
813
|
+
...actors.byId,
|
|
814
|
+
...Object.fromEntries(found.map((a) => [a.id, a]))
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
const messageNotFound = (id) => () => notFound(`message not found: #${id}`, "message_not_found");
|
|
818
|
+
/**
|
|
819
|
+
* A message the board looked up and the caller may act on with `permission`,
|
|
820
|
+
* proved against its own channel. A message they can't read answers 404, as if
|
|
821
|
+
* it didn't exist: IDs are sequential, so a 403 would confirm it does.
|
|
822
|
+
*/
|
|
823
|
+
async function authorizeMessage(ctx, access, permission, id) {
|
|
824
|
+
const message = await ctx.reads.message(id);
|
|
825
|
+
if (!message) throw messageNotFound(id)();
|
|
826
|
+
return {
|
|
827
|
+
message,
|
|
828
|
+
proof: authorize(access, permission, message.channel, messageNotFound(id))
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Your own message, to edit or delete: `post` in its channel. Where you can't
|
|
833
|
+
* read, it's yours only if it was posted without reading too: a write-only
|
|
834
|
+
* source fixes what it wrote, but a principal ID it shares with a caller that
|
|
835
|
+
* reads (a repo's workflows share one) doesn't reach that caller's messages.
|
|
836
|
+
* Someone else's answers 404 where you can't read it, and 403 where you can.
|
|
837
|
+
*/
|
|
838
|
+
async function authorizeOwn(ctx, principal, id, verb) {
|
|
839
|
+
const message = await ctx.reads.message(id);
|
|
840
|
+
if (!message) throw messageNotFound(id)();
|
|
841
|
+
const readable = principal.access.readable(message.channel);
|
|
842
|
+
if (message.by.principal !== principal.id || !readable && !message.blind) {
|
|
843
|
+
if (!readable) throw messageNotFound(id)();
|
|
844
|
+
throw forbidden(`only the author can ${verb} a message`);
|
|
845
|
+
}
|
|
846
|
+
return {
|
|
847
|
+
message,
|
|
848
|
+
proof: authorize(principal.access, "post", message.channel)
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
const hasLabels = (labels) => Object.keys(labels ?? {}).length > 0;
|
|
852
|
+
/**
|
|
853
|
+
* Proofs for a post. A root lands in the channel it names, which the caller
|
|
854
|
+
* needn't read: write-only is `['post']`. A reply lands in its root's channel,
|
|
855
|
+
* looked up here and never taken from the request, and needs `read` there too,
|
|
856
|
+
* since addressing a thread you can't see would confirm it exists.
|
|
857
|
+
*/
|
|
858
|
+
async function authorizePost(ctx, access, input) {
|
|
859
|
+
let channel;
|
|
860
|
+
let re;
|
|
861
|
+
if (input.re !== void 0) {
|
|
862
|
+
re = (await authorizeMessage(ctx, access, "read", input.re)).message;
|
|
863
|
+
channel = re.channel;
|
|
864
|
+
} else {
|
|
865
|
+
if (!input.channel) throw badRequest("a post needs a channel");
|
|
866
|
+
channel = input.channel;
|
|
867
|
+
}
|
|
868
|
+
return {
|
|
869
|
+
post: authorize(access, "post", channel),
|
|
870
|
+
...re ? { re } : {},
|
|
871
|
+
...hasLabels(input.labels) ? { label: authorize(access, "label", channel) } : {},
|
|
872
|
+
...input.sync?.length ? { sync: authorize(access, "sync", channel) } : {}
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Spends a proof on its target: a write checked against one channel can't land
|
|
877
|
+
* in another. A mismatch is the board's own bug, never the caller's.
|
|
878
|
+
*/
|
|
879
|
+
function spend(proof, permission, target) {
|
|
880
|
+
if (!proof || proof.permission !== permission || proof.channel !== target.channel) throw new Error(`a ${permission} write in ${target.channel} needs a proof for that channel`);
|
|
881
|
+
}
|
|
882
|
+
/** Full-access proofs for the board's own code (sync), still checked against the channel each write lands in. */
|
|
883
|
+
const trusted = {
|
|
884
|
+
message: (ctx, permission, id) => authorizeMessage(ctx, FULL_ACCESS, permission, id),
|
|
885
|
+
post: (ctx, input) => authorizePost(ctx, FULL_ACCESS, input)
|
|
886
|
+
};
|
|
887
|
+
async function writePost(ctx, input) {
|
|
888
|
+
const { identity } = input;
|
|
889
|
+
const sent = input.text;
|
|
890
|
+
let channel = input.channel;
|
|
891
|
+
let re = null;
|
|
892
|
+
let replyTo = null;
|
|
893
|
+
let thread;
|
|
894
|
+
const id = input.id ?? (input.re === void 0 ? crypto.randomUUID() : void 0);
|
|
895
|
+
if (input.re !== void 0) {
|
|
896
|
+
const target = input.proofs.re;
|
|
897
|
+
if (target?.id !== input.re) throw new Error(`a reply to #${input.re} needs proofs for that message`);
|
|
898
|
+
re = target.re ?? target.id;
|
|
899
|
+
replyTo = target.id === re ? null : target.id;
|
|
900
|
+
channel = target.channel;
|
|
901
|
+
thread = target.thread;
|
|
902
|
+
if (input.sync?.length) throw badRequest("sync attaches whole threads: use `board sync <id> <target>`");
|
|
903
|
+
} else {
|
|
904
|
+
if (!channel) throw badRequest("a post needs a channel");
|
|
905
|
+
thread = id;
|
|
906
|
+
}
|
|
907
|
+
spend(input.proofs.post, "post", { channel });
|
|
908
|
+
if (hasLabels(input.labels)) spend(input.proofs.label, "label", { channel });
|
|
909
|
+
if (input.sync?.length) spend(input.proofs.sync, "sync", { channel });
|
|
910
|
+
if (input.id !== void 0) {
|
|
911
|
+
const landed = await ctx.reads.byEventId(input.id);
|
|
912
|
+
if (landed) {
|
|
913
|
+
if (landed.channel !== channel || landed.re !== re || landed.replyTo !== replyTo) throw duplicateId();
|
|
914
|
+
const shown = await landedFor(ctx, identity, landed);
|
|
915
|
+
if (!shown) throw duplicateId();
|
|
916
|
+
return shownTo(shown, identity.principal.access, sent);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return commitPost(ctx, input, {
|
|
920
|
+
channel,
|
|
921
|
+
re,
|
|
922
|
+
replyTo,
|
|
923
|
+
thread,
|
|
924
|
+
id,
|
|
925
|
+
sent
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* A post that landed, as its author may have it back: only the same author's, and one in a
|
|
930
|
+
* channel it can't read only if it posted without reading too. A caller that can't read gets back
|
|
931
|
+
* what it sent, as it landed: never what others have added since. Null for anyone else.
|
|
932
|
+
*/
|
|
933
|
+
async function landedFor(ctx, identity, landed) {
|
|
934
|
+
if (!sameAuthor(landed.by, identity.author)) return null;
|
|
935
|
+
const { access } = identity.principal;
|
|
936
|
+
const { state: actors } = await ctx.session.state(actorsReducer);
|
|
937
|
+
if (access.readable(landed.channel)) return view(landed, actors, await readerFor(ctx, access, landed));
|
|
938
|
+
if (!landed.blind) return null;
|
|
939
|
+
const [event] = await ctx.session.history({
|
|
940
|
+
gte: landed.id,
|
|
941
|
+
lte: landed.id
|
|
942
|
+
});
|
|
943
|
+
const posted = foldMessages({
|
|
944
|
+
byId: {},
|
|
945
|
+
replies: {}
|
|
946
|
+
}, event).byId[landed.id];
|
|
947
|
+
return {
|
|
948
|
+
...view(posted, actors, await readerFor(ctx, access, posted)),
|
|
949
|
+
text: blindText(posted.text, identity.actor.id),
|
|
950
|
+
mentions: []
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* A message's text for its writer where it can't read: each exact mention but of itself shows as
|
|
955
|
+
* `@…`. The board may have turned an email it wrote into an actor ID, and who that is, `/actors`
|
|
956
|
+
* doesn't show it either.
|
|
957
|
+
*/
|
|
958
|
+
const blindText = (text, own) => replaceMentions(text, (token) => token.kind === "actor" && token.actor !== own ? "@…" : null);
|
|
959
|
+
const sameAuthor = (a, b) => a.actor === b.actor && a.principal === b.principal && a.profile === b.profile && a.session === b.session;
|
|
960
|
+
async function commitPost(ctx, input, placed) {
|
|
961
|
+
const { identity } = input;
|
|
962
|
+
const { channel, re, replyTo, thread, id, sent } = placed;
|
|
963
|
+
const { state: actors } = await ctx.session.state(actorsReducer);
|
|
964
|
+
const targets = [];
|
|
965
|
+
if (re === null) for (const requested of input.sync ?? []) {
|
|
966
|
+
const target = syncTarget(requested);
|
|
967
|
+
await unclaimed(ctx, target, thread, identity.principal.access);
|
|
968
|
+
if (!targets.some((t) => t.target === target.target)) targets.push({
|
|
969
|
+
...target,
|
|
970
|
+
cause: "request"
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
const seen = seenFor(identity, actors);
|
|
974
|
+
const { text, found } = await withEmailMentions(input.text);
|
|
975
|
+
const named = found.map((actor) => actorSeen(actor, actors)).filter((event) => event !== null);
|
|
976
|
+
const { mentions, unknown } = resolveMentions(text, knownWith(actors, found), seen ? identity.actor : void 0, input.lenientMentions ?? false, identity.principal.access.readsAll);
|
|
977
|
+
const synced = await Promise.all(targets.map(async ({ target, kind, cause }) => ({
|
|
978
|
+
type: "thread.synced",
|
|
979
|
+
id: await derivedId("board", thread, "sync", target),
|
|
980
|
+
payload: {
|
|
981
|
+
thread,
|
|
982
|
+
target,
|
|
983
|
+
kind,
|
|
984
|
+
cause,
|
|
985
|
+
by: identity.author
|
|
986
|
+
}
|
|
987
|
+
})));
|
|
988
|
+
const rows = await ctx.session.append(...seen ? [seen] : [], ...named, {
|
|
989
|
+
type: "message.posted",
|
|
990
|
+
...id ? { id } : {},
|
|
991
|
+
payload: {
|
|
992
|
+
channel,
|
|
993
|
+
text,
|
|
994
|
+
re,
|
|
995
|
+
replyTo,
|
|
996
|
+
thread,
|
|
997
|
+
labels: input.labels ?? {},
|
|
998
|
+
noSync: input.noSync ?? false,
|
|
999
|
+
mentions,
|
|
1000
|
+
by: identity.author,
|
|
1001
|
+
origin: input.origin ?? null,
|
|
1002
|
+
from: input.from ?? null,
|
|
1003
|
+
...input.attachments?.length ? { attachments: input.attachments } : {},
|
|
1004
|
+
...input.links?.length ? { links: input.links } : {},
|
|
1005
|
+
...input.schedule ? { schedule: input.schedule } : {},
|
|
1006
|
+
...identity.principal.access.readable(channel) ? {} : { blind: true },
|
|
1007
|
+
...input.importedBy !== void 0 ? { importedBy: input.importedBy } : {}
|
|
1008
|
+
}
|
|
1009
|
+
}, ...synced);
|
|
1010
|
+
const posted = rows.find((row) => row.type === "message.posted");
|
|
1011
|
+
await untilApplied(ctx, rows);
|
|
1012
|
+
const message = foldMessages({
|
|
1013
|
+
byId: {},
|
|
1014
|
+
replies: {}
|
|
1015
|
+
}, posted).byId[posted.index];
|
|
1016
|
+
const known = seen ? {
|
|
1017
|
+
...actors,
|
|
1018
|
+
byId: {
|
|
1019
|
+
...actors.byId,
|
|
1020
|
+
[identity.actor.id]: identity.actor
|
|
1021
|
+
}
|
|
1022
|
+
} : actors;
|
|
1023
|
+
return shownTo(view(message, known, await readerFor(ctx, identity.principal.access, message)), identity.principal.access, sent, unknown);
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* A post's view for its writer. One that can't read the channel gets back the
|
|
1027
|
+
* text it sent, no mentions, and no `unknownMentions`: the board resolved its
|
|
1028
|
+
* handles and emails to actor IDs, which `/actors` doesn't show it, and which
|
|
1029
|
+
* handles matched no one would say which ones exist. The board keeps the
|
|
1030
|
+
* mentions, so they still notify.
|
|
1031
|
+
*/
|
|
1032
|
+
const shownTo = (shown, access, sent, unknown = []) => !access.readable(shown.channel) ? {
|
|
1033
|
+
...shown,
|
|
1034
|
+
text: sent,
|
|
1035
|
+
mentions: []
|
|
1036
|
+
} : unknown.length ? {
|
|
1037
|
+
...shown,
|
|
1038
|
+
unknownMentions: unknown
|
|
1039
|
+
} : shown;
|
|
1040
|
+
/** The writer, as the reader of the view its write returns. Only a link to a board message needs a lookup. */
|
|
1041
|
+
const readerFor = (ctx, access, message) => ctx.reads.reader(access, [message]);
|
|
1042
|
+
/**
|
|
1043
|
+
* Refuses an external thread another board thread is synced with: taking it
|
|
1044
|
+
* over would send its replies away from that thread, to a caller who may not
|
|
1045
|
+
* read it. A reader of that thread's channel learns which thread it is. It
|
|
1046
|
+
* reads the tables once they've applied the whole log, so a sync just made
|
|
1047
|
+
* counts.
|
|
1048
|
+
*/
|
|
1049
|
+
async function unclaimed(ctx, target, thread, access) {
|
|
1050
|
+
if (target.kind !== "thread") return;
|
|
1051
|
+
await ctx.reads.ensure();
|
|
1052
|
+
const owner = await ctx.reads.tables.threadOf(target.target);
|
|
1053
|
+
if (owner === null || owner === thread) return;
|
|
1054
|
+
const synced = await ctx.reads.tables.syncOf(owner);
|
|
1055
|
+
const which = synced && access.readable(synced.channel) ? `#${synced.root}` : "another thread";
|
|
1056
|
+
throw new HttpError(409, "already_synced", `${target.target} is already synced with ${which}`);
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Waits until the tables hold a write, so any read after it includes it. Most often
|
|
1060
|
+
* nothing was written in between, and the writer applies its own events itself; else
|
|
1061
|
+
* it waits for the tables' consumer. The write landed either way: if the tables are
|
|
1062
|
+
* slow or stuck, it still succeeds.
|
|
1063
|
+
*/
|
|
1064
|
+
async function untilApplied(ctx, rows) {
|
|
1065
|
+
const events = [...rows].sort((a, b) => a.index - b.index);
|
|
1066
|
+
const last = events.at(-1).index;
|
|
1067
|
+
try {
|
|
1068
|
+
let caughtUp = true;
|
|
1069
|
+
for (const event of events) if (!(caughtUp = await ctx.reads.tables.applyNext(event))) break;
|
|
1070
|
+
if (caughtUp) return;
|
|
1071
|
+
} catch {}
|
|
1072
|
+
await ctx.reads.ensure(last, AbortSignal.timeout(LANDED_WAIT_MS)).catch((error) => {
|
|
1073
|
+
console.warn(`[board] the tables haven't applied #${last} yet:`, error.message);
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
/** How long a write waits for the tables before it answers anyway. */
|
|
1077
|
+
const LANDED_WAIT_MS = 1e4;
|
|
1078
|
+
/** The message a by-ID write targets, as the board looked it up, once its proof is spent on the message's channel. */
|
|
1079
|
+
function spent(authorized, permission) {
|
|
1080
|
+
spend(authorized.proof, permission, authorized.message);
|
|
1081
|
+
return authorized.message;
|
|
1082
|
+
}
|
|
1083
|
+
async function writeEdit(ctx, input) {
|
|
1084
|
+
const message = spent(input.authorized, "post");
|
|
1085
|
+
if (input.asAuthor !== false && message.by.principal !== input.identity.principal.id) throw forbidden("only the author can edit a message");
|
|
1086
|
+
if (message.deleted) throw badRequest("message was deleted", "message_deleted");
|
|
1087
|
+
return commitEdit(ctx, input, message);
|
|
1088
|
+
}
|
|
1089
|
+
async function commitEdit(ctx, input, message) {
|
|
1090
|
+
const { state: actors } = await ctx.session.state(actorsReducer);
|
|
1091
|
+
const seen = seenFor(input.identity, actors);
|
|
1092
|
+
const { text, found } = await withEmailMentions(input.text);
|
|
1093
|
+
const named = found.map((actor) => actorSeen(actor, actors)).filter((event) => event !== null);
|
|
1094
|
+
const { mentions, unknown } = resolveMentions(text, knownWith(actors, found), seen ? input.identity.actor : void 0, input.from != null, input.identity.principal.access.readsAll);
|
|
1095
|
+
await untilApplied(ctx, await ctx.session.append(...seen ? [seen] : [], ...named, {
|
|
1096
|
+
type: "message.edited",
|
|
1097
|
+
payload: {
|
|
1098
|
+
id: message.id,
|
|
1099
|
+
thread: message.thread,
|
|
1100
|
+
text,
|
|
1101
|
+
mentions,
|
|
1102
|
+
by: input.identity.author,
|
|
1103
|
+
from: input.from ?? null
|
|
1104
|
+
}
|
|
1105
|
+
}));
|
|
1106
|
+
return unknown.length && input.identity.principal.access.readable(message.channel) ? { unknownMentions: unknown } : {};
|
|
1107
|
+
}
|
|
1108
|
+
/** A message's links, replaced: a platform edit or unfurl changed what it links to. */
|
|
1109
|
+
async function writeLinks(ctx, input) {
|
|
1110
|
+
const message = spent(input.authorized, "post");
|
|
1111
|
+
if (message.deleted) return;
|
|
1112
|
+
await untilApplied(ctx, await ctx.session.append({
|
|
1113
|
+
type: "message.linked",
|
|
1114
|
+
payload: {
|
|
1115
|
+
id: message.id,
|
|
1116
|
+
thread: message.thread,
|
|
1117
|
+
links: input.links,
|
|
1118
|
+
by: input.identity.author,
|
|
1119
|
+
from: input.from
|
|
1120
|
+
}
|
|
1121
|
+
}));
|
|
1122
|
+
}
|
|
1123
|
+
async function writeDelete(ctx, input) {
|
|
1124
|
+
const message = spent(input.authorized, "post");
|
|
1125
|
+
if (input.asAuthor !== false && message.by.principal !== input.identity.principal.id) throw forbidden("only the author can delete a message");
|
|
1126
|
+
if (message.deleted) return;
|
|
1127
|
+
await untilApplied(ctx, await ctx.session.append({
|
|
1128
|
+
type: "message.deleted",
|
|
1129
|
+
payload: {
|
|
1130
|
+
id: message.id,
|
|
1131
|
+
thread: message.thread,
|
|
1132
|
+
by: input.identity.author,
|
|
1133
|
+
from: input.from ?? null
|
|
1134
|
+
}
|
|
1135
|
+
}));
|
|
1136
|
+
}
|
|
1137
|
+
async function writeReaction(ctx, input) {
|
|
1138
|
+
const message = spent(input.authorized, "react");
|
|
1139
|
+
if (message.deleted) throw badRequest("message was deleted", "message_deleted");
|
|
1140
|
+
const { state: actors } = await ctx.session.state(actorsReducer);
|
|
1141
|
+
const seen = seenFor(input.identity, actors);
|
|
1142
|
+
await untilApplied(ctx, await ctx.session.append(...seen ? [seen] : [], {
|
|
1143
|
+
type: input.remove ? "reaction.removed" : "reaction.added",
|
|
1144
|
+
payload: {
|
|
1145
|
+
id: message.id,
|
|
1146
|
+
thread: message.thread,
|
|
1147
|
+
emoji: input.emoji,
|
|
1148
|
+
by: input.identity.author,
|
|
1149
|
+
from: input.from ?? null
|
|
1150
|
+
}
|
|
1151
|
+
}));
|
|
1152
|
+
}
|
|
1153
|
+
async function writeLabels(ctx, input) {
|
|
1154
|
+
const message = spent(input.authorized, "label");
|
|
1155
|
+
if (message.deleted) throw badRequest("message was deleted", "message_deleted");
|
|
1156
|
+
const { state: actors } = await ctx.session.state(actorsReducer);
|
|
1157
|
+
const seen = seenFor(input.identity, actors);
|
|
1158
|
+
await untilApplied(ctx, await ctx.session.append(...seen ? [seen] : [], {
|
|
1159
|
+
type: "labels.changed",
|
|
1160
|
+
payload: {
|
|
1161
|
+
id: message.id,
|
|
1162
|
+
thread: message.thread,
|
|
1163
|
+
set: input.set ?? {},
|
|
1164
|
+
unset: input.unset ?? [],
|
|
1165
|
+
by: input.identity.author
|
|
1166
|
+
}
|
|
1167
|
+
}));
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Syncs a message's thread with a target, resolved by the sync that delivers it. Attaching twice
|
|
1171
|
+
* is a no-op; a platform thread another board thread is synced with is a 409.
|
|
1172
|
+
*/
|
|
1173
|
+
async function writeAttach(ctx, input) {
|
|
1174
|
+
const message = spent(input.authorized, "sync");
|
|
1175
|
+
const { target, kind } = input.target;
|
|
1176
|
+
await ctx.reads.ensure();
|
|
1177
|
+
if ((await ctx.reads.tables.syncOf(message.thread))?.targets.includes(target)) return { attached: false };
|
|
1178
|
+
await unclaimed(ctx, input.target, message.thread, input.identity.principal.access);
|
|
1179
|
+
await untilApplied(ctx, await ctx.session.append({
|
|
1180
|
+
type: "thread.synced",
|
|
1181
|
+
id: await derivedId("board", message.thread, "sync", target),
|
|
1182
|
+
payload: {
|
|
1183
|
+
thread: message.thread,
|
|
1184
|
+
target,
|
|
1185
|
+
kind,
|
|
1186
|
+
cause: input.cause,
|
|
1187
|
+
by: input.identity.author
|
|
1188
|
+
}
|
|
1189
|
+
}));
|
|
1190
|
+
return { attached: true };
|
|
1191
|
+
}
|
|
1192
|
+
//#endregion
|
|
303
1193
|
//#region src/server/schedules.ts
|
|
304
1194
|
/** Vercel Queues delays at most 6 days: a send further out than this goes in hops. */
|
|
305
1195
|
const MAX_HOP_MS = 432e6;
|
|
@@ -1654,6 +2544,13 @@ const inboxesReducer = reducer({
|
|
|
1654
2544
|
});
|
|
1655
2545
|
/** An append that holds an event ID already in the log. */
|
|
1656
2546
|
const duplicate = (error) => error instanceof A2Error && error.code === "PARTIAL_DUPLICATE_BATCH";
|
|
2547
|
+
/**
|
|
2548
|
+
* A platform event whose ID is already in an inbox: the platform's retry, which can read differently
|
|
2549
|
+
* (another instance, a name changed since), so the copy already there stands.
|
|
2550
|
+
*/
|
|
2551
|
+
const alreadyReceived = (error) => {
|
|
2552
|
+
if (!duplicate(error)) throw error;
|
|
2553
|
+
};
|
|
1657
2554
|
/** Where a board given no store keeps it, in the working directory, in every environment. */
|
|
1658
2555
|
const DEFAULT_STORE_PATH = ".a2/dev.db";
|
|
1659
2556
|
/** Why a file couldn't be opened, in a few words: a read-only filesystem, a serverless function's say. */
|
|
@@ -1729,9 +2626,8 @@ function build(options, binding) {
|
|
|
1729
2626
|
const event = ctx.event;
|
|
1730
2627
|
const [messages, { state: actors }] = await Promise.all([loadBefore(reads, event, ctx.signal), ctx.session.state(actorsReducer, { through: event.index - 1 })]);
|
|
1731
2628
|
const { before, after } = foldEvent(messages, actors, event);
|
|
1732
|
-
const about = event.type === "thread.synced" ? (await reads.ensure(event.index - 1, ctx.signal), await store.tables.byEventId(event.payload.thread)) ?? void 0 : after;
|
|
1733
2629
|
const shown = {
|
|
1734
|
-
...eventView(event,
|
|
2630
|
+
...eventView(event, event.type === "thread.synced" ? (await reads.ensure(event.index - 1, ctx.signal), await store.tables.byEventId(event.payload.thread)) ?? void 0 : after, actors, OWN_CODE),
|
|
1735
2631
|
...event.type === "labels.changed" && before ? { before: view(before, actors, OWN_CODE) } : {}
|
|
1736
2632
|
};
|
|
1737
2633
|
await delivering.run(ctx, () => binding.run(SYNC, shown, {
|
|
@@ -1900,7 +2796,7 @@ function build(options, binding) {
|
|
|
1900
2796
|
if (fromRequest(caller) && body.id?.startsWith("key:") && !isKeyIdOf(body.id, principal.id)) throw badRequest(`id: key:… IDs derive from a client's key, and this token's are key:${principal.id}:…`);
|
|
1901
2797
|
if (fromRequest(caller) && body.id?.startsWith("sync:")) throw badRequest("id: sync:… IDs are what sync imports, under the principal that imports them");
|
|
1902
2798
|
const attachments = await attachmentsFor(principal, body.attachments);
|
|
1903
|
-
const { schedule
|
|
2799
|
+
const { schedule } = caller.origin;
|
|
1904
2800
|
const { unknownMentions, ...message } = await writePost(writes, {
|
|
1905
2801
|
attachments,
|
|
1906
2802
|
identity,
|
|
@@ -1912,7 +2808,6 @@ function build(options, binding) {
|
|
|
1912
2808
|
noSync: body.noSync,
|
|
1913
2809
|
id: body.id,
|
|
1914
2810
|
sync: body.sync,
|
|
1915
|
-
config,
|
|
1916
2811
|
...schedule && !fromRequest(caller) ? {
|
|
1917
2812
|
schedule,
|
|
1918
2813
|
lenientMentions: true
|
|
@@ -2255,7 +3150,7 @@ function build(options, binding) {
|
|
|
2255
3150
|
const { principal, actor } = await identityOf(caller);
|
|
2256
3151
|
const after = query.after !== void 0 ? cursorAt(query.after) : void 0;
|
|
2257
3152
|
const { state: actors } = await session.state(actorsReducer);
|
|
2258
|
-
const
|
|
3153
|
+
const { events, cursor } = await pollFeed({
|
|
2259
3154
|
session,
|
|
2260
3155
|
reads,
|
|
2261
3156
|
after,
|
|
@@ -2264,8 +3159,7 @@ function build(options, binding) {
|
|
|
2264
3159
|
me: actor.id,
|
|
2265
3160
|
actors,
|
|
2266
3161
|
access: principal.access
|
|
2267
|
-
};
|
|
2268
|
-
const { events, cursor } = await pollFeed(feed, (query.wait ?? 0) * 1e3, signal);
|
|
3162
|
+
}, (query.wait ?? 0) * 1e3, signal);
|
|
2269
3163
|
return {
|
|
2270
3164
|
events,
|
|
2271
3165
|
cursor: ref(cursor)
|
|
@@ -2285,6 +3179,57 @@ function build(options, binding) {
|
|
|
2285
3179
|
if (!principal.access.everywhere("manage")) throw forbidden("this token can't read a source: it needs manage in every channel");
|
|
2286
3180
|
return binding.readSource(key, target, limit ?? 50);
|
|
2287
3181
|
},
|
|
3182
|
+
async synced(caller, { id }, signal) {
|
|
3183
|
+
const { principal } = await identityOf(caller);
|
|
3184
|
+
const message = await reads.message(messageAt(id), signal);
|
|
3185
|
+
if (!message || !principal.access.readable(message.channel)) throw notFound(`message not found: #${id}`, "message_not_found");
|
|
3186
|
+
await reads.ensure(void 0, signal);
|
|
3187
|
+
const { tables } = store;
|
|
3188
|
+
const [thread, copies] = await Promise.all([tables.syncOf(message.thread), tables.copiesOf([message.id])]);
|
|
3189
|
+
const mine = copies.get(message.id) ?? {};
|
|
3190
|
+
return { targets: await Promise.all((thread?.targets ?? []).map(async (target) => {
|
|
3191
|
+
const key = target.slice(0, target.indexOf(":"));
|
|
3192
|
+
const external = (thread && Object.hasOwn(thread.external, target) ? thread.external[target] : void 0) ?? null;
|
|
3193
|
+
const copy = (Object.hasOwn(mine, target) ? mine[target] : external !== null && Object.hasOwn(mine, external) ? mine[external] : void 0) ?? null;
|
|
3194
|
+
return {
|
|
3195
|
+
target,
|
|
3196
|
+
key,
|
|
3197
|
+
thread: copy?.thread ?? external,
|
|
3198
|
+
message: copy?.message ?? null,
|
|
3199
|
+
url: copy ? await binding.linkOf(key, copy.thread, copy.message) : null,
|
|
3200
|
+
undelivered: Object.hasOwn(message.undelivered, target) ? message.undelivered[target] : null
|
|
3201
|
+
};
|
|
3202
|
+
})) };
|
|
3203
|
+
},
|
|
3204
|
+
async linked(caller, { url }, signal) {
|
|
3205
|
+
const missing = () => notFound("no message on this board for that link", "message_not_found");
|
|
3206
|
+
const read = binding.locate(url);
|
|
3207
|
+
if (read.length === 0) throw missing();
|
|
3208
|
+
await reads.ensure(void 0, signal);
|
|
3209
|
+
const { tables } = store;
|
|
3210
|
+
const registered = Object.values((await registry.state()).byOwner).flatMap((registration) => registration.sync);
|
|
3211
|
+
const keysOf = (platform) => new Set([...binding.sync, ...registered].filter((entry) => entry.platform === platform).map((entry) => entry.key));
|
|
3212
|
+
const first = async () => {
|
|
3213
|
+
for (const { platform, thread, message } of read) for (const key of keysOf(platform)) {
|
|
3214
|
+
let found;
|
|
3215
|
+
if (message !== null) found = await tables.messageOf(`${key}:${thread}`, message);
|
|
3216
|
+
else {
|
|
3217
|
+
const at = await tables.threadOf(`${key}:${thread}`);
|
|
3218
|
+
found = at === null ? null : (await tables.syncOf(at))?.root ?? null;
|
|
3219
|
+
}
|
|
3220
|
+
if (found) return found;
|
|
3221
|
+
}
|
|
3222
|
+
return null;
|
|
3223
|
+
};
|
|
3224
|
+
const id = await first();
|
|
3225
|
+
if (id === null) throw missing();
|
|
3226
|
+
try {
|
|
3227
|
+
return await methods.message(caller, { id: ref(id) }, signal);
|
|
3228
|
+
} catch (error) {
|
|
3229
|
+
if (error instanceof HttpError && error.status === 404) throw missing();
|
|
3230
|
+
throw error;
|
|
3231
|
+
}
|
|
3232
|
+
},
|
|
2288
3233
|
async syncImport(caller, body, signal) {
|
|
2289
3234
|
const { principal } = await needSync(caller, "import", true);
|
|
2290
3235
|
await speaksFor(caller, principal.id, body.author, body.op === "seen" ? null : body.from);
|
|
@@ -2330,8 +3275,7 @@ function build(options, binding) {
|
|
|
2330
3275
|
...body.origin && !ownSync(caller) ? { importedBy: principal.id } : {},
|
|
2331
3276
|
lenientMentions: true,
|
|
2332
3277
|
attachments,
|
|
2333
|
-
links: await linksOf(principal, body.links)
|
|
2334
|
-
config: caller.origin.config
|
|
3278
|
+
links: await linksOf(principal, body.links)
|
|
2335
3279
|
})).id;
|
|
2336
3280
|
};
|
|
2337
3281
|
const write = {
|
|
@@ -2555,7 +3499,7 @@ function build(options, binding) {
|
|
|
2555
3499
|
event,
|
|
2556
3500
|
raw
|
|
2557
3501
|
}
|
|
2558
|
-
});
|
|
3502
|
+
}).catch(alreadyReceived);
|
|
2559
3503
|
}
|
|
2560
3504
|
return { ok: true };
|
|
2561
3505
|
}
|
|
@@ -2575,7 +3519,7 @@ function build(options, binding) {
|
|
|
2575
3519
|
})));
|
|
2576
3520
|
await queue.append(...matched).catch(async (error) => {
|
|
2577
3521
|
if (!duplicate(error)) throw error;
|
|
2578
|
-
for (const one of matched) await queue.append(one);
|
|
3522
|
+
for (const one of matched) await queue.append(one).catch(alreadyReceived);
|
|
2579
3523
|
});
|
|
2580
3524
|
return { ok: true };
|
|
2581
3525
|
},
|