experimental-a2 0.20.0 → 0.22.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/CHANGELOG.md +55 -0
- package/README.md +1 -1
- package/dist/{actor-C7b-1arn.d.ts → actor-CqibLgjQ.d.ts} +15 -7
- package/dist/actor-CqibLgjQ.d.ts.map +1 -0
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-client.js +2 -2
- package/dist/actor-react.d.ts +3 -3
- package/dist/actor-react.js +5 -5
- package/dist/{actor-shared-t7w8xIKP.js → actor-shared-DFET5q16.js} +3 -3
- package/dist/actor-shared-DFET5q16.js.map +1 -0
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +54 -43
- package/dist/actor.js.map +1 -1
- package/dist/{ai-Cz3hqVhk.d.ts → ai-DAu77CDr.d.ts} +8 -8
- package/dist/{ai-Cz3hqVhk.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
- package/dist/{ai-mMiWul1T.js → ai-EEfKyc-z.js} +9 -9
- package/dist/ai-EEfKyc-z.js.map +1 -0
- package/dist/ai-client.d.ts +2 -2
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.d.ts +4 -2
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +214 -67
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
- package/dist/{ai-harness-CV6rXbB3.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
- package/dist/{ai-harness-Clbg5ei7.js → ai-harness-dQeJaR1t.js} +7 -3
- package/dist/ai-harness-dQeJaR1t.js.map +1 -0
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.js +3 -3
- package/dist/ai-pi.js.map +1 -1
- package/dist/ai-react.d.ts +5 -5
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +14 -17
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +86 -73
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +2 -2
- package/dist/{client-BhHLoNKF.js → client-BQS4i_V9.js} +29 -6
- package/dist/client-BQS4i_V9.js.map +1 -0
- package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
- package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
- package/dist/client.d.ts +3 -3
- package/dist/client.js +1 -1
- package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
- package/dist/contract-CWukt01d.js.map +1 -0
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3 -3
- package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
- package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
- package/dist/{internal-CscSY7K_.js → internal-CvsrprnU.js} +40 -16
- package/dist/internal-CvsrprnU.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/otel.js +1 -1
- package/dist/otel.js.map +1 -1
- package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
- package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
- package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
- package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
- package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
- package/dist/runtime-consumer-CIeL33wj.js +12 -0
- package/dist/runtime-consumer-CIeL33wj.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +3 -3
- package/dist/scheduler-task-X1SToDjP.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +3 -3
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-qyb5aJJG.js → server-DtFrWxi3.js} +412 -116
- package/dist/server-DtFrWxi3.js.map +1 -0
- package/dist/{server-DYAATFuT.d.ts → server-qgez0Xny.d.ts} +108 -41
- package/dist/server-qgez0Xny.d.ts.map +1 -0
- package/dist/server.browser.js +1 -1
- package/dist/server.browser.js.map +1 -1
- package/dist/server.d.ts +4 -4
- package/dist/server.js +2 -2
- package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
- package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
- package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
- package/dist/session-clear-wHd5GCpZ.js.map +1 -0
- package/dist/store-D0MhBvg6.d.ts +532 -0
- package/dist/store-D0MhBvg6.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-codec-DwrWlgQx.js +65 -0
- package/dist/store-codec-DwrWlgQx.js.map +1 -0
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +406 -225
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-7WJV0YWP.js → store-polling-BZt2Rf3D.js} +2 -2
- package/dist/{store-polling-7WJV0YWP.js.map → store-polling-BZt2Rf3D.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +1598 -587
- package/dist/store-postgres.js.map +1 -1
- package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
- package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +4 -4
- package/dist/store-redis-notify-CsJw_hfD.js +3060 -0
- package/dist/store-redis-notify-CsJw_hfD.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.d.ts.map +1 -1
- package/dist/store-redis.js +4 -5
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +527 -299
- package/dist/store-sqlite.js.map +1 -1
- package/dist/stream-buffer-DrTyiIuo.js +57 -0
- package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
- package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
- package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
- package/dist/{wire-BKxH1vbP.js → wire-CV-Z7wz-.js} +6 -5
- package/dist/wire-CV-Z7wz-.js.map +1 -0
- package/docs/01-quickstart.mdx +21 -15
- package/docs/02-examples.mdx +1 -1
- package/docs/actors/01-introduction.mdx +13 -2
- package/docs/actors/02-concurrency.mdx +1 -1
- package/docs/agents/01-introduction.mdx +1 -1
- package/docs/agents/02-tools.mdx +12 -11
- package/docs/agents/03-conversations.mdx +36 -25
- package/docs/agents/04-harnesses.mdx +14 -7
- package/docs/client/03-presence.mdx +18 -14
- package/docs/contract/01-events.mdx +2 -2
- package/docs/contract/02-state.mdx +18 -14
- package/docs/contract/03-sessions.mdx +32 -25
- package/docs/index.mdx +29 -24
- package/docs/reference/01-core.mdx +7 -6
- package/docs/reference/02-server.mdx +250 -191
- package/docs/reference/04-ai.mdx +33 -23
- package/docs/reference/05-telemetry.mdx +27 -13
- package/docs/reference/06-errors.mdx +25 -19
- package/docs/reference/meta.ts +1 -1
- package/docs/schedulers/01-overview.mdx +8 -8
- package/docs/schedulers/02-vercel-queues.mdx +2 -2
- package/docs/schedulers/03-qstash.mdx +4 -4
- package/docs/server/01-consumers.mdx +418 -0
- package/docs/server/02-timers.mdx +31 -21
- package/docs/server/03-cancellation.mdx +52 -44
- package/docs/server/04-routes.mdx +4 -1
- package/docs/server/05-sql-projections.mdx +391 -0
- package/docs/server/06-guarantees.mdx +61 -51
- package/docs/stores/01-overview.mdx +3 -3
- package/docs/stores/02-redis.mdx +24 -10
- package/docs/stores/03-redis-http.mdx +8 -6
- package/docs/stores/04-postgres.mdx +65 -14
- package/docs/stores/05-sqlite.mdx +5 -3
- package/docs/upgrades/01-overview.mdx +31 -0
- package/docs/upgrades/78-0-22.mdx +115 -0
- package/docs/upgrades/79-0-21.mdx +165 -0
- package/docs/upgrades/80-0-20.mdx +43 -0
- package/docs/upgrades/81-0-19.mdx +33 -0
- package/docs/upgrades/82-0-18.mdx +33 -0
- package/docs/upgrades/83-0-17.mdx +69 -0
- package/docs/upgrades/meta.ts +3 -0
- package/examples/playground/DEPLOY.md +39 -7
- package/examples/playground/app/a0/chat.test.tsx +2 -2
- package/examples/playground/app/a0/sandbox-log.ts +17 -15
- package/examples/playground/app/a0/sandbox.ts +58 -13
- package/examples/playground/app/a0/server.ts +4 -4
- package/examples/playground/app/a2slack/a2slack.css +1067 -0
- package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
- package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
- package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
- package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
- package/examples/playground/app/a2slack/consumers/search.ts +37 -0
- package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
- package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
- package/examples/playground/app/a2slack/db/a2.ts +209 -0
- package/examples/playground/app/a2slack/db/index.ts +73 -0
- package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
- package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
- package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
- package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
- package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
- package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +41 -0
- package/examples/playground/app/a2slack/db/queries.ts +263 -0
- package/examples/playground/app/a2slack/db/schema.ts +145 -0
- package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
- package/examples/playground/app/a2slack/drills/harness.ts +244 -0
- package/examples/playground/app/a2slack/events/route.ts +36 -0
- package/examples/playground/app/a2slack/identity.ts +44 -0
- package/examples/playground/app/a2slack/inbox/route.ts +8 -0
- package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
- package/examples/playground/app/a2slack/lag/page.tsx +101 -0
- package/examples/playground/app/a2slack/lag.ts +73 -0
- package/examples/playground/app/a2slack/layout.tsx +43 -0
- package/examples/playground/app/a2slack/messages/route.ts +17 -0
- package/examples/playground/app/a2slack/model.test.ts +208 -0
- package/examples/playground/app/a2slack/model.ts +369 -0
- package/examples/playground/app/a2slack/models.test.ts +72 -0
- package/examples/playground/app/a2slack/page.tsx +10 -0
- package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
- package/examples/playground/app/a2slack/search/route.ts +40 -0
- package/examples/playground/app/a2slack/search-index.ts +108 -0
- package/examples/playground/app/a2slack/server.test.ts +315 -0
- package/examples/playground/app/a2slack/server.ts +55 -0
- package/examples/playground/app/a2slack/sessions.ts +50 -0
- package/examples/playground/app/a2slack/thread/route.ts +8 -0
- package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
- package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
- package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
- package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
- package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
- package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
- package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
- package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
- package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
- package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
- package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
- package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
- package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
- package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
- package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
- package/examples/playground/app/agent/server.ts +3 -3
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
- package/examples/playground/app/chat/server.ts +45 -35
- package/examples/playground/app/components/demo-icons.tsx +7 -0
- package/examples/playground/app/components/demos.tsx +7 -0
- package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
- package/examples/playground/app/orders/page.tsx +1 -1
- package/examples/playground/app/orders/server.ts +49 -30
- package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
- package/examples/playground/app/recovery/page.tsx +4 -4
- package/examples/playground/app/recovery/server.ts +23 -32
- package/examples/playground/drizzle.config.ts +14 -0
- package/examples/playground/lib/store.ts +1 -1
- package/examples/playground/next.config.ts +6 -1
- package/examples/playground/package.json +12 -1
- package/examples/playground/scripts/migrate.mjs +31 -0
- package/examples/playground/vercel.json +9 -0
- package/examples/playground/vitest.drills.config.ts +32 -0
- package/package.json +5 -2
- package/sql/postgres/drizzle-schema.ts +209 -0
- package/sql/postgres/schema.sql +227 -67
- package/src/actor-shared.ts +10 -2
- package/src/actor.ts +101 -71
- package/src/ai-agent.ts +162 -111
- package/src/ai-control-server.ts +8 -8
- package/src/ai-fx.ts +324 -82
- package/src/ai-harness.ts +17 -2
- package/src/ai-pi.ts +2 -2
- package/src/ai-server.ts +2 -2
- package/src/ai.ts +7 -7
- package/src/client.ts +19 -2
- package/src/contract.ts +1 -1
- package/src/deterministic-id.ts +27 -4
- package/src/errors.ts +8 -5
- package/src/idempotent-replay.ts +0 -1
- package/src/index.ts +1 -1
- package/src/internal.ts +57 -16
- package/src/otel.ts +1 -1
- package/src/postgres-schema.ts +98 -57
- package/src/presence-recovery.ts +18 -11
- package/src/presence-tombstones.ts +52 -0
- package/src/push-envelope.ts +20 -0
- package/src/runtime-consumer.ts +10 -0
- package/src/scheduler-task.ts +2 -2
- package/src/scheduler-vercel.ts +1 -1
- package/src/server.browser.ts +1 -1
- package/src/server.ts +819 -218
- package/src/session-socket.ts +1 -1
- package/src/store-codec.ts +66 -0
- package/src/store-memory.ts +604 -286
- package/src/store-postgres.ts +2006 -857
- package/src/store-presence-polling.ts +59 -34
- package/src/store-redis-core.ts +1961 -1333
- package/src/store-redis-notify.ts +76 -26
- package/src/store-redis.ts +2 -3
- package/src/store-sqlite.ts +915 -516
- package/src/store.ts +241 -102
- package/src/telemetry.ts +2 -1
- package/src/wire.ts +1 -0
- package/dist/actor-C7b-1arn.d.ts.map +0 -1
- package/dist/actor-shared-t7w8xIKP.js.map +0 -1
- package/dist/ai-harness-Clbg5ei7.js.map +0 -1
- package/dist/ai-mMiWul1T.js.map +0 -1
- package/dist/client-BhHLoNKF.js.map +0 -1
- package/dist/contract-BYKASlCV.js.map +0 -1
- package/dist/idempotent-replay-JdgMQRRm.js +0 -21
- package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
- package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
- package/dist/internal-CscSY7K_.js.map +0 -1
- package/dist/server-DYAATFuT.d.ts.map +0 -1
- package/dist/server-qyb5aJJG.js.map +0 -1
- package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
- package/dist/session-clear-DGI4jTQ5.js.map +0 -1
- package/dist/store-CyLUy86v.d.ts +0 -404
- package/dist/store-CyLUy86v.d.ts.map +0 -1
- package/dist/store-codec-DTG0Ftek.js +0 -10
- package/dist/store-codec-DTG0Ftek.js.map +0 -1
- package/dist/store-presence-polling-B1ixeqIS.js +0 -94
- package/dist/store-presence-polling-B1ixeqIS.js.map +0 -1
- package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
- package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
- package/dist/stream-buffer-Cx7AAPGq.js +0 -23
- package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
- package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
- package/dist/wire-BKxH1vbP.js.map +0 -1
- package/docs/server/01-handlers.mdx +0 -227
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the drills share: a throwaway Redis and Postgres, a scheduler made of
|
|
3
|
+
* timers, a telemetry sink that times every consumer run, the workspace's
|
|
4
|
+
* cursors, and percentiles.
|
|
5
|
+
*/
|
|
6
|
+
import { execFileSync, spawn, type ChildProcess } from 'node:child_process'
|
|
7
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
8
|
+
import { createServer as listen } from 'node:net'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import type { A2AttributeValue, A2Telemetry } from 'experimental-a2'
|
|
12
|
+
import type {
|
|
13
|
+
A2Scheduler,
|
|
14
|
+
A2Store,
|
|
15
|
+
DrainableServer,
|
|
16
|
+
} from 'experimental-a2/server'
|
|
17
|
+
|
|
18
|
+
const freePort = (): Promise<number> =>
|
|
19
|
+
new Promise((resolve, reject) => {
|
|
20
|
+
const server = listen()
|
|
21
|
+
server.unref()
|
|
22
|
+
server.on('error', reject)
|
|
23
|
+
server.listen(0, () => {
|
|
24
|
+
const address = server.address()
|
|
25
|
+
server.close(() =>
|
|
26
|
+
resolve(typeof address === 'object' && address ? address.port : 0),
|
|
27
|
+
)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
32
|
+
|
|
33
|
+
export type Services = {
|
|
34
|
+
redisUrl: string
|
|
35
|
+
databaseUrl: string
|
|
36
|
+
/** Stops Postgres the way a crash would; `startPostgres` brings it back. */
|
|
37
|
+
stopPostgres(): void
|
|
38
|
+
startPostgres(): void
|
|
39
|
+
close(): void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A Redis and a Postgres in a temporary directory, on free ports. */
|
|
43
|
+
export async function startServices(): Promise<Services> {
|
|
44
|
+
const dir = mkdtempSync(join(tmpdir(), 'a2slack-drills-'))
|
|
45
|
+
const redisPort = await freePort()
|
|
46
|
+
const pgPort = await freePort()
|
|
47
|
+
const redis: ChildProcess = spawn(
|
|
48
|
+
'redis-server',
|
|
49
|
+
[
|
|
50
|
+
'--port',
|
|
51
|
+
String(redisPort),
|
|
52
|
+
'--save',
|
|
53
|
+
'',
|
|
54
|
+
'--appendonly',
|
|
55
|
+
'no',
|
|
56
|
+
'--dir',
|
|
57
|
+
dir,
|
|
58
|
+
],
|
|
59
|
+
{ stdio: 'ignore' },
|
|
60
|
+
)
|
|
61
|
+
for (let tries = 0; ; tries += 1) {
|
|
62
|
+
try {
|
|
63
|
+
if (
|
|
64
|
+
execFileSync('redis-cli', ['-p', String(redisPort), 'ping'])
|
|
65
|
+
.toString()
|
|
66
|
+
.trim() === 'PONG'
|
|
67
|
+
)
|
|
68
|
+
break
|
|
69
|
+
} catch {
|
|
70
|
+
if (tries > 100) throw new Error('redis-server did not start')
|
|
71
|
+
}
|
|
72
|
+
await sleep(50)
|
|
73
|
+
}
|
|
74
|
+
const data = join(dir, 'pg')
|
|
75
|
+
execFileSync('initdb', ['-U', 'postgres', '-A', 'trust', data], {
|
|
76
|
+
stdio: 'ignore',
|
|
77
|
+
})
|
|
78
|
+
const pgCtl = (...args: string[]) =>
|
|
79
|
+
execFileSync('pg_ctl', ['-D', data, ...args], { stdio: 'ignore' })
|
|
80
|
+
const startPostgres = () =>
|
|
81
|
+
pgCtl(
|
|
82
|
+
'-o',
|
|
83
|
+
`-p ${pgPort} -k ${dir} -c max_connections=300`,
|
|
84
|
+
'-l',
|
|
85
|
+
join(dir, 'pg.log'),
|
|
86
|
+
'-w',
|
|
87
|
+
'start',
|
|
88
|
+
)
|
|
89
|
+
startPostgres()
|
|
90
|
+
return {
|
|
91
|
+
redisUrl: `redis://127.0.0.1:${redisPort}`,
|
|
92
|
+
databaseUrl: `postgres://postgres@127.0.0.1:${pgPort}/postgres`,
|
|
93
|
+
stopPostgres: () => pgCtl('-m', 'immediate', '-w', 'stop'),
|
|
94
|
+
startPostgres,
|
|
95
|
+
close() {
|
|
96
|
+
redis.kill('SIGKILL')
|
|
97
|
+
try {
|
|
98
|
+
pgCtl('-m', 'immediate', '-w', 'stop')
|
|
99
|
+
} catch {
|
|
100
|
+
// already stopped
|
|
101
|
+
}
|
|
102
|
+
rmSync(dir, { recursive: true, force: true })
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A scheduler made of timers. A due drain task drains its session, the way
|
|
109
|
+
* Vercel Queues delivers one, so failed events retry and watchdogs fire as
|
|
110
|
+
* they do in production. Without a scheduler, a failed event waits for the
|
|
111
|
+
* session's next append or `consume`.
|
|
112
|
+
*/
|
|
113
|
+
export function timerScheduler(): {
|
|
114
|
+
scheduler: A2Scheduler
|
|
115
|
+
deliverTo(server: DrainableServer): void
|
|
116
|
+
} {
|
|
117
|
+
let target: DrainableServer | undefined
|
|
118
|
+
return {
|
|
119
|
+
scheduler: {
|
|
120
|
+
async schedule(task) {
|
|
121
|
+
if (task.kind !== 'drain') return
|
|
122
|
+
setTimeout(
|
|
123
|
+
() => void target?.drain(task.sessionId).catch(() => {}),
|
|
124
|
+
Math.max(0, task.dueAt - Date.now()),
|
|
125
|
+
).unref()
|
|
126
|
+
},
|
|
127
|
+
handler: () => async () => new Response(null, { status: 204 }),
|
|
128
|
+
},
|
|
129
|
+
deliverTo(server) {
|
|
130
|
+
target = server
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** One consumer run, as its `a2.event` span saw it. */
|
|
136
|
+
export type Run = {
|
|
137
|
+
consumer: string
|
|
138
|
+
index: number
|
|
139
|
+
/** How old the event was when the run started. */
|
|
140
|
+
ageMs: number
|
|
141
|
+
/** How many events the session had after it when it was claimed. */
|
|
142
|
+
lag: number
|
|
143
|
+
durationMs: number
|
|
144
|
+
failed: boolean
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Telemetry that keeps every consumer run and every append's duration. */
|
|
148
|
+
export function recorder(): {
|
|
149
|
+
telemetry: A2Telemetry
|
|
150
|
+
runs: Run[]
|
|
151
|
+
reset(): void
|
|
152
|
+
} {
|
|
153
|
+
const runs: Run[] = []
|
|
154
|
+
const telemetry: A2Telemetry = {
|
|
155
|
+
async span(name, attributes, fn) {
|
|
156
|
+
const started = performance.now()
|
|
157
|
+
let failed = false
|
|
158
|
+
const set: Record<string, A2AttributeValue> = {}
|
|
159
|
+
try {
|
|
160
|
+
return await fn({
|
|
161
|
+
setAttribute: (key, value) => {
|
|
162
|
+
set[key] = value
|
|
163
|
+
},
|
|
164
|
+
recordError: () => {
|
|
165
|
+
failed = true
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
} catch (error) {
|
|
169
|
+
failed = true
|
|
170
|
+
throw error
|
|
171
|
+
} finally {
|
|
172
|
+
if (name === 'a2.event' && attributes['a2.event.handled'] === true)
|
|
173
|
+
runs.push({
|
|
174
|
+
consumer: String(attributes['a2.event.consumer']),
|
|
175
|
+
index: Number(attributes['a2.event.index']),
|
|
176
|
+
ageMs: Number(attributes['a2.event.age_ms'] ?? 0),
|
|
177
|
+
lag: Number(attributes['a2.event.lag'] ?? 0),
|
|
178
|
+
durationMs: performance.now() - started,
|
|
179
|
+
failed: failed || set['a2.event.outcome'] === 'failed',
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
return { telemetry, runs, reset: () => runs.splice(0) }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export type Summary = {
|
|
188
|
+
n: number
|
|
189
|
+
p50: number
|
|
190
|
+
p95: number
|
|
191
|
+
p99: number
|
|
192
|
+
max: number
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Percentiles of `values`, rounded to a tenth. */
|
|
196
|
+
export function summarize(values: readonly number[]): Summary {
|
|
197
|
+
const sorted = values.toSorted((a, b) => a - b)
|
|
198
|
+
const at = (q: number) =>
|
|
199
|
+
sorted.length === 0
|
|
200
|
+
? 0
|
|
201
|
+
: Math.round(
|
|
202
|
+
sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]! *
|
|
203
|
+
10,
|
|
204
|
+
) / 10
|
|
205
|
+
return {
|
|
206
|
+
n: sorted.length,
|
|
207
|
+
p50: at(0.5),
|
|
208
|
+
p95: at(0.95),
|
|
209
|
+
p99: at(0.99),
|
|
210
|
+
max: at(1),
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Waits until `ready` holds, checking every `everyMs`. */
|
|
215
|
+
export async function until(
|
|
216
|
+
ready: () => Promise<boolean> | boolean,
|
|
217
|
+
{ timeoutMs, everyMs = 50 }: { timeoutMs: number; everyMs?: number },
|
|
218
|
+
): Promise<number> {
|
|
219
|
+
const started = performance.now()
|
|
220
|
+
for (;;) {
|
|
221
|
+
// oxlint-disable-next-line no-await-in-loop -- polling, one check at a time
|
|
222
|
+
if (await ready()) return performance.now() - started
|
|
223
|
+
if (performance.now() - started > timeoutMs)
|
|
224
|
+
throw new Error(`not ready after ${timeoutMs}ms`)
|
|
225
|
+
// oxlint-disable-next-line no-await-in-loop -- polling, one check at a time
|
|
226
|
+
await sleep(everyMs)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The workspace session as the store keys it. */
|
|
231
|
+
export const WORKSPACE_KEY = 'a2slack\u001fworkspace'
|
|
232
|
+
|
|
233
|
+
/** Whether none of `consumers` has an event left to run. */
|
|
234
|
+
export async function idle(
|
|
235
|
+
store: A2Store,
|
|
236
|
+
consumers: readonly string[],
|
|
237
|
+
): Promise<boolean> {
|
|
238
|
+
const cursors = await store.cursors(WORKSPACE_KEY)
|
|
239
|
+
return cursors
|
|
240
|
+
.filter((cursor) => consumers.includes(cursor.consumer))
|
|
241
|
+
.every((cursor) => cursor.head === null)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export { sleep }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { WORKSPACE } from '../model'
|
|
2
|
+
import { a2slackServer } from '../server'
|
|
3
|
+
|
|
4
|
+
/** What a person does. Notifications and labels are the server's: its consumers append them. */
|
|
5
|
+
const CLIENT_TYPES = new Set([
|
|
6
|
+
'channel.created',
|
|
7
|
+
'message.posted',
|
|
8
|
+
'message.edited',
|
|
9
|
+
'message.deleted',
|
|
10
|
+
'reaction.added',
|
|
11
|
+
'reaction.removed',
|
|
12
|
+
'inbox.read',
|
|
13
|
+
])
|
|
14
|
+
|
|
15
|
+
/** The ids the client makes, with crypto.randomUUID(). */
|
|
16
|
+
const RANDOM_ID =
|
|
17
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The workspace is the app's one session: a request can't read or write any
|
|
21
|
+
* other, push an event the server appends, or pick an id a consumer will need.
|
|
22
|
+
*/
|
|
23
|
+
const handle = (request: Request): Promise<Response> =>
|
|
24
|
+
a2slackServer.fetch(request, {
|
|
25
|
+
authorize: (operation) =>
|
|
26
|
+
operation.sessionId === WORKSPACE &&
|
|
27
|
+
(operation.type !== 'push' ||
|
|
28
|
+
operation.events.every(
|
|
29
|
+
(event) =>
|
|
30
|
+
CLIENT_TYPES.has(event.type) &&
|
|
31
|
+
(event.id === undefined || RANDOM_ID.test(event.id)),
|
|
32
|
+
)),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
export const GET = handle
|
|
36
|
+
export const POST = handle
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who is typing — a name picked on first visit, kept in localStorage.
|
|
3
|
+
* This example has no auth: the identity is client-asserted, exactly as
|
|
4
|
+
* far as it should be trusted (not at all). A real app derives it from
|
|
5
|
+
* the request in the route's `before` hook instead.
|
|
6
|
+
*/
|
|
7
|
+
'use client'
|
|
8
|
+
import { useSyncExternalStore } from 'react'
|
|
9
|
+
|
|
10
|
+
export type Identity = { id: string; name: string }
|
|
11
|
+
|
|
12
|
+
const KEY = 'a2slack-identity'
|
|
13
|
+
const listeners = new Set<() => void>()
|
|
14
|
+
let cached: Identity | null | undefined
|
|
15
|
+
|
|
16
|
+
function read(): Identity | null {
|
|
17
|
+
if (cached === undefined) {
|
|
18
|
+
try {
|
|
19
|
+
cached = JSON.parse(
|
|
20
|
+
localStorage.getItem(KEY) ?? 'null',
|
|
21
|
+
) as Identity | null
|
|
22
|
+
} catch {
|
|
23
|
+
cached = null
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return cached
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useIdentity(): Identity | null {
|
|
30
|
+
return useSyncExternalStore(
|
|
31
|
+
(onChange) => {
|
|
32
|
+
listeners.add(onChange)
|
|
33
|
+
return () => listeners.delete(onChange)
|
|
34
|
+
},
|
|
35
|
+
read,
|
|
36
|
+
() => null,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function saveIdentity(name: string): void {
|
|
41
|
+
cached = { id: crypto.randomUUID(), name: name.trim() }
|
|
42
|
+
localStorage.setItem(KEY, JSON.stringify(cached))
|
|
43
|
+
for (const notify of listeners) notify()
|
|
44
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { inboxPage } from '../db/queries'
|
|
2
|
+
|
|
3
|
+
/** Someone's inbox. There's no auth: identity is a name in localStorage. */
|
|
4
|
+
export async function GET(request: Request): Promise<Response> {
|
|
5
|
+
const user = new URL(request.url).searchParams.get('user')
|
|
6
|
+
if (!user) return Response.json({ error: 'no user' }, { status: 400 })
|
|
7
|
+
return Response.json(await inboxPage(user))
|
|
8
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
/**
|
|
3
|
+
* How far each consumer is behind the workspace's log, every two seconds:
|
|
4
|
+
* where it is, what it runs next, and what it gave up on.
|
|
5
|
+
*/
|
|
6
|
+
import { useEffect, useState, type ReactNode } from 'react'
|
|
7
|
+
import type { Lag } from '@/app/a2slack/lag'
|
|
8
|
+
|
|
9
|
+
const EVERY_MS = 2_000
|
|
10
|
+
|
|
11
|
+
export default function LagPage(): ReactNode {
|
|
12
|
+
const [lag, setLag] = useState<Lag | null>(null)
|
|
13
|
+
const [error, setError] = useState<string | null>(null)
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
let stopped = false
|
|
17
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
18
|
+
const read = async () => {
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetch('/a2slack/lag/data', { cache: 'no-store' })
|
|
21
|
+
if (!response.ok) throw new Error(`lag failed (${response.status})`)
|
|
22
|
+
const next = (await response.json()) as Lag
|
|
23
|
+
if (!stopped) {
|
|
24
|
+
setLag(next)
|
|
25
|
+
setError(null)
|
|
26
|
+
}
|
|
27
|
+
} catch (caught) {
|
|
28
|
+
if (!stopped) setError(String(caught))
|
|
29
|
+
}
|
|
30
|
+
if (!stopped) timer = setTimeout(read, EVERY_MS)
|
|
31
|
+
}
|
|
32
|
+
void read()
|
|
33
|
+
return () => {
|
|
34
|
+
stopped = true
|
|
35
|
+
clearTimeout(timer)
|
|
36
|
+
}
|
|
37
|
+
}, [])
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="a2slack-view">
|
|
41
|
+
<div className="a2slack-header">
|
|
42
|
+
<span className="a2slack-header-name">lag</span>
|
|
43
|
+
<span className="a2slack-status">
|
|
44
|
+
{error ?? (lag ? `head ${lag.head}` : 'reading…')}
|
|
45
|
+
</span>
|
|
46
|
+
</div>
|
|
47
|
+
{lag && (
|
|
48
|
+
<div className="a2slack-lag">
|
|
49
|
+
<table>
|
|
50
|
+
<thead>
|
|
51
|
+
<tr>
|
|
52
|
+
<th>consumer</th>
|
|
53
|
+
<th>lane</th>
|
|
54
|
+
<th>position</th>
|
|
55
|
+
<th>next</th>
|
|
56
|
+
<th>behind</th>
|
|
57
|
+
<th>failures</th>
|
|
58
|
+
<th>last error</th>
|
|
59
|
+
</tr>
|
|
60
|
+
</thead>
|
|
61
|
+
<tbody>
|
|
62
|
+
{lag.consumers.map((row) => (
|
|
63
|
+
<tr
|
|
64
|
+
key={`${row.consumer}|${row.lane}|${row.next}`}
|
|
65
|
+
data-behind={row.behind > 0 ? 'yes' : undefined}
|
|
66
|
+
>
|
|
67
|
+
<td>{row.consumer}</td>
|
|
68
|
+
<td>{row.lane ?? '·'}</td>
|
|
69
|
+
<td>{row.position}</td>
|
|
70
|
+
<td>{row.next ?? '·'}</td>
|
|
71
|
+
<td>{row.behind}</td>
|
|
72
|
+
<td>{row.failures}</td>
|
|
73
|
+
<td className="a2slack-lag-error">{row.lastError ?? ''}</td>
|
|
74
|
+
</tr>
|
|
75
|
+
))}
|
|
76
|
+
</tbody>
|
|
77
|
+
</table>
|
|
78
|
+
{lag.deadLetters.length > 0 && (
|
|
79
|
+
<>
|
|
80
|
+
<p className="a2slack-lag-title">dead letters</p>
|
|
81
|
+
<table>
|
|
82
|
+
<tbody>
|
|
83
|
+
{lag.deadLetters.map((letter) => (
|
|
84
|
+
<tr key={`${letter.consumer}|${letter.index}`}>
|
|
85
|
+
<td>{letter.consumer}</td>
|
|
86
|
+
<td>#{letter.index}</td>
|
|
87
|
+
<td>{letter.type}</td>
|
|
88
|
+
<td className="a2slack-lag-error">
|
|
89
|
+
{letter.error ?? ''}
|
|
90
|
+
</td>
|
|
91
|
+
</tr>
|
|
92
|
+
))}
|
|
93
|
+
</tbody>
|
|
94
|
+
</table>
|
|
95
|
+
</>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How far each consumer is behind the workspace's log: its cursors as the
|
|
3
|
+
* store keeps them, against the log's head, and the events it gave up on.
|
|
4
|
+
* The lag page (lag/page.tsx) polls it.
|
|
5
|
+
*/
|
|
6
|
+
import 'server-only'
|
|
7
|
+
import type { A2Store } from 'experimental-a2/server'
|
|
8
|
+
import { store } from '@/lib/store'
|
|
9
|
+
import { WORKSPACE, workspace } from './model'
|
|
10
|
+
import { a2slackServer } from './server'
|
|
11
|
+
|
|
12
|
+
export type ConsumerLag = {
|
|
13
|
+
consumer: string
|
|
14
|
+
lane: string | null
|
|
15
|
+
/** The last event it finished. */
|
|
16
|
+
position: number
|
|
17
|
+
/** The event it runs next; null when it has nothing to run. */
|
|
18
|
+
next: number | null
|
|
19
|
+
/**
|
|
20
|
+
* Events in the log after its position, while it has one to run. A
|
|
21
|
+
* consumer that takes some types is caught up once none of them is left.
|
|
22
|
+
*/
|
|
23
|
+
behind: number
|
|
24
|
+
attempts: number
|
|
25
|
+
failures: number
|
|
26
|
+
lastError: string | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type Lag = {
|
|
30
|
+
/** The log's last event. */
|
|
31
|
+
head: number
|
|
32
|
+
consumers: ConsumerLag[]
|
|
33
|
+
deadLetters: {
|
|
34
|
+
consumer: string
|
|
35
|
+
index: number
|
|
36
|
+
type: string
|
|
37
|
+
error: string | null
|
|
38
|
+
}[]
|
|
39
|
+
at: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Without REDIS_URL or DATABASE_URL, the server uses A2's development store.
|
|
43
|
+
const lagStore = async (): Promise<A2Store> =>
|
|
44
|
+
store ?? (await import('experimental-a2/store-sqlite')).sqlite()
|
|
45
|
+
|
|
46
|
+
export async function workspaceLag(): Promise<Lag> {
|
|
47
|
+
const session = a2slackServer.session(WORKSPACE)
|
|
48
|
+
const [cursors, { index: head }, deadLetters] = await Promise.all([
|
|
49
|
+
(await lagStore()).cursors(`${workspace.name}\u001f${WORKSPACE}`),
|
|
50
|
+
session.state(),
|
|
51
|
+
session.deadLetters(),
|
|
52
|
+
])
|
|
53
|
+
return {
|
|
54
|
+
head,
|
|
55
|
+
consumers: cursors.map((cursor) => ({
|
|
56
|
+
consumer: cursor.consumer,
|
|
57
|
+
lane: cursor.lane,
|
|
58
|
+
position: cursor.position,
|
|
59
|
+
next: cursor.head,
|
|
60
|
+
behind: cursor.head === null ? 0 : Math.max(0, head - cursor.position),
|
|
61
|
+
attempts: cursor.attemptCount,
|
|
62
|
+
failures: cursor.failureCount,
|
|
63
|
+
lastError: cursor.lastError,
|
|
64
|
+
})),
|
|
65
|
+
deadLetters: deadLetters.map((letter) => ({
|
|
66
|
+
consumer: letter.consumer,
|
|
67
|
+
index: letter.index,
|
|
68
|
+
type: letter.type,
|
|
69
|
+
error: letter.lastError,
|
|
70
|
+
})),
|
|
71
|
+
at: new Date().toISOString(),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { GeistSans } from 'geist/font/sans'
|
|
4
|
+
import { WORKSPACE } from '@/app/a2slack/model'
|
|
5
|
+
import { feed } from '@/app/a2slack/consumers/feed'
|
|
6
|
+
import { workspaceSnapshot } from '@/app/a2slack/db/queries'
|
|
7
|
+
import { a2slackServer } from '@/app/a2slack/server'
|
|
8
|
+
import { WorkspaceProvider } from '@/app/a2slack/sessions'
|
|
9
|
+
import { Shell } from '@/app/a2slack/ui/Shell'
|
|
10
|
+
import './a2slack.css'
|
|
11
|
+
|
|
12
|
+
export const metadata: Metadata = {
|
|
13
|
+
title: 'a2slack · a2 example',
|
|
14
|
+
description: 'A team chat whose whole workspace is one A2 session.',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// A chat is dynamic by definition; never read the workspace at build time.
|
|
18
|
+
export const dynamic = 'force-dynamic'
|
|
19
|
+
|
|
20
|
+
export default async function A2slackLayout({
|
|
21
|
+
children,
|
|
22
|
+
}: {
|
|
23
|
+
children: ReactNode
|
|
24
|
+
}): Promise<ReactNode> {
|
|
25
|
+
// The channel list as the database has it, and the log index it reflects:
|
|
26
|
+
// the stream resumes after `through`.
|
|
27
|
+
const { channels, opened, through } = await workspaceSnapshot()
|
|
28
|
+
// A feed that hasn't read anything yet, on a log that already has events,
|
|
29
|
+
// starts its backfill now instead of waiting for the next post.
|
|
30
|
+
if (through === 0)
|
|
31
|
+
await a2slackServer.session(WORKSPACE).consume(feed, { through: 0 })
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className={`a2slack-page ${GeistSans.variable}`}>
|
|
35
|
+
<WorkspaceProvider
|
|
36
|
+
sessionId={WORKSPACE}
|
|
37
|
+
initial={{ state: { channels, opened }, index: through }}
|
|
38
|
+
>
|
|
39
|
+
<Shell>{children}</Shell>
|
|
40
|
+
</WorkspaceProvider>
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { messagePage } from '../db/queries'
|
|
2
|
+
import { channelIdSchema } from '../model'
|
|
3
|
+
|
|
4
|
+
/** A page of a channel's top-level messages, older than `before` if given. */
|
|
5
|
+
export async function GET(request: Request): Promise<Response> {
|
|
6
|
+
const params = new URL(request.url).searchParams
|
|
7
|
+
const channel = channelIdSchema.safeParse(params.get('channel'))
|
|
8
|
+
const before = Number(params.get('before') ?? Number.NaN)
|
|
9
|
+
if (!channel.success)
|
|
10
|
+
return Response.json({ error: 'unknown channel' }, { status: 400 })
|
|
11
|
+
return Response.json(
|
|
12
|
+
await messagePage(
|
|
13
|
+
channel.data,
|
|
14
|
+
Number.isSafeInteger(before) && before > 0 ? { before } : {},
|
|
15
|
+
),
|
|
16
|
+
)
|
|
17
|
+
}
|