@abloatai/ablo 0.16.2 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs CHANGED
@@ -277378,7 +277378,7 @@ var ErrorBodyShapeSchema = import_zod4.z.object({
277378
277378
  }).passthrough();
277379
277379
 
277380
277380
  // src/cli/migrate.ts
277381
- var import_picocolors4 = __toESM(require_picocolors(), 1);
277381
+ var import_picocolors3 = __toESM(require_picocolors(), 1);
277382
277382
  var import_fs5 = require("fs");
277383
277383
 
277384
277384
  // node_modules/postgres/src/index.js
@@ -278098,7 +278098,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
278098
278098
  async function cancel({ pid, secret }, resolve6, reject) {
278099
278099
  try {
278100
278100
  cancelMessage = bytes_default().i32(16).i32(80877102).i32(pid).i32(secret).end(16);
278101
- await connect();
278101
+ await connect2();
278102
278102
  socket.once("error", reject);
278103
278103
  socket.once("close", resolve6);
278104
278104
  } catch (error2) {
@@ -278231,7 +278231,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
278231
278231
  incomings = null;
278232
278232
  }
278233
278233
  }
278234
- async function connect() {
278234
+ async function connect2() {
278235
278235
  terminated = false;
278236
278236
  backendParameters = {};
278237
278237
  socket || (socket = await createSocket());
@@ -278250,7 +278250,7 @@ function Connection(options, queues = {}, { onopen = noop, onend = noop, onclose
278250
278250
  hostIndex = (hostIndex + 1) % port.length;
278251
278251
  }
278252
278252
  function reconnect() {
278253
- setTimeout(connect, closedTime ? Math.max(0, closedTime + delay - import_perf_hooks.performance.now()) : 0);
278253
+ setTimeout(connect2, closedTime ? Math.max(0, closedTime + delay - import_perf_hooks.performance.now()) : 0);
278254
278254
  }
278255
278255
  function connected() {
278256
278256
  try {
@@ -279252,7 +279252,7 @@ function Postgres(a, b4) {
279252
279252
  const c = open.length ? open.shift() : await new Promise((resolve6, reject) => {
279253
279253
  const query = { reserve: resolve6, reject };
279254
279254
  queries.push(query);
279255
- closed.length && connect(closed.shift(), query);
279255
+ closed.length && connect2(closed.shift(), query);
279256
279256
  });
279257
279257
  move(c, reserved);
279258
279258
  c.reserved = () => queue.length ? c.execute(queue.shift()) : move(c, reserved);
@@ -279339,7 +279339,7 @@ function Postgres(a, b4) {
279339
279339
  if (open.length)
279340
279340
  return go(open.shift(), query);
279341
279341
  if (closed.length)
279342
- return connect(closed.shift(), query);
279342
+ return connect2(closed.shift(), query);
279343
279343
  busy.length ? go(busy.shift(), query) : queries.push(query);
279344
279344
  }
279345
279345
  function go(c, query) {
@@ -279372,7 +279372,7 @@ function Postgres(a, b4) {
279372
279372
  queries.shift().reject(Errors.connection("CONNECTION_DESTROYED", options));
279373
279373
  resolve6();
279374
279374
  }
279375
- function connect(c, query) {
279375
+ function connect2(c, query) {
279376
279376
  move(c, connecting);
279377
279377
  c.connect(query);
279378
279378
  return c;
@@ -279397,7 +279397,7 @@ function Postgres(a, b4) {
279397
279397
  c.reserved = null;
279398
279398
  c.onclose && (c.onclose(e2), c.onclose = null);
279399
279399
  options.onclose && options.onclose(c.id);
279400
- queries.length && connect(c, queries.shift());
279400
+ queries.length && connect2(c, queries.shift());
279401
279401
  }
279402
279402
  }
279403
279403
  function parseOptions(a, b4) {
@@ -279517,158 +279517,8 @@ function osUsername() {
279517
279517
 
279518
279518
  // src/cli/dbRole.ts
279519
279519
  init_cjs_shims();
279520
- var import_crypto2 = require("crypto");
279521
- var import_picocolors2 = __toESM(require_picocolors(), 1);
279522
279520
  var import_fs2 = require("fs");
279523
279521
  var import_path = require("path");
279524
- var DEFAULT_SCOPED_ROLE = "ablo_app";
279525
- async function detectRoleSafety(sql) {
279526
- const rows = await sql`SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user`;
279527
- const row = rows[0];
279528
- if (!row) return { role: "unknown", superuser: false, bypassRls: false, unsafe: false };
279529
- return {
279530
- role: row.rolname,
279531
- superuser: row.rolsuper,
279532
- bypassRls: row.rolbypassrls,
279533
- unsafe: row.rolsuper || row.rolbypassrls
279534
- };
279535
- }
279536
- function generateRolePassword() {
279537
- return (0, import_crypto2.randomBytes)(24).toString("base64url");
279538
- }
279539
- function scramSha256Verifier(password, iterations = 4096) {
279540
- const salt = (0, import_crypto2.randomBytes)(16);
279541
- const saltedPassword = (0, import_crypto2.pbkdf2Sync)(password, salt, iterations, 32, "sha256");
279542
- const clientKey = (0, import_crypto2.createHmac)("sha256", saltedPassword).update("Client Key").digest();
279543
- const storedKey = (0, import_crypto2.createHash)("sha256").update(clientKey).digest();
279544
- const serverKey = (0, import_crypto2.createHmac)("sha256", saltedPassword).update("Server Key").digest();
279545
- return `SCRAM-SHA-256$${iterations}:${salt.toString("base64")}$${storedKey.toString("base64")}:${serverKey.toString("base64")}`;
279546
- }
279547
- function scopedRoleStatements(input) {
279548
- const role = input.role ?? DEFAULT_SCOPED_ROLE;
279549
- const q2 = (id) => `"${id.replace(/"/g, '""')}"`;
279550
- const pw = (input.passwordMode ?? "scram-verifier") === "scram-verifier" ? scramSha256Verifier(input.password) : input.password.replace(/'/g, "''");
279551
- return [
279552
- `DO $$ BEGIN
279553
- CREATE ROLE ${q2(role)} LOGIN PASSWORD '${pw}'
279554
- NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE;
279555
- EXCEPTION WHEN duplicate_object THEN
279556
- -- Rerun: rotate ONLY the password. Re-asserting attributes here trips
279557
- -- managed-Postgres permission walls (Neon: "permission denied to alter
279558
- -- role" for attribute changes by non-superusers); the attributes were set
279559
- -- at creation, and the server-side probe still audits the live role.
279560
- ALTER ROLE ${q2(role)} WITH LOGIN PASSWORD '${pw}';
279561
- END $$;`,
279562
- `GRANT CREATE, CONNECT ON DATABASE ${q2(input.database)} TO ${q2(role)};`,
279563
- `GRANT CREATE, USAGE ON SCHEMA public TO ${q2(role)};`
279564
- ];
279565
- }
279566
- function rewriteDatabaseUrl(ownerUrl, role, password) {
279567
- const url = new URL(ownerUrl);
279568
- url.username = role;
279569
- url.password = password;
279570
- return url.toString();
279571
- }
279572
- async function createScopedRole(ownerUrl, options) {
279573
- const role = options?.role ?? DEFAULT_SCOPED_ROLE;
279574
- const password = generateRolePassword();
279575
- const database = new URL(ownerUrl).pathname.replace(/^\//, "") || "postgres";
279576
- const sql = src_default(ownerUrl, { max: 1, prepare: false, onnotice: () => {
279577
- } });
279578
- try {
279579
- try {
279580
- for (const statement of scopedRoleStatements({ database, role, password })) {
279581
- await sql.unsafe(statement);
279582
- }
279583
- } catch (err) {
279584
- const message = err instanceof Error ? err.message : String(err);
279585
- if (!/plaintext password/i.test(message)) throw err;
279586
- for (const statement of scopedRoleStatements({ database, role, password, passwordMode: "plaintext" })) {
279587
- await sql.unsafe(statement);
279588
- }
279589
- }
279590
- } finally {
279591
- await sql.end({ timeout: 5 });
279592
- }
279593
- return { role, databaseUrl: rewriteDatabaseUrl(ownerUrl, role, password) };
279594
- }
279595
- async function ensureScopedRoleInteractive(dbUrl) {
279596
- let safety;
279597
- try {
279598
- const sql = src_default(dbUrl, { max: 1, prepare: false, onnotice: () => {
279599
- } });
279600
- try {
279601
- safety = await detectRoleSafety(sql);
279602
- } finally {
279603
- await sql.end({ timeout: 5 });
279604
- }
279605
- } catch {
279606
- return dbUrl;
279607
- }
279608
- if (!safety.unsafe) return dbUrl;
279609
- const why = safety.superuser ? "a superuser" : "BYPASSRLS";
279610
- console.log(
279611
- `
279612
- ${import_picocolors2.default.yellow("!")} DATABASE_URL connects as ${import_picocolors2.default.bold(safety.role)} \u2014 ${why}, so row-level security can't be enforced.
279613
- Ablo's server will refuse this connection (${import_picocolors2.default.bold("database_role_cannot_enforce_rls")}).`
279614
- );
279615
- if (!process.stdout.isTTY) {
279616
- console.log(
279617
- import_picocolors2.default.dim(
279618
- ` Create a scoped role and update DATABASE_URL \u2014 run \`npx ablo migrate\` interactively
279619
- to do it automatically, or see https://docs.abloatai.com/quickstart#scoped-role`
279620
- )
279621
- );
279622
- return dbUrl;
279623
- }
279624
- const proceed = await ye({
279625
- message: `Create a scoped role ${DEFAULT_SCOPED_ROLE} (NOSUPERUSER, NOBYPASSRLS) and update DATABASE_URL?`,
279626
- initialValue: true
279627
- });
279628
- if (pD(proceed) || !proceed) {
279629
- console.log(import_picocolors2.default.dim(" Skipped \u2014 see https://docs.abloatai.com/quickstart#scoped-role for the manual recipe."));
279630
- return dbUrl;
279631
- }
279632
- const { role, databaseUrl } = await createScopedRole(dbUrl);
279633
- const where = persistDatabaseUrl(databaseUrl);
279634
- console.log(
279635
- ` ${import_picocolors2.default.green("\u2713")} Created role ${import_picocolors2.default.bold(role)} and updated ${import_picocolors2.default.bold("DATABASE_URL")} in ${import_picocolors2.default.bold(where)}.
279636
- ` + import_picocolors2.default.dim(` The owner credential never left this machine; the new password was written, not printed.`)
279637
- );
279638
- return databaseUrl;
279639
- }
279640
- function persistDatabaseUrl(databaseUrl, cwd = process.cwd()) {
279641
- const line = `DATABASE_URL=${databaseUrl}`;
279642
- for (const name of [".env.local", ".env"]) {
279643
- const path = (0, import_path.resolve)(cwd, name);
279644
- if (!(0, import_fs2.existsSync)(path)) continue;
279645
- const content = (0, import_fs2.readFileSync)(path, "utf8");
279646
- if (/^DATABASE_URL=/m.test(content)) {
279647
- (0, import_fs2.writeFileSync)(path, content.replace(/^DATABASE_URL=.*$/m, line));
279648
- return name;
279649
- }
279650
- }
279651
- const envLocal = (0, import_path.resolve)(cwd, ".env.local");
279652
- if ((0, import_fs2.existsSync)(envLocal)) {
279653
- const content = (0, import_fs2.readFileSync)(envLocal, "utf8");
279654
- (0, import_fs2.appendFileSync)(envLocal, `${content.endsWith("\n") || content.length === 0 ? "" : "\n"}${line}
279655
- `);
279656
- } else {
279657
- (0, import_fs2.writeFileSync)(envLocal, `${line}
279658
- `, { mode: 384 });
279659
- }
279660
- const gitignorePath = (0, import_path.resolve)(cwd, ".gitignore");
279661
- const gitignore = (0, import_fs2.existsSync)(gitignorePath) ? (0, import_fs2.readFileSync)(gitignorePath, "utf8") : "";
279662
- if (!/^(\.env\.local|\.env\*|\.env\.\*|\.env.*)$/m.test(gitignore)) {
279663
- (0, import_fs2.writeFileSync)(
279664
- gitignorePath,
279665
- `${gitignore.endsWith("\n") || gitignore.length === 0 ? gitignore : `${gitignore}
279666
- `}.env.local
279667
- `
279668
- );
279669
- }
279670
- return ".env.local";
279671
- }
279672
279522
  function readProjectDatabaseUrl(cwd = process.cwd()) {
279673
279523
  const fromEnv = process.env.DATABASE_URL ?? process.env.ABLO_DATABASE_URL;
279674
279524
  if (fromEnv) return fromEnv;
@@ -279687,7 +279537,7 @@ var import_source = require("@abloatai/ablo/source");
279687
279537
 
279688
279538
  // src/cli/push.ts
279689
279539
  init_cjs_shims();
279690
- var import_picocolors3 = __toESM(require_picocolors(), 1);
279540
+ var import_picocolors2 = __toESM(require_picocolors(), 1);
279691
279541
 
279692
279542
  // src/auth/credentialPolicy.ts
279693
279543
  init_cjs_shims();
@@ -279929,7 +279779,16 @@ var DEFAULT_URL = "https://api.abloatai.com";
279929
279779
  function fmtSignal(s) {
279930
279780
  const sig = s;
279931
279781
  const where = sig.field ? `${sig.model}.${sig.field}` : sig.model;
279932
- return ` \u2022 ${import_picocolors3.default.bold(where ?? "?")} \u2014 ${sig.detail ?? ""}`;
279782
+ let line = ` \u2022 ${import_picocolors2.default.bold(where ?? "?")} \u2014 ${sig.detail ?? ""}`;
279783
+ if (sig.shadowed) {
279784
+ const env = sig.shadowed.environment ?? "production";
279785
+ const ver = sig.shadowed.version != null ? `v${sig.shadowed.version}` : "active";
279786
+ const when = sig.shadowed.pushedAt ? new Date(sig.shadowed.pushedAt).toISOString().slice(0, 10) : "unknown date";
279787
+ const by = sig.shadowed.pushedBy ? ` by ${sig.shadowed.pushedBy}` : "";
279788
+ line += `
279789
+ ${import_picocolors2.default.dim(`\u21B3 baseline: ${env} ${ver}, pushed ${when}${by}`)}`;
279790
+ }
279791
+ return line;
279933
279792
  }
279934
279793
  async function pushSchema(schema, args) {
279935
279794
  const schemaJson = JSON.parse((0, import_schema2.serializeSchema)(schema));
@@ -280010,7 +279869,7 @@ async function loadSchema(schemaPath, exportName) {
280010
279869
  const abs = (0, import_path3.resolve)(process.cwd(), schemaPath);
280011
279870
  if (!(0, import_fs4.existsSync)(abs)) {
280012
279871
  throw new AbloValidationError(
280013
- `schema not found at ${import_picocolors3.default.bold(schemaPath)}. Run ${import_picocolors3.default.bold("npx ablo init")} or pass ${import_picocolors3.default.bold("--schema <path>")}.`,
279872
+ `schema not found at ${import_picocolors2.default.bold(schemaPath)}. Run ${import_picocolors2.default.bold("npx ablo init")} or pass ${import_picocolors2.default.bold("--schema <path>")}.`,
280014
279873
  { code: "cli_invalid_arguments" }
280015
279874
  );
280016
279875
  }
@@ -280021,7 +279880,7 @@ async function loadSchema(schemaPath, exportName) {
280021
279880
  const schema = mod[exportName] ?? nested?.[exportName];
280022
279881
  if (!schema || typeof schema !== "object" || !("models" in schema)) {
280023
279882
  throw new AbloValidationError(
280024
- `${import_picocolors3.default.bold(schemaPath)} has no \`${exportName}\` export that looks like a Schema. Did you \`export const ${exportName} = defineSchema({ ... })\`?`,
279883
+ `${import_picocolors2.default.bold(schemaPath)} has no \`${exportName}\` export that looks like a Schema. Did you \`export const ${exportName} = defineSchema({ ... })\`?`,
280025
279884
  { code: "cli_invalid_arguments" }
280026
279885
  );
280027
279886
  }
@@ -280032,14 +279891,14 @@ async function push(argv) {
280032
279891
  try {
280033
279892
  args = parsePushArgs(argv);
280034
279893
  } catch (err) {
280035
- console.error(import_picocolors3.default.red(` ${err instanceof Error ? err.message : String(err)}`));
279894
+ console.error(import_picocolors2.default.red(` ${err instanceof Error ? err.message : String(err)}`));
280036
279895
  process.exit(1);
280037
279896
  }
280038
279897
  if (!args.apiKey) args.apiKey = resolveApiKey();
280039
279898
  if (!args.apiKey) {
280040
279899
  console.error(
280041
- import_picocolors3.default.red(` No API key.`) + import_picocolors3.default.dim(
280042
- ` Run ${import_picocolors3.default.bold("npx ablo login")} for the sandbox dev loop \u2014 or set ${import_picocolors3.default.bold("ABLO_API_KEY")} (${import_picocolors3.default.bold("sk_test_")} = sandbox; ${import_picocolors3.default.bold("sk_live_")} = deliberate production deploy). Mode is currently '${getMode()}'.`
279900
+ import_picocolors2.default.red(` No API key.`) + import_picocolors2.default.dim(
279901
+ ` Run ${import_picocolors2.default.bold("npx ablo login")} for the sandbox dev loop \u2014 or set ${import_picocolors2.default.bold("ABLO_API_KEY")} (${import_picocolors2.default.bold("sk_test_")} = sandbox; ${import_picocolors2.default.bold("sk_live_")} = deliberate production deploy). Mode is currently '${getMode()}'.`
280043
279902
  )
280044
279903
  );
280045
279904
  process.exit(1);
@@ -280047,17 +279906,17 @@ async function push(argv) {
280047
279906
  const schema = await loadSchema(args.schemaPath, args.exportName);
280048
279907
  const hash = (0, import_schema2.schemaHash)(schema);
280049
279908
  console.log(
280050
- ` Pushing ${import_picocolors3.default.bold(args.schemaPath)} ${import_picocolors3.default.dim(`(${Object.keys(schema.models).length} models, hash ${hash})`)} \u2192 ${import_picocolors3.default.dim(args.url)}`
279909
+ ` Pushing ${import_picocolors2.default.bold(args.schemaPath)} ${import_picocolors2.default.dim(`(${Object.keys(schema.models).length} models, hash ${hash})`)} \u2192 ${import_picocolors2.default.dim(args.url)}`
280051
279910
  );
280052
279911
  const { ok: resOk, status: status2, body, bodyText } = await pushSchema(schema, args);
280053
279912
  if (resOk) {
280054
279913
  if (body.unchanged) {
280055
- console.log(` ${import_picocolors3.default.dim("\u25CB")} No changes \u2014 schema already active (v${body.version}).`);
279914
+ console.log(` ${import_picocolors2.default.dim("\u25CB")} No changes \u2014 schema already active (v${body.version}).`);
280056
279915
  } else {
280057
- console.log(` ${import_picocolors3.default.green("\u2713")} Activated ${import_picocolors3.default.bold(`v${body.version}`)} ${import_picocolors3.default.dim(`(hash ${body.hash})`)}`);
279916
+ console.log(` ${import_picocolors2.default.green("\u2713")} Activated ${import_picocolors2.default.bold(`v${body.version}`)} ${import_picocolors2.default.dim(`(hash ${body.hash})`)}`);
280058
279917
  if (Array.isArray(body.warnings) && body.warnings.length > 0) {
280059
- console.log(import_picocolors3.default.yellow(` Applied ${body.warnings.length} destructive change(s):`));
280060
- for (const w2 of body.warnings) console.log(import_picocolors3.default.yellow(fmtSignal(w2)));
279918
+ console.log(import_picocolors2.default.yellow(` Applied ${body.warnings.length} destructive change(s):`));
279919
+ for (const w2 of body.warnings) console.log(import_picocolors2.default.yellow(fmtSignal(w2)));
280061
279920
  }
280062
279921
  }
280063
279922
  return;
@@ -280065,27 +279924,56 @@ async function push(argv) {
280065
279924
  if (status2 === 409) {
280066
279925
  const unexecutable = Array.isArray(body.unexecutable) ? body.unexecutable : [];
280067
279926
  const warnings = Array.isArray(body.warnings) ? body.warnings : [];
280068
- console.error(import_picocolors3.default.red(" Incompatible change \u2014 this push is not safe to apply as-is."));
279927
+ console.error(import_picocolors2.default.red(" Incompatible change \u2014 this push is not safe to apply as-is."));
280069
279928
  if (unexecutable.length > 0) {
280070
- console.error(import_picocolors3.default.red(` Unexecutable (would fail on existing rows):`));
280071
- for (const u2 of unexecutable) console.error(import_picocolors3.default.red(fmtSignal(u2)));
279929
+ console.error(import_picocolors2.default.red(` Unexecutable (would fail on existing rows):`));
279930
+ for (const u2 of unexecutable) console.error(import_picocolors2.default.red(fmtSignal(u2)));
280072
279931
  }
280073
279932
  if (warnings.length > 0) {
280074
- console.error(import_picocolors3.default.yellow(` Destructive (data loss):`));
280075
- for (const w2 of warnings) console.error(import_picocolors3.default.yellow(fmtSignal(w2)));
279933
+ console.error(import_picocolors2.default.yellow(` Destructive (data loss):`));
279934
+ for (const w2 of warnings) console.error(import_picocolors2.default.yellow(fmtSignal(w2)));
279935
+ }
279936
+ const hasShadowed = [...unexecutable, ...warnings].some(
279937
+ (s) => s.shadowed != null
279938
+ );
279939
+ if (hasShadowed) {
279940
+ console.error(
279941
+ import_picocolors2.default.dim(
279942
+ " These models exist in the baseline above but not in your push. Sandbox readers fall"
279943
+ )
279944
+ );
279945
+ console.error(
279946
+ import_picocolors2.default.dim(
279947
+ " back to the production schema until you push your own, so applying this drops them."
279948
+ )
279949
+ );
280076
279950
  }
280077
- console.error(import_picocolors3.default.dim(` Re-push with ${import_picocolors3.default.bold("--force")} to override, or use ${import_picocolors3.default.bold("--rename old:new")} if you renamed a model.`));
279951
+ console.error(import_picocolors2.default.dim(` Re-push with ${import_picocolors2.default.bold("--force")} to override, or use ${import_picocolors2.default.bold("--rename old:new")} if you renamed a model.`));
280078
279952
  } else if (status2 === 403) {
280079
- console.error(import_picocolors3.default.red(` Forbidden: ${body.message ?? body.reason ?? "key lacks schema:push scope"}`));
280080
- if (args.apiKey != null && classifyCredentialKind(args.apiKey) === "restricted") {
279953
+ const code = body.code ?? body.reason;
279954
+ const serverMsg = body.message ?? body.reason;
279955
+ console.error(import_picocolors2.default.red(` Forbidden${code ? ` [${code}]` : ""}: ${serverMsg ?? "permission denied"}`));
279956
+ if (code === "database_role_cannot_enforce_rls") {
280081
279957
  console.error(
280082
- import_picocolors3.default.dim(
280083
- ` Schema pushes need a SECRET key: ${import_picocolors3.default.bold("sk_test_")} (sandbox dev loop) or a dashboard ${import_picocolors3.default.bold("sk_live_")} (production deploy: ${import_picocolors3.default.bold("ABLO_API_KEY=sk_live_\u2026 npx ablo push")}).`
279958
+ import_picocolors2.default.dim(
279959
+ ` Your database role bypasses row-level security. Run ${import_picocolors2.default.bold("npx ablo migrate")} to create a scoped (NOBYPASSRLS) role and repoint DATABASE_URL, then re-push.`
279960
+ )
279961
+ );
279962
+ } else if (code === "database_tables_unforced_rls") {
279963
+ console.error(
279964
+ import_picocolors2.default.dim(
279965
+ ` One or more synced tables don't have FORCE ROW LEVEL SECURITY. Run ${import_picocolors2.default.bold("npx ablo migrate")} to (re)apply the tenant policies, then re-push.`
279966
+ )
279967
+ );
279968
+ } else if (args.apiKey != null && classifyCredentialKind(args.apiKey) === "restricted") {
279969
+ console.error(
279970
+ import_picocolors2.default.dim(
279971
+ ` Schema pushes need a SECRET key: ${import_picocolors2.default.bold("sk_test_")} (sandbox dev loop) or a dashboard ${import_picocolors2.default.bold("sk_live_")} (production deploy: ${import_picocolors2.default.bold("ABLO_API_KEY=sk_live_\u2026 npx ablo push")}).`
280084
279972
  )
280085
279973
  );
280086
279974
  }
280087
279975
  } else {
280088
- console.error(import_picocolors3.default.red(` Push failed (${status2}): ${body.message ?? body.reason ?? bodyText}`));
279976
+ console.error(import_picocolors2.default.red(` Push failed (${status2}): ${body.message ?? body.reason ?? bodyText}`));
280089
279977
  }
280090
279978
  process.exit(1);
280091
279979
  }
@@ -280093,6 +279981,10 @@ async function push(argv) {
280093
279981
  // src/cli/migrate.ts
280094
279982
  var MIGRATE_USAGE = ` ablo migrate \u2014 provision your schema's tables in your own Postgres (DATABASE_URL)
280095
279983
 
279984
+ To connect a real database, run \`ablo connect\` \u2014 Ablo reads your WAL via logical
279985
+ replication, the one way. \`migrate\` is the optional escape hatch for provisioning
279986
+ tables when you don't already have them.
279987
+
280096
279988
  Usage:
280097
279989
  npx ablo migrate Create the synced-model tables (with row-level security)
280098
279990
  npx ablo migrate --dry-run Print the SQL without executing it
@@ -280141,8 +280033,8 @@ function planFor(schema, targetSchema = "public") {
280141
280033
  }
280142
280034
  var log = {
280143
280035
  info: (msg, fields) => console.log(`[migrate] ${msg}`, fields),
280144
- warn: (msg, fields) => console.warn(import_picocolors4.default.yellow(`[migrate] ${msg}`), fields),
280145
- error: (msg, fields) => console.error(import_picocolors4.default.red(`[migrate] ${msg}`), fields)
280036
+ warn: (msg, fields) => console.warn(import_picocolors3.default.yellow(`[migrate] ${msg}`), fields),
280037
+ error: (msg, fields) => console.error(import_picocolors3.default.red(`[migrate] ${msg}`), fields)
280146
280038
  };
280147
280039
  var PG_LOCK_NOT_AVAILABLE = "55P03";
280148
280040
  var LOCK_TIMEOUT = process.env.ABLO_SCHEMA_LOCK_TIMEOUT ?? process.env.ABLO_DDL_LOCK_TIMEOUT ?? "5s";
@@ -280217,7 +280109,7 @@ async function migrate(argv) {
280217
280109
  try {
280218
280110
  args = parseMigrateArgs(argv);
280219
280111
  } catch (err) {
280220
- console.error(import_picocolors4.default.red(` ${err instanceof Error ? err.message : String(err)}`));
280112
+ console.error(import_picocolors3.default.red(` ${err instanceof Error ? err.message : String(err)}`));
280221
280113
  process.exit(1);
280222
280114
  }
280223
280115
  const schema = await loadSchema(args.schemaPath, args.exportName);
@@ -280228,31 +280120,290 @@ async function migrate(argv) {
280228
280120
  ].join("\n");
280229
280121
  const totalStatements = plan.statements.length + plan.concurrent.length;
280230
280122
  console.log(
280231
- ` ${import_picocolors4.default.dim("Schema")} ${import_picocolors4.default.bold(args.schemaPath)} \u2192 ${import_picocolors4.default.dim(`${Object.keys(schema.models).length} models, ${totalStatements} statements`)}`
280123
+ ` ${import_picocolors3.default.dim("Schema")} ${import_picocolors3.default.bold(args.schemaPath)} \u2192 ${import_picocolors3.default.dim(`${Object.keys(schema.models).length} models, ${totalStatements} statements`)}`
280232
280124
  );
280233
280125
  if (args.outputFile) {
280234
280126
  (0, import_fs5.writeFileSync)(args.outputFile, sql + "\n");
280235
- console.log(` ${import_picocolors4.default.green("\u2713")} SQL written to ${import_picocolors4.default.bold(args.outputFile)}`);
280127
+ console.log(` ${import_picocolors3.default.green("\u2713")} SQL written to ${import_picocolors3.default.bold(args.outputFile)}`);
280236
280128
  return;
280237
280129
  }
280238
280130
  if (args.dryRun) {
280239
280131
  console.log("\n" + sql + "\n");
280240
280132
  return;
280241
280133
  }
280242
- const dbUrl = process.env.DATABASE_URL ?? process.env.ABLO_DATABASE_URL;
280134
+ const dbUrl = readProjectDatabaseUrl();
280243
280135
  if (!dbUrl) {
280244
- console.error(import_picocolors4.default.red(" Set DATABASE_URL (or ABLO_DATABASE_URL) to apply, or use --dry-run to preview."));
280136
+ console.error(
280137
+ import_picocolors3.default.red(
280138
+ " No DATABASE_URL found (checked process env, .env.local, .env). Set it to apply, or use --dry-run to preview."
280139
+ )
280140
+ );
280245
280141
  process.exit(1);
280246
280142
  }
280247
- const effectiveUrl = await ensureScopedRoleInteractive(dbUrl);
280248
280143
  try {
280249
- await applyStatements(effectiveUrl, args.targetSchema, plan.statements, plan.concurrent);
280250
- console.log(` ${import_picocolors4.default.green("\u2713")} Migration complete`);
280144
+ await applyStatements(dbUrl, args.targetSchema, plan.statements, plan.concurrent);
280145
+ console.log(` ${import_picocolors3.default.green("\u2713")} Migration complete`);
280251
280146
  } catch {
280252
280147
  process.exit(1);
280253
280148
  }
280254
280149
  }
280255
280150
 
280151
+ // src/cli/connect.ts
280152
+ init_cjs_shims();
280153
+ var import_picocolors4 = __toESM(require_picocolors(), 1);
280154
+
280155
+ // src/cli/theme.ts
280156
+ init_cjs_shims();
280157
+ var RESET = "\x1B[0m";
280158
+ var PAPER_BG = "\x1B[48;2;250;250;250m";
280159
+ var BLACK_FG = "\x1B[38;2;0;0;0m";
280160
+ function colorEnabled() {
280161
+ return Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
280162
+ }
280163
+ function brand(label = "ablo") {
280164
+ if (!colorEnabled()) return label;
280165
+ return `${PAPER_BG}${BLACK_FG} ${label} ${RESET}`;
280166
+ }
280167
+
280168
+ // src/cli/connect.ts
280169
+ var ABLO_PUBLICATION = "ablo_publication";
280170
+ var ABLO_REPLICATION_ROLE = "ablo_replicator";
280171
+ function parseConnectArgs(argv) {
280172
+ let check2 = false;
280173
+ let tables = [];
280174
+ let role = ABLO_REPLICATION_ROLE;
280175
+ for (let i = 0; i < argv.length; i++) {
280176
+ const arg = argv[i];
280177
+ switch (arg) {
280178
+ case "--check":
280179
+ check2 = true;
280180
+ break;
280181
+ case "--tables": {
280182
+ const value = argv[++i] ?? "";
280183
+ tables = value.split(",").map((t) => t.trim()).filter((t) => t.length > 0);
280184
+ break;
280185
+ }
280186
+ case "--role":
280187
+ role = argv[++i] ?? role;
280188
+ break;
280189
+ default:
280190
+ throw new AbloValidationError(`unknown flag: ${arg}`, { code: "cli_invalid_arguments" });
280191
+ }
280192
+ }
280193
+ return { check: check2, tables, role };
280194
+ }
280195
+ function quoteIdent(id) {
280196
+ return `"${id.replace(/"/g, '""')}"`;
280197
+ }
280198
+ function connectSetupSql(input) {
280199
+ const role = input.role && input.role.length > 0 ? input.role : ABLO_REPLICATION_ROLE;
280200
+ const tables = input.tables ?? [];
280201
+ const publicationTarget = tables.length > 0 ? `FOR TABLE ${tables.map(quoteIdent).join(", ")}` : "FOR ALL TABLES";
280202
+ return [
280203
+ // 1. Turn on logical decoding. Requires a restart (it's not reloadable).
280204
+ `ALTER SYSTEM SET wal_level = 'logical';`,
280205
+ // 2. Publish the tables Ablo should read.
280206
+ `CREATE PUBLICATION ${quoteIdent(ABLO_PUBLICATION)} ${publicationTarget};`,
280207
+ // 3. A least-privilege role: it can stream replication and SELECT, nothing more.
280208
+ `CREATE ROLE ${quoteIdent(role)} WITH REPLICATION LOGIN PASSWORD '<password>';`,
280209
+ `GRANT SELECT ON ALL TABLES IN SCHEMA public TO ${quoteIdent(role)};`,
280210
+ // Future tables get SELECT automatically, so the publication doesn't outgrow the grant.
280211
+ `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO ${quoteIdent(role)};`
280212
+ ];
280213
+ }
280214
+ function printConnectRecipe(args) {
280215
+ const sql = connectSetupSql({ tables: args.tables, role: args.role });
280216
+ console.log(`
280217
+ ${brand("ablo")} ${import_picocolors4.default.dim("connect")} ${import_picocolors4.default.dim("logical replication \u2014 the one way to connect a real database")}
280218
+ `);
280219
+ console.log(
280220
+ ` Ablo READS your write-ahead log (WAL) and ${import_picocolors4.default.bold("never")} runs DDL, owns, or migrates your
280221
+ schema. Your app keeps writing through your own backend \u2014 Ablo only tails the changes.
280222
+ Run this once against your Postgres ${import_picocolors4.default.dim("(as a superuser / the DB owner)")}:
280223
+ `
280224
+ );
280225
+ console.log(` ${import_picocolors4.default.bold("1.")} Enable logical decoding ${import_picocolors4.default.dim("(then RESTART Postgres \u2014 wal_level is not reloadable)")}`);
280226
+ console.log(` ${import_picocolors4.default.cyan(sql[0])}`);
280227
+ console.log(
280228
+ import_picocolors4.default.dim(
280229
+ ` On Amazon RDS / Aurora you can't ALTER SYSTEM: set ${import_picocolors4.default.bold("rds.logical_replication = 1")} in the
280230
+ instance's parameter group instead, then reboot.`
280231
+ )
280232
+ );
280233
+ console.log(`
280234
+ ${import_picocolors4.default.bold("2.")} Publish the tables Ablo should read`);
280235
+ console.log(` ${import_picocolors4.default.cyan(sql[1])}`);
280236
+ if (args.tables.length === 0) {
280237
+ console.log(import_picocolors4.default.dim(` (Scope it with ${import_picocolors4.default.bold("ablo connect --tables a,b,c")} to publish a subset.)`));
280238
+ }
280239
+ console.log(`
280240
+ ${import_picocolors4.default.bold("3.")} Create a least-privilege replication role ${import_picocolors4.default.dim("(pick your own password)")}`);
280241
+ console.log(` ${import_picocolors4.default.cyan(sql[2])}`);
280242
+ console.log(` ${import_picocolors4.default.cyan(sql[3])}`);
280243
+ console.log(` ${import_picocolors4.default.cyan(sql[4])}`);
280244
+ console.log(
280245
+ import_picocolors4.default.dim(
280246
+ ` On Amazon RDS, the REPLICATION attribute is granted, not set directly:
280247
+ ${import_picocolors4.default.bold(`GRANT rds_replication TO ${quoteIdent(args.role)};`)}`
280248
+ )
280249
+ );
280250
+ console.log(
280251
+ `
280252
+ ${import_picocolors4.default.bold("4.")} Put the role's connection string in ${import_picocolors4.default.bold("DATABASE_URL")}, then verify:
280253
+ ${import_picocolors4.default.cyan("npx ablo connect --check")}
280254
+ `
280255
+ );
280256
+ console.log(
280257
+ import_picocolors4.default.dim(
280258
+ ` Reminder: Ablo never writes to your database on this path. Provisioning tables with
280259
+ ${import_picocolors4.default.bold("ablo migrate")} is a separate, optional escape hatch \u2014 connecting a real database is this.`
280260
+ )
280261
+ );
280262
+ console.log();
280263
+ }
280264
+ function printCheckItem(item) {
280265
+ if (item.ok) {
280266
+ console.log(` ${import_picocolors4.default.green("\u2713")} ${item.label}`);
280267
+ } else {
280268
+ console.log(` ${import_picocolors4.default.red("\u2717")} ${item.label}`);
280269
+ if (item.fix) {
280270
+ for (const line of item.fix.split("\n")) console.log(` ${import_picocolors4.default.red("\u2022")} ${line}`);
280271
+ }
280272
+ }
280273
+ }
280274
+ async function probeReadiness(sql, opts = {}) {
280275
+ const publication = opts.publication ?? ABLO_PUBLICATION;
280276
+ const items = [];
280277
+ const walRows = await sql.unsafe(`SHOW wal_level`);
280278
+ const walLevel = walRows[0]?.setting ?? "unknown";
280279
+ items.push(
280280
+ walLevel === "logical" ? { ok: true, label: `wal_level is ${import_picocolors4.default.bold("logical")}` } : {
280281
+ ok: false,
280282
+ label: `wal_level is ${import_picocolors4.default.bold(walLevel)} (need ${import_picocolors4.default.bold("logical")})`,
280283
+ fix: `ALTER SYSTEM SET wal_level = 'logical'; then RESTART Postgres.
280284
+ On RDS/Aurora set rds.logical_replication = 1 in the parameter group, then reboot.`
280285
+ }
280286
+ );
280287
+ const pubRows = await sql.unsafe(
280288
+ `SELECT puballtables FROM pg_publication WHERE pubname = $1`,
280289
+ [publication]
280290
+ );
280291
+ items.push(
280292
+ pubRows.length > 0 ? {
280293
+ ok: true,
280294
+ label: `publication ${import_picocolors4.default.bold(publication)} exists ${import_picocolors4.default.dim(pubRows[0].puballtables ? "(all tables)" : "(table subset)")}`
280295
+ } : {
280296
+ ok: false,
280297
+ label: `publication ${import_picocolors4.default.bold(publication)} not found`,
280298
+ fix: `CREATE PUBLICATION ${quoteIdent(publication)} FOR ALL TABLES;`
280299
+ }
280300
+ );
280301
+ const roleRows = await sql.unsafe(
280302
+ `SELECT rolreplication, rolsuper FROM pg_roles WHERE rolname = current_user`
280303
+ );
280304
+ const role = roleRows[0];
280305
+ const hasReplication = Boolean(role && (role.rolreplication || role.rolsuper));
280306
+ items.push(
280307
+ hasReplication ? { ok: true, label: `DATABASE_URL role can stream replication ${import_picocolors4.default.dim("(REPLICATION)")}` } : {
280308
+ ok: false,
280309
+ label: `DATABASE_URL role lacks the ${import_picocolors4.default.bold("REPLICATION")} attribute`,
280310
+ fix: `ALTER ROLE current_user WITH REPLICATION;
280311
+ On RDS: GRANT rds_replication TO <your_role>;`
280312
+ }
280313
+ );
280314
+ if (pubRows.length > 0) {
280315
+ const badRows = await sql.unsafe(
280316
+ `SELECT c.relname AS table_name, c.relreplident
280317
+ FROM pg_publication_tables pt
280318
+ JOIN pg_class c ON c.relname = pt.tablename
280319
+ JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = pt.schemaname
280320
+ WHERE pt.pubname = $1
280321
+ AND (
280322
+ c.relreplident = 'n'
280323
+ OR (
280324
+ c.relreplident = 'd'
280325
+ AND NOT EXISTS (
280326
+ SELECT 1 FROM pg_index i
280327
+ WHERE i.indrelid = c.oid AND i.indisprimary
280328
+ )
280329
+ )
280330
+ )`,
280331
+ [publication]
280332
+ );
280333
+ items.push(
280334
+ badRows.length === 0 ? { ok: true, label: `all published tables have a usable REPLICA IDENTITY` } : {
280335
+ ok: false,
280336
+ label: `${badRows.length} published table${badRows.length === 1 ? "" : "s"} cannot replicate UPDATE/DELETE`,
280337
+ fix: badRows.map(
280338
+ (r2) => `${r2.table_name}: add a PRIMARY KEY, or ALTER TABLE ${quoteIdent(r2.table_name)} REPLICA IDENTITY FULL;`
280339
+ ).join("\n")
280340
+ }
280341
+ );
280342
+ }
280343
+ return items;
280344
+ }
280345
+ async function runCheck() {
280346
+ const dbUrl = readProjectDatabaseUrl();
280347
+ if (!dbUrl) {
280348
+ console.error(
280349
+ import_picocolors4.default.red(" No DATABASE_URL found (checked process env, .env.local, .env).") + import_picocolors4.default.dim(` Set it to the Postgres you want Ablo to read, then re-run ${import_picocolors4.default.bold("ablo connect --check")}.`)
280350
+ );
280351
+ process.exit(1);
280352
+ }
280353
+ console.log(`
280354
+ ${brand("ablo")} ${import_picocolors4.default.dim("connect --check")} ${import_picocolors4.default.dim("logical-replication readiness")}
280355
+ `);
280356
+ const sql = src_default(dbUrl, { max: 1, prepare: false, onnotice: () => {
280357
+ } });
280358
+ let items;
280359
+ try {
280360
+ items = await probeReadiness(sql);
280361
+ } catch (err) {
280362
+ const pg = err ?? {};
280363
+ console.error(import_picocolors4.default.red(` Couldn't read the database: ${pg.message ?? String(err)}`));
280364
+ await sql.end({ timeout: 2 });
280365
+ process.exit(1);
280366
+ }
280367
+ await sql.end({ timeout: 2 });
280368
+ for (const item of items) printCheckItem(item);
280369
+ const failures = items.filter((i) => !i.ok).length;
280370
+ console.log();
280371
+ if (failures === 0) {
280372
+ console.log(` ${import_picocolors4.default.green("\u2713")} Ready \u2014 Ablo can connect and tail this database's WAL.
280373
+ `);
280374
+ process.exit(0);
280375
+ }
280376
+ console.log(
280377
+ ` ${import_picocolors4.default.red(`${failures} item${failures === 1 ? "" : "s"} to fix`)} ${import_picocolors4.default.dim(`\u2014 apply the fixes above, then re-run ${import_picocolors4.default.bold("ablo connect --check")}.`)}
280378
+ `
280379
+ );
280380
+ process.exit(1);
280381
+ }
280382
+ async function connect(argv) {
280383
+ let args;
280384
+ try {
280385
+ args = parseConnectArgs(argv);
280386
+ } catch (err) {
280387
+ console.error(import_picocolors4.default.red(` ${err instanceof Error ? err.message : String(err)}`));
280388
+ process.exit(1);
280389
+ }
280390
+ if (args.check) {
280391
+ await runCheck();
280392
+ return;
280393
+ }
280394
+ printConnectRecipe(args);
280395
+ }
280396
+ var CONNECT_USAGE = ` ablo connect \u2014 connect a real database to Ablo via logical replication (the one way)
280397
+
280398
+ Ablo READS your WAL and never runs DDL, owns, or migrates your schema. Your app
280399
+ keeps writing through your own backend.
280400
+
280401
+ Usage:
280402
+ npx ablo connect Print the exact setup SQL for your Postgres
280403
+ npx ablo connect --tables a,b,c Publish only these tables (default: all tables)
280404
+ npx ablo connect --role <name> Name the replication role (default: ablo_replicator)
280405
+ npx ablo connect --check Validate DATABASE_URL is replication-ready`;
280406
+
280256
280407
  // src/cli/generate.ts
280257
280408
  init_cjs_shims();
280258
280409
  var import_fs6 = require("fs");
@@ -280313,21 +280464,6 @@ var import_picocolors6 = __toESM(require_picocolors(), 1);
280313
280464
  var import_fs7 = require("fs");
280314
280465
  var import_path5 = require("path");
280315
280466
  var import_schema5 = require("@abloatai/ablo/schema");
280316
-
280317
- // src/cli/theme.ts
280318
- init_cjs_shims();
280319
- var RESET = "\x1B[0m";
280320
- var PAPER_BG = "\x1B[48;2;250;250;250m";
280321
- var BLACK_FG = "\x1B[38;2;0;0;0m";
280322
- function colorEnabled() {
280323
- return Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
280324
- }
280325
- function brand(label = "ablo") {
280326
- if (!colorEnabled()) return label;
280327
- return `${PAPER_BG}${BLACK_FG} ${label} ${RESET}`;
280328
- }
280329
-
280330
- // src/cli/dev.ts
280331
280467
  function parseDevArgs(argv) {
280332
280468
  let schemaPath = DEFAULT_SCHEMA_PATH;
280333
280469
  let exportName = DEFAULT_EXPORT;
@@ -280437,11 +280573,27 @@ async function runPush(schema, args) {
280437
280573
  if (status2 === 409) {
280438
280574
  const unexecutable = Array.isArray(body.unexecutable) ? body.unexecutable : [];
280439
280575
  const warnings = Array.isArray(body.warnings) ? body.warnings : [];
280576
+ const hasShadowed = [...unexecutable, ...warnings].some(
280577
+ (s) => s.shadowed != null
280578
+ );
280440
280579
  const lines = [
280441
- "Incompatible schema change \u2014 not safe to apply as-is.",
280580
+ import_picocolors6.default.bold("Incompatible schema change \u2014 not safe to apply as-is."),
280581
+ "",
280442
280582
  ...unexecutable.map((u2) => import_picocolors6.default.red(fmtSignal(u2))),
280443
280583
  ...warnings.map((w2) => import_picocolors6.default.yellow(fmtSignal(w2))),
280444
- import_picocolors6.default.dim(`Run ${import_picocolors6.default.bold("ablo push --force")} (or ${import_picocolors6.default.bold("--rename old:new")}) to resolve.`)
280584
+ "",
280585
+ ...hasShadowed ? [
280586
+ import_picocolors6.default.dim(
280587
+ " These models exist in the baseline above but not in your push. Sandbox readers"
280588
+ ),
280589
+ import_picocolors6.default.dim(
280590
+ " fall back to the production schema until you push your own, so applying this drops them."
280591
+ ),
280592
+ ""
280593
+ ] : [],
280594
+ import_picocolors6.default.dim(
280595
+ ` Fix: ${import_picocolors6.default.bold("ablo push --force")} to apply anyway, or ${import_picocolors6.default.bold("--rename old:new")} if you renamed a model.`
280596
+ )
280445
280597
  ];
280446
280598
  return { ok: false, message: lines.join("\n") };
280447
280599
  }
@@ -280473,8 +280625,6 @@ async function dev(argv) {
280473
280625
  console.log(`
280474
280626
  ${brand("ablo")} ${import_picocolors6.default.dim("push")} ${import_picocolors6.default.dim("(sandbox)")}
280475
280627
  `);
280476
- const projectDbUrl = readProjectDatabaseUrl();
280477
- if (projectDbUrl) await ensureScopedRoleInteractive(projectDbUrl);
280478
280628
  const schema = await loadSchema(args.schemaPath, args.exportName);
280479
280629
  const modelCount = Object.keys(schema.models).length;
280480
280630
  console.log(
@@ -282355,6 +282505,7 @@ var LOGO = `
282355
282505
  ${brand("ablo")} ${import_picocolors18.default.dim("sync engine")}
282356
282506
  `;
282357
282507
  var SUBCOMMAND_USAGE = {
282508
+ connect: CONNECT_USAGE,
282358
282509
  migrate: MIGRATE_USAGE
282359
282510
  };
282360
282511
  async function main() {
@@ -282402,6 +282553,8 @@ async function main() {
282402
282553
  } else {
282403
282554
  await pull(rest);
282404
282555
  }
282556
+ } else if (command === "connect") {
282557
+ await connect(process.argv.slice(3));
282405
282558
  } else if (command === "migrate") {
282406
282559
  await migrate(process.argv.slice(3));
282407
282560
  } else if (command === "push") {
@@ -282465,7 +282618,9 @@ async function main() {
282465
282618
  console.log(` npx ablo pull prisma [path] Generate schema.ts from a Prisma schema (keeps enums + relations)`);
282466
282619
  console.log(` npx ablo pull drizzle <module> Generate schema.ts from a Drizzle schema (keeps enums + relations)`);
282467
282620
  console.log(` npx ablo check Check your existing database fits the schema (read-only, creates no tables)`);
282468
- console.log(` npx ablo migrate Provision your synced-model tables in your own Postgres (DATABASE_URL)`);
282621
+ console.log(` npx ablo connect Connect a real database \u2014 prints the logical-replication setup SQL (the one way)`);
282622
+ console.log(` npx ablo connect --check Validate DATABASE_URL is replication-ready (wal_level, publication, role, replica identity)`);
282623
+ console.log(` npx ablo migrate Provision your synced-model tables in your own Postgres (optional escape hatch \u2014 \`connect\` is the way)`);
282469
282624
  console.log(` npx ablo migrate --dry-run Print the SQL without executing (preview)`);
282470
282625
  console.log(` npx ablo push Upload your schema definition to Ablo (metadata only \u2014 rows stay in your DB)`);
282471
282626
  console.log(` npx ablo push --force Allow destructive/unexecutable changes`);