@revoengine/cli 1.0.8 → 1.0.10
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 +181 -4
- package/dist/src/cli.js +9 -1
- package/dist/src/client.d.ts +10 -0
- package/dist/src/client.js +157 -0
- package/dist/src/commands/component.js +607 -298
- package/dist/src/commands/env.d.ts +2 -0
- package/dist/src/commands/env.js +360 -0
- package/dist/src/commands/index.d.ts +2 -0
- package/dist/src/commands/index.js +2 -0
- package/dist/src/commands/metadata.d.ts +2 -0
- package/dist/src/commands/metadata.js +137 -0
- package/dist/src/commands/project.js +75 -1
- package/dist/src/component-lock.d.ts +28 -3
- package/dist/src/component-lock.js +92 -9
- package/dist/src/config.d.ts +12 -0
- package/dist/src/config.js +89 -4
- package/dist/src/env-sync.d.ts +86 -0
- package/dist/src/env-sync.js +331 -0
- package/dist/src/metadata-backfill.d.ts +46 -0
- package/dist/src/metadata-backfill.js +125 -0
- package/dist/src/project.d.ts +15 -9
- package/dist/src/project.js +89 -0
- package/dist/src/resource-metadata.d.ts +24 -0
- package/dist/src/resource-metadata.js +129 -0
- package/dist/src/tracked-resources.d.ts +7 -0
- package/dist/src/tracked-resources.js +37 -0
- package/dist/src/types.d.ts +3 -0
- package/dist/src/ui.js +12 -3
- package/package.json +2 -2
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { hashPortableComponentSource, normalizePortableComponentSource, sanitizeComponentSource, } from "./component-lock.js";
|
|
2
|
+
import { buildStableKey as buildComponentStableKey, DEFAULT_STABLE_KEY_NAME, NULL_CATEGORY_KEY, sanitizeUserMetadata, } from "./resource-metadata.js";
|
|
3
|
+
export function normalizeKeyPart(value) {
|
|
4
|
+
return (value ?? '').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
5
|
+
}
|
|
6
|
+
export function buildStableKey(component, stableKeyName = DEFAULT_STABLE_KEY_NAME) {
|
|
7
|
+
return buildComponentStableKey(component, stableKeyName);
|
|
8
|
+
}
|
|
9
|
+
function isRecord(value) {
|
|
10
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
function unwrapComponent(value) {
|
|
13
|
+
if (isRecord(value) && isRecord(value.data)) {
|
|
14
|
+
return value.data;
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function componentIdOf(component) {
|
|
19
|
+
return String(component.componentId || component.id || '');
|
|
20
|
+
}
|
|
21
|
+
export async function captureEnvSnapshot(client, envName, options = {}) {
|
|
22
|
+
const { components: summaries } = await client.listAllComponents();
|
|
23
|
+
const byStableKey = new Map();
|
|
24
|
+
for (const summary of summaries) {
|
|
25
|
+
const componentId = componentIdOf(summary);
|
|
26
|
+
if (!componentId) {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const component = unwrapComponent(await client.getComponent(componentId));
|
|
30
|
+
const stableKey = buildStableKey(component, options.stableKeyName);
|
|
31
|
+
const entry = {
|
|
32
|
+
stableKey,
|
|
33
|
+
componentId,
|
|
34
|
+
component,
|
|
35
|
+
portable: normalizePortableComponentSource(component),
|
|
36
|
+
portableHash: hashPortableComponentSource(component),
|
|
37
|
+
};
|
|
38
|
+
const bucket = byStableKey.get(stableKey);
|
|
39
|
+
if (bucket) {
|
|
40
|
+
bucket.push(entry);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
byStableKey.set(stableKey, [entry]);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const components = new Map();
|
|
47
|
+
const collisions = [];
|
|
48
|
+
for (const [stableKey, entries] of byStableKey) {
|
|
49
|
+
if (entries.length === 1) {
|
|
50
|
+
components.set(stableKey, entries[0]);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
collisions.push({
|
|
54
|
+
stableKey,
|
|
55
|
+
components: entries.map((entry) => ({
|
|
56
|
+
componentId: entry.componentId,
|
|
57
|
+
name: String(entry.component.name || ''),
|
|
58
|
+
})),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
collisions.sort((left, right) => left.stableKey.localeCompare(right.stableKey));
|
|
62
|
+
return {
|
|
63
|
+
ref: {
|
|
64
|
+
env: envName,
|
|
65
|
+
baseUrl: client.baseUrl,
|
|
66
|
+
instance: client.instance,
|
|
67
|
+
},
|
|
68
|
+
components,
|
|
69
|
+
collisions,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const PORTABLE_METADATA_FIELDS = ['name', 'category', 'desc', 'active', 'type', 'async'];
|
|
73
|
+
function diffMetadataFields(source, target) {
|
|
74
|
+
return PORTABLE_METADATA_FIELDS.filter((field) => source[field] !== target[field]);
|
|
75
|
+
}
|
|
76
|
+
function diffElementKeys(source, target) {
|
|
77
|
+
const sourceByKey = new Map(source.elements.map((element) => [element.key, element]));
|
|
78
|
+
const targetByKey = new Map(target.elements.map((element) => [element.key, element]));
|
|
79
|
+
const addedElementKeys = [];
|
|
80
|
+
const changedElementKeys = [];
|
|
81
|
+
for (const [key, element] of sourceByKey) {
|
|
82
|
+
const other = targetByKey.get(key);
|
|
83
|
+
if (!other) {
|
|
84
|
+
addedElementKeys.push(key);
|
|
85
|
+
}
|
|
86
|
+
else if (JSON.stringify(element) !== JSON.stringify(other)) {
|
|
87
|
+
changedElementKeys.push(key);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const removedElementKeys = [...targetByKey.keys()].filter((key) => !sourceByKey.has(key));
|
|
91
|
+
return { addedElementKeys, removedElementKeys, changedElementKeys };
|
|
92
|
+
}
|
|
93
|
+
function buildScopeFilter(scope) {
|
|
94
|
+
const keys = scope?.keys?.map((key) => normalizeKeyPart(key)).filter(Boolean);
|
|
95
|
+
const categories = scope?.categories?.map((category) => normalizeKeyPart(category) || NULL_CATEGORY_KEY);
|
|
96
|
+
if ((!keys || keys.length === 0) && (!categories || categories.length === 0)) {
|
|
97
|
+
return () => true;
|
|
98
|
+
}
|
|
99
|
+
return (stableKey) => {
|
|
100
|
+
if (keys && keys.length > 0 && keys.includes(stableKey)) {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
if (categories && categories.length > 0) {
|
|
104
|
+
const categoryPart = stableKey.slice(0, stableKey.lastIndexOf('/'));
|
|
105
|
+
return categories.includes(categoryPart);
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function emptyCounts() {
|
|
111
|
+
return {
|
|
112
|
+
create: 0,
|
|
113
|
+
update: 0,
|
|
114
|
+
delete: 0,
|
|
115
|
+
orphan: 0,
|
|
116
|
+
noop: 0,
|
|
117
|
+
collision: 0,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
export function buildEnvSyncPlan(source, target, options) {
|
|
121
|
+
const inScope = buildScopeFilter(options.scope);
|
|
122
|
+
const items = [];
|
|
123
|
+
const collisionKeys = new Set();
|
|
124
|
+
for (const [envLabel, snapshot] of [['source', source], ['target', target]]) {
|
|
125
|
+
for (const collision of snapshot.collisions) {
|
|
126
|
+
if (!inScope(collision.stableKey) || collisionKeys.has(`${envLabel}:${collision.stableKey}`)) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
collisionKeys.add(`${envLabel}:${collision.stableKey}`);
|
|
130
|
+
items.push({
|
|
131
|
+
resourceType: 'component',
|
|
132
|
+
stableKey: collision.stableKey,
|
|
133
|
+
action: 'collision',
|
|
134
|
+
name: collision.components[0]?.name || '',
|
|
135
|
+
category: null,
|
|
136
|
+
sourceComponentId: null,
|
|
137
|
+
targetComponentId: null,
|
|
138
|
+
sourceHash: null,
|
|
139
|
+
targetHash: null,
|
|
140
|
+
reason: `${collision.components.length} components share this key in ${envLabel} (${snapshot.ref.env}): ${collision.components.map((component) => component.componentId).join(', ')}`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const blockedKeys = new Set([...source.collisions, ...target.collisions].map((collision) => collision.stableKey));
|
|
145
|
+
const allKeys = new Set([...source.components.keys(), ...target.components.keys()]);
|
|
146
|
+
for (const stableKey of allKeys) {
|
|
147
|
+
if (!inScope(stableKey) || blockedKeys.has(stableKey)) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const sourceEntry = source.components.get(stableKey);
|
|
151
|
+
const targetEntry = target.components.get(stableKey);
|
|
152
|
+
const reference = (sourceEntry || targetEntry);
|
|
153
|
+
const base = {
|
|
154
|
+
resourceType: 'component',
|
|
155
|
+
stableKey,
|
|
156
|
+
name: String(reference.component.name || ''),
|
|
157
|
+
category: reference.portable.category,
|
|
158
|
+
sourceComponentId: sourceEntry?.componentId ?? null,
|
|
159
|
+
targetComponentId: targetEntry?.componentId ?? null,
|
|
160
|
+
sourceHash: sourceEntry?.portableHash ?? null,
|
|
161
|
+
targetHash: targetEntry?.portableHash ?? null,
|
|
162
|
+
};
|
|
163
|
+
if (sourceEntry && !targetEntry) {
|
|
164
|
+
items.push({ ...base, action: 'create', reason: 'missing in target' });
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (!sourceEntry && targetEntry) {
|
|
168
|
+
items.push(options.prune
|
|
169
|
+
? { ...base, action: 'delete', reason: 'missing in source' }
|
|
170
|
+
: { ...base, action: 'orphan', reason: 'missing in source; use --prune to delete' });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (sourceEntry.portableHash === targetEntry.portableHash) {
|
|
174
|
+
items.push({ ...base, action: 'noop', reason: 'up to date' });
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const changedFields = diffMetadataFields(sourceEntry.portable, targetEntry.portable);
|
|
178
|
+
const elementDiff = diffElementKeys(sourceEntry.portable, targetEntry.portable);
|
|
179
|
+
items.push({
|
|
180
|
+
...base,
|
|
181
|
+
action: 'update',
|
|
182
|
+
changedFields,
|
|
183
|
+
...elementDiff,
|
|
184
|
+
reason: 'source and target content differ',
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
items.sort((left, right) => left.stableKey.localeCompare(right.stableKey)
|
|
188
|
+
|| left.action.localeCompare(right.action));
|
|
189
|
+
const counts = emptyCounts();
|
|
190
|
+
for (const item of items) {
|
|
191
|
+
counts[item.action] += 1;
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
schemaVersion: 1,
|
|
195
|
+
generatedAt: new Date().toISOString(),
|
|
196
|
+
from: source.ref,
|
|
197
|
+
to: target.ref,
|
|
198
|
+
prune: options.prune,
|
|
199
|
+
...(options.scope && (options.scope.keys?.length || options.scope.categories?.length)
|
|
200
|
+
? { scope: options.scope }
|
|
201
|
+
: {}),
|
|
202
|
+
counts,
|
|
203
|
+
items,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
// Mirrors the push payload shape, but componentElementIds never cross
|
|
207
|
+
// environments: they are only set when they belong to the target component.
|
|
208
|
+
function buildPortableElementPayload(component, targetElementsByKey) {
|
|
209
|
+
return [...(component.elements || [])].map((element) => {
|
|
210
|
+
const targetElementId = targetElementsByKey?.get(element.key)?.componentElementId;
|
|
211
|
+
return {
|
|
212
|
+
...(typeof targetElementId === 'string' && targetElementId
|
|
213
|
+
? { componentElementId: targetElementId }
|
|
214
|
+
: {}),
|
|
215
|
+
key: element.key,
|
|
216
|
+
desc: element.desc,
|
|
217
|
+
details: element.details || '',
|
|
218
|
+
hidden: Boolean(element.hidden),
|
|
219
|
+
order: element.order,
|
|
220
|
+
};
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
function compilerFromType(type, compiler) {
|
|
224
|
+
if (compiler) {
|
|
225
|
+
return compiler;
|
|
226
|
+
}
|
|
227
|
+
return (type || '').toLowerCase().includes('ts') ? 'typescript' : 'javascript';
|
|
228
|
+
}
|
|
229
|
+
function buildCreatePayload(component) {
|
|
230
|
+
const sanitized = sanitizeComponentSource(component);
|
|
231
|
+
return {
|
|
232
|
+
name: sanitized.name || '',
|
|
233
|
+
category: sanitized.category ?? null,
|
|
234
|
+
desc: sanitized.desc ?? null,
|
|
235
|
+
type: sanitized.type || '',
|
|
236
|
+
compiler: compilerFromType(sanitized.type, sanitized.compiler),
|
|
237
|
+
active: sanitized.active ?? true,
|
|
238
|
+
async: Boolean(sanitized.async),
|
|
239
|
+
metadata: sanitizeUserMetadata(component.metadata),
|
|
240
|
+
elements: buildPortableElementPayload(component),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
function buildMetadataPayload(source, target, changedFields) {
|
|
244
|
+
if (!Number.isInteger(target.version)) {
|
|
245
|
+
throw new Error('target component version is required to update component metadata');
|
|
246
|
+
}
|
|
247
|
+
const payload = { version: target.version };
|
|
248
|
+
for (const field of PORTABLE_METADATA_FIELDS) {
|
|
249
|
+
if (changedFields.includes(field)) {
|
|
250
|
+
payload[field] = source[field];
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return payload;
|
|
254
|
+
}
|
|
255
|
+
async function verifyAppliedComponent(targetClient, componentId, expectedHash) {
|
|
256
|
+
const component = unwrapComponent(await targetClient.getComponent(componentId));
|
|
257
|
+
return hashPortableComponentSource(component) === expectedHash;
|
|
258
|
+
}
|
|
259
|
+
export async function applyEnvSyncPlan(input) {
|
|
260
|
+
const { targetClient, plan, source, target, stableKeyName, onItem } = input;
|
|
261
|
+
const collisions = plan.items.filter((item) => item.action === 'collision');
|
|
262
|
+
if (collisions.length > 0) {
|
|
263
|
+
throw new Error(`Cannot apply: ${collisions.length} stable-key collision(s) in scope. Resolve duplicates or narrow the scope with --key/--category.`);
|
|
264
|
+
}
|
|
265
|
+
const ordered = [
|
|
266
|
+
...plan.items.filter((item) => item.action === 'create'),
|
|
267
|
+
...plan.items.filter((item) => item.action === 'update'),
|
|
268
|
+
...plan.items.filter((item) => item.action === 'delete'),
|
|
269
|
+
];
|
|
270
|
+
const results = [];
|
|
271
|
+
for (const item of ordered) {
|
|
272
|
+
const result = await applyPlanItem(targetClient, item, source, target, stableKeyName);
|
|
273
|
+
results.push(result);
|
|
274
|
+
onItem?.(result);
|
|
275
|
+
}
|
|
276
|
+
return results;
|
|
277
|
+
}
|
|
278
|
+
async function applyPlanItem(targetClient, item, source, target, stableKeyName = DEFAULT_STABLE_KEY_NAME) {
|
|
279
|
+
try {
|
|
280
|
+
if (item.action === 'create') {
|
|
281
|
+
const sourceEntry = source.components.get(item.stableKey);
|
|
282
|
+
if (!sourceEntry) {
|
|
283
|
+
throw new Error('source component disappeared from the snapshot');
|
|
284
|
+
}
|
|
285
|
+
const created = unwrapComponent(await targetClient.createComponent(buildCreatePayload(sourceEntry.component)));
|
|
286
|
+
const newComponentId = componentIdOf(created);
|
|
287
|
+
if (!newComponentId) {
|
|
288
|
+
throw new Error('create response did not include a componentId');
|
|
289
|
+
}
|
|
290
|
+
// The save endpoint is the authoritative element write; re-sending the
|
|
291
|
+
// elements guards against create endpoints that ignore inline elements.
|
|
292
|
+
await targetClient.saveComponentElements(newComponentId, buildPortableElementPayload(sourceEntry.component));
|
|
293
|
+
const verified = await verifyAppliedComponent(targetClient, newComponentId, sourceEntry.portableHash);
|
|
294
|
+
return { stableKey: item.stableKey, action: item.action, status: 'applied', verified };
|
|
295
|
+
}
|
|
296
|
+
if (item.action === 'update') {
|
|
297
|
+
const sourceEntry = source.components.get(item.stableKey);
|
|
298
|
+
const targetEntry = target.components.get(item.stableKey);
|
|
299
|
+
if (!sourceEntry || !targetEntry) {
|
|
300
|
+
throw new Error('component disappeared from the snapshot');
|
|
301
|
+
}
|
|
302
|
+
if (item.changedFields && item.changedFields.length > 0) {
|
|
303
|
+
await targetClient.updateComponent(targetEntry.componentId, buildMetadataPayload(sourceEntry.portable, targetEntry.component, item.changedFields));
|
|
304
|
+
}
|
|
305
|
+
const hasElementChanges = Boolean(item.addedElementKeys?.length || item.removedElementKeys?.length || item.changedElementKeys?.length);
|
|
306
|
+
if (hasElementChanges) {
|
|
307
|
+
const targetElementsByKey = new Map((targetEntry.component.elements || []).map((element) => [element.key, element]));
|
|
308
|
+
await targetClient.saveComponentElements(targetEntry.componentId, buildPortableElementPayload(sourceEntry.component, targetElementsByKey));
|
|
309
|
+
}
|
|
310
|
+
const verified = await verifyAppliedComponent(targetClient, targetEntry.componentId, sourceEntry.portableHash);
|
|
311
|
+
return { stableKey: item.stableKey, action: item.action, status: 'applied', verified };
|
|
312
|
+
}
|
|
313
|
+
if (item.action === 'delete') {
|
|
314
|
+
const targetEntry = target.components.get(item.stableKey);
|
|
315
|
+
if (!targetEntry) {
|
|
316
|
+
throw new Error('target component disappeared from the snapshot');
|
|
317
|
+
}
|
|
318
|
+
await targetClient.deleteComponent(targetEntry.componentId);
|
|
319
|
+
return { stableKey: item.stableKey, action: item.action, status: 'applied' };
|
|
320
|
+
}
|
|
321
|
+
throw new Error(`unexpected plan action: ${item.action}`);
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
return {
|
|
325
|
+
stableKey: item.stableKey,
|
|
326
|
+
action: item.action,
|
|
327
|
+
status: 'failed',
|
|
328
|
+
error: error instanceof Error ? error.message : String(error),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { RevoClient } from './client.ts';
|
|
2
|
+
import { type TrackedResource } from './tracked-resources.ts';
|
|
3
|
+
import type { ComponentRecord } from './types.ts';
|
|
4
|
+
export type MetadataBackfillAction = 'update' | 'skip' | 'failed';
|
|
5
|
+
export type MetadataBackfillPlanItem = {
|
|
6
|
+
resourceType: 'component';
|
|
7
|
+
resourceId: string;
|
|
8
|
+
name: string;
|
|
9
|
+
category: string | null;
|
|
10
|
+
action: Exclude<MetadataBackfillAction, 'failed'>;
|
|
11
|
+
metadataField: 'metadata';
|
|
12
|
+
stableKeyName: string;
|
|
13
|
+
stableKey?: string;
|
|
14
|
+
reason: string;
|
|
15
|
+
};
|
|
16
|
+
export type MetadataBackfillPlan = {
|
|
17
|
+
schemaVersion: 1;
|
|
18
|
+
generatedAt: string;
|
|
19
|
+
env: string;
|
|
20
|
+
baseUrl: string;
|
|
21
|
+
instance: string;
|
|
22
|
+
stableKeyName: string;
|
|
23
|
+
trackedResources: TrackedResource[];
|
|
24
|
+
counts: Record<Exclude<MetadataBackfillAction, 'failed'>, number>;
|
|
25
|
+
items: MetadataBackfillPlanItem[];
|
|
26
|
+
};
|
|
27
|
+
export type MetadataBackfillResult = {
|
|
28
|
+
resourceType: 'component';
|
|
29
|
+
resourceId: string;
|
|
30
|
+
stableKey?: string;
|
|
31
|
+
action: MetadataBackfillAction;
|
|
32
|
+
error?: string;
|
|
33
|
+
};
|
|
34
|
+
export declare function generateStableKeySuffix(bytes?: number): string;
|
|
35
|
+
export declare function buildGeneratedStableKey(component: ComponentRecord): string;
|
|
36
|
+
export declare function buildMetadataBackfillPlan(input: {
|
|
37
|
+
client: RevoClient;
|
|
38
|
+
envName: string;
|
|
39
|
+
stableKeyName: string;
|
|
40
|
+
trackedResources: TrackedResource[];
|
|
41
|
+
force?: boolean;
|
|
42
|
+
}): Promise<MetadataBackfillPlan>;
|
|
43
|
+
export declare function applyMetadataBackfillPlan(input: {
|
|
44
|
+
client: RevoClient;
|
|
45
|
+
plan: MetadataBackfillPlan;
|
|
46
|
+
}): Promise<MetadataBackfillResult[]>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { buildStableKey, buildResourceIdentityStableKeyValue, readConfiguredResourceMetadataStableKey, sanitizeUserMetadata, } from "./resource-metadata.js";
|
|
3
|
+
import { shouldTrackResource } from "./tracked-resources.js";
|
|
4
|
+
function isRecord(value) {
|
|
5
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function unwrapComponent(value) {
|
|
8
|
+
if (isRecord(value) && isRecord(value.data)) {
|
|
9
|
+
return value.data;
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
function componentIdOf(component) {
|
|
14
|
+
return String(component.componentId || component.id || '');
|
|
15
|
+
}
|
|
16
|
+
function normalizeValue(value, fallback = null) {
|
|
17
|
+
return value ?? fallback;
|
|
18
|
+
}
|
|
19
|
+
export function generateStableKeySuffix(bytes = 4) {
|
|
20
|
+
return randomBytes(bytes).toString('hex');
|
|
21
|
+
}
|
|
22
|
+
export function buildGeneratedStableKey(component) {
|
|
23
|
+
return `${buildResourceIdentityStableKeyValue(component)}#${generateStableKeySuffix()}`;
|
|
24
|
+
}
|
|
25
|
+
function buildComponentMetadataUpdatePayload(component, stableKeyName, stableKey) {
|
|
26
|
+
if (!Number.isInteger(component.version)) {
|
|
27
|
+
throw new Error('component version is required to update metadata');
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
version: component.version,
|
|
31
|
+
metadata: {
|
|
32
|
+
...sanitizeUserMetadata(component.metadata),
|
|
33
|
+
[stableKeyName]: stableKey,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export async function buildMetadataBackfillPlan(input) {
|
|
38
|
+
const { client, envName, stableKeyName, trackedResources, force = false } = input;
|
|
39
|
+
const items = [];
|
|
40
|
+
if (shouldTrackResource(trackedResources, 'component')) {
|
|
41
|
+
const { components } = await client.listAllComponents();
|
|
42
|
+
for (const summary of components) {
|
|
43
|
+
const summaryId = componentIdOf(summary);
|
|
44
|
+
if (!summaryId) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const component = unwrapComponent(await client.getComponent(summaryId));
|
|
48
|
+
const resourceId = componentIdOf(component) || summaryId;
|
|
49
|
+
const existingStableKey = readConfiguredResourceMetadataStableKey(component, stableKeyName);
|
|
50
|
+
const stableKey = existingStableKey && !force
|
|
51
|
+
? undefined
|
|
52
|
+
: buildGeneratedStableKey(component);
|
|
53
|
+
items.push({
|
|
54
|
+
resourceType: 'component',
|
|
55
|
+
resourceId,
|
|
56
|
+
name: String(component.name || ''),
|
|
57
|
+
category: normalizeValue(component.category),
|
|
58
|
+
action: existingStableKey && !force ? 'skip' : 'update',
|
|
59
|
+
metadataField: 'metadata',
|
|
60
|
+
stableKeyName,
|
|
61
|
+
...(stableKey ? { stableKey } : {}),
|
|
62
|
+
reason: existingStableKey
|
|
63
|
+
? (force ? 'stable key will be regenerated' : 'stable key already exists')
|
|
64
|
+
: 'stable key missing',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
items.sort((left, right) => left.resourceType.localeCompare(right.resourceType)
|
|
69
|
+
|| buildStableKey({ name: left.name, category: left.category }).localeCompare(buildStableKey({ name: right.name, category: right.category }))
|
|
70
|
+
|| left.resourceId.localeCompare(right.resourceId));
|
|
71
|
+
const counts = {
|
|
72
|
+
update: items.filter((item) => item.action === 'update').length,
|
|
73
|
+
skip: items.filter((item) => item.action === 'skip').length,
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
schemaVersion: 1,
|
|
77
|
+
generatedAt: new Date().toISOString(),
|
|
78
|
+
env: envName,
|
|
79
|
+
baseUrl: client.baseUrl,
|
|
80
|
+
instance: client.instance,
|
|
81
|
+
stableKeyName,
|
|
82
|
+
trackedResources,
|
|
83
|
+
counts,
|
|
84
|
+
items,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export async function applyMetadataBackfillPlan(input) {
|
|
88
|
+
const { client, plan } = input;
|
|
89
|
+
const results = [];
|
|
90
|
+
for (const item of plan.items) {
|
|
91
|
+
if (item.action === 'skip') {
|
|
92
|
+
results.push({
|
|
93
|
+
resourceType: item.resourceType,
|
|
94
|
+
resourceId: item.resourceId,
|
|
95
|
+
action: 'skip',
|
|
96
|
+
});
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
if (item.resourceType === 'component') {
|
|
101
|
+
const component = unwrapComponent(await client.getComponent(item.resourceId));
|
|
102
|
+
const stableKey = item.stableKey || buildGeneratedStableKey(component);
|
|
103
|
+
await client.updateComponent(item.resourceId, buildComponentMetadataUpdatePayload(component, plan.stableKeyName, stableKey));
|
|
104
|
+
results.push({
|
|
105
|
+
resourceType: item.resourceType,
|
|
106
|
+
resourceId: item.resourceId,
|
|
107
|
+
stableKey,
|
|
108
|
+
action: 'update',
|
|
109
|
+
});
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`unsupported resource type: ${item.resourceType}`);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
results.push({
|
|
116
|
+
resourceType: item.resourceType,
|
|
117
|
+
resourceId: item.resourceId,
|
|
118
|
+
stableKey: item.stableKey,
|
|
119
|
+
action: 'failed',
|
|
120
|
+
error: error instanceof Error ? error.message : String(error),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return results;
|
|
125
|
+
}
|
package/dist/src/project.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ParsedArgs } from './types.ts';
|
|
2
|
+
import { type ComponentIdentityConfig, type ComponentIdentityMode } from './resource-metadata.ts';
|
|
3
|
+
import { type TrackedResource } from './tracked-resources.ts';
|
|
2
4
|
export declare const REVO_PROJECT_DIR = ".revoengine";
|
|
3
5
|
export declare const REVO_TYPES_DIR: string;
|
|
4
6
|
export declare const REVO_TYPES_FILE: string;
|
|
@@ -15,6 +17,9 @@ export type ProjectInvocation = {
|
|
|
15
17
|
action: 'init' | 'update';
|
|
16
18
|
targetArg?: string;
|
|
17
19
|
extraArgs: string[];
|
|
20
|
+
identityMode?: ComponentIdentityMode;
|
|
21
|
+
stableKeyName?: string;
|
|
22
|
+
trackedResources?: TrackedResource[];
|
|
18
23
|
};
|
|
19
24
|
export type EditorTypesBundle = {
|
|
20
25
|
code: string;
|
|
@@ -34,6 +39,10 @@ export type RevoProjectMetadata = {
|
|
|
34
39
|
hash: string;
|
|
35
40
|
lastSyncAt: string;
|
|
36
41
|
workspace?: string;
|
|
42
|
+
componentIdentity?: ComponentIdentityConfig;
|
|
43
|
+
/** Legacy project metadata field. Read for migration, but no longer written. */
|
|
44
|
+
stableKeyName?: string;
|
|
45
|
+
trackedResources?: TrackedResource[];
|
|
37
46
|
};
|
|
38
47
|
export type ProjectSyncInput = {
|
|
39
48
|
endpoint: string;
|
|
@@ -43,6 +52,8 @@ export type ProjectSyncInput = {
|
|
|
43
52
|
libVersion: string;
|
|
44
53
|
hash: string;
|
|
45
54
|
lastSyncAt: string;
|
|
55
|
+
componentIdentity?: ComponentIdentityConfig;
|
|
56
|
+
trackedResources?: TrackedResource[];
|
|
46
57
|
};
|
|
47
58
|
export type ProjectConfigResult = {
|
|
48
59
|
filePath: string;
|
|
@@ -80,21 +91,16 @@ export declare function buildProjectMetadata(input: {
|
|
|
80
91
|
};
|
|
81
92
|
export declare function findProjectMetadataFile(startDir?: string): string;
|
|
82
93
|
export declare function readProjectMetadata(startDir?: string): RevoProjectMetadata | null;
|
|
94
|
+
export declare function readProjectStableKeyName(startDir?: string): string;
|
|
95
|
+
export declare function readProjectComponentIdentity(startDir?: string): ComponentIdentityConfig;
|
|
96
|
+
export declare function readProjectTrackedResources(startDir?: string): TrackedResource[];
|
|
83
97
|
export declare function resolveProjectRoot(startDir?: string): string;
|
|
84
98
|
export declare function resolveProjectWorkspace(startDir?: string): string;
|
|
85
99
|
export declare function buildProjectSyncState(input: {
|
|
86
100
|
fallbackEndpoint: string;
|
|
87
101
|
bundle: EditorTypesBundle;
|
|
88
102
|
now?: Date;
|
|
89
|
-
}):
|
|
90
|
-
endpoint: string;
|
|
91
|
-
code: string;
|
|
92
|
-
definitions: unknown[];
|
|
93
|
-
apiVersion: string;
|
|
94
|
-
libVersion: string;
|
|
95
|
-
hash: string;
|
|
96
|
-
lastSyncAt: string;
|
|
97
|
-
};
|
|
103
|
+
}): ProjectSyncInput;
|
|
98
104
|
export declare function syncProjectFiles(layoutOrTargetDir: ProjectTargetLayout | string, input: ProjectSyncInput): {
|
|
99
105
|
projectDir: string;
|
|
100
106
|
typesFile: string;
|