@abloatai/cli 0.45.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 +412 -115
  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.`
@@ -4538,6 +4546,7 @@ async function requestRemoteValidation(input) {
4538
4546
  ok: true,
4539
4547
  reachable: verdict.reachable,
4540
4548
  ready: verdict.ready,
4549
+ ...verdict.initial_snapshot !== void 0 ? { initialSnapshot: verdict.initial_snapshot } : {},
4541
4550
  ...verdict.reason !== void 0 ? { reason: verdict.reason } : {},
4542
4551
  failures: verdict.failures
4543
4552
  };
@@ -4565,8 +4574,13 @@ var init_remoteValidation = __esm({
4565
4574
  wal_level: () => `your database isn't set up to share changes as they happen yet`,
4566
4575
  publication: () => `none of your tables are shared with Ablo yet`,
4567
4576
  replication_role: () => `the login Ablo reads with can't follow your changes yet`,
4568
- replica_identity: (f) => withActual(`some shared tables don't record enough for Ablo to track edits and deletes`, f.actual),
4577
+ replication_slot_capacity: (f) => withActual(`this database has no remaining change-stream capacity for this binding`, f.actual),
4578
+ replica_identity: (f) => withActual(
4579
+ `some shared tables don't record enough for Ablo to track edits and deletes`,
4580
+ f.actual
4581
+ ),
4569
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),
4570
4584
  write_role: () => `the login Ablo writes with isn't set up yet`,
4571
4585
  row_security: () => `the writer login isn't set to honor your row-level security`,
4572
4586
  database_privileges: () => `the writer login can still create things in your database`,
@@ -4666,7 +4680,7 @@ function quoteIdent(id) {
4666
4680
  function quoteLiteral(value) {
4667
4681
  return `'${value.replace(/'/g, "''")}'`;
4668
4682
  }
4669
- function scopedSequenceGrant(tables, writeRole) {
4683
+ function scopedSequenceGrant(tables, writeRole, schema) {
4670
4684
  const names = tables.map(quoteLiteral).join(", ");
4671
4685
  return `DO $$
4672
4686
  DECLARE seq regclass;
@@ -4677,7 +4691,7 @@ BEGIN
4677
4691
  JOIN pg_class s ON s.oid = d.objid AND s.relkind = 'S'
4678
4692
  JOIN pg_class t ON t.oid = d.refobjid
4679
4693
  JOIN pg_namespace n ON n.oid = t.relnamespace
4680
- 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})
4681
4695
  LOOP
4682
4696
  EXECUTE format('GRANT USAGE, SELECT ON SEQUENCE %s TO ${quoteIdent(writeRole)}', seq);
4683
4697
  END LOOP;
@@ -4687,24 +4701,29 @@ function connectSetupSql(input) {
4687
4701
  const role = input.role && input.role.length > 0 ? input.role : import_footprint.ABLO_REPLICATION_ROLE;
4688
4702
  const writeRole = input.writeRole && input.writeRole.length > 0 ? input.writeRole : import_footprint.ABLO_WRITE_ROLE;
4689
4703
  const tables = input.tables ?? [];
4690
- const publicationTarget = tables.length > 0 ? `FOR TABLE ${tables.map(quoteIdent).join(", ")}` : "FOR ALL TABLES";
4691
- 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(", ");
4692
4709
  const scoped = tables.length > 0;
4693
- 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)};`;
4694
4711
  const replicationReadGrants = scoped ? [`GRANT SELECT ON TABLE ${tableList} TO ${quoteIdent(role)};`] : [
4695
- `GRANT SELECT ON ALL TABLES IN SCHEMA public TO ${quoteIdent(role)};`,
4696
- `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)};`
4697
4714
  ];
4698
- const writerSequenceGrants = scoped ? [scopedSequenceGrant(tables, writeRole)] : [`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${quoteIdent(writeRole)};`];
4699
- 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);
4700
4717
  return [
4701
4718
  // 1. Turn on logical decoding. Requires a restart (it's not reloadable).
4702
4719
  `ALTER SYSTEM SET wal_level = 'logical';`,
4703
4720
  // 2. Publish the tables Ablo should read.
4704
- `CREATE PUBLICATION ${quoteIdent(import_footprint.ABLO_PUBLICATION)} ${publicationTarget};`,
4721
+ `CREATE PUBLICATION ${quoteIdent(publication)} ${publicationTarget};`,
4705
4722
  // 3. A least-privilege role: it can stream replication and SELECT the
4706
- // published tables, nothing more.
4707
- `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>';`,
4708
4727
  ...replicationReadGrants,
4709
4728
  // 4. A distinct DML role: no replication, role administration, ownership,
4710
4729
  // schema creation, or DDL. It runs NOBYPASSRLS with row_security on, so on a
@@ -4725,20 +4744,20 @@ function connectSetupSql(input) {
4725
4744
  DO $$ BEGIN
4726
4745
  EXECUTE format('REVOKE TEMPORARY, CREATE ON DATABASE %I FROM PUBLIC', current_database());
4727
4746
  END $$;`,
4728
- `GRANT USAGE ON SCHEMA public TO ${quoteIdent(writeRole)};`,
4747
+ `GRANT USAGE ON SCHEMA ${quoteIdent(schema)} TO ${quoteIdent(writeRole)};`,
4729
4748
  applicationGrant,
4730
4749
  ...writerSequenceGrants,
4731
4750
  ...scoped ? [] : [
4732
4751
  // "All tables" mode only: keep future tables/sequences writable so the
4733
4752
  // publication doesn't outgrow the grant.
4734
- `ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${quoteIdent(writeRole)};`,
4735
- `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)};`
4736
4755
  ],
4737
4756
  // 5. Direct uses the durable replay ledger but deliberately no outbox.
4738
4757
  ...ledger,
4739
- `REVOKE ALL ON TABLE public.ablo_idempotency FROM PUBLIC;`,
4740
- `GRANT SELECT, INSERT, UPDATE ON TABLE public.ablo_idempotency TO ${quoteIdent(writeRole)};`,
4741
- `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)};`,
4742
4761
  // The writer emits a transactional marker on your WAL so Ablo can correlate
4743
4762
  // the committed row back to the originating write and confirm it — this
4744
4763
  // EXECUTE grant is what makes that confirmation possible. Granted by lookup
@@ -4761,10 +4780,12 @@ BEGIN
4761
4780
  END $$;`
4762
4781
  ];
4763
4782
  }
