@spfn/core 0.3.0-beta.2 → 0.3.0-beta.21

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 (94) hide show
  1. package/README.md +343 -16
  2. package/dist/app-config/index.d.ts +134 -0
  3. package/dist/app-config/index.js +58 -0
  4. package/dist/app-config/index.js.map +1 -0
  5. package/dist/authz/index.js +1 -381
  6. package/dist/authz/index.js.map +1 -1
  7. package/dist/{boss-D16fO2oG.d.ts → boss-BiDCbbAQ.d.ts} +60 -7
  8. package/dist/codegen/index.d.ts +93 -15
  9. package/dist/codegen/index.js +626 -165
  10. package/dist/codegen/index.js.map +1 -1
  11. package/dist/config/index.d.ts +0 -63
  12. package/dist/config/index.js +15 -15
  13. package/dist/config/index.js.map +1 -1
  14. package/dist/contract/index.d.ts +40 -17
  15. package/dist/contract/index.js +147 -23
  16. package/dist/contract/index.js.map +1 -1
  17. package/dist/db/index.d.ts +201 -34
  18. package/dist/db/index.js +294 -158
  19. package/dist/db/index.js.map +1 -1
  20. package/dist/{define-middleware-DfDP39Nq.d.ts → define-middleware-CVKgqo8S.d.ts} +1 -1
  21. package/dist/env/index.d.ts +6 -442
  22. package/dist/env/index.js +13 -16
  23. package/dist/env/index.js.map +1 -1
  24. package/dist/env/loader.js +24 -1
  25. package/dist/env/loader.js.map +1 -1
  26. package/dist/error-TsUIjp_x.d.ts +157 -0
  27. package/dist/errors/index.js +1 -381
  28. package/dist/errors/index.js.map +1 -1
  29. package/dist/event/index.d.ts +5 -5
  30. package/dist/event/index.js +32 -1
  31. package/dist/event/index.js.map +1 -1
  32. package/dist/event/sse/client.d.ts +18 -4
  33. package/dist/event/sse/client.js +38 -2
  34. package/dist/event/sse/client.js.map +1 -1
  35. package/dist/event/sse/index.d.ts +5 -4
  36. package/dist/event/ws/client.d.ts +3 -2
  37. package/dist/event/ws/index.d.ts +4 -3
  38. package/dist/job/index.d.ts +48 -3
  39. package/dist/job/index.js +131 -52
  40. package/dist/job/index.js.map +1 -1
  41. package/dist/logger/index.js +0 -12
  42. package/dist/logger/index.js.map +1 -1
  43. package/dist/middleware/index.d.ts +6 -2
  44. package/dist/middleware/index.js +7 -388
  45. package/dist/middleware/index.js.map +1 -1
  46. package/dist/nextjs/index.d.ts +88 -8
  47. package/dist/nextjs/index.js +138 -62
  48. package/dist/nextjs/index.js.map +1 -1
  49. package/dist/nextjs/server.d.ts +45 -7
  50. package/dist/nextjs/server.js +14 -0
  51. package/dist/nextjs/server.js.map +1 -1
  52. package/dist/ops/index.d.ts +62 -43
  53. package/dist/ops/index.js +638 -97
  54. package/dist/ops/index.js.map +1 -1
  55. package/dist/registry-C2KQCh0m.d.ts +473 -0
  56. package/dist/route/index.d.ts +6 -3
  57. package/dist/route/index.js +97 -28
  58. package/dist/route/index.js.map +1 -1
  59. package/dist/route/types.d.ts +7 -1
  60. package/dist/{router-Qbssr11H.d.ts → route-builder-DRW-MSCJ.d.ts} +52 -127
  61. package/dist/router-4YoIy5fN.d.ts +172 -0
  62. package/dist/{token-manager-BT5EnUAR.d.ts → router-CysBmgv3.d.ts} +81 -127
  63. package/dist/server/index.d.ts +185 -14
  64. package/dist/server/index.js +883 -78
  65. package/dist/server/index.js.map +1 -1
  66. package/dist/surface/index.d.ts +115 -0
  67. package/dist/surface/index.js +1003 -0
  68. package/dist/surface/index.js.map +1 -0
  69. package/dist/token-manager-Ckjt2-qq.d.ts +122 -0
  70. package/dist/{types-D1c57Ko-.d.ts → types-5Rl11_Nb.d.ts} +43 -4
  71. package/dist/{types-ZQODsBft.d.ts → types-B8zAxFsE.d.ts} +2 -1
  72. package/dist/types-CxhPH4s_.d.ts +375 -0
  73. package/dist/{types-2AbaW4Ie.d.ts → types-W4u_S0lF.d.ts} +4 -3
  74. package/docs/file-upload.md +195 -333
  75. package/package.json +20 -9
  76. package/src/cache/README.md +330 -0
  77. package/src/codegen/README.md +634 -0
  78. package/src/config/README.md +333 -0
  79. package/src/contract/README.md +440 -0
  80. package/src/db/README.md +589 -0
  81. package/src/db/manager/README.md +500 -0
  82. package/src/db/schema/README.md +354 -0
  83. package/src/db/transaction/README.md +822 -0
  84. package/src/env/README.md +787 -0
  85. package/src/errors/README.md +429 -0
  86. package/src/event/README.md +789 -0
  87. package/src/job/README.md +613 -0
  88. package/src/logger/README.md +321 -0
  89. package/src/middleware/README.md +634 -0
  90. package/src/nextjs/README.md +688 -0
  91. package/src/route/README.md +763 -0
  92. package/src/security/README.md +100 -0
  93. package/src/server/README.md +704 -0
  94. package/dist/types-9oszaJqp.d.ts +0 -250
