experimental-a2 0.10.0 → 0.12.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 (121) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/actor-DJi3RsNu.d.ts +310 -0
  3. package/dist/actor-DJi3RsNu.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 -208
  24. package/dist/client.js +2 -1206
  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-DlLyvaSH.js → server-BeNADlCI.js} +171 -65
  43. package/dist/server-BeNADlCI.js.map +1 -0
  44. package/dist/{server-DgCrSuhB.d.ts → server-DjZZa1wr.d.ts} +9 -3
  45. package/dist/{server-DgCrSuhB.d.ts.map → server-DjZZa1wr.d.ts.map} +1 -1
  46. package/dist/server.d.ts +3 -3
  47. package/dist/server.js +2 -2
  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 +15 -2
  80. package/docs/reference/01-api.mdx +124 -17
  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 +253 -96
  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.ts +160 -22
  100. package/src/session-socket.ts +25 -7
  101. package/src/sse.ts +17 -0
  102. package/src/store-polling.ts +32 -12
  103. package/src/store-postgres.ts +292 -75
  104. package/src/store-presence-polling.ts +125 -0
  105. package/src/store-redis-core.ts +37 -30
  106. package/src/store-redis-http.ts +51 -45
  107. package/src/store-redis-notify.ts +464 -0
  108. package/src/store-redis.ts +9 -364
  109. package/src/store.ts +3 -4
  110. package/src/stream-activity.ts +32 -0
  111. package/src/wire.ts +39 -15
  112. package/dist/actor-shared-USo5MyuF.d.ts +0 -136
  113. package/dist/actor-shared-USo5MyuF.d.ts.map +0 -1
  114. package/dist/actor.d.ts.map +0 -1
  115. package/dist/client.d.ts.map +0 -1
  116. package/dist/client.js.map +0 -1
  117. package/dist/index.d.ts.map +0 -1
  118. package/dist/server-DlLyvaSH.js.map +0 -1
  119. package/dist/store-polling-6DW7F1DT.js.map +0 -1
  120. package/dist/store-redis-core-z-ykbyMg.js.map +0 -1
  121. package/dist/wire--yji6mO3.js.map +0 -1
@@ -1,9 +1,482 @@
1
1
  import { t as A2Error } from "./errors-DCk6ch5n.js";
2
- import { n as pollingStream } from "./store-polling-6DW7F1DT.js";
2
+ import { h as nullProtoRecord, r as NOTIFY_TIMINGS } from "./internal-Dq2qYxou.js";
3
+ import { n as pollingStream, t as defaultSleep } from "./store-polling-CmxUbV93.js";
4
+ import { r as platformWaitUntil } from "./platform-B4TnJtWu.js";
3
5
  import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
4
6
  import { t as idempotentReplay } from "./idempotent-replay-DVOlyYbx.js";
5
7
  import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./store-N8PXxDAS.js";
6
8
  import { t as decodeReturnedEventIds } from "./store-codec-DTG0Ftek.js";
