@things-factory/figure-service 10.1.111 → 10.1.113

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.
Files changed (49) hide show
  1. package/dist-server/index.d.ts +1 -0
  2. package/dist-server/index.js +17 -0
  3. package/dist-server/index.js.map +1 -1
  4. package/dist-server/routers/figure-thumbnail-router.d.ts +12 -3
  5. package/dist-server/routers/figure-thumbnail-router.js +44 -31
  6. package/dist-server/routers/figure-thumbnail-router.js.map +1 -1
  7. package/dist-server/service/figure/figure-history.d.ts +1 -0
  8. package/dist-server/service/figure/figure-history.js +5 -0
  9. package/dist-server/service/figure/figure-history.js.map +1 -1
  10. package/dist-server/service/figure/figure-mutation.js +11 -8
  11. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  12. package/dist-server/service/figure/figure-replaced-draft.d.ts +1 -0
  13. package/dist-server/service/figure/figure-replaced-draft.js +5 -0
  14. package/dist-server/service/figure/figure-replaced-draft.js.map +1 -1
  15. package/dist-server/service/figure/figure-revert.js +9 -4
  16. package/dist-server/service/figure/figure-revert.js.map +1 -1
  17. package/dist-server/service/figure/figure-thumbnail-reader.d.ts +7 -0
  18. package/dist-server/service/figure/figure-thumbnail-reader.js +21 -0
  19. package/dist-server/service/figure/figure-thumbnail-reader.js.map +1 -0
  20. package/dist-server/service/figure/figure-thumbnail-store.d.ts +35 -0
  21. package/dist-server/service/figure/figure-thumbnail-store.js +96 -0
  22. package/dist-server/service/figure/figure-thumbnail-store.js.map +1 -0
  23. package/dist-server/service/figure/figure-thumbnail-upgrade.d.ts +8 -0
  24. package/dist-server/service/figure/figure-thumbnail-upgrade.js +56 -0
  25. package/dist-server/service/figure/figure-thumbnail-upgrade.js.map +1 -0
  26. package/dist-server/service/figure/figure-write.d.ts +14 -10
  27. package/dist-server/service/figure/figure-write.js +9 -4
  28. package/dist-server/service/figure/figure-write.js.map +1 -1
  29. package/dist-server/service/figure/figure.d.ts +1 -0
  30. package/dist-server/service/figure/figure.js +5 -0
  31. package/dist-server/service/figure/figure.js.map +1 -1
  32. package/dist-server/service/figure/index.d.ts +1 -1
  33. package/dist-server/service/index.d.ts +1 -1
  34. package/dist-server/tsconfig.tsbuildinfo +1 -1
  35. package/package.json +4 -3
  36. package/server/index.ts +17 -0
  37. package/server/routers/figure-thumbnail-router.ts +48 -37
  38. package/server/service/figure/figure-history.ts +9 -0
  39. package/server/service/figure/figure-mutation.ts +11 -9
  40. package/server/service/figure/figure-replaced-draft.ts +9 -0
  41. package/server/service/figure/figure-revert-db.test.ts +6 -5
  42. package/server/service/figure/figure-revert.ts +9 -4
  43. package/server/service/figure/figure-thumbnail-db.test.ts +236 -0
  44. package/server/service/figure/figure-thumbnail-reader.ts +20 -0
  45. package/server/service/figure/figure-thumbnail-store.ts +108 -0
  46. package/server/service/figure/figure-thumbnail-upgrade.pg.test.ts +93 -0
  47. package/server/service/figure/figure-thumbnail-upgrade.ts +70 -0
  48. package/server/service/figure/figure-write.ts +15 -4
  49. package/server/service/figure/figure.ts +9 -0
@@ -1,4 +1,5 @@
1
1
  import './routes.js';
2
+ import './service/figure/figure-thumbnail-reader.js';
2
3
  export * from './service/index.js';
3
4
  export * from './migrations/index.js';
4
5
  export { registerFigureToolCategories } from './service/figure/figure-tools.js';
@@ -21,6 +21,23 @@ process.on('bootstrap-module-start', () => {
21
21
  const { templates, refused } = (0, figure_recipe_templates_js_1.recipeTemplates)();
22
22
  console.log(`[figure-service:bootstrap] recipe templates ${templates.length}${refused.length ? `, left out: ${refused.join(' | ')}` : ''}`);
23
23
  });
24
+ /*
25
+ * The pictures an install kept as base64 move into attachment-base by their content, once, at start (ADR-0104 decision
26
+ * 6). In the boot's one line: it writes, and sqlite holds one runner. A failure is said and the server goes on.
27
+ */
28
+ const shell_1 = require("@things-factory/shell");
29
+ const figure_thumbnail_upgrade_js_1 = require("./service/figure/figure-thumbnail-upgrade.js");
30
+ /* Who may read a figure picture by its attachment path (ADR-0106): registered with attachment-base at load. */
31
+ require("./service/figure/figure-thumbnail-reader.js");
32
+ process.on('bootstrap-module-start', () => {
33
+ (0, shell_1.inBootTurn)(() => (0, figure_thumbnail_upgrade_js_1.moveBase64Thumbnails)((0, shell_1.getDataSource)()))
34
+ .then(moved => {
35
+ const any = moved.filter(m => m.moved || m.kept);
36
+ if (any.length)
37
+ console.log(`[figure-service:bootstrap] thumbnails moved: ${any.map(m => `${m.table} ${m.moved}${m.kept ? ` (kept as base64: ${m.kept})` : ''}`).join(', ')}`);
38
+ })
39
+ .catch(error => console.error(`[figure-service] 썸네일을 옮기지 못했다 — ${error?.message ?? error}`));
40
+ });
24
41
  tslib_1.__exportStar(require("./service/index.js"), exports);
25
42
  tslib_1.__exportStar(require("./migrations/index.js"), exports);