package/dist/db/index.js CHANGED
@@ -3,10 +3,11 @@ import { env } from '@spfn/core/config';
3
3
  import { logger } from '@spfn/core/logger';
4
4
  import net from 'net';
5
5
  import postgres from 'postgres';
6
- import { QueryError, ConnectionError, DeadlockError, TransactionError, ConstraintViolationError, DuplicateEntryError, DatabaseError } from '@spfn/core/errors';
6
+ import { QueryError, ConnectionError, DeadlockError, TransactionError, ConstraintViolationError, DuplicateEntryError, SerializableError } from '@spfn/core/errors';
7
7
  import { parseNumber, parseBoolean } from '@spfn/core/env';
8
- import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
9
- import { join, dirname, basename } from 'path';
8
+ import { existsSync, readdirSync, readFileSync, statSync, realpathSync, lstatSync } from 'fs';
9
+ import { join, relative } from 'path';
10
+ import mm from 'micromatch';
10
11
  import { sql, count as count$1, lt, gt, and, desc, asc, eq } from 'drizzle-orm';
11
12
  import { bigserial, timestamp, bigint, uuid as uuid$1, text, jsonb, pgSchema } from 'drizzle-orm/pg-core';
12
13
  import { createHash, randomUUID } from 'crypto';
@@ -1213,7 +1214,10 @@ var BARREL_FILE_PATTERNS = [
1213
1214
  "/config.js",
1214
1215
  "/config.mjs"
1215
1216
  ];
