@shivaedev/local-postgres 0.7.0 → 0.8.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +50 -37
  3. package/dist/server/LocalPostgresError.d.ts +8 -0
  4. package/dist/server/LocalPostgresError.d.ts.map +1 -0
  5. package/dist/server/LocalPostgresError.js +6 -0
  6. package/dist/server/LocalPostgresError.js.map +1 -0
  7. package/dist/server/PostgresCommandError.d.ts +11 -0
  8. package/dist/server/PostgresCommandError.d.ts.map +1 -0
  9. package/dist/server/PostgresCommandError.js +9 -0
  10. package/dist/server/PostgresCommandError.js.map +1 -0
  11. package/dist/server/PostgresVersionMismatch.d.ts +8 -5
  12. package/dist/server/PostgresVersionMismatch.d.ts.map +1 -1
  13. package/dist/server/PostgresVersionMismatch.js +7 -7
  14. package/dist/server/PostgresVersionMismatch.js.map +1 -1
  15. package/dist/server/clientConnection.d.ts +11 -0
  16. package/dist/server/clientConnection.d.ts.map +1 -0
  17. package/dist/server/clientConnection.js +14 -0
  18. package/dist/server/clientConnection.js.map +1 -0
  19. package/dist/server/localConnections.d.ts +16 -6
  20. package/dist/server/localConnections.d.ts.map +1 -1
  21. package/dist/server/localConnections.js +25 -24
  22. package/dist/server/localConnections.js.map +1 -1
  23. package/dist/server/localContainer.d.ts +8 -4
  24. package/dist/server/localContainer.d.ts.map +1 -1
  25. package/dist/server/localContainer.js +24 -36
  26. package/dist/server/localContainer.js.map +1 -1
  27. package/dist/server/localDocker.d.ts +7 -2
  28. package/dist/server/localDocker.d.ts.map +1 -1
  29. package/dist/server/localDocker.js +10 -9
  30. package/dist/server/localDocker.js.map +1 -1
  31. package/dist/server/localPostgres.d.ts +8 -5
  32. package/dist/server/localPostgres.d.ts.map +1 -1
  33. package/dist/server/localPostgres.js +51 -82
  34. package/dist/server/localPostgres.js.map +1 -1
  35. package/dist/server/runCommand.d.ts +7 -0
  36. package/dist/server/runCommand.d.ts.map +1 -0
  37. package/dist/server/runCommand.js +23 -0
  38. package/dist/server/runCommand.js.map +1 -0
  39. package/package.json +8 -4
  40. package/src/server/LocalPostgresError.ts +5 -0
  41. package/src/server/PostgresCommandError.ts +8 -0
  42. package/src/server/PostgresVersionMismatch.ts +7 -7
  43. package/src/server/clientConnection.ts +15 -0
  44. package/src/server/localConnections.ts +35 -24
  45. package/src/server/localContainer.ts +32 -36
  46. package/src/server/localDocker.ts +11 -10
  47. package/src/server/localPostgres.ts +60 -85
  48. package/src/server/runCommand.ts +31 -0
@@ -1,31 +1,27 @@
1
- import { type ExecFileSyncOptionsWithStringEncoding, execFileSync } from "node:child_process";
2
- import { env } from "node:process";
3
- import { ensureConnections } from "./localConnections.ts";
4
- import { sqlInContainer, startContainer } from "./localContainer.ts";
5
- import { type DockerEnvironment, docker } from "./localDocker.ts";
6
- import { PostgresVersionMismatch } from "./PostgresVersionMismatch.ts";
1
+ import { Effect, Schedule } from "effect";
2
+ import { clientConnection } from "#clientConnection.ts";
3
+ import { LocalPostgresError } from "#LocalPostgresError.ts";
4
+ import { ensureConnections } from "#localConnections.ts";
5
+ import { sqlInContainer, startContainer } from "#localContainer.ts";
6
+ import { type DockerEnvironment, docker } from "#localDocker.ts";
7
+ import { PostgresVersionMismatch } from "#PostgresVersionMismatch.ts";
8
+ import { type CommandOptions, missingCommand, runCommand } from "#runCommand.ts";
7
9
 
8
10
  export const LOCAL_SERVER = "postgresql://postgres:postgres@127.0.0.1:55432";
9
11
 
