experimental-a2 0.10.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 (121) hide show
  1. package/CHANGELOG.md +39 -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 -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-Dkz2a84E.js} +165 -62
  43. package/dist/server-Dkz2a84E.js.map +1 -0
  44. package/dist/{server-DgCrSuhB.d.ts → server-DwPrMqHB.d.ts} +2 -2
  45. package/dist/{server-DgCrSuhB.d.ts.map → server-DwPrMqHB.d.ts.map} +1 -1
  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 +15 -2
  80. package/docs/reference/01-api.mdx +60 -16
  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 +146 -19
  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
package/src/errors.ts CHANGED
@@ -1,12 +1,4 @@
1
- /**
2
- * a2 errors — one class, discriminated by `code`.
3
- *
4
- * See specs/a2-implementation.md §10 and docs/reference/02-errors.mdx.
5
- * A single class serializes cleanly across the push-route boundary and
6
- * keeps `instanceof` checks working after bundling.
7
- */
8
-
9
- export type A2ErrorCode =
1
+ export type A2ServerErrorCode =
10
2
  | 'INVALID_PAYLOAD' // schema validation failed — thrown before anything is written
11
3
  | 'FORBIDDEN' // external ingress was denied by server.fetch authorization
12
4
  | 'UNKNOWN_EVENT_TYPE' // event type not in the machine's `events` map
@@ -18,6 +10,9 @@ export type A2ErrorCode =
18
10
  | 'UNKNOWN_PRESENCE_FIELD' // presence field not in the contract's `presence` map
19
11
  | 'PRESENCE_NOT_SUPPORTED' // contract declares presence but the store lacks the capability
20
12
 
