@kne/fastify-app-manager 0.1.10 → 0.1.11

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 CHANGED
@@ -51,18 +51,20 @@ Client / Admin UI
51
51
  | Path gateway | `/app/{name}` 剥前缀;改写绝对路径 Location、Cookie Path、文本类 body |
52
52
  | 未运行兜底 | 应用存在但未 `running` 时,网关返回 503 状态页(已停止 / 启动中 / 异常 / 未部署),接口请求返回 JSON,不再落到宿主 404;`/app/{name}` 下应用不存在时返回 404「应用不存在」页 |
53
53
  | PM2 | 每应用一进程,名 `app-manager__{name}`;stdout/stderr 由 PM2 落 `appsRoot/{name}/logs`,插件按大小/按天切分归档 |
54
- | Bootstrap | `onReady` 连接 PM2、挂 log bus(只推 SSE)、启动日志切分与负载采样定时器;**异步**对托管应用做 `syncAllStatuses`(不阻塞父应用 ready) |
54
+ | Bootstrap | `onReady` 连接 PM2、挂 log bus(只推 SSE)、启动日志切分与负载采样定时器;**异步**并行:目录完好的托管应用立即 `syncAllStatuses`,缺当前版本目录的应用按版本 `fileId` 逐个补回后再单独拉起,最后清理非当前版本目录(均不阻塞父应用 ready) |
55
55
 
56
56
  ##### 部署时序
57
57
 
58
58
  ```
59
- uploadVersion (zip)
59
+ uploadVersion (zip / fileId)
60
60
  ↓
61
- 校验包格式 → 解压 → npm install --production(server)
61
+ 校验包格式 → SQL 写入版本记录;multipart 解压并 npm install 后保留目录,fileId 不保留目录
62
62
  ↓
63
63
  deploy(versionId)
64
64
  ↓
65
- status=deploying → 可选 SQL 迁移 → inject entryHtml → pm2.start
65
+ 目录缺失则按 fileId 解压 + npm install → 数据库 SQL 覆盖写回
66
+ ↓
67
+ status=deploying → 可选 SQL 迁移 → inject entryHtml → pm2.start → 清理其它版本目录
66
68
  ↓(立即返回)
67
69
  后台 healthCheck → running / error
68
70
  ```
@@ -74,7 +76,7 @@ deploy(versionId)
74
76
  | 概念 | 说明 |
75
77
  |------|------|
76
78
  | **App** | 元信息、分配端口、env、pm2Config、状态机;落盘目录 `appsRoot/{name}` |
77
- | **AppVersion** | 一次 zip 上传产物;`artifactPath` 存解压后的包;部署时按 id 或 version 选择 |
79
+ | **AppVersion** | 一次 zip 上传产物;`artifactPath` 为解压目录(有 `fileId` 的版本只在当前部署时常驻磁盘),SQL 脚本存于 `migrationFiles`;部署时按 id 或 version 选择 |
78
80
  | **端口池** | 默认 `4000–7999`,创建时分配未占用端口;`PORT` 强制写入子进程 env |
79
81
 
80
82
  ##### 状态机
@@ -120,6 +122,7 @@ deploy(versionId)
120
122
  |------|------|
121
123
  | 版本上传 | Zip Slip 防护、条目数/体积上限、解压丢弃任意层级 `node_modules`、Fullstack 包格式校验、server 生产依赖安装 |
122
124
  | 短部署 | `deploy` / `start` / `restart` 立即返回 `deploying`,后台按 PM2 真实状态 + healthCheck 写回 `running`/`error`/`stopped` |
125
+ | 部署进度 | `deploy` 按「准备部署包 → SQL 迁移 → 启动进程 → 等待就绪」上报阶段,经 `GET .../app/deploy/stream` SSE 推送(含下载 / 解压 / 安装依赖等子阶段) |
123
126
  | 生命周期 | `start` / `stop` / `restart` / `remove`;父应用重启后异步 `syncAllStatuses` 对齐 PM2 |
124
127
  | 双通道访问 | 自定义域名透明反代 + `/app/{name}` 剥前缀反代 |
125
128
  | 日志 | PM2 落盘 + 历史分页(`beforeLine` 上滚、每页最多 100 行)+ SSE(含回放行与心跳) |
@@ -194,6 +197,7 @@ await fastify.listen({ port: 3000 });
194
197
  | `systemEnvKeys` | array | 否 | `[]` | 宿主维护的系统变量键名,与内置 `DB_TABLE_PREFIX` 合并:接口不返回,create / save / saveEnv 不可新增、修改或删除 |
195
198
  | `resolveSystemEnv` | function | 否 | `null` | `async ({ app, version, serverDir }) => env \| null`,每次启动进程(deploy / start / restart / 恢复)前调用;返回值覆盖应用变量注入子进程,值为 `null` / `undefined` 的键忽略,属于 `systemEnvKeys` 的键持久化到 `app.env` |
196
199
  | `onAppRemoved` | function | 否 | `null` | `async ({ app }) => void`,应用删除成功后调用,`app` 为删除前快照(含 `env`);数据清理未完成而未删除时不调用,钩子报错只记 warn 日志 |
200
+ | `resolveVersionFile` | function | 否 | `null` | `async ({ fileId, name, version, request }) => Buffer`,版本上传传 `fileId`(而非 multipart 文件)时调用,返回 zip 内容;未配置时传 `fileId` 返回 400。版本记录保存 `fileId`;这类版本只在当前部署时常驻磁盘,部署非当前版本或启动时当前版本目录缺失会再次调用以重建 |
197
201
  | `defaultAppDb` | object | 否 | `appsRoot/_shared/apps-data.sqlite` | 托管应用默认库(须与宿主主库分离) |
198
202
  | `dbQueryMaxRows` | number | 否 | `500` | 只读 query SQL 最大返回行数 |
199
203
  | `pm2Defaults` | object | 否 | 见下表 | 全局 PM2 默认;可被应用 `pm2Config` 覆盖 |
@@ -323,16 +327,17 @@ await fastify.listen({ port: 3000 });
323
327
 
324
328
  ##### POST `{prefix}/app/version/upload`
325
329
 
326
- `multipart/form-data` 上传 zip。
330
+ `multipart/form-data` 上传 zip;或 JSON 传 `fileId`,由 `resolveVersionFile` 读取宿主文件(如文件管理中已上传的 zip)。
327
331
 
328
332
  | 字段名 | 类型 | 必填 | 说明 |
329
333
  |--------|------|------|------|
330
- | file | file | 是 | zip 二进制(字段名任意 file part) |
334
+ | file | file | 否 | zip 二进制(字段名任意 file part);与 `fileId` 二选一,同时存在时以 `file` 为准 |
335
+ | fileId | string | 否 | 宿主文件 id,交给 `resolveVersionFile` 读取;与 `file` 二选一 |
331
336
  | name | string | 是 | 应用 slug(须已 create) |
332
337
  | version | string | 是 | 版本号;同应用内唯一 |
333
338
  | label | string | 否 | 版本说明 |
334
339
 
335
- 成功返回 `appVersion` 记录(含 `artifactPath`、`hasMigration` 等)。包在临时目录完成解压、校验与 `npm install` 后才写入版本表,上传中或失败的包不会出现在版本列表;包非法或 npm 失败时清理临时目录并返回 400。版本号已存在返回 409;同版本号仅残留软删除记录时会先彻底清除再写入。
340
+ 成功返回 `appVersion` 记录(含 `artifactPath`、`hasMigration`、`fileId`、`sqlFiles` 等)。包中 `server/{sqlPath}/*.sql` 的内容写入版本记录(数据库)。传 `fileId` 时只解压校验、不装依赖,也不保留版本目录(部署时再从宿主文件解压);multipart 上传无处重建,解压并 `npm install` 后保留目录。包在临时目录处理完才写入版本表,上传中或失败的包不会出现在版本列表;包非法或 npm 失败时清理临时目录并返回 400。版本号已存在返回 409;同版本号仅残留软删除记录时会先彻底清除再写入。
336
341
 
337
342
  ##### GET `{prefix}/app/version/list`
338
343
 
@@ -361,9 +366,20 @@ await fastify.listen({ port: 3000 });
361
366
 
362
367
  `hasMigration` 为 true 时额外附带 `sqlFiles` 列表。
363
368
 
