@abloatai/ablo 0.29.1 → 0.29.3
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/CHANGELOG.md +12 -0
- package/dist/cli.cjs +214 -23
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.29.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- **`ablo connect` validates from Ablo's network when your machine can't reach the database.** Replication runs from Ablo's infrastructure, not from your laptop, so a database that is IPv6-only (Supabase direct hosts), IP-allowlisted, or behind a VPN can be perfectly replicable even when every local dial fails. When `--check` can't connect at all, it now asks the engine to run the same readiness checklist from its own network instead of reporting a false failure; `--register` no longer blocks on local unreachability and lets the registration preflight decide. A host that _was_ reached and rejected the connection — bad credentials, a TLS error, a Postgres error — still fails locally, because the engine would see exactly the same thing.
|
|
8
|
+
|
|
9
|
+
## 0.29.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- **Switching to a project you never minted a key for no longer locks you out.** Project-management commands — `ablo projects list`, `create`, `rename`, and `use` — are organization-level operations, so they now accept any of the organization's stored keys (the active project's key first, then the `default` profile, then any profile still holding an unexpired key) instead of strictly the active project's key. Selecting a keyless project could previously leave `ablo projects use default` unauthorized — the one command that would undo the switch. Data commands keep the strict resolver, so this permissive fallback never routes a read or write through an unintended project.
|
|
14
|
+
|
|
3
15
|
## 0.29.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/dist/cli.cjs
CHANGED
|
@@ -280036,13 +280036,10 @@ function clearCredential() {
|
|
|
280036
280036
|
return removed;
|
|
280037
280037
|
}
|
|
280038
280038
|
function resolveApiKey(modeOverride) {
|
|
280039
|
-
|
|
280040
|
-
|
|
280041
|
-
|
|
280042
|
-
|
|
280043
|
-
if (!entry) return void 0;
|
|
280044
|
-
if (entry.expiresAt && Date.parse(entry.expiresAt) <= Date.now()) return void 0;
|
|
280045
|
-
return entry.apiKey;
|
|
280039
|
+
return resolveKey({ purpose: "data", mode: modeOverride }).key;
|
|
280040
|
+
}
|
|
280041
|
+
function resolveOrgKey(modeOverride) {
|
|
280042
|
+
return resolveKey({ purpose: "org-read", mode: modeOverride }).key;
|
|
280046
280043
|
}
|
|
280047
280044
|
function guardActiveProjectKey() {
|
|
280048
280045
|
if (process.env.ABLO_API_KEY) {
|
|
@@ -280054,11 +280051,31 @@ function guardActiveProjectKey() {
|
|
|
280054
280051
|
const available = Object.entries(profiles).filter(([, keys]) => hasKey(keys)).map(([name]) => name);
|
|
280055
280052
|
return { ok: hasKey(profiles[activeProfile]), activeProfile, available };
|
|
280056
280053
|
}
|
|
280054
|
+
function resolveKey(policy) {
|
|
280055
|
+
if (policy.scanEnvFiles) {
|
|
280056
|
+
const fromProject = readProjectApiKey(policy.cwd);
|
|
280057
|
+
if (fromProject) return { key: fromProject.key, source: fromProject.source };
|
|
280058
|
+
} else if (process.env.ABLO_API_KEY) {
|
|
280059
|
+
return { key: process.env.ABLO_API_KEY, source: "env" };
|
|
280060
|
+
}
|
|
280061
|
+
const cfg = readConfig();
|
|
280062
|
+
if (!cfg) return { key: void 0, source: null };
|
|
280063
|
+
const mode2 = policy.mode ?? cfg.mode;
|
|
280064
|
+
const profiles = policy.purpose === "org-read" ? (
|
|
280065
|
+
// Active first (a scoped key is preferred when present), then the
|
|
280066
|
+
// org-default, then any remaining profile — deduplicated, order-preserving.
|
|
280067
|
+
[.../* @__PURE__ */ new Set([activeProfileName(cfg), DEFAULT_PROFILE, ...Object.keys(cfg.profiles)])]
|
|
280068
|
+
) : [activeProfileName(cfg)];
|
|
280069
|
+
for (const name of profiles) {
|
|
280070
|
+
const entry = cfg.profiles[name]?.[mode2];
|
|
280071
|
+
if (!entry) continue;
|
|
280072
|
+
if (entry.expiresAt && Date.parse(entry.expiresAt) <= Date.now()) continue;
|
|
280073
|
+
return { key: entry.apiKey, source: "stored" };
|
|
280074
|
+
}
|
|
280075
|
+
return { key: void 0, source: null };
|
|
280076
|
+
}
|
|
280057
280077
|
function resolveEffectiveApiKey(modeOverride, cwd) {
|
|
280058
|
-
|
|
280059
|
-
if (fromProject) return { key: fromProject.key, source: fromProject.source };
|
|
280060
|
-
const key = resolveApiKey(modeOverride);
|
|
280061
|
-
return key ? { key, source: "stored" } : { key: void 0, source: null };
|
|
280078
|
+
return resolveKey({ purpose: "data", mode: modeOverride, scanEnvFiles: true, cwd });
|
|
280062
280079
|
}
|
|
280063
280080
|
function resolvePushPlan() {
|
|
280064
280081
|
const { key, source } = resolveEffectiveApiKey();
|
|
@@ -280254,7 +280271,7 @@ function apiUrl() {
|
|
|
280254
280271
|
return (process.env.ABLO_API_URL ?? ABLO_DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
280255
280272
|
}
|
|
280256
280273
|
function requireKey() {
|
|
280257
|
-
const apiKey =
|
|
280274
|
+
const apiKey = resolveOrgKey();
|
|
280258
280275
|
if (!apiKey) {
|
|
280259
280276
|
console.error(
|
|
280260
280277
|
import_picocolors3.default.red(" No API key.") + import_picocolors3.default.dim(
|
|
@@ -280460,7 +280477,7 @@ async function resolveTarget(opts) {
|
|
|
280460
280477
|
keyEnv,
|
|
280461
280478
|
confirmed,
|
|
280462
280479
|
localProject,
|
|
280463
|
-
mismatches: reconcile({ confirmed, keyEnv, localProject })
|
|
280480
|
+
mismatches: reconcile({ confirmed, keyEnv, localProject, cliMode: getMode() })
|
|
280464
280481
|
};
|
|
280465
280482
|
}
|
|
280466
280483
|
async function confirmFromServer(opts) {
|
|
@@ -280505,10 +280522,9 @@ function confirmedProjectSlug(project) {
|
|
|
280505
280522
|
return project.isDefault ? DEFAULT_PROJECT_SLUG : project.slug;
|
|
280506
280523
|
}
|
|
280507
280524
|
function reconcile(input) {
|
|
280508
|
-
const { confirmed, keyEnv, localProject } = input;
|
|
280525
|
+
const { confirmed, keyEnv, localProject, cliMode } = input;
|
|
280509
280526
|
const mismatches = [];
|
|
280510
280527
|
const realEnv = confirmed?.environment ?? keyEnv;
|
|
280511
|
-
const cliMode = getMode();
|
|
280512
280528
|
if (realEnv && realEnv !== cliMode) {
|
|
280513
280529
|
mismatches.push({ kind: "environment", keyEnv: realEnv, cliMode });
|
|
280514
280530
|
}
|
|
@@ -281132,6 +281148,106 @@ async function migrate(argv) {
|
|
|
281132
281148
|
// src/cli/connect.ts
|
|
281133
281149
|
init_cjs_shims();
|
|
281134
281150
|
var import_picocolors6 = __toESM(require_picocolors(), 1);
|
|
281151
|
+
|
|
281152
|
+
// src/cli/remoteValidation.ts
|
|
281153
|
+
init_cjs_shims();
|
|
281154
|
+
var DIAL_FAILURE_CODES = /* @__PURE__ */ new Set([
|
|
281155
|
+
"ENOTFOUND",
|
|
281156
|
+
"EAI_AGAIN",
|
|
281157
|
+
"ECONNREFUSED",
|
|
281158
|
+
"ECONNRESET",
|
|
281159
|
+
"ETIMEDOUT",
|
|
281160
|
+
"ENETUNREACH",
|
|
281161
|
+
"EHOSTUNREACH",
|
|
281162
|
+
"CONNECT_TIMEOUT"
|
|
281163
|
+
]);
|
|
281164
|
+
function dialFailureReason(err) {
|
|
281165
|
+
if (err === null || typeof err !== "object") return null;
|
|
281166
|
+
const coded = err;
|
|
281167
|
+
const code = typeof coded.code === "string" ? coded.code : null;
|
|
281168
|
+
if (code && DIAL_FAILURE_CODES.has(code)) {
|
|
281169
|
+
return typeof coded.message === "string" && coded.message.length > 0 ? coded.message : code;
|
|
281170
|
+
}
|
|
281171
|
+
if (Array.isArray(coded.errors)) {
|
|
281172
|
+
for (const member of coded.errors) {
|
|
281173
|
+
const reason = dialFailureReason(member);
|
|
281174
|
+
if (reason) return reason;
|
|
281175
|
+
}
|
|
281176
|
+
}
|
|
281177
|
+
return null;
|
|
281178
|
+
}
|
|
281179
|
+
function validateEndpoint(baseUrl2) {
|
|
281180
|
+
return `${baseUrl2.replace(/\/+$/, "")}/api/v1/datasources/validate`;
|
|
281181
|
+
}
|
|
281182
|
+
function describeRemoteFailure(failure) {
|
|
281183
|
+
switch (failure.item) {
|
|
281184
|
+
case "wal_level":
|
|
281185
|
+
return {
|
|
281186
|
+
label: failure.actual ? `wal_level is ${failure.actual} (need logical)` : `wal_level must be logical`,
|
|
281187
|
+
fix: failure.fix
|
|
281188
|
+
};
|
|
281189
|
+
case "publication":
|
|
281190
|
+
return { label: "the Ablo publication does not exist", fix: failure.fix };
|
|
281191
|
+
case "replication_role":
|
|
281192
|
+
return { label: "the DATABASE_URL role lacks the REPLICATION attribute", fix: failure.fix };
|
|
281193
|
+
case "replica_identity":
|
|
281194
|
+
return {
|
|
281195
|
+
label: `published tables cannot replicate UPDATE/DELETE${failure.actual ? ` (${failure.actual})` : ""}`,
|
|
281196
|
+
fix: failure.fix
|
|
281197
|
+
};
|
|
281198
|
+
default:
|
|
281199
|
+
return { label: failure.item, fix: failure.fix };
|
|
281200
|
+
}
|
|
281201
|
+
}
|
|
281202
|
+
function parseWireFailures(value) {
|
|
281203
|
+
if (!Array.isArray(value)) return [];
|
|
281204
|
+
const failures = [];
|
|
281205
|
+
for (const entry of value) {
|
|
281206
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
281207
|
+
const { item, actual, fix } = entry;
|
|
281208
|
+
if (typeof item !== "string" || typeof fix !== "string") continue;
|
|
281209
|
+
failures.push({ item, fix, ...typeof actual === "string" ? { actual } : {} });
|
|
281210
|
+
}
|
|
281211
|
+
return failures;
|
|
281212
|
+
}
|
|
281213
|
+
async function requestRemoteValidation(input) {
|
|
281214
|
+
const doFetch = input.fetchImpl ?? fetch;
|
|
281215
|
+
let res;
|
|
281216
|
+
try {
|
|
281217
|
+
res = await doFetch(validateEndpoint(input.apiUrl), {
|
|
281218
|
+
method: "POST",
|
|
281219
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${input.apiKey}` },
|
|
281220
|
+
body: JSON.stringify({ connectionString: input.connectionString })
|
|
281221
|
+
});
|
|
281222
|
+
} catch (err) {
|
|
281223
|
+
return {
|
|
281224
|
+
ok: false,
|
|
281225
|
+
status: 0,
|
|
281226
|
+
message: `couldn't reach ${input.apiUrl}: ${err instanceof Error ? err.message : String(err)}`
|
|
281227
|
+
};
|
|
281228
|
+
}
|
|
281229
|
+
const body = await res.json().catch(() => ({}));
|
|
281230
|
+
if (!res.ok) {
|
|
281231
|
+
const code = typeof body.code === "string" ? body.code : body.error?.code;
|
|
281232
|
+
const message = typeof body.message === "string" ? body.message : typeof body.error?.message === "string" ? body.error.message : `HTTP ${res.status}`;
|
|
281233
|
+
return {
|
|
281234
|
+
ok: false,
|
|
281235
|
+
status: res.status,
|
|
281236
|
+
message,
|
|
281237
|
+
...typeof code === "string" ? { code } : {}
|
|
281238
|
+
};
|
|
281239
|
+
}
|
|
281240
|
+
const reachable = body.reachable === true;
|
|
281241
|
+
return {
|
|
281242
|
+
ok: true,
|
|
281243
|
+
reachable,
|
|
281244
|
+
ready: body.ready === true,
|
|
281245
|
+
...typeof body.reason === "string" ? { reason: body.reason } : {},
|
|
281246
|
+
failures: parseWireFailures(body.failures)
|
|
281247
|
+
};
|
|
281248
|
+
}
|
|
281249
|
+
|
|
281250
|
+
// src/cli/connect.ts
|
|
281135
281251
|
var ABLO_PUBLICATION = "ablo_publication";
|
|
281136
281252
|
var ABLO_REPLICATION_ROLE = "ablo_replicator";
|
|
281137
281253
|
function parseConnectArgs(argv) {
|
|
@@ -281361,27 +281477,94 @@ function requireDatabaseUrl(verb) {
|
|
|
281361
281477
|
return dbUrl;
|
|
281362
281478
|
}
|
|
281363
281479
|
async function probeAndReport(dbUrl) {
|
|
281364
|
-
const sql = src_default(dbUrl, { max: 1, prepare: false, onnotice: () => {
|
|
281480
|
+
const sql = src_default(dbUrl, { max: 1, prepare: false, connect_timeout: 10, onnotice: () => {
|
|
281365
281481
|
} });
|
|
281366
281482
|
let items;
|
|
281367
281483
|
try {
|
|
281368
281484
|
items = await probeReadiness(sql);
|
|
281369
281485
|
} catch (err) {
|
|
281486
|
+
await sql.end({ timeout: 2 }).catch(() => void 0);
|
|
281487
|
+
const dial = dialFailureReason(err);
|
|
281488
|
+
if (dial) return { kind: "no-dial", reason: dial };
|
|
281370
281489
|
const pg = err ?? {};
|
|
281371
281490
|
console.error(import_picocolors6.default.red(` Couldn't read the database: ${pg.message ?? String(err)}`));
|
|
281372
|
-
await sql.end({ timeout: 2 });
|
|
281373
281491
|
process.exit(1);
|
|
281374
281492
|
}
|
|
281375
281493
|
await sql.end({ timeout: 2 });
|
|
281376
281494
|
for (const item of items) printCheckItem(item);
|
|
281377
|
-
return items.filter((i) => !i.ok).length;
|
|
281495
|
+
return { kind: "probed", failures: items.filter((i) => !i.ok).length };
|
|
281496
|
+
}
|
|
281497
|
+
async function runRemoteCheck(dbUrl, localReason) {
|
|
281498
|
+
console.log(
|
|
281499
|
+
` This machine can't reach the database (${import_picocolors6.default.dim(localReason)}).
|
|
281500
|
+
That is not the verdict: replication runs from Ablo's infrastructure, not from here.
|
|
281501
|
+
Asking Ablo to check the database from its side\u2026
|
|
281502
|
+
`
|
|
281503
|
+
);
|
|
281504
|
+
const apiKey = resolveApiKey();
|
|
281505
|
+
if (!apiKey) {
|
|
281506
|
+
console.error(
|
|
281507
|
+
import_picocolors6.default.red(` The engine-side check needs an API key, and none was found.`) + import_picocolors6.default.dim(
|
|
281508
|
+
` Run ${import_picocolors6.default.bold("ablo login")} (or set ${import_picocolors6.default.bold("ABLO_API_KEY")}), then re-run ${import_picocolors6.default.bold("ablo connect --check")}.`
|
|
281509
|
+
)
|
|
281510
|
+
);
|
|
281511
|
+
process.exit(1);
|
|
281512
|
+
}
|
|
281513
|
+
const apiUrl2 = (process.env.ABLO_API_URL ?? DEFAULT_URL).replace(/\/+$/, "");
|
|
281514
|
+
const result = await requestRemoteValidation({ apiUrl: apiUrl2, apiKey, connectionString: dbUrl });
|
|
281515
|
+
if (!result.ok) {
|
|
281516
|
+
console.error(import_picocolors6.default.red(` The engine-side check failed: ${result.message}`));
|
|
281517
|
+
if (result.code === "forbidden") {
|
|
281518
|
+
console.error(
|
|
281519
|
+
import_picocolors6.default.dim(` Checking a database from Ablo's side needs a ${import_picocolors6.default.bold("secret")} key (sk_\u2026). Run ${import_picocolors6.default.bold("ablo login")} for one.`)
|
|
281520
|
+
);
|
|
281521
|
+
}
|
|
281522
|
+
console.error();
|
|
281523
|
+
process.exit(1);
|
|
281524
|
+
}
|
|
281525
|
+
if (!result.reachable) {
|
|
281526
|
+
console.error(
|
|
281527
|
+
` ${import_picocolors6.default.red("\u2717")} Ablo's infrastructure can't reach it either${result.reason ? ` ${import_picocolors6.default.dim(`(${result.reason})`)}` : ""}.`
|
|
281528
|
+
);
|
|
281529
|
+
console.error(
|
|
281530
|
+
import_picocolors6.default.dim(
|
|
281531
|
+
` Replication needs a host Ablo's servers can dial \u2014 a localhost or private-network
|
|
281532
|
+
Postgres can't be replicated by the hosted service. Use a reachable host (or a tunnel),
|
|
281533
|
+
or the signed ${import_picocolors6.default.bold("dataSource()")} endpoint fallback.
|
|
281534
|
+
`
|
|
281535
|
+
)
|
|
281536
|
+
);
|
|
281537
|
+
process.exit(1);
|
|
281538
|
+
}
|
|
281539
|
+
for (const failure of result.failures) {
|
|
281540
|
+
const { label, fix } = describeRemoteFailure(failure);
|
|
281541
|
+
printCheckItem({ ok: false, label, fix });
|
|
281542
|
+
}
|
|
281543
|
+
console.log();
|
|
281544
|
+
if (result.ready) {
|
|
281545
|
+
console.log(
|
|
281546
|
+
` ${import_picocolors6.default.green("\u2713")} Ready \u2014 checked from Ablo's infrastructure. Ablo can connect and tail this database's WAL.
|
|
281547
|
+
`
|
|
281548
|
+
);
|
|
281549
|
+
process.exit(0);
|
|
281550
|
+
}
|
|
281551
|
+
const count = result.failures.length;
|
|
281552
|
+
console.log(
|
|
281553
|
+
` ${import_picocolors6.default.red(`${count} item${count === 1 ? "" : "s"} to fix`)} ${import_picocolors6.default.dim(`\u2014 found by Ablo's infrastructure. Apply the fixes above, then re-run ${import_picocolors6.default.bold("ablo connect --check")}.`)}
|
|
281554
|
+
`
|
|
281555
|
+
);
|
|
281556
|
+
process.exit(1);
|
|
281378
281557
|
}
|
|
281379
281558
|
async function runCheck() {
|
|
281380
281559
|
const dbUrl = requireDatabaseUrl("--check");
|
|
281381
281560
|
console.log(`
|
|
281382
281561
|
${brand("ablo")} ${import_picocolors6.default.dim("connect --check")} ${import_picocolors6.default.dim("logical-replication readiness")}
|
|
281383
281562
|
`);
|
|
281384
|
-
const
|
|
281563
|
+
const outcome = await probeAndReport(dbUrl);
|
|
281564
|
+
if (outcome.kind === "no-dial") {
|
|
281565
|
+
return runRemoteCheck(dbUrl, outcome.reason);
|
|
281566
|
+
}
|
|
281567
|
+
const failures = outcome.failures;
|
|
281385
281568
|
console.log();
|
|
281386
281569
|
if (failures === 0) {
|
|
281387
281570
|
console.log(` ${import_picocolors6.default.green("\u2713")} Ready \u2014 Ablo can connect and tail this database's WAL.
|
|
@@ -281409,11 +281592,18 @@ async function runRegister() {
|
|
|
281409
281592
|
console.log(`
|
|
281410
281593
|
${brand("ablo")} ${import_picocolors6.default.dim("connect --register")} ${import_picocolors6.default.dim("register this database for replication")}
|
|
281411
281594
|
`);
|
|
281412
|
-
const
|
|
281413
|
-
if (
|
|
281595
|
+
const outcome = await probeAndReport(dbUrl);
|
|
281596
|
+
if (outcome.kind === "no-dial") {
|
|
281597
|
+
console.log(
|
|
281598
|
+
` This machine can't reach the database (${import_picocolors6.default.dim(outcome.reason)}) \u2014 continuing anyway.
|
|
281599
|
+
Replication runs from Ablo's infrastructure, which validates the database during
|
|
281600
|
+
registration and refuses with the full checklist if it isn't ready.
|
|
281601
|
+
`
|
|
281602
|
+
);
|
|
281603
|
+
} else if (outcome.failures > 0) {
|
|
281414
281604
|
console.log(
|
|
281415
281605
|
`
|
|
281416
|
-
${import_picocolors6.default.red(`${failures} item${failures === 1 ? "" : "s"} to fix`)} ${import_picocolors6.default.dim("\u2014 a database that isn\u2019t replication-ready can\u2019t stream. Fix the above, then re-run.")}
|
|
281606
|
+
${import_picocolors6.default.red(`${outcome.failures} item${outcome.failures === 1 ? "" : "s"} to fix`)} ${import_picocolors6.default.dim("\u2014 a database that isn\u2019t replication-ready can\u2019t stream. Fix the above, then re-run.")}
|
|
281417
281607
|
`
|
|
281418
281608
|
);
|
|
281419
281609
|
process.exit(1);
|
|
@@ -281536,7 +281726,8 @@ var CONNECT_USAGE = ` ablo connect \u2014 connect your database for the read pa
|
|
|
281536
281726
|
npx ablo connect Print the exact setup SQL for your Postgres
|
|
281537
281727
|
npx ablo connect --tables a,b,c Publish only these tables (default: all tables)
|
|
281538
281728
|
npx ablo connect --role <name> Name the replication role (default: ablo_replicator)
|
|
281539
|
-
npx ablo connect --check Validate DATABASE_URL is replication-ready
|
|
281729
|
+
npx ablo connect --check Validate DATABASE_URL is replication-ready (runs from Ablo's
|
|
281730
|
+
infrastructure when this machine can't reach the host)
|
|
281540
281731
|
npx ablo connect --register Register DATABASE_URL so Ablo replicates it (one self-service step)
|
|
281541
281732
|
npx ablo connect --audit-infra Read-only Stage 5 audit for deprecated Ablo sync tables/types`;
|
|
281542
281733
|
|