@batadata/cli 0.2.9 → 0.2.10

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.
@@ -45,7 +45,7 @@ export interface TableSchema {
45
45
  constraints: ConstraintInfo[];
46
46
  }
47
47
  /** The Stage-A PowDB types a static PowQL artifact can carry. */
48
- export type PowType = "int" | "float" | "str" | "bool" | "datetime";
48
+ export type PowType = "int" | "float" | "str" | "bool" | "datetime" | "json";
49
49
  export interface TypeMapping {
50
50
  /** null → PowDB has no Stage-A representation; the column is DROPPED. */
51
51
  powType: PowType | null;
@@ -133,8 +133,16 @@ export interface TablePlan {
133
133
  pgName: string;
134
134
  powName: string;
135
135
  columns: ColumnPlan[];
136
+ /** powNames of carried single-column secondary (non-unique) indexes. */
137
+ secondaryIndexes: string[];
136
138
  warnings: string[];
137
139
  }
140
+ /**
141
+ * Extract the single plain column of a non-unique btree index definition, or
142
+ * null when the index can't be carried (multi-column, expression, partial,
143
+ * non-btree). Exported for unit testing.
144
+ */
145
+ export declare function parseSingleColumnIndex(definition: string): string | null;
138
146
  /**
139
147
  * Build the per-table PowQL `type` plan from a Postgres table's schema,
140
148
  * accumulating an honesty note for every column/constraint that can't be
@@ -143,6 +151,12 @@ export interface TablePlan {
143
151
  export declare function planTable(t: TableSchema): TablePlan;
144
152
  /** Render a table plan as a PowQL `type` DDL block. */
145
153
  export declare function renderTypeDdl(plan: TablePlan): string;
154
+ /**
155
+ * Render a plan's carried secondary indexes as `alter T add index .col;`
156
+ * statements (PowQL DDL, valid since 0.1.1; keyword columns backtick-quote
157
+ * inside the path, e.g. `alter Post add index .\`order\``).
158
+ */
159
+ export declare function renderIndexDdl(plan: TablePlan): string[];
146
160
  export interface InsertResult {
147
161
  statements: string[];
148
162
  warnings: string[];
@@ -8,7 +8,7 @@ import { emitError, isRetryable } from "../utils/errors.js";
8
8
  import { resolveProjectId, resolveBranchId } from "../link.js";
9
9
  // PowQL reserved words a column/table name must not collide with. Mirrors the
10
10
  // FULL engine lexer keyword set (crates/query/src/lexer.rs POWQL_KEYWORDS as
11
- // of PowDB 0.10.0 — which added `schema`/`describe`). A colliding name is
11
+ // of PowDB 0.13.0 — which added `raw`, `json_type`, `json_text`). A colliding name is
12
12
  // emitted backtick-quoted (0.10+ syntax) rather than renamed, so the exported
13
13
  // column keeps its source name. Keep this list in sync on PowDB upgrades: a
14
14
  // missing word here means a bare identifier that fails to parse on load.
@@ -21,7 +21,8 @@ const POWQL_KEYWORDS = new Set([
21
21
  "having", "in", "index", "inner", "insert", "is", "join", "left", "length",
22
22
  "let", "like", "limit", "link", "lower", "match", "materialize",
23
23
  "materialized", "max", "min", "multi", "not", "now", "null", "offset", "on",
24
- "or", "order", "outer", "over", "partition", "pow", "rank", "refresh",
24
+ "json_text", "json_type", "or", "order", "outer", "over", "partition",
25
+ "pow", "rank", "raw", "refresh",
25
26
  "required", "returning", "right", "rollback", "round", "row_number",
26
27
  "schema", "select", "sqrt", "substring", "sum", "then", "transaction",
27
28
  "trim", "true", "type", "union", "unique", "update", "upper", "upsert",
@@ -75,9 +76,14 @@ export function mapPgType(dataType) {
75
76
  if (t === "bytea") {
76
77
  return { powType: "str", note: "bytea carried as hex text (str) — PowDB has no static bytes literal for a script load" };
77
78
  }
78
- // json/jsonb carried as text; no JSON operators.
79
+ // json/jsonb native PowDB `json` (0.12+): canonical binary (PJ1), `->`
80
+ // path querying. Canonicalization is honest-but-lossy for exact text: object
81
+ // keys re-sort bytewise and duplicate keys collapse last-wins (same as pg jsonb).
79
82
  if (t === "json" || t === "jsonb") {
80
- return { powType: "str", note: `${t} carried as text (str) — no JSON operators in PowDB Stage A` };
83
+ return {
84
+ powType: "json",
85
+ note: `${t} stored as native PowDB json (requires PowDB 0.12+; canonical form — key order normalized${t === "json" ? ", duplicate keys last-wins" : ""})`,
86
+ };
81
87
  }
82
88
  // Everything else (arrays, enums, ranges, geometry, network, tsvector,
83
89
  // composite/user-defined) has no honest Stage-A representation.
@@ -183,6 +189,16 @@ export function powqlLiteral(value, powType) {
183
189
  const raw = typeof value === "string" ? value : JSON.stringify(value);
184
190
  return { literal: `"${escapePowqlString(raw)}"`, risky: isLoadRiskyString(raw) };
185
191
  }
192
+ case "json": {
193
+ // PowQL inserts JSON as a string literal (validated + canonicalized by
194
+ // the engine). Rows arrive as parsed JSON over SQL-over-HTTP, so
195
+ // re-stringify the VALUE — a JS string here is a JSON string scalar
196
+ // document and must be emitted quoted-inside-the-document.
197
+ const doc = JSON.stringify(value);
198
+ if (doc === undefined)
199
+ return { skip: true, reason: `value has no JSON representation` };
200
+ return { literal: `"${escapePowqlString(doc)}"`, risky: isLoadRiskyString(doc) };
201
+ }
186
202
  }
187
203
  }
188
204
  /**
@@ -229,9 +245,49 @@ export function parsePgDefault(defaultValue, powType) {
229
245
  return { kind: "literal", text: `"${escapePowqlString(m[1].replace(/''/g, "'"))}"` };
230
246
  return { kind: "drop", note: `expression default dropped (${raw})` };
231
247
  }
248
+ // json: a quoted literal default ('{}'::jsonb) carries as a string-literal
249
+ // default only if its content is valid JSON — the engine would reject the
250
+ // whole type DDL otherwise.
251
+ if (powType === "json") {
252
+ const m = /^'([\s\S]*)'$/.exec(noCast);
253
+ if (m) {
254
+ const text = m[1].replace(/''/g, "'");
255
+ try {
256
+ JSON.parse(text);
257
+ return { kind: "literal", text: `"${escapePowqlString(text)}"` };
258
+ }
259
+ catch {
260
+ return { kind: "drop", note: `non-JSON default on json column dropped (${raw})` };
261
+ }
262
+ }
263
+ return { kind: "drop", note: `expression default dropped (${raw})` };
264
+ }
232
265
  // datetime defaults are expressions (now(), CURRENT_TIMESTAMP) — never scalar.
233
266
  return { kind: "drop", note: `expression default dropped (${raw})` };
234
267
  }
268
+ /**
269
+ * Extract the single plain column of a non-unique btree index definition, or
270
+ * null when the index can't be carried (multi-column, expression, partial,
271
+ * non-btree). Exported for unit testing.
272
+ */
273
+ export function parseSingleColumnIndex(definition) {
274
+ if (/\bWHERE\b/i.test(definition))
275
+ return null; // partial
276
+ if (/\bINCLUDE\b/i.test(definition))
277
+ return null; // covering — the trailing parens are the INCLUDE list, not the key
278
+ if (/\bUSING\s+(?!btree\b)/i.test(definition))
279
+ return null; // gin/gist/hash/...
280
+ const m = /\(([^()]*)\)\s*$/.exec(definition);
281
+ if (!m)
282
+ return null; // expression index
283
+ const cols = m[1].split(",");
284
+ if (cols.length !== 1)
285
+ return null; // multi-column
286
+ const col = /^\s*(?:"([^"]+)"|([A-Za-z_][A-Za-z0-9_$]*))\s*(?:ASC|DESC)?\s*(?:NULLS\s+(?:FIRST|LAST))?\s*$/i.exec(cols[0]);
287
+ if (!col)
288
+ return null;
289
+ return col[1] ?? col[2];
290
+ }
235
291
  /**
236
292
  * Build the per-table PowQL `type` plan from a Postgres table's schema,
237
293
  * accumulating an honesty note for every column/constraint that can't be
@@ -264,14 +320,30 @@ export function planTable(t) {
264
320
  warnings.push(`foreign key ${c.name} → ${c.foreignTableName ?? "?"} dropped — PowDB Stage A has no foreign keys`);
265
321
  }
266
322
  else if (type.includes("CHECK")) {
323
+ // PG17+ surfaces NOT NULL as synthetic check constraints named
324
+ // <reloid>_<attnum>_..._not_null (all-numeric prefix). Those ARE carried
325
+ // (as `required`), so warning about them would be noise — but a
326
+ // USER-named check that merely ends in _not_null must still warn.
327
+ if (/^\d+(_\d+)*_not_null$/.test(c.name))
328
+ continue;
267
329
  warnings.push(`check constraint ${c.name} dropped — PowDB Stage A has no check constraints`);
268
330
  }
269
331
  }
270
- // Secondary (non-unique) indexes aren't carried the type definition only
271
- // expresses uniqueness, not standalone indexes.
332
+ // Single-column plain btree indexes carry as `alter T add index .col`
333
+ // statements (resolved against carried columns after the column loop below);
334
+ // anything else (multi-column, expression, partial, non-btree) is warned.
335
+ const pendingIndexCols = [];
272
336
  for (const idx of t.indexes ?? []) {
273
- if (!/unique/i.test(idx.definition)) {
274
- warnings.push(`index ${idx.name} not carried PowDB Stage A expresses only unique constraints, not secondary indexes`);
337
+ // Only a leading CREATE UNIQUE INDEX is a unique index — a bare substring
338
+ // match would silently skip an index whose NAME merely contains "unique".
339
+ if (/^\s*CREATE\s+UNIQUE\s+INDEX\b/i.test(idx.definition))
340
+ continue;
341
+ const pgCol = parseSingleColumnIndex(idx.definition);
342
+ if (pgCol) {
343
+ pendingIndexCols.push({ idxName: idx.name, pgCol });
344
+ }
345
+ else {
346
+ warnings.push(`index ${idx.name} not carried — only single-column plain btree indexes map to PowDB \`add index\``);
275
347
  }
276
348
  }
277
349
  const columns = [];
@@ -312,12 +384,27 @@ export function planTable(t) {
312
384
  defaultLiteral: auto ? null : defaultLiteral,
313
385
  });
314
386
  }
387
+ // Resolve carried secondary indexes against the columns that survived the
388
+ // mapping; an index on a dropped column is warned, not silently lost.
389
+ const byPgName = new Map(columns.map((c) => [c.pgName, c]));
390
+ const secondaryIndexes = [];
391
+ for (const { idxName, pgCol } of pendingIndexCols) {
392
+ const col = byPgName.get(pgCol);
393
+ if (!col) {
394
+ warnings.push(`index ${idxName} not carried — its column ${pgCol} was not representable`);
395
+ continue;
396
+ }
397
+ if (col.unique)
398
+ continue; // already an index via the unique modifier
399
+ secondaryIndexes.push(col.powName);
400
+ }
315
401
  return {
316
402
  pgQualified: `"${t.schema}"."${t.name}"`,
317
403
  pgSchema: t.schema,
318
404
  pgName: t.name,
319
405
  powName: tPowName,
320
406
  columns,
407
+ secondaryIndexes,
321
408
  warnings,
322
409
  };
323
410
  }
@@ -340,6 +427,14 @@ export function renderTypeDdl(plan) {
340
427
  lines.push("}");
341
428
  return lines.join("\n");
342
429
  }
430
+ /**
431
+ * Render a plan's carried secondary indexes as `alter T add index .col;`
432
+ * statements (PowQL DDL, valid since 0.1.1; keyword columns backtick-quote
433
+ * inside the path, e.g. `alter Post add index .\`order\``).
434
+ */
435
+ export function renderIndexDdl(plan) {
436
+ return plan.secondaryIndexes.map((powName) => `alter ${renderPowqlIdent(plan.powName)} add index .${renderPowqlIdent(powName)};`);
437
+ }
343
438
  /**
344
439
  * Render rows for one table as PowQL `insert` statements against its plan.
345
440
  * Values that can't be represented are omitted (the field falls back to
@@ -499,6 +594,7 @@ async function powdbPull(args) {
499
594
  const reports = [];
500
595
  let totalRows = 0;
501
596
  let anyLoadRisk = false;
597
+ let anyJson = false;
502
598
  for (const t of tables) {
503
599
  s?.update(`Exporting ${t.schema}.${t.name}`);
504
600
  const plan = planTable(t);
@@ -522,6 +618,8 @@ async function powdbPull(args) {
522
618
  }
523
619
  if (inserts.loadRisk)
524
620
  anyLoadRisk = true;
621
+ if (plan.columns.some((c) => c.powType === "json"))
622
+ anyJson = true;
525
623
  // Emit the type DDL, its honesty notes, and the inserts for this table.
526
624
  // Comments are `;`/newline-sanitized so they survive the `--exec` split.
527
625
  parts.push(commentSafe(`# ── ${t.schema}.${t.name} → ${plan.powName} (${inserts.statements.length} row(s)) ──`));
@@ -533,6 +631,9 @@ async function powdbPull(args) {
533
631
  }
534
632
  else {
535
633
  parts.push(ddl + ";");
634
+ const indexDdl = renderIndexDdl(plan);
635
+ if (indexDdl.length)
636
+ parts.push(indexDdl.join("\n"));
536
637
  parts.push("");
537
638
  if (inserts.statements.length) {
538
639
  parts.push(inserts.statements.join("\n"));
@@ -557,6 +658,7 @@ async function powdbPull(args) {
557
658
  tableCount: tables.length,
558
659
  rowCount: totalRows,
559
660
  loadRisk: anyLoadRisk,
661
+ usesJson: anyJson,
560
662
  limit,
561
663
  });
562
664
  const artifact = header + "\n" + parts.join("\n") + "\n";
@@ -629,7 +731,9 @@ function buildArtifactHeader(o) {
629
731
  // which lexes as two minus signs and fails to parse).
630
732
  const lines = [
631
733
  "# PowDB Stage A export — generated by `bata powdb pull`",
632
- "# Target: PowDB 0.10+ (reserved identifiers are backtick-quoted)",
734
+ o.usesJson
735
+ ? "# Target: PowDB 0.12+ (native json columns; reserved identifiers are backtick-quoted)"
736
+ : "# Target: PowDB 0.10+ (reserved identifiers are backtick-quoted)",
633
737
  "#",
634
738
  `# Source project : ${o.projectName} (${o.projectId})`,
635
739
  `# Source branch : ${o.branchName} (${o.branchId})`,
@@ -642,7 +746,8 @@ function buildArtifactHeader(o) {
642
746
  "#",
643
747
  "# COMPAT-HONEST: `# WARN:` lines below name every Postgres feature that",
644
748
  "# PowDB Stage A cannot carry faithfully (foreign keys, check constraints,",
645
- "# secondary indexes, unmapped types, expression defaults, lossy numerics).",
749
+ "# multi-column/expression/partial indexes, unmapped types, expression",
750
+ "# defaults, lossy numerics).",
646
751
  "# PowDB is NOT Postgres-compatible beyond this mapping; nothing here claims",
647
752
  "# otherwise, and no engine-speed comparison is implied.",
648
753
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@batadata/cli",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
4
4
  "description": "CLI for BataDB — serverless Postgres platform",
5
5
  "bin": {
6
6
  "bata": "./dist/index.js"