4764
- function reconcilePublicationPlan(current, desiredTables) {
4765
- 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)}`;
4766
4787
  const desiredAll = desiredTables.length === 0;
4767
- 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(", ")}`;
4768
4789
  if (!current.exists) {
4769
4790
  return {
4770
4791
  sql: [`CREATE PUBLICATION ${pub} ${target};`],
@@ -4790,28 +4811,31 @@ function reconcilePublicationPlan(current, desiredTables) {
4790
4811
  return { sql: [], added: [], removed: [], recreated: false };
4791
4812
  }
4792
4813
  return {
4793
- sql: [`ALTER PUBLICATION ${pub} SET TABLE ${desiredTables.map(quoteIdent).join(", ")};`],
4814
+ sql: [`ALTER PUBLICATION ${pub} SET TABLE ${desiredTables.map(qualified).join(", ")};`],
4794
4815
  added,
4795
4816
  removed,
4796
4817
  recreated: false
4797
4818
  };
4798
4819
  }
4799
- 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";
4800
4823
  const pubRows = await sql.unsafe(
4801
4824
  `SELECT puballtables FROM pg_publication WHERE pubname = $1`,
4802
- [import_footprint.ABLO_PUBLICATION]
4825
+ [publication]
4803
4826
  );
4804
4827
  const pubRow = pubRows[0];
4805
4828
  if (!pubRow) return { exists: false, allTables: false, tables: [] };
4806
4829
  if (pubRow.puballtables) return { exists: true, allTables: true, tables: [] };
4807
4830
  const tableRows = await sql.unsafe(
4808
- `SELECT tablename FROM pg_publication_tables WHERE pubname = $1 AND schemaname = 'public' ORDER BY tablename`,
4809
- [import_footprint.ABLO_PUBLICATION]
4831
+ `SELECT tablename FROM pg_publication_tables WHERE pubname = $1 AND schemaname = $2 ORDER BY tablename`,
4832
+ [publication, schema]
4810
4833
  );
4811
4834
  return { exists: true, allTables: false, tables: tableRows.map((r2) => r2.tablename) };
4812
4835
  }
4813
4836
  async function probeReadiness(sql, opts = {}) {
4814
4837
  const publication = opts.publication ?? import_footprint.ABLO_PUBLICATION;
4838
+ const schema = opts.schema ?? "public";
4815
4839
  const coordinated = opts.coordinatedTables && opts.coordinatedTables.length > 0 ? new Set(opts.coordinatedTables) : null;
4816
4840
  const items = [];
4817
4841
  const walRows = await sql.unsafe(
@@ -4843,7 +4867,7 @@ On Neon enable Logical Replication in the project (Console \u2192 Settings \u219
4843
4867
  }
4844
4868
  );
4845
4869
  const roleRows = await sql.unsafe(
4846
- `SELECT rolreplication, rolsuper FROM pg_roles WHERE rolname = current_user`
4870
+ `SELECT rolreplication, rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user`
4847
4871
  );
4848
4872
  const role = roleRows[0];
4849
4873
  const hasReplication = Boolean(role && (role.rolreplication || role.rolsuper));
@@ -4859,12 +4883,31 @@ On RDS: GRANT rds_replication TO <your_role>;`
4859
4883
  }
4860
4884
  );
4861
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
+ );
4862
4905
  const badRows = await sql.unsafe(
4863
4906
  `SELECT c.relname AS table_name, c.relreplident
4864
4907
  FROM pg_publication_tables pt
4865
4908
  JOIN pg_class c ON c.relname = pt.tablename
4866
4909
  JOIN pg_namespace n ON n.oid = c.relnamespace AND n.nspname = pt.schemaname
4867
- WHERE pt.pubname = $1
4910
+ WHERE pt.pubname = $1 AND pt.schemaname = $2
4868
4911
  AND (
4869
4912
  c.relreplident = 'n'
4870
4913
  OR (
@@ -4875,7 +4918,7 @@ On RDS: GRANT rds_replication TO <your_role>;`
4875
4918
  )
4876
4919
  )
4877
4920
  )`,
4878
- [publication]
4921
+ [publication, schema]
4879
4922
  );
4880
4923
  const relevant = coordinated ? badRows.filter((row) => coordinated.has(row.table_name)) : badRows;
4881
4924
  items.push(
@@ -4883,7 +4926,7 @@ On RDS: GRANT rds_replication TO <your_role>;`
4883
4926
  ok: false,
4884
4927
  label: `${relevant.length} published table${relevant.length === 1 ? "" : "s"} cannot replicate UPDATE/DELETE`,
4885
4928
  fix: relevant.map(
4886
- (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;`
4887
4930
  ).join("\n")
4888
4931
  }
4889
4932
  );
@@ -4900,7 +4943,10 @@ async function registerDirectDataSource(opts) {
4900
4943
  connection: "direct",
4901
4944
  connectionString: opts.replicationUrl,
4902
4945
  writeConnectionString: opts.writeUrl,
4903
- 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 } : {}
4904
4950
  },
4905
4951
  responseSchema: import_wire3.datasourceSummarySchema
4906
4952
  });
@@ -4911,7 +4957,8 @@ async function registerDirectDataSource(opts) {
4911
4957
  `
4912
4958
  ${import_picocolors7.default.green("\u2713")} Registered${body.host ? ` ${import_picocolors7.default.dim(body.host)}` : ""}${body.id ? ` ${import_picocolors7.default.dim(`(${body.id})`)}` : ""} as a direct DataSource (${statusNote}).
4913
4959
  Your database is connected. Reads follow its replication stream; writes go through Ablo
4914
- and land in your own tables. Check the connection anytime with ${import_picocolors7.default.cyan("ablo connect check")}.
4960
+ and land in your own tables. Rows that already exist load automatically \u2014 no manual
4961
+ backfill or row updates. Check their progress with ${import_picocolors7.default.cyan("ablo connect check")}.
4915
4962
  `
4916
4963
  );
4917
4964
  return true;
@@ -5213,26 +5260,27 @@ function ownershipRemediation(blockers2, admin) {
5213
5260
  const unresolved = blockers2.filter((blocker) => !resolvedOwners.has(blocker.owner));
5214
5261
  return { inheritGrants, unresolved };
5215
5262
  }
