@things-factory/figure-service 10.1.111 → 10.1.112

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 (44) hide show
  1. package/dist-server/index.js +15 -0
  2. package/dist-server/index.js.map +1 -1
  3. package/dist-server/routers/figure-thumbnail-router.d.ts +12 -3
  4. package/dist-server/routers/figure-thumbnail-router.js +36 -30
  5. package/dist-server/routers/figure-thumbnail-router.js.map +1 -1
  6. package/dist-server/service/figure/figure-history.d.ts +1 -0
  7. package/dist-server/service/figure/figure-history.js +5 -0
  8. package/dist-server/service/figure/figure-history.js.map +1 -1
  9. package/dist-server/service/figure/figure-mutation.js +11 -8
  10. package/dist-server/service/figure/figure-mutation.js.map +1 -1
  11. package/dist-server/service/figure/figure-replaced-draft.d.ts +1 -0
  12. package/dist-server/service/figure/figure-replaced-draft.js +5 -0
  13. package/dist-server/service/figure/figure-replaced-draft.js.map +1 -1
  14. package/dist-server/service/figure/figure-revert.js +9 -4
  15. package/dist-server/service/figure/figure-revert.js.map +1 -1
  16. package/dist-server/service/figure/figure-thumbnail-store.d.ts +35 -0
  17. package/dist-server/service/figure/figure-thumbnail-store.js +96 -0
  18. package/dist-server/service/figure/figure-thumbnail-store.js.map +1 -0
  19. package/dist-server/service/figure/figure-thumbnail-upgrade.d.ts +8 -0
  20. package/dist-server/service/figure/figure-thumbnail-upgrade.js +56 -0
  21. package/dist-server/service/figure/figure-thumbnail-upgrade.js.map +1 -0
  22. package/dist-server/service/figure/figure-write.d.ts +14 -10
  23. package/dist-server/service/figure/figure-write.js +9 -4
  24. package/dist-server/service/figure/figure-write.js.map +1 -1
  25. package/dist-server/service/figure/figure.d.ts +1 -0
  26. package/dist-server/service/figure/figure.js +5 -0
  27. package/dist-server/service/figure/figure.js.map +1 -1
  28. package/dist-server/service/figure/index.d.ts +1 -1
  29. package/dist-server/service/index.d.ts +1 -1
  30. package/dist-server/tsconfig.tsbuildinfo +1 -1
  31. package/package.json +3 -2
  32. package/server/index.ts +15 -0
  33. package/server/routers/figure-thumbnail-router.ts +40 -36
  34. package/server/service/figure/figure-history.ts +9 -0
  35. package/server/service/figure/figure-mutation.ts +11 -9
  36. package/server/service/figure/figure-replaced-draft.ts +9 -0
  37. package/server/service/figure/figure-revert-db.test.ts +6 -5
  38. package/server/service/figure/figure-revert.ts +9 -4
  39. package/server/service/figure/figure-thumbnail-db.test.ts +218 -0
  40. package/server/service/figure/figure-thumbnail-store.ts +107 -0
  41. package/server/service/figure/figure-thumbnail-upgrade.pg.test.ts +93 -0
  42. package/server/service/figure/figure-thumbnail-upgrade.ts +70 -0
  43. package/server/service/figure/figure-write.ts +15 -4
  44. package/server/service/figure/figure.ts +9 -0
@@ -55,6 +55,11 @@ tslib_1.__decorate([
55
55
  (0, typeorm_1.Column)({ nullable: true, ...(0, shell_1.longTextColumn)() }),
56
56
  tslib_1.__metadata("design:type", String)
57
57
  ], FigureReplacedDraft.prototype, "thumbnail", void 0);
