@revoengine/cli 1.0.9 → 1.0.11
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 +464 -8
- package/dist/src/cli.js +65 -6
- package/dist/src/client.d.ts +366 -5
- package/dist/src/client.js +954 -13
- package/dist/src/commands/auth.js +4 -2
- package/dist/src/commands/component.js +839 -412
- package/dist/src/commands/database-schemas.d.ts +2 -0
- package/dist/src/commands/database-schemas.js +188 -0
- package/dist/src/commands/database-views.d.ts +2 -0
- package/dist/src/commands/database-views.js +123 -0
- package/dist/src/commands/endpoints.js +114 -0
- package/dist/src/commands/env.d.ts +2 -0
- package/dist/src/commands/env.js +380 -0
- package/dist/src/commands/events.d.ts +2 -0
- package/dist/src/commands/events.js +146 -0
- package/dist/src/commands/groups.d.ts +2 -0
- package/dist/src/commands/groups.js +169 -0
- package/dist/src/commands/index.d.ts +10 -0
- package/dist/src/commands/index.js +10 -0
- package/dist/src/commands/job-templates.d.ts +2 -0
- package/dist/src/commands/job-templates.js +101 -0
- package/dist/src/commands/metadata.d.ts +2 -0
- package/dist/src/commands/metadata.js +159 -0
- package/dist/src/commands/project.js +82 -1
- package/dist/src/commands/role-groups.d.ts +2 -0
- package/dist/src/commands/role-groups.js +152 -0
- package/dist/src/commands/schedules.d.ts +2 -0
- package/dist/src/commands/schedules.js +141 -0
- package/dist/src/commands/terminal-service.d.ts +38 -0
- package/dist/src/commands/terminal-service.js +210 -0
- package/dist/src/commands/terminal.d.ts +22 -0
- package/dist/src/commands/terminal.js +511 -0
- package/dist/src/component-lock.d.ts +126 -2
- package/dist/src/component-lock.js +378 -15
- package/dist/src/config.d.ts +20 -0
- package/dist/src/config.js +121 -6
- package/dist/src/database-schema-artifacts.d.ts +7 -0
- package/dist/src/database-schema-artifacts.js +8 -0
- package/dist/src/env-sync.d.ts +83 -0
- package/dist/src/env-sync.js +315 -0
- package/dist/src/metadata-backfill.d.ts +56 -0
- package/dist/src/metadata-backfill.js +1176 -0
- package/dist/src/project.d.ts +17 -9
- package/dist/src/project.js +87 -11
- package/dist/src/prompt.js +10 -18
- package/dist/src/resource-metadata.d.ts +25 -0
- package/dist/src/resource-metadata.js +132 -0
- package/dist/src/resource-syncs/database-schema-sync.d.ts +117 -0
- package/dist/src/resource-syncs/database-schema-sync.js +2289 -0
- package/dist/src/resource-syncs/database-view-sync.d.ts +124 -0
- package/dist/src/resource-syncs/database-view-sync.js +1317 -0
- package/dist/src/resource-syncs/endpoint-sync.d.ts +96 -0
- package/dist/src/resource-syncs/endpoint-sync.js +1283 -0
- package/dist/src/resource-syncs/event-sync.d.ts +99 -0
- package/dist/src/resource-syncs/event-sync.js +949 -0
- package/dist/src/resource-syncs/group-sync.d.ts +86 -0
- package/dist/src/resource-syncs/group-sync.js +882 -0
- package/dist/src/resource-syncs/job-template-sync.d.ts +85 -0
- package/dist/src/resource-syncs/job-template-sync.js +782 -0
- package/dist/src/resource-syncs/role-group-sync.d.ts +83 -0
- package/dist/src/resource-syncs/role-group-sync.js +597 -0
- package/dist/src/resource-syncs/schedule-sync.d.ts +111 -0
- package/dist/src/resource-syncs/schedule-sync.js +1302 -0
- package/dist/src/resource-syncs/util.d.ts +19 -0
- package/dist/src/resource-syncs/util.js +116 -0
- package/dist/src/runtime-view.d.ts +1 -0
- package/dist/src/runtime-view.js +6 -1
- package/dist/src/sync-output.d.ts +38 -0
- package/dist/src/sync-output.js +131 -0
- package/dist/src/tracked-resources.d.ts +7 -0
- package/dist/src/tracked-resources.js +61 -0
- package/dist/src/types.d.ts +227 -0
- package/dist/src/ui.d.ts +3 -0
- package/dist/src/ui.js +68 -10
- package/dist/src/utils.d.ts +2 -0
- package/dist/src/utils.js +64 -0
- package/dist/src/workspace-component.d.ts +2 -0
- package/dist/src/workspace-component.js +34 -0
- package/dist/src/workspace-resource.d.ts +2 -0
- package/dist/src/workspace-resource.js +52 -0
- package/package.json +8 -3
package/dist/src/config.js
CHANGED
|
@@ -74,16 +74,44 @@ export function saveStoredConfig(nextConfig) {
|
|
|
74
74
|
ensureDirectory(dir);
|
|
75
75
|
const current = readStoredConfigFile();
|
|
76
76
|
const instance = nextConfig.instance || current.instance || '';
|
|
77
|
+
const environments = nextConfig.environments ?? current.environments;
|
|
78
|
+
const activeEnvironment = nextConfig.activeEnvironment ?? current.activeEnvironment;
|
|
77
79
|
const config = {
|
|
78
80
|
baseUrl: nextConfig.baseUrl || current.baseUrl || DEFAULT_BASE_URL,
|
|
79
81
|
token: nextConfig.token || current.token || '',
|
|
80
82
|
...(instance ? { instance } : {}),
|
|
83
|
+
...(environments && Object.keys(environments).length > 0 ? { environments } : {}),
|
|
84
|
+
...(activeEnvironment && activeEnvironment !== DEFAULT_ENV_NAME ? { activeEnvironment } : {}),
|
|
81
85
|
};
|
|
82
86
|
writeJsonFile(configFile, config);
|
|
83
87
|
removeFileIfExists(credentialsFile);
|
|
84
88
|
}
|
|
85
89
|
export function clearStoredConfig() {
|
|
86
|
-
const { configFile, credentialsFile, authStateFile } = getConfigPaths();
|
|
90
|
+
const { dir, configFile, credentialsFile, authStateFile } = getConfigPaths();
|
|
91
|
+
// Logout clears the default login but must not destroy named environment
|
|
92
|
+
// profiles stored alongside it.
|
|
93
|
+
const stored = readStoredConfigFile();
|
|
94
|
+
const environments = stored.environments;
|
|
95
|
+
if (environments && Object.keys(environments).length > 0) {
|
|
96
|
+
ensureDirectory(dir);
|
|
97
|
+
writeJsonFile(configFile, {
|
|
98
|
+
baseUrl: DEFAULT_BASE_URL,
|
|
99
|
+
token: '',
|
|
100
|
+
environments,
|
|
101
|
+
...(stored.activeEnvironment ? { activeEnvironment: stored.activeEnvironment } : {}),
|
|
102
|
+
});
|
|
103
|
+
for (const filePath of [credentialsFile, authStateFile]) {
|
|
104
|
+
try {
|
|
105
|
+
fs.unlinkSync(filePath);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
if (!error || error.code !== 'ENOENT') {
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
87
115
|
for (const filePath of [configFile, credentialsFile, authStateFile]) {
|
|
88
116
|
try {
|
|
89
117
|
fs.unlinkSync(filePath);
|
|
@@ -144,6 +172,24 @@ function normalizeInstanceConfig(value) {
|
|
|
144
172
|
token: typeof value.token === 'string' ? value.token : '',
|
|
145
173
|
};
|
|
146
174
|
}
|
|
175
|
+
function parseEnvironmentProfiles(value) {
|
|
176
|
+
if (!isRecord(value)) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
const environments = {};
|
|
180
|
+
for (const [name, entry] of Object.entries(value)) {
|
|
181
|
+
if (!isRecord(entry) || typeof entry.baseUrl !== 'string' || typeof entry.token !== 'string') {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
environments[name] = {
|
|
185
|
+
baseUrl: entry.baseUrl,
|
|
186
|
+
token: entry.token,
|
|
187
|
+
...(typeof entry.instance === 'string' && isUuid(entry.instance) ? { instance: entry.instance } : {}),
|
|
188
|
+
...(entry.production === true ? { production: true } : {}),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
return Object.keys(environments).length > 0 ? environments : null;
|
|
192
|
+
}
|
|
147
193
|
function parseFlatConfig(raw, credentials) {
|
|
148
194
|
if (!isRecord(raw) || isRecord(raw.instances)) {
|
|
149
195
|
return null;
|
|
@@ -153,6 +199,12 @@ function parseFlatConfig(raw, credentials) {
|
|
|
153
199
|
: isRecord(credentials) && typeof credentials.token === 'string'
|
|
154
200
|
? credentials.token
|
|
155
201
|
: '';
|
|
202
|
+
const environments = parseEnvironmentProfiles(raw.environments);
|
|
203
|
+
const activeEnvironment = typeof raw.activeEnvironment === 'string'
|
|
204
|
+
&& raw.activeEnvironment !== DEFAULT_ENV_NAME
|
|
205
|
+
&& environments?.[raw.activeEnvironment]
|
|
206
|
+
? raw.activeEnvironment
|
|
207
|
+
: undefined;
|
|
156
208
|
return {
|
|
157
209
|
baseUrl: typeof raw.baseUrl === 'string'
|
|
158
210
|
? raw.baseUrl
|
|
@@ -160,6 +212,8 @@ function parseFlatConfig(raw, credentials) {
|
|
|
160
212
|
? raw.url
|
|
161
213
|
: DEFAULT_BASE_URL,
|
|
162
214
|
...(typeof raw.instance === 'string' && isUuid(raw.instance) ? { instance: raw.instance } : {}),
|
|
215
|
+
...(environments ? { environments } : {}),
|
|
216
|
+
...(activeEnvironment ? { activeEnvironment } : {}),
|
|
163
217
|
token,
|
|
164
218
|
};
|
|
165
219
|
}
|
|
@@ -181,14 +235,25 @@ function parseMappedConfig(raw) {
|
|
|
181
235
|
const defaultInstance = isUuid(configuredDefault) && instances[configuredDefault]
|
|
182
236
|
? configuredDefault
|
|
183
237
|
: Object.keys(instances)[0] || '';
|
|
238
|
+
const environments = parseEnvironmentProfiles(raw.environments);
|
|
239
|
+
const activeEnvironment = typeof raw.activeEnvironment === 'string'
|
|
240
|
+
&& raw.activeEnvironment !== DEFAULT_ENV_NAME
|
|
241
|
+
&& environments?.[raw.activeEnvironment]
|
|
242
|
+
? raw.activeEnvironment
|
|
243
|
+
: undefined;
|
|
184
244
|
const selected = defaultInstance ? instances[defaultInstance] : undefined;
|
|
185
|
-
|
|
245
|
+
const base = selected
|
|
186
246
|
? {
|
|
187
247
|
baseUrl: selected.baseUrl,
|
|
188
248
|
instance: defaultInstance,
|
|
189
249
|
token: selected.token,
|
|
190
250
|
}
|
|
191
251
|
: emptyStoredConfigFile();
|
|
252
|
+
return {
|
|
253
|
+
...base,
|
|
254
|
+
...(environments ? { environments } : {}),
|
|
255
|
+
...(activeEnvironment ? { activeEnvironment } : {}),
|
|
256
|
+
};
|
|
192
257
|
}
|
|
193
258
|
function parseLegacyConfig(config, credentials) {
|
|
194
259
|
const baseUrl = isRecord(config) && typeof config.baseUrl === 'string'
|
|
@@ -256,6 +321,7 @@ function mergeWithLegacy(stored, options = {}) {
|
|
|
256
321
|
return stored;
|
|
257
322
|
}
|
|
258
323
|
const merged = {
|
|
324
|
+
...stored,
|
|
259
325
|
baseUrl: stored.baseUrl || legacy.baseUrl || DEFAULT_BASE_URL,
|
|
260
326
|
token: stored.token || legacy.token || '',
|
|
261
327
|
};
|
|
@@ -267,6 +333,38 @@ function mergeWithLegacy(stored, options = {}) {
|
|
|
267
333
|
export function loadStoredConfig(options = {}) {
|
|
268
334
|
return mergeWithLegacy(readStoredConfig(), options);
|
|
269
335
|
}
|
|
336
|
+
export function listEnvironmentProfiles() {
|
|
337
|
+
return readStoredConfigFile().environments || {};
|
|
338
|
+
}
|
|
339
|
+
export function getEnvironmentProfile(name) {
|
|
340
|
+
return listEnvironmentProfiles()[name] || null;
|
|
341
|
+
}
|
|
342
|
+
export const DEFAULT_ENV_NAME = 'default';
|
|
343
|
+
export function getActiveEnvironmentName() {
|
|
344
|
+
return readStoredConfigFile().activeEnvironment || DEFAULT_ENV_NAME;
|
|
345
|
+
}
|
|
346
|
+
export function setActiveEnvironmentName(name) {
|
|
347
|
+
if (name !== DEFAULT_ENV_NAME && !getEnvironmentProfile(name)) {
|
|
348
|
+
throw new Error(`Unknown project "${name}". Run 'revo projects list' to see saved projects.`);
|
|
349
|
+
}
|
|
350
|
+
saveStoredConfig({ activeEnvironment: name });
|
|
351
|
+
}
|
|
352
|
+
export function saveEnvironmentProfile(name, profile, options = {}) {
|
|
353
|
+
const environments = { ...listEnvironmentProfiles(), [name]: profile };
|
|
354
|
+
saveStoredConfig({ environments, ...(options.activate ? { activeEnvironment: name } : {}) });
|
|
355
|
+
}
|
|
356
|
+
export function removeEnvironmentProfile(name) {
|
|
357
|
+
const environments = listEnvironmentProfiles();
|
|
358
|
+
if (!(name in environments)) {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
delete environments[name];
|
|
362
|
+
saveStoredConfig({
|
|
363
|
+
environments,
|
|
364
|
+
...(getActiveEnvironmentName() === name ? { activeEnvironment: DEFAULT_ENV_NAME } : {}),
|
|
365
|
+
});
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
270
368
|
export function buildAuthValidationKey(runtime) {
|
|
271
369
|
return createHash('sha256')
|
|
272
370
|
.update(JSON.stringify({
|
|
@@ -363,10 +461,27 @@ export function resolveRuntimeConfig(options = {}) {
|
|
|
363
461
|
instance: resolveEnvValue(['REVO_INSTANCE', 'REVOENGINE_INSTANCE']),
|
|
364
462
|
token: resolveEnvValue(['REVO_TOKEN', 'REVO_API_KEY', 'REVOENGINE_TOKEN', 'REVOENGINE_API_KEY']),
|
|
365
463
|
};
|
|
366
|
-
const stored =
|
|
464
|
+
const stored = readStoredConfigFile();
|
|
465
|
+
// A named profile is an explicit selection and resolves exclusively: no
|
|
466
|
+
// fallback to env vars or the stored default, or --from/--to could silently
|
|
467
|
+
// mix one environment's URL with another environment's token or instance.
|
|
468
|
+
const envName = options.envName || stored.activeEnvironment || DEFAULT_ENV_NAME;
|
|
469
|
+
if (envName !== DEFAULT_ENV_NAME) {
|
|
470
|
+
const profile = stored.environments?.[envName];
|
|
471
|
+
if (!profile) {
|
|
472
|
+
throw new Error(`Unknown project "${envName}". Run 'revo projects list' to see saved projects.`);
|
|
473
|
+
}
|
|
474
|
+
return {
|
|
475
|
+
baseUrl: options.baseUrl || profile.baseUrl || DEFAULT_BASE_URL,
|
|
476
|
+
instance: options.instance ?? (profile.instance || ''),
|
|
477
|
+
token: options.token || profile.token || '',
|
|
478
|
+
...(profile.production ? { production: true } : {}),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
const defaultStored = mergeWithLegacy(stored);
|
|
367
482
|
return {
|
|
368
|
-
baseUrl: options.baseUrl || env.baseUrl ||
|
|
369
|
-
instance: options.instance
|
|
370
|
-
token: options.token || env.token ||
|
|
483
|
+
baseUrl: options.baseUrl || env.baseUrl || defaultStored.baseUrl || DEFAULT_BASE_URL,
|
|
484
|
+
instance: options.instance ?? (env.instance || defaultStored.instance || ''),
|
|
485
|
+
token: options.token || env.token || defaultStored.token || '',
|
|
371
486
|
};
|
|
372
487
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const DATABASE_BACKUP_CATEGORY = "Database Backup";
|
|
2
|
+
export declare function isDatabaseBackupArtifact(value: {
|
|
3
|
+
category?: unknown;
|
|
4
|
+
}): boolean;
|
|
5
|
+
export declare function excludeDatabaseBackupArtifacts<T extends {
|
|
6
|
+
category?: unknown;
|
|
7
|
+
}>(records: readonly T[]): T[];
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export const DATABASE_BACKUP_CATEGORY = 'Database Backup';
|
|
2
|
+
export function isDatabaseBackupArtifact(value) {
|
|
3
|
+
return typeof value.category === 'string'
|
|
4
|
+
&& value.category.trim() === DATABASE_BACKUP_CATEGORY;
|
|
5
|
+
}
|
|
6
|
+
export function excludeDatabaseBackupArtifacts(records) {
|
|
7
|
+
return records.filter((record) => !isDatabaseBackupArtifact(record));
|
|
8
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { RevoClient } from './client.ts';
|
|
2
|
+
import { normalizePortableComponentSource } from './component-lock.ts';
|
|
3
|
+
import type { ComponentRecord } from './types.ts';
|
|
4
|
+
export type EnvRef = {
|
|
5
|
+
env: string;
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
instance: string;
|
|
8
|
+
};
|
|
9
|
+
export type PortableComponentSource = ReturnType<typeof normalizePortableComponentSource>;
|
|
10
|
+
export type EnvComponentEntry = {
|
|
11
|
+
stableKey: string;
|
|
12
|
+
componentId: string;
|
|
13
|
+
component: ComponentRecord;
|
|
14
|
+
portable: PortableComponentSource;
|
|
15
|
+
portableHash: string;
|
|
16
|
+
};
|
|
17
|
+
export type EnvCollision = {
|
|
18
|
+
stableKey: string;
|
|
19
|
+
components: Array<{
|
|
20
|
+
componentId: string;
|
|
21
|
+
name: string;
|
|
22
|
+
}>;
|
|
23
|
+
};
|
|
24
|
+
export type EnvSnapshot = {
|
|
25
|
+
ref: EnvRef;
|
|
26
|
+
components: Map<string, EnvComponentEntry>;
|
|
27
|
+
collisions: EnvCollision[];
|
|
28
|
+
};
|
|
29
|
+
export type EnvSyncAction = 'create' | 'update' | 'delete' | 'orphan' | 'noop' | 'collision';
|
|
30
|
+
export type EnvSyncPlanItem = {
|
|
31
|
+
resourceType: 'component';
|
|
32
|
+
stableKey: string;
|
|
33
|
+
action: EnvSyncAction;
|
|
34
|
+
name: string;
|
|
35
|
+
category: string | null;
|
|
36
|
+
sourceComponentId: string | null;
|
|
37
|
+
targetComponentId: string | null;
|
|
38
|
+
sourceHash: string | null;
|
|
39
|
+
targetHash: string | null;
|
|
40
|
+
changedFields?: string[];
|
|
41
|
+
addedElementKeys?: string[];
|
|
42
|
+
removedElementKeys?: string[];
|
|
43
|
+
changedElementKeys?: string[];
|
|
44
|
+
reason: string;
|
|
45
|
+
};
|
|
46
|
+
export type EnvSyncScope = {
|
|
47
|
+
keys?: string[];
|
|
48
|
+
categories?: string[];
|
|
49
|
+
};
|
|
50
|
+
export type EnvSyncPlan = {
|
|
51
|
+
schemaVersion: 1;
|
|
52
|
+
generatedAt: string;
|
|
53
|
+
from: EnvRef;
|
|
54
|
+
to: EnvRef;
|
|
55
|
+
prune: boolean;
|
|
56
|
+
scope?: EnvSyncScope;
|
|
57
|
+
counts: Record<EnvSyncAction, number>;
|
|
58
|
+
items: EnvSyncPlanItem[];
|
|
59
|
+
};
|
|
60
|
+
export type EnvApplyItemResult = {
|
|
61
|
+
stableKey: string;
|
|
62
|
+
action: EnvSyncAction;
|
|
63
|
+
status: 'applied' | 'failed';
|
|
64
|
+
verified?: boolean;
|
|
65
|
+
error?: string;
|
|
66
|
+
};
|
|
67
|
+
export declare function normalizeKeyPart(value: string | null | undefined): string;
|
|
68
|
+
export declare function buildStableKey(component: ComponentRecord, stableKeyName?: string): string;
|
|
69
|
+
export declare function captureEnvSnapshot(client: RevoClient, envName: string, options?: {
|
|
70
|
+
stableKeyName?: string;
|
|
71
|
+
}): Promise<EnvSnapshot>;
|
|
72
|
+
export declare function buildEnvSyncPlan(source: EnvSnapshot, target: EnvSnapshot, options: {
|
|
73
|
+
prune: boolean;
|
|
74
|
+
scope?: EnvSyncScope;
|
|
75
|
+
}): EnvSyncPlan;
|
|
76
|
+
export declare function applyEnvSyncPlan(input: {
|
|
77
|
+
targetClient: RevoClient;
|
|
78
|
+
plan: EnvSyncPlan;
|
|
79
|
+
source: EnvSnapshot;
|
|
80
|
+
target: EnvSnapshot;
|
|
81
|
+
stableKeyName?: string;
|
|
82
|
+
onItem?: (result: EnvApplyItemResult) => void;
|
|
83
|
+
}): Promise<EnvApplyItemResult[]>;
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { buildPortableComponentUpdatePayload, diffPortableComponentSource, 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
|
+
function diffElementKeys(source, target) {
|
|
73
|
+
const sourceByKey = new Map(source.elements.map((element) => [element.key, element]));
|
|
74
|
+
const targetByKey = new Map(target.elements.map((element) => [element.key, element]));
|
|
75
|
+
const addedElementKeys = [];
|
|
76
|
+
const changedElementKeys = [];
|
|
77
|
+
for (const [key, element] of sourceByKey) {
|
|
78
|
+
const other = targetByKey.get(key);
|
|
79
|
+
if (!other) {
|
|
80
|
+
addedElementKeys.push(key);
|
|
81
|
+
}
|
|
82
|
+
else if (JSON.stringify(element) !== JSON.stringify(other)) {
|
|
83
|
+
changedElementKeys.push(key);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const removedElementKeys = [...targetByKey.keys()].filter((key) => !sourceByKey.has(key));
|
|
87
|
+
return { addedElementKeys, removedElementKeys, changedElementKeys };
|
|
88
|
+
}
|
|
89
|
+
function buildScopeFilter(scope) {
|
|
90
|
+
const keys = scope?.keys?.map((key) => normalizeKeyPart(key)).filter(Boolean);
|
|
91
|
+
const categories = scope?.categories?.map((category) => normalizeKeyPart(category) || NULL_CATEGORY_KEY);
|
|
92
|
+
if ((!keys || keys.length === 0) && (!categories || categories.length === 0)) {
|
|
93
|
+
return () => true;
|
|
94
|
+
}
|
|
95
|
+
return (stableKey) => {
|
|
96
|
+
if (keys && keys.length > 0 && keys.includes(stableKey)) {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
if (categories && categories.length > 0) {
|
|
100
|
+
const categoryPart = stableKey.slice(0, stableKey.lastIndexOf('/'));
|
|
101
|
+
return categories.includes(categoryPart);
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function emptyCounts() {
|
|
107
|
+
return {
|
|
108
|
+
create: 0,
|
|
109
|
+
update: 0,
|
|
110
|
+
delete: 0,
|
|
111
|
+
orphan: 0,
|
|
112
|
+
noop: 0,
|
|
113
|
+
collision: 0,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
export function buildEnvSyncPlan(source, target, options) {
|
|
117
|
+
const inScope = buildScopeFilter(options.scope);
|
|
118
|
+
const items = [];
|
|
119
|
+
const collisionKeys = new Set();
|
|
120
|
+
for (const [envLabel, snapshot] of [['source', source], ['target', target]]) {
|
|
121
|
+
for (const collision of snapshot.collisions) {
|
|
122
|
+
if (!inScope(collision.stableKey) || collisionKeys.has(`${envLabel}:${collision.stableKey}`)) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
collisionKeys.add(`${envLabel}:${collision.stableKey}`);
|
|
126
|
+
items.push({
|
|
127
|
+
resourceType: 'component',
|
|
128
|
+
stableKey: collision.stableKey,
|
|
129
|
+
action: 'collision',
|
|
130
|
+
name: collision.components[0]?.name || '',
|
|
131
|
+
category: null,
|
|
132
|
+
sourceComponentId: null,
|
|
133
|
+
targetComponentId: null,
|
|
134
|
+
sourceHash: null,
|
|
135
|
+
targetHash: null,
|
|
136
|
+
reason: `${collision.components.length} components share this key in ${envLabel} (${snapshot.ref.env}): ${collision.components.map((component) => component.componentId).join(', ')}`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const blockedKeys = new Set([...source.collisions, ...target.collisions].map((collision) => collision.stableKey));
|
|
141
|
+
const allKeys = new Set([...source.components.keys(), ...target.components.keys()]);
|
|
142
|
+
for (const stableKey of allKeys) {
|
|
143
|
+
if (!inScope(stableKey) || blockedKeys.has(stableKey)) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const sourceEntry = source.components.get(stableKey);
|
|
147
|
+
const targetEntry = target.components.get(stableKey);
|
|
148
|
+
const reference = (sourceEntry || targetEntry);
|
|
149
|
+
const base = {
|
|
150
|
+
resourceType: 'component',
|
|
151
|
+
stableKey,
|
|
152
|
+
name: String(reference.component.name || ''),
|
|
153
|
+
category: reference.portable.category,
|
|
154
|
+
sourceComponentId: sourceEntry?.componentId ?? null,
|
|
155
|
+
targetComponentId: targetEntry?.componentId ?? null,
|
|
156
|
+
sourceHash: sourceEntry?.portableHash ?? null,
|
|
157
|
+
targetHash: targetEntry?.portableHash ?? null,
|
|
158
|
+
};
|
|
159
|
+
if (sourceEntry && !targetEntry) {
|
|
160
|
+
items.push({ ...base, action: 'create', reason: 'missing in target' });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (!sourceEntry && targetEntry) {
|
|
164
|
+
items.push(options.prune
|
|
165
|
+
? { ...base, action: 'delete', reason: 'missing in source' }
|
|
166
|
+
: { ...base, action: 'orphan', reason: 'missing in source; use --prune to delete' });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (sourceEntry.portableHash === targetEntry.portableHash) {
|
|
170
|
+
items.push({ ...base, action: 'noop', reason: 'up to date' });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const changedFields = diffPortableComponentSource(sourceEntry.component, targetEntry.component).configurationFields;
|
|
174
|
+
const elementDiff = diffElementKeys(sourceEntry.portable, targetEntry.portable);
|
|
175
|
+
items.push({
|
|
176
|
+
...base,
|
|
177
|
+
action: 'update',
|
|
178
|
+
changedFields,
|
|
179
|
+
...elementDiff,
|
|
180
|
+
reason: 'source and target content differ',
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
items.sort((left, right) => left.stableKey.localeCompare(right.stableKey)
|
|
184
|
+
|| left.action.localeCompare(right.action));
|
|
185
|
+
const counts = emptyCounts();
|
|
186
|
+
for (const item of items) {
|
|
187
|
+
counts[item.action] += 1;
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
schemaVersion: 1,
|
|
191
|
+
generatedAt: new Date().toISOString(),
|
|
192
|
+
from: source.ref,
|
|
193
|
+
to: target.ref,
|
|
194
|
+
prune: options.prune,
|
|
195
|
+
...(options.scope && (options.scope.keys?.length || options.scope.categories?.length)
|
|
196
|
+
? { scope: options.scope }
|
|
197
|
+
: {}),
|
|
198
|
+
counts,
|
|
199
|
+
items,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
// Mirrors the push payload shape, but componentElementIds never cross
|
|
203
|
+
// environments: they are only set when they belong to the target component.
|
|
204
|
+
function buildPortableElementPayload(component, targetElementsByKey) {
|
|
205
|
+
return [...(component.elements || [])].map((element) => {
|
|
206
|
+
const targetElementId = targetElementsByKey?.get(element.key)?.componentElementId;
|
|
207
|
+
return {
|
|
208
|
+
...(typeof targetElementId === 'string' && targetElementId
|
|
209
|
+
? { componentElementId: targetElementId }
|
|
210
|
+
: {}),
|
|
211
|
+
key: element.key,
|
|
212
|
+
desc: element.desc,
|
|
213
|
+
details: element.details || '',
|
|
214
|
+
hidden: Boolean(element.hidden),
|
|
215
|
+
order: element.order,
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
function compilerFromType(type, compiler) {
|
|
220
|
+
if (compiler) {
|
|
221
|
+
return compiler;
|
|
222
|
+
}
|
|
223
|
+
return (type || '').toLowerCase().includes('ts') ? 'typescript' : 'javascript';
|
|
224
|
+
}
|
|
225
|
+
function buildCreatePayload(component) {
|
|
226
|
+
const sanitized = sanitizeComponentSource(component);
|
|
227
|
+
return {
|
|
228
|
+
name: sanitized.name || '',
|
|
229
|
+
category: sanitized.category ?? null,
|
|
230
|
+
desc: sanitized.desc ?? null,
|
|
231
|
+
type: sanitized.type || '',
|
|
232
|
+
compiler: compilerFromType(sanitized.type, sanitized.compiler),
|
|
233
|
+
active: sanitized.active ?? true,
|
|
234
|
+
async: Boolean(sanitized.async),
|
|
235
|
+
metadata: sanitizeUserMetadata(component.metadata),
|
|
236
|
+
elements: buildPortableElementPayload(component),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
async function verifyAppliedComponent(targetClient, componentId, expectedHash) {
|
|
240
|
+
const component = unwrapComponent(await targetClient.getComponent(componentId));
|
|
241
|
+
return hashPortableComponentSource(component) === expectedHash;
|
|
242
|
+
}
|
|
243
|
+
export async function applyEnvSyncPlan(input) {
|
|
244
|
+
const { targetClient, plan, source, target, stableKeyName, onItem } = input;
|
|
245
|
+
const collisions = plan.items.filter((item) => item.action === 'collision');
|
|
246
|
+
if (collisions.length > 0) {
|
|
247
|
+
throw new Error(`Cannot apply: ${collisions.length} stable-key collision(s) in scope. Resolve duplicates or narrow the scope with --key/--category.`);
|
|
248
|
+
}
|
|
249
|
+
const ordered = [
|
|
250
|
+
...plan.items.filter((item) => item.action === 'create'),
|
|
251
|
+
...plan.items.filter((item) => item.action === 'update'),
|
|
252
|
+
...plan.items.filter((item) => item.action === 'delete'),
|
|
253
|
+
];
|
|
254
|
+
const results = [];
|
|
255
|
+
for (const item of ordered) {
|
|
256
|
+
const result = await applyPlanItem(targetClient, item, source, target, stableKeyName);
|
|
257
|
+
results.push(result);
|
|
258
|
+
onItem?.(result);
|
|
259
|
+
}
|
|
260
|
+
return results;
|
|
261
|
+
}
|
|
262
|
+
async function applyPlanItem(targetClient, item, source, target, stableKeyName = DEFAULT_STABLE_KEY_NAME) {
|
|
263
|
+
try {
|
|
264
|
+
if (item.action === 'create') {
|
|
265
|
+
const sourceEntry = source.components.get(item.stableKey);
|
|
266
|
+
if (!sourceEntry) {
|
|
267
|
+
throw new Error('source component disappeared from the snapshot');
|
|
268
|
+
}
|
|
269
|
+
const created = unwrapComponent(await targetClient.createComponent(buildCreatePayload(sourceEntry.component)));
|
|
270
|
+
const newComponentId = componentIdOf(created);
|
|
271
|
+
if (!newComponentId) {
|
|
272
|
+
throw new Error('create response did not include a componentId');
|
|
273
|
+
}
|
|
274
|
+
// The save endpoint is the authoritative element write; re-sending the
|
|
275
|
+
// elements guards against create endpoints that ignore inline elements.
|
|
276
|
+
await targetClient.saveComponentElements(newComponentId, buildPortableElementPayload(sourceEntry.component));
|
|
277
|
+
const verified = await verifyAppliedComponent(targetClient, newComponentId, sourceEntry.portableHash);
|
|
278
|
+
return { stableKey: item.stableKey, action: item.action, status: 'applied', verified };
|
|
279
|
+
}
|
|
280
|
+
if (item.action === 'update') {
|
|
281
|
+
const sourceEntry = source.components.get(item.stableKey);
|
|
282
|
+
const targetEntry = target.components.get(item.stableKey);
|
|
283
|
+
if (!sourceEntry || !targetEntry) {
|
|
284
|
+
throw new Error('component disappeared from the snapshot');
|
|
285
|
+
}
|
|
286
|
+
if (item.changedFields && item.changedFields.length > 0) {
|
|
287
|
+
await targetClient.updateComponent(targetEntry.componentId, buildPortableComponentUpdatePayload(sourceEntry.component, targetEntry.component, item.changedFields));
|
|
288
|
+
}
|
|
289
|
+
const hasElementChanges = Boolean(item.addedElementKeys?.length || item.removedElementKeys?.length || item.changedElementKeys?.length);
|
|
290
|
+
if (hasElementChanges) {
|
|
291
|
+
const targetElementsByKey = new Map((targetEntry.component.elements || []).map((element) => [element.key, element]));
|
|
292
|
+
await targetClient.saveComponentElements(targetEntry.componentId, buildPortableElementPayload(sourceEntry.component, targetElementsByKey));
|
|
293
|
+
}
|
|
294
|
+
const verified = await verifyAppliedComponent(targetClient, targetEntry.componentId, sourceEntry.portableHash);
|
|
295
|
+
return { stableKey: item.stableKey, action: item.action, status: 'applied', verified };
|
|
296
|
+
}
|
|
297
|
+
if (item.action === 'delete') {
|
|
298
|
+
const targetEntry = target.components.get(item.stableKey);
|
|
299
|
+
if (!targetEntry) {
|
|
300
|
+
throw new Error('target component disappeared from the snapshot');
|
|
301
|
+
}
|
|
302
|
+
await targetClient.deleteComponent(targetEntry.componentId);
|
|
303
|
+
return { stableKey: item.stableKey, action: item.action, status: 'applied' };
|
|
304
|
+
}
|
|
305
|
+
throw new Error(`unexpected plan action: ${item.action}`);
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
return {
|
|
309
|
+
stableKey: item.stableKey,
|
|
310
|
+
action: item.action,
|
|
311
|
+
status: 'failed',
|
|
312
|
+
error: error instanceof Error ? error.message : String(error),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { RevoClient } from './client.ts';
|
|
2
|
+
import { type TrackedResource } from './tracked-resources.ts';
|
|
3
|
+
import type { ComponentRecord, DatabaseSchemaRecord, DatabaseViewRecord, EndpointRecord, EventRecord, GroupRecord, JobTemplateRecord, RoleGroupRecord, ScheduleRecord } from './types.ts';
|
|
4
|
+
export type MetadataBackfillAction = 'update' | 'skip' | 'blocked' | 'failed';
|
|
5
|
+
type MetadataBackfillResourceType = 'component' | 'endpoint' | 'group' | 'role-group' | 'job-template' | 'schedule' | 'event' | 'database-schema' | 'database-view';
|
|
6
|
+
type MetadataResourceRecord = ComponentRecord | DatabaseSchemaRecord | DatabaseViewRecord | EndpointRecord | GroupRecord | RoleGroupRecord | JobTemplateRecord | ScheduleRecord | EventRecord;
|
|
7
|
+
export type MetadataBackfillPlanItem = {
|
|
8
|
+
resourceType: MetadataBackfillResourceType;
|
|
9
|
+
resourceId: string;
|
|
10
|
+
name: string;
|
|
11
|
+
category: string | null;
|
|
12
|
+
action: Exclude<MetadataBackfillAction, 'failed'>;
|
|
13
|
+
metadataField: 'metadata' | 'definition[].metadata';
|
|
14
|
+
stableKeyName: string;
|
|
15
|
+
stableKey?: string;
|
|
16
|
+
columnName?: string;
|
|
17
|
+
columnDefinitionId?: string;
|
|
18
|
+
reason: string;
|
|
19
|
+
};
|
|
20
|
+
export type MetadataBackfillPlan = {
|
|
21
|
+
schemaVersion: 1;
|
|
22
|
+
generatedAt: string;
|
|
23
|
+
env: string;
|
|
24
|
+
baseUrl: string;
|
|
25
|
+
instance: string;
|
|
26
|
+
stableKeyName: string;
|
|
27
|
+
trackedResources: TrackedResource[];
|
|
28
|
+
force: boolean;
|
|
29
|
+
skipPartitions?: boolean;
|
|
30
|
+
counts: Record<Exclude<MetadataBackfillAction, 'failed'>, number>;
|
|
31
|
+
blockers: string[];
|
|
32
|
+
items: MetadataBackfillPlanItem[];
|
|
33
|
+
};
|
|
34
|
+
export type MetadataBackfillResult = {
|
|
35
|
+
resourceType: MetadataBackfillResourceType;
|
|
36
|
+
resourceId: string;
|
|
37
|
+
stableKey?: string;
|
|
38
|
+
columnName?: string;
|
|
39
|
+
action: MetadataBackfillAction;
|
|
40
|
+
error?: string;
|
|
41
|
+
};
|
|
42
|
+
export declare function generateStableKeySuffix(bytes?: number): string;
|
|
43
|
+
export declare function buildGeneratedStableKey(resource: MetadataResourceRecord): string;
|
|
44
|
+
export declare function buildMetadataBackfillPlan(input: {
|
|
45
|
+
client: RevoClient;
|
|
46
|
+
envName: string;
|
|
47
|
+
stableKeyName: string;
|
|
48
|
+
trackedResources: TrackedResource[];
|
|
49
|
+
force?: boolean;
|
|
50
|
+
skipPartitions?: boolean;
|
|
51
|
+
}): Promise<MetadataBackfillPlan>;
|
|
52
|
+
export declare function applyMetadataBackfillPlan(input: {
|
|
53
|
+
client: RevoClient;
|
|
54
|
+
plan: MetadataBackfillPlan;
|
|
55
|
+
}): Promise<MetadataBackfillResult[]>;
|
|
56
|
+
export {};
|