26
43
  var figure_tools_js_2 = require("./service/figure/figure-tools.js");
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../server/index.ts"],"names":[],"mappings":";;;;AAAA,uBAAoB;AAEpB;;;;;;GAMG;AACH,sEAA+E;AAC/E,IAAA,8CAA4B,GAAE,CAAA;AAE9B;;;GAGG;AACH,mGAAoF;AACpF,OAAO,CAAC,EAAE,CAAC,wBAA+B,EAAE,GAAG,EAAE;IAC/C,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,GAAG,IAAA,4CAAe,GAAE,CAAA;IAChD,OAAO,CAAC,GAAG,CAAC,+CAA+C,SAAS,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,eAAe,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;AAC7I,CAAC,CAAC,CAAA;AAEF,6DAAkC;AAClC,gEAAqC;AACrC,oEAA+E;AAAtE,+HAAA,4BAA4B,OAAA","sourcesContent":["import './routes.js'\n\n/*\n * 저작 도구를 어시스턴트 레지스트리에 등록한다.\n *\n * 이 패키지가 적재되면 대화면(도크)이 저작 중인 Figure 의 정본·비용·발행 판정을 읽고 후보를\n * 요청할 수 있다. 능력을 복제하지 않고 figure-service 가 이미 가진 진입점을 투영한다 —\n * 노출 범위는 대화면이 `toolCategories` 로 고른다(twin-ai · board-import 와 같은 방식).\n */\nimport { registerFigureToolCategories } from './service/figure/figure-tools.js'\nregisterFigureToolCategories()\n\n/*\n * The recipe templates are read once at start (ADR-0095, amended): what was served, and any file whose form is wrong,\n * said here rather than found by a person as a missing template.\n */\nimport { recipeTemplates } from './service/figure-recipe/figure-recipe-templates.js'\nprocess.on('bootstrap-module-start' as any, () => {\n const { templates, refused } = recipeTemplates()\n console.log(`[figure-service:bootstrap] recipe templates ${templates.length}${refused.length ? `, left out: ${refused.join(' | ')}` : ''}`)\n})\n\nexport * from './service/index.js'\nexport * from './migrations/index.js'\nexport { registerFigureToolCategories } from './service/figure/figure-tools.js'\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../server/index.ts"],"names":[],"mappings":";;;;AAAA,uBAAoB;AAEpB;;;;;;GAMG;AACH,sEAA+E;AAC/E,IAAA,8CAA4B,GAAE,CAAA;AAE9B;;;GAGG;AACH,mGAAoF;AACpF,OAAO,CAAC,EAAE,CAAC,wBAA+B,EAAE,GAAG,EAAE;IAC/C,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,GAAG,IAAA,4CAAe,GAAE,CAAA;IAChD,OAAO,CAAC,GAAG,CAAC,+CAA+C,SAAS,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,eAAe,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;AAC7I,CAAC,CAAC,CAAA;AAEF;;;GAGG;AACH,iDAAiE;AACjE,8FAAmF;AACnF,+GAA+G;AAC/G,uDAAoD;AACpD,OAAO,CAAC,EAAE,CAAC,wBAA+B,EAAE,GAAG,EAAE;IAC/C,IAAA,kBAAU,EAAC,GAAG,EAAE,CAAC,IAAA,kDAAoB,EAAC,IAAA,qBAAa,GAAE,CAAC,CAAC;SACpD,IAAI,CAAC,KAAK,CAAC,EAAE;QACZ,MAAM,GAAG,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,IAAI,CAAC,CAAA;QAChD,IAAI,GAAG,CAAC,MAAM;YAAE,OAAO,CAAC,GAAG,CAAC,gDAAgD,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,qBAAqB,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,CAAA;IAChL,CAAC,CAAC;SACD,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,CAAC,KAAK,CAAC,mCAAmC,KAAK,EAAE,OAAO,IAAI,KAAK,EAAE,CAAC,CAAC,CAAA;AAChG,CAAC,CAAC,CAAA;AAEF,6DAAkC;AAClC,gEAAqC;AACrC,oEAA+E;AAAtE,+HAAA,4BAA4B,OAAA","sourcesContent":["import './routes.js'\n\n/*\n * 저작 도구를 어시스턴트 레지스트리에 등록한다.\n *\n * 이 패키지가 적재되면 대화면(도크)이 저작 중인 Figure 의 정본·비용·발행 판정을 읽고 후보를\n * 요청할 수 있다. 능력을 복제하지 않고 figure-service 가 이미 가진 진입점을 투영한다 —\n * 노출 범위는 대화면이 `toolCategories` 로 고른다(twin-ai · board-import 와 같은 방식).\n */\nimport { registerFigureToolCategories } from './service/figure/figure-tools.js'\nregisterFigureToolCategories()\n\n/*\n * The recipe templates are read once at start (ADR-0095, amended): what was served, and any file whose form is wrong,\n * said here rather than found by a person as a missing template.\n */\nimport { recipeTemplates } from './service/figure-recipe/figure-recipe-templates.js'\nprocess.on('bootstrap-module-start' as any, () => {\n const { templates, refused } = recipeTemplates()\n console.log(`[figure-service:bootstrap] recipe templates ${templates.length}${refused.length ? `, left out: ${refused.join(' | ')}` : ''}`)\n})\n\n/*\n * The pictures an install kept as base64 move into attachment-base by their content, once, at start (ADR-0104 decision\n * 6). In the boot's one line: it writes, and sqlite holds one runner. A failure is said and the server goes on.\n */\nimport { getDataSource, inBootTurn } from '@things-factory/shell'\nimport { moveBase64Thumbnails } from './service/figure/figure-thumbnail-upgrade.js'\n/* Who may read a figure picture by its attachment path (ADR-0106): registered with attachment-base at load. */\nimport './service/figure/figure-thumbnail-reader.js'\nprocess.on('bootstrap-module-start' as any, () => {\n inBootTurn(() => moveBase64Thumbnails(getDataSource()))\n .then(moved => {\n const any = moved.filter(m => m.moved || m.kept)\n if (any.length) console.log(`[figure-service:bootstrap] thumbnails moved: ${any.map(m => `${m.table} ${m.moved}${m.kept ? ` (kept as base64: ${m.kept})` : ''}`).join(', ')}`)\n })\n .catch(error => console.error(`[figure-service] 썸네일을 옮기지 못했다 — ${error?.message ?? error}`))\n})\n\nexport * from './service/index.js'\nexport * from './migrations/index.js'\nexport { registerFigureToolCategories } from './service/figure/figure-tools.js'\n"]}
@@ -1,9 +1,10 @@
1
+ import { type ThumbnailFiles } from '../service/figure/figure-thumbnail-store.js';
1
2
  /**
2
3
  * 썸네일을 **그림으로** 내보내는 길.
3
4
  *
4
5
  * ## 왜 필요한가
5
6
  *
6
- * 썸네일은 base64 문자열로 칸에 들어 있다(`Board.thumbnail` 과 같은 방식). 그것을 목록 질의가
7
+ * 썸네일은 attachment-base 가 내용 해시로 갖는다(ADR-0104 결정 6). 옛날에는 base64 문자열로 칸에 있었고, 그것을 목록 질의가
7
8
  * 함께 실어 오면 카탈로그를 열 때마다 그림 전부가 JSON 안에 담겨 다시 온다 — data URL 은
8
9
  * 브라우저가 캐시할 수 없다. [확인함] 표본 14개에 227KB, 한 장 최대 20KB.
9
10
  *
@@ -18,7 +19,15 @@
18
19
  * `/thumbnail/:id` 로 두면 먼저 등록된 쪽이 이기고, 남의 표에서 id 를 찾아 404 를 낸다.
19
20
  * 2. **행 전체를 읽는다.** board 는 `findOneBy` 로 행을 다 읽는다. figure 행에는 정본(`source`)이
20
21
  * 붙어 있어, 그림 한 장에 정본까지 읽게 된다. 필요한 칸만 고른다.
21
- * 3. **png 로 못 박는다.** figure 썸네일은 webp 다(모델러가 그렇게 만든다). data URL 이 스스로
22
- * 말하는 형식을 쓴다 — 못 박으면 브라우저가 확장자와 다른 바이트를 받는다.
22
+ * 3. **png 로 못 박는다.** figure 썸네일은 webp 다(모델러가 그렇게 만든다). 저장할 때 data URL 이 스스로
23
+ * 말한 형식을 attachment 의 mimetype 으로 쓴다 — 못 박으면 브라우저가 확장자와 다른 바이트를 받는다.
23
24
  */
