@tenonhq/dovetail-core 0.0.104 → 0.0.106
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/dist/commander.js +34 -0
- package/dist/reconcile/applyPlan.js +60 -0
- package/dist/reconcile/baseline.js +126 -0
- package/dist/reconcile/fields.js +28 -0
- package/dist/reconcile/gitignore.js +68 -0
- package/dist/reconcile/multiPass.js +55 -0
- package/dist/reconcile/recordDiff.js +103 -0
- package/dist/reconcile/recordSource.js +231 -0
- package/dist/reconcile/report.js +193 -0
- package/dist/reconcile/types.js +6 -0
- package/dist/reconcileCommand.js +571 -0
- package/dist/tests/reconcileApplyPlan.test.js +98 -0
- package/dist/tests/reconcileBaseline.test.js +103 -0
- package/dist/tests/reconcileFields.test.js +24 -0
- package/dist/tests/reconcileGitignore.test.js +51 -0
- package/dist/tests/reconcileMultiPass.test.js +74 -0
- package/dist/tests/reconcileRecordDiff.test.js +125 -0
- package/dist/tests/reconcileReport.test.js +136 -0
- package/package.json +1 -1
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// `dove reconcile` — make a developer's personal ServiceNow instance match the
|
|
3
|
+
// git branch they just checked out: the deliberate, diff-gated inverse of
|
|
4
|
+
// `dove watch`.
|
|
5
|
+
//
|
|
6
|
+
// Modes:
|
|
7
|
+
// (default) read-only dry run — snapshot branch (on-disk) + live
|
|
8
|
+
// instance, classify create/update/delete, surface drift,
|
|
9
|
+
// report schema drift (report-only), print the report.
|
|
10
|
+
// --write-baseline establish the per-instance merge-base from current live
|
|
11
|
+
// state (no record changes). Run this once before --apply.
|
|
12
|
+
// --apply [--force] apply records branch -> instance: CREATE net-new (with
|
|
13
|
+
// the branch's own sys_id, which the server honors),
|
|
14
|
+
// UPDATE changed records, DELETE tracked instance-only
|
|
15
|
+
// records. Refuses if the instance drifted since baseline
|
|
16
|
+
// (unless --force). Schema stays report-only (a SN ceiling).
|
|
17
|
+
// Updates for a scope with no configured update set are
|
|
18
|
+
// skipped, not pushed — a raw PATCH would re-parent scope.
|
|
19
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
20
|
+
if (k2 === undefined) k2 = k;
|
|
21
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
22
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
23
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
24
|
+
}
|
|
25
|
+
Object.defineProperty(o, k2, desc);
|
|
26
|
+
}) : (function(o, m, k, k2) {
|
|
27
|
+
if (k2 === undefined) k2 = k;
|
|
28
|
+
o[k2] = m[k];
|
|
29
|
+
}));
|
|
30
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
31
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
32
|
+
}) : function(o, v) {
|
|
33
|
+
o["default"] = v;
|
|
34
|
+
});
|
|
35
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
36
|
+
var ownKeys = function(o) {
|
|
37
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
38
|
+
var ar = [];
|
|
39
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
40
|
+
return ar;
|
|
41
|
+
};
|
|
42
|
+
return ownKeys(o);
|
|
43
|
+
};
|
|
44
|
+
return function (mod) {
|
|
45
|
+
if (mod && mod.__esModule) return mod;
|
|
46
|
+
var result = {};
|
|
47
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
48
|
+
__setModuleDefault(result, mod);
|
|
49
|
+
return result;
|
|
50
|
+
};
|
|
51
|
+
})();
|
|
52
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
53
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
54
|
+
};
|
|
55
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
56
|
+
exports.reconcileCommand = reconcileCommand;
|
|
57
|
+
const path_1 = __importDefault(require("path"));
|
|
58
|
+
const os_1 = __importDefault(require("os"));
|
|
59
|
+
const fs_1 = __importStar(require("fs"));
|
|
60
|
+
const child_process_1 = require("child_process");
|
|
61
|
+
const util_1 = require("util");
|
|
62
|
+
const dovetail_schema_1 = require("@tenonhq/dovetail-schema");
|
|
63
|
+
const Logger_1 = require("./Logger");
|
|
64
|
+
const FileLogger_1 = require("./FileLogger");
|
|
65
|
+
const commands_1 = require("./commands");
|
|
66
|
+
const ConfigManager = __importStar(require("./config"));
|
|
67
|
+
const snClient_1 = require("./snClient");
|
|
68
|
+
const appUtils_1 = require("./appUtils");
|
|
69
|
+
const projectFiles_1 = require("./projectFiles");
|
|
70
|
+
const recordSource_1 = require("./reconcile/recordSource");
|
|
71
|
+
const recordDiff_1 = require("./reconcile/recordDiff");
|
|
72
|
+
const fields_1 = require("./reconcile/fields");
|
|
73
|
+
const baseline_1 = require("./reconcile/baseline");
|
|
74
|
+
const report_1 = require("./reconcile/report");
|
|
75
|
+
const applyPlan_1 = require("./reconcile/applyPlan");
|
|
76
|
+
const multiPass_1 = require("./reconcile/multiPass");
|
|
77
|
+
const gitignore_1 = require("./reconcile/gitignore");
|
|
78
|
+
// Per-scope update-set routing, read from the same .dove-update-sets.json the
|
|
79
|
+
// push pipeline uses. Missing/parse errors degrade to {} (treated as "no update
|
|
80
|
+
// set configured"), never throw.
|
|
81
|
+
function getUpdateSetConfig() {
|
|
82
|
+
try {
|
|
83
|
+
const configPath = (0, projectFiles_1.getUpdateSetsConfigPath)();
|
|
84
|
+
if (fs_1.default.existsSync(configPath)) {
|
|
85
|
+
const parsed = JSON.parse(fs_1.default.readFileSync(configPath, "utf8"));
|
|
86
|
+
if (parsed && typeof parsed === "object") {
|
|
87
|
+
return parsed;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
FileLogger_1.fileLogger.debug("reconcile: could not read update-set config");
|
|
93
|
+
}
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
97
|
+
function requireCreds() {
|
|
98
|
+
const { SN_USER = "", SN_PASSWORD = "", SN_INSTANCE = "" } = process.env;
|
|
99
|
+
if (!SN_USER || !SN_PASSWORD || !SN_INSTANCE) {
|
|
100
|
+
throw new Error("Missing ServiceNow credentials. Ensure SN_INSTANCE, SN_USER, and SN_PASSWORD are set in your .env file or environment.");
|
|
101
|
+
}
|
|
102
|
+
return { SN_USER, SN_PASSWORD, SN_INSTANCE };
|
|
103
|
+
}
|
|
104
|
+
function normalizeInstance(instance) {
|
|
105
|
+
return instance.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
106
|
+
}
|
|
107
|
+
function resolveScopes(args) {
|
|
108
|
+
const config = ConfigManager.getConfig();
|
|
109
|
+
const configScopes = config.scopes ? Object.keys(config.scopes) : [];
|
|
110
|
+
if (configScopes.length === 0) {
|
|
111
|
+
throw new Error("No scopes configured in dove.config.js. Add scopes to the 'scopes' object in your configuration.");
|
|
112
|
+
}
|
|
113
|
+
if (args.scope) {
|
|
114
|
+
if (configScopes.indexOf(args.scope) === -1) {
|
|
115
|
+
throw new Error('Scope "' +
|
|
116
|
+
args.scope +
|
|
117
|
+
'" is not configured in dove.config.js. Available scopes: ' +
|
|
118
|
+
configScopes.join(", "));
|
|
119
|
+
}
|
|
120
|
+
return [args.scope];
|
|
121
|
+
}
|
|
122
|
+
return configScopes;
|
|
123
|
+
}
|
|
124
|
+
async function currentBranch() {
|
|
125
|
+
try {
|
|
126
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
127
|
+
return stdout.trim();
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
return "";
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Best-effort schema arm: only runs when a snapshot exists for this instance.
|
|
134
|
+
async function buildSchemaDiffs(options) {
|
|
135
|
+
const { creds, instance, scopes } = options;
|
|
136
|
+
const rootDir = ConfigManager.getRootDir();
|
|
137
|
+
const outputDir = path_1.default.join(rootDir, "schema");
|
|
138
|
+
let snapshots = [];
|
|
139
|
+
try {
|
|
140
|
+
snapshots = await (0, dovetail_schema_1.listSnapshots)({ outputDir, instance });
|
|
141
|
+
}
|
|
142
|
+
catch (e) {
|
|
143
|
+
return { diffs: {}, skipped: "schema snapshot lookup failed" };
|
|
144
|
+
}
|
|
145
|
+
if (snapshots.length === 0) {
|
|
146
|
+
return {
|
|
147
|
+
diffs: {},
|
|
148
|
+
skipped: "no schema snapshot for " +
|
|
149
|
+
instance +
|
|
150
|
+
" — run `dove schema pull --snapshot <label>` to include schema drift",
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
const fromRef = path_1.default.basename(snapshots[0].dir);
|
|
154
|
+
let liveDir;
|
|
155
|
+
try {
|
|
156
|
+
const tmpDir = await fs_1.promises.mkdtemp(path_1.default.join(os_1.default.tmpdir(), "dove-reconcile-schema-"));
|
|
157
|
+
await (0, dovetail_schema_1.pullSchema)({
|
|
158
|
+
instance: creds.SN_INSTANCE,
|
|
159
|
+
username: creds.SN_USER,
|
|
160
|
+
password: creds.SN_PASSWORD,
|
|
161
|
+
outputDir: tmpDir,
|
|
162
|
+
scopes,
|
|
163
|
+
});
|
|
164
|
+
liveDir = tmpDir;
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
return { diffs: {}, skipped: "live schema pull failed" };
|
|
168
|
+
}
|
|
169
|
+
const fromDir = await (0, dovetail_schema_1.resolveSnapshotDir)({ ref: fromRef, outputDir, instance });
|
|
170
|
+
const diffs = {};
|
|
171
|
+
for (const scope of scopes) {
|
|
172
|
+
try {
|
|
173
|
+
const from = await (0, dovetail_schema_1.readSchemaTree)({ dir: fromDir, scope });
|
|
174
|
+
const to = await (0, dovetail_schema_1.readSchemaTree)({ dir: liveDir, scope });
|
|
175
|
+
diffs[scope] = (0, dovetail_schema_1.diffSchemas)({ from, to, fromRef, toRef: "live", scope });
|
|
176
|
+
}
|
|
177
|
+
catch (e) {
|
|
178
|
+
FileLogger_1.fileLogger.debug("reconcile: schema diff failed for scope " + scope);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { diffs, skipped: null };
|
|
182
|
+
}
|
|
183
|
+
// Collect the on-disk field file paths for the records an apply will UPDATE, so
|
|
184
|
+
// the existing build + update-set push pipeline can overwrite the instance with
|
|
185
|
+
// branch content.
|
|
186
|
+
async function collectUpdateFilePaths(scope, updates) {
|
|
187
|
+
const sourcePath = ConfigManager.getSourcePathForScope(scope);
|
|
188
|
+
const paths = [];
|
|
189
|
+
for (const change of updates) {
|
|
190
|
+
const recordDir = path_1.default.join(sourcePath, change.table, change.name);
|
|
191
|
+
let entries;
|
|
192
|
+
try {
|
|
193
|
+
entries = await fs_1.promises.readdir(recordDir);
|
|
194
|
+
}
|
|
195
|
+
catch (e) {
|
|
196
|
+
FileLogger_1.fileLogger.debug("reconcile: update record dir missing " + recordDir);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
for (const entry of entries) {
|
|
200
|
+
if (entry === "metaData.json") {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const full = path_1.default.join(recordDir, entry);
|
|
204
|
+
try {
|
|
205
|
+
const stat = await fs_1.promises.stat(full);
|
|
206
|
+
if (stat.isFile()) {
|
|
207
|
+
paths.push(full);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
catch (e) {
|
|
211
|
+
// ignore unreadable entry
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return paths;
|
|
216
|
+
}
|
|
217
|
+
async function applyUpdates(scope, updates) {
|
|
218
|
+
if (updates.length === 0) {
|
|
219
|
+
return { pushed: 0, failures: [] };
|
|
220
|
+
}
|
|
221
|
+
const paths = await collectUpdateFilePaths(scope, updates);
|
|
222
|
+
if (paths.length === 0) {
|
|
223
|
+
return { pushed: 0, failures: [] };
|
|
224
|
+
}
|
|
225
|
+
const recs = await (0, appUtils_1.getAppFileList)(paths);
|
|
226
|
+
const results = await (0, appUtils_1.pushFiles)(recs);
|
|
227
|
+
const failures = [];
|
|
228
|
+
let pushed = 0;
|
|
229
|
+
for (const result of results) {
|
|
230
|
+
if (result.success) {
|
|
231
|
+
pushed++;
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
failures.push(result.message);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return { pushed, failures };
|
|
238
|
+
}
|
|
239
|
+
async function applyDeletes(scope, deletes) {
|
|
240
|
+
if (deletes.length === 0) {
|
|
241
|
+
return [];
|
|
242
|
+
}
|
|
243
|
+
const client = (0, snClient_1.defaultClient)();
|
|
244
|
+
return (0, multiPass_1.runMultiPassOps)(deletes, async (change) => {
|
|
245
|
+
try {
|
|
246
|
+
const response = await client.deleteRecord({
|
|
247
|
+
table: change.table,
|
|
248
|
+
sys_id: change.sys_id,
|
|
249
|
+
scope,
|
|
250
|
+
});
|
|
251
|
+
let data = response.data;
|
|
252
|
+
if (data && data.result) {
|
|
253
|
+
data = data.result;
|
|
254
|
+
}
|
|
255
|
+
if (data && (data.error || data.success === false)) {
|
|
256
|
+
return { ok: false, error: data.error || "delete rejected" };
|
|
257
|
+
}
|
|
258
|
+
return { ok: true };
|
|
259
|
+
}
|
|
260
|
+
catch (e) {
|
|
261
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
// Reconstruct the full field set for a CREATE from the record's on-disk
|
|
266
|
+
// representation: the metaData.json snapshot supplies every captured field
|
|
267
|
+
// value, then the canonical field files (script.js, etc.) override their own
|
|
268
|
+
// fields. Volatile bookkeeping and identity columns are excluded — sys_id is
|
|
269
|
+
// passed separately (and honored by the server), sys_scope comes from `scope`.
|
|
270
|
+
async function buildCreateFields(scope, change) {
|
|
271
|
+
const sourcePath = ConfigManager.getSourcePathForScope(scope);
|
|
272
|
+
const recordDir = path_1.default.join(sourcePath, change.table, change.name);
|
|
273
|
+
const fields = {};
|
|
274
|
+
// 1. metaData snapshot — short/non-file fields (active, order, name, ...).
|
|
275
|
+
try {
|
|
276
|
+
const metaRaw = await fs_1.promises.readFile(path_1.default.join(recordDir, "metaData.json"), "utf8");
|
|
277
|
+
const meta = JSON.parse(metaRaw);
|
|
278
|
+
for (const key of Object.keys(meta)) {
|
|
279
|
+
if (key.charAt(0) === "_" || key === "sys_id" || key === "sys_scope") {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (key.indexOf("sys_") === 0) {
|
|
283
|
+
// sys_created_on/by, sys_updated_on/by, sys_mod_count, sys_class_name, ...
|
|
284
|
+
// Skipped: server-managed. (sys_class_name on a base table defaults fine.)
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const value = meta[key];
|
|
288
|
+
if (typeof value === "string") {
|
|
289
|
+
fields[key] = value;
|
|
290
|
+
}
|
|
291
|
+
else if (typeof value === "object" &&
|
|
292
|
+
value !== null &&
|
|
293
|
+
typeof value.value === "string") {
|
|
294
|
+
fields[key] = value.value;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
catch (e) {
|
|
299
|
+
FileLogger_1.fileLogger.debug("reconcile: no metaData for create " + recordDir);
|
|
300
|
+
}
|
|
301
|
+
// 2. Canonical field files override (the authoritative large-field content).
|
|
302
|
+
let entries = [];
|
|
303
|
+
try {
|
|
304
|
+
entries = await fs_1.promises.readdir(recordDir);
|
|
305
|
+
}
|
|
306
|
+
catch (e) {
|
|
307
|
+
FileLogger_1.fileLogger.debug("reconcile: create record dir missing " + recordDir);
|
|
308
|
+
}
|
|
309
|
+
for (const entry of entries) {
|
|
310
|
+
if (entry === "metaData.json") {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const dot = entry.lastIndexOf(".");
|
|
314
|
+
if (dot <= 0) {
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const name = entry.slice(0, dot);
|
|
318
|
+
const type = entry.slice(dot + 1);
|
|
319
|
+
if (!(0, fields_1.isComparableField)({ name, type })) {
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
const stat = await fs_1.promises.stat(path_1.default.join(recordDir, entry));
|
|
324
|
+
if (stat.isFile()) {
|
|
325
|
+
fields[name] = await fs_1.promises.readFile(path_1.default.join(recordDir, entry), "utf8");
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
catch (e) {
|
|
329
|
+
// ignore unreadable entry
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// Ensure a name is present (some tables require it).
|
|
333
|
+
if (!fields.name && change.name) {
|
|
334
|
+
fields.name = change.name;
|
|
335
|
+
}
|
|
336
|
+
return fields;
|
|
337
|
+
}
|
|
338
|
+
async function applyCreates(scope, creates, updateSetSysId) {
|
|
339
|
+
if (creates.length === 0) {
|
|
340
|
+
return [];
|
|
341
|
+
}
|
|
342
|
+
const client = (0, snClient_1.defaultClient)();
|
|
343
|
+
return (0, multiPass_1.runMultiPassOps)(creates, async (change) => {
|
|
344
|
+
try {
|
|
345
|
+
const fields = await buildCreateFields(scope, change);
|
|
346
|
+
const response = await client.createRecord({
|
|
347
|
+
table: change.table,
|
|
348
|
+
fields,
|
|
349
|
+
sys_id: change.sys_id,
|
|
350
|
+
scope,
|
|
351
|
+
update_set_sys_id: updateSetSysId,
|
|
352
|
+
});
|
|
353
|
+
let data = response.data;
|
|
354
|
+
if (data && data.result) {
|
|
355
|
+
data = data.result;
|
|
356
|
+
}
|
|
357
|
+
if (data && data.error) {
|
|
358
|
+
return { ok: false, error: data.error };
|
|
359
|
+
}
|
|
360
|
+
if (data && data.sys_id && data.sys_id !== change.sys_id) {
|
|
361
|
+
// The server reassigned the sys_id — identity would diverge and the
|
|
362
|
+
// diff would never converge. Surface it loudly rather than silently
|
|
363
|
+
// creating a phantom.
|
|
364
|
+
return {
|
|
365
|
+
ok: false,
|
|
366
|
+
error: "server reassigned sys_id (" + data.sys_id + " != " + change.sys_id + ")",
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
return { ok: true };
|
|
370
|
+
}
|
|
371
|
+
catch (e) {
|
|
372
|
+
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
// Capture current live state across every scope into a fresh baseline + ensure
|
|
377
|
+
// the baseline file is gitignored in the consumer project.
|
|
378
|
+
async function recaptureBaseline(options) {
|
|
379
|
+
const all = [];
|
|
380
|
+
for (const scope of options.scopes) {
|
|
381
|
+
try {
|
|
382
|
+
const live = await (0, recordSource_1.loadLiveRecords)(scope);
|
|
383
|
+
for (const record of live) {
|
|
384
|
+
all.push(record);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
catch (e) {
|
|
388
|
+
Logger_1.logger.warn("Baseline capture failed for scope " + scope + " — skipped.");
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
(0, baseline_1.writeBaseline)(options.rootDir, (0, baseline_1.baselineFromLive)(options.instance, all));
|
|
392
|
+
const update = (0, gitignore_1.ensureGitignored)(options.rootDir, baseline_1.BASELINE_FILENAME);
|
|
393
|
+
if (update.changed) {
|
|
394
|
+
Logger_1.logger.info("Added " + baseline_1.BASELINE_FILENAME + " to .gitignore.");
|
|
395
|
+
}
|
|
396
|
+
return all.length;
|
|
397
|
+
}
|
|
398
|
+
async function writeBaselineMode(options) {
|
|
399
|
+
Logger_1.logger.info("Establishing reconcile baseline for " + options.instance + "...");
|
|
400
|
+
const count = await recaptureBaseline(options);
|
|
401
|
+
Logger_1.logger.success("Baseline established: " + count + " record(s) captured to " + baseline_1.BASELINE_FILENAME + ".");
|
|
402
|
+
}
|
|
403
|
+
async function reconcileCommand(args) {
|
|
404
|
+
(0, commands_1.setLogLevel)(args);
|
|
405
|
+
const typedArgs = args;
|
|
406
|
+
let creds;
|
|
407
|
+
try {
|
|
408
|
+
creds = requireCreds();
|
|
409
|
+
}
|
|
410
|
+
catch (e) {
|
|
411
|
+
Logger_1.logger.error(e instanceof Error ? e.message : String(e));
|
|
412
|
+
process.exitCode = 1;
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
const instance = normalizeInstance(creds.SN_INSTANCE);
|
|
416
|
+
const scopes = resolveScopes(typedArgs);
|
|
417
|
+
const rootDir = ConfigManager.getRootDir();
|
|
418
|
+
// --write-baseline: establish the merge-base and stop.
|
|
419
|
+
if (typedArgs.writeBaseline) {
|
|
420
|
+
await writeBaselineMode({ rootDir, instance, scopes });
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const branchRef = await currentBranch();
|
|
424
|
+
const baseline = (0, baseline_1.readBaseline)(rootDir, instance);
|
|
425
|
+
const baselineIds = (0, baseline_1.baselineSysIds)(baseline);
|
|
426
|
+
// Schema arm is on by default; --no-schema disables it.
|
|
427
|
+
let schemaDiffs = {};
|
|
428
|
+
let schemaSkippedReason = null;
|
|
429
|
+
if (typedArgs.schema === false) {
|
|
430
|
+
schemaSkippedReason = "schema diff disabled (--no-schema)";
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
const schemaResult = await buildSchemaDiffs({ creds, instance, scopes });
|
|
434
|
+
schemaDiffs = schemaResult.diffs;
|
|
435
|
+
schemaSkippedReason = schemaResult.skipped;
|
|
436
|
+
}
|
|
437
|
+
const scopeData = [];
|
|
438
|
+
const scopeResults = [];
|
|
439
|
+
for (const scope of scopes) {
|
|
440
|
+
Logger_1.logger.info("Reconciling scope: " + scope + "...");
|
|
441
|
+
let branch;
|
|
442
|
+
let live;
|
|
443
|
+
try {
|
|
444
|
+
branch = await (0, recordSource_1.loadBranchRecords)(scope);
|
|
445
|
+
live = await (0, recordSource_1.loadLiveRecords)(scope);
|
|
446
|
+
}
|
|
447
|
+
catch (e) {
|
|
448
|
+
Logger_1.logger.error("Failed to load records for scope " +
|
|
449
|
+
scope +
|
|
450
|
+
": " +
|
|
451
|
+
(e instanceof Error ? e.message : String(e)));
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
const diff = (0, recordDiff_1.diffRecords)({ branch, live, baselineSysIds: baselineIds });
|
|
455
|
+
const dirty = (0, baseline_1.computeDirty)({ baseline, live });
|
|
456
|
+
scopeData.push({ scope, live, diff, dirty });
|
|
457
|
+
scopeResults.push({
|
|
458
|
+
scope,
|
|
459
|
+
diff,
|
|
460
|
+
dirty,
|
|
461
|
+
hasBaseline: baseline !== null,
|
|
462
|
+
schema: schemaDiffs[scope] || null,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
const report = (0, report_1.formatReconcileReport)({
|
|
466
|
+
instanceHost: instance,
|
|
467
|
+
branchRef,
|
|
468
|
+
scopes: scopeResults,
|
|
469
|
+
schemaSkippedReason,
|
|
470
|
+
});
|
|
471
|
+
// console.log so the report's own layout survives the winston line-wrapper.
|
|
472
|
+
console.log(report);
|
|
473
|
+
if (!typedArgs.apply) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
await applyMode({ typedArgs, scopeData, baseline: baseline !== null, rootDir, instance, scopes });
|
|
477
|
+
}
|
|
478
|
+
async function applyMode(options) {
|
|
479
|
+
const { typedArgs, scopeData } = options;
|
|
480
|
+
const force = typedArgs.force === true;
|
|
481
|
+
const plans = scopeData.map((data) => ({
|
|
482
|
+
scope: data.scope,
|
|
483
|
+
plan: (0, applyPlan_1.buildApplyPlan)({
|
|
484
|
+
diff: data.diff,
|
|
485
|
+
dirty: data.dirty,
|
|
486
|
+
hasBaseline: options.baseline,
|
|
487
|
+
force,
|
|
488
|
+
}),
|
|
489
|
+
}));
|
|
490
|
+
const refused = plans.filter((entry) => entry.plan.refuse);
|
|
491
|
+
if (refused.length > 0) {
|
|
492
|
+
Logger_1.logger.error("Apply refused:");
|
|
493
|
+
for (const entry of refused) {
|
|
494
|
+
Logger_1.logger.error(" [" + entry.scope + "] " + entry.plan.refuseReason);
|
|
495
|
+
}
|
|
496
|
+
process.exitCode = 1;
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
Logger_1.logger.info("");
|
|
500
|
+
Logger_1.logger.info("Applying reconcile (branch -> instance)...");
|
|
501
|
+
const updateSetConfig = getUpdateSetConfig();
|
|
502
|
+
let totalCreated = 0;
|
|
503
|
+
let totalUpdated = 0;
|
|
504
|
+
let totalDeleted = 0;
|
|
505
|
+
const failures = [];
|
|
506
|
+
for (const entry of plans) {
|
|
507
|
+
const { scope, plan } = entry;
|
|
508
|
+
const updateSet = updateSetConfig[scope];
|
|
509
|
+
const updateSetSysId = updateSet ? updateSet.sys_id : undefined;
|
|
510
|
+
// CREATE — scope-correct via the `scope` param; routed into the update set
|
|
511
|
+
// when one is configured.
|
|
512
|
+
const createOutcomes = await applyCreates(scope, plan.creates, updateSetSysId);
|
|
513
|
+
for (const outcome of createOutcomes) {
|
|
514
|
+
if (outcome.ok) {
|
|
515
|
+
totalCreated++;
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
failures.push("[" + scope + "] create " + outcome.change.table + "/" + outcome.change.name + ": " + (outcome.error || "failed"));
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
// UPDATE — guard against the re-parenting trap: pushFiles falls back to a
|
|
522
|
+
// raw PATCH (which adopts session scope) when no update set is configured.
|
|
523
|
+
// Refuse to push updates for such a scope rather than silently re-parent.
|
|
524
|
+
if (plan.updates.length > 0 && !updateSetSysId) {
|
|
525
|
+
failures.push("[" +
|
|
526
|
+
scope +
|
|
527
|
+
"] " +
|
|
528
|
+
plan.updates.length +
|
|
529
|
+
" update(s) skipped — no update set configured for this scope. " +
|
|
530
|
+
"Set one (e.g. `dove switchUpdateSet`) before applying updates, to avoid re-parenting scope.");
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
const updateResult = await applyUpdates(scope, plan.updates);
|
|
534
|
+
totalUpdated += updateResult.pushed;
|
|
535
|
+
for (const failure of updateResult.failures) {
|
|
536
|
+
failures.push("[" + scope + "] update: " + failure);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
// DELETE — tracked instance-only records.
|
|
540
|
+
const deleteOutcomes = await applyDeletes(scope, plan.deletes);
|
|
541
|
+
for (const outcome of deleteOutcomes) {
|
|
542
|
+
if (outcome.ok) {
|
|
543
|
+
totalDeleted++;
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
failures.push("[" + scope + "] delete " + outcome.change.table + "/" + outcome.change.name + ": " + (outcome.error || "failed"));
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
// Re-capture the baseline from the now-converged instance.
|
|
551
|
+
await recaptureBaseline({
|
|
552
|
+
rootDir: options.rootDir,
|
|
553
|
+
instance: options.instance,
|
|
554
|
+
scopes: options.scopes,
|
|
555
|
+
});
|
|
556
|
+
Logger_1.logger.info("");
|
|
557
|
+
Logger_1.logger.success("Applied: " +
|
|
558
|
+
totalCreated +
|
|
559
|
+
" created, " +
|
|
560
|
+
totalUpdated +
|
|
561
|
+
" updated, " +
|
|
562
|
+
totalDeleted +
|
|
563
|
+
" deleted.");
|
|
564
|
+
if (failures.length > 0) {
|
|
565
|
+
Logger_1.logger.error(failures.length + " operation(s) failed:");
|
|
566
|
+
for (const failure of failures) {
|
|
567
|
+
Logger_1.logger.error(" " + failure);
|
|
568
|
+
}
|
|
569
|
+
process.exitCode = 1;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Tests for the pure apply-plan decision: refuse-if-dirty, baseline gating,
|
|
3
|
+
// --force semantics, and delete eligibility by disposition.
|
|
4
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
5
|
+
const applyPlan_1 = require("../reconcile/applyPlan");
|
|
6
|
+
const change = (over) => ({
|
|
7
|
+
table: "sys_script",
|
|
8
|
+
scope: "x_cadso_core",
|
|
9
|
+
name: over.sys_id,
|
|
10
|
+
fieldDeltas: [],
|
|
11
|
+
...over,
|
|
12
|
+
});
|
|
13
|
+
const diff = (over) => ({
|
|
14
|
+
creates: [],
|
|
15
|
+
updates: [],
|
|
16
|
+
deletes: [],
|
|
17
|
+
unchangedCount: 0,
|
|
18
|
+
...over,
|
|
19
|
+
});
|
|
20
|
+
const dirty = (sys_id) => ({
|
|
21
|
+
sys_id,
|
|
22
|
+
table: "sys_script",
|
|
23
|
+
name: sys_id,
|
|
24
|
+
reason: "changed-since-baseline",
|
|
25
|
+
});
|
|
26
|
+
describe("buildApplyPlan — refusal gating", () => {
|
|
27
|
+
it("refuses with no baseline", () => {
|
|
28
|
+
const plan = (0, applyPlan_1.buildApplyPlan)({ diff: diff({}), dirty: [], hasBaseline: false, force: false });
|
|
29
|
+
expect(plan.refuse).toBe(true);
|
|
30
|
+
expect(plan.refuseReason).toContain("--write-baseline");
|
|
31
|
+
});
|
|
32
|
+
it("refuses on drift without --force", () => {
|
|
33
|
+
const plan = (0, applyPlan_1.buildApplyPlan)({
|
|
34
|
+
diff: diff({}),
|
|
35
|
+
dirty: [dirty("a")],
|
|
36
|
+
hasBaseline: true,
|
|
37
|
+
force: false,
|
|
38
|
+
});
|
|
39
|
+
expect(plan.refuse).toBe(true);
|
|
40
|
+
expect(plan.refuseReason).toContain("changed on the instance since baseline");
|
|
41
|
+
});
|
|
42
|
+
it("does not refuse on drift with --force", () => {
|
|
43
|
+
const plan = (0, applyPlan_1.buildApplyPlan)({
|
|
44
|
+
diff: diff({}),
|
|
45
|
+
dirty: [dirty("a")],
|
|
46
|
+
hasBaseline: true,
|
|
47
|
+
force: true,
|
|
48
|
+
});
|
|
49
|
+
expect(plan.refuse).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
it("does not refuse when clean", () => {
|
|
52
|
+
const plan = (0, applyPlan_1.buildApplyPlan)({ diff: diff({}), dirty: [], hasBaseline: true, force: false });
|
|
53
|
+
expect(plan.refuse).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe("buildApplyPlan — what applies", () => {
|
|
57
|
+
it("includes updates and creates", () => {
|
|
58
|
+
const plan = (0, applyPlan_1.buildApplyPlan)({
|
|
59
|
+
diff: diff({
|
|
60
|
+
updates: [change({ sys_id: "u", kind: "update" })],
|
|
61
|
+
creates: [change({ sys_id: "c", kind: "create" })],
|
|
62
|
+
}),
|
|
63
|
+
dirty: [],
|
|
64
|
+
hasBaseline: true,
|
|
65
|
+
force: false,
|
|
66
|
+
});
|
|
67
|
+
expect(plan.updates.map((u) => u.sys_id)).toEqual(["u"]);
|
|
68
|
+
expect(plan.creates.map((c) => c.sys_id)).toEqual(["c"]);
|
|
69
|
+
});
|
|
70
|
+
it("applies only tracked deletes; keeps local-new and no-baseline", () => {
|
|
71
|
+
const plan = (0, applyPlan_1.buildApplyPlan)({
|
|
72
|
+
diff: diff({
|
|
73
|
+
deletes: [
|
|
74
|
+
change({ sys_id: "t", kind: "delete", deleteDisposition: "tracked" }),
|
|
75
|
+
change({ sys_id: "l", kind: "delete", deleteDisposition: "local-new" }),
|
|
76
|
+
change({ sys_id: "n", kind: "delete", deleteDisposition: "no-baseline" }),
|
|
77
|
+
],
|
|
78
|
+
}),
|
|
79
|
+
dirty: [],
|
|
80
|
+
hasBaseline: true,
|
|
81
|
+
force: false,
|
|
82
|
+
});
|
|
83
|
+
expect(plan.deletes.map((d) => d.sys_id)).toEqual(["t"]);
|
|
84
|
+
expect(plan.skippedDeletes.map((d) => d.sys_id).sort()).toEqual(["l", "n"]);
|
|
85
|
+
});
|
|
86
|
+
it("--force still never deletes local-new records", () => {
|
|
87
|
+
const plan = (0, applyPlan_1.buildApplyPlan)({
|
|
88
|
+
diff: diff({
|
|
89
|
+
deletes: [change({ sys_id: "mine", kind: "delete", deleteDisposition: "local-new" })],
|
|
90
|
+
}),
|
|
91
|
+
dirty: [dirty("other")],
|
|
92
|
+
hasBaseline: true,
|
|
93
|
+
force: true,
|
|
94
|
+
});
|
|
95
|
+
expect(plan.deletes).toHaveLength(0);
|
|
96
|
+
expect(plan.skippedDeletes.map((d) => d.sys_id)).toEqual(["mine"]);
|
|
97
|
+
});
|
|
98
|
+
});
|