orez 0.1.43 → 0.1.45

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 (111) hide show
  1. package/dist/admin/http-proxy.d.ts.map +1 -1
  2. package/dist/admin/http-proxy.js +3 -1
  3. package/dist/admin/http-proxy.js.map +1 -1
  4. package/dist/admin/log-store.d.ts.map +1 -1
  5. package/dist/admin/log-store.js +5 -1
  6. package/dist/admin/log-store.js.map +1 -1
  7. package/dist/admin/server.d.ts.map +1 -1
  8. package/dist/admin/server.js +25 -25
  9. package/dist/admin/server.js.map +1 -1
  10. package/dist/browser.d.ts +54 -0
  11. package/dist/browser.d.ts.map +1 -0
  12. package/dist/browser.js +110 -0
  13. package/dist/browser.js.map +1 -0
  14. package/dist/cli.js +1 -1
  15. package/dist/cli.js.map +1 -1
  16. package/dist/index.d.ts +1 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +5 -2
  19. package/dist/index.js.map +1 -1
  20. package/dist/pg-proxy-browser.d.ts +26 -0
  21. package/dist/pg-proxy-browser.d.ts.map +1 -0
  22. package/dist/pg-proxy-browser.js +1460 -0
  23. package/dist/pg-proxy-browser.js.map +1 -0
  24. package/dist/pg-proxy.d.ts.map +1 -1
  25. package/dist/pg-proxy.js +48 -34
  26. package/dist/pg-proxy.js.map +1 -1
  27. package/dist/pglite-ipc.d.ts.map +1 -1
  28. package/dist/pglite-ipc.js +3 -2
  29. package/dist/pglite-ipc.js.map +1 -1
  30. package/dist/pglite-manager.d.ts.map +1 -1
  31. package/dist/pglite-manager.js +101 -111
  32. package/dist/pglite-manager.js.map +1 -1
  33. package/dist/pglite-web-proxy.d.ts +38 -0
  34. package/dist/pglite-web-proxy.d.ts.map +1 -0
  35. package/dist/pglite-web-proxy.js +155 -0
  36. package/dist/pglite-web-proxy.js.map +1 -0
  37. package/dist/pglite-web-worker.d.ts +24 -0
  38. package/dist/pglite-web-worker.d.ts.map +1 -0
  39. package/dist/pglite-web-worker.js +139 -0
  40. package/dist/pglite-web-worker.js.map +1 -0
  41. package/dist/pglite-worker-thread.js +65 -24
  42. package/dist/pglite-worker-thread.js.map +1 -1
  43. package/dist/recovery.js +2 -2
  44. package/dist/recovery.js.map +1 -1
  45. package/dist/replication/change-tracker.js +9 -9
  46. package/dist/replication/change-tracker.js.map +1 -1
  47. package/dist/replication/handler.d.ts.map +1 -1
  48. package/dist/replication/handler.js +34 -26
  49. package/dist/replication/handler.js.map +1 -1
  50. package/dist/worker/browser-build-config.d.ts.map +1 -1
  51. package/dist/worker/browser-build-config.js +5 -2
  52. package/dist/worker/browser-build-config.js.map +1 -1
  53. package/dist/worker/browser-embed.d.ts.map +1 -1
  54. package/dist/worker/browser-embed.js +31 -26
  55. package/dist/worker/browser-embed.js.map +1 -1
  56. package/dist/worker/shims/fastify.d.ts +1 -0
  57. package/dist/worker/shims/fastify.d.ts.map +1 -1
  58. package/dist/worker/shims/fastify.js +31 -20
  59. package/dist/worker/shims/fastify.js.map +1 -1
  60. package/dist/worker/shims/postgres-browser.d.ts +12 -0
  61. package/dist/worker/shims/postgres-browser.d.ts.map +1 -0
  62. package/dist/worker/shims/postgres-browser.js +52 -0
  63. package/dist/worker/shims/postgres-browser.js.map +1 -0
  64. package/dist/worker/shims/postgres-socket.d.ts +83 -0
  65. package/dist/worker/shims/postgres-socket.d.ts.map +1 -0
  66. package/dist/worker/shims/postgres-socket.js +278 -0
  67. package/dist/worker/shims/postgres-socket.js.map +1 -0
  68. package/dist/worker/shims/postgres.d.ts.map +1 -1
  69. package/dist/worker/shims/postgres.js +18 -9
  70. package/dist/worker/shims/postgres.js.map +1 -1
  71. package/dist/worker/shims/stream-browser.d.ts +5 -4
  72. package/dist/worker/shims/stream-browser.d.ts.map +1 -1
  73. package/dist/worker/shims/stream-browser.js +7 -6
  74. package/dist/worker/shims/stream-browser.js.map +1 -1
  75. package/dist/worker/shims/ws-browser.d.ts.map +1 -1
  76. package/dist/worker/shims/ws-browser.js +43 -21
  77. package/dist/worker/shims/ws-browser.js.map +1 -1
  78. package/dist/worker/shims/ws.d.ts.map +1 -1
  79. package/dist/worker/shims/ws.js +81 -17
  80. package/dist/worker/shims/ws.js.map +1 -1
  81. package/package.json +11 -58
  82. package/src/admin/http-proxy.ts +4 -1
  83. package/src/admin/log-store.ts +5 -1
  84. package/src/admin/server.ts +26 -25
  85. package/src/browser.ts +195 -0
  86. package/src/cli.ts +1 -1
  87. package/src/index.ts +5 -2
  88. package/src/integration/integration.test.ts +1 -1
  89. package/src/integration/restore-live-stress.test.ts +2 -2
  90. package/src/pg-proxy-browser.ts +1673 -0
  91. package/src/pg-proxy.ts +48 -40
  92. package/src/pglite-ipc.ts +3 -2
  93. package/src/pglite-manager.ts +115 -133
  94. package/src/pglite-web-proxy.ts +180 -0
  95. package/src/pglite-web-worker.ts +152 -0
  96. package/src/pglite-worker-thread.ts +66 -24
  97. package/src/recovery.ts +2 -2
  98. package/src/replication/change-tracker.test.ts +1 -1
  99. package/src/replication/change-tracker.ts +9 -9
  100. package/src/replication/handler.ts +37 -26
  101. package/src/worker/browser-build-config.test.ts +1 -1
  102. package/src/worker/browser-build-config.ts +5 -2
  103. package/src/worker/browser-embed.ts +33 -30
  104. package/src/worker/shims/fastify.ts +37 -24
  105. package/src/worker/shims/postgres-browser.ts +59 -0
  106. package/src/worker/shims/postgres-socket.test.ts +576 -0
  107. package/src/worker/shims/postgres-socket.ts +310 -0
  108. package/src/worker/shims/postgres.ts +30 -15
  109. package/src/worker/shims/stream-browser.ts +15 -0
  110. package/src/worker/shims/ws-browser.ts +38 -20
  111. package/src/worker/shims/ws.ts +76 -21