9
+ import { t as pollPresence } from "./store-presence-polling-C7-XZyW9.js";
10
+ import { createHash } from "node:crypto";
11
+ //#region src/postgres-notifications.ts
12
+ const postgresNotificationChannel = ({ kind, sessionId }) => `a2_${kind}_${createHash("md5").update(sessionId).digest("hex")}`;
13
+ function postgresNotifications(createConnection) {
14
+ const channels = /* @__PURE__ */ new Map();
15
+ const ending = /* @__PURE__ */ new Set();
16
+ const end = (connection) => {
17
+ if (!connection) return;
18
+ const pending = connection.end().catch(() => {}).finally(() => {
19
+ ending.delete(pending);
20
+ });
21
+ ending.add(pending);
22
+ };
23
+ let current;
24
+ let task;
25
+ let retry;
26
+ let delay = 100;
27
+ let connectedAt = 0;
28
+ let closed = false;
29
+ const queues = /* @__PURE__ */ new WeakMap();
30
+ const query = ({ connection, text }) => {
31
+ const result = (queues.get(connection) ?? Promise.resolve()).then(() => {
32
+ if (current !== connection) throw new Error("Postgres listener disconnected");
33
+ return connection.query(text);
34
+ });
35
+ queues.set(connection, result.catch(() => {}));
36
+ return result;
37
+ };
38
+ const fail = ({ connection, error }) => {
39
+ if (connection !== current) return;
40
+ current = void 0;
41
+ end(connection);
42
+ for (const entry of channels.values()) {
43
+ entry.listening = false;
44
+ for (const watcher of entry.watchers) watcher.reject(error);
45
+ }
46
+ if (closed || channels.size === 0 || retry) return;
47
+ if (Date.now() - connectedAt >= NOTIFY_TIMINGS.safetyReadMs) delay = 100;
48
+ retry = defaultSleep(delay);
49
+ delay = Math.min(delay * 2, NOTIFY_TIMINGS.safetyReadMs);
50
+ retry.promise.then(() => {
51
+ retry = void 0;
52
+ reconcile();
53
+ });
54
+ };
55
+ const reconcile = () => {
56
+ if (closed || task || retry || channels.size === 0) return;
57
+ let attempt;
58
+ task = (async () => {
59
+ if (!current) {
60
+ connectedAt = Date.now();
61
+ const connection = await createConnection();
62
+ if (closed || channels.size === 0) {
63
+ await connection.end();
64
+ return;
65
+ }
66
+ current = connection;
67
+ attempt = connection;
68
+ connectedAt = Date.now();
69
+ connection.onNotification(({ channel, payload }) => {
70
+ if (current !== connection) return;
71
+ for (const watcher of channels.get(channel)?.watchers ?? []) watcher.notify(payload ?? "");
72
+ });
73
+ connection.onError((error) => fail({
74
+ connection,
75
+ error
76
+ }));
77
+ connection.onEnd(() => fail({
78
+ connection,
79
+ error: /* @__PURE__ */ new Error("Postgres listener disconnected")
80
+ }));
81
+ await connection.connect();
82
+ if (current !== connection) return;
83
+ }
84
+ const connection = current;
85
+ attempt = connection;
86
+ for (const [channel, entry] of channels) {
87
+ if (entry.listening) continue;
88
+ entry.listening = true;
89
+ entry.ready = query({
90
+ connection,
91
+ text: `LISTEN "${channel.replaceAll("\"", "\"\"")}"`
92
+ });
93
+ await entry.ready;
94
+ if (current !== connection) return;
95
+ for (const watcher of entry.watchers) {
96
+ watcher.resolve();
97
+ watcher.notify("");
98
+ }
99
+ }
100
+ })().catch((error) => {
101
+ if (attempt === current) fail({
102
+ connection: attempt,
103
+ error
104
+ });
105
+ }).finally(() => {
106
+ task = void 0;
107
+ if ([...channels.values()].some((entry) => !entry.listening)) reconcile();
108
+ });
109
+ };
110
+ return {
111
+ watch({ channel, notify }) {
112
+ let resolve;
113
+ let reject;
114
+ const ready = new Promise((yes, no) => {
115
+ resolve = yes;
116
+ reject = no;
117
+ });
118
+ const watcher = {
119
+ notify,
120
+ resolve,
121
+ reject
122
+ };
123
+ let entry = channels.get(channel);
124
+ if (!entry) {
125
+ entry = {
126
+ listening: false,
127
+ watchers: /* @__PURE__ */ new Set()
128
+ };
129
+ channels.set(channel, entry);
130
+ }
131
+ entry.watchers.add(watcher);
132
+ if (entry.listening) entry.ready?.then(resolve, reject);
133
+ reconcile();
134
+ let stopped = false;
135
+ return {
136
+ ready,
137
+ stop() {
138
+ if (stopped) return;
139
+ stopped = true;
140
+ resolve();
141
+ entry.watchers.delete(watcher);
142
+ if (entry.watchers.size > 0) return;
143
+ channels.delete(channel);
144
+ if (channels.size === 0) {
145
+ retry?.cancel();
146
+ const connection = current;
147
+ current = void 0;
148
+ end(connection);
149
+ } else if (entry.listening) {
150
+ const connection = current;
151
+ if (connection) query({
152
+ connection,
153
+ text: `UNLISTEN "${channel.replaceAll("\"", "\"\"")}"`
154
+ }).catch((error) => fail({
155
+ connection,
156
+ error
157
+ }));
158
+ }
159
+ }
160
+ };
161
+ },
162
+ async close() {
163
+ closed = true;
164
+ retry?.cancel();
165
+ for (const entry of channels.values()) for (const watcher of entry.watchers) watcher.resolve();
166
+ channels.clear();
167
+ const connection = current;
168
+ current = void 0;
169
+ end(connection);
170
+ await Promise.all(ending);
171
+ }
172
+ };
173
+ }
174
+ const POSTGRES_NOTIFICATIONS_SQL = `
175
+ create or replace function a2_notify_events() returns trigger language plpgsql as $$
176
+ begin
177
+ perform pg_notify('a2_events_' || md5(new.session_id), '');
178
+ return null;
179
+ end $$;
180
+ create or replace function a2_notify_presence() returns trigger language plpgsql as $$
181
+ begin
182
+ perform pg_notify('a2_presence_' || md5(case when TG_OP = 'DELETE' then old.ns else new.ns end),
183
+ coalesce(current_setting('a2.presence_source', true), ''));
184
+ return null;
185
+ end $$;
186
+ do $$ begin
187
+ if not exists (select 1 from pg_trigger where tgname = 'a2_notify_events' and tgrelid = 'a2_events'::regclass) then
188
+ create trigger a2_notify_events after insert on a2_events for each row execute function a2_notify_events();
189
+ end if;
190
+ if not exists (select 1 from pg_trigger where tgname = 'a2_notify_presence' and tgrelid = 'a2_presence'::regclass) then
191
+ create trigger a2_notify_presence after insert or update or delete on a2_presence for each row execute function a2_notify_presence();
192
+ end if;
193
+ end $$;
194
+ `;
195
+ //#endregion
196
+ //#region src/postgres-notification-scope.ts
197
+ const failure = (error) => error instanceof A2Error ? error : new A2Error("STORE_UNAVAILABLE", "Postgres subscription failed", { cause: error });
198
+ function postgresNotificationScope(load) {
199
+ const state = {
200
+ shared: retryableLazy(load),
201
+ stops: /* @__PURE__ */ new Set(),
202
+ closed: false
203
+ };
204
+ const scope = {
205
+ watch(options) {
206
+ let stopped = false;
207
+ let cancel;
208
+ const cancelled = new Promise((resolve) => {
209
+ cancel = resolve;
210
+ });
211
+ let subscription;
212
+ const stop = () => {
213
+ stopped = true;
214
+ cancel();
215
+ subscription?.stop();
216
+ state.stops.delete(stop);
217
+ };
218
+ state.stops.add(stop);
219
+ return {
220
+ ready: Promise.race([cancelled, state.shared.get().then(async ({ value }) => {
221
+ if (stopped || state.closed) return;
222
+ subscription = value.watch(options);
223
+ await subscription.ready;
224
+ })]).catch((error) => {
225
+ if (!stopped && !state.closed) throw failure(error);
226
+ }),
227
+ stop
228
+ };
229
+ },
230
+ stream({ channel, read, startAfter }) {
231
+ return { [Symbol.asyncIterator]() {
232
+ let stopped = false;
233
+ let after = startAfter;
234
+ let buffer = [];
235
+ let version = 0;
236
+ let waiting;
237
+ let lease;
238
+ const stop = () => {
239
+ stopped = true;
240
+ waiting?.cancel();
241
+ lease?.stop();
242
+ state.stops.delete(stop);
243
+ };
244
+ state.stops.add(stop);
245
+ return {
246
+ async next() {
247
+ try {
248
+ if (stopped || state.closed) return {
249
+ done: true,
250
+ value: void 0
251
+ };
252
+ lease ??= scope.watch({
253
+ channel,
254
+ notify: () => {
255
+ version += 1;
256
+ waiting?.cancel();
257
+ }
258
+ });
259
+ await lease.ready;
260
+ for (;;) {
261
+ if (stopped || state.closed) return {
262
+ done: true,
263
+ value: void 0
264
+ };
265
+ const row = buffer.shift();
266
+ if (row) {
267
+ after = row.index;
268
+ return {
269
+ done: false,
270
+ value: row
271
+ };
272
+ }
273
+ const before = version;
274
+ buffer = await read(after);
275
+ if (stopped || state.closed) return {
276
+ done: true,
277
+ value: void 0
278
+ };
279
+ if (buffer.length || before !== version) continue;
280
+ waiting = defaultSleep(NOTIFY_TIMINGS.safetyReadMs);
281
+ await waiting.promise;
282
+ }
283
+ } catch (error) {
284
+ if (stopped || state.closed) return {
285
+ done: true,
286
+ value: void 0
287
+ };
288
+ stop();
289
+ throw failure(error);
290
+ }
291
+ },
292
+ async return() {
293
+ stop();
294
+ return {
295
+ done: true,
296
+ value: void 0
297
+ };
298
+ }
299
+ };
300
+ } };
301
+ },
302
+ async close() {
303
+ if (state.closed) return;
304
+ state.closed = true;
305
+ for (const stop of state.stops) stop();
306
+ await (await state.shared.peek()?.catch(() => void 0))?.release();
307
+ }
308
+ };
309
+ return scope;
310
+ }
311
+ //#endregion
312
+ //#region src/postgres-pool.ts
313
+ function postgresPoolClient(pool) {
314
+ let timer;
315
+ let resolve;
316
+ const finish = () => {
317
+ clearTimeout(timer);
318
+ timer = void 0;
319
+ resolve?.();
320
+ resolve = void 0;
321
+ };
322
+ const keepAlive = () => {
323
+ const idleTimeout = pool.options.idleTimeoutMillis;
324
+ if (pool.ending || pool.idleCount === 0 || typeof idleTimeout !== "number" || !Number.isFinite(idleTimeout) || idleTimeout <= 0) return;
325
+ finish();
326
+ const pending = new Promise((done) => {
327
+ resolve = done;
328
+ timer = setTimeout(finish, idleTimeout + 100);
329
+ timer.unref();
330
+ });
331
+ platformWaitUntil(pending);
332
+ };
333
+ pool.on("remove", () => {
334
+ if (pool.totalCount === 0) finish();
335
+ });
336
+ return {
337
+ async query(text, values) {
338
+ try {
339
+ return await pool.query(text, values);
340
+ } finally {
341
+ keepAlive();
342
+ }
343
+ },
344
+ async connect() {
345
+ const client = await pool.connect();
346
+ return {
347
+ query: (text, values) => client.query(text, values),
348
+ release() {
349
+ try {
350
+ client.release();
351
+ } finally {
352
+ keepAlive();
353
+ }
354
+ }
355
+ };
356
+ },
357
+ async end() {
358
+ try {
359
+ await pool.end();
360
+ } finally {
361
+ finish();
362
+ }
363
+ }
364
+ };
365
+ }
366
+ //#endregion
367
+ //#region src/postgres-resources.ts
368
+ const registryKey = Symbol.for("experimental-a2.postgres-resources.v3");
369
+ const registry = () => {
370
+ let value = Reflect.get(globalThis, registryKey);
371
+ if (!value) {
372
+ value = {
373
+ pools: /* @__PURE__ */ new Map(),
374
+ injected: /* @__PURE__ */ new WeakMap(),
375
+ listeners: /* @__PURE__ */ new Map(),
376
+ identities: /* @__PURE__ */ new WeakMap(),
377
+ nextIdentity: 0
378
+ };
379
+ Reflect.set(globalThis, registryKey, value);
380
+ }
381
+ return value;
382
+ };
383
+ const acquire = ({ cache, key, create }) => {
384
+ let entry = cache.get(key);
385
+ if (!entry) {
386
+ entry = {
387
+ value: create(),
388
+ refs: 0
389
+ };
390
+ cache.set(key, entry);
391
+ }
392
+ entry.refs += 1;
393
+ const held = entry;
394
+ let released = false;
395
+ return {
396
+ value: entry.value,
397
+ async release() {
398
+ if (released) return;
399
+ released = true;
400
+ held.refs -= 1;
401
+ if (held.refs !== 0) return;
402
+ if (cache.get(key) === held) cache.delete(key);
403
+ await held.value.close();
404
+ }
405
+ };
406
+ };
407
+ function postgresConnectionKey({ connectionString, defaults }) {
408
+ const shared = registry();
409
+ const visited = /* @__PURE__ */ new Set();
410
+ const identity = (value) => {
411
+ let id = shared.identities.get(value);
412
+ if (id === void 0) {
413
+ id = ++shared.nextIdentity;
414
+ shared.identities.set(value, id);
415
+ }
416
+ return id;
417
+ };
418
+ const encode = (value) => {
419
+ if (typeof value === "function") return ["reference", identity(value)];
420
+ if (value === null || typeof value !== "object") return [typeof value, String(value)];
421
+ if (Buffer.isBuffer(value)) return ["buffer", value.toString("base64")];
422
+ const prototype = Object.getPrototypeOf(value);
423
+ if (visited.has(value) || prototype !== Object.prototype && prototype !== null && !Array.isArray(value)) return ["reference", identity(value)];
424
+ visited.add(value);
425
+ const result = [Array.isArray(value) ? "array" : "object", Object.getOwnPropertyNames(value).toSorted().map((key) => [key, encode(Reflect.get(value, key))])];
426
+ visited.delete(value);
427
+ return result;
428
+ };
429
+ const environment = Object.keys(process.env).filter((key) => key.startsWith("PG")).toSorted().map((key) => [key, process.env[key]]);
430
+ return createHash("sha256").update(JSON.stringify([
431
+ connectionString,
432
+ environment,
433
+ encode(defaults)
434
+ ])).digest("hex");
435
+ }
436
+ const queries = (raw) => {
437
+ let pending = Promise.resolve();
438
+ const schemas = /* @__PURE__ */ new Map();
439
+ const exclusive = (fn) => {
440
+ const result = pending.then(fn);
441
+ pending = result.catch(() => {});
442
+ return result;
443
+ };
444
+ return {
445
+ raw,
446
+ client: raw.connect ? raw : { query: (text, values) => exclusive(() => raw.query(text, values)) },
447
+ exclusive,
448
+ initialize({ schema, run }) {
449
+ const existing = schemas.get(schema);
450
+ if (existing) return existing;
451
+ const attempt = run();
452
+ schemas.set(schema, attempt);
453
+ attempt.catch(() => {
454
+ if (schemas.get(schema) === attempt) schemas.delete(schema);
455
+ });
456
+ return attempt;
457
+ },
458
+ async close() {
459
+ await pending;
460
+ await raw.end?.();
461
+ }
462
+ };
463
+ };
464
+ const sharePostgresPool = ({ key, create }) => acquire({
465
+ cache: registry().pools,
466
+ key,
467
+ create: () => queries(create())
468
+ });
469
+ const sharePostgresClient = (client) => acquire({
470
+ cache: registry().injected,
471
+ key: client,
472
+ create: () => queries(client)
473
+ });
474
+ const sharePostgresListener = ({ key, create }) => acquire({
475
+ cache: registry().listeners,
476
+ key,
477
+ create
478
+ });
479
+ //#endregion
7
480
  //#region src/store-postgres.ts
