@vibeorm/adapter-pg 5.1.0 → 5.2.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/README.md CHANGED
@@ -55,7 +55,7 @@ const db = VibeClient({ adapter: pgAdapter({ pool }) });
55
55
  | `cancellationConnectionString` | `string` | Owned `connectionString`; none for an injected `pool` | Separate control endpoint for running read cancellation; same PostgreSQL server/database, with permission to signal the target role |
56
56
  | `preparedStatements` | `boolean` | `false` | Send ORM-path queries as named prepared statements. Measured −17…−25 % on reads and −6 % on a by-key update (see below). Off by default because of transaction-mode poolers — turn it on for direct connections, session pooling, or PgBouncer 1.21+ with `max_prepared_statements > 0`. Works with an injected `pool` too; the name budget (`stmtCacheMax`) then belongs to that pool and is shared by every adapter on it |
57
57
  | `stmtCacheMax` | `number` | `1000` | Budget of prepared-statement names per pool. Names never evict (an evicted name stays prepared on the backend), so this bounds server-side retention per connection; a text arriving after the budget is spent runs unnamed |
58
- | `planCacheMode` | `"auto" \| "force_custom_plan" \| "force_generic_plan"` | `"auto"` | PostgreSQL `plan_cache_mode` for every connection of the pool the adapter builds; sent in the `options` startup parameter, merged with an `options` the URL already carries (a URL that sets `plan_cache_mode` itself wins). Refused with `VIBE_CONFIG` next to a caller-supplied `pool` |
58
+ | `planCacheMode` | `"auto" \| "force_custom_plan" \| "force_generic_plan"` | `"auto"` | PostgreSQL `plan_cache_mode` for every connection of the pool the adapter builds; sent in the `options` startup parameter, merged with an `options` the URL already carries (a URL that sets `plan_cache_mode` itself wins), or with `PGOPTIONS` when the URL has none. Refused with `VIBE_CONFIG` next to a caller-supplied `pool` |
59
59
 
60
60
  ### A database far away
61
61
 
