@spfn/core 0.2.0-beta.70 → 0.2.0-beta.72

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.
@@ -11,7 +11,7 @@ import { setDefaultSafeFetchPolicy } from '@spfn/core/security';
11
11
  import { streamSSE } from 'hono/streaming';
12
12
  import { randomBytes, createHash } from 'crypto';
13
13
  import { Agent, setGlobalDispatcher } from 'undici';
14
- import { initDatabase, getDatabase, closeDatabase } from '@spfn/core/db';
14
+ import { initDatabase, getDatabase, hasMigrationTargets, collectMigrationStatus, countPendingMigrations, pendingMigrationTargets, formatPendingMigrations, pendingMigrationsSummary, RUN_MIGRATIONS_HINT, migrationTargets, closeDatabase } from '@spfn/core/db';
15
15
  import { initCache, getCache, closeCache } from '@spfn/core/cache';
16
16
  import { serve } from '@hono/node-server';
17
17
  import PgBoss from 'pg-boss';
@@ -646,8 +646,8 @@ var InMemoryTokenStore = class {
646
646
  }
647
647
  };
648
648
  var CacheTokenStore = class {
649
- constructor(cache) {
650
- this.cache = cache;
649
+ constructor(cache2) {
650
+ this.cache = cache2;
651
651
  }
652
652
  prefix = "sse:token:";
653
653
  async set(token, data) {
@@ -989,6 +989,93 @@ async function closeEventTransport() {
989
989
  }
990
990
  var serverLogger = logger.child("@spfn/core:server");
991
991
 
992
+ // src/server/migration-gate.ts
993
+ var SNAPSHOT_TTL_MS = 3e4;
994
+ var cache = null;
995
+ var PendingMigrationsError = class extends Error {
996
+ targets;
997
+ constructor(message, targets) {
998
+ super(message);
999
+ this.name = "PendingMigrationsError";
1000
+ this.targets = targets;
1001
+ }
1002
+ };
1003
+ function resetMigrationSnapshot() {
1004
+ cache = null;
1005
+ }
1006
+ async function inspect(cwd, db) {
1007
+ const checkedAt = (/* @__PURE__ */ new Date()).toISOString();
1008
+ if (!hasMigrationTargets(cwd)) {
1009
+ return { state: "skipped", checkedAt, reason: "no function package or project migrations found" };
1010
+ }
1011
+ let database = db;
1012
+ if (!database) {
1013
+ try {
1014
+ database = getDatabase();
1015
+ } catch {
1016
+ return { state: "skipped", checkedAt, reason: "no database initialized" };
1017
+ }
1018
+ }
1019
+ try {
1020
+ const status = await collectMigrationStatus(database, cwd);
1021
+ return { state: "ok", checkedAt, status, pending: countPendingMigrations(status) };
1022
+ } catch (error) {
1023
+ return {
1024
+ state: "unavailable",
1025
+ checkedAt,
1026
+ reason: error instanceof Error ? error.message : String(error)
1027
+ };
1028
+ }
1029
+ }
1030
+ async function getMigrationSnapshot(options = {}) {
1031
+ const cwd = options.cwd ?? process.cwd();
1032
+ const ttl = options.ttlMs ?? SNAPSHOT_TTL_MS;
1033
+ const now = Date.now();
1034
+ if (!options.force && cache && cache.cwd === cwd && now - cache.at < ttl) {
1035
+ return cache.value;
1036
+ }
1037
+ const value = await inspect(cwd, options.db);
1038
+ cache = { at: now, cwd, value };
1039
+ return value;
1040
+ }
1041
+ function pendingIsAllowed(config) {
1042
+ return config?.migrations?.allowPending ?? env.SPFN_ALLOW_PENDING_MIGRATIONS === true;
1043
+ }
1044
+ async function runMigrationBootGate(config, cwd = process.cwd()) {
1045
+ const snapshot = await getMigrationSnapshot({ cwd, force: true });
1046
+ if (snapshot.state === "skipped") {
1047
+ serverLogger.debug(`Migration gate skipped: ${snapshot.reason}`);
1048
+ return snapshot;
1049
+ }
1050
+ if (snapshot.state === "unavailable") {
1051
+ serverLogger.warn(
1052
+ `Could not verify migration status: ${snapshot.reason}. Starting anyway \u2014 run \`pnpm spfn db status\` to check.`
1053
+ );
1054
+ return snapshot;
1055
+ }
1056
+ const targets = pendingMigrationTargets(snapshot.status);
1057
+ if (targets.length === 0) {
1058
+ serverLogger.debug("Migration gate passed: database is up to date");
1059
+ return snapshot;
1060
+ }
1061
+ const lines = formatPendingMigrations(targets);
1062
+ const summary = pendingMigrationsSummary(targets);
1063
+ if (pendingIsAllowed(config)) {
1064
+ serverLogger.warn(`Starting with pending migrations \u2014 ${summary}`);
1065
+ lines.forEach((line) => serverLogger.warn(` ${line}`));
1066
+ serverLogger.warn(` Requests hitting the missing columns will fail. ${RUN_MIGRATIONS_HINT}`);
1067
+ return snapshot;
1068
+ }
1069
+ serverLogger.error(`Refusing to start: ${summary}`);
1070
+ lines.forEach((line) => serverLogger.error(` ${line}`));
1071
+ serverLogger.error(` ${RUN_MIGRATIONS_HINT}`);
1072
+ serverLogger.error(" To start anyway: SPFN_ALLOW_PENDING_MIGRATIONS=true (or `spfn dev --allow-pending-migrations`)");
1073
+ throw new PendingMigrationsError(
1074
+ `${summary}. ${RUN_MIGRATIONS_HINT}, or set SPFN_ALLOW_PENDING_MIGRATIONS=true to start anyway.`,
1075
+ targets.map((target) => target.name)
1076
+ );
1077
+ }
1078
+
992
1079
  // src/server/shutdown-manager.ts
993
1080
  var DEFAULT_HOOK_TIMEOUT = 1e4;
994
1081
  var DEFAULT_HOOK_ORDER = 100;
@@ -1203,6 +1290,24 @@ async function withTimeout2(promise, timeout, message) {
1203
1290
  }
1204
1291
 
1205
1292
  // src/server/helpers.ts
1293
+ async function readMigrationHealth() {
1294
+ const snapshot = await getMigrationSnapshot();
1295
+ if (snapshot.state !== "ok") {
1296
+ return {
1297
+ status: "unknown",
1298
+ pending: 0,
1299
+ checkedAt: snapshot.checkedAt,
1300
+ targets: [],
1301
+ reason: snapshot.reason
1302
+ };
1303
+ }
1304
+ return {
1305
+ status: snapshot.pending > 0 ? "pending" : "up_to_date",
1306
+ pending: snapshot.pending,
1307
+ checkedAt: snapshot.checkedAt,
1308
+ targets: migrationTargets(snapshot.status)
1309
+ };
1310
+ }
1206
1311
  function createHealthCheckHandler(detailed) {
1207
1312
  return async (c) => {
1208
1313
  const shutdownManager = getShutdownManager();
@@ -1256,11 +1361,32 @@ function createHealthCheckHandler(detailed) {
1256
1361
  };
1257
1362
  const hasErrors = dbStatus === "error" || dbStatus === "not_initialized" || redisStatus === "error";
1258
1363
  response.status = hasErrors ? "degraded" : "ok";
1364
+ response.migrations = await readMigrationHealth();
1259
1365
  }
1260
1366
  const statusCode = response.status === "ok" ? 200 : 503;
1261
1367
  return c.json(response, statusCode);
1262
1368
  };
1263
1369
  }
1370
+ function resolveEndpointMiddlewares(config) {
1371
+ const candidates = [
1372
+ ...config.middlewares ?? [],
1373
+ ...config.routes?._globalMiddlewares ?? []
1374
+ ];
1375
+ for (const pkgRouter of config.routes?._packageRouters ?? []) {
1376
+ candidates.push(...pkgRouter._globalMiddlewares ?? []);
1377
+ }
1378
+ const seen = /* @__PURE__ */ new Set();
1379
+ return candidates.filter((mw) => {
1380
+ if (!mw.name) {
1381
+ return true;
1382
+ }
1383
+ if (seen.has(mw.name)) {
1384
+ return false;
1385
+ }
1386
+ seen.add(mw.name);
1387
+ return true;
1388
+ });
1389
+ }
1264
1390
  function applyServerTimeouts(server, timeouts) {
1265
1391
  if ("timeout" in server) {
1266
1392
  server.timeout = timeouts.request;
@@ -1416,9 +1542,9 @@ async function applyProxyGuard(app, config) {
1416
1542
  if (proxyGuardConfig?.nonce) {
1417
1543
  try {
1418
1544
  const { getCache: getCache2 } = await import('@spfn/core/cache');
1419
- const cache = getCache2();
1420
- if (cache) {
1421
- nonceStore = createCacheNonceStore(cache);
1545
+ const cache2 = getCache2();
1546
+ if (cache2) {
1547
+ nonceStore = createCacheNonceStore(cache2);
1422
1548
  serverLogger.info("Proxy-guard nonce replay rejection: cache (Redis/Valkey)");
1423
1549
  } else {
1424
1550
  nonceStore = createInMemoryNonceStore();
@@ -1530,9 +1656,9 @@ async function registerSSEEndpoint(app, config) {
1530
1656
  if (!store) {
1531
1657
  try {
1532
1658
  const { getCache: getCache2 } = await import('@spfn/core/cache');
1533
- const cache = getCache2();
1534
- if (cache) {
1535
- store = new CacheTokenStore(cache);
1659
+ const cache2 = getCache2();
1660
+ if (cache2) {
1661
+ store = new CacheTokenStore(cache2);
1536
1662
  if (debug) {
1537
1663
  serverLogger.info("SSE token store: cache (Redis/Valkey)");
1538
1664
  }
@@ -1546,7 +1672,7 @@ async function registerSSEEndpoint(app, config) {
1546
1672
  store
1547
1673
  });
1548
1674
  const tokenPath = streamPath.replace(/\/[^/]+$/, "/token");
1549
- const mwHandlers = (config.middlewares ?? []).map((mw) => mw.handler);
1675
+ const mwHandlers = resolveEndpointMiddlewares(config).map((mw) => mw.handler);
1550
1676
  const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
1551
1677
  app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
1552
1678
  const subject = getSubject(c);
@@ -2228,6 +2354,7 @@ async function startServer(config) {
2228
2354
  };
2229
2355
  try {
2230
2356
  await initializeInfrastructure(finalConfig);
2357
+ await runMigrationBootGate(finalConfig);
2231
2358
  const app = await createServer(finalConfig);
2232
2359
  const server = startHttpServer(app, host, port);
2233
2360
  let wsCleanup;
@@ -2380,9 +2507,9 @@ async function initializeWebSocket(server, app, config) {
2380
2507
  if (!store) {
2381
2508
  try {
2382
2509
  const { getCache: getCache2 } = await import('@spfn/core/cache');
2383
- const cache = getCache2();
2384
- if (cache) {
2385
- store = new CacheTokenStore(cache);
2510
+ const cache2 = getCache2();
2511
+ if (cache2) {
2512
+ store = new CacheTokenStore(cache2);
2386
2513
  if (debug) serverLogger.info("WS token store: cache (Redis/Valkey)");
2387
2514
  }
2388
2515
  } catch {
@@ -2394,7 +2521,7 @@ async function initializeWebSocket(server, app, config) {
2394
2521
  store
2395
2522
  });
2396
2523
  const tokenPath = wsPath.replace(/\/[^/]+$/, "/token");
2397
- const mwHandlers = (config.middlewares ?? []).map((mw) => mw.handler);
2524
+ const mwHandlers = resolveEndpointMiddlewares(config).map((mw) => mw.handler);
2398
2525
  const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
2399
2526
  app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
2400
2527
  const subject = getSubject(c);
@@ -2803,9 +2930,16 @@ var ServerConfigBuilder = class {
2803
2930
  /**
2804
2931
  * Register define-route based router
2805
2932
  *
2806
- * Automatically applies:
2807
- * - Global middlewares from router._globalMiddlewares (via .use())
2808
- * - Package routers from router._packageRouters (via .packages())
2933
+ * Router-level middleware (`.use()`) and package routers (`.packages()`) travel
2934
+ * with the router itself and are applied by `registerRoutes` when the routes are
2935
+ * mounted this method only records which router to mount.
2936
+ *
2937
+ * It deliberately does **not** copy `router._globalMiddlewares` into
2938
+ * `config.middlewares`: that copy used to make `registerRoutes` see the same
2939
+ * middleware twice (once from the config list, once from the router it was
2940
+ * handed) and attach both to every route. Middleware that verifies a JWT survives
2941
+ * running twice; middleware that consumes one-shot state — a nonce replay ledger —
2942
+ * rejects its own request the second time round.
2809
2943
  *
2810
2944
  * @example
2811
2945
  * ```typescript
@@ -2816,29 +2950,12 @@ var ServerConfigBuilder = class {
2816
2950
  * .use([authMiddleware]);
2817
2951
  *
2818
2952
  * export default defineServerConfig()
2819
- * .routes(appRouter) // middlewares auto-applied
2953
+ * .routes(appRouter) // .use() middleware applied once, at registration
2820
2954
  * .build();
2821
2955
  * ```
2822
2956
  */
2823
2957
  routes(router) {
2824
2958
  this.config.routes = router;
2825
- const allGlobalMiddlewares = [];
2826
- if (router._globalMiddlewares?.length > 0) {
2827
- allGlobalMiddlewares.push(...router._globalMiddlewares);
2828
- }
2829
- if (router._packageRouters?.length > 0) {
2830
- for (const pkgRouter of router._packageRouters) {
2831
- if (pkgRouter._globalMiddlewares?.length > 0) {
2832
- allGlobalMiddlewares.push(...pkgRouter._globalMiddlewares);
2833
- }
2834
- }
2835
- }
2836
- if (allGlobalMiddlewares.length > 0) {
2837
- this.config.middlewares = [
2838
- ...this.config.middlewares || [],
2839
- ...allGlobalMiddlewares
2840
- ];
2841
- }
2842
2959
  return this;
2843
2960
  }
2844
2961
  /**
@@ -2978,6 +3095,21 @@ var ServerConfigBuilder = class {
2978
3095
  this.config.infrastructure = infrastructure;
2979
3096
  return this;
2980
3097
  }
3098
+ /**
3099
+ * Configure the migration boot gate
3100
+ *
3101
+ * @example
3102
+ * ```typescript
3103
+ * // A harness that applies migrations itself, after the server is up
3104
+ * export default defineServerConfig()
3105
+ * .migrations({ allowPending: true })
3106
+ * .build();
3107
+ * ```
3108
+ */
3109
+ migrations(migrations) {
3110
+ this.config.migrations = migrations;
3111
+ return this;
3112
+ }
2981
3113
  /**
2982
3114
  * Register workflow router for workflow orchestration
2983
3115
  *
@@ -3052,6 +3184,6 @@ function defineServerConfig() {
3052
3184
  return new ServerConfigBuilder();
3053
3185
  }
3054
3186
 
3055
- export { createServer, createServerlessApp, defineServerConfig, getShutdownManager, loadEnv, loadEnvFiles, provisionInfrastructure, resetServerlessApp, startServer };
3187
+ export { PendingMigrationsError, createServer, createServerlessApp, defineServerConfig, getMigrationSnapshot, getShutdownManager, loadEnv, loadEnvFiles, provisionInfrastructure, resetMigrationSnapshot, resetServerlessApp, startServer };
3056
3188
  //# sourceMappingURL=index.js.map
3057
3189
  //# sourceMappingURL=index.js.map