24
25
  export declare const figureThumbnailRouter: any;
26
+ /**
27
+ * The picture of a figure the caller may read, by its hash (ADR-0104 decision 6). Exported for tests, which give
28
+ * their own `files`.
29
+ */
30
+ export declare function sendFigureThumbnail(context: any, next: () => Promise<any>, id: string, domain: {
31
+ id: string;
32
+ parentId?: string;
33
+ }, files?: ThumbnailFiles): Promise<void>;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.figureThumbnailRouter = void 0;
4
+ exports.sendFigureThumbnail = sendFigureThumbnail;
4
5
  const tslib_1 = require("tslib");
5
6
  const koa_router_1 = tslib_1.__importDefault(require("koa-router"));
6
7
  const typeorm_1 = require("typeorm");
@@ -8,13 +9,14 @@ const auth_base_1 = require("@things-factory/auth-base");
8
9
  const shell_1 = require("@things-factory/shell");
9
10
  const fs_1 = require("fs");
10
11
  const figure_js_1 = require("../service/figure/figure.js");
12
+ const figure_thumbnail_store_js_1 = require("../service/figure/figure-thumbnail-store.js");
11
13
  const figure_recipe_templates_js_1 = require("../service/figure-recipe/figure-recipe-templates.js");
12
14
  /**
13
15
  * 썸네일을 **그림으로** 내보내는 길.
14
16
  *
15
17
  * ## 왜 필요한가
16
18
  *
17
- * 썸네일은 base64 문자열로 칸에 들어 있다(`Board.thumbnail` 과 같은 방식). 그것을 목록 질의가
19
+ * 썸네일은 attachment-base 가 내용 해시로 갖는다(ADR-0104 결정 6). 옛날에는 base64 문자열로 칸에 있었고, 그것을 목록 질의가
18
20
  * 함께 실어 오면 카탈로그를 열 때마다 그림 전부가 JSON 안에 담겨 다시 온다 — data URL 은
19
21
  * 브라우저가 캐시할 수 없다. [확인함] 표본 14개에 227KB, 한 장 최대 20KB.
20
22
  *
@@ -29,18 +31,10 @@ const figure_recipe_templates_js_1 = require("../service/figure-recipe/figure-re
29
31
  * `/thumbnail/:id` 로 두면 먼저 등록된 쪽이 이기고, 남의 표에서 id 를 찾아 404 를 낸다.
30
32
  * 2. **행 전체를 읽는다.** board 는 `findOneBy` 로 행을 다 읽는다. figure 행에는 정본(`source`)이
31
33
  * 붙어 있어, 그림 한 장에 정본까지 읽게 된다. 필요한 칸만 고른다.
32
- * 3. **png 로 못 박는다.** figure 썸네일은 webp 다(모델러가 그렇게 만든다). data URL 이 스스로
33
- * 말하는 형식을 쓴다 — 못 박으면 브라우저가 확장자와 다른 바이트를 받는다.
34
+ * 3. **png 로 못 박는다.** figure 썸네일은 webp 다(모델러가 그렇게 만든다). 저장할 때 data URL 이 스스로
35
+ * 말한 형식을 attachment 의 mimetype 으로 쓴다 — 못 박으면 브라우저가 확장자와 다른 바이트를 받는다.
34
36
  */
35
37
  exports.figureThumbnailRouter = new koa_router_1.default();
