@abloatai/ablo 0.29.2 → 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 +6 -0
- package/dist/cli.cjs +209 -31
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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
|
+
|
|
3
9
|
## 0.29.2
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/dist/cli.cjs
CHANGED
|
@@ -280036,27 +280036,10 @@ function clearCredential() {
|
|
|
280036
280036
|
return removed;
|
|
280037
280037
|
}
|
|
280038
280038
|
function resolveApiKey(modeOverride) {
|
|
280039
|
-
|
|
280040
|
-
const cfg = readConfig();
|
|
280041
|
-
if (!cfg) return void 0;
|
|
280042
|
-
const entry = cfg.profiles[activeProfileName(cfg)]?.[modeOverride ?? cfg.mode];
|
|
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;
|
|
280046
280040
|
}
|
|
280047
280041
|
function resolveOrgKey(modeOverride) {
|
|
280048
|
-
|
|
280049
|
-
const cfg = readConfig();
|
|
280050
|
-
if (!cfg) return void 0;
|
|
280051
|
-
const mode2 = modeOverride ?? cfg.mode;
|
|
280052
|
-
const order = [.../* @__PURE__ */ new Set([activeProfileName(cfg), DEFAULT_PROFILE, ...Object.keys(cfg.profiles)])];
|
|
280053
|
-
for (const name of order) {
|
|
280054
|
-
const entry = cfg.profiles[name]?.[mode2];
|
|
280055
|
-
if (!entry) continue;
|
|
280056
|
-
if (entry.expiresAt && Date.parse(entry.expiresAt) <= Date.now()) continue;
|
|
280057
|
-
return entry.apiKey;
|
|
280058
|
-
}
|
|
280059
|
-
return void 0;
|
|
280042
|
+
return resolveKey({ purpose: "org-read", mode: modeOverride }).key;
|
|
280060
280043
|
}
|
|
280061
280044
|
function guardActiveProjectKey() {
|
|
280062
280045
|
if (process.env.ABLO_API_KEY) {
|
|
@@ -280068,11 +280051,31 @@ function guardActiveProjectKey() {
|
|
|
280068
280051
|
const available = Object.entries(profiles).filter(([, keys]) => hasKey(keys)).map(([name]) => name);
|
|
280069
280052
|
return { ok: hasKey(profiles[activeProfile]), activeProfile, available };
|
|
280070
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
|
+
}
|
|
280071
280077
|
function resolveEffectiveApiKey(modeOverride, cwd) {
|
|
280072
|
-
|
|
280073
|
-
if (fromProject) return { key: fromProject.key, source: fromProject.source };
|
|
280074
|
-
const key = resolveApiKey(modeOverride);
|
|
280075
|
-
return key ? { key, source: "stored" } : { key: void 0, source: null };
|
|
280078
|
+
return resolveKey({ purpose: "data", mode: modeOverride, scanEnvFiles: true, cwd });
|
|
280076
280079
|
}
|
|
280077
280080
|
function resolvePushPlan() {
|
|
280078
280081
|
const { key, source } = resolveEffectiveApiKey();
|
|
@@ -281145,6 +281148,106 @@ async function migrate(argv) {
|
|
|
281145
281148
|
// src/cli/connect.ts
|
|
281146
281149
|
init_cjs_shims();
|
|
281147
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
|
|
281148
281251
|
var ABLO_PUBLICATION = "ablo_publication";
|
|
281149
281252
|
var ABLO_REPLICATION_ROLE = "ablo_replicator";
|
|
281150
281253
|
function parseConnectArgs(argv) {
|
|
@@ -281374,27 +281477,94 @@ function requireDatabaseUrl(verb) {
|
|
|
281374
281477
|
return dbUrl;
|
|
281375
281478
|
}
|
|
281376
281479
|
async function probeAndReport(dbUrl) {
|
|
281377
|
-
const sql = src_default(dbUrl, { max: 1, prepare: false, onnotice: () => {
|
|
281480
|
+
const sql = src_default(dbUrl, { max: 1, prepare: false, connect_timeout: 10, onnotice: () => {
|
|
281378
281481
|
} });
|
|
281379
281482
|
let items;
|
|
281380
281483
|
try {
|
|
281381
281484
|
items = await probeReadiness(sql);
|
|
281382
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 };
|
|
281383
281489
|
const pg = err ?? {};
|
|
281384
281490
|
console.error(import_picocolors6.default.red(` Couldn't read the database: ${pg.message ?? String(err)}`));
|
|
281385
|
-
await sql.end({ timeout: 2 });
|
|
281386
281491
|
process.exit(1);
|
|
281387
281492
|
}
|
|
281388
281493
|
await sql.end({ timeout: 2 });
|
|
281389
281494
|
for (const item of items) printCheckItem(item);
|
|
281390
|
-
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);
|
|
281391
281557
|
}
|
|
281392
281558
|
async function runCheck() {
|
|
281393
281559
|
const dbUrl = requireDatabaseUrl("--check");
|
|
281394
281560
|
console.log(`
|
|
281395
281561
|
${brand("ablo")} ${import_picocolors6.default.dim("connect --check")} ${import_picocolors6.default.dim("logical-replication readiness")}
|
|
281396
281562
|
`);
|
|
281397
|
-
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;
|
|
281398
281568
|
console.log();
|
|
281399
281569
|
if (failures === 0) {
|
|
281400
281570
|
console.log(` ${import_picocolors6.default.green("\u2713")} Ready \u2014 Ablo can connect and tail this database's WAL.
|
|
@@ -281422,11 +281592,18 @@ async function runRegister() {
|
|
|
281422
281592
|
console.log(`
|
|
281423
281593
|
${brand("ablo")} ${import_picocolors6.default.dim("connect --register")} ${import_picocolors6.default.dim("register this database for replication")}
|
|
281424
281594
|
`);
|
|
281425
|
-
const
|
|
281426
|
-
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) {
|
|
281427
281604
|
console.log(
|
|
281428
281605
|
`
|
|
281429
|
-
${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.")}
|
|
281430
281607
|
`
|
|
281431
281608
|
);
|
|
281432
281609
|
process.exit(1);
|
|
@@ -281549,7 +281726,8 @@ var CONNECT_USAGE = ` ablo connect \u2014 connect your database for the read pa
|
|
|
281549
281726
|
npx ablo connect Print the exact setup SQL for your Postgres
|
|
281550
281727
|
npx ablo connect --tables a,b,c Publish only these tables (default: all tables)
|
|
281551
281728
|
npx ablo connect --role <name> Name the replication role (default: ablo_replicator)
|
|
281552
|
-
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)
|
|
281553
281731
|
npx ablo connect --register Register DATABASE_URL so Ablo replicates it (one self-service step)
|
|
281554
281732
|
npx ablo connect --audit-infra Read-only Stage 5 audit for deprecated Ablo sync tables/types`;
|
|
281555
281733
|
|