experimental-a2 0.9.0 → 0.11.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 +53 -0
- package/dist/actor-D_54lz_1.d.ts +310 -0
- package/dist/actor-D_54lz_1.d.ts.map +1 -0
- package/dist/actor-client.d.ts +13 -4
- package/dist/actor-client.d.ts.map +1 -1
- package/dist/actor-client.js +63 -7
- package/dist/actor-client.js.map +1 -1
- package/dist/actor-react.d.ts +5 -4
- package/dist/actor-react.d.ts.map +1 -1
- package/dist/actor-react.js +16 -2
- package/dist/actor-react.js.map +1 -1
- package/dist/actor.d.ts +2 -176
- package/dist/actor.js +13 -2
- package/dist/actor.js.map +1 -1
- package/dist/ai-server.d.ts +1 -1
- package/dist/ai-server.js +2 -2
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/client-Bf6uSEAk.js +1342 -0
- package/dist/client-Bf6uSEAk.js.map +1 -0
- package/dist/client-P_NNNRM-.d.ts +243 -0
- package/dist/client-P_NNNRM-.d.ts.map +1 -0
- package/dist/client.d.ts +2 -202
- package/dist/client.js +2 -1026
- package/dist/errors-DCk6ch5n.js.map +1 -1
- package/dist/errors-DvhSXnxk.d.ts +28 -0
- package/dist/errors-DvhSXnxk.d.ts.map +1 -0
- package/dist/index.d.ts +3 -35
- package/dist/{internal-DRXJ56EI.js → internal-Dq2qYxou.js} +2 -2
- package/dist/{internal-DRXJ56EI.js.map → internal-Dq2qYxou.js.map} +1 -1
- package/dist/platform-B4TnJtWu.js +34 -0
- package/dist/platform-B4TnJtWu.js.map +1 -0
- package/dist/react.d.ts +3 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +2 -1
- package/dist/react.js.map +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +3 -2
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +2 -2
- package/dist/{server-CBET-jSz.js → server-Dkz2a84E.js} +295 -133
- package/dist/server-Dkz2a84E.js.map +1 -0
- package/dist/{server-CKY3_lbw.d.ts → server-DwPrMqHB.d.ts} +4 -2
- package/dist/server-DwPrMqHB.d.ts.map +1 -0
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{store-DGHeBtIQ.d.ts → store-DtDOWLSn.d.ts} +4 -5
- package/dist/{store-DGHeBtIQ.d.ts.map → store-DtDOWLSn.d.ts.map} +1 -1
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +1 -1
- package/dist/{store-polling-6DW7F1DT.js → store-polling-CmxUbV93.js} +56 -7
- package/dist/store-polling-CmxUbV93.js.map +1 -0
- package/dist/store-postgres.d.ts +6 -4
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +701 -53
- package/dist/store-postgres.js.map +1 -1
- package/dist/store-presence-polling-C7-XZyW9.js +94 -0
- package/dist/store-presence-polling-C7-XZyW9.js.map +1 -0
- package/dist/store-redis-http.d.ts +2 -2
- package/dist/store-redis-http.d.ts.map +1 -1
- package/dist/store-redis-http.js +184 -38
- package/dist/store-redis-http.js.map +1 -1
- package/dist/{store-redis-core-z-ykbyMg.js → store-redis-notify-BUCyXOn0.js} +491 -27
- package/dist/store-redis-notify-BUCyXOn0.js.map +1 -0
- package/dist/store-redis.d.ts +5 -13
- package/dist/store-redis.d.ts.map +1 -1
- package/dist/store-redis.js +27 -271
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +1 -1
- package/dist/{wire--yji6mO3.js → wire-BO5wWCb1.js} +18 -16
- package/dist/wire-BO5wWCb1.js.map +1 -0
- package/docs/actors/04-routes.mdx +40 -8
- package/docs/guides/03-react.mdx +52 -10
- package/docs/guides/05-production.mdx +54 -5
- package/docs/guides/09-presence.mdx +12 -7
- package/docs/guides/10-transports.mdx +85 -22
- package/docs/reference/01-api.mdx +90 -28
- package/docs/reference/02-errors.mdx +45 -11
- package/examples/playground/package.json +1 -1
- package/package.json +1 -1
- package/src/actor-client.ts +114 -14
- package/src/actor-react.ts +18 -3
- package/src/actor.ts +16 -1
- package/src/client-errors.ts +51 -0
- package/src/client.ts +577 -175
- package/src/errors.ts +4 -9
- package/src/index.ts +1 -1
- package/src/internal.ts +1 -1
- package/src/postgres-notification-scope.ts +134 -0
- package/src/postgres-notifications.ts +244 -0
- package/src/postgres-pool.ts +65 -0
- package/src/postgres-resources.ts +185 -0
- package/src/presence-recovery.ts +120 -0
- package/src/react.ts +3 -0
- package/src/redis-http-subscriptions.ts +199 -0
- package/src/server-fetch.ts +51 -23
- package/src/server.ts +146 -19
- package/src/session-socket.ts +241 -88
- package/src/sse.ts +17 -0
- package/src/store-polling.ts +32 -12
- package/src/store-postgres.ts +292 -75
- package/src/store-presence-polling.ts +125 -0
- package/src/store-redis-core.ts +37 -30
- package/src/store-redis-http.ts +51 -45
- package/src/store-redis-notify.ts +464 -0
- package/src/store-redis.ts +9 -364
- package/src/store.ts +3 -4
- package/src/stream-activity.ts +32 -0
- package/src/wire.ts +39 -15
- package/dist/actor-shared-BACubf4x.d.ts +0 -136
- package/dist/actor-shared-BACubf4x.d.ts.map +0 -1
- package/dist/actor.d.ts.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/server-CBET-jSz.js.map +0 -1
- package/dist/server-CKY3_lbw.d.ts.map +0 -1
- package/dist/store-polling-6DW7F1DT.js.map +0 -1
- package/dist/store-redis-core-z-ykbyMg.js.map +0 -1
- package/dist/wire--yji6mO3.js.map +0 -1
package/src/store-postgres.ts
CHANGED
|
@@ -10,9 +10,8 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Semantics mirror the sqlite backend: `index` is stored as `idx`,
|
|
12
12
|
* timestamps are epoch milliseconds written from the injected clock —
|
|
13
|
-
* never SQL `now()
|
|
14
|
-
*
|
|
15
|
-
* on the transport.) Appends serialize per session via
|
|
13
|
+
* never SQL `now()`. Streams poll by default; an optional direct
|
|
14
|
+
* connection enables LISTEN/NOTIFY for events and presence. Appends serialize per session via
|
|
16
15
|
* `pg_advisory_xact_lock`, so concurrent writers can't race the index
|
|
17
16
|
* assignment.
|
|
18
17
|
*/
|
|
@@ -20,8 +19,25 @@
|
|
|
20
19
|
import { A2Error } from './errors.ts'
|
|
21
20
|
import { idempotentReplay } from './idempotent-replay.ts'
|
|
22
21
|
import { decodeReturnedEventIds } from './store-codec.ts'
|
|
23
|
-
import { pollingStream } from './store-polling.ts'
|
|
22
|
+
import { pollingStream, defaultSleep, type Sleeper } from './store-polling.ts'
|
|
23
|
+
import { NOTIFY_TIMINGS, nullProtoRecord } from './internal.ts'
|
|
24
|
+
import type { PresencePatch } from './contract.ts'
|
|
25
|
+
import { pollPresence } from './store-presence-polling.ts'
|
|
26
|
+
import {
|
|
27
|
+
postgresNotifications,
|
|
28
|
+
postgresNotificationChannel,
|
|
29
|
+
POSTGRES_NOTIFICATIONS_SQL,
|
|
30
|
+
type PostgresListenerConnection,
|
|
31
|
+
} from './postgres-notifications.ts'
|
|
24
32
|
import { retryableLazy } from './retryable-lazy.ts'
|
|
33
|
+
import { postgresNotificationScope } from './postgres-notification-scope.ts'
|
|
34
|
+
import { postgresPoolClient } from './postgres-pool.ts'
|
|
35
|
+
import {
|
|
36
|
+
postgresConnectionKey,
|
|
37
|
+
sharePostgresClient,
|
|
38
|
+
sharePostgresPool,
|
|
39
|
+
sharePostgresListener,
|
|
40
|
+
} from './postgres-resources.ts'
|
|
25
41
|
import {
|
|
26
42
|
RANDOM_IDS,
|
|
27
43
|
SYSTEM_CLOCK,
|
|
@@ -57,8 +73,10 @@ export type PostgresClient = {
|
|
|
57
73
|
}
|
|
58
74
|
|
|
59
75
|
export type PostgresStoreOptions = {
|
|
60
|
-
/**
|
|
76
|
+
/** Shares a lazy `pg` Pool for matching connection configuration (optional peer `pg`). */
|
|
61
77
|
connectionString?: string | undefined
|
|
78
|
+
/** Direct connection to the same database, shared by live LISTEN subscriptions. */
|
|
79
|
+
listenConnectionString?: string | undefined
|
|
62
80
|
/** Bring your own client — a `pg` Pool, PGlite, a pooler wrapper. */
|
|
63
81
|
client?: PostgresClient
|
|
64
82
|
/** Injectable clock — every stored timestamp comes from here. */
|
|
@@ -68,8 +86,8 @@ export type PostgresStoreOptions = {
|
|
|
68
86
|
}
|
|
69
87
|
|
|
70
88
|
export type PostgresStore = A2Store & {
|
|
71
|
-
/**
|
|
72
|
-
*
|
|
89
|
+
/** Release this store’s subscriptions and resource references. Shared
|
|
90
|
+
* connections remain available to other stores. */
|
|
73
91
|
close(): Promise<void>
|
|
74
92
|
}
|
|
75
93
|
|
|
@@ -246,9 +264,16 @@ const wrap = async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
|
246
264
|
|
|
247
265
|
const jsonb = (value: unknown): string => JSON.stringify(value) ?? 'null'
|
|
248
266
|
|
|
249
|
-
const initializeSchema = async (
|
|
267
|
+
const initializeSchema = async ({
|
|
268
|
+
client,
|
|
269
|
+
listen,
|
|
270
|
+
}: {
|
|
271
|
+
client: PostgresClient
|
|
272
|
+
listen: boolean
|
|
273
|
+
}): Promise<void> => {
|
|
274
|
+
const schema = SCHEMA + (listen ? POSTGRES_NOTIFICATIONS_SQL : '')
|
|
250
275
|
if (!client.connect) {
|
|
251
|
-
await client.query(
|
|
276
|
+
await client.query(schema)
|
|
252
277
|
return
|
|
253
278
|
}
|
|
254
279
|
|
|
@@ -260,7 +285,7 @@ const initializeSchema = async (client: PostgresClient): Promise<void> => {
|
|
|
260
285
|
await conn.query(
|
|
261
286
|
"select pg_advisory_xact_lock(hashtext('a2:postgres:schema'))",
|
|
262
287
|
)
|
|
263
|
-
await conn.query(
|
|
288
|
+
await conn.query(schema)
|
|
264
289
|
await conn.query('commit')
|
|
265
290
|
} catch (err) {
|
|
266
291
|
await conn.query('rollback').catch(() => {})
|
|
@@ -270,9 +295,54 @@ const initializeSchema = async (client: PostgresClient): Promise<void> => {
|
|
|
270
295
|
}
|
|
271
296
|
}
|
|
272
297
|
|
|
298
|
+
const loadPg = async () =>
|
|
299
|
+
import('pg').catch(() => {
|
|
300
|
+
throw new A2Error(
|
|
301
|
+
'STORE_NOT_CONFIGURED',
|
|
302
|
+
"Postgres connections created by A2 require the optional 'pg' package",
|
|
303
|
+
)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
const createListener = ({
|
|
307
|
+
pg,
|
|
308
|
+
connectionString,
|
|
309
|
+
}: {
|
|
310
|
+
pg: Awaited<ReturnType<typeof loadPg>>
|
|
311
|
+
connectionString: string
|
|
312
|
+
}): ReturnType<typeof postgresNotifications> =>
|
|
313
|
+
postgresNotifications(async () => {
|
|
314
|
+
const listener = new pg.default.Client({
|
|
315
|
+
connectionString,
|
|
316
|
+
application_name: 'a2-listener',
|
|
317
|
+
connectionTimeoutMillis: 10_000,
|
|
318
|
+
})
|
|
319
|
+
const listenerConnection: PostgresListenerConnection = {
|
|
320
|
+
connect: () => listener.connect(),
|
|
321
|
+
query: (text) => listener.query(text),
|
|
322
|
+
end: () => listener.end(),
|
|
323
|
+
onNotification: (callback) => {
|
|
324
|
+
listener.on('notification', callback)
|
|
325
|
+
},
|
|
326
|
+
onError: (callback) => {
|
|
327
|
+
listener.on('error', callback)
|
|
328
|
+
},
|
|
329
|
+
onEnd: (callback) => {
|
|
330
|
+
listener.on('end', callback)
|
|
331
|
+
},
|
|
332
|
+
}
|
|
333
|
+
return listenerConnection
|
|
334
|
+
})
|
|
335
|
+
|
|
273
336
|
export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
274
337
|
const clock = options.clock ?? SYSTEM_CLOCK
|
|
275
338
|
const generateId = options.ids ?? RANDOM_IDS
|
|
339
|
+
// oxlint-disable-next-line a2/adapter-time-is-injected -- ephemeral notification source, never a stored event ID
|
|
340
|
+
const instanceId = crypto.randomUUID()
|
|
341
|
+
let closed = false
|
|
342
|
+
if (options.listenConnectionString === '')
|
|
343
|
+
throw new TypeError(
|
|
344
|
+
'listenConnectionString must be a direct Postgres connection string',
|
|
345
|
+
)
|
|
276
346
|
|
|
277
347
|
if (!options.client && !options.connectionString) {
|
|
278
348
|
throw new TypeError(
|
|
@@ -280,33 +350,74 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
280
350
|
)
|
|
281
351
|
}
|
|
282
352
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
353
|
+
const injected = options.client
|
|
354
|
+
? sharePostgresClient(options.client)
|
|
355
|
+
: undefined
|
|
286
356
|
const connection = retryableLazy(async () => {
|
|
287
|
-
let
|
|
357
|
+
let lease = injected
|
|
358
|
+
if (!lease) {
|
|
359
|
+
const pg = await loadPg()
|
|
360
|
+
const connectionString = options.connectionString!
|
|
361
|
+
const key = postgresConnectionKey({
|
|
362
|
+
connectionString,
|
|
363
|
+
defaults: pg.default.defaults,
|
|
364
|
+
})
|
|
365
|
+
lease = sharePostgresPool({
|
|
366
|
+
key,
|
|
367
|
+
create: () => {
|
|
368
|
+
const pool = new pg.default.Pool({ connectionString })
|
|
369
|
+
// pg removes failed idle clients; active queries report their own errors.
|
|
370
|
+
pool.on('error', () => {})
|
|
371
|
+
return postgresPoolClient(pool)
|
|
372
|
+
},
|
|
373
|
+
})
|
|
374
|
+
}
|
|
288
375
|
try {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
)
|
|
376
|
+
const listen = Boolean(options.listenConnectionString)
|
|
377
|
+
const resource = lease.value
|
|
378
|
+
if (injected) await initializeSchema({ client: resource.client, listen })
|
|
379
|
+
else
|
|
380
|
+
await resource.initialize({
|
|
381
|
+
schema: SCHEMA + (listen ? POSTGRES_NOTIFICATIONS_SQL : ''),
|
|
382
|
+
run: () => initializeSchema({ client: resource.client, listen }),
|
|
296
383
|
})
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
}
|
|
302
|
-
await initializeSchema(c)
|
|
303
|
-
return c
|
|
304
|
-
} catch (err) {
|
|
305
|
-
if (owned?.end) await owned.end().catch(() => {})
|
|
306
|
-
throw err
|
|
384
|
+
return lease
|
|
385
|
+
} catch (error) {
|
|
386
|
+
if (!injected) await lease.release().catch(() => {})
|
|
387
|
+
throw error
|
|
307
388
|
}
|
|
308
389
|
})
|
|
309
|
-
const client =
|
|
390
|
+
const client = async (): Promise<PostgresClient> => {
|
|
391
|
+
if (closed) throw new A2Error('STORE_UNAVAILABLE', 'the store was closed')
|
|
392
|
+
const lease = await connection.get()
|
|
393
|
+
if (closed) throw new A2Error('STORE_UNAVAILABLE', 'the store was closed')
|
|
394
|
+
return lease.value.client
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const notifications = options.listenConnectionString
|
|
398
|
+
? postgresNotificationScope(async () => {
|
|
399
|
+
await client()
|
|
400
|
+
const pg = await loadPg()
|
|
401
|
+
const connectionString = options.listenConnectionString!
|
|
402
|
+
const key = postgresConnectionKey({
|
|
403
|
+
connectionString,
|
|
404
|
+
defaults: pg.default.defaults,
|
|
405
|
+
})
|
|
406
|
+
return sharePostgresListener({
|
|
407
|
+
key,
|
|
408
|
+
create: () => createListener({ pg, connectionString }),
|
|
409
|
+
})
|
|
410
|
+
})
|
|
411
|
+
: undefined
|
|
412
|
+
const presenceSubscriptions = new Map<
|
|
413
|
+
string,
|
|
414
|
+
{
|
|
415
|
+
listeners: Set<(patch: PresencePatch) => void>
|
|
416
|
+
ready: Promise<void>
|
|
417
|
+
poll: ReturnType<typeof pollPresence>
|
|
418
|
+
stop(): void
|
|
419
|
+
}
|
|
420
|
+
>()
|
|
310
421
|
|
|
311
422
|
const readStateBatch = async (
|
|
312
423
|
requests: readonly StoreStateReadRequest[],
|
|
@@ -405,9 +516,9 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
405
516
|
return reads
|
|
406
517
|
}
|
|
407
518
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
519
|
+
const begin = notifications
|
|
520
|
+
? `begin; set local a2.presence_source = '${instanceId}'`
|
|
521
|
+
: 'begin'
|
|
411
522
|
const withTx = async <T>(
|
|
412
523
|
fn: (query: PostgresConnection['query']) => Promise<T>,
|
|
413
524
|
): Promise<T> => {
|
|
@@ -415,7 +526,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
415
526
|
if (c.connect) {
|
|
416
527
|
const conn = await c.connect()
|
|
417
528
|
try {
|
|
418
|
-
await conn.query(
|
|
529
|
+
await conn.query(begin)
|
|
419
530
|
const result = await fn((text, values) => conn.query(text, values))
|
|
420
531
|
await conn.query('commit')
|
|
421
532
|
return result
|
|
@@ -426,22 +537,22 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
426
537
|
conn.release()
|
|
427
538
|
}
|
|
428
539
|
}
|
|
429
|
-
const
|
|
430
|
-
|
|
540
|
+
const resource = (await connection.get()).value
|
|
541
|
+
return resource.exclusive(async () => {
|
|
542
|
+
const raw = resource.raw
|
|
543
|
+
await raw.query(begin)
|
|
431
544
|
try {
|
|
432
|
-
const result = await fn((text, values) =>
|
|
433
|
-
await
|
|
545
|
+
const result = await fn((text, values) => raw.query(text, values))
|
|
546
|
+
await raw.query('commit')
|
|
434
547
|
return result
|
|
435
548
|
} catch (err) {
|
|
436
|
-
await
|
|
549
|
+
await raw.query('rollback').catch(() => {})
|
|
437
550
|
throw err
|
|
438
551
|
}
|
|
439
552
|
})
|
|
440
|
-
mutex = run.catch(() => {})
|
|
441
|
-
return run
|
|
442
553
|
}
|
|
443
554
|
|
|
444
|
-
|
|
555
|
+
const store: PostgresStore = {
|
|
445
556
|
async append(sessionId, events) {
|
|
446
557
|
if (events.length === 0) {
|
|
447
558
|
return wrap(async () => {
|
|
@@ -1233,24 +1344,27 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
1233
1344
|
const entries = Object.entries(values)
|
|
1234
1345
|
if (entries.length === 0) return
|
|
1235
1346
|
await wrap(async () => {
|
|
1236
|
-
const c = await client()
|
|
1237
1347
|
const atMs = meta.at.getTime()
|
|
1238
1348
|
// Expiry anchors on the storage clock — the sender's `at`
|
|
1239
1349
|
// orders writes but never anchors their lifetime.
|
|
1240
1350
|
const expiresAtMs = clock.now().getTime() + meta.ttlMs
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1351
|
+
const applied: Record<string, unknown> = nullProtoRecord()
|
|
1352
|
+
const write = async (
|
|
1353
|
+
query: PostgresConnection['query'],
|
|
1354
|
+
): Promise<void> => {
|
|
1355
|
+
for (const [field, value] of entries) {
|
|
1356
|
+
// Field-wise LWW: a stored row with a strictly newer `at`
|
|
1357
|
+
// survives; ties go to the incoming write (memory parity).
|
|
1358
|
+
// oxlint-disable-next-line no-await-in-loop -- independent per-field registers
|
|
1359
|
+
const result = await (value === null
|
|
1360
|
+
? query(
|
|
1361
|
+
`delete from a2_presence
|
|
1248
1362
|
where ns = $1 and participant = $2 and field = $3
|
|
1249
|
-
and at <= $4`,
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1363
|
+
and at <= $4${notifications ? ' returning field' : ''}`,
|
|
1364
|
+
[ns, participant, field, atMs],
|
|
1365
|
+
)
|
|
1366
|
+
: query(
|
|
1367
|
+
`insert into a2_presence
|
|
1254
1368
|
(ns, participant, field, value, seen, at, expires_at)
|
|
1255
1369
|
values ($1, $2, $3, $4::jsonb, $5, $6, $7)
|
|
1256
1370
|
on conflict (ns, participant, field) do update set
|
|
@@ -1258,17 +1372,36 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
1258
1372
|
seen = excluded.seen,
|
|
1259
1373
|
at = excluded.at,
|
|
1260
1374
|
expires_at = excluded.expires_at
|
|
1261
|
-
where a2_presence.at <= excluded.at`,
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1375
|
+
where a2_presence.at <= excluded.at${notifications ? ' returning field' : ''}`,
|
|
1376
|
+
[
|
|
1377
|
+
ns,
|
|
1378
|
+
participant,
|
|
1379
|
+
field,
|
|
1380
|
+
jsonb(value),
|
|
1381
|
+
meta.seen,
|
|
1382
|
+
atMs,
|
|
1383
|
+
expiresAtMs,
|
|
1384
|
+
],
|
|
1385
|
+
))
|
|
1386
|
+
if (result.rows.length) applied[field] = structuredClone(value)
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
if (notifications) await withTx(write)
|
|
1390
|
+
else {
|
|
1391
|
+
const c = await client()
|
|
1392
|
+
await write((text, parameters) => c.query(text, parameters))
|
|
1393
|
+
}
|
|
1394
|
+
if (Object.keys(applied).length) {
|
|
1395
|
+
const patch = {
|
|
1396
|
+
participant,
|
|
1397
|
+
values: applied,
|
|
1398
|
+
seen: meta.seen,
|
|
1399
|
+
at: new Date(atMs),
|
|
1400
|
+
}
|
|
1401
|
+
const subscription = presenceSubscriptions.get(ns)
|
|
1402
|
+
subscription?.poll.update(patch)
|
|
1403
|
+
for (const listener of subscription?.listeners ?? [])
|
|
1404
|
+
listener(patch)
|
|
1272
1405
|
}
|
|
1273
1406
|
})
|
|
1274
1407
|
},
|
|
@@ -1304,14 +1437,23 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
1304
1437
|
},
|
|
1305
1438
|
|
|
1306
1439
|
stream(sessionId, opts) {
|
|
1307
|
-
const readAfter = (afterIndex: number): Promise<
|
|
1440
|
+
const readAfter = (afterIndex: number): Promise<Event[]> =>
|
|
1308
1441
|
wrap(async () => {
|
|
1309
1442
|
const c = await client()
|
|
1310
1443
|
const { rows } = await c.query(
|
|
1311
|
-
|
|
1444
|
+
`select session_id, idx, event_type, payload, event_id, created_at
|
|
1445
|
+
from a2_events
|
|
1446
|
+
where session_id = $1 and idx > $2
|
|
1447
|
+
order by idx`,
|
|
1312
1448
|
[sessionId, afterIndex],
|
|
1313
1449
|
)
|
|
1314
|
-
return rows.map(
|
|
1450
|
+
return rows.map(toEvent)
|
|
1451
|
+
})
|
|
1452
|
+
if (notifications)
|
|
1453
|
+
return notifications.stream({
|
|
1454
|
+
channel: postgresNotificationChannel({ kind: 'events', sessionId }),
|
|
1455
|
+
read: readAfter,
|
|
1456
|
+
startAfter: opts?.startAfter ?? 0,
|
|
1315
1457
|
})
|
|
1316
1458
|
return pollingStream(
|
|
1317
1459
|
readAfter,
|
|
@@ -1320,10 +1462,85 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
|
|
|
1320
1462
|
},
|
|
1321
1463
|
|
|
1322
1464
|
async close() {
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
const
|
|
1326
|
-
|
|
1465
|
+
if (closed) return
|
|
1466
|
+
closed = true
|
|
1467
|
+
for (const subscription of presenceSubscriptions.values())
|
|
1468
|
+
subscription.stop()
|
|
1469
|
+
presenceSubscriptions.clear()
|
|
1470
|
+
await notifications?.close()
|
|
1471
|
+
const lease = await connection.peek()?.catch(() => undefined)
|
|
1472
|
+
await (injected ?? lease)?.release()
|
|
1327
1473
|
},
|
|
1328
1474
|
}
|
|
1475
|
+
if (notifications) {
|
|
1476
|
+
const presence = store.presence!
|
|
1477
|
+
const read = presence.read
|
|
1478
|
+
presence.read = async (ns) => {
|
|
1479
|
+
const subscription = presenceSubscriptions.get(ns)
|
|
1480
|
+
await subscription?.ready
|
|
1481
|
+
await subscription?.poll.ready
|
|
1482
|
+
return read(ns)
|
|
1483
|
+
}
|
|
1484
|
+
presence.subscribe = (ns, onPatch) => {
|
|
1485
|
+
if (closed) throw new A2Error('STORE_UNAVAILABLE', 'the store was closed')
|
|
1486
|
+
let subscription = presenceSubscriptions.get(ns)
|
|
1487
|
+
if (!subscription) {
|
|
1488
|
+
const listeners = new Set<(patch: PresencePatch) => void>()
|
|
1489
|
+
let dirty = false
|
|
1490
|
+
let ended = false
|
|
1491
|
+
let waiting: Sleeper | undefined
|
|
1492
|
+
const lease = notifications.watch({
|
|
1493
|
+
channel: postgresNotificationChannel({
|
|
1494
|
+
kind: 'presence',
|
|
1495
|
+
sessionId: ns,
|
|
1496
|
+
}),
|
|
1497
|
+
notify: (source) => {
|
|
1498
|
+
if (source === instanceId) return
|
|
1499
|
+
dirty = true
|
|
1500
|
+
waiting?.cancel()
|
|
1501
|
+
},
|
|
1502
|
+
})
|
|
1503
|
+
const poll = pollPresence({
|
|
1504
|
+
read: async () => {
|
|
1505
|
+
await lease.ready
|
|
1506
|
+
if (ended || closed) return []
|
|
1507
|
+
dirty = false
|
|
1508
|
+
return read(ns)
|
|
1509
|
+
},
|
|
1510
|
+
deliver: (patch) => {
|
|
1511
|
+
for (const listener of listeners) listener(patch)
|
|
1512
|
+
},
|
|
1513
|
+
sleep: () => {
|
|
1514
|
+
waiting = defaultSleep(dirty ? 0 : NOTIFY_TIMINGS.safetyReadMs)
|
|
1515
|
+
return waiting
|
|
1516
|
+
},
|
|
1517
|
+
})
|
|
1518
|
+
void lease.ready.catch(() => poll.stop())
|
|
1519
|
+
subscription = {
|
|
1520
|
+
listeners,
|
|
1521
|
+
ready: lease.ready,
|
|
1522
|
+
poll,
|
|
1523
|
+
stop() {
|
|
1524
|
+
ended = true
|
|
1525
|
+
poll.stop()
|
|
1526
|
+
lease.stop()
|
|
1527
|
+
waiting?.cancel()
|
|
1528
|
+
},
|
|
1529
|
+
}
|
|
1530
|
+
presenceSubscriptions.set(ns, subscription)
|
|
1531
|
+
}
|
|
1532
|
+
subscription.listeners.add(onPatch)
|
|
1533
|
+
let stopped = false
|
|
1534
|
+
return () => {
|
|
1535
|
+
if (stopped) return
|
|
1536
|
+
stopped = true
|
|
1537
|
+
subscription.listeners.delete(onPatch)
|
|
1538
|
+
if (subscription.listeners.size === 0) {
|
|
1539
|
+
presenceSubscriptions.delete(ns)
|
|
1540
|
+
subscription.stop()
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
return store
|
|
1329
1546
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { PresencePatch } from './contract.ts'
|
|
2
|
+
import { POLL_TIMINGS } from './internal.ts'
|
|
3
|
+
import { defaultSleep, type Sleeper } from './store-polling.ts'
|
|
4
|
+
import type { PresenceRow } from './store.ts'
|
|
5
|
+
|
|
6
|
+
const key = ({
|
|
7
|
+
participant,
|
|
8
|
+
field,
|
|
9
|
+
}: Pick<PresenceRow, 'participant' | 'field'>): string =>
|
|
10
|
+
JSON.stringify([participant, field])
|
|
11
|
+
|
|
12
|
+
export function pollPresence({
|
|
13
|
+
read,
|
|
14
|
+
deliver,
|
|
15
|
+
sleep = defaultSleep,
|
|
16
|
+
}: {
|
|
17
|
+
read: () => Promise<PresenceRow[]>
|
|
18
|
+
deliver: (patch: PresencePatch) => void
|
|
19
|
+
sleep?: (ms: number) => Sleeper
|
|
20
|
+
}): {
|
|
21
|
+
ready: Promise<void>
|
|
22
|
+
update(patch: PresencePatch): void
|
|
23
|
+
wake(): void
|
|
24
|
+
stop(): void
|
|
25
|
+
} {
|
|
26
|
+
let stopped = false
|
|
27
|
+
let pending: Sleeper | undefined
|
|
28
|
+
let interval = Math.min(
|
|
29
|
+
POLL_TIMINGS.activeFloorMs,
|
|
30
|
+
POLL_TIMINGS.idleCeilingMs,
|
|
31
|
+
)
|
|
32
|
+
let version = 0
|
|
33
|
+
const seen = new Map<string, Omit<PresenceRow, 'expiresAt'>>()
|
|
34
|
+
const activate = (): void => {
|
|
35
|
+
version += 1
|
|
36
|
+
interval = Math.min(POLL_TIMINGS.activeFloorMs, POLL_TIMINGS.idleCeilingMs)
|
|
37
|
+
pending?.cancel()
|
|
38
|
+
}
|
|
39
|
+
const ready = read()
|
|
40
|
+
.then((rows) => {
|
|
41
|
+
if (stopped) return
|
|
42
|
+
for (const row of rows) seen.set(key(row), row)
|
|
43
|
+
return undefined
|
|
44
|
+
})
|
|
45
|
+
// Seeding is best effort; foreground snapshots still report read errors.
|
|
46
|
+
.catch(() => undefined)
|
|
47
|
+
|
|
48
|
+
const run = async (): Promise<void> => {
|
|
49
|
+
await ready
|
|
50
|
+
for (;;) {
|
|
51
|
+
if (stopped) return
|
|
52
|
+
const before = version
|
|
53
|
+
pending = sleep(interval)
|
|
54
|
+
// oxlint-disable-next-line no-await-in-loop -- adaptive presence polling
|
|
55
|
+
await pending.promise
|
|
56
|
+
if (stopped) return
|
|
57
|
+
try {
|
|
58
|
+
const reading = version
|
|
59
|
+
// oxlint-disable-next-line no-await-in-loop -- read each new presence snapshot
|
|
60
|
+
const rows = await read()
|
|
61
|
+
if (stopped) return
|
|
62
|
+
if (version !== reading) continue
|
|
63
|
+
const live = new Set<string>()
|
|
64
|
+
let changed = false
|
|
65
|
+
for (const row of rows) {
|
|
66
|
+
const id = key(row)
|
|
67
|
+
live.add(id)
|
|
68
|
+
const prior = seen.get(id)
|
|
69
|
+
seen.set(id, row)
|
|
70
|
+
if (
|
|
71
|
+
prior?.seen === row.seen &&
|
|
72
|
+
prior.at.getTime() === row.at.getTime() &&
|
|
73
|
+
JSON.stringify(prior.value) === JSON.stringify(row.value)
|
|
74
|
+
)
|
|
75
|
+
continue
|
|
76
|
+
changed = true
|
|
77
|
+
deliver({
|
|
78
|
+
participant: row.participant,
|
|
79
|
+
values: { [row.field]: row.value },
|
|
80
|
+
seen: row.seen,
|
|
81
|
+
at: row.at,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
for (const [id, row] of seen) {
|
|
85
|
+
if (live.has(id)) continue
|
|
86
|
+
seen.delete(id)
|
|
87
|
+
changed = true
|
|
88
|
+
deliver({
|
|
89
|
+
participant: row.participant,
|
|
90
|
+
values: { [row.field]: null },
|
|
91
|
+
seen: row.seen,
|
|
92
|
+
at: new Date(),
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
if (changed) activate()
|
|
96
|
+
else if (version === before)
|
|
97
|
+
interval = Math.min(interval * 2, POLL_TIMINGS.idleCeilingMs)
|
|
98
|
+
} catch {
|
|
99
|
+
interval = POLL_TIMINGS.idleCeilingMs
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
void run()
|
|
104
|
+
return {
|
|
105
|
+
ready,
|
|
106
|
+
wake: activate,
|
|
107
|
+
update(patch) {
|
|
108
|
+
for (const [field, value] of Object.entries(patch.values)) {
|
|
109
|
+
seen.set(key({ participant: patch.participant, field }), {
|
|
110
|
+
participant: patch.participant,
|
|
111
|
+
field,
|
|
112
|
+
value,
|
|
113
|
+
seen: patch.seen,
|
|
114
|
+
at: patch.at,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
activate()
|
|
118
|
+
},
|
|
119
|
+
stop() {
|
|
120
|
+
stopped = true
|
|
121
|
+
pending?.cancel()
|
|
122
|
+
seen.clear()
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
}
|