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
@@ -44,23 +44,15 @@ export type RedisStoreCoreOptions = {
44
44
  clock: Clock
45
45
  ids: IdSource
46
46
  keyPrefix: string
47
- /**
48
- * Disposable wake-up fired after a write appends new events to a
49
- * watched session — `count` is the session's new event count. The
50
- * watched check runs inside the write script, so unwatched sessions
51
- * cost no wake-up command at all. Losing one is harmless: live
52
- * feeds re-read on a safety cadence, so notifications only affect
53
- * latency, never delivery.
54
- */
55
- notify?: (sessionId: string, count: number) => void
47
+ presenceSource: string
56
48
  }
57
49
 
58
50
  export type RedisStoreCore = Omit<A2Store, 'stream' | 'presence'> & {
59
51
  /**
60
52
  * The storage half of the presence capability, shared by both
61
- * transports. `set` returns the applied patch — null when every
62
- * field lost the LWW race — so a push-capable transport broadcasts
63
- * exactly what `read` now reflects; delivery is its business.
53
+ * transports. `set` returns the applied patch for local loopback,
54
+ * or null when every field lost the LWW race. The script publishes
55
+ * the applied fields for remote subscribers.
64
56
  */
65
57
  presence: {
66
58
  set(
@@ -77,7 +69,7 @@ export type RedisStoreCore = Omit<A2Store, 'stream' | 'presence'> & {
77
69
  afterIndex: number,
78
70
  throughIndex?: number,
79
71
  ): Promise<StoredEvent[]>
80
- /** XRANGE only — the light catch-up read live feeds use. */
72
+ /** One bounded XRANGE page of public events for live feeds. */
81
73
  readEvents(sessionId: string, afterIndex: number): Promise<Event[]>
82
74
  /**
83
75
  * Declare the session watched for `ttlMs` — writes publish wake-ups
@@ -96,8 +88,8 @@ const READ_PAGE_SIZE = 256
96
88
  * KEYS: store stream, counter, global id hash, metadata, pending, ready,
97
89
  * watched.
98
90
  * ARGV: sessionId, nowMs, n, then per event:
99
- * id, type, payloadJson, causeJson, lane, settled.
100
- * Returns ['empty', hasPending] | ['ok', firstIndex, hasPending, watched] |
91
+ * id, type, payloadJson, causeJson, lane, settled; finally notify channel.
92
+ * Returns ['empty', hasPending] | ['ok', firstIndex, hasPending] |
101
93
  * ['dup', hasPending, ...indexes] | ['partial', count] |
102
94
  * ['foreign', eventId] | ['no_parent', idx] | ['superseded', idx].
103
95
  */
@@ -178,8 +170,10 @@ for i = 1, n do
178
170
  end
179
171
  redis.call('SET', KEYS[2], tostring(base + n))
180
172
  has_pending = redis.call('ZCARD', KEYS[5]) > 0 and '1' or '0'
181
- return { 'ok', tostring(base + 1), has_pending,
182
- tostring(redis.call('EXISTS', KEYS[7])) }
173
+ if redis.call('EXISTS', KEYS[7]) == 1 then
174
+ redis.pcall('PUBLISH', ARGV[4 + n * 6], tostring(base + n))
175
+ end
176
+ return { 'ok', tostring(base + 1), has_pending }
183
177
  `
184
178
 
185
179
  /**
@@ -350,7 +344,7 @@ return result
350
344
  * KEYS: store, counter, ids, meta, pending, ready, watched,
351
345
  * event snapshot pins, snapshot pin counts.
352
346
  * ARGV: sessionId, nowMs, parent index, attempt, n, then per child:
353
- * id, type, payloadJson, lane, settled.
347
+ * id, type, payloadJson, lane, settled; finally notify channel.
354
348
  */
355
349
  const COMPLETE_ATTEMPT_LUA = `
356
350
  local function release_snapshot_pins()
@@ -485,8 +479,10 @@ for i = 1, n do
485
479
  end
486
480
  end
487
481
  redis.call('SET', KEYS[2], tostring(total + n))
488
- return { 'ok', tostring(total + 1),
489
- tostring(redis.call('EXISTS', KEYS[7])) }
482
+ if n > 0 and redis.call('EXISTS', KEYS[7]) == 1 then
483
+ redis.pcall('PUBLISH', ARGV[6 + n * 5], tostring(total + n))
484
+ end
485
+ return { 'ok', tostring(total + 1) }
490
486
  `
491
487
 
492
488
  /**
@@ -549,7 +545,9 @@ return 1
549
545
 
550
546
  /**
551
547
  * KEYS: presence hash. ARGV: atMs, ttlMs, n, then per entry:
552
- * hashField, valueJson ('' deletes). Field-wise LWW by the sender's
548
+ * hashField, valueJson ('' deletes), patchFieldJson; finally channel and
549
+ * message prefix. Wire fragments stay JSON text to preserve arrays
550
+ * and numeric precision through Lua. Field-wise LWW by the sender's
553
551
  * `at`: a stored entry with a strictly newer stamp survives; ties go
554
552
  * to the incoming write (memory-tier parity). Returns the applied
555
553
  * 1-based entry positions. The whole-key PEXPIRE is the coarse bound,
@@ -561,9 +559,11 @@ return 1
561
559
  const PRESENCE_SET_LUA = `
562
560
  local at = tonumber(ARGV[1])
563
561
  local applied = {}
564
- for i = 1, tonumber(ARGV[3]) do
565
- local field = ARGV[3 + (i - 1) * 2 + 1]
566
- local value = ARGV[3 + (i - 1) * 2 + 2]
562
+ local published = {}
563
+ local n = tonumber(ARGV[3])
564
+ for i = 1, n do
565
+ local field = ARGV[3 + (i - 1) * 3 + 1]
566
+ local value = ARGV[3 + (i - 1) * 3 + 2]
567
567
  local existing = redis.call('HGET', KEYS[1], field)
568
568
  if not existing or tonumber(cjson.decode(existing)['a']) <= at then
569
569
  if value == '' then
@@ -572,9 +572,13 @@ for i = 1, tonumber(ARGV[3]) do
572
572
  redis.call('HSET', KEYS[1], field, value)
573
573
  end
574
574
  applied[#applied + 1] = tostring(i)
575
+ published[#published + 1] = ARGV[3 + (i - 1) * 3 + 3]
575
576
  end
576
577
  end
577
- if #applied > 0 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end
578
+ if #applied > 0 then
579
+ redis.call('PEXPIRE', KEYS[1], ARGV[2])
580
+ redis.pcall('PUBLISH', ARGV[4 + n * 3], ARGV[5 + n * 3] .. table.concat(published, ',') .. '}}')
581
+ end
578
582
  return applied
579
583
  `
580
584
 
@@ -766,7 +770,7 @@ const wrap = async <T>(fn: () => Promise<T>): Promise<T> => {
766
770
  export function createRedisStoreCore(
767
771
  options: RedisStoreCoreOptions,
768
772
  ): RedisStoreCore {
769
- const { call, clock, notify } = options
773
+ const { call, clock } = options
770
774
  const generateId = options.ids
771
775
  const prefix = options.keyPrefix
772
776
 
@@ -1004,6 +1008,7 @@ export function createRedisStoreCore(
1004
1008
  e.lane ?? '',
1005
1009
  e.settled ? '1' : '0',
1006
1010
  ]),
1011
+ `${prefix}:${sessionId}:notify`,
1007
1012
  ]
1008
1013
  const reply = (await evalScript(
1009
1014
  APPEND_LUA,
@@ -1060,7 +1065,6 @@ export function createRedisStoreCore(
1060
1065
  }
1061
1066
  }
1062
1067
  const base = Number(reply[1]) - 1
1063
- if (reply[3] === '1') notify?.(sessionId, base + withIds.length)
1064
1068
  return {
1065
1069
  events: withIds.map((e, i) => ({
1066
1070
  id: e.id,
@@ -1187,6 +1191,7 @@ export function createRedisStoreCore(
1187
1191
  event.lane ?? '',
1188
1192
  event.settled === true ? '1' : '0',
1189
1193
  ]),
1194
+ `${prefix}:${sessionId}:notify`,
1190
1195
  ],
1191
1196
  )) as string[]
1192
1197
  const outcome = reply[0]
@@ -1206,9 +1211,6 @@ export function createRedisStoreCore(
1206
1211
  const cause = { index, attempt }
1207
1212
  if (outcome === 'ok') {
1208
1213
  const base = Number(reply[1]) - 1
1209
- if (events.length > 0 && reply[2] === '1') {
1210
- notify?.(sessionId, base + events.length)
1211
- }
1212
1214
  return {
1213
1215
  outcome: 'completed',
1214
1216
  events: events.map((event, i) => ({
@@ -1428,7 +1430,10 @@ export function createRedisStoreCore(
1428
1430
  a: atMs,
1429
1431
  e: expiresAtMs,
1430
1432
  }),
1433
+ `${json(field)}:${json(value)}`,
1431
1434
  ]),
1435
+ presenceKey(ns),
1436
+ `${json({ src: options.presenceSource, participant, seen: meta.seen, at: atMs }).slice(0, -1)},"values":{`,
1432
1437
  ],
1433
1438
  )) as string[]
1434
1439
  if (applied.length === 0) return null
@@ -1492,6 +1497,8 @@ export function createRedisStoreCore(
1492
1497
  storeKey(sessionId),
1493
1498
  `${afterIndex + 1}-0`,
1494
1499
  '+',
1500
+ 'COUNT',
1501
+ READ_PAGE_SIZE,
1495
1502
  )) as StreamEntry[]
1496
1503
  return entries.map((entry) => toEvent(sessionId, entry))
1497
1504
  })
@@ -1,21 +1,10 @@
1
- /**
2
- * experimental-a2/store-redis-http — the same Redis Streams store over a
3
- * provider REST API (Upstash-shaped: POST one command as a JSON array,
4
- * receive `{ result }` or `{ error }`). The storage semantics live in
5
- * store-redis-core.ts, shared with experimental-a2/store-redis.
6
- *
7
- * Fully connectionless: every command is one `fetch`, so it works
8
- * where a Redis-protocol connection cannot exist or cannot be spared.
9
- * Without a connection there is no push and no pub/sub, so `stream()`
10
- * is the shared activity-adaptive poll loop (25ms while events flow,
11
- * backing off to a 250ms idle ceiling), and presence is the degraded
12
- * tier: `set`/`read` from the shared core, no `subscribe`. No peer
13
- * dependencies.
14
- */
15
-
16
1
  import { A2Error } from './errors.ts'
17
- import { pollingStream } from './store-polling.ts'
18
- import { createRedisStoreCore } from './store-redis-core.ts'
2
+ import { NOTIFY_TIMINGS } from './internal.ts'
3
+ import { redisHttpSubscriptions } from './redis-http-subscriptions.ts'
4
+ import {
5
+ createRedisNotifyStore,
6
+ type RedisConnection,
7
+ } from './store-redis-notify.ts'
19
8
  import {
20
9
  RANDOM_IDS,
21
10
  SYSTEM_CLOCK,
@@ -38,7 +27,7 @@ export type RedisHttpStoreOptions = {
38
27
  }
39
28
 
40
29
  export type RedisHttpStore = A2Store & {
41
- /** Nothing to disconnect; present so callers can treat stores uniformly. */
30
+ /** Close live subscriptions and pending reconnects. */
42
31
  close(): Promise<void>
43
32
  }
44
33
 
@@ -47,6 +36,8 @@ export function redisHttp(options: RedisHttpStoreOptions): RedisHttpStore {
47
36
  throw new TypeError('redisHttp() needs a url and a token')
48
37
  }
49
38
  const { url, token } = options
39
+ let polling = false
40
+ let fallbackCheck: Promise<void> | undefined
50
41
 
51
42
  const call = async (
52
43
  command: string,
@@ -61,6 +52,9 @@ export function redisHttp(options: RedisHttpStoreOptions): RedisHttpStore {
61
52
  'content-type': 'application/json',
62
53
  },
63
54
  body: JSON.stringify([command, ...args.map(String)]),
55
+ ...(command === 'PING'
56
+ ? { signal: AbortSignal.timeout(NOTIFY_TIMINGS.safetyReadMs) }
57
+ : {}),
64
58
  })
65
59
  } catch (err) {
66
60
  throw new A2Error('STORE_UNAVAILABLE', 'redis rest request failed', {
@@ -80,36 +74,48 @@ export function redisHttp(options: RedisHttpStoreOptions): RedisHttpStore {
80
74
  return body.result
81
75
  }
82
76
 
83
- const core = createRedisStoreCore({
77
+ let subscriber: RedisConnection | undefined
78
+ let onReconnect: ((channel: string) => void) | undefined
79
+ const client: RedisConnection = {
84
80
  call,
81
+ duplicate: () =>
82
+ (subscriber ??= redisHttpSubscriptions({
83
+ url,
84
+ token,
85
+ onReconnect: (channel) => onReconnect?.(channel),
86
+ fallback: {
87
+ enabled: () => polling,
88
+ enable() {
89
+ fallbackCheck ??= call('PING')
90
+ .then((result) => {
91
+ if (result !== 'PONG')
92
+ throw new A2Error(
93
+ 'STORE_UNAVAILABLE',
94
+ 'redis rest capability check failed',
95
+ )
96
+ polling = true
97
+ return undefined
98
+ })
99
+ .catch((error: unknown) => {
100
+ fallbackCheck = undefined
101
+ throw error
102
+ })
103
+ return fallbackCheck
104
+ },
105
+ },
106
+ })),
107
+ on() {},
108
+ disconnect: () => subscriber?.disconnect(),
109
+ }
110
+ return createRedisNotifyStore({
111
+ client: () => Promise.resolve(client),
112
+ closeClient: async () => client.disconnect(),
113
+ polling: () => polling,
114
+ onReconnect: (listener) => {
115
+ onReconnect = listener
116
+ },
85
117
  clock: options.clock ?? SYSTEM_CLOCK,
86
118
  ids: options.ids ?? RANDOM_IDS,
87
119
  keyPrefix: options.keyPrefix ?? 'a2',
88
120
  })
89
-
90
- return {
91
- append: core.append,
92
- read: core.read,
93
- claimAvailable: core.claimAvailable,
94
- renewClaims: core.renewClaims,
95
- completeAttempt: core.completeAttempt,
96
- failAttempt: core.failAttempt,
97
- readState: core.readState,
98
- putSnapshots: core.putSnapshots,
99
- presence: {
100
- async set(ns, participant, values, meta) {
101
- await core.presence.set(ns, participant, values, meta)
102
- },
103
- read: core.presence.read,
104
- },
105
-
106
- stream(sessionId, opts) {
107
- return pollingStream(
108
- (afterIndex) => core.readRange(sessionId, afterIndex),
109
- opts?.startAfter !== undefined ? { startAfter: opts.startAfter } : {},
110
- )
111
- },
112
-
113
- async close() {},
114
- }
115
121
  }