13
+ export type A2ErrorCode =
14
+ A2ServerErrorCode | 'TIMEOUT' | 'HTTP_ERROR' | 'CONNECTION_FAILED' | 'UNKNOWN'
15
+
21
16
  export class A2Error extends Error {
22
17
  readonly code: A2ErrorCode
23
18
  /** e.g. the Standard Schema issues for INVALID_PAYLOAD */
package/src/index.ts CHANGED
@@ -24,7 +24,7 @@ export type {
24
24
  export type { Reducer, ReducerBuilder } from './reducer.ts'
25
25
 
26
26
  export { A2Error, NonRetriableError } from './errors.ts'
27
- export type { A2ErrorCode } from './errors.ts'
27
+ export type { A2ErrorCode, A2ServerErrorCode } from './errors.ts'
28
28
 
29
29
  export type {
30
30
  A2AttributeValue,
package/src/internal.ts CHANGED
@@ -289,7 +289,7 @@ export const POLL_TIMINGS: {
289
289
  idleCeilingMs: number
290
290
  } = {
291
291
  activeFloorMs: 25,
292
- idleCeilingMs: 250,
292
+ idleCeilingMs: 2_000,
293
293
  }
294
294
 
295
295
  /**
@@ -0,0 +1,134 @@
1
+ import { A2Error } from './errors.ts'
2
+ import { retryableLazy } from './retryable-lazy.ts'
3
+ import { NOTIFY_TIMINGS } from './internal.ts'
4
+ import { defaultSleep, type Sleeper } from './store-polling.ts'
5
+ import type { Event } from './store.ts'
6
+ import type {
7
+ PostgresNotifications,
8
+ ResourceLease,
9
+ } from './postgres-resources.ts'
10
+
11
+ type ScopedNotifications = PostgresNotifications & {
12
+ stream(options: {
13
+ channel: string
14
+ read: (after: number) => Promise<Event[]>
15
+ startAfter: number
16
+ }): AsyncIterable<Event>
17
+ }
18
+
19
+ const failure = (error: unknown): A2Error =>
20
+ error instanceof A2Error
21
+ ? error
22
+ : new A2Error('STORE_UNAVAILABLE', 'Postgres subscription failed', {
23
+ cause: error,
24
+ })
25
+
26
+ export function postgresNotificationScope(
27
+ load: () => Promise<ResourceLease<PostgresNotifications>>,
28
+ ): ScopedNotifications {
29
+ const state = {
30
+ shared: retryableLazy(load),
31
+ stops: new Set<() => void>(),
32
+ closed: false,
33
+ }
34
+
35
+ const scope: ScopedNotifications = {
36
+ watch(options) {
37
+ let stopped = false
38
+ let cancel!: () => void
39
+ const cancelled = new Promise<void>((resolve) => {
40
+ cancel = resolve
41
+ })
42
+ let subscription: ReturnType<PostgresNotifications['watch']> | undefined
43
+ const stop = (): void => {
44
+ stopped = true
45
+ cancel()
46
+ subscription?.stop()
47
+ state.stops.delete(stop)
48
+ }
49
+ state.stops.add(stop)
50
+ const ready = Promise.race([
51
+ cancelled,
52
+ state.shared.get().then(async ({ value }) => {
53
+ if (stopped || state.closed) return
54
+ subscription = value.watch(options)
55
+ await subscription.ready
56
+ return undefined
57
+ }),
58
+ ]).catch((error: unknown) => {
59
+ if (!stopped && !state.closed) throw failure(error)
60
+ })
61
+ return { ready, stop }
62
+ },
63
+ stream({ channel, read, startAfter }) {
64
+ return {
65
+ [Symbol.asyncIterator]() {
66
+ let stopped = false
67
+ let after = startAfter
68
+ let buffer: Event[] = []
69
+ let version = 0
70
+ let waiting: Sleeper | undefined
71
+ let lease: ReturnType<PostgresNotifications['watch']> | undefined
72
+ const stop = (): void => {
73
+ stopped = true
74
+ waiting?.cancel()
75
+ lease?.stop()
76
+ state.stops.delete(stop)
77
+ }
78
+ state.stops.add(stop)
79
+ return {
80
+ async next(): Promise<IteratorResult<Event>> {
81
+ try {
82
+ if (stopped || state.closed)
83
+ return { done: true, value: undefined }
84
+ lease ??= scope.watch({
85
+ channel,
86
+ notify: () => {
87
+ version += 1
88
+ waiting?.cancel()
89
+ },
90
+ })
91
+ await lease.ready
92
+ for (;;) {
93
+ if (stopped || state.closed)
94
+ return { done: true, value: undefined }
95
+ const row = buffer.shift()
96
+ if (row) {
97
+ after = row.index
98
+ return { done: false, value: row }
99
+ }
100
+ const before = version
101
+ // oxlint-disable-next-line no-await-in-loop -- cursor catch-up
102
+ buffer = await read(after)
103
+ if (stopped || state.closed)
104
+ return { done: true, value: undefined }
105
+ if (buffer.length || before !== version) continue
106
+ waiting = defaultSleep(NOTIFY_TIMINGS.safetyReadMs)
107
+ // oxlint-disable-next-line no-await-in-loop -- wait for a hint or the safety read
108
+ await waiting.promise
109
+ }
110
+ } catch (error) {
111
+ if (stopped || state.closed)
112
+ return { done: true, value: undefined }
113
+ stop()
114
+ throw failure(error)
115
+ }
116
+ },
117
+ async return(): Promise<IteratorResult<Event>> {
118
+ stop()
119
+ return { done: true, value: undefined }
120
+ },
121
+ }
122
+ },
123
+ }
124
+ },
125
+ async close() {
126
+ if (state.closed) return
127
+ state.closed = true
128
+ for (const stop of state.stops) stop()
129
+ const lease = await state.shared.peek()?.catch(() => undefined)
130
+ await lease?.release()
131
+ },
132
+ }
133
+ return scope
134
+ }
@@ -0,0 +1,244 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { NOTIFY_TIMINGS } from './internal.ts'
3
+ import { defaultSleep, type Sleeper } from './store-polling.ts'
4
+
5
+ export const postgresNotificationChannel = ({
6
+ kind,
7
+ sessionId,
8
+ }: {
9
+ kind: 'events' | 'presence'
10
+ sessionId: string
11
+ }): string => `a2_${kind}_${createHash('md5').update(sessionId).digest('hex')}`
12
+
13
+ export type PostgresListenerConnection = {
14
+ connect(): Promise<unknown>
15
+ query(text: string): Promise<unknown>
16
+ end(): Promise<void>
17
+ onNotification(
18
+ listener: (message: {
19
+ channel: string
20
+ payload?: string | undefined
21
+ }) => void,
22
+ ): void
23
+ onError(listener: (error: Error) => void): void
24
+ onEnd(listener: () => void): void
25
+ }
26
+
27
+ type Watcher = {
28
+ notify(payload: string): void
29
+ resolve(): void
30
+ reject(error: unknown): void
31
+ }
32
+
33
+ type WatchOptions = { channel: string; notify: (payload: string) => void }
34
+
35
+ export function postgresNotifications(
36
+ createConnection: () => Promise<PostgresListenerConnection>,
37
+ ): {
38
+ watch(options: WatchOptions): { ready: Promise<void>; stop(): void }
39
+ close(): Promise<void>
40
+ } {
41
+ const channels = new Map<
42
+ string,
43
+ { listening: boolean; ready?: Promise<unknown>; watchers: Set<Watcher> }
44
+ >()
45
+ const ending = new Set<Promise<void>>()
46
+ const end = (connection: PostgresListenerConnection | undefined): void => {
47
+ if (!connection) return
48
+ const pending = connection
49
+ .end()
50
+ .catch(() => {})
51
+ .finally(() => {
52
+ ending.delete(pending)
53
+ })
54
+ ending.add(pending)
55
+ }
56
+ let current: PostgresListenerConnection | undefined
57
+ let task: Promise<void> | undefined
58
+ let retry: Sleeper | undefined
59
+ let delay = 100
60
+ let connectedAt = 0
61
+ let closed = false
62
+ const queues = new WeakMap<PostgresListenerConnection, Promise<unknown>>()
63
+ const query = ({
64
+ connection,
65
+ text,
66
+ }: {
67
+ connection: PostgresListenerConnection
68
+ text: string
69
+ }): Promise<unknown> => {
70
+ const result = (queues.get(connection) ?? Promise.resolve()).then(() => {
71
+ if (current !== connection)
72
+ throw new Error('Postgres listener disconnected')
73
+ return connection.query(text)
74
+ })
75
+ queues.set(
76
+ connection,
77
+ result.catch(() => {}),
78
+ )
79
+ return result
80
+ }
81
+
82
+ const fail = ({
83
+ connection,
84
+ error,
85
+ }: {
86
+ connection: PostgresListenerConnection | undefined
87
+ error: unknown
88
+ }): void => {
89
+ if (connection !== current) return
90
+ current = undefined
91
+ end(connection)
92
+ for (const entry of channels.values()) {
93
+ entry.listening = false
94
+ for (const watcher of entry.watchers) watcher.reject(error)
95
+ }
96
+ if (closed || channels.size === 0 || retry) return
97
+ if (Date.now() - connectedAt >= NOTIFY_TIMINGS.safetyReadMs) delay = 100
98
+ retry = defaultSleep(delay)
99
+ delay = Math.min(delay * 2, NOTIFY_TIMINGS.safetyReadMs)
100
+ void retry.promise.then(() => {
101
+ retry = undefined
102
+ reconcile()
103
+ return undefined
104
+ })
105
+ }
106
+
107
+ const reconcile = (): void => {
108
+ if (closed || task || retry || channels.size === 0) return
109
+ let attempt: PostgresListenerConnection | undefined
110
+ task = (async () => {
111
+ if (!current) {
112
+ connectedAt = Date.now()
113
+ const connection = await createConnection()
114
+ if (closed || channels.size === 0) {
115
+ await connection.end()
116
+ return
117
+ }
118
+ current = connection
119
+ attempt = connection
120
+ connectedAt = Date.now()
121
+ connection.onNotification(({ channel, payload }) => {
122
+ if (current !== connection) return
123
+ for (const watcher of channels.get(channel)?.watchers ?? [])
124
+ watcher.notify(payload ?? '')
125
+ })
126
+ connection.onError((error) => fail({ connection, error }))
127
+ connection.onEnd(() =>
128
+ fail({
129
+ connection,
130
+ error: new Error('Postgres listener disconnected'),
131
+ }),
132
+ )
133
+ await connection.connect()
134
+ if (current !== connection) return
135
+ }
136
+ const connection = current
137
+ attempt = connection
138
+ for (const [channel, entry] of channels) {
139
+ if (entry.listening) continue
140
+ entry.listening = true
141
+ // oxlint-disable-next-line no-await-in-loop -- one session owns the LISTEN commands
142
+ entry.ready = query({
143
+ connection,
144
+ text: `LISTEN "${channel.replaceAll('"', '""')}"`,
145
+ })
146
+ // oxlint-disable-next-line no-await-in-loop -- wait for subscription registration
147
+ await entry.ready
148
+ if (current !== connection) return
149
+ for (const watcher of entry.watchers) {
150
+ watcher.resolve()
151
+ watcher.notify('')
152
+ }
153
+ }
154
+ })()
155
+ .catch((error: unknown) => {
156
+ if (attempt === current) fail({ connection: attempt, error })
157
+ })
158
+ .finally(() => {
159
+ task = undefined
160
+ if ([...channels.values()].some((entry) => !entry.listening))
161
+ reconcile()
162
+ })
163
+ }
164
+
165
+ const manager = {
166
+ watch({ channel, notify }: WatchOptions) {
167
+ let resolve!: () => void
168
+ let reject!: (error: unknown) => void
169
+ const ready = new Promise<void>((yes, no) => {
170
+ resolve = yes
171
+ reject = no
172
+ })
173
+ const watcher = { notify, resolve, reject }
174
+ let entry = channels.get(channel)
175
+ if (!entry) {
176
+ entry = { listening: false, watchers: new Set() }
177
+ channels.set(channel, entry)
178
+ }
179
+ entry.watchers.add(watcher)
180
+ if (entry.listening) void entry.ready?.then(resolve, reject)
181
+ reconcile()
182
+ let stopped = false
183
+ return {
184
+ ready,
185
+ stop() {
186
+ if (stopped) return
187
+ stopped = true
188
+ resolve()
189
+ entry.watchers.delete(watcher)
190
+ if (entry.watchers.size > 0) return
191
+ channels.delete(channel)
192
+ if (channels.size === 0) {
193
+ retry?.cancel()
194
+ const connection = current
195
+ current = undefined
196
+ end(connection)
197
+ } else if (entry.listening) {
198
+ const connection = current
199
+ if (connection)
200
+ void query({
201
+ connection,
202
+ text: `UNLISTEN "${channel.replaceAll('"', '""')}"`,
203
+ }).catch((error: unknown) => fail({ connection, error }))
204
+ }
205
+ },
206
+ }
207
+ },
208
+ async close() {
209
+ closed = true
210
+ retry?.cancel()
211
+ for (const entry of channels.values()) {
212
+ for (const watcher of entry.watchers) watcher.resolve()
213
+ }
214
+ channels.clear()
215
+ const connection = current
216
+ current = undefined
217
+ end(connection)
218
+ await Promise.all(ending)
219
+ },
220
+ }
221
+ return manager
222
+ }
223
+
224
+ export const POSTGRES_NOTIFICATIONS_SQL = `
225
+ create or replace function a2_notify_events() returns trigger language plpgsql as $$
226
+ begin
227
+ perform pg_notify('a2_events_' || md5(new.session_id), '');
228
+ return null;
229
+ end $$;
230
+ create or replace function a2_notify_presence() returns trigger language plpgsql as $$
231
+ begin
232
+ perform pg_notify('a2_presence_' || md5(case when TG_OP = 'DELETE' then old.ns else new.ns end),
233
+ coalesce(current_setting('a2.presence_source', true), ''));
234
+ return null;
235
+ end $$;
236
+ do $$ begin
237
+ if not exists (select 1 from pg_trigger where tgname = 'a2_notify_events' and tgrelid = 'a2_events'::regclass) then
238
+ create trigger a2_notify_events after insert on a2_events for each row execute function a2_notify_events();
239
+ end if;
240
+ if not exists (select 1 from pg_trigger where tgname = 'a2_notify_presence' and tgrelid = 'a2_presence'::regclass) then
241
+ create trigger a2_notify_presence after insert or update or delete on a2_presence for each row execute function a2_notify_presence();
242
+ end if;
243
+ end $$;
244
+ `
@@ -0,0 +1,65 @@
1
+ import type { Pool } from 'pg'
2
+ import { platformWaitUntil } from './platform.ts'
3
+ import type { PostgresClient } from './store-postgres.ts'
4
+
5
+ export function postgresPoolClient(pool: Pool): PostgresClient {
6
+ let timer: ReturnType<typeof setTimeout> | undefined
7
+ let resolve: (() => void) | undefined
8
+ const finish = () => {
9
+ clearTimeout(timer)
10
+ timer = undefined
11
+ resolve?.()
12
+ resolve = undefined
13
+ }
14
+ const keepAlive = () => {
15
+ const idleTimeout = pool.options.idleTimeoutMillis
16
+ if (
17
+ pool.ending ||
18
+ pool.idleCount === 0 ||
19
+ typeof idleTimeout !== 'number' ||
20
+ !Number.isFinite(idleTimeout) ||
21
+ idleTimeout <= 0
22
+ )
23
+ return
24
+ finish()
25
+ const pending = new Promise<void>((done) => {
26
+ resolve = done
27
+ timer = setTimeout(finish, idleTimeout + 100)
28
+ timer.unref()
29
+ })
30
+ platformWaitUntil(pending)
31
+ }
32
+ pool.on('remove', () => {
33
+ if (pool.totalCount === 0) finish()
34
+ })
35
+ return {
36
+ async query(text, values) {
37
+ try {
38
+ return await pool.query<Record<string, unknown>>(text, values)
39
+ } finally {
40
+ keepAlive()
41
+ }
42
+ },
43
+ async connect() {
44
+ const client = await pool.connect()
45
+ return {
46
+ query: (text, values) =>
47
+ client.query<Record<string, unknown>>(text, values),
48
+ release() {
49
+ try {
50
+ client.release()
51
+ } finally {
52
+ keepAlive()
53
+ }
54
+ },
55
+ }
56
+ },
57
+ async end() {
58
+ try {
59
+ await pool.end()
60
+ } finally {
61
+ finish()
62
+ }
63
+ },
64
+ }
65
+ }
@@ -0,0 +1,185 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { PostgresClient } from './store-postgres.ts'
3
+ import type { postgresNotifications } from './postgres-notifications.ts'
4
+
5
+ export type PostgresNotifications = ReturnType<typeof postgresNotifications>
6
+ export type ResourceLease<T> = { value: T; release(): Promise<void> }
7
+ type Entry<T> = { value: T; refs: number }
8
+ type Cache<K, T> = {
9
+ get(key: K): Entry<T> | undefined
10
+ set(key: K, entry: Entry<T>): unknown
11
+ delete(key: K): boolean
12
+ }
13
+
14
+ export type PostgresQueries = {
15
+ client: PostgresClient
16
+ raw: PostgresClient
17
+ exclusive<T>(fn: () => Promise<T>): Promise<T>
18
+ initialize(options: {
19
+ schema: string
20
+ run: () => Promise<void>
21
+ }): Promise<void>
22
+ close(): Promise<void>
23
+ }
24
+
25
+ type Registry = {
26
+ pools: Map<string, Entry<PostgresQueries>>
27
+ injected: WeakMap<PostgresClient, Entry<PostgresQueries>>
28
+ listeners: Map<string, Entry<PostgresNotifications>>
29
+ identities: WeakMap<object, number>
30
+ nextIdentity: number
31
+ }
32
+
33
+ const registryKey = Symbol.for('experimental-a2.postgres-resources.v3')
34
+ const registry = (): Registry => {
35
+ let value = Reflect.get(globalThis, registryKey) as Registry | undefined
36
+ if (!value) {
37
+ value = {
38
+ pools: new Map(),
39
+ injected: new WeakMap(),
40
+ listeners: new Map(),
41
+ identities: new WeakMap(),
42
+ nextIdentity: 0,
43
+ }
44
+ Reflect.set(globalThis, registryKey, value)
45
+ }
46
+ return value
47
+ }
48
+
49
+ const acquire = <K, T extends { close(): Promise<void> }>({
50
+ cache,
51
+ key,
52
+ create,
53
+ }: {
54
+ cache: Cache<K, T>
55
+ key: K
56
+ create: () => T
57
+ }): ResourceLease<T> => {
58
+ let entry = cache.get(key)
59
+ if (!entry) {
60
+ entry = { value: create(), refs: 0 }
61
+ cache.set(key, entry)
62
+ }
63
+ entry.refs += 1
64
+ const held = entry
65
+ let released = false
66
+ const lease = {
67
+ value: entry.value,
68
+ async release() {
69
+ if (released) return
70
+ released = true
71
+ held.refs -= 1
72
+ if (held.refs !== 0) return
73
+ if (cache.get(key) === held) cache.delete(key)
74
+ await held.value.close()
75
+ },
76
+ }
77
+ return lease
78
+ }
79
+
80
+ export function postgresConnectionKey({
81
+ connectionString,
82
+ defaults,
83
+ }: {
84
+ connectionString: string
85
+ defaults: object
86
+ }): string {
87
+ const shared = registry()
88
+ const visited = new Set<object>()
89
+ const identity = (value: object): number => {
90
+ let id = shared.identities.get(value)
91
+ if (id === undefined) {
92
+ id = ++shared.nextIdentity
93
+ shared.identities.set(value, id)
94
+ }
95
+ return id
96
+ }
97
+ const encode = (value: unknown): unknown => {
98
+ if (typeof value === 'function') return ['reference', identity(value)]
99
+ if (value === null || typeof value !== 'object')
100
+ return [typeof value, String(value)]
101
+ if (Buffer.isBuffer(value)) return ['buffer', value.toString('base64')]
102
+ const prototype: unknown = Object.getPrototypeOf(value)
103
+ if (
104
+ visited.has(value) ||
105
+ (prototype !== Object.prototype &&
106
+ prototype !== null &&
107
+ !Array.isArray(value))
108
+ )
109
+ return ['reference', identity(value)]
110
+ visited.add(value)
111
+ const result = [
112
+ Array.isArray(value) ? 'array' : 'object',
113
+ Object.getOwnPropertyNames(value)
114
+ .toSorted()
115
+ .map((key) => [key, encode(Reflect.get(value, key))]),
116
+ ]
117
+ visited.delete(value)
118
+ return result
119
+ }
120
+ const environment = Object.keys(process.env)
121
+ .filter((key) => key.startsWith('PG'))
122
+ .toSorted()
123
+ .map((key) => [key, process.env[key]])
124
+ return createHash('sha256')
125
+ .update(JSON.stringify([connectionString, environment, encode(defaults)]))
126
+ .digest('hex')
127
+ }
128
+
129
+ const queries = (raw: PostgresClient): PostgresQueries => {
130
+ let pending: Promise<unknown> = Promise.resolve()
131
+ const schemas = new Map<string, Promise<void>>()
132
+ const exclusive = <T>(fn: () => Promise<T>): Promise<T> => {
133
+ const result = pending.then(fn)
134
+ pending = result.catch(() => {})
135
+ return result
136
+ }
137
+ return {
138
+ raw,
139
+ client: raw.connect
140
+ ? raw
141
+ : { query: (text, values) => exclusive(() => raw.query(text, values)) },
142
+ exclusive,
143
+ initialize({ schema, run }) {
144
+ const existing = schemas.get(schema)
145
+ if (existing) return existing
146
+ const attempt = run()
147
+ schemas.set(schema, attempt)
148
+ void attempt.catch(() => {
149
+ if (schemas.get(schema) === attempt) schemas.delete(schema)
150
+ })
151
+ return attempt
152
+ },
153
+ async close() {
154
+ await pending
155
+ await raw.end?.()
156
+ },
157
+ }
158
+ }
159
+
160
+ export const sharePostgresPool = ({
161
+ key,
162
+ create,
163
+ }: {
164
+ key: string
165
+ create: () => PostgresClient
166
+ }): ResourceLease<PostgresQueries> =>
167
+ acquire({ cache: registry().pools, key, create: () => queries(create()) })
168
+
169
+ export const sharePostgresClient = (
170
+ client: PostgresClient,
171
+ ): ResourceLease<PostgresQueries> =>
172
+ acquire({
173
+ cache: registry().injected,
174
+ key: client,
175
+ create: () => queries(client),
176
+ })
177
+
178
+ export const sharePostgresListener = ({
179
+ key,
180
+ create,
181
+ }: {
182
+ key: string
183
+ create: () => PostgresNotifications
184
+ }): ResourceLease<PostgresNotifications> =>
185
+ acquire({ cache: registry().listeners, key, create })