@batadata/cli 0.1.13 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/powdb.d.ts +173 -0
- package/dist/commands/powdb.js +697 -0
- package/dist/index.js +8 -0
- package/package.json +1 -1
|
@@ -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
|
@@ -12,6 +12,7 @@ import { status } from "./commands/status.js";
|
|
|
12
12
|
import { connect } from "./commands/connect.js";
|
|
13
13
|
import { usage } from "./commands/usage.js";
|
|
14
14
|
import { handleRestore } from "./commands/restore.js";
|
|
15
|
+
import { handlePowdb } from "./commands/powdb.js";
|
|
15
16
|
import { link, unlink } from "./commands/link.js";
|
|
16
17
|
import { parseGlobalFlags } from "./args.js";
|
|
17
18
|
import { isJsonMode } from "./config.js";
|
|
@@ -64,6 +65,9 @@ function help() {
|
|
|
64
65
|
log(` ${colors.cyan("schema check|dump|diff")} Schema safety gate + introspection`);
|
|
65
66
|
log(` ${colors.cyan("migrate check")} Gate a migration against live query traffic ${colors.dim("(exit 2 if breaking)")}`);
|
|
66
67
|
log();
|
|
68
|
+
log(` ${colors.bold("PowDB (embedded lane, Stage A)")}`);
|
|
69
|
+
log(` ${colors.cyan("powdb pull")} Pull a branch's schema + data into a local PowDB-loadable PowQL script`);
|
|
70
|
+
log();
|
|
67
71
|
log(` ${colors.bold("Development")}`);
|
|
68
72
|
log(` ${colors.cyan("dev")} Local development setup guide`);
|
|
69
73
|
log();
|
|
@@ -173,6 +177,10 @@ async function main() {
|
|
|
173
177
|
case "schema":
|
|
174
178
|
await handleSchema(rest);
|
|
175
179
|
break;
|
|
180
|
+
// PowDB embedded lane (Stage A)
|
|
181
|
+
case "powdb":
|
|
182
|
+
await handlePowdb(rest);
|
|
183
|
+
break;
|
|
176
184
|
// Generate
|
|
177
185
|
case "generate":
|
|
178
186
|
await generate(rest);
|