369
+ ##### GET `{prefix}/app/version/download`
370
+
371
+ 下载版本部署包,文件名 `{name}-{version}.zip`。
372
+
373
+ | 参数名 | 类型 | 必填 | 说明 |
374
+ |--------|------|------|------|
375
+ | name | string | 是 | 应用 slug |
376
+ | versionId | string | 是 | 版本 id;不属于该应用返回 404 |
377
+
378
+ 版本有 `fileId` 且配置了 `resolveVersionFile` 时返回原始 zip;否则(或读取失败时)由版本目录流式打包,排除部署时生成的 `server/node_modules` 与 `server/build`。版本目录也不存在时返回 404。
379
+
364
380
  ##### POST `{prefix}/app/deploy`
365
381
 
366
- 选择版本部署。立即返回 `deploying`,后台做健康检查。
382
+ 选择版本部署。版本目录不在磁盘时先经 `resolveVersionFile` 取回 zip 解压并 `npm install`(耗时,取不回返回 400 且不改应用状态),再以数据库中的 SQL 覆盖目录内 `.sql`;随后立即返回 `deploying`,后台做健康检查。进程启动后删除该应用其它有 `fileId` 的版本目录(只保留当前版本)。
367
383
 
368
384
  | 参数名 | 类型 | 必填 | 默认值 | 说明 |
369
385
  |--------|------|------|--------|------|
@@ -372,6 +388,7 @@ await fastify.listen({ port: 3000 });
372
388
  | version | string | 否 | - | 版本号 |
373
389
  | runMigration | boolean | 否 | `true` | false 时向 app.env 写入 `RUN_SQL_ON_SYNC=false`;预跑 SQL 仍受 `migrateBeforeStart` 约束 |
374
390
  | migrations | array | 否 | - | `[{ name, action }]` 逐个指定**待执行**脚本的处理方式;未列出的按 `execute`。仅在 `runMigration` 且 `migrateBeforeStart` 时生效 |
391
+ | progressId | string | 否 | 随机 UUID | 进度标识;前端先用它连 `GET .../app/deploy/stream` 再发起部署,可收到完整进度 |
375
392
 
376
393
  `migrations[].action`:
377
394
 
@@ -388,15 +405,47 @@ await fastify.listen({ port: 3000 });
388
405
  "name": "demo",
389
406
  "status": "deploying",
390
407
  "versionId": "354173577374729216",
408
+ "progressId": "c0a8...",
391
409
  "port": 4001,
392
410
  "pathUrl": "/app/demo/",
393
411
  "domain": null
394
412
  }
