orez 0.5.9 → 0.5.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/browser.js +1 -1
  2. package/dist/browser.js.map +1 -1
  3. package/dist/cf-do/tx-journal.d.ts +6 -5
  4. package/dist/cf-do/tx-journal.d.ts.map +1 -1
  5. package/dist/cf-do/tx-journal.js +116 -27
  6. package/dist/cf-do/tx-journal.js.map +1 -1
  7. package/dist/pg-proxy-browser.d.ts +3 -1
  8. package/dist/pg-proxy-browser.d.ts.map +1 -1
  9. package/dist/pg-proxy-browser.js +240 -210
  10. package/dist/pg-proxy-browser.js.map +1 -1
  11. package/dist/pg-proxy-do-backend.d.ts +8 -0
  12. package/dist/pg-proxy-do-backend.d.ts.map +1 -1
  13. package/dist/pg-proxy-do-backend.js +27 -9
  14. package/dist/pg-proxy-do-backend.js.map +1 -1
  15. package/dist/pg-sqlite-compiler/compiler.d.ts +17 -0
  16. package/dist/pg-sqlite-compiler/compiler.d.ts.map +1 -0
  17. package/dist/pg-sqlite-compiler/compiler.js +80 -0
  18. package/dist/pg-sqlite-compiler/compiler.js.map +1 -0
  19. package/dist/pg-sqlite-compiler/index.d.ts +1 -16
  20. package/dist/pg-sqlite-compiler/index.d.ts.map +1 -1
  21. package/dist/pg-sqlite-compiler/index.js +2 -79
  22. package/dist/pg-sqlite-compiler/index.js.map +1 -1
  23. package/dist/replication/handler.d.ts +8 -7
  24. package/dist/replication/handler.d.ts.map +1 -1
  25. package/dist/replication/handler.js +147 -116
  26. package/dist/replication/handler.js.map +1 -1
  27. package/dist/worker/browser-build-config.d.ts.map +1 -1
  28. package/dist/worker/browser-build-config.js +1 -0
  29. package/dist/worker/browser-build-config.js.map +1 -1
  30. package/dist/worker/browser-embed.d.ts +3 -2
  31. package/dist/worker/browser-embed.d.ts.map +1 -1
  32. package/dist/worker/browser-embed.js +187 -150
  33. package/dist/worker/browser-embed.js.map +1 -1
  34. package/dist/worker/cf-instance-runtime.d.ts +62 -0
  35. package/dist/worker/cf-instance-runtime.d.ts.map +1 -0
  36. package/dist/worker/cf-instance-runtime.js +260 -0
  37. package/dist/worker/cf-instance-runtime.js.map +1 -0
  38. package/dist/worker/cf-patches.d.ts +9 -6
  39. package/dist/worker/cf-patches.d.ts.map +1 -1
  40. package/dist/worker/cf-patches.js +117 -60
  41. package/dist/worker/cf-patches.js.map +1 -1
  42. package/dist/worker/durable-object-websocket-handoff.d.ts +2 -0
  43. package/dist/worker/durable-object-websocket-handoff.d.ts.map +1 -1
  44. package/dist/worker/durable-object-websocket-handoff.js +22 -41
  45. package/dist/worker/durable-object-websocket-handoff.js.map +1 -1
  46. package/dist/worker/embed-generation.d.ts +9 -6
  47. package/dist/worker/embed-generation.d.ts.map +1 -1
  48. package/dist/worker/embed-generation.js +27 -6
  49. package/dist/worker/embed-generation.js.map +1 -1
  50. package/dist/worker/shims/fastify-websocket.d.ts +3 -0
  51. package/dist/worker/shims/fastify-websocket.d.ts.map +1 -0
  52. package/dist/worker/shims/fastify-websocket.js +3 -0
  53. package/dist/worker/shims/fastify-websocket.js.map +1 -0
  54. package/dist/worker/shims/fastify.d.ts +2 -10
  55. package/dist/worker/shims/fastify.d.ts.map +1 -1
  56. package/dist/worker/shims/fastify.js +14 -21
  57. package/dist/worker/shims/fastify.js.map +1 -1
  58. package/dist/worker/shims/postgres-browser.d.ts +1 -1
  59. package/dist/worker/shims/postgres-browser.d.ts.map +1 -1
  60. package/dist/worker/shims/postgres-browser.js +29 -20
  61. package/dist/worker/shims/postgres-browser.js.map +1 -1
  62. package/dist/worker/shims/sqlite-browser.d.ts +2 -3
  63. package/dist/worker/shims/sqlite-browser.d.ts.map +1 -1
  64. package/dist/worker/shims/sqlite-browser.js +2 -3
  65. package/dist/worker/shims/sqlite-browser.js.map +1 -1
  66. package/dist/worker/shims/sqlite.d.ts.map +1 -1
  67. package/dist/worker/shims/sqlite.js +56 -35
  68. package/dist/worker/shims/sqlite.js.map +1 -1
  69. package/dist/worker/shims/ws.d.ts.map +1 -1
  70. package/dist/worker/shims/ws.js +54 -39
  71. package/dist/worker/shims/ws.js.map +1 -1
  72. package/dist/worker/shims/zero-process-env.d.ts +1 -1
  73. package/dist/worker/shims/zero-process-env.d.ts.map +1 -1
  74. package/dist/worker/shims/zero-process-env.js +79 -8
  75. package/dist/worker/shims/zero-process-env.js.map +1 -1
  76. package/dist/worker/zero-cache-embed-cf.d.ts +5 -2
  77. package/dist/worker/zero-cache-embed-cf.d.ts.map +1 -1
  78. package/dist/worker/zero-cache-embed-cf.js +339 -256
  79. package/dist/worker/zero-cache-embed-cf.js.map +1 -1
  80. package/dist/worker/zero-cache-run-worker.d.ts +2 -0
  81. package/dist/worker/zero-cache-run-worker.d.ts.map +1 -0
  82. package/dist/worker/zero-cache-run-worker.js +3 -0
  83. package/dist/worker/zero-cache-run-worker.js.map +1 -0
  84. package/package.json +6 -2
