@spfn/core 0.2.0-beta.53 → 0.2.0-beta.54

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.
Files changed (41) hide show
  1. package/dist/{boss-Cxqc-Oiw.d.ts → boss-CEik0yq-.d.ts} +7 -0
  2. package/dist/cache/index.js +10 -1
  3. package/dist/cache/index.js.map +1 -1
  4. package/dist/config/index.d.ts +213 -0
  5. package/dist/config/index.js +43 -1
  6. package/dist/config/index.js.map +1 -1
  7. package/dist/db/index.d.ts +57 -0
  8. package/dist/db/index.js +55 -8
  9. package/dist/db/index.js.map +1 -1
  10. package/dist/define-middleware-DuXD8Hvu.d.ts +167 -0
  11. package/dist/env/index.js +4 -3
  12. package/dist/env/index.js.map +1 -1
  13. package/dist/errors/index.d.ts +10 -0
  14. package/dist/errors/index.js +20 -2
  15. package/dist/errors/index.js.map +1 -1
  16. package/dist/event/index.d.ts +3 -3
  17. package/dist/event/index.js.map +1 -1
  18. package/dist/event/sse/client.d.ts +2 -2
  19. package/dist/event/sse/index.d.ts +4 -4
  20. package/dist/event/sse/index.js +41 -16
  21. package/dist/event/sse/index.js.map +1 -1
  22. package/dist/event/ws/client.d.ts +2 -2
  23. package/dist/event/ws/index.d.ts +3 -3
  24. package/dist/event/ws/index.js +75 -16
  25. package/dist/event/ws/index.js.map +1 -1
  26. package/dist/job/index.d.ts +2 -2
  27. package/dist/job/index.js +4 -4
  28. package/dist/job/index.js.map +1 -1
  29. package/dist/middleware/index.d.ts +64 -2
  30. package/dist/middleware/index.js +1030 -96
  31. package/dist/middleware/index.js.map +1 -1
  32. package/dist/nextjs/server.js +17 -0
  33. package/dist/nextjs/server.js.map +1 -1
  34. package/dist/route/index.d.ts +3 -165
  35. package/dist/server/index.d.ts +4 -4
  36. package/dist/server/index.js +125 -39
  37. package/dist/server/index.js.map +1 -1
  38. package/dist/{token-manager-C2Ag5-s8.d.ts → token-manager-jKD_EsSE.d.ts} +0 -1
  39. package/dist/{types-VpVQIsyB.d.ts → types-BFB72jbM.d.ts} +1 -1
  40. package/dist/{types-CKsmzaB8.d.ts → types-DVjf37yO.d.ts} +37 -1
  41. package/package.json +6 -6
@@ -81,6 +81,13 @@ interface PoolConfig {
81
81
  max: number;
82
82
  /** Idle connection timeout in seconds */
83
83
  idleTimeout: number;
84
+ /**
85
+ * Maximum connections for the read-replica pool. Defaults to `max`. Set
86
+ * separately (DB_POOL_READ_MAX) so a master-replica app can size the two
87
+ * pools independently and keep `write.max + read.max` under the server's
88
+ * `max_connections` (otherwise each process opens up to 2 × max).
89
+ */
90
+ readMax?: number;
84
91
  }
85
92
  /**
86
93
  * Retry configuration for exponential backoff algorithm
@@ -1039,6 +1046,15 @@ interface TransactionalOptions {
1039
1046
  * ```
1040
1047
  */
1041
1048
  timeout?: number;
1049
+ /**
1050
+ * Idle-in-transaction timeout in milliseconds — Postgres reclaims the pooled
1051
+ * connection if the transaction sits idle (e.g. the handler awaits external
1052
+ * I/O) longer than this. A backstop against pool starvation, not a license
1053
+ * to do non-DB work inside a transaction. `0` disables it.
1054
+ *
1055
+ * @default 30000 (30s) or TRANSACTION_IDLE_TIMEOUT environment variable
1056
+ */
1057
+ idleTimeout?: number;
1042
1058
  }
1043
1059
  /**
1044
1060
  * Transaction middleware for Hono routes
@@ -1129,6 +1145,21 @@ interface RunInTransactionOptions {
1129
1145
  * ```
1130
1146
  */
1131
1147
  timeout?: number;
