@things-factory/board-service 10.1.206 → 10.1.208
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/dist-server/service/board/board-mutation.js +26 -16
- package/dist-server/service/board/board-mutation.js.map +1 -1
- package/dist-server/service/board/thumbnail-save.test.d.ts +6 -0
- package/dist-server/service/board/thumbnail-save.test.js +67 -0
- package/dist-server/service/board/thumbnail-save.test.js.map +1 -0
- package/dist-server/service/board/thumbnail-scheduler.d.ts +22 -1
- package/dist-server/service/board/thumbnail-scheduler.js +41 -15
- package/dist-server/service/board/thumbnail-scheduler.js.map +1 -1
- package/dist-server/service/board/thumbnail-scheduler.test.d.ts +1 -0
- package/dist-server/service/board/thumbnail-scheduler.test.js +106 -6
- package/dist-server/service/board/thumbnail-scheduler.test.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
|
@@ -68,8 +68,12 @@ let BoardMutation = class BoardMutation {
|
|
|
68
68
|
const newBoard = {
|
|
69
69
|
...board
|
|
70
70
|
};
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
if ((0, thumbnail_scheduler_js_1.isClientThumbnail)(board.thumbnail)) {
|
|
72
|
+
newBoard.thumbnailUpdatedAt = new Date();
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
newBoard.thumbnail = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; /* empty thumbnail */
|
|
76
|
+
}
|
|
73
77
|
if (board.groupId) {
|
|
74
78
|
newBoard.group = await groupRepository.findOneBy({
|
|
75
79
|
id: board.groupId
|
|
@@ -90,9 +94,8 @@ let BoardMutation = class BoardMutation {
|
|
|
90
94
|
updater: user
|
|
91
95
|
});
|
|
92
96
|
// 새 보드의 thumbnail 도 백그라운드로 생성 — mutation 응답 즉시 반환.
|
|
93
|
-
// 클라이언트가 thumbnail 을 보낸
|
|
94
|
-
|
|
95
|
-
if (newBoard.model && (!board.thumbnail || board.thumbnail.startsWith('data:image/gif'))) {
|
|
97
|
+
// 클라이언트가 thumbnail 을 보낸 경우는 그것을 사용하고 puppeteer 생략.
|
|
98
|
+
if (newBoard.model && !(0, thumbnail_scheduler_js_1.isClientThumbnail)(board.thumbnail)) {
|
|
96
99
|
(0, thumbnail_scheduler_js_1.scheduleThumbnail)(created.id, newBoard.model, context);
|
|
97
100
|
}
|
|
98
101
|
notify &&
|
|
@@ -171,6 +174,15 @@ let BoardMutation = class BoardMutation {
|
|
|
171
174
|
if (!claimed)
|
|
172
175
|
throw new shell_1.Refusal('board-changed-since', { name: board.name }, 'this board was saved somewhere else after you opened it. open it again and then save.');
|
|
173
176
|
}
|
|
177
|
+
const { groupId, expectedUpdatedAt, ...patched } = patch;
|
|
178
|
+
if ((0, thumbnail_scheduler_js_1.isClientThumbnail)(patch.thumbnail)) {
|
|
179
|
+
// 클라이언트가 만든 thumbnail 을 그대로 쓴다 — 백엔드는 이 저장에 thumbnail 을 만들지 않는다.
|
|
180
|
+
(0, thumbnail_scheduler_js_1.acceptClientThumbnail)(id);
|
|
181
|
+
patched.thumbnailUpdatedAt = new Date();
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
delete patched.thumbnail;
|
|
185
|
+
}
|
|
174
186
|
if (patch.model) {
|
|
175
187
|
// 데이터 무결성 — model JSON 의 syntax 검증. invalid JSON 영속 차단.
|
|
176
188
|
// (의미 검증 [schema] 은 별도 layer — board-ai 의 tool 단계에서 수행.)
|
|
@@ -190,17 +202,15 @@ let BoardMutation = class BoardMutation {
|
|
|
190
202
|
// 명시 에러 — silent 데이터 손상보다 명확한 reject 선호
|
|
191
203
|
throw new Error(`Invalid board model: ${e.message}`);
|
|
192
204
|
}
|
|
193
|
-
// Thumbnail 생성은 fire-and-forget
|
|
194
|
-
// (또는 풀 timeout) 에 차단되지 않도록
|
|
195
|
-
// + model hash dedup 으로 puppeteer 호출
|
|
196
|
-
// ./thumbnail-scheduler.ts 참고.
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
(0, thumbnail_scheduler_js_1.scheduleThumbnail)(id, patch.model, context);
|
|
205
|
+
// 클라이언트 thumbnail 이 없으면 백엔드가 만든다. Thumbnail 생성은 fire-and-forget
|
|
206
|
+
// 백그라운드. mutation 응답이 puppeteer 완료(또는 풀 timeout) 에 차단되지 않도록
|
|
207
|
+
// 분리. 빈번한 저장 시 per-board debounce + model hash dedup 으로 puppeteer 호출
|
|
208
|
+
// 횟수 자체를 줄인다. 자세한 동작은 ./thumbnail-scheduler.ts 참고. 새 thumbnail 이
|
|
209
|
+
// 만들어지기 전까지는 board.thumbnail 의 기존 값이 유지된다.
|
|
210
|
+
if (!(0, thumbnail_scheduler_js_1.isClientThumbnail)(patch.thumbnail)) {
|
|
211
|
+
(0, thumbnail_scheduler_js_1.scheduleThumbnail)(id, patch.model, context);
|
|
212
|
+
}
|
|
202
213
|
}
|
|
203
|
-
const { groupId, expectedUpdatedAt, ...patched } = patch;
|
|
204
214
|
if (groupId !== undefined) {
|
|
205
215
|
const groupRepository = (0, shell_1.getRepository)(group_js_1.Group, tx);
|
|
206
216
|
board.group = groupId
|
|
@@ -471,7 +481,7 @@ tslib_1.__decorate([
|
|
|
471
481
|
(0, type_graphql_1.Directive)('@privilege(category: "board", privilege: "mutation", domainOwnerGranted: true)'),
|
|
472
482
|
(0, type_graphql_1.UseMiddleware)(shell_1.SpeakRefusals),
|
|
473
483
|
(0, type_graphql_1.Mutation)(returns => board_js_1.Board, {
|
|
474
|
-
description: 'Updates a board. If a model is provided, it also generates a new thumbnail.'
|
|
484
|
+
description: 'Updates a board. If a model is provided without a thumbnail, it also generates a new thumbnail; a thumbnail sent with the patch is kept as it is.'
|
|
475
485
|
}),
|
|
476
486
|
tslib_1.__param(0, (0, type_graphql_1.Arg)('id')),
|
|
477
487
|
tslib_1.__param(1, (0, type_graphql_1.Arg)('patch')),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"board-mutation.js","sourceRoot":"","sources":["../../../server/service/board/board-mutation.ts"],"names":[],"mappings":";;;;AAAA,+CAAqF;AACrF,qCAA4B;AAE5B,+FAA2D;AAC3D,iDAA+G;AAE/G,gDAAyC;AACzC,yCAAkC;AAClC,yDAAiD;AACjD,mDAAsD;AACtD,qEAA4D;AAC5D,qDAAsD;AAEtD;;;GAGG;AACH,SAAS,eAAe,CAAC,IAAY,EAAE,IAAY;IACjD,IAAI,IAAI,IAAI,IAAI;QAAE,OAAO,IAAI,GAAG,CAAC,CAAA;IAEjC,MAAM,GAAG,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,CAAA;IAC7B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC;QAC7B,MAAM,KAAK,GAAG,EAAE,IAAI,CAAC,CAAA;QACrB,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,KAAK,CAAC,GAAG,CAAC,CAAA;QACxC,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,GAAG,KAAK,CAAC,GAAG,CAAC,CAAA;QACxC,IAAI,GAAG,IAAI,IAAI,EAAE,CAAC;YAChB,OAAO,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,GAAG,KAAK,CAAC,EAAE,GAAG,CAAC,EAAE,IAAI,CAAC,GAAG,KAAK,CAAA;QACvE,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,KAAK,UAAU,aAAa,CAAC,YAAwB;IACnD,IAAI,EAAE,gBAAgB,EAAE,GAAG,MAAM,YAAY,CAAA;IAE7C,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,MAAM,GAAiB,EAAE,CAAA;QAE/B,gBAAgB,EAAE;aACf,EAAE,CAAC,MAAM,EAAE,CAAC,KAAiB,EAAE,EAAE;YAChC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACpB,CAAC,CAAC;aACD,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE;YACd,IAAI,CAAC;gBACH,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAA;gBAC5D,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,CAAA;gBACzC,OAAO,CAAC,QAAQ,CAAC,CAAA;YACnB,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,MAAM,CAAC,KAAK,CAAC,CAAA;YACf,CAAC;QACH,CAAC,CAAC;aACD,EAAE,CAAC,OAAO,EAAE,CAAC,KAAY,EAAE,EAAE;YAC5B,MAAM,CAAC,KAAK,CAAC,CAAA;QACf,CAAC,CAAC,CAAA;IACN,CAAC,CAAC,CAAA;AACJ,CAAC;AAGM,IAAM,aAAa,GAAnB,MAAM,aAAa;IAIlB,AAAN,KAAK,CAAC,WAAW,CAAe,KAAe,EAAS,OAAwB;QAC9E,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;QAClD,MAAM,UAAU,GAAG,IAAA,qBAAa,EAAC,gBAAK,EAAE,EAAE,CAAC,CAAA;QAC3C,MAAM,eAAe,GAAG,IAAA,qBAAa,EAAC,gBAAK,EAAE,EAAE,CAAC,CAAA;QAEhD,MAAM,QAAQ,GAAU,MAAM,UAAU,CAAC,SAAS,CAAC;YACjD,IAAI,EAAE,KAAK,CAAC,IAAI;YAChB,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE;SAC1B,CAAC,CAAA;QAEF,IAAI,QAAQ,EAAE,CAAC;YACb,MAAM,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,mCAAmC,EAAE,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAA;QACvF,CAAC;QAED,MAAM,QAAQ,GAAU;YACtB,GAAG,KAAK;SACT,CAAA;QAED,QAAQ,CAAC,SAAS;YAChB,4EAA4E,CAAA,CAAC,qBAAqB;QAEpG,IAAI,KAAK,CAAC,OAAO,EAAE,CAAC;YAClB,QAAQ,CAAC,KAAK,GAAG,MAAM,eAAe,CAAC,SAAS,CAAC;gBAC/C,EAAE,EAAE,KAAK,CAAC,OAAO;aAClB,CAAC,CAAA;QACJ,CAAC;QAED,gBAAgB;QAChB,MAAM,EAAE,YAAY,EAAE,GAAG,CAAC,MAAM,UAAU;aACvC,kBAAkB,CAAC,OAAO,CAAC;aAC3B,MAAM,CAAC,sBAAsB,EAAE,cAAc,CAAC;aAC9C,KAAK,CAAC,0BAA0B,EAAE,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,EAAE,CAAC;aAC1D,SAAS,EAAE,CAAC,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,CAAA;QAEzC,MAAM,OAAO,GAAG,MAAM,UAAU,CAAC,IAAI,CAAC;YACpC,MAAM;YACN,GAAG,QAAQ;YACX,SAAS,EAAE,YAAY,IAAI,IAAI,CAAC,CAAC,CAAC,YAAY,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;YACtD,KAAK,EAAE,OAAO;YACd,OAAO,EAAE,IAAI;YACb,OAAO,EAAE,IAAI;SACd,CAAC,CAAA;QAEF,mDAAmD;QACnD,4DAA4D;QAC5D,yCAAyC;QACzC,IAAI,QAAQ,CAAC,KAAK,IAAI,CAAC,CAAC,KAAK,CAAC,SAAS,IAAI,KAAK,CAAC,SAAS,CAAC,UAAU,CAAC,gBAAgB,CAAC,CAAC,EAAE,CAAC;YACzF,IAAA,0CAAiB,EAAC,OAAO,CAAC,EAAE,EAAE,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC,CAAA;QACxD,CAAC;QAED,MAAM;YACJ,MAAM,CAAC;gBACL,IAAI,EAAE,QAAQ;gBACd,KAAK,EAAE,UAAU,OAAO,CAAC,IAAI,WAAW;gBACxC,IAAI,EAAE,UAAU,OAAO,CAAC,IAAI,gBAAgB,IAAI,CAAC,IAAI,KAAK,OAAO,CAAC,WAAW,EAAE;gBAC/E,GAAG,EAAE,IAAA,gCAAwB,EAAC,OAAO,EAAE,MAAM,EAAE,iBAAiB,OAAO,CAAC,EAAE,EAAE,CAAC;aAC9E,CAAC,CAAA;QAEJ,OAAO,OAAO,CAAA;IAChB,CAAC;IAOK,AAAN,KAAK,CAAC,UAAU,CACH,EAAU,EACP,KAAiB,EACP,eAAuB,EACL,aAAqB,EACxD,OAAwB;QAE/B,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;QAClD,MAAM,EAAE,CAAC,EAAE,GAAG,OAAO,CAAA;QACrB,MAAM,UAAU,GAAG,IAAA,qBAAa,EAAC,gBAAK,EAAE,EAAE,CAAC,CAAA;QAE3C,MAAM,KAAK,GAAG,MAAM,UAAU,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,IAAA,YAAE,EAAC,CAAC,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC,CAAA;QAElH,IAAI,CAAC,KAAK,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,IAAI,KAAK,CAAC,IAAI,IAAI,eAAe,IAAI,MAAM,CAAC,SAAS,CAAC,EAAE,CAAC;YACrF,MAAM,IAAI,KAAK,CAAC,CAAC,CAAC,mDAAmD,EAAE,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAA;QAC/F,CAAC;QAED,IAAI,eAAe,IAAI,MAAM,CAAC,SAAS,EAAE,CAAC;YACxC,IAAI,CAAC,MAAM,UAAU,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,SAAS,EAAE,eAAe,EAAE,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC;gBAC1G,MAAM,IAAI,KAAK,CAAC,CAAC,CAAC,mDAAmD,EAAE,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAA;YAC/F,CAAC;QACH,CAAC;QAED,IAAI,YAAY,GAAG,MAAM,CAAA;QACzB,IAAI,YAAY,IAAI,MAAM,CAAC,SAAS,IAAI,eAAe,EAAE,CAAC;YACxD,YAAY,GAAG,MAAM,IAAA,qBAAa,EAAC,cAAM,EAAE,EAAE,CAAC,CAAC,SAAS,CAAC,EAAE,SAAS,EAAE,eAAe,EAAE,CAAC,CAAA;YACxF,IAAI,CAAC,YAAY,EAAE,CAAC;gBAClB,MAAM,IAAI,KAAK,CAAC,mBAAmB,eAAe,aAAa,CAAC,CAAA;YAClE,CAAC;QACH,CAAC;QAED,IAAI,WAAW,GAAG,IAAI,CAAA;QACtB,IAAI,aAAa,EAAE,CAAC;YAClB,WAAW,GAAG,MAAM,IAAA,qBAAa,EAAC,gBAAK,EAAE,EAAE,CAAC,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,YAAY,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,aAAa,EAAE,CAAC,CAAA;YAC9G,IAAI,CAAC,WAAW,EAAE,CAAC;gBACjB,MAAM,IAAI,KAAK,CAAC,eAAe,aAAa,eAAe,eAAe,aAAa,CAAC,CAAA;YAC1F,CAAC;QACH,CAAC;QAED,MAAM,EAAE,EAAE,EAAE,QAAQ,EAAE,GAAG,KAAK,EAAE,GAAG,KAAK,CAAA;QAExC,MAAM,MAAM,GAAG,MAAM,UAAU,CAAC,IAAI,CAAC;YACnC,MAAM,EAAE,YAAY;YACpB,GAAG,KAAK;YACR,GAAG,KAAK;YACR,KAAK,EAAE,WAAW;YAClB,OAAO,EAAE,CAAC;YACV,KAAK,EAAE,OAAO;YACd,OAAO,EAAE,IAAI;YACb,OAAO,EAAE,IAAI;SACd,CAAC,CAAA;QAEF,MAAM;YACJ,MAAM,CAAC;gBACL,IAAI,EAAE,QAAQ;gBACd,KAAK,EAAE,UAAU,MAAM,CAAC,IAAI,UAAU;gBACtC,IAAI,EAAE,UAAU,MAAM,CAAC,IAAI,eAAe,IAAI,CAAC,IAAI,KAAK,MAAM,CAAC,WAAW,EAAE;gBAC5E,KAAK,EAAE,IAAA,gCAAwB,EAAC,OAAO,EAAE,YAAY,EAAE,cAAc,MAAM,CAAC,EAAE,EAAE,CAAC;gBACjF,GAAG,EAAE,IAAA,gCAAwB,EAAC,OAAO,EAAE,YAAY,EAAE,iBAAiB,MAAM,CAAC,EAAE,EAAE,CAAC;aACnF,CAAC,CAAA;QAEJ,OAAO,MAAM,CAAA;IACf,CAAC;IAQK,AAAN,KAAK,CAAC,WAAW,CACJ,EAAU,EACP,KAAiB,EACxB,OAAwB;QAE/B,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;QAClD,MAAM,UAAU,GAAG,IAAA,qBAAa,EAAC,gBAAK,EAAE,EAAE,CAAC,CAAA;QAE3C,MAAM,KAAK,GAAG,MAAM,UAAU,CAAC,OAAO,CAAC;YACrC,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE;YACxC,SAAS,EAAE,CAAC,SAAS,CAAC;SACvB,CAAC,CAAA;QAEF,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,MAAM,IAAI,KAAK,CAAC,iBAAiB,CAAC,CAAA;QACpC,CAAC;QAED;;;WAGG;QACH,IAAI,KAAK,CAAC,iBAAiB,EAAE,CAAC;YAC5B,MAAM,OAAO,GAAG,MAAM,IAAA,oCAAmB,EAAC,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,IAAI,IAAI,CAAC,KAAK,CAAC,iBAAiB,CAAC,CAAC,CAAA;YAC/F,IAAI,CAAC,OAAO;gBACV,MAAM,IAAI,eAAO,CACf,qBAAqB,EACrB,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,EACpB,uFAAuF,CACxF,CAAA;QACL,CAAC;QAED,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC;YAChB,wDAAwD;YACxD,yDAAyD;YACzD,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,CAAA;gBACtC,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE,CAAC;oBAC1C,MAAM,IAAI,KAAK,CAAC,mCAAmC,CAAC,CAAA;gBACtD,CAAC;gBACD,IAAI,OAAO,MAAM,CAAC,KAAK,KAAK,QAAQ,IAAI,OAAO,MAAM,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;oBAC1E,MAAM,IAAI,KAAK,CAAC,gDAAgD,CAAC,CAAA;gBACnE,CAAC;gBACD,IAAI,MAAM,CAAC,UAAU,KAAK,SAAS,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,UAAU,CAAC,EAAE,CAAC;oBACzE,MAAM,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAAA;gBACvE,CAAC;YACH,CAAC;YAAC,OAAO,CAAM,EAAE,CAAC;gBAChB,wCAAwC;gBACxC,MAAM,IAAI,KAAK,CAAC,wBAAwB,CAAC,CAAC,OAAO,EAAE,CAAC,CAAA;YACtD,CAAC;YAED,iEAAiE;YACjE,4DAA4D;YAC5D,yDAAyD;YACzD,+BAA+B;YAC/B,EAAE;YACF,8DAA8D;YAC9D,oDAAoD;YACpD,uDAAuD;YACvD,IAAA,0CAAiB,EAAC,EAAE,EAAE,KAAK,CAAC,KAAK,EAAE,OAAO,CAAC,CAAA;QAC7C,CAAC;QAED,MAAM,EAAE,OAAO,EAAE,iBAAiB,EAAE,GAAG,OAAO,EAAE,GAAG,KAAK,CAAA;QAExD,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;YAC1B,MAAM,eAAe,GAAG,IAAA,qBAAa,EAAC,gBAAK,EAAE,EAAE,CAAC,CAAA;YAChD,KAAK,CAAC,KAAK,GAAG,OAAO;gBACnB,CAAC,CAAC,CAAC,MAAM,eAAe,CAAC,SAAS,CAAC;oBAC/B,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE;oBACzB,EAAE,EAAE,OAAO;iBACZ,CAAC,CAAC,IAAI,IAAI;gBACb,CAAC,CAAC,IAAI,CAAA;QACV,CAAC;QAED,MAAM,OAAO,GAAG,MAAM,UAAU,CAAC,IAAI,CAAC;YACpC,MAAM;YACN,GAAG,KAAK;YACR,GAAG,OAAO;YACV,KAAK,EAAE,OAAO;YACd,OAAO,EAAE,IAAI;SACd,CAAC,CAAA;QAEF,MAAM;YACJ,MAAM,CAAC;gBACL,IAAI,EAAE,QAAQ;gBACd,KAAK,EAAE,UAAU,OAAO,CAAC,IAAI,WAAW;gBACxC,IAAI,EAAE,UAAU,OAAO,CAAC,IAAI,gBAAgB,IAAI,CAAC,IAAI,KAAK,OAAO,CAAC,WAAW,EAAE;gBAC/E,KAAK,EAAE,IAAA,gCAAwB,EAAC,OAAO,EAAE,MAAM,EAAE,cAAc,OAAO,CAAC,EAAE,EAAE,CAAC;gBAC5E,GAAG,EAAE,IAAA,gCAAwB,EAAC,OAAO,EAAE,MAAM,EAAE,iBAAiB,OAAO,CAAC,EAAE,EAAE,CAAC;aAC9E,CAAC,CAAA;QAEJ,OAAO,OAAO,CAAA;IAChB,CAAC;IAKK,AAAN,KAAK,CAAC,YAAY,CACL,EAAU,EACd,OAAwB,EACK,OAAgB;QAEpD,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;QAClD,MAAM,UAAU,GAAG,IAAA,qBAAa,EAAC,gBAAK,EAAE,EAAE,CAAC,CAAA;QAC3C,MAAM,iBAAiB,GAAG,IAAA,qBAAa,EAAC,+BAAY,EAAE,EAAE,CAAC,CAAA;QAEzD,MAAM,KAAK,GAAG,MAAM,UAAU,CAAC,OAAO,CAAC;YACrC,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE;YACxC,SAAS,EAAE,CAAC,SAAS,CAAC;SACvB,CAAC,CAAA;QAEF,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,MAAM,IAAI,KAAK,CAAC,kBAAkB,EAAE,gBAAgB,CAAC,CAAA;QACvD,CAAC;QAED,IAAI,KAAK,CAAC,KAAK,IAAI,UAAU,EAAE,CAAC;YAC9B,MAAM,IAAI,KAAK,CAAC,kBAAkB,EAAE,uBAAuB,CAAC,CAAA;QAC9D,CAAC;QAED,0BAA0B;QAC1B,MAAM,UAAU,GAAG,MAAM,iBAAiB;aACvC,kBAAkB,CAAC,SAAS,CAAC;aAC7B,MAAM,CAAC,sBAAsB,EAAE,KAAK,CAAC;aACrC,KAAK,CAAC,0BAA0B,EAAE,EAAE,EAAE,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC;aACnD,SAAS,EAAE,CAAA;QAEd,MAAM,WAAW,GAAG,UAAU,EAAE,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;QAEpE,MAAM,OAAO,GAAG,MAAM,UAAU,CAAC,IAAI,CAAC;YACpC,MAAM;YACN,GAAG,KAAK;YACR,OAAO,EAAE,WAAW;YACpB,KAAK,EAAE,UAAU;YACjB,OAAO,EAAE,IAAI;YACb,6EAA6E;YAC7E,gBAAgB,EAAE,OAAO,IAAI,IAAI;SAC3B,CAAC,CAAA;QAET,MAAM;YACJ,MAAM,CAAC;gBACL,IAAI,EAAE,QAAQ;gBACd,KAAK,EAAE,UAAU,OAAO,CAAC,IAAI,YAAY;gBACzC,IAAI,EAAE,UAAU,OAAO,CAAC,IAAI,iBAAiB,IAAI,CAAC,IAAI,KAAK,OAAO,CAAC,WAAW,EAAE;gBAChF,KAAK,EAAE,IAAA,gCAAwB,EAAC,OAAO,EAAE,MAAM,EAAE,cAAc,OAAO,CAAC,EAAE,EAAE,CAAC;gBAC5E,GAAG,EAAE,IAAA,gCAAwB,EAAC,OAAO,EAAE,MAAM,EAAE,iBAAiB,OAAO,CAAC,EAAE,EAAE,CAAC;aAC9E,CAAC,CAAA;QAEJ,OAAO,OAAO,CAAA;IAChB,CAAC;IAKK,AAAN,KAAK,CAAC,kBAAkB,CACX,EAAU,EACL,OAAe,EACxB,OAAwB;QAE/B,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;QAClD,MAAM,UAAU,GAAG,IAAA,qBAAa,EAAC,gBAAK,EAAE,EAAE,CAAC,CAAA;QAE3C,MAAM,KAAK,GAAG,MAAM,UAAU,CAAC,OAAO,CAAC;YACrC,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE;YACxC,SAAS,EAAE,CAAC,SAAS,CAAC;SACvB,CAAC,CAAA;QAEF,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,MAAM,IAAI,KAAK,CAAC,iBAAiB,EAAE,gBAAgB,CAAC,CAAA;QACtD,CAAC;QAED,MAAM,iBAAiB,GAAG,IAAA,qBAAa,EAAC,+BAAY,EAAE,EAAE,CAAC,CAAA;QAEzD,MAAM,YAAY,GAAG,MAAM,iBAAiB,CAAC,OAAO,CAAC;YACnD,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,UAAU,EAAE,EAAE,EAAE,OAAO,EAAE;YAC7D,KAAK,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE;SAC3B,CAAC,CAAA;QAEF,IAAI,CAAC,YAAY,EAAE,CAAC;YAClB,MAAM,IAAI,KAAK,CAAC,yBAAyB,EAAE,IAAI,OAAO,gBAAgB,CAAC,CAAA;QACzE,CAAC;QAED,MAAM,OAAO,GAAG,MAAM,UAAU,CAAC,IAAI,CAAC;YACpC,MAAM;YACN,GAAG,KAAK;YACR,KAAK,EAAE,YAAY,CAAC,KAAK;YACzB,SAAS,EAAE,YAAY,CAAC,SAAS;YACjC,KAAK,EAAE,OAAO;YACd,OAAO,EAAE,IAAI;SACd,CAAC,CAAA;QAEF,MAAM;YACJ,MAAM,CAAC;gBACL,IAAI,EAAE,QAAQ;gBACd,KAAK,EAAE,UAAU,OAAO,CAAC,IAAI,WAAW;gBACxC,IAAI,EAAE,UAAU,OAAO,CAAC,IAAI,gBAAgB,IAAI,CAAC,IAAI,KAAK,OAAO,CAAC,WAAW,EAAE;gBAC/E,KAAK,EAAE,IAAA,gCAAwB,EAAC,OAAO,EAAE,MAAM,EAAE,cAAc,OAAO,CAAC,EAAE,EAAE,CAAC;gBAC5E,GAAG,EAAE,IAAA,gCAAwB,EAAC,OAAO,EAAE,MAAM,EAAE,iBAAiB,OAAO,CAAC,EAAE,EAAE,CAAC;aAC9E,CAAC,CAAA;QAEJ,OAAO,OAAO,CAAA;IAChB,CAAC;IAKK,AAAN,KAAK,CAAC,WAAW,CAAY,EAAU,EAAS,OAAwB;QACtE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;QAClD,MAAM,UAAU,GAAG,IAAA,qBAAa,EAAC,gBAAK,EAAE,EAAE,CAAC,CAAA;QAC3C,MAAM,KAAK,GAAG,MAAM,UAAU,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,CAAA;QAE3E,MAAM,OAAO,GAAG,MAAM,UAAU,CAAC,UAAU,CAAC,EAAE,CAAC,CAAA;QAE/C,MAAM;YACJ,MAAM,CAAC;gBACL,IAAI,EAAE,QAAQ;gBACd,KAAK,EAAE,UAAU,KAAK,CAAC,IAAI,WAAW;gBACtC,IAAI,EAAE,UAAU,KAAK,CAAC,IAAI,gBAAgB,IAAI,CAAC,IAAI,KAAK,KAAK,CAAC,WAAW,EAAE;aAC5E,CAAC,CAAA;QAEJ,OAAO,IAAI,CAAA;IACb,CAAC;IAKK,AAAN,KAAK,CAAC,YAAY,CACA,OAAe,EACM,KAAmB,EACtC,SAAkB,EAC7B,OAAwB;QAE/B,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;QAClD,MAAM,eAAe,GAAG,IAAA,qBAAa,EAAC,gBAAK,EAAE,EAAE,CAAC,CAAA;QAChD,MAAM,eAAe,GAAG,IAAA,qBAAa,EAAC,gBAAK,EAAE,EAAE,CAAC,CAAA;QAChD,MAAM,KAAK,GAAG,MAAM,eAAe,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,OAAO,EAAE,CAAC,CAAA;QAEzF,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,MAAM,IAAI,KAAK,CAAC,iBAAiB,OAAO,gBAAgB,CAAC,CAAA;QAC3D,CAAC;QAED,MAAM,MAAM,GAAG,EAAE,CAAA;QAEjB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,MAAM,EAAE,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,KAAK,EAAE,SAAS,EAAE,GAAG,MAAM,aAAa,CAAC,IAAI,CAAC,CAAA;YAE7E,IAAI,aAAa,GAAG,MAAM,eAAe,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAA;YACxF,IAAI,WAAW,GAAG,MAAM,eAAe,CAAC,SAAS,CAAC,EAAE,EAAE,EAAE,CAAC,CAAA;YAEzD,IAAI,SAAS,EAAE,CAAC;gBACd,IAAI,KAAK,GAAG,EAAS,CAAA;gBAErB,IAAI,WAAW,EAAE,CAAC;oBAChB,IAAI,SAAS,IAAI,WAAW,CAAC,QAAQ,IAAI,MAAM,CAAC,EAAE,EAAE,CAAC;wBACnD,MAAM,IAAI,KAAK,CAAC,iBAAiB,EAAE,sCAAsC,CAAC,CAAA;oBAC5E,CAAC;oBAED,KAAK,GAAG;wBACN,GAAG,WAAW;wBACd,IAAI;qBACL,CAAA;oBAED,IAAI,aAAa,IAAI,WAAW,CAAC,EAAE,IAAI,aAAa,CAAC,EAAE,EAAE,CAAC;wBACxD,cAAc;wBACd,KAAK,CAAC,IAAI,GAAG,GAAG,KAAK,CAAC,IAAI,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,CAAA;oBAC7C,CAAC;gBACH,CAAC;qBAAM,CAAC;oBACN,KAAK,GAAG;wBACN,EAAE;wBACF,IAAI,EAAE,aAAa,CAAC,CAAC,CAAC,GAAG,IAAI,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI;wBACrD,OAAO,EAAE,CAAC;wBACV,OAAO,EAAE,IAAI;qBACd,CAAA;gBACH,CAAC;YACH,CAAC;iBAAM,CAAC;gBACN,KAAK,GAAG;oBACN,IAAI;oBACJ,OAAO,EAAE,CAAC;oBACV,OAAO,EAAE,IAAI;iBACd,CAAA;gBAED,qBAAqB;gBACrB,IAAI,CAAC,WAAW,EAAE,CAAC;oBACjB,KAAK,CAAC,EAAE,GAAG,EAAE,CAAA;gBACf,CAAC;gBAED,cAAc;gBACd,IAAI,aAAa,EAAE,CAAC;oBAClB,KAAK,CAAC,IAAI,GAAG,GAAG,KAAK,CAAC,IAAI,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,CAAA;gBAC7C,CAAC;YACH,CAAC;YAED,MAAM,CAAC,IAAI,CACT,MAAM,eAAe,CAAC,IAAI,CAAC;gBACzB,GAAG,KAAK;gBACR,MAAM;gBACN,WAAW;gBACX,KAAK,EAAE,OAAO,KAAK,IAAI,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK;gBAC/D,SAAS;gBACT,KAAK;gBACL,KAAK,EAAE,OAAO;gBACd,OAAO,EAAE,IAAI;aACd,CAAC,CACH,CAAA;QACH,CAAC;QAED,MAAM;YACJ,MAAM,CAAC;gBACL,IAAI,EAAE,QAAQ;gBACd,KAAK,EAAE,GAAG,MAAM,CAAC,MAAM,wBAAwB;gBAC/C,IAAI,EAAE,GAAG,MAAM,CAAC,MAAM,qCAAqC,KAAK,CAAC,IAAI,OAAO,IAAI,CAAC,IAAI,EAAE;aACxF,CAAC,CAAA;QAEJ,OAAO,MAAM,CAAA;IACf,CAAC;IAKK,AAAN,KAAK,CAAC,YAAY,CACL,EAAU,EACc,MAAc,EACd,MAAc,EAC1C,OAAwB;QAE/B,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;QACpC,MAAM,IAAI,GAAG,EAAE,CAAC,aAAa,CAAC,gBAAK,CAAC,CAAA;QACpC,MAAM,QAAQ,GAAG,MAAM,CAAC,EAAE,CAAA;QAE1B,0BAA0B;QAC1B,IAAI,SAAS,GAAkB,IAAI,CAAA;QACnC,IAAI,SAAS,GAAkB,IAAI,CAAA;QAEnC,IAAI,MAAM,EAAE,CAAC;YACX,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAS,EAAE,CAAC,CAAA;YAC3F,SAAS,GAAG,IAAI,EAAE,SAAS,IAAI,IAAI,CAAA;QACrC,CAAC;QACD,IAAI,MAAM,EAAE,CAAC;YACX,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAS,EAAE,CAAC,CAAA;YAC3F,SAAS,GAAG,IAAI,EAAE,SAAS,IAAI,IAAI,CAAA;QACrC,CAAC;QAED,eAAe;QACf,IAAI,IAAI,GAAG,SAAS,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,CAAC,CAAC,SAAS,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;QAC/D,IAAI,IAAI,GAAG,SAAS,IAAI,IAAI,GAAG,CAAC,CAAA;QAEhC,6CAA6C;QAC7C,IAAI,IAAI,GAAG,IAAI,GAAG,KAAK,EAAE,CAAC;YACxB,MAAM,SAAS,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC;gBAChC,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAS;aAC3C,CAAC,CAAA;YAEF,mDAAmD;YACnD,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;gBACtB,MAAM,EAAE,GAAG,CAAC,CAAC,SAAS,IAAI,MAAM,CAAC,gBAAgB,CAAA;gBACjD,MAAM,EAAE,GAAG,CAAC,CAAC,SAAS,IAAI,MAAM,CAAC,gBAAgB,CAAA;gBACjD,IAAI,EAAE,KAAK,EAAE;oBAAE,OAAO,EAAE,GAAG,EAAE,CAAA;gBAC7B,OAAO,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC,CAAA;YACnD,CAAC,CAAC,CAAA;YAEF,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,SAAS,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;gBAC1C,IAAI,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,KAAK,CAAC,EAAE,CAAC;oBACjC,MAAM,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,EAAG,EAAE,EAAE,EAAE,SAAS,EAAE,CAAC,EAAE,CAAC,CAAA;oBAC7D,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,GAAG,CAAC,CAAA;gBAC5B,CAAC;YACH,CAAC;YAED,yBAAyB;YACzB,IAAI,MAAM,EAAE,CAAC;gBACX,IAAI,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,MAAM,CAAC,EAAE,SAAS,IAAI,IAAI,CAAA;YAChE,CAAC;YACD,IAAI,MAAM,EAAE,CAAC;gBACX,IAAI,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,MAAM,CAAC,EAAE,SAAS,IAAI,IAAI,CAAA;YAChE,CAAC;iBAAM,CAAC;gBACN,IAAI,GAAG,IAAI,GAAG,CAAC,CAAA;YACjB,CAAC;QACH,CAAC;QAED,MAAM,SAAS,GAAG,eAAe,CAAC,IAAI,EAAE,IAAI,CAAC,CAAA;QAE7C,MAAM,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,EAAE,SAAS,EAAE,CAAC,CAAA;QAClE,OAAO,IAAI,CAAA;IACb,CAAC;CACF,CAAA;AAzgBY,sCAAa;AAIlB;IAHL,IAAA,wBAAS,EAAC,cAAc,CAAC;IACzB,IAAA,wBAAS,EAAC,gFAAgF,CAAC;IAC3F,IAAA,uBAAQ,EAAC,OAAO,CAAC,EAAE,CAAC,gBAAK,EAAE,EAAE,WAAW,EAAE,sBAAsB,EAAE,CAAC;IACjD,mBAAA,IAAA,kBAAG,EAAC,OAAO,CAAC,CAAA;IAAmB,mBAAA,IAAA,kBAAG,GAAE,CAAA;;6CAAhB,wBAAQ;;gDA2D9C;AAOK;IALL,IAAA,wBAAS,EAAC,cAAc,CAAC;IACzB,IAAA,wBAAS,EAAC,gFAAgF,CAAC;IAC3F,IAAA,uBAAQ,EAAC,OAAO,CAAC,EAAE,CAAC,gBAAK,EAAE;QAC1B,WAAW,EAAE,2EAA2E;KACzF,CAAC;IAEC,mBAAA,IAAA,kBAAG,EAAC,IAAI,CAAC,CAAA;IACT,mBAAA,IAAA,kBAAG,EAAC,OAAO,CAAC,CAAA;IACZ,mBAAA,IAAA,kBAAG,EAAC,iBAAiB,CAAC,CAAA;IACtB,mBAAA,IAAA,kBAAG,EAAC,eAAe,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;IACxC,mBAAA,IAAA,kBAAG,GAAE,CAAA;;qDAHe,0BAAU;;+CA4DhC;AAQK;IANL,IAAA,wBAAS,EAAC,cAAc,CAAC;IACzB,IAAA,wBAAS,EAAC,gFAAgF,CAAC;IAC3F,IAAA,4BAAa,EAAC,qBAAa,CAAC;IAC5B,IAAA,uBAAQ,EAAC,OAAO,CAAC,EAAE,CAAC,gBAAK,EAAE;QAC1B,WAAW,EAAE,6EAA6E;KAC3F,CAAC;IAEC,mBAAA,IAAA,kBAAG,EAAC,IAAI,CAAC,CAAA;IACT,mBAAA,IAAA,kBAAG,EAAC,OAAO,CAAC,CAAA;IACZ,mBAAA,IAAA,kBAAG,GAAE,CAAA;;qDADe,0BAAU;;gDAyFhC;AAKK;IAHL,IAAA,wBAAS,EAAC,cAAc,CAAC;IACzB,IAAA,wBAAS,EAAC,gFAAgF,CAAC;IAC3F,IAAA,uBAAQ,EAAC,OAAO,CAAC,EAAE,CAAC,gBAAK,EAAE,EAAE,WAAW,EAAE,oEAAoE,EAAE,CAAC;IAE/G,mBAAA,IAAA,kBAAG,EAAC,IAAI,CAAC,CAAA;IACT,mBAAA,IAAA,kBAAG,GAAE,CAAA;IACL,mBAAA,IAAA,kBAAG,EAAC,SAAS,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;;;;iDAgDpC;AAKK;IAHL,IAAA,wBAAS,EAAC,cAAc,CAAC;IACzB,IAAA,wBAAS,EAAC,gFAAgF,CAAC;IAC3F,IAAA,uBAAQ,EAAC,OAAO,CAAC,EAAE,CAAC,gBAAK,EAAE,EAAE,WAAW,EAAE,mDAAmD,EAAE,CAAC;IAE9F,mBAAA,IAAA,kBAAG,EAAC,IAAI,CAAC,CAAA;IACT,mBAAA,IAAA,kBAAG,EAAC,SAAS,CAAC,CAAA;IACd,mBAAA,IAAA,kBAAG,GAAE,CAAA;;;;uDA4CP;AAKK;IAHL,IAAA,wBAAS,EAAC,cAAc,CAAC;IACzB,IAAA,wBAAS,EAAC,gFAAgF,CAAC;IAC3F,IAAA,uBAAQ,EAAC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,WAAW,EAAE,kBAAkB,EAAE,CAAC;IAC/C,mBAAA,IAAA,kBAAG,EAAC,IAAI,CAAC,CAAA;IAAc,mBAAA,IAAA,kBAAG,GAAE,CAAA;;;;gDAe9C;AAKK;IAHL,IAAA,wBAAS,EAAC,cAAc,CAAC;IACzB,IAAA,wBAAS,EAAC,gFAAgF,CAAC;IAC3F,IAAA,uBAAQ,EAAC,OAAO,CAAC,EAAE,CAAC,CAAC,gBAAK,CAAC,EAAE,EAAE,WAAW,EAAE,0CAA0C,EAAE,CAAC;IAEvF,mBAAA,IAAA,kBAAG,EAAC,SAAS,CAAC,CAAA;IACd,mBAAA,IAAA,kBAAG,EAAC,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC,0BAAa,CAAC,CAAC,CAAA;IACnC,mBAAA,IAAA,kBAAG,EAAC,WAAW,CAAC,CAAA;IAChB,mBAAA,IAAA,kBAAG,GAAE,CAAA;;;;iDAoFP;AAKK;IAHL,IAAA,uBAAQ,EAAC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,WAAW,EAAE,qDAAqD,EAAE,CAAC;IACpG,IAAA,wBAAS,EAAC,sDAAsD,CAAC;IACjE,IAAA,wBAAS,EAAC,cAAc,CAAC;IAEvB,mBAAA,IAAA,kBAAG,EAAC,IAAI,CAAC,CAAA;IACT,mBAAA,IAAA,kBAAG,EAAC,QAAQ,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;IACjC,mBAAA,IAAA,kBAAG,EAAC,QAAQ,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;IACjC,mBAAA,IAAA,kBAAG,GAAE,CAAA;;;;iDA2DP;wBAxgBU,aAAa;IADzB,IAAA,uBAAQ,EAAC,gBAAK,CAAC;GACH,aAAa,CAygBzB","sourcesContent":["import { Arg, Ctx, Mutation, Resolver, Directive, UseMiddleware } from 'type-graphql'\nimport { In } from 'typeorm'\nimport type { FileUpload } from 'graphql-upload/GraphQLUpload.js'\nimport GraphQLUpload from 'graphql-upload/GraphQLUpload.js'\nimport { Domain, getRedirectSubdomainPath, getRepository, Refusal, SpeakRefusals } from '@things-factory/shell'\n\nimport { Group } from '../group/group.js'\nimport { Board } from './board.js'\nimport { BoardHistory } from './board-history.js'\nimport { BoardPatch, NewBoard } from './board-type.js'\nimport { scheduleThumbnail } from './thumbnail-scheduler.js'\nimport { claimUnchangedSince } from './board-claim.js'\n\n/**\n * prev와 next 사이에서 자릿수가 최소인 중간값을 찾는다.\n * (prev, next) open interval에서 가장 깔끔한 숫자를 선택.\n */\nfunction optimalMidValue(prev: number, next: number): number {\n if (prev >= next) return prev - 1\n\n const mid = (prev + next) / 2\n for (let p = 0; p <= 10; p++) {\n const scale = 10 ** p\n const low = Math.floor(prev * scale) + 1\n const high = Math.ceil(next * scale) - 1\n if (low <= high) {\n return Math.min(Math.max(Math.round(mid * scale), low), high) / scale\n }\n }\n return mid\n}\n\nasync function parseJSONFile(uploadedFile: FileUpload): Promise<any> {\n var { createReadStream } = await uploadedFile\n\n return new Promise((resolve, reject) => {\n const chunks: Uint8Array[] = []\n\n createReadStream()\n .on('data', (chunk: Uint8Array) => {\n chunks.push(chunk)\n })\n .on('end', () => {\n try {\n const fileContents = Buffer.concat(chunks).toString('utf-8')\n const jsonData = JSON.parse(fileContents)\n resolve(jsonData)\n } catch (error) {\n reject(error)\n }\n })\n .on('error', (error: Error) => {\n reject(error)\n })\n })\n}\n\n@Resolver(Board)\nexport class BoardMutation {\n @Directive('@transaction')\n @Directive('@privilege(category: \"board\", privilege: \"mutation\", domainOwnerGranted: true)')\n @Mutation(returns => Board, { description: 'Creates a new board.' })\n async createBoard(@Arg('board') board: NewBoard, @Ctx() context: ResolverContext): Promise<Board> {\n const { domain, user, notify, tx } = context.state\n const repository = getRepository(Board, tx)\n const groupRepository = getRepository(Group, tx)\n\n const oldBoard: Board = await repository.findOneBy({\n name: board.name,\n domain: { id: domain.id }\n })\n\n if (oldBoard) {\n throw new Error(context.t('error.board name is already taken', { name: board.name }))\n }\n\n const newBoard: Board = {\n ...board\n }\n\n newBoard.thumbnail ||=\n 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==' /* empty thumbnail */\n\n if (board.groupId) {\n newBoard.group = await groupRepository.findOneBy({\n id: board.groupId\n })\n }\n\n // 새 보드는 맨 앞에 배치\n const { minSortOrder } = (await repository\n .createQueryBuilder('board')\n .select('MIN(board.sortOrder)', 'minSortOrder')\n .where('board.domain = :domainId', { domainId: domain.id })\n .getRawOne()) || { minSortOrder: null }\n\n const created = await repository.save({\n domain,\n ...newBoard,\n sortOrder: minSortOrder != null ? minSortOrder - 1 : 0,\n state: 'draft',\n creator: user,\n updater: user\n })\n\n // 새 보드의 thumbnail 도 백그라운드로 생성 — mutation 응답 즉시 반환.\n // 클라이언트가 thumbnail 을 보낸 경우 (NewBoard.thumbnail 있음) 는 그것을 사용\n // 하고 puppeteer 생략. 그게 빈 gif 인 경우만 실제 캡처.\n if (newBoard.model && (!board.thumbnail || board.thumbnail.startsWith('data:image/gif'))) {\n scheduleThumbnail(created.id, newBoard.model, context)\n }\n\n notify &&\n notify({\n mode: 'in-app',\n title: `Board '${created.name}' created`,\n body: `Board '${created.name}' created by ${user.name}\\n${created.description}`,\n url: getRedirectSubdomainPath(context, domain, `/board-viewer/${created.id}`)\n })\n\n return created\n }\n\n @Directive('@transaction')\n @Directive('@privilege(category: \"board\", privilege: \"mutation\", domainOwnerGranted: true)')\n @Mutation(returns => Board, {\n description: 'Clones a board from an existing one, potentially into a different domain.'\n })\n async cloneBoard(\n @Arg('id') id: string,\n @Arg('patch') patch: BoardPatch,\n @Arg('targetSubdomain') targetSubdomain: string,\n @Arg('targetGroupId', { nullable: true }) targetGroupId: string,\n @Ctx() context: ResolverContext\n ): Promise<Board> {\n const { domain, user, notify, tx } = context.state\n const { t } = context\n const repository = getRepository(Board, tx)\n\n const board = await repository.findOneBy({ domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id })\n\n if (!patch.name || (patch.name == board.name && targetSubdomain == domain.subdomain)) {\n throw new Error(t('error.name must be unique from the original board', { name: patch.name }))\n }\n\n if (targetSubdomain != domain.subdomain) {\n if ((await repository.count({ where: { domain: { subdomain: targetSubdomain }, name: patch.name } })) > 0) {\n throw new Error(t('error.name must be unique from the original board', { name: patch.name }))\n }\n }\n\n var targetDomain = domain\n if (targetDomain && domain.subdomain != targetSubdomain) {\n targetDomain = await getRepository(Domain, tx).findOneBy({ subdomain: targetSubdomain })\n if (!targetDomain) {\n throw new Error(`given subdomain(${targetSubdomain}) not found`)\n }\n }\n\n var targetGroup = null\n if (targetGroupId) {\n targetGroup = await getRepository(Group, tx).findOneBy({ domain: { id: targetDomain.id }, id: targetGroupId })\n if (!targetGroup) {\n throw new Error(`given group(${targetGroupId}) in domain(${targetSubdomain}) not found`)\n }\n }\n\n const { id: excluded, ...clone } = board\n\n const cloned = await repository.save({\n domain: targetDomain,\n ...clone,\n ...patch,\n group: targetGroup,\n version: 0,\n state: 'draft',\n updater: user,\n creator: user\n })\n\n notify &&\n notify({\n mode: 'in-app',\n title: `Board '${cloned.name}' cloned`,\n body: `Board '${cloned.name}' cloned by ${user.name}\\n${cloned.description}`,\n image: getRedirectSubdomainPath(context, targetDomain, `/thumbnail/${cloned.id}`),\n url: getRedirectSubdomainPath(context, targetDomain, `/board-viewer/${cloned.id}`)\n })\n\n return cloned\n }\n\n @Directive('@transaction')\n @Directive('@privilege(category: \"board\", privilege: \"mutation\", domainOwnerGranted: true)')\n @UseMiddleware(SpeakRefusals)\n @Mutation(returns => Board, {\n description: 'Updates a board. If a model is provided, it also generates a new thumbnail.'\n })\n async updateBoard(\n @Arg('id') id: string,\n @Arg('patch') patch: BoardPatch,\n @Ctx() context: ResolverContext\n ): Promise<Board> {\n const { domain, user, notify, tx } = context.state\n const repository = getRepository(Board, tx)\n\n const board = await repository.findOne({\n where: { domain: { id: domain.id }, id },\n relations: ['creator']\n })\n\n if (!board) {\n throw new Error('board not found')\n }\n\n /*\n * Saved over since the editor read it? Checked and claimed in one statement (board-claim.ts) - a read,\n * a comparison and then a save would let two tabs through the same gap (ADR-0098, architect ruling).\n */\n if (patch.expectedUpdatedAt) {\n const claimed = await claimUnchangedSince(tx, domain.id, id, new Date(patch.expectedUpdatedAt))\n if (!claimed)\n throw new Refusal(\n 'board-changed-since',\n { name: board.name },\n 'this board was saved somewhere else after you opened it. open it again and then save.'\n )\n }\n\n if (patch.model) {\n // 데이터 무결성 — model JSON 의 syntax 검증. invalid JSON 영속 차단.\n // (의미 검증 [schema] 은 별도 layer — board-ai 의 tool 단계에서 수행.)\n try {\n const parsed = JSON.parse(patch.model)\n if (!parsed || typeof parsed !== 'object') {\n throw new Error('Board model must be a JSON object')\n }\n if (typeof parsed.width !== 'number' || typeof parsed.height !== 'number') {\n throw new Error('Board model must have numeric width and height')\n }\n if (parsed.components !== undefined && !Array.isArray(parsed.components)) {\n throw new Error('Board model components must be an array if present')\n }\n } catch (e: any) {\n // 명시 에러 — silent 데이터 손상보다 명확한 reject 선호\n throw new Error(`Invalid board model: ${e.message}`)\n }\n\n // Thumbnail 생성은 fire-and-forget 백그라운드. mutation 응답이 puppeteer 완료\n // (또는 풀 timeout) 에 차단되지 않도록 분리. 빈번한 저장 시 per-board debounce\n // + model hash dedup 으로 puppeteer 호출 횟수 자체를 줄인다. 자세한 동작은\n // ./thumbnail-scheduler.ts 참고.\n //\n // patch.thumbnail 은 set 하지 않는다 — 클라이언트가 thumbnail 을 만들 수 없으므로\n // (권한 분리 / 렌더 결정론 / 무인 트리거 / ZPL 정확성), 백엔드만 생성한다. 새\n // thumbnail 이 만들어지기 전까지는 board.thumbnail 의 기존 값이 유지된다.\n scheduleThumbnail(id, patch.model, context)\n }\n\n const { groupId, expectedUpdatedAt, ...patched } = patch\n\n if (groupId !== undefined) {\n const groupRepository = getRepository(Group, tx)\n board.group = groupId\n ? (await groupRepository.findOneBy({\n domain: { id: domain.id },\n id: groupId\n })) || null\n : null\n }\n\n const updated = await repository.save({\n domain,\n ...board,\n ...patched,\n state: 'draft',\n updater: user\n })\n\n notify &&\n notify({\n mode: 'in-app',\n title: `Board '${updated.name}' updated`,\n body: `Board '${updated.name}' updated by ${user.name}\\n${updated.description}`,\n image: getRedirectSubdomainPath(context, domain, `/thumbnail/${updated.id}`),\n url: getRedirectSubdomainPath(context, domain, `/board-viewer/${updated.id}`)\n })\n\n return updated\n }\n\n @Directive('@transaction')\n @Directive('@privilege(category: \"board\", privilege: \"mutation\", domainOwnerGranted: true)')\n @Mutation(returns => Board, { description: 'Releases a board, making it public and creating a version history.' })\n async releaseBoard(\n @Arg('id') id: string,\n @Ctx() context: ResolverContext,\n @Arg('comment', { nullable: true }) comment?: string\n ): Promise<Board> {\n const { domain, user, notify, tx } = context.state\n const repository = getRepository(Board, tx)\n const historyRepository = getRepository(BoardHistory, tx)\n\n const board = await repository.findOne({\n where: { domain: { id: domain.id }, id },\n relations: ['creator']\n })\n\n if (!board) {\n throw new Error(`Board given id(${id}) is not found`)\n }\n\n if (board.state == 'released') {\n throw new Error(`Board given id(${id}) is already released`)\n }\n\n // 히스토리에서 max version 가져오기\n const maxHistory = await historyRepository\n .createQueryBuilder('history')\n .select('MAX(history.version)', 'max')\n .where('history.originalId = :id', { id: board.id })\n .getRawOne()\n\n const nextVersion = maxHistory?.max ? Number(maxHistory.max) + 1 : 1\n\n const updated = await repository.save({\n domain,\n ...board,\n version: nextVersion,\n state: 'released',\n updater: user,\n // BoardHistoryEntitySubscriber 가 스냅샷 생성 시 history.comment 로 주입(Board 컬럼 아님).\n __releaseComment: comment ?? null\n } as any)\n\n notify &&\n notify({\n mode: 'in-app',\n title: `Board '${updated.name}' released`,\n body: `Board '${updated.name}' released by ${user.name}\\n${updated.description}`,\n image: getRedirectSubdomainPath(context, domain, `/thumbnail/${updated.id}`),\n url: getRedirectSubdomainPath(context, domain, `/board-viewer/${updated.id}`)\n })\n\n return updated\n }\n\n @Directive('@transaction')\n @Directive('@privilege(category: \"board\", privilege: \"mutation\", domainOwnerGranted: true)')\n @Mutation(returns => Board, { description: 'Reverts a board to a specific historical version.' })\n async revertBoardVersion(\n @Arg('id') id: string,\n @Arg('version') version: number,\n @Ctx() context: ResolverContext\n ): Promise<Board> {\n const { domain, user, notify, tx } = context.state\n const repository = getRepository(Board, tx)\n\n const board = await repository.findOne({\n where: { domain: { id: domain.id }, id },\n relations: ['creator']\n })\n\n if (!board) {\n throw new Error(`Board with id(${id}) is not found`)\n }\n\n const historyRepository = getRepository(BoardHistory, tx)\n\n const boardHistory = await historyRepository.findOne({\n where: { domain: { id: domain.id }, originalId: id, version },\n order: { version: 'DESC' }\n })\n\n if (!boardHistory) {\n throw new Error(`Board with id:version(${id}:${version}) is not found`)\n }\n\n const updated = await repository.save({\n domain,\n ...board,\n model: boardHistory.model,\n thumbnail: boardHistory.thumbnail,\n state: 'draft',\n updater: user\n })\n\n notify &&\n notify({\n mode: 'in-app',\n title: `Board '${updated.name}' updated`,\n body: `Board '${updated.name}' updated by ${user.name}\\n${updated.description}`,\n image: getRedirectSubdomainPath(context, domain, `/thumbnail/${updated.id}`),\n url: getRedirectSubdomainPath(context, domain, `/board-viewer/${updated.id}`)\n })\n\n return updated\n }\n\n @Directive('@transaction')\n @Directive('@privilege(category: \"board\", privilege: \"mutation\", domainOwnerGranted: true)')\n @Mutation(returns => Boolean, { description: 'Deletes a board.' })\n async deleteBoard(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<boolean> {\n const { domain, user, notify, tx } = context.state\n const repository = getRepository(Board, tx)\n const board = await repository.findOneBy({ domain: { id: domain.id }, id })\n\n const deleted = await repository.softDelete(id)\n\n notify &&\n notify({\n mode: 'in-app',\n title: `Board '${board.name}' deleted`,\n body: `Board '${board.name}' deleted by ${user.name}\\n${board.description}`\n })\n\n return true\n }\n\n @Directive('@transaction')\n @Directive('@privilege(category: \"board\", privilege: \"mutation\", domainOwnerGranted: true)')\n @Mutation(returns => [Board], { description: 'Imports multiple boards from JSON files.' })\n async importBoards(\n @Arg('groupId') groupId: string,\n @Arg('files', () => [GraphQLUpload]) files: FileUpload[],\n @Arg('overwrite') overwrite: boolean,\n @Ctx() context: ResolverContext\n ): Promise<Board[]> {\n const { domain, user, notify, tx } = context.state\n const groupRepository = getRepository(Group, tx)\n const boardRepository = getRepository(Board, tx)\n const group = await groupRepository.findOneBy({ domain: { id: domain.id }, id: groupId })\n\n if (!group) {\n throw new Error(`Group with id(${groupId}) is not found`)\n }\n\n const boards = []\n\n for (const file of files) {\n const { id, name, description, model, thumbnail } = await parseJSONFile(file)\n\n var sameNameBoard = await boardRepository.findOneBy({ domain: { id: domain.id }, name })\n var sameIdBoard = await boardRepository.findOneBy({ id })\n\n if (overwrite) {\n var board = {} as any\n\n if (sameIdBoard) {\n if (overwrite && sameIdBoard.domainId != domain.id) {\n throw new Error(`Board with id(${id}) is already taken in another domain`)\n }\n\n board = {\n ...sameIdBoard,\n name\n }\n\n if (sameNameBoard && sameIdBoard.id != sameNameBoard.id) {\n /* 이름 충돌 회피 */\n board.name = `${board.name}(${Date.now()})`\n }\n } else {\n board = {\n id,\n name: sameNameBoard ? `${name}(${Date.now()})` : name,\n version: 0,\n creator: user\n }\n }\n } else {\n board = {\n name,\n version: 0,\n creator: user\n }\n\n /* ID가 없으면, 사용해도 됨 */\n if (!sameIdBoard) {\n board.id = id\n }\n\n /* 이름 충돌 회피 */\n if (sameNameBoard) {\n board.name = `${board.name}(${Date.now()})`\n }\n }\n\n boards.push(\n await boardRepository.save({\n ...board,\n domain,\n description,\n model: typeof model != 'string' ? JSON.stringify(model) : model,\n thumbnail,\n group,\n state: 'draft',\n updater: user\n })\n )\n }\n\n notify &&\n notify({\n mode: 'in-app',\n title: `${boards.length} Board(s) are imported`,\n body: `${boards.length} Board(s) are imported into group ${group.name} by ${user.name}`\n })\n\n return boards\n }\n\n @Mutation(returns => Boolean, { description: 'Reorders a board between two adjacent boards by ID.' })\n @Directive('@privilege(category: \"board\", privilege: \"mutation\")')\n @Directive('@transaction')\n async reorderBoard(\n @Arg('id') id: string,\n @Arg('prevId', { nullable: true }) prevId: string,\n @Arg('nextId', { nullable: true }) nextId: string,\n @Ctx() context: ResolverContext\n ): Promise<boolean> {\n const { domain, tx } = context.state\n const repo = tx.getRepository(Board)\n const domainId = domain.id\n\n // prev/next의 sortOrder 조회\n let prevOrder: number | null = null\n let nextOrder: number | null = null\n\n if (prevId) {\n const prev = await repo.findOne({ where: { id: prevId, domain: { id: domainId } } as any })\n prevOrder = prev?.sortOrder ?? null\n }\n if (nextId) {\n const next = await repo.findOne({ where: { id: nextId, domain: { id: domainId } } as any })\n nextOrder = next?.sortOrder ?? null\n }\n\n // sortOrder 계산\n let prev = prevOrder ?? (nextOrder != null ? nextOrder - 2 : 0)\n let next = nextOrder ?? prev + 2\n\n // prev와 next의 간격이 너무 좁으면 전체 보드 sortOrder 정규화\n if (next - prev < 0.001) {\n const allBoards = await repo.find({\n where: { domain: { id: domainId } } as any\n })\n\n // DB 독립적인 정렬 (NULL → 맨 뒤, sortOrder ASC, name ASC)\n allBoards.sort((a, b) => {\n const sa = a.sortOrder ?? Number.MAX_SAFE_INTEGER\n const sb = b.sortOrder ?? Number.MAX_SAFE_INTEGER\n if (sa !== sb) return sa - sb\n return (a.name || '').localeCompare(b.name || '')\n })\n\n for (let i = 0; i < allBoards.length; i++) {\n if (allBoards[i].sortOrder !== i) {\n await repo.update({ id: allBoards[i].id! }, { sortOrder: i })\n allBoards[i].sortOrder = i\n }\n }\n\n // 정규화된 값으로 prev/next 재조회\n if (prevId) {\n prev = allBoards.find(b => b.id === prevId)?.sortOrder ?? prev\n }\n if (nextId) {\n next = allBoards.find(b => b.id === nextId)?.sortOrder ?? next\n } else {\n next = prev + 2\n }\n }\n\n const sortOrder = optimalMidValue(prev, next)\n\n await repo.update({ id, domain: { id: domainId } }, { sortOrder })\n return true\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"board-mutation.js","sourceRoot":"","sources":["../../../server/service/board/board-mutation.ts"],"names":[],"mappings":";;;;AAAA,+CAAqF;AACrF,qCAA4B;AAE5B,+FAA2D;AAC3D,iDAA+G;AAE/G,gDAAyC;AACzC,yCAAkC;AAClC,yDAAiD;AACjD,mDAAsD;AACtD,qEAAsG;AACtG,qDAAsD;AAEtD;;;GAGG;AACH,SAAS,eAAe,CAAC,IAAY,EAAE,IAAY;IACjD,IAAI,IAAI,IAAI,IAAI;QAAE,OAAO,IAAI,GAAG,CAAC,CAAA;IAEjC,MAAM,GAAG,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,CAAA;IAC7B,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC;QAC7B,MAAM,KAAK,GAAG,EAAE,IAAI,CAAC,CAAA;QACrB,MAAM,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,GAAG,KAAK,CAAC,GAAG,CAAC,CAAA;QACxC,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,IAAI,GAAG,KAAK,CAAC,GAAG,CAAC,CAAA;QACxC,IAAI,GAAG,IAAI,IAAI,EAAE,CAAC;YAChB,OAAO,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,GAAG,GAAG,KAAK,CAAC,EAAE,GAAG,CAAC,EAAE,IAAI,CAAC,GAAG,KAAK,CAAA;QACvE,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAA;AACZ,CAAC;AAED,KAAK,UAAU,aAAa,CAAC,YAAwB;IACnD,IAAI,EAAE,gBAAgB,EAAE,GAAG,MAAM,YAAY,CAAA;IAE7C,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,MAAM,GAAiB,EAAE,CAAA;QAE/B,gBAAgB,EAAE;aACf,EAAE,CAAC,MAAM,EAAE,CAAC,KAAiB,EAAE,EAAE;YAChC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAA;QACpB,CAAC,CAAC;aACD,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE;YACd,IAAI,CAAC;gBACH,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAA;gBAC5D,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,CAAA;gBACzC,OAAO,CAAC,QAAQ,CAAC,CAAA;YACnB,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,MAAM,CAAC,KAAK,CAAC,CAAA;YACf,CAAC;QACH,CAAC,CAAC;aACD,EAAE,CAAC,OAAO,EAAE,CAAC,KAAY,EAAE,EAAE;YAC5B,MAAM,CAAC,KAAK,CAAC,CAAA;QACf,CAAC,CAAC,CAAA;IACN,CAAC,CAAC,CAAA;AACJ,CAAC;AAGM,IAAM,aAAa,GAAnB,MAAM,aAAa;IAIlB,AAAN,KAAK,CAAC,WAAW,CAAe,KAAe,EAAS,OAAwB;QAC9E,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;QAClD,MAAM,UAAU,GAAG,IAAA,qBAAa,EAAC,gBAAK,EAAE,EAAE,CAAC,CAAA;QAC3C,MAAM,eAAe,GAAG,IAAA,qBAAa,EAAC,gBAAK,EAAE,EAAE,CAAC,CAAA;QAEhD,MAAM,QAAQ,GAAU,MAAM,UAAU,CAAC,SAAS,CAAC;YACjD,IAAI,EAAE,KAAK,CAAC,IAAI;YAChB,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE;SAC1B,CAAC,CAAA;QAEF,IAAI,QAAQ,EAAE,CAAC;YACb,MAAM,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,mCAAmC,EAAE,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAA;QACvF,CAAC;QAED,MAAM,QAAQ,GAAU;YACtB,GAAG,KAAK;SACT,CAAA;QAED,IAAI,IAAA,0CAAiB,EAAC,KAAK,CAAC,SAAS,CAAC,EAAE,CAAC;YACvC,QAAQ,CAAC,kBAAkB,GAAG,IAAI,IAAI,EAAE,CAAA;QAC1C,CAAC;aAAM,CAAC;YACN,QAAQ,CAAC,SAAS,GAAG,4EAA4E,CAAA,CAAC,qBAAqB;QACzH,CAAC;QAED,IAAI,KAAK,CAAC,OAAO,EAAE,CAAC;YAClB,QAAQ,CAAC,KAAK,GAAG,MAAM,eAAe,CAAC,SAAS,CAAC;gBAC/C,EAAE,EAAE,KAAK,CAAC,OAAO;aAClB,CAAC,CAAA;QACJ,CAAC;QAED,gBAAgB;QAChB,MAAM,EAAE,YAAY,EAAE,GAAG,CAAC,MAAM,UAAU;aACvC,kBAAkB,CAAC,OAAO,CAAC;aAC3B,MAAM,CAAC,sBAAsB,EAAE,cAAc,CAAC;aAC9C,KAAK,CAAC,0BAA0B,EAAE,EAAE,QAAQ,EAAE,MAAM,CAAC,EAAE,EAAE,CAAC;aAC1D,SAAS,EAAE,CAAC,IAAI,EAAE,YAAY,EAAE,IAAI,EAAE,CAAA;QAEzC,MAAM,OAAO,GAAG,MAAM,UAAU,CAAC,IAAI,CAAC;YACpC,MAAM;YACN,GAAG,QAAQ;YACX,SAAS,EAAE,YAAY,IAAI,IAAI,CAAC,CAAC,CAAC,YAAY,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;YACtD,KAAK,EAAE,OAAO;YACd,OAAO,EAAE,IAAI;YACb,OAAO,EAAE,IAAI;SACd,CAAC,CAAA;QAEF,mDAAmD;QACnD,mDAAmD;QACnD,IAAI,QAAQ,CAAC,KAAK,IAAI,CAAC,IAAA,0CAAiB,EAAC,KAAK,CAAC,SAAS,CAAC,EAAE,CAAC;YAC1D,IAAA,0CAAiB,EAAC,OAAO,CAAC,EAAE,EAAE,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC,CAAA;QACxD,CAAC;QAED,MAAM;YACJ,MAAM,CAAC;gBACL,IAAI,EAAE,QAAQ;gBACd,KAAK,EAAE,UAAU,OAAO,CAAC,IAAI,WAAW;gBACxC,IAAI,EAAE,UAAU,OAAO,CAAC,IAAI,gBAAgB,IAAI,CAAC,IAAI,KAAK,OAAO,CAAC,WAAW,EAAE;gBAC/E,GAAG,EAAE,IAAA,gCAAwB,EAAC,OAAO,EAAE,MAAM,EAAE,iBAAiB,OAAO,CAAC,EAAE,EAAE,CAAC;aAC9E,CAAC,CAAA;QAEJ,OAAO,OAAO,CAAA;IAChB,CAAC;IAOK,AAAN,KAAK,CAAC,UAAU,CACH,EAAU,EACP,KAAiB,EACP,eAAuB,EACL,aAAqB,EACxD,OAAwB;QAE/B,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;QAClD,MAAM,EAAE,CAAC,EAAE,GAAG,OAAO,CAAA;QACrB,MAAM,UAAU,GAAG,IAAA,qBAAa,EAAC,gBAAK,EAAE,EAAE,CAAC,CAAA;QAE3C,MAAM,KAAK,GAAG,MAAM,UAAU,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,IAAA,YAAE,EAAC,CAAC,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC,CAAA;QAElH,IAAI,CAAC,KAAK,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,IAAI,KAAK,CAAC,IAAI,IAAI,eAAe,IAAI,MAAM,CAAC,SAAS,CAAC,EAAE,CAAC;YACrF,MAAM,IAAI,KAAK,CAAC,CAAC,CAAC,mDAAmD,EAAE,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAA;QAC/F,CAAC;QAED,IAAI,eAAe,IAAI,MAAM,CAAC,SAAS,EAAE,CAAC;YACxC,IAAI,CAAC,MAAM,UAAU,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,SAAS,EAAE,eAAe,EAAE,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC;gBAC1G,MAAM,IAAI,KAAK,CAAC,CAAC,CAAC,mDAAmD,EAAE,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAA;YAC/F,CAAC;QACH,CAAC;QAED,IAAI,YAAY,GAAG,MAAM,CAAA;QACzB,IAAI,YAAY,IAAI,MAAM,CAAC,SAAS,IAAI,eAAe,EAAE,CAAC;YACxD,YAAY,GAAG,MAAM,IAAA,qBAAa,EAAC,cAAM,EAAE,EAAE,CAAC,CAAC,SAAS,CAAC,EAAE,SAAS,EAAE,eAAe,EAAE,CAAC,CAAA;YACxF,IAAI,CAAC,YAAY,EAAE,CAAC;gBAClB,MAAM,IAAI,KAAK,CAAC,mBAAmB,eAAe,aAAa,CAAC,CAAA;YAClE,CAAC;QACH,CAAC;QAED,IAAI,WAAW,GAAG,IAAI,CAAA;QACtB,IAAI,aAAa,EAAE,CAAC;YAClB,WAAW,GAAG,MAAM,IAAA,qBAAa,EAAC,gBAAK,EAAE,EAAE,CAAC,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,YAAY,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,aAAa,EAAE,CAAC,CAAA;YAC9G,IAAI,CAAC,WAAW,EAAE,CAAC;gBACjB,MAAM,IAAI,KAAK,CAAC,eAAe,aAAa,eAAe,eAAe,aAAa,CAAC,CAAA;YAC1F,CAAC;QACH,CAAC;QAED,MAAM,EAAE,EAAE,EAAE,QAAQ,EAAE,GAAG,KAAK,EAAE,GAAG,KAAK,CAAA;QAExC,MAAM,MAAM,GAAG,MAAM,UAAU,CAAC,IAAI,CAAC;YACnC,MAAM,EAAE,YAAY;YACpB,GAAG,KAAK;YACR,GAAG,KAAK;YACR,KAAK,EAAE,WAAW;YAClB,OAAO,EAAE,CAAC;YACV,KAAK,EAAE,OAAO;YACd,OAAO,EAAE,IAAI;YACb,OAAO,EAAE,IAAI;SACd,CAAC,CAAA;QAEF,MAAM;YACJ,MAAM,CAAC;gBACL,IAAI,EAAE,QAAQ;gBACd,KAAK,EAAE,UAAU,MAAM,CAAC,IAAI,UAAU;gBACtC,IAAI,EAAE,UAAU,MAAM,CAAC,IAAI,eAAe,IAAI,CAAC,IAAI,KAAK,MAAM,CAAC,WAAW,EAAE;gBAC5E,KAAK,EAAE,IAAA,gCAAwB,EAAC,OAAO,EAAE,YAAY,EAAE,cAAc,MAAM,CAAC,EAAE,EAAE,CAAC;gBACjF,GAAG,EAAE,IAAA,gCAAwB,EAAC,OAAO,EAAE,YAAY,EAAE,iBAAiB,MAAM,CAAC,EAAE,EAAE,CAAC;aACnF,CAAC,CAAA;QAEJ,OAAO,MAAM,CAAA;IACf,CAAC;IAQK,AAAN,KAAK,CAAC,WAAW,CACJ,EAAU,EACP,KAAiB,EACxB,OAAwB;QAE/B,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;QAClD,MAAM,UAAU,GAAG,IAAA,qBAAa,EAAC,gBAAK,EAAE,EAAE,CAAC,CAAA;QAE3C,MAAM,KAAK,GAAG,MAAM,UAAU,CAAC,OAAO,CAAC;YACrC,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE;YACxC,SAAS,EAAE,CAAC,SAAS,CAAC;SACvB,CAAC,CAAA;QAEF,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,MAAM,IAAI,KAAK,CAAC,iBAAiB,CAAC,CAAA;QACpC,CAAC;QAED;;;WAGG;QACH,IAAI,KAAK,CAAC,iBAAiB,EAAE,CAAC;YAC5B,MAAM,OAAO,GAAG,MAAM,IAAA,oCAAmB,EAAC,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,IAAI,IAAI,CAAC,KAAK,CAAC,iBAAiB,CAAC,CAAC,CAAA;YAC/F,IAAI,CAAC,OAAO;gBACV,MAAM,IAAI,eAAO,CACf,qBAAqB,EACrB,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,EAAE,EACpB,uFAAuF,CACxF,CAAA;QACL,CAAC;QAED,MAAM,EAAE,OAAO,EAAE,iBAAiB,EAAE,GAAG,OAAO,EAAE,GAAG,KAAuD,CAAA;QAE1G,IAAI,IAAA,0CAAiB,EAAC,KAAK,CAAC,SAAS,CAAC,EAAE,CAAC;YACvC,iEAAiE;YACjE,IAAA,8CAAqB,EAAC,EAAE,CAAC,CAAA;YACzB,OAAO,CAAC,kBAAkB,GAAG,IAAI,IAAI,EAAE,CAAA;QACzC,CAAC;aAAM,CAAC;YACN,OAAO,OAAO,CAAC,SAAS,CAAA;QAC1B,CAAC;QAED,IAAI,KAAK,CAAC,KAAK,EAAE,CAAC;YAChB,wDAAwD;YACxD,yDAAyD;YACzD,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,CAAA;gBACtC,IAAI,CAAC,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,EAAE,CAAC;oBAC1C,MAAM,IAAI,KAAK,CAAC,mCAAmC,CAAC,CAAA;gBACtD,CAAC;gBACD,IAAI,OAAO,MAAM,CAAC,KAAK,KAAK,QAAQ,IAAI,OAAO,MAAM,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;oBAC1E,MAAM,IAAI,KAAK,CAAC,gDAAgD,CAAC,CAAA;gBACnE,CAAC;gBACD,IAAI,MAAM,CAAC,UAAU,KAAK,SAAS,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,UAAU,CAAC,EAAE,CAAC;oBACzE,MAAM,IAAI,KAAK,CAAC,oDAAoD,CAAC,CAAA;gBACvE,CAAC;YACH,CAAC;YAAC,OAAO,CAAM,EAAE,CAAC;gBAChB,wCAAwC;gBACxC,MAAM,IAAI,KAAK,CAAC,wBAAwB,CAAC,CAAC,OAAO,EAAE,CAAC,CAAA;YACtD,CAAC;YAED,gEAAgE;YAChE,4DAA4D;YAC5D,qEAAqE;YACrE,iEAAiE;YACjE,2CAA2C;YAC3C,IAAI,CAAC,IAAA,0CAAiB,EAAC,KAAK,CAAC,SAAS,CAAC,EAAE,CAAC;gBACxC,IAAA,0CAAiB,EAAC,EAAE,EAAE,KAAK,CAAC,KAAK,EAAE,OAAO,CAAC,CAAA;YAC7C,CAAC;QACH,CAAC;QAED,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;YAC1B,MAAM,eAAe,GAAG,IAAA,qBAAa,EAAC,gBAAK,EAAE,EAAE,CAAC,CAAA;YAChD,KAAK,CAAC,KAAK,GAAG,OAAO;gBACnB,CAAC,CAAC,CAAC,MAAM,eAAe,CAAC,SAAS,CAAC;oBAC/B,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE;oBACzB,EAAE,EAAE,OAAO;iBACZ,CAAC,CAAC,IAAI,IAAI;gBACb,CAAC,CAAC,IAAI,CAAA;QACV,CAAC;QAED,MAAM,OAAO,GAAG,MAAM,UAAU,CAAC,IAAI,CAAC;YACpC,MAAM;YACN,GAAG,KAAK;YACR,GAAG,OAAO;YACV,KAAK,EAAE,OAAO;YACd,OAAO,EAAE,IAAI;SACd,CAAC,CAAA;QAEF,MAAM;YACJ,MAAM,CAAC;gBACL,IAAI,EAAE,QAAQ;gBACd,KAAK,EAAE,UAAU,OAAO,CAAC,IAAI,WAAW;gBACxC,IAAI,EAAE,UAAU,OAAO,CAAC,IAAI,gBAAgB,IAAI,CAAC,IAAI,KAAK,OAAO,CAAC,WAAW,EAAE;gBAC/E,KAAK,EAAE,IAAA,gCAAwB,EAAC,OAAO,EAAE,MAAM,EAAE,cAAc,OAAO,CAAC,EAAE,EAAE,CAAC;gBAC5E,GAAG,EAAE,IAAA,gCAAwB,EAAC,OAAO,EAAE,MAAM,EAAE,iBAAiB,OAAO,CAAC,EAAE,EAAE,CAAC;aAC9E,CAAC,CAAA;QAEJ,OAAO,OAAO,CAAA;IAChB,CAAC;IAKK,AAAN,KAAK,CAAC,YAAY,CACL,EAAU,EACd,OAAwB,EACK,OAAgB;QAEpD,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;QAClD,MAAM,UAAU,GAAG,IAAA,qBAAa,EAAC,gBAAK,EAAE,EAAE,CAAC,CAAA;QAC3C,MAAM,iBAAiB,GAAG,IAAA,qBAAa,EAAC,+BAAY,EAAE,EAAE,CAAC,CAAA;QAEzD,MAAM,KAAK,GAAG,MAAM,UAAU,CAAC,OAAO,CAAC;YACrC,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE;YACxC,SAAS,EAAE,CAAC,SAAS,CAAC;SACvB,CAAC,CAAA;QAEF,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,MAAM,IAAI,KAAK,CAAC,kBAAkB,EAAE,gBAAgB,CAAC,CAAA;QACvD,CAAC;QAED,IAAI,KAAK,CAAC,KAAK,IAAI,UAAU,EAAE,CAAC;YAC9B,MAAM,IAAI,KAAK,CAAC,kBAAkB,EAAE,uBAAuB,CAAC,CAAA;QAC9D,CAAC;QAED,0BAA0B;QAC1B,MAAM,UAAU,GAAG,MAAM,iBAAiB;aACvC,kBAAkB,CAAC,SAAS,CAAC;aAC7B,MAAM,CAAC,sBAAsB,EAAE,KAAK,CAAC;aACrC,KAAK,CAAC,0BAA0B,EAAE,EAAE,EAAE,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC;aACnD,SAAS,EAAE,CAAA;QAEd,MAAM,WAAW,GAAG,UAAU,EAAE,GAAG,CAAC,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;QAEpE,MAAM,OAAO,GAAG,MAAM,UAAU,CAAC,IAAI,CAAC;YACpC,MAAM;YACN,GAAG,KAAK;YACR,OAAO,EAAE,WAAW;YACpB,KAAK,EAAE,UAAU;YACjB,OAAO,EAAE,IAAI;YACb,6EAA6E;YAC7E,gBAAgB,EAAE,OAAO,IAAI,IAAI;SAC3B,CAAC,CAAA;QAET,MAAM;YACJ,MAAM,CAAC;gBACL,IAAI,EAAE,QAAQ;gBACd,KAAK,EAAE,UAAU,OAAO,CAAC,IAAI,YAAY;gBACzC,IAAI,EAAE,UAAU,OAAO,CAAC,IAAI,iBAAiB,IAAI,CAAC,IAAI,KAAK,OAAO,CAAC,WAAW,EAAE;gBAChF,KAAK,EAAE,IAAA,gCAAwB,EAAC,OAAO,EAAE,MAAM,EAAE,cAAc,OAAO,CAAC,EAAE,EAAE,CAAC;gBAC5E,GAAG,EAAE,IAAA,gCAAwB,EAAC,OAAO,EAAE,MAAM,EAAE,iBAAiB,OAAO,CAAC,EAAE,EAAE,CAAC;aAC9E,CAAC,CAAA;QAEJ,OAAO,OAAO,CAAA;IAChB,CAAC;IAKK,AAAN,KAAK,CAAC,kBAAkB,CACX,EAAU,EACL,OAAe,EACxB,OAAwB;QAE/B,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;QAClD,MAAM,UAAU,GAAG,IAAA,qBAAa,EAAC,gBAAK,EAAE,EAAE,CAAC,CAAA;QAE3C,MAAM,KAAK,GAAG,MAAM,UAAU,CAAC,OAAO,CAAC;YACrC,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE;YACxC,SAAS,EAAE,CAAC,SAAS,CAAC;SACvB,CAAC,CAAA;QAEF,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,MAAM,IAAI,KAAK,CAAC,iBAAiB,EAAE,gBAAgB,CAAC,CAAA;QACtD,CAAC;QAED,MAAM,iBAAiB,GAAG,IAAA,qBAAa,EAAC,+BAAY,EAAE,EAAE,CAAC,CAAA;QAEzD,MAAM,YAAY,GAAG,MAAM,iBAAiB,CAAC,OAAO,CAAC;YACnD,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,UAAU,EAAE,EAAE,EAAE,OAAO,EAAE;YAC7D,KAAK,EAAE,EAAE,OAAO,EAAE,MAAM,EAAE;SAC3B,CAAC,CAAA;QAEF,IAAI,CAAC,YAAY,EAAE,CAAC;YAClB,MAAM,IAAI,KAAK,CAAC,yBAAyB,EAAE,IAAI,OAAO,gBAAgB,CAAC,CAAA;QACzE,CAAC;QAED,MAAM,OAAO,GAAG,MAAM,UAAU,CAAC,IAAI,CAAC;YACpC,MAAM;YACN,GAAG,KAAK;YACR,KAAK,EAAE,YAAY,CAAC,KAAK;YACzB,SAAS,EAAE,YAAY,CAAC,SAAS;YACjC,KAAK,EAAE,OAAO;YACd,OAAO,EAAE,IAAI;SACd,CAAC,CAAA;QAEF,MAAM;YACJ,MAAM,CAAC;gBACL,IAAI,EAAE,QAAQ;gBACd,KAAK,EAAE,UAAU,OAAO,CAAC,IAAI,WAAW;gBACxC,IAAI,EAAE,UAAU,OAAO,CAAC,IAAI,gBAAgB,IAAI,CAAC,IAAI,KAAK,OAAO,CAAC,WAAW,EAAE;gBAC/E,KAAK,EAAE,IAAA,gCAAwB,EAAC,OAAO,EAAE,MAAM,EAAE,cAAc,OAAO,CAAC,EAAE,EAAE,CAAC;gBAC5E,GAAG,EAAE,IAAA,gCAAwB,EAAC,OAAO,EAAE,MAAM,EAAE,iBAAiB,OAAO,CAAC,EAAE,EAAE,CAAC;aAC9E,CAAC,CAAA;QAEJ,OAAO,OAAO,CAAA;IAChB,CAAC;IAKK,AAAN,KAAK,CAAC,WAAW,CAAY,EAAU,EAAS,OAAwB;QACtE,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;QAClD,MAAM,UAAU,GAAG,IAAA,qBAAa,EAAC,gBAAK,EAAE,EAAE,CAAC,CAAA;QAC3C,MAAM,KAAK,GAAG,MAAM,UAAU,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,CAAA;QAE3E,MAAM,OAAO,GAAG,MAAM,UAAU,CAAC,UAAU,CAAC,EAAE,CAAC,CAAA;QAE/C,MAAM;YACJ,MAAM,CAAC;gBACL,IAAI,EAAE,QAAQ;gBACd,KAAK,EAAE,UAAU,KAAK,CAAC,IAAI,WAAW;gBACtC,IAAI,EAAE,UAAU,KAAK,CAAC,IAAI,gBAAgB,IAAI,CAAC,IAAI,KAAK,KAAK,CAAC,WAAW,EAAE;aAC5E,CAAC,CAAA;QAEJ,OAAO,IAAI,CAAA;IACb,CAAC;IAKK,AAAN,KAAK,CAAC,YAAY,CACA,OAAe,EACM,KAAmB,EACtC,SAAkB,EAC7B,OAAwB;QAE/B,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;QAClD,MAAM,eAAe,GAAG,IAAA,qBAAa,EAAC,gBAAK,EAAE,EAAE,CAAC,CAAA;QAChD,MAAM,eAAe,GAAG,IAAA,qBAAa,EAAC,gBAAK,EAAE,EAAE,CAAC,CAAA;QAChD,MAAM,KAAK,GAAG,MAAM,eAAe,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,OAAO,EAAE,CAAC,CAAA;QAEzF,IAAI,CAAC,KAAK,EAAE,CAAC;YACX,MAAM,IAAI,KAAK,CAAC,iBAAiB,OAAO,gBAAgB,CAAC,CAAA;QAC3D,CAAC;QAED,MAAM,MAAM,GAAG,EAAE,CAAA;QAEjB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,MAAM,EAAE,EAAE,EAAE,IAAI,EAAE,WAAW,EAAE,KAAK,EAAE,SAAS,EAAE,GAAG,MAAM,aAAa,CAAC,IAAI,CAAC,CAAA;YAE7E,IAAI,aAAa,GAAG,MAAM,eAAe,CAAC,SAAS,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,IAAI,EAAE,CAAC,CAAA;YACxF,IAAI,WAAW,GAAG,MAAM,eAAe,CAAC,SAAS,CAAC,EAAE,EAAE,EAAE,CAAC,CAAA;YAEzD,IAAI,SAAS,EAAE,CAAC;gBACd,IAAI,KAAK,GAAG,EAAS,CAAA;gBAErB,IAAI,WAAW,EAAE,CAAC;oBAChB,IAAI,SAAS,IAAI,WAAW,CAAC,QAAQ,IAAI,MAAM,CAAC,EAAE,EAAE,CAAC;wBACnD,MAAM,IAAI,KAAK,CAAC,iBAAiB,EAAE,sCAAsC,CAAC,CAAA;oBAC5E,CAAC;oBAED,KAAK,GAAG;wBACN,GAAG,WAAW;wBACd,IAAI;qBACL,CAAA;oBAED,IAAI,aAAa,IAAI,WAAW,CAAC,EAAE,IAAI,aAAa,CAAC,EAAE,EAAE,CAAC;wBACxD,cAAc;wBACd,KAAK,CAAC,IAAI,GAAG,GAAG,KAAK,CAAC,IAAI,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,CAAA;oBAC7C,CAAC;gBACH,CAAC;qBAAM,CAAC;oBACN,KAAK,GAAG;wBACN,EAAE;wBACF,IAAI,EAAE,aAAa,CAAC,CAAC,CAAC,GAAG,IAAI,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC,IAAI;wBACrD,OAAO,EAAE,CAAC;wBACV,OAAO,EAAE,IAAI;qBACd,CAAA;gBACH,CAAC;YACH,CAAC;iBAAM,CAAC;gBACN,KAAK,GAAG;oBACN,IAAI;oBACJ,OAAO,EAAE,CAAC;oBACV,OAAO,EAAE,IAAI;iBACd,CAAA;gBAED,qBAAqB;gBACrB,IAAI,CAAC,WAAW,EAAE,CAAC;oBACjB,KAAK,CAAC,EAAE,GAAG,EAAE,CAAA;gBACf,CAAC;gBAED,cAAc;gBACd,IAAI,aAAa,EAAE,CAAC;oBAClB,KAAK,CAAC,IAAI,GAAG,GAAG,KAAK,CAAC,IAAI,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,CAAA;gBAC7C,CAAC;YACH,CAAC;YAED,MAAM,CAAC,IAAI,CACT,MAAM,eAAe,CAAC,IAAI,CAAC;gBACzB,GAAG,KAAK;gBACR,MAAM;gBACN,WAAW;gBACX,KAAK,EAAE,OAAO,KAAK,IAAI,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK;gBAC/D,SAAS;gBACT,KAAK;gBACL,KAAK,EAAE,OAAO;gBACd,OAAO,EAAE,IAAI;aACd,CAAC,CACH,CAAA;QACH,CAAC;QAED,MAAM;YACJ,MAAM,CAAC;gBACL,IAAI,EAAE,QAAQ;gBACd,KAAK,EAAE,GAAG,MAAM,CAAC,MAAM,wBAAwB;gBAC/C,IAAI,EAAE,GAAG,MAAM,CAAC,MAAM,qCAAqC,KAAK,CAAC,IAAI,OAAO,IAAI,CAAC,IAAI,EAAE;aACxF,CAAC,CAAA;QAEJ,OAAO,MAAM,CAAA;IACf,CAAC;IAKK,AAAN,KAAK,CAAC,YAAY,CACL,EAAU,EACc,MAAc,EACd,MAAc,EAC1C,OAAwB;QAE/B,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,KAAK,CAAA;QACpC,MAAM,IAAI,GAAG,EAAE,CAAC,aAAa,CAAC,gBAAK,CAAC,CAAA;QACpC,MAAM,QAAQ,GAAG,MAAM,CAAC,EAAE,CAAA;QAE1B,0BAA0B;QAC1B,IAAI,SAAS,GAAkB,IAAI,CAAA;QACnC,IAAI,SAAS,GAAkB,IAAI,CAAA;QAEnC,IAAI,MAAM,EAAE,CAAC;YACX,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAS,EAAE,CAAC,CAAA;YAC3F,SAAS,GAAG,IAAI,EAAE,SAAS,IAAI,IAAI,CAAA;QACrC,CAAC;QACD,IAAI,MAAM,EAAE,CAAC;YACX,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAS,EAAE,CAAC,CAAA;YAC3F,SAAS,GAAG,IAAI,EAAE,SAAS,IAAI,IAAI,CAAA;QACrC,CAAC;QAED,eAAe;QACf,IAAI,IAAI,GAAG,SAAS,IAAI,CAAC,SAAS,IAAI,IAAI,CAAC,CAAC,CAAC,SAAS,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;QAC/D,IAAI,IAAI,GAAG,SAAS,IAAI,IAAI,GAAG,CAAC,CAAA;QAEhC,6CAA6C;QAC7C,IAAI,IAAI,GAAG,IAAI,GAAG,KAAK,EAAE,CAAC;YACxB,MAAM,SAAS,GAAG,MAAM,IAAI,CAAC,IAAI,CAAC;gBAChC,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAS;aAC3C,CAAC,CAAA;YAEF,mDAAmD;YACnD,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE;gBACtB,MAAM,EAAE,GAAG,CAAC,CAAC,SAAS,IAAI,MAAM,CAAC,gBAAgB,CAAA;gBACjD,MAAM,EAAE,GAAG,CAAC,CAAC,SAAS,IAAI,MAAM,CAAC,gBAAgB,CAAA;gBACjD,IAAI,EAAE,KAAK,EAAE;oBAAE,OAAO,EAAE,GAAG,EAAE,CAAA;gBAC7B,OAAO,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC,CAAA;YACnD,CAAC,CAAC,CAAA;YAEF,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,SAAS,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;gBAC1C,IAAI,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,KAAK,CAAC,EAAE,CAAC;oBACjC,MAAM,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,SAAS,CAAC,CAAC,CAAC,CAAC,EAAG,EAAE,EAAE,EAAE,SAAS,EAAE,CAAC,EAAE,CAAC,CAAA;oBAC7D,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,GAAG,CAAC,CAAA;gBAC5B,CAAC;YACH,CAAC;YAED,yBAAyB;YACzB,IAAI,MAAM,EAAE,CAAC;gBACX,IAAI,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,MAAM,CAAC,EAAE,SAAS,IAAI,IAAI,CAAA;YAChE,CAAC;YACD,IAAI,MAAM,EAAE,CAAC;gBACX,IAAI,GAAG,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,MAAM,CAAC,EAAE,SAAS,IAAI,IAAI,CAAA;YAChE,CAAC;iBAAM,CAAC;gBACN,IAAI,GAAG,IAAI,GAAG,CAAC,CAAA;YACjB,CAAC;QACH,CAAC;QAED,MAAM,SAAS,GAAG,eAAe,CAAC,IAAI,EAAE,IAAI,CAAC,CAAA;QAE7C,MAAM,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,EAAE,EAAE,SAAS,EAAE,CAAC,CAAA;QAClE,OAAO,IAAI,CAAA;IACb,CAAC;CACF,CAAA;AAlhBY,sCAAa;AAIlB;IAHL,IAAA,wBAAS,EAAC,cAAc,CAAC;IACzB,IAAA,wBAAS,EAAC,gFAAgF,CAAC;IAC3F,IAAA,uBAAQ,EAAC,OAAO,CAAC,EAAE,CAAC,gBAAK,EAAE,EAAE,WAAW,EAAE,sBAAsB,EAAE,CAAC;IACjD,mBAAA,IAAA,kBAAG,EAAC,OAAO,CAAC,CAAA;IAAmB,mBAAA,IAAA,kBAAG,GAAE,CAAA;;6CAAhB,wBAAQ;;gDA6D9C;AAOK;IALL,IAAA,wBAAS,EAAC,cAAc,CAAC;IACzB,IAAA,wBAAS,EAAC,gFAAgF,CAAC;IAC3F,IAAA,uBAAQ,EAAC,OAAO,CAAC,EAAE,CAAC,gBAAK,EAAE;QAC1B,WAAW,EAAE,2EAA2E;KACzF,CAAC;IAEC,mBAAA,IAAA,kBAAG,EAAC,IAAI,CAAC,CAAA;IACT,mBAAA,IAAA,kBAAG,EAAC,OAAO,CAAC,CAAA;IACZ,mBAAA,IAAA,kBAAG,EAAC,iBAAiB,CAAC,CAAA;IACtB,mBAAA,IAAA,kBAAG,EAAC,eAAe,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;IACxC,mBAAA,IAAA,kBAAG,GAAE,CAAA;;qDAHe,0BAAU;;+CA4DhC;AAQK;IANL,IAAA,wBAAS,EAAC,cAAc,CAAC;IACzB,IAAA,wBAAS,EAAC,gFAAgF,CAAC;IAC3F,IAAA,4BAAa,EAAC,qBAAa,CAAC;IAC5B,IAAA,uBAAQ,EAAC,OAAO,CAAC,EAAE,CAAC,gBAAK,EAAE;QAC1B,WAAW,EAAE,mJAAmJ;KACjK,CAAC;IAEC,mBAAA,IAAA,kBAAG,EAAC,IAAI,CAAC,CAAA;IACT,mBAAA,IAAA,kBAAG,EAAC,OAAO,CAAC,CAAA;IACZ,mBAAA,IAAA,kBAAG,GAAE,CAAA;;qDADe,0BAAU;;gDAgGhC;AAKK;IAHL,IAAA,wBAAS,EAAC,cAAc,CAAC;IACzB,IAAA,wBAAS,EAAC,gFAAgF,CAAC;IAC3F,IAAA,uBAAQ,EAAC,OAAO,CAAC,EAAE,CAAC,gBAAK,EAAE,EAAE,WAAW,EAAE,oEAAoE,EAAE,CAAC;IAE/G,mBAAA,IAAA,kBAAG,EAAC,IAAI,CAAC,CAAA;IACT,mBAAA,IAAA,kBAAG,GAAE,CAAA;IACL,mBAAA,IAAA,kBAAG,EAAC,SAAS,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;;;;iDAgDpC;AAKK;IAHL,IAAA,wBAAS,EAAC,cAAc,CAAC;IACzB,IAAA,wBAAS,EAAC,gFAAgF,CAAC;IAC3F,IAAA,uBAAQ,EAAC,OAAO,CAAC,EAAE,CAAC,gBAAK,EAAE,EAAE,WAAW,EAAE,mDAAmD,EAAE,CAAC;IAE9F,mBAAA,IAAA,kBAAG,EAAC,IAAI,CAAC,CAAA;IACT,mBAAA,IAAA,kBAAG,EAAC,SAAS,CAAC,CAAA;IACd,mBAAA,IAAA,kBAAG,GAAE,CAAA;;;;uDA4CP;AAKK;IAHL,IAAA,wBAAS,EAAC,cAAc,CAAC;IACzB,IAAA,wBAAS,EAAC,gFAAgF,CAAC;IAC3F,IAAA,uBAAQ,EAAC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,WAAW,EAAE,kBAAkB,EAAE,CAAC;IAC/C,mBAAA,IAAA,kBAAG,EAAC,IAAI,CAAC,CAAA;IAAc,mBAAA,IAAA,kBAAG,GAAE,CAAA;;;;gDAe9C;AAKK;IAHL,IAAA,wBAAS,EAAC,cAAc,CAAC;IACzB,IAAA,wBAAS,EAAC,gFAAgF,CAAC;IAC3F,IAAA,uBAAQ,EAAC,OAAO,CAAC,EAAE,CAAC,CAAC,gBAAK,CAAC,EAAE,EAAE,WAAW,EAAE,0CAA0C,EAAE,CAAC;IAEvF,mBAAA,IAAA,kBAAG,EAAC,SAAS,CAAC,CAAA;IACd,mBAAA,IAAA,kBAAG,EAAC,OAAO,EAAE,GAAG,EAAE,CAAC,CAAC,0BAAa,CAAC,CAAC,CAAA;IACnC,mBAAA,IAAA,kBAAG,EAAC,WAAW,CAAC,CAAA;IAChB,mBAAA,IAAA,kBAAG,GAAE,CAAA;;;;iDAoFP;AAKK;IAHL,IAAA,uBAAQ,EAAC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,WAAW,EAAE,qDAAqD,EAAE,CAAC;IACpG,IAAA,wBAAS,EAAC,sDAAsD,CAAC;IACjE,IAAA,wBAAS,EAAC,cAAc,CAAC;IAEvB,mBAAA,IAAA,kBAAG,EAAC,IAAI,CAAC,CAAA;IACT,mBAAA,IAAA,kBAAG,EAAC,QAAQ,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;IACjC,mBAAA,IAAA,kBAAG,EAAC,QAAQ,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;IACjC,mBAAA,IAAA,kBAAG,GAAE,CAAA;;;;iDA2DP;wBAjhBU,aAAa;IADzB,IAAA,uBAAQ,EAAC,gBAAK,CAAC;GACH,aAAa,CAkhBzB","sourcesContent":["import { Arg, Ctx, Mutation, Resolver, Directive, UseMiddleware } from 'type-graphql'\nimport { In } from 'typeorm'\nimport type { FileUpload } from 'graphql-upload/GraphQLUpload.js'\nimport GraphQLUpload from 'graphql-upload/GraphQLUpload.js'\nimport { Domain, getRedirectSubdomainPath, getRepository, Refusal, SpeakRefusals } from '@things-factory/shell'\n\nimport { Group } from '../group/group.js'\nimport { Board } from './board.js'\nimport { BoardHistory } from './board-history.js'\nimport { BoardPatch, NewBoard } from './board-type.js'\nimport { acceptClientThumbnail, isClientThumbnail, scheduleThumbnail } from './thumbnail-scheduler.js'\nimport { claimUnchangedSince } from './board-claim.js'\n\n/**\n * prev와 next 사이에서 자릿수가 최소인 중간값을 찾는다.\n * (prev, next) open interval에서 가장 깔끔한 숫자를 선택.\n */\nfunction optimalMidValue(prev: number, next: number): number {\n if (prev >= next) return prev - 1\n\n const mid = (prev + next) / 2\n for (let p = 0; p <= 10; p++) {\n const scale = 10 ** p\n const low = Math.floor(prev * scale) + 1\n const high = Math.ceil(next * scale) - 1\n if (low <= high) {\n return Math.min(Math.max(Math.round(mid * scale), low), high) / scale\n }\n }\n return mid\n}\n\nasync function parseJSONFile(uploadedFile: FileUpload): Promise<any> {\n var { createReadStream } = await uploadedFile\n\n return new Promise((resolve, reject) => {\n const chunks: Uint8Array[] = []\n\n createReadStream()\n .on('data', (chunk: Uint8Array) => {\n chunks.push(chunk)\n })\n .on('end', () => {\n try {\n const fileContents = Buffer.concat(chunks).toString('utf-8')\n const jsonData = JSON.parse(fileContents)\n resolve(jsonData)\n } catch (error) {\n reject(error)\n }\n })\n .on('error', (error: Error) => {\n reject(error)\n })\n })\n}\n\n@Resolver(Board)\nexport class BoardMutation {\n @Directive('@transaction')\n @Directive('@privilege(category: \"board\", privilege: \"mutation\", domainOwnerGranted: true)')\n @Mutation(returns => Board, { description: 'Creates a new board.' })\n async createBoard(@Arg('board') board: NewBoard, @Ctx() context: ResolverContext): Promise<Board> {\n const { domain, user, notify, tx } = context.state\n const repository = getRepository(Board, tx)\n const groupRepository = getRepository(Group, tx)\n\n const oldBoard: Board = await repository.findOneBy({\n name: board.name,\n domain: { id: domain.id }\n })\n\n if (oldBoard) {\n throw new Error(context.t('error.board name is already taken', { name: board.name }))\n }\n\n const newBoard: Board = {\n ...board\n }\n\n if (isClientThumbnail(board.thumbnail)) {\n newBoard.thumbnailUpdatedAt = new Date()\n } else {\n newBoard.thumbnail = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==' /* empty thumbnail */\n }\n\n if (board.groupId) {\n newBoard.group = await groupRepository.findOneBy({\n id: board.groupId\n })\n }\n\n // 새 보드는 맨 앞에 배치\n const { minSortOrder } = (await repository\n .createQueryBuilder('board')\n .select('MIN(board.sortOrder)', 'minSortOrder')\n .where('board.domain = :domainId', { domainId: domain.id })\n .getRawOne()) || { minSortOrder: null }\n\n const created = await repository.save({\n domain,\n ...newBoard,\n sortOrder: minSortOrder != null ? minSortOrder - 1 : 0,\n state: 'draft',\n creator: user,\n updater: user\n })\n\n // 새 보드의 thumbnail 도 백그라운드로 생성 — mutation 응답 즉시 반환.\n // 클라이언트가 thumbnail 을 보낸 경우는 그것을 사용하고 puppeteer 생략.\n if (newBoard.model && !isClientThumbnail(board.thumbnail)) {\n scheduleThumbnail(created.id, newBoard.model, context)\n }\n\n notify &&\n notify({\n mode: 'in-app',\n title: `Board '${created.name}' created`,\n body: `Board '${created.name}' created by ${user.name}\\n${created.description}`,\n url: getRedirectSubdomainPath(context, domain, `/board-viewer/${created.id}`)\n })\n\n return created\n }\n\n @Directive('@transaction')\n @Directive('@privilege(category: \"board\", privilege: \"mutation\", domainOwnerGranted: true)')\n @Mutation(returns => Board, {\n description: 'Clones a board from an existing one, potentially into a different domain.'\n })\n async cloneBoard(\n @Arg('id') id: string,\n @Arg('patch') patch: BoardPatch,\n @Arg('targetSubdomain') targetSubdomain: string,\n @Arg('targetGroupId', { nullable: true }) targetGroupId: string,\n @Ctx() context: ResolverContext\n ): Promise<Board> {\n const { domain, user, notify, tx } = context.state\n const { t } = context\n const repository = getRepository(Board, tx)\n\n const board = await repository.findOneBy({ domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id })\n\n if (!patch.name || (patch.name == board.name && targetSubdomain == domain.subdomain)) {\n throw new Error(t('error.name must be unique from the original board', { name: patch.name }))\n }\n\n if (targetSubdomain != domain.subdomain) {\n if ((await repository.count({ where: { domain: { subdomain: targetSubdomain }, name: patch.name } })) > 0) {\n throw new Error(t('error.name must be unique from the original board', { name: patch.name }))\n }\n }\n\n var targetDomain = domain\n if (targetDomain && domain.subdomain != targetSubdomain) {\n targetDomain = await getRepository(Domain, tx).findOneBy({ subdomain: targetSubdomain })\n if (!targetDomain) {\n throw new Error(`given subdomain(${targetSubdomain}) not found`)\n }\n }\n\n var targetGroup = null\n if (targetGroupId) {\n targetGroup = await getRepository(Group, tx).findOneBy({ domain: { id: targetDomain.id }, id: targetGroupId })\n if (!targetGroup) {\n throw new Error(`given group(${targetGroupId}) in domain(${targetSubdomain}) not found`)\n }\n }\n\n const { id: excluded, ...clone } = board\n\n const cloned = await repository.save({\n domain: targetDomain,\n ...clone,\n ...patch,\n group: targetGroup,\n version: 0,\n state: 'draft',\n updater: user,\n creator: user\n })\n\n notify &&\n notify({\n mode: 'in-app',\n title: `Board '${cloned.name}' cloned`,\n body: `Board '${cloned.name}' cloned by ${user.name}\\n${cloned.description}`,\n image: getRedirectSubdomainPath(context, targetDomain, `/thumbnail/${cloned.id}`),\n url: getRedirectSubdomainPath(context, targetDomain, `/board-viewer/${cloned.id}`)\n })\n\n return cloned\n }\n\n @Directive('@transaction')\n @Directive('@privilege(category: \"board\", privilege: \"mutation\", domainOwnerGranted: true)')\n @UseMiddleware(SpeakRefusals)\n @Mutation(returns => Board, {\n description: 'Updates a board. If a model is provided without a thumbnail, it also generates a new thumbnail; a thumbnail sent with the patch is kept as it is.'\n })\n async updateBoard(\n @Arg('id') id: string,\n @Arg('patch') patch: BoardPatch,\n @Ctx() context: ResolverContext\n ): Promise<Board> {\n const { domain, user, notify, tx } = context.state\n const repository = getRepository(Board, tx)\n\n const board = await repository.findOne({\n where: { domain: { id: domain.id }, id },\n relations: ['creator']\n })\n\n if (!board) {\n throw new Error('board not found')\n }\n\n /*\n * Saved over since the editor read it? Checked and claimed in one statement (board-claim.ts) - a read,\n * a comparison and then a save would let two tabs through the same gap (ADR-0098, architect ruling).\n */\n if (patch.expectedUpdatedAt) {\n const claimed = await claimUnchangedSince(tx, domain.id, id, new Date(patch.expectedUpdatedAt))\n if (!claimed)\n throw new Refusal(\n 'board-changed-since',\n { name: board.name },\n 'this board was saved somewhere else after you opened it. open it again and then save.'\n )\n }\n\n const { groupId, expectedUpdatedAt, ...patched } = patch as BoardPatch & Pick<Board, 'thumbnailUpdatedAt'>\n\n if (isClientThumbnail(patch.thumbnail)) {\n // 클라이언트가 만든 thumbnail 을 그대로 쓴다 — 백엔드는 이 저장에 thumbnail 을 만들지 않는다.\n acceptClientThumbnail(id)\n patched.thumbnailUpdatedAt = new Date()\n } else {\n delete patched.thumbnail\n }\n\n if (patch.model) {\n // 데이터 무결성 — model JSON 의 syntax 검증. invalid JSON 영속 차단.\n // (의미 검증 [schema] 은 별도 layer — board-ai 의 tool 단계에서 수행.)\n try {\n const parsed = JSON.parse(patch.model)\n if (!parsed || typeof parsed !== 'object') {\n throw new Error('Board model must be a JSON object')\n }\n if (typeof parsed.width !== 'number' || typeof parsed.height !== 'number') {\n throw new Error('Board model must have numeric width and height')\n }\n if (parsed.components !== undefined && !Array.isArray(parsed.components)) {\n throw new Error('Board model components must be an array if present')\n }\n } catch (e: any) {\n // 명시 에러 — silent 데이터 손상보다 명확한 reject 선호\n throw new Error(`Invalid board model: ${e.message}`)\n }\n\n // 클라이언트 thumbnail 이 없으면 백엔드가 만든다. Thumbnail 생성은 fire-and-forget\n // 백그라운드. mutation 응답이 puppeteer 완료(또는 풀 timeout) 에 차단되지 않도록\n // 분리. 빈번한 저장 시 per-board debounce + model hash dedup 으로 puppeteer 호출\n // 횟수 자체를 줄인다. 자세한 동작은 ./thumbnail-scheduler.ts 참고. 새 thumbnail 이\n // 만들어지기 전까지는 board.thumbnail 의 기존 값이 유지된다.\n if (!isClientThumbnail(patch.thumbnail)) {\n scheduleThumbnail(id, patch.model, context)\n }\n }\n\n if (groupId !== undefined) {\n const groupRepository = getRepository(Group, tx)\n board.group = groupId\n ? (await groupRepository.findOneBy({\n domain: { id: domain.id },\n id: groupId\n })) || null\n : null\n }\n\n const updated = await repository.save({\n domain,\n ...board,\n ...patched,\n state: 'draft',\n updater: user\n })\n\n notify &&\n notify({\n mode: 'in-app',\n title: `Board '${updated.name}' updated`,\n body: `Board '${updated.name}' updated by ${user.name}\\n${updated.description}`,\n image: getRedirectSubdomainPath(context, domain, `/thumbnail/${updated.id}`),\n url: getRedirectSubdomainPath(context, domain, `/board-viewer/${updated.id}`)\n })\n\n return updated\n }\n\n @Directive('@transaction')\n @Directive('@privilege(category: \"board\", privilege: \"mutation\", domainOwnerGranted: true)')\n @Mutation(returns => Board, { description: 'Releases a board, making it public and creating a version history.' })\n async releaseBoard(\n @Arg('id') id: string,\n @Ctx() context: ResolverContext,\n @Arg('comment', { nullable: true }) comment?: string\n ): Promise<Board> {\n const { domain, user, notify, tx } = context.state\n const repository = getRepository(Board, tx)\n const historyRepository = getRepository(BoardHistory, tx)\n\n const board = await repository.findOne({\n where: { domain: { id: domain.id }, id },\n relations: ['creator']\n })\n\n if (!board) {\n throw new Error(`Board given id(${id}) is not found`)\n }\n\n if (board.state == 'released') {\n throw new Error(`Board given id(${id}) is already released`)\n }\n\n // 히스토리에서 max version 가져오기\n const maxHistory = await historyRepository\n .createQueryBuilder('history')\n .select('MAX(history.version)', 'max')\n .where('history.originalId = :id', { id: board.id })\n .getRawOne()\n\n const nextVersion = maxHistory?.max ? Number(maxHistory.max) + 1 : 1\n\n const updated = await repository.save({\n domain,\n ...board,\n version: nextVersion,\n state: 'released',\n updater: user,\n // BoardHistoryEntitySubscriber 가 스냅샷 생성 시 history.comment 로 주입(Board 컬럼 아님).\n __releaseComment: comment ?? null\n } as any)\n\n notify &&\n notify({\n mode: 'in-app',\n title: `Board '${updated.name}' released`,\n body: `Board '${updated.name}' released by ${user.name}\\n${updated.description}`,\n image: getRedirectSubdomainPath(context, domain, `/thumbnail/${updated.id}`),\n url: getRedirectSubdomainPath(context, domain, `/board-viewer/${updated.id}`)\n })\n\n return updated\n }\n\n @Directive('@transaction')\n @Directive('@privilege(category: \"board\", privilege: \"mutation\", domainOwnerGranted: true)')\n @Mutation(returns => Board, { description: 'Reverts a board to a specific historical version.' })\n async revertBoardVersion(\n @Arg('id') id: string,\n @Arg('version') version: number,\n @Ctx() context: ResolverContext\n ): Promise<Board> {\n const { domain, user, notify, tx } = context.state\n const repository = getRepository(Board, tx)\n\n const board = await repository.findOne({\n where: { domain: { id: domain.id }, id },\n relations: ['creator']\n })\n\n if (!board) {\n throw new Error(`Board with id(${id}) is not found`)\n }\n\n const historyRepository = getRepository(BoardHistory, tx)\n\n const boardHistory = await historyRepository.findOne({\n where: { domain: { id: domain.id }, originalId: id, version },\n order: { version: 'DESC' }\n })\n\n if (!boardHistory) {\n throw new Error(`Board with id:version(${id}:${version}) is not found`)\n }\n\n const updated = await repository.save({\n domain,\n ...board,\n model: boardHistory.model,\n thumbnail: boardHistory.thumbnail,\n state: 'draft',\n updater: user\n })\n\n notify &&\n notify({\n mode: 'in-app',\n title: `Board '${updated.name}' updated`,\n body: `Board '${updated.name}' updated by ${user.name}\\n${updated.description}`,\n image: getRedirectSubdomainPath(context, domain, `/thumbnail/${updated.id}`),\n url: getRedirectSubdomainPath(context, domain, `/board-viewer/${updated.id}`)\n })\n\n return updated\n }\n\n @Directive('@transaction')\n @Directive('@privilege(category: \"board\", privilege: \"mutation\", domainOwnerGranted: true)')\n @Mutation(returns => Boolean, { description: 'Deletes a board.' })\n async deleteBoard(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<boolean> {\n const { domain, user, notify, tx } = context.state\n const repository = getRepository(Board, tx)\n const board = await repository.findOneBy({ domain: { id: domain.id }, id })\n\n const deleted = await repository.softDelete(id)\n\n notify &&\n notify({\n mode: 'in-app',\n title: `Board '${board.name}' deleted`,\n body: `Board '${board.name}' deleted by ${user.name}\\n${board.description}`\n })\n\n return true\n }\n\n @Directive('@transaction')\n @Directive('@privilege(category: \"board\", privilege: \"mutation\", domainOwnerGranted: true)')\n @Mutation(returns => [Board], { description: 'Imports multiple boards from JSON files.' })\n async importBoards(\n @Arg('groupId') groupId: string,\n @Arg('files', () => [GraphQLUpload]) files: FileUpload[],\n @Arg('overwrite') overwrite: boolean,\n @Ctx() context: ResolverContext\n ): Promise<Board[]> {\n const { domain, user, notify, tx } = context.state\n const groupRepository = getRepository(Group, tx)\n const boardRepository = getRepository(Board, tx)\n const group = await groupRepository.findOneBy({ domain: { id: domain.id }, id: groupId })\n\n if (!group) {\n throw new Error(`Group with id(${groupId}) is not found`)\n }\n\n const boards = []\n\n for (const file of files) {\n const { id, name, description, model, thumbnail } = await parseJSONFile(file)\n\n var sameNameBoard = await boardRepository.findOneBy({ domain: { id: domain.id }, name })\n var sameIdBoard = await boardRepository.findOneBy({ id })\n\n if (overwrite) {\n var board = {} as any\n\n if (sameIdBoard) {\n if (overwrite && sameIdBoard.domainId != domain.id) {\n throw new Error(`Board with id(${id}) is already taken in another domain`)\n }\n\n board = {\n ...sameIdBoard,\n name\n }\n\n if (sameNameBoard && sameIdBoard.id != sameNameBoard.id) {\n /* 이름 충돌 회피 */\n board.name = `${board.name}(${Date.now()})`\n }\n } else {\n board = {\n id,\n name: sameNameBoard ? `${name}(${Date.now()})` : name,\n version: 0,\n creator: user\n }\n }\n } else {\n board = {\n name,\n version: 0,\n creator: user\n }\n\n /* ID가 없으면, 사용해도 됨 */\n if (!sameIdBoard) {\n board.id = id\n }\n\n /* 이름 충돌 회피 */\n if (sameNameBoard) {\n board.name = `${board.name}(${Date.now()})`\n }\n }\n\n boards.push(\n await boardRepository.save({\n ...board,\n domain,\n description,\n model: typeof model != 'string' ? JSON.stringify(model) : model,\n thumbnail,\n group,\n state: 'draft',\n updater: user\n })\n )\n }\n\n notify &&\n notify({\n mode: 'in-app',\n title: `${boards.length} Board(s) are imported`,\n body: `${boards.length} Board(s) are imported into group ${group.name} by ${user.name}`\n })\n\n return boards\n }\n\n @Mutation(returns => Boolean, { description: 'Reorders a board between two adjacent boards by ID.' })\n @Directive('@privilege(category: \"board\", privilege: \"mutation\")')\n @Directive('@transaction')\n async reorderBoard(\n @Arg('id') id: string,\n @Arg('prevId', { nullable: true }) prevId: string,\n @Arg('nextId', { nullable: true }) nextId: string,\n @Ctx() context: ResolverContext\n ): Promise<boolean> {\n const { domain, tx } = context.state\n const repo = tx.getRepository(Board)\n const domainId = domain.id\n\n // prev/next의 sortOrder 조회\n let prevOrder: number | null = null\n let nextOrder: number | null = null\n\n if (prevId) {\n const prev = await repo.findOne({ where: { id: prevId, domain: { id: domainId } } as any })\n prevOrder = prev?.sortOrder ?? null\n }\n if (nextId) {\n const next = await repo.findOne({ where: { id: nextId, domain: { id: domainId } } as any })\n nextOrder = next?.sortOrder ?? null\n }\n\n // sortOrder 계산\n let prev = prevOrder ?? (nextOrder != null ? nextOrder - 2 : 0)\n let next = nextOrder ?? prev + 2\n\n // prev와 next의 간격이 너무 좁으면 전체 보드 sortOrder 정규화\n if (next - prev < 0.001) {\n const allBoards = await repo.find({\n where: { domain: { id: domainId } } as any\n })\n\n // DB 독립적인 정렬 (NULL → 맨 뒤, sortOrder ASC, name ASC)\n allBoards.sort((a, b) => {\n const sa = a.sortOrder ?? Number.MAX_SAFE_INTEGER\n const sb = b.sortOrder ?? Number.MAX_SAFE_INTEGER\n if (sa !== sb) return sa - sb\n return (a.name || '').localeCompare(b.name || '')\n })\n\n for (let i = 0; i < allBoards.length; i++) {\n if (allBoards[i].sortOrder !== i) {\n await repo.update({ id: allBoards[i].id! }, { sortOrder: i })\n allBoards[i].sortOrder = i\n }\n }\n\n // 정규화된 값으로 prev/next 재조회\n if (prevId) {\n prev = allBoards.find(b => b.id === prevId)?.sortOrder ?? prev\n }\n if (nextId) {\n next = allBoards.find(b => b.id === nextId)?.sortOrder ?? next\n } else {\n next = prev + 2\n }\n }\n\n const sortOrder = optimalMidValue(prev, next)\n\n await repo.update({ id, domain: { id: domainId } }, { sortOrder })\n return true\n }\n}\n"]}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* saveGeneratedThumbnail — 백엔드가 만든 thumbnail 은 생성하는 사이 클라이언트 thumbnail 이 저장되지 않았을 때만 저장된다.
|
|
4
|
+
*
|
|
5
|
+
* 조건이 UPDATE 문 하나에 들어 있는지를 실제 DB(sqlite 메모리)로 본다. Board 는 같은 열 두 개만 가진 스키마로 대신한다.
|
|
6
|
+
*/
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
const typeorm_1 = require("typeorm");
|
|
9
|
+
const BoardSchema = new typeorm_1.EntitySchema({
|
|
10
|
+
name: 'Board',
|
|
11
|
+
columns: {
|
|
12
|
+
id: { type: String, primary: true },
|
|
13
|
+
thumbnail: { type: 'text', nullable: true },
|
|
14
|
+
thumbnailUpdatedAt: { type: Date, nullable: true }
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
const mockDataSource = new typeorm_1.DataSource({
|
|
18
|
+
type: 'better-sqlite3',
|
|
19
|
+
database: ':memory:',
|
|
20
|
+
entities: [BoardSchema],
|
|
21
|
+
synchronize: true
|
|
22
|
+
});
|
|
23
|
+
jest.mock('../../controllers/thumbnail', () => ({ thumbnail: jest.fn() }));
|
|
24
|
+
jest.mock('./board', () => ({ Board: 'Board' }));
|
|
25
|
+
jest.mock('@things-factory/shell', () => ({
|
|
26
|
+
getRepository: (target) => mockDataSource.getRepository(target)
|
|
27
|
+
}));
|
|
28
|
+
const thumbnail_scheduler_1 = require("./thumbnail-scheduler");
|
|
29
|
+
describe('saveGeneratedThumbnail', () => {
|
|
30
|
+
const repository = () => mockDataSource.getRepository('Board');
|
|
31
|
+
const startedAt = new Date('2026-10-23T01:00:00.000Z');
|
|
32
|
+
const finishedAt = new Date('2026-10-23T01:00:05.000Z');
|
|
33
|
+
beforeAll(async () => {
|
|
34
|
+
await mockDataSource.initialize();
|
|
35
|
+
});
|
|
36
|
+
afterAll(async () => {
|
|
37
|
+
await mockDataSource.destroy();
|
|
38
|
+
});
|
|
39
|
+
beforeEach(async () => {
|
|
40
|
+
await repository().clear();
|
|
41
|
+
});
|
|
42
|
+
test('thumbnail 이 처음 만들어지는 board 는 저장한다', async () => {
|
|
43
|
+
await repository().save({ id: 'b1', thumbnail: null, thumbnailUpdatedAt: null });
|
|
44
|
+
expect(await (0, thumbnail_scheduler_1.saveGeneratedThumbnail)('b1', 'server', finishedAt, startedAt)).toBe(true);
|
|
45
|
+
expect(await repository().findOneBy({ id: 'b1' })).toMatchObject({ thumbnail: 'server', thumbnailUpdatedAt: finishedAt });
|
|
46
|
+
});
|
|
47
|
+
test('생성을 시작하기 전에 저장된 thumbnail 은 덮는다', async () => {
|
|
48
|
+
await repository().save({ id: 'b1', thumbnail: 'older', thumbnailUpdatedAt: new Date('2026-10-23T00:59:59.000Z') });
|
|
49
|
+
expect(await (0, thumbnail_scheduler_1.saveGeneratedThumbnail)('b1', 'server', finishedAt, startedAt)).toBe(true);
|
|
50
|
+
expect((await repository().findOneBy({ id: 'b1' })).thumbnail).toBe('server');
|
|
51
|
+
});
|
|
52
|
+
test('생성하는 사이 클라이언트 thumbnail 이 저장됐으면 덮지 않는다', async () => {
|
|
53
|
+
const clientSavedAt = new Date('2026-10-23T01:00:02.000Z');
|
|
54
|
+
await repository().save({ id: 'b1', thumbnail: 'client', thumbnailUpdatedAt: clientSavedAt });
|
|
55
|
+
expect(await (0, thumbnail_scheduler_1.saveGeneratedThumbnail)('b1', 'server', finishedAt, startedAt)).toBe(false);
|
|
56
|
+
expect(await repository().findOneBy({ id: 'b1' })).toMatchObject({ thumbnail: 'client', thumbnailUpdatedAt: clientSavedAt });
|
|
57
|
+
});
|
|
58
|
+
test('다른 board 는 건드리지 않는다', async () => {
|
|
59
|
+
await repository().save([
|
|
60
|
+
{ id: 'b1', thumbnail: null, thumbnailUpdatedAt: null },
|
|
61
|
+
{ id: 'b2', thumbnail: 'b2', thumbnailUpdatedAt: null }
|
|
62
|
+
]);
|
|
63
|
+
await (0, thumbnail_scheduler_1.saveGeneratedThumbnail)('b1', 'server', finishedAt, startedAt);
|
|
64
|
+
expect((await repository().findOneBy({ id: 'b2' })).thumbnail).toBe('b2');
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
//# sourceMappingURL=thumbnail-save.test.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"thumbnail-save.test.js","sourceRoot":"","sources":["../../../server/service/board/thumbnail-save.test.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * saveGeneratedThumbnail — 백엔드가 만든 thumbnail 은 생성하는 사이 클라이언트 thumbnail 이 저장되지 않았을 때만 저장된다.\n *\n * 조건이 UPDATE 문 하나에 들어 있는지를 실제 DB(sqlite 메모리)로 본다. Board 는 같은 열 두 개만 가진 스키마로 대신한다.\n */\n\nimport { DataSource, EntitySchema } from 'typeorm'\n\nconst BoardSchema = new EntitySchema<any>({\n name: 'Board',\n columns: {\n id: { type: String, primary: true },\n thumbnail: { type: 'text', nullable: true },\n thumbnailUpdatedAt: { type: Date, nullable: true }\n }\n})\n\nconst mockDataSource = new DataSource({\n type: 'better-sqlite3',\n database: ':memory:',\n entities: [BoardSchema],\n synchronize: true\n})\n\njest.mock('../../controllers/thumbnail', () => ({ thumbnail: jest.fn() }))\njest.mock('./board', () => ({ Board: 'Board' }))\njest.mock('@things-factory/shell', () => ({\n getRepository: (target: any) => mockDataSource.getRepository(target)\n}))\n\nimport { saveGeneratedThumbnail } from './thumbnail-scheduler'\n\ndescribe('saveGeneratedThumbnail', () => {\n const repository = () => mockDataSource.getRepository('Board')\n const startedAt = new Date('2026-10-23T01:00:00.000Z')\n const finishedAt = new Date('2026-10-23T01:00:05.000Z')\n\n beforeAll(async () => {\n await mockDataSource.initialize()\n })\n\n afterAll(async () => {\n await mockDataSource.destroy()\n })\n\n beforeEach(async () => {\n await repository().clear()\n })\n\n test('thumbnail 이 처음 만들어지는 board 는 저장한다', async () => {\n await repository().save({ id: 'b1', thumbnail: null, thumbnailUpdatedAt: null })\n\n expect(await saveGeneratedThumbnail('b1', 'server', finishedAt, startedAt)).toBe(true)\n expect(await repository().findOneBy({ id: 'b1' })).toMatchObject({ thumbnail: 'server', thumbnailUpdatedAt: finishedAt })\n })\n\n test('생성을 시작하기 전에 저장된 thumbnail 은 덮는다', async () => {\n await repository().save({ id: 'b1', thumbnail: 'older', thumbnailUpdatedAt: new Date('2026-10-23T00:59:59.000Z') })\n\n expect(await saveGeneratedThumbnail('b1', 'server', finishedAt, startedAt)).toBe(true)\n expect((await repository().findOneBy({ id: 'b1' })).thumbnail).toBe('server')\n })\n\n test('생성하는 사이 클라이언트 thumbnail 이 저장됐으면 덮지 않는다', async () => {\n const clientSavedAt = new Date('2026-10-23T01:00:02.000Z')\n await repository().save({ id: 'b1', thumbnail: 'client', thumbnailUpdatedAt: clientSavedAt })\n\n expect(await saveGeneratedThumbnail('b1', 'server', finishedAt, startedAt)).toBe(false)\n expect(await repository().findOneBy({ id: 'b1' })).toMatchObject({ thumbnail: 'client', thumbnailUpdatedAt: clientSavedAt })\n })\n\n test('다른 board 는 건드리지 않는다', async () => {\n await repository().save([\n { id: 'b1', thumbnail: null, thumbnailUpdatedAt: null },\n { id: 'b2', thumbnail: 'b2', thumbnailUpdatedAt: null }\n ])\n\n await saveGeneratedThumbnail('b1', 'server', finishedAt, startedAt)\n\n expect((await repository().findOneBy({ id: 'b2' })).thumbnail).toBe('b2')\n })\n})\n"]}
|
|
@@ -4,8 +4,14 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export interface SchedulerDeps {
|
|
6
6
|
generateThumbnail: (model: string, context: any) => Promise<string | undefined>;
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* board 의 thumbnailUpdatedAt 이 없거나 since 보다 이를 때만 저장하고, 저장했는지 돌려준다. 생성하는 사이 클라이언트
|
|
9
|
+
* thumbnail 이 저장됐으면(thumbnailUpdatedAt 이 since 뒤) 더 오래된 model 의 그림이라 덮지 않는다.
|
|
10
|
+
*/
|
|
11
|
+
saveThumbnail: (boardId: string, thumbnail: string, timestamp: Date, since: Date) => Promise<boolean>;
|
|
8
12
|
}
|
|
13
|
+
/** SchedulerDeps.saveThumbnail 의 DB 구현 — 조건과 저장을 한 UPDATE 문으로 한다. */
|
|
14
|
+
export declare function saveGeneratedThumbnail(boardId: string, value: string, now: Date, since: Date): Promise<boolean>;
|
|
9
15
|
/** Test-only — DI 로 internal dependency 교체. */
|
|
10
16
|
export declare function _setSchedulerDeps(d: SchedulerDeps): void;
|
|
11
17
|
/** Test-only — default dependency 복원. */
|
|
@@ -30,6 +36,21 @@ export declare function fnv1a(s: string): string;
|
|
|
30
36
|
* 그 경우 lastHashByBoard 를 set 하지 않아 다음 저장에서 재시도된다.
|
|
31
37
|
*/
|
|
32
38
|
export declare function scheduleThumbnail(boardId: string, model: string, context: any): void;
|
|
39
|
+
/**
|
|
40
|
+
* 저장에 실린 thumbnail 이 클라이언트가 만든 그림인가. 비었거나 빈 gif(EMPTY_THUMBNAIL 등 data:image/gif)는 그림이
|
|
41
|
+
* 없다는 뜻이라 백엔드가 만든다.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isClientThumbnail(thumbnail?: string | null): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* 클라이언트가 보낸 thumbnail 을 저장한다 — 이 board 의 백엔드 생성은 하지 않는다.
|
|
46
|
+
*
|
|
47
|
+
* - 대기 중인 생성은 버린다.
|
|
48
|
+
* - 진행 중인 생성은 끝나도 저장하지 않는다 — 호출자가 저장하는 thumbnailUpdatedAt 이 그 생성의 시작보다 늦기 때문이다.
|
|
49
|
+
* - 마지막으로 그린 model 의 hash 를 지운다 — 다음에 thumbnail 없이 저장하면 같은 model 이라도 다시 그린다.
|
|
50
|
+
*
|
|
51
|
+
* 호출자는 thumbnail 과 함께 thumbnailUpdatedAt 을 저장한다.
|
|
52
|
+
*/
|
|
53
|
+
export declare function acceptClientThumbnail(boardId: string): void;
|
|
33
54
|
/**
|
|
34
55
|
* 진단 / 운영용 — 현재 큐 / 진행 상태.
|
|
35
56
|
* 추후 admin endpoint 에서 활용 가능.
|
|
@@ -10,21 +10,25 @@
|
|
|
10
10
|
* 3) model 변경 없는 저장 → puppeteer 0 회 (hash dedup)
|
|
11
11
|
* 를 달성한다.
|
|
12
12
|
*
|
|
13
|
-
* 클라이언트가 thumbnail 을
|
|
14
|
-
*
|
|
15
|
-
* - 렌더 결정론: 모든 사용자에게 동일한 thumbnail (브라우저/디바이스/DPR 무관).
|
|
13
|
+
* 클라이언트가 thumbnail 을 같이 보낸 저장은 그것을 쓰고, 백엔드는 새로 만들지 않는다 (acceptClientThumbnail).
|
|
14
|
+
* 그 밖의 저장은 백엔드가 만든다:
|
|
16
15
|
* - 무인 트리거: API/AI/시스템 자동 저장 시 클라이언트 없음.
|
|
16
|
+
* - 렌더 결정론: 모든 사용자에게 동일한 thumbnail (브라우저/디바이스/DPR 무관).
|
|
17
17
|
* - 라벨 ZPL/GRF: 1-bit 정확 비트맵 — 클라이언트 anti-aliasing 비호환.
|
|
18
|
-
*
|
|
18
|
+
* 백엔드 생성의 비용은 *호출 횟수* 와 *응답 경로 분리* 로 줄인다.
|
|
19
19
|
*/
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.saveGeneratedThumbnail = saveGeneratedThumbnail;
|
|
21
22
|
exports._setSchedulerDeps = _setSchedulerDeps;
|
|
22
23
|
exports._resetSchedulerDeps = _resetSchedulerDeps;
|
|
23
24
|
exports._resetSchedulerState = _resetSchedulerState;
|
|
24
25
|
exports._waitForIdle = _waitForIdle;
|
|
25
26
|
exports.fnv1a = fnv1a;
|
|
26
27
|
exports.scheduleThumbnail = scheduleThumbnail;
|
|
28
|
+
exports.isClientThumbnail = isClientThumbnail;
|
|
29
|
+
exports.acceptClientThumbnail = acceptClientThumbnail;
|
|
27
30
|
exports.getSchedulerStats = getSchedulerStats;
|
|
31
|
+
const typeorm_1 = require("typeorm");
|
|
28
32
|
const shell_1 = require("@things-factory/shell");
|
|
29
33
|
const thumbnail_js_1 = require("../../controllers/thumbnail.js");
|
|
30
34
|
const board_js_1 = require("./board.js");
|
|
@@ -39,17 +43,14 @@ const inFlightByBoard = new Set();
|
|
|
39
43
|
* 영구화하려면 board 테이블에 thumbnailModelHash 컬럼 추가 필요 (별도 마이그레이션).
|
|
40
44
|
*/
|
|
41
45
|
const lastHashByBoard = new Map();
|
|
46
|
+
/** SchedulerDeps.saveThumbnail 의 DB 구현 — 조건과 저장을 한 UPDATE 문으로 한다. */
|
|
47
|
+
async function saveGeneratedThumbnail(boardId, value, now, since) {
|
|
48
|
+
const { affected } = await (0, shell_1.getRepository)(board_js_1.Board).update({ id: boardId, thumbnailUpdatedAt: (0, typeorm_1.Or)((0, typeorm_1.IsNull)(), (0, typeorm_1.LessThan)(since)) }, { thumbnail: value, thumbnailUpdatedAt: now });
|
|
49
|
+
return affected > 0;
|
|
50
|
+
}
|
|
42
51
|
const defaultDeps = {
|
|
43
52
|
generateThumbnail: (model, context) => (0, thumbnail_js_1.thumbnail)({ model, context }),
|
|
44
|
-
saveThumbnail:
|
|
45
|
-
await (0, shell_1.getDataSource)().transaction(async (tx) => {
|
|
46
|
-
await (0, shell_1.getRepository)(board_js_1.Board, tx).save({
|
|
47
|
-
id: boardId,
|
|
48
|
-
thumbnail: value,
|
|
49
|
-
thumbnailUpdatedAt: now
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
+
saveThumbnail: saveGeneratedThumbnail
|
|
53
54
|
};
|
|
54
55
|
let deps = defaultDeps;
|
|
55
56
|
/** Test-only — DI 로 internal dependency 교체. */
|
|
@@ -116,6 +117,28 @@ function scheduleThumbnail(boardId, model, context) {
|
|
|
116
117
|
});
|
|
117
118
|
});
|
|
118
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* 저장에 실린 thumbnail 이 클라이언트가 만든 그림인가. 비었거나 빈 gif(EMPTY_THUMBNAIL 등 data:image/gif)는 그림이
|
|
122
|
+
* 없다는 뜻이라 백엔드가 만든다.
|
|
123
|
+
*/
|
|
124
|
+
function isClientThumbnail(thumbnail) {
|
|
125
|
+
return !!thumbnail && !thumbnail.startsWith('data:image/gif');
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* 클라이언트가 보낸 thumbnail 을 저장한다 — 이 board 의 백엔드 생성은 하지 않는다.
|
|
129
|
+
*
|
|
130
|
+
* - 대기 중인 생성은 버린다.
|
|
131
|
+
* - 진행 중인 생성은 끝나도 저장하지 않는다 — 호출자가 저장하는 thumbnailUpdatedAt 이 그 생성의 시작보다 늦기 때문이다.
|
|
132
|
+
* - 마지막으로 그린 model 의 hash 를 지운다 — 다음에 thumbnail 없이 저장하면 같은 model 이라도 다시 그린다.
|
|
133
|
+
*
|
|
134
|
+
* 호출자는 thumbnail 과 함께 thumbnailUpdatedAt 을 저장한다.
|
|
135
|
+
*/
|
|
136
|
+
function acceptClientThumbnail(boardId) {
|
|
137
|
+
if (!boardId)
|
|
138
|
+
return;
|
|
139
|
+
pendingByBoard.delete(boardId);
|
|
140
|
+
lastHashByBoard.delete(boardId);
|
|
141
|
+
}
|
|
119
142
|
async function processNext(boardId) {
|
|
120
143
|
// pending 큐에 남은 동안 계속 처리 — 진행 중에 새 model 들어와도 끝나면 이어 처리.
|
|
121
144
|
while (pendingByBoard.has(boardId)) {
|
|
@@ -123,14 +146,17 @@ async function processNext(boardId) {
|
|
|
123
146
|
pendingByBoard.delete(boardId);
|
|
124
147
|
inFlightByBoard.add(boardId);
|
|
125
148
|
const hash = fnv1a(entry.model);
|
|
149
|
+
const startedAt = new Date();
|
|
126
150
|
try {
|
|
127
151
|
const base64 = await deps.generateThumbnail(entry.model, entry.context);
|
|
128
152
|
const value = base64 && base64 !== 'null' ? 'data:image/png;base64,' + base64 : EMPTY_THUMBNAIL;
|
|
129
153
|
// thumbnail 과 thumbnailUpdatedAt 을 같이 갱신 — board.updatedAt 과는 별개
|
|
130
154
|
// 도메인이라 메타데이터 변경(name/description)에 영향받지 않는 thumbnail 만의
|
|
131
155
|
// 시각. 클라이언트 cache-bust / push 알림 / stale 감지의 기준점.
|
|
132
|
-
|
|
133
|
-
|
|
156
|
+
// 생성하는 사이 클라이언트 thumbnail 이 저장됐으면 저장하지 않는다 (saveThumbnail 의 since).
|
|
157
|
+
if (await deps.saveThumbnail(boardId, value, new Date(), startedAt)) {
|
|
158
|
+
lastHashByBoard.set(boardId, hash);
|
|
159
|
+
}
|
|
134
160
|
}
|
|
135
161
|
catch (e) {
|
|
136
162
|
// 풀 timeout / puppeteer 오류 — 다음 저장에서 재시도되도록 hash set 안 함.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"thumbnail-scheduler.js","sourceRoot":"","sources":["../../../server/service/board/thumbnail-scheduler.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright © HatioLab Inc. All rights reserved.\n *\n * Thumbnail Scheduler\n *\n * 백엔드 헤드리스(puppeteer) thumbnail 생성을 mutation 응답 경로에서 분리하여\n * 1) save mutation latency 폭증 / cascade 장애 차단,\n * 2) 같은 board 의 빈번 저장 → puppeteer 1 회만 (per-board debounce),\n * 3) model 변경 없는 저장 → puppeteer 0 회 (hash dedup)\n * 를 달성한다.\n *\n * 클라이언트가 thumbnail 을 만들 수 없는 이유:\n * - 권한 분리: 캡처는 인증 user 컨텍스트의 데이터 바인딩 필요.\n * - 렌더 결정론: 모든 사용자에게 동일한 thumbnail (브라우저/디바이스/DPR 무관).\n * - 무인 트리거: API/AI/시스템 자동 저장 시 클라이언트 없음.\n * - 라벨 ZPL/GRF: 1-bit 정확 비트맵 — 클라이언트 anti-aliasing 비호환.\n * → 백엔드 헤드리스가 본질적이며, 비용을 *호출 횟수* 와 *응답 경로 분리* 로 줄인다.\n */\n\nimport { getDataSource, getRepository } from '@things-factory/shell'\nimport { thumbnail } from '../../controllers/thumbnail.js'\nimport { Board } from './board.js'\n\nconst EMPTY_THUMBNAIL =\n 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='\n\n/** 처리 대기 중인 최신 model (boardId → {model, context}). 늦게 들어온 model 이 이전을 덮어쓴다. */\nconst pendingByBoard = new Map<string, { model: string; context: any }>()\n\n/** 진행 중인 board — processNext 가 끝나면 자동으로 큐의 최신을 또 처리. */\nconst inFlightByBoard = new Set<string>()\n\n/**\n * 직전 성공한 thumbnail 의 model hash. 같은 model 반복 저장 시 skip.\n * In-memory only — 인스턴스 재시작 시 잃지만 한 lifetime 안에서 효과 충분.\n * 영구화하려면 board 테이블에 thumbnailModelHash 컬럼 추가 필요 (별도 마이그레이션).\n */\nconst lastHashByBoard = new Map<string, string>()\n\n/**\n * Internal dependencies — production 에서는 puppeteer / DB 를 사용하지만, test 에서는\n * mock 으로 교체해 hash dedup / debounce / queue 로직만 검증.\n */\nexport interface SchedulerDeps {\n generateThumbnail: (model: string, context: any) => Promise<string | undefined>\n saveThumbnail: (boardId: string, thumbnail: string, timestamp: Date) => Promise<void>\n}\n\nconst defaultDeps: SchedulerDeps = {\n generateThumbnail: (model, context) => thumbnail({ model, context }) as Promise<string | undefined>,\n saveThumbnail: async (boardId, value, now) => {\n await getDataSource().transaction(async tx => {\n await getRepository(Board, tx).save({\n id: boardId,\n thumbnail: value,\n thumbnailUpdatedAt: now\n })\n })\n }\n}\n\nlet deps: SchedulerDeps = defaultDeps\n\n/** Test-only — DI 로 internal dependency 교체. */\nexport function _setSchedulerDeps(d: SchedulerDeps): void {\n deps = d\n}\n\n/** Test-only — default dependency 복원. */\nexport function _resetSchedulerDeps(): void {\n deps = defaultDeps\n}\n\n/** Test-only — module-level state 초기화. */\nexport function _resetSchedulerState(): void {\n pendingByBoard.clear()\n inFlightByBoard.clear()\n lastHashByBoard.clear()\n}\n\n/** Test-only — 현재 진행 중인 모든 작업 완료까지 대기. */\nexport async function _waitForIdle(): Promise<void> {\n // setImmediate 큐 비우기\n await new Promise(resolve => setImmediate(resolve))\n // 진행 중 작업 끝날 때까지 polling (테스트 환경 한정 — 보통 즉시 끝남)\n let safety = 1000\n while (inFlightByBoard.size > 0 && safety-- > 0) {\n await new Promise(resolve => setImmediate(resolve))\n }\n}\n\n/** FNV-1a 32-bit hash — 빠르고 model 변경 감지에 충분. */\nexport function fnv1a(s: string): string {\n let hash = 2166136261\n for (let i = 0; i < s.length; i++) {\n hash ^= s.charCodeAt(i)\n hash = Math.imul(hash, 16777619) >>> 0\n }\n return hash.toString(16)\n}\n\n/**\n * 백엔드 thumbnail 생성을 fire-and-forget 으로 schedule.\n *\n * 호출자는 await 하지 않는다 — mutation 응답이 thumbnail 완료에 차단되지 않도록.\n *\n * 동작:\n * - 직전 성공 model 과 hash 동일 → 즉시 return (no-op).\n * - 같은 board 에 진행 중 작업 있으면 → pending 만 갱신, 끝나면 이어서 처리.\n * - 진행 중 작업 없음 → 즉시 시작.\n *\n * 풀 timeout / puppeteer 오류는 사용자 mutation 에 전혀 영향 없다.\n * 그 경우 lastHashByBoard 를 set 하지 않아 다음 저장에서 재시도된다.\n */\nexport function scheduleThumbnail(boardId: string, model: string, context: any): void {\n if (!boardId || !model) return\n\n const hash = fnv1a(model)\n if (lastHashByBoard.get(boardId) === hash) {\n return // model unchanged — puppeteer 호출 불필요.\n }\n\n // 큐의 최신만 보존. 5 번 저장 와도 마지막 1 개만 처리됨.\n pendingByBoard.set(boardId, { model, context })\n\n if (inFlightByBoard.has(boardId)) return // 진행 중 — 끝나면 큐의 최신 자동 처리.\n\n // setImmediate 로 mutation transaction commit 이후 시작 보장.\n setImmediate(() => {\n processNext(boardId).catch(err => {\n console.error(`[thumbnail-scheduler] board=${boardId} unexpected:`, err)\n })\n })\n}\n\nasync function processNext(boardId: string): Promise<void> {\n // pending 큐에 남은 동안 계속 처리 — 진행 중에 새 model 들어와도 끝나면 이어 처리.\n while (pendingByBoard.has(boardId)) {\n const entry = pendingByBoard.get(boardId)!\n pendingByBoard.delete(boardId)\n inFlightByBoard.add(boardId)\n\n const hash = fnv1a(entry.model)\n try {\n const base64 = await deps.generateThumbnail(entry.model, entry.context)\n const value =\n base64 && base64 !== 'null' ? 'data:image/png;base64,' + base64 : EMPTY_THUMBNAIL\n\n // thumbnail 과 thumbnailUpdatedAt 을 같이 갱신 — board.updatedAt 과는 별개\n // 도메인이라 메타데이터 변경(name/description)에 영향받지 않는 thumbnail 만의\n // 시각. 클라이언트 cache-bust / push 알림 / stale 감지의 기준점.\n await deps.saveThumbnail(boardId, value, new Date())\n lastHashByBoard.set(boardId, hash)\n } catch (e) {\n // 풀 timeout / puppeteer 오류 — 다음 저장에서 재시도되도록 hash set 안 함.\n // 기존 board.thumbnail 은 그대로 유지 (DB save 시 thumbnail field 미터치).\n console.warn(`[thumbnail-scheduler] board=${boardId} failed: ${(e as Error).message}`)\n } finally {\n inFlightByBoard.delete(boardId)\n }\n }\n}\n\n/**\n * 진단 / 운영용 — 현재 큐 / 진행 상태.\n * 추후 admin endpoint 에서 활용 가능.\n */\nexport function getSchedulerStats() {\n return {\n pending: pendingByBoard.size,\n inFlight: inFlightByBoard.size,\n cachedHashes: lastHashByBoard.size\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"thumbnail-scheduler.js","sourceRoot":"","sources":["../../../server/service/board/thumbnail-scheduler.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * Copyright © HatioLab Inc. All rights reserved.\n *\n * Thumbnail Scheduler\n *\n * 백엔드 헤드리스(puppeteer) thumbnail 생성을 mutation 응답 경로에서 분리하여\n * 1) save mutation latency 폭증 / cascade 장애 차단,\n * 2) 같은 board 의 빈번 저장 → puppeteer 1 회만 (per-board debounce),\n * 3) model 변경 없는 저장 → puppeteer 0 회 (hash dedup)\n * 를 달성한다.\n *\n * 클라이언트가 thumbnail 을 같이 보낸 저장은 그것을 쓰고, 백엔드는 새로 만들지 않는다 (acceptClientThumbnail).\n * 그 밖의 저장은 백엔드가 만든다:\n * - 무인 트리거: API/AI/시스템 자동 저장 시 클라이언트 없음.\n * - 렌더 결정론: 모든 사용자에게 동일한 thumbnail (브라우저/디바이스/DPR 무관).\n * - 라벨 ZPL/GRF: 1-bit 정확 비트맵 — 클라이언트 anti-aliasing 비호환.\n * 백엔드 생성의 비용은 *호출 횟수* 와 *응답 경로 분리* 로 줄인다.\n */\n\nimport { IsNull, LessThan, Or } from 'typeorm'\nimport { getRepository } from '@things-factory/shell'\nimport { thumbnail } from '../../controllers/thumbnail.js'\nimport { Board } from './board.js'\n\nconst EMPTY_THUMBNAIL =\n 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='\n\n/** 처리 대기 중인 최신 model (boardId → {model, context}). 늦게 들어온 model 이 이전을 덮어쓴다. */\nconst pendingByBoard = new Map<string, { model: string; context: any }>()\n\n/** 진행 중인 board — processNext 가 끝나면 자동으로 큐의 최신을 또 처리. */\nconst inFlightByBoard = new Set<string>()\n\n/**\n * 직전 성공한 thumbnail 의 model hash. 같은 model 반복 저장 시 skip.\n * In-memory only — 인스턴스 재시작 시 잃지만 한 lifetime 안에서 효과 충분.\n * 영구화하려면 board 테이블에 thumbnailModelHash 컬럼 추가 필요 (별도 마이그레이션).\n */\nconst lastHashByBoard = new Map<string, string>()\n\n/**\n * Internal dependencies — production 에서는 puppeteer / DB 를 사용하지만, test 에서는\n * mock 으로 교체해 hash dedup / debounce / queue 로직만 검증.\n */\nexport interface SchedulerDeps {\n generateThumbnail: (model: string, context: any) => Promise<string | undefined>\n /**\n * board 의 thumbnailUpdatedAt 이 없거나 since 보다 이를 때만 저장하고, 저장했는지 돌려준다. 생성하는 사이 클라이언트\n * thumbnail 이 저장됐으면(thumbnailUpdatedAt 이 since 뒤) 더 오래된 model 의 그림이라 덮지 않는다.\n */\n saveThumbnail: (boardId: string, thumbnail: string, timestamp: Date, since: Date) => Promise<boolean>\n}\n\n/** SchedulerDeps.saveThumbnail 의 DB 구현 — 조건과 저장을 한 UPDATE 문으로 한다. */\nexport async function saveGeneratedThumbnail(boardId: string, value: string, now: Date, since: Date): Promise<boolean> {\n const { affected } = await getRepository(Board).update(\n { id: boardId, thumbnailUpdatedAt: Or(IsNull(), LessThan(since)) },\n { thumbnail: value, thumbnailUpdatedAt: now }\n )\n\n return affected > 0\n}\n\nconst defaultDeps: SchedulerDeps = {\n generateThumbnail: (model, context) => thumbnail({ model, context }) as Promise<string | undefined>,\n saveThumbnail: saveGeneratedThumbnail\n}\n\nlet deps: SchedulerDeps = defaultDeps\n\n/** Test-only — DI 로 internal dependency 교체. */\nexport function _setSchedulerDeps(d: SchedulerDeps): void {\n deps = d\n}\n\n/** Test-only — default dependency 복원. */\nexport function _resetSchedulerDeps(): void {\n deps = defaultDeps\n}\n\n/** Test-only — module-level state 초기화. */\nexport function _resetSchedulerState(): void {\n pendingByBoard.clear()\n inFlightByBoard.clear()\n lastHashByBoard.clear()\n}\n\n/** Test-only — 현재 진행 중인 모든 작업 완료까지 대기. */\nexport async function _waitForIdle(): Promise<void> {\n // setImmediate 큐 비우기\n await new Promise(resolve => setImmediate(resolve))\n // 진행 중 작업 끝날 때까지 polling (테스트 환경 한정 — 보통 즉시 끝남)\n let safety = 1000\n while (inFlightByBoard.size > 0 && safety-- > 0) {\n await new Promise(resolve => setImmediate(resolve))\n }\n}\n\n/** FNV-1a 32-bit hash — 빠르고 model 변경 감지에 충분. */\nexport function fnv1a(s: string): string {\n let hash = 2166136261\n for (let i = 0; i < s.length; i++) {\n hash ^= s.charCodeAt(i)\n hash = Math.imul(hash, 16777619) >>> 0\n }\n return hash.toString(16)\n}\n\n/**\n * 백엔드 thumbnail 생성을 fire-and-forget 으로 schedule.\n *\n * 호출자는 await 하지 않는다 — mutation 응답이 thumbnail 완료에 차단되지 않도록.\n *\n * 동작:\n * - 직전 성공 model 과 hash 동일 → 즉시 return (no-op).\n * - 같은 board 에 진행 중 작업 있으면 → pending 만 갱신, 끝나면 이어서 처리.\n * - 진행 중 작업 없음 → 즉시 시작.\n *\n * 풀 timeout / puppeteer 오류는 사용자 mutation 에 전혀 영향 없다.\n * 그 경우 lastHashByBoard 를 set 하지 않아 다음 저장에서 재시도된다.\n */\nexport function scheduleThumbnail(boardId: string, model: string, context: any): void {\n if (!boardId || !model) return\n\n const hash = fnv1a(model)\n if (lastHashByBoard.get(boardId) === hash) {\n return // model unchanged — puppeteer 호출 불필요.\n }\n\n // 큐의 최신만 보존. 5 번 저장 와도 마지막 1 개만 처리됨.\n pendingByBoard.set(boardId, { model, context })\n\n if (inFlightByBoard.has(boardId)) return // 진행 중 — 끝나면 큐의 최신 자동 처리.\n\n // setImmediate 로 mutation transaction commit 이후 시작 보장.\n setImmediate(() => {\n processNext(boardId).catch(err => {\n console.error(`[thumbnail-scheduler] board=${boardId} unexpected:`, err)\n })\n })\n}\n\n/**\n * 저장에 실린 thumbnail 이 클라이언트가 만든 그림인가. 비었거나 빈 gif(EMPTY_THUMBNAIL 등 data:image/gif)는 그림이\n * 없다는 뜻이라 백엔드가 만든다.\n */\nexport function isClientThumbnail(thumbnail?: string | null): boolean {\n return !!thumbnail && !thumbnail.startsWith('data:image/gif')\n}\n\n/**\n * 클라이언트가 보낸 thumbnail 을 저장한다 — 이 board 의 백엔드 생성은 하지 않는다.\n *\n * - 대기 중인 생성은 버린다.\n * - 진행 중인 생성은 끝나도 저장하지 않는다 — 호출자가 저장하는 thumbnailUpdatedAt 이 그 생성의 시작보다 늦기 때문이다.\n * - 마지막으로 그린 model 의 hash 를 지운다 — 다음에 thumbnail 없이 저장하면 같은 model 이라도 다시 그린다.\n *\n * 호출자는 thumbnail 과 함께 thumbnailUpdatedAt 을 저장한다.\n */\nexport function acceptClientThumbnail(boardId: string): void {\n if (!boardId) return\n\n pendingByBoard.delete(boardId)\n lastHashByBoard.delete(boardId)\n}\n\nasync function processNext(boardId: string): Promise<void> {\n // pending 큐에 남은 동안 계속 처리 — 진행 중에 새 model 들어와도 끝나면 이어 처리.\n while (pendingByBoard.has(boardId)) {\n const entry = pendingByBoard.get(boardId)!\n pendingByBoard.delete(boardId)\n inFlightByBoard.add(boardId)\n\n const hash = fnv1a(entry.model)\n const startedAt = new Date()\n try {\n const base64 = await deps.generateThumbnail(entry.model, entry.context)\n const value =\n base64 && base64 !== 'null' ? 'data:image/png;base64,' + base64 : EMPTY_THUMBNAIL\n\n // thumbnail 과 thumbnailUpdatedAt 을 같이 갱신 — board.updatedAt 과는 별개\n // 도메인이라 메타데이터 변경(name/description)에 영향받지 않는 thumbnail 만의\n // 시각. 클라이언트 cache-bust / push 알림 / stale 감지의 기준점.\n // 생성하는 사이 클라이언트 thumbnail 이 저장됐으면 저장하지 않는다 (saveThumbnail 의 since).\n if (await deps.saveThumbnail(boardId, value, new Date(), startedAt)) {\n lastHashByBoard.set(boardId, hash)\n }\n } catch (e) {\n // 풀 timeout / puppeteer 오류 — 다음 저장에서 재시도되도록 hash set 안 함.\n // 기존 board.thumbnail 은 그대로 유지 (DB save 시 thumbnail field 미터치).\n console.warn(`[thumbnail-scheduler] board=${boardId} failed: ${(e as Error).message}`)\n } finally {\n inFlightByBoard.delete(boardId)\n }\n }\n}\n\n/**\n * 진단 / 운영용 — 현재 큐 / 진행 상태.\n * 추후 admin endpoint 에서 활용 가능.\n */\nexport function getSchedulerStats() {\n return {\n pending: pendingByBoard.size,\n inFlight: inFlightByBoard.size,\n cachedHashes: lastHashByBoard.size\n }\n}\n"]}
|