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.
Files changed (123) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/dist/actor-D_54lz_1.d.ts +310 -0
  3. package/dist/actor-D_54lz_1.d.ts.map +1 -0
  4. package/dist/actor-client.d.ts +13 -4
  5. package/dist/actor-client.d.ts.map +1 -1
  6. package/dist/actor-client.js +63 -7
  7. package/dist/actor-client.js.map +1 -1
  8. package/dist/actor-react.d.ts +5 -4
  9. package/dist/actor-react.d.ts.map +1 -1
  10. package/dist/actor-react.js +16 -2
  11. package/dist/actor-react.js.map +1 -1
  12. package/dist/actor.d.ts +2 -176
  13. package/dist/actor.js +13 -2
  14. package/dist/actor.js.map +1 -1
  15. package/dist/ai-server.d.ts +1 -1
  16. package/dist/ai-server.js +2 -2
  17. package/dist/ai.d.ts +1 -1
  18. package/dist/ai.js +1 -1
  19. package/dist/client-Bf6uSEAk.js +1342 -0
  20. package/dist/client-Bf6uSEAk.js.map +1 -0
  21. package/dist/client-P_NNNRM-.d.ts +243 -0
  22. package/dist/client-P_NNNRM-.d.ts.map +1 -0
  23. package/dist/client.d.ts +2 -202
  24. package/dist/client.js +2 -1026
  25. package/dist/errors-DCk6ch5n.js.map +1 -1
  26. package/dist/errors-DvhSXnxk.d.ts +28 -0
  27. package/dist/errors-DvhSXnxk.d.ts.map +1 -0
  28. package/dist/index.d.ts +3 -35
  29. package/dist/{internal-DRXJ56EI.js → internal-Dq2qYxou.js} +2 -2
  30. package/dist/{internal-DRXJ56EI.js.map → internal-Dq2qYxou.js.map} +1 -1
  31. package/dist/platform-B4TnJtWu.js +34 -0
  32. package/dist/platform-B4TnJtWu.js.map +1 -0
  33. package/dist/react.d.ts +3 -1
  34. package/dist/react.d.ts.map +1 -1
  35. package/dist/react.js +2 -1
  36. package/dist/react.js.map +1 -1
  37. package/dist/scheduler-qstash.d.ts +2 -2
  38. package/dist/scheduler-qstash.js +3 -2
  39. package/dist/scheduler-qstash.js.map +1 -1
  40. package/dist/scheduler-vercel.d.ts +2 -2
  41. package/dist/scheduler-vercel.js +2 -2
  42. package/dist/{server-CBET-jSz.js → server-Dkz2a84E.js} +295 -133
  43. package/dist/server-Dkz2a84E.js.map +1 -0
  44. package/dist/{server-CKY3_lbw.d.ts → server-DwPrMqHB.d.ts} +4 -2
  45. package/dist/server-DwPrMqHB.d.ts.map +1 -0
  46. package/dist/server.d.ts +2 -2
  47. package/dist/server.js +1 -1
  48. package/dist/{store-DGHeBtIQ.d.ts → store-DtDOWLSn.d.ts} +4 -5
  49. package/dist/{store-DGHeBtIQ.d.ts.map → store-DtDOWLSn.d.ts.map} +1 -1
  50. package/dist/store-N8PXxDAS.js.map +1 -1
  51. package/dist/store-memory.d.ts +1 -1
  52. package/dist/store-memory.js +1 -1
  53. package/dist/{store-polling-6DW7F1DT.js → store-polling-CmxUbV93.js} +56 -7
  54. package/dist/store-polling-CmxUbV93.js.map +1 -0
  55. package/dist/store-postgres.d.ts +6 -4
  56. package/dist/store-postgres.d.ts.map +1 -1
  57. package/dist/store-postgres.js +701 -53
  58. package/dist/store-postgres.js.map +1 -1
  59. package/dist/store-presence-polling-C7-XZyW9.js +94 -0
  60. package/dist/store-presence-polling-C7-XZyW9.js.map +1 -0
  61. package/dist/store-redis-http.d.ts +2 -2
  62. package/dist/store-redis-http.d.ts.map +1 -1
  63. package/dist/store-redis-http.js +184 -38
  64. package/dist/store-redis-http.js.map +1 -1
  65. package/dist/{store-redis-core-z-ykbyMg.js → store-redis-notify-BUCyXOn0.js} +491 -27
  66. package/dist/store-redis-notify-BUCyXOn0.js.map +1 -0
  67. package/dist/store-redis.d.ts +5 -13
  68. package/dist/store-redis.d.ts.map +1 -1
  69. package/dist/store-redis.js +27 -271
  70. package/dist/store-redis.js.map +1 -1
  71. package/dist/store-sqlite.d.ts +1 -1
  72. package/dist/store-sqlite.js +1 -1
  73. package/dist/{wire--yji6mO3.js → wire-BO5wWCb1.js} +18 -16
  74. package/dist/wire-BO5wWCb1.js.map +1 -0
  75. package/docs/actors/04-routes.mdx +40 -8
  76. package/docs/guides/03-react.mdx +52 -10
  77. package/docs/guides/05-production.mdx +54 -5
  78. package/docs/guides/09-presence.mdx +12 -7
  79. package/docs/guides/10-transports.mdx +85 -22
  80. package/docs/reference/01-api.mdx +90 -28
  81. package/docs/reference/02-errors.mdx +45 -11
  82. package/examples/playground/package.json +1 -1
  83. package/package.json +1 -1
  84. package/src/actor-client.ts +114 -14
  85. package/src/actor-react.ts +18 -3
  86. package/src/actor.ts +16 -1
  87. package/src/client-errors.ts +51 -0
  88. package/src/client.ts +577 -175
  89. package/src/errors.ts +4 -9
  90. package/src/index.ts +1 -1
  91. package/src/internal.ts +1 -1
  92. package/src/postgres-notification-scope.ts +134 -0
  93. package/src/postgres-notifications.ts +244 -0
  94. package/src/postgres-pool.ts +65 -0
  95. package/src/postgres-resources.ts +185 -0
  96. package/src/presence-recovery.ts +120 -0
  97. package/src/react.ts +3 -0
  98. package/src/redis-http-subscriptions.ts +199 -0
  99. package/src/server-fetch.ts +51 -23
  100. package/src/server.ts +146 -19
  101. package/src/session-socket.ts +241 -88
  102. package/src/sse.ts +17 -0
  103. package/src/store-polling.ts +32 -12
  104. package/src/store-postgres.ts +292 -75
  105. package/src/store-presence-polling.ts +125 -0
  106. package/src/store-redis-core.ts +37 -30
  107. package/src/store-redis-http.ts +51 -45
  108. package/src/store-redis-notify.ts +464 -0
  109. package/src/store-redis.ts +9 -364
  110. package/src/store.ts +3 -4
  111. package/src/stream-activity.ts +32 -0
  112. package/src/wire.ts +39 -15
  113. package/dist/actor-shared-BACubf4x.d.ts +0 -136
  114. package/dist/actor-shared-BACubf4x.d.ts.map +0 -1
  115. package/dist/actor.d.ts.map +0 -1
  116. package/dist/client.d.ts.map +0 -1
  117. package/dist/client.js.map +0 -1
  118. package/dist/index.d.ts.map +0 -1
  119. package/dist/server-CBET-jSz.js.map +0 -1
  120. package/dist/server-CKY3_lbw.d.ts.map +0 -1
  121. package/dist/store-polling-6DW7F1DT.js.map +0 -1
  122. package/dist/store-redis-core-z-ykbyMg.js.map +0 -1
  123. package/dist/wire--yji6mO3.js.map +0 -1