10
- export function assertLocalDatabase(value: string, names: readonly string[]) {
11
- const url = new URL(value);
12
- const name = url.pathname.slice(1);
12
+ export const assertLocalDatabase = Effect.fn("LocalPostgres.assertLocalDatabase")(function* (value: string, names: readonly string[]) {
13
+ const message = `Use a local database on 127.0.0.1:55432 named ${names.join(" or ")}.`;
14
+ const url = yield* Effect.try({ catch: () => new LocalPostgresError({ message }), try: () => new URL(value) });
13
15
  if (
14
16
  !(["postgres:", "postgresql:"].includes(url.protocol) && ["localhost", "127.0.0.1"].includes(url.hostname))
15
17
  || url.port !== "55432"
16
18
  || ["host", "hostaddr", "port", "dbname", "service"].some((key) => url.searchParams.has(key))
17
- || !names.includes(name)
19
+ || !names.includes(url.pathname.slice(1))
18
20
  ) {
19
- throw new Error(`Use a local database on 127.0.0.1:55432 named ${names.join(" or ")}.`);
21
+ return yield* new LocalPostgresError({ message });
20
22
  }
21
23
  return url;
22
- }
23
-
24
- function clientEnvironment(url: URL) {
25
- // libpq treats + literally in URI passwords.
26
- const password = new URLSearchParams(url.search.replaceAll("+", "%2B")).getAll("password").at(-1) ?? decodeURIComponent(url.password);
27
- return { ...env, ...(password && { "PGPASSWORD": password }) };
28
- }
24
+ });
29
25
 
