@happyvertical/sql 0.89.4 → 0.89.6

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
@@ -353,6 +353,13 @@ child rollback therefore cannot silently remove a successful parent operation.
353
353
  The enclosing commit or rollback drains all
354
354
  accepted child scopes before ending the transaction. PostgreSQL pools separate
355
355
  connections, so top-level transactions there run concurrently and never queue.
356
+ PostgreSQL CRUD helpers apply the same value serialization both inside and
357
+ outside a transaction: objects and arrays are encoded for JSON/JSONB columns,
358
+ dates use ISO timestamps, binary buffers and views retain their native `bytea`
359
+ representation, `null` remains SQL `NULL`, and scalar values pass through
360
+ unchanged. If a PostgreSQL statement aborts a transaction, later
361
+ operations and commit preserve the first statement error rather than replacing
362
+ it with the generic `25P02 current transaction is aborted` state error.
356
363
  Every SQLite transaction-scoped operation is registered when its public method
357
364
  is called. Ending a callback or invoking a manual handle's commit/rollback
358
365
  seals the scope synchronously, drains operations already accepted, and rejects
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import { mkdir, readdir, stat } from "node:fs/promises";
8
8
  import { basename, extname, join } from "node:path";
9
9
  import { AsyncLocalStorage } from "node:async_hooks";
10
10
  import { webcrypto } from "node:crypto";
11
- import { Pool } from "pg";
11
+ import { DatabaseError as DatabaseError$1, Pool } from "pg";
12
12
  //#region \0rolldown/runtime.js
13
13
  var __defProp = Object.defineProperty;