5216
- async function publishedTableBlockers(sql, tables) {
5263
+ async function publishedTableBlockers(sql, tables, schema = "public") {
5217
5264
  const scoped = tables.length > 0;
5218
5265
  const raw = await sql.unsafe(
5219
5266
  `SELECT format('%I.%I', n.nspname, c.relname) AS relation, ${OWNERSHIP_COLUMNS}
5220
5267
  ${OWNERSHIP_FROM}
5221
5268
  WHERE c.relkind = 'r'
5222
- AND n.nspname = 'public'
5269
+ AND n.nspname = $${scoped ? "2" : "1"}
5223
5270
  AND c.relname <> 'ablo_idempotency'
5224
5271
  ${scoped ? "AND c.relname = ANY($1)" : ""}`,
5225
- scoped ? [tables] : []
5272
+ scoped ? [tables, schema] : [schema]
5226
5273
  );
5227
5274
  return ownershipBlockers(import_zod3.z.array(ownedRelationRowSchema).parse(raw));
5228
5275
  }
5229
- async function ledgerBlocker(sql) {
5276
+ async function ledgerBlocker(sql, schema = "public") {
5230
5277
  const raw = await sql.unsafe(
5231
5278
  `SELECT format('%I.%I', n.nspname, c.relname) AS relation, ${OWNERSHIP_COLUMNS}
5232
5279
  ${OWNERSHIP_FROM}
5233
5280
  WHERE c.relkind = 'r'
5234
- AND n.nspname = 'public'
5235
- AND c.relname = 'ablo_idempotency'`
5281
+ AND n.nspname = $1
5282
+ AND c.relname = 'ablo_idempotency'`,
5283
+ [schema]
5236
5284
  );
5237
5285
  const rows = import_zod3.z.array(ownedRelationRowSchema).parse(raw);
5238
5286
  const row = rows[0];
@@ -5468,13 +5516,15 @@ function connectApplyPlan(input) {
5468
5516
  const role = input.role && input.role.length > 0 ? input.role : import_footprint.ABLO_REPLICATION_ROLE;
5469
5517
  const writeRole = input.writeRole && input.writeRole.length > 0 ? input.writeRole : import_footprint.ABLO_WRITE_ROLE;
5470
5518
  const tables = input.tables ?? [];
5519
+ const schema = input.schema ?? "public";
5520
+ const publication = input.publication ?? import_footprint.ABLO_PUBLICATION;
5471
5521
  const provider = input.provider ?? "generic";
5472
- const recipe = connectSetupSql({ tables, role, writeRole });
5522
+ const recipe = connectSetupSql({ tables, role, writeRole, schema, publication });
5473
5523
  const isWal = (s) => s.startsWith("ALTER SYSTEM SET wal_level");
5474
5524
  const isPublication = (s) => s.startsWith("CREATE PUBLICATION");
5475
5525
  const isRoleCreate = (s) => s.startsWith("CREATE ROLE ");
5476
5526
  const grants = recipe.filter((s) => !isWal(s) && !isPublication(s) && !isRoleCreate(s));
5477
- 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";
5478
5528
  const affectsOthers = effectsOnOthers({ grants, allTables: tables.length === 0 });
5479
5529
  const walStep = input.walAlreadyLogical ? [] : [
5480
5530
  provider === "generic" ? {
@@ -5489,10 +5539,10 @@ function connectApplyPlan(input) {
5489
5539
  sql: []
5490
5540
  }
5491
5541
  ];
5492
- const reconcile2 = input.existingPublication ? reconcilePublicationPlan(input.existingPublication, tables) : null;
5542
+ const reconcile2 = input.existingPublication ? reconcilePublicationPlan(input.existingPublication, tables, { schema, publication }) : null;
5493
5543
  const publicationSql = reconcile2 ? reconcile2.sql : [
5494
5544
  `DO $$ BEGIN
5495
- CREATE PUBLICATION ${quoteIdent(import_footprint.ABLO_PUBLICATION)} ${publicationTarget};
5545
+ CREATE PUBLICATION ${quoteIdent(publication)} ${publicationTarget};
5496
5546
  EXCEPTION WHEN duplicate_object THEN NULL;
5497
5547
  END $$;`
5498
5548
  ];
@@ -5517,14 +5567,19 @@ END $$;`
5517
5567
  {
5518
5568
  key: "replication-role",
5519
5569
  title: "Create the read-only login Ablo reads with",
5520
- 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`,
5521
5571
  sql: [
5522
5572
  idempotentRole(
5523
5573
  role,
5524
- "REPLICATION",
5574
+ "NOSUPERUSER BYPASSRLS NOCREATEDB NOCREATEROLE REPLICATION NOINHERIT",
5525
5575
  input.credentials.replicationClause,
5526
5576
  input.rotate === true
5527
- )
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;`
5528
5583
  ]
5529
5584
  },
5530
5585
  {
@@ -5686,16 +5741,23 @@ async function locateExistingConnection(input) {
5686
5741
  method: "POST",
5687
5742
  baseUrl: input.apiUrl,
5688
5743
  apiKey: input.apiKey,
5689
- body: { connectionString: input.connectionString },
5744
+ body: {
5745
+ connectionString: input.connectionString,
5746
+ ...input.schema ? { schema: input.schema } : {}
5747
+ },
5690
5748
  responseSchema: import_wire6.datasourceLocationResponseSchema
5691
5749
  });
5692
5750
  if (!result.ok) return null;
5693
- return result.value.held;
5751
+ if (result.value.held) return result.value.held;
5752
+ return result.value.available === false ? { private: true } : null;
5694
5753
  }
5695
5754
  function alreadyConnectedElsewhere(held) {
5696
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
+ }
5697
5759
  const where = held.project ? `project ${held.project}, branch ${held.branch}` : `branch ${held.branch}`;
5698
- 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.`;
5699
5761
  }
5700
5762
  var import_wire6, import_schema5;
5701
5763
  var init_connectPreflight = __esm({
@@ -5844,12 +5906,18 @@ ${ambient}` : ""}`,
5844
5906
  )
5845
5907
  );
5846
5908
  }
5847
- const tables = args.tables;
5848
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
+ }
5849
5917
  if (args.tables.length === 0) {
5850
5918
  console.log(
5851
5919
  import_picocolors11.default.dim(
5852
- ` 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)
5853
5921
  `
5854
5922
  )
5855
5923
  );
@@ -5864,8 +5932,28 @@ ${ambient}` : ""}`,
5864
5932
  console.log(` ${import_picocolors11.default.yellow("!")} ${mismatch}
5865
5933
  `);
5866
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;
5867
5950
  const heldElsewhere = alreadyConnectedElsewhere(
5868
- await locateExistingConnection({ apiUrl: apiBaseUrl(), apiKey, connectionString: adminUrl })
5951
+ await locateExistingConnection({
5952
+ apiUrl: apiBaseUrl(),
5953
+ apiKey,
5954
+ connectionString: adminUrl,
5955
+ schema: args.schema
5956
+ })
5869
5957
  );
5870
5958
  if (heldElsewhere) {
5871
5959
  console.error(` ${import_picocolors11.default.yellow("!")} ${heldElsewhere}
@@ -5925,8 +6013,8 @@ ${ambient}` : ""}`,
5925
6013
  );
5926
6014
  process.exit(1);
5927
6015
  }
5928
- const ledger = await ledgerBlocker(admin).catch(() => null);
5929
- 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(() => []);
5930
6018
  const { inheritGrants, unresolved } = ownershipRemediation(
5931
6019
  [...ledger ? [ledger] : [], ...foreignTables],
5932
6020
  capability.rolname
@@ -5950,12 +6038,16 @@ ${ambient}` : ""}`,
5950
6038
  );
5951
6039
  process.exit(1);
5952
6040
  }
5953
- const existingPublication = await readPublicationState(admin).catch(
6041
+ const existingPublication = await readPublicationState(admin, {
6042
+ schema: args.schema,
6043
+ publication
6044
+ }).catch(
5954
6045
  () => ({ exists: false, allTables: false, tables: [] })
5955
6046
  );
5956
- const pubReconcile = reconcilePublicationPlan(existingPublication, tables);
5957
- const role = args.role && args.role.length > 0 ? args.role : import_footprint.ABLO_REPLICATION_ROLE;
5958
- 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
+ });
5959
6051
  const existingRoles = await presentRoles(admin, [role, writeRole]).catch(() => []);