@@ -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()` — 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
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
- /** Creates a `pg` Pool lazily (requires the optional peer dep `pg`). */
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
- /** End the underlying pool/client (when this backend created it, or
72
- * the injected client has `end`). */
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 (client: PostgresClient): Promise<void> => {
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(SCHEMA)
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(SCHEMA)
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
- // Lazy init: the first operation creates the client (dynamic import
284
- // of the optional peer dep `pg`) and the schema. Constructing the
285
- // backend does no I/O — same contract as every other adapter.
353
+ const injected = options.client
354
+ ? sharePostgresClient(options.client)
355
+ : undefined
286
356
  const connection = retryableLazy(async () => {
287
- let owned: PostgresClient | null = null
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
- let c = options.client
290
- if (!c) {
291
- const pg = await import('pg').catch(() => {
292
- throw new A2Error(
293
- 'STORE_NOT_CONFIGURED',
294
- "experimental-a2/store-postgres with a connectionString needs the 'pg' package (optional peer dependency) — install it, or inject a client",
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
- c = new pg.default.Pool({
298
- connectionString: options.connectionString,
299
- }) as unknown as PostgresClient
300
- owned = c
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 = connection.get
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
- // Transactions run on one connection. Pools check one out; a
409
- // single-connection client (PGlite) serializes on this mutex instead.
410
- let mutex: Promise<unknown> = Promise.resolve()
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('begin')
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 run = mutex.then(async () => {
430
- await c.query('begin')
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) => c.query(text, values))
433
- await c.query('commit')
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 c.query('rollback').catch(() => {})
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
- return {
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
- for (const [field, value] of entries) {
1242
- // Field-wise LWW: a stored row with a strictly newer `at`
1243
- // survives; ties go to the incoming write (memory parity).
1244
- // oxlint-disable-next-line no-await-in-loop -- independent per-field registers
1245
- await (value === null
1246
- ? c.query(
1247
- `delete from a2_presence
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
- [ns, participant, field, atMs],
1251
- )
1252
- : c.query(
1253
- `insert into a2_presence
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
- ns,
1264
- participant,
1265
- field,
1266
- jsonb(value),
1267
- meta.seen,
1268
- atMs,
1269
- expiresAtMs,
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<StoredEvent[]> =>
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
- 'select * from a2_events where session_id = $1 and idx > $2 order by idx',
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(toStored)
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
- const current = connection.peek()
1324
- if (!current) return
1325
- const c = await current
1326
- await c.end?.()
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
+ }