14
14
  var __exportAll = (all, no_symbols) => {
@@ -2337,21 +2337,13 @@ async function createDatabase(config) {
2337
2337
  const serializeValue = (value) => {
2338
2338
  if (value === null || value === void 0) return value;
2339
2339
  if (value instanceof Date) return value.toISOString();
2340
+ if (Buffer.isBuffer(value) || ArrayBuffer.isView(value)) return value;
2340
2341
  if (typeof value === "object") return JSON.stringify(value);
2341
2342
  return value;
2342
2343
  };
2343
2344
  /**
2344
2345
  * Serializes all values in an object for database storage
2345
2346
  */
2346
- /**
2347
- * The serializer the transaction-scoped interfaces have always used: none.
2348
- *
2349
- * Identity, deliberately — not even the `undefined`-dropping the pool-backed
2350
- * `serializeRecord` does. Those copies bound `undefined` straight through to
2351
- * `pg`, which stores NULL, whereas dropping the key omits the column and lets
2352
- * its default apply. Preserving that is the point of this parameter.
2353
- */
2354
- const passThroughRecord = (record) => record;
2355
2347
  const serializeRecord = (record) => {
2356
2348
  const serialized = {};
2357
2349
  for (const [key, value] of Object.entries(record)) {
@@ -2505,12 +2497,183 @@ async function createDatabase(config) {
2505
2497
  return rollbackError;
2506
2498
  }
2507
2499
  };
2500
+ /**
2501
+ * Keeps the first PostgreSQL statement failure attached to a transaction.
2502
+ *
2503
+ * PostgreSQL reports every later statement in an aborted transaction as
2504
+ * 25P02. Replacing the first failure with that generic state error hides the
2505
+ * actionable code and diagnostics from callers. The scoped executor instead
2506
+ * rethrows the first failure whenever PostgreSQL reports 25P02, and exposes
2507
+ * the recorded failure so a swallowed statement error cannot make COMMIT's
2508
+ * implicit rollback look successful.
2509
+ */
2510
+ const createTransactionExecutor = (txClient) => {
2511
+ let firstError;
2512
+ let firstUnconfirmedError;
2513
+ const pending = /* @__PURE__ */ new Set();
2514
+ const isRollbackToSavepoint = (query) => {
2515
+ const sql = typeof query === "string" ? query : typeof query === "object" && query !== null && "text" in query && typeof query.text === "string" ? query.text : void 0;
2516
+ if (sql === void 0) return false;
2517
+ let statement = sql.trimStart();
2518
+ while (statement.startsWith("--") || statement.startsWith("/*")) {
2519
+ if (statement.startsWith("--")) {
2520
+ const newline = statement.search(/[\r\n]/);
2521
+ if (newline === -1) return false;
2522
+ statement = statement.slice(newline + 1).trimStart();
2523
+ continue;
2524
+ }
2525
+ const commentEnd = statement.indexOf("*/", 2);
2526
+ if (commentEnd === -1) return false;
2527
+ statement = statement.slice(commentEnd + 2).trimStart();
2528
+ }
2529
+ return /^ROLLBACK\s+TO(?:\s+SAVEPOINT)?\s+/i.test(statement);
2530
+ };
2531
+ const errorCode = (error) => {
2532
+ if (typeof error === "object" && error !== null && "code" in error && typeof error.code === "string") return error.code;
2533
+ };
2534
+ const preserveFirstError = (error) => {
2535
+ if (errorCode(error) === "25P02" && firstError !== void 0) return firstError;
2536
+ if (errorCode(error) === "25P02" && firstUnconfirmedError !== void 0) return firstUnconfirmedError;
2537
+ if (error instanceof DatabaseError$1) firstError ??= error;
2538
+ else firstUnconfirmedError ??= error;
2539
+ return error;
2540
+ };
2541
+ const track = (operation, query) => {
2542
+ const tracked = operation.then((result) => {
2543
+ if (isRollbackToSavepoint(query)) firstError = void 0;
2544
+ firstUnconfirmedError = void 0;
2545
+ return result;
2546
+ }).catch((error) => {
2547
+ throw preserveFirstError(error);
2548
+ });
2549
+ pending.add(tracked);
2550
+ tracked.finally(() => pending.delete(tracked)).catch(() => {});
2551
+ return tracked;
2552
+ };
2553
+ return {
2554
+ client: new Proxy(txClient, { get(target, property) {
2555
+ if (property === "query") return (...args) => {
2556
+ const query = args[0];
2557
+ const callbackIndex = typeof args.at(-1) === "function" ? args.length - 1 : -1;
2558
+ if (typeof query === "object" && query !== null && "submit" in query && typeof query.submit === "function") {
2559
+ if (!("handleError" in query) || typeof query.handleError !== "function" || !("handleReadyForQuery" in query) || typeof query.handleReadyForQuery !== "function") return Reflect.apply(target.query, target, args);
2560
+ const handleError = query.handleError;
2561
+ const handleReadyForQuery = query.handleReadyForQuery;
2562
+ let complete;
2563
+ const completion = new Promise((resolve) => {
2564
+ complete = resolve;
2565
+ });
2566
+ let completed = false;
2567
+ const finish = () => {
2568
+ if (completed) return;
2569
+ completed = true;
2570
+ query.handleError = handleError;
2571
+ query.handleReadyForQuery = handleReadyForQuery;
2572
+ pending.delete(completion);
2573
+ complete();
2574
+ };
2575
+ pending.add(completion);
2576
+ query.handleError = function(...handlerArgs) {
2577
+ const error = preserveFirstError(handlerArgs[0]);
2578
+ try {
2579
+ return Reflect.apply(handleError, this, [error, ...handlerArgs.slice(1)]);
2580
+ } finally {
2581
+ finish();
2582
+ }
2583
+ };
2584
+ query.handleReadyForQuery = function(...handlerArgs) {
2585
+ try {
2586
+ const result = Reflect.apply(handleReadyForQuery, this, handlerArgs);
2587
+ if (isRollbackToSavepoint(query)) firstError = void 0;
2588
+ firstUnconfirmedError = void 0;
2589
+ return result;
2590
+ } finally {
2591
+ finish();
2592
+ }
2593
+ };
2594
+ try {
2595
+ return Reflect.apply(target.query, target, args);
2596
+ } catch (error) {
2597
+ finish();
2598
+ throw preserveFirstError(error);
2599
+ }
2600
+ }
2601
+ if (callbackIndex !== -1) {
2602
+ const callback = args[callbackIndex];
2603
+ let complete;
2604
+ const completion = new Promise((resolve) => {
2605
+ complete = resolve;
2606
+ });
2607
+ pending.add(completion);
2608
+ args[callbackIndex] = (error, result) => {
2609
+ const callbackError = error === null || error === void 0 ? error : preserveFirstError(error);
2610
+ if (callbackError === null || callbackError === void 0) {
2611
+ if (isRollbackToSavepoint(query)) firstError = void 0;
2612
+ firstUnconfirmedError = void 0;
2613
+ }
2614
+ pending.delete(completion);
2615
+ complete();
2616
+ callback(callbackError, result);
2617
+ };
2618
+ try {
2619
+ return Reflect.apply(target.query, target, args);
2620
+ } catch (error) {
2621
+ pending.delete(completion);
2622
+ complete();
2623
+ throw preserveFirstError(error);
2624
+ }
2625
+ }
2626
+ const result = Reflect.apply(target.query, target, args);
2627
+ if (typeof result === "object" && result !== null && "then" in result && typeof result.then === "function") return track(Promise.resolve(result), query);
2628
+ if (typeof result === "object" && result !== null && "once" in result && typeof result.once === "function") {
2629
+ let complete;
2630
+ const completion = new Promise((resolve) => {
2631
+ complete = resolve;
2632
+ });
2633
+ const finish = () => {
2634
+ pending.delete(completion);
2635
+ complete();
2636
+ };
2637
+ pending.add(completion);
2638
+ result.once("error", (error) => {
2639
+ preserveFirstError(error);
2640
+ finish();
2641
+ });
2642
+ result.once("end", () => {
2643
+ if (isRollbackToSavepoint(query)) firstError = void 0;
2644
+ firstUnconfirmedError = void 0;
2645
+ finish();
2646
+ });
2647
+ result.once("close", finish);
2648
+ }
2649
+ return result;
2650
+ };
2651
+ return Reflect.get(target, property, target);
2652
+ } }),
2653
+ executor: { query: (sql, values) => track(txClient.query(sql, values), sql) },
2654
+ clearError: () => {
2655
+ firstError = void 0;
2656
+ },
2657
+ drain: async () => {
2658
+ while (pending.size > 0) await Promise.allSettled([...pending]);
2659
+ },
2660
+ getError: () => firstError,
2661
+ getUnconfirmedError: () => firstUnconfirmedError
2662
+ };
2663
+ };
2664
+ const assertCommitted = (result, transactionState) => {
2665
+ if (result.command === "COMMIT") return;
2666
+ const queryError = transactionState?.getError() ?? transactionState?.getUnconfirmedError();
2667
+ if (queryError !== void 0) throw queryError;
2668
+ throw new DatabaseError("PostgreSQL rolled back the transaction at commit", { command: result.command });
2669
+ };
2508
2670
  const executeNullAwarePostgresUpsertInTransaction = async (table, conflictColumns, serializedData) => {
2509
2671
  const txClient = await acquireTxClient();
2510
2672
  try {
2511
2673
  await txClient.query("BEGIN");
2512
2674
  const result = await executeNullAwarePostgresUpsert(txClient, table, conflictColumns, serializedData);
2513
- await txClient.query("COMMIT");
2675
+ const commitResult = await txClient.query("COMMIT");
2676
+ assertCommitted(commitResult);
2514
2677
  releaseTxClient(txClient);
2515
2678
  return result;
2516
2679
  } catch (error) {
@@ -2567,34 +2730,14 @@ async function createDatabase(config) {
2567
2730
  * Binding a client is the only difference between the two, so it is the only
2568
2731
  * thing this factory parameterizes.
2569
2732
  *
2570
- * One thing is deliberately NOT unified: how `insert` and `update` serialize
2571
- * values. The pool-backed methods run every value through `serializeRecord`,
2572
- * which JSON-stringifies objects and arrays; the transaction-scoped copies
2573
- * never did, so they handed the raw value to `pg`. That is not a cosmetic
2574
- * difference — it decides what lands in the column, and no single answer is
2575
- * right for both shapes:
2576
- *
2577
- * ```
2578
- * text[] column jsonb column
2579
- * raw JS array accepted "invalid input syntax for type json"
2580
- * JSON string "malformed accepted
2581
- * array literal"
2582
- * ```
2583
- *
2584
- * So collapsing the two would have silently broken array-column writes inside
2585
- * a transaction while fixing jsonb-array writes, or the reverse — and would
2586
- * have corrupted `bytea` writes, where a Buffer would become the JSON of a
2587
- * Buffer with no error anywhere. Neither belongs in a fix for the error
2588
- * contract, so the serializer is a parameter: each interface keeps exactly
2589
- * the behaviour it had, and the difference is stated here instead of being an
2590
- * accident of two copies drifting apart.
2591
- *
2592
2733
  * @param executor - Pool or checked-out client every statement runs on
2593
2734
  * @param inTransaction - Whether `executor` is already inside a transaction
2594
2735
  * @param serialize - Applied to each record before `insert`/`update` builds
2595
- * its SQL. Pass-through for the transaction-scoped interfaces.
2736
+ * its SQL
2737
+ * @param clearTransactionError - Clears a statement error after a savepoint
2738
+ * has successfully restored the transaction to a usable state
2596
2739
  */
2597
- const createClientMethods = (executor, inTransaction, serialize) => {
2740
+ const createClientMethods = (executor, inTransaction, serialize, clearTransactionError = () => {}) => {
2598
2741
  /**
2599
2742
  * Inserts one or more records into a table
2600
2743
  *
@@ -2980,6 +3123,7 @@ async function createDatabase(config) {
2980
3123
  return result;
2981
3124
  } catch (error) {
2982
3125
  await executor.query(`ROLLBACK TO SAVEPOINT ${name}`);
3126
+ clearTransactionError();
2983
3127
  await executor.query(`RELEASE SAVEPOINT ${name}`);
2984
3128
  throw error;
2985
3129
  }
@@ -3143,26 +3287,27 @@ async function createDatabase(config) {
3143
3287
  * rather than on a lock, so `deadlock_timeout` never fires and the process
3144
3288
  * hangs until some unrelated timeout.
3145
3289
  *
3146
- * @param txClient - The client running the enclosing transaction
3290
+ * @param executor - Error-preserving executor bound to the enclosing client
3147
3291
  * @param scopeFor - Builds the interface handed to the nested callback
3148
3292
  */
3149
- const createNestedTransaction = (txClient, scopeFor) => {
3293
+ const createNestedTransaction = (executor, scopeFor, clearTransactionError) => {
3150
3294
  const nestedScope = new AsyncLocalStorage();
3151
3295
  const rootScope = { tail: Promise.resolve() };
3152
3296
  const runScope = async (scope, callback) => {
3153
3297
  savepointSequence += 1;
3154
3298
  const name = `hv_sp_${savepointSequence}`;
3155
- await txClient.query(`SAVEPOINT ${name}`);
3299
+ await executor.query(`SAVEPOINT ${name}`);
3156
3300
  try {
3157
3301
  const result = await callback(scopeFor());
3158
3302
  await scope.tail;
3159
- await txClient.query(`RELEASE SAVEPOINT ${name}`);
3303
+ await executor.query(`RELEASE SAVEPOINT ${name}`);
3160
3304
  return result;
3161
3305
  } catch (error) {
3162
3306
  await scope.tail;
3163
3307
  try {
3164
- await txClient.query(`ROLLBACK TO SAVEPOINT ${name}`);
3165
- await txClient.query(`RELEASE SAVEPOINT ${name}`);
3308
+ await executor.query(`ROLLBACK TO SAVEPOINT ${name}`);
3309
+ clearTransactionError();
3310
+ await executor.query(`RELEASE SAVEPOINT ${name}`);
3166
3311
  } catch {}
3167
3312
  throw error;
3168
3313
  }
@@ -3187,23 +3332,29 @@ async function createDatabase(config) {
3187
3332
  const transaction = async (callback) => {
3188
3333
  const txClient = await acquireTxClient();
3189
3334
  let nestedTransaction;
3335
+ const transactionState = createTransactionExecutor(txClient);
3190
3336
  try {
3191
3337
  await txClient.query("BEGIN");
3192
3338
  let txDb;
3193
- nestedTransaction = createNestedTransaction(txClient, () => txDb);
3339
+ nestedTransaction = createNestedTransaction(transactionState.executor, () => txDb, transactionState.clearError);
3194
3340
  txDb = {
3195
3341
  url,
3196
- client: txClient,
3197
- ...createClientMethods(txClient, true, passThroughRecord),
3342
+ client: transactionState.client,
3343
+ ...createClientMethods(transactionState.executor, true, serializeRecord, transactionState.clearError),
3198
3344
  transaction: nestedTransaction
3199
3345
  };
3200
3346
  const result = await callback(txDb);
3201
3347
  await nestedTransaction.drain();
3202
- await txClient.query("COMMIT");
3348
+ await transactionState.drain();
3349
+ const statementError = transactionState.getError();
3350
+ if (statementError !== void 0) throw statementError;
3351
+ const commitResult = await txClient.query("COMMIT");
3352
+ assertCommitted(commitResult, transactionState);
3203
3353
  releaseTxClient(txClient);
3204
3354
  return result;
3205
3355
  } catch (error) {
3206
3356
  await nestedTransaction?.drain();
3357
+ await transactionState.drain();
3207
3358
  const rollbackError = await discardTxClient(txClient);
3208
3359
  if (rollbackError !== void 0 && error instanceof Error) error.cause ??= rollbackError;
3209
3360
  throw error;
@@ -3227,11 +3378,16 @@ async function createDatabase(config) {
3227
3378
  }
3228
3379
  let active = true;
3229
3380
  let nestedTransaction;
3381
+ const transactionState = createTransactionExecutor(txClient);
3230
3382
  const end = async (command) => {
3231
3383
  if (!active) throw new DatabaseError("Transaction already ended", {});
3232
3384
  try {
3233
3385
  await nestedTransaction?.drain();
3234
- await txClient.query(command);
3386
+ await transactionState.drain();
3387
+ const statementError = transactionState.getError();
3388
+ if (command === "COMMIT" && statementError !== void 0) throw statementError;
3389
+ const result = await txClient.query(command);
3390
+ if (command === "COMMIT") assertCommitted(result, transactionState);
3235
3391
  active = false;
3236
3392
  releaseTxClient(txClient);
3237
3393
  } catch (error) {
@@ -3245,11 +3401,11 @@ async function createDatabase(config) {
3245
3401
  const rollback = () => end("ROLLBACK");
3246
3402
  const isActive = () => active;
3247
3403
  let txHandle;
3248
- nestedTransaction = createNestedTransaction(txClient, () => txHandle);
3404
+ nestedTransaction = createNestedTransaction(transactionState.executor, () => txHandle, transactionState.clearError);
3249
3405
  txHandle = {
3250
3406
  url,
3251
- client: txClient,
3252
- ...createClientMethods(txClient, true, passThroughRecord),
3407
+ client: transactionState.client,
3408
+ ...createClientMethods(transactionState.executor, true, serializeRecord, transactionState.clearError),
3253
3409
  transaction: nestedTransaction,
3254
3410
  commit,
3255
3411
  rollback,