@batadata/cli 0.2.10 → 0.2.12

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.
@@ -136,7 +136,18 @@ export declare function missingExtensions(sourceExts: string[], targetHasExts: s
136
136
  * compute_ctl records applied internal SQL migrations here (table
137
137
  * `neon_migration.migration_id`) on EVERY compute startup. Real Neon hides this
138
138
  * schema from customer `pg_tables`; our compute exposes it, so ANY object under
139
- * `neon_migration.*` is platform furniture, never user data.
139
+ * `neon_migration.*` is platform furniture, never user data. Same story for
140
+ * `neon` — compute_ctl seeds it with engine helper functions on every startup,
141
+ * so a BataDB source's dump would carry `CREATE SCHEMA neon` straight into the
142
+ * identical schema every target is provisioned with.
143
+ *
144
+ * Named objects are treated differently from whole schemas. A source
145
+ * `public.health_check` is still dumped and verified as real data (colliding
146
+ * loudly on the target rather than being silently dropped). Scaffolding SCHEMAS,
147
+ * by contrast, are engine furniture on the source too whenever they exist, so
148
+ * they are excluded from the dump itself (pg_dump --exclude-schema) and from
149
+ * source-side verify accounting — the target's own engine already maintains its
150
+ * copies, and restoring them is guaranteed to fail with "already exists".
140
151
  */
141
152
  export declare const PLATFORM_SCAFFOLDING_TABLES: string[];
142
153
  export declare const PLATFORM_SCAFFOLDING_SEQUENCES: string[];
@@ -273,12 +273,23 @@ export function missingExtensions(sourceExts, targetHasExts) {
273
273
  * compute_ctl records applied internal SQL migrations here (table
274
274
  * `neon_migration.migration_id`) on EVERY compute startup. Real Neon hides this
275
275
  * schema from customer `pg_tables`; our compute exposes it, so ANY object under
276
- * `neon_migration.*` is platform furniture, never user data.
276
+ * `neon_migration.*` is platform furniture, never user data. Same story for
277
+ * `neon` — compute_ctl seeds it with engine helper functions on every startup,
278
+ * so a BataDB source's dump would carry `CREATE SCHEMA neon` straight into the
279
+ * identical schema every target is provisioned with.
280
+ *
281
+ * Named objects are treated differently from whole schemas. A source
282
+ * `public.health_check` is still dumped and verified as real data (colliding
283
+ * loudly on the target rather than being silently dropped). Scaffolding SCHEMAS,
284
+ * by contrast, are engine furniture on the source too whenever they exist, so
285
+ * they are excluded from the dump itself (pg_dump --exclude-schema) and from
286
+ * source-side verify accounting — the target's own engine already maintains its
287
+ * copies, and restoring them is guaranteed to fail with "already exists".
277
288
  */
278
289
  export const PLATFORM_SCAFFOLDING_TABLES = ["public.health_check"];
279
290
  export const PLATFORM_SCAFFOLDING_SEQUENCES = ["public.health_check_id_seq"];
280
291
  /** Schemas whose ENTIRE contents are platform furniture (any `<schema>.*` object). */
281
- export const PLATFORM_SCAFFOLDING_SCHEMAS = ["neon_migration"];
292
+ export const PLATFORM_SCAFFOLDING_SCHEMAS = ["neon_migration", "neon"];
282
293
  let PLATFORM_SCAFFOLDING_NAMES = new Set([
283
294
  ...PLATFORM_SCAFFOLDING_TABLES,
284
295
  ...PLATFORM_SCAFFOLDING_SEQUENCES,
@@ -432,7 +443,11 @@ function runMigration(sourceUri, targetUri) {
432
443
  // data corruption that row-count verify wouldn't catch. Forcing UTF8 makes the
433
444
  // server convert, guaranteeing a valid-UTF-8 stream; the dump carries
434
445
  // `SET client_encoding = 'UTF8';`, which restores correctly into any target.
435
- const dump = spawn("pg_dump", [sourceUri, "--no-owner", "--no-privileges", "--encoding=UTF8"], {
446
+ // Scaffolding SCHEMAS are excluded at the dump: every target is provisioned
447
+ // with its own copies, so restoring them can only fail ("already exists").
448
+ // Read at call time — applyServerScaffolding() may have extended the list.
449
+ const excludeArgs = PLATFORM_SCAFFOLDING_SCHEMAS.flatMap((s) => ["--exclude-schema", s]);
450
+ const dump = spawn("pg_dump", [sourceUri, "--no-owner", "--no-privileges", "--encoding=UTF8", ...excludeArgs], {
436
451
  stdio: ["ignore", "pipe", "pipe"],
437
452
  env,
438
453
  });
@@ -833,10 +848,20 @@ export async function importDb(args) {
833
848
  const sourceScaffoldingHits = srcCollisionRes.code === 0
834
849
  ? scaffoldingCollisions(srcCollisionRes.stdout.split("\n").map((s) => s.trim()).filter(Boolean))
835
850
  : [];
836
- if (sourceScaffoldingHits.length > 0 && !jsonMode) {
837
- warn(`Source contains ${sourceScaffoldingHits.join(", ")}, which shares a name with BataDB's seeded ` +
838
- 'platform scaffolding on the target. The restore will likely fail with "relation already ' +
839
- 'exists" rename or exclude it on the source to migrate cleanly.');
851
+ // Schema-level hits are engine furniture and are EXCLUDED from the dump
852
+ // outright (see runMigration); only named-object collisions still restore
853
+ // into a clash on the target.
854
+ const schemaHits = sourceScaffoldingHits.filter((n) => PLATFORM_SCAFFOLDING_SCHEMAS.some((s) => n.startsWith(`${s}.`)));
855
+ const namedHits = sourceScaffoldingHits.filter((n) => !schemaHits.includes(n));
856
+ if (schemaHits.length > 0 && !jsonMode) {
857
+ warn(`Source contains engine-internal object(s) ${schemaHits.join(", ")} — platform furniture the ` +
858
+ "target maintains itself. They are skipped by the migration (not customer data).");
859
+ }
860
+ if (namedHits.length > 0 && !jsonMode) {
861
+ warn(`Source contains ${namedHits.join(", ")}, which shares a name with BataDB's seeded ` +
862
+ "platform scaffolding. On a freshly created target the seeded copy is dropped so the " +
863
+ "source's version restores as real data; importing into an existing --project target " +
864
+ 'will likely fail with "relation already exists".');
840
865
  }
841
866
  // ── Phase 2: check pg_dump AND psql are new enough ──────────────────────────
842
867
  // BOTH matter: pg_dump can't dump a newer server than itself, and psql must be
@@ -1048,6 +1073,27 @@ export async function importDb(args) {
1048
1073
  const strandedHintJson = "The target project was left in place (NOT deleted) for diagnosis/retry — re-run with " +
1049
1074
  `--project ${targetProjectId} --yes after fixing` +
1050
1075
  (created ? `, or remove it with: bata projects delete ${targetProjectId} --yes.` : ".");
1076
+ // A freshly CREATED target's named scaffolding (public.health_check + its
1077
+ // sequence) is disposable furniture — but the SOURCE may carry same-named
1078
+ // tables (always true migrating FROM another BataDB, where provisioning
1079
+ // seeded them too). Doctrine says source objects are real data and must
1080
+ // restore + verify normally, so drop the target's seeded copies first;
1081
+ // otherwise the restore is GUARANTEED to die on "already exists". Never
1082
+ // touched on a --project target (created === false): its objects may be the
1083
+ // user's own data, and the old collide-loudly behavior stays.
1084
+ if (created && namedHits.length > 0) {
1085
+ phase("Dropping seeded scaffolding the source will re-create");
1086
+ for (const qn of namedHits) {
1087
+ const dot = qn.indexOf(".");
1088
+ const dropSql = `DROP TABLE IF EXISTS ${quoteIdent(qn.slice(0, dot))}.${quoteIdent(qn.slice(dot + 1))} CASCADE`;
1089
+ const dropRes = runPsql(targetUri, dropSql);
1090
+ if (dropRes.code !== 0) {
1091
+ emitError("RESTORE_FAILED", `Could not clear the target's seeded ${qn} before restore.`, `The source has its own ${qn}, which cannot restore over the target's seeded copy. ` +
1092
+ strandedHintJson, 1);
1093
+ return;
1094
+ }
1095
+ }
1096
+ }
1051
1097
  // ── Phase 4: migrate ───────────────────────────────────────────────────────
1052
1098
  phase("Migrating schema + data (pg_dump | psql)");
1053
1099
  const migration = await runMigration(source, targetUri);
@@ -1083,7 +1129,11 @@ export async function importDb(args) {
1083
1129
  "FROM pg_tables WHERE schemaname NOT IN ('pg_catalog','information_schema')");
1084
1130
  if (tableListRes.code !== 0)
1085
1131
  verifyFailed = "could not list source tables";
1086
- const sourceTables = tableListRes.code === 0 ? safeJsonArray(tableListRes.stdout) : [];
1132
+ // Scaffolding-SCHEMA tables were deliberately excluded from the dump (the
1133
+ // target engine maintains its own copies), so they must not be counted on the
1134
+ // source side either — they'd read as "missing on target" mismatches. Named
1135
+ // scaffolding objects (public.health_check) stay in: they ARE dumped.
1136
+ const sourceTables = (tableListRes.code === 0 ? safeJsonArray(tableListRes.stdout) : []).filter((t) => !PLATFORM_SCAFFOLDING_SCHEMAS.includes(t.schema));
1087
1137
  const srcCounts = new Map();
1088
1138
  const tgtCounts = new Map();
1089
1139
  if (!verifyFailed && sourceTables.length > 0) {
@@ -1125,8 +1175,13 @@ export async function importDb(args) {
1125
1175
  verifyFailed = "sequence query failed";
1126
1176
  }
1127
1177
  else {
1128
- for (const [k, v] of parseKvRows(sSeqRes.stdout))
1129
- srcSeq.set(k, v);
1178
+ // Mirror the table filter: source sequences inside scaffolding schemas
1179
+ // were never dumped, so they can't be expected on the target.
1180
+ for (const [k, v] of parseKvRows(sSeqRes.stdout)) {
1181
+ const schema = k.slice(0, k.indexOf("."));
1182
+ if (!PLATFORM_SCAFFOLDING_SCHEMAS.includes(schema))
1183
+ srcSeq.set(k, v);
1184
+ }
1130
1185
  for (const [k, v] of parseKvRows(tSeqRes.stdout))
1131
1186
  tgtSeq.set(k, v);
1132
1187
  }
@@ -20,6 +20,7 @@ export declare function list(): Promise<void>;
20
20
  export declare function parseCreateComputeArgs(args: string[]): {
21
21
  tier: ComputeTier;
22
22
  sizeCu: number;
23
+ placement?: "fly" | "density";
23
24
  } | {
24
25
  error: string;
25
26
  };
@@ -108,18 +108,21 @@ export async function list() {
108
108
  export function parseCreateComputeArgs(args) {
109
109
  let tierRaw;
110
110
  let sizeRaw;
111
+ let placementRaw;
111
112
  for (let i = 0; i < args.length; i++) {
112
113
  const arg = args[i];
113
- if (arg === "--tier" || arg === "--size") {
114
+ if (arg === "--tier" || arg === "--size" || arg === "--placement") {
114
115
  // The spaced form needs a value: reject a missing one (end of args, or
115
116
  // the next token is another flag) instead of silently using the default.
116
117
  const next = args[i + 1];
117
118
  if (next === undefined || next.startsWith("-")) {
118
- const usage = arg === "--tier" ? "serverless|always-on" : "1|2|4|8|16|32|64|128";
119
+ const usage = arg === "--tier" ? "serverless|always-on" : arg === "--placement" ? "fly|density" : "1|2|4|8|16|32|64|128";
119
120
  return { error: `${arg} requires a value (${usage}).` };
120
121
  }
121
122
  if (arg === "--tier")
122
123
  tierRaw = next;
124
+ else if (arg === "--placement")
125
+ placementRaw = next;
123
126
  else
124
127
  sizeRaw = next;
125
128
  i++;
@@ -128,6 +131,8 @@ export function parseCreateComputeArgs(args) {
128
131
  tierRaw = arg.slice("--tier=".length);
129
132
  else if (arg.startsWith("--size="))
130
133
  sizeRaw = arg.slice("--size=".length);
134
+ else if (arg.startsWith("--placement="))
135
+ placementRaw = arg.slice("--placement=".length);
131
136
  }
132
137
  let tier = "serverless";
133
138
  if (tierRaw !== undefined) {
@@ -158,7 +163,19 @@ export function parseCreateComputeArgs(args) {
158
163
  }
159
164
  sizeCu = parsed;
160
165
  }
161
- return { tier, sizeCu };
166
+ let placement;
167
+ if (placementRaw !== undefined) {
168
+ const normalized = placementRaw.toLowerCase();
169
+ if (normalized === "fly" || normalized === "density")
170
+ placement = normalized;
171
+ else
172
+ return { error: `Invalid --placement "${placementRaw}". Use "fly" or "density".` };
173
+ // Placement pins a SERVERLESS compute's provider; always-on is Fly always.
174
+ if (tier === "always_on") {
175
+ return { error: `--placement applies to serverless projects only (always-on runs on Fly).` };
176
+ }
177
+ }
178
+ return { tier, sizeCu, ...(placement ? { placement } : {}) };
162
179
  }
163
180
  /** `--flag <value>` / `--flag=<value>` reader. Distinguishes "absent"
164
181
  * (undefined) from "present but missing its value" ({ error }) so a headless
@@ -275,6 +292,7 @@ export async function create(args = []) {
275
292
  // One friendly step: the compute is born on the chosen tier + size (no
276
293
  // follow-up `bata compute set` needed). Defaults to serverless / 1 CU.
277
294
  compute: { tier: compute.tier, size_cu: compute.sizeCu },
295
+ ...(compute.placement ? { placement: compute.placement } : {}),
278
296
  };
279
297
  if (teamId) {
280
298
  body.team_id = teamId;
package/dist/index.js CHANGED
@@ -51,7 +51,7 @@ function help() {
51
51
  log(` ${colors.bold("Projects")}`);
52
52
  log(` ${colors.cyan("projects")} List all projects`);
53
53
  log(` ${colors.cyan("projects list")} Alias for status`);
54
- log(` ${colors.cyan("projects create")} Create a new project ${colors.dim("(--tier serverless|always-on, --size 1|2|4|8|16|32|64|128)")}`);
54
+ log(` ${colors.cyan("projects create")} Create a new project ${colors.dim("(--tier serverless|always-on, --size 1|2|4|8|16|32|64|128, --placement fly|density)")}`);
55
55
  log(` ${colors.cyan("projects info")} Show project details`);
56
56
  log(` ${colors.cyan("projects delete")} Delete a project`);
57
57
  log();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@batadata/cli",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "CLI for BataDB — serverless Postgres platform",
5
5
  "bin": {
6
6
  "bata": "./dist/index.js"