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.
Files changed (130) hide show
  1. package/dist/cli-entry.js +0 -0
  2. package/dist/cli.js +7 -1
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.d.ts +1 -0
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +1 -0
  7. package/dist/config.js.map +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +14 -11
  10. package/dist/index.js.map +1 -1
  11. package/dist/pg-proxy.d.ts.map +1 -1
  12. package/dist/pg-proxy.js +8 -4
  13. package/dist/pg-proxy.js.map +1 -1
  14. package/dist/pglite-manager.d.ts +12 -0
  15. package/dist/pglite-manager.d.ts.map +1 -1
  16. package/dist/pglite-manager.js +81 -0
  17. package/dist/pglite-manager.js.map +1 -1
  18. package/dist/recovery.js +2 -2
  19. package/dist/recovery.js.map +1 -1
  20. package/dist/replication/change-tracker.js +9 -9
  21. package/dist/replication/change-tracker.js.map +1 -1
  22. package/dist/replication/handler.d.ts +12 -0
  23. package/dist/replication/handler.d.ts.map +1 -1
  24. package/dist/replication/handler.js +34 -6
  25. package/dist/replication/handler.js.map +1 -1
  26. package/dist/worker/browser-build-config.d.ts +59 -0
  27. package/dist/worker/browser-build-config.d.ts.map +1 -0
  28. package/dist/worker/browser-build-config.js +101 -0
  29. package/dist/worker/browser-build-config.js.map +1 -0
  30. package/dist/worker/browser-embed.d.ts +58 -0
  31. package/dist/worker/browser-embed.d.ts.map +1 -0
  32. package/dist/worker/browser-embed.js +195 -0
  33. package/dist/worker/browser-embed.js.map +1 -0
  34. package/dist/worker/cf-patches.d.ts +20 -0
  35. package/dist/worker/cf-patches.d.ts.map +1 -0
  36. package/dist/worker/cf-patches.js +94 -0
  37. package/dist/worker/cf-patches.js.map +1 -0
  38. package/dist/worker/index.d.ts +12 -0
  39. package/dist/worker/index.d.ts.map +1 -0
  40. package/dist/worker/index.js +105 -0
  41. package/dist/worker/index.js.map +1 -0
  42. package/dist/worker/shims/fastify.d.ts +80 -0
  43. package/dist/worker/shims/fastify.d.ts.map +1 -0
  44. package/dist/worker/shims/fastify.js +223 -0
  45. package/dist/worker/shims/fastify.js.map +1 -0
  46. package/dist/worker/shims/http-service.d.ts +104 -0
  47. package/dist/worker/shims/http-service.d.ts.map +1 -0
  48. package/dist/worker/shims/http-service.js +198 -0
  49. package/dist/worker/shims/http-service.js.map +1 -0
  50. package/dist/worker/shims/node-stub.d.ts +147 -0
  51. package/dist/worker/shims/node-stub.d.ts.map +1 -0
  52. package/dist/worker/shims/node-stub.js +204 -0
  53. package/dist/worker/shims/node-stub.js.map +1 -0
  54. package/dist/worker/shims/postgres.d.ts +115 -0
  55. package/dist/worker/shims/postgres.d.ts.map +1 -0
  56. package/dist/worker/shims/postgres.js +1181 -0
  57. package/dist/worker/shims/postgres.js.map +1 -0
  58. package/dist/worker/shims/sqlite-browser.d.ts +54 -0
  59. package/dist/worker/shims/sqlite-browser.d.ts.map +1 -0
  60. package/dist/worker/shims/sqlite-browser.js +144 -0
  61. package/dist/worker/shims/sqlite-browser.js.map +1 -0
  62. package/dist/worker/shims/sqlite.d.ts +126 -0
  63. package/dist/worker/shims/sqlite.d.ts.map +1 -0
  64. package/dist/worker/shims/sqlite.js +599 -0
  65. package/dist/worker/shims/sqlite.js.map +1 -0
  66. package/dist/worker/shims/stream-browser.d.ts +9 -0
  67. package/dist/worker/shims/stream-browser.d.ts.map +1 -0
  68. package/dist/worker/shims/stream-browser.js +13 -0
  69. package/dist/worker/shims/stream-browser.js.map +1 -0
  70. package/dist/worker/shims/ws-browser.d.ts +50 -0
  71. package/dist/worker/shims/ws-browser.d.ts.map +1 -0
  72. package/dist/worker/shims/ws-browser.js +105 -0
  73. package/dist/worker/shims/ws-browser.js.map +1 -0
  74. package/dist/worker/shims/ws.d.ts +62 -0
  75. package/dist/worker/shims/ws.d.ts.map +1 -0
  76. package/dist/worker/shims/ws.js +310 -0
  77. package/dist/worker/shims/ws.js.map +1 -0
  78. package/dist/worker/types.d.ts +57 -0
  79. package/dist/worker/types.d.ts.map +1 -0
  80. package/dist/worker/types.js +9 -0
  81. package/dist/worker/types.js.map +1 -0
  82. package/dist/worker/zero-cache-embed-cf.d.ts +63 -0
  83. package/dist/worker/zero-cache-embed-cf.d.ts.map +1 -0
  84. package/dist/worker/zero-cache-embed-cf.js +268 -0
  85. package/dist/worker/zero-cache-embed-cf.js.map +1 -0
  86. package/dist/worker/zero-cache-embed.d.ts +66 -0
  87. package/dist/worker/zero-cache-embed.d.ts.map +1 -0
  88. package/dist/worker/zero-cache-embed.js +200 -0
  89. package/dist/worker/zero-cache-embed.js.map +1 -0
  90. package/package.json +62 -3
  91. package/src/cli-entry.ts +0 -0
  92. package/src/cli.ts +8 -1
  93. package/src/config.ts +2 -0
  94. package/src/index.ts +15 -10
  95. package/src/integration/integration.test.ts +1 -1
  96. package/src/integration/restore-live-stress.test.ts +2 -2
  97. package/src/pg-proxy.ts +9 -4
  98. package/src/pglite-manager.ts +111 -0
  99. package/src/recovery.ts +2 -2
  100. package/src/replication/change-tracker.test.ts +1 -1
  101. package/src/replication/change-tracker.ts +9 -9
  102. package/src/replication/handler.test.ts +37 -0
  103. package/src/replication/handler.ts +46 -6
  104. package/src/wasm-sqlite.test.ts +2 -1
  105. package/src/worker/browser-build-config.test.ts +59 -0
  106. package/src/worker/browser-build-config.ts +105 -0
  107. package/src/worker/browser-embed.ts +306 -0
  108. package/src/worker/cf-patches.ts +114 -0
  109. package/src/worker/embed-integration.test.ts +321 -0
  110. package/src/worker/index.ts +138 -0
  111. package/src/worker/shims/fastify.test.ts +255 -0
  112. package/src/worker/shims/fastify.ts +292 -0
  113. package/src/worker/shims/http-service.test.ts +355 -0
  114. package/src/worker/shims/http-service.ts +293 -0
  115. package/src/worker/shims/node-stub.ts +223 -0
  116. package/src/worker/shims/postgres.test.ts +364 -0
  117. package/src/worker/shims/postgres.ts +1434 -0
  118. package/src/worker/shims/sqlite-browser.test.ts +233 -0
  119. package/src/worker/shims/sqlite-browser.ts +178 -0
  120. package/src/worker/shims/sqlite.test.ts +641 -0
  121. package/src/worker/shims/sqlite.ts +731 -0
  122. package/src/worker/shims/ws-browser.test.ts +184 -0
  123. package/src/worker/shims/ws-browser.ts +125 -0
  124. package/src/worker/shims/ws.test.ts +288 -0
  125. package/src/worker/shims/ws.ts +367 -0
  126. package/src/worker/types.ts +75 -0
  127. package/src/worker/worker-integration.test.ts +223 -0
  128. package/src/worker/worker.test.ts +136 -0
  129. package/src/worker/zero-cache-embed-cf.ts +367 -0
  130. 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
+ })