naro-cli 0.7.0 → 0.8.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 CHANGED
@@ -86,6 +86,12 @@ naro db enable Create the project's database (Cloudflare D1)
86
86
  naro db info Status, size, table count, limits
87
87
  naro db query "select 1" Run SQL; --file query.sql, or pipe stdin
88
88
  naro db push [--dry-run] Apply pending ./migrations (asks first; --yes in CI)
89
+ naro db dump --output backup.sql SQL dump, streamed from Cloudflare (stdout without
90
+ --output); --schema-only | --data-only, --table a,b
91
+ naro db restore --bookmark <id> Time Travel, last 30 days (or --timestamp <time>);
92
+ asks first, prints the command that undoes it
93
+ naro db pull Existing database, no migrations yet: writes its schema
94
+ as migrations/0001_remote_schema.sql, recorded as applied
89
95
  naro migration new create_users Creates migrations/0001_create_users.sql
90
96
  naro migration list Local files vs the d1_migrations ledger
91
97
  naro gen types --output src/db.d.ts TypeScript types for the schema
@@ -108,6 +114,14 @@ An apex (`example.com`) is added as a pair by default because most registrars
108
114
  goes on `www.example.com` and the apex answers with a 308 redirect to it.
109
115
  `--no-www` registers the apex alone.
110
116
 
117
+ `naro db dump` asks Cloudflare for the dump and streams it straight from
118
+ Cloudflare's storage to the file — the bytes never pass through naro. While the
119
+ export runs, Cloudflare holds every other query to the database, so dump a busy
120
+ production database when a short pause is acceptable. It ends with the bookmark
121
+ the dump reflects; `naro db restore --bookmark <that>` brings the database back
122
+ to it. A restore rolls back everything written after that point — the
123
+ `d1_migrations` ledger included — and prints the bookmark that undoes it.
124
+
111
125
  ## Machine-readable output (CI / AI agents)
112
126
 
113
127
  Every command supports `--json`: stdout carries exactly one JSON object,
@@ -18833,6 +18833,9 @@ var LogsApi = class {
18833
18833
  return body.entries;
18834
18834
  }
18835
18835
  };
18836
+ var EXPORT_FIRST_POLL_MS = 1e3;
18837
+ var EXPORT_MAX_POLL_MS = 5e3;
18838
+ var EXPORT_TIMEOUT_MS = 15 * 6e4;
18836
18839
  var DbApi = class {
18837
18840
  constructor(client) {
18838
18841
  this.client = client;
@@ -18903,9 +18906,127 @@ var DbApi = class {
18903
18906
  }
18904
18907
  );
18905
18908
  }
18909
+ /**
18910
+ * The current Time Travel bookmark, or the one `timestamp` (RFC 3339 with a
18911
+ * timezone, or Unix seconds) names. The server checks the range - the last
18912
+ * 30 days, not before the database existed - before asking Cloudflare.
18913
+ */
18914
+ async bookmark(projectId, options = {}) {
18915
+ const query = options.timestamp === void 0 ? "" : `?timestamp=${encodeURIComponent(options.timestamp)}`;
18916
+ const body = await this.client.request(
18917
+ this.path(projectId, `/bookmark${query}`),
18918
+ { context: "Read bookmark" }
18919
+ );
18920
+ return { bookmark: body.bookmark, timestamp: body.timestamp };
18921
+ }
18922
+ /**
18923
+ * Rolls the database back to a bookmark or a timestamp - rows, schema and
18924
+ * the migration ledger alike - and cancels queries in flight. Restoring to
18925
+ * `previousBookmark` undoes it.
18926
+ */
18927
+ async restore(projectId, target) {
18928
+ const given = target;
18929
+ const body = await this.client.request(
18930
+ this.path(projectId, "/restore"),
18931
+ {
18932
+ method: "POST",
18933
+ body: {
18934
+ ...given.bookmark !== void 0 ? { bookmark: given.bookmark } : {},
18935
+ ...given.timestamp !== void 0 ? { timestamp: given.timestamp } : {}
18936
+ },
18937
+ context: "Restore database"
18938
+ }
18939
+ );
18940
+ return { bookmark: body.bookmark, previousBookmark: body.previousBookmark };
18941
+ }
18942
+ /**
18943
+ * One step of an export: starts it, or - with the first step's
18944
+ * `atBookmark` as `currentBookmark` - polls it. See `export` for the loop.
18945
+ */
18946
+ async exportStep(projectId, options = {}) {
18947
+ const body = await this.client.request(
18948
+ this.path(projectId, "/export"),
18949
+ {
18950
+ method: "POST",
18951
+ // Only the keys given: the server validates each one it sees.
18952
+ body: {
18953
+ ...options.schemaOnly !== void 0 ? { schemaOnly: options.schemaOnly } : {},
18954
+ ...options.dataOnly !== void 0 ? { dataOnly: options.dataOnly } : {},
18955
+ ...options.tables !== void 0 ? { tables: options.tables } : {},
18956
+ ...options.currentBookmark !== void 0 ? { currentBookmark: options.currentBookmark } : {}
18957
+ },
18958
+ context: "Export database"
18959
+ }
18960
+ );
18961
+ return body.export;
18962
+ }
18963
+ /**
18964
+ * Exports the database and polls until the dump is ready. The server takes
18965
+ * one Cloudflare step per call - a big database runs for minutes, longer
18966
+ * than a Worker should wait - so the loop is here: 1 s, then 1.5x per poll
18967
+ * up to 5 s. Cloudflare holds every other query to the database while the
18968
+ * export runs.
18969
+ *
18970
+ * On TIMEOUT the export is still running at Cloudflare; nothing was
18971
+ * cancelled.
18972
+ */
18973
+ async export(projectId, options = {}, wait = {}) {
18974
+ const timeoutMs = wait.timeoutMs ?? EXPORT_TIMEOUT_MS;
18975
+ const sleep2 = wait.sleep ?? ((ms) => new Promise((resolve10) => setTimeout(resolve10, ms)));
18976
+ const given = wait.pollIntervalMs;
18977
+ const firstPollMs = given !== void 0 && Number.isFinite(given) && given > 0 ? given : EXPORT_FIRST_POLL_MS;
18978
+ const maxPollMs = Math.max(EXPORT_MAX_POLL_MS, firstPollMs);
18979
+ const startedAt = Date.now();
18980
+ let step = await this.exportStep(projectId, options);
18981
+ wait.onProgress?.(step);
18982
+ const currentBookmark = step.atBookmark;
18983
+ const polled = {
18984
+ ...options,
18985
+ ...step.tables !== void 0 ? { tables: step.tables } : {}
18986
+ };
18987
+ let intervalMs = firstPollMs;
18988
+ while (step.status !== "complete") {
18989
+ if (Date.now() - startedAt >= timeoutMs) {
18990
+ throw new NaroError(
18991
+ "TIMEOUT",
18992
+ `The export was still running after ${String(Math.round(timeoutMs / 6e4))} minutes. Cloudflare keeps it going; run naro db dump again later.`,
18993
+ { details: { atBookmark: currentBookmark } }
18994
+ );
18995
+ }
18996
+ await sleep2(intervalMs);
18997
+ intervalMs = Math.min(intervalMs * 1.5, maxPollMs);
18998
+ step = await this.exportStep(projectId, { ...polled, currentBookmark });
18999
+ wait.onProgress?.(step);
19000
+ }
19001
+ return step;
19002
+ }
19003
+ /**
19004
+ * Records the remote schema as the already-applied migration `name` and
19005
+ * returns its SQL - a baseline for a database that has none. Refused with
19006
+ * DATABASE_HAS_MIGRATIONS when the ledger has rows, DATABASE_EMPTY when
19007
+ * there is no schema. The caller writes the file.
19008
+ */
19009
+ async pull(projectId, input) {
19010
+ return this.client.request(this.path(projectId, "/pull"), {
19011
+ method: "POST",
19012
+ body: { name: input.name },
19013
+ context: "Pull schema"
19014
+ });
19015
+ }
18906
19016
  };
