@abloatai/cli 0.46.0 → 0.48.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.
Files changed (2) hide show
  1. package/dist/cli.cjs +481 -122
  2. package/package.json +3 -3
package/dist/cli.cjs CHANGED
@@ -3755,7 +3755,8 @@ async function confirmFromServer(opts) {
3755
3755
  }
3756
3756
  }
3757
3757
  async function nameProject(projectId, organizationId, opts) {
3758
- const listed = await listProjects(opts.apiKey, opts.url);
3758
+ const namingKey = resolveOrgManagementKey() ?? opts.apiKey;
3759
+ const listed = await listProjects(namingKey, opts.url);
3759
3760
  const match = listed.ok ? listed.projects.find((p2) => p2.id === projectId) : void 0;
3760
3761
  if (match) {
3761
3762
  return { id: match.id, slug: match.slug, name: match.name, isDefault: match.default };
@@ -4098,6 +4099,12 @@ async function loadSchema(schemaPath, exportName) {
4098
4099
  function maskKey(key) {
4099
4100
  return key ? `${key.slice(0, 12)}\u2026` : "(none)";
4100
4101
  }
4102
+ function schemaPushStorageHint(code) {
4103
+ if (code === "no_data_source_registered") {
4104
+ return `This branch is not connected to your database yet. Run ${import_picocolors5.default.bold("ablo connect")} for this branch, then retry the schema push.`;
4105
+ }
4106
+ return null;
4107
+ }
4101
4108
  function schemaGitState(schemaPath) {
4102
4109
  try {
4103
4110
  const out = (0, import_child_process.execFileSync)("git", ["status", "--porcelain", "--", schemaPath], {
@@ -4406,7 +4413,10 @@ async function push(argv) {
4406
4413
  const serverMsg = body.message ?? body.reason;
4407
4414
  console.error(import_picocolors5.default.red(` Forbidden${code ? ` [${code}]` : ""}: ${serverMsg ?? "permission denied"}`));
4408
4415
  console.error(import_picocolors5.default.dim(` Push used ${import_picocolors5.default.bold(maskKey(args.apiKey))} from ${describeKeySource(keySource)}.`));
4409
- if (code === "database_role_cannot_enforce_rls") {
4416
+ const storageHint = schemaPushStorageHint(code);
4417
+ if (storageHint) {
4418
+ console.error(import_picocolors5.default.dim(` ${storageHint}`));
4419
+ } else if (code === "database_role_cannot_enforce_rls") {
4410
4420
  console.error(
4411
4421
  import_picocolors5.default.dim(
4412
4422
  ` Your database role bypasses row-level security. Run ${import_picocolors5.default.bold("npx ablo migrate")} to create a scoped (NOBYPASSRLS) role and repoint DATABASE_URL, then re-push.`
@@ -4566,11 +4576,13 @@ var init_remoteValidation = __esm({
4566
4576
  wal_level: () => `your database isn't set up to share changes as they happen yet`,
4567
4577
  publication: () => `none of your tables are shared with Ablo yet`,
4568
4578
  replication_role: () => `the login Ablo reads with can't follow your changes yet`,
4579
+ replication_slot_capacity: (f) => withActual(`this database has no remaining change-stream capacity for this binding`, f.actual),
4569
4580
  replica_identity: (f) => withActual(
4570
4581
  `some shared tables don't record enough for Ablo to track edits and deletes`,
4571
4582
  f.actual
4572
4583
  ),
4573
4584
  table_select: (f) => withActual(`the login Ablo reads with can't read some shared tables`, f.actual),
4585
+ snapshot_row_security: (f) => withActual(`row-level security hides historical rows from Ablo's initial load`, f.actual),
4574
4586
  write_role: () => `the login Ablo writes with isn't set up yet`,
4575
4587
  row_security: () => `the writer login isn't set to honor your row-level security`,
4576
4588
  database_privileges: () => `the writer login can still create things in your database`,
@@ -4670,7 +4682,7 @@ function quoteIdent(id) {
4670
4682
  function quoteLiteral(value) {
4671
4683
  return `'${value.replace(/'/g, "''")}'`;
4672
4684
  }
4673
- function scopedSequenceGrant(tables, writeRole) {
4685
+ function scopedSequenceGrant(tables, writeRole, schema) {
4674
4686
  const names = tables.map(quoteLiteral).join(", ");
4675
4687
  return `DO $$
4676
4688
  DECLARE seq regclass;
@@ -4681,7 +4693,7 @@ BEGIN
4681
4693
  JOIN pg_class s ON s.oid = d.objid AND s.relkind = 'S'
4682
4694
  JOIN pg_class t ON t.oid = d.refobjid
4683
4695
  JOIN pg_namespace n ON n.oid = t.relnamespace
4684
- WHERE d.deptype IN ('a', 'i') AND n.nspname = 'public' AND t.relname IN (${names})
4696
+ WHERE d.deptype IN ('a', 'i') AND n.nspname = ${quoteLiteral(schema)} AND t.relname IN (${names})
4685
4697
  LOOP
4686
4698
  EXECUTE format('GRANT USAGE, SELECT ON SEQUENCE %s TO ${quoteIdent(writeRole)}', seq);
4687
4699
  END LOOP;
@@ -4691,24 +4703,29 @@ function connectSetupSql(input) {
4691
4703
  const role = input.role && input.role.length > 0 ? input.role : import_footprint.ABLO_REPLICATION_ROLE;
4692
4704
  const writeRole = input.writeRole && input.writeRole.length > 0 ? input.writeRole : import_footprint.ABLO_WRITE_ROLE;
4693
4705
  const tables = input.tables ?? [];
4694
- const publicationTarget = tables.length > 0 ? `FOR TABLE ${tables.map(quoteIdent).join(", ")}` : "FOR ALL TABLES";
4695
- const tableList = tables.map(quoteIdent).join(", ");
4706
+ const schema = input.schema ?? "public";
4707
+ const publication = input.publication;
4708
+ const qualifiedTables = tables.map((table) => `${quoteIdent(schema)}.${quoteIdent(table)}`);
4709
+ const publicationTarget = tables.length > 0 ? `FOR TABLE ${qualifiedTables.join(", ")}` : "FOR ALL TABLES";
4710
+ const tableList = qualifiedTables.join(", ");
4696
4711
  const scoped = tables.length > 0;
4697
- const applicationGrant = scoped ? `GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE ${tableList} TO ${quoteIdent(writeRole)};` : `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO ${quoteIdent(writeRole)};`;
4712
+ const applicationGrant = scoped ? `GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE ${tableList} TO ${quoteIdent(writeRole)};` : `GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA ${quoteIdent(schema)} TO ${quoteIdent(writeRole)};`;
4698
4713
  const replicationReadGrants = scoped ? [`GRANT SELECT ON TABLE ${tableList} TO ${quoteIdent(role)};`] : [
4699
- `GRANT SELECT ON ALL TABLES IN SCHEMA public TO ${quoteIdent(role)};`,
4700
- `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO ${quoteIdent(role)};`
4714
+ `GRANT SELECT ON ALL TABLES IN SCHEMA ${quoteIdent(schema)} TO ${quoteIdent(role)};`,
4715
+ `ALTER DEFAULT PRIVILEGES IN SCHEMA ${quoteIdent(schema)} GRANT SELECT ON TABLES TO ${quoteIdent(role)};`
4701
4716
  ];
4702
- const writerSequenceGrants = scoped ? [scopedSequenceGrant(tables, writeRole)] : [`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${quoteIdent(writeRole)};`];
4703
- const ledger = (0, import_source2.idempotencyLedgerMigrations)().map((migration) => migration.up);
4717
+ const writerSequenceGrants = scoped ? [scopedSequenceGrant(tables, writeRole, schema)] : [`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${quoteIdent(schema)} TO ${quoteIdent(writeRole)};`];
4718
+ const ledger = (0, import_source2.idempotencyLedgerMigrations)(schema).map((migration) => migration.up);
4704
4719
  return [
4705
4720
  // 1. Turn on logical decoding. Requires a restart (it's not reloadable).
4706
4721
  `ALTER SYSTEM SET wal_level = 'logical';`,
4707
4722
  // 2. Publish the tables Ablo should read.
4708
- `CREATE PUBLICATION ${quoteIdent(import_footprint.ABLO_PUBLICATION)} ${publicationTarget};`,
4723
+ `CREATE PUBLICATION ${quoteIdent(publication)} ${publicationTarget};`,
4709
4724
  // 3. A least-privilege role: it can stream replication and SELECT the
4710
- // published tables, nothing more.
4711
- `CREATE ROLE ${quoteIdent(role)} WITH REPLICATION LOGIN PASSWORD '<password>';`,
4725
+ // published tables, including the initial snapshot of RLS-protected tables.
4726
+ // Logical decoding already exposes every published row independently of
4727
+ // RLS; BYPASSRLS makes the ordinary SELECT snapshot match that same scope.
4728
+ `CREATE ROLE ${quoteIdent(role)} WITH NOSUPERUSER BYPASSRLS NOCREATEDB NOCREATEROLE REPLICATION NOINHERIT LOGIN PASSWORD '<password>';`,
4712
4729
  ...replicationReadGrants,
4713
4730
  // 4. A distinct DML role: no replication, role administration, ownership,
4714
4731
  // schema creation, or DDL. It runs NOBYPASSRLS with row_security on, so on a
@@ -4729,20 +4746,20 @@ function connectSetupSql(input) {
4729
4746
  DO $$ BEGIN
4730
4747
  EXECUTE format('REVOKE TEMPORARY, CREATE ON DATABASE %I FROM PUBLIC', current_database());
4731
4748
  END $$;`,
4732
- `GRANT USAGE ON SCHEMA public TO ${quoteIdent(writeRole)};`,
4749
+ `GRANT USAGE ON SCHEMA ${quoteIdent(schema)} TO ${quoteIdent(writeRole)};`,
4733
4750
  applicationGrant,
4734
4751
  ...writerSequenceGrants,
4735
4752
  ...scoped ? [] : [
4736
4753
  // "All tables" mode only: keep future tables/sequences writable so the
4737
4754
  // publication doesn't outgrow the grant.
4738
- `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${quoteIdent(writeRole)};`,
4739
- `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO ${quoteIdent(writeRole)};`
4755
+ `ALTER DEFAULT PRIVILEGES IN SCHEMA ${quoteIdent(schema)} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${quoteIdent(writeRole)};`,
4756
+ `ALTER DEFAULT PRIVILEGES IN SCHEMA ${quoteIdent(schema)} GRANT USAGE, SELECT ON SEQUENCES TO ${quoteIdent(writeRole)};`
4740
4757
  ],
4741
4758
  // 5. Direct uses the durable replay ledger but deliberately no outbox.
4742
4759
  ...ledger,
4743
- `REVOKE ALL ON TABLE public.ablo_idempotency FROM PUBLIC;`,
4744
- `GRANT SELECT, INSERT, UPDATE ON TABLE public.ablo_idempotency TO ${quoteIdent(writeRole)};`,
4745
- `REVOKE DELETE ON TABLE public.ablo_idempotency FROM ${quoteIdent(writeRole)};`,
4760
+ `REVOKE ALL ON TABLE ${quoteIdent(schema)}.${quoteIdent("ablo_idempotency")} FROM PUBLIC;`,
4761
+ `GRANT SELECT, INSERT, UPDATE ON TABLE ${quoteIdent(schema)}.${quoteIdent("ablo_idempotency")} TO ${quoteIdent(writeRole)};`,
4762
+ `REVOKE DELETE ON TABLE ${quoteIdent(schema)}.${quoteIdent("ablo_idempotency")} FROM ${quoteIdent(writeRole)};`,
4746
4763
  // The writer emits a transactional marker on your WAL so Ablo can correlate
4747
4764
  // the committed row back to the originating write and confirm it — this
4748
4765
  // EXECUTE grant is what makes that confirmation possible. Granted by lookup
@@ -4765,10 +4782,12 @@ BEGIN
4765
4782
  END $$;`
4766
4783
  ];
4767
4784
  }
4768
- function reconcilePublicationPlan(current, desiredTables) {
4769
- const pub = quoteIdent(import_footprint.ABLO_PUBLICATION);
4785
+ function reconcilePublicationPlan(current, desiredTables, opts) {
4786
+ const pub = quoteIdent(opts.publication);
4787
+ const schema = opts.schema ?? "public";
4788
+ const qualified = (table) => `${quoteIdent(schema)}.${quoteIdent(table)}`;
4770
4789
  const desiredAll = desiredTables.length === 0;
4771
- const target = desiredAll ? "FOR ALL TABLES" : `FOR TABLE ${desiredTables.map(quoteIdent).join(", ")}`;
4790
+ const target = desiredAll ? "FOR ALL TABLES" : `FOR TABLE ${desiredTables.map(qualified).join(", ")}`;
4772
4791
  if (!current.exists) {
4773
4792
  return {
4774
4793
  sql: [`CREATE PUBLICATION ${pub} ${target};`],
@@ -4794,28 +4813,31 @@ function reconcilePublicationPlan(current, desiredTables) {
4794
4813
  return { sql: [], added: [], removed: [], recreated: false };
4795
4814
  }
4796
4815
  return {
4797
- sql: [`ALTER PUBLICATION ${pub} SET TABLE ${desiredTables.map(quoteIdent).join(", ")};`],
4816
+ sql: [`ALTER PUBLICATION ${pub} SET TABLE ${desiredTables.map(qualified).join(", ")};`],
4798
4817
  added,
4799
4818
  removed,
4800
4819
  recreated: false
4801
4820
  };
4802
4821
  }
4803
- async function readPublicationState(sql) {
4822
+ async function readPublicationState(sql, opts) {
4823
+ const publication = opts.publication;
4824
+ const schema = opts.schema ?? "public";
4804
4825
  const pubRows = await sql.unsafe(
4805
4826
  `SELECT puballtables FROM pg_publication WHERE pubname = $1`,
4806
- [import_footprint.ABLO_PUBLICATION]
4827
+ [publication]
4807
4828
  );
4808
4829
  const pubRow = pubRows[0];
4809
4830
  if (!pubRow) return { exists: false, allTables: false, tables: [] };
4810
4831
  if (pubRow.puballtables) return { exists: true, allTables: true, tables: [] };
4811
4832
  const tableRows = await sql.unsafe(
4812
- `SELECT tablename FROM pg_publication_tables WHERE pubname = $1 AND schemaname = 'public' ORDER BY tablename`,
4813
- [import_footprint.ABLO_PUBLICATION]
4833
+ `SELECT tablename FROM pg_publication_tables WHERE pubname = $1 AND schemaname = $2 ORDER BY tablename`,
4834
+ [publication, schema]
4814
4835
  );
4815
4836
  return { exists: true, allTables: false, tables: tableRows.map((r2) => r2.tablename) };
4816
4837
  }
4817
- async function probeReadiness(sql, opts = {}) {
4818
- const publication = opts.publication ?? import_footprint.ABLO_PUBLICATION;
4838
+ async function probeReadiness(sql, opts) {
4839
+ const publication = opts.publication;
4840
+ const schema = opts.schema ?? "public";
4819
4841
  const coordinated = opts.coordinatedTables && opts.coordinatedTables.length > 0 ? new Set(opts.coordinatedTables) : null;
4820
4842
  const items = [];
4821
4843
  const walRows = await sql.unsafe(
@@ -4847,7 +4869,7 @@ On Neon enable Logical Replication in the project (Console \u2192 Settings \u219
4847
4869
  }
4848
4870
  );
4849
4871
  const roleRows = await sql.unsafe(
4850
- `SELECT rolreplication, rolsuper FROM pg_roles WHERE rolname = current_user`
4872
+ `SELECT rolreplication, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user`
4851
4873
  );
4852
4874
  const role = roleRows[0];
4853
4875
  const hasReplication = Boolean(role && (role.rolreplication || role.rolsuper));
@@ -4863,12 +4885,31 @@ On RDS: GRANT rds_replication TO <your_role>;`
4863
4885
  }
4864
4886
  );
4865
4887
  if (pubRows.length > 0) {
4888
+ const rlsRows = await sql.unsafe(
4889
+ `SELECT DISTINCT pt.tablename AS table_name
4890
+ FROM pg_publication_tables pt
4891
+ JOIN pg_namespace n ON n.nspname = pt.schemaname
4892
+ JOIN pg_class c ON c.relnamespace = n.oid AND c.relname = pt.tablename
4893
+ WHERE pt.pubname = $1 AND pt.schemaname = $2
4894
+ AND row_security_active(c.oid)
4895
+ ORDER BY table_name`,
4896
+ [publication, schema]
4897
+ );
4898
+ const rlsRelevant = coordinated ? rlsRows.filter((row) => coordinated.has(row.table_name)) : rlsRows;
4899
+ items.push(
4900
+ rlsRelevant.length === 0 ? { ok: true, label: "the initial snapshot can read every published row" } : {
4901
+ ok: false,
4902
+ label: `${rlsRelevant.length} published table${rlsRelevant.length === 1 ? "" : "s"} hide historical rows behind RLS`,
4903
+ fix: `ALTER ROLE current_user WITH BYPASSRLS;
4904
+ Logical replication already exposes every published row; this lets the ordinary initial SELECT read that same scope.`
4905
+ }
4906
+ );
4866
4907
  const badRows = await sql.unsafe(
4867
4908
  `SELECT c.relname AS table_name, c.relreplident
4868
4909
  FROM pg_publication_tables pt
4869
4910
  JOIN pg_class c ON c.relname = pt.tablename
4870
4911
  JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = pt.schemaname
4871
- WHERE pt.pubname = $1
4912
+ WHERE pt.pubname = $1 AND pt.schemaname = $2
4872
4913
  AND (
4873
4914
  c.relreplident = 'n'
4874
4915
  OR (
@@ -4879,7 +4920,7 @@ On RDS: GRANT rds_replication TO <your_role>;`
4879
4920
  )
4880
4921
  )
4881
4922
  )`,
4882
- [publication]
4923
+ [publication, schema]
4883
4924
  );
4884
4925
  const relevant = coordinated ? badRows.filter((row) => coordinated.has(row.table_name)) : badRows;
4885
4926
  items.push(
@@ -4887,7 +4928,7 @@ On RDS: GRANT rds_replication TO <your_role>;`
4887
4928
  ok: false,
4888
4929
  label: `${relevant.length} published table${relevant.length === 1 ? "" : "s"} cannot replicate UPDATE/DELETE`,
4889
4930
  fix: relevant.map(
4890
- (r2) => `${r2.table_name}: add a PRIMARY KEY, or ALTER TABLE ${quoteIdent(r2.table_name)} REPLICA IDENTITY FULL;`
4931
+ (r2) => `${r2.table_name}: add a PRIMARY KEY, or ALTER TABLE ${quoteIdent(schema)}.${quoteIdent(r2.table_name)} REPLICA IDENTITY FULL;`
4891
4932
  ).join("\n")
4892
4933
  }
4893
4934
  );
@@ -4904,7 +4945,10 @@ async function registerDirectDataSource(opts) {
4904
4945
  connection: "direct",
4905
4946
  connectionString: opts.replicationUrl,
4906
4947
  writeConnectionString: opts.writeUrl,
4907
- route: opts.route
4948
+ route: opts.route,
4949
+ ...opts.schema ? { schema: opts.schema } : {},
4950
+ ...opts.replicationSlot ? { replicationSlot: opts.replicationSlot } : {},
4951
+ ...opts.publication ? { publication: opts.publication } : {}
4908
4952
  },
4909
4953
  responseSchema: import_wire3.datasourceSummarySchema
4910
4954
  });
@@ -5218,26 +5262,27 @@ function ownershipRemediation(blockers2, admin) {
5218
5262
  const unresolved = blockers2.filter((blocker) => !resolvedOwners.has(blocker.owner));
5219
5263
  return { inheritGrants, unresolved };
5220
5264
  }
5221
- async function publishedTableBlockers(sql, tables) {
5265
+ async function publishedTableBlockers(sql, tables, schema = "public") {
5222
5266
  const scoped = tables.length > 0;
5223
5267
  const raw = await sql.unsafe(
5224
5268
  `SELECT format('%I.%I', n.nspname, c.relname) AS relation, ${OWNERSHIP_COLUMNS}
5225
5269
  ${OWNERSHIP_FROM}
5226
5270
  WHERE c.relkind = 'r'
5227
- AND n.nspname = 'public'
5271
+ AND n.nspname = $${scoped ? "2" : "1"}
5228
5272
  AND c.relname <> 'ablo_idempotency'
5229
5273
  ${scoped ? "AND c.relname = ANY($1)" : ""}`,
5230
- scoped ? [tables] : []
5274
+ scoped ? [tables, schema] : [schema]
5231
5275
  );
5232
5276
  return ownershipBlockers(import_zod3.z.array(ownedRelationRowSchema).parse(raw));
5233
5277
  }
5234
- async function ledgerBlocker(sql) {
5278
+ async function ledgerBlocker(sql, schema = "public") {
5235
5279
  const raw = await sql.unsafe(
5236
5280
  `SELECT format('%I.%I', n.nspname, c.relname) AS relation, ${OWNERSHIP_COLUMNS}
5237
5281
  ${OWNERSHIP_FROM}
5238
5282
  WHERE c.relkind = 'r'
5239
- AND n.nspname = 'public'
5240
- AND c.relname = 'ablo_idempotency'`
5283
+ AND n.nspname = $1
5284
+ AND c.relname = 'ablo_idempotency'`,
5285
+ [schema]
5241
5286
  );
5242
5287
  const rows = import_zod3.z.array(ownedRelationRowSchema).parse(raw);
5243
5288
  const row = rows[0];
@@ -5422,7 +5467,7 @@ function blockers(input) {
5422
5467
  }
5423
5468
  if (input.dataSource.kind === "none") {
5424
5469
  found.push({
5425
- problem: "no database is connected to this plane, so writes are held rather than routed",
5470
+ problem: "this branch is not connected to a database, so writes are held",
5426
5471
  fix: "connect one with `ablo connect apply`"
5427
5472
  });
5428
5473
  }
@@ -5473,13 +5518,15 @@ function connectApplyPlan(input) {
5473
5518
  const role = input.role && input.role.length > 0 ? input.role : import_footprint.ABLO_REPLICATION_ROLE;
5474
5519
  const writeRole = input.writeRole && input.writeRole.length > 0 ? input.writeRole : import_footprint.ABLO_WRITE_ROLE;
5475
5520
  const tables = input.tables ?? [];
5521
+ const schema = input.schema ?? "public";
5522
+ const publication = input.publication;
5476
5523
  const provider = input.provider ?? "generic";
5477
- const recipe = connectSetupSql({ tables, role, writeRole });
5524
+ const recipe = connectSetupSql({ tables, role, writeRole, schema, publication });
5478
5525
  const isWal = (s) => s.startsWith("ALTER SYSTEM SET wal_level");
5479
5526
  const isPublication = (s) => s.startsWith("CREATE PUBLICATION");
5480
5527
  const isRoleCreate = (s) => s.startsWith("CREATE ROLE ");
5481
5528
  const grants = recipe.filter((s) => !isWal(s) && !isPublication(s) && !isRoleCreate(s));
5482
- const publicationTarget = tables.length > 0 ? `FOR TABLE ${tables.map(quoteIdent).join(", ")}` : "FOR ALL TABLES";
5529
+ const publicationTarget = tables.length > 0 ? `FOR TABLE ${tables.map((table) => `${quoteIdent(schema)}.${quoteIdent(table)}`).join(", ")}` : "FOR ALL TABLES";
5483
5530
  const affectsOthers = effectsOnOthers({ grants, allTables: tables.length === 0 });
5484
5531
  const walStep = input.walAlreadyLogical ? [] : [
5485
5532
  provider === "generic" ? {
@@ -5494,10 +5541,10 @@ function connectApplyPlan(input) {
5494
5541
  sql: []
5495
5542
  }
5496
5543
  ];
5497
- const reconcile2 = input.existingPublication ? reconcilePublicationPlan(input.existingPublication, tables) : null;
5544
+ const reconcile2 = input.existingPublication ? reconcilePublicationPlan(input.existingPublication, tables, { schema, publication }) : null;
5498
5545
  const publicationSql = reconcile2 ? reconcile2.sql : [
5499
5546
  `DO $$ BEGIN
5500
- CREATE PUBLICATION ${quoteIdent(import_footprint.ABLO_PUBLICATION)} ${publicationTarget};
5547
+ CREATE PUBLICATION ${quoteIdent(publication)} ${publicationTarget};
5501
5548
  EXCEPTION WHEN duplicate_object THEN NULL;
5502
5549
  END $$;`
5503
5550
  ];
@@ -5522,14 +5569,19 @@ END $$;`
5522
5569
  {
5523
5570
  key: "replication-role",
5524
5571
  title: "Create the read-only login Ablo reads with",
5525
- detail: `${role} \u2014 it can follow your changes and read, nothing else`,
5572
+ detail: `${role} \u2014 it can follow your changes and snapshot the same published rows, including through RLS`,
5526
5573
  sql: [
5527
5574
  idempotentRole(
5528
5575
  role,
5529
- "REPLICATION",
5576
+ "NOSUPERUSER BYPASSRLS NOCREATEDB NOCREATEROLE REPLICATION NOINHERIT",
5530
5577
  input.credentials.replicationClause,
5531
5578
  input.rotate === true
5532
- )
5579
+ ),
5580
+ // Unlike a password, this is a required invariant rather than secret
5581
+ // material owned by one plane. Re-assert it for an existing pre-snapshot
5582
+ // role so `connect apply` repairs the exact upgrade gap that otherwise
5583
+ // certifies an RLS-filtered empty snapshot as complete.
5584
+ `ALTER ROLE ${quoteIdent(role)} WITH BYPASSRLS;`
5533
5585
  ]
5534
5586
  },
5535
5587
  {
@@ -5683,7 +5735,7 @@ function rotateWithoutConnection(input) {
5683
5735
  }
5684
5736
  if (!input.known || input.planeHasConnection) return null;
5685
5737
  if (input.existingRoles.length > 0) return null;
5686
- return "This plane has no connected database and Ablo's roles are not in this database, so there is no credential to re-key. Connecting for the first time is `ablo connect apply`, which creates the roles and registers them in one run.";
5738
+ return "This branch has no connected database and Ablo's roles are not in this database, so there is no credential to re-key. Connecting for the first time is `ablo connect apply`, which creates the roles and registers them in one run.";
5687
5739
  }
5688
5740
  async function locateExistingConnection(input) {
5689
5741
  const result = await tryControlPlane({
@@ -5691,16 +5743,23 @@ async function locateExistingConnection(input) {
5691
5743
  method: "POST",
5692
5744
  baseUrl: input.apiUrl,
5693
5745
  apiKey: input.apiKey,
5694
- body: { connectionString: input.connectionString },
5746
+ body: {
5747
+ connectionString: input.connectionString,
5748
+ ...input.schema ? { schema: input.schema } : {}
5749
+ },
5695
5750
  responseSchema: import_wire6.datasourceLocationResponseSchema
5696
5751
  });
5697
5752
  if (!result.ok) return null;
5698
- return result.value.held;
5753
+ if (result.value.held) return result.value.held;
5754
+ return result.value.available === false ? { private: true } : null;
5699
5755
  }
5700
5756
  function alreadyConnectedElsewhere(held) {
5701
5757
  if (!held) return null;
5758
+ if ("private" in held) {
5759
+ return "This database schema is already connected to another Ablo organization. Ablo does not reveal that organization\u2019s project or branch.";
5760
+ }
5702
5761
  const where = held.project ? `project ${held.project}, branch ${held.branch}` : `branch ${held.branch}`;
5703
- return `This database is already connected to ${where}. Ablo streams a database from one plane at a time, so registering it here would take the change stream over from the plane that has it.`;
5762
+ return `This database schema is already connected to ${where}. A (database, schema) binding belongs to one plane at a time.`;
5704
5763
  }
5705
5764
  var import_wire6, import_schema5;
5706
5765
  var init_connectPreflight = __esm({
@@ -5849,12 +5908,18 @@ ${ambient}` : ""}`,
5849
5908
  )
5850
5909
  );
5851
5910
  }
5852
- const tables = args.tables;
5853
5911
  const coordinatedTables = await schemaDeclaredTables() ?? [];
5912
+ const tables = args.tables.length > 0 ? args.tables : coordinatedTables;
5913
+ if (tables.length === 0) {
5914
+ throw new import_errors9.AbloValidationError(
5915
+ `No mapped tables were found for schema ${args.schema}. Push the Ablo schema first, or pass --tables a,b,c. A project binding must enumerate its own schema-qualified tables; it cannot publish every table in a shared database.`,
5916
+ { code: "cli_invalid_arguments" }
5917
+ );
5918
+ }
5854
5919
  if (args.tables.length === 0) {
5855
5920
  console.log(
5856
5921
  import_picocolors11.default.dim(
5857
- ` publishing every table${coordinatedTables.length > 0 ? `; readiness judged on the ${coordinatedTables.length} your schema declares` : ""} (${import_picocolors11.default.bold("--tables")} to publish only some)
5922
+ ` publishing the ${tables.length} table${tables.length === 1 ? "" : "s"} declared by your Ablo schema in ${import_picocolors11.default.bold(args.schema)} (${import_picocolors11.default.bold("--tables")} to override)
5858
5923
  `
5859
5924
  )
5860
5925
  );
@@ -5869,8 +5934,28 @@ ${ambient}` : ""}`,
5869
5934
  console.log(` ${import_picocolors11.default.yellow("!")} ${mismatch}
5870
5935
  `);
5871
5936
  }
5937
+ const confirmed = connectTarget?.confirmed;
5938
+ if (!confirmed?.branchId) {
5939
+ throw new import_errors9.AbloConnectionError(
5940
+ "Ablo could not confirm the branch this key targets, so it cannot derive an isolated database footprint safely. Check the API URL/key and re-run.",
5941
+ { code: "cli_database_unreachable" }
5942
+ );
5943
+ }
5944
+ const footprint = (0, import_footprint2.footprintNamesFor)({
5945
+ organizationId: confirmed.organizationId,
5946
+ branchId: confirmed.branchId,
5947
+ ...confirmed.projectId ? { projectId: confirmed.projectId } : {}
5948
+ });
5949
+ const role = args.role === import_footprint.ABLO_REPLICATION_ROLE ? footprint.replicationRole : args.role;
5950
+ const writeRole = args.writeRole === import_footprint.ABLO_WRITE_ROLE ? footprint.writeRole : args.writeRole;
5951
+ const publication = footprint.publication;
5872
5952
  const heldElsewhere = alreadyConnectedElsewhere(
5873
- await locateExistingConnection({ apiUrl: apiBaseUrl(), apiKey, connectionString: adminUrl })
5953
+ await locateExistingConnection({
5954
+ apiUrl: apiBaseUrl(),
5955
+ apiKey,
5956
+ connectionString: adminUrl,
5957
+ schema: args.schema
5958
+ })
5874
5959
  );
5875
5960
  if (heldElsewhere) {
5876
5961
  console.error(` ${import_picocolors11.default.yellow("!")} ${heldElsewhere}
@@ -5930,8 +6015,8 @@ ${ambient}` : ""}`,
5930
6015
  );
5931
6016
  process.exit(1);
5932
6017
  }
5933
- const ledger = await ledgerBlocker(admin).catch(() => null);
5934
- const foreignTables = await publishedTableBlockers(admin, tables).catch(() => []);
6018
+ const ledger = await ledgerBlocker(admin, args.schema).catch(() => null);
6019
+ const foreignTables = await publishedTableBlockers(admin, tables, args.schema).catch(() => []);
5935
6020
  const { inheritGrants, unresolved } = ownershipRemediation(
5936
6021
  [...ledger ? [ledger] : [], ...foreignTables],
5937
6022
  capability.rolname
@@ -5955,12 +6040,16 @@ ${ambient}` : ""}`,
5955
6040
  );
5956
6041
  process.exit(1);
5957
6042
  }
5958
- const existingPublication = await readPublicationState(admin).catch(
6043
+ const existingPublication = await readPublicationState(admin, {
6044
+ schema: args.schema,
6045
+ publication
6046
+ }).catch(
5959
6047
  () => ({ exists: false, allTables: false, tables: [] })
5960
6048
  );
5961
- const pubReconcile = reconcilePublicationPlan(existingPublication, tables);
5962
- const role = args.role && args.role.length > 0 ? args.role : import_footprint.ABLO_REPLICATION_ROLE;
5963
- const writeRole = args.writeRole && args.writeRole.length > 0 ? args.writeRole : import_footprint.ABLO_WRITE_ROLE;
6049
+ const pubReconcile = reconcilePublicationPlan(existingPublication, tables, {
6050
+ schema: args.schema,
6051
+ publication
6052
+ });
5964
6053
  const existingRoles = await presentRoles(admin, [role, writeRole]).catch(() => []);
5965
6054
  if (rotatePlane) {
5966
6055
  const refusal = rotateWithoutConnection({ rotating, ...rotatePlane, existingRoles });
@@ -6001,8 +6090,10 @@ ${ambient}` : ""}`,
6001
6090
  const writePassword = generateRolePassword();
6002
6091
  const buildPlan = (mode) => connectApplyPlan({
6003
6092
  tables,
6004
- role: args.role,
6005
- writeRole: args.writeRole,
6093
+ role,
6094
+ writeRole,
6095
+ schema: args.schema,
6096
+ publication,
6006
6097
  rotate: rotating,
6007
6098
  credentials: {
6008
6099
  replicationClause: passwordClause(replicationPassword, mode),
@@ -6016,7 +6107,7 @@ ${ambient}` : ""}`,
6016
6107
  const steps = buildPlan("scram-verifier");
6017
6108
  if (pubReconcile.removed.length > 0 || pubReconcile.recreated) {
6018
6109
  console.log(
6019
- ` ${import_picocolors11.default.yellow("!")} ${import_picocolors11.default.bold(import_footprint.ABLO_PUBLICATION)} already publishes a different set; reconciling to your ${import_picocolors11.default.bold("--tables")}:`
6110
+ ` ${import_picocolors11.default.yellow("!")} ${import_picocolors11.default.bold(publication)} already publishes a different set; reconciling to your mapped tables:`
6020
6111
  );
6021
6112
  for (const t of pubReconcile.added) console.log(` ${import_picocolors11.default.green("+")} ${t}`);
6022
6113
  for (const t of pubReconcile.removed)
@@ -6093,9 +6184,12 @@ ${ambient}` : ""}`,
6093
6184
  }
6094
6185
  const replicationProbe = await probeAsRole(
6095
6186
  replicationUrl,
6096
- (sql) => probeReadiness(sql, { coordinatedTables })
6187
+ (sql) => probeReadiness(sql, { coordinatedTables, schema: args.schema, publication })
6188
+ );
6189
+ const writeProbe = await probeAsRole(
6190
+ writeUrl,
6191
+ (sql) => probeDirectWriteReadiness(sql, { schema: args.schema, publication })
6097
6192
  );
6098
- const writeProbe = await probeAsRole(writeUrl, probeDirectWriteReadiness);
6099
6193
  const refused = [
6100
6194
  ...replicationProbe.credentialRefused ? [role] : [],
6101
6195
  ...writeProbe.credentialRefused ? [writeRole] : []
@@ -6150,7 +6244,10 @@ ${ambient}` : ""}`,
6150
6244
  apiKey,
6151
6245
  replicationUrl,
6152
6246
  writeUrl,
6153
- route: args.route
6247
+ route: args.route,
6248
+ schema: args.schema,
6249
+ replicationSlot: footprint.slot,
6250
+ publication
6154
6251
  });
6155
6252
  process.off("SIGINT", onRotateInterrupt);
6156
6253
  process.off("SIGTERM", onRotateInterrupt);
@@ -6162,7 +6259,7 @@ ${ambient}` : ""}`,
6162
6259
  }
6163
6260
  process.exit(outcome.exitCode);
6164
6261
  }
6165
- var import_picocolors11, import_errors9, ROTATE_STRANDED_CREDENTIALS_NOTICE;
6262
+ var import_picocolors11, import_errors9, import_footprint2, ROTATE_STRANDED_CREDENTIALS_NOTICE;
6166
6263
  var init_connectApply = __esm({
6167
6264
  "src/connectApply.ts"() {
6168
6265
  "use strict";
@@ -6171,6 +6268,7 @@ var init_connectApply = __esm({
6171
6268
  init_src();
6172
6269
  init_dist2();
6173
6270
  import_errors9 = require("@abloatai/transaction/errors");
6271
+ import_footprint2 = require("@abloatai/transaction/footprint");
6174
6272
  init_connectSetup();
6175
6273
  init_connectOwnership();
6176
6274
  init_connect();
@@ -6194,6 +6292,7 @@ function parseConnectArgs(argv) {
6194
6292
  let register = false;
6195
6293
  let apply = false;
6196
6294
  let rotate = false;
6295
+ let resnapshot = false;
6197
6296
  let url;
6198
6297
  let envFile;
6199
6298
  let yes = false;
@@ -6202,6 +6301,7 @@ function parseConnectArgs(argv) {
6202
6301
  let locate = false;
6203
6302
  let manual = false;
6204
6303
  let tables = [];
6304
+ let schema = "public";
6205
6305
  let role = import_footprint.ABLO_REPLICATION_ROLE;
6206
6306
  let writeRole = import_footprint.ABLO_WRITE_ROLE;
6207
6307
  let route = "public-allowlist";
@@ -6221,6 +6321,9 @@ function parseConnectArgs(argv) {
6221
6321
  case "rotate":
6222
6322
  rotate = true;
6223
6323
  break;
6324
+ case "resnapshot":
6325
+ resnapshot = true;
6326
+ break;
6224
6327
  case "scan":
6225
6328
  scan = true;
6226
6329
  break;
@@ -6229,7 +6332,7 @@ function parseConnectArgs(argv) {
6229
6332
  break;
6230
6333
  default:
6231
6334
  throw new import_errors10.AbloValidationError(
6232
- `unknown connect subcommand: ${lead} (expected register, deregister, check, apply, rotate, scan, locate)`,
6335
+ `unknown connect subcommand: ${lead} (expected register, deregister, check, apply, rotate, resnapshot, scan, locate)`,
6233
6336
  { code: "cli_invalid_arguments" }
6234
6337
  );
6235
6338
  }
@@ -6259,6 +6362,9 @@ function parseConnectArgs(argv) {
6259
6362
  tables = value.split(",").map((t) => t.trim()).filter((t) => t.length > 0);
6260
6363
  break;
6261
6364
  }
6365
+ case "--schema":
6366
+ schema = argv[++i] ?? schema;
6367
+ break;
6262
6368
  case "--role":
6263
6369
  role = argv[++i] ?? role;
6264
6370
  break;
@@ -6290,6 +6396,7 @@ function parseConnectArgs(argv) {
6290
6396
  register,
6291
6397
  apply,
6292
6398
  rotate,
6399
+ resnapshot,
6293
6400
  url,
6294
6401
  envFile,
6295
6402
  yes,
@@ -6297,17 +6404,22 @@ function parseConnectArgs(argv) {
6297
6404
  scan,
6298
6405
  locate,
6299
6406
  tables,
6407
+ schema,
6300
6408
  role,
6301
6409
  writeRole,
6302
6410
  route,
6303
6411
  manual
6304
6412
  };
6305
6413
  }
6306
- function printConnectRecipe(args) {
6414
+ function printConnectRecipe(args, footprint) {
6415
+ const role = args.role === import_footprint.ABLO_REPLICATION_ROLE ? footprint.replicationRole : args.role;
6416
+ const writeRole = args.writeRole === import_footprint.ABLO_WRITE_ROLE ? footprint.writeRole : args.writeRole;
6307
6417
  const sql = connectSetupSql({
6308
6418
  tables: args.tables,
6309
- role: args.role,
6310
- writeRole: args.writeRole
6419
+ role,
6420
+ writeRole,
6421
+ schema: args.schema,
6422
+ publication: footprint.publication
6311
6423
  });
6312
6424
  console.log(
6313
6425
  `
@@ -6358,7 +6470,7 @@ function printConnectRecipe(args) {
6358
6470
  console.log(
6359
6471
  import_picocolors12.default.dim(
6360
6472
  ` On Amazon RDS, the REPLICATION attribute is granted, not set directly:
6361
- ${import_picocolors12.default.bold(`GRANT rds_replication TO ${quoteIdent(args.role)};`)}`
6473
+ ${import_picocolors12.default.bold(`GRANT rds_replication TO ${quoteIdent(role)};`)}`
6362
6474
  )
6363
6475
  );
6364
6476
  console.log(
@@ -6382,8 +6494,8 @@ function printConnectRecipe(args) {
6382
6494
  `
6383
6495
  ${import_picocolors12.default.bold("5.")} Register the two roles with Ablo. Set them just long enough to register \u2014
6384
6496
  Ablo holds them from here, so your app keeps only ${import_picocolors12.default.bold("ABLO_API_KEY")}:
6385
- ${import_picocolors12.default.bold("ABLO_REPLICATION_DATABASE_URL")} ${import_picocolors12.default.dim(`\u2192 ${args.role} (replication only)`)}
6386
- ${import_picocolors12.default.bold("ABLO_WRITE_DATABASE_URL")} ${import_picocolors12.default.dim(`\u2192 ${args.writeRole} (DML only)`)}
6497
+ ${import_picocolors12.default.bold("ABLO_REPLICATION_DATABASE_URL")} ${import_picocolors12.default.dim(`\u2192 ${role} (replication only)`)}
6498
+ ${import_picocolors12.default.bold("ABLO_WRITE_DATABASE_URL")} ${import_picocolors12.default.dim(`\u2192 ${writeRole} (DML only)`)}
6387
6499
  ${import_picocolors12.default.cyan("npx ablo connect register")}
6388
6500
  `
6389
6501
  );
@@ -6411,9 +6523,9 @@ function printCheckItem(item) {
6411
6523
  }
6412
6524
  }
6413
6525
  }
6414
- async function probeDirectWriteReadiness(sql, opts = {}) {
6526
+ async function probeDirectWriteReadiness(sql, opts) {
6415
6527
  const schema = opts.schema ?? "public";
6416
- const publication = opts.publication ?? import_footprint.ABLO_PUBLICATION;
6528
+ const publication = opts.publication;
6417
6529
  const items = [];
6418
6530
  const roleRows = await sql.unsafe(
6419
6531
  `SELECT rolname, rolsuper, rolbypassrls, rolcreatedb, rolcreaterole, rolreplication
@@ -6526,20 +6638,27 @@ async function probeDirectWriteReadiness(sql, opts = {}) {
6526
6638
  );
6527
6639
  return items;
6528
6640
  }
6529
- async function auditTenantSyncInfra(sql) {
6641
+ async function auditTenantSyncInfra(sql, opts = {}) {
6530
6642
  const artifacts = [];
6531
- for (const artifact of import_footprint2.ABLO_FOOTPRINT) {
6532
- const key = artifact.kind === "table" || artifact.kind === "type" ? `public.${artifact.name}` : artifact.name;
6643
+ const branchScopedTemplates = /* @__PURE__ */ new Set([
6644
+ import_footprint.ABLO_PUBLICATION,
6645
+ import_footprint3.ABLO_REPLICATION_SLOT,
6646
+ import_footprint.ABLO_REPLICATION_ROLE,
6647
+ import_footprint.ABLO_WRITE_ROLE
6648
+ ]);
6649
+ for (const artifact of import_footprint3.ABLO_FOOTPRINT) {
6650
+ const name = opts.names ? artifact.name === import_footprint.ABLO_PUBLICATION ? opts.names.publication : artifact.name === import_footprint3.ABLO_REPLICATION_SLOT ? opts.names.slot : artifact.name === import_footprint.ABLO_REPLICATION_ROLE ? opts.names.replicationRole : artifact.name === import_footprint.ABLO_WRITE_ROLE ? opts.names.writeRole : artifact.name : artifact.name;
6651
+ const key = artifact.kind === "table" || artifact.kind === "type" ? `${artifact.retired ? "public" : opts.schema ?? "public"}.${name}` : name;
6533
6652
  const rows = await sql.unsafe(FOOTPRINT_LOOKUP[artifact.kind], [
6534
6653
  key
6535
6654
  ]);
6536
6655
  artifacts.push({
6537
6656
  kind: artifact.kind,
6538
- name: artifact.name,
6657
+ name,
6539
6658
  present: rows[0]?.present === true,
6540
6659
  purpose: artifact.purpose,
6541
6660
  ...artifact.hazard ? { hazard: artifact.hazard } : {},
6542
- ...artifact.retired ? { retired: true } : {}
6661
+ ...artifact.retired || !opts.names && branchScopedTemplates.has(artifact.name) ? { retired: true } : {}
6543
6662
  });
6544
6663
  }
6545
6664
  return artifacts;
@@ -6564,12 +6683,12 @@ function requireScopedUrl(kind, verb) {
6564
6683
  );
6565
6684
  process.exit(1);
6566
6685
  }
6567
- async function probeAndReport(dbUrl, kind = "replication") {
6686
+ async function probeAndReport(dbUrl, kind, opts) {
6568
6687
  const sql = src_default(dbUrl, { max: 1, prepare: false, connect_timeout: 10, onnotice: () => {
6569
6688
  } });
6570
6689
  let items;
6571
6690
  try {
6572
- items = kind === "replication" ? await probeReadiness(sql) : await probeDirectWriteReadiness(sql);
6691
+ items = kind === "replication" ? await probeReadiness(sql, opts) : await probeDirectWriteReadiness(sql, opts);
6573
6692
  } catch (err) {
6574
6693
  await sql.end({ timeout: 2 }).catch(() => void 0);
6575
6694
  const dial = dialFailureReason(err);
@@ -6605,7 +6724,7 @@ ${ambient}` : ""}`,
6605
6724
  if (!result.ok) {
6606
6725
  if (result.code === "no_data_source_registered") {
6607
6726
  console.error(
6608
- ` ${import_picocolors12.default.yellow("\u2014")} No database is connected to this plane yet, so there's nothing to check.
6727
+ ` ${import_picocolors12.default.yellow("\u2014")} This branch is not connected to a database yet, so there's nothing to check.
6609
6728
  ` + import_picocolors12.default.dim(
6610
6729
  ` Connect one with ${import_picocolors12.default.bold("ablo connect apply")}, then re-run ${import_picocolors12.default.bold("ablo connect check")}.
6611
6730
  `
@@ -6697,13 +6816,32 @@ ${ambient}` : ""}`,
6697
6816
  ${brand("ablo")} ${import_picocolors12.default.dim("connect register")} ${import_picocolors12.default.dim("register a direct DataSource")}
6698
6817
  `
6699
6818
  );
6819
+ const target = await resolveTarget({ url: apiBaseUrl(), apiKey, keySource: "env" });
6820
+ const confirmed = target.confirmed;
6821
+ if (!confirmed?.branchId) {
6822
+ throw new import_errors10.AbloValidationError(
6823
+ "This key is not bound to a branch, so Ablo cannot validate isolated database objects safely.",
6824
+ { code: "cli_database_unreachable" }
6825
+ );
6826
+ }
6827
+ const publication = (0, import_footprint3.footprintNamesFor)({
6828
+ organizationId: confirmed.organizationId,
6829
+ branchId: confirmed.branchId,
6830
+ ...confirmed.projectId ? { projectId: confirmed.projectId } : {}
6831
+ }).publication;
6700
6832
  console.log(` ${import_picocolors12.default.bold("Replication role")}
6701
6833
  `);
6702
- const replication = await probeAndReport(dbUrl, "replication");
6834
+ const replication = await probeAndReport(dbUrl, "replication", {
6835
+ schema: args.schema,
6836
+ publication
6837
+ });
6703
6838
  console.log(`
6704
6839
  ${import_picocolors12.default.bold("Direct-write role")}
6705
6840
  `);
6706
- const write = await probeAndReport(writeDbUrl, "write");
6841
+ const write = await probeAndReport(writeDbUrl, "write", {
6842
+ schema: args.schema,
6843
+ publication
6844
+ });
6707
6845
  const noDial = [
6708
6846
  replication.kind === "no-dial" ? `replication: ${replication.reason}` : null,
6709
6847
  write.kind === "no-dial" ? `write: ${write.reason}` : null
@@ -6731,17 +6869,35 @@ ${ambient}` : ""}`,
6731
6869
  apiKey,
6732
6870
  replicationUrl: dbUrl,
6733
6871
  writeUrl: writeDbUrl,
6734
- route: args.route
6872
+ route: args.route,
6873
+ schema: args.schema
6735
6874
  });
6736
6875
  process.exit(registered ? 0 : 1);
6737
6876
  }
6738
- async function runScan() {
6877
+ async function runScan(args) {
6739
6878
  const dbUrl = requireScopedUrl("replication", "scan");
6740
6879
  const sql = src_default(dbUrl, { max: 1, prepare: false, onnotice: () => {
6741
6880
  } });
6742
6881
  let artifacts;
6743
6882
  try {
6744
- artifacts = await auditTenantSyncInfra(sql);
6883
+ const apiKey = resolveRuntimeApiKey().key;
6884
+ const target = apiKey ? await resolveTarget({ url: apiBaseUrl(), apiKey, keySource: "env" }).catch(() => null) : null;
6885
+ const confirmed = target?.confirmed;
6886
+ const names = confirmed?.branchId ? (0, import_footprint3.footprintNamesFor)({
6887
+ organizationId: confirmed.organizationId,
6888
+ branchId: confirmed.branchId,
6889
+ ...confirmed.projectId ? { projectId: confirmed.projectId } : {}
6890
+ }) : void 0;
6891
+ const isolated = await auditTenantSyncInfra(sql, {
6892
+ schema: args.schema,
6893
+ ...names ? { names } : {}
6894
+ });
6895
+ const legacy = names ? await auditTenantSyncInfra(sql, { schema: args.schema }) : [];
6896
+ artifacts = [...isolated, ...legacy].filter(
6897
+ (artifact, index, all) => all.findIndex(
6898
+ (candidate) => candidate.kind === artifact.kind && candidate.name === artifact.name
6899
+ ) === index
6900
+ );
6745
6901
  } catch (err) {
6746
6902
  const pg = err ?? {};
6747
6903
  await sql.end({ timeout: 2 });
@@ -6792,7 +6948,7 @@ async function runScan() {
6792
6948
  async function runLocate(args) {
6793
6949
  console.log(
6794
6950
  `
6795
- ${brand("ablo")} ${import_picocolors12.default.dim("connect locate")} ${import_picocolors12.default.dim("which plane holds this database")}
6951
+ ${brand("ablo")} ${import_picocolors12.default.dim("connect locate")} ${import_picocolors12.default.dim("which branch is connected to this database")}
6796
6952
  `
6797
6953
  );
6798
6954
  const url = args.url ?? readProjectAdminDatabaseUrl();
@@ -6822,12 +6978,20 @@ ${ambient}` : ""}`,
6822
6978
  path: "/v1/datasources/locate",
6823
6979
  method: "POST",
6824
6980
  apiKey,
6825
- body: { connectionString: url },
6981
+ body: { connectionString: url, schema: args.schema },
6826
6982
  responseSchema: import_wire7.datasourceLocationResponseSchema
6827
6983
  });
6984
+ if (answer.available === false && !answer.held) {
6985
+ console.log(
6986
+ ` ${import_picocolors12.default.yellow("!")} ${import_picocolors12.default.bold(label)} schema ${import_picocolors12.default.bold(args.schema)} is connected to another organization.
6987
+ ` + import_picocolors12.default.dim(` Its project and branch are private. Use another schema or provider database URL.
6988
+ `)
6989
+ );
6990
+ return;
6991
+ }
6828
6992
  if (!answer.held) {
6829
6993
  console.log(
6830
- ` ${import_picocolors12.default.green("\u2713")} No plane holds ${import_picocolors12.default.bold(label)} \u2014 ${import_picocolors12.default.bold("ablo connect apply")} can register it here.
6994
+ ` ${import_picocolors12.default.green("\u2713")} No branch is connected to ${import_picocolors12.default.bold(`${label}/${args.schema}`)} \u2014 ${import_picocolors12.default.bold("ablo connect apply")} can register it here.
6831
6995
  `
6832
6996
  );
6833
6997
  return;
@@ -6838,13 +7002,50 @@ ${ambient}` : ""}`,
6838
7002
  );
6839
7003
  console.log(
6840
7004
  import_picocolors12.default.dim(
6841
- ` Ablo streams a database from one plane at a time. To move it, disconnect it there
7005
+ ` Ablo connects one branch to each database schema. To move this schema, disconnect it there
6842
7006
  first \u2014 run `
6843
- ) + import_picocolors12.default.cyan("ablo connect deregister") + import_picocolors12.default.dim(` with a key for that plane, then connect here.
7007
+ ) + import_picocolors12.default.cyan("ablo connect deregister") + import_picocolors12.default.dim(` with a key for that branch, then connect here.
6844
7008
  `) + import_picocolors12.default.dim(` Confirm a candidate key first with `) + import_picocolors12.default.bold("ablo whoami --key-env <NAME>") + import_picocolors12.default.dim(`.
6845
7009
  `) + import_picocolors12.default.dim(` Match the project id to a name with `) + import_picocolors12.default.bold("ablo projects list --json") + import_picocolors12.default.dim(".") + "\n"
6846
7010
  );
6847
7011
  }
7012
+ async function runResnapshot() {
7013
+ const apiKey = resolveMutationApiKey();
7014
+ if (!apiKey) {
7015
+ throw new import_errors10.AbloAuthenticationError(
7016
+ "No branch-bound secret key found. Set ABLO_API_KEY to the sk_ key for the branch to resnapshot.",
7017
+ { code: "cli_api_key_missing" }
7018
+ );
7019
+ }
7020
+ console.log(
7021
+ `
7022
+ ${brand("ablo")} ${import_picocolors12.default.dim("connect resnapshot")} ${import_picocolors12.default.dim("reload existing rows")}
7023
+ `
7024
+ );
7025
+ const result = await requestControlPlane({
7026
+ path: "/v1/datasources/resnapshot",
7027
+ method: "POST",
7028
+ apiKey,
7029
+ body: {},
7030
+ responseSchema: import_wire7.datasourceResnapshotResponseSchema
7031
+ });
7032
+ if (result.replication_slot?.released === false) {
7033
+ console.log(` ${import_picocolors12.default.yellow("\u2014")} Snapshot reset recorded, but the old slot is still active.`);
7034
+ if (result.replication_slot.detail) console.log(` ${import_picocolors12.default.dim(result.replication_slot.detail)}`);
7035
+ if (result.replication_slot.remove_with) {
7036
+ console.log(` ${import_picocolors12.default.dim("Remove it with:")} ${import_picocolors12.default.bold(result.replication_slot.remove_with)}`);
7037
+ }
7038
+ console.log(`
7039
+ Re-run ${import_picocolors12.default.bold("ablo connect resnapshot")} after releasing it.
7040
+ `);
7041
+ process.exit(1);
7042
+ }
7043
+ console.log(
7044
+ ` ${import_picocolors12.default.green("\u2713")} Fresh snapshot requested. The DataSource and credentials were kept.
7045
+ ${import_picocolors12.default.dim(`Run ${import_picocolors12.default.bold("ablo connect check")} until the existing-row load is complete.`)}
7046
+ `
7047
+ );
7048
+ }
6848
7049
  async function connect(argv) {
6849
7050
  if (argv[0] === "deregister") {
6850
7051
  const { disconnect: disconnect2 } = await Promise.resolve().then(() => (init_disconnect(), disconnect_exports));
@@ -6871,12 +7072,16 @@ async function connect(argv) {
6871
7072
  await runCheck();
6872
7073
  return;
6873
7074
  }
7075
+ if (args.resnapshot) {
7076
+ await runResnapshot();
7077
+ return;
7078
+ }
6874
7079
  if (args.register) {
6875
7080
  await runRegister(args);
6876
7081
  return;
6877
7082
  }
6878
7083
  if (args.scan) {
6879
- await runScan();
7084
+ await runScan(args);
6880
7085
  return;
6881
7086
  }
6882
7087
  if (args.locate) {
@@ -6890,9 +7095,31 @@ async function connect(argv) {
6890
7095
  await runConnectApply2(args);
6891
7096
  return;
6892
7097
  }
6893
- printConnectRecipe(args);
7098
+ const apiKey = resolveRuntimeApiKey().key;
7099
+ if (!apiKey) {
7100
+ throw new import_errors10.AbloAuthenticationError(
7101
+ "Ablo needs the branch key to derive isolated database object names. Set ABLO_API_KEY for this branch, then re-run `ablo connect --manual`.",
7102
+ { code: "cli_api_key_missing" }
7103
+ );
7104
+ }
7105
+ const target = await resolveTarget({ url: apiBaseUrl(), apiKey, keySource: "env" });
7106
+ const confirmed = target.confirmed;
7107
+ if (!confirmed?.branchId) {
7108
+ throw new import_errors10.AbloValidationError(
7109
+ "This key is not bound to a branch, so Ablo cannot derive isolated database object names safely.",
7110
+ { code: "cli_database_unreachable" }
7111
+ );
7112
+ }
7113
+ printConnectRecipe(
7114
+ args,
7115
+ (0, import_footprint3.footprintNamesFor)({
7116
+ organizationId: confirmed.organizationId,
7117
+ branchId: confirmed.branchId,
7118
+ ...confirmed.projectId ? { projectId: confirmed.projectId } : {}
7119
+ })
7120
+ );
6894
7121
  }
6895
- var import_errors10, import_picocolors12, import_footprint2, import_wire7, FOOTPRINT_LOOKUP, CONNECT_USAGE;
7122
+ var import_errors10, import_picocolors12, import_footprint3, import_wire7, FOOTPRINT_LOOKUP, CONNECT_USAGE;
6896
7123
  var init_connect = __esm({
6897
7124
  "src/connect.ts"() {
6898
7125
  "use strict";
@@ -6900,12 +7127,13 @@ var init_connect = __esm({
6900
7127
  import_errors10 = require("@abloatai/transaction/errors");
6901
7128
  import_picocolors12 = __toESM(require_picocolors(), 1);
6902
7129
  init_src();
6903
- import_footprint2 = require("@abloatai/transaction/footprint");
7130
+ import_footprint3 = require("@abloatai/transaction/footprint");
6904
7131
  init_dbRole();
6905
7132
  init_config();
6906
7133
  init_controlPlane();
6907
7134
  import_wire7 = require("@abloatai/transaction/wire");
6908
7135
  init_theme();
7136
+ init_target();
6909
7137
  init_remoteValidation();
6910
7138
  init_connectSetup();
6911
7139
  FOOTPRINT_LOOKUP = {
@@ -6927,8 +7155,9 @@ var init_connect = __esm({
6927
7155
  npx ablo connect deregister Disconnect this project's database \u2014 Ablo stops reading and writing it
6928
7156
  npx ablo connect check Confirm the connected database is ready, from Ablo's side (needs only ABLO_API_KEY)
6929
7157
  npx ablo connect rotate New passwords for both logins, then re-register
7158
+ npx ablo connect resnapshot Recreate only the slot and reload existing rows
6930
7159
  npx ablo connect scan List anything Ablo ever set up in your database (read-only, never drops)
6931
- npx ablo connect locate See which plane holds this database (read-only; nothing is changed)
7160
+ npx ablo connect locate See which branch is connected to this database (read-only; nothing is changed)
6932
7161
 
6933
7162
  Running it: bare \`ablo connect\` sets everything up for you \u2014 creating the two
6934
7163
  scoped logins, sharing your tables, and registering \u2014 whenever it finds a
@@ -6941,6 +7170,7 @@ var init_connect = __esm({
6941
7170
  --url <admin-conn> Admin connection used once to set up (else DATABASE_URL); never stored
6942
7171
  --env-file <path> Explicitly load DATABASE_URL and ABLO_API_KEY from this file
6943
7172
  --tables a,b,c Publish only these tables (default: all tables)
7173
+ --schema <name> Bind this project to one database schema (default: public)
6944
7174
  --role <name> Name the replication role (default: ablo_replicator)
6945
7175
  --write-role <name> Name the DML role (default: ablo_writer)
6946
7176
  --route <route> public-allowlist | privatelink | peering | vpn
@@ -283406,7 +283636,7 @@ async function readStatus(ref, context = {}) {
283406
283636
  return import_branches.branchStatusResponseSchema.parse(response.body);
283407
283637
  }
283408
283638
  function printStatus(status2) {
283409
- const { branch, schema, data_source: source } = status2;
283639
+ const { branch, schema, storage, data_source: source } = status2;
283410
283640
  console.log(` ${import_picocolors14.default.bold(branch.slug)} ${import_picocolors14.default.dim(branch.id)}`);
283411
283641
  console.log(
283412
283642
  ` ${import_picocolors14.default.dim("state")} ${branch.state === "ready" ? import_picocolors14.default.green(branch.state) : import_picocolors14.default.yellow(branch.state)}`
@@ -283422,7 +283652,7 @@ function printStatus(status2) {
283422
283652
  ` ${import_picocolors14.default.dim("parent")} ${import_picocolors14.default.bold(schema.parent_compatibility)}${counts}`
283423
283653
  );
283424
283654
  }
283425
- const sourceLabel = source.kind === "hosted" ? "hosted branch plane" : `${source.kind} \xB7 ${source.host ?? "unknown host"}${source.database ? `/${source.database}` : ""} \xB7 ${source.status ?? "unknown"}`;
283655
+ const sourceLabel = source ? `${source.connection} \xB7 ${source.host ?? "unknown host"}${source.database ? `/${source.database}` : ""} \xB7 ${source.status}` : storage.kind === "unbound" ? "not connected to a database" : storage.kind === "internal" ? `Ablo internal product storage \xB7 ${storage.implementation}` : storage.kind === "blocked" ? "storage configuration needs attention" : "customer database connection unavailable";
283426
283656
  console.log(` ${import_picocolors14.default.dim("data")} ${sourceLabel}`);
283427
283657
  if (status2.ready) {
283428
283658
  console.log(`
@@ -283569,11 +283799,15 @@ init_controlPlane();
283569
283799
  init_config();
283570
283800
  init_readiness();
283571
283801
  init_theme();
283802
+ init_dbRole();
283803
+ var import_source3 = require("@abloatai/transaction/source");
283572
283804
  function parseDevArgs(argv) {
283573
283805
  let schemaPath = DEFAULT_SCHEMA_PATH;
283574
283806
  let exportName = DEFAULT_EXPORT;
283575
283807
  let url = process.env.ABLO_API_URL ?? DEFAULT_URL;
283576
283808
  let watchEnabled = false;
283809
+ let local = false;
283810
+ let sourcePath = "ablo/data-source.ts";
283577
283811
  for (let i = 0; i < argv.length; i++) {
283578
283812
  const arg = argv[i];
283579
283813
  switch (arg) {
@@ -283592,6 +283826,12 @@ function parseDevArgs(argv) {
283592
283826
  case "--no-watch":
283593
283827
  watchEnabled = false;
283594
283828
  break;
283829
+ case "--local":
283830
+ local = true;
283831
+ break;
283832
+ case "--source":
283833
+ sourcePath = argv[++i] ?? sourcePath;
283834
+ break;
283595
283835
  default:
283596
283836
  throw new import_errors11.AbloValidationError(`unknown flag: ${arg}`, { code: "cli_invalid_arguments" });
283597
283837
  }
@@ -283603,9 +283843,55 @@ function parseDevArgs(argv) {
283603
283843
  url,
283604
283844
  apiKey: process.env.ABLO_API_KEY,
283605
283845
  watch: watchEnabled,
283846
+ local,
283847
+ sourcePath,
283606
283848
  planeLabel: "branch"
283607
283849
  };
283608
283850
  }
283851
+ async function loadLocalSourceHandler(sourcePath) {
283852
+ const abs = (0, import_path5.resolve)(process.cwd(), sourcePath);
283853
+ if (!(0, import_fs7.existsSync)(abs)) {
283854
+ throw new import_errors11.AbloValidationError(
283855
+ `local Data Source not found at ${import_picocolors15.default.bold(sourcePath)}. Add the signed Data Source handler described by ${import_picocolors15.default.bold("npx ablo docs data-sources")}, or pass ${import_picocolors15.default.bold("--source <path>")}.`,
283856
+ { code: "cli_invalid_arguments" }
283857
+ );
283858
+ }
283859
+ const { createJiti } = await import("jiti");
283860
+ const jiti = createJiti(process.cwd());
283861
+ const mod = await jiti.import(abs);
283862
+ const nested = mod.default && typeof mod.default === "object" ? mod.default : void 0;
283863
+ const handler = mod.POST ?? nested?.POST;
283864
+ if (typeof handler !== "function") {
283865
+ throw new import_errors11.AbloValidationError(
283866
+ `${import_picocolors15.default.bold(sourcePath)} must export a ${import_picocolors15.default.bold("POST(request)")} Data Source handler.`,
283867
+ { code: "cli_invalid_arguments" }
283868
+ );
283869
+ }
283870
+ return handler;
283871
+ }
283872
+ async function registerLocalSource(args) {
283873
+ const response = await fetch(`${args.url}/v1/datasources`, {
283874
+ method: "POST",
283875
+ headers: {
283876
+ authorization: `Bearer ${args.apiKey}`,
283877
+ "content-type": "application/json"
283878
+ },
283879
+ body: JSON.stringify({
283880
+ connection: "endpoint",
283881
+ endpoint: "http://localhost/ablo-dev/reverse-channel",
283882
+ signingKey: args.apiKey,
283883
+ reverseChannel: true,
283884
+ metadata: { managed_by: "ablo dev --local" }
283885
+ })
283886
+ });
283887
+ if (!response.ok) {
283888
+ const body = await response.text();
283889
+ throw new import_errors11.AbloValidationError(
283890
+ `Could not register the local Data Source (${response.status}): ${body}`,
283891
+ { code: "cli_invalid_arguments" }
283892
+ );
283893
+ }
283894
+ }
283609
283895
  function classifyKey(apiKey) {
283610
283896
  if (!apiKey) {
283611
283897
  return {
@@ -283625,13 +283911,21 @@ function classifyKey(apiKey) {
283625
283911
  reason: `${import_picocolors15.default.bold("ABLO_API_KEY")} is not a secret Ablo key. Expected a branch-bound ${import_picocolors15.default.bold("sk_\u2026")} credential. Run ${import_picocolors15.default.bold("npx ablo dev")} to prepare a development branch.`
283626
283912
  };
283627
283913
  }
283628
- function wireEnvLocal(apiKey, cwd = process.cwd()) {
283914
+ function wireEnvLocal(apiKey, cwd = process.cwd(), projectId, branchId) {
283629
283915
  const envPath = (0, import_path5.resolve)(cwd, ".env.local");
283630
283916
  const line = `ABLO_API_KEY=${apiKey}`;
283917
+ const projectLine = projectId ? `ABLO_PROJECT_ID=${projectId}` : null;
283918
+ const branchLine = branchId ? `ABLO_BRANCH_ID=${branchId}` : null;
283631
283919
  let action;
283632
283920
  if (!(0, import_fs7.existsSync)(envPath)) {
283633
- (0, import_fs7.writeFileSync)(envPath, `${line}
283634
- `, { mode: 384 });
283921
+ (0, import_fs7.writeFileSync)(
283922
+ envPath,
283923
+ `${line}
283924
+ ${projectLine ? `${projectLine}
283925
+ ` : ""}${branchLine ? `${branchLine}
283926
+ ` : ""}`,
283927
+ { mode: 384 }
283928
+ );
283635
283929
  action = `Created ${import_picocolors15.default.bold(".env.local")} with ${import_picocolors15.default.bold("ABLO_API_KEY")}`;
283636
283930
  } else {
283637
283931
  const content = (0, import_fs7.readFileSync)(envPath, "utf8");
@@ -283647,6 +283941,24 @@ function wireEnvLocal(apiKey, cwd = process.cwd()) {
283647
283941
  (0, import_fs7.writeFileSync)(envPath, content.replace(/^ABLO_API_KEY=.*$/m, line));
283648
283942
  action = `Updated ${import_picocolors15.default.bold("ABLO_API_KEY")} in ${import_picocolors15.default.bold(".env.local")} ${import_picocolors15.default.dim(`(was ${existing.slice(0, 12)}\u2026)`)}`;
283649
283943
  }
283944
+ if (projectLine) {
283945
+ const next = (0, import_fs7.readFileSync)(envPath, "utf8");
283946
+ if (/^ABLO_PROJECT_ID=.*$/m.test(next)) {
283947
+ (0, import_fs7.writeFileSync)(envPath, next.replace(/^ABLO_PROJECT_ID=.*$/m, projectLine));
283948
+ } else {
283949
+ (0, import_fs7.appendFileSync)(envPath, `${next.endsWith("\n") || next.length === 0 ? "" : "\n"}${projectLine}
283950
+ `);
283951
+ }
283952
+ }
283953
+ if (branchLine) {
283954
+ const next = (0, import_fs7.readFileSync)(envPath, "utf8");
283955
+ if (/^ABLO_BRANCH_ID=.*$/m.test(next)) {
283956
+ (0, import_fs7.writeFileSync)(envPath, next.replace(/^ABLO_BRANCH_ID=.*$/m, branchLine));
283957
+ } else {
283958
+ (0, import_fs7.appendFileSync)(envPath, `${next.endsWith("\n") || next.length === 0 ? "" : "\n"}${branchLine}
283959
+ `);
283960
+ }
283961
+ }
283650
283962
  }
283651
283963
  const gitignorePath = (0, import_path5.resolve)(cwd, ".gitignore");
283652
283964
  const gitignore = (0, import_fs7.existsSync)(gitignorePath) ? (0, import_fs7.readFileSync)(gitignorePath, "utf8") : "";
@@ -283717,7 +284029,7 @@ async function runPush(schema, args) {
283717
284029
  }
283718
284030
  if (status2 === 403) {
283719
284031
  const serverSays = body.message ?? body.reason;
283720
- const hint = body.code === "database_role_cannot_enforce_rls" ? `Run ${import_picocolors15.default.bold("npx ablo migrate")} \u2014 it creates the scoped role for you (your DB credential never leaves this machine).` : `Schema authoring needs a branch-bound ${import_picocolors15.default.bold("sk_")} key with ${import_picocolors15.default.bold("schema:push")} \u2014 manage keys at ${import_picocolors15.default.cyan("https://abloatai.com")}.`;
284032
+ const hint = schemaPushStorageHint(body.code) ?? (body.code === "database_role_cannot_enforce_rls" ? `Run ${import_picocolors15.default.bold("npx ablo migrate")} \u2014 it creates the scoped role for you (your DB credential never leaves this machine).` : `Schema authoring needs a branch-bound ${import_picocolors15.default.bold("sk_")} key with ${import_picocolors15.default.bold("schema:push")} \u2014 manage keys at ${import_picocolors15.default.cyan("https://abloatai.com")}.`);
283721
284033
  return {
283722
284034
  ok: false,
283723
284035
  message: `${serverSays ?? "This key can't author schema (missing schema:push scope)."}
@@ -283725,6 +284037,11 @@ async function runPush(schema, args) {
283725
284037
  };
283726
284038
  }
283727
284039
  const serverMessage = String(body.message ?? body.reason ?? bodyText);
284040
+ const storageHint = schemaPushStorageHint(body.code);
284041
+ if (storageHint) {
284042
+ return { ok: false, message: `${serverMessage}
284043
+ ${import_picocolors15.default.dim(storageHint)}` };
284044
+ }
283728
284045
  if (looksLikeCredentialRefusal(serverMessage)) {
283729
284046
  const pooled = await poolerExplanation(apiBaseUrl(), args.apiKey);
283730
284047
  if (pooled) {
@@ -283745,6 +284062,12 @@ async function dev(argv, runtime = {}) {
283745
284062
  if (runtime.apiKey) args.apiKey = runtime.apiKey;
283746
284063
  else if (!args.apiKey) args.apiKey = resolveRuntimeApiKey("sandbox").key;
283747
284064
  if (runtime.branch) args.planeLabel = runtime.branch.slug;
284065
+ if (args.local && !args.watch) {
284066
+ throw new import_errors11.AbloValidationError(
284067
+ `${import_picocolors15.default.bold("--local")} opens a long-lived secure connector and cannot be combined with ${import_picocolors15.default.bold("--no-watch")}.`,
284068
+ { code: "cli_invalid_arguments" }
284069
+ );
284070
+ }
283748
284071
  const key = classifyKey(args.apiKey);
283749
284072
  if (!key.ok) {
283750
284073
  console.error(import_picocolors15.default.red(` ${key.reason}`));
@@ -283761,6 +284084,35 @@ async function dev(argv, runtime = {}) {
283761
284084
  ` ${import_picocolors15.default.dim("key")} temporary \xB7 expires ${runtime.branch.expiresAt}`
283762
284085
  );
283763
284086
  }
284087
+ let localAbort = null;
284088
+ if (args.local) {
284089
+ process.env.ABLO_API_KEY = args.apiKey;
284090
+ if (!process.env.DATABASE_URL) {
284091
+ const databaseUrl = readProjectEnvVariable("DATABASE_URL", process.cwd(), false);
284092
+ if (databaseUrl) process.env.DATABASE_URL = databaseUrl.value;
284093
+ }
284094
+ const handler = await loadLocalSourceHandler(args.sourcePath);
284095
+ await registerLocalSource(args);
284096
+ localAbort = new AbortController();
284097
+ const connector = (0, import_source3.createSourceConnector)({
284098
+ apiKey: args.apiKey,
284099
+ handler,
284100
+ baseURL: args.url,
284101
+ client: "ablo-dev",
284102
+ onStatus(status2) {
284103
+ if (status2 === "ready") {
284104
+ console.log(` ${import_picocolors15.default.green("\u2713")} local Postgres connected through the secure reverse channel`);
284105
+ }
284106
+ },
284107
+ onError(error) {
284108
+ console.error(import_picocolors15.default.yellow(` local connector: ${error instanceof Error ? error.message : String(error)}`));
284109
+ }
284110
+ });
284111
+ void connector.run(localAbort.signal).catch((error) => {
284112
+ console.error(import_picocolors15.default.red(` local connector stopped: ${error instanceof Error ? error.message : String(error)}`));
284113
+ });
284114
+ console.log(` ${import_picocolors15.default.dim("source")} ${args.sourcePath} ${import_picocolors15.default.dim("(outbound connector; no public URL)")}`);
284115
+ }
283764
284116
  const schema = await loadSchema(args.schemaPath, args.exportName);
283765
284117
  const modelCount = Object.keys(schema.models).length;
283766
284118
  console.log(
@@ -283775,10 +284127,15 @@ async function dev(argv, runtime = {}) {
283775
284127
  s.start("Pushing schema definition (development branch)");
283776
284128
  const first = await runPush(schema, args);
283777
284129
  s.stop(first.message, first.ok ? 0 : 1);
283778
- if (!first.ok) process.exit(1);
284130
+ if (!first.ok) {
284131
+ localAbort?.abort();
284132
+ process.exit(1);
284133
+ }
283779
284134
  if (runtime.branch) {
283780
- console.log(`
283781
- ${import_picocolors15.default.green("\u2713")} ${wireEnvLocal(args.apiKey)}`);
284135
+ console.log(
284136
+ `
284137
+ ${import_picocolors15.default.green("\u2713")} ${wireEnvLocal(args.apiKey, process.cwd(), runtime.branch.projectId, runtime.branch.id)}`
284138
+ );
283782
284139
  console.log(
283783
284140
  ` ${import_picocolors15.default.dim(`Temporary branch credential expires ${runtime.branch.expiresAt}; rerun ablo dev to rotate it.`)}`
283784
284141
  );
@@ -283827,6 +284184,7 @@ async function dev(argv, runtime = {}) {
283827
284184
  }
283828
284185
  const stop = () => {
283829
284186
  watcher.close();
284187
+ localAbort?.abort();
283830
284188
  console.log(`
283831
284189
  ${import_picocolors15.default.dim("stopped.")}`);
283832
284190
  process.exit(0);
@@ -283842,6 +284200,7 @@ init_controlPlane();
283842
284200
  var BRANCH_DEV_USAGE = `Usage:
283843
284201
  ablo dev [--branch <slug>] [--branch-ttl-hours <1-168>]
283844
284202
  [--schema <path>] [--export <name>] [--url <url>]
284203
+ [--local] [--source <path>]
283845
284204
  ablo dev --no-watch [branch options]
283846
284205
 
283847
284206
  By default, dev discovers the Git/CI branch, ensures its isolated Ablo branch,
@@ -283953,6 +284312,7 @@ async function runBranchDev(argv, dependencies = {}) {
283953
284312
  apiKey: result.credential.api_key,
283954
284313
  branch: {
283955
284314
  id: result.branch.id,
284315
+ projectId: result.branch.project_id,
283956
284316
  slug: result.branch.slug,
283957
284317
  expiresAt: result.credential.expires_at
283958
284318
  }
@@ -284227,8 +284587,9 @@ var COMMANDS = [
284227
284587
  { run: "connect", does: "Connect your database \u2014 shows the setup to run, or applies it for you" },
284228
284588
  { run: "connect apply", does: "Run that setup for you, from a one-time admin URL" },
284229
284589
  { run: "connect check", does: "Confirm your database is ready to share changes with Ablo" },
284590
+ { run: "connect resnapshot", does: "Reload existing rows without deregistering or rotating credentials" },
284230
284591
  { run: "connect scan", does: "List anything Ablo ever set up in your database (read-only)" },
284231
- { run: "connect locate", does: "See which plane holds a database before connecting it" },
284592
+ { run: "connect locate", does: "See which branch is connected to a database before connecting it" },
284232
284593
  { run: "connect deregister", does: "Disconnect this project's database \u2014 Ablo stops reading and writing it" }
284233
284594
  ]
284234
284595
  }
@@ -284972,11 +285333,11 @@ async function status(args = []) {
284972
285333
  }
284973
285334
  } else if (dataSource.kind === "none") {
284974
285335
  console.log(
284975
- ` ${import_picocolors19.default.dim("data")} ${import_picocolors19.default.red("\u2717 no database connected to this plane")} ${import_picocolors19.default.dim("\u2014 writes are held")}`
285336
+ ` ${import_picocolors19.default.dim("data")} ${import_picocolors19.default.red("\u2717 this branch is not connected to a database")} ${import_picocolors19.default.dim("\u2014 writes are held")}`
284976
285337
  );
284977
285338
  } else if (reachable) {
284978
285339
  console.log(
284979
- ` ${import_picocolors19.default.dim("data")} ${import_picocolors19.default.yellow("?")} ${import_picocolors19.default.dim(`could not read the plane's databases (${dataSource.detail})`)}`
285340
+ ` ${import_picocolors19.default.dim("data")} ${import_picocolors19.default.yellow("?")} ${import_picocolors19.default.dim(`could not read this branch's database connection (${dataSource.detail})`)}`
284980
285341
  );
284981
285342
  }
284982
285343
  const pushed = reachable ? await fetchPushedSchema(apiUrl3, introspectKey) : null;
@@ -285022,7 +285383,7 @@ async function status(args = []) {
285022
285383
  }
285023
285384
  } else if (dataSource.kind === "unknown") {
285024
285385
  console.log(
285025
- ` ${import_picocolors19.default.yellow("?")} ${import_picocolors19.default.dim("nothing is blocking a write, but this key could not read the plane's databases \u2014 some checks were skipped")}`
285386
+ ` ${import_picocolors19.default.yellow("?")} ${import_picocolors19.default.dim("nothing is blocking a write, but this key could not read the branch's database connection \u2014 some checks were skipped")}`
285026
285387
  );
285027
285388
  } else {
285028
285389
  console.log(
@@ -285129,7 +285490,7 @@ async function doctor() {
285129
285490
  checks.push({
285130
285491
  label: "data",
285131
285492
  state: "fail",
285132
- detail: "no database connected to this plane \u2014 writes are held",
285493
+ detail: "this branch is not connected to a database \u2014 writes are held",
285133
285494
  fix: "connect one with `ablo connect apply`"
285134
285495
  });
285135
285496
  } else {
@@ -285721,7 +286082,7 @@ async function reportReadSubject(dbUrl) {
285721
286082
  }
285722
286083
  if (state.kind === "none") {
285723
286084
  console.log(
285724
- ` ${import_picocolors23.default.dim("ablo")} ${import_picocolors23.default.yellow("!")} no database is registered for this plane, so Ablo does not read this one`
286085
+ ` ${import_picocolors23.default.dim("ablo")} ${import_picocolors23.default.yellow("!")} this branch is not connected to a database, so Ablo does not read this one`
285725
286086
  );
285726
286087
  console.log(
285727
286088
  ` ${import_picocolors23.default.dim(`Connect it with ${import_picocolors23.default.bold("ablo connect apply")}. Until then a table here is invisible to the engine.`)}
@@ -287138,6 +287499,8 @@ import { schema } from './schema';
287138
287499
  // via the session route and never touches the key.
287139
287500
  export const sync = Ablo({
287140
287501
  apiKey: process.env.ABLO_API_KEY,${authLine}
287502
+ projectId: process.env.ABLO_PROJECT_ID,
287503
+ branchId: process.env.ABLO_BRANCH_ID,
287141
287504
  schema,
287142
287505
  });
287143
287506
 
@@ -287163,7 +287526,7 @@ function generateEnv(storage, opts = {}) {
287163
287526
  const { includeApiKey = true } = opts;
287164
287527
  const databaseBlock = storage === "replication" ? "# Used by `npx ablo connect` to set up + register logical replication \u2014 the\n# DIRECT (un-pooled) endpoint. Ablo TAILS your WAL from here; it never writes.\n# The client never sees it; the browser never sees it. Your DB stays yours.\nDATABASE_URL=postgres://user:password@host:5432/db\n" : "# Used by ablo/data-source.ts (your DB endpoint) + `ablo migrate` \u2014 NOT the client.\n# Ablo never sees it; the browser never sees it. Your DB stays in your app.\nDATABASE_URL=postgres://user:password@host:5432/db\n";
287165
287528
  const webhookBlock = storage === "endpoint" ? "# Signing secret for the webhook receiver (app/api/ablo/webhooks/route.ts).\n# Ablo mints this when you register the endpoint's URL (POST /v1/webhook_endpoints\n# or the dashboard) and returns it once \u2014 paste it here.\nABLO_WEBHOOK_SECRET=whsec_your_endpoint_secret_here\n" : "";
287166
- const apiKeyBlock = includeApiKey ? "# Ablo: a branch-bound sk_ key (`npx ablo dev` wires a development branch for you)\nABLO_API_KEY=sk_your_key_here\n" : "";
287529
+ const apiKeyBlock = includeApiKey ? "# Ablo: a branch-bound sk_ key (`npx ablo dev` wires both values for you).\n# Project + branch are assertions: the SDK rejects a key for another app or environment.\nABLO_API_KEY=sk_your_key_here\nABLO_PROJECT_ID=proj_your_project_id\nABLO_BRANCH_ID=br_your_branch_id\n" : "";
287167
287530
  return `${apiKeyBlock}${webhookBlock}${databaseBlock}`;
287168
287531
  }
287169
287532
  function generateDataSource(orm) {
@@ -287324,8 +287687,7 @@ export async function POST(req: Request): Promise<Response> {
287324
287687
  `;
287325
287688
  }
287326
287689
  function generateAgent() {
287327
- return `import Ablo from '@abloatai/ablo';
287328
- import { schema } from './schema';
287690
+ return `import { sync as ablo } from './sync';
287329
287691
 
287330
287692
  /**
287331
287693
  * An AI "teammate" that works the same synced tasks a human does.
@@ -287335,8 +287697,6 @@ import { schema } from './schema';
287335
287697
  * \`npx ablo logs\`. That's the whole idea: agents and people on one typed,
287336
287698
  * synced dataset.
287337
287699
  */
287338
- const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
287339
-
287340
287700
  async function main() {
287341
287701
  await ablo.ready();
287342
287702
 
@@ -287354,7 +287714,6 @@ async function main() {
287354
287714
  data: { priority: 10 },
287355
287715
  readAt: snap.stamp,
287356
287716
  onStale: 'reject',
287357
- wait: 'confirmed',
287358
287717
  });
287359
287718
  console.log('prioritized:', urgent.title);
287360
287719
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/cli",
3
- "version": "0.46.0",
3
+ "version": "0.48.0",
4
4
  "description": "The ablo command line: set up Ablo, connect your database, and push your schema from the terminal.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -30,10 +30,10 @@
30
30
  "directory": "packages/cli"
31
31
  },
32
32
  "dependencies": {
33
- "@abloatai/transaction": "^0.46.0",
33
+ "@abloatai/transaction": "^0.48.0",
34
34
  "jiti": "^2.7.0",
35
35
  "zod": "^4.4.3",
36
- "@abloatai/humans": "^0.46.0"
36
+ "@abloatai/humans": "^0.48.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@clack/prompts": "^0.11.0",