@cartanova/qgrid-cli 2.9.0 → 2.9.1

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.
@@ -1,13 +1,49 @@
1
1
  import { TokenModel } from "./application/token/token.model.js";
2
2
  import { getLogger } from "@logtape/logtape";
3
+ import { existsSync } from "node:fs";
4
+ import { readdir } from "node:fs/promises";
3
5
  import path from "node:path";
6
+ import { pathToFileURL } from "node:url";
4
7
  //#region src/startup-migrations.ts
5
- async function runRequiredMigrations() {
8
+ function resolveStartupMigrationDirs(moduleDir = import.meta.dirname) {
9
+ return {
10
+ sourceDir: path.join(moduleDir, "../src/migrations"),
11
+ compiledDir: path.join(moduleDir, "migrations")
12
+ };
13
+ }
14
+ const MIGRATION_FILE_RE = /^\d{14}_.+\.ts$/;
15
+ /**
16
+ * 패키지된 CLI 는 `bundle/dist` 에서 실행되는데, knex 기본 FsMigrations 에 `../src/migrations` 를
17
+ * 넘기면 pending migration 을 `.ts` 로 import 하다 "Unknown file extension .ts" 로 죽는다(2.9.0 사고).
18
+ * 반대로 디렉터리를 `dist/migrations` 로 바꾸면 `knex_migrations` 에 `.ts` 이름으로 기록된 과거
19
+ * migration 이 전부 pending 으로 보인다. 그래서 이름은 `src/migrations` 의 `.ts` 파일명을 그대로
20
+ * 쓰고, 모듈만 컴파일된 `.js` 가 있으면 그것을 로드한다.
21
+ */
22
+ function createStartupMigrationSource(dirs) {
23
+ return {
24
+ async getMigrations() {
25
+ return (await readdir(dirs.sourceDir)).filter((name) => MIGRATION_FILE_RE.test(name) && !name.endsWith(".d.ts")).sort();
26
+ },
27
+ getMigrationName(name) {
28
+ return name;
29
+ },
30
+ async getMigration(name) {
31
+ const compiled = path.join(dirs.compiledDir, name.replace(/\.ts$/, ".js"));
32
+ const source = path.join(dirs.sourceDir, name);
33
+ const target = existsSync(compiled) ? compiled : source;
34
+ const module = await import(pathToFileURL(target).href);
35
+ if (typeof module.up !== "function") throw new Error(`migration ${name} has no up() export (${target})`);
36
+ return {
37
+ up: module.up,
38
+ down: module.down ?? (async () => void 0)
39
+ };
40
+ }
41
+ };
42
+ }
43
+ async function runRequiredMigrations(dirs = resolveStartupMigrationDirs()) {
6
44
  const log = getLogger(["qgrid", "startup"]);
7
45
  try {
8
- const knex = TokenModel.getDB("w");
9
- const migrationsDir = path.join(import.meta.dirname, "../src/migrations");
10
- const [batch, migrations] = await knex.migrate.latest({ directory: migrationsDir });
46
+ const [batch, migrations] = await TokenModel.getDB("w").migrate.latest({ migrationSource: createStartupMigrationSource(dirs) });
11
47
  if (migrations.length > 0) log.info(`migration: ${migrations.length} applied (batch ${batch})`);
12
48
  } catch (error) {
13
49
  log.error(`migration failed: ${error.message}`);
@@ -15,6 +51,6 @@ async function runRequiredMigrations() {
15
51
  }
16
52
  }
17
53
  //#endregion
18
- export { runRequiredMigrations };
54
+ export { createStartupMigrationSource, resolveStartupMigrationDirs, runRequiredMigrations };
19
55
 
20
- //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic3RhcnR1cC1taWdyYXRpb25zLmpzIiwibmFtZXMiOltdLCJzb3VyY2VzIjpbIi4uL3NyYy9zdGFydHVwLW1pZ3JhdGlvbnMudHMiXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHBhdGggZnJvbSBcIm5vZGU6cGF0aFwiO1xuXG5pbXBvcnQgeyBnZXRMb2dnZXIgfSBmcm9tIFwiQGxvZ3RhcGUvbG9ndGFwZVwiO1xuXG5pbXBvcnQgeyBUb2tlbk1vZGVsIH0gZnJvbSBcIi4vYXBwbGljYXRpb24vdG9rZW4vdG9rZW4ubW9kZWxcIjtcblxuZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIHJ1blJlcXVpcmVkTWlncmF0aW9ucygpOiBQcm9taXNlPHZvaWQ+IHtcbiAgY29uc3QgbG9nID0gZ2V0TG9nZ2VyKFtcInFncmlkXCIsIFwic3RhcnR1cFwiXSk7XG4gIHRyeSB7XG4gICAgY29uc3Qga25leCA9IFRva2VuTW9kZWwuZ2V0REIoXCJ3XCIpO1xuICAgIGNvbnN0IG1pZ3JhdGlvbnNEaXIgPSBwYXRoLmpvaW4oaW1wb3J0Lm1ldGEuZGlybmFtZSwgXCIuLi9zcmMvbWlncmF0aW9uc1wiKTtcbiAgICBjb25zdCBbYmF0Y2gsIG1pZ3JhdGlvbnNdID0gYXdhaXQga25leC5taWdyYXRlLmxhdGVzdCh7IGRpcmVjdG9yeTogbWlncmF0aW9uc0RpciB9KTtcbiAgICBpZiAobWlncmF0aW9ucy5sZW5ndGggPiAwKSB7XG4gICAgICBsb2cuaW5mbyhgbWlncmF0aW9uOiAke21pZ3JhdGlvbnMubGVuZ3RofSBhcHBsaWVkIChiYXRjaCAke2JhdGNofSlgKTtcbiAgICB9XG4gIH0gY2F0Y2ggKGVycm9yKSB7XG4gICAgbG9nLmVycm9yKGBtaWdyYXRpb24gZmFpbGVkOiAkeyhlcnJvciBhcyBFcnJvcikubWVzc2FnZX1gKTtcbiAgICBwcm9jZXNzLmV4aXQoMSk7XG4gIH1cbn1cbiJdLCJtYXBwaW5ncyI6Ijs7OztBQU1BLGVBQXNCLHdCQUF1QztDQUMzRCxNQUFNLE1BQU0sVUFBVSxDQUFDLFNBQVMsU0FBUyxDQUFDO0NBQzFDLElBQUk7RUFDRixNQUFNLE9BQU8sV0FBVyxNQUFNLEdBQUc7RUFDakMsTUFBTSxnQkFBZ0IsS0FBSyxLQUFLLE9BQU8sS0FBSyxTQUFTLG1CQUFtQjtFQUN4RSxNQUFNLENBQUMsT0FBTyxjQUFjLE1BQU0sS0FBSyxRQUFRLE9BQU8sRUFBRSxXQUFXLGNBQWMsQ0FBQztFQUNsRixJQUFJLFdBQVcsU0FBUyxHQUN0QixJQUFJLEtBQUssY0FBYyxXQUFXLE9BQU8sa0JBQWtCLE1BQU0sRUFBRTtDQUV2RSxTQUFTLE9BQU87RUFDZCxJQUFJLE1BQU0scUJBQXNCLE1BQWdCLFNBQVM7RUFDekQsUUFBUSxLQUFLLENBQUM7Q0FDaEI7QUFDRiJ9
56
+ //# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoic3RhcnR1cC1taWdyYXRpb25zLmpzIiwibmFtZXMiOltdLCJzb3VyY2VzIjpbIi4uL3NyYy9zdGFydHVwLW1pZ3JhdGlvbnMudHMiXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgZXhpc3RzU3luYyB9IGZyb20gXCJub2RlOmZzXCI7XG5pbXBvcnQgeyByZWFkZGlyIH0gZnJvbSBcIm5vZGU6ZnMvcHJvbWlzZXNcIjtcbmltcG9ydCBwYXRoIGZyb20gXCJub2RlOnBhdGhcIjtcbmltcG9ydCB7IHBhdGhUb0ZpbGVVUkwgfSBmcm9tIFwibm9kZTp1cmxcIjtcblxuaW1wb3J0IHsgZ2V0TG9nZ2VyIH0gZnJvbSBcIkBsb2d0YXBlL2xvZ3RhcGVcIjtcbmltcG9ydCB7IHR5cGUgS25leCB9IGZyb20gXCJrbmV4XCI7XG5cbmltcG9ydCB7IFRva2VuTW9kZWwgfSBmcm9tIFwiLi9hcHBsaWNhdGlvbi90b2tlbi90b2tlbi5tb2RlbFwiO1xuXG5leHBvcnQgdHlwZSBTdGFydHVwTWlncmF0aW9uRGlycyA9IHtcbiAgLyoqIFNvbmFtdSDqsIAgbWlncmF0aW9uIOydhCDsg53shLHCt+q4sOuhne2VmOuKlCDsm5Drs7gg65SU66CJ7YSw66asLiBga25leF9taWdyYXRpb25zLm5hbWVgIOydgCDsnbQg7YyM7J2866qFKGAudHNgKeydtOuLpC4gKi9cbiAgc291cmNlRGlyOiBzdHJpbmc7XG4gIC8qKiDruYzrk5wg7IKw7Lac66y8IOuUlOugie2EsOumrC4gZGlzdCDsl5DshJwg7Iuk7ZaJIOykkeydtOuptCDqsJnsnYAg7J2066aE7J2YIGAuanNgIOqwgCDsl6zquLAg7J6I64ukLiAqL1xuICBjb21waWxlZERpcjogc3RyaW5nO1xufTtcblxuLy8gaW1wb3J0Lm1ldGEuZGlybmFtZSDsnYAg7IaM7IqkIOyLpO2WiSh0cyBsb2FkZXIp7JeQ7ISc64qUIGBzcmMvYCwg7Yyo7YKk7KeA65CcIENMSSDsl5DshJzripQgYGJ1bmRsZS9kaXN0L2Ag64ukLlxuLy8g65GQIOqyveyasCDrqqjrkZAgYC4uL3NyYy9taWdyYXRpb25zYCDqsIAg7J2066aE7J2YIOygleuzuOydtOqzoCBgLi9taWdyYXRpb25zYCDqsIAg7Lu07YyM7J28IOyCsOy2nOusvCDsnITsuZjri6QuXG4vLyDshozsiqQg7Iuk7ZaJ7JeQ7ISc64qUIOuRmOydtCDqsJnsnYAg65SU66CJ7YSw66as6528IOy7tO2MjOydvCDtjIzsnbzsnbQg7JeG6rOgLCBgLnRzYCDrpbwgbG9hZGVyIOqwgCDsp4HsoJEg7J2964qU64ukLlxuZXhwb3J0IGZ1bmN0aW9uIHJlc29sdmVTdGFydHVwTWlncmF0aW9uRGlycyhcbiAgbW9kdWxlRGlyOiBzdHJpbmcgPSBpbXBvcnQubWV0YS5kaXJuYW1lLFxuKTogU3RhcnR1cE1pZ3JhdGlvbkRpcnMge1xuICByZXR1cm4ge1xuICAgIHNvdXJjZURpcjogcGF0aC5qb2luKG1vZHVsZURpciwgXCIuLi9zcmMvbWlncmF0aW9uc1wiKSxcbiAgICBjb21waWxlZERpcjogcGF0aC5qb2luKG1vZHVsZURpciwgXCJtaWdyYXRpb25zXCIpLFxuICB9O1xufVxuXG4vLyBTb25hbXUg6rCAIOunjOuTnOuKlCDtjIzsnbzrqoUg6rec7LmZOiBgWVlZWU1NRERISG1tc3NfPHRpdGxlPi50c2AuIO2DgOyehSDshKDslrjsnbTrgpggUkVBRE1FIOuKlCDsoJzsmbjtlZzri6QuXG5jb25zdCBNSUdSQVRJT05fRklMRV9SRSA9IC9eXFxkezE0fV8uK1xcLnRzJC87XG5cbnR5cGUgTWlncmF0aW9uTW9kdWxlID0ge1xuICB1cD86IChrbmV4OiBLbmV4KSA9PiBQcm9taXNlPHVua25vd24+O1xuICBkb3duPzogKGtuZXg6IEtuZXgpID0+IFByb21pc2U8dW5rbm93bj47XG59O1xuXG4vKipcbiAqIO2MqO2CpOyngOuQnCBDTEkg64qUIGBidW5kbGUvZGlzdGAg7JeQ7IScIOyLpO2WieuQmOuKlOuNsCwga25leCDquLDrs7ggRnNNaWdyYXRpb25zIOyXkCBgLi4vc3JjL21pZ3JhdGlvbnNgIOulvFxuICog64SY6riw66m0IHBlbmRpbmcgbWlncmF0aW9uIOydhCBgLnRzYCDroZwgaW1wb3J0IO2VmOuLpCBcIlVua25vd24gZmlsZSBleHRlbnNpb24gLnRzXCIg66GcIOyjveuKlOuLpCgyLjkuMCDsgqzqs6ApLlxuICog67CY64yA66GcIOuUlOugie2EsOumrOulvCBgZGlzdC9taWdyYXRpb25zYCDroZwg67CU6r6466m0IGBrbmV4X21pZ3JhdGlvbnNgIOyXkCBgLnRzYCDsnbTrpoTsnLzroZwg6riw66Gd65CcIOqzvOqxsFxuICogbWlncmF0aW9uIOydtCDsoITrtoAgcGVuZGluZyDsnLzroZwg67O07J2464ukLiDqt7jrnpjshJwg7J2066aE7J2AIGBzcmMvbWlncmF0aW9uc2Ag7J2YIGAudHNgIO2MjOydvOuqheydhCDqt7jrjIDroZxcbiAqIOyTsOqzoCwg66qo65OI66eMIOy7tO2MjOydvOuQnCBgLmpzYCDqsIAg7J6I7Jy866m0IOq3uOqyg+ydhCDroZzrk5ztlZzri6QuXG4gKi9cbmV4cG9ydCBmdW5jdGlvbiBjcmVhdGVTdGFydHVwTWlncmF0aW9uU291cmNlKFxuICBkaXJzOiBTdGFydHVwTWlncmF0aW9uRGlycyxcbik6IEtuZXguTWlncmF0aW9uU291cmNlPHN0cmluZz4ge1xuICByZXR1cm4ge1xuICAgIGFzeW5jIGdldE1pZ3JhdGlvbnMoKSB7XG4gICAgICBjb25zdCBlbnRyaWVzID0gYXdhaXQgcmVhZGRpcihkaXJzLnNvdXJjZURpcik7XG4gICAgICByZXR1cm4gZW50cmllc1xuICAgICAgICAuZmlsdGVyKChuYW1lKSA9PiBNSUdSQVRJT05fRklMRV9SRS50ZXN0KG5hbWUpICYmICFuYW1lLmVuZHNXaXRoKFwiLmQudHNcIikpXG4gICAgICAgIC5zb3J0KCk7XG4gICAgfSxcbiAgICBnZXRNaWdyYXRpb25OYW1lKG5hbWUpIHtcbiAgICAgIHJldHVybiBuYW1lO1xuICAgIH0sXG4gICAgYXN5bmMgZ2V0TWlncmF0aW9uKG5hbWUpIHtcbiAgICAgIGNvbnN0IGNvbXBpbGVkID0gcGF0aC5qb2luKGRpcnMuY29tcGlsZWREaXIsIG5hbWUucmVwbGFjZSgvXFwudHMkLywgXCIuanNcIikpO1xuICAgICAgY29uc3Qgc291cmNlID0gcGF0aC5qb2luKGRpcnMuc291cmNlRGlyLCBuYW1lKTtcbiAgICAgIGNvbnN0IHRhcmdldCA9IGV4aXN0c1N5bmMoY29tcGlsZWQpID8gY29tcGlsZWQgOiBzb3VyY2U7XG4gICAgICBjb25zdCBtb2R1bGUgPSAoYXdhaXQgaW1wb3J0KHBhdGhUb0ZpbGVVUkwodGFyZ2V0KS5ocmVmKSkgYXMgTWlncmF0aW9uTW9kdWxlO1xuICAgICAgaWYgKHR5cGVvZiBtb2R1bGUudXAgIT09IFwiZnVuY3Rpb25cIikge1xuICAgICAgICB0aHJvdyBuZXcgRXJyb3IoYG1pZ3JhdGlvbiAke25hbWV9IGhhcyBubyB1cCgpIGV4cG9ydCAoJHt0YXJnZXR9KWApO1xuICAgICAgfVxuICAgICAgcmV0dXJuIHsgdXA6IG1vZHVsZS51cCwgZG93bjogbW9kdWxlLmRvd24gPz8gKGFzeW5jICgpID0+IHVuZGVmaW5lZCkgfTtcbiAgICB9LFxuICB9O1xufVxuXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gcnVuUmVxdWlyZWRNaWdyYXRpb25zKFxuICBkaXJzOiBTdGFydHVwTWlncmF0aW9uRGlycyA9IHJlc29sdmVTdGFydHVwTWlncmF0aW9uRGlycygpLFxuKTogUHJvbWlzZTx2b2lkPiB7XG4gIGNvbnN0IGxvZyA9IGdldExvZ2dlcihbXCJxZ3JpZFwiLCBcInN0YXJ0dXBcIl0pO1xuICB0cnkge1xuICAgIGNvbnN0IGtuZXggPSBUb2tlbk1vZGVsLmdldERCKFwid1wiKTtcbiAgICBjb25zdCBbYmF0Y2gsIG1pZ3JhdGlvbnNdID0gYXdhaXQga25leC5taWdyYXRlLmxhdGVzdCh7XG4gICAgICBtaWdyYXRpb25Tb3VyY2U6IGNyZWF0ZVN0YXJ0dXBNaWdyYXRpb25Tb3VyY2UoZGlycyksXG4gICAgfSk7XG4gICAgaWYgKG1pZ3JhdGlvbnMubGVuZ3RoID4gMCkge1xuICAgICAgbG9nLmluZm8oYG1pZ3JhdGlvbjogJHttaWdyYXRpb25zLmxlbmd0aH0gYXBwbGllZCAoYmF0Y2ggJHtiYXRjaH0pYCk7XG4gICAgfVxuICB9IGNhdGNoIChlcnJvcikge1xuICAgIGxvZy5lcnJvcihgbWlncmF0aW9uIGZhaWxlZDogJHsoZXJyb3IgYXMgRXJyb3IpLm1lc3NhZ2V9YCk7XG4gICAgcHJvY2Vzcy5leGl0KDEpO1xuICB9XG59XG4iXSwibWFwcGluZ3MiOiI7Ozs7Ozs7QUFvQkEsU0FBZ0IsNEJBQ2QsWUFBb0IsT0FBTyxLQUFLLFNBQ1Y7Q0FDdEIsT0FBTztFQUNMLFdBQVcsS0FBSyxLQUFLLFdBQVcsbUJBQW1CO0VBQ25ELGFBQWEsS0FBSyxLQUFLLFdBQVcsWUFBWTtDQUNoRDtBQUNGO0FBR0EsTUFBTSxvQkFBb0I7Ozs7Ozs7O0FBYzFCLFNBQWdCLDZCQUNkLE1BQzhCO0NBQzlCLE9BQU87RUFDTCxNQUFNLGdCQUFnQjtHQUVwQixRQUFPLE1BRGUsUUFBUSxLQUFLLFNBQVMsRUFDckMsQ0FDSixRQUFRLFNBQVMsa0JBQWtCLEtBQUssSUFBSSxLQUFLLENBQUMsS0FBSyxTQUFTLE9BQU8sQ0FBQyxDQUFDLENBQ3pFLEtBQUs7RUFDVjtFQUNBLGlCQUFpQixNQUFNO0dBQ3JCLE9BQU87RUFDVDtFQUNBLE1BQU0sYUFBYSxNQUFNO0dBQ3ZCLE1BQU0sV0FBVyxLQUFLLEtBQUssS0FBSyxhQUFhLEtBQUssUUFBUSxTQUFTLEtBQUssQ0FBQztHQUN6RSxNQUFNLFNBQVMsS0FBSyxLQUFLLEtBQUssV0FBVyxJQUFJO0dBQzdDLE1BQU0sU0FBUyxXQUFXLFFBQVEsSUFBSSxXQUFXO0dBQ2pELE1BQU0sU0FBVSxNQUFNLE9BQU8sY0FBYyxNQUFNLENBQUMsQ0FBQztHQUNuRCxJQUFJLE9BQU8sT0FBTyxPQUFPLFlBQ3ZCLE1BQU0sSUFBSSxNQUFNLGFBQWEsS0FBSyx1QkFBdUIsT0FBTyxFQUFFO0dBRXBFLE9BQU87SUFBRSxJQUFJLE9BQU87SUFBSSxNQUFNLE9BQU8sU0FBUyxZQUFZO0dBQVc7RUFDdkU7Q0FDRjtBQUNGO0FBRUEsZUFBc0Isc0JBQ3BCLE9BQTZCLDRCQUE0QixHQUMxQztDQUNmLE1BQU0sTUFBTSxVQUFVLENBQUMsU0FBUyxTQUFTLENBQUM7Q0FDMUMsSUFBSTtFQUVGLE1BQU0sQ0FBQyxPQUFPLGNBQWMsTUFEZixXQUFXLE1BQU0sR0FDSSxDQUFBLENBQUssUUFBUSxPQUFPLEVBQ3BELGlCQUFpQiw2QkFBNkIsSUFBSSxFQUNwRCxDQUFDO0VBQ0QsSUFBSSxXQUFXLFNBQVMsR0FDdEIsSUFBSSxLQUFLLGNBQWMsV0FBVyxPQUFPLGtCQUFrQixNQUFNLEVBQUU7Q0FFdkUsU0FBUyxPQUFPO0VBQ2QsSUFBSSxNQUFNLHFCQUFzQixNQUFnQixTQUFTO0VBQ3pELFFBQVEsS0FBSyxDQUFDO0NBQ2hCO0FBQ0YifQ==
@@ -3,8 +3,8 @@ import { ANTHROPIC_CLAUDE_CWD, ANTHROPIC_DISALLOWED_TOOLS, anthropicConfigDir, a
3
3
  import { createTtftTracker } from "../common/ttft.js";
4
4
  import { buildStreamJsonInput, handleStreamJsonLine } from "./stream-json-adapter.js";
5
5
  import { randomUUID } from "node:crypto";
6
- import { spawn } from "node:child_process";
7
6
  import { mkdirSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { spawn } from "node:child_process";
8
8
  //#region src/utils/providers/anthropic/claude-session.ts
9
9
  /**
10
10
  * claude-session — Claude CLI 세션 spawn + QgridThreadCoord 매핑 + per-session 락.
@@ -1,4 +1,8 @@
1
- import { describe, expect, it, vi } from "vitest";
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
6
 
3
7
  const { latest } = vi.hoisted(() => ({ latest: vi.fn() }));
4
8
 
@@ -8,17 +12,126 @@ vi.mock("./application/token/token.model", () => ({
8
12
  },
9
13
  }));
10
14
 
11
- import { runRequiredMigrations } from "./startup-migrations";
15
+ import {
16
+ createStartupMigrationSource,
17
+ resolveStartupMigrationDirs,
18
+ runRequiredMigrations,
19
+ } from "./startup-migrations";
20
+
21
+ const tempRoots: string[] = [];
22
+
23
+ async function makeDirs() {
24
+ const root = await mkdtemp(path.join(os.tmpdir(), "qgrid-startup-migrations-"));
25
+ tempRoots.push(root);
26
+ const sourceDir = path.join(root, "src", "migrations");
27
+ const compiledDir = path.join(root, "dist", "migrations");
28
+ await mkdir(sourceDir, { recursive: true });
29
+ await mkdir(compiledDir, { recursive: true });
30
+ return { sourceDir, compiledDir };
31
+ }
32
+
33
+ afterEach(async () => {
34
+ vi.restoreAllMocks();
35
+ await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
36
+ });
37
+
38
+ describe("resolveStartupMigrationDirs", () => {
39
+ it("패키지된 CLI(dist 실행)에서는 이름은 bundle/src, 모듈은 bundle/dist 를 가리킨다", () => {
40
+ expect(resolveStartupMigrationDirs("/opt/qgrid-cli/bundle/dist")).toEqual({
41
+ sourceDir: "/opt/qgrid-cli/bundle/src/migrations",
42
+ compiledDir: "/opt/qgrid-cli/bundle/dist/migrations",
43
+ });
44
+ });
45
+
46
+ it("소스 실행(src)에서는 두 경로가 같은 src/migrations 다", () => {
47
+ expect(resolveStartupMigrationDirs("/repo/packages/api/src")).toEqual({
48
+ sourceDir: "/repo/packages/api/src/migrations",
49
+ compiledDir: "/repo/packages/api/src/migrations",
50
+ });
51
+ });
52
+ });
53
+
54
+ describe("createStartupMigrationSource", () => {
55
+ it("knex_migrations 에 기록된 것과 같은 .ts 파일명을 정렬해 나열하고 그 외 파일은 무시한다", async () => {
56
+ const dirs = await makeDirs();
57
+ await writeFile(path.join(dirs.sourceDir, "20260901163614_alter_tokens_add1_alter1.ts"), "");
58
+ await writeFile(path.join(dirs.sourceDir, "20260820153531_alter_tokens_add1_alter1.ts"), "");
59
+ await writeFile(path.join(dirs.sourceDir, "20260820153531_alter_tokens_add1_alter1.d.ts"), "");
60
+ await writeFile(path.join(dirs.sourceDir, "README.md"), "");
61
+
62
+ const source = createStartupMigrationSource(dirs);
63
+ const names = await source.getMigrations([]);
64
+
65
+ expect(names).toEqual([
66
+ "20260820153531_alter_tokens_add1_alter1.ts",
67
+ "20260901163614_alter_tokens_add1_alter1.ts",
68
+ ]);
69
+ expect(source.getMigrationName("20260820153531_alter_tokens_add1_alter1.ts")).toBe(
70
+ "20260820153531_alter_tokens_add1_alter1.ts",
71
+ );
72
+ });
73
+
74
+ it("컴파일된 .js 가 있으면 .ts 대신 그것을 로드한다 (2.9.0 부팅 크래시 재발 방지)", async () => {
75
+ const dirs = await makeDirs();
76
+ const name = "20260901163614_alter_tokens_add1_alter1.ts";
77
+ await writeFile(path.join(dirs.sourceDir, name), 'throw new Error("source .ts must not load");\n');
78
+ await writeFile(
79
+ path.join(dirs.compiledDir, name.replace(/\.ts$/, ".js")),
80
+ 'export async function up() { return "compiled"; }\nexport async function down() { return "down"; }\n',
81
+ );
82
+
83
+ const migration = await createStartupMigrationSource(dirs).getMigration(name);
84
+
85
+ await expect(migration.up({} as never)).resolves.toBe("compiled");
86
+ await expect(migration.down?.({} as never)).resolves.toBe("down");
87
+ });
88
+
89
+ it("컴파일 산출물이 없으면 소스 모듈로 fallback 한다 (ts loader 가 있는 소스 실행)", async () => {
90
+ const dirs = await makeDirs();
91
+ const name = "20260901163614_alter_tokens_add1_alter1.ts";
92
+ await writeFile(
93
+ path.join(dirs.sourceDir, name),
94
+ 'export async function up() { return "source"; }\n',
95
+ );
96
+
97
+ const migration = await createStartupMigrationSource(dirs).getMigration(name);
98
+
99
+ await expect(migration.up({} as never)).resolves.toBe("source");
100
+ await expect(migration.down?.({} as never)).resolves.toBeUndefined();
101
+ });
102
+
103
+ it("up() 이 없는 모듈은 명확한 오류로 실패한다", async () => {
104
+ const dirs = await makeDirs();
105
+ const name = "20260901163614_alter_tokens_add1_alter1.ts";
106
+ await writeFile(path.join(dirs.compiledDir, name.replace(/\.ts$/, ".js")), "export const x = 1;\n");
107
+
108
+ await expect(createStartupMigrationSource(dirs).getMigration(name)).rejects.toThrow(
109
+ /has no up\(\) export/,
110
+ );
111
+ });
112
+ });
12
113
 
13
114
  describe("required startup migrations", () => {
115
+ it("knex 기본 directory 대신 migrationSource 로 latest 를 실행한다", async () => {
116
+ const dirs = await makeDirs();
117
+ latest.mockResolvedValueOnce([3, ["20260901163614_alter_tokens_add1_alter1.ts"]]);
118
+
119
+ await runRequiredMigrations(dirs);
120
+
121
+ expect(latest).toHaveBeenCalledWith({ migrationSource: expect.any(Object) });
122
+ const call = latest.mock.calls.at(-1)?.[0] as { migrationSource: unknown; directory?: string };
123
+ expect(call.directory).toBeUndefined();
124
+ });
125
+
14
126
  it("terminates with a failure status when migration fails", async () => {
127
+ const dirs = await makeDirs();
15
128
  const failure = new Error("tokens.weight migration failed");
16
129
  const exit = vi.spyOn(process, "exit").mockImplementation(() => {
17
130
  throw failure;
18
131
  });
19
132
  latest.mockRejectedValueOnce(failure);
20
133
 
21
- await expect(runRequiredMigrations()).rejects.toBe(failure);
134
+ await expect(runRequiredMigrations(dirs)).rejects.toBe(failure);
22
135
 
23
136
  expect(exit).toHaveBeenCalledWith(1);
24
137
  });
@@ -1,15 +1,82 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readdir } from "node:fs/promises";
1
3
  import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
2
5
 
3
6
  import { getLogger } from "@logtape/logtape";
7
+ import { type Knex } from "knex";
4
8
 
5
9
  import { TokenModel } from "./application/token/token.model";
6
10
 
7
- export async function runRequiredMigrations(): Promise<void> {
11
+ export type StartupMigrationDirs = {
12
+ /** Sonamu 가 migration 을 생성·기록하는 원본 디렉터리. `knex_migrations.name` 은 이 파일명(`.ts`)이다. */
13
+ sourceDir: string;
14
+ /** 빌드 산출물 디렉터리. dist 에서 실행 중이면 같은 이름의 `.js` 가 여기 있다. */
15
+ compiledDir: string;
16
+ };
17
+
18
+ // import.meta.dirname 은 소스 실행(ts loader)에서는 `src/`, 패키지된 CLI 에서는 `bundle/dist/` 다.
19
+ // 두 경우 모두 `../src/migrations` 가 이름의 정본이고 `./migrations` 가 컴파일 산출물 위치다.
20
+ // 소스 실행에서는 둘이 같은 디렉터리라 컴파일 파일이 없고, `.ts` 를 loader 가 직접 읽는다.
21
+ export function resolveStartupMigrationDirs(
22
+ moduleDir: string = import.meta.dirname,
23
+ ): StartupMigrationDirs {
24
+ return {
25
+ sourceDir: path.join(moduleDir, "../src/migrations"),
26
+ compiledDir: path.join(moduleDir, "migrations"),
27
+ };
28
+ }
29
+
30
+ // Sonamu 가 만드는 파일명 규칙: `YYYYMMDDHHmmss_<title>.ts`. 타입 선언이나 README 는 제외한다.
31
+ const MIGRATION_FILE_RE = /^\d{14}_.+\.ts$/;
32
+
33
+ type MigrationModule = {
34
+ up?: (knex: Knex) => Promise<unknown>;
35
+ down?: (knex: Knex) => Promise<unknown>;
36
+ };
37
+
38
+ /**
39
+ * 패키지된 CLI 는 `bundle/dist` 에서 실행되는데, knex 기본 FsMigrations 에 `../src/migrations` 를
40
+ * 넘기면 pending migration 을 `.ts` 로 import 하다 "Unknown file extension .ts" 로 죽는다(2.9.0 사고).
41
+ * 반대로 디렉터리를 `dist/migrations` 로 바꾸면 `knex_migrations` 에 `.ts` 이름으로 기록된 과거
42
+ * migration 이 전부 pending 으로 보인다. 그래서 이름은 `src/migrations` 의 `.ts` 파일명을 그대로
43
+ * 쓰고, 모듈만 컴파일된 `.js` 가 있으면 그것을 로드한다.
44
+ */
45
+ export function createStartupMigrationSource(
46
+ dirs: StartupMigrationDirs,
47
+ ): Knex.MigrationSource<string> {
48
+ return {
49
+ async getMigrations() {
50
+ const entries = await readdir(dirs.sourceDir);
51
+ return entries
52
+ .filter((name) => MIGRATION_FILE_RE.test(name) && !name.endsWith(".d.ts"))
53
+ .sort();
54
+ },
55
+ getMigrationName(name) {
56
+ return name;
57
+ },
58
+ async getMigration(name) {
59
+ const compiled = path.join(dirs.compiledDir, name.replace(/\.ts$/, ".js"));
60
+ const source = path.join(dirs.sourceDir, name);
61
+ const target = existsSync(compiled) ? compiled : source;
62
+ const module = (await import(pathToFileURL(target).href)) as MigrationModule;
63
+ if (typeof module.up !== "function") {
64
+ throw new Error(`migration ${name} has no up() export (${target})`);
65
+ }
66
+ return { up: module.up, down: module.down ?? (async () => undefined) };
67
+ },
68
+ };
69
+ }
70
+
71
+ export async function runRequiredMigrations(
72
+ dirs: StartupMigrationDirs = resolveStartupMigrationDirs(),
73
+ ): Promise<void> {
8
74
  const log = getLogger(["qgrid", "startup"]);
9
75
  try {
10
76
  const knex = TokenModel.getDB("w");
11
- const migrationsDir = path.join(import.meta.dirname, "../src/migrations");
12
- const [batch, migrations] = await knex.migrate.latest({ directory: migrationsDir });
77
+ const [batch, migrations] = await knex.migrate.latest({
78
+ migrationSource: createStartupMigrationSource(dirs),
79
+ });
13
80
  if (migrations.length > 0) {
14
81
  log.info(`migration: ${migrations.length} applied (batch ${batch})`);
15
82
  }