orez 0.2.26 → 0.2.29
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.
- package/dist/cf-do/worker.d.ts.map +1 -1
- package/dist/cf-do/worker.js +9 -1
- package/dist/cf-do/worker.js.map +1 -1
- package/dist/pg-proxy-do-backend.d.ts +2 -0
- package/dist/pg-proxy-do-backend.d.ts.map +1 -1
- package/dist/pg-proxy-do-backend.js +49 -7
- package/dist/pg-proxy-do-backend.js.map +1 -1
- package/dist/pg-sqlite-compiler/catalog/seed.d.ts +67 -0
- package/dist/pg-sqlite-compiler/catalog/seed.d.ts.map +1 -0
- package/dist/pg-sqlite-compiler/catalog/seed.js +436 -0
- package/dist/pg-sqlite-compiler/catalog/seed.js.map +1 -0
- package/dist/pg-sqlite-compiler/index.d.ts +12 -0
- package/dist/pg-sqlite-compiler/index.d.ts.map +1 -0
- package/dist/pg-sqlite-compiler/index.js +59 -0
- package/dist/pg-sqlite-compiler/index.js.map +1 -0
- package/dist/pg-sqlite-compiler/passes/ast-utils.d.ts +48 -0
- package/dist/pg-sqlite-compiler/passes/ast-utils.d.ts.map +1 -0
- package/dist/pg-sqlite-compiler/passes/ast-utils.js +93 -0
- package/dist/pg-sqlite-compiler/passes/ast-utils.js.map +1 -0
- package/dist/pg-sqlite-compiler/passes/catalog.d.ts +34 -0
- package/dist/pg-sqlite-compiler/passes/catalog.d.ts.map +1 -0
- package/dist/pg-sqlite-compiler/passes/catalog.js +30 -0
- package/dist/pg-sqlite-compiler/passes/catalog.js.map +1 -0
- package/dist/pg-sqlite-compiler/passes/datetime.d.ts +21 -0
- package/dist/pg-sqlite-compiler/passes/datetime.d.ts.map +1 -0
- package/dist/pg-sqlite-compiler/passes/datetime.js +53 -0
- package/dist/pg-sqlite-compiler/passes/datetime.js.map +1 -0
- package/dist/pg-sqlite-compiler/passes/index.d.ts +21 -0
- package/dist/pg-sqlite-compiler/passes/index.d.ts.map +1 -0
- package/dist/pg-sqlite-compiler/passes/index.js +39 -0
- package/dist/pg-sqlite-compiler/passes/index.js.map +1 -0
- package/dist/pg-sqlite-compiler/passes/types.d.ts +41 -0
- package/dist/pg-sqlite-compiler/passes/types.d.ts.map +1 -0
- package/dist/pg-sqlite-compiler/passes/types.js +103 -0
- package/dist/pg-sqlite-compiler/passes/types.js.map +1 -0
- package/dist/pg-sqlite-compiler/test/oracle.d.ts +34 -0
- package/dist/pg-sqlite-compiler/test/oracle.d.ts.map +1 -0
- package/dist/pg-sqlite-compiler/test/oracle.js +204 -0
- package/dist/pg-sqlite-compiler/test/oracle.js.map +1 -0
- package/dist/pg-sqlite-compiler/types.d.ts +55 -0
- package/dist/pg-sqlite-compiler/types.d.ts.map +1 -0
- package/dist/pg-sqlite-compiler/types.js +2 -0
- package/dist/pg-sqlite-compiler/types.js.map +1 -0
- package/package.json +8 -4
- package/src/admin/admin-data.test.ts +0 -348
- package/src/admin/http-proxy.ts +0 -252
- package/src/admin/log-store.ts +0 -192
- package/src/admin/server.ts +0 -471
- package/src/admin/ui.ts +0 -1322
- package/src/bench/proxy-throughput.bench.ts +0 -343
- package/src/bench/serial-mutations.bench.ts +0 -270
- package/src/browser.ts +0 -203
- package/src/cf-do/.wrangler/cache/cf.json +0 -1
- package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-shm +0 -0
- package/src/cf-do/.wrangler/state/v3/cache/miniflare-CacheObject/metadata.sqlite-wal +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/0f0f3bdf0abda097eb6f1246db4657d9fc622081362d894d82c1a1ce067b05b6.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/1ddd3a4a48a11b51658444f5458a1fb175194b1d5b6a5bda20ef3fe3205b900c.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/204a39120310d37e972c5914cfd71ad55c151bdb9e8ed289a5f8c5b052dd60e4.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/3835f242df9728adba3d127a238793fd054ed3e51df3f60749ee744c469bf2a2.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/4aa9c80eb716cf55b8995ccf7afab0b36c683e6da07d7c37a3f9c570136036df.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/533e2fd1d6ea46e7a9a0017916ef341802d438d72583462755f2c1f8225e9bf2.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/5ffa1aced1225ecaeac6366f7586aa3de92761cdff8711d81fbd81f248076abd.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/686c3a9f0d7e59ed2ab607efd4b76d779c97cafeb3818380033bf7c7eb86c819.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/6e8214e8dcfadd0deb52d64e5e9ca85c6b329ace11193909845995396914c473.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/78d9ec9ff873d3fe3507ff53c2a6f6dfc408b4268eb0db3f2a146c0678965366.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/7eff9f0ed7e27ad0d3f9d923de0682fab1928591172c1ba336c5f79a134a5d85.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/836cda5b995b25867d722ed4f4c2292167e80351a3c6038db626648eb247dd8b.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/91ef63b112209ab30172763acd8a0935106c248f7f1bcae5545ce37a9f201551.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/a66ea4293a5f5938bc6d116edfa2522bb85bc37aea3541fbc09c3b613b9b32c0.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/ceb2ab26b80590840b65651deb6e948d3bf81565c6751f3a58752cf4bf4aecae.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-shm +0 -0
- package/src/cf-do/.wrangler/state/v3/do/zero-do-ZeroDO/metadata.sqlite-wal +0 -0
- package/src/cf-do/ARCHITECTURE.md +0 -83
- package/src/cf-do/watermark.test.ts +0 -103
- package/src/cf-do/watermark.ts +0 -118
- package/src/cf-do/worker.ts +0 -1033
- package/src/cf-do/wrangler.toml +0 -11
- package/src/cf-pglite/README.md +0 -19
- package/src/change-tracking.ts +0 -25
- package/src/child-process.test.ts +0 -147
- package/src/child-process.ts +0 -90
- package/src/cli-entry.ts +0 -72
- package/src/cli.test.ts +0 -38
- package/src/cli.ts +0 -1214
- package/src/config.ts +0 -150
- package/src/do-sql-tracking.test.ts +0 -19
- package/src/do-sql-tracking.ts +0 -19
- package/src/index.ts +0 -1215
- package/src/integration/integration.test.ts +0 -517
- package/src/integration/native-binary.guard.test.ts +0 -13
- package/src/integration/native-startup.test.ts +0 -44
- package/src/integration/replication-latency.test.ts +0 -428
- package/src/integration/restore-live-stress.test.ts +0 -433
- package/src/integration/restore-reset.test.ts +0 -400
- package/src/integration/restore.test.ts +0 -274
- package/src/integration/test-permissions.ts +0 -147
- package/src/load-config.ts +0 -46
- package/src/log.ts +0 -96
- package/src/mutex.ts +0 -47
- package/src/pg-proxy-browser.singledb.test.ts +0 -233
- package/src/pg-proxy-browser.ts +0 -2022
- package/src/pg-proxy-do-backend.test.ts +0 -3890
- package/src/pg-proxy-do-backend.ts +0 -7157
- package/src/pg-proxy.ts +0 -1087
- package/src/pglite-ipc.test.ts +0 -116
- package/src/pglite-ipc.ts +0 -266
- package/src/pglite-manager.ts +0 -557
- package/src/pglite-web-proxy.test.ts +0 -57
- package/src/pglite-web-proxy.ts +0 -221
- package/src/pglite-web-worker.ts +0 -152
- package/src/pglite-worker-thread.ts +0 -253
- package/src/port.ts +0 -25
- package/src/process-title.ts +0 -9
- package/src/recovery.ts +0 -155
- package/src/replication/change-tracker.test.ts +0 -357
- package/src/replication/change-tracker.ts +0 -279
- package/src/replication/handler.test.ts +0 -511
- package/src/replication/handler.ts +0 -1190
- package/src/replication/pgoutput-encoder.test.ts +0 -697
- package/src/replication/pgoutput-encoder.ts +0 -373
- package/src/replication/tcp-replication.test.ts +0 -876
- package/src/replication/zero-compat.test.ts +0 -1150
- package/src/restore-stress.test.ts +0 -188
- package/src/s3-local.ts +0 -203
- package/src/shim/hooks.mjs +0 -120
- package/src/shim/register.mjs +0 -4
- package/src/sqlite-mode/apply-mode.ts +0 -224
- package/src/sqlite-mode/index.ts +0 -15
- package/src/sqlite-mode/native-binary.ts +0 -89
- package/src/sqlite-mode/package-resolve.ts +0 -17
- package/src/sqlite-mode/resolve-mode.ts +0 -80
- package/src/sqlite-mode/shim-template.ts +0 -159
- package/src/sqlite-mode/sqlite-mode.test.ts +0 -427
- package/src/sqlite-mode/types.ts +0 -30
- package/src/vite-plugin.ts +0 -67
- package/src/wasm-sqlite.test.ts +0 -537
- package/src/worker/browser-admin.ts +0 -52
- package/src/worker/browser-build-config.test.ts +0 -71
- package/src/worker/browser-build-config.ts +0 -109
- package/src/worker/browser-embed-admin.test.ts +0 -75
- package/src/worker/browser-embed.ts +0 -345
- package/src/worker/cf-patches.ts +0 -384
- package/src/worker/embed-integration.test.ts +0 -321
- package/src/worker/index.ts +0 -138
- package/src/worker/shims/fastify.test.ts +0 -255
- package/src/worker/shims/fastify.ts +0 -306
- package/src/worker/shims/http-service.test.ts +0 -355
- package/src/worker/shims/http-service.ts +0 -293
- package/src/worker/shims/node-stub.ts +0 -290
- package/src/worker/shims/oxfmt.ts +0 -3
- package/src/worker/shims/postgres-browser.ts +0 -59
- package/src/worker/shims/postgres-socket.test.ts +0 -576
- package/src/worker/shims/postgres-socket.ts +0 -310
- package/src/worker/shims/postgres.test.ts +0 -364
- package/src/worker/shims/postgres.ts +0 -1454
- package/src/worker/shims/sqlite-browser.test.ts +0 -233
- package/src/worker/shims/sqlite-browser.ts +0 -175
- package/src/worker/shims/sqlite.test.ts +0 -786
- package/src/worker/shims/sqlite.ts +0 -978
- package/src/worker/shims/stream-browser.ts +0 -15
- package/src/worker/shims/ws-browser.test.ts +0 -205
- package/src/worker/shims/ws-browser.ts +0 -248
- package/src/worker/shims/ws.test.ts +0 -288
- package/src/worker/shims/ws.ts +0 -467
- package/src/worker/shims/zero-process-env.ts +0 -11
- package/src/worker/types.ts +0 -75
- package/src/worker/worker-integration.test.ts +0 -223
- package/src/worker/worker.test.ts +0 -136
- package/src/worker/zero-cache-embed-cf.ts +0 -463
- 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
|
-
}
|