395
413
  ```
396
414
 
415
+ ##### GET `{prefix}/app/deploy/stream`
416
+
417
+ SSE 部署进度。连接时先推送 `snapshot`(该应用最近一次部署中匹配 `progressId` 的已有事件,便于断线重连),之后每个阶段变化推送 `progress`。每个应用只在内存中保留最近一次部署的事件。
418
+
419
+ | 参数名 | 类型 | 必填 | 说明 |
420
+ |--------|------|------|------|
421
+ | name | string | 是 | 应用 slug |
422
+ | progressId | string | 否 | 只推送该次部署的事件;不传则推送该应用所有部署事件 |
423
+
424
+ 阶段(`steps`,依次推进):
425
+
426
+ | step | 说明 | `detail`(`status=running` 时) |
427
+ |------|------|------|
428
+ | `package` | 准备版本目录 | `reuse` 目录已在磁盘 / `download` 取回 zip / `extract` 解压 / `install` 安装依赖 |
429
+ | `migrate` | 预跑 SQL 迁移;未开启 `runMigration`、`migrateBeforeStart` 或版本无脚本时为 `skipped` | - |
430
+ | `start` | PM2 拉起进程 | - |
431
+ | `ready` | 健康检查;通过为 `done`,否则 `error`(`message` 为原因,如 `health check timeout`) | - |
432
+
433
+ `status`:`running` / `done` / `skipped` / `error`。任一阶段 `error` 即部署失败;`ready` 为 `done` / `error` 时部署结束,前端可断开连接。
434
+
435
+ ```text
436
+ event: snapshot
437
+ data: {"steps":["package","migrate","start","ready"],"events":[]}
438
+
439
+ event: progress
440
+ data: {"progressId":"p-1","step":"package","status":"running","detail":"download","message":null,"at":"2026-10-10T10:00:00.000Z"}
441
+
442
+ event: progress
443
+ data: {"progressId":"p-1","step":"ready","status":"done","detail":null,"message":null,"at":"2026-10-10T10:00:05.000Z"}
444
+ ```
445
+
397
446
  ##### 版本迁移脚本管理
398
447
 
399
- 管理某版本 `server/{migrationPath}` 下的 `.sql` 文件,执行状态读写应用库的 `_fs_sql_migrations`(与 `@kne/fastify-sequelize` 共用,按文件名记账;共享库中不同应用的同名脚本会互相影响)。文件名须匹配 `[A-Za-z0-9_-.]+\.sql`。
448
+ 管理某版本的 `.sql` 文件。脚本内容存于版本记录(数据库),版本目录不在磁盘时也可查看和编辑;目录在磁盘上(当前版本)时同步写入 `server/{migrationPath}`,部署时以数据库为准覆盖。执行状态读写应用库的 `_fs_sql_migrations`(与 `@kne/fastify-sequelize` 共用,按文件名记账;共享库中不同应用的同名脚本会互相影响)。文件名须匹配 `[A-Za-z0-9_-.]+\.sql`。
400
449
 
401
450
  | 方法 | 路径 | 参数 | 说明 |
402
451
  |------|------|------|------|
@@ -627,12 +676,21 @@ data: {"name":"demo","ts":1791456608533,...}
627
676
  | `saveEnv({ name, env, secretEnvKeys? })` | 同 save-env |
628
677
  | `list({ filter, perPage, currentPage })` | 列表 |
629
678
  | `detail({ name })` | 详情(脱敏) |
630
- | `uploadVersion({ name, version, label, zipBuffer })` | 上传版本 |
679
+ | `uploadVersion({ name, version, label, zipBuffer, fileId? })` | 上传版本;`fileId` 记入版本记录供后续重建 |
680
+ | `resolveVersionPackage({ name, versionId })` | 下载用:返回 `{ fileName, buffer }`(原始 zip)或 `{ fileName, artifactPath, files }`(版本目录内待打包的相对路径) |
681
+ | `getMigrationFiles(ver)` | 读取版本 SQL `[{ name, content, updatedAt }]`;旧版本记录为空时从版本目录回填 |
682
+ | `saveMigrationFiles(ver, files)` | 写入版本 SQL 并同步 `hasMigration`,版本目录在磁盘上时同步覆盖 `.sql` |
683
+ | `pruneVersionArtifacts(app)` | 删除该应用非当前、且有 `fileId` 的版本目录(删前回填 SQL),返回被删版本号 |
684
+ | `pruneAllVersionArtifacts()` | 对所有应用执行 `pruneVersionArtifacts` |
685
+ | `restoreMissingArtifacts()` | 当前版本目录(`artifactPath/server`)缺失时,按版本 `fileId` 经 `resolveVersionFile` 取回 zip 重建并恢复 `current` 链接(不跑 SQL 迁移);返回 `[{ name, version, action: 'restored' \| 'skipped' \| 'failed', reason? }]`,无 `fileId` 的记 `skipped` |
631
686
  | `listVersions({ name, perPage, currentPage })` | 版本列表 |
632
- | `deploy({ name, versionId, version, runMigration, migrations? })` | 部署;立即 `deploying`,后台 `syncRealStatus` |
687
+ | `deploy({ name, versionId, version, runMigration, migrations?, progressId? })` | 部署;版本目录缺失时先从宿主文件重建,启动后清理其它版本目录;立即 `deploying`,后台 `syncRealStatus`;各阶段进度写入 `getDeployProgress()` |
688
+ | `getDeployProgress()` | 部署进度中心:`emitter`(事件名 `deploy:{name}`)、`snapshot(name)` 返回最近一次部署 `{ progressId, events }` |
633
689
  | `start` / `stop` / `restart` | 生命周期;`start`/`restart` 立即 `deploying`,后台按 PM2+health 写回真实状态 |
634
690
  | `syncStatus({ name, recoverIfMissing? })` | 同步单个应用真实状态并返回公开对象 |
635
- | `syncAllStatuses({ recoverIfMissing? })` | 批量同步(父应用启动 reconcile 使用) |
691
+ | `syncAllStatuses({ recoverIfMissing?, excludeIds? })` | 批量同步(父应用启动 reconcile 使用);`excludeIds` 中的应用跳过 |
692
+ | `listMissingArtifacts()` | 列出当前版本目录(`artifactPath/server`)缺失的应用:`[{ app, ver }]` |
693
+ | `restoreArtifact({ app, ver })` | 补回单个应用的当前版本目录,返回同 `restoreMissingArtifacts` 的单项 |
636
694
  | `remove({ name, exportBeforeRemove?, cleanupData?, ... })` | 删除;默认可导出+清理所属表 |
637
695
  | `logs({ name, stream, file?, perPage, currentPage, beforeLine? })` | 读当前日志或指定归档 |
638
696
  | `logFiles({ name })` | 列出日志文件 |
@@ -672,11 +730,13 @@ data: {"name":"demo","ts":1791456608533,...}
672
730
 
673
731
  | 方法签名 | 说明 |
674
732
  |----------|------|
675
- | `onReady()` | 连接 PM2、挂 log bus、启动日志切分与负载采样定时器,并 **setImmediate** 异步 `reconcile`、一次 `rotateLogs` 与一次 `sampleLoad`(不阻塞父应用 ready) |
733
+ | `onReady()` | `ensureSchema` 后连接 PM2、挂 log bus、启动日志切分与负载采样定时器,并 **setImmediate** 异步执行 `recoverOnStartup`,以及一次 `rotateLogs` 与一次 `sampleLoad`(不阻塞父应用 ready) |
734
+ | `ensureSchema()` | 宿主 `sync()` 不给已有表加列:为老库补齐后续新增的可空列(当前为 `appVersion.fileId`、`appVersion.migrationFiles`),可重复执行 |
735
+ | `recoverOnStartup()` | 两路并行:部署目录完好的应用立即 `reconcile`;目录缺失的应用逐个 `restoreArtifact`(需重装依赖,较慢),每补完一个即单独 `syncStatus` 并按需拉起;补不回且原为 `running`/`deploying` 的标记 `error`;两路结束后 `pruneAllVersionArtifacts`。返回 `{ reconciled, restored, pruned }` |
676
736
  | `onClose()` | 停止切分与采样定时器并等待进行中的任务,断开 bus / PM2(由插件 `onClose` 调用) |
677
737
  | `rotateLogs({ now? })` | 立即执行一轮切分 + 压缩 + 保留清理;返回本轮切分的 `[{ name, stream, fileName, reason }]`,`reason` 为 `size` / `daily` |
678
738
  | `sampleLoad({ now? })` | 立即采样一轮:每个应用一条样本写入负载缓冲并推送 SSE,返回样本数组;PM2 未连接时返回 `[]` |
679
- | `reconcile()` | 调用 `app.syncAllStatuses({ recoverIfMissing: true })`:按 PM2 真实状态写回 DB;`running`/`deploying` 且进程缺失时尝试拉起 |
739
+ | `reconcile({ excludeIds? })` | 调用 `app.syncAllStatuses({ recoverIfMissing: true, excludeIds })`:按 PM2 真实状态写回 DB;`running`/`deploying` 且进程缺失时尝试拉起 |
680
740
 
681
741
  #### 数据模型
682
742
 
@@ -715,6 +775,8 @@ data: {"name":"demo","ts":1791456608533,...}
715
775
  | artifactPath | string | 解压目录 |
716
776
  | hasMigration | boolean | 是否存在 sql 文件 |
717
777
  | migrationPath | string | 相对 server 的 sql 目录,默认 `sql` |
778
+ | fileId | string / null | 宿主文件 id(经 `fileId` 上传时写入);版本目录据此按需重建,非当前版本不常驻磁盘 |
779
+ | migrationFiles | json / null | SQL 迁移脚本 `[{ name, content, updatedAt }]`,接口不返回(列表只给 `sqlFiles` 文件名) |
718
780
 
719
781
  唯一约束:`(app_name, version)`。
720
782
 
package/index.js CHANGED
@@ -42,6 +42,8 @@ module.exports = fp(
42
42
  resolveSystemEnv: null,
43
43
  /** async ({ app }) => void,应用删除成功后调用,app 为删除前快照;报错只记日志 */
44
44
  onAppRemoved: null,
45
+ /** async ({ fileId, name, version, request }) => Buffer,版本上传传 fileId 时读取宿主文件 */
46
+ resolveVersionFile: null,
45
47
  secretEnvKeyPattern: /(SECRET|PASSWORD|TOKEN|KEY|PRIVATE)/i,
46
48
  sqlPath: 'sql',
47
49
  migrateBeforeStart: false,
@@ -1,7 +1,9 @@
1
1
  const fp = require('fastify-plugin');
2
+ const path = require('node:path');
2
3
  const fs = require('fs-extra');
3
4
  const yazl = require('yazl');
4
5
  const { createSseReply } = require('../utils/logStream');
6
+ const { DEPLOY_STEPS } = require('../utils/deployProgress');
5
7
  const { formatInTz } = require('../utils/logTime');
6
8
 
7
9
  module.exports = fp(async (fastify, options) => {
@@ -117,29 +119,36 @@ module.exports = fp(async (fastify, options) => {
117
119
  {
118
120
  onRequest: auth(),
119
121
  schema: {
120
- summary: '上传应用版本 zip'
122
+ summary: '上传应用版本 zip(multipart 传 file,或 JSON 传宿主文件 fileId)'
121
123
  }
122
124
  },
123
125
  async request => {
124
- const parts = request.parts();
125
- let name;
126
- let version;
127
- let label;
126
+ let { name, version, label, fileId } = request.isMultipart() ? {} : request.body || {};
128
127
  let zipBuffer;
129
- for await (const part of parts) {
130
- if (part.type === 'file') {
131
- zipBuffer = await part.toBuffer();
132
- } else {
133
- const val = part.value;
134
- if (part.fieldname === 'name') name = val;
135
- if (part.fieldname === 'version') version = val;
136
- if (part.fieldname === 'label') label = val;
128
+ if (request.isMultipart()) {
129
+ for await (const part of request.parts()) {
130
+ if (part.type === 'file') {
131
+ zipBuffer = await part.toBuffer();
132
+ } else {
133
+ const val = part.value;
134
+ if (part.fieldname === 'name') name = val;
135
+ if (part.fieldname === 'version') version = val;
136
+ if (part.fieldname === 'label') label = val;
137
+ if (part.fieldname === 'fileId') fileId = val;
138
+ }
137
139
  }
138
140
  }
139
- if (!zipBuffer) {
140
- throw fastify.httpErrors.badRequest('file is required');
141
+ if (zipBuffer) {
142
+ return services.app.uploadVersion({ name, version, label, zipBuffer });
143
+ }
144
+ if (!fileId) {
145
+ throw fastify.httpErrors.badRequest('file or fileId is required');
141
146
  }
142
- return services.app.uploadVersion({ name, version, label, zipBuffer });
147
+ if (typeof options.resolveVersionFile !== 'function') {
148
+ throw fastify.httpErrors.badRequest('fileId upload is not supported: resolveVersionFile is not configured');
149
+ }
150
+ zipBuffer = await options.resolveVersionFile({ fileId, name, version, request });
151
+ return services.app.uploadVersion({ name, version, label, zipBuffer, fileId });
143
152
  }
144
153
  );
145
154
 
@@ -163,6 +172,39 @@ module.exports = fp(async (fastify, options) => {
163
172
  async request => services.app.listVersions(request.query)
164
173
  );
165
174
 
175
+ fastify.get(
176
+ `${options.prefix}/app/version/download`,
177
+ {
178
+ onRequest: auth(),
179
+ schema: {
180
+ summary: '下载版本部署包(有 fileId 返回原始 zip,否则由版本目录打包)',
181
+ query: {
182
+ type: 'object',
183
+ required: ['name', 'versionId'],
184
+ properties: {
185
+ name: { type: 'string' },
186
+ versionId: { type: 'string' }
187
+ }
188
+ }
189
+ }
190
+ },
191
+ async (request, reply) => {
192
+ const { fileName, buffer, artifactPath, files } = await services.app.resolveVersionPackage(request.query);
193
+ reply.header('Content-Type', 'application/zip');
194
+ reply.header('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"; filename*=UTF-8''${encodeURIComponent(fileName)}`);
195
+ if (buffer) {
196
+ reply.header('Content-Length', buffer.length);
197
+ return reply.send(buffer);
198
+ }
199
+ const zipFile = new yazl.ZipFile();
200
+ for (const file of files) {
201
+ zipFile.addFile(path.join(artifactPath, file), file);
202
+ }
203
+ zipFile.end();
204
+ return reply.send(zipFile.outputStream);
205
+ }
206
+ );
207
+
166
208
  fastify.post(
167
209
  `${options.prefix}/app/deploy`,
168
210
  {
@@ -177,6 +219,7 @@ module.exports = fp(async (fastify, options) => {
177
219
  versionId: { type: 'string' },
178
220
  version: { type: 'string' },
179
221
  runMigration: { type: 'boolean', default: true },
222
+ progressId: { type: 'string', description: '进度标识;先用它订阅 deploy/stream 再发起部署' },
180
223
  migrations: {
181
224
  type: 'array',
182
225
  description: '逐个指定待执行脚本的处理方式;未列出的待执行脚本按 execute 处理',
@@ -196,6 +239,44 @@ module.exports = fp(async (fastify, options) => {
196
239
  async request => services.app.deploy(request.body)
197
240
  );
198
241
 
242
+ fastify.get(
243
+ `${options.prefix}/app/deploy/stream`,
244
+ {
245
+ onRequest: auth(),
246
+ schema: {
247
+ summary: 'SSE 部署进度',
248
+ query: {
249
+ type: 'object',
250
+ required: ['name'],
251
+ properties: {
252
+ name: { type: 'string' },
253
+ progressId: { type: 'string' }
254
+ }
255
+ }
256
+ }
257
+ },
258
+ async (request, reply) => {
259
+ const { name, progressId } = request.query;
260
+ await services.app.detail({ name });
261
+ const sse = createSseReply(reply, { heartbeatMs: options.sseHeartbeatMs });
262
+ const progress = services.app.getDeployProgress();
263
+ const matches = event => !progressId || event.progressId === progressId;
264
+ const snapshot = progress.snapshot(name);
265
+ sse.writeEvent('snapshot', {
266
+ steps: DEPLOY_STEPS,
267
+ events: snapshot ? snapshot.events.filter(matches) : []
268
+ });
269
+
270
+ const onProgress = event => matches(event) && sse.writeEvent('progress', event);
271
+ const eventName = `deploy:${name}`;
272
+ progress.emitter.on(eventName, onProgress);
273
+ request.raw.on('close', () => {
274
+ progress.emitter.off(eventName, onProgress);
275
+ sse.close();
276
+ });
277
+ }
278
+ );
279
+
199
280
  fastify.get(
200
281
  `${options.prefix}/app/list`,
201
282
  {
@@ -33,6 +33,16 @@ module.exports = ({ DataTypes }) => {
33
33
  allowNull: true,
34
34
  defaultValue: 'sql',
35
35
  comment: '相对 server 的 sql 目录'
36
+ },
37
+ fileId: {
38
+ type: DataTypes.STRING,
39
+ allowNull: true,
40
+ comment: '宿主文件 id;版本目录缺失时经 resolveVersionFile 重建'
41
+ },
42
+ migrationFiles: {
43
+ type: DataTypes.JSON,
44
+ allowNull: true,
45
+ comment: 'SQL 迁移文件 [{ name, content, updatedAt }];版本目录可随时删除,部署时写回磁盘'
36
46
  }
37
47
  },
38
48
  options: {
@@ -10,7 +10,8 @@ const { ensureLogFiles, readLogTail, readLastLines, appendLog, countLines } = re
10
10
  const { currentFileName, isCurrentFileName, resolveLogFile, listLogFiles } = require('../utils/logRotate');
11
11
  const { toIsoInTz, convertLogText } = require('../utils/logTime');
12
12
  const { runSqlMigrations, MIGRATION_ACTIONS } = require('../utils/migrate');
13
- const { listSqlFiles } = require('../utils/validatePackage');
13
+ const { readSqlDir, writeSqlDir } = require('../utils/versionSql');
14
+ const { createDeployProgress } = require('../utils/deployProgress');
14
15
  const pm2Util = require('../utils/pm2');
15
16
 
16
17
  const NAME_RE = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/;
@@ -250,7 +251,42 @@ module.exports = fp(async (fastify, options) => {
250
251
  return toPublicApp(app);
251
252
  };
252
253
 
253
- const uploadVersion = async ({ name, version, label, zipBuffer }) => {
254
+ const versionSqlDir = ver => path.join(ver.artifactPath, 'server', ver.migrationPath || options.sqlPath || 'sql');
255
+
256
+ const hasArtifact = ver => fs.pathExists(path.join(ver.artifactPath, 'server'));
257
+
258
+ /** SQL 以数据库为准;旧版本行(migrationFiles 为空)首次访问时从版本目录回填 */
259
+ const getMigrationFiles = async ver => {
260
+ if (Array.isArray(ver.migrationFiles)) {
261
+ return ver.migrationFiles;
262
+ }
263
+ if (!(await hasArtifact(ver))) {
264
+ return [];
265
+ }
266
+ const files = await readSqlDir(versionSqlDir(ver));
267
+ await ver.update({ migrationFiles: files, hasMigration: files.length > 0 });
268
+ return files;
269
+ };
270
+
271
+ /** 写库,版本目录在磁盘上时同步覆盖其 .sql */
272
+ const saveMigrationFiles = async (ver, files) => {
273
+ const sorted = [...files].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
274
+ await ver.update({ migrationFiles: sorted, hasMigration: sorted.length > 0 });
275
+ if (await hasArtifact(ver)) {
276
+ await writeSqlDir(versionSqlDir(ver), sorted);
277
+ }
278
+ return sorted;
279
+ };
280
+
281
+ const toPublicVersion = (row, files = row.migrationFiles) => {
282
+ const { migrationFiles, ...json } = row.toJSON();
283
+ if (Array.isArray(files) && files.length) {
284
+ json.sqlFiles = files.map(item => item.name);
285
+ }
286
+ return json;
287
+ };
288
+
289
+ const uploadVersion = async ({ name, version, label, zipBuffer, fileId }) => {
254
290
  if (!version) {
255
291
  httpError(400, 'version is required');
256
292
  }
@@ -270,7 +306,10 @@ module.exports = fp(async (fastify, options) => {
270
306
  // Prepare outside the version table so failed or in-progress uploads never appear in the version list.
271
307
  const versionsDir = path.join(app.rootPath, 'versions');
272
308
  const stagingPath = path.join(versionsDir, `.staging-${crypto.randomUUID()}`);
309
+ // 有宿主文件的版本只校验不常驻磁盘,部署时再解压;没有的只能保留目录
310
+ const keepArtifact = !fileId;
273
311
  let prepared;
312
+ let migrationFiles;
274
313
  try {
275
314
  prepared = await prepareVersionArtifact({
276
315
  zipBuffer,
@@ -278,8 +317,10 @@ module.exports = fp(async (fastify, options) => {
278
317
  maxZipSize: options.maxZipSize,
279
318
  maxZipEntries: options.maxZipEntries,
280
319
  npmInstallTimeoutMs: options.npmInstallTimeoutMs,
281
- sqlPath: options.sqlPath
320
+ sqlPath: options.sqlPath,
321
+ install: keepArtifact
282
322
  });
323
+ migrationFiles = await readSqlDir(path.join(stagingPath, 'server', prepared.migrationPath));
283
324
  } catch (e) {
284
325
  await fs.remove(stagingPath).catch(() => {});
285
326
  httpError(400, e.message);
@@ -287,26 +328,34 @@ module.exports = fp(async (fastify, options) => {
287
328
 
288
329
  let artifactPath = null;
289
330
  try {
290
- return await models.appVersion.sequelize.transaction(async transaction => {
331
+ const result = await models.appVersion.sequelize.transaction(async transaction => {
291
332
  const versionRow = await models.appVersion.create(
292
333
  {
293
334
  appName: name,
294
335
  version,
295
336
  label: label || null,
296
337
  artifactPath: stagingPath,
297
- hasMigration: prepared.hasMigration,
298
- migrationPath: prepared.migrationPath
338
+ hasMigration: migrationFiles.length > 0,
339
+ migrationPath: prepared.migrationPath,
340
+ fileId: fileId ? String(fileId) : null,
341
+ migrationFiles
299
342
  },
300
343
  { transaction }
301
344
  );
302
345
  artifactPath = path.join(versionsDir, String(versionRow.id));
303
- await fs.move(stagingPath, artifactPath);
346
+ if (keepArtifact) {
347
+ await fs.move(stagingPath, artifactPath);
348
+ }
304
349
  await versionRow.update({ artifactPath }, { transaction });
305
- return versionRow.toJSON();
350
+ return toPublicVersion(versionRow);
306
351
  });
352
+ if (!keepArtifact) {
353
+ await fs.remove(stagingPath).catch(() => {});
354
+ }
355
+ return result;
307
356
  } catch (e) {
308
357
  await fs.remove(stagingPath).catch(() => {});
309
- if (artifactPath) {
358
+ if (artifactPath && keepArtifact) {
310
359
  await fs.remove(artifactPath).catch(() => {});
311
360
  }
312
361
  if (e.name === 'SequelizeUniqueConstraintError') {
@@ -326,16 +375,52 @@ module.exports = fp(async (fastify, options) => {
326
375
  });
327
376
  const pageData = await Promise.all(
328
377
  rows.map(async row => {
329
- const json = row.toJSON();
330
- if (json.hasMigration) {
331
- json.sqlFiles = await listSqlFiles(path.join(json.artifactPath, 'server', json.migrationPath || 'sql'));
332
- }
333
- return json;
378
+ const files = await getMigrationFiles(row);
379
+ return toPublicVersion(row, files);
334
380
  })
335
381
  );
336
382
  return { pageData, totalCount: count };
337
383
  };
338
384
 
385
+ const listArtifactFiles = async (root, dir = '') => {
386
+ const files = [];
387
+ for (const entry of await fs.readdir(path.join(root, dir), { withFileTypes: true })) {
388
+ const rel = dir ? `${dir}/${entry.name}` : entry.name;
389
+ // 部署时装的依赖与复制进 server 的 build 都不属于原始包
390
+ if (rel === 'server/node_modules' || rel === 'server/build') {
391
+ continue;
392
+ }
393
+ if (entry.isDirectory()) {
394
+ files.push(...(await listArtifactFiles(root, rel)));
395
+ } else if (entry.isFile()) {
396
+ files.push(rel);
397
+ }
398
+ }
399
+ return files;
400
+ };
401
+
402
+ /** 有 fileId 时返回原始 zip;否则从版本目录还原包内容(不含 server/node_modules、server/build) */
403
+ const resolveVersionPackage = async ({ name, versionId }) => {
404
+ await getByName(name);
405
+ const ver = await models.appVersion.findByPk(versionId);
406
+ if (!ver || ver.appName !== name) {
407
+ httpError(404, 'version not found');
408
+ }
409
+ const fileName = `${name}-${ver.version}.zip`;
410
+ if (ver.fileId && typeof options.resolveVersionFile === 'function') {
411
+ try {
412
+ const buffer = await options.resolveVersionFile({ fileId: ver.fileId, name, version: ver.version });
413
+ return { fileName, buffer };
414
+ } catch (e) {
415
+ fastify.log.warn({ err: e, app: name, version: ver.version }, 'resolve version file failed, fallback to artifact');
416
+ }
417
+ }
418
+ if (!(await fs.pathExists(path.join(ver.artifactPath, 'server')))) {
419
+ httpError(404, 'version package not found');
420
+ }
421
+ return { fileName, artifactPath: ver.artifactPath, files: await listArtifactFiles(ver.artifactPath) };
422
+ };
423
+
339
424
  const healthCheck = async (port, { timeoutMs, intervalMs, healthPath }) => {
340
425
  const started = Date.now();
341
426
  const url = `http://127.0.0.1:${port}${healthPath || '/'}`;
@@ -365,6 +450,7 @@ module.exports = fp(async (fastify, options) => {
365
450
 
366
451
  /** 同一 app 并发同步合并为一次,避免 restart/reconcile 竞态 */
367
452
  const statusSyncInflight = new Map();
453
+ const deployProgress = createDeployProgress();
368
454
 
369
455
  /**
370
456
  * 根据 PM2 真实进程状态 + HTTP 健康检查,异步写回 DB status。
@@ -459,11 +545,14 @@ module.exports = fp(async (fastify, options) => {
459
545
 
460
546
  const finishDeployAsync = async appId => syncRealStatus(appId, { recoverIfMissing: false });
461
547
 
462
- const scheduleStatusSync = (appId, opts) => {
548
+ const scheduleStatusSync = (appId, { onSettled, ...opts } = {}) => {
463
549
  setImmediate(() => {
464
- syncRealStatus(appId, opts).catch(err => {
465
- fastify.log.warn({ err, appId }, 'syncRealStatus failed');
466
- });
550
+ syncRealStatus(appId, opts)
551
+ .catch(err => {
552
+ fastify.log.warn({ err, appId }, 'syncRealStatus failed');
553
+ return { status: 'error', message: err.message };
554
+ })
555
+ .then(result => onSettled && onSettled(result));
467
556
  });
468
557
  };
469
558
 
@@ -472,14 +561,18 @@ module.exports = fp(async (fastify, options) => {
472
561
  return syncRealStatus(app.id, { recoverIfMissing });
473
562
  };
474
563
 
475
- const syncAllStatuses = async ({ recoverIfMissing = true } = {}) => {
564
+ const syncAllStatuses = async ({ recoverIfMissing = true, excludeIds = [] } = {}) => {
476
565
  const apps = await models.app.findAll({
477
566
  where: {
478
567
  status: ['running', 'deploying', 'stopped', 'error']
479
568
  }
480
569
  });
570
+ const excluded = new Set(excludeIds.map(String));
481
571
  const results = [];
482
572
  for (const app of apps) {
573
+ if (excluded.has(String(app.id))) {
574
+ continue;
575
+ }
483
576
  const shouldRecover = recoverIfMissing && (app.status === 'running' || app.status === 'deploying');
484
577
  results.push(await syncRealStatus(app.id, { recoverIfMissing: shouldRecover }));
485
578
  }
@@ -551,7 +644,117 @@ module.exports = fp(async (fastify, options) => {
551
644
  });
552
645
  };
553
646
 
554
- const deploy = async ({ name, versionId, version, runMigration = true, migrations }) => {
647
+ const linkCurrent = async (app, artifactPath) => {
648
+ const currentLink = path.join(app.rootPath, 'current');
649
+ await fs.remove(currentLink).catch(() => {});
650
+ await fs.symlink(artifactPath, currentLink).catch(async () => {
651
+ // Windows fallback: copy marker file
652
+ await fs.writeFile(path.join(app.rootPath, 'current-path.txt'), artifactPath);
653
+ });
654
+ };
655
+
656
+ /** 只做 fs 检查:返回当前版本目录(artifactPath/server)缺失的应用 */
657
+ const listMissingArtifacts = async () => {
658
+ const { Op } = fastify.sequelize.Sequelize;
659
+ const apps = await models.app.findAll({ where: { currentVersionId: { [Op.ne]: null } } });
660
+ const missing = [];
661
+ for (const app of apps) {
662
+ const ver = await models.appVersion.findByPk(app.currentVersionId);
663
+ if (ver && !(await fs.pathExists(path.join(ver.artifactPath, 'server')))) {
664
+ missing.push({ app, ver });
665
+ }
666
+ }
667
+ return missing;
668
+ };
669
+
670
+ const canResolveFile = ver => !!ver.fileId && typeof options.resolveVersionFile === 'function';
671
+
672
+ /** 版本目录不在磁盘时按 fileId 取回 zip 解压并装依赖;再以数据库中的 SQL 覆盖目录内 .sql */
673
+ const materializeArtifact = async (app, ver, { onProgress } = {}) => {
674
+ if (await hasArtifact(ver)) {
675
+ onProgress && onProgress('reuse');
676
+ } else {
677
+ if (!canResolveFile(ver)) {
678
+ throw new Error(`version ${ver.version} artifact missing and cannot be restored without fileId`);
679
+ }
680
+ const stagingPath = path.join(app.rootPath, 'versions', `.staging-${crypto.randomUUID()}`);
681
+ try {
682
+ onProgress && onProgress('download');
683
+ const zipBuffer = await options.resolveVersionFile({ fileId: ver.fileId, name: app.name, version: ver.version });
684
+ await prepareVersionArtifact({
685
+ zipBuffer,
686
+ artifactPath: stagingPath,
687
+ maxZipSize: options.maxZipSize,
688
+ maxZipEntries: options.maxZipEntries,
689
+ npmInstallTimeoutMs: options.npmInstallTimeoutMs,
690
+ sqlPath: options.sqlPath,
691
+ onProgress
692
+ });
693
+ await fs.move(stagingPath, ver.artifactPath, { overwrite: true });
694
+ } catch (e) {
695
+ await fs.remove(stagingPath).catch(() => {});
696
+ throw e;
697
+ }
698
+ }
699
+ await writeSqlDir(versionSqlDir(ver), await getMigrationFiles(ver));
700
+ };
701
+
702
+ /** 删除非当前版本中可从宿主文件重建的目录;删前先把 SQL 回填进库 */
703
+ const pruneVersionArtifacts = async app => {
704
+ const versions = await models.appVersion.findAll({ where: { appName: app.name } });
705
+ const removed = [];
706
+ for (const ver of versions) {
707
+ if (String(ver.id) === String(app.currentVersionId) || !canResolveFile(ver) || !(await fs.pathExists(ver.artifactPath))) {
708
+ continue;
709
+ }
710
+ await getMigrationFiles(ver);
711
+ await fs.remove(ver.artifactPath);
712
+ removed.push(ver.version);
713
+ }
714
+ if (removed.length) {
715
+ fastify.log.info({ app: app.name, versions: removed }, 'pruned non-current version artifacts');
716
+ }
717
+ return removed;
718
+ };
719
+
720
+ const pruneAllVersionArtifacts = async () => {
721
+ const results = [];
722
+ for (const app of await models.app.findAll()) {
723
+ try {
724
+ results.push({ name: app.name, removed: await pruneVersionArtifacts(app) });
725
+ } catch (e) {
726
+ fastify.log.warn({ err: e, app: app.name }, 'prune version artifacts failed');
727
+ }
728
+ }
729
+ return results;
730
+ };
731
+
732
+ /** 补回当前版本目录并恢复 current 链接;不跑 SQL 迁移 */
733
+ const restoreArtifact = async ({ app, ver }) => {
734
+ if (!canResolveFile(ver)) {
735
+ fastify.log.warn({ app: app.name, version: ver.version }, 'current version artifact missing and cannot be restored without fileId');
736
+ return { name: app.name, version: ver.version, action: 'skipped', reason: 'no fileId' };
737
+ }
738
+ try {
739
+ await materializeArtifact(app, ver);
740
+ await linkCurrent(app, ver.artifactPath);
741
+ fastify.log.info({ app: app.name, version: ver.version }, 'current version artifact restored from file');
742
+ return { name: app.name, version: ver.version, action: 'restored' };
743
+ } catch (e) {
744
+ fastify.log.warn({ err: e, app: app.name, version: ver.version }, 'restore current version artifact failed');
745
+ return { name: app.name, version: ver.version, action: 'failed', reason: e.message };
746
+ }
747
+ };
748
+
749
+ const restoreMissingArtifacts = async () => {
750
+ const results = [];
751
+ for (const item of await listMissingArtifacts()) {
752
+ results.push(await restoreArtifact(item));
753
+ }
754
+ return results;
755
+ };
756
+
757
+ const deploy = async ({ name, versionId, version, runMigration = true, migrations, progressId }) => {
555
758
  const app = await getByName(name);
556
759
  let ver = null;
557
760
  if (versionId) {
@@ -563,21 +766,29 @@ module.exports = fp(async (fastify, options) => {
563
766
  httpError(404, 'version not found');
564
767
  }
565
768
 
769
+ const progress = deployProgress.begin(app.name, progressId || crypto.randomUUID());
770
+ let step = 'package';
771
+ progress.report(step, 'running');
772
+ try {
773
+ await materializeArtifact(app, ver, { onProgress: detail => progress.report('package', 'running', { detail }) });
774
+ } catch (e) {
775
+ progress.report(step, 'error', { message: e.message });
776
+ httpError(400, e.message);
777
+ }
778
+ progress.report(step, 'done');
779
+
566
780
  await app.update({
567
781
  status: 'deploying',
568
782
  currentVersionId: String(ver.id),
569
783
  message: null
570
784
  });
571
785
 
572
- const currentLink = path.join(app.rootPath, 'current');
573
- await fs.remove(currentLink).catch(() => {});
574
- await fs.symlink(ver.artifactPath, currentLink).catch(async () => {
575
- // Windows fallback: copy marker file
576
- await fs.writeFile(path.join(app.rootPath, 'current-path.txt'), ver.artifactPath);
577
- });
786
+ await linkCurrent(app, ver.artifactPath);
578
787
 
579
788
  try {
789
+ step = 'migrate';
580
790
  if (runMigration && options.migrateBeforeStart && ver.hasMigration) {
791
+ progress.report(step, 'running');
581
792
  const env = mergeEnv({
582
793
  passthroughEnvKeys: options.passthroughEnvKeys,
583
794
  appEnv: app.env,
@@ -612,6 +823,9 @@ module.exports = fp(async (fastify, options) => {
612
823
  } catch (e) {
613
824
  // ignore ownership merge failures
614
825
  }
826
+ progress.report(step, 'done');
827
+ } else {
828
+ progress.report(step, 'skipped');
615
829
  }
616
830
 
617
831
  if (runMigration === false) {
@@ -619,18 +833,29 @@ module.exports = fp(async (fastify, options) => {
619
833
  app.env = Object.assign({}, app.env, { RUN_SQL_ON_SYNC: 'false' });
620
834
  }
621
835
 
836
+ step = 'start';
837
+ progress.report(step, 'running');
622
838
  const beforeForReady = await snapshotTablesSafe(app);
623
839
  await startProcess(app);
624
- scheduleStatusSync(app.id, { beforeTables: beforeForReady });
840
+ progress.report(step, 'done');
841
+ step = 'ready';
842
+ progress.report(step, 'running');
843
+ scheduleStatusSync(app.id, {
844
+ beforeTables: beforeForReady,
845
+ onSettled: result => (result?.status === 'running' ? progress.report('ready', 'done') : progress.report('ready', 'error', { message: result?.message || result?.status || null }))
846
+ });
847
+ await pruneVersionArtifacts(app).catch(err => fastify.log.warn({ err, app: app.name }, 'prune version artifacts failed'));
625
848
  return {
626
849
  name: app.name,
627
850
  status: 'deploying',
628
851
  versionId: String(ver.id),
852
+ progressId: progress.progressId,
629
853
  port: app.port,
630
854
  pathUrl: `${mountPrefix(app.name)}/`,
631
855
  domain: app.domain
632
856
  };
633
857
  } catch (e) {
858
+ progress.report(step, 'error', { message: e.message });
634
859
  await app.update({ status: 'error', message: e.message });
635
860
  httpError(400, e.message);
636
861
  }
@@ -715,6 +940,7 @@ module.exports = fp(async (fastify, options) => {
715
940
  const snapshot = app.toJSON();
716
941
  await models.appVersion.destroy({ where: { appName: name } });
717
942
  await fs.remove(app.rootPath).catch(() => {});
943
+ deployProgress.clear(app.name);
718
944
  await app.destroy();
719
945
  if (typeof options.onAppRemoved === 'function') {
720
946
  try {
@@ -858,6 +1084,14 @@ module.exports = fp(async (fastify, options) => {
858
1084
  syncStatus,
859
1085
  syncAllStatuses,
860
1086
  remove,
1087
+ resolveVersionPackage,
1088
+ listMissingArtifacts,
1089
+ restoreArtifact,
1090
+ restoreMissingArtifacts,
1091
+ pruneVersionArtifacts,
1092
+ pruneAllVersionArtifacts,
1093
+ getMigrationFiles,
1094
+ saveMigrationFiles,
861
1095
  logs,
862
1096
  logFiles,
863
1097
  resolveAppLogFile,
@@ -865,6 +1099,7 @@ module.exports = fp(async (fastify, options) => {
865
1099
  removeLogFiles,
866
1100
  load,
867
1101
  getLoadStore,
1102
+ getDeployProgress: () => deployProgress,
868
1103
  toPublicApp,
869
1104
  mountPrefix,
870
1105
  findByHost,
@@ -35,8 +35,28 @@ module.exports = fp(async (fastify, options) => {
35
35
  return models.app.findOne({ where: { pm2Name } });
36
36
  };
37
37
 
38
+ /** 宿主 sync() 不会给已有表加列:老库升级时补齐后续新增的可空列 */
39
+ const ensureSchema = async () => {
40
+ const { models } = fastify[options.name];
41
+ const model = models.appVersion;
42
+ const queryInterface = model.sequelize.getQueryInterface();
43
+ const table = model.getTableName();
44
+ const columns = await queryInterface.describeTable(table);
45
+ for (const name of ['fileId', 'migrationFiles']) {
46
+ const attr = model.rawAttributes[name];
47
+ if (!columns[attr.field]) {
48
+ await queryInterface.addColumn(table, attr.field, { type: attr.type, allowNull: true, comment: attr.comment });
49
+ }
50
+ }
51
+ };
52
+
38
53
  const onReady = async () => {
39
54
  await fsEnsureAppsRoot();
55
+ try {
56
+ await ensureSchema();
57
+ } catch (e) {
58
+ fastify.log.warn({ err: e }, 'ensure app-manager schema failed');
59
+ }
40
60
  try {
41
61
  await pm2Util.connect();
42
62
  connected = true;
@@ -87,9 +107,9 @@ module.exports = fp(async (fastify, options) => {
87
107
  loadTimer.unref?.();
88
108
  }
89
109
 
90
- // 父应用重启后不阻塞 ready:后台按 PM2 真实状态异步对齐 DB
110
+ // 父应用重启后不阻塞 ready:后台按 PM2 真实状态对齐 DB,缺部署目录的应用另行补回后再拉起
91
111
  setImmediate(() => {
92
- reconcile().catch(err => {
112
+ recoverOnStartup().catch(err => {
93
113
  fastify.log.warn({ err }, 'async reconcile/sync statuses failed');
94
114
  });
95
115
  rotateLogs().catch(err => fastify.log.warn({ err }, 'log rotation failed'));
@@ -103,14 +123,57 @@ module.exports = fp(async (fastify, options) => {
103
123
  await fs.ensureDir(options.appsRoot);
104
124
  };
105
125
 
106
- const reconcile = async () => {
126
+ const reconcile = async ({ excludeIds } = {}) => {
107
127
  const { services } = fastify[options.name];
108
128
  if (typeof services.app.syncAllStatuses === 'function') {
109
- return services.app.syncAllStatuses({ recoverIfMissing: true });
129
+ return services.app.syncAllStatuses({ recoverIfMissing: true, excludeIds });
110
130
  }
111
131
  return [];
112
132
  };
113
133
 
134
+ /** 逐个补回缺失的部署目录(npm install 耗时),每补完一个就单独对齐状态并按需拉起 */
135
+ const restoreArtifacts = async missing => {
136
+ const { services } = fastify[options.name];
137
+ const results = [];
138
+ for (const item of missing) {
139
+ const result = await services.app.restoreArtifact(item);
140
+ results.push(result);
141
+ const { app } = item;
142
+ const wasActive = app.status === 'running' || app.status === 'deploying';
143
+ try {
144
+ const synced = await services.app.syncStatus({ name: app.name, recoverIfMissing: result.action === 'restored' && wasActive });
145
+ if (result.action !== 'restored' && wasActive && synced?.status !== 'running') {
146
+ await app.update({ status: 'error', message: `current version artifact missing: ${result.reason}` });
147
+ }
148
+ } catch (e) {
149
+ fastify.log.warn({ err: e, app: app.name }, 'sync status after artifact restore failed');
150
+ }
151
+ }
152
+ return results;
153
+ };
154
+
155
+ const recoverOnStartup = async () => {
156
+ const { services } = fastify[options.name];
157
+ const missing = await services.app.listMissingArtifacts();
158
+ // 两路并行:部署目录完好的应用不必等其它应用重装依赖
159
+ const [reconciled, restored] = await Promise.all([
160
+ reconcile({ excludeIds: missing.map(({ app }) => app.id) }).catch(err => {
161
+ fastify.log.warn({ err }, 'async reconcile/sync statuses failed');
162
+ return [];
163
+ }),
164
+ restoreArtifacts(missing).catch(err => {
165
+ fastify.log.warn({ err }, 'restore missing artifacts failed');
166
+ return [];
167
+ })
168
+ ]);
169
+ // 只保留当前版本目录,其余版本部署时再从宿主文件解压
170
+ const pruned = await services.app.pruneAllVersionArtifacts().catch(err => {
171
+ fastify.log.warn({ err }, 'prune version artifacts failed');
172
+ return [];
173
+ });
174
+ return { reconciled, restored, pruned };
175
+ };
176
+
114
177
  const runRotation = async ({ now = new Date() } = {}) => {
115
178
  const { models } = fastify[options.name];
116
179
  const apps = await models.app.findAll({ attributes: ['name', 'rootPath'] });
@@ -244,6 +307,6 @@ module.exports = fp(async (fastify, options) => {
244
307
  };
245
308
 
246
309
  Object.assign(fastify[options.name].services, {
247
- bootstrap: { onReady, onClose, reconcile, rotateLogs, sampleLoad }
310
+ bootstrap: { onReady, onClose, ensureSchema, recoverOnStartup, reconcile, rotateLogs, sampleLoad }
248
311
  });
249
312
  });
@@ -1,12 +1,10 @@
1
1
  const fp = require('fastify-plugin');
2
- const path = require('node:path');
3
- const fs = require('fs-extra');
4
- const { listSqlFiles } = require('../utils/validatePackage');
5
2
  const { isValidSqlFileName, readExecutedMigrations, recordMigration, removeMigrationRecord } = require('../utils/migrate');
6
3
 
7
4
  module.exports = fp(async (fastify, options) => {
8
5
  const { models } = fastify[options.name];
9
6
  const getDbops = () => fastify[options.name].services.dbops;
7
+ const getAppService = () => fastify[options.name].services.app;
10
8
 
11
9
  const httpError = (statusCode, message) => {
12
10
  if (fastify.httpErrors) {
@@ -27,8 +25,7 @@ module.exports = fp(async (fastify, options) => {
27
25
  if (!ver || ver.appName !== name) {
28
26
  httpError(404, 'version not found');
29
27
  }
30
- const sqlDir = path.join(ver.artifactPath, 'server', ver.migrationPath || options.sqlPath);
31
- return { app, ver, sqlDir };
28
+ return { app, ver };
32
29
  };
33
30
 
34
31
  const assertFileName = file => {
@@ -37,25 +34,20 @@ module.exports = fp(async (fastify, options) => {
37
34
  }
38
35
  };
39
36
 
40
- const assertFileExists = async (sqlDir, file) => {
37
+ const getFiles = ver => getAppService().getMigrationFiles(ver);
38
+
39
+ const findFile = async (ver, file) => {
41
40
  assertFileName(file);
42
- const filePath = path.join(sqlDir, file);
43
- if (!(await fs.pathExists(filePath))) {
41
+ const item = (await getFiles(ver)).find(f => f.name === file);
42
+ if (!item) {
44
43
  httpError(404, `sql file ${file} not found`);
45
44
  }
46
- return filePath;
47
- };
48
-
49
- const syncHasMigration = async (ver, sqlDir) => {
50
- const hasMigration = (await listSqlFiles(sqlDir)).length > 0;
51
- if (ver.hasMigration !== hasMigration) {
52
- await ver.update({ hasMigration });
53
- }
45
+ return item;
54
46
  };
55
47
 
56
48
  const list = async ({ name, versionId }) => {
57
- const { app, ver, sqlDir } = await resolveVersion({ name, versionId });
58
- const files = await listSqlFiles(sqlDir);
49
+ const { app, ver } = await resolveVersion({ name, versionId });
50
+ const files = await getFiles(ver);
59
51
  let executedMap = new Map();
60
52
  let dbError = null;
61
53
  try {
@@ -63,18 +55,13 @@ module.exports = fp(async (fastify, options) => {
63
55
  } catch (e) {
64
56
  dbError = e.message;
65
57
  }
66
- const pageData = await Promise.all(
67
- files.map(async file => {
68
- const stat = await fs.stat(path.join(sqlDir, file));
69
- return {
70
- name: file,
71
- size: stat.size,
72
- updatedAt: stat.mtime,
73
- executed: dbError ? null : executedMap.has(file),
74
- executedAt: executedMap.get(file) || null
75
- };
76
- })
77
- );
58
+ const pageData = files.map(item => ({
59
+ name: item.name,
60
+ size: Buffer.byteLength(item.content || '', 'utf8'),
61
+ updatedAt: item.updatedAt || null,
62
+ executed: dbError ? null : executedMap.has(item.name),
63
+ executedAt: executedMap.get(item.name) || null
64
+ }));
78
65
  return {
79
66
  versionId: String(ver.id),
80
67
  version: ver.version,
@@ -87,32 +74,33 @@ module.exports = fp(async (fastify, options) => {
87
74
  };
88
75
 
89
76
  const content = async ({ name, versionId, file }) => {
90
- const { sqlDir } = await resolveVersion({ name, versionId });
91
- const filePath = await assertFileExists(sqlDir, file);
92
- return { name: file, content: await fs.readFile(filePath, 'utf8') };
77
+ const { ver } = await resolveVersion({ name, versionId });
78
+ const item = await findFile(ver, file);
79
+ return { name: file, content: item.content || '' };
93
80
  };
94
81
 
95
82
  const save = async ({ name, versionId, file, content: sql }) => {
96
83
  assertFileName(file);
97
- const { ver, sqlDir } = await resolveVersion({ name, versionId });
98
- await fs.ensureDir(sqlDir);
99
- await fs.writeFile(path.join(sqlDir, file), sql || '', 'utf8');
100
- await syncHasMigration(ver, sqlDir);
84
+ const { ver } = await resolveVersion({ name, versionId });
85
+ const files = (await getFiles(ver)).filter(f => f.name !== file);
86
+ files.push({ name: file, content: sql || '', updatedAt: new Date().toISOString() });
87
+ await getAppService().saveMigrationFiles(ver, files);
101
88
  return { name: file };
102
89
  };
103
90
 
104
91
  const remove = async ({ name, versionId, file }) => {
105
- const { ver, sqlDir } = await resolveVersion({ name, versionId });
106
- const filePath = await assertFileExists(sqlDir, file);
107
- await fs.remove(filePath);
108
- await syncHasMigration(ver, sqlDir);
92
+ const { ver } = await resolveVersion({ name, versionId });
93
+ await findFile(ver, file);
94
+ await getAppService().saveMigrationFiles(
95
+ ver,
96
+ (await getFiles(ver)).filter(f => f.name !== file)
97
+ );
109
98
  return { name: file };
110
99
  };
111
100
 
112
101
  const execute = async ({ name, versionId, file }) => {
113
- const { app, sqlDir } = await resolveVersion({ name, versionId });
114
- const filePath = await assertFileExists(sqlDir, file);
115
- const sql = await fs.readFile(filePath, 'utf8');
102
+ const { app, ver } = await resolveVersion({ name, versionId });
103
+ const { content: sql } = await findFile(ver, file);
116
104
  const dbops = getDbops();
117
105
  let beforeTables = [];
118
106
  let afterTables = [];
@@ -131,8 +119,8 @@ module.exports = fp(async (fastify, options) => {
131
119
  };
132
120
 
133
121
  const mark = async ({ name, versionId, file }) => {
134
- const { app, sqlDir } = await resolveVersion({ name, versionId });
135
- await assertFileExists(sqlDir, file);
122
+ const { app, ver } = await resolveVersion({ name, versionId });
123
+ await findFile(ver, file);
136
124
  await getDbops().withSequelize(app, sequelize => recordMigration(sequelize, file));
137
125
  return { name: file, executed: true };
138
126
  };
@@ -0,0 +1,37 @@
1
+ const { EventEmitter } = require('node:events');
2
+
3
+ /** 部署阶段,按顺序推进:准备部署包 → SQL 迁移 → 启动进程 → 等待就绪 */
4
+ const DEPLOY_STEPS = ['package', 'migrate', 'start', 'ready'];
5
+
6
+ /**
7
+ * 每个应用只保留最近一次部署的进度事件,供 SSE 订阅与断线重连回放。
8
+ * 事件:{ progressId, step, status: 'running' | 'done' | 'skipped' | 'error', detail?, message?, at }
9
+ */
10
+ const createDeployProgress = () => {
11
+ const emitter = new EventEmitter();
12
+ emitter.setMaxListeners(0);
13
+ const latest = new Map();
14
+
15
+ const begin = (name, progressId) => {
16
+ const state = { progressId, events: [] };
17
+ latest.set(name, state);
18
+ const report = (step, status, { detail = null, message = null } = {}) => {
19
+ const event = { progressId, step, status, detail, message, at: new Date().toISOString() };
20
+ state.events.push(event);
21
+ emitter.emit(`deploy:${name}`, event);
22
+ return event;
23
+ };
24
+ return { progressId, report };
25
+ };
26
+
27
+ const snapshot = name => latest.get(name) || null;
28
+
29
+ const clear = name => latest.delete(name);
30
+
31
+ return { emitter, begin, snapshot, clear };
32
+ };
33
+
34
+ module.exports = {
35
+ DEPLOY_STEPS,
36
+ createDeployProgress
37
+ };
@@ -33,8 +33,10 @@ const runNpmInstall = (cwd, timeoutMs) =>
33
33
  });
34
34
  });
35
35
 
36
- const prepareVersionArtifact = async ({ zipBuffer, artifactPath, maxZipSize, maxZipEntries, npmInstallTimeoutMs, sqlPath }) => {
36
+ /** install=false 时只解压校验,不装依赖(用于不常驻磁盘的版本上传) */
37
+ const prepareVersionArtifact = async ({ zipBuffer, artifactPath, maxZipSize, maxZipEntries, npmInstallTimeoutMs, sqlPath, install = true, onProgress }) => {
37
38
  await fs.emptyDir(artifactPath);
39
+ onProgress && onProgress('extract');
38
40
  const extractDir = path.join(artifactPath, '_extract');
39
41
  await extractZipSafe(zipBuffer, extractDir, { maxZipSize, maxZipEntries });
40
42
  const packageRoot = await resolvePackageRoot(extractDir);
@@ -60,7 +62,10 @@ const prepareVersionArtifact = async ({ zipBuffer, artifactPath, maxZipSize, max
60
62
  });
61
63
 
62
64
  const serverDir = path.join(artifactPath, 'server');
63
- await runNpmInstall(serverDir, npmInstallTimeoutMs);
65
+ if (install) {
66
+ onProgress && onProgress('install');
67
+ await runNpmInstall(serverDir, npmInstallTimeoutMs);
68
+ }
64
69
 
65
70
  const sqlDir = path.join(serverDir, sqlPath || 'sql');
66
71
  const sqlFiles = await listSqlFiles(sqlDir);
@@ -0,0 +1,33 @@
1
+ const path = require('node:path');
2
+ const fs = require('fs-extra');
3
+ const { listSqlFiles } = require('./validatePackage');
4
+
5
+ /** 读取目录内的 .sql 为 [{ name, content, updatedAt }] */
6
+ const readSqlDir = async sqlDir => {
7
+ const files = [];
8
+ for (const name of await listSqlFiles(sqlDir)) {
9
+ const filePath = path.join(sqlDir, name);
10
+ const [content, stat] = await Promise.all([fs.readFile(filePath, 'utf8'), fs.stat(filePath)]);
11
+ files.push({ name, content, updatedAt: stat.mtime.toISOString() });
12
+ }
13
+ return files;
14
+ };
15
+
16
+ /** 以 files 为准覆盖目录内的 .sql:删掉多余的,写入全部 */
17
+ const writeSqlDir = async (sqlDir, files) => {
18
+ await fs.ensureDir(sqlDir);
19
+ const keep = new Set(files.map(item => item.name));
20
+ for (const name of await listSqlFiles(sqlDir)) {
21
+ if (!keep.has(name)) {
22
+ await fs.remove(path.join(sqlDir, name));
23
+ }
24
+ }
25
+ for (const item of files) {
26
+ await fs.writeFile(path.join(sqlDir, item.name), item.content || '', 'utf8');
27
+ }
28
+ };
29
+
30
+ module.exports = {
31
+ readSqlDir,
32
+ writeSqlDir
33
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kne/fastify-app-manager",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Fastify 单机应用市场插件:版本上传、PM2 托管、路径/域名反代与日志 SSE",
5
5
  "main": "index.js",
6
6
  "scripts": {