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.
- package/CHANGELOG.md +39 -0
- package/dist/actor-D_54lz_1.d.ts +310 -0
- package/dist/actor-D_54lz_1.d.ts.map +1 -0
- package/dist/actor-client.d.ts +13 -4
- package/dist/actor-client.d.ts.map +1 -1
- package/dist/actor-client.js +63 -7
- package/dist/actor-client.js.map +1 -1
- package/dist/actor-react.d.ts +5 -4
- package/dist/actor-react.d.ts.map +1 -1
- package/dist/actor-react.js +16 -2
- package/dist/actor-react.js.map +1 -1
- package/dist/actor.d.ts +2 -176
- package/dist/actor.js +13 -2
- package/dist/actor.js.map +1 -1
- package/dist/ai-server.d.ts +1 -1
- package/dist/ai-server.js +2 -2
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/client-Bf6uSEAk.js +1342 -0
- package/dist/client-Bf6uSEAk.js.map +1 -0
- package/dist/client-P_NNNRM-.d.ts +243 -0
- package/dist/client-P_NNNRM-.d.ts.map +1 -0
- package/dist/client.d.ts +2 -208
- package/dist/client.js +2 -1206
- package/dist/errors-DCk6ch5n.js.map +1 -1
- package/dist/errors-DvhSXnxk.d.ts +28 -0
- package/dist/errors-DvhSXnxk.d.ts.map +1 -0
- package/dist/index.d.ts +3 -35
- package/dist/{internal-DRXJ56EI.js → internal-Dq2qYxou.js} +2 -2
- package/dist/{internal-DRXJ56EI.js.map → internal-Dq2qYxou.js.map} +1 -1
- package/dist/platform-B4TnJtWu.js +34 -0
- package/dist/platform-B4TnJtWu.js.map +1 -0
- package/dist/react.d.ts +3 -1
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +2 -1
- package/dist/react.js.map +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +3 -2
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +2 -2
- package/dist/{server-DlLyvaSH.js → server-Dkz2a84E.js} +165 -62
- package/dist/server-Dkz2a84E.js.map +1 -0
- package/dist/{server-DgCrSuhB.d.ts → server-DwPrMqHB.d.ts} +2 -2
- package/dist/{server-DgCrSuhB.d.ts.map → server-DwPrMqHB.d.ts.map} +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{store-DGHeBtIQ.d.ts → store-DtDOWLSn.d.ts} +4 -5
- package/dist/{store-DGHeBtIQ.d.ts.map → store-DtDOWLSn.d.ts.map} +1 -1
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +1 -1
- package/dist/{store-polling-6DW7F1DT.js → store-polling-CmxUbV93.js} +56 -7
- package/dist/store-polling-CmxUbV93.js.map +1 -0
- package/dist/store-postgres.d.ts +6 -4
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +701 -53
- package/dist/store-postgres.js.map +1 -1
- package/dist/store-presence-polling-C7-XZyW9.js +94 -0
- package/dist/store-presence-polling-C7-XZyW9.js.map +1 -0
- package/dist/store-redis-http.d.ts +2 -2
- package/dist/store-redis-http.d.ts.map +1 -1
- package/dist/store-redis-http.js +184 -38
- package/dist/store-redis-http.js.map +1 -1
- package/dist/{store-redis-core-z-ykbyMg.js → store-redis-notify-BUCyXOn0.js} +491 -27
- package/dist/store-redis-notify-BUCyXOn0.js.map +1 -0
- package/dist/store-redis.d.ts +5 -13
- package/dist/store-redis.d.ts.map +1 -1
- package/dist/store-redis.js +27 -271
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +1 -1
- package/dist/{wire--yji6mO3.js → wire-BO5wWCb1.js} +18 -16
- package/dist/wire-BO5wWCb1.js.map +1 -0
- package/docs/actors/04-routes.mdx +40 -8
- package/docs/guides/03-react.mdx +52 -10
- package/docs/guides/05-production.mdx +54 -5
- package/docs/guides/09-presence.mdx +12 -7
- package/docs/guides/10-transports.mdx +15 -2
- package/docs/reference/01-api.mdx +60 -16
- package/docs/reference/02-errors.mdx +45 -11
- package/examples/playground/package.json +1 -1
- package/package.json +1 -1
- package/src/actor-client.ts +114 -14
- package/src/actor-react.ts +18 -3
- package/src/actor.ts +16 -1
- package/src/client-errors.ts +51 -0
- package/src/client.ts +253 -96
- package/src/errors.ts +4 -9
- package/src/index.ts +1 -1
- package/src/internal.ts +1 -1
- package/src/postgres-notification-scope.ts +134 -0
- package/src/postgres-notifications.ts +244 -0
- package/src/postgres-pool.ts +65 -0
- package/src/postgres-resources.ts +185 -0
- package/src/presence-recovery.ts +120 -0
- package/src/react.ts +3 -0
- package/src/redis-http-subscriptions.ts +199 -0
- package/src/server.ts +146 -19
- package/src/session-socket.ts +25 -7
- package/src/sse.ts +17 -0
- package/src/store-polling.ts +32 -12
- package/src/store-postgres.ts +292 -75
- package/src/store-presence-polling.ts +125 -0
- package/src/store-redis-core.ts +37 -30
- package/src/store-redis-http.ts +51 -45
- package/src/store-redis-notify.ts +464 -0
- package/src/store-redis.ts +9 -364
- package/src/store.ts +3 -4
- package/src/stream-activity.ts +32 -0
- package/src/wire.ts +39 -15
- package/dist/actor-shared-USo5MyuF.d.ts +0 -136
- package/dist/actor-shared-USo5MyuF.d.ts.map +0 -1
- package/dist/actor.d.ts.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/server-DlLyvaSH.js.map +0 -1
- package/dist/store-polling-6DW7F1DT.js.map +0 -1
- package/dist/store-redis-core-z-ykbyMg.js.map +0 -1
- package/dist/wire--yji6mO3.js.map +0 -1
package/src/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
|
@@ -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 })
|