@ssobig/writer-cli 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/config.js +2 -1
- package/package.json +1 -1
- package/templates/mystery-v1/authoring-view-preference.js +22 -5
- package/templates/mystery-v1/character-perspective-preview.js +3 -3
- package/templates/mystery-v1/codemirror6-runtime.min.js +28 -0
- package/templates/mystery-v1/component-asset-operations.js +5 -4
- package/templates/mystery-v1/component-catalog-contract.js +26 -35
- package/templates/mystery-v1/component-draft-operations.js +19 -8
- package/templates/mystery-v1/component-field-contracts.js +29 -49
- package/templates/mystery-v1/component-id-policy.js +1 -1
- package/templates/mystery-v1/component-manager.js +21 -11
- package/templates/mystery-v1/component-navigation-counts.js +3 -8
- package/templates/mystery-v1/component-registry.js +11 -5
- package/templates/mystery-v1/component-renderers.js +12 -8
- package/templates/mystery-v1/markdown-live-editor.js +350 -0
- package/templates/mystery-v1/page-header.js +2 -1
- package/tools/writer-cli/package-lock.json +2 -2
- package/tools/writer-cli/package.json +1 -1
- package/tools/writer-cli/skills/ssobig-writer-cli/SKILL.md +1 -1
- package/tools/writer-cli/skills/ssobig-writer-cli/references/projects-components.md +1 -1
- package/tools/writer-cli/src/agent-service.cjs +3 -1
- package/tools/writer-cli/src/command-registry.cjs +3 -0
- package/tools/writer-cli/src/commands.cjs +85 -3
- package/tools/writer-cli/src/domain.cjs +250 -1
- package/tools/writer-cli/src/gateway.cjs +14 -0
- package/tools/writer-cli/src/mutations.cjs +167 -1
- package/tools/writer-cli/src/project-import.cjs +12 -21
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
const crypto = require("node:crypto");
|
|
4
4
|
const componentContract = require("../../../templates/mystery-v1/component-storage-contract.js");
|
|
5
|
+
const componentCatalog = require("../../../templates/mystery-v1/component-catalog-contract.js");
|
|
6
|
+
const componentRegistry = require("../../../templates/mystery-v1/component-registry.js").createDefaultRegistry();
|
|
5
7
|
const viewContract = require("../../../templates/mystery-v1/view-component-contract.js");
|
|
6
8
|
const projectRuntime = require("../../../project-runtime.js");
|
|
7
9
|
const { applyPatch } = require("./json-patch.cjs");
|
|
@@ -27,6 +29,7 @@ const BINDING_COLUMNS = "view_instance_id,binding_key,data_instance_id,access_mo
|
|
|
27
29
|
const CHECKPOINT_COLUMNS = "checkpoint_id,project_id,version_number,checkpoint_number,parent_checkpoint_id,restored_from_checkpoint_id,operation_id,message,source,component_set_revision,component_checksum,manifest_hash,changed_instance_ids,created_by_email,created_at";
|
|
28
30
|
const CHECKPOINT_ENTRY_COLUMNS = "checkpoint_id,project_id,version_number,instance_id,template_id,tab_label,sort_order,is_enabled,is_archived,is_required,is_removable,editor_view_id,preview_view_id,capabilities,config,source_revision,data_hash,changed_from_parent";
|
|
29
31
|
const CHECKPOINT_MESSAGE_MAX_LENGTH = 500;
|
|
32
|
+
const INSTANCE_ID_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
30
33
|
|
|
31
34
|
function isObject(value) {
|
|
32
35
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
@@ -77,6 +80,17 @@ function findInstance(validated, selector) {
|
|
|
77
80
|
throw cliError("E_COMPONENT_NOT_FOUND", `활성 Component를 찾을 수 없습니다: ${value || "(비어 있음)"}`);
|
|
78
81
|
}
|
|
79
82
|
|
|
83
|
+
function findStoredInstance(validated, selector) {
|
|
84
|
+
const value = String(selector || "").trim();
|
|
85
|
+
const stored = validated.componentRows.map(componentContract.normalizeInstance).filter(Boolean);
|
|
86
|
+
const byId = stored.find(instance => instance.instanceId === value);
|
|
87
|
+
if (byId) return byId;
|
|
88
|
+
const byTemplate = stored.filter(instance => instance.templateId === value);
|
|
89
|
+
if (byTemplate.length === 1) return byTemplate[0];
|
|
90
|
+
if (byTemplate.length > 1) throw cliError("E_AMBIGUOUS_INSTANCE", `${value} Template의 Instance가 여러 개입니다. instance ID를 지정해 주세요.`);
|
|
91
|
+
throw cliError("E_COMPONENT_NOT_FOUND", `저장된 Component를 찾을 수 없습니다: ${value || "(비어 있음)"}`);
|
|
92
|
+
}
|
|
93
|
+
|
|
80
94
|
function assertGenericPatchAllowed(instance) {
|
|
81
95
|
if (instance.templateId === "ssobig.assets") {
|
|
82
96
|
throw cliError("E_ASSET_COMMAND_REQUIRED", "ssobig.assets Component는 generic patch로 수정할 수 없습니다. asset 명령을 사용해 주세요.");
|
|
@@ -140,6 +154,166 @@ function checkpointMessage(value, fallback) {
|
|
|
140
154
|
return message;
|
|
141
155
|
}
|
|
142
156
|
|
|
157
|
+
function componentViewReview(validated, descriptor, instanceId, tabLabel) {
|
|
158
|
+
const activeByTemplate = new Map();
|
|
159
|
+
for (const instance of validated.instances) {
|
|
160
|
+
if (!activeByTemplate.has(instance.templateId)) activeByTemplate.set(instance.templateId, instance);
|
|
161
|
+
}
|
|
162
|
+
activeByTemplate.set(descriptor.templateId, { instanceId, templateId: descriptor.templateId });
|
|
163
|
+
const bindings = [];
|
|
164
|
+
for (const binding of descriptor.view.bindings) {
|
|
165
|
+
const target = activeByTemplate.get(binding.dataTemplateId);
|
|
166
|
+
if (!target) {
|
|
167
|
+
if (binding.required) throw cliError("E_VALIDATION", `${descriptor.templateId} 사용에는 활성 ${binding.dataTemplateId} Component가 필요합니다.`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
bindings.push({ key: binding.key, dataInstanceId: target.instanceId, accessMode: binding.accessMode, required: binding.required });
|
|
171
|
+
}
|
|
172
|
+
const viewInstanceId = descriptor.allowMultiple ? `${instanceId}-workbench` : descriptor.view.instanceId;
|
|
173
|
+
if (!INSTANCE_ID_PATTERN.test(viewInstanceId)) throw cliError("E_VALIDATION", `생성될 View instance ID가 올바르지 않습니다: ${viewInstanceId}`);
|
|
174
|
+
return {
|
|
175
|
+
viewInstanceId,
|
|
176
|
+
viewTemplateId: descriptor.view.templateId,
|
|
177
|
+
label: `${tabLabel} 작업 화면`,
|
|
178
|
+
rendererId: descriptor.view.rendererId,
|
|
179
|
+
modes: clone(descriptor.view.supportedModes),
|
|
180
|
+
bindings
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function componentAddReview(validated, input = {}) {
|
|
185
|
+
const templateId = String(input.templateId || "").trim();
|
|
186
|
+
const descriptor = componentCatalog.get(templateId);
|
|
187
|
+
if (!descriptor || descriptor.required || descriptor.internal || !descriptor.view) {
|
|
188
|
+
throw cliError("E_CODE_CHANGE_REQUIRED", `CLI에서 추가할 수 없는 Component Template입니다: ${templateId || "(비어 있음)"}`);
|
|
189
|
+
}
|
|
190
|
+
const requestedInstanceId = String(input.instanceId || "").trim();
|
|
191
|
+
if (descriptor.allowMultiple && !requestedInstanceId) {
|
|
192
|
+
throw cliError("E_VALIDATION", `${templateId}에는 고유한 instance ID가 필요합니다.`);
|
|
193
|
+
}
|
|
194
|
+
const instanceId = requestedInstanceId || descriptor.defaultInstanceId;
|
|
195
|
+
if (!INSTANCE_ID_PATTERN.test(instanceId)) throw cliError("E_VALIDATION", `Component instance ID가 올바르지 않습니다: ${instanceId || "(비어 있음)"}`);
|
|
196
|
+
const tabLabel = String(input.tabLabel || descriptor.tabLabel || "").trim();
|
|
197
|
+
if (!tabLabel || tabLabel.length > 120) throw cliError("E_VALIDATION", "Component label은 비어 있지 않은 120자 이하 문자열이어야 합니다.");
|
|
198
|
+
|
|
199
|
+
const conflictingRow = validated.componentRows.find(row => String(row.instance_id) === instanceId
|
|
200
|
+
|| (!descriptor.allowMultiple && String(row.template_id) === templateId));
|
|
201
|
+
if (conflictingRow) {
|
|
202
|
+
const state = conflictingRow.is_archived || !conflictingRow.is_enabled ? "보관된" : "활성";
|
|
203
|
+
throw cliError("E_CONFLICT", `${state} Component가 이미 존재합니다: ${conflictingRow.instance_id}`);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const initialData = clone(descriptor.defaultData);
|
|
207
|
+
const componentSpec = componentContract.componentSpec(templateId);
|
|
208
|
+
if (!componentSpec) throw cliError("E_CODE_CHANGE_REQUIRED", `등록된 ${templateId}의 Data schema가 없습니다.`);
|
|
209
|
+
const dataError = componentSpec.validate(initialData);
|
|
210
|
+
if (dataError) throw cliError("E_CODE_CHANGE_REQUIRED", `등록된 ${templateId} 기본 data가 현재 schema와 일치하지 않습니다: ${dataError}`);
|
|
211
|
+
return Object.freeze({
|
|
212
|
+
target: {
|
|
213
|
+
templateId,
|
|
214
|
+
instanceId,
|
|
215
|
+
tabLabel,
|
|
216
|
+
expectedState: "missing",
|
|
217
|
+
allowMultiple: descriptor.allowMultiple
|
|
218
|
+
},
|
|
219
|
+
initialData,
|
|
220
|
+
composition: componentViewReview(validated, descriptor, instanceId, tabLabel)
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function createComponentAddPlan(validated, input, actorEmail, options = {}) {
|
|
225
|
+
const review = componentAddReview(validated, input);
|
|
226
|
+
return finalizePlan({
|
|
227
|
+
...basePlan(validated, actorEmail, "component.add", options),
|
|
228
|
+
target: review.target,
|
|
229
|
+
initialData: review.initialData,
|
|
230
|
+
initialDataHash: sha256(review.initialData),
|
|
231
|
+
composition: review.composition
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function componentActiveReview(validated, input = {}) {
|
|
236
|
+
if (typeof input.active !== "boolean") throw cliError("E_VALIDATION", "Component 사용 여부는 true 또는 false여야 합니다.");
|
|
237
|
+
const instance = findStoredInstance(validated, input.instanceId);
|
|
238
|
+
const descriptor = componentCatalog.get(instance.templateId);
|
|
239
|
+
if (!descriptor || descriptor.required || descriptor.internal || !descriptor.view || instance.required || !instance.removable) {
|
|
240
|
+
throw cliError("E_CODE_CHANGE_REQUIRED", `사용 여부를 변경할 수 없는 Component입니다: ${instance.instanceId}`);
|
|
241
|
+
}
|
|
242
|
+
const targetActive = input.active === true;
|
|
243
|
+
const currentActive = instance.enabled === true && instance.archived === false;
|
|
244
|
+
if (currentActive === targetActive) throw cliError("E_NO_CHANGES", `Component가 이미 ${targetActive ? "사용 중" : "사용 안 함"} 상태입니다: ${instance.instanceId}`);
|
|
245
|
+
if (!targetActive) {
|
|
246
|
+
const requiredDependents = validated.views.filter(view => view.bindings.some(binding => binding.dataInstanceId === instance.instanceId
|
|
247
|
+
&& binding.required && !(binding.key === "primary" && view.rendererId === instance.templateId)));
|
|
248
|
+
if (requiredDependents.length) {
|
|
249
|
+
throw cliError("E_VALIDATION", `${instance.tab}을 사용 안 함으로 전환하기 전에 필수로 의존하는 Component를 먼저 보관해야 합니다.`, {
|
|
250
|
+
dependentViews: requiredDependents.map(view => view.viewInstanceId)
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return Object.freeze({
|
|
255
|
+
target: {
|
|
256
|
+
instanceId: instance.instanceId,
|
|
257
|
+
templateId: instance.templateId,
|
|
258
|
+
tabLabel: instance.tab,
|
|
259
|
+
expectedActive: currentActive,
|
|
260
|
+
active: targetActive,
|
|
261
|
+
expectedRevision: instance.revision,
|
|
262
|
+
expectedDataHash: sha256(instance.data)
|
|
263
|
+
},
|
|
264
|
+
composition: targetActive
|
|
265
|
+
? componentViewReview(validated, descriptor, instance.instanceId, instance.tab)
|
|
266
|
+
: { viewInstanceId: descriptor.allowMultiple ? `${instance.instanceId}-workbench` : descriptor.view.instanceId, expectedState: "absent" }
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function createComponentActivePlan(validated, input, actorEmail, options = {}) {
|
|
271
|
+
const review = componentActiveReview(validated, input);
|
|
272
|
+
return finalizePlan({
|
|
273
|
+
...basePlan(validated, actorEmail, "component.set-active", options),
|
|
274
|
+
target: review.target,
|
|
275
|
+
composition: review.composition
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function componentFieldReview(validated, selector, fieldKey, enabled) {
|
|
280
|
+
const instance = findInstance(validated, selector);
|
|
281
|
+
const definition = componentRegistry.get(instance.templateId);
|
|
282
|
+
const field = definition?.fieldConfiguration?.find(item => item.key === String(fieldKey || ""));
|
|
283
|
+
if (!field) throw cliError("E_CODE_CHANGE_REQUIRED", `등록된 속성 구성 항목이 아닙니다: ${fieldKey || "(비어 있음)"}`);
|
|
284
|
+
if (field.required) throw cliError("E_VALIDATION", `필수 속성은 사용 안 함으로 전환할 수 없습니다: ${field.label}`);
|
|
285
|
+
const optionalFields = definition.fieldConfiguration.filter(item => !item.required);
|
|
286
|
+
const hasSelection = Array.isArray(instance.data?.enabledOptionalFields);
|
|
287
|
+
const selected = new Set(hasSelection ? instance.data.enabledOptionalFields : optionalFields.map(item => item.key));
|
|
288
|
+
const beforeEnabled = selected.has(field.key);
|
|
289
|
+
if (beforeEnabled === enabled) throw cliError("E_NO_CHANGES", `${field.label} 속성이 이미 ${enabled ? "사용" : "사용 안 함"} 상태입니다.`);
|
|
290
|
+
if (enabled) selected.add(field.key);
|
|
291
|
+
else selected.delete(field.key);
|
|
292
|
+
const afterFields = optionalFields.filter(item => selected.has(item.key)).map(item => item.key);
|
|
293
|
+
const patch = hasSelection
|
|
294
|
+
? [{ op: "test", path: "/enabledOptionalFields", value: clone(instance.data.enabledOptionalFields) }, { op: "replace", path: "/enabledOptionalFields", value: afterFields }]
|
|
295
|
+
: [{ op: "add", path: "/enabledOptionalFields", value: afterFields }];
|
|
296
|
+
return Object.freeze({ instance, field, beforeEnabled, enabled, afterFields, patch });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function createComponentFieldPlan(validated, selector, fieldKey, enabled, actorEmail, options = {}) {
|
|
300
|
+
const review = componentFieldReview(validated, selector, fieldKey, enabled);
|
|
301
|
+
const base = createComponentPlan(validated, review.instance.instanceId, review.patch, actorEmail, {
|
|
302
|
+
...options,
|
|
303
|
+
checkpointMessage: options.checkpointMessage || `CLI: ${review.instance.tab} 선택 속성 ${review.field.label} ${enabled ? "사용" : "사용 안 함"}`
|
|
304
|
+
});
|
|
305
|
+
const enriched = clone(base);
|
|
306
|
+
delete enriched.digest;
|
|
307
|
+
enriched.fieldSelection = {
|
|
308
|
+
key: review.field.key,
|
|
309
|
+
label: review.field.label,
|
|
310
|
+
beforeEnabled: review.beforeEnabled,
|
|
311
|
+
enabled: review.enabled,
|
|
312
|
+
enabledOptionalFields: review.afterFields
|
|
313
|
+
};
|
|
314
|
+
return finalizePlan(enriched);
|
|
315
|
+
}
|
|
316
|
+
|
|
143
317
|
function createComponentPlan(validated, selector, patch, actorEmail, options = {}) {
|
|
144
318
|
const instance = findInstance(validated, selector);
|
|
145
319
|
assertGenericPatchAllowed(instance);
|
|
@@ -461,7 +635,7 @@ function verifyPlan(plan) {
|
|
|
461
635
|
if (!UUID_PATTERN.test(String(plan.planId || "")) || typeof plan.digest !== "string" || plan.digest !== planDigest(plan)) {
|
|
462
636
|
throw cliError("E_INVALID_PLAN", "plan 식별자 또는 digest가 올바르지 않습니다.");
|
|
463
637
|
}
|
|
464
|
-
if (!["component.patch", "asset.upload", "asset.delete", "project.create", "project.import", "project.archive", "project.restore", "project.catalog-demo", "checkpoint.create", "checkpoint.restore"].includes(plan.operation)) throw cliError("E_INVALID_PLAN", "지원하지 않는 plan operation입니다.");
|
|
638
|
+
if (!["component.add", "component.set-active", "component.patch", "asset.upload", "asset.delete", "project.create", "project.import", "project.archive", "project.restore", "project.catalog-demo", "checkpoint.create", "checkpoint.restore"].includes(plan.operation)) throw cliError("E_INVALID_PLAN", "지원하지 않는 plan operation입니다.");
|
|
465
639
|
if (["component.patch", "asset.upload", "asset.delete", "checkpoint.create", "checkpoint.restore"].includes(plan.operation)) {
|
|
466
640
|
let normalizedCheckpointMessage = "";
|
|
467
641
|
try { normalizedCheckpointMessage = checkpointMessage(plan.checkpointMessage, ""); }
|
|
@@ -502,6 +676,36 @@ function verifyPlan(plan) {
|
|
|
502
676
|
|| String(plan.target?.scaffold || "") !== target.scaffold) {
|
|
503
677
|
throw cliError("E_INVALID_PLAN", "새 작품 plan의 입력값이 올바르지 않습니다.");
|
|
504
678
|
}
|
|
679
|
+
} else if (plan.operation === "component.add") {
|
|
680
|
+
const descriptor = componentCatalog.get(String(plan.target?.templateId || ""));
|
|
681
|
+
if (!descriptor || descriptor.required || descriptor.internal || !descriptor.view
|
|
682
|
+
|| !INSTANCE_ID_PATTERN.test(String(plan.target?.instanceId || ""))
|
|
683
|
+
|| String(plan.target?.expectedState || "") !== "missing"
|
|
684
|
+
|| Boolean(plan.target?.allowMultiple) !== descriptor.allowMultiple
|
|
685
|
+
|| !String(plan.target?.tabLabel || "").trim()
|
|
686
|
+
|| String(plan.target.tabLabel).length > 120
|
|
687
|
+
|| !equalJson(plan.initialData, descriptor.defaultData)
|
|
688
|
+
|| String(plan.initialDataHash || "") !== sha256(descriptor.defaultData)
|
|
689
|
+
|| !isObject(plan.composition)
|
|
690
|
+
|| !INSTANCE_ID_PATTERN.test(String(plan.composition.viewInstanceId || ""))
|
|
691
|
+
|| String(plan.composition.viewTemplateId || "") !== descriptor.view.templateId
|
|
692
|
+
|| String(plan.composition.rendererId || "") !== descriptor.view.rendererId
|
|
693
|
+
|| !Array.isArray(plan.composition.bindings)) {
|
|
694
|
+
throw cliError("E_INVALID_PLAN", "Component 추가 plan의 대상 또는 구성 review가 올바르지 않습니다.");
|
|
695
|
+
}
|
|
696
|
+
} else if (plan.operation === "component.set-active") {
|
|
697
|
+
const descriptor = componentCatalog.get(String(plan.target?.templateId || ""));
|
|
698
|
+
if (!descriptor || descriptor.required || descriptor.internal || !descriptor.view
|
|
699
|
+
|| !INSTANCE_ID_PATTERN.test(String(plan.target?.instanceId || ""))
|
|
700
|
+
|| !String(plan.target?.tabLabel || "").trim()
|
|
701
|
+
|| typeof plan.target?.expectedActive !== "boolean"
|
|
702
|
+
|| typeof plan.target?.active !== "boolean"
|
|
703
|
+
|| plan.target.expectedActive === plan.target.active
|
|
704
|
+
|| !Number.isSafeInteger(Number(plan.target?.expectedRevision))
|
|
705
|
+
|| !/^[0-9a-f]{64}$/.test(String(plan.target?.expectedDataHash || ""))
|
|
706
|
+
|| !isObject(plan.composition)) {
|
|
707
|
+
throw cliError("E_INVALID_PLAN", "Component 사용 여부 plan의 대상 또는 구성 review가 올바르지 않습니다.");
|
|
708
|
+
}
|
|
505
709
|
} else if (plan.operation === "project.import") {
|
|
506
710
|
const target = projectCreationTarget(plan.target);
|
|
507
711
|
const components = normalizeImportComponents(plan.components);
|
|
@@ -655,10 +859,50 @@ function validateComponentPlanForApply(plan, validated, actorEmail) {
|
|
|
655
859
|
throw cliError("E_INVALID_PLAN", "추리 보드 layout plan의 spec, review 또는 생성 결과가 일치하지 않습니다.");
|
|
656
860
|
}
|
|
657
861
|
}
|
|
862
|
+
if (Object.hasOwn(plan, "fieldSelection")) {
|
|
863
|
+
const review = componentFieldReview(validated, instance.instanceId, plan.fieldSelection?.key, plan.fieldSelection?.enabled === true);
|
|
864
|
+
const expected = {
|
|
865
|
+
key: review.field.key,
|
|
866
|
+
label: review.field.label,
|
|
867
|
+
beforeEnabled: review.beforeEnabled,
|
|
868
|
+
enabled: review.enabled,
|
|
869
|
+
enabledOptionalFields: review.afterFields
|
|
870
|
+
};
|
|
871
|
+
if (!equalJson(plan.fieldSelection, expected) || !equalJson(plan.patch, review.patch)) {
|
|
872
|
+
throw cliError("E_INVALID_PLAN", "선택 속성 plan의 review와 patch가 현재 등록 계약과 일치하지 않습니다.");
|
|
873
|
+
}
|
|
874
|
+
}
|
|
658
875
|
const nextInstance = validateHypotheticalData(validated, instance, recomputed);
|
|
659
876
|
return { instance, nextInstance, payload: componentContract.instancePayload(nextInstance) };
|
|
660
877
|
}
|
|
661
878
|
|
|
879
|
+
function validateComponentAddPlanForApply(plan, validated, actorEmail) {
|
|
880
|
+
assertPlanContext(plan, validated, actorEmail);
|
|
881
|
+
if (plan.operation !== "component.add") throw cliError("E_INVALID_PLAN", "Component 추가 plan이 아닙니다.");
|
|
882
|
+
const current = componentAddReview(validated, {
|
|
883
|
+
templateId: plan.target?.templateId,
|
|
884
|
+
instanceId: plan.target?.instanceId,
|
|
885
|
+
tabLabel: plan.target?.tabLabel
|
|
886
|
+
});
|
|
887
|
+
if (!equalJson(plan.target, current.target)
|
|
888
|
+
|| !equalJson(plan.initialData, current.initialData)
|
|
889
|
+
|| plan.initialDataHash !== sha256(current.initialData)
|
|
890
|
+
|| !equalJson(plan.composition, current.composition)) {
|
|
891
|
+
throw cliError("E_INVALID_PLAN", "Component 추가 plan의 review가 현재 등록 계약과 일치하지 않습니다.");
|
|
892
|
+
}
|
|
893
|
+
return current;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function validateComponentActivePlanForApply(plan, validated, actorEmail) {
|
|
897
|
+
assertPlanContext(plan, validated, actorEmail);
|
|
898
|
+
if (plan.operation !== "component.set-active") throw cliError("E_INVALID_PLAN", "Component 사용 여부 plan이 아닙니다.");
|
|
899
|
+
const current = componentActiveReview(validated, { instanceId: plan.target?.instanceId, active: plan.target?.active });
|
|
900
|
+
if (!equalJson(plan.target, current.target) || !equalJson(plan.composition, current.composition)) {
|
|
901
|
+
throw cliError("E_INVALID_PLAN", "Component 사용 여부 plan의 review가 현재 등록 계약과 일치하지 않습니다.");
|
|
902
|
+
}
|
|
903
|
+
return current;
|
|
904
|
+
}
|
|
905
|
+
|
|
662
906
|
function validateAssetPlanForApply(plan, validated, actorEmail) {
|
|
663
907
|
assertPlanContext(plan, validated, actorEmail);
|
|
664
908
|
if (!["asset.upload", "asset.delete"].includes(plan.operation)) throw cliError("E_INVALID_PLAN", "에셋 plan이 아닙니다.");
|
|
@@ -709,6 +953,9 @@ module.exports = Object.freeze({
|
|
|
709
953
|
componentContract,
|
|
710
954
|
validateLoadedVersion,
|
|
711
955
|
findInstance,
|
|
956
|
+
createComponentActivePlan,
|
|
957
|
+
createComponentAddPlan,
|
|
958
|
+
createComponentFieldPlan,
|
|
712
959
|
createComponentPlan,
|
|
713
960
|
investigationBoardReferences,
|
|
714
961
|
createInvestigationBoardLayoutPlan,
|
|
@@ -724,6 +971,8 @@ module.exports = Object.freeze({
|
|
|
724
971
|
validateProjectImportPlanForApply,
|
|
725
972
|
validateProjectStatusPlanForApply,
|
|
726
973
|
validateCatalogDemoPlanForApply,
|
|
974
|
+
validateComponentActivePlanForApply,
|
|
975
|
+
validateComponentAddPlanForApply,
|
|
727
976
|
validateComponentPlanForApply,
|
|
728
977
|
validateAssetPlanForApply,
|
|
729
978
|
validateCheckpointPlanForApply,
|
|
@@ -278,6 +278,19 @@ function createSupabaseGateway(client, options = {}) {
|
|
|
278
278
|
return dataOrThrow(result);
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
async function setComponentActive(input) {
|
|
282
|
+
return dataOrThrow(await client.rpc("set_somi_project_component_active", {
|
|
283
|
+
p_project_id: String(input.projectId),
|
|
284
|
+
p_version_number: Number(input.versionNumber),
|
|
285
|
+
p_expected_set_revision: Number(input.expectedSetRevision),
|
|
286
|
+
p_template_id: String(input.templateId),
|
|
287
|
+
p_instance_id: String(input.instanceId),
|
|
288
|
+
p_tab_label: String(input.tabLabel),
|
|
289
|
+
p_active: input.active === true,
|
|
290
|
+
p_updated_by_email: String(input.actorEmail || "")
|
|
291
|
+
}));
|
|
292
|
+
}
|
|
293
|
+
|
|
281
294
|
async function setProjectArchived(input) {
|
|
282
295
|
return dataOrThrow(await client.rpc("set_somi_project_archived", {
|
|
283
296
|
p_project_id: String(input.projectId || ""),
|
|
@@ -345,6 +358,7 @@ function createSupabaseGateway(client, options = {}) {
|
|
|
345
358
|
createCheckpoint,
|
|
346
359
|
restoreCheckpoint,
|
|
347
360
|
saveInstance,
|
|
361
|
+
setComponentActive,
|
|
348
362
|
setProjectArchived,
|
|
349
363
|
setCatalogDemoProject,
|
|
350
364
|
createScaffoldProject,
|
|
@@ -14,6 +14,8 @@ const {
|
|
|
14
14
|
validateProjectImportPlanForApply,
|
|
15
15
|
validateProjectStatusPlanForApply,
|
|
16
16
|
validateCatalogDemoPlanForApply,
|
|
17
|
+
validateComponentActivePlanForApply,
|
|
18
|
+
validateComponentAddPlanForApply,
|
|
17
19
|
validateComponentPlanForApply,
|
|
18
20
|
validateAssetPlanForApply,
|
|
19
21
|
validateCheckpointPlanForApply,
|
|
@@ -346,6 +348,170 @@ async function verifyAutomaticCheckpoint(gateway, plan, rpcResponse, validated,
|
|
|
346
348
|
return checkpoint;
|
|
347
349
|
}
|
|
348
350
|
|
|
351
|
+
async function applyComponentAddPlan(gateway, plan, actorEmail, options = {}) {
|
|
352
|
+
const before = await loadValidated(gateway, plan.project.id, plan.versionNumber);
|
|
353
|
+
validateComponentAddPlanForApply(plan, before, actorEmail);
|
|
354
|
+
let response;
|
|
355
|
+
try {
|
|
356
|
+
response = await gateway.setComponentActive({
|
|
357
|
+
projectId: before.project.id,
|
|
358
|
+
versionNumber: before.versionNumber,
|
|
359
|
+
expectedSetRevision: before.componentSet.revision,
|
|
360
|
+
templateId: plan.target.templateId,
|
|
361
|
+
instanceId: plan.target.instanceId,
|
|
362
|
+
tabLabel: plan.target.tabLabel,
|
|
363
|
+
active: true,
|
|
364
|
+
actorEmail
|
|
365
|
+
});
|
|
366
|
+
} catch (error) {
|
|
367
|
+
const normalized = normalizeError(error);
|
|
368
|
+
if (["E_CONFLICT", "E_AUTHORIZATION", "E_VALIDATION", "E_CODE_CHANGE_REQUIRED"].includes(normalized.code)) throw normalized;
|
|
369
|
+
let observed = null;
|
|
370
|
+
try {
|
|
371
|
+
const current = await gateway.loadVersion(plan.project.id, plan.versionNumber);
|
|
372
|
+
const row = current.componentRows.find(item => String(item.instance_id) === String(plan.target.instanceId));
|
|
373
|
+
if (row) observed = {
|
|
374
|
+
instanceId: String(row.instance_id),
|
|
375
|
+
templateId: String(row.template_id),
|
|
376
|
+
active: row.is_enabled === true && row.is_archived !== true,
|
|
377
|
+
revision: Number(row.revision),
|
|
378
|
+
componentSetRevision: Number(current.componentSet?.revision || 0)
|
|
379
|
+
};
|
|
380
|
+
} catch (readError) { void readError; }
|
|
381
|
+
throw cliError("E_AMBIGUOUS_SAVE", "Component 추가 요청의 결과를 확정할 수 없습니다. 같은 plan을 재적용하지 말고 Component 원본을 확인해 주세요.", {
|
|
382
|
+
requestErrorCode: normalized.code,
|
|
383
|
+
...(observed ? { observed } : {})
|
|
384
|
+
}, normalized);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
let after;
|
|
388
|
+
try { after = await loadValidated(gateway, plan.project.id, plan.versionNumber); }
|
|
389
|
+
catch (error) {
|
|
390
|
+
throw cliError("E_AMBIGUOUS_SAVE", "Component 추가 후 활성 구성을 다시 읽고 검증할 수 없습니다. 같은 plan을 재적용하지 마세요.", null, error);
|
|
391
|
+
}
|
|
392
|
+
const row = rpcRow(response);
|
|
393
|
+
const instance = findInstance(after, plan.target.instanceId);
|
|
394
|
+
const view = after.views.find(item => item.viewInstanceId === plan.composition.viewInstanceId);
|
|
395
|
+
const actualBindings = view?.bindings.map(binding => ({
|
|
396
|
+
key: binding.key,
|
|
397
|
+
dataInstanceId: binding.dataInstanceId,
|
|
398
|
+
accessMode: binding.accessMode,
|
|
399
|
+
required: binding.required
|
|
400
|
+
})) || [];
|
|
401
|
+
const responseMatches = String(row?.saved_instance_id || "") === instance.instanceId
|
|
402
|
+
&& Number(row?.saved_instance_revision) === instance.revision
|
|
403
|
+
&& Number(row?.saved_set_revision) === Number(after.componentSet.revision)
|
|
404
|
+
&& String(row?.saved_component_checksum || "") === String(after.componentSet.component_checksum || "")
|
|
405
|
+
&& row?.is_active === true;
|
|
406
|
+
const componentMatches = instance.templateId === plan.target.templateId
|
|
407
|
+
&& instance.tab === plan.target.tabLabel
|
|
408
|
+
&& equalJson(instance.data, plan.initialData)
|
|
409
|
+
&& instance.enabled === true
|
|
410
|
+
&& instance.archived === false
|
|
411
|
+
&& instance.required === false
|
|
412
|
+
&& instance.removable === true;
|
|
413
|
+
const compositionMatches = view
|
|
414
|
+
&& view.templateId === plan.composition.viewTemplateId
|
|
415
|
+
&& view.label === plan.composition.label
|
|
416
|
+
&& view.rendererId === plan.composition.rendererId
|
|
417
|
+
&& equalJson(view.modes, plan.composition.modes)
|
|
418
|
+
&& equalJson(actualBindings, plan.composition.bindings);
|
|
419
|
+
if (!responseMatches || !componentMatches || !compositionMatches
|
|
420
|
+
|| Number(after.componentSet.revision) <= Number(before.componentSet.revision)) {
|
|
421
|
+
throw cliError("E_AMBIGUOUS_SAVE", "Component 추가 RPC 응답과 authoritative Data/View/binding read-back이 일치하지 않습니다. 같은 plan을 재적용하지 마세요.");
|
|
422
|
+
}
|
|
423
|
+
return Object.freeze({
|
|
424
|
+
...receiptBase(plan, actorEmail, options),
|
|
425
|
+
target: clone(plan.target),
|
|
426
|
+
initialDataHash: plan.initialDataHash,
|
|
427
|
+
component: {
|
|
428
|
+
instanceId: instance.instanceId,
|
|
429
|
+
templateId: instance.templateId,
|
|
430
|
+
tabLabel: instance.tab,
|
|
431
|
+
revision: instance.revision
|
|
432
|
+
},
|
|
433
|
+
composition: clone(plan.composition),
|
|
434
|
+
componentSetRevision: Number(after.componentSet.revision),
|
|
435
|
+
componentChecksum: String(after.componentSet.component_checksum),
|
|
436
|
+
recoveredFromAmbiguousResponse: false
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async function applyComponentActivePlan(gateway, plan, actorEmail, options = {}) {
|
|
441
|
+
const before = await loadValidated(gateway, plan.project.id, plan.versionNumber);
|
|
442
|
+
validateComponentActivePlanForApply(plan, before, actorEmail);
|
|
443
|
+
let response;
|
|
444
|
+
try {
|
|
445
|
+
response = await gateway.setComponentActive({
|
|
446
|
+
projectId: before.project.id,
|
|
447
|
+
versionNumber: before.versionNumber,
|
|
448
|
+
expectedSetRevision: before.componentSet.revision,
|
|
449
|
+
templateId: plan.target.templateId,
|
|
450
|
+
instanceId: plan.target.instanceId,
|
|
451
|
+
tabLabel: plan.target.tabLabel,
|
|
452
|
+
active: plan.target.active,
|
|
453
|
+
actorEmail
|
|
454
|
+
});
|
|
455
|
+
} catch (error) {
|
|
456
|
+
const normalized = normalizeError(error);
|
|
457
|
+
if (["E_CONFLICT", "E_AUTHORIZATION", "E_VALIDATION", "E_CODE_CHANGE_REQUIRED"].includes(normalized.code)) throw normalized;
|
|
458
|
+
throw cliError("E_AMBIGUOUS_SAVE", "Component 사용 여부 변경 결과를 확정할 수 없습니다. 같은 plan을 재적용하지 말고 Component 원본을 확인해 주세요.", {
|
|
459
|
+
requestErrorCode: normalized.code
|
|
460
|
+
}, normalized);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
let after;
|
|
464
|
+
try { after = await loadValidated(gateway, plan.project.id, plan.versionNumber); }
|
|
465
|
+
catch (error) {
|
|
466
|
+
throw cliError("E_AMBIGUOUS_SAVE", "Component 사용 여부 변경 후 활성 구성을 다시 읽고 검증할 수 없습니다. 같은 plan을 재적용하지 마세요.", null, error);
|
|
467
|
+
}
|
|
468
|
+
const row = rpcRow(response);
|
|
469
|
+
const storedRow = after.componentRows.find(item => String(item.instance_id) === plan.target.instanceId);
|
|
470
|
+
const activeInstance = after.instances.find(item => item.instanceId === plan.target.instanceId) || null;
|
|
471
|
+
const view = after.views.find(item => item.viewInstanceId === plan.composition.viewInstanceId) || null;
|
|
472
|
+
const actualBindings = view?.bindings.map(binding => ({
|
|
473
|
+
key: binding.key,
|
|
474
|
+
dataInstanceId: binding.dataInstanceId,
|
|
475
|
+
accessMode: binding.accessMode,
|
|
476
|
+
required: binding.required
|
|
477
|
+
})) || [];
|
|
478
|
+
const dataPreserved = storedRow && sha256(storedRow.data) === plan.target.expectedDataHash
|
|
479
|
+
&& Number(storedRow.revision) === Number(plan.target.expectedRevision);
|
|
480
|
+
const responseMatches = String(row?.saved_instance_id || "") === plan.target.instanceId
|
|
481
|
+
&& Number(row?.saved_instance_revision) === Number(plan.target.expectedRevision)
|
|
482
|
+
&& Number(row?.saved_set_revision) === Number(after.componentSet.revision)
|
|
483
|
+
&& String(row?.saved_component_checksum || "") === String(after.componentSet.component_checksum || "")
|
|
484
|
+
&& row?.is_active === plan.target.active;
|
|
485
|
+
const stateMatches = plan.target.active
|
|
486
|
+
? activeInstance && storedRow.is_enabled === true && storedRow.is_archived === false && view
|
|
487
|
+
&& view.templateId === plan.composition.viewTemplateId
|
|
488
|
+
&& view.label === plan.composition.label
|
|
489
|
+
&& view.rendererId === plan.composition.rendererId
|
|
490
|
+
&& equalJson(view.modes, plan.composition.modes)
|
|
491
|
+
&& equalJson(actualBindings, plan.composition.bindings)
|
|
492
|
+
: !activeInstance && storedRow?.is_enabled === false && storedRow?.is_archived === true && !view;
|
|
493
|
+
if (!dataPreserved || !responseMatches || !stateMatches
|
|
494
|
+
|| Number(after.componentSet.revision) <= Number(before.componentSet.revision)) {
|
|
495
|
+
throw cliError("E_AMBIGUOUS_SAVE", "Component 사용 여부 RPC 응답과 authoritative Data/View/binding read-back이 일치하지 않습니다. 같은 plan을 재적용하지 마세요.");
|
|
496
|
+
}
|
|
497
|
+
return Object.freeze({
|
|
498
|
+
...receiptBase(plan, actorEmail, options),
|
|
499
|
+
target: clone(plan.target),
|
|
500
|
+
component: {
|
|
501
|
+
instanceId: plan.target.instanceId,
|
|
502
|
+
templateId: plan.target.templateId,
|
|
503
|
+
tabLabel: plan.target.tabLabel,
|
|
504
|
+
revision: Number(storedRow.revision),
|
|
505
|
+
active: plan.target.active,
|
|
506
|
+
archived: !plan.target.active,
|
|
507
|
+
dataHash: sha256(storedRow.data)
|
|
508
|
+
},
|
|
509
|
+
componentSetRevision: Number(after.componentSet.revision),
|
|
510
|
+
componentChecksum: String(after.componentSet.component_checksum),
|
|
511
|
+
recoveredFromAmbiguousResponse: false
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
|
|
349
515
|
async function applyComponentPlan(gateway, plan, actorEmail, options = {}) {
|
|
350
516
|
const before = await loadValidated(gateway, plan.project.id, plan.versionNumber);
|
|
351
517
|
const prepared = validateComponentPlanForApply(plan, before, actorEmail);
|
|
@@ -719,4 +885,4 @@ async function applyAssetPlan(gateway, plan, actorEmail, options = {}) {
|
|
|
719
885
|
});
|
|
720
886
|
}
|
|
721
887
|
|
|
722
|
-
module.exports = Object.freeze({ applyProjectCreationPlan, applyProjectImportPlan, applyProjectStatusPlan, applyCatalogDemoPlan, applyComponentPlan, applyAssetPlan, applyCheckpointPlan, comparePlannedFile, assetPathPrefix });
|
|
888
|
+
module.exports = Object.freeze({ applyProjectCreationPlan, applyProjectImportPlan, applyProjectStatusPlan, applyCatalogDemoPlan, applyComponentActivePlan, applyComponentAddPlan, applyComponentPlan, applyAssetPlan, applyCheckpointPlan, comparePlannedFile, assetPathPrefix });
|
|
@@ -164,13 +164,12 @@ function characterComponent(characterSource) {
|
|
|
164
164
|
tag: "플레이어 캐릭터",
|
|
165
165
|
color: CHARACTER_COLORS[characterId],
|
|
166
166
|
customSections,
|
|
167
|
-
containers: [{ id: crypto.randomUUID(), role: "primary", title: "기본 정보", sectionIds: customSections.map(section => section.id) }]
|
|
168
|
-
authorNote: ""
|
|
167
|
+
containers: [{ id: crypto.randomUUID(), role: "primary", title: "기본 정보", sectionIds: customSections.map(section => section.id) }]
|
|
169
168
|
};
|
|
170
169
|
}
|
|
171
170
|
return {
|
|
172
171
|
characters, names, order, customCharacters: [], deletedColumns: [],
|
|
173
|
-
enabledOptionalFields: ["image", "color", "tag"
|
|
172
|
+
enabledOptionalFields: ["image", "color", "tag"]
|
|
174
173
|
};
|
|
175
174
|
}
|
|
176
175
|
|
|
@@ -195,7 +194,7 @@ function progressComponent(flowSource) {
|
|
|
195
194
|
};
|
|
196
195
|
}
|
|
197
196
|
|
|
198
|
-
function cluesComponent(resourceSource
|
|
197
|
+
function cluesComponent(resourceSource) {
|
|
199
198
|
if (!isObject(resourceSource)) throw cliError("E_VALIDATION", "단서 resource 원본이 객체가 아닙니다.");
|
|
200
199
|
const clues = [];
|
|
201
200
|
Object.entries(resourceSource).forEach(([sourceId, item]) => {
|
|
@@ -207,7 +206,6 @@ function cluesComponent(resourceSource, writerNote) {
|
|
|
207
206
|
title: name,
|
|
208
207
|
location: source.verification === undefined ? "1차 조사" : "2차 조사",
|
|
209
208
|
description: text(source.content),
|
|
210
|
-
secret: text(source.verification),
|
|
211
209
|
tags: [source.verification === undefined ? "자료" : "제보"],
|
|
212
210
|
color: source.verification === undefined ? "#497fa8" : "#a86b49",
|
|
213
211
|
confirmed: true,
|
|
@@ -216,8 +214,7 @@ function cluesComponent(resourceSource, writerNote) {
|
|
|
216
214
|
});
|
|
217
215
|
return {
|
|
218
216
|
clues,
|
|
219
|
-
enabledOptionalFields: ["confirmed", "image", "location", "tags", "color"
|
|
220
|
-
writerNote
|
|
217
|
+
enabledOptionalFields: ["confirmed", "image", "location", "tags", "color"]
|
|
221
218
|
};
|
|
222
219
|
}
|
|
223
220
|
|
|
@@ -372,25 +369,19 @@ function loadTrueWriterCase(input, options = {}) {
|
|
|
372
369
|
}),
|
|
373
370
|
component("progress", progress),
|
|
374
371
|
component("common", {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
deletedBaseSections: []
|
|
372
|
+
documents: [
|
|
373
|
+
{ id: crypto.randomUUID(), title: "작품 개요", body: docs["00-overview.md"], subdocuments: [] },
|
|
374
|
+
{ id: crypto.randomUUID(), title: "프롤로그", body: text(jsonData.game_set.flow?.["00 - 프롤로그"]?.script), subdocuments: [] },
|
|
375
|
+
{ id: crypto.randomUUID(), title: "사건의 전말", body: "디자이너 전용 정답 및 사건의 전말", subdocuments: [{ id: crypto.randomUUID(), title: "사건의 진실", body: docs["01-truth.md"] }] },
|
|
376
|
+
{ id: crypto.randomUUID(), title: "에필로그", body: "게임 종료 후 공개 문서", subdocuments: [{ id: crypto.randomUUID(), title: "에필로그", body: text(jsonData.game_set.flow?.["14 - 에필로그"]?.script) }] }
|
|
377
|
+
],
|
|
378
|
+
deletedDocumentIds: []
|
|
383
379
|
}),
|
|
384
380
|
component("characters", characterComponent(jsonData.game_set.character)),
|
|
385
381
|
component("assets", { assets: {} }),
|
|
386
|
-
component("clues", cluesComponent(jsonData.resource
|
|
382
|
+
component("clues", cluesComponent(jsonData.resource)),
|
|
387
383
|
component("timeline", { events: timelineEvents }),
|
|
388
384
|
component("ending", ending),
|
|
389
|
-
component("postgame", {
|
|
390
|
-
truth: [{ id: crypto.randomUUID(), title: "사건의 진실", body: docs["01-truth.md"] }],
|
|
391
|
-
epilogue: [{ id: crypto.randomUUID(), title: "에필로그", body: text(jsonData.game_set.flow?.["14 - 에필로그"]?.script) }],
|
|
392
|
-
intro: { truth: "디자이너 전용 정답 및 사건의 전말", epilogue: "게임 종료 후 공개 문서" }
|
|
393
|
-
}),
|
|
394
385
|
component("author-notes", {
|
|
395
386
|
notes: [
|
|
396
387
|
["character-design", "캐릭터 설계 원문", docs["02-characters.md"]],
|