orez 0.2.27 → 0.2.30

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 (157) hide show
  1. package/dist/cf-do/worker.d.ts +3 -0
  2. package/dist/cf-do/worker.d.ts.map +1 -1
  3. package/dist/cf-do/worker.js +37 -15
  4. package/dist/cf-do/worker.js.map +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +8 -0
  7. package/dist/index.js.map +1 -1
  8. package/package.json +3 -4
  9. package/src/admin/admin-data.test.ts +0 -348
  10. package/src/admin/http-proxy.ts +0 -252
  11. package/src/admin/log-store.ts +0 -192
  12. package/src/admin/server.ts +0 -471
  13. package/src/admin/ui.ts +0 -1322
  14. package/src/bench/proxy-throughput.bench.ts +0 -343
  15. package/src/bench/serial-mutations.bench.ts +0 -270
  16. package/src/browser.ts +0 -203
  17. package/src/cf-do/.wrangler/cache/cf.json +0 -1
  18. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
  19. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
  20. package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
  21. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/0ffaabee41a60e04dd0eb7db3073f0a40139e6a97ccd26823967acb652b89a7b.sqlite +0 -0
  22. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite +0 -0
  23. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-shm +0 -0
  24. package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-wal +0 -0
  25. package/src/cf-do/.wrangler/tmp/bundle-0z4CpE/middleware-insertion-facade.js +0 -11
  26. package/src/cf-do/.wrangler/tmp/bundle-0z4CpE/middleware-loader.entry.ts +0 -134
  27. package/src/cf-do/.wrangler/tmp/bundle-vYmw0E/middleware-insertion-facade.js +0 -11
  28. package/src/cf-do/.wrangler/tmp/bundle-vYmw0E/middleware-loader.entry.ts +0 -134
  29. package/src/cf-do/.wrangler/tmp/dev-cbILNo/worker.js +0 -1059
  30. package/src/cf-do/.wrangler/tmp/dev-cbILNo/worker.js.map +0 -8
  31. package/src/cf-do/.wrangler/tmp/dev-qbho19/worker.js +0 -1059
  32. package/src/cf-do/.wrangler/tmp/dev-qbho19/worker.js.map +0 -8
  33. package/src/cf-do/ARCHITECTURE.md +0 -93
  34. package/src/cf-do/CHAT_E2E.md +0 -213
  35. package/src/cf-do/watermark.test.ts +0 -103
  36. package/src/cf-do/watermark.ts +0 -118
  37. package/src/cf-do/worker.ts +0 -1041
  38. package/src/cf-do/wrangler.toml +0 -11
  39. package/src/cf-pglite/README.md +0 -19
  40. package/src/change-tracking.ts +0 -25
  41. package/src/child-process.test.ts +0 -147
  42. package/src/child-process.ts +0 -90
  43. package/src/cli-entry.ts +0 -72
  44. package/src/cli.test.ts +0 -40
  45. package/src/cli.ts +0 -1214
  46. package/src/config.ts +0 -150
  47. package/src/do-sql-tracking.test.ts +0 -19
  48. package/src/do-sql-tracking.ts +0 -19
  49. package/src/index.ts +0 -1215
  50. package/src/integration/integration.test.ts +0 -517
  51. package/src/integration/native-binary.guard.test.ts +0 -13
  52. package/src/integration/native-startup.test.ts +0 -44
  53. package/src/integration/replication-latency.test.ts +0 -428
  54. package/src/integration/restore-live-stress.test.ts +0 -433
  55. package/src/integration/restore-reset.test.ts +0 -400
  56. package/src/integration/restore.test.ts +0 -274
  57. package/src/integration/test-permissions.ts +0 -147
  58. package/src/load-config.ts +0 -46
  59. package/src/log.ts +0 -96
  60. package/src/mutex.ts +0 -47
  61. package/src/pg-proxy-browser.singledb.test.ts +0 -233
  62. package/src/pg-proxy-browser.ts +0 -2022
  63. package/src/pg-proxy-do-backend.test.ts +0 -3890
  64. package/src/pg-proxy-do-backend.ts +0 -7191
  65. package/src/pg-proxy.ts +0 -1087
  66. package/src/pg-sqlite-compiler/README.md +0 -53
  67. package/src/pg-sqlite-compiler/catalog/seed.ts +0 -524
  68. package/src/pg-sqlite-compiler/fixtures/pgsqlite/arithmetic.json +0 -307
  69. package/src/pg-sqlite-compiler/fixtures/pgsqlite/array.json +0 -377
  70. package/src/pg-sqlite-compiler/fixtures/pgsqlite/cast.json +0 -12
  71. package/src/pg-sqlite-compiler/fixtures/pgsqlite/catalog.json +0 -447
  72. package/src/pg-sqlite-compiler/fixtures/pgsqlite/create-table.json +0 -32
  73. package/src/pg-sqlite-compiler/fixtures/pgsqlite/datetime.json +0 -397
  74. package/src/pg-sqlite-compiler/fixtures/pgsqlite/enum.json +0 -337
  75. package/src/pg-sqlite-compiler/fixtures/pgsqlite/insert.json +0 -337
  76. package/src/pg-sqlite-compiler/fixtures/pgsqlite/json.json +0 -537
  77. package/src/pg-sqlite-compiler/fixtures/pgsqlite/misc.json +0 -1837
  78. package/src/pg-sqlite-compiler/index.ts +0 -73
  79. package/src/pg-sqlite-compiler/integration.test.ts +0 -136
  80. package/src/pg-sqlite-compiler/passes/ast-utils.ts +0 -113
  81. package/src/pg-sqlite-compiler/passes/catalog.ts +0 -65
  82. package/src/pg-sqlite-compiler/passes/datetime.ts +0 -74
  83. package/src/pg-sqlite-compiler/passes/index.ts +0 -49
  84. package/src/pg-sqlite-compiler/passes/types.ts +0 -156
  85. package/src/pg-sqlite-compiler/smoke.test.ts +0 -69
  86. package/src/pg-sqlite-compiler/test/catalog.test.ts +0 -171
  87. package/src/pg-sqlite-compiler/test/corpus.test.ts +0 -161
  88. package/src/pg-sqlite-compiler/test/datetime.oracle.test.ts +0 -102
  89. package/src/pg-sqlite-compiler/test/oracle.ts +0 -237
  90. package/src/pg-sqlite-compiler/test/types.test.ts +0 -109
  91. package/src/pg-sqlite-compiler/types.ts +0 -63
  92. package/src/pglite-ipc.test.ts +0 -116
  93. package/src/pglite-ipc.ts +0 -266
  94. package/src/pglite-manager.ts +0 -557
  95. package/src/pglite-web-proxy.test.ts +0 -57
  96. package/src/pglite-web-proxy.ts +0 -221
  97. package/src/pglite-web-worker.ts +0 -152
  98. package/src/pglite-worker-thread.ts +0 -253
  99. package/src/port.ts +0 -25
  100. package/src/process-title.ts +0 -9
  101. package/src/recovery.ts +0 -155
  102. package/src/replication/change-tracker.test.ts +0 -357
  103. package/src/replication/change-tracker.ts +0 -279
  104. package/src/replication/handler.test.ts +0 -511
  105. package/src/replication/handler.ts +0 -1190
  106. package/src/replication/pgoutput-encoder.test.ts +0 -697
  107. package/src/replication/pgoutput-encoder.ts +0 -373
  108. package/src/replication/tcp-replication.test.ts +0 -876
  109. package/src/replication/zero-compat.test.ts +0 -1150
  110. package/src/restore-stress.test.ts +0 -188
  111. package/src/s3-local.ts +0 -203
  112. package/src/shim/hooks.mjs +0 -120
  113. package/src/shim/register.mjs +0 -4
  114. package/src/sqlite-mode/apply-mode.ts +0 -224
  115. package/src/sqlite-mode/index.ts +0 -15
  116. package/src/sqlite-mode/native-binary.ts +0 -89
  117. package/src/sqlite-mode/package-resolve.ts +0 -17
  118. package/src/sqlite-mode/resolve-mode.ts +0 -80
  119. package/src/sqlite-mode/shim-template.ts +0 -159
  120. package/src/sqlite-mode/sqlite-mode.test.ts +0 -427
  121. package/src/sqlite-mode/types.ts +0 -30
  122. package/src/vite-plugin.ts +0 -67
  123. package/src/wasm-sqlite.test.ts +0 -537
  124. package/src/worker/browser-admin.ts +0 -52
  125. package/src/worker/browser-build-config.test.ts +0 -71
  126. package/src/worker/browser-build-config.ts +0 -109
  127. package/src/worker/browser-embed-admin.test.ts +0 -75
  128. package/src/worker/browser-embed.ts +0 -345
  129. package/src/worker/cf-patches.ts +0 -384
  130. package/src/worker/embed-integration.test.ts +0 -321
  131. package/src/worker/index.ts +0 -138
  132. package/src/worker/shims/fastify.test.ts +0 -255
  133. package/src/worker/shims/fastify.ts +0 -306
  134. package/src/worker/shims/http-service.test.ts +0 -355
  135. package/src/worker/shims/http-service.ts +0 -293
  136. package/src/worker/shims/node-stub.ts +0 -290
  137. package/src/worker/shims/oxfmt.ts +0 -3
  138. package/src/worker/shims/postgres-browser.ts +0 -59
  139. package/src/worker/shims/postgres-socket.test.ts +0 -576
  140. package/src/worker/shims/postgres-socket.ts +0 -310
  141. package/src/worker/shims/postgres.test.ts +0 -364
  142. package/src/worker/shims/postgres.ts +0 -1454
  143. package/src/worker/shims/sqlite-browser.test.ts +0 -233
  144. package/src/worker/shims/sqlite-browser.ts +0 -175
  145. package/src/worker/shims/sqlite.test.ts +0 -786
  146. package/src/worker/shims/sqlite.ts +0 -978
  147. package/src/worker/shims/stream-browser.ts +0 -15
  148. package/src/worker/shims/ws-browser.test.ts +0 -205
  149. package/src/worker/shims/ws-browser.ts +0 -248
  150. package/src/worker/shims/ws.test.ts +0 -288
  151. package/src/worker/shims/ws.ts +0 -467
  152. package/src/worker/shims/zero-process-env.ts +0 -11
  153. package/src/worker/types.ts +0 -75
  154. package/src/worker/worker-integration.test.ts +0 -223
  155. package/src/worker/worker.test.ts +0 -136
  156. package/src/worker/zero-cache-embed-cf.ts +0 -463
  157. package/src/worker/zero-cache-embed.ts +0 -277
