pgsql-test 0.0.1 → 2.0.1

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/esm/admin.js ADDED
@@ -0,0 +1,126 @@
1
+ import { execSync } from 'child_process';
2
+ import { getPgEnvOptions } from '@launchql/types';
3
+ import { existsSync } from 'fs';
4
+ import { streamSql as stream } from './stream';
5
+ export class DbAdmin {
6
+ config;
7
+ verbose;
8
+ constructor(config, verbose = false) {
9
+ this.config = config;
10
+ this.verbose = verbose;
11
+ this.config = getPgEnvOptions(config);
12
+ }
13
+ getEnv() {
14
+ return {
15
+ PGHOST: this.config.host,
16
+ PGPORT: String(this.config.port),
17
+ PGUSER: this.config.user,
18
+ PGPASSWORD: this.config.password,
19
+ };
20
+ }
21
+ run(command) {
22
+ execSync(command, {
23
+ stdio: this.verbose ? 'inherit' : 'pipe',
24
+ env: {
25
+ ...process.env,
26
+ ...this.getEnv(),
27
+ },
28
+ });
29
+ }
30
+ safeDropDb(name) {
31
+ try {
32
+ this.run(`dropdb "${name}"`);
33
+ }
34
+ catch (err) {
35
+ const message = err instanceof Error ? err.message : String(err);
36
+ if (!message.includes('does not exist')) {
37
+ console.warn(`⚠️ Could not drop database ${name}: ${message}`);
38
+ }
39
+ }
40
+ }
41
+ drop(dbName) {
42
+ this.safeDropDb(dbName ?? this.config.database);
43
+ }
44
+ dropTemplate(dbName) {
45
+ this.run(`psql -c "UPDATE pg_database SET datistemplate='false' WHERE datname='${dbName}';"`);
46
+ this.drop(dbName);
47
+ }
48
+ create(dbName) {
49
+ const db = dbName ?? this.config.database;
50
+ this.run(`createdb -U ${this.config.user} -h ${this.config.host} -p ${this.config.port} "${db}"`);
51
+ }
52
+ createFromTemplate(template, dbName) {
53
+ const db = dbName ?? this.config.database;
54
+ this.run(`createdb -U ${this.config.user} -h ${this.config.host} -p ${this.config.port} -e "${db}" -T "${template}"`);
55
+ }
56
+ installExtensions(extensions, dbName) {
57
+ const db = dbName ?? this.config.database;
58
+ const extList = typeof extensions === 'string' ? extensions.split(',') : extensions;
59
+ for (const extension of extList) {
60
+ this.run(`psql --dbname "${db}" -c 'CREATE EXTENSION IF NOT EXISTS "${extension}" CASCADE;'`);
61
+ }
62
+ }
63
+ connectionString(dbName) {
64
+ const { user, password, host, port } = this.config;
65
+ const db = dbName ?? this.config.database;
66
+ return `postgres://${user}:${password}@${host}:${port}/${db}`;
67
+ }
68
+ createTemplateFromBase(base, template) {
69
+ this.run(`createdb -T "${base}" "${template}"`);
70
+ this.run(`psql -c "UPDATE pg_database SET datistemplate = true WHERE datname = '${template}';"`);
71
+ }
72
+ cleanupTemplate(template) {
73
+ try {
74
+ this.run(`psql -c "UPDATE pg_database SET datistemplate = false WHERE datname = '${template}'"`);
75
+ }
76
+ catch { }
77
+ this.safeDropDb(template);
78
+ }
79
+ async grantRole(role, user, dbName) {
80
+ const db = dbName ?? this.config.database;
81
+ const sql = `GRANT ${role} TO ${user};`;
82
+ await this.streamSql(sql, db);
83
+ }
84
+ async grantConnect(role, dbName) {
85
+ const db = dbName ?? this.config.database;
86
+ const sql = `GRANT CONNECT ON DATABASE "${db}" TO ${role};`;
87
+ await this.streamSql(sql, db);
88
+ }
89
+ async createUserRole(user, password, dbName) {
90
+ const sql = `
91
+ DO $$
92
+ BEGIN
93
+ IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${user}') THEN
94
+ CREATE ROLE ${user} LOGIN PASSWORD '${password}';
95
+ GRANT anonymous TO ${user};
96
+ GRANT authenticated TO ${user};
97
+ END IF;
98
+ END $$;
99
+ `.trim();
100
+ this.streamSql(sql, dbName);
101
+ }
102
+ loadSql(file, dbName) {
103
+ if (!existsSync(file)) {
104
+ throw new Error(`Missing SQL file: ${file}`);
105
+ }
106
+ this.run(`psql -f ${file} ${dbName}`);
107
+ }
108
+ async streamSql(sql, dbName) {
109
+ await stream({
110
+ ...this.config,
111
+ database: dbName
112
+ }, sql);
113
+ }
114
+ async createSeededTemplate(templateName, adapter) {
115
+ const seedDb = this.config.database;
116
+ this.create(seedDb);
117
+ await adapter.seed({
118
+ admin: this,
119
+ config: this.config,
120
+ pg: null // sorry!
121
+ });
122
+ this.cleanupTemplate(templateName);
123
+ this.createTemplateFromBase(seedDb, templateName);
124
+ this.drop(seedDb);
125
+ }
126
+ }
package/esm/connect.js ADDED
@@ -0,0 +1,90 @@
1
+ import { DbAdmin } from './admin';
2
+ import { getEnvOptions, getPgEnvOptions, getConnEnvOptions } from '@launchql/types';
3
+ import { deploy, deployFast, LaunchQLProject } from '@launchql/migrate';
4
+ import { PgTestConnector } from './manager';
5
+ import { randomUUID } from 'crypto';
6
+ import { teardownPgPools } from '@launchql/server-utils';
7
+ let manager;
8
+ export const getPgRootAdmin = (connOpts = {}) => {
9
+ const opts = getPgEnvOptions({
10
+ database: connOpts.rootDb
11
+ });
12
+ const admin = new DbAdmin(opts);
13
+ return admin;
14
+ };
15
+ const getConnOopts = (cn = {}) => {
16
+ const connect = getConnEnvOptions(cn.db);
17
+ const config = getPgEnvOptions({
18
+ database: `${connect.prefix}${randomUUID()}`,
19
+ ...cn.pg
20
+ });
21
+ return {
22
+ pg: config,
23
+ db: connect
24
+ };
25
+ };
26
+ export const getConnections = async (cn = {}, seedAdapter) => {
27
+ cn = getConnOopts(cn);
28
+ const config = cn.pg;
29
+ const connOpts = cn.db;
30
+ const root = getPgRootAdmin(connOpts);
31
+ await root.createUserRole(connOpts.connection.user, connOpts.connection.password, connOpts.rootDb);
32
+ const admin = new DbAdmin(config);
33
+ const proj = new LaunchQLProject(connOpts.cwd);
34
+ if (proj.isInModule()) {
35
+ admin.create(config.database);
36
+ admin.installExtensions(connOpts.extensions);
37
+ const opts = getEnvOptions({
38
+ pg: config
39
+ });
40
+ if (connOpts.deployFast) {
41
+ await deployFast({
42
+ opts,
43
+ name: proj.getModuleName(),
44
+ database: config.database,
45
+ dir: proj.modulePath,
46
+ usePlan: true,
47
+ verbose: false
48
+ });
49
+ }
50
+ else {
51
+ await deploy(opts, proj.getModuleName(), config.database, proj.modulePath);
52
+ }
53
+ }
54
+ else {
55
+ // Create the test database
56
+ if (process.env.TEST_DB) {
57
+ config.database = process.env.TEST_DB;
58
+ }
59
+ else if (connOpts.template) {
60
+ admin.createFromTemplate(connOpts.template, config.database);
61
+ }
62
+ else {
63
+ admin.create(config.database);
64
+ admin.installExtensions(connOpts.extensions);
65
+ }
66
+ }
67
+ await admin.grantConnect(connOpts.connection.user, config.database);
68
+ // Main admin client (optional unless needed elsewhere)
69
+ manager = PgTestConnector.getInstance();
70
+ const pg = manager.getClient(config);
71
+ if (seedAdapter) {
72
+ await seedAdapter.seed({
73
+ admin,
74
+ config: config,
75
+ pg: manager.getClient(config)
76
+ });
77
+ }
78
+ // App user connection
79
+ const db = manager.getClient({
80
+ ...config,
81
+ user: connOpts.connection.user,
82
+ password: connOpts.connection.password
83
+ });
84
+ db.setContext({ role: 'anonymous' });
85
+ const teardown = async () => {
86
+ await teardownPgPools();
87
+ await manager.closeAll();
88
+ };
89
+ return { pg, db, teardown, manager, admin };
90
+ };
@@ -1,2 +1,2 @@
1
1
  export * from './legacy-connect';
