@spfn/core 0.2.0-beta.71 → 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.
@@ -9,7 +9,7 @@ import { c as JobRouter, e as BossOptions } from '../boss-D16fO2oG.js';
9
9
  import { b as EventRouterDef, E as EventDef } from '../token-manager-BT5EnUAR.js';
10
10
  import { d as SSEHandlerConfig, e as SSEAuthConfig } from '../types-ZQODsBft.js';
11
11
  import { W as WSRouterDef, f as WSHandlerConfig, e as WSMessageHandlers, g as WSAuthConfig } from '../types-2AbaW4Ie.js';
12
- import { DatabaseProvider } from '@spfn/core/db';
12
+ import { DatabaseProvider, MigrationStatus, MigrationStatusDb } from '@spfn/core/db';
13
13
  import '@sinclair/typebox';
14
14
  import 'pg-boss';
15
15
 
@@ -477,6 +477,27 @@ interface ServerConfig {
477
477
  */
478
478
  detailed?: boolean;
479
479
  };
480
+ /**
481
+ * Migration boot gate
482
+ *
483
+ * Before serving, the server compares the migrations shipped by installed
484
+ * function packages (and by `src/server/drizzle`) against what the database
485
+ * records as applied, and refuses to start when any are still pending —
486
+ * otherwise the mismatch surfaces only at request time, as an opaque 500.
487
+ *
488
+ * Skipped when the app initializes no database or ships no migrations.
489
+ */
490
+ migrations?: {
491
+ /**
492
+ * Start anyway when migrations are pending, logging a warning that lists
493
+ * them. The environment equivalent is `SPFN_ALLOW_PENDING_MIGRATIONS=true`;
494
+ * this field wins over it.
495
+ *
496
+ * @default false
497
+ * @env SPFN_ALLOW_PENDING_MIGRATIONS
498
+ */
499
+ allowPending?: boolean;
500
+ };
480
501
  /**
481
502
  * Infrastructure initialization control
482
503
  * Controls automatic initialization of database and Redis
@@ -877,6 +898,65 @@ declare function resetServerlessApp(): void;
877
898
  */
878
899
  declare function provisionInfrastructure(config?: ServerConfig): Promise<void>;
879
900
 
901
+ /**
902
+ * Migration Boot Gate
903
+ *
904
+ * A server that boots with pending migrations passes its health check and then
905
+ * fails every request that touches a missing column, as an opaque 500. The gate
906
+ * moves that failure to boot, where it is one line to read and one command to
907
+ * fix.
908
+ *
909
+ * The check runs on the database the server already connected to. When no
910
+ * database was initialized — an app that uses none — there is nothing to check
911
+ * and boot proceeds. When the database is configured but unreachable,
912
+ * `initDatabase()` has already failed before the gate runs, so the gate never
913
+ * turns a database outage into a migration message.
914
+ */
915
+
916
+ type MigrationSnapshot =
917
+ /** Checked successfully — `status` carries per-target applied/pending counts. */
918
+ {
919
+ state: 'ok';
920
+ checkedAt: string;
921
+ status: MigrationStatus;
922
+ pending: number;
923
+ }
924
+ /** Nothing to check: no database in use, or no migrations shipped. */
925
+ | {
926
+ state: 'skipped';
927
+ checkedAt: string;
928
+ reason: string;
929
+ }
930
+ /** Could not check — distinct from "checked and pending". */
931
+ | {
932
+ state: 'unavailable';
933
+ checkedAt: string;
934
+ reason: string;
935
+ };
936
+ /**
937
+ * Thrown when the gate refuses a boot. Not an HTTP error — it never reaches a
938
+ * request.
939
+ */
940
+ declare class PendingMigrationsError extends Error {
941
+ readonly targets: string[];
942
+ constructor(message: string, targets: string[]);
943
+ }
944
+ /**
945
+ * Forget the cached snapshot — used by tests and after a manual migration run.
946
+ */
947
+ declare function resetMigrationSnapshot(): void;
948
+ /**
949
+ * Current migration snapshot, recomputed at most once per TTL.
950
+ *
951
+ * The boot gate seeds it, so the first health probe after startup costs nothing.
952
+ */
953
+ declare function getMigrationSnapshot(options?: {
954
+ cwd?: string;
955
+ db?: MigrationStatusDb;
956
+ force?: boolean;
957
+ ttlMs?: number;
958
+ }): Promise<MigrationSnapshot>;
959
+
880
960
  /**
881
961
  * Server Config Builder
882
962
  *
@@ -1073,6 +1153,18 @@ declare class ServerConfigBuilder {
1073
1153
  * Configure infrastructure initialization
1074
1154
  */
1075
1155
  infrastructure(infrastructure: ServerConfig['infrastructure']): this;