@@ -0,0 +1,152 @@
1
+ /**
2
+ * PGlite Web Worker — browser equivalent of pglite-worker-thread.ts.
3
+ *
4
+ * runs a single PGlite instance in a Web Worker. receives commands via
5
+ * postMessage, executes on PGlite, sends results back. ArrayBuffers
6
+ * are transferred (not copied) for execProtocolRaw.
7
+ *
8
+ * message protocol (same as pglite-worker-thread.ts):
9
+ * init: { type: 'init', dataDir, name, withExtensions, pgliteOptions }
10
+ * → { type: 'ready' }
11
+ *
12
+ * execProtocolRaw: { type: 'execProtocolRaw', id, data: ArrayBuffer, options }
13
+ * → { type: 'result', id, data: ArrayBuffer }
14
+ *
15
+ * query: { type: 'query', id, sql, params }
16
+ * → { type: 'result', id, rows, affectedRows }
17
+ *
18
+ * exec: { type: 'exec', id, sql }
19
+ * → { type: 'result', id, results: [{ affectedRows }] }
20
+ *
21
+ * listen/unlisten/close: same as pglite-worker-thread.ts
22
+ */
23
+
24
+ // NOTE: this file is meant to be bundled with PGlite as external
25
+ // the consumer provides PGlite via importScripts or ESM import
26
+
27
+ declare const self: any
28
+
29
+ const listeners = new Map<number, () => Promise<void>>()
30
+ let db: any // PGlite instance — type depends on how it's loaded
31
+
32
+ self.onmessage = async (ev: MessageEvent) => {
33
+ const msg = ev.data
34
+ if (!msg || typeof msg !== 'object') return
35
+ const { type, id } = msg
36
+
37
+ try {
38
+ switch (type) {
39
+ case 'init': {
40
+ // dynamically import PGlite (external, provided by consumer's bundler)
41
+ const { PGlite } = await import('@electric-sql/pglite')
42
+ db = new PGlite({
43
+ dataDir: msg.dataDir || 'idb://orez-pglite',
44
+ relaxedDurability: true,
45
+ initialMemory: 32 * 1024 * 1024,
46
+ startParams: [
47
+ '--single',
48
+ '-F',
49
+ '-O',
50
+ '-j',
51
+ '-c',
52
+ 'search_path=public',
53
+ '-c',
54
+ 'exit_on_error=false',
55
+ '-c',
56
+ 'log_checkpoints=false',
57
+ '-c',
58
+ 'shared_buffers=4MB',
59
+ '-c',
60
+ 'wal_buffers=1MB',
61
+ ],
62
+ ...(msg.pgliteOptions || {}),
63
+ // extensions loaded by consumer if needed
64
+ })
65
+ await db.waitReady
66
+
67
+ // tune postgres internals — modest values for embedded use
68
+ await db.exec(`
69
+ SET work_mem = '4MB';
70
+ SET maintenance_work_mem = '16MB';
71
+ SET effective_cache_size = '64MB';
72
+ SET random_page_cost = 1.1;
73
+ SET jit = off;
74
+ `)
75
+
76
+ self.postMessage({ type: 'ready' })
77
+ break
78
+ }
79
+
80
+ case 'execProtocolRaw': {
81
+ const input = new Uint8Array(msg.data as ArrayBuffer)
82
+ const result = await db.execProtocolRaw(input, msg.options)
83
+ const buf = new ArrayBuffer(result.byteLength)
84
+ new Uint8Array(buf).set(result)
85
+ self.postMessage({ type: 'result', id, data: buf }, [buf])
86
+ break
87
+ }
88
+
89
+ case 'query': {
90
+ const result = await db.query(msg.sql, msg.params)
91
+ self.postMessage({
92
+ type: 'result',
93
+ id,
94
+ rows: result.rows,
95
+ affectedRows: result.affectedRows,
96
+ })
97
+ break
98
+ }
99
+
100
+ case 'exec': {
101
+ const result = await db.exec(msg.sql)
102
+ const results = result.map((r: any) => ({ affectedRows: r.affectedRows ?? 0 }))
103
+ self.postMessage({ type: 'result', id, results })
104
+ break
105
+ }
106
+
107
+ case 'listen': {
108
+ const unsub = await db.listen(msg.channel, (payload: string) => {
109
+ self.postMessage({ type: 'notification', channel: msg.channel, payload })
110
+ })
111
+ listeners.set(id, unsub)
112
+ self.postMessage({ type: 'result', id })
113
+ break
114
+ }
115
+
116
+ case 'unlisten': {
117
+ const unsub = listeners.get(msg.listenId)
118
+ if (unsub) {
119
+ await unsub()
120
+ listeners.delete(msg.listenId)
121
+ }
122
+ self.postMessage({ type: 'result', id })
123
+ break
124
+ }
125
+
126
+ case 'close': {
127
+ for (const unsub of listeners.values()) {
128
+ await unsub().catch(() => {})
129
+ }
130
+ listeners.clear()
131
+ await db.close()
132
+ self.postMessage({ type: 'result', id })
133
+ break
134
+ }
135
+
136
+ default:
137
+ self.postMessage({
138
+ type: 'error',
139
+ id,
140
+ message: `unknown message type: ${type}`,
141
+ })
142
+ }
143
+ } catch (err: unknown) {
144
+ const error = err as { message?: string; code?: string }
145
+ self.postMessage({
146
+ type: 'error',
147
+ id,
148
+ message: error?.message || String(err),
149
+ code: error?.code,
150
+ })
151
+ }
152
+ }
@@ -38,42 +38,84 @@ const listeners = new Map<number, () => Promise<void>>()
38
38
 
