@voltro/sql-postgres 0.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.
@@ -0,0 +1,375 @@
1
+ import { ChangeEvent } from '@voltro/database';
2
+ import { ChangeStrategy } from '@voltro/database';
3
+ import { ConfigError } from 'effect';
4
+ import { ConnectionConfig } from '@voltro/database';
5
+ import { Context } from 'effect';
6
+ import { DataStore } from '@voltro/database';
7
+ import { DialectReplicationAdapter } from '@voltro/database';
8
+ import { Effect } from 'effect';
9
+ import { Layer } from 'effect';
10
+ import { ManagedRuntime } from 'effect';
11
+ import { PgClient } from '@effect/sql-pg';
12
+ import { Predicate } from '@voltro/database';
13
+ import { QueryDescriptor } from '@voltro/database';
14
+ import { RawSqlFragment } from '@voltro/database/sql';
15
+ import { RetryDecision } from '@voltro/database';
16
+ import { Row } from '@voltro/database';
17
+ import { SqlClient } from '@effect/sql';
18
+ import { SqlClient as SqlClient_2 } from '@effect/sql/SqlClient';
19
+ import { SqlDialect } from '@voltro/database';
20
+ import { SqlError } from '@effect/sql';
21
+ import { TransactionConnection } from '@effect/sql/SqlClient';
22
+
23
+ /**
24
+ * Parse a `ConnectionConfig` from the cross-dialect interface into the
25
+ * postgres-specific `PostgresConnection` shape.
26
+ *
27
+ * Resolution order:
28
+ * 1. If `url` is present, parse it (`postgres://user:pw@host:port/db`).
29
+ * 2. Otherwise use discrete fields.
30
+ * 3. Anything missing falls back to canonical defaults
31
+ * (localhost / 5432 / app / app / app) so dev-mode `voltro dev`
32
+ * without env config still boots against a default-config postgres.
33
+ *
34
+ * TLS: an explicit `config.ssl` wins; otherwise the URL's `?sslmode=` /
35
+ * `?ssl=` query decides (see `sslFromUrlQuery`); otherwise unset — the
36
+ * driver default. `ssl: true` maps to `sslmode=require` semantics,
37
+ * `ssl: false` forces plaintext (see `sslDriverOption` for the exact
38
+ * driver options).
39
+ */
40
+ export declare const connectionFromConfig: (config: ConnectionConfig) => PostgresConnection;
41
+
42
+ export declare type DatabaseConnectionLayer = ReturnType<typeof makePostgresSqlLayer>;
43
+
44
+ export declare type DatabaseConnectionLayerInput = Parameters<typeof makePostgresSqlLayer>[0];
45
+
46
+ /**
47
+ * Construct a `PostgresDataStore`. The owned `ManagedRuntime` keeps the
48
+ * connection pool alive for the lifetime of the store; `close()` disposes it.
49
+ */
50
+ export declare const makePostgresDataStore: (options: PostgresDataStoreOptions) => Promise<PostgresDataStore>;
51
+
52
+ /**
53
+ * Build a `PgClient` Layer from explicit field values.
54
+ *
55
+ * Marked exported because the CLI's migrate / dbCommand still construct
56
+ * the layer directly from environment values; once those callsites move
57
+ * to the dialect resolver they'll go through `postgresDialect.makeSqlLayer`
58
+ * and this becomes an internal helper.
59
+ *
60
+ * When `schema` is set, every pooled connection opens with
61
+ * `search_path = <schema>` (the PG `options` startup parameter, applied by
62
+ * the server BEFORE any query — no pool-rotation race), so introspection
63
+ * (`current_schema()`), unqualified DDL, and runtime queries all target it.
64
+ * `@effect/sql-pg`'s config can't carry that param, so we hand it a
65
+ * pre-built `pg.Pool` via `layerFromPool`. No schema → the unchanged
66
+ * `layerConfig` path (zero behaviour change).
67
+ */
68
+ export declare const makePostgresSqlLayer: (options: PostgresConnection) => ReturnType<typeof PgClient.layerConfig>;
69
+
70
+ /**
71
+ * The cross-dialect `SqlDialect.makeSqlLayer` implementation for postgres.
72
+ * Takes a generic `ConnectionConfig`, parses it via `connectionFromConfig`,
73
+ * and produces the same PgClient Layer that direct callers would build.
74
+ */
75
+ export declare const makePostgresSqlLayerFromConfig: (config: ConnectionConfig) => ReturnType<typeof PgClient.layerConfig>;
76
+
77
+ export { PgClient }
78
+
79
+ /**
80
+ * Internal handle a `PostgresDataStore` exposes so this adapter can
81
+ * issue raw SQL against the same managed runtime + connection pool.
82
+ *
83
+ * The framework's `ReplicatedDataStore` typing only sees a generic
84
+ * `DataStore`, so this adapter casts at the call site after checking
85
+ * for the friend-handle's presence — same pattern the dispatcher
86
+ * uses for transactional view internals.
87
+ */
88
+ declare interface PostgresAdapterFriend {
89
+ readonly runEffect: <A, E>(effect: Effect.Effect<A, E, SqlClient_2>) => Promise<A>;
90
+ }
91
+
92
+ export declare interface PostgresConnection {
93
+ readonly host: string;
94
+ readonly port: number;
95
+ readonly username: string;
96
+ readonly password: string;
97
+ readonly database: string;
98
+ /** Connection-pool size; defaults to 10. */
99
+ readonly maxConnections?: number;
100
+ /** TLS. `true` = `sslmode=require` semantics (encrypt, skip certificate
101
+ * verification — see `sslDriverOption`); `false` = force plaintext (also
102
+ * overrides a `PGSSLMODE` the driver would otherwise pick up from the
103
+ * process env); unset = the driver default (plaintext unless `PGSSLMODE`
104
+ * says otherwise). */
105
+ readonly ssl?: boolean;
106
+ /** Pin the connection `search_path` to this schema (env `DB_SCHEMA`).
107
+ * See `ConnectionConfig.schema`. */
108
+ readonly schema?: string;
109
+ }
110
+
111
+ export declare class PostgresDataStore implements DataStore {
112
+ private readonly sql;
113
+ private readonly runtime;
114
+ private readonly changeStrategy;
115
+ private readonly cdcChannel;
116
+ private readonly emitter;
117
+ private cdcFiber;
118
+ /** In-flight count of `transactional()` calls. `close()` waits for
119
+ * this to drop to 0 (or its grace period to expire) before disposing
120
+ * the runtime — preventing connection-pool teardown mid-COMMIT. */
121
+ private inflightTxns;
122
+ constructor(sql: SqlClient.SqlClient, runtime: ManagedRuntime.ManagedRuntime<SqlClient.SqlClient | PgClient.PgClient, never>, changeStrategy: ChangeStrategy, cdcChannel: string);
123
+ /**
124
+ * Bind a per-request tenant NAMESPACE (a postgres SCHEMA). The returned
125
+ * view runs every operation inside a transaction whose first statement
126
+ * is `SET LOCAL search_path TO "<namespace>"`, so unqualified table
127
+ * names resolve to that tenant's schema — and the setting RESETS at
128
+ * commit/rollback. This is mandatory on a pooled connection: a bare
129
+ * `SET search_path` would persist on the connection and leak into the
130
+ * NEXT request that checks the same connection out of the pool. `SET
131
+ * LOCAL` is transaction-scoped, so the leak window is closed.
132
+ *
133
+ * `null` returns `this` — the shared-schema default (no search_path
134
+ * manipulation).
135
+ */
136
+ withNamespace(namespace: string | null): DataStore;
137
+ /* Excluded from this release type: runInNamespace */
138
+ /**
139
+ * Friend-handle the postgres replication adapter reads via a
140
+ * structural check on `DataStore` (declared in `./replicationAdapter.ts`).
141
+ * The adapter needs raw SQL access against THIS store's managed
142
+ * runtime to issue `pg_current_wal_lsn()` / `pg_last_wal_replay_lsn()`
143
+ * — without a friend handle it would need its own connection pool +
144
+ * config, which would double the deployment cost.
145
+ *
146
+ * The handle stays on the store class as a public-but-prefixed
147
+ * property so the type cast in `adapterFriend(store)` resolves
148
+ * without exporting the runtime to the wider API surface.
149
+ */
150
+ readonly __postgresReplicationFriend: PostgresAdapterFriend;
151
+ /**
152
+ * Core write/read path. Both the public-store API (no txn) and the
153
+ * per-transactional-view (with txn) flow through these, with explicit
154
+ * txn-context + event-sink params so concurrent transactions remain
155
+ * fully isolated. The public methods pass `null/null`; the view passes
156
+ * its own captured TxnContext + per-call events buffer.
157
+ */
158
+ private executeQuery;
159
+ private executeInsert;
160
+ private executeUpdate;
161
+ private executeUpsert;
162
+ private executeInsertIgnore;
163
+ private executeInsertMany;
164
+ private executePatchJson;
165
+ private executeDelete;
166
+ /** Inside a transaction → push into the per-call events buffer (drained
167
+ * on commit, dropped on throw); outside → emit live. */
168
+ private routeEvent;
169
+ query(descriptor: QueryDescriptor): Promise<ReadonlyArray<Row>>;
170
+ raw<T extends object = Row>(fragment: RawSqlFragment, _opts?: {
171
+ dependsOn?: ReadonlyArray<string>;
172
+ }): Promise<ReadonlyArray<T>>;
173
+ /**
174
+ * Run a query, applying eager-loads when present.
175
+ *
176
+ * Path A (fast): compile to ONE roundtrip via `compileEagerJson` →
177
+ * postgres' `to_jsonb(parent.*) || jsonb_build_object('rel', ...)`
178
+ * shape. Each parent comes back with relations already nested as
179
+ * jsonb. Decoder restores Date types per schema.
180
+ *
181
+ * Path B (walker fallback): when compilation returns null
182
+ * (registry miss, unsupported feature, etc.) OR the JSON-agg
183
+ * execution throws, run the base SELECT + the portable walker.
184
+ * Walker is the canonical correctness path; JSON-agg is the perf
185
+ * layer.
186
+ *
187
+ * The fallback-on-throw catches dialect quirks I couldn't predict
188
+ * up front — anything that breaks the JSON-agg query (driver
189
+ * compatibility, schema feature) silently degrades to walker
190
+ * instead of surfacing a routing-level error. The log line on
191
+ * fallback makes the degradation visible during dev.
192
+ */
193
+ private runWithEager;
194
+ /** Friend accessor for the transactional view — same eager-aware
195
+ * path as the public `query()` but pinned to the view's txn. */
196
+ getInternalRunWithEager(): (d: QueryDescriptor, txn: TxnContext | null) => Promise<ReadonlyArray<Row>>;
197
+ insert(table: string, row: Row): Promise<Row>;
198
+ insertMany(table: string, rows: ReadonlyArray<Row>): Promise<ReadonlyArray<Row>>;
199
+ patchJson(table: string, primaryKey: string, path: string, value: unknown): Promise<Row | null>;
200
+ update(table: string, primaryKey: string, patch: Readonly<Record<string, unknown>>): Promise<Row | null>;
201
+ delete(table: string, primaryKey: string): Promise<boolean>;
202
+ updateMany(table: string, patch: Readonly<Record<string, unknown>>, options: {
203
+ where: Predicate;
204
+ }): Promise<number>;
205
+ deleteMany(table: string, options: {
206
+ where: Predicate;
207
+ }): Promise<number>;
208
+ /**
209
+ * Q9 v2 — single-statement `UPDATE ... WHERE <pred> RETURNING *`.
210
+ * Postgres supports RETURNING on UPDATE; we use it to fan out the
211
+ * per-row ChangeEvent reactive subscribers expect, without paying the
212
+ * SELECT-then-update round-trip cost. The predicate lives on the write
213
+ * itself, so the match is a CAS: a row that stopped matching between a
214
+ * caller's read and this write is never patched. When an ambient `txn`
215
+ * is present (the transactional view) the statement routes to that
216
+ * captured connection so the CAS holds inside the transaction too —
217
+ * this is why the view forwards here instead of SELECT-ids-then-apply-
218
+ * by-id, which under READ COMMITTED could patch a row that no longer
219
+ * matches.
220
+ */
221
+ private executeUpdateMany;
222
+ /**
223
+ * Single-statement `DELETE ... WHERE <pred> RETURNING *` — the set-based
224
+ * mirror of {@link executeUpdateMany}. The returned rows ARE the
225
+ * old-images we fan out as per-row delete ChangeEvents. Same
226
+ * predicate-CAS + ambient-txn routing rationale as updateMany.
227
+ */
228
+ private executeDeleteMany;
229
+ upsert(table: string, row: Row, options: {
230
+ conflictColumns: ReadonlyArray<string>;
231
+ update?: ReadonlyArray<string> | ((existing: Row) => Readonly<Record<string, unknown>>);
232
+ }): Promise<Row>;
233
+ insertIgnore(table: string, row: Row, options: {
234
+ conflictColumns: ReadonlyArray<string>;
235
+ }): Promise<Row>;
236
+ /* Excluded from this release type: emitChange */
237
+ /* Excluded from this release type: getInternalExecuteQuery */
238
+ /* Excluded from this release type: getInternalExecuteInsert */
239
+ /* Excluded from this release type: getInternalExecuteInsertMany */
240
+ /* Excluded from this release type: getInternalExecutePatchJson */
241
+ /* Excluded from this release type: getInternalExecuteUpdate */
242
+ /* Excluded from this release type: getInternalExecuteDelete */
243
+ /* Excluded from this release type: getInternalExecuteUpdateMany */
244
+ /* Excluded from this release type: getInternalExecuteDeleteMany */
245
+ /* Excluded from this release type: getInternalExecuteUpsert */
246
+ /* Excluded from this release type: getInternalExecuteInsertIgnore */
247
+ /**
248
+ * Run `work` inside a real Postgres transaction.
249
+ *
250
+ * Each call to `transactional()` is fully independent — two concurrent
251
+ * invocations run on TWO separate Connections from the pool, BEGIN/
252
+ * COMMIT/ROLLBACK on their own connections, see their own (uncommitted)
253
+ * writes via the per-call view passed to `work`, and don't conflict.
254
+ *
255
+ * Mechanism:
256
+ * 1. `sql.withTransaction(effect)` checks out a Connection from the
257
+ * pool, issues `BEGIN`, and provides the `TransactionConnection`
258
+ * service to `effect`. On effect success → `COMMIT` + release;
259
+ * on failure → `ROLLBACK` + release.
260
+ * 2. Inside `effect` we capture the `TransactionConnection` tuple
261
+ * and construct a `PostgresTransactionalView` that owns the
262
+ * captured TxnContext + an events buffer. We pass it to `work`.
263
+ * 3. `work` calls `tx.insert/update/delete/query` on the view; the
264
+ * view forwards to the parent's private execute* methods with
265
+ * its TxnContext explicitly attached. Every statement therefore
266
+ * routes to the SAME Connection as BEGIN — atomic at the DB level.
267
+ * 4. ChangeEvents queue in the view's buffer; on commit they drain
268
+ * to the parent's emitter. On throw they're dropped.
269
+ *
270
+ * Resilience:
271
+ * - Automatic retry on `serialization_failure` (40001) and
272
+ * `deadlock_detected` (40P01) with exponential backoff (10ms base,
273
+ * up to 3 attempts). Each retry rebuilds a fresh view — the
274
+ * aborted attempt's events were never drained, so the retry's
275
+ * subscribers see exactly one event-set (the winning attempt's).
276
+ * - The whole `transactional()` boundary is wrapped in an
277
+ * `Effect.withSpan('store.transactional')` so OpenTelemetry
278
+ * collectors see one span per logical mutation, with retry counts
279
+ * and committed-event counts as attributes.
280
+ */
281
+ transactional<T>(work: (tx: DataStore) => Promise<T>): Promise<T>;
282
+ onChange(listener: (event: ChangeEvent) => void): () => void;
283
+ /** Cross-instance reactivity seam — emit an externally-sourced event to
284
+ * local subscribers without re-persisting. Both the LISTEN/NOTIFY CDC
285
+ * consumer and `@voltro/plugin-broadcast` route through here. See
286
+ * `DataStore.injectExternalChange`. */
287
+ /** 'fleet' under LISTEN/NOTIFY CDC (every replica gets the full stream);
288
+ * 'local' under inline emission. See DataStore.changeScope. */
289
+ get changeScope(): 'local' | 'fleet';
290
+ injectExternalChange(event: ChangeEvent): void;
291
+ /** Run an arbitrary Effect against the underlying SqlClient. */
292
+ run<A, E>(effect: Effect.Effect<A, E, SqlClient.SqlClient>): Promise<A>;
293
+ /**
294
+ * Tear down the connection pool + CDC consumer.
295
+ *
296
+ * Waits up to `gracePeriodMs` for in-flight `transactional()` calls to
297
+ * complete before disposing the runtime. Without this, a graceful
298
+ * shutdown that races a mid-COMMIT mutation could see the connection
299
+ * pool yanked out from under the transaction — undefined outcome (the
300
+ * write may or may not land, depending on whether COMMIT had been
301
+ * flushed to disk).
302
+ *
303
+ * Defaults to a 5-second grace period — long enough for typical
304
+ * mutations to finish, short enough that a shutdown isn't blocked
305
+ * indefinitely by a stuck transaction. After the grace period
306
+ * expires, logs a warning and proceeds with disposal anyway (the
307
+ * shutdown must complete).
308
+ */
309
+ close(gracePeriodMs?: number): Promise<void>;
310
+ /** Liveness probe — `SELECT 1`. Rejects if the pool can't answer. */
311
+ ping(): Promise<void>;
312
+ /**
313
+ * Subscribe to the Postgres NOTIFY channel and translate each notification
314
+ * into a ChangeEvent. Called automatically from `makePostgresDataStore`
315
+ * when `changeStrategy: 'cdc'`.
316
+ */
317
+ startCdcConsumer(): Promise<void>;
318
+ }
319
+
320
+ export declare interface PostgresDataStoreOptions {
321
+ /**
322
+ * Layer that provides the postgres SQL client. `makePostgresSqlLayer({...})`
323
+ * returns `Layer<PgClient | SqlClient, ConfigError | SqlError>`, which is
324
+ * exactly this shape.
325
+ */
326
+ readonly sqlLayer: Layer.Layer<SqlClient.SqlClient | PgClient.PgClient, ConfigError.ConfigError | SqlError.SqlError, never>;
327
+ /** Optional tracer layer (from `buildTracingLayer`). When provided, every
328
+ * `Effect.withSpan` inside the store's runtime — most importantly
329
+ * `transactional()` — emits real OpenTelemetry spans. Absent → spans
330
+ * become no-ops. Typed `Layer<never>` because Layer's provided-services
331
+ * channel is contravariant — `never` is the widest acceptor that
332
+ * absorbs both `Layer.empty` and real tracer layers (which provide
333
+ * OTel's Resource service). */
334
+ readonly tracerLayer?: Layer.Layer<never, never, never>;
335
+ /** Change emission strategy. Defaults to 'inline'. */
336
+ readonly changeStrategy?: ChangeStrategy;
337
+ /** Postgres NOTIFY channel name when changeStrategy is 'cdc'. Defaults to 'framework_changes'. */
338
+ readonly cdcChannel?: string;
339
+ }
340
+
341
+ /**
342
+ * The dialect singleton — what `loadDialect()` returns and what every
343
+ * dialect-aware caller (CLI buildStore, migrate, workflow layer)
344
+ * receives.
345
+ *
346
+ * `compileContains` keeps `ILIKE ... ESCAPE '\\'` semantics; the
347
+ * centralised `sqlCompiler` routes `contains` through the dialect, with
348
+ * this as the postgres-side dispatch target.
349
+ */
350
+ export declare const postgresDialect: SqlDialect;
351
+
352
+ /**
353
+ * Build a Postgres replication adapter.
354
+ *
355
+ * The store the adapter operates against MUST expose the
356
+ * `__postgresReplicationFriend` handle the framework's
357
+ * `PostgresDataStore` attaches at construction time. The adapter
358
+ * throws a clear error if the handle is missing — surfaces
359
+ * "wrong store passed" at the routing boundary instead of
360
+ * downstream as a SQL error.
361
+ */
362
+ export declare const postgresReplicationAdapter: () => DialectReplicationAdapter;
363
+
364
+ /** `SqlDialect.retryFilter` implementation for postgres. */
365
+ export declare const postgresRetryFilter: (err: unknown) => RetryDecision;
366
+
367
+ /**
368
+ * Captured inside `transactional()`'s `withTransaction` scope. Holds the
369
+ * `TransactionConnection` context tuple `[Connection, depth]` so we can
370
+ * explicitly provide it to each inner statement's Effect — without this,
371
+ * statements run on the shared pool and bypass our `BEGIN`.
372
+ */
373
+ declare type TxnContext = Context.Tag.Service<typeof TransactionConnection>;
374
+
375
+ export { }