18907
19017
 
18908
19018
  // ../../packages/sdk/src/database.ts
19019
+ var BOOKMARK_RE = /^[0-9a-f]{8}-[0-9a-f]{8}-[0-9a-f]{8}-[0-9a-f]{32}$/;
19020
+ var BOOKMARK_FORMAT_MESSAGE = "bookmark must look like 00000001-00000008-000050ed-272153b8520cfb93b49834d93a1a1ab3 (four hyphen-separated hex groups, 59 characters).";
19021
+ function isBookmark(value) {
19022
+ return typeof value === "string" && BOOKMARK_RE.test(value);
19023
+ }
19024
+ var RESTORE_OUTCOME_UNKNOWN_CODES = /* @__PURE__ */ new Set([
19025
+ "DATABASE_RESTORE_FAILED",
19026
+ "DATABASE_UNAVAILABLE",
19027
+ "TIMEOUT",
19028
+ "NETWORK_ERROR"
19029
+ ]);
18909
19030
  var MAX_CELL_CHARS = 60;
18910
19031
  function formatCell(value) {
18911
19032
  if (value === null || value === void 0) return "NULL";
@@ -19905,6 +20026,9 @@ export {
19905
20026
  openBrowser,
19906
20027
  isTerminalStatus,
19907
20028
  NaroClient,
20029
+ BOOKMARK_FORMAT_MESSAGE,
20030
+ isBookmark,
20031
+ RESTORE_OUTCOME_UNKNOWN_CODES,
19908
20032
  renderRows,
19909
20033
  summarizeStatement,
19910
20034
  serializeDotenv,
package/dist/deploy.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  parseDotenv,
6
6
  readProjectEnv,
7
7
  slugify
8
- } from "./chunk-E2M6CQL7.js";
8
+ } from "./chunk-ONALKL7O.js";
9
9
  export {
10
10
  deployDirectory,
11
11
  eventsObserver,
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ BOOKMARK_FORMAT_MESSAGE,
3
4
  NaroClient,
4
5
  NaroError,
6
+ RESTORE_OUTCOME_UNKNOWN_CODES,
5
7
  clearSession,
6
8
  compareMigrations,
7
9
  controlPlaneUrl,
@@ -10,6 +12,7 @@ import {
10
12
  detectGitInfo,
11
13
  ensureGitignoreHasNaro,
12
14
  generateTypes,
15
+ isBookmark,
13
16
  isExpired,
14
17
  isNaroError,
15
18
  isTerminalStatus,
@@ -33,7 +36,7 @@ import {
33
36
  summarizeStatement,
34
37
  toNaroError,
35
38
  writeProjectLink
36
- } from "./chunk-E2M6CQL7.js";
39
+ } from "./chunk-ONALKL7O.js";
37
40
 
38
41
  // src/args.ts
39
42
  var BOOLEAN_FLAGS = {
@@ -52,7 +55,9 @@ var BOOLEAN_FLAGS = {
52
55
  "--reveal": "reveal",
53
56
  "--wait": "wait",
54
57
  "--no-www": "noWww",
55
- "--dry-run": "dryRun"
58
+ "--dry-run": "dryRun",
59
+ "--schema-only": "schemaOnly",
60
+ "--data-only": "dataOnly"
56
61
  };
57
62
  var STRING_FLAGS = {
58
63
  "--token": "token",
@@ -67,8 +72,15 @@ var STRING_FLAGS = {
67
72
  "--level": "level",
68
73
  "--file": "file",
69
74
  "--output": "output",
70
- "--dir": "dir"
75
+ "--dir": "dir",
76
+ "--table": "table",
77
+ "--timestamp": "timestamp",
78
+ "--bookmark": "bookmark"
71
79
  };
80
+ function listFlag(value) {
81
+ if (value === void 0) return [];
82
+ return value.split(",").map((piece) => piece.trim()).filter((piece) => piece.length > 0);
83
+ }
72
84
  function parseArgs(argv) {
73
85
  const flags = {
74
86
  json: false,
@@ -220,7 +232,7 @@ var TtyPrompter = class {
220
232
  });
221
233
  try {
222
234
  return await new Promise(
223
- (resolve3) => rl.question(question, resolve3)
235
+ (resolve4) => rl.question(question, resolve4)
224
236
  );
225
237
  } finally {
226
238
  rl.close();
@@ -241,7 +253,7 @@ var TtyPrompter = class {
241
253
  });
242
254
  try {
243
255
  const answer = new Promise(
244
- (resolve3) => rl.question(question, resolve3)
256
+ (resolve4) => rl.question(question, resolve4)
245
257
  );
246
258
  muted = true;
247
259
  const value = await answer;
@@ -353,7 +365,7 @@ function createContext(flags, positionals = []) {
353
365
 
354
366
  // src/router.ts
355
367
  import { readFileSync as readFileSync3 } from "fs";
356
- import { dirname as dirname2, join as join3 } from "path";
368
+ import { dirname as dirname3, join as join4 } from "path";
357
369
  import { fileURLToPath } from "url";
358
370
 
359
371
  // src/commands/auth.ts
@@ -566,11 +578,474 @@ async function runDbQuery(context, deps = processStdin) {
566
578
  context.output.result({ projectId, result }, lines);
567
579
  }
568
580
 
569
- // src/commands/git.ts
581
+ // src/commands/db-backup.ts
582
+ import {
583
+ createWriteStream,
584
+ mkdirSync as mkdirSync2,
585
+ renameSync,
586
+ rmSync,
587
+ statSync,
588
+ writeFileSync as writeFileSync2
589
+ } from "fs";
590
+ import { basename, dirname, join as join2, resolve as resolve2 } from "path";
591
+ import { Transform } from "stream";
592
+ import { pipeline } from "stream/promises";
593
+
594
+ // src/commands/migration.ts
595
+ import { mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
596
+ import { join, relative } from "path";
597
+ var processStdin2 = {
598
+ stdinIsTty: () => process.stdin.isTTY === true,
599
+ readStdin
600
+ };
601
+ function migrationsDir(context) {
602
+ return resolveMigrationsDir({
603
+ baseDir: context.cwd,
604
+ rootDirectory: context.flags.root,
605
+ dir: context.flags.dir
606
+ });
607
+ }
608
+ function relativeToCwd(context, path) {
609
+ const rel = relative(context.cwd, path);
610
+ return rel === "" ? "." : rel;
611
+ }
612
+ async function appliedMigrations(client, projectId) {
613
+ try {
614
+ return await client.db.migrations(projectId);
615
+ } catch (error) {
616
+ if (isNaroError(error) && error.code === "DATABASE_NOT_READY") return [];
617
+ throw error;
618
+ }
619
+ }
620
+ function printable(text) {
621
+ let out = "";
622
+ for (const char of text) {
623
+ const code = char.charCodeAt(0);
624
+ const control = code < 32 || code >= 127 && code <= 159;
625
+ out += control ? "?" : char;
626
+ }
627
+ return out;
628
+ }
629
+ function warningLine(warning) {
630
+ if (warning === "out-of-order")
631
+ return " \u26A0 older than an applied migration \u2014 db push will still apply it";
632
+ if (warning === "ledger-name-missing-extension")
633
+ return " \u26A0 recorded without .sql in the ledger";
634
+ return null;
635
+ }
636
+ async function runMigrationNew(context, deps = processStdin2) {
637
+ const [name] = context.positionalOrThrow(
638
+ 1,
639
+ "Usage: naro migration new <name>"
640
+ );
641
+ const dir = migrationsDir(context);
642
+ const fileName = nextMigrationFileName(
643
+ listMigrationFiles(dir),
644
+ name
645
+ );
646
+ mkdirSync(dir, { recursive: true });
647
+ const piped = !deps.stdinIsTty() ? await deps.readStdin() : "";
648
+ const fromStdin = piped.length > 0;
649
+ const body = fromStdin ? piped.endsWith("\n") ? piped : `${piped}
650
+ ` : `-- Migration: ${fileName}
651
+ `;
652
+ const path = join(dir, fileName);
653
+ writeFileSync(path, body, { flag: "wx" });
654
+ const relPath = relativeToCwd(context, path);
655
+ context.output.result({ file: fileName, path: relPath, fromStdin }, [
656
+ `\u2705 Created ${relPath}`
657
+ ]);
658
+ }
659
+ async function runMigrationList(context) {
660
+ const client = await context.client();
661
+ const projectId = await context.resolveProjectId();
662
+ const dir = migrationsDir(context);
663
+ const local = listMigrationFiles(dir);
664
+ const ledger = await appliedMigrations(client, projectId);
665
+ const migrations = compareMigrations(local, ledger);
666
+ const relDir = relativeToCwd(context, dir);
667
+ const lines = migrations.length === 0 ? ["No migrations yet. Create one with `naro migration new <name>`."] : [
668
+ `Migrations in ${relDir} (${String(migrations.length)}):`,
669
+ ...migrations.flatMap((row) => {
670
+ const line = ` ${row.status.padEnd(12)}${printable(row.name)}${row.appliedAt ? ` ${printable(row.appliedAt)}` : ""}`;
671
+ const warning = warningLine(row.warning);
672
+ return warning ? [line, warning] : [line];
673
+ })
674
+ ];
675
+ context.output.result({ projectId, dir: relDir, migrations }, lines);
676
+ }
677
+ async function runMigrationUp(context) {
678
+ const client = await context.client();
679
+ const projectId = await context.resolveProjectId();
680
+ const dir = migrationsDir(context);
681
+ const local = listMigrationFiles(dir);
682
+ const ledger = await appliedMigrations(client, projectId);
683
+ const pending = compareMigrations(local, ledger).filter((row) => row.status === "pending").map((row) => row.name);
684
+ if (pending.length === 0) {
685
+ context.output.result({ projectId, applied: [], pending: [] }, [
686
+ "No pending migrations."
687
+ ]);
688
+ return;
689
+ }
690
+ if (context.flags.dryRun) {
691
+ context.output.result({ projectId, dryRun: true, pending }, [
692
+ `Pending migrations (${String(pending.length)}):`,
693
+ ...pending.map((name) => ` ${printable(name)}`)
694
+ ]);
695
+ return;
696
+ }
697
+ context.output.log(`Pending migrations (${String(pending.length)}):`);
698
+ for (const name of pending) context.output.log(` ${printable(name)}`);
699
+ await confirmOrAbort(
700
+ context,
701
+ `Apply ${String(pending.length)} migration(s) to the project database? Each file is applied all-or-nothing; a failure stops the run.`
702
+ );
703
+ const applied = [];
704
+ for (const name of pending) {
705
+ const sql = readFileSync2(join(dir, name), "utf8");
706
+ try {
707
+ await client.db.applyMigration(projectId, { name, sql });
708
+ } catch (error) {
709
+ if (!isNaroError(error)) throw error;
710
+ const shown = context.output.json ? name : printable(name);
711
+ throw new NaroError(error.code, `${shown}: ${error.message}`, {
712
+ ...error.status === void 0 ? {} : { status: error.status },
713
+ details: { ...error.details, migration: name, applied },
714
+ cause: error
715
+ });
716
+ }
717
+ context.output.log(`\u2713 ${printable(name)}`);
718
+ applied.push(name);
719
+ }
720
+ context.output.result({ projectId, applied, pending: [] }, [
721
+ `\u2705 Applied ${String(applied.length)} migration(s).`
722
+ ]);
723
+ }
724
+
725
+ // src/commands/db-backup.ts
570
726
  var defaults = {
727
+ fetch: (input, init) => fetch(input, init),
728
+ stdout: process.stdout,
729
+ sleep: (ms) => new Promise((done) => setTimeout(done, ms)),
730
+ writeFile: (path, data) => {
731
+ mkdirSync2(dirname(path), { recursive: true });
732
+ writeFileSync2(path, data, { flag: "wx" });
733
+ }
734
+ };
735
+ var EXPORT_NOTICE = "Exporting the project database. Cloudflare holds other queries to this database until the export finishes.";
736
+ function reasonOf(error) {
737
+ return error instanceof Error ? error.message : String(error);
738
+ }
739
+ function withoutUrl(text, url) {
740
+ return text.split(url).join("the signed URL").replace(/\S*X-Amz-Signature=\S*/gi, "the signed URL");
741
+ }
742
+ function downloadFailed(url, reason, original) {
743
+ const scrubbed = withoutUrl(reason, url);
744
+ let cause;
745
+ if (original !== void 0) {
746
+ cause = new Error(scrubbed);
747
+ if (original instanceof Error) {
748
+ cause.name = original.name;
749
+ if (original.stack !== void 0)
750
+ cause.stack = withoutUrl(original.stack, url);
751
+ }
752
+ }
753
+ return new NaroError(
754
+ "DATABASE_UNAVAILABLE",
755
+ `Could not download the dump (${scrubbed}). Run naro db dump again.`,
756
+ cause === void 0 ? {} : { cause }
757
+ );
758
+ }
759
+ function writeFailed(shown, error) {
760
+ return new NaroError(
761
+ "INTERNAL_ERROR",
762
+ `Could not write ${shown}: ${reasonOf(error)}.`,
763
+ { cause: error }
764
+ );
765
+ }
766
+ var DownloadStreamError = class extends Error {
767
+ constructor(original) {
768
+ super(reasonOf(original));
769
+ this.original = original;
770
+ this.name = "DownloadStreamError";
771
+ }
772
+ };
773
+ async function openDownload(deps, url) {
774
+ let response;
775
+ try {
776
+ response = await deps.fetch(url);
777
+ } catch (error) {
778
+ throw downloadFailed(url, reasonOf(error), error);
779
+ }
780
+ if (!response.ok || response.body === null) {
781
+ await response.body?.cancel().catch(() => {
782
+ });
783
+ throw downloadFailed(url, `HTTP ${String(response.status)}`);
784
+ }
785
+ return response.body;
786
+ }
787
+ async function* chunksOf(body) {
788
+ const reader = body.getReader();
789
+ const read = async () => {
790
+ try {
791
+ return await reader.read();
792
+ } catch (error) {
793
+ throw new DownloadStreamError(error);
794
+ }
795
+ };
796
+ try {
797
+ for (; ; ) {
798
+ const next = await read();
799
+ if (next.done) return;
800
+ yield next.value;
801
+ }
802
+ } finally {
803
+ await reader.cancel().catch(() => {
804
+ });
805
+ }
806
+ }
807
+ async function streamInto(body, destination, end) {
808
+ let bytes = 0;
809
+ const counter = new Transform({
810
+ transform(chunk, _encoding, done) {
811
+ bytes += chunk.length;
812
+ done(null, chunk);
813
+ }
814
+ });
815
+ await pipeline(chunksOf(body), counter, destination, { end });
816
+ return bytes;
817
+ }
818
+ function streamFailed(error, url, shown) {
819
+ return error instanceof DownloadStreamError ? downloadFailed(url, reasonOf(error.original), error.original) : writeFailed(shown, error);
820
+ }
821
+ function refuseDirectory(path, shown) {
822
+ let isDirectory = false;
823
+ try {
824
+ isDirectory = statSync(path, { throwIfNoEntry: false })?.isDirectory() ?? false;
825
+ } catch {
826
+ }
827
+ if (isDirectory)
828
+ throw new NaroError(
829
+ "VALIDATION_FAILED",
830
+ `--output ${shown} is a directory. Pass a file path.`
831
+ );
832
+ }
833
+ function makeOutputDirectory(path, shown) {
834
+ try {
835
+ mkdirSync2(dirname(path), { recursive: true });
836
+ } catch (error) {
837
+ throw writeFailed(shown, error);
838
+ }
839
+ }
840
+ async function downloadToFile(deps, url, path, shown) {
841
+ const body = await openDownload(deps, url);
842
+ const partial = join2(
843
+ dirname(path),
844
+ `.${basename(path)}.${String(process.pid)}.part`
845
+ );
846
+ try {
847
+ const bytes = await streamInto(body, createWriteStream(partial), true);
848
+ renameSync(partial, path);
849
+ return bytes;
850
+ } catch (error) {
851
+ try {
852
+ rmSync(partial, { force: true });
853
+ } catch {
854
+ }
855
+ throw streamFailed(error, url, shown);
856
+ }
857
+ }
858
+ async function downloadToStdout(deps, url) {
859
+ const body = await openDownload(deps, url);
860
+ try {
861
+ return await streamInto(body, deps.stdout, false);
862
+ } catch (error) {
863
+ throw streamFailed(error, url, "stdout");
864
+ }
865
+ }
866
+ async function runDbDump(context, overrides = {}) {
867
+ const deps = { ...defaults, ...overrides };
868
+ const { flags } = context;
869
+ if (flags.schemaOnly && flags.dataOnly)
870
+ throw new NaroError(
871
+ "VALIDATION_FAILED",
872
+ "Pass --schema-only or --data-only, not both."
873
+ );
874
+ if (flags.json && flags.output === void 0)
875
+ throw new NaroError(
876
+ "VALIDATION_FAILED",
877
+ "--json needs --output: the dump and the JSON result would share stdout."
878
+ );
879
+ const tables = listFlag(flags.table);
880
+ if (flags.table !== void 0 && tables.length === 0)
881
+ throw new NaroError(
882
+ "VALIDATION_FAILED",
883
+ "--table needs at least one table name."
884
+ );
885
+ const options = {
886
+ ...flags.schemaOnly ? { schemaOnly: true } : {},
887
+ ...flags.dataOnly ? { dataOnly: true } : {},
888
+ ...tables.length > 0 ? { tables } : {}
889
+ };
890
+ const output = flags.output === void 0 ? null : { path: resolve2(context.cwd, flags.output), shown: flags.output };
891
+ if (output) refuseDirectory(output.path, output.shown);
892
+ const client = await context.client();
893
+ const projectId = await context.resolveProjectId();
894
+ if (output) makeOutputDirectory(output.path, output.shown);
895
+ context.output.warn(EXPORT_NOTICE);
896
+ const step = await client.db.export(projectId, options, {
897
+ sleep: deps.sleep
898
+ });
899
+ const file = step.file;
900
+ if (file === null)
901
+ throw new NaroError(
902
+ "DATABASE_UNAVAILABLE",
903
+ "Could not download the dump (Cloudflare finished the export without a file). Run naro db dump again."
904
+ );
905
+ const bytes = output === null ? await downloadToStdout(deps, file.url) : await downloadToFile(deps, file.url, output.path, output.shown);
906
+ const bookmark = step.atBookmark;
907
+ if (!context.output.json) {
908
+ context.output.warn(
909
+ `\u2713 Wrote ${flags.output ?? "stdout"} (${humanSize(bytes)}) at bookmark ${bookmark}.`
910
+ );
911
+ context.output.warn(
912
+ ` naro db restore --bookmark ${bookmark} returns the database to this point.`
913
+ );
914
+ }
915
+ context.output.result({ output: flags.output, bytes, atBookmark: bookmark });
916
+ }
917
+ async function bookmarkNow(context, client, projectId) {
918
+ try {
919
+ const { bookmark } = await client.db.bookmark(projectId);
920
+ return isBookmark(bookmark) ? bookmark : null;
921
+ } catch (error) {
922
+ context.output.debugLog(
923
+ `Could not read the current bookmark before restoring: ${reasonOf(error)}`
924
+ );
925
+ return null;
926
+ }
927
+ }
928
+ function withWayBack(context, error, before) {
929
+ if (before === null || !isNaroError(error) || !RESTORE_OUTCOME_UNKNOWN_CODES.has(error.code))
930
+ return error;
931
+ if (!context.output.json)
932
+ context.output.warn(
933
+ `Before this attempt the database was at bookmark ${before}. If the restore ran anyway, naro db restore --bookmark ${before} undoes it.`
934
+ );
935
+ return new NaroError(error.code, error.message, {
936
+ ...error.status === void 0 ? {} : { status: error.status },
937
+ details: { ...error.details, previousBookmark: before },
938
+ cause: error
939
+ });
940
+ }
941
+ async function runDbRestore(context) {
942
+ const { bookmark, timestamp } = context.flags;
943
+ const given = bookmark !== void 0 && timestamp === void 0 ? { bookmark } : timestamp !== void 0 && bookmark === void 0 ? { timestamp } : null;
944
+ if (given === null)
945
+ throw new NaroError(
946
+ "VALIDATION_FAILED",
947
+ "Pass --timestamp <time> or --bookmark <id> (exactly one)."
948
+ );
949
+ if ("bookmark" in given && !isBookmark(given.bookmark))
950
+ throw new NaroError("VALIDATION_FAILED", BOOKMARK_FORMAT_MESSAGE);
951
+ const client = await context.client();
952
+ const projectId = await context.resolveProjectId();
953
+ let target;
954
+ let at = null;
955
+ if ("bookmark" in given) {
956
+ target = given.bookmark;
957
+ } else {
958
+ const resolved = await client.db.bookmark(projectId, {
959
+ timestamp: given.timestamp
960
+ });
961
+ target = resolved.bookmark;
962
+ at = resolved.timestamp ?? given.timestamp;
963
+ }
964
+ const label = at === null ? `bookmark ${target}` : `${at} (bookmark ${target})`;
965
+ if (context.flags.dryRun) {
966
+ context.output.result(
967
+ {
968
+ dryRun: true,
969
+ bookmark: target,
970
+ ...at === null ? {} : { timestamp: at }
971
+ },
972
+ [`Would restore to ${label}. Nothing was changed.`]
973
+ );
974
+ return;
975
+ }
976
+ await confirmOrAbort(
977
+ context,
978
+ `Restore the project database to ${label}? Everything written after that point is rolled back \u2014 migration history included \u2014 and queries running right now are cancelled.`
979
+ );
980
+ const before = await bookmarkNow(context, client, projectId);
981
+ let restored;
982
+ try {
983
+ restored = await client.db.restore(projectId, { bookmark: target });
984
+ } catch (error) {
985
+ throw withWayBack(context, error, before);
986
+ }
987
+ context.output.result(
988
+ {
989
+ bookmark: restored.bookmark,
990
+ previousBookmark: restored.previousBookmark
991
+ },
992
+ [
993
+ `\u2713 Restored the project database to bookmark ${restored.bookmark}.`,
994
+ ` To undo: naro db restore --bookmark ${restored.previousBookmark}`
995
+ ]
996
+ );
997
+ }
998
+ async function runDbPull(context, overrides = {}) {
999
+ const deps = { ...defaults, ...overrides };
1000
+ const dir = migrationsDir(context);
1001
+ const existing = listMigrationFiles(dir);
1002
+ if (existing.length > 0)
1003
+ throw new NaroError(
1004
+ "VALIDATION_FAILED",
1005
+ `${relativeToCwd(context, dir)} already has ${String(existing.length)} migration file(s). naro db pull only baselines a project without migrations; to see the current schema, run naro db dump --schema-only.`
1006
+ );
1007
+ const name = nextMigrationFileName(existing, "remote_schema");
1008
+ const path = join2(dir, name);
1009
+ const shown = relativeToCwd(context, path);
1010
+ const client = await context.client();
1011
+ const projectId = await context.resolveProjectId();
1012
+ await confirmOrAbort(
1013
+ context,
1014
+ `Write ${shown} with the database's current schema and record it as already applied?`
1015
+ );
1016
+ const pulled = await client.db.pull(projectId, { name });
1017
+ try {
1018
+ deps.writeFile(path, `${pulled.sql}
1019
+ `);
1020
+ } catch (error) {
1021
+ if (!context.output.json) context.output.log(pulled.sql);
1022
+ throw new NaroError(
1023
+ "INTERNAL_ERROR",
1024
+ `Recorded ${pulled.name} as applied but could not write ${shown}: ${reasonOf(error)}. The SQL is on stdout \u2014 save it as that file.`,
1025
+ {
1026
+ details: { migration: pulled.name, path: shown, sql: pulled.sql },
1027
+ cause: error
1028
+ }
1029
+ );
1030
+ }
1031
+ context.output.result(
1032
+ {
1033
+ projectId,
1034
+ name: pulled.name,
1035
+ path: shown,
1036
+ statements: pulled.statements
1037
+ },
1038
+ [
1039
+ `\u2713 Wrote ${shown} (${String(pulled.statements)} ${pulled.statements === 1 ? "statement" : "statements"}) and recorded it as applied.`
1040
+ ]
1041
+ );
1042
+ }
1043
+
1044
+ // src/commands/git.ts
1045
+ var defaults2 = {
571
1046
  detectRemote: async (dir) => (await detectGitInfo(dir)).repositoryUrl,
572
1047
  openBrowser,
573
- sleep: (ms) => new Promise((resolve3) => setTimeout(resolve3, ms))
1048
+ sleep: (ms) => new Promise((resolve4) => setTimeout(resolve4, ms))
574
1049
  };
575
1050
  function connected(context, project) {
576
1051
  const branch = project.defaultBranch;
@@ -589,7 +1064,7 @@ function connected(context, project) {
589
1064
  );
590
1065
  }
591
1066
  async function runGitConnect(context, overrides = {}) {
592
- const deps = { ...defaults, ...overrides };
1067
+ const deps = { ...defaults2, ...overrides };
593
1068
  const { output } = context;
594
1069
  const explicit = context.positionals[0];
595
1070
  let fullName;
@@ -676,7 +1151,7 @@ async function runGitDisconnect(context) {
676
1151
  ]);
677
1152
  }
678
1153
  async function offerAutoDeploy(context, projectId, overrides) {
679
- const deps = { ...defaults, ...overrides };
1154
+ const deps = { ...defaults2, ...overrides };
680
1155
  if (overrides.canPrompt === false) return;
681
1156
  try {
682
1157
  if (readProjectLink(context.cwd)?.autoDeployDeclined === true) return;
@@ -1057,8 +1532,8 @@ async function runInspect(context) {
1057
1532
  }
1058
1533
 
1059
1534
  // src/commands/domains.ts
1060
- var defaults2 = {
1061
- sleep: (ms) => new Promise((resolve3) => setTimeout(resolve3, ms)),
1535
+ var defaults3 = {
1536
+ sleep: (ms) => new Promise((resolve4) => setTimeout(resolve4, ms)),
1062
1537
  now: () => Date.now()
1063
1538
  };
1064
1539
  var FIRST_DELAY_MS = 5e3;
@@ -1206,7 +1681,7 @@ async function verifyAll(client, projectId, hostname, auto = false) {
1206
1681
  return verified;
1207
1682
  }
1208
1683
  async function runDomainsVerify(context, overrides = {}) {
1209
- const deps = { ...defaults2, ...overrides };
1684
+ const deps = { ...defaults3, ...overrides };
1210
1685
  const client = await context.client();
1211
1686
  const projectId = await context.resolveProjectId();
1212
1687
  const hostname = context.positionals[0];
@@ -1301,8 +1776,8 @@ async function runDomainsRemove(context) {
1301
1776
  }
1302
1777
 
1303
1778
  // src/commands/env.ts
1304
- import { existsSync, writeFileSync } from "fs";
1305
- import { join } from "path";
1779
+ import { existsSync, writeFileSync as writeFileSync3 } from "fs";
1780
+ import { join as join3 } from "path";
1306
1781
  var TARGETS = /* @__PURE__ */ new Set(["production", "preview", "all"]);
1307
1782
  var KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
1308
1783
  function parseTarget(value) {
@@ -1405,12 +1880,12 @@ async function runEnvPull(context) {
1405
1880
  const vars = Object.fromEntries(
1406
1881
  selected.map((row) => [row.key, row.value ?? ""])
1407
1882
  );
1408
- const path = join(context.cwd, file);
1883
+ const path = join3(context.cwd, file);
1409
1884
  if (existsSync(path) && isInteractive() && !context.flags.yes && !context.flags.json) {
1410
1885
  const confirmed = await context.prompter.confirm(`Overwrite ${file}?`);
1411
1886
  if (!confirmed) throw new NaroError("CANCELED", "Canceled.");
1412
1887
  }
1413
- writeFileSync(
1888
+ writeFileSync3(
1414
1889
  path,
1415
1890
  `# Pulled from Naro (${surface}) \u2014 do not commit secrets.
1416
1891
  ${serializeDotenv(vars)}`,
@@ -1423,141 +1898,8 @@ ${serializeDotenv(vars)}`,
1423
1898
  }
1424
1899
 
1425
1900
  // src/commands/gen.ts
1426
- import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync3 } from "fs";
1427
- import { dirname, resolve as resolve2 } from "path";
1428
-
1429
- // src/commands/migration.ts
1430
- import { mkdirSync, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
1431
- import { join as join2, relative } from "path";
1432
- var processStdin2 = {
1433
- stdinIsTty: () => process.stdin.isTTY === true,
1434
- readStdin
1435
- };
1436
- function migrationsDir(context) {
1437
- return resolveMigrationsDir({
1438
- baseDir: context.cwd,
1439
- rootDirectory: context.flags.root,
1440
- dir: context.flags.dir
1441
- });
1442
- }
1443
- function relativeToCwd(context, path) {
1444
- const rel = relative(context.cwd, path);
1445
- return rel === "" ? "." : rel;
1446
- }
1447
- async function appliedMigrations(client, projectId) {
1448
- try {
1449
- return await client.db.migrations(projectId);
1450
- } catch (error) {
1451
- if (isNaroError(error) && error.code === "DATABASE_NOT_READY") return [];
1452
- throw error;
1453
- }
1454
- }
1455
- function printable(text) {
1456
- let out = "";
1457
- for (const char of text) {
1458
- const code = char.charCodeAt(0);
1459
- const control = code < 32 || code >= 127 && code <= 159;
1460
- out += control ? "?" : char;
1461
- }
1462
- return out;
1463
- }
1464
- function warningLine(warning) {
1465
- if (warning === "out-of-order")
1466
- return " \u26A0 older than an applied migration \u2014 db push will still apply it";
1467
- if (warning === "ledger-name-missing-extension")
1468
- return " \u26A0 recorded without .sql in the ledger";
1469
- return null;
1470
- }
1471
- async function runMigrationNew(context, deps = processStdin2) {
1472
- const [name] = context.positionalOrThrow(
1473
- 1,
1474
- "Usage: naro migration new <name>"
1475
- );
1476
- const dir = migrationsDir(context);
1477
- const fileName = nextMigrationFileName(
1478
- listMigrationFiles(dir),
1479
- name
1480
- );
1481
- mkdirSync(dir, { recursive: true });
1482
- const piped = !deps.stdinIsTty() ? await deps.readStdin() : "";
1483
- const fromStdin = piped.length > 0;
1484
- const body = fromStdin ? piped.endsWith("\n") ? piped : `${piped}
1485
- ` : `-- Migration: ${fileName}
1486
- `;
1487
- const path = join2(dir, fileName);
1488
- writeFileSync2(path, body, { flag: "wx" });
1489
- const relPath = relativeToCwd(context, path);
1490
- context.output.result({ file: fileName, path: relPath, fromStdin }, [
1491
- `\u2705 Created ${relPath}`
1492
- ]);
1493
- }
1494
- async function runMigrationList(context) {
1495
- const client = await context.client();
1496
- const projectId = await context.resolveProjectId();
1497
- const dir = migrationsDir(context);
1498
- const local = listMigrationFiles(dir);
1499
- const ledger = await appliedMigrations(client, projectId);
1500
- const migrations = compareMigrations(local, ledger);
1501
- const relDir = relativeToCwd(context, dir);
1502
- const lines = migrations.length === 0 ? ["No migrations yet. Create one with `naro migration new <name>`."] : [
1503
- `Migrations in ${relDir} (${String(migrations.length)}):`,
1504
- ...migrations.flatMap((row) => {
1505
- const line = ` ${row.status.padEnd(12)}${printable(row.name)}${row.appliedAt ? ` ${printable(row.appliedAt)}` : ""}`;
1506
- const warning = warningLine(row.warning);
1507
- return warning ? [line, warning] : [line];
1508
- })
1509
- ];
1510
- context.output.result({ projectId, dir: relDir, migrations }, lines);
1511
- }
1512
- async function runMigrationUp(context) {
1513
- const client = await context.client();
1514
- const projectId = await context.resolveProjectId();
1515
- const dir = migrationsDir(context);
1516
- const local = listMigrationFiles(dir);
1517
- const ledger = await appliedMigrations(client, projectId);
1518
- const pending = compareMigrations(local, ledger).filter((row) => row.status === "pending").map((row) => row.name);
1519
- if (pending.length === 0) {
1520
- context.output.result({ projectId, applied: [], pending: [] }, [
1521
- "No pending migrations."
1522
- ]);
1523
- return;
1524
- }
1525
- if (context.flags.dryRun) {
1526
- context.output.result({ projectId, dryRun: true, pending }, [
1527
- `Pending migrations (${String(pending.length)}):`,
1528
- ...pending.map((name) => ` ${printable(name)}`)
1529
- ]);
1530
- return;
1531
- }
1532
- context.output.log(`Pending migrations (${String(pending.length)}):`);
1533
- for (const name of pending) context.output.log(` ${printable(name)}`);
1534
- await confirmOrAbort(
1535
- context,
1536
- `Apply ${String(pending.length)} migration(s) to the project database? Each file is applied all-or-nothing; a failure stops the run.`
1537
- );
1538
- const applied = [];
1539
- for (const name of pending) {
1540
- const sql = readFileSync2(join2(dir, name), "utf8");
1541
- try {
1542
- await client.db.applyMigration(projectId, { name, sql });
1543
- } catch (error) {
1544
- if (!isNaroError(error)) throw error;
1545
- const shown = context.output.json ? name : printable(name);
1546
- throw new NaroError(error.code, `${shown}: ${error.message}`, {
1547
- ...error.status === void 0 ? {} : { status: error.status },
1548
- details: { ...error.details, migration: name, applied },
1549
- cause: error
1550
- });
1551
- }
1552
- context.output.log(`\u2713 ${printable(name)}`);
1553
- applied.push(name);
1554
- }
1555
- context.output.result({ projectId, applied, pending: [] }, [
1556
- `\u2705 Applied ${String(applied.length)} migration(s).`
1557
- ]);
1558
- }
1559
-
1560
- // src/commands/gen.ts
1901
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync4 } from "fs";
1902
+ import { dirname as dirname2, resolve as resolve3 } from "path";
1561
1903
  async function runGenTypes(context) {
1562
1904
  const client = await context.client();
1563
1905
  const projectId = await context.resolveProjectId();
@@ -1572,9 +1914,9 @@ async function runGenTypes(context) {
1572
1914
  );
1573
1915
  const extra = undescribed.length > 0 ? { undescribed } : {};
1574
1916
  if (context.flags.output) {
1575
- const path = resolve2(context.cwd, context.flags.output);
1576
- mkdirSync2(dirname(path), { recursive: true });
1577
- writeFileSync3(path, types);
1917
+ const path = resolve3(context.cwd, context.flags.output);
1918
+ mkdirSync3(dirname2(path), { recursive: true });
1919
+ writeFileSync4(path, types);
1578
1920
  context.output.result(
1579
1921
  {
1580
1922
  projectId,
@@ -1596,7 +1938,7 @@ async function runGenTypes(context) {
1596
1938
  // src/commands/logs.ts
1597
1939
  var FOLLOW_INTERVAL_MS = 3e3;
1598
1940
  function sleep(ms) {
1599
- return new Promise((resolve3) => setTimeout(resolve3, ms));
1941
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
1600
1942
  }
1601
1943
  function formatBuildLog(log) {
1602
1944
  return `[${log.stage}] ${log.level === "ERROR" ? "\u2717 " : ""}${log.message}`;
@@ -1884,6 +2226,12 @@ Usage:
1884
2226
  naro db info Database status, size, table count and limits
1885
2227
  naro db query <sql> | --file <f> Run SQL against it (reads stdin when piped)
1886
2228
  naro db push [--dry-run] Apply pending migrations from ./migrations (asks first; --yes to skip)
2229
+ naro db dump [--output <f>] Download a SQL dump of the database (stdout by default)
2230
+ [--schema-only|--data-only] [--table a,b] Cloudflare holds other queries until it finishes
2231
+ naro db restore --bookmark <id> Roll the database back (Time Travel, last 30 days; asks first)
2232
+ | --timestamp <time> [--dry-run] prints the command that undoes it
2233
+ naro db pull [--dir <d>] Write the remote schema as migrations/0001_remote_schema.sql
2234
+ and record it as applied - a baseline (asks first)
1887
2235
  naro migration new <name> Create migrations/NNNN_<name>.sql (stdin becomes the body when piped)
1888
2236
  naro migration list Local vs applied migrations
1889
2237
  naro migration up [--dry-run] Same as db push
@@ -1905,9 +2253,14 @@ Global options:
1905
2253
  --wait Poll until the domain goes live (naro domains verify)
1906
2254
  --no-www Register the apex alone (naro domains add)
1907
2255
  --file <path> SQL file to run (naro db query)
1908
- --dry-run List pending migrations without applying (naro db push)
1909
- --output <file> Write to a file instead of stdout (naro gen types)
2256
+ --dry-run Show what would change without changing it (naro db push, db restore)
2257
+ --output <file> Write to a file instead of stdout (naro gen types, db dump)
1910
2258
  --dir <path> Migrations directory (default ./migrations, or <--root>/migrations)
2259
+ --schema-only Dump the schema without rows (naro db dump)
2260
+ --data-only Dump rows without the schema (naro db dump)
2261
+ --table <a,b> Dump only these tables, comma-separated (naro db dump)
2262
+ --bookmark <id> Restore point: a bookmark from db dump or db restore (naro db restore)
2263
+ --timestamp <time> Restore point: RFC 3339 with a timezone, or Unix seconds (naro db restore)
1911
2264
  --debug Verbose diagnostics
1912
2265
 
1913
2266
  Credentials: \`naro login\` opens a browser (GitHub). CI: NARO_TOKEN=$(naro token).
@@ -2000,6 +2353,12 @@ function route(parsed) {
2000
2353
  return { handler: runDbQuery, positionals: positionals.slice(1) };
2001
2354
  case "push":
2002
2355
  return { handler: runMigrationUp, positionals: positionals.slice(1) };
2356
+ case "dump":
2357
+ return { handler: runDbDump, positionals: positionals.slice(1) };
2358
+ case "restore":
2359
+ return { handler: runDbRestore, positionals: positionals.slice(1) };
2360
+ case "pull":
2361
+ return { handler: runDbPull, positionals: positionals.slice(1) };
2003
2362
  default:
2004
2363
  return null;
2005
2364
  }
@@ -2045,7 +2404,7 @@ function version() {
2045
2404
  try {
2046
2405
  const pkg = JSON.parse(
2047
2406
  readFileSync3(
2048
- join3(dirname2(fileURLToPath(import.meta.url)), "..", "package.json"),
2407
+ join4(dirname3(fileURLToPath(import.meta.url)), "..", "package.json"),
2049
2408
  "utf8"
2050
2409
  )
2051
2410
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "naro-cli",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "naro": "./dist/index.js"
@@ -33,9 +33,9 @@
33
33
  "typescript": "7.0.2",
34
34
  "vitest": "3.2.7",
35
35
  "@next-deploy/build": "0.1.0",
36
- "@next-deploy/core": "0.1.0",
36
+ "@next-deploy/config": "0.1.0",
37
37
  "@next-deploy/sdk": "0.1.0",
38
- "@next-deploy/config": "0.1.0"
38
+ "@next-deploy/core": "0.1.0"
39
39
  },
40
40
  "scripts": {
41
41
  "build": "tsup && pnpm build:types",