@revoengine/cli 1.0.10 → 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 (79) hide show
  1. package/README.md +312 -33
  2. package/dist/src/cli.js +58 -7
  3. package/dist/src/client.d.ts +356 -5
  4. package/dist/src/client.js +803 -19
  5. package/dist/src/commands/auth.js +4 -2
  6. package/dist/src/commands/component.js +260 -141
  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.js +44 -24
  13. package/dist/src/commands/events.d.ts +2 -0
  14. package/dist/src/commands/events.js +146 -0
  15. package/dist/src/commands/groups.d.ts +2 -0
  16. package/dist/src/commands/groups.js +169 -0
  17. package/dist/src/commands/index.d.ts +8 -0
  18. package/dist/src/commands/index.js +8 -0
  19. package/dist/src/commands/job-templates.d.ts +2 -0
  20. package/dist/src/commands/job-templates.js +101 -0
  21. package/dist/src/commands/metadata.js +29 -7
  22. package/dist/src/commands/project.js +10 -3
  23. package/dist/src/commands/role-groups.d.ts +2 -0
  24. package/dist/src/commands/role-groups.js +152 -0
  25. package/dist/src/commands/schedules.d.ts +2 -0
  26. package/dist/src/commands/schedules.js +141 -0
  27. package/dist/src/commands/terminal-service.d.ts +38 -0
  28. package/dist/src/commands/terminal-service.js +210 -0
  29. package/dist/src/commands/terminal.d.ts +22 -0
  30. package/dist/src/commands/terminal.js +511 -0
  31. package/dist/src/component-lock.d.ts +100 -2
  32. package/dist/src/component-lock.js +304 -15
  33. package/dist/src/config.d.ts +10 -2
  34. package/dist/src/config.js +49 -14
  35. package/dist/src/database-schema-artifacts.d.ts +7 -0
  36. package/dist/src/database-schema-artifacts.js +8 -0
  37. package/dist/src/env-sync.d.ts +0 -3
  38. package/dist/src/env-sync.js +3 -19
  39. package/dist/src/metadata-backfill.d.ts +16 -6
  40. package/dist/src/metadata-backfill.js +1069 -18
  41. package/dist/src/project.d.ts +2 -0
  42. package/dist/src/project.js +4 -17
  43. package/dist/src/prompt.js +10 -18
  44. package/dist/src/resource-metadata.d.ts +1 -0
  45. package/dist/src/resource-metadata.js +3 -0
  46. package/dist/src/resource-syncs/database-schema-sync.d.ts +117 -0
  47. package/dist/src/resource-syncs/database-schema-sync.js +2289 -0
  48. package/dist/src/resource-syncs/database-view-sync.d.ts +124 -0
  49. package/dist/src/resource-syncs/database-view-sync.js +1317 -0
  50. package/dist/src/resource-syncs/endpoint-sync.d.ts +96 -0
  51. package/dist/src/resource-syncs/endpoint-sync.js +1283 -0
  52. package/dist/src/resource-syncs/event-sync.d.ts +99 -0
  53. package/dist/src/resource-syncs/event-sync.js +949 -0
  54. package/dist/src/resource-syncs/group-sync.d.ts +86 -0
  55. package/dist/src/resource-syncs/group-sync.js +882 -0
  56. package/dist/src/resource-syncs/job-template-sync.d.ts +85 -0
  57. package/dist/src/resource-syncs/job-template-sync.js +782 -0
  58. package/dist/src/resource-syncs/role-group-sync.d.ts +83 -0
  59. package/dist/src/resource-syncs/role-group-sync.js +597 -0
  60. package/dist/src/resource-syncs/schedule-sync.d.ts +111 -0
  61. package/dist/src/resource-syncs/schedule-sync.js +1302 -0
  62. package/dist/src/resource-syncs/util.d.ts +19 -0
  63. package/dist/src/resource-syncs/util.js +116 -0
  64. package/dist/src/runtime-view.d.ts +1 -0
  65. package/dist/src/runtime-view.js +6 -1
  66. package/dist/src/sync-output.d.ts +38 -0
  67. package/dist/src/sync-output.js +131 -0
  68. package/dist/src/tracked-resources.d.ts +1 -1
  69. package/dist/src/tracked-resources.js +26 -2
  70. package/dist/src/types.d.ts +224 -0
  71. package/dist/src/ui.d.ts +3 -0
  72. package/dist/src/ui.js +67 -18
  73. package/dist/src/utils.d.ts +2 -0
  74. package/dist/src/utils.js +64 -0
  75. package/dist/src/workspace-component.d.ts +2 -0
  76. package/dist/src/workspace-component.js +34 -0
  77. package/dist/src/workspace-resource.d.ts +2 -0
  78. package/dist/src/workspace-resource.js +52 -0
  79. package/package.json +8 -3
@@ -1,55 +1,303 @@
1
1
  import { randomBytes } from 'node:crypto';
2
- import { buildStableKey, buildResourceIdentityStableKeyValue, readConfiguredResourceMetadataStableKey, sanitizeUserMetadata, } from "./resource-metadata.js";
2
+ import { isDatabaseBackupArtifact } from "./database-schema-artifacts.js";
3
+ import { buildStableKey, buildResourceIdentityStableKeyValue, normalizeStableKeyValue, readConfiguredResourceMetadataStableKey, sanitizeUserMetadata, } from "./resource-metadata.js";
3
4
  import { shouldTrackResource } from "./tracked-resources.js";
4
5
  function isRecord(value) {
5
6
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
6
7
  }
