@ssobig/writer-cli 0.2.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 +35 -0
- package/asset-repository.js +278 -0
- package/config.js +14 -0
- package/package.json +28 -0
- package/project-runtime.js +102 -0
- package/storage-path.js +110 -0
- package/templates/mystery-v1/authoring-view-preference.js +34 -0
- package/templates/mystery-v1/character-perspective-preview.js +61 -0
- package/templates/mystery-v1/component-asset-operations.js +103 -0
- package/templates/mystery-v1/component-autosave.js +121 -0
- package/templates/mystery-v1/component-catalog-contract.js +340 -0
- package/templates/mystery-v1/component-checkpoint-history.js +145 -0
- package/templates/mystery-v1/component-contract.js +90 -0
- package/templates/mystery-v1/component-draft-operations.js +313 -0
- package/templates/mystery-v1/component-field-contracts.js +595 -0
- package/templates/mystery-v1/component-id-policy.js +64 -0
- package/templates/mystery-v1/component-manager.js +396 -0
- package/templates/mystery-v1/component-navigation-counts.js +64 -0
- package/templates/mystery-v1/component-registry.js +205 -0
- package/templates/mystery-v1/component-renderers.js +139 -0
- package/templates/mystery-v1/component-storage-contract.js +237 -0
- package/templates/mystery-v1/external-update-coordinator.js +91 -0
- package/templates/mystery-v1/output-clue-card-layout.js +46 -0
- package/templates/mystery-v1/page-header.js +26 -0
- package/templates/mystery-v1/render-ui-state.js +76 -0
- package/templates/mystery-v1/runtime-snapshot-reconciler.js +40 -0
- package/templates/mystery-v1/tab-bar.js +87 -0
- package/templates/mystery-v1/view-component-contract.js +152 -0
- package/templates/mystery-v1/view-component-registry.js +44 -0
- package/templates/mystery-v1/view-component-runtime.js +95 -0
- package/tools/writer-cli/bin/ssobig-writer-daemon.cjs +34 -0
- package/tools/writer-cli/bin/ssobig-writer.cjs +12 -0
- package/tools/writer-cli/package-lock.json +121 -0
- package/tools/writer-cli/package.json +22 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/SKILL.md +38 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/agents/openai.yaml +4 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/assets-checkpoints.md +5 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/errors.md +10 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/install-auth.md +7 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/projects-components.md +7 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/read-search.md +5 -0
- package/tools/writer-cli/src/agent-paths.cjs +114 -0
- package/tools/writer-cli/src/agent-service.cjs +496 -0
- package/tools/writer-cli/src/asset-policy.cjs +113 -0
- package/tools/writer-cli/src/auth.cjs +655 -0
- package/tools/writer-cli/src/checkpoint-diff.cjs +128 -0
- package/tools/writer-cli/src/command-registry.cjs +152 -0
- package/tools/writer-cli/src/commands.cjs +841 -0
- package/tools/writer-cli/src/corpus.cjs +83 -0
- package/tools/writer-cli/src/daemon-app.cjs +106 -0
- package/tools/writer-cli/src/daemon-client.cjs +187 -0
- package/tools/writer-cli/src/daemon-protocol.cjs +184 -0
- package/tools/writer-cli/src/daemon-runner.cjs +97 -0
- package/tools/writer-cli/src/daemon-server.cjs +378 -0
- package/tools/writer-cli/src/diagnostics.cjs +235 -0
- package/tools/writer-cli/src/domain.cjs +731 -0
- package/tools/writer-cli/src/errors.cjs +47 -0
- package/tools/writer-cli/src/gateway.cjs +357 -0
- package/tools/writer-cli/src/investigation-board-layout.cjs +328 -0
- package/tools/writer-cli/src/json-patch.cjs +98 -0
- package/tools/writer-cli/src/json.cjs +26 -0
- package/tools/writer-cli/src/local-index-cache.cjs +139 -0
- package/tools/writer-cli/src/local-index-lookup.cjs +98 -0
- package/tools/writer-cli/src/local-index-query.cjs +304 -0
- package/tools/writer-cli/src/local-index-snapshot.cjs +235 -0
- package/tools/writer-cli/src/local-index-storage.cjs +284 -0
- package/tools/writer-cli/src/local-index.cjs +199 -0
- package/tools/writer-cli/src/mutations.cjs +722 -0
- package/tools/writer-cli/src/platform-runner.cjs +55 -0
- package/tools/writer-cli/src/project-import.cjs +485 -0
- package/tools/writer-cli/src/skill-manager.cjs +255 -0
- package/tools/writer-cli/src/source-fingerprint.cjs +90 -0
- package/tools/writer-cli/src/update-gate.cjs +102 -0
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { File } = require("node:buffer");
|
|
4
|
+
const AssetRepositoryModule = require("../../../asset-repository.js");
|
|
5
|
+
const StoragePath = require("../../../storage-path.js");
|
|
6
|
+
const { sha256, equalJson, clone } = require("./json.cjs");
|
|
7
|
+
const { cliError, normalizeError } = require("./errors.cjs");
|
|
8
|
+
const { readAssetFile, assertAssetId } = require("./asset-policy.cjs");
|
|
9
|
+
const {
|
|
10
|
+
componentContract,
|
|
11
|
+
validateLoadedVersion,
|
|
12
|
+
findInstance,
|
|
13
|
+
validateProjectCreationPlanForApply,
|
|
14
|
+
validateProjectImportPlanForApply,
|
|
15
|
+
validateProjectStatusPlanForApply,
|
|
16
|
+
validateCatalogDemoPlanForApply,
|
|
17
|
+
validateComponentPlanForApply,
|
|
18
|
+
validateAssetPlanForApply,
|
|
19
|
+
validateCheckpointPlanForApply,
|
|
20
|
+
normalizeCheckpoint
|
|
21
|
+
} = require("./domain.cjs");
|
|
22
|
+
const { assertSourceSnapshot, importCompositionRows } = require("./project-import.cjs");
|
|
23
|
+
|
|
24
|
+
function rpcRow(value) {
|
|
25
|
+
return Array.isArray(value) ? value[0] : value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function receiptBase(plan, actorEmail, options = {}) {
|
|
29
|
+
return {
|
|
30
|
+
schemaVersion: "ssobig-writer-apply-receipt-v1",
|
|
31
|
+
planId: plan.planId,
|
|
32
|
+
operation: plan.operation,
|
|
33
|
+
actorEmail,
|
|
34
|
+
project: clone(plan.project),
|
|
35
|
+
...(Number.isSafeInteger(Number(plan.versionNumber)) ? { versionNumber: Number(plan.versionNumber) } : {}),
|
|
36
|
+
appliedAt: String(options.appliedAt || new Date().toISOString())
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function applyProjectCreationPlan(gateway, plan, actorEmail, options = {}) {
|
|
41
|
+
const existing = await gateway.findProjectBySlug(plan.target?.slug);
|
|
42
|
+
const target = validateProjectCreationPlanForApply(plan, existing, actorEmail);
|
|
43
|
+
let response;
|
|
44
|
+
try {
|
|
45
|
+
response = await gateway.createScaffoldProject({ slug: target.slug, title: target.title, authorName: target.authorName });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
const normalized = normalizeError(error);
|
|
48
|
+
if (["E_CONFLICT", "E_AUTHORIZATION", "E_VALIDATION"].includes(normalized.code)) throw normalized;
|
|
49
|
+
let observed = null;
|
|
50
|
+
try {
|
|
51
|
+
const candidate = await gateway.findProjectBySlug(target.slug);
|
|
52
|
+
if (candidate) observed = { id: candidate.id, slug: candidate.slug, status: candidate.status, updatedAt: candidate.updated_at };
|
|
53
|
+
} catch (readError) { void readError; }
|
|
54
|
+
throw cliError("E_AMBIGUOUS_SAVE", "새 작품 생성 요청의 결과를 확정할 수 없습니다. 같은 plan을 재적용하지 말고 작품 목록을 확인해 주세요.", {
|
|
55
|
+
requestErrorCode: normalized.code,
|
|
56
|
+
...(observed ? { observed } : {})
|
|
57
|
+
}, normalized);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const row = rpcRow(response);
|
|
61
|
+
let project;
|
|
62
|
+
try { project = await gateway.findProjectBySlug(target.slug); }
|
|
63
|
+
catch (error) {
|
|
64
|
+
throw cliError("E_AMBIGUOUS_SAVE", "새 작품 생성 후 작품 원본을 다시 읽을 수 없습니다. 같은 plan을 재적용하지 마세요.", null, error);
|
|
65
|
+
}
|
|
66
|
+
if (!project) throw cliError("E_AMBIGUOUS_SAVE", "새 작품 생성 후 작품 원본을 찾을 수 없습니다. 같은 plan을 재적용하지 마세요.");
|
|
67
|
+
|
|
68
|
+
let validated;
|
|
69
|
+
try { validated = await loadValidated(gateway, project.id, 1); }
|
|
70
|
+
catch (error) {
|
|
71
|
+
throw cliError("E_AMBIGUOUS_SAVE", "새 작품의 활성 Component 구성을 검증할 수 없습니다. 같은 plan을 재적용하지 마세요.", null, error);
|
|
72
|
+
}
|
|
73
|
+
const basic = findInstance(validated, "ssobig.basic");
|
|
74
|
+
const responseMatches = String(row?.id || "") === String(project.id)
|
|
75
|
+
&& String(row?.slug || "") === target.slug;
|
|
76
|
+
const projectMatches = String(project.slug || "") === target.slug
|
|
77
|
+
&& String(project.title || "") === target.title
|
|
78
|
+
&& String(project.status || "") === "active"
|
|
79
|
+
&& String(basic.data?.title || "") === target.title;
|
|
80
|
+
if (!responseMatches || !projectMatches) {
|
|
81
|
+
throw cliError("E_AMBIGUOUS_SAVE", "새 작품 RPC 응답과 authoritative Component read-back이 일치하지 않습니다. 같은 plan을 재적용하지 마세요.");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return Object.freeze({
|
|
85
|
+
schemaVersion: "ssobig-writer-apply-receipt-v1",
|
|
86
|
+
planId: plan.planId,
|
|
87
|
+
operation: plan.operation,
|
|
88
|
+
actorEmail,
|
|
89
|
+
appliedAt: String(options.appliedAt || new Date().toISOString()),
|
|
90
|
+
target: clone(plan.target),
|
|
91
|
+
project: {
|
|
92
|
+
id: String(project.id),
|
|
93
|
+
slug: String(project.slug),
|
|
94
|
+
storageNamespace: String(project.storage_namespace),
|
|
95
|
+
authorName: String(project.author_name),
|
|
96
|
+
status: String(project.status)
|
|
97
|
+
},
|
|
98
|
+
manuscriptTitle: String(basic.data.title),
|
|
99
|
+
versionNumber: 1,
|
|
100
|
+
componentSetRevision: Number(validated.componentSet.revision),
|
|
101
|
+
componentChecksum: String(validated.componentSet.component_checksum),
|
|
102
|
+
componentCount: validated.instances.length,
|
|
103
|
+
viewCount: validated.views.length,
|
|
104
|
+
recoveredFromAmbiguousResponse: false
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function applyProjectImportPlan(gateway, plan, actorEmail, options = {}) {
|
|
109
|
+
const existing = await gateway.findProjectBySlug(plan.target?.slug);
|
|
110
|
+
const prepared = validateProjectImportPlanForApply(plan, existing, actorEmail);
|
|
111
|
+
assertSourceSnapshot(plan.source, { fs: options.fs });
|
|
112
|
+
const plannedComposition = importCompositionRows(prepared.components);
|
|
113
|
+
let response;
|
|
114
|
+
try {
|
|
115
|
+
response = await gateway.createImportedProject({
|
|
116
|
+
slug: prepared.target.slug,
|
|
117
|
+
title: prepared.target.title,
|
|
118
|
+
authorName: prepared.target.authorName,
|
|
119
|
+
components: prepared.components,
|
|
120
|
+
provenance: prepared.provenance
|
|
121
|
+
});
|
|
122
|
+
} catch (error) {
|
|
123
|
+
const normalized = normalizeError(error);
|
|
124
|
+
if (["E_CONFLICT", "E_AUTHORIZATION", "E_VALIDATION"].includes(normalized.code)) throw normalized;
|
|
125
|
+
let observed = null;
|
|
126
|
+
try {
|
|
127
|
+
const candidate = await gateway.findProjectBySlug(prepared.target.slug);
|
|
128
|
+
if (candidate) observed = { id: candidate.id, slug: candidate.slug, status: candidate.status, updatedAt: candidate.updated_at };
|
|
129
|
+
} catch (readError) { void readError; }
|
|
130
|
+
throw cliError("E_AMBIGUOUS_SAVE", "작품 import 요청의 결과를 확정할 수 없습니다. 같은 plan을 재적용하지 말고 작품 목록을 확인해 주세요.", {
|
|
131
|
+
requestErrorCode: normalized.code,
|
|
132
|
+
...(observed ? { observed } : {})
|
|
133
|
+
}, normalized);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const row = rpcRow(response);
|
|
137
|
+
let project;
|
|
138
|
+
try { project = await gateway.findProjectBySlug(prepared.target.slug); }
|
|
139
|
+
catch (error) {
|
|
140
|
+
throw cliError("E_AMBIGUOUS_SAVE", "작품 import 후 작품 원본을 다시 읽을 수 없습니다. 같은 plan을 재적용하지 마세요.", null, error);
|
|
141
|
+
}
|
|
142
|
+
if (!project) throw cliError("E_AMBIGUOUS_SAVE", "작품 import 후 작품 원본을 찾을 수 없습니다. 같은 plan을 재적용하지 마세요.");
|
|
143
|
+
|
|
144
|
+
let validated;
|
|
145
|
+
try { validated = await loadValidated(gateway, project.id, 1); }
|
|
146
|
+
catch (error) {
|
|
147
|
+
throw cliError("E_AMBIGUOUS_SAVE", "import한 작품의 활성 Component 구성을 검증할 수 없습니다. 같은 plan을 재적용하지 마세요.", null, error);
|
|
148
|
+
}
|
|
149
|
+
const basic = findInstance(validated, "ssobig.basic");
|
|
150
|
+
const actualById = new Map(validated.instances.map(instance => [instance.instanceId, instance]));
|
|
151
|
+
const componentDataMatches = prepared.components.every(expected => {
|
|
152
|
+
const actual = actualById.get(expected.instanceId);
|
|
153
|
+
return actual
|
|
154
|
+
&& actual.templateId === expected.templateId
|
|
155
|
+
&& equalJson(actual.data, expected.data);
|
|
156
|
+
});
|
|
157
|
+
const metadata = validated.componentSet.migration_metadata || {};
|
|
158
|
+
const responseMatches = String(row?.id || "") === String(project.id)
|
|
159
|
+
&& String(row?.slug || "") === prepared.target.slug;
|
|
160
|
+
const projectMatches = String(project.slug || "") === prepared.target.slug
|
|
161
|
+
&& String(project.title || "") === prepared.target.title
|
|
162
|
+
&& String(project.status || "") === "active"
|
|
163
|
+
&& String(basic.data?.title || "") === prepared.target.title;
|
|
164
|
+
const compositionMatches = validated.instances.length === plannedComposition.componentRows.length
|
|
165
|
+
&& validated.views.length === plannedComposition.viewRows.length
|
|
166
|
+
&& componentDataMatches
|
|
167
|
+
&& String(metadata.operation || "") === "writer-import-v1"
|
|
168
|
+
&& String(metadata.source?.sourceHash || "") === String(plan.source.sourceHash || "");
|
|
169
|
+
if (!responseMatches || !projectMatches || !compositionMatches) {
|
|
170
|
+
throw cliError("E_AMBIGUOUS_SAVE", "작품 import RPC 응답과 authoritative Component read-back이 일치하지 않습니다. 같은 plan을 재적용하지 마세요.");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return Object.freeze({
|
|
174
|
+
schemaVersion: "ssobig-writer-apply-receipt-v1",
|
|
175
|
+
planId: plan.planId,
|
|
176
|
+
operation: plan.operation,
|
|
177
|
+
actorEmail,
|
|
178
|
+
appliedAt: String(options.appliedAt || new Date().toISOString()),
|
|
179
|
+
target: clone(plan.target),
|
|
180
|
+
source: clone(prepared.provenance),
|
|
181
|
+
coverage: clone(plan.coverage),
|
|
182
|
+
project: {
|
|
183
|
+
id: String(project.id),
|
|
184
|
+
slug: String(project.slug),
|
|
185
|
+
storageNamespace: String(project.storage_namespace),
|
|
186
|
+
authorName: String(project.author_name),
|
|
187
|
+
status: String(project.status)
|
|
188
|
+
},
|
|
189
|
+
manuscriptTitle: String(basic.data.title),
|
|
190
|
+
versionNumber: 1,
|
|
191
|
+
componentSetRevision: Number(validated.componentSet.revision),
|
|
192
|
+
componentChecksum: String(validated.componentSet.component_checksum),
|
|
193
|
+
componentCount: validated.instances.length,
|
|
194
|
+
viewCount: validated.views.length,
|
|
195
|
+
pendingAssets: clone(plan.assets),
|
|
196
|
+
recoveredFromAmbiguousResponse: false
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function applyProjectStatusPlan(gateway, plan, actorEmail, options = {}) {
|
|
201
|
+
let before;
|
|
202
|
+
try {
|
|
203
|
+
before = await gateway.getProject(plan.project.id, plan.target.expectedStatus);
|
|
204
|
+
} catch (error) {
|
|
205
|
+
const normalized = normalizeError(error);
|
|
206
|
+
if (normalized.code === "E_PROJECT_NOT_FOUND") {
|
|
207
|
+
let moved = false;
|
|
208
|
+
try { moved = Boolean(await gateway.getProject(plan.project.id, plan.target.targetStatus)); }
|
|
209
|
+
catch (readError) { void readError; }
|
|
210
|
+
if (moved) throw cliError("E_CONFLICT", "plan 생성 후 작품 상태가 변경되었습니다. 새 plan을 만들어 주세요.");
|
|
211
|
+
}
|
|
212
|
+
throw normalized;
|
|
213
|
+
}
|
|
214
|
+
validateProjectStatusPlanForApply(plan, before, actorEmail);
|
|
215
|
+
let response;
|
|
216
|
+
try {
|
|
217
|
+
response = await gateway.setProjectArchived({
|
|
218
|
+
projectId: before.id,
|
|
219
|
+
expectedStatus: plan.target.expectedStatus,
|
|
220
|
+
expectedUpdatedAt: plan.target.expectedUpdatedAt,
|
|
221
|
+
archived: plan.target.targetStatus === "archived",
|
|
222
|
+
actorEmail
|
|
223
|
+
});
|
|
224
|
+
} catch (error) {
|
|
225
|
+
const normalized = normalizeError(error);
|
|
226
|
+
if (["E_CONFLICT", "E_AUTHORIZATION", "E_PROJECT_NOT_FOUND"].includes(normalized.code)) throw normalized;
|
|
227
|
+
let observed = null;
|
|
228
|
+
for (const status of [plan.target.targetStatus, plan.target.expectedStatus]) {
|
|
229
|
+
try {
|
|
230
|
+
const project = await gateway.getProject(plan.project.id, status);
|
|
231
|
+
observed = { status: project.status, updatedAt: project.updated_at };
|
|
232
|
+
break;
|
|
233
|
+
} catch (readError) { void readError; }
|
|
234
|
+
}
|
|
235
|
+
throw cliError("E_AMBIGUOUS_SAVE", "작품 상태 변경 요청의 결과를 확정할 수 없습니다. 같은 plan을 재적용하지 말고 작품 목록을 확인해 주세요.", {
|
|
236
|
+
requestErrorCode: normalized.code,
|
|
237
|
+
...(observed ? { observed } : {})
|
|
238
|
+
}, normalized);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let after;
|
|
242
|
+
try { after = await gateway.getProject(plan.project.id, plan.target.targetStatus); }
|
|
243
|
+
catch (error) {
|
|
244
|
+
throw cliError("E_AMBIGUOUS_SAVE", "작품 상태 변경 후 원본을 다시 읽을 수 없습니다. 같은 plan을 재적용하지 마세요.", null, error);
|
|
245
|
+
}
|
|
246
|
+
const row = rpcRow(response);
|
|
247
|
+
if (String(after.id) !== String(plan.project.id)
|
|
248
|
+
|| String(after.status) !== String(plan.target.targetStatus)
|
|
249
|
+
|| String(row?.id || "") !== String(after.id)
|
|
250
|
+
|| String(row?.status || "") !== String(after.status)
|
|
251
|
+
|| String(row?.updated_at || "") !== String(after.updated_at || "")) {
|
|
252
|
+
throw cliError("E_AMBIGUOUS_SAVE", "작품 상태 RPC 응답과 authoritative read-back이 일치하지 않습니다. 같은 plan을 재적용하지 마세요.");
|
|
253
|
+
}
|
|
254
|
+
return Object.freeze({
|
|
255
|
+
...receiptBase(plan, actorEmail, options),
|
|
256
|
+
target: clone(plan.target),
|
|
257
|
+
beforeStatus: String(before.status),
|
|
258
|
+
afterStatus: String(after.status),
|
|
259
|
+
beforeUpdatedAt: String(before.updated_at),
|
|
260
|
+
afterUpdatedAt: String(after.updated_at),
|
|
261
|
+
recoveredFromAmbiguousResponse: false
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function applyCatalogDemoPlan(gateway, plan, actorEmail, options = {}) {
|
|
266
|
+
const before = await gateway.getProject(plan.project.id, "active");
|
|
267
|
+
validateCatalogDemoPlanForApply(plan, before, actorEmail);
|
|
268
|
+
let response;
|
|
269
|
+
try {
|
|
270
|
+
response = await gateway.setCatalogDemoProject({
|
|
271
|
+
projectId: before.id,
|
|
272
|
+
expectedIsCatalogDemo: plan.target.expectedIsCatalogDemo,
|
|
273
|
+
expectedUpdatedAt: plan.target.expectedUpdatedAt,
|
|
274
|
+
enabled: plan.target.enabled
|
|
275
|
+
});
|
|
276
|
+
} catch (error) {
|
|
277
|
+
const normalized = normalizeError(error);
|
|
278
|
+
if (["E_CONFLICT", "E_AUTHORIZATION", "E_PROJECT_NOT_FOUND", "E_VALIDATION"].includes(normalized.code)) throw normalized;
|
|
279
|
+
let observed = null;
|
|
280
|
+
try {
|
|
281
|
+
const project = await gateway.getProject(plan.project.id, "active");
|
|
282
|
+
observed = { isCatalogDemo: project.is_catalog_demo === true, updatedAt: project.updated_at };
|
|
283
|
+
} catch (readError) { void readError; }
|
|
284
|
+
throw cliError("E_AMBIGUOUS_SAVE", "공용 데모 지정 결과를 확정할 수 없습니다. 같은 plan을 재적용하지 말고 작품 상태를 확인해 주세요.", {
|
|
285
|
+
requestErrorCode: normalized.code,
|
|
286
|
+
...(observed ? { observed } : {})
|
|
287
|
+
}, normalized);
|
|
288
|
+
}
|
|
289
|
+
const after = await gateway.getProject(plan.project.id, "active");
|
|
290
|
+
const row = rpcRow(response);
|
|
291
|
+
if (String(after.id) !== String(plan.project.id)
|
|
292
|
+
|| Boolean(after.is_catalog_demo) !== plan.target.enabled
|
|
293
|
+
|| String(row?.id || "") !== String(after.id)
|
|
294
|
+
|| Boolean(row?.is_catalog_demo) !== Boolean(after.is_catalog_demo)
|
|
295
|
+
|| String(row?.updated_at || "") !== String(after.updated_at || "")) {
|
|
296
|
+
throw cliError("E_AMBIGUOUS_SAVE", "공용 데모 RPC 응답과 authoritative read-back이 일치하지 않습니다. 같은 plan을 재적용하지 마세요.");
|
|
297
|
+
}
|
|
298
|
+
return Object.freeze({
|
|
299
|
+
...receiptBase(plan, actorEmail, options),
|
|
300
|
+
target: clone(plan.target),
|
|
301
|
+
beforeIsCatalogDemo: before.is_catalog_demo === true,
|
|
302
|
+
afterIsCatalogDemo: after.is_catalog_demo === true,
|
|
303
|
+
beforeUpdatedAt: String(before.updated_at),
|
|
304
|
+
afterUpdatedAt: String(after.updated_at),
|
|
305
|
+
recoveredFromAmbiguousResponse: false
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function loadValidated(gateway, projectId, versionNumber) {
|
|
310
|
+
return validateLoadedVersion(await gateway.loadVersion(projectId, versionNumber));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function verifyReadBack(plan, validated) {
|
|
314
|
+
const instance = findInstance(validated, plan.target.instanceId);
|
|
315
|
+
const expectedRevision = Number(plan.target.expectedRevision) + 1;
|
|
316
|
+
if (instance.revision !== expectedRevision
|
|
317
|
+
|| sha256(instance.data) !== plan.afterHash
|
|
318
|
+
|| Number(validated.componentSet.revision) <= Number(plan.readiness.componentSetRevision)) {
|
|
319
|
+
throw cliError("E_AMBIGUOUS_SAVE", "저장 후 Component 원본을 검증할 수 없습니다. 다시 쓰지 말고 현재 원본을 확인해 주세요.", {
|
|
320
|
+
expectedRevision,
|
|
321
|
+
actualRevision: instance.revision,
|
|
322
|
+
actualSetRevision: Number(validated.componentSet.revision)
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
return instance;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function verifyAutomaticCheckpoint(gateway, plan, rpcResponse, validated, expectedInstanceIds = null) {
|
|
329
|
+
const row = rpcRow(rpcResponse);
|
|
330
|
+
let checkpointRow;
|
|
331
|
+
try { checkpointRow = await gateway.getCheckpoint(plan.project.id, plan.versionNumber, String(row?.checkpoint_id || "")); }
|
|
332
|
+
catch (error) { throw cliError("E_AMBIGUOUS_SAVE", "CLI 저장 후 자동 체크포인트를 다시 읽을 수 없습니다. 같은 plan을 재적용하지 마세요.", null, error); }
|
|
333
|
+
const checkpoint = normalizeCheckpoint(checkpointRow, { projectId: plan.project.id, versionNumber: plan.versionNumber });
|
|
334
|
+
const changedIds = Array.isArray(expectedInstanceIds)
|
|
335
|
+
? expectedInstanceIds.map(String)
|
|
336
|
+
: plan.target?.instanceId ? [String(plan.target.instanceId)] : [];
|
|
337
|
+
if (checkpoint.id !== String(row?.checkpoint_id || "")
|
|
338
|
+
|| checkpoint.number !== Number(row?.checkpoint_number)
|
|
339
|
+
|| checkpoint.source !== "cli"
|
|
340
|
+
|| checkpoint.message !== plan.checkpointMessage
|
|
341
|
+
|| checkpoint.componentSetRevision !== Number(validated.componentSet.revision)
|
|
342
|
+
|| checkpoint.componentChecksum !== String(validated.componentSet.component_checksum || "")
|
|
343
|
+
|| changedIds.some(instanceId => !checkpoint.changedInstanceIds.includes(instanceId))) {
|
|
344
|
+
throw cliError("E_AMBIGUOUS_SAVE", "CLI 자동 체크포인트와 authoritative read-back이 일치하지 않습니다. 같은 plan을 재적용하지 마세요.");
|
|
345
|
+
}
|
|
346
|
+
return checkpoint;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async function applyComponentPlan(gateway, plan, actorEmail, options = {}) {
|
|
350
|
+
const before = await loadValidated(gateway, plan.project.id, plan.versionNumber);
|
|
351
|
+
const prepared = validateComponentPlanForApply(plan, before, actorEmail);
|
|
352
|
+
let response = null;
|
|
353
|
+
let saveError = null;
|
|
354
|
+
try {
|
|
355
|
+
response = await gateway.saveInstance({
|
|
356
|
+
projectId: before.project.id,
|
|
357
|
+
versionNumber: before.versionNumber,
|
|
358
|
+
payload: prepared.payload,
|
|
359
|
+
expectedRevision: prepared.instance.revision,
|
|
360
|
+
actorEmail,
|
|
361
|
+
checkpointMessage: plan.checkpointMessage
|
|
362
|
+
});
|
|
363
|
+
} catch (error) {
|
|
364
|
+
const normalized = normalizeError(error);
|
|
365
|
+
if (normalized.code === "E_CONFLICT" || normalized.code === "E_AUTHORIZATION") throw normalized;
|
|
366
|
+
saveError = normalized;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (saveError) {
|
|
370
|
+
let observed = null;
|
|
371
|
+
try {
|
|
372
|
+
const current = await loadValidated(gateway, plan.project.id, plan.versionNumber);
|
|
373
|
+
const currentInstance = findInstance(current, plan.target.instanceId);
|
|
374
|
+
observed = {
|
|
375
|
+
revision: currentInstance.revision,
|
|
376
|
+
dataHash: sha256(currentInstance.data),
|
|
377
|
+
componentSetRevision: Number(current.componentSet.revision)
|
|
378
|
+
};
|
|
379
|
+
} catch (readError) { void readError; }
|
|
380
|
+
throw cliError("E_AMBIGUOUS_SAVE", "저장 요청의 결과를 확정할 수 없습니다. 같은 plan을 재적용하지 말고 현재 원본을 확인해 주세요.", {
|
|
381
|
+
requestErrorCode: saveError.code,
|
|
382
|
+
...(observed ? { observed } : {})
|
|
383
|
+
}, saveError);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let after;
|
|
387
|
+
try { after = await loadValidated(gateway, plan.project.id, plan.versionNumber); }
|
|
388
|
+
catch (readError) {
|
|
389
|
+
throw cliError("E_AMBIGUOUS_SAVE", "저장 후 원본을 다시 읽을 수 없습니다. 같은 plan을 재적용하지 마세요.", null, readError);
|
|
390
|
+
}
|
|
391
|
+
const savedInstance = verifyReadBack(plan, after);
|
|
392
|
+
const row = rpcRow(response);
|
|
393
|
+
const responseValid = Number(row?.saved_revision) === savedInstance.revision
|
|
394
|
+
&& Number(row?.component_set_revision) === Number(after.componentSet.revision)
|
|
395
|
+
&& typeof row?.checkpoint_id === "string"
|
|
396
|
+
&& Number.isSafeInteger(Number(row?.checkpoint_number));
|
|
397
|
+
if (!responseValid) throw cliError("E_AMBIGUOUS_SAVE", "RPC 저장 응답과 authoritative read-back이 일치하지 않습니다. 같은 plan을 재적용하지 마세요.");
|
|
398
|
+
const checkpoint = await verifyAutomaticCheckpoint(gateway, plan, response, after);
|
|
399
|
+
return Object.freeze({
|
|
400
|
+
...receiptBase(plan, actorEmail, options),
|
|
401
|
+
target: clone(plan.target),
|
|
402
|
+
beforeHash: plan.beforeHash,
|
|
403
|
+
afterHash: plan.afterHash,
|
|
404
|
+
savedRevision: savedInstance.revision,
|
|
405
|
+
componentSetRevision: Number(after.componentSet.revision),
|
|
406
|
+
checkpoint: {
|
|
407
|
+
id: checkpoint.id,
|
|
408
|
+
number: checkpoint.number,
|
|
409
|
+
message: checkpoint.message
|
|
410
|
+
},
|
|
411
|
+
recoveredFromAmbiguousResponse: false
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function checkpointAfterOperation(gateway, plan) {
|
|
416
|
+
let row = null;
|
|
417
|
+
try { row = await gateway.getCheckpointByOperation(plan.project.id, plan.versionNumber, plan.operationId); }
|
|
418
|
+
catch (error) { throw cliError("E_AMBIGUOUS_SAVE", "체크포인트 적용 후 이력을 다시 읽을 수 없습니다. 같은 plan을 재적용하지 마세요.", null, error); }
|
|
419
|
+
if (!row) throw cliError("E_AMBIGUOUS_SAVE", "체크포인트 적용 후 operation ID에 해당하는 이력을 찾을 수 없습니다. 같은 plan을 재적용하지 마세요.");
|
|
420
|
+
return normalizeCheckpoint(row, { projectId: plan.project.id, versionNumber: plan.versionNumber });
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function applyCheckpointPlan(gateway, plan, actorEmail, options = {}) {
|
|
424
|
+
const before = await loadValidated(gateway, plan.project.id, plan.versionNumber);
|
|
425
|
+
let targetRow = null;
|
|
426
|
+
if (plan.operation === "checkpoint.restore") {
|
|
427
|
+
targetRow = await gateway.getCheckpoint(plan.project.id, plan.versionNumber, plan.target.checkpointId);
|
|
428
|
+
}
|
|
429
|
+
const target = validateCheckpointPlanForApply(plan, before, actorEmail, targetRow);
|
|
430
|
+
const targetSnapshot = target
|
|
431
|
+
? await gateway.getCheckpointSnapshot(plan.project.id, plan.versionNumber, target.id, { includeData: true })
|
|
432
|
+
: null;
|
|
433
|
+
let response = null;
|
|
434
|
+
let requestError = null;
|
|
435
|
+
try {
|
|
436
|
+
response = plan.operation === "checkpoint.create"
|
|
437
|
+
? await gateway.createCheckpoint({
|
|
438
|
+
projectId: plan.project.id,
|
|
439
|
+
versionNumber: plan.versionNumber,
|
|
440
|
+
expectedSetRevision: plan.readiness.componentSetRevision,
|
|
441
|
+
expectedComponentChecksum: plan.readiness.componentChecksum,
|
|
442
|
+
message: plan.checkpointMessage,
|
|
443
|
+
operationId: plan.operationId,
|
|
444
|
+
actorEmail
|
|
445
|
+
})
|
|
446
|
+
: await gateway.restoreCheckpoint({
|
|
447
|
+
projectId: plan.project.id,
|
|
448
|
+
versionNumber: plan.versionNumber,
|
|
449
|
+
checkpointId: target.id,
|
|
450
|
+
expectedSetRevision: plan.readiness.componentSetRevision,
|
|
451
|
+
expectedComponentChecksum: plan.readiness.componentChecksum,
|
|
452
|
+
message: plan.checkpointMessage,
|
|
453
|
+
operationId: plan.operationId,
|
|
454
|
+
actorEmail
|
|
455
|
+
});
|
|
456
|
+
} catch (error) {
|
|
457
|
+
const normalized = normalizeError(error);
|
|
458
|
+
if (["E_CONFLICT", "E_AUTHORIZATION", "E_VALIDATION", "E_NO_CHANGES", "E_CODE_CHANGE_REQUIRED", "E_MIGRATION_REQUIRED"].includes(normalized.code)) throw normalized;
|
|
459
|
+
requestError = normalized;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
let checkpoint;
|
|
463
|
+
try { checkpoint = await checkpointAfterOperation(gateway, plan); }
|
|
464
|
+
catch (error) {
|
|
465
|
+
if (requestError) {
|
|
466
|
+
throw cliError("E_AMBIGUOUS_SAVE", "체크포인트 요청의 결과를 확정할 수 없습니다. 같은 plan을 재적용하지 말고 기록을 확인해 주세요.", {
|
|
467
|
+
requestErrorCode: requestError.code
|
|
468
|
+
}, requestError);
|
|
469
|
+
}
|
|
470
|
+
throw error;
|
|
471
|
+
}
|
|
472
|
+
const row = rpcRow(response);
|
|
473
|
+
if (checkpoint.message !== plan.checkpointMessage
|
|
474
|
+
|| checkpoint.operationId !== plan.operationId
|
|
475
|
+
|| (response && (String(row?.checkpoint_id || "") !== checkpoint.id
|
|
476
|
+
|| Number(row?.checkpoint_number) !== checkpoint.number))) {
|
|
477
|
+
throw cliError("E_AMBIGUOUS_SAVE", "체크포인트 RPC 응답과 authoritative 이력이 일치하지 않습니다. 같은 plan을 재적용하지 마세요.");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const after = await loadValidated(gateway, plan.project.id, plan.versionNumber);
|
|
481
|
+
if (String(after.componentSet.component_checksum || "") !== String(checkpoint.componentChecksum || "")
|
|
482
|
+
|| Number(after.componentSet.revision) !== Number(checkpoint.componentSetRevision)) {
|
|
483
|
+
throw cliError("E_AMBIGUOUS_SAVE", "체크포인트와 현재 Latest Component Set이 일치하지 않습니다. 같은 plan을 재적용하지 마세요.");
|
|
484
|
+
}
|
|
485
|
+
if (plan.operation === "checkpoint.create") {
|
|
486
|
+
if (checkpoint.source !== "manual"
|
|
487
|
+
|| Number(after.componentSet.revision) !== Number(before.componentSet.revision)) {
|
|
488
|
+
throw cliError("E_AMBIGUOUS_SAVE", "수동 체크포인트가 Latest를 변경하지 않았는지 확인할 수 없습니다.");
|
|
489
|
+
}
|
|
490
|
+
} else {
|
|
491
|
+
if (checkpoint.source !== "restore" || checkpoint.restoredFromId !== target.id) {
|
|
492
|
+
throw cliError("E_AMBIGUOUS_SAVE", "복원 체크포인트가 목표 이력을 가리키지 않습니다.");
|
|
493
|
+
}
|
|
494
|
+
const latestById = new Map(after.componentRows.map(item => [String(item.instance_id), item]));
|
|
495
|
+
const mismatch = targetSnapshot.entries.find(entry => {
|
|
496
|
+
const latest = latestById.get(String(entry.instance_id));
|
|
497
|
+
return !latest || !equalJson(latest.data, entry.data);
|
|
498
|
+
});
|
|
499
|
+
if (mismatch || latestById.size !== targetSnapshot.entries.length) {
|
|
500
|
+
throw cliError("E_AMBIGUOUS_SAVE", "복원 후 Latest 원고가 목표 체크포인트 payload와 일치하지 않습니다.", {
|
|
501
|
+
...(mismatch ? { instanceId: String(mismatch.instance_id) } : {})
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
let guardCheckpoint = null;
|
|
507
|
+
if (row?.guard_checkpoint_id) {
|
|
508
|
+
const guardRow = await gateway.getCheckpoint(plan.project.id, plan.versionNumber, String(row.guard_checkpoint_id));
|
|
509
|
+
guardCheckpoint = normalizeCheckpoint(guardRow, { projectId: plan.project.id, versionNumber: plan.versionNumber });
|
|
510
|
+
} else if (target && checkpoint.parentId) {
|
|
511
|
+
const parentRow = await gateway.getCheckpoint(plan.project.id, plan.versionNumber, checkpoint.parentId);
|
|
512
|
+
const parent = normalizeCheckpoint(parentRow, { projectId: plan.project.id, versionNumber: plan.versionNumber });
|
|
513
|
+
if (parent.source === "restore_guard") guardCheckpoint = parent;
|
|
514
|
+
}
|
|
515
|
+
if (guardCheckpoint && guardCheckpoint.source !== "restore_guard") {
|
|
516
|
+
throw cliError("E_AMBIGUOUS_SAVE", "복원 전 안전 체크포인트를 확인할 수 없습니다.");
|
|
517
|
+
}
|
|
518
|
+
return Object.freeze({
|
|
519
|
+
...receiptBase(plan, actorEmail, options),
|
|
520
|
+
checkpoint,
|
|
521
|
+
...(target ? { restoredFrom: target } : {}),
|
|
522
|
+
...(guardCheckpoint ? { guardCheckpoint } : {}),
|
|
523
|
+
componentSetRevision: Number(after.componentSet.revision),
|
|
524
|
+
componentChecksum: String(after.componentSet.component_checksum),
|
|
525
|
+
recoveredFromAmbiguousResponse: Boolean(requestError)
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function comparePlannedFile(planFile, currentFile) {
|
|
530
|
+
for (const key of ["path", "name", "type", "extension", "size", "contentHash"]) {
|
|
531
|
+
if (String(planFile?.[key]) !== String(currentFile?.[key])) throw cliError("E_CONFLICT", "plan 생성 후 업로드 파일이 변경되었습니다. 새 plan을 만들어 주세요.", { field: key });
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function assetPathPrefix(validated) {
|
|
536
|
+
const namespace = String(validated.project.storage_namespace || "").trim();
|
|
537
|
+
if (!/^project-[0-9a-f]{32}-$/.test(namespace)) {
|
|
538
|
+
throw cliError("E_MIGRATION_REQUIRED", "작품의 불변 Storage namespace를 확인할 수 없습니다.");
|
|
539
|
+
}
|
|
540
|
+
return namespace;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function createAssetAdapter(gateway, plan, actorEmail, state) {
|
|
544
|
+
const prefix = assetPathPrefix(state.initial);
|
|
545
|
+
const extension = plan.file?.extension || "bin";
|
|
546
|
+
const folder = plan.file?.type === "application/json" ? `json/v${plan.versionNumber}` : `image/v${plan.versionNumber}`;
|
|
547
|
+
|
|
548
|
+
async function readSnapshot() {
|
|
549
|
+
const validated = await loadValidated(gateway, plan.project.id, plan.versionNumber);
|
|
550
|
+
const instance = componentContract.assetInstance(validated.instances);
|
|
551
|
+
return { validated, instance, manifest: componentContract.assetManifest(validated.instances) };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
return new AssetRepositoryModule.AssetRepository({
|
|
555
|
+
readManifest: async () => {
|
|
556
|
+
const fresh = await readSnapshot();
|
|
557
|
+
if (!state.commitAttempted) validateAssetPlanForApply(plan, fresh.validated, actorEmail);
|
|
558
|
+
return { manifest: fresh.manifest, revision: fresh.instance.revision };
|
|
559
|
+
},
|
|
560
|
+
saveManifest: async (manifest, snapshot) => {
|
|
561
|
+
state.commitAttempted = true;
|
|
562
|
+
const fresh = await readSnapshot();
|
|
563
|
+
if (fresh.instance.instanceId !== plan.target.instanceId || fresh.instance.revision !== Number(snapshot.revision)) {
|
|
564
|
+
throw cliError("E_CONFLICT", "에셋 manifest revision이 변경되었습니다.");
|
|
565
|
+
}
|
|
566
|
+
if (Number(fresh.validated.componentSet.revision) !== Number(plan.readiness.componentSetRevision)
|
|
567
|
+
|| String(fresh.validated.componentSet.component_checksum) !== String(plan.readiness.componentChecksum)) {
|
|
568
|
+
throw cliError("E_CONFLICT", "에셋 업로드 전에 작품 Component 상태가 변경되었습니다.");
|
|
569
|
+
}
|
|
570
|
+
const draft = componentContract.assetInstanceWithManifest(fresh.instance, manifest, Number(snapshot.revision));
|
|
571
|
+
const syntheticPlan = {
|
|
572
|
+
...plan,
|
|
573
|
+
operation: "component.patch",
|
|
574
|
+
target: { ...plan.target, expectedRevision: fresh.instance.revision },
|
|
575
|
+
readiness: {
|
|
576
|
+
componentSetRevision: Number(fresh.validated.componentSet.revision),
|
|
577
|
+
componentChecksum: String(fresh.validated.componentSet.component_checksum)
|
|
578
|
+
},
|
|
579
|
+
beforeHash: sha256(fresh.instance.data),
|
|
580
|
+
afterHash: sha256(draft.data),
|
|
581
|
+
beforeData: clone(fresh.instance.data),
|
|
582
|
+
afterData: clone(draft.data)
|
|
583
|
+
};
|
|
584
|
+
const payload = componentContract.instancePayload(draft);
|
|
585
|
+
state.lastAttempt = {
|
|
586
|
+
expectedRevision: fresh.instance.revision,
|
|
587
|
+
componentSetRevision: Number(fresh.validated.componentSet.revision),
|
|
588
|
+
beforeHash: syntheticPlan.beforeHash,
|
|
589
|
+
afterHash: syntheticPlan.afterHash
|
|
590
|
+
};
|
|
591
|
+
let response;
|
|
592
|
+
try {
|
|
593
|
+
response = await gateway.saveInstance({
|
|
594
|
+
projectId: fresh.validated.project.id,
|
|
595
|
+
versionNumber: fresh.validated.versionNumber,
|
|
596
|
+
payload,
|
|
597
|
+
expectedRevision: fresh.instance.revision,
|
|
598
|
+
actorEmail,
|
|
599
|
+
checkpointMessage: plan.checkpointMessage
|
|
600
|
+
});
|
|
601
|
+
} catch (error) {
|
|
602
|
+
state.saveError = normalizeError(error);
|
|
603
|
+
throw state.saveError;
|
|
604
|
+
}
|
|
605
|
+
const after = await readSnapshot();
|
|
606
|
+
if (after.instance.revision !== fresh.instance.revision + 1
|
|
607
|
+
|| Number(after.validated.componentSet.revision) <= Number(fresh.validated.componentSet.revision)
|
|
608
|
+
|| !equalJson(after.manifest, manifest)) {
|
|
609
|
+
throw cliError("E_AMBIGUOUS_SAVE", "에셋 pointer 저장 결과를 검증할 수 없습니다.");
|
|
610
|
+
}
|
|
611
|
+
const row = rpcRow(response);
|
|
612
|
+
const responseValid = Number(row?.saved_revision) === after.instance.revision
|
|
613
|
+
&& Number(row?.component_set_revision) === Number(after.validated.componentSet.revision)
|
|
614
|
+
&& Number(row?.component_set_revision) > Number(fresh.validated.componentSet.revision)
|
|
615
|
+
&& typeof row?.checkpoint_id === "string"
|
|
616
|
+
&& Number.isSafeInteger(Number(row?.checkpoint_number));
|
|
617
|
+
if (!responseValid) {
|
|
618
|
+
state.invalidRpcResponse = true;
|
|
619
|
+
throw cliError("E_AMBIGUOUS_SAVE", "에셋 RPC 응답과 authoritative read-back이 일치하지 않습니다.");
|
|
620
|
+
}
|
|
621
|
+
const checkpoint = await verifyAutomaticCheckpoint(gateway, plan, response, after.validated);
|
|
622
|
+
state.lastSave = {
|
|
623
|
+
savedRevision: after.instance.revision,
|
|
624
|
+
componentSetRevision: Number(after.validated.componentSet.revision),
|
|
625
|
+
recoveredFromAmbiguousResponse: false,
|
|
626
|
+
beforeHash: syntheticPlan.beforeHash,
|
|
627
|
+
afterHash: syntheticPlan.afterHash,
|
|
628
|
+
checkpoint: {
|
|
629
|
+
id: checkpoint.id,
|
|
630
|
+
number: checkpoint.number,
|
|
631
|
+
message: checkpoint.message
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
return { manifest: after.manifest, revision: after.instance.revision };
|
|
635
|
+
},
|
|
636
|
+
uploadBlob: (objectPath, body, uploadOptions) => gateway.uploadObject(objectPath, body, uploadOptions),
|
|
637
|
+
removeBlobs: paths => gateway.removeObjects(paths),
|
|
638
|
+
buildImmutablePath: ({ assetId, contentHash, operationId }) => {
|
|
639
|
+
const safeId = StoragePath.safePathSegment(String(assetId).replaceAll(":", "-"), "asset");
|
|
640
|
+
const suffix = StoragePath.safePathSegment(String(contentHash).slice(0, 20), "hash");
|
|
641
|
+
return StoragePath.joinStoragePath(prefix, folder, `${safeId}-${suffix}-${operationId}.${extension}`);
|
|
642
|
+
},
|
|
643
|
+
operationId: () => String(plan.operationId),
|
|
644
|
+
onOrphan: orphan => state.orphans.push({ path: orphan.path, reason: orphan.reason }),
|
|
645
|
+
now: optionsNow(state)
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function optionsNow(state) {
|
|
650
|
+
return () => Number(state.now());
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async function applyAssetPlan(gateway, plan, actorEmail, options = {}) {
|
|
654
|
+
const initial = await loadValidated(gateway, plan.project.id, plan.versionNumber);
|
|
655
|
+
validateAssetPlanForApply(plan, initial, actorEmail);
|
|
656
|
+
const assetId = assertAssetId(plan.target.assetId);
|
|
657
|
+
let file = null;
|
|
658
|
+
if (plan.operation === "asset.upload") {
|
|
659
|
+
const inspectedFile = readAssetFile(plan.file?.path, assetId, { fs: options.fs });
|
|
660
|
+
const inspected = inspectedFile.metadata;
|
|
661
|
+
comparePlannedFile(plan.file, inspected);
|
|
662
|
+
file = new File([inspectedFile.buffer], inspected.name, { type: inspected.type });
|
|
663
|
+
}
|
|
664
|
+
const state = { initial, lastAttempt: null, lastSave: null, saveError: null, invalidRpcResponse: false, orphans: [], commitAttempted: false, now: options.now || Date.now };
|
|
665
|
+
const repository = createAssetAdapter(gateway, plan, actorEmail, state);
|
|
666
|
+
let result;
|
|
667
|
+
try {
|
|
668
|
+
result = plan.operation === "asset.upload"
|
|
669
|
+
? await repository.upload(assetId, file, { name: plan.file.name, type: plan.file.type, metadata: { size: Number(plan.file.size) } })
|
|
670
|
+
: await repository.remove(assetId, { tombstone: true });
|
|
671
|
+
} catch (error) {
|
|
672
|
+
const normalized = normalizeError(error);
|
|
673
|
+
if (!state.orphans.length) throw normalized;
|
|
674
|
+
throw cliError(normalized.code, normalized.message, {
|
|
675
|
+
...(normalized.details || {}),
|
|
676
|
+
orphans: clone(state.orphans)
|
|
677
|
+
}, normalized);
|
|
678
|
+
}
|
|
679
|
+
const after = await loadValidated(gateway, plan.project.id, plan.versionNumber);
|
|
680
|
+
const afterInstance = componentContract.assetInstance(after.instances);
|
|
681
|
+
const afterManifest = componentContract.assetManifest(after.instances);
|
|
682
|
+
const entry = afterManifest.assets?.[assetId] || null;
|
|
683
|
+
if (plan.operation === "asset.upload") {
|
|
684
|
+
if (!entry || entry.deleted || !equalJson(entry, result?.entry)
|
|
685
|
+
|| entry.contentHash !== plan.file.contentHash
|
|
686
|
+
|| (result?.unchanged !== true && (entry.operationId !== plan.operationId || entry.path !== result?.path))) {
|
|
687
|
+
throw cliError("E_AMBIGUOUS_SAVE", "업로드한 에셋 pointer를 원본에서 정확히 확인할 수 없습니다.");
|
|
688
|
+
}
|
|
689
|
+
} else if (!entry || entry.deleted !== true || entry.operationId !== plan.operationId) {
|
|
690
|
+
throw cliError("E_AMBIGUOUS_SAVE", "에셋 tombstone을 원본에서 확인할 수 없습니다.");
|
|
691
|
+
}
|
|
692
|
+
const unchanged = result?.unchanged === true;
|
|
693
|
+
if (state.invalidRpcResponse) throw cliError("E_AMBIGUOUS_SAVE", "에셋 저장 RPC 계약을 확인할 수 없어 적용 성공으로 기록하지 않습니다.");
|
|
694
|
+
if (!unchanged && !state.lastSave) {
|
|
695
|
+
const recovered = result?.recoveredCommit === true
|
|
696
|
+
&& afterInstance.revision === Number(plan.target.expectedRevision) + 1
|
|
697
|
+
&& Number(after.componentSet.revision) > Number(plan.readiness.componentSetRevision);
|
|
698
|
+
if (!recovered) throw cliError("E_AMBIGUOUS_SAVE", "에셋 pointer 저장 revision을 이 작업에 귀속할 수 없습니다.");
|
|
699
|
+
}
|
|
700
|
+
if (state.lastSave && (afterInstance.revision < state.lastSave.savedRevision
|
|
701
|
+
|| Number(after.componentSet.revision) < state.lastSave.componentSetRevision)) {
|
|
702
|
+
throw cliError("E_AMBIGUOUS_SAVE", "에셋 pointer 저장 revision read-back이 일치하지 않습니다.");
|
|
703
|
+
}
|
|
704
|
+
const savedRevision = unchanged ? Number(plan.target.expectedRevision) : (state.lastSave?.savedRevision || afterInstance.revision);
|
|
705
|
+
const componentSetRevision = unchanged ? Number(plan.readiness.componentSetRevision) : (state.lastSave?.componentSetRevision || Number(after.componentSet.revision));
|
|
706
|
+
return Object.freeze({
|
|
707
|
+
...receiptBase(plan, actorEmail, options),
|
|
708
|
+
target: clone(plan.target),
|
|
709
|
+
unchanged,
|
|
710
|
+
asset: clone(entry),
|
|
711
|
+
previousEntry: clone(plan.previousEntry),
|
|
712
|
+
beforeManifestHash: plan.beforeManifestHash,
|
|
713
|
+
afterManifestHash: sha256(result?.manifest || afterManifest),
|
|
714
|
+
savedRevision,
|
|
715
|
+
componentSetRevision,
|
|
716
|
+
...(!unchanged && state.lastSave?.checkpoint?.id ? { checkpoint: clone(state.lastSave.checkpoint) } : {}),
|
|
717
|
+
recoveredFromAmbiguousResponse: Boolean(result?.recoveredCommit || state.lastSave?.recoveredFromAmbiguousResponse),
|
|
718
|
+
orphans: state.orphans
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
module.exports = Object.freeze({ applyProjectCreationPlan, applyProjectImportPlan, applyProjectStatusPlan, applyCatalogDemoPlan, applyComponentPlan, applyAssetPlan, applyCheckpointPlan, comparePlannedFile, assetPathPrefix });
|