singulio-postgres 1.1.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/src/health.ts ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * @singulio/postgres - Health Check Utilities
3
+ * Kubernetes readiness/liveness probes for PostgreSQL
4
+ */
5
+
6
+ import { sql } from 'bun';
7
+ import type { HealthCheckResult, Logger } from './types.js';
8
+ import type { PostgresPool } from './pool.js';
9
+
10
+ /**
11
+ * Simple health check - verifies database connectivity
12
+ */
13
+ export async function healthCheck(logger?: Logger): Promise<HealthCheckResult> {
14
+ const start = performance.now();
15
+
16
+ try {
17
+ const result = await sql`SELECT 1 as ok`;
18
+ const latencyMs = performance.now() - start;
19
+
20
+ const healthy = result.length > 0 && result[0].ok === 1;
21
+
22
+ logger?.debug('Health check completed', { healthy, latencyMs: latencyMs.toFixed(2) });
23
+
24
+ return {
25
+ healthy,
26
+ latencyMs,
27
+ };
28
+ } catch (error) {
29
+ const latencyMs = performance.now() - start;
30
+ const errorMessage = error instanceof Error ? error.message : String(error);
31
+
32
+ logger?.error('Health check failed', { error: errorMessage, latencyMs: latencyMs.toFixed(2) });
33
+
34
+ return {
35
+ healthy: false,
36
+ latencyMs,
37
+ error: errorMessage,
38
+ };
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Extended health check with pool statistics
44
+ */
45
+ export async function healthCheckWithPool(
46
+ pool: PostgresPool,
47
+ logger?: Logger
48
+ ): Promise<HealthCheckResult> {
49
+ const start = performance.now();
50
+
51
+ try {
52
+ // Query through pool
53
+ const result = await pool.query<{ ok: number }>('SELECT 1 as ok');
54
+ const latencyMs = performance.now() - start;
55
+
56
+ const healthy = result.rows.length > 0 && result.rows[0].ok === 1;
57
+ const poolStats = pool.stats();
58
+
59
+ logger?.debug('Pool health check completed', {
60
+ healthy,
61
+ latencyMs: latencyMs.toFixed(2),
62
+ ...poolStats,
63
+ });
64
+
65
+ return {
66
+ healthy,
67
+ latencyMs,
68
+ poolStats,
69
+ };
70
+ } catch (error) {
71
+ const latencyMs = performance.now() - start;
72
+ const errorMessage = error instanceof Error ? error.message : String(error);
73
+ const poolStats = pool.stats();
74
+
75
+ logger?.error('Pool health check failed', {
76
+ error: errorMessage,
77
+ latencyMs: latencyMs.toFixed(2),
78
+ ...poolStats,
79
+ });
80
+
81
+ return {
82
+ healthy: false,
83
+ latencyMs,
84
+ poolStats,
85
+ error: errorMessage,
86
+ };
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Deep health check - verifies database is accepting writes
92
+ */
93
+ export async function deepHealthCheck(logger?: Logger): Promise<HealthCheckResult> {
94
+ const start = performance.now();
95
+
96
+ try {
97
+ // Check we can read and write
98
+ await sql`
99
+ CREATE TEMP TABLE IF NOT EXISTS _health_check (
100
+ id serial PRIMARY KEY,
101
+ ts timestamptz DEFAULT NOW()
102
+ )
103
+ `;
104
+
105
+ await sql`INSERT INTO _health_check DEFAULT VALUES`;
106
+ const result = await sql`SELECT COUNT(*) as count FROM _health_check`;
107
+ await sql`DROP TABLE IF EXISTS _health_check`;
108
+
109
+ const latencyMs = performance.now() - start;
110
+ const healthy = result.length > 0 && (result[0].count as number) > 0;
111
+
112
+ logger?.debug('Deep health check completed', {
113
+ healthy,
114
+ latencyMs: latencyMs.toFixed(2),
115
+ });
116
+
117
+ return {
118
+ healthy,
119
+ latencyMs,
120
+ };
121
+ } catch (error) {
122
+ const latencyMs = performance.now() - start;
123
+ const errorMessage = error instanceof Error ? error.message : String(error);
124
+
125
+ // Cleanup on error
126
+ try {
127
+ await sql`DROP TABLE IF EXISTS _health_check`;
128
+ } catch {
129
+ // Ignore cleanup errors
130
+ }
131
+
132
+ logger?.error('Deep health check failed', {
133
+ error: errorMessage,
134
+ latencyMs: latencyMs.toFixed(2),
135
+ });
136
+
137
+ return {
138
+ healthy: false,
139
+ latencyMs,
140
+ error: errorMessage,
141
+ };
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Check PostgreSQL version
147
+ */
148
+ export async function getVersion(): Promise<string> {
149
+ const result = await sql`SELECT version()`;
150
+ return result[0]?.version ?? 'unknown';
151
+ }
152
+
153
+ /**
154
+ * Check if database is in recovery mode (replica)
155
+ */
156
+ export async function isInRecovery(): Promise<boolean> {
157
+ const result = await sql`SELECT pg_is_in_recovery() as in_recovery`;
158
+ return result[0]?.in_recovery === true;
159
+ }
160
+
161
+ /**
162
+ * Get database size
163
+ */
164
+ export async function getDatabaseSize(dbName?: string): Promise<string> {
165
+ const result = await sql.unsafe(
166
+ `SELECT pg_size_pretty(pg_database_size(${dbName ? '$1' : 'current_database()'}))::text as size`,
167
+ dbName ? [dbName] : []
168
+ );
169
+ return result[0]?.size ?? 'unknown';
170
+ }
171
+
172
+ /**
173
+ * Get connection count
174
+ */
175
+ export async function getConnectionCount(): Promise<{
176
+ active: number;
177
+ idle: number;
178
+ total: number;
179
+ maxConnections: number;
180
+ }> {
181
+ const result = await sql`
182
+ SELECT
183
+ COUNT(*) FILTER (WHERE state = 'active') as active,
184
+ COUNT(*) FILTER (WHERE state = 'idle') as idle,
185
+ COUNT(*) as total,
186
+ (SELECT setting::int FROM pg_settings WHERE name = 'max_connections') as max_connections
187
+ FROM pg_stat_activity
188
+ WHERE datname = current_database()
189
+ `;
190
+
191
+ return {
192
+ active: Number(result[0]?.active) || 0,
193
+ idle: Number(result[0]?.idle) || 0,
194
+ total: Number(result[0]?.total) || 0,
195
+ maxConnections: Number(result[0]?.max_connections) || 100,
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Create an HTTP health check handler for Bun.serve
201
+ */
202
+ export function createHealthHandler(pool?: PostgresPool) {
203
+ return async (req: Request): Promise<Response> => {
204
+ const url = new URL(req.url);
205
+
206
+ if (url.pathname === '/health' || url.pathname === '/healthz') {
207
+ const result = pool ? await healthCheckWithPool(pool) : await healthCheck();
208
+
209
+ return new Response(JSON.stringify(result), {
210
+ status: result.healthy ? 200 : 503,
211
+ headers: { 'Content-Type': 'application/json' },
212
+ });
213
+ }
214
+
215
+ if (url.pathname === '/ready' || url.pathname === '/readyz') {
216
+ const result = await deepHealthCheck();
217
+
218
+ return new Response(JSON.stringify(result), {
219
+ status: result.healthy ? 200 : 503,
220
+ headers: { 'Content-Type': 'application/json' },
221
+ });
222
+ }
223
+
224
+ return new Response('Not Found', { status: 404 });
225
+ };
226
+ }
package/src/index.ts ADDED
@@ -0,0 +1,110 @@
1
+ /**
2
+ * @singulio/postgres
3
+ * PostgreSQL 18.x client for Bun with RLS, pooling, transactions, and pgvector support
4
+ *
5
+ * A thin wrapper around bun:postgres native client providing:
6
+ * - Connection pooling with configurable limits
7
+ * - Row-Level Security (RLS) helpers for multi-tenant isolation
8
+ * - Transaction support with auto-rollback
9
+ * - pgvector extension for AI/ML embeddings
10
+ * - Kubernetes health check utilities
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * import { createClient, createPool, withTenant, vectorSearch } from '@singulio/postgres';
15
+ *
16
+ * // Simple client
17
+ * const client = createClient(process.env.DATABASE_URL);
18
+ * const users = await client.queryAll('SELECT * FROM users');
19
+ *
20
+ * // With RLS tenant context
21
+ * const tenantData = await withTenant({ tenantId: 'tenant-123' }, async () => {
22
+ * return client.queryAll('SELECT * FROM contacts'); // RLS filters by tenant
23
+ * });
24
+ *
25
+ * // Vector similarity search
26
+ * const similar = await vectorSearch('documents', 'embedding', queryVector, {
27
+ * operator: '<=>', // Cosine similarity
28
+ * limit: 10,
29
+ * });
30
+ * ```
31
+ */
32
+
33
+ // Types
34
+ export type {
35
+ PostgresConfig,
36
+ PoolConfig,
37
+ RLSContext,
38
+ QueryResult,
39
+ QueryRow,
40
+ FieldInfo,
41
+ IsolationLevel,
42
+ TransactionOptions,
43
+ HealthCheckResult,
44
+ PoolStats,
45
+ VectorDistanceOperator,
46
+ VectorIndexType,
47
+ VectorSearchOptions,
48
+ Logger,
49
+ } from './types.js';
50
+
51
+ // Client
52
+ export { PostgresClient, createClient } from './client.js';
53
+
54
+ // Pool
55
+ export { PostgresPool, createPool } from './pool.js';
56
+
57
+ // RLS
58
+ export {
59
+ setRLSContext,
60
+ clearRLSContext,
61
+ getRLSContext,
62
+ withTenant,
63
+ queryWithTenant,
64
+ RLSClient,
65
+ createRLSClient,
66
+ } from './rls.js';
67
+
68
+ // Transactions
69
+ export {
70
+ transaction,
71
+ serializableTransaction,
72
+ readOnlyTransaction,
73
+ savepoint,
74
+ rollbackToSavepoint,
75
+ releaseSavepoint,
76
+ type TransactionQuery,
77
+ } from './transaction.js';
78
+
79
+ // Vector (pgvector)
80
+ export {
81
+ formatVector,
82
+ parseVector,
83
+ vectorDimension,
84
+ normalizeVector,
85
+ getDistanceOperator,
86
+ getDistanceOperatorName,
87
+ ensureVectorExtension,
88
+ createVectorColumn,
89
+ createVectorIndex,
90
+ vectorSearch,
91
+ insertWithVector,
92
+ updateVector,
93
+ batchInsertWithVectors,
94
+ type Vector,
95
+ } from './vector.js';
96
+
97
+ // Health
98
+ export {
99
+ healthCheck,
100
+ healthCheckWithPool,
101
+ deepHealthCheck,
102
+ getVersion,
103
+ isInRecovery,
104
+ getDatabaseSize,
105
+ getConnectionCount,
106
+ createHealthHandler,
107
+ } from './health.js';
108
+
109
+ // Re-export bun:postgres sql for direct usage
110
+ export { sql } from 'bun';
package/src/pool.ts ADDED
@@ -0,0 +1,268 @@
1
+ /**
2
+ * @singulio/postgres - Connection Pool Manager
3
+ * Manages PostgreSQL connections with configurable pooling
4
+ *
5
+ * Note: Bun's native sql driver handles connection pooling internally.
6
+ * This module provides a higher-level API with explicit pool management
7
+ * for scenarios requiring fine-grained control.
8
+ */
9
+
10
+ import { sql } from 'bun';
11
+ import type { PoolConfig, PoolStats, QueryResult, QueryRow, Logger } from './types.js';
12
+
13
+ interface PooledConnection {
14
+ id: number;
15
+ createdAt: number;
16
+ lastUsedAt: number;
17
+ inUse: boolean;
18
+ }
19
+
20
+ /** Internal pool config with required pool fields */
21
+ interface InternalPoolConfig extends PoolConfig {
22
+ min: number;
23
+ max: number;
24
+ idleTimeout: number;
25
+ connectionTimeout: number;
26
+ maxLifetime: number;
27
+ }
28
+
29
+ /**
30
+ * Connection pool manager for PostgreSQL
31
+ */
32
+ export class PostgresPool {
33
+ private config: InternalPoolConfig;
34
+ private logger?: Logger;
35
+ private connections: Map<number, PooledConnection> = new Map();
36
+ private nextId = 0;
37
+ private waitQueue: Array<{
38
+ resolve: (conn: PooledConnection) => void;
39
+ reject: (err: Error) => void;
40
+ timer: Timer;
41
+ }> = [];
42
+ private closed = false;
43
+ private idleCheckTimer?: Timer;
44
+
45
+ constructor(config: PoolConfig, logger?: Logger) {
46
+ this.config = {
47
+ min: 2,
48
+ max: 20,
49
+ idleTimeout: 30000,
50
+ connectionTimeout: 10000,
51
+ maxLifetime: 3600000,
52
+ ...config,
53
+ };
54
+ this.logger = logger;
55
+
56
+ // Start idle connection checker
57
+ this.idleCheckTimer = setInterval(() => this.checkIdleConnections(), 10000);
58
+ }
59
+
60
+ /**
61
+ * Initialize the pool with minimum connections
62
+ */
63
+ async initialize(): Promise<void> {
64
+ this.logger?.info('Initializing PostgreSQL pool', {
65
+ min: this.config.min,
66
+ max: this.config.max,
67
+ });
68
+
69
+ // Warm up minimum connections
70
+ for (let i = 0; i < this.config.min; i++) {
71
+ await this.createConnection();
72
+ }
73
+
74
+ this.logger?.info('PostgreSQL pool initialized', {
75
+ connections: this.connections.size,
76
+ });
77
+ }
78
+
79
+ private async createConnection(): Promise<PooledConnection> {
80
+ const conn: PooledConnection = {
81
+ id: this.nextId++,
82
+ createdAt: Date.now(),
83
+ lastUsedAt: Date.now(),
84
+ inUse: false,
85
+ };
86
+ this.connections.set(conn.id, conn);
87
+ return conn;
88
+ }
89
+
90
+ private async acquireConnection(): Promise<PooledConnection> {
91
+ // Find an idle connection
92
+ for (const conn of this.connections.values()) {
93
+ if (!conn.inUse) {
94
+ // Check if connection is too old
95
+ if (Date.now() - conn.createdAt > this.config.maxLifetime) {
96
+ await this.destroyConnection(conn);
97
+ continue;
98
+ }
99
+ conn.inUse = true;
100
+ conn.lastUsedAt = Date.now();
101
+ return conn;
102
+ }
103
+ }
104
+
105
+ // Create new connection if under max
106
+ if (this.connections.size < this.config.max) {
107
+ const conn = await this.createConnection();
108
+ conn.inUse = true;
109
+ return conn;
110
+ }
111
+
112
+ // Wait for a connection
113
+ return new Promise((resolve, reject) => {
114
+ const timer = setTimeout(() => {
115
+ const idx = this.waitQueue.findIndex(w => w.resolve === resolve);
116
+ if (idx >= 0) this.waitQueue.splice(idx, 1);
117
+ reject(new Error(`Connection timeout after ${this.config.connectionTimeout}ms`));
118
+ }, this.config.connectionTimeout);
119
+
120
+ this.waitQueue.push({ resolve, reject, timer });
121
+ });
122
+ }
123
+
124
+ private releaseConnection(conn: PooledConnection): void {
125
+ conn.inUse = false;
126
+ conn.lastUsedAt = Date.now();
127
+
128
+ // Check waiting queue
129
+ if (this.waitQueue.length > 0) {
130
+ const waiter = this.waitQueue.shift()!;
131
+ clearTimeout(waiter.timer);
132
+ conn.inUse = true;
133
+ waiter.resolve(conn);
134
+ }
135
+ }
136
+
137
+ private async destroyConnection(conn: PooledConnection): Promise<void> {
138
+ this.connections.delete(conn.id);
139
+ this.logger?.debug('Connection destroyed', { id: conn.id });
140
+ }
141
+
142
+ private async checkIdleConnections(): Promise<void> {
143
+ if (this.closed) return;
144
+
145
+ const now = Date.now();
146
+ for (const conn of this.connections.values()) {
147
+ // Don't close if we're at minimum
148
+ if (this.connections.size <= this.config.min) break;
149
+
150
+ if (!conn.inUse && now - conn.lastUsedAt > this.config.idleTimeout) {
151
+ await this.destroyConnection(conn);
152
+ }
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Execute a query using a pooled connection
158
+ */
159
+ async query<T = QueryRow>(queryText: string, params?: unknown[]): Promise<QueryResult<T>> {
160
+ if (this.closed) {
161
+ throw new Error('Pool is closed');
162
+ }
163
+
164
+ const conn = await this.acquireConnection();
165
+ const start = performance.now();
166
+
167
+ try {
168
+ const result = await sql.unsafe(queryText, params ?? []);
169
+ const latency = performance.now() - start;
170
+
171
+ this.logger?.debug('Pool query executed', {
172
+ connId: conn.id,
173
+ query: queryText.substring(0, 100),
174
+ rows: result.length,
175
+ latencyMs: latency.toFixed(2),
176
+ });
177
+
178
+ return {
179
+ rows: result as T[],
180
+ rowCount: result.length,
181
+ };
182
+ } finally {
183
+ this.releaseConnection(conn);
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Execute with exclusive connection for multi-statement operations
189
+ */
190
+ async withConnection<T>(
191
+ fn: (query: (sql: string, params?: unknown[]) => Promise<QueryResult>) => Promise<T>
192
+ ): Promise<T> {
193
+ if (this.closed) {
194
+ throw new Error('Pool is closed');
195
+ }
196
+
197
+ const conn = await this.acquireConnection();
198
+
199
+ try {
200
+ return await fn(async (queryText: string, params?: unknown[]) => {
201
+ const result = await sql.unsafe(queryText, params ?? []);
202
+ return {
203
+ rows: result,
204
+ rowCount: result.length,
205
+ };
206
+ });
207
+ } finally {
208
+ this.releaseConnection(conn);
209
+ }
210
+ }
211
+
212
+ /**
213
+ * Get current pool statistics
214
+ */
215
+ stats(): PoolStats {
216
+ let idle = 0;
217
+ let active = 0;
218
+
219
+ for (const conn of this.connections.values()) {
220
+ if (conn.inUse) {
221
+ active++;
222
+ } else {
223
+ idle++;
224
+ }
225
+ }
226
+
227
+ return {
228
+ total: this.connections.size,
229
+ idle,
230
+ active,
231
+ pending: this.waitQueue.length,
232
+ };
233
+ }
234
+
235
+ /**
236
+ * Close the pool and all connections
237
+ */
238
+ async close(): Promise<void> {
239
+ this.closed = true;
240
+
241
+ if (this.idleCheckTimer) {
242
+ clearInterval(this.idleCheckTimer);
243
+ }
244
+
245
+ // Reject all waiting
246
+ for (const waiter of this.waitQueue) {
247
+ clearTimeout(waiter.timer);
248
+ waiter.reject(new Error('Pool is closing'));
249
+ }
250
+ this.waitQueue = [];
251
+
252
+ // Destroy all connections
253
+ for (const conn of this.connections.values()) {
254
+ await this.destroyConnection(conn);
255
+ }
256
+
257
+ this.logger?.info('PostgreSQL pool closed');
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Create and initialize a connection pool
263
+ */
264
+ export async function createPool(config: PoolConfig, logger?: Logger): Promise<PostgresPool> {
265
+ const pool = new PostgresPool(config, logger);
266
+ await pool.initialize();
267
+ return pool;
268
+ }