orez 0.2.24 → 0.2.26

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 (138) hide show
  1. package/dist/cf-do/test-protocol.d.ts +11 -0
  2. package/dist/cf-do/test-protocol.d.ts.map +1 -0
  3. package/dist/cf-do/test-protocol.js +137 -0
  4. package/dist/cf-do/test-protocol.js.map +1 -0
  5. package/dist/cf-do/watermark.d.ts +21 -0
  6. package/dist/cf-do/watermark.d.ts.map +1 -0
  7. package/dist/cf-do/watermark.js +93 -0
  8. package/dist/cf-do/watermark.js.map +1 -0
  9. package/dist/cf-do/worker.d.ts +91 -0
  10. package/dist/cf-do/worker.d.ts.map +1 -0
  11. package/dist/cf-do/worker.js +813 -0
  12. package/dist/cf-do/worker.js.map +1 -0
  13. package/dist/config.d.ts +4 -0
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +1 -0
  16. package/dist/config.js.map +1 -1
  17. package/dist/do-sql-tracking.d.ts +6 -0
  18. package/dist/do-sql-tracking.d.ts.map +1 -0
  19. package/dist/do-sql-tracking.js +14 -0
  20. package/dist/do-sql-tracking.js.map +1 -0
  21. package/dist/index.d.ts +2 -3
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +69 -23
  24. package/dist/index.js.map +1 -1
  25. package/dist/pg-proxy-browser.js +6 -6
  26. package/dist/pg-proxy-browser.js.map +1 -1
  27. package/dist/pg-proxy-do-backend.d.ts +128 -0
  28. package/dist/pg-proxy-do-backend.d.ts.map +1 -0
  29. package/dist/pg-proxy-do-backend.js +6292 -0
  30. package/dist/pg-proxy-do-backend.js.map +1 -0
  31. package/dist/pglite-ipc.d.ts +3 -0
  32. package/dist/pglite-ipc.d.ts.map +1 -1
  33. package/dist/pglite-ipc.js +34 -12
  34. package/dist/pglite-ipc.js.map +1 -1
  35. package/dist/pglite-web-proxy.d.ts +3 -0
  36. package/dist/pglite-web-proxy.d.ts.map +1 -1
  37. package/dist/pglite-web-proxy.js +50 -7
  38. package/dist/pglite-web-proxy.js.map +1 -1
  39. package/dist/query-rewrites.d.ts +2 -0
  40. package/dist/query-rewrites.d.ts.map +1 -0
  41. package/dist/query-rewrites.js +140 -0
  42. package/dist/query-rewrites.js.map +1 -0
  43. package/dist/replication/change-tracker.d.ts.map +1 -1
  44. package/dist/replication/change-tracker.js +18 -1
  45. package/dist/replication/change-tracker.js.map +1 -1
  46. package/dist/replication/handler.d.ts.map +1 -1
  47. package/dist/replication/handler.js +7 -2
  48. package/dist/replication/handler.js.map +1 -1
  49. package/dist/replication/pgoutput-encoder.d.ts.map +1 -1
  50. package/dist/replication/pgoutput-encoder.js +72 -30
  51. package/dist/replication/pgoutput-encoder.js.map +1 -1
  52. package/dist/worker/browser-build-config.d.ts.map +1 -1
  53. package/dist/worker/browser-build-config.js +2 -1
  54. package/dist/worker/browser-build-config.js.map +1 -1
  55. package/dist/worker/cf-patches.d.ts +5 -2
  56. package/dist/worker/cf-patches.d.ts.map +1 -1
  57. package/dist/worker/cf-patches.js +238 -4
  58. package/dist/worker/cf-patches.js.map +1 -1
  59. package/dist/worker/shims/node-stub.d.ts +35 -0
  60. package/dist/worker/shims/node-stub.d.ts.map +1 -1
  61. package/dist/worker/shims/node-stub.js +53 -1
  62. package/dist/worker/shims/node-stub.js.map +1 -1
  63. package/dist/worker/shims/oxfmt.d.ts +4 -0
  64. package/dist/worker/shims/oxfmt.d.ts.map +1 -0
  65. package/dist/worker/shims/oxfmt.js +4 -0
  66. package/dist/worker/shims/oxfmt.js.map +1 -0
  67. package/dist/worker/shims/postgres-socket.js +1 -1
  68. package/dist/worker/shims/postgres-socket.js.map +1 -1
  69. package/dist/worker/shims/sqlite.d.ts +1 -0
  70. package/dist/worker/shims/sqlite.d.ts.map +1 -1
  71. package/dist/worker/shims/sqlite.js +229 -9
  72. package/dist/worker/shims/sqlite.js.map +1 -1
  73. package/dist/worker/shims/ws.d.ts.map +1 -1
  74. package/dist/worker/shims/ws.js +45 -0
  75. package/dist/worker/shims/ws.js.map +1 -1
  76. package/dist/worker/shims/zero-process-env.d.ts +2 -0
  77. package/dist/worker/shims/zero-process-env.d.ts.map +1 -0
  78. package/dist/worker/shims/zero-process-env.js +9 -0
  79. package/dist/worker/shims/zero-process-env.js.map +1 -0
  80. package/dist/worker/zero-cache-embed-cf.d.ts +29 -12
  81. package/dist/worker/zero-cache-embed-cf.d.ts.map +1 -1
  82. package/dist/worker/zero-cache-embed-cf.js +83 -14
  83. package/dist/worker/zero-cache-embed-cf.js.map +1 -1
  84. package/package.json +6 -2
  85. package/src/cf-do/.wrangler/cache/cf.json +1 -0
  86. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
  87. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
  88. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
  89. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/0f0f3bdf0abda097eb6f1246db4657d9fc622081362d894d82c1a1ce067b05b6.sqlite +0 -0
  90. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/1ddd3a4a48a11b51658444f5458a1fb175194b1d5b6a5bda20ef3fe3205b900c.sqlite +0 -0
  91. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/204a39120310d37e972c5914cfd71ad55c151bdb9e8ed289a5f8c5b052dd60e4.sqlite +0 -0
  92. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/3835f242df9728adba3d127a238793fd054ed3e51df3f60749ee744c469bf2a2.sqlite +0 -0
  93. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/4aa9c80eb716cf55b8995ccf7afab0b36c683e6da07d7c37a3f9c570136036df.sqlite +0 -0
  94. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/533e2fd1d6ea46e7a9a0017916ef341802d438d72583462755f2c1f8225e9bf2.sqlite +0 -0
  95. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/5ffa1aced1225ecaeac6366f7586aa3de92761cdff8711d81fbd81f248076abd.sqlite +0 -0
  96. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/686c3a9f0d7e59ed2ab607efd4b76d779c97cafeb3818380033bf7c7eb86c819.sqlite +0 -0
  97. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/6e8214e8dcfadd0deb52d64e5e9ca85c6b329ace11193909845995396914c473.sqlite +0 -0
  98. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/78d9ec9ff873d3fe3507ff53c2a6f6dfc408b4268eb0db3f2a146c0678965366.sqlite +0 -0
  99. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/7eff9f0ed7e27ad0d3f9d923de0682fab1928591172c1ba336c5f79a134a5d85.sqlite +0 -0
  100. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/836cda5b995b25867d722ed4f4c2292167e80351a3c6038db626648eb247dd8b.sqlite +0 -0
  101. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/91ef63b112209ab30172763acd8a0935106c248f7f1bcae5545ce37a9f201551.sqlite +0 -0
  102. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/a66ea4293a5f5938bc6d116edfa2522bb85bc37aea3541fbc09c3b613b9b32c0.sqlite +0 -0
  103. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/ceb2ab26b80590840b65651deb6e948d3bf81565c6751f3a58752cf4bf4aecae.sqlite +0 -0
  104. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite +0 -0
  105. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-shm +0 -0
  106. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-wal +0 -0
  107. package/src/cf-do/ARCHITECTURE.md +83 -0
  108. package/src/cf-do/watermark.test.ts +103 -0
  109. package/src/cf-do/watermark.ts +118 -0
  110. package/src/cf-do/worker.ts +1033 -0
  111. package/src/cf-do/wrangler.toml +11 -0
  112. package/src/config.ts +5 -0
  113. package/src/do-sql-tracking.test.ts +19 -0
  114. package/src/do-sql-tracking.ts +19 -0
  115. package/src/index.ts +76 -28
  116. package/src/pg-proxy-browser.ts +6 -6
  117. package/src/pg-proxy-do-backend.test.ts +3890 -0
  118. package/src/pg-proxy-do-backend.ts +7157 -0
  119. package/src/pglite-ipc.test.ts +17 -0
  120. package/src/pglite-ipc.ts +31 -12
  121. package/src/pglite-web-proxy.test.ts +57 -0
  122. package/src/pglite-web-proxy.ts +48 -7
  123. package/src/replication/change-tracker.ts +16 -1
  124. package/src/replication/handler.test.ts +35 -0
  125. package/src/replication/handler.ts +7 -2
  126. package/src/replication/pgoutput-encoder.test.ts +71 -2
  127. package/src/replication/pgoutput-encoder.ts +65 -30
  128. package/src/worker/browser-build-config.test.ts +12 -0
  129. package/src/worker/browser-build-config.ts +2 -1
  130. package/src/worker/cf-patches.ts +274 -4
  131. package/src/worker/shims/node-stub.ts +53 -1
  132. package/src/worker/shims/oxfmt.ts +3 -0
  133. package/src/worker/shims/postgres-socket.ts +1 -1
  134. package/src/worker/shims/sqlite.test.ts +145 -0
  135. package/src/worker/shims/sqlite.ts +256 -9
  136. package/src/worker/shims/ws.ts +45 -0
  137. package/src/worker/shims/zero-process-env.ts +11 -0
  138. package/src/worker/zero-cache-embed-cf.ts +114 -18