@@ -35,21 +35,25 @@
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
- import { setLogLevel } from '../log.js';
45
39
  import { createBrowserProxy } from '../pg-proxy-browser.js';
46
- import { DoBackend } from '../pg-proxy-do-backend.js';
47
- import { resetReplicationState } from '../replication/handler.js';
40
+ import { DoBackend, releaseDoBackendInstanceCaches } from '../pg-proxy-do-backend.js';
41
+ import { deleteReplicationState, resetReplicationState, signalReplicationChange, } from '../replication/handler.js';
42
+ import { apiURLForCFInstance, dispatcherFastifyForCFInstance, logCFInstance, postgresURLForCFInstance, registerCFInstanceRuntime, releaseCFInstanceRuntime, setCFInstanceEnv, setCFInstanceProxy, sqliteDirectoryForCFInstance, sqlitePathForCFInstance, } from './cf-instance-runtime.js';
48
43
  import { DurableObjectWebSocketHandoff, } from './durable-object-websocket-handoff.js';
49
- import { sweepLeakedSqliteHandles } from './embed-generation.js';
44
+ import { sweepCFInstanceSqliteHandles } from './embed-generation.js';
50
45
  import { createLocalSqlBackend } from './local-sql-backend.js';
51
- import { resetFastifyRegistry } from './shims/fastify.js';
46
+ import { acquireZeroProcessEnv } from './shims/zero-process-env.js';
47
+ // static import so wrangler follows zero-cache's dependency tree and shim aliases.
48
+ import { runWorker as _runWorker } from './zero-cache-run-worker.js';
52
49
  const runWorkerFn = _runWorker;
50
+ const WORKER_SHUTDOWN_TIMEOUT_MS = 5_000;
51
+ function releaseGenerationWhenComplete(generation) {
52
+ if (generation.workerDone && generation.cleanupDone && !generation.cleanupFailed) {
53
+ deleteReplicationState(generation.runtime.instanceId);
54
+ releaseCFInstanceRuntime(generation.runtime);
55
+ }
56
+ }
53
57
  // tx-journal owner id for every pg session this embed opens. recovery at
54
58
  // embed boot targets exactly this owner, so it can never roll back another
55
59
  // client's live transaction on the shared upstream db (e.g. the app worker's
@@ -57,12 +61,12 @@ const runWorkerFn = _runWorker;
57
61
  const EMBED_TX_OWNER = 'orez-embed';
58
62
  // roll back journaled transactions a dead embed generation left on the
59
63
  // remote SQL DO (upstream db sessions killed mid-transaction).
60
- async function recoverRemoteTransactions(url, backendFetch) {
64
+ async function recoverRemoteTransactions(url, owner, backendFetch) {
61
65
  const fetcher = backendFetch ?? fetch;
62
66
  const resp = await fetcher(url, {
63
67
  method: 'POST',
64
68
  headers: { 'Content-Type': 'application/json' },
65
- body: JSON.stringify({ owner: EMBED_TX_OWNER }),
69
+ body: JSON.stringify({ owner }),
66
70
  });
67
71
  if (!resp.ok) {
68
72
  const text = await resp.text().catch(() => '');
@@ -79,27 +83,6 @@ function addProtocolSessionFactory(backend, createProtocolSession) {
79
83
  * a DoBackend target for upstream/CVR/change Postgres connections.
80
84
  */
81
85
  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
86
  const appId = opts.appId || 'zero';
104
87
  const publications = opts.publications?.join(',') || `orez_${appId}_public`;
105
88
  const readyTimeout = opts.readyTimeout ?? 30000;
@@ -107,210 +90,344 @@ export async function startZeroCacheEmbedCF(opts) {
107
90
  const pgPassword = opts.pgPassword || '';
108
91
  const backendUrl = opts.backendUrl || 'https://orez-do-backend.local';
109
92
  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,
93
+ if (!opts.apiFetch && (opts.env?.ZERO_MUTATE_URL || opts.env?.ZERO_QUERY_URL)) {
94
+ throw new Error('zero-cache CF embed: apiFetch is required with ZERO_MUTATE_URL or ZERO_QUERY_URL');
95
+ }
96
+ const runtime = registerCFInstanceRuntime({
97
+ apiFetch: opts.apiFetch,
98
+ doSqlite: opts.doSqlite,
99
+ env: opts.env ?? {},
100
+ instanceId: opts.instanceId,
101
+ log: opts.log,
147
102
  pgPassword,
148
- singleDb: false,
149
- logLevel: opts.env?.ZERO_LOG_LEVEL || 'info',
103
+ pgUser,
150
104
  });
151
- globalThis.__orez_do_sqlite = opts.doSqlite;
152
- globalThis.__orez_proxy_connect = (port) => {
153
- proxy.handleConnection(port);
105
+ const generation = {
106
+ cleanupDone: false,
107
+ cleanupFailed: false,
108
+ runtime,
109
+ workerDone: true,
154
110
  };
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;
111
+ const instanceId = runtime.instanceId;
112
+ const globalRecord = globalThis;
113
+ let processRecord;
114
+ let releaseProcessEnv = null;
115
+ const backendRoots = [];
116
+ let proxy = null;
117
+ let parent = null;
118
+ let parentEmitter = null;
119
+ let wrappedParent = null;
120
+ let isReady = false;
121
+ let stopping = false;
122
+ let runWorkerPromise = null;
123
+ let workerSettledPromise = null;
124
+ let workerError;
125
+ let workerFailed = false;
126
+ let startupFailure;
127
+ let shutdownPromise = null;
128
+ let fastifyInstance = null;
129
+ let readyTimer;
130
+ let debugEmbed = false;
131
+ const webSocketHandoff = new DurableObjectWebSocketHandoff(() => fastifyInstance);
132
+ const releaseProcessEnvWhenWorkerDone = () => {
133
+ if (!generation.workerDone || !releaseProcessEnv)
134
+ return;
135
+ releaseProcessEnv();
136
+ releaseProcessEnv = null;
171
137
  };
172
- parent.kill = (signal = 'SIGTERM') => {
173
- parent.emit(signal, signal);
138
+ const shutdown = () => {
139
+ if (shutdownPromise)
140
+ return shutdownPromise;
141
+ stopping = true;
142
+ isReady = false;
143
+ if (readyTimer)
144
+ clearTimeout(readyTimer);
145
+ shutdownPromise = (async () => {
146
+ const cleanupErrors = [];
147
+ let resourceCleanupFailed = false;
148
+ if (wrappedParent && runWorkerPromise && !generation.workerDone) {
149
+ try {
150
+ ;
151
+ wrappedParent.kill('SIGTERM');
152
+ }
153
+ catch (err) {
154
+ cleanupErrors.push(err);
155
+ }
156
+ }
157
+ if (workerSettledPromise && !generation.workerDone) {
158
+ let timeout;
159
+ const workerStopped = await Promise.race([
160
+ workerSettledPromise.then(() => true),
161
+ new Promise((resolve) => {
162
+ timeout = setTimeout(() => resolve(false), WORKER_SHUTDOWN_TIMEOUT_MS);
163
+ }),
164
+ ]);
165
+ if (timeout)
166
+ clearTimeout(timeout);
167
+ if (!workerStopped) {
168
+ resourceCleanupFailed = true;
169
+ cleanupErrors.push(new Error(`zero-cache CF embed: worker did not terminate within ${WORKER_SHUTDOWN_TIMEOUT_MS}ms`));
170
+ }
171
+ }
172
+ if (workerFailed && workerError !== startupFailure) {
173
+ cleanupErrors.push(workerError);
174
+ }
175
+ if (proxy) {
176
+ try {
177
+ await proxy.close();
178
+ }
179
+ catch (err) {
180
+ resourceCleanupFailed = true;
181
+ cleanupErrors.push(err);
182
+ }
183
+ }
184
+ const backendResults = await Promise.allSettled(backendRoots.map((backend) => Promise.resolve().then(() => backend.close())));
185
+ for (const result of backendResults) {
186
+ if (result.status === 'rejected') {
187
+ resourceCleanupFailed = true;
188
+ cleanupErrors.push(result.reason);
189
+ }
190
+ }
191
+ releaseDoBackendInstanceCaches(instanceId);
192
+ try {
193
+ webSocketHandoff.closeAll();
194
+ }
195
+ catch (err) {
196
+ resourceCleanupFailed = true;
197
+ cleanupErrors.push(err);
198
+ }
199
+ try {
200
+ sweepCFInstanceSqliteHandles(runtime);
201
+ }
202
+ catch (err) {
203
+ resourceCleanupFailed = true;
204
+ cleanupErrors.push(err);
205
+ }
206
+ releaseProcessEnvWhenWorkerDone();
207
+ parentEmitter?.removeAllListeners();
208
+ if (generation.workerDone)
209
+ parent?.removeAllListeners();
210
+ generation.cleanupDone = true;
211
+ generation.cleanupFailed = resourceCleanupFailed;
212
+ releaseGenerationWhenComplete(generation);
213
+ if (cleanupErrors.length > 0) {
214
+ throw new AggregateError(cleanupErrors, 'zero-cache CF embed: teardown failed');
215
+ }
216
+ })();
217
+ return shutdownPromise;
174
218
  };
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);
219
+ const handleUnexpectedWorkerExit = () => {
220
+ if (!isReady || stopping)
221
+ return;
222
+ isReady = false;
223
+ if (!workerFailed) {
224
+ workerFailed = true;
225
+ workerError = new Error('zero-cache CF embed: runWorker exited after becoming ready');
226
+ }
227
+ void shutdown().catch((error) => {
228
+ logCFInstance(runtime, {
229
+ component: 'embed',
230
+ error,
231
+ event: 'unexpected-worker-exit-cleanup-failed',
232
+ });
233
+ console.error(`[orez-zero-cache-cf:${runtime.instanceId}] unexpected worker exit cleanup failed`, error);
234
+ });
183
235
  };
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);
236
+ try {
237
+ releaseProcessEnv = acquireZeroProcessEnv();
238
+ processRecord = globalRecord.process;
239
+ const localSql = createLocalSqlBackend(opts.doSqlite);
240
+ const txOwner = `${EMBED_TX_OWNER}:${runtime.encodedId}`;
241
+ const instantiateBackend = (dbName) => new DoBackend(backendUrl, dbName, backendNamespace, {
242
+ allowTransactionalDDL: true,
243
+ fetch: dbName === 'postgres' ? opts.backendFetch : localSql.fetch,
244
+ instanceId,
245
+ signalReplication: () => signalReplicationChange(instanceId),
246
+ txOwner,
247
+ });
248
+ const createRootBackend = (dbName) => {
249
+ const backend = instantiateBackend(dbName);
250
+ backendRoots.push(backend);
251
+ return backend;
192
252
  };
193
- }
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',
233
- };
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
- };
253
+ localSql.recoverOrphanedTransactions();
254
+ await recoverRemoteTransactions(`${backendUrl.replace(/\/+$/, '')}/recover-txs?db=postgres&ns=${encodeURIComponent(backendNamespace)}`, txOwner, opts.backendFetch);
255
+ const backends = {
256
+ postgres: createRootBackend('postgres'),
257
+ cvr: createRootBackend('zero_cvr'),
258
+ cdb: createRootBackend('zero_cdb'),
259
+ };
260
+ const proxyBackends = {
261
+ postgres: addProtocolSessionFactory(backends.postgres, () => instantiateBackend('postgres')),
262
+ cvr: addProtocolSessionFactory(backends.cvr, () => instantiateBackend('zero_cvr')),
263
+ cdb: addProtocolSessionFactory(backends.cdb, () => instantiateBackend('zero_cdb')),
264
+ };
265
+ const backendReadyResults = await Promise.allSettled([
266
+ backends.postgres.waitReady,
267
+ backends.cvr.waitReady,
268
+ backends.cdb.waitReady,
269
+ ]);
270
+ const backendReadyErrors = backendReadyResults.flatMap((result) => result.status === 'rejected' ? [result.reason] : []);
271
+ if (backendReadyErrors.length === 1)
272
+ throw backendReadyErrors[0];
273
+ if (backendReadyErrors.length > 1) {
274
+ throw new AggregateError(backendReadyErrors, 'zero-cache CF embed: backend setup failed');
275
+ }
276
+ proxy = await createBrowserProxy({
277
+ postgres: proxyBackends.postgres,
278
+ cvr: proxyBackends.cvr,
279
+ cdb: proxyBackends.cdb,
280
+ postgresReplicas: [],
281
+ }, {
282
+ debugWire: opts.env?.OREZ_DEBUG_WIRE === '1',
283
+ instanceId,
284
+ pgUser,
285
+ pgPassword,
286
+ singleDb: false,
287
+ logLevel: opts.env?.ZERO_LOG_LEVEL || 'info',
288
+ });
289
+ setCFInstanceProxy(runtime, (port) => proxy?.handleConnection(port));
290
+ const createdParent = new EventEmitter();
291
+ parent = createdParent;
292
+ parentEmitter = new EventEmitter();
293
+ createdParent.send = (message, sendHandle) => {
294
+ parentEmitter?.emit('message', message, sendHandle);
295
+ return true;
296
+ };
297
+ createdParent.kill = (signal = 'SIGTERM') => {
298
+ createdParent.emit(signal, signal);
299
+ };
300
+ createdParent.pid = processRecord.pid ?? 1;
301
+ const env = {
302
+ SINGLE_PROCESS: '1',
303
+ NODE_ENV: 'development',
304
+ ZERO_APP_ID: appId,
305
+ ZERO_APP_PUBLICATIONS: publications,
306
+ ZERO_ADMIN_PASSWORD: opts.env?.ZERO_ADMIN_PASSWORD || crypto.randomUUID(),
307
+ ZERO_NUM_SYNC_WORKERS: opts.env?.ZERO_NUM_SYNC_WORKERS || '1',
308
+ ZERO_ENABLE_QUERY_PLANNER: 'false',
309
+ ZERO_UPSTREAM_MAX_CONNS: opts.env?.ZERO_UPSTREAM_MAX_CONNS || '2',
310
+ ZERO_CVR_MAX_CONNS: opts.env?.ZERO_CVR_MAX_CONNS || '2',
311
+ ZERO_CHANGE_MAX_CONNS: opts.env?.ZERO_CHANGE_MAX_CONNS || '2',
312
+ ZERO_LOG_LEVEL: opts.env?.ZERO_LOG_LEVEL || 'warn',
313
+ ...opts.env,
314
+ ZERO_UPSTREAM_DB: postgresURLForCFInstance(instanceId, 'postgres', pgUser),
315
+ ZERO_CVR_DB: postgresURLForCFInstance(instanceId, 'zero_cvr', pgUser),
316
+ ZERO_CHANGE_DB: postgresURLForCFInstance(instanceId, 'zero_cdb', pgUser),
317
+ ZERO_REPLICA_FILE: sqlitePathForCFInstance(instanceId),
318
+ ZERO_STORAGE_DB_TMP_DIR: sqliteDirectoryForCFInstance(instanceId),
319
+ ZERO_PORT: String(runtime.basePort),
320
+ ZERO_TASK_ID: `orez-cf-${runtime.encodedId}`,
321
+ ZERO_SHADOW_SYNC_ENABLED: 'false',
322
+ };
323
+ if (opts.apiFetch) {
324
+ for (const key of ['ZERO_MUTATE_URL', 'ZERO_QUERY_URL']) {
325
+ if (env[key])
326
+ env[key] = apiURLForCFInstance(instanceId, env[key]);
249
327
  }
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
- }
328
+ }
329
+ setCFInstanceEnv(runtime, env);
330
+ resetReplicationState(instanceId, env, opts.log);
331
+ debugEmbed = env.OREZ_DEBUG_EMBED === '1';
332
+ wrappedParent = new Proxy(createdParent, {
333
+ get(target, prop, receiver) {
334
+ if (prop === 'onMessageType') {
335
+ return (type, handler) => {
336
+ target.on('message', (data, sendHandle) => {
337
+ if (Array.isArray(data) && data.length === 2 && data[0] === type) {
338
+ handler(data[1], sendHandle);
339
+ }
340
+ });
341
+ return receiver;
342
+ };
343
+ }
344
+ if (prop === 'onceMessageType') {
345
+ return (type, handler) => {
346
+ const listener = (data, sendHandle) => {
347
+ if (Array.isArray(data) && data.length === 2 && data[0] === type) {
348
+ target.off('message', listener);
349
+ handler(data[1], sendHandle);
350
+ }
351
+ };
352
+ target.on('message', listener);
353
+ return receiver;
257
354
  };
258
- target.on('message', listener);
259
- return receiver;
355
+ }
356
+ const value = Reflect.get(target, prop, target);
357
+ if (typeof value !== 'function')
358
+ return value;
359
+ return (...args) => {
360
+ const result = value.apply(target, args);
361
+ return result === target ? receiver : result;
260
362
  };
363
+ },
364
+ });
365
+ const readyPromise = new Promise((resolve, reject) => {
366
+ readyTimer = setTimeout(() => {
367
+ reject(new Error(`zero-cache CF embed: timed out waiting for ready after ${readyTimeout}ms`));
368
+ }, readyTimeout);
369
+ parentEmitter?.on('message', (msg) => {
370
+ if (debugEmbed) {
371
+ logCFInstance(runtime, {
372
+ component: 'embed',
373
+ event: 'parent-message',
374
+ message: msg,
375
+ });
376
+ }
377
+ if (!stopping && Array.isArray(msg) && msg[0] === 'ready') {
378
+ if (readyTimer)
379
+ clearTimeout(readyTimer);
380
+ isReady = true;
381
+ resolve();
382
+ }
383
+ });
384
+ });
385
+ generation.workerDone = false;
386
+ runWorkerPromise = Promise.resolve().then(() => runWorkerFn(wrappedParent, env));
387
+ void runWorkerPromise.catch((err) => {
388
+ if (debugEmbed) {
389
+ logCFInstance(runtime, {
390
+ component: 'embed',
391
+ error: err,
392
+ event: 'worker-error',
393
+ });
261
394
  }
262
- return Reflect.get(target, prop, receiver);
263
- },
264
- });
265
- // track state
266
- let isReady = false;
267
- let runWorkerPromise = null;
268
- // capture the Fastify shim instance from zero-cache's HttpService.
269
- // the fastify shim stores itself on globalThis when created.
270
- let fastifyInstance = null;
271
- let readyTimer;
272
- 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();
395
+ });
396
+ workerSettledPromise = runWorkerPromise.then(() => {
397
+ generation.workerDone = true;
398
+ releaseProcessEnvWhenWorkerDone();
399
+ handleUnexpectedWorkerExit();
400
+ if (generation.cleanupDone)
401
+ parent?.removeAllListeners();
402
+ releaseGenerationWhenComplete(generation);
403
+ }, (error) => {
404
+ workerError = error;
405
+ workerFailed = true;
406
+ generation.workerDone = true;
407
+ releaseProcessEnvWhenWorkerDone();
408
+ handleUnexpectedWorkerExit();
409
+ if (generation.cleanupDone)
410
+ parent?.removeAllListeners();
411
+ releaseGenerationWhenComplete(generation);
412
+ });
413
+ const workerStartupPromise = runWorkerPromise.then(() => {
414
+ if (!isReady) {
415
+ throw new Error('zero-cache CF embed: runWorker exited before ready');
286
416
  }
287
417
  });
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;
295
- }
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');
301
- }
302
- });
303
- // wait for ready
304
- try {
305
418
  await Promise.race([readyPromise, workerStartupPromise]);
419
+ fastifyInstance = dispatcherFastifyForCFInstance(instanceId);
306
420
  }
307
- catch (err) {
308
- if (readyTimer)
309
- clearTimeout(readyTimer);
310
- throw err;
421
+ catch (startupError) {
422
+ startupFailure = startupError;
423
+ try {
424
+ await shutdown();
425
+ }
426
+ catch (cleanupError) {
427
+ throw new AggregateError([startupError, cleanupError], 'zero-cache CF embed: startup failed and teardown also failed', { cause: startupError });
428
+ }
429
+ throw startupError;
311
430
  }
312
- // get the fastify instance (set by our shim during init)
313
- fastifyInstance = globalThis.__orez_fastify_instance;
314
431
  return {
315
432
  get ready() {
316
433
  return isReady;
@@ -330,41 +447,7 @@ export async function startZeroCacheEmbedCF(opts) {
330
447
  }
331
448
  return handleHttpRequest(request, url, fastifyInstance);
332
449
  },
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
- },
450
+ stop: shutdown,
368
451
  };
369
452
  }
370
453
  // -- HTTP request handling --