@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,1317 @@
1
+ import path from 'node:path';
2
+ import { hashStable, readComponentLock, writeComponentLock } from "../component-lock.js";
3
+ import { readConfiguredResourceMetadataStableKey, sanitizeUserMetadata } from "../resource-metadata.js";
4
+ import { readJsonFile, sanitizeSegment } from "../utils.js";
5
+ import { findManifestFiles, isRecord, localTargetPath, requireProjectLayout, stableKeySuffix, toPortableRelativePath, unwrapData, writePulledManifest, } from "./util.js";
6
+ export const DATABASE_VIEWS_DIRECTORY = 'DatabaseViews';
7
+ export const DATABASE_VIEW_MANIFEST_FILE = 'database-view.json';
8
+ function canonicalValue(value) {
9
+ if (Array.isArray(value)) {
10
+ return value.map((item) => canonicalValue(item));
11
+ }
12
+ if (!isRecord(value)) {
13
+ return value;
14
+ }
15
+ return Object.fromEntries(Object.keys(value)
16
+ .sort((left, right) => left.localeCompare(right))
17
+ .map((key) => [key, canonicalValue(value[key])]));
18
+ }
19
+ function isNonEmptyString(value) {
20
+ return typeof value === 'string' && value.trim().length > 0;
21
+ }
22
+ function viewIdOf(record) {
23
+ return String(record.databaseViewId || record.id || '');
24
+ }
25
+ function databaseIdOf(record) {
26
+ return String(record.databaseId || record.id || '');
27
+ }
28
+ function stableKeyOf(record, stableKeyName) {
29
+ return readConfiguredResourceMetadataStableKey(record, stableKeyName);
30
+ }
31
+ function metadataFromRecord(record, stableKeyName, stableKey) {
32
+ return canonicalValue({
33
+ ...sanitizeUserMetadata(record.resourceMetadata),
34
+ ...sanitizeUserMetadata(record.metaData),
35
+ ...sanitizeUserMetadata(record.metadata),
36
+ [stableKeyName]: stableKey,
37
+ });
38
+ }
39
+ function databaseViewManifestPath(workspaceRoot, manifest) {
40
+ const category = typeof manifest.category === 'string' && manifest.category.trim()
41
+ ? sanitizeSegment(manifest.category)
42
+ : 'Uncategorized';
43
+ return path.join(workspaceRoot, DATABASE_VIEWS_DIRECTORY, category, `${sanitizeSegment(manifest.name)}-${stableKeySuffix(manifest.stableKey)}`, DATABASE_VIEW_MANIFEST_FILE);
44
+ }
45
+ function readWorkspaceDatabaseViews(cwd) {
46
+ const { workspaceRoot } = requireProjectLayout(cwd, 'Database View');
47
+ return findManifestFiles(path.join(workspaceRoot, DATABASE_VIEWS_DIRECTORY), DATABASE_VIEW_MANIFEST_FILE)
48
+ .map((manifestPath) => ({
49
+ manifestPath,
50
+ manifest: readJsonFile(manifestPath),
51
+ }));
52
+ }
53
+ function validColumn(value) {
54
+ return isRecord(value)
55
+ && isNonEmptyString(value.stableKey)
56
+ && isNonEmptyString(value.name)
57
+ && isNonEmptyString(value.type)
58
+ && typeof value.isArray === 'boolean'
59
+ && Object.keys(value).every((key) => ['stableKey', 'name', 'type', 'isArray'].includes(key));
60
+ }
61
+ function validSource(value) {
62
+ return isRecord(value)
63
+ && isNonEmptyString(value.databaseStableKey)
64
+ && isNonEmptyString(value.name)
65
+ && Array.isArray(value.columns)
66
+ && value.columns.every(validColumn)
67
+ && Object.keys(value).every((key) => ['databaseStableKey', 'name', 'columns'].includes(key));
68
+ }
69
+ function validRawSqlSource(value) {
70
+ return isRecord(value)
71
+ && isNonEmptyString(value.databaseStableKey)
72
+ && isNonEmptyString(value.name)
73
+ && Object.keys(value).every((key) => ['databaseStableKey', 'name'].includes(key));
74
+ }
75
+ function validOutput(value) {
76
+ return isRecord(value)
77
+ && isNonEmptyString(value.name)
78
+ && isNonEmptyString(value.type)
79
+ && typeof value.isArray === 'boolean'
80
+ && Object.keys(value).every((key) => ['name', 'type', 'isArray'].includes(key));
81
+ }
82
+ function validManifest(value, stableKeyName) {
83
+ if (!isRecord(value)) {
84
+ return false;
85
+ }
86
+ if (!Object.keys(value).every((key) => [
87
+ 'kind', 'stableKey', 'name', 'category', 'description', 'metadata', 'type', 'definitionMode', 'request', 'rawQuery', 'sources', 'output', 'uniqueKey', 'indexes',
88
+ ].includes(key))) {
89
+ return false;
90
+ }
91
+ if (value.kind !== 'database-view'
92
+ || !isNonEmptyString(value.stableKey)
93
+ || !isNonEmptyString(value.name)
94
+ || (value.type !== 'VIEW' && value.type !== 'MATERIALIZED_VIEW')
95
+ || (value.definitionMode !== 'STRUCTURED' && value.definitionMode !== 'RAW_SQL')
96
+ || !isRecord(value.metadata)
97
+ || !Array.isArray(value.sources)
98
+ || !Array.isArray(value.output)
99
+ || !value.output.every(validOutput)) {
100
+ return false;
101
+ }
102
+ if ((value.definitionMode === 'STRUCTURED' && (!isRecord(value.request) || !value.sources.every(validSource) || value.rawQuery !== undefined))
103
+ || (value.definitionMode === 'RAW_SQL' && (!isNonEmptyString(value.rawQuery) || !value.sources.every(validRawSqlSource) || value.request !== undefined))) {
104
+ return false;
105
+ }
106
+ if (value.category !== undefined && value.category !== null && typeof value.category !== 'string') {
107
+ return false;
108
+ }
109
+ if (value.description !== undefined && typeof value.description !== 'string') {
110
+ return false;
111
+ }
112
+ if (value.type === 'VIEW' && (value.uniqueKey !== undefined || value.indexes !== undefined)) {
113
+ return false;
114
+ }
115
+ if (value.type === 'MATERIALIZED_VIEW') {
116
+ if (!Array.isArray(value.uniqueKey)
117
+ || value.uniqueKey.length === 0
118
+ || !value.uniqueKey.every(isNonEmptyString)) {
119
+ return false;
120
+ }
121
+ if (value.indexes !== undefined && (!Array.isArray(value.indexes)
122
+ || !value.indexes.every((index) => (isRecord(index)
123
+ && Array.isArray(index.columns)
124
+ && index.columns.length > 0
125
+ && index.columns.every(isNonEmptyString)
126
+ && (index.unique === undefined || typeof index.unique === 'boolean')
127
+ && Object.keys(index).every((key) => key === 'columns' || key === 'unique'))))) {
128
+ return false;
129
+ }
130
+ }
131
+ return stableKeyOf(value, stableKeyName) === value.stableKey;
132
+ }
133
+ export function hashPortableDatabaseView(manifest) {
134
+ return hashStable(canonicalValue({
135
+ ...manifest,
136
+ sources: [...manifest.sources].sort((left, right) => (left.databaseStableKey.localeCompare(right.databaseStableKey)
137
+ || left.name.localeCompare(right.name))),
138
+ }));
139
+ }
140
+ export function hashDatabaseViewManifest(manifest) {
141
+ return hashPortableDatabaseView(manifest);
142
+ }
143
+ function exclusionReason(record) {
144
+ if (record.deletedAt || record.deletedBy) {
145
+ return 'deleted Database View is excluded';
146
+ }
147
+ if (record.definitionMode !== 'STRUCTURED' && record.definitionMode !== 'RAW_SQL') {
148
+ return `definitionMode ${String(record.definitionMode || 'unknown')} is unsupported`;
149
+ }
150
+ if (record.type !== 'VIEW' && record.type !== 'MATERIALIZED_VIEW') {
151
+ return `type ${String(record.type || 'unknown')} is unsupported; only STRUCTURED VIEW or MATERIALIZED_VIEW is migrated`;
152
+ }
153
+ return undefined;
154
+ }
155
+ function sourceInventory(databases, stableKeyName) {
156
+ const byId = new Map();
157
+ const stableKeys = new Map();
158
+ const duplicateStableKeys = new Set();
159
+ for (const database of databases) {
160
+ const id = databaseIdOf(database);
161
+ if (!id) {
162
+ continue;
163
+ }
164
+ byId.set(id, database);
165
+ const stableKey = stableKeyOf(database, stableKeyName);
166
+ if (!stableKey) {
167
+ continue;
168
+ }
169
+ const previousId = stableKeys.get(stableKey);
170
+ if (previousId && previousId !== id) {
171
+ duplicateStableKeys.add(stableKey);
172
+ continue;
173
+ }
174
+ stableKeys.set(stableKey, id);
175
+ }
176
+ return { byId, duplicateStableKeys };
177
+ }
178
+ function sourceColumns(input) {
179
+ const databaseName = String(input.database.name || databaseIdOf(input.database));
180
+ if (!Array.isArray(input.dependencyFields)) {
181
+ return `Database View source "${databaseName}" does not expose structured field dependencies`;
182
+ }
183
+ const definitions = Array.isArray(input.database.definition) ? input.database.definition.filter(isRecord) : [];
184
+ const byName = new Map(definitions.map((definition) => [String(definition.name || ''), definition]));
185
+ const columns = [];
186
+ const seenStableKeys = new Set();
187
+ for (const fieldName of input.dependencyFields) {
188
+ if (!isNonEmptyString(fieldName)) {
189
+ return `Database View source "${databaseName}" has an invalid referenced column`;
190
+ }
191
+ const definition = byName.get(fieldName);
192
+ if (!definition) {
193
+ return `Database View source "${databaseName}" is missing referenced column "${fieldName}"`;
194
+ }
195
+ const stableKey = stableKeyOf(definition, input.stableKeyName);
196
+ if (!stableKey) {
197
+ return `Database View source column "${databaseName}.${fieldName}" is missing ${input.stableKeyName}`;
198
+ }
199
+ if (seenStableKeys.has(stableKey)) {
200
+ return `Database View source "${databaseName}" has duplicate column stableKey "${stableKey}"`;
201
+ }
202
+ seenStableKeys.add(stableKey);
203
+ if (!isNonEmptyString(definition.type)) {
204
+ return `Database View source column "${databaseName}.${fieldName}" has no type`;
205
+ }
206
+ columns.push({ stableKey, name: fieldName, type: definition.type, isArray: definition.isArray === true });
207
+ }
208
+ return columns;
209
+ }
210
+ function outputSignature(record) {
211
+ const name = String(record.name || viewIdOf(record) || 'unknown');
212
+ if (!Array.isArray(record.definition) || record.definition.length === 0) {
213
+ return `Database View "${name}" has no output definition`;
214
+ }
215
+ const output = [];
216
+ const names = new Set();
217
+ for (const field of record.definition) {
218
+ if (!isRecord(field) || !isNonEmptyString(field.name) || !isNonEmptyString(field.type)) {
219
+ return `Database View "${name}" has an invalid output definition`;
220
+ }
221
+ if (names.has(field.name)) {
222
+ return `Database View "${name}" has duplicate output column "${field.name}"`;
223
+ }
224
+ names.add(field.name);
225
+ output.push({ name: field.name, type: field.type, isArray: field.isArray === true });
226
+ }
227
+ return output;
228
+ }
229
+ function materializedConfiguration(record, output) {
230
+ if (!Array.isArray(record.uniqueKey) || record.uniqueKey.length === 0 || !record.uniqueKey.every(isNonEmptyString)) {
231
+ return `Database View "${String(record.name || viewIdOf(record) || 'unknown')}" has no valid materialized uniqueKey`;
232
+ }
233
+ const outputNames = new Set(output.map((field) => field.name));
234
+ if (record.uniqueKey.some((column) => !outputNames.has(column))) {
235
+ return `Database View "${String(record.name || viewIdOf(record) || 'unknown')}" uniqueKey references an unknown output column`;
236
+ }
237
+ const indexes = [];
238
+ if (record.indexes !== undefined && !Array.isArray(record.indexes)) {
239
+ return `Database View "${String(record.name || viewIdOf(record) || 'unknown')}" has an invalid materialized index`;
240
+ }
241
+ for (const index of record.indexes || []) {
242
+ if (!isRecord(index) || !Array.isArray(index.columns) || index.columns.length === 0) {
243
+ return `Database View "${String(record.name || viewIdOf(record) || 'unknown')}" has an invalid materialized index`;
244
+ }
245
+ const columns = [];
246
+ for (const column of index.columns) {
247
+ const name = isNonEmptyString(column)
248
+ ? column
249
+ : isRecord(column) && isNonEmptyString(column.name)
250
+ ? column.name
251
+ : undefined;
252
+ if (!name) {
253
+ return `Database View "${String(record.name || viewIdOf(record) || 'unknown')}" has an invalid materialized index`;
254
+ }
255
+ columns.push(name);
256
+ }
257
+ if (columns.some((column) => !outputNames.has(column))) {
258
+ return `Database View "${String(record.name || viewIdOf(record) || 'unknown')}" index references an unknown output column`;
259
+ }
260
+ if (index.unique !== undefined && typeof index.unique !== 'boolean') {
261
+ return `Database View "${String(record.name || viewIdOf(record) || 'unknown')}" has an invalid materialized index unique flag`;
262
+ }
263
+ indexes.push({ columns, ...(index.unique === true ? { unique: true } : {}) });
264
+ }
265
+ return {
266
+ uniqueKey: [...record.uniqueKey],
267
+ indexes: indexes.sort((left, right) => hashStable(canonicalValue(left)).localeCompare(hashStable(canonicalValue(right)))),
268
+ };
269
+ }
270
+ function rawSqlSources(input) {
271
+ if (!Array.isArray(input.record.sourceDatabaseIds)) {
272
+ return `Database View "${String(input.record.name || viewIdOf(input.record) || 'unknown')}" does not expose raw SQL source Database Schemas`;
273
+ }
274
+ const sources = [];
275
+ for (const sourceId of [...new Set(input.record.sourceDatabaseIds)]) {
276
+ const database = input.inventory.byId.get(sourceId);
277
+ if (!database) {
278
+ return `Database View "${String(input.record.name || viewIdOf(input.record) || 'unknown')}" source Database Schema "${sourceId}" is unavailable`;
279
+ }
280
+ const databaseStableKey = stableKeyOf(database, input.stableKeyName);
281
+ if (!databaseStableKey) {
282
+ return `Database View source Database Schema "${String(database.name || sourceId)}" is missing ${input.stableKeyName}`;
283
+ }
284
+ if (input.inventory.duplicateStableKeys.has(databaseStableKey)) {
285
+ return `Database View source Database Schema "${String(database.name || sourceId)}" has duplicate stableKey "${databaseStableKey}"`;
286
+ }
287
+ if (!isNonEmptyString(database.name)) {
288
+ return `Database View source Database Schema "${sourceId}" has no logical name`;
289
+ }
290
+ sources.push({ databaseStableKey, name: database.name });
291
+ }
292
+ return sources.sort((left, right) => (left.databaseStableKey.localeCompare(right.databaseStableKey)
293
+ || left.name.localeCompare(right.name)));
294
+ }
295
+ function manifestFromRecord(input) {
296
+ const { record, stableKey, stableKeyName, inventory } = input;
297
+ if (!isNonEmptyString(record.name)) {
298
+ return `Database View "${String(record.name || viewIdOf(record) || stableKey)}" has no valid name`;
299
+ }
300
+ const output = outputSignature(record);
301
+ if (typeof output === 'string') {
302
+ return output;
303
+ }
304
+ const materialized = record.type === 'MATERIALIZED_VIEW' ? materializedConfiguration(record, output) : undefined;
305
+ if (typeof materialized === 'string') {
306
+ return materialized;
307
+ }
308
+ const base = {
309
+ kind: 'database-view',
310
+ stableKey,
311
+ name: record.name,
312
+ ...(record.category === undefined ? {} : { category: record.category }),
313
+ ...(typeof record.desc === 'string' ? { description: record.desc } : typeof record.description === 'string' ? { description: record.description } : {}),
314
+ metadata: metadataFromRecord(record, stableKeyName, stableKey),
315
+ type: record.type,
316
+ output,
317
+ ...(materialized ? materialized : {}),
318
+ };
319
+ if (record.definitionMode === 'RAW_SQL') {
320
+ if (!isNonEmptyString(record.rawQuery)) {
321
+ return `Database View "${record.name}" has no valid raw SQL query`;
322
+ }
323
+ const sources = rawSqlSources({ record, stableKeyName, inventory });
324
+ if (typeof sources === 'string') {
325
+ return sources;
326
+ }
327
+ return {
328
+ ...base,
329
+ definitionMode: 'RAW_SQL',
330
+ rawQuery: record.rawQuery,
331
+ sources,
332
+ };
333
+ }
334
+ if (!isRecord(record.request)) {
335
+ return `Database View "${String(record.name || viewIdOf(record) || stableKey)}" has no valid structured request`;
336
+ }
337
+ if (!Array.isArray(record.sourceDatabaseIds) || record.sourceDatabaseIds.length === 0) {
338
+ return `Database View "${record.name}" has no source Database Schemas`;
339
+ }
340
+ const dependencies = isRecord(record.sourceFieldDependencies) ? record.sourceFieldDependencies : {};
341
+ const sources = [];
342
+ for (const sourceId of [...new Set(record.sourceDatabaseIds)]) {
343
+ const database = inventory.byId.get(sourceId);
344
+ if (!database) {
345
+ return `Database View "${record.name}" source Database Schema "${sourceId}" is unavailable`;
346
+ }
347
+ const databaseStableKey = stableKeyOf(database, stableKeyName);
348
+ if (!databaseStableKey) {
349
+ return `Database View source Database Schema "${String(database.name || sourceId)}" is missing ${stableKeyName}`;
350
+ }
351
+ if (inventory.duplicateStableKeys.has(databaseStableKey)) {
352
+ return `Database View source Database Schema "${String(database.name || sourceId)}" has duplicate stableKey "${databaseStableKey}"`;
353
+ }
354
+ if (!isNonEmptyString(database.name)) {
355
+ return `Database View source Database Schema "${sourceId}" has no logical name`;
356
+ }
357
+ const columns = sourceColumns({ database, dependencyFields: dependencies[sourceId], stableKeyName });
358
+ if (typeof columns === 'string') {
359
+ return columns;
360
+ }
361
+ sources.push({ databaseStableKey, name: database.name, columns });
362
+ }
363
+ return {
364
+ ...base,
365
+ definitionMode: 'STRUCTURED',
366
+ request: canonicalValue(record.request),
367
+ sources,
368
+ };
369
+ }
370
+ function warningKey(record, stableKey) {
371
+ return stableKey || String(record.name || viewIdOf(record) || 'unknown Database View');
372
+ }
373
+ function emptyPlanCounts() {
374
+ return {
375
+ clean: 0,
376
+ create: 0,
377
+ 'safe-update': 0,
378
+ 'remote-changed': 0,
379
+ conflict: 0,
380
+ 'missing-lock': 0,
381
+ orphan: 0,
382
+ collision: 0,
383
+ skipped: 0,
384
+ };
385
+ }
386
+ function diffManifestFields(desired, current) {
387
+ const fields = [
388
+ ['name', desired.name, current.name],
389
+ ['category', desired.category ?? null, current.category ?? null],
390
+ ['description', desired.description ?? '', current.description ?? ''],
391
+ ['metadata', desired.metadata, current.metadata],
392
+ ['definitionMode', desired.definitionMode, current.definitionMode],
393
+ ['request', desired.definitionMode === 'STRUCTURED' ? desired.request : null, current.definitionMode === 'STRUCTURED' ? current.request : null],
394
+ ['rawQuery', desired.definitionMode === 'RAW_SQL' ? desired.rawQuery : null, current.definitionMode === 'RAW_SQL' ? current.rawQuery : null],
395
+ ['sources', desired.sources, current.sources],
396
+ ['output', desired.output, current.output],
397
+ ];
398
+ return fields
399
+ .filter(([, expected, actual]) => hashStable(canonicalValue(expected)) !== hashStable(canonicalValue(actual)))
400
+ .map(([field]) => field);
401
+ }
402
+ function materializedPlanDetails(input) {
403
+ if (input.manifest.type !== 'MATERIALIZED_VIEW') {
404
+ return {};
405
+ }
406
+ const scheduleDependencies = input.databaseViewId
407
+ ? input.schedules
408
+ .filter((schedule) => {
409
+ if (schedule.active !== true || schedule.targetType !== 'PLATFORM_OPERATION') {
410
+ return false;
411
+ }
412
+ const config = schedule.targetConfig;
413
+ return isRecord(config)
414
+ && config.kind === 'refresh_materialized_view'
415
+ && schedule.targetId === input.databaseViewId;
416
+ })
417
+ .map((schedule) => String(schedule.name || schedule.scheduleId || schedule.id || 'unnamed refresh schedule'))
418
+ .sort((left, right) => left.localeCompare(right))
419
+ : [];
420
+ return {
421
+ risks: ['materialization/rebuild can consume the shared databases and databaseSize quota; deploy in a reviewed maintenance window'],
422
+ ...(scheduleDependencies.length > 0 ? { scheduleDependencies } : {}),
423
+ };
424
+ }
425
+ function validateTargetSources(manifest, databases, stableKeyName) {
426
+ const byStableKey = new Map();
427
+ for (const database of databases) {
428
+ const stableKey = stableKeyOf(database, stableKeyName);
429
+ if (!stableKey) {
430
+ continue;
431
+ }
432
+ byStableKey.set(stableKey, [...(byStableKey.get(stableKey) || []), database]);
433
+ }
434
+ for (const source of manifest.sources) {
435
+ const candidates = byStableKey.get(source.databaseStableKey) || [];
436
+ if (candidates.length !== 1) {
437
+ return `source Database Schema Stable Key "${source.databaseStableKey}" resolves to ${candidates.length} TARGET records`;
438
+ }
439
+ const database = candidates[0];
440
+ if (database.name !== source.name) {
441
+ return `source Database Schema "${source.databaseStableKey}" has TARGET name "${String(database.name || '')}" instead of "${source.name}"`;
442
+ }
443
+ if (!('columns' in source)) {
444
+ continue;
445
+ }
446
+ const columnsByStableKey = new Map();
447
+ for (const definition of Array.isArray(database.definition) ? database.definition.filter(isRecord) : []) {
448
+ const stableKey = stableKeyOf(definition, stableKeyName);
449
+ if (!stableKey) {
450
+ continue;
451
+ }
452
+ columnsByStableKey.set(stableKey, [...(columnsByStableKey.get(stableKey) || []), definition]);
453
+ }
454
+ for (const sourceColumn of source.columns) {
455
+ const columns = columnsByStableKey.get(sourceColumn.stableKey) || [];
456
+ if (columns.length !== 1) {
457
+ return `source column Stable Key "${sourceColumn.stableKey}" resolves to ${columns.length} TARGET definitions`;
458
+ }
459
+ const column = columns[0];
460
+ if (column.name !== sourceColumn.name
461
+ || column.type !== sourceColumn.type
462
+ || (column.isArray === true) !== sourceColumn.isArray) {
463
+ return `source column "${sourceColumn.stableKey}" is incompatible on TARGET`;
464
+ }
465
+ }
466
+ }
467
+ return undefined;
468
+ }
469
+ function databaseViewPreviewPayload(manifest) {
470
+ const output = { take: 0, count: false };
471
+ if (manifest.definitionMode === 'RAW_SQL') {
472
+ return {
473
+ definitionMode: 'RAW_SQL',
474
+ rawQuery: manifest.rawQuery,
475
+ take: 0,
476
+ output,
477
+ };
478
+ }
479
+ return {
480
+ definitionMode: 'STRUCTURED',
481
+ request: manifest.request,
482
+ take: 0,
483
+ output,
484
+ };
485
+ }
486
+ function previewOutput(result) {
487
+ const value = isRecord(result) && isRecord(result.data) ? result.data : result;
488
+ if (!isRecord(value)) {
489
+ return 'target preview returned an invalid response';
490
+ }
491
+ return outputSignature({ definition: value.definition });
492
+ }
493
+ function supportedTargetManifest(input) {
494
+ const exclusion = exclusionReason(input.record);
495
+ if (exclusion) {
496
+ return exclusion;
497
+ }
498
+ return manifestFromRecord(input);
499
+ }
500
+ export async function buildDatabaseViewsPlan(input) {
501
+ const { projectRoot } = requireProjectLayout(input.cwd, 'Database View');
502
+ const localEntries = readWorkspaceDatabaseViews(input.cwd);
503
+ const [targetViews, targetDatabaseResult, targetSchedules] = await Promise.all([
504
+ input.client.listAllDatabaseViewsForSync(),
505
+ input.client.listAllDatabaseSchemas({ projection: 'pull' }),
506
+ input.client.listAllSchedules(),
507
+ ]);
508
+ const targetDatabases = targetDatabaseResult.databases;
509
+ const targetInventory = sourceInventory(targetDatabases, input.stableKeyName);
510
+ const lock = readComponentLock(projectRoot);
511
+ const items = [];
512
+ const blockers = [];
513
+ const globalBlockers = [];
514
+ const warnings = [];
515
+ const exclusions = [];
516
+ const localByKey = new Map();
517
+ const lockEntriesByPath = new Map(Object.values(lock.databaseViews).map((entry) => [entry.path, entry]));
518
+ for (const entry of localEntries) {
519
+ if (!validManifest(entry.manifest, input.stableKeyName)) {
520
+ blockers.push(`Invalid Database View manifest ${path.relative(projectRoot, entry.manifestPath)}.`);
521
+ items.push({ status: 'conflict', name: path.basename(path.dirname(entry.manifestPath)), reason: 'invalid Database View manifest' });
522
+ continue;
523
+ }
524
+ const lockEntry = lockEntriesByPath.get(toPortableRelativePath(projectRoot, entry.manifestPath));
525
+ if (lockEntry && lockEntry.stableKey !== entry.manifest.stableKey) {
526
+ blockers.push(`Database View manifest ${path.relative(projectRoot, entry.manifestPath)} changed stableKey from "${lockEntry.stableKey}" to "${entry.manifest.stableKey}".`);
527
+ items.push({
528
+ status: 'conflict',
529
+ stableKey: entry.manifest.stableKey,
530
+ name: entry.manifest.name,
531
+ reason: `stableKey changed from "${lockEntry.stableKey}"; identity changes are not renames`,
532
+ });
533
+ continue;
534
+ }
535
+ localByKey.set(entry.manifest.stableKey, [...(localByKey.get(entry.manifest.stableKey) || []), entry]);
536
+ }
537
+ const targetByKey = new Map();
538
+ const excludedTargetByKey = new Map();
539
+ const targetUnmanagedNames = new Set();
540
+ for (const record of targetViews) {
541
+ const stableKey = stableKeyOf(record, input.stableKeyName);
542
+ const exclusion = exclusionReason(record);
543
+ const name = warningKey(record, stableKey || undefined);
544
+ if (exclusion) {
545
+ exclusions.push(`Database View "${name}": ${exclusion}.`);
546
+ items.push({ status: 'skipped', stableKey: stableKey || undefined, name, reason: exclusion });
547
+ if (stableKey) {
548
+ excludedTargetByKey.set(stableKey, exclusion);
549
+ }
550
+ continue;
551
+ }
552
+ if (!stableKey) {
553
+ targetUnmanagedNames.add(String(record.name || ''));
554
+ exclusions.push(`Database View "${name}": missing ${input.stableKeyName}; unmanaged.`);
555
+ items.push({ status: 'skipped', name, reason: `missing ${input.stableKeyName}; unmanaged` });
556
+ continue;
557
+ }
558
+ targetByKey.set(stableKey, [...(targetByKey.get(stableKey) || []), record]);
559
+ }
560
+ const duplicateTargetKeys = new Set();
561
+ for (const [stableKey, records] of targetByKey) {
562
+ if (records.length > 1) {
563
+ duplicateTargetKeys.add(stableKey);
564
+ const blocker = `Duplicate TARGET Database View stableKey "${stableKey}".`;
565
+ blockers.push(blocker);
566
+ globalBlockers.push(blocker);
567
+ items.push({ status: 'collision', stableKey, name: warningKey(records[0], stableKey), reason: 'duplicate target stableKey' });
568
+ }
569
+ }
570
+ for (const [stableKey, entries] of localByKey) {
571
+ if (entries.length > 1) {
572
+ const blocker = `Duplicate local Database View stableKey "${stableKey}".`;
573
+ blockers.push(blocker);
574
+ globalBlockers.push(blocker);
575
+ items.push({ status: 'collision', stableKey, name: entries[0].manifest.name, reason: 'duplicate local stableKey' });
576
+ continue;
577
+ }
578
+ const entry = entries[0];
579
+ const desired = entry.manifest;
580
+ const desiredHash = hashPortableDatabaseView(desired);
581
+ const excludedTargetReason = excludedTargetByKey.get(stableKey);
582
+ if (excludedTargetReason) {
583
+ const reason = `TARGET Database View is incompatible: ${excludedTargetReason}`;
584
+ blockers.push(`Database View "${stableKey}": ${reason}.`);
585
+ items.push({ status: 'collision', stableKey, name: desired.name, reason, desiredHash });
586
+ continue;
587
+ }
588
+ const sourceReadiness = validateTargetSources(desired, targetDatabases, input.stableKeyName);
589
+ if (sourceReadiness) {
590
+ blockers.push(`Database View "${stableKey}": ${sourceReadiness}.`);
591
+ items.push({ status: 'conflict', stableKey, name: desired.name, reason: sourceReadiness, desiredHash });
592
+ continue;
593
+ }
594
+ let preview;
595
+ try {
596
+ preview = await input.client.previewDatabaseView(databaseViewPreviewPayload(desired));
597
+ }
598
+ catch (error) {
599
+ const message = error instanceof Error ? error.message : String(error);
600
+ warnings.push(`Database View "${stableKey}" TARGET preview failed: ${message}`);
601
+ items.push({ status: 'conflict', stableKey, name: desired.name, reason: `target preview failed: ${message}`, desiredHash });
602
+ continue;
603
+ }
604
+ const output = previewOutput(preview);
605
+ if (typeof output === 'string' || hashStable(output) !== hashStable(desired.output)) {
606
+ const reason = typeof output === 'string' ? output : 'target preview output signature differs from the manifest';
607
+ blockers.push(`Database View "${stableKey}": ${reason}.`);
608
+ items.push({ status: 'conflict', stableKey, name: desired.name, reason, desiredHash });
609
+ continue;
610
+ }
611
+ const records = targetByKey.get(stableKey) || [];
612
+ if (duplicateTargetKeys.has(stableKey)) {
613
+ continue;
614
+ }
615
+ if (records.length === 0) {
616
+ if (targetUnmanagedNames.has(desired.name)) {
617
+ const reason = 'TARGET has same-name unmanaged Database View; automatic adoption is forbidden';
618
+ blockers.push(`Database View "${stableKey}": ${reason}.`);
619
+ items.push({ status: 'collision', stableKey, name: desired.name, reason, desiredHash });
620
+ }
621
+ else {
622
+ items.push({ status: 'create', stableKey, name: desired.name, desiredHash, ...materializedPlanDetails({ manifest: desired, schedules: targetSchedules.schedules }) });
623
+ }
624
+ continue;
625
+ }
626
+ const record = records[0];
627
+ const current = supportedTargetManifest({ record, stableKey, stableKeyName: input.stableKeyName, inventory: targetInventory });
628
+ if (typeof current === 'string') {
629
+ const reason = `TARGET Database View is incompatible: ${current}`;
630
+ blockers.push(`Database View "${stableKey}": ${reason}.`);
631
+ items.push({ status: 'collision', stableKey, name: desired.name, reason, desiredHash });
632
+ continue;
633
+ }
634
+ if (current.name !== desired.name) {
635
+ const reason = 'TARGET name differs; renames require an explicit migration';
636
+ blockers.push(`Database View "${stableKey}": ${reason}.`);
637
+ items.push({ status: 'conflict', stableKey, name: desired.name, reason, desiredHash });
638
+ continue;
639
+ }
640
+ if (current.type !== desired.type || current.definitionMode !== desired.definitionMode) {
641
+ const reason = current.type !== desired.type
642
+ ? 'TARGET type differs; VIEW and MATERIALIZED_VIEW transitions require an explicit migration'
643
+ : 'TARGET definitionMode transition between STRUCTURED and RAW_SQL requires an explicit migration';
644
+ blockers.push(`Database View "${stableKey}": ${reason}.`);
645
+ items.push({ status: 'conflict', stableKey, name: desired.name, reason, desiredHash });
646
+ continue;
647
+ }
648
+ const currentHash = hashPortableDatabaseView(current);
649
+ const baselineHash = lock.databaseViews[stableKey]?.environments[input.envName]?.remoteHash;
650
+ if (desiredHash === currentHash) {
651
+ items.push({
652
+ status: baselineHash ? 'clean' : 'missing-lock',
653
+ stableKey,
654
+ name: desired.name,
655
+ desiredHash,
656
+ currentHash,
657
+ ...(baselineHash ? { baselineHash } : {}),
658
+ ...(baselineHash ? {} : { reason: 'TARGET matches manifest but has no environment baseline' }),
659
+ ...materializedPlanDetails({ manifest: desired, schedules: targetSchedules.schedules, databaseViewId: viewIdOf(record) }),
660
+ });
661
+ continue;
662
+ }
663
+ if (!baselineHash) {
664
+ items.push({ status: 'missing-lock', stableKey, name: desired.name, desiredHash, currentHash, reason: 'TARGET differs and has no environment baseline' });
665
+ continue;
666
+ }
667
+ const localChanged = hashDatabaseViewManifest(desired) !== lock.databaseViews[stableKey]?.sourceHash;
668
+ const targetChanged = currentHash !== baselineHash;
669
+ const status = localChanged && targetChanged ? 'conflict' : targetChanged ? 'remote-changed' : 'safe-update';
670
+ items.push({
671
+ status,
672
+ stableKey,
673
+ name: desired.name,
674
+ desiredHash,
675
+ currentHash,
676
+ baselineHash,
677
+ changedFields: diffManifestFields(desired, current),
678
+ ...materializedPlanDetails({ manifest: desired, schedules: targetSchedules.schedules, databaseViewId: viewIdOf(record) }),
679
+ });
680
+ }
681
+ for (const [stableKey, records] of targetByKey) {
682
+ if (!localByKey.has(stableKey) && !duplicateTargetKeys.has(stableKey)) {
683
+ items.push({ status: 'orphan', stableKey, name: warningKey(records[0], stableKey), reason: 'managed TARGET Database View has no local manifest' });
684
+ }
685
+ }
686
+ items.sort((left, right) => (String(left.stableKey || left.name).localeCompare(String(right.stableKey || right.name))
687
+ || left.status.localeCompare(right.status)));
688
+ const counts = emptyPlanCounts();
689
+ for (const item of items) {
690
+ counts[item.status] += 1;
691
+ }
692
+ return {
693
+ resourceType: 'database-view',
694
+ env: input.envName,
695
+ counts,
696
+ warnings: [...new Set(warnings)].sort((left, right) => left.localeCompare(right)),
697
+ blockers: [...new Set(blockers)].sort((left, right) => left.localeCompare(right)),
698
+ globalBlockers: [...new Set(globalBlockers)].sort((left, right) => left.localeCompare(right)),
699
+ exclusions: [...new Set(exclusions)].sort((left, right) => left.localeCompare(right)),
700
+ items,
701
+ };
702
+ }
703
+ function workspaceDatabaseViewsForPush(cwd, stableKeyName) {
704
+ const entriesByKey = new Map();
705
+ for (const entry of readWorkspaceDatabaseViews(cwd)) {
706
+ if (!validManifest(entry.manifest, stableKeyName)) {
707
+ continue;
708
+ }
709
+ entriesByKey.set(entry.manifest.stableKey, [...(entriesByKey.get(entry.manifest.stableKey) || []), entry]);
710
+ }
711
+ return new Map([...entriesByKey]
712
+ .filter(([, entries]) => entries.length === 1)
713
+ .map(([stableKey, entries]) => [stableKey, entries[0]]));
714
+ }
715
+ function createDatabaseViewPayload(manifest) {
716
+ const base = {
717
+ name: manifest.name,
718
+ ...(manifest.category === undefined ? {} : { category: manifest.category }),
719
+ ...(manifest.description === undefined ? {} : { desc: manifest.description }),
720
+ metadata: sanitizeUserMetadata(manifest.metadata),
721
+ type: manifest.type,
722
+ restricted: true,
723
+ users: [],
724
+ groups: [],
725
+ ...(manifest.type === 'MATERIALIZED_VIEW'
726
+ ? { uniqueKey: manifest.uniqueKey, indexes: databaseViewWriteIndexes(manifest.indexes) }
727
+ : {}),
728
+ };
729
+ if (manifest.definitionMode === 'RAW_SQL') {
730
+ return {
731
+ ...base,
732
+ definitionMode: 'RAW_SQL',
733
+ rawQuery: manifest.rawQuery,
734
+ };
735
+ }
736
+ return {
737
+ ...base,
738
+ definitionMode: 'STRUCTURED',
739
+ request: canonicalValue(manifest.request),
740
+ };
741
+ }
742
+ function databaseViewWriteIndexes(indexes) {
743
+ return indexes?.map((index) => ({
744
+ columns: index.columns.map((name) => ({ name })),
745
+ ...(index.unique === true ? { unique: true } : {}),
746
+ }));
747
+ }
748
+ function assertTargetPreviewMatchesManifest(manifest, preview) {
749
+ const output = previewOutput(preview);
750
+ if (typeof output === 'string') {
751
+ throw new Error(output);
752
+ }
753
+ if (hashStable(output) !== hashStable(manifest.output)) {
754
+ throw new Error('target preview output signature differs from the manifest');
755
+ }
756
+ }
757
+ async function recheckDatabaseViewCreate(input) {
758
+ const [views, databaseResult] = await Promise.all([
759
+ input.client.listAllDatabaseViewsForSync(),
760
+ input.client.listAllDatabaseSchemas({ projection: 'pull' }),
761
+ ]);
762
+ const stableKeyMatches = views.filter((record) => stableKeyOf(record, input.stableKeyName) === input.manifest.stableKey);
763
+ if (stableKeyMatches.length > 0) {
764
+ throw new Error('matching Stable Key appeared after planning');
765
+ }
766
+ if (views.some((record) => record.name === input.manifest.name)) {
767
+ throw new Error(`unmanaged-name-conflict: TARGET Database View "${input.manifest.name}" appeared after planning`);
768
+ }
769
+ const sourceReadiness = validateTargetSources(input.manifest, databaseResult.databases, input.stableKeyName);
770
+ if (sourceReadiness) {
771
+ throw new Error(sourceReadiness);
772
+ }
773
+ const preview = await input.client.previewDatabaseView(databaseViewPreviewPayload(input.manifest));
774
+ assertTargetPreviewMatchesManifest(input.manifest, preview);
775
+ return databaseResult.databases;
776
+ }
777
+ function verifyCreatedDatabaseView(input) {
778
+ if (stableKeyOf(input.created, input.stableKeyName) !== input.desired.stableKey) {
779
+ throw new Error(`written target is missing or changed ${input.stableKeyName}`);
780
+ }
781
+ if (input.created.restricted !== true || !Array.isArray(input.created.users) || !Array.isArray(input.created.groups)) {
782
+ throw new Error('created target does not expose the required private access policy');
783
+ }
784
+ if (input.created.users.length > 0 || input.created.groups.length > 0) {
785
+ throw new Error('created target copied ACL principals instead of remaining private');
786
+ }
787
+ const actual = manifestFromRecord({
788
+ record: input.created,
789
+ stableKey: input.desired.stableKey,
790
+ stableKeyName: input.stableKeyName,
791
+ inventory: sourceInventory(input.databases, input.stableKeyName),
792
+ });
793
+ if (typeof actual === 'string') {
794
+ throw new Error(actual);
795
+ }
796
+ const desiredHash = hashPortableDatabaseView(input.desired);
797
+ const remoteHash = hashPortableDatabaseView(actual);
798
+ if (remoteHash !== desiredHash) {
799
+ throw new Error(`created target did not match desired portable hash (desired: ${desiredHash}; actual: ${remoteHash})`);
800
+ }
801
+ return remoteHash;
802
+ }
803
+ function targetPrincipalIds(entries, property) {
804
+ const ids = [];
805
+ for (const entry of entries) {
806
+ const value = typeof entry === 'string'
807
+ ? entry
808
+ : isRecord(entry) && typeof entry[property] === 'string'
809
+ ? entry[property]
810
+ : undefined;
811
+ if (!isNonEmptyString(value)) {
812
+ throw new Error(`TARGET ${property === 'userId' ? 'user' : 'group'} ACL principal is invalid`);
813
+ }
814
+ ids.push(value);
815
+ }
816
+ return ids;
817
+ }
818
+ function updateDatabaseViewPayload(input) {
819
+ if (typeof input.target.restricted !== 'boolean' || !Array.isArray(input.target.users) || !Array.isArray(input.target.groups)) {
820
+ throw new Error('TARGET access policy is incomplete and cannot be preserved safely');
821
+ }
822
+ if (!Number.isInteger(input.target.version) || input.target.version < 0) {
823
+ throw new Error('TARGET version is unavailable for optimistic update');
824
+ }
825
+ const version = input.target.version;
826
+ const base = {
827
+ name: input.manifest.name,
828
+ category: input.manifest.category ?? null,
829
+ desc: input.manifest.description ?? null,
830
+ metadata: sanitizeUserMetadata(input.manifest.metadata),
831
+ type: input.manifest.type,
832
+ restricted: input.target.restricted,
833
+ users: targetPrincipalIds(input.target.users, 'userId'),
834
+ groups: targetPrincipalIds(input.target.groups, 'groupId'),
835
+ version,
836
+ ...(input.manifest.type === 'MATERIALIZED_VIEW'
837
+ ? { uniqueKey: input.manifest.uniqueKey, indexes: databaseViewWriteIndexes(input.manifest.indexes) }
838
+ : {}),
839
+ };
840
+ if (input.manifest.definitionMode === 'RAW_SQL') {
841
+ return {
842
+ ...base,
843
+ definitionMode: 'RAW_SQL',
844
+ rawQuery: input.manifest.rawQuery,
845
+ };
846
+ }
847
+ return {
848
+ ...base,
849
+ definitionMode: 'STRUCTURED',
850
+ request: canonicalValue(input.manifest.request),
851
+ };
852
+ }
853
+ function verifyUpdatedDatabaseView(input) {
854
+ if (stableKeyOf(input.written, input.stableKeyName) !== input.desired.stableKey) {
855
+ throw new Error(`written target is missing or changed ${input.stableKeyName}`);
856
+ }
857
+ if (!Array.isArray(input.written.users) || !Array.isArray(input.written.groups)) {
858
+ throw new Error('written target does not expose its access policy');
859
+ }
860
+ const actualUsers = targetPrincipalIds(input.written.users, 'userId');
861
+ const actualGroups = targetPrincipalIds(input.written.groups, 'groupId');
862
+ if (input.written.restricted !== input.expectedRestricted
863
+ || hashStable(actualUsers.sort()) !== hashStable([...input.expectedUsers].sort())
864
+ || hashStable(actualGroups.sort()) !== hashStable([...input.expectedGroups].sort())) {
865
+ throw new Error('written target did not preserve its access policy');
866
+ }
867
+ const actual = manifestFromRecord({
868
+ record: input.written,
869
+ stableKey: input.desired.stableKey,
870
+ stableKeyName: input.stableKeyName,
871
+ inventory: sourceInventory(input.databases, input.stableKeyName),
872
+ });
873
+ if (typeof actual === 'string') {
874
+ throw new Error(actual);
875
+ }
876
+ const remoteHash = hashPortableDatabaseView(actual);
877
+ const desiredHash = hashPortableDatabaseView(input.desired);
878
+ if (remoteHash !== desiredHash) {
879
+ throw new Error(`written target did not match desired portable hash (desired: ${desiredHash}; actual: ${remoteHash})`);
880
+ }
881
+ return remoteHash;
882
+ }
883
+ async function recheckDatabaseViewUpdate(input) {
884
+ const [views, databaseResult, schedules] = await Promise.all([
885
+ input.client.listAllDatabaseViewsForSync(),
886
+ input.client.listAllDatabaseSchemas({ projection: 'pull' }),
887
+ input.client.listAllSchedules(),
888
+ ]);
889
+ const matches = views.filter((record) => stableKeyOf(record, input.stableKeyName) === input.manifest.stableKey);
890
+ if (matches.length !== 1) {
891
+ throw new Error(`TARGET Stable Key resolves to ${matches.length} Database Views`);
892
+ }
893
+ const databaseViewId = viewIdOf(matches[0]);
894
+ if (!databaseViewId) {
895
+ throw new Error('TARGET Database View has no databaseViewId');
896
+ }
897
+ const target = unwrapData(await input.client.getDatabaseView(databaseViewId));
898
+ if (stableKeyOf(target, input.stableKeyName) !== input.manifest.stableKey) {
899
+ throw new Error(`TARGET Database View changed ${input.stableKeyName} after planning`);
900
+ }
901
+ if (target.type !== input.manifest.type || target.definitionMode !== input.manifest.definitionMode) {
902
+ throw new Error('TARGET Database View changed type or definitionMode after planning');
903
+ }
904
+ if (target.name !== input.manifest.name) {
905
+ throw new Error('TARGET name differs; renames require an explicit migration');
906
+ }
907
+ const sourceReadiness = validateTargetSources(input.manifest, databaseResult.databases, input.stableKeyName);
908
+ if (sourceReadiness) {
909
+ throw new Error(sourceReadiness);
910
+ }
911
+ const current = manifestFromRecord({
912
+ record: target,
913
+ stableKey: input.manifest.stableKey,
914
+ stableKeyName: input.stableKeyName,
915
+ inventory: sourceInventory(databaseResult.databases, input.stableKeyName),
916
+ });
917
+ if (typeof current === 'string') {
918
+ throw new Error(current);
919
+ }
920
+ if (hashPortableDatabaseView(current) !== input.expectedHash) {
921
+ throw new Error('TARGET portable content changed after planning');
922
+ }
923
+ const materializedDetails = materializedPlanDetails({
924
+ manifest: input.manifest,
925
+ schedules: schedules.schedules,
926
+ databaseViewId,
927
+ });
928
+ if (materializedDetails.scheduleDependencies && materializedDetails.scheduleDependencies.length > 0) {
929
+ throw new Error(`active refresh Schedule dependencies require manual coordination: ${materializedDetails.scheduleDependencies.join(', ')}`);
930
+ }
931
+ const preview = await input.client.previewDatabaseView(databaseViewPreviewPayload(input.manifest));
932
+ assertTargetPreviewMatchesManifest(input.manifest, preview);
933
+ return { target, databases: databaseResult.databases };
934
+ }
935
+ function updateDatabaseViewEnvironmentBaseline(input) {
936
+ const { projectRoot } = requireProjectLayout(input.cwd, 'Database View');
937
+ const lock = readComponentLock(projectRoot);
938
+ const existing = lock.databaseViews[input.manifest.stableKey];
939
+ const entry = {
940
+ stableKey: input.manifest.stableKey,
941
+ path: toPortableRelativePath(projectRoot, input.manifestPath),
942
+ sourceHash: hashDatabaseViewManifest(input.manifest),
943
+ environments: {
944
+ ...(existing?.environments || {}),
945
+ [input.envName]: { remoteHash: input.remoteHash, syncedAt: new Date().toISOString() },
946
+ },
947
+ };
948
+ lock.databaseViews[input.manifest.stableKey] = entry;
949
+ writeComponentLock(projectRoot, lock);
950
+ }
951
+ export async function pushDatabaseViews(input) {
952
+ const plan = await buildDatabaseViewsPlan(input);
953
+ const localByKey = workspaceDatabaseViewsForPush(input.cwd, input.stableKeyName);
954
+ if (input.stableKey && !localByKey.has(input.stableKey)) {
955
+ throw new Error(`Database View Stable Key "${input.stableKey}" was not found in the workspace.`);
956
+ }
957
+ const selected = (item) => !input.stableKey || item.stableKey === input.stableKey;
958
+ if (plan.globalBlockers.length > 0) {
959
+ return {
960
+ plan,
961
+ results: plan.items
962
+ .filter((item) => selected(item) && item.stableKey && localByKey.has(item.stableKey))
963
+ .map((item) => ({
964
+ stableKey: item.stableKey,
965
+ action: 'skipped',
966
+ status: 'skipped',
967
+ targetPath: localTargetPath(input.cwd, localByKey.get(item.stableKey).manifestPath),
968
+ reason: 'global Database View Stable Key ambiguity blocks every write',
969
+ })),
970
+ };
971
+ }
972
+ const canReconcile = (item) => (item.status === 'missing-lock'
973
+ && item.desiredHash !== undefined
974
+ && item.desiredHash === item.currentHash);
975
+ const canUpdate = (item) => (item.status === 'safe-update'
976
+ || (input.force === true && ['remote-changed', 'conflict', 'missing-lock'].includes(item.status) && !canReconcile(item)));
977
+ const results = [];
978
+ for (const item of plan.items) {
979
+ const local = item.stableKey ? localByKey.get(item.stableKey) : undefined;
980
+ if (selected(item) && item.stableKey && local && local.manifest.type === 'MATERIALIZED_VIEW' && input.allowMaterializedWrites !== true) {
981
+ results.push({
982
+ stableKey: item.stableKey,
983
+ action: 'skipped',
984
+ status: 'skipped',
985
+ targetPath: localTargetPath(input.cwd, local.manifestPath),
986
+ reason: 'MATERIALIZED_VIEW deployment requires --allow-materialized',
987
+ });
988
+ continue;
989
+ }
990
+ if (!selected(item)
991
+ || !item.stableKey
992
+ || !localByKey.has(item.stableKey)
993
+ || item.status === 'create'
994
+ || canReconcile(item)
995
+ || canUpdate(item)) {
996
+ continue;
997
+ }
998
+ const resolvedLocal = localByKey.get(item.stableKey);
999
+ results.push({
1000
+ stableKey: item.stableKey,
1001
+ action: 'skipped',
1002
+ status: 'skipped',
1003
+ targetPath: localTargetPath(input.cwd, resolvedLocal.manifestPath),
1004
+ reason: item.reason || item.status,
1005
+ });
1006
+ }
1007
+ for (const item of plan.items.filter((candidate) => (selected(candidate)
1008
+ && candidate.status === 'create'
1009
+ && candidate.stableKey
1010
+ && (localByKey.get(candidate.stableKey)?.manifest.type === 'VIEW' || input.allowMaterializedWrites === true)))) {
1011
+ const stableKey = item.stableKey;
1012
+ const local = localByKey.get(stableKey);
1013
+ if (!local) {
1014
+ results.push({ stableKey, action: 'create', status: 'failed', targetPath: stableKey, error: 'desired manifest disappeared after planning' });
1015
+ continue;
1016
+ }
1017
+ let writeAccepted = false;
1018
+ try {
1019
+ const desired = local.manifest;
1020
+ if (hashPortableDatabaseView(desired) !== item.desiredHash) {
1021
+ throw new Error('desired manifest changed after planning');
1022
+ }
1023
+ const databases = await recheckDatabaseViewCreate({
1024
+ client: input.client,
1025
+ manifest: desired,
1026
+ stableKeyName: input.stableKeyName,
1027
+ });
1028
+ const created = unwrapData(await input.client.createDatabaseView(createDatabaseViewPayload(desired)));
1029
+ writeAccepted = true;
1030
+ const databaseViewId = viewIdOf(created);
1031
+ if (!databaseViewId) {
1032
+ throw new Error('create response did not include databaseViewId');
1033
+ }
1034
+ const verified = unwrapData(await input.client.getDatabaseView(databaseViewId));
1035
+ const remoteHash = verifyCreatedDatabaseView({
1036
+ created: verified,
1037
+ databases,
1038
+ desired,
1039
+ stableKeyName: input.stableKeyName,
1040
+ });
1041
+ updateDatabaseViewEnvironmentBaseline({
1042
+ cwd: input.cwd,
1043
+ envName: input.envName,
1044
+ manifestPath: local.manifestPath,
1045
+ manifest: desired,
1046
+ remoteHash,
1047
+ });
1048
+ results.push({
1049
+ stableKey,
1050
+ action: 'create',
1051
+ status: 'deployed',
1052
+ targetPath: localTargetPath(input.cwd, local.manifestPath),
1053
+ accessPolicyRequired: true,
1054
+ reason: 'created private; configure target-local ACL principals before use',
1055
+ });
1056
+ }
1057
+ catch (error) {
1058
+ const message = error instanceof Error ? error.message : String(error);
1059
+ results.push({
1060
+ stableKey,
1061
+ action: 'create',
1062
+ status: writeAccepted ? 'write-unverified' : 'failed',
1063
+ targetPath: localTargetPath(input.cwd, local.manifestPath),
1064
+ ...(writeAccepted
1065
+ ? { reason: `POST was accepted, but readback did not verify the desired portable hash; the environment lock was not updated (${message})` }
1066
+ : { error: message }),
1067
+ });
1068
+ }
1069
+ }
1070
+ for (const item of plan.items.filter((candidate) => (selected(candidate)
1071
+ && candidate.stableKey
1072
+ && canUpdate(candidate)
1073
+ && (localByKey.get(candidate.stableKey)?.manifest.type === 'VIEW' || input.allowMaterializedWrites === true)))) {
1074
+ const stableKey = item.stableKey;
1075
+ const local = localByKey.get(stableKey);
1076
+ if (!local) {
1077
+ results.push({ stableKey, action: 'update', status: 'failed', targetPath: stableKey, error: 'desired manifest disappeared after planning' });
1078
+ continue;
1079
+ }
1080
+ let writeAccepted = false;
1081
+ try {
1082
+ const desired = local.manifest;
1083
+ if (hashPortableDatabaseView(desired) !== item.desiredHash || !item.currentHash) {
1084
+ throw new Error('desired manifest or target hash changed after planning');
1085
+ }
1086
+ const live = await recheckDatabaseViewUpdate({
1087
+ client: input.client,
1088
+ manifest: desired,
1089
+ stableKeyName: input.stableKeyName,
1090
+ expectedHash: item.currentHash,
1091
+ });
1092
+ const payload = updateDatabaseViewPayload({ manifest: desired, target: live.target });
1093
+ await input.client.updateDatabaseView(viewIdOf(live.target), payload);
1094
+ writeAccepted = true;
1095
+ const verified = unwrapData(await input.client.getDatabaseView(viewIdOf(live.target)));
1096
+ const remoteHash = verifyUpdatedDatabaseView({
1097
+ written: verified,
1098
+ databases: live.databases,
1099
+ desired,
1100
+ stableKeyName: input.stableKeyName,
1101
+ expectedRestricted: payload.restricted,
1102
+ expectedUsers: payload.users,
1103
+ expectedGroups: payload.groups,
1104
+ });
1105
+ updateDatabaseViewEnvironmentBaseline({
1106
+ cwd: input.cwd,
1107
+ envName: input.envName,
1108
+ manifestPath: local.manifestPath,
1109
+ manifest: desired,
1110
+ remoteHash,
1111
+ });
1112
+ results.push({
1113
+ stableKey,
1114
+ action: 'update',
1115
+ status: 'deployed',
1116
+ targetPath: localTargetPath(input.cwd, local.manifestPath),
1117
+ });
1118
+ }
1119
+ catch (error) {
1120
+ const message = error instanceof Error ? error.message : String(error);
1121
+ results.push({
1122
+ stableKey,
1123
+ action: 'update',
1124
+ status: writeAccepted ? 'write-unverified' : 'failed',
1125
+ targetPath: localTargetPath(input.cwd, local.manifestPath),
1126
+ ...(writeAccepted
1127
+ ? { reason: `PUT was accepted, but readback did not verify the desired portable hash; the environment lock was not updated (${message})` }
1128
+ : { error: message }),
1129
+ });
1130
+ }
1131
+ }
1132
+ for (const item of plan.items.filter((candidate) => (selected(candidate)
1133
+ && candidate.stableKey
1134
+ && canReconcile(candidate)
1135
+ && (localByKey.get(candidate.stableKey)?.manifest.type === 'VIEW' || input.allowMaterializedWrites === true)))) {
1136
+ const stableKey = item.stableKey;
1137
+ const local = localByKey.get(stableKey);
1138
+ if (!local) {
1139
+ results.push({ stableKey, action: 'reconcile', status: 'failed', targetPath: stableKey, error: 'desired manifest disappeared after planning' });
1140
+ continue;
1141
+ }
1142
+ try {
1143
+ if (!item.currentHash || hashPortableDatabaseView(local.manifest) !== item.desiredHash) {
1144
+ throw new Error('desired manifest or target hash changed after planning');
1145
+ }
1146
+ const live = await recheckDatabaseViewUpdate({
1147
+ client: input.client,
1148
+ manifest: local.manifest,
1149
+ stableKeyName: input.stableKeyName,
1150
+ expectedHash: item.currentHash,
1151
+ });
1152
+ const accessPolicy = updateDatabaseViewPayload({ manifest: local.manifest, target: live.target });
1153
+ const remoteHash = verifyUpdatedDatabaseView({
1154
+ written: live.target,
1155
+ databases: live.databases,
1156
+ desired: local.manifest,
1157
+ stableKeyName: input.stableKeyName,
1158
+ expectedRestricted: accessPolicy.restricted,
1159
+ expectedUsers: accessPolicy.users,
1160
+ expectedGroups: accessPolicy.groups,
1161
+ });
1162
+ updateDatabaseViewEnvironmentBaseline({
1163
+ cwd: input.cwd,
1164
+ envName: input.envName,
1165
+ manifestPath: local.manifestPath,
1166
+ manifest: local.manifest,
1167
+ remoteHash,
1168
+ });
1169
+ results.push({
1170
+ stableKey,
1171
+ action: 'reconcile',
1172
+ status: 'deployed',
1173
+ targetPath: localTargetPath(input.cwd, local.manifestPath),
1174
+ });
1175
+ }
1176
+ catch (error) {
1177
+ results.push({
1178
+ stableKey,
1179
+ action: 'reconcile',
1180
+ status: 'failed',
1181
+ targetPath: localTargetPath(input.cwd, local.manifestPath),
1182
+ error: error instanceof Error ? error.message : String(error),
1183
+ });
1184
+ }
1185
+ }
1186
+ return { plan, results };
1187
+ }
1188
+ export async function pullDatabaseViewsToWorkspace(input) {
1189
+ const { projectRoot, workspaceRoot } = requireProjectLayout(input.cwd, 'Database View');
1190
+ const [sourceViews, sourceDatabases] = await Promise.all([
1191
+ input.client.listAllDatabaseViewsForSync(),
1192
+ input.client.listAllDatabaseSchemas({ projection: 'pull' }),
1193
+ ]);
1194
+ const inventory = sourceInventory(sourceDatabases.databases, input.stableKeyName);
1195
+ const stableKeys = new Set();
1196
+ for (const record of sourceViews) {
1197
+ const stableKey = stableKeyOf(record, input.stableKeyName);
1198
+ if (!stableKey) {
1199
+ continue;
1200
+ }
1201
+ if (stableKeys.has(stableKey)) {
1202
+ throw new Error(`Duplicate Database View stableKey "${stableKey}" on ${input.envName}; no files were changed.`);
1203
+ }
1204
+ stableKeys.add(stableKey);
1205
+ }
1206
+ const manifests = [];
1207
+ const skipped = [];
1208
+ for (const record of sourceViews) {
1209
+ const stableKey = stableKeyOf(record, input.stableKeyName);
1210
+ const exclusion = exclusionReason(record);
1211
+ if (exclusion) {
1212
+ skipped.push({ stableKey: stableKey || undefined, name: warningKey(record, stableKey || undefined), reason: exclusion });
1213
+ continue;
1214
+ }
1215
+ if (!stableKey) {
1216
+ skipped.push({ name: warningKey(record), reason: `missing ${input.stableKeyName}; Database View remains unmanaged` });
1217
+ continue;
1218
+ }
1219
+ const manifest = manifestFromRecord({ record, stableKey, stableKeyName: input.stableKeyName, inventory });
1220
+ if (typeof manifest === 'string') {
1221
+ skipped.push({ stableKey, name: warningKey(record, stableKey), reason: manifest });
1222
+ continue;
1223
+ }
1224
+ if (!validManifest(manifest, input.stableKeyName)) {
1225
+ skipped.push({ stableKey, name: warningKey(record, stableKey), reason: 'Database View did not produce a valid portable manifest' });
1226
+ continue;
1227
+ }
1228
+ manifests.push(manifest);
1229
+ }
1230
+ manifests.sort((left, right) => left.stableKey.localeCompare(right.stableKey));
1231
+ skipped.sort((left, right) => (String(left.stableKey || left.name).localeCompare(String(right.stableKey || right.name))
1232
+ || left.reason.localeCompare(right.reason)));
1233
+ if (input.strict && skipped.length > 0) {
1234
+ throw new Error(`Database View strict pull is incomplete: ${skipped.length} skipped or unmanaged item(s); no files were changed.`);
1235
+ }
1236
+ const lock = readComponentLock(projectRoot);
1237
+ const existingByKey = new Map();
1238
+ const existingByPath = new Map();
1239
+ for (const entry of readWorkspaceDatabaseViews(input.cwd)) {
1240
+ if (!validManifest(entry.manifest, input.stableKeyName)) {
1241
+ throw new Error(`Cannot pull over invalid Database View manifest ${path.relative(projectRoot, entry.manifestPath)}.`);
1242
+ }
1243
+ if (existingByKey.has(entry.manifest.stableKey)) {
1244
+ throw new Error(`Duplicate local Database View stableKey "${entry.manifest.stableKey}"; no files were changed.`);
1245
+ }
1246
+ existingByKey.set(entry.manifest.stableKey, entry);
1247
+ existingByPath.set(path.resolve(entry.manifestPath), entry);
1248
+ }
1249
+ const writes = manifests.map((manifest) => {
1250
+ const existing = existingByKey.get(manifest.stableKey);
1251
+ const sourceHash = hashDatabaseViewManifest(manifest);
1252
+ if (existing && !input.force) {
1253
+ const localHash = hashDatabaseViewManifest(existing.manifest);
1254
+ const baseline = lock.databaseViews[manifest.stableKey];
1255
+ if (localHash !== sourceHash && (!baseline || localHash !== baseline.sourceHash)) {
1256
+ throw new Error(`Cannot pull Database View "${manifest.stableKey}": local manifest changed since the lock baseline. Re-run with --force.`);
1257
+ }
1258
+ }
1259
+ return {
1260
+ manifest,
1261
+ sourceHash,
1262
+ remoteHash: hashPortableDatabaseView(manifest),
1263
+ filePath: databaseViewManifestPath(workspaceRoot, manifest),
1264
+ previousPath: existing?.manifestPath,
1265
+ };
1266
+ });
1267
+ const targetPaths = new Set();
1268
+ for (const write of writes) {
1269
+ const targetPath = path.resolve(write.filePath);
1270
+ if (targetPaths.has(targetPath)) {
1271
+ throw new Error('Multiple Database Views resolve to the same workspace path; no files were changed.');
1272
+ }
1273
+ targetPaths.add(targetPath);
1274
+ const existingAtPath = existingByPath.get(targetPath);
1275
+ if (existingAtPath && existingAtPath.manifest.stableKey !== write.manifest.stableKey) {
1276
+ throw new Error(`Database View workspace path "${path.relative(projectRoot, write.filePath)}" is occupied by different stableKey "${existingAtPath.manifest.stableKey}"; no files were changed.`);
1277
+ }
1278
+ }
1279
+ const root = path.join(workspaceRoot, DATABASE_VIEWS_DIRECTORY);
1280
+ let lockChanged = false;
1281
+ for (const write of writes) {
1282
+ writePulledManifest({
1283
+ filePath: write.filePath,
1284
+ manifest: write.manifest,
1285
+ previousPath: write.previousPath,
1286
+ root: root,
1287
+ force: input.force,
1288
+ });
1289
+ const previous = lock.databaseViews[write.manifest.stableKey];
1290
+ const previousBaseline = previous?.environments[input.envName];
1291
+ const entry = {
1292
+ stableKey: write.manifest.stableKey,
1293
+ path: toPortableRelativePath(projectRoot, write.filePath),
1294
+ sourceHash: write.sourceHash,
1295
+ environments: {
1296
+ ...(previous?.environments || {}),
1297
+ [input.envName]: previousBaseline?.remoteHash === write.remoteHash
1298
+ ? previousBaseline
1299
+ : { remoteHash: write.remoteHash, syncedAt: new Date().toISOString() },
1300
+ },
1301
+ };
1302
+ if (!previous || hashStable(previous) !== hashStable(entry)) {
1303
+ lockChanged = true;
1304
+ }
1305
+ lock.databaseViews[write.manifest.stableKey] = entry;
1306
+ }
1307
+ if (lockChanged) {
1308
+ writeComponentLock(projectRoot, lock);
1309
+ }
1310
+ return {
1311
+ pulled: writes.map((write) => ({
1312
+ manifest: write.manifest,
1313
+ targetPath: localTargetPath(input.cwd, write.filePath),
1314
+ })),
1315
+ skipped,
1316
+ };
1317
+ }