@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.
- package/dist/cli.cjs +412 -115
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
4691
|
-
const
|
|
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
|
|
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
|
|
4696
|
-
`ALTER DEFAULT PRIVILEGES IN SCHEMA
|
|
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
|
|
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(
|
|
4721
|
+
`CREATE PUBLICATION ${quoteIdent(publication)} ${publicationTarget};`,
|
|
4705
4722
|
// 3. A least-privilege role: it can stream replication and SELECT the
|
|
4706
|
-
// published tables,
|
|
4707
|
-
|
|
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
|
|
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
|
|
4735
|
-
`ALTER DEFAULT PRIVILEGES IN SCHEMA
|
|
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
|
|
4740
|
-
`GRANT SELECT, INSERT, UPDATE ON TABLE
|
|
4741
|
-
`REVOKE DELETE ON TABLE
|
|
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(
|
|
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(
|
|
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
|
-
[
|
|
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 =
|
|
4809
|
-
[
|
|
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.
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
|
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: {
|
|
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}.
|
|
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
|
|
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({
|
|
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
|
|
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
|
-
|
|
5958
|
-
|
|
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
|
|
6000
|
-
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(
|
|
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
|
|
6527
|
-
const
|
|
6528
|
-
const
|
|
6529
|
-
|
|
6530
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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)(
|
|
283607
|
-
|
|
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
|
-
|
|
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(
|
|
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({
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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({
|
|
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 ? {
|
|
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 ? {
|
|
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(
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
33
|
+
"@abloatai/transaction": "^0.47.0",
|
|
34
34
|
"jiti": "^2.7.0",
|
|
35
35
|
"zod": "^4.4.3",
|
|
36
|
-
"@abloatai/humans": "^0.
|
|
36
|
+
"@abloatai/humans": "^0.47.0"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@clack/prompts": "^0.11.0",
|