@abloatai/cli 0.46.0 → 0.47.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 +302 -96
  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,10 @@ async function loadSchema(schemaPath, exportName) {
4098
4099
  function maskKey(key) {
4099
4100
  return key ? `${key.slice(0, 12)}\u2026` : "(none)";
4100
4101
  }
4102
+ function schemaPushPlaneHint(code) {
4103
+ if (code !== "test_database_not_registered") return null;
4104
+ return `The key authenticated and already passed ${import_picocolors5.default.bold("schema:push")} authorization. This is a branch storage-provisioning error, not a key-scope error. Check the plane with ${import_picocolors5.default.bold("npx ablo branch status <branch>")}; a branch reported as hosted should not need a customer database.`;
4105
+ }
4101
4106
  function schemaGitState(schemaPath) {
4102
4107
  try {
4103
4108
  const out = (0, import_child_process.execFileSync)("git", ["status", "--porcelain", "--", schemaPath], {
@@ -4406,7 +4411,10 @@ async function push(argv) {
4406
4411
  const serverMsg = body.message ?? body.reason;
4407
4412
  console.error(import_picocolors5.default.red(` Forbidden${code ? ` [${code}]` : ""}: ${serverMsg ?? "permission denied"}`));
4408
4413
  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") {
4414
+ const planeHint = schemaPushPlaneHint(code);
4415
+ if (planeHint) {
4416
+ console.error(import_picocolors5.default.dim(` ${planeHint}`));
4417
+ } else if (code === "database_role_cannot_enforce_rls") {
4410
4418
  console.error(
4411
4419
  import_picocolors5.default.dim(
4412
4420
  ` 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 +4574,13 @@ var init_remoteValidation = __esm({
4566
4574
  wal_level: () => `your database isn't set up to share changes as they happen yet`,
4567
4575
  publication: () => `none of your tables are shared with Ablo yet`,
4568
4576
  replication_role: () => `the login Ablo reads with can't follow your changes yet`,
4577
+ replication_slot_capacity: (f) => withActual(`this database has no remaining change-stream capacity for this binding`, f.actual),
4569
4578
  replica_identity: (f) => withActual(
4570
4579
  `some shared tables don't record enough for Ablo to track edits and deletes`,
4571
4580
  f.actual
4572
4581
  ),
4573
4582
  table_select: (f) => withActual(`the login Ablo reads with can't read some shared tables`, f.actual),
4583
+ snapshot_row_security: (f) => withActual(`row-level security hides historical rows from Ablo's initial load`, f.actual),
4574
4584
  write_role: () => `the login Ablo writes with isn't set up yet`,
4575
4585
  row_security: () => `the writer login isn't set to honor your row-level security`,
4576
4586
  database_privileges: () => `the writer login can still create things in your database`,
@@ -4670,7 +4680,7 @@ function quoteIdent(id) {
4670
4680
  function quoteLiteral(value) {
4671
4681
  return `'${value.replace(/'/g, "''")}'`;
4672
4682
  }
4673
- function scopedSequenceGrant(tables, writeRole) {
4683
+ function scopedSequenceGrant(tables, writeRole, schema) {
4674
4684
  const names = tables.map(quoteLiteral).join(", ");
4675
4685
  return `DO $$
4676
4686
  DECLARE seq regclass;
@@ -4681,7 +4691,7 @@ BEGIN
4681
4691
  JOIN pg_class s ON s.oid = d.objid AND s.relkind = 'S'
4682
4692
  JOIN pg_class t ON t.oid = d.refobjid
4683
4693
  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})
4694
+ WHERE d.deptype IN ('a', 'i') AND n.nspname = ${quoteLiteral(schema)} AND t.relname IN (${names})
4685
4695
  LOOP
4686
4696
  EXECUTE format('GRANT USAGE, SELECT ON SEQUENCE %s TO ${quoteIdent(writeRole)}', seq);
4687
4697
  END LOOP;
@@ -4691,24 +4701,29 @@ function connectSetupSql(input) {
4691
4701
  const role = input.role && input.role.length > 0 ? input.role : import_footprint.ABLO_REPLICATION_ROLE;
4692
4702
  const writeRole = input.writeRole && input.writeRole.length > 0 ? input.writeRole : import_footprint.ABLO_WRITE_ROLE;
4693
4703
  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(", ");
4704
+ const schema = input.schema ?? "public";
4705
+ const publication = input.publication ?? import_footprint.ABLO_PUBLICATION;
4706
+ const qualifiedTables = tables.map((table) => `${quoteIdent(schema)}.${quoteIdent(table)}`);
4707
+ const publicationTarget = tables.length > 0 ? `FOR TABLE ${qualifiedTables.join(", ")}` : "FOR ALL TABLES";
4708
+ const tableList = qualifiedTables.join(", ");
4696
4709
  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)};`;
4710
+ 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
4711
  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)};`
4712
+ `GRANT SELECT ON ALL TABLES IN SCHEMA ${quoteIdent(schema)} TO ${quoteIdent(role)};`,
4713
+ `ALTER DEFAULT PRIVILEGES IN SCHEMA ${quoteIdent(schema)} GRANT SELECT ON TABLES TO ${quoteIdent(role)};`
4701
4714
  ];
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);
4715
+ const writerSequenceGrants = scoped ? [scopedSequenceGrant(tables, writeRole, schema)] : [`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA ${quoteIdent(schema)} TO ${quoteIdent(writeRole)};`];
4716
+ const ledger = (0, import_source2.idempotencyLedgerMigrations)(schema).map((migration) => migration.up);
4704
4717
  return [
4705
4718
  // 1. Turn on logical decoding. Requires a restart (it's not reloadable).
4706
4719
  `ALTER SYSTEM SET wal_level = 'logical';`,
4707
4720
  // 2. Publish the tables Ablo should read.
4708
- `CREATE PUBLICATION ${quoteIdent(import_footprint.ABLO_PUBLICATION)} ${publicationTarget};`,
4721
+ `CREATE PUBLICATION ${quoteIdent(publication)} ${publicationTarget};`,
4709
4722
  // 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>';`,
4723
+ // published tables, including the initial snapshot of RLS-protected tables.
4724
+ // Logical decoding already exposes every published row independently of
4725
+ // RLS; BYPASSRLS makes the ordinary SELECT snapshot match that same scope.
4726
+ `CREATE ROLE ${quoteIdent(role)} WITH NOSUPERUSER BYPASSRLS NOCREATEDB NOCREATEROLE REPLICATION NOINHERIT LOGIN PASSWORD '<password>';`,
4712
4727
  ...replicationReadGrants,
4713
4728
  // 4. A distinct DML role: no replication, role administration, ownership,
4714
4729
  // schema creation, or DDL. It runs NOBYPASSRLS with row_security on, so on a
@@ -4729,20 +4744,20 @@ function connectSetupSql(input) {
4729
4744
  DO $$ BEGIN
4730
4745
  EXECUTE format('REVOKE TEMPORARY, CREATE ON DATABASE %I FROM PUBLIC', current_database());
4731
4746
  END $$;`,
4732
- `GRANT USAGE ON SCHEMA public TO ${quoteIdent(writeRole)};`,
4747
+ `GRANT USAGE ON SCHEMA ${quoteIdent(schema)} TO ${quoteIdent(writeRole)};`,
4733
4748
  applicationGrant,
4734
4749
  ...writerSequenceGrants,
4735
4750
  ...scoped ? [] : [
4736
4751
  // "All tables" mode only: keep future tables/sequences writable so the
4737
4752
  // 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)};`
4753
+ `ALTER DEFAULT PRIVILEGES IN SCHEMA ${quoteIdent(schema)} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${quoteIdent(writeRole)};`,
4754
+ `ALTER DEFAULT PRIVILEGES IN SCHEMA ${quoteIdent(schema)} GRANT USAGE, SELECT ON SEQUENCES TO ${quoteIdent(writeRole)};`
4740
4755
  ],
4741
4756
  // 5. Direct uses the durable replay ledger but deliberately no outbox.
4742
4757
  ...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)};`,
4758
+ `REVOKE ALL ON TABLE ${quoteIdent(schema)}.${quoteIdent("ablo_idempotency")} FROM PUBLIC;`,
4759
+ `GRANT SELECT, INSERT, UPDATE ON TABLE ${quoteIdent(schema)}.${quoteIdent("ablo_idempotency")} TO ${quoteIdent(writeRole)};`,
4760
+ `REVOKE DELETE ON TABLE ${quoteIdent(schema)}.${quoteIdent("ablo_idempotency")} FROM ${quoteIdent(writeRole)};`,
4746
4761
  // The writer emits a transactional marker on your WAL so Ablo can correlate
4747
4762
  // the committed row back to the originating write and confirm it — this
4748
4763
  // EXECUTE grant is what makes that confirmation possible. Granted by lookup
@@ -4765,10 +4780,12 @@ BEGIN
4765
4780
  END $$;`
4766
4781
  ];
4767
4782
  }
4768
- function reconcilePublicationPlan(current, desiredTables) {
4769
- const pub = quoteIdent(import_footprint.ABLO_PUBLICATION);
4783
+ function reconcilePublicationPlan(current, desiredTables, opts = {}) {
4784
+ const pub = quoteIdent(opts.publication ?? import_footprint.ABLO_PUBLICATION);
4785
+ const schema = opts.schema ?? "public";
4786
+ const qualified = (table) => `${quoteIdent(schema)}.${quoteIdent(table)}`;
4770
4787
  const desiredAll = desiredTables.length === 0;
4771
- const target = desiredAll ? "FOR ALL TABLES" : `FOR TABLE ${desiredTables.map(quoteIdent).join(", ")}`;
4788
+ const target = desiredAll ? "FOR ALL TABLES" : `FOR TABLE ${desiredTables.map(qualified).join(", ")}`;
4772
4789
  if (!current.exists) {
4773
4790
  return {
4774
4791
  sql: [`CREATE PUBLICATION ${pub} ${target};`],
@@ -4794,28 +4811,31 @@ function reconcilePublicationPlan(current, desiredTables) {
4794
4811
  return { sql: [], added: [], removed: [], recreated: false };
4795
4812
  }
4796
4813
  return {
4797
- sql: [`ALTER PUBLICATION ${pub} SET TABLE ${desiredTables.map(quoteIdent).join(", ")};`],
4814
+ sql: [`ALTER PUBLICATION ${pub} SET TABLE ${desiredTables.map(qualified).join(", ")};`],
4798
4815
  added,
4799
4816
  removed,
4800
4817
  recreated: false
4801
4818
  };
4802
4819
  }
4803
- async function readPublicationState(sql) {
4820
+ async function readPublicationState(sql, opts = {}) {
4821
+ const publication = opts.publication ?? import_footprint.ABLO_PUBLICATION;
4822
+ const schema = opts.schema ?? "public";
4804
4823
  const pubRows = await sql.unsafe(
4805
4824
  `SELECT puballtables FROM pg_publication WHERE pubname = $1`,
4806
- [import_footprint.ABLO_PUBLICATION]
4825
+ [publication]
4807
4826
  );
4808
4827
  const pubRow = pubRows[0];
4809
4828
  if (!pubRow) return { exists: false, allTables: false, tables: [] };
4810
4829
  if (pubRow.puballtables) return { exists: true, allTables: true, tables: [] };
4811
4830
  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]
4831
+ `SELECT tablename FROM pg_publication_tables WHERE pubname = $1 AND schemaname = $2 ORDER BY tablename`,
4832
+ [publication, schema]
4814
4833
  );
4815
4834
  return { exists: true, allTables: false, tables: tableRows.map((r2) => r2.tablename) };
4816
4835
  }
4817
4836
  async function probeReadiness(sql, opts = {}) {
4818
4837
  const publication = opts.publication ?? import_footprint.ABLO_PUBLICATION;
4838
+ const schema = opts.schema ?? "public";
4819
4839
  const coordinated = opts.coordinatedTables && opts.coordinatedTables.length > 0 ? new Set(opts.coordinatedTables) : null;
4820
4840
  const items = [];
4821
4841
  const walRows = await sql.unsafe(
@@ -4847,7 +4867,7 @@ On Neon enable Logical Replication in the project (Console \u2192 Settings \u219
4847
4867
  }
4848
4868
  );
4849
4869
  const roleRows = await sql.unsafe(
4850
- `SELECT rolreplication, rolsuper FROM pg_roles WHERE rolname = current_user`
4870
+ `SELECT rolreplication, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user`
4851
4871
  );
4852
4872
  const role = roleRows[0];
4853
4873
  const hasReplication = Boolean(role && (role.rolreplication || role.rolsuper));
@@ -4863,12 +4883,31 @@ On RDS: GRANT rds_replication TO <your_role>;`
4863
4883
  }
4864
4884
  );
4865
4885
  if (pubRows.length > 0) {
4886
+ const rlsRows = await sql.unsafe(
4887
+ `SELECT DISTINCT pt.tablename AS table_name
4888
+ FROM pg_publication_tables pt
4889
+ JOIN pg_namespace n ON n.nspname = pt.schemaname
4890
+ JOIN pg_class c ON c.relnamespace = n.oid AND c.relname = pt.tablename
4891
+ WHERE pt.pubname = $1 AND pt.schemaname = $2
4892
+ AND row_security_active(c.oid)
4893
+ ORDER BY table_name`,
4894
+ [publication, schema]
4895
+ );
4896
+ const rlsRelevant = coordinated ? rlsRows.filter((row) => coordinated.has(row.table_name)) : rlsRows;
4897
+ items.push(
4898
+ rlsRelevant.length === 0 ? { ok: true, label: "the initial snapshot can read every published row" } : {
4899
+ ok: false,
4900
+ label: `${rlsRelevant.length} published table${rlsRelevant.length === 1 ? "" : "s"} hide historical rows behind RLS`,
4901
+ fix: `ALTER ROLE current_user WITH BYPASSRLS;
4902
+ Logical replication already exposes every published row; this lets the ordinary initial SELECT read that same scope.`
4903
+ }
4904
+ );
4866
4905
  const badRows = await sql.unsafe(
4867
4906
  `SELECT c.relname AS table_name, c.relreplident
4868
4907
  FROM pg_publication_tables pt
4869
4908
  JOIN pg_class c ON c.relname = pt.tablename
4870
4909
  JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = pt.schemaname
4871
- WHERE pt.pubname = $1
4910
+ WHERE pt.pubname = $1 AND pt.schemaname = $2
4872
4911
  AND (
4873
4912
  c.relreplident = 'n'
4874
4913
  OR (
@@ -4879,7 +4918,7 @@ On RDS: GRANT rds_replication TO <your_role>;`
4879
4918
  )
4880
4919
  )
4881
4920
  )`,
4882
- [publication]
4921
+ [publication, schema]
4883
4922
  );
4884
4923
  const relevant = coordinated ? badRows.filter((row) => coordinated.has(row.table_name)) : badRows;
4885
4924
  items.push(
@@ -4887,7 +4926,7 @@ On RDS: GRANT rds_replication TO <your_role>;`
4887
4926
  ok: false,
4888
4927
  label: `${relevant.length} published table${relevant.length === 1 ? "" : "s"} cannot replicate UPDATE/DELETE`,
4889
4928
  fix: relevant.map(
4890
- (r2) => `${r2.table_name}: add a PRIMARY KEY, or ALTER TABLE ${quoteIdent(r2.table_name)} REPLICA IDENTITY FULL;`
4929
+ (r2) => `${r2.table_name}: add a PRIMARY KEY, or ALTER TABLE ${quoteIdent(schema)}.${quoteIdent(r2.table_name)} REPLICA IDENTITY FULL;`
4891
4930
  ).join("\n")
4892
4931
  }
4893
4932
  );
@@ -4904,7 +4943,10 @@ async function registerDirectDataSource(opts) {
4904
4943
  connection: "direct",
4905
4944
  connectionString: opts.replicationUrl,
4906
4945
  writeConnectionString: opts.writeUrl,
4907
- route: opts.route
4946
+ route: opts.route,
4947
+ ...opts.schema ? { schema: opts.schema } : {},
4948
+ ...opts.replicationSlot ? { replicationSlot: opts.replicationSlot } : {},
4949
+ ...opts.publication ? { publication: opts.publication } : {}
4908
4950
  },
4909
4951
  responseSchema: import_wire3.datasourceSummarySchema
4910
4952
  });
@@ -5218,26 +5260,27 @@ function ownershipRemediation(blockers2, admin) {
5218
5260
  const unresolved = blockers2.filter((blocker) => !resolvedOwners.has(blocker.owner));
5219
5261
  return { inheritGrants, unresolved };
5220
5262
  }
5221
- async function publishedTableBlockers(sql, tables) {
5263
+ async function publishedTableBlockers(sql, tables, schema = "public") {
5222
5264
  const scoped = tables.length > 0;
5223
5265
  const raw = await sql.unsafe(
5224
5266
  `SELECT format('%I.%I', n.nspname, c.relname) AS relation, ${OWNERSHIP_COLUMNS}
5225
5267
  ${OWNERSHIP_FROM}
5226
5268
  WHERE c.relkind = 'r'
5227
- AND n.nspname = 'public'
5269
+ AND n.nspname = $${scoped ? "2" : "1"}
5228
5270
  AND c.relname <> 'ablo_idempotency'
5229
5271
  ${scoped ? "AND c.relname = ANY($1)" : ""}`,
5230
- scoped ? [tables] : []
5272
+ scoped ? [tables, schema] : [schema]
5231
5273
  );
5232
5274
  return ownershipBlockers(import_zod3.z.array(ownedRelationRowSchema).parse(raw));
5233
5275
  }
5234
- async function ledgerBlocker(sql) {
5276
+ async function ledgerBlocker(sql, schema = "public") {
5235
5277
  const raw = await sql.unsafe(
5236
5278
  `SELECT format('%I.%I', n.nspname, c.relname) AS relation, ${OWNERSHIP_COLUMNS}
5237
5279
  ${OWNERSHIP_FROM}
5238
5280
  WHERE c.relkind = 'r'
5239
- AND n.nspname = 'public'
5240
- AND c.relname = 'ablo_idempotency'`
5281
+ AND n.nspname = $1
5282
+ AND c.relname = 'ablo_idempotency'`,
5283
+ [schema]
5241
5284
  );
5242
5285
  const rows = import_zod3.z.array(ownedRelationRowSchema).parse(raw);
5243
5286
  const row = rows[0];
@@ -5473,13 +5516,15 @@ function connectApplyPlan(input) {
5473
5516
  const role = input.role && input.role.length > 0 ? input.role : import_footprint.ABLO_REPLICATION_ROLE;
5474
5517
  const writeRole = input.writeRole && input.writeRole.length > 0 ? input.writeRole : import_footprint.ABLO_WRITE_ROLE;
5475
5518
  const tables = input.tables ?? [];
5519
+ const schema = input.schema ?? "public";
5520
+ const publication = input.publication ?? import_footprint.ABLO_PUBLICATION;
5476
5521
  const provider = input.provider ?? "generic";
5477
- const recipe = connectSetupSql({ tables, role, writeRole });
5522
+ const recipe = connectSetupSql({ tables, role, writeRole, schema, publication });
5478
5523
  const isWal = (s) => s.startsWith("ALTER SYSTEM SET wal_level");
5479
5524
  const isPublication = (s) => s.startsWith("CREATE PUBLICATION");
5480
5525
  const isRoleCreate = (s) => s.startsWith("CREATE ROLE ");
5481
5526
  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";
5527
+ const publicationTarget = tables.length > 0 ? `FOR TABLE ${tables.map((table) => `${quoteIdent(schema)}.${quoteIdent(table)}`).join(", ")}` : "FOR ALL TABLES";
5483
5528
  const affectsOthers = effectsOnOthers({ grants, allTables: tables.length === 0 });
5484
5529
  const walStep = input.walAlreadyLogical ? [] : [
5485
5530
  provider === "generic" ? {
@@ -5494,10 +5539,10 @@ function connectApplyPlan(input) {
5494
5539
  sql: []
5495
5540
  }
5496
5541
  ];
5497
- const reconcile2 = input.existingPublication ? reconcilePublicationPlan(input.existingPublication, tables) : null;
5542
+ const reconcile2 = input.existingPublication ? reconcilePublicationPlan(input.existingPublication, tables, { schema, publication }) : null;
5498
5543
  const publicationSql = reconcile2 ? reconcile2.sql : [
5499
5544
  `DO $$ BEGIN
5500
- CREATE PUBLICATION ${quoteIdent(import_footprint.ABLO_PUBLICATION)} ${publicationTarget};
5545
+ CREATE PUBLICATION ${quoteIdent(publication)} ${publicationTarget};
5501
5546
  EXCEPTION WHEN duplicate_object THEN NULL;
5502
5547
  END $$;`
5503
5548
  ];
@@ -5522,14 +5567,19 @@ END $$;`
5522
5567
  {
5523
5568
  key: "replication-role",
5524
5569
  title: "Create the read-only login Ablo reads with",
5525
- detail: `${role} \u2014 it can follow your changes and read, nothing else`,
5570
+ detail: `${role} \u2014 it can follow your changes and snapshot the same published rows, including through RLS`,
5526
5571
  sql: [
5527
5572
  idempotentRole(
5528
5573
  role,
5529
- "REPLICATION",
5574
+ "NOSUPERUSER BYPASSRLS NOCREATEDB NOCREATEROLE REPLICATION NOINHERIT",
5530
5575
  input.credentials.replicationClause,
5531
5576
  input.rotate === true
5532
- )
5577
+ ),
5578
+ // Unlike a password, this is a required invariant rather than secret
5579
+ // material owned by one plane. Re-assert it for an existing pre-snapshot
5580
+ // role so `connect apply` repairs the exact upgrade gap that otherwise
5581
+ // certifies an RLS-filtered empty snapshot as complete.
5582
+ `ALTER ROLE ${quoteIdent(role)} WITH BYPASSRLS;`
5533
5583
  ]
5534
5584
  },
5535
5585
  {
@@ -5691,16 +5741,23 @@ async function locateExistingConnection(input) {
5691
5741
  method: "POST",
5692
5742
  baseUrl: input.apiUrl,
5693
5743
  apiKey: input.apiKey,
5694
- body: { connectionString: input.connectionString },
5744
+ body: {
5745
+ connectionString: input.connectionString,
5746
+ ...input.schema ? { schema: input.schema } : {}
5747
+ },
5695
5748
  responseSchema: import_wire6.datasourceLocationResponseSchema
5696
5749
  });
5697
5750
  if (!result.ok) return null;
5698
- return result.value.held;
5751
+ if (result.value.held) return result.value.held;
5752
+ return result.value.available === false ? { private: true } : null;
5699
5753
  }
5700
5754
  function alreadyConnectedElsewhere(held) {
5701
5755
  if (!held) return null;
5756
+ if ("private" in held) {
5757
+ return "This database schema is already connected to another Ablo organization. Ablo does not reveal that organization\u2019s project or branch.";
5758
+ }
5702
5759
  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.`;
5760
+ return `This database schema is already connected to ${where}. A (database, schema) binding belongs to one plane at a time.`;
5704
5761
  }
5705
5762
  var import_wire6, import_schema5;
5706
5763
  var init_connectPreflight = __esm({
@@ -5849,12 +5906,18 @@ ${ambient}` : ""}`,
5849
5906
  )
5850
5907
  );
5851
5908
  }
5852
- const tables = args.tables;
5853
5909
  const coordinatedTables = await schemaDeclaredTables() ?? [];
5910
+ const tables = args.tables.length > 0 ? args.tables : coordinatedTables;
5911
+ if (tables.length === 0) {
5912
+ throw new import_errors9.AbloValidationError(
5913
+ `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.`,
5914
+ { code: "cli_invalid_arguments" }
5915
+ );
5916
+ }
5854
5917
  if (args.tables.length === 0) {
5855
5918
  console.log(
5856
5919
  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)
5920
+ ` 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
5921
  `
5859
5922
  )
5860
5923
  );
@@ -5869,8 +5932,28 @@ ${ambient}` : ""}`,
5869
5932
  console.log(` ${import_picocolors11.default.yellow("!")} ${mismatch}
5870
5933
  `);
5871
5934
  }
5935
+ const confirmed = connectTarget?.confirmed;
5936
+ if (!confirmed?.branchId) {
5937
+ throw new import_errors9.AbloConnectionError(
5938
+ "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.",
5939
+ { code: "cli_database_unreachable" }
5940
+ );
5941
+ }
5942
+ const footprint = (0, import_footprint2.footprintNamesFor)({
5943
+ organizationId: confirmed.organizationId,
5944
+ branchId: confirmed.branchId,
5945
+ ...confirmed.projectId ? { projectId: confirmed.projectId } : {}
5946
+ });
5947
+ const role = args.role === import_footprint.ABLO_REPLICATION_ROLE ? footprint.replicationRole : args.role;
5948
+ const writeRole = args.writeRole === import_footprint.ABLO_WRITE_ROLE ? footprint.writeRole : args.writeRole;
5949
+ const publication = footprint.publication;
5872
5950
  const heldElsewhere = alreadyConnectedElsewhere(
5873
- await locateExistingConnection({ apiUrl: apiBaseUrl(), apiKey, connectionString: adminUrl })
5951
+ await locateExistingConnection({
5952
+ apiUrl: apiBaseUrl(),
5953
+ apiKey,
5954
+ connectionString: adminUrl,
5955
+ schema: args.schema
5956
+ })
5874
5957
  );
5875
5958
  if (heldElsewhere) {
5876
5959
  console.error(` ${import_picocolors11.default.yellow("!")} ${heldElsewhere}
@@ -5930,8 +6013,8 @@ ${ambient}` : ""}`,
5930
6013
  );
5931
6014
  process.exit(1);
5932
6015
  }
5933
- const ledger = await ledgerBlocker(admin).catch(() => null);
5934
- const foreignTables = await publishedTableBlockers(admin, tables).catch(() => []);
6016
+ const ledger = await ledgerBlocker(admin, args.schema).catch(() => null);
6017
+ const foreignTables = await publishedTableBlockers(admin, tables, args.schema).catch(() => []);
5935
6018
  const { inheritGrants, unresolved } = ownershipRemediation(
5936
6019
  [...ledger ? [ledger] : [], ...foreignTables],
5937
6020
  capability.rolname
@@ -5955,12 +6038,16 @@ ${ambient}` : ""}`,
5955
6038
  );
5956
6039
  process.exit(1);
5957
6040
  }
5958
- const existingPublication = await readPublicationState(admin).catch(
6041
+ const existingPublication = await readPublicationState(admin, {
6042
+ schema: args.schema,
6043
+ publication
6044
+ }).catch(
5959
6045
  () => ({ exists: false, allTables: false, tables: [] })
5960
6046
  );
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;
6047
+ const pubReconcile = reconcilePublicationPlan(existingPublication, tables, {
6048
+ schema: args.schema,
6049
+ publication
6050
+ });
5964
6051
  const existingRoles = await presentRoles(admin, [role, writeRole]).catch(() => []);
5965
6052
  if (rotatePlane) {
5966
6053
  const refusal = rotateWithoutConnection({ rotating, ...rotatePlane, existingRoles });
@@ -6001,8 +6088,10 @@ ${ambient}` : ""}`,
6001
6088
  const writePassword = generateRolePassword();
6002
6089
  const buildPlan = (mode) => connectApplyPlan({
6003
6090
  tables,
6004
- role: args.role,
6005
- writeRole: args.writeRole,
6091
+ role,
6092
+ writeRole,
6093
+ schema: args.schema,
6094
+ publication,
6006
6095
  rotate: rotating,
6007
6096
  credentials: {
6008
6097
  replicationClause: passwordClause(replicationPassword, mode),
@@ -6016,7 +6105,7 @@ ${ambient}` : ""}`,
6016
6105
  const steps = buildPlan("scram-verifier");
6017
6106
  if (pubReconcile.removed.length > 0 || pubReconcile.recreated) {
6018
6107
  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")}:`
6108
+ ` ${import_picocolors11.default.yellow("!")} ${import_picocolors11.default.bold(publication)} already publishes a different set; reconciling to your mapped tables:`
6020
6109
  );
6021
6110
  for (const t of pubReconcile.added) console.log(` ${import_picocolors11.default.green("+")} ${t}`);
6022
6111
  for (const t of pubReconcile.removed)
@@ -6093,9 +6182,12 @@ ${ambient}` : ""}`,
6093
6182
  }
6094
6183
  const replicationProbe = await probeAsRole(
6095
6184
  replicationUrl,
6096
- (sql) => probeReadiness(sql, { coordinatedTables })
6185
+ (sql) => probeReadiness(sql, { coordinatedTables, schema: args.schema, publication })
6186
+ );
6187
+ const writeProbe = await probeAsRole(
6188
+ writeUrl,
6189
+ (sql) => probeDirectWriteReadiness(sql, { schema: args.schema, publication })
6097
6190
  );
6098
- const writeProbe = await probeAsRole(writeUrl, probeDirectWriteReadiness);
6099
6191
  const refused = [
6100
6192
  ...replicationProbe.credentialRefused ? [role] : [],
6101
6193
  ...writeProbe.credentialRefused ? [writeRole] : []
@@ -6150,7 +6242,10 @@ ${ambient}` : ""}`,
6150
6242
  apiKey,
6151
6243
  replicationUrl,
6152
6244
  writeUrl,
6153
- route: args.route
6245
+ route: args.route,
6246
+ schema: args.schema,
6247
+ replicationSlot: footprint.slot,
6248
+ publication
6154
6249
  });
6155
6250
  process.off("SIGINT", onRotateInterrupt);
6156
6251
  process.off("SIGTERM", onRotateInterrupt);
@@ -6162,7 +6257,7 @@ ${ambient}` : ""}`,
6162
6257
  }
6163
6258
  process.exit(outcome.exitCode);
6164
6259
  }
6165
- var import_picocolors11, import_errors9, ROTATE_STRANDED_CREDENTIALS_NOTICE;
6260
+ var import_picocolors11, import_errors9, import_footprint2, ROTATE_STRANDED_CREDENTIALS_NOTICE;
6166
6261
  var init_connectApply = __esm({
6167
6262
  "src/connectApply.ts"() {
6168
6263
  "use strict";
@@ -6171,6 +6266,7 @@ var init_connectApply = __esm({
6171
6266
  init_src();
6172
6267
  init_dist2();
6173
6268
  import_errors9 = require("@abloatai/transaction/errors");
6269
+ import_footprint2 = require("@abloatai/transaction/footprint");
6174
6270
  init_connectSetup();
6175
6271
  init_connectOwnership();
6176
6272
  init_connect();
@@ -6194,6 +6290,7 @@ function parseConnectArgs(argv) {
6194
6290
  let register = false;
6195
6291
  let apply = false;
6196
6292
  let rotate = false;
6293
+ let resnapshot = false;
6197
6294
  let url;
6198
6295
  let envFile;
6199
6296
  let yes = false;
@@ -6202,6 +6299,7 @@ function parseConnectArgs(argv) {
6202
6299
  let locate = false;
6203
6300
  let manual = false;
6204
6301
  let tables = [];
6302
+ let schema = "public";
6205
6303
  let role = import_footprint.ABLO_REPLICATION_ROLE;
6206
6304
  let writeRole = import_footprint.ABLO_WRITE_ROLE;
6207
6305
  let route = "public-allowlist";
@@ -6221,6 +6319,9 @@ function parseConnectArgs(argv) {
6221
6319
  case "rotate":
6222
6320
  rotate = true;
6223
6321
  break;
6322
+ case "resnapshot":
6323
+ resnapshot = true;
6324
+ break;
6224
6325
  case "scan":
6225
6326
  scan = true;
6226
6327
  break;
@@ -6229,7 +6330,7 @@ function parseConnectArgs(argv) {
6229
6330
  break;
6230
6331
  default:
6231
6332
  throw new import_errors10.AbloValidationError(
6232
- `unknown connect subcommand: ${lead} (expected register, deregister, check, apply, rotate, scan, locate)`,
6333
+ `unknown connect subcommand: ${lead} (expected register, deregister, check, apply, rotate, resnapshot, scan, locate)`,
6233
6334
  { code: "cli_invalid_arguments" }
6234
6335
  );
6235
6336
  }
@@ -6259,6 +6360,9 @@ function parseConnectArgs(argv) {
6259
6360
  tables = value.split(",").map((t) => t.trim()).filter((t) => t.length > 0);
6260
6361
  break;
6261
6362
  }
6363
+ case "--schema":
6364
+ schema = argv[++i] ?? schema;
6365
+ break;
6262
6366
  case "--role":
6263
6367
  role = argv[++i] ?? role;
6264
6368
  break;
@@ -6290,6 +6394,7 @@ function parseConnectArgs(argv) {
6290
6394
  register,
6291
6395
  apply,
6292
6396
  rotate,
6397
+ resnapshot,
6293
6398
  url,
6294
6399
  envFile,
6295
6400
  yes,
@@ -6297,6 +6402,7 @@ function parseConnectArgs(argv) {
6297
6402
  scan,
6298
6403
  locate,
6299
6404
  tables,
6405
+ schema,
6300
6406
  role,
6301
6407
  writeRole,
6302
6408
  route,
@@ -6307,7 +6413,8 @@ function printConnectRecipe(args) {
6307
6413
  const sql = connectSetupSql({
6308
6414
  tables: args.tables,
6309
6415
  role: args.role,
6310
- writeRole: args.writeRole
6416
+ writeRole: args.writeRole,
6417
+ schema: args.schema
6311
6418
  });
6312
6419
  console.log(
6313
6420
  `
@@ -6526,16 +6633,17 @@ async function probeDirectWriteReadiness(sql, opts = {}) {
6526
6633
  );
6527
6634
  return items;
6528
6635
  }
6529
- async function auditTenantSyncInfra(sql) {
6636
+ async function auditTenantSyncInfra(sql, opts = {}) {
6530
6637
  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;
6638
+ for (const artifact of import_footprint3.ABLO_FOOTPRINT) {
6639
+ 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;
6640
+ const key = artifact.kind === "table" || artifact.kind === "type" ? `${artifact.retired ? "public" : opts.schema ?? "public"}.${name}` : name;
6533
6641
  const rows = await sql.unsafe(FOOTPRINT_LOOKUP[artifact.kind], [
6534
6642
  key
6535
6643
  ]);
6536
6644
  artifacts.push({
6537
6645
  kind: artifact.kind,
6538
- name: artifact.name,
6646
+ name,
6539
6647
  present: rows[0]?.present === true,
6540
6648
  purpose: artifact.purpose,
6541
6649
  ...artifact.hazard ? { hazard: artifact.hazard } : {},
@@ -6564,12 +6672,12 @@ function requireScopedUrl(kind, verb) {
6564
6672
  );
6565
6673
  process.exit(1);
6566
6674
  }
6567
- async function probeAndReport(dbUrl, kind = "replication") {
6675
+ async function probeAndReport(dbUrl, kind = "replication", opts = {}) {
6568
6676
  const sql = src_default(dbUrl, { max: 1, prepare: false, connect_timeout: 10, onnotice: () => {
6569
6677
  } });
6570
6678
  let items;
6571
6679
  try {
6572
- items = kind === "replication" ? await probeReadiness(sql) : await probeDirectWriteReadiness(sql);
6680
+ items = kind === "replication" ? await probeReadiness(sql, opts) : await probeDirectWriteReadiness(sql, opts);
6573
6681
  } catch (err) {
6574
6682
  await sql.end({ timeout: 2 }).catch(() => void 0);
6575
6683
  const dial = dialFailureReason(err);
@@ -6699,11 +6807,11 @@ ${ambient}` : ""}`,
6699
6807
  );
6700
6808
  console.log(` ${import_picocolors12.default.bold("Replication role")}
6701
6809
  `);
6702
- const replication = await probeAndReport(dbUrl, "replication");
6810
+ const replication = await probeAndReport(dbUrl, "replication", { schema: args.schema });
6703
6811
  console.log(`
6704
6812
  ${import_picocolors12.default.bold("Direct-write role")}
6705
6813
  `);
6706
- const write = await probeAndReport(writeDbUrl, "write");
6814
+ const write = await probeAndReport(writeDbUrl, "write", { schema: args.schema });
6707
6815
  const noDial = [
6708
6816
  replication.kind === "no-dial" ? `replication: ${replication.reason}` : null,
6709
6817
  write.kind === "no-dial" ? `write: ${write.reason}` : null
@@ -6731,17 +6839,35 @@ ${ambient}` : ""}`,
6731
6839
  apiKey,
6732
6840
  replicationUrl: dbUrl,
6733
6841
  writeUrl: writeDbUrl,
6734
- route: args.route
6842
+ route: args.route,
6843
+ schema: args.schema
6735
6844
  });
6736
6845
  process.exit(registered ? 0 : 1);
6737
6846
  }
6738
- async function runScan() {
6847
+ async function runScan(args) {
6739
6848
  const dbUrl = requireScopedUrl("replication", "scan");
6740
6849
  const sql = src_default(dbUrl, { max: 1, prepare: false, onnotice: () => {
6741
6850
  } });
6742
6851
  let artifacts;
6743
6852
  try {
6744
- artifacts = await auditTenantSyncInfra(sql);
6853
+ const apiKey = resolveRuntimeApiKey().key;
6854
+ const target = apiKey ? await resolveTarget({ url: apiBaseUrl(), apiKey, keySource: "env" }).catch(() => null) : null;
6855
+ const confirmed = target?.confirmed;
6856
+ const names = confirmed?.branchId ? (0, import_footprint3.footprintNamesFor)({
6857
+ organizationId: confirmed.organizationId,
6858
+ branchId: confirmed.branchId,
6859
+ ...confirmed.projectId ? { projectId: confirmed.projectId } : {}
6860
+ }) : void 0;
6861
+ const isolated = await auditTenantSyncInfra(sql, {
6862
+ schema: args.schema,
6863
+ ...names ? { names } : {}
6864
+ });
6865
+ const legacy = names ? await auditTenantSyncInfra(sql, { schema: args.schema }) : [];
6866
+ artifacts = [...isolated, ...legacy].filter(
6867
+ (artifact, index, all) => all.findIndex(
6868
+ (candidate) => candidate.kind === artifact.kind && candidate.name === artifact.name
6869
+ ) === index
6870
+ );
6745
6871
  } catch (err) {
6746
6872
  const pg = err ?? {};
6747
6873
  await sql.end({ timeout: 2 });
@@ -6822,12 +6948,20 @@ ${ambient}` : ""}`,
6822
6948
  path: "/v1/datasources/locate",
6823
6949
  method: "POST",
6824
6950
  apiKey,
6825
- body: { connectionString: url },
6951
+ body: { connectionString: url, schema: args.schema },
6826
6952
  responseSchema: import_wire7.datasourceLocationResponseSchema
6827
6953
  });
6954
+ if (answer.available === false && !answer.held) {
6955
+ console.log(
6956
+ ` ${import_picocolors12.default.yellow("!")} ${import_picocolors12.default.bold(label)} schema ${import_picocolors12.default.bold(args.schema)} is connected to another organization.
6957
+ ` + import_picocolors12.default.dim(` Its project and branch are private. Use another schema or provider database URL.
6958
+ `)
6959
+ );
6960
+ return;
6961
+ }
6828
6962
  if (!answer.held) {
6829
6963
  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.
6964
+ ` ${import_picocolors12.default.green("\u2713")} No plane holds ${import_picocolors12.default.bold(`${label}/${args.schema}`)} \u2014 ${import_picocolors12.default.bold("ablo connect apply")} can register it here.
6831
6965
  `
6832
6966
  );
6833
6967
  return;
@@ -6838,13 +6972,50 @@ ${ambient}` : ""}`,
6838
6972
  );
6839
6973
  console.log(
6840
6974
  import_picocolors12.default.dim(
6841
- ` Ablo streams a database from one plane at a time. To move it, disconnect it there
6975
+ ` Ablo binds one plane to each database schema. To move this schema, disconnect it there
6842
6976
  first \u2014 run `
6843
6977
  ) + import_picocolors12.default.cyan("ablo connect deregister") + import_picocolors12.default.dim(` with a key for that plane, then connect here.
6844
6978
  `) + import_picocolors12.default.dim(` Confirm a candidate key first with `) + import_picocolors12.default.bold("ablo whoami --key-env <NAME>") + import_picocolors12.default.dim(`.
6845
6979
  `) + 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
6980
  );
6847
6981
  }
6982
+ async function runResnapshot() {
6983
+ const apiKey = resolveMutationApiKey();
6984
+ if (!apiKey) {
6985
+ throw new import_errors10.AbloAuthenticationError(
6986
+ "No branch-bound secret key found. Set ABLO_API_KEY to the sk_ key for the branch to resnapshot.",
6987
+ { code: "cli_api_key_missing" }
6988
+ );
6989
+ }
6990
+ console.log(
6991
+ `
6992
+ ${brand("ablo")} ${import_picocolors12.default.dim("connect resnapshot")} ${import_picocolors12.default.dim("reload existing rows")}
6993
+ `
6994
+ );
6995
+ const result = await requestControlPlane({
6996
+ path: "/v1/datasources/resnapshot",
6997
+ method: "POST",
6998
+ apiKey,
6999
+ body: {},
7000
+ responseSchema: import_wire7.datasourceResnapshotResponseSchema
7001
+ });
7002
+ if (result.replication_slot?.released === false) {
7003
+ console.log(` ${import_picocolors12.default.yellow("\u2014")} Snapshot reset recorded, but the old slot is still active.`);
7004
+ if (result.replication_slot.detail) console.log(` ${import_picocolors12.default.dim(result.replication_slot.detail)}`);
7005
+ if (result.replication_slot.remove_with) {
7006
+ console.log(` ${import_picocolors12.default.dim("Remove it with:")} ${import_picocolors12.default.bold(result.replication_slot.remove_with)}`);
7007
+ }
7008
+ console.log(`
7009
+ Re-run ${import_picocolors12.default.bold("ablo connect resnapshot")} after releasing it.
7010
+ `);
7011
+ process.exit(1);
7012
+ }
7013
+ console.log(
7014
+ ` ${import_picocolors12.default.green("\u2713")} Fresh snapshot requested. The DataSource and credentials were kept.
7015
+ ${import_picocolors12.default.dim(`Run ${import_picocolors12.default.bold("ablo connect check")} until the existing-row load is complete.`)}
7016
+ `
7017
+ );
7018
+ }
6848
7019
  async function connect(argv) {
6849
7020
  if (argv[0] === "deregister") {
6850
7021
  const { disconnect: disconnect2 } = await Promise.resolve().then(() => (init_disconnect(), disconnect_exports));
@@ -6871,12 +7042,16 @@ async function connect(argv) {
6871
7042
  await runCheck();
6872
7043
  return;
6873
7044
  }
7045
+ if (args.resnapshot) {
7046
+ await runResnapshot();
7047
+ return;
7048
+ }
6874
7049
  if (args.register) {
6875
7050
  await runRegister(args);
6876
7051
  return;
6877
7052
  }
6878
7053
  if (args.scan) {
6879
- await runScan();
7054
+ await runScan(args);
6880
7055
  return;
6881
7056
  }
6882
7057
  if (args.locate) {
@@ -6892,7 +7067,7 @@ async function connect(argv) {
6892
7067
  }
6893
7068
  printConnectRecipe(args);
6894
7069
  }
6895
- var import_errors10, import_picocolors12, import_footprint2, import_wire7, FOOTPRINT_LOOKUP, CONNECT_USAGE;
7070
+ var import_errors10, import_picocolors12, import_footprint3, import_wire7, FOOTPRINT_LOOKUP, CONNECT_USAGE;
6896
7071
  var init_connect = __esm({
6897
7072
  "src/connect.ts"() {
6898
7073
  "use strict";
@@ -6900,12 +7075,13 @@ var init_connect = __esm({
6900
7075
  import_errors10 = require("@abloatai/transaction/errors");
6901
7076
  import_picocolors12 = __toESM(require_picocolors(), 1);
6902
7077
  init_src();
6903
- import_footprint2 = require("@abloatai/transaction/footprint");
7078
+ import_footprint3 = require("@abloatai/transaction/footprint");
6904
7079
  init_dbRole();
6905
7080
  init_config();
6906
7081
  init_controlPlane();
6907
7082
  import_wire7 = require("@abloatai/transaction/wire");
6908
7083
  init_theme();
7084
+ init_target();
6909
7085
  init_remoteValidation();
6910
7086
  init_connectSetup();
6911
7087
  FOOTPRINT_LOOKUP = {
@@ -6927,6 +7103,7 @@ var init_connect = __esm({
6927
7103
  npx ablo connect deregister Disconnect this project's database \u2014 Ablo stops reading and writing it
6928
7104
  npx ablo connect check Confirm the connected database is ready, from Ablo's side (needs only ABLO_API_KEY)
6929
7105
  npx ablo connect rotate New passwords for both logins, then re-register
7106
+ npx ablo connect resnapshot Recreate only the slot and reload existing rows
6930
7107
  npx ablo connect scan List anything Ablo ever set up in your database (read-only, never drops)
6931
7108
  npx ablo connect locate See which plane holds this database (read-only; nothing is changed)
6932
7109
 
@@ -6941,6 +7118,7 @@ var init_connect = __esm({
6941
7118
  --url <admin-conn> Admin connection used once to set up (else DATABASE_URL); never stored
6942
7119
  --env-file <path> Explicitly load DATABASE_URL and ABLO_API_KEY from this file
6943
7120
  --tables a,b,c Publish only these tables (default: all tables)
7121
+ --schema <name> Bind this project to one database schema (default: public)
6944
7122
  --role <name> Name the replication role (default: ablo_replicator)
6945
7123
  --write-role <name> Name the DML role (default: ablo_writer)
6946
7124
  --route <route> public-allowlist | privatelink | peering | vpn
@@ -283625,13 +283803,21 @@ function classifyKey(apiKey) {
283625
283803
  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
283804
  };
283627
283805
  }
283628
- function wireEnvLocal(apiKey, cwd = process.cwd()) {
283806
+ function wireEnvLocal(apiKey, cwd = process.cwd(), projectId, branchId) {
283629
283807
  const envPath = (0, import_path5.resolve)(cwd, ".env.local");
283630
283808
  const line = `ABLO_API_KEY=${apiKey}`;
283809
+ const projectLine = projectId ? `ABLO_PROJECT_ID=${projectId}` : null;
283810
+ const branchLine = branchId ? `ABLO_BRANCH_ID=${branchId}` : null;
283631
283811
  let action;
283632
283812
  if (!(0, import_fs7.existsSync)(envPath)) {
283633
- (0, import_fs7.writeFileSync)(envPath, `${line}
283634
- `, { mode: 384 });
283813
+ (0, import_fs7.writeFileSync)(
283814
+ envPath,
283815
+ `${line}
283816
+ ${projectLine ? `${projectLine}
283817
+ ` : ""}${branchLine ? `${branchLine}
283818
+ ` : ""}`,
283819
+ { mode: 384 }
283820
+ );
283635
283821
  action = `Created ${import_picocolors15.default.bold(".env.local")} with ${import_picocolors15.default.bold("ABLO_API_KEY")}`;
283636
283822
  } else {
283637
283823
  const content = (0, import_fs7.readFileSync)(envPath, "utf8");
@@ -283647,6 +283833,24 @@ function wireEnvLocal(apiKey, cwd = process.cwd()) {
283647
283833
  (0, import_fs7.writeFileSync)(envPath, content.replace(/^ABLO_API_KEY=.*$/m, line));
283648
283834
  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
283835
  }
283836
+ if (projectLine) {
283837
+ const next = (0, import_fs7.readFileSync)(envPath, "utf8");
283838
+ if (/^ABLO_PROJECT_ID=.*$/m.test(next)) {
283839
+ (0, import_fs7.writeFileSync)(envPath, next.replace(/^ABLO_PROJECT_ID=.*$/m, projectLine));
283840
+ } else {
283841
+ (0, import_fs7.appendFileSync)(envPath, `${next.endsWith("\n") || next.length === 0 ? "" : "\n"}${projectLine}
283842
+ `);
283843
+ }
283844
+ }
283845
+ if (branchLine) {
283846
+ const next = (0, import_fs7.readFileSync)(envPath, "utf8");
283847
+ if (/^ABLO_BRANCH_ID=.*$/m.test(next)) {
283848
+ (0, import_fs7.writeFileSync)(envPath, next.replace(/^ABLO_BRANCH_ID=.*$/m, branchLine));
283849
+ } else {
283850
+ (0, import_fs7.appendFileSync)(envPath, `${next.endsWith("\n") || next.length === 0 ? "" : "\n"}${branchLine}
283851
+ `);
283852
+ }
283853
+ }
283650
283854
  }
283651
283855
  const gitignorePath = (0, import_path5.resolve)(cwd, ".gitignore");
283652
283856
  const gitignore = (0, import_fs7.existsSync)(gitignorePath) ? (0, import_fs7.readFileSync)(gitignorePath, "utf8") : "";
@@ -283717,7 +283921,7 @@ async function runPush(schema, args) {
283717
283921
  }
283718
283922
  if (status2 === 403) {
283719
283923
  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")}.`;
283924
+ const hint = schemaPushPlaneHint(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
283925
  return {
283722
283926
  ok: false,
283723
283927
  message: `${serverSays ?? "This key can't author schema (missing schema:push scope)."}
@@ -283777,8 +283981,10 @@ async function dev(argv, runtime = {}) {
283777
283981
  s.stop(first.message, first.ok ? 0 : 1);
283778
283982
  if (!first.ok) process.exit(1);
283779
283983
  if (runtime.branch) {
283780
- console.log(`
283781
- ${import_picocolors15.default.green("\u2713")} ${wireEnvLocal(args.apiKey)}`);
283984
+ console.log(
283985
+ `
283986
+ ${import_picocolors15.default.green("\u2713")} ${wireEnvLocal(args.apiKey, process.cwd(), runtime.branch.projectId, runtime.branch.id)}`
283987
+ );
283782
283988
  console.log(
283783
283989
  ` ${import_picocolors15.default.dim(`Temporary branch credential expires ${runtime.branch.expiresAt}; rerun ablo dev to rotate it.`)}`
283784
283990
  );
@@ -283953,6 +284159,7 @@ async function runBranchDev(argv, dependencies = {}) {
283953
284159
  apiKey: result.credential.api_key,
283954
284160
  branch: {
283955
284161
  id: result.branch.id,
284162
+ projectId: result.branch.project_id,
283956
284163
  slug: result.branch.slug,
283957
284164
  expiresAt: result.credential.expires_at
283958
284165
  }
@@ -284227,6 +284434,7 @@ var COMMANDS = [
284227
284434
  { run: "connect", does: "Connect your database \u2014 shows the setup to run, or applies it for you" },
284228
284435
  { run: "connect apply", does: "Run that setup for you, from a one-time admin URL" },
284229
284436
  { run: "connect check", does: "Confirm your database is ready to share changes with Ablo" },
284437
+ { run: "connect resnapshot", does: "Reload existing rows without deregistering or rotating credentials" },
284230
284438
  { run: "connect scan", does: "List anything Ablo ever set up in your database (read-only)" },
284231
284439
  { run: "connect locate", does: "See which plane holds a database before connecting it" },
284232
284440
  { run: "connect deregister", does: "Disconnect this project's database \u2014 Ablo stops reading and writing it" }
@@ -287138,6 +287346,8 @@ import { schema } from './schema';
287138
287346
  // via the session route and never touches the key.
287139
287347
  export const sync = Ablo({
287140
287348
  apiKey: process.env.ABLO_API_KEY,${authLine}
287349
+ projectId: process.env.ABLO_PROJECT_ID,
287350
+ branchId: process.env.ABLO_BRANCH_ID,
287141
287351
  schema,
287142
287352
  });
287143
287353
 
@@ -287163,7 +287373,7 @@ function generateEnv(storage, opts = {}) {
287163
287373
  const { includeApiKey = true } = opts;
287164
287374
  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
287375
  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" : "";
287376
+ 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
287377
  return `${apiKeyBlock}${webhookBlock}${databaseBlock}`;
287168
287378
  }
287169
287379
  function generateDataSource(orm) {
@@ -287324,8 +287534,7 @@ export async function POST(req: Request): Promise<Response> {
287324
287534
  `;
287325
287535
  }
287326
287536
  function generateAgent() {
287327
- return `import Ablo from '@abloatai/ablo';
287328
- import { schema } from './schema';
287537
+ return `import { sync as ablo } from './sync';
287329
287538
 
287330
287539
  /**
287331
287540
  * An AI "teammate" that works the same synced tasks a human does.
@@ -287335,8 +287544,6 @@ import { schema } from './schema';
287335
287544
  * \`npx ablo logs\`. That's the whole idea: agents and people on one typed,
287336
287545
  * synced dataset.
287337
287546
  */
287338
- const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
287339
-
287340
287547
  async function main() {
287341
287548
  await ablo.ready();
287342
287549
 
@@ -287354,7 +287561,6 @@ async function main() {
287354
287561
  data: { priority: 10 },
287355
287562
  readAt: snap.stamp,
287356
287563
  onStale: 'reject',
287357
- wait: 'confirmed',
287358
287564
  });
287359
287565
  console.log('prioritized:', urgent.title);
287360
287566
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/cli",
3
- "version": "0.46.0",
3
+ "version": "0.47.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.47.0",
34
34
  "jiti": "^2.7.0",
35
35
  "zod": "^4.4.3",
36
- "@abloatai/humans": "^0.46.0"
36
+ "@abloatai/humans": "^0.47.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@clack/prompts": "^0.11.0",