@@ -96,4 +96,21 @@ describe('PGliteWorkerProxy', () => {
96
96
  test('error propagation with SQL code', async () => {
97
97
  await expect(proxy.exec('SELECT * FROM nonexistent_table')).rejects.toThrow()
98
98
  })
99
+
100
+ test('rejects requests after worker exits', async () => {
101
+ const local = new PGliteWorkerProxy({
102
+ dataDir: 'memory://',
103
+ name: 'exit-test',
104
+ withExtensions: false,
105
+ debug: 0,
106
+ pgliteOptions: {},
107
+ })
108
+ await local.waitReady
109
+
110
+ await (local as any).worker.terminate()
111
+
112
+ await expect(local.query('SELECT 1')).rejects.toThrow(
113
+ /worker exited|worker is closed/
114
+ )
115
+ })
99
116
  })
package/src/pglite-ipc.ts CHANGED
@@ -51,6 +51,8 @@ export class PGliteWorkerProxy {
51
51
  private pending = new Map<number, PendingRequest>()
52
52
  private nextId = 1
53
53
  private notificationCallbacks = new Map<string, Set<(payload: string) => void>>()
54
+ private closed = false
55
+ private failure: Error | null = null
54
56
  readonly name: string
55
57
 
56
58
  /** resolves when the worker's PGlite instance is ready */
@@ -89,24 +91,31 @@ export class PGliteWorkerProxy {
89
91
 
90
92
  // handle unexpected worker crashes
91
93
  this.worker.on('error', (err) => {
94
+ const failure = new Error(`worker crashed: ${err.message}`)
92
95
  log.pglite(`worker ${config.name} error: ${err.message}`)
93
- for (const [, req] of this.pending) {
94
- req.reject(new Error(`worker crashed: ${err.message}`))
95
- }
96
- this.pending.clear()
96
+ this.failPending(failure)
97
97
  })
98
98
 
99
99
  this.worker.on('exit', (code) => {
100
+ if (this.closed) return
100
101
  if (code !== 0) {
102
+ const failure = new Error(`worker exited with code ${code}`)
101
103
  log.pglite(`worker ${config.name} exited with code ${code}`)
102
- for (const [, req] of this.pending) {
103
- req.reject(new Error(`worker exited with code ${code}`))
104
- }
105
- this.pending.clear()
104
+ this.failPending(failure)
105
+ return
106
106
  }
107
+ this.failPending(new Error('worker exited unexpectedly'))
107
108
  })
108
109
  }
109
110
 
111
+ private failPending(error: Error) {
112
+ if (!this.failure) this.failure = error
113
+ for (const [, req] of this.pending) {
114
+ req.reject(error)
115
+ }
116
+ this.pending.clear()
117
+ }
118
+
110
119
  private installMessageHandler() {
111
120
  this.worker.on(
112
121
  'message',
@@ -139,14 +148,22 @@ export class PGliteWorkerProxy {
139
148
  }
140
149
 
141
150
  private send(msg: Record<string, unknown>, transfer?: ArrayBuffer[]): Promise<any> {
151
+ if (this.failure) return Promise.reject(this.failure)
152
+ if (this.closed) return Promise.reject(new Error('worker is closed'))
153
+
142
154
  const id = this.nextId++
143
155
  msg.id = id
144
156
  return new Promise((resolve, reject) => {
145
157
  this.pending.set(id, { resolve, reject })
146
- if (transfer?.length) {
147
- this.worker.postMessage(msg, transfer)
148
- } else {
149
- this.worker.postMessage(msg)
158
+ try {
159
+ if (transfer?.length) {
160
+ this.worker.postMessage(msg, transfer)
161
+ } else {
162
+ this.worker.postMessage(msg)
163
+ }
164
+ } catch (err) {
165
+ this.pending.delete(id)
166
+ reject(err instanceof Error ? err : new Error(String(err)))
150
167
  }
151
168
  })
152
169
  }
@@ -242,6 +259,8 @@ export class PGliteWorkerProxy {
242
259
  } catch {
243
260
  // worker may already be gone
244
261
  }
262
+ this.closed = true
263
+ this.failPending(new Error('worker is closed'))
245
264
  await this.worker.terminate()
246
265
  }
247
266
  }
@@ -0,0 +1,57 @@
1
+ import { describe, expect, test } from 'vitest'
2
+
3
+ import { PGliteWebProxy } from './pglite-web-proxy.js'
4
+
5
+ class FakeWorker {
6
+ messages: unknown[] = []
7
+ terminated = false
8
+ private listeners = new Map<string, Set<(event: any) => void>>()
9
+
10
+ addEventListener(type: string, handler: (event: any) => void) {
11
+ let handlers = this.listeners.get(type)
12
+ if (!handlers) {
13
+ handlers = new Set()
14
+ this.listeners.set(type, handlers)
15
+ }
16
+ handlers.add(handler)
17
+ }
18
+
19
+ removeEventListener(type: string, handler: (event: any) => void) {
20
+ this.listeners.get(type)?.delete(handler)
21
+ }
22
+
23
+ postMessage(message: unknown) {
24
+ this.messages.push(message)
25
+ }
26
+
27
+ terminate() {
28
+ this.terminated = true
29
+ }
30
+
31
+ dispatch(type: string, event: any) {
32
+ for (const handler of this.listeners.get(type) ?? []) {
33
+ handler(event)
34
+ }
35
+ }
36
+ }
37
+
38
+ describe('PGliteWebProxy', () => {
39
+ test('rejects pending and future requests when the worker errors', async () => {
40
+ const worker = new FakeWorker()
41
+ const proxy = new PGliteWebProxy(worker as unknown as Worker, 'postgres')
42
+
43
+ worker.dispatch('message', { data: { type: 'ready' } })
44
+ await proxy.waitReady
45
+
46
+ const pending = proxy.query('SELECT 1')
47
+ expect(worker.messages).toHaveLength(1)
48
+
49
+ worker.dispatch('error', {
50
+ error: new Error('pglite worker crashed'),
51
+ message: 'pglite worker crashed',
52
+ })
53
+
54
+ await expect(pending).rejects.toThrow('pglite worker crashed')
55
+ await expect(proxy.query('SELECT 2')).rejects.toThrow('pglite worker crashed')
56
+ })
57
+ })
@@ -34,6 +34,7 @@ export class PGliteWebProxy {
34
34
  private pending = new Map<number, PendingRequest>()
35
35
  private nextId = 1
36
36
  private notificationCallbacks = new Map<string, Set<(payload: string) => void>>()
37
+ private failure: Error | null = null
37
38
  readonly name: string
38
39
 
39
40
  readonly waitReady: Promise<void>
@@ -66,12 +67,26 @@ export class PGliteWebProxy {
66
67
 
67
68
  this.worker.addEventListener('message', onMessage)
68
69
  this.worker.addEventListener('error', (ev) => {
69
- rejectReady(new Error(String(ev)))
70
+ const error = this.errorFromEvent(ev, 'worker failed during startup')
71
+ this.failPending(error)
72
+ rejectReady(error)
73
+ })
74
+ this.worker.addEventListener('messageerror', (ev) => {
75
+ const error = this.errorFromEvent(ev, 'worker message error during startup')
76
+ this.failPending(error)
77
+ rejectReady(error)
70
78
  })
71
79
  })
72
80
  }
73
81
 
74
82
  private installMessageHandler() {
83
+ this.worker.addEventListener('error', (ev) => {
84
+ this.failPending(this.errorFromEvent(ev, 'worker failed'))
85
+ })
86
+ this.worker.addEventListener('messageerror', (ev) => {
87
+ this.failPending(this.errorFromEvent(ev, 'worker message error'))
88
+ })
89
+
75
90
  this.worker.addEventListener('message', (ev: MessageEvent) => {
76
91
  const msg = ev.data
77
92
  if (!msg || typeof msg !== 'object') return
@@ -102,15 +117,40 @@ export class PGliteWebProxy {
102
117
  })
103
118
  }
104
119
 
120
+ private errorFromEvent(ev: Event, fallback: string): Error {
121
+ const maybeError = ev as ErrorEvent
122
+ if (maybeError.error instanceof Error) return maybeError.error
123
+ if (maybeError.message) return new Error(maybeError.message)
124
+ return new Error(fallback)
125
+ }
126
+
127
+ private failPending(error: Error) {
128
+ if (!this.failure) this.failure = error
129
+ this.closed = true
130
+ this.ready = false
131
+ for (const [, req] of this.pending) {
132
+ req.reject(error)
133
+ }
134
+ this.pending.clear()
135
+ }
136
+
105
137
  private send(msg: Record<string, unknown>, transfer?: Transferable[]): Promise<any> {
138
+ if (this.failure) return Promise.reject(this.failure)
139
+ if (this.closed) return Promise.reject(new Error('worker is closed'))
140
+
106
141
  const id = this.nextId++
107
142
  msg.id = id
108
143
  return new Promise((resolve, reject) => {
109
144
  this.pending.set(id, { resolve, reject })
110
- if (transfer?.length) {
111
- this.worker.postMessage(msg, transfer)
112
- } else {
113
- this.worker.postMessage(msg)
145
+ try {
146
+ if (transfer?.length) {
147
+ this.worker.postMessage(msg, transfer)
148
+ } else {
149
+ this.worker.postMessage(msg)
150
+ }
151
+ } catch (err) {
152
+ this.pending.delete(id)
153
+ reject(err instanceof Error ? err : new Error(String(err)))
114
154
  }
115
155
  })
116
156
  }
@@ -170,11 +210,12 @@ export class PGliteWebProxy {
170
210
  }
171
211
 
172
212
  async close(): Promise<void> {
173
- this.closed = true
174
- this.ready = false
175
213
  try {
176
214
  await this.send({ type: 'close' })
177
215
  } catch {}
216
+ this.closed = true
217
+ this.ready = false
218
+ this.failPending(new Error('worker is closed'))
178
219
  this.worker.terminate()
179
220
  }
180
221
  }
@@ -20,6 +20,16 @@ export interface ChangeTrackingDb {
20
20
  query<T>(sql: string, params?: unknown[]): Promise<{ rows: T[] }>
21
21
  }
22
22
 
23
+ // PGlite returns JSONB columns as parsed objects; the DO backend returns them
24
+ // as JSON strings (it stores `row_data TEXT`). normalize once at the consumer
25
+ // boundary so callers always get an object.
26
+ function jsonRecord(value: unknown): Record<string, unknown> | null {
27
+ if (value === null || value === undefined) return null
28
+ if (typeof value === 'object') return value as Record<string, unknown>
29
+ if (typeof value !== 'string' || value === '') return null
30
+ return JSON.parse(value) as Record<string, unknown>
31
+ }
32
+
23
33
  export async function installChangeTracking(db: ChangeTrackingDb): Promise<void> {
24
34
  // use _orez schema for internal tables - survives pg_restore of public schema
25
35
  await db.exec(`CREATE SCHEMA IF NOT EXISTS _orez`)
@@ -241,7 +251,12 @@ export async function getChangesSince(
241
251
  'SELECT watermark, table_name, op, row_data, old_data FROM _orez._zero_changes WHERE watermark > $1 ORDER BY watermark LIMIT $2',
242
252
  [watermark, limit]
243
253
  )
244
- return result.rows
254
+ return result.rows.map((row) => ({
255
+ ...row,
256
+ watermark: Number(row.watermark),
257
+ row_data: jsonRecord(row.row_data),
258
+ old_data: jsonRecord(row.old_data),
259
+ }))
245
260
  }
246
261
 
247
262
  export async function purgeConsumedChanges(
@@ -174,6 +174,19 @@ describe('handleStartReplication', () => {
174
174
  return types
175
175
  }
176
176
 
177
+ function countCopyDataFrames(buf: Uint8Array): number {
178
+ const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
179
+ let pos = 0
180
+ let count = 0
181
+ while (pos < buf.length) {
182
+ if (buf[pos] !== 0x64) return count
183
+ const len = dv.getInt32(pos + 1)
184
+ pos += 1 + len
185
+ count++
186
+ }
187
+ return count
188
+ }
189
+
177
190
  it('sends CopyBothResponse first', async () => {
178
191
  const { written, writer } = createWriter()
179
192
 
@@ -238,6 +251,28 @@ describe('handleStartReplication', () => {
238
251
  expect(insIdx).toBeLessThan(comIdx)
239
252
  })
240
253
 
254
+ it('writes one CopyData frame per socket chunk', async () => {
255
+ const { written, writer } = createWriter()
256
+
257
+ replicationPromise = handleStartReplication(
258
+ 'START_REPLICATION SLOT "s" LOGICAL 0/0',
259
+ writer,
260
+ db,
261
+ testMutex
262
+ )
263
+
264
+ await new Promise((r) => setTimeout(r, 100))
265
+ await db.exec(`INSERT INTO public.items (name, value) VALUES ('chunked', 123)`)
266
+ signalReplicationChange()
267
+ await new Promise((r) => setTimeout(r, 700))
268
+
269
+ const copyDataWrites = written.filter((msg) => msg[0] === 0x64)
270
+ expect(copyDataWrites.length).toBeGreaterThanOrEqual(4)
271
+ for (const msg of copyDataWrites) {
272
+ expect(countCopyDataFrames(msg)).toBe(1)
273
+ }
274
+ })
275
+
241
276
  it('streams UPDATE and DELETE operations', async () => {
242
277
  const { written, writer } = createWriter()
243
278
 
@@ -117,6 +117,10 @@ let _replicationWakeup: (() => void) | null = null
117
117
  * called by the proxy after executing writes on the postgres instance. */
118
118
  export function signalReplicationChange() {
119
119
  _replicationWakeup?.()
120
+ const globalWakeup = (globalThis as any).__orez_signal_replication
121
+ if (typeof globalWakeup === 'function' && globalWakeup !== _replicationWakeup) {
122
+ globalWakeup()
123
+ }
120
124
  }
121
125
 
122
126
  // cached setup results so reconnects skip the expensive mutex-holding setup phase.
@@ -1146,8 +1150,9 @@ async function streamChanges(
1146
1150
  const endLsn = nextLsn()
1147
1151
  messages.push(encodeWrappedChange(endLsn, endLsn, ts, encodeCommit(0, lsn, endLsn, ts)))
1148
1152
 
1149
- // write messages individually works for both TCP sockets and in-process
1150
- // pipes (browser pipe handler parses one message per write() call)
1153
+ // The MessagePort-backed socket delivers each write as one readable chunk.
1154
+ // zero-cache parses one replication payload per chunk, so each CopyData frame
1155
+ // must be written separately.
1151
1156
  let totalSize = 0
1152
1157
  for (const msg of messages) totalSize += msg.length
1153
1158
  log.debug.repl(
@@ -40,6 +40,24 @@ function rText(buf: Uint8Array, off: number): [string, number] {
40
40
  const str = new TextDecoder().decode(buf.subarray(off + 4, off + 4 + len))
41
41
  return [str, off + 4 + len]
42
42
  }
43
+ function rTupleTextValues(buf: Uint8Array, off: number): [Array<string | null>, number] {
44
+ const values: Array<string | null> = []
45
+ const count = r16(buf, off)
46
+ let pos = off + 2
47
+ for (let i = 0; i < count; i++) {
48
+ const kind = buf[pos++]
49
+ if (kind === 0x6e) {
50
+ values.push(null)
51
+ continue
52
+ }
53
+ expect(kind).toBe(0x74)
54
+ const len = r32(buf, pos)
55
+ pos += 4
56
+ values.push(new TextDecoder().decode(buf.subarray(pos, pos + len)))
57
+ pos += len
58
+ }
59
+ return [values, pos]
60
+ }
43
61
 
44
62
  describe('pgoutput-encoder', () => {
45
63
  describe('encodeBegin', () => {
@@ -276,6 +294,26 @@ describe('pgoutput-encoder', () => {
276
294
  }
277
295
  expect(buf[pos]).toBe(0x4e) // 'N' new tuple marker
278
296
  })
297
+
298
+ it('encodes sqlite integer booleans as postgres bool text', () => {
299
+ const boolCols: ColumnInfo[] = [
300
+ { name: 'id', typeOid: 25, typeMod: -1, isKey: true },
301
+ { name: 'completed', typeOid: 16, typeMod: -1 },
302
+ ]
303
+ const buf = encodeUpdate(
304
+ 16384,
305
+ { id: '1', completed: 1 },
306
+ { id: '1', completed: 0 },
307
+ boolCols
308
+ )
309
+
310
+ expect(buf[5]).toBe(0x4f) // 'O' old tuple
311
+ const [oldValues, afterOld] = rTupleTextValues(buf, 6)
312
+ expect(oldValues).toEqual(['1', 'f'])
313
+ expect(buf[afterOld]).toBe(0x4e) // 'N' new tuple
314
+ const [newValues] = rTupleTextValues(buf, afterOld + 1)
315
+ expect(newValues).toEqual(['1', 't'])
316
+ })
279
317
  })
280
318
 
281
319
  describe('encodeDelete', () => {
@@ -364,6 +402,12 @@ describe('pgoutput-encoder', () => {
364
402
  const b = getTableOid('oid_test_y')
365
403
  expect(a).not.toBe(b)
366
404
  })
405
+
406
+ it('matches DoBackend catalog oids for flattened schema tables', () => {
407
+ expect(getTableOid('public.todo')).toBe(4392680)
408
+ expect(getTableOid('todo_0.clients')).toBe(9663976)
409
+ expect(getTableOid('todo_0.mutations')).toBe(8519194)
410
+ })
367
411
  })
368
412
 
369
413
  // roundtrip tests: encode with orez → parse with zero-cache's parser
@@ -384,8 +428,16 @@ describe('pgoutput-encoder', () => {
384
428
  PgoutputParser = (await import(parserPath)).PgoutputParser
385
429
  })
386
430
 
387
- function makeParser() {
388
- return new PgoutputParser(typeParsers)
431
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
432
+ function makeParser(parserOverrides: any = typeParsers) {
433
+ return new PgoutputParser(parserOverrides)
434
+ }
435
+
436
+ function makeBoolAwareParser() {
437
+ return makeParser({
438
+ getTypeParser: (oid: number) =>
439
+ oid === 16 ? (value: string) => value === 't' : String,
440
+ })
389
441
  }
390
442
 
391
443
  it('BEGIN roundtrip', () => {
@@ -490,6 +542,23 @@ describe('pgoutput-encoder', () => {
490
542
  expect(parsed.key).toBeNull()
491
543
  })
492
544
 
545
+ it('UPDATE roundtrip decodes sqlite boolean integers through zero-cache parser', () => {
546
+ const oid = getTableOid('rt.bool_update')
547
+ const cols: ColumnInfo[] = [
548
+ { name: 'id', typeOid: 25, typeMod: -1, isKey: true },
549
+ { name: 'completed', typeOid: 16, typeMod: -1 },
550
+ ]
551
+ const parser = makeBoolAwareParser()
552
+ parser.parse(encodeRelation(oid, 'public', 'bool_update', 0x64, cols))
553
+
554
+ const parsed = parser.parse(
555
+ encodeUpdate(oid, { id: '1', completed: 1 }, { id: '1', completed: 0 }, cols)
556
+ )
557
+ expect(parsed.tag).toBe('update')
558
+ expect(parsed.old.completed).toBe(false)
559
+ expect(parsed.new.completed).toBe(true)
560
+ })
561
+
493
562
  it('DELETE roundtrip', () => {
494
563
  const oid = getTableOid('rt.del_test')
495
564
  const cols: ColumnInfo[] = [
@@ -9,21 +9,32 @@
9
9
 
10
10
  // postgres epoch: 2000-01-01 in microseconds from unix epoch
11
11
  const PG_EPOCH_MICROS = 946684800000000n
12
+ const PG_TYPE_BOOL = 16
13
+ const PG_TYPE_TIMESTAMP = 1114
14
+ const PG_TYPE_TIMESTAMPTZ = 1184
12
15
 
13
16
  // shared encoder instance - avoids per-call allocation
14
17
  const encoder = new TextEncoder()
15
18
 
16
- // table oid tracking
17
- const tableOids = new Map<string, number>()
18
- let nextOid = 16384
19
+ function flattenRelationName(tableName: string): string {
20
+ const dot = tableName.indexOf('.')
21
+ if (dot < 0) return tableName
22
+ const schema = tableName.slice(0, dot)
23
+ const name = tableName.slice(dot + 1)
24
+ if (schema === 'public') return name
25
+ if (schema === '_orez' && name === '_zero_changes') return '_zero_changes'
26
+ if (schema === '_orez' && name === '_zero_replication_slots')
27
+ return '_orez__zero_replication_slots'
28
+ if (schema === '_orez') return `_orez__${name}`
29
+ if (schema === '_zero') return `_zero_${name}`
30
+ return `${schema}_${name}`
31
+ }
19
32
 
20
33
  function getTableOid(tableName: string): number {
21
- let oid = tableOids.get(tableName)
22
- if (!oid) {
23
- oid = nextOid++
24
- tableOids.set(tableName, oid)
25
- }
26
- return oid
34
+ let hash = 0
35
+ const key = `table:${flattenRelationName(tableName)}`
36
+ for (let i = 0; i < key.length; i++) hash = (hash * 33 + key.charCodeAt(i)) >>> 0
37
+ return 50_000 + (hash % 10_000_000)
27
38
  }
28
39
 
29
40
  export interface ColumnInfo {
@@ -42,6 +53,50 @@ export function inferColumns(row: Record<string, unknown>): ColumnInfo[] {
42
53
  }))
43
54
  }
44
55
 
56
+ function postgresBooleanText(value: unknown): string | null {
57
+ if (typeof value === 'boolean') return value ? 't' : 'f'
58
+ if (typeof value === 'number') return value === 0 ? 'f' : 't'
59
+ if (typeof value === 'bigint') return value === 0n ? 'f' : 't'
60
+ if (typeof value !== 'string') return null
61
+ switch (value.trim().toLowerCase()) {
62
+ case 't':
63
+ case 'true':
64
+ case '1':
65
+ return 't'
66
+ case 'f':
67
+ case 'false':
68
+ case '0':
69
+ return 'f'
70
+ default:
71
+ return null
72
+ }
73
+ }
74
+
75
+ function postgresTupleTextValue(value: unknown, column: ColumnInfo): string {
76
+ if (column.typeOid === PG_TYPE_BOOL) {
77
+ const booleanText = postgresBooleanText(value)
78
+ if (booleanText !== null) return booleanText
79
+ }
80
+
81
+ if (typeof value === 'boolean') return value ? 't' : 'f'
82
+ if (typeof value === 'object') return JSON.stringify(value)
83
+
84
+ let strVal = String(value)
85
+ // normalize ISO timestamps to postgres text format.
86
+ // to_jsonb() produces "2026-03-19T07:20:11.643" but postgres
87
+ // pgoutput sends "2026-03-19 07:20:11.643" (space, no T).
88
+ // mismatch causes zero-cache to see different values during
89
+ // mutation reconciliation, triggering unnecessary rebases.
90
+ if (
91
+ (column.typeOid === PG_TYPE_TIMESTAMP || column.typeOid === PG_TYPE_TIMESTAMPTZ) &&
92
+ typeof value === 'string' &&
93
+ value.length >= 19
94
+ ) {
95
+ strVal = strVal.replace('T', ' ')
96
+ }
97
+ return strVal
98
+ }
99
+
45
100
  // reusable scratch buffer for building messages (64KB, grows if needed)
46
101
  let scratch = new Uint8Array(65536)
47
102
  let scratchView = new DataView(scratch.buffer)
@@ -175,27 +230,7 @@ function encodeTupleDataInto(
175
230
  ensureScratch(pos + 1)
176
231
  scratch[pos++] = 0x6e // 'n' for null
177
232
  } else {
178
- // convert to postgresql text format
179
- let strVal: string
180
- if (typeof val === 'boolean') {
181
- strVal = val ? 't' : 'f'
182
- } else if (typeof val === 'object') {
183
- strVal = JSON.stringify(val)
184
- } else {
185
- strVal = String(val)
186
- // normalize ISO timestamps to postgres text format.
187
- // to_jsonb() produces "2026-03-19T07:20:11.643" but postgres
188
- // pgoutput sends "2026-03-19 07:20:11.643" (space, no T).
189
- // mismatch causes zero-cache to see different values during
190
- // mutation reconciliation, triggering unnecessary rebases.
191
- if (
192
- (col.typeOid === 1114 || col.typeOid === 1184) &&
193
- typeof val === 'string' &&
194
- val.length >= 19
195
- ) {
196
- strVal = strVal.replace('T', ' ')
197
- }
198
- }
233
+ const strVal = postgresTupleTextValue(val, col)
199
234
  const bytes = encoder.encode(strVal)
200
235
  ensureScratch(pos + 1 + 4 + bytes.length)
201
236
  scratch[pos++] = 0x74 // 't' for text
@@ -5,6 +5,7 @@ import {
5
5
  getBrowserDefine,
6
6
  getBrowserBuildConfig,
7
7
  } from './browser-build-config.js'
8
+ import { getHeapStatistics } from './shims/node-stub.js'
8
9
 
9
10
  describe('browser build config', () => {
10
11
  describe('getBrowserAliases', () => {
@@ -19,6 +20,7 @@ describe('browser build config', () => {
19
20
  expect(aliases['@rocicorp/zero-sqlite3']).toBe('orez/worker/shims/sqlite')
20
21
  expect(aliases.fastify).toBe('orez/worker/shims/fastify')
21
22
  expect(aliases.ws).toBe('orez/worker/shims/ws')
23
+ expect(aliases.oxfmt).toBe('orez/worker/shims/oxfmt')
22
24
  })
23
25
 
24
26
  it('includes Node.js polyfills', () => {
@@ -26,6 +28,7 @@ describe('browser build config', () => {
26
28
  expect(aliases['node:events']).toBe('events')
27
29
  expect(aliases['node:stream']).toBe('orez/worker/shims/stream-browser')
28
30
  expect(aliases['node:path']).toBe('path-browserify')
31
+ expect(aliases['node:os']).toBe('orez/worker/shims/node-stub')
29
32
  })
30
33
 
31
34
  it('includes Node.js stubs', () => {
@@ -35,6 +38,7 @@ describe('browser build config', () => {
35
38
  expect(aliases['node:child_process']).toBe('orez/worker/shims/node-stub')
36
39
  expect(aliases['node:http']).toBe('orez/worker/shims/node-stub')
37
40
  expect(aliases['node:crypto']).toBe('orez/worker/shims/node-stub')
41
+ expect(aliases['node:v8']).toBe('orez/worker/shims/node-stub')
38
42
  })
39
43
  })
40
44
 
@@ -56,4 +60,12 @@ describe('browser build config', () => {
56
60
  expect(config.bundle).toBe(true)
57
61
  })
58
62
  })
63
+
64
+ describe('node:v8 shim', () => {
65
+ it('reports a positive worker heap budget', () => {
66
+ const stats = getHeapStatistics()
67
+ expect(stats.heap_size_limit).toBe(128 * 1024 * 1024)
68
+ expect(stats.heap_size_limit - stats.used_heap_size).toBeGreaterThan(0)
69
+ })
70
+ })
59
71
  })
@@ -49,6 +49,7 @@ export function getBrowserAliases(): Record<string, string> {
49
49
  '@rocicorp/zero-sqlite3': 'orez/worker/shims/sqlite',
50
50
  fastify: 'orez/worker/shims/fastify',
51
51
  ws: 'orez/worker/shims/ws',
52
+ oxfmt: 'orez/worker/shims/oxfmt',
52
53
 
53
54
  // -- Node.js built-in polyfills --
54
55
  // these are needed because zero-cache imports node: modules.
@@ -60,7 +61,7 @@ export function getBrowserAliases(): Record<string, string> {
60
61
  'crypto-browserify': 'orez/worker/shims/node-stub',
61
62
  'node:stream': 'orez/worker/shims/stream-browser',
62
63
  'node:path': 'path-browserify',
63
- 'node:os': 'os-browserify/browser',
64
+ 'node:os': 'orez/worker/shims/node-stub',
64
65
 
65
66
  // -- stubs for Node.js modules that zero-cache imports but doesn't --
66
67
  // -- use in SINGLE_PROCESS mode --