UNPKG

@electric-sql/pglite-socket

Version:

A socket implementation for PGlite enabling remote connections

537 lines (455 loc) 15.1 kB
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, } from 'vitest' import { Client } from 'pg' import { PGlite } from '@electric-sql/pglite' import { PGLiteSocketServer } from '../src' /** * Debug configuration for testing * * To test against a real PostgreSQL server: * - Set DEBUG_TESTS=true as an environment variable * - Optionally set DEBUG_TESTS_REAL_SERVER with a connection URL (defaults to localhost) * * Example: * DEBUG_TESTS=true DEBUG_TESTS_REAL_SERVER=postgres://user:pass@host:port/db npm vitest ./tests/query-with-node-pg.test.ts */ const DEBUG_TESTS = process.env.DEBUG_TESTS === 'true' const DEBUG_TESTS_REAL_SERVER = process.env.DEBUG_TESTS_REAL_SERVER || 'postgres://postgres:postgres@localhost:5432/postgres' const TEST_PORT = 5434 describe(`PGLite Socket Server`, () => { describe('with node-pg client', () => { let db: PGlite let server: PGLiteSocketServer let client: typeof Client.prototype let connectionConfig: any beforeAll(async () => { if (DEBUG_TESTS) { console.log('TESTING WITH REAL POSTGRESQL SERVER') console.log(`Connection URL: ${DEBUG_TESTS_REAL_SERVER}`) } else { console.log('TESTING WITH PGLITE SERVER') // Create a PGlite instance db = await PGlite.create() // Wait for database to be ready await db.waitReady console.log('PGLite database ready') // Create and start the server with explicit host server = new PGLiteSocketServer({ db, port: TEST_PORT, host: '127.0.0.1', }) // Add event listeners for debugging server.addEventListener('error', (event) => { console.error('Socket server error:', (event as CustomEvent).detail) }) server.addEventListener('connection', (event) => { console.log( 'Socket connection received:', (event as CustomEvent).detail, ) }) await server.start() console.log(`PGLite Socket Server started on port ${TEST_PORT}`) connectionConfig = { host: '127.0.0.1', port: TEST_PORT, database: 'postgres', user: 'postgres', password: 'postgres', // Connection timeout in milliseconds connectionTimeoutMillis: 10000, // Query timeout in milliseconds statement_timeout: 5000, } } }) afterAll(async () => { if (!DEBUG_TESTS) { // Stop server if running if (server) { await server.stop() console.log('PGLite Socket Server stopped') } // Close database if (db) { await db.close() console.log('PGLite database closed') } } }) beforeEach(async () => { // Create pg client instance before each test if (DEBUG_TESTS) { // Direct connection to real PostgreSQL server using URL client = new Client({ connectionString: DEBUG_TESTS_REAL_SERVER, connectionTimeoutMillis: 10000, statement_timeout: 5000, }) } else { // Connection to PGLite Socket Server client = new Client(connectionConfig) } // Connect the client await client.connect() }) afterEach(async () => { // Clean up any tables created in tests try { await client.query('DROP TABLE IF EXISTS test_users') } catch (e) { console.error('Error cleaning up tables:', e) } // Disconnect the client after each test if (client) { await client.end() } }) it('should execute a basic SELECT query', async () => { const result = await client.query('SELECT 1 as one') expect(result.rows[0].one).toBe(1) }) it('should create a table', async () => { await client.query(` CREATE TABLE test_users ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) `) // Verify table exists by querying the schema const tableCheck = await client.query(` SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'test_users' `) expect(tableCheck.rows.length).toBe(1) expect(tableCheck.rows[0].table_name).toBe('test_users') }) it('should insert rows into a table', async () => { // Create table await client.query(` CREATE TABLE test_users ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT ) `) // Insert data const insertResult = await client.query(` INSERT INTO test_users (name, email) VALUES ('Alice', 'alice@example.com'), ('Bob', 'bob@example.com') RETURNING * `) expect(insertResult.rows.length).toBe(2) expect(insertResult.rows[0].name).toBe('Alice') expect(insertResult.rows[1].name).toBe('Bob') // Verify data is there const count = await client.query( 'SELECT COUNT(*)::int as count FROM test_users', ) expect(count.rows[0].count).toBe(2) }) it('should update rows in a table', async () => { // Create and populate table await client.query(` CREATE TABLE test_users ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT ) `) await client.query(` INSERT INTO test_users (name, email) VALUES ('Alice', 'alice@example.com') `) // Update const updateResult = await client.query(` UPDATE test_users SET email = 'alice.new@example.com' WHERE name = 'Alice' RETURNING * `) expect(updateResult.rows.length).toBe(1) expect(updateResult.rows[0].email).toBe('alice.new@example.com') }) it('should delete rows from a table', async () => { // Create and populate table await client.query(` CREATE TABLE test_users ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT ) `) await client.query(` INSERT INTO test_users (name, email) VALUES ('Alice', 'alice@example.com'), ('Bob', 'bob@example.com') `) // Delete const deleteResult = await client.query(` DELETE FROM test_users WHERE name = 'Alice' RETURNING * `) expect(deleteResult.rows.length).toBe(1) expect(deleteResult.rows[0].name).toBe('Alice') // Verify only Bob remains const remaining = await client.query('SELECT * FROM test_users') expect(remaining.rows.length).toBe(1) expect(remaining.rows[0].name).toBe('Bob') }) it('should execute operations in a transaction', async () => { // Create table await client.query(` CREATE TABLE test_users ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, balance INTEGER DEFAULT 0 ) `) // Insert initial data await client.query(` INSERT INTO test_users (name, balance) VALUES ('Alice', 100), ('Bob', 50) `) // Start a transaction and perform operations await client.query('BEGIN') try { // Deduct from Alice await client.query(` UPDATE test_users SET balance = balance - 30 WHERE name = 'Alice' `) // Add to Bob await client.query(` UPDATE test_users SET balance = balance + 30 WHERE name = 'Bob' `) // Commit the transaction await client.query('COMMIT') } catch (error) { // Rollback on error await client.query('ROLLBACK') throw error } // Verify both operations succeeded const users = await client.query( 'SELECT name, balance FROM test_users ORDER BY name', ) expect(users.rows.length).toBe(2) expect(users.rows[0].name).toBe('Alice') expect(users.rows[0].balance).toBe(70) expect(users.rows[1].name).toBe('Bob') expect(users.rows[1].balance).toBe(80) }) it('should rollback a transaction on ROLLBACK', async () => { // Create table await client.query(` CREATE TABLE test_users ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, balance INTEGER DEFAULT 0 ) `) // Insert initial data await client.query(` INSERT INTO test_users (name, balance) VALUES ('Alice', 100), ('Bob', 50) `) // Get initial balance const initialResult = await client.query(` SELECT balance FROM test_users WHERE name = 'Alice' `) const initialBalance = initialResult.rows[0].balance // Start a transaction await client.query('BEGIN') try { // Deduct from Alice await client.query(` UPDATE test_users SET balance = balance - 30 WHERE name = 'Alice' `) // Verify balance is changed within transaction const midResult = await client.query(` SELECT balance FROM test_users WHERE name = 'Alice' `) expect(midResult.rows[0].balance).toBe(70) // Explicitly roll back (cancel) the transaction await client.query('ROLLBACK') } catch (error) { await client.query('ROLLBACK') throw error } // Verify balance wasn't changed after rollback const finalResult = await client.query(` SELECT balance FROM test_users WHERE name = 'Alice' `) expect(finalResult.rows[0].balance).toBe(initialBalance) }) it('should rollback a transaction on error', async () => { // Create table await client.query(` CREATE TABLE test_users ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, balance INTEGER DEFAULT 0 ) `) // Insert initial data await client.query(` INSERT INTO test_users (name, balance) VALUES ('Alice', 100), ('Bob', 50) `) try { // Start a transaction await client.query('BEGIN') // Deduct from Alice await client.query(` UPDATE test_users SET balance = balance - 30 WHERE name = 'Alice' `) // This will trigger an error await client.query(` UPDATE test_users_nonexistent SET balance = balance + 30 WHERE name = 'Bob' `) // Should never get here await client.query('COMMIT') } catch (error) { // Expected to fail - rollback transaction await client.query('ROLLBACK').catch(() => { // If the client connection is in a bad state, we just ignore // the rollback error }) } // Verify Alice's balance was not changed due to rollback const users = await client.query( 'SELECT name, balance FROM test_users ORDER BY name', ) expect(users.rows.length).toBe(2) expect(users.rows[0].name).toBe('Alice') expect(users.rows[0].balance).toBe(100) // Should remain 100 after rollback }) it('should handle a syntax error', async () => { // Expect syntax error let errorMessage = '' try { await client.query('THIS IS NOT VALID SQL;') } catch (error) { errorMessage = (error as Error).message } expect(errorMessage).not.toBe('') expect(errorMessage.toLowerCase()).toContain('syntax error') }) it('should support cursor-based pagination', async () => { // Create a test table with many rows await client.query(` CREATE TABLE test_users ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, value INTEGER ) `) // Insert 100 rows using generate_series (server-side generation) await client.query(` INSERT INTO test_users (name, value) SELECT 'User ' || i as name, i as value FROM generate_series(1, 100) as i `) // Use a cursor to read data in smaller chunks const chunkSize = 10 let results: any[] = [] let page = 0 try { // Begin transaction await client.query('BEGIN') // Declare a cursor await client.query( 'DECLARE user_cursor CURSOR FOR SELECT * FROM test_users ORDER BY id', ) let hasMoreData = true while (hasMoreData) { // Fetch a batch of results const chunk = await client.query('FETCH 10 FROM user_cursor') // If no rows returned, we're done if (chunk.rows.length === 0) { hasMoreData = false continue } // Process this chunk page++ // Add to our results array results = [...results, ...chunk.rows] // Verify each chunk has correct data (except possibly the last one) if (chunk.rows.length === chunkSize) { expect(chunk.rows.length).toBe(chunkSize) expect(chunk.rows[0].id).toBe((page - 1) * chunkSize + 1) } } // Close the cursor await client.query('CLOSE user_cursor') // Commit transaction await client.query('COMMIT') } catch (error) { await client.query('ROLLBACK') throw error } // Verify we got all 100 records expect(results.length).toBe(100) expect(results[0].name).toBe('User 1') expect(results[99].name).toBe('User 100') // Verify we received the expected number of pages expect(page).toBe(Math.ceil(100 / chunkSize)) }) it('should support LISTEN/NOTIFY for pub/sub messaging', async () => { // Set up listener for notifications let receivedPayload = '' const notificationReceived = new Promise<void>((resolve) => { client.on('notification', (msg) => { receivedPayload = msg.payload || '' resolve() }) }) // Start listening await client.query('LISTEN test_channel') // Small delay to ensure listener is set up await new Promise((resolve) => setTimeout(resolve, 100)) // Send a notification await client.query("NOTIFY test_channel, 'Hello from PGlite!'") // Wait for the notification to be received with an appropriate timeout const timeoutPromise = new Promise<void>((_, reject) => { setTimeout(() => reject(new Error('Notification timeout')), 2000) }) await Promise.race([notificationReceived, timeoutPromise]).catch( (error) => { console.error('Notification error:', error) }, ) // Verify the notification was received with the correct payload expect(receivedPayload).toBe('Hello from PGlite!') }) }) })