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
package/src/pg-proxy.ts CHANGED
@@ -45,24 +45,45 @@ const SCHEMA_CACHE_TTL_MS = 30_000
45
45
  // performance tracking
46
46
  const proxyStats = { totalWaitMs: 0, totalExecMs: 0, count: 0, batches: 0 }
47
47
 
48
- function isCacheableQuery(query: string): boolean {
49
- const q = query.trimStart().toLowerCase()
50
- return (
51
- (q.includes('information_schema.') ||
52
- q.includes('pg_catalog.') ||
53
- q.includes('pg_tables') ||
54
- q.includes('pg_namespace') ||
55
- q.includes('pg_class') ||
56
- q.includes('pg_attribute') ||
57
- q.includes('pg_type') ||
58
- q.includes('pg_publication')) &&
59
- !q.startsWith('insert') &&
60
- !q.startsWith('update') &&
61
- !q.startsWith('delete') &&
62
- !q.startsWith('create') &&
63
- !q.startsWith('alter') &&
64
- !q.startsWith('drop')
65
- )
48
+ // query classification helpers — operate on pre-normalized (trimmed+lowercased) query strings
49
+ const SCHEMA_QUERY_MARKERS = [
50
+ 'information_schema.',
51
+ 'pg_catalog.',
52
+ 'pg_tables',
53
+ 'pg_namespace',
54
+ 'pg_class',
55
+ 'pg_attribute',
56
+ 'pg_type',
57
+ 'pg_publication',
58
+ ]
59
+ const WRITE_PREFIXES = ['insert', 'update', 'delete', 'copy', 'truncate']
60
+ const DDL_PREFIXES = ['create', 'alter', 'drop']
61
+ const MUTATING_PREFIXES = [...WRITE_PREFIXES, ...DDL_PREFIXES]
62
+
63
+ function isCacheableNormalized(q: string): boolean {
64
+ // fast-fail: mutating queries are never cacheable
65
+ for (const p of MUTATING_PREFIXES) {
66
+ if (q.startsWith(p)) return false
67
+ }
68
+ // check if it touches schema/catalog tables
69
+ for (const marker of SCHEMA_QUERY_MARKERS) {
70
+ if (q.includes(marker)) return true
71
+ }
72
+ return false
73
+ }
74
+
75
+ function isWriteNormalized(q: string): boolean {
76
+ for (const p of WRITE_PREFIXES) {
77
+ if (q.startsWith(p)) return true
78
+ }
79
+ return false
80
+ }
81
+
82
+ function isDDLNormalized(q: string): boolean {
83
+ for (const p of DDL_PREFIXES) {
84
+ if (q.startsWith(p)) return true
85
+ }
86
+ return false
66
87
  }
67
88
 
68
89
  function extractQueryText(data: Uint8Array): string | null {
@@ -795,10 +816,13 @@ export async function startPgProxy(
795
816
  // intercept and rewrite queries
796
817
  data = interceptQuery(data)
797
818
 
798
- // cache Simple Query (0x51) schema queries
819
+ // normalize query once for all classification checks
799
820
  const isSimpleQuery = msgType === 0x51
800
821
  const queryText = isSimpleQuery ? extractQueryText(data) : null
801
- const cacheable = queryText && isCacheableQuery(queryText)
822
+ const queryNorm = queryText ? queryText.trimStart().toLowerCase() : null
823
+ const cacheable = queryNorm && isCacheableNormalized(queryNorm)
824
+
825
+ // cache Simple Query schema queries
802
826
  if (cacheable) {
803
827
  const cached = schemaQueryCache.get(queryText!)
804
828
  if (cached && Date.now() < cached.expiresAt) {
@@ -861,15 +885,8 @@ export async function startPgProxy(
861
885
  }
862
886
  } else {
863
887
  result = await execute()
864
- if (isSimpleQuery && queryText) {
865
- const q = queryText.trimStart().toLowerCase()
866
- if (
867
- q.startsWith('create') ||
868
- q.startsWith('alter') ||
869
- q.startsWith('drop')
870
- ) {
871
- invalidateSchemaCache()
872
- }
888
+ if (queryNorm && isDDLNormalized(queryNorm)) {
889
+ invalidateSchemaCache()
873
890
  }
874
891
  }
875
892
 
@@ -877,17 +894,8 @@ export async function startPgProxy(
877
894
  result = stripResponseMessages(result, stripRfq)
878
895
 
879
896
  // signal replication handler on postgres writes for instant sync
880
- if (dbName === 'postgres' && isSimpleQuery && queryText) {
881
- const q = queryText.trimStart().toLowerCase()
882
- if (
883
- q.startsWith('insert') ||
884
- q.startsWith('update') ||
885
- q.startsWith('delete') ||
886
- q.startsWith('copy') ||
887
- q.startsWith('truncate')
888
- ) {
889
- signalReplicationChange()
890
- }
897
+ if (dbName === 'postgres' && queryNorm && isWriteNormalized(queryNorm)) {
898
+ signalReplicationChange()
891
899
  }
892
900
 
893
901
  return result
package/src/pglite-ipc.ts CHANGED
@@ -26,13 +26,14 @@ interface PendingRequest {
26
26
  const WRITE_PREFIXES = ['insert', 'update', 'delete', 'copy', 'truncate']
27
27
  // shard-internal tables that the replication handler filters out.
28
28
  // signaling for these just causes spurious wakeups + mutex contention.
29
- const SHARD_INTERNAL_TABLES = ['"replicas"', '"mutations"', '"replicationState"']
29
+ // pre-lowercased so we don't call toLowerCase() per iteration
30
+ const SHARD_INTERNAL_TABLES = ['"replicas"', '"mutations"', '"replicationstate"']
30
31
  function isReplicatedWrite(sql: string): boolean {
31
32
  const q = sql.trimStart().toLowerCase()
32
33
  if (!WRITE_PREFIXES.some((p) => q.startsWith(p))) return false
33
34
  // skip shard-internal writes (zero-cache manages these, not replicated)
34
35
  for (const t of SHARD_INTERNAL_TABLES) {
35
- if (q.includes(t.toLowerCase())) return false
36
+ if (q.includes(t)) return false
36
37
  }
37
38
  return true
38
39
  }
@@ -78,6 +78,70 @@ export interface PGliteInstances {
78
78
  cdb: PGlite
79
79
  }
80
80
 
81
+ // shared setup extracted from the 4 factory functions below
82
+
83
+ /** migrate old single-instance pgdata dir to the new pgdata-postgres layout */
84
+ function migrateDataDir(config: ZeroLiteConfig): void {
85
+ const pgliteDataDir = (config.pgliteOptions as Record<string, any>)?.dataDir
86
+ if (!pgliteDataDir || !String(pgliteDataDir).startsWith('memory://')) {
87
+ const oldDataPath = resolve(config.dataDir, 'pgdata')
88
+ const newDataPath = resolve(config.dataDir, 'pgdata-postgres')
89
+ if (existsSync(oldDataPath) && !existsSync(newDataPath)) {
90
+ renameSync(oldDataPath, newDataPath)
91
+ log.debug.pglite('migrated pgdata → pgdata-postgres')
92
+ }
93
+ }
94
+ }
95
+
96
+ /** create publication if ZERO_APP_PUBLICATIONS is set and publication doesn't exist */
97
+ async function ensurePublication(db: {
98
+ exec(sql: string): Promise<any>
99
+ query<T>(sql: string, params?: any[]): Promise<{ rows: T[] }>
100
+ }): Promise<void> {
101
+ await db.exec('CREATE EXTENSION IF NOT EXISTS plpgsql')
102
+
103
+ const pubName = process.env.ZERO_APP_PUBLICATIONS?.trim()
104
+ if (pubName) {
105
+ const pubs = await db.query<{ count: string }>(
106
+ `SELECT count(*) as count FROM pg_publication WHERE pubname = $1`,
107
+ [pubName]
108
+ )
109
+ if (Number(pubs.rows[0].count) === 0) {
110
+ const quoted = '"' + pubName.replace(/"/g, '""') + '"'
111
+ await db.exec(`CREATE PUBLICATION ${quoted}`)
112
+ }
113
+ }
114
+ }
115
+
116
+ // pglite startParams replaces defaults, so always include required flags
117
+ const PGLITE_BASE_FLAGS = [
118
+ '--single',
119
+ '-F',
120
+ '-O',
121
+ '-j',
122
+ '-c',
123
+ 'search_path=public',
124
+ '-c',
125
+ 'exit_on_error=false',
126
+ '-c',
127
+ 'log_checkpoints=false',
128
+ ]
129
+
130
+ // cvr/cdb are just zero-cache bookkeeping — minimal fixed memory
131
+ const ZERO_START_PARAMS = [
132
+ ...PGLITE_BASE_FLAGS,
133
+ '-c',
134
+ 'shared_buffers=128kB',
135
+ '-c',
136
+ 'wal_buffers=64kB',
137
+ '-c',
138
+ 'work_mem=64kB',
139
+ '-c',
140
+ 'maintenance_work_mem=1MB',
141
+ '-c',
142
+ 'temp_buffers=800kB',
143
+ ]
144
+
81
145
  // create a single pglite instance with given dataDir suffix
82
146
  async function createInstance(
83
147
  config: ZeroLiteConfig,
@@ -103,42 +167,57 @@ async function createInstance(
103
167
 
104
168
  log.debug.pglite(`creating ${name} instance at ${dataPath}`)
105
169
 
170
+ const isMain = withExtensions
171
+
106
172
  try {
107
173
  const db = new PGlite({
108
174
  dataDir: dataPath,
109
175
  debug: config.logLevel === 'debug' ? 1 : 0,
110
176
  relaxedDurability: true,
111
- ...userOpts,
112
- extensions: withExtensions
113
- ? userOpts.extensions || {
114
- vector,
115
- pg_trgm,
116
- pgcrypto,
117
- uuid_ossp,
118
- citext,
119
- hstore,
120
- ltree,
121
- fuzzystrmatch,
122
- btree_gin,
123
- btree_gist,
124
- cube,
125
- earthdistance,
177
+ initialMemory: isMain ? 32 * 1024 * 1024 : 16 * 1024 * 1024,
178
+ ...(isMain ? {} : { startParams: ZERO_START_PARAMS }),
179
+ // main instance: user overrides via pgliteOptions, zero instances: fixed
180
+ ...(isMain
181
+ ? {
182
+ startParams: [
183
+ ...PGLITE_BASE_FLAGS,
184
+ '-c',
185
+ 'shared_buffers=4MB',
186
+ '-c',
187
+ 'wal_buffers=1MB',
188
+ ],
189
+ ...userOpts,
190
+ extensions: userOpts.extensions || {
191
+ vector,
192
+ pg_trgm,
193
+ pgcrypto,
194
+ uuid_ossp,
195
+ citext,
196
+ hstore,
197
+ ltree,
198
+ fuzzystrmatch,
199
+ btree_gin,
200
+ btree_gist,
201
+ cube,
202
+ earthdistance,
203
+ },
126
204
  }
127
- : {},
205
+ : { extensions: {} }),
128
206
  })
129
207
 
130
208
  await db.waitReady
131
209
 
132
- // tune postgres internals for throughput over durability.
133
- // pglite defaults are conservative for embedded use — these settings
134
- // match what a real postgres would use for a dev/test workload.
135
- await db.exec(`
136
- SET work_mem = '64MB';
137
- SET maintenance_work_mem = '128MB';
138
- SET effective_cache_size = '512MB';
139
- SET random_page_cost = 1.1;
140
- SET jit = off;
141
- `)
210
+ if (isMain) {
211
+ await db.exec(`
212
+ SET work_mem = '4MB';
213
+ SET maintenance_work_mem = '16MB';
214
+ SET effective_cache_size = '64MB';
215
+ SET random_page_cost = 1.1;
216
+ SET jit = off;
217
+ `)
218
+ } else {
219
+ await db.exec(`SET jit = off;`)
220
+ }
142
221
 
143
222
  log.debug.pglite(`${name} ready`)
144
223
  return db
@@ -174,40 +253,15 @@ async function createInstance(
174
253
  export async function createPGliteInstances(
175
254
  config: ZeroLiteConfig
176
255
  ): Promise<PGliteInstances> {
177
- // migrate from old single-instance layout (pgdata → pgdata-postgres)
178
- const pgliteDataDir = (config.pgliteOptions as Record<string, any>)?.dataDir
179
- if (!pgliteDataDir || !String(pgliteDataDir).startsWith('memory://')) {
180
- const oldDataPath = resolve(config.dataDir, 'pgdata')
181
- const newDataPath = resolve(config.dataDir, 'pgdata-postgres')
182
- if (existsSync(oldDataPath) && !existsSync(newDataPath)) {
183
- renameSync(oldDataPath, newDataPath)
184
- log.debug.pglite('migrated pgdata → pgdata-postgres')
185
- }
186
- }
256
+ migrateDataDir(config)
187
257
 
188
- // create all 3 instances in parallel (only postgres needs app extensions)
189
258
  const [postgres, cvr, cdb] = await Promise.all([
190
259
  createInstance(config, 'postgres', true),
191
260
  createInstance(config, 'cvr', false),
192
261
  createInstance(config, 'cdb', false),
193
262
  ])
194
263
 
195
- // postgres-specific setup
196
- await postgres.exec('CREATE EXTENSION IF NOT EXISTS plpgsql')
197
-
198
- // create publication only when explicitly configured
199
- const pubName = process.env.ZERO_APP_PUBLICATIONS?.trim()
200
- if (pubName) {
201
- const pubs = await postgres.query<{ count: string }>(
202
- `SELECT count(*) as count FROM pg_publication WHERE pubname = $1`,
203
- [pubName]
204
- )
205
- if (Number(pubs.rows[0].count) === 0) {
206
- const quoted = '"' + pubName.replace(/"/g, '""') + '"'
207
- await postgres.exec(`CREATE PUBLICATION ${quoted}`)
208
- }
209
- }
210
-
264
+ await ensurePublication(postgres)
211
265
  return { postgres, cvr, cdb }
212
266
  }
213
267
 
@@ -221,17 +275,9 @@ export async function createPGliteInstances(
221
275
  export async function createPGliteWorkerInstances(
222
276
  config: ZeroLiteConfig
223
277
  ): Promise<PGliteInstances> {
224
- // migrate from old single-instance layout (pgdata → pgdata-postgres)
225
- const pgliteDataDir = (config.pgliteOptions as Record<string, any>)?.dataDir
226
- if (!pgliteDataDir || !String(pgliteDataDir).startsWith('memory://')) {
227
- const oldDataPath = resolve(config.dataDir, 'pgdata')
228
- const newDataPath = resolve(config.dataDir, 'pgdata-postgres')
229
- if (existsSync(oldDataPath) && !existsSync(newDataPath)) {
230
- renameSync(oldDataPath, newDataPath)
231
- log.debug.pglite('migrated pgdata → pgdata-postgres')
232
- }
233
- }
278
+ migrateDataDir(config)
234
279
 
280
+ const pgliteDataDir = (config.pgliteOptions as Record<string, any>)?.dataDir
235
281
  const useMemory =
236
282
  typeof pgliteDataDir === 'string' && pgliteDataDir.startsWith('memory://')
237
283
  const {
@@ -259,32 +305,15 @@ export async function createPGliteWorkerInstances(
259
305
 
260
306
  log.pglite('starting worker threads for postgres, cvr, cdb')
261
307
 
262
- // create all 3 worker proxies in parallel
263
308
  const pgProxy = new PGliteWorkerProxy(makeWorkerConfig('postgres', true))
264
309
  const cvrProxy = new PGliteWorkerProxy(makeWorkerConfig('cvr', false))
265
310
  const cdbProxy = new PGliteWorkerProxy(makeWorkerConfig('cdb', false))
266
311
 
267
312
  await Promise.all([pgProxy.waitReady, cvrProxy.waitReady, cdbProxy.waitReady])
268
-
269
313
  log.pglite('all worker threads ready')
270
314
 
271
- // postgres-specific setup
272
- await pgProxy.exec('CREATE EXTENSION IF NOT EXISTS plpgsql')
315
+ await ensurePublication(pgProxy)
273
316
 
274
- // create publication only when explicitly configured
275
- const pubName = process.env.ZERO_APP_PUBLICATIONS?.trim()
276
- if (pubName) {
277
- const pubs = await pgProxy.query<{ count: string }>(
278
- `SELECT count(*) as count FROM pg_publication WHERE pubname = $1`,
279
- [pubName]
280
- )
281
- if (Number(pubs.rows[0].count) === 0) {
282
- const quoted = '"' + pubName.replace(/"/g, '""') + '"'
283
- await pgProxy.exec(`CREATE PUBLICATION ${quoted}`)
284
- }
285
- }
286
-
287
- // cast to PGlite — our proxy implements the same interface surface
288
317
  return {
289
318
  postgres: pgProxy as unknown as PGlite,
290
319
  cvr: cvrProxy as unknown as PGlite,
@@ -302,36 +331,11 @@ export async function createPGliteWorkerInstances(
302
331
  export async function createSinglePGliteInstance(
303
332
  config: ZeroLiteConfig
304
333
  ): Promise<PGliteInstances> {
305
- // migrate from old single-instance layout (pgdata → pgdata-postgres)
306
- const pgliteDataDir = (config.pgliteOptions as Record<string, any>)?.dataDir
307
- if (!pgliteDataDir || !String(pgliteDataDir).startsWith('memory://')) {
308
- const oldDataPath = resolve(config.dataDir, 'pgdata')
309
- const newDataPath = resolve(config.dataDir, 'pgdata-postgres')
310
- if (existsSync(oldDataPath) && !existsSync(newDataPath)) {
311
- renameSync(oldDataPath, newDataPath)
312
- log.debug.pglite('migrated pgdata → pgdata-postgres')
313
- }
314
- }
315
-
334
+ migrateDataDir(config)
316
335
  log.pglite('starting single shared pglite instance')
317
336
 
318
337
  const db = await createInstance(config, 'postgres', true)
319
-
320
- // postgres-specific setup
321
- await db.exec('CREATE EXTENSION IF NOT EXISTS plpgsql')
322
-
323
- // create publication only when explicitly configured
324
- const pubName = process.env.ZERO_APP_PUBLICATIONS?.trim()
325
- if (pubName) {
326
- const pubs = await db.query<{ count: string }>(
327
- `SELECT count(*) as count FROM pg_publication WHERE pubname = $1`,
328
- [pubName]
329
- )
330
- if (Number(pubs.rows[0].count) === 0) {
331
- const quoted = '"' + pubName.replace(/"/g, '""') + '"'
332
- await db.exec(`CREATE PUBLICATION ${quoted}`)
333
- }
334
- }
338
+ await ensurePublication(db)
335
339
 
336
340
  // same instance for all three — pg-proxy detects this and shares a mutex
337
341
  return { postgres: db, cvr: db, cdb: db }
@@ -343,17 +347,9 @@ export async function createSinglePGliteInstance(
343
347
  export async function createSinglePGliteWorkerInstance(
344
348
  config: ZeroLiteConfig
345
349
  ): Promise<PGliteInstances> {
346
- // migrate from old single-instance layout (pgdata → pgdata-postgres)
347
- const pgliteDataDir = (config.pgliteOptions as Record<string, any>)?.dataDir
348
- if (!pgliteDataDir || !String(pgliteDataDir).startsWith('memory://')) {
349
- const oldDataPath = resolve(config.dataDir, 'pgdata')
350
- const newDataPath = resolve(config.dataDir, 'pgdata-postgres')
351
- if (existsSync(oldDataPath) && !existsSync(newDataPath)) {
352
- renameSync(oldDataPath, newDataPath)
353
- log.debug.pglite('migrated pgdata → pgdata-postgres')
354
- }
355
- }
350
+ migrateDataDir(config)
356
351
 
352
+ const pgliteDataDir = (config.pgliteOptions as Record<string, any>)?.dataDir
357
353
  const useMemory =
358
354
  typeof pgliteDataDir === 'string' && pgliteDataDir.startsWith('memory://')
359
355
  const {
@@ -383,21 +379,7 @@ export async function createSinglePGliteWorkerInstance(
383
379
  await proxy.waitReady
384
380
  log.pglite('single worker thread ready')
385
381
 
386
- // postgres-specific setup
387
- await proxy.exec('CREATE EXTENSION IF NOT EXISTS plpgsql')
388
-
389
- // create publication only when explicitly configured
390
- const pubName = process.env.ZERO_APP_PUBLICATIONS?.trim()
391
- if (pubName) {
392
- const pubs = await proxy.query<{ count: string }>(
393
- `SELECT count(*) as count FROM pg_publication WHERE pubname = $1`,
394
- [pubName]
395
- )
396
- if (Number(pubs.rows[0].count) === 0) {
397
- const quoted = '"' + pubName.replace(/"/g, '""') + '"'
398
- await proxy.exec(`CREATE PUBLICATION ${quoted}`)
399
- }
400
- }
382
+ await ensurePublication(proxy)
401
383
 
402
384
  const db = proxy as unknown as PGlite
403
385
  return { postgres: db, cvr: db, cdb: db }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * PGlite Web Worker proxy — browser equivalent of pglite-ipc.ts.
3
+ *
4
+ * runs in the zero-cache worker, proxies calls to a Web Worker
5
+ * running the actual PGlite instance. mirrors PGliteWorkerProxy
6
+ * from pglite-ipc.ts but uses Web Worker postMessage instead of
7
+ * node worker_threads.
8
+ *
9
+ * ArrayBuffers are transferred (not copied) for execProtocolRaw
10
+ * to keep IPC overhead near-zero for wire protocol data.
11
+ */
12
+
13
+ import { signalReplicationChange } from './replication/handler.js'
14
+
15
+ interface PendingRequest {
16
+ resolve: (value: any) => void
17
+ reject: (error: Error) => void
18
+ }
19
+
20
+ const WRITE_PREFIXES = ['insert', 'update', 'delete', 'copy', 'truncate']
21
+ const SHARD_INTERNAL_TABLES = ['"replicas"', '"mutations"', '"replicationState"']
22
+
23
+ function isReplicatedWrite(sql: string): boolean {
24
+ const q = sql.trimStart().toLowerCase()
25
+ if (!WRITE_PREFIXES.some((p) => q.startsWith(p))) return false
26
+ for (const t of SHARD_INTERNAL_TABLES) {
27
+ if (q.includes(t.toLowerCase())) return false
28
+ }
29
+ return true
30
+ }
31
+
32
+ export class PGliteWebProxy {
33
+ private worker: Worker
34
+ private pending = new Map<number, PendingRequest>()
35
+ private nextId = 1
36
+ private notificationCallbacks = new Map<string, Set<(payload: string) => void>>()
37
+ readonly name: string
38
+
39
+ readonly waitReady: Promise<void>
40
+
41
+ // PGlite compat flags
42
+ closed = false
43
+ ready = false
44
+
45
+ constructor(worker: Worker, name: string) {
46
+ this.name = name
47
+ this.worker = worker
48
+
49
+ let onReady: () => void
50
+ this.waitReady = new Promise<void>((resolveReady, rejectReady) => {
51
+ onReady = () => {
52
+ this.ready = true
53
+ resolveReady()
54
+ }
55
+
56
+ const onMessage = (ev: MessageEvent) => {
57
+ const msg = ev.data
58
+ if (msg?.type === 'ready') {
59
+ this.worker.removeEventListener('message', onMessage)
60
+ this.installMessageHandler()
61
+ onReady()
62
+ } else if (msg?.type === 'error' && msg.id === 0) {
63
+ rejectReady(new Error(msg.message))
64
+ }
65
+ }
66
+
67
+ this.worker.addEventListener('message', onMessage)
68
+ this.worker.addEventListener('error', (ev) => {
69
+ rejectReady(new Error(String(ev)))
70
+ })
71
+ })
72
+ }
73
+
74
+ private installMessageHandler() {
75
+ this.worker.addEventListener('message', (ev: MessageEvent) => {
76
+ const msg = ev.data
77
+ if (!msg || typeof msg !== 'object') return
78
+
79
+ if (msg.type === 'notification') {
80
+ const callbacks = this.notificationCallbacks.get(msg.channel)
81
+ if (callbacks) {
82
+ for (const cb of callbacks) {
83
+ try {
84
+ cb(msg.payload)
85
+ } catch {}
86
+ }
87
+ }
88
+ return
89
+ }
90
+
91
+ const req = this.pending.get(msg.id)
92
+ if (!req) return
93
+ this.pending.delete(msg.id)
94
+
95
+ if (msg.type === 'error') {
96
+ const err = new Error(msg.message) as Error & { code?: string }
97
+ if (msg.code) err.code = msg.code
98
+ req.reject(err)
99
+ } else {
100
+ req.resolve(msg)
101
+ }
102
+ })
103
+ }
104
+
105
+ private send(msg: Record<string, unknown>, transfer?: Transferable[]): Promise<any> {
106
+ const id = this.nextId++
107
+ msg.id = id
108
+ return new Promise((resolve, reject) => {
109
+ this.pending.set(id, { resolve, reject })
110
+ if (transfer?.length) {
111
+ this.worker.postMessage(msg, transfer)
112
+ } else {
113
+ this.worker.postMessage(msg)
114
+ }
115
+ })
116
+ }
117
+
118
+ async execProtocolRaw(
119
+ data: Uint8Array,
120
+ options?: { syncToFs?: boolean; throwOnError?: boolean }
121
+ ): Promise<Uint8Array> {
122
+ // copy to a transferable buffer then transfer
123
+ const buf = new ArrayBuffer(data.byteLength)
124
+ new Uint8Array(buf).set(data)
125
+ const result = await this.send({ type: 'execProtocolRaw', data: buf, options }, [buf])
126
+ return new Uint8Array(result.data)
127
+ }
128
+
129
+ async query<T = any>(
130
+ sql: string,
131
+ params?: any[]
132
+ ): Promise<{ rows: T[]; affectedRows?: number }> {
133
+ const result = await this.send({ type: 'query', sql, params })
134
+ // signal replication after writes on postgres instance (like orez-node's PGliteWorkerProxy)
135
+ if (this.name === 'postgres' && isReplicatedWrite(sql)) {
136
+ signalReplicationChange()
137
+ }
138
+ return { rows: result.rows ?? [], affectedRows: result.affectedRows }
139
+ }
140
+
141
+ async exec(sql: string): Promise<{ affectedRows?: number }[]> {
142
+ const result = await this.send({ type: 'exec', sql })
143
+ if (this.name === 'postgres' && isReplicatedWrite(sql)) {
144
+ signalReplicationChange()
145
+ }
146
+ return result.results ?? []
147
+ }
148
+
149
+ async listen(
150
+ channel: string,
151
+ callback: (payload: string) => void
152
+ ): Promise<() => Promise<void>> {
153
+ let callbacks = this.notificationCallbacks.get(channel)
154
+ if (!callbacks) {
155
+ callbacks = new Set()
156
+ this.notificationCallbacks.set(channel, callbacks)
157
+ }
158
+ callbacks.add(callback)
159
+
160
+ const result = await this.send({ type: 'listen', channel })
161
+ const listenId = result.id
162
+
163
+ return async () => {
164
+ callbacks!.delete(callback)
165
+ if (callbacks!.size === 0) {
166
+ this.notificationCallbacks.delete(channel)
167
+ }
168
+ await this.send({ type: 'unlisten', listenId }).catch(() => {})
169
+ }
170
+ }
171
+
172
+ async close(): Promise<void> {
173
+ this.closed = true
174
+ this.ready = false
175
+ try {
176
+ await this.send({ type: 'close' })
177
+ } catch {}
178
+ this.worker.terminate()
179
+ }
180
+ }