@shivaedev/local-postgres 0.2.1 → 0.4.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 - 2026-10-18
4
+
5
+ ### Breaking
6
+
7
+ - Modules are named in camelCase: `local-container.ts` becomes `localContainer.ts`, and `local-docker.ts` becomes `localDocker.ts`.
8
+
9
+ ## 0.3.0 - 2026-10-17
10
+
11
+ ### Added
12
+
13
+ - `POSTGRES_IMAGE` exports the pinned PostgreSQL image, so CI can pull it before `db:setup` starts the container.
14
+ - `localPostgres(environment, { attempts, delayMillis })` sets how many readiness checks `startPostgres` makes after it starts the container and how long it waits between them. The defaults stay at 30 checks one second apart.
15
+
16
+ ### Fixed
17
+
18
+ - PostgreSQL setup query errors no longer expose connection passwords from userinfo or query parameters through host or Docker client commands, while preserving diagnostics and the original error as `cause`.
19
+
3
20
  ## 0.2.1 - 2026-10-16
4
21
 
5
22
  ### Changed
package/README.md CHANGED
@@ -43,7 +43,7 @@ const postgres = localPostgres(process.env);
43
43
  postgres.prepareDatabases([development, tests]);
44
44
  ```
45
45
 
46
- This is a complete preparation script for two application-owned databases. `assertLocalDatabase` checks the name against the list the caller supplies. It rejects a remote host, the wrong port, a different database name or a `host` connection parameter that redirects the connection. Preparation and SQL execution also reject remote database targets.
46
+ This is a complete preparation script for two application-owned databases. `assertLocalDatabase` checks the name against the list the caller supplies. It rejects a remote host, the wrong port, a different database name or any redirecting connection parameter: `host`, `hostaddr`, `port`, `dbname` or `service`. Preparation and SQL execution also reject remote database targets.
47
47
 
48
48
  Keep the explicit name checks even though the preparation functions check their destinations. The application's allowlist says which database it intends to touch; service preparation does not choose that policy for it.
49
49
 
@@ -76,7 +76,7 @@ Import the setup API from `@shivaedev/local-postgres/localPostgres.ts`:
76
76
  | --- | --- |
77
77
  | `localServer` | The base URL `postgresql://postgres:postgres@127.0.0.1:55432`. Append the application database name. |
78
78
  | `assertLocalDatabase(value: string, names: readonly string[]): URL` | Validate a local database URL against application-owned allowed names. |
79
- | `localPostgres(environment: DockerEnvironment)` | Create the setup functions using the Docker settings to validate. |
79
+ | `localPostgres(environment: DockerEnvironment, readiness?: ReadinessOptions)` | Create the setup functions using the Docker settings to validate. After starting the container, `startPostgres` checks readiness `attempts` times (default 30), waiting `delayMillis` between checks (default 1000). |
80
80
  | `prepareDatabases(values: readonly string[]): void` | Prepare the service and create absent databases. |
81
81
  | `startPostgres(connection?: string): void` | Prepare the service; the default connection is `localServer`. |
82
82
  | `sql(value: string \| URL, query: string): string` | Execute SQL through PostgreSQL's command-line client. |
@@ -87,8 +87,8 @@ The package's module export pattern also exposes these lower-level functions:
87
87
 
88
88
  | Module | Exports |
89
89
  | --- | --- |
90
- | `@shivaedev/local-postgres/local-docker.ts` | `DockerEnvironment`, the type with optional `DOCKER_CONTEXT` and `DOCKER_HOST`; `docker(environment, args, options?)`, the local-endpoint-checked Docker command function. |
91
- | `@shivaedev/local-postgres/local-container.ts` | `startContainer(docker)`, the container-start function; `sqlInContainer(docker, url, args, options)`, the container SQL-client function. Both take a Docker command callback. |
90
+ | `@shivaedev/local-postgres/localDocker.ts` | `DockerEnvironment`, the type with optional `DOCKER_CONTEXT` and `DOCKER_HOST`; `docker(environment, args, options?)`, the local-endpoint-checked Docker command function. |
91
+ | `@shivaedev/local-postgres/localContainer.ts` | `startContainer(docker)`, the container-start function; `sqlInContainer(docker, url, args, options)`, the container SQL-client function. Both take a Docker command callback. |
92
92
 
93
93
  Use `localPostgres.ts` for application preparation. The lower-level functions describe the command paths it uses and take Node child-process options where shown.
94
94
 
@@ -98,7 +98,7 @@ Use `localPostgres.ts` for application preparation. The lower-level functions de
98
98
  pnpm add --save-dev @shivaedev/local-postgres
