@prisma/composer-prisma-cloud 0.30.0-dev.3 → 0.30.0-dev.4

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.
@@ -1,43 +1,10 @@
1
1
  import { a as readOwnVersion, i as readJsonFile, n as StateFile, r as isPidAlive } from "./daemon-D9BV062Q-ZTmWho_N.mjs";
2
- import { n as getPorts, r as portNumbers } from "./get-port-CwLPc-ca.mjs";
3
- import { t as isValidSegment } from "./segments-CA7TOJ1U-CoICbb95.mjs";
2
+ import { n as getPorts, r as portNumbers, t as clearLockedPorts } from "./get-port-CwLPc-ca.mjs";
4
3
  import * as fs from "node:fs";
5
4
  import * as path from "node:path";
6
5
  import { pathToFileURL } from "node:url";
6
+ import { randomBytes } from "node:crypto";
7
7
  import * as http from "node:http";
8
- //#region ../../1-prisma-cloud/0-lowering/dev-emulators/dist/instance-name-BF6J_weL.mjs
9
- /**
10
- * `pcdev-<app>-<database-id>` instance-name derivation (local-dev spec § 2
11
- * `postgres-main.ts`): each half lowercased, every char outside `[a-z0-9]`
12
- * replaced by `-`, runs collapsed, the combined name trimmed to 63 chars.
13
- *
14
- * A separate, side-effect-free module (not defined inline in
15
- * `postgres-main.ts`) so it can be imported directly by tests without also
16
- * running that file's own `main()` — `postgres-main.ts` is a daemon
17
- * entrypoint script, always invoked as a subprocess, and calls `main()`
18
- * unconditionally at module load.
19
- */
20
- /**
21
- * Deliberately linear, no ambiguous quantifiers: a per-character replace
22
- * (no `+`, so no run-length backtracking surface), a bounded-quantifier
23
- * collapse (`{2,}`, not alternation-with-quantifiers), and plain
24
- * index-walking for the leading/trailing trim instead of a regex — a
25
- * combined `+`-plus-alternation trim (`/^-+|-+$/g`) is exactly the shape
26
- * CodeQL's polynomial-ReDoS check flags, whether or not this particular
27
- * instance is provably safe.
28
- */
29
- function slug(segment) {
30
- const collapsed = segment.toLowerCase().replace(/[^a-z0-9]/g, "-").replace(/-{2,}/g, "-");
31
- let start = 0;
32
- let end = collapsed.length;
33
- while (start < end && collapsed[start] === "-") start++;
34
- while (end > start && collapsed[end - 1] === "-") end--;
35
- return collapsed.slice(start, end);
36
- }
37
- function instanceNameFor(app, id) {
38
- return `pcdev-${slug(app)}-${slug(id)}`.slice(0, 63);
39
- }
40
- //#endregion
41
8
  //#region ../../1-prisma-cloud/0-lowering/dev-emulators/dist/postgres-main.mjs
