@batadata/cli 0.1.13 → 0.1.15

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.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * bata claim <token> — redeem a claimable database into your team.
3
+ *
4
+ * Authed. Resolves the team (like `bata create`), calls
5
+ * POST /v1/claim/redeem { claim_token, team_id }, and on success the project
6
+ * becomes a permanent member of your team (its TTL is cleared). Typed,
7
+ * agent-friendly errors for unknown / expired / already-claimed tokens.
8
+ */
9
+ export interface ClaimArgs {
10
+ token?: string;
11
+ help: boolean;
12
+ }
13
+ /** Parse `bata claim <token>` args. Exported for tests. */
14
+ export declare function parseClaimArgs(args: string[]): ClaimArgs;
15
+ export declare function claim(args: string[]): Promise<void>;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * bata claim <token> — redeem a claimable database into your team.
3
+ *
4
+ * Authed. Resolves the team (like `bata create`), calls
5
+ * POST /v1/claim/redeem { claim_token, team_id }, and on success the project
6
+ * becomes a permanent member of your team (its TTL is cleared). Typed,
7
+ * agent-friendly errors for unknown / expired / already-claimed tokens.
8
+ */
9
+ import { api, apiError, resolveTeamId } from "../api.js";
10
+ import { requireToken, saveConfig, isJsonMode } from "../config.js";
11
+ import { colors, log, json, success, kvList } from "../utils/logger.js";
12
+ import { emitError } from "../utils/errors.js";
13
+ /** Parse `bata claim <token>` args. Exported for tests. */
14
+ export function parseClaimArgs(args) {
15
+ let token;
16
+ for (const arg of args) {
17
+ if (arg === "--help" || arg === "-h")
18
+ return { help: true };
19
+ if (!arg.startsWith("-") && !token)
20
+ token = arg;
21
+ }
22
+ return { token, help: false };
23
+ }
24
+ function printHelp() {
25
+ log();
26
+ log(` ${colors.bold("bata claim")} ${colors.dim("<token>")}`);
27
+ log();
28
+ log(` Claim an instant database (from ${colors.cyan("bata new")}) into your team.`);
29
+ log(` Requires login — pass ${colors.cyan("--api-key")}, set ${colors.cyan("BATA_API_KEY")}, or run ${colors.cyan("bata login")}.`);
30
+ log();
31
+ log(` ${colors.bold("Examples")}`);
32
+ log(` ${colors.cyan("bata claim claim_EXAMPLE_TOKEN")}`);
33
+ log(` ${colors.cyan("bata claim claim_EXAMPLE_TOKEN --json")}`);
34
+ log();
35
+ }
36
+ export async function claim(args) {
37
+ const parsed = parseClaimArgs(args);
38
+ if (parsed.help) {
39
+ printHelp();
40
+ return;
41
+ }
42
+ if (!parsed.token) {
43
+ emitError("MISSING_ARG", "A claim token is required. Usage: bata claim <token>", "Get one from `bata new`.");
44
+ }
45
+ const token = requireToken();
46
+ const teamId = await resolveTeamId(token);
47
+ if (!teamId) {
48
+ emitError("NO_TEAM", "No team found for this credential.", "Run `bata login` or check your API key.");
49
+ }
50
+ const res = await api.post("/v1/claim/redeem", { claim_token: parsed.token, team_id: teamId }, token);
51
+ if (!res.ok) {
52
+ const data = res.data;
53
+ const code = data?.code;
54
+ if (code === "CLAIM_TOKEN_UNKNOWN") {
55
+ emitError("NOT_FOUND", "Unknown or invalid claim token.", "Double-check the token from `bata new`.");
56
+ }
57
+ if (code === "CLAIM_EXPIRED") {
58
+ emitError("NOT_FOUND", "This database has expired and been deleted.", "Create a fresh one with `bata new`.");
59
+ }
60
+ if (code === "CLAIM_ALREADY_CLAIMED") {
61
+ emitError("NOT_FOUND", "This database has already been claimed.", "");
62
+ }
63
+ emitError("CLI_ERROR", apiError(res, "Failed to claim database"));
64
+ }
65
+ const claimed = res.data;
66
+ saveConfig({ defaultProject: claimed.project_id });
67
+ if (isJsonMode()) {
68
+ json(claimed);
69
+ return;
70
+ }
71
+ log();
72
+ success(`Claimed ${colors.cyan(claimed.name)} into your team.`);
73
+ log();
74
+ kvList([
75
+ ["Project ID", colors.dim(claimed.project_id)],
76
+ ["Team", colors.dim(claimed.team_id)],
77
+ ]);
78
+ log();
79
+ log(` ${colors.dim("Connect:")} ${colors.cyan("bata db url")}`);
80
+ log();
81
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * bata new — instant, no-signup Postgres (bata.new / claimable databases).
3
+ *
4
+ * Calls the PUBLIC POST /v1/claim/new (no credentials needed), prints the
5
+ * connection string, the expiry, and how to claim the database into a real
6
+ * team later. `--json` emits the full payload (including the one-time claim
7
+ * token) for agents.
8
+ *
9
+ * Honest wording: an UNCLAIMED database is permanently DELETED at its expiry.
10
+ */
11
+ export interface NewArgs {
12
+ help: boolean;
13
+ }
14
+ /** Parse `bata new` args. No positional/flags today beyond --help (global
15
+ * flags like --json are stripped upstream in index.ts). Exported for tests. */
16
+ export declare function parseNewArgs(args: string[]): NewArgs;
17
+ export declare function newDb(args: string[]): Promise<void>;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * bata new — instant, no-signup Postgres (bata.new / claimable databases).
3
+ *
4
+ * Calls the PUBLIC POST /v1/claim/new (no credentials needed), prints the
5
+ * connection string, the expiry, and how to claim the database into a real
6
+ * team later. `--json` emits the full payload (including the one-time claim
7
+ * token) for agents.
8
+ *
9
+ * Honest wording: an UNCLAIMED database is permanently DELETED at its expiry.
10
+ */
11
+ import { api, apiError } from "../api.js";
12
+ import { isJsonMode } from "../config.js";
13
+ import { colors, log, json, success, kvList } from "../utils/logger.js";
14
+ import { emitError } from "../utils/errors.js";
15
+ /** Parse `bata new` args. No positional/flags today beyond --help (global
16
+ * flags like --json are stripped upstream in index.ts). Exported for tests. */
17
+ export function parseNewArgs(args) {
18
+ for (const arg of args) {
19
+ if (arg === "--help" || arg === "-h")
20
+ return { help: true };
21
+ }
22
+ return { help: false };
23
+ }
24
+ function printHelp() {
25
+ log();
26
+ log(` ${colors.bold("bata new")}`);
27
+ log();
28
+ log(` Create an instant, no-signup Postgres database and print how to connect.`);
29
+ log(` You get a connection string immediately plus a one-time claim token.`);
30
+ log();
31
+ log(` ${colors.bold("Claim it later")}`);
32
+ log(` ${colors.cyan("bata claim <token>")} Move the database into your team (clears the expiry)`);
33
+ log();
34
+ log(` ${colors.yellow("Unclaimed databases are permanently DELETED at their expiry.")}`);
35
+ log();
36
+ log(` ${colors.bold("Options")}`);
37
+ log(` ${colors.dim("--json")} Machine-readable output (includes the claim token)`);
38
+ log(` ${colors.dim("--help, -h")} Show this help`);
39
+ log();
40
+ }
41
+ export async function newDb(args) {
42
+ const parsed = parseNewArgs(args);
43
+ if (parsed.help) {
44
+ printHelp();
45
+ return;
46
+ }
47
+ // No credentials: this hits the PUBLIC endpoint.
48
+ const res = await api.post("/v1/claim/new", {});
49
+ if (!res.ok) {
50
+ const data = res.data;
51
+ const code = data?.code;
52
+ if (res.status === 404 && code === "CLAIMABLE_DISABLED") {
53
+ emitError("NOT_FOUND", "Instant databases (bata.new) are not enabled on this server.", "Contact the operator, or create a project with `bata create` after `bata login`.");
54
+ }
55
+ if (res.status === 503 || code === "CLAIM_POOL_EXHAUSTED") {
56
+ emitError("API_UNAVAILABLE", "The free database pool is full right now. Try again shortly.", "This is transient — retry in a minute.");
57
+ }
58
+ if (res.status === 429) {
59
+ emitError("API_UNAVAILABLE", "Rate limit reached for instant databases from this network.", "Wait a bit before creating another, or claim one you already made.");
60
+ }
61
+ emitError("CLI_ERROR", apiError(res, "Failed to create instant database"));
62
+ }
63
+ const db = res.data;
64
+ if (isJsonMode()) {
65
+ json(db);
66
+ return;
67
+ }
68
+ log();
69
+ success("Your instant database is ready to claim!");
70
+ log();
71
+ kvList([
72
+ ["Project ID", colors.dim(db.project_id)],
73
+ ["Expires", db.expires_at],
74
+ ]);
75
+ log();
76
+ log(` ${colors.bold("Connection")}`);
77
+ log();
78
+ kvList([
79
+ ["Direct", colors.dim(db.connection.direct)],
80
+ ["Pooled", colors.dim(db.connection.pooled)],
81
+ ]);
82
+ log();
83
+ log(` ${colors.bold("Claim token")} ${colors.dim("(shown once)")}`);
84
+ log(` ${colors.cyan(db.claim_token)}`);
85
+ log();
86
+ log(` ${colors.dim("Claim it into your team:")} ${colors.cyan(`bata claim ${db.claim_token}`)}`);
87
+ log(` ${colors.yellow("Unclaimed databases are permanently DELETED at expiry.")}`);
88
+ log(` ${colors.dim("First connection may take a few seconds while the compute wakes.")}`);
89
+ log();
90
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * `bata powdb pull` — Stage A of the PowDB lane (see
3
+ * docs/strategy/2026-07-04-production-readiness-and-powdb.md §5 feature #1 and
4
+ * docs/powdb.md). It pulls a BataDB branch's schema + data into a single,
5
+ * local, PowDB-loadable **PowQL script** so an agent can bootstrap a real copy
6
+ * of the database inside its own process — no network round-trips at query time.
7
+ *
8
+ * COMPAT-HONEST by construction. PowDB's Stage-A surface is narrower than
9
+ * Postgres, so instead of silently dropping what it can't represent, every
10
+ * unrepresentable / lossy column and constraint is reported per-object (in the
11
+ * human summary, the `--json` payload, AND as `# WARN:` comments in the
12
+ * artifact). No claim is made that PowDB is Postgres-compatible beyond the
13
+ * mapping below, and nothing here compares engine speed.
14
+ *
15
+ * The artifact is PowQL (not SQL) because the shipped `powdb-cli --exec` path
16
+ * executes PowQL; the SQL frontend is not wired into the CLI one-shot loader.
17
+ * See the upstream issues filed against the PowDB repo for the engine-side
18
+ * loader work Stage A wants next (statement-aware file import, uuid/bytes
19
+ * literals, a bootstrap contract for the embedded-addon sandbox).
20
+ */
21
+ interface ColumnInfo {
22
+ name: string;
23
+ dataType: string;
24
+ isNullable: boolean;
25
+ defaultValue: string | null;
26
+ isPrimaryKey: boolean;
27
+ }
28
+ interface ConstraintInfo {
29
+ name: string;
30
+ type: string;
31
+ columns: string[];
32
+ foreignTableSchema: string | null;
33
+ foreignTableName: string | null;
34
+ foreignColumnName: string | null;
35
+ }
36
+ interface IndexInfo {
37
+ name: string;
38
+ definition: string;
39
+ }
40
+ interface TableSchema {
41
+ schema: string;
42
+ name: string;
43
+ columns: ColumnInfo[];
44
+ indexes: IndexInfo[];
45
+ constraints: ConstraintInfo[];
46
+ }
47
+ /** The Stage-A PowDB types a static PowQL artifact can carry. */
48
+ export type PowType = "int" | "float" | "str" | "bool" | "datetime";
49
+ export interface TypeMapping {
50
+ /** null → PowDB has no Stage-A representation; the column is DROPPED. */
51
+ powType: PowType | null;
52
+ /** Honesty note, present whenever the mapping is lossy or the column drops. */
53
+ note?: string;
54
+ }
55
+ /**
56
+ * Map a Postgres data type (as reported by schema introspection) to the PowDB
57
+ * Stage-A type that best represents it — or `null` when PowDB has no honest
58
+ * Stage-A representation and the column must be dropped. Every non-faithful
59
+ * mapping carries a `note` so the caller can surface it per-object.
60
+ */
61
+ export declare function mapPgType(dataType: string): TypeMapping;
62
+ /**
63
+ * Sanitize a Postgres identifier into a valid PowQL identifier (leading
64
+ * alpha/underscore, then alphanumeric/underscore). Reports whether it had to be
65
+ * changed and whether it collides with a PowQL keyword, so the caller can flag
66
+ * the rename honestly rather than emit a name that means something else.
67
+ */
68
+ export declare function powqlIdentifier(name: string): {
69
+ ident: string;
70
+ changed: boolean;
71
+ keyword: boolean;
72
+ };
73
+ /**
74
+ * Escape a string for a PowQL double-quoted literal. The engine lexer honors
75
+ * `\" \\ \n \t`; every other character passes through verbatim. Returns the
76
+ * inner content (no surrounding quotes).
77
+ */
78
+ export declare function escapePowqlString(s: string): string;
79
+ /** A raw string value that would break `powdb-cli --exec`'s naive `;`-split. */
80
+ export declare function isLoadRiskyString(s: string): boolean;
81
+ export type LiteralResult = {
82
+ literal: string;
83
+ risky: boolean;
84
+ } | {
85
+ skip: true;
86
+ reason: string;
87
+ };
88
+ /**
89
+ * Render a JS value (as returned by SQL-over-HTTP, i.e. parsed JSON) as a PowQL
90
+ * literal of the given PowDB type. Returns `{ skip }` when the value cannot be
91
+ * represented (the field is then omitted from the insert, becoming null). Marks
92
+ * `risky` when the produced string literal contains a `;` or newline — data
93
+ * that the shipped `--exec` loader's `;`-split would corrupt (see upstream
94
+ * issue for a statement-aware loader).
95
+ */
96
+ export declare function powqlLiteral(value: unknown, powType: PowType): LiteralResult;
97
+ export type PgDefault = {
98
+ kind: "auto";
99
+ } | {
100
+ kind: "literal";
101
+ text: string;
102
+ } | {
103
+ kind: "drop";
104
+ note: string;
105
+ } | null;
106
+ /**
107
+ * Interpret a Postgres column default for a given PowDB type. Only scalar
108
+ * literals map to a PowQL `default`; `nextval(...)` on an int maps to `auto`;
109
+ * expression defaults (now(), gen_random_uuid(), …) are dropped with a note so
110
+ * the omission is visible rather than silent.
111
+ */
112
+ export declare function parsePgDefault(defaultValue: string | null, powType: PowType): PgDefault;
113
+ export interface ColumnPlan {
114
+ pgName: string;
115
+ powName: string;
116
+ powType: PowType;
117
+ required: boolean;
118
+ unique: boolean;
119
+ auto: boolean;
120
+ defaultLiteral: string | null;
121
+ }
122
+ export interface TablePlan {
123
+ pgQualified: string;
124
+ pgSchema: string;
125
+ pgName: string;
126
+ powName: string;
127
+ columns: ColumnPlan[];
128
+ warnings: string[];
129
+ }
130
+ /**
131
+ * Build the per-table PowQL `type` plan from a Postgres table's schema,
132
+ * accumulating an honesty note for every column/constraint that can't be
133
+ * carried faithfully. Pure — no I/O — so the mapping is unit-tested directly.
134
+ */
135
+ export declare function planTable(t: TableSchema): TablePlan;
136
+ /** Render a table plan as a PowQL `type` DDL block. */
137
+ export declare function renderTypeDdl(plan: TablePlan): string;
138
+ export interface InsertResult {
139
+ statements: string[];
140
+ warnings: string[];
141
+ loadRisk: boolean;
142
+ skippedRows: number;
143
+ }
144
+ /**
145
+ * Render rows for one table as PowQL `insert` statements against its plan.
146
+ * Values that can't be represented are omitted (the field falls back to
147
+ * null/default), and each distinct skip reason is reported once per column.
148
+ */
149
+ export declare function buildInserts(plan: TablePlan, rows: Array<Record<string, unknown>>): InsertResult;
150
+ export interface PowdbPullArgs {
151
+ project?: string;
152
+ branch?: string;
153
+ out?: string;
154
+ limit?: string;
155
+ rest: string[];
156
+ }
157
+ /**
158
+ * Parse `powdb pull` args, consuming every value-taking flag (both ` ` and `=`
159
+ * forms) so a `--project` can never be mistaken for a positional. Mirrors the
160
+ * value-consuming discipline the other agent-facing commands use.
161
+ */
162
+ export declare function parsePowdbPullArgs(args: string[]): PowdbPullArgs;
163
+ /**
164
+ * Make a string safe to sit on a PowQL `#` comment line inside the artifact.
165
+ * `powdb-cli --exec` splits its whole input on `;` BEFORE lexing, so a `;`
166
+ * anywhere in a comment (our own prose, or an echoed Postgres default) would
167
+ * carve off a comment-only fragment that lexes to zero tokens — "expected
168
+ * statement, got end of input". Newlines would likewise smear a comment across
169
+ * a statement boundary. Neutralize both. Exported for unit testing.
170
+ */
171
+ export declare function commentSafe(s: string): string;
172
+ export declare function handlePowdb(args: string[]): Promise<void>;
173
+ export {};
@@ -0,0 +1,697 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import * as path from "node:path";
3
+ import { api, apiError } from "../api.js";
4
+ import { requireToken, isJsonMode, loadConfig } from "../config.js";
5
+ import { colors, log, json, spinner, heading, table } from "../utils/logger.js";
6
+ import { emitError, isRetryable } from "../utils/errors.js";
7
+ import { resolveProjectId, resolveBranchId } from "../link.js";
8
+ // PowQL reserved words a column/table name must not collide with (from the
9
+ // engine lexer keyword set). A colliding name is renamed and flagged rather
10
+ // than silently producing a broken `type`/`insert`.
11
+ const POWQL_KEYWORDS = new Set([
12
+ "type", "filter", "order", "limit", "offset", "insert", "update", "delete",
13
+ "default", "upsert", "returning", "group", "having", "distinct", "and", "or",
14
+ "not", "is", "null", "true", "false", "asc", "desc", "like", "in", "between",
15
+ "required", "unique", "auto", "count", "sum", "avg", "min", "max",
16
+ ]);
17
+ /**
18
+ * Map a Postgres data type (as reported by schema introspection) to the PowDB
19
+ * Stage-A type that best represents it — or `null` when PowDB has no honest
20
+ * Stage-A representation and the column must be dropped. Every non-faithful
21
+ * mapping carries a `note` so the caller can surface it per-object.
22
+ */
23
+ export function mapPgType(dataType) {
24
+ const t = dataType.trim().toLowerCase();
25
+ // Integer family (incl. serial, which is int + a nextval default).
26
+ if (["smallint", "integer", "bigint", "int", "int2", "int4", "int8",
27
+ "serial", "bigserial", "smallserial", "serial2", "serial4", "serial8"].includes(t)) {
28
+ return { powType: "int" };
29
+ }
30
+ // Floating point.
31
+ if (["real", "double precision", "float", "float4", "float8"].includes(t)) {
32
+ return { powType: "float" };
33
+ }
34
+ // Exact numeric — represented as float64, so NOT exact. Lossy, flagged.
35
+ if (t === "numeric" || t === "decimal" || t.startsWith("numeric(") || t.startsWith("decimal(")) {
36
+ return { powType: "float", note: "numeric/decimal represented as float64 — not exact; precision may be lost" };
37
+ }
38
+ // Text family.
39
+ if (["text", "character varying", "varchar", "character", "char", "bpchar",
40
+ "name", "citext", "\"char\""].includes(t) || t.startsWith("varchar(") || t.startsWith("character varying(") || t.startsWith("char(") || t.startsWith("character(")) {
41
+ return { powType: "str" };
42
+ }
43
+ // Boolean.
44
+ if (t === "boolean" || t === "bool") {
45
+ return { powType: "bool" };
46
+ }
47
+ // Temporal → PowDB `datetime` (i64 epoch). Stored as epoch SECONDS; timezone
48
+ // and sub-second precision are not modelled. Flagged.
49
+ if (["timestamp with time zone", "timestamp without time zone", "timestamptz",
50
+ "timestamp", "date"].includes(t) || t.startsWith("timestamp")) {
51
+ return { powType: "datetime", note: "stored as PowDB datetime (epoch seconds); timezone + sub-second precision not preserved" };
52
+ }
53
+ // time / interval have no PowDB type — carried as text.
54
+ if (["time", "time without time zone", "time with time zone", "timetz", "interval"].includes(t) || t.startsWith("time") || t.startsWith("interval")) {
55
+ return { powType: "str", note: `${t} has no PowDB type — carried as text (str)` };
56
+ }
57
+ // uuid — no static PowQL uuid literal exists, so it is carried as text.
58
+ if (t === "uuid") {
59
+ return { powType: "str", note: "uuid carried as text (str) — PowDB has no static uuid literal for a script load" };
60
+ }
61
+ // bytea — no static PowQL bytes literal exists; carried as hex text.
62
+ if (t === "bytea") {
63
+ return { powType: "str", note: "bytea carried as hex text (str) — PowDB has no static bytes literal for a script load" };
64
+ }
65
+ // json/jsonb — carried as text; no JSON operators.
66
+ if (t === "json" || t === "jsonb") {
67
+ return { powType: "str", note: `${t} carried as text (str) — no JSON operators in PowDB Stage A` };
68
+ }
69
+ // Everything else (arrays, enums, ranges, geometry, network, tsvector,
70
+ // composite/user-defined) has no honest Stage-A representation.
71
+ return { powType: null, note: `${dataType} has no PowDB Stage-A representation — column dropped` };
72
+ }
73
+ /**
74
+ * Sanitize a Postgres identifier into a valid PowQL identifier (leading
75
+ * alpha/underscore, then alphanumeric/underscore). Reports whether it had to be
76
+ * changed and whether it collides with a PowQL keyword, so the caller can flag
77
+ * the rename honestly rather than emit a name that means something else.
78
+ */
79
+ export function powqlIdentifier(name) {
80
+ let ident = name.replace(/[^A-Za-z0-9_]/g, "_");
81
+ if (ident === "" || !/^[A-Za-z_]/.test(ident)) {
82
+ ident = "_" + ident;
83
+ }
84
+ const keyword = POWQL_KEYWORDS.has(ident.toLowerCase());
85
+ if (keyword) {
86
+ ident = ident + "_";
87
+ }
88
+ return { ident, changed: ident !== name, keyword };
89
+ }
90
+ /**
91
+ * Escape a string for a PowQL double-quoted literal. The engine lexer honors
92
+ * `\" \\ \n \t`; every other character passes through verbatim. Returns the
93
+ * inner content (no surrounding quotes).
94
+ */
95
+ export function escapePowqlString(s) {
96
+ return s
97
+ .replace(/\\/g, "\\\\")
98
+ .replace(/"/g, '\\"')
99
+ .replace(/\n/g, "\\n")
100
+ .replace(/\t/g, "\\t")
101
+ .replace(/\r/g, "\\n");
102
+ }
103
+ /** A raw string value that would break `powdb-cli --exec`'s naive `;`-split. */
104
+ export function isLoadRiskyString(s) {
105
+ return s.includes(";") || s.includes("\n");
106
+ }
107
+ /**
108
+ * Render a JS value (as returned by SQL-over-HTTP, i.e. parsed JSON) as a PowQL
109
+ * literal of the given PowDB type. Returns `{ skip }` when the value cannot be
110
+ * represented (the field is then omitted from the insert, becoming null). Marks
111
+ * `risky` when the produced string literal contains a `;` or newline — data
112
+ * that the shipped `--exec` loader's `;`-split would corrupt (see upstream
113
+ * issue for a statement-aware loader).
114
+ */
115
+ export function powqlLiteral(value, powType) {
116
+ if (value === null || value === undefined) {
117
+ return { skip: true, reason: "null" };
118
+ }
119
+ switch (powType) {
120
+ case "int": {
121
+ const n = typeof value === "number" ? value : Number(String(value));
122
+ if (!Number.isFinite(n))
123
+ return { skip: true, reason: `non-numeric int value ${JSON.stringify(value)}` };
124
+ return { literal: String(Math.trunc(n)), risky: false };
125
+ }
126
+ case "float": {
127
+ const n = typeof value === "number" ? value : Number(String(value));
128
+ if (!Number.isFinite(n))
129
+ return { skip: true, reason: `non-numeric float value ${JSON.stringify(value)}` };
130
+ // PowQL floats need a decimal point to lex as FloatLit.
131
+ const s = Number.isInteger(n) ? `${n}.0` : String(n);
132
+ return { literal: s, risky: false };
133
+ }
134
+ case "bool": {
135
+ if (typeof value === "boolean")
136
+ return { literal: value ? "true" : "false", risky: false };
137
+ const s = String(value).toLowerCase();
138
+ if (["true", "t", "1", "yes"].includes(s))
139
+ return { literal: "true", risky: false };
140
+ if (["false", "f", "0", "no"].includes(s))
141
+ return { literal: "false", risky: false };
142
+ return { skip: true, reason: `non-boolean value ${JSON.stringify(value)}` };
143
+ }
144
+ case "datetime": {
145
+ // Accept an epoch number or a parseable timestamp string and emit a bare
146
+ // epoch-SECONDS integer. PowDB stores datetime as an i64 epoch and coerces
147
+ // an int literal into a `datetime` column on insert — a `cast(...)`
148
+ // expression is rejected in insert value position (verified on 0.8.0).
149
+ let epoch;
150
+ if (typeof value === "number") {
151
+ epoch = Math.trunc(value);
152
+ }
153
+ else {
154
+ const ms = Date.parse(String(value));
155
+ if (!Number.isFinite(ms))
156
+ return { skip: true, reason: `unparseable timestamp ${JSON.stringify(value)}` };
157
+ epoch = Math.floor(ms / 1000);
158
+ }
159
+ return { literal: String(epoch), risky: false };
160
+ }
161
+ case "str": {
162
+ // Objects/arrays (e.g. a json column) become their JSON text.
163
+ const raw = typeof value === "string" ? value : JSON.stringify(value);
164
+ return { literal: `"${escapePowqlString(raw)}"`, risky: isLoadRiskyString(raw) };
165
+ }
166
+ }
167
+ }
168
+ /**
169
+ * Interpret a Postgres column default for a given PowDB type. Only scalar
170
+ * literals map to a PowQL `default`; `nextval(...)` on an int maps to `auto`;
171
+ * expression defaults (now(), gen_random_uuid(), …) are dropped with a note so
172
+ * the omission is visible rather than silent.
173
+ */
174
+ export function parsePgDefault(defaultValue, powType) {
175
+ if (!defaultValue)
176
+ return null;
177
+ const raw = defaultValue.trim();
178
+ // Sequence-backed (serial) → PowQL auto (int only).
179
+ if (/nextval\(/i.test(raw)) {
180
+ if (powType === "int")
181
+ return { kind: "auto" };
182
+ return { kind: "drop", note: `sequence default on a non-int column dropped (${raw})` };
183
+ }
184
+ // Strip a trailing ::type cast, e.g. 'active'::text or 0::numeric.
185
+ const noCast = raw.replace(/::[A-Za-z0-9_ "\[\]]+$/, "").trim();
186
+ if (powType === "bool") {
187
+ const s = noCast.toLowerCase();
188
+ if (s === "true")
189
+ return { kind: "literal", text: "true" };
190
+ if (s === "false")
191
+ return { kind: "literal", text: "false" };
192
+ return { kind: "drop", note: `non-literal bool default dropped (${raw})` };
193
+ }
194
+ if (powType === "int") {
195
+ if (/^-?\d+$/.test(noCast))
196
+ return { kind: "literal", text: noCast };
197
+ return { kind: "drop", note: `expression default dropped (${raw})` };
198
+ }
199
+ if (powType === "float") {
200
+ if (/^-?\d+(\.\d+)?$/.test(noCast)) {
201
+ return { kind: "literal", text: noCast.includes(".") ? noCast : `${noCast}.0` };
202
+ }
203
+ return { kind: "drop", note: `expression default dropped (${raw})` };
204
+ }
205
+ // str/datetime: only a quoted string literal is a safe scalar default.
206
+ if (powType === "str") {
207
+ const m = /^'([\s\S]*)'$/.exec(noCast);
208
+ if (m)
209
+ return { kind: "literal", text: `"${escapePowqlString(m[1].replace(/''/g, "'"))}"` };
210
+ return { kind: "drop", note: `expression default dropped (${raw})` };
211
+ }
212
+ // datetime defaults are expressions (now(), CURRENT_TIMESTAMP) — never scalar.
213
+ return { kind: "drop", note: `expression default dropped (${raw})` };
214
+ }
215
+ /**
216
+ * Build the per-table PowQL `type` plan from a Postgres table's schema,
217
+ * accumulating an honesty note for every column/constraint that can't be
218
+ * carried faithfully. Pure — no I/O — so the mapping is unit-tested directly.
219
+ */
220
+ export function planTable(t) {
221
+ const warnings = [];
222
+ const { ident: tPowName, changed: nameChanged, keyword } = powqlIdentifier(t.name);
223
+ if (nameChanged) {
224
+ warnings.push(`table renamed to \`${tPowName}\`${keyword ? " (collides with a PowQL keyword)" : " (name not a valid PowQL identifier)"}`);
225
+ }
226
+ // Single-column UNIQUE constraints → the PowQL `unique` modifier.
227
+ const singleUniqueCols = new Set();
228
+ for (const c of t.constraints ?? []) {
229
+ const type = c.type.toUpperCase();
230
+ if (type.includes("PRIMARY")) {
231
+ if (c.columns.length > 1)
232
+ warnings.push(`composite primary key (${c.columns.join(", ")}) not enforced — PowDB Stage A has no table constraints`);
233
+ }
234
+ else if (type.includes("UNIQUE")) {
235
+ if (c.columns.length === 1)
236
+ singleUniqueCols.add(c.columns[0]);
237
+ else
238
+ warnings.push(`composite unique constraint ${c.name} (${c.columns.join(", ")}) not enforced — PowDB uniqueness is per-column`);
239
+ }
240
+ else if (type.includes("FOREIGN")) {
241
+ warnings.push(`foreign key ${c.name} → ${c.foreignTableName ?? "?"} dropped — PowDB Stage A has no foreign keys`);
242
+ }
243
+ else if (type.includes("CHECK")) {
244
+ warnings.push(`check constraint ${c.name} dropped — PowDB Stage A has no check constraints`);
245
+ }
246
+ }
247
+ // Secondary (non-unique) indexes aren't carried — the type definition only
248
+ // expresses uniqueness, not standalone indexes.
249
+ for (const idx of t.indexes ?? []) {
250
+ if (!/unique/i.test(idx.definition)) {
251
+ warnings.push(`index ${idx.name} not carried — PowDB Stage A expresses only unique constraints, not secondary indexes`);
252
+ }
253
+ }
254
+ const columns = [];
255
+ for (const col of t.columns) {
256
+ const mapping = mapPgType(col.dataType);
257
+ if (mapping.powType === null) {
258
+ warnings.push(`column ${col.name}: ${mapping.note}`);
259
+ continue;
260
+ }
261
+ if (mapping.note) {
262
+ warnings.push(`column ${col.name}: ${mapping.note}`);
263
+ }
264
+ const { ident: colPowName, changed: colChanged, keyword: colKeyword } = powqlIdentifier(col.name);
265
+ if (colChanged) {
266
+ warnings.push(`column ${col.name} renamed to \`${colPowName}\`${colKeyword ? " (collides with a PowQL keyword)" : ""}`);
267
+ }
268
+ const def = parsePgDefault(col.defaultValue, mapping.powType);
269
+ let auto = false;
270
+ let defaultLiteral = null;
271
+ if (def?.kind === "auto")
272
+ auto = true;
273
+ else if (def?.kind === "literal")
274
+ defaultLiteral = def.text;
275
+ else if (def?.kind === "drop")
276
+ warnings.push(`column ${col.name}: ${def.note}`);
277
+ columns.push({
278
+ pgName: col.name,
279
+ powName: colPowName,
280
+ powType: mapping.powType,
281
+ // PowDB `auto` cannot combine with `default`; also `required` on an auto
282
+ // column is redundant. PK columns are required + unique.
283
+ required: (!col.isNullable || col.isPrimaryKey) && !auto,
284
+ unique: col.isPrimaryKey || singleUniqueCols.has(col.name),
285
+ auto,
286
+ defaultLiteral: auto ? null : defaultLiteral,
287
+ });
288
+ }
289
+ return {
290
+ pgQualified: `"${t.schema}"."${t.name}"`,
291
+ pgSchema: t.schema,
292
+ pgName: t.name,
293
+ powName: tPowName,
294
+ columns,
295
+ warnings,
296
+ };
297
+ }
298
+ /** Render a table plan as a PowQL `type` DDL block. */
299
+ export function renderTypeDdl(plan) {
300
+ const lines = [`type ${plan.powName} {`];
301
+ const fieldLines = plan.columns.map((c) => {
302
+ const mods = [];
303
+ if (c.required)
304
+ mods.push("required");
305
+ if (c.unique)
306
+ mods.push("unique");
307
+ if (c.auto)
308
+ mods.push("auto");
309
+ const prefix = mods.length ? mods.join(" ") + " " : "";
310
+ const def = c.defaultLiteral ? ` default ${c.defaultLiteral}` : "";
311
+ return ` ${prefix}${c.powName}: ${c.powType}${def}`;
312
+ });
313
+ lines.push(fieldLines.join(",\n"));
314
+ lines.push("}");
315
+ return lines.join("\n");
316
+ }
317
+ /**
318
+ * Render rows for one table as PowQL `insert` statements against its plan.
319
+ * Values that can't be represented are omitted (the field falls back to
320
+ * null/default), and each distinct skip reason is reported once per column.
321
+ */
322
+ export function buildInserts(plan, rows) {
323
+ const statements = [];
324
+ const skipNotes = new Map(); // colName → first skip reason
325
+ let loadRisk = false;
326
+ let skippedRows = 0;
327
+ for (const row of rows) {
328
+ const assigns = [];
329
+ let anyField = false;
330
+ for (const col of plan.columns) {
331
+ const res = powqlLiteral(row[col.pgName], col.powType);
332
+ if ("skip" in res) {
333
+ if (res.reason !== "null" && !skipNotes.has(col.pgName)) {
334
+ skipNotes.set(col.pgName, res.reason);
335
+ }
336
+ continue;
337
+ }
338
+ if (res.risky)
339
+ loadRisk = true;
340
+ assigns.push(`${col.powName} := ${res.literal}`);
341
+ anyField = true;
342
+ }
343
+ if (!anyField) {
344
+ skippedRows++;
345
+ continue;
346
+ }
347
+ statements.push(`insert ${plan.powName} { ${assigns.join(", ")} };`);
348
+ }
349
+ const warnings = [];
350
+ for (const [col, reason] of skipNotes) {
351
+ warnings.push(`column ${col}: some values dropped — ${reason}`);
352
+ }
353
+ return { statements, warnings, loadRisk, skippedRows };
354
+ }
355
+ /**
356
+ * Parse `powdb pull` args, consuming every value-taking flag (both ` ` and `=`
357
+ * forms) so a `--project` can never be mistaken for a positional. Mirrors the
358
+ * value-consuming discipline the other agent-facing commands use.
359
+ */
360
+ export function parsePowdbPullArgs(args) {
361
+ const out = { rest: [] };
362
+ for (let i = 0; i < args.length; i++) {
363
+ const a = args[i];
364
+ if (a === "--project") {
365
+ out.project = args[++i];
366
+ continue;
367
+ }
368
+ if (a.startsWith("--project=")) {
369
+ out.project = a.slice("--project=".length);
370
+ continue;
371
+ }
372
+ if (a === "--branch") {
373
+ out.branch = args[++i];
374
+ continue;
375
+ }
376
+ if (a.startsWith("--branch=")) {
377
+ out.branch = a.slice("--branch=".length);
378
+ continue;
379
+ }
380
+ if (a === "--out" || a === "-o") {
381
+ out.out = args[++i];
382
+ continue;
383
+ }
384
+ if (a.startsWith("--out=")) {
385
+ out.out = a.slice("--out=".length);
386
+ continue;
387
+ }
388
+ if (a === "--limit") {
389
+ out.limit = args[++i];
390
+ continue;
391
+ }
392
+ if (a.startsWith("--limit=")) {
393
+ out.limit = a.slice("--limit=".length);
394
+ continue;
395
+ }
396
+ out.rest.push(a);
397
+ }
398
+ return out;
399
+ }
400
+ // ─── Command orchestration ────────────────────────────────────────────────────
401
+ /** List a project's branches (GET /v1/projects/:id). Null on lookup failure. */
402
+ async function listProjectBranches(projectId, token, teamId) {
403
+ const query = {};
404
+ if (teamId)
405
+ query.team_id = teamId;
406
+ const res = await api.get(`/v1/projects/${projectId}`, token, query);
407
+ if (!res.ok)
408
+ return null;
409
+ return { branches: res.data.branches ?? [], projectName: res.data.name };
410
+ }
411
+ /** Resolve a branch ref (id OR name) to a concrete id, or the primary branch. */
412
+ async function resolveBranchTarget(projectId, token, teamId, ref) {
413
+ const result = await listProjectBranches(projectId, token, teamId);
414
+ if (!result) {
415
+ emitError("API_UNAVAILABLE", "Failed to list branches for this project.", "Check the project id and try again.");
416
+ }
417
+ const { branches, projectName } = result;
418
+ if (ref) {
419
+ const match = branches.find((b) => b.id === ref || b.name === ref);
420
+ if (!match) {
421
+ emitError("BRANCH_NOT_FOUND", `Branch "${ref}" not found in this project.`, "List branches with: bata db branches --json");
422
+ }
423
+ return { id: match.id, name: match.name, projectName };
424
+ }
425
+ const primary = branches.find((b) => b.isPrimary) ?? branches[0];
426
+ if (!primary) {
427
+ emitError("BRANCH_NOT_FOUND", "This project has no branches to pull.", "Create one with: bata db branch create <name>");
428
+ }
429
+ return { id: primary.id, name: primary.name, projectName };
430
+ }
431
+ /** Handle a failed schema/SQL request: retryable → COMPUTE_STARTING (exit 6). */
432
+ function failRequest(res, fallback) {
433
+ const body = res.data;
434
+ if (isRetryable({ status: res.status, code: body?.code, message: body?.error })) {
435
+ emitError("COMPUTE_STARTING", apiError(res, fallback), "compute is starting; retry in a few seconds");
436
+ }
437
+ const code = res.status === 401 || res.status === 403 ? "INVALID_KEY"
438
+ : res.status === 404 ? "NO_PROJECT"
439
+ : "CLI_ERROR";
440
+ emitError(code, apiError(res, fallback), "");
441
+ }
442
+ async function powdbPull(args) {
443
+ const jsonMode = isJsonMode();
444
+ const { project: projectFlag, branch: branchFlag, out: outFlag, limit: limitFlag } = parsePowdbPullArgs(args);
445
+ let limit;
446
+ if (limitFlag !== undefined) {
447
+ const n = Number(limitFlag);
448
+ if (!Number.isInteger(n) || n <= 0) {
449
+ emitError("INVALID_FLAG", `Invalid --limit value "${limitFlag}".`, "Pass a positive integer, e.g. --limit 1000.");
450
+ }
451
+ limit = n;
452
+ }
453
+ const token = requireToken();
454
+ const config = loadConfig();
455
+ // Project precedence: --project flag > .batadata link > config default.
456
+ const projectId = resolveProjectId(projectFlag).projectId;
457
+ if (!projectId) {
458
+ emitError("NO_PROJECT", "No project for powdb pull.", "Pass --project <id>, or run `bata link <project>` to set a default.");
459
+ }
460
+ // Branch precedence: explicit --branch wins, else the pinned checkout branch,
461
+ // else the project's primary branch.
462
+ const branchRef = resolveBranchId(branchFlag).branchId;
463
+ const s = jsonMode ? null : spinner("Resolving branch");
464
+ const target = await resolveBranchTarget(projectId, token, config.defaultTeam, branchRef);
465
+ s?.update("Introspecting schema");
466
+ const schemaRes = await api.get(`/v1/schema/${projectId}`, token, { branch_id: target.id });
467
+ if (!schemaRes.ok) {
468
+ s?.stop();
469
+ failRequest(schemaRes, "Schema introspection failed");
470
+ }
471
+ const tables = schemaRes.data.schema.tables ?? [];
472
+ const parts = [];
473
+ const reports = [];
474
+ let totalRows = 0;
475
+ let anyLoadRisk = false;
476
+ for (const t of tables) {
477
+ s?.update(`Exporting ${t.schema}.${t.name}`);
478
+ const plan = planTable(t);
479
+ // Pull the table's rows over SQL-over-HTTP.
480
+ const sql = `SELECT * FROM ${plan.pgQualified}${limit ? ` LIMIT ${limit}` : ""}`;
481
+ const dataRes = await api.post("/v1/sql/execute", { branch_id: target.id, query: sql }, token);
482
+ if (!dataRes.ok) {
483
+ s?.stop();
484
+ failRequest(dataRes, `Failed to export data from ${t.schema}.${t.name}`);
485
+ }
486
+ if (dataRes.data.error) {
487
+ s?.stop();
488
+ emitError("CLI_ERROR", `Data export failed for ${t.schema}.${t.name}: ${dataRes.data.error}`, "");
489
+ }
490
+ const rows = dataRes.data.rows ?? [];
491
+ const ddl = renderTypeDdl(plan);
492
+ const inserts = buildInserts(plan, rows);
493
+ const warnings = [...plan.warnings, ...inserts.warnings];
494
+ if (inserts.skippedRows > 0) {
495
+ warnings.push(`${inserts.skippedRows} row(s) had no representable columns and were skipped`);
496
+ }
497
+ if (inserts.loadRisk)
498
+ anyLoadRisk = true;
499
+ // Emit the type DDL, its honesty notes, and the inserts for this table.
500
+ // Comments are `;`/newline-sanitized so they survive the `--exec` split.
501
+ parts.push(commentSafe(`# ── ${t.schema}.${t.name} → ${plan.powName} (${inserts.statements.length} row(s)) ──`));
502
+ for (const w of warnings)
503
+ parts.push(commentSafe(`# WARN: ${w}`));
504
+ if (plan.columns.length === 0) {
505
+ parts.push(`# (no representable columns — table skipped)`);
506
+ parts.push("");
507
+ }
508
+ else {
509
+ parts.push(ddl + ";");
510
+ parts.push("");
511
+ if (inserts.statements.length) {
512
+ parts.push(inserts.statements.join("\n"));
513
+ parts.push("");
514
+ }
515
+ }
516
+ totalRows += inserts.statements.length;
517
+ reports.push({
518
+ postgres: `${t.schema}.${t.name}`,
519
+ powdb: plan.powName,
520
+ columns: plan.columns.length,
521
+ rows: inserts.statements.length,
522
+ warnings,
523
+ });
524
+ }
525
+ s?.stop();
526
+ const header = buildArtifactHeader({
527
+ projectId,
528
+ projectName: target.projectName,
529
+ branchName: target.name,
530
+ branchId: target.id,
531
+ tableCount: tables.length,
532
+ rowCount: totalRows,
533
+ loadRisk: anyLoadRisk,
534
+ limit,
535
+ });
536
+ const artifact = header + "\n" + parts.join("\n") + "\n";
537
+ // Default output path is derived from the project + branch so repeated pulls
538
+ // are stable and self-describing.
539
+ const outPath = outFlag ?? `./${projectId}-${sanitizeForFile(target.name)}.powql`;
540
+ const isStdout = outFlag === "-";
541
+ if (!isStdout) {
542
+ writeFileSync(path.resolve(outPath), artifact, "utf8");
543
+ }
544
+ const warningsTotal = reports.reduce((n, r) => n + r.warnings.length, 0);
545
+ const loadCommand = isStdout
546
+ ? `powdb-cli --data-dir ./sandbox --exec "$(bata powdb pull ... )"`
547
+ : `powdb-cli --data-dir ./sandbox --exec "$(cat ${outPath})"`;
548
+ if (jsonMode) {
549
+ if (isStdout) {
550
+ // In --json + stdout mode the artifact rides in the payload, not raw.
551
+ json({
552
+ project_id: projectId,
553
+ branch: { id: target.id, name: target.name },
554
+ engine: "powdb",
555
+ stage: "A",
556
+ out: null,
557
+ artifact,
558
+ tables: reports,
559
+ row_count: totalRows,
560
+ warnings_total: warningsTotal,
561
+ load_risk: anyLoadRisk,
562
+ load_command: loadCommand,
563
+ });
564
+ return;
565
+ }
566
+ json({
567
+ project_id: projectId,
568
+ branch: { id: target.id, name: target.name },
569
+ engine: "powdb",
570
+ stage: "A",
571
+ out: path.resolve(outPath),
572
+ tables: reports,
573
+ row_count: totalRows,
574
+ warnings_total: warningsTotal,
575
+ load_risk: anyLoadRisk,
576
+ load_command: loadCommand,
577
+ });
578
+ return;
579
+ }
580
+ if (isStdout) {
581
+ process.stdout.write(artifact);
582
+ return;
583
+ }
584
+ renderPullSummary({ outPath: path.resolve(outPath), projectName: target.projectName, branchName: target.name, reports, totalRows, warningsTotal, loadRisk: anyLoadRisk, loadCommand });
585
+ }
586
+ /** Replace filesystem-unfriendly characters in a branch name for the out path. */
587
+ function sanitizeForFile(name) {
588
+ return name.replace(/[^A-Za-z0-9._-]/g, "_");
589
+ }
590
+ /**
591
+ * Make a string safe to sit on a PowQL `#` comment line inside the artifact.
592
+ * `powdb-cli --exec` splits its whole input on `;` BEFORE lexing, so a `;`
593
+ * anywhere in a comment (our own prose, or an echoed Postgres default) would
594
+ * carve off a comment-only fragment that lexes to zero tokens — "expected
595
+ * statement, got end of input". Newlines would likewise smear a comment across
596
+ * a statement boundary. Neutralize both. Exported for unit testing.
597
+ */
598
+ export function commentSafe(s) {
599
+ return s.replace(/;/g, ",").replace(/[\r\n]+/g, " ");
600
+ }
601
+ function buildArtifactHeader(o) {
602
+ // PowQL comments start with `#` (the engine lexer's comment char — NOT `--`,
603
+ // which lexes as two minus signs and fails to parse).
604
+ const lines = [
605
+ "# PowDB Stage A export — generated by `bata powdb pull`",
606
+ "#",
607
+ `# Source project : ${o.projectName} (${o.projectId})`,
608
+ `# Source branch : ${o.branchName} (${o.branchId})`,
609
+ `# Tables : ${o.tableCount} Rows: ${o.rowCount}${o.limit ? ` (--limit ${o.limit} per table)` : ""}`,
610
+ "#",
611
+ "# This is a PowQL script (PowDB's native language). Load it into a fresh",
612
+ "# local PowDB data dir and query it in-process — no network at query time:",
613
+ "#",
614
+ "# powdb-cli --data-dir ./sandbox --exec \"$(cat <this-file>)\"",
615
+ "#",
616
+ "# COMPAT-HONEST: `# WARN:` lines below name every Postgres feature that",
617
+ "# PowDB Stage A cannot carry faithfully (foreign keys, check constraints,",
618
+ "# secondary indexes, unmapped types, expression defaults, lossy numerics).",
619
+ "# PowDB is NOT Postgres-compatible beyond this mapping; nothing here claims",
620
+ "# otherwise, and no engine-speed comparison is implied.",
621
+ ];
622
+ if (o.loadRisk) {
623
+ lines.push("#", "# LOAD CAVEAT: some string values contain ';' or a newline. The shipped", "# `powdb-cli --exec` splits input on ';', so those statements need a", "# statement-aware loader to load intact (tracked upstream in the PowDB repo).");
624
+ }
625
+ // Sanitize so no `;` in the prose (e.g. "beyond this mapping; nothing…")
626
+ // survives to break the loader's `;`-split.
627
+ return lines.map(commentSafe).join("\n");
628
+ }
629
+ function renderPullSummary(o) {
630
+ heading(`PowDB pull — ${o.projectName} / ${o.branchName}`);
631
+ if (o.reports.length === 0) {
632
+ log(` ${colors.dim("No tables in this branch's schema.")}`);
633
+ log();
634
+ return;
635
+ }
636
+ table(["POSTGRES", "POWDB", "COLUMNS", "ROWS", "NOTES"], o.reports.map((r) => [
637
+ r.postgres,
638
+ r.powdb,
639
+ String(r.columns),
640
+ String(r.rows),
641
+ r.warnings.length ? colors.yellow(String(r.warnings.length)) : colors.dim("0"),
642
+ ]));
643
+ log();
644
+ if (o.warningsTotal > 0) {
645
+ log(` ${colors.yellow(`${o.warningsTotal} compat note(s)`)} ${colors.dim("— PowDB Stage A can't carry every Postgres feature. Details are `# WARN:` lines in the artifact.")}`);
646
+ }
647
+ if (o.loadRisk) {
648
+ log(` ${colors.yellow("!")} ${colors.dim("Some string data contains ';' or newlines — see the LOAD CAVEAT in the artifact header.")}`);
649
+ }
650
+ log();
651
+ log(` ${colors.green(">")} Wrote ${colors.cyan(o.outPath)} ${colors.dim(`(${o.totalRows} row(s))`)}`);
652
+ log(` ${colors.dim("Load it:")} ${colors.cyan(o.loadCommand)}`);
653
+ log();
654
+ }
655
+ // ─── Help + dispatch ──────────────────────────────────────────────────────────
656
+ function powdbHelp() {
657
+ log();
658
+ log(` ${colors.bold("bata powdb")} ${colors.dim("— PowDB embedded lane (Stage A)")}`);
659
+ log();
660
+ log(` ${colors.dim("PowDB is an embedded engine: it runs inside your process, so a pulled")}`);
661
+ log(` ${colors.dim("copy of a branch costs nothing to query and has no network round-trips.")}`);
662
+ log(` ${colors.dim("BataDB branching / metering / server insights do NOT apply to it.")}`);
663
+ log();
664
+ log(` ${colors.dim("Commands:")}`);
665
+ log(` ${colors.cyan("pull")} Pull a branch's schema + data into a local PowDB-loadable PowQL script`);
666
+ log();
667
+ log(` ${colors.dim("Options (pull):")}`);
668
+ log(` ${colors.dim("--project <id> override the linked/default project")}`);
669
+ log(` ${colors.dim("--branch <name|id> branch to pull (default: primary)")}`);
670
+ log(` ${colors.dim("--out <path|-> artifact path (default: ./<project>-<branch>.powql; - = stdout)")}`);
671
+ log(` ${colors.dim("--limit <n> cap rows exported per table")}`);
672
+ log(` ${colors.dim("--json machine-readable summary + per-table compat notes")}`);
673
+ log();
674
+ log(` ${colors.dim("Example:")}`);
675
+ log(` ${colors.dim("bata powdb pull --branch main --out ./sandbox.powql")}`);
676
+ log(` ${colors.dim('powdb-cli --data-dir ./sandbox --exec "$(cat ./sandbox.powql)"')}`);
677
+ log();
678
+ log(` ${colors.dim("Compat-honest: unrepresentable Postgres features are reported per-object")}`);
679
+ log(` ${colors.dim("as `# WARN:` lines in the artifact — see docs/powdb.md.")}`);
680
+ log();
681
+ }
682
+ export async function handlePowdb(args) {
683
+ const sub = args[0];
684
+ if (sub === "--help" || sub === "-h" || args.includes("--help") || args.includes("-h")) {
685
+ powdbHelp();
686
+ return;
687
+ }
688
+ switch (sub) {
689
+ case "pull":
690
+ return powdbPull(args.slice(1));
691
+ case undefined:
692
+ powdbHelp();
693
+ return;
694
+ default:
695
+ emitError("INVALID_FLAG", `Unknown subcommand: powdb ${sub}`, "Available: pull. Run `bata powdb --help`.");
696
+ }
697
+ }
package/dist/index.js CHANGED
@@ -8,10 +8,13 @@ import { handleMigrate } from "./commands/migrate.js";
8
8
  import { handleApiKeys } from "./commands/api-keys.js";
9
9
  import { dev } from "./commands/dev.js";
10
10
  import { create } from "./commands/create.js";
11
+ import { newDb } from "./commands/new.js";
12
+ import { claim } from "./commands/claim.js";
11
13
  import { status } from "./commands/status.js";
12
14
  import { connect } from "./commands/connect.js";
13
15
  import { usage } from "./commands/usage.js";
14
16
  import { handleRestore } from "./commands/restore.js";
17
+ import { handlePowdb } from "./commands/powdb.js";
15
18
  import { link, unlink } from "./commands/link.js";
16
19
  import { parseGlobalFlags } from "./args.js";
17
20
  import { isJsonMode } from "./config.js";
@@ -24,6 +27,8 @@ function help() {
24
27
  log(` ${colors.cyan("bata")} ${colors.dim("<command>")} ${colors.dim("[options]")}`);
25
28
  log();
26
29
  log(` ${colors.bold("Quick Start")}`);
30
+ log(` ${colors.cyan("new")} Instant no-signup database ${colors.dim("(no login) — claim it later")}`);
31
+ log(` ${colors.cyan("claim <token>")} Claim a ` + "`bata new`" + ` database into your team`);
27
32
  log(` ${colors.cyan("create <name>")} Create a project and wait for it to be ready`);
28
33
  log(` ${colors.cyan("connect <name>")} Open psql to a project (auto-wakes if suspended)`);
29
34
  log(` ${colors.cyan("status")} Show all projects and their status`);
@@ -64,6 +69,9 @@ function help() {
64
69
  log(` ${colors.cyan("schema check|dump|diff")} Schema safety gate + introspection`);
65
70
  log(` ${colors.cyan("migrate check")} Gate a migration against live query traffic ${colors.dim("(exit 2 if breaking)")}`);
66
71
  log();
72
+ log(` ${colors.bold("PowDB (embedded lane, Stage A)")}`);
73
+ log(` ${colors.cyan("powdb pull")} Pull a branch's schema + data into a local PowDB-loadable PowQL script`);
74
+ log();
67
75
  log(` ${colors.bold("Development")}`);
68
76
  log(` ${colors.cyan("dev")} Local development setup guide`);
69
77
  log();
@@ -122,6 +130,12 @@ async function main() {
122
130
  case "create":
123
131
  await create(rest);
124
132
  break;
133
+ case "new":
134
+ await newDb(rest);
135
+ break;
136
+ case "claim":
137
+ await claim(rest);
138
+ break;
125
139
  case "status":
126
140
  await status();
127
141
  break;
@@ -173,6 +187,10 @@ async function main() {
173
187
  case "schema":
174
188
  await handleSchema(rest);
175
189
  break;
190
+ // PowDB embedded lane (Stage A)
191
+ case "powdb":
192
+ await handlePowdb(rest);
193
+ break;
176
194
  // Generate
177
195
  case "generate":
178
196
  await generate(rest);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@batadata/cli",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "CLI for BataDB — serverless Postgres platform",
5
5
  "bin": {
6
6
  "bata": "./dist/index.js"