@saws/postgres-service 2.0.0-beta.3

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/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@saws/postgres-service",
3
+ "version": "2.0.0-beta.3",
4
+ "description": "",
5
+ "license": "ISC",
6
+ "author": "",
7
+ "type": "module",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "dependencies": {
16
+ "@saws/core": "2.0.0-beta.3",
17
+ "@saws/docker-service": "2.0.0-beta.3",
18
+ "commander": "^15.0.0"
19
+ }
20
+ }
@@ -0,0 +1,411 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { mkdir, readFile, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import {
5
+ SecretsManager,
6
+ ServiceDefinition,
7
+ type SecretReference,
8
+ type ServiceEnvironmentTarget,
9
+ } from "@saws/core";
10
+ import { hasDependency, installDependencies } from "@saws/core/utils/dependency-management";
11
+ import { fileExists } from "@saws/core/utils/file-exists";
12
+ import { listFiles } from "@saws/core/utils/list-files";
13
+ import { runLocal } from "@saws/core/utils/run-local";
14
+ import { shellQuote } from "@saws/core/utils/shell-quote";
15
+ import type { Outputs } from "@saws/core/utils/stage-outputs";
16
+ import {
17
+ DockerService,
18
+ type DockerRunConfig,
19
+ type DockerServiceConfig,
20
+ type RuntimeFile,
21
+ } from "@saws/docker-service";
22
+ import { createMigrateCommand } from "./migrate-command.js";
23
+
24
+ const POSTGRES_VOLUME_DIRECTORY = "/var/lib/postgresql";
25
+
26
+ export type PostgresConnectionTarget = "container" | "host";
27
+
28
+ export type PostgresConnectionInfo = {
29
+ host: string;
30
+ port: string;
31
+ username: string;
32
+ password: string;
33
+ database: string;
34
+ url: string;
35
+ };
36
+
37
+ export interface PostgresServiceConfig extends Omit<
38
+ DockerServiceConfig,
39
+ "image" | "dockerfile" | "buildContext" | "volumes" | "ports" | "command"
40
+ > {
41
+ image?: string;
42
+ /** Host port to expose Postgres on. Local dev defaults to 5432; deploys stay private unless set. */
43
+ port?: number;
44
+ /** Database created by the Postgres image and used in generated connection URLs. */
45
+ database?: string;
46
+ /** Postgres superuser created by the Postgres image. Defaults to "postgres". */
47
+ username?: string;
48
+ /** Postgres password secret reference. Omit to store a generated password in SAWS secrets. */
49
+ password?: SecretReference;
50
+ /** Override the Docker volume name. By default SAWS derives a stable stage/service volume. */
51
+ volume?: string;
52
+ /** Enable logical WAL for replication/change data capture use cases. */
53
+ wal_enabled?: boolean;
54
+ /** dbmate image used to apply migrations in an ephemeral sibling container. */
55
+ migrationImage?: string;
56
+ }
57
+
58
+ export class PostgresService extends DockerService {
59
+ static getCommands(services: ServiceDefinition[] = []) {
60
+ return [createMigrateCommand(services.filter(isPostgresService))];
61
+ }
62
+
63
+ readonly port?: number;
64
+ readonly database?: string;
65
+ readonly username: string;
66
+ readonly password?: SecretReference;
67
+ readonly volume?: string;
68
+ readonly walEnabled: boolean;
69
+ readonly migrationImage: string;
70
+ protected override readonly serviceType = "postgres";
71
+
72
+ constructor(config: PostgresServiceConfig) {
73
+ super({
74
+ ...config,
75
+ image: config.image ?? "postgres:18",
76
+ healthCheck: config.healthCheck ?? {
77
+ command: 'pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"',
78
+ interval: "10s",
79
+ timeout: "5s",
80
+ retries: 5,
81
+ startPeriod: "10s",
82
+ },
83
+ });
84
+
85
+ this.port = config.port;
86
+ this.database = config.database;
87
+ this.username = config.username ?? "postgres";
88
+ this.password = config.password;
89
+ this.volume = config.volume;
90
+ this.walEnabled = config.wal_enabled ?? false;
91
+ this.migrationImage = config.migrationImage ?? "ghcr.io/amacneil/dbmate:2.33.0";
92
+ }
93
+
94
+ override async init() {
95
+ await super.init();
96
+ await mkdir(path.resolve(this.name, "migrations"), { recursive: true });
97
+ if (
98
+ !(await hasDependency("dbmate")) ||
99
+ !(await fileExists(path.resolve("node_modules", ".bin", "dbmate")))
100
+ ) {
101
+ await installDependencies(["dbmate"], {
102
+ development: true,
103
+ logSink: this.getRuntimeLogSink(),
104
+ serviceName: this.migrationLogServiceName,
105
+ });
106
+ }
107
+ }
108
+
109
+ override async getEnvironmentVariables(
110
+ stage: string,
111
+ target: ServiceEnvironmentTarget = "container",
112
+ ): Promise<Record<string, string>> {
113
+ const connection = await this.getConnectionInfo(stage, target);
114
+ const prefix = this.environmentVariablePrefix;
115
+
116
+ return {
117
+ [`${prefix}_POSTGRES_HOST`]: connection.host,
118
+ [`${prefix}_POSTGRES_PORT`]: connection.port,
119
+ [`${prefix}_POSTGRES_USERNAME`]: connection.username,
120
+ [`${prefix}_POSTGRES_PASSWORD`]: connection.password,
121
+ [`${prefix}_POSTGRES_DB_NAME`]: connection.database,
122
+ [`${prefix}_DATABASE_URL`]: connection.url,
123
+ };
124
+ }
125
+
126
+ override async dev() {
127
+ await super.dev();
128
+ await this.runMigrations("local", {
129
+ rootDir: process.cwd(),
130
+ dbmateArgs: ["--wait", "--no-dump-schema", "migrate"],
131
+ readOnly: true,
132
+ });
133
+ }
134
+
135
+ override async deploy(stage: string) {
136
+ await super.deploy(stage);
137
+ await this.runMigrations(stage, {
138
+ rootDir: process.cwd(),
139
+ dbmateArgs: ["--wait", "--no-dump-schema", "migrate"],
140
+ readOnly: true,
141
+ });
142
+ }
143
+
144
+ async getConnectionInfo(
145
+ stage: string,
146
+ target: PostgresConnectionTarget = "host",
147
+ ): Promise<PostgresConnectionInfo> {
148
+ const password = await this.resolvePassword(stage);
149
+ const host = target === "container" ? this.getContainerName(stage) : this.getHost(stage);
150
+ const port = target === "container" ? "5432" : String(this.getHostPort(stage));
151
+ const database = this.database ?? this.defaultDatabaseName(stage);
152
+ const connection = {
153
+ host,
154
+ port,
155
+ username: this.username,
156
+ password,
157
+ database,
158
+ };
159
+
160
+ return {
161
+ ...connection,
162
+ url: this.toDatabaseUrl(connection),
163
+ };
164
+ }
165
+
166
+ toDatabaseUrl(connection: Omit<PostgresConnectionInfo, "url">) {
167
+ return withSslModeDisabled(
168
+ `postgresql://${encodeURIComponent(connection.username)}:${encodeURIComponent(
169
+ connection.password,
170
+ )}@${connection.host}:${connection.port}/${connection.database}`,
171
+ );
172
+ }
173
+
174
+ protected override async getContainerEnvironment(stage: string): Promise<Record<string, string>> {
175
+ const connection = await this.getConnectionInfo(stage, "container");
176
+
177
+ return {
178
+ ...(await super.getContainerEnvironment(stage)),
179
+ POSTGRES_USER: connection.username,
180
+ POSTGRES_PASSWORD: connection.password,
181
+ POSTGRES_DB: connection.database,
182
+ };
183
+ }
184
+
185
+ protected override async getDockerRunConfig(stage: string, deploy: boolean) {
186
+ const config = await super.getDockerRunConfig(stage, deploy);
187
+
188
+ return {
189
+ ...config,
190
+ volumes: [`${this.getVolumeName(stage)}:${POSTGRES_VOLUME_DIRECTORY}`],
191
+ ports: deploy && this.port == null ? [] : [`${this.getHostPort(stage)}:5432`],
192
+ command: this.walEnabled ? ["postgres", "-c", "wal_level=logical"] : config.command,
193
+ };
194
+ }
195
+
196
+ protected override async onContainerStarted(stage: string) {
197
+ await this.setOutputs(this.toOutputs(await this.getConnectionInfo(stage, "host")), stage);
198
+ }
199
+
200
+ async runMigrations(
201
+ stage: string,
202
+ options: {
203
+ rootDir: string;
204
+ dbmateArgs: string[];
205
+ readOnly?: boolean;
206
+ dryRun?: boolean;
207
+ },
208
+ ) {
209
+ const sourceDirectory = path.join(options.rootDir, this.name, "migrations");
210
+ let migrationFiles: string[];
211
+ try {
212
+ const result = await stat(sourceDirectory);
213
+ if (!result.isDirectory()) {
214
+ throw new Error("No migrations");
215
+ }
216
+ migrationFiles = await listFiles(sourceDirectory);
217
+ } catch {
218
+ // no migrations to run
219
+ return;
220
+ }
221
+ if (migrationFiles.length === 0) return;
222
+
223
+ this.writeMigrationLog(
224
+ `${options.dryRun ? "Dry run" : "Run"} migrations for ${this.name} (${stage})\n`,
225
+ );
226
+
227
+ if (stage === "local") {
228
+ await this.runLocalMigrations(stage, {
229
+ rootDir: options.rootDir,
230
+ sourceDirectory,
231
+ dbmateArgs: options.dbmateArgs,
232
+ dryRun: options.dryRun,
233
+ });
234
+ this.writeMigrationLog(`Finished migrations for ${this.name} (${stage})\n`);
235
+ return;
236
+ }
237
+
238
+ const relativeDirectory = `${this.name}/migrations`;
239
+ const remoteDirectory = path.posix.join(this.getAppDirectory(stage), relativeDirectory);
240
+ const runtimeFiles: RuntimeFile[] = [];
241
+
242
+ await this.host.exec(
243
+ [`rm -rf ${shellQuote(remoteDirectory)}`, `mkdir -p ${shellQuote(remoteDirectory)}`].join(
244
+ "\n",
245
+ ),
246
+ { dryRun: options.dryRun },
247
+ );
248
+
249
+ try {
250
+ for (const file of migrationFiles) {
251
+ const relativeFile = path
252
+ .relative(sourceDirectory, file)
253
+ .split(path.sep)
254
+ .join(path.posix.sep);
255
+ runtimeFiles.push(
256
+ await this.writeRemoteRuntimeFile(
257
+ stage,
258
+ path.posix.join(relativeDirectory, relativeFile),
259
+ await readFile(file, "utf8"),
260
+ options.dryRun,
261
+ ),
262
+ );
263
+ }
264
+
265
+ await this.runMigrationContainer(stage, {
266
+ migrationVolume: `${remoteDirectory}:/db/migrations${options.readOnly ? ":ro" : ""}`,
267
+ dbmateArgs: options.dbmateArgs,
268
+ dryRun: options.dryRun,
269
+ });
270
+ this.writeMigrationLog(`Finished migrations for ${this.name} (${stage})\n`);
271
+ } finally {
272
+ for (const runtimeFile of runtimeFiles.reverse()) {
273
+ await this.removeRemoteRuntimeFile(runtimeFile, options.dryRun);
274
+ }
275
+ await this.host.exec(`rm -rf ${shellQuote(remoteDirectory)}`, { dryRun: options.dryRun });
276
+ }
277
+ }
278
+
279
+ private async runMigrationContainer(
280
+ stage: string,
281
+ options: { migrationVolume: string; dbmateArgs: string[]; dryRun?: boolean },
282
+ ) {
283
+ const connection = await this.getConnectionInfo(stage, "container");
284
+ const config: DockerRunConfig = {
285
+ name: `${this.getContainerName(stage)}-migrations`,
286
+ image: this.migrationImage,
287
+ network: this.getNetwork(stage),
288
+ pull: true,
289
+ env: {
290
+ DATABASE_URL: connection.url,
291
+ DBMATE_MIGRATIONS_DIR: "/db/migrations",
292
+ },
293
+ volumes: [options.migrationVolume],
294
+ command: options.dbmateArgs,
295
+ healthCheck: false,
296
+ labels: {
297
+ "saws.service": this.name,
298
+ "saws.serviceType": this.serviceType,
299
+ "saws.stage": stage,
300
+ "saws.task": "postgres-migrations",
301
+ },
302
+ };
303
+
304
+ await this.runEphemeralContainer(stage, config, {
305
+ dryRun: options.dryRun,
306
+ logServiceName: this.migrationLogServiceName,
307
+ });
308
+ }
309
+
310
+ private async runLocalMigrations(
311
+ stage: string,
312
+ options: { rootDir: string; sourceDirectory: string; dbmateArgs: string[]; dryRun?: boolean },
313
+ ) {
314
+ const connection = await this.getConnectionInfo(stage, "host");
315
+ const command = [
316
+ `DATABASE_URL=${shellQuote(connection.url)}`,
317
+ `DBMATE_MIGRATIONS_DIR=${shellQuote(options.sourceDirectory)}`,
318
+ shellQuote(path.resolve(options.rootDir, "node_modules", ".bin", "dbmate")),
319
+ ...options.dbmateArgs.map(shellQuote),
320
+ ].join(" ");
321
+
322
+ await runLocal(command, {
323
+ dryRun: options.dryRun,
324
+ logSink: this.getRuntimeLogSink(),
325
+ serviceName: this.migrationLogServiceName,
326
+ });
327
+ }
328
+
329
+ private writeMigrationLog(chunk: string, stream: "stdout" | "stderr" = "stdout") {
330
+ const sink = this.getRuntimeLogSink();
331
+ if (sink == null) {
332
+ this.writeRuntimeLog(chunk, stream);
333
+ return;
334
+ }
335
+
336
+ sink({
337
+ serviceName: this.migrationLogServiceName,
338
+ stream,
339
+ chunk,
340
+ timestamp: new Date(),
341
+ });
342
+ }
343
+
344
+ private get migrationLogServiceName() {
345
+ return `${this.name}--migrations`;
346
+ }
347
+
348
+ private get environmentVariablePrefix() {
349
+ return this.name.replace(/[^a-zA-Z\d]/g, "_").toUpperCase();
350
+ }
351
+
352
+ private getHost(stage: string) {
353
+ return stage === "local" ? "localhost" : this.getContainerName(stage);
354
+ }
355
+
356
+ private getHostPort(_stage: string) {
357
+ return this.port ?? 5432;
358
+ }
359
+
360
+ private getVolumeName(stage: string) {
361
+ return this.volume ?? `${stage}-${this.name}-postgres-data`.replaceAll("_", "-").toLowerCase();
362
+ }
363
+
364
+ private defaultDatabaseName(stage: string) {
365
+ return `${stage}_${this.name}`.replaceAll("-", "_").toLowerCase();
366
+ }
367
+
368
+ private toOutputs(connection: PostgresConnectionInfo): Outputs {
369
+ return {
370
+ postgresHost: connection.host,
371
+ postgresPort: connection.port,
372
+ postgresUsername: connection.username,
373
+ postgresPassword: connection.password,
374
+ postgresDBName: connection.database,
375
+ databaseUrl: connection.url,
376
+ };
377
+ }
378
+
379
+ private async getOrCreateManagedPassword(stage: string) {
380
+ const manager = new SecretsManager({ stage });
381
+ const secretName = `${this.name}-postgres-password`;
382
+
383
+ try {
384
+ return await manager.get(secretName);
385
+ } catch (error) {
386
+ if ((error as Error).name !== "ParameterNotFound") throw error;
387
+ }
388
+
389
+ const password = randomBytes(24).toString("base64url");
390
+ await manager.set(secretName, password);
391
+ return password;
392
+ }
393
+
394
+ private async resolvePassword(stage: string) {
395
+ return this.password == null
396
+ ? this.getOrCreateManagedPassword(stage)
397
+ : this.password.resolve({ stage });
398
+ }
399
+ }
400
+
401
+ function isPostgresService(service: ServiceDefinition): service is PostgresService {
402
+ return service instanceof PostgresService;
403
+ }
404
+
405
+ function withSslModeDisabled(databaseUrl: string) {
406
+ const url = new URL(databaseUrl);
407
+ if (!url.searchParams.has("sslmode")) {
408
+ url.searchParams.set("sslmode", "disable");
409
+ }
410
+ return url.toString();
411
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./PostgresService.js";
@@ -0,0 +1,64 @@
1
+ import path from "node:path";
2
+ import { Command } from "commander";
3
+ import type { PostgresService } from "./PostgresService.js";
4
+
5
+ export interface MigrateCommandOptions {
6
+ rootDir?: string;
7
+ service?: string;
8
+ stage?: string;
9
+ dryRun?: boolean;
10
+ }
11
+
12
+ export const createMigrateCommand = (services: PostgresService[]) =>
13
+ new Command("migrate")
14
+ .description("run dbmate commands in an ephemeral Docker container")
15
+ .argument("[dbmateArgs...]", "arguments passed to dbmate")
16
+ .option("--root-dir <path>", "project root containing the service migrations directory")
17
+ .option("--service <name>", "Postgres service whose migrations and database URL to use")
18
+ .option("--stage <stage>", "SAWS stage to migrate", "local")
19
+ .option("--dry-run", "print the dbmate command without running it")
20
+ .helpOption(false)
21
+ .allowUnknownOption()
22
+ .action((dbmateArgs: string[], options: MigrateCommandOptions) =>
23
+ migrateCommand(services, dbmateArgs, options),
24
+ );
25
+
26
+ export async function migrateCommand(
27
+ services: PostgresService[],
28
+ dbmateArgs: string[],
29
+ options: MigrateCommandOptions,
30
+ ) {
31
+ const service = resolveService(options.service, services);
32
+ const stage = options.stage ?? "local";
33
+ const rootDir = path.resolve(options.rootDir ?? process.cwd());
34
+ const args = dbmateArgs.length === 0 ? ["--wait", "--no-dump-schema", "migrate"] : dbmateArgs;
35
+
36
+ await service.runMigrations(stage, {
37
+ rootDir,
38
+ dbmateArgs: args,
39
+ dryRun: options.dryRun,
40
+ });
41
+ }
42
+
43
+ function resolveService(requestedService: string | undefined, services: PostgresService[]) {
44
+ if (requestedService != null) {
45
+ const service = services.find((candidate) => candidate.name === requestedService);
46
+ if (service != null) return service;
47
+ throw new Error(
48
+ `Postgres service "${requestedService}" was not found. Available services: ${services
49
+ .map((candidate) => candidate.name)
50
+ .join(", ")}`,
51
+ );
52
+ }
53
+
54
+ if (services.length === 1) return services[0]!;
55
+ if (services.length === 0) {
56
+ throw new Error("No Postgres services are configured.");
57
+ }
58
+
59
+ throw new Error(
60
+ `Multiple Postgres services are configured. Select one with --service: ${services
61
+ .map((service) => service.name)
62
+ .join(", ")}`,
63
+ );
64
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig-node.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "tsBuildInfoFile": "./dist/.tsbuildinfo"
7
+ },
8
+ "references": [{ "path": "../core/tsconfig.json" }, { "path": "../docker-service/tsconfig.json" }]
9
+ }