@shivaedev/local-postgres 0.11.0 → 0.12.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 (33) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +43 -3
  3. package/dist/server/AppPostgresVersionMismatch.d.ts +11 -0
  4. package/dist/server/AppPostgresVersionMismatch.d.ts.map +1 -0
  5. package/dist/server/AppPostgresVersionMismatch.js +11 -0
  6. package/dist/server/AppPostgresVersionMismatch.js.map +1 -0
  7. package/dist/server/PostgresBinaries.d.ts +11 -0
  8. package/dist/server/PostgresBinaries.d.ts.map +1 -0
  9. package/dist/server/PostgresBinaries.js +4 -0
  10. package/dist/server/PostgresBinaries.js.map +1 -0
  11. package/dist/server/adapters/hostPostgresBinaries.d.ts +4 -0
  12. package/dist/server/adapters/hostPostgresBinaries.d.ts.map +1 -0
  13. package/dist/server/adapters/hostPostgresBinaries.js +20 -0
  14. package/dist/server/adapters/hostPostgresBinaries.js.map +1 -0
  15. package/dist/server/appPostgres.d.ts +22 -0
  16. package/dist/server/appPostgres.d.ts.map +1 -0
  17. package/dist/server/appPostgres.js +50 -0
  18. package/dist/server/appPostgres.js.map +1 -0
  19. package/dist/server/migrateAppPostgres.d.ts +6 -0
  20. package/dist/server/migrateAppPostgres.d.ts.map +1 -0
  21. package/dist/server/migrateAppPostgres.js +31 -0
  22. package/dist/server/migrateAppPostgres.js.map +1 -0
  23. package/dist/server/prepareAppPostgresData.d.ts +6 -0
  24. package/dist/server/prepareAppPostgresData.d.ts.map +1 -0
  25. package/dist/server/prepareAppPostgresData.js +35 -0
  26. package/dist/server/prepareAppPostgresData.js.map +1 -0
  27. package/package.json +3 -3
  28. package/src/server/AppPostgresVersionMismatch.ts +11 -0
  29. package/src/server/PostgresBinaries.ts +9 -0
  30. package/src/server/adapters/hostPostgresBinaries.ts +27 -0
  31. package/src/server/appPostgres.ts +80 -0
  32. package/src/server/migrateAppPostgres.ts +35 -0
  33. package/src/server/prepareAppPostgresData.ts +35 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0 - 2026-11-03
4
+
5
+ ### Added
6
+
7
+ - App-owned PostgreSQL instances initialize and preserve application data, run as scoped children with private Unix sockets and no TCP listener, and refuse incompatible data major versions with `AppPostgresVersionMismatch`.
8
+ - Migration Effects run after a `pg_dump` backup, with configurable retention of completed backups. `PostgresBinaries` separates binary sourcing from lifecycle management; `hostPostgresBinaries` supplies host-installed executables.
9
+
3
10
  ## 0.11.0 - 2026-11-03
4
11
 
5
12
  ### Added
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @shivaedev/local-postgres
2
2
 
3
- Make development and integration tests share one persistent local PostgreSQL service. Repeat setup without rebuilding the database contents, and keep each application's database names and schema preparation in its own setup script.
3
+ Make development and integration tests share one persistent local PostgreSQL service, or let a local application own a private instance. Keep data across starts and leave schema preparation with the application.
4
4
 
5
5
  ## Why you want this
6
6
 
@@ -112,12 +112,52 @@ The last three functions are members of the object returned by `localPostgres`,
112
112
 
113
113
  The module export pattern also exposes the lower-level Docker and container command functions in `@shivaedev/local-postgres/server/adapters/docker.ts` and `@shivaedev/local-postgres/server/adapters/localContainer.ts`. Use `@shivaedev/local-postgres/server/localPostgres.ts` for application preparation.
114
114
 
115
+ ### An instance owned by a local application
116
+
117
+ `appPostgres` from `@shivaedev/local-postgres/server/appPostgres.ts` initializes a cluster in `dataDirectory` and starts PostgreSQL as a child in the current Effect scope. Closing the scope sends a fast-shutdown signal and waits for the child to exit. The data directory survives; the temporary socket directory is removed. Keep the scope open for the application's lifetime:
118
+
119
+ ```ts
120
+ import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
121
+ import * as NodeServices from "@effect/platform-node/NodeServices";
122
+ import { Effect } from "effect";
123
+ import { hostPostgresBinaries } from "@shivaedev/local-postgres/server/adapters/hostPostgresBinaries.ts";
124
+ import { appPostgres } from "@shivaedev/local-postgres/server/appPostgres.ts";
125
+
126
+ const app = Effect.scoped(
127
+ Effect.gen(function* () {
128
+ const postgres = yield* appPostgres({ dataDirectory: "/path/to/app/data", backupsToKeep: 3 });
129
+ yield* Effect.log(postgres.connectionUrl);
130
+ yield* Effect.never;
131
+ }),
132
+ );
133
+
134
+ NodeRuntime.runMain(app.pipe(Effect.provide(hostPostgresBinaries()), Effect.provide(NodeServices.layer)));
135
+ ```
136
+
137
+ The instance listens on a Unix socket in a directory with permissions `0700` and opens no TCP listener. `connectionUrl` selects the `postgres` database and role through that socket. Use it with the application's PostgreSQL client. App-owned mode is separate from the shared development service on port 55432.
138
+
139
+ Before migrations, yield `migrateAppPostgres(instance, migrations)` from `@shivaedev/local-postgres/server/migrateAppPostgres.ts`. `migrations` is the application's Effect, such as its `@shivaedev/sql` migration entry point. The wrapper finishes a custom-format `pg_dump` before evaluating that Effect. A backup failure prevents the migration from running. Completed dumps live in `dataDirectory/backups`; `backupsToKeep` defaults to five and must be a positive integer. After each completed backup, only the newest configured number of dumps remain. The same module exports `backupAppPostgres(instance)` for an explicit backup; it returns the completed dump's path.
140
+
141
+ The instance reads its binaries from the `PostgresBinaries` port in `@shivaedev/local-postgres/server/PostgresBinaries.ts`. `hostPostgresBinaries(directory?)` supplies host executables: either from the supplied directory, from `PATH`, or from `pg_config --bindir` when `postgres` is absent from `PATH`. It checks `initdb`, `postgres` and `pg_dump`. Another adapter can supply the port's `initdb`, `postgres` and `pgDump` paths without changing instance management.
142
+
143
+ Before starting an existing cluster, the instance compares `PG_VERSION` with the server binary's major version. A mismatch fails with `AppPostgresVersionMismatch` from `@shivaedev/local-postgres/server/AppPostgresVersionMismatch.ts`, carrying `dataDirectory`, `expectedMajor` and `observedMajor`. It does not initialize or upgrade that data directory. Choose upgrades and restores outside this package.
144
+
145
+ App-owned mode needs host PostgreSQL server and client binaries and a Unix-socket-capable system. Its real-instance stories skip with a diagnostic when those binaries are absent; set `PLATFORM_LOCAL_POSTGRES_BIN_DIRECTORY` to run them with a particular installed binary directory.
146
+
115
147
  ### Install and machine setup