30
26
  export interface ReadinessOptions {
31
27
  readonly attempts?: number;
@@ -33,90 +29,69 @@ export interface ReadinessOptions {
33
29
  }
34
30
 
35
31
  export function localPostgres(environment: DockerEnvironment, { attempts = 30, delayMillis = 1000 }: ReadinessOptions = {}) {
36
- function localDocker(args: readonly string[], options: ExecFileSyncOptionsWithStringEncoding = { encoding: "utf8" }) {
32
+ function localDocker(args: readonly string[], options?: CommandOptions) {
37
33
  return docker(environment, args, options);
38
34
  }
39
- function sql(value: string | URL, query: string) {
40
- const url = new URL(value);
41
- assertLocalDatabase(url.toString(), [url.pathname.slice(1)]);
42
- const childEnvironment = clientEnvironment(url);
43
- url.password = "";
44
- url.searchParams.delete("password");
45
- for (const key of ["schema", "connection_limit", "pool_timeout", "pgbouncer"]) {
46
- url.searchParams.delete(key);
47
- }
48
-
49
- const args = ["-X", "--set", "ON_ERROR_STOP=1", "-At", "-c", query];
50
- if (!url.searchParams.has("connect_timeout")) {
51
- url.searchParams.set("connect_timeout", "3");
52
- }
53
- const options = {
54
- encoding: "utf8",
55
- env: childEnvironment,
56
- stdio: ["ignore", "pipe", "pipe"],
57
- } satisfies ExecFileSyncOptionsWithStringEncoding;
58
- try {
59
- return execFileSync("psql", [...args, "--dbname", url.toString()], options).trim();
60
- } catch (error) {
61
- if (error instanceof Error && "code" in error && error.code === "ENOENT") {
62
- return sqlInContainer(localDocker, url, args, options);
63
- }
64
- throw new Error(error instanceof Error && "stderr" in error ? String(error.stderr) : "PostgreSQL query failed.", { cause: error });
65
- }
66
- }
67
-
68
- function startPostgres(connection = LOCAL_SERVER) {
69
- const server = new URL(connection);
35
+ const sql = localSql(localDocker);
36
+ const startPostgres = Effect.fn("LocalPostgres.startPostgres")(function* (connection = LOCAL_SERVER) {
37
+ const server = yield* Effect.try({
38
+ catch: () => new LocalPostgresError({ message: "Invalid local database URL." }),
39
+ try: () => new URL(connection),
40
+ });
70
41
  server.pathname = "/postgres";
71
- assertLocalDatabase(server.toString(), ["postgres"]);
42
+ yield* assertLocalDatabase(server.toString(), ["postgres"]);
72
43
  function waitForVersion() {
73
- for (let attempt = 0; attempt < attempts; attempt += 1) {
74
- try {
75
- return sql(server, "SHOW server_version");
76
- } catch {
77
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMillis);
78
- }
79
- }
80
- return undefined;
44
+ return sql(server, "SHOW server_version").pipe(Effect.retry({ schedule: Schedule.spaced(delayMillis), times: attempts - 1 }));
81
45
  }
82
- function confirmVersion(observed: string | undefined) {
83
- if (observed !== "18.6") {
84
- throw new PostgresVersionMismatch(observed);
85
- }
86
- }
87
- let version: string | undefined;
88
- try {
89
- version = sql(server, "SHOW server_version");
90
- } catch {
91
- startContainer(localDocker);
92
- version = waitForVersion();
46
+ function confirmVersion(observed: string) {
47
+ return observed === "18.6" ? Effect.void : Effect.fail(new PostgresVersionMismatch({ expectedVersion: "18.6", observedVersion: observed }));
93
48
  }
94
- confirmVersion(version);
95
- ensureConnections(
49
+ const version = yield* sql(server, "SHOW server_version").pipe(
50
+ Effect.catchTag("PostgresCommandError", () => Effect.andThen(startContainer(localDocker), waitForVersion)),
51
+ );
52
+ yield* confirmVersion(version);
53
+ yield* ensureConnections(
96
54
  localDocker,
97
55
  (query) => sql(server, query),
98
- () => confirmVersion(waitForVersion()),
56
+ () => Effect.flatMap(waitForVersion(), confirmVersion),
99
57
  );
100
- }
101
-
102
- function prepareDatabases(values: readonly string[]) {
58
+ });
59
+ const prepareDatabases = Effect.fn("LocalPostgres.prepareDatabases")(function* (values: readonly string[]) {
60
+ const urls: URL[] = [];
103
61
  for (const value of values) {
104
- const name = new URL(value).pathname.slice(1);
105
- assertLocalDatabase(value, [name]);
62
+ const parsed = yield* Effect.try({
63
+ catch: () => new LocalPostgresError({ message: "Invalid local database URL." }),
64
+ try: () => new URL(value),
65
+ });
66
+ const name = parsed.pathname.slice(1);
67
+ const url = yield* assertLocalDatabase(value, [name]);
106
68
  if (!/^[a-z][a-z0-9_]{0,62}$/u.test(name)) {
107
- throw new Error("Invalid local database name.");
69
+ return yield* new LocalPostgresError({ message: "Invalid local database name." });
108
70
  }
71
+ urls.push(url);
109
72
  }
110
- startPostgres(values[0]);
111
- for (const value of values) {
112
- const url = new URL(value);
73
+ yield* startPostgres(values[0]);
74
+ for (const url of urls) {
113
75
  const name = url.pathname.slice(1);
114
76
  const server = new URL(url);
115
77
  server.pathname = "/postgres";
116
- if (!sql(server, `SELECT 1 FROM pg_database WHERE datname = '${name}'`)) {
117
- sql(server, `CREATE DATABASE "${name}"`);
78
+ if (!(yield* sql(server, `SELECT 1 FROM pg_database WHERE datname = '${name}'`))) {
79
+ yield* sql(server, `CREATE DATABASE "${name}"`);
118
80
  }
119
81
  }
120
- }
82
+ });
121
83
  return { prepareDatabases, sql, startPostgres };
122
84
  }
85
+
86
+ function localSql(localDocker: (args: readonly string[], options?: CommandOptions) => ReturnType<typeof docker>) {
87
+ return Effect.fn("LocalPostgres.sql")(function* (value: string | URL, query: string) {
88
+ const parsed = yield* Effect.try({ catch: () => new LocalPostgresError({ message: "Invalid local database URL." }), try: () => new URL(value) });
89
+ const { url, options } = clientConnection(yield* assertLocalDatabase(parsed.toString(), [parsed.pathname.slice(1)]));
90
+ const args = ["-X", "--set", "ON_ERROR_STOP=1", "-At", "-c", query];
91
+ return yield* runCommand("psql", [...args, "--dbname", url.toString()], options).pipe(
92
+ Effect.catchTag("PostgresCommandError", (error) =>
93
+ missingCommand(error) ? sqlInContainer(localDocker, url, args, options) : Effect.fail(error),
94
+ ),
95
+ );
96
+ });
97
+ }
@@ -0,0 +1,31 @@
1
+ import { Effect, PlatformError, Stream } from "effect";
2
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
3
+ import { PostgresCommandError } from "#PostgresCommandError.ts";
4
+
5
+ export type CommandOptions = Pick<ChildProcess.CommandOptions, "env" | "extendEnv">;
6
+
7
+ export const runCommand = Effect.fn("LocalPostgres.runCommand")(function* (command: string, args: readonly string[], options: CommandOptions = {}) {
8
+ const label = command === "docker" ? `docker ${args[0] ?? ""}`.trim() : command;
9
+ return yield* Effect.scoped(
10
+ Effect.gen(function* () {
11
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
12
+ const child = yield* spawner.spawn(ChildProcess.make(command, args, { ...options, stdin: "ignore" }));
13
+ const [stdout, stderr, code] = yield* Effect.all(
14
+ [Stream.mkString(Stream.decodeText(child.stdout)), Stream.mkString(Stream.decodeText(child.stderr)), child.exitCode],
15
+ { concurrency: "unbounded" },
16
+ );
17
+ if (code !== 0) {
18
+ return yield* new PostgresCommandError({
19
+ command: label,
20
+ exitCode: Number(code),
21
+ message: stderr.trim() || `${command} exited with code ${code}.`,
22
+ });
23
+ }
24
+ return stdout.trim();
25
+ }),
26
+ ).pipe(Effect.catchTag("PlatformError", (cause) => Effect.fail(new PostgresCommandError({ cause, command: label, message: cause.message }))));
27
+ });
28
+
29
+ export function missingCommand(error: PostgresCommandError) {
30
+ return error.cause instanceof PlatformError.PlatformError && error.cause.reason._tag === "NotFound";
31
+ }