99
99
  ```
100
100
 
101
- The package requires Node 24 or later and has no runtime dependencies. It needs host `psql` or a local Docker installation. SQL uses host `psql` when available and falls back to the container's client when the host executable is missing.
101
+ The package requires Node 24 or later and has no runtime dependencies. It needs host `psql` or a local Docker installation. SQL uses host `psql` when available and falls back to the container's client when the host executable is missing. Both clients receive the connection password through `PGPASSWORD`, outside the command line. Query failures retain PostgreSQL's diagnostics and the original error as `cause` without exposing the connection password.
102
102
 
103
103
  The shared baseline is PostgreSQL 18.6 on loopback port 55432. Docker preparation uses container and volume name `development-postgres`, loopback publication `127.0.0.1:55432:5432`, and this image:
104
104
 
@@ -116,5 +116,6 @@ Pass the process's actual Docker settings to `localPostgres`, as `process.env` i
116
116
  - Commands block the calling thread. Keep them in development and test setup tooling.
117
117
  - Keep allowed names, schema changes, test isolation and cleanup in the application. Preparation is not a database reset.
118
118
  - A prepared database name has 1–63 lowercase letters, digits or underscores and starts with a letter. An allowlisted connection URL does not bypass this SQL-name constraint.
119
- - The PostgreSQL preservation test checks repeated preparation on a real service. It does not establish container creation, image-mismatch handling or host-client fallback.
119
+ - Scripted command tests cover container start/create, image-mismatch refusal, client selection, Docker context precedence and local endpoint variants. A lifecycle test simulates host-client absence and delayed container discovery, then uses real Docker and PostgreSQL to check readiness. Real service tests cover supplied container-client credentials, password-free failure diagnostics on both clients and data preservation across repeated preparation.
120
+ - Rejection of an exact running PostgreSQL version mismatch still needs a test; image-mismatch command tests do not prove that check.
120
121
  - Concurrent creation of the same absent database is not covered by the package's tests. Prepare databases before parallel tests begin.
@@ -1,6 +1,7 @@
1
1
  import type { ExecFileSyncOptionsWithStringEncoding } from "node:child_process";
2
+ export declare const POSTGRES_IMAGE = "postgres:18.6-alpine@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873";
2
3
  type Docker = (args: readonly string[], options?: ExecFileSyncOptionsWithStringEncoding) => string;
3
4
  export declare function sqlInContainer(docker: Docker, url: URL, args: readonly string[], options: ExecFileSyncOptionsWithStringEncoding): string;
4
5
  export declare function startContainer(docker: Docker): void;
5
6
  export {};
6
- //# sourceMappingURL=local-container.d.ts.map
7
+ //# sourceMappingURL=localContainer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"localContainer.d.ts","sourceRoot":"","sources":["../../src/server/localContainer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qCAAqC,EAAE,MAAM,oBAAoB,CAAC;AAGhF,eAAO,MAAM,cAAc,iGAAiG,CAAC;AAE7H,KAAK,MAAM,GAAG,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,OAAO,CAAC,EAAE,qCAAqC,KAAK,MAAM,CAAC;AAEnG,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,OAAO,EAAE,qCAAqC,UAuB/H;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,QA+B5C"}
@@ -1,38 +1,53 @@
1
- const image = "postgres:18.6-alpine@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873";
2
- const container = "development-postgres";
1
+ import { env } from "node:process";
2
+ export const POSTGRES_IMAGE = "postgres:18.6-alpine@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873";
3
+ const CONTAINER = "development-postgres";
3
4
  export function sqlInContainer(docker, url, args, options) {
4
5
  const id = docker(["ps", "--filter", "publish=55432", "--format", "{{.ID}}"], options).trim();
5
6
  if (!id || id.includes("\n")) {
6
7
  throw new Error("Install psql or start the shared PostgreSQL service with Docker.");
7
8
  }
8
- if (docker(["inspect", "--format", "{{.Config.Image}}", id], options).trim() !== image) {
9
+ if (docker(["inspect", "--format", "{{.Config.Image}}", id], options).trim() !== POSTGRES_IMAGE) {
9
10
  throw new Error("The shared PostgreSQL image differs from the pinned baseline. Upgrade explicitly, preserving data.");
10
11
  }
12
+ // libpq treats + literally in URI passwords.
13
+ const password = new URLSearchParams(url.search.replaceAll("+", "%2B")).getAll("password").at(-1) ?? decodeURIComponent(url.password);
11
14
  const inside = new URL(url);
12
15
  inside.hostname = "127.0.0.1";
13
16
  inside.port = "5432";
14
- return docker(["exec", id, "psql", ...args, "--dbname", inside.toString()], options).trim();
17
+ inside.password = "";
18
+ inside.searchParams.delete("password");
19
+ try {
20
+ return docker(["exec", "--env", "PGPASSWORD", id, "psql", ...args, "--dbname", inside.toString()], {
21
+ ...options,
22
+ env: { ...env, ...options.env, ...(password && { "PGPASSWORD": password }) },
23
+ }).trim();
24
+ }
25
+ catch (error) {
26
+ throw new Error(error instanceof Error && "stderr" in error ? String(error.stderr) : "PostgreSQL query failed.", { cause: error });
27
+ }
15
28
  }
16
29
  export function startContainer(docker) {
17
30
  docker(["info"]);
18
- let existing = false;
31
+ let existing;
19
32
  try {
20
- docker(["container", "inspect", container]);
33
+ docker(["container", "inspect", CONTAINER]);
21
34
  existing = true;
22
35
  }
23
- catch { }
36
+ catch {
37
+ existing = false;
38
+ }
24
39
  if (existing) {
25
- if (docker(["inspect", "--format", "{{.Config.Image}}", container]).trim() !== image) {
40
+ if (docker(["inspect", "--format", "{{.Config.Image}}", CONTAINER]).trim() !== POSTGRES_IMAGE) {
26
41
  throw new Error("The shared PostgreSQL container uses a different image. Preserve its volume and upgrade it explicitly.");
27
42
  }
28
- docker(["start", container]);
43
+ docker(["start", CONTAINER]);
29
44
  }
30
45
  else {
31
46
  docker([
32
47
  "run",
33
48
  "--detach",
34
49
  "--name",
35
- container,
50
+ CONTAINER,
36
51
  "--publish",
37
52
  "127.0.0.1:55432:5432",
38
53
  "--env",
@@ -41,8 +56,8 @@ export function startContainer(docker) {
41
56
  "POSTGRES_PASSWORD=postgres",
42
57
  "--mount",
43
58
  "type=volume,src=development-postgres,dst=/var/lib/postgresql",
44
- image,
59
+ POSTGRES_IMAGE,
45
60
  ]);
46
61
  }
47
62
  }
48
- //# sourceMappingURL=local-container.js.map
63
+ //# sourceMappingURL=localContainer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"localContainer.js","sourceRoot":"","sources":["../../src/server/localContainer.ts"],"names":[],"mappings":"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"}
@@ -4,4 +4,4 @@ export type DockerEnvironment = Readonly<{
4
4
  "DOCKER_HOST"?: string | undefined;
5
5
  }>;
6
6
  export declare function docker(environment: DockerEnvironment, args: readonly string[], options?: ExecFileSyncOptionsWithStringEncoding): string;
7
- //# sourceMappingURL=local-docker.d.ts.map
7
+ //# sourceMappingURL=localDocker.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"localDocker.d.ts","sourceRoot":"","sources":["../../src/server/localDocker.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,qCAAqC,EAAgB,MAAM,oBAAoB,CAAC;AAE9F,MAAM,MAAM,iBAAiB,GAAG,QAAQ,CAAC;IAAE,gBAAgB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAAC,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CAAC,CAAC;AAExH,wBAAgB,MAAM,CACrB,WAAW,EAAE,iBAAiB,EAC9B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,GAAE,qCAA4D,UAmBrE"}
@@ -13,4 +13,4 @@ export function docker(environment, args, options = { encoding: "utf8" }) {
13
13
  }
14
14
  return execFileSync("docker", args, options);
15
15
  }
16
- //# sourceMappingURL=local-docker.js.map
16
+ //# sourceMappingURL=localDocker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"localDocker.js","sourceRoot":"","sources":["../../src/server/localDocker.ts"],"names":[],"mappings":"AAAA,OAAO,EAA8C,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAI9F,MAAM,UAAU,MAAM,CACrB,WAA8B,EAC9B,IAAuB,EACvB,OAAO,GAA0C,EAAE,QAAQ,EAAE,MAAM,EAAE;IAErE,MAAM,OAAO,GAAG,WAAW,CAAC,cAAc,CAAC;IAC3C,MAAM,IAAI,GAAG,WAAW,CAAC,WAAW,CAAC;IACrC,MAAM,QAAQ,GACb,CAAC,CAAC,OAAO,IAAI,IAAI,CAAC;WACf,YAAY,CAAC,QAAQ,EAAE,CAAC,SAAS,EAAE,SAAS,EAAE,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,UAAU,EAAE,4BAA4B,CAAC,EAAE;YAC1H,QAAQ,EAAE,MAAM;SAChB,CAAC,CAAC,IAAI,EAAE,CAAC;IACX,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,CAAC;IAC9B,IACC,CAAC,CACA,CAAC,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;WACrF,CAAC,GAAG,CAAC,QAAQ,KAAK,MAAM,IAAI,CAAC,WAAW,EAAE,WAAW,EAAE,OAAO,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAC1F,EACA,CAAC;QACF,MAAM,IAAI,KAAK,CAAC,qFAAqF,CAAC,CAAC;IACxG,CAAC;IACD,OAAO,YAAY,CAAC,QAAQ,EAAE,IAAI,EAAE,OAAO,CAAC,CAAC;AAC9C,CAAC"}
@@ -1,7 +1,11 @@
1
- import { type DockerEnvironment } from "./local-docker.ts";
1
+ import { type DockerEnvironment } from "./localDocker.ts";
2
2
  export declare const localServer = "postgresql://postgres:postgres@127.0.0.1:55432";
3
3
  export declare function assertLocalDatabase(value: string, names: readonly string[]): import("url").URL;
4
- export declare function localPostgres(environment: DockerEnvironment): {
4
+ export interface ReadinessOptions {
5
+ readonly attempts?: number;
6
+ readonly delayMillis?: number;
7
+ }
8
+ export declare function localPostgres(environment: DockerEnvironment, { attempts, delayMillis }?: ReadinessOptions): {
5
9
  prepareDatabases: (values: readonly string[]) => void;
6
10
  sql: (value: string | URL, query: string) => string;
7
11
  startPostgres: (connection?: string) => void;
@@ -1 +1 @@
1
- {"version":3,"file":"localPostgres.d.ts","sourceRoot":"","sources":["../../src/server/localPostgres.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,iBAAiB,EAAU,MAAM,mBAAmB,CAAC;AAEnE,eAAO,MAAM,WAAW,mDAAmD,CAAC;AAE5E,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,qBAY1E;AAED,wBAAgB,aAAa,CAAC,WAAW,EAAE,iBAAiB;+BAiDzB,SAAS,MAAM,EAAE;iBA7C/B,MAAM,GAAG,GAAG,SAAS,MAAM;;EAiE/C"}
1
+ {"version":3,"file":"localPostgres.d.ts","sourceRoot":"","sources":["../../src/server/localPostgres.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,iBAAiB,EAAU,MAAM,kBAAkB,CAAC;AAElE,eAAO,MAAM,WAAW,mDAAmD,CAAC;AAE5E,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,MAAM,EAAE,qBAY1E;AAQD,MAAM,WAAW,gBAAgB;IAChC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,wBAAgB,aAAa,CAAC,WAAW,EAAE,iBAAiB,EAAE,EAAE,QAAa,EAAE,WAAkB,EAAE,GAAE,gBAAqB;+BAwDvF,SAAS,MAAM,EAAE;iBApD/B,MAAM,GAAG,GAAG,SAAS,MAAM;;EAwE/C"}
@@ -1,6 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { sqlInContainer, startContainer } from "./local-container.js";
3
- import { docker } from "./local-docker.js";
2
+ import { env } from "node:process";
3
+ import { sqlInContainer, startContainer } from "./localContainer.js";
4
+ import { docker } from "./localDocker.js";
4
5
  export const localServer = "postgresql://postgres:postgres@127.0.0.1:55432";
5
6
  export function assertLocalDatabase(value, names) {
6
7
  const url = new URL(value);
@@ -13,13 +14,21 @@ export function assertLocalDatabase(value, names) {
13
14
  }
14
15
  return url;
15
16
  }
16
- export function localPostgres(environment) {
17
+ function clientEnvironment(url) {
18
+ // libpq treats + literally in URI passwords.
19
+ const password = new URLSearchParams(url.search.replaceAll("+", "%2B")).getAll("password").at(-1) ?? decodeURIComponent(url.password);
20
+ return { ...env, ...(password && { "PGPASSWORD": password }) };
21
+ }
22
+ export function localPostgres(environment, { attempts = 30, delayMillis = 1000 } = {}) {
17
23
  function localDocker(args, options = { encoding: "utf8" }) {
18
24
  return docker(environment, args, options);
19
25
  }
20
26
  function sql(value, query) {
21
27
  const url = new URL(value);
22
28
  assertLocalDatabase(url.toString(), [url.pathname.slice(1)]);
29
+ const childEnvironment = clientEnvironment(url);
30
+ url.password = "";
31
+ url.searchParams.delete("password");
23
32
  for (const key of ["schema", "connection_limit", "pool_timeout", "pgbouncer"]) {
24
33
  url.searchParams.delete(key);
25
34
  }
@@ -27,15 +36,19 @@ export function localPostgres(environment) {
27
36
  if (!url.searchParams.has("connect_timeout")) {
28
37
  url.searchParams.set("connect_timeout", "3");
29
38
  }
30
- const options = { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] };
39
+ const options = {
40
+ encoding: "utf8",
41
+ env: childEnvironment,
42
+ stdio: ["ignore", "pipe", "pipe"],
43
+ };
31
44
  try {
32
- return execFileSync("psql", [...args, "--dbname", String(url)], options).trim();
45
+ return execFileSync("psql", [...args, "--dbname", url.toString()], options).trim();
33
46
  }
34
47
  catch (error) {
35
- if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) {
36
- throw new Error(error instanceof Error && "stderr" in error ? String(error.stderr) : "PostgreSQL query failed.");
48
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
49
+ return sqlInContainer(localDocker, url, args, options);
37
50
  }
38
- return sqlInContainer(localDocker, url, args, options);
51
+ throw new Error(error instanceof Error && "stderr" in error ? String(error.stderr) : "PostgreSQL query failed.", { cause: error });
39
52
  }
40
53
  }
41
54
  function startPostgres(connection = localServer) {
@@ -48,13 +61,13 @@ export function localPostgres(environment) {
48
61
  }
49
62
  catch {
50
63
  startContainer(localDocker);
51
- for (let attempt = 0; attempt < 30; attempt += 1) {
64
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
52
65
  try {
53
66
  version = sql(server, "SHOW server_version");
54
67
  break;
55
68
  }
56
69
  catch {
57
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000);
70
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMillis);
58
71
  }
59
72
  }
60
73
  }
@@ -1 +1 @@
1
- {"version":3,"file":"localPostgres.js","sourceRoot":"","sources":["../../src/server/localPostgres.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"localPostgres.js","sourceRoot":"","sources":["../../src/server/localPostgres.ts"],"names":[],"mappings":"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"}
package/docs/roadmap.md CHANGED
@@ -5,15 +5,17 @@ This file owns local service preparation, its command paths and package API ques
5
5
  ## Built
6
6
 
7
7
  - [x] The main setup module exposes an explicit local server URL, application name validation, service preparation, missing-database preparation and SQL execution.
8
- - [x] Tests reject a remote database host, the wrong port, an unapproved database name and a `host` query redirect. All three setup functions reject remote database targets before connecting.
8
+ - [x] Tests reject a remote database host, the wrong port, an unapproved database name and each redirecting query parameter (`host`, `hostaddr`, `port`, `dbname` and `service`). Invalid database identifiers are rejected before preparation. All three setup functions reject remote database targets before connecting.
9
9
  - [x] A real PostgreSQL test retains an inserted row across repeated preparation and checks PostgreSQL 18.6, including queries whose URLs carry `schema` and `connection_limit` settings.
10
- - [x] The remote `DOCKER_HOST` rejection test checks that SSH and non-loopback TCP endpoints fail before a Docker operation.
10
+ - [x] Scripted container tests cover daemon failures, starting an existing pinned container, creating a missing container with persistent storage and loopback publication, refusing image mismatches without replacement, and refusing client selection when discovery finds zero or multiple containers.
11
+ - [x] Scripted Docker tests cover active and explicit context selection, context precedence over `DOCKER_HOST`, remote endpoint refusal, and local Unix socket, Windows pipe and loopback TCP endpoints.
12
+ - [x] The lifecycle test simulates missing host `psql` and delayed container discovery, then checks readiness through the real Docker client and PostgreSQL service. It scripts container startup rather than starting or creating a real container.
13
+ - [x] Real PostgreSQL tests check supplied container-client credentials and failure diagnostics on both host and Docker clients, including password-free original errors retained as causes.
11
14
  - [x] The installed consumer fixture checks the main module's exports and types.
12
15
 
13
16
  ## Next
14
17
 
15
- - [ ] Add focused command-path tests for host-client absence, container client selection, container start/create, image mismatch and exact service-version mismatch. The real preservation test does not prove these branches.
16
- - [ ] Extend destination tests to each redirected connection parameter and invalid database name, and Docker tests to context selection and local endpoint variants.
18
+ - [ ] Add a test for rejection of an exact running PostgreSQL version mismatch. Scripted image-mismatch checks do not prove this service-version check.
17
19
  - [ ] Establish whether parallel setup needs coordinated creation of an absent database; keep application test startup ordered until there is evidence for another contract.
18
20
 
19
21
  ## Open questions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/local-postgres",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Repeatable local PostgreSQL preparation with one shared persistent service",
5
5
  "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/local-postgres#readme",
6
6
  "bugs": {
@@ -24,9 +24,8 @@
24
24
  },
25
25
  "exports": {
26
26
  "./*.ts": {
27
- "types": "./dist/server/*.d.ts",
28
27
  "source": "./src/server/*.ts",
29
- "import": "./dist/server/*.js",
28
+ "types": "./dist/server/*.d.ts",
30
29
  "default": "./dist/server/*.js"
31
30
  },
32
31
  "./package.json": "./package.json"
@@ -42,9 +41,11 @@
42
41
  "README.md"
43
42
  ],
44
43
  "devDependencies": {
45
- "@shivaedev/quality": "0.20.0",
46
- "@types/node": "24.19.1",
44
+ "@shivaedev/quality": "0.25.0",
45
+ "@shivaedev/test-story": "0.7.1",
46
+ "@types/node": "24.19.2",
47
47
  "@typescript/native": "npm:typescript@7.0.2",
48
+ "effect": "4.0.4",
48
49
  "typescript": "npm:@typescript/typescript6@6.0.2",
49
50
  "vitest": "5.0.3"
50
51
  },
@@ -1,7 +1,8 @@
1
1
  import type { ExecFileSyncOptionsWithStringEncoding } from "node:child_process";
2
+ import { env } from "node:process";
2
3
 
3
- const image = "postgres:18.6-alpine@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873";
4
- const container = "development-postgres";
4
+ export const POSTGRES_IMAGE = "postgres:18.6-alpine@sha256:77f585114c32fbca283dc835b0596f4e52b51b4c6662d7810b2f4084f60a1873";
5
+ const CONTAINER = "development-postgres";
5
6
  type Docker = (args: readonly string[], options?: ExecFileSyncOptionsWithStringEncoding) => string;
6
7
 
7
8
  export function sqlInContainer(docker: Docker, url: URL, args: readonly string[], options: ExecFileSyncOptionsWithStringEncoding) {
@@ -9,33 +10,46 @@ export function sqlInContainer(docker: Docker, url: URL, args: readonly string[]
9
10
  if (!id || id.includes("\n")) {
10
11
  throw new Error("Install psql or start the shared PostgreSQL service with Docker.");
11
12
  }
12
- if (docker(["inspect", "--format", "{{.Config.Image}}", id], options).trim() !== image) {
13
+ if (docker(["inspect", "--format", "{{.Config.Image}}", id], options).trim() !== POSTGRES_IMAGE) {
13
14
  throw new Error("The shared PostgreSQL image differs from the pinned baseline. Upgrade explicitly, preserving data.");
14
15
  }
16
+ // libpq treats + literally in URI passwords.
17
+ const password = new URLSearchParams(url.search.replaceAll("+", "%2B")).getAll("password").at(-1) ?? decodeURIComponent(url.password);
15
18
  const inside = new URL(url);
16
19
  inside.hostname = "127.0.0.1";
17
20
  inside.port = "5432";
18
- return docker(["exec", id, "psql", ...args, "--dbname", inside.toString()], options).trim();
21
+ inside.password = "";
22
+ inside.searchParams.delete("password");
23
+ try {
24
+ return docker(["exec", "--env", "PGPASSWORD", id, "psql", ...args, "--dbname", inside.toString()], {
25
+ ...options,
26
+ env: { ...env, ...options.env, ...(password && { "PGPASSWORD": password }) },
27
+ }).trim();
28
+ } catch (error) {
29
+ throw new Error(error instanceof Error && "stderr" in error ? String(error.stderr) : "PostgreSQL query failed.", { cause: error });
30
+ }
19
31
  }
20
32
 
21
33
  export function startContainer(docker: Docker) {
22
34
  docker(["info"]);
23
- let existing = false;
35
+ let existing: boolean;
24
36
  try {
25
- docker(["container", "inspect", container]);
37
+ docker(["container", "inspect", CONTAINER]);
26
38
  existing = true;
27
- } catch {}
39
+ } catch {
40
+ existing = false;
41
+ }
28
42
  if (existing) {
29
- if (docker(["inspect", "--format", "{{.Config.Image}}", container]).trim() !== image) {
43
+ if (docker(["inspect", "--format", "{{.Config.Image}}", CONTAINER]).trim() !== POSTGRES_IMAGE) {
30
44
  throw new Error("The shared PostgreSQL container uses a different image. Preserve its volume and upgrade it explicitly.");
31
45
  }
32
- docker(["start", container]);
46
+ docker(["start", CONTAINER]);
33
47
  } else {
34
48
  docker([
35
49
  "run",
36
50
  "--detach",
37
51
  "--name",
38
- container,
52
+ CONTAINER,
39
53
  "--publish",
40
54
  "127.0.0.1:55432:5432",
41
55
  "--env",
@@ -44,7 +58,7 @@ export function startContainer(docker: Docker) {
44
58
  "POSTGRES_PASSWORD=postgres",
45
59
  "--mount",
46
60
  "type=volume,src=development-postgres,dst=/var/lib/postgresql",
47
- image,
61
+ POSTGRES_IMAGE,
48
62
  ]);
49
63
  }
50
64
  }
@@ -1,6 +1,7 @@
1
1
  import { type ExecFileSyncOptionsWithStringEncoding, execFileSync } from "node:child_process";
2
- import { sqlInContainer, startContainer } from "./local-container.ts";
3
- import { type DockerEnvironment, docker } from "./local-docker.ts";
2
+ import { env } from "node:process";
3
+ import { sqlInContainer, startContainer } from "./localContainer.ts";
4
+ import { type DockerEnvironment, docker } from "./localDocker.ts";
4
5
 
5
6
  export const localServer = "postgresql://postgres:postgres@127.0.0.1:55432";
6
7
 
@@ -18,13 +19,27 @@ export function assertLocalDatabase(value: string, names: readonly string[]) {
18
19
  return url;
19
20
  }
20
21
 
21
- export function localPostgres(environment: DockerEnvironment) {
22
+ function clientEnvironment(url: URL) {
23
+ // libpq treats + literally in URI passwords.
24
+ const password = new URLSearchParams(url.search.replaceAll("+", "%2B")).getAll("password").at(-1) ?? decodeURIComponent(url.password);
25
+ return { ...env, ...(password && { "PGPASSWORD": password }) };
26
+ }
27
+
28
+ export interface ReadinessOptions {
29
+ readonly attempts?: number;
30
+ readonly delayMillis?: number;
31
+ }
32
+
33
+ export function localPostgres(environment: DockerEnvironment, { attempts = 30, delayMillis = 1000 }: ReadinessOptions = {}) {
22
34
  function localDocker(args: readonly string[], options: ExecFileSyncOptionsWithStringEncoding = { encoding: "utf8" }) {
23
35
  return docker(environment, args, options);
24
36
  }
25
37
  function sql(value: string | URL, query: string) {
26
38
  const url = new URL(value);
27
39
  assertLocalDatabase(url.toString(), [url.pathname.slice(1)]);
40
+ const childEnvironment = clientEnvironment(url);
41
+ url.password = "";
42
+ url.searchParams.delete("password");
28
43
  for (const key of ["schema", "connection_limit", "pool_timeout", "pgbouncer"]) {
29
44
  url.searchParams.delete(key);
30
45
  }
@@ -33,14 +48,18 @@ export function localPostgres(environment: DockerEnvironment) {
33
48
  if (!url.searchParams.has("connect_timeout")) {
34
49
  url.searchParams.set("connect_timeout", "3");
35
50
  }
36
- const options = { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] } satisfies ExecFileSyncOptionsWithStringEncoding;
51
+ const options = {
52
+ encoding: "utf8",
53
+ env: childEnvironment,
54
+ stdio: ["ignore", "pipe", "pipe"],
55
+ } satisfies ExecFileSyncOptionsWithStringEncoding;
37
56
  try {
38
- return execFileSync("psql", [...args, "--dbname", String(url)], options).trim();
57
+ return execFileSync("psql", [...args, "--dbname", url.toString()], options).trim();
39
58
  } catch (error) {
40
- if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) {
41
- throw new Error(error instanceof Error && "stderr" in error ? String(error.stderr) : "PostgreSQL query failed.");
59
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
60
+ return sqlInContainer(localDocker, url, args, options);
42
61
  }
43
- return sqlInContainer(localDocker, url, args, options);
62
+ throw new Error(error instanceof Error && "stderr" in error ? String(error.stderr) : "PostgreSQL query failed.", { cause: error });
44
63
  }
45
64
  }
46
65
 
@@ -53,12 +72,12 @@ export function localPostgres(environment: DockerEnvironment) {
53
72
  version = sql(server, "SHOW server_version");
54
73
  } catch {
55
74
  startContainer(localDocker);
56
- for (let attempt = 0; attempt < 30; attempt += 1) {
75
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
57
76
  try {
58
77
  version = sql(server, "SHOW server_version");
59
78
  break;
60
79
  } catch {
61
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000);
80
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMillis);
62
81
  }
63
82
  }
64
83
  }
@@ -1 +0,0 @@
1
- {"version":3,"file":"local-container.d.ts","sourceRoot":"","sources":["../../src/server/local-container.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qCAAqC,EAAE,MAAM,oBAAoB,CAAC;AAIhF,KAAK,MAAM,GAAG,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,OAAO,CAAC,EAAE,qCAAqC,KAAK,MAAM,CAAC;AAEnG,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,EAAE,IAAI,EAAE,SAAS,MAAM,EAAE,EAAE,OAAO,EAAE,qCAAqC,UAY/H;AAED,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,QA6B5C"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"local-container.js","sourceRoot":"","sources":["../../src/server/local-container.ts"],"names":[],"mappings":"AAEA,MAAM,KAAK,GAAG,8FAA8F,CAAC;AAC7G,MAAM,SAAS,GAAG,sBAAsB,CAAC;AAGzC,MAAM,UAAU,cAAc,CAAC,MAAc,EAAE,GAAQ,EAAE,IAAuB,EAAE,OAA8C;IAC/H,MAAM,EAAE,GAAG,MAAM,CAAC,CAAC,IAAI,EAAE,UAAU,EAAE,eAAe,EAAE,UAAU,EAAE,SAAS,CAAC,EAAE,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC;IAC9F,IAAI,CAAC,EAAE,IAAI,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;QAC9B,MAAM,IAAI,KAAK,CAAC,kEAAkE,CAAC,CAAC;IACrF,CAAC;IACD,IAAI,MAAM,CAAC,CAAC,SAAS,EAAE,UAAU,EAAE,mBAAmB,EAAE,EAAE,CAAC,EAAE,OAAO,CAAC,CAAC,IAAI,EAAE,KAAK,KAAK,EAAE,CAAC;QACxF,MAAM,IAAI,KAAK,CAAC,oGAAoG,CAAC,CAAC;IACvH,CAAC;IACD,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;IAC5B,MAAM,CAAC,QAAQ,GAAG,WAAW,CAAC;IAC9B,MAAM,CAAC,IAAI,GAAG,MAAM,CAAC;IACrB,OAAO,MAAM,CAAC,CAAC,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,UAAU,EAAE,MAAM,CAAC,QAAQ,EAAE,CAAC,EAAE,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC;AAC7F,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,MAAc;IAC5C,MAAM,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC;IACjB,IAAI,QAAQ,GAAG,KAAK,CAAC;IACrB,IAAI,CAAC;QACJ,MAAM,CAAC,CAAC,WAAW,EAAE,SAAS,EAAE,SAAS,CAAC,CAAC,CAAC;QAC5C,QAAQ,GAAG,IAAI,CAAC;IACjB,CAAC;IAAC,MAAM,CAAC,CAAA,CAAC;IACV,IAAI,QAAQ,EAAE,CAAC;QACd,IAAI,MAAM,CAAC,CAAC,SAAS,EAAE,UAAU,EAAE,mBAAmB,EAAE,SAAS,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,KAAK,EAAE,CAAC;YACtF,MAAM,IAAI,KAAK,CAAC,wGAAwG,CAAC,CAAC;QAC3H,CAAC;QACD,MAAM,CAAC,CAAC,OAAO,EAAE,SAAS,CAAC,CAAC,CAAC;IAC9B,CAAC;SAAM,CAAC;QACP,MAAM,CAAC;YACN,KAAK;YACL,UAAU;YACV,QAAQ;YACR,SAAS;YACT,WAAW;YACX,sBAAsB;YACtB,OAAO;YACP,wBAAwB;YACxB,OAAO;YACP,4BAA4B;YAC5B,SAAS;YACT,8DAA8D;YAC9D,KAAK;SACL,CAAC,CAAC;IACJ,CAAC;AACF,CAAC"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"local-docker.d.ts","sourceRoot":"","sources":["../../src/server/local-docker.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,qCAAqC,EAAgB,MAAM,oBAAoB,CAAC;AAE9F,MAAM,MAAM,iBAAiB,GAAG,QAAQ,CAAC;IAAE,gBAAgB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAAC,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CAAC,CAAC;AAExH,wBAAgB,MAAM,CACrB,WAAW,EAAE,iBAAiB,EAC9B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,GAAE,qCAA4D,UAmBrE"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"local-docker.js","sourceRoot":"","sources":["../../src/server/local-docker.ts"],"names":[],"mappings":"AAAA,OAAO,EAA8C,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAI9F,MAAM,UAAU,MAAM,CACrB,WAA8B,EAC9B,IAAuB,EACvB,OAAO,GAA0C,EAAE,QAAQ,EAAE,MAAM,EAAE;IAErE,MAAM,OAAO,GAAG,WAAW,CAAC,cAAc,CAAC;IAC3C,MAAM,IAAI,GAAG,WAAW,CAAC,WAAW,CAAC;IACrC,MAAM,QAAQ,GACb,CAAC,CAAC,OAAO,IAAI,IAAI,CAAC;WACf,YAAY,CAAC,QAAQ,EAAE,CAAC,SAAS,EAAE,SAAS,EAAE,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,UAAU,EAAE,4BAA4B,CAAC,EAAE;YAC1H,QAAQ,EAAE,MAAM;SAChB,CAAC,CAAC,IAAI,EAAE,CAAC;IACX,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,CAAC;IAC9B,IACC,CAAC,CACA,CAAC,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;WACrF,CAAC,GAAG,CAAC,QAAQ,KAAK,MAAM,IAAI,CAAC,WAAW,EAAE,WAAW,EAAE,OAAO,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,CAC1F,EACA,CAAC;QACF,MAAM,IAAI,KAAK,CAAC,qFAAqF,CAAC,CAAC;IACxG,CAAC;IACD,OAAO,YAAY,CAAC,QAAQ,EAAE,IAAI,EAAE,OAAO,CAAC,CAAC;AAC9C,CAAC"}
File without changes