orez 0.5.8 → 0.5.11

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.
@@ -35,12 +35,7 @@
35
35
  * again from durable DO SQLite. see plans/cf-do-idle-hibernation.md and
36
36
  * `shouldHibernateIdleZeroCache` in ./zero-cache-do-idle.ts.
37
37
  */
38
- import './shims/zero-process-env.js';
39
38
  import { EventEmitter } from 'node:events';
40
- // static import so wrangler can follow the dependency tree and bundle
41
- // zero-cache with all its transitive deps + our shim aliases.
42
- // @ts-expect-error — internal zero-cache module, no type declarations
43
- import { runWorker as _runWorker } from '@rocicorp/zero/out/zero-cache/src/server/runner/run-worker.js';
44
39
  import { setLogLevel } from '../log.js';
45
40
  import { createBrowserProxy } from '../pg-proxy-browser.js';
46
41
  import { DoBackend } from '../pg-proxy-do-backend.js';
@@ -49,7 +44,73 @@ import { DurableObjectWebSocketHandoff, } from './durable-object-websocket-hando
49
44
  import { sweepLeakedSqliteHandles } from './embed-generation.js';
50
45
  import { createLocalSqlBackend } from './local-sql-backend.js';
51
46
  import { resetFastifyRegistry } from './shims/fastify.js';
47
+ import { acquireZeroProcessEnv } from './shims/zero-process-env.js';
48
+ // static import so wrangler follows zero-cache's dependency tree and shim aliases.
49
+ import { runWorker as _runWorker } from './zero-cache-run-worker.js';
52
50
  const runWorkerFn = _runWorker;
51
+ const WORKER_SHUTDOWN_TIMEOUT_MS = 5_000;
52
+ // zero-cache's in-process worker graph and the CF shims still contain
53
+ // process-wide module state. keep one embed per isolate until those upstream
54
+ // globals are instance-routed; rejecting a second generation is safer than
55
+ // cross-routing one durable object's sql or process events into another.
56
+ let activeGeneration = null;
57
+ const propertyOwners = new WeakMap();
58
+ function releaseGenerationWhenComplete(generation) {
59
+ if (activeGeneration === generation &&
60
+ generation.workerDone &&
61
+ generation.cleanupDone &&
62
+ !generation.cleanupFailed) {
63
+ activeGeneration = null;
64
+ }
65
+ }
66
+ function setOwnedProperty(mutations, generation, target, key, value) {
67
+ const mutation = mutations.find((candidate) => candidate.target === target && candidate.key === key);
68
+ if (mutation) {
69
+ mutation.installedValue = value;
70
+ }
71
+ else {
72
+ mutations.push({
73
+ hadValue: Object.prototype.hasOwnProperty.call(target, key),
74
+ installedValue: value,
75
+ key,
76
+ previousValue: target[key],
77
+ target,
78
+ });
79
+ }
80
+ let owners = propertyOwners.get(target);
81
+ if (!owners) {
82
+ owners = new Map();
83
+ propertyOwners.set(target, owners);
84
+ }
85
+ owners.set(key, generation.token);
86
+ target[key] = value;
87
+ }
88
+ function updateOwnedProperty(mutations, generation, target, key) {
89
+ const mutation = mutations.find((candidate) => candidate.target === target && candidate.key === key);
90
+ if (!mutation)
91
+ return;
92
+ const owners = propertyOwners.get(target);
93
+ if (owners?.get(key) !== generation.token)
94
+ return;
95
+ mutation.installedValue = target[key];
96
+ }
97
+ function restoreOwnedProperties(mutations, generation) {
98
+ for (let index = mutations.length - 1; index >= 0; index--) {
99
+ const mutation = mutations[index];
100
+ const owners = propertyOwners.get(mutation.target);
101
+ if (owners?.get(mutation.key) !== generation.token)
102
+ continue;
103
+ owners.delete(mutation.key);
104
+ if (mutation.target[mutation.key] !== mutation.installedValue)
105
+ continue;
106
+ if (mutation.hadValue) {
107
+ mutation.target[mutation.key] = mutation.previousValue;
108
+ }
109
+ else {
110
+ delete mutation.target[mutation.key];
111
+ }
112
+ }
113
+ }
53
114
  // tx-journal owner id for every pg session this embed opens. recovery at
