@yinzuoweia/nis 0.1.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.
- package/README.md +76 -0
- package/dist/api.d.ts +15 -0
- package/dist/api.js +29 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +313 -0
- package/dist/core.d.ts +84 -0
- package/dist/core.js +614 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +12 -0
- package/dist/id.d.ts +1 -0
- package/dist/id.js +9 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/lock.d.ts +6 -0
- package/dist/lock.js +47 -0
- package/dist/query.d.ts +4 -0
- package/dist/query.js +166 -0
- package/dist/storage.d.ts +12 -0
- package/dist/storage.js +96 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.js +8 -0
- package/package.json +32 -0
package/dist/core.js
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
import { copyFile, mkdir, readdir, readFile, rm, stat } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, parse } from 'node:path';
|
|
3
|
+
import { CliError } from './errors.js';
|
|
4
|
+
import { generateNodeId } from './id.js';
|
|
5
|
+
import { withFileLock } from './lock.js';
|
|
6
|
+
import { matchesNode, parseQuery } from './query.js';
|
|
7
|
+
import { createInitialTree, fileExists, getLockPath, readTree, resolveTreePath, snapshotDir, writeTreeAtomic } from './storage.js';
|
|
8
|
+
import { RESERVED_FIELDS, ROOT_ID } from './types.js';
|
|
9
|
+
function defaultSnapshotKeep() {
|
|
10
|
+
const raw = process.env.NIS_SNAPSHOT_KEEP;
|
|
11
|
+
if (!raw) {
|
|
12
|
+
return 20;
|
|
13
|
+
}
|
|
14
|
+
const parsed = Number(raw);
|
|
15
|
+
if (Number.isNaN(parsed) || parsed <= 0) {
|
|
16
|
+
return 20;
|
|
17
|
+
}
|
|
18
|
+
return Math.floor(parsed);
|
|
19
|
+
}
|
|
20
|
+
function nowIso() {
|
|
21
|
+
return new Date().toISOString();
|
|
22
|
+
}
|
|
23
|
+
function ensureMutableField(key) {
|
|
24
|
+
if (RESERVED_FIELDS.has(key)) {
|
|
25
|
+
throw new CliError('SCHEMA_INVALID', `field '${key}' is reserved and cannot be modified directly`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function assertNodeExists(tree, nodeId) {
|
|
29
|
+
const node = tree.nodes[nodeId];
|
|
30
|
+
if (!node) {
|
|
31
|
+
throw new CliError('NODE_NOT_FOUND', `node '${nodeId}' not found`, `use find with id:${nodeId}`);
|
|
32
|
+
}
|
|
33
|
+
return node;
|
|
34
|
+
}
|
|
35
|
+
function assertNodeIdAvailable(tree, nodeId) {
|
|
36
|
+
if (tree.nodes[nodeId]) {
|
|
37
|
+
throw new CliError('NODE_ID_CONFLICT', `node id '${nodeId}' already exists`);
|
|
38
|
+
}
|
|
39
|
+
if (nodeId === ROOT_ID) {
|
|
40
|
+
throw new CliError('NODE_ID_CONFLICT', `node id '${ROOT_ID}' is reserved`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function collectSubtreeIds(tree, nodeId) {
|
|
44
|
+
const result = [];
|
|
45
|
+
const stack = [nodeId];
|
|
46
|
+
while (stack.length > 0) {
|
|
47
|
+
const current = stack.pop();
|
|
48
|
+
result.push(current);
|
|
49
|
+
const node = assertNodeExists(tree, current);
|
|
50
|
+
for (const childId of node.children) {
|
|
51
|
+
stack.push(childId);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
function removeFromParent(tree, nodeId) {
|
|
57
|
+
const node = assertNodeExists(tree, nodeId);
|
|
58
|
+
if (!node.parent) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const parent = assertNodeExists(tree, node.parent);
|
|
62
|
+
parent.children = parent.children.filter((id) => id !== nodeId);
|
|
63
|
+
parent.updated_at = nowIso();
|
|
64
|
+
}
|
|
65
|
+
function addToParent(tree, nodeId, parentId) {
|
|
66
|
+
const parent = assertNodeExists(tree, parentId);
|
|
67
|
+
if (!parent.children.includes(nodeId)) {
|
|
68
|
+
parent.children.push(nodeId);
|
|
69
|
+
}
|
|
70
|
+
parent.updated_at = nowIso();
|
|
71
|
+
}
|
|
72
|
+
function isDescendant(tree, ancestorId, maybeDescendant) {
|
|
73
|
+
if (ancestorId === maybeDescendant) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
const stack = [ancestorId];
|
|
77
|
+
const visited = new Set();
|
|
78
|
+
while (stack.length > 0) {
|
|
79
|
+
const id = stack.pop();
|
|
80
|
+
if (visited.has(id)) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
visited.add(id);
|
|
84
|
+
const node = assertNodeExists(tree, id);
|
|
85
|
+
for (const childId of node.children) {
|
|
86
|
+
if (childId === maybeDescendant) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
stack.push(childId);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
function parseSort(sortRaw) {
|
|
95
|
+
if (!sortRaw) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
const [field, directionRaw] = sortRaw.split(':');
|
|
99
|
+
const direction = directionRaw?.toLowerCase() === 'asc' ? 'asc' : 'desc';
|
|
100
|
+
if (!field) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return { field, direction };
|
|
104
|
+
}
|
|
105
|
+
function valueForSort(value) {
|
|
106
|
+
if (typeof value === 'number') {
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
if (typeof value === 'string') {
|
|
110
|
+
const date = Date.parse(value);
|
|
111
|
+
if (!Number.isNaN(date)) {
|
|
112
|
+
return date;
|
|
113
|
+
}
|
|
114
|
+
const num = Number(value);
|
|
115
|
+
if (!Number.isNaN(num)) {
|
|
116
|
+
return num;
|
|
117
|
+
}
|
|
118
|
+
return value.toLowerCase();
|
|
119
|
+
}
|
|
120
|
+
return String(value).toLowerCase();
|
|
121
|
+
}
|
|
122
|
+
async function createSnapshotInternal(filePath, name) {
|
|
123
|
+
const snapDir = snapshotDir(filePath);
|
|
124
|
+
await mkdir(snapDir, { recursive: true });
|
|
125
|
+
const base = name && name.trim().length > 0 ? name.replace(/[^a-zA-Z0-9_-]/g, '_') : 'snapshot';
|
|
126
|
+
const snapshotId = `${Date.now()}-${base}.json`;
|
|
127
|
+
const snapPath = join(snapDir, snapshotId);
|
|
128
|
+
await copyFile(filePath, snapPath);
|
|
129
|
+
return {
|
|
130
|
+
snapshot_id: snapshotId,
|
|
131
|
+
path: snapPath
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
async function pruneSnapshots(filePath, keep) {
|
|
135
|
+
if (keep <= 0) {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const snapDir = snapshotDir(filePath);
|
|
139
|
+
const entries = await readdir(snapDir, { withFileTypes: true }).catch(() => []);
|
|
140
|
+
const files = entries.filter((entry) => entry.isFile()).map((entry) => join(snapDir, entry.name));
|
|
141
|
+
if (files.length <= keep) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const withTime = await Promise.all(files.map(async (file) => {
|
|
145
|
+
const info = await stat(file);
|
|
146
|
+
return { file, mtimeMs: info.mtimeMs };
|
|
147
|
+
}));
|
|
148
|
+
withTime.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
149
|
+
const toDelete = withTime.slice(keep);
|
|
150
|
+
await Promise.all(toDelete.map(async (item) => rm(item.file, { force: true })));
|
|
151
|
+
}
|
|
152
|
+
async function mutateWithLock(filePathRaw, mutator, options = {}) {
|
|
153
|
+
const filePath = resolveTreePath({ filePath: filePathRaw });
|
|
154
|
+
const lockPath = getLockPath(filePath);
|
|
155
|
+
return withFileLock(lockPath, async () => {
|
|
156
|
+
const tree = await readTree(filePath);
|
|
157
|
+
if (options.autoSnapshot !== false) {
|
|
158
|
+
await createSnapshotInternal(filePath, 'autosave');
|
|
159
|
+
await pruneSnapshots(filePath, options.snapshotKeep ?? defaultSnapshotKeep());
|
|
160
|
+
}
|
|
161
|
+
const out = await mutator(tree);
|
|
162
|
+
await writeTreeAtomic(filePath, tree);
|
|
163
|
+
return out;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
function addNodeOnTree(tree, input) {
|
|
167
|
+
const parentId = input.parent ?? ROOT_ID;
|
|
168
|
+
assertNodeExists(tree, parentId);
|
|
169
|
+
const nodeId = input.id ?? generateNodeId(new Set(Object.keys(tree.nodes)));
|
|
170
|
+
assertNodeIdAvailable(tree, nodeId);
|
|
171
|
+
const ts = nowIso();
|
|
172
|
+
const extra = {};
|
|
173
|
+
for (const [key, value] of Object.entries(input.set ?? {})) {
|
|
174
|
+
ensureMutableField(key);
|
|
175
|
+
extra[key] = value;
|
|
176
|
+
}
|
|
177
|
+
tree.nodes[nodeId] = {
|
|
178
|
+
id: nodeId,
|
|
179
|
+
parent: parentId,
|
|
180
|
+
children: [],
|
|
181
|
+
created_at: ts,
|
|
182
|
+
updated_at: ts,
|
|
183
|
+
...extra
|
|
184
|
+
};
|
|
185
|
+
addToParent(tree, nodeId, parentId);
|
|
186
|
+
return { id: nodeId, parent: parentId };
|
|
187
|
+
}
|
|
188
|
+
function updateNodeOnTree(tree, nodeId, patch) {
|
|
189
|
+
const node = assertNodeExists(tree, nodeId);
|
|
190
|
+
for (const key of Object.keys(patch.set ?? {})) {
|
|
191
|
+
ensureMutableField(key);
|
|
192
|
+
}
|
|
193
|
+
for (const key of patch.unset ?? []) {
|
|
194
|
+
ensureMutableField(key);
|
|
195
|
+
}
|
|
196
|
+
for (const [key, value] of Object.entries(patch.set ?? {})) {
|
|
197
|
+
node[key] = value;
|
|
198
|
+
}
|
|
199
|
+
for (const key of patch.unset ?? []) {
|
|
200
|
+
delete node[key];
|
|
201
|
+
}
|
|
202
|
+
node.updated_at = nowIso();
|
|
203
|
+
return node;
|
|
204
|
+
}
|
|
205
|
+
function deleteNodeOnTree(tree, nodeId, input) {
|
|
206
|
+
if (nodeId === ROOT_ID) {
|
|
207
|
+
throw new CliError('ROOT_IMMUTABLE', 'root node cannot be deleted');
|
|
208
|
+
}
|
|
209
|
+
const node = assertNodeExists(tree, nodeId);
|
|
210
|
+
if (!input.cascade && node.children.length > 0) {
|
|
211
|
+
throw new CliError('DELETE_CONFIRM_REQUIRED', 'node has children, use --cascade to delete subtree');
|
|
212
|
+
}
|
|
213
|
+
const ids = collectSubtreeIds(tree, nodeId);
|
|
214
|
+
removeFromParent(tree, nodeId);
|
|
215
|
+
for (const id of ids) {
|
|
216
|
+
delete tree.nodes[id];
|
|
217
|
+
}
|
|
218
|
+
return { deleted_ids: ids };
|
|
219
|
+
}
|
|
220
|
+
function moveNodeOnTree(tree, nodeId, targetParentId) {
|
|
221
|
+
if (nodeId === ROOT_ID) {
|
|
222
|
+
throw new CliError('ROOT_IMMUTABLE', 'root node cannot be moved');
|
|
223
|
+
}
|
|
224
|
+
const node = assertNodeExists(tree, nodeId);
|
|
225
|
+
assertNodeExists(tree, targetParentId);
|
|
226
|
+
if (isDescendant(tree, nodeId, targetParentId)) {
|
|
227
|
+
throw new CliError('CYCLE_DETECTED', `cannot move '${nodeId}' under '${targetParentId}'`);
|
|
228
|
+
}
|
|
229
|
+
const oldParent = node.parent;
|
|
230
|
+
removeFromParent(tree, nodeId);
|
|
231
|
+
node.parent = targetParentId;
|
|
232
|
+
node.updated_at = nowIso();
|
|
233
|
+
addToParent(tree, nodeId, targetParentId);
|
|
234
|
+
return {
|
|
235
|
+
id: nodeId,
|
|
236
|
+
from: oldParent,
|
|
237
|
+
to: targetParentId
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function validateTreeObject(tree) {
|
|
241
|
+
const errors = [];
|
|
242
|
+
const warnings = [];
|
|
243
|
+
if (tree.root_id !== ROOT_ID) {
|
|
244
|
+
errors.push(`root_id must be '${ROOT_ID}'`);
|
|
245
|
+
}
|
|
246
|
+
const root = tree.nodes[ROOT_ID];
|
|
247
|
+
if (!root) {
|
|
248
|
+
errors.push('root node missing');
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
if (root.parent !== null) {
|
|
252
|
+
errors.push('root.parent must be null');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
for (const [id, node] of Object.entries(tree.nodes)) {
|
|
256
|
+
if (node.id !== id) {
|
|
257
|
+
errors.push(`node key '${id}' != node.id '${node.id}'`);
|
|
258
|
+
}
|
|
259
|
+
if (id !== ROOT_ID && node.parent === null) {
|
|
260
|
+
errors.push(`node '${id}' parent cannot be null`);
|
|
261
|
+
}
|
|
262
|
+
if (node.parent) {
|
|
263
|
+
const parent = tree.nodes[node.parent];
|
|
264
|
+
if (!parent) {
|
|
265
|
+
errors.push(`node '${id}' parent '${node.parent}' not found`);
|
|
266
|
+
}
|
|
267
|
+
else if (!parent.children.includes(id)) {
|
|
268
|
+
errors.push(`node '${id}' missing in parent '${node.parent}' children`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
for (const childId of node.children) {
|
|
272
|
+
const child = tree.nodes[childId];
|
|
273
|
+
if (!child) {
|
|
274
|
+
errors.push(`node '${id}' child '${childId}' not found`);
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (child.parent !== id) {
|
|
278
|
+
errors.push(`node '${childId}' parent mismatch, expected '${id}' got '${child.parent}'`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const visited = new Set();
|
|
283
|
+
const stack = new Set();
|
|
284
|
+
function dfs(id) {
|
|
285
|
+
if (stack.has(id)) {
|
|
286
|
+
errors.push(`cycle detected at '${id}'`);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (visited.has(id)) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
visited.add(id);
|
|
293
|
+
stack.add(id);
|
|
294
|
+
const node = tree.nodes[id];
|
|
295
|
+
for (const childId of node.children) {
|
|
296
|
+
if (tree.nodes[childId]) {
|
|
297
|
+
dfs(childId);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
stack.delete(id);
|
|
301
|
+
}
|
|
302
|
+
if (tree.nodes[ROOT_ID]) {
|
|
303
|
+
dfs(ROOT_ID);
|
|
304
|
+
}
|
|
305
|
+
for (const id of Object.keys(tree.nodes)) {
|
|
306
|
+
if (!visited.has(id)) {
|
|
307
|
+
warnings.push(`node '${id}' is disconnected from root`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
valid: errors.length === 0,
|
|
312
|
+
errors,
|
|
313
|
+
warnings
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
function normalizeOps(raw) {
|
|
317
|
+
if (Array.isArray(raw)) {
|
|
318
|
+
return raw;
|
|
319
|
+
}
|
|
320
|
+
if (raw && typeof raw === 'object' && Array.isArray(raw.ops)) {
|
|
321
|
+
return raw.ops;
|
|
322
|
+
}
|
|
323
|
+
throw new CliError('SCHEMA_INVALID', 'bulk ops file must be an array or {ops: []}');
|
|
324
|
+
}
|
|
325
|
+
function asRecord(value) {
|
|
326
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
327
|
+
throw new CliError('SCHEMA_INVALID', 'expected object value in bulk operation');
|
|
328
|
+
}
|
|
329
|
+
return value;
|
|
330
|
+
}
|
|
331
|
+
function applyOneBulkOp(tree, op) {
|
|
332
|
+
const action = String(op.action ?? '');
|
|
333
|
+
switch (action) {
|
|
334
|
+
case 'add': {
|
|
335
|
+
return addNodeOnTree(tree, {
|
|
336
|
+
parent: typeof op.parent === 'string' ? op.parent : ROOT_ID,
|
|
337
|
+
id: typeof op.id === 'string' ? op.id : undefined,
|
|
338
|
+
set: asRecord(op.set ?? {})
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
case 'update': {
|
|
342
|
+
if (typeof op.id !== 'string') {
|
|
343
|
+
throw new CliError('SCHEMA_INVALID', 'update operation requires id');
|
|
344
|
+
}
|
|
345
|
+
return updateNodeOnTree(tree, op.id, {
|
|
346
|
+
set: asRecord(op.set ?? {}),
|
|
347
|
+
unset: Array.isArray(op.unset) ? op.unset.map(String) : []
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
case 'delete': {
|
|
351
|
+
if (typeof op.id !== 'string') {
|
|
352
|
+
throw new CliError('SCHEMA_INVALID', 'delete operation requires id');
|
|
353
|
+
}
|
|
354
|
+
if (op.yes !== true) {
|
|
355
|
+
throw new CliError('DELETE_CONFIRM_REQUIRED', 'bulk delete requires yes=true');
|
|
356
|
+
}
|
|
357
|
+
return deleteNodeOnTree(tree, op.id, { cascade: op.cascade !== false, yes: true });
|
|
358
|
+
}
|
|
359
|
+
case 'move': {
|
|
360
|
+
if (typeof op.id !== 'string' || typeof op.to !== 'string') {
|
|
361
|
+
throw new CliError('SCHEMA_INVALID', 'move operation requires id and to');
|
|
362
|
+
}
|
|
363
|
+
return moveNodeOnTree(tree, op.id, op.to);
|
|
364
|
+
}
|
|
365
|
+
case 'upsert': {
|
|
366
|
+
if (typeof op.id !== 'string') {
|
|
367
|
+
throw new CliError('SCHEMA_INVALID', 'upsert operation requires id');
|
|
368
|
+
}
|
|
369
|
+
const set = asRecord(op.set ?? {});
|
|
370
|
+
if (tree.nodes[op.id]) {
|
|
371
|
+
const updated = updateNodeOnTree(tree, op.id, { set });
|
|
372
|
+
if (typeof op.parent === 'string' && updated.parent !== op.parent) {
|
|
373
|
+
moveNodeOnTree(tree, op.id, op.parent);
|
|
374
|
+
}
|
|
375
|
+
return { id: op.id, created: false };
|
|
376
|
+
}
|
|
377
|
+
addNodeOnTree(tree, {
|
|
378
|
+
id: op.id,
|
|
379
|
+
parent: typeof op.parent === 'string' ? op.parent : ROOT_ID,
|
|
380
|
+
set
|
|
381
|
+
});
|
|
382
|
+
return { id: op.id, created: true };
|
|
383
|
+
}
|
|
384
|
+
default:
|
|
385
|
+
throw new CliError('SCHEMA_INVALID', `unsupported bulk action '${action}'`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
export async function initTree(filePathRaw, options = {}) {
|
|
389
|
+
const file = resolveTreePath({ filePath: filePathRaw });
|
|
390
|
+
const lockPath = getLockPath(file);
|
|
391
|
+
return withFileLock(lockPath, async () => {
|
|
392
|
+
const exists = await fileExists(file);
|
|
393
|
+
if (exists && !options.force) {
|
|
394
|
+
throw new CliError('SCHEMA_INVALID', `tree file already exists: ${file}`, 'use --force to overwrite');
|
|
395
|
+
}
|
|
396
|
+
await mkdir(dirname(file), { recursive: true });
|
|
397
|
+
const tree = createInitialTree();
|
|
398
|
+
await writeTreeAtomic(file, tree);
|
|
399
|
+
return {
|
|
400
|
+
file,
|
|
401
|
+
root_id: ROOT_ID
|
|
402
|
+
};
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
export async function addNode(filePathRaw, input, options = {}) {
|
|
406
|
+
return mutateWithLock(filePathRaw, (tree) => addNodeOnTree(tree, input), options);
|
|
407
|
+
}
|
|
408
|
+
export async function getNode(filePathRaw, nodeId) {
|
|
409
|
+
const file = resolveTreePath({ filePath: filePathRaw });
|
|
410
|
+
const tree = await readTree(file);
|
|
411
|
+
return assertNodeExists(tree, nodeId);
|
|
412
|
+
}
|
|
413
|
+
export async function listChildren(filePathRaw, parentId = ROOT_ID, max) {
|
|
414
|
+
const file = resolveTreePath({ filePath: filePathRaw });
|
|
415
|
+
const tree = await readTree(file);
|
|
416
|
+
const parent = assertNodeExists(tree, parentId);
|
|
417
|
+
const children = parent.children.map((childId) => assertNodeExists(tree, childId));
|
|
418
|
+
return typeof max === 'number' ? children.slice(0, max) : children;
|
|
419
|
+
}
|
|
420
|
+
export async function updateNode(filePathRaw, nodeId, input, options = {}) {
|
|
421
|
+
return mutateWithLock(filePathRaw, (tree) => updateNodeOnTree(tree, nodeId, input), options);
|
|
422
|
+
}
|
|
423
|
+
export async function deleteNode(filePathRaw, nodeId, input, options = {}) {
|
|
424
|
+
if (!input.yes) {
|
|
425
|
+
const file = resolveTreePath({ filePath: filePathRaw });
|
|
426
|
+
const tree = await readTree(file);
|
|
427
|
+
if (nodeId === ROOT_ID) {
|
|
428
|
+
throw new CliError('ROOT_IMMUTABLE', 'root node cannot be deleted');
|
|
429
|
+
}
|
|
430
|
+
assertNodeExists(tree, nodeId);
|
|
431
|
+
const ids = collectSubtreeIds(tree, nodeId);
|
|
432
|
+
return {
|
|
433
|
+
requires_confirmation: true,
|
|
434
|
+
count: ids.length,
|
|
435
|
+
ids
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
return mutateWithLock(filePathRaw, (tree) => deleteNodeOnTree(tree, nodeId, { cascade: input.cascade !== false, yes: true }), options);
|
|
439
|
+
}
|
|
440
|
+
export async function moveNode(filePathRaw, nodeId, newParentId, options = {}) {
|
|
441
|
+
return mutateWithLock(filePathRaw, (tree) => moveNodeOnTree(tree, nodeId, newParentId), options);
|
|
442
|
+
}
|
|
443
|
+
export async function findNodesInternal(filePathRaw, input) {
|
|
444
|
+
const file = resolveTreePath({ filePath: filePathRaw });
|
|
445
|
+
const tree = await readTree(file);
|
|
446
|
+
const parsed = parseQuery(input.query ?? '');
|
|
447
|
+
let nodes = Object.values(tree.nodes).filter((node) => matchesNode(node, parsed, new Date()));
|
|
448
|
+
const sort = parseSort(input.sort);
|
|
449
|
+
if (sort) {
|
|
450
|
+
nodes = nodes.sort((a, b) => {
|
|
451
|
+
const av = valueForSort(a[sort.field]);
|
|
452
|
+
const bv = valueForSort(b[sort.field]);
|
|
453
|
+
if (av < bv) {
|
|
454
|
+
return sort.direction === 'asc' ? -1 : 1;
|
|
455
|
+
}
|
|
456
|
+
if (av > bv) {
|
|
457
|
+
return sort.direction === 'asc' ? 1 : -1;
|
|
458
|
+
}
|
|
459
|
+
return 0;
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
if (typeof input.max === 'number') {
|
|
463
|
+
nodes = nodes.slice(0, input.max);
|
|
464
|
+
}
|
|
465
|
+
if (input.fields && input.fields.length > 0) {
|
|
466
|
+
return nodes.map((node) => {
|
|
467
|
+
const picked = {};
|
|
468
|
+
for (const field of input.fields ?? []) {
|
|
469
|
+
picked[field] = node[field];
|
|
470
|
+
}
|
|
471
|
+
return picked;
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
return nodes;
|
|
475
|
+
}
|
|
476
|
+
export async function validateTree(filePathRaw) {
|
|
477
|
+
const file = resolveTreePath({ filePath: filePathRaw });
|
|
478
|
+
const tree = await readTree(file);
|
|
479
|
+
return validateTreeObject(tree);
|
|
480
|
+
}
|
|
481
|
+
export async function upsertNode(filePathRaw, input, options = {}) {
|
|
482
|
+
return mutateWithLock(filePathRaw, (tree) => {
|
|
483
|
+
const parent = input.parent ?? ROOT_ID;
|
|
484
|
+
if (tree.nodes[input.id]) {
|
|
485
|
+
updateNodeOnTree(tree, input.id, { set: input.set });
|
|
486
|
+
const current = tree.nodes[input.id];
|
|
487
|
+
if (current.parent !== parent) {
|
|
488
|
+
moveNodeOnTree(tree, input.id, parent);
|
|
489
|
+
}
|
|
490
|
+
return { id: input.id, created: false, parent };
|
|
491
|
+
}
|
|
492
|
+
addNodeOnTree(tree, { id: input.id, parent, set: input.set });
|
|
493
|
+
return { id: input.id, created: true, parent };
|
|
494
|
+
}, options);
|
|
495
|
+
}
|
|
496
|
+
export async function applyBulkFromFile(filePathRaw, input, options = {}) {
|
|
497
|
+
const file = resolveTreePath({ filePath: filePathRaw });
|
|
498
|
+
const lockPath = getLockPath(file);
|
|
499
|
+
return withFileLock(lockPath, async () => {
|
|
500
|
+
const tree = await readTree(file);
|
|
501
|
+
const opsRaw = JSON.parse(await readFile(input.opsFile, 'utf-8'));
|
|
502
|
+
const ops = normalizeOps(opsRaw);
|
|
503
|
+
let atomicSnapshot = null;
|
|
504
|
+
if (input.atomic !== false) {
|
|
505
|
+
atomicSnapshot = await createSnapshotInternal(file, 'bulk-atomic');
|
|
506
|
+
}
|
|
507
|
+
else if (options.autoSnapshot !== false) {
|
|
508
|
+
await createSnapshotInternal(file, 'autosave');
|
|
509
|
+
}
|
|
510
|
+
const results = [];
|
|
511
|
+
try {
|
|
512
|
+
for (const op of ops) {
|
|
513
|
+
const result = applyOneBulkOp(tree, op);
|
|
514
|
+
results.push(result);
|
|
515
|
+
}
|
|
516
|
+
await writeTreeAtomic(file, tree);
|
|
517
|
+
await pruneSnapshots(file, options.snapshotKeep ?? defaultSnapshotKeep());
|
|
518
|
+
return {
|
|
519
|
+
applied: ops.length,
|
|
520
|
+
results
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
catch (err) {
|
|
524
|
+
if (atomicSnapshot) {
|
|
525
|
+
await copyFile(atomicSnapshot.path, file);
|
|
526
|
+
}
|
|
527
|
+
throw err;
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
export async function createSnapshot(filePathRaw, name) {
|
|
532
|
+
const file = resolveTreePath({ filePath: filePathRaw });
|
|
533
|
+
const lockPath = getLockPath(file);
|
|
534
|
+
return withFileLock(lockPath, async () => {
|
|
535
|
+
await readTree(file);
|
|
536
|
+
const info = await createSnapshotInternal(file, name);
|
|
537
|
+
await pruneSnapshots(file, defaultSnapshotKeep());
|
|
538
|
+
return info;
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
export async function restoreSnapshot(filePathRaw, snapshotId) {
|
|
542
|
+
const file = resolveTreePath({ filePath: filePathRaw });
|
|
543
|
+
const lockPath = getLockPath(file);
|
|
544
|
+
return withFileLock(lockPath, async () => {
|
|
545
|
+
const snapPath = join(snapshotDir(file), snapshotId);
|
|
546
|
+
const exists = await fileExists(snapPath);
|
|
547
|
+
if (!exists) {
|
|
548
|
+
throw new CliError('FILE_NOT_FOUND', `snapshot '${snapshotId}' not found`);
|
|
549
|
+
}
|
|
550
|
+
await mkdir(dirname(file), { recursive: true });
|
|
551
|
+
await copyFile(snapPath, file);
|
|
552
|
+
return { restored: snapshotId };
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
export async function listSnapshots(filePathRaw) {
|
|
556
|
+
const file = resolveTreePath({ filePath: filePathRaw });
|
|
557
|
+
const dir = snapshotDir(file);
|
|
558
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
559
|
+
return entries.filter((entry) => entry.isFile()).map((entry) => entry.name).sort();
|
|
560
|
+
}
|
|
561
|
+
export function parseSetPairs(pairs = []) {
|
|
562
|
+
const out = {};
|
|
563
|
+
for (const pair of pairs) {
|
|
564
|
+
const index = pair.indexOf('=');
|
|
565
|
+
if (index <= 0) {
|
|
566
|
+
throw new CliError('SCHEMA_INVALID', `invalid --set pair '${pair}', expected key=value`);
|
|
567
|
+
}
|
|
568
|
+
const key = pair.slice(0, index).trim();
|
|
569
|
+
const valueRaw = pair.slice(index + 1).trim();
|
|
570
|
+
if (!key) {
|
|
571
|
+
throw new CliError('SCHEMA_INVALID', `invalid --set pair '${pair}', missing key`);
|
|
572
|
+
}
|
|
573
|
+
let value = valueRaw;
|
|
574
|
+
if (valueRaw === 'null') {
|
|
575
|
+
value = null;
|
|
576
|
+
}
|
|
577
|
+
else if (valueRaw === 'true') {
|
|
578
|
+
value = true;
|
|
579
|
+
}
|
|
580
|
+
else if (valueRaw === 'false') {
|
|
581
|
+
value = false;
|
|
582
|
+
}
|
|
583
|
+
else if (!Number.isNaN(Number(valueRaw)) && valueRaw.length > 0) {
|
|
584
|
+
value = Number(valueRaw);
|
|
585
|
+
}
|
|
586
|
+
else {
|
|
587
|
+
try {
|
|
588
|
+
if ((valueRaw.startsWith('{') && valueRaw.endsWith('}')) || (valueRaw.startsWith('[') && valueRaw.endsWith(']'))) {
|
|
589
|
+
value = JSON.parse(valueRaw);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
value = valueRaw;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
out[key] = value;
|
|
597
|
+
}
|
|
598
|
+
return out;
|
|
599
|
+
}
|
|
600
|
+
export function parseSparkExpression(expression) {
|
|
601
|
+
const pairs = expression.match(/"[^"]+"|'[^']+'|\S+/g) ?? [];
|
|
602
|
+
const normalized = pairs.map((part) => {
|
|
603
|
+
const token = part.startsWith('"') || part.startsWith("'") ? part.slice(1, -1) : part;
|
|
604
|
+
const idx = token.indexOf(':');
|
|
605
|
+
if (idx <= 0) {
|
|
606
|
+
throw new CliError('SCHEMA_INVALID', `invalid spark token '${token}', expected key:value`);
|
|
607
|
+
}
|
|
608
|
+
return `${token.slice(0, idx)}=${token.slice(idx + 1)}`;
|
|
609
|
+
});
|
|
610
|
+
return parseSetPairs(normalized);
|
|
611
|
+
}
|
|
612
|
+
export function snapshotIdFromPath(pathValue) {
|
|
613
|
+
return parse(pathValue).base;
|
|
614
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ErrorCode } from './types.js';
|
|
2
|
+
export declare class CliError extends Error {
|
|
3
|
+
readonly code: ErrorCode;
|
|
4
|
+
readonly hint?: string;
|
|
5
|
+
constructor(code: ErrorCode, message: string, hint?: string);
|
|
6
|
+
}
|
|
7
|
+
export declare function isNodeErrorWithCode(err: unknown): err is NodeJS.ErrnoException;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export class CliError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
hint;
|
|
4
|
+
constructor(code, message, hint) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.hint = hint;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export function isNodeErrorWithCode(err) {
|
|
11
|
+
return Boolean(err && typeof err === 'object' && 'code' in err);
|
|
12
|
+
}
|
package/dist/id.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function generateNodeId(existing: Set<string>): string;
|
package/dist/id.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function generateNodeId(existing) {
|
|
2
|
+
for (let i = 0; i < 20; i += 1) {
|
|
3
|
+
const id = `n_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
4
|
+
if (!existing.has(id)) {
|
|
5
|
+
return id;
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
throw new Error('failed to generate unique node id');
|
|
9
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED