@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.
Files changed (81) hide show
  1. package/README.md +464 -8
  2. package/dist/src/cli.js +65 -6
  3. package/dist/src/client.d.ts +366 -5
  4. package/dist/src/client.js +954 -13
  5. package/dist/src/commands/auth.js +4 -2
  6. package/dist/src/commands/component.js +839 -412
  7. package/dist/src/commands/database-schemas.d.ts +2 -0
  8. package/dist/src/commands/database-schemas.js +188 -0
  9. package/dist/src/commands/database-views.d.ts +2 -0
  10. package/dist/src/commands/database-views.js +123 -0
  11. package/dist/src/commands/endpoints.js +114 -0
  12. package/dist/src/commands/env.d.ts +2 -0
  13. package/dist/src/commands/env.js +380 -0
  14. package/dist/src/commands/events.d.ts +2 -0
  15. package/dist/src/commands/events.js +146 -0
  16. package/dist/src/commands/groups.d.ts +2 -0
  17. package/dist/src/commands/groups.js +169 -0
  18. package/dist/src/commands/index.d.ts +10 -0
  19. package/dist/src/commands/index.js +10 -0
  20. package/dist/src/commands/job-templates.d.ts +2 -0
  21. package/dist/src/commands/job-templates.js +101 -0
  22. package/dist/src/commands/metadata.d.ts +2 -0
  23. package/dist/src/commands/metadata.js +159 -0
  24. package/dist/src/commands/project.js +82 -1
  25. package/dist/src/commands/role-groups.d.ts +2 -0
  26. package/dist/src/commands/role-groups.js +152 -0
  27. package/dist/src/commands/schedules.d.ts +2 -0
  28. package/dist/src/commands/schedules.js +141 -0
  29. package/dist/src/commands/terminal-service.d.ts +38 -0
  30. package/dist/src/commands/terminal-service.js +210 -0
  31. package/dist/src/commands/terminal.d.ts +22 -0
  32. package/dist/src/commands/terminal.js +511 -0
  33. package/dist/src/component-lock.d.ts +126 -2
  34. package/dist/src/component-lock.js +378 -15
  35. package/dist/src/config.d.ts +20 -0
  36. package/dist/src/config.js +121 -6
  37. package/dist/src/database-schema-artifacts.d.ts +7 -0
  38. package/dist/src/database-schema-artifacts.js +8 -0
  39. package/dist/src/env-sync.d.ts +83 -0
  40. package/dist/src/env-sync.js +315 -0
  41. package/dist/src/metadata-backfill.d.ts +56 -0
  42. package/dist/src/metadata-backfill.js +1176 -0
  43. package/dist/src/project.d.ts +17 -9
  44. package/dist/src/project.js +87 -11
  45. package/dist/src/prompt.js +10 -18
  46. package/dist/src/resource-metadata.d.ts +25 -0
  47. package/dist/src/resource-metadata.js +132 -0
  48. package/dist/src/resource-syncs/database-schema-sync.d.ts +117 -0
  49. package/dist/src/resource-syncs/database-schema-sync.js +2289 -0
  50. package/dist/src/resource-syncs/database-view-sync.d.ts +124 -0
  51. package/dist/src/resource-syncs/database-view-sync.js +1317 -0
  52. package/dist/src/resource-syncs/endpoint-sync.d.ts +96 -0
  53. package/dist/src/resource-syncs/endpoint-sync.js +1283 -0
  54. package/dist/src/resource-syncs/event-sync.d.ts +99 -0
  55. package/dist/src/resource-syncs/event-sync.js +949 -0
  56. package/dist/src/resource-syncs/group-sync.d.ts +86 -0
  57. package/dist/src/resource-syncs/group-sync.js +882 -0
  58. package/dist/src/resource-syncs/job-template-sync.d.ts +85 -0
  59. package/dist/src/resource-syncs/job-template-sync.js +782 -0
  60. package/dist/src/resource-syncs/role-group-sync.d.ts +83 -0
  61. package/dist/src/resource-syncs/role-group-sync.js +597 -0
  62. package/dist/src/resource-syncs/schedule-sync.d.ts +111 -0
  63. package/dist/src/resource-syncs/schedule-sync.js +1302 -0
  64. package/dist/src/resource-syncs/util.d.ts +19 -0
  65. package/dist/src/resource-syncs/util.js +116 -0
  66. package/dist/src/runtime-view.d.ts +1 -0
  67. package/dist/src/runtime-view.js +6 -1
  68. package/dist/src/sync-output.d.ts +38 -0
  69. package/dist/src/sync-output.js +131 -0
  70. package/dist/src/tracked-resources.d.ts +7 -0
  71. package/dist/src/tracked-resources.js +61 -0
  72. package/dist/src/types.d.ts +227 -0
  73. package/dist/src/ui.d.ts +3 -0
  74. package/dist/src/ui.js +68 -10
  75. package/dist/src/utils.d.ts +2 -0
  76. package/dist/src/utils.js +64 -0
  77. package/dist/src/workspace-component.d.ts +2 -0
  78. package/dist/src/workspace-component.js +34 -0
  79. package/dist/src/workspace-resource.d.ts +2 -0
  80. package/dist/src/workspace-resource.js +52 -0
  81. package/package.json +8 -3
@@ -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
- return selected
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 = loadStoredConfig();
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 || stored.baseUrl || DEFAULT_BASE_URL,
369
- instance: options.instance || env.instance || stored.instance || '',
370
- token: options.token || env.token || stored.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 {};