116
148
 
149
+ For shared development and test setup tooling:
150
+
117
151
  ```sh
118
152
  pnpm add --save-dev @shivaedev/local-postgres @effect/platform-node effect
119
153
  ```
120
154
 
155
+ For an app-owned instance, install these as runtime dependencies:
156
+
157
+ ```sh
158
+ pnpm add @shivaedev/local-postgres @effect/platform-node effect
159
+ ```
160
+
121
161
  The package requires Node 24 or later and uses Effect as a peer dependency. 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 command failure details (and a platform `cause` when a process cannot start) without exposing the connection password.
122
162
 
123
163
  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:
@@ -132,8 +172,8 @@ Yield `dockerEnvironment` from `@shivaedev/local-postgres/server/dockerEnvironme
132
172
 
133
173
  ### Limits
134
174
 
135
- - The service version, port, container name and volume name are fixed. Choose any upgrade and data-preservation procedure outside this package.
136
- - Keep commands in development and test setup tooling; setup never becomes an application request path.
175
+ - The shared service's version, port, container name and volume name are fixed. Choose any upgrade and data-preservation procedure outside this package.
176
+ - Keep shared-service commands in development and test setup tooling. App-owned mode manages the local child's lifetime; runtime queries belong to the application's SQL client.
137
177
  - Keep allowed names, schema changes, test isolation and cleanup in the application. Preparation is not a database reset.
138
178
  - 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.
139
179
  - Concurrent creation of the same absent database is not supported. Prepare databases before parallel tests begin.
@@ -0,0 +1,11 @@
1
+ import { Schema } from "effect";
2
+ declare const AppPostgresVersionMismatch_base: Schema.Class<AppPostgresVersionMismatch, Schema.TaggedStruct<"AppPostgresVersionMismatch", {
3
+ readonly dataDirectory: Schema.String;
4
+ readonly expectedMajor: Schema.String;
5
+ readonly observedMajor: Schema.String;
6
+ }>, import("effect/Cause").YieldableError>;
7
+ export declare class AppPostgresVersionMismatch extends AppPostgresVersionMismatch_base {
8
+ get message(): string;
9
+ }
10
+ export {};
11
+ //# sourceMappingURL=AppPostgresVersionMismatch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AppPostgresVersionMismatch.d.ts","sourceRoot":"","sources":["../../src/server/AppPostgresVersionMismatch.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;;;;;;AAEhC,qBAAa,0BAA2B,SAAQ,+BAI9C;IACD,IAAa,OAAO,WAEnB;CACD"}
@@ -0,0 +1,11 @@
1
+ import { Schema } from "effect";
2
+ export class AppPostgresVersionMismatch extends Schema.TaggedError()("AppPostgresVersionMismatch", {
3
+ dataDirectory: Schema.String,
4
+ expectedMajor: Schema.String,
5
+ observedMajor: Schema.String,
6
+ }) {
7
+ get message() {
8
+ return `PostgreSQL ${this.expectedMajor} cannot open ${this.dataDirectory}: its data belongs to PostgreSQL ${this.observedMajor}.`;
9
+ }
10
+ }
11
+ //# sourceMappingURL=AppPostgresVersionMismatch.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AppPostgresVersionMismatch.js","sourceRoot":"","sources":["../../src/server/AppPostgresVersionMismatch.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEhC,MAAM,OAAO,0BAA2B,SAAQ,MAAM,CAAC,WAAW,EAA8B,CAAC,4BAA4B,EAAE;IAC9H,aAAa,EAAE,MAAM,CAAC,MAAM;IAC5B,aAAa,EAAE,MAAM,CAAC,MAAM;IAC5B,aAAa,EAAE,MAAM,CAAC,MAAM;CAC5B,CAAC;IACD,IAAa,OAAO;QACnB,OAAO,cAAc,IAAI,CAAC,aAAa,gBAAgB,IAAI,CAAC,aAAa,oCAAoC,IAAI,CAAC,aAAa,GAAG,CAAC;IACpI,CAAC;CACD"}
@@ -0,0 +1,11 @@
1
+ import { Context } from "effect";
2
+ export interface PostgresExecutables {
3
+ readonly initdb: string;
4
+ readonly pgDump: string;
5
+ readonly postgres: string;
6
+ }
7
+ declare const PostgresBinaries_base: Context.ServiceClass<PostgresBinaries, "@shivaedev/local-postgres/PostgresBinaries", PostgresExecutables>;
8
+ export declare class PostgresBinaries extends PostgresBinaries_base {
9
+ }
10
+ export {};
11
+ //# sourceMappingURL=PostgresBinaries.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PostgresBinaries.d.ts","sourceRoot":"","sources":["../../src/server/PostgresBinaries.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAEjC,MAAM,WAAW,mBAAmB;IACnC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC1B;;AAED,qBAAa,gBAAiB,SAAQ,qBAAsG;CAAG"}
@@ -0,0 +1,4 @@
1
+ import { Context } from "effect";
2
+ export class PostgresBinaries extends Context.Service()("@shivaedev/local-postgres/PostgresBinaries") {
3
+ }
4
+ //# sourceMappingURL=PostgresBinaries.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PostgresBinaries.js","sourceRoot":"","sources":["../../src/server/PostgresBinaries.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAQjC,MAAM,OAAO,gBAAiB,SAAQ,OAAO,CAAC,OAAO,EAAyC,CAAC,4CAA4C,CAAC;CAAG"}
@@ -0,0 +1,4 @@
1
+ import { Layer, Path } from "effect";
2
+ import { PostgresBinaries } from "#server/PostgresBinaries.ts";
3
+ export declare function hostPostgresBinaries(directory?: string): Layer.Layer<PostgresBinaries, import("../PostgresCommandError.ts").PostgresCommandError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | Path.Path>;
4
+ //# sourceMappingURL=hostPostgresBinaries.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hostPostgresBinaries.d.ts","sourceRoot":"","sources":["../../../src/server/adapters/hostPostgresBinaries.ts"],"names":[],"mappings":"AAAA,OAAO,EAAU,KAAK,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAE7C,OAAO,EAAE,gBAAgB,EAA4B,MAAM,6BAA6B,CAAC;AAEzF,wBAAgB,oBAAoB,CAAC,SAAS,CAAC,EAAE,MAAM,0KAsBtD"}
@@ -0,0 +1,20 @@
1
+ import { Effect, Layer, Path } from "effect";
2
+ import { missingCommand, runCommand } from "#server/adapters/runCommand.ts";
3
+ import { PostgresBinaries } from "#server/PostgresBinaries.ts";
4
+ export function hostPostgresBinaries(directory) {
5
+ return Layer.effect(PostgresBinaries, Effect.gen(function* () {
6
+ const path = yield* Path.Path;
7
+ const bin = directory
8
+ ?? (yield* runCommand("postgres", ["--version"]).pipe(Effect.as(""), Effect.catchTag("PostgresCommandError", (error) => (missingCommand(error) ? runCommand("pg_config", ["--bindir"]) : Effect.fail(error)))));
9
+ const executables = {
10
+ initdb: bin ? path.join(bin, "initdb") : "initdb",
11
+ pgDump: bin ? path.join(bin, "pg_dump") : "pg_dump",
12
+ postgres: bin ? path.join(bin, "postgres") : "postgres",
13
+ };
14
+ for (const command of Object.values(executables)) {
15
+ yield* runCommand(command, ["--version"]);
16
+ }
17
+ return executables;
18
+ }));
19
+ }
20
+ //# sourceMappingURL=hostPostgresBinaries.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hostPostgresBinaries.js","sourceRoot":"","sources":["../../../src/server/adapters/hostPostgresBinaries.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAC7C,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,gCAAgC,CAAC;AAC5E,OAAO,EAAE,gBAAgB,EAA4B,MAAM,6BAA6B,CAAC;AAEzF,MAAM,UAAU,oBAAoB,CAAC,SAAkB;IACtD,OAAO,KAAK,CAAC,MAAM,CAClB,gBAAgB,EAChB,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QACnB,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC;QAC9B,MAAM,GAAG,GACR,SAAS;eACN,CAAC,KAAK,CAAC,CAAC,UAAU,CAAC,UAAU,EAAE,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI,CACpD,MAAM,CAAC,EAAE,CAAC,EAAE,CAAC,EACb,MAAM,CAAC,QAAQ,CAAC,sBAAsB,EAAE,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,WAAW,EAAE,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CACxI,CAAC,CAAC;QACJ,MAAM,WAAW,GAAwB;YACxC,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ;YACjD,MAAM,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS;YACnD,QAAQ,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,UAAU,CAAC,CAAC,CAAC,CAAC,UAAU;SACvD,CAAC;QACF,KAAK,MAAM,OAAO,IAAI,MAAM,CAAC,MAAM,CAAC,WAAW,CAAC,EAAE,CAAC;YAClD,KAAK,CAAC,CAAC,UAAU,CAAC,OAAO,EAAE,CAAC,WAAW,CAAC,CAAC,CAAC;QAC3C,CAAC;QACD,OAAO,WAAW,CAAC;IACpB,CAAC,CAAC,CACF,CAAC;AACH,CAAC"}
@@ -0,0 +1,22 @@
1
+ import { Effect, FileSystem, Path } from "effect";
2
+ import { ChildProcessSpawner } from "effect/process";
3
+ import { LocalPostgresError } from "#server/LocalPostgresError.ts";
4
+ import { PostgresBinaries } from "#server/PostgresBinaries.ts";
5
+ import { PostgresCommandError } from "#server/PostgresCommandError.ts";
6
+ export interface AppPostgresOptions {
7
+ readonly backupsToKeep?: number;
8
+ readonly dataDirectory: string;
9
+ }
10
+ export interface AppPostgresInstance {
11
+ readonly backupsToKeep: number;
12
+ readonly connectionUrl: string;
13
+ readonly dataDirectory: string;
14
+ readonly socketDirectory: string;
15
+ }
16
+ export declare const appPostgres: (args_0: AppPostgresOptions) => Effect.Effect<{
17
+ backupsToKeep: number;
18
+ connectionUrl: string;
19
+ dataDirectory: string;
20
+ socketDirectory: string;
21
+ }, import("./AppPostgresVersionMismatch.ts").AppPostgresVersionMismatch | LocalPostgresError | import("effect/PlatformError").PlatformError | PostgresCommandError | import("effect/Cause").TimeoutError, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path | PostgresBinaries | import("effect/Scope").Scope>;
22
+ //# sourceMappingURL=appPostgres.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"appPostgres.d.ts","sourceRoot":"","sources":["../../src/server/appPostgres.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,MAAM,EAAE,UAAU,EAAE,IAAI,EAAU,MAAM,QAAQ,CAAC;AACpE,OAAO,EAAgB,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AACnE,OAAO,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAC;AACnE,OAAO,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAC;AAC/D,OAAO,EAAE,oBAAoB,EAAE,MAAM,iCAAiC,CAAC;AAGvE,MAAM,WAAW,kBAAkB;IAClC,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;CAC/B;AAED,MAAM,WAAW,mBAAmB;IACnC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;CACjC;AAED,eAAO,MAAM,WAAW;;;;;wUA4DtB,CAAC"}
@@ -0,0 +1,50 @@
1
+ import { Deferred, Effect, FileSystem, Path, Stream } from "effect";
2
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
3
+ import { LocalPostgresError } from "#server/LocalPostgresError.ts";
4
+ import { PostgresBinaries } from "#server/PostgresBinaries.ts";
5
+ import { PostgresCommandError } from "#server/PostgresCommandError.ts";
6
+ import { prepareAppPostgresData } from "#server/prepareAppPostgresData.ts";
7
+ export const appPostgres = Effect.fn("LocalPostgres.appPostgres")(function* ({ dataDirectory, backupsToKeep = 5 }) {
8
+ if (!Number.isSafeInteger(backupsToKeep) || backupsToKeep < 1) {
9
+ return yield* new LocalPostgresError({ message: "backupsToKeep must be a positive integer." });
10
+ }
11
+ const fs = yield* FileSystem.FileSystem;
12
+ const path = yield* Path.Path;
13
+ const binaries = yield* PostgresBinaries;
14
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
15
+ const directory = path.resolve(dataDirectory);
16
+ yield* prepareAppPostgresData(directory);
17
+ const socketDirectory = yield* fs.makeTempDirectoryScoped({ prefix: "app-postgres-" });
18
+ yield* fs.chmod(socketDirectory, 0o700);
19
+ const child = yield* spawner.spawn(ChildProcess.make(binaries.postgres, [
20
+ "-D",
21
+ directory,
22
+ "-h",
23
+ "",
24
+ "-k",
25
+ socketDirectory,
26
+ "-c",
27
+ "unix_socket_permissions=0700",
28
+ "-c",
29
+ "logging_collector=off",
30
+ "-c",
31
+ "log_destination=stderr",
32
+ "-c",
33
+ "lc_messages=C",
34
+ ], { env: { "LC_ALL": "C" }, extendEnv: true, killSignal: "SIGINT", stdin: "ignore", stdout: "ignore" }));
35
+ const ready = yield* Deferred.make();
36
+ let diagnostics = "";
37
+ yield* child.stderr.pipe(Stream.decodeText, Stream.splitLines, Stream.runForEach((line) => {
38
+ diagnostics = line;
39
+ return line.includes("database system is ready to accept connections") ? Deferred.succeed(ready, undefined) : Effect.void;
40
+ }), Effect.forkScoped);
41
+ yield* Effect.raceFirst(Deferred.await(ready), Effect.flatMap(child.exitCode, (code) => Effect.fail(new PostgresCommandError({
42
+ command: binaries.postgres,
43
+ exitCode: Number(code),
44
+ message: diagnostics || "PostgreSQL exited before becoming ready.",
45
+ })))).pipe(Effect.timeout("30 seconds"));
46
+ const connection = new URL("postgresql://postgres@localhost/postgres");
47
+ connection.searchParams.set("host", socketDirectory);
48
+ return { backupsToKeep, connectionUrl: connection.toString(), dataDirectory: directory, socketDirectory };
49
+ });
50
+ //# sourceMappingURL=appPostgres.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"appPostgres.js","sourceRoot":"","sources":["../../src/server/appPostgres.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ import { Effect, FileSystem, Path } from "effect";
2
+ import type { AppPostgresInstance } from "#server/appPostgres.ts";
3
+ import { PostgresBinaries } from "#server/PostgresBinaries.ts";
4
+ export declare const backupAppPostgres: (instance: AppPostgresInstance) => Effect.Effect<string, import("effect/PlatformError").PlatformError | import("./PostgresCommandError.ts").PostgresCommandError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | FileSystem.FileSystem | Path.Path | PostgresBinaries>;
5
+ export declare function migrateAppPostgres<A, E, R>(instance: AppPostgresInstance, migration: Effect.Effect<A, E, R>): Effect.Effect<A, E | import("effect/PlatformError").PlatformError | import("./PostgresCommandError.ts").PostgresCommandError, R | import("effect/process/ChildProcessSpawner").ChildProcessSpawner | FileSystem.FileSystem | Path.Path | PostgresBinaries>;
6
+ //# sourceMappingURL=migrateAppPostgres.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migrateAppPostgres.d.ts","sourceRoot":"","sources":["../../src/server/migrateAppPostgres.ts"],"names":[],"mappings":"AAAA,OAAO,EAAS,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAEzD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,wBAAwB,CAAC;AAClE,OAAO,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAC;AAE/D,eAAO,MAAM,iBAAiB,4RAyB5B,CAAC;AAEH,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,QAAQ,EAAE,mBAAmB,EAAE,SAAS,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC,8PAE3G"}
@@ -0,0 +1,31 @@
1
+ import { Clock, Effect, FileSystem, Path } from "effect";
2
+ import { runCommand } from "#server/adapters/runCommand.ts";
3
+ import { PostgresBinaries } from "#server/PostgresBinaries.ts";
4
+ export const backupAppPostgres = Effect.fn("LocalPostgres.backupAppPostgres")(function* (instance) {
5
+ const fs = yield* FileSystem.FileSystem;
6
+ const path = yield* Path.Path;
7
+ const binaries = yield* PostgresBinaries;
8
+ const directory = path.join(instance.dataDirectory, "backups");
9
+ yield* fs.makeDirectory(directory, { mode: 0o700, recursive: true });
10
+ const timestamp = yield* Clock.currentTimeMillis;
11
+ const backup = yield* Effect.scoped(Effect.gen(function* () {
12
+ const staging = yield* fs.makeTempDirectoryScoped({ directory, prefix: `backup-${timestamp}-` });
13
+ const file = path.join(staging, "dump.partial");
14
+ yield* runCommand(binaries.pgDump, ["--format=custom", "--file", file, "--dbname", instance.connectionUrl]);
15
+ const completed = `${staging}.dump`;
16
+ yield* fs.rename(file, completed);
17
+ return completed;
18
+ }));
19
+ const files = (yield* fs.readDirectory(directory))
20
+ .filter((name) => /^backup-\d+-.+\.dump$/u.test(name))
21
+ .sort()
22
+ .reverse();
23
+ for (const file of files.slice(instance.backupsToKeep)) {
24
+ yield* fs.remove(path.join(directory, file));
25
+ }
26
+ return backup;
27
+ });
28
+ export function migrateAppPostgres(instance, migration) {
29
+ return backupAppPostgres(instance).pipe(Effect.andThen(migration));
30
+ }
31
+ //# sourceMappingURL=migrateAppPostgres.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migrateAppPostgres.js","sourceRoot":"","sources":["../../src/server/migrateAppPostgres.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AACzD,OAAO,EAAE,UAAU,EAAE,MAAM,gCAAgC,CAAC;AAE5D,OAAO,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAC;AAE/D,MAAM,CAAC,MAAM,iBAAiB,GAAG,MAAM,CAAC,EAAE,CAAC,iCAAiC,CAAC,CAAC,QAAQ,CAAC,EAAE,QAA6B;IACrH,MAAM,EAAE,GAAG,KAAK,CAAC,CAAC,UAAU,CAAC,UAAU,CAAC;IACxC,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC;IAC9B,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,gBAAgB,CAAC;IACzC,MAAM,SAAS,GAAG,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,aAAa,EAAE,SAAS,CAAC,CAAC;IAC/D,KAAK,CAAC,CAAC,EAAE,CAAC,aAAa,CAAC,SAAS,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACrE,MAAM,SAAS,GAAG,KAAK,CAAC,CAAC,KAAK,CAAC,iBAAiB,CAAC;IACjD,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,MAAM,CAAC,MAAM,CAClC,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC;QACnB,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC,EAAE,CAAC,uBAAuB,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,UAAU,SAAS,GAAG,EAAE,CAAC,CAAC;QACjG,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,cAAc,CAAC,CAAC;QAChD,KAAK,CAAC,CAAC,UAAU,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC,iBAAiB,EAAE,QAAQ,EAAE,IAAI,EAAE,UAAU,EAAE,QAAQ,CAAC,aAAa,CAAC,CAAC,CAAC;QAC5G,MAAM,SAAS,GAAG,GAAG,OAAO,OAAO,CAAC;QACpC,KAAK,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC;QAClC,OAAO,SAAS,CAAC;IAClB,CAAC,CAAC,CACF,CAAC;IACF,MAAM,KAAK,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC;SAChD,MAAM,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,wBAAwB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;SACrD,IAAI,EAAE;SACN,OAAO,EAAE,CAAC;IACZ,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,KAAK,CAAC,QAAQ,CAAC,aAAa,CAAC,EAAE,CAAC;QACxD,KAAK,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC;IAC9C,CAAC;IACD,OAAO,MAAM,CAAC;AACf,CAAC,CAAC,CAAC;AAEH,MAAM,UAAU,kBAAkB,CAAU,QAA6B,EAAE,SAAiC;IAC3G,OAAO,iBAAiB,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC;AACpE,CAAC"}
@@ -0,0 +1,6 @@
1
+ import { Effect, FileSystem, Path } from "effect";
2
+ import { AppPostgresVersionMismatch } from "#server/AppPostgresVersionMismatch.ts";
3
+ import { LocalPostgresError } from "#server/LocalPostgresError.ts";
4
+ import { PostgresBinaries } from "#server/PostgresBinaries.ts";
5
+ export declare const prepareAppPostgresData: (dataDirectory: string) => Effect.Effect<undefined, AppPostgresVersionMismatch | LocalPostgresError | import("effect/PlatformError").PlatformError | import("./PostgresCommandError.ts").PostgresCommandError, import("effect/process/ChildProcessSpawner").ChildProcessSpawner | FileSystem.FileSystem | Path.Path | PostgresBinaries>;
6
+ //# sourceMappingURL=prepareAppPostgresData.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"prepareAppPostgresData.d.ts","sourceRoot":"","sources":["../../src/server/prepareAppPostgresData.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAClD,OAAO,EAAE,0BAA0B,EAAE,MAAM,uCAAuC,CAAC;AAEnF,OAAO,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAC;AACnE,OAAO,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAC;AAE/D,eAAO,MAAM,sBAAsB,yUA4BjC,CAAC"}
@@ -0,0 +1,35 @@
1
+ import { Effect, FileSystem, Path } from "effect";
2
+ import { AppPostgresVersionMismatch } from "#server/AppPostgresVersionMismatch.ts";
3
+ import { runCommand } from "#server/adapters/runCommand.ts";
4
+ import { LocalPostgresError } from "#server/LocalPostgresError.ts";
5
+ import { PostgresBinaries } from "#server/PostgresBinaries.ts";
6
+ export const prepareAppPostgresData = Effect.fn("LocalPostgres.prepareAppPostgresData")(function* (dataDirectory) {
7
+ const binaries = yield* PostgresBinaries;
8
+ const fs = yield* FileSystem.FileSystem;
9
+ const path = yield* Path.Path;
10
+ const version = yield* runCommand(binaries.postgres, ["--version"]);
11
+ const expectedMajor = /\b(?<major>\d+)\./u.exec(version)?.groups?.major;
12
+ if (!expectedMajor) {
13
+ return yield* new LocalPostgresError({ message: `Cannot read PostgreSQL major version from ${version}.` });
14
+ }
15
+ const versionFile = path.join(dataDirectory, "PG_VERSION");
16
+ if (yield* fs.exists(versionFile)) {
17
+ const observedMajor = (yield* fs.readFileString(versionFile)).trim();
18
+ if (observedMajor !== expectedMajor) {
19
+ return yield* new AppPostgresVersionMismatch({ dataDirectory, expectedMajor, observedMajor });
20
+ }
21
+ return;
22
+ }
23
+ yield* fs.makeDirectory(dataDirectory, { recursive: true });
24
+ yield* runCommand(binaries.initdb, [
25
+ "--pgdata",
26
+ dataDirectory,
27
+ "--username",
28
+ "postgres",
29
+ "--auth-local=trust",
30
+ "--auth-host=reject",
31
+ "--encoding=UTF8",
32
+ "--locale=C",
33
+ ]);
34
+ });
35
+ //# sourceMappingURL=prepareAppPostgresData.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"prepareAppPostgresData.js","sourceRoot":"","sources":["../../src/server/prepareAppPostgresData.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAClD,OAAO,EAAE,0BAA0B,EAAE,MAAM,uCAAuC,CAAC;AACnF,OAAO,EAAE,UAAU,EAAE,MAAM,gCAAgC,CAAC;AAC5D,OAAO,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAC;AACnE,OAAO,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAC;AAE/D,MAAM,CAAC,MAAM,sBAAsB,GAAG,MAAM,CAAC,EAAE,CAAC,sCAAsC,CAAC,CAAC,QAAQ,CAAC,EAAE,aAAqB;IACvH,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,gBAAgB,CAAC;IACzC,MAAM,EAAE,GAAG,KAAK,CAAC,CAAC,UAAU,CAAC,UAAU,CAAC;IACxC,MAAM,IAAI,GAAG,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC;IAC9B,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC,UAAU,CAAC,QAAQ,CAAC,QAAQ,EAAE,CAAC,WAAW,CAAC,CAAC,CAAC;IACpE,MAAM,aAAa,GAAG,oBAAoB,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC;IACxE,IAAI,CAAC,aAAa,EAAE,CAAC;QACpB,OAAO,KAAK,CAAC,CAAC,IAAI,kBAAkB,CAAC,EAAE,OAAO,EAAE,6CAA6C,OAAO,GAAG,EAAE,CAAC,CAAC;IAC5G,CAAC;IACD,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,CAAC,aAAa,EAAE,YAAY,CAAC,CAAC;IAC3D,IAAI,KAAK,CAAC,CAAC,EAAE,CAAC,MAAM,CAAC,WAAW,CAAC,EAAE,CAAC;QACnC,MAAM,aAAa,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,cAAc,CAAC,WAAW,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QACrE,IAAI,aAAa,KAAK,aAAa,EAAE,CAAC;YACrC,OAAO,KAAK,CAAC,CAAC,IAAI,0BAA0B,CAAC,EAAE,aAAa,EAAE,aAAa,EAAE,aAAa,EAAE,CAAC,CAAC;QAC/F,CAAC;QACD,OAAO;IACR,CAAC;IACD,KAAK,CAAC,CAAC,EAAE,CAAC,aAAa,CAAC,aAAa,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC5D,KAAK,CAAC,CAAC,UAAU,CAAC,QAAQ,CAAC,MAAM,EAAE;QAClC,UAAU;QACV,aAAa;QACb,YAAY;QACZ,UAAU;QACV,oBAAoB;QACpB,oBAAoB;QACpB,iBAAiB;QACjB,YAAY;KACZ,CAAC,CAAC;AACJ,CAAC,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/local-postgres",
3
- "version": "0.11.0",
3
+ "version": "0.12.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": {
@@ -41,8 +41,8 @@
41
41
  ],
42
42
  "devDependencies": {
43
43
  "@effect/platform-node": "4.0.6",
44
- "@shivaedev/quality": "0.57.0",
45
- "@shivaedev/test-story": "0.35.0",
44
+ "@shivaedev/quality": "0.57.1",
45
+ "@shivaedev/test-story": "0.36.0",
46
46
  "@types/node": "24.19.2",
47
47
  "effect": "4.0.6",
48
48
  "typescript": "7.0.2",
@@ -0,0 +1,11 @@
1
+ import { Schema } from "effect";
2
+
3
+ export class AppPostgresVersionMismatch extends Schema.TaggedError<AppPostgresVersionMismatch>()("AppPostgresVersionMismatch", {
4
+ dataDirectory: Schema.String,
5
+ expectedMajor: Schema.String,
6
+ observedMajor: Schema.String,
7
+ }) {
8
+ override get message() {
9
+ return `PostgreSQL ${this.expectedMajor} cannot open ${this.dataDirectory}: its data belongs to PostgreSQL ${this.observedMajor}.`;
10
+ }
11
+ }
@@ -0,0 +1,9 @@
1
+ import { Context } from "effect";
2
+
3
+ export interface PostgresExecutables {
4
+ readonly initdb: string;
5
+ readonly pgDump: string;
6
+ readonly postgres: string;
7
+ }
8
+
9
+ export class PostgresBinaries extends Context.Service<PostgresBinaries, PostgresExecutables>()("@shivaedev/local-postgres/PostgresBinaries") {}
@@ -0,0 +1,27 @@
1
+ import { Effect, Layer, Path } from "effect";
2
+ import { missingCommand, runCommand } from "#server/adapters/runCommand.ts";
3
+ import { PostgresBinaries, type PostgresExecutables } from "#server/PostgresBinaries.ts";
4
+
5
+ export function hostPostgresBinaries(directory?: string) {
6
+ return Layer.effect(
7
+ PostgresBinaries,
8
+ Effect.gen(function* () {
9
+ const path = yield* Path.Path;
10
+ const bin =
11
+ directory
12
+ ?? (yield* runCommand("postgres", ["--version"]).pipe(
13
+ Effect.as(""),
14
+ Effect.catchTag("PostgresCommandError", (error) => (missingCommand(error) ? runCommand("pg_config", ["--bindir"]) : Effect.fail(error))),
15
+ ));
16
+ const executables: PostgresExecutables = {
17
+ initdb: bin ? path.join(bin, "initdb") : "initdb",
18
+ pgDump: bin ? path.join(bin, "pg_dump") : "pg_dump",
19
+ postgres: bin ? path.join(bin, "postgres") : "postgres",
20
+ };
21
+ for (const command of Object.values(executables)) {
22
+ yield* runCommand(command, ["--version"]);
23
+ }
24
+ return executables;
25
+ }),
26
+ );
27
+ }
@@ -0,0 +1,80 @@
1
+ import { Deferred, Effect, FileSystem, Path, Stream } from "effect";
2
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
3
+ import { LocalPostgresError } from "#server/LocalPostgresError.ts";
4
+ import { PostgresBinaries } from "#server/PostgresBinaries.ts";
5
+ import { PostgresCommandError } from "#server/PostgresCommandError.ts";
6
+ import { prepareAppPostgresData } from "#server/prepareAppPostgresData.ts";
7
+
8
+ export interface AppPostgresOptions {
9
+ readonly backupsToKeep?: number;
10
+ readonly dataDirectory: string;
11
+ }
12
+
13
+ export interface AppPostgresInstance {
14
+ readonly backupsToKeep: number;
15
+ readonly connectionUrl: string;
16
+ readonly dataDirectory: string;
17
+ readonly socketDirectory: string;
18
+ }
19
+
20
+ export const appPostgres = Effect.fn("LocalPostgres.appPostgres")(function* ({ dataDirectory, backupsToKeep = 5 }: AppPostgresOptions) {
21
+ if (!Number.isSafeInteger(backupsToKeep) || backupsToKeep < 1) {
22
+ return yield* new LocalPostgresError({ message: "backupsToKeep must be a positive integer." });
23
+ }
24
+ const fs = yield* FileSystem.FileSystem;
25
+ const path = yield* Path.Path;
26
+ const binaries = yield* PostgresBinaries;
27
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
28
+ const directory = path.resolve(dataDirectory);
29
+ yield* prepareAppPostgresData(directory);
30
+ const socketDirectory = yield* fs.makeTempDirectoryScoped({ prefix: "app-postgres-" });
31
+ yield* fs.chmod(socketDirectory, 0o700);
32
+ const child = yield* spawner.spawn(
33
+ ChildProcess.make(
34
+ binaries.postgres,
35
+ [
36
+ "-D",
37
+ directory,
38
+ "-h",
39
+ "",
40
+ "-k",
41
+ socketDirectory,
42
+ "-c",
43
+ "unix_socket_permissions=0700",
44
+ "-c",
45
+ "logging_collector=off",
46
+ "-c",
47
+ "log_destination=stderr",
48
+ "-c",
49
+ "lc_messages=C",
50
+ ],
51
+ { env: { "LC_ALL": "C" }, extendEnv: true, killSignal: "SIGINT", stdin: "ignore", stdout: "ignore" },
52
+ ),
53
+ );
54
+ const ready = yield* Deferred.make<void>();
55
+ let diagnostics = "";
56
+ yield* child.stderr.pipe(
57
+ Stream.decodeText,
58
+ Stream.splitLines,
59
+ Stream.runForEach((line) => {
60
+ diagnostics = line;
61
+ return line.includes("database system is ready to accept connections") ? Deferred.succeed(ready, undefined) : Effect.void;
62
+ }),
63
+ Effect.forkScoped,
64
+ );
65
+ yield* Effect.raceFirst(
66
+ Deferred.await(ready),
67
+ Effect.flatMap(child.exitCode, (code) =>
68
+ Effect.fail(
69
+ new PostgresCommandError({
70
+ command: binaries.postgres,
71
+ exitCode: Number(code),
72
+ message: diagnostics || "PostgreSQL exited before becoming ready.",
73
+ }),
74
+ ),
75
+ ),
76
+ ).pipe(Effect.timeout("30 seconds"));
77
+ const connection = new URL("postgresql://postgres@localhost/postgres");
78
+ connection.searchParams.set("host", socketDirectory);
79
+ return { backupsToKeep, connectionUrl: connection.toString(), dataDirectory: directory, socketDirectory } satisfies AppPostgresInstance;
80
+ });
@@ -0,0 +1,35 @@
1
+ import { Clock, Effect, FileSystem, Path } from "effect";
2
+ import { runCommand } from "#server/adapters/runCommand.ts";
3
+ import type { AppPostgresInstance } from "#server/appPostgres.ts";
4
+ import { PostgresBinaries } from "#server/PostgresBinaries.ts";
5
+
6
+ export const backupAppPostgres = Effect.fn("LocalPostgres.backupAppPostgres")(function* (instance: AppPostgresInstance) {
7
+ const fs = yield* FileSystem.FileSystem;
8
+ const path = yield* Path.Path;
9
+ const binaries = yield* PostgresBinaries;
10
+ const directory = path.join(instance.dataDirectory, "backups");
11
+ yield* fs.makeDirectory(directory, { mode: 0o700, recursive: true });
12
+ const timestamp = yield* Clock.currentTimeMillis;
13
+ const backup = yield* Effect.scoped(
14
+ Effect.gen(function* () {
15
+ const staging = yield* fs.makeTempDirectoryScoped({ directory, prefix: `backup-${timestamp}-` });
16
+ const file = path.join(staging, "dump.partial");
17
+ yield* runCommand(binaries.pgDump, ["--format=custom", "--file", file, "--dbname", instance.connectionUrl]);
18
+ const completed = `${staging}.dump`;
19
+ yield* fs.rename(file, completed);
20
+ return completed;
21
+ }),
22
+ );
23
+ const files = (yield* fs.readDirectory(directory))
24
+ .filter((name) => /^backup-\d+-.+\.dump$/u.test(name))
25
+ .sort()
26
+ .reverse();
27
+ for (const file of files.slice(instance.backupsToKeep)) {
28
+ yield* fs.remove(path.join(directory, file));
29
+ }
30
+ return backup;
31
+ });
32
+
33
+ export function migrateAppPostgres<A, E, R>(instance: AppPostgresInstance, migration: Effect.Effect<A, E, R>) {
34
+ return backupAppPostgres(instance).pipe(Effect.andThen(migration));
35
+ }
@@ -0,0 +1,35 @@
1
+ import { Effect, FileSystem, Path } from "effect";
2
+ import { AppPostgresVersionMismatch } from "#server/AppPostgresVersionMismatch.ts";
3
+ import { runCommand } from "#server/adapters/runCommand.ts";
4
+ import { LocalPostgresError } from "#server/LocalPostgresError.ts";
5
+ import { PostgresBinaries } from "#server/PostgresBinaries.ts";
6
+
7
+ export const prepareAppPostgresData = Effect.fn("LocalPostgres.prepareAppPostgresData")(function* (dataDirectory: string) {
8
+ const binaries = yield* PostgresBinaries;
9
+ const fs = yield* FileSystem.FileSystem;
10
+ const path = yield* Path.Path;
11
+ const version = yield* runCommand(binaries.postgres, ["--version"]);
12
+ const expectedMajor = /\b(?<major>\d+)\./u.exec(version)?.groups?.major;
13
+ if (!expectedMajor) {
14
+ return yield* new LocalPostgresError({ message: `Cannot read PostgreSQL major version from ${version}.` });
15
+ }
16
+ const versionFile = path.join(dataDirectory, "PG_VERSION");
17
+ if (yield* fs.exists(versionFile)) {
18
+ const observedMajor = (yield* fs.readFileString(versionFile)).trim();
19
+ if (observedMajor !== expectedMajor) {
20
+ return yield* new AppPostgresVersionMismatch({ dataDirectory, expectedMajor, observedMajor });
21
+ }
22
+ return;
23
+ }
24
+ yield* fs.makeDirectory(dataDirectory, { recursive: true });
25
+ yield* runCommand(binaries.initdb, [
26
+ "--pgdata",
27
+ dataDirectory,
28
+ "--username",
29
+ "postgres",
30
+ "--auth-local=trust",
31
+ "--auth-host=reject",
32
+ "--encoding=UTF8",
33
+ "--locale=C",
34
+ ]);
35
+ });