orez 0.1.36 → 0.1.38
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/dist/cli-entry.js +0 -0
- package/dist/cli.js +7 -1
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -11
- package/dist/index.js.map +1 -1
- package/dist/pg-proxy.d.ts.map +1 -1
- package/dist/pg-proxy.js +8 -4
- package/dist/pg-proxy.js.map +1 -1
- package/dist/pglite-manager.d.ts +12 -0
- package/dist/pglite-manager.d.ts.map +1 -1
- package/dist/pglite-manager.js +81 -0
- package/dist/pglite-manager.js.map +1 -1
- package/dist/recovery.js +2 -2
- package/dist/recovery.js.map +1 -1
- package/dist/replication/change-tracker.js +9 -9
- package/dist/replication/change-tracker.js.map +1 -1
- package/dist/replication/handler.d.ts +12 -0
- package/dist/replication/handler.d.ts.map +1 -1
- package/dist/replication/handler.js +34 -6
- package/dist/replication/handler.js.map +1 -1
- package/dist/worker/browser-build-config.d.ts +59 -0
- package/dist/worker/browser-build-config.d.ts.map +1 -0
- package/dist/worker/browser-build-config.js +101 -0
- package/dist/worker/browser-build-config.js.map +1 -0
- package/dist/worker/browser-embed.d.ts +58 -0
- package/dist/worker/browser-embed.d.ts.map +1 -0
- package/dist/worker/browser-embed.js +195 -0
- package/dist/worker/browser-embed.js.map +1 -0
- package/dist/worker/cf-patches.d.ts +20 -0
- package/dist/worker/cf-patches.d.ts.map +1 -0
- package/dist/worker/cf-patches.js +94 -0
- package/dist/worker/cf-patches.js.map +1 -0
- package/dist/worker/index.d.ts +12 -0
- package/dist/worker/index.d.ts.map +1 -0
- package/dist/worker/index.js +105 -0
- package/dist/worker/index.js.map +1 -0
- package/dist/worker/shims/fastify.d.ts +80 -0
- package/dist/worker/shims/fastify.d.ts.map +1 -0
- package/dist/worker/shims/fastify.js +223 -0
- package/dist/worker/shims/fastify.js.map +1 -0
- package/dist/worker/shims/http-service.d.ts +104 -0
- package/dist/worker/shims/http-service.d.ts.map +1 -0
- package/dist/worker/shims/http-service.js +198 -0
- package/dist/worker/shims/http-service.js.map +1 -0
- package/dist/worker/shims/node-stub.d.ts +147 -0
- package/dist/worker/shims/node-stub.d.ts.map +1 -0
- package/dist/worker/shims/node-stub.js +204 -0
- package/dist/worker/shims/node-stub.js.map +1 -0
- package/dist/worker/shims/postgres.d.ts +115 -0
- package/dist/worker/shims/postgres.d.ts.map +1 -0
- package/dist/worker/shims/postgres.js +1181 -0
- package/dist/worker/shims/postgres.js.map +1 -0
- package/dist/worker/shims/sqlite-browser.d.ts +54 -0
- package/dist/worker/shims/sqlite-browser.d.ts.map +1 -0
- package/dist/worker/shims/sqlite-browser.js +144 -0
- package/dist/worker/shims/sqlite-browser.js.map +1 -0
- package/dist/worker/shims/sqlite.d.ts +126 -0
- package/dist/worker/shims/sqlite.d.ts.map +1 -0
- package/dist/worker/shims/sqlite.js +599 -0
- package/dist/worker/shims/sqlite.js.map +1 -0
- package/dist/worker/shims/stream-browser.d.ts +9 -0
- package/dist/worker/shims/stream-browser.d.ts.map +1 -0
- package/dist/worker/shims/stream-browser.js +13 -0
- package/dist/worker/shims/stream-browser.js.map +1 -0
- package/dist/worker/shims/ws-browser.d.ts +50 -0
- package/dist/worker/shims/ws-browser.d.ts.map +1 -0
- package/dist/worker/shims/ws-browser.js +105 -0
- package/dist/worker/shims/ws-browser.js.map +1 -0
- package/dist/worker/shims/ws.d.ts +62 -0
- package/dist/worker/shims/ws.d.ts.map +1 -0
- package/dist/worker/shims/ws.js +310 -0
- package/dist/worker/shims/ws.js.map +1 -0
- package/dist/worker/types.d.ts +57 -0
- package/dist/worker/types.d.ts.map +1 -0
- package/dist/worker/types.js +9 -0
- package/dist/worker/types.js.map +1 -0
- package/dist/worker/zero-cache-embed-cf.d.ts +63 -0
- package/dist/worker/zero-cache-embed-cf.d.ts.map +1 -0
- package/dist/worker/zero-cache-embed-cf.js +268 -0
- package/dist/worker/zero-cache-embed-cf.js.map +1 -0
- package/dist/worker/zero-cache-embed.d.ts +66 -0
- package/dist/worker/zero-cache-embed.d.ts.map +1 -0
- package/dist/worker/zero-cache-embed.js +200 -0
- package/dist/worker/zero-cache-embed.js.map +1 -0
- package/package.json +62 -3
- package/src/cli-entry.ts +0 -0
- package/src/cli.ts +8 -1
- package/src/config.ts +2 -0
- package/src/index.ts +15 -10
- package/src/integration/integration.test.ts +1 -1
- package/src/integration/restore-live-stress.test.ts +2 -2
- package/src/pg-proxy.ts +9 -4
- package/src/pglite-manager.ts +111 -0
- package/src/recovery.ts +2 -2
- package/src/replication/change-tracker.test.ts +1 -1
- package/src/replication/change-tracker.ts +9 -9
- package/src/replication/handler.test.ts +37 -0
- package/src/replication/handler.ts +46 -6
- package/src/wasm-sqlite.test.ts +2 -1
- package/src/worker/browser-build-config.test.ts +59 -0
- package/src/worker/browser-build-config.ts +105 -0
- package/src/worker/browser-embed.ts +306 -0
- package/src/worker/cf-patches.ts +114 -0
- package/src/worker/embed-integration.test.ts +321 -0
- package/src/worker/index.ts +138 -0
- package/src/worker/shims/fastify.test.ts +255 -0
- package/src/worker/shims/fastify.ts +292 -0
- package/src/worker/shims/http-service.test.ts +355 -0
- package/src/worker/shims/http-service.ts +293 -0
- package/src/worker/shims/node-stub.ts +223 -0
- package/src/worker/shims/postgres.test.ts +364 -0
- package/src/worker/shims/postgres.ts +1434 -0
- package/src/worker/shims/sqlite-browser.test.ts +233 -0
- package/src/worker/shims/sqlite-browser.ts +178 -0
- package/src/worker/shims/sqlite.test.ts +641 -0
- package/src/worker/shims/sqlite.ts +731 -0
- package/src/worker/shims/ws-browser.test.ts +184 -0
- package/src/worker/shims/ws-browser.ts +125 -0
- package/src/worker/shims/ws.test.ts +288 -0
- package/src/worker/shims/ws.ts +367 -0
- package/src/worker/types.ts +75 -0
- package/src/worker/worker-integration.test.ts +223 -0
- package/src/worker/worker.test.ts +136 -0
- package/src/worker/zero-cache-embed-cf.ts +367 -0
- package/src/worker/zero-cache-embed.ts +277 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// NOTE THIS IS NOT OREZ NODE THIS IS NOT A GOOD REFERENCE BECAUSE ITS OUR EARLY GUESS AT WHAT COULD WORK
|
|
2
|
+
// DO NOT STUDY THIS, THE OTHER STUFF IN SRC IS WHERE YOU EANT TO LOOK
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ws (websocket) shim for cloudflare workers.
|
|
6
|
+
*
|
|
7
|
+
* wraps CF Workers WebSocket (from WebSocketPair) to implement the
|
|
8
|
+
* ws npm package API that zero-cache uses. enables bundler aliasing
|
|
9
|
+
* so zero-cache's WebSocket handling works with CF durable WebSockets.
|
|
10
|
+
*
|
|
11
|
+
* usage with bundler alias:
|
|
12
|
+
* alias: { 'ws': './src/worker/shims/ws.js' }
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import EventEmitter from 'node:events'
|
|
16
|
+
import { Duplex } from 'node:stream'
|
|
17
|
+
|
|
18
|
+
// -- readyState constants --
|
|
19
|
+
const CONNECTING = 0
|
|
20
|
+
const OPEN = 1
|
|
21
|
+
const CLOSING = 2
|
|
22
|
+
const CLOSED = 3
|
|
23
|
+
|
|
24
|
+
// -- CF WebSocket interface (minimal) --
|
|
25
|
+
interface CFWebSocket {
|
|
26
|
+
send(data: string | ArrayBuffer | ArrayBufferView): void
|
|
27
|
+
close(code?: number, reason?: string): void
|
|
28
|
+
addEventListener(type: string, handler: (event: any) => void): void
|
|
29
|
+
removeEventListener(type: string, handler: (event: any) => void): void
|
|
30
|
+
readyState: number
|
|
31
|
+
accept?(): void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// -- WebSocket shim --
|
|
35
|
+
// wraps a CF WebSocket to match the ws package WebSocket API
|
|
36
|
+
|
|
37
|
+
class WebSocket extends EventEmitter {
|
|
38
|
+
static readonly CONNECTING = CONNECTING
|
|
39
|
+
static readonly OPEN = OPEN
|
|
40
|
+
static readonly CLOSING = CLOSING
|
|
41
|
+
static readonly CLOSED = CLOSED
|
|
42
|
+
|
|
43
|
+
readonly CONNECTING = CONNECTING
|
|
44
|
+
readonly OPEN = OPEN
|
|
45
|
+
readonly CLOSING = CLOSING
|
|
46
|
+
readonly CLOSED = CLOSED
|
|
47
|
+
|
|
48
|
+
#ws!: CFWebSocket
|
|
49
|
+
#url: string
|
|
50
|
+
#listeners = new Map<string, (event: any) => void>()
|
|
51
|
+
|
|
52
|
+
constructor(urlOrSocket: string | CFWebSocket, _protocols?: unknown, _opts?: unknown) {
|
|
53
|
+
super()
|
|
54
|
+
|
|
55
|
+
if (typeof urlOrSocket === 'string') {
|
|
56
|
+
this.#url = urlOrSocket
|
|
57
|
+
// check for in-process connections (fastify shim uses port 0 or 1)
|
|
58
|
+
const parsedUrl = new URL(urlOrSocket, 'http://localhost')
|
|
59
|
+
const isInProcess =
|
|
60
|
+
parsedUrl.port === '0' ||
|
|
61
|
+
parsedUrl.port === '1' ||
|
|
62
|
+
parsedUrl.hostname === 'localhost'
|
|
63
|
+
|
|
64
|
+
if (isInProcess) {
|
|
65
|
+
// in-process: connect via fastify server's handoff mechanism
|
|
66
|
+
const fastifyInstance = (globalThis as any).__orez_fastify_instance
|
|
67
|
+
if (fastifyInstance?.server) {
|
|
68
|
+
// create paired message channels for bidirectional communication
|
|
69
|
+
// the client-side WS (this) and serverWs are cross-linked so
|
|
70
|
+
// ping/pong, messages, and close propagate between them
|
|
71
|
+
const clientSide = this
|
|
72
|
+
const serverWs: any = {
|
|
73
|
+
readyState: 1,
|
|
74
|
+
_listeners: {} as Record<string, Function[]>,
|
|
75
|
+
send: (data: string | ArrayBuffer) => {
|
|
76
|
+
// deliver to client side
|
|
77
|
+
queueMicrotask(() => clientSide.emit('message', data))
|
|
78
|
+
},
|
|
79
|
+
close: (code?: number, reason?: string) => {
|
|
80
|
+
serverWs.readyState = 3
|
|
81
|
+
queueMicrotask(() => clientSide.emit('close', code || 1000, reason || ''))
|
|
82
|
+
},
|
|
83
|
+
ping: () => {
|
|
84
|
+
// ping from server → deliver 'ping' event to client
|
|
85
|
+
// (expectPingsForLiveness listens for 'ping', not 'pong')
|
|
86
|
+
queueMicrotask(() => clientSide.emit('ping'))
|
|
87
|
+
},
|
|
88
|
+
addEventListener: (type: string, handler: Function) => {
|
|
89
|
+
if (!serverWs._listeners[type]) serverWs._listeners[type] = []
|
|
90
|
+
serverWs._listeners[type].push(handler)
|
|
91
|
+
},
|
|
92
|
+
removeEventListener: (type: string, handler: Function) => {
|
|
93
|
+
const arr = serverWs._listeners[type]
|
|
94
|
+
if (arr) {
|
|
95
|
+
const idx = arr.indexOf(handler)
|
|
96
|
+
if (idx >= 0) arr.splice(idx, 1)
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.#ws = {
|
|
102
|
+
accept: () => {},
|
|
103
|
+
send: (data: string | ArrayBuffer) => {
|
|
104
|
+
// deliver to server side
|
|
105
|
+
const handlers = serverWs._listeners['message'] || []
|
|
106
|
+
for (const h of handlers) h({ data })
|
|
107
|
+
},
|
|
108
|
+
close: (code?: number, reason?: string) => {
|
|
109
|
+
const handlers = serverWs._listeners['close'] || []
|
|
110
|
+
for (const h of handlers) h({ code, reason })
|
|
111
|
+
},
|
|
112
|
+
addEventListener: () => {},
|
|
113
|
+
removeEventListener: () => {},
|
|
114
|
+
get readyState() {
|
|
115
|
+
return 1
|
|
116
|
+
},
|
|
117
|
+
} as CFWebSocket
|
|
118
|
+
|
|
119
|
+
// emit handoff to fastify server
|
|
120
|
+
const path = parsedUrl.pathname + parsedUrl.search
|
|
121
|
+
queueMicrotask(() => {
|
|
122
|
+
fastifyInstance.server.emit(
|
|
123
|
+
'message',
|
|
124
|
+
[
|
|
125
|
+
'handoff',
|
|
126
|
+
{
|
|
127
|
+
message: { url: path, headers: {}, method: 'GET' },
|
|
128
|
+
head: new Uint8Array(0),
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
serverWs
|
|
132
|
+
)
|
|
133
|
+
this.emit('open')
|
|
134
|
+
})
|
|
135
|
+
} else {
|
|
136
|
+
// no fastify instance — emit close immediately
|
|
137
|
+
queueMicrotask(() => this.emit('close', 1006, 'no fastify server'))
|
|
138
|
+
}
|
|
139
|
+
} else if (typeof globalThis.WebSocket === 'function') {
|
|
140
|
+
// real outbound WebSocket for external connections
|
|
141
|
+
const nativeWs = new globalThis.WebSocket(urlOrSocket) as any
|
|
142
|
+
this.#ws = {
|
|
143
|
+
accept: () => {},
|
|
144
|
+
send: (data: string | ArrayBuffer) => nativeWs.send(data),
|
|
145
|
+
close: (code?: number, reason?: string) => nativeWs.close(code, reason),
|
|
146
|
+
addEventListener: (type: string, handler: (event: any) => void) =>
|
|
147
|
+
nativeWs.addEventListener(type, handler),
|
|
148
|
+
removeEventListener: (type: string, handler: (event: any) => void) =>
|
|
149
|
+
nativeWs.removeEventListener(type, handler),
|
|
150
|
+
get readyState() {
|
|
151
|
+
return nativeWs.readyState
|
|
152
|
+
},
|
|
153
|
+
} as CFWebSocket
|
|
154
|
+
nativeWs.addEventListener('open', () => this.emit('open'))
|
|
155
|
+
nativeWs.addEventListener('message', (ev: MessageEvent) =>
|
|
156
|
+
this.emit('message', ev.data)
|
|
157
|
+
)
|
|
158
|
+
nativeWs.addEventListener('close', (ev: CloseEvent) =>
|
|
159
|
+
this.emit('close', ev.code, ev.reason)
|
|
160
|
+
)
|
|
161
|
+
nativeWs.addEventListener('error', (ev: Event) =>
|
|
162
|
+
this.emit('error', new Error('WebSocket error'))
|
|
163
|
+
)
|
|
164
|
+
} else {
|
|
165
|
+
throw new Error(
|
|
166
|
+
'ws shim: outbound WebSocket connections not yet supported. ' +
|
|
167
|
+
'use the CF Workers fetch API for outbound WebSocket.'
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
this.#ws = urlOrSocket
|
|
172
|
+
this.#url = ''
|
|
173
|
+
this.#setupListeners()
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
get url(): string {
|
|
178
|
+
return this.#url
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
get readyState(): number {
|
|
182
|
+
return this.#ws.readyState
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
send(
|
|
186
|
+
data: string | Buffer | ArrayBuffer | ArrayBufferView,
|
|
187
|
+
cb?: (err?: Error) => void
|
|
188
|
+
): void {
|
|
189
|
+
try {
|
|
190
|
+
if (typeof data === 'string') {
|
|
191
|
+
this.#ws.send(data)
|
|
192
|
+
} else if (Buffer.isBuffer(data)) {
|
|
193
|
+
this.#ws.send(new Uint8Array(data.buffer, data.byteOffset, data.byteLength))
|
|
194
|
+
} else if (data instanceof ArrayBuffer) {
|
|
195
|
+
this.#ws.send(data)
|
|
196
|
+
} else if (ArrayBuffer.isView(data)) {
|
|
197
|
+
this.#ws.send(new Uint8Array(data.buffer, data.byteOffset, data.byteLength))
|
|
198
|
+
} else {
|
|
199
|
+
this.#ws.send(String(data))
|
|
200
|
+
}
|
|
201
|
+
cb?.()
|
|
202
|
+
} catch (err) {
|
|
203
|
+
cb?.(err as Error)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
close(code?: number, reason?: string): void {
|
|
208
|
+
try {
|
|
209
|
+
this.#ws.close(code, reason)
|
|
210
|
+
} catch {
|
|
211
|
+
// socket may already be closed
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
terminate(): void {
|
|
216
|
+
// real ws.terminate() destroys the socket without a close frame.
|
|
217
|
+
// CF Workers don't expose raw socket destroy, so close with 1000.
|
|
218
|
+
this.close(1000)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
ping(_data?: unknown, _mask?: boolean, _cb?: () => void): void {
|
|
222
|
+
// forward ping to underlying socket if it supports it (in-process pairs)
|
|
223
|
+
if (typeof (this.#ws as any).ping === 'function') {
|
|
224
|
+
;(this.#ws as any).ping()
|
|
225
|
+
}
|
|
226
|
+
// also emit pong locally (CF WebSockets handle ping/pong at platform level)
|
|
227
|
+
this.emit('pong')
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// standard EventTarget-style addEventListener (used by Connection)
|
|
231
|
+
addEventListener(type: string, handler: (event: any) => void): void {
|
|
232
|
+
// wrap to emit EventEmitter-style
|
|
233
|
+
this.on(type, handler)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
removeEventListener(type: string, handler: (event: any) => void): void {
|
|
237
|
+
this.off(type, handler)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
#setupListeners(): void {
|
|
241
|
+
// match ws npm package event signatures:
|
|
242
|
+
// message: (data: Buffer|string, isBinary: boolean)
|
|
243
|
+
// close: (code: number, reason: string)
|
|
244
|
+
// error: (err: Error)
|
|
245
|
+
const onMessage = (event: any) => {
|
|
246
|
+
const data = event.data
|
|
247
|
+
this.emit('message', data, typeof data !== 'string')
|
|
248
|
+
}
|
|
249
|
+
const onClose = (event: any) => {
|
|
250
|
+
this.emit('close', event.code ?? 1000, event.reason ?? '')
|
|
251
|
+
}
|
|
252
|
+
const onError = (event: any) => {
|
|
253
|
+
this.emit('error', event.error ?? new Error(event.message ?? 'WebSocket error'))
|
|
254
|
+
}
|
|
255
|
+
const onOpen = () => {
|
|
256
|
+
this.emit('open')
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
this.#ws.addEventListener('message', onMessage)
|
|
260
|
+
this.#ws.addEventListener('close', onClose)
|
|
261
|
+
this.#ws.addEventListener('error', onError)
|
|
262
|
+
this.#ws.addEventListener('open', onOpen)
|
|
263
|
+
|
|
264
|
+
this.#listeners.set('message', onMessage)
|
|
265
|
+
this.#listeners.set('close', onClose)
|
|
266
|
+
this.#listeners.set('error', onError)
|
|
267
|
+
this.#listeners.set('open', onOpen)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// -- WebSocketServer shim --
|
|
272
|
+
// zero-cache uses WebSocketServer with { noServer: true } for handleUpgrade
|
|
273
|
+
|
|
274
|
+
class WebSocketServer extends EventEmitter {
|
|
275
|
+
constructor(_opts?: { noServer?: boolean }) {
|
|
276
|
+
super()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
close(cb?: (err?: Error) => void): void {
|
|
280
|
+
// no-op — browser embed has no real server to close
|
|
281
|
+
cb?.()
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* handle a WebSocket upgrade. on CF Workers the upgrade is already done
|
|
286
|
+
* (WebSocketPair), so this just wraps the CF WebSocket in our shim.
|
|
287
|
+
*
|
|
288
|
+
* @param message - the HTTP request (IncomingMessage-like object)
|
|
289
|
+
* @param socket - the underlying socket (CF WebSocket on CF Workers)
|
|
290
|
+
* @param head - upgrade head buffer
|
|
291
|
+
* @param callback - receives the wrapped WebSocket
|
|
292
|
+
*/
|
|
293
|
+
handleUpgrade(
|
|
294
|
+
_message: unknown,
|
|
295
|
+
socket: CFWebSocket | unknown,
|
|
296
|
+
_head: unknown,
|
|
297
|
+
callback: (ws: WebSocket) => void
|
|
298
|
+
): void {
|
|
299
|
+
// wrap the CF WebSocket in our shim
|
|
300
|
+
const ws = new WebSocket(socket as CFWebSocket)
|
|
301
|
+
callback(ws)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// -- createWebSocketStream --
|
|
306
|
+
// creates a Node.js Duplex stream from a WebSocket.
|
|
307
|
+
// used by zero-cache's Connection class for streaming messages.
|
|
308
|
+
|
|
309
|
+
function createWebSocketStream(
|
|
310
|
+
ws: WebSocket,
|
|
311
|
+
_opts?: { decodeStrings?: boolean }
|
|
312
|
+
): Duplex {
|
|
313
|
+
const duplex = new Duplex({
|
|
314
|
+
objectMode: false,
|
|
315
|
+
decodeStrings: false,
|
|
316
|
+
|
|
317
|
+
read() {
|
|
318
|
+
// data is pushed from ws message events
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
write(
|
|
322
|
+
chunk: Buffer | string,
|
|
323
|
+
_encoding: string,
|
|
324
|
+
callback: (err?: Error | null) => void
|
|
325
|
+
) {
|
|
326
|
+
try {
|
|
327
|
+
ws.send(typeof chunk === 'string' ? chunk : chunk.toString(), callback)
|
|
328
|
+
} catch (err) {
|
|
329
|
+
callback(err as Error)
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
destroy(err: Error | null, callback: (err?: Error | null) => void) {
|
|
334
|
+
ws.close()
|
|
335
|
+
callback(err)
|
|
336
|
+
},
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
// pipe ws messages into the readable side
|
|
340
|
+
ws.on('message', (event: any) => {
|
|
341
|
+
const data = event?.data ?? event
|
|
342
|
+
if (typeof data === 'string') {
|
|
343
|
+
duplex.push(data)
|
|
344
|
+
} else if (data instanceof ArrayBuffer) {
|
|
345
|
+
duplex.push(Buffer.from(data))
|
|
346
|
+
} else if (ArrayBuffer.isView(data)) {
|
|
347
|
+
duplex.push(Buffer.from(data.buffer, data.byteOffset, data.byteLength))
|
|
348
|
+
} else {
|
|
349
|
+
duplex.push(String(data))
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
ws.on('close', () => {
|
|
354
|
+
duplex.push(null) // signal end of readable
|
|
355
|
+
duplex.destroy()
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
ws.on('error', (event: any) => {
|
|
359
|
+
const err = event?.error ?? new Error(event?.message ?? 'WebSocket error')
|
|
360
|
+
duplex.destroy(err)
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
return duplex
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export default WebSocket
|
|
367
|
+
export { WebSocket, WebSocketServer, createWebSocketStream }
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* orez/worker types.
|
|
3
|
+
*
|
|
4
|
+
* interfaces for the embeddable orez worker that runs PGlite + change
|
|
5
|
+
* tracking without Node.js dependencies. designed for CF Workers/DO
|
|
6
|
+
* but usable in any JS runtime (browser, vitest, bun, deno).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Mutex } from '../mutex.js'
|
|
10
|
+
import type { ChangeRecord } from '../replication/change-tracker.js'
|
|
11
|
+
import type { ReplicationWriter } from '../replication/handler.js'
|
|
12
|
+
import type { PGlite, PGliteOptions, Results } from '@electric-sql/pglite'
|
|
13
|
+
|
|
14
|
+
/** options for creating an orez worker */
|
|
15
|
+
export interface OrezWorkerOptions {
|
|
16
|
+
/**
|
|
17
|
+
* pre-created PGlite instance. when provided, orez wraps it
|
|
18
|
+
* without managing its lifecycle (caller is responsible for closing).
|
|
19
|
+
*/
|
|
20
|
+
pglite?: PGlite
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* PGlite constructor options. used when `pglite` is not provided.
|
|
24
|
+
* in CF Workers, pass wasmModule/fsBundle/loadDataDir here.
|
|
25
|
+
*/
|
|
26
|
+
pgliteOptions?: PGliteOptions
|
|
27
|
+
|
|
28
|
+
/** publication names to track. defaults to all public tables. */
|
|
29
|
+
publications?: string[]
|
|
30
|
+
|
|
31
|
+
/** log level (default: 'warn') */
|
|
32
|
+
logLevel?: 'error' | 'warn' | 'info' | 'debug'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** the orez worker instance */
|
|
36
|
+
export interface OrezWorker {
|
|
37
|
+
/** the underlying PGlite instance */
|
|
38
|
+
readonly db: PGlite
|
|
39
|
+
|
|
40
|
+
/** mutex for serializing PGlite access */
|
|
41
|
+
readonly mutex: Mutex
|
|
42
|
+
|
|
43
|
+
/** run a parameterized query */
|
|
44
|
+
query<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
45
|
+
sql: string,
|
|
46
|
+
params?: unknown[]
|
|
47
|
+
): Promise<Results<T>>
|
|
48
|
+
|
|
49
|
+
/** execute raw SQL (DDL, multi-statement) */
|
|
50
|
+
exec(sql: string): Promise<void>
|
|
51
|
+
|
|
52
|
+
/** install/reinstall change tracking triggers on all published tables */
|
|
53
|
+
installChangeTracking(): Promise<void>
|
|
54
|
+
|
|
55
|
+
/** get changes since a watermark */
|
|
56
|
+
getChangesSince(watermark: number, limit?: number): Promise<ChangeRecord[]>
|
|
57
|
+
|
|
58
|
+
/** get current watermark value */
|
|
59
|
+
getCurrentWatermark(): Promise<number>
|
|
60
|
+
|
|
61
|
+
/** purge consumed changes up to watermark */
|
|
62
|
+
purgeChanges(watermark: number): Promise<number>
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* start streaming replication to a writer.
|
|
66
|
+
* runs until the writer is closed or the worker is shut down.
|
|
67
|
+
*/
|
|
68
|
+
startReplication(writer: ReplicationWriter): Promise<void>
|
|
69
|
+
|
|
70
|
+
/** whether this worker owns the PGlite instance (manages its lifecycle) */
|
|
71
|
+
readonly ownsInstance: boolean
|
|
72
|
+
|
|
73
|
+
/** close the worker. if ownsInstance, also closes PGlite. */
|
|
74
|
+
close(): Promise<void>
|
|
75
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* integration test for orez/worker.
|
|
3
|
+
*
|
|
4
|
+
* tests the full pipeline available without zero-cache:
|
|
5
|
+
* PGlite → change tracking → replication encoding → InProcessWriter
|
|
6
|
+
*
|
|
7
|
+
* mirrors the existing integration test patterns but uses the worker
|
|
8
|
+
* API instead of startZeroLite(). validates that the worker entry
|
|
9
|
+
* point produces the same replication stream that zero-cache expects.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
13
|
+
|
|
14
|
+
import { InProcessWriter } from '../replication/handler.js'
|
|
15
|
+
import { resetReplicationState, signalReplicationChange } from '../replication/handler.js'
|
|
16
|
+
import { createOrezWorker } from './index'
|
|
17
|
+
|
|
18
|
+
import type { OrezWorker } from './types'
|
|
19
|
+
|
|
20
|
+
// extract pgoutput message types from CopyData(XLogData(...)) buffers
|
|
21
|
+
function extractPayloadTypes(buf: Uint8Array): number[] {
|
|
22
|
+
const types: number[] = []
|
|
23
|
+
let pos = 0
|
|
24
|
+
while (pos < buf.length) {
|
|
25
|
+
if (buf[pos] !== 0x64) break // CopyData
|
|
26
|
+
const view = new DataView(buf.buffer, buf.byteOffset + pos + 1)
|
|
27
|
+
const len = view.getInt32(0)
|
|
28
|
+
// XLogData starts at pos+5, payload type at pos+5+1+8+8+8 = pos+30
|
|
29
|
+
if (pos + 30 < buf.length) {
|
|
30
|
+
types.push(buf[pos + 30])
|
|
31
|
+
}
|
|
32
|
+
pos += 1 + len
|
|
33
|
+
}
|
|
34
|
+
return types
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('orez/worker integration', { timeout: 30000 }, () => {
|
|
38
|
+
let worker: OrezWorker
|
|
39
|
+
|
|
40
|
+
beforeEach(async () => {
|
|
41
|
+
resetReplicationState()
|
|
42
|
+
worker = await createOrezWorker({
|
|
43
|
+
pgliteOptions: { dataDir: 'memory://' },
|
|
44
|
+
})
|
|
45
|
+
// create test table
|
|
46
|
+
await worker.exec(`
|
|
47
|
+
CREATE TABLE public.foo (
|
|
48
|
+
id TEXT PRIMARY KEY,
|
|
49
|
+
value TEXT,
|
|
50
|
+
num INTEGER
|
|
51
|
+
)
|
|
52
|
+
`)
|
|
53
|
+
// reinstall triggers after table creation
|
|
54
|
+
await worker.installChangeTracking()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
afterEach(async () => {
|
|
58
|
+
resetReplicationState()
|
|
59
|
+
await worker.close()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('change tracking captures insert/update/delete cycle', async () => {
|
|
63
|
+
await worker.query('INSERT INTO foo (id, value, num) VALUES ($1, $2, $3)', [
|
|
64
|
+
'row1',
|
|
65
|
+
'hello',
|
|
66
|
+
42,
|
|
67
|
+
])
|
|
68
|
+
await worker.query('UPDATE foo SET value = $1 WHERE id = $2', ['updated', 'row1'])
|
|
69
|
+
await worker.query('DELETE FROM foo WHERE id = $1', ['row1'])
|
|
70
|
+
|
|
71
|
+
const changes = await worker.getChangesSince(0)
|
|
72
|
+
expect(changes).toHaveLength(3)
|
|
73
|
+
expect(changes[0].op).toBe('INSERT')
|
|
74
|
+
expect(changes[0].row_data).toMatchObject({ id: 'row1', value: 'hello', num: 42 })
|
|
75
|
+
expect(changes[1].op).toBe('UPDATE')
|
|
76
|
+
expect(changes[1].row_data).toMatchObject({ id: 'row1', value: 'updated' })
|
|
77
|
+
expect(changes[1].old_data).toMatchObject({ id: 'row1', value: 'hello' })
|
|
78
|
+
expect(changes[2].op).toBe('DELETE')
|
|
79
|
+
expect(changes[2].old_data).toMatchObject({ id: 'row1', value: 'updated' })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('InProcessWriter receives pgoutput stream from replication', async () => {
|
|
83
|
+
const received: Uint8Array[] = []
|
|
84
|
+
const writer = new InProcessWriter((data) => received.push(new Uint8Array(data)))
|
|
85
|
+
|
|
86
|
+
// start replication in background
|
|
87
|
+
const replPromise = worker.startReplication(writer)
|
|
88
|
+
|
|
89
|
+
// wait for handler to set up
|
|
90
|
+
await new Promise((r) => setTimeout(r, 200))
|
|
91
|
+
|
|
92
|
+
// insert data
|
|
93
|
+
await worker.query('INSERT INTO foo (id, value, num) VALUES ($1, $2, $3)', [
|
|
94
|
+
'streamed-1',
|
|
95
|
+
'live',
|
|
96
|
+
99,
|
|
97
|
+
])
|
|
98
|
+
signalReplicationChange()
|
|
99
|
+
|
|
100
|
+
// wait for replication to deliver
|
|
101
|
+
await new Promise((r) => setTimeout(r, 1500))
|
|
102
|
+
|
|
103
|
+
// close writer to stop replication
|
|
104
|
+
writer.close()
|
|
105
|
+
await replPromise.catch(() => {}) // may reject on close
|
|
106
|
+
|
|
107
|
+
// should have received pgoutput messages
|
|
108
|
+
expect(received.length).toBeGreaterThan(0)
|
|
109
|
+
|
|
110
|
+
// extract message types from all received buffers
|
|
111
|
+
const allTypes = received.flatMap(extractPayloadTypes)
|
|
112
|
+
|
|
113
|
+
// first message is CopyBothResponse (0x57), then pgoutput messages
|
|
114
|
+
// check we got the core pgoutput types
|
|
115
|
+
expect(allTypes).toContain(0x42) // BEGIN
|
|
116
|
+
expect(allTypes).toContain(0x52) // RELATION
|
|
117
|
+
expect(allTypes).toContain(0x49) // INSERT
|
|
118
|
+
expect(allTypes).toContain(0x43) // COMMIT
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('watermarks advance monotonically', async () => {
|
|
122
|
+
const watermarks: number[] = []
|
|
123
|
+
|
|
124
|
+
for (let i = 0; i < 5; i++) {
|
|
125
|
+
await worker.query('INSERT INTO foo (id, value, num) VALUES ($1, $2, $3)', [
|
|
126
|
+
`wm-${i}`,
|
|
127
|
+
`val-${i}`,
|
|
128
|
+
i,
|
|
129
|
+
])
|
|
130
|
+
watermarks.push(await worker.getCurrentWatermark())
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// each watermark should be strictly greater than the previous
|
|
134
|
+
for (let i = 1; i < watermarks.length; i++) {
|
|
135
|
+
expect(watermarks[i]).toBeGreaterThan(watermarks[i - 1])
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// getChangesSince with earlier watermark returns only newer changes
|
|
139
|
+
const midpoint = watermarks[2]
|
|
140
|
+
const laterChanges = await worker.getChangesSince(midpoint)
|
|
141
|
+
expect(laterChanges).toHaveLength(2)
|
|
142
|
+
expect(laterChanges[0].row_data).toMatchObject({ id: 'wm-3' })
|
|
143
|
+
expect(laterChanges[1].row_data).toMatchObject({ id: 'wm-4' })
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('purge removes consumed changes', async () => {
|
|
147
|
+
for (let i = 0; i < 10; i++) {
|
|
148
|
+
await worker.query('INSERT INTO foo (id, value, num) VALUES ($1, $2, $3)', [
|
|
149
|
+
`purge-${i}`,
|
|
150
|
+
'x',
|
|
151
|
+
i,
|
|
152
|
+
])
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const allChanges = await worker.getChangesSince(0)
|
|
156
|
+
expect(allChanges).toHaveLength(10)
|
|
157
|
+
|
|
158
|
+
// purge first 7
|
|
159
|
+
const purged = await worker.purgeChanges(allChanges[6].watermark)
|
|
160
|
+
expect(purged).toBe(7)
|
|
161
|
+
|
|
162
|
+
// only 3 remain
|
|
163
|
+
const remaining = await worker.getChangesSince(0)
|
|
164
|
+
expect(remaining).toHaveLength(3)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('multiple tables each get tracked', async () => {
|
|
168
|
+
await worker.exec(`
|
|
169
|
+
CREATE TABLE public.bar (
|
|
170
|
+
id TEXT PRIMARY KEY,
|
|
171
|
+
foo_id TEXT
|
|
172
|
+
)
|
|
173
|
+
`)
|
|
174
|
+
await worker.installChangeTracking()
|
|
175
|
+
|
|
176
|
+
await worker.query('INSERT INTO foo VALUES ($1, $2, $3)', ['f1', 'a', 1])
|
|
177
|
+
await worker.query('INSERT INTO bar VALUES ($1, $2)', ['b1', 'f1'])
|
|
178
|
+
|
|
179
|
+
const changes = await worker.getChangesSince(0)
|
|
180
|
+
expect(changes).toHaveLength(2)
|
|
181
|
+
expect(changes[0].table_name).toBe('public.foo')
|
|
182
|
+
expect(changes[1].table_name).toBe('public.bar')
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('replication stream contains correct data for multiple operations', async () => {
|
|
186
|
+
const received: Uint8Array[] = []
|
|
187
|
+
const writer = new InProcessWriter((data) => received.push(new Uint8Array(data)))
|
|
188
|
+
|
|
189
|
+
const replPromise = worker.startReplication(writer)
|
|
190
|
+
await new Promise((r) => setTimeout(r, 200))
|
|
191
|
+
|
|
192
|
+
// insert
|
|
193
|
+
await worker.query('INSERT INTO foo VALUES ($1, $2, $3)', ['r1', 'initial', 1])
|
|
194
|
+
signalReplicationChange()
|
|
195
|
+
await new Promise((r) => setTimeout(r, 800))
|
|
196
|
+
|
|
197
|
+
// update
|
|
198
|
+
await worker.query('UPDATE foo SET value = $1 WHERE id = $2', ['modified', 'r1'])
|
|
199
|
+
signalReplicationChange()
|
|
200
|
+
await new Promise((r) => setTimeout(r, 800))
|
|
201
|
+
|
|
202
|
+
// delete
|
|
203
|
+
await worker.query('DELETE FROM foo WHERE id = $1', ['r1'])
|
|
204
|
+
signalReplicationChange()
|
|
205
|
+
await new Promise((r) => setTimeout(r, 800))
|
|
206
|
+
|
|
207
|
+
writer.close()
|
|
208
|
+
await replPromise.catch(() => {})
|
|
209
|
+
|
|
210
|
+
const allTypes = received.flatMap(extractPayloadTypes)
|
|
211
|
+
|
|
212
|
+
// should have all three operation types
|
|
213
|
+
expect(allTypes).toContain(0x49) // INSERT
|
|
214
|
+
expect(allTypes).toContain(0x55) // UPDATE
|
|
215
|
+
expect(allTypes).toContain(0x44) // DELETE
|
|
216
|
+
|
|
217
|
+
// should have BEGIN/COMMIT pairs
|
|
218
|
+
const begins = allTypes.filter((t) => t === 0x42).length
|
|
219
|
+
const commits = allTypes.filter((t) => t === 0x43).length
|
|
220
|
+
expect(begins).toBe(commits)
|
|
221
|
+
expect(begins).toBeGreaterThanOrEqual(1)
|
|
222
|
+
})
|
|
223
|
+
})
|