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/rls.ts ADDED
@@ -0,0 +1,169 @@
1
+ /**
2
+ * @singulio/postgres - Row-Level Security Helpers
3
+ * Multi-tenant isolation via PostgreSQL session GUCs
4
+ */
5
+
6
+ import { sql } from 'bun';
7
+ import type { RLSContext, QueryResult, QueryRow, Logger } from './types.js';
8
+
9
+ /**
10
+ * Set RLS context variables for the current session
11
+ */
12
+ export async function setRLSContext(context: RLSContext): Promise<void> {
13
+ // Set tenant reference (required for RLS policies)
14
+ await sql`SELECT set_config('app.tenant_ref', ${context.tenantId}, TRUE)`;
15
+
16
+ // Set admin flag (bypasses some policies)
17
+ await sql`SELECT set_config('app.is_admin', ${context.isAdmin ? 't' : 'f'}, TRUE)`;
18
+
19
+ // Set user ID if provided
20
+ if (context.userId) {
21
+ await sql`SELECT set_config('app.user_id', ${context.userId}, TRUE)`;
22
+ }
23
+
24
+ // Set any extra GUCs
25
+ if (context.extraGUCs) {
26
+ for (const [key, value] of Object.entries(context.extraGUCs)) {
27
+ await sql.unsafe(`SELECT set_config($1, $2, TRUE)`, [key, value]);
28
+ }
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Clear RLS context variables
34
+ */
35
+ export async function clearRLSContext(): Promise<void> {
36
+ await sql`SELECT set_config('app.tenant_ref', '', TRUE)`;
37
+ await sql`SELECT set_config('app.is_admin', 'f', TRUE)`;
38
+ await sql`SELECT set_config('app.user_id', '', TRUE)`;
39
+ }
40
+
41
+ /**
42
+ * Get current RLS context
43
+ */
44
+ export async function getRLSContext(): Promise<RLSContext | null> {
45
+ const result = await sql`
46
+ SELECT
47
+ current_setting('app.tenant_ref', TRUE) as tenant_id,
48
+ current_setting('app.user_id', TRUE) as user_id,
49
+ current_setting('app.is_admin', TRUE) as is_admin
50
+ `;
51
+
52
+ const row = result[0];
53
+ if (!row || !row.tenant_id) return null;
54
+
55
+ return {
56
+ tenantId: row.tenant_id as string,
57
+ userId: (row.user_id as string) || undefined,
58
+ isAdmin: row.is_admin === 't',
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Execute a function with RLS context, automatically cleaning up afterward
64
+ */
65
+ export async function withTenant<T>(context: RLSContext, fn: () => Promise<T>): Promise<T> {
66
+ await setRLSContext(context);
67
+ try {
68
+ return await fn();
69
+ } finally {
70
+ await clearRLSContext();
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Execute a query with RLS context
76
+ */
77
+ export async function queryWithTenant<T = QueryRow>(
78
+ context: RLSContext,
79
+ queryText: string,
80
+ params?: unknown[]
81
+ ): Promise<QueryResult<T>> {
82
+ return withTenant(context, async () => {
83
+ const result = await sql.unsafe(queryText, params ?? []);
84
+ return {
85
+ rows: result as T[],
86
+ rowCount: result.length,
87
+ };
88
+ });
89
+ }
90
+
91
+ /**
92
+ * RLS-aware client wrapper
93
+ */
94
+ export class RLSClient {
95
+ private logger?: Logger;
96
+
97
+ constructor(logger?: Logger) {
98
+ this.logger = logger;
99
+ }
100
+
101
+ /**
102
+ * Execute query with tenant context
103
+ */
104
+ async query<T = QueryRow>(
105
+ context: RLSContext,
106
+ queryText: string,
107
+ params?: unknown[]
108
+ ): Promise<QueryResult<T>> {
109
+ const start = performance.now();
110
+
111
+ try {
112
+ const result = await queryWithTenant<T>(context, queryText, params);
113
+
114
+ this.logger?.debug('RLS query executed', {
115
+ tenant: context.tenantId,
116
+ query: queryText.substring(0, 100),
117
+ rows: result.rowCount,
118
+ latencyMs: (performance.now() - start).toFixed(2),
119
+ });
120
+
121
+ return result;
122
+ } catch (error) {
123
+ this.logger?.error('RLS query failed', {
124
+ tenant: context.tenantId,
125
+ query: queryText.substring(0, 100),
126
+ error: error instanceof Error ? error.message : String(error),
127
+ });
128
+ throw error;
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Execute with tenant context, return first row
134
+ */
135
+ async queryOne<T = QueryRow>(
136
+ context: RLSContext,
137
+ queryText: string,
138
+ params?: unknown[]
139
+ ): Promise<T | null> {
140
+ const result = await this.query<T>(context, queryText, params);
141
+ return result.rows[0] ?? null;
142
+ }
143
+
144
+ /**
145
+ * Execute with tenant context, return all rows
146
+ */
147
+ async queryAll<T = QueryRow>(
148
+ context: RLSContext,
149
+ queryText: string,
150
+ params?: unknown[]
151
+ ): Promise<T[]> {
152
+ const result = await this.query<T>(context, queryText, params);
153
+ return result.rows;
154
+ }
155
+
156
+ /**
157
+ * Wrap a function with RLS context
158
+ */
159
+ withTenant<T>(context: RLSContext, fn: () => Promise<T>): Promise<T> {
160
+ return withTenant(context, fn);
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Create an RLS-aware client
166
+ */
167
+ export function createRLSClient(logger?: Logger): RLSClient {
168
+ return new RLSClient(logger);
169
+ }
@@ -0,0 +1,207 @@
1
+ /**
2
+ * @singulio/postgres - Transaction Helpers
3
+ * ACID transaction support with auto-rollback
4
+ */
5
+
6
+ import { sql } from 'bun';
7
+ import type { TransactionOptions, RLSContext, QueryResult, QueryRow, Logger } from './types.js';
8
+ import { setRLSContext, clearRLSContext } from './rls.js';
9
+
10
+ /**
11
+ * Build BEGIN statement with options
12
+ */
13
+ function buildBeginStatement(options: TransactionOptions): string {
14
+ const parts = ['BEGIN'];
15
+
16
+ if (options.isolationLevel) {
17
+ parts.push(`ISOLATION LEVEL ${options.isolationLevel}`);
18
+ }
19
+
20
+ if (options.readOnly) {
21
+ parts.push('READ ONLY');
22
+ }
23
+
24
+ if (options.deferrable && options.readOnly && options.isolationLevel === 'SERIALIZABLE') {
25
+ parts.push('DEFERRABLE');
26
+ }
27
+
28
+ return parts.join(' ');
29
+ }
30
+
31
+ /**
32
+ * Transaction query interface
33
+ */
34
+ export interface TransactionQuery {
35
+ /**
36
+ * Execute a query within the transaction
37
+ */
38
+ query<T = QueryRow>(queryText: string, params?: unknown[]): Promise<QueryResult<T>>;
39
+
40
+ /**
41
+ * Execute and return first row
42
+ */
43
+ queryOne<T = QueryRow>(queryText: string, params?: unknown[]): Promise<T | null>;
44
+
45
+ /**
46
+ * Execute and return all rows
47
+ */
48
+ queryAll<T = QueryRow>(queryText: string, params?: unknown[]): Promise<T[]>;
49
+
50
+ /**
51
+ * Execute a command (INSERT/UPDATE/DELETE)
52
+ */
53
+ execute(queryText: string, params?: unknown[]): Promise<number>;
54
+ }
55
+
56
+ /**
57
+ * Execute a function within a transaction
58
+ * Auto-commits on success, auto-rollbacks on error
59
+ */
60
+ export async function transaction<T>(
61
+ fn: (tx: TransactionQuery) => Promise<T>,
62
+ options: TransactionOptions = {},
63
+ logger?: Logger
64
+ ): Promise<T> {
65
+ const start = performance.now();
66
+
67
+ // Begin transaction
68
+ const beginStmt = buildBeginStatement(options);
69
+ await sql.unsafe(beginStmt);
70
+
71
+ // Set RLS context if provided
72
+ if (options.rlsContext) {
73
+ await setRLSContext(options.rlsContext);
74
+ }
75
+
76
+ // Create query interface
77
+ const tx: TransactionQuery = {
78
+ async query<T = QueryRow>(queryText: string, params?: unknown[]): Promise<QueryResult<T>> {
79
+ const result = await sql.unsafe(queryText, params ?? []);
80
+ return {
81
+ rows: result as T[],
82
+ rowCount: result.length,
83
+ };
84
+ },
85
+
86
+ async queryOne<T = QueryRow>(queryText: string, params?: unknown[]): Promise<T | null> {
87
+ const result = await tx.query<T>(queryText, params);
88
+ return result.rows[0] ?? null;
89
+ },
90
+
91
+ async queryAll<T = QueryRow>(queryText: string, params?: unknown[]): Promise<T[]> {
92
+ const result = await tx.query<T>(queryText, params);
93
+ return result.rows;
94
+ },
95
+
96
+ async execute(queryText: string, params?: unknown[]): Promise<number> {
97
+ const result = await tx.query(queryText, params);
98
+ return result.rowCount;
99
+ },
100
+ };
101
+
102
+ try {
103
+ const result = await fn(tx);
104
+
105
+ // Clear RLS context before commit
106
+ if (options.rlsContext) {
107
+ await clearRLSContext();
108
+ }
109
+
110
+ // Commit
111
+ await sql`COMMIT`;
112
+
113
+ const latency = performance.now() - start;
114
+ logger?.debug('Transaction committed', {
115
+ isolationLevel: options.isolationLevel ?? 'READ COMMITTED',
116
+ tenant: options.rlsContext?.tenantId,
117
+ latencyMs: latency.toFixed(2),
118
+ });
119
+
120
+ return result;
121
+ } catch (error) {
122
+ // Clear RLS context before rollback
123
+ if (options.rlsContext) {
124
+ try {
125
+ await clearRLSContext();
126
+ } catch {
127
+ // Ignore errors during cleanup
128
+ }
129
+ }
130
+
131
+ // Rollback
132
+ try {
133
+ await sql`ROLLBACK`;
134
+ } catch {
135
+ // Connection might be broken
136
+ }
137
+
138
+ const latency = performance.now() - start;
139
+ logger?.error('Transaction rolled back', {
140
+ isolationLevel: options.isolationLevel ?? 'READ COMMITTED',
141
+ tenant: options.rlsContext?.tenantId,
142
+ error: error instanceof Error ? error.message : String(error),
143
+ latencyMs: latency.toFixed(2),
144
+ });
145
+
146
+ throw error;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Execute a function within a serializable transaction
152
+ * Best for operations requiring strong consistency
153
+ */
154
+ export async function serializableTransaction<T>(
155
+ fn: (tx: TransactionQuery) => Promise<T>,
156
+ rlsContext?: RLSContext,
157
+ logger?: Logger
158
+ ): Promise<T> {
159
+ return transaction(
160
+ fn,
161
+ {
162
+ isolationLevel: 'SERIALIZABLE',
163
+ rlsContext,
164
+ },
165
+ logger
166
+ );
167
+ }
168
+
169
+ /**
170
+ * Execute a function within a read-only transaction
171
+ * Best for reporting/analytics queries
172
+ */
173
+ export async function readOnlyTransaction<T>(
174
+ fn: (tx: TransactionQuery) => Promise<T>,
175
+ rlsContext?: RLSContext,
176
+ logger?: Logger
177
+ ): Promise<T> {
178
+ return transaction(
179
+ fn,
180
+ {
181
+ readOnly: true,
182
+ rlsContext,
183
+ },
184
+ logger
185
+ );
186
+ }
187
+
188
+ /**
189
+ * Create a savepoint within a transaction
190
+ */
191
+ export async function savepoint(name: string): Promise<void> {
192
+ await sql.unsafe(`SAVEPOINT ${name}`);
193
+ }
194
+
195
+ /**
196
+ * Rollback to a savepoint
197
+ */
198
+ export async function rollbackToSavepoint(name: string): Promise<void> {
199
+ await sql.unsafe(`ROLLBACK TO SAVEPOINT ${name}`);
200
+ }
201
+
202
+ /**
203
+ * Release a savepoint
204
+ */
205
+ export async function releaseSavepoint(name: string): Promise<void> {
206
+ await sql.unsafe(`RELEASE SAVEPOINT ${name}`);
207
+ }
package/src/types.ts ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * @singulio/postgres - Type Definitions
3
+ * PostgreSQL 18.x types for Bun native client
4
+ */
5
+
6
+ /** Connection configuration */
7
+ export interface PostgresConfig {
8
+ /** Connection string (postgres://user:pass@host:port/db) */
9
+ connectionString?: string;
10
+ /** Host (default: localhost) */
11
+ host?: string;
12
+ /** Port (default: 5432) */
13
+ port?: number;
14
+ /** Database name */
15
+ database?: string;
16
+ /** Username */
17
+ user?: string;
18
+ /** Password */
19
+ password?: string;
20
+ /** SSL mode */
21
+ ssl?: boolean | 'require' | 'prefer' | 'disable';
22
+ /** Application name for pg_stat_activity */
23
+ applicationName?: string;
24
+ }
25
+
26
+ /** Pool configuration */
27
+ export interface PoolConfig extends PostgresConfig {
28
+ /** Minimum connections (default: 2) */
29
+ min?: number;
30
+ /** Maximum connections (default: 20) */
31
+ max?: number;
32
+ /** Idle timeout in ms (default: 30000) */
33
+ idleTimeout?: number;
34
+ /** Connection timeout in ms (default: 10000) */
35
+ connectionTimeout?: number;
36
+ /** Max lifetime per connection in ms (default: 3600000 = 1 hour) */
37
+ maxLifetime?: number;
38
+ }
39
+
40
+ /** RLS context for multi-tenant isolation */
41
+ export interface RLSContext {
42
+ /** Tenant reference ID */
43
+ tenantId: string;
44
+ /** User ID (optional) */
45
+ userId?: string;
46
+ /** Is admin user (bypasses some RLS) */
47
+ isAdmin?: boolean;
48
+ /** Additional session GUCs */
49
+ extraGUCs?: Record<string, string>;
50
+ }
51
+
52
+ /** Query result row */
53
+ export type QueryRow = Record<string, unknown>;
54
+
55
+ /** Query result */
56
+ export interface QueryResult<T = QueryRow> {
57
+ /** Result rows */
58
+ rows: T[];
59
+ /** Number of affected rows */
60
+ rowCount: number;
61
+ /** Field metadata */
62
+ fields?: FieldInfo[];
63
+ }
64
+
65
+ /** Field metadata */
66
+ export interface FieldInfo {
67
+ name: string;
68
+ dataTypeID: number;
69
+ tableID?: number;
70
+ columnID?: number;
71
+ }
72
+
73
+ /** Transaction isolation levels */
74
+ export type IsolationLevel =
75
+ | 'READ UNCOMMITTED'
76
+ | 'READ COMMITTED'
77
+ | 'REPEATABLE READ'
78
+ | 'SERIALIZABLE';
79
+
80
+ /** Transaction options */
81
+ export interface TransactionOptions {
82
+ /** Isolation level (default: READ COMMITTED) */
83
+ isolationLevel?: IsolationLevel;
84
+ /** Read-only transaction */
85
+ readOnly?: boolean;
86
+ /** Deferrable (only with SERIALIZABLE + readOnly) */
87
+ deferrable?: boolean;
88
+ /** RLS context to apply */
89
+ rlsContext?: RLSContext;
90
+ }
91
+
92
+ /** Health check result */
93
+ export interface HealthCheckResult {
94
+ healthy: boolean;
95
+ latencyMs: number;
96
+ poolStats?: PoolStats;
97
+ error?: string;
98
+ }
99
+
100
+ /** Pool statistics */
101
+ export interface PoolStats {
102
+ /** Total connections in pool */
103
+ total: number;
104
+ /** Idle connections */
105
+ idle: number;
106
+ /** Active connections */
107
+ active: number;
108
+ /** Pending connection requests */
109
+ pending: number;
110
+ }
111
+
112
+ /** Vector distance operators for pgvector */
113
+ export type VectorDistanceOperator =
114
+ | '<->' // L2 distance (Euclidean)
115
+ | '<=>' // Cosine distance
116
+ | '<#>' // Inner product (negative)
117
+ | '<+>'; // L1 distance (Manhattan)
118
+
119
+ /** Vector index types */
120
+ export type VectorIndexType = 'hnsw' | 'ivfflat';
121
+
122
+ /** Vector search options */
123
+ export interface VectorSearchOptions {
124
+ /** Distance operator (default: <->) */
125
+ operator?: VectorDistanceOperator;
126
+ /** Maximum results (default: 10) */
127
+ limit?: number;
128
+ /** Minimum similarity threshold (optional) */
129
+ threshold?: number;
130
+ /** Additional WHERE clause */
131
+ filter?: string;
132
+ /** Filter parameters */
133
+ filterParams?: unknown[];
134
+ }
135
+
136
+ /** Logger interface (matches @singulio/logger) */
137
+ export interface Logger {
138
+ debug(message: string, meta?: Record<string, unknown>): void;
139
+ info(message: string, meta?: Record<string, unknown>): void;
140
+ warn(message: string, meta?: Record<string, unknown>): void;
141
+ error(message: string, meta?: Record<string, unknown>): void;
142
+ }