@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.
- package/CHANGELOG.md +7 -0
- package/README.md +43 -3
- package/dist/server/AppPostgresVersionMismatch.d.ts +11 -0
- package/dist/server/AppPostgresVersionMismatch.d.ts.map +1 -0
- package/dist/server/AppPostgresVersionMismatch.js +11 -0
- package/dist/server/AppPostgresVersionMismatch.js.map +1 -0
- package/dist/server/PostgresBinaries.d.ts +11 -0
- package/dist/server/PostgresBinaries.d.ts.map +1 -0
- package/dist/server/PostgresBinaries.js +4 -0
- package/dist/server/PostgresBinaries.js.map +1 -0
- package/dist/server/adapters/hostPostgresBinaries.d.ts +4 -0
- package/dist/server/adapters/hostPostgresBinaries.d.ts.map +1 -0
- package/dist/server/adapters/hostPostgresBinaries.js +20 -0
- package/dist/server/adapters/hostPostgresBinaries.js.map +1 -0
- package/dist/server/appPostgres.d.ts +22 -0
- package/dist/server/appPostgres.d.ts.map +1 -0
- package/dist/server/appPostgres.js +50 -0
- package/dist/server/appPostgres.js.map +1 -0
- package/dist/server/migrateAppPostgres.d.ts +6 -0
- package/dist/server/migrateAppPostgres.d.ts.map +1 -0
- package/dist/server/migrateAppPostgres.js +31 -0
- package/dist/server/migrateAppPostgres.js.map +1 -0
- package/dist/server/prepareAppPostgresData.d.ts +6 -0
- package/dist/server/prepareAppPostgresData.d.ts.map +1 -0
- package/dist/server/prepareAppPostgresData.js +35 -0
- package/dist/server/prepareAppPostgresData.js.map +1 -0
- package/package.json +3 -3
- package/src/server/AppPostgresVersionMismatch.ts +11 -0
- package/src/server/PostgresBinaries.ts +9 -0
- package/src/server/adapters/hostPostgresBinaries.ts +27 -0
- package/src/server/appPostgres.ts +80 -0
- package/src/server/migrateAppPostgres.ts +35 -0
- 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
|
|
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;
|
|
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 @@
|
|
|
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.
|
|
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.
|
|
45
|
-
"@shivaedev/test-story": "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
|
+
});
|