1216
- var SUPPORTED_EXTENSIONS = [".ts", ".js", ".mjs"];
1217
+ var DEFAULT_ENTITY_GLOB = "./src/server/entities/**/*.ts";
1218
+ var SUPPORTED_EXTENSIONS = [".ts", ".mts", ".cts", ".tsx", ".js", ".mjs", ".cjs", ".jsx"];
1219
+ var DECLARATION_PATTERN = /\.d\.[mc]?ts$/;
1220
+ var SKIPPED_DIRECTORIES = /^(\.|node_modules$)/;
1217
1221
  function isBarrelFile(filePath) {
1218
1222
  const posixPath = toPosixPath(filePath);
1219
1223
  return BARREL_FILE_PATTERNS.some((pattern) => posixPath.endsWith(pattern));
@@ -1223,90 +1227,100 @@ function isAbsolutePath(path) {
1223
1227
  return !!path.match(/^[A-Za-z]:[\/\\]/);
1224
1228
  }
1225
1229
  function hasSupportedExtension(filePath) {
1226
- if (filePath.endsWith(".d.ts")) return false;
1230
+ if (DECLARATION_PATTERN.test(filePath)) return false;
1227
1231
  return SUPPORTED_EXTENSIONS.some((ext) => filePath.endsWith(ext));
1228
1232
  }
1229
1233
  function filterBarrelFiles(files) {
1230
1234
  return files.filter((file) => !isBarrelFile(file));
1231
1235
  }
1232
- function scanDirectoryRecursive(dir, extension) {
1233
- const files = [];
1234
- if (!existsSync(dir)) return files;
1236
+ function safeStat(path) {
1235
1237
  try {
1236
- const entries = readdirSync(dir);
1237
- for (const entry of entries) {
1238
- const fullPath = join(dir, entry);
1239
- try {
1240
- const stat = statSync(fullPath);
1241
- if (stat.isDirectory()) {
1242
- files.push(...scanDirectoryRecursive(fullPath, extension));
1243
- } else if (stat.isFile()) {
1244
- if ((!extension || fullPath.endsWith(extension)) && hasSupportedExtension(fullPath)) {
1245
- files.push(fullPath);
1246
- }
1247
- }
1248
- } catch (error) {
1249
- }
1250
- }
1251
- } catch (error) {
1238
+ return statSync(path);
1239
+ } catch {
1240
+ return void 0;
1252
1241
  }
1253
- return files;
1254
1242
  }
1255
- function scanDirectorySingleLevel(dir, filePattern) {
1256
- const files = [];
1257
- if (!existsSync(dir)) return files;
1243
+ function safeReaddir(dir) {
1258
1244
  try {
1259
- const entries = readdirSync(dir);
1260
- for (const entry of entries) {
1261
- const fullPath = join(dir, entry);
1262
- try {
1263
- const stat = statSync(fullPath);
1264
- if (stat.isFile()) {
1265
- if ((filePattern === "*" || filePattern.startsWith("*.") && entry.endsWith(filePattern.slice(1))) && hasSupportedExtension(fullPath)) {
1266
- files.push(fullPath);
1267
- }
1268
- }
1269
- } catch (error) {
1270
- }
1271
- }
1272
- } catch (error) {
1245
+ return readdirSync(dir);
1246
+ } catch {
1247
+ return [];
1273
1248
  }
1274
- return files;
1275
1249
  }
1276
- function detectSchemasFromFiles(files) {
1277
- const schemas = /* @__PURE__ */ new Set(["public"]);
1278
- const pgSchemaPattern = /pgSchema\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
1279
- const createSchemaPattern = /createSchema\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
1280
- for (const filePath of files) {
1281
- try {
1282
- const content = readFileSync(filePath, "utf-8");
1283
- let match;
1284
- while ((match = pgSchemaPattern.exec(content)) !== null) {
1285
- schemas.add(match[1]);
1286
- }
1287
- while ((match = createSchemaPattern.exec(content)) !== null) {
1288
- const packageName = match[1];
1289
- const schemaName = packageName.replace(/@/g, "").replace(/\//g, "_").replace(/-/g, "_");
1290
- schemas.add(schemaName);
1291
- }
1292
- } catch {
1293
- }
1250
+ function walkDirectory(dir, maxDepth, ancestors = /* @__PURE__ */ new Set()) {
1251
+ const walk = { files: [], directories: [] };
1252
+ if (maxDepth < 1 || !safeStat(dir)?.isDirectory()) return walk;
1253
+ const realDir = realpathSafe(dir);
1254
+ if (ancestors.has(realDir)) return walk;
1255
+ const chain = new Set(ancestors).add(realDir);
1256
+ for (const entry of safeReaddir(dir)) {
1257
+ const path = join(dir, entry);
1258
+ const stat = safeStat(path);
1259
+ if (stat?.isFile() && hasSupportedExtension(path)) {
1260
+ walk.files.push(path);
1261
+ } else if (stat?.isDirectory() && !SKIPPED_DIRECTORIES.test(entry)) {
1262
+ walk.directories.push(path);
1263
+ const isLink = isSymlink(path);
1264
+ const child = walkDirectory(path, isLink ? Math.min(maxDepth - 1, 1) : maxDepth - 1, chain);
1265
+ for (const file of child.files) walk.files.push(file);
1266
+ for (const sub of child.directories) walk.directories.push(sub);
1267
+ }
1268
+ }
1269
+ return walk;
1270
+ }
1271
+ function isSymlink(path) {
1272
+ try {
1273
+ return lstatSync(path).isSymbolicLink();
1274
+ } catch {
1275
+ return false;
1294
1276
  }
1295
- return Array.from(schemas);
1296
1277
  }
1297
- function expandGlobPattern(pattern) {
1298
- if (!pattern.includes("*")) {
1299
- return existsSync(pattern) ? [pattern] : [];
1300
- }
1301
- if (pattern.includes("**")) {
1302
- const [baseDir, ...rest] = pattern.split("**");
1303
- const extension = rest.join("").replace(/[\/\\]\*\./g, "").trim();
1304
- const dir2 = baseDir.trim() || ".";
1305
- return scanDirectoryRecursive(dir2, extension || void 0);
1278
+ function realpathSafe(path) {
1279
+ try {
1280
+ return realpathSync(path);
1281
+ } catch {
1282
+ return path;
1283
+ }
1284
+ }
1285
+ function scanDirectory(dir, maxDepth) {
1286
+ return walkDirectory(dir, maxDepth).files;
1287
+ }
1288
+ function toRelativePattern(entry, cwd) {
1289
+ return toPosixPath(isAbsolutePath(entry) ? relative(cwd, entry) : entry).replace(/^\.\//, "").replace(/[()]/g, "\\$&");
1290
+ }
1291
+ function expandGlob(pattern, cwd) {
1292
+ const scan = mm.scan(pattern);
1293
+ const depth = scan.isGlobstar || scan.glob.includes("**") ? Infinity : scan.glob.split("/").length;
1294
+ const base = join(cwd, scan.base.replace(/\\([()])/g, "$1"));
1295
+ const matches = mm.matcher(pattern);
1296
+ const matched = (path) => matches(toPosixPath(relative(cwd, path)));
1297
+ const walk = walkDirectory(base, depth);
1298
+ return Array.from(/* @__PURE__ */ new Set([...walk.files.filter(matched), ...walk.directories.filter(matched).flatMap((dir) => scanDirectory(dir, 1))]));
1299
+ }
1300
+ function expandSchemaEntry(entry, cwd) {
1301
+ const pattern = toRelativePattern(entry, cwd);
1302
+ if (mm.scan(pattern).isGlob) {
1303
+ return expandGlob(pattern, cwd);
1304
+ }
1305
+ const path = isAbsolutePath(entry) ? entry : join(cwd, entry);
1306
+ const stat = safeStat(path);
1307
+ if (!stat) return [];
1308
+ return stat.isDirectory() ? scanDirectory(path, 1) : [path];
1309
+ }
1310
+ function selectDefaultSchema(cwd) {
1311
+ const registry = env.DRIZZLE_SCHEMA_PATH;
1312
+ const registryFile = isAbsolutePath(registry) ? registry : join(cwd, registry);
1313
+ const registryExists = safeStat(registryFile)?.isFile() === true;
1314
+ const scanned = filterBarrelFiles(expandSchemaEntry(DEFAULT_ENTITY_GLOB, cwd));
1315
+ if (scanned.length === 0 && registryExists) {
1316
+ return { schemas: [registry], source: `entity registry ${registry}` };
1306
1317
  }
1307
- const dir = dirname(pattern);
1308
- const filePattern = basename(pattern);
1309
- return scanDirectorySingleLevel(dir, filePattern);
1318
+ return {
1319
+ schemas: [DEFAULT_ENTITY_GLOB],
1320
+ source: `${DEFAULT_ENTITY_GLOB} scan`,
1321
+ files: scanned,
1322
+ registry: registryExists ? registry : void 0
1323
+ };
1310
1324
  }
1311
1325
  function discoverPackageSchemas(cwd) {
1312
1326
  const schemas = [];
@@ -1335,9 +1349,7 @@ function discoverPackageSchemas(cwd) {
1335
1349
  const packageSchemas = Array.isArray(pkgJson.spfn.schemas) ? pkgJson.spfn.schemas : [pkgJson.spfn.schemas];
1336
1350
  for (const schema of packageSchemas) {
1337
1351
  const absolutePath = join(pkgPath, schema);
1338
- const expandedFiles = expandGlobPattern(absolutePath);
1339
- const schemaFiles = filterBarrelFiles(expandedFiles);
1340
- schemas.push(...schemaFiles);
1352
+ schemas.push(...filterBarrelFiles(expandSchemaEntry(absolutePath, pkgPath)));
1341
1353
  }
1342
1354
  }
1343
1355
  } catch (error) {
@@ -1396,6 +1408,8 @@ function getDrizzleConfig(options = {}) {
1396
1408
  const schema2 = filteredSchemas.length === 1 ? filteredSchemas[0] : filteredSchemas;
1397
1409
  return {
1398
1410
  schema: schema2,
1411
+ schemaSource: `package ${options.packageFilter}`,
1412
+ schemaRegistry: void 0,
1399
1413
  out,
1400
1414
  dialect,
1401
1415
  dbCredentials: getDbCredentials(dialect, databaseUrl),
@@ -1404,32 +1418,18 @@ function getDrizzleConfig(options = {}) {
1404
1418
  }
1405
1419
  };
1406
1420
  }
1407
- const userSchema = options.schema ?? "./src/server/entities/**/*.ts";
1408
- const userSchemas = Array.isArray(userSchema) ? userSchema : [userSchema];
1409
- const packageSchemas = options.disablePackageDiscovery ? [] : discoverPackageSchemas(options.cwd ?? process.cwd());
1410
- let allSchemas = [...userSchemas, ...packageSchemas];
1411
1421
  const cwd = options.cwd ?? process.cwd();
1412
- let expandedFiles = [];
1413
- if (options.expandGlobs) {
1414
- for (const schema2 of allSchemas) {
1415
- const absoluteSchema = isAbsolutePath(schema2) ? schema2 : join(cwd, schema2);
1416
- const expanded = expandGlobPattern(absoluteSchema);
1417
- const filtered = filterBarrelFiles(expanded);
1418
- expandedFiles.push(...filtered);
1419
- }
1420
- allSchemas = expandedFiles;
1421
- }
1422
+ const selection = options.schema ? { schemas: Array.isArray(options.schema) ? options.schema : [options.schema], source: "schema option" } : selectDefaultSchema(cwd);
1423
+ const schemaSource = selection.source;
1424
+ const schemaRegistry = selection.registry;
1425
+ const packageSchemas = options.disablePackageDiscovery ? [] : discoverPackageSchemas(cwd);
1426
+ const allSchemas = options.expandGlobs ? [...selection.files ?? selection.schemas.flatMap((entry) => expandSchemaEntry(entry, cwd)), ...packageSchemas] : [...selection.schemas, ...packageSchemas];
1422
1427
  const schema = allSchemas.length === 1 ? allSchemas[0] : allSchemas;
1423
- let schemaFilter;
1424
- if (dialect === "postgresql") {
1425
- if (options.schemaFilter) {
1426
- schemaFilter = options.schemaFilter;
1427
- } else if (options.autoDetectSchemas && expandedFiles.length > 0) {
1428
- schemaFilter = detectSchemasFromFiles(expandedFiles);
1429
- }
1430
- }
1428
+ const schemaFilter = dialect === "postgresql" ? options.schemaFilter : void 0;
1431
1429
  return {
1432
1430
  schema,
1431
+ schemaSource,
1432
+ schemaRegistry,
1433
1433
  out,
1434
1434
  dialect,
1435
1435
  dbCredentials: getDbCredentials(dialect, databaseUrl),
@@ -1451,9 +1451,7 @@ function getDbCredentials(dialect, url) {
1451
1451
  throw new Error(`Unsupported dialect: ${dialect}`);
1452
1452
  }
1453
1453
  }
1454
- function generateDrizzleConfigFile(options = {}) {
1455
- const config = getDrizzleConfig(options);
1456
- const cwd = options.cwd ?? process.cwd();
1454
+ function renderDrizzleConfig(config, cwd = process.cwd()) {
1457
1455
  const normalizeSchemaPath = (schemaPath) => {
1458
1456
  if (isAbsolutePath(schemaPath)) {
1459
1457
  return schemaPath;
@@ -1477,6 +1475,9 @@ export default defineConfig({
1477
1475
  });
1478
1476
  `;
1479
1477
  }
1478
+ function generateDrizzleConfigFile(options = {}) {
1479
+ return renderDrizzleConfig(getDrizzleConfig(options), options.cwd ?? process.cwd());
1480
+ }
1480
1481
  function id() {
1481
1482
  return bigserial("id", { mode: "number" }).primaryKey();
1482
1483
  }
@@ -1743,6 +1744,32 @@ function pendingMigrationsSummary(targets) {
1743
1744
  return `${pending} pending migration(s) in ${names}`;
1744
1745
  }
1745
1746
  var txLogger = logger.child("@spfn/core:transaction");
1747
+ var concurrentNestingReported = false;
1748
+ function reportConcurrentNesting() {
1749
+ if (concurrentNestingReported) {
1750
+ return;
1751
+ }
1752
+ concurrentNestingReported = true;
1753
+ txLogger.warn("Concurrent nested transactions are serialized", {
1754
+ reason: "sibling SAVEPOINTs share the outer transaction's connection, where one sibling's ROLLBACK TO would discard the other's writes",
1755
+ hint: "await nested calls one at a time, or pass requiresNew: true to give a branch its own connection. A nested call whose callback awaits a sibling started after it deadlocks."
1756
+ });
1757
+ }
1758
+ function createNestedFrameGate() {
1759
+ let tail = Promise.resolve();
1760
+ let pending = 0;
1761
+ return {
1762
+ run(frame) {
1763
+ if (pending > 0) {
1764
+ reportConcurrentNesting();
1765
+ }
1766
+ pending++;
1767
+ const result = tail.then(frame);
1768
+ tail = result.then(() => void pending--, () => void pending--);
1769
+ return result;
1770
+ }
1771
+ };
1772
+ }
1746
1773
  var asyncContext = new AsyncLocalStorage();
1747
1774
  function getTransactionContext() {
1748
1775
  return asyncContext.getStore() ?? null;
@@ -1755,7 +1782,7 @@ function runWithTransaction(tx, txId, callback) {
1755
1782
  const existingContext = getTransactionContext();
1756
1783
  const newLevel = existingContext ? existingContext.level + 1 : 1;
1757
1784
  if (existingContext) {
1758
- txLogger.info("Nested transaction started (SAVEPOINT)", {
1785
+ txLogger.debug("Nested transaction started (SAVEPOINT)", {
1759
1786
  outerTxId: existingContext.txId,
1760
1787
  innerTxId: txId,
1761
1788
  level: newLevel
@@ -1763,9 +1790,19 @@ function runWithTransaction(tx, txId, callback) {
1763
1790
  } else {
1764
1791
  txLogger.debug("Root transaction context set", { txId, level: newLevel });
1765
1792
  }
1766
- const afterCommitCallbacks = existingContext ? existingContext.afterCommitCallbacks : [];
1793
+ const beforeCommitCallbacks = existingContext?.beforeCommitCallbacks ?? [];
1794
+ const afterCommitCallbacks = existingContext?.afterCommitCallbacks ?? [];
1795
+ const afterRollbackCallbacks = existingContext?.afterRollbackCallbacks ?? [];
1767
1796
  return asyncContext.run(
1768
- { tx, txId, level: newLevel, afterCommitCallbacks },
1797
+ {
1798
+ tx,
1799
+ txId,
1800
+ level: newLevel,
1801
+ beforeCommitCallbacks,
1802
+ afterCommitCallbacks,
1803
+ afterRollbackCallbacks,
1804
+ nestedFrames: createNestedFrameGate()
1805
+ },
1769
1806
  callback
1770
1807
  );
1771
1808
  }
@@ -1781,14 +1818,67 @@ function onAfterCommit(callback) {
1781
1818
  }
1782
1819
  context.afterCommitCallbacks.push(callback);
1783
1820
  }
1821
+ function onBeforeCommit(callback) {
1822
+ const context = getTransactionContext();
1823
+ if (!context) {
1824
+ txLogger.warn(
1825
+ "beforeCommit callback ran immediately (no transaction): a throw cannot abort anything"
1826
+ );
1827
+ Promise.resolve().then(callback).catch((err) => {
1828
+ txLogger.error("beforeCommit callback failed (no transaction)", {
1829
+ error: err instanceof Error ? err.message : String(err)
1830
+ });
1831
+ });
1832
+ return;
1833
+ }
1834
+ context.beforeCommitCallbacks.push(callback);
1835
+ }
1836
+ function onAfterRollback(callback) {
1837
+ const context = getTransactionContext();
1838
+ if (!context) {
1839
+ txLogger.warn("afterRollback callback ignored (no transaction)");
1840
+ return;
1841
+ }
1842
+ context.afterRollbackCallbacks.push(callback);
1843
+ }
1784
1844
  var MAX_TIMEOUT_MS = 2147483647;
1785
1845
  var txLogger2 = logger.child("@spfn/core:transaction");
1846
+ function logSafely(emit) {
1847
+ try {
1848
+ emit();
1849
+ } catch {
1850
+ }
1851
+ }
1852
+ async function runBeforeCommitCallbacks(callbacks) {
1853
+ for (const cb of [...callbacks]) {
1854
+ await cb();
1855
+ }
1856
+ }
1857
+ async function runAfterRollbackCallbacks(callbacks, txId, context, enableLogging) {
1858
+ if (enableLogging) {
1859
+ logSafely(() => txLogger2.debug("Executing afterRollback callbacks", {
1860
+ txId,
1861
+ context,
1862
+ count: callbacks.length
1863
+ }));
1864
+ }
1865
+ for (const cb of callbacks) {
1866
+ await Promise.resolve().then(cb).catch((err) => logSafely(() => {
1867
+ txLogger2.error("afterRollback callback failed", {
1868
+ txId,
1869
+ context,
1870
+ error: err instanceof Error ? err.message : String(err)
1871
+ });
1872
+ }));
1873
+ }
1874
+ }
1786
1875
  async function runInTransaction(callback, options = {}) {
1787
1876
  const defaultTimeout = env.TRANSACTION_TIMEOUT;
1788
1877
  const {
1789
1878
  slowThreshold = 1e3,
1790
1879
  enableLogging = true,
1791
- context = "transaction"
1880
+ context = "transaction",
1881
+ requiresNew = false
1792
1882
  } = options;
1793
1883
  const timeout = options.timeout ?? defaultTimeout;
1794
1884
  const idleTimeout = options.idleTimeout ?? env.TRANSACTION_IDLE_TIMEOUT;
@@ -1866,24 +1956,37 @@ async function runInTransaction(callback, options = {}) {
1866
1956
  }
1867
1957
  throw error;
1868
1958
  }
1869
- const existingContext = getTransactionContext();
1870
- const isNested = existingContext !== null;
1871
- if (isNested && timeout > 0 && enableLogging) {
1959
+ const savepointOwner = requiresNew ? null : getTransactionContext();
1960
+ const isNested = savepointOwner !== null;
1961
+ if (isNested && options.timeout !== void 0 && options.timeout > 0 && enableLogging) {
1872
1962
  txLogger2.warn("Timeout ignored in nested transaction", {
1873
1963
  txId,
1874
1964
  context,
1875
- outerTxId: existingContext.txId,
1965
+ outerTxId: savepointOwner.txId,
1876
1966
  requestedTimeout: `${timeout}ms`,
1877
- reason: "SET LOCAL statement_timeout affects the entire outer transaction"
1967
+ reason: "the SAVEPOINT runs under the outer transaction's statement_timeout; SET LOCAL here would re-scope the whole outer transaction"
1878
1968
  });
1879
1969
  }
1880
1970
  if (enableLogging) {
1881
1971
  txLogger2.debug("Transaction started", { txId, context });
1882
1972
  }
1883
1973
  const startTime = Date.now();
1974
+ let beforeCommitCallbacks = [];
1884
1975
  let afterCommitCallbacks = [];
1976
+ let afterRollbackCallbacks = [];
1977
+ const openTransaction = (body) => {
1978
+ if (savepointOwner) {
1979
+ return savepointOwner.nestedFrames.run(
1980
+ () => savepointOwner.tx.transaction(body)
1981
+ );
1982
+ }
1983
+ if (requiresNew) {
1984
+ return asyncContext.exit(() => writeDb.transaction(body));
1985
+ }
1986
+ return writeDb.transaction(body);
1987
+ };
1885
1988
  try {
1886
- const result = await writeDb.transaction(async (tx) => {
1989
+ const result = await openTransaction(async (tx) => {
1887
1990
  const transaction = tx;
1888
1991
  if (timeout > 0 && !isNested) {
1889
1992
  await transaction.execute(sql.raw(`SET LOCAL statement_timeout = ${timeout}`));
@@ -1892,81 +1995,117 @@ async function runInTransaction(callback, options = {}) {
1892
1995
  await transaction.execute(sql.raw(`SET LOCAL idle_in_transaction_session_timeout = ${idleTimeout}`));
1893
1996
  }
1894
1997
  return await runWithTransaction(transaction, txId, async () => {
1895
- const innerResult = await callback(transaction);
1896
1998
  if (!isNested) {
1897
1999
  const ctx = getTransactionContext();
1898
2000
  if (ctx) {
1899
- afterCommitCallbacks = [...ctx.afterCommitCallbacks];
2001
+ beforeCommitCallbacks = ctx.beforeCommitCallbacks;
2002
+ afterCommitCallbacks = ctx.afterCommitCallbacks;
2003
+ afterRollbackCallbacks = ctx.afterRollbackCallbacks;
1900
2004
  }
1901
2005
  }
2006
+ const innerResult = await callback(transaction);
2007
+ if (!isNested && beforeCommitCallbacks.length > 0) {
2008
+ if (enableLogging) {
2009
+ logSafely(() => txLogger2.debug("Executing beforeCommit callbacks", {
2010
+ txId,
2011
+ context,
2012
+ count: beforeCommitCallbacks.length
2013
+ }));
2014
+ }
2015
+ await runBeforeCommitCallbacks(beforeCommitCallbacks);
2016
+ }
1902
2017
  return innerResult;
1903
2018
  });
1904
2019
  });
1905
2020
  const duration = Date.now() - startTime;
1906
2021
  if (enableLogging) {
1907
- if (duration >= slowThreshold) {
1908
- txLogger2.warn("Slow transaction committed", {
1909
- txId,
1910
- context,
1911
- duration: `${duration}ms`,
1912
- threshold: `${slowThreshold}ms`,
1913
- 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."
1914
- });
1915
- } else {
1916
- txLogger2.debug("Transaction committed", {
1917
- txId,
1918
- context,
1919
- duration: `${duration}ms`
1920
- });
1921
- }
2022
+ logSafely(() => {
2023
+ if (duration >= slowThreshold) {
2024
+ txLogger2.warn("Slow transaction committed", {
2025
+ txId,
2026
+ context,
2027
+ duration: `${duration}ms`,
2028
+ threshold: `${slowThreshold}ms`,
2029
+ 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."
2030
+ });
2031
+ } else {
2032
+ txLogger2.debug("Transaction committed", {
2033
+ txId,
2034
+ context,
2035
+ duration: `${duration}ms`
2036
+ });
2037
+ }
2038
+ });
1922
2039
  }
1923
2040
  if (!isNested && afterCommitCallbacks.length > 0) {
1924
2041
  if (enableLogging) {
1925
- txLogger2.debug("Executing afterCommit callbacks", {
2042
+ logSafely(() => txLogger2.debug("Executing afterCommit callbacks", {
1926
2043
  txId,
1927
2044
  context,
1928
2045
  count: afterCommitCallbacks.length
1929
- });
2046
+ }));
1930
2047
  }
1931
2048
  for (const cb of afterCommitCallbacks) {
1932
- Promise.resolve().then(cb).catch((err) => {
2049
+ Promise.resolve().then(cb).catch((err) => logSafely(() => {
1933
2050
  txLogger2.error("afterCommit callback failed", {
1934
2051
  txId,
1935
2052
  context,
1936
2053
  error: err instanceof Error ? err.message : String(err)
1937
2054
  });
1938
- });
2055
+ }));
1939
2056
  }
1940
2057
  }
1941
2058
  return result;
1942
2059
  } catch (error) {
1943
2060
  const duration = Date.now() - startTime;
1944
2061
  if (enableLogging) {
1945
- if (duration >= slowThreshold) {
1946
- txLogger2.warn("Slow transaction rolled back", {
1947
- txId,
1948
- context,
1949
- duration: `${duration}ms`,
1950
- threshold: `${slowThreshold}ms`,
1951
- error: error instanceof Error ? error.message : String(error),
1952
- errorType: error instanceof Error ? error.name : "Unknown",
1953
- 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."
1954
- });
1955
- } else {
1956
- txLogger2.error("Transaction rolled back", {
1957
- txId,
1958
- context,
1959
- duration: `${duration}ms`,
1960
- error: error instanceof Error ? error.message : String(error),
1961
- errorType: error instanceof Error ? error.name : "Unknown"
1962
- });
1963
- }
2062
+ logSafely(() => {
2063
+ if (duration >= slowThreshold) {
2064
+ txLogger2.warn("Slow transaction rolled back", {
2065
+ txId,
2066
+ context,
2067
+ duration: `${duration}ms`,
2068
+ threshold: `${slowThreshold}ms`,
2069
+ error: error instanceof Error ? error.message : String(error),
2070
+ errorType: error instanceof Error ? error.name : "Unknown",
2071
+ 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."
2072
+ });
2073
+ } else {
2074
+ txLogger2.error("Transaction rolled back", {
2075
+ txId,
2076
+ context,
2077
+ duration: `${duration}ms`,
2078
+ error: error instanceof Error ? error.message : String(error),
2079
+ errorType: error instanceof Error ? error.name : "Unknown"
2080
+ });
2081
+ }
2082
+ });
2083
+ }
2084
+ if (!isNested && afterRollbackCallbacks.length > 0) {
2085
+ await runAfterRollbackCallbacks(afterRollbackCallbacks, txId, context, enableLogging).catch(() => void 0);
1964
2086
  }
1965
2087
  throw error;
1966
2088
  }
1967
2089
  }
1968
2090
 
1969
2091
  // src/db/transaction/middleware.ts
2092
+ var SQLSTATE_PATTERN = /^[0-9A-Z]{5}$/;
2093
+ function isDriverOriginError(error) {
2094
+ if (!error || typeof error !== "object") {
2095
+ return false;
2096
+ }
2097
+ const candidate = error;
2098
+ if (typeof candidate.code !== "string") {
2099
+ return false;
2100
+ }
2101
+ if (POSTGRES_JS_CONNECTION_CODES.has(candidate.code)) {
2102
+ return true;
2103
+ }
2104
+ if (!SQLSTATE_PATTERN.test(candidate.code)) {
2105
+ return false;
2106
+ }
2107
+ return typeof candidate.severity === "string" || typeof candidate.severity_local === "string";
2108
+ }
1970
2109
  function Transactional(options = {}) {
1971
2110
  return createMiddleware(async (c, next) => {
1972
2111
  const route = `${c.req.method} ${c.req.path}`;
@@ -1986,13 +2125,10 @@ function Transactional(options = {}) {
1986
2125
  );
1987
2126
  } catch (error) {
1988
2127
  reportDatabaseError(error);
1989
- if (error instanceof DatabaseError) {
1990
- throw error;
1991
- }
1992
- if (error instanceof TransactionError) {
2128
+ if (error instanceof SerializableError) {
1993
2129
  throw error;
1994
2130
  }
1995
- if (error && typeof error === "object" && "code" in error && typeof error.code === "string") {
2131
+ if (isDriverOriginError(error)) {
1996
2132
  throw fromPostgresError(error);
1997
2133
  }
1998
2134
  throw error;
@@ -2531,6 +2667,6 @@ var BaseRepository = class {
2531
2667
  }
2532
2668
  };
2533
2669
 
2534
- export { BaseRepository, PROJECT_MIGRATIONS_TABLE, PROJECT_TARGET_NAME, RUN_MIGRATIONS_HINT, RepositoryError, Transactional, auditFields, checkConnection, closeDatabase, collectMigrationStatus, count, countPendingMigrations, create, createDatabaseConnection, createDatabaseFromEnv, createMany, createSchema, deleteMany, deleteOne, detectDialect, discoverFunctionMigrations, enumText, filterPendingEntries, findMany, findOne, forceReconnectDatabase, foreignKey, formatPendingMigrations, fromPostgresError, functionMigrationsTable, generateDrizzleConfigFile, getDatabase, getDatabaseInfo, getDrizzleConfig, getSchemaInfo, getTransaction, getTransactionContext, hasMigrationTargets, id, initDatabase, isConnectionLevelError, migrationTargets, onAfterCommit, optionalForeignKey, packageNameToSchema, pendingMigrationTargets, pendingMigrationsSummary, projectMigrationsDir, publishingFields, readMigrationEntries, reportDatabaseError, resetConnectionErrorCounter, runInTransaction, runWithTransaction, setDatabase, setDatabaseProvider, softDelete, timestamps, typedJsonb, updateMany, updateOne, upsert, utcTimestamp, uuid, verificationTimestamp };
2670
+ export { BaseRepository, PROJECT_MIGRATIONS_TABLE, PROJECT_TARGET_NAME, RUN_MIGRATIONS_HINT, RepositoryError, Transactional, auditFields, checkConnection, closeDatabase, collectMigrationStatus, count, countPendingMigrations, create, createDatabaseConnection, createDatabaseFromEnv, createMany, createSchema, deleteMany, deleteOne, detectDialect, discoverFunctionMigrations, enumText, filterPendingEntries, findMany, findOne, forceReconnectDatabase, foreignKey, formatPendingMigrations, fromPostgresError, functionMigrationsTable, generateDrizzleConfigFile, getDatabase, getDatabaseInfo, getDrizzleConfig, getSchemaInfo, getTransaction, getTransactionContext, hasMigrationTargets, id, initDatabase, isConnectionLevelError, migrationTargets, onAfterCommit, onAfterRollback, onBeforeCommit, optionalForeignKey, packageNameToSchema, pendingMigrationTargets, pendingMigrationsSummary, projectMigrationsDir, publishingFields, readMigrationEntries, renderDrizzleConfig, reportDatabaseError, resetConnectionErrorCounter, runInTransaction, runWithTransaction, setDatabase, setDatabaseProvider, softDelete, timestamps, typedJsonb, updateMany, updateOne, upsert, utcTimestamp, uuid, verificationTimestamp };
2535
2671
  //# sourceMappingURL=index.js.map
2536
2672
  //# sourceMappingURL=index.js.map