1156
+ /**
1157
+ * Configure the migration boot gate
1158
+ *
1159
+ * @example
1160
+ * ```typescript
1161
+ * // A harness that applies migrations itself, after the server is up
1162
+ * export default defineServerConfig()
1163
+ * .migrations({ allowPending: true })
1164
+ * .build();
1165
+ * ```
1166
+ */
1167
+ migrations(migrations: ServerConfig['migrations']): this;
1076
1168
  /**
1077
1169
  * Register workflow router for workflow orchestration
1078
1170
  *
@@ -1133,4 +1225,4 @@ declare class ServerConfigBuilder {
1133
1225
  */
1134
1226
  declare function defineServerConfig(): ServerConfigBuilder;
1135
1227
 
1136
- export { type AppFactory, type ServerConfig, type ServerInstance, type ShutdownHookOptions, createServer, createServerlessApp, defineServerConfig, getShutdownManager, loadEnvFiles, provisionInfrastructure, resetServerlessApp, startServer };
1228
+ export { type AppFactory, type MigrationSnapshot, PendingMigrationsError, type ServerConfig, type ServerInstance, type ShutdownHookOptions, createServer, createServerlessApp, defineServerConfig, getMigrationSnapshot, getShutdownManager, loadEnvFiles, provisionInfrastructure, resetMigrationSnapshot, resetServerlessApp, startServer };
@@ -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,6 +1361,7 @@ 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);
@@ -1436,9 +1542,9 @@ async function applyProxyGuard(app, config) {
1436
1542
  if (proxyGuardConfig?.nonce) {
1437
1543
  try {
1438
1544
  const { getCache: getCache2 } = await import('@spfn/core/cache');
1439
- const cache = getCache2();
1440
- if (cache) {
1441
- nonceStore = createCacheNonceStore(cache);
1545
+ const cache2 = getCache2();
1546
+ if (cache2) {
1547
+ nonceStore = createCacheNonceStore(cache2);
1442
1548
  serverLogger.info("Proxy-guard nonce replay rejection: cache (Redis/Valkey)");
1443
1549
  } else {
1444
1550
  nonceStore = createInMemoryNonceStore();
@@ -1550,9 +1656,9 @@ async function registerSSEEndpoint(app, config) {
1550
1656
  if (!store) {
1551
1657
  try {
1552
1658
  const { getCache: getCache2 } = await import('@spfn/core/cache');
1553
- const cache = getCache2();
1554
- if (cache) {
1555
- store = new CacheTokenStore(cache);
1659
+ const cache2 = getCache2();
1660
+ if (cache2) {
1661
+ store = new CacheTokenStore(cache2);
1556
1662
  if (debug) {
1557
1663
  serverLogger.info("SSE token store: cache (Redis/Valkey)");
1558
1664
  }
@@ -2248,6 +2354,7 @@ async function startServer(config) {
2248
2354
  };
2249
2355
  try {
2250
2356
  await initializeInfrastructure(finalConfig);
2357
+ await runMigrationBootGate(finalConfig);
2251
2358
  const app = await createServer(finalConfig);
2252
2359
  const server = startHttpServer(app, host, port);
2253
2360
  let wsCleanup;
@@ -2400,9 +2507,9 @@ async function initializeWebSocket(server, app, config) {
2400
2507
  if (!store) {
2401
2508
  try {
2402
2509
  const { getCache: getCache2 } = await import('@spfn/core/cache');
2403
- const cache = getCache2();
2404
- if (cache) {
2405
- store = new CacheTokenStore(cache);
2510
+ const cache2 = getCache2();
2511
+ if (cache2) {
2512
+ store = new CacheTokenStore(cache2);
2406
2513
  if (debug) serverLogger.info("WS token store: cache (Redis/Valkey)");
2407
2514
  }
2408
2515
  } catch {
@@ -2988,6 +3095,21 @@ var ServerConfigBuilder = class {
2988
3095
  this.config.infrastructure = infrastructure;
2989
3096
  return this;
2990
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
+ }
2991
3113
  /**
2992
3114
  * Register workflow router for workflow orchestration
2993
3115
  *
@@ -3062,6 +3184,6 @@ function defineServerConfig() {
3062
3184
  return new ServerConfigBuilder();
3063
3185
  }
3064
3186
 
3065
- export { createServer, createServerlessApp, defineServerConfig, getShutdownManager, loadEnv, loadEnvFiles, provisionInfrastructure, resetServerlessApp, startServer };
3187
+ export { PendingMigrationsError, createServer, createServerlessApp, defineServerConfig, getMigrationSnapshot, getShutdownManager, loadEnv, loadEnvFiles, provisionInfrastructure, resetMigrationSnapshot, resetServerlessApp, startServer };
3066
3188
  //# sourceMappingURL=index.js.map
3067
3189
  //# sourceMappingURL=index.js.map