58
+ tslib_1.__decorate([
59
+ (0, typeorm_1.Column)({ nullable: true }),
60
+ (0, type_graphql_1.Field)({ nullable: true, description: 'SHA-256 of the thumbnail picture; the picture is served by /figure-thumbnail/:id.' }),
61
+ tslib_1.__metadata("design:type", String)
62
+ ], FigureReplacedDraft.prototype, "thumbnailHash", void 0);
58
63
  tslib_1.__decorate([
59
64
  (0, typeorm_1.Column)({ nullable: true }),
60
65
  (0, type_graphql_1.Field)({ nullable: true, description: 'The state the figure was in.' }),
@@ -1 +1 @@
1
- {"version":3,"file":"figure-replaced-draft.js","sourceRoot":"","sources":["../../../server/service/figure/figure-replaced-draft.ts"],"names":[],"mappings":";;;;AAAA,qCAAgH;AAChH,+CAAyD;AAEzD,yDAAgD;AAChD,iDAA8D;AAE9D;;;;;;;;;GASG;AAKI,IAAM,mBAAmB,GAAzB,MAAM,mBAAmB;CAqE/B,CAAA;AArEY,kDAAmB;AAGrB;IAFR,IAAA,gCAAsB,EAAC,MAAM,CAAC;IAC9B,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,iBAAE,EAAE,EAAE,WAAW,EAAE,sCAAsC,EAAE,CAAC;;+CACvD;AAGpB;IADC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,cAAM,CAAC;sCACjB,cAAM;mDAAA;AAGf;IADC,IAAA,oBAAU,EAAC,CAAC,IAAyB,EAAE,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC;;qDACtC;AAIjB;IAFC,IAAA,gBAAM,GAAE;IACR,IAAA,oBAAK,EAAC,EAAE,WAAW,EAAE,kCAAkC,EAAE,CAAC;;qDAC1C;AAIjB;IAFC,IAAA,gBAAM,EAAC,EAAE,OAAO,EAAE,QAAQ,EAAE,CAAC;IAC7B,IAAA,oBAAK,EAAC,EAAE,WAAW,EAAE,kGAAkG,EAAE,CAAC;;iDACnG;AAIxB;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,GAAG,IAAA,sBAAc,GAAE,EAAE,CAAC;IAC/C,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,uCAAuC,EAAE,CAAC;;mDACjE;AAGf;IADC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,GAAG,IAAA,sBAAc,GAAE,EAAE,CAAC;;uDAC7B;AAGnB;IADC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,GAAG,IAAA,sBAAc,GAAE,EAAE,CAAC;;sDAC9B;AAIlB;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,8BAA8B,EAAE,CAAC;;kDACzD;AAId;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,kBAAG,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,kDAAkD,EAAE,CAAC;;oDACxF;AAIhB;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,kBAAG,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,kEAAkE,EAAE,CAAC;;8DAC9F;AAI1B;IADC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,GAAG,IAAA,sBAAc,GAAE,EAAE,CAAC;;0DAC1B;AAItB;IADC,IAAA,gBAAM,GAAE;;8DACiB;AAG1B;IADC,IAAA,gBAAM,GAAE;;sDACS;AAIlB;IAFC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC1B,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,8CAA8C,EAAE,CAAC;sCAC1E,IAAI;uDAAA;AAGjB;IADC,IAAA,gBAAM,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;;6DACF;AAIzB;IAFC,IAAA,0BAAgB,GAAE;IAClB,IAAA,oBAAK,EAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,kDAAkD,EAAE,CAAC;sCAC/E,IAAI;sDAAA;AAIhB;IAFC,IAAA,mBAAS,EAAC,IAAI,CAAC,EAAE,CAAC,gBAAI,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC;IAC3C,IAAA,oBAAK,EAAC,IAAI,CAAC,EAAE,CAAC,gBAAI,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,WAAW,EAAE,eAAe,EAAE,CAAC;sCAC5D,gBAAI;oDAAA;AAGd;IADC,IAAA,oBAAU,EAAC,CAAC,IAAyB,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC;;sDACtC;8BApEP,mBAAmB;IAJ/B,IAAA,gBAAM,GAAE;IACR,IAAA,eAAK,EAAC,4BAA4B,EAAE,CAAC,IAAyB,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC;IACnH,IAAA,eAAK,EAAC,4BAA4B,EAAE,CAAC,IAAyB,EAAE,EAAE,CAAC,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,QAAQ,CAAC,CAAC;IAChG,IAAA,yBAAU,EAAC,EAAE,WAAW,EAAE,0EAA0E,EAAE,CAAC;GAC3F,mBAAmB,CAqE/B","sourcesContent":["import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, RelationId } from 'typeorm'\nimport { Field, ID, Int, ObjectType } from 'type-graphql'\n\nimport { User } from '@things-factory/auth-base'\nimport { Domain, longTextColumn } from '@things-factory/shell'\n\n/**\n * A figure's draft as it stood before a revert wrote over it -- kept, so what was saved just before a revert can be\n * brought back, after a reload too (ADR-0097 §5.6, designer 2026-09-27; table chosen with the chief architect).\n *\n * Not a released version: it has no version number of its own, is not listed with the releases, and never changes\n * what a board uses. Kept in the same transaction as the revert that replaces the draft -- kept, or neither happens.\n * `requestId` makes a retried revert (its answer lost) the same revert, not a second one.\n *\n * An undo of a revert is a revert too (`kind: 'undo'`): it keeps what it replaces the same way.\n */\n@Entity()\n@Index('ix_figure_replaced_draft_1', (kept: FigureReplacedDraft) => [kept.domain, kept.requestId], { unique: true })\n@Index('ix_figure_replaced_draft_2', (kept: FigureReplacedDraft) => [kept.domain, kept.figureId])\n@ObjectType({ description: 'A figure draft kept when a revert wrote over it. Not a released version.' })\nexport class FigureReplacedDraft {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { description: 'Unique identifier of the kept draft.' })\n readonly id?: string\n\n @ManyToOne(type => Domain)\n domain?: Domain\n\n @RelationId((kept: FigureReplacedDraft) => kept.domain)\n domainId?: string\n\n @Column()\n @Field({ description: 'The figure whose draft this was.' })\n figureId?: string\n\n @Column({ default: 'revert' })\n @Field({ description: \"What replaced it: 'revert' (a released version written over it) or 'undo' (an undo of a revert).\" })\n kind?: 'revert' | 'undo'\n\n @Column({ nullable: true, ...longTextColumn() })\n @Field({ nullable: true, description: 'The figure asset JSON the draft held.' })\n source?: string\n\n @Column({ nullable: true, ...longTextColumn() })\n properties?: string\n\n @Column({ nullable: true, ...longTextColumn() })\n thumbnail?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'The state the figure was in.' })\n state?: string\n\n @Column({ nullable: true })\n @Field(type => Int, { nullable: true, description: 'The released version number the figure had then.' })\n version?: number\n\n @Column({ nullable: true })\n @Field(type => Int, { nullable: true, description: 'The released version the revert wrote over it; none for an undo.' })\n revertedToVersion?: number\n\n /* What replaced it, whole: an undo proceeds only while the figure still holds exactly this (compare-and-swap). */\n @Column({ nullable: true, ...longTextColumn() })\n writtenSource?: string\n\n /* Its content hash, for finding and retrying without reading the whole text (chief architect, 2026-09-27). */\n @Column()\n writtenSourceHash?: string\n\n @Column()\n requestId?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'When this draft was brought back by an undo.' })\n restoredAt?: Date\n\n @Column({ nullable: true })\n restoreRequestId?: string\n\n @CreateDateColumn()\n @Field({ nullable: true, description: 'When it was kept -- when the revert replaced it.' })\n createdAt?: Date\n\n @ManyToOne(type => User, { nullable: true })\n @Field(type => User, { nullable: true, description: 'Who reverted.' })\n creator?: User\n\n @RelationId((kept: FigureReplacedDraft) => kept.creator)\n creatorId?: string\n}\n"]}
1
+ {"version":3,"file":"figure-replaced-draft.js","sourceRoot":"","sources":["../../../server/service/figure/figure-replaced-draft.ts"],"names":[],"mappings":"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","sourcesContent":["import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, RelationId } from 'typeorm'\nimport { Field, ID, Int, ObjectType } from 'type-graphql'\n\nimport { User } from '@things-factory/auth-base'\nimport { Domain, longTextColumn } from '@things-factory/shell'\n\n/**\n * A figure's draft as it stood before a revert wrote over it -- kept, so what was saved just before a revert can be\n * brought back, after a reload too (ADR-0097 §5.6, designer 2026-09-27; table chosen with the chief architect).\n *\n * Not a released version: it has no version number of its own, is not listed with the releases, and never changes\n * what a board uses. Kept in the same transaction as the revert that replaces the draft -- kept, or neither happens.\n * `requestId` makes a retried revert (its answer lost) the same revert, not a second one.\n *\n * An undo of a revert is a revert too (`kind: 'undo'`): it keeps what it replaces the same way.\n */\n@Entity()\n@Index('ix_figure_replaced_draft_1', (kept: FigureReplacedDraft) => [kept.domain, kept.requestId], { unique: true })\n@Index('ix_figure_replaced_draft_2', (kept: FigureReplacedDraft) => [kept.domain, kept.figureId])\n@ObjectType({ description: 'A figure draft kept when a revert wrote over it. Not a released version.' })\nexport class FigureReplacedDraft {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { description: 'Unique identifier of the kept draft.' })\n readonly id?: string\n\n @ManyToOne(type => Domain)\n domain?: Domain\n\n @RelationId((kept: FigureReplacedDraft) => kept.domain)\n domainId?: string\n\n @Column()\n @Field({ description: 'The figure whose draft this was.' })\n figureId?: string\n\n @Column({ default: 'revert' })\n @Field({ description: \"What replaced it: 'revert' (a released version written over it) or 'undo' (an undo of a revert).\" })\n kind?: 'revert' | 'undo'\n\n @Column({ nullable: true, ...longTextColumn() })\n @Field({ nullable: true, description: 'The figure asset JSON the draft held.' })\n source?: string\n\n @Column({ nullable: true, ...longTextColumn() })\n properties?: string\n\n @Column({ nullable: true, ...longTextColumn() })\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 @Column({ nullable: true })\n @Field({ nullable: true, description: 'The state the figure was in.' })\n state?: string\n\n @Column({ nullable: true })\n @Field(type => Int, { nullable: true, description: 'The released version number the figure had then.' })\n version?: number\n\n @Column({ nullable: true })\n @Field(type => Int, { nullable: true, description: 'The released version the revert wrote over it; none for an undo.' })\n revertedToVersion?: number\n\n /* What replaced it, whole: an undo proceeds only while the figure still holds exactly this (compare-and-swap). */\n @Column({ nullable: true, ...longTextColumn() })\n writtenSource?: string\n\n /* Its content hash, for finding and retrying without reading the whole text (chief architect, 2026-09-27). */\n @Column()\n writtenSourceHash?: string\n\n @Column()\n requestId?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'When this draft was brought back by an undo.' })\n restoredAt?: Date\n\n @Column({ nullable: true })\n restoreRequestId?: string\n\n @CreateDateColumn()\n @Field({ nullable: true, description: 'When it was kept -- when the revert replaced it.' })\n createdAt?: Date\n\n @ManyToOne(type => User, { nullable: true })\n @Field(type => User, { nullable: true, description: 'Who reverted.' })\n creator?: User\n\n @RelationId((kept: FigureReplacedDraft) => kept.creator)\n creatorId?: string\n}\n"]}
@@ -75,6 +75,7 @@ async function revertKeepingDraft(tx, domain, user, request) {
75
75
  source: figure.source,
76
76
  properties: figure.properties,
77
77
  thumbnail: figure.thumbnail,
78
+ thumbnailHash: figure.thumbnailHash,
78
79
  state: figure.state,
79
80
  version: figure.version,
80
81
  revertedToVersion: request.version,
@@ -87,8 +88,10 @@ async function revertKeepingDraft(tx, domain, user, request) {
87
88
  source,
88
89
  ...derived,
89
90
  properties: history.properties,
90
- thumbnail: history.thumbnail,
91
- thumbnailUpdatedAt: history.thumbnail === figure.thumbnail ? figure.thumbnailUpdatedAt : new Date(),
91
+ /* The picture goes by its hash (ADR-0104 decision 6); the release's own, as the source is. */
92
+ thumbnail: null,
93
+ thumbnailHash: history.thumbnailHash ?? null,
94
+ thumbnailUpdatedAt: history.thumbnailHash === figure.thumbnailHash ? figure.thumbnailUpdatedAt : new Date(),
92
95
  state: 'draft',
93
96
  ...(user ? { updater: { id: user.id } } : {})
94
97
  });
@@ -123,6 +126,7 @@ async function undoRevert(tx, domain, user, request) {
123
126
  source: figure.source,
124
127
  properties: figure.properties,
125
128
  thumbnail: figure.thumbnail,
129
+ thumbnailHash: figure.thumbnailHash,
126
130
  state: figure.state,
127
131
  version: figure.version,
128
132
  writtenSource: record.source,
@@ -134,8 +138,9 @@ async function undoRevert(tx, domain, user, request) {
134
138
  source: record.source,
135
139
  ...derived,
136
140
  properties: record.properties,
137
- thumbnail: record.thumbnail,
138
- thumbnailUpdatedAt: record.thumbnail === figure.thumbnail ? figure.thumbnailUpdatedAt : new Date(),
141
+ thumbnail: null,
142
+ thumbnailHash: record.thumbnailHash ?? null,
143
+ thumbnailUpdatedAt: record.thumbnailHash === figure.thumbnailHash ? figure.thumbnailUpdatedAt : new Date(),
139
144
  state: 'draft',
140
145
  ...(user ? { updater: { id: user.id } } : {})
141
146
  });
@@ -1 +1 @@
1
- {"version":3,"file":"figure-revert.js","sourceRoot":"","sources":["../../../server/service/figure/figure-revert.ts"],"names":[],"mappings":";;AAmDA,gDA6DC;AAGD,gCAwDC;AAGD,4CAMC;AApLD,mCAAmC;AACnC,qCAAgD;AAChD,qCAAsC;AAEtC,2CAAoC;AACpC,2DAAmD;AACnD,yEAAgE;AAChE,yDAAgD;AAChD,uDAAkD;AAElD;;;;;;;;;;;;;GAaG;AAEH,MAAM,IAAI,GAAG,wEAAwE,CAAA;AACrF,MAAM,MAAM,GAAG,CAAC,IAAY,EAAE,EAAE,CAAC,IAAA,mBAAU,EAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AAChF,MAAM,OAAO,GAAG,CAAC,IAAY,EAAE,OAAe,EAAE,EAAE,CAAC,IAAI,sBAAY,CAAC,OAAO,EAAE,EAAE,UAAU,EAAE,EAAE,IAAI,EAAE,EAAE,CAAC,CAAA;AAEtG;;;GAGG;AACH,KAAK,UAAU,IAAI,CAAC,IAA0C,EAAE,GAAQ;IACtE,IAAI,CAAC;QACH,OAAO,MAAM,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;IAC7B,CAAC;IAAC,OAAO,KAAU,EAAE,CAAC;QACpB,MAAM,MAAM,GAAG,KAAK,EAAE,IAAI,KAAK,OAAO,IAAI,KAAK,EAAE,WAAW,EAAE,IAAI,KAAK,OAAO,IAAI,0BAA0B,CAAC,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC,CAAA;QACzI,IAAI,MAAM;YAAE,MAAM,OAAO,CAAC,yBAAyB,EAAE,kCAAkC,CAAC,CAAA;QACxF,MAAM,KAAK,CAAA;IACb,CAAC;AACH,CAAC;AAUM,KAAK,UAAU,kBAAkB,CACtC,EAAiB,EACjB,MAAgD,EAChD,IAAgC,EAChC,OAAsB;IAEtB,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,SAAS,IAAI,EAAE,CAAC;QAAE,MAAM,OAAO,CAAC,uBAAuB,EAAE,qBAAqB,CAAC,CAAA;IACtG,MAAM,OAAO,GAAG,EAAE,CAAC,aAAa,CAAC,kBAAM,CAAC,CAAA;IACxC,MAAM,IAAI,GAAG,EAAE,CAAC,aAAa,CAAC,8CAAmB,CAAC,CAAA;IAElD,iEAAiE;IACjE,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,SAAS,EAAE,OAAO,CAAC,SAAS,EAAE,EAAE,CAAC,CAAA;IACzG,IAAI,MAAM,EAAE,CAAC;QACX,IAAI,MAAM,CAAC,QAAQ,KAAK,OAAO,CAAC,EAAE,IAAI,MAAM,CAAC,iBAAiB,KAAK,OAAO,CAAC,OAAO,IAAI,MAAM,CAAC,IAAI,KAAK,QAAQ;YAC5G,MAAM,OAAO,CAAC,uBAAuB,EAAE,mCAAmC,CAAC,CAAA;QAC7E,OAAO,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC,aAAa,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC,EAAE,UAAU,EAAE,EAAE,EAAE,IAAI,EAAE,MAAM,EAAE,CAAA;IACxI,CAAC;IAED,gGAAgG;IAChG,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC,CAAA;IAC9F,IAAI,CAAC,MAAM;QAAE,MAAM,OAAO,CAAC,kBAAkB,EAAE,UAAU,OAAO,CAAC,EAAE,eAAe,CAAC,CAAA;IACnF,IAAI,MAAM,CAAC,MAAM,KAAK,OAAO,CAAC,cAAc;QAAE,MAAM,IAAA,gCAAc,EAAC,6CAA6C,CAAC,CAAA;IAEjH,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,aAAa,CAAC,iCAAa,CAAC,CAAC,OAAO,CAAC;QAC5D,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,IAAA,YAAE,EAAC,CAAC,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,OAAO,CAAa,CAAC,EAAE,EAAE,UAAU,EAAE,OAAO,CAAC,EAAE,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO,EAAE;KAC1I,CAAC,CAAA;IACF,IAAI,CAAC,OAAO;QAAE,MAAM,OAAO,CAAC,0BAA0B,EAAE,WAAW,MAAM,CAAC,IAAI,OAAO,OAAO,CAAC,OAAO,WAAW,CAAC,CAAA;IAChH,qGAAqG;IACrG,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,EAAE,GAAG,IAAA,8BAAW,EAAC,OAAO,CAAC,MAAM,CAAC,CAAA;IAEnE,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,IAAI,EAAE;QAC9B,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE;QACzB,QAAQ,EAAE,MAAM,CAAC,EAAE;QACnB,IAAI,EAAE,QAAiB;QACvB,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,UAAU,EAAE,MAAM,CAAC,UAAU;QAC7B,SAAS,EAAE,MAAM,CAAC,SAAS;QAC3B,KAAK,EAAE,MAAM,CAAC,KAAK;QACnB,OAAO,EAAE,MAAM,CAAC,OAAO;QACvB,iBAAiB,EAAE,OAAO,CAAC,OAAO;QAClC,aAAa,EAAE,MAAM;QACrB,iBAAiB,EAAE,MAAM,CAAC,MAAM,CAAC;QACjC,SAAS,EAAE,OAAO,CAAC,SAAS;QAC5B,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC9C,CAAC,CAAA;IAEF,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,MAAM,CAClC,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,OAAO,CAAC,cAAc,EAAE,EAC5E;QACE,MAAM;QACN,GAAG,OAAO;QACV,UAAU,EAAE,OAAO,CAAC,UAAU;QAC9B,SAAS,EAAE,OAAO,CAAC,SAAS;QAC5B,kBAAkB,EAAE,OAAO,CAAC,SAAS,KAAK,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,kBAAkB,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE;QACnG,KAAK,EAAE,OAAO;QACd,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC9C,CACF,CAAA;IACD,IAAI,OAAO,CAAC,QAAQ,KAAK,CAAC;QAAE,MAAM,IAAA,gCAAc,EAAC,6CAA6C,CAAC,CAAA;IAE/F,OAAO,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC,aAAa,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,CAAA;AACnI,CAAC;AAED,8FAA8F;AACvF,KAAK,UAAU,UAAU,CAC9B,EAAiB,EACjB,MAAsB,EACtB,IAAgC,EAChC,OAA8C;IAE9C,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,SAAS,IAAI,EAAE,CAAC;QAAE,MAAM,OAAO,CAAC,uBAAuB,EAAE,wBAAwB,CAAC,CAAA;IACzG,MAAM,OAAO,GAAG,EAAE,CAAC,aAAa,CAAC,kBAAM,CAAC,CAAA;IACxC,MAAM,IAAI,GAAG,EAAE,CAAC,aAAa,CAAC,8CAAmB,CAAC,CAAA;IAElD,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,OAAO,CAAC,MAAM,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC,CAAA;IAC/F,IAAI,CAAC,MAAM;QAAE,MAAM,OAAO,CAAC,uBAAuB,EAAE,oBAAoB,CAAC,CAAA;IACzE,IAAI,MAAM,CAAC,UAAU,EAAE,CAAC;QACtB,6GAA6G;QAC7G,IAAI,MAAM,CAAC,gBAAgB,KAAK,OAAO,CAAC,SAAS;YAC/C,OAAO,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC,aAAa,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,CAAA;QAC7H,MAAM,OAAO,CAAC,sBAAsB,EAAE,kBAAkB,CAAC,CAAA;IAC3D,CAAC;IAED,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,OAAO,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC,CAAA;IACnG,IAAI,CAAC,MAAM;QAAE,MAAM,OAAO,CAAC,kBAAkB,EAAE,UAAU,MAAM,CAAC,QAAQ,eAAe,CAAC,CAAA;IAExF,sEAAsE;IACtE,MAAM,EAAE,OAAO,EAAE,GAAG,IAAA,8BAAW,EAAC,MAAM,CAAC,MAAO,CAAC,CAAA;IAC/C,MAAM,IAAI,CAAC,IAAI,EAAE;QACf,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE;QACzB,QAAQ,EAAE,MAAM,CAAC,EAAE;QACnB,IAAI,EAAE,MAAe;QACrB,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,UAAU,EAAE,MAAM,CAAC,UAAU;QAC7B,SAAS,EAAE,MAAM,CAAC,SAAS;QAC3B,KAAK,EAAE,MAAM,CAAC,KAAK;QACnB,OAAO,EAAE,MAAM,CAAC,OAAO;QACvB,aAAa,EAAE,MAAM,CAAC,MAAM;QAC5B,iBAAiB,EAAE,MAAM,CAAC,MAAM,CAAC,MAAO,CAAC;QACzC,SAAS,EAAE,OAAO,CAAC,SAAS;QAC5B,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC9C,CAAC,CAAA;IAEF,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,MAAM,CAClC,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,aAAa,EAAE,EAC1E;QACE,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,GAAG,OAAO;QACV,UAAU,EAAE,MAAM,CAAC,UAAU;QAC7B,SAAS,EAAE,MAAM,CAAC,SAAS;QAC3B,kBAAkB,EAAE,MAAM,CAAC,SAAS,KAAK,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,CAAC,kBAAkB,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE;QAClG,KAAK,EAAE,OAAO;QACd,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,EAAE,IAAI,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC9C,CACF,CAAA;IACD,IAAI,OAAO,CAAC,QAAQ,KAAK,CAAC;QACxB,MAAM,IAAA,gCAAc,EAAC,yDAAyD,CAAC,CAAA;IAEjF,MAAM,IAAI,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,UAAU,EAAE,IAAI,IAAI,EAAE,EAAE,gBAAgB,EAAE,OAAO,CAAC,SAAS,EAAE,CAAC,CAAA;IACrG,OAAO,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC,aAAa,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,EAAE,CAAC,EAAE,IAAI,EAAE,EAAE,GAAG,MAAM,EAAE,UAAU,EAAE,IAAI,IAAI,EAAE,EAAE,gBAAgB,EAAE,OAAO,CAAC,SAAS,EAAE,EAAE,CAAA;AAC3L,CAAC;AAED,wFAAwF;AACjF,KAAK,UAAU,gBAAgB,CAAC,EAAiB,EAAE,MAAsB,EAAE,QAAgB;IAChG,OAAO,MAAM,EAAE,CAAC,aAAa,CAAC,8CAAmB,CAAC,CAAC,IAAI,CAAC;QACtD,KAAK,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,MAAM,CAAC,EAAE,EAAE,EAAE,QAAQ,EAAE;QAC9C,KAAK,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE;QAC5B,SAAS,EAAE,CAAC,SAAS,CAAC;KACvB,CAAC,CAAA;AACJ,CAAC","sourcesContent":["import { createHash } from 'crypto'\nimport { In, type EntityManager } from 'typeorm'\nimport { GraphQLError } from 'graphql'\n\nimport { Figure } from './figure.js'\nimport { FigureHistory } from './figure-history.js'\nimport { FigureReplacedDraft } from './figure-replaced-draft.js'\nimport { parseSource } from './figure-format.js'\nimport { sourceConflict } from './figure-write.js'\n\n/*\n * Reverting a figure to a released version, keeping the draft it writes over (ADR-0097 §5.6, designer 2026-09-27;\n * chief architect's ruling on the storage the same day).\n *\n * - One transaction: the draft is kept, then the figure is written over -- conditional on it being what the reverter\n * saw (`expectedSource`). If either fails, neither stays: the caller's transaction is rolled back.\n * - One request, one revert: `requestId` is unique in the domain. A retry -- the answer lost -- finds the revert it\n * asked for and answers with it; nothing is kept or written twice.\n * - An undo brings a kept draft back only while the figure still holds exactly what that revert wrote\n * (`WHERE id = ? AND source = ?`, as a save's compare-and-swap); edited since, it is refused and the kept draft stays.\n * It keeps what it replaces the same way.\n *\n * Every function here runs on the caller's transaction (`tx`); the resolvers open it (`@transaction`).\n */\n\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i\nconst hashOf = (text: string) => createHash('sha256').update(text).digest('hex')\nconst refused = (code: string, message: string) => new GraphQLError(message, { extensions: { code } })\n\n/*\n * The same request arriving twice at once: the second waits on the first's requestId and then meets the unique index.\n * Named, not a raw database error -- asked again after the first has finished, it gets the first's answer.\n */\nasync function keep(kept: { save: (row: any) => Promise<any> }, row: any) {\n try {\n return await kept.save(row)\n } catch (error: any) {\n const unique = error?.code === '23505' || error?.driverError?.code === '23505' || /UNIQUE constraint failed/.test(String(error?.message))\n if (unique) throw refused('FIGURE_REVERT_IN_FLIGHT', '같은 요청이 처리 중입니다. 잠시 뒤 다시 확인해 주세요.')\n throw error\n }\n}\n\nexport interface RevertRequest {\n id: string\n version: number\n requestId: string\n /** The figure's source as the reverter saw it; the revert goes on only while the figure still holds it. */\n expectedSource: string\n}\n\nexport async function revertKeepingDraft(\n tx: EntityManager,\n domain: { id: string; parentId?: string | null },\n user: { id: string } | undefined,\n request: RevertRequest\n) {\n if (!UUID.test(request.requestId ?? '')) throw refused('FIGURE_REVERT_REQUEST', '되돌리기 요청 식별자가 필요합니다.')\n const figures = tx.getRepository(Figure)\n const kept = tx.getRepository(FigureReplacedDraft)\n\n /* The same request again: the revert it made, answered again. */\n const before = await kept.findOne({ where: { domain: { id: domain.id }, requestId: request.requestId } })\n if (before) {\n if (before.figureId !== request.id || before.revertedToVersion !== request.version || before.kind !== 'revert')\n throw refused('FIGURE_REVERT_REQUEST', '이 되돌리기 요청 식별자는 다른 되돌리기에 이미 쓰였습니다.')\n return { figure: await figures.findOneOrFail({ where: { id: request.id, domain: { id: domain.id } } }), violations: [], kept: before }\n }\n\n /* Exact tenant ownership, as a save: an inherited figure is not written from a child domain. */\n const figure = await figures.findOne({ where: { id: request.id, domain: { id: domain.id } } })\n if (!figure) throw refused('FIGURE_NOT_FOUND', `Figure ${request.id} 를 찾을 수 없습니다.`)\n if (figure.source !== request.expectedSource) throw sourceConflict('다른 편집에서 모델이 변경되었습니다. 최신 모델을 다시 연 뒤 되돌려 주세요.')\n\n const history = await tx.getRepository(FigureHistory).findOne({\n where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean) as string[]) }, originalId: request.id, version: request.version }\n })\n if (!history) throw refused('FIGURE_VERSION_NOT_FOUND', `Figure '${figure.type}' 에 ${request.version} 판이 없습니다.`)\n /* The format is checked again: an old version may break today's rules, and is not let in quietly. */\n const { source, derived, violations } = parseSource(history.source)\n\n const record = await keep(kept, {\n domain: { id: domain.id },\n figureId: figure.id,\n kind: 'revert' as const,\n source: figure.source,\n properties: figure.properties,\n thumbnail: figure.thumbnail,\n state: figure.state,\n version: figure.version,\n revertedToVersion: request.version,\n writtenSource: source,\n writtenSourceHash: hashOf(source),\n requestId: request.requestId,\n ...(user ? { creator: { id: user.id } } : {})\n })\n\n const written = await figures.update(\n { id: figure.id, domain: { id: domain.id }, source: request.expectedSource },\n {\n source,\n ...derived,\n properties: history.properties,\n thumbnail: history.thumbnail,\n thumbnailUpdatedAt: history.thumbnail === figure.thumbnail ? figure.thumbnailUpdatedAt : new Date(),\n state: 'draft',\n ...(user ? { updater: { id: user.id } } : {})\n }\n )\n if (written.affected !== 1) throw sourceConflict('다른 편집에서 모델이 변경되었습니다. 최신 모델을 다시 연 뒤 되돌려 주세요.')\n\n return { figure: await figures.findOneOrFail({ where: { id: figure.id, domain: { id: domain.id } } }), violations, kept: record }\n}\n\n/** Brings a kept draft back -- only while the figure holds exactly what that revert wrote. */\nexport async function undoRevert(\n tx: EntityManager,\n domain: { id: string },\n user: { id: string } | undefined,\n request: { keptId: string; requestId: string }\n) {\n if (!UUID.test(request.requestId ?? '')) throw refused('FIGURE_REVERT_REQUEST', '되돌리기 취소 요청 식별자가 필요합니다.')\n const figures = tx.getRepository(Figure)\n const kept = tx.getRepository(FigureReplacedDraft)\n\n const record = await kept.findOne({ where: { id: request.keptId, domain: { id: domain.id } } })\n if (!record) throw refused('FIGURE_KEPT_NOT_FOUND', '되살릴 초안을 찾을 수 없습니다.')\n if (record.restoredAt) {\n /* Asked again with the same request: the undo it made. Another request for a draft already back: refused. */\n if (record.restoreRequestId === request.requestId)\n return { figure: await figures.findOneOrFail({ where: { id: record.figureId, domain: { id: domain.id } } }), kept: record }\n throw refused('FIGURE_KEPT_RESTORED', '이 초안은 이미 되살렸습니다.')\n }\n\n const figure = await figures.findOne({ where: { id: record.figureId, domain: { id: domain.id } } })\n if (!figure) throw refused('FIGURE_NOT_FOUND', `Figure ${record.figureId} 를 찾을 수 없습니다.`)\n\n /* What the undo replaces is kept too -- the same rule as a revert. */\n const { derived } = parseSource(record.source!)\n await keep(kept, {\n domain: { id: domain.id },\n figureId: figure.id,\n kind: 'undo' as const,\n source: figure.source,\n properties: figure.properties,\n thumbnail: figure.thumbnail,\n state: figure.state,\n version: figure.version,\n writtenSource: record.source,\n writtenSourceHash: hashOf(record.source!),\n requestId: request.requestId,\n ...(user ? { creator: { id: user.id } } : {})\n })\n\n const written = await figures.update(\n { id: figure.id, domain: { id: domain.id }, source: record.writtenSource },\n {\n source: record.source,\n ...derived,\n properties: record.properties,\n thumbnail: record.thumbnail,\n thumbnailUpdatedAt: record.thumbnail === figure.thumbnail ? figure.thumbnailUpdatedAt : new Date(),\n state: 'draft',\n ...(user ? { updater: { id: user.id } } : {})\n }\n )\n if (written.affected !== 1)\n throw sourceConflict('되돌린 뒤 모델이 다시 바뀌어 되돌리기를 취소하지 않았습니다. 보관한 초안은 그대로 남아 있습니다.')\n\n await kept.update({ id: record.id }, { restoredAt: new Date(), restoreRequestId: request.requestId })\n return { figure: await figures.findOneOrFail({ where: { id: figure.id, domain: { id: domain.id } } }), kept: { ...record, restoredAt: new Date(), restoreRequestId: request.requestId } }\n}\n\n/** The drafts reverts kept for a figure, newest first -- found again after a reload. */\nexport async function replacedDraftsOf(tx: EntityManager, domain: { id: string }, figureId: string) {\n return await tx.getRepository(FigureReplacedDraft).find({\n where: { domain: { id: domain.id }, figureId },\n order: { createdAt: 'DESC' },\n relations: ['creator']\n })\n}\n"]}
1
+ {"version":3,"file":"figure-revert.js","sourceRoot":"","sources":["../../../server/service/figure/figure-revert.ts"],"names":[],"mappings":"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","sourcesContent":["import { createHash } from 'crypto'\nimport { In, type EntityManager } from 'typeorm'\nimport { GraphQLError } from 'graphql'\n\nimport { Figure } from './figure.js'\nimport { FigureHistory } from './figure-history.js'\nimport { FigureReplacedDraft } from './figure-replaced-draft.js'\nimport { parseSource } from './figure-format.js'\nimport { sourceConflict } from './figure-write.js'\n\n/*\n * Reverting a figure to a released version, keeping the draft it writes over (ADR-0097 §5.6, designer 2026-09-27;\n * chief architect's ruling on the storage the same day).\n *\n * - One transaction: the draft is kept, then the figure is written over -- conditional on it being what the reverter\n * saw (`expectedSource`). If either fails, neither stays: the caller's transaction is rolled back.\n * - One request, one revert: `requestId` is unique in the domain. A retry -- the answer lost -- finds the revert it\n * asked for and answers with it; nothing is kept or written twice.\n * - An undo brings a kept draft back only while the figure still holds exactly what that revert wrote\n * (`WHERE id = ? AND source = ?`, as a save's compare-and-swap); edited since, it is refused and the kept draft stays.\n * It keeps what it replaces the same way.\n *\n * Every function here runs on the caller's transaction (`tx`); the resolvers open it (`@transaction`).\n */\n\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i\nconst hashOf = (text: string) => createHash('sha256').update(text).digest('hex')\nconst refused = (code: string, message: string) => new GraphQLError(message, { extensions: { code } })\n\n/*\n * The same request arriving twice at once: the second waits on the first's requestId and then meets the unique index.\n * Named, not a raw database error -- asked again after the first has finished, it gets the first's answer.\n */\nasync function keep(kept: { save: (row: any) => Promise<any> }, row: any) {\n try {\n return await kept.save(row)\n } catch (error: any) {\n const unique = error?.code === '23505' || error?.driverError?.code === '23505' || /UNIQUE constraint failed/.test(String(error?.message))\n if (unique) throw refused('FIGURE_REVERT_IN_FLIGHT', '같은 요청이 처리 중입니다. 잠시 뒤 다시 확인해 주세요.')\n throw error\n }\n}\n\nexport interface RevertRequest {\n id: string\n version: number\n requestId: string\n /** The figure's source as the reverter saw it; the revert goes on only while the figure still holds it. */\n expectedSource: string\n}\n\nexport async function revertKeepingDraft(\n tx: EntityManager,\n domain: { id: string; parentId?: string | null },\n user: { id: string } | undefined,\n request: RevertRequest\n) {\n if (!UUID.test(request.requestId ?? '')) throw refused('FIGURE_REVERT_REQUEST', '되돌리기 요청 식별자가 필요합니다.')\n const figures = tx.getRepository(Figure)\n const kept = tx.getRepository(FigureReplacedDraft)\n\n /* The same request again: the revert it made, answered again. */\n const before = await kept.findOne({ where: { domain: { id: domain.id }, requestId: request.requestId } })\n if (before) {\n if (before.figureId !== request.id || before.revertedToVersion !== request.version || before.kind !== 'revert')\n throw refused('FIGURE_REVERT_REQUEST', '이 되돌리기 요청 식별자는 다른 되돌리기에 이미 쓰였습니다.')\n return { figure: await figures.findOneOrFail({ where: { id: request.id, domain: { id: domain.id } } }), violations: [], kept: before }\n }\n\n /* Exact tenant ownership, as a save: an inherited figure is not written from a child domain. */\n const figure = await figures.findOne({ where: { id: request.id, domain: { id: domain.id } } })\n if (!figure) throw refused('FIGURE_NOT_FOUND', `Figure ${request.id} 를 찾을 수 없습니다.`)\n if (figure.source !== request.expectedSource) throw sourceConflict('다른 편집에서 모델이 변경되었습니다. 최신 모델을 다시 연 뒤 되돌려 주세요.')\n\n const history = await tx.getRepository(FigureHistory).findOne({\n where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean) as string[]) }, originalId: request.id, version: request.version }\n })\n if (!history) throw refused('FIGURE_VERSION_NOT_FOUND', `Figure '${figure.type}' 에 ${request.version} 판이 없습니다.`)\n /* The format is checked again: an old version may break today's rules, and is not let in quietly. */\n const { source, derived, violations } = parseSource(history.source)\n\n const record = await keep(kept, {\n domain: { id: domain.id },\n figureId: figure.id,\n kind: 'revert' as const,\n source: figure.source,\n properties: figure.properties,\n thumbnail: figure.thumbnail,\n thumbnailHash: figure.thumbnailHash,\n state: figure.state,\n version: figure.version,\n revertedToVersion: request.version,\n writtenSource: source,\n writtenSourceHash: hashOf(source),\n requestId: request.requestId,\n ...(user ? { creator: { id: user.id } } : {})\n })\n\n const written = await figures.update(\n { id: figure.id, domain: { id: domain.id }, source: request.expectedSource },\n {\n source,\n ...derived,\n properties: history.properties,\n /* The picture goes by its hash (ADR-0104 decision 6); the release's own, as the source is. */\n thumbnail: null,\n thumbnailHash: history.thumbnailHash ?? null,\n thumbnailUpdatedAt: history.thumbnailHash === figure.thumbnailHash ? figure.thumbnailUpdatedAt : new Date(),\n state: 'draft',\n ...(user ? { updater: { id: user.id } } : {})\n }\n )\n if (written.affected !== 1) throw sourceConflict('다른 편집에서 모델이 변경되었습니다. 최신 모델을 다시 연 뒤 되돌려 주세요.')\n\n return { figure: await figures.findOneOrFail({ where: { id: figure.id, domain: { id: domain.id } } }), violations, kept: record }\n}\n\n/** Brings a kept draft back -- only while the figure holds exactly what that revert wrote. */\nexport async function undoRevert(\n tx: EntityManager,\n domain: { id: string },\n user: { id: string } | undefined,\n request: { keptId: string; requestId: string }\n) {\n if (!UUID.test(request.requestId ?? '')) throw refused('FIGURE_REVERT_REQUEST', '되돌리기 취소 요청 식별자가 필요합니다.')\n const figures = tx.getRepository(Figure)\n const kept = tx.getRepository(FigureReplacedDraft)\n\n const record = await kept.findOne({ where: { id: request.keptId, domain: { id: domain.id } } })\n if (!record) throw refused('FIGURE_KEPT_NOT_FOUND', '되살릴 초안을 찾을 수 없습니다.')\n if (record.restoredAt) {\n /* Asked again with the same request: the undo it made. Another request for a draft already back: refused. */\n if (record.restoreRequestId === request.requestId)\n return { figure: await figures.findOneOrFail({ where: { id: record.figureId, domain: { id: domain.id } } }), kept: record }\n throw refused('FIGURE_KEPT_RESTORED', '이 초안은 이미 되살렸습니다.')\n }\n\n const figure = await figures.findOne({ where: { id: record.figureId, domain: { id: domain.id } } })\n if (!figure) throw refused('FIGURE_NOT_FOUND', `Figure ${record.figureId} 를 찾을 수 없습니다.`)\n\n /* What the undo replaces is kept too -- the same rule as a revert. */\n const { derived } = parseSource(record.source!)\n await keep(kept, {\n domain: { id: domain.id },\n figureId: figure.id,\n kind: 'undo' as const,\n source: figure.source,\n properties: figure.properties,\n thumbnail: figure.thumbnail,\n thumbnailHash: figure.thumbnailHash,\n state: figure.state,\n version: figure.version,\n writtenSource: record.source,\n writtenSourceHash: hashOf(record.source!),\n requestId: request.requestId,\n ...(user ? { creator: { id: user.id } } : {})\n })\n\n const written = await figures.update(\n { id: figure.id, domain: { id: domain.id }, source: record.writtenSource },\n {\n source: record.source,\n ...derived,\n properties: record.properties,\n thumbnail: null,\n thumbnailHash: record.thumbnailHash ?? null,\n thumbnailUpdatedAt: record.thumbnailHash === figure.thumbnailHash ? figure.thumbnailUpdatedAt : new Date(),\n state: 'draft',\n ...(user ? { updater: { id: user.id } } : {})\n }\n )\n if (written.affected !== 1)\n throw sourceConflict('되돌린 뒤 모델이 다시 바뀌어 되돌리기를 취소하지 않았습니다. 보관한 초안은 그대로 남아 있습니다.')\n\n await kept.update({ id: record.id }, { restoredAt: new Date(), restoreRequestId: request.requestId })\n return { figure: await figures.findOneOrFail({ where: { id: figure.id, domain: { id: domain.id } } }), kept: { ...record, restoredAt: new Date(), restoreRequestId: request.requestId } }\n}\n\n/** The drafts reverts kept for a figure, newest first -- found again after a reload. */\nexport async function replacedDraftsOf(tx: EntityManager, domain: { id: string }, figureId: string) {\n return await tx.getRepository(FigureReplacedDraft).find({\n where: { domain: { id: domain.id }, figureId },\n order: { createdAt: 'DESC' },\n relations: ['creator']\n })\n}\n"]}
@@ -0,0 +1,35 @@
1
+ import { Attachment } from '@things-factory/attachment-base';
2
+ export declare const THUMBNAIL_REF_TYPE = "figure-thumbnail";
3
+ /** `data:image/webp;base64,....` as its type and bytes; undefined when it is not one. */
4
+ export declare function decodeDataUrl(dataUrl: string): {
5
+ type: string;
6
+ bytes: Buffer;
7
+ } | undefined;
8
+ export declare const sha256Of: (bytes: Buffer) => string;
9
+ /** Where the bytes go: attachment-base's storage. Tests give another. */
10
+ export interface ThumbnailFiles {
11
+ put(bytes: Buffer, mimetype: string, name: string, context: any): Promise<{
12
+ id: string;
13
+ path: string;
14
+ size: number;
15
+ contents?: Buffer;
16
+ }>;
17
+ send(koa: any, path: string, next: () => Promise<any>): Promise<void>;
18
+ }
19
+ export declare const attachmentFiles: ThumbnailFiles;
20
+ export declare const thumbnailFiles: () => ThumbnailFiles;
21
+ /** For tests: use these files until set back; returns the ones it replaced. */
22
+ export declare function useThumbnailFiles(files: ThumbnailFiles): ThumbnailFiles;
23
+ type Tx = {
24
+ getRepository: (target: any) => any;
25
+ };
26
+ /** The attachment holding this content in this domain, or undefined. */
27
+ export declare function thumbnailAttachmentOf(tx: Tx, domainId: string, hash: string): Promise<Attachment | undefined>;
28
+ /**
29
+ * Keeps a picture and gives its hash. Refused when it is not a data URL -- a picture the server cannot read is one it
30
+ * must not keep under a name.
31
+ */
32
+ export declare function keepThumbnail(tx: Tx, domain: {
33
+ id: string;
34
+ }, user: unknown, dataUrl: string, files?: ThumbnailFiles): Promise<string>;
35
+ export {};
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.thumbnailFiles = exports.attachmentFiles = exports.sha256Of = exports.THUMBNAIL_REF_TYPE = void 0;
4
+ exports.decodeDataUrl = decodeDataUrl;
5
+ exports.useThumbnailFiles = useThumbnailFiles;
6
+ exports.thumbnailAttachmentOf = thumbnailAttachmentOf;
7
+ exports.keepThumbnail = keepThumbnail;
8
+ const crypto_1 = require("crypto");
9
+ const stream_1 = require("stream");
10
+ const attachment_base_1 = require("@things-factory/attachment-base");
11
+ /*
12
+ * A figure's picture, kept by attachment-base and named by its content (ADR-0104 decision 6, chief architect's ruling
13
+ * 2026-10-02).
14
+ *
15
+ * stored by attachment-base's storage (file, S3, database -- whichever the install configures), one attachment
16
+ * per (domain, content): refType `figure-thumbnail`, refBy the SHA-256 of the bytes. A figure, its
17
+ * releases and the drafts a revert kept carry that hash, never the bytes.
18
+ * served only through `/figure-thumbnail/:id`, behind the figure query permission -- never through attachment-
19
+ * base's public `/attachment/:path`, which checks nothing (a known gap, recorded in ADR-0104).
20
+ * the same content twice in one domain is one attachment, read by its hash. Two writers that both find none both
21
+ * write one; either serves the same bytes, so the result is the same (ruling, condition 2).
22
+ *
23
+ * The file is written before the row that names it: a file cannot be rolled back, so it goes first, and a row that
24
+ * fails to commit leaves a file the next write of the same content writes again.
25
+ */
26
+ exports.THUMBNAIL_REF_TYPE = 'figure-thumbnail';
27
+ /** `data:image/webp;base64,....` as its type and bytes; undefined when it is not one. */
28
+ function decodeDataUrl(dataUrl) {
29
+ const matched = /^data:([^;,]+);base64,(.*)$/s.exec(dataUrl);
30
+ return matched ? { type: matched[1], bytes: Buffer.from(matched[2], 'base64') } : undefined;
31
+ }
32
+ const sha256Of = (bytes) => (0, crypto_1.createHash)('sha256').update(bytes).digest('hex');
33
+ exports.sha256Of = sha256Of;
34
+ exports.attachmentFiles = {
35
+ async put(bytes, mimetype, name, context) {
36
+ const file = Promise.resolve({
37
+ createReadStream: () => stream_1.Readable.from([bytes]),
38
+ filename: name,
39
+ mimetype,
40
+ encoding: 'binary'
41
+ });
42
+ const { id, path, size, contents } = await attachment_base_1.STORAGE.uploadFile({ file, context });
43
+ return { id, path, size, contents };
44
+ },
45
+ send: (koa, path, next) => attachment_base_1.STORAGE.sendFile(koa, path, next)
46
+ };
47
+ /*
48
+ * The files in use: attachment-base's storage, unless a test gives its own. One seam for every writer and the router,
49
+ * so a test of a save or a revert need not reach into each.
50
+ */
51
+ let inUse = exports.attachmentFiles;
52
+ const thumbnailFiles = () => inUse;
53
+ exports.thumbnailFiles = thumbnailFiles;
54
+ /** For tests: use these files until set back; returns the ones it replaced. */
55
+ function useThumbnailFiles(files) {
56
+ const was = inUse;
57
+ inUse = files;
58
+ return was;
59
+ }
60
+ /** The attachment holding this content in this domain, or undefined. */
61
+ async function thumbnailAttachmentOf(tx, domainId, hash) {
62
+ return (await tx.getRepository(attachment_base_1.Attachment).findOne({ where: { domain: { id: domainId }, refType: exports.THUMBNAIL_REF_TYPE, refBy: hash } })) ?? undefined;
63
+ }
64
+ /**
65
+ * Keeps a picture and gives its hash. Refused when it is not a data URL -- a picture the server cannot read is one it
66
+ * must not keep under a name.
67
+ */
68
+ async function keepThumbnail(tx, domain, user, dataUrl, files = (0, exports.thumbnailFiles)()) {
69
+ const decoded = decodeDataUrl(dataUrl);
70
+ if (!decoded)
71
+ throw new Error('썸네일은 data URL(data:image/...;base64,...) 이어야 한다');
72
+ const hash = (0, exports.sha256Of)(decoded.bytes);
73
+ if (await thumbnailAttachmentOf(tx, domain.id, hash))
74
+ return hash;
75
+ const ext = decoded.type.split('/')[1]?.split('+')[0] || 'bin';
76
+ const name = `${hash}.${ext}`;
77
+ /* The file first: it cannot be rolled back. The row that names it comes after, in the caller's transaction. */
78
+ const put = await files.put(decoded.bytes, decoded.type, name, { state: { domain, user, tx } });
79
+ await tx.getRepository(attachment_base_1.Attachment).save({
80
+ domain,
81
+ creator: user,
82
+ updater: user,
83
+ id: put.id,
84
+ name,
85
+ mimetype: decoded.type,
86
+ encoding: 'binary',
87
+ refType: exports.THUMBNAIL_REF_TYPE,
88
+ refBy: hash,
89
+ category: 'image',
90
+ size: String(put.size),
91
+ path: put.path,
92
+ ...(put.contents ? { contents: put.contents } : {})
93
+ });
94
+ return hash;
95
+ }
96
+ //# sourceMappingURL=figure-thumbnail-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"figure-thumbnail-store.js","sourceRoot":"","sources":["../../../server/service/figure/figure-thumbnail-store.ts"],"names":[],"mappings":"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","sourcesContent":["import { createHash } from 'crypto'\nimport { Readable } from 'stream'\n\nimport { Attachment, STORAGE } from '@things-factory/attachment-base'\n\n/*\n * A figure's picture, kept by attachment-base and named by its content (ADR-0104 decision 6, chief architect's ruling\n * 2026-10-02).\n *\n * stored by attachment-base's storage (file, S3, database -- whichever the install configures), one attachment\n * per (domain, content): refType `figure-thumbnail`, refBy the SHA-256 of the bytes. A figure, its\n * releases and the drafts a revert kept carry that hash, never the bytes.\n * served only through `/figure-thumbnail/:id`, behind the figure query permission -- never through attachment-\n * base's public `/attachment/:path`, which checks nothing (a known gap, recorded in ADR-0104).\n * the same content twice in one domain is one attachment, read by its hash. Two writers that both find none both\n * write one; either serves the same bytes, so the result is the same (ruling, condition 2).\n *\n * The file is written before the row that names it: a file cannot be rolled back, so it goes first, and a row that\n * fails to commit leaves a file the next write of the same content writes again.\n */\n\nexport const THUMBNAIL_REF_TYPE = 'figure-thumbnail'\n\n/** `data:image/webp;base64,....` as its type and bytes; undefined when it is not one. */\nexport function decodeDataUrl(dataUrl: string): { type: string; bytes: Buffer } | undefined {\n const matched = /^data:([^;,]+);base64,(.*)$/s.exec(dataUrl)\n return matched ? { type: matched[1]!, bytes: Buffer.from(matched[2]!, 'base64') } : undefined\n}\n\nexport const sha256Of = (bytes: Buffer) => createHash('sha256').update(bytes).digest('hex')\n\n/** Where the bytes go: attachment-base's storage. Tests give another. */\nexport interface ThumbnailFiles {\n put(bytes: Buffer, mimetype: string, name: string, context: any): Promise<{ id: string; path: string; size: number; contents?: Buffer }>\n send(koa: any, path: string, next: () => Promise<any>): Promise<void>\n}\n\nexport const attachmentFiles: ThumbnailFiles = {\n async put(bytes, mimetype, name, context) {\n const file = Promise.resolve({\n createReadStream: () => Readable.from([bytes]),\n filename: name,\n mimetype,\n encoding: 'binary'\n })\n const { id, path, size, contents } = await STORAGE.uploadFile({ file, context })\n return { id, path, size, contents }\n },\n send: (koa, path, next) => STORAGE.sendFile(koa, path, next)\n}\n\n/*\n * The files in use: attachment-base's storage, unless a test gives its own. One seam for every writer and the router,\n * so a test of a save or a revert need not reach into each.\n */\nlet inUse: ThumbnailFiles = attachmentFiles\nexport const thumbnailFiles = (): ThumbnailFiles => inUse\n/** For tests: use these files until set back; returns the ones it replaced. */\nexport function useThumbnailFiles(files: ThumbnailFiles): ThumbnailFiles {\n const was = inUse\n inUse = files\n return was\n}\n\ntype Tx = { getRepository: (target: any) => any }\n\n/** The attachment holding this content in this domain, or undefined. */\nexport async function thumbnailAttachmentOf(tx: Tx, domainId: string, hash: string): Promise<Attachment | undefined> {\n return (await tx.getRepository(Attachment).findOne({ where: { domain: { id: domainId }, refType: THUMBNAIL_REF_TYPE, refBy: hash } })) ?? undefined\n}\n\n/**\n * Keeps a picture and gives its hash. Refused when it is not a data URL -- a picture the server cannot read is one it\n * must not keep under a name.\n */\nexport async function keepThumbnail(\n tx: Tx,\n domain: { id: string },\n user: unknown,\n dataUrl: string,\n files: ThumbnailFiles = thumbnailFiles()\n): Promise<string> {\n const decoded = decodeDataUrl(dataUrl)\n if (!decoded) throw new Error('썸네일은 data URL(data:image/...;base64,...) 이어야 한다')\n const hash = sha256Of(decoded.bytes)\n if (await thumbnailAttachmentOf(tx, domain.id, hash)) return hash\n const ext = decoded.type.split('/')[1]?.split('+')[0] || 'bin'\n const name = `${hash}.${ext}`\n /* The file first: it cannot be rolled back. The row that names it comes after, in the caller's transaction. */\n const put = await files.put(decoded.bytes, decoded.type, name, { state: { domain, user, tx } })\n await tx.getRepository(Attachment).save({\n domain,\n creator: user,\n updater: user,\n id: put.id,\n name,\n mimetype: decoded.type,\n encoding: 'binary',\n refType: THUMBNAIL_REF_TYPE,\n refBy: hash,\n category: 'image',\n size: String(put.size),\n path: put.path,\n ...(put.contents ? { contents: put.contents } : {})\n })\n return hash\n}\n"]}
@@ -0,0 +1,8 @@
1
+ import { type ThumbnailFiles } from './figure-thumbnail-store.js';
2
+ type Moved = {
3
+ table: string;
4
+ moved: number;
5
+ kept: number;
6
+ };
7
+ export declare function moveBase64Thumbnails(dataSource: any, files?: ThumbnailFiles): Promise<Moved[]>;
8
+ export {};
@@ -0,0 +1,56 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.moveBase64Thumbnails = moveBase64Thumbnails;
4
+ const typeorm_1 = require("typeorm");
5
+ const figure_js_1 = require("./figure.js");
6
+ const figure_history_js_1 = require("./figure-history.js");
7
+ const figure_replaced_draft_js_1 = require("./figure-replaced-draft.js");
8
+ const figure_thumbnail_store_js_1 = require("./figure-thumbnail-store.js");
9
+ const TABLES = [
10
+ { table: 'figure', entity: figure_js_1.Figure },
11
+ { table: 'figure_history', entity: figure_history_js_1.FigureHistory },
12
+ { table: 'figure_replaced_draft', entity: figure_replaced_draft_js_1.FigureReplacedDraft }
13
+ ];
14
+ async function moveBase64Thumbnails(dataSource, files = (0, figure_thumbnail_store_js_1.thumbnailFiles)()) {
15
+ const out = [];
16
+ for (const { table, entity } of TABLES) {
17
+ const rows = await dataSource.getRepository(entity).find({
18
+ where: { thumbnail: (0, typeorm_1.Not)((0, typeorm_1.IsNull)()) },
19
+ relations: { domain: true },
20
+ select: { id: true, thumbnail: true, domain: { id: true }, ...(entity === figure_history_js_1.FigureHistory ? { originalId: true } : {}) },
21
+ withDeleted: true
22
+ });
23
+ let moved = 0, kept = 0;
24
+ for (const row of rows) {
25
+ /* A release written before releases carried their domain names it through the figure it belongs to. */
26
+ const domainId = row.domain?.id ??
27
+ (entity === figure_history_js_1.FigureHistory && row.originalId
28
+ ? (await dataSource.getRepository(figure_js_1.Figure).findOne({ where: { id: row.originalId }, relations: { domain: true }, select: { id: true, domain: { id: true } }, withDeleted: true }))?.domain?.id
29
+ : undefined);
30
+ if (!domainId) {
31
+ kept++;
32
+ continue;
33
+ }
34
+ try {
35
+ await dataSource.transaction(async (tx) => {
36
+ const hash = await (0, figure_thumbnail_store_js_1.keepThumbnail)(tx, { id: domainId }, undefined, row.thumbnail, files);
37
+ await tx
38
+ .createQueryBuilder()
39
+ .update(entity)
40
+ .set({ thumbnailHash: hash, thumbnail: null })
41
+ .where('id = :id AND thumbnail = :was', { id: row.id, was: row.thumbnail })
42
+ .callListeners(false)
43
+ .execute();
44
+ });
45
+ moved++;
46
+ }
47
+ catch (error) {
48
+ console.error(`[figure-service] ${table} ${row.id} 의 썸네일을 옮기지 못했다 — base64 를 그대로 둔다: ${error?.message ?? error}`);
49
+ kept++;
50
+ }
51
+ }
52
+ out.push({ table, moved, kept });
53
+ }
54
+ return out;
55
+ }
56
+ //# sourceMappingURL=figure-thumbnail-upgrade.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"figure-thumbnail-upgrade.js","sourceRoot":"","sources":["../../../server/service/figure/figure-thumbnail-upgrade.ts"],"names":[],"mappings":"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","sourcesContent":["import { IsNull, Not } from 'typeorm'\n\nimport { Figure } from './figure.js'\nimport { FigureHistory } from './figure-history.js'\nimport { FigureReplacedDraft } from './figure-replaced-draft.js'\nimport { keepThumbnail, thumbnailFiles, type ThumbnailFiles } from './figure-thumbnail-store.js'\n\n/*\n * Moves the pictures an install kept as base64 into attachment-base, by their content (ADR-0104 decision 6; chief\n * architect's ruling 2026-10-02, condition 3). Run at start, in the boot's one line (`inBootTurn`).\n *\n * per row the file first -- it cannot be rolled back -- then, in one transaction, the attachment row, the hash,\n * and the base64 emptied. The update only takes a row whose base64 is still the one read, so two starts\n * at once, or a save in between, never empty a picture that was not moved.\n * again safe: a row already moved has no base64 and is not read; a picture already kept is found by its hash.\n * kept a picture that is not a data URL, or a row whose domain cannot be found, keeps its base64 and is\n * counted -- nothing is emptied that was not moved.\n */\n\ntype Moved = { table: string; moved: number; kept: number }\n\nconst TABLES = [\n { table: 'figure', entity: Figure },\n { table: 'figure_history', entity: FigureHistory },\n { table: 'figure_replaced_draft', entity: FigureReplacedDraft }\n] as const\n\nexport async function moveBase64Thumbnails(dataSource: any, files: ThumbnailFiles = thumbnailFiles()): Promise<Moved[]> {\n const out: Moved[] = []\n for (const { table, entity } of TABLES) {\n const rows: any[] = await dataSource.getRepository(entity).find({\n where: { thumbnail: Not(IsNull()) },\n relations: { domain: true },\n select: { id: true, thumbnail: true, domain: { id: true }, ...(entity === FigureHistory ? { originalId: true } : {}) },\n withDeleted: true\n })\n let moved = 0,\n kept = 0\n for (const row of rows) {\n /* A release written before releases carried their domain names it through the figure it belongs to. */\n const domainId: string | undefined =\n row.domain?.id ??\n (entity === FigureHistory && row.originalId\n ? (await dataSource.getRepository(Figure).findOne({ where: { id: row.originalId }, relations: { domain: true }, select: { id: true, domain: { id: true } }, withDeleted: true }))?.domain?.id\n : undefined)\n if (!domainId) {\n kept++\n continue\n }\n try {\n await dataSource.transaction(async (tx: any) => {\n const hash = await keepThumbnail(tx, { id: domainId }, undefined, row.thumbnail, files)\n await tx\n .createQueryBuilder()\n .update(entity)\n .set({ thumbnailHash: hash, thumbnail: null })\n .where('id = :id AND thumbnail = :was', { id: row.id, was: row.thumbnail })\n .callListeners(false)\n .execute()\n })\n moved++\n } catch (error) {\n console.error(`[figure-service] ${table} ${row.id} 의 썸네일을 옮기지 못했다 — base64 를 그대로 둔다: ${(error as Error)?.message ?? error}`)\n kept++\n }\n }\n out.push({ table, moved, kept })\n }\n return out\n}\n"]}
@@ -1,3 +1,4 @@
1
+ import { type ThumbnailFiles } from './figure-thumbnail-store.js';
1
2
  import { GraphQLError } from 'graphql';
2
3
  /**
3
4
  * The row is not what the writer expected: another edit, or the writer's own earlier save whose answer it never got.
@@ -27,18 +28,21 @@ export declare function prepareFigureWrite(source: string, existing?: {
27
28
  state: "draft";
28
29
  };
29
30
  /**
30
- * The picture a card shows, when the modeller sent one.
31
+ * The picture a card shows, when the modeller sent one: kept by its content (`keepThumbnail`) and named on the row by
32
+ * its hash.
31
33
  *
32
34
  * Absent means the author saved without one — a tab in the background cannot draw, and the source matters more
33
35
  * than the picture, so the save goes on and the modeller fills it in next time. The timestamp moves only when
34
36
  * the picture itself is new, so renaming a figure does not make every card look freshly drawn.
35
37
  */
36
- export declare function pictureOf(thumbnail?: string, existing?: {
37
- thumbnail?: string;
38
- }): {
39
- thumbnail?: undefined;
40
- thumbnailUpdatedAt?: undefined;
41
- } | {
42
- thumbnail: string;
43
- thumbnailUpdatedAt: Date;
44
- };
38
+ export declare function pictureOf(tx: {
39
+ getRepository: (target: any) => any;
40
+ }, domain: {
41
+ id: string;
42
+ }, user: unknown, thumbnail?: string, existing?: {
43
+ thumbnailHash?: string | null;
44
+ }, files?: ThumbnailFiles): Promise<{
45
+ thumbnailHash?: string;
46
+ thumbnail?: null;
47
+ thumbnailUpdatedAt?: Date;
48
+ }>;
@@ -4,6 +4,7 @@ exports.sourceConflict = sourceConflict;
4
4
  exports.compareAndSwapFigure = compareAndSwapFigure;
5
5
  exports.prepareFigureWrite = prepareFigureWrite;
6
6
  exports.pictureOf = pictureOf;
7
+ const figure_thumbnail_store_js_1 = require("./figure-thumbnail-store.js");
7
8
  const graphql_1 = require("graphql");
8
9
  const figure_model_1 = require("@hatiolab/figure-model");
9
10
  /**
@@ -42,15 +43,19 @@ function prepareFigureWrite(source, existing, expectedSource) {
42
43
  };
43
44
  }
44
45
  /**
45
- * The picture a card shows, when the modeller sent one.
46
+ * The picture a card shows, when the modeller sent one: kept by its content (`keepThumbnail`) and named on the row by
47
+ * its hash.
46
48
  *
47
49
  * Absent means the author saved without one — a tab in the background cannot draw, and the source matters more
48
50
  * than the picture, so the save goes on and the modeller fills it in next time. The timestamp moves only when
49
51
  * the picture itself is new, so renaming a figure does not make every card look freshly drawn.
50
52
  */
51
- function pictureOf(thumbnail, existing) {
52
- if (thumbnail === undefined || thumbnail === existing?.thumbnail)
53
+ async function pictureOf(tx, domain, user, thumbnail, existing, files) {
54
+ if (thumbnail === undefined)
53
55
  return {};
54
- return { thumbnail, thumbnailUpdatedAt: new Date() };
56
+ const thumbnailHash = await (0, figure_thumbnail_store_js_1.keepThumbnail)(tx, domain, user, thumbnail, files);
57
+ if (thumbnailHash === existing?.thumbnailHash)
58
+ return {};
59
+ return { thumbnailHash, thumbnail: null, thumbnailUpdatedAt: new Date() };
55
60
  }
56
61
  //# sourceMappingURL=figure-write.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"figure-write.js","sourceRoot":"","sources":["../../../server/service/figure/figure-write.ts"],"names":[],"mappings":";;AAQA,wCAEC;AAOD,oDASC;AAGD,gDAkBC;AASD,8BAGC;AA3DD,qCAAsC;AACtC,yDAAgI;AAEhI;;;;GAIG;AACH,SAAgB,cAAc,CAAC,OAAe;IAC5C,OAAO,IAAI,sBAAY,CAAC,OAAO,EAAE,EAAE,UAAU,EAAE,EAAE,IAAI,EAAE,wBAAwB,EAAE,EAAE,CAAC,CAAA;AACtF,CAAC;AAOM,KAAK,UAAU,oBAAoB,CACxC,MAAgF,EAChF,EAAU,EACV,QAAgB,EAChB,cAAkC;IAElC,IAAI,cAAc,KAAK,SAAS;QAAE,MAAM,KAAK,CAAC,iBAAiB,CAAC,CAAA;IAChE,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,MAAM,EAAE,cAAc,EAAE,CAAC,CAAA;IACrF,IAAI,MAAM,CAAC,QAAQ,KAAK,CAAC;QAAE,MAAM,cAAc,CAAC,wCAAwC,CAAC,CAAA;AAC3F,CAAC;AAED,mGAAmG;AACnG,SAAgB,kBAAkB,CAAC,MAAc,EAAE,QAA8B,EAAE,cAAuB;IACxG,IAAI,QAAQ,IAAI,CAAC,cAAc,KAAK,SAAS,IAAI,QAAQ,CAAC,MAAM,KAAK,cAAc,CAAC,EAAE,CAAC;QACrF,MAAM,cAAc,CAAC,6CAA6C,CAAC,CAAA;IACrE,CAAC;IACD,MAAM,KAAK,GAAG,IAAA,+BAAgB,EAAC,MAAM,CAAC,CAAA;IACtC,IAAA,iCAAkB,EAAC,KAAK,CAAC,CAAA;IACzB;;MAEE;IACF,MAAM,IAAI,GAAG,IAAA,2BAAY,EAAC,KAAK,CAAC,CAAA;IAChC,OAAO;QACL,MAAM,EAAE,IAAA,mCAAoB,EAAC,KAAK,CAAC;QACnC,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,KAAK,EAAE,IAAA,4BAAa,EAAC,KAAK,CAAC,EAAE,KAAK,IAAI,IAAI;QAC1C,SAAS,EAAE,IAAI,CAAC,SAAS;QACzB,MAAM,EAAE,IAAI,CAAC,MAAM;QACnB,KAAK,EAAE,OAAgB;KACxB,CAAA;AACH,CAAC;AAED;;;;;;GAMG;AACH,SAAgB,SAAS,CAAC,SAAkB,EAAE,QAAiC;IAC7E,IAAI,SAAS,KAAK,SAAS,IAAI,SAAS,KAAK,QAAQ,EAAE,SAAS;QAAE,OAAO,EAAE,CAAA;IAC3E,OAAO,EAAE,SAAS,EAAE,kBAAkB,EAAE,IAAI,IAAI,EAAE,EAAE,CAAA;AACtD,CAAC","sourcesContent":["import { GraphQLError } from 'graphql'\nimport { parseFigureAsset, serializeFigureAsset, compileFigureAsset, figureCostOf, figureScoreOf } from '@hatiolab/figure-model'\n\n/**\n * The row is not what the writer expected: another edit, or the writer's own earlier save whose answer it never got.\n * Coded, so a client can tell it from a refusal on grounds (permission, validation) and read the row back before\n * saying its save failed (D, designer 2026-09-27).\n */\nexport function sourceConflict(message: string) {\n return new GraphQLError(message, { extensions: { code: 'FIGURE_SOURCE_CONFLICT' } })\n}\n\nexport interface FigureWriteCriteria {\n id: string\n domain: { id: string }\n source: string\n}\nexport async function compareAndSwapFigure(\n update: (criteria: FigureWriteCriteria) => Promise<{ affected?: number | null }>,\n id: string,\n domainId: string,\n expectedSource: string | undefined\n) {\n if (expectedSource === undefined) throw Error('수정 전 정본이 필요합니다.')\n const result = await update({ id, domain: { id: domainId }, source: expectedSource })\n if (result.affected !== 1) throw sourceConflict('다른 편집에서 모델이 변경되었습니다. 최신 모델을 다시 열어 주세요.')\n}\n\n/** Early validation only. The repository must also compare expectedSource in its atomic UPDATE. */\nexport function prepareFigureWrite(source: string, existing?: { source?: string }, expectedSource?: string) {\n if (existing && (expectedSource === undefined || existing.source !== expectedSource)) {\n throw sourceConflict('다른 편집에서 모델이 변경되었습니다. 최신 모델을 다시 연 뒤 적용해 주세요.')\n }\n const asset = parseFigureAsset(source)\n compileFigureAsset(asset)\n /*\n The same measure `parseSource` uses, so the catalogue's columns do not depend on which mutation wrote the row.\n */\n const cost = figureCostOf(asset)\n return {\n source: serializeFigureAsset(asset),\n name: asset.name,\n score: figureScoreOf(asset)?.score ?? null,\n triangles: cost.triangles,\n groups: cost.groups,\n state: 'draft' as const\n }\n}\n\n/**\n * The picture a card shows, when the modeller sent one.\n *\n * Absent means the author saved without one — a tab in the background cannot draw, and the source matters more\n * than the picture, so the save goes on and the modeller fills it in next time. The timestamp moves only when\n * the picture itself is new, so renaming a figure does not make every card look freshly drawn.\n */\nexport function pictureOf(thumbnail?: string, existing?: { thumbnail?: string }) {\n if (thumbnail === undefined || thumbnail === existing?.thumbnail) return {}\n return { thumbnail, thumbnailUpdatedAt: new Date() }\n}\n"]}
1
+ {"version":3,"file":"figure-write.js","sourceRoot":"","sources":["../../../server/service/figure/figure-write.ts"],"names":[],"mappings":";;AASA,wCAEC;AAOD,oDASC;AAGD,gDAkBC;AAUD,8BAYC;AAtED,2EAAgF;AAChF,qCAAsC;AACtC,yDAAgI;AAEhI;;;;GAIG;AACH,SAAgB,cAAc,CAAC,OAAe;IAC5C,OAAO,IAAI,sBAAY,CAAC,OAAO,EAAE,EAAE,UAAU,EAAE,EAAE,IAAI,EAAE,wBAAwB,EAAE,EAAE,CAAC,CAAA;AACtF,CAAC;AAOM,KAAK,UAAU,oBAAoB,CACxC,MAAgF,EAChF,EAAU,EACV,QAAgB,EAChB,cAAkC;IAElC,IAAI,cAAc,KAAK,SAAS;QAAE,MAAM,KAAK,CAAC,iBAAiB,CAAC,CAAA;IAChE,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,MAAM,EAAE,cAAc,EAAE,CAAC,CAAA;IACrF,IAAI,MAAM,CAAC,QAAQ,KAAK,CAAC;QAAE,MAAM,cAAc,CAAC,wCAAwC,CAAC,CAAA;AAC3F,CAAC;AAED,mGAAmG;AACnG,SAAgB,kBAAkB,CAAC,MAAc,EAAE,QAA8B,EAAE,cAAuB;IACxG,IAAI,QAAQ,IAAI,CAAC,cAAc,KAAK,SAAS,IAAI,QAAQ,CAAC,MAAM,KAAK,cAAc,CAAC,EAAE,CAAC;QACrF,MAAM,cAAc,CAAC,6CAA6C,CAAC,CAAA;IACrE,CAAC;IACD,MAAM,KAAK,GAAG,IAAA,+BAAgB,EAAC,MAAM,CAAC,CAAA;IACtC,IAAA,iCAAkB,EAAC,KAAK,CAAC,CAAA;IACzB;;MAEE;IACF,MAAM,IAAI,GAAG,IAAA,2BAAY,EAAC,KAAK,CAAC,CAAA;IAChC,OAAO;QACL,MAAM,EAAE,IAAA,mCAAoB,EAAC,KAAK,CAAC;QACnC,IAAI,EAAE,KAAK,CAAC,IAAI;QAChB,KAAK,EAAE,IAAA,4BAAa,EAAC,KAAK,CAAC,EAAE,KAAK,IAAI,IAAI;QAC1C,SAAS,EAAE,IAAI,CAAC,SAAS;QACzB,MAAM,EAAE,IAAI,CAAC,MAAM;QACnB,KAAK,EAAE,OAAgB;KACxB,CAAA;AACH,CAAC;AAED;;;;;;;GAOG;AACI,KAAK,UAAU,SAAS,CAC7B,EAA2C,EAC3C,MAAsB,EACtB,IAAa,EACb,SAAkB,EAClB,QAA4C,EAC5C,KAAsB;IAEtB,IAAI,SAAS,KAAK,SAAS;QAAE,OAAO,EAAE,CAAA;IACtC,MAAM,aAAa,GAAG,MAAM,IAAA,yCAAa,EAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,EAAE,KAAK,CAAC,CAAA;IAC7E,IAAI,aAAa,KAAK,QAAQ,EAAE,aAAa;QAAE,OAAO,EAAE,CAAA;IACxD,OAAO,EAAE,aAAa,EAAE,SAAS,EAAE,IAAI,EAAE,kBAAkB,EAAE,IAAI,IAAI,EAAE,EAAE,CAAA;AAC3E,CAAC","sourcesContent":["import { keepThumbnail, type ThumbnailFiles } from './figure-thumbnail-store.js'\nimport { GraphQLError } from 'graphql'\nimport { parseFigureAsset, serializeFigureAsset, compileFigureAsset, figureCostOf, figureScoreOf } from '@hatiolab/figure-model'\n\n/**\n * The row is not what the writer expected: another edit, or the writer's own earlier save whose answer it never got.\n * Coded, so a client can tell it from a refusal on grounds (permission, validation) and read the row back before\n * saying its save failed (D, designer 2026-09-27).\n */\nexport function sourceConflict(message: string) {\n return new GraphQLError(message, { extensions: { code: 'FIGURE_SOURCE_CONFLICT' } })\n}\n\nexport interface FigureWriteCriteria {\n id: string\n domain: { id: string }\n source: string\n}\nexport async function compareAndSwapFigure(\n update: (criteria: FigureWriteCriteria) => Promise<{ affected?: number | null }>,\n id: string,\n domainId: string,\n expectedSource: string | undefined\n) {\n if (expectedSource === undefined) throw Error('수정 전 정본이 필요합니다.')\n const result = await update({ id, domain: { id: domainId }, source: expectedSource })\n if (result.affected !== 1) throw sourceConflict('다른 편집에서 모델이 변경되었습니다. 최신 모델을 다시 열어 주세요.')\n}\n\n/** Early validation only. The repository must also compare expectedSource in its atomic UPDATE. */\nexport function prepareFigureWrite(source: string, existing?: { source?: string }, expectedSource?: string) {\n if (existing && (expectedSource === undefined || existing.source !== expectedSource)) {\n throw sourceConflict('다른 편집에서 모델이 변경되었습니다. 최신 모델을 다시 연 뒤 적용해 주세요.')\n }\n const asset = parseFigureAsset(source)\n compileFigureAsset(asset)\n /*\n The same measure `parseSource` uses, so the catalogue's columns do not depend on which mutation wrote the row.\n */\n const cost = figureCostOf(asset)\n return {\n source: serializeFigureAsset(asset),\n name: asset.name,\n score: figureScoreOf(asset)?.score ?? null,\n triangles: cost.triangles,\n groups: cost.groups,\n state: 'draft' as const\n }\n}\n\n/**\n * The picture a card shows, when the modeller sent one: kept by its content (`keepThumbnail`) and named on the row by\n * its hash.\n *\n * Absent means the author saved without one — a tab in the background cannot draw, and the source matters more\n * than the picture, so the save goes on and the modeller fills it in next time. The timestamp moves only when\n * the picture itself is new, so renaming a figure does not make every card look freshly drawn.\n */\nexport async function pictureOf(\n tx: { getRepository: (target: any) => any },\n domain: { id: string },\n user: unknown,\n thumbnail?: string,\n existing?: { thumbnailHash?: string | null },\n files?: ThumbnailFiles\n): Promise<{ thumbnailHash?: string; thumbnail?: null; thumbnailUpdatedAt?: Date }> {\n if (thumbnail === undefined) return {}\n const thumbnailHash = await keepThumbnail(tx, domain, user, thumbnail, files)\n if (thumbnailHash === existing?.thumbnailHash) return {}\n return { thumbnailHash, thumbnail: null, thumbnailUpdatedAt: new Date() }\n}\n"]}
@@ -78,6 +78,7 @@ export declare class Figure {
78
78
  /** 기준정보 항목 정의. Figure 를 배치할 때 인스턴스가 채우는 자리. */
79
79
  properties?: string;
80
80
  thumbnail?: string;
81
+ thumbnailHash?: string;
81
82
  /**
82
83
  * 썸네일이 **썸네일로서** 마지막으로 갱신된 시각. `updatedAt` 과는 별개 도메인이다 —
83
84
  * 이름·설명만 고쳤을 때는 갱신되지 않는다. `Board` 와 같은 방식.
@@ -133,6 +133,11 @@ tslib_1.__decorate([
133
133
  (0, type_graphql_1.Field)({ nullable: true, description: 'A base64 encoded thumbnail image of the figure.' }),
134
134
  tslib_1.__metadata("design:type", String)
135
135
  ], Figure.prototype, "thumbnail", void 0);
136
+ tslib_1.__decorate([
137
+ (0, typeorm_1.Column)({ nullable: true }),
138
+ (0, type_graphql_1.Field)({ nullable: true, description: 'SHA-256 of the thumbnail picture; the picture is served by /figure-thumbnail/:id.' }),
139
+ tslib_1.__metadata("design:type", String)
140
+ ], Figure.prototype, "thumbnailHash", void 0);
136
141
  tslib_1.__decorate([
137
142
  (0, typeorm_1.Column)({ nullable: true }),
138
143
  (0, type_graphql_1.Field)({ nullable: true, description: 'Timestamp of the last thumbnail generation. Separate lifecycle from updatedAt.' }),
@@ -1 +1 @@
1
- {"version":3,"file":"figure.js","sourceRoot":"","sources":["../../../server/service/figure/figure.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n Column,\n CreateDateColumn,\n DeleteDateColumn,\n Entity,\n Index,\n ManyToOne,\n PrimaryGeneratedColumn,\n RelationId,\n UpdateDateColumn\n} from 'typeorm'\nimport { Field, Float, ID, Int, ObjectType } from 'type-graphql'\nimport { GraphQLJSON } from 'graphql-scalars'\n\nimport { User } from '@things-factory/auth-base'\nimport { Domain, longTextColumn } from '@things-factory/shell'\n\n/**\n * Figure — 저작된 3D 컴포넌트 한 종류.\n *\n * `Board` 를 준용한다. 다른 점만 주석으로 적는다.\n *\n * ## 무엇을 담나 — 도형 자산이다, 씬 모델이 아니다\n *\n * 형식은 `@hatiolab/figure-model` 이 정한다(`figure-asset-1`). 씬 컴포넌트 트리를 담지 않는다.\n * 컴파일한 것도 저장하지 않는다 — 원본에서 파생되므로 소비하는 쪽이 커널로 컴파일한다. 저장하면 원본과 갈린다.\n *\n * 그래서 부품·움직임·자리 같은 것을 컬럼으로 두지 않는다 — 전부 `source` 안에 이미 있다. 두 벌이면 갈린다.\n *\n * 설계: `docs/design/figure-modules.md` §2\n */\n@Entity()\n@Index('ix_figure_1', (figure: Figure) => [figure.domain, figure.type], {\n unique: true,\n where: '\"deleted_at\" IS NULL'\n})\n@Index('ix_figure_2', (figure: Figure) => [figure.domain, figure.category])\n@ObjectType({ description: 'An authored 3D component type — its figure asset plus its metadata.' })\nexport class Figure {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { nullable: true, description: 'Unique identifier for the figure.' })\n readonly id?: string\n\n /**\n * 발행된 판의 번호 — **발행할 때만 올라간다.**\n *\n * 저장 횟수가 아니다. 저작 중에는 몇 번을 고쳐 저장하든 그대로이고, `draft` 가 `released` 가\n * 되는 순간에만 하나 오른다. 소비처가 「몇 번째 판을 쓰고 있나」를 이 값으로 말한다.\n *\n * 한 번도 발행되지 않았으면 **0** 이다. 1 로 시작하면 아직 아무에게도 약속하지 않은 초안이\n * 첫 판인 것처럼 보인다.\n */\n @Column({ nullable: true, default: 0 })\n @Field(type => Int, {\n nullable: true,\n description: 'Published version. Raised when the figure is released, not when it is saved. 0 means never released.'\n })\n version?: number = 0\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { nullable: true, description: 'The domain this figure belongs to.' })\n domain?: Domain\n\n @RelationId((figure: Figure) => figure.domain)\n domainId?: string\n\n /**\n * 컴포넌트 타입 이름. **저장되는 식별자다.**\n *\n * 배치된 도면이 이 이름으로 타입을 찾는다. 바꾸면 그 도면은 타입을 못 찾아\n * **오류 없이 빈 화면**이 된다. 그래서 만들 때 정하고 잠근다 — 고칠 수 있는 것은\n * 표시 이름(`name`)뿐이다. 수정 리졸버가 이 필드를 받지 않는다.\n *\n * `Board.type`(main/sub/popup)과 뜻이 전혀 다르다. 혼동하지 않는다.\n */\n @Column()\n @Field({ description: 'Component type name. A stored identifier — unique per domain and immutable once created.' })\n type?: string\n\n @Column()\n @Field({ description: 'Display name shown to authors. Safe to rename.' })\n name?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'A detailed description for the figure.' })\n description?: string\n\n /*\n 언어별 표시 이름(ADR-0104 결정 6). 없는 언어는 `name` 을 보인다. 식별자는 여전히 `type` 이다 — 이름은 몇 번을\n 바꿔도 도면이 가리키는 것이 달라지지 않는다.\n */\n @Column('simple-json', { nullable: true, default: null })\n @Field(type => GraphQLJSON, { nullable: true, description: 'Display names by language, e.g. { \"en\": \"...\", \"ko\": \"...\" }. A language not here shows `name`.' })\n names?: Record<string, string>\n\n /*\n 퇴역(ADR-0104 결정 6). 표시일 뿐이다 — 발행판은 지우지 않고 받는 쪽은 그대로 받는다. 무엇으로 대신하라는지는\n `supersededBy`(그 도메인의 type)가 말한다. 퇴역은 저장이 아니라 `setFigureDeprecation` 이 정하고, 발행을 풀지 않는다.\n */\n @Column({ nullable: true, default: false })\n @Field({ nullable: true, description: 'Retired: still released and still served, but not to be chosen for new work.' })\n deprecated?: boolean\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'The type to use instead of a retired figure.' })\n supersededBy?: string\n\n /**\n * 분류 하나. **팔레트에서 묶어 보이는 기준이다.**\n *\n * 도형 하나에 하나뿐이다 — 팔레트는 목록을 한 벌로 나눠 보여 주므로, 한 도형이 두 묶음에\n * 동시에 있으면 저작자가 같은 것을 두 번 만난다.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Classification code used to group figures in the palette. One per figure.' })\n category?: string\n\n /**\n * 태그. **여럿 붙는다.**\n *\n * 분류와 다른 일을 한다. 분류는 「이것이 무엇인가」를 한 번 정하는 것이고, 태그는 「어디에\n * 걸리는가」를 여러 갈래로 적는 것이다 — 컨베이어이면서 반출 구역의 것일 수 있다. 그래서\n * 분류로는 못 하는 찾기가 된다.\n *\n * `simple-json` 으로 담는다. 사내 선례가 그것이고(`ops-master` · `organization` ·\n * `personalization`), 드라이버마다 배열 타입이 다른 것을 피한다.\n */\n /* `simple-json` 과 `default: null` 은 사내 선례 그대로다 — board-template · attachment 가 같은 모양이다. */\n @Column('simple-json', { nullable: true, default: null })\n @Field(type => [String], { nullable: true, description: 'Free-form tags. A figure may carry several.' })\n tags?: string[]\n\n @Column({ nullable: true, default: 'draft' })\n @Field({ nullable: true, description: \"Lifecycle state — 'draft' figures do not appear in the board palette.\" })\n state?: 'draft' | 'released'\n\n /**\n * The figure asset JSON (`figure-asset-1`).\n *\n * `Board.model` 자리이지만 이름을 바꿨다 — 「씬 모델이 아니다」를 필드 이름이\n * 드러내야 한다.\n */\n @Column({ nullable: true, ...longTextColumn() })\n @Field({ nullable: true, description: 'The figure asset JSON (figure-asset-1) authored by the modeller.' })\n source?: string\n\n // ── 파생값 — 저장하는 이유가 하나뿐이다 ──────────────────────────────────\n //\n // 셋 다 `source` 에서 계산된다. 그런데도 컬럼으로 두는 이유는 **목록 화면이\n // 정렬·필터를 해야** 하기 때문이다. JSON 을 파싱해 정렬할 수는 없다.\n //\n // 저장할 때 `figure-model` 로 계산해 채운다. **클라이언트가 준 값을 받지 않는다** —\n // 받으면 거짓이 들어오고, 목록이 실물과 다른 것을 보여 준다.\n\n /** 재활용 점수 0~100. `scoreOf()` 가 계산한다. */\n @Column({ type: 'float', nullable: true })\n @Field(type => Float, { nullable: true, description: 'Reuse score 0-100, computed from source on save.' })\n score?: number\n\n /** 삼각형 수. `costOf()` 가 계산한다. */\n @Column({ nullable: true })\n @Field(type => Int, { nullable: true, description: 'Triangle count, computed from source on save.' })\n triangles?: number\n\n /** 재질 묶음 수 — 인스턴스당 draw call 의 근거. */\n @Column({ nullable: true })\n @Field(type => Int, { nullable: true, description: 'Material group count — the basis of per-instance draw calls.' })\n groups?: number\n\n /** 기준정보 항목 정의. Figure 를 배치할 때 인스턴스가 채우는 자리. */\n @Column({ nullable: true, ...longTextColumn() })\n @Field({ nullable: true, description: 'JSON definition of the master-data fields an instance of this figure carries.' })\n properties?: string\n\n @Column({ nullable: true, ...longTextColumn() })\n @Field({ nullable: true, description: 'A base64 encoded thumbnail image of the figure.' })\n thumbnail?: string\n\n /**\n * 썸네일이 **썸네일로서** 마지막으로 갱신된 시각. `updatedAt` 과는 별개 도메인이다 —\n * 이름·설명만 고쳤을 때는 갱신되지 않는다. `Board` 와 같은 방식.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Timestamp of the last thumbnail generation. Separate lifecycle from updatedAt.' })\n thumbnailUpdatedAt?: Date\n\n @Column({ type: 'float', nullable: true, default: 0 })\n @Field(type => Float, { nullable: true, description: 'Sort order within a listing.' })\n sortOrder?: number\n\n @CreateDateColumn()\n @Field({ nullable: true, description: 'Creation timestamp.' })\n createdAt?: Date\n\n @UpdateDateColumn()\n @Field({ nullable: true, description: 'Last update timestamp.' })\n updatedAt?: Date\n\n @DeleteDateColumn()\n @Field({ nullable: true, description: 'Soft-delete timestamp.' })\n deletedAt?: Date\n\n @ManyToOne(type => User, { nullable: true })\n @Field(type => User, { nullable: true, description: 'The user who created this figure.' })\n creator?: User\n\n @RelationId((figure: Figure) => figure.creator)\n creatorId?: string\n\n @ManyToOne(type => User, { nullable: true })\n @Field(type => User, { nullable: true, description: 'The user who last updated this figure.' })\n updater?: User\n\n @RelationId((figure: Figure) => figure.updater)\n updaterId?: string\n}\n"]}
1
+ {"version":3,"file":"figure.js","sourceRoot":"","sources":["../../../server/service/figure/figure.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n Column,\n CreateDateColumn,\n DeleteDateColumn,\n Entity,\n Index,\n ManyToOne,\n PrimaryGeneratedColumn,\n RelationId,\n UpdateDateColumn\n} from 'typeorm'\nimport { Field, Float, ID, Int, ObjectType } from 'type-graphql'\nimport { GraphQLJSON } from 'graphql-scalars'\n\nimport { User } from '@things-factory/auth-base'\nimport { Domain, longTextColumn } from '@things-factory/shell'\n\n/**\n * Figure — 저작된 3D 컴포넌트 한 종류.\n *\n * `Board` 를 준용한다. 다른 점만 주석으로 적는다.\n *\n * ## 무엇을 담나 — 도형 자산이다, 씬 모델이 아니다\n *\n * 형식은 `@hatiolab/figure-model` 이 정한다(`figure-asset-1`). 씬 컴포넌트 트리를 담지 않는다.\n * 컴파일한 것도 저장하지 않는다 — 원본에서 파생되므로 소비하는 쪽이 커널로 컴파일한다. 저장하면 원본과 갈린다.\n *\n * 그래서 부품·움직임·자리 같은 것을 컬럼으로 두지 않는다 — 전부 `source` 안에 이미 있다. 두 벌이면 갈린다.\n *\n * 설계: `docs/design/figure-modules.md` §2\n */\n@Entity()\n@Index('ix_figure_1', (figure: Figure) => [figure.domain, figure.type], {\n unique: true,\n where: '\"deleted_at\" IS NULL'\n})\n@Index('ix_figure_2', (figure: Figure) => [figure.domain, figure.category])\n@ObjectType({ description: 'An authored 3D component type — its figure asset plus its metadata.' })\nexport class Figure {\n @PrimaryGeneratedColumn('uuid')\n @Field(type => ID, { nullable: true, description: 'Unique identifier for the figure.' })\n readonly id?: string\n\n /**\n * 발행된 판의 번호 — **발행할 때만 올라간다.**\n *\n * 저장 횟수가 아니다. 저작 중에는 몇 번을 고쳐 저장하든 그대로이고, `draft` 가 `released` 가\n * 되는 순간에만 하나 오른다. 소비처가 「몇 번째 판을 쓰고 있나」를 이 값으로 말한다.\n *\n * 한 번도 발행되지 않았으면 **0** 이다. 1 로 시작하면 아직 아무에게도 약속하지 않은 초안이\n * 첫 판인 것처럼 보인다.\n */\n @Column({ nullable: true, default: 0 })\n @Field(type => Int, {\n nullable: true,\n description: 'Published version. Raised when the figure is released, not when it is saved. 0 means never released.'\n })\n version?: number = 0\n\n @ManyToOne(type => Domain)\n @Field(type => Domain, { nullable: true, description: 'The domain this figure belongs to.' })\n domain?: Domain\n\n @RelationId((figure: Figure) => figure.domain)\n domainId?: string\n\n /**\n * 컴포넌트 타입 이름. **저장되는 식별자다.**\n *\n * 배치된 도면이 이 이름으로 타입을 찾는다. 바꾸면 그 도면은 타입을 못 찾아\n * **오류 없이 빈 화면**이 된다. 그래서 만들 때 정하고 잠근다 — 고칠 수 있는 것은\n * 표시 이름(`name`)뿐이다. 수정 리졸버가 이 필드를 받지 않는다.\n *\n * `Board.type`(main/sub/popup)과 뜻이 전혀 다르다. 혼동하지 않는다.\n */\n @Column()\n @Field({ description: 'Component type name. A stored identifier — unique per domain and immutable once created.' })\n type?: string\n\n @Column()\n @Field({ description: 'Display name shown to authors. Safe to rename.' })\n name?: string\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'A detailed description for the figure.' })\n description?: string\n\n /*\n 언어별 표시 이름(ADR-0104 결정 6). 없는 언어는 `name` 을 보인다. 식별자는 여전히 `type` 이다 — 이름은 몇 번을\n 바꿔도 도면이 가리키는 것이 달라지지 않는다.\n */\n @Column('simple-json', { nullable: true, default: null })\n @Field(type => GraphQLJSON, { nullable: true, description: 'Display names by language, e.g. { \"en\": \"...\", \"ko\": \"...\" }. A language not here shows `name`.' })\n names?: Record<string, string>\n\n /*\n 퇴역(ADR-0104 결정 6). 표시일 뿐이다 — 발행판은 지우지 않고 받는 쪽은 그대로 받는다. 무엇으로 대신하라는지는\n `supersededBy`(그 도메인의 type)가 말한다. 퇴역은 저장이 아니라 `setFigureDeprecation` 이 정하고, 발행을 풀지 않는다.\n */\n @Column({ nullable: true, default: false })\n @Field({ nullable: true, description: 'Retired: still released and still served, but not to be chosen for new work.' })\n deprecated?: boolean\n\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'The type to use instead of a retired figure.' })\n supersededBy?: string\n\n /**\n * 분류 하나. **팔레트에서 묶어 보이는 기준이다.**\n *\n * 도형 하나에 하나뿐이다 — 팔레트는 목록을 한 벌로 나눠 보여 주므로, 한 도형이 두 묶음에\n * 동시에 있으면 저작자가 같은 것을 두 번 만난다.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Classification code used to group figures in the palette. One per figure.' })\n category?: string\n\n /**\n * 태그. **여럿 붙는다.**\n *\n * 분류와 다른 일을 한다. 분류는 「이것이 무엇인가」를 한 번 정하는 것이고, 태그는 「어디에\n * 걸리는가」를 여러 갈래로 적는 것이다 — 컨베이어이면서 반출 구역의 것일 수 있다. 그래서\n * 분류로는 못 하는 찾기가 된다.\n *\n * `simple-json` 으로 담는다. 사내 선례가 그것이고(`ops-master` · `organization` ·\n * `personalization`), 드라이버마다 배열 타입이 다른 것을 피한다.\n */\n /* `simple-json` 과 `default: null` 은 사내 선례 그대로다 — board-template · attachment 가 같은 모양이다. */\n @Column('simple-json', { nullable: true, default: null })\n @Field(type => [String], { nullable: true, description: 'Free-form tags. A figure may carry several.' })\n tags?: string[]\n\n @Column({ nullable: true, default: 'draft' })\n @Field({ nullable: true, description: \"Lifecycle state — 'draft' figures do not appear in the board palette.\" })\n state?: 'draft' | 'released'\n\n /**\n * The figure asset JSON (`figure-asset-1`).\n *\n * `Board.model` 자리이지만 이름을 바꿨다 — 「씬 모델이 아니다」를 필드 이름이\n * 드러내야 한다.\n */\n @Column({ nullable: true, ...longTextColumn() })\n @Field({ nullable: true, description: 'The figure asset JSON (figure-asset-1) authored by the modeller.' })\n source?: string\n\n // ── 파생값 — 저장하는 이유가 하나뿐이다 ──────────────────────────────────\n //\n // 셋 다 `source` 에서 계산된다. 그런데도 컬럼으로 두는 이유는 **목록 화면이\n // 정렬·필터를 해야** 하기 때문이다. JSON 을 파싱해 정렬할 수는 없다.\n //\n // 저장할 때 `figure-model` 로 계산해 채운다. **클라이언트가 준 값을 받지 않는다** —\n // 받으면 거짓이 들어오고, 목록이 실물과 다른 것을 보여 준다.\n\n /** 재활용 점수 0~100. `scoreOf()` 가 계산한다. */\n @Column({ type: 'float', nullable: true })\n @Field(type => Float, { nullable: true, description: 'Reuse score 0-100, computed from source on save.' })\n score?: number\n\n /** 삼각형 수. `costOf()` 가 계산한다. */\n @Column({ nullable: true })\n @Field(type => Int, { nullable: true, description: 'Triangle count, computed from source on save.' })\n triangles?: number\n\n /** 재질 묶음 수 — 인스턴스당 draw call 의 근거. */\n @Column({ nullable: true })\n @Field(type => Int, { nullable: true, description: 'Material group count — the basis of per-instance draw calls.' })\n groups?: number\n\n /** 기준정보 항목 정의. Figure 를 배치할 때 인스턴스가 채우는 자리. */\n @Column({ nullable: true, ...longTextColumn() })\n @Field({ nullable: true, description: 'JSON definition of the master-data fields an instance of this figure carries.' })\n properties?: string\n\n @Column({ nullable: true, ...longTextColumn() })\n @Field({ nullable: true, description: 'A base64 encoded thumbnail image of the figure.' })\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 * 썸네일이 **썸네일로서** 마지막으로 갱신된 시각. `updatedAt` 과는 별개 도메인이다 —\n * 이름·설명만 고쳤을 때는 갱신되지 않는다. `Board` 와 같은 방식.\n */\n @Column({ nullable: true })\n @Field({ nullable: true, description: 'Timestamp of the last thumbnail generation. Separate lifecycle from updatedAt.' })\n thumbnailUpdatedAt?: Date\n\n @Column({ type: 'float', nullable: true, default: 0 })\n @Field(type => Float, { nullable: true, description: 'Sort order within a listing.' })\n sortOrder?: number\n\n @CreateDateColumn()\n @Field({ nullable: true, description: 'Creation timestamp.' })\n createdAt?: Date\n\n @UpdateDateColumn()\n @Field({ nullable: true, description: 'Last update timestamp.' })\n updatedAt?: Date\n\n @DeleteDateColumn()\n @Field({ nullable: true, description: 'Soft-delete timestamp.' })\n deletedAt?: Date\n\n @ManyToOne(type => User, { nullable: true })\n @Field(type => User, { nullable: true, description: 'The user who created this figure.' })\n creator?: User\n\n @RelationId((figure: Figure) => figure.creator)\n creatorId?: string\n\n @ManyToOne(type => User, { nullable: true })\n @Field(type => User, { nullable: true, description: 'The user who last updated this figure.' })\n updater?: User\n\n @RelationId((figure: Figure) => figure.updater)\n updaterId?: string\n}\n"]}
@@ -5,6 +5,6 @@ import { FigureProposalRecord } from './figure-proposal-record.js';
5
5
  import { FigureQuery } from './figure-query.js';
6
6
  import { FigureMutation } from './figure-mutation.js';
7
7
  import { FigureHistoryEntitySubscriber } from './event-subscriber.js';
8
- export declare const entities: (typeof Figure | typeof FigureReplacedDraft | typeof FigureHistory | typeof FigureProposalRecord)[];
8
+ export declare const entities: (typeof Figure | typeof FigureHistory | typeof FigureReplacedDraft | typeof FigureProposalRecord)[];
9
9
  export declare const resolvers: (typeof FigureQuery | typeof FigureMutation)[];
10
10
  export declare const subscribers: (typeof FigureHistoryEntitySubscriber)[];