@@ -137,6 +137,18 @@ Outside transactions the adapter declares `connectionGroup` (when the driver can
137
137
 
138
138
  A raw script that produces multiple PostgreSQL results returns only the **final command's** rows and affected-row count. Earlier results are not concatenated or summed; a final DDL command returns empty rows and zero affected rows.
139
139
 
140
+ ### Explicit deferred work
141
+
142
+ Transaction clients accept `tx.$queueRaw({ query, name? })`, where `query` is a bound `sql` fragment. Queued statements keep insertion order and travel before the next eligible statement, or before COMMIT when no statement follows. Each remains a separate SQL statement, so under READ COMMITTED a load after a lock wait takes its snapshot after acquisition. A queued failure rejects the companion statement and the transaction with the queued statement's typed error, even if the callback catches the companion rejection.
143
+
144
+ `$lazy(() => db.model.findMany(...))`, `$queryRawLazy({ query, access, name? })` and `$executeRawLazy({ query, name? })` stay unsent until awaited or explicitly claimed by `$all`, an array transaction or `commitWith`. Existing raw calls remain immediate. Search and `vector.nearest` contribute lazy reads; `vector.set` contributes a write. Inside an existing transaction, `$all` preserves object-key enumeration order, including locking reads. Eligible one-statement members still share an ordered batch.
145
+
146
+ `commitWith` accepts an ordered final-item list. Eligible application writes and a following flush can share the closing batch with COMMIT. Result-bearing tail items settle only after transaction completion, in list order. A failure rejects every item with the transaction's same error. A refusable client-side reply keeps COMMIT separate. Nested array transactions remain savepoints and never infer that they are final.
147
+
148
+ Count-only bulk writes accept `expect: n` on PostgreSQL. The SQL guard checks direct affected rows and raises `VIBE_ROW_COUNT_MISMATCH` before COMMIT when the count differs. Unsupported or multi-statement shapes refuse instead of substituting a client-only check.
149
+
150
+ `tx.$advisoryLocks({ keys, mode?, defer? })` takes sorted, deduplicated signed 64-bit keys in one statement. The immediate form confirms acquisition. `defer: true` confirms enqueueing only, with acquisition before the next statement. The top-level `advisoryLocks` transaction option queues the same statement before the first load. These explicit boundaries add no timer or microtask gathering window. Existing deadline, driver-timeout and pipeline capability checks still decide whether wire batching is safe.
151
+
140
152
  ## Parameter serialization: two paths
141
153
 
142
154
  `pg` turns a JS array into a PostgreSQL array literal natively, which is what a raw query wants but not what the ORM path can assume. The adapter therefore keeps two helpers:
package/dist/index.d.ts CHANGED
@@ -179,7 +179,8 @@ export type PgAdapterSettings = {
179
179
  * statement is still prepared once, but planned with the real parameter
180
180
  * values on every execution. Rides the `options` startup parameter, merged
181
181
  * with whatever the connection string already carries (a URL that sets
182
- * `plan_cache_mode` itself wins). Refused with `VIBE_CONFIG` together with
182
+ * `plan_cache_mode` itself wins), or with `PGOPTIONS` when it carries none.
183
+ * Refused with `VIBE_CONFIG` together with
183
184
  * a caller-supplied `pool` — the adapter never reconfigures a pool it does
184
185
  * not own; set it on that pool's own `options` instead.
185
186
  */
package/dist/index.js CHANGED
@@ -792,13 +792,14 @@ function placePlanCacheMode(params) {
792
792
  if (planCacheMode === "auto")
793
793
  return { connectionString, options: undefined };
794
794
  const setting = `-c plan_cache_mode=${planCacheMode}`;
795
+ const configOptions = [params.ambientOptions?.trim() ?? "", setting].filter(Boolean).join(" ");
795
796
  if (connectionString === undefined || !URL.canParse(connectionString)) {
796
- return { connectionString, options: setting };
797
+ return { connectionString, options: configOptions };
797
798
  }
798
799
  const url = new URL(connectionString);
799
800
  const existing = url.searchParams.get("options");
800
801
  if (existing === null) {
801
- return { connectionString, options: setting };
802
+ return { connectionString, options: configOptions };
802
803
  }
803
804
  if (/(^|\s)-c\s*plan_cache_mode=/.test(existing)) {
804
805
  return { connectionString, options: undefined };
@@ -831,7 +832,8 @@ function buildPoolConfig(params) {
831
832
  const { max, min, statementTimeout, connectionTimeout, idleTimeout } = params;
832
833
  const placement = placePlanCacheMode({
833
834
  connectionString: params.connectionString,
834
- planCacheMode: params.planCacheMode ?? "auto"
835
+ planCacheMode: params.planCacheMode ?? "auto",
836
+ ...params.ambientOptions === undefined ? {} : { ambientOptions: params.ambientOptions }
835
837
  });
836
838
  const config = { connectionString: placement.connectionString, max };
837
839
  if (min !== undefined)
@@ -1352,7 +1354,8 @@ function pgAdapter(options) {
1352
1354
  ...options?.statementTimeout === undefined ? {} : { statementTimeout: options.statementTimeout },
1353
1355
  ...options?.connectionTimeout === undefined ? {} : { connectionTimeout: options.connectionTimeout },
1354
1356
  ...idleTimeout === undefined ? {} : { idleTimeout },
1355
- planCacheMode
1357
+ planCacheMode,
1358
+ ...process.env.PGOPTIONS === undefined ? {} : { ambientOptions: process.env.PGOPTIONS }
1356
1359
  });
1357
1360
  const PoolCtor = pg3.Pool;
1358
1361
  pool = new PoolCtor(config);
@@ -1452,6 +1455,25 @@ function pgAdapter(options) {
1452
1455
  pipelining: pipelines === null ? "ordered" : PIPELINING,
1453
1456
  connectionIdentity: connection,
1454
1457
  ...params.topLevel !== true || deferred === null ? {} : {
1458
+ queueStatement: (statement) => {
1459
+ budget.assertUsable({ stage: "statement" });
1460
+ if (scope.state.open !== null || final !== undefined)
1461
+ throw new VibeError9({ code: "VIBE_VALIDATION", message: "Queue statements on the active transaction handle, outside an open savepoint", meta: { reason: "queue-scope" } });
1462
+ scopeTurn({ scope });
1463
+ const name = statement.name === undefined ? undefined : assertStatementName({ name: statement.name, method: "$queueRaw" });
1464
+ if (name !== undefined)
1465
+ rawStatementNamesFor({ pool: getPool() }).admit({ name, text: statement.text });
1466
+ deferred.defer({
1467
+ text: statement.text,
1468
+ values: prepareBatchValues({ values: serializeParamsRaw({ values: [...statement.values] }) }),
1469
+ ...name === undefined ? {} : { name },
1470
+ required: true,
1471
+ onError: statement.onError,
1472
+ ...statement.onStart === undefined ? {} : { onStart: statement.onStart },
1473
+ ...statement.onSettled === undefined ? {} : { onSettled: statement.onSettled },
1474
+ onSuccess: () => observeRowChanges({ scope, count: statement.effect === "read" ? 0 : "unknown" })
1475
+ });
1476
+ },
1455
1477
  deferStatement: (statement) => {
1456
1478
  budget.assertUsable({ stage: "statement" });
1457
1479
  deferred.defer({ text: statement.text, values: prepareBatchValues({ values: statement.values }) });
@@ -1744,6 +1766,26 @@ function pgAdapter(options) {
1744
1766
  return transactionSettingRefusedError({ provider: PROVIDER, settings, setting: findRefusedSetting({ settings, cause: mapped }), cause: mapped, deferred: true });
1745
1767
  };
1746
1768
  let openingFailure;
1769
+ let queuedFailure;
1770
+ const observeDeferred = (control, outcome) => {
1771
+ for (let index = 0;index < control.length; index++) {
1772
+ const entry = control[index];
1773
+ const step = outcome.steps[index];
1774
+ if (step?.status === "ok") {
1775
+ entry.onSuccess?.();
1776
+ entry.onSettled?.({ status: "fulfilled", value: { rows: step.result.rows, affectedRows: step.result.rowCount ?? 0 } });
1777
+ } else {
1778
+ const origin = mapPgDriverError({ error: step?.status === "failed" ? step.error : firstBatchFailure({ outcome })?.error ?? outcome.transportError });
1779
+ if (entry.required === true && step?.status === "failed") {
1780
+ queuedFailure ??= origin;
1781
+ if (entry.name !== undefined && isStaleStatementFailure({ error: queuedFailure }))
1782
+ staleNamedStatement = true;
1783
+ entry.onError?.(queuedFailure);
1784
+ }
1785
+ entry.onSettled?.({ status: "rejected", reason: queuedFailure ?? origin });
1786
+ }
1787
+ }
1788
+ };
1747
1789
  let inFlight = null;
1748
1790
  const commits = capable && budget.totalMs === null && !((client.connectionParameters?.query_timeout ?? 0) > 0);
1749
1791
  let ridden;
@@ -1789,6 +1831,8 @@ function pgAdapter(options) {
1789
1831
  dispatchParams.sent?.();
1790
1832
  return cancellation.run({ config, execution: dispatchParams.execution, onReadControl: dispatchParams.onReadControl, send: () => onClient.query(config) });
1791
1833
  };
1834
+ if (cleanup !== true && queuedFailure !== undefined)
1835
+ throw queuedFailure;
1792
1836
  if (cleanup !== true && cancellationAttempted)
1793
1837
  throw transactionAbortedError2({ cause: readCancelledError2({ stage: "running" }) });
1794
1838
  if (cleanup !== true && openingFailure !== undefined)
@@ -1835,11 +1879,16 @@ function pgAdapter(options) {
1835
1879
  if (statement.length > 0)
1836
1880
  dispatchParams.sent?.();
1837
1881
  const commitIndex = control.length + statement.length;
1838
- const batch = runTurn(() => runBatch({ client, steps: [...control, ...statement, ...rides ? [COMMIT_STEP] : []], types: EXACT_TYPES }));
1882
+ const batch = runTurn(() => {
1883
+ for (const step of control)
1884
+ step.onStart?.();
1885
+ return runBatch({ client, steps: [...control, ...statement, ...rides ? [COMMIT_STEP] : []], types: EXACT_TYPES });
1886
+ });
1839
1887
  if (rides)
1840
1888
  trackRidden(batch, commitIndex);
1841
1889
  const burst = batch.then((received) => {
1842
1890
  const outcome = rides ? beforeCommit({ outcome: received, commitIndex }) : received;
1891
+ observeDeferred(control, outcome);
1843
1892
  const failure = firstBatchFailure({ outcome });
1844
1893
  if (failure !== undefined) {
1845
1894
  const openingFailed = failure.index < openingSteps || outcome.transportError !== undefined && openingSteps > 0;
@@ -1851,6 +1900,10 @@ function pgAdapter(options) {
1851
1900
  if (typeof mapped.meta.sqlstate === "string")
1852
1901
  markTransactionAborted({ scope, cause: mapped });
1853
1902
  }
1903
+ if (dispatchParams.stage === "commit" && failure.index < control.length && outcome.transportError === undefined)
1904
+ commitProgress = "not-sent";
1905
+ if (queuedFailure !== undefined)
1906
+ throw queuedFailure;
1854
1907
  throw openingFailed && settingsDeferred && failure.index === opening.length && openingFailure !== undefined ? openingFailure : failure.error;
1855
1908
  }
1856
1909
  if (statement.length === 0)
@@ -1902,7 +1955,7 @@ function pgAdapter(options) {
1902
1955
  });
1903
1956
  return;
1904
1957
  }
1905
- const mapped = failure.index < openingSteps ? openingStepError(failure.index, failure.error) : mapPgDriverError({ error: failure.error });
1958
+ const mapped = queuedFailure ?? (failure.index < openingSteps ? openingStepError(failure.index, failure.error) : mapPgDriverError({ error: failure.error }));
1906
1959
  if (failure.index < openingSteps || outcome.transportError !== undefined && openingSteps > 0)
1907
1960
  openingFailure = mapped;
1908
1961
  if (typeof mapped.meta.sqlstate === "string")
@@ -1933,7 +1986,7 @@ function pgAdapter(options) {
1933
1986
  }
1934
1987
  });
1935
1988
  };
1936
- const sendRefusal = () => ridden !== undefined ? commitSentRefusal() : cancellationAttempted ? transactionAbortedError2({ cause: readCancelledError2({ stage: "running" }) }) : openingFailure !== undefined ? transactionAbortedError2({ cause: openingFailure }) : scope.state.poisoned ?? (scope.state.aborted === null ? undefined : transactionAbortedError2({ cause: scope.state.aborted.cause }));
1989
+ const sendRefusal = () => queuedFailure !== undefined ? queuedFailure : ridden !== undefined ? commitSentRefusal() : cancellationAttempted ? transactionAbortedError2({ cause: readCancelledError2({ stage: "running" }) }) : openingFailure !== undefined ? transactionAbortedError2({ cause: openingFailure }) : scope.state.poisoned ?? (scope.state.aborted === null ? undefined : transactionAbortedError2({ cause: scope.state.aborted.cause }));
1937
1990
  const ridesQueued = (live) => {
1938
1991
  const last = live.at(-1);
1939
1992
  if (last === undefined || last.commit !== true && last.lane.seat?.group.final !== true || queued.length > 0 || inFlight !== null || ordinaryOpen > 0 || !ridesNow())
@@ -1976,18 +2029,24 @@ function pgAdapter(options) {
1976
2029
  const control = inTurn ? [] : deferred.takeSteps();
1977
2030
  const openingSteps = carriesOpening ? openingLength : 0;
1978
2031
  const commitIndex = control.length + live.length;
1979
- const sendBatch = () => runBatch({
1980
- client,
1981
- steps: [...control, ...live.map((entry) => entry.name === undefined ? { text: entry.text, values: entry.values } : { text: entry.text, values: entry.values, name: entry.name }), ...rides ? [COMMIT_STEP] : []],
1982
- types: EXACT_TYPES
1983
- });
2032
+ const sendBatch = () => {
2033
+ for (const step of control)
2034
+ step.onStart?.();
2035
+ return runBatch({
2036
+ client,
2037
+ steps: [...control, ...live.map((entry) => entry.name === undefined ? { text: entry.text, values: entry.values } : { text: entry.text, values: entry.values, name: entry.name }), ...rides ? [COMMIT_STEP] : []],
2038
+ types: EXACT_TYPES
2039
+ });
2040
+ };
1984
2041
  const batch = inTurn ? new Promise((resolve) => {
1985
2042
  resolve(sendBatch());
1986
2043
  }) : runTurn(sendBatch);
1987
2044
  if (rides)
1988
2045
  trackRidden(batch, commitIndex);
1989
2046
  const burst = batch.then((received) => {
1990
- distributeBurst({ outcome: rides ? beforeCommit({ outcome: received, commitIndex }) : received, live, controlSteps: control.length, openingSteps });
2047
+ const outcome = rides ? beforeCommit({ outcome: received, commitIndex }) : received;
2048
+ observeDeferred(control, outcome);
2049
+ distributeBurst({ outcome, live, controlSteps: control.length, openingSteps });
1991
2050
  }).then(undefined, (error) => {
1992
2051
  if (openingSteps > 0)
1993
2052
  openingFailure ??= mapPgDriverError({ error });
@@ -2516,7 +2575,7 @@ function pgAdapter(options) {
2516
2575
  throw riding.state === "skipped" ? transactionAbortedError2({ cause: scope.state.aborted?.cause }) : riding.error;
2517
2576
  }
2518
2577
  budget.assertUsable({ stage: "commit" });
2519
- if (!deferred.opened()) {
2578
+ if (!deferred.opened() && !deferred.hasRequired()) {
2520
2579
  budget.close({ outcome: "committed" });
2521
2580
  return result;
2522
2581
  }
@@ -2559,7 +2618,7 @@ function pgAdapter(options) {
2559
2618
  budget.close({ outcome: "committed" });
2560
2619
  throw caught;
2561
2620
  }
2562
- const error = riding?.state === "lost" ? riding.error : caught;
2621
+ const error = riding?.state === "lost" ? riding.error : queuedFailure ?? caught;
2563
2622
  if (isSavepointRollbackFailedError(error) || scope.state.poisoned !== null) {
2564
2623
  discard = true;
2565
2624
  budget.close({ outcome: "unknown" });
@@ -2715,4 +2774,4 @@ export {
2715
2774
  validateTransactionDeadline2 as validateTransactionDeadline
2716
2775
  };
2717
2776
 
2718
- //# debugId=7B0EC2D2FB69EE3264756E2164756E21
2777
+ //# debugId=F571608C9B212DA864756E2164756E21