@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
@@ -0,0 +1,2289 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { ApiError } from "../client.js";
4
+ import { hashStable, readComponentLock, writeComponentLock, } from "../component-lock.js";
5
+ import { excludeDatabaseBackupArtifacts, isDatabaseBackupArtifact } from "../database-schema-artifacts.js";
6
+ import { readConfiguredResourceMetadataStableKey, sanitizeUserMetadata } from "../resource-metadata.js";
7
+ import { readJsonFile, sanitizeSegment } from "../utils.js";
8
+ import { findManifestFiles, isRecord, localTargetPath, removeRelocatedManifest, requireProjectLayout, stableKeySuffix, toPortableRelativePath, unwrapData, writePulledManifest, } from "./util.js";
9
+ export const DATABASE_SCHEMAS_DIRECTORY = 'DatabaseSchemas';
10
+ export const DATABASE_SCHEMA_MANIFEST_FILE = 'database-schema.json';
11
+ const INSTANCE_FIELDS = new Set([
12
+ 'databaseId', 'databaseDefinitionId', 'id', 'version', 'revision', 'details', 'records',
13
+ 'createdAt', 'createdBy', 'lastUpdateAt', 'lastUpdateBy', 'updatedAt', 'updatedBy',
14
+ 'deletedAt', 'deletedBy', 'users', 'groups', 'restricted', 'audit', 'tags',
15
+ ]);
16
+ function databaseIdOf(record) {
17
+ return String(record.databaseId || record.id || '');
18
+ }
19
+ function label(record) {
20
+ const category = typeof record.category === 'string' && record.category.trim()
21
+ ? record.category.trim()
22
+ : 'Uncategorized';
23
+ return `${category}/${String(record.name || databaseIdOf(record) || 'unknown')}`;
24
+ }
25
+ function canonicalValue(value, omitInstanceFields = false) {
26
+ if (Array.isArray(value)) {
27
+ return value.map((entry) => canonicalValue(entry, omitInstanceFields));
28
+ }
29
+ if (!isRecord(value)) {
30
+ return value;
31
+ }
32
+ return Object.fromEntries(Object.keys(value)
33
+ .filter((key) => !omitInstanceFields || !INSTANCE_FIELDS.has(key))
34
+ .sort((left, right) => left.localeCompare(right))
35
+ .map((key) => [key, canonicalValue(value[key], omitInstanceFields)]));
36
+ }
37
+ function portableDefinition(value) {
38
+ const definition = canonicalValue(value, true);
39
+ if (!Object.hasOwn(definition, 'metadata')) {
40
+ return definition;
41
+ }
42
+ const metadata = sanitizeUserMetadata(definition.metadata);
43
+ if (Object.keys(metadata).length === 0) {
44
+ const { metadata: _metadata, ...withoutMetadata } = definition;
45
+ return withoutMetadata;
46
+ }
47
+ return {
48
+ ...definition,
49
+ metadata: canonicalValue(metadata),
50
+ };
51
+ }
52
+ function portableDefinitions(value) {
53
+ if (!Array.isArray(value)) {
54
+ return [];
55
+ }
56
+ return value.filter(isRecord).map((definition) => portableDefinition(definition));
57
+ }
58
+ function configuredStableKey(record, stableKeyName) {
59
+ return readConfiguredResourceMetadataStableKey(record, stableKeyName);
60
+ }
61
+ function rawStableKey(record, stableKeyName) {
62
+ for (const metadata of [record.metadata, record.metaData, record.resourceMetadata]) {
63
+ if (isRecord(metadata) && Object.hasOwn(metadata, stableKeyName)) {
64
+ return { present: true, value: metadata[stableKeyName] };
65
+ }
66
+ }
67
+ return { present: false, value: undefined };
68
+ }
69
+ function parentCandidates(record) {
70
+ const candidates = [];
71
+ if (typeof record.parentId === 'string' && record.parentId) {
72
+ candidates.push({ kind: 'id', value: record.parentId });
73
+ }
74
+ if (typeof record.parent === 'string' && record.parent) {
75
+ candidates.push({ kind: 'name', value: record.parent });
76
+ }
77
+ else if (isRecord(record.parent)) {
78
+ const id = record.parent.databaseId || record.parent.id;
79
+ if (typeof id === 'string' && id) {
80
+ candidates.push({ kind: 'id', value: id });
81
+ }
82
+ if (typeof record.parent.name === 'string' && record.parent.name) {
83
+ candidates.push({ kind: 'name', value: record.parent.name });
84
+ }
85
+ }
86
+ return candidates;
87
+ }
88
+ function hasForeignReferences(definition) {
89
+ return definition.some((column) => Array.isArray(column.foreignReferences) && column.foreignReferences.length > 0);
90
+ }
91
+ function manifestFromEntry(entry, stableKeyName) {
92
+ const record = entry.record;
93
+ const metadata = canonicalValue({
94
+ ...sanitizeUserMetadata(record.resourceMetadata),
95
+ ...sanitizeUserMetadata(record.metaData),
96
+ ...sanitizeUserMetadata(record.metadata),
97
+ [stableKeyName]: entry.stableKey,
98
+ });
99
+ const definition = Array.isArray(record.definition)
100
+ ? portableDefinitions(record.definition)
101
+ : [];
102
+ const partition = isRecord(record.partition)
103
+ ? canonicalValue(record.partition, true)
104
+ : undefined;
105
+ const common = {
106
+ stableKey: entry.stableKey,
107
+ name: String(record.name || ''),
108
+ ...(record.category === undefined ? {} : { category: record.category }),
109
+ ...(typeof record.desc === 'string' && record.desc
110
+ ? { description: record.desc }
111
+ : typeof record.description === 'string' && record.description
112
+ ? { description: record.description }
113
+ : {}),
114
+ metadata,
115
+ };
116
+ if (entry.parentStableKey) {
117
+ return {
118
+ kind: 'database-partition',
119
+ ...common,
120
+ parentStableKey: entry.parentStableKey,
121
+ ...(partition ? { partition } : {}),
122
+ ...(hasForeignReferences(definition) ? { migration: { foreignReferences: 'manual' } } : {}),
123
+ };
124
+ }
125
+ return {
126
+ kind: 'database-schema',
127
+ ...common,
128
+ ...(typeof record.type === 'string' ? { type: record.type } : {}),
129
+ definition,
130
+ ...(partition ? { partition } : {}),
131
+ ...(hasForeignReferences(definition) ? { migration: { foreignReferences: 'manual' } } : {}),
132
+ };
133
+ }
134
+ function portableDatabaseSchemaManifest(manifest) {
135
+ const common = {
136
+ stableKey: manifest.stableKey,
137
+ name: manifest.name,
138
+ ...(manifest.category === undefined ? {} : { category: manifest.category }),
139
+ ...(typeof manifest.description === 'string' && manifest.description ? { description: manifest.description } : {}),
140
+ metadata: canonicalValue(sanitizeUserMetadata(manifest.metadata)),
141
+ ...(manifest.migration === undefined
142
+ ? {}
143
+ : { migration: canonicalValue(manifest.migration) }),
144
+ };
145
+ if (manifest.kind === 'database-partition') {
146
+ return {
147
+ kind: manifest.kind,
148
+ ...common,
149
+ ...(manifest.parentStableKey === undefined ? {} : { parentStableKey: manifest.parentStableKey }),
150
+ ...(manifest.partition === undefined
151
+ ? {}
152
+ : { partition: canonicalValue(manifest.partition, true) }),
153
+ };
154
+ }
155
+ return {
156
+ kind: manifest.kind,
157
+ ...common,
158
+ ...(manifest.type === undefined ? {} : { type: manifest.type }),
159
+ ...(manifest.definition === undefined
160
+ ? {}
161
+ : { definition: portableDefinitions(manifest.definition) }),
162
+ ...(manifest.partition === undefined
163
+ ? {}
164
+ : { partition: canonicalValue(manifest.partition, true) }),
165
+ };
166
+ }
167
+ export function hashPortableDatabaseSchema(manifest) {
168
+ return hashStable(portableDatabaseSchemaManifest(manifest));
169
+ }
170
+ function rootManifestPath(workspaceRoot, manifest) {
171
+ const category = typeof manifest.category === 'string' && manifest.category.trim()
172
+ ? sanitizeSegment(manifest.category)
173
+ : 'Uncategorized';
174
+ return path.join(workspaceRoot, DATABASE_SCHEMAS_DIRECTORY, category, `${sanitizeSegment(manifest.name)}-${stableKeySuffix(manifest.stableKey)}`, DATABASE_SCHEMA_MANIFEST_FILE);
175
+ }
176
+ function pathIdentity(filePath) {
177
+ return path.resolve(filePath)
178
+ .normalize('NFC')
179
+ .split(path.sep)
180
+ .map((segment) => segment.replace(/[. ]+$/g, '').toLowerCase())
181
+ .join(path.sep);
182
+ }
183
+ function loadWorkspaceManifests(workspaceRoot) {
184
+ return findManifestFiles(path.join(workspaceRoot, DATABASE_SCHEMAS_DIRECTORY), DATABASE_SCHEMA_MANIFEST_FILE)
185
+ .map((manifestPath) => ({
186
+ manifestPath,
187
+ manifest: readJsonFile(manifestPath),
188
+ }))
189
+ .filter(({ manifest }) => !isDatabaseBackupArtifact(manifest));
190
+ }
191
+ function emptyPlanCounts() {
192
+ return {
193
+ clean: 0,
194
+ create: 0,
195
+ 'safe-update': 0,
196
+ 'remote-changed': 0,
197
+ conflict: 0,
198
+ 'missing-lock': 0,
199
+ 'orphan-root': 0,
200
+ 'orphan-partition': 0,
201
+ 'delete-partition': 0,
202
+ 'previously-deleted-partition': 0,
203
+ 'replacement-requires-two-phases': 0,
204
+ unmanaged: 0,
205
+ collision: 0,
206
+ reconciled: 0,
207
+ 'manual-schema-migration-required': 0,
208
+ 'access-policy-required': 0,
209
+ };
210
+ }
211
+ function physicalNameHint(value) {
212
+ return String(value.name || '').normalize('NFC');
213
+ }
214
+ function validatePlanManifest(manifest, stableKeyName) {
215
+ if (!isRecord(manifest)) {
216
+ return ['manifest must be an object'];
217
+ }
218
+ const errors = [];
219
+ if (manifest.kind !== 'database-schema' && manifest.kind !== 'database-partition') {
220
+ errors.push('kind must be database-schema or database-partition');
221
+ }
222
+ if (typeof manifest.stableKey !== 'string' || !manifest.stableKey.trim()) {
223
+ errors.push('stableKey must be a non-empty string');
224
+ }
225
+ if (typeof manifest.name !== 'string' || !manifest.name.trim()) {
226
+ errors.push('name must be a non-empty string');
227
+ }
228
+ if (!isRecord(manifest.metadata)) {
229
+ errors.push('metadata must be an object');
230
+ }
231
+ else if (typeof manifest.stableKey === 'string' && manifest.metadata[stableKeyName] !== manifest.stableKey) {
232
+ errors.push(`metadata.${stableKeyName} must match stableKey`);
233
+ }
234
+ if (manifest.kind === 'database-schema' && !Array.isArray(manifest.definition)) {
235
+ errors.push('database-schema definition must be an array');
236
+ }
237
+ else if (manifest.kind === 'database-schema' && Array.isArray(manifest.definition)) {
238
+ const names = manifest.definition.map((column) => isRecord(column) && typeof column.name === 'string' ? column.name : '');
239
+ if (names.some((name) => !name)) {
240
+ errors.push('every database column must have a non-empty exact name');
241
+ }
242
+ if (new Set(names).size !== names.length) {
243
+ errors.push('database column names must be unique');
244
+ }
245
+ if (manifest.parentStableKey !== undefined) {
246
+ errors.push('database-schema must not declare parentStableKey');
247
+ }
248
+ }
249
+ if (manifest.kind === 'database-partition') {
250
+ if (typeof manifest.parentStableKey !== 'string' || !manifest.parentStableKey.trim()) {
251
+ errors.push('database-partition parentStableKey must be a non-empty string');
252
+ }
253
+ if (!isRecord(manifest.partition)) {
254
+ errors.push('database-partition partition must be an object');
255
+ }
256
+ if (manifest.definition !== undefined) {
257
+ errors.push('database-partition must not duplicate the inherited definition');
258
+ }
259
+ if (manifest.type !== undefined) {
260
+ errors.push('database-partition must not declare an inherited type');
261
+ }
262
+ }
263
+ return errors;
264
+ }
265
+ function valuesEqual(left, right) {
266
+ return hashStable({ value: left }) === hashStable({ value: right });
267
+ }
268
+ function manualHint(field) {
269
+ if (field === 'name') {
270
+ return 'Create a replacement table with a new Stable Key, copy and verify the data, switch dependants, then retire the old table in a reviewed migration.';
271
+ }
272
+ if (field === 'parentStableKey' || field === 'partition') {
273
+ return 'Create a replacement partition with a new Stable Key, migrate and verify rows, then detach the old partition in a later forced prune phase.';
274
+ }
275
+ return 'Run an explicit reviewed DDL migration, estimate lock and table-rewrite impact, back up affected data, verify row counts and indexes, then reconcile the manifest.';
276
+ }
277
+ function safeFieldHint(field) {
278
+ if (field === 'definition') {
279
+ return 'Apply only the reviewed additive or presentation-only definition change; monitor the short schema lock and verify the resulting definition.';
280
+ }
281
+ return 'This portable metadata change does not rewrite table data; verify the live value and advance the selected environment lock after a successful push.';
282
+ }
283
+ function sizeRiskHint(size) {
284
+ if (!size) {
285
+ return undefined;
286
+ }
287
+ return `Observed target size: ${size.total} bytes total (${size.table} table, ${size.indexes} indexes, ${size.audit} audit). Use it to choose the maintenance window and timeout for scans, rewrites, and index work.`;
288
+ }
289
+ function columnName(column) {
290
+ return typeof column.name === 'string' ? column.name : '';
291
+ }
292
+ function rawColumnStableKey(column, stableKeyName) {
293
+ const metadata = column.metadata;
294
+ if (!isRecord(metadata) || !Object.hasOwn(metadata, stableKeyName)) {
295
+ return { present: false, value: undefined };
296
+ }
297
+ return { present: true, value: metadata[stableKeyName] };
298
+ }
299
+ function columnStableKey(column, stableKeyName) {
300
+ const raw = rawColumnStableKey(column, stableKeyName);
301
+ return typeof raw.value === 'string' && raw.value.trim() ? raw.value : null;
302
+ }
303
+ function columnStableKeyValidationChanges(columns, stableKeyName, side) {
304
+ const changes = [];
305
+ const byKey = new Map();
306
+ for (const column of columns) {
307
+ const name = columnName(column) || 'unknown';
308
+ const raw = rawColumnStableKey(column, stableKeyName);
309
+ if (raw.present && !columnStableKey(column, stableKeyName)) {
310
+ changes.push({
311
+ kind: 'column-changed',
312
+ field: `definition.${name}.metadata.${stableKeyName}`,
313
+ column: name,
314
+ risk: 'manual',
315
+ after: raw.value,
316
+ hint: `The ${side} column Stable Key must be a non-empty string. Repair it manually before any schema synchronization.`,
317
+ });
318
+ continue;
319
+ }
320
+ const key = columnStableKey(column, stableKeyName);
321
+ if (key) {
322
+ byKey.set(key, [...(byKey.get(key) || []), column]);
323
+ }
324
+ }
325
+ for (const [key, duplicates] of byKey) {
326
+ if (duplicates.length < 2) {
327
+ continue;
328
+ }
329
+ for (const column of duplicates) {
330
+ const name = columnName(column) || 'unknown';
331
+ changes.push({
332
+ kind: 'column-changed',
333
+ field: `definition.${name}.metadata.${stableKeyName}`,
334
+ column: name,
335
+ risk: 'manual',
336
+ after: key,
337
+ hint: `Duplicate ${side} column Stable Key "${key}". Assign unique column keys manually before any schema synchronization.`,
338
+ });
339
+ }
340
+ }
341
+ return changes;
342
+ }
343
+ function columnBoolean(column, names) {
344
+ return names.some((name) => column[name] === true);
345
+ }
346
+ function isSafeColumnAddition(column) {
347
+ const nullable = column.nullable === true || column.isNullable === true;
348
+ const hasDefault = column.default !== undefined && column.default !== null;
349
+ const constrained = columnBoolean(column, [
350
+ 'primary', 'isPrimary', 'primaryKey', 'isPrimaryKey',
351
+ 'unique', 'isUnique', 'index', 'indexed', 'isIndex', 'isIndexed',
352
+ ]);
353
+ const references = Array.isArray(column.foreignReferences) && column.foreignReferences.length > 0;
354
+ return nullable && !hasDefault && !constrained && !references;
355
+ }
356
+ function diffDefinitions(desired, current, stableKeyName) {
357
+ const changes = [];
358
+ changes.push(...columnStableKeyValidationChanges(desired, stableKeyName, 'desired'));
359
+ changes.push(...columnStableKeyValidationChanges(current, stableKeyName, 'target'));
360
+ const currentByName = new Map();
361
+ for (const column of current) {
362
+ const name = columnName(column);
363
+ currentByName.set(name, [...(currentByName.get(name) || []), column]);
364
+ }
365
+ const currentByStableKey = new Map();
366
+ const desiredByStableKey = new Map();
367
+ for (const column of current) {
368
+ const stableKey = columnStableKey(column, stableKeyName);
369
+ if (stableKey) {
370
+ currentByStableKey.set(stableKey, [...(currentByStableKey.get(stableKey) || []), column]);
371
+ }
372
+ }
373
+ for (const column of desired) {
374
+ const stableKey = columnStableKey(column, stableKeyName);
375
+ if (stableKey) {
376
+ desiredByStableKey.set(stableKey, [...(desiredByStableKey.get(stableKey) || []), column]);
377
+ }
378
+ }
379
+ const matchedCurrentByDesired = new Map();
380
+ const matchedDesired = new Set();
381
+ const matchedCurrent = new Set();
382
+ for (const desiredColumn of desired) {
383
+ const stableKey = columnStableKey(desiredColumn, stableKeyName);
384
+ const stableKeyMatches = stableKey ? currentByStableKey.get(stableKey) || [] : [];
385
+ const stableKeyIsUnique = stableKeyMatches.length === 1 && (desiredByStableKey.get(stableKey) || []).length === 1;
386
+ const namedMatches = currentByName.get(columnName(desiredColumn)) || [];
387
+ const namedMatch = namedMatches.length === 1 ? namedMatches[0] : undefined;
388
+ const canUseLegacyNameMatch = Boolean(namedMatch) && (!stableKey || !columnStableKey(namedMatch, stableKeyName));
389
+ const currentColumn = stableKeyIsUnique
390
+ ? stableKeyMatches[0]
391
+ : canUseLegacyNameMatch
392
+ ? namedMatch
393
+ : undefined;
394
+ if (!currentColumn || matchedCurrent.has(currentColumn)) {
395
+ continue;
396
+ }
397
+ matchedCurrentByDesired.set(desiredColumn, currentColumn);
398
+ matchedDesired.add(desiredColumn);
399
+ matchedCurrent.add(currentColumn);
400
+ }
401
+ const reportMissingStableKey = (desiredColumn, currentColumn) => {
402
+ const desiredStableKey = desiredColumn ? columnStableKey(desiredColumn, stableKeyName) : null;
403
+ const currentStableKey = currentColumn ? columnStableKey(currentColumn, stableKeyName) : null;
404
+ if (desiredStableKey && currentStableKey) {
405
+ return;
406
+ }
407
+ const name = columnName(desiredColumn || currentColumn || {});
408
+ if (!name) {
409
+ return;
410
+ }
411
+ const missingSides = [
412
+ ...(desiredStableKey ? [] : ['desired']),
413
+ ...(currentStableKey ? [] : ['target']),
414
+ ];
415
+ changes.push({
416
+ kind: 'column-stable-key-missing',
417
+ field: `definition.${name}.metadata.${stableKeyName}`,
418
+ column: name,
419
+ risk: 'operational',
420
+ before: currentStableKey || undefined,
421
+ after: desiredStableKey || undefined,
422
+ hint: `Column "${name}" is missing a Stable Key on the ${missingSides.join(' and ')} side. It remains non-blocking work in progress and uses exact-name matching until the source bootstrap assigns a unique key.`,
423
+ });
424
+ };
425
+ for (const [desiredColumn, currentColumn] of matchedCurrentByDesired) {
426
+ reportMissingStableKey(desiredColumn, currentColumn);
427
+ }
428
+ for (const column of desired) {
429
+ if (!matchedDesired.has(column)) {
430
+ reportMissingStableKey(column, undefined);
431
+ }
432
+ }
433
+ for (const column of current) {
434
+ if (!matchedCurrent.has(column)) {
435
+ reportMissingStableKey(undefined, column);
436
+ }
437
+ }
438
+ for (const column of desired) {
439
+ const name = columnName(column);
440
+ if (!name || matchedDesired.has(column)) {
441
+ continue;
442
+ }
443
+ const safe = isSafeColumnAddition(column);
444
+ const stableKey = columnStableKey(column, stableKeyName);
445
+ const controlledKeyedAddition = Boolean(stableKey
446
+ && (desiredByStableKey.get(stableKey) || []).length === 1
447
+ && (currentByStableKey.get(stableKey) || []).length === 0);
448
+ changes.push({
449
+ kind: 'column-added',
450
+ field: `definition.${name}`,
451
+ column: name,
452
+ risk: safe ? 'safe' : controlledKeyedAddition ? 'operational' : 'manual',
453
+ after: column,
454
+ hint: safe
455
+ ? 'Add this nullable column without a default, primary key, unique constraint, or index; this avoids a table rewrite, but still monitor the schema lock.'
456
+ : controlledKeyedAddition
457
+ ? 'Add this new column through its unique Stable Key. Review target size and existing rows, use an appropriate maintenance window for default backfill, validation, index work, or table rewrites, and verify the resulting definition.'
458
+ : 'Use an explicit DDL migration and staged backfill; adding a required/defaulted/indexed/unique column can scan or rewrite a large table and hold locks.',
459
+ });
460
+ }
461
+ for (const column of current) {
462
+ const name = columnName(column);
463
+ if (!name || matchedCurrent.has(column)) {
464
+ continue;
465
+ }
466
+ changes.push({
467
+ kind: 'column-removed',
468
+ field: `definition.${name}`,
469
+ column: name,
470
+ risk: 'manual',
471
+ before: column,
472
+ hint: 'Back up and verify the column data, update all dependants, and remove the column only through an explicit reviewed DDL migration; a drop is data loss.',
473
+ });
474
+ }
475
+ for (const [desiredColumn, currentColumn] of matchedCurrentByDesired) {
476
+ const name = columnName(desiredColumn);
477
+ const currentName = columnName(currentColumn);
478
+ if (!name || !currentName) {
479
+ continue;
480
+ }
481
+ if (name !== currentName) {
482
+ changes.push({
483
+ kind: 'column-renamed',
484
+ field: `definition.${currentName}.name`,
485
+ column: currentName,
486
+ risk: 'operational',
487
+ before: currentName,
488
+ after: name,
489
+ hint: `Rename column "${currentName}" to "${name}" through its shared column Stable Key. This keeps the target column identity, but requires a reviewed maintenance window and no active Database View dependency.`,
490
+ });
491
+ }
492
+ const desiredStableKey = columnStableKey(desiredColumn, stableKeyName);
493
+ const currentStableKey = columnStableKey(currentColumn, stableKeyName);
494
+ const uniquelyMatchedByStableKey = Boolean(desiredStableKey
495
+ && desiredStableKey === currentStableKey
496
+ && (desiredByStableKey.get(desiredStableKey) || []).length === 1
497
+ && (currentByStableKey.get(desiredStableKey) || []).length === 1);
498
+ const keys = [...new Set([...Object.keys(desiredColumn), ...Object.keys(currentColumn)])]
499
+ .filter((key) => key !== 'name')
500
+ .sort((left, right) => left.localeCompare(right));
501
+ for (const key of keys) {
502
+ if (valuesEqual(desiredColumn[key], currentColumn[key])) {
503
+ continue;
504
+ }
505
+ const presentationOnly = key === 'desc' || key === 'metadata';
506
+ const controlledDefinitionChange = key !== 'foreignReferences' && uniquelyMatchedByStableKey;
507
+ changes.push({
508
+ kind: 'column-changed',
509
+ field: `definition.${name}.${key}`,
510
+ column: name,
511
+ risk: presentationOnly ? 'safe' : controlledDefinitionChange ? 'operational' : 'manual',
512
+ before: currentColumn[key],
513
+ after: desiredColumn[key],
514
+ hint: presentationOnly
515
+ ? 'Column metadata is portable presentation metadata; update it without retyping, rebuilding, or changing constraints.'
516
+ : controlledDefinitionChange
517
+ ? `Update ${key} on this existing column through its shared Stable Key. Review data compatibility and target size, use an appropriate maintenance window for locks, scans, rewrites, validation, or index work, and verify the resulting definition.`
518
+ : `Review ${key} as an explicit DDL migration: estimate locks, scans, rewrites, index work, validation cost, and data-conversion risk before reconciling the manifest.`,
519
+ });
520
+ }
521
+ }
522
+ const desiredSharedOrder = desired
523
+ .filter((column) => matchedDesired.has(column))
524
+ .map(columnName);
525
+ const currentSharedOrder = current
526
+ .filter((column) => matchedCurrent.has(column))
527
+ .map(columnName);
528
+ if (!valuesEqual(desiredSharedOrder, currentSharedOrder)) {
529
+ changes.push({
530
+ kind: 'column-order',
531
+ field: 'definition.order',
532
+ risk: 'safe',
533
+ before: currentSharedOrder,
534
+ after: desiredSharedOrder,
535
+ hint: 'Column order is presentation-only in the portable contract; do not rebuild or rewrite the physical table for this change.',
536
+ });
537
+ }
538
+ return changes;
539
+ }
540
+ function diffDatabaseSchemas(desired, current, stableKeyName) {
541
+ const changes = [];
542
+ const immutableFields = ['kind', 'name', 'parentStableKey', 'partition'];
543
+ for (const field of immutableFields) {
544
+ if (valuesEqual(desired[field], current[field])) {
545
+ continue;
546
+ }
547
+ changes.push({
548
+ kind: 'immutable-field',
549
+ field,
550
+ risk: 'manual',
551
+ before: current[field],
552
+ after: desired[field],
553
+ hint: manualHint(field),
554
+ });
555
+ }
556
+ if (!valuesEqual(desired.type, current.type)) {
557
+ changes.push({
558
+ kind: 'immutable-field',
559
+ field: 'type',
560
+ risk: 'manual',
561
+ before: current.type,
562
+ after: desired.type,
563
+ hint: 'Changing the physical table kind can rebuild storage and dependencies; use a replacement table with a new Stable Key and an explicit data migration.',
564
+ });
565
+ }
566
+ for (const field of ['category', 'description', 'metadata']) {
567
+ if (valuesEqual(desired[field], current[field])) {
568
+ continue;
569
+ }
570
+ const cannotClearCategory = field === 'category' && (desired.category === undefined || desired.category === null);
571
+ changes.push({
572
+ kind: 'field-changed',
573
+ field,
574
+ risk: cannotClearCategory ? 'manual' : 'safe',
575
+ before: current[field],
576
+ after: desired[field],
577
+ hint: cannotClearCategory
578
+ ? 'The current Database API accepts a category value but cannot clear one. Clear it through the target UI or a reviewed backend migration, then reconcile the manifest.'
579
+ : safeFieldHint(field),
580
+ });
581
+ }
582
+ if (desired.kind === 'database-schema' && current.kind === 'database-schema') {
583
+ changes.push(...diffDefinitions(desired.definition || [], current.definition || [], stableKeyName));
584
+ }
585
+ const hasReferences = desired.migration?.foreignReferences === 'manual'
586
+ || current.migration?.foreignReferences === 'manual'
587
+ || hasForeignReferences(desired.definition || [])
588
+ || hasForeignReferences(current.definition || []);
589
+ const migrationMarkerChanged = !valuesEqual(desired.migration, current.migration);
590
+ if (hasReferences && (changes.length > 0 || migrationMarkerChanged)) {
591
+ changes.push({
592
+ kind: 'foreign-reference',
593
+ field: 'definition.foreignReferences',
594
+ risk: 'manual',
595
+ hint: 'Foreign-reference declarations are not enforced as SQL constraints by the API; deploy and verify the relationship manually before reconciling this table.',
596
+ });
597
+ }
598
+ return changes;
599
+ }
600
+ function partitionBoundsOverlap(left, right) {
601
+ if (!left || !right) {
602
+ return false;
603
+ }
604
+ const leftFrom = typeof left.from === 'string' ? left.from : undefined;
605
+ const leftTo = typeof left.to === 'string' ? left.to : undefined;
606
+ const rightFrom = typeof right.from === 'string' ? right.from : undefined;
607
+ const rightTo = typeof right.to === 'string' ? right.to : undefined;
608
+ if (leftFrom && leftTo && rightFrom && rightTo) {
609
+ return leftFrom < rightTo && rightFrom < leftTo;
610
+ }
611
+ const leftValues = Array.isArray(left.values) ? left.values : undefined;
612
+ const rightValues = Array.isArray(right.values) ? right.values : undefined;
613
+ if (leftValues && rightValues) {
614
+ return leftValues.some((value) => rightValues.some((candidate) => valuesEqual(value, candidate)));
615
+ }
616
+ return String(left.type || '').toUpperCase() === 'HASH' && String(right.type || '').toUpperCase() === 'HASH';
617
+ }
618
+ function targetPlanCatalog(records, stableKeyName) {
619
+ const migratableRecords = excludeDatabaseBackupArtifacts(records);
620
+ const recordsById = new Map(migratableRecords.map((record) => [databaseIdOf(record), record]));
621
+ const recordsByName = new Map();
622
+ for (const record of migratableRecords) {
623
+ const name = String(record.name || '');
624
+ recordsByName.set(name, [...(recordsByName.get(name) || []), record]);
625
+ }
626
+ const resolveParent = (record) => {
627
+ const references = parentCandidates(record);
628
+ if (references.length === 0) {
629
+ return { parentId: null, parent: undefined };
630
+ }
631
+ const parents = new Set();
632
+ for (const reference of references) {
633
+ if (reference.kind === 'id') {
634
+ const parent = recordsById.get(reference.value);
635
+ if (parent) {
636
+ parents.add(parent);
637
+ }
638
+ }
639
+ else {
640
+ for (const parent of recordsByName.get(reference.value) || []) {
641
+ parents.add(parent);
642
+ }
643
+ }
644
+ }
645
+ if (parents.size !== 1) {
646
+ return { parentId: '', parent: undefined };
647
+ }
648
+ const parent = [...parents][0];
649
+ return { parentId: databaseIdOf(parent), parent };
650
+ };
651
+ const managed = [];
652
+ const unmanaged = [];
653
+ for (const record of migratableRecords) {
654
+ const stableKey = configuredStableKey(record, stableKeyName);
655
+ if (!stableKey) {
656
+ unmanaged.push({ record, reason: `missing ${stableKeyName}; unmanaged WIP` });
657
+ continue;
658
+ }
659
+ const resolved = resolveParent(record);
660
+ let parentStableKey;
661
+ if (resolved.parentId !== null) {
662
+ parentStableKey = resolved.parent
663
+ ? configuredStableKey(resolved.parent, stableKeyName) || undefined
664
+ : undefined;
665
+ if (!resolved.parentId || !parentStableKey) {
666
+ unmanaged.push({ record, reason: `parent is missing or unmanaged; missing ${stableKeyName}` });
667
+ continue;
668
+ }
669
+ }
670
+ const entry = {
671
+ record,
672
+ id: databaseIdOf(record),
673
+ stableKey,
674
+ parentId: resolved.parentId,
675
+ ...(parentStableKey ? { parentStableKey } : {}),
676
+ };
677
+ const manifest = manifestFromEntry(entry, stableKeyName);
678
+ managed.push({ record, manifest, hash: hashPortableDatabaseSchema(manifest), databaseId: entry.id });
679
+ }
680
+ return { managed, unmanaged };
681
+ }
682
+ export async function buildDatabaseSchemasPlan(input) {
683
+ const { projectRoot, workspaceRoot } = requireProjectLayout(input.cwd, 'Database Schema');
684
+ const localEntries = loadWorkspaceManifests(workspaceRoot).filter((entry) => (input.skipPartitions !== true
685
+ || !isRecord(entry.manifest)
686
+ || entry.manifest.kind !== 'database-partition'));
687
+ const { databases } = await input.client.listAllDatabaseSchemas({
688
+ projection: 'pull',
689
+ skipPartitions: input.skipPartitions === true,
690
+ });
691
+ const target = targetPlanCatalog(databases, input.stableKeyName);
692
+ const stats = input.withStats ? await input.client.getDatabaseSchemaStats() : [];
693
+ const statsById = new Map(stats.map((entry) => [entry.databaseId, entry.size]));
694
+ const lock = readComponentLock(projectRoot);
695
+ const blockers = [];
696
+ const warnings = [];
697
+ const items = [];
698
+ let databasesWithActiveViewDependencies;
699
+ const localByKey = new Map();
700
+ const declaredLocalKeys = new Set();
701
+ for (const entry of localEntries) {
702
+ const stableKeyValue = isRecord(entry.manifest) ? entry.manifest.stableKey : undefined;
703
+ const missingStableKey = isRecord(entry.manifest)
704
+ && (!Object.hasOwn(entry.manifest, 'stableKey')
705
+ || stableKeyValue === null
706
+ || stableKeyValue === undefined
707
+ || (typeof stableKeyValue === 'string' && !stableKeyValue.trim()));
708
+ if (missingStableKey) {
709
+ items.push({
710
+ status: 'unmanaged',
711
+ name: typeof entry.manifest.name === 'string'
712
+ ? entry.manifest.name
713
+ : path.basename(path.dirname(entry.manifestPath)),
714
+ reason: `local WIP manifest is missing ${input.stableKeyName}`,
715
+ hints: ['This local WIP resource is non-blocking for ordinary planning; assign a Stable Key before it can be synchronized.'],
716
+ });
717
+ continue;
718
+ }
719
+ if (isRecord(entry.manifest) && typeof entry.manifest.stableKey === 'string' && entry.manifest.stableKey.trim()) {
720
+ declaredLocalKeys.add(entry.manifest.stableKey);
721
+ }
722
+ const errors = validatePlanManifest(entry.manifest, input.stableKeyName);
723
+ if (errors.length > 0) {
724
+ const relativePath = path.relative(projectRoot, entry.manifestPath);
725
+ blockers.push(`Invalid Database Schema manifest ${relativePath}: ${errors.join('; ')}.`);
726
+ items.push({
727
+ status: 'collision',
728
+ name: isRecord(entry.manifest) && typeof entry.manifest.name === 'string'
729
+ ? entry.manifest.name
730
+ : path.basename(path.dirname(entry.manifestPath)),
731
+ reason: 'invalid local manifest',
732
+ hints: ['Fix the manifest validation errors before planning or writing this resource.'],
733
+ });
734
+ continue;
735
+ }
736
+ localByKey.set(entry.manifest.stableKey, [...(localByKey.get(entry.manifest.stableKey) || []), entry]);
737
+ }
738
+ const targetByKey = new Map();
739
+ const managedTargetByName = new Map();
740
+ for (const entry of target.managed) {
741
+ targetByKey.set(entry.manifest.stableKey, [...(targetByKey.get(entry.manifest.stableKey) || []), entry]);
742
+ const name = physicalNameHint(entry.manifest);
743
+ managedTargetByName.set(name, [...(managedTargetByName.get(name) || []), entry]);
744
+ }
745
+ const unmanagedTargetByName = new Map();
746
+ for (const entry of target.unmanaged) {
747
+ const name = physicalNameHint(entry.record);
748
+ unmanagedTargetByName.set(name, [...(unmanagedTargetByName.get(name) || []), entry]);
749
+ }
750
+ const consumedTargetKeys = new Set();
751
+ const consumedUnmanaged = new Set();
752
+ const invalidLocalGraph = new Map();
753
+ const localKeysByPhysicalName = new Map();
754
+ for (const [stableKey, entries] of localByKey) {
755
+ if (entries.length !== 1) {
756
+ continue;
757
+ }
758
+ const name = physicalNameHint(entries[0].manifest);
759
+ localKeysByPhysicalName.set(name, [...(localKeysByPhysicalName.get(name) || []), stableKey]);
760
+ }
761
+ const duplicateLocalNames = new Set();
762
+ for (const [name, stableKeys] of localKeysByPhysicalName) {
763
+ if (stableKeys.length > 1) {
764
+ for (const stableKey of stableKeys) {
765
+ duplicateLocalNames.add(stableKey);
766
+ }
767
+ blockers.push(`Duplicate desired physical Database Schema name "${name}" for Stable Keys: ${stableKeys.sort().join(', ')}.`);
768
+ }
769
+ }
770
+ for (const [stableKey, entries] of localByKey) {
771
+ if (entries.length !== 1 || entries[0].manifest.kind !== 'database-partition') {
772
+ continue;
773
+ }
774
+ const parentStableKey = entries[0].manifest.parentStableKey;
775
+ if (!localByKey.has(parentStableKey)) {
776
+ invalidLocalGraph.set(stableKey, `parent "${parentStableKey}" is missing from the desired workspace`);
777
+ }
778
+ }
779
+ const visiting = new Set();
780
+ const visited = new Set();
781
+ const visitParent = (stableKey, chain) => {
782
+ if (visiting.has(stableKey)) {
783
+ const cycleStart = chain.indexOf(stableKey);
784
+ for (const cycleKey of chain.slice(cycleStart)) {
785
+ invalidLocalGraph.set(cycleKey, 'desired partition parent cycle');
786
+ }
787
+ return;
788
+ }
789
+ if (visited.has(stableKey)) {
790
+ return;
791
+ }
792
+ const entries = localByKey.get(stableKey);
793
+ if (!entries || entries.length !== 1 || entries[0].manifest.kind !== 'database-partition') {
794
+ visited.add(stableKey);
795
+ return;
796
+ }
797
+ visiting.add(stableKey);
798
+ visitParent(entries[0].manifest.parentStableKey, [...chain, stableKey]);
799
+ visiting.delete(stableKey);
800
+ visited.add(stableKey);
801
+ };
802
+ for (const stableKey of localByKey.keys()) {
803
+ visitParent(stableKey, []);
804
+ }
805
+ for (const [stableKey, entries] of localByKey) {
806
+ const desired = portableDatabaseSchemaManifest(entries[0].manifest);
807
+ if (entries.length > 1) {
808
+ blockers.push(`Duplicate desired Database Schema stableKey "${stableKey}".`);
809
+ items.push({
810
+ status: 'collision', stableKey, name: desired.name, kind: desired.kind,
811
+ ...(desired.parentStableKey ? { parentStableKey: desired.parentStableKey } : {}),
812
+ reason: 'duplicate desired Stable Key', hints: ['Give each physical table or partition its own unique Stable Key.'],
813
+ });
814
+ continue;
815
+ }
816
+ if (duplicateLocalNames.has(stableKey)) {
817
+ items.push({
818
+ status: 'collision', stableKey, name: desired.name, kind: desired.kind,
819
+ ...(desired.parentStableKey ? { parentStableKey: desired.parentStableKey } : {}),
820
+ reason: 'duplicate desired physical table name',
821
+ hints: ['Choose a unique exact physical table name for each desired Stable Key before synchronization.'],
822
+ });
823
+ continue;
824
+ }
825
+ const graphError = invalidLocalGraph.get(stableKey);
826
+ if (graphError) {
827
+ blockers.push(`Invalid desired Database Schema graph for "${stableKey}": ${graphError}.`);
828
+ items.push({
829
+ status: 'collision', stableKey, name: desired.name, kind: desired.kind,
830
+ ...(desired.parentStableKey ? { parentStableKey: desired.parentStableKey } : {}),
831
+ reason: graphError, hints: ['Restore the complete parent chain and remove cycles before synchronizing this partition.'],
832
+ });
833
+ continue;
834
+ }
835
+ const matches = targetByKey.get(stableKey) || [];
836
+ const tombstone = lock.databaseSchemas[stableKey]?.environments[input.envName]?.tombstone;
837
+ if (desired.kind === 'database-partition' && tombstone) {
838
+ items.push({
839
+ status: 'previously-deleted-partition',
840
+ stableKey,
841
+ name: desired.name,
842
+ kind: desired.kind,
843
+ ...(desired.parentStableKey ? { parentStableKey: desired.parentStableKey } : {}),
844
+ desiredHash: hashPortableDatabaseSchema(desired),
845
+ reason: 'this Stable Key was previously deleted in the selected environment',
846
+ hints: ['Do not recreate or restore this partition automatically. Reuse this Stable Key only through an explicit reviewed restore workflow; create a physical replacement with a new Stable Key.'],
847
+ });
848
+ continue;
849
+ }
850
+ if (matches.length > 1) {
851
+ consumedTargetKeys.add(stableKey);
852
+ blockers.push(`Duplicate target Database Schema stableKey "${stableKey}".`);
853
+ items.push({
854
+ status: 'collision', stableKey, name: desired.name, kind: desired.kind,
855
+ ...(desired.parentStableKey ? { parentStableKey: desired.parentStableKey } : {}),
856
+ reason: 'duplicate target Stable Key', hints: ['Resolve the duplicate target metadata before synchronizing this resource.'],
857
+ });
858
+ continue;
859
+ }
860
+ const desiredName = physicalNameHint(desired);
861
+ const unmanagedNameCollisions = unmanagedTargetByName.get(desiredName) || [];
862
+ const managedNameCollisions = (managedTargetByName.get(desiredName) || [])
863
+ .filter(({ manifest }) => manifest.stableKey !== stableKey);
864
+ if (unmanagedNameCollisions.length > 0 || managedNameCollisions.length > 0) {
865
+ for (const collision of unmanagedNameCollisions) {
866
+ consumedUnmanaged.add(collision.record);
867
+ }
868
+ for (const collision of managedNameCollisions) {
869
+ consumedTargetKeys.add(collision.manifest.stableKey);
870
+ }
871
+ const reason = unmanagedNameCollisions.length > 0
872
+ ? `target Database Schema with the same physical name is unmanaged because it is missing ${input.stableKeyName}`
873
+ : `target Database Schema with the same physical name has a different ${input.stableKeyName}`;
874
+ items.push({
875
+ status: 'collision', stableKey, name: desired.name, kind: desired.kind, reason,
876
+ ...(desired.parentStableKey ? { parentStableKey: desired.parentStableKey } : {}),
877
+ desiredHash: hashPortableDatabaseSchema(desired),
878
+ hints: ['Assign and verify the intended Stable Key on the existing target table, or rename the desired physical table before creating it.'],
879
+ });
880
+ continue;
881
+ }
882
+ if (matches.length === 0) {
883
+ const sourceRestricted = lock.databaseSchemas[stableKey]?.sourceRestricted;
884
+ if (desired.kind === 'database-schema' && sourceRestricted !== false) {
885
+ items.push({
886
+ status: 'access-policy-required',
887
+ stableKey,
888
+ name: desired.name,
889
+ kind: desired.kind,
890
+ reason: sourceRestricted === true
891
+ ? 'source root table is restricted and target ACL principals are intentionally not portable'
892
+ : 'source root access policy is unknown because the lock predates policy capture',
893
+ desiredHash: hashPortableDatabaseSchema(desired),
894
+ hints: [
895
+ sourceRestricted === true
896
+ ? 'Do not create this table without an explicit target access-policy decision. Configure target ACL principals through the target-local workflow before reconciling this resource.'
897
+ : 'Pull the source again to capture its access policy before creating this root table on the target.',
898
+ ],
899
+ });
900
+ continue;
901
+ }
902
+ const foreignReferences = desired.migration?.foreignReferences === 'manual'
903
+ || hasForeignReferences(desired.definition || []);
904
+ items.push({
905
+ status: foreignReferences ? 'manual-schema-migration-required' : 'create',
906
+ stableKey,
907
+ name: desired.name,
908
+ kind: desired.kind,
909
+ ...(desired.parentStableKey ? { parentStableKey: desired.parentStableKey } : {}),
910
+ reason: foreignReferences ? 'foreign references require manual deployment and verification' : 'desired resource is missing on target',
911
+ desiredHash: hashPortableDatabaseSchema(desired),
912
+ changes: foreignReferences ? [{
913
+ kind: 'foreign-reference', field: 'definition.foreignReferences', risk: 'manual',
914
+ hint: 'Create the table and enforce or verify its foreign relationships through an explicit migration; the API does not create SQL foreign-key constraints.',
915
+ }] : undefined,
916
+ hints: [foreignReferences
917
+ ? 'Create and verify this table through a reviewed migration before reconciling its Stable Key.'
918
+ : desired.kind === 'database-partition'
919
+ ? 'Create this partition only after its target parent exists, then verify its portable hash before recording the target lock baseline.'
920
+ : 'Create this root table without records, verify its portable hash, then record the target lock baseline.'],
921
+ });
922
+ continue;
923
+ }
924
+ consumedTargetKeys.add(stableKey);
925
+ const current = matches[0];
926
+ const desiredHash = hashPortableDatabaseSchema(desired);
927
+ const changes = diffDatabaseSchemas(desired, current.manifest, input.stableKeyName);
928
+ if (changes.some((change) => change.kind === 'column-renamed')) {
929
+ if (!databasesWithActiveViewDependencies) {
930
+ const views = await input.client.listAllDatabaseViews();
931
+ databasesWithActiveViewDependencies = new Set(views
932
+ .filter((view) => !view.deletedAt)
933
+ .flatMap((view) => Array.isArray(view.sourceDatabaseIds) ? view.sourceDatabaseIds : []));
934
+ }
935
+ if (databasesWithActiveViewDependencies.has(current.databaseId)) {
936
+ changes.push({
937
+ kind: 'database-view-dependency',
938
+ field: 'databaseViews',
939
+ risk: 'manual',
940
+ hint: 'An active Database View depends on this table. Update the view and validate its fields through an explicit reviewed migration before renaming a column.',
941
+ });
942
+ }
943
+ }
944
+ if (changes.length > 0 && desired.kind === 'database-schema' && current.record.audit === true) {
945
+ changes.push({
946
+ kind: 'audit-trigger-rebuild',
947
+ field: 'audit',
948
+ risk: 'operational',
949
+ hint: 'This target has audit enabled, so the API rebuilds its audit trigger during the update. Writes are serialized and retried once only after a fresh live hash and version check.',
950
+ });
951
+ }
952
+ const changedFields = [...new Set(changes.map((change) => change.field))];
953
+ const baselineHash = lock.databaseSchemas[stableKey]?.environments[input.envName]?.remoteHash;
954
+ const currentStats = statsById.get(current.databaseId);
955
+ const sizeHint = changes.length > 0 ? sizeRiskHint(currentStats) : undefined;
956
+ const common = {
957
+ stableKey,
958
+ name: desired.name,
959
+ kind: desired.kind,
960
+ ...(desired.parentStableKey ? { parentStableKey: desired.parentStableKey } : {}),
961
+ desiredHash,
962
+ currentHash: current.hash,
963
+ ...(baselineHash ? { baselineHash } : {}),
964
+ ...(changes.length > 0 ? { changes, changedFields } : {}),
965
+ hints: [...changes.map((change) => change.hint), ...(sizeHint ? [sizeHint] : [])],
966
+ ...(currentStats ? { stats: currentStats } : {}),
967
+ };
968
+ const columnStableKeyValidationFailed = changes.some((change) => (change.risk === 'manual'
969
+ && change.kind === 'column-changed'
970
+ && change.field.includes(`.metadata.${input.stableKeyName}`)));
971
+ if (columnStableKeyValidationFailed) {
972
+ items.push({
973
+ ...common,
974
+ status: 'manual-schema-migration-required',
975
+ reason: 'one or more column Stable Keys are malformed or duplicated',
976
+ });
977
+ continue;
978
+ }
979
+ if (desiredHash === current.hash) {
980
+ if (!baselineHash) {
981
+ items.push({
982
+ ...common,
983
+ status: 'missing-lock',
984
+ reason: `desired and target match, but the ${input.envName} lock baseline is missing`,
985
+ hints: ['Adopt this verified live match through the push workflow so the selected environment receives its own lock baseline.'],
986
+ });
987
+ }
988
+ else if (baselineHash !== current.hash) {
989
+ items.push({
990
+ ...common,
991
+ status: 'reconciled',
992
+ reason: 'desired and target match after a manual or external change; lock baseline is stale',
993
+ hints: ['The live portable hash is converged; a later verified push may advance only this environment lock baseline.'],
994
+ });
995
+ }
996
+ else {
997
+ items.push({ ...common, status: 'clean' });
998
+ }
999
+ continue;
1000
+ }
1001
+ const partitionBoundsChanged = desired.kind === 'database-partition'
1002
+ && current.manifest.kind === 'database-partition'
1003
+ && !valuesEqual(desired.partition, current.manifest.partition);
1004
+ if (partitionBoundsChanged) {
1005
+ items.push({
1006
+ ...common,
1007
+ status: 'replacement-requires-two-phases',
1008
+ reason: 'partition bounds changed under an existing Stable Key',
1009
+ hints: ['Partition bounds are immutable. Keep the old Stable Key only for an explicit reviewed restore; prune the old physical partition, then create its replacement with a new Stable Key in a later push.'],
1010
+ });
1011
+ continue;
1012
+ }
1013
+ if (changes.some((change) => change.risk === 'manual')) {
1014
+ items.push({
1015
+ ...common,
1016
+ status: 'manual-schema-migration-required',
1017
+ reason: 'one or more changes require an explicit DDL or data migration',
1018
+ });
1019
+ }
1020
+ else if (!baselineHash) {
1021
+ items.push({ ...common, status: 'missing-lock', reason: `missing ${input.envName} lock baseline` });
1022
+ }
1023
+ else if (desiredHash === baselineHash) {
1024
+ items.push({ ...common, status: 'remote-changed', reason: 'target changed since lock baseline' });
1025
+ }
1026
+ else if (current.hash === baselineHash) {
1027
+ items.push({ ...common, status: 'safe-update' });
1028
+ }
1029
+ else {
1030
+ items.push({ ...common, status: 'conflict', reason: 'desired and target changed since lock baseline' });
1031
+ }
1032
+ }
1033
+ for (const [stableKey, matches] of targetByKey) {
1034
+ if (consumedTargetKeys.has(stableKey) || declaredLocalKeys.has(stableKey)) {
1035
+ continue;
1036
+ }
1037
+ if (matches.length > 1) {
1038
+ blockers.push(`Duplicate target Database Schema stableKey "${stableKey}".`);
1039
+ items.push({
1040
+ status: 'collision', stableKey, name: matches[0].manifest.name, kind: matches[0].manifest.kind,
1041
+ ...(matches[0].manifest.parentStableKey ? { parentStableKey: matches[0].manifest.parentStableKey } : {}),
1042
+ reason: 'duplicate target Stable Key', hints: ['Resolve the duplicate target metadata before synchronizing this resource.'],
1043
+ });
1044
+ continue;
1045
+ }
1046
+ const current = matches[0];
1047
+ const isPartition = current.manifest.kind === 'database-partition';
1048
+ const lockEntry = lock.databaseSchemas[stableKey];
1049
+ const wasManagedPartition = isPartition
1050
+ && Boolean(lockEntry?.parentStableKey)
1051
+ && Boolean(lockEntry?.environments[input.envName]?.remoteHash)
1052
+ && !lockEntry?.environments[input.envName]?.tombstone;
1053
+ const currentStats = statsById.get(current.databaseId);
1054
+ const deletionSizeHint = wasManagedPartition ? sizeRiskHint(currentStats) : undefined;
1055
+ items.push({
1056
+ status: isPartition && lockEntry?.environments[input.envName]?.tombstone
1057
+ ? 'previously-deleted-partition'
1058
+ : wasManagedPartition
1059
+ ? 'delete-partition'
1060
+ : isPartition ? 'orphan-partition' : 'orphan-root',
1061
+ stableKey,
1062
+ name: current.manifest.name,
1063
+ kind: current.manifest.kind,
1064
+ ...(current.manifest.parentStableKey ? { parentStableKey: current.manifest.parentStableKey } : {}),
1065
+ currentHash: current.hash,
1066
+ reason: isPartition && lockEntry?.environments[input.envName]?.tombstone
1067
+ ? 'a partition with this Stable Key reappeared after its recorded deletion'
1068
+ : wasManagedPartition
1069
+ ? 'previously managed partition manifest was removed locally'
1070
+ : `target-only ${isPartition ? 'partition' : 'root schema'} remains untouched`,
1071
+ hints: [isPartition && lockEntry?.environments[input.envName]?.tombstone
1072
+ ? 'Do not restore or delete this resource automatically. Resolve the tombstone through an explicit reviewed lifecycle operation.'
1073
+ : wasManagedPartition
1074
+ ? 'Before deletion, check dependent Database Views, back up or migrate rows, verify the full descendant branch, then use the dedicated forced partition prune flow leaves-first.'
1075
+ : 'Target-only resources are informational and are never deleted automatically by a general schema plan.',
1076
+ ...(deletionSizeHint ? [deletionSizeHint] : [])],
1077
+ ...(currentStats ? { stats: currentStats } : {}),
1078
+ });
1079
+ }
1080
+ const createdPartitions = items.filter((item) => item.status === 'create' && item.kind === 'database-partition');
1081
+ const deletedPartitions = items.filter((item) => item.status === 'delete-partition');
1082
+ for (const created of createdPartitions) {
1083
+ const desired = localByKey.get(created.stableKey || '')?.[0]?.manifest;
1084
+ if (!desired || !created.parentStableKey) {
1085
+ continue;
1086
+ }
1087
+ for (const deleted of deletedPartitions) {
1088
+ const current = targetByKey.get(deleted.stableKey || '')?.[0]?.manifest;
1089
+ if (!current
1090
+ || current.parentStableKey !== created.parentStableKey
1091
+ || !partitionBoundsOverlap(desired.partition, current.partition)) {
1092
+ continue;
1093
+ }
1094
+ const reason = 'overlapping partition replacement requires delete and create in two separate pushes';
1095
+ const hints = ['First prune the old partition with --prune-partitions --yes --force and verify its tombstone. Then create the replacement with a new Stable Key in a later push.'];
1096
+ created.status = 'replacement-requires-two-phases';
1097
+ created.reason = reason;
1098
+ created.hints = hints;
1099
+ deleted.status = 'replacement-requires-two-phases';
1100
+ deleted.reason = reason;
1101
+ deleted.hints = hints;
1102
+ }
1103
+ }
1104
+ for (const unmanaged of target.unmanaged) {
1105
+ if (consumedUnmanaged.has(unmanaged.record)) {
1106
+ continue;
1107
+ }
1108
+ const databaseId = databaseIdOf(unmanaged.record);
1109
+ items.push({
1110
+ status: 'unmanaged',
1111
+ name: String(unmanaged.record.name || databaseId || 'unknown'),
1112
+ reason: unmanaged.reason,
1113
+ hints: ['This WIP resource is non-blocking for ordinary planning; assign a Stable Key when it should become managed.'],
1114
+ ...(statsById.has(databaseId) ? { stats: statsById.get(databaseId) } : {}),
1115
+ });
1116
+ }
1117
+ const rank = {
1118
+ create: 0,
1119
+ 'safe-update': 1,
1120
+ 'manual-schema-migration-required': 2,
1121
+ 'access-policy-required': 3,
1122
+ 'remote-changed': 4,
1123
+ conflict: 5,
1124
+ 'missing-lock': 6,
1125
+ reconciled: 7,
1126
+ clean: 8,
1127
+ 'delete-partition': 9,
1128
+ 'previously-deleted-partition': 10,
1129
+ 'replacement-requires-two-phases': 11,
1130
+ 'orphan-partition': 12,
1131
+ 'orphan-root': 13,
1132
+ unmanaged: 14,
1133
+ collision: 15,
1134
+ };
1135
+ items.sort((left, right) => rank[left.status] - rank[right.status]
1136
+ || (left.stableKey || left.name).localeCompare(right.stableKey || right.name));
1137
+ const counts = emptyPlanCounts();
1138
+ for (const item of items) {
1139
+ counts[item.status] += 1;
1140
+ }
1141
+ if (!input.withStats) {
1142
+ warnings.push('Table sizes were not fetched; use --with-stats for one cached bulk size request and more concrete DDL risk context.');
1143
+ }
1144
+ return {
1145
+ resourceType: 'database-schema',
1146
+ env: input.envName,
1147
+ withStats: Boolean(input.withStats),
1148
+ skipPartitions: input.skipPartitions === true,
1149
+ counts,
1150
+ blockers: [...new Set(blockers)].sort((left, right) => left.localeCompare(right)),
1151
+ warnings,
1152
+ exclusions: [
1153
+ 'audit configuration and audit history are target-local',
1154
+ 'ACL users, groups, and restricted policy are target-local',
1155
+ 'tags are target-local',
1156
+ 'root schemas and unknown target-only partitions are never deleted by plan',
1157
+ ...(input.skipPartitions === true
1158
+ ? ['partitions were excluded from this root-schema-only plan']
1159
+ : []),
1160
+ ],
1161
+ items,
1162
+ };
1163
+ }
1164
+ function validateCatalog(records, stableKeyName, envName) {
1165
+ const migratableRecords = excludeDatabaseBackupArtifacts(records);
1166
+ const allIds = new Set();
1167
+ const idsByName = new Map();
1168
+ for (const record of migratableRecords) {
1169
+ const id = databaseIdOf(record);
1170
+ if (!id) {
1171
+ throw new Error('Database schema without databaseId was returned; no files were changed.');
1172
+ }
1173
+ allIds.add(id);
1174
+ const name = String(record.name || '');
1175
+ idsByName.set(name, [...(idsByName.get(name) || []), id]);
1176
+ }
1177
+ const entriesById = new Map();
1178
+ const entriesByKey = new Map();
1179
+ const skipped = [];
1180
+ for (const record of migratableRecords) {
1181
+ const id = databaseIdOf(record);
1182
+ if (!id) {
1183
+ throw new Error('Database schema without databaseId was returned; no files were changed.');
1184
+ }
1185
+ const raw = rawStableKey(record, stableKeyName);
1186
+ if (raw.present && (typeof raw.value !== 'string' || !raw.value.trim())) {
1187
+ throw new Error(`Invalid database-schema stableKey on ${id}; no files were changed.`);
1188
+ }
1189
+ const stableKey = configuredStableKey(record, stableKeyName);
1190
+ if (!stableKey) {
1191
+ skipped.push({ targetPath: label(record), reason: `unmanaged: missing ${stableKeyName}` });
1192
+ continue;
1193
+ }
1194
+ if (entriesByKey.has(stableKey)) {
1195
+ throw new Error(`Duplicate database-schema stableKey "${stableKey}" on ${envName}; no files were changed.`);
1196
+ }
1197
+ const entry = { record, id, stableKey, parentId: null };
1198
+ entriesById.set(id, entry);
1199
+ entriesByKey.set(stableKey, entry);
1200
+ }
1201
+ for (const entry of entriesById.values()) {
1202
+ const resolvedParents = new Set();
1203
+ for (const reference of parentCandidates(entry.record)) {
1204
+ if (reference.kind === 'id') {
1205
+ resolvedParents.add(reference.value);
1206
+ continue;
1207
+ }
1208
+ const matches = idsByName.get(reference.value) || [];
1209
+ if (matches.length > 1) {
1210
+ throw new Error(`Ambiguous parent "${reference.value}" for database schema "${entry.stableKey}"; no files were changed.`);
1211
+ }
1212
+ if (matches.length === 1) {
1213
+ resolvedParents.add(matches[0]);
1214
+ }
1215
+ else {
1216
+ resolvedParents.add(reference.value);
1217
+ }
1218
+ }
1219
+ if (resolvedParents.size > 1) {
1220
+ throw new Error(`Ambiguous parent for database schema "${entry.stableKey}"; no files were changed.`);
1221
+ }
1222
+ entry.parentId = [...resolvedParents][0] || null;
1223
+ }
1224
+ const unmanagedParentIds = new Set();
1225
+ let exclusionsChanged = true;
1226
+ while (exclusionsChanged) {
1227
+ exclusionsChanged = false;
1228
+ for (const entry of entriesById.values()) {
1229
+ if (!entry.parentId || unmanagedParentIds.has(entry.id)) {
1230
+ continue;
1231
+ }
1232
+ if (!allIds.has(entry.parentId)) {
1233
+ throw new Error(`Database schema "${entry.stableKey}" has a missing parent; no files were changed.`);
1234
+ }
1235
+ if (!entriesById.has(entry.parentId) || unmanagedParentIds.has(entry.parentId)) {
1236
+ unmanagedParentIds.add(entry.id);
1237
+ skipped.push({
1238
+ targetPath: label(entry.record),
1239
+ reason: `unmanaged: parent is missing ${stableKeyName}`,
1240
+ });
1241
+ exclusionsChanged = true;
1242
+ }
1243
+ }
1244
+ }
1245
+ for (const id of unmanagedParentIds) {
1246
+ const entry = entriesById.get(id);
1247
+ if (entry) {
1248
+ entriesByKey.delete(entry.stableKey);
1249
+ }
1250
+ entriesById.delete(id);
1251
+ }
1252
+ for (const entry of entriesById.values()) {
1253
+ if (!entry.parentId) {
1254
+ continue;
1255
+ }
1256
+ const parent = entriesById.get(entry.parentId);
1257
+ if (!parent) {
1258
+ throw new Error(`Database schema "${entry.stableKey}" has a missing parent; no files were changed.`);
1259
+ }
1260
+ entry.parentStableKey = parent.stableKey;
1261
+ }
1262
+ const visiting = new Set();
1263
+ const visited = new Set();
1264
+ const visit = (entry) => {
1265
+ if (visiting.has(entry.id)) {
1266
+ throw new Error(`Database-schema parent cycle detected at "${entry.stableKey}"; no files were changed.`);
1267
+ }
1268
+ if (visited.has(entry.id)) {
1269
+ return;
1270
+ }
1271
+ visiting.add(entry.id);
1272
+ if (entry.parentId) {
1273
+ visit(entriesById.get(entry.parentId));
1274
+ }
1275
+ visiting.delete(entry.id);
1276
+ visited.add(entry.id);
1277
+ };
1278
+ for (const entry of entriesById.values()) {
1279
+ visit(entry);
1280
+ }
1281
+ return { entries: [...entriesById.values()], entriesById, skipped };
1282
+ }
1283
+ export async function pullDatabaseSchemasToWorkspace(input) {
1284
+ const { projectRoot, workspaceRoot } = requireProjectLayout(input.cwd, 'Database Schema');
1285
+ const { databases } = await input.client.listAllDatabaseSchemas({ projection: 'pull' });
1286
+ const catalog = validateCatalog(databases, input.stableKeyName, input.envName);
1287
+ if (input.strict && catalog.skipped.length > 0) {
1288
+ throw new Error(`Database Schema strict pull requires Stable Keys on all active resources; ${catalog.skipped.length} unmanaged resource(s) found.`);
1289
+ }
1290
+ const manifestsById = new Map(catalog.entries.map((entry) => [entry.id, manifestFromEntry(entry, input.stableKeyName)]));
1291
+ const pathsById = new Map();
1292
+ const resolvePath = (entry) => {
1293
+ const existing = pathsById.get(entry.id);
1294
+ if (existing) {
1295
+ return existing;
1296
+ }
1297
+ const manifest = manifestsById.get(entry.id);
1298
+ const filePath = entry.parentId
1299
+ ? path.join(path.dirname(resolvePath(catalog.entriesById.get(entry.parentId))), 'Partitions', `${sanitizeSegment(manifest.name)}-${stableKeySuffix(manifest.stableKey)}`, DATABASE_SCHEMA_MANIFEST_FILE)
1300
+ : rootManifestPath(workspaceRoot, manifest);
1301
+ pathsById.set(entry.id, filePath);
1302
+ return filePath;
1303
+ };
1304
+ const existingByKey = new Map();
1305
+ const catalogPathOwners = new Map();
1306
+ for (const entry of catalog.entries) {
1307
+ const identity = pathIdentity(resolvePath(entry));
1308
+ const owner = catalogPathOwners.get(identity);
1309
+ if (owner && owner !== entry.stableKey) {
1310
+ throw new Error(`Database Schema workspace path collision for "${entry.stableKey}" and "${owner}"; no files were changed.`);
1311
+ }
1312
+ catalogPathOwners.set(identity, entry.stableKey);
1313
+ }
1314
+ for (const local of loadWorkspaceManifests(workspaceRoot)) {
1315
+ if (!isRecord(local.manifest) || typeof local.manifest.stableKey !== 'string') {
1316
+ throw new Error(`Invalid database-schema manifest ${path.relative(projectRoot, local.manifestPath)}; no files were changed.`);
1317
+ }
1318
+ if (existingByKey.has(local.manifest.stableKey)) {
1319
+ throw new Error(`Duplicate local database-schema stableKey "${local.manifest.stableKey}"; no files were changed.`);
1320
+ }
1321
+ existingByKey.set(local.manifest.stableKey, local);
1322
+ }
1323
+ const lock = readComponentLock(projectRoot);
1324
+ const skipped = [...catalog.skipped];
1325
+ const writes = [];
1326
+ const sourceStableKeys = new Set(catalog.entries.map((entry) => entry.stableKey));
1327
+ const pruned = input.pruneOrphans === true
1328
+ ? [...existingByKey.values()]
1329
+ .filter((local) => !sourceStableKeys.has(local.manifest.stableKey))
1330
+ .map((local) => ({
1331
+ stableKey: local.manifest.stableKey,
1332
+ targetPath: localTargetPath(input.cwd, local.manifestPath),
1333
+ manifestPath: local.manifestPath,
1334
+ }))
1335
+ .sort((left, right) => right.manifestPath.length - left.manifestPath.length || left.stableKey.localeCompare(right.stableKey))
1336
+ : [];
1337
+ for (const entry of catalog.entries) {
1338
+ const manifest = manifestsById.get(entry.id);
1339
+ const hash = hashPortableDatabaseSchema(manifest);
1340
+ const existing = existingByKey.get(entry.stableKey);
1341
+ if (existing && !input.force) {
1342
+ const localHash = hashPortableDatabaseSchema(existing.manifest);
1343
+ const baseline = lock.databaseSchemas[entry.stableKey];
1344
+ if (localHash !== hash && (!baseline || localHash !== baseline.sourceHash)) {
1345
+ skipped.push({
1346
+ targetPath: localTargetPath(input.cwd, existing.manifestPath),
1347
+ reason: 'local manifest changed since the lock baseline; use --force to overwrite',
1348
+ });
1349
+ continue;
1350
+ }
1351
+ }
1352
+ writes.push({
1353
+ manifest,
1354
+ hash,
1355
+ filePath: resolvePath(entry),
1356
+ ...(entry.parentId || typeof entry.record.restricted !== 'boolean'
1357
+ ? {}
1358
+ : { sourceRestricted: entry.record.restricted }),
1359
+ ...(existing ? { previousPath: existing.manifestPath } : {}),
1360
+ });
1361
+ }
1362
+ const pathOwners = new Map();
1363
+ for (const write of writes) {
1364
+ const identity = pathIdentity(write.filePath);
1365
+ const owner = pathOwners.get(identity);
1366
+ if (owner && owner !== write.manifest.stableKey) {
1367
+ throw new Error(`Database Schema workspace path collision for "${write.manifest.stableKey}" and "${owner}"; no files were changed.`);
1368
+ }
1369
+ pathOwners.set(identity, write.manifest.stableKey);
1370
+ if (fs.existsSync(write.filePath)) {
1371
+ const local = readJsonFile(write.filePath);
1372
+ if (local.stableKey !== write.manifest.stableKey) {
1373
+ throw new Error(`Database Schema workspace path is occupied by "${String(local.stableKey)}"; no files were changed.`);
1374
+ }
1375
+ }
1376
+ }
1377
+ for (const write of writes.sort((left, right) => left.manifest.stableKey.localeCompare(right.manifest.stableKey))) {
1378
+ writePulledManifest({
1379
+ filePath: write.filePath,
1380
+ manifest: write.manifest,
1381
+ previousPath: write.previousPath,
1382
+ root: path.join(workspaceRoot, DATABASE_SCHEMAS_DIRECTORY),
1383
+ force: input.force,
1384
+ });
1385
+ const previous = lock.databaseSchemas[write.manifest.stableKey];
1386
+ const entry = {
1387
+ stableKey: write.manifest.stableKey,
1388
+ path: toPortableRelativePath(projectRoot, write.filePath),
1389
+ sourceHash: write.hash,
1390
+ ...(typeof write.sourceRestricted === 'boolean' ? { sourceRestricted: write.sourceRestricted } : {}),
1391
+ ...(write.manifest.parentStableKey ? { parentStableKey: write.manifest.parentStableKey } : {}),
1392
+ environments: {
1393
+ ...(previous?.environments || {}),
1394
+ [input.envName]: {
1395
+ remoteHash: write.hash,
1396
+ },
1397
+ },
1398
+ };
1399
+ lock.databaseSchemas[write.manifest.stableKey] = entry;
1400
+ }
1401
+ for (const removed of pruned) {
1402
+ removeRelocatedManifest(removed.manifestPath, path.join(workspaceRoot, DATABASE_SCHEMAS_DIRECTORY));
1403
+ delete lock.databaseSchemas[removed.stableKey];
1404
+ }
1405
+ if (writes.length > 0 || pruned.length > 0) {
1406
+ writeComponentLock(projectRoot, lock);
1407
+ }
1408
+ return {
1409
+ pulled: writes.map((write) => ({
1410
+ manifest: write.manifest,
1411
+ targetPath: localTargetPath(input.cwd, write.filePath),
1412
+ })),
1413
+ pruned: pruned
1414
+ .map(({ stableKey, targetPath }) => ({ stableKey, targetPath }))
1415
+ .sort((left, right) => left.stableKey.localeCompare(right.stableKey)),
1416
+ skipped: skipped.sort((left, right) => left.targetPath.localeCompare(right.targetPath)),
1417
+ diagnostics: catalog.entries.map((entry) => ({
1418
+ stableKey: entry.stableKey,
1419
+ ...(entry.record.audit === undefined ? {} : { audit: entry.record.audit }),
1420
+ ...(entry.record.restricted !== undefined
1421
+ ? { acl: { restricted: entry.record.restricted } }
1422
+ : {}),
1423
+ ...(entry.record.tags === undefined ? {} : { tags: entry.record.tags }),
1424
+ })),
1425
+ };
1426
+ }
1427
+ class PartitionParentUnavailableError extends Error {
1428
+ }
1429
+ function readWorkspaceDatabaseSchemasForPush(cwd, stableKeyName) {
1430
+ const { workspaceRoot } = requireProjectLayout(cwd, 'Database Schema');
1431
+ const entriesByKey = new Map();
1432
+ for (const entry of loadWorkspaceManifests(workspaceRoot)) {
1433
+ const errors = validatePlanManifest(entry.manifest, stableKeyName);
1434
+ if (errors.length > 0) {
1435
+ continue;
1436
+ }
1437
+ entriesByKey.set(entry.manifest.stableKey, [
1438
+ ...(entriesByKey.get(entry.manifest.stableKey) || []),
1439
+ entry,
1440
+ ]);
1441
+ }
1442
+ return new Map([...entriesByKey]
1443
+ .filter(([, entries]) => entries.length === 1)
1444
+ .map(([stableKey, entries]) => [stableKey, entries[0]]));
1445
+ }
1446
+ function createPayloadForDatabaseSchema(input) {
1447
+ const common = {
1448
+ name: input.manifest.name,
1449
+ ...(input.manifest.category === undefined ? {} : { category: input.manifest.category }),
1450
+ ...(input.manifest.description === undefined ? {} : { desc: input.manifest.description }),
1451
+ metadata: sanitizeUserMetadata(input.manifest.metadata),
1452
+ };
1453
+ if (input.manifest.kind === 'database-partition') {
1454
+ if (!input.parentName) {
1455
+ throw new PartitionParentUnavailableError('target parent is missing or ambiguous');
1456
+ }
1457
+ return {
1458
+ ...common,
1459
+ parent: input.parentName,
1460
+ partition: canonicalValue(input.manifest.partition || {}, true),
1461
+ };
1462
+ }
1463
+ return {
1464
+ ...common,
1465
+ definition: portableDefinitions(input.manifest.definition),
1466
+ ...(input.manifest.partition === undefined
1467
+ ? {}
1468
+ : { partition: canonicalValue(input.manifest.partition, true) }),
1469
+ audit: false,
1470
+ restricted: false,
1471
+ };
1472
+ }
1473
+ function createItemsInDependencyOrder(items, localByKey) {
1474
+ const itemsByKey = new Map(items
1475
+ .filter((item) => typeof item.stableKey === 'string')
1476
+ .map((item) => [item.stableKey, item]));
1477
+ const ordered = [];
1478
+ const visiting = new Set();
1479
+ const visited = new Set();
1480
+ const visit = (stableKey) => {
1481
+ if (visited.has(stableKey) || visiting.has(stableKey)) {
1482
+ return;
1483
+ }
1484
+ visiting.add(stableKey);
1485
+ const parentStableKey = localByKey.get(stableKey)?.manifest.parentStableKey;
1486
+ if (parentStableKey && itemsByKey.has(parentStableKey)) {
1487
+ visit(parentStableKey);
1488
+ }
1489
+ visiting.delete(stableKey);
1490
+ visited.add(stableKey);
1491
+ const item = itemsByKey.get(stableKey);
1492
+ if (item) {
1493
+ ordered.push(item);
1494
+ }
1495
+ };
1496
+ for (const stableKey of [...itemsByKey.keys()].sort((left, right) => left.localeCompare(right))) {
1497
+ visit(stableKey);
1498
+ }
1499
+ return ordered;
1500
+ }
1501
+ function currentTargetParentName(input) {
1502
+ const catalog = targetPlanCatalog(input.records, input.stableKeyName);
1503
+ const matches = catalog.managed.filter((entry) => entry.manifest.stableKey === input.parentStableKey);
1504
+ if (matches.length !== 1) {
1505
+ throw new PartitionParentUnavailableError(`target parent "${input.parentStableKey}" is missing or ambiguous`);
1506
+ }
1507
+ const parentName = matches[0].manifest.name;
1508
+ if (!parentName) {
1509
+ throw new PartitionParentUnavailableError(`target parent "${input.parentStableKey}" has no physical name`);
1510
+ }
1511
+ return parentName;
1512
+ }
1513
+ async function recheckDatabaseSchemaCreate(input) {
1514
+ const [stableKeyMatchRecords, matchingNameRecords] = await Promise.all([
1515
+ input.client.findActiveDatabaseSchemasByField('metadata', input.manifest.stableKey, [input.stableKeyName]),
1516
+ input.client.findActiveDatabaseSchemasByField('name', input.manifest.name),
1517
+ ]);
1518
+ const stableKeyMatches = excludeDatabaseBackupArtifacts(stableKeyMatchRecords);
1519
+ const matchingNames = excludeDatabaseBackupArtifacts(matchingNameRecords);
1520
+ if (stableKeyMatches.length > 0) {
1521
+ throw new Error('matching Stable Key appeared after planning');
1522
+ }
1523
+ if (matchingNames.length > 0) {
1524
+ throw new Error(`unmanaged-name-conflict: target table "${input.manifest.name}" appeared after planning`);
1525
+ }
1526
+ let records = input.records;
1527
+ if (input.manifest.kind === 'database-partition') {
1528
+ const parentStableKey = input.manifest.parentStableKey || '';
1529
+ const parents = excludeDatabaseBackupArtifacts(await input.client.findActiveDatabaseSchemasByField('metadata', parentStableKey, [input.stableKeyName]));
1530
+ if (parents.length !== 1) {
1531
+ throw new PartitionParentUnavailableError(`target parent "${parentStableKey}" is missing or ambiguous`);
1532
+ }
1533
+ records = replaceDatabaseSchemaRecord(records, parents[0]);
1534
+ }
1535
+ const migratableDatabases = excludeDatabaseBackupArtifacts(records);
1536
+ const parentName = input.manifest.kind === 'database-partition'
1537
+ ? currentTargetParentName({
1538
+ records: migratableDatabases,
1539
+ stableKeyName: input.stableKeyName,
1540
+ parentStableKey: input.manifest.parentStableKey || '',
1541
+ })
1542
+ : undefined;
1543
+ return { databases: migratableDatabases, parentName };
1544
+ }
1545
+ function verifyCreatedDatabaseSchema(input) {
1546
+ const verifiedStableKey = configuredStableKey(input.created, input.stableKeyName);
1547
+ if (verifiedStableKey !== input.desired.stableKey) {
1548
+ throw new Error(`written target is missing or changed ${input.stableKeyName}`);
1549
+ }
1550
+ const createdId = databaseIdOf(input.created);
1551
+ const records = [
1552
+ ...input.recordsBeforeCreate.filter((record) => databaseIdOf(record) !== createdId),
1553
+ input.created,
1554
+ ];
1555
+ const catalog = targetPlanCatalog(records, input.stableKeyName);
1556
+ const verified = catalog.managed.filter((entry) => entry.databaseId === createdId);
1557
+ if (verified.length !== 1) {
1558
+ throw new Error('created target could not be resolved into the live partition graph');
1559
+ }
1560
+ const remoteHash = verified[0].hash;
1561
+ const desiredHash = hashPortableDatabaseSchema(input.desired);
1562
+ if (remoteHash !== desiredHash) {
1563
+ throw new Error(`created target did not match desired portable hash (desired: ${desiredHash}; actual: ${remoteHash})`);
1564
+ }
1565
+ return remoteHash;
1566
+ }
1567
+ function updateDatabaseSchemaEnvironmentBaseline(input) {
1568
+ const { projectRoot } = requireProjectLayout(input.cwd, 'Database Schema');
1569
+ const lock = readComponentLock(projectRoot);
1570
+ const existing = lock.databaseSchemas[input.manifest.stableKey];
1571
+ const entry = {
1572
+ stableKey: input.manifest.stableKey,
1573
+ path: toPortableRelativePath(projectRoot, input.manifestPath),
1574
+ sourceHash: hashPortableDatabaseSchema(input.manifest),
1575
+ ...(input.manifest.kind === 'database-schema' && typeof existing?.sourceRestricted === 'boolean'
1576
+ ? { sourceRestricted: existing.sourceRestricted }
1577
+ : {}),
1578
+ ...(input.manifest.parentStableKey ? { parentStableKey: input.manifest.parentStableKey } : {}),
1579
+ environments: {
1580
+ ...(existing?.environments || {}),
1581
+ [input.envName]: { remoteHash: input.remoteHash },
1582
+ },
1583
+ };
1584
+ lock.databaseSchemas[input.manifest.stableKey] = entry;
1585
+ writeComponentLock(projectRoot, lock);
1586
+ }
1587
+ function resolveDatabaseSchemaManifestFromRecords(input) {
1588
+ const catalog = targetPlanCatalog(input.records, input.stableKeyName);
1589
+ const matches = catalog.managed.filter((entry) => (entry.databaseId === input.databaseId && entry.manifest.stableKey === input.stableKey));
1590
+ if (matches.length !== 1) {
1591
+ throw new Error('live target could not be resolved into an unambiguous managed database schema');
1592
+ }
1593
+ return matches[0];
1594
+ }
1595
+ async function resolveLiveDatabaseSchemaWriteTarget(input) {
1596
+ const records = excludeDatabaseBackupArtifacts(input.records);
1597
+ const matches = records.filter((record) => configuredStableKey(record, input.stableKeyName) === input.stableKey);
1598
+ if (matches.length !== 1) {
1599
+ throw new Error(`expected one live target Database Schema match, found ${matches.length}`);
1600
+ }
1601
+ const databaseId = databaseIdOf(matches[0]);
1602
+ if (!databaseId) {
1603
+ throw new Error('live target Database Schema did not include databaseId');
1604
+ }
1605
+ const record = unwrapData(await input.client.getDatabaseSchema(databaseId));
1606
+ if (configuredStableKey(record, input.stableKeyName) !== input.stableKey) {
1607
+ throw new Error(`live target Database Schema is missing or changed ${input.stableKeyName}`);
1608
+ }
1609
+ if (databaseIdOf(record) !== databaseId) {
1610
+ throw new Error('live target Database Schema identity changed before write');
1611
+ }
1612
+ if (typeof record.version !== 'number') {
1613
+ throw new Error('live target Database Schema did not include a current version');
1614
+ }
1615
+ const freshRecords = records.map((candidate) => databaseIdOf(candidate) === databaseId ? record : candidate);
1616
+ const resolved = resolveDatabaseSchemaManifestFromRecords({
1617
+ records: freshRecords,
1618
+ databaseId,
1619
+ stableKey: input.stableKey,
1620
+ stableKeyName: input.stableKeyName,
1621
+ });
1622
+ if (resolved.hash !== input.expectedHash) {
1623
+ throw new Error('target Database Schema changed after planning');
1624
+ }
1625
+ return {
1626
+ databaseId,
1627
+ record,
1628
+ manifest: resolved.manifest,
1629
+ hash: resolved.hash,
1630
+ records: freshRecords,
1631
+ };
1632
+ }
1633
+ function replaceDatabaseSchemaRecord(records, replacement) {
1634
+ const databaseId = databaseIdOf(replacement);
1635
+ if (!databaseId) {
1636
+ throw new Error('target Database Schema did not include databaseId');
1637
+ }
1638
+ const existing = records.some((record) => databaseIdOf(record) === databaseId);
1639
+ return existing
1640
+ ? records.map((record) => (databaseIdOf(record) === databaseId ? replacement : record))
1641
+ : [...records, replacement];
1642
+ }
1643
+ function buildDatabaseSchemaUpdatePayload(input) {
1644
+ if (typeof input.target.version !== 'number') {
1645
+ throw new Error('live target Database Schema did not include a current version');
1646
+ }
1647
+ const payload = {
1648
+ version: input.target.version,
1649
+ metadata: sanitizeUserMetadata(input.desired.metadata),
1650
+ ...(typeof input.desired.category === 'string' ? { category: input.desired.category } : {}),
1651
+ ...(input.desired.description === undefined
1652
+ ? (typeof input.target.desc === 'string' && input.target.desc ? { desc: '' } : {})
1653
+ : { desc: input.desired.description }),
1654
+ };
1655
+ if (input.desired.kind === 'database-partition') {
1656
+ return payload;
1657
+ }
1658
+ const targetDefinitions = Array.isArray(input.target.definition)
1659
+ ? input.target.definition.filter(isRecord)
1660
+ : [];
1661
+ const targetDefinitionsByName = new Map();
1662
+ for (const definition of targetDefinitions) {
1663
+ const name = typeof definition.name === 'string' ? definition.name : '';
1664
+ targetDefinitionsByName.set(name, [...(targetDefinitionsByName.get(name) || []), definition]);
1665
+ }
1666
+ const stableKeyName = input.stableKeyName || 'stableKey';
1667
+ const targetDefinitionsByStableKey = new Map();
1668
+ for (const definition of targetDefinitions) {
1669
+ const stableKey = columnStableKey(portableDefinition(definition), stableKeyName);
1670
+ if (stableKey) {
1671
+ targetDefinitionsByStableKey.set(stableKey, [...(targetDefinitionsByStableKey.get(stableKey) || []), definition]);
1672
+ }
1673
+ }
1674
+ const desiredStableKeyOwners = new Map();
1675
+ for (const definition of input.desired.definition || []) {
1676
+ const stableKey = columnStableKey(definition, stableKeyName);
1677
+ if (stableKey) {
1678
+ desiredStableKeyOwners.set(stableKey, [...(desiredStableKeyOwners.get(stableKey) || []), definition]);
1679
+ }
1680
+ }
1681
+ payload.definition = (input.desired.definition || []).map((definition) => {
1682
+ const value = portableDefinition(definition);
1683
+ const stableKey = columnStableKey(value, stableKeyName);
1684
+ const stableKeyTargets = stableKey ? targetDefinitionsByStableKey.get(stableKey) || [] : [];
1685
+ if (stableKey && (stableKeyTargets.length > 1 || (desiredStableKeyOwners.get(stableKey) || []).length > 1)) {
1686
+ throw new Error(`column Stable Key "${stableKey}" is duplicated; refusing to update Database Schema definitions`);
1687
+ }
1688
+ const namedTargets = targetDefinitionsByName.get(columnName(value)) || [];
1689
+ const namedTarget = namedTargets.length === 1 ? namedTargets[0] : undefined;
1690
+ const canUseLegacyNameMatch = Boolean(namedTarget) && (!stableKey || !columnStableKey(portableDefinition(namedTarget), stableKeyName));
1691
+ const targetDefinition = stableKey
1692
+ ? stableKeyTargets[0] || (canUseLegacyNameMatch ? namedTarget : undefined)
1693
+ : canUseLegacyNameMatch ? namedTarget : undefined;
1694
+ if (!targetDefinition) {
1695
+ return value;
1696
+ }
1697
+ if (typeof targetDefinition.databaseDefinitionId !== 'string' || !targetDefinition.databaseDefinitionId) {
1698
+ throw new Error(`live target column "${columnName(value)}" did not include databaseDefinitionId`);
1699
+ }
1700
+ return { ...value, databaseDefinitionId: targetDefinition.databaseDefinitionId };
1701
+ });
1702
+ return payload;
1703
+ }
1704
+ function verifyDatabaseSchemaWrite(input) {
1705
+ const databaseId = databaseIdOf(input.record);
1706
+ if (!databaseId) {
1707
+ throw new Error('verified target did not include databaseId');
1708
+ }
1709
+ if (configuredStableKey(input.record, input.stableKeyName) !== input.desired.stableKey) {
1710
+ throw new Error(`written target is missing or changed ${input.stableKeyName}`);
1711
+ }
1712
+ const records = input.recordsBeforeWrite.map((candidate) => (databaseIdOf(candidate) === databaseId ? input.record : candidate));
1713
+ const verified = resolveDatabaseSchemaManifestFromRecords({
1714
+ records,
1715
+ databaseId,
1716
+ stableKey: input.desired.stableKey,
1717
+ stableKeyName: input.stableKeyName,
1718
+ });
1719
+ const desiredHash = hashPortableDatabaseSchema(input.desired);
1720
+ if (verified.hash !== desiredHash) {
1721
+ throw new Error(`written target did not match desired portable hash (desired: ${desiredHash}; actual: ${verified.hash})`);
1722
+ }
1723
+ return verified.hash;
1724
+ }
1725
+ function isRetriableAuditedDatabaseSchemaWrite(error, target) {
1726
+ if (error instanceof ApiError && error.status === 429) {
1727
+ return true;
1728
+ }
1729
+ if (target.audit !== true) {
1730
+ return false;
1731
+ }
1732
+ const message = error instanceof Error ? error.message : String(error);
1733
+ return /audit configuration is busy|retry the save operation|newer version/i.test(message);
1734
+ }
1735
+ const DATABASE_SCHEMA_MUTATION_INTERVAL_MS = 110;
1736
+ const AUDITED_DATABASE_SCHEMA_WRITE_INTERVAL_MS = 250;
1737
+ async function waitForDatabaseSchemaMutation(lastMutationAt) {
1738
+ const remaining = DATABASE_SCHEMA_MUTATION_INTERVAL_MS - (Date.now() - lastMutationAt);
1739
+ if (remaining > 0) {
1740
+ await new Promise((resolve) => setTimeout(resolve, remaining));
1741
+ }
1742
+ }
1743
+ async function waitForAuditedDatabaseSchemaWrite(lastWriteAt) {
1744
+ const remaining = AUDITED_DATABASE_SCHEMA_WRITE_INTERVAL_MS - (Date.now() - lastWriteAt);
1745
+ if (remaining > 0) {
1746
+ await new Promise((resolve) => setTimeout(resolve, remaining));
1747
+ }
1748
+ }
1749
+ function deletePartitionsLeafFirst(items) {
1750
+ const itemsByKey = new Map(items.map((item) => [item.stableKey, item]));
1751
+ const childrenByParent = new Map();
1752
+ for (const item of itemsByKey.values()) {
1753
+ if (!item.parentStableKey) {
1754
+ continue;
1755
+ }
1756
+ const children = childrenByParent.get(item.parentStableKey) || [];
1757
+ children.push(item);
1758
+ childrenByParent.set(item.parentStableKey, children);
1759
+ }
1760
+ const ordered = [];
1761
+ const visited = new Set();
1762
+ const visit = (item) => {
1763
+ const stableKey = item.stableKey;
1764
+ if (visited.has(stableKey)) {
1765
+ return;
1766
+ }
1767
+ visited.add(stableKey);
1768
+ for (const child of (childrenByParent.get(stableKey) || []).sort((left, right) => (left.stableKey.localeCompare(right.stableKey)))) {
1769
+ visit(child);
1770
+ }
1771
+ ordered.push(item);
1772
+ };
1773
+ for (const item of [...itemsByKey.values()].sort((left, right) => (left.stableKey.localeCompare(right.stableKey)))) {
1774
+ visit(item);
1775
+ }
1776
+ return ordered;
1777
+ }
1778
+ function resolveLivePartitionDeleteTarget(input) {
1779
+ const catalog = targetPlanCatalog(input.databases, input.stableKeyName);
1780
+ const matches = catalog.managed.filter((entry) => entry.manifest.stableKey === input.stableKey);
1781
+ if (matches.length !== 1) {
1782
+ throw new Error(`expected one live target partition match, found ${matches.length}`);
1783
+ }
1784
+ const target = matches[0];
1785
+ if (target.manifest.kind !== 'database-partition' || target.manifest.parentStableKey !== input.parentStableKey) {
1786
+ throw new Error('live target partition parent relationship changed before delete');
1787
+ }
1788
+ if (target.hash !== input.expectedHash) {
1789
+ throw new Error('live target partition changed after planning');
1790
+ }
1791
+ return target;
1792
+ }
1793
+ async function assertNoActiveDatabaseViewDependencies(input) {
1794
+ const activeViews = await input.client.listAllDatabaseViews();
1795
+ const dependencies = activeViews.filter((view) => (!view.deletedAt
1796
+ && Array.isArray(view.sourceDatabaseIds)
1797
+ && view.sourceDatabaseIds.includes(input.databaseId)));
1798
+ if (dependencies.length > 0) {
1799
+ throw new Error(`active Database View dependency blocks this operation (${dependencies.length} view(s))`);
1800
+ }
1801
+ }
1802
+ async function verifyDeletedDatabaseSchema(input) {
1803
+ try {
1804
+ const record = unwrapData(await input.client.getDatabaseSchema(input.databaseId));
1805
+ if (!record.deletedAt && !record.deletedBy) {
1806
+ throw new Error('deleted partition remains active after delete');
1807
+ }
1808
+ }
1809
+ catch (error) {
1810
+ if (error instanceof ApiError && error.status === 404) {
1811
+ return;
1812
+ }
1813
+ throw error;
1814
+ }
1815
+ }
1816
+ function resolveLiveOrphanRootDeleteTarget(input) {
1817
+ const catalog = targetPlanCatalog(input.databases, input.stableKeyName);
1818
+ const matches = catalog.managed.filter((entry) => entry.manifest.stableKey === input.stableKey);
1819
+ if (matches.length !== 1) {
1820
+ throw new Error(`expected one live target root schema match, found ${matches.length}`);
1821
+ }
1822
+ const target = matches[0];
1823
+ if (target.manifest.kind !== 'database-schema') {
1824
+ throw new Error('live target schema became a partition before delete');
1825
+ }
1826
+ if (target.hash !== input.expectedHash) {
1827
+ throw new Error('live target root schema changed after planning');
1828
+ }
1829
+ return target;
1830
+ }
1831
+ function recordDeletedPartitionTombstone(input) {
1832
+ const { projectRoot } = requireProjectLayout(input.cwd, 'Database Schema');
1833
+ const lock = readComponentLock(projectRoot);
1834
+ const existing = lock.databaseSchemas[input.stableKey];
1835
+ if (!existing) {
1836
+ throw new Error('cannot record tombstone without an existing Database Schema lock entry');
1837
+ }
1838
+ lock.databaseSchemas[input.stableKey] = {
1839
+ ...existing,
1840
+ environments: {
1841
+ ...existing.environments,
1842
+ [input.envName]: {
1843
+ remoteHash: input.remoteHash,
1844
+ tombstone: {
1845
+ deletedAt: new Date().toISOString(),
1846
+ parentStableKey: input.parentStableKey,
1847
+ },
1848
+ },
1849
+ },
1850
+ };
1851
+ writeComponentLock(projectRoot, lock);
1852
+ }
1853
+ export async function pushDatabaseSchemas(input) {
1854
+ const plan = await buildDatabaseSchemasPlan(input);
1855
+ const localByKey = readWorkspaceDatabaseSchemasForPush(input.cwd, input.stableKeyName);
1856
+ if (input.stableKey && !localByKey.has(input.stableKey) && input.pruneOrphans !== true) {
1857
+ throw new Error(`Database Schema Stable Key "${input.stableKey}" was not found in the workspace.`);
1858
+ }
1859
+ const selected = (item) => !input.stableKey || item.stableKey === input.stableKey;
1860
+ const results = [];
1861
+ const lastMutation = { at: 0 };
1862
+ let targetInventory;
1863
+ const readTargetInventory = async () => {
1864
+ if (!targetInventory) {
1865
+ const listed = await input.client.listAllDatabaseSchemas({ projection: 'pull' });
1866
+ targetInventory = listed.databases;
1867
+ }
1868
+ return targetInventory;
1869
+ };
1870
+ const replaceTargetInventoryRecord = (record) => {
1871
+ targetInventory = replaceDatabaseSchemaRecord(targetInventory || [], record);
1872
+ };
1873
+ const blockedCreateStatuses = new Set([
1874
+ 'access-policy-required',
1875
+ 'manual-schema-migration-required',
1876
+ 'collision',
1877
+ ]);
1878
+ for (const item of plan.items.filter((candidate) => (selected(candidate)
1879
+ && Boolean(candidate.stableKey)
1880
+ && localByKey.has(candidate.stableKey || '')
1881
+ && (blockedCreateStatuses.has(candidate.status)
1882
+ || (candidate.status === 'missing-lock' && input.force !== true))))) {
1883
+ const local = localByKey.get(item.stableKey || '');
1884
+ results.push({
1885
+ stableKey: item.stableKey,
1886
+ action: 'skipped',
1887
+ status: 'skipped',
1888
+ targetPath: localTargetPath(input.cwd, local.manifestPath),
1889
+ reason: item.status === 'manual-schema-migration-required' && input.force === true
1890
+ ? `${item.reason || item.status}; --force cannot bypass it`
1891
+ : item.reason || item.status,
1892
+ });
1893
+ }
1894
+ const createItems = createItemsInDependencyOrder(plan.items.filter((item) => selected(item) && item.status === 'create'), localByKey);
1895
+ const resultsByKey = new Map(results.map((result) => [result.stableKey, result]));
1896
+ for (const item of createItems) {
1897
+ const stableKey = item.stableKey;
1898
+ const local = localByKey.get(stableKey);
1899
+ if (!local) {
1900
+ results.push({ stableKey, action: 'create', status: 'failed', targetPath: stableKey, error: 'desired manifest disappeared after planning' });
1901
+ continue;
1902
+ }
1903
+ const parentResult = local.manifest.parentStableKey
1904
+ ? resultsByKey.get(local.manifest.parentStableKey)
1905
+ : undefined;
1906
+ if (parentResult && parentResult.status !== 'deployed') {
1907
+ const skipped = {
1908
+ stableKey,
1909
+ action: 'skipped',
1910
+ status: 'skipped',
1911
+ targetPath: localTargetPath(input.cwd, local.manifestPath),
1912
+ reason: `parent "${local.manifest.parentStableKey}" was not created`,
1913
+ };
1914
+ results.push(skipped);
1915
+ resultsByKey.set(stableKey, skipped);
1916
+ continue;
1917
+ }
1918
+ let writeAccepted = false;
1919
+ try {
1920
+ const desired = portableDatabaseSchemaManifest(local.manifest);
1921
+ if (hashPortableDatabaseSchema(desired) !== item.desiredHash) {
1922
+ throw new Error('desired manifest changed after planning');
1923
+ }
1924
+ await waitForDatabaseSchemaMutation(lastMutation.at);
1925
+ const preflight = await recheckDatabaseSchemaCreate({
1926
+ client: input.client,
1927
+ records: await readTargetInventory(),
1928
+ manifest: desired,
1929
+ stableKeyName: input.stableKeyName,
1930
+ });
1931
+ lastMutation.at = Date.now();
1932
+ const created = unwrapData(await input.client.createDatabaseSchema(createPayloadForDatabaseSchema({ manifest: desired, parentName: preflight.parentName })));
1933
+ writeAccepted = true;
1934
+ const createdId = databaseIdOf(created);
1935
+ if (!createdId) {
1936
+ throw new Error('create response did not include databaseId');
1937
+ }
1938
+ const verified = unwrapData(await input.client.getDatabaseSchema(createdId));
1939
+ const remoteHash = verifyCreatedDatabaseSchema({
1940
+ created: verified,
1941
+ recordsBeforeCreate: preflight.databases,
1942
+ desired,
1943
+ stableKeyName: input.stableKeyName,
1944
+ });
1945
+ replaceTargetInventoryRecord(verified);
1946
+ updateDatabaseSchemaEnvironmentBaseline({
1947
+ cwd: input.cwd,
1948
+ envName: input.envName,
1949
+ manifestPath: local.manifestPath,
1950
+ manifest: desired,
1951
+ remoteHash,
1952
+ });
1953
+ const deployed = {
1954
+ stableKey,
1955
+ action: 'create',
1956
+ status: 'deployed',
1957
+ targetPath: localTargetPath(input.cwd, local.manifestPath),
1958
+ };
1959
+ results.push(deployed);
1960
+ resultsByKey.set(stableKey, deployed);
1961
+ }
1962
+ catch (error) {
1963
+ const message = error instanceof Error ? error.message : String(error);
1964
+ const unavailableParent = error instanceof PartitionParentUnavailableError;
1965
+ const result = {
1966
+ stableKey,
1967
+ action: unavailableParent ? 'skipped' : 'create',
1968
+ status: unavailableParent ? 'skipped' : writeAccepted ? 'write-unverified' : 'failed',
1969
+ targetPath: localTargetPath(input.cwd, local.manifestPath),
1970
+ ...(unavailableParent
1971
+ ? { reason: message }
1972
+ : writeAccepted
1973
+ ? { reason: `POST was accepted, but readback did not verify the desired portable hash; the environment lock was not updated (${message})` }
1974
+ : { error: message }),
1975
+ };
1976
+ results.push(result);
1977
+ resultsByKey.set(stableKey, result);
1978
+ }
1979
+ }
1980
+ const lastAuditedWrite = { at: 0 };
1981
+ const processUpdate = async (item, action) => {
1982
+ const stableKey = item.stableKey;
1983
+ const local = localByKey.get(stableKey);
1984
+ const targetPath = local ? localTargetPath(input.cwd, local.manifestPath) : stableKey;
1985
+ let writeAccepted = false;
1986
+ try {
1987
+ if (!local) {
1988
+ throw new Error('desired manifest disappeared after planning');
1989
+ }
1990
+ const desired = portableDatabaseSchemaManifest(local.manifest);
1991
+ const desiredHash = hashPortableDatabaseSchema(desired);
1992
+ if (desiredHash !== item.desiredHash) {
1993
+ throw new Error('desired manifest changed after planning');
1994
+ }
1995
+ if (!item.currentHash) {
1996
+ throw new Error('plan did not include a target portable hash');
1997
+ }
1998
+ let live;
1999
+ if (action === 'update') {
2000
+ const auditedByPlan = item.changes?.some((change) => change.kind === 'audit-trigger-rebuild') === true;
2001
+ for (let attempt = 0; attempt < 2; attempt += 1) {
2002
+ try {
2003
+ if (auditedByPlan) {
2004
+ await waitForAuditedDatabaseSchemaWrite(lastAuditedWrite.at);
2005
+ }
2006
+ live = await resolveLiveDatabaseSchemaWriteTarget({
2007
+ client: input.client,
2008
+ records: await readTargetInventory(),
2009
+ stableKey,
2010
+ stableKeyName: input.stableKeyName,
2011
+ expectedHash: item.currentHash,
2012
+ });
2013
+ if (!auditedByPlan && live.record.audit === true && lastAuditedWrite.at > 0) {
2014
+ await waitForAuditedDatabaseSchemaWrite(lastAuditedWrite.at);
2015
+ live = await resolveLiveDatabaseSchemaWriteTarget({
2016
+ client: input.client,
2017
+ records: await readTargetInventory(),
2018
+ stableKey,
2019
+ stableKeyName: input.stableKeyName,
2020
+ expectedHash: item.currentHash,
2021
+ });
2022
+ }
2023
+ if (item.changes?.some((change) => change.kind === 'column-renamed')) {
2024
+ await assertNoActiveDatabaseViewDependencies({
2025
+ client: input.client,
2026
+ databaseId: live.databaseId,
2027
+ });
2028
+ }
2029
+ await waitForDatabaseSchemaMutation(lastMutation.at);
2030
+ lastMutation.at = Date.now();
2031
+ await input.client.updateDatabaseSchema(live.databaseId, buildDatabaseSchemaUpdatePayload({
2032
+ desired,
2033
+ target: live.record,
2034
+ stableKeyName: input.stableKeyName,
2035
+ }));
2036
+ writeAccepted = true;
2037
+ if (live.record.audit === true) {
2038
+ lastAuditedWrite.at = Date.now();
2039
+ }
2040
+ break;
2041
+ }
2042
+ catch (error) {
2043
+ if (attempt === 1 || !live || !isRetriableAuditedDatabaseSchemaWrite(error, live.record)) {
2044
+ throw error;
2045
+ }
2046
+ lastAuditedWrite.at = Date.now();
2047
+ }
2048
+ }
2049
+ }
2050
+ else {
2051
+ live = await resolveLiveDatabaseSchemaWriteTarget({
2052
+ client: input.client,
2053
+ records: await readTargetInventory(),
2054
+ stableKey,
2055
+ stableKeyName: input.stableKeyName,
2056
+ expectedHash: item.currentHash,
2057
+ });
2058
+ }
2059
+ if (!live) {
2060
+ throw new Error('live target Database Schema could not be resolved before verification');
2061
+ }
2062
+ const verified = unwrapData(await input.client.getDatabaseSchema(live.databaseId));
2063
+ const remoteHash = verifyDatabaseSchemaWrite({
2064
+ record: verified,
2065
+ recordsBeforeWrite: live.records,
2066
+ desired,
2067
+ stableKeyName: input.stableKeyName,
2068
+ });
2069
+ replaceTargetInventoryRecord(verified);
2070
+ updateDatabaseSchemaEnvironmentBaseline({
2071
+ cwd: input.cwd,
2072
+ envName: input.envName,
2073
+ manifestPath: local.manifestPath,
2074
+ manifest: desired,
2075
+ remoteHash,
2076
+ });
2077
+ results.push({
2078
+ stableKey,
2079
+ action,
2080
+ status: 'deployed',
2081
+ targetPath,
2082
+ ...(action === 'reconcile' && item.status === 'missing-lock'
2083
+ ? { reason: 'force-adopted verified target into environment lock baseline' }
2084
+ : {}),
2085
+ });
2086
+ }
2087
+ catch (error) {
2088
+ const message = error instanceof Error ? error.message : String(error);
2089
+ results.push({
2090
+ stableKey,
2091
+ action,
2092
+ status: writeAccepted ? 'write-unverified' : 'failed',
2093
+ targetPath,
2094
+ ...(writeAccepted
2095
+ ? {
2096
+ reason: `PUT was accepted, but readback did not verify the desired portable hash; the environment lock was not updated (${message})`,
2097
+ }
2098
+ : { error: message }),
2099
+ });
2100
+ }
2101
+ };
2102
+ const forcedContentDrift = (item) => (input.force === true
2103
+ && (item.status === 'missing-lock' || item.status === 'remote-changed' || item.status === 'conflict')
2104
+ && item.desiredHash !== item.currentHash
2105
+ && !item.changes?.some((change) => change.risk === 'manual'));
2106
+ for (const item of plan.items.filter((candidate) => (selected(candidate)
2107
+ && candidate.status === 'safe-update'))) {
2108
+ await processUpdate(item, 'update');
2109
+ }
2110
+ for (const item of plan.items.filter((candidate) => (selected(candidate)
2111
+ && forcedContentDrift(candidate)))) {
2112
+ await processUpdate(item, 'update');
2113
+ }
2114
+ for (const item of plan.items.filter((candidate) => (selected(candidate)
2115
+ && (candidate.status === 'reconciled'
2116
+ || (input.force === true
2117
+ && candidate.status === 'missing-lock'
2118
+ && candidate.desiredHash === candidate.currentHash))))) {
2119
+ await processUpdate(item, 'reconcile');
2120
+ }
2121
+ for (const item of plan.items.filter((candidate) => (selected(candidate)
2122
+ && (candidate.status === 'missing-lock' || candidate.status === 'remote-changed' || candidate.status === 'conflict')
2123
+ && !forcedContentDrift(candidate)
2124
+ && !results.some((result) => result.stableKey === candidate.stableKey)))) {
2125
+ const local = localByKey.get(item.stableKey || '');
2126
+ if (!local) {
2127
+ continue;
2128
+ }
2129
+ results.push({
2130
+ stableKey: item.stableKey,
2131
+ action: 'skipped',
2132
+ status: 'skipped',
2133
+ targetPath: localTargetPath(input.cwd, local.manifestPath),
2134
+ reason: item.changes?.some((change) => change.risk === 'manual')
2135
+ ? 'one or more changes require an explicit DDL or data migration; --force cannot bypass it'
2136
+ : item.status === 'missing-lock'
2137
+ ? 'missing environment lock baseline; review the live target and re-run with --force to adopt an identical target or overwrite safe portable content only'
2138
+ : 'target content changed since the lock baseline; review it and re-run with --force to overwrite safe portable content only',
2139
+ });
2140
+ }
2141
+ for (const item of plan.items.filter((candidate) => (selected(candidate)
2142
+ && (candidate.status === 'previously-deleted-partition' || candidate.status === 'replacement-requires-two-phases')
2143
+ && Boolean(candidate.stableKey)))) {
2144
+ const stableKey = item.stableKey;
2145
+ if (results.some((result) => result.stableKey === stableKey)) {
2146
+ continue;
2147
+ }
2148
+ const local = localByKey.get(stableKey);
2149
+ results.push({
2150
+ stableKey,
2151
+ action: 'skipped',
2152
+ status: 'skipped',
2153
+ targetPath: local ? localTargetPath(input.cwd, local.manifestPath) : stableKey,
2154
+ reason: item.reason || item.status,
2155
+ });
2156
+ }
2157
+ const deleteItems = deletePartitionsLeafFirst(plan.items.filter((candidate) => (selected(candidate)
2158
+ && candidate.status === 'delete-partition'
2159
+ && Boolean(candidate.stableKey)
2160
+ && Boolean(candidate.parentStableKey)
2161
+ && Boolean(candidate.currentHash))));
2162
+ const deletedPartitionKeys = new Set();
2163
+ for (const item of deleteItems) {
2164
+ const stableKey = item.stableKey;
2165
+ if (input.prunePartitions !== true || input.prunePartitionsConfirmed !== true || input.force !== true) {
2166
+ results.push({
2167
+ stableKey,
2168
+ action: 'skipped',
2169
+ status: 'skipped',
2170
+ targetPath: stableKey,
2171
+ reason: 'partition delete requires --prune-partitions --yes --force',
2172
+ });
2173
+ continue;
2174
+ }
2175
+ try {
2176
+ const databases = await readTargetInventory();
2177
+ const catalog = targetPlanCatalog(databases, input.stableKeyName);
2178
+ const live = resolveLivePartitionDeleteTarget({
2179
+ databases,
2180
+ stableKey,
2181
+ stableKeyName: input.stableKeyName,
2182
+ parentStableKey: item.parentStableKey,
2183
+ expectedHash: item.currentHash,
2184
+ });
2185
+ const retainedChild = catalog.managed.find((entry) => (entry.manifest.parentStableKey === stableKey
2186
+ && !deletedPartitionKeys.has(entry.manifest.stableKey)));
2187
+ if (retainedChild) {
2188
+ throw new Error(`live target partition still has retained child "${retainedChild.manifest.stableKey}"`);
2189
+ }
2190
+ await assertNoActiveDatabaseViewDependencies({ client: input.client, databaseId: live.databaseId });
2191
+ await waitForDatabaseSchemaMutation(lastMutation.at);
2192
+ const refreshed = unwrapData(await input.client.getDatabaseSchema(live.databaseId));
2193
+ replaceTargetInventoryRecord(refreshed);
2194
+ const deleteTarget = resolveLivePartitionDeleteTarget({
2195
+ databases: await readTargetInventory(),
2196
+ stableKey,
2197
+ stableKeyName: input.stableKeyName,
2198
+ parentStableKey: item.parentStableKey,
2199
+ expectedHash: item.currentHash,
2200
+ });
2201
+ const currentChildren = await input.client.findActiveDatabaseSchemasByField('parent', deleteTarget.manifest.name);
2202
+ const retainedCurrentChild = currentChildren.find((record) => {
2203
+ const childStableKey = configuredStableKey(record, input.stableKeyName);
2204
+ return !childStableKey || !deletedPartitionKeys.has(childStableKey);
2205
+ });
2206
+ if (retainedCurrentChild) {
2207
+ throw new Error(`live target partition still has retained child "${configuredStableKey(retainedCurrentChild, input.stableKeyName) || databaseIdOf(retainedCurrentChild)}"`);
2208
+ }
2209
+ lastMutation.at = Date.now();
2210
+ await input.client.deleteDatabaseSchema(deleteTarget.databaseId);
2211
+ await verifyDeletedDatabaseSchema({
2212
+ client: input.client,
2213
+ databaseId: deleteTarget.databaseId,
2214
+ });
2215
+ targetInventory = (await readTargetInventory()).filter((record) => (databaseIdOf(record) !== deleteTarget.databaseId));
2216
+ recordDeletedPartitionTombstone({
2217
+ cwd: input.cwd,
2218
+ envName: input.envName,
2219
+ stableKey,
2220
+ parentStableKey: item.parentStableKey,
2221
+ remoteHash: item.currentHash,
2222
+ });
2223
+ deletedPartitionKeys.add(stableKey);
2224
+ results.push({ stableKey, action: 'delete', status: 'deployed', targetPath: stableKey });
2225
+ }
2226
+ catch (error) {
2227
+ results.push({
2228
+ stableKey,
2229
+ action: 'delete',
2230
+ status: 'failed',
2231
+ targetPath: stableKey,
2232
+ error: error instanceof Error ? error.message : String(error),
2233
+ });
2234
+ }
2235
+ }
2236
+ for (const item of plan.items.filter((candidate) => (selected(candidate)
2237
+ && candidate.status === 'orphan-root'
2238
+ && Boolean(candidate.stableKey)
2239
+ && Boolean(candidate.currentHash)))) {
2240
+ const stableKey = item.stableKey;
2241
+ if (input.pruneOrphans !== true || input.pruneOrphansConfirmed !== true || input.force !== true) {
2242
+ results.push({
2243
+ stableKey,
2244
+ action: 'skipped',
2245
+ status: 'skipped',
2246
+ targetPath: stableKey,
2247
+ reason: 'root orphan delete requires --prune-orphans --yes --force',
2248
+ });
2249
+ continue;
2250
+ }
2251
+ try {
2252
+ const live = resolveLiveOrphanRootDeleteTarget({
2253
+ databases: await readTargetInventory(),
2254
+ stableKey,
2255
+ stableKeyName: input.stableKeyName,
2256
+ expectedHash: item.currentHash,
2257
+ });
2258
+ await assertNoActiveDatabaseViewDependencies({ client: input.client, databaseId: live.databaseId });
2259
+ const activeChildren = await input.client.findActiveDatabaseSchemasByField('parent', live.manifest.name);
2260
+ if (activeChildren.length > 0) {
2261
+ throw new Error(`live target root schema still has ${activeChildren.length} active partition(s)`);
2262
+ }
2263
+ await waitForDatabaseSchemaMutation(lastMutation.at);
2264
+ const refreshed = unwrapData(await input.client.getDatabaseSchema(live.databaseId));
2265
+ replaceTargetInventoryRecord(refreshed);
2266
+ const deleteTarget = resolveLiveOrphanRootDeleteTarget({
2267
+ databases: await readTargetInventory(),
2268
+ stableKey,
2269
+ stableKeyName: input.stableKeyName,
2270
+ expectedHash: item.currentHash,
2271
+ });
2272
+ lastMutation.at = Date.now();
2273
+ await input.client.deleteDatabaseSchema(deleteTarget.databaseId);
2274
+ await verifyDeletedDatabaseSchema({ client: input.client, databaseId: deleteTarget.databaseId });
2275
+ targetInventory = (await readTargetInventory()).filter((record) => (databaseIdOf(record) !== deleteTarget.databaseId));
2276
+ results.push({ stableKey, action: 'delete', status: 'deployed', targetPath: stableKey });
2277
+ }
2278
+ catch (error) {
2279
+ results.push({
2280
+ stableKey,
2281
+ action: 'delete',
2282
+ status: 'failed',
2283
+ targetPath: stableKey,
2284
+ error: error instanceof Error ? error.message : String(error),
2285
+ });
2286
+ }
2287
+ }
2288
+ return { plan, results };
2289
+ }