@thingd/cli 0.79.1 → 0.81.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/dist/data-movement.d.ts.map +1 -1
- package/dist/data-movement.js +185 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +45 -1
- package/dist/native-archive.d.ts +3 -0
- package/dist/native-archive.d.ts.map +1 -0
- package/dist/native-archive.js +134 -0
- package/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"data-movement.d.ts","sourceRoot":"","sources":["../src/data-movement.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"data-movement.d.ts","sourceRoot":"","sources":["../src/data-movement.ts"],"names":[],"mappings":"AAqBA,OAAO,EACL,KAAK,UAAU,EAOhB,MAAM,YAAY,CAAC;AAwEpB,wBAAsB,SAAS,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAyClE;AAED,wBAAsB,SAAS,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAsFlE;AAoND,wBAAsB,WAAW,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAmMpE;AAoHD,wBAAsB,SAAS,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,IAAI,CAAC,CAyClE"}
|
package/dist/data-movement.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { once } from "node:events";
|
|
2
|
+
import { createReadStream, createWriteStream, existsSync, readFileSync, writeFileSync, } from "node:fs";
|
|
2
3
|
import { resolve } from "node:path";
|
|
3
|
-
import {
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { Readable } from "node:stream";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import { hasFlag, requiredFlag, resolveConnection, stringFlag, withDb, writeJson, } from "./index.js";
|
|
4
8
|
const SIDECAR_DEFAULT_URL = "http://localhost:8757";
|
|
5
9
|
const DEFAULT_REDACT_KEYS = [
|
|
6
10
|
"password",
|
|
@@ -336,31 +340,48 @@ async function runImportDb(context, source, explicitType) {
|
|
|
336
340
|
}
|
|
337
341
|
export async function runSnapshot(context) {
|
|
338
342
|
const subCommand = context.parsed.tokens[1];
|
|
343
|
+
const connection = resolveConnection(context);
|
|
344
|
+
if (connection.cloud) {
|
|
345
|
+
await runRemoteSnapshot(context, subCommand);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
339
348
|
if (subCommand === "create") {
|
|
340
349
|
const outPath = requiredFlag(context.parsed, "out");
|
|
341
350
|
await withDb(context, async (db) => {
|
|
342
|
-
const
|
|
351
|
+
const output = createWriteStream(resolve(outPath), "utf8");
|
|
352
|
+
const writeRecord = async (record) => {
|
|
353
|
+
if (!output.write(`${JSON.stringify(record)}\n`)) {
|
|
354
|
+
await once(output, "drain");
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
await writeRecord({
|
|
358
|
+
type: "thingd.snapshot",
|
|
359
|
+
version: "2.0.0",
|
|
360
|
+
timestamp: new Date().toISOString(),
|
|
361
|
+
format: "jsonl",
|
|
362
|
+
});
|
|
343
363
|
const cols = await db.listCollections();
|
|
344
364
|
for (const col of cols) {
|
|
345
|
-
|
|
365
|
+
const objects = await db.listObjects(col);
|
|
366
|
+
for (const object of objects) {
|
|
367
|
+
await writeRecord({ type: "object", collection: col, object });
|
|
368
|
+
}
|
|
346
369
|
}
|
|
347
370
|
const eventsList = await db.events.list();
|
|
348
|
-
const
|
|
371
|
+
for (const event of eventsList) {
|
|
372
|
+
await writeRecord({ type: "event", event });
|
|
373
|
+
}
|
|
349
374
|
const queues = await db.listQueues();
|
|
350
375
|
for (const q of queues) {
|
|
351
376
|
const queue = db.queue(q);
|
|
352
377
|
const [active, dead] = await Promise.all([queue.list(), queue.dead()]);
|
|
353
|
-
|
|
378
|
+
for (const job of [...active, ...dead]) {
|
|
379
|
+
await writeRecord({ type: "queue", job });
|
|
380
|
+
}
|
|
354
381
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
collections: collectionsMap,
|
|
359
|
-
events: eventsList,
|
|
360
|
-
queues: queuesMap,
|
|
361
|
-
};
|
|
362
|
-
writeFileSync(resolve(outPath), JSON.stringify(snapshot, null, 2), "utf8");
|
|
363
|
-
writeJson(context.stdout, { success: true, out: outPath }, context.pretty);
|
|
382
|
+
output.end();
|
|
383
|
+
await once(output, "close");
|
|
384
|
+
writeJson(context.stdout, { success: true, out: outPath, format: "thingd-snapshot-jsonl", version: "2.0.0" }, context.pretty);
|
|
364
385
|
});
|
|
365
386
|
}
|
|
366
387
|
else if (subCommand === "restore") {
|
|
@@ -369,13 +390,65 @@ export async function runSnapshot(context) {
|
|
|
369
390
|
if (!existsSync(resolvedPath)) {
|
|
370
391
|
throw new Error(`Snapshot file not found: ${inPath}`);
|
|
371
392
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
393
|
+
if (inPath.endsWith(".jsonl") || inPath.endsWith(".ndjson")) {
|
|
394
|
+
await restoreJsonlStream(context, inPath, resolvedPath);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const content = readFileSync(resolvedPath, "utf8");
|
|
398
|
+
const firstLine = content
|
|
399
|
+
.split("\n")
|
|
400
|
+
.find((line) => line.trim().length > 0)
|
|
401
|
+
?.trim() ?? "";
|
|
402
|
+
const isJsonl = firstLine.includes('"type":"thingd.snapshot"');
|
|
403
|
+
const snapshot = isJsonl ? null : JSON.parse(content);
|
|
404
|
+
if (!isJsonl && snapshot?.version !== "1.0.0") {
|
|
405
|
+
throw new Error(`Unsupported snapshot version: ${snapshot?.version ?? "unknown"}`);
|
|
375
406
|
}
|
|
376
407
|
context.stderr.write("Restoring snapshot... (consider 'thingd backup --out pre-restore.db' first)\n");
|
|
377
408
|
await withDb(context, async (db) => {
|
|
378
409
|
try {
|
|
410
|
+
if (isJsonl) {
|
|
411
|
+
const records = content
|
|
412
|
+
.split("\n")
|
|
413
|
+
.map((line) => line.trim())
|
|
414
|
+
.filter(Boolean)
|
|
415
|
+
.map((line) => JSON.parse(line));
|
|
416
|
+
const header = records.shift();
|
|
417
|
+
if (header?.type !== "thingd.snapshot" || header.version !== "2.0.0") {
|
|
418
|
+
throw new Error(`Unsupported JSONL snapshot version: ${String(header?.version ?? "unknown")}`);
|
|
419
|
+
}
|
|
420
|
+
for (const record of records) {
|
|
421
|
+
if (record.type === "object") {
|
|
422
|
+
const object = record.object;
|
|
423
|
+
const cleanObject = { ...object };
|
|
424
|
+
delete cleanObject.collection;
|
|
425
|
+
delete cleanObject.createdAt;
|
|
426
|
+
delete cleanObject.updatedAt;
|
|
427
|
+
delete cleanObject.version;
|
|
428
|
+
await db.put(String(record.collection), cleanObject);
|
|
429
|
+
}
|
|
430
|
+
else if (record.type === "event") {
|
|
431
|
+
const event = record.event;
|
|
432
|
+
const cleanEvent = { ...event };
|
|
433
|
+
delete cleanEvent.id;
|
|
434
|
+
delete cleanEvent.createdAt;
|
|
435
|
+
delete cleanEvent.stream;
|
|
436
|
+
await db.events.append(event.stream, cleanEvent);
|
|
437
|
+
}
|
|
438
|
+
else if (record.type === "queue") {
|
|
439
|
+
const job = record.job;
|
|
440
|
+
await db.queue(job.queue).push(job.payload, {
|
|
441
|
+
idempotencyKey: job.id,
|
|
442
|
+
maxAttempts: job.maxAttempts,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
throw new Error(`Unsupported snapshot record type: ${String(record.type)}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
writeJson(context.stdout, { success: true, in: inPath, format: "thingd-snapshot-jsonl", records: records.length }, context.pretty);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
379
452
|
// 1. Restore Collections (clear existing first for true restore)
|
|
380
453
|
if (snapshot.collections) {
|
|
381
454
|
for (const [colName, objects] of Object.entries(snapshot.collections)) {
|
|
@@ -438,6 +511,100 @@ export async function runSnapshot(context) {
|
|
|
438
511
|
throw new Error(`Unknown snapshot command: ${subCommand}. Expected 'create' or 'restore'.`);
|
|
439
512
|
}
|
|
440
513
|
}
|
|
514
|
+
async function restoreJsonlStream(context, inPath, resolvedPath) {
|
|
515
|
+
context.stderr.write("Restoring snapshot... (consider 'thingd backup --out pre-restore.db' first)\n");
|
|
516
|
+
await withDb(context, async (db) => {
|
|
517
|
+
const input = createInterface({ input: createReadStream(resolvedPath), crlfDelay: Infinity });
|
|
518
|
+
let headerSeen = false;
|
|
519
|
+
let records = 0;
|
|
520
|
+
try {
|
|
521
|
+
for await (const line of input) {
|
|
522
|
+
if (!line.trim()) {
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
const record = JSON.parse(line);
|
|
526
|
+
if (!headerSeen) {
|
|
527
|
+
if (record.type !== "thingd.snapshot" || record.version !== "2.0.0") {
|
|
528
|
+
throw new Error(`Unsupported JSONL snapshot version: ${String(record.version ?? "unknown")}`);
|
|
529
|
+
}
|
|
530
|
+
headerSeen = true;
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (record.type === "object") {
|
|
534
|
+
const object = record.object;
|
|
535
|
+
const cleanObject = { ...object };
|
|
536
|
+
delete cleanObject.collection;
|
|
537
|
+
delete cleanObject.createdAt;
|
|
538
|
+
delete cleanObject.updatedAt;
|
|
539
|
+
delete cleanObject.version;
|
|
540
|
+
await db.put(String(record.collection), cleanObject);
|
|
541
|
+
}
|
|
542
|
+
else if (record.type === "event") {
|
|
543
|
+
const event = record.event;
|
|
544
|
+
const cleanEvent = { ...event };
|
|
545
|
+
delete cleanEvent.id;
|
|
546
|
+
delete cleanEvent.createdAt;
|
|
547
|
+
delete cleanEvent.stream;
|
|
548
|
+
await db.events.append(event.stream, cleanEvent);
|
|
549
|
+
}
|
|
550
|
+
else if (record.type === "queue") {
|
|
551
|
+
const job = record.job;
|
|
552
|
+
await db.queue(job.queue).push(job.payload, {
|
|
553
|
+
idempotencyKey: job.id,
|
|
554
|
+
maxAttempts: job.maxAttempts,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
throw new Error(`Unsupported snapshot record type: ${String(record.type)}`);
|
|
559
|
+
}
|
|
560
|
+
records += 1;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
finally {
|
|
564
|
+
input.close();
|
|
565
|
+
}
|
|
566
|
+
if (!headerSeen) {
|
|
567
|
+
throw new Error("Snapshot is missing its header");
|
|
568
|
+
}
|
|
569
|
+
writeJson(context.stdout, { success: true, in: inPath, format: "thingd-snapshot-jsonl", records }, context.pretty);
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
async function runRemoteSnapshot(context, subCommand) {
|
|
573
|
+
const inPath = stringFlag(context.parsed, "in");
|
|
574
|
+
const outPath = stringFlag(context.parsed, "out");
|
|
575
|
+
const base = resolveSidecarUrl(context);
|
|
576
|
+
const headers = authHeaders(context);
|
|
577
|
+
if (subCommand === "create") {
|
|
578
|
+
if (!outPath) {
|
|
579
|
+
throw new Error("Expected --out <path> for remote snapshot export.");
|
|
580
|
+
}
|
|
581
|
+
const response = await fetch(`${base}/v1/snapshot`, { headers });
|
|
582
|
+
if (!response.ok || !response.body) {
|
|
583
|
+
throw new Error(`Remote snapshot export failed: HTTP ${response.status}`);
|
|
584
|
+
}
|
|
585
|
+
await pipeline(Readable.fromWeb(response.body), createWriteStream(resolve(outPath)));
|
|
586
|
+
writeJson(context.stdout, { success: true, out: outPath, format: "thingd-snapshot-jsonl", remote: true }, context.pretty);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (subCommand === "restore") {
|
|
590
|
+
if (!inPath) {
|
|
591
|
+
throw new Error("Expected --in <path> for remote snapshot import.");
|
|
592
|
+
}
|
|
593
|
+
const response = await fetch(`${base}/v1/snapshot`, {
|
|
594
|
+
method: "POST",
|
|
595
|
+
headers: { ...headers, "content-type": "application/x-ndjson" },
|
|
596
|
+
body: createReadStream(resolve(inPath)),
|
|
597
|
+
duplex: "half",
|
|
598
|
+
});
|
|
599
|
+
const result = (await response.json());
|
|
600
|
+
if (!response.ok) {
|
|
601
|
+
throw new Error(`Remote snapshot import failed: ${JSON.stringify(result)}`);
|
|
602
|
+
}
|
|
603
|
+
writeJson(context.stdout, result, context.pretty);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
throw new Error(`Unknown snapshot command: ${subCommand}. Expected 'create' or 'restore'.`);
|
|
607
|
+
}
|
|
441
608
|
export async function runBackup(context) {
|
|
442
609
|
const inPath = stringFlag(context.parsed, "in");
|
|
443
610
|
const outPath = stringFlag(context.parsed, "out");
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAeA,OAAO,EAYL,MAAM,EACN,KAAK,YAAY,EAClB,MAAM,aAAa,CAAC;AASrB,KAAK,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;AAEjD,KAAK,YAAY,GAAG;IAClB,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B,CAAC;AAEF,KAAK,YAAY,GAAG,MAAM,CAAC,cAAc,CAAC;AAE1C,MAAM,MAAM,aAAa,GAAG;IAC1B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,KAAK,CAAC,EAAE,YAAY,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IACvB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IAC7B,QAAQ,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IACvB,MAAM,EAAE,UAAU,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,YAAY,CAAC;IACrB,MAAM,EAAE,YAAY,CAAC;IACrB,KAAK,EAAE,YAAY,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;CACjB,CAAC;AAsCF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,OAAO,CAAC;IACf,sDAAsD;IACtD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAeA,OAAO,EAYL,MAAM,EACN,KAAK,YAAY,EAClB,MAAM,aAAa,CAAC;AASrB,KAAK,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;AAEjD,KAAK,YAAY,GAAG;IAClB,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B,CAAC;AAEF,KAAK,YAAY,GAAG,MAAM,CAAC,cAAc,CAAC;AAE1C,MAAM,MAAM,aAAa,GAAG;IAC1B,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,KAAK,CAAC,EAAE,YAAY,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IACvB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IAC7B,QAAQ,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IACvB,MAAM,EAAE,UAAU,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,YAAY,CAAC;IACrB,MAAM,EAAE,YAAY,CAAC;IACrB,KAAK,EAAE,YAAY,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;CACjB,CAAC;AAsCF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,OAAO,CAAC;IACf,sDAAsD;IACtD,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAuGF,wBAAsB,MAAM,CAC1B,IAAI,WAAwB,EAC5B,OAAO,GAAE,aAAkB,GAC1B,OAAO,CAAC,MAAM,CAAC,CA8EjB;AAwxCD,wBAAsB,MAAM,CAC1B,OAAO,EAAE,UAAU,EACnB,QAAQ,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GACtC,OAAO,CAAC,IAAI,CAAC,CAgBf;AA4BD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,UAAU,GAAG,iBAAiB,CAuDxE;AAiDD,wBAAgB,OAAO,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAEjE;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAE/E;AAED,wBAAgB,WAAW,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAGlF;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAMrE;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEnF;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAMrF;AAyFD,wBAAgB,SAAS,CAAC,MAAM,EAAE,YAAY,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,GAAG,IAAI,CAEpF;AAED,wBAAgB,SAAS,CAAC,MAAM,EAAE,YAAY,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAElE"}
|
package/dist/index.js
CHANGED
|
@@ -106,6 +106,8 @@ Usage:
|
|
|
106
106
|
thingd cloud api-key create <project> <name>
|
|
107
107
|
thingd db checkpoint [--path <path>]
|
|
108
108
|
thingd db integrity [--path <path>]
|
|
109
|
+
thingd db backup --out <archive.tar> [--path <path>]
|
|
110
|
+
thingd db restore --in <archive.tar> --destination <path> [--replace]
|
|
109
111
|
thingd db reencrypt --source <path> --destination <path> [--allow-plaintext-output]
|
|
110
112
|
|
|
111
113
|
Options:
|
|
@@ -329,11 +331,19 @@ async function runCommand(context) {
|
|
|
329
331
|
await runDbIntegrity(context);
|
|
330
332
|
return;
|
|
331
333
|
}
|
|
334
|
+
if (sub === "backup") {
|
|
335
|
+
await runDbNativeBackup(context);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (sub === "restore") {
|
|
339
|
+
await runDbNativeRestore(context);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
332
342
|
if (sub === "reencrypt") {
|
|
333
343
|
await runDbReencrypt(context);
|
|
334
344
|
return;
|
|
335
345
|
}
|
|
336
|
-
throw new Error(`Unknown db subcommand: ${sub}. Expected: checkpoint, integrity, reencrypt`);
|
|
346
|
+
throw new Error(`Unknown db subcommand: ${sub}. Expected: checkpoint, integrity, backup, restore, reencrypt`);
|
|
337
347
|
}
|
|
338
348
|
throw new Error(`Unknown command: ${command}`);
|
|
339
349
|
}
|
|
@@ -427,6 +437,40 @@ async function runDbIntegrity(context) {
|
|
|
427
437
|
}
|
|
428
438
|
});
|
|
429
439
|
}
|
|
440
|
+
async function runDbNativeBackup(context) {
|
|
441
|
+
const output = stringFlag(context.parsed, "out");
|
|
442
|
+
if (!output) {
|
|
443
|
+
throw new Error("db backup requires --out <archive.tar>");
|
|
444
|
+
}
|
|
445
|
+
const connection = resolveConnection(context);
|
|
446
|
+
if (connection.cloud || connection.driver !== "native") {
|
|
447
|
+
throw new Error("db backup requires a local native database; logical snapshots support remote stores");
|
|
448
|
+
}
|
|
449
|
+
const db = await ThingD.open({
|
|
450
|
+
path: connection.path,
|
|
451
|
+
driver: "native",
|
|
452
|
+
encryption: connection.encryptionKey ? { key: connection.encryptionKey } : undefined,
|
|
453
|
+
});
|
|
454
|
+
try {
|
|
455
|
+
db.walCheckpoint();
|
|
456
|
+
}
|
|
457
|
+
finally {
|
|
458
|
+
await db.close();
|
|
459
|
+
}
|
|
460
|
+
const { createNativeArchive } = await import("./native-archive.js");
|
|
461
|
+
await createNativeArchive(connection.path, output);
|
|
462
|
+
writeJson(context.stdout, { ok: true, out: resolve(output), format: "thingd-native-tar" }, context.pretty);
|
|
463
|
+
}
|
|
464
|
+
async function runDbNativeRestore(context) {
|
|
465
|
+
const input = stringFlag(context.parsed, "in");
|
|
466
|
+
const destination = stringFlag(context.parsed, "destination");
|
|
467
|
+
if (!input || !destination) {
|
|
468
|
+
throw new Error("db restore requires --in <archive.tar> and --destination <path>");
|
|
469
|
+
}
|
|
470
|
+
const { restoreNativeArchive } = await import("./native-archive.js");
|
|
471
|
+
await restoreNativeArchive(input, destination, hasFlag(context.parsed, "replace"));
|
|
472
|
+
writeJson(context.stdout, { ok: true, destination: resolve(destination), format: "thingd-native-tar" }, context.pretty);
|
|
473
|
+
}
|
|
430
474
|
async function runCompletions(context) {
|
|
431
475
|
const shell = optionalToken(context.parsed, 1) ?? "bash";
|
|
432
476
|
const cmds = [
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"native-archive.d.ts","sourceRoot":"","sources":["../src/native-archive.ts"],"names":[],"mappings":"AA0DA,wBAAsB,mBAAmB,CAAC,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAoC/F;AAED,wBAAsB,oBAAoB,CACxC,WAAW,EAAE,MAAM,EACnB,eAAe,EAAE,MAAM,EACvB,OAAO,EAAE,OAAO,GACf,OAAO,CAAC,IAAI,CAAC,CAkEf"}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const METADATA_NAME = "thingd-backup.json";
|
|
8
|
+
async function runTar(args) {
|
|
9
|
+
try {
|
|
10
|
+
await execFileAsync("tar", args, { maxBuffer: 16 * 1024 * 1024 });
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
14
|
+
throw new Error(`tar operation failed: ${detail}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function assertDirectory(path, label) {
|
|
18
|
+
if (!existsSync(path) || !lstatSync(path).isDirectory()) {
|
|
19
|
+
throw new Error(`${label} must be an existing directory: ${path}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function assertArchivePathSafe(path) {
|
|
23
|
+
if (path.startsWith("/") || path.startsWith("\\") || /^[A-Za-z]:[\\/]/.test(path)) {
|
|
24
|
+
throw new Error(`Unsafe archive path: ${path}`);
|
|
25
|
+
}
|
|
26
|
+
const normalized = path.replaceAll("\\", "/");
|
|
27
|
+
if (normalized.split("/").includes("..")) {
|
|
28
|
+
throw new Error(`Unsafe archive path: ${path}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function archiveEntries(archive) {
|
|
32
|
+
const result = execFileSync("tar", ["-tf", archive], {
|
|
33
|
+
encoding: "utf8",
|
|
34
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
35
|
+
});
|
|
36
|
+
return result.split(/\r?\n/).filter(Boolean);
|
|
37
|
+
}
|
|
38
|
+
export async function createNativeArchive(sourcePath, outputPath) {
|
|
39
|
+
const source = resolve(sourcePath);
|
|
40
|
+
const output = resolve(outputPath);
|
|
41
|
+
assertDirectory(source, "Native database");
|
|
42
|
+
const outputRelative = relative(source, output);
|
|
43
|
+
if (outputRelative && !outputRelative.startsWith("..") && !isAbsolute(outputRelative)) {
|
|
44
|
+
throw new Error("Backup archive must be outside the source database directory");
|
|
45
|
+
}
|
|
46
|
+
if (existsSync(output)) {
|
|
47
|
+
throw new Error(`Backup destination already exists: ${output}`);
|
|
48
|
+
}
|
|
49
|
+
mkdirSync(dirname(output), { recursive: true });
|
|
50
|
+
const staging = mkdtempSync(join(tmpdir(), "thingd-backup-"));
|
|
51
|
+
const metadata = {
|
|
52
|
+
format: "thingd-native-tar",
|
|
53
|
+
version: 1,
|
|
54
|
+
sourceName: basename(source),
|
|
55
|
+
createdAt: new Date().toISOString(),
|
|
56
|
+
};
|
|
57
|
+
const metadataPath = join(staging, METADATA_NAME);
|
|
58
|
+
writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, "utf8");
|
|
59
|
+
try {
|
|
60
|
+
await runTar([
|
|
61
|
+
"-cf",
|
|
62
|
+
output,
|
|
63
|
+
"-C",
|
|
64
|
+
dirname(source),
|
|
65
|
+
basename(source),
|
|
66
|
+
"-C",
|
|
67
|
+
staging,
|
|
68
|
+
METADATA_NAME,
|
|
69
|
+
]);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
rmSync(staging, { recursive: true, force: true });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export async function restoreNativeArchive(archivePath, destinationPath, replace) {
|
|
76
|
+
const archive = resolve(archivePath);
|
|
77
|
+
const destination = resolve(destinationPath);
|
|
78
|
+
if (!existsSync(archive) || !lstatSync(archive).isFile()) {
|
|
79
|
+
throw new Error(`Backup archive not found: ${archive}`);
|
|
80
|
+
}
|
|
81
|
+
if (existsSync(destination) && !replace) {
|
|
82
|
+
throw new Error(`Restore destination already exists: ${destination}; pass --replace to replace it`);
|
|
83
|
+
}
|
|
84
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
85
|
+
const entries = archiveEntries(archive);
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
assertArchivePathSafe(entry);
|
|
88
|
+
}
|
|
89
|
+
if (!entries.includes(METADATA_NAME)) {
|
|
90
|
+
throw new Error(`Invalid Thingd archive: missing ${METADATA_NAME}`);
|
|
91
|
+
}
|
|
92
|
+
const roots = new Set(entries
|
|
93
|
+
.filter((entry) => entry !== METADATA_NAME)
|
|
94
|
+
.map((entry) => entry.split("/")[0])
|
|
95
|
+
.filter(Boolean));
|
|
96
|
+
if (roots.size !== 1) {
|
|
97
|
+
throw new Error("Invalid Thingd archive: expected exactly one database directory");
|
|
98
|
+
}
|
|
99
|
+
const stagingParent = mkdtempSync(join(dirname(destination), ".thingd-restore-"));
|
|
100
|
+
const root = [...roots][0];
|
|
101
|
+
if (!root) {
|
|
102
|
+
throw new Error("Invalid Thingd archive: missing database directory");
|
|
103
|
+
}
|
|
104
|
+
const staging = join(stagingParent, root);
|
|
105
|
+
let previous;
|
|
106
|
+
try {
|
|
107
|
+
await runTar(["-xf", archive, "-C", stagingParent]);
|
|
108
|
+
const metadata = JSON.parse(readFileSync(join(stagingParent, METADATA_NAME), "utf8"));
|
|
109
|
+
if (metadata.format !== "thingd-native-tar" || metadata.version !== 1) {
|
|
110
|
+
throw new Error("Invalid Thingd archive metadata");
|
|
111
|
+
}
|
|
112
|
+
assertDirectory(staging, "Restored native database");
|
|
113
|
+
if (!existsSync(join(staging, "lock")) || !existsSync(join(staging, "keyspaces"))) {
|
|
114
|
+
throw new Error("Invalid Thingd archive: missing lock or keyspaces directory");
|
|
115
|
+
}
|
|
116
|
+
if (replace && existsSync(destination)) {
|
|
117
|
+
previous = `${destination}.previous-${Date.now()}`;
|
|
118
|
+
renameSync(destination, previous);
|
|
119
|
+
}
|
|
120
|
+
renameSync(staging, destination);
|
|
121
|
+
if (previous) {
|
|
122
|
+
rmSync(previous, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (previous && !existsSync(destination) && existsSync(previous)) {
|
|
127
|
+
renameSync(previous, destination);
|
|
128
|
+
}
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
rmSync(stagingParent, { recursive: true, force: true });
|
|
133
|
+
}
|
|
134
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thingd/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.81.0",
|
|
4
4
|
"description": "CLI, Interactive TUI Dashboard, and MCP server for thingd — a fast object-first data engine for applications and AI agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"homepage": "https://engine.thingd.cloud",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
44
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
45
|
-
"@thingd/sdk": "^0.
|
|
45
|
+
"@thingd/sdk": "^0.81.0",
|
|
46
46
|
"cli-table3": "^0.6.5",
|
|
47
47
|
"picocolors": "^1.1.1",
|
|
48
48
|
"zod": "^4.4.3"
|