39
39
  let db: PGlite
40
40
 
41
+ const PGLITE_BASE_FLAGS = [
42
+ '--single',
43
+ '-F',
44
+ '-O',
45
+ '-j',
46
+ '-c',
47
+ 'search_path=public',
48
+ '-c',
49
+ 'exit_on_error=false',
50
+ '-c',
51
+ 'log_checkpoints=false',
52
+ ]
53
+
54
+ const ZERO_START_PARAMS = [
55
+ ...PGLITE_BASE_FLAGS,
56
+ '-c',
57
+ 'shared_buffers=128kB',
58
+ '-c',
59
+ 'wal_buffers=64kB',
60
+ '-c',
61
+ 'work_mem=64kB',
62
+ '-c',
63
+ 'maintenance_work_mem=1MB',
64
+ '-c',
65
+ 'temp_buffers=800kB',
66
+ ]
67
+
41
68
  async function init() {
42
69
  const { dataDir: _userDataDir, debug: _dbg, ...userOpts } = config.pgliteOptions || {}
70
+ const isMain = config.withExtensions
43
71
 
44
72
  db = new PGlite({
45
73
  dataDir: config.dataDir,
46
74
  debug: config.debug,
47
75
  relaxedDurability: true,
48
- ...userOpts,
49
- extensions: config.withExtensions
50
- ? userOpts.extensions || {
51
- vector,
52
- pg_trgm,
53
- pgcrypto,
54
- uuid_ossp,
55
- citext,
56
- hstore,
57
- ltree,
58
- fuzzystrmatch,
59
- btree_gin,
60
- btree_gist,
61
- cube,
62
- earthdistance,
76
+ initialMemory: isMain ? 32 * 1024 * 1024 : 16 * 1024 * 1024,
77
+ ...(isMain ? {} : { startParams: ZERO_START_PARAMS }),
78
+ ...(isMain
79
+ ? {
80
+ startParams: [
81
+ ...PGLITE_BASE_FLAGS,
82
+ '-c',
83
+ 'shared_buffers=4MB',
84
+ '-c',
85
+ 'wal_buffers=1MB',
86
+ ],
87
+ ...userOpts,
88
+ extensions: userOpts.extensions || {
89
+ vector,
90
+ pg_trgm,
91
+ pgcrypto,
92
+ uuid_ossp,
93
+ citext,
94
+ hstore,
95
+ ltree,
96
+ fuzzystrmatch,
97
+ btree_gin,
98
+ btree_gist,
99
+ cube,
100
+ earthdistance,
101
+ },
63
102
  }
64
- : {},
103
+ : { extensions: {} }),
65
104
  } as any)
66
105
 
67
106
  await db.waitReady
68
107
 
69
- // tune postgres internals
70
- await db.exec(`
71
- SET work_mem = '64MB';
72
- SET maintenance_work_mem = '128MB';
73
- SET effective_cache_size = '512MB';
74
- SET random_page_cost = 1.1;
75
- SET jit = off;
76
- `)
108
+ if (isMain) {
109
+ await db.exec(`
110
+ SET work_mem = '4MB';
111
+ SET maintenance_work_mem = '16MB';
112
+ SET effective_cache_size = '64MB';
113
+ SET random_page_cost = 1.1;
114
+ SET jit = off;
115
+ `)
116
+ } else {
117
+ await db.exec(`SET jit = off;`)
118
+ }
77
119
 
78
120
  port.postMessage({ type: 'ready' })
79
121
  }
package/src/recovery.ts CHANGED
@@ -100,9 +100,9 @@ export async function recoverFromCdcCorruption(ctx: RecoveryContext): Promise<vo
100
100
 
101
101
  // clear upstream replication tracking
102
102
  const db = instances.postgres
103
- await db.exec(`TRUNCATE _orez.changes`).catch(() => {})
103
+ await db.exec(`TRUNCATE _orez._zero_changes`).catch(() => {})
104
104
  await db.exec(`TRUNCATE _orez._zero_replication_slots`).catch(() => {})
105
- await db.exec(`ALTER SEQUENCE _orez.watermark RESTART WITH 1`).catch(() => {})
105
+ await db.exec(`ALTER SEQUENCE _orez._zero_watermark RESTART WITH 1`).catch(() => {})
106
106
 
107
107
  // drop stale shard schemas
108
108
  const shardSchemas = await db.query<{ schemaname: string }>(
@@ -151,7 +151,7 @@ describe('change-tracker', () => {
151
151
  try {
152
152
  await db.exec(`CREATE PUBLICATION "zero_scope"`)
153
153
  await installChangeTracking(db) // reinstall picks up publication scope
154
- await db.exec(`TRUNCATE _orez.changes`)
154
+ await db.exec(`TRUNCATE _orez._zero_changes`)
155
155
 
156
156
  await db.exec(`INSERT INTO public.items (name, value) VALUES ('x', 1)`)
157
157
  const changes = await getChangesSince(db, 0)
@@ -17,10 +17,10 @@ export async function installChangeTracking(db: PGlite): Promise<void> {
17
17
  // create changes table and watermark sequence
18
18
  // watermark is the primary key - monotonically increasing, no separate id needed
19
19
  await db.exec(`
20
- CREATE SEQUENCE IF NOT EXISTS _orez.watermark;
20
+ CREATE SEQUENCE IF NOT EXISTS _orez._zero_watermark;
21
21
 
22
- CREATE TABLE IF NOT EXISTS _orez.changes (
23
- watermark BIGINT NOT NULL DEFAULT nextval('_orez.watermark') PRIMARY KEY,
22
+ CREATE TABLE IF NOT EXISTS _orez._zero_changes (
23
+ watermark BIGINT NOT NULL DEFAULT nextval('_orez._zero_watermark') PRIMARY KEY,
24
24
  table_name TEXT NOT NULL,
25
25
  op TEXT NOT NULL CHECK (op IN ('INSERT', 'UPDATE', 'DELETE')),
26
26
  row_data JSONB,
@@ -47,7 +47,7 @@ export async function installChangeTracking(db: PGlite): Promise<void> {
47
47
  CREATE OR REPLACE FUNCTION public._zero_track_change() RETURNS TRIGGER AS $$
48
48
  BEGIN
49
49
  IF TG_OP = 'DELETE' THEN
50
- INSERT INTO _orez.changes (table_name, op, old_data)
50
+ INSERT INTO _orez._zero_changes (table_name, op, old_data)
51
51
  VALUES (TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME, 'DELETE', to_jsonb(OLD));
52
52
  RETURN OLD;
53
53
  ELSIF TG_OP = 'UPDATE' THEN
@@ -55,11 +55,11 @@ export async function installChangeTracking(db: PGlite): Promise<void> {
55
55
  IF to_jsonb(NEW) = to_jsonb(OLD) THEN
56
56
  RETURN NEW;
57
57
  END IF;
58
- INSERT INTO _orez.changes (table_name, op, row_data, old_data)
58
+ INSERT INTO _orez._zero_changes (table_name, op, row_data, old_data)
59
59
  VALUES (TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME, 'UPDATE', to_jsonb(NEW), to_jsonb(OLD));
60
60
  RETURN NEW;
61
61
  ELSE
62
- INSERT INTO _orez.changes (table_name, op, row_data)
62
+ INSERT INTO _orez._zero_changes (table_name, op, row_data)
63
63
  VALUES (TG_TABLE_SCHEMA || '.' || TG_TABLE_NAME, 'INSERT', to_jsonb(NEW));
64
64
  RETURN NEW;
65
65
  END IF;
@@ -228,7 +228,7 @@ export async function getChangesSince(
228
228
  limit = 50000
229
229
  ): Promise<ChangeRecord[]> {
230
230
  const result = await db.query<ChangeRecord>(
231
- 'SELECT watermark, table_name, op, row_data, old_data FROM _orez.changes WHERE watermark > $1 ORDER BY watermark LIMIT $2',
231
+ 'SELECT watermark, table_name, op, row_data, old_data FROM _orez._zero_changes WHERE watermark > $1 ORDER BY watermark LIMIT $2',
232
232
  [watermark, limit]
233
233
  )
234
234
  return result.rows
@@ -239,14 +239,14 @@ export async function purgeConsumedChanges(
239
239
  watermark: number
240
240
  ): Promise<number> {
241
241
  const result = await db.exec(
242
- `DELETE FROM _orez.changes WHERE watermark <= ${Number(watermark)}`
242
+ `DELETE FROM _orez._zero_changes WHERE watermark <= ${Number(watermark)}`
243
243
  )
244
244
  return result[0]?.affectedRows ?? 0
245
245
  }
246
246
 
247
247
  export async function getCurrentWatermark(db: PGlite): Promise<number> {
248
248
  const result = await db.query<{ last_value: string; is_called: boolean }>(
249
- 'SELECT last_value, is_called FROM _orez.watermark'
249
+ 'SELECT last_value, is_called FROM _orez._zero_watermark'
250
250
  )
251
251
  const { last_value, is_called } = result.rows[0]
252
252
  if (!is_called) return 0
@@ -32,6 +32,32 @@ import {
32
32
  import type { Mutex } from '../mutex.js'
33
33
  import type { PGlite } from '@electric-sql/pglite'
34
34
 
35
+ // types pglite can't replicate — excluded from change tracking columns
36
+ const UNSUPPORTED_TYPES = new Set(['tsvector', 'tsquery', 'USER-DEFINED'])
37
+
38
+ // pg data_type string → wire protocol oid mapping
39
+ const PG_DATA_TYPE_OIDS: Record<string, number> = {
40
+ boolean: 16,
41
+ bytea: 17,
42
+ bigint: 20,
43
+ smallint: 21,
44
+ integer: 23,
45
+ text: 25,
46
+ json: 114,
47
+ real: 700,
48
+ 'double precision': 701,
49
+ character: 1042,
50
+ 'character varying': 1043,
51
+ date: 1082,
52
+ 'time without time zone': 1083,
53
+ 'timestamp without time zone': 1114,
54
+ 'timestamp with time zone': 1184,
55
+ 'time with time zone': 1266,
56
+ numeric: 1700,
57
+ uuid: 2950,
58
+ jsonb: 3802,
59
+ }
60
+
35
61
  export interface ReplicationWriter {
36
62
  write(data: Uint8Array): void
37
63
  readonly closed?: boolean
@@ -393,7 +419,7 @@ export async function handleStartReplication(
393
419
  const all = await db.query<{ tablename: string }>(
394
420
  `SELECT tablename FROM pg_tables
395
421
  WHERE schemaname = 'public'
396
- AND tablename NOT IN ('migrations', 'changes')
422
+ AND tablename NOT IN ('migrations', '_zero_changes')
397
423
  AND tablename NOT LIKE '_zero_%'`
398
424
  )
399
425
  tables = all.rows
@@ -403,7 +429,7 @@ export async function handleStartReplication(
403
429
  const ddlParts: string[] = [
404
430
  `CREATE OR REPLACE FUNCTION public._zero_notify_change() RETURNS TRIGGER AS $$
405
431
  BEGIN
406
- PERFORM pg_notify('changes', TG_TABLE_NAME);
432
+ PERFORM pg_notify('_zero_changes', TG_TABLE_NAME);
407
433
  RETURN NULL;
408
434
  END;
409
435
  $$ LANGUAGE plpgsql;`,
@@ -499,28 +525,6 @@ export async function handleStartReplication(
499
525
  }
500
526
  keys.add(row.column_name)
501
527
  } else {
502
- const UNSUPPORTED_TYPES = new Set(['tsvector', 'tsquery', 'USER-DEFINED'])
503
- const PG_DATA_TYPE_OIDS: Record<string, number> = {
504
- boolean: 16,
505
- bytea: 17,
506
- bigint: 20,
507
- smallint: 21,
508
- integer: 23,
509
- text: 25,
510
- json: 114,
511
- real: 700,
512
- 'double precision': 701,
513
- character: 1042,
514
- 'character varying': 1043,
515
- date: 1082,
516
- 'time without time zone': 1083,
517
- 'timestamp without time zone': 1114,
518
- 'timestamp with time zone': 1184,
519
- 'time with time zone': 1266,
520
- numeric: 1700,
521
- uuid: 2950,
522
- jsonb: 3802,
523
- }
524
528
  if (row.data_type && UNSUPPORTED_TYPES.has(row.data_type)) {
525
529
  let cols = excludedColumns.get(key)
526
530
  if (!cols) {
@@ -612,8 +616,8 @@ export async function handleStartReplication(
612
616
  // also set up LISTEN as secondary signal
613
617
  let unsubscribe: (() => Promise<void>) | null = null
614
618
  try {
615
- unsubscribe = await db.listen('changes', wakeup)
616
- log.debug.proxy('replication: listening for changes notifications')
619
+ unsubscribe = await db.listen('_zero_changes', wakeup)
620
+ log.debug.proxy('replication: listening for _zero_changes notifications')
617
621
  } catch {
618
622
  log.debug.proxy('replication: LISTEN not available')
619
623
  }
@@ -962,6 +966,13 @@ async function streamChanges(
962
966
  for (const msg of messages) {
963
967
  writer.write(msg)
964
968
  }
969
+
970
+ // hook for arch instrumentation (soot-arch sq-write events)
971
+ const hook = (globalThis as any).__orez_on_repl_commit
972
+ if (hook) {
973
+ const tables = new Set(changes.map((c) => c.table_name))
974
+ hook({ changes: changes.length, tables: [...tables], txId })
975
+ }
965
976
  }
966
977
 
967
978
  function normalizeShardClientsRow(
@@ -24,7 +24,7 @@ describe('browser build config', () => {
24
24
  it('includes Node.js polyfills', () => {
25
25
  const aliases = getBrowserAliases()
26
26
  expect(aliases['node:events']).toBe('events')
27
- expect(aliases['node:stream']).toBe('stream-browserify')
27
+ expect(aliases['node:stream']).toBe('orez/worker/shims/stream-browser')
28
28
  expect(aliases['node:path']).toBe('path-browserify')
29
29
  })
30
30
 
@@ -42,7 +42,10 @@
42
42
  export function getBrowserAliases(): Record<string, string> {
43
43
  return {
44
44
  // -- orez shims for zero-cache dependencies --
45
- postgres: 'orez/worker/shims/postgres',
45
+ // postgres-browser uses the real postgres package with MessagePort transport
46
+ // to pg-proxy-browser, matching orez-node's wire protocol architecture.
47
+ // falls back to old PGlite-wrapping shim if postgres-browser isn't available.
48
+ postgres: 'orez/worker/shims/postgres-browser',
46
49
  '@rocicorp/zero-sqlite3': 'orez/worker/shims/sqlite',
47
50
  fastify: 'orez/worker/shims/fastify',
48
51
  ws: 'orez/worker/shims/ws',
@@ -55,7 +58,7 @@ export function getBrowserAliases(): Record<string, string> {
55
58
  'node:process': 'process/browser',
56
59
  'node:crypto': 'orez/worker/shims/node-stub',
57
60
  'crypto-browserify': 'orez/worker/shims/node-stub',
58
- 'node:stream': 'stream-browserify',
61
+ 'node:stream': 'orez/worker/shims/stream-browser',
59
62
  'node:path': 'path-browserify',
60
63
  'node:os': 'os-browserify/browser',
61
64
 
@@ -171,9 +171,9 @@ export async function startZeroCacheEmbedBrowser(
171
171
  ...((globalThis as any).process.env as Record<string, string>),
172
172
  SINGLE_PROCESS: '1',
173
173
  NODE_ENV: 'development',
174
- ZERO_UPSTREAM_DB: 'pglite://in-process',
175
- ZERO_CVR_DB: 'pglite://in-process',
176
- ZERO_CHANGE_DB: 'pglite://in-process',
174
+ ZERO_UPSTREAM_DB: 'pglite://localhost/postgres',
175
+ ZERO_CVR_DB: 'pglite://localhost/zero_cvr',
176
+ ZERO_CHANGE_DB: 'pglite://localhost/zero_cdb',
177
177
  ZERO_REPLICA_FILE: ':browser-sqlite:',
178
178
  ZERO_PORT: '0',
179
179
  ZERO_APP_ID: appId,
@@ -263,40 +263,43 @@ export async function startZeroCacheEmbedBrowser(
263
263
  },
264
264
 
265
265
  async handleWebSocket(ws: WsLike, url = '/', headers?: Record<string, string>) {
266
- // lazily resolve fastifyInstance — ZeroDispatcher is constructed AFTER the
267
- // ready signal fires, so __orez_fastify_instance may not be set yet.
268
- // the dispatcher's instance is the one with the WS handoff handler,
269
- // and it's always the LAST Fastify() call. we need to wait for it.
270
- //
271
- // check: the dispatcher's server will have 'message' listeners (from
272
- // installWebSocketHandoff). poll until we find an instance with listeners.
266
+ // lazily resolve fastify instances — ZeroDispatcher is constructed AFTER the
267
+ // ready signal fires, so instances may not all be registered yet.
268
+ // poll until we have instances with ws routes (tryHandoff will check).
269
+ let instances: any[] = []
273
270
  for (let i = 0; i < 100; i++) {
271
+ instances = (globalThis as any).__orez_fastify_instances || []
274
272
  fastifyInstance = (globalThis as any).__orez_fastify_instance
275
- // the dispatcher's server has 2+ 'message' listeners:
276
- // 1. from FastifyShim#installWsHandoffHandler (every instance has this)
277
- // 2. from ZeroDispatcher's installWebSocketHandoff (only the dispatcher has this)
278
- // wait for >= 2 to ensure we have the dispatcher's instance
279
- if (fastifyInstance?.server?.listenerCount?.('message') >= 2) break
273
+ // wait until we have at least one instance with 2+ message listeners
274
+ // (the dispatcher's instance has both the shim handler + installWebSocketHandoff)
275
+ if (instances.some((inst: any) => inst?.server?.listenerCount?.('message') >= 2))
276
+ break
280
277
  await new Promise((r) => setTimeout(r, 50))
281
278
  }
282
- if (!isReady || !fastifyInstance?.server) return
283
-
284
- const message = {
285
- url,
286
- headers: headers || {},
287
- method: 'GET',
279
+ if (!isReady) return
280
+
281
+ const handoffMsg = {
282
+ message: {
283
+ url,
284
+ headers: headers || {},
285
+ method: 'GET',
286
+ },
287
+ head: new Uint8Array(0),
288
288
  }
289
289
 
290
- // emit handoff
290
+ // try all fastify instances via tryHandoff, stop at first match
291
+ let handled = false
292
+ for (const inst of instances) {
293
+ if (inst?.tryHandoff?.(handoffMsg, ws)) {
294
+ handled = true
295
+ break
296
+ }
297
+ }
291
298
 
292
- // feed the WebSocket into zero-cache's handoff mechanism.
293
- // the fastify shim's server is an EventEmitter with onMessageType.
294
- // installWebSocketHandoff (non-Server branch) listens for "handoff".
295
- fastifyInstance.server.emit(
296
- 'message',
297
- ['handoff', { message, head: new Uint8Array(0) }],
298
- ws // the WebSocket-like object as sendHandle
299
- )
299
+ // fallback: emit directly on the last instance's server
300
+ if (!handled && fastifyInstance?.server) {
301
+ fastifyInstance.server.emit('message', ['handoff', handoffMsg], ws)
302
+ }
300
303
  },
301
304
 
302
305
  async handleHttp(request: HttpRequest): Promise<HttpResponse> {
@@ -124,32 +124,41 @@ class FastifyShim {
124
124
  // and call the handler with the socket.
125
125
  #installWsHandoffHandler() {
126
126
  this.server.onMessageType('handoff', (msg: any, socket?: any) => {
127
- if (!socket || !msg?.message?.url) return
128
- const url = msg.message.url
129
- const parsedUrl = new URL(url, 'http://localhost')
130
- const pathname = parsedUrl.pathname
131
-
132
- for (const route of this.#wsRoutes) {
133
- if (route.pattern.test(pathname)) {
134
- const req = {
135
- url,
136
- headers: msg.message.headers || {},
137
- method: msg.message.method || 'GET',
138
- }
139
- // wrap socket through handleUpgrade so it gets the full WS API
140
- // (ping, on, once, terminate, etc.) needed by zero-cache's streamOut
141
- this.websocketServer.handleUpgrade(
142
- req,
143
- socket,
144
- Buffer.from(new Uint8Array(0)),
145
- (ws: any) => {
146
- route.handler(ws, req)
147
- }
148
- )
149
- return
127
+ this.tryHandoff(msg, socket)
128
+ })
129
+ }
130
+
131
+ // try to match a handoff message against registered websocket routes.
132
+ // returns true if a route matched, false otherwise.
133
+ // this is public so callers (ws shim, browser-embed) can iterate
134
+ // all fastify instances and stop at the first match.
135
+ tryHandoff(msg: any, socket?: any): boolean {
136
+ if (!socket || !msg?.message?.url) return false
137
+ const url = msg.message.url
138
+ const parsedUrl = new URL(url, 'http://localhost')
139
+ const pathname = parsedUrl.pathname
140
+
141
+ for (const route of this.#wsRoutes) {
142
+ if (route.pattern.test(pathname)) {
143
+ const req = {
144
+ url,
145
+ headers: msg.message.headers || {},
146
+ method: msg.message.method || 'GET',
150
147
  }
148
+ // wrap socket through handleUpgrade so it gets the full WS API
149
+ // (ping, on, once, terminate, etc.) needed by zero-cache's streamOut
150
+ this.websocketServer.handleUpgrade(
151
+ req,
152
+ socket,
153
+ Buffer.from(new Uint8Array(0)),
154
+ (ws: any) => {
155
+ route.handler(ws, req)
156
+ }
157
+ )
158
+ return true
151
159
  }
152
- })
160
+ }
161
+ return false
153
162
  }
154
163
 
155
164
  // route registration — supports optional { websocket: true } option
@@ -286,6 +295,10 @@ function Fastify(_opts?: unknown): FastifyShim {
286
295
  // always overwrite — the ZeroDispatcher (which has the WS handoff routes)
287
296
  // is created LAST, so the final instance is the one handleWebSocket needs.
288
297
  ;(globalThis as any).__orez_fastify_instance = instance
298
+ // track all instances so callers can try handoff against each one
299
+ ;(globalThis as any).__orez_fastify_instances =
300
+ (globalThis as any).__orez_fastify_instances || []
301
+ ;(globalThis as any).__orez_fastify_instances.push(instance)
289
302
  return instance
290
303
  }
291
304