@things-factory/shell 10.0.10 → 10.0.18

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.
@@ -17,6 +17,13 @@ body {
17
17
  --oops-note-icon-font: normal 18px var(--md-icon-font, 'Material Symbols Outlined');
18
18
  --oops-note-title-font: bold 13px var(--theme-font);
19
19
  --oops-note-title-margin: var(--margin-narrow) 0 2px 0;
20
- --oops-note-description-font: normal 0px var(--theme-font);
20
+ /*
21
+ * 좁은 화면에서도 설명은 남긴다.
22
+ *
23
+ * 전에는 여기가 `normal 0px` 이었다. 글자 크기를 0 으로 만들어 설명을 지운 것이라, 460px 아래에서는
24
+ * 제목만 남고 무엇을 하면 되는지가 사라졌다. 홈 화면이 그 상태였다 — 「HOME DASHBOARD」한 줄만
25
+ * 보이고 다음에 할 일이 안 보였다. 자리가 좁으면 글자를 줄이고, 없애지 않는다.
26
+ */
27
+ --oops-note-description-font: normal 11px var(--theme-font);
21
28
  }
22
29
  }
@@ -1,6 +1,55 @@
1
+ /*
2
+ * 상태 의미색 — 정상·주의·정보·위험. 모든 앱이 여기서 가져다 쓴다.
3
+ *
4
+ * ── 왜 라이트·다크로 갈리나 (2026-08-30) ────────────────────────────────────
5
+ * 전에는 `:root` 한 벌뿐이었다. 그래서 다크 테마에서 밝은 초록·주황이 어두운 바탕에 그대로 얹혔고,
6
+ * 앱들이 각자 자기 값을 따로 갖게 됐다(operato-twin 이 `--twin-good`·`--twin-warn` 을 직접 들고 있었다).
7
+ * 두 화면을 나란히 놓으면 「정상」이 서로 다른 초록이었다.
8
+ *
9
+ * ── 값을 어떻게 잡았나 ──────────────────────────────────────────────────────
10
+ * 이 색들은 **글자색으로 쓰인다.** 그래서 WCAG AA(4.5:1)를 기준으로, 가장 불리한 바탕에 대고 잡았다.
11
+ *
12
+ * ```
13
+ * 라이트 기준 바탕 rgb(238 241 243) 앱들 중 가장 어두운 라이트 surface
14
+ * 다크 기준 바탕 rgb(51 52 58) 가장 밝은 다크 surface-container-highest
15
+ * ```
16
+ *
17
+ * 색상(hue)과 채도는 그대로 두고 명도만 옮겼다 — 초록은 초록으로, 주황은 주황으로 남는다.
18
+ *
19
+ * ```
20
+ * 이전(라이트) 지금(라이트) 지금(다크)
21
+ * success #35a24a 2.88 #297d39 4.53 #35c46e 5.48
22
+ * warning #ee8d03 2.19 #9f5e02 4.54 #e0a24a 5.57
23
+ * info #398ace 3.25 #2b72ad 4.50 #61a2d8 4.53
24
+ * ```
25
+ *
26
+ * 주의(warning)는 AA 를 맞추면 갈색에 가까워진다. 주황을 살리려면 3:1(#c97703)에서 멈춰야 하는데,
27
+ * 그러면 글자색으로 쓸 수 없다. **읽히는 쪽을 골랐다** — 상태는 색만으로 전하지 않고 글자를 함께
28
+ * 보이므로, 그 글자가 읽혀야 한다.
29
+ *
30
+ * ── danger 는 여기서 값을 정하지 않는다 ─────────────────────────────────────
31
+ * `--md-sys-color-error` 를 그대로 쓴다. 위험은 md 역할색에 대응이 있어서 팔레트가 정하는 것이 맞다.
32
+ * 다만 그 값의 대비는 앱 팔레트마다 다르다(셸 라이트 #fe594e 는 2.74 로 낮다) — 팔레트 쪽에서 볼 일이다.
33
+ */
34
+
35
+ /* 테마 클래스가 붙기 전 · 붙지 않는 앱을 위한 기본값. 라이트와 같다. */
1
36
  :root {
2
37
  --status-danger-color: var(--md-sys-color-error, #d14946);
3
- --status-warning-color: #ee8d03;
4
- --status-info-color: #398ace;
5
- --status-success-color: #35a24a;
38
+ --status-warning-color: #9f5e02;
39
+ --status-info-color: #2b72ad;
40
+ --status-success-color: #297d39;
41
+ }
42
+
43
+ .light {
44
+ --status-danger-color: var(--md-sys-color-error, #d14946);
45
+ --status-warning-color: #9f5e02;
46
+ --status-info-color: #2b72ad;
47
+ --status-success-color: #297d39;
48
+ }
49
+
50
+ .dark {
51
+ --status-danger-color: var(--md-sys-color-error, #fbafa9);
52
+ --status-warning-color: #e0a24a;
53
+ --status-info-color: #61a2d8;
54
+ --status-success-color: #35c46e;
6
55
  }
@@ -28,3 +28,40 @@
28
28
  .dark {
29
29
  color-scheme: dark;
30
30
  }
31
+
32
+ /*
33
+ * 앱 글꼴을 md 타이포스케일에 잇는다.
34
+ *
35
+ * [무엇이 어긋나 있었나]
36
+ * 셸은 `--theme-font` 를 정의하고(`app-theme.css`) 자기 파생 토큰 15개에만 썼다. md 타이포스케일이
37
+ * 참조하는 `--md-ref-typeface-*` 에는 아무도 값을 넣지 않았다. 그래서 md 기반 공통 위젯 — 확인
38
+ * 대화상자(`ox-prompt`)·버튼 라벨·토스트 — 이 앱 글꼴을 못 받고 각자의 폴백(Roboto)으로 갈렸다.
39
+ * 한 화면 안에서 본문과 대화상자의 글꼴이 달랐다.
40
+ *
41
+ * 부품이 스타일을 빠뜨린 것이 아니다. 부품은 토큰으로 받도록 이미 짜여 있고, 그 토큰을 정한 곳이
42
+ * 없었을 뿐이다. 그러므로 고칠 자리는 부품이 아니라 토큰이다.
43
+ *
44
+ * [왜 여기인가]
45
+ * `app-theme.css` 에 넣으면 그 파일을 override 하는 앱이 이 규칙을 통째로 잃는다(파일 단위 대체).
46
+ * `theme-base.css` 는 어떤 앱도 같은 이름으로 두지 않으므로 항상 번들에 들어간다.
47
+ *
48
+ * [plain 과 brand 둘 다]
49
+ * md 타이포스케일은 본문·라벨(plain)과 제목(brand)을 다른 토큰으로 읽는다. 하나만 걸면 같은
50
+ * 대화상자에서 제목과 본문의 글꼴이 갈린다(operato-twin 에서 실제로 그랬다).
51
+ *
52
+ * [body 까지 짚는 이유]
53
+ * 셸은 `--theme-font` 를 body 에 정한다. 상속은 가까운 조상이 이기므로 html 에만 두면 body 아래에
54
+ * 붙는 토스트는 여전히 셸 값을 물려받는다. `body.light`·`body.dark` 로 한 번 더 짚어, 테마 파일의
55
+ * 병합 순서에 기대지 않고 앱 값이 이기게 한다.
56
+ *
57
+ * `:root` 를 빼고 body 만 짚는다: `--theme-font` 는 body 에 정의되므로, html 에서는 `var()` 가
58
+ * 풀리지 않아 값이 비어 버린다. 대화상자·토스트를 포함해 눈에 보이는 것은 모두 body 아래에 있다.
59
+ *
60
+ * 글꼴 값 자체는 여기서 정하지 않는다 — 앱이 `--theme-font` 하나만 정하면 된다.
61
+ */
62
+ body,
63
+ body.light,
64
+ body.dark {
65
+ --md-ref-typeface-plain: var(--theme-font);
66
+ --md-ref-typeface-brand: var(--theme-font);
67
+ }
@@ -27,6 +27,39 @@ export declare function getDataSourceNames(): string[];
27
27
  * @returns {Repository<X>} - The repository for the specified entity.
28
28
  */
29
29
  export declare function getRepository<X>(target: EntityTarget<X>, tx?: EntityManager): Repository<X>;
30
+ /**
31
+ * **무거운 읽기를 위한 저장소** — 다른 요청을 붙잡지 않게 별도 연결로 보낸다.
32
+ *
33
+ * ── 무엇을 재서 알았나 (2026-08-22) ────────────────────────────────────────
34
+ * TypeORM 의 sqlite 드라이버는 연결을 **하나**만 든다(풀 없음). 그래서 무거운 읽기 하나가 도는 동안
35
+ * 앱의 **모든** 질의가 그 뒤에 선다 — 수십 행짜리 `users`·`domains` 조회가 30초로 찍혔다.
36
+ *
37
+ * 같은 DB(16.3GB · 저널 1,134만 행)에서 실측했다. 무거운 읽기(2만 행)가 도는 동안 가벼운 읽기가
38
+ * 얼마나 기다리나:
39
+ *
40
+ * DataSource 하나 → 47,647ms
41
+ * DataSource 둘 → 2,786ms (평균 1,473ms)
42
+ *
43
+ * 즉 **sqlite 쪽 분리는 실재한다.** WAL 이면 연결마다 독립으로 읽으므로 잠금·직렬화가 풀린다.
44
+ *
45
+ * ── 남는 것을 감추지 않는다 ────────────────────────────────────────────────
46
+ * 평균 1,473ms 는 사라지지 않는다. 그것은 sqlite 가 아니라 **행을 객체로 만드는 JS 일**이다
47
+ * (같은 측정에서 한 행만 돌려주는 무거운 질의는 가벼운 읽기를 0.3ms 로 두었다). 이벤트 루프는 하나이고,
48
+ * 그 비용은 DB 를 바꿔도 남는다. 고칠 자리는 연결이 아니라 **그 질의**다 — 페이지 크기·필요한 컬럼만·
49
+ * 엔티티 대신 raw. 이 함수는 그 일을 대신해 주지 않는다.
50
+ *
51
+ * ── 언제 쓰나 ──────────────────────────────────────────────────────────────
52
+ * **읽기 전용이고, 느린 것이 측정된 경로**에만 쓴다. 두 가지를 주의한다.
53
+ *
54
+ * · **쓰지 않는다.** 이 연결로 쓰면 별도 쓰기 주체가 되어 잠금을 다툰다. 쓰기는 `getRepository` 다.
55
+ * · **쓴 뒤 곧바로 읽는 자리에는 쓰지 않는다.** 쓰기를 `await` 한 뒤라면 WAL 에서 최신 커밋이 보이므로
56
+ * 안전하지만, 같은 트랜잭션 안에서 읽어야 하는 값이면 `tx` 를 받는 `getRepository` 를 쓴다.
57
+ *
58
+ * 풀이 있는 드라이버(postgres·mysql)에서는 얻을 것이 없으므로 `'read'` 가 `'default'` 를 가리킨다 —
59
+ * 소비처는 드라이버를 알 필요가 없다. 배포가 그 연결을 원하지 않으면 `ormconfig4Read: false` 로 끈다
60
+ * (그때도 이 함수는 그대로 쓸 수 있다 — `'default'` 로 떨어진다).
61
+ */
62
+ export declare function getReadRepository<X>(target: EntityTarget<X>): Repository<X>;
30
63
  /**
31
64
  * Initializes the database connections and data sources.
32
65
  */
@@ -6,6 +6,7 @@ exports.addDataSource = addDataSource;
6
6
  exports.removeDataSource = removeDataSource;
7
7
  exports.getDataSourceNames = getDataSourceNames;
8
8
  exports.getRepository = getRepository;
9
+ exports.getReadRepository = getReadRepository;
9
10
  const tslib_1 = require("tslib");
10
11
  const typeorm_1 = require("typeorm");
11
12
  const env_1 = require("@things-factory/env");
@@ -49,6 +50,72 @@ function getDataSourceNames() {
49
50
  function getRepository(target, tx) {
50
51
  return tx ? tx.getRepository(target) : getDataSource('default').getRepository(target);
51
52
  }
53
+ /**
54
+ * **무거운 읽기를 위한 저장소** — 다른 요청을 붙잡지 않게 별도 연결로 보낸다.
55
+ *
56
+ * ── 무엇을 재서 알았나 (2026-08-22) ────────────────────────────────────────
57
+ * TypeORM 의 sqlite 드라이버는 연결을 **하나**만 든다(풀 없음). 그래서 무거운 읽기 하나가 도는 동안
58
+ * 앱의 **모든** 질의가 그 뒤에 선다 — 수십 행짜리 `users`·`domains` 조회가 30초로 찍혔다.
59
+ *
60
+ * 같은 DB(16.3GB · 저널 1,134만 행)에서 실측했다. 무거운 읽기(2만 행)가 도는 동안 가벼운 읽기가
61
+ * 얼마나 기다리나:
62
+ *
63
+ * DataSource 하나 → 47,647ms
64
+ * DataSource 둘 → 2,786ms (평균 1,473ms)
65
+ *
66
+ * 즉 **sqlite 쪽 분리는 실재한다.** WAL 이면 연결마다 독립으로 읽으므로 잠금·직렬화가 풀린다.
67
+ *
68
+ * ── 남는 것을 감추지 않는다 ────────────────────────────────────────────────
69
+ * 평균 1,473ms 는 사라지지 않는다. 그것은 sqlite 가 아니라 **행을 객체로 만드는 JS 일**이다
70
+ * (같은 측정에서 한 행만 돌려주는 무거운 질의는 가벼운 읽기를 0.3ms 로 두었다). 이벤트 루프는 하나이고,
71
+ * 그 비용은 DB 를 바꿔도 남는다. 고칠 자리는 연결이 아니라 **그 질의**다 — 페이지 크기·필요한 컬럼만·
72
+ * 엔티티 대신 raw. 이 함수는 그 일을 대신해 주지 않는다.
73
+ *
74
+ * ── 언제 쓰나 ──────────────────────────────────────────────────────────────
75
+ * **읽기 전용이고, 느린 것이 측정된 경로**에만 쓴다. 두 가지를 주의한다.
76
+ *
77
+ * · **쓰지 않는다.** 이 연결로 쓰면 별도 쓰기 주체가 되어 잠금을 다툰다. 쓰기는 `getRepository` 다.
78
+ * · **쓴 뒤 곧바로 읽는 자리에는 쓰지 않는다.** 쓰기를 `await` 한 뒤라면 WAL 에서 최신 커밋이 보이므로
79
+ * 안전하지만, 같은 트랜잭션 안에서 읽어야 하는 값이면 `tx` 를 받는 `getRepository` 를 쓴다.
80
+ *
81
+ * 풀이 있는 드라이버(postgres·mysql)에서는 얻을 것이 없으므로 `'read'` 가 `'default'` 를 가리킨다 —
82
+ * 소비처는 드라이버를 알 필요가 없다. 배포가 그 연결을 원하지 않으면 `ormconfig4Read: false` 로 끈다
83
+ * (그때도 이 함수는 그대로 쓸 수 있다 — `'default'` 로 떨어진다).
84
+ */
85
+ function getReadRepository(target) {
86
+ return (getDataSource('read') ?? getDataSource('default')).getRepository(target);
87
+ }
88
+ /**
89
+ * sqlite 일 때만 얹는 기본값 — **쓰기가 읽기를 막지 않게.**
90
+ *
91
+ * ── 무엇을 재서 알았나 (2026-08-22) ────────────────────────────────────────
92
+ * 개발 DB(16.3GB · 저널 1,134만 행)에서 서버가 간헐적으로 아주 느렸다. 밖에서는 보이지 않았다:
93
+ * `GET /` 는 1ms, CPU 는 96% 유휴, 외부 연결의 읽기는 깨끗했다. 서버가 스스로 말하게 하자
94
+ * (`maxQueryExecutionTime`) 20분에 **느린 질의 223건 · 합계 3,279초**가 나왔다.
95
+ *
96
+ * 결정적인 것은 **무엇이 느렸나가 아니라 무엇까지 느렸나**였다. 수십 행짜리 `users`·`domains` 조회가
97
+ * 32초·15초였다. 그럴 수 있는 비용이 아니다 — 그리고 종류가 다른 질의들의 최대값이 32.3 · 22.0 ·
98
+ * 15.7 로 **되풀이됐다.** 여러 질의가 같은 순간에 함께 풀렸다는 뜻이다: 줄서기다.
99
+ *
100
+ * 두 성질이 겹쳤다.
101
+ * ① sqlite 의 기본 저널 모드는 `delete` 다(아무도 고르지 않으면 그것이 된다 — 저장소 이력에
102
+ * `journal_mode` 를 정한 곳이 없다). 그 모드에서 **쓰는 동안 모든 읽기가 막힌다.**
103
+ * ② TypeORM 의 sqlite 드라이버는 연결을 **하나**만 든다(풀 없음). 그래서 느린 쓰기 하나가 앱의 모든
104
+ * 질의를 그 뒤에 세운다.
105
+ *
106
+ * WAL 은 ①을 없앤다 — 쓰는 중에도 읽기가 지나간다. 쓰기 자체를 빠르게 하지는 않지만, 사용자가 겪는
107
+ * 32초가 사라진다.
108
+ *
109
+ * ── 왜 sqlite 에만 얹나 ────────────────────────────────────────────────────
110
+ * `enableWAL` 은 sqlite 드라이버의 옵션이다. 다섯 드라이버를 서는 프레임워크이므로 다른 드라이버에
111
+ * 낯선 옵션을 흘리지 않는다(postgres·mysql 은 이 축이 없다 — 연결 풀과 MVCC 가 있다).
112
+ *
113
+ * **환경 설정이 덮을 수 있다** — 이 값은 뒤에 오는 `config.ormconfig` 보다 먼저 펼쳐진다.
114
+ * WAL 이 맞지 않는 자리가 있다(네트워크 파일시스템에서는 쓰지 못한다). 그때는 설정이 끈다.
115
+ */
116
+ function sqliteWriteConcurrency(cfg) {
117
+ return cfg?.type === 'sqlite' ? { enableWAL: true } : {};
118
+ }
52
119
  /**
53
120
  * Initializes the database connections and data sources.
54
121
  */
@@ -57,6 +124,7 @@ const databaseInitializer = async () => {
57
124
  const readConnectionConfig = env_1.config.get('ormconfig');
58
125
  const dataSource = await (0, typeorm_1.createConnection)({
59
126
  ...ormconfig_js_1.default,
127
+ ...sqliteWriteConcurrency(readConnectionConfig),
60
128
  ...readConnectionConfig
61
129
  });
62
130
  addDataSource('default', dataSource);
@@ -66,9 +134,58 @@ const databaseInitializer = async () => {
66
134
  await dataSource.synchronize();
67
135
  await dataSource.query('PRAGMA foreign_keys=ON');
68
136
  }
137
+ /*
138
+ * **무거운 읽기용 연결** — sqlite 에만 만든다(§`getReadRepository` 가 이유와 실측을 든다).
139
+ *
140
+ * 풀이 없는 드라이버에서만 얻을 것이 있으므로 그때만 연결을 하나 더 연다. 다른 드라이버에서는
141
+ * `'read'` 를 등록하지 않고, `getReadRepository` 가 `'default'` 로 떨어진다 — 소비처는 드라이버를
142
+ * 알 필요가 없다.
143
+ *
144
+ * `synchronize` 는 반드시 끈다: 스키마를 만드는 주체는 하나여야 한다(위에서 `default` 가 했다).
145
+ * 두 연결이 같은 스키마를 만들려 하면 서로를 밀어낸다.
146
+ *
147
+ * 실패하면 **삼키지 않고 넘어간다** — 이 연결이 없어도 앱은 돈다(느릴 뿐이다). 그래서 부팅을
148
+ * 세우지는 않되, 없다는 사실은 로그에 남긴다: 조용히 빠지면 「고쳤는데 왜 그대로냐」가 된다.
149
+ *
150
+ * ── 설정이 덮을 수 있다 — `ormconfig4Read` ────────────────────────────────
151
+ * 기본은 **자동**이다: `tx` 와 달리 「어디」를 결정할 것이 없다(같은 DB · 같은 데이터이고, 다른 것은
152
+ * 연결이 하나 더 있다는 것뿐이다). 그래서 설정 항목 없이도 선다.
153
+ *
154
+ * 그런데 **끌 수 있어야 하는 자리가 실재한다.** WAL 을 쓸 수 없는 파일시스템(네트워크 마운트)에서는
155
+ * 연결을 늘려도 얻는 것이 없고 잠금 다툼만 늘어난다. 파일 핸들이 빡빡한 배포도 그렇다. 그 판단은
156
+ * **배포마다 갈리는 사실**이므로 코드에 박아 두지 않는다.
157
+ *
158
+ * 없으면 sqlite 면 자동으로 만든다(기본)
159
+ * `false` 만들지 않는다 — `getReadRepository` 가 `'default'` 로 떨어진다
160
+ * 객체 그 설정으로 만든다(읽기 전용 복제본을 가리키는 등)
161
+ */
162
+ const readOverride = env_1.config.get('ormconfig4Read');
163
+ const wantsRead = readOverride === undefined ? readConnectionConfig.type === 'sqlite' : readOverride !== false;
164
+ if (!wantsRead && readOverride === false) {
165
+ env_1.logger.info('Read DataSource disabled by config (ormconfig4Read: false) — heavy reads share the default connection');
166
+ }
167
+ if (wantsRead) {
168
+ try {
169
+ const readSource = new typeorm_1.DataSource({
170
+ ...ormconfig_js_1.default,
171
+ ...readConnectionConfig,
172
+ ...(readOverride && readOverride !== true ? readOverride : {}),
173
+ name: 'read',
174
+ synchronize: false,
175
+ migrationsRun: false
176
+ });
177
+ await readSource.initialize();
178
+ addDataSource('read', readSource);
179
+ env_1.logger.info('Read DataSource established (sqlite — heavy reads do not block the write connection)');
180
+ }
181
+ catch (e) {
182
+ env_1.logger.warn('Read DataSource not established — heavy reads will share the default connection', e);
183
+ }
184
+ }
69
185
  if (env_1.config.get('ormconfig4Tx')) {
70
186
  const dataSource4Tx = new typeorm_1.DataSource({
71
187
  ...ormconfig_js_1.default,
188
+ ...sqliteWriteConcurrency(env_1.config.get('ormconfig4Tx')),
72
189
  ...env_1.config.get('ormconfig4Tx')
73
190
  });
74
191
  await dataSource4Tx.initialize();
@@ -1 +1 @@
1
- {"version":3,"file":"database.js","sourceRoot":"","sources":["../../server/initializers/database.ts"],"names":[],"mappings":";;;AAYA,sCAEC;AAOD,sCAEC;AAMD,4CAEC;AAMD,gDAEC;AAOD,sCAEC;;AAhDD,qCAA+F;AAE/F,6CAAoD;AACpD,0EAAsC;AAEtC,MAAM,WAAW,GAAmC,EAAE,CAAA;AAEtD;;;;GAIG;AACH,SAAgB,aAAa,CAAC,IAAa;IACzC,OAAO,WAAW,CAAC,IAAI,IAAI,SAAS,CAAC,CAAA;AACvC,CAAC;AAED;;;;GAIG;AACH,SAAgB,aAAa,CAAC,IAAY,EAAE,UAAsB;IAChE,WAAW,CAAC,IAAI,CAAC,GAAG,UAAU,CAAA;AAChC,CAAC;AAED;;;GAGG;AACH,SAAgB,gBAAgB,CAAC,IAAY;IAC3C,OAAO,WAAW,CAAC,IAAI,CAAC,CAAA;AAC1B,CAAC;AAED;;;GAGG;AACH,SAAgB,kBAAkB;IAChC,OAAO,MAAM,CAAC,IAAI,CAAC,WAAW,CAAC,CAAA;AACjC,CAAC;AAED;;;;GAIG;AACH,SAAgB,aAAa,CAAI,MAAuB,EAAE,EAAkB;IAC1E,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,aAAa,CAAI,MAAM,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,SAAS,CAAC,CAAC,aAAa,CAAI,MAAM,CAAC,CAAA;AAC7F,CAAC;AAED;;GAEG;AACI,MAAM,mBAAmB,GAAG,KAAK,IAAI,EAAE;IAC5C,IAAI,CAAC;QACH,MAAM,oBAAoB,GAAG,YAAM,CAAC,GAAG,CAAC,WAAW,CAAC,CAAA;QAEpD,MAAM,UAAU,GAAG,MAAM,IAAA,0BAAgB,EAAC;YACxC,GAAG,sBAAS;YACZ,GAAG,oBAAoB;SACxB,CAAC,CAAA;QAEF,aAAa,CAAC,SAAS,EAAE,UAAU,CAAC,CAAA;QAEpC,YAAM,CAAC,IAAI,CAAC,gCAAgC,CAAC,CAAA;QAE7C,IAAI,oBAAoB,CAAC,IAAI,IAAI,QAAQ,IAAI,oBAAoB,CAAC,WAAW,IAAI,KAAK,EAAE,CAAC;YACvF,MAAM,UAAU,CAAC,KAAK,CAAC,yBAAyB,CAAC,CAAA;YACjD,MAAM,UAAU,CAAC,WAAW,EAAE,CAAA;YAC9B,MAAM,UAAU,CAAC,KAAK,CAAC,wBAAwB,CAAC,CAAA;QAClD,CAAC;QAED,IAAI,YAAM,CAAC,GAAG,CAAC,cAAc,CAAC,EAAE,CAAC;YAC/B,MAAM,aAAa,GAAG,IAAI,oBAAU,CAAC;gBACnC,GAAG,sBAAS;gBACZ,GAAG,YAAM,CAAC,GAAG,CAAC,cAAc,CAAC;aAC9B,CAAC,CAAA;YACF,MAAM,aAAa,CAAC,UAAU,EAAE,CAAA;YAChC,aAAa,CAAC,IAAI,EAAE,aAAa,CAAC,CAAA;YAElC,YAAM,CAAC,IAAI,CAAC,oCAAoC,CAAC,CAAA;QACnD,CAAC;aAAM,CAAC;YACN,aAAa,CAAC,IAAI,EAAE,UAAU,CAAC,CAAA;QACjC,CAAC;IACH,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX;oEAC4D;QAC5D,YAAM,CAAC,KAAK,CAAC,+BAA+B,EAAE,CAAC,CAAC,CAAA;QAChD,MAAM,CAAC,CAAA;IACT,CAAC;AACH,CAAC,CAAA;AArCY,QAAA,mBAAmB,uBAqC/B","sourcesContent":["import { createConnection, DataSource, EntityManager, EntityTarget, Repository } from 'typeorm'\n\nimport { config, logger } from '@things-factory/env'\nimport ormconfig from './ormconfig.js'\n\nconst dataSources: { [name: string]: DataSource } = {}\n\n/**\n * Returns the specified DataSource by name.\n * @param {string} name - The name of the DataSource.\n * @returns {DataSource} - The DataSource with the specified name.\n */\nexport function getDataSource(name?: string): DataSource {\n return dataSources[name || 'default']\n}\n\n/**\n * Adds a new DataSource with the specified name.\n * @param {string} name - The name of the DataSource to add.\n * @param {DataSource} dataSource - The DataSource to add.\n */\nexport function addDataSource(name: string, dataSource: DataSource) {\n dataSources[name] = dataSource\n}\n\n/**\n * Removes a DataSource with the specified name.\n * @param {string} name - The name of the DataSource to remove.\n */\nexport function removeDataSource(name: string) {\n delete dataSources[name]\n}\n\n/**\n * Returns an array of all registered DataSource names.\n * @returns {string[]} - An array of DataSource names.\n */\nexport function getDataSourceNames() {\n return Object.keys(dataSources)\n}\n\n/**\n * Returns a repository for the specified entity.\n * @param {EntityTarget<X>} target - The target entity for which to get the repository.\n * @returns {Repository<X>} - The repository for the specified entity.\n */\nexport function getRepository<X>(target: EntityTarget<X>, tx?: EntityManager): Repository<X> {\n return tx ? tx.getRepository<X>(target) : getDataSource('default').getRepository<X>(target)\n}\n\n/**\n * Initializes the database connections and data sources.\n */\nexport const databaseInitializer = async () => {\n try {\n const readConnectionConfig = config.get('ormconfig')\n\n const dataSource = await createConnection({\n ...ormconfig,\n ...readConnectionConfig\n })\n\n addDataSource('default', dataSource)\n\n logger.info('Default DataSource established')\n\n if (readConnectionConfig.type == 'sqlite' && readConnectionConfig.synchronize == false) {\n await dataSource.query('PRAGMA foreign_keys=OFF')\n await dataSource.synchronize()\n await dataSource.query('PRAGMA foreign_keys=ON')\n }\n\n if (config.get('ormconfig4Tx')) {\n const dataSource4Tx = new DataSource({\n ...ormconfig,\n ...config.get('ormconfig4Tx')\n })\n await dataSource4Tx.initialize()\n addDataSource('tx', dataSource4Tx)\n\n logger.info('Transaction DataSource established')\n } else {\n addDataSource('tx', dataSource)\n }\n } catch (e) {\n /* DB 는 필수 인프라 — 초기화 실패를 삼키면 'default' DataSource 없이 부팅되어\n 이후 모든 요청이 런타임에 터진다(좀비 부팅). 실패를 전파해 즉시 중단(fail-fast)한다. */\n logger.error('Failed to initialize database', e)\n throw e\n }\n}\n"]}
1
+ {"version":3,"file":"database.js","sourceRoot":"","sources":["../../server/initializers/database.ts"],"names":[],"mappings":"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","sourcesContent":["import { createConnection, DataSource, EntityManager, EntityTarget, Repository } from 'typeorm'\n\nimport { config, logger } from '@things-factory/env'\nimport ormconfig from './ormconfig.js'\n\nconst dataSources: { [name: string]: DataSource } = {}\n\n/**\n * Returns the specified DataSource by name.\n * @param {string} name - The name of the DataSource.\n * @returns {DataSource} - The DataSource with the specified name.\n */\nexport function getDataSource(name?: string): DataSource {\n return dataSources[name || 'default']\n}\n\n/**\n * Adds a new DataSource with the specified name.\n * @param {string} name - The name of the DataSource to add.\n * @param {DataSource} dataSource - The DataSource to add.\n */\nexport function addDataSource(name: string, dataSource: DataSource) {\n dataSources[name] = dataSource\n}\n\n/**\n * Removes a DataSource with the specified name.\n * @param {string} name - The name of the DataSource to remove.\n */\nexport function removeDataSource(name: string) {\n delete dataSources[name]\n}\n\n/**\n * Returns an array of all registered DataSource names.\n * @returns {string[]} - An array of DataSource names.\n */\nexport function getDataSourceNames() {\n return Object.keys(dataSources)\n}\n\n/**\n * Returns a repository for the specified entity.\n * @param {EntityTarget<X>} target - The target entity for which to get the repository.\n * @returns {Repository<X>} - The repository for the specified entity.\n */\nexport function getRepository<X>(target: EntityTarget<X>, tx?: EntityManager): Repository<X> {\n return tx ? tx.getRepository<X>(target) : getDataSource('default').getRepository<X>(target)\n}\n\n/**\n * **무거운 읽기를 위한 저장소** — 다른 요청을 붙잡지 않게 별도 연결로 보낸다.\n *\n * ── 무엇을 재서 알았나 (2026-08-22) ────────────────────────────────────────\n * TypeORM 의 sqlite 드라이버는 연결을 **하나**만 든다(풀 없음). 그래서 무거운 읽기 하나가 도는 동안\n * 앱의 **모든** 질의가 그 뒤에 선다 — 수십 행짜리 `users`·`domains` 조회가 30초로 찍혔다.\n *\n * 같은 DB(16.3GB · 저널 1,134만 행)에서 실측했다. 무거운 읽기(2만 행)가 도는 동안 가벼운 읽기가\n * 얼마나 기다리나:\n *\n * DataSource 하나 → 47,647ms\n * DataSource 둘 → 2,786ms (평균 1,473ms)\n *\n * 즉 **sqlite 쪽 분리는 실재한다.** WAL 이면 연결마다 독립으로 읽으므로 잠금·직렬화가 풀린다.\n *\n * ── 남는 것을 감추지 않는다 ────────────────────────────────────────────────\n * 평균 1,473ms 는 사라지지 않는다. 그것은 sqlite 가 아니라 **행을 객체로 만드는 JS 일**이다\n * (같은 측정에서 한 행만 돌려주는 무거운 질의는 가벼운 읽기를 0.3ms 로 두었다). 이벤트 루프는 하나이고,\n * 그 비용은 DB 를 바꿔도 남는다. 고칠 자리는 연결이 아니라 **그 질의**다 — 페이지 크기·필요한 컬럼만·\n * 엔티티 대신 raw. 이 함수는 그 일을 대신해 주지 않는다.\n *\n * ── 언제 쓰나 ──────────────────────────────────────────────────────────────\n * **읽기 전용이고, 느린 것이 측정된 경로**에만 쓴다. 두 가지를 주의한다.\n *\n * · **쓰지 않는다.** 이 연결로 쓰면 별도 쓰기 주체가 되어 잠금을 다툰다. 쓰기는 `getRepository` 다.\n * · **쓴 뒤 곧바로 읽는 자리에는 쓰지 않는다.** 쓰기를 `await` 한 뒤라면 WAL 에서 최신 커밋이 보이므로\n * 안전하지만, 같은 트랜잭션 안에서 읽어야 하는 값이면 `tx` 를 받는 `getRepository` 를 쓴다.\n *\n * 풀이 있는 드라이버(postgres·mysql)에서는 얻을 것이 없으므로 `'read'` 가 `'default'` 를 가리킨다 —\n * 소비처는 드라이버를 알 필요가 없다. 배포가 그 연결을 원하지 않으면 `ormconfig4Read: false` 로 끈다\n * (그때도 이 함수는 그대로 쓸 수 있다 — `'default'` 로 떨어진다).\n */\nexport function getReadRepository<X>(target: EntityTarget<X>): Repository<X> {\n return (getDataSource('read') ?? getDataSource('default')).getRepository<X>(target)\n}\n\n/**\n * sqlite 일 때만 얹는 기본값 — **쓰기가 읽기를 막지 않게.**\n *\n * ── 무엇을 재서 알았나 (2026-08-22) ────────────────────────────────────────\n * 개발 DB(16.3GB · 저널 1,134만 행)에서 서버가 간헐적으로 아주 느렸다. 밖에서는 보이지 않았다:\n * `GET /` 는 1ms, CPU 는 96% 유휴, 외부 연결의 읽기는 깨끗했다. 서버가 스스로 말하게 하자\n * (`maxQueryExecutionTime`) 20분에 **느린 질의 223건 · 합계 3,279초**가 나왔다.\n *\n * 결정적인 것은 **무엇이 느렸나가 아니라 무엇까지 느렸나**였다. 수십 행짜리 `users`·`domains` 조회가\n * 32초·15초였다. 그럴 수 있는 비용이 아니다 — 그리고 종류가 다른 질의들의 최대값이 32.3 · 22.0 ·\n * 15.7 로 **되풀이됐다.** 여러 질의가 같은 순간에 함께 풀렸다는 뜻이다: 줄서기다.\n *\n * 두 성질이 겹쳤다.\n * ① sqlite 의 기본 저널 모드는 `delete` 다(아무도 고르지 않으면 그것이 된다 — 저장소 이력에\n * `journal_mode` 를 정한 곳이 없다). 그 모드에서 **쓰는 동안 모든 읽기가 막힌다.**\n * ② TypeORM 의 sqlite 드라이버는 연결을 **하나**만 든다(풀 없음). 그래서 느린 쓰기 하나가 앱의 모든\n * 질의를 그 뒤에 세운다.\n *\n * WAL 은 ①을 없앤다 — 쓰는 중에도 읽기가 지나간다. 쓰기 자체를 빠르게 하지는 않지만, 사용자가 겪는\n * 32초가 사라진다.\n *\n * ── 왜 sqlite 에만 얹나 ────────────────────────────────────────────────────\n * `enableWAL` 은 sqlite 드라이버의 옵션이다. 다섯 드라이버를 서는 프레임워크이므로 다른 드라이버에\n * 낯선 옵션을 흘리지 않는다(postgres·mysql 은 이 축이 없다 — 연결 풀과 MVCC 가 있다).\n *\n * **환경 설정이 덮을 수 있다** — 이 값은 뒤에 오는 `config.ormconfig` 보다 먼저 펼쳐진다.\n * WAL 이 맞지 않는 자리가 있다(네트워크 파일시스템에서는 쓰지 못한다). 그때는 설정이 끈다.\n */\nfunction sqliteWriteConcurrency(cfg: any): Record<string, unknown> {\n return cfg?.type === 'sqlite' ? { enableWAL: true } : {}\n}\n\n/**\n * Initializes the database connections and data sources.\n */\nexport const databaseInitializer = async () => {\n try {\n const readConnectionConfig = config.get('ormconfig')\n\n const dataSource = await createConnection({\n ...ormconfig,\n ...sqliteWriteConcurrency(readConnectionConfig),\n ...readConnectionConfig\n })\n\n addDataSource('default', dataSource)\n\n logger.info('Default DataSource established')\n\n if (readConnectionConfig.type == 'sqlite' && readConnectionConfig.synchronize == false) {\n await dataSource.query('PRAGMA foreign_keys=OFF')\n await dataSource.synchronize()\n await dataSource.query('PRAGMA foreign_keys=ON')\n }\n\n /*\n * **무거운 읽기용 연결** — sqlite 에만 만든다(§`getReadRepository` 가 이유와 실측을 든다).\n *\n * 풀이 없는 드라이버에서만 얻을 것이 있으므로 그때만 연결을 하나 더 연다. 다른 드라이버에서는\n * `'read'` 를 등록하지 않고, `getReadRepository` 가 `'default'` 로 떨어진다 — 소비처는 드라이버를\n * 알 필요가 없다.\n *\n * `synchronize` 는 반드시 끈다: 스키마를 만드는 주체는 하나여야 한다(위에서 `default` 가 했다).\n * 두 연결이 같은 스키마를 만들려 하면 서로를 밀어낸다.\n *\n * 실패하면 **삼키지 않고 넘어간다** — 이 연결이 없어도 앱은 돈다(느릴 뿐이다). 그래서 부팅을\n * 세우지는 않되, 없다는 사실은 로그에 남긴다: 조용히 빠지면 「고쳤는데 왜 그대로냐」가 된다.\n *\n * ── 설정이 덮을 수 있다 — `ormconfig4Read` ────────────────────────────────\n * 기본은 **자동**이다: `tx` 와 달리 「어디」를 결정할 것이 없다(같은 DB · 같은 데이터이고, 다른 것은\n * 연결이 하나 더 있다는 것뿐이다). 그래서 설정 항목 없이도 선다.\n *\n * 그런데 **끌 수 있어야 하는 자리가 실재한다.** WAL 을 쓸 수 없는 파일시스템(네트워크 마운트)에서는\n * 연결을 늘려도 얻는 것이 없고 잠금 다툼만 늘어난다. 파일 핸들이 빡빡한 배포도 그렇다. 그 판단은\n * **배포마다 갈리는 사실**이므로 코드에 박아 두지 않는다.\n *\n * 없으면 sqlite 면 자동으로 만든다(기본)\n * `false` 만들지 않는다 — `getReadRepository` 가 `'default'` 로 떨어진다\n * 객체 그 설정으로 만든다(읽기 전용 복제본을 가리키는 등)\n */\n const readOverride = config.get('ormconfig4Read')\n const wantsRead = readOverride === undefined ? readConnectionConfig.type === 'sqlite' : readOverride !== false\n if (!wantsRead && readOverride === false) {\n logger.info('Read DataSource disabled by config (ormconfig4Read: false) — heavy reads share the default connection')\n }\n if (wantsRead) {\n try {\n const readSource = new DataSource({\n ...ormconfig,\n ...readConnectionConfig,\n ...(readOverride && readOverride !== true ? readOverride : {}),\n name: 'read',\n synchronize: false,\n migrationsRun: false\n })\n await readSource.initialize()\n addDataSource('read', readSource)\n logger.info('Read DataSource established (sqlite — heavy reads do not block the write connection)')\n } catch (e) {\n logger.warn('Read DataSource not established — heavy reads will share the default connection', e)\n }\n }\n\n if (config.get('ormconfig4Tx')) {\n const dataSource4Tx = new DataSource({\n ...ormconfig,\n ...sqliteWriteConcurrency(config.get('ormconfig4Tx')),\n ...config.get('ormconfig4Tx')\n })\n await dataSource4Tx.initialize()\n addDataSource('tx', dataSource4Tx)\n\n logger.info('Transaction DataSource established')\n } else {\n addDataSource('tx', dataSource)\n }\n } catch (e) {\n /* DB 는 필수 인프라 — 초기화 실패를 삼키면 'default' DataSource 없이 부팅되어\n 이후 모든 요청이 런타임에 터진다(좀비 부팅). 실패를 전파해 즉시 중단(fail-fast)한다. */\n logger.error('Failed to initialize database', e)\n throw e\n }\n}\n"]}
@@ -20,8 +20,33 @@ subscribers = flattenDeep(subscribers);
20
20
  debugLogger('entities', entities);
21
21
  debugLogger('migrations', migrations);
22
22
  debugLogger('subscribers', subscribers);
23
+ /**
24
+ * 느린 질의를 **스스로 말하게** 하는 문턱(ms) — 넘긴 질의만 SQL 과 함께 찍는다.
25
+ *
26
+ * ── 왜 기본값으로 두는가 (2026-08-22) ──────────────────────────────────────
27
+ * 서버가 간헐적으로 느리다는 것을 밖에서 재려 하면 계속 놓친다. 실제로 그랬다: `GET /` 는 1ms 이고
28
+ * (302 라 DB 를 거의 안 지난다) CPU 프로파일은 96% 유휴였다 — 유휴는 **기다리는 중**이라는 뜻인데
29
+ * 그것을 건강하다고 읽었다. 비용은 DB 를 지나는 경로에만, 실 데이터 양에서만 나타난다.
30
+ *
31
+ * 그리고 sqlite 드라이버는 연결을 **하나**만 든다(`SqliteDriver.databaseConnection`, 풀 없음). 그래서
32
+ * 어딘가에서 느린 질의 하나가 도는 동안 **앱의 모든 질의가 그 뒤에 줄을 선다.** 실측: 저널
33
+ * 1,134만 행에서 `count(*)` 한 번이 11.2초다. 그 11초 동안 모든 화면이 멈추고, CPU 는 유휴로 보인다.
34
+ *
35
+ * 그러므로 필요한 것은 추측이 아니라 **이름**이다. 넘긴 질의가 자기 SQL 을 찍으면 다음에 느려질 때
36
+ * 우리가 잴 일이 없다.
37
+ *
38
+ * ── 왜 `logging` 과 무관하게 켜지는가 ─────────────────────────────────────
39
+ * TypeORM 의 `AbstractLogger.isLogEnabledFor` 가 `'query-slow'` 에는 **무조건 true** 를 돌려준다
40
+ * (`logging: false` 여도 찍힌다). 즉 이 값만 주면 되고, 질의 전체가 쏟아지지는 않는다.
41
+ *
42
+ * 문턱을 1초로 둔다. 사람이 「눌렀는데 반응이 없다」고 느끼는 구간이 그 위이고, 그보다 낮추면 정상적인
43
+ * 무거운 질의까지 섞여 로그가 신호를 잃는다. 환경별 설정(`config.ormconfig`)이 이 값을 덮을 수 있다 —
44
+ * 여기 있는 것은 **기본값**이다.
45
+ */
46
+ const SLOW_QUERY_MS = 1000;
23
47
  module.exports = {
24
48
  namingStrategy: new naming_strategy_js_1.NamingStrategy(),
49
+ maxQueryExecutionTime: SLOW_QUERY_MS,
25
50
  entities,
26
51
  migrations,
27
52
  subscribers
@@ -1 +1 @@
1
- {"version":3,"file":"ormconfig.js","sourceRoot":"","sources":["../../server/initializers/ormconfig.ts"],"names":[],"mappings":";;;AAAA,6CAAgE;AAChE,6DAAqD;AACrD,0DAAyB;AAEzB,MAAM,WAAW,GAAG,IAAA,eAAK,EAAC,gCAAgC,CAAC,CAAA;AAE3D,SAAS,WAAW,CAAC,GAAG;IACtB,OAAO,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;AAC5G,CAAC;AAED;;EAEE;AACF,IAAI,QAAQ,GAAG,wBAAkB,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,IAAA,YAAM,EAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;AAChH,QAAQ,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAA;AAEhC,IAAI,UAAU,GAAG,wBAAkB,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,IAAA,YAAM,EAAC,GAAG,CAAC,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;AACpH,UAAU,GAAG,WAAW,CAAC,UAAU,CAAC,CAAA;AAEpC,IAAI,WAAW,GAAG,wBAAkB,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,IAAA,YAAM,EAAC,GAAG,CAAC,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;AACtH,WAAW,GAAG,WAAW,CAAC,WAAW,CAAC,CAAA;AAEtC,WAAW,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAA;AACjC,WAAW,CAAC,YAAY,EAAE,UAAU,CAAC,CAAA;AACrC,WAAW,CAAC,aAAa,EAAE,WAAW,CAAC,CAAA;AAEvC,MAAM,CAAC,OAAO,GAAG;IACf,cAAc,EAAE,IAAI,mCAAc,EAAE;IACpC,QAAQ;IACR,UAAU;IACV,WAAW;CACZ,CAAA;AAED,kBAAe,MAAM,CAAC,OAAO,CAAA","sourcesContent":["import { orderedModuleNames, loader } from '@things-factory/env'\nimport { NamingStrategy } from './naming-strategy.js'\nimport debug from 'debug'\n\nconst debugLogger = debug('things-factory:shell:ormconfig')\n\nfunction flattenDeep(arr) {\n return arr.reduce((acc, val) => (Array.isArray(val) ? acc.concat(flattenDeep(val)) : acc.concat(val)), [])\n}\n\n/*\n dependencies list를 받아서, entities, migrations, subscribers 폴더 어레이를 빌드한다.\n*/\nvar entities = orderedModuleNames.map(dep => loader(dep).entities).filter(entity => entity && entity.length > 0)\nentities = flattenDeep(entities)\n\nvar migrations = orderedModuleNames.map(dep => loader(dep).migrations).filter(entity => entity && entity.length > 0)\nmigrations = flattenDeep(migrations)\n\nvar subscribers = orderedModuleNames.map(dep => loader(dep).subscribers).filter(entity => entity && entity.length > 0)\nsubscribers = flattenDeep(subscribers)\n\ndebugLogger('entities', entities)\ndebugLogger('migrations', migrations)\ndebugLogger('subscribers', subscribers)\n\nmodule.exports = {\n namingStrategy: new NamingStrategy(),\n entities,\n migrations,\n subscribers\n}\n\nexport default module.exports\n"]}
1
+ {"version":3,"file":"ormconfig.js","sourceRoot":"","sources":["../../server/initializers/ormconfig.ts"],"names":[],"mappings":";;;AAAA,6CAAgE;AAChE,6DAAqD;AACrD,0DAAyB;AAEzB,MAAM,WAAW,GAAG,IAAA,eAAK,EAAC,gCAAgC,CAAC,CAAA;AAE3D,SAAS,WAAW,CAAC,GAAG;IACtB,OAAO,GAAG,CAAC,MAAM,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;AAC5G,CAAC;AAED;;EAEE;AACF,IAAI,QAAQ,GAAG,wBAAkB,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,IAAA,YAAM,EAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;AAChH,QAAQ,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAA;AAEhC,IAAI,UAAU,GAAG,wBAAkB,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,IAAA,YAAM,EAAC,GAAG,CAAC,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;AACpH,UAAU,GAAG,WAAW,CAAC,UAAU,CAAC,CAAA;AAEpC,IAAI,WAAW,GAAG,wBAAkB,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,IAAA,YAAM,EAAC,GAAG,CAAC,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,IAAI,MAAM,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;AACtH,WAAW,GAAG,WAAW,CAAC,WAAW,CAAC,CAAA;AAEtC,WAAW,CAAC,UAAU,EAAE,QAAQ,CAAC,CAAA;AACjC,WAAW,CAAC,YAAY,EAAE,UAAU,CAAC,CAAA;AACrC,WAAW,CAAC,aAAa,EAAE,WAAW,CAAC,CAAA;AAEvC;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,aAAa,GAAG,IAAI,CAAA;AAE1B,MAAM,CAAC,OAAO,GAAG;IACf,cAAc,EAAE,IAAI,mCAAc,EAAE;IACpC,qBAAqB,EAAE,aAAa;IACpC,QAAQ;IACR,UAAU;IACV,WAAW;CACZ,CAAA;AAED,kBAAe,MAAM,CAAC,OAAO,CAAA","sourcesContent":["import { orderedModuleNames, loader } from '@things-factory/env'\nimport { NamingStrategy } from './naming-strategy.js'\nimport debug from 'debug'\n\nconst debugLogger = debug('things-factory:shell:ormconfig')\n\nfunction flattenDeep(arr) {\n return arr.reduce((acc, val) => (Array.isArray(val) ? acc.concat(flattenDeep(val)) : acc.concat(val)), [])\n}\n\n/*\n dependencies list를 받아서, entities, migrations, subscribers 폴더 어레이를 빌드한다.\n*/\nvar entities = orderedModuleNames.map(dep => loader(dep).entities).filter(entity => entity && entity.length > 0)\nentities = flattenDeep(entities)\n\nvar migrations = orderedModuleNames.map(dep => loader(dep).migrations).filter(entity => entity && entity.length > 0)\nmigrations = flattenDeep(migrations)\n\nvar subscribers = orderedModuleNames.map(dep => loader(dep).subscribers).filter(entity => entity && entity.length > 0)\nsubscribers = flattenDeep(subscribers)\n\ndebugLogger('entities', entities)\ndebugLogger('migrations', migrations)\ndebugLogger('subscribers', subscribers)\n\n/**\n * 느린 질의를 **스스로 말하게** 하는 문턱(ms) — 넘긴 질의만 SQL 과 함께 찍는다.\n *\n * ── 왜 기본값으로 두는가 (2026-08-22) ──────────────────────────────────────\n * 서버가 간헐적으로 느리다는 것을 밖에서 재려 하면 계속 놓친다. 실제로 그랬다: `GET /` 는 1ms 이고\n * (302 라 DB 를 거의 안 지난다) CPU 프로파일은 96% 유휴였다 — 유휴는 **기다리는 중**이라는 뜻인데\n * 그것을 건강하다고 읽었다. 비용은 DB 를 지나는 경로에만, 실 데이터 양에서만 나타난다.\n *\n * 그리고 sqlite 드라이버는 연결을 **하나**만 든다(`SqliteDriver.databaseConnection`, 풀 없음). 그래서\n * 어딘가에서 느린 질의 하나가 도는 동안 **앱의 모든 질의가 그 뒤에 줄을 선다.** 실측: 저널\n * 1,134만 행에서 `count(*)` 한 번이 11.2초다. 그 11초 동안 모든 화면이 멈추고, CPU 는 유휴로 보인다.\n *\n * 그러므로 필요한 것은 추측이 아니라 **이름**이다. 넘긴 질의가 자기 SQL 을 찍으면 다음에 느려질 때\n * 우리가 잴 일이 없다.\n *\n * ── 왜 `logging` 과 무관하게 켜지는가 ─────────────────────────────────────\n * TypeORM 의 `AbstractLogger.isLogEnabledFor` 가 `'query-slow'` 에는 **무조건 true** 를 돌려준다\n * (`logging: false` 여도 찍힌다). 즉 이 값만 주면 되고, 질의 전체가 쏟아지지는 않는다.\n *\n * 문턱을 1초로 둔다. 사람이 「눌렀는데 반응이 없다」고 느끼는 구간이 그 위이고, 그보다 낮추면 정상적인\n * 무거운 질의까지 섞여 로그가 신호를 잃는다. 환경별 설정(`config.ormconfig`)이 이 값을 덮을 수 있다 —\n * 여기 있는 것은 **기본값**이다.\n */\nconst SLOW_QUERY_MS = 1000\n\nmodule.exports = {\n namingStrategy: new NamingStrategy(),\n maxQueryExecutionTime: SLOW_QUERY_MS,\n entities,\n migrations,\n subscribers\n}\n\nexport default module.exports\n"]}