package/src/index.ts DELETED
@@ -1,1215 +0,0 @@
1
- /**
2
- * orez: pglite-powered zero-sync development backend.
3
- *
4
- * starts a pglite instance, tcp proxy, and zero-cache process.
5
- * replaces docker-based postgresql and zero-cache with a single
6
- * `bun run` command.
7
- */
8
-
9
- import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
10
- import { randomUUID } from 'node:crypto'
11
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
12
- import { resolve } from 'node:path'
13
-
14
- import {
15
- createHttpLogStore,
16
- startHttpProxy,
17
- type HttpLogStore,
18
- } from './admin/http-proxy.js'
19
- import { createLogStore, type LogStore } from './admin/log-store.js'
20
- import {
21
- isChildProcessRunning,
22
- isPidRunning,
23
- killProcessTree,
24
- waitForChildProcessExit,
25
- } from './child-process.js'
26
- import { getConfig, getConnectionString } from './config.js'
27
- import { log, port, setLogLevel, setLogStore } from './log.js'
28
- import { DoBackend } from './pg-proxy-do-backend.js'
29
- import { startPgProxy } from './pg-proxy.js'
30
- import {
31
- createPGliteInstances,
32
- createPGliteWorkerInstances,
33
- createSinglePGliteInstance,
34
- createSinglePGliteWorkerInstance,
35
- createPGliteWorker,
36
- runMigrations,
37
- startPeriodicCheckpoint,
38
- } from './pglite-manager.js'
39
- import { findPort } from './port.js'
40
- import { orezTitle } from './process-title.js'
41
- import {
42
- cleanCdcStateOnStartup,
43
- hasCdcCorruptionSignature,
44
- recoverFromCdcCorruption,
45
- } from './recovery.js'
46
- import { installChangeTracking } from './replication/change-tracker.js'
47
- import { resetReplicationState } from './replication/handler.js'
48
- import {
49
- applySqliteMode,
50
- cleanupShim,
51
- formatNativeBootstrapInstructions,
52
- hasMissingNativeBinarySignature,
53
- inspectNativeSqliteBinary,
54
- resolveSqliteMode,
55
- resolveSqliteModeConfig,
56
- type SqliteMode,
57
- type SqliteModeConfig,
58
- } from './sqlite-mode/index.js'
59
-
60
- import type { ZeroLiteConfig } from './config.js'
61
- import type { PGlite } from '@electric-sql/pglite'
62
-
63
- type ZeroChildProcess = ChildProcess & { __orezTail?: string[] }
64
-
65
- function ensureDoBackendNamespace(dataDir: string): string {
66
- const marker = resolve(dataDir, 'do-backend-namespace')
67
- if (existsSync(marker)) {
68
- const existing = readFileSync(marker, 'utf8').trim()
69
- if (existing) return existing
70
- }
71
- const next = randomUUID()
72
- writeFileSync(marker, `${next}\n`)
73
- return next
74
- }
75
-
76
- function resolveNodeBinary(): string {
77
- const explicitNode = process.env.NODE
78
- if (explicitNode && existsSync(explicitNode)) {
79
- return explicitNode
80
- }
81
-
82
- if (process.execPath.endsWith('/node')) {
83
- return process.execPath
84
- }
85
-
86
- const whichResult = spawnSync('which', ['node'], {
87
- encoding: 'utf8',
88
- env: process.env,
89
- })
90
- const candidate = whichResult.stdout?.trim()
91
- if (whichResult.status === 0 && candidate && existsSync(candidate)) {
92
- return candidate
93
- }
94
-
95
- throw new Error(
96
- 'could not resolve a node binary for zero-cache; set process.env.NODE or ensure node is in PATH'
97
- )
98
- }
99
-
100
- export { defineConfig, getConfig, getConnectionString } from './config.js'
101
- export type { Hook, LogLevel, OrezConfig, ZeroLiteConfig } from './config.js'
102
- export { installChangeTracking } from './replication/change-tracker.js'
103
-
104
- // helper to run a hook (string command or callback function)
105
- async function runHook(
106
- hook: string | (() => void | Promise<void>) | undefined,
107
- name: string,
108
- env: Record<string, string>
109
- ): Promise<void> {
110
- if (!hook) return
111
-
112
- if (typeof hook === 'function') {
113
- log.debug.orez(`running ${name} callback`)
114
- await hook()
115
- log.orez(`${name} done`)
116
- return
117
- }
118
-
119
- // string command
120
- log.debug.orez(`running ${name}: ${hook}`)
121
- await new Promise<void>((resolve, reject) => {
122
- const child = spawn(hook, {
123
- shell: true,
124
- stdio: 'inherit',
125
- env: { ...process.env, ...env },
126
- })
127
- child.on('exit', (code) => {
128
- if (code === 0) {
129
- log.orez(`${name} done`)
130
- resolve()
131
- } else {
132
- reject(new Error(`${name} exited with code ${code}`))
133
- }
134
- })
135
- child.on('error', reject)
136
- })
137
- }
138
-
139
- function getManagedPublicationConfig(): { names: string[]; managedByOrez: boolean } {
140
- const existing = process.env.ZERO_APP_PUBLICATIONS?.trim()
141
- if (existing) {
142
- const names = existing
143
- .split(',')
144
- .map((s) => s.trim())
145
- .filter(Boolean)
146
- return { names, managedByOrez: false }
147
- }
148
-
149
- const appId = (process.env.ZERO_APP_ID || 'zero').trim() || 'zero'
150
- const fallback = `orez_${appId}_public`
151
- process.env.ZERO_APP_PUBLICATIONS = fallback
152
- return { names: [fallback], managedByOrez: true }
153
- }
154
-
155
- async function syncManagedPublications(
156
- db: PGlite,
157
- names: string[],
158
- managedByOrez: boolean
159
- ): Promise<void> {
160
- if (!managedByOrez || names.length === 0) return
161
-
162
- const tables = await db.query<{ tablename: string }>(
163
- `SELECT tablename
164
- FROM pg_tables
165
- WHERE schemaname = 'public'
166
- AND tablename NOT LIKE '_zero_%'`
167
- )
168
- const publicTables = tables.rows
169
- .map((r) => r.tablename)
170
- .filter((t) => !t.startsWith('_'))
171
-
172
- for (const pub of names) {
173
- const quotedPub = '"' + pub.replace(/"/g, '""') + '"'
174
- await db.exec(`CREATE PUBLICATION ${quotedPub}`).catch(() => {})
175
-
176
- if (publicTables.length === 0) continue
177
- const inPub = await db.query<{ tablename: string }>(
178
- `SELECT tablename
179
- FROM pg_publication_tables
180
- WHERE pubname = $1
181
- AND schemaname = 'public'`,
182
- [pub]
183
- )
184
- const inPubSet = new Set(inPub.rows.map((r) => r.tablename))
185
- const toAdd = publicTables.filter((t) => !inPubSet.has(t))
186
- if (toAdd.length === 0) continue
187
- const tableList = toAdd.map((t) => `"public"."${t.replace(/"/g, '""')}"`).join(', ')
188
- await db.exec(`ALTER PUBLICATION ${quotedPub} ADD TABLE ${tableList}`)
189
- log.debug.orez(`added ${toAdd.length} table(s) to publication "${pub}"`)
190
- }
191
- }
192
-
193
- /**
194
- * ensure publications have table membership after on-db-ready.
195
- * handles the case where orez pre-created an empty publication and the app's
196
- * migration skipped adding tables because the publication already existed.
197
- */
198
- async function ensurePublicationHasTables(db: PGlite, names: string[]): Promise<void> {
199
- for (const pub of names) {
200
- const inPub = await db.query<{ count: string }>(
201
- `SELECT count(*)::text as count FROM pg_publication_tables
202
- WHERE pubname = $1 AND schemaname = 'public'`,
203
- [pub]
204
- )
205
- if (Number(inPub.rows[0]?.count) > 0) continue
206
-
207
- // publication exists but has no tables — add all public tables
208
- const pubExists = await db.query<{ count: string }>(
209
- `SELECT count(*)::text as count FROM pg_publication WHERE pubname = $1`,
210
- [pub]
211
- )
212
- if (Number(pubExists.rows[0]?.count) === 0) continue
213
-
214
- const tables = await db.query<{ tablename: string }>(
215
- `SELECT tablename FROM pg_tables
216
- WHERE schemaname = 'public'
217
- AND tablename NOT LIKE '_zero_%'
218
- AND tablename NOT LIKE '\\_%'`
219
- )
220
- if (tables.rows.length === 0) continue
221
-
222
- const tableList = tables.rows
223
- .map((t) => `"public"."${t.tablename.replace(/"/g, '""')}"`)
224
- .join(', ')
225
- const quotedPub = '"' + pub.replace(/"/g, '""') + '"'
226
- await db.exec(`ALTER PUBLICATION ${quotedPub} ADD TABLE ${tableList}`)
227
- log.orez(`publication "${pub}" was empty, added ${tables.rows.length} table(s)`)
228
- }
229
- }
230
-
231
- // resolvePackage moved to sqlite-mode/resolve-mode.ts
232
- import { resolvePackage } from './sqlite-mode/resolve-mode.js'
233
-
234
- export async function startZeroLite(overrides: Partial<ZeroLiteConfig> = {}) {
235
- const config = getConfig(overrides)
236
- setLogLevel(config.logLevel)
237
-
238
- // find available ports
239
- const pgPort = await findPort(config.pgPort)
240
- const zeroPort = config.skipZeroCache
241
- ? config.zeroPort
242
- : await findPort(config.zeroPort)
243
- const adminPort = config.adminPort > 0 ? await findPort(config.adminPort) : 0
244
- if (pgPort !== config.pgPort)
245
- log.debug.orez(`port ${config.pgPort} in use, using ${pgPort}`)
246
- if (!config.skipZeroCache && zeroPort !== config.zeroPort)
247
- log.debug.orez(`port ${config.zeroPort} in use, using ${zeroPort}`)
248
- if (adminPort > 0 && adminPort !== config.adminPort)
249
- log.debug.orez(`port ${config.adminPort} in use, using ${adminPort}`)
250
- config.pgPort = pgPort
251
- config.zeroPort = zeroPort
252
- config.adminPort = adminPort
253
-
254
- // create log store for admin dashboard
255
- const logStore: LogStore | undefined =
256
- adminPort > 0
257
- ? createLogStore(config.dataDir, !config.disableDiskLogs, config.maxLogFileSize)
258
- : undefined
259
-
260
- // wire up logStore so all log.* calls flow to admin dashboard
261
- setLogStore(logStore)
262
-
263
- // create http log store for HTTP tab
264
- const httpLog: HttpLogStore | undefined =
265
- adminPort > 0 ? createHttpLogStore() : undefined
266
-
267
- log.debug.orez(`data dir: ${resolve(config.dataDir)}`)
268
-
269
- // resolve sqlite mode config early (used for shim application and cleanup)
270
- // auto-detects native if available, falls back to wasm
271
- let sqliteMode = resolveSqliteMode(config.disableWasmSqlite, config.forceWasmSqlite)
272
- let sqliteModeConfig = resolveSqliteModeConfig(
273
- config.disableWasmSqlite,
274
- config.forceWasmSqlite
275
- )
276
- if (sqliteMode === 'wasm' && !sqliteModeConfig) {
277
- log.orez(
278
- 'warning: wasm sqlite requested but dependencies are missing, falling back to native'
279
- )
280
- sqliteMode = 'native'
281
- config.disableWasmSqlite = true
282
- sqliteModeConfig = resolveSqliteModeConfig(true, false)
283
- }
284
-
285
- mkdirSync(config.dataDir, { recursive: true })
286
-
287
- // write pid file for IPC (pg_restore uses this to signal restart).
288
- // before overwriting, check for orphaned zero-cache processes from a
289
- // previous orez run that didn't shut down cleanly (e.g. SIGKILL'd before
290
- // the in-process watchdog could notice). sweep anything still holding
291
- // the zero port so the new run can bind.
292
- const pidFile = resolve(config.dataDir, 'orez.pid')
293
- if (!config.skipZeroCache && process.platform !== 'win32') {
294
- try {
295
- const priorPid = Number(readFileSync(pidFile, 'utf8').trim())
296
- if (priorPid > 0 && priorPid !== process.pid && !isPidRunning(priorPid)) {
297
- const result = spawnSync('lsof', ['-ti', `:${config.zeroPort}`], {
298
- encoding: 'utf8',
299
- })
300
- const orphans = (result.stdout || '')
301
- .split(/\s+/)
302
- .map((v) => Number(v.trim()))
303
- .filter((v) => Number.isInteger(v) && v > 0 && v !== process.pid)
304
- for (const pid of orphans) {
305
- log.orez(
306
- `killing orphan pid ${pid} holding zero port ${config.zeroPort} from previous orez run`
307
- )
308
- try {
309
- killProcessTree(pid, 'SIGKILL')
310
- } catch {}
311
- }
312
- }
313
- } catch {}
314
- }
315
- writeFileSync(pidFile, String(process.pid))
316
-
317
- // write admin port file so pg_restore can find it
318
- const adminFile = resolve(config.dataDir, 'orez.admin')
319
- if (adminPort > 0) {
320
- writeFileSync(adminFile, String(adminPort))
321
- }
322
-
323
- // remove any stale ready marker from a previous run so external waiters
324
- // (e.g. CI scripts) don't see a stale "ready" before this run finishes
325
- // initializing. the marker is (re-)written after on-db-ready completes.
326
- const readyFile = resolve(config.dataDir, 'orez.ready')
327
- try {
328
- unlinkSync(readyFile)
329
- } catch {}
330
-
331
- // start pglite instance(s).
332
- // single-db mode uses one instance for all databases (lighter for constrained envs).
333
- // otherwise, separate instances for postgres, zero_cvr, zero_cdb with optional
334
- // worker threads for non-blocking WASM execution.
335
-
336
- // ── DO backend path (replaces PGlite) ──────────────────────────────
337
- let instances: any, db: any, stopCheckpoint: any
338
- let migrationsApplied = 0
339
- let isDoBackend = false
340
-
341
- if (config.doBackendUrl) {
342
- isDoBackend = true
343
- log.orez(`using DO backend: ${config.doBackendUrl}`)
344
- const backendUrl = config.doBackendUrl.replace(/\/+$/, '')
345
- const doNamespace = ensureDoBackendNamespace(config.dataDir)
346
- const doInstances = {
347
- postgres: new DoBackend(backendUrl, 'postgres', doNamespace),
348
- cvr: new DoBackend(backendUrl, 'zero_cvr', doNamespace),
349
- cdb: new DoBackend(backendUrl, 'zero_cdb', doNamespace),
350
- postgresReplicas: [],
351
- }
352
- await Promise.all([
353
- doInstances.postgres.waitReady,
354
- doInstances.cvr.waitReady,
355
- doInstances.cdb.waitReady,
356
- ])
357
- instances = doInstances
358
- db = doInstances.postgres
359
- stopCheckpoint = () => {}
360
- } else {
361
- // ── PGlite backend (default) ────────────────────────────────────────────
362
- instances = config.singleDb
363
- ? config.useWorkerThreads
364
- ? await createSinglePGliteWorkerInstance(config)
365
- : await createSinglePGliteInstance(config)
366
- : config.useWorkerThreads
367
- ? await createPGliteWorkerInstances(config)
368
- : await createPGliteInstances(config)
369
- db = instances.postgres
370
-
371
- // periodic WAL checkpoint
372
- stopCheckpoint =
373
- config.checkpointIntervalMs > 0
374
- ? startPeriodicCheckpoint(instances, config.checkpointIntervalMs)
375
- : () => {}
376
-
377
- // config-based publications
378
- if (config.zeroPublications && !process.env.ZERO_APP_PUBLICATIONS) {
379
- process.env.ZERO_APP_PUBLICATIONS = config.zeroPublications
380
- }
381
-
382
- // run migrations & change tracking
383
- migrationsApplied = await runMigrations(db, config)
384
- log.debug.orez('installing change tracking')
385
- await installChangeTracking(db)
386
- }
387
-
388
- // shared: publications config
389
- if (config.zeroPublications && !process.env.ZERO_APP_PUBLICATIONS) {
390
- process.env.ZERO_APP_PUBLICATIONS = config.zeroPublications
391
- }
392
- const managedPub = getManagedPublicationConfig()
393
- if (managedPub.managedByOrez) {
394
- log.debug.orez(`using managed publication: ${managedPub.names.join(', ')}`)
395
- }
396
-
397
- // sync publications. for DO backend this goes through the TCP proxy, which
398
- // rewrites the catalog queries and forwards CREATE PUBLICATION / ALTER PUBLICATION
399
- // as no-ops or DO-native equivalents (PGlite still owns the real path).
400
- await syncManagedPublications(db, managedPub.names, managedPub.managedByOrez)
401
-
402
- // start tcp proxy (routes connections to correct instance by database name)
403
- const pgServer = await startPgProxy(instances, config)
404
-
405
- if (migrationsApplied > 0)
406
- log.orez(
407
- `${migrationsApplied} migration${migrationsApplied === 1 ? '' : 's'} applied`
408
- )
409
-
410
- // seed data if needed
411
- await seedIfNeeded(db, config)
412
-
413
- // run on-db-ready hook (e.g. migrations) before zero-cache starts
414
- if (config.onDbReady) {
415
- const upstreamUrl = getConnectionString(config, 'postgres')
416
- const cvrUrl = getConnectionString(config, 'zero_cvr')
417
- const cdbUrl = getConnectionString(config, 'zero_cdb')
418
- await runHook(config.onDbReady, 'on-db-ready', {
419
- ZERO_UPSTREAM_DB: upstreamUrl,
420
- ZERO_CVR_DB: cvrUrl,
421
- ZERO_CHANGE_DB: cdbUrl,
422
- DATABASE_URL: upstreamUrl,
423
- OREZ_PG_PORT: String(config.pgPort),
424
- })
425
-
426
- // re-sync publication membership
427
- await syncManagedPublications(db, managedPub.names, managedPub.managedByOrez)
428
- await ensurePublicationHasTables(db, managedPub.names)
429
- log.debug.orez('re-installing change tracking after on-db-ready')
430
- await installChangeTracking(db)
431
- }
432
-
433
- if (isDoBackend) {
434
- await installChangeTracking(db)
435
- }
436
-
437
- // write the ready marker so external orchestrators (e.g. CI scripts that
438
- // currently `wait:ports 6434`) can wait for orez to be fully initialized.
439
- // important: the pg port is bound earlier (startPgProxy above) so that
440
- // on-db-ready can connect, but external clients connecting before this
441
- // marker exists race with on-db-ready and corrupt transaction state on
442
- // the shared pglite session.
443
- writeFileSync(readyFile, String(Date.now()))
444
-
445
- // create read replicas after the primary is fully initialized
446
- // (migrations, seed, change tracking, publications all set up)
447
- if (config.readReplicas > 0 && config.useWorkerThreads) {
448
- const { createReadReplicas } = await import('./pglite-manager.js')
449
- instances.postgresReplicas = await createReadReplicas(db, config.readReplicas, config)
450
- }
451
-
452
- // clean up stale lock files from previous crash (keep replica for fast restart).
453
- // if lock files were present, it means the previous shutdown was unclean (SIGKILL)
454
- // and CDC state may be corrupt — clean it along with the replica to avoid
455
- // duplicate watermark errors when zero-cache tries to replay changes.
456
- const hadStaleLocks = cleanupStaleLockFiles(config)
457
- if (hadStaleLocks) {
458
- log.debug.orez('unclean shutdown detected, cleaning CDC state and replica')
459
- cleanupStaleReplica(config)
460
- await cleanCdcStateOnStartup(instances.cdb)
461
- }
462
-
463
- // when admin is enabled, zero-cache runs on internal port with http proxy in front
464
- let zeroInternalPort = config.zeroPort
465
- let httpProxyServer: import('node:net').Server | null = null
466
- if (httpLog && !config.skipZeroCache) {
467
- zeroInternalPort = await findPort(config.zeroPort + 1000)
468
- log.debug.orez(`http proxy: public ${config.zeroPort} → internal ${zeroInternalPort}`)
469
- }
470
-
471
- // start zero-cache
472
- let zeroCacheProcess: ChildProcess | null = null
473
- let zeroEnv: Record<string, string> = {}
474
- if (!config.skipZeroCache) {
475
- // use internal port when http proxy is enabled
476
- const zeroConfig = httpLog ? { ...config, zeroPort: zeroInternalPort } : config
477
-
478
- // helper to start zero-cache and wait for it (including stability check)
479
- const tryStartZeroCache = async () => {
480
- const result = await startZeroCache(
481
- zeroConfig,
482
- logStore,
483
- sqliteMode,
484
- sqliteModeConfig
485
- )
486
- zeroCacheProcess = result.process
487
- zeroEnv = result.env
488
- await waitForZeroCache(zeroConfig, zeroCacheProcess, 60000, sqliteMode)
489
-
490
- // stability check: wait a bit to catch early crashes (e.g. change-streamer)
491
- // zero-cache can pass health check but crash shortly after when workers start
492
- await new Promise((r) => setTimeout(r, 2000))
493
- if (zeroCacheProcess.exitCode !== null) {
494
- const tail = (zeroCacheProcess as ZeroChildProcess).__orezTail
495
- const details = tail?.length ? tail.slice(-20).join('\n') : ''
496
- throw new Error(`zero-cache crashed during startup stability check\n${details}`)
497
- }
498
- }
499
-
500
- try {
501
- await tryStartZeroCache()
502
- } catch (err: any) {
503
- const errMsg = err?.message || String(err)
504
- // check for CDC corruption (duplicate key in changeLog)
505
- if (hasCdcCorruptionSignature(errMsg)) {
506
- await recoverFromCdcCorruption({
507
- config,
508
- instances,
509
- zeroCacheProcess,
510
- })
511
- log.orez('retrying zero-cache startup...')
512
- await tryStartZeroCache()
513
- log.orez('CDC corruption auto-recovery successful')
514
- } else if (
515
- // native sqlite failed to load at runtime - fallback to wasm
516
- sqliteMode === 'native' &&
517
- !config.disableWasmSqlite &&
518
- hasMissingNativeBinarySignature(errMsg)
519
- ) {
520
- log.orez('native sqlite failed to load, falling back to wasm...')
521
- sqliteMode = 'wasm'
522
- sqliteModeConfig = resolveSqliteModeConfig(false, true) // force wasm
523
- await tryStartZeroCache()
524
- log.orez('wasm fallback successful')
525
- } else {
526
- // unrecoverable error, rethrow
527
- throw err
528
- }
529
- }
530
-
531
- // start http proxy in front of zero-cache when admin is enabled
532
- // also exposes read-only /__orez/api/logs and /__orez/api/status
533
- if (httpLog) {
534
- httpProxyServer = await startHttpProxy({
535
- listenPort: config.zeroPort,
536
- targetPort: zeroInternalPort,
537
- httpLog,
538
- logStore,
539
- config,
540
- startTime: Date.now(),
541
- })
542
- log.debug.orez(`http proxy listening on ${config.zeroPort}`)
543
- }
544
-
545
- log.zero(`ready ${port(config.zeroPort, 'magenta')} (sqlite: ${sqliteMode})`)
546
- } else {
547
- log.orez('skip zero-cache')
548
- }
549
-
550
- // run on-healthy hook after all services are ready
551
- if (config.onHealthy) {
552
- await runHook(config.onHealthy, 'on-healthy', {
553
- OREZ_PG_PORT: String(config.pgPort),
554
- OREZ_ZERO_PORT: String(config.zeroPort),
555
- })
556
- }
557
-
558
- const killZeroCache = async () => {
559
- const child = zeroCacheProcess
560
- if (!isChildProcessRunning(child)) return
561
-
562
- try {
563
- child.kill('SIGTERM')
564
- } catch (err: any) {
565
- if (err?.code !== 'ESRCH') throw err
566
- return
567
- }
568
-
569
- const exitedGracefully = await waitForChildProcessExit(child, 5000)
570
- if (exitedGracefully) return
571
-
572
- log.debug.orez(
573
- `zero-cache pid ${child.pid} did not exit after SIGTERM, force killing`
574
- )
575
- if (child.pid) killProcessTree(child.pid, 'SIGKILL')
576
- else child.kill('SIGKILL')
577
- await waitForChildProcessExit(child, 1000)
578
- }
579
-
580
- // simple restart without any state cleanup
581
- const restartZeroCache = async () => {
582
- await killZeroCache()
583
- // use internal port when http proxy is enabled
584
- const zeroConfig = httpLog ? { ...config, zeroPort: zeroInternalPort } : config
585
- const result = await startZeroCache(
586
- zeroConfig,
587
- logStore,
588
- sqliteMode,
589
- sqliteModeConfig
590
- )
591
- zeroCacheProcess = result.process
592
- zeroEnv = result.env
593
- await waitForZeroCache(zeroConfig, zeroCacheProcess, 60000, sqliteMode)
594
- }
595
-
596
- // unified reset function for zero state
597
- // modes:
598
- // 'cache-only' - deletes replica file only (fast, for minor sync issues)
599
- // 'full' - deletes CVR/CDB + replica and recreates instances (for schema changes)
600
- let resetInProgress = false
601
- const resetFile = resolve(config.dataDir, 'orez.resetting')
602
- const resetZeroState = async (mode: 'cache-only' | 'full'): Promise<void> => {
603
- if (resetInProgress) {
604
- log.orez('reset already in progress, skipping')
605
- return
606
- }
607
- resetInProgress = true
608
- // write marker file so pg_restore can wait for reset to complete
609
- writeFileSync(resetFile, String(Date.now()))
610
-
611
- try {
612
- log.orez(`resetting zero state (${mode})...`)
613
-
614
- // stop zero-cache first
615
- log.orez('stopping zero-cache...')
616
- await killZeroCache()
617
- log.orez('zero-cache stopped')
618
-
619
- if (mode === 'full') {
620
- // give connections time to drain before closing instances
621
- await new Promise((r) => setTimeout(r, 500))
622
-
623
- // close CVR/CDB instances
624
- log.orez('closing CVR/CDB...')
625
- await instances.cvr.close().catch((e: any) => {
626
- log.debug.orez(`cvr close error (expected): ${e?.message || e}`)
627
- })
628
- await instances.cdb.close().catch((e: any) => {
629
- log.debug.orez(`cdb close error (expected): ${e?.message || e}`)
630
- })
631
- log.orez('CVR/CDB closed')
632
-
633
- // delete CVR/CDB data directories
634
- log.orez('deleting CVR/CDB data...')
635
- const { rmSync } = await import('node:fs')
636
- for (const dir of ['pgdata-cvr', 'pgdata-cdb']) {
637
- try {
638
- rmSync(resolve(config.dataDir, dir), { recursive: true, force: true })
639
- } catch {}
640
- }
641
-
642
- // recreate CVR/CDB instances
643
- log.orez('recreating CVR/CDB...')
644
- if (config.useWorkerThreads) {
645
- const cvrProxy = createPGliteWorker(
646
- resolve(config.dataDir, 'pgdata-cvr'),
647
- 'cvr'
648
- )
649
- const cdbProxy = createPGliteWorker(
650
- resolve(config.dataDir, 'pgdata-cdb'),
651
- 'cdb'
652
- )
653
- await Promise.all([cvrProxy.waitReady, cdbProxy.waitReady])
654
- instances.cvr = cvrProxy as unknown as PGlite
655
- instances.cdb = cdbProxy as unknown as PGlite
656
- } else {
657
- const { PGlite: PGliteCtor } = await import('@electric-sql/pglite')
658
- mkdirSync(resolve(config.dataDir, 'pgdata-cvr'), { recursive: true })
659
- mkdirSync(resolve(config.dataDir, 'pgdata-cdb'), { recursive: true })
660
- instances.cvr = new PGliteCtor({
661
- dataDir: resolve(config.dataDir, 'pgdata-cvr'),
662
- relaxedDurability: true,
663
- })
664
- instances.cdb = new PGliteCtor({
665
- dataDir: resolve(config.dataDir, 'pgdata-cdb'),
666
- relaxedDurability: true,
667
- })
668
- await instances.cvr.waitReady
669
- await instances.cdb.waitReady
670
- }
671
- log.orez('CVR/CDB recreated')
672
-
673
- // remove stale zero shard schemas from upstream; these can outlive CVR/CDB
674
- // and cause dispatcher errors after full reset.
675
- const shardSchemas = await db.query(
676
- `SELECT DISTINCT schemaname
677
- FROM pg_tables
678
- WHERE tablename IN ('clients', 'replicas', 'mutations')
679
- AND schemaname NOT IN (
680
- 'pg_catalog',
681
- 'information_schema',
682
- 'pg_toast',
683
- 'public',
684
- '_orez'
685
- )
686
- AND schemaname NOT LIKE 'pg_%'`
687
- )
688
- for (const { schemaname } of shardSchemas.rows) {
689
- const quoted = '"' + schemaname.replace(/"/g, '""') + '"'
690
- await db.exec(`DROP SCHEMA IF EXISTS ${quoted} CASCADE`)
691
- }
692
- if (shardSchemas.rows.length > 0) {
693
- log.orez(`dropped ${shardSchemas.rows.length} stale shard schema(s)`)
694
- }
695
-
696
- // clear upstream replication tracking so zero-cache starts from a
697
- // clean change stream baseline after full reset.
698
- await db.exec(`TRUNCATE _orez._zero_changes`).catch(() => {})
699
- await db.exec(`TRUNCATE _orez._zero_replication_slots`).catch(() => {})
700
- await db
701
- .exec(`ALTER SEQUENCE _orez._zero_watermark RESTART WITH 1`)
702
- .catch(() => {})
703
- log.orez('cleared upstream replication tracking state')
704
- }
705
-
706
- // clear cached schema info so the handler re-introspects on reconnect
707
- resetReplicationState()
708
-
709
- // always clean up replica file
710
- cleanupStaleReplica(config)
711
- log.orez('replica cleaned up')
712
-
713
- // re-run on-db-ready hook after full reset (re-runs migrations, syncs publication)
714
- if (mode === 'full' && config.onDbReady) {
715
- log.orez('re-running on-db-ready...')
716
- const upstreamUrl = getConnectionString(config, 'postgres')
717
- const cvrUrl = getConnectionString(config, 'zero_cvr')
718
- const cdbUrl = getConnectionString(config, 'zero_cdb')
719
- await runHook(config.onDbReady, 'on-db-ready', {
720
- ZERO_UPSTREAM_DB: upstreamUrl,
721
- ZERO_CVR_DB: cvrUrl,
722
- ZERO_CHANGE_DB: cdbUrl,
723
- DATABASE_URL: upstreamUrl,
724
- OREZ_PG_PORT: String(config.pgPort),
725
- })
726
- }
727
-
728
- // always re-install change tracking after a full reset so public table
729
- // triggers reflect any schema changes introduced by restore.
730
- await syncManagedPublications(db, managedPub.names, managedPub.managedByOrez)
731
- log.debug.orez('re-installing change tracking after full reset')
732
- await installChangeTracking(db)
733
-
734
- // restart zero-cache
735
- log.orez('starting zero-cache...')
736
- // use internal port when http proxy is enabled
737
- const zeroConfig = httpLog ? { ...config, zeroPort: zeroInternalPort } : config
738
- const result = await startZeroCache(
739
- zeroConfig,
740
- logStore,
741
- sqliteMode,
742
- sqliteModeConfig
743
- )
744
- zeroCacheProcess = result.process
745
- zeroEnv = result.env
746
-
747
- await waitForZeroCache(zeroConfig, zeroCacheProcess, 60000, sqliteMode)
748
- log.orez(`zero state reset complete (${mode})`)
749
- log.zero(`ready ${port(config.zeroPort, 'magenta')}`)
750
- } catch (err: any) {
751
- log.orez(`reset failed: ${err?.message || err}`)
752
- throw err
753
- } finally {
754
- resetInProgress = false
755
- // remove marker file so pg_restore knows we're done
756
- try {
757
- unlinkSync(resetFile)
758
- } catch {}
759
- }
760
- }
761
-
762
- // handle SIGUSR1 to reset zero state (sent by pg_restore after restore completes)
763
- if (!config.skipZeroCache) {
764
- process.on('SIGUSR1', () => {
765
- log.orez('received SIGUSR1 - full reset')
766
- resetZeroState('full').catch((err) => {
767
- log.orez(`SIGUSR1 reset failed: ${err?.message || err}`)
768
- })
769
- })
770
-
771
- // handle SIGUSR2 to quiesce zero-cache (sent by pg_restore before restore starts)
772
- process.on('SIGUSR2', () => {
773
- log.orez('received SIGUSR2 - stopping zero-cache for restore')
774
- killZeroCache().catch((err) => {
775
- log.orez(`SIGUSR2 stop failed: ${err?.message || err}`)
776
- })
777
- })
778
- }
779
-
780
- // auto-recover when zero-cache exits unexpectedly. two failure modes:
781
- // - CDC corruption (recognizable signature) — needs a full state reset.
782
- // - any other crash (e.g. a change-streamer statement timeout) — orez
783
- // used to do nothing here, leaving the dead child so the proxy returned
784
- // 502 on every /sync until a manual restart. now: restart it, and if
785
- // the crashes keep coming, escalate to one full reset before giving up.
786
- let shuttingDown = false
787
- const ZERO_CRASH_WINDOW_MS = 5 * 60_000
788
- const ZERO_CRASH_RESTART_BUDGET = 5
789
- let zeroCrashTimes: number[] = []
790
- let zeroFullResetTried = false
791
- const installCrashWatcher = () => {
792
- if (!zeroCacheProcess || config.skipZeroCache) return
793
- zeroCacheProcess.on('exit', (code) => {
794
- if (shuttingDown || resetInProgress || code === 0 || code === null) return
795
- const tail = (zeroCacheProcess as ZeroChildProcess)?.__orezTail
796
- const details = tail?.length ? tail.join('\n') : ''
797
- if (hasCdcCorruptionSignature(details)) {
798
- log.orez('zero-cache crashed with CDC corruption, auto-recovering...')
799
- resetZeroState('full')
800
- .then(() => {
801
- log.orez('CDC auto-recovery successful')
802
- installCrashWatcher()
803
- })
804
- .catch((err) => {
805
- log.orez(`CDC auto-recovery failed: ${err?.message || err}`)
806
- })
807
- return
808
- }
809
-
810
- // non-CDC unexpected crash — restart, bounded by a sliding-window
811
- // budget so a genuinely broken instance can't restart-loop forever.
812
- zeroCrashTimes = zeroCrashTimes.filter((t) => Date.now() - t < ZERO_CRASH_WINDOW_MS)
813
- if (zeroCrashTimes.length === 0) zeroFullResetTried = false
814
- zeroCrashTimes.push(Date.now())
815
-
816
- if (zeroCrashTimes.length > ZERO_CRASH_RESTART_BUDGET) {
817
- if (zeroFullResetTried) {
818
- log.orez('zero-cache kept crashing after a full reset — giving up auto-restart')
819
- return
820
- }
821
- zeroFullResetTried = true
822
- log.orez('zero-cache crash-looping — escalating to a full state reset')
823
- resetZeroState('full')
824
- .then(() => {
825
- log.orez('zero-cache full-reset recovery successful')
826
- zeroCrashTimes = []
827
- installCrashWatcher()
828
- })
829
- .catch((err) => {
830
- log.orez(`zero-cache full-reset recovery failed: ${err?.message || err}`)
831
- })
832
- return
833
- }
834
-
835
- log.orez(
836
- `zero-cache exited unexpectedly (code ${code}) — restarting ` +
837
- `(${zeroCrashTimes.length}/${ZERO_CRASH_RESTART_BUDGET})`
838
- )
839
- restartZeroCache()
840
- .then(() => {
841
- log.orez('zero-cache restart successful')
842
- installCrashWatcher()
843
- })
844
- .catch((err) => {
845
- log.orez(`zero-cache restart failed: ${err?.message || err}`)
846
- })
847
- })
848
- }
849
- installCrashWatcher()
850
-
851
- const stop = async () => {
852
- log.debug.orez('shutting down')
853
- shuttingDown = true
854
- stopCheckpoint()
855
- httpProxyServer?.close()
856
- await killZeroCache()
857
- pgServer.close()
858
- await Promise.all([
859
- instances.postgres.close(),
860
- instances.cvr.close(),
861
- instances.cdb.close(),
862
- ])
863
- try {
864
- unlinkSync(pidFile)
865
- } catch {}
866
- try {
867
- unlinkSync(adminFile)
868
- } catch {}
869
- try {
870
- unlinkSync(readyFile)
871
- } catch {}
872
- log.debug.orez('stopped')
873
- }
874
-
875
- return {
876
- config,
877
- stop,
878
- db,
879
- instances,
880
- pgPort: config.pgPort,
881
- zeroPort: config.zeroPort,
882
- logStore,
883
- httpLog,
884
- zeroEnv,
885
- restartZero: config.skipZeroCache ? undefined : restartZeroCache,
886
- // stop zero-cache without restart (for pg_restore to safely modify schema)
887
- stopZero: config.skipZeroCache ? undefined : killZeroCache,
888
- // cache-only reset: just replica file (fast, for minor sync issues)
889
- resetZero: config.skipZeroCache ? undefined : () => resetZeroState('cache-only'),
890
- // full reset: CVR/CDB + replica (for schema changes, used by pg_restore via SIGUSR1)
891
- resetZeroFull: config.skipZeroCache ? undefined : () => resetZeroState('full'),
892
- }
893
- }
894
-
895
- /** clean lock files only — keeps replica intact for fast incremental sync on restart.
896
- * returns true if any stale lock files were found (indicates unclean shutdown). */
897
- function cleanupStaleLockFiles(config: ZeroLiteConfig): boolean {
898
- const replicaPath = resolve(config.dataDir, 'zero-replica.db')
899
- let found = false
900
- for (const suffix of ['-wal', '-shm', '-wal2']) {
901
- const file = replicaPath + suffix
902
- try {
903
- if (existsSync(file)) {
904
- unlinkSync(file)
905
- log.debug.orez(`cleaned up stale ${suffix} file`)
906
- found = true
907
- }
908
- } catch {}
909
- }
910
- return found
911
- }
912
-
913
- /** delete replica + all lock/wal files — forces zero-cache to do a full resync */
914
- function cleanupStaleReplica(config: ZeroLiteConfig): void {
915
- const replicaPath = resolve(config.dataDir, 'zero-replica.db')
916
- for (const suffix of ['', '-wal', '-shm', '-wal2']) {
917
- const file = replicaPath + suffix
918
- try {
919
- if (existsSync(file)) {
920
- unlinkSync(file)
921
- if (suffix) log.debug.orez(`cleaned up stale ${suffix} file`)
922
- else log.debug.orez('cleaned up stale replica (will re-sync)')
923
- }
924
- } catch {}
925
- }
926
- }
927
-
928
- async function seedIfNeeded(db: PGlite, config: ZeroLiteConfig): Promise<void> {
929
- // check if we already have data
930
- try {
931
- const result = await db.query<{ count: string }>(
932
- 'SELECT count(*) as count FROM public."user"'
933
- )
934
- if (Number(result.rows[0].count) > 0) {
935
- return
936
- }
937
- } catch {
938
- // table might not exist yet
939
- }
940
-
941
- log.debug.orez('seeding demo data')
942
- const seedFile = resolve(config.seedFile)
943
- if (!existsSync(seedFile)) {
944
- log.debug.orez('no seed file found, skipping')
945
- return
946
- }
947
-
948
- const sql = readFileSync(seedFile, 'utf-8')
949
- const statements = sql
950
- .split('--> statement-breakpoint')
951
- .map((s) => s.trim())
952
- .filter(Boolean)
953
-
954
- for (const stmt of statements) {
955
- await db.exec(stmt)
956
- }
957
- log.orez('seeded')
958
- }
959
-
960
- async function startZeroCache(
961
- config: ZeroLiteConfig,
962
- logStore?: LogStore,
963
- sqliteMode: SqliteMode = resolveSqliteMode(config.disableWasmSqlite),
964
- sqliteModeConfig?: SqliteModeConfig | null
965
- ): Promise<{ process: ChildProcess; env: Record<string, string> }> {
966
- // resolve @rocicorp/zero entry for finding zero-cache modules
967
- const zeroEntry = resolvePackage('@rocicorp/zero')
968
-
969
- if (!zeroEntry) {
970
- throw new Error('zero-cache not found. install @rocicorp/zero')
971
- }
972
-
973
- if (sqliteMode === 'native') {
974
- log.debug.orez('wasm sqlite disabled, using native @rocicorp/zero-sqlite3')
975
- }
976
-
977
- const upstreamUrl = getConnectionString(config, 'postgres')
978
- const cvrUrl = getConnectionString(config, 'zero_cvr')
979
- const cdbUrl = getConnectionString(config, 'zero_cdb')
980
-
981
- // defaults that can be overridden by user env
982
- // when admin is enabled and user hasn't set ZERO_LOG_LEVEL, use 'info'
983
- // to avoid flooding stdout with debug logs (each line triggers log processing).
984
- // debug was too expensive — tens of thousands of lines per minute.
985
- const zeroLogLevel =
986
- config.adminPort > 0 && !process.env.ZERO_LOG_LEVEL ? 'info' : config.logLevel
987
- const defaults: Record<string, string> = {
988
- NODE_ENV: 'development',
989
- ZERO_LOG_LEVEL: zeroLogLevel,
990
- ZERO_NUM_SYNC_WORKERS: '1',
991
- // disable query planner — it relies on scanStatus which causes infinite
992
- // loops with wasm sqlite and has caused freezes with native too.
993
- // planner is an optimization, not required for correctness.
994
- ZERO_ENABLE_QUERY_PLANNER: 'false',
995
- // disable otel metrics export — zero-cache has built-in OTEL that tries
996
- // to export even without a collector, causing periodic Bad Request errors.
997
- // user can override by setting OTEL_SDK_DISABLED=false in their env.
998
- OTEL_SDK_DISABLED: 'true',
999
- }
1000
-
1001
- const env: Record<string, string> = {
1002
- ...defaults,
1003
- ...(process.env as Record<string, string>),
1004
- // orez is a development tool — always run zero-cache in development mode
1005
- // to avoid production requirements like --admin-password
1006
- NODE_ENV: 'development',
1007
- ZERO_UPSTREAM_DB: upstreamUrl,
1008
- ZERO_CVR_DB: cvrUrl,
1009
- ZERO_CHANGE_DB: cdbUrl,
1010
- ZERO_REPLICA_FILE: resolve(config.dataDir, 'zero-replica.db'),
1011
- ZERO_PORT: String(config.zeroPort),
1012
- ...(config.zeroMutateUrl ? { ZERO_MUTATE_URL: config.zeroMutateUrl } : {}),
1013
- ...(config.zeroQueryUrl ? { ZERO_QUERY_URL: config.zeroQueryUrl } : {}),
1014
- // wasm sqlite SHM is file-backed but not as robust as native mmap —
1015
- // force single sync worker to avoid multi-process SHM contention
1016
- ...(sqliteMode === 'wasm' ? { ZERO_NUM_SYNC_WORKERS: '1' } : {}),
1017
- }
1018
-
1019
- // high worker counts multiply the blast radius of any sync-worker bug
1020
- // (e.g. orphaned workers busy-looping on EOF'd sibling pipes). dev rarely
1021
- // benefits from more than a couple; warn so it's obvious where the CPU
1022
- // went.
1023
- const workerCount = Number(env.ZERO_NUM_SYNC_WORKERS)
1024
- if (Number.isFinite(workerCount) && workerCount > 4) {
1025
- log.orez(
1026
- `warning: ZERO_NUM_SYNC_WORKERS=${workerCount} is high for development — each worker consumes CPU/memory and amplifies any sync-loop bug. consider 2.`
1027
- )
1028
- }
1029
-
1030
- const zeroCacheBin = resolve(zeroEntry, '..', 'cli.js')
1031
- if (!existsSync(zeroCacheBin)) {
1032
- throw new Error('zero-cache cli.js not found. install @rocicorp/zero')
1033
- }
1034
-
1035
- // apply sqlite mode shim (wasm: patches lib/index.js, native: restores original)
1036
- if (sqliteModeConfig) {
1037
- const shimResult = applySqliteMode(sqliteModeConfig)
1038
- if (!shimResult.success) {
1039
- log.orez(`warning: sqlite shim failed: ${shimResult.error}`)
1040
- }
1041
- }
1042
-
1043
- // preload script to label the zero-cache child process AND self-destruct
1044
- // if the orez parent dies. macOS has no PR_SET_PDEATHSIG, so on a hard
1045
- // parent kill (SIGKILL) or a crash that skips the `stop()` path, zero-cache
1046
- // workers get reparented to init and can busy-loop on EOF'd sibling pipes
1047
- // at 100% CPU indefinitely. every forked zero-cache worker inherits
1048
- // NODE_OPTIONS, so the --require below runs in each one; they independently
1049
- // poll the captured orez pid and exit when it disappears.
1050
- const preloadPath = resolve(config.dataDir, '.orez-zero-title.cjs')
1051
- const zeroTitle = orezTitle('orez [zero]')
1052
- writeFileSync(
1053
- preloadPath,
1054
- `process.title = ${JSON.stringify(zeroTitle)};\n` +
1055
- `const __orezPid = ${process.pid};\n` +
1056
- `setInterval(() => {\n` +
1057
- ` try { process.kill(__orezPid, 0); } catch { process.exit(0); }\n` +
1058
- `}, 1000).unref();\n`
1059
- )
1060
-
1061
- const nodeOptions = [
1062
- sqliteMode === 'wasm' ? '--max-old-space-size=16384' : '',
1063
- `--require ${preloadPath}`,
1064
- process.env.NODE_OPTIONS || '',
1065
- ]
1066
- .filter(Boolean)
1067
- .join(' ')
1068
- if (nodeOptions.trim()) env.NODE_OPTIONS = nodeOptions.trim()
1069
-
1070
- const nodeBinary = resolveNodeBinary()
1071
- const child = spawn(nodeBinary, [zeroCacheBin], {
1072
- env,
1073
- // stdin piped (not 'ignore') so zero-cache's pipe fd to orez closes with
1074
- // EOF on parent death — belt-and-suspenders alongside the ppid watchdog
1075
- // in the --require preload above.
1076
- stdio: ['pipe', 'pipe', 'pipe'],
1077
- }) as ZeroChildProcess
1078
- child.__orezTail = []
1079
-
1080
- const pushTail = (line: string) => {
1081
- const tail = child.__orezTail!
1082
- tail.push(line)
1083
- if (tail.length > 80) tail.splice(0, tail.length - 80)
1084
- }
1085
-
1086
- // known transient errors during zero-cache startup — demote to debug
1087
- const STARTUP_NOISE = [
1088
- '_zero.tableMetadata',
1089
- 'Unable to create full ReplicationStatusEvent',
1090
- 'replication slot',
1091
- 'does not exist',
1092
- 'error dropping',
1093
- 'EPIPE',
1094
- 'socket has been ended by the other party',
1095
- 'ideal db ping time',
1096
- 'average ping to',
1097
- // node.js warnings from stale replica timestamps causing negative setTimeout
1098
- 'TimeoutNegativeWarning',
1099
- 'does not allow a negative number',
1100
- // otel metrics export noise when no collector is configured
1101
- 'PeriodicExportingMetricReader',
1102
- 'OTLPExporterError',
1103
- ]
1104
- const isStartupNoise = (line: string): boolean =>
1105
- STARTUP_NOISE.some((pattern) => line.includes(pattern))
1106
-
1107
- // detect log level from zero-cache output
1108
- const detectLevel = (line: string, fallback: string): string => {
1109
- if (isStartupNoise(line)) return 'debug'
1110
- const lower = line.toLowerCase()
1111
- if (
1112
- lower.includes('"level":"error"') ||
1113
- lower.includes(' error ') ||
1114
- lower.includes('error:')
1115
- )
1116
- return 'error'
1117
- if (
1118
- lower.includes('"level":"warn"') ||
1119
- lower.includes(' warn ') ||
1120
- lower.includes('warning:')
1121
- )
1122
- return 'warn'
1123
- if (lower.includes('"level":"debug"') || lower.includes(' debug ')) return 'debug'
1124
- return fallback
1125
- }
1126
-
1127
- child.stdout?.on('data', (data: Buffer) => {
1128
- const lines = data.toString().trim().split('\n')
1129
- for (const line of lines) {
1130
- pushTail(`stdout: ${line}`)
1131
- const level = detectLevel(line, 'info')
1132
- if (level === 'warn' || level === 'error') log.zero(line)
1133
- else log.debug.zero(line)
1134
- logStore?.push('zero', level, line)
1135
- }
1136
- })
1137
-
1138
- child.stderr?.on('data', (data: Buffer) => {
1139
- const lines = data.toString().trim().split('\n')
1140
- for (const line of lines) {
1141
- pushTail(`stderr: ${line}`)
1142
- const level = detectLevel(line, 'error')
1143
- if (level === 'warn' || level === 'error') log.zero(line)
1144
- else log.debug.zero(line)
1145
- logStore?.push('zero', level, line)
1146
- }
1147
- })
1148
-
1149
- child.on('exit', (code) => {
1150
- if (code !== 0 && code !== null) {
1151
- pushTail(`exit: code ${code}`)
1152
- log.zero(`exited with code ${code}`)
1153
- logStore?.push('zero', 'error', `exited with code ${code}`)
1154
- }
1155
- })
1156
-
1157
- return { process: child, env }
1158
- }
1159
-
1160
- async function waitForZeroCache(
1161
- config: ZeroLiteConfig,
1162
- zeroProcess?: ChildProcess | null,
1163
- timeoutMs = 60000,
1164
- sqliteMode: SqliteMode = resolveSqliteMode(config.disableWasmSqlite)
1165
- ): Promise<void> {
1166
- const start = Date.now()
1167
- const url = `http://127.0.0.1:${config.zeroPort}/`
1168
-
1169
- const checkProcessAlive = () => {
1170
- if (zeroProcess && zeroProcess.exitCode !== null) {
1171
- const tail = (zeroProcess as ZeroChildProcess).__orezTail
1172
- const details = tail?.length ? `\n${tail.slice(-20).join('\n')}` : ''
1173
- throw new Error(
1174
- `zero-cache exited with code ${zeroProcess.exitCode}${details}${nativeStartupDiagnostics(details, sqliteMode)}`
1175
- )
1176
- }
1177
- }
1178
-
1179
- // phase 1: wait for HTTP health check
1180
- while (Date.now() - start < timeoutMs) {
1181
- checkProcessAlive()
1182
- try {
1183
- const controller = new AbortController()
1184
- const timer = setTimeout(() => controller.abort(), 1000)
1185
- const res = await fetch(url, { signal: controller.signal })
1186
- clearTimeout(timer)
1187
- // zero may return 404 on "/" while still being healthy.
1188
- if (res.ok || res.status === 404) break
1189
- } catch {
1190
- // not ready yet
1191
- }
1192
- await new Promise((r) => setTimeout(r, 500))
1193
- }
1194
-
1195
- if (Date.now() - start < timeoutMs) {
1196
- log.debug.orez('zero-cache HTTP health check passed')
1197
- return
1198
- }
1199
-
1200
- const tail = (zeroProcess as ZeroChildProcess | null | undefined)?.__orezTail
1201
- const details = tail?.length ? `\n${tail.slice(-20).join('\n')}` : ''
1202
- throw new Error(
1203
- `zero-cache health check timed out after ${timeoutMs}ms${details}${nativeStartupDiagnostics(details, sqliteMode)}`
1204
- )
1205
- }
1206
-
1207
- function nativeStartupDiagnostics(details: string, sqliteMode: SqliteMode): string {
1208
- if (sqliteMode !== 'native') return ''
1209
- if (!details) return ''
1210
- if (!hasMissingNativeBinarySignature(details)) return ''
1211
-
1212
- const check = inspectNativeSqliteBinary()
1213
- const instructions = formatNativeBootstrapInstructions(check)
1214
- return `\n\nnative sqlite startup diagnostics:\n${instructions}`
1215
- }