1148
+ /**
1149
+ * Idle-in-transaction timeout in milliseconds (root transactions only).
1150
+ *
1151
+ * Sets PostgreSQL `idle_in_transaction_session_timeout`: if the transaction
1152
+ * sits open without running a query for longer than this — e.g. while the
1153
+ * handler awaits external I/O inside the transaction — Postgres terminates
1154
+ * the session and rolls back, reclaiming the pooled connection instead of
1155
+ * letting one stuck request hold it (and its row locks) indefinitely.
1156
+ *
1157
+ * Do not put external I/O inside a transaction; this is a backstop, not a
1158
+ * license. `0` disables it.
1159
+ *
1160
+ * @default 30000 (30s) or TRANSACTION_IDLE_TIMEOUT environment variable
1161
+ */
1162
+ idleTimeout?: number;
1132
1163
  /**
1133
1164
  * Context string for logging (e.g., 'migration:add-user', 'script:cleanup')
1134
1165
  * @default 'transaction'
@@ -1633,6 +1664,32 @@ declare abstract class BaseRepository<TSchema extends Record<string, unknown> =
1633
1664
  limit?: number;
1634
1665
  offset?: number;
1635
1666
  }): Promise<T['$inferSelect'][]>;
1667
+ /**
1668
+ * Keyset (cursor) pagination — O(limit) instead of OFFSET's O(offset).
1669
+ *
1670
+ * Pages by a strictly-ordered, unique column instead of a numeric offset, so a
1671
+ * deep page doesn't scan and discard everything before it. Pass the cursor
1672
+ * column's value from the last row of the previous page as `after`; omit it for
1673
+ * the first page. The column must be unique and the sole sort key (e.g. an
1674
+ * auto-increment id or a ULID).
1675
+ *
1676
+ * @example
1677
+ * ```typescript
1678
+ * const page1 = await this._findManyKeyset(users, { cursorColumn: users.id, limit: 20 });
1679
+ * const page2 = await this._findManyKeyset(users, {
1680
+ * cursorColumn: users.id,
1681
+ * after: page1.at(-1)?.id,
1682
+ * limit: 20,
1683
+ * });
1684
+ * ```
1685
+ */
1686
+ protected _findManyKeyset<T extends PgTable>(table: T, options: {
1687
+ cursorColumn: PgColumn;
1688
+ limit: number;
1689
+ after?: string | number | bigint | Date;
1690
+ order?: 'asc' | 'desc';
1691
+ where?: Record<string, any> | SQL | undefined;
1692
+ }): Promise<T['$inferSelect'][]>;
1636
1693
  /**
1637
1694
  * Create a new record
1638
1695
  *
package/dist/db/index.js CHANGED
@@ -11,7 +11,7 @@ import { bigserial, timestamp, bigint, uuid as uuid$1, text, jsonb, pgSchema } f
11
11
  import { AsyncLocalStorage } from 'async_hooks';
12
12
  import { createMiddleware } from 'hono/factory';
13
13
  import { randomUUID } from 'crypto';
14
- import { sql, count as count$1, eq, and } from 'drizzle-orm';
14
+ import { sql, count as count$1, lt, gt, and, desc, asc, eq } from 'drizzle-orm';
15
15
 
16
16
  // src/db/manager/factory.ts
17
17
  function parseUniqueViolation(message) {
@@ -344,9 +344,12 @@ function parseEnvBoolean(key, defaultValue) {
344
344
  }
345
345
  }
346
346
  function getPoolConfig(options) {
347
+ const max = options?.max ?? parseEnvNumber("DB_POOL_MAX", 20, 10);
347
348
  return {
348
- max: options?.max ?? parseEnvNumber("DB_POOL_MAX", 20, 10),
349
- idleTimeout: options?.idleTimeout ?? parseEnvNumber("DB_POOL_IDLE_TIMEOUT", 30, 20)
349
+ max,
350
+ idleTimeout: options?.idleTimeout ?? parseEnvNumber("DB_POOL_IDLE_TIMEOUT", 30, 20),
351
+ // Defaults to the write `max`; DB_POOL_READ_MAX lets ops size the replica pool separately
352
+ readMax: options?.readMax ?? parseEnvNumber("DB_POOL_READ_MAX", max, max)
350
353
  };
351
354
  }
352
355
  function getRetryConfig() {
@@ -415,8 +418,9 @@ async function createWriteReadClients(writeUrl, readUrl, poolConfig, retryConfig
415
418
  dbLogger2.error("Failed to connect to write database", errorObj);
416
419
  throw new Error(`Write database connection failed: ${errorObj.message}`, { cause: error });
417
420
  }
421
+ const readPoolConfig = { ...poolConfig, max: poolConfig.readMax ?? poolConfig.max };
418
422
  try {
419
- readClient = await createDatabaseConnection(readUrl, poolConfig, retryConfig);
423
+ readClient = await createDatabaseConnection(readUrl, readPoolConfig, retryConfig);
420
424
  return {
421
425
  write: drizzle(writeClient),
422
426
  read: drizzle(readClient),
@@ -1412,6 +1416,7 @@ async function runInTransaction(callback, options = {}) {
1412
1416
  context = "transaction"
1413
1417
  } = options;
1414
1418
  const timeout = options.timeout ?? defaultTimeout;
1419
+ const idleTimeout = options.idleTimeout ?? env.TRANSACTION_IDLE_TIMEOUT;
1415
1420
  const txId = `tx_${randomUUID()}`;
1416
1421
  const validateAndThrow = (condition, message, logMessage, metadata) => {
1417
1422
  if (condition) {
@@ -1489,6 +1494,9 @@ async function runInTransaction(callback, options = {}) {
1489
1494
  if (timeout > 0 && !isNested) {
1490
1495
  await tx.execute(sql.raw(`SET LOCAL statement_timeout = ${timeout}`));
1491
1496
  }
1497
+ if (idleTimeout > 0 && !isNested) {
1498
+ await tx.execute(sql.raw(`SET LOCAL idle_in_transaction_session_timeout = ${idleTimeout}`));
1499
+ }
1492
1500
  return await runWithTransaction(tx, txId, async () => {
1493
1501
  const innerResult = await callback(tx);
1494
1502
  if (!isNested) {
@@ -1507,7 +1515,8 @@ async function runInTransaction(callback, options = {}) {
1507
1515
  txId,
1508
1516
  context,
1509
1517
  duration: `${duration}ms`,
1510
- threshold: `${slowThreshold}ms`
1518
+ threshold: `${slowThreshold}ms`,
1519
+ hint: "A transaction holds a pooled connection (and row locks) for its whole duration. If this is slow because of non-DB work (external API, etc.) inside the transaction, move that work out \u2014 it starves the connection pool."
1511
1520
  });
1512
1521
  } else {
1513
1522
  txLogger2.debug("Transaction committed", {
@@ -1546,7 +1555,8 @@ async function runInTransaction(callback, options = {}) {
1546
1555
  duration: `${duration}ms`,
1547
1556
  threshold: `${slowThreshold}ms`,
1548
1557
  error: error instanceof Error ? error.message : String(error),
1549
- errorType: error instanceof Error ? error.name : "Unknown"
1558
+ errorType: error instanceof Error ? error.name : "Unknown",
1559
+ hint: "If the error is an idle-in-transaction timeout, the transaction held a pooled connection while awaiting non-DB work (external API, etc.). Move that work out of the transaction."
1550
1560
  });
1551
1561
  } else {
1552
1562
  txLogger2.error("Transaction rolled back", {
@@ -1896,14 +1906,51 @@ var BaseRepository = class {
1896
1906
  const orderByArray = Array.isArray(options.orderBy) ? options.orderBy : [options.orderBy];
1897
1907
  query = query.orderBy(...orderByArray);
1898
1908
  }
1899
- if (options?.limit) {
1900
- query = query.limit(options.limit);
1909
+ const maxRows = env.DB_MAX_ROWS;
1910
+ const requestedLimit = options?.limit && options.limit > 0 ? options.limit : void 0;
1911
+ const effectiveLimit = maxRows > 0 ? Math.min(requestedLimit ?? maxRows, maxRows) : requestedLimit;
1912
+ if (effectiveLimit) {
1913
+ query = query.limit(effectiveLimit);
1901
1914
  }
1902
1915
  if (options?.offset) {
1903
1916
  query = query.offset(options.offset);
1904
1917
  }
1905
1918
  return query;
1906
1919
  }
1920
+ /**
1921
+ * Keyset (cursor) pagination — O(limit) instead of OFFSET's O(offset).
1922
+ *
1923
+ * Pages by a strictly-ordered, unique column instead of a numeric offset, so a
1924
+ * deep page doesn't scan and discard everything before it. Pass the cursor
1925
+ * column's value from the last row of the previous page as `after`; omit it for
1926
+ * the first page. The column must be unique and the sole sort key (e.g. an
1927
+ * auto-increment id or a ULID).
1928
+ *
1929
+ * @example
1930
+ * ```typescript
1931
+ * const page1 = await this._findManyKeyset(users, { cursorColumn: users.id, limit: 20 });
1932
+ * const page2 = await this._findManyKeyset(users, {
1933
+ * cursorColumn: users.id,
1934
+ * after: page1.at(-1)?.id,
1935
+ * limit: 20,
1936
+ * });
1937
+ * ```
1938
+ */
1939
+ async _findManyKeyset(table, options) {
1940
+ const { cursorColumn, after, order = "asc", where } = options;
1941
+ const maxRows = env.DB_MAX_ROWS;
1942
+ const limit = maxRows > 0 ? Math.min(options.limit, maxRows) : options.limit;
1943
+ const baseWhere = where ? isSQLWrapper(where) ? where : buildWhereFromObject(table, where) : void 0;
1944
+ const cursorPredicate = after !== void 0 ? order === "desc" ? lt(cursorColumn, after) : gt(cursorColumn, after) : void 0;
1945
+ const whereClause = baseWhere && cursorPredicate ? and(baseWhere, cursorPredicate) : cursorPredicate ?? baseWhere;
1946
+ let query = this.readDb.select().from(table);
1947
+ if (whereClause) {
1948
+ query = query.where(whereClause);
1949
+ }
1950
+ query = query.orderBy(order === "desc" ? desc(cursorColumn) : asc(cursorColumn));
1951
+ query = query.limit(limit);
1952
+ return query;
1953
+ }
1907
1954
  /**
1908
1955
  * Create a new record
1909
1956
  *