experimental-a2 0.3.0 → 0.5.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 +207 -0
- package/dist/ai-CFNeCrRl.d.ts +334 -0
- package/dist/ai-CFNeCrRl.d.ts.map +1 -0
- package/dist/ai-server.browser.js +2 -0
- package/dist/ai-server.browser.js.map +1 -0
- package/dist/ai-server.d.ts +49 -9
- package/dist/ai-server.d.ts.map +1 -0
- package/dist/ai-server.js +123 -49
- package/dist/ai-server.js.map +1 -0
- package/dist/ai.d.ts +2 -303
- package/dist/ai.js +233 -86
- package/dist/ai.js.map +1 -0
- package/dist/cli-B3VuxoDe.js +599 -0
- package/dist/cli-B3VuxoDe.js.map +1 -0
- package/dist/cli-bin.d.ts +1 -0
- package/dist/cli-bin.js +7 -0
- package/dist/cli-bin.js.map +1 -0
- package/dist/cli.d.ts +20 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +2 -0
- package/dist/client-BKlyLiOU.js +1008 -0
- package/dist/client-BKlyLiOU.js.map +1 -0
- package/dist/client-D7mvIXrF.d.ts +191 -0
- package/dist/client-D7mvIXrF.d.ts.map +1 -0
- package/dist/client.d.ts +2 -90
- package/dist/client.js +1 -409
- package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +12 -2
- package/dist/contract-48bUMgcL.js.map +1 -0
- package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +63 -8
- package/dist/contract-jIfaR085.d.ts.map +1 -0
- package/dist/devtools-J_jZ2vQf.d.ts +152 -0
- package/dist/devtools-J_jZ2vQf.d.ts.map +1 -0
- package/dist/devtools-kJJaORn-.js +340 -0
- package/dist/devtools-kJJaORn-.js.map +1 -0
- package/dist/devtools-server.browser.js +3 -1
- package/dist/devtools-server.browser.js.map +1 -0
- package/dist/devtools-server.d.ts +4 -3
- package/dist/devtools-server.d.ts.map +1 -0
- package/dist/devtools-server.js +226 -43
- package/dist/devtools-server.js.map +1 -0
- package/dist/devtools.d.ts +2 -0
- package/dist/devtools.js +2 -0
- package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +6 -4
- package/dist/errors-BQuJpe82.js.map +1 -0
- package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +3 -2
- package/dist/errors-W6nwJ-fm.d.ts.map +1 -0
- package/dist/http.d.ts +135 -28
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +647 -61
- package/dist/http.js.map +1 -0
- package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +4 -2
- package/dist/idempotent-replay-DuqEkYA7.js.map +1 -0
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +4 -1
- package/dist/inspection-DaxB5jM2.js.map +1 -0
- package/dist/internal-DstsI6Re.js +211 -0
- package/dist/internal-DstsI6Re.js.map +1 -0
- package/dist/otel.d.ts +3 -2
- package/dist/otel.d.ts.map +1 -0
- package/dist/otel.js +2 -0
- package/dist/otel.js.map +1 -0
- package/dist/platform-B4TnJtWu.js +34 -0
- package/dist/platform-B4TnJtWu.js.map +1 -0
- package/dist/react.d.ts +47 -15
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +30 -15
- package/dist/react.js.map +1 -0
- package/dist/retryable-lazy-DZWmHpii.js +2 -0
- package/dist/retryable-lazy-DZWmHpii.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +79 -0
- package/dist/scheduler-qstash.d.ts.map +1 -0
- package/dist/scheduler-qstash.js +501 -0
- package/dist/scheduler-qstash.js.map +1 -0
- package/dist/scheduler-task-BpzhPnRS.js +56 -0
- package/dist/scheduler-task-BpzhPnRS.js.map +1 -0
- package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +18 -24
- package/dist/scheduler-vercel.d.ts.map +1 -0
- package/dist/scheduler-vercel.js +228 -0
- package/dist/scheduler-vercel.js.map +1 -0
- package/dist/server-C72KOw51.js +1457 -0
- package/dist/server-C72KOw51.js.map +1 -0
- package/dist/server-DUF9pjsx.d.ts +261 -0
- package/dist/server-DUF9pjsx.d.ts.map +1 -0
- package/dist/server.browser.js +2 -0
- package/dist/server.browser.js.map +1 -0
- package/dist/server.d.ts +4 -155
- package/dist/server.js +2 -2
- package/dist/{log-ldf5g8Cx.d.ts → store-DysUkTH3.d.ts} +120 -35
- package/dist/store-DysUkTH3.d.ts.map +1 -0
- package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +3 -1
- package/dist/store-N8PXxDAS.js.map +1 -0
- package/dist/store-codec-DTG0Ftek.js +10 -0
- package/dist/store-codec-DTG0Ftek.js.map +1 -0
- package/dist/store-memory.d.ts +12 -0
- package/dist/store-memory.d.ts.map +1 -0
- package/dist/{log-memory.js → store-memory.js} +145 -34
- package/dist/store-memory.js.map +1 -0
- package/dist/{log-polling-DZ1MiKLg.js → store-polling-dSeLxzfb.js} +7 -5
- package/dist/store-polling-dSeLxzfb.js.map +1 -0
- package/dist/{log-postgres.d.ts → store-postgres.d.ts} +7 -6
- package/dist/store-postgres.d.ts.map +1 -0
- package/dist/{log-postgres.js → store-postgres.js} +214 -24
- package/dist/store-postgres.js.map +1 -0
- package/dist/{log-redis-core-CyJ5L8yR.js → store-redis-core-BFLwz0Wj.js} +271 -66
- package/dist/store-redis-core-BFLwz0Wj.js.map +1 -0
- package/dist/{log-redis-http.d.ts → store-redis-http.d.ts} +8 -7
- package/dist/store-redis-http.d.ts.map +1 -0
- package/dist/{log-redis-http.js → store-redis-http.js} +22 -12
- package/dist/store-redis-http.js.map +1 -0
- package/dist/{log-redis.d.ts → store-redis.d.ts} +7 -6
- package/dist/store-redis.d.ts.map +1 -0
- package/dist/{log-redis.js → store-redis.js} +101 -39
- package/dist/store-redis.js.map +1 -0
- package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +7 -6
- package/dist/store-sqlite.d.ts.map +1 -0
- package/dist/{log-sqlite.js → store-sqlite.js} +118 -22
- package/dist/store-sqlite.js.map +1 -0
- package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +3 -2
- package/dist/telemetry-BjYHTfh2.d.ts.map +1 -0
- package/dist/testing.browser.d.ts +1 -0
- package/dist/testing.browser.js +6 -0
- package/dist/testing.browser.js.map +1 -0
- package/dist/testing.d.ts +32 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +103 -0
- package/dist/testing.js.map +1 -0
- package/dist/validate-XKT4FSNn.js +2 -0
- package/dist/validate-XKT4FSNn.js.map +1 -0
- package/dist/wire-BFQmSJ-9.js +284 -0
- package/dist/wire-BFQmSJ-9.js.map +1 -0
- package/docs/01-quickstart.mdx +3 -3
- package/docs/concepts/01-contracts.mdx +18 -13
- package/docs/concepts/02-handlers.mdx +7 -7
- package/docs/concepts/03-durability.mdx +21 -21
- package/docs/concepts/04-state.mdx +18 -21
- package/docs/guides/01-timers.mdx +154 -49
- package/docs/guides/02-cancellation.mdx +30 -4
- package/docs/guides/03-react.mdx +76 -57
- package/docs/guides/04-local-first.mdx +1 -1
- package/docs/guides/05-production.mdx +310 -61
- package/docs/guides/06-ai-agents.mdx +252 -74
- package/docs/guides/07-devtools.mdx +137 -12
- package/docs/guides/08-application-data.mdx +1 -1
- package/docs/guides/09-presence.mdx +263 -0
- package/docs/guides/10-transports.mdx +140 -0
- package/docs/index.mdx +18 -14
- package/docs/reference/01-api.mdx +839 -110
- package/docs/reference/02-errors.mdx +13 -6
- package/package.json +24 -7
- package/src/ai-coordinator.ts +358 -0
- package/src/ai-projector.ts +524 -0
- package/src/ai-sdk-step.ts +261 -0
- package/src/ai-server.browser.ts +5 -0
- package/src/ai-server.ts +1719 -0
- package/src/ai.ts +2155 -0
- package/src/cache-indexeddb.ts +10 -0
- package/src/cli-bin.ts +5 -0
- package/src/cli.ts +1046 -0
- package/src/client.ts +1826 -0
- package/src/contract.ts +206 -0
- package/src/deterministic-id.ts +72 -0
- package/src/devtools-app.ts +989 -0
- package/src/devtools-server.browser.ts +5 -0
- package/src/devtools-server.ts +604 -0
- package/src/devtools.ts +716 -0
- package/src/errors.ts +50 -0
- package/src/http.ts +394 -0
- package/src/idempotent-replay.ts +53 -0
- package/src/index.ts +37 -0
- package/src/inspection.ts +39 -0
- package/src/internal.ts +426 -0
- package/src/otel.ts +59 -0
- package/src/platform.ts +60 -0
- package/src/push-envelope.ts +137 -0
- package/src/react.ts +284 -0
- package/src/reducer.ts +108 -0
- package/src/retryable-lazy.ts +27 -0
- package/src/scheduler-qstash.ts +915 -0
- package/src/scheduler-task.ts +106 -0
- package/src/scheduler-vercel.ts +437 -0
- package/src/server.browser.ts +12 -0
- package/src/server.ts +2700 -0
- package/src/session-socket.ts +548 -0
- package/src/sse.ts +141 -0
- package/src/standard-schema.ts +77 -0
- package/src/store-codec.ts +10 -0
- package/src/store-memory.ts +788 -0
- package/src/store-polling.ts +102 -0
- package/src/store-postgres.ts +1212 -0
- package/src/store-redis-core.ts +1494 -0
- package/src/store-redis-http.ts +116 -0
- package/src/store-redis.ts +458 -0
- package/src/store-sqlite.ts +1108 -0
- package/src/store.ts +385 -0
- package/src/telemetry.ts +47 -0
- package/src/testing.browser.ts +5 -0
- package/src/testing.ts +185 -0
- package/src/validate.ts +39 -0
- package/src/wire.ts +454 -0
- package/dist/internal-gCd5qMry.js +0 -44
- package/dist/log-memory.d.ts +0 -11
- package/dist/recovery-vercel.js +0 -119
- package/dist/server-BcLa4RFL.js +0 -877
- package/dist/wire-BVsgR8o9.js +0 -62
|
@@ -0,0 +1,1212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* experimental-a2/store-postgres — postgres store backend (the production path).
|
|
3
|
+
*
|
|
4
|
+
* Implements the A2Store interface over any client that speaks the tiny
|
|
5
|
+
* `PostgresClient` shape below — a `pg` Pool matches it structurally
|
|
6
|
+
* (and is what `connectionString` creates, lazily; `pg` is an optional
|
|
7
|
+
* peer dependency). The conformance suite in test/conformance is the
|
|
8
|
+
* executable contract, run against PGlite (real Postgres, in-process)
|
|
9
|
+
* and against a real server when `TEST_DATABASE_URL` is set.
|
|
10
|
+
*
|
|
11
|
+
* Semantics mirror the sqlite backend: `index` is stored as `idx`,
|
|
12
|
+
* timestamps are epoch milliseconds written from the injected clock —
|
|
13
|
+
* never SQL `now()` — and `stream()` is a poll loop. (A LISTEN/NOTIFY
|
|
14
|
+
* upgrade can land later without any API change; callers never branch
|
|
15
|
+
* on the transport.) Appends serialize per session via
|
|
16
|
+
* `pg_advisory_xact_lock`, so concurrent writers can't race the index
|
|
17
|
+
* assignment.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { A2Error } from './errors.ts'
|
|
21
|
+
import { idempotentReplay } from './idempotent-replay.ts'
|
|
22
|
+
import { decodeReturnedEventIds } from './store-codec.ts'
|
|
23
|
+
import { pollingStream } from './store-polling.ts'
|
|
24
|
+
import { retryableLazy } from './retryable-lazy.ts'
|
|
25
|
+
import {
|
|
26
|
+
RANDOM_IDS,
|
|
27
|
+
SYSTEM_CLOCK,
|
|
28
|
+
type A2Store,
|
|
29
|
+
type Clock,
|
|
30
|
+
type Event,
|
|
31
|
+
type EventCause,
|
|
32
|
+
type IdSource,
|
|
33
|
+
type StoredEvent,
|
|
34
|
+
type StoreStateRead,
|
|
35
|
+
} from './store.ts'
|
|
36
|
+
|
|
37
|
+
/** The result shape this backend reads: just rows. */
|
|
38
|
+
export type PostgresQueryResult = { rows: Record<string, unknown>[] }
|
|
39
|
+
|
|
40
|
+
/** One checked-out connection — what transactions run on. */
|
|
41
|
+
export type PostgresConnection = {
|
|
42
|
+
query(text: string, values?: unknown[]): Promise<PostgresQueryResult>
|
|
43
|
+
release(): void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The minimal client this backend needs. `pg.Pool` matches it
|
|
48
|
+
* structurally; so does PGlite (which has no `connect` — transactions
|
|
49
|
+
* then serialize on an internal mutex, fine for a single-connection
|
|
50
|
+
* client).
|
|
51
|
+
*/
|
|
52
|
+
export type PostgresClient = {
|
|
53
|
+
query(text: string, values?: unknown[]): Promise<PostgresQueryResult>
|
|
54
|
+
connect?(): Promise<PostgresConnection>
|
|
55
|
+
end?(): Promise<void>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type PostgresStoreOptions = {
|
|
59
|
+
/** Creates a `pg` Pool lazily (requires the optional peer dep `pg`). */
|
|
60
|
+
connectionString?: string | undefined
|
|
61
|
+
/** Bring your own client — a `pg` Pool, PGlite, a pooler wrapper. */
|
|
62
|
+
client?: PostgresClient
|
|
63
|
+
/** Injectable clock — every stored timestamp comes from here. */
|
|
64
|
+
clock?: Clock
|
|
65
|
+
/** Injectable id source for generated event ids. */
|
|
66
|
+
ids?: IdSource
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type PostgresStore = A2Store & {
|
|
70
|
+
/** End the underlying pool/client (when this backend created it, or
|
|
71
|
+
* the injected client has `end`). */
|
|
72
|
+
close(): Promise<void>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const SCHEMA = `
|
|
76
|
+
create table if not exists a2_events (
|
|
77
|
+
session_id text not null,
|
|
78
|
+
idx bigint not null,
|
|
79
|
+
event_type text not null,
|
|
80
|
+
payload jsonb not null,
|
|
81
|
+
event_id text not null,
|
|
82
|
+
created_at bigint not null,
|
|
83
|
+
cause jsonb,
|
|
84
|
+
lane text,
|
|
85
|
+
lane_ready boolean not null default false,
|
|
86
|
+
processed_at bigint,
|
|
87
|
+
processed_by_attempt integer,
|
|
88
|
+
returned_event_ids jsonb,
|
|
89
|
+
first_claimed_at bigint,
|
|
90
|
+
last_claimed_at bigint,
|
|
91
|
+
attempt_count integer not null default 0,
|
|
92
|
+
failure_count integer not null default 0,
|
|
93
|
+
last_failed_at bigint,
|
|
94
|
+
last_failed_attempt integer,
|
|
95
|
+
last_error text,
|
|
96
|
+
failed_at bigint,
|
|
97
|
+
claim_holder text,
|
|
98
|
+
claim_expires_at bigint,
|
|
99
|
+
primary key (session_id, idx)
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
create unique index if not exists a2_events_event_id on a2_events (event_id);
|
|
103
|
+
create index if not exists a2_events_unprocessed
|
|
104
|
+
on a2_events (session_id, idx) where processed_at is null;
|
|
105
|
+
create index if not exists a2_events_lane_pending
|
|
106
|
+
on a2_events (session_id, lane, idx) where processed_at is null;
|
|
107
|
+
create index if not exists a2_events_dispatch_ready
|
|
108
|
+
on a2_events (session_id, idx)
|
|
109
|
+
where lane_ready and processed_at is null and failed_at is null;
|
|
110
|
+
create index if not exists a2_events_claim_expiry
|
|
111
|
+
on a2_events (session_id, claim_expires_at)
|
|
112
|
+
where lane_ready and processed_at is null and failed_at is null;
|
|
113
|
+
|
|
114
|
+
create table if not exists a2_snapshots (
|
|
115
|
+
session_id text not null,
|
|
116
|
+
reducer_name text not null,
|
|
117
|
+
up_to_index bigint not null,
|
|
118
|
+
state jsonb not null,
|
|
119
|
+
updated_at bigint not null,
|
|
120
|
+
primary key (session_id, reducer_name)
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
create table if not exists a2_presence (
|
|
124
|
+
ns text not null,
|
|
125
|
+
participant text not null,
|
|
126
|
+
field text not null,
|
|
127
|
+
value jsonb not null,
|
|
128
|
+
seen bigint not null,
|
|
129
|
+
at bigint not null,
|
|
130
|
+
expires_at bigint not null,
|
|
131
|
+
primary key (ns, participant, field)
|
|
132
|
+
);
|
|
133
|
+
`
|
|
134
|
+
|
|
135
|
+
const asNumber = (value: unknown): number => Number(value) // bigint columns arrive as strings
|
|
136
|
+
|
|
137
|
+
const toDate = (ms: unknown): Date | null =>
|
|
138
|
+
ms === null || ms === undefined ? null : new Date(asNumber(ms))
|
|
139
|
+
|
|
140
|
+
const toCause = (raw: unknown): EventCause | null => {
|
|
141
|
+
if (raw === null || raw === undefined) return null
|
|
142
|
+
const value = (typeof raw === 'string' ? JSON.parse(raw) : raw) as {
|
|
143
|
+
index?: unknown
|
|
144
|
+
attempt?: unknown
|
|
145
|
+
batchSize?: unknown
|
|
146
|
+
}
|
|
147
|
+
if (
|
|
148
|
+
!Number.isInteger(value.index) ||
|
|
149
|
+
Number(value.index) < 1 ||
|
|
150
|
+
!Number.isInteger(value.attempt) ||
|
|
151
|
+
Number(value.attempt) < 1
|
|
152
|
+
) {
|
|
153
|
+
throw new TypeError('stored event has an invalid cause')
|
|
154
|
+
}
|
|
155
|
+
if (
|
|
156
|
+
value.batchSize !== undefined &&
|
|
157
|
+
(!Number.isInteger(value.batchSize) || Number(value.batchSize) < 1)
|
|
158
|
+
) {
|
|
159
|
+
throw new TypeError('stored event has an invalid cause')
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
index: Number(value.index),
|
|
163
|
+
attempt: Number(value.attempt),
|
|
164
|
+
...(value.batchSize === undefined
|
|
165
|
+
? {}
|
|
166
|
+
: { batchSize: Number(value.batchSize) }),
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const toStored = (row: Record<string, unknown>): StoredEvent => ({
|
|
171
|
+
id: row['event_id'] as string,
|
|
172
|
+
type: row['event_type'] as string,
|
|
173
|
+
payload: row['payload'],
|
|
174
|
+
index: asNumber(row['idx']),
|
|
175
|
+
sessionId: row['session_id'] as string,
|
|
176
|
+
createdAt: new Date(asNumber(row['created_at'])),
|
|
177
|
+
cause: toCause(row['cause']),
|
|
178
|
+
lane: (row['lane'] as string | null) ?? null,
|
|
179
|
+
processedAt: toDate(row['processed_at']),
|
|
180
|
+
processedByAttempt:
|
|
181
|
+
row['processed_by_attempt'] === null ||
|
|
182
|
+
row['processed_by_attempt'] === undefined
|
|
183
|
+
? null
|
|
184
|
+
: asNumber(row['processed_by_attempt']),
|
|
185
|
+
returnedEventIds:
|
|
186
|
+
row['returned_event_ids'] === null ||
|
|
187
|
+
row['returned_event_ids'] === undefined
|
|
188
|
+
? null
|
|
189
|
+
: decodeReturnedEventIds(row['returned_event_ids']),
|
|
190
|
+
firstClaimedAt: toDate(row['first_claimed_at']),
|
|
191
|
+
lastClaimedAt: toDate(row['last_claimed_at']),
|
|
192
|
+
attemptCount: asNumber(row['attempt_count']),
|
|
193
|
+
failureCount: asNumber(row['failure_count']),
|
|
194
|
+
lastFailedAt: toDate(row['last_failed_at']),
|
|
195
|
+
lastFailedAttempt:
|
|
196
|
+
row['last_failed_attempt'] === null ||
|
|
197
|
+
row['last_failed_attempt'] === undefined
|
|
198
|
+
? null
|
|
199
|
+
: asNumber(row['last_failed_attempt']),
|
|
200
|
+
lastError: (row['last_error'] as string | null) ?? null,
|
|
201
|
+
failedAt: toDate(row['failed_at']),
|
|
202
|
+
claimHolder: (row['claim_holder'] as string | null) ?? null,
|
|
203
|
+
claimExpiresAt: toDate(row['claim_expires_at']),
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
const toEvent = (row: Record<string, unknown>): Event => ({
|
|
207
|
+
id: row['event_id'] as string,
|
|
208
|
+
type: row['event_type'] as string,
|
|
209
|
+
payload: row['payload'],
|
|
210
|
+
index: asNumber(row['idx']),
|
|
211
|
+
sessionId: row['session_id'] as string,
|
|
212
|
+
createdAt: new Date(asNumber(row['created_at'])),
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
const wrap = async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
216
|
+
try {
|
|
217
|
+
return await fn()
|
|
218
|
+
} catch (err) {
|
|
219
|
+
if (err instanceof A2Error || err instanceof TypeError) throw err
|
|
220
|
+
throw new A2Error('STORE_UNAVAILABLE', 'postgres store operation failed', {
|
|
221
|
+
cause: err,
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const jsonb = (value: unknown): string => JSON.stringify(value) ?? 'null'
|
|
227
|
+
|
|
228
|
+
const initializeSchema = async (client: PostgresClient): Promise<void> => {
|
|
229
|
+
if (!client.connect) {
|
|
230
|
+
await client.query(SCHEMA)
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const conn = await client.connect()
|
|
235
|
+
try {
|
|
236
|
+
await conn.query('begin')
|
|
237
|
+
// `if not exists` does not serialize concurrent catalog writes. Every
|
|
238
|
+
// serverless isolate takes the same transaction-scoped lock before DDL.
|
|
239
|
+
await conn.query(
|
|
240
|
+
"select pg_advisory_xact_lock(hashtext('a2:postgres:schema'))",
|
|
241
|
+
)
|
|
242
|
+
await conn.query(SCHEMA)
|
|
243
|
+
await conn.query('commit')
|
|
244
|
+
} catch (err) {
|
|
245
|
+
await conn.query('rollback').catch(() => {})
|
|
246
|
+
throw err
|
|
247
|
+
} finally {
|
|
248
|
+
conn.release()
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
253
|
+
const clock = options.clock ?? SYSTEM_CLOCK
|
|
254
|
+
const generateId = options.ids ?? RANDOM_IDS
|
|
255
|
+
|
|
256
|
+
if (!options.client && !options.connectionString) {
|
|
257
|
+
throw new TypeError(
|
|
258
|
+
'postgres() needs a connectionString or an injected client',
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Lazy init: the first operation creates the client (dynamic import
|
|
263
|
+
// of the optional peer dep `pg`) and the schema. Constructing the
|
|
264
|
+
// backend does no I/O — same contract as every other adapter.
|
|
265
|
+
const connection = retryableLazy(async () => {
|
|
266
|
+
let owned: PostgresClient | null = null
|
|
267
|
+
try {
|
|
268
|
+
let c = options.client
|
|
269
|
+
if (!c) {
|
|
270
|
+
const pg = await import('pg').catch(() => {
|
|
271
|
+
throw new A2Error(
|
|
272
|
+
'STORE_NOT_CONFIGURED',
|
|
273
|
+
"experimental-a2/store-postgres with a connectionString needs the 'pg' package (optional peer dependency) — install it, or inject a client",
|
|
274
|
+
)
|
|
275
|
+
})
|
|
276
|
+
c = new pg.default.Pool({
|
|
277
|
+
connectionString: options.connectionString,
|
|
278
|
+
}) as unknown as PostgresClient
|
|
279
|
+
owned = c
|
|
280
|
+
}
|
|
281
|
+
await initializeSchema(c)
|
|
282
|
+
return c
|
|
283
|
+
} catch (err) {
|
|
284
|
+
if (owned?.end) await owned.end().catch(() => {})
|
|
285
|
+
throw err
|
|
286
|
+
}
|
|
287
|
+
})
|
|
288
|
+
const client = connection.get
|
|
289
|
+
|
|
290
|
+
// Transactions run on one connection. Pools check one out; a
|
|
291
|
+
// single-connection client (PGlite) serializes on this mutex instead.
|
|
292
|
+
let mutex: Promise<unknown> = Promise.resolve()
|
|
293
|
+
const withTx = async <T>(
|
|
294
|
+
fn: (query: PostgresConnection['query']) => Promise<T>,
|
|
295
|
+
): Promise<T> => {
|
|
296
|
+
const c = await client()
|
|
297
|
+
if (c.connect) {
|
|
298
|
+
const conn = await c.connect()
|
|
299
|
+
try {
|
|
300
|
+
await conn.query('begin')
|
|
301
|
+
const result = await fn((text, values) => conn.query(text, values))
|
|
302
|
+
await conn.query('commit')
|
|
303
|
+
return result
|
|
304
|
+
} catch (err) {
|
|
305
|
+
await conn.query('rollback').catch(() => {})
|
|
306
|
+
throw err
|
|
307
|
+
} finally {
|
|
308
|
+
conn.release()
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const run = mutex.then(async () => {
|
|
312
|
+
await c.query('begin')
|
|
313
|
+
try {
|
|
314
|
+
const result = await fn((text, values) => c.query(text, values))
|
|
315
|
+
await c.query('commit')
|
|
316
|
+
return result
|
|
317
|
+
} catch (err) {
|
|
318
|
+
await c.query('rollback').catch(() => {})
|
|
319
|
+
throw err
|
|
320
|
+
}
|
|
321
|
+
})
|
|
322
|
+
mutex = run.catch(() => {})
|
|
323
|
+
return run
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
async append(sessionId, events) {
|
|
328
|
+
if (events.length === 0) {
|
|
329
|
+
return wrap(async () => {
|
|
330
|
+
const c = await client()
|
|
331
|
+
const { rows } = await c.query(
|
|
332
|
+
`select exists(
|
|
333
|
+
select 1 from a2_events
|
|
334
|
+
where session_id = $1 and processed_at is null
|
|
335
|
+
) as has_pending`,
|
|
336
|
+
[sessionId],
|
|
337
|
+
)
|
|
338
|
+
return {
|
|
339
|
+
events: [],
|
|
340
|
+
hasPending: rows[0]?.['has_pending'] === true,
|
|
341
|
+
}
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
return wrap(() =>
|
|
345
|
+
withTx(async (query) => {
|
|
346
|
+
// Serialize appends per session — index assignment must not
|
|
347
|
+
// race across writers. Transaction-scoped, self-releasing.
|
|
348
|
+
await query('select pg_advisory_xact_lock(hashtext($1))', [sessionId])
|
|
349
|
+
|
|
350
|
+
const supplied = events.filter((e) => e.id !== undefined)
|
|
351
|
+
const suppliedIds = supplied.map((e) => e.id as string)
|
|
352
|
+
if (new Set(suppliedIds).size !== suppliedIds.length) {
|
|
353
|
+
throw new A2Error(
|
|
354
|
+
'PARTIAL_DUPLICATE_BATCH',
|
|
355
|
+
'batch contains the same event id more than once',
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
if (suppliedIds.length > 0) {
|
|
359
|
+
const { rows: existing } = await query(
|
|
360
|
+
`select stored.*,
|
|
361
|
+
exists(
|
|
362
|
+
select 1 from a2_events pending
|
|
363
|
+
where pending.session_id = $2
|
|
364
|
+
and pending.processed_at is null
|
|
365
|
+
) as session_has_pending
|
|
366
|
+
from a2_events stored
|
|
367
|
+
where stored.event_id = any($1)
|
|
368
|
+
order by stored.idx`,
|
|
369
|
+
[suppliedIds, sessionId],
|
|
370
|
+
)
|
|
371
|
+
if (existing.length > 0) {
|
|
372
|
+
const foreign = existing.find(
|
|
373
|
+
(row) => row['session_id'] !== sessionId,
|
|
374
|
+
)
|
|
375
|
+
if (foreign) {
|
|
376
|
+
throw new A2Error(
|
|
377
|
+
'PARTIAL_DUPLICATE_BATCH',
|
|
378
|
+
`event id '${String(foreign['event_id'])}' already exists in another session`,
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
if (existing.length === events.length) {
|
|
382
|
+
return {
|
|
383
|
+
events: idempotentReplay(events, existing.map(toStored)),
|
|
384
|
+
hasPending: existing[0]?.['session_has_pending'] === true,
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
throw new A2Error(
|
|
388
|
+
'PARTIAL_DUPLICATE_BATCH',
|
|
389
|
+
`batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`,
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Attempt-currency fence: a fresh handler append commits only
|
|
395
|
+
// while its causal attempt is still the parent's latest
|
|
396
|
+
// (store.ts `append`). `for update` serializes this read against
|
|
397
|
+
// a concurrent `claimAvailable` — an unlocked read admits write
|
|
398
|
+
// skew.
|
|
399
|
+
const causeIndexes = [
|
|
400
|
+
...new Set(
|
|
401
|
+
events
|
|
402
|
+
.filter((e) => e.cause !== undefined)
|
|
403
|
+
.map((e) => e.cause!.index),
|
|
404
|
+
),
|
|
405
|
+
]
|
|
406
|
+
if (causeIndexes.length > 0) {
|
|
407
|
+
const { rows: parents } = await query(
|
|
408
|
+
`select idx, attempt_count, failed_at from a2_events
|
|
409
|
+
where session_id = $1 and idx = any($2::bigint[])
|
|
410
|
+
for update`,
|
|
411
|
+
[sessionId, causeIndexes],
|
|
412
|
+
)
|
|
413
|
+
const byIndex = new Map(
|
|
414
|
+
parents.map((row) => [asNumber(row['idx']), row]),
|
|
415
|
+
)
|
|
416
|
+
for (const e of events) {
|
|
417
|
+
if (!e.cause) continue
|
|
418
|
+
const parent = byIndex.get(e.cause.index)
|
|
419
|
+
if (!parent) {
|
|
420
|
+
throw new TypeError(
|
|
421
|
+
`no event at index ${e.cause.index} in session '${sessionId}'`,
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
if (
|
|
425
|
+
asNumber(parent['attempt_count']) !== e.cause.attempt ||
|
|
426
|
+
parent['failed_at'] !== null
|
|
427
|
+
) {
|
|
428
|
+
throw new A2Error(
|
|
429
|
+
'SUPERSEDED_ATTEMPT',
|
|
430
|
+
`attempt ${e.cause.attempt} no longer owns event ${e.cause.index} in session '${sessionId}'`,
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const { rows: maxRows } = await query(
|
|
437
|
+
`select coalesce(max(idx), 0) as max,
|
|
438
|
+
exists(
|
|
439
|
+
select 1 from a2_events pending
|
|
440
|
+
where pending.session_id = $1
|
|
441
|
+
and pending.processed_at is null
|
|
442
|
+
) as session_has_pending
|
|
443
|
+
from a2_events
|
|
444
|
+
where session_id = $1`,
|
|
445
|
+
[sessionId],
|
|
446
|
+
)
|
|
447
|
+
const base = asNumber(maxRows[0]?.['max'])
|
|
448
|
+
const now = clock.now().getTime()
|
|
449
|
+
const inserted: StoredEvent[] = []
|
|
450
|
+
for (let i = 0; i < events.length; i += 1) {
|
|
451
|
+
const e = events[i]!
|
|
452
|
+
const id = e.id ?? generateId()
|
|
453
|
+
const index = base + 1 + i
|
|
454
|
+
// oxlint-disable-next-line no-await-in-loop -- one tx, ordered inserts
|
|
455
|
+
await query(
|
|
456
|
+
`insert into a2_events
|
|
457
|
+
(session_id, idx, event_type, payload, event_id, created_at,
|
|
458
|
+
cause, lane, lane_ready, processed_at)
|
|
459
|
+
values (
|
|
460
|
+
$1, $2, $3, $4::jsonb, $5, $6, $7::jsonb, $8,
|
|
461
|
+
$8::text is null or not exists (
|
|
462
|
+
select 1 from a2_events
|
|
463
|
+
where session_id = $1 and lane = $8 and processed_at is null
|
|
464
|
+
),
|
|
465
|
+
$9
|
|
466
|
+
)`,
|
|
467
|
+
[
|
|
468
|
+
sessionId,
|
|
469
|
+
index,
|
|
470
|
+
e.type,
|
|
471
|
+
jsonb(e.payload),
|
|
472
|
+
id,
|
|
473
|
+
now,
|
|
474
|
+
e.cause ? jsonb(e.cause) : null,
|
|
475
|
+
e.lane ?? null,
|
|
476
|
+
e.settled ? now : null,
|
|
477
|
+
],
|
|
478
|
+
)
|
|
479
|
+
inserted.push({
|
|
480
|
+
id,
|
|
481
|
+
type: e.type,
|
|
482
|
+
payload: structuredClone(e.payload),
|
|
483
|
+
index,
|
|
484
|
+
sessionId,
|
|
485
|
+
createdAt: new Date(now),
|
|
486
|
+
cause: e.cause ? { ...e.cause } : null,
|
|
487
|
+
lane: e.lane ?? null,
|
|
488
|
+
processedAt: e.settled ? new Date(now) : null,
|
|
489
|
+
processedByAttempt: null,
|
|
490
|
+
returnedEventIds: null,
|
|
491
|
+
firstClaimedAt: null,
|
|
492
|
+
lastClaimedAt: null,
|
|
493
|
+
attemptCount: 0,
|
|
494
|
+
failureCount: 0,
|
|
495
|
+
lastFailedAt: null,
|
|
496
|
+
lastFailedAttempt: null,
|
|
497
|
+
lastError: null,
|
|
498
|
+
failedAt: null,
|
|
499
|
+
claimHolder: null,
|
|
500
|
+
claimExpiresAt: null,
|
|
501
|
+
})
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
events: inserted,
|
|
505
|
+
hasPending:
|
|
506
|
+
maxRows[0]?.['session_has_pending'] === true ||
|
|
507
|
+
events.some((event) => event.settled !== true),
|
|
508
|
+
}
|
|
509
|
+
}),
|
|
510
|
+
)
|
|
511
|
+
},
|
|
512
|
+
|
|
513
|
+
async read(sessionId, opts) {
|
|
514
|
+
return wrap(async () => {
|
|
515
|
+
const c = await client()
|
|
516
|
+
const conditions = ['session_id = $1']
|
|
517
|
+
const params: unknown[] = [sessionId]
|
|
518
|
+
if (opts?.afterIndex !== undefined) {
|
|
519
|
+
params.push(opts.afterIndex)
|
|
520
|
+
conditions.push(`idx > $${params.length}`)
|
|
521
|
+
}
|
|
522
|
+
if (opts?.throughIndex !== undefined) {
|
|
523
|
+
params.push(opts.throughIndex)
|
|
524
|
+
conditions.push(`idx <= $${params.length}`)
|
|
525
|
+
}
|
|
526
|
+
const { rows } = await c.query(
|
|
527
|
+
`select * from a2_events where ${conditions.join(' and ')} order by idx`,
|
|
528
|
+
params,
|
|
529
|
+
)
|
|
530
|
+
return rows.map(toStored)
|
|
531
|
+
})
|
|
532
|
+
},
|
|
533
|
+
|
|
534
|
+
async claimAvailable({
|
|
535
|
+
sessionId,
|
|
536
|
+
holder,
|
|
537
|
+
ttlMs,
|
|
538
|
+
expiresAtMs,
|
|
539
|
+
excludeIndexes = [],
|
|
540
|
+
}) {
|
|
541
|
+
return wrap(() =>
|
|
542
|
+
withTx(async (query) => {
|
|
543
|
+
const now = clock.now().getTime()
|
|
544
|
+
const expiresAt = expiresAtMs ?? now + ttlMs
|
|
545
|
+
const { rows } = await query(
|
|
546
|
+
`with eligible as materialized (
|
|
547
|
+
select event.idx
|
|
548
|
+
from a2_events event
|
|
549
|
+
where event.session_id = $1
|
|
550
|
+
and event.processed_at is null
|
|
551
|
+
and event.failed_at is null
|
|
552
|
+
and event.lane_ready
|
|
553
|
+
and (event.claim_expires_at is null or event.claim_expires_at <= $2)
|
|
554
|
+
and not (event.idx = any($5::bigint[]))
|
|
555
|
+
for update of event
|
|
556
|
+
)
|
|
557
|
+
update a2_events claimed set
|
|
558
|
+
attempt_count = claimed.attempt_count + 1,
|
|
559
|
+
first_claimed_at = coalesce(claimed.first_claimed_at, $2),
|
|
560
|
+
last_claimed_at = $2,
|
|
561
|
+
claim_holder = $3,
|
|
562
|
+
claim_expires_at = $4
|
|
563
|
+
from eligible
|
|
564
|
+
where claimed.session_id = $1 and claimed.idx = eligible.idx
|
|
565
|
+
returning claimed.*`,
|
|
566
|
+
[sessionId, now, holder, expiresAt, [...excludeIndexes]],
|
|
567
|
+
)
|
|
568
|
+
if (rows.length > 0) {
|
|
569
|
+
const events = rows
|
|
570
|
+
.map(toStored)
|
|
571
|
+
.toSorted((a, b) => a.index - b.index)
|
|
572
|
+
return { outcome: 'claimed', events }
|
|
573
|
+
}
|
|
574
|
+
const { rows: activeRows } = await query(
|
|
575
|
+
`select min(claim_expires_at) as retry_at
|
|
576
|
+
from a2_events
|
|
577
|
+
where session_id = $1
|
|
578
|
+
and processed_at is null
|
|
579
|
+
and failed_at is null
|
|
580
|
+
and lane_ready
|
|
581
|
+
and claim_expires_at > $2`,
|
|
582
|
+
[sessionId, now],
|
|
583
|
+
)
|
|
584
|
+
const retryAt = activeRows[0]?.['retry_at']
|
|
585
|
+
return retryAt === null || retryAt === undefined
|
|
586
|
+
? { outcome: 'settled' }
|
|
587
|
+
: { outcome: 'busy', retryAt: new Date(asNumber(retryAt)) }
|
|
588
|
+
}),
|
|
589
|
+
)
|
|
590
|
+
},
|
|
591
|
+
|
|
592
|
+
async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
|
|
593
|
+
if (claims.length === 0) return { renewed: [], superseded: [] }
|
|
594
|
+
return wrap(async () => {
|
|
595
|
+
const c = await client()
|
|
596
|
+
const now = clock.now().getTime()
|
|
597
|
+
const expiresAt = expiresAtMs ?? now + ttlMs
|
|
598
|
+
const indexes = claims.map((claim) => claim.index)
|
|
599
|
+
const attempts = new Map(
|
|
600
|
+
claims.map((claim) => [claim.index, claim.attempt]),
|
|
601
|
+
)
|
|
602
|
+
const { rows } = await c.query(
|
|
603
|
+
`with listed as (
|
|
604
|
+
select idx, attempt
|
|
605
|
+
from unnest($3::bigint[], $6::int[]) as listed(idx, attempt)
|
|
606
|
+
),
|
|
607
|
+
renewed as (
|
|
608
|
+
update a2_events event set claim_expires_at = $1
|
|
609
|
+
from listed
|
|
610
|
+
where event.session_id = $2
|
|
611
|
+
and event.idx = listed.idx
|
|
612
|
+
and event.attempt_count = listed.attempt
|
|
613
|
+
and event.processed_at is null
|
|
614
|
+
and event.failed_at is null
|
|
615
|
+
and event.claim_holder = $4
|
|
616
|
+
and event.claim_expires_at > $5
|
|
617
|
+
returning event.idx
|
|
618
|
+
)
|
|
619
|
+
select event.idx, event.attempt_count,
|
|
620
|
+
exists(select 1 from renewed where renewed.idx = event.idx)
|
|
621
|
+
as renewed
|
|
622
|
+
from a2_events event
|
|
623
|
+
where event.session_id = $2 and event.idx = any($3::bigint[])`,
|
|
624
|
+
[
|
|
625
|
+
expiresAt,
|
|
626
|
+
sessionId,
|
|
627
|
+
indexes,
|
|
628
|
+
holder,
|
|
629
|
+
now,
|
|
630
|
+
claims.map((claim) => claim.attempt),
|
|
631
|
+
],
|
|
632
|
+
)
|
|
633
|
+
const renewed: number[] = []
|
|
634
|
+
const superseded: number[] = []
|
|
635
|
+
for (const row of rows) {
|
|
636
|
+
const index = asNumber(row['idx'])
|
|
637
|
+
if (row['renewed'] === true) renewed.push(index)
|
|
638
|
+
else if (
|
|
639
|
+
asNumber(row['attempt_count']) > (attempts.get(index) ?? 0)
|
|
640
|
+
) {
|
|
641
|
+
superseded.push(index)
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
renewed: renewed.toSorted((a, b) => a - b),
|
|
646
|
+
superseded: superseded.toSorted((a, b) => a - b),
|
|
647
|
+
}
|
|
648
|
+
})
|
|
649
|
+
},
|
|
650
|
+
|
|
651
|
+
async completeAttempt({ sessionId, index, attempt, events }) {
|
|
652
|
+
return wrap(() =>
|
|
653
|
+
withTx(async (query) => {
|
|
654
|
+
const { rows: parentRows } = await query(
|
|
655
|
+
`select * from a2_events
|
|
656
|
+
where session_id = $1 and idx = $2
|
|
657
|
+
for update`,
|
|
658
|
+
[sessionId, index],
|
|
659
|
+
)
|
|
660
|
+
const parent = parentRows[0]
|
|
661
|
+
if (!parent) {
|
|
662
|
+
throw new TypeError(
|
|
663
|
+
`no event at index ${index} in session '${sessionId}'`,
|
|
664
|
+
)
|
|
665
|
+
}
|
|
666
|
+
const ids = events.map((event) => event.id)
|
|
667
|
+
if (new Set(ids).size !== ids.length) {
|
|
668
|
+
throw new A2Error(
|
|
669
|
+
'PARTIAL_DUPLICATE_BATCH',
|
|
670
|
+
'returned event batch contains the same event id more than once',
|
|
671
|
+
)
|
|
672
|
+
}
|
|
673
|
+
if (parent['processed_at'] !== null) {
|
|
674
|
+
if (asNumber(parent['processed_by_attempt']) !== attempt) {
|
|
675
|
+
return { outcome: 'superseded' }
|
|
676
|
+
}
|
|
677
|
+
const returnedIds =
|
|
678
|
+
parent['returned_event_ids'] === null ||
|
|
679
|
+
parent['returned_event_ids'] === undefined
|
|
680
|
+
? null
|
|
681
|
+
: decodeReturnedEventIds(parent['returned_event_ids'])
|
|
682
|
+
if (
|
|
683
|
+
returnedIds === null ||
|
|
684
|
+
returnedIds.length !== ids.length ||
|
|
685
|
+
returnedIds.some((id, offset) => id !== ids[offset])
|
|
686
|
+
) {
|
|
687
|
+
throw new A2Error(
|
|
688
|
+
'PARTIAL_DUPLICATE_BATCH',
|
|
689
|
+
'completed attempt does not match the returned event batch',
|
|
690
|
+
)
|
|
691
|
+
}
|
|
692
|
+
if (events.length === 0) {
|
|
693
|
+
return { outcome: 'completed', events: [] }
|
|
694
|
+
}
|
|
695
|
+
const { rows: existing } = await query(
|
|
696
|
+
`select * from a2_events
|
|
697
|
+
where event_id = any($1::text[])`,
|
|
698
|
+
[ids],
|
|
699
|
+
)
|
|
700
|
+
const byId = new Map(
|
|
701
|
+
existing.map((row) => [row['event_id'] as string, row]),
|
|
702
|
+
)
|
|
703
|
+
const ordered = ids.map((id) => byId.get(id))
|
|
704
|
+
if (
|
|
705
|
+
ordered.some((row) => {
|
|
706
|
+
const cause = row ? toCause(row['cause']) : null
|
|
707
|
+
return (
|
|
708
|
+
!row ||
|
|
709
|
+
row['session_id'] !== sessionId ||
|
|
710
|
+
cause?.index !== index ||
|
|
711
|
+
cause.attempt !== attempt
|
|
712
|
+
)
|
|
713
|
+
})
|
|
714
|
+
) {
|
|
715
|
+
throw new A2Error(
|
|
716
|
+
'PARTIAL_DUPLICATE_BATCH',
|
|
717
|
+
'returned event retry does not match the committed batch',
|
|
718
|
+
)
|
|
719
|
+
}
|
|
720
|
+
return {
|
|
721
|
+
outcome: 'completed',
|
|
722
|
+
events: idempotentReplay(
|
|
723
|
+
events,
|
|
724
|
+
(ordered as Record<string, unknown>[]).map(toStored),
|
|
725
|
+
),
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
if (
|
|
729
|
+
asNumber(parent['attempt_count']) !== attempt ||
|
|
730
|
+
parent['claim_holder'] === null ||
|
|
731
|
+
parent['claim_holder'] === undefined ||
|
|
732
|
+
parent['failed_at'] !== null
|
|
733
|
+
) {
|
|
734
|
+
return { outcome: 'superseded' }
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
await query('select pg_advisory_xact_lock(hashtext($1))', [sessionId])
|
|
738
|
+
if (ids.length > 0) {
|
|
739
|
+
const { rows: existing } = await query(
|
|
740
|
+
'select event_id from a2_events where event_id = any($1::text[])',
|
|
741
|
+
[ids],
|
|
742
|
+
)
|
|
743
|
+
if (existing.length > 0) {
|
|
744
|
+
throw new A2Error(
|
|
745
|
+
'PARTIAL_DUPLICATE_BATCH',
|
|
746
|
+
'returned event batch contains an already-appended event id',
|
|
747
|
+
)
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const { rows: maxRows } = await query(
|
|
752
|
+
'select coalesce(max(idx), 0) as max from a2_events where session_id = $1',
|
|
753
|
+
[sessionId],
|
|
754
|
+
)
|
|
755
|
+
const base = asNumber(maxRows[0]?.['max'])
|
|
756
|
+
const now = clock.now().getTime()
|
|
757
|
+
const inserted: StoredEvent[] = []
|
|
758
|
+
for (let offset = 0; offset < events.length; offset += 1) {
|
|
759
|
+
const event = events[offset]!
|
|
760
|
+
const childIndex = base + offset + 1
|
|
761
|
+
// oxlint-disable-next-line no-await-in-loop -- one tx, ordered inserts
|
|
762
|
+
const { rows } = await query(
|
|
763
|
+
`insert into a2_events
|
|
764
|
+
(session_id, idx, event_type, payload, event_id, created_at,
|
|
765
|
+
cause, lane, lane_ready, processed_at)
|
|
766
|
+
values (
|
|
767
|
+
$1, $2, $3, $4::jsonb, $5, $6, $7::jsonb, $8,
|
|
768
|
+
$8::text is null or not exists (
|
|
769
|
+
select 1 from a2_events
|
|
770
|
+
where session_id = $1 and lane = $8 and processed_at is null
|
|
771
|
+
),
|
|
772
|
+
$9
|
|
773
|
+
)
|
|
774
|
+
returning *`,
|
|
775
|
+
[
|
|
776
|
+
sessionId,
|
|
777
|
+
childIndex,
|
|
778
|
+
event.type,
|
|
779
|
+
jsonb(event.payload),
|
|
780
|
+
event.id,
|
|
781
|
+
now,
|
|
782
|
+
jsonb({ index, attempt }),
|
|
783
|
+
event.lane ?? null,
|
|
784
|
+
event.settled ? now : null,
|
|
785
|
+
],
|
|
786
|
+
)
|
|
787
|
+
inserted.push(toStored(rows[0]!))
|
|
788
|
+
}
|
|
789
|
+
await query(
|
|
790
|
+
`update a2_events set
|
|
791
|
+
processed_at = $1,
|
|
792
|
+
processed_by_attempt = $2,
|
|
793
|
+
returned_event_ids = $5::jsonb,
|
|
794
|
+
lane_ready = false,
|
|
795
|
+
claim_holder = null,
|
|
796
|
+
claim_expires_at = null
|
|
797
|
+
where session_id = $3 and idx = $4`,
|
|
798
|
+
[now, attempt, sessionId, index, jsonb(ids)],
|
|
799
|
+
)
|
|
800
|
+
const parentLane = (parent['lane'] as string | null) ?? null
|
|
801
|
+
if (parentLane !== null) {
|
|
802
|
+
await query(
|
|
803
|
+
`update a2_events set lane_ready = true
|
|
804
|
+
where session_id = $1
|
|
805
|
+
and idx = (
|
|
806
|
+
select idx from a2_events
|
|
807
|
+
where session_id = $1 and lane = $2 and processed_at is null
|
|
808
|
+
order by idx limit 1
|
|
809
|
+
)`,
|
|
810
|
+
[sessionId, parentLane],
|
|
811
|
+
)
|
|
812
|
+
}
|
|
813
|
+
return { outcome: 'completed', events: inserted }
|
|
814
|
+
}),
|
|
815
|
+
)
|
|
816
|
+
},
|
|
817
|
+
|
|
818
|
+
async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
|
|
819
|
+
return wrap(async () => {
|
|
820
|
+
const c = await client()
|
|
821
|
+
const { rows } = await c.query(
|
|
822
|
+
`with current as materialized (
|
|
823
|
+
select e.* from a2_events e
|
|
824
|
+
where e.session_id = $1 and e.idx = $2
|
|
825
|
+
for update of e
|
|
826
|
+
), updated as (
|
|
827
|
+
update a2_events e set
|
|
828
|
+
failure_count = e.failure_count + 1,
|
|
829
|
+
last_error = $4,
|
|
830
|
+
last_failed_at = $6,
|
|
831
|
+
last_failed_attempt = $3,
|
|
832
|
+
failed_at = case
|
|
833
|
+
when e.failure_count + 1 >= $5 then $6
|
|
834
|
+
else e.failed_at
|
|
835
|
+
end,
|
|
836
|
+
claim_holder = null,
|
|
837
|
+
claim_expires_at = null
|
|
838
|
+
from current
|
|
839
|
+
where e.session_id = $1
|
|
840
|
+
and e.idx = $2
|
|
841
|
+
and current.processed_at is null
|
|
842
|
+
and current.failed_at is null
|
|
843
|
+
and current.attempt_count = $3
|
|
844
|
+
and current.claim_holder is not null
|
|
845
|
+
returning e.failure_count, e.failed_at
|
|
846
|
+
)
|
|
847
|
+
select case
|
|
848
|
+
when not exists (select 1 from current) then 'missing'
|
|
849
|
+
when exists (
|
|
850
|
+
select 1 from updated where failed_at is not null
|
|
851
|
+
) then 'dead_lettered'
|
|
852
|
+
when exists (select 1 from updated) then 'failed'
|
|
853
|
+
when exists (
|
|
854
|
+
select 1 from current
|
|
855
|
+
where processed_at is not null or attempt_count <> $3
|
|
856
|
+
) then 'superseded'
|
|
857
|
+
when exists (
|
|
858
|
+
select 1 from current where failed_at is not null
|
|
859
|
+
) then 'dead_lettered'
|
|
860
|
+
when exists (
|
|
861
|
+
select 1 from current
|
|
862
|
+
where claim_holder is null and last_failed_attempt = $3
|
|
863
|
+
) then 'failed'
|
|
864
|
+
else 'superseded'
|
|
865
|
+
end as outcome,
|
|
866
|
+
coalesce(
|
|
867
|
+
(select failure_count from updated),
|
|
868
|
+
(select failure_count from current)
|
|
869
|
+
) as failure_count`,
|
|
870
|
+
[
|
|
871
|
+
sessionId,
|
|
872
|
+
index,
|
|
873
|
+
attempt,
|
|
874
|
+
error,
|
|
875
|
+
maxFailures,
|
|
876
|
+
clock.now().getTime(),
|
|
877
|
+
],
|
|
878
|
+
)
|
|
879
|
+
const row = rows[0]
|
|
880
|
+
if (!row || row['outcome'] === 'missing') {
|
|
881
|
+
throw new TypeError(
|
|
882
|
+
`no event at index ${index} in session '${sessionId}'`,
|
|
883
|
+
)
|
|
884
|
+
}
|
|
885
|
+
return {
|
|
886
|
+
outcome: row['outcome'] as 'failed' | 'dead_lettered' | 'superseded',
|
|
887
|
+
failureCount: asNumber(row['failure_count']),
|
|
888
|
+
}
|
|
889
|
+
})
|
|
890
|
+
},
|
|
891
|
+
|
|
892
|
+
async readState(sessionId, reducerName) {
|
|
893
|
+
return wrap(async () => {
|
|
894
|
+
const c = await client()
|
|
895
|
+
const { rows } = await c.query(
|
|
896
|
+
`with snapshot as materialized (
|
|
897
|
+
select up_to_index, state
|
|
898
|
+
from a2_snapshots
|
|
899
|
+
where session_id = $1 and reducer_name = $2
|
|
900
|
+
)
|
|
901
|
+
select 0 as row_order, 'snapshot' as row_kind,
|
|
902
|
+
snapshot.up_to_index as snapshot_index,
|
|
903
|
+
snapshot.state as snapshot_state,
|
|
904
|
+
null::text as session_id, null::bigint as idx,
|
|
905
|
+
null::text as event_type, null::jsonb as payload,
|
|
906
|
+
null::text as event_id, null::bigint as created_at
|
|
907
|
+
from snapshot
|
|
908
|
+
union all
|
|
909
|
+
select 1 as row_order, 'event' as row_kind,
|
|
910
|
+
null::bigint as snapshot_index,
|
|
911
|
+
null::jsonb as snapshot_state,
|
|
912
|
+
event.session_id, event.idx, event.event_type,
|
|
913
|
+
event.payload, event.event_id, event.created_at
|
|
914
|
+
from a2_events event
|
|
915
|
+
where event.session_id = $1
|
|
916
|
+
and event.idx > coalesce((select up_to_index from snapshot), 0)
|
|
917
|
+
order by row_order, idx`,
|
|
918
|
+
[sessionId, reducerName],
|
|
919
|
+
)
|
|
920
|
+
const snapshot = rows.find((row) => row['row_kind'] === 'snapshot')
|
|
921
|
+
return {
|
|
922
|
+
snapshot: snapshot
|
|
923
|
+
? {
|
|
924
|
+
index: asNumber(snapshot['snapshot_index']),
|
|
925
|
+
state: snapshot['snapshot_state'],
|
|
926
|
+
}
|
|
927
|
+
: null,
|
|
928
|
+
events: rows
|
|
929
|
+
.filter((row) => row['row_kind'] === 'event')
|
|
930
|
+
.map(toEvent),
|
|
931
|
+
}
|
|
932
|
+
})
|
|
933
|
+
},
|
|
934
|
+
|
|
935
|
+
async readStates(sessionIds, reducerName) {
|
|
936
|
+
if (sessionIds.length === 0) return []
|
|
937
|
+
return wrap(async () => {
|
|
938
|
+
const c = await client()
|
|
939
|
+
// One statement, one MVCC snapshot — each session's tail is
|
|
940
|
+
// bounded by the same snapshot row the read returns.
|
|
941
|
+
const { rows } = await c.query(
|
|
942
|
+
`with snapshot as materialized (
|
|
943
|
+
select session_id, up_to_index, state
|
|
944
|
+
from a2_snapshots
|
|
945
|
+
where session_id = any($1::text[]) and reducer_name = $2
|
|
946
|
+
)
|
|
947
|
+
select 0 as row_order, 'snapshot' as row_kind,
|
|
948
|
+
snapshot.session_id,
|
|
949
|
+
snapshot.up_to_index as snapshot_index,
|
|
950
|
+
snapshot.state as snapshot_state,
|
|
951
|
+
null::bigint as idx, null::text as event_type,
|
|
952
|
+
null::jsonb as payload, null::text as event_id,
|
|
953
|
+
null::bigint as created_at
|
|
954
|
+
from snapshot
|
|
955
|
+
union all
|
|
956
|
+
select 1 as row_order, 'event' as row_kind,
|
|
957
|
+
event.session_id,
|
|
958
|
+
null::bigint as snapshot_index,
|
|
959
|
+
null::jsonb as snapshot_state,
|
|
960
|
+
event.idx, event.event_type, event.payload,
|
|
961
|
+
event.event_id, event.created_at
|
|
962
|
+
from a2_events event
|
|
963
|
+
left join snapshot on snapshot.session_id = event.session_id
|
|
964
|
+
where event.session_id = any($1::text[])
|
|
965
|
+
and event.idx > coalesce(snapshot.up_to_index, 0)
|
|
966
|
+
order by row_order, session_id, idx`,
|
|
967
|
+
[sessionIds, reducerName],
|
|
968
|
+
)
|
|
969
|
+
const bySession = new Map<string, StoreStateRead>()
|
|
970
|
+
const readOf = (sessionId: string): StoreStateRead => {
|
|
971
|
+
let read = bySession.get(sessionId)
|
|
972
|
+
if (!read) {
|
|
973
|
+
read = { snapshot: null, events: [] }
|
|
974
|
+
bySession.set(sessionId, read)
|
|
975
|
+
}
|
|
976
|
+
return read
|
|
977
|
+
}
|
|
978
|
+
for (const row of rows) {
|
|
979
|
+
const read = readOf(row['session_id'] as string)
|
|
980
|
+
if (row['row_kind'] === 'snapshot') {
|
|
981
|
+
read.snapshot = {
|
|
982
|
+
index: asNumber(row['snapshot_index']),
|
|
983
|
+
state: row['snapshot_state'],
|
|
984
|
+
}
|
|
985
|
+
} else {
|
|
986
|
+
read.events.push(toEvent(row))
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
return sessionIds.map(
|
|
990
|
+
(sessionId) =>
|
|
991
|
+
bySession.get(sessionId) ?? { snapshot: null, events: [] },
|
|
992
|
+
)
|
|
993
|
+
})
|
|
994
|
+
},
|
|
995
|
+
|
|
996
|
+
async putSnapshot(sessionId, reducerName, index, state) {
|
|
997
|
+
await wrap(async () => {
|
|
998
|
+
const c = await client()
|
|
999
|
+
// Guarded upsert: a slower concurrent writer must never clobber
|
|
1000
|
+
// a further-along snapshot (spec §3).
|
|
1001
|
+
await c.query(
|
|
1002
|
+
`insert into a2_snapshots (session_id, reducer_name, up_to_index, state, updated_at)
|
|
1003
|
+
values ($1, $2, $3, $4::jsonb, $5)
|
|
1004
|
+
on conflict (session_id, reducer_name) do update set
|
|
1005
|
+
up_to_index = excluded.up_to_index,
|
|
1006
|
+
state = excluded.state,
|
|
1007
|
+
updated_at = excluded.updated_at
|
|
1008
|
+
where excluded.up_to_index > a2_snapshots.up_to_index`,
|
|
1009
|
+
[sessionId, reducerName, index, jsonb(state), clock.now().getTime()],
|
|
1010
|
+
)
|
|
1011
|
+
})
|
|
1012
|
+
},
|
|
1013
|
+
|
|
1014
|
+
presence: {
|
|
1015
|
+
async set(ns, participant, values, meta) {
|
|
1016
|
+
const entries = Object.entries(values)
|
|
1017
|
+
if (entries.length === 0) return
|
|
1018
|
+
await wrap(async () => {
|
|
1019
|
+
const c = await client()
|
|
1020
|
+
const atMs = meta.at.getTime()
|
|
1021
|
+
// Expiry anchors on the storage clock — the sender's `at`
|
|
1022
|
+
// orders writes but never anchors their lifetime.
|
|
1023
|
+
const expiresAtMs = clock.now().getTime() + meta.ttlMs
|
|
1024
|
+
for (const [field, value] of entries) {
|
|
1025
|
+
// Field-wise LWW: a stored row with a strictly newer `at`
|
|
1026
|
+
// survives; ties go to the incoming write (memory parity).
|
|
1027
|
+
// oxlint-disable-next-line no-await-in-loop -- independent per-field registers
|
|
1028
|
+
await (value === null
|
|
1029
|
+
? c.query(
|
|
1030
|
+
`delete from a2_presence
|
|
1031
|
+
where ns = $1 and participant = $2 and field = $3
|
|
1032
|
+
and at <= $4`,
|
|
1033
|
+
[ns, participant, field, atMs],
|
|
1034
|
+
)
|
|
1035
|
+
: c.query(
|
|
1036
|
+
`insert into a2_presence
|
|
1037
|
+
(ns, participant, field, value, seen, at, expires_at)
|
|
1038
|
+
values ($1, $2, $3, $4::jsonb, $5, $6, $7)
|
|
1039
|
+
on conflict (ns, participant, field) do update set
|
|
1040
|
+
value = excluded.value,
|
|
1041
|
+
seen = excluded.seen,
|
|
1042
|
+
at = excluded.at,
|
|
1043
|
+
expires_at = excluded.expires_at
|
|
1044
|
+
where a2_presence.at <= excluded.at`,
|
|
1045
|
+
[
|
|
1046
|
+
ns,
|
|
1047
|
+
participant,
|
|
1048
|
+
field,
|
|
1049
|
+
jsonb(value),
|
|
1050
|
+
meta.seen,
|
|
1051
|
+
atMs,
|
|
1052
|
+
expiresAtMs,
|
|
1053
|
+
],
|
|
1054
|
+
))
|
|
1055
|
+
}
|
|
1056
|
+
})
|
|
1057
|
+
},
|
|
1058
|
+
|
|
1059
|
+
async read(ns) {
|
|
1060
|
+
return wrap(async () => {
|
|
1061
|
+
const c = await client()
|
|
1062
|
+
const now = clock.now().getTime()
|
|
1063
|
+
// Sweep and read share one statement (one snapshot): the
|
|
1064
|
+
// delete is opportunistic cleanup; the filter alone keeps
|
|
1065
|
+
// expired rows out of this result.
|
|
1066
|
+
const { rows } = await c.query(
|
|
1067
|
+
`with swept as (
|
|
1068
|
+
delete from a2_presence
|
|
1069
|
+
where ns = $1 and expires_at <= $2
|
|
1070
|
+
)
|
|
1071
|
+
select participant, field, value, seen, at, expires_at
|
|
1072
|
+
from a2_presence
|
|
1073
|
+
where ns = $1 and expires_at > $2
|
|
1074
|
+
order by participant, field`,
|
|
1075
|
+
[ns, now],
|
|
1076
|
+
)
|
|
1077
|
+
return rows.map((row) => ({
|
|
1078
|
+
participant: row['participant'] as string,
|
|
1079
|
+
field: row['field'] as string,
|
|
1080
|
+
value: row['value'],
|
|
1081
|
+
seen: asNumber(row['seen']),
|
|
1082
|
+
at: new Date(asNumber(row['at'])),
|
|
1083
|
+
expiresAt: new Date(asNumber(row['expires_at'])),
|
|
1084
|
+
}))
|
|
1085
|
+
})
|
|
1086
|
+
},
|
|
1087
|
+
},
|
|
1088
|
+
|
|
1089
|
+
inspect: {
|
|
1090
|
+
async listSessions(inspectionOptions) {
|
|
1091
|
+
return wrap(async () => {
|
|
1092
|
+
const c = await client()
|
|
1093
|
+
const conditions = ['left(session_id, length($1)) = $1']
|
|
1094
|
+
const params: unknown[] = [inspectionOptions.prefix]
|
|
1095
|
+
if (inspectionOptions.cursor !== undefined) {
|
|
1096
|
+
params.push(inspectionOptions.cursor)
|
|
1097
|
+
conditions.push(`session_id > $${params.length}`)
|
|
1098
|
+
}
|
|
1099
|
+
params.push(inspectionOptions.limit + 1)
|
|
1100
|
+
const { rows } = await c.query(
|
|
1101
|
+
`select
|
|
1102
|
+
session_id,
|
|
1103
|
+
count(*) as event_count,
|
|
1104
|
+
count(*) filter (where processed_at is null and failed_at is null) as pending_count,
|
|
1105
|
+
count(*) filter (where failed_at is not null) as failed_count,
|
|
1106
|
+
sum(attempt_count) as attempt_count,
|
|
1107
|
+
sum(failure_count) as failure_count,
|
|
1108
|
+
min(created_at) as first_event_at,
|
|
1109
|
+
max(greatest(
|
|
1110
|
+
created_at,
|
|
1111
|
+
coalesce(first_claimed_at, 0),
|
|
1112
|
+
coalesce(last_claimed_at, 0),
|
|
1113
|
+
coalesce(last_failed_at, 0),
|
|
1114
|
+
coalesce(processed_at, 0),
|
|
1115
|
+
coalesce(failed_at, 0)
|
|
1116
|
+
)) as updated_at
|
|
1117
|
+
from a2_events
|
|
1118
|
+
where ${conditions.join(' and ')}
|
|
1119
|
+
group by session_id
|
|
1120
|
+
order by session_id
|
|
1121
|
+
limit $${params.length}`,
|
|
1122
|
+
params,
|
|
1123
|
+
)
|
|
1124
|
+
const hasMore = rows.length > inspectionOptions.limit
|
|
1125
|
+
const visible = rows.slice(0, inspectionOptions.limit)
|
|
1126
|
+
return {
|
|
1127
|
+
sessions: visible.map((row) => ({
|
|
1128
|
+
sessionId: row['session_id'] as string,
|
|
1129
|
+
eventCount: asNumber(row['event_count']),
|
|
1130
|
+
pendingCount: asNumber(row['pending_count']),
|
|
1131
|
+
failedCount: asNumber(row['failed_count']),
|
|
1132
|
+
attemptCount: asNumber(row['attempt_count']),
|
|
1133
|
+
failureCount: asNumber(row['failure_count']),
|
|
1134
|
+
firstEventAt: new Date(asNumber(row['first_event_at'])),
|
|
1135
|
+
updatedAt: new Date(asNumber(row['updated_at'])),
|
|
1136
|
+
})),
|
|
1137
|
+
cursor: hasMore
|
|
1138
|
+
? ((visible.at(-1)?.['session_id'] as string | undefined) ?? null)
|
|
1139
|
+
: null,
|
|
1140
|
+
}
|
|
1141
|
+
})
|
|
1142
|
+
},
|
|
1143
|
+
async readEvents(sessionId, pageOptions) {
|
|
1144
|
+
return wrap(async () => {
|
|
1145
|
+
const c = await client()
|
|
1146
|
+
const currentFrontier = asNumber(
|
|
1147
|
+
(
|
|
1148
|
+
await c.query(
|
|
1149
|
+
'select coalesce(max(idx), 0) as frontier from a2_events where session_id = $1',
|
|
1150
|
+
[sessionId],
|
|
1151
|
+
)
|
|
1152
|
+
).rows[0]?.['frontier'] ?? 0,
|
|
1153
|
+
)
|
|
1154
|
+
const throughIndex = Math.min(
|
|
1155
|
+
pageOptions.throughIndex ?? currentFrontier,
|
|
1156
|
+
currentFrontier,
|
|
1157
|
+
)
|
|
1158
|
+
const { rows } = await c.query(
|
|
1159
|
+
`select * from a2_events
|
|
1160
|
+
where session_id = $1 and idx > $2 and idx <= $3
|
|
1161
|
+
order by idx limit $4`,
|
|
1162
|
+
[
|
|
1163
|
+
sessionId,
|
|
1164
|
+
pageOptions.afterIndex,
|
|
1165
|
+
throughIndex,
|
|
1166
|
+
pageOptions.limit,
|
|
1167
|
+
],
|
|
1168
|
+
)
|
|
1169
|
+
return { events: rows.map(toStored), throughIndex }
|
|
1170
|
+
})
|
|
1171
|
+
},
|
|
1172
|
+
async listSnapshots(sessionId) {
|
|
1173
|
+
return wrap(async () => {
|
|
1174
|
+
const c = await client()
|
|
1175
|
+
const { rows } = await c.query(
|
|
1176
|
+
`select reducer_name, up_to_index, updated_at
|
|
1177
|
+
from a2_snapshots where session_id = $1 order by reducer_name`,
|
|
1178
|
+
[sessionId],
|
|
1179
|
+
)
|
|
1180
|
+
return rows.map((row) => ({
|
|
1181
|
+
reducerName: row['reducer_name'] as string,
|
|
1182
|
+
index: asNumber(row['up_to_index']),
|
|
1183
|
+
updatedAt: new Date(asNumber(row['updated_at'])),
|
|
1184
|
+
}))
|
|
1185
|
+
})
|
|
1186
|
+
},
|
|
1187
|
+
},
|
|
1188
|
+
|
|
1189
|
+
stream(sessionId, opts) {
|
|
1190
|
+
const readAfter = (afterIndex: number): Promise<StoredEvent[]> =>
|
|
1191
|
+
wrap(async () => {
|
|
1192
|
+
const c = await client()
|
|
1193
|
+
const { rows } = await c.query(
|
|
1194
|
+
'select * from a2_events where session_id = $1 and idx > $2 order by idx',
|
|
1195
|
+
[sessionId, afterIndex],
|
|
1196
|
+
)
|
|
1197
|
+
return rows.map(toStored)
|
|
1198
|
+
})
|
|
1199
|
+
return pollingStream(
|
|
1200
|
+
readAfter,
|
|
1201
|
+
opts?.startAfter !== undefined ? { startAfter: opts.startAfter } : {},
|
|
1202
|
+
)
|
|
1203
|
+
},
|
|
1204
|
+
|
|
1205
|
+
async close() {
|
|
1206
|
+
const current = connection.peek()
|
|
1207
|
+
if (!current) return
|
|
1208
|
+
const c = await current
|
|
1209
|
+
await c.end?.()
|
|
1210
|
+
},
|
|
1211
|
+
}
|
|
1212
|
+
}
|