2
- export * from './utils';
2
+ export * from './utils';
@@ -0,0 +1,25 @@
1
+ import { PgTestConnector } from './manager';
2
+ import { getPgEnvOptions } from '@launchql/types';
3
+ export function connect(config) {
4
+ const manager = PgTestConnector.getInstance();
5
+ return manager.getClient(config);
6
+ }
7
+ export function close(client) {
8
+ client.close();
9
+ }
10
+ const manager = PgTestConnector.getInstance();
11
+ export const Connection = {
12
+ connect(config) {
13
+ const creds = getPgEnvOptions(config);
14
+ return manager.getClient(creds);
15
+ },
16
+ close(client) {
17
+ client.close();
18
+ },
19
+ closeAll() {
20
+ return manager.closeAll();
21
+ },
22
+ getManager() {
23
+ return manager;
24
+ }
25
+ };
package/esm/manager.js ADDED
@@ -0,0 +1,117 @@
1
+ import { Pool } from 'pg';
2
+ import chalk from 'chalk';
3
+ import { DbAdmin } from './admin';
4
+ import { getPgEnvOptions } from '@launchql/types';
5
+ import { PgTestClient } from './test-client';
6
+ const SYS_EVENTS = ['SIGTERM'];
7
+ const end = (pool) => {
8
+ try {
9
+ if (pool.ended || pool.ending) {
10
+ console.warn(chalk.yellow('⚠️ pg pool already ended or ending'));
11
+ return;
12
+ }
13
+ pool.end();
14
+ }
15
+ catch (err) {
16
+ console.error(chalk.red('❌ pg pool termination error:'), err);
17
+ }
18
+ };
19
+ export class PgTestConnector {
20
+ static instance;
21
+ clients = new Set();
22
+ pgPools = new Map();
23
+ seenDbConfigs = new Map();
24
+ verbose = false;
25
+ constructor(verbose = false) {
26
+ this.verbose = verbose;
27
+ SYS_EVENTS.forEach((event) => {
28
+ process.on(event, () => {
29
+ this.log(chalk.magenta(`⏹ Received ${event}, closing all connections...`));
30
+ this.closeAll();
31
+ });
32
+ });
33
+ }
34
+ static getInstance(verbose = false) {
35
+ if (!PgTestConnector.instance) {
36
+ PgTestConnector.instance = new PgTestConnector(verbose);
37
+ }
38
+ return PgTestConnector.instance;
39
+ }
40
+ log(...args) {
41
+ if (this.verbose)
42
+ console.log(...args);
43
+ }
44
+ poolKey(config) {
45
+ return `${config.user}@${config.host}:${config.port}/${config.database}`;
46
+ }
47
+ dbKey(config) {
48
+ return `${config.host}:${config.port}/${config.database}`;
49
+ }
50
+ getPool(config) {
51
+ const key = this.poolKey(config);
52
+ if (!this.pgPools.has(key)) {
53
+ const pool = new Pool(config);
54
+ this.pgPools.set(key, pool);
55
+ this.log(chalk.blue(`📘 Created new pg pool: ${chalk.white(key)}`));
56
+ }
57
+ return this.pgPools.get(key);
58
+ }
59
+ getClient(config) {
60
+ const client = new PgTestClient(config);
61
+ this.clients.add(client);
62
+ const key = this.dbKey(config);
63
+ this.seenDbConfigs.set(key, config);
64
+ this.log(chalk.green(`🔌 New PgTestClient connected to ${config.database}`));
65
+ return client;
66
+ }
67
+ async closeAll() {
68
+ this.log(chalk.cyan('\n🧹 Closing all PgTestClients...'));
69
+ await Promise.all(Array.from(this.clients).map(async (client) => {
70
+ try {
71
+ await client.close();
72
+ this.log(chalk.green(`✅ Closed client for ${client.config.database}`));
73
+ }
74
+ catch (err) {
75
+ console.warn(chalk.red(`❌ Error closing PgTestClient for ${client.config.database}:`), err);
76
+ }
77
+ }));
78
+ this.clients.clear();
79
+ this.log(chalk.cyan('\n🧯 Disposing pg pools...'));
80
+ for (const [key, pool] of this.pgPools.entries()) {
81
+ this.log(chalk.gray(`🧯 Disposing pg pool [${key}]`));
82
+ end(pool);
83
+ }
84
+ this.pgPools.clear();
85
+ this.log(chalk.cyan('\n🗑️ Dropping seen databases...'));
86
+ await Promise.all(Array.from(this.seenDbConfigs.values()).map(async (config) => {
87
+ try {
88
+ // somehow an "admin" db had app_user creds?
89
+ const rootPg = getPgEnvOptions();
90
+ const admin = new DbAdmin({ ...config, user: rootPg.user, password: rootPg.password }, this.verbose);
91
+ // console.log(config);
92
+ admin.drop();
93
+ this.log(chalk.yellow(`🧨 Dropped database: ${chalk.white(config.database)}`));
94
+ }
95
+ catch (err) {
96
+ console.warn(chalk.red(`❌ Failed to drop database ${config.database}:`), err);
97
+ }
98
+ }));
99
+ this.seenDbConfigs.clear();
100
+ this.log(chalk.green('\n✅ All PgTestClients closed, pools disposed, databases dropped.'));
101
+ }
102
+ close() {
103
+ this.closeAll();
104
+ }
105
+ drop(config) {
106
+ const key = this.dbKey(config);
107
+ // for drop, no need for conn opts
108
+ const admin = new DbAdmin(config, this.verbose);
109
+ admin.drop();
110
+ this.log(chalk.red(`🧨 Dropped database: ${chalk.white(config.database)}`));
111
+ this.seenDbConfigs.delete(key);
112
+ }
113
+ kill(client) {
114
+ client.close();
115
+ this.drop(client.config);
116
+ }
117
+ }
package/esm/seed.js ADDED
@@ -0,0 +1,35 @@
1
+ function sqlfile(files) {
2
+ return {
3
+ seed(ctx) {
4
+ for (const file of files) {
5
+ ctx.admin.loadSql(file, ctx.config.database);
6
+ }
7
+ }
8
+ };
9
+ }
10
+ function fn(fn) {
11
+ return {
12
+ seed: fn
13
+ };
14
+ }
15
+ function csv(fn) {
16
+ throw new Error('not yet implemented');
17
+ return {
18
+ seed: fn
19
+ };
20
+ }
21
+ function compose(adapters) {
22
+ return {
23
+ async seed(ctx) {
24
+ for (const adapter of adapters) {
25
+ await adapter.seed(ctx);
26
+ }
27
+ }
28
+ };
29
+ }
30
+ export const seed = {
31
+ compose,
32
+ fn,
33
+ csv,
34
+ sqlfile
35
+ };
package/esm/stream.js ADDED
@@ -0,0 +1,43 @@
1
+ import { spawn } from 'child_process';
2
+ import { Readable } from 'stream';
3
+ import { getSpawnEnvWithPg } from '@launchql/types';
4
+ function setArgs(config) {
5
+ const args = [
6
+ '-U', config.user,
7
+ '-h', config.host,
8
+ '-d', config.database
9
+ ];
10
+ if (config.port) {
11
+ args.push('-p', String(config.port));
12
+ }
13
+ return args;
14
+ }
15
+ // Converts a string to a readable stream (replaces streamify-string)
16
+ function stringToStream(text) {
17
+ const stream = new Readable({
18
+ read() {
19
+ this.push(text);
20
+ this.push(null);
21
+ }
22
+ });
23
+ return stream;
24
+ }
25
+ export async function streamSql(config, sql) {
26
+ const args = setArgs(config);
27
+ return new Promise((resolve, reject) => {
28
+ const sqlStream = stringToStream(sql);
29
+ const proc = spawn('psql', args, {
30
+ env: getSpawnEnvWithPg(config)
31
+ });
32
+ sqlStream.pipe(proc.stdin);
33
+ proc.on('close', (code) => {
34
+ resolve();
35
+ });
36
+ proc.on('error', (error) => {
37
+ reject(error);
38
+ });
39
+ proc.stderr.on('data', (data) => {
40
+ reject(new Error(data.toString()));
41
+ });
42
+ });
43
+ }
@@ -0,0 +1,88 @@
1
+ import { Client } from 'pg';
2
+ export class PgTestClient {
3
+ config;
4
+ client;
5
+ ctxStmts = '';
6
+ _ended = false;
7
+ constructor(config) {
8
+ this.config = config;
9
+ this.client = new Client({
10
+ host: this.config.host,
11
+ port: this.config.port,
12
+ database: this.config.database,
13
+ user: this.config.user,
14
+ password: this.config.password
15
+ });
16
+ this.client.connect();
17
+ }
18
+ close() {
19
+ if (!this._ended) {
20
+ this._ended = true;
21
+ this.client.end();
22
+ }
23
+ }
24
+ async begin() {
25
+ await this.client.query('BEGIN;');
26
+ }
27
+ async savepoint(name = 'lqlsavepoint') {
28
+ await this.client.query(`SAVEPOINT "${name}";`);
29
+ }
30
+ async rollback(name = 'lqlsavepoint') {
31
+ await this.client.query(`ROLLBACK TO SAVEPOINT "${name}";`);
32
+ }
33
+ async commit() {
34
+ await this.client.query('COMMIT;');
35
+ }
36
+ async beforeEach() {
37
+ await this.begin();
38
+ await this.savepoint();
39
+ }
40
+ async afterEach() {
41
+ await this.rollback();
42
+ await this.commit();
43
+ }
44
+ setContext(ctx) {
45
+ this.ctxStmts = Object.entries(ctx)
46
+ .map(([key, val]) => val === null
47
+ ? `SELECT set_config('${key}', NULL, true);`
48
+ : `SELECT set_config('${key}', '${val}', true);`)
49
+ .join('\n');
50
+ }
51
+ async any(query, values) {
52
+ const result = await this.query(query, values);
53
+ return result.rows;
54
+ }
55
+ async one(query, values) {
56
+ const rows = await this.any(query, values);
57
+ if (rows.length !== 1) {
58
+ throw new Error('Expected exactly one result');
59
+ }
60
+ return rows[0];
61
+ }
62
+ async oneOrNone(query, values) {
63
+ const rows = await this.any(query, values);
64
+ return rows[0] || null;
65
+ }
66
+ async many(query, values) {
67
+ const rows = await this.any(query, values);
68
+ if (rows.length === 0)
69
+ throw new Error('Expected many rows, got none');
70
+ return rows;
71
+ }
72
+ async manyOrNone(query, values) {
73
+ return this.any(query, values);
74
+ }
75
+ async none(query, values) {
76
+ await this.query(query, values);
77
+ }
78
+ async result(query, values) {
79
+ return this.query(query, values);
80
+ }
81
+ async query(query, values) {
82
+ if (this.ctxStmts) {
83
+ await this.client.query(this.ctxStmts);
84
+ }
85
+ const result = await this.client.query(query, values);
86
+ return result;
87
+ }
88
+ }
package/index.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export * from './legacy-connect';
2
+ export * from './utils';
package/index.js ADDED
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./legacy-connect"), exports);
18
+ __exportStar(require("./utils"), exports);
@@ -0,0 +1,11 @@
1
+ import { PgTestClient } from './test-client';
2
+ import { PgTestConnector } from './manager';
3
+ import { PgConfig } from '@launchql/types';
4
+ export declare function connect(config: PgConfig): PgTestClient;
5
+ export declare function close(client: PgTestClient): void;
6
+ export declare const Connection: {
7
+ connect(config: Partial<PgConfig>): PgTestClient;
8
+ close(client: PgTestClient): void;
9
+ closeAll(): Promise<void>;
10
+ getManager(): PgTestConnector;
11
+ };
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Connection = void 0;
4
+ exports.connect = connect;
5
+ exports.close = close;
6
+ const manager_1 = require("./manager");
7
+ const types_1 = require("@launchql/types");
8
+ function connect(config) {
9
+ const manager = manager_1.PgTestConnector.getInstance();
10
+ return manager.getClient(config);
11
+ }
12
+ function close(client) {
13
+ client.close();
14
+ }
15
+ const manager = manager_1.PgTestConnector.getInstance();
16
+ exports.Connection = {
17
+ connect(config) {
18
+ const creds = (0, types_1.getPgEnvOptions)(config);
19
+ return manager.getClient(creds);
20
+ },
21
+ close(client) {
22
+ client.close();
23
+ },
24
+ closeAll() {
25
+ return manager.closeAll();
26
+ },
27
+ getManager() {
28
+ return manager;
29
+ }
30
+ };
package/manager.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { Pool } from 'pg';
2
+ import { PgConfig } from '@launchql/types';
3
+ import { PgTestClient } from './test-client';
4
+ export declare class PgTestConnector {
5
+ private static instance;
6
+ private readonly clients;
7
+ private readonly pgPools;
8
+ private readonly seenDbConfigs;
9
+ private verbose;
10
+ private constructor();
11
+ static getInstance(verbose?: boolean): PgTestConnector;
12
+ private log;
13
+ private poolKey;
14
+ private dbKey;
15
+ getPool(config: PgConfig): Pool;
16
+ getClient(config: PgConfig): PgTestClient;
17
+ closeAll(): Promise<void>;
18
+ close(): void;
19
+ drop(config: PgConfig): void;
20
+ kill(client: PgTestClient): void;
21
+ }