5960
6052
  if (rotatePlane) {
5961
6053
  const refusal = rotateWithoutConnection({ rotating, ...rotatePlane, existingRoles });
@@ -5996,8 +6088,10 @@ ${ambient}` : ""}`,
5996
6088
  const writePassword = generateRolePassword();
5997
6089
  const buildPlan = (mode) => connectApplyPlan({
5998
6090
  tables,
5999
- role: args.role,
6000
- writeRole: args.writeRole,
6091
+ role,
6092
+ writeRole,
6093
+ schema: args.schema,
6094
+ publication,
6001
6095
  rotate: rotating,
6002
6096
  credentials: {
6003
6097
  replicationClause: passwordClause(replicationPassword, mode),
@@ -6011,7 +6105,7 @@ ${ambient}` : ""}`,
6011
6105
  const steps = buildPlan("scram-verifier");
6012
6106
  if (pubReconcile.removed.length > 0 || pubReconcile.recreated) {
6013
6107
  console.log(
6014
- ` ${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:`
6015
6109
  );
6016
6110
  for (const t of pubReconcile.added) console.log(` ${import_picocolors11.default.green("+")} ${t}`);
6017
6111
  for (const t of pubReconcile.removed)
@@ -6088,9 +6182,12 @@ ${ambient}` : ""}`,
6088
6182
  }
6089
6183
  const replicationProbe = await probeAsRole(
6090
6184
  replicationUrl,
6091
- (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 })
6092
6190
  );
6093
- const writeProbe = await probeAsRole(writeUrl, probeDirectWriteReadiness);
6094
6191
  const refused = [
6095
6192
  ...replicationProbe.credentialRefused ? [role] : [],
6096
6193
  ...writeProbe.credentialRefused ? [writeRole] : []
@@ -6145,7 +6242,10 @@ ${ambient}` : ""}`,
6145
6242
  apiKey,
6146
6243
  replicationUrl,
6147
6244
  writeUrl,
6148
- route: args.route
6245
+ route: args.route,
6246
+ schema: args.schema,
6247
+ replicationSlot: footprint.slot,
6248
+ publication
6149
6249
  });
6150
6250
  process.off("SIGINT", onRotateInterrupt);
6151
6251
  process.off("SIGTERM", onRotateInterrupt);
@@ -6157,7 +6257,7 @@ ${ambient}` : ""}`,
6157
6257
  }
6158
6258
  process.exit(outcome.exitCode);
6159
6259
  }