7
- function unwrapComponent(value) {
8
+ function unwrapResource(value) {
8
9
  if (isRecord(value) && isRecord(value.data)) {
9
10
  return value.data;
10
11
  }
11
12
  return value;
12
13
  }
13
- function componentIdOf(component) {
14
- return String(component.componentId || component.id || '');
14
+ function resourceIdOf(resource) {
15
+ return String(('databaseId' in resource && resource.databaseId)
16
+ || ('databaseViewId' in resource && resource.databaseViewId)
17
+ || ('jobTemplateId' in resource && resource.jobTemplateId)
18
+ || ('endpointId' in resource && resource.endpointId)
19
+ || ('scheduleId' in resource && resource.scheduleId)
20
+ || ('jobEventId' in resource && resource.jobEventId)
21
+ || ('eventId' in resource && resource.eventId)
22
+ || ('groupId' in resource && resource.groupId)
23
+ || resource.componentId
24
+ || resource.roleGroupId
25
+ || resource.id
26
+ || '');
15
27
  }
16
28
  function normalizeValue(value, fallback = null) {
17
29
  return value ?? fallback;
18
30
  }
31
+ function rawConfiguredStableKey(resource, stableKeyName) {
32
+ for (const metadata of [resource.metadata, resource.metaData, resource.resourceMetadata]) {
33
+ if (isRecord(metadata) && Object.prototype.hasOwnProperty.call(metadata, stableKeyName)) {
34
+ return { present: true, value: metadata[stableKeyName] };
35
+ }
36
+ }
37
+ return { present: false, value: undefined };
38
+ }
39
+ function metadataStableKeyIdentity(resourceType, stableKey) {
40
+ // TODO(APP-918): Align Role Groups with the exact Stable Key contract used by
41
+ // Components and Job Templates. Update role-group pull, plan, push and
42
+ // filesystem-path collision handling together before changing this branch.
43
+ const identityValue = resourceType === 'role-group'
44
+ ? normalizeStableKeyValue(stableKey)
45
+ : stableKey;
46
+ return `${resourceType}:${identityValue}`;
47
+ }
19
48
  export function generateStableKeySuffix(bytes = 4) {
20
49
  return randomBytes(bytes).toString('hex');
21
50
  }
22
- export function buildGeneratedStableKey(component) {
23
- return `${buildResourceIdentityStableKeyValue(component)}#${generateStableKeySuffix()}`;
51
+ export function buildGeneratedStableKey(resource) {
52
+ return `${buildResourceIdentityStableKeyValue(resource)}#${generateStableKeySuffix()}`;
53
+ }
54
+ function buildNamedResourceGeneratedStableKey(resource) {
55
+ const category = typeof resource.category === 'string' && resource.category.trim()
56
+ ? resource.category.trim()
57
+ : 'Uncategorized';
58
+ const name = typeof resource.name === 'string' ? resource.name.trim() : '';
59
+ return `${category}/${name}#${generateStableKeySuffix()}`;
60
+ }
61
+ function isDatabaseSchemaPartition(database) {
62
+ return Boolean(database.parentId || database.parent);
63
+ }
64
+ function databaseDefinitions(database) {
65
+ return Array.isArray(database.definition)
66
+ ? database.definition.filter(isRecord)
67
+ : [];
68
+ }
69
+ function columnStableKeyValue(definition, stableKeyName) {
70
+ const metadata = definition.metadata;
71
+ if (!isRecord(metadata) || !Object.hasOwn(metadata, stableKeyName)) {
72
+ return { present: false, value: undefined };
73
+ }
74
+ return { present: true, value: metadata[stableKeyName] };
75
+ }
76
+ function buildGeneratedDatabaseColumnStableKey(database, columnName) {
77
+ const category = typeof database.category === 'string' && database.category.trim()
78
+ ? database.category.trim()
79
+ : 'Uncategorized';
80
+ const databaseName = typeof database.name === 'string' ? database.name.trim() : '';
81
+ return `${category}/${databaseName}/${columnName}#${generateStableKeySuffix()}`;
24
82
  }
25
- function buildComponentMetadataUpdatePayload(component, stableKeyName, stableKey) {
26
- if (!Number.isInteger(component.version)) {
27
- throw new Error('component version is required to update metadata');
83
+ function buildColumnStableKeyMetadata(definition, stableKeyName, stableKey) {
84
+ return {
85
+ ...sanitizeUserMetadata(definition.metadata),
86
+ [stableKeyName]: stableKey,
87
+ };
88
+ }
89
+ function buildDatabaseColumnMetadataUpdatePayload(input) {
90
+ if (!Number.isInteger(input.database.version)) {
91
+ throw new Error('database schema version is required to update column metadata');
92
+ }
93
+ if (input.assignments.length === 0) {
94
+ throw new Error('at least one database schema column metadata assignment is required');
95
+ }
96
+ const definitions = databaseDefinitions(input.database);
97
+ const assignmentsByDefinition = new Map(input.assignments.map((assignment) => [
98
+ assignment.definition,
99
+ assignment.stableKey,
100
+ ]));
101
+ for (const assignment of input.assignments) {
102
+ const name = typeof assignment.definition.name === 'string' ? assignment.definition.name : 'unknown';
103
+ if (!definitions.includes(assignment.definition)) {
104
+ throw new Error(`database schema column "${name}" no longer exists`);
105
+ }
106
+ }
107
+ for (const definition of definitions) {
108
+ const name = typeof definition.name === 'string' ? definition.name : 'unknown';
109
+ if (typeof definition.databaseDefinitionId !== 'string' || !definition.databaseDefinitionId) {
110
+ throw new Error(`database schema column "${name}" is missing databaseDefinitionId; refusing to rewrite the full definition for metadata backfill`);
111
+ }
28
112
  }
29
113
  return {
30
- version: component.version,
114
+ version: input.database.version,
115
+ definition: definitions.map((definition) => {
116
+ const { metadata: _metadata, ...withoutMetadata } = definition;
117
+ const stableKey = assignmentsByDefinition.get(definition);
118
+ if (!stableKey) {
119
+ const metadata = sanitizeUserMetadata(definition.metadata);
120
+ return Object.keys(metadata).length > 0
121
+ ? { ...withoutMetadata, metadata }
122
+ : withoutMetadata;
123
+ }
124
+ return {
125
+ ...withoutMetadata,
126
+ metadata: buildColumnStableKeyMetadata(definition, input.stableKeyName, stableKey),
127
+ };
128
+ }),
129
+ };
130
+ }
131
+ function groupMetadataBackfillReason(group, existingStableKey, force) {
132
+ const baseReason = existingStableKey
133
+ ? (force ? 'stable key will be regenerated' : 'stable key already exists')
134
+ : 'stable key missing';
135
+ const reservedKeys = new Set();
136
+ for (const metadata of [group.resourceMetadata, group.metaData, group.metadata]) {
137
+ if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
138
+ continue;
139
+ }
140
+ for (const key of Object.keys(metadata)) {
141
+ if (key.startsWith('__')) {
142
+ reservedKeys.add(key);
143
+ }
144
+ }
145
+ }
146
+ if (reservedKeys.size === 0) {
147
+ return baseReason;
148
+ }
149
+ return `${baseReason}; backend-reserved metadata keys excluded: ${[...reservedKeys].sort((left, right) => left.localeCompare(right)).join(', ')}`;
150
+ }
151
+ function waitForMutationSlot() {
152
+ return new Promise((resolve) => setTimeout(resolve, 100));
153
+ }
154
+ function scheduleExclusionReason(schedule) {
155
+ if (schedule.systemManaged === true) {
156
+ return 'system-managed schedule is excluded';
157
+ }
158
+ const materializedViewRefresh = schedule.targetType === 'PLATFORM_OPERATION'
159
+ && isRecord(schedule.targetConfig)
160
+ && schedule.targetConfig.kind === 'refresh_materialized_view';
161
+ if (schedule.targetType !== 'JOB_TEMPLATE' && !materializedViewRefresh) {
162
+ return `target type ${String(schedule.targetType || 'unknown')} is outside Schedule migration scope`;
163
+ }
164
+ return null;
165
+ }
166
+ function eventExclusionReason(event) {
167
+ if (event.deletedAt || event.deletedBy) {
168
+ return 'deleted event is excluded';
169
+ }
170
+ if (event.targetType !== 'JOB_TEMPLATE') {
171
+ return `target type ${String(event.targetType || 'unknown')} is outside Event migration scope`;
172
+ }
173
+ return null;
174
+ }
175
+ function endpointExclusionReason(endpoint) {
176
+ return endpoint.deletedAt || endpoint.deletedBy ? 'deleted endpoint is excluded' : null;
177
+ }
178
+ function buildMetadataUpdatePayload(resource, stableKeyName, stableKey, resourceType) {
179
+ if (!Number.isInteger(resource.version)) {
180
+ throw new Error(`${resourceType} version is required to update metadata`);
181
+ }
182
+ return {
183
+ version: resource.version,
31
184
  metadata: {
32
- ...sanitizeUserMetadata(component.metadata),
185
+ ...sanitizeUserMetadata(resource.resourceMetadata),
186
+ ...sanitizeUserMetadata(resource.metaData),
187
+ ...sanitizeUserMetadata(resource.metadata),
33
188
  [stableKeyName]: stableKey,
34
189
  },
35
190
  };
36
191
  }
192
+ function databaseViewPrincipalIds(entries, property) {
193
+ if (!Array.isArray(entries)) {
194
+ throw new Error(`database view ${property === 'userId' ? 'users' : 'groups'} are required to preserve its access policy`);
195
+ }
196
+ return entries.map((entry) => {
197
+ const value = typeof entry === 'string'
198
+ ? entry
199
+ : isRecord(entry) && typeof entry[property] === 'string'
200
+ ? entry[property]
201
+ : undefined;
202
+ if (typeof value !== 'string' || !value.trim()) {
203
+ throw new Error(`database view ${property === 'userId' ? 'user' : 'group'} access policy contains an invalid principal`);
204
+ }
205
+ return value;
206
+ });
207
+ }
208
+ function databaseViewIndexesForUpdate(view) {
209
+ if (view.indexes === undefined) {
210
+ return undefined;
211
+ }
212
+ if (!Array.isArray(view.indexes)) {
213
+ throw new Error('materialized database view indexes are invalid and cannot be preserved safely');
214
+ }
215
+ return JSON.parse(JSON.stringify(view.indexes));
216
+ }
217
+ function buildDatabaseViewMetadataUpdatePayload(view, stableKeyName, stableKey) {
218
+ if (typeof view.name !== 'string' || !view.name.trim()) {
219
+ throw new Error('database view name is required for a full update');
220
+ }
221
+ if (view.type !== 'VIEW' && view.type !== 'MATERIALIZED_VIEW') {
222
+ throw new Error(`database view type ${String(view.type || 'unknown')} cannot be updated`);
223
+ }
224
+ if (view.definitionMode !== 'STRUCTURED' && view.definitionMode !== 'RAW_SQL') {
225
+ throw new Error(`database view definitionMode ${String(view.definitionMode || 'unknown')} cannot be updated`);
226
+ }
227
+ if (typeof view.restricted !== 'boolean') {
228
+ throw new Error('database view restricted access policy is required to preserve it');
229
+ }
230
+ if (!Number.isInteger(view.version) || view.version < 0) {
231
+ throw new Error('database view version is required for a full update');
232
+ }
233
+ if (view.type === 'MATERIALIZED_VIEW' && !Array.isArray(view.uniqueKey)) {
234
+ throw new Error('materialized database view uniqueKey is required to preserve its definition');
235
+ }
236
+ const payload = {
237
+ name: view.name,
238
+ ...(typeof view.category === 'string' ? { category: view.category } : {}),
239
+ ...(typeof view.desc === 'string'
240
+ ? { desc: view.desc }
241
+ : typeof view.description === 'string'
242
+ ? { desc: view.description }
243
+ : {}),
244
+ ...buildMetadataUpdatePayload(view, stableKeyName, stableKey, 'database-view'),
245
+ type: view.type,
246
+ definitionMode: view.definitionMode,
247
+ restricted: view.restricted,
248
+ users: databaseViewPrincipalIds(view.users, 'userId'),
249
+ groups: databaseViewPrincipalIds(view.groups, 'groupId'),
250
+ };
251
+ const indexes = view.type === 'MATERIALIZED_VIEW'
252
+ ? databaseViewIndexesForUpdate(view)
253
+ : undefined;
254
+ const materialized = view.type === 'MATERIALIZED_VIEW'
255
+ ? {
256
+ uniqueKey: view.uniqueKey,
257
+ ...(indexes === undefined ? {} : { indexes }),
258
+ }
259
+ : {};
260
+ if (view.definitionMode === 'STRUCTURED') {
261
+ if (!isRecord(view.request)) {
262
+ throw new Error('structured database view request is required for a full update');
263
+ }
264
+ return { ...payload, ...materialized, request: view.request };
265
+ }
266
+ if (typeof view.rawQuery !== 'string' || !view.rawQuery.trim()) {
267
+ throw new Error('raw SQL database view query is required for a full update');
268
+ }
269
+ return {
270
+ ...payload,
271
+ ...materialized,
272
+ rawQuery: view.rawQuery,
273
+ };
274
+ }
275
+ function buildJobTemplateMetadataUpdatePayload(jobTemplate, stableKeyName, stableKey) {
276
+ if (!Object.hasOwn(jobTemplate, 'options') || jobTemplate.options === undefined || jobTemplate.options === null) {
277
+ throw new Error(`Job-template ${resourceIdOf(jobTemplate) || 'unknown'} is missing options in the API response; refusing metadata update to preserve its configuration.`);
278
+ }
279
+ return {
280
+ ...buildMetadataUpdatePayload(jobTemplate, stableKeyName, stableKey, 'job-template'),
281
+ options: jobTemplate.options,
282
+ };
283
+ }
37
284
  export async function buildMetadataBackfillPlan(input) {
38
- const { client, envName, stableKeyName, trackedResources, force = false } = input;
285
+ const { client, envName, stableKeyName, trackedResources, force = false, skipPartitions = false } = input;
39
286
  const items = [];
40
287
  if (shouldTrackResource(trackedResources, 'component')) {
41
288
  const { components } = await client.listAllComponents();
42
289
  for (const summary of components) {
43
- const summaryId = componentIdOf(summary);
290
+ const summaryId = resourceIdOf(summary);
44
291
  if (!summaryId) {
45
292
  continue;
46
293
  }
47
- const component = unwrapComponent(await client.getComponent(summaryId));
48
- const resourceId = componentIdOf(component) || summaryId;
294
+ const component = unwrapResource(await client.getComponent(summaryId));
295
+ const resourceId = resourceIdOf(component) || summaryId;
49
296
  const existingStableKey = readConfiguredResourceMetadataStableKey(component, stableKeyName);
50
297
  const stableKey = existingStableKey && !force
51
298
  ? undefined
52
299
  : buildGeneratedStableKey(component);
300
+ const plannedStableKey = stableKey || existingStableKey || undefined;
53
301
  items.push({
54
302
  resourceType: 'component',
55
303
  resourceId,
@@ -58,19 +306,396 @@ export async function buildMetadataBackfillPlan(input) {
58
306
  action: existingStableKey && !force ? 'skip' : 'update',
59
307
  metadataField: 'metadata',
60
308
  stableKeyName,
61
- ...(stableKey ? { stableKey } : {}),
309
+ ...(plannedStableKey ? { stableKey: plannedStableKey } : {}),
62
310
  reason: existingStableKey
63
311
  ? (force ? 'stable key will be regenerated' : 'stable key already exists')
64
312
  : 'stable key missing',
65
313
  });
66
314
  }
67
315
  }
316
+ if (shouldTrackResource(trackedResources, 'database-schema') && typeof client.listAllDatabaseSchemas === 'function') {
317
+ const { databases } = await client.listAllDatabaseSchemas({ projection: 'pull', skipPartitions });
318
+ for (const database of databases) {
319
+ if (isDatabaseBackupArtifact(database)) {
320
+ continue;
321
+ }
322
+ const resourceId = resourceIdOf(database);
323
+ if (!resourceId) {
324
+ continue;
325
+ }
326
+ const category = normalizeValue(database.category);
327
+ const name = String(database.name || '');
328
+ const rawStableKey = rawConfiguredStableKey(database, stableKeyName);
329
+ const invalidDatabaseStableKey = rawStableKey.present && (typeof rawStableKey.value !== 'string' || !rawStableKey.value.trim());
330
+ if (invalidDatabaseStableKey) {
331
+ const reason = `Invalid database-schema stableKey on ${resourceId}: metadata.${stableKeyName} must be a non-empty string.`;
332
+ items.push({
333
+ resourceType: 'database-schema', resourceId, name, category, action: 'blocked',
334
+ metadataField: 'metadata', stableKeyName, reason,
335
+ });
336
+ }
337
+ else {
338
+ const existingStableKey = readConfiguredResourceMetadataStableKey(database, stableKeyName);
339
+ const stableKey = existingStableKey || buildNamedResourceGeneratedStableKey(database);
340
+ items.push({
341
+ resourceType: 'database-schema', resourceId, name, category,
342
+ action: existingStableKey ? 'skip' : 'update', metadataField: 'metadata', stableKeyName,
343
+ stableKey,
344
+ reason: existingStableKey ? 'stable key already exists' : 'assign-stable-key',
345
+ });
346
+ }
347
+ if (isDatabaseSchemaPartition(database)) {
348
+ continue;
349
+ }
350
+ const definitions = databaseDefinitions(database);
351
+ const columnsByStableKey = new Map();
352
+ const columnsByName = new Map();
353
+ const columnsByDefinitionId = new Map();
354
+ for (const definition of definitions) {
355
+ const columnName = typeof definition.name === 'string' ? definition.name.trim() : '';
356
+ if (columnName) {
357
+ columnsByName.set(columnName, [...(columnsByName.get(columnName) || []), definition]);
358
+ }
359
+ const columnDefinitionId = typeof definition.databaseDefinitionId === 'string'
360
+ ? definition.databaseDefinitionId.trim()
361
+ : '';
362
+ if (columnDefinitionId) {
363
+ columnsByDefinitionId.set(columnDefinitionId, [
364
+ ...(columnsByDefinitionId.get(columnDefinitionId) || []),
365
+ definition,
366
+ ]);
367
+ }
368
+ const rawColumnStableKey = columnStableKeyValue(definition, stableKeyName);
369
+ if (typeof rawColumnStableKey.value === 'string' && rawColumnStableKey.value.trim()) {
370
+ columnsByStableKey.set(rawColumnStableKey.value, [
371
+ ...(columnsByStableKey.get(rawColumnStableKey.value) || []),
372
+ { definition, name: columnName },
373
+ ]);
374
+ }
375
+ }
376
+ const duplicateColumnStableKeys = new Set([...columnsByStableKey]
377
+ .filter(([, columns]) => columns.length > 1)
378
+ .map(([stableKeyValue]) => stableKeyValue));
379
+ const duplicateColumnNames = new Set([...columnsByName]
380
+ .filter(([, columns]) => columns.length > 1)
381
+ .map(([columnName]) => columnName));
382
+ const duplicateColumnDefinitionIds = new Set([...columnsByDefinitionId]
383
+ .filter(([, columns]) => columns.length > 1)
384
+ .map(([columnDefinitionId]) => columnDefinitionId));
385
+ for (const definition of definitions) {
386
+ const columnName = typeof definition.name === 'string' ? definition.name.trim() : '';
387
+ const columnDefinitionId = typeof definition.databaseDefinitionId === 'string'
388
+ ? definition.databaseDefinitionId.trim()
389
+ : '';
390
+ const rawColumnStableKey = columnStableKeyValue(definition, stableKeyName);
391
+ const existingColumnStableKey = typeof rawColumnStableKey.value === 'string' && rawColumnStableKey.value.trim()
392
+ ? rawColumnStableKey.value
393
+ : undefined;
394
+ const duplicate = existingColumnStableKey && duplicateColumnStableKeys.has(existingColumnStableKey);
395
+ const duplicateName = columnName && duplicateColumnNames.has(columnName);
396
+ const duplicateDefinitionId = columnDefinitionId && duplicateColumnDefinitionIds.has(columnDefinitionId);
397
+ const invalid = rawColumnStableKey.present && !existingColumnStableKey;
398
+ const columnReason = !columnName
399
+ ? `Database column on ${resourceId} is missing a non-empty name; refusing to generate a Stable Key.`
400
+ : !columnDefinitionId
401
+ ? `Database column "${columnName}" on ${resourceId} is missing databaseDefinitionId; refusing to rewrite the full definition.`
402
+ : duplicateName
403
+ ? `Duplicate database column name "${columnName}" on root database schema ${resourceId}.`
404
+ : duplicateDefinitionId
405
+ ? `Duplicate database column databaseDefinitionId "${columnDefinitionId}" on root database schema ${resourceId}.`
406
+ : invalid
407
+ ? `Invalid database column stableKey on ${resourceId}.${columnName}: definition[].metadata.${stableKeyName} must be a non-empty string.`
408
+ : duplicate
409
+ ? `Duplicate database column stableKey "${existingColumnStableKey}" on root database schema ${resourceId}.`
410
+ : existingColumnStableKey
411
+ ? 'column stable key already exists'
412
+ : 'assign-column-stable-key';
413
+ items.push({
414
+ resourceType: 'database-schema',
415
+ resourceId,
416
+ name: `${name}.${columnName || '<unnamed>'}`,
417
+ category,
418
+ action: !columnName || !columnDefinitionId || duplicateName || duplicateDefinitionId || invalid || duplicate
419
+ ? 'blocked'
420
+ : existingColumnStableKey ? 'skip' : 'update',
421
+ metadataField: 'definition[].metadata',
422
+ stableKeyName,
423
+ ...(columnName && existingColumnStableKey ? { stableKey: existingColumnStableKey } : columnName ? {
424
+ stableKey: buildGeneratedDatabaseColumnStableKey(database, columnName),
425
+ } : {}),
426
+ ...(columnName ? { columnName } : {}),
427
+ ...(columnDefinitionId ? { columnDefinitionId } : {}),
428
+ reason: columnReason,
429
+ });
430
+ }
431
+ }
432
+ }
433
+ if (shouldTrackResource(trackedResources, 'database-view') && typeof client.listAllDatabaseViewsForSync === 'function') {
434
+ const views = await client.listAllDatabaseViewsForSync();
435
+ for (const view of views) {
436
+ const resourceId = resourceIdOf(view);
437
+ if (!resourceId) {
438
+ continue;
439
+ }
440
+ const category = normalizeValue(view.category);
441
+ const name = String(view.name || '');
442
+ const rawStableKey = rawConfiguredStableKey(view, stableKeyName);
443
+ if (rawStableKey.present && (typeof rawStableKey.value !== 'string' || !rawStableKey.value.trim())) {
444
+ const reason = `Invalid database-view stableKey on ${resourceId}: metadata.${stableKeyName} must be a non-empty string.`;
445
+ items.push({ resourceType: 'database-view', resourceId, name, category, action: 'blocked', metadataField: 'metadata', stableKeyName, reason });
446
+ continue;
447
+ }
448
+ const existingStableKey = readConfiguredResourceMetadataStableKey(view, stableKeyName);
449
+ const stableKey = existingStableKey || buildNamedResourceGeneratedStableKey(view);
450
+ items.push({
451
+ resourceType: 'database-view',
452
+ resourceId,
453
+ name,
454
+ category,
455
+ action: existingStableKey ? 'skip' : 'update',
456
+ metadataField: 'metadata',
457
+ stableKeyName,
458
+ stableKey,
459
+ reason: existingStableKey ? 'stable key already exists' : 'assign-stable-key',
460
+ });
461
+ }
462
+ }
463
+ if (shouldTrackResource(trackedResources, 'endpoint') && typeof client.listAllEndpoints === 'function') {
464
+ const { endpoints } = await client.listAllEndpoints();
465
+ for (const summary of endpoints) {
466
+ const summaryId = resourceIdOf(summary);
467
+ if (!summaryId) {
468
+ continue;
469
+ }
470
+ const endpoint = unwrapResource(await client.getEndpoint(summaryId));
471
+ const resourceId = resourceIdOf(endpoint) || summaryId;
472
+ const category = normalizeValue(endpoint.category);
473
+ const name = String(endpoint.name || '');
474
+ const exclusionReason = endpointExclusionReason(endpoint);
475
+ if (exclusionReason) {
476
+ items.push({
477
+ resourceType: 'endpoint', resourceId, name, category, action: 'skip', metadataField: 'metadata', stableKeyName,
478
+ reason: exclusionReason,
479
+ });
480
+ continue;
481
+ }
482
+ const rawStableKey = rawConfiguredStableKey(endpoint, stableKeyName);
483
+ if (rawStableKey.present && (typeof rawStableKey.value !== 'string' || !rawStableKey.value.trim())) {
484
+ const reason = `Invalid endpoint stableKey on ${resourceId}: metadata.${stableKeyName} must be a non-empty string.`;
485
+ items.push({ resourceType: 'endpoint', resourceId, name, category, action: 'blocked', metadataField: 'metadata', stableKeyName, reason });
486
+ continue;
487
+ }
488
+ const existingStableKey = readConfiguredResourceMetadataStableKey(endpoint, stableKeyName);
489
+ const stableKey = existingStableKey && !force ? undefined : buildNamedResourceGeneratedStableKey(endpoint);
490
+ const plannedStableKey = stableKey || existingStableKey || undefined;
491
+ items.push({
492
+ resourceType: 'endpoint', resourceId, name, category,
493
+ action: existingStableKey && !force ? 'skip' : 'update', metadataField: 'metadata', stableKeyName,
494
+ ...(plannedStableKey ? { stableKey: plannedStableKey } : {}),
495
+ reason: existingStableKey
496
+ ? (force ? 'stable key will be regenerated' : 'stable key already exists')
497
+ : 'stable key missing',
498
+ });
499
+ }
500
+ }
501
+ if (shouldTrackResource(trackedResources, 'role-group') && typeof client.listAllRoleGroups === 'function') {
502
+ const { roleGroups } = await client.listAllRoleGroups();
503
+ for (const summary of roleGroups) {
504
+ const summaryId = resourceIdOf(summary);
505
+ if (!summaryId) {
506
+ continue;
507
+ }
508
+ const roleGroup = unwrapResource(await client.getRoleGroup(summaryId));
509
+ const resourceId = resourceIdOf(roleGroup) || summaryId;
510
+ const existingStableKey = readConfiguredResourceMetadataStableKey(roleGroup, stableKeyName);
511
+ const stableKey = existingStableKey && !force
512
+ ? undefined
513
+ : buildGeneratedStableKey(roleGroup);
514
+ const plannedStableKey = stableKey || existingStableKey || undefined;
515
+ items.push({
516
+ resourceType: 'role-group',
517
+ resourceId,
518
+ name: String(roleGroup.name || ''),
519
+ category: normalizeValue(roleGroup.category),
520
+ action: existingStableKey && !force ? 'skip' : 'update',
521
+ metadataField: 'metadata',
522
+ stableKeyName,
523
+ ...(plannedStableKey ? { stableKey: plannedStableKey } : {}),
524
+ reason: existingStableKey
525
+ ? (force ? 'stable key will be regenerated' : 'stable key already exists')
526
+ : 'stable key missing',
527
+ });
528
+ }
529
+ }
530
+ if (shouldTrackResource(trackedResources, 'group') && typeof client.listAllGroups === 'function') {
531
+ const { groups } = await client.listAllGroups();
532
+ for (const summary of groups) {
533
+ const summaryId = resourceIdOf(summary);
534
+ if (!summaryId) {
535
+ continue;
536
+ }
537
+ const group = unwrapResource(await client.getGroup(summaryId));
538
+ const resourceId = resourceIdOf(group) || summaryId;
539
+ const existingStableKey = readConfiguredResourceMetadataStableKey(group, stableKeyName);
540
+ const stableKey = existingStableKey && !force
541
+ ? undefined
542
+ : buildNamedResourceGeneratedStableKey(group);
543
+ const plannedStableKey = stableKey || existingStableKey || undefined;
544
+ items.push({
545
+ resourceType: 'group',
546
+ resourceId,
547
+ name: String(group.name || ''),
548
+ category: normalizeValue(group.category),
549
+ action: existingStableKey && !force ? 'skip' : 'update',
550
+ metadataField: 'metadata',
551
+ stableKeyName,
552
+ ...(plannedStableKey ? { stableKey: plannedStableKey } : {}),
553
+ reason: groupMetadataBackfillReason(group, existingStableKey, force),
554
+ });
555
+ }
556
+ }
557
+ if (shouldTrackResource(trackedResources, 'job-template') && typeof client.listAllJobTemplates === 'function') {
558
+ const { jobTemplates } = await client.listAllJobTemplates();
559
+ for (const summary of jobTemplates) {
560
+ const summaryId = resourceIdOf(summary);
561
+ if (!summaryId)
562
+ continue;
563
+ const jobTemplate = unwrapResource(await client.getJobTemplate(summaryId));
564
+ const resourceId = resourceIdOf(jobTemplate) || summaryId;
565
+ const rawStableKey = rawConfiguredStableKey(jobTemplate, stableKeyName);
566
+ if (rawStableKey.present && (typeof rawStableKey.value !== 'string' || !rawStableKey.value.trim())) {
567
+ const reason = `Invalid job-template stableKey on ${resourceId}: metadata.${stableKeyName} must be a non-empty string.`;
568
+ items.push({
569
+ resourceType: 'job-template', resourceId, name: String(jobTemplate.name || ''), category: normalizeValue(jobTemplate.category),
570
+ action: 'blocked', metadataField: 'metadata', stableKeyName, reason,
571
+ });
572
+ continue;
573
+ }
574
+ const existingStableKey = readConfiguredResourceMetadataStableKey(jobTemplate, stableKeyName);
575
+ const stableKey = existingStableKey && !force ? undefined : buildNamedResourceGeneratedStableKey(jobTemplate);
576
+ const plannedStableKey = stableKey || existingStableKey || undefined;
577
+ items.push({
578
+ resourceType: 'job-template',
579
+ resourceId,
580
+ name: String(jobTemplate.name || ''),
581
+ category: normalizeValue(jobTemplate.category),
582
+ action: existingStableKey && !force ? 'skip' : 'update',
583
+ metadataField: 'metadata',
584
+ stableKeyName,
585
+ ...(plannedStableKey ? { stableKey: plannedStableKey } : {}),
586
+ reason: existingStableKey
587
+ ? (force ? 'stable key will be regenerated' : 'stable key already exists')
588
+ : 'stable key missing',
589
+ });
590
+ }
591
+ }
592
+ if (shouldTrackResource(trackedResources, 'schedule') && typeof client.listAllSchedules === 'function') {
593
+ const { schedules } = await client.listAllSchedules();
594
+ for (const summary of schedules) {
595
+ const summaryId = resourceIdOf(summary);
596
+ if (!summaryId) {
597
+ continue;
598
+ }
599
+ const schedule = unwrapResource(await client.getSchedule(summaryId));
600
+ const resourceId = resourceIdOf(schedule) || summaryId;
601
+ const category = normalizeValue(schedule.category);
602
+ const name = String(schedule.name || '');
603
+ const exclusionReason = scheduleExclusionReason(schedule);
604
+ if (exclusionReason) {
605
+ items.push({
606
+ resourceType: 'schedule', resourceId, name, category, action: 'skip', metadataField: 'metadata', stableKeyName,
607
+ reason: exclusionReason,
608
+ });
609
+ continue;
610
+ }
611
+ const rawStableKey = rawConfiguredStableKey(schedule, stableKeyName);
612
+ if (rawStableKey.present && (typeof rawStableKey.value !== 'string' || !rawStableKey.value.trim())) {
613
+ const reason = `Invalid schedule stableKey on ${resourceId}: metadata.${stableKeyName} must be a non-empty string.`;
614
+ items.push({ resourceType: 'schedule', resourceId, name, category, action: 'blocked', metadataField: 'metadata', stableKeyName, reason });
615
+ continue;
616
+ }
617
+ const existingStableKey = readConfiguredResourceMetadataStableKey(schedule, stableKeyName);
618
+ const stableKey = existingStableKey && !force ? undefined : buildNamedResourceGeneratedStableKey(schedule);
619
+ const plannedStableKey = stableKey || existingStableKey || undefined;
620
+ items.push({
621
+ resourceType: 'schedule', resourceId, name, category,
622
+ action: existingStableKey && !force ? 'skip' : 'update', metadataField: 'metadata', stableKeyName,
623
+ ...(plannedStableKey ? { stableKey: plannedStableKey } : {}),
624
+ reason: existingStableKey
625
+ ? (force ? 'stable key will be regenerated' : 'stable key already exists')
626
+ : 'stable key missing',
627
+ });
628
+ }
629
+ }
630
+ if (shouldTrackResource(trackedResources, 'event') && typeof client.listAllEvents === 'function') {
631
+ const { events } = await client.listAllEvents({ includeDeleted: true });
632
+ for (const summary of events) {
633
+ const summaryId = resourceIdOf(summary);
634
+ if (!summaryId) {
635
+ continue;
636
+ }
637
+ const event = unwrapResource(await client.getEvent(summaryId));
638
+ const resourceId = resourceIdOf(event) || summaryId;
639
+ const category = normalizeValue(event.category);
640
+ const name = String(event.name || '');
641
+ const exclusionReason = eventExclusionReason(event);
642
+ if (exclusionReason) {
643
+ items.push({
644
+ resourceType: 'event', resourceId, name, category, action: 'skip', metadataField: 'metadata', stableKeyName,
645
+ reason: exclusionReason,
646
+ });
647
+ continue;
648
+ }
649
+ const rawStableKey = rawConfiguredStableKey(event, stableKeyName);
650
+ if (rawStableKey.present && (typeof rawStableKey.value !== 'string' || !rawStableKey.value.trim())) {
651
+ const reason = `Invalid event stableKey on ${resourceId}: metadata.${stableKeyName} must be a non-empty string.`;
652
+ items.push({ resourceType: 'event', resourceId, name, category, action: 'blocked', metadataField: 'metadata', stableKeyName, reason });
653
+ continue;
654
+ }
655
+ const existingStableKey = readConfiguredResourceMetadataStableKey(event, stableKeyName);
656
+ const stableKey = existingStableKey && !force ? undefined : buildNamedResourceGeneratedStableKey(event);
657
+ const plannedStableKey = stableKey || existingStableKey || undefined;
658
+ items.push({
659
+ resourceType: 'event', resourceId, name, category,
660
+ action: existingStableKey && !force ? 'skip' : 'update', metadataField: 'metadata', stableKeyName,
661
+ ...(plannedStableKey ? { stableKey: plannedStableKey } : {}),
662
+ reason: existingStableKey
663
+ ? (force ? 'stable key will be regenerated' : 'stable key already exists')
664
+ : 'stable key missing',
665
+ });
666
+ }
667
+ }
668
+ const blockers = items.filter((item) => item.action === 'blocked').map((item) => item.reason);
669
+ const itemsByStableKey = new Map();
670
+ for (const item of items) {
671
+ if (item.metadataField !== 'metadata'
672
+ || !item.stableKey
673
+ || ((item.resourceType === 'schedule' || item.resourceType === 'event') && item.action === 'skip' && item.reason !== 'stable key already exists')) {
674
+ continue;
675
+ }
676
+ const identity = metadataStableKeyIdentity(item.resourceType, item.stableKey);
677
+ itemsByStableKey.set(identity, [...(itemsByStableKey.get(identity) || []), item]);
678
+ }
679
+ for (const duplicates of itemsByStableKey.values()) {
680
+ if (duplicates.length < 2) {
681
+ continue;
682
+ }
683
+ const stableKey = duplicates[0].stableKey;
684
+ const resourceType = duplicates[0].resourceType;
685
+ const reason = `Duplicate ${resourceType} stableKey "${stableKey}" on ${envName}.`;
686
+ blockers.push(reason);
687
+ for (const item of duplicates) {
688
+ item.action = 'blocked';
689
+ item.reason = reason;
690
+ }
691
+ }
68
692
  items.sort((left, right) => left.resourceType.localeCompare(right.resourceType)
69
693
  || buildStableKey({ name: left.name, category: left.category }).localeCompare(buildStableKey({ name: right.name, category: right.category }))
70
694
  || left.resourceId.localeCompare(right.resourceId));
71
695
  const counts = {
72
696
  update: items.filter((item) => item.action === 'update').length,
73
697
  skip: items.filter((item) => item.action === 'skip').length,
698
+ blocked: items.filter((item) => item.action === 'blocked').length,
74
699
  };
75
700
  return {
76
701
  schemaVersion: 1,
@@ -80,27 +705,366 @@ export async function buildMetadataBackfillPlan(input) {
80
705
  instance: client.instance,
81
706
  stableKeyName,
82
707
  trackedResources,
708
+ force,
709
+ ...(skipPartitions ? { skipPartitions: true } : {}),
83
710
  counts,
711
+ blockers: [...new Set(blockers)].sort((left, right) => left.localeCompare(right)),
84
712
  items,
85
713
  };
86
714
  }
87
715
  export async function applyMetadataBackfillPlan(input) {
88
716
  const { client, plan } = input;
717
+ if (client.baseUrl !== plan.baseUrl || client.instance !== plan.instance) {
718
+ throw new Error(`Metadata plan target ${plan.baseUrl} (${plan.instance}) does not match selected target ${client.baseUrl} (${client.instance || 'unknown'}).`);
719
+ }
720
+ if (plan.blockers.length > 0) {
721
+ throw new Error(`Metadata plan has ${plan.blockers.length} blocking validation error(s): ${plan.blockers.join(' ')}`);
722
+ }
723
+ if (plan.items.some((item) => item.action === 'blocked')) {
724
+ throw new Error('Metadata plan contains blocked items and cannot be applied.');
725
+ }
726
+ const currentResources = new Map();
727
+ const resourceTypes = new Set(plan.items.map((item) => item.resourceType));
728
+ if (resourceTypes.has('component')) {
729
+ const { components } = await client.listAllComponents();
730
+ for (const summary of components) {
731
+ const id = resourceIdOf(summary);
732
+ if (id)
733
+ currentResources.set(`component:${id}`, unwrapResource(await client.getComponent(id)));
734
+ }
735
+ }
736
+ if (resourceTypes.has('database-schema')) {
737
+ const { databases } = await client.listAllDatabaseSchemas({
738
+ projection: 'pull',
739
+ skipPartitions: plan.skipPartitions,
740
+ });
741
+ for (const database of databases) {
742
+ if (isDatabaseBackupArtifact(database)) {
743
+ continue;
744
+ }
745
+ const id = resourceIdOf(database);
746
+ if (id) {
747
+ currentResources.set(`database-schema:${id}`, database);
748
+ }
749
+ }
750
+ }
751
+ if (resourceTypes.has('database-view')) {
752
+ const views = await client.listAllDatabaseViewsForSync();
753
+ for (const view of views) {
754
+ const id = resourceIdOf(view);
755
+ if (id) {
756
+ currentResources.set(`database-view:${id}`, view);
757
+ }
758
+ }
759
+ }
760
+ if (resourceTypes.has('endpoint')) {
761
+ const { endpoints } = await client.listAllEndpoints();
762
+ for (const summary of endpoints) {
763
+ const id = resourceIdOf(summary);
764
+ if (id) {
765
+ currentResources.set(`endpoint:${id}`, unwrapResource(await client.getEndpoint(id)));
766
+ }
767
+ }
768
+ }
769
+ if (resourceTypes.has('role-group')) {
770
+ const { roleGroups } = await client.listAllRoleGroups();
771
+ for (const summary of roleGroups) {
772
+ const id = resourceIdOf(summary);
773
+ if (id)
774
+ currentResources.set(`role-group:${id}`, unwrapResource(await client.getRoleGroup(id)));
775
+ }
776
+ }
777
+ if (resourceTypes.has('group')) {
778
+ const { groups } = await client.listAllGroups();
779
+ for (const summary of groups) {
780
+ const id = resourceIdOf(summary);
781
+ if (id) {
782
+ currentResources.set(`group:${id}`, unwrapResource(await client.getGroup(id)));
783
+ }
784
+ }
785
+ }
786
+ if (resourceTypes.has('job-template')) {
787
+ const { jobTemplates } = await client.listAllJobTemplates();
788
+ for (const summary of jobTemplates) {
789
+ const id = resourceIdOf(summary);
790
+ if (id)
791
+ currentResources.set(`job-template:${id}`, unwrapResource(await client.getJobTemplate(id)));
792
+ }
793
+ }
794
+ if (resourceTypes.has('schedule')) {
795
+ const { schedules } = await client.listAllSchedules();
796
+ for (const summary of schedules) {
797
+ const id = resourceIdOf(summary);
798
+ if (id) {
799
+ currentResources.set(`schedule:${id}`, unwrapResource(await client.getSchedule(id)));
800
+ }
801
+ }
802
+ }
803
+ if (resourceTypes.has('event')) {
804
+ const { events } = await client.listAllEvents({ includeDeleted: true });
805
+ for (const summary of events) {
806
+ const id = resourceIdOf(summary);
807
+ if (id) {
808
+ currentResources.set(`event:${id}`, unwrapResource(await client.getEvent(id)));
809
+ }
810
+ }
811
+ }
812
+ const stableKeyOwners = new Map();
813
+ for (const [identity, resource] of currentResources) {
814
+ const resourceType = identity.slice(0, identity.indexOf(':'));
815
+ if (resourceType === 'schedule' && scheduleExclusionReason(resource)) {
816
+ continue;
817
+ }
818
+ if (resourceType === 'event' && eventExclusionReason(resource)) {
819
+ continue;
820
+ }
821
+ if (resourceType === 'endpoint' && endpointExclusionReason(resource)) {
822
+ continue;
823
+ }
824
+ const stableKey = readConfiguredResourceMetadataStableKey(resource, plan.stableKeyName);
825
+ if (!stableKey)
826
+ continue;
827
+ const key = metadataStableKeyIdentity(resourceType, stableKey);
828
+ if (stableKeyOwners.has(key))
829
+ throw new Error(`Duplicate ${resourceType} stableKey "${stableKey}" detected before apply.`);
830
+ stableKeyOwners.set(key, identity);
831
+ }
832
+ for (const item of plan.items.filter((candidate) => candidate.action === 'update' && candidate.stableKey)) {
833
+ if (item.metadataField !== 'metadata') {
834
+ continue;
835
+ }
836
+ const owner = stableKeyOwners.get(metadataStableKeyIdentity(item.resourceType, item.stableKey));
837
+ if (owner && owner !== `${item.resourceType}:${item.resourceId}`) {
838
+ throw new Error(`Planned ${item.resourceType} stableKey "${item.stableKey}" is already assigned to another resource.`);
839
+ }
840
+ }
841
+ if (!plan.force) {
842
+ for (const item of plan.items.filter((candidate) => (candidate.resourceType === 'endpoint'
843
+ || candidate.resourceType === 'job-template'
844
+ || candidate.resourceType === 'schedule'
845
+ || candidate.resourceType === 'event') && candidate.action === 'update')) {
846
+ const resource = currentResources.get(`${item.resourceType}:${item.resourceId}`);
847
+ if (!resource) {
848
+ continue;
849
+ }
850
+ const rawStableKey = rawConfiguredStableKey(resource, plan.stableKeyName);
851
+ if (rawStableKey.present && (typeof rawStableKey.value !== 'string' || !rawStableKey.value.trim())) {
852
+ throw new Error(`Invalid ${item.resourceType} stableKey appeared before apply on ${item.resourceId}; refusing to overwrite it without --force.`);
853
+ }
854
+ }
855
+ }
89
856
  const results = [];
857
+ let databaseMutationStarted = false;
858
+ const columnUpdatesByDatabase = new Map();
859
+ for (const item of plan.items) {
860
+ if (item.resourceType === 'database-schema' && item.metadataField === 'definition[].metadata' && item.action === 'update') {
861
+ columnUpdatesByDatabase.set(item.resourceId, [
862
+ ...(columnUpdatesByDatabase.get(item.resourceId) || []),
863
+ item,
864
+ ]);
865
+ }
866
+ }
867
+ const processedColumnUpdateDatabases = new Set();
90
868
  for (const item of plan.items) {
91
869
  if (item.action === 'skip') {
92
870
  results.push({
93
871
  resourceType: item.resourceType,
94
872
  resourceId: item.resourceId,
873
+ ...(item.columnName ? { columnName: item.columnName } : {}),
874
+ ...(item.stableKey ? { stableKey: item.stableKey } : {}),
95
875
  action: 'skip',
96
876
  });
97
877
  continue;
98
878
  }
879
+ if (item.action !== 'update') {
880
+ throw new Error(`Unsupported metadata plan action: ${item.action}.`);
881
+ }
99
882
  try {
100
883
  if (item.resourceType === 'component') {
101
- const component = unwrapComponent(await client.getComponent(item.resourceId));
884
+ const component = currentResources.get(`component:${item.resourceId}`);
885
+ if (!component)
886
+ throw new Error('component no longer exists');
887
+ const currentStableKey = readConfiguredResourceMetadataStableKey(component, plan.stableKeyName);
888
+ if (currentStableKey && !plan.force) {
889
+ results.push({ resourceType: item.resourceType, resourceId: item.resourceId, stableKey: currentStableKey, action: 'skip' });
890
+ continue;
891
+ }
102
892
  const stableKey = item.stableKey || buildGeneratedStableKey(component);
103
- await client.updateComponent(item.resourceId, buildComponentMetadataUpdatePayload(component, plan.stableKeyName, stableKey));
893
+ await client.updateComponent(item.resourceId, buildMetadataUpdatePayload(component, plan.stableKeyName, stableKey, item.resourceType));
894
+ results.push({
895
+ resourceType: item.resourceType,
896
+ resourceId: item.resourceId,
897
+ stableKey,
898
+ action: 'update',
899
+ });
900
+ continue;
901
+ }
902
+ if (item.resourceType === 'database-schema') {
903
+ const listed = currentResources.get(`database-schema:${item.resourceId}`);
904
+ if (!listed) {
905
+ throw new Error('database schema no longer exists');
906
+ }
907
+ if (item.metadataField === 'definition[].metadata' && processedColumnUpdateDatabases.has(item.resourceId)) {
908
+ continue;
909
+ }
910
+ const live = unwrapResource(await client.getDatabaseSchema(item.resourceId));
911
+ if (item.metadataField === 'definition[].metadata') {
912
+ if (isDatabaseSchemaPartition(live)) {
913
+ throw new Error('database schema became a partition; column stable keys are inherited from the root');
914
+ }
915
+ const assignments = [];
916
+ for (const columnItem of columnUpdatesByDatabase.get(item.resourceId) || []) {
917
+ const columnName = columnItem.columnName;
918
+ const columnDefinitionId = columnItem.columnDefinitionId;
919
+ if (!columnName || !columnDefinitionId) {
920
+ throw new Error(`database schema column metadata plan for ${item.resourceId} is missing its live identity`);
921
+ }
922
+ const definitions = databaseDefinitions(live).filter((candidate) => candidate.databaseDefinitionId === columnDefinitionId);
923
+ if (definitions.length !== 1 || definitions[0].name !== columnName) {
924
+ throw new Error(`database schema column "${columnName}" no longer matches databaseDefinitionId "${columnDefinitionId}"`);
925
+ }
926
+ const definition = definitions[0];
927
+ const rawColumnStableKey = columnStableKeyValue(definition, plan.stableKeyName);
928
+ if (typeof rawColumnStableKey.value === 'string' && rawColumnStableKey.value.trim()) {
929
+ results.push({
930
+ resourceType: columnItem.resourceType,
931
+ resourceId: columnItem.resourceId,
932
+ columnName,
933
+ stableKey: rawColumnStableKey.value,
934
+ action: 'skip',
935
+ });
936
+ continue;
937
+ }
938
+ if (rawColumnStableKey.present) {
939
+ throw new Error(`Invalid database column stableKey appeared before apply on ${item.resourceId}.${columnName}; refusing to overwrite it.`);
940
+ }
941
+ assignments.push({
942
+ item: columnItem,
943
+ definition,
944
+ stableKey: columnItem.stableKey || buildGeneratedDatabaseColumnStableKey(live, columnName),
945
+ });
946
+ }
947
+ processedColumnUpdateDatabases.add(item.resourceId);
948
+ if (assignments.length === 0) {
949
+ continue;
950
+ }
951
+ if (databaseMutationStarted) {
952
+ await waitForMutationSlot();
953
+ }
954
+ databaseMutationStarted = true;
955
+ await client.updateDatabaseSchema(item.resourceId, buildDatabaseColumnMetadataUpdatePayload({
956
+ database: live,
957
+ assignments,
958
+ stableKeyName: plan.stableKeyName,
959
+ }));
960
+ for (const assignment of assignments) {
961
+ results.push({
962
+ resourceType: assignment.item.resourceType,
963
+ resourceId: assignment.item.resourceId,
964
+ columnName: assignment.item.columnName,
965
+ stableKey: assignment.stableKey,
966
+ action: 'update',
967
+ });
968
+ }
969
+ continue;
970
+ }
971
+ const currentStableKey = readConfiguredResourceMetadataStableKey(live, plan.stableKeyName);
972
+ if (currentStableKey) {
973
+ results.push({ resourceType: item.resourceType, resourceId: item.resourceId, stableKey: currentStableKey, action: 'skip' });
974
+ continue;
975
+ }
976
+ const rawStableKey = rawConfiguredStableKey(live, plan.stableKeyName);
977
+ if (rawStableKey.present) {
978
+ throw new Error(`Invalid database-schema stableKey appeared before apply on ${item.resourceId}; refusing to overwrite it.`);
979
+ }
980
+ if (databaseMutationStarted) {
981
+ await waitForMutationSlot();
982
+ }
983
+ databaseMutationStarted = true;
984
+ const stableKey = item.stableKey || buildNamedResourceGeneratedStableKey(live);
985
+ await client.updateDatabaseSchemaMetadata(item.resourceId, buildMetadataUpdatePayload(live, plan.stableKeyName, stableKey, item.resourceType));
986
+ results.push({ resourceType: item.resourceType, resourceId: item.resourceId, stableKey, action: 'update' });
987
+ continue;
988
+ }
989
+ if (item.resourceType === 'database-view') {
990
+ const view = currentResources.get(`database-view:${item.resourceId}`);
991
+ if (!view) {
992
+ throw new Error('database view no longer exists');
993
+ }
994
+ const currentStableKey = readConfiguredResourceMetadataStableKey(view, plan.stableKeyName);
995
+ if (currentStableKey) {
996
+ results.push({ resourceType: item.resourceType, resourceId: item.resourceId, stableKey: currentStableKey, action: 'skip' });
997
+ continue;
998
+ }
999
+ const rawStableKey = rawConfiguredStableKey(view, plan.stableKeyName);
1000
+ if (rawStableKey.present) {
1001
+ throw new Error(`Invalid database-view stableKey appeared before apply on ${item.resourceId}; refusing to overwrite it.`);
1002
+ }
1003
+ const stableKey = item.stableKey || buildNamedResourceGeneratedStableKey(view);
1004
+ await client.updateDatabaseView(item.resourceId, buildDatabaseViewMetadataUpdatePayload(view, plan.stableKeyName, stableKey));
1005
+ const verified = unwrapResource(await client.getDatabaseView(item.resourceId));
1006
+ const verifiedStableKey = readConfiguredResourceMetadataStableKey(verified, plan.stableKeyName);
1007
+ if (verifiedStableKey !== stableKey) {
1008
+ throw new Error('updated database view did not retain the planned stable key');
1009
+ }
1010
+ results.push({ resourceType: item.resourceType, resourceId: item.resourceId, stableKey, action: 'update' });
1011
+ continue;
1012
+ }
1013
+ if (item.resourceType === 'endpoint') {
1014
+ const endpoint = currentResources.get(`endpoint:${item.resourceId}`);
1015
+ if (!endpoint) {
1016
+ throw new Error('endpoint no longer exists');
1017
+ }
1018
+ const exclusionReason = endpointExclusionReason(endpoint);
1019
+ if (exclusionReason) {
1020
+ throw new Error(`endpoint moved outside migration scope: ${exclusionReason}`);
1021
+ }
1022
+ const currentStableKey = readConfiguredResourceMetadataStableKey(endpoint, plan.stableKeyName);
1023
+ if (currentStableKey && !plan.force) {
1024
+ results.push({ resourceType: item.resourceType, resourceId: item.resourceId, stableKey: currentStableKey, action: 'skip' });
1025
+ continue;
1026
+ }
1027
+ const stableKey = item.stableKey || buildNamedResourceGeneratedStableKey(endpoint);
1028
+ await client.updateEndpoint(item.resourceId, buildMetadataUpdatePayload(endpoint, plan.stableKeyName, stableKey, item.resourceType));
1029
+ const verifiedEndpoint = unwrapResource(await client.getEndpoint(item.resourceId));
1030
+ const verifiedStableKey = readConfiguredResourceMetadataStableKey(verifiedEndpoint, plan.stableKeyName);
1031
+ if (verifiedStableKey !== stableKey) {
1032
+ throw new Error('updated endpoint did not retain the planned stable key');
1033
+ }
1034
+ results.push({ resourceType: item.resourceType, resourceId: item.resourceId, stableKey, action: 'update' });
1035
+ continue;
1036
+ }
1037
+ if (item.resourceType === 'role-group') {
1038
+ const roleGroup = currentResources.get(`role-group:${item.resourceId}`);
1039
+ if (!roleGroup)
1040
+ throw new Error('role-group no longer exists');
1041
+ const currentStableKey = readConfiguredResourceMetadataStableKey(roleGroup, plan.stableKeyName);
1042
+ if (currentStableKey && !plan.force) {
1043
+ results.push({ resourceType: item.resourceType, resourceId: item.resourceId, stableKey: currentStableKey, action: 'skip' });
1044
+ continue;
1045
+ }
1046
+ const stableKey = item.stableKey || buildGeneratedStableKey(roleGroup);
1047
+ await client.updateRoleGroup(item.resourceId, buildMetadataUpdatePayload(roleGroup, plan.stableKeyName, stableKey, item.resourceType));
1048
+ results.push({
1049
+ resourceType: item.resourceType,
1050
+ resourceId: item.resourceId,
1051
+ stableKey,
1052
+ action: 'update',
1053
+ });
1054
+ continue;
1055
+ }
1056
+ if (item.resourceType === 'group') {
1057
+ const group = currentResources.get(`group:${item.resourceId}`);
1058
+ if (!group) {
1059
+ throw new Error('group no longer exists');
1060
+ }
1061
+ const currentStableKey = readConfiguredResourceMetadataStableKey(group, plan.stableKeyName);
1062
+ if (currentStableKey && !plan.force) {
1063
+ results.push({ resourceType: item.resourceType, resourceId: item.resourceId, stableKey: currentStableKey, action: 'skip' });
1064
+ continue;
1065
+ }
1066
+ const stableKey = item.stableKey || buildNamedResourceGeneratedStableKey(group);
1067
+ await client.updateGroup(item.resourceId, buildMetadataUpdatePayload(group, plan.stableKeyName, stableKey, item.resourceType));
104
1068
  results.push({
105
1069
  resourceType: item.resourceType,
106
1070
  resourceId: item.resourceId,
@@ -109,6 +1073,58 @@ export async function applyMetadataBackfillPlan(input) {
109
1073
  });
110
1074
  continue;
111
1075
  }
1076
+ if (item.resourceType === 'job-template') {
1077
+ const jobTemplate = currentResources.get(`job-template:${item.resourceId}`);
1078
+ if (!jobTemplate)
1079
+ throw new Error('job-template no longer exists');
1080
+ const currentStableKey = readConfiguredResourceMetadataStableKey(jobTemplate, plan.stableKeyName);
1081
+ if (currentStableKey && !plan.force) {
1082
+ results.push({ resourceType: item.resourceType, resourceId: item.resourceId, stableKey: currentStableKey, action: 'skip' });
1083
+ continue;
1084
+ }
1085
+ const stableKey = item.stableKey || buildNamedResourceGeneratedStableKey(jobTemplate);
1086
+ await client.updateJobTemplate(item.resourceId, buildJobTemplateMetadataUpdatePayload(jobTemplate, plan.stableKeyName, stableKey));
1087
+ results.push({ resourceType: item.resourceType, resourceId: item.resourceId, stableKey, action: 'update' });
1088
+ continue;
1089
+ }
1090
+ if (item.resourceType === 'schedule') {
1091
+ const schedule = currentResources.get(`schedule:${item.resourceId}`);
1092
+ if (!schedule) {
1093
+ throw new Error('schedule no longer exists');
1094
+ }
1095
+ const exclusionReason = scheduleExclusionReason(schedule);
1096
+ if (exclusionReason) {
1097
+ throw new Error(`schedule moved outside migration scope: ${exclusionReason}`);
1098
+ }
1099
+ const currentStableKey = readConfiguredResourceMetadataStableKey(schedule, plan.stableKeyName);
1100
+ if (currentStableKey && !plan.force) {
1101
+ results.push({ resourceType: item.resourceType, resourceId: item.resourceId, stableKey: currentStableKey, action: 'skip' });
1102
+ continue;
1103
+ }
1104
+ const stableKey = item.stableKey || buildNamedResourceGeneratedStableKey(schedule);
1105
+ await client.updateSchedule(item.resourceId, buildMetadataUpdatePayload(schedule, plan.stableKeyName, stableKey, item.resourceType));
1106
+ results.push({ resourceType: item.resourceType, resourceId: item.resourceId, stableKey, action: 'update' });
1107
+ continue;
1108
+ }
1109
+ if (item.resourceType === 'event') {
1110
+ const event = currentResources.get(`event:${item.resourceId}`);
1111
+ if (!event) {
1112
+ throw new Error('event no longer exists');
1113
+ }
1114
+ const exclusionReason = eventExclusionReason(event);
1115
+ if (exclusionReason) {
1116
+ throw new Error(`event moved outside migration scope: ${exclusionReason}`);
1117
+ }
1118
+ const currentStableKey = readConfiguredResourceMetadataStableKey(event, plan.stableKeyName);
1119
+ if (currentStableKey && !plan.force) {
1120
+ results.push({ resourceType: item.resourceType, resourceId: item.resourceId, stableKey: currentStableKey, action: 'skip' });
1121
+ continue;
1122
+ }
1123
+ const stableKey = item.stableKey || buildNamedResourceGeneratedStableKey(event);
1124
+ await client.updateEvent(item.resourceId, buildMetadataUpdatePayload(event, plan.stableKeyName, stableKey, item.resourceType));
1125
+ results.push({ resourceType: item.resourceType, resourceId: item.resourceId, stableKey, action: 'update' });
1126
+ continue;
1127
+ }
112
1128
  throw new Error(`unsupported resource type: ${item.resourceType}`);
113
1129
  }
114
1130
  catch (error) {
@@ -116,10 +1132,45 @@ export async function applyMetadataBackfillPlan(input) {
116
1132
  resourceType: item.resourceType,
117
1133
  resourceId: item.resourceId,
118
1134
  stableKey: item.stableKey,
1135
+ ...(item.columnName ? { columnName: item.columnName } : {}),
119
1136
  action: 'failed',
120
1137
  error: error instanceof Error ? error.message : String(error),
121
1138
  });
122
1139
  }
123
1140
  }
1141
+ if (resourceTypes.has('database-schema')) {
1142
+ try {
1143
+ const { databases } = await client.listAllDatabaseSchemas({
1144
+ projection: 'pull',
1145
+ skipPartitions: plan.skipPartitions,
1146
+ });
1147
+ const verified = new Map(databases
1148
+ .filter((database) => !isDatabaseBackupArtifact(database))
1149
+ .map((database) => [resourceIdOf(database), database]));
1150
+ for (const result of results.filter((candidate) => candidate.resourceType === 'database-schema' && candidate.action === 'update')) {
1151
+ const database = verified.get(result.resourceId);
1152
+ const definition = result.columnName && database
1153
+ ? databaseDefinitions(database).find((candidate) => candidate.name === result.columnName)
1154
+ : undefined;
1155
+ const stableKey = result.columnName
1156
+ ? (definition
1157
+ ? columnStableKeyValue(definition, plan.stableKeyName).value
1158
+ : null)
1159
+ : (database
1160
+ ? readConfiguredResourceMetadataStableKey(database, plan.stableKeyName)
1161
+ : null);
1162
+ if (stableKey !== result.stableKey) {
1163
+ result.action = 'failed';
1164
+ result.error = 'final verification did not find the assigned stable key';
1165
+ }
1166
+ }
1167
+ }
1168
+ catch (error) {
1169
+ for (const result of results.filter((candidate) => candidate.resourceType === 'database-schema' && candidate.action === 'update')) {
1170
+ result.action = 'failed';
1171
+ result.error = `final verification failed: ${error instanceof Error ? error.message : String(error)}`;
1172
+ }
1173
+ }
1174
+ }
124
1175
  return results;
125
1176
  }