@tolinax/ayoune-cli 2026.6.0 → 2026.7.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/lib/commands/createDbCommand.js +243 -197
- package/lib/db/copyEngine.js +28 -64
- package/lib/helpers/updateNotifier.js +32 -25
- package/package.json +1 -2
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
+
import inquirer from "inquirer";
|
|
2
3
|
import { spinner } from "../../index.js";
|
|
3
4
|
import { EXIT_GENERAL_ERROR, EXIT_MISUSE } from "../exitCodes.js";
|
|
4
5
|
import { cliError } from "../helpers/cliError.js";
|
|
5
6
|
import { handleResponseFormatOptions } from "../helpers/handleResponseFormatOptions.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { isCronDue, getNextRun, validateCron } from "../db/cronMatcher.js";
|
|
9
|
-
function generateId() {
|
|
10
|
-
return Math.random().toString(36).substring(2, 10) + Date.now().toString(36);
|
|
11
|
-
}
|
|
7
|
+
import { apiCallHandler } from "../api/apiCallHandler.js";
|
|
8
|
+
import { writeToTarget, getDbStats } from "../db/copyEngine.js";
|
|
12
9
|
function formatBytes(bytes) {
|
|
13
10
|
if (bytes === 0)
|
|
14
11
|
return "0 B";
|
|
@@ -16,242 +13,232 @@ function formatBytes(bytes) {
|
|
|
16
13
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
17
14
|
return (bytes / Math.pow(1024, i)).toFixed(1) + " " + units[i];
|
|
18
15
|
}
|
|
16
|
+
function maskUri(uri) {
|
|
17
|
+
return uri.replace(/:\/\/([^:]+):([^@]+)@/, "://***:***@");
|
|
18
|
+
}
|
|
19
19
|
export function createDbCommand(program) {
|
|
20
20
|
const db = program
|
|
21
21
|
.command("db")
|
|
22
|
-
.description("
|
|
22
|
+
.description("Copy query results to external databases, manage targets")
|
|
23
23
|
.addHelpText("after", `
|
|
24
24
|
Examples:
|
|
25
|
-
ay db copy
|
|
26
|
-
ay db copy
|
|
27
|
-
ay db
|
|
28
|
-
ay db
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
.
|
|
34
|
-
.option("--
|
|
35
|
-
.option("--
|
|
36
|
-
.option("--query <json>", "JSON filter query for source documents")
|
|
37
|
-
.option("--drop", "Drop target collection before copying")
|
|
38
|
-
.option("--upsert", "Upsert documents by _id (idempotent, safe to re-run)")
|
|
39
|
-
.option("--batch-size <number>", "Documents per batch", parseInt, 1000)
|
|
40
|
-
.option("--schedule <cron>", "Save as scheduled copy (cron expression) instead of executing")
|
|
25
|
+
ay db copy Interactive: pick target, pick query
|
|
26
|
+
ay db copy my-local-db Pick query for target "my-local-db"
|
|
27
|
+
ay db copy my-local-db active-users Fully autonomous: run query, write to target
|
|
28
|
+
ay db targets list List configured targets
|
|
29
|
+
ay db targets add Add a new target
|
|
30
|
+
ay db stats Show aYOUne database stats`);
|
|
31
|
+
// ─── ay db copy [target] [query] ───────────────────────────────
|
|
32
|
+
db.command("copy [target] [query]")
|
|
33
|
+
.description("Run a saved query and write results to a target database")
|
|
34
|
+
.option("--collection <name>", "Override target collection name")
|
|
35
|
+
.option("--write-mode <mode>", "Override write mode (insert, upsert, replace)")
|
|
41
36
|
.addHelpText("after", `
|
|
37
|
+
Runs a saved aggregation query and writes the results to a pre-configured target.
|
|
38
|
+
Both target and query are resolved by slug. If omitted, you'll be prompted to select.
|
|
39
|
+
|
|
42
40
|
Examples:
|
|
43
|
-
ay db copy
|
|
44
|
-
ay db copy
|
|
45
|
-
ay db copy
|
|
46
|
-
ay db copy
|
|
47
|
-
.action(async (options) => {
|
|
41
|
+
ay db copy Interactive mode
|
|
42
|
+
ay db copy my-local-db Select query interactively
|
|
43
|
+
ay db copy my-local-db active-users Fully autonomous
|
|
44
|
+
ay db copy my-local-db active-users --collection results --write-mode upsert`)
|
|
45
|
+
.action(async (target, query, options) => {
|
|
46
|
+
var _a;
|
|
48
47
|
try {
|
|
49
48
|
const opts = { ...program.opts(), ...options };
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
// Step 1: Resolve target
|
|
50
|
+
let targetData;
|
|
51
|
+
if (target) {
|
|
52
|
+
targetData = await resolveTarget(target);
|
|
53
53
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
cliError("--from must be a MongoDB URI (mongodb:// or mongodb+srv://)", EXIT_MISUSE);
|
|
54
|
+
else {
|
|
55
|
+
if (!process.stdin.isTTY)
|
|
56
|
+
cliError("Provide target slug or use interactive mode", EXIT_MISUSE);
|
|
57
|
+
targetData = await promptTarget();
|
|
59
58
|
}
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
// Step 2: Resolve query
|
|
60
|
+
let queryData;
|
|
61
|
+
if (query) {
|
|
62
|
+
queryData = await resolveQuery(query);
|
|
62
63
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
}
|
|
68
|
-
catch (_a) {
|
|
69
|
-
cliError("--query must be valid JSON", EXIT_MISUSE);
|
|
70
|
-
}
|
|
64
|
+
else {
|
|
65
|
+
if (!process.stdin.isTTY)
|
|
66
|
+
cliError("Provide query slug or use interactive mode", EXIT_MISUSE);
|
|
67
|
+
queryData = await promptQuery();
|
|
71
68
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
createdAt: new Date().toISOString(),
|
|
81
|
-
from: maskUri(opts.from),
|
|
82
|
-
to: maskUri(opts.to),
|
|
83
|
-
fromUri: opts.from, // Will be encrypted by addCopyConfig
|
|
84
|
-
toUri: opts.to,
|
|
85
|
-
collections,
|
|
86
|
-
query,
|
|
87
|
-
drop: opts.drop || false,
|
|
88
|
-
upsert: opts.upsert || false,
|
|
89
|
-
batchSize: opts.batchSize,
|
|
90
|
-
schedule: opts.schedule,
|
|
91
|
-
};
|
|
92
|
-
addCopyConfig(config);
|
|
93
|
-
const nextRun = getNextRun(opts.schedule);
|
|
94
|
-
console.log(chalk.green(`\n Scheduled copy saved (ID: ${config.id})`));
|
|
95
|
-
console.log(chalk.dim(` Source: ${config.from}`));
|
|
96
|
-
console.log(chalk.dim(` Target: ${config.to}`));
|
|
97
|
-
console.log(chalk.dim(` Collections: ${collections.join(", ")}`));
|
|
98
|
-
console.log(chalk.dim(` Schedule: ${opts.schedule}`));
|
|
99
|
-
if (nextRun)
|
|
100
|
-
console.log(chalk.dim(` Next run: ${nextRun.toISOString()}`));
|
|
101
|
-
console.log();
|
|
102
|
-
console.log(chalk.yellow(" Set up a cron job to run scheduled copies:"));
|
|
103
|
-
console.log(chalk.dim(" Linux/macOS: crontab -e → */5 * * * * ay db schedules run"));
|
|
104
|
-
console.log(chalk.dim(" Windows: schtasks /create /tn ayoune-db-sync /tr \"ay db schedules run\" /sc minute /mo 5"));
|
|
105
|
-
console.log();
|
|
69
|
+
// Step 3: Execute query via aggregation API
|
|
70
|
+
const queryId = queryData._id;
|
|
71
|
+
spinner.start({ text: `Running query "${queryData.name}"...`, color: "magenta" });
|
|
72
|
+
const aggResult = await apiCallHandler("aggregation", `queries/execute/${queryId}`, "post");
|
|
73
|
+
spinner.stop();
|
|
74
|
+
const docs = Array.isArray(aggResult) ? aggResult : ((aggResult === null || aggResult === void 0 ? void 0 : aggResult.payload) || []);
|
|
75
|
+
if (!docs.length) {
|
|
76
|
+
spinner.success({ text: "Query returned 0 results — nothing to write" });
|
|
106
77
|
return;
|
|
107
78
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}, (progress) => {
|
|
116
|
-
spinner.update({
|
|
117
|
-
text: `${progress.collection}: ${progress.copied}/${progress.total} docs${progress.errors ? chalk.red(` (${progress.errors} errors)`) : ""}`,
|
|
118
|
-
});
|
|
119
|
-
});
|
|
79
|
+
console.log(chalk.dim(` Query returned ${docs.length} documents\n`));
|
|
80
|
+
// Step 4: Write to target
|
|
81
|
+
const collection = opts.collection || targetData.targetCollection || ((_a = queryData.name) === null || _a === void 0 ? void 0 : _a.toLowerCase().replace(/\s+/g, "_")) || "query_results";
|
|
82
|
+
const writeMode = opts.writeMode || targetData.writeMode || "insert";
|
|
83
|
+
const batchSize = targetData.batchSize || 1000;
|
|
84
|
+
spinner.start({ text: `Writing ${docs.length} docs to ${maskUri(targetData.connectionUri)}/${collection}...`, color: "cyan" });
|
|
85
|
+
const result = await writeToTarget(targetData.connectionUri, targetData.database, collection, docs, writeMode, batchSize);
|
|
120
86
|
spinner.stop();
|
|
121
|
-
//
|
|
87
|
+
// Step 5: Update target status via config-api
|
|
88
|
+
try {
|
|
89
|
+
await apiCallHandler("config", `datatargets/${targetData._id}`, "put", {
|
|
90
|
+
lastUsed: new Date().toISOString(),
|
|
91
|
+
lastStatus: result.errors > 0 ? "failed" : "success",
|
|
92
|
+
lastResultCount: result.written,
|
|
93
|
+
lastError: result.errors > 0 ? `${result.errors} write errors` : undefined,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
catch (_b) {
|
|
97
|
+
// Non-critical — don't fail the copy if status update fails
|
|
98
|
+
}
|
|
99
|
+
// Output
|
|
122
100
|
const wrapped = {
|
|
123
|
-
payload:
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
101
|
+
payload: {
|
|
102
|
+
target: targetData.name,
|
|
103
|
+
query: queryData.name,
|
|
104
|
+
collection,
|
|
105
|
+
writeMode,
|
|
106
|
+
written: result.written,
|
|
107
|
+
errors: result.errors,
|
|
108
|
+
duration: result.duration,
|
|
128
109
|
},
|
|
110
|
+
meta: { written: result.written, errors: result.errors },
|
|
129
111
|
};
|
|
130
112
|
if (opts.responseFormat === "json" || opts.responseFormat === "yaml") {
|
|
131
113
|
handleResponseFormatOptions(opts, wrapped);
|
|
132
114
|
}
|
|
133
115
|
else {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const icon = col.errors === 0 ? chalk.green("●") : chalk.yellow("●");
|
|
137
|
-
console.log(` ${icon} ${col.name}: ${col.copied} docs copied${col.errors ? chalk.red(` (${col.errors} errors)`) : ""} ${chalk.dim(`(${col.duration}ms)`)}`);
|
|
138
|
-
}
|
|
139
|
-
console.log();
|
|
140
|
-
console.log(` ${chalk.green(summary.totalCopied + " total")}${summary.totalErrors ? ` ${chalk.red(summary.totalErrors + " errors")}` : ""} ${chalk.dim(`in ${summary.duration}ms`)}`);
|
|
141
|
-
console.log();
|
|
116
|
+
const icon = result.errors === 0 ? chalk.green("●") : chalk.yellow("●");
|
|
117
|
+
console.log(`\n ${icon} ${result.written} docs written to ${chalk.cyan(collection)}${result.errors ? chalk.red(` (${result.errors} errors)`) : ""} ${chalk.dim(`(${result.duration}ms)`)}\n`);
|
|
142
118
|
}
|
|
143
|
-
if (
|
|
119
|
+
if (result.errors > 0)
|
|
144
120
|
process.exit(EXIT_GENERAL_ERROR);
|
|
145
121
|
}
|
|
146
122
|
catch (e) {
|
|
147
123
|
cliError(e.message || "Copy failed", EXIT_GENERAL_ERROR);
|
|
148
124
|
}
|
|
149
125
|
});
|
|
150
|
-
// ─── ay db
|
|
151
|
-
const
|
|
152
|
-
// ay db
|
|
153
|
-
|
|
126
|
+
// ─── ay db targets ─────────────────────────────────────────────
|
|
127
|
+
const targets = db.command("targets").alias("t").description("Manage database targets");
|
|
128
|
+
// ay db targets list
|
|
129
|
+
targets
|
|
154
130
|
.command("list")
|
|
155
131
|
.alias("ls")
|
|
156
|
-
.description("List
|
|
132
|
+
.description("List configured database targets")
|
|
133
|
+
.option("-l, --limit <number>", "Limit results", parseInt, 50)
|
|
157
134
|
.action(async (options) => {
|
|
135
|
+
var _a, _b, _c, _d, _e;
|
|
158
136
|
try {
|
|
159
137
|
const opts = { ...program.opts(), ...options };
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
handleResponseFormatOptions(opts,
|
|
138
|
+
spinner.start({ text: "Fetching targets...", color: "cyan" });
|
|
139
|
+
const res = await apiCallHandler("config", "datatargets", "get", null, {
|
|
140
|
+
limit: opts.limit,
|
|
141
|
+
responseFormat: opts.responseFormat,
|
|
142
|
+
verbosity: opts.verbosity,
|
|
143
|
+
});
|
|
144
|
+
handleResponseFormatOptions(opts, res);
|
|
145
|
+
const total = (_e = (_c = (_b = (_a = res.meta) === null || _a === void 0 ? void 0 : _a.pageInfo) === null || _b === void 0 ? void 0 : _b.totalEntries) !== null && _c !== void 0 ? _c : (_d = res.payload) === null || _d === void 0 ? void 0 : _d.length) !== null && _e !== void 0 ? _e : 0;
|
|
146
|
+
spinner.success({ text: `${total} targets configured` });
|
|
167
147
|
}
|
|
168
148
|
catch (e) {
|
|
169
|
-
cliError(e.message || "Failed to list
|
|
149
|
+
cliError(e.message || "Failed to list targets", EXIT_GENERAL_ERROR);
|
|
170
150
|
}
|
|
171
151
|
});
|
|
172
|
-
// ay db
|
|
173
|
-
|
|
174
|
-
.command("
|
|
175
|
-
.
|
|
176
|
-
.
|
|
177
|
-
.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
152
|
+
// ay db targets add
|
|
153
|
+
targets
|
|
154
|
+
.command("add")
|
|
155
|
+
.description("Add a new database target")
|
|
156
|
+
.option("--name <name>", "Target name")
|
|
157
|
+
.option("--uri <uri>", "MongoDB connection URI")
|
|
158
|
+
.option("--database <db>", "Database name")
|
|
159
|
+
.option("--collection <col>", "Default target collection name")
|
|
160
|
+
.option("--write-mode <mode>", "Write mode: insert, upsert, replace", "insert")
|
|
161
|
+
.action(async (options) => {
|
|
162
|
+
var _a;
|
|
163
|
+
try {
|
|
164
|
+
const opts = { ...program.opts(), ...options };
|
|
165
|
+
let { name, uri, database, collection, writeMode } = opts;
|
|
166
|
+
// Interactive prompts for missing fields
|
|
167
|
+
if (!name || !uri) {
|
|
168
|
+
if (!process.stdin.isTTY)
|
|
169
|
+
cliError("Provide --name and --uri in non-interactive mode", EXIT_MISUSE);
|
|
170
|
+
const answers = await inquirer.prompt([
|
|
171
|
+
...(!name ? [{ type: "input", name: "name", message: "Target name:", validate: (v) => v.length > 0 || "Required" }] : []),
|
|
172
|
+
...(!uri ? [{ type: "input", name: "uri", message: "MongoDB connection URI:", validate: (v) => v.startsWith("mongodb") || "Must be a MongoDB URI" }] : []),
|
|
173
|
+
...(!database ? [{ type: "input", name: "database", message: "Database name (leave empty for URI default):" }] : []),
|
|
174
|
+
...(!collection ? [{ type: "input", name: "collection", message: "Default collection name (leave empty to derive from query):" }] : []),
|
|
175
|
+
]);
|
|
176
|
+
name = name || answers.name;
|
|
177
|
+
uri = uri || answers.uri;
|
|
178
|
+
database = database || answers.database || undefined;
|
|
179
|
+
collection = collection || answers.collection || undefined;
|
|
180
|
+
}
|
|
181
|
+
spinner.start({ text: "Creating target...", color: "cyan" });
|
|
182
|
+
const body = {
|
|
183
|
+
name,
|
|
184
|
+
type: "mongodb",
|
|
185
|
+
connectionUri: uri,
|
|
186
|
+
database: database || undefined,
|
|
187
|
+
targetCollection: collection || undefined,
|
|
188
|
+
writeMode: writeMode || "insert",
|
|
189
|
+
active: true,
|
|
190
|
+
};
|
|
191
|
+
const res = await apiCallHandler("config", "datatargets", "post", body, {
|
|
192
|
+
responseFormat: opts.responseFormat,
|
|
193
|
+
});
|
|
194
|
+
const slug = ((_a = res.payload) === null || _a === void 0 ? void 0 : _a.slug) || name;
|
|
195
|
+
spinner.success({ text: `Target "${slug}" created — use with: ay db copy ${slug}` });
|
|
181
196
|
}
|
|
182
|
-
|
|
183
|
-
cliError(
|
|
197
|
+
catch (e) {
|
|
198
|
+
cliError(e.message || "Failed to create target", EXIT_GENERAL_ERROR);
|
|
184
199
|
}
|
|
185
200
|
});
|
|
186
|
-
// ay db
|
|
187
|
-
|
|
188
|
-
.command("
|
|
189
|
-
.
|
|
190
|
-
.
|
|
191
|
-
.action(async (options) => {
|
|
201
|
+
// ay db targets remove <slug>
|
|
202
|
+
targets
|
|
203
|
+
.command("remove <slug>")
|
|
204
|
+
.alias("rm")
|
|
205
|
+
.description("Remove a database target")
|
|
206
|
+
.action(async (slug, options) => {
|
|
192
207
|
try {
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
: configs.filter((c) => isCronDue(c.schedule, c.lastRun, now));
|
|
198
|
-
if (toRun.length === 0) {
|
|
199
|
-
console.log(chalk.dim("\n No copies due.\n"));
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
console.log(chalk.cyan(`\n Running ${toRun.length} scheduled copies...\n`));
|
|
203
|
-
for (const config of toRun) {
|
|
204
|
-
const { fromUri, toUri } = getDecryptedConfig(config);
|
|
205
|
-
console.log(chalk.dim(` ${config.id}: ${config.from} → ${config.to}`));
|
|
206
|
-
try {
|
|
207
|
-
spinner.start({ text: `${config.id}: copying...`, color: "cyan" });
|
|
208
|
-
const summary = await executeCopy(fromUri, toUri, config.collections, {
|
|
209
|
-
query: config.query,
|
|
210
|
-
drop: config.drop,
|
|
211
|
-
upsert: config.upsert,
|
|
212
|
-
batchSize: config.batchSize,
|
|
213
|
-
}, (progress) => {
|
|
214
|
-
spinner.update({
|
|
215
|
-
text: `${config.id}: ${progress.collection} ${progress.copied}/${progress.total}`,
|
|
216
|
-
});
|
|
217
|
-
});
|
|
218
|
-
updateCopyConfigLastRun(config.id, "success");
|
|
219
|
-
spinner.success({ text: `${config.id}: ${summary.totalCopied} docs copied${summary.totalErrors ? `, ${summary.totalErrors} errors` : ""}` });
|
|
220
|
-
}
|
|
221
|
-
catch (e) {
|
|
222
|
-
updateCopyConfigLastRun(config.id, "failed", e.message);
|
|
223
|
-
spinner.error({ text: `${config.id}: ${e.message}` });
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
console.log();
|
|
208
|
+
const target = await resolveTarget(slug);
|
|
209
|
+
spinner.start({ text: `Removing target "${slug}"...`, color: "cyan" });
|
|
210
|
+
await apiCallHandler("config", `datatargets/${target._id}`, "delete");
|
|
211
|
+
spinner.success({ text: `Target "${slug}" removed` });
|
|
227
212
|
}
|
|
228
213
|
catch (e) {
|
|
229
|
-
cliError(e.message || "
|
|
214
|
+
cliError(e.message || "Failed to remove target", EXIT_GENERAL_ERROR);
|
|
230
215
|
}
|
|
231
216
|
});
|
|
232
|
-
// ─── ay db stats
|
|
233
|
-
db.command("stats
|
|
234
|
-
.description("Show database statistics (
|
|
217
|
+
// ─── ay db stats [uri] ─────────────────────────────────────────
|
|
218
|
+
db.command("stats [uri]")
|
|
219
|
+
.description("Show database statistics (defaults to aYOUne database)")
|
|
235
220
|
.addHelpText("after", `
|
|
221
|
+
Defaults to MONGO_CONNECTSTRING when no URI is given.
|
|
222
|
+
|
|
236
223
|
Examples:
|
|
237
|
-
ay db stats
|
|
238
|
-
ay db stats "mongodb://
|
|
224
|
+
ay db stats
|
|
225
|
+
ay db stats "mongodb://other/db" -r table`)
|
|
239
226
|
.action(async (uri, options) => {
|
|
240
227
|
try {
|
|
241
228
|
const opts = { ...program.opts(), ...options };
|
|
242
|
-
|
|
229
|
+
const dbUri = uri || process.env.MONGO_CONNECTSTRING || process.env.MONGODB_URI;
|
|
230
|
+
if (!dbUri) {
|
|
231
|
+
cliError("No database URI. Set MONGO_CONNECTSTRING env var or pass a URI.", EXIT_MISUSE);
|
|
232
|
+
}
|
|
233
|
+
if (!dbUri.startsWith("mongodb")) {
|
|
243
234
|
cliError("URI must start with mongodb:// or mongodb+srv://", EXIT_MISUSE);
|
|
244
235
|
}
|
|
245
|
-
spinner.start({ text: `Connecting to ${maskUri(
|
|
246
|
-
const stats = await getDbStats(
|
|
236
|
+
spinner.start({ text: `Connecting to ${maskUri(dbUri)}...`, color: "cyan" });
|
|
237
|
+
const stats = await getDbStats(dbUri);
|
|
247
238
|
spinner.stop();
|
|
248
239
|
const wrapped = {
|
|
249
240
|
payload: stats,
|
|
250
|
-
meta: {
|
|
251
|
-
database: stats.database,
|
|
252
|
-
collectionCount: stats.collections.length,
|
|
253
|
-
totalSize: formatBytes(stats.totalSize),
|
|
254
|
-
},
|
|
241
|
+
meta: { database: stats.database, collectionCount: stats.collections.length, totalSize: formatBytes(stats.totalSize) },
|
|
255
242
|
};
|
|
256
243
|
if (opts.responseFormat === "json" || opts.responseFormat === "yaml") {
|
|
257
244
|
handleResponseFormatOptions(opts, wrapped);
|
|
@@ -271,16 +258,75 @@ Examples:
|
|
|
271
258
|
}
|
|
272
259
|
});
|
|
273
260
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
261
|
+
// ─── Helpers ───────────────────────────────────────────────────────
|
|
262
|
+
async function resolveTarget(slugOrId) {
|
|
263
|
+
const isObjectId = /^[a-f0-9]{24}$/i.test(slugOrId);
|
|
264
|
+
if (isObjectId) {
|
|
265
|
+
const res = await apiCallHandler("config", `datatargets/${slugOrId}`, "get");
|
|
266
|
+
if (!(res === null || res === void 0 ? void 0 : res.payload))
|
|
267
|
+
cliError(`Target not found: ${slugOrId}`, EXIT_GENERAL_ERROR);
|
|
268
|
+
return res.payload;
|
|
269
|
+
}
|
|
270
|
+
// Resolve by slug
|
|
271
|
+
const res = await apiCallHandler("config", "datatargets", "get", null, { slug: slugOrId, limit: 1 });
|
|
272
|
+
const entries = res === null || res === void 0 ? void 0 : res.payload;
|
|
273
|
+
if (!entries || (Array.isArray(entries) && entries.length === 0)) {
|
|
274
|
+
cliError(`No target found with slug "${slugOrId}"`, EXIT_GENERAL_ERROR);
|
|
275
|
+
}
|
|
276
|
+
return Array.isArray(entries) ? entries[0] : entries;
|
|
277
|
+
}
|
|
278
|
+
async function resolveQuery(slugOrId) {
|
|
279
|
+
const isObjectId = /^[a-f0-9]{24}$/i.test(slugOrId);
|
|
280
|
+
if (isObjectId) {
|
|
281
|
+
const res = await apiCallHandler("config", `queries/${slugOrId}`, "get");
|
|
282
|
+
if (!(res === null || res === void 0 ? void 0 : res.payload))
|
|
283
|
+
cliError(`Query not found: ${slugOrId}`, EXIT_GENERAL_ERROR);
|
|
284
|
+
return res.payload;
|
|
285
|
+
}
|
|
286
|
+
const res = await apiCallHandler("config", "queries", "get", null, { slug: slugOrId, limit: 1 });
|
|
287
|
+
const entries = res === null || res === void 0 ? void 0 : res.payload;
|
|
288
|
+
if (!entries || (Array.isArray(entries) && entries.length === 0)) {
|
|
289
|
+
cliError(`No query found with slug "${slugOrId}"`, EXIT_GENERAL_ERROR);
|
|
290
|
+
}
|
|
291
|
+
return Array.isArray(entries) ? entries[0] : entries;
|
|
292
|
+
}
|
|
293
|
+
async function promptTarget() {
|
|
294
|
+
spinner.start({ text: "Loading targets...", color: "cyan" });
|
|
295
|
+
const res = await apiCallHandler("config", "datatargets", "get", null, { limit: 100, active: true });
|
|
296
|
+
spinner.stop();
|
|
297
|
+
const targets = (res === null || res === void 0 ? void 0 : res.payload) || [];
|
|
298
|
+
if (targets.length === 0) {
|
|
299
|
+
cliError("No targets configured. Add one with: ay db targets add", EXIT_GENERAL_ERROR);
|
|
300
|
+
}
|
|
301
|
+
const choices = targets.map((t) => ({
|
|
302
|
+
name: `${t.name} ${chalk.dim(`(${maskUri(t.connectionUri)})`)}`,
|
|
303
|
+
value: t,
|
|
304
|
+
}));
|
|
305
|
+
const { selected } = await inquirer.prompt([
|
|
306
|
+
{ type: "search-list", name: "selected", message: "Select target:", choices },
|
|
307
|
+
]);
|
|
308
|
+
return selected;
|
|
309
|
+
}
|
|
310
|
+
async function promptQuery() {
|
|
311
|
+
spinner.start({ text: "Loading queries...", color: "magenta" });
|
|
312
|
+
const res = await apiCallHandler("config", "queries", "get", null, {
|
|
313
|
+
limit: 100,
|
|
314
|
+
"dataSource.source": "aggregation",
|
|
315
|
+
});
|
|
316
|
+
spinner.stop();
|
|
317
|
+
const queries = (res === null || res === void 0 ? void 0 : res.payload) || [];
|
|
318
|
+
if (queries.length === 0) {
|
|
319
|
+
cliError("No saved queries found. Create one with: ay agg save", EXIT_GENERAL_ERROR);
|
|
320
|
+
}
|
|
321
|
+
const choices = queries.map((q) => {
|
|
322
|
+
var _a;
|
|
323
|
+
return ({
|
|
324
|
+
name: `${q.name} ${chalk.dim(`(${((_a = q.dataSource) === null || _a === void 0 ? void 0 : _a.aggregationModel) || "?"})`)}`,
|
|
325
|
+
value: q,
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
const { selected } = await inquirer.prompt([
|
|
329
|
+
{ type: "search-list", name: "selected", message: "Select query:", choices },
|
|
330
|
+
]);
|
|
331
|
+
return selected;
|
|
286
332
|
}
|
package/lib/db/copyEngine.js
CHANGED
|
@@ -1,67 +1,37 @@
|
|
|
1
1
|
import { MongoClient } from "mongodb";
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Writes aggregation query results to a target MongoDB collection.
|
|
4
|
+
* This is the core of the copy flow: query results from the aggregation API
|
|
5
|
+
* are written to an external MongoDB target.
|
|
6
|
+
*/
|
|
7
|
+
export async function writeToTarget(targetUri, database, collection, docs, writeMode, batchSize = 1000) {
|
|
3
8
|
const startTime = Date.now();
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
9
|
+
const client = new MongoClient(targetUri);
|
|
10
|
+
let written = 0;
|
|
11
|
+
let errors = 0;
|
|
7
12
|
try {
|
|
8
|
-
await
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if (collections.length === 1 && collections[0] === "*") {
|
|
15
|
-
const colls = await sourceDb.listCollections().toArray();
|
|
16
|
-
collectionNames = colls
|
|
17
|
-
.map((c) => c.name)
|
|
18
|
-
.filter((n) => !n.startsWith("system."));
|
|
19
|
-
}
|
|
20
|
-
for (const name of collectionNames) {
|
|
21
|
-
const colStart = Date.now();
|
|
22
|
-
let copied = 0;
|
|
23
|
-
let errors = 0;
|
|
24
|
-
const sourceCol = sourceDb.collection(name);
|
|
25
|
-
const targetCol = targetDb.collection(name);
|
|
26
|
-
if (options.drop) {
|
|
27
|
-
try {
|
|
28
|
-
await targetCol.drop();
|
|
29
|
-
}
|
|
30
|
-
catch (_a) {
|
|
31
|
-
// Collection may not exist — ignore
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
const query = options.query || {};
|
|
35
|
-
const total = await sourceCol.countDocuments(query);
|
|
36
|
-
onProgress({ collection: name, copied: 0, total, errors: 0 });
|
|
37
|
-
const cursor = sourceCol.find(query).batchSize(options.batchSize);
|
|
38
|
-
let batch = [];
|
|
39
|
-
for await (const doc of cursor) {
|
|
40
|
-
batch.push(doc);
|
|
41
|
-
if (batch.length >= options.batchSize) {
|
|
42
|
-
const result = await writeBatch(targetCol, batch, options.upsert || false);
|
|
43
|
-
copied += result.written;
|
|
44
|
-
errors += result.errors;
|
|
45
|
-
batch = [];
|
|
46
|
-
onProgress({ collection: name, copied, total, errors });
|
|
47
|
-
}
|
|
13
|
+
await client.connect();
|
|
14
|
+
const db = database ? client.db(database) : client.db();
|
|
15
|
+
const col = db.collection(collection);
|
|
16
|
+
if (writeMode === "replace") {
|
|
17
|
+
try {
|
|
18
|
+
await col.drop();
|
|
48
19
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const result = await writeBatch(targetCol, batch, options.upsert || false);
|
|
52
|
-
copied += result.written;
|
|
53
|
-
errors += result.errors;
|
|
54
|
-
onProgress({ collection: name, copied, total, errors });
|
|
20
|
+
catch (_a) {
|
|
21
|
+
// Collection may not exist
|
|
55
22
|
}
|
|
56
|
-
results.push({ name, copied, errors, duration: Date.now() - colStart });
|
|
57
23
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
24
|
+
// Write in batches
|
|
25
|
+
for (let i = 0; i < docs.length; i += batchSize) {
|
|
26
|
+
const batch = docs.slice(i, i + batchSize);
|
|
27
|
+
const result = await writeBatch(col, batch, writeMode === "upsert");
|
|
28
|
+
written += result.written;
|
|
29
|
+
errors += result.errors;
|
|
30
|
+
}
|
|
31
|
+
return { written, errors, duration: Date.now() - startTime };
|
|
61
32
|
}
|
|
62
33
|
finally {
|
|
63
|
-
await
|
|
64
|
-
await targetClient.close();
|
|
34
|
+
await client.close();
|
|
65
35
|
}
|
|
66
36
|
}
|
|
67
37
|
async function writeBatch(collection, docs, upsert) {
|
|
@@ -84,10 +54,8 @@ async function writeBatch(collection, docs, upsert) {
|
|
|
84
54
|
}
|
|
85
55
|
}
|
|
86
56
|
catch (e) {
|
|
87
|
-
// Partial success on ordered:false — count what made it through
|
|
88
57
|
const written = (_d = (_b = (_a = e.result) === null || _a === void 0 ? void 0 : _a.nInserted) !== null && _b !== void 0 ? _b : (_c = e.result) === null || _c === void 0 ? void 0 : _c.insertedCount) !== null && _d !== void 0 ? _d : 0;
|
|
89
|
-
|
|
90
|
-
return { written, errors: errorCount };
|
|
58
|
+
return { written, errors: docs.length - written };
|
|
91
59
|
}
|
|
92
60
|
}
|
|
93
61
|
export async function getDbStats(uri) {
|
|
@@ -111,11 +79,7 @@ export async function getDbStats(uri) {
|
|
|
111
79
|
}
|
|
112
80
|
}
|
|
113
81
|
collections.sort((a, b) => b.documents - a.documents);
|
|
114
|
-
return {
|
|
115
|
-
database: stats.db,
|
|
116
|
-
collections,
|
|
117
|
-
totalSize: stats.dataSize || 0,
|
|
118
|
-
};
|
|
82
|
+
return { database: stats.db, collections, totalSize: stats.dataSize || 0 };
|
|
119
83
|
}
|
|
120
84
|
finally {
|
|
121
85
|
await client.close();
|
|
@@ -1,43 +1,50 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { exec } from "child_process";
|
|
2
2
|
import chalk from "chalk";
|
|
3
3
|
import { localStorage } from "./localStorage.js";
|
|
4
4
|
const PKG_NAME = "@tolinax/ayoune-cli";
|
|
5
5
|
const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours
|
|
6
6
|
/**
|
|
7
|
-
* Non-blocking update check.
|
|
8
|
-
*
|
|
9
|
-
* Prints a notice to stderr if a newer version is available.
|
|
7
|
+
* Non-blocking update check. Fires an async npm view in the background.
|
|
8
|
+
* Shows cached notice immediately if one exists; refreshes the cache asynchronously.
|
|
10
9
|
*/
|
|
11
10
|
export function checkForUpdates(currentVersion) {
|
|
12
11
|
try {
|
|
13
|
-
// Skip in CI environments
|
|
14
12
|
if (process.env.CI || process.env.AYOUNE_NO_UPDATE_CHECK)
|
|
15
13
|
return;
|
|
16
|
-
//
|
|
14
|
+
// Show cached notice immediately (zero latency)
|
|
15
|
+
const cached = localStorage.getItem("updateCheckLatest");
|
|
16
|
+
if (cached && cached !== currentVersion && isNewerVersion(cached, currentVersion)) {
|
|
17
|
+
printUpdateNotice(currentVersion, cached);
|
|
18
|
+
}
|
|
19
|
+
// Throttle the actual npm check
|
|
17
20
|
const lastCheck = localStorage.getItem("updateCheckTimestamp");
|
|
18
|
-
if (lastCheck && Date.now() - parseInt(lastCheck, 10) < CHECK_INTERVAL_MS)
|
|
19
|
-
// Still show cached notice if there was one
|
|
20
|
-
const cached = localStorage.getItem("updateCheckLatest");
|
|
21
|
-
if (cached && cached !== currentVersion) {
|
|
22
|
-
printUpdateNotice(currentVersion, cached);
|
|
23
|
-
}
|
|
21
|
+
if (lastCheck && Date.now() - parseInt(lastCheck, 10) < CHECK_INTERVAL_MS)
|
|
24
22
|
return;
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
printUpdateNotice(currentVersion, latest);
|
|
36
|
-
}
|
|
23
|
+
// Fire-and-forget: async check, never blocks startup
|
|
24
|
+
exec(`npm view ${PKG_NAME} version`, { timeout: 5000 }, (err, stdout) => {
|
|
25
|
+
if (err)
|
|
26
|
+
return;
|
|
27
|
+
const latest = stdout.trim();
|
|
28
|
+
if (latest) {
|
|
29
|
+
localStorage.setItem("updateCheckTimestamp", String(Date.now()));
|
|
30
|
+
localStorage.setItem("updateCheckLatest", latest);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
37
33
|
}
|
|
38
34
|
catch (_a) {
|
|
39
|
-
// Silently ignore
|
|
35
|
+
// Silently ignore
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function isNewerVersion(latest, current) {
|
|
39
|
+
const l = latest.split(".").map(Number);
|
|
40
|
+
const c = current.split(".").map(Number);
|
|
41
|
+
for (let i = 0; i < Math.max(l.length, c.length); i++) {
|
|
42
|
+
if ((l[i] || 0) > (c[i] || 0))
|
|
43
|
+
return true;
|
|
44
|
+
if ((l[i] || 0) < (c[i] || 0))
|
|
45
|
+
return false;
|
|
40
46
|
}
|
|
47
|
+
return false;
|
|
41
48
|
}
|
|
42
49
|
function printUpdateNotice(current, latest) {
|
|
43
50
|
const msg = [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tolinax/ayoune-cli",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.7.0",
|
|
4
4
|
"description": "CLI for the aYOUne Business-as-a-Service platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
@@ -119,7 +119,6 @@
|
|
|
119
119
|
"chalk": "^5.3.0",
|
|
120
120
|
"chalk-animation": "^2.0.3",
|
|
121
121
|
"commander": "^12.0.0",
|
|
122
|
-
"cron-parser": "^4.9.0",
|
|
123
122
|
"figlet": "^1.7.0",
|
|
124
123
|
"gradient-string": "^2.0.2",
|
|
125
124
|
"inquirer": "^9.2.14",
|