@rebasepro/cli 0.16.0 → 0.16.1-canary.g0d7af95
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/bundle.d.ts +28 -2
- package/dist/commands/build.d.ts +10 -0
- package/dist/commands/cloud/context.d.ts +17 -1
- package/dist/commands/cloud/databases.d.ts +1 -0
- package/dist/commands/cloud/deploy.d.ts +58 -0
- package/dist/commands/cloud/deployments.d.ts +42 -0
- package/dist/commands/cloud/env.d.ts +1 -0
- package/dist/commands/cloud/extensions.d.ts +1 -0
- package/dist/commands/cloud/projects.d.ts +14 -4
- package/dist/commands/cloud/resources.d.ts +10 -1
- package/dist/commands/db.d.ts +16 -0
- package/dist/commands/dev.d.ts +11 -0
- package/dist/commands/doctor.d.ts +1 -1
- package/dist/commands/init.d.ts +1 -1
- package/dist/commands/resources.d.ts +1 -0
- package/dist/constraints-BK1_4vci.js +80 -0
- package/dist/constraints-BK1_4vci.js.map +1 -0
- package/dist/daemon-Bdl4lrdt.js +252 -0
- package/dist/daemon-Bdl4lrdt.js.map +1 -0
- package/dist/daemon-entry-Brq-S8XX.js +378 -0
- package/dist/daemon-entry-Brq-S8XX.js.map +1 -0
- package/dist/dev-db/__fixtures__/cli-entry.d.ts +1 -0
- package/dist/dev-db/constraints.d.ts +98 -0
- package/dist/dev-db/daemon-entry.d.ts +35 -0
- package/dist/dev-db/daemon.d.ts +92 -0
- package/dist/dev-db/notification-proxy.d.ts +102 -0
- package/dist/dev-db/prepare.d.ts +63 -0
- package/dist/dev-db/pull.d.ts +92 -0
- package/dist/dev-db/resolve.d.ts +66 -0
- package/dist/dev-db/state.d.ts +93 -0
- package/dist/function-portability.d.ts +45 -0
- package/dist/index.d.ts +17 -17
- package/dist/index.es.js +5638 -4098
- package/dist/index.es.js.map +1 -1
- package/dist/manifest.d.ts +24 -1
- package/dist/pull-DqPRu1te.js +167 -0
- package/dist/pull-DqPRu1te.js.map +1 -0
- package/dist/resources/derive.d.ts +47 -0
- package/dist/state-c0CJ6Kwb.js +190 -0
- package/dist/state-c0CJ6Kwb.js.map +1 -0
- package/dist/telemetry/consent.d.ts +1 -1
- package/dist/telemetry/index.d.ts +7 -7
- package/dist/utils/dev-preflight.d.ts +73 -0
- package/package.json +13 -8
- package/templates/eject/backend/src/index.ts +15 -8
- package/templates/eject/config/resources.ts +24 -0
- package/templates/template/AGENTS.md +1 -1
- package/templates/template/CLAUDE.md +1 -1
- package/templates/template/README.md +1 -1
- package/templates/template/ai-instructions.md +5 -2
- package/templates/template/backend/functions/hello.ts +43 -22
- package/templates/template/config/resources.ts +57 -0
- package/templates/template/docker-compose.yml +10 -1
- package/templates/template/gitignore +1 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { a as findFreePort, c as releaseStartLock, f as __exportAll, i as devDbDir, l as stateFile, n as clearState, o as pidRunning, r as dataDir, s as readState, t as acquireStartLock } from "./state-c0CJ6Kwb.js";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import net from "net";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import { randomBytes } from "crypto";
|
|
7
|
+
import { spawn } from "child_process";
|
|
8
|
+
//#region src/dev-db/daemon.ts
|
|
9
|
+
/**
|
|
10
|
+
* Starting, finding and stopping the managed database, from the caller's side.
|
|
11
|
+
*
|
|
12
|
+
* Every command that needs Postgres calls {@link ensureManagedDatabase} and
|
|
13
|
+
* gets a connection string back. Whether that started a process or found one
|
|
14
|
+
* already running is not the caller's business, which is the point: `rebase
|
|
15
|
+
* db push`, `rebase dev` and `rebase studio` in three terminals must all reach
|
|
16
|
+
* the same database without coordinating, because two processes opening one
|
|
17
|
+
* PGlite data directory would corrupt it.
|
|
18
|
+
*
|
|
19
|
+
* The hard part is not starting the daemon; it is deciding whether the one the
|
|
20
|
+
* state file describes is still there. A pid can be recycled after a reboot and
|
|
21
|
+
* a port can be taken by a stranger, so believing either on its own would let
|
|
22
|
+
* Rebase send a migration somewhere unintended. {@link isDaemonAlive} asks the
|
|
23
|
+
* daemon to identify itself instead.
|
|
24
|
+
*/
|
|
25
|
+
var daemon_exports = /* @__PURE__ */ __exportAll({
|
|
26
|
+
ensureManagedDatabase: () => ensureManagedDatabase,
|
|
27
|
+
findRunningDaemon: () => findRunningDaemon,
|
|
28
|
+
isDaemonAlive: () => isDaemonAlive,
|
|
29
|
+
managedUrl: () => managedUrl,
|
|
30
|
+
resetManagedDatabase: () => resetManagedDatabase,
|
|
31
|
+
resolveSpawn: () => resolveSpawn,
|
|
32
|
+
stopManagedDatabase: () => stopManagedDatabase
|
|
33
|
+
});
|
|
34
|
+
/** How long to wait for a first boot. PGlite runs initdb on an empty data dir. */
|
|
35
|
+
var START_TIMEOUT_MS = 6e4;
|
|
36
|
+
/** Poll interval while waiting for the daemon to publish its state file. */
|
|
37
|
+
var POLL_INTERVAL_MS = 150;
|
|
38
|
+
/** How long the identity handshake may take before we call it dead. */
|
|
39
|
+
var IDENTITY_TIMEOUT_MS = 1500;
|
|
40
|
+
/**
|
|
41
|
+
* PGlite's built-in superuser and database.
|
|
42
|
+
*
|
|
43
|
+
* Fixed by PGlite rather than chosen here, and asserted by the socket spike:
|
|
44
|
+
* `session_user` comes back as `postgres`.
|
|
45
|
+
*/
|
|
46
|
+
var MANAGED_USER = "postgres";
|
|
47
|
+
var MANAGED_DATABASE = "postgres";
|
|
48
|
+
function managedUrl(port) {
|
|
49
|
+
return `postgresql://${MANAGED_USER}@127.0.0.1:${port}/${MANAGED_DATABASE}`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Ask the process behind a state record to prove it is the one we wrote down.
|
|
53
|
+
*
|
|
54
|
+
* A pid check alone answers "is *a* process running", and a port check alone
|
|
55
|
+
* answers "is *something* listening" — after a reboot both say yes about
|
|
56
|
+
* strangers. The daemon answers with the token from its own state file, so a
|
|
57
|
+
* match is the only evidence accepted.
|
|
58
|
+
*/
|
|
59
|
+
function isDaemonAlive(state) {
|
|
60
|
+
if (!pidRunning(state.pid)) return Promise.resolve(false);
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
const socket = new net.Socket();
|
|
63
|
+
let answer = "";
|
|
64
|
+
const settle = (alive) => {
|
|
65
|
+
socket.removeAllListeners();
|
|
66
|
+
socket.destroy();
|
|
67
|
+
resolve(alive);
|
|
68
|
+
};
|
|
69
|
+
socket.setTimeout(IDENTITY_TIMEOUT_MS);
|
|
70
|
+
socket.once("timeout", () => settle(false));
|
|
71
|
+
socket.once("error", () => settle(false));
|
|
72
|
+
socket.on("data", (chunk) => {
|
|
73
|
+
answer += chunk.toString("utf8");
|
|
74
|
+
if (answer.includes("\n")) settle(answer.trim() === `rebase-dev-db ${state.token}`);
|
|
75
|
+
});
|
|
76
|
+
socket.once("close", () => settle(answer.trim() === `rebase-dev-db ${state.token}`));
|
|
77
|
+
socket.connect(state.identityPort, "127.0.0.1");
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** The running daemon for this project, or `null`. Never starts anything. */
|
|
81
|
+
async function findRunningDaemon(projectRoot) {
|
|
82
|
+
const state = readState(projectRoot);
|
|
83
|
+
if (!state) return null;
|
|
84
|
+
if (await isDaemonAlive(state)) return state;
|
|
85
|
+
clearState(projectRoot);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Resolve the CLI entry point, so the daemon can be spawned as ourselves.
|
|
90
|
+
*
|
|
91
|
+
* A hidden subcommand rather than a second build entry: under `tsx` this file
|
|
92
|
+
* is `src/dev-db/daemon.ts` and in a published CLI it is bundled into
|
|
93
|
+
* `dist/index.js`, and in both cases the executable to re-invoke is the one
|
|
94
|
+
* already running.
|
|
95
|
+
*/
|
|
96
|
+
function resolveCliEntry() {
|
|
97
|
+
const argvEntry = process.argv[1];
|
|
98
|
+
if (argvEntry && fs.existsSync(argvEntry)) return argvEntry;
|
|
99
|
+
return fileURLToPath(new URL("data:video/mp2t;base64,ZXhwb3J0ICogZnJvbSAiLi9jbGkiOwpleHBvcnQgKiBmcm9tICIuL2NvbW1hbmRzL2luaXQiOwpleHBvcnQgKiBmcm9tICIuL2NvbW1hbmRzL3NjaGVtYSI7CmV4cG9ydCAqIGZyb20gIi4vY29tbWFuZHMvZGIiOwpleHBvcnQgKiBmcm9tICIuL2NvbW1hbmRzL2RldiI7CmV4cG9ydCAqIGZyb20gIi4vY29tbWFuZHMvYnVpbGQiOwpleHBvcnQgKiBmcm9tICIuL2NvbW1hbmRzL2VqZWN0IjsKZXhwb3J0ICogZnJvbSAiLi9jb21tYW5kcy9zdGFydCI7CmV4cG9ydCAqIGZyb20gIi4vY29tbWFuZHMvYXV0aCI7CmV4cG9ydCAqIGZyb20gIi4vY29tbWFuZHMvZG9jdG9yIjsKZXhwb3J0ICogZnJvbSAiLi9jb21tYW5kcy9nZW5lcmF0ZV9zZGsiOwpleHBvcnQgKiBmcm9tICIuL2NvbW1hbmRzL2Nsb3VkIjsKZXhwb3J0ICogZnJvbSAiLi9jb21tYW5kcy9hcHBzIjsKZXhwb3J0ICogZnJvbSAiLi91dGlscy9wcm9qZWN0IjsKZXhwb3J0ICogZnJvbSAiLi91dGlscy9wYWNrYWdlLW1hbmFnZXIiOwoKLy8gVGhlIG1hbmlmZXN0IGFuZCBidW5kbGUgY29udHJhY3RzLgovLwovLyBFeHBvcnRlZCBiZWNhdXNlIHZhbGlkYXRpbmcgdGhlbSBpcyBub3Qgb25seSB0aGUgQ0xJJ3Mgam9iOiBhIGNvbnRyb2wgcGxhbmUKLy8gaGFzIHRvIGRlY2lkZSB3aGV0aGVyIGEgc3VibWl0dGVkIGJ1bmRsZSBjYW4gcnVuIGJlZm9yZSBpdCBkZXBsb3lzIGl0LCBhbmQgaXQKLy8gbXVzdCByZWFjaCB0aGF0IHZlcmRpY3Qgd2l0aCB0aGUgc2FtZSBjb2RlIHRoYXQgcHJvZHVjZWQgdGhlIGFydGlmYWN0LgpleHBvcnQgKiBmcm9tICIuL21hbmlmZXN0IjsKZXhwb3J0ICogZnJvbSAiLi9idW5kbGUiOwo=", "" + import.meta.url));
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* How to re-invoke ourselves, which differs between a published CLI and this
|
|
103
|
+
* repository.
|
|
104
|
+
*
|
|
105
|
+
* A published `rebase` is `node bin/rebase.js`, and re-running that is trivial.
|
|
106
|
+
* Inside the monorepo the entry is TypeScript, which plain `node` cannot load —
|
|
107
|
+
* so the daemon has to be started through the same loader that is running now.
|
|
108
|
+
* Getting this wrong fails as a spawn that exits instantly with a syntax error,
|
|
109
|
+
* which is why the caller reads `pglite.log` on failure.
|
|
110
|
+
*/
|
|
111
|
+
function resolveSpawn(entry) {
|
|
112
|
+
if (!/\.[cm]?ts$/.test(entry)) return {
|
|
113
|
+
execPath: process.execPath,
|
|
114
|
+
prefixArgs: []
|
|
115
|
+
};
|
|
116
|
+
return {
|
|
117
|
+
execPath: process.execPath,
|
|
118
|
+
prefixArgs: ["--import", "tsx"]
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* The project's managed database, started if it is not already running.
|
|
123
|
+
*
|
|
124
|
+
* Safe to call concurrently from several commands: the loser of the race finds
|
|
125
|
+
* the winner's state file during its poll and adopts it rather than starting a
|
|
126
|
+
* second daemon.
|
|
127
|
+
*/
|
|
128
|
+
async function ensureManagedDatabase(projectRoot, options = {}) {
|
|
129
|
+
const existing = await findRunningDaemon(projectRoot);
|
|
130
|
+
if (existing) return adopt(existing, false);
|
|
131
|
+
fs.mkdirSync(devDbDir(projectRoot), { recursive: true });
|
|
132
|
+
ensureGitignore(projectRoot);
|
|
133
|
+
if (!acquireStartLock(projectRoot, START_TIMEOUT_MS)) {
|
|
134
|
+
const adopted = await waitForDaemon(projectRoot, START_TIMEOUT_MS);
|
|
135
|
+
if (adopted) return adopt(adopted, false);
|
|
136
|
+
if (!acquireStartLock(projectRoot, 0)) throw new Error(`Another process is starting the development database and did not finish.
|
|
137
|
+
Remove ${path.join(devDbDir(projectRoot), "starting.lock")} if nothing is running.`);
|
|
138
|
+
}
|
|
139
|
+
const port = await findFreePort();
|
|
140
|
+
const token = randomBytes(16).toString("hex");
|
|
141
|
+
const entry = options.entry ?? resolveCliEntry();
|
|
142
|
+
const spawnPlan = options.spawn ?? resolveSpawn(entry);
|
|
143
|
+
const args = [
|
|
144
|
+
...spawnPlan.prefixArgs,
|
|
145
|
+
entry,
|
|
146
|
+
"__dev-db-daemon",
|
|
147
|
+
"--project",
|
|
148
|
+
projectRoot,
|
|
149
|
+
"--port",
|
|
150
|
+
String(port),
|
|
151
|
+
"--token",
|
|
152
|
+
token
|
|
153
|
+
];
|
|
154
|
+
if (options.idleTimeoutMs !== void 0) args.push("--idle-timeout", String(options.idleTimeoutMs));
|
|
155
|
+
const log = fs.openSync(path.join(devDbDir(projectRoot), "pglite.log"), "a");
|
|
156
|
+
const child = spawn(spawnPlan.execPath, args, {
|
|
157
|
+
detached: true,
|
|
158
|
+
stdio: [
|
|
159
|
+
"ignore",
|
|
160
|
+
log,
|
|
161
|
+
log
|
|
162
|
+
]
|
|
163
|
+
});
|
|
164
|
+
child.unref();
|
|
165
|
+
try {
|
|
166
|
+
const deadline = Date.now() + START_TIMEOUT_MS;
|
|
167
|
+
let announced = false;
|
|
168
|
+
while (Date.now() < deadline) {
|
|
169
|
+
const state = readState(projectRoot);
|
|
170
|
+
if (state && await isDaemonAlive(state)) return adopt(state, true);
|
|
171
|
+
if (!announced && !options.quiet) {
|
|
172
|
+
announced = true;
|
|
173
|
+
options.onProgress?.("Starting the development database…");
|
|
174
|
+
}
|
|
175
|
+
if (child.exitCode !== null && child.exitCode !== 0) throw new Error(`The development database failed to start (exit ${child.exitCode}).\n See ${path.join(devDbDir(projectRoot), "pglite.log")} for the reason.`);
|
|
176
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`The development database did not start within ${Math.round(START_TIMEOUT_MS / 1e3)}s.\n See ${path.join(devDbDir(projectRoot), "pglite.log")} for the reason.\n To use your own Postgres instead, set DATABASE_URL or pass --database-url.`);
|
|
179
|
+
} finally {
|
|
180
|
+
releaseStartLock(projectRoot);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** Shape a live state record as the caller's result. */
|
|
184
|
+
function adopt(state, started) {
|
|
185
|
+
return {
|
|
186
|
+
url: managedUrl(state.port),
|
|
187
|
+
dataDir: state.dataDir,
|
|
188
|
+
port: state.port,
|
|
189
|
+
pid: state.pid,
|
|
190
|
+
started
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/** Poll until another process publishes a live daemon, or give up. */
|
|
194
|
+
async function waitForDaemon(projectRoot, timeoutMs) {
|
|
195
|
+
const deadline = Date.now() + timeoutMs;
|
|
196
|
+
while (Date.now() < deadline) {
|
|
197
|
+
const state = readState(projectRoot);
|
|
198
|
+
if (state && await isDaemonAlive(state)) return state;
|
|
199
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
/** Stop the daemon. Returns false when there was nothing running. */
|
|
204
|
+
async function stopManagedDatabase(projectRoot) {
|
|
205
|
+
const state = await findRunningDaemon(projectRoot);
|
|
206
|
+
if (!state) return false;
|
|
207
|
+
try {
|
|
208
|
+
process.kill(state.pid, "SIGTERM");
|
|
209
|
+
} catch {
|
|
210
|
+
clearState(projectRoot);
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
const deadline = Date.now() + 1e4;
|
|
214
|
+
while (Date.now() < deadline) {
|
|
215
|
+
if (!fs.existsSync(stateFile(projectRoot))) return true;
|
|
216
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
217
|
+
}
|
|
218
|
+
clearState(projectRoot);
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Stop the daemon and delete the data directory.
|
|
223
|
+
*
|
|
224
|
+
* Destructive and deliberately not clever: it removes the whole directory
|
|
225
|
+
* rather than dropping schemas, because "give me an empty database" is the only
|
|
226
|
+
* thing anyone means by it.
|
|
227
|
+
*/
|
|
228
|
+
async function resetManagedDatabase(projectRoot) {
|
|
229
|
+
await stopManagedDatabase(projectRoot);
|
|
230
|
+
fs.rmSync(dataDir(projectRoot), {
|
|
231
|
+
recursive: true,
|
|
232
|
+
force: true
|
|
233
|
+
});
|
|
234
|
+
clearState(projectRoot);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Keep the generated directory out of git.
|
|
238
|
+
*
|
|
239
|
+
* `.rebase/` holds a Postgres data directory and a log. A project that commits
|
|
240
|
+
* it will push hundreds of megabytes and a database that only makes sense on
|
|
241
|
+
* one machine, so the ignore file is written next to the data rather than
|
|
242
|
+
* relying on the project's root `.gitignore` having been updated.
|
|
243
|
+
*/
|
|
244
|
+
function ensureGitignore(projectRoot) {
|
|
245
|
+
const target = path.join(devDbDir(projectRoot), ".gitignore");
|
|
246
|
+
if (fs.existsSync(target)) return;
|
|
247
|
+
fs.writeFileSync(target, "# Generated by `rebase dev` — a local Postgres data directory and its log.\n# Machine-specific and large; never commit it.\n*\n", "utf8");
|
|
248
|
+
}
|
|
249
|
+
//#endregion
|
|
250
|
+
export { ensureManagedDatabase as n, daemon_exports as t };
|
|
251
|
+
|
|
252
|
+
//# sourceMappingURL=daemon-Bdl4lrdt.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"daemon-Bdl4lrdt.js","names":[],"sources":["../src/dev-db/daemon.ts"],"sourcesContent":["/**\n * Starting, finding and stopping the managed database, from the caller's side.\n *\n * Every command that needs Postgres calls {@link ensureManagedDatabase} and\n * gets a connection string back. Whether that started a process or found one\n * already running is not the caller's business, which is the point: `rebase\n * db push`, `rebase dev` and `rebase studio` in three terminals must all reach\n * the same database without coordinating, because two processes opening one\n * PGlite data directory would corrupt it.\n *\n * The hard part is not starting the daemon; it is deciding whether the one the\n * state file describes is still there. A pid can be recycled after a reboot and\n * a port can be taken by a stranger, so believing either on its own would let\n * Rebase send a migration somewhere unintended. {@link isDaemonAlive} asks the\n * daemon to identify itself instead.\n */\n\nimport { randomBytes } from \"crypto\";\nimport { type ChildProcess, spawn } from \"child_process\";\nimport fs from \"fs\";\nimport net from \"net\";\nimport path from \"path\";\nimport { fileURLToPath } from \"url\";\n\nimport {\n acquireStartLock,\n clearState,\n type DaemonState,\n dataDir,\n devDbDir,\n findFreePort,\n pidRunning,\n readState,\n releaseStartLock,\n stateFile\n} from \"./state\";\n\n/** How long to wait for a first boot. PGlite runs initdb on an empty data dir. */\nconst START_TIMEOUT_MS = 60_000;\n/** Poll interval while waiting for the daemon to publish its state file. */\nconst POLL_INTERVAL_MS = 150;\n/** How long the identity handshake may take before we call it dead. */\nconst IDENTITY_TIMEOUT_MS = 1_500;\n\nexport interface ManagedDatabase {\n /** Connection string for this project's managed database. */\n url: string;\n /** Where the data lives, for diagnostics and `--reset`. */\n dataDir: string;\n port: number;\n pid: number;\n /** True when this call started the daemon rather than finding it. */\n started: boolean;\n}\n\n/**\n * PGlite's built-in superuser and database.\n *\n * Fixed by PGlite rather than chosen here, and asserted by the socket spike:\n * `session_user` comes back as `postgres`.\n */\nconst MANAGED_USER = \"postgres\";\nconst MANAGED_DATABASE = \"postgres\";\n\nexport function managedUrl(port: number): string {\n return `postgresql://${MANAGED_USER}@127.0.0.1:${port}/${MANAGED_DATABASE}`;\n}\n\n/**\n * Ask the process behind a state record to prove it is the one we wrote down.\n *\n * A pid check alone answers \"is *a* process running\", and a port check alone\n * answers \"is *something* listening\" — after a reboot both say yes about\n * strangers. The daemon answers with the token from its own state file, so a\n * match is the only evidence accepted.\n */\nexport function isDaemonAlive(state: DaemonState): Promise<boolean> {\n if (!pidRunning(state.pid)) return Promise.resolve(false);\n\n return new Promise((resolve) => {\n const socket = new net.Socket();\n let answer = \"\";\n const settle = (alive: boolean) => {\n socket.removeAllListeners();\n socket.destroy();\n resolve(alive);\n };\n socket.setTimeout(IDENTITY_TIMEOUT_MS);\n socket.once(\"timeout\", () => settle(false));\n socket.once(\"error\", () => settle(false));\n socket.on(\"data\", (chunk) => {\n answer += chunk.toString(\"utf8\");\n if (answer.includes(\"\\n\")) settle(answer.trim() === `rebase-dev-db ${state.token}`);\n });\n socket.once(\"close\", () => settle(answer.trim() === `rebase-dev-db ${state.token}`));\n socket.connect(state.identityPort, \"127.0.0.1\");\n });\n}\n\n/** The running daemon for this project, or `null`. Never starts anything. */\nexport async function findRunningDaemon(projectRoot: string): Promise<DaemonState | null> {\n const state = readState(projectRoot);\n if (!state) return null;\n if (await isDaemonAlive(state)) return state;\n\n // The record describes something that is not there. Clearing it is the\n // whole recovery: the next ensure call starts a fresh daemon, and leaving\n // it would make every subsequent check pay the identity timeout.\n clearState(projectRoot);\n\n return null;\n}\n\n/**\n * Resolve the CLI entry point, so the daemon can be spawned as ourselves.\n *\n * A hidden subcommand rather than a second build entry: under `tsx` this file\n * is `src/dev-db/daemon.ts` and in a published CLI it is bundled into\n * `dist/index.js`, and in both cases the executable to re-invoke is the one\n * already running.\n */\nfunction resolveCliEntry(): string {\n // `process.argv[1]` is `bin/rebase.js` for a real invocation, which is\n // exactly what should be re-run. Under a test runner it is the runner, so\n // fall back to this module's own directory.\n const argvEntry = process.argv[1];\n if (argvEntry && fs.existsSync(argvEntry)) return argvEntry;\n\n return fileURLToPath(new URL(\"../index.js\", import.meta.url));\n}\n\n/**\n * How to re-invoke ourselves, which differs between a published CLI and this\n * repository.\n *\n * A published `rebase` is `node bin/rebase.js`, and re-running that is trivial.\n * Inside the monorepo the entry is TypeScript, which plain `node` cannot load —\n * so the daemon has to be started through the same loader that is running now.\n * Getting this wrong fails as a spawn that exits instantly with a syntax error,\n * which is why the caller reads `pglite.log` on failure.\n */\nexport function resolveSpawn(entry: string): { execPath: string; prefixArgs: string[] } {\n if (!/\\.[cm]?ts$/.test(entry)) return { execPath: process.execPath, prefixArgs: [] };\n\n // Node can load TypeScript when tsx is registered as an import hook. tsx is\n // already a dependency of this package, because `rebase dev` runs the\n // backend through it.\n return { execPath: process.execPath, prefixArgs: [\"--import\", \"tsx\"] };\n}\n\nexport interface EnsureOptions {\n /** Silence the \"starting…\" progress line. */\n quiet?: boolean;\n /** Milliseconds of inactivity before the daemon exits. 0 disables. */\n idleTimeoutMs?: number;\n /** Where progress goes. Injected for tests. */\n onProgress?: (message: string) => void;\n /** Override how the daemon process is launched. For tests. */\n spawn?: { execPath: string; prefixArgs: string[] };\n /**\n * Override the CLI entry to re-invoke. For tests.\n *\n * Necessary because under a test runner `process.argv[1]` is the runner\n * itself, which exists and is therefore accepted by {@link resolveCliEntry}\n * — spawning vitest with `__dev-db-daemon` rather than the CLI.\n */\n entry?: string;\n}\n\n/**\n * The project's managed database, started if it is not already running.\n *\n * Safe to call concurrently from several commands: the loser of the race finds\n * the winner's state file during its poll and adopts it rather than starting a\n * second daemon.\n */\nexport async function ensureManagedDatabase(\n projectRoot: string,\n options: EnsureOptions = {}\n): Promise<ManagedDatabase> {\n const existing = await findRunningDaemon(projectRoot);\n if (existing) return adopt(existing, false);\n\n fs.mkdirSync(devDbDir(projectRoot), { recursive: true });\n ensureGitignore(projectRoot);\n\n // Exactly one process may spawn a daemon. Without this, `rebase dev` and\n // `rebase db push` started in the same second both see no state file, both\n // spawn, and two processes open one PGlite data directory — the corruption\n // the single-daemon design exists to prevent.\n if (!acquireStartLock(projectRoot, START_TIMEOUT_MS)) {\n const adopted = await waitForDaemon(projectRoot, START_TIMEOUT_MS);\n if (adopted) return adopt(adopted, false);\n\n // The holder gave up or died without publishing. Falling through to\n // start one ourselves is better than failing: the lock is stale by now,\n // so the next acquire will break it.\n if (!acquireStartLock(projectRoot, 0)) {\n throw new Error(\n \"Another process is starting the development database and did not finish.\\n\" +\n ` Remove ${path.join(devDbDir(projectRoot), \"starting.lock\")} if nothing is running.`\n );\n }\n }\n\n const port = await findFreePort();\n const token = randomBytes(16).toString(\"hex\");\n const entry = options.entry ?? resolveCliEntry();\n\n const spawnPlan = options.spawn ?? resolveSpawn(entry);\n const args = [\n ...spawnPlan.prefixArgs,\n entry,\n \"__dev-db-daemon\",\n \"--project\", projectRoot,\n \"--port\", String(port),\n \"--token\", token\n ];\n if (options.idleTimeoutMs !== undefined) {\n args.push(\"--idle-timeout\", String(options.idleTimeoutMs));\n }\n\n const log = fs.openSync(path.join(devDbDir(projectRoot), \"pglite.log\"), \"a\");\n const child: ChildProcess = spawn(spawnPlan.execPath, args, {\n // Detached with no stdin and its output on a file: the daemon must\n // survive the command that started it, and must not hold the terminal\n // open when that command exits.\n detached: true,\n stdio: [\"ignore\", log, log]\n });\n child.unref();\n\n try {\n const deadline = Date.now() + START_TIMEOUT_MS;\n let announced = false;\n while (Date.now() < deadline) {\n const state = readState(projectRoot);\n if (state && (await isDaemonAlive(state))) return adopt(state, true);\n\n if (!announced && !options.quiet) {\n announced = true;\n options.onProgress?.(\"Starting the development database…\");\n }\n\n // A daemon that died on startup will never publish a state file, and\n // waiting the full minute for that is a bad way to learn it. The log\n // is the only place the reason exists.\n if (child.exitCode !== null && child.exitCode !== 0) {\n throw new Error(\n `The development database failed to start (exit ${child.exitCode}).\\n` +\n ` See ${path.join(devDbDir(projectRoot), \"pglite.log\")} for the reason.`\n );\n }\n\n await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));\n }\n\n throw new Error(\n `The development database did not start within ${Math.round(START_TIMEOUT_MS / 1000)}s.\\n` +\n ` See ${path.join(devDbDir(projectRoot), \"pglite.log\")} for the reason.\\n` +\n \" To use your own Postgres instead, set DATABASE_URL or pass --database-url.\"\n );\n } finally {\n // Released whether we succeeded, timed out or threw. A lock left behind\n // by a crash is broken by age, but only after a full timeout — which a\n // developer should never have to wait out.\n releaseStartLock(projectRoot);\n }\n}\n\n/** Shape a live state record as the caller's result. */\nfunction adopt(state: DaemonState, started: boolean): ManagedDatabase {\n return {\n url: managedUrl(state.port),\n dataDir: state.dataDir,\n port: state.port,\n pid: state.pid,\n started\n };\n}\n\n/** Poll until another process publishes a live daemon, or give up. */\nasync function waitForDaemon(projectRoot: string, timeoutMs: number): Promise<DaemonState | null> {\n const deadline = Date.now() + timeoutMs;\n while (Date.now() < deadline) {\n const state = readState(projectRoot);\n if (state && (await isDaemonAlive(state))) return state;\n await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));\n }\n\n return null;\n}\n\n/** Stop the daemon. Returns false when there was nothing running. */\nexport async function stopManagedDatabase(projectRoot: string): Promise<boolean> {\n const state = await findRunningDaemon(projectRoot);\n if (!state) return false;\n\n try {\n process.kill(state.pid, \"SIGTERM\");\n } catch {\n clearState(projectRoot);\n\n return false;\n }\n\n // Wait for it to clear its own state file, which is how it says it closed\n // the data directory cleanly. A hard kill here would risk the WAL.\n const deadline = Date.now() + 10_000;\n while (Date.now() < deadline) {\n if (!fs.existsSync(stateFile(projectRoot))) return true;\n await new Promise((resolve) => setTimeout(resolve, 100));\n }\n\n clearState(projectRoot);\n\n return true;\n}\n\n/**\n * Stop the daemon and delete the data directory.\n *\n * Destructive and deliberately not clever: it removes the whole directory\n * rather than dropping schemas, because \"give me an empty database\" is the only\n * thing anyone means by it.\n */\nexport async function resetManagedDatabase(projectRoot: string): Promise<void> {\n await stopManagedDatabase(projectRoot);\n fs.rmSync(dataDir(projectRoot), { recursive: true, force: true });\n clearState(projectRoot);\n}\n\n/**\n * Keep the generated directory out of git.\n *\n * `.rebase/` holds a Postgres data directory and a log. A project that commits\n * it will push hundreds of megabytes and a database that only makes sense on\n * one machine, so the ignore file is written next to the data rather than\n * relying on the project's root `.gitignore` having been updated.\n */\nfunction ensureGitignore(projectRoot: string): void {\n const target = path.join(devDbDir(projectRoot), \".gitignore\");\n if (fs.existsSync(target)) return;\n fs.writeFileSync(\n target,\n \"# Generated by `rebase dev` — a local Postgres data directory and its log.\\n\" +\n \"# Machine-specific and large; never commit it.\\n*\\n\",\n \"utf8\"\n );\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { a as findFreePort, d as writeState, n as clearState, r as dataDir } from "./state-c0CJ6Kwb.js";
|
|
2
|
+
import { n as PGLITE_EXTENSION_NAMES } from "./constraints-BK1_4vci.js";
|
|
3
|
+
import fs from "fs";
|
|
4
|
+
import net from "net";
|
|
5
|
+
//#region src/dev-db/notification-proxy.ts
|
|
6
|
+
/**
|
|
7
|
+
* A transparent Postgres proxy that puts LISTEN/NOTIFY back.
|
|
8
|
+
*
|
|
9
|
+
* Without this, realtime does not work against the managed database — and it
|
|
10
|
+
* fails silently, which is worse than failing. The reason is specific and
|
|
11
|
+
* measurable:
|
|
12
|
+
*
|
|
13
|
+
* PGlite is a *single* backend session, and `PGLiteSocketServer` multiplexes
|
|
14
|
+
* every client connection onto it. `LISTEN` is therefore session-wide: whichever
|
|
15
|
+
* client issues it arms the whole database. But a `NotificationResponse` is an
|
|
16
|
+
* asynchronous message with no request to answer, so the multiplexer hands it to
|
|
17
|
+
* whichever socket happens to be reading the protocol stream at that moment —
|
|
18
|
+
* which is the client that *caused* the notification, not the one that asked for
|
|
19
|
+
* it.
|
|
20
|
+
*
|
|
21
|
+
* Measured against pglite-socket 0.2.9:
|
|
22
|
+
*
|
|
23
|
+
* LISTEN and NOTIFY on one connection → delivered
|
|
24
|
+
* trigger-fired pg_notify, same connection → delivered
|
|
25
|
+
* another connection causes the notify → NOT delivered to the listener
|
|
26
|
+
* …and the same notification IS delivered to the notifier, which never asked
|
|
27
|
+
*
|
|
28
|
+
* The realtime engine listens on a dedicated connection and the writes come
|
|
29
|
+
* from request connections, so it is exactly the broken case, every time.
|
|
30
|
+
*
|
|
31
|
+
* The fix is to stop treating a notification as belonging to one connection,
|
|
32
|
+
* which for a single-session database is the truth anyway: this proxy watches
|
|
33
|
+
* the server→client direction, and every `NotificationResponse` frame it sees is
|
|
34
|
+
* copied to every other connected client. A client that never issued `LISTEN`
|
|
35
|
+
* may receive one it did not ask for; `pg` raises a `notification` event nobody
|
|
36
|
+
* has subscribed to, which costs nothing. A client that *did* ask now always
|
|
37
|
+
* gets it, which is the whole point.
|
|
38
|
+
*
|
|
39
|
+
* Two properties make this safe rather than clever:
|
|
40
|
+
*
|
|
41
|
+
* - **It never parses SQL and never rewrites a byte.** Frames are forwarded
|
|
42
|
+
* verbatim; the only edit is delivering a copy of one to more sockets.
|
|
43
|
+
* - **Injection only happens on a message boundary.** The server→client stream
|
|
44
|
+
* is reassembled into whole protocol messages before anything is written on,
|
|
45
|
+
* so an injected frame can never land inside another message.
|
|
46
|
+
*
|
|
47
|
+
* This exists only for the managed development database. Against a real Postgres
|
|
48
|
+
* there is no proxy, because there is no defect to correct.
|
|
49
|
+
*/
|
|
50
|
+
/** `NotificationResponse`. The one message type this proxy treats specially. */
|
|
51
|
+
var NOTIFICATION_RESPONSE = 65;
|
|
52
|
+
/**
|
|
53
|
+
* The SSL negotiation request, which is the one thing on the wire that is not
|
|
54
|
+
* a typed message.
|
|
55
|
+
*
|
|
56
|
+
* A client may open with an 8-byte `SSLRequest` (length 8, code 80877103), and
|
|
57
|
+
* the server answers with a *single untyped byte* — `N` or `S`. Feeding that
|
|
58
|
+
* byte to a parser expecting `type + Int32 length` would desynchronise the
|
|
59
|
+
* stream for the rest of the connection, so it is recognised and passed through.
|
|
60
|
+
*/
|
|
61
|
+
var SSL_REQUEST_LENGTH = 8;
|
|
62
|
+
var SSL_REQUEST_CODE = 80877103;
|
|
63
|
+
function isSslRequest(chunk) {
|
|
64
|
+
return chunk.length >= SSL_REQUEST_LENGTH && chunk.readInt32BE(0) === SSL_REQUEST_LENGTH && chunk.readInt32BE(4) === SSL_REQUEST_CODE;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Reassembles a server→client byte stream into whole protocol messages.
|
|
68
|
+
*
|
|
69
|
+
* Every backend message is `Int8 type` + `Int32 length` + payload, where the
|
|
70
|
+
* length counts itself but not the type byte. Anything shorter than a full
|
|
71
|
+
* message is held until the rest arrives — TCP offers no guarantee that a
|
|
72
|
+
* message arrives in one chunk, and a proxy that assumed otherwise would inject
|
|
73
|
+
* into the middle of a row description under load.
|
|
74
|
+
*/
|
|
75
|
+
var BackendMessageParser = class {
|
|
76
|
+
buffered = Buffer.alloc(0);
|
|
77
|
+
/** Set once the untyped SSL negotiation byte has been dealt with. */
|
|
78
|
+
awaitingSslReply = false;
|
|
79
|
+
expectSslReply() {
|
|
80
|
+
this.awaitingSslReply = true;
|
|
81
|
+
}
|
|
82
|
+
/** Feed bytes in; get whole messages out, in order. */
|
|
83
|
+
push(chunk) {
|
|
84
|
+
const messages = [];
|
|
85
|
+
this.buffered = this.buffered.length === 0 ? chunk : Buffer.concat([this.buffered, chunk]);
|
|
86
|
+
if (this.awaitingSslReply && this.buffered.length >= 1) {
|
|
87
|
+
messages.push(this.buffered.subarray(0, 1));
|
|
88
|
+
this.buffered = this.buffered.subarray(1);
|
|
89
|
+
this.awaitingSslReply = false;
|
|
90
|
+
}
|
|
91
|
+
while (this.buffered.length >= 5) {
|
|
92
|
+
const length = this.buffered.readInt32BE(1);
|
|
93
|
+
if (length < 4) break;
|
|
94
|
+
const total = length + 1;
|
|
95
|
+
if (this.buffered.length < total) break;
|
|
96
|
+
messages.push(this.buffered.subarray(0, total));
|
|
97
|
+
this.buffered = this.buffered.subarray(total);
|
|
98
|
+
}
|
|
99
|
+
return messages;
|
|
100
|
+
}
|
|
101
|
+
/** Bytes held back because they are not yet a whole message. */
|
|
102
|
+
get pending() {
|
|
103
|
+
return this.buffered.length;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
function isNotificationFrame(message) {
|
|
107
|
+
return message.length > 0 && message[0] === NOTIFICATION_RESPONSE;
|
|
108
|
+
}
|
|
109
|
+
/** Channel and payload of a NotificationResponse, for logging and tests. */
|
|
110
|
+
function decodeNotification(message) {
|
|
111
|
+
if (!isNotificationFrame(message) || message.length < 10) return null;
|
|
112
|
+
const body = message.subarray(9);
|
|
113
|
+
const split = body.indexOf(0);
|
|
114
|
+
if (split === -1) return null;
|
|
115
|
+
const channel = body.subarray(0, split).toString("utf8");
|
|
116
|
+
const rest = body.subarray(split + 1);
|
|
117
|
+
const end = rest.indexOf(0);
|
|
118
|
+
return {
|
|
119
|
+
channel,
|
|
120
|
+
payload: (end === -1 ? rest : rest.subarray(0, end)).toString("utf8")
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The proxy itself.
|
|
125
|
+
*
|
|
126
|
+
* One upstream connection per client connection, so the multiplexer downstream
|
|
127
|
+
* sees exactly what it would have seen without the proxy.
|
|
128
|
+
*/
|
|
129
|
+
var NotificationProxy = class {
|
|
130
|
+
options;
|
|
131
|
+
server = null;
|
|
132
|
+
connections = /* @__PURE__ */ new Set();
|
|
133
|
+
constructor(options) {
|
|
134
|
+
this.options = options;
|
|
135
|
+
}
|
|
136
|
+
get connectionCount() {
|
|
137
|
+
return this.connections.size;
|
|
138
|
+
}
|
|
139
|
+
start() {
|
|
140
|
+
const host = this.options.host ?? "127.0.0.1";
|
|
141
|
+
return new Promise((resolve, reject) => {
|
|
142
|
+
const server = net.createServer((client) => this.accept(client, host));
|
|
143
|
+
server.once("error", reject);
|
|
144
|
+
server.listen(this.options.listenPort, host, () => {
|
|
145
|
+
this.server = server;
|
|
146
|
+
resolve();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
accept(client, host) {
|
|
151
|
+
const upstream = net.connect(this.options.upstreamPort, host);
|
|
152
|
+
const connection = {
|
|
153
|
+
client,
|
|
154
|
+
upstream,
|
|
155
|
+
parser: new BackendMessageParser()
|
|
156
|
+
};
|
|
157
|
+
this.connections.add(connection);
|
|
158
|
+
client.setNoDelay(true);
|
|
159
|
+
upstream.setNoDelay(true);
|
|
160
|
+
client.on("data", (chunk) => {
|
|
161
|
+
if (isSslRequest(chunk)) connection.parser.expectSslReply();
|
|
162
|
+
upstream.write(chunk);
|
|
163
|
+
});
|
|
164
|
+
upstream.on("data", (chunk) => {
|
|
165
|
+
for (const message of connection.parser.push(chunk)) {
|
|
166
|
+
client.write(message);
|
|
167
|
+
if (isNotificationFrame(message)) this.broadcast(message, connection);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
const close = () => {
|
|
171
|
+
this.connections.delete(connection);
|
|
172
|
+
client.destroy();
|
|
173
|
+
upstream.destroy();
|
|
174
|
+
};
|
|
175
|
+
client.on("close", close);
|
|
176
|
+
client.on("error", close);
|
|
177
|
+
upstream.on("close", close);
|
|
178
|
+
upstream.on("error", close);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Copy a notification to every other client.
|
|
182
|
+
*
|
|
183
|
+
* Written directly rather than through a parser: it is already a whole
|
|
184
|
+
* message, and every other socket is only ever written whole messages, so
|
|
185
|
+
* there is no boundary to land inside.
|
|
186
|
+
*/
|
|
187
|
+
broadcast(message, origin) {
|
|
188
|
+
let copies = 0;
|
|
189
|
+
for (const connection of this.connections) {
|
|
190
|
+
if (connection === origin) continue;
|
|
191
|
+
if (connection.client.destroyed || !connection.client.writable) continue;
|
|
192
|
+
connection.client.write(message);
|
|
193
|
+
copies += 1;
|
|
194
|
+
}
|
|
195
|
+
const decoded = decodeNotification(message);
|
|
196
|
+
if (decoded) this.options.onNotification?.(decoded.channel, decoded.payload, copies);
|
|
197
|
+
}
|
|
198
|
+
async stop() {
|
|
199
|
+
for (const connection of [...this.connections]) {
|
|
200
|
+
connection.client.destroy();
|
|
201
|
+
connection.upstream.destroy();
|
|
202
|
+
}
|
|
203
|
+
this.connections.clear();
|
|
204
|
+
const server = this.server;
|
|
205
|
+
this.server = null;
|
|
206
|
+
if (!server) return;
|
|
207
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
//#endregion
|
|
211
|
+
//#region src/dev-db/daemon-entry.ts
|
|
212
|
+
/**
|
|
213
|
+
* The managed database process: one PGlite instance behind a Postgres socket.
|
|
214
|
+
*
|
|
215
|
+
* Runs as `rebase __dev-db-daemon`, a hidden subcommand rather than a separate
|
|
216
|
+
* build entry point, so the same resolution works from `src` under tsx and from
|
|
217
|
+
* the bundled `dist` a published CLI ships — there is no second file for a
|
|
218
|
+
* build config to forget.
|
|
219
|
+
*
|
|
220
|
+
* It is deliberately detached from whoever started it. `rebase db push` in one
|
|
221
|
+
* terminal and `rebase dev` in another must reach the same database, because
|
|
222
|
+
* two processes opening one PGlite data directory would corrupt it, so the
|
|
223
|
+
* daemon belongs to the *project* rather than to a command. What starts it is
|
|
224
|
+
* incidental; what stops it is an explicit `rebase db stop`, an idle timeout,
|
|
225
|
+
* or the machine going away.
|
|
226
|
+
*
|
|
227
|
+
* PGlite is imported dynamically. It is an optional dependency carrying a 25MB
|
|
228
|
+
* WASM build, and the cost of that must fall only on someone who actually uses
|
|
229
|
+
* the managed database — never on `rebase init`, and never on a CLI startup
|
|
230
|
+
* that is about to print help.
|
|
231
|
+
*/
|
|
232
|
+
/** Shut down after this long with nothing connected. */
|
|
233
|
+
var DEFAULT_IDLE_TIMEOUT_MS = 30 * 6e4;
|
|
234
|
+
/** How often to check for idleness. */
|
|
235
|
+
var IDLE_CHECK_INTERVAL_MS = 6e4;
|
|
236
|
+
/**
|
|
237
|
+
* `--project <dir> --port <n> --token <t> [--idle-timeout <ms>]`.
|
|
238
|
+
*
|
|
239
|
+
* Every field is required and unvalidated input is fatal: this process is
|
|
240
|
+
* spawned by the CLI, never typed by a person, so a malformed argument is a bug
|
|
241
|
+
* in the caller and guessing would hide it.
|
|
242
|
+
*/
|
|
243
|
+
function parseDaemonArgs(argv) {
|
|
244
|
+
const take = (flag) => {
|
|
245
|
+
const index = argv.indexOf(flag);
|
|
246
|
+
return index >= 0 && index + 1 < argv.length ? argv[index + 1] : null;
|
|
247
|
+
};
|
|
248
|
+
const projectRoot = take("--project");
|
|
249
|
+
const port = Number(take("--port"));
|
|
250
|
+
const token = take("--token");
|
|
251
|
+
const idleRaw = take("--idle-timeout");
|
|
252
|
+
if (!projectRoot) throw new Error("__dev-db-daemon: --project is required");
|
|
253
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error("__dev-db-daemon: --port must be a valid port");
|
|
254
|
+
if (!token) throw new Error("__dev-db-daemon: --token is required");
|
|
255
|
+
const idleTimeoutMs = idleRaw === null ? DEFAULT_IDLE_TIMEOUT_MS : Number(idleRaw);
|
|
256
|
+
if (!Number.isFinite(idleTimeoutMs) || idleTimeoutMs < 0) throw new Error("__dev-db-daemon: --idle-timeout must be a non-negative number of milliseconds");
|
|
257
|
+
return {
|
|
258
|
+
projectRoot,
|
|
259
|
+
port,
|
|
260
|
+
token,
|
|
261
|
+
idleTimeoutMs
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Load the extension bundles PGlite needs by name.
|
|
266
|
+
*
|
|
267
|
+
* `CREATE EXTENSION pg_trgm` cannot install anything on its own here — PGlite
|
|
268
|
+
* resolves extensions from bundles handed to the constructor, and a missing one
|
|
269
|
+
* fails at migration time with `extension "pg_trgm" is not available`, which
|
|
270
|
+
* reads like a broken database rather than a missing import.
|
|
271
|
+
*/
|
|
272
|
+
async function loadExtensions() {
|
|
273
|
+
const extensions = {};
|
|
274
|
+
for (const name of PGLITE_EXTENSION_NAMES) {
|
|
275
|
+
const bundle = (await import(`@electric-sql/pglite/contrib/${name}`))[name];
|
|
276
|
+
if (!bundle) throw new Error(`@electric-sql/pglite/contrib/${name} did not export "${name}". The installed PGlite version may not ship this extension.`);
|
|
277
|
+
extensions[name] = bundle;
|
|
278
|
+
}
|
|
279
|
+
return extensions;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* A tiny sidecar listener that answers one question: "are you the daemon this
|
|
283
|
+
* state file describes?"
|
|
284
|
+
*
|
|
285
|
+
* Liveness cannot be answered by the pid — after a reboot the number belongs to
|
|
286
|
+
* something else — nor by the port alone, for the same reason. Both would let
|
|
287
|
+
* Rebase send a migration to a stranger. So the daemon publishes a token on a
|
|
288
|
+
* second loopback port and the answer is only yes when the token matches.
|
|
289
|
+
*/
|
|
290
|
+
function startIdentityServer(token, onConnection) {
|
|
291
|
+
return new Promise((resolve, reject) => {
|
|
292
|
+
const server = net.createServer((socket) => {
|
|
293
|
+
onConnection();
|
|
294
|
+
socket.end(`rebase-dev-db ${token}\n`);
|
|
295
|
+
});
|
|
296
|
+
server.once("error", reject);
|
|
297
|
+
server.listen(0, "127.0.0.1", () => resolve(server));
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
async function runDaemon(args) {
|
|
301
|
+
const directory = dataDir(args.projectRoot);
|
|
302
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
303
|
+
const { PGlite } = await import("@electric-sql/pglite");
|
|
304
|
+
const { PGLiteSocketServer } = await import("@electric-sql/pglite-socket");
|
|
305
|
+
const extensions = await loadExtensions();
|
|
306
|
+
const db = await PGlite.create({
|
|
307
|
+
dataDir: directory,
|
|
308
|
+
extensions
|
|
309
|
+
});
|
|
310
|
+
const upstreamPort = await findFreePort();
|
|
311
|
+
const server = new PGLiteSocketServer({
|
|
312
|
+
db,
|
|
313
|
+
port: upstreamPort,
|
|
314
|
+
host: "127.0.0.1",
|
|
315
|
+
maxConnections: 4
|
|
316
|
+
});
|
|
317
|
+
await server.start();
|
|
318
|
+
const proxy = new NotificationProxy({
|
|
319
|
+
listenPort: args.port,
|
|
320
|
+
upstreamPort,
|
|
321
|
+
onNotification: (channel, _payload, copies) => {
|
|
322
|
+
if (copies > 0) process.stdout.write(`dev-db: relayed notification on ${channel} to ${copies} client(s)\n`);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
await proxy.start();
|
|
326
|
+
let idleSince = Date.now();
|
|
327
|
+
const identity = await startIdentityServer(args.token, () => {
|
|
328
|
+
idleSince = null;
|
|
329
|
+
});
|
|
330
|
+
const identityAddress = identity.address();
|
|
331
|
+
const identityPort = identityAddress !== null && typeof identityAddress !== "string" ? identityAddress.port : 0;
|
|
332
|
+
writeState(args.projectRoot, {
|
|
333
|
+
port: args.port,
|
|
334
|
+
pid: process.pid,
|
|
335
|
+
dataDir: directory,
|
|
336
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
337
|
+
token: args.token,
|
|
338
|
+
identityPort
|
|
339
|
+
});
|
|
340
|
+
let shuttingDown = false;
|
|
341
|
+
const shutdown = async (reason) => {
|
|
342
|
+
if (shuttingDown) return;
|
|
343
|
+
shuttingDown = true;
|
|
344
|
+
process.stdout.write(`dev-db: stopping (${reason})\n`);
|
|
345
|
+
clearState(args.projectRoot);
|
|
346
|
+
try {
|
|
347
|
+
await proxy.stop();
|
|
348
|
+
} catch {}
|
|
349
|
+
try {
|
|
350
|
+
await server.stop();
|
|
351
|
+
} catch {}
|
|
352
|
+
identity.close();
|
|
353
|
+
try {
|
|
354
|
+
await db.close();
|
|
355
|
+
} catch {}
|
|
356
|
+
process.exit(0);
|
|
357
|
+
};
|
|
358
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
359
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
360
|
+
process.on("disconnect", () => {});
|
|
361
|
+
if (args.idleTimeoutMs > 0) setInterval(() => {
|
|
362
|
+
const stats = server.getStats();
|
|
363
|
+
if (stats.activeConnections > 0 || stats.queuedQueries > 0) {
|
|
364
|
+
idleSince = null;
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (idleSince === null) {
|
|
368
|
+
idleSince = Date.now();
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (Date.now() - idleSince >= args.idleTimeoutMs) shutdown(`idle for ${Math.round(args.idleTimeoutMs / 6e4)} minutes`);
|
|
372
|
+
}, IDLE_CHECK_INTERVAL_MS).unref();
|
|
373
|
+
process.stdout.write(`dev-db: ready on 127.0.0.1:${args.port}\n`);
|
|
374
|
+
}
|
|
375
|
+
//#endregion
|
|
376
|
+
export { parseDaemonArgs, runDaemon };
|
|
377
|
+
|
|
378
|
+
//# sourceMappingURL=daemon-entry-Brq-S8XX.js.map
|