36
- /** `data:image/webp;base64,....` → 형식과 바이트. 아니면 undefined. */
37
- function decodeDataUrl(dataUrl) {
38
- const matched = /^data:([^;,]+);base64,(.*)$/s.exec(dataUrl);
39
- if (!matched) {
40
- return undefined;
41
- }
42
- return { type: matched[1], buffer: Buffer.from(matched[2], 'base64') };
43
- }
44
38
  exports.figureThumbnailRouter.get('/figure-thumbnail/:id', async (context, next) => {
45
39
  const { domain, user, unsafeIP, prohibitedPrivileges } = context.state;
46
40
  /*
@@ -59,39 +53,58 @@ exports.figureThumbnailRouter.get('/figure-thumbnail/:id', async (context, next)
59
53
  context.status = 403;
60
54
  return;
61
55
  }
62
- const { id } = context.params;
56
+ await sendFigureThumbnail(context, next, context.params.id, domain);
57
+ });
58
+ /**
59
+ * The picture of a figure the caller may read, by its hash (ADR-0104 decision 6). Exported for tests, which give
60
+ * their own `files`.
61
+ */
62
+ async function sendFigureThumbnail(context, next, id, domain, files = (0, figure_thumbnail_store_js_1.thumbnailFiles)()) {
63
63
  /*
64
64
  읽기는 부모 도메인까지 본다 — `figure` 질의와 같은 범위다. 물려받은 형상을 목록에는 보여 주고
65
65
  그림만 404 를 내면, 화면은 「그림이 아직 없다」고 거짓말하게 된다.
66
66
  */
67
67
  const found = await (0, shell_1.getRepository)(figure_js_1.Figure).findOne({
68
68
  where: { domain: { id: (0, typeorm_1.In)([domain.id, domain.parentId].filter(Boolean)) }, id },
69
- select: { id: true, thumbnail: true, thumbnailUpdatedAt: true }
69
+ relations: { domain: true },
70
+ select: { id: true, thumbnailHash: true, thumbnailUpdatedAt: true, domain: { id: true } }
70
71
  });
71
- const decoded = found?.thumbnail ? decodeDataUrl(found.thumbnail) : undefined;
72
- if (!decoded) {
72
+ if (!found?.thumbnailHash) {
73
73
  context.status = 404;
74
74
  return;
75
75
  }
76
76
  /*
77
- **개인 캐시만 허락한다.** 이 그림은 권한을 본 뒤에 내주는 것이므로 공용 캐시(프록시)에 남으면
78
- 안 된다. 부르는 쪽이 `?v=` 로 판을 가리므로 오래 두어도 낡은 그림이 남지 않고, `v` 없이 부른
79
- 경우를 위해 시각을 함께 실어 다시 물어볼 수 있게 한다.
77
+ 그림은 **그 figure 의 도메인**에서 찾는다 — 묻는 도메인이 아니다(판정 조건 4). 해시가 같아도 다른 도메인의 행을
78
+ 집지 않는다. 물려받은 figure 의 그림은 부모 도메인에 있다.
80
79
  */
81
- const stamp = found.thumbnailUpdatedAt;
82
- context.type = decoded.type;
83
- context.set('Cache-Control', 'private, max-age=31536000');
84
- if (stamp) {
85
- context.set('Last-Modified', new Date(stamp).toUTCString());
86
- context.set('ETag', `"${new Date(stamp).getTime()}"`);
87
- /* 브라우저가 가진 것이 아직 그것이면 바이트를 보내지 않는다. */
88
- if (context.fresh) {
89
- context.status = 304;
90
- return;
91
- }
80
+ const picture = await (0, figure_thumbnail_store_js_1.thumbnailAttachmentOf)({ getRepository: shell_1.getRepository }, found.domain.id, found.thumbnailHash);
81
+ if (!picture) {
82
+ context.status = 404;
83
+ return;
92
84
  }
93
- context.body = decoded.buffer;
94
- });
85
+ /*
86
+ **개인 캐시만 허락한다.** 이 그림은 권한을 본 뒤에 내주는 것이므로 공용 캐시(프록시)에 남으면 안 된다 —
87
+ attachment 의 공개 경로가 쓰는 `public` 을 따라 쓰지 않는다(판정 조건 1). 같은 내용은 같은 해시이므로 ETag 가
88
+ 곧 내용이다. 부르는 쪽이 `?v=` 로 판을 가리므로 오래 두어도 낡은 그림이 남지 않는다.
89
+ */
90
+ const ours = () => {
91
+ context.type = picture.mimetype;
92
+ context.set('Cache-Control', 'private, max-age=31536000');
93
+ context.set('ETag', `"${found.thumbnailHash}"`);
94
+ if (found.thumbnailUpdatedAt)
95
+ context.set('Last-Modified', new Date(found.thumbnailUpdatedAt).toUTCString());
96
+ };
97
+ ours();
98
+ /* koa answers `fresh` only for a 2xx: before a body the status is still 404, and the check never matched. */
99
+ context.status = 200;
100
+ if (context.fresh) {
101
+ context.status = 304;
102
+ return;
103
+ }
104
+ await files.send(context, picture.path, next);
105
+ /* The storage sets its own (S3 its ETag, attachment's Cache-Control): this door's are said last. */
106
+ ours();
107
+ }
95
108
  /**
96
109
  * A recipe template's thumbnail (ADR-0095 amended): the file drawn from its model, served behind the same door as the
97
110
  * templates themselves. The file changes only with a release, so a day's private cache is enough.
@@ -1 +1 @@
1
- {"version":3,"file":"figure-thumbnail-router.js","sourceRoot":"","sources":["../../server/routers/figure-thumbnail-router.ts"],"names":[],"mappings":"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","sourcesContent":["import Router from 'koa-router'\nimport { In } from 'typeorm'\n\nimport { checkPermission } from '@things-factory/auth-base'\nimport { getRepository } from '@things-factory/shell'\n\nimport { readFileSync } from 'fs'\n\nimport { Figure } from '../service/figure/figure.js'\nimport { recipeTemplates } from '../service/figure-recipe/figure-recipe-templates.js'\n\n/**\n * 썸네일을 **그림으로** 내보내는 길.\n *\n * ## 왜 필요한가\n *\n * 썸네일은 base64 문자열로 칸에 들어 있다(`Board.thumbnail` 과 같은 방식). 그것을 목록 질의가\n * 함께 실어 오면 카탈로그를 열 때마다 그림 전부가 JSON 안에 담겨 다시 온다 — data URL 은\n * 브라우저가 캐시할 수 없다. [확인함] 표본 14개에 227KB, 한 장 최대 20KB.\n *\n * 그림으로 내보내면 브라우저가 캐시하고, 부르는 쪽이 `?v=thumbnailUpdatedAt` 을 붙이므로 그림이\n * 다시 만들어진 것만 다시 받는다. 목록 질의에서는 `thumbnail` 을 아예 고르지 않는다.\n *\n * ## board 를 베끼면서 고친 것 셋\n *\n * `standalone-board-service-router` 의 `/thumbnail/:id` 가 본이지만 그대로 두면 안 되는 것들이 있다.\n *\n * 1. **길 이름이 겹친다.** 이 앱은 board 도 함께 싣는다(메뉴가 board-ui 를 끌어온다).\n * `/thumbnail/:id` 로 두면 먼저 등록된 쪽이 이기고, 남의 표에서 id 를 찾아 404 를 낸다.\n * 2. **행 전체를 읽는다.** board 는 `findOneBy` 로 행을 다 읽는다. figure 행에는 정본(`source`)이\n * 붙어 있어, 그림 한 장에 정본까지 읽게 된다. 필요한 칸만 고른다.\n * 3. **png 로 못 박는다.** figure 썸네일은 webp 다(모델러가 그렇게 만든다). data URL 이 스스로\n * 말하는 형식을 쓴다 — 못 박으면 브라우저가 확장자와 다른 바이트를 받는다.\n */\nexport const figureThumbnailRouter = new Router()\n\n/** `data:image/webp;base64,....` → 형식과 바이트. 아니면 undefined. */\nfunction decodeDataUrl(dataUrl: string): { type: string; buffer: Buffer } | undefined {\n const matched = /^data:([^;,]+);base64,(.*)$/s.exec(dataUrl)\n if (!matched) {\n return undefined\n }\n\n return { type: matched[1], buffer: Buffer.from(matched[2], 'base64') }\n}\n\nfigureThumbnailRouter.get('/figure-thumbnail/:id', async (context, next) => {\n const { domain, user, unsafeIP, prohibitedPrivileges } = context.state\n\n /*\n **질의와 같은 문을 쓴다.**\n\n `User.hasPrivilege('query','figure',…)` 만 보면 안 된다 — figure 질의는\n `@privilege(category: \"figure\", privilege: \"query\", domainOwnerGranted: true)` 로 선언되어 있고,\n 도메인 소유자는 `figure:query` 권한 줄이 없어도 통과한다. 그 허용을 여기서 빼면 목록은 보이는데\n 그림만 403 이 되어, 화면은 「그림이 아직 없다」고 거짓말한다. [확인함] 실제로 카드 14장이 전부\n 403 이었다.\n\n 조건을 손으로 다시 적지 않고 지시자가 쓰는 함수를 그대로 쓴다. 선언이 바뀌면 여기도 함께 바뀐다.\n */\n const allowed = await checkPermission(\n { category: 'figure', privilege: 'query', owner: true },\n user,\n domain,\n unsafeIP,\n prohibitedPrivileges\n )\n\n if (!allowed) {\n context.status = 403\n return\n }\n\n const { id } = context.params\n\n /*\n 읽기는 부모 도메인까지 본다 — `figure` 질의와 같은 범위다. 물려받은 형상을 목록에는 보여 주고\n 그림만 404 를 내면, 화면은 「그림이 아직 없다」고 거짓말하게 된다.\n */\n const found = await getRepository(Figure).findOne({\n where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id },\n select: { id: true, thumbnail: true, thumbnailUpdatedAt: true }\n })\n\n const decoded = found?.thumbnail ? decodeDataUrl(found.thumbnail) : undefined\n\n if (!decoded) {\n context.status = 404\n return\n }\n\n /*\n **개인 캐시만 허락한다.** 이 그림은 권한을 본 뒤에 내주는 것이므로 공용 캐시(프록시)에 남으면\n 안 된다. 부르는 쪽이 `?v=` 로 판을 가리므로 오래 두어도 낡은 그림이 남지 않고, `v` 없이 부른\n 경우를 위해 시각을 함께 실어 다시 물어볼 수 있게 한다.\n */\n const stamp = found!.thumbnailUpdatedAt\n\n context.type = decoded.type\n context.set('Cache-Control', 'private, max-age=31536000')\n if (stamp) {\n context.set('Last-Modified', new Date(stamp).toUTCString())\n context.set('ETag', `\"${new Date(stamp).getTime()}\"`)\n\n /* 브라우저가 가진 것이 아직 그것이면 바이트를 보내지 않는다. */\n if (context.fresh) {\n context.status = 304\n return\n }\n }\n\n context.body = decoded.buffer\n})\n\n/**\n * A recipe template's thumbnail (ADR-0095 amended): the file drawn from its model, served behind the same door as the\n * templates themselves. The file changes only with a release, so a day's private cache is enough.\n */\nfigureThumbnailRouter.get('/figure-recipe-thumbnail/:name', async context => {\n const { domain, user, unsafeIP, prohibitedPrivileges } = context.state\n const allowed = await checkPermission({ category: 'figure', privilege: 'query', owner: true }, user, domain, unsafeIP, prohibitedPrivileges)\n if (!allowed) {\n context.status = 403\n return\n }\n const template = recipeTemplates().templates.find(t => t.recipe.name === context.params.name)\n if (!template?.thumbnail) {\n context.status = 404\n return\n }\n context.type = 'image/png'\n context.set('Cache-Control', 'private, max-age=86400')\n context.body = readFileSync(template.thumbnail)\n})\n"]}
1
+ {"version":3,"file":"figure-thumbnail-router.js","sourceRoot":"","sources":["../../server/routers/figure-thumbnail-router.ts"],"names":[],"mappings":"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","sourcesContent":["import Router from 'koa-router'\nimport { In } from 'typeorm'\n\nimport { checkPermission } from '@things-factory/auth-base'\nimport { getRepository } from '@things-factory/shell'\n\nimport { readFileSync } from 'fs'\n\nimport { Figure } from '../service/figure/figure.js'\nimport { thumbnailAttachmentOf, thumbnailFiles, type ThumbnailFiles } from '../service/figure/figure-thumbnail-store.js'\nimport { recipeTemplates } from '../service/figure-recipe/figure-recipe-templates.js'\n\n/**\n * 썸네일을 **그림으로** 내보내는 길.\n *\n * ## 왜 필요한가\n *\n * 썸네일은 attachment-base 가 내용 해시로 갖는다(ADR-0104 결정 6). 옛날에는 base64 문자열로 칸에 있었고, 그것을 목록 질의가\n * 함께 실어 오면 카탈로그를 열 때마다 그림 전부가 JSON 안에 담겨 다시 온다 — data URL 은\n * 브라우저가 캐시할 수 없다. [확인함] 표본 14개에 227KB, 한 장 최대 20KB.\n *\n * 그림으로 내보내면 브라우저가 캐시하고, 부르는 쪽이 `?v=thumbnailUpdatedAt` 을 붙이므로 그림이\n * 다시 만들어진 것만 다시 받는다. 목록 질의에서는 `thumbnail` 을 아예 고르지 않는다.\n *\n * ## board 를 베끼면서 고친 것 셋\n *\n * `standalone-board-service-router` 의 `/thumbnail/:id` 가 본이지만 그대로 두면 안 되는 것들이 있다.\n *\n * 1. **길 이름이 겹친다.** 이 앱은 board 도 함께 싣는다(메뉴가 board-ui 를 끌어온다).\n * `/thumbnail/:id` 로 두면 먼저 등록된 쪽이 이기고, 남의 표에서 id 를 찾아 404 를 낸다.\n * 2. **행 전체를 읽는다.** board 는 `findOneBy` 로 행을 다 읽는다. figure 행에는 정본(`source`)이\n * 붙어 있어, 그림 한 장에 정본까지 읽게 된다. 필요한 칸만 고른다.\n * 3. **png 로 못 박는다.** figure 썸네일은 webp 다(모델러가 그렇게 만든다). 저장할 때 data URL 이 스스로\n * 말한 형식을 attachment 의 mimetype 으로 쓴다 — 못 박으면 브라우저가 확장자와 다른 바이트를 받는다.\n */\nexport const figureThumbnailRouter = new Router()\n\nfigureThumbnailRouter.get('/figure-thumbnail/:id', async (context, next) => {\n const { domain, user, unsafeIP, prohibitedPrivileges } = context.state\n\n /*\n **질의와 같은 문을 쓴다.**\n\n `User.hasPrivilege('query','figure',…)` 만 보면 안 된다 — figure 질의는\n `@privilege(category: \"figure\", privilege: \"query\", domainOwnerGranted: true)` 로 선언되어 있고,\n 도메인 소유자는 `figure:query` 권한 줄이 없어도 통과한다. 그 허용을 여기서 빼면 목록은 보이는데\n 그림만 403 이 되어, 화면은 「그림이 아직 없다」고 거짓말한다. [확인함] 실제로 카드 14장이 전부\n 403 이었다.\n\n 조건을 손으로 다시 적지 않고 지시자가 쓰는 함수를 그대로 쓴다. 선언이 바뀌면 여기도 함께 바뀐다.\n */\n const allowed = await checkPermission(\n { category: 'figure', privilege: 'query', owner: true },\n user,\n domain,\n unsafeIP,\n prohibitedPrivileges\n )\n\n if (!allowed) {\n context.status = 403\n return\n }\n\n await sendFigureThumbnail(context, next, context.params.id, domain)\n})\n\n/**\n * The picture of a figure the caller may read, by its hash (ADR-0104 decision 6). Exported for tests, which give\n * their own `files`.\n */\nexport async function sendFigureThumbnail(\n context: any,\n next: () => Promise<any>,\n id: string,\n domain: { id: string; parentId?: string },\n files: ThumbnailFiles = thumbnailFiles()\n): Promise<void> {\n /*\n 읽기는 부모 도메인까지 본다 — `figure` 질의와 같은 범위다. 물려받은 형상을 목록에는 보여 주고\n 그림만 404 를 내면, 화면은 「그림이 아직 없다」고 거짓말하게 된다.\n */\n const found = await getRepository(Figure).findOne({\n where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id },\n relations: { domain: true },\n select: { id: true, thumbnailHash: true, thumbnailUpdatedAt: true, domain: { id: true } }\n })\n if (!found?.thumbnailHash) {\n context.status = 404\n return\n }\n\n /*\n 그림은 **그 figure 의 도메인**에서 찾는다 — 묻는 도메인이 아니다(판정 조건 4). 해시가 같아도 다른 도메인의 행을\n 집지 않는다. 물려받은 figure 의 그림은 부모 도메인에 있다.\n */\n const picture = await thumbnailAttachmentOf({ getRepository }, found.domain!.id, found.thumbnailHash)\n if (!picture) {\n context.status = 404\n return\n }\n\n /*\n **개인 캐시만 허락한다.** 이 그림은 권한을 본 뒤에 내주는 것이므로 공용 캐시(프록시)에 남으면 안 된다 —\n attachment 의 공개 경로가 쓰는 `public` 을 따라 쓰지 않는다(판정 조건 1). 같은 내용은 같은 해시이므로 ETag 가\n 곧 내용이다. 부르는 쪽이 `?v=` 로 판을 가리므로 오래 두어도 낡은 그림이 남지 않는다.\n */\n const ours = () => {\n context.type = picture.mimetype\n context.set('Cache-Control', 'private, max-age=31536000')\n context.set('ETag', `\"${found.thumbnailHash}\"`)\n if (found.thumbnailUpdatedAt) context.set('Last-Modified', new Date(found.thumbnailUpdatedAt).toUTCString())\n }\n ours()\n /* koa answers `fresh` only for a 2xx: before a body the status is still 404, and the check never matched. */\n context.status = 200\n if (context.fresh) {\n context.status = 304\n return\n }\n await files.send(context, picture.path!, next)\n /* The storage sets its own (S3 its ETag, attachment's Cache-Control): this door's are said last. */\n ours()\n}\n\n/**\n * A recipe template's thumbnail (ADR-0095 amended): the file drawn from its model, served behind the same door as the\n * templates themselves. The file changes only with a release, so a day's private cache is enough.\n */\nfigureThumbnailRouter.get('/figure-recipe-thumbnail/:name', async context => {\n const { domain, user, unsafeIP, prohibitedPrivileges } = context.state\n const allowed = await checkPermission({ category: 'figure', privilege: 'query', owner: true }, user, domain, unsafeIP, prohibitedPrivileges)\n if (!allowed) {\n context.status = 403\n return\n }\n const template = recipeTemplates().templates.find(t => t.recipe.name === context.params.name)\n if (!template?.thumbnail) {\n context.status = 404\n return\n }\n context.type = 'image/png'\n context.set('Cache-Control', 'private, max-age=86400')\n context.body = readFileSync(template.thumbnail)\n})\n"]}
@@ -28,6 +28,7 @@ export declare class FigureHistory implements HistoryEntityInterface<Figure> {
28
28
  source?: string;
29
29
  properties?: string;
30
30
  thumbnail?: string;
31
+ thumbnailHash?: string;
31
32
  score?: number;
32
33
  triangles?: number;
33
34
  groups?: number;
@@ -90,6 +90,11 @@ tslib_1.__decorate([
90
90
  (0, type_graphql_1.Field)({ nullable: true, description: 'The thumbnail as it looked at this version.' }),
91
91
  tslib_1.__metadata("design:type", String)
92
92
  ], FigureHistory.prototype, "thumbnail", void 0);
93
+ tslib_1.__decorate([
94
+ (0, typeorm_1.Column)({ nullable: true }),
95
+ (0, type_graphql_1.Field)({ nullable: true, description: 'SHA-256 of the thumbnail picture; the picture is served by /figure-thumbnail/:id.' }),
96
+ tslib_1.__metadata("design:type", String)
97
+ ], FigureHistory.prototype, "thumbnailHash", void 0);
93
98
  tslib_1.__decorate([
94
99
  (0, typeorm_1.Column)({ type: 'float', nullable: true }),
95
100
  (0, type_graphql_1.Field)(type => type_graphql_1.Float, { nullable: true, description: 'Reuse score as computed at this version.' }),
@@ -1 +1 @@
1
- {"version":3,"file":"figure-history.js","sourceRoot":"","sources":["../../../server/service/figure/figure-history.ts"],"names":[],"mappings":"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","sourcesContent":["import { Field, ID, ObjectType, Float, Int } from 'type-graphql'\nimport { GraphQLJSON } from 'graphql-scalars'\nimport { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, RelationId } from 'typeorm'\n\nimport {\n HistoryActionColumn,\n HistoryActionType,\n HistoryEntityInterface,\n HistoryOriginalIdColumn\n} from '@operato/typeorm-history'\nimport { User } from '@things-factory/auth-base'\nimport { Domain, longTextColumn } from '@things-factory/shell'\n\nimport { Figure } from './figure.js'\n\n/**\n * 발행된 판 하나 — **발행할 때만 남는다.**\n *\n * 저작 중 저장은 여기 오지 않는다. 스냅샷을 뜨는 subscriber 가 `state === 'released'` 인 저장에만\n * 움직인다(`event-subscriber.ts`) — board 가 같은 규칙으로 돈다.\n *\n * ## 왜 통째로 베끼나\n *\n * 판을 되세우려면 그때의 정본만으로는 모자란다. 이름·분류·그림·비용까지 함께 있어야 「그 판이\n * 무엇이었나」를 보여 주고 되돌릴 수 있다. 파생값(점수·삼각형·묶음)도 같이 둔다 — 다시 계산하면\n * 형식이 바뀐 오늘의 값이 나오고, 그것은 그 판의 값이 아니다.\n */\n@Entity()\n@Index('ix_figure_history_0', (history: FigureHistory) => [history.originalId, history.version], { unique: true })\n@ObjectType({ description: 'A released version of a figure — one row per release.' })\nexport class FigureHistory implements HistoryEntityInterface<Figure> {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { description: 'Unique identifier for this history record.' })\n readonly id: string\n\n @Column({ nullable: true, default: 1 })\n @Field(type => Int, { nullable: true, description: 'The published version number this record holds.' })\n version?: number = 1\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { nullable: true, description: 'The domain the figure belonged to.' })\n domain?: Domain\n\n @RelationId((history: FigureHistory) => history.domain)\n domainId?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'The stored identifier of the figure. It never changes.' })\n type?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'The display name at this version.' })\n name?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'The description at this version.' })\n description?: string\n\n @Column('simple-json', { nullable: true, default: null })\n @Field(type => GraphQLJSON, { nullable: true, description: 'Display names by language at this version.' })\n names?: Record<string, string>\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'The classification code at this version.' })\n category?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'The lifecycle state recorded with this version.' })\n state?: 'draft' | 'released'\n\n @Column({ nullable: true, ...longTextColumn() })\n @Field({ nullable: true, description: 'The figure asset JSON (figure-asset-1) at this version.' })\n source?: string\n\n @Column({ nullable: true, ...longTextColumn() })\n @Field({ nullable: true, description: 'The master-data field definition at this version.' })\n properties?: string\n\n @Column({ nullable: true, ...longTextColumn() })\n @Field({ nullable: true, description: 'The thumbnail as it looked at this version.' })\n thumbnail?: string\n\n /*\n * 그때의 비용이다. 오늘 다시 계산하면 오늘의 형식으로 나온다 — 그 판을 설명하지 못한다.\n */\n @Column({ type: 'float', nullable: true })\n @Field(type => Float, { nullable: true, description: 'Reuse score as computed at this version.' })\n score?: number\n\n @Column({ nullable: true })\n @Field(type => Int, { nullable: true, description: 'Triangle count at this version.' })\n triangles?: number\n\n @Column({ nullable: true })\n @Field(type => Int, { nullable: true, description: 'Material group count at this version.' })\n groups?: number\n\n @Column({ nullable: true, ...longTextColumn() })\n @Field({ nullable: true, description: 'The note left when this version was released.' })\n comment?: string\n\n /*\n * 이 판의 정본 형식(`figure-asset-1`)과, 직전 판에 견준 변경(ADR-0104 결정 5). 발행할 때 한 번 정하고 다시\n * 계산하지 않는다 — 판을 고정해 쓰는 쪽은 다음 판으로 옮기기 전에 이것을 읽는다.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'The asset format this version was written in (figure-asset-1).' })\n assetFormat?: string\n\n @Column({ nullable: true, ...longTextColumn() })\n @Field({\n nullable: true,\n description:\n 'What changed against the previous release, as JSON: [{ code, blocking, subject?, values, detail }]. [] for the first release. null when it was not compared: the previous release could not be read, or this release was made before releases recorded it (figure-service 10.1.109).'\n })\n changes?: string\n\n @Column({ nullable: true, ...longTextColumn() })\n @Field({\n nullable: true,\n description:\n 'What this version measured once, as JSON (figure-model figureReleaseMetricsOf): { atLargest?: { size, triangles, groups }, linear?: { scalable, parts, probe } }. Only for a figure that takes its size from the instance; {} otherwise.'\n })\n metrics?: string\n\n @Column({ nullable: true, ...longTextColumn() })\n @Field({\n nullable: true,\n description: 'The plan from above at the authored size, as JSON (figure-model figureTopViewOf): { shapes: [{ id, color, outline, holes, top, transparent }], size }.'\n })\n topView?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'When the original figure was created.' })\n createdAt?: Date\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'When the figure was saved at this version.' })\n updatedAt?: Date\n\n @ManyToOne(type => User, { nullable: true })\n @Field(type => User, { nullable: true, description: 'Who created the original figure.' })\n creator?: User\n\n @RelationId((history: FigureHistory) => history.creator)\n creatorId?: string\n\n @ManyToOne(type => User, { nullable: true })\n @Field(type => User, { nullable: true, description: 'Who released this version.' })\n updater?: User\n\n @RelationId((history: FigureHistory) => history.updater)\n updaterId?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'When the original figure was deleted, if it was.' })\n deletedAt?: Date\n\n @HistoryOriginalIdColumn()\n @Field({ description: 'The id of the figure this version belongs to.' })\n public originalId!: string\n\n @HistoryActionColumn({ nullable: false, type: 'varchar', length: 10 })\n @Field(type => String, { description: 'What produced this record (created · updated · deleted).' })\n public action!: HistoryActionType\n}\n"]}
1
+ {"version":3,"file":"figure-history.js","sourceRoot":"","sources":["../../../server/service/figure/figure-history.ts"],"names":[],"mappings":"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","sourcesContent":["import { Field, ID, ObjectType, Float, Int } from 'type-graphql'\nimport { GraphQLJSON } from 'graphql-scalars'\nimport { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, RelationId } from 'typeorm'\n\nimport {\n HistoryActionColumn,\n HistoryActionType,\n HistoryEntityInterface,\n HistoryOriginalIdColumn\n} from '@operato/typeorm-history'\nimport { User } from '@things-factory/auth-base'\nimport { Domain, longTextColumn } from '@things-factory/shell'\n\nimport { Figure } from './figure.js'\n\n/**\n * 발행된 판 하나 — **발행할 때만 남는다.**\n *\n * 저작 중 저장은 여기 오지 않는다. 스냅샷을 뜨는 subscriber 가 `state === 'released'` 인 저장에만\n * 움직인다(`event-subscriber.ts`) — board 가 같은 규칙으로 돈다.\n *\n * ## 왜 통째로 베끼나\n *\n * 판을 되세우려면 그때의 정본만으로는 모자란다. 이름·분류·그림·비용까지 함께 있어야 「그 판이\n * 무엇이었나」를 보여 주고 되돌릴 수 있다. 파생값(점수·삼각형·묶음)도 같이 둔다 — 다시 계산하면\n * 형식이 바뀐 오늘의 값이 나오고, 그것은 그 판의 값이 아니다.\n */\n@Entity()\n@Index('ix_figure_history_0', (history: FigureHistory) => [history.originalId, history.version], { unique: true })\n@ObjectType({ description: 'A released version of a figure — one row per release.' })\nexport class FigureHistory implements HistoryEntityInterface<Figure> {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { description: 'Unique identifier for this history record.' })\n readonly id: string\n\n @Column({ nullable: true, default: 1 })\n @Field(type => Int, { nullable: true, description: 'The published version number this record holds.' })\n version?: number = 1\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { nullable: true, description: 'The domain the figure belonged to.' })\n domain?: Domain\n\n @RelationId((history: FigureHistory) => history.domain)\n domainId?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'The stored identifier of the figure. It never changes.' })\n type?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'The display name at this version.' })\n name?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'The description at this version.' })\n description?: string\n\n @Column('simple-json', { nullable: true, default: null })\n @Field(type => GraphQLJSON, { nullable: true, description: 'Display names by language at this version.' })\n names?: Record<string, string>\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'The classification code at this version.' })\n category?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'The lifecycle state recorded with this version.' })\n state?: 'draft' | 'released'\n\n @Column({ nullable: true, ...longTextColumn() })\n @Field({ nullable: true, description: 'The figure asset JSON (figure-asset-1) at this version.' })\n source?: string\n\n @Column({ nullable: true, ...longTextColumn() })\n @Field({ nullable: true, description: 'The master-data field definition at this version.' })\n properties?: string\n\n @Column({ nullable: true, ...longTextColumn() })\n @Field({ nullable: true, description: 'The thumbnail as it looked at this version.' })\n thumbnail?: string\n\n /*\n * 그림의 내용 해시(SHA-256). 그림 자체는 attachment-base 가 갖고(refType `figure-thumbnail`, refBy 이 해시),\n * `/figure-thumbnail/:id` 가 권한을 본 뒤 내준다(ADR-0104 결정 6). `thumbnail` 칸은 옛 base64 가 남은 행을\n * 기동 때 옮기기 위한 칸이다 — 옮긴 뒤에는 비어 있고, 새 그림은 그 칸에 쓰지 않는다.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'SHA-256 of the thumbnail picture; the picture is served by /figure-thumbnail/:id.' })\n thumbnailHash?: string\n\n /*\n * 그때의 비용이다. 오늘 다시 계산하면 오늘의 형식으로 나온다 — 그 판을 설명하지 못한다.\n */\n @Column({ type: 'float', nullable: true })\n @Field(type => Float, { nullable: true, description: 'Reuse score as computed at this version.' })\n score?: number\n\n @Column({ nullable: true })\n @Field(type => Int, { nullable: true, description: 'Triangle count at this version.' })\n triangles?: number\n\n @Column({ nullable: true })\n @Field(type => Int, { nullable: true, description: 'Material group count at this version.' })\n groups?: number\n\n @Column({ nullable: true, ...longTextColumn() })\n @Field({ nullable: true, description: 'The note left when this version was released.' })\n comment?: string\n\n /*\n * 이 판의 정본 형식(`figure-asset-1`)과, 직전 판에 견준 변경(ADR-0104 결정 5). 발행할 때 한 번 정하고 다시\n * 계산하지 않는다 — 판을 고정해 쓰는 쪽은 다음 판으로 옮기기 전에 이것을 읽는다.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'The asset format this version was written in (figure-asset-1).' })\n assetFormat?: string\n\n @Column({ nullable: true, ...longTextColumn() })\n @Field({\n nullable: true,\n description:\n 'What changed against the previous release, as JSON: [{ code, blocking, subject?, values, detail }]. [] for the first release. null when it was not compared: the previous release could not be read, or this release was made before releases recorded it (figure-service 10.1.109).'\n })\n changes?: string\n\n @Column({ nullable: true, ...longTextColumn() })\n @Field({\n nullable: true,\n description:\n 'What this version measured once, as JSON (figure-model figureReleaseMetricsOf): { atLargest?: { size, triangles, groups }, linear?: { scalable, parts, probe } }. Only for a figure that takes its size from the instance; {} otherwise.'\n })\n metrics?: string\n\n @Column({ nullable: true, ...longTextColumn() })\n @Field({\n nullable: true,\n description: 'The plan from above at the authored size, as JSON (figure-model figureTopViewOf): { shapes: [{ id, color, outline, holes, top, transparent }], size }.'\n })\n topView?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'When the original figure was created.' })\n createdAt?: Date\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'When the figure was saved at this version.' })\n updatedAt?: Date\n\n @ManyToOne(type => User, { nullable: true })\n @Field(type => User, { nullable: true, description: 'Who created the original figure.' })\n creator?: User\n\n @RelationId((history: FigureHistory) => history.creator)\n creatorId?: string\n\n @ManyToOne(type => User, { nullable: true })\n @Field(type => User, { nullable: true, description: 'Who released this version.' })\n updater?: User\n\n @RelationId((history: FigureHistory) => history.updater)\n updaterId?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'When the original figure was deleted, if it was.' })\n deletedAt?: Date\n\n @HistoryOriginalIdColumn()\n @Field({ description: 'The id of the figure this version belongs to.' })\n public originalId!: string\n\n @HistoryActionColumn({ nullable: false, type: 'varchar', length: 10 })\n @Field(type => String, { description: 'What produced this record (created · updated · deleted).' })\n public action!: HistoryActionType\n}\n"]}
@@ -40,7 +40,7 @@ let FigureMutation = class FigureMutation {
40
40
  if (!id) {
41
41
  if (!creationId || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(creationId))
42
42
  throw Error('새 모델 저장에는 생성 요청 식별자가 필요합니다.');
43
- const prepared = { ...(0, figure_write_js_1.prepareFigureWrite)(source), ...(0, figure_write_js_1.pictureOf)(thumbnail) };
43
+ const prepared = { ...(0, figure_write_js_1.prepareFigureWrite)(source), ...(await (0, figure_write_js_1.pictureOf)(tx, domain, user, thumbnail)) };
44
44
  const previous = await repository.findOne({ where: { id: creationId, domain: { id: domain.id } } });
45
45
  if (previous) {
46
46
  if (previous.source !== prepared.source)
@@ -72,7 +72,7 @@ let FigureMutation = class FigureMutation {
72
72
  const figure = await repository.findOne({ where: { id, domain: { id: domain.id } } });
73
73
  if (!figure)
74
74
  throw Error('편집할 Figure를 찾을 수 없습니다.');
75
- const prepared = { ...(0, figure_write_js_1.prepareFigureWrite)(source, figure, expectedSource), ...(0, figure_write_js_1.pictureOf)(thumbnail, figure) };
75
+ const prepared = { ...(0, figure_write_js_1.prepareFigureWrite)(source, figure, expectedSource), ...(await (0, figure_write_js_1.pictureOf)(tx, domain, user, thumbnail, figure)) };
76
76
  // Compare-and-swap in the UPDATE itself: supported by SQLite as well as PostgreSQL.
77
77
  // A pessimistic row lock is not supported by the product's SQLite deployment.
78
78
  await (0, figure_write_js_1.compareAndSwapFigure)(criteria => repository.update(criteria, { ...prepared, updater: user }), id, domain.id, expectedSource);
@@ -89,12 +89,13 @@ let FigureMutation = class FigureMutation {
89
89
  }
90
90
  // 형식을 어기면 여기서 던진다. 파생값은 서버가 계산한다.
91
91
  const { source, derived, violations } = (0, figure_format_js_2.parseSource)(figure.source);
92
+ const { thumbnail, ...rest } = figure;
92
93
  const saved = await repository.save({
93
- ...figure,
94
+ ...rest,
94
95
  source,
95
96
  ...derived,
96
97
  state: figure.state ?? 'draft',
97
- thumbnailUpdatedAt: figure.thumbnail ? new Date() : undefined,
98
+ ...(await (0, figure_write_js_1.pictureOf)(tx, domain, user, thumbnail)),
98
99
  domain,
99
100
  creator: user,
100
101
  updater: user
@@ -133,6 +134,9 @@ let FigureMutation = class FigureMutation {
133
134
  */
134
135
  if (patch.names !== undefined)
135
136
  assertNames(patch.names);
137
+ /* The picture is kept by its content; the patch's data URL is never written to the row. */
138
+ const { thumbnail: picturePatch, ...patchRest } = patch;
139
+ const picture = await (0, figure_write_js_1.pictureOf)(tx, domain, user, picturePatch, figure);
136
140
  let renamed;
137
141
  if (patch.type !== undefined && patch.type !== figure.type) {
138
142
  const change = await this.renameType(figure, domain, patch.type, source, tx);
@@ -153,7 +157,7 @@ let FigureMutation = class FigureMutation {
153
157
  */
154
158
  domain,
155
159
  ...figure,
156
- ...patch,
160
+ ...patchRest,
157
161
  /* 앞뒤 공백을 떼고 겹침까지 본 이름. `patch.type` 을 그대로 두면 다듬기 전 값이 들어간다. */
158
162
  ...(renamed !== undefined ? { type: renamed } : {}),
159
163
  source,
@@ -167,9 +171,8 @@ let FigureMutation = class FigureMutation {
167
171
  state: 'draft',
168
172
  ...(derived ?? {}),
169
173
  // 썸네일이 **썸네일로서** 바뀐 때만 시각을 갱신한다. 이름·설명 변경에는 안 건드린다.
170
- thumbnailUpdatedAt: patch.thumbnail !== undefined && patch.thumbnail !== figure.thumbnail
171
- ? new Date()
172
- : figure.thumbnailUpdatedAt,
174
+ thumbnail: figure.thumbnail ?? null,
175
+ ...picture,
173
176
  updater: user
174
177
  });
175
178
  return { figure: saved, violations };