@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.
- package/README.md +74 -0
- package/dist/config/index.d.ts +30 -0
- package/dist/config/index.js +8 -0
- package/dist/config/index.js.map +1 -1
- package/dist/db/index.d.ts +133 -2
- package/dist/db/index.js +198 -4
- package/dist/db/index.js.map +1 -1
- package/dist/server/index.d.ts +94 -2
- package/dist/server/index.js +135 -13
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/dist/server/index.d.ts
CHANGED
|
@@ -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 };
|
package/dist/server/index.js
CHANGED
|
@@ -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(
|
|
650
|
-
this.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
|
|
1440
|
-
if (
|
|
1441
|
-
nonceStore = createCacheNonceStore(
|
|
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
|
|
1554
|
-
if (
|
|
1555
|
-
store = new CacheTokenStore(
|
|
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
|
|
2404
|
-
if (
|
|
2405
|
-
store = new CacheTokenStore(
|
|
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
|