@revoengine/cli 1.0.9 → 1.0.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +464 -8
- package/dist/src/cli.js +65 -6
- package/dist/src/client.d.ts +366 -5
- package/dist/src/client.js +954 -13
- package/dist/src/commands/auth.js +4 -2
- package/dist/src/commands/component.js +839 -412
- package/dist/src/commands/database-schemas.d.ts +2 -0
- package/dist/src/commands/database-schemas.js +188 -0
- package/dist/src/commands/database-views.d.ts +2 -0
- package/dist/src/commands/database-views.js +123 -0
- package/dist/src/commands/endpoints.js +114 -0
- package/dist/src/commands/env.d.ts +2 -0
- package/dist/src/commands/env.js +380 -0
- package/dist/src/commands/events.d.ts +2 -0
- package/dist/src/commands/events.js +146 -0
- package/dist/src/commands/groups.d.ts +2 -0
- package/dist/src/commands/groups.js +169 -0
- package/dist/src/commands/index.d.ts +10 -0
- package/dist/src/commands/index.js +10 -0
- package/dist/src/commands/job-templates.d.ts +2 -0
- package/dist/src/commands/job-templates.js +101 -0
- package/dist/src/commands/metadata.d.ts +2 -0
- package/dist/src/commands/metadata.js +159 -0
- package/dist/src/commands/project.js +82 -1
- package/dist/src/commands/role-groups.d.ts +2 -0
- package/dist/src/commands/role-groups.js +152 -0
- package/dist/src/commands/schedules.d.ts +2 -0
- package/dist/src/commands/schedules.js +141 -0
- package/dist/src/commands/terminal-service.d.ts +38 -0
- package/dist/src/commands/terminal-service.js +210 -0
- package/dist/src/commands/terminal.d.ts +22 -0
- package/dist/src/commands/terminal.js +511 -0
- package/dist/src/component-lock.d.ts +126 -2
- package/dist/src/component-lock.js +378 -15
- package/dist/src/config.d.ts +20 -0
- package/dist/src/config.js +121 -6
- package/dist/src/database-schema-artifacts.d.ts +7 -0
- package/dist/src/database-schema-artifacts.js +8 -0
- package/dist/src/env-sync.d.ts +83 -0
- package/dist/src/env-sync.js +315 -0
- package/dist/src/metadata-backfill.d.ts +56 -0
- package/dist/src/metadata-backfill.js +1176 -0
- package/dist/src/project.d.ts +17 -9
- package/dist/src/project.js +87 -11
- package/dist/src/prompt.js +10 -18
- package/dist/src/resource-metadata.d.ts +25 -0
- package/dist/src/resource-metadata.js +132 -0
- package/dist/src/resource-syncs/database-schema-sync.d.ts +117 -0
- package/dist/src/resource-syncs/database-schema-sync.js +2289 -0
- package/dist/src/resource-syncs/database-view-sync.d.ts +124 -0
- package/dist/src/resource-syncs/database-view-sync.js +1317 -0
- package/dist/src/resource-syncs/endpoint-sync.d.ts +96 -0
- package/dist/src/resource-syncs/endpoint-sync.js +1283 -0
- package/dist/src/resource-syncs/event-sync.d.ts +99 -0
- package/dist/src/resource-syncs/event-sync.js +949 -0
- package/dist/src/resource-syncs/group-sync.d.ts +86 -0
- package/dist/src/resource-syncs/group-sync.js +882 -0
- package/dist/src/resource-syncs/job-template-sync.d.ts +85 -0
- package/dist/src/resource-syncs/job-template-sync.js +782 -0
- package/dist/src/resource-syncs/role-group-sync.d.ts +83 -0
- package/dist/src/resource-syncs/role-group-sync.js +597 -0
- package/dist/src/resource-syncs/schedule-sync.d.ts +111 -0
- package/dist/src/resource-syncs/schedule-sync.js +1302 -0
- package/dist/src/resource-syncs/util.d.ts +19 -0
- package/dist/src/resource-syncs/util.js +116 -0
- package/dist/src/runtime-view.d.ts +1 -0
- package/dist/src/runtime-view.js +6 -1
- package/dist/src/sync-output.d.ts +38 -0
- package/dist/src/sync-output.js +131 -0
- package/dist/src/tracked-resources.d.ts +7 -0
- package/dist/src/tracked-resources.js +61 -0
- package/dist/src/types.d.ts +227 -0
- package/dist/src/ui.d.ts +3 -0
- package/dist/src/ui.js +68 -10
- package/dist/src/utils.d.ts +2 -0
- package/dist/src/utils.js +64 -0
- package/dist/src/workspace-component.d.ts +2 -0
- package/dist/src/workspace-component.js +34 -0
- package/dist/src/workspace-resource.d.ts +2 -0
- package/dist/src/workspace-resource.js +52 -0
- package/package.json +8 -3
|
@@ -0,0 +1,1302 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { hashStable, readComponentLock, writeComponentLock } from "../component-lock.js";
|
|
3
|
+
import { NULL_CATEGORY_KEY, normalizeStableKeyValue, readConfiguredResourceMetadataStableKey, sanitizeUserMetadata } from "../resource-metadata.js";
|
|
4
|
+
import { isValidNormalizedCron, normalizeCron, readJsonFile, sanitizeSegment } from "../utils.js";
|
|
5
|
+
import { findManifestFiles, isRecord, localTargetPath, requireProjectLayout, stableKeySuffix, toPortableRelativePath, unwrapData, writePulledManifest, } from "./util.js";
|
|
6
|
+
export const SCHEDULES_DIRECTORY = 'Schedules';
|
|
7
|
+
export const SCHEDULE_MANIFEST_FILE = 'schedule.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).sort((left, right) => left.localeCompare(right))
|
|
16
|
+
.map((key) => [key, canonicalValue(value[key])]));
|
|
17
|
+
}
|
|
18
|
+
function scheduleIdOf(record) {
|
|
19
|
+
return String(record.scheduleId || record.id || '');
|
|
20
|
+
}
|
|
21
|
+
function jobTemplateIdOf(record) {
|
|
22
|
+
return String(record.jobTemplateId || record.id || '');
|
|
23
|
+
}
|
|
24
|
+
function databaseViewIdOf(record) {
|
|
25
|
+
return String(record.databaseViewId || record.id || '');
|
|
26
|
+
}
|
|
27
|
+
function canonicalMetadata(record, stableKeyName, stableKey) {
|
|
28
|
+
return canonicalValue({
|
|
29
|
+
...sanitizeUserMetadata(record.resourceMetadata),
|
|
30
|
+
...sanitizeUserMetadata(record.metaData),
|
|
31
|
+
...sanitizeUserMetadata(record.metadata),
|
|
32
|
+
[stableKeyName]: stableKey,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export function scheduleManifestPath(workspaceRoot, manifest) {
|
|
36
|
+
const category = typeof manifest.category === 'string' && manifest.category.trim()
|
|
37
|
+
? sanitizeSegment(manifest.category)
|
|
38
|
+
: 'Uncategorized';
|
|
39
|
+
return path.join(workspaceRoot, SCHEDULES_DIRECTORY, category, `${sanitizeSegment(manifest.name)}-${stableKeySuffix(manifest.stableKey)}`, SCHEDULE_MANIFEST_FILE);
|
|
40
|
+
}
|
|
41
|
+
function portablePathIdentity(filePath) {
|
|
42
|
+
return path.resolve(filePath)
|
|
43
|
+
.normalize('NFC')
|
|
44
|
+
.split(path.sep)
|
|
45
|
+
.map((segment) => segment.replace(/[. ]+$/g, '').toLowerCase())
|
|
46
|
+
.join(path.sep);
|
|
47
|
+
}
|
|
48
|
+
function disambiguateManifestPath(filePath, stableKey) {
|
|
49
|
+
const directory = path.dirname(filePath);
|
|
50
|
+
const disambiguatedDirectory = `${path.basename(directory)}-${hashStable(stableKey).slice(0, 8)}`;
|
|
51
|
+
return path.join(path.dirname(directory), disambiguatedDirectory, path.basename(filePath));
|
|
52
|
+
}
|
|
53
|
+
function readWorkspaceSchedules(cwd) {
|
|
54
|
+
const { workspaceRoot } = requireProjectLayout(cwd, 'Schedule');
|
|
55
|
+
return findManifestFiles(path.join(workspaceRoot, SCHEDULES_DIRECTORY), SCHEDULE_MANIFEST_FILE).map((manifestPath) => ({
|
|
56
|
+
manifestPath,
|
|
57
|
+
manifest: readJsonFile(manifestPath),
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
function hasOnlyKeys(value, allowed) {
|
|
61
|
+
return Object.keys(value).every((key) => allowed.has(key));
|
|
62
|
+
}
|
|
63
|
+
function isNonEmptyString(value) {
|
|
64
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
65
|
+
}
|
|
66
|
+
function normalizeTimezone(value) {
|
|
67
|
+
const normalized = value.trim();
|
|
68
|
+
const aliases = {
|
|
69
|
+
UTC: 'UTC',
|
|
70
|
+
Z: 'UTC',
|
|
71
|
+
GMT: 'UTC',
|
|
72
|
+
'ETC/UTC': 'UTC',
|
|
73
|
+
'ETC/GMT': 'UTC',
|
|
74
|
+
'ETC/GMT0': 'UTC',
|
|
75
|
+
UTC0: 'UTC',
|
|
76
|
+
};
|
|
77
|
+
const alias = normalized.toUpperCase();
|
|
78
|
+
return Object.hasOwn(aliases, alias) ? aliases[alias] : normalized;
|
|
79
|
+
}
|
|
80
|
+
function isValidNormalizedIanaTimezone(value) {
|
|
81
|
+
if (value !== normalizeTimezone(value)) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
if (value === 'UTC') {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
if (!value.includes('/')) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone === value;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function validManifest(manifest, stableKeyName, { validateDiagnostics = true } = {}) {
|
|
98
|
+
if (!isRecord(manifest) || !hasOnlyKeys(manifest, new Set([
|
|
99
|
+
'kind', 'stableKey', 'name', 'category', 'description', 'metadata', 'cron', 'timezone', 'target', 'sourceTargetConfig', 'activationCandidate',
|
|
100
|
+
]))) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
if (manifest.kind !== 'schedule' || !isNonEmptyString(manifest.stableKey) || !isNonEmptyString(manifest.name)) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
if (manifest.category !== undefined && manifest.category !== null && typeof manifest.category !== 'string') {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
if (manifest.description !== undefined && typeof manifest.description !== 'string') {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
if (!isRecord(manifest.metadata)
|
|
113
|
+
|| !isNonEmptyString(manifest.cron)
|
|
114
|
+
|| !isNonEmptyString(manifest.timezone)
|
|
115
|
+
|| !isValidNormalizedCron(manifest.cron)
|
|
116
|
+
|| !isValidNormalizedIanaTimezone(manifest.timezone)) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
if (!isRecord(manifest.target)) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
const validJobTemplateTarget = manifest.target.type === 'JOB_TEMPLATE'
|
|
123
|
+
&& hasOnlyKeys(manifest.target, new Set(['type', 'jobTemplateStableKey']))
|
|
124
|
+
&& isNonEmptyString(manifest.target.jobTemplateStableKey);
|
|
125
|
+
const validMaterializedViewTarget = manifest.target.type === 'MATERIALIZED_VIEW'
|
|
126
|
+
&& hasOnlyKeys(manifest.target, new Set(['type', 'databaseViewStableKey', 'refreshMode']))
|
|
127
|
+
&& isNonEmptyString(manifest.target.databaseViewStableKey)
|
|
128
|
+
&& ['CONCURRENT', 'BLOCKING'].includes(String(manifest.target.refreshMode));
|
|
129
|
+
if (!validJobTemplateTarget && !validMaterializedViewTarget) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
if (!validateDiagnostics) {
|
|
133
|
+
return readConfiguredResourceMetadataStableKey(manifest, stableKeyName) === manifest.stableKey;
|
|
134
|
+
}
|
|
135
|
+
if (manifest.sourceTargetConfig !== undefined && manifest.sourceTargetConfig !== null && !isRecord(manifest.sourceTargetConfig)) {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
if (typeof manifest.activationCandidate !== 'boolean') {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return readConfiguredResourceMetadataStableKey(manifest, stableKeyName) === manifest.stableKey;
|
|
142
|
+
}
|
|
143
|
+
function manifestFromRecord(input) {
|
|
144
|
+
const { record, stableKey, target, stableKeyName } = input;
|
|
145
|
+
return {
|
|
146
|
+
kind: 'schedule',
|
|
147
|
+
stableKey,
|
|
148
|
+
name: String(record.name || ''),
|
|
149
|
+
...(record.category === undefined ? {} : { category: record.category }),
|
|
150
|
+
...(typeof record.desc === 'string' ? { description: record.desc } : typeof record.description === 'string' ? { description: record.description } : {}),
|
|
151
|
+
metadata: canonicalMetadata(record, stableKeyName, stableKey),
|
|
152
|
+
cron: normalizeCron(String(record.cron || '')),
|
|
153
|
+
timezone: normalizeTimezone(String(record.timezone || '')),
|
|
154
|
+
target,
|
|
155
|
+
...(target.type === 'JOB_TEMPLATE' && record.targetConfig !== undefined
|
|
156
|
+
? { sourceTargetConfig: canonicalValue(record.targetConfig) }
|
|
157
|
+
: {}),
|
|
158
|
+
activationCandidate: record.active === true,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** Desired target shape. Source-only configuration and activation are excluded. */
|
|
162
|
+
export function hashPortableSchedule(manifest) {
|
|
163
|
+
return hashStable({
|
|
164
|
+
kind: 'schedule',
|
|
165
|
+
stableKey: manifest.stableKey,
|
|
166
|
+
name: manifest.name,
|
|
167
|
+
category: manifest.category ?? null,
|
|
168
|
+
description: manifest.description ?? '',
|
|
169
|
+
metadata: canonicalValue(manifest.metadata),
|
|
170
|
+
cron: manifest.cron,
|
|
171
|
+
timezone: manifest.timezone,
|
|
172
|
+
target: canonicalValue(manifest.target),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/** Local baseline shape. It deliberately includes editable/source-only pull data. */
|
|
176
|
+
export function hashScheduleManifest(manifest) {
|
|
177
|
+
return hashStable(canonicalValue(manifest));
|
|
178
|
+
}
|
|
179
|
+
function warningKey(record, stableKey) {
|
|
180
|
+
return stableKey || String(record.name || scheduleIdOf(record) || 'unknown schedule');
|
|
181
|
+
}
|
|
182
|
+
async function hydrateRemoteRecords(input) {
|
|
183
|
+
return Promise.all(input.summaries.map(async (summary) => {
|
|
184
|
+
const id = input.idOf(summary);
|
|
185
|
+
return id ? unwrapData(await input.get(id)) : summary;
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
async function loadRemoteJobTemplates(client) {
|
|
189
|
+
const { jobTemplates } = await client.listAllJobTemplates();
|
|
190
|
+
return hydrateRemoteRecords({
|
|
191
|
+
summaries: jobTemplates,
|
|
192
|
+
idOf: jobTemplateIdOf,
|
|
193
|
+
get: (id) => client.getJobTemplate(id),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
async function loadRemoteSchedules(client) {
|
|
197
|
+
const { schedules } = await client.listAllSchedules();
|
|
198
|
+
return hydrateRemoteRecords({
|
|
199
|
+
summaries: schedules,
|
|
200
|
+
idOf: scheduleIdOf,
|
|
201
|
+
get: (id) => client.getSchedule(id),
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
async function loadJobTemplatesById(client, ids) {
|
|
205
|
+
const uniqueIds = [...new Set(ids)].filter(Boolean);
|
|
206
|
+
const entries = await Promise.all(uniqueIds.map(async (id) => [
|
|
207
|
+
id,
|
|
208
|
+
unwrapData(await client.getJobTemplate(id)),
|
|
209
|
+
]));
|
|
210
|
+
return new Map(entries);
|
|
211
|
+
}
|
|
212
|
+
async function loadDatabaseViewsById(client, ids) {
|
|
213
|
+
const uniqueIds = [...new Set(ids)].filter(Boolean);
|
|
214
|
+
const entries = await Promise.all(uniqueIds.map(async (id) => [
|
|
215
|
+
id,
|
|
216
|
+
unwrapData(await client.getDatabaseView(id)),
|
|
217
|
+
]));
|
|
218
|
+
return new Map(entries);
|
|
219
|
+
}
|
|
220
|
+
function materializedRefreshMode(record) {
|
|
221
|
+
if (!isRecord(record.targetConfig) || record.targetConfig.kind !== 'refresh_materialized_view') {
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
const refreshMode = record.targetConfig.refreshMode ?? 'CONCURRENT';
|
|
225
|
+
return refreshMode === 'CONCURRENT' || refreshMode === 'BLOCKING' ? refreshMode : undefined;
|
|
226
|
+
}
|
|
227
|
+
function isMaterializedViewRefreshSchedule(record) {
|
|
228
|
+
return record.targetType === 'PLATFORM_OPERATION'
|
|
229
|
+
&& isRecord(record.targetConfig)
|
|
230
|
+
&& record.targetConfig.kind === 'refresh_materialized_view';
|
|
231
|
+
}
|
|
232
|
+
function isLiveMaterializedView(record) {
|
|
233
|
+
return record !== undefined
|
|
234
|
+
&& record.type === 'MATERIALIZED_VIEW'
|
|
235
|
+
&& !record.deletedAt
|
|
236
|
+
&& !record.deletedBy;
|
|
237
|
+
}
|
|
238
|
+
async function loadRemoteScheduleInventory(client, stableKeyName, { includeDatabaseViews = false } = {}) {
|
|
239
|
+
const [schedules, jobTemplates] = await Promise.all([
|
|
240
|
+
loadRemoteSchedules(client),
|
|
241
|
+
loadRemoteJobTemplates(client),
|
|
242
|
+
]);
|
|
243
|
+
const databaseViews = includeDatabaseViews || schedules.some(isMaterializedViewRefreshSchedule)
|
|
244
|
+
? await client.listAllDatabaseViewsForSync()
|
|
245
|
+
: [];
|
|
246
|
+
const schedulesByStableKey = new Map();
|
|
247
|
+
const jobTemplatesById = new Map();
|
|
248
|
+
const jobTemplatesByStableKey = new Map();
|
|
249
|
+
const databaseViewsById = new Map();
|
|
250
|
+
const databaseViewsByStableKey = new Map();
|
|
251
|
+
for (const schedule of schedules) {
|
|
252
|
+
const stableKey = readConfiguredResourceMetadataStableKey(schedule, stableKeyName);
|
|
253
|
+
if (stableKey) {
|
|
254
|
+
schedulesByStableKey.set(stableKey, [...(schedulesByStableKey.get(stableKey) || []), schedule]);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
for (const jobTemplate of jobTemplates) {
|
|
258
|
+
const id = jobTemplateIdOf(jobTemplate);
|
|
259
|
+
const stableKey = readConfiguredResourceMetadataStableKey(jobTemplate, stableKeyName);
|
|
260
|
+
if (id) {
|
|
261
|
+
jobTemplatesById.set(id, jobTemplate);
|
|
262
|
+
}
|
|
263
|
+
if (stableKey) {
|
|
264
|
+
jobTemplatesByStableKey.set(stableKey, [...(jobTemplatesByStableKey.get(stableKey) || []), jobTemplate]);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
for (const databaseView of databaseViews) {
|
|
268
|
+
const id = databaseViewIdOf(databaseView);
|
|
269
|
+
const stableKey = readConfiguredResourceMetadataStableKey(databaseView, stableKeyName);
|
|
270
|
+
if (id) {
|
|
271
|
+
databaseViewsById.set(id, databaseView);
|
|
272
|
+
}
|
|
273
|
+
if (stableKey && !databaseView.deletedAt && !databaseView.deletedBy) {
|
|
274
|
+
databaseViewsByStableKey.set(stableKey, [...(databaseViewsByStableKey.get(stableKey) || []), databaseView]);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
schedules,
|
|
279
|
+
schedulesByStableKey,
|
|
280
|
+
jobTemplatesById,
|
|
281
|
+
jobTemplatesByStableKey,
|
|
282
|
+
databaseViewsById,
|
|
283
|
+
databaseViewsByStableKey,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
function schedulePlanExclusionReason(record) {
|
|
287
|
+
if (record.systemManaged === true) {
|
|
288
|
+
return 'system-managed schedule is excluded';
|
|
289
|
+
}
|
|
290
|
+
if (record.deletedAt || record.deletedBy) {
|
|
291
|
+
return 'deleted schedule is excluded';
|
|
292
|
+
}
|
|
293
|
+
if (record.targetType === 'JOB_TEMPLATE' || isMaterializedViewRefreshSchedule(record)) {
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
return `target type ${String(record.targetType || 'unknown')} is unsupported; only JOB_TEMPLATE and MATERIALIZED_VIEW refresh schedules are migrated`;
|
|
297
|
+
}
|
|
298
|
+
function scheduleHint(value) {
|
|
299
|
+
const category = typeof value.category === 'string' && value.category.trim()
|
|
300
|
+
? value.category
|
|
301
|
+
: NULL_CATEGORY_KEY;
|
|
302
|
+
return normalizeStableKeyValue(`${category}/${String(value.name || '')}`);
|
|
303
|
+
}
|
|
304
|
+
function diffSchedules(desired, target) {
|
|
305
|
+
const fields = [
|
|
306
|
+
['name', desired.name, target.name],
|
|
307
|
+
['category', desired.category ?? null, target.category ?? null],
|
|
308
|
+
['description', desired.description ?? '', target.description ?? ''],
|
|
309
|
+
['metadata', canonicalValue(desired.metadata), canonicalValue(target.metadata)],
|
|
310
|
+
['cron', desired.cron, target.cron],
|
|
311
|
+
['timezone', desired.timezone, target.timezone],
|
|
312
|
+
['target', canonicalValue(desired.target), canonicalValue(target.target)],
|
|
313
|
+
];
|
|
314
|
+
return fields
|
|
315
|
+
.filter(([, desiredValue, targetValue]) => (hashStable({ value: desiredValue }) !== hashStable({ value: targetValue })))
|
|
316
|
+
.map(([field]) => field)
|
|
317
|
+
.sort((left, right) => left.localeCompare(right));
|
|
318
|
+
}
|
|
319
|
+
function planTargetDetails(target) {
|
|
320
|
+
return target.type === 'JOB_TEMPLATE'
|
|
321
|
+
? { jobTemplateStableKey: target.jobTemplateStableKey }
|
|
322
|
+
: { databaseViewStableKey: target.databaseViewStableKey };
|
|
323
|
+
}
|
|
324
|
+
function targetDependencyLabel(target) {
|
|
325
|
+
return target.type === 'JOB_TEMPLATE' ? 'Job Template' : 'Materialized View';
|
|
326
|
+
}
|
|
327
|
+
function targetDependencyStableKey(target) {
|
|
328
|
+
return target.type === 'JOB_TEMPLATE' ? target.jobTemplateStableKey : target.databaseViewStableKey;
|
|
329
|
+
}
|
|
330
|
+
function targetFromRemoteRecord(record, remote, stableKeyName) {
|
|
331
|
+
if (record.targetType === 'JOB_TEMPLATE') {
|
|
332
|
+
const dependency = record.targetId ? remote.jobTemplatesById.get(record.targetId) : undefined;
|
|
333
|
+
const stableKey = dependency
|
|
334
|
+
? readConfiguredResourceMetadataStableKey(dependency, stableKeyName)
|
|
335
|
+
: undefined;
|
|
336
|
+
return stableKey
|
|
337
|
+
? { target: { type: 'JOB_TEMPLATE', jobTemplateStableKey: stableKey } }
|
|
338
|
+
: { reason: `target Job Template ${record.targetId || 'unknown'} is missing ${stableKeyName} or unavailable` };
|
|
339
|
+
}
|
|
340
|
+
const dependency = record.targetId ? remote.databaseViewsById.get(record.targetId) : undefined;
|
|
341
|
+
const stableKey = dependency
|
|
342
|
+
? readConfiguredResourceMetadataStableKey(dependency, stableKeyName)
|
|
343
|
+
: undefined;
|
|
344
|
+
const refreshMode = materializedRefreshMode(record);
|
|
345
|
+
if (!isLiveMaterializedView(dependency) || !stableKey) {
|
|
346
|
+
return { reason: `target Materialized View ${record.targetId || 'unknown'} is missing ${stableKeyName}, unavailable, deleted, or not materialized` };
|
|
347
|
+
}
|
|
348
|
+
if (!refreshMode) {
|
|
349
|
+
return { reason: 'target Materialized View Schedule has an unsupported refresh mode' };
|
|
350
|
+
}
|
|
351
|
+
return { target: { type: 'MATERIALIZED_VIEW', databaseViewStableKey: stableKey, refreshMode } };
|
|
352
|
+
}
|
|
353
|
+
function emptyPlanCounts() {
|
|
354
|
+
return {
|
|
355
|
+
clean: 0,
|
|
356
|
+
create: 0,
|
|
357
|
+
'safe-update': 0,
|
|
358
|
+
'remote-changed': 0,
|
|
359
|
+
conflict: 0,
|
|
360
|
+
'missing-lock': 0,
|
|
361
|
+
orphan: 0,
|
|
362
|
+
collision: 0,
|
|
363
|
+
skipped: 0,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
export async function buildSchedulesPlan(input) {
|
|
367
|
+
const { projectRoot } = requireProjectLayout(input.cwd, 'Schedule');
|
|
368
|
+
const localEntries = readWorkspaceSchedules(input.cwd);
|
|
369
|
+
const remote = await loadRemoteScheduleInventory(input.client, input.stableKeyName, {
|
|
370
|
+
includeDatabaseViews: localEntries.some((entry) => entry.manifest?.target?.type === 'MATERIALIZED_VIEW'),
|
|
371
|
+
});
|
|
372
|
+
const targetRecords = remote.schedules;
|
|
373
|
+
const lock = readComponentLock(projectRoot);
|
|
374
|
+
const blockers = [];
|
|
375
|
+
const warnings = [];
|
|
376
|
+
const exclusions = [];
|
|
377
|
+
const items = [];
|
|
378
|
+
const lockEntriesByPath = new Map(Object.values(lock.schedules).map((entry) => [entry.path, entry]));
|
|
379
|
+
const identityConflictedTargetKeys = new Set();
|
|
380
|
+
const localByKey = new Map();
|
|
381
|
+
for (const entry of localEntries) {
|
|
382
|
+
const rawManifest = entry.manifest;
|
|
383
|
+
if (!validManifest(rawManifest, input.stableKeyName, { validateDiagnostics: false })) {
|
|
384
|
+
blockers.push(`Invalid Schedule manifest ${path.relative(projectRoot, entry.manifestPath)}.`);
|
|
385
|
+
items.push({
|
|
386
|
+
status: 'conflict',
|
|
387
|
+
name: isRecord(rawManifest) && isNonEmptyString(rawManifest.name)
|
|
388
|
+
? rawManifest.name
|
|
389
|
+
: path.basename(path.dirname(entry.manifestPath)),
|
|
390
|
+
reason: 'invalid Schedule manifest',
|
|
391
|
+
});
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
const lockEntry = lockEntriesByPath.get(toPortableRelativePath(projectRoot, entry.manifestPath));
|
|
395
|
+
if (lockEntry && lockEntry.stableKey !== entry.manifest.stableKey) {
|
|
396
|
+
blockers.push(`Schedule manifest ${path.relative(projectRoot, entry.manifestPath)} changed stableKey from "${lockEntry.stableKey}" to "${entry.manifest.stableKey}".`);
|
|
397
|
+
items.push({
|
|
398
|
+
status: 'conflict',
|
|
399
|
+
stableKey: entry.manifest.stableKey,
|
|
400
|
+
name: entry.manifest.name,
|
|
401
|
+
reason: `stableKey changed from "${lockEntry.stableKey}"; identity changes are not renames`,
|
|
402
|
+
desiredHash: hashPortableSchedule(entry.manifest),
|
|
403
|
+
...planTargetDetails(entry.manifest.target),
|
|
404
|
+
});
|
|
405
|
+
identityConflictedTargetKeys.add(lockEntry.stableKey);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
localByKey.set(entry.manifest.stableKey, [...(localByKey.get(entry.manifest.stableKey) || []), entry]);
|
|
409
|
+
}
|
|
410
|
+
const targetByKey = new Map();
|
|
411
|
+
const unresolvedTargetByKey = new Map();
|
|
412
|
+
const excludedTargetByKey = new Map();
|
|
413
|
+
const supportedTargetRecordsByKey = new Map();
|
|
414
|
+
const unmanaged = [];
|
|
415
|
+
for (const record of targetRecords) {
|
|
416
|
+
const exclusion = schedulePlanExclusionReason(record);
|
|
417
|
+
const name = String(record.name || scheduleIdOf(record) || 'unknown schedule');
|
|
418
|
+
const stableKey = readConfiguredResourceMetadataStableKey(record, input.stableKeyName);
|
|
419
|
+
if (exclusion) {
|
|
420
|
+
exclusions.push(`Schedule "${name}": ${exclusion}.`);
|
|
421
|
+
if (stableKey) {
|
|
422
|
+
items.push({ status: 'skipped', stableKey, name, reason: exclusion });
|
|
423
|
+
excludedTargetByKey.set(stableKey, [...(excludedTargetByKey.get(stableKey) || []), { record, reason: exclusion }]);
|
|
424
|
+
}
|
|
425
|
+
else {
|
|
426
|
+
unmanaged.push(record);
|
|
427
|
+
}
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (!stableKey) {
|
|
431
|
+
const reason = `missing ${input.stableKeyName}; unmanaged`;
|
|
432
|
+
exclusions.push(`Schedule "${name}": ${reason}.`);
|
|
433
|
+
unmanaged.push(record);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
supportedTargetRecordsByKey.set(stableKey, [...(supportedTargetRecordsByKey.get(stableKey) || []), record]);
|
|
437
|
+
}
|
|
438
|
+
const duplicateTargetKeys = new Set();
|
|
439
|
+
for (const [stableKey, records] of supportedTargetRecordsByKey) {
|
|
440
|
+
if (records.length < 2) {
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
duplicateTargetKeys.add(stableKey);
|
|
444
|
+
blockers.push(`Duplicate target Schedule stableKey "${stableKey}".`);
|
|
445
|
+
items.push({
|
|
446
|
+
status: 'collision',
|
|
447
|
+
stableKey,
|
|
448
|
+
name: String(records[0].name || stableKey),
|
|
449
|
+
reason: 'duplicate target stableKey',
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
for (const [stableKey, records] of supportedTargetRecordsByKey) {
|
|
453
|
+
if (duplicateTargetKeys.has(stableKey)) {
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
const record = records[0];
|
|
457
|
+
const resolved = targetFromRemoteRecord(record, remote, input.stableKeyName);
|
|
458
|
+
if (!resolved.target) {
|
|
459
|
+
const reason = resolved.reason || 'target dependency is unavailable';
|
|
460
|
+
exclusions.push(`Schedule "${stableKey}": ${reason}.`);
|
|
461
|
+
unresolvedTargetByKey.set(stableKey, [...(unresolvedTargetByKey.get(stableKey) || []), { record, reason }]);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
const manifest = manifestFromRecord({ record, stableKey, target: resolved.target, stableKeyName: input.stableKeyName });
|
|
465
|
+
if (!validManifest(manifest, input.stableKeyName, { validateDiagnostics: false })) {
|
|
466
|
+
const reason = 'target Schedule definition is incomplete or invalid';
|
|
467
|
+
exclusions.push(`Schedule "${stableKey}": ${reason}.`);
|
|
468
|
+
unresolvedTargetByKey.set(stableKey, [...(unresolvedTargetByKey.get(stableKey) || []), { record, reason }]);
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
targetByKey.set(stableKey, [...(targetByKey.get(stableKey) || []), {
|
|
472
|
+
record,
|
|
473
|
+
manifest,
|
|
474
|
+
hash: hashPortableSchedule(manifest),
|
|
475
|
+
}]);
|
|
476
|
+
}
|
|
477
|
+
const consumedTargetKeys = new Set(identityConflictedTargetKeys);
|
|
478
|
+
const consumedUnmanaged = new Set();
|
|
479
|
+
for (const [stableKey, entries] of localByKey) {
|
|
480
|
+
if (entries.length > 1) {
|
|
481
|
+
const desired = entries[0].manifest;
|
|
482
|
+
blockers.push(`Duplicate desired Schedule stableKey "${desired.stableKey}".`);
|
|
483
|
+
items.push({ status: 'collision', stableKey: desired.stableKey, name: desired.name, reason: 'duplicate desired stableKey' });
|
|
484
|
+
consumedTargetKeys.add(stableKey);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
const desired = entries[0].manifest;
|
|
488
|
+
const desiredHash = hashPortableSchedule(desired);
|
|
489
|
+
const excluded = excludedTargetByKey.get(stableKey) || [];
|
|
490
|
+
if (excluded.length > 0) {
|
|
491
|
+
const reason = excluded.length === 1
|
|
492
|
+
? excluded[0].reason
|
|
493
|
+
: 'multiple excluded target Schedules use this stableKey';
|
|
494
|
+
blockers.push(`Target Schedule "${desired.stableKey}" is excluded: ${reason}.`);
|
|
495
|
+
items.push({
|
|
496
|
+
status: 'conflict',
|
|
497
|
+
stableKey: desired.stableKey,
|
|
498
|
+
name: desired.name,
|
|
499
|
+
reason: `target Schedule is excluded: ${reason}`,
|
|
500
|
+
desiredHash,
|
|
501
|
+
...planTargetDetails(desired.target),
|
|
502
|
+
});
|
|
503
|
+
consumedTargetKeys.add(stableKey);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
const unresolved = unresolvedTargetByKey.get(stableKey) || [];
|
|
507
|
+
if (unresolved.length > 0) {
|
|
508
|
+
const reason = unresolved.length === 1
|
|
509
|
+
? unresolved[0].reason
|
|
510
|
+
: 'multiple target Schedules have unresolved targets';
|
|
511
|
+
blockers.push(`Target Schedule "${desired.stableKey}" ${reason}.`);
|
|
512
|
+
items.push({
|
|
513
|
+
status: 'conflict',
|
|
514
|
+
stableKey: desired.stableKey,
|
|
515
|
+
name: desired.name,
|
|
516
|
+
reason,
|
|
517
|
+
desiredHash,
|
|
518
|
+
...planTargetDetails(desired.target),
|
|
519
|
+
});
|
|
520
|
+
consumedTargetKeys.add(stableKey);
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
if (duplicateTargetKeys.has(stableKey)) {
|
|
524
|
+
consumedTargetKeys.add(stableKey);
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const dependencyStableKey = targetDependencyStableKey(desired.target);
|
|
528
|
+
const dependencyLabel = targetDependencyLabel(desired.target);
|
|
529
|
+
const dependencyMatches = desired.target.type === 'JOB_TEMPLATE'
|
|
530
|
+
? remote.jobTemplatesByStableKey.get(dependencyStableKey) || []
|
|
531
|
+
: remote.databaseViewsByStableKey.get(dependencyStableKey) || [];
|
|
532
|
+
if (dependencyMatches.length !== 1) {
|
|
533
|
+
const reason = dependencyMatches.length === 0
|
|
534
|
+
? `target ${dependencyLabel} is missing`
|
|
535
|
+
: `target ${dependencyLabel} stableKey is ambiguous`;
|
|
536
|
+
blockers.push(`Schedule "${desired.stableKey}" dependency "${dependencyStableKey}": ${reason}.`);
|
|
537
|
+
items.push({
|
|
538
|
+
status: 'conflict',
|
|
539
|
+
stableKey: desired.stableKey,
|
|
540
|
+
name: desired.name,
|
|
541
|
+
reason,
|
|
542
|
+
desiredHash,
|
|
543
|
+
...planTargetDetails(desired.target),
|
|
544
|
+
});
|
|
545
|
+
consumedTargetKeys.add(stableKey);
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (desired.target.type === 'MATERIALIZED_VIEW'
|
|
549
|
+
&& dependencyMatches[0].type !== 'MATERIALIZED_VIEW') {
|
|
550
|
+
const reason = 'target Database View is not a MATERIALIZED_VIEW';
|
|
551
|
+
blockers.push(`Schedule "${desired.stableKey}" dependency "${dependencyStableKey}": ${reason}.`);
|
|
552
|
+
items.push({
|
|
553
|
+
status: 'conflict',
|
|
554
|
+
stableKey: desired.stableKey,
|
|
555
|
+
name: desired.name,
|
|
556
|
+
reason,
|
|
557
|
+
desiredHash,
|
|
558
|
+
...planTargetDetails(desired.target),
|
|
559
|
+
});
|
|
560
|
+
consumedTargetKeys.add(stableKey);
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
const matches = targetByKey.get(stableKey) || [];
|
|
564
|
+
if (matches.length === 0) {
|
|
565
|
+
const managedHintedKeys = [...targetByKey.entries()]
|
|
566
|
+
.filter(([targetStableKey, candidates]) => (targetStableKey !== stableKey
|
|
567
|
+
&& candidates.some((candidate) => scheduleHint(candidate.manifest) === scheduleHint(desired))))
|
|
568
|
+
.map(([targetStableKey]) => targetStableKey)
|
|
569
|
+
.sort((left, right) => left.localeCompare(right));
|
|
570
|
+
if (managedHintedKeys.length > 0) {
|
|
571
|
+
managedHintedKeys.forEach((targetStableKey) => consumedTargetKeys.add(targetStableKey));
|
|
572
|
+
const reason = managedHintedKeys.length === 1
|
|
573
|
+
? `target Schedule matching category/name uses stableKey "${managedHintedKeys[0]}"; identity conflict`
|
|
574
|
+
: `multiple target Schedules matching category/name use different stable keys; identity conflict`;
|
|
575
|
+
blockers.push(`Cannot create "${desired.stableKey}": ${reason}.`);
|
|
576
|
+
items.push({
|
|
577
|
+
status: 'conflict',
|
|
578
|
+
stableKey: desired.stableKey,
|
|
579
|
+
name: desired.name,
|
|
580
|
+
reason,
|
|
581
|
+
desiredHash,
|
|
582
|
+
...planTargetDetails(desired.target),
|
|
583
|
+
});
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
const hinted = unmanaged.filter((record) => scheduleHint(record) === scheduleHint(desired));
|
|
587
|
+
if (hinted.length > 0) {
|
|
588
|
+
hinted.forEach((record) => consumedUnmanaged.add(record));
|
|
589
|
+
const reason = `target Schedule matching category/name is missing ${input.stableKeyName}`;
|
|
590
|
+
blockers.push(`Cannot create "${desired.stableKey}": ${reason}.`);
|
|
591
|
+
items.push({
|
|
592
|
+
status: 'conflict',
|
|
593
|
+
stableKey: desired.stableKey,
|
|
594
|
+
name: desired.name,
|
|
595
|
+
reason,
|
|
596
|
+
desiredHash,
|
|
597
|
+
...planTargetDetails(desired.target),
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
else {
|
|
601
|
+
items.push({
|
|
602
|
+
status: 'create',
|
|
603
|
+
stableKey: desired.stableKey,
|
|
604
|
+
name: desired.name,
|
|
605
|
+
desiredHash,
|
|
606
|
+
...planTargetDetails(desired.target),
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
consumedTargetKeys.add(stableKey);
|
|
612
|
+
const target = matches[0];
|
|
613
|
+
if (desired.target.type !== target.manifest.target.type) {
|
|
614
|
+
const reason = `target type transition from ${target.manifest.target.type} to ${desired.target.type} requires --force`;
|
|
615
|
+
warnings.push(`Schedule "${desired.stableKey}": ${reason}.`);
|
|
616
|
+
items.push({
|
|
617
|
+
status: 'conflict',
|
|
618
|
+
stableKey: desired.stableKey,
|
|
619
|
+
name: desired.name,
|
|
620
|
+
reason,
|
|
621
|
+
desiredHash,
|
|
622
|
+
currentHash: target.hash,
|
|
623
|
+
...planTargetDetails(desired.target),
|
|
624
|
+
});
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
const changedFields = diffSchedules(desired, target.manifest);
|
|
628
|
+
const common = {
|
|
629
|
+
stableKey: desired.stableKey,
|
|
630
|
+
name: desired.name,
|
|
631
|
+
desiredHash,
|
|
632
|
+
currentHash: target.hash,
|
|
633
|
+
...planTargetDetails(desired.target),
|
|
634
|
+
...(changedFields.length > 0 ? { changedFields } : {}),
|
|
635
|
+
};
|
|
636
|
+
const baselineHash = lock.schedules[desired.stableKey]?.environments[input.envName]?.remoteHash;
|
|
637
|
+
if (!baselineHash) {
|
|
638
|
+
items.push({ status: 'missing-lock', ...common, reason: `missing ${input.envName} lock baseline` });
|
|
639
|
+
}
|
|
640
|
+
else if (desiredHash === target.hash) {
|
|
641
|
+
items.push({ status: 'clean', ...common });
|
|
642
|
+
}
|
|
643
|
+
else if (target.hash === baselineHash) {
|
|
644
|
+
items.push({ status: 'safe-update', ...common, baselineHash, reason: 'local manifest changed; target matches lock baseline' });
|
|
645
|
+
}
|
|
646
|
+
else if (desiredHash === baselineHash) {
|
|
647
|
+
items.push({ status: 'remote-changed', ...common, baselineHash, reason: 'target changed; local manifest matches lock baseline' });
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
items.push({ status: 'conflict', ...common, baselineHash, reason: 'local manifest and target both changed since lock baseline' });
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
for (const [stableKey, matches] of unresolvedTargetByKey) {
|
|
654
|
+
if (consumedTargetKeys.has(stableKey) || localByKey.has(stableKey)) {
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
const target = matches[0];
|
|
658
|
+
items.push({
|
|
659
|
+
status: 'skipped',
|
|
660
|
+
stableKey,
|
|
661
|
+
name: String(target.record.name || stableKey),
|
|
662
|
+
reason: target.reason,
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
for (const [stableKey, matches] of targetByKey) {
|
|
666
|
+
if (consumedTargetKeys.has(stableKey) || localByKey.has(stableKey)) {
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
if (matches.length > 1) {
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
const target = matches[0];
|
|
673
|
+
items.push({
|
|
674
|
+
status: 'orphan',
|
|
675
|
+
stableKey,
|
|
676
|
+
name: target.manifest.name,
|
|
677
|
+
currentHash: target.hash,
|
|
678
|
+
...planTargetDetails(target.manifest.target),
|
|
679
|
+
reason: 'target-only Schedule',
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
for (const record of unmanaged) {
|
|
683
|
+
if (consumedUnmanaged.has(record)) {
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
items.push({
|
|
687
|
+
status: 'skipped',
|
|
688
|
+
name: String(record.name || scheduleIdOf(record) || 'unknown schedule'),
|
|
689
|
+
reason: `missing ${input.stableKeyName}; unmanaged`,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
const rank = {
|
|
693
|
+
create: 0,
|
|
694
|
+
'safe-update': 1,
|
|
695
|
+
'remote-changed': 2,
|
|
696
|
+
conflict: 3,
|
|
697
|
+
'missing-lock': 4,
|
|
698
|
+
clean: 5,
|
|
699
|
+
orphan: 6,
|
|
700
|
+
collision: 7,
|
|
701
|
+
skipped: 8,
|
|
702
|
+
};
|
|
703
|
+
items.sort((left, right) => {
|
|
704
|
+
const leftKey = left.stableKey || left.name;
|
|
705
|
+
const rightKey = right.stableKey || right.name;
|
|
706
|
+
return rank[left.status] - rank[right.status]
|
|
707
|
+
|| leftKey.localeCompare(rightKey);
|
|
708
|
+
});
|
|
709
|
+
const counts = emptyPlanCounts();
|
|
710
|
+
for (const item of items) {
|
|
711
|
+
counts[item.status] += 1;
|
|
712
|
+
}
|
|
713
|
+
return {
|
|
714
|
+
resourceType: 'schedule',
|
|
715
|
+
env: input.envName,
|
|
716
|
+
counts,
|
|
717
|
+
blockers: [...new Set(blockers)].sort((left, right) => left.localeCompare(right)),
|
|
718
|
+
warnings: [...new Set(warnings)].sort((left, right) => left.localeCompare(right)),
|
|
719
|
+
items,
|
|
720
|
+
exclusions: [...new Set(exclusions)].sort((left, right) => left.localeCompare(right)),
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
export async function pullSchedulesToWorkspace(input) {
|
|
724
|
+
const { projectRoot, workspaceRoot } = requireProjectLayout(input.cwd, 'Schedule');
|
|
725
|
+
const sourceSchedules = await loadRemoteSchedules(input.client);
|
|
726
|
+
const supportedSourceSchedules = sourceSchedules
|
|
727
|
+
.filter((record) => !schedulePlanExclusionReason(record))
|
|
728
|
+
.filter((record) => Boolean(readConfiguredResourceMetadataStableKey(record, input.stableKeyName)));
|
|
729
|
+
const [jobTemplates, databaseViews] = await Promise.all([
|
|
730
|
+
loadJobTemplatesById(input.client, supportedSourceSchedules
|
|
731
|
+
.filter((record) => record.targetType === 'JOB_TEMPLATE')
|
|
732
|
+
.map((record) => record.targetId || '')),
|
|
733
|
+
loadDatabaseViewsById(input.client, supportedSourceSchedules
|
|
734
|
+
.filter(isMaterializedViewRefreshSchedule)
|
|
735
|
+
.map((record) => record.targetId || '')),
|
|
736
|
+
]);
|
|
737
|
+
const manifests = [];
|
|
738
|
+
const warnings = [];
|
|
739
|
+
const stableKeys = new Set();
|
|
740
|
+
for (const record of sourceSchedules) {
|
|
741
|
+
const exclusion = schedulePlanExclusionReason(record);
|
|
742
|
+
if (exclusion) {
|
|
743
|
+
warnings.push({ stableKey: warningKey(record), reason: exclusion });
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
const stableKey = readConfiguredResourceMetadataStableKey(record, input.stableKeyName);
|
|
747
|
+
if (!stableKey) {
|
|
748
|
+
warnings.push({ stableKey: warningKey(record), reason: `missing ${input.stableKeyName}; run metadata backfill first` });
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
if (stableKeys.has(stableKey)) {
|
|
752
|
+
throw new Error(`Duplicate schedule stableKey "${stableKey}" on ${input.envName}; no files were changed.`);
|
|
753
|
+
}
|
|
754
|
+
stableKeys.add(stableKey);
|
|
755
|
+
let target;
|
|
756
|
+
if (record.targetType === 'JOB_TEMPLATE') {
|
|
757
|
+
const sourceTemplate = record.targetId ? jobTemplates.get(record.targetId) : undefined;
|
|
758
|
+
const jobTemplateStableKey = sourceTemplate
|
|
759
|
+
? readConfiguredResourceMetadataStableKey(sourceTemplate, input.stableKeyName)
|
|
760
|
+
: undefined;
|
|
761
|
+
if (!jobTemplateStableKey) {
|
|
762
|
+
warnings.push({ stableKey, reason: `target Job Template ${record.targetId || 'unknown'} is missing ${input.stableKeyName} or unavailable` });
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
target = { type: 'JOB_TEMPLATE', jobTemplateStableKey };
|
|
766
|
+
}
|
|
767
|
+
else {
|
|
768
|
+
const sourceView = record.targetId ? databaseViews.get(record.targetId) : undefined;
|
|
769
|
+
const databaseViewStableKey = sourceView
|
|
770
|
+
? readConfiguredResourceMetadataStableKey(sourceView, input.stableKeyName)
|
|
771
|
+
: undefined;
|
|
772
|
+
const refreshMode = materializedRefreshMode(record);
|
|
773
|
+
if (!isLiveMaterializedView(sourceView) || !databaseViewStableKey) {
|
|
774
|
+
warnings.push({ stableKey, reason: `target Materialized View ${record.targetId || 'unknown'} is missing ${input.stableKeyName}, unavailable, deleted, or not materialized` });
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
if (!refreshMode) {
|
|
778
|
+
warnings.push({ stableKey, reason: 'Schedule definition has an unsupported materialized view refresh mode' });
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
target = { type: 'MATERIALIZED_VIEW', databaseViewStableKey, refreshMode };
|
|
782
|
+
}
|
|
783
|
+
const manifest = manifestFromRecord({ record, stableKey, target, stableKeyName: input.stableKeyName });
|
|
784
|
+
if (!validManifest(manifest, input.stableKeyName)) {
|
|
785
|
+
warnings.push({ stableKey, reason: 'Schedule definition has an invalid normalized cron expression or IANA timezone' });
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
manifests.push(manifest);
|
|
789
|
+
}
|
|
790
|
+
manifests.sort((left, right) => left.stableKey.localeCompare(right.stableKey));
|
|
791
|
+
warnings.sort((left, right) => left.stableKey.localeCompare(right.stableKey) || left.reason.localeCompare(right.reason));
|
|
792
|
+
const lock = readComponentLock(projectRoot);
|
|
793
|
+
const existingByKey = new Map();
|
|
794
|
+
const existingByPath = new Map();
|
|
795
|
+
for (const entry of readWorkspaceSchedules(input.cwd)) {
|
|
796
|
+
if (!validManifest(entry.manifest, input.stableKeyName)) {
|
|
797
|
+
throw new Error(`Cannot pull over invalid Schedule manifest ${path.relative(projectRoot, entry.manifestPath)}.`);
|
|
798
|
+
}
|
|
799
|
+
if (existingByKey.has(entry.manifest.stableKey)) {
|
|
800
|
+
throw new Error(`Duplicate local Schedule stableKey "${entry.manifest.stableKey}"; no files were changed.`);
|
|
801
|
+
}
|
|
802
|
+
existingByKey.set(entry.manifest.stableKey, entry);
|
|
803
|
+
const pathIdentity = portablePathIdentity(entry.manifestPath);
|
|
804
|
+
existingByPath.set(pathIdentity, [...(existingByPath.get(pathIdentity) || []), entry]);
|
|
805
|
+
}
|
|
806
|
+
const writes = manifests.map((manifest) => {
|
|
807
|
+
const sourceHash = hashScheduleManifest(manifest);
|
|
808
|
+
const remoteHash = hashPortableSchedule(manifest);
|
|
809
|
+
const existing = existingByKey.get(manifest.stableKey);
|
|
810
|
+
if (existing && !input.force) {
|
|
811
|
+
const localHash = hashScheduleManifest(existing.manifest);
|
|
812
|
+
const baseline = lock.schedules[manifest.stableKey];
|
|
813
|
+
if (localHash !== sourceHash && (!baseline || localHash !== baseline.sourceHash)) {
|
|
814
|
+
throw new Error(`Cannot pull Schedule "${manifest.stableKey}": local manifest changed since the lock baseline. Re-run with --force.`);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return {
|
|
818
|
+
manifest,
|
|
819
|
+
sourceHash,
|
|
820
|
+
remoteHash,
|
|
821
|
+
filePath: scheduleManifestPath(workspaceRoot, manifest),
|
|
822
|
+
previousPath: existing?.manifestPath,
|
|
823
|
+
};
|
|
824
|
+
});
|
|
825
|
+
const candidatePathCounts = new Map();
|
|
826
|
+
for (const write of writes) {
|
|
827
|
+
const identity = portablePathIdentity(write.filePath);
|
|
828
|
+
candidatePathCounts.set(identity, (candidatePathCounts.get(identity) || 0) + 1);
|
|
829
|
+
}
|
|
830
|
+
for (const write of writes) {
|
|
831
|
+
if ((candidatePathCounts.get(portablePathIdentity(write.filePath)) || 0) > 1) {
|
|
832
|
+
write.filePath = disambiguateManifestPath(write.filePath, write.manifest.stableKey);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
const paths = new Set();
|
|
836
|
+
for (const write of writes) {
|
|
837
|
+
const pathIdentity = portablePathIdentity(write.filePath);
|
|
838
|
+
if (paths.has(pathIdentity)) {
|
|
839
|
+
throw new Error('Multiple Schedules resolve to the same workspace path; no files were changed.');
|
|
840
|
+
}
|
|
841
|
+
paths.add(pathIdentity);
|
|
842
|
+
const pathOwner = (existingByPath.get(pathIdentity) || [])
|
|
843
|
+
.find((entry) => entry.manifest.stableKey !== write.manifest.stableKey);
|
|
844
|
+
if (pathOwner) {
|
|
845
|
+
throw new Error(`Schedule workspace path "${path.relative(projectRoot, write.filePath)}" is occupied by different stableKey "${pathOwner.manifest.stableKey}"; no files were changed.`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
const root = path.join(workspaceRoot, SCHEDULES_DIRECTORY);
|
|
849
|
+
let lockChanged = false;
|
|
850
|
+
for (const write of writes) {
|
|
851
|
+
writePulledManifest({
|
|
852
|
+
filePath: write.filePath,
|
|
853
|
+
manifest: write.manifest,
|
|
854
|
+
previousPath: write.previousPath,
|
|
855
|
+
root: root,
|
|
856
|
+
force: input.force,
|
|
857
|
+
});
|
|
858
|
+
const previous = lock.schedules[write.manifest.stableKey];
|
|
859
|
+
const previousBaseline = previous?.environments[input.envName];
|
|
860
|
+
const entry = {
|
|
861
|
+
stableKey: write.manifest.stableKey,
|
|
862
|
+
path: toPortableRelativePath(projectRoot, write.filePath),
|
|
863
|
+
sourceHash: write.sourceHash,
|
|
864
|
+
environments: {
|
|
865
|
+
...(previous?.environments || {}),
|
|
866
|
+
[input.envName]: previousBaseline?.remoteHash === write.remoteHash
|
|
867
|
+
? previousBaseline
|
|
868
|
+
: { remoteHash: write.remoteHash, syncedAt: new Date().toISOString() },
|
|
869
|
+
},
|
|
870
|
+
};
|
|
871
|
+
if (!previous || hashStable(previous) !== hashStable(entry)) {
|
|
872
|
+
lockChanged = true;
|
|
873
|
+
}
|
|
874
|
+
lock.schedules[write.manifest.stableKey] = entry;
|
|
875
|
+
}
|
|
876
|
+
if (lockChanged) {
|
|
877
|
+
writeComponentLock(projectRoot, lock);
|
|
878
|
+
}
|
|
879
|
+
return {
|
|
880
|
+
pulled: writes.map((write) => ({ manifest: write.manifest, targetPath: localTargetPath(input.cwd, write.filePath) })),
|
|
881
|
+
warnings,
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
function buildScheduleCreatePayload(manifest, targetId) {
|
|
885
|
+
const target = manifest.target.type === 'JOB_TEMPLATE'
|
|
886
|
+
? { targetType: 'JOB_TEMPLATE', targetId }
|
|
887
|
+
: {
|
|
888
|
+
targetType: 'PLATFORM_OPERATION',
|
|
889
|
+
targetId,
|
|
890
|
+
targetConfig: { kind: 'refresh_materialized_view', refreshMode: manifest.target.refreshMode },
|
|
891
|
+
};
|
|
892
|
+
return {
|
|
893
|
+
name: manifest.name,
|
|
894
|
+
...(manifest.category === undefined || manifest.category === null ? {} : { category: manifest.category }),
|
|
895
|
+
active: false,
|
|
896
|
+
...(manifest.description === undefined ? {} : { desc: manifest.description }),
|
|
897
|
+
metadata: canonicalValue(manifest.metadata),
|
|
898
|
+
cron: manifest.cron,
|
|
899
|
+
timezone: manifest.timezone,
|
|
900
|
+
...target,
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
function buildScheduleUpdatePayload(manifest, targetId, version) {
|
|
904
|
+
const target = manifest.target.type === 'JOB_TEMPLATE'
|
|
905
|
+
? { targetType: 'JOB_TEMPLATE', targetId }
|
|
906
|
+
: {
|
|
907
|
+
targetType: 'PLATFORM_OPERATION',
|
|
908
|
+
targetId,
|
|
909
|
+
targetConfig: { kind: 'refresh_materialized_view', refreshMode: manifest.target.refreshMode },
|
|
910
|
+
};
|
|
911
|
+
return {
|
|
912
|
+
name: manifest.name,
|
|
913
|
+
category: manifest.category ?? null,
|
|
914
|
+
desc: manifest.description ?? '',
|
|
915
|
+
metadata: canonicalValue(manifest.metadata),
|
|
916
|
+
cron: manifest.cron,
|
|
917
|
+
timezone: manifest.timezone,
|
|
918
|
+
...target,
|
|
919
|
+
version,
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
function workspaceSchedulesByStableKey(cwd, stableKeyName) {
|
|
923
|
+
return new Map(readWorkspaceSchedules(cwd)
|
|
924
|
+
.filter((entry) => validManifest(entry.manifest, stableKeyName, { validateDiagnostics: false }))
|
|
925
|
+
.map((entry) => [entry.manifest.stableKey, entry]));
|
|
926
|
+
}
|
|
927
|
+
function updateScheduleEnvironmentBaseline(input) {
|
|
928
|
+
const { projectRoot } = requireProjectLayout(input.cwd, 'Schedule');
|
|
929
|
+
const lock = readComponentLock(projectRoot);
|
|
930
|
+
const stableKey = input.manifest.stableKey;
|
|
931
|
+
const existingPair = Object.entries(lock.schedules).find(([, entry]) => entry.stableKey === stableKey);
|
|
932
|
+
const existing = existingPair?.[1];
|
|
933
|
+
if (existingPair && existingPair[0] !== stableKey) {
|
|
934
|
+
delete lock.schedules[existingPair[0]];
|
|
935
|
+
}
|
|
936
|
+
const previousBaseline = existing?.environments[input.envName];
|
|
937
|
+
lock.schedules[stableKey] = {
|
|
938
|
+
stableKey,
|
|
939
|
+
path: toPortableRelativePath(projectRoot, input.manifestPath),
|
|
940
|
+
sourceHash: hashScheduleManifest(input.manifest),
|
|
941
|
+
environments: {
|
|
942
|
+
...(existing?.environments || {}),
|
|
943
|
+
[input.envName]: previousBaseline?.remoteHash === input.remoteHash
|
|
944
|
+
? previousBaseline
|
|
945
|
+
: { remoteHash: input.remoteHash, syncedAt: new Date().toISOString() },
|
|
946
|
+
},
|
|
947
|
+
};
|
|
948
|
+
writeComponentLock(projectRoot, lock);
|
|
949
|
+
}
|
|
950
|
+
function schedulePushError(error) {
|
|
951
|
+
return error instanceof Error ? error.message : String(error);
|
|
952
|
+
}
|
|
953
|
+
function assertSchedulesPushable(plan, force, allowMaterialized) {
|
|
954
|
+
if (plan.blockers.length > 0) {
|
|
955
|
+
throw new Error(`Schedule push blocked: ${plan.blockers.join(' ')}`);
|
|
956
|
+
}
|
|
957
|
+
const missingLock = plan.items.filter((item) => item.status === 'missing-lock');
|
|
958
|
+
if (missingLock.length > 0 && !force) {
|
|
959
|
+
throw new Error('Schedule push blocked by missing-lock; establish a reviewed environment baseline before pushing.');
|
|
960
|
+
}
|
|
961
|
+
const remoteConflicts = plan.items.filter((item) => ['remote-changed', 'conflict'].includes(item.status));
|
|
962
|
+
if (remoteConflicts.length > 0 && !force) {
|
|
963
|
+
const statuses = [...new Set(remoteConflicts.map((item) => item.status))].sort().join(', ');
|
|
964
|
+
throw new Error(`Schedule push blocked by ${statuses}; review the plan and re-run with --force only to overwrite reviewed remote content drift.`);
|
|
965
|
+
}
|
|
966
|
+
const materializedWrites = plan.items.filter((item) => (item.databaseViewStableKey
|
|
967
|
+
&& ['create', 'safe-update', 'remote-changed', 'conflict', 'missing-lock'].includes(item.status)));
|
|
968
|
+
if (materializedWrites.length > 0 && !allowMaterialized) {
|
|
969
|
+
throw new Error('Schedule push includes Materialized View refresh definitions; review the plan and re-run with --allow-materialized.');
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
async function resolveLiveScheduleWriteTarget(input) {
|
|
973
|
+
const remote = await loadRemoteScheduleInventory(input.client, input.stableKeyName, {
|
|
974
|
+
includeDatabaseViews: input.manifest.target.type === 'MATERIALIZED_VIEW',
|
|
975
|
+
});
|
|
976
|
+
const dependencyStableKey = targetDependencyStableKey(input.manifest.target);
|
|
977
|
+
const dependencyLabel = targetDependencyLabel(input.manifest.target);
|
|
978
|
+
const dependencies = input.manifest.target.type === 'JOB_TEMPLATE'
|
|
979
|
+
? remote.jobTemplatesByStableKey.get(dependencyStableKey) || []
|
|
980
|
+
: remote.databaseViewsByStableKey.get(dependencyStableKey) || [];
|
|
981
|
+
if (dependencies.length !== 1) {
|
|
982
|
+
const reason = dependencies.length === 0 ? 'is missing' : 'is ambiguous';
|
|
983
|
+
throw new Error(`target ${dependencyLabel} dependency "${dependencyStableKey}" ${reason}`);
|
|
984
|
+
}
|
|
985
|
+
const targetId = input.manifest.target.type === 'JOB_TEMPLATE'
|
|
986
|
+
? jobTemplateIdOf(dependencies[0])
|
|
987
|
+
: databaseViewIdOf(dependencies[0]);
|
|
988
|
+
if (!targetId) {
|
|
989
|
+
throw new Error(`target ${dependencyLabel} dependency "${dependencyStableKey}" has no id`);
|
|
990
|
+
}
|
|
991
|
+
if (input.manifest.target.type === 'MATERIALIZED_VIEW'
|
|
992
|
+
&& dependencies[0].type !== 'MATERIALIZED_VIEW') {
|
|
993
|
+
throw new Error(`target Materialized View dependency "${dependencyStableKey}" is not materialized`);
|
|
994
|
+
}
|
|
995
|
+
const matchingSchedules = remote.schedulesByStableKey.get(input.manifest.stableKey) || [];
|
|
996
|
+
if (input.action === 'create') {
|
|
997
|
+
if (matchingSchedules.length > 0) {
|
|
998
|
+
throw new Error(`target Schedule "${input.manifest.stableKey}" appeared after planning`);
|
|
999
|
+
}
|
|
1000
|
+
const nameMatches = remote.schedules.filter((record) => scheduleHint(record) === scheduleHint(input.manifest));
|
|
1001
|
+
if (nameMatches.length > 0) {
|
|
1002
|
+
throw new Error(`target Schedule matching category/name for "${input.manifest.stableKey}" appeared after planning`);
|
|
1003
|
+
}
|
|
1004
|
+
return { targetId, target: input.manifest.target };
|
|
1005
|
+
}
|
|
1006
|
+
if (matchingSchedules.length !== 1) {
|
|
1007
|
+
throw new Error(`expected one live target Schedule match, found ${matchingSchedules.length}`);
|
|
1008
|
+
}
|
|
1009
|
+
const schedule = matchingSchedules[0];
|
|
1010
|
+
const exclusion = schedulePlanExclusionReason(schedule);
|
|
1011
|
+
if (exclusion) {
|
|
1012
|
+
throw new Error(`live target Schedule is excluded: ${exclusion}`);
|
|
1013
|
+
}
|
|
1014
|
+
const scheduleId = scheduleIdOf(schedule);
|
|
1015
|
+
if (!scheduleId) {
|
|
1016
|
+
throw new Error('live target Schedule did not include scheduleId');
|
|
1017
|
+
}
|
|
1018
|
+
const resolvedCurrent = targetFromRemoteRecord(schedule, remote, input.stableKeyName);
|
|
1019
|
+
if (!resolvedCurrent.target) {
|
|
1020
|
+
throw new Error('live target Schedule dependency is missing or unmanaged');
|
|
1021
|
+
}
|
|
1022
|
+
const currentManifest = manifestFromRecord({
|
|
1023
|
+
record: schedule,
|
|
1024
|
+
stableKey: input.manifest.stableKey,
|
|
1025
|
+
target: resolvedCurrent.target,
|
|
1026
|
+
stableKeyName: input.stableKeyName,
|
|
1027
|
+
});
|
|
1028
|
+
return {
|
|
1029
|
+
targetId,
|
|
1030
|
+
target: input.manifest.target,
|
|
1031
|
+
schedule,
|
|
1032
|
+
scheduleId,
|
|
1033
|
+
currentHash: hashPortableSchedule(currentManifest),
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
async function verifyScheduleWrite(input) {
|
|
1037
|
+
const schedule = unwrapData(await input.client.getSchedule(input.scheduleId));
|
|
1038
|
+
if (readConfiguredResourceMetadataStableKey(schedule, input.stableKeyName) !== input.manifest.stableKey) {
|
|
1039
|
+
throw new Error(`verified Schedule is missing or changed ${input.stableKeyName}`);
|
|
1040
|
+
}
|
|
1041
|
+
const expectedTargetType = input.manifest.target.type === 'JOB_TEMPLATE' ? 'JOB_TEMPLATE' : 'PLATFORM_OPERATION';
|
|
1042
|
+
if (schedule.targetType !== expectedTargetType || schedule.targetId !== input.targetId) {
|
|
1043
|
+
throw new Error(`verified Schedule target does not match the live ${targetDependencyLabel(input.manifest.target)} dependency`);
|
|
1044
|
+
}
|
|
1045
|
+
if (input.manifest.target.type === 'MATERIALIZED_VIEW'
|
|
1046
|
+
&& materializedRefreshMode(schedule) !== input.manifest.target.refreshMode) {
|
|
1047
|
+
throw new Error('verified Schedule materialized view refresh configuration does not match the desired definition');
|
|
1048
|
+
}
|
|
1049
|
+
const dependency = input.manifest.target.type === 'JOB_TEMPLATE'
|
|
1050
|
+
? unwrapData(await input.client.getJobTemplate(input.targetId))
|
|
1051
|
+
: unwrapData(await input.client.getDatabaseView(input.targetId));
|
|
1052
|
+
if (input.manifest.target.type === 'MATERIALIZED_VIEW' && !isLiveMaterializedView(dependency)) {
|
|
1053
|
+
throw new Error('verified Schedule target is no longer a live Materialized View');
|
|
1054
|
+
}
|
|
1055
|
+
if (readConfiguredResourceMetadataStableKey(dependency, input.stableKeyName) !== targetDependencyStableKey(input.manifest.target)) {
|
|
1056
|
+
throw new Error(`verified Schedule target ${targetDependencyLabel(input.manifest.target)} does not match the desired stable key`);
|
|
1057
|
+
}
|
|
1058
|
+
const verifiedManifest = manifestFromRecord({
|
|
1059
|
+
record: schedule,
|
|
1060
|
+
stableKey: input.manifest.stableKey,
|
|
1061
|
+
target: input.manifest.target,
|
|
1062
|
+
stableKeyName: input.stableKeyName,
|
|
1063
|
+
});
|
|
1064
|
+
if (hashPortableSchedule(verifiedManifest) !== input.desiredHash) {
|
|
1065
|
+
throw new Error('written Schedule did not match desired portable hash');
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
export async function pushSchedules(input) {
|
|
1069
|
+
const plan = await buildSchedulesPlan(input);
|
|
1070
|
+
assertSchedulesPushable(plan, input.force === true, input.allowMaterialized === true);
|
|
1071
|
+
const localByKey = workspaceSchedulesByStableKey(input.cwd, input.stableKeyName);
|
|
1072
|
+
const results = [];
|
|
1073
|
+
const verifiedActivationCandidates = [];
|
|
1074
|
+
const processWrite = async (item, action) => {
|
|
1075
|
+
const stableKey = item.stableKey || '';
|
|
1076
|
+
const local = localByKey.get(stableKey);
|
|
1077
|
+
const targetPath = local ? localTargetPath(input.cwd, local.manifestPath) : stableKey;
|
|
1078
|
+
try {
|
|
1079
|
+
if (!local) {
|
|
1080
|
+
throw new Error('desired manifest disappeared after planning');
|
|
1081
|
+
}
|
|
1082
|
+
const desiredHash = hashPortableSchedule(local.manifest);
|
|
1083
|
+
if (desiredHash !== item.desiredHash) {
|
|
1084
|
+
throw new Error('desired manifest changed after planning');
|
|
1085
|
+
}
|
|
1086
|
+
const live = await resolveLiveScheduleWriteTarget({
|
|
1087
|
+
client: input.client,
|
|
1088
|
+
stableKeyName: input.stableKeyName,
|
|
1089
|
+
manifest: local.manifest,
|
|
1090
|
+
action,
|
|
1091
|
+
});
|
|
1092
|
+
let scheduleId;
|
|
1093
|
+
if (action === 'create') {
|
|
1094
|
+
const created = unwrapData(await input.client.createSchedule(buildScheduleCreatePayload(local.manifest, live.targetId)));
|
|
1095
|
+
scheduleId = scheduleIdOf(created);
|
|
1096
|
+
if (!scheduleId) {
|
|
1097
|
+
const resolvedCreated = await resolveLiveScheduleWriteTarget({
|
|
1098
|
+
client: input.client,
|
|
1099
|
+
stableKeyName: input.stableKeyName,
|
|
1100
|
+
manifest: local.manifest,
|
|
1101
|
+
action: 'update',
|
|
1102
|
+
});
|
|
1103
|
+
if (!resolvedCreated.scheduleId) {
|
|
1104
|
+
throw new Error('created Schedule could not be resolved by stableKey');
|
|
1105
|
+
}
|
|
1106
|
+
scheduleId = resolvedCreated.scheduleId;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
else {
|
|
1110
|
+
if (!live.scheduleId || !live.schedule || typeof live.schedule.version !== 'number') {
|
|
1111
|
+
throw new Error('live target Schedule did not include id and version');
|
|
1112
|
+
}
|
|
1113
|
+
if (live.currentHash !== item.currentHash) {
|
|
1114
|
+
throw new Error('target Schedule changed after planning');
|
|
1115
|
+
}
|
|
1116
|
+
await input.client.updateSchedule(live.scheduleId, buildScheduleUpdatePayload(local.manifest, live.targetId, live.schedule.version));
|
|
1117
|
+
scheduleId = live.scheduleId;
|
|
1118
|
+
}
|
|
1119
|
+
await verifyScheduleWrite({
|
|
1120
|
+
client: input.client,
|
|
1121
|
+
stableKeyName: input.stableKeyName,
|
|
1122
|
+
manifest: local.manifest,
|
|
1123
|
+
scheduleId,
|
|
1124
|
+
targetId: live.targetId,
|
|
1125
|
+
desiredHash,
|
|
1126
|
+
});
|
|
1127
|
+
updateScheduleEnvironmentBaseline({
|
|
1128
|
+
cwd: input.cwd,
|
|
1129
|
+
envName: input.envName,
|
|
1130
|
+
manifest: local.manifest,
|
|
1131
|
+
manifestPath: local.manifestPath,
|
|
1132
|
+
remoteHash: desiredHash,
|
|
1133
|
+
});
|
|
1134
|
+
results.push({ stableKey, action, status: 'deployed', targetPath });
|
|
1135
|
+
if (local.manifest.activationCandidate === true) {
|
|
1136
|
+
verifiedActivationCandidates.push({
|
|
1137
|
+
stableKey,
|
|
1138
|
+
scheduleId,
|
|
1139
|
+
targetPath,
|
|
1140
|
+
stableKeyName: input.stableKeyName,
|
|
1141
|
+
targetId: live.targetId,
|
|
1142
|
+
target: local.manifest.target,
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
catch (error) {
|
|
1147
|
+
results.push({ stableKey, action, status: 'failed', targetPath, error: schedulePushError(error) });
|
|
1148
|
+
}
|
|
1149
|
+
};
|
|
1150
|
+
for (const item of plan.items.filter((candidate) => candidate.status === 'create')) {
|
|
1151
|
+
await processWrite(item, 'create');
|
|
1152
|
+
}
|
|
1153
|
+
for (const item of plan.items.filter((candidate) => candidate.status === 'clean')) {
|
|
1154
|
+
const local = item.stableKey ? localByKey.get(item.stableKey) : undefined;
|
|
1155
|
+
if (local && item.currentHash) {
|
|
1156
|
+
updateScheduleEnvironmentBaseline({
|
|
1157
|
+
cwd: input.cwd,
|
|
1158
|
+
envName: input.envName,
|
|
1159
|
+
manifest: local.manifest,
|
|
1160
|
+
manifestPath: local.manifestPath,
|
|
1161
|
+
remoteHash: item.currentHash,
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
results.push({
|
|
1165
|
+
stableKey: item.stableKey || item.name,
|
|
1166
|
+
action: 'noop',
|
|
1167
|
+
status: 'skipped',
|
|
1168
|
+
targetPath: local ? localTargetPath(input.cwd, local.manifestPath) : item.stableKey || item.name,
|
|
1169
|
+
reason: 'no local changes',
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
for (const item of plan.items.filter((candidate) => (input.force === true
|
|
1173
|
+
&& candidate.status === 'missing-lock'
|
|
1174
|
+
&& candidate.desiredHash === candidate.currentHash))) {
|
|
1175
|
+
const stableKey = item.stableKey || '';
|
|
1176
|
+
const local = localByKey.get(stableKey);
|
|
1177
|
+
const targetPath = local ? localTargetPath(input.cwd, local.manifestPath) : stableKey;
|
|
1178
|
+
try {
|
|
1179
|
+
if (!local || !item.currentHash) {
|
|
1180
|
+
throw new Error('missing-lock Schedule could not be resolved for baseline adoption');
|
|
1181
|
+
}
|
|
1182
|
+
const live = await resolveLiveScheduleWriteTarget({
|
|
1183
|
+
client: input.client,
|
|
1184
|
+
stableKeyName: input.stableKeyName,
|
|
1185
|
+
manifest: local.manifest,
|
|
1186
|
+
action: 'update',
|
|
1187
|
+
});
|
|
1188
|
+
if (live.currentHash !== item.currentHash) {
|
|
1189
|
+
throw new Error('target Schedule changed after planning');
|
|
1190
|
+
}
|
|
1191
|
+
updateScheduleEnvironmentBaseline({
|
|
1192
|
+
cwd: input.cwd,
|
|
1193
|
+
envName: input.envName,
|
|
1194
|
+
manifest: local.manifest,
|
|
1195
|
+
manifestPath: local.manifestPath,
|
|
1196
|
+
remoteHash: item.currentHash,
|
|
1197
|
+
});
|
|
1198
|
+
results.push({
|
|
1199
|
+
stableKey,
|
|
1200
|
+
action: 'noop',
|
|
1201
|
+
status: 'deployed',
|
|
1202
|
+
targetPath,
|
|
1203
|
+
reason: 'force-adopted verified target into environment lock baseline',
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
catch (error) {
|
|
1207
|
+
results.push({
|
|
1208
|
+
stableKey,
|
|
1209
|
+
action: 'noop',
|
|
1210
|
+
status: 'failed',
|
|
1211
|
+
targetPath,
|
|
1212
|
+
error: schedulePushError(error),
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
for (const item of plan.items.filter((candidate) => (candidate.status === 'safe-update'
|
|
1217
|
+
|| (input.force === true
|
|
1218
|
+
&& (['remote-changed', 'conflict'].includes(candidate.status)
|
|
1219
|
+
|| (candidate.status === 'missing-lock'
|
|
1220
|
+
&& candidate.desiredHash !== candidate.currentHash)))))) {
|
|
1221
|
+
await processWrite(item, 'update');
|
|
1222
|
+
}
|
|
1223
|
+
for (const item of plan.items.filter((candidate) => candidate.status === 'orphan')) {
|
|
1224
|
+
results.push({
|
|
1225
|
+
stableKey: item.stableKey || item.name,
|
|
1226
|
+
action: 'orphan',
|
|
1227
|
+
status: 'skipped',
|
|
1228
|
+
targetPath: item.stableKey || item.name,
|
|
1229
|
+
reason: 'target-only orphan',
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
for (const item of plan.items.filter((candidate) => candidate.status === 'skipped')) {
|
|
1233
|
+
results.push({
|
|
1234
|
+
stableKey: item.stableKey || item.name,
|
|
1235
|
+
action: 'skipped',
|
|
1236
|
+
status: 'skipped',
|
|
1237
|
+
targetPath: item.stableKey || item.name,
|
|
1238
|
+
reason: item.reason || 'unmanaged',
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
return { plan, results, verifiedActivationCandidates };
|
|
1242
|
+
}
|
|
1243
|
+
export async function activateVerifiedSchedules(input) {
|
|
1244
|
+
const results = [];
|
|
1245
|
+
for (const candidate of input.candidates) {
|
|
1246
|
+
try {
|
|
1247
|
+
const current = unwrapData(await input.client.getSchedule(candidate.scheduleId));
|
|
1248
|
+
if (readConfiguredResourceMetadataStableKey(current, candidate.stableKeyName) !== candidate.stableKey) {
|
|
1249
|
+
throw new Error('target Schedule stable key changed after activation preflight');
|
|
1250
|
+
}
|
|
1251
|
+
const expectedTargetType = candidate.target.type === 'JOB_TEMPLATE' ? 'JOB_TEMPLATE' : 'PLATFORM_OPERATION';
|
|
1252
|
+
if (current.targetType !== expectedTargetType || current.targetId !== candidate.targetId) {
|
|
1253
|
+
throw new Error('target Schedule dependency changed after activation preflight');
|
|
1254
|
+
}
|
|
1255
|
+
if (candidate.target.type === 'MATERIALIZED_VIEW'
|
|
1256
|
+
&& materializedRefreshMode(current) !== candidate.target.refreshMode) {
|
|
1257
|
+
throw new Error('target Schedule materialized view refresh mode changed after activation preflight');
|
|
1258
|
+
}
|
|
1259
|
+
const dependency = candidate.target.type === 'JOB_TEMPLATE'
|
|
1260
|
+
? unwrapData(await input.client.getJobTemplate(candidate.targetId))
|
|
1261
|
+
: unwrapData(await input.client.getDatabaseView(candidate.targetId));
|
|
1262
|
+
if (candidate.target.type === 'MATERIALIZED_VIEW' && !isLiveMaterializedView(dependency)) {
|
|
1263
|
+
throw new Error('target Schedule dependency is no longer a live Materialized View');
|
|
1264
|
+
}
|
|
1265
|
+
if (readConfiguredResourceMetadataStableKey(dependency, candidate.stableKeyName)
|
|
1266
|
+
!== targetDependencyStableKey(candidate.target)) {
|
|
1267
|
+
throw new Error('target Schedule dependency stable key changed after activation preflight');
|
|
1268
|
+
}
|
|
1269
|
+
if (current.active === true) {
|
|
1270
|
+
results.push({
|
|
1271
|
+
stableKey: candidate.stableKey,
|
|
1272
|
+
action: 'activate',
|
|
1273
|
+
status: 'skipped',
|
|
1274
|
+
targetPath: candidate.targetPath,
|
|
1275
|
+
reason: 'already active',
|
|
1276
|
+
});
|
|
1277
|
+
continue;
|
|
1278
|
+
}
|
|
1279
|
+
await input.client.activateSchedule(candidate.scheduleId);
|
|
1280
|
+
const verified = unwrapData(await input.client.getSchedule(candidate.scheduleId));
|
|
1281
|
+
if (verified.active !== true) {
|
|
1282
|
+
throw new Error('activation did not leave the Schedule active');
|
|
1283
|
+
}
|
|
1284
|
+
results.push({
|
|
1285
|
+
stableKey: candidate.stableKey,
|
|
1286
|
+
action: 'activate',
|
|
1287
|
+
status: 'deployed',
|
|
1288
|
+
targetPath: candidate.targetPath,
|
|
1289
|
+
});
|
|
1290
|
+
}
|
|
1291
|
+
catch (error) {
|
|
1292
|
+
results.push({
|
|
1293
|
+
stableKey: candidate.stableKey,
|
|
1294
|
+
action: 'activate',
|
|
1295
|
+
status: 'failed',
|
|
1296
|
+
targetPath: candidate.targetPath,
|
|
1297
|
+
error: schedulePushError(error),
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
return results;
|
|
1302
|
+
}
|