42
9
  function isStringKeyedRecord(value) {
43
10
  return typeof value === "object" && value !== null;
@@ -109,9 +76,13 @@ async function registryClaimedPorts(internalState, ownInstanceName) {
109
76
  /**
110
77
  * The Postgres emulator daemon (local-dev spec § 2 `postgres-main.ts`): a
111
78
  * small local counterpart of hosted Postgres, hosting `@prisma/dev`'s
112
- * `startPrismaDevServer()` — one named, persistent server per `Database`
113
- * resource, several servers in this one daemon process. Loopback
114
- * `node:http` JSON admin API; state under its `--state-dir`.
79
+ * `startPrismaDevServer()` — one named, persistent server per database
80
+ * instance, several servers in this one daemon process. Instances are keyed
81
+ * by ids this daemon generates; a server is named after its instance's id,
82
+ * and `@prisma/dev` keeps its data under that name in its own data
83
+ * directory. Labels are stored and returned verbatim, never interpreted.
84
+ * Stopping an instance shuts its server down and keeps its data and port.
85
+ * Loopback `node:http` JSON admin API; state under its `--state-dir`.
115
86
  *
116
87
  * `@prisma/dev` is imported dynamically from a CALLER-RESOLVED path (each
117
88
  * admin request that needs it carries `prismaDevModulePath`): the local
@@ -124,7 +95,7 @@ async function registryClaimedPorts(internalState, ownInstanceName) {
124
95
  */
125
96
  const MIN_DATABASE_PORT = 51300;
126
97
  const MAX_DATABASE_PORT = 65535;
127
- const APPS_STATE_MODE = 384;
98
+ const STATE_MODE = 384;
128
99
  /** Spec § 2 step 5's pattern, applied to a fresh database-port allocation. */
129
100
  const MAX_FRESH_PORT_CANDIDATES = 5;
130
101
  /**
@@ -137,24 +108,58 @@ const MAX_FRESH_PORT_CANDIDATES = 5;
137
108
  const MAX_ALREADY_RUNNING_RETRIES = 2;
138
109
  const ALREADY_RUNNING_POLLS = 24;
139
110
  const ALREADY_RUNNING_POLL_MS = 500;
140
- const NOT_INSTALLED_MESSAGE = "local dev needs @prisma/dev for its local Postgres emulator — @prisma/composer-prisma-cloud declares it as a dependency, but the given module path did not load; reinstall your dependencies.";
111
+ const NOT_INSTALLED_MESSAGE = "local dev needs @prisma/dev for its local Postgres emulator, and the given module path did not load; reinstall your dependencies.";
141
112
  function maskCredentials(text) {
142
113
  return text.replace(/:\/\/([^:@/\s]+):[^@/\s]+@/g, "://$1:***@");
143
114
  }
144
115
  function firstLine(text) {
145
116
  return (text.split("\n")[0] ?? text).trim();
146
117
  }
118
+ const INSTANCE_ID_RE = /^db_[0-9a-f]{16}$/;
119
+ function newInstanceId() {
120
+ return `db_${randomBytes(8).toString("hex")}`;
121
+ }
122
+ function serverNameFor(id) {
123
+ return `pcdev-${id}`;
124
+ }
125
+ function isStringRecord(value) {
126
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((v) => typeof v === "string");
127
+ }
128
+ function isPort(value) {
129
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= MAX_DATABASE_PORT;
130
+ }
147
131
  function isDatabaseRecord(value) {
148
- return typeof value === "object" && value !== null && "id" in value && typeof value.id === "string" && "instanceName" in value && typeof value.instanceName === "string" && "databasePort" in value && typeof value.databasePort === "number" && "url" in value && typeof value.url === "string";
132
+ return typeof value === "object" && value !== null && "id" in value && typeof value.id === "string" && "labels" in value && isStringRecord(value.labels) && "serverName" in value && typeof value.serverName === "string" && "port" in value && isPort(value.port) && "url" in value && typeof value.url === "string" && "prismaDevModulePath" in value && typeof value.prismaDevModulePath === "string";
133
+ }
134
+ function isInstancesState(value) {
135
+ return typeof value === "object" && value !== null && "instances" in value && typeof value.instances === "object" && value.instances !== null && Object.values(value.instances).every(isDatabaseRecord) && "discardedServers" in value && Array.isArray(value.discardedServers) && value.discardedServers.every((name) => typeof name === "string");
136
+ }
137
+ function isCreateBody(value) {
138
+ return typeof value === "object" && value !== null && "labels" in value && isStringRecord(value.labels) && (!("port" in value) || value.port === void 0 || isPort(value.port)) && "prismaDevModulePath" in value && typeof value.prismaDevModulePath === "string";
149
139
  }
150
- function isAppRecord(value) {
151
- return typeof value === "object" && value !== null && "databases" in value && typeof value.databases === "object" && value.databases !== null && Object.values(value.databases).every(isDatabaseRecord);
140
+ function isStartBody(value) {
141
+ return isIdsBody(value) && "prismaDevModulePath" in value && typeof value.prismaDevModulePath === "string";
152
142
  }
153
- function isAppsState(value) {
154
- return typeof value === "object" && value !== null && Object.values(value).every(isAppRecord);
143
+ function isIdsBody(value) {
144
+ return typeof value === "object" && value !== null && "ids" in value && Array.isArray(value.ids) && value.ids.every((id) => typeof id === "string");
155
145
  }
156
- function isDatabaseBody(value) {
157
- return typeof value === "object" && value !== null && "prismaDevModulePath" in value && typeof value.prismaDevModulePath === "string";
146
+ /** Every `@prisma/dev` server name an earlier version's state file lists; an unreadable file lists none. */
147
+ function serverNamesInEarlierState(raw) {
148
+ let parsed;
149
+ try {
150
+ parsed = JSON.parse(raw);
151
+ } catch {
152
+ return [];
153
+ }
154
+ if (!isStringKeyedRecord(parsed)) return [];
155
+ return Object.values(parsed).flatMap((group) => {
156
+ const databases = isStringKeyedRecord(group) ? group["databases"] : void 0;
157
+ if (!isStringKeyedRecord(databases)) return [];
158
+ return Object.values(databases).flatMap((db) => {
159
+ const name = isStringKeyedRecord(db) ? db["instanceName"] : void 0;
160
+ return typeof name === "string" ? [name] : [];
161
+ });
162
+ });
158
163
  }
159
164
  function isPrismaDevModule(value) {
160
165
  return typeof value === "object" && value !== null && "startPrismaDevServer" in value && typeof value.startPrismaDevServer === "function";
@@ -318,20 +323,21 @@ function readBody(req) {
318
323
  function main() {
319
324
  const { port, stateDir } = parseArgs(process.argv.slice(2));
320
325
  const ownVersion = readOwnVersion();
321
- const appsJsonPath = path.join(stateDir, "apps.json");
322
- const stateFile = new StateFile(appsJsonPath, APPS_STATE_MODE);
323
- let state = {};
326
+ const statePath = path.join(stateDir, "instances.json");
327
+ const stateFile = new StateFile(statePath, STATE_MODE);
328
+ let state = {
329
+ instances: {},
330
+ discardedServers: []
331
+ };
324
332
  const runtimes = /* @__PURE__ */ new Map();
333
+ /** Instances whose server another live process holds: running, but with no handle in this one. */
334
+ const adopted = /* @__PURE__ */ new Set();
325
335
  /**
326
336
  * Server starts run ONE AT A TIME. `@prisma/dev`'s start is not
327
337
  * concurrency-safe within a process: two simultaneous calls pick their
328
338
  * ports without seeing each other and one fails with a port refusal
329
339
  * (verified directly — two concurrent starts with distinct, pinned
330
340
  * database ports fail; the same two started in sequence both succeed).
331
- * The daemon issues concurrent starts whenever an app converges more than
332
- * one database, which is what produced the port refusals, the retries
333
- * whose half-started servers held their own name's lock, and every
334
- * "already running" failure downstream of that.
335
341
  */
336
342
  let startQueue = Promise.resolve();
337
343
  function serializeStart(run) {
@@ -342,26 +348,38 @@ function main() {
342
348
  function schedulePersist() {
343
349
  stateFile.write(state);
344
350
  }
345
- function getOrCreateApp(app) {
346
- let appRec = state[app];
347
- if (!appRec) {
348
- appRec = { databases: {} };
349
- state[app] = appRec;
350
- }
351
- return appRec;
352
- }
353
- function usedDatabasePorts() {
354
- const ports = /* @__PURE__ */ new Set();
355
- for (const appRec of Object.values(state)) for (const db of Object.values(appRec.databases)) ports.add(db.databasePort);
356
- return ports;
351
+ function heldPorts() {
352
+ return new Set(Object.values(state.instances).map((db) => db.port));
357
353
  }
358
- async function smallestUnusedDatabasePort(min, alsoExclude = /* @__PURE__ */ new Set()) {
354
+ async function smallestUnusedPort(exclude) {
359
355
  return getPorts({
360
- port: portNumbers(min, MAX_DATABASE_PORT),
361
- exclude: [...usedDatabasePorts(), ...alsoExclude]
356
+ port: portNumbers(MIN_DATABASE_PORT, MAX_DATABASE_PORT),
357
+ exclude: [...heldPorts(), ...exclude]
358
+ });
359
+ }
360
+ /** The requested port when no instance or record holds it and it is bindable; otherwise the smallest such port in the database range. */
361
+ async function allocatePort(requested, exclude) {
362
+ const held = heldPorts();
363
+ if (requested !== void 0 && !held.has(requested) && !exclude.has(requested)) {
364
+ const given = await getPorts({
365
+ port: requested,
366
+ exclude: [...held, ...exclude]
367
+ });
368
+ if (given === requested) return given;
369
+ }
370
+ return smallestUnusedPort(exclude);
371
+ }
372
+ /** In-flight starts and stops per instance, so a stop, a start or a delete never overlaps another on the same server. */
373
+ const inflight = /* @__PURE__ */ new Map();
374
+ function exclusive(id, run) {
375
+ const next = (inflight.get(id) ?? Promise.resolve()).then(run, run);
376
+ const settled = next.then(() => void 0, () => void 0);
377
+ inflight.set(id, settled);
378
+ settled.then(() => {
379
+ if (inflight.get(id) === settled) inflight.delete(id);
362
380
  });
381
+ return next;
363
382
  }
364
- const inflightEnsures = /* @__PURE__ */ new Map();
365
383
  /**
366
384
  * Names whose `startPrismaDevServer` call failed in THIS process for any
367
385
  * reason other than "already running". `@prisma/dev` acquires the name's
@@ -374,39 +392,26 @@ function main() {
374
392
  * acquired by anyone else since (the leak never releases).
375
393
  */
376
394
  const leakedStartNames = /* @__PURE__ */ new Set();
377
- /** Concurrent PUTs for the same database coalesce onto one start — a second `startPrismaDevServer` for a name whose first start is still booting fails as "already running". */
378
- function ensureDatabase(app, id, prismaDevModulePath) {
379
- const instanceName = instanceNameFor(app, id);
380
- const inflight = inflightEnsures.get(instanceName);
381
- if (inflight) return inflight;
382
- const run = ensureDatabaseSerialized(app, id, prismaDevModulePath, instanceName).finally(() => {
383
- inflightEnsures.delete(instanceName);
384
- });
385
- inflightEnsures.set(instanceName, run);
386
- return run;
387
- }
388
- async function ensureDatabaseSerialized(app, id, prismaDevModulePath, instanceName) {
389
- const appRec = getOrCreateApp(app);
390
- const existingRecord = appRec.databases[id];
391
- const runningServer = runtimes.get(instanceName);
392
- if (existingRecord && runningServer) return { url: existingRecord.url };
395
+ /**
396
+ * Starts the server for `serverName`. A fresh allocation picks its port
397
+ * (preferring `requestedPort`) inside the serialized section and may move
398
+ * to another port on a refusal; a start of an existing instance is pinned
399
+ * to its port, because dependents' environments carry its URL.
400
+ */
401
+ async function startServer(opts) {
402
+ const { serverName, prismaDevModulePath } = opts;
393
403
  const prismaDev = await importPrismaDev(prismaDevModulePath);
394
404
  const internalState = await importPrismaDevInternalState(prismaDevModulePath);
395
- const recorded = await serverStatusOf(internalState, instanceName);
405
+ await discardEarlierServers(internalState);
406
+ const recorded = await serverStatusOf(internalState, serverName);
396
407
  if (recorded.live && recorded.pid !== void 0 && isPidAlive(recorded.pid)) {
397
- if (recorded.url !== void 0) {
398
- appRec.databases[id] = {
399
- id,
400
- instanceName,
401
- databasePort: recorded.databasePort ?? portOfUrl(recorded.url) ?? MIN_DATABASE_PORT,
402
- url: recorded.url
403
- };
404
- schedulePersist();
405
- return { url: recorded.url };
406
- }
407
- } else if (recorded.live) await internalState.killServer(instanceName).catch(() => void 0);
408
- const isFreshAllocation = existingRecord === void 0;
409
- let dbPort = existingRecord?.databasePort ?? 0;
408
+ if (recorded.url !== void 0) return {
409
+ url: recorded.url,
410
+ port: recorded.databasePort ?? portOfUrl(recorded.url) ?? opts.pinnedPort ?? 0
411
+ };
412
+ } else if (recorded.live) await internalState.killServer(serverName).catch(() => void 0);
413
+ const isFreshAllocation = opts.pinnedPort === void 0;
414
+ let dbPort = opts.pinnedPort ?? 0;
410
415
  const conflicted = /* @__PURE__ */ new Set();
411
416
  let alreadyRunningRetries = 0;
412
417
  let blindRetries = 0;
@@ -414,24 +419,24 @@ function main() {
414
419
  const auxPorts = [];
415
420
  try {
416
421
  const server = await serializeStart(async () => {
417
- const registryPorts = await registryClaimedPorts(internalState, instanceName);
418
- if (isFreshAllocation) dbPort = await smallestUnusedDatabasePort(MIN_DATABASE_PORT, /* @__PURE__ */ new Set([...registryPorts, ...conflicted]));
422
+ const registryPorts = await registryClaimedPorts(internalState, serverName);
423
+ if (isFreshAllocation) dbPort = await allocatePort(attempt === 1 ? opts.requestedPort : void 0, /* @__PURE__ */ new Set([...registryPorts, ...conflicted]));
419
424
  const auxExclude = /* @__PURE__ */ new Set([
420
425
  ...registryPorts,
421
426
  ...conflicted,
422
427
  dbPort
423
428
  ]);
424
429
  const pickAux = async () => {
425
- const port = await smallestUnusedDatabasePort(MIN_DATABASE_PORT, auxExclude);
426
- auxExclude.add(port);
427
- auxPorts.push(port);
428
- return port;
430
+ const picked = await smallestUnusedPort(auxExclude);
431
+ auxExclude.add(picked);
432
+ auxPorts.push(picked);
433
+ return picked;
429
434
  };
430
435
  const httpPort = await pickAux();
431
436
  const shadowPort = await pickAux();
432
437
  const streamsPort = await pickAux();
433
438
  return prismaDev.startPrismaDevServer({
434
- name: instanceName,
439
+ name: serverName,
435
440
  databasePort: dbPort,
436
441
  port: httpPort,
437
442
  shadowDatabasePort: shadowPort,
@@ -439,47 +444,36 @@ function main() {
439
444
  persistenceMode: "stateful"
440
445
  });
441
446
  });
442
- const url = server.database.connectionString;
443
- leakedStartNames.delete(instanceName);
444
- runtimes.set(instanceName, server);
445
- appRec.databases[id] = {
446
- id,
447
- instanceName,
448
- databasePort: dbPort,
449
- url
447
+ leakedStartNames.delete(serverName);
448
+ return {
449
+ url: server.database.connectionString,
450
+ port: dbPort,
451
+ server
450
452
  };
451
- schedulePersist();
452
- return { url };
453
453
  } catch (err) {
454
454
  if (isNameAlreadyTaken(err) && alreadyRunningRetries < MAX_ALREADY_RUNNING_RETRIES) {
455
455
  alreadyRunningRetries += 1;
456
- if (leakedStartNames.has(instanceName)) {
457
- if (!(await serverStatusOf(internalState, instanceName)).recordExists) {
458
- await internalState.deleteServer(instanceName).catch(() => void 0);
456
+ if (leakedStartNames.has(serverName)) {
457
+ if (!(await serverStatusOf(internalState, serverName)).recordExists) {
458
+ await internalState.deleteServer(serverName).catch(() => void 0);
459
459
  continue;
460
460
  }
461
461
  }
462
462
  for (let poll = 1; poll <= ALREADY_RUNNING_POLLS; poll += 1) {
463
- const now = await serverStatusOf(internalState, instanceName);
464
- if (now.live && now.pid !== void 0 && isPidAlive(now.pid) && now.url !== void 0) {
465
- appRec.databases[id] = {
466
- id,
467
- instanceName,
468
- databasePort: now.databasePort ?? portOfUrl(now.url) ?? dbPort,
469
- url: now.url
470
- };
471
- schedulePersist();
472
- return { url: now.url };
473
- }
463
+ const now = await serverStatusOf(internalState, serverName);
464
+ if (now.live && now.pid !== void 0 && isPidAlive(now.pid) && now.url !== void 0) return {
465
+ url: now.url,
466
+ port: now.databasePort ?? portOfUrl(now.url) ?? dbPort
467
+ };
474
468
  if (now.live && (now.pid === void 0 || !isPidAlive(now.pid))) {
475
- await internalState.killServer(instanceName).catch(() => void 0);
469
+ await internalState.killServer(serverName).catch(() => void 0);
476
470
  break;
477
471
  }
478
472
  await new Promise((resolve) => setTimeout(resolve, ALREADY_RUNNING_POLL_MS));
479
473
  }
480
474
  continue;
481
475
  }
482
- if (!isNameAlreadyTaken(err)) leakedStartNames.add(instanceName);
476
+ if (!isNameAlreadyTaken(err)) leakedStartNames.add(serverName);
483
477
  const conflictPort = portConflictOf(err, [dbPort, ...auxPorts]);
484
478
  if (!(conflictPort !== void 0 && attempt < MAX_FRESH_PORT_CANDIDATES && (conflictPort !== dbPort || isFreshAllocation))) {
485
479
  if (conflictPort === void 0 && !isNameAlreadyTaken(err) && blindRetries < 2) {
@@ -487,39 +481,131 @@ function main() {
487
481
  continue;
488
482
  }
489
483
  const reason = err instanceof Error ? err.message : String(err);
490
- throw new Error(`postgres emulator failed to start database "${instanceName}" on port ${String(dbPort)}: ${maskCredentials(firstLine(reason))}`);
484
+ throw new Error(`postgres emulator failed to start database "${serverName}" on port ${String(dbPort)}: ${maskCredentials(firstLine(reason))}`);
491
485
  }
492
486
  conflicted.add(conflictPort);
493
487
  }
494
488
  }
495
489
  }
496
- async function deleteApp(app, prismaDevModulePathHint) {
497
- const appRec = state[app];
498
- if (!appRec) return;
499
- const entries = Object.values(appRec.databases);
500
- for (const db of entries) {
501
- const server = runtimes.get(db.instanceName);
502
- if (server) {
503
- await server.close().catch(() => void 0);
504
- runtimes.delete(db.instanceName);
505
- }
506
- }
507
- if (entries.length > 0 && prismaDevModulePathHint) {
508
- const internalState = await importPrismaDevInternalState(prismaDevModulePathHint);
509
- for (const db of entries) {
510
- if (!runtimes.has(db.instanceName)) {
511
- if ((await serverStatusOf(internalState, db.instanceName)).pid !== process.pid) await internalState.killServer(db.instanceName).catch(() => void 0);
512
- }
513
- if (!await settledAfterClose(internalState, db.instanceName)) {
514
- console.error(`postgres-main: server "${db.instanceName}" still reports running after close — leaving its persisted data in place instead of risking a self-kill.`);
515
- continue;
516
- }
517
- await internalState.deleteServer(db.instanceName);
490
+ /**
491
+ * Deletes the data of servers an earlier version of this emulator created,
492
+ * once a request brings a `@prisma/dev` module to do it with. Best effort:
493
+ * a server another live process still serves, or one whose delete fails,
494
+ * is tried again on the next request.
495
+ */
496
+ let discarding;
497
+ function discardEarlierServers(internalState) {
498
+ if (state.discardedServers.length === 0) return Promise.resolve();
499
+ discarding ??= deleteEarlierServers(internalState).finally(() => {
500
+ discarding = void 0;
501
+ });
502
+ return discarding;
503
+ }
504
+ async function deleteEarlierServers(internalState) {
505
+ const remaining = [];
506
+ for (const name of state.discardedServers) try {
507
+ const owner = await serverStatusOf(internalState, name);
508
+ if (owner.live && owner.pid !== void 0 && isPidAlive(owner.pid)) {
509
+ remaining.push(name);
510
+ continue;
518
511
  }
512
+ if (owner.live) await internalState.killServer(name).catch(() => void 0);
513
+ await internalState.deleteServer(name);
514
+ } catch (err) {
515
+ console.error(`postgres-main: could not delete the data of "${name}", left by an earlier version: ${err instanceof Error ? err.message : String(err)}`);
516
+ remaining.push(name);
519
517
  }
520
- delete state[app];
518
+ state.discardedServers = remaining;
521
519
  schedulePersist();
522
520
  }
521
+ async function createInstance(body) {
522
+ const id = newInstanceId();
523
+ const serverName = serverNameFor(id);
524
+ let started;
525
+ try {
526
+ started = await startServer({
527
+ serverName,
528
+ prismaDevModulePath: body.prismaDevModulePath,
529
+ ...body.port !== void 0 ? { requestedPort: body.port } : {}
530
+ });
531
+ } catch (err) {
532
+ await discardFailedServer(serverName, body.prismaDevModulePath);
533
+ throw err;
534
+ }
535
+ const record = {
536
+ id,
537
+ labels: { ...body.labels },
538
+ serverName,
539
+ port: started.port,
540
+ url: started.url,
541
+ prismaDevModulePath: body.prismaDevModulePath
542
+ };
543
+ if (started.server) runtimes.set(id, started.server);
544
+ else adopted.add(id);
545
+ state.instances[id] = record;
546
+ await stateFile.write(state);
547
+ return record;
548
+ }
549
+ /**
550
+ * A failed create records no instance, so nothing will use its server name
551
+ * again. A start that failed after `@prisma/dev` took the name's lock leaves
552
+ * the name's directory behind with no record; remove it.
553
+ */
554
+ async function discardFailedServer(serverName, prismaDevModulePath) {
555
+ if (!leakedStartNames.has(serverName)) return;
556
+ try {
557
+ const internalState = await importPrismaDevInternalState(prismaDevModulePath);
558
+ if (!(await serverStatusOf(internalState, serverName)).recordExists) await internalState.deleteServer(serverName);
559
+ leakedStartNames.delete(serverName);
560
+ } catch {}
561
+ }
562
+ function isRunning(id) {
563
+ return runtimes.has(id) || adopted.has(id);
564
+ }
565
+ function startInstance(db, prismaDevModulePath) {
566
+ return exclusive(db.id, async () => {
567
+ db.prismaDevModulePath = prismaDevModulePath;
568
+ if (isRunning(db.id)) return;
569
+ const started = await startServer({
570
+ serverName: db.serverName,
571
+ prismaDevModulePath,
572
+ pinnedPort: db.port
573
+ });
574
+ db.url = started.url;
575
+ if (started.server) runtimes.set(db.id, started.server);
576
+ else adopted.add(db.id);
577
+ schedulePersist();
578
+ });
579
+ }
580
+ /** Shuts the server down and keeps its data. */
581
+ async function closeServer(db) {
582
+ const server = runtimes.get(db.id);
583
+ if (server) {
584
+ await server.close().catch(() => void 0);
585
+ runtimes.delete(db.id);
586
+ return;
587
+ }
588
+ if (!adopted.has(db.id)) return;
589
+ const internalState = await importPrismaDevInternalState(db.prismaDevModulePath);
590
+ if ((await serverStatusOf(internalState, db.serverName)).pid !== process.pid) await internalState.killServer(db.serverName).catch(() => void 0);
591
+ adopted.delete(db.id);
592
+ }
593
+ function stopInstance(db) {
594
+ return exclusive(db.id, () => closeServer(db));
595
+ }
596
+ /** `prismaDevModulePath` is the caller's: the one the database was last started with may since have been removed. */
597
+ function deleteInstance(db, prismaDevModulePath) {
598
+ return exclusive(db.id, async () => {
599
+ if (prismaDevModulePath !== void 0) db.prismaDevModulePath = prismaDevModulePath;
600
+ await closeServer(db);
601
+ const internalState = await importPrismaDevInternalState(db.prismaDevModulePath);
602
+ if (await settledAfterClose(internalState, db.serverName)) await internalState.deleteServer(db.serverName);
603
+ else console.error(`postgres-main: server "${db.serverName}" still reports running after close — leaving its persisted data in place instead of risking a self-kill.`);
604
+ delete state.instances[db.id];
605
+ await stateFile.write(state);
606
+ clearLockedPorts();
607
+ });
608
+ }
523
609
  function json(res, status, body) {
524
610
  res.writeHead(status, { "content-type": "application/json" });
525
611
  res.end(JSON.stringify(body));
@@ -528,55 +614,79 @@ function main() {
528
614
  res.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
529
615
  res.end(body);
530
616
  }
531
- function badSegment(res, segment) {
532
- text(res, 400, `invalid path segment "${segment}": must match /^[a-z0-9][a-z0-9-]*$/ and be at most 63 characters`);
617
+ function noContent(res) {
618
+ res.writeHead(204);
619
+ res.end();
533
620
  }
534
- function databaseView(db) {
621
+ async function readJsonBody(req) {
622
+ const raw = await readBody(req);
623
+ try {
624
+ return JSON.parse(raw.toString("utf8"));
625
+ } catch {
626
+ return;
627
+ }
628
+ }
629
+ function instanceView(db) {
535
630
  return {
536
631
  id: db.id,
632
+ labels: db.labels,
633
+ port: db.port,
537
634
  url: db.url,
538
- instanceName: db.instanceName,
539
- databasePort: db.databasePort
635
+ status: isRunning(db.id) ? "running" : "stopped"
540
636
  };
541
637
  }
542
- /** The most recently observed `prismaDevModulePath` for any database of `app` — DELETE has no body of its own to carry one. */
543
- const recentPrismaDevModulePath = /* @__PURE__ */ new Map();
544
- function lastKnownPrismaDevModulePath(app) {
545
- return recentPrismaDevModulePath.get(app);
638
+ function unknownInstances(res, ids) {
639
+ text(res, 404, `no such instance: ${ids.map((id) => `"${id}"`).join(", ")}`);
546
640
  }
547
641
  async function handleRequest(req, res) {
548
642
  const url = new URL(req.url ?? "/", `http://127.0.0.1:${String(port)}`);
549
643
  const method = req.method ?? "GET";
550
644
  const segments = url.pathname.split("/").filter((s) => s.length > 0).map((s) => decodeURIComponent(s));
551
645
  if (method === "GET" && segments.length === 1 && segments[0] === "health") return json(res, 200, { version: ownVersion });
552
- if (segments[0] === "apps" && segments.length >= 2) {
553
- const app = segments[1];
554
- if (app === void 0 || !isValidSegment(app)) return badSegment(res, app ?? "");
555
- if (method === "PUT" && segments.length === 4 && segments[2] === "databases") {
556
- const id = segments[3];
557
- if (id === void 0 || !isValidSegment(id)) return badSegment(res, id ?? "");
558
- const raw = await readBody(req);
559
- let parsed;
560
- try {
561
- parsed = JSON.parse(raw.toString("utf8"));
562
- } catch {
563
- return text(res, 400, "malformed JSON body");
564
- }
565
- if (!isDatabaseBody(parsed)) return text(res, 400, "malformed database body: expected { \"prismaDevModulePath\": string }");
566
- recentPrismaDevModulePath.set(app, parsed.prismaDevModulePath);
567
- return json(res, 200, await ensureDatabase(app, id, parsed.prismaDevModulePath));
646
+ if (segments[0] !== "instances") {
647
+ res.writeHead(404);
648
+ res.end();
649
+ return;
650
+ }
651
+ if (segments.length === 1) {
652
+ if (method === "GET") return json(res, 200, Object.values(state.instances).map(instanceView));
653
+ if (method === "POST") {
654
+ const body = await readJsonBody(req);
655
+ if (!isCreateBody(body)) return text(res, 400, "malformed body: expected { \"labels\": Record<string, string>, \"port\"?: number, \"prismaDevModulePath\": string }");
656
+ return json(res, 201, instanceView(await createInstance(body)));
568
657
  }
569
- if (method === "GET" && segments.length === 3 && segments[2] === "databases") {
570
- const appRec = state[app];
571
- return json(res, 200, appRec ? Object.values(appRec.databases).map(databaseView) : []);
658
+ }
659
+ if (method === "POST" && segments.length === 2 && segments[1] === "start") {
660
+ const body = await readJsonBody(req);
661
+ if (!isStartBody(body)) return text(res, 400, "malformed body: expected { \"ids\": string[], \"prismaDevModulePath\": string }");
662
+ const unknown = body.ids.filter((id) => state.instances[id] === void 0);
663
+ if (unknown.length > 0) return unknownInstances(res, unknown);
664
+ for (const id of body.ids) {
665
+ const db = state.instances[id];
666
+ if (db) await startInstance(db, body.prismaDevModulePath);
572
667
  }
573
- if (method === "DELETE" && segments.length === 2) {
574
- await deleteApp(app, lastKnownPrismaDevModulePath(app));
575
- res.writeHead(204);
576
- res.end();
577
- return;
668
+ return noContent(res);
669
+ }
670
+ if (method === "POST" && segments.length === 2 && segments[1] === "stop") {
671
+ const body = await readJsonBody(req);
672
+ if (!isIdsBody(body)) return text(res, 400, "malformed body: expected { \"ids\": string[] }");
673
+ for (const id of body.ids) {
674
+ const db = state.instances[id];
675
+ if (db) await stopInstance(db);
578
676
  }
677
+ return noContent(res);
678
+ }
679
+ const id = segments[1];
680
+ if (id !== void 0 && !INSTANCE_ID_RE.test(id)) return text(res, 400, `invalid instance id "${id}"`);
681
+ const db = id === void 0 ? void 0 : state.instances[id];
682
+ if (method === "DELETE" && segments.length === 2) {
683
+ const body = await readJsonBody(req);
684
+ const modulePath = isStringKeyedRecord(body) ? body["prismaDevModulePath"] : void 0;
685
+ if (db) await deleteInstance(db, typeof modulePath === "string" ? modulePath : void 0);
686
+ return noContent(res);
579
687
  }
688
+ if (!db) return unknownInstances(res, id === void 0 ? [] : [id]);
689
+ if (method === "GET" && segments.length === 2) return json(res, 200, instanceView(db));
580
690
  res.writeHead(404);
581
691
  res.end();
582
692
  }
@@ -587,7 +697,7 @@ function main() {
587
697
  });
588
698
  });
589
699
  async function shutdown() {
590
- for (const server of runtimes.values()) await server.close().catch(() => void 0);
700
+ for (const live of runtimes.values()) await live.close().catch(() => void 0);
591
701
  await stateFile.flush();
592
702
  server.close();
593
703
  process.exit(0);
@@ -600,8 +710,9 @@ function main() {
600
710
  process.on("uncaughtException", (err) => {
601
711
  console.error("postgres-main: uncaught exception from background work:", err instanceof Error ? maskCredentials(err.stack ?? err.message) : err);
602
712
  });
603
- readJsonFile(appsJsonPath, isAppsState).then((loaded) => {
604
- if (loaded) state = loaded;
713
+ loadState(stateDir, statePath).then((loaded) => {
714
+ state = loaded;
715
+ schedulePersist();
605
716
  server.listen(port, "127.0.0.1", () => {
606
717
  console.log(`[dev-emulators] postgres-main listening on 127.0.0.1:${String(port)}`);
607
718
  });
@@ -610,6 +721,26 @@ function main() {
610
721
  process.exit(1);
611
722
  });
612
723
  }
724
+ /**
725
+ * This version's state, plus the servers named in any other JSON state file
726
+ * in the state directory, which an earlier version wrote; those files are
727
+ * removed. Local dev state is never migrated; the servers' data is deleted
728
+ * on the first request that can (see `discardEarlierServers`).
729
+ */
730
+ async function loadState(stateDir, statePath) {
731
+ const loaded = await readJsonFile(statePath, isInstancesState) ?? {
732
+ instances: {},
733
+ discardedServers: []
734
+ };
735
+ const files = await fs.promises.readdir(stateDir).catch(() => []);
736
+ for (const file of files.map((name) => path.join(stateDir, name))) {
737
+ if (!file.endsWith(".json") || file === statePath) continue;
738
+ const names = serverNamesInEarlierState(await fs.promises.readFile(file, "utf8").catch(() => ""));
739
+ loaded.discardedServers = [.../* @__PURE__ */ new Set([...loaded.discardedServers, ...names])];
740
+ await fs.promises.rm(file, { force: true });
741
+ }
742
+ return loaded;
743
+ }
613
744
  main();
614
745
  //#endregion
615
746
  export {};