54
115
  // embed boot targets exactly this owner, so it can never roll back another
55
116
  // client's live transaction on the shared upstream db (e.g. the app worker's
@@ -79,238 +140,326 @@ function addProtocolSessionFactory(backend, createProtocolSession) {
79
140
  * a DoBackend target for upstream/CVR/change Postgres connections.
80
141
  */
81
142
  export async function startZeroCacheEmbedCF(opts) {
82
- // wire orez's own logger from env, mirroring the node path's setLogLevel
83
- // (index.ts). without this, OREZ_LOG_LEVEL=debug on a CF deploy is silently
84
- // ignored and the replication poll loop is undebuggable in tails.
85
- setLogLevel(opts.env?.OREZ_LOG_LEVEL || 'warn');
86
- // generation hermeticity: the isolate (module state) outlives a stop() —
87
- // CF reuses it across DO instance recreation after idle-hibernation
88
- // teardown. a fresh embed must start from the replication-handler state a
89
- // fresh isolate would have (lsn floor, stream watermark, schema caches);
90
- // the reconnect reconciliation in handleStartReplication is designed to
91
- // resume from durable state, not from a prior generation's module vars.
92
- resetReplicationState();
93
- // embed restart contract (see ./embed-generation.ts): reclaim what process
94
- // death would have — sqlite handles the dead generation never closed
95
- // (zero-cache relies on process-per-worker exit for these), and the
96
- // fastify/ws shim instance registry (a dead change-streamer otherwise
97
- // captures the new generation's replicator subscription and boot hangs).
98
- const leakedHandles = sweepLeakedSqliteHandles();
99
- if (leakedHandles > 0) {
100
- console.warn(`[orez-zero-cache-cf] closed ${leakedHandles} sqlite handles leaked by the previous embed generation`);
101
- }
102
- resetFastifyRegistry();
103
- const appId = opts.appId || 'zero';
104
- const publications = opts.publications?.join(',') || `orez_${appId}_public`;
105
- const readyTimeout = opts.readyTimeout ?? 30000;
106
- const pgUser = opts.pgUser || 'user';
107
- const pgPassword = opts.pgPassword || '';
108
- const backendUrl = opts.backendUrl || 'https://orez-do-backend.local';
109
- const backendNamespace = opts.backendNamespace || appId;
110
- // zero-cache's CVR and change DBs are embed-private state; they live in
111
- // THIS Durable Object's SQLite so their pg sessions never pay a cross-DO
112
- // round-trip. only the shared upstream `postgres` db routes to ZeroSqlDO.
113
- const localSql = createLocalSqlBackend(opts.doSqlite);
114
- const createBackend = (dbName) => new DoBackend(backendUrl, dbName, backendNamespace, {
115
- fetch: dbName === 'postgres' ? opts.backendFetch : localSql.fetch,
116
- txOwner: EMBED_TX_OWNER,
117
- });
118
- // crash recovery: this embed boot proves the previous embed generation is
119
- // dead, so any journaled transaction it left mid-flight (DO eviction,
120
- // deploy upgrade-kill) is rolled back BEFORE any pg session opens. without
121
- // this, a partially-persisted tx (e.g. the change-streamer's cdc changeLog
122
- // write) wedges replication on every subsequent boot.
123
- localSql.recoverOrphanedTransactions();
124
- await recoverRemoteTransactions(`${backendUrl.replace(/\/+$/, '')}/recover-txs?db=postgres&ns=${encodeURIComponent(backendNamespace)}`, opts.backendFetch);
125
- const backends = {
126
- postgres: createBackend('postgres'),
127
- cvr: createBackend('zero_cvr'),
128
- cdb: createBackend('zero_cdb'),
129
- };
130
- const proxyBackends = {
131
- postgres: addProtocolSessionFactory(backends.postgres, () => createBackend('postgres')),
132
- cvr: addProtocolSessionFactory(backends.cvr, () => createBackend('zero_cvr')),
133
- cdb: addProtocolSessionFactory(backends.cdb, () => createBackend('zero_cdb')),
134
- };
135
- await Promise.all([
136
- backends.postgres.waitReady,
137
- backends.cvr.waitReady,
138
- backends.cdb.waitReady,
139
- ]);
140
- const proxy = await createBrowserProxy({
141
- postgres: proxyBackends.postgres,
142
- cvr: proxyBackends.cvr,
143
- cdb: proxyBackends.cdb,
144
- postgresReplicas: [],
145
- }, {
146
- pgUser,
147
- pgPassword,
148
- singleDb: false,
149
- logLevel: opts.env?.ZERO_LOG_LEVEL || 'info',
150
- });
151
- globalThis.__orez_do_sqlite = opts.doSqlite;
152
- globalThis.__orez_proxy_connect = (port) => {
153
- proxy.handleConnection(port);
154
- };
155
- globalThis.__orez_proxy_user = pgUser;
156
- globalThis.__orez_proxy_password = pgPassword;
157
- globalThis.process ??= {};
158
- globalThis.process.env ??= {};
159
- globalThis.process.pid ??= 1;
160
- globalThis.process.argv ??= [];
161
- globalThis.process.env.SINGLE_PROCESS = '1';
162
- globalThis.process.env.NODE_ENV = 'development';
163
- globalThis.process.kill ??= () => { };
164
- // create fake parent EventEmitter for zero-cache's runWorker()
165
- // must be declared before process.exit shim (which references it)
166
- const parent = new EventEmitter();
167
- const parentEmitter = new EventEmitter();
168
- parent.send = (message, sendHandle) => {
169
- parentEmitter.emit('message', message, sendHandle);
170
- return true;
171
- };
172
- parent.kill = (signal = 'SIGTERM') => {
173
- parent.emit(signal, signal);
174
- };
175
- parent.pid = globalThis.process.pid ?? 1;
176
- // shim process.exit to emit on parent instead of actually exiting
177
- const origExit = globalThis.process.exit;
178
- const origNodeEnv = globalThis.process.env.NODE_ENV;
179
- const origKill = globalThis.process.kill;
180
- const origFetch = globalThis.fetch;
181
- globalThis.process.exit = (code) => {
182
- parent.emit('exit', code ?? 0);
183
- };
184
- if (opts.apiFetch) {
185
- ;
186
- globalThis.fetch = (input, init) => {
187
- const request = new Request(input, init);
188
- const url = new URL(request.url);
189
- if (url.hostname === 'orez-zero-api.local')
190
- return opts.apiFetch(request);
191
- return origFetch(input, init);
192
- };
143
+ if (activeGeneration) {
144
+ throw new Error('zero-cache CF embed: another generation is active or still tearing down');
193
145
  }
194
- // build env for zero-cache
195
- const env = {
196
- ...globalThis.process.env,
197
- SINGLE_PROCESS: '1',
198
- NODE_ENV: 'development',
199
- // postgres-browser intercepts these URLs and routes PG wire over
200
- // MessagePort to the DoBackend-backed proxy above.
201
- ZERO_UPSTREAM_DB: `postgres://${pgUser}:ignored@127.0.0.1/postgres`,
202
- ZERO_CVR_DB: `postgres://${pgUser}:ignored@127.0.0.1/zero_cvr`,
203
- ZERO_CHANGE_DB: `postgres://${pgUser}:ignored@127.0.0.1/zero_cdb`,
204
- // this path is intercepted by the sqlite shim
205
- ZERO_REPLICA_FILE: ':do-sqlite:',
206
- // don't bind a port — we route via inject/handoff
207
- ZERO_PORT: '0',
208
- ZERO_APP_ID: appId,
209
- ZERO_APP_PUBLICATIONS: publications,
210
- ZERO_ADMIN_PASSWORD: opts.env?.ZERO_ADMIN_PASSWORD || crypto.randomUUID(),
211
- ZERO_NUM_SYNC_WORKERS: opts.env?.ZERO_NUM_SYNC_WORKERS || '1',
212
- ZERO_ENABLE_QUERY_PLANNER: 'false',
213
- // one isolate, one sync worker — zero-cache's default pg pools (upstream 20,
214
- // cvr 30, change 5) would let ~50 DoBackend protocol sessions accumulate
215
- // inside the single 128MB DO isolate, each carrying its own rewrite cache +
216
- // protocol/schema state. cap all three hard: with SINGLE_PROCESS + one sync
217
- // worker a couple connections per db is plenty, and the freed heap is what
218
- // keeps cold-boot view-syncer hydration from tipping the isolate over its
219
- // memory limit. (these are the max=N pools; zero also opens fixed max=1/max=2
220
- // pools per db for the replication stream + initial-sync that we can't cap.)
221
- ZERO_UPSTREAM_MAX_CONNS: opts.env?.ZERO_UPSTREAM_MAX_CONNS || '2',
222
- ZERO_CVR_MAX_CONNS: opts.env?.ZERO_CVR_MAX_CONNS || '2',
223
- ZERO_CHANGE_MAX_CONNS: opts.env?.ZERO_CHANGE_MAX_CONNS || '2',
224
- // 'info' dumps the full table schema as JSON on every replication-status
225
- // event; during cold-boot hydration + the reconnect loop that string churn
226
- // is pure heap pressure in the 128MB isolate. 'warn' keeps errors visible.
227
- ZERO_LOG_LEVEL: opts.env?.ZERO_LOG_LEVEL || 'warn',
228
- ...opts.env,
229
- // shadow sync is an optional upstream canary that imports the initial-sync
230
- // copy path. keep it disabled in the CF embed to avoid bundling unused
231
- // worker code and storage paths into Durable Objects.
232
- ZERO_SHADOW_SYNC_ENABLED: 'false',
146
+ const generation = {
147
+ cleanupDone: false,
148
+ cleanupFailed: false,
149
+ token: Symbol('zero-cache-cf-generation'),
150
+ workerDone: true,
233
151
  };
234
- Object.assign(globalThis.process.env, env);
235
- const debugEmbed = env.OREZ_DEBUG_EMBED === '1' || globalThis.__OREZ_DEBUG_EMBED__ === true;
236
- // wrap parent with onMessageType/onceMessageType helpers
237
- // must forward sendHandle (second arg) for WebSocket handoff
238
- const wrappedParent = new Proxy(parent, {
239
- get(target, prop, receiver) {
240
- if (prop === 'onMessageType') {
241
- return (type, handler) => {
242
- target.on('message', (data, sendHandle) => {
243
- if (Array.isArray(data) && data.length === 2 && data[0] === type) {
244
- handler(data[1], sendHandle);
245
- }
246
- });
247
- return receiver;
248
- };
249
- }
250
- if (prop === 'onceMessageType') {
251
- return (type, handler) => {
252
- const listener = (data, sendHandle) => {
253
- if (Array.isArray(data) && data.length === 2 && data[0] === type) {
254
- target.off('message', listener);
255
- handler(data[1], sendHandle);
256
- }
257
- };
258
- target.on('message', listener);
259
- return receiver;
260
- };
261
- }
262
- return Reflect.get(target, prop, receiver);
263
- },
264
- });
265
- // track state
152
+ activeGeneration = generation;
153
+ const globalRecord = globalThis;
154
+ let processRecord;
155
+ let processEnv;
156
+ let releaseProcessEnv = null;
157
+ const mutations = [];
158
+ const backendRoots = [];
159
+ let proxy = null;
160
+ let parent = null;
161
+ let parentEmitter = null;
162
+ let wrappedParent = null;
266
163
  let isReady = false;
164
+ let stopping = false;
267
165
  let runWorkerPromise = null;
268
- // capture the Fastify shim instance from zero-cache's HttpService.
269
- // the fastify shim stores itself on globalThis when created.
166
+ let workerSettledPromise = null;
167
+ let workerError;
168
+ let workerFailed = false;
169
+ let startupFailure;
170
+ let shutdownPromise = null;
270
171
  let fastifyInstance = null;
271
172
  let readyTimer;
173
+ let debugEmbed = false;
272
174
  const webSocketHandoff = new DurableObjectWebSocketHandoff(() => fastifyInstance);
273
- // wait for "ready" message
274
- const readyPromise = new Promise((resolve, reject) => {
275
- readyTimer = setTimeout(() => {
276
- reject(new Error(`zero-cache CF embed: timed out waiting for ready after ${readyTimeout}ms`));
277
- }, readyTimeout);
278
- parentEmitter.on('message', (msg) => {
279
- if (debugEmbed)
280
- console.debug('[orez-zero-cache-cf] parent message', msg);
281
- if (Array.isArray(msg) && msg[0] === 'ready') {
282
- if (readyTimer)
283
- clearTimeout(readyTimer);
284
- isReady = true;
285
- resolve();
175
+ const updateOwnedFastifyInstance = () => {
176
+ const instancesMutation = mutations.find((mutation) => mutation.target === globalRecord && mutation.key === '__orez_fastify_instances');
177
+ const currentInstance = globalRecord.__orez_fastify_instance;
178
+ if (Array.isArray(instancesMutation?.installedValue) &&
179
+ instancesMutation.installedValue.includes(currentInstance)) {
180
+ updateOwnedProperty(mutations, generation, globalRecord, '__orez_fastify_instance');
181
+ }
182
+ };
183
+ const shutdown = () => {
184
+ if (shutdownPromise)
185
+ return shutdownPromise;
186
+ stopping = true;
187
+ isReady = false;
188
+ if (readyTimer)
189
+ clearTimeout(readyTimer);
190
+ shutdownPromise = (async () => {
191
+ const cleanupErrors = [];
192
+ let resourceCleanupFailed = false;
193
+ if (wrappedParent && runWorkerPromise && !generation.workerDone) {
194
+ try {
195
+ ;
196
+ wrappedParent.kill('SIGTERM');
197
+ }
198
+ catch (err) {
199
+ cleanupErrors.push(err);
200
+ }
201
+ }
202
+ if (workerSettledPromise && !generation.workerDone) {
203
+ let timeout;
204
+ const workerStopped = await Promise.race([
205
+ workerSettledPromise.then(() => true),
206
+ new Promise((resolve) => {
207
+ timeout = setTimeout(() => resolve(false), WORKER_SHUTDOWN_TIMEOUT_MS);
208
+ }),
209
+ ]);
210
+ if (timeout)
211
+ clearTimeout(timeout);
212
+ if (!workerStopped) {
213
+ cleanupErrors.push(new Error(`zero-cache CF embed: worker did not terminate within ${WORKER_SHUTDOWN_TIMEOUT_MS}ms`));
214
+ }
215
+ }
216
+ if (workerFailed && workerError !== startupFailure) {
217
+ cleanupErrors.push(workerError);
218
+ }
219
+ if (proxy) {
220
+ try {
221
+ await proxy.close();
222
+ }
223
+ catch (err) {
224
+ resourceCleanupFailed = true;
225
+ cleanupErrors.push(err);
226
+ }
227
+ }
228
+ const backendResults = await Promise.allSettled(backendRoots.map((backend) => Promise.resolve().then(() => backend.close())));
229
+ for (const result of backendResults) {
230
+ if (result.status === 'rejected') {
231
+ resourceCleanupFailed = true;
232
+ cleanupErrors.push(result.reason);
233
+ }
234
+ }
235
+ updateOwnedFastifyInstance();
236
+ restoreOwnedProperties(mutations, generation);
237
+ releaseProcessEnv?.();
238
+ parentEmitter?.removeAllListeners();
239
+ if (generation.workerDone)
240
+ parent?.removeAllListeners();
241
+ generation.cleanupDone = true;
242
+ generation.cleanupFailed = resourceCleanupFailed;
243
+ releaseGenerationWhenComplete(generation);
244
+ if (cleanupErrors.length > 0) {
245
+ throw new AggregateError(cleanupErrors, 'zero-cache CF embed: teardown failed');
286
246
  }
247
+ })();
248
+ return shutdownPromise;
249
+ };
250
+ const handleUnexpectedWorkerExit = () => {
251
+ if (!isReady || stopping)
252
+ return;
253
+ isReady = false;
254
+ if (!workerFailed) {
255
+ workerFailed = true;
256
+ workerError = new Error('zero-cache CF embed: runWorker exited after becoming ready');
257
+ }
258
+ void shutdown().catch((error) => {
259
+ console.error('[orez-zero-cache-cf] unexpected worker exit cleanup failed', error);
287
260
  });
288
- });
289
- // start zero-cache
290
- runWorkerPromise = runWorkerFn(wrappedParent, env).catch((err) => {
291
- if (debugEmbed)
292
- console.error('[orez-zero-cache-cf] runWorker error', err);
293
- if (!isReady) {
294
- throw err;
261
+ };
262
+ try {
263
+ releaseProcessEnv = acquireZeroProcessEnv();
264
+ processRecord = globalRecord.process;
265
+ processEnv = processRecord.env;
266
+ // wire orez's own logger from env, mirroring the node path's setLogLevel.
267
+ setLogLevel(opts.env?.OREZ_LOG_LEVEL || 'warn');
268
+ resetReplicationState();
269
+ const leakedHandles = sweepLeakedSqliteHandles();
270
+ if (leakedHandles > 0) {
271
+ console.warn(`[orez-zero-cache-cf] closed ${leakedHandles} sqlite handles leaked by the previous embed generation`);
295
272
  }
296
- // after ready, errors during shutdown are expected
297
- });
298
- const workerStartupPromise = runWorkerPromise.then(() => {
299
- if (!isReady) {
300
- throw new Error('zero-cache CF embed: runWorker exited before ready');
273
+ setOwnedProperty(mutations, generation, globalRecord, '__orez_fastify_instance', undefined);
274
+ delete globalRecord.__orez_fastify_instance;
275
+ setOwnedProperty(mutations, generation, globalRecord, '__orez_fastify_instances', []);
276
+ resetFastifyRegistry();
277
+ updateOwnedProperty(mutations, generation, globalRecord, '__orez_fastify_instances');
278
+ const appId = opts.appId || 'zero';
279
+ const publications = opts.publications?.join(',') || `orez_${appId}_public`;
280
+ const readyTimeout = opts.readyTimeout ?? 30000;
281
+ const pgUser = opts.pgUser || 'user';
282
+ const pgPassword = opts.pgPassword || '';
283
+ const backendUrl = opts.backendUrl || 'https://orez-do-backend.local';
284
+ const backendNamespace = opts.backendNamespace || appId;
285
+ const localSql = createLocalSqlBackend(opts.doSqlite);
286
+ const instantiateBackend = (dbName) => new DoBackend(backendUrl, dbName, backendNamespace, {
287
+ fetch: dbName === 'postgres' ? opts.backendFetch : localSql.fetch,
288
+ txOwner: EMBED_TX_OWNER,
289
+ });
290
+ const createRootBackend = (dbName) => {
291
+ const backend = instantiateBackend(dbName);
292
+ backendRoots.push(backend);
293
+ return backend;
294
+ };
295
+ localSql.recoverOrphanedTransactions();
296
+ await recoverRemoteTransactions(`${backendUrl.replace(/\/+$/, '')}/recover-txs?db=postgres&ns=${encodeURIComponent(backendNamespace)}`, opts.backendFetch);
297
+ const backends = {
298
+ postgres: createRootBackend('postgres'),
299
+ cvr: createRootBackend('zero_cvr'),
300
+ cdb: createRootBackend('zero_cdb'),
301
+ };
302
+ const proxyBackends = {
303
+ postgres: addProtocolSessionFactory(backends.postgres, () => instantiateBackend('postgres')),
304
+ cvr: addProtocolSessionFactory(backends.cvr, () => instantiateBackend('zero_cvr')),
305
+ cdb: addProtocolSessionFactory(backends.cdb, () => instantiateBackend('zero_cdb')),
306
+ };
307
+ const backendReadyResults = await Promise.allSettled([
308
+ backends.postgres.waitReady,
309
+ backends.cvr.waitReady,
310
+ backends.cdb.waitReady,
311
+ ]);
312
+ const backendReadyErrors = backendReadyResults.flatMap((result) => result.status === 'rejected' ? [result.reason] : []);
313
+ if (backendReadyErrors.length === 1)
314
+ throw backendReadyErrors[0];
315
+ if (backendReadyErrors.length > 1) {
316
+ throw new AggregateError(backendReadyErrors, 'zero-cache CF embed: backend setup failed');
301
317
  }
302
- });
303
- // wait for ready
304
- try {
318
+ proxy = await createBrowserProxy({
319
+ postgres: proxyBackends.postgres,
320
+ cvr: proxyBackends.cvr,
321
+ cdb: proxyBackends.cdb,
322
+ postgresReplicas: [],
323
+ }, {
324
+ pgUser,
325
+ pgPassword,
326
+ singleDb: false,
327
+ logLevel: opts.env?.ZERO_LOG_LEVEL || 'info',
328
+ });
329
+ setOwnedProperty(mutations, generation, globalRecord, '__orez_do_sqlite', opts.doSqlite);
330
+ setOwnedProperty(mutations, generation, globalRecord, '__orez_proxy_connect', (port) => proxy?.handleConnection(port));
331
+ setOwnedProperty(mutations, generation, globalRecord, '__orez_proxy_user', pgUser);
332
+ setOwnedProperty(mutations, generation, globalRecord, '__orez_proxy_password', pgPassword);
333
+ const createdParent = new EventEmitter();
334
+ parent = createdParent;
335
+ parentEmitter = new EventEmitter();
336
+ createdParent.send = (message, sendHandle) => {
337
+ parentEmitter?.emit('message', message, sendHandle);
338
+ return true;
339
+ };
340
+ createdParent.kill = (signal = 'SIGTERM') => {
341
+ createdParent.emit(signal, signal);
342
+ };
343
+ createdParent.pid = processRecord.pid ?? 1;
344
+ const originalFetch = globalRecord.fetch;
345
+ setOwnedProperty(mutations, generation, processRecord, 'exit', (code) => parent?.emit('exit', code ?? 0));
346
+ if (opts.apiFetch) {
347
+ setOwnedProperty(mutations, generation, globalRecord, 'fetch', (input, init) => {
348
+ const request = new Request(input, init);
349
+ const url = new URL(request.url);
350
+ if (url.hostname === 'orez-zero-api.local')
351
+ return opts.apiFetch(request);
352
+ return originalFetch(input, init);
353
+ });
354
+ }
355
+ const env = {
356
+ ...processEnv,
357
+ SINGLE_PROCESS: '1',
358
+ NODE_ENV: 'development',
359
+ ZERO_UPSTREAM_DB: `postgres://${pgUser}:ignored@127.0.0.1/postgres`,
360
+ ZERO_CVR_DB: `postgres://${pgUser}:ignored@127.0.0.1/zero_cvr`,
361
+ ZERO_CHANGE_DB: `postgres://${pgUser}:ignored@127.0.0.1/zero_cdb`,
362
+ ZERO_REPLICA_FILE: ':do-sqlite:',
363
+ ZERO_PORT: '0',
364
+ ZERO_APP_ID: appId,
365
+ ZERO_APP_PUBLICATIONS: publications,
366
+ ZERO_ADMIN_PASSWORD: opts.env?.ZERO_ADMIN_PASSWORD || crypto.randomUUID(),
367
+ ZERO_NUM_SYNC_WORKERS: opts.env?.ZERO_NUM_SYNC_WORKERS || '1',
368
+ ZERO_ENABLE_QUERY_PLANNER: 'false',
369
+ ZERO_UPSTREAM_MAX_CONNS: opts.env?.ZERO_UPSTREAM_MAX_CONNS || '2',
370
+ ZERO_CVR_MAX_CONNS: opts.env?.ZERO_CVR_MAX_CONNS || '2',
371
+ ZERO_CHANGE_MAX_CONNS: opts.env?.ZERO_CHANGE_MAX_CONNS || '2',
372
+ ZERO_LOG_LEVEL: opts.env?.ZERO_LOG_LEVEL || 'warn',
373
+ ...opts.env,
374
+ ZERO_SHADOW_SYNC_ENABLED: 'false',
375
+ };
376
+ for (const [key, value] of Object.entries(env)) {
377
+ setOwnedProperty(mutations, generation, processEnv, key, value);
378
+ }
379
+ debugEmbed =
380
+ env.OREZ_DEBUG_EMBED === '1' || globalRecord.__OREZ_DEBUG_EMBED__ === true;
381
+ wrappedParent = new Proxy(createdParent, {
382
+ get(target, prop, receiver) {
383
+ if (prop === 'onMessageType') {
384
+ return (type, handler) => {
385
+ target.on('message', (data, sendHandle) => {
386
+ if (Array.isArray(data) && data.length === 2 && data[0] === type) {
387
+ handler(data[1], sendHandle);
388
+ }
389
+ });
390
+ return receiver;
391
+ };
392
+ }
393
+ if (prop === 'onceMessageType') {
394
+ return (type, handler) => {
395
+ const listener = (data, sendHandle) => {
396
+ if (Array.isArray(data) && data.length === 2 && data[0] === type) {
397
+ target.off('message', listener);
398
+ handler(data[1], sendHandle);
399
+ }
400
+ };
401
+ target.on('message', listener);
402
+ return receiver;
403
+ };
404
+ }
405
+ return Reflect.get(target, prop, receiver);
406
+ },
407
+ });
408
+ const readyPromise = new Promise((resolve, reject) => {
409
+ readyTimer = setTimeout(() => {
410
+ reject(new Error(`zero-cache CF embed: timed out waiting for ready after ${readyTimeout}ms`));
411
+ }, readyTimeout);
412
+ parentEmitter?.on('message', (msg) => {
413
+ if (debugEmbed)
414
+ console.debug('[orez-zero-cache-cf] parent message', msg);
415
+ if (!stopping && Array.isArray(msg) && msg[0] === 'ready') {
416
+ if (readyTimer)
417
+ clearTimeout(readyTimer);
418
+ isReady = true;
419
+ resolve();
420
+ }
421
+ });
422
+ });
423
+ generation.workerDone = false;
424
+ runWorkerPromise = Promise.resolve().then(() => runWorkerFn(wrappedParent, env));
425
+ void runWorkerPromise.catch((err) => {
426
+ if (debugEmbed)
427
+ console.error('[orez-zero-cache-cf] runWorker error', err);
428
+ });
429
+ workerSettledPromise = runWorkerPromise.then(() => {
430
+ generation.workerDone = true;
431
+ handleUnexpectedWorkerExit();
432
+ if (generation.cleanupDone)
433
+ parent?.removeAllListeners();
434
+ releaseGenerationWhenComplete(generation);
435
+ }, (error) => {
436
+ workerError = error;
437
+ workerFailed = true;
438
+ generation.workerDone = true;
439
+ handleUnexpectedWorkerExit();
440
+ if (generation.cleanupDone)
441
+ parent?.removeAllListeners();
442
+ releaseGenerationWhenComplete(generation);
443
+ });
444
+ const workerStartupPromise = runWorkerPromise.then(() => {
445
+ if (!isReady) {
446
+ throw new Error('zero-cache CF embed: runWorker exited before ready');
447
+ }
448
+ });
305
449
  await Promise.race([readyPromise, workerStartupPromise]);
450
+ fastifyInstance = globalRecord.__orez_fastify_instance;
451
+ updateOwnedFastifyInstance();
306
452
  }
307
- catch (err) {
308
- if (readyTimer)
309
- clearTimeout(readyTimer);
310
- throw err;
453
+ catch (startupError) {
454
+ startupFailure = startupError;
455
+ try {
456
+ await shutdown();
457
+ }
458
+ catch (cleanupError) {
459
+ throw new AggregateError([startupError, cleanupError], 'zero-cache CF embed: startup failed and teardown also failed', { cause: startupError });
460
+ }
461
+ throw startupError;
311
462
  }
312
- // get the fastify instance (set by our shim during init)
313
- fastifyInstance = globalThis.__orez_fastify_instance;
314
463
  return {
315
464
  get ready() {
316
465
  return isReady;
@@ -330,41 +479,7 @@ export async function startZeroCacheEmbedCF(opts) {
330
479
  }
331
480
  return handleHttpRequest(request, url, fastifyInstance);
332
481
  },
333
- async stop() {
334
- isReady = false;
335
- wrappedParent.kill('SIGTERM');
336
- if (runWorkerPromise) {
337
- await Promise.race([runWorkerPromise, new Promise((r) => setTimeout(r, 5000))]);
338
- }
339
- await new Promise((r) => setTimeout(r, 200));
340
- proxy.close();
341
- await Promise.all([
342
- backends.postgres.close(),
343
- backends.cvr.close(),
344
- backends.cdb.close(),
345
- ]);
346
- // restore all modified globals
347
- if (origExit) {
348
- ;
349
- globalThis.process.exit = origExit;
350
- }
351
- if (origNodeEnv !== undefined) {
352
- ;
353
- globalThis.process.env.NODE_ENV = origNodeEnv;
354
- }
355
- if (origKill) {
356
- ;
357
- globalThis.process.kill = origKill;
358
- }
359
- if (opts.apiFetch) {
360
- ;
361
- globalThis.fetch = origFetch;
362
- }
363
- delete globalThis.process.env.SINGLE_PROCESS;
364
- delete globalThis.__orez_proxy_connect;
365
- delete globalThis.__orez_proxy_user;
366
- delete globalThis.__orez_proxy_password;
367
- },
482
+ stop: shutdown,
368
483
  };
369
484
  }
370
485
  // -- HTTP request handling --