@dboio/cli 0.4.2 → 0.6.0

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.
@@ -0,0 +1,204 @@
1
+ import { readFile } from 'fs/promises';
2
+ import { join, dirname } from 'path';
3
+ import { log } from './logger.js';
4
+
5
+ /**
6
+ * Load the baseline file (.app.json) from disk.
7
+ *
8
+ * @param {string} cwd - Current working directory
9
+ * @returns {Promise<Object|null>} - Baseline JSON or null if not found
10
+ */
11
+ export async function loadBaseline(cwd = process.cwd()) {
12
+ const baselinePath = join(cwd, '.app.json');
13
+ try {
14
+ const raw = await readFile(baselinePath, 'utf8');
15
+ return JSON.parse(raw);
16
+ } catch (err) {
17
+ if (err.code === 'ENOENT') {
18
+ return null; // Baseline doesn't exist
19
+ }
20
+ log.warn(`Failed to parse .app.json: ${err.message}`);
21
+ return null;
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Save baseline data to .app.json file.
27
+ *
28
+ * @param {Object} data - Baseline JSON data
29
+ * @param {string} cwd - Current working directory
30
+ */
31
+ export async function saveBaseline(data, cwd = process.cwd()) {
32
+ const { writeFile } = await import('fs/promises');
33
+ const baselinePath = join(cwd, '.app.json');
34
+ await writeFile(baselinePath, JSON.stringify(data, null, 2), 'utf8');
35
+ }
36
+
37
+ /**
38
+ * Find a baseline entry by UID and entity type.
39
+ * Traverses the baseline's children hierarchy.
40
+ *
41
+ * @param {Object} baseline - The baseline JSON
42
+ * @param {string} entity - Entity type (e.g., "content", "output")
43
+ * @param {string} uid - Record UID
44
+ * @returns {Object|null} - Matching entry or null
45
+ */
46
+ export function findBaselineEntry(baseline, entity, uid) {
47
+ if (!baseline || !baseline.children) {
48
+ return null;
49
+ }
50
+
51
+ const entityArray = baseline.children[entity];
52
+ if (!Array.isArray(entityArray)) {
53
+ return null;
54
+ }
55
+
56
+ return entityArray.find(item => item.UID === uid) || null;
57
+ }
58
+
59
+ /**
60
+ * Compare a file's content on disk against a baseline value.
61
+ *
62
+ * @param {string} filePath - Absolute path to file
63
+ * @param {string|null} baselineValue - Baseline value to compare against
64
+ * @returns {Promise<boolean>} - True if different, false if same
65
+ */
66
+ export async function compareFileContent(filePath, baselineValue) {
67
+ try {
68
+ const currentContent = await readFile(filePath, 'utf8');
69
+
70
+ // Normalize line endings for comparison
71
+ const normalizedCurrent = currentContent.replace(/\r\n/g, '\n').trim();
72
+ const normalizedBaseline = (baselineValue || '').replace(/\r\n/g, '\n').trim();
73
+
74
+ return normalizedCurrent !== normalizedBaseline;
75
+ } catch (err) {
76
+ // If file doesn't exist or can't be read, consider it changed
77
+ log.warn(`Failed to read ${filePath}: ${err.message}`);
78
+ return true;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Detect which columns have changed by comparing current metadata against baseline.
84
+ *
85
+ * @param {string} metaPath - Path to metadata.json file
86
+ * @param {Object} baseline - The baseline JSON
87
+ * @param {Object} config - CLI config (for resolving file paths)
88
+ * @returns {Promise<string[]>} - Array of changed column names
89
+ */
90
+ export async function detectChangedColumns(metaPath, baseline, config) {
91
+ // Load current metadata
92
+ const metaRaw = await readFile(metaPath, 'utf8');
93
+ const metadata = JSON.parse(metaRaw);
94
+
95
+ const { UID, _entity } = metadata;
96
+
97
+ // Find matching baseline entry
98
+ const baselineEntry = findBaselineEntry(baseline, _entity, UID);
99
+
100
+ // If no baseline exists for this record, all non-system columns are "changed"
101
+ if (!baselineEntry) {
102
+ return getAllUserColumns(metadata);
103
+ }
104
+
105
+ const changedColumns = [];
106
+ const metaDir = dirname(metaPath);
107
+
108
+ // Compare each column
109
+ for (const [columnName, columnValue] of Object.entries(metadata)) {
110
+ // Skip system columns and special fields
111
+ if (shouldSkipColumn(columnName)) {
112
+ continue;
113
+ }
114
+
115
+ // Check if value is a @reference
116
+ if (isReference(columnValue)) {
117
+ const refPath = resolveReferencePath(columnValue, metaDir);
118
+ const baselineValue = baselineEntry[columnName];
119
+
120
+ // Compare file content
121
+ const isDifferent = await compareFileContent(refPath, baselineValue);
122
+ if (isDifferent) {
123
+ changedColumns.push(columnName);
124
+ }
125
+ } else {
126
+ // Scalar value comparison
127
+ const currentValue = normalizeValue(columnValue);
128
+ const baselineValue = normalizeValue(baselineEntry[columnName]);
129
+
130
+ if (currentValue !== baselineValue) {
131
+ changedColumns.push(columnName);
132
+ }
133
+ }
134
+ }
135
+
136
+ return changedColumns;
137
+ }
138
+
139
+ /**
140
+ * Get all user-defined columns (non-system columns).
141
+ *
142
+ * @param {Object} metadata - Metadata object
143
+ * @returns {string[]} - Array of user column names
144
+ */
145
+ function getAllUserColumns(metadata) {
146
+ return Object.keys(metadata).filter(col => !shouldSkipColumn(col));
147
+ }
148
+
149
+ /**
150
+ * Determine if a column should be skipped (system columns).
151
+ *
152
+ * @param {string} columnName - Column name
153
+ * @returns {boolean} - True if should skip
154
+ */
155
+ function shouldSkipColumn(columnName) {
156
+ // Skip system columns starting with underscore, UID, and children
157
+ return columnName.startsWith('_') ||
158
+ columnName === 'UID' ||
159
+ columnName === 'children';
160
+ }
161
+
162
+ /**
163
+ * Check if a value is a @reference object.
164
+ *
165
+ * @param {*} value - Value to check
166
+ * @returns {boolean} - True if reference
167
+ */
168
+ function isReference(value) {
169
+ return value &&
170
+ typeof value === 'object' &&
171
+ !Array.isArray(value) &&
172
+ value['@reference'] !== undefined;
173
+ }
174
+
175
+ /**
176
+ * Resolve a @reference path to absolute file path.
177
+ *
178
+ * @param {Object} reference - Reference object with @reference property
179
+ * @param {string} baseDir - Base directory containing metadata
180
+ * @returns {string} - Absolute file path
181
+ */
182
+ function resolveReferencePath(reference, baseDir) {
183
+ const refPath = reference['@reference'];
184
+ return join(baseDir, refPath);
185
+ }
186
+
187
+ /**
188
+ * Normalize a value for comparison (handle null, undefined, etc.).
189
+ *
190
+ * @param {*} value - Value to normalize
191
+ * @returns {string} - Normalized string value
192
+ */
193
+ function normalizeValue(value) {
194
+ if (value === null || value === undefined) {
195
+ return '';
196
+ }
197
+
198
+ // If it's still a @reference object in baseline, compare as string
199
+ if (isReference(value)) {
200
+ return JSON.stringify(value);
201
+ }
202
+
203
+ return String(value).trim();
204
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Dependency management for entity synchronization.
3
+ * Ensures children are processed before parents to maintain referential integrity.
4
+ */
5
+
6
+ /**
7
+ * Entity dependency hierarchy.
8
+ * Lower levels must be processed before higher levels.
9
+ * This ensures foreign key relationships are maintained (children before parents).
10
+ */
11
+ export const ENTITY_DEPENDENCIES = {
12
+ // Level 1: Most dependent (children)
13
+ 'output_value_filter': 1,
14
+ 'output_value_entity_column_rel': 2,
15
+
16
+ // Level 2: Mid-level dependencies
17
+ 'output_value': 2,
18
+
19
+ // Level 3: Parent entities
20
+ 'output': 3,
21
+
22
+ // Default level 0 for entities not in this map
23
+ };
24
+
25
+ /**
26
+ * Get the dependency level for an entity type.
27
+ *
28
+ * @param {string} entity - Entity type name
29
+ * @returns {number} - Dependency level (0 = no dependencies, higher = more dependent)
30
+ */
31
+ function getDependencyLevel(entity) {
32
+ return ENTITY_DEPENDENCIES[entity] || 0;
33
+ }
34
+
35
+ /**
36
+ * Build a dependency-ordered structure from synchronize.json data.
37
+ * Returns operations grouped by type and sorted by dependency level.
38
+ *
39
+ * @param {Object} synchronizeData - The synchronize.json contents
40
+ * @returns {Object} - Ordered structure: { delete: [], add: [], edit: [] }
41
+ */
42
+ export function buildDependencyGraph(synchronizeData) {
43
+ if (!synchronizeData) {
44
+ return { delete: [], add: [], edit: [] };
45
+ }
46
+
47
+ const result = {
48
+ delete: sortByDependency(synchronizeData.delete || [], 'delete'),
49
+ add: sortByDependency(synchronizeData.add || [], 'add'),
50
+ edit: sortByDependency(synchronizeData.edit || [], 'edit'),
51
+ };
52
+
53
+ return result;
54
+ }
55
+
56
+ /**
57
+ * Sort entries by dependency level and UID.
58
+ * For delete operations: process parents before children (descending order)
59
+ * For add/edit operations: process children before parents (ascending order)
60
+ *
61
+ * @param {Array} entries - Array of sync entries
62
+ * @param {string} operationType - Operation type: 'delete', 'add', or 'edit'
63
+ * @returns {Array} - Sorted entries
64
+ */
65
+ function sortByDependency(entries, operationType) {
66
+ if (!Array.isArray(entries) || entries.length === 0) {
67
+ return [];
68
+ }
69
+
70
+ // Group by entity type
71
+ const grouped = groupByEntity(entries);
72
+
73
+ // Sort entity types by dependency level
74
+ const entityTypes = Object.keys(grouped);
75
+ entityTypes.sort((a, b) => {
76
+ const levelA = getDependencyLevel(a);
77
+ const levelB = getDependencyLevel(b);
78
+
79
+ // Delete operations: parents before children (descending)
80
+ // Add/Edit operations: children before parents (ascending)
81
+ if (operationType === 'delete') {
82
+ return levelB - levelA; // Higher level first
83
+ } else {
84
+ return levelA - levelB; // Lower level first
85
+ }
86
+ });
87
+
88
+ // Flatten back to array, maintaining UID sort within each entity type
89
+ const sorted = [];
90
+ for (const entity of entityTypes) {
91
+ const entityEntries = grouped[entity];
92
+ // Sort by UID for consistent ordering
93
+ entityEntries.sort((a, b) => (a.UID || '').localeCompare(b.UID || ''));
94
+ sorted.push(...entityEntries);
95
+ }
96
+
97
+ return sorted;
98
+ }
99
+
100
+ /**
101
+ * Group entries by entity type.
102
+ *
103
+ * @param {Array} entries - Array of sync entries
104
+ * @returns {Object} - Entries grouped by entity: { entityType: [...entries] }
105
+ */
106
+ function groupByEntity(entries) {
107
+ const groups = {};
108
+
109
+ for (const entry of entries) {
110
+ const entity = entry.entity || entry._entity || 'unknown';
111
+ if (!groups[entity]) {
112
+ groups[entity] = [];
113
+ }
114
+ groups[entity].push(entry);
115
+ }
116
+
117
+ return groups;
118
+ }
119
+
120
+ /**
121
+ * Sort entries by UID for consistent ordering within an entity type.
122
+ *
123
+ * @param {Array} entries - Array of entries
124
+ * @returns {Array} - Sorted entries
125
+ */
126
+ export function sortEntriesByUid(entries) {
127
+ if (!Array.isArray(entries)) {
128
+ return [];
129
+ }
130
+ return entries.slice().sort((a, b) => (a.UID || '').localeCompare(b.UID || ''));
131
+ }