@dboio/cli 0.9.8 → 0.11.1
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 +172 -70
- package/bin/dbo.js +2 -0
- package/bin/postinstall.js +9 -1
- package/package.json +3 -3
- package/plugins/claude/dbo/commands/dbo.md +3 -3
- package/plugins/claude/dbo/skills/cli/SKILL.md +3 -3
- package/src/commands/add.js +50 -0
- package/src/commands/clone.js +720 -552
- package/src/commands/content.js +7 -3
- package/src/commands/deploy.js +22 -7
- package/src/commands/diff.js +41 -3
- package/src/commands/init.js +42 -79
- package/src/commands/input.js +5 -0
- package/src/commands/login.js +2 -2
- package/src/commands/mv.js +3 -0
- package/src/commands/output.js +8 -10
- package/src/commands/pull.js +268 -87
- package/src/commands/push.js +814 -94
- package/src/commands/rm.js +4 -1
- package/src/commands/status.js +12 -1
- package/src/commands/sync.js +71 -0
- package/src/lib/client.js +10 -0
- package/src/lib/config.js +80 -8
- package/src/lib/delta.js +178 -25
- package/src/lib/diff.js +150 -20
- package/src/lib/folder-icon.js +120 -0
- package/src/lib/ignore.js +2 -3
- package/src/lib/input-parser.js +37 -10
- package/src/lib/metadata-templates.js +21 -4
- package/src/lib/migrations.js +75 -0
- package/src/lib/save-to-disk.js +1 -1
- package/src/lib/scaffold.js +58 -3
- package/src/lib/structure.js +158 -21
- package/src/lib/toe-stepping.js +381 -0
- package/src/migrations/001-transaction-key-preset-scope.js +35 -0
- package/src/migrations/002-move-entity-dirs-to-lib.js +190 -0
- package/src/migrations/003-move-deploy-config.js +50 -0
- package/src/migrations/004-rename-output-files.js +101 -0
package/src/lib/structure.js
CHANGED
|
@@ -3,23 +3,55 @@ import { join } from 'path';
|
|
|
3
3
|
|
|
4
4
|
const STRUCTURE_FILE = '.dbo/structure.json';
|
|
5
5
|
|
|
6
|
-
/** All
|
|
7
|
-
export const
|
|
6
|
+
/** All server-managed directories live under this subdirectory */
|
|
7
|
+
export const LIB_DIR = 'lib';
|
|
8
8
|
|
|
9
|
-
/**
|
|
10
|
-
export const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
/** All bin-placed files go under this directory */
|
|
10
|
+
export const BINS_DIR = 'lib/bins';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Directories always created by `dbo init --scaffold` and `dbo clone`
|
|
14
|
+
* regardless of whether records exist for that entity type.
|
|
15
|
+
*/
|
|
16
|
+
export const SCAFFOLD_DIRS = [
|
|
17
|
+
'lib/bins',
|
|
18
|
+
'lib/automation',
|
|
19
|
+
'lib/app_version',
|
|
20
|
+
'lib/entity',
|
|
21
|
+
'lib/entity_column',
|
|
22
|
+
'lib/entity_column_value',
|
|
23
|
+
'lib/extension',
|
|
24
|
+
'lib/integration',
|
|
25
|
+
'lib/security',
|
|
26
|
+
'lib/security_column',
|
|
27
|
+
'src',
|
|
28
|
+
'test',
|
|
29
|
+
'trash',
|
|
14
30
|
'docs',
|
|
15
|
-
|
|
16
|
-
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Entity names whose lib/<name> directories are only created on-demand
|
|
35
|
+
* when records of that entity type actually exist (during clone/pull).
|
|
36
|
+
* These are NOT created by scaffold — only by processEntityDirEntries().
|
|
37
|
+
*/
|
|
38
|
+
export const ON_DEMAND_ENTITY_DIRS = new Set([
|
|
17
39
|
'data_source',
|
|
18
40
|
'group',
|
|
19
|
-
'
|
|
20
|
-
'
|
|
21
|
-
|
|
22
|
-
|
|
41
|
+
'site',
|
|
42
|
+
'redirect',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Union of SCAFFOLD_DIRS and on-demand dirs.
|
|
47
|
+
* Used for membership checks ("is this path a known project directory?").
|
|
48
|
+
*/
|
|
49
|
+
export const DEFAULT_PROJECT_DIRS = [
|
|
50
|
+
...SCAFFOLD_DIRS,
|
|
51
|
+
'lib/data_source',
|
|
52
|
+
'lib/group',
|
|
53
|
+
'lib/redirect',
|
|
54
|
+
'lib/site',
|
|
23
55
|
];
|
|
24
56
|
|
|
25
57
|
/** Map from physical output table names → documentation/display names */
|
|
@@ -40,15 +72,103 @@ export const OUTPUT_HIERARCHY_ENTITIES = [
|
|
|
40
72
|
|
|
41
73
|
/** Entity keys that correspond to project directories (key IS the dir name) */
|
|
42
74
|
export const ENTITY_DIR_NAMES = new Set([
|
|
43
|
-
'
|
|
75
|
+
'automation',
|
|
44
76
|
'app_version',
|
|
45
77
|
'data_source',
|
|
46
|
-
'
|
|
78
|
+
'entity',
|
|
79
|
+
'entity_column',
|
|
80
|
+
'entity_column_value',
|
|
81
|
+
'extension',
|
|
47
82
|
'group',
|
|
48
83
|
'integration',
|
|
49
|
-
'
|
|
84
|
+
'redirect',
|
|
85
|
+
'security',
|
|
86
|
+
'security_column',
|
|
87
|
+
'site',
|
|
50
88
|
]);
|
|
51
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Resolve the local directory path for an entity-dir type.
|
|
92
|
+
* Always returns "lib/<entityName>" (e.g. "lib/extension", "lib/site").
|
|
93
|
+
* Use this instead of bare entity name concatenation everywhere.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} entityName - Entity key from ENTITY_DIR_NAMES (e.g. "extension")
|
|
96
|
+
* @returns {string} - Path relative to project root (e.g. "lib/extension")
|
|
97
|
+
*/
|
|
98
|
+
export function resolveEntityDirPath(entityName) {
|
|
99
|
+
return `${LIB_DIR}/${entityName}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ─── Core Asset Entity Classification ─────────────────────────────────────
|
|
103
|
+
//
|
|
104
|
+
// Core assets are entities that carry a UID, are tracked by index triggers
|
|
105
|
+
// (AFTER INSERT/UPDATE/DELETE → index_insert_*), and participate in the
|
|
106
|
+
// versioning / revision system.
|
|
107
|
+
//
|
|
108
|
+
// "Exportable" assets are part of an app's export package — a builder creates
|
|
109
|
+
// and manages these. "Non-exportable" assets have UIDs and index triggers
|
|
110
|
+
// but are system-managed or instance-specific (not included in app exports).
|
|
111
|
+
//
|
|
112
|
+
// "Data" entities (user, audit, authentication, message, etc.) do NOT carry
|
|
113
|
+
// auto-generated UIDs and are NOT tracked by index triggers.
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Exportable core asset entities (20).
|
|
117
|
+
* Part of app export packages. Builder-managed. All have UID (NOT NULL, UNIQUE)
|
|
118
|
+
* and index triggers.
|
|
119
|
+
*/
|
|
120
|
+
export const EXPORTABLE_ENTITIES = [
|
|
121
|
+
'app',
|
|
122
|
+
'automation',
|
|
123
|
+
'bin',
|
|
124
|
+
'content',
|
|
125
|
+
'data_source',
|
|
126
|
+
'entity',
|
|
127
|
+
'entity_column',
|
|
128
|
+
'entity_column_value',
|
|
129
|
+
'extension',
|
|
130
|
+
'group',
|
|
131
|
+
'integration',
|
|
132
|
+
'media',
|
|
133
|
+
'output',
|
|
134
|
+
'output_value',
|
|
135
|
+
'output_value_entity_column_rel',
|
|
136
|
+
'output_value_filter',
|
|
137
|
+
'redirect',
|
|
138
|
+
'security',
|
|
139
|
+
'security_column',
|
|
140
|
+
'site',
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Non-exportable core asset entities (3).
|
|
145
|
+
* Have UIDs and index triggers but are NOT part of app export packages.
|
|
146
|
+
* System-managed or instance-specific.
|
|
147
|
+
*/
|
|
148
|
+
export const NON_EXPORTABLE_ENTITIES = [
|
|
149
|
+
'app_version',
|
|
150
|
+
'mail_server',
|
|
151
|
+
'media_server',
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
/** All core asset entities (exportable + non-exportable). 23 total. */
|
|
155
|
+
export const CORE_ENTITIES = [...EXPORTABLE_ENTITIES, ...NON_EXPORTABLE_ENTITIES];
|
|
156
|
+
|
|
157
|
+
/** Set variant for O(1) lookups */
|
|
158
|
+
export const CORE_ENTITIES_SET = new Set(CORE_ENTITIES);
|
|
159
|
+
export const EXPORTABLE_ENTITIES_SET = new Set(EXPORTABLE_ENTITIES);
|
|
160
|
+
export const NON_EXPORTABLE_ENTITIES_SET = new Set(NON_EXPORTABLE_ENTITIES);
|
|
161
|
+
|
|
162
|
+
/** Check whether an entity name is a core asset */
|
|
163
|
+
export function isCoreEntity(entityName) {
|
|
164
|
+
return CORE_ENTITIES_SET.has(entityName);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Check whether an entity name is an exportable core asset */
|
|
168
|
+
export function isExportableEntity(entityName) {
|
|
169
|
+
return EXPORTABLE_ENTITIES_SET.has(entityName);
|
|
170
|
+
}
|
|
171
|
+
|
|
52
172
|
/**
|
|
53
173
|
* Build a bin hierarchy from an array of bin objects.
|
|
54
174
|
* Filters by targetAppId and resolves full directory paths via ParentBinID traversal.
|
|
@@ -70,6 +190,21 @@ export function buildBinHierarchy(bins, targetAppId) {
|
|
|
70
190
|
// Build lookup by BinID
|
|
71
191
|
const byId = {};
|
|
72
192
|
for (const bin of filtered) {
|
|
193
|
+
// bin.Name=null is legacy — these bins map directly to bins/ root.
|
|
194
|
+
// Never split bin.Path into sub-directories for legacy null-Name bins.
|
|
195
|
+
if (!bin.Name) {
|
|
196
|
+
byId[bin.BinID] = {
|
|
197
|
+
name: null,
|
|
198
|
+
path: bin.Path,
|
|
199
|
+
segment: '',
|
|
200
|
+
parentBinID: null,
|
|
201
|
+
binId: bin.BinID,
|
|
202
|
+
uid: bin.UID,
|
|
203
|
+
fullPath: '',
|
|
204
|
+
};
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
|
|
73
208
|
// Use Path as the segment name, but only the last part if it contains slashes
|
|
74
209
|
// (Path often stores the full path, e.g. "assets/css/vendor" for a bin named "vendor")
|
|
75
210
|
const rawPath = bin.Path || bin.Name;
|
|
@@ -114,7 +249,9 @@ export function buildBinHierarchy(bins, targetAppId) {
|
|
|
114
249
|
*/
|
|
115
250
|
export function resolveBinPath(binId, structure) {
|
|
116
251
|
const entry = structure[binId];
|
|
117
|
-
|
|
252
|
+
if (!entry) return null;
|
|
253
|
+
// Legacy bins (Name=null) have empty fullPath → resolve to bins/ root
|
|
254
|
+
return entry.fullPath ? `${BINS_DIR}/${entry.fullPath}` : BINS_DIR;
|
|
118
255
|
}
|
|
119
256
|
|
|
120
257
|
/**
|
|
@@ -187,12 +324,12 @@ export function findChildBins(binId, structure) {
|
|
|
187
324
|
// ─── Extension Descriptor Sub-directory Support ───────────────────────────
|
|
188
325
|
|
|
189
326
|
/** Root for all extension descriptor-grouped sub-directories */
|
|
190
|
-
export const EXTENSION_DESCRIPTORS_DIR = 'extension';
|
|
327
|
+
export const EXTENSION_DESCRIPTORS_DIR = 'lib/extension';
|
|
191
328
|
|
|
192
|
-
/** Extensions that cannot be mapped go here
|
|
193
|
-
export const EXTENSION_UNSUPPORTED_DIR = 'extension/_unsupported';
|
|
329
|
+
/** Extensions that cannot be mapped go here */
|
|
330
|
+
export const EXTENSION_UNSUPPORTED_DIR = 'lib/extension/_unsupported';
|
|
194
331
|
|
|
195
|
-
/** Root-level documentation directory
|
|
332
|
+
/** Root-level documentation directory (remains at project root) */
|
|
196
333
|
export const DOCUMENTATION_DIR = 'docs';
|
|
197
334
|
|
|
198
335
|
/**
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { dirname, basename } from 'path';
|
|
2
|
+
import { readFile } from 'fs/promises';
|
|
3
|
+
import { findBaselineEntry, shouldSkipColumn, normalizeValue, isReference, resolveReferencePath } from './delta.js';
|
|
4
|
+
import { resolveContentValue } from '../commands/clone.js';
|
|
5
|
+
import { log } from './logger.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Columns to include in the diff even though shouldSkipColumn() would normally
|
|
9
|
+
* skip them. _LastUpdated and _LastUpdatedUserID are valuable conflict
|
|
10
|
+
* indicators: _LastUpdated reveals a newer server edit, and _LastUpdatedUserID
|
|
11
|
+
* identifies *who* made it.
|
|
12
|
+
*/
|
|
13
|
+
const DIFF_ALLOW = new Set(['_LastUpdated', '_LastUpdatedUserID']);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Fetch a single record from the server by entity name + row UID.
|
|
17
|
+
*
|
|
18
|
+
* Uses the lightweight entity endpoint:
|
|
19
|
+
* GET /api/o/e/{entity}/{rowUID}?_template=json_raw
|
|
20
|
+
*
|
|
21
|
+
* Returns the parsed record object, or null on failure (network, 404, auth).
|
|
22
|
+
*
|
|
23
|
+
* @param {DboClient} client
|
|
24
|
+
* @param {string} entity - Entity name (e.g., "content", "media", "output")
|
|
25
|
+
* @param {string} uid - Row UID
|
|
26
|
+
* @returns {Promise<Object|null>}
|
|
27
|
+
*/
|
|
28
|
+
export async function fetchServerRecord(client, entity, uid) {
|
|
29
|
+
try {
|
|
30
|
+
const result = await client.get(`/api/o/e/${entity}/${uid}?_template=json_raw`);
|
|
31
|
+
if (!result.ok) return null;
|
|
32
|
+
|
|
33
|
+
// json_raw returns { Rows: [...] } or { rows: [...] } or an array
|
|
34
|
+
const data = result.payload || result.data;
|
|
35
|
+
if (Array.isArray(data)) return data.length > 0 ? data[0] : null;
|
|
36
|
+
if (data?.Rows?.length > 0) return data.Rows[0];
|
|
37
|
+
if (data?.rows?.length > 0) return data.rows[0];
|
|
38
|
+
// Direct object with UID → single record response
|
|
39
|
+
if (data && typeof data === 'object' && data.UID) return data;
|
|
40
|
+
return null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null; // network or parse failure — degrade gracefully
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Fetch multiple server records in parallel.
|
|
48
|
+
*
|
|
49
|
+
* Each record is fetched independently via fetchServerRecord().
|
|
50
|
+
* Uses Promise.allSettled() so one failure doesn't block others.
|
|
51
|
+
*
|
|
52
|
+
* @param {DboClient} client
|
|
53
|
+
* @param {Array<{ entity: string, uid: string }>} requests
|
|
54
|
+
* @returns {Promise<Map<string, Object>>} - Map of uid → server record (only successful fetches)
|
|
55
|
+
*/
|
|
56
|
+
export async function fetchServerRecords(client, requests) {
|
|
57
|
+
const results = await Promise.allSettled(
|
|
58
|
+
requests.map(({ entity, uid }) =>
|
|
59
|
+
fetchServerRecord(client, entity, uid).then(record => ({ uid, record }))
|
|
60
|
+
)
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const map = new Map();
|
|
64
|
+
for (const result of results) {
|
|
65
|
+
if (result.status === 'fulfilled' && result.value.record) {
|
|
66
|
+
map.set(result.value.uid, result.value.record);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return map;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Fetch server records in bulk using the app object endpoint with UpdatedAfter
|
|
74
|
+
* filtering. This is far more efficient than per-record fetches because it
|
|
75
|
+
* makes a single HTTP request and the server only returns records modified
|
|
76
|
+
* after the given date.
|
|
77
|
+
*
|
|
78
|
+
* The response is used ONLY for comparison — it must NOT replace app.json or
|
|
79
|
+
* app_baseline.json.
|
|
80
|
+
*
|
|
81
|
+
* @param {DboClient} client
|
|
82
|
+
* @param {string} appShortName - App short name for the /api/app/object/ endpoint
|
|
83
|
+
* @param {string} updatedAfter - ISO date string (oldest _LastUpdated among records to push)
|
|
84
|
+
* @returns {Promise<Map<string, Object>>} - Map of uid → server record (across all entities)
|
|
85
|
+
*/
|
|
86
|
+
export async function fetchServerRecordsBatch(client, appShortName, updatedAfter) {
|
|
87
|
+
const map = new Map();
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
// Void cache first so the app object response reflects the latest server state
|
|
91
|
+
await client.voidCache();
|
|
92
|
+
|
|
93
|
+
const result = await client.get(`/api/app/object/${appShortName}`, {
|
|
94
|
+
UpdatedAfter: updatedAfter,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (!result.ok && !result.successful) return map;
|
|
98
|
+
|
|
99
|
+
const data = result.payload || result.data;
|
|
100
|
+
if (!data) return map;
|
|
101
|
+
|
|
102
|
+
// Normalize: the response may be a single app object or wrapped in an array
|
|
103
|
+
let appRecord;
|
|
104
|
+
if (Array.isArray(data)) {
|
|
105
|
+
appRecord = data.length > 0 ? data[0] : null;
|
|
106
|
+
} else if (data?.Rows?.length > 0) {
|
|
107
|
+
appRecord = data.Rows[0];
|
|
108
|
+
} else if (data && typeof data === 'object' && (data.UID || data.children)) {
|
|
109
|
+
appRecord = data;
|
|
110
|
+
} else {
|
|
111
|
+
return map;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!appRecord?.children) return map;
|
|
115
|
+
|
|
116
|
+
// Walk children hierarchy and index every record by UID
|
|
117
|
+
_indexChildren(appRecord.children, map);
|
|
118
|
+
} catch {
|
|
119
|
+
// degrade gracefully — caller will fall back to per-record fetches
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return map;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Recursively index all records in a children hierarchy into a Map<UID, record>.
|
|
127
|
+
* Handles nested children (e.g. output records contain children.output_value).
|
|
128
|
+
*/
|
|
129
|
+
function _indexChildren(children, map) {
|
|
130
|
+
for (const [, entityArray] of Object.entries(children)) {
|
|
131
|
+
if (!Array.isArray(entityArray)) continue;
|
|
132
|
+
for (const record of entityArray) {
|
|
133
|
+
if (record.UID) {
|
|
134
|
+
map.set(record.UID, record);
|
|
135
|
+
}
|
|
136
|
+
// Recurse into nested children (e.g. output → output_value)
|
|
137
|
+
if (record.children) {
|
|
138
|
+
_indexChildren(record.children, map);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Decode a server column value that may be base64-encoded.
|
|
146
|
+
* The json_raw format may return large text as:
|
|
147
|
+
* { bytes: N, value: "base64string", encoding: "base64" }
|
|
148
|
+
* Delegates to resolveContentValue() from clone.js.
|
|
149
|
+
*
|
|
150
|
+
* @param {*} value
|
|
151
|
+
* @returns {string|null}
|
|
152
|
+
*/
|
|
153
|
+
function decodeServerValue(value) {
|
|
154
|
+
return resolveContentValue(value);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Compare a server record against local metadata and baseline to
|
|
159
|
+
* build a diff description.
|
|
160
|
+
*
|
|
161
|
+
* Returns an array of conflict columns:
|
|
162
|
+
* [{ col, serverValue, localValue, baselineValue }]
|
|
163
|
+
*
|
|
164
|
+
* A "conflict column" is one where the server value differs from the baseline
|
|
165
|
+
* (meaning the server changed since we last pulled/pushed).
|
|
166
|
+
*
|
|
167
|
+
* _LastUpdated and _LastUpdatedUserID are intentionally included in the diff
|
|
168
|
+
* because they carry meaningful conflict information: _LastUpdated indicates
|
|
169
|
+
* a newer server edit, and _LastUpdatedUserID identifies who made the change.
|
|
170
|
+
*
|
|
171
|
+
* @param {Object} serverEntry - Record from per-record server fetch
|
|
172
|
+
* @param {Object} baselineEntry - Record from .app_baseline.json
|
|
173
|
+
* @param {Object} localMeta - Local .metadata.json object
|
|
174
|
+
* @param {string} metaDir - Absolute directory of the metadata file
|
|
175
|
+
* @returns {Promise<Array<{ col: string, serverValue: string, localValue: string, baselineValue: string }>>}
|
|
176
|
+
*/
|
|
177
|
+
export async function buildRecordDiff(serverEntry, baselineEntry, localMeta, metaDir) {
|
|
178
|
+
const conflicts = [];
|
|
179
|
+
|
|
180
|
+
// Compare all columns present in the server entry
|
|
181
|
+
for (const [col, rawServerVal] of Object.entries(serverEntry)) {
|
|
182
|
+
// Allow _LastUpdated and _LastUpdatedUserID through; skip other system cols
|
|
183
|
+
if (!DIFF_ALLOW.has(col) && shouldSkipColumn(col)) continue;
|
|
184
|
+
|
|
185
|
+
const serverValue = normalizeValue(decodeServerValue(rawServerVal));
|
|
186
|
+
const baselineValue = normalizeValue(baselineEntry ? baselineEntry[col] : undefined);
|
|
187
|
+
|
|
188
|
+
// If server value equals baseline value, no server-side change for this col
|
|
189
|
+
if (serverValue === baselineValue) continue;
|
|
190
|
+
|
|
191
|
+
// Server changed this column — record what's local too
|
|
192
|
+
const localRaw = localMeta[col];
|
|
193
|
+
let localValue;
|
|
194
|
+
if (localRaw && isReference(String(localRaw))) {
|
|
195
|
+
// Read file content for display (truncate for readability)
|
|
196
|
+
try {
|
|
197
|
+
const refPath = resolveReferencePath(String(localRaw), metaDir);
|
|
198
|
+
const content = await readFile(refPath, 'utf8');
|
|
199
|
+
localValue = content.substring(0, 120) + (content.length > 120 ? '…' : '');
|
|
200
|
+
} catch {
|
|
201
|
+
localValue = String(localRaw);
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
localValue = normalizeValue(localRaw);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
conflicts.push({ col, serverValue: serverValue.substring(0, 120), localValue, baselineValue: baselineValue.substring(0, 120) });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return conflicts;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Print a conflict summary for a record to the terminal.
|
|
215
|
+
*
|
|
216
|
+
* @param {string} label - Human-readable record label (filename without extension)
|
|
217
|
+
* @param {string} serverUser - _LastUpdatedUserID from server
|
|
218
|
+
* @param {string} serverTimestamp - _LastUpdated from server
|
|
219
|
+
* @param {Array} diffColumns - Output of buildRecordDiff()
|
|
220
|
+
*/
|
|
221
|
+
export function displayConflict(label, serverUser, serverTimestamp, diffColumns) {
|
|
222
|
+
log.warn('');
|
|
223
|
+
log.warn(` Server conflict: "${label}"`);
|
|
224
|
+
log.label(' Changed by', serverUser || 'unknown');
|
|
225
|
+
log.label(' Server time', serverTimestamp || 'unknown');
|
|
226
|
+
if (diffColumns.length > 0) {
|
|
227
|
+
log.dim(' Column changes on server:');
|
|
228
|
+
for (const { col, serverValue, baselineValue } of diffColumns) {
|
|
229
|
+
log.dim(` ${col}:`);
|
|
230
|
+
if (baselineValue) log.dim(` was: ${baselineValue}`);
|
|
231
|
+
log.dim(` now: ${serverValue}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Find the oldest _LastUpdated date among the baseline entries for the
|
|
238
|
+
* records about to be pushed. This is used as the UpdatedAfter parameter
|
|
239
|
+
* to limit the app object response to only recently modified records.
|
|
240
|
+
*
|
|
241
|
+
* @param {Array<{ meta: Object }>} records
|
|
242
|
+
* @param {Object} baseline
|
|
243
|
+
* @returns {string|null} - ISO date string or null if no baseline dates found
|
|
244
|
+
*/
|
|
245
|
+
function findOldestBaselineDate(records, baseline) {
|
|
246
|
+
let oldest = null;
|
|
247
|
+
for (const { meta } of records) {
|
|
248
|
+
if (!meta.UID || !meta._entity) continue;
|
|
249
|
+
const entry = findBaselineEntry(baseline, meta._entity, meta.UID);
|
|
250
|
+
if (!entry?._LastUpdated) continue;
|
|
251
|
+
const d = new Date(entry._LastUpdated);
|
|
252
|
+
if (isNaN(d)) continue;
|
|
253
|
+
if (!oldest || d < oldest) oldest = d;
|
|
254
|
+
}
|
|
255
|
+
return oldest ? oldest.toISOString() : null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Main toe-stepping check. Compares each record being pushed against the
|
|
260
|
+
* live server state.
|
|
261
|
+
*
|
|
262
|
+
* When appShortName is provided, uses a single bulk fetch via
|
|
263
|
+
* GET /api/app/object/{appShortName}?UpdatedAfter={date}
|
|
264
|
+
* which is far more efficient than per-record fetches. Falls back to
|
|
265
|
+
* per-record GET /api/o/e/{entity}/{uid} when the bulk endpoint is
|
|
266
|
+
* unavailable or returns no data.
|
|
267
|
+
*
|
|
268
|
+
* The bulk response is used ONLY for comparison — it does NOT replace
|
|
269
|
+
* app.json or app_baseline.json.
|
|
270
|
+
*
|
|
271
|
+
* @param {Array<{ meta: Object, metaPath: string }>} records
|
|
272
|
+
* Records about to be pushed. Each must have meta.UID, meta._entity.
|
|
273
|
+
* @param {DboClient} client
|
|
274
|
+
* @param {Object} baseline - Loaded baseline from .dbo/.app_baseline.json
|
|
275
|
+
* @param {Object} options - Commander options (options.yes used for auto-accept)
|
|
276
|
+
* @param {string} [appShortName] - App short name for bulk fetch (optional)
|
|
277
|
+
* @returns {Promise<boolean>} - true = proceed, false = user cancelled
|
|
278
|
+
*/
|
|
279
|
+
export async function checkToeStepping(records, client, baseline, options, appShortName) {
|
|
280
|
+
// Build list of records to check (skip new records without UID)
|
|
281
|
+
const requests = [];
|
|
282
|
+
for (const { meta } of records) {
|
|
283
|
+
if (meta.UID && meta._entity) {
|
|
284
|
+
requests.push({ entity: meta._entity, uid: meta.UID });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (requests.length === 0) return true; // nothing to check
|
|
289
|
+
|
|
290
|
+
const ora = (await import('ora')).default;
|
|
291
|
+
const spinner = ora(`Checking ${requests.length} record(s) for server conflicts...`).start();
|
|
292
|
+
|
|
293
|
+
// Try bulk fetch via /api/app/object/ with UpdatedAfter when possible
|
|
294
|
+
let serverRecords;
|
|
295
|
+
if (appShortName) {
|
|
296
|
+
const updatedAfter = findOldestBaselineDate(records, baseline);
|
|
297
|
+
if (updatedAfter) {
|
|
298
|
+
spinner.text = `Fetching server state (UpdatedAfter: ${updatedAfter})...`;
|
|
299
|
+
serverRecords = await fetchServerRecordsBatch(client, appShortName, updatedAfter);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Fall back to per-record fetches if batch returned nothing or wasn't available
|
|
304
|
+
if (!serverRecords || serverRecords.size === 0) {
|
|
305
|
+
spinner.text = `Fetching ${requests.length} record(s) from server...`;
|
|
306
|
+
serverRecords = await fetchServerRecords(client, requests);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (serverRecords.size === 0) {
|
|
310
|
+
spinner.stop();
|
|
311
|
+
log.dim(' Toe-stepping: no server records fetched — skipping conflict check');
|
|
312
|
+
return true; // degrade gracefully
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
spinner.succeed(`Fetched ${serverRecords.size} record(s) from server`);
|
|
316
|
+
|
|
317
|
+
const conflicts = [];
|
|
318
|
+
|
|
319
|
+
for (const { meta, metaPath } of records) {
|
|
320
|
+
const uid = meta.UID;
|
|
321
|
+
const entity = meta._entity;
|
|
322
|
+
if (!uid || !entity) continue;
|
|
323
|
+
|
|
324
|
+
const serverEntry = serverRecords.get(uid);
|
|
325
|
+
if (!serverEntry) continue; // fetch failed or record not on server — skip
|
|
326
|
+
|
|
327
|
+
const baselineEntry = findBaselineEntry(baseline, entity, uid);
|
|
328
|
+
|
|
329
|
+
// Compare _LastUpdated: server newer than baseline → someone else changed it
|
|
330
|
+
const serverTs = serverEntry._LastUpdated;
|
|
331
|
+
const baselineTs = baselineEntry?._LastUpdated;
|
|
332
|
+
|
|
333
|
+
if (!serverTs || !baselineTs) continue; // missing timestamps — skip safely
|
|
334
|
+
|
|
335
|
+
// Parse both as dates (ISO 8601 strings or server-format timestamps)
|
|
336
|
+
const serverDate = new Date(serverTs);
|
|
337
|
+
const baselineDate = new Date(baselineTs);
|
|
338
|
+
|
|
339
|
+
if (isNaN(serverDate) || isNaN(baselineDate)) continue; // unparseable — skip
|
|
340
|
+
if (serverDate <= baselineDate) continue; // server is same or older — no conflict
|
|
341
|
+
|
|
342
|
+
// Conflict detected: server changed since our baseline
|
|
343
|
+
const metaDir = dirname(metaPath);
|
|
344
|
+
const label = basename(metaPath, '.metadata.json');
|
|
345
|
+
const diffColumns = await buildRecordDiff(serverEntry, baselineEntry, meta, metaDir);
|
|
346
|
+
const serverUser = serverEntry._LastUpdatedUserID || 'unknown';
|
|
347
|
+
|
|
348
|
+
displayConflict(label, serverUser, serverTs, diffColumns);
|
|
349
|
+
conflicts.push({ label, serverUser });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (conflicts.length === 0) return true; // no conflicts
|
|
353
|
+
|
|
354
|
+
log.warn('');
|
|
355
|
+
log.warn(` ${conflicts.length} record(s) have server changes that would be overwritten.`);
|
|
356
|
+
log.warn('');
|
|
357
|
+
|
|
358
|
+
if (options.yes) {
|
|
359
|
+
log.dim(' --yes flag: proceeding despite server conflicts');
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const inquirer = (await import('inquirer')).default;
|
|
364
|
+
const { action } = await inquirer.prompt([{
|
|
365
|
+
type: 'list',
|
|
366
|
+
name: 'action',
|
|
367
|
+
message: 'Server has newer changes. How would you like to proceed?',
|
|
368
|
+
choices: [
|
|
369
|
+
{ name: 'Overwrite server changes (push anyway)', value: 'overwrite' },
|
|
370
|
+
{ name: 'Cancel — pull server changes first', value: 'cancel' },
|
|
371
|
+
],
|
|
372
|
+
}]);
|
|
373
|
+
|
|
374
|
+
if (action === 'cancel') {
|
|
375
|
+
log.info('Push cancelled. Run "dbo pull" to fetch server changes first.');
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
log.dim(' Proceeding — local changes will overwrite server state.');
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { readFile } from 'fs/promises';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { log } from '../lib/logger.js';
|
|
4
|
+
|
|
5
|
+
export const description = 'Restrict TransactionKeyPreset to records with a UID column';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Migration 001 — TransactionKeyPreset scope correction.
|
|
9
|
+
*
|
|
10
|
+
* The pushFromMetadata() function now only consults TransactionKeyPreset when
|
|
11
|
+
* a record carries a UID column (meta.UID is non-null and non-empty).
|
|
12
|
+
* Records without a UID column (data entities) always use RowID regardless of
|
|
13
|
+
* the preset. This migration notifies users of the behaviour change.
|
|
14
|
+
*
|
|
15
|
+
* @param {object} _options - Command options (unused)
|
|
16
|
+
*/
|
|
17
|
+
export default async function run(_options) {
|
|
18
|
+
let preset;
|
|
19
|
+
try {
|
|
20
|
+
const configPath = join(process.cwd(), '.dbo', 'config.json');
|
|
21
|
+
const raw = await readFile(configPath, 'utf8');
|
|
22
|
+
preset = JSON.parse(raw).TransactionKeyPreset;
|
|
23
|
+
} catch {
|
|
24
|
+
// Not in a project dir or config unreadable — nothing to notify
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!preset) return; // Key not set — no notice needed
|
|
29
|
+
|
|
30
|
+
log.plain('');
|
|
31
|
+
log.dim(' TransactionKeyPreset now applies only to records with a UID column.');
|
|
32
|
+
log.dim(' Data records without a UID always use RowID (no preset applied).');
|
|
33
|
+
log.dim(` Your current preset: ${preset} — no config change required.`);
|
|
34
|
+
log.plain('');
|
|
35
|
+
}
|