@ssobig/writer-cli 0.2.1 → 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 +13 -1
- 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/errors.md +1 -1
- package/tools/writer-cli/skills/ssobig-writer-cli/references/install-auth.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
- package/tools/writer-cli/src/update-remediation.cjs +4 -0
|
@@ -5,6 +5,8 @@ const fs = require("node:fs");
|
|
|
5
5
|
const path = require("node:path");
|
|
6
6
|
const config = require("../../../config.js");
|
|
7
7
|
const projectRuntime = require("../../../project-runtime.js");
|
|
8
|
+
const componentCatalog = require("../../../templates/mystery-v1/component-catalog-contract.js");
|
|
9
|
+
const componentRegistry = require("../../../templates/mystery-v1/component-registry.js").createDefaultRegistry();
|
|
8
10
|
const { createAuthManager } = require("./auth.cjs");
|
|
9
11
|
const { createSupabaseGateway } = require("./gateway.cjs");
|
|
10
12
|
const { inspectAssetFile, assertAssetId } = require("./asset-policy.cjs");
|
|
@@ -13,6 +15,9 @@ const {
|
|
|
13
15
|
componentContract,
|
|
14
16
|
validateLoadedVersion,
|
|
15
17
|
findInstance,
|
|
18
|
+
createComponentActivePlan,
|
|
19
|
+
createComponentAddPlan,
|
|
20
|
+
createComponentFieldPlan,
|
|
16
21
|
createComponentPlan,
|
|
17
22
|
createInvestigationBoardLayoutPlan,
|
|
18
23
|
createAssetPlan,
|
|
@@ -25,7 +30,7 @@ const {
|
|
|
25
30
|
verifyPlan
|
|
26
31
|
} = require("./domain.cjs");
|
|
27
32
|
const { loadTrueWriterCase } = require("./project-import.cjs");
|
|
28
|
-
const { applyProjectCreationPlan, applyProjectImportPlan, applyProjectStatusPlan, applyCatalogDemoPlan, applyComponentPlan, applyAssetPlan, applyCheckpointPlan } = require("./mutations.cjs");
|
|
33
|
+
const { applyProjectCreationPlan, applyProjectImportPlan, applyProjectStatusPlan, applyCatalogDemoPlan, applyComponentActivePlan, applyComponentAddPlan, applyComponentPlan, applyAssetPlan, applyCheckpointPlan } = require("./mutations.cjs");
|
|
29
34
|
const { snapshotSummary, diffSnapshots } = require("./checkpoint-diff.cjs");
|
|
30
35
|
const { cliError, normalizeError, redact } = require("./errors.cjs");
|
|
31
36
|
const { startDaemon, stopDaemon } = require("./daemon-runner.cjs");
|
|
@@ -186,10 +191,43 @@ function publicInstance(instance, includeData = false) {
|
|
|
186
191
|
required: instance.required,
|
|
187
192
|
removable: instance.removable
|
|
188
193
|
};
|
|
194
|
+
const fields = componentRegistry.get(instance.templateId)?.fieldConfiguration || [];
|
|
195
|
+
if (fields.length) {
|
|
196
|
+
const hasSelection = Array.isArray(instance.data?.enabledOptionalFields);
|
|
197
|
+
const enabled = new Set(hasSelection ? instance.data.enabledOptionalFields : fields.filter(field => !field.required).map(field => field.key));
|
|
198
|
+
result.fieldConfiguration = fields.map(field => ({
|
|
199
|
+
key: field.key,
|
|
200
|
+
label: field.label,
|
|
201
|
+
description: field.description,
|
|
202
|
+
required: field.required,
|
|
203
|
+
enabled: field.required || enabled.has(field.key)
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
189
206
|
if (includeData) result.data = instance.data;
|
|
190
207
|
return result;
|
|
191
208
|
}
|
|
192
209
|
|
|
210
|
+
function publicComponentAvailability(validated) {
|
|
211
|
+
const archivedComponents = componentContract.archivedInstances(validated.componentRows);
|
|
212
|
+
const occupiedSingletonTemplates = new Set(validated.componentRows.map(row => String(row.template_id)));
|
|
213
|
+
const addableTemplates = componentCatalog.list()
|
|
214
|
+
.filter(template => !template.required && !template.internal && template.view
|
|
215
|
+
&& (template.allowMultiple || !occupiedSingletonTemplates.has(template.templateId)))
|
|
216
|
+
.map(template => ({
|
|
217
|
+
templateId: template.templateId,
|
|
218
|
+
defaultInstanceId: template.defaultInstanceId,
|
|
219
|
+
tabLabel: template.tabLabel,
|
|
220
|
+
displayLabel: template.displayLabel,
|
|
221
|
+
description: template.description,
|
|
222
|
+
allowMultiple: template.allowMultiple,
|
|
223
|
+
requiresInstanceId: template.allowMultiple
|
|
224
|
+
}));
|
|
225
|
+
return {
|
|
226
|
+
archivedComponents: archivedComponents.map(item => publicInstance(item)),
|
|
227
|
+
addableTemplates
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
193
231
|
function publicCheckpoint(row, projectId, versionNumber) {
|
|
194
232
|
return normalizeCheckpoint({
|
|
195
233
|
...row,
|
|
@@ -610,13 +648,53 @@ async function execute(argv, deps) {
|
|
|
610
648
|
return { command: "project.plan-set-catalog-demo", data: { planFile: outputPath, planId: plan.planId, digest: plan.digest, operation: plan.operation, project: plan.project, target: plan.target } };
|
|
611
649
|
}
|
|
612
650
|
|
|
613
|
-
if (command === "component" && ["list", "get", "plan-patch", "plan-layout-investigation-board"].includes(subcommand)) {
|
|
651
|
+
if (command === "component" && ["list", "get", "plan-add", "plan-set-active", "plan-set-field", "plan-patch", "plan-layout-investigation-board"].includes(subcommand)) {
|
|
614
652
|
assertAllowedOptions(options, `component.${subcommand}`);
|
|
615
653
|
const projectRef = requireOption(options, "project");
|
|
616
654
|
const validated = validateLoadedVersion(await gateway.loadLatestVersion(projectRef, optionalVersionOption(options)));
|
|
617
655
|
const version = validated.versionNumber;
|
|
618
656
|
if (subcommand === "list") {
|
|
619
|
-
return { command: "component.list", data: { project: publicProject(validated.project, validated.instances), versionNumber: version, components: validated.instances.map(item => publicInstance(item)) } };
|
|
657
|
+
return { command: "component.list", data: { project: publicProject(validated.project, validated.instances), versionNumber: version, components: validated.instances.map(item => publicInstance(item)), ...publicComponentAvailability(validated) } };
|
|
658
|
+
}
|
|
659
|
+
if (subcommand === "plan-add") {
|
|
660
|
+
const plan = createComponentAddPlan(validated, {
|
|
661
|
+
templateId: requireOption(options, "template"),
|
|
662
|
+
instanceId: options.instance,
|
|
663
|
+
tabLabel: options.label
|
|
664
|
+
}, user.email, planOptions(deps));
|
|
665
|
+
const outputPath = writePrivateJson(requireOption(options, "out"), plan, deps.fs);
|
|
666
|
+
return {
|
|
667
|
+
command: "component.plan-add",
|
|
668
|
+
data: {
|
|
669
|
+
planFile: outputPath,
|
|
670
|
+
planId: plan.planId,
|
|
671
|
+
digest: plan.digest,
|
|
672
|
+
operation: plan.operation,
|
|
673
|
+
target: plan.target,
|
|
674
|
+
initialData: plan.initialData,
|
|
675
|
+
initialDataHash: plan.initialDataHash,
|
|
676
|
+
composition: plan.composition
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
if (subcommand === "plan-set-active") {
|
|
681
|
+
const activeValue = requireOption(options, "active");
|
|
682
|
+
if (!new Set(["true", "false"]).has(activeValue)) throw cliError("E_USAGE", "--active는 true 또는 false여야 합니다.");
|
|
683
|
+
const plan = createComponentActivePlan(validated, {
|
|
684
|
+
instanceId: requireOption(options, "instance"),
|
|
685
|
+
active: activeValue === "true"
|
|
686
|
+
}, user.email, planOptions(deps));
|
|
687
|
+
const outputPath = writePrivateJson(requireOption(options, "out"), plan, deps.fs);
|
|
688
|
+
return { command: "component.plan-set-active", data: { planFile: outputPath, planId: plan.planId, digest: plan.digest, operation: plan.operation, target: plan.target, composition: plan.composition } };
|
|
689
|
+
}
|
|
690
|
+
if (subcommand === "plan-set-field") {
|
|
691
|
+
const enabledValue = requireOption(options, "enabled");
|
|
692
|
+
if (!new Set(["true", "false"]).has(enabledValue)) throw cliError("E_USAGE", "--enabled는 true 또는 false여야 합니다.");
|
|
693
|
+
const fieldPlanOptions = planOptions(deps);
|
|
694
|
+
if (options.message !== undefined) fieldPlanOptions.checkpointMessage = String(options.message);
|
|
695
|
+
const plan = createComponentFieldPlan(validated, requireOption(options, "instance"), requireOption(options, "field"), enabledValue === "true", user.email, fieldPlanOptions);
|
|
696
|
+
const outputPath = writePrivateJson(requireOption(options, "out"), plan, deps.fs);
|
|
697
|
+
return { command: "component.plan-set-field", data: { planFile: outputPath, planId: plan.planId, digest: plan.digest, operation: plan.operation, checkpointMessage: plan.checkpointMessage, target: plan.target, fieldSelection: plan.fieldSelection, beforeHash: plan.beforeHash, afterHash: plan.afterHash } };
|
|
620
698
|
}
|
|
621
699
|
if (subcommand === "plan-layout-investigation-board") {
|
|
622
700
|
const spec = readJson(requireOption(options, "spec"), "추리 보드 layout spec", deps.fs).value;
|
|
@@ -783,6 +861,10 @@ async function execute(argv, deps) {
|
|
|
783
861
|
receipt = await applyCatalogDemoPlan(gateway, plan, user.email, { appliedAt: nowIso(deps) });
|
|
784
862
|
} else if (plan.operation.startsWith("project.")) {
|
|
785
863
|
receipt = await applyProjectStatusPlan(gateway, plan, user.email, { appliedAt: nowIso(deps) });
|
|
864
|
+
} else if (plan.operation === "component.set-active") {
|
|
865
|
+
receipt = await applyComponentActivePlan(gateway, plan, user.email, { appliedAt: nowIso(deps) });
|
|
866
|
+
} else if (plan.operation === "component.add") {
|
|
867
|
+
receipt = await applyComponentAddPlan(gateway, plan, user.email, { appliedAt: nowIso(deps) });
|
|
786
868
|
} else if (plan.operation === "component.patch") {
|
|
787
869
|
receipt = await applyComponentPlan(gateway, plan, user.email, { appliedAt: nowIso(deps) });
|
|
788
870
|
} else if (plan.operation.startsWith("checkpoint.")) {
|
|
@@ -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 });
|