8
481
  /**
9
482
  * experimental-a2/store-postgres — postgres store backend (the production path).
@@ -17,9 +490,8 @@ import { t as decodeReturnedEventIds } from "./store-codec-DTG0Ftek.js";
17
490
  *
18
491
  * Semantics mirror the sqlite backend: `index` is stored as `idx`,
19
492
  * timestamps are epoch milliseconds written from the injected clock —
20
- * never SQL `now()` — and `stream()` is a poll loop. (A LISTEN/NOTIFY
21
- * upgrade can land later without any API change; callers never branch
22
- * on the transport.) Appends serialize per session via
493
+ * never SQL `now()`. Streams poll by default; an optional direct
494
+ * connection enables LISTEN/NOTIFY for events and presence. Appends serialize per session via
23
495
  * `pg_advisory_xact_lock`, so concurrent writers can't race the index
24
496
  * assignment.
25
497
  */
@@ -155,16 +627,17 @@ const wrap = async (fn) => {
155
627
  }
156
628
  };
157
629
  const jsonb = (value) => JSON.stringify(value) ?? "null";
158
- const initializeSchema = async (client) => {
630
+ const initializeSchema = async ({ client, listen }) => {
631
+ const schema = SCHEMA + (listen ? POSTGRES_NOTIFICATIONS_SQL : "");
159
632
  if (!client.connect) {
160
- await client.query(SCHEMA);
633
+ await client.query(schema);
161
634
  return;
162
635
  }
163
636
  const conn = await client.connect();
164
637
  try {
165
638
  await conn.query("begin");
166
639
  await conn.query("select pg_advisory_xact_lock(hashtext('a2:postgres:schema'))");
167
- await conn.query(SCHEMA);
640
+ await conn.query(schema);
168
641
  await conn.query("commit");
169
642
  } catch (err) {
170
643
  await conn.query("rollback").catch(() => {});
@@ -173,28 +646,99 @@ const initializeSchema = async (client) => {
173
646
  conn.release();
174
647
  }
175
648
  };
649
+ const loadPg = async () => import("pg").catch(() => {
650
+ throw new A2Error("STORE_NOT_CONFIGURED", "Postgres connections created by A2 require the optional 'pg' package");
651
+ });
652
+ const createListener = ({ pg, connectionString }) => postgresNotifications(async () => {
653
+ const listener = new pg.default.Client({
654
+ connectionString,
655
+ application_name: "a2-listener",
656
+ connectionTimeoutMillis: 1e4
657
+ });
658
+ return {
659
+ connect: () => listener.connect(),
660
+ query: (text) => listener.query(text),
661
+ end: () => listener.end(),
662
+ onNotification: (callback) => {
663
+ listener.on("notification", callback);
664
+ },
665
+ onError: (callback) => {
666
+ listener.on("error", callback);
667
+ },
668
+ onEnd: (callback) => {
669
+ listener.on("end", callback);
670
+ }
671
+ };
672
+ });
176
673
  function postgres(options = {}) {
177
674
  const clock = options.clock ?? SYSTEM_CLOCK;
178
675
  const generateId = options.ids ?? RANDOM_IDS;
676
+ const instanceId = crypto.randomUUID();
677
+ let closed = false;
678
+ if (options.listenConnectionString === "") throw new TypeError("listenConnectionString must be a direct Postgres connection string");
179
679
  if (!options.client && !options.connectionString) throw new TypeError("postgres() needs a connectionString or an injected client");
680
+ const injected = options.client ? sharePostgresClient(options.client) : void 0;
180
681
  const connection = retryableLazy(async () => {
181
- let owned = null;
682
+ let lease = injected;
683
+ if (!lease) {
684
+ const pg = await loadPg();
685
+ const connectionString = options.connectionString;
686
+ const key = postgresConnectionKey({
687
+ connectionString,
688
+ defaults: pg.default.defaults
689
+ });
690
+ lease = sharePostgresPool({
691
+ key,
692
+ create: () => {
693
+ const pool = new pg.default.Pool({ connectionString });
694
+ pool.on("error", () => {});
695
+ return postgresPoolClient(pool);
696
+ }
697
+ });
698
+ }
182
699
  try {
183
- let c = options.client;
184
- if (!c) {
185
- c = new (await (import("pg").catch(() => {
186
- throw new A2Error("STORE_NOT_CONFIGURED", "experimental-a2/store-postgres with a connectionString needs the 'pg' package (optional peer dependency) — install it, or inject a client");
187
- }))).default.Pool({ connectionString: options.connectionString });
188
- owned = c;
189
- }
190
- await initializeSchema(c);
191
- return c;
192
- } catch (err) {
193
- if (owned?.end) await owned.end().catch(() => {});
194
- throw err;
700
+ const listen = Boolean(options.listenConnectionString);
701
+ const resource = lease.value;
702
+ if (injected) await initializeSchema({
703
+ client: resource.client,
704
+ listen
705
+ });
706
+ else await resource.initialize({
707
+ schema: SCHEMA + (listen ? POSTGRES_NOTIFICATIONS_SQL : ""),
708
+ run: () => initializeSchema({
709
+ client: resource.client,
710
+ listen
711
+ })
712
+ });
713
+ return lease;
714
+ } catch (error) {
715
+ if (!injected) await lease.release().catch(() => {});
716
+ throw error;
195
717
  }
196
718
  });
197
- const client = connection.get;
719
+ const client = async () => {
720
+ if (closed) throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
721
+ const lease = await connection.get();
722
+ if (closed) throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
723
+ return lease.value.client;
724
+ };
725
+ const notifications = options.listenConnectionString ? postgresNotificationScope(async () => {
726
+ await client();
727
+ const pg = await loadPg();
728
+ const connectionString = options.listenConnectionString;
729
+ const key = postgresConnectionKey({
730
+ connectionString,
731
+ defaults: pg.default.defaults
732
+ });
733
+ return sharePostgresListener({
734
+ key,
735
+ create: () => createListener({
736
+ pg,
737
+ connectionString
738
+ })
739
+ });
740
+ }) : void 0;
741
+ const presenceSubscriptions = /* @__PURE__ */ new Map();
198
742
  const readStateBatch = async (requests, reducerName) => {
199
743
  if (requests.length === 0) return [];
200
744
  const c = await client();
@@ -279,13 +823,13 @@ function postgres(options = {}) {
279
823
  }
280
824
  return reads;
281
825
  };
282
- let mutex = Promise.resolve();
826
+ const begin = notifications ? `begin; set local a2.presence_source = '${instanceId}'` : "begin";
283
827
  const withTx = async (fn) => {
284
828
  const c = await client();
285
829
  if (c.connect) {
286
830
  const conn = await c.connect();
287
831
  try {
288
- await conn.query("begin");
832
+ await conn.query(begin);
289
833
  const result = await fn((text, values) => conn.query(text, values));
290
834
  await conn.query("commit");
291
835
  return result;
@@ -296,21 +840,21 @@ function postgres(options = {}) {
296
840
  conn.release();
297
841
  }
298
842
  }
299
- const run = mutex.then(async () => {
300
- await c.query("begin");
843
+ const resource = (await connection.get()).value;
844
+ return resource.exclusive(async () => {
845
+ const raw = resource.raw;
846
+ await raw.query(begin);
301
847
  try {
302
- const result = await fn((text, values) => c.query(text, values));
303
- await c.query("commit");
848
+ const result = await fn((text, values) => raw.query(text, values));
849
+ await raw.query("commit");
304
850
  return result;
305
851
  } catch (err) {
306
- await c.query("rollback").catch(() => {});
852
+ await raw.query("rollback").catch(() => {});
307
853
  throw err;
308
854
  }
309
855
  });
310
- mutex = run.catch(() => {});
311
- return run;
312
856
  };
313
- return {
857
+ const store = {
314
858
  async append(sessionId, events) {
315
859
  if (events.length === 0) return wrap(async () => {
316
860
  const { rows } = await (await client()).query(`select exists(
@@ -838,17 +1382,18 @@ function postgres(options = {}) {
838
1382
  const entries = Object.entries(values);
839
1383
  if (entries.length === 0) return;
840
1384
  await wrap(async () => {
841
- const c = await client();
842
1385
  const atMs = meta.at.getTime();
843
1386
  const expiresAtMs = clock.now().getTime() + meta.ttlMs;
844
- for (const [field, value] of entries) await (value === null ? c.query(`delete from a2_presence
1387
+ const applied = nullProtoRecord();
1388
+ const write = async (query) => {
1389
+ for (const [field, value] of entries) if ((await (value === null ? query(`delete from a2_presence
845
1390
  where ns = $1 and participant = $2 and field = $3
846
- and at <= $4`, [
847
- ns,
848
- participant,
849
- field,
850
- atMs
851
- ]) : c.query(`insert into a2_presence
1391
+ and at <= $4${notifications ? " returning field" : ""}`, [
1392
+ ns,
1393
+ participant,
1394
+ field,
1395
+ atMs
1396
+ ]) : query(`insert into a2_presence
852
1397
  (ns, participant, field, value, seen, at, expires_at)
853
1398
  values ($1, $2, $3, $4::jsonb, $5, $6, $7)
854
1399
  on conflict (ns, participant, field) do update set
@@ -856,15 +1401,32 @@ function postgres(options = {}) {
856
1401
  seen = excluded.seen,
857
1402
  at = excluded.at,
858
1403
  expires_at = excluded.expires_at
859
- where a2_presence.at <= excluded.at`, [
860
- ns,
861
- participant,
862
- field,
863
- jsonb(value),
864
- meta.seen,
865
- atMs,
866
- expiresAtMs
867
- ]));
1404
+ where a2_presence.at <= excluded.at${notifications ? " returning field" : ""}`, [
1405
+ ns,
1406
+ participant,
1407
+ field,
1408
+ jsonb(value),
1409
+ meta.seen,
1410
+ atMs,
1411
+ expiresAtMs
1412
+ ]))).rows.length) applied[field] = structuredClone(value);
1413
+ };
1414
+ if (notifications) await withTx(write);
1415
+ else {
1416
+ const c = await client();
1417
+ await write((text, parameters) => c.query(text, parameters));
1418
+ }
1419
+ if (Object.keys(applied).length) {
1420
+ const patch = {
1421
+ participant,
1422
+ values: applied,
1423
+ seen: meta.seen,
1424
+ at: new Date(atMs)
1425
+ };
1426
+ const subscription = presenceSubscriptions.get(ns);
1427
+ subscription?.poll.update(patch);
1428
+ for (const listener of subscription?.listeners ?? []) listener(patch);
1429
+ }
868
1430
  });
869
1431
  },
870
1432
  async read(ns) {
@@ -892,17 +1454,103 @@ function postgres(options = {}) {
892
1454
  },
893
1455
  stream(sessionId, opts) {
894
1456
  const readAfter = (afterIndex) => wrap(async () => {
895
- const { rows } = await (await client()).query("select * from a2_events where session_id = $1 and idx > $2 order by idx", [sessionId, afterIndex]);
896
- return rows.map(toStored);
1457
+ const { rows } = await (await client()).query(`select session_id, idx, event_type, payload, event_id, created_at
1458
+ from a2_events
1459
+ where session_id = $1 and idx > $2
1460
+ order by idx`, [sessionId, afterIndex]);
1461
+ return rows.map(toEvent);
1462
+ });
1463
+ if (notifications) return notifications.stream({
1464
+ channel: postgresNotificationChannel({
1465
+ kind: "events",
1466
+ sessionId
1467
+ }),
1468
+ read: readAfter,
1469
+ startAfter: opts?.startAfter ?? 0
897
1470
  });
898
1471
  return pollingStream(readAfter, opts?.startAfter !== void 0 ? { startAfter: opts.startAfter } : {});
899
1472
  },
900
1473
  async close() {
901
- const current = connection.peek();
902
- if (!current) return;
903
- await (await current).end?.();
1474
+ if (closed) return;
1475
+ closed = true;
1476
+ for (const subscription of presenceSubscriptions.values()) subscription.stop();
1477
+ presenceSubscriptions.clear();
1478
+ await notifications?.close();
1479
+ const lease = await connection.peek()?.catch(() => void 0);
1480
+ await (injected ?? lease)?.release();
904
1481
  }
905
1482
  };
1483
+ if (notifications) {
1484
+ const presence = store.presence;
1485
+ const read = presence.read;
1486
+ presence.read = async (ns) => {
1487
+ const subscription = presenceSubscriptions.get(ns);
1488
+ await subscription?.ready;
1489
+ await subscription?.poll.ready;
1490
+ return read(ns);
1491
+ };
1492
+ presence.subscribe = (ns, onPatch) => {
1493
+ if (closed) throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
1494
+ let subscription = presenceSubscriptions.get(ns);
1495
+ if (!subscription) {
1496
+ const listeners = /* @__PURE__ */ new Set();
1497
+ let dirty = false;
1498
+ let ended = false;
1499
+ let waiting;
1500
+ const lease = notifications.watch({
1501
+ channel: postgresNotificationChannel({
1502
+ kind: "presence",
1503
+ sessionId: ns
1504
+ }),
1505
+ notify: (source) => {
1506
+ if (source === instanceId) return;
1507
+ dirty = true;
1508
+ waiting?.cancel();
1509
+ }
1510
+ });
1511
+ const poll = pollPresence({
1512
+ read: async () => {
1513
+ await lease.ready;
1514
+ if (ended || closed) return [];
1515
+ dirty = false;
1516
+ return read(ns);
1517
+ },
1518
+ deliver: (patch) => {
1519
+ for (const listener of listeners) listener(patch);
1520
+ },
1521
+ sleep: () => {
1522
+ waiting = defaultSleep(dirty ? 0 : NOTIFY_TIMINGS.safetyReadMs);
1523
+ return waiting;
1524
+ }
1525
+ });
1526
+ lease.ready.catch(() => poll.stop());
1527
+ subscription = {
1528
+ listeners,
1529
+ ready: lease.ready,
1530
+ poll,
1531
+ stop() {
1532
+ ended = true;
1533
+ poll.stop();
1534
+ lease.stop();
1535
+ waiting?.cancel();
1536
+ }
1537
+ };
1538
+ presenceSubscriptions.set(ns, subscription);
1539
+ }
1540
+ subscription.listeners.add(onPatch);
1541
+ let stopped = false;
1542
+ return () => {
1543
+ if (stopped) return;
1544
+ stopped = true;
1545
+ subscription.listeners.delete(onPatch);
1546
+ if (subscription.listeners.size === 0) {
1547
+ presenceSubscriptions.delete(ns);
1548
+ subscription.stop();
1549
+ }
1550
+ };
1551
+ };
1552
+ }
1553
+ return store;
906
1554
  }
907
1555
  //#endregion
908
1556
  export { postgres };