6160
- var import_picocolors11, import_errors9, ROTATE_STRANDED_CREDENTIALS_NOTICE;
6260
+ var import_picocolors11, import_errors9, import_footprint2, ROTATE_STRANDED_CREDENTIALS_NOTICE;
6161
6261
  var init_connectApply = __esm({
6162
6262
  "src/connectApply.ts"() {
6163
6263
  "use strict";
@@ -6166,6 +6266,7 @@ var init_connectApply = __esm({
6166
6266
  init_src();
6167
6267
  init_dist2();
6168
6268
  import_errors9 = require("@abloatai/transaction/errors");
6269
+ import_footprint2 = require("@abloatai/transaction/footprint");
6169
6270
  init_connectSetup();
6170
6271
  init_connectOwnership();
6171
6272
  init_connect();
@@ -6189,6 +6290,7 @@ function parseConnectArgs(argv) {
6189
6290
  let register = false;
6190
6291
  let apply = false;
6191
6292
  let rotate = false;
6293
+ let resnapshot = false;
6192
6294
  let url;
6193
6295
  let envFile;
6194
6296
  let yes = false;
@@ -6197,6 +6299,7 @@ function parseConnectArgs(argv) {
6197
6299
  let locate = false;
6198
6300
  let manual = false;
6199
6301
  let tables = [];
6302
+ let schema = "public";
6200
6303
  let role = import_footprint.ABLO_REPLICATION_ROLE;
6201
6304
  let writeRole = import_footprint.ABLO_WRITE_ROLE;
6202
6305
  let route = "public-allowlist";
@@ -6216,6 +6319,9 @@ function parseConnectArgs(argv) {
6216
6319
  case "rotate":
6217
6320
  rotate = true;
6218
6321
  break;
6322
+ case "resnapshot":
6323
+ resnapshot = true;
6324
+ break;
6219
6325
  case "scan":
6220
6326
  scan = true;
6221
6327
  break;
@@ -6224,7 +6330,7 @@ function parseConnectArgs(argv) {
6224
6330
  break;
6225
6331
  default:
6226
6332
  throw new import_errors10.AbloValidationError(
6227
- `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)`,
6228
6334
  { code: "cli_invalid_arguments" }
6229
6335
  );
6230
6336
  }
@@ -6254,6 +6360,9 @@ function parseConnectArgs(argv) {
6254
6360
  tables = value.split(",").map((t) => t.trim()).filter((t) => t.length > 0);
6255
6361
  break;
6256
6362
  }
6363
+ case "--schema":
6364
+ schema = argv[++i] ?? schema;
6365
+ break;
6257
6366
  case "--role":
6258
6367
  role = argv[++i] ?? role;
6259
6368
  break;
@@ -6285,6 +6394,7 @@ function parseConnectArgs(argv) {
6285
6394
  register,
6286
6395
  apply,
6287
6396
  rotate,
6397
+ resnapshot,
6288
6398
  url,
6289
6399
  envFile,
6290
6400
  yes,
@@ -6292,6 +6402,7 @@ function parseConnectArgs(argv) {
6292
6402
  scan,
6293
6403
  locate,
6294
6404
  tables,
6405
+ schema,
6295
6406
  role,
6296
6407
  writeRole,
6297
6408
  route,
@@ -6302,7 +6413,8 @@ function printConnectRecipe(args) {
6302
6413
  const sql = connectSetupSql({
6303
6414
  tables: args.tables,
6304
6415
  role: args.role,
6305
- writeRole: args.writeRole
6416
+ writeRole: args.writeRole,
6417
+ schema: args.schema
6306
6418
  });
6307
6419
  console.log(
6308
6420
  `
@@ -6521,17 +6633,17 @@ async function probeDirectWriteReadiness(sql, opts = {}) {
6521
6633
  );
6522
6634
  return items;
6523
6635
  }
6524
- async function auditTenantSyncInfra(sql) {
6636
+ async function auditTenantSyncInfra(sql, opts = {}) {
6525
6637
  const artifacts = [];
6526
- for (const artifact of import_footprint2.ABLO_FOOTPRINT) {
6527
- const key = artifact.kind === "table" || artifact.kind === "type" ? `public.${artifact.name}` : artifact.name;
6528
- const rows = await sql.unsafe(
6529
- FOOTPRINT_LOOKUP[artifact.kind],
6530
- [key]
6531
- );
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;
6641
+ const rows = await sql.unsafe(FOOTPRINT_LOOKUP[artifact.kind], [
6642
+ key
6643
+ ]);
6532
6644
  artifacts.push({
6533
6645
  kind: artifact.kind,
6534
- name: artifact.name,
6646
+ name,
6535
6647
  present: rows[0]?.present === true,
6536
6648
  purpose: artifact.purpose,
6537
6649
  ...artifact.hazard ? { hazard: artifact.hazard } : {},
@@ -6560,12 +6672,12 @@ function requireScopedUrl(kind, verb) {
6560
6672
  );
6561
6673
  process.exit(1);
6562
6674
  }
6563
- async function probeAndReport(dbUrl, kind = "replication") {
6675
+ async function probeAndReport(dbUrl, kind = "replication", opts = {}) {
6564
6676
  const sql = src_default(dbUrl, { max: 1, prepare: false, connect_timeout: 10, onnotice: () => {
6565
6677
  } });
6566
6678
  let items;
6567
6679
  try {
6568
- items = kind === "replication" ? await probeReadiness(sql) : await probeDirectWriteReadiness(sql);
6680
+ items = kind === "replication" ? await probeReadiness(sql, opts) : await probeDirectWriteReadiness(sql, opts);
6569
6681
  } catch (err) {
6570
6682
  await sql.end({ timeout: 2 }).catch(() => void 0);
6571
6683
  const dial = dialFailureReason(err);
@@ -6637,6 +6749,22 @@ ${ambient}` : ""}`,
6637
6749
  const { label, fix } = describeRemoteFailure(failure);
6638
6750
  printCheckItem({ ok: false, label, fix });
6639
6751
  }
6752
+ if (result.initialSnapshot?.status === "complete") {
6753
+ printCheckItem({ ok: true, label: "Rows that existed before connecting are loaded" });
6754
+ } else if (result.initialSnapshot?.status === "loading") {
6755
+ printCheckItem({
6756
+ ok: false,
6757
+ label: "Rows that existed before connecting are still loading",
6758
+ fix: "No SQL or row-touch script is needed. Ablo snapshots published tables automatically; wait a moment, then rerun `ablo connect check`."
6759
+ });
6760
+ } else if (result.initialSnapshot?.status === "retrying") {
6761
+ printCheckItem({
6762
+ ok: false,
6763
+ label: "Loading existing rows hit an error and Ablo is retrying",
6764
+ fix: `${result.initialSnapshot.detail ?? "The replication worker reported an error."}
6765
+ No row updates are needed. Fix the reported connection or database issue, then rerun \`ablo connect check\`.`
6766
+ });
6767
+ }
6640
6768
  console.log();
6641
6769
  if (result.ready) {
6642
6770
  console.log(
@@ -6645,6 +6773,13 @@ ${ambient}` : ""}`,
6645
6773
  );
6646
6774
  process.exit(0);
6647
6775
  }
6776
+ if ((result.initialSnapshot?.status === "loading" || result.initialSnapshot?.status === "retrying") && result.failures.length === 0) {
6777
+ console.log(
6778
+ ` ${import_picocolors12.default.yellow("\u2014")} Not ready yet ${import_picocolors12.default.dim(`\u2014 Ablo is loading existing rows. Re-run ${import_picocolors12.default.bold("ablo connect check")} shortly.`)}
6779
+ `
6780
+ );
6781
+ process.exit(1);
6782
+ }
6648
6783
  const count = result.failures.length;
6649
6784
  console.log(
6650
6785
  ` ${import_picocolors12.default.red(`${count} item${count === 1 ? "" : "s"} to fix`)} ${import_picocolors12.default.dim(`\u2014 apply the fixes above, then re-run ${import_picocolors12.default.bold("ablo connect check")}.`)}
@@ -6672,11 +6807,11 @@ ${ambient}` : ""}`,
6672
6807
  );
6673
6808
  console.log(` ${import_picocolors12.default.bold("Replication role")}
6674
6809
  `);
6675
- const replication = await probeAndReport(dbUrl, "replication");
6810
+ const replication = await probeAndReport(dbUrl, "replication", { schema: args.schema });
6676
6811
  console.log(`
6677
6812
  ${import_picocolors12.default.bold("Direct-write role")}
6678
6813
  `);
6679
- const write = await probeAndReport(writeDbUrl, "write");
6814
+ const write = await probeAndReport(writeDbUrl, "write", { schema: args.schema });
6680
6815
  const noDial = [
6681
6816
  replication.kind === "no-dial" ? `replication: ${replication.reason}` : null,
6682
6817
  write.kind === "no-dial" ? `write: ${write.reason}` : null
@@ -6704,17 +6839,35 @@ ${ambient}` : ""}`,
6704
6839
  apiKey,
6705
6840
  replicationUrl: dbUrl,
6706
6841
  writeUrl: writeDbUrl,
6707
- route: args.route
6842
+ route: args.route,
6843
+ schema: args.schema
6708
6844
  });
6709
6845
  process.exit(registered ? 0 : 1);
6710
6846
  }
6711
- async function runScan() {
6847
+ async function runScan(args) {
6712
6848
  const dbUrl = requireScopedUrl("replication", "scan");
6713
6849
  const sql = src_default(dbUrl, { max: 1, prepare: false, onnotice: () => {
6714
6850
  } });
6715
6851
  let artifacts;
6716
6852
  try {
6717
- 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
+ );
6718
6871
  } catch (err) {
6719
6872
  const pg = err ?? {};
6720
6873
  await sql.end({ timeout: 2 });
@@ -6795,12 +6948,20 @@ ${ambient}` : ""}`,
6795
6948
  path: "/v1/datasources/locate",
6796
6949
  method: "POST",
6797
6950
  apiKey,
6798
- body: { connectionString: url },
6951
+ body: { connectionString: url, schema: args.schema },
6799
6952
  responseSchema: import_wire7.datasourceLocationResponseSchema
6800
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
+ }
6801
6962
  if (!answer.held) {
6802
6963
  console.log(
6803
- ` ${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.
6804
6965
  `
6805
6966
  );
6806
6967
  return;
@@ -6811,13 +6972,50 @@ ${ambient}` : ""}`,
6811
6972
  );
6812
6973
  console.log(
6813
6974
  import_picocolors12.default.dim(
6814
- ` 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
6815
6976
  first \u2014 run `
6816
6977
  ) + import_picocolors12.default.cyan("ablo connect deregister") + import_picocolors12.default.dim(` with a key for that plane, then connect here.
6817
6978
  `) + import_picocolors12.default.dim(` Confirm a candidate key first with `) + import_picocolors12.default.bold("ablo whoami --key-env <NAME>") + import_picocolors12.default.dim(`.
6818
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"
6819
6980
  );
6820
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
+ }
6821
7019
  async function connect(argv) {
6822
7020
  if (argv[0] === "deregister") {
6823
7021
  const { disconnect: disconnect2 } = await Promise.resolve().then(() => (init_disconnect(), disconnect_exports));
@@ -6844,12 +7042,16 @@ async function connect(argv) {
6844
7042
  await runCheck();
6845
7043
  return;
6846
7044
  }
7045
+ if (args.resnapshot) {
7046
+ await runResnapshot();
7047
+ return;
7048
+ }
6847
7049
  if (args.register) {
6848
7050
  await runRegister(args);
6849
7051
  return;
6850
7052
  }
6851
7053
  if (args.scan) {
6852
- await runScan();
7054
+ await runScan(args);
6853
7055
  return;
6854
7056
  }
6855
7057
  if (args.locate) {
@@ -6865,7 +7067,7 @@ async function connect(argv) {
6865
7067
  }
6866
7068
  printConnectRecipe(args);
6867
7069
  }
6868
- 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;
6869
7071
  var init_connect = __esm({
6870
7072
  "src/connect.ts"() {
6871
7073
  "use strict";
@@ -6873,12 +7075,13 @@ var init_connect = __esm({
6873
7075
  import_errors10 = require("@abloatai/transaction/errors");
6874
7076
  import_picocolors12 = __toESM(require_picocolors(), 1);
6875
7077
  init_src();
6876
- import_footprint2 = require("@abloatai/transaction/footprint");
7078
+ import_footprint3 = require("@abloatai/transaction/footprint");
6877
7079
  init_dbRole();
6878
7080
  init_config();
6879
7081
  init_controlPlane();
6880
7082
  import_wire7 = require("@abloatai/transaction/wire");
6881
7083
  init_theme();
7084
+ init_target();
6882
7085
  init_remoteValidation();
6883
7086
  init_connectSetup();
6884
7087
  FOOTPRINT_LOOKUP = {
@@ -6900,6 +7103,7 @@ var init_connect = __esm({
6900
7103
  npx ablo connect deregister Disconnect this project's database \u2014 Ablo stops reading and writing it
6901
7104
  npx ablo connect check Confirm the connected database is ready, from Ablo's side (needs only ABLO_API_KEY)
6902
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
6903
7107
  npx ablo connect scan List anything Ablo ever set up in your database (read-only, never drops)
6904
7108
  npx ablo connect locate See which plane holds this database (read-only; nothing is changed)
6905
7109
 
@@ -6914,6 +7118,7 @@ var init_connect = __esm({
6914
7118
  --url <admin-conn> Admin connection used once to set up (else DATABASE_URL); never stored
6915
7119
  --env-file <path> Explicitly load DATABASE_URL and ABLO_API_KEY from this file
6916
7120
  --tables a,b,c Publish only these tables (default: all tables)
7121
+ --schema <name> Bind this project to one database schema (default: public)
6917
7122
  --role <name> Name the replication role (default: ablo_replicator)
6918
7123
  --write-role <name> Name the DML role (default: ablo_writer)
6919
7124
  --route <route> public-allowlist | privatelink | peering | vpn
@@ -283598,13 +283803,21 @@ function classifyKey(apiKey) {
283598
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.`
283599
283804
  };
283600
283805
  }
283601
- function wireEnvLocal(apiKey, cwd = process.cwd()) {
283806
+ function wireEnvLocal(apiKey, cwd = process.cwd(), projectId, branchId) {
283602
283807
  const envPath = (0, import_path5.resolve)(cwd, ".env.local");
283603
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;
283604
283811
  let action;
283605
283812
  if (!(0, import_fs7.existsSync)(envPath)) {
283606
- (0, import_fs7.writeFileSync)(envPath, `${line}
283607
- `, { mode: 384 });
283813
+ (0, import_fs7.writeFileSync)(
283814
+ envPath,
283815
+ `${line}
283816
+ ${projectLine ? `${projectLine}
283817
+ ` : ""}${branchLine ? `${branchLine}
283818
+ ` : ""}`,
283819
+ { mode: 384 }
283820
+ );
283608
283821
  action = `Created ${import_picocolors15.default.bold(".env.local")} with ${import_picocolors15.default.bold("ABLO_API_KEY")}`;
283609
283822
  } else {
283610
283823
  const content = (0, import_fs7.readFileSync)(envPath, "utf8");
@@ -283620,6 +283833,24 @@ function wireEnvLocal(apiKey, cwd = process.cwd()) {
283620
283833
  (0, import_fs7.writeFileSync)(envPath, content.replace(/^ABLO_API_KEY=.*$/m, line));
283621
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)`)}`;
283622
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
+ }
283623
283854
  }
283624
283855
  const gitignorePath = (0, import_path5.resolve)(cwd, ".gitignore");
283625
283856
  const gitignore = (0, import_fs7.existsSync)(gitignorePath) ? (0, import_fs7.readFileSync)(gitignorePath, "utf8") : "";
@@ -283690,7 +283921,7 @@ async function runPush(schema, args) {
283690
283921
  }
283691
283922
  if (status2 === 403) {
283692
283923
  const serverSays = body.message ?? body.reason;
283693
- 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")}.`);
283694
283925
  return {
283695
283926
  ok: false,
283696
283927
  message: `${serverSays ?? "This key can't author schema (missing schema:push scope)."}
@@ -283750,8 +283981,10 @@ async function dev(argv, runtime = {}) {
283750
283981
  s.stop(first.message, first.ok ? 0 : 1);
283751
283982
  if (!first.ok) process.exit(1);
283752
283983
  if (runtime.branch) {
283753
- console.log(`
283754
- ${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
+ );
283755
283988
  console.log(
283756
283989
  ` ${import_picocolors15.default.dim(`Temporary branch credential expires ${runtime.branch.expiresAt}; rerun ablo dev to rotate it.`)}`
283757
283990
  );
@@ -283926,6 +284159,7 @@ async function runBranchDev(argv, dependencies = {}) {
283926
284159
  apiKey: result.credential.api_key,
283927
284160
  branch: {
283928
284161
  id: result.branch.id,
284162
+ projectId: result.branch.project_id,
283929
284163
  slug: result.branch.slug,
283930
284164
  expiresAt: result.credential.expires_at
283931
284165
  }
@@ -284200,6 +284434,7 @@ var COMMANDS = [
284200
284434
  { run: "connect", does: "Connect your database \u2014 shows the setup to run, or applies it for you" },
284201
284435
  { run: "connect apply", does: "Run that setup for you, from a one-time admin URL" },
284202
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" },
284203
284438
  { run: "connect scan", does: "List anything Ablo ever set up in your database (read-only)" },
284204
284439
  { run: "connect locate", does: "See which plane holds a database before connecting it" },
284205
284440
  { run: "connect deregister", does: "Disconnect this project's database \u2014 Ablo stops reading and writing it" }
@@ -284738,7 +284973,9 @@ async function ping(apiUrl3) {
284738
284973
  }
284739
284974
  function formatConflict(conflict) {
284740
284975
  if (!conflict) return "";
284741
- const parts = import_schema8.participantKindSchema.options.flatMap((k3) => conflict[k3] ? [`${k3}:${conflict[k3]}`] : []);
284976
+ const parts = import_schema8.participantKindSchema.options.flatMap(
284977
+ (k3) => conflict[k3] ? [`${k3}:${conflict[k3]}`] : []
284978
+ );
284742
284979
  return parts.length ? `{${parts.join(",")}}` : "";
284743
284980
  }
284744
284981
  function printTargetLines(target, localProject, storedOrganizationId, storedOrganizationSlug) {
@@ -284783,13 +285020,21 @@ async function status(args = []) {
284783
285020
  const cfg = readConfig();
284784
285021
  const mode = getMode();
284785
285022
  const runtimeKey = resolveRuntimeApiKey();
284786
- const target = runtimeKey.key ? await resolveTarget({ url: apiUrl3, apiKey: runtimeKey.key, keySource: runtimeKey.source ?? "stored" }) : null;
285023
+ const target = runtimeKey.key ? await resolveTarget({
285024
+ url: apiUrl3,
285025
+ apiKey: runtimeKey.key,
285026
+ keySource: runtimeKey.source ?? "stored"
285027
+ }) : null;
284787
285028
  if (args.includes("--json")) {
284788
285029
  const entry = getKeyEntry(mode);
284789
285030
  const activeProject2 = getActiveProject();
284790
285031
  const pushed2 = await fetchPushedSchema(apiUrl3, runtimeKey.key);
284791
285032
  const reachableForJson = await ping(apiUrl3);
284792
- const dataSource2 = reachableForJson ? (await fetchRoutingState(apiUrl3, runtimeKey.key)).source : { kind: "unknown", detail: "unreachable" };
285033
+ const routingForJson = reachableForJson ? await fetchRoutingState(apiUrl3, runtimeKey.key) : {
285034
+ source: { kind: "unknown", detail: "unreachable" },
285035
+ validation: null
285036
+ };
285037
+ const dataSource2 = routingForJson.source;
284793
285038
  const driftForJson = schemaDrift(await readLocalSchemaHash(), pushed2?.hash);
284794
285039
  const out = {
284795
285040
  // The locally-active project (`ablo projects use`); null = org-default.
@@ -284835,6 +285080,10 @@ async function status(args = []) {
284835
285080
  // form of the human verdict: a caller can gate on it in CI rather than
284836
285081
  // discovering the same facts from a failed request later.
284837
285082
  dataSource: dataSource2,
285083
+ // Existing rows are copied into Ablo automatically after a direct source
285084
+ // is connected. Agents can gate a read cutover on this field instead of
285085
+ // probing for one row or inventing a row-touch backfill.
285086
+ initialSnapshot: routingForJson.validation?.ok === true ? routingForJson.validation.initialSnapshot ?? null : null,
284838
285087
  drift: driftForJson,
284839
285088
  blockers: blockers({
284840
285089
  reachable: reachableForJson,
@@ -284868,7 +285117,9 @@ async function status(args = []) {
284868
285117
  );
284869
285118
  const management = getManagementKeyEntry();
284870
285119
  console.log(
284871
- ` ${import_picocolors19.default.dim("\u25CB")} ${"management".padEnd(12)} ${management ? import_picocolors19.default.dim(`${management.apiKey.slice(0, 12)}\u2026${management.expiresAt ? ` \xB7 ${expiryLabel(management.expiresAt)}` : ""}`) : import_picocolors19.default.dim("\u2014 no key")}`
285120
+ ` ${import_picocolors19.default.dim("\u25CB")} ${"management".padEnd(12)} ${management ? import_picocolors19.default.dim(
285121
+ `${management.apiKey.slice(0, 12)}\u2026${management.expiresAt ? ` \xB7 ${expiryLabel(management.expiresAt)}` : ""}`
285122
+ ) : import_picocolors19.default.dim("\u2014 no key")}`
284872
285123
  );
284873
285124
  for (const { key: m2, label } of [
284874
285125
  { key: "sandbox", label: "legacy child" },
@@ -284901,7 +285152,9 @@ async function status(args = []) {
284901
285152
  const how = [...new Set(dataSource.connections)].join(" + ");
284902
285153
  const pooled = detectPoolerIn(dataSource.hosts);
284903
285154
  const unreachable = validation && !validation.ok ? validation.message : void 0;
284904
- console.log(` ${import_picocolors19.default.dim("data")} ${import_picocolors19.default.green("\u2713")} ${import_picocolors19.default.dim(`database connected to this plane (${how})`)}`);
285155
+ console.log(
285156
+ ` ${import_picocolors19.default.dim("data")} ${import_picocolors19.default.green("\u2713")} ${import_picocolors19.default.dim(`database connected to this plane (${how})`)}`
285157
+ );
284905
285158
  if (pooled) {
284906
285159
  console.log(
284907
285160
  ` ${import_picocolors19.default.yellow("\u26A0")} ${import_picocolors19.default.dim(
@@ -284912,12 +285165,27 @@ async function status(args = []) {
284912
285165
  if (unreachable) {
284913
285166
  console.log(` ${import_picocolors19.default.red("\u2717")} ${import_picocolors19.default.dim(`Ablo could not reach it \u2014 ${unreachable}`)}`);
284914
285167
  }
285168
+ if (validation?.ok && validation.initialSnapshot?.status === "loading") {
285169
+ console.log(
285170
+ ` ${import_picocolors19.default.yellow("\u25CC")} ${import_picocolors19.default.dim(
285171
+ `loading rows that predate the connection \u2014 automatic; check with ${import_picocolors19.default.bold("ablo connect check")}`
285172
+ )}`
285173
+ );
285174
+ } else if (validation?.ok && validation.initialSnapshot?.status === "retrying") {
285175
+ console.log(
285176
+ ` ${import_picocolors19.default.red("\u2717")} ${import_picocolors19.default.dim(
285177
+ `loading existing rows is retrying${validation.initialSnapshot.detail ? ` \u2014 ${validation.initialSnapshot.detail}` : ""}`
285178
+ )}`
285179
+ );
285180
+ }
284915
285181
  } else if (dataSource.kind === "none") {
284916
285182
  console.log(
284917
285183
  ` ${import_picocolors19.default.dim("data")} ${import_picocolors19.default.red("\u2717 no database connected to this plane")} ${import_picocolors19.default.dim("\u2014 writes are held")}`
284918
285184
  );
284919
285185
  } else if (reachable) {
284920
- console.log(` ${import_picocolors19.default.dim("data")} ${import_picocolors19.default.yellow("?")} ${import_picocolors19.default.dim(`could not read the plane's databases (${dataSource.detail})`)}`);
285186
+ console.log(
285187
+ ` ${import_picocolors19.default.dim("data")} ${import_picocolors19.default.yellow("?")} ${import_picocolors19.default.dim(`could not read the plane's databases (${dataSource.detail})`)}`
285188
+ );
284921
285189
  }
284922
285190
  const pushed = reachable ? await fetchPushedSchema(apiUrl3, introspectKey) : null;
284923
285191
  if (reachable) {
@@ -284925,7 +285193,9 @@ async function status(args = []) {
284925
285193
  const when = pushed.pushedAt ? ` ${import_picocolors19.default.dim(`@ ${pushed.pushedAt.slice(0, 10)}`)}` : "";
284926
285194
  const ver = pushed.version != null ? ` ${import_picocolors19.default.dim(`(rev ${pushed.version})`)}` : "";
284927
285195
  const hashLabel = pushed.hash ? ` ${import_picocolors19.default.dim(`hash ${pushed.hash}`)}` : "";
284928
- console.log(` ${import_picocolors19.default.dim("schema")} ${import_picocolors19.default.bold(`${pushed.models.length} models pushed`)}${ver}${hashLabel}${when}`);
285196
+ console.log(
285197
+ ` ${import_picocolors19.default.dim("schema")} ${import_picocolors19.default.bold(`${pushed.models.length} models pushed`)}${ver}${hashLabel}${when}`
285198
+ );
284929
285199
  for (const m2 of pushed.models) {
284930
285200
  const tn = m2.typename === m2.key ? import_picocolors19.default.dim(`typename=${m2.typename}`) : import_picocolors19.default.yellow(`typename=${m2.typename}`);
284931
285201
  const conflict = formatConflict(m2.conflict);
@@ -284933,7 +285203,9 @@ async function status(args = []) {
284933
285203
  console.log(` ${import_picocolors19.default.dim("\u2022")} ${m2.key.padEnd(14)} ${tn}${conflictStr2}`);
284934
285204
  }
284935
285205
  } else if (pushed && !pushed.active) {
284936
- console.log(` ${import_picocolors19.default.dim("schema")} ${import_picocolors19.default.yellow("none pushed")} ${import_picocolors19.default.dim(`(run ${import_picocolors19.default.bold("ablo push")} or ${import_picocolors19.default.bold("ablo dev")})`)}`);
285206
+ console.log(
285207
+ ` ${import_picocolors19.default.dim("schema")} ${import_picocolors19.default.yellow("none pushed")} ${import_picocolors19.default.dim(`(run ${import_picocolors19.default.bold("ablo push")} or ${import_picocolors19.default.bold("ablo dev")})`)}`
285208
+ );
284937
285209
  }
284938
285210
  }
284939
285211
  const drift = schemaDrift(await readLocalSchemaHash(), pushed?.hash);
@@ -284978,7 +285250,9 @@ init_target();
284978
285250
  init_readiness();
284979
285251
  function render(check2) {
284980
285252
  const mark = check2.state === "ok" ? import_picocolors20.default.green("\u2713") : check2.state === "fail" ? import_picocolors20.default.red("\u2717") : import_picocolors20.default.dim("\u2013");
284981
- console.log(` ${mark} ${check2.label.padEnd(10)} ${check2.state === "fail" ? check2.detail : import_picocolors20.default.dim(check2.detail)}`);
285253
+ console.log(
285254
+ ` ${mark} ${check2.label.padEnd(10)} ${check2.state === "fail" ? check2.detail : import_picocolors20.default.dim(check2.detail)}`
285255
+ );
284982
285256
  if (check2.fix) console.log(` ${" ".repeat(11)}${import_picocolors20.default.dim(`\u2192 ${check2.fix}`)}`);
284983
285257
  }
284984
285258
  async function ping2(apiUrl3) {
@@ -285025,7 +285299,11 @@ async function doctor() {
285025
285299
  fix: "check your connection, then re-run `ablo doctor`"
285026
285300
  }
285027
285301
  );
285028
- const target = runtimeKey.key ? await resolveTarget({ url: apiUrl3, apiKey: runtimeKey.key, keySource: runtimeKey.source ?? "stored" }) : null;
285302
+ const target = runtimeKey.key ? await resolveTarget({
285303
+ url: apiUrl3,
285304
+ apiKey: runtimeKey.key,
285305
+ keySource: runtimeKey.source ?? "stored"
285306
+ }) : null;
285029
285307
  const confirmed = target?.confirmed ?? null;
285030
285308
  if (confirmed) {
285031
285309
  const project = confirmed.project ? confirmed.project.isDefault ? "default" : confirmed.project.name === null ? `${confirmed.project.id} (unnamed \u2014 ${confirmed.project.unnamedReason ?? "the project list did not answer"})` : `${confirmed.project.slug} (${confirmed.project.id})` : "default";
@@ -285071,7 +285349,12 @@ async function doctor() {
285071
285349
  label: "schema",
285072
285350
  state: "ok",
285073
285351
  detail: `${pushed.models.length} models active${pushed.hash ? `, hash ${pushed.hash}` : ""}`
285074
- } : reachable && runtimeKey.key ? { label: "schema", state: "fail", detail: "none active for this key", fix: "run `ablo push`" } : { label: "schema", state: "skip", detail: "not determined" }
285352
+ } : reachable && runtimeKey.key ? {
285353
+ label: "schema",
285354
+ state: "fail",
285355
+ detail: "none active for this key",
285356
+ fix: "run `ablo push`"
285357
+ } : { label: "schema", state: "skip", detail: "not determined" }
285075
285358
  );
285076
285359
  const drift = schemaDrift(await readLocalSchemaHash(), pushed?.hash);
285077
285360
  if (drift) {
@@ -285084,7 +285367,21 @@ async function doctor() {
285084
285367
  }
285085
285368
  if (dataSource.kind === "connected" && validation) {
285086
285369
  checks.push(
285087
- validation.ok ? validation.ready ? { label: "database", state: "ok", detail: "reachable from Ablo, and ready to replicate" } : {
285370
+ validation.ok ? validation.ready ? {
285371
+ label: "database",
285372
+ state: "ok",
285373
+ detail: "reachable from Ablo, and ready to replicate"
285374
+ } : validation.initialSnapshot?.status === "loading" && validation.failures.length === 0 ? {
285375
+ label: "database",
285376
+ state: "fail",
285377
+ detail: "reachable; rows that predate the connection are still loading",
285378
+ fix: "wait a moment and rerun `ablo doctor` \u2014 Ablo snapshots them automatically; do not update every row or write a manual backfill"
285379
+ } : validation.initialSnapshot?.status === "retrying" && validation.failures.length === 0 ? {
285380
+ label: "database",
285381
+ state: "fail",
285382
+ detail: `the initial row load is retrying${validation.initialSnapshot.detail ? ` \u2014 ${validation.initialSnapshot.detail}` : ""}`,
285383
+ fix: "fix the reported connection or database issue, then rerun `ablo doctor`; no row-touch backfill is needed"
285384
+ } : {
285088
285385
  label: "database",
285089
285386
  state: "fail",
285090
285387
  detail: `reachable, but not ready (${validation.failures.length} check${validation.failures.length === 1 ? "" : "s"} failing)`,
@@ -285114,7 +285411,9 @@ async function doctor() {
285114
285411
  console.log(
285115
285412
  ` ${import_picocolors20.default.red("\u2717")} ${import_picocolors20.default.bold(`${failed} problem${failed === 1 ? "" : "s"}`)}` + import_picocolors20.default.dim(skipped > 0 ? `, ${skipped} check${skipped === 1 ? "" : "s"} skipped` : "")
285116
285413
  );
285117
- console.log(import_picocolors20.default.dim(" Fix them in the order above \u2014 an earlier one often explains a later one."));
285414
+ console.log(
285415
+ import_picocolors20.default.dim(" Fix them in the order above \u2014 an earlier one often explains a later one.")
285416
+ );
285118
285417
  } else if (skipped > 0) {
285119
285418
  console.log(
285120
285419
  ` ${import_picocolors20.default.yellow("?")} ${import_picocolors20.default.dim(`nothing is blocking a write, but ${skipped} check${skipped === 1 ? "" : "s"} could not be run`)}`
@@ -287047,6 +287346,8 @@ import { schema } from './schema';
287047
287346
  // via the session route and never touches the key.
287048
287347
  export const sync = Ablo({
287049
287348
  apiKey: process.env.ABLO_API_KEY,${authLine}
287349
+ projectId: process.env.ABLO_PROJECT_ID,
287350
+ branchId: process.env.ABLO_BRANCH_ID,
287050
287351
  schema,
287051
287352
  });
287052
287353
 
@@ -287072,7 +287373,7 @@ function generateEnv(storage, opts = {}) {
287072
287373
  const { includeApiKey = true } = opts;
287073
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";
287074
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" : "";
287075
- 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" : "";
287076
287377
  return `${apiKeyBlock}${webhookBlock}${databaseBlock}`;
287077
287378
  }
287078
287379
  function generateDataSource(orm) {
@@ -287233,8 +287534,7 @@ export async function POST(req: Request): Promise<Response> {
287233
287534
  `;
287234
287535
  }
287235
287536
  function generateAgent() {
287236
- return `import Ablo from '@abloatai/ablo';
287237
- import { schema } from './schema';
287537
+ return `import { sync as ablo } from './sync';
287238
287538
 
287239
287539
  /**
287240
287540
  * An AI "teammate" that works the same synced tasks a human does.
@@ -287244,8 +287544,6 @@ import { schema } from './schema';
287244
287544
  * \`npx ablo logs\`. That's the whole idea: agents and people on one typed,
287245
287545
  * synced dataset.
287246
287546
  */
287247
- const ablo = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
287248
-
287249
287547
  async function main() {
287250
287548
  await ablo.ready();
287251
287549
 
@@ -287263,7 +287561,6 @@ async function main() {
287263
287561
  data: { priority: 10 },
287264
287562
  readAt: snap.stamp,
287265
287563
  onStale: 'reject',
287266
- wait: 'confirmed',
287267
287564
  });
287268
287565
  console.log('prioritized:', urgent.title);
287269
287566
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/cli",
3
- "version": "0.45.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.45.0",
33
+ "@abloatai/transaction": "^0.47.0",
34
34
  "jiti": "^2.7.0",
35
35
  "zod": "^4.4.3",
36
- "@abloatai/humans": "^0.45.0"
36
+ "@abloatai/humans": "^0.47.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@clack/prompts": "^0.11.0",