@abloatai/ablo 0.24.0 → 0.25.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/CHANGELOG.md +13 -0
- package/README.md +42 -33
- package/dist/cli.cjs +267 -37
- package/docs/data-sources.md +12 -5
- package/llms.txt +4 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.25.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- ca30064: Logical replication is now the documented default storage path, with self-service data-source registration from the CLI.
|
|
8
|
+
- **`ablo connect --register`** — registers your database as an Ablo data source over logical replication in one step: it runs the same pre-flight replication probe `ablo connect` uses (server reachable, `REPLICATION` privilege, `wal_level=logical`, publication/slot creatable), and on success `POST`s the connection to the engine's `/v1/datasources`. The `ek_`-authed call scopes the source to your org automatically; the password is stored decomposed as a secret, never echoed back. This is the "registration is the enable" path — there is no separate tier or flag to pick.
|
|
9
|
+
- **`ablo init` leads with logical replication** — the default storage mode is now `replication` (was `endpoint`); the generated env + next-steps point at `ablo connect` / `ablo connect --register`. The signed-endpoint and direct modes remain as the explicit fallback / legacy options.
|
|
10
|
+
- **`ablo status`** — a data-plane diagnostic that probes whether your registered source is reachable and replicating (failure-only reporting, so it never falsely reports healthy).
|
|
11
|
+
- **`ablo push`** — minor guard/UX refinements (deploy-target clarity).
|
|
12
|
+
- **Docs (README, `docs/data-sources.md`, `llms.txt`)** rewritten to the one-path model: Ablo consumes your database's logical-replication stream and your application owns the write path. The security wording is precise — a logical-replication connection requires the `REPLICATION` privilege (it is **not** a read-only SQL account), so reviews are not misled by a "read-only" claim.
|
|
13
|
+
|
|
14
|
+
No breaking changes to the SDK runtime API. The server-side WAL read cutover these CLI changes support ships by deploying the engine, not this package.
|
|
15
|
+
|
|
3
16
|
## 0.24.0
|
|
4
17
|
|
|
5
18
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -55,11 +55,13 @@ claims are visible while the work is still in progress.
|
|
|
55
55
|
[Version History & Migration Guide](./docs/migration.md)
|
|
56
56
|
|
|
57
57
|
It works with the auth and database you already have. **In production, your
|
|
58
|
-
database is the system of record.** Ablo is the
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
database is the system of record.** Ablo is the sync + coordination layer on top
|
|
59
|
+
of it: it consumes your Postgres' logical-replication stream, scopes realtime
|
|
60
|
+
data to *sync groups* from your own identity, and your application keeps owning
|
|
61
|
+
the write path — every row lives in your Postgres. (Trying Ablo with no database
|
|
62
|
+
yet? A **sandbox** `sk_test` key holds throwaway **test data** — like Stripe test
|
|
63
|
+
mode — so you can explore before pointing it at your Postgres. Test-mode only; in
|
|
64
|
+
production every row lives in your database.)
|
|
63
65
|
|
|
64
66
|
**Built for** collaborative editors, AI agent workflows, and internal tools —
|
|
65
67
|
anywhere people and agents change shared state and everyone has to see it live.
|
|
@@ -92,11 +94,11 @@ what's configured at any time.
|
|
|
92
94
|
|
|
93
95
|
**Keys & runtime.** Ablo needs Node 24+ and TypeScript 5+. Keys come in two of
|
|
94
96
|
*your* environments — `sk_test_` and `sk_live_`, like Stripe — and `ablo login`
|
|
95
|
-
mints both. Keep the key
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
[Data Source endpoint](./docs/data-sources.md) instead
|
|
97
|
+
mints both. Keep the key in trusted server runtimes only. In the browser,
|
|
98
|
+
`<AbloProvider>` authenticates with the signed-in user's session — never the raw
|
|
99
|
+
key. Your database is connected once, out of band, via `npx ablo connect`
|
|
100
|
+
(logical replication); if it can't grant a replication role, expose a signed
|
|
101
|
+
[Data Source endpoint](./docs/data-sources.md) instead.
|
|
100
102
|
|
|
101
103
|
For production (React, an existing backend, Data Source, agents), the
|
|
102
104
|
[Integration Guide](./docs/integration-guide.md) is the deeper map.
|
|
@@ -175,8 +177,9 @@ const schema = defineSchema({
|
|
|
175
177
|
const ablo = Ablo({
|
|
176
178
|
schema,
|
|
177
179
|
apiKey: process.env.ABLO_API_KEY, // written to .env.local by `npx ablo push`
|
|
178
|
-
databaseUrl: process.env.DATABASE_URL, // your Postgres, passed explicitly — rows live here
|
|
179
180
|
});
|
|
181
|
+
// Your Postgres is connected once via `npx ablo connect` (logical replication) —
|
|
182
|
+
// not passed here. Ablo tails its WAL; your rows never leave your database.
|
|
180
183
|
|
|
181
184
|
await ablo.ready();
|
|
182
185
|
|
|
@@ -403,10 +406,12 @@ each other's changes in real time — that's the default, not a feature you turn
|
|
|
403
406
|
- `useAblo(...)` gives React clients the live row, kept current automatically.
|
|
404
407
|
- `ablo.<model>.claim({ id })` / `claim.state({ id })` / `claim.queue({ id })` let humans and agents coordinate (and observe) active work on a row — and the line waiting behind it — before a write lands.
|
|
405
408
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
409
|
+
Your application owns the write path: writes that land in your database — through
|
|
410
|
+
your own backend, your existing API, any path — are tailed off the WAL and fanned
|
|
411
|
+
out to connected clients automatically (the Electric model). The SDK model
|
|
412
|
+
methods (`ablo.<model>.create/update/delete`) and the HTTP write endpoint are the
|
|
413
|
+
*JavaScript/agent* ergonomic surface — they participate in claims and confirmation
|
|
414
|
+
— but they are not a requirement for your normal writes to show up.
|
|
410
415
|
|
|
411
416
|
## HTTP Writes
|
|
412
417
|
|
|
@@ -429,35 +434,39 @@ curl https://api.abloatai.com/v1/commits \
|
|
|
429
434
|
|
|
430
435
|
## Your Database
|
|
431
436
|
|
|
432
|
-
In production, every schema model is backed by **your own database** — Ablo
|
|
433
|
-
|
|
437
|
+
In production, every schema model is backed by **your own database** — Ablo
|
|
438
|
+
**consumes** it, never owns it. The model is Electric's (and PowerSync's, and
|
|
439
|
+
Zero's): Ablo consumes your Postgres' logical-replication stream and fans the
|
|
440
|
+
changes out; **your application continues to own the write path.** Two ways Ablo
|
|
441
|
+
connects:
|
|
434
442
|
|
|
435
|
-
| | How Ablo
|
|
443
|
+
| | How Ablo connects to your Postgres | Use when |
|
|
436
444
|
| --- | --- | --- |
|
|
437
|
-
| **
|
|
438
|
-
| **Signed endpoint** | Your app exposes one route built from an ORM adapter (`prismaDataSource` / `drizzleDataSource`); Ablo sends signed
|
|
445
|
+
| **Logical replication** (primary) | `npx ablo connect` prints the setup SQL (`wal_level=logical`, a publication, a `REPLICATION` role); `npx ablo connect --register` tells Ablo to replicate it. Ablo tails the WAL — it never runs DDL on, writes to, owns, or migrates your database. | Your database can grant a `REPLICATION` role (most can). |
|
|
446
|
+
| **Signed endpoint** (fallback) | Your app exposes one route built from an ORM adapter (`prismaDataSource` / `drizzleDataSource`); Ablo sends signed requests and your app touches its own database. Needs no database configuration. | Your database **can't** grant a replication role (a locked-down managed DB). |
|
|
439
447
|
|
|
440
|
-
(No database yet?
|
|
441
|
-
|
|
442
|
-
|
|
448
|
+
(No database yet? A **sandbox** `sk_test` key lets you try Ablo's full API and
|
|
449
|
+
coordination with throwaway **test data** — like Stripe test mode — before you
|
|
450
|
+
connect your own Postgres. Test-mode only: in production your rows always live in
|
|
451
|
+
your database and Ablo holds just the transaction log, never your data.)
|
|
443
452
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
453
|
+
Your database is the system of record. The old `databaseUrl` dial-in (Ablo
|
|
454
|
+
holding a read/write connection) is **deprecated and being removed** — connect
|
|
455
|
+
via logical replication instead. See
|
|
456
|
+
[Connect Your Database](./docs/data-sources.md).
|
|
447
457
|
|
|
448
458
|
## Configuration
|
|
449
459
|
|
|
450
|
-
`Ablo({ ... })` takes your schema
|
|
451
|
-
|
|
452
|
-
[Data Source endpoint](./docs/data-sources.md)
|
|
453
|
-
|
|
454
|
-
sandbox.) Every other option has correct defaults:
|
|
460
|
+
`Ablo({ ... })` takes your schema and your key. Your database is connected
|
|
461
|
+
**out of band** — once, via `npx ablo connect` (logical replication) or a signed
|
|
462
|
+
[Data Source endpoint](./docs/data-sources.md) — not through the constructor.
|
|
463
|
+
Every other option has correct defaults:
|
|
455
464
|
|
|
456
465
|
| Option | Type | Default | Purpose |
|
|
457
466
|
| --- | --- | --- | --- |
|
|
458
467
|
| `schema` | `Schema` | — (required) | Typed model proxies (`ablo.<model>.*`) |
|
|
459
468
|
| `apiKey` | `string \| ApiKeySetter \| null` | `process.env.ABLO_API_KEY` | Server key — a string, or an async function for rotation |
|
|
460
|
-
| `databaseUrl` | `string \| null` | `—` |
|
|
469
|
+
| `databaseUrl` | `string \| null` | `—` | **Deprecated — being removed.** The old dial-in (Ablo holding a read/write connection to your DB). Connect via `npx ablo connect` (logical replication) instead; Ablo tails your WAL rather than dialing in. See [Connect Your Database](./docs/data-sources.md). |
|
|
461
470
|
|
|
462
471
|
Keep `apiKey` in trusted server runtimes. In the browser, `<AbloProvider>`
|
|
463
472
|
authenticates with the signed-in user's session; the raw-key path is gated
|
|
@@ -512,7 +521,7 @@ contract; there are no retry or timeout knobs to tune.
|
|
|
512
521
|
- [React](./docs/react.md) — `<AbloProvider>`, `useAblo`, presence, status, and bootstrap gating.
|
|
513
522
|
- [Coordination](./docs/coordination.md) — `claim` / `claim.state` / `claim.queue` / `claim.release` reference: hold a row across slow agent work, and observe the line waiting behind it.
|
|
514
523
|
- [Client Behavior](./docs/client-behavior.md) — options, errors, retries, timeouts, and public imports.
|
|
515
|
-
- [Connect Your Database](./docs/data-sources.md) — connect your Postgres by
|
|
524
|
+
- [Connect Your Database](./docs/data-sources.md) — connect your Postgres by logical replication (`npx ablo connect`) or, as a fallback, a signed endpoint; your database is the system of record either way.
|
|
516
525
|
- [Existing Python Backend](./docs/examples/existing-python-backend.md) — migrate existing Python endpoints to multiplayer and agent-safe writes gradually.
|
|
517
526
|
- [AI SDK Tool](./docs/examples/ai-sdk-tool.md) — use Ablo inside an AI SDK tool call.
|
|
518
527
|
- [Server Agent](./docs/examples/server-agent.md) — schema-backed worker.
|
package/dist/cli.cjs
CHANGED
|
@@ -212120,17 +212120,19 @@ var require_commonjs2 = __commonJS({
|
|
|
212120
212120
|
}
|
|
212121
212121
|
function expand_(str, max, isTop) {
|
|
212122
212122
|
const expansions = [];
|
|
212123
|
-
|
|
212124
|
-
|
|
212125
|
-
|
|
212126
|
-
|
|
212127
|
-
|
|
212128
|
-
|
|
212129
|
-
|
|
212130
|
-
|
|
212131
|
-
|
|
212123
|
+
for (; ; ) {
|
|
212124
|
+
const m2 = (0, balanced_match_1.balanced)("{", "}", str);
|
|
212125
|
+
if (!m2)
|
|
212126
|
+
return [str];
|
|
212127
|
+
const pre = m2.pre;
|
|
212128
|
+
if (/\$$/.test(m2.pre)) {
|
|
212129
|
+
const post2 = m2.post.length ? expand_(m2.post, max, false) : [""];
|
|
212130
|
+
for (let k3 = 0; k3 < post2.length && k3 < max; k3++) {
|
|
212131
|
+
const expansion = pre + "{" + m2.body + "}" + post2[k3];
|
|
212132
|
+
expansions.push(expansion);
|
|
212133
|
+
}
|
|
212134
|
+
return expansions;
|
|
212132
212135
|
}
|
|
212133
|
-
} else {
|
|
212134
212136
|
const isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m2.body);
|
|
212135
212137
|
const isAlphaSequence = /^[a-zA-Z]\.\.[a-zA-Z](?:\.\.-?\d+)?$/.test(m2.body);
|
|
212136
212138
|
const isSequence = isNumericSequence || isAlphaSequence;
|
|
@@ -212138,10 +212140,12 @@ var require_commonjs2 = __commonJS({
|
|
|
212138
212140
|
if (!isSequence && !isOptions) {
|
|
212139
212141
|
if (m2.post.match(/,(?!,).*\}/)) {
|
|
212140
212142
|
str = m2.pre + "{" + m2.body + escClose + m2.post;
|
|
212141
|
-
|
|
212143
|
+
isTop = true;
|
|
212144
|
+
continue;
|
|
212142
212145
|
}
|
|
212143
212146
|
return [str];
|
|
212144
212147
|
}
|
|
212148
|
+
const post = m2.post.length ? expand_(m2.post, max, false) : [""];
|
|
212145
212149
|
let n;
|
|
212146
212150
|
if (isSequence) {
|
|
212147
212151
|
n = m2.body.split(/\.\./);
|
|
@@ -212205,8 +212209,8 @@ var require_commonjs2 = __commonJS({
|
|
|
212205
212209
|
}
|
|
212206
212210
|
}
|
|
212207
212211
|
}
|
|
212212
|
+
return expansions;
|
|
212208
212213
|
}
|
|
212209
|
-
return expansions;
|
|
212210
212214
|
}
|
|
212211
212215
|
}
|
|
212212
212216
|
});
|
|
@@ -280268,6 +280272,12 @@ async function push(argv) {
|
|
|
280268
280272
|
` One or more synced tables don't have FORCE ROW LEVEL SECURITY. Run ${import_picocolors3.default.bold("npx ablo migrate")} to (re)apply the tenant policies, then re-push.`
|
|
280269
280273
|
)
|
|
280270
280274
|
);
|
|
280275
|
+
} else if (code === "capability_scope_denied" || code === "capability_invalid") {
|
|
280276
|
+
console.error(
|
|
280277
|
+
import_picocolors3.default.dim(
|
|
280278
|
+
` This is a ${import_picocolors3.default.bold("database privilege")} error (Postgres 42501 / row-level security), not a key scope \u2014 a different API key won't help. The role behind this org's database can't write the target. Provision a writable, RLS-scoped role with ${import_picocolors3.default.bold("npx ablo migrate")}, or check the org's database registration. See docs/plans/read-path-logical-replication-vs-hosting.md.`
|
|
280279
|
+
)
|
|
280280
|
+
);
|
|
280271
280281
|
} else if (args.apiKey != null && classifyCredentialKind(args.apiKey) === "restricted") {
|
|
280272
280282
|
console.error(
|
|
280273
280283
|
import_picocolors3.default.dim(
|
|
@@ -280464,6 +280474,8 @@ var ABLO_PUBLICATION = "ablo_publication";
|
|
|
280464
280474
|
var ABLO_REPLICATION_ROLE = "ablo_replicator";
|
|
280465
280475
|
function parseConnectArgs(argv) {
|
|
280466
280476
|
let check2 = false;
|
|
280477
|
+
let register = false;
|
|
280478
|
+
let auditInfra = false;
|
|
280467
280479
|
let tables = [];
|
|
280468
280480
|
let role = ABLO_REPLICATION_ROLE;
|
|
280469
280481
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -280472,6 +280484,12 @@ function parseConnectArgs(argv) {
|
|
|
280472
280484
|
case "--check":
|
|
280473
280485
|
check2 = true;
|
|
280474
280486
|
break;
|
|
280487
|
+
case "--register":
|
|
280488
|
+
register = true;
|
|
280489
|
+
break;
|
|
280490
|
+
case "--audit-infra":
|
|
280491
|
+
auditInfra = true;
|
|
280492
|
+
break;
|
|
280475
280493
|
case "--tables": {
|
|
280476
280494
|
const value = argv[++i] ?? "";
|
|
280477
280495
|
tables = value.split(",").map((t) => t.trim()).filter((t) => t.length > 0);
|
|
@@ -280484,7 +280502,7 @@ function parseConnectArgs(argv) {
|
|
|
280484
280502
|
throw new AbloValidationError(`unknown flag: ${arg}`, { code: "cli_invalid_arguments" });
|
|
280485
280503
|
}
|
|
280486
280504
|
}
|
|
280487
|
-
return { check: check2, tables, role };
|
|
280505
|
+
return { check: check2, register, auditInfra, tables, role };
|
|
280488
280506
|
}
|
|
280489
280507
|
function quoteIdent(id) {
|
|
280490
280508
|
return `"${id.replace(/"/g, '""')}"`;
|
|
@@ -280508,12 +280526,12 @@ function connectSetupSql(input) {
|
|
|
280508
280526
|
function printConnectRecipe(args) {
|
|
280509
280527
|
const sql = connectSetupSql({ tables: args.tables, role: args.role });
|
|
280510
280528
|
console.log(`
|
|
280511
|
-
${brand("ablo")} ${import_picocolors5.default.dim("connect")} ${import_picocolors5.default.dim("logical replication \u2014 the
|
|
280529
|
+
${brand("ablo")} ${import_picocolors5.default.dim("connect")} ${import_picocolors5.default.dim("logical replication \u2014 the read path (your writes stay on your own backend)")}
|
|
280512
280530
|
`);
|
|
280513
280531
|
console.log(
|
|
280514
|
-
` Ablo
|
|
280515
|
-
schema. Your app
|
|
280516
|
-
Run this once against your Postgres ${import_picocolors5.default.dim("(as a superuser /
|
|
280532
|
+
` Ablo consumes your write-ahead log (WAL) via logical replication and ${import_picocolors5.default.bold("never")} runs DDL,
|
|
280533
|
+
owns, hosts, or migrates your schema. Your app continues to own the write path \u2014 Ablo tails
|
|
280534
|
+
the changes and serves them as live shapes. Run this once against your Postgres ${import_picocolors5.default.dim("(as a superuser / DB owner)")}:
|
|
280517
280535
|
`
|
|
280518
280536
|
);
|
|
280519
280537
|
console.log(` ${import_picocolors5.default.bold("1.")} Enable logical decoding ${import_picocolors5.default.dim("(then RESTART Postgres \u2014 wal_level is not reloadable)")}`);
|
|
@@ -280549,12 +280567,24 @@ function printConnectRecipe(args) {
|
|
|
280549
280567
|
);
|
|
280550
280568
|
console.log(
|
|
280551
280569
|
import_picocolors5.default.dim(
|
|
280552
|
-
` Reminder: Ablo never writes to your database
|
|
280553
|
-
|
|
280570
|
+
` Reminder: this is the READ path \u2014 Ablo tails your WAL and never writes to your database.
|
|
280571
|
+
Your writes keep going through your own backend. (Ablo HOSTING your rows, or dialing in via
|
|
280572
|
+
${import_picocolors5.default.bold("databaseUrl")}, is the deprecated posture \u2014 see the read-path decision doc.)`
|
|
280554
280573
|
)
|
|
280555
280574
|
);
|
|
280556
280575
|
console.log();
|
|
280557
280576
|
}
|
|
280577
|
+
var SYNC_INFRA_RELATIONS = [
|
|
280578
|
+
"sync_deltas",
|
|
280579
|
+
"sync_id_seq",
|
|
280580
|
+
"sync_metadata",
|
|
280581
|
+
"mutation_log"
|
|
280582
|
+
];
|
|
280583
|
+
var SYNC_INFRA_TYPES = [
|
|
280584
|
+
"participant_kind",
|
|
280585
|
+
"backfill_provenance",
|
|
280586
|
+
"confirmation_state"
|
|
280587
|
+
];
|
|
280558
280588
|
function printCheckItem(item) {
|
|
280559
280589
|
if (item.ok) {
|
|
280560
280590
|
console.log(` ${import_picocolors5.default.green("\u2713")} ${item.label}`);
|
|
@@ -280568,14 +280598,17 @@ function printCheckItem(item) {
|
|
|
280568
280598
|
async function probeReadiness(sql, opts = {}) {
|
|
280569
280599
|
const publication = opts.publication ?? ABLO_PUBLICATION;
|
|
280570
280600
|
const items = [];
|
|
280571
|
-
const walRows = await sql.unsafe(
|
|
280601
|
+
const walRows = await sql.unsafe(
|
|
280602
|
+
`SELECT setting FROM pg_settings WHERE name = 'wal_level'`
|
|
280603
|
+
);
|
|
280572
280604
|
const walLevel = walRows[0]?.setting ?? "unknown";
|
|
280573
280605
|
items.push(
|
|
280574
280606
|
walLevel === "logical" ? { ok: true, label: `wal_level is ${import_picocolors5.default.bold("logical")}` } : {
|
|
280575
280607
|
ok: false,
|
|
280576
280608
|
label: `wal_level is ${import_picocolors5.default.bold(walLevel)} (need ${import_picocolors5.default.bold("logical")})`,
|
|
280577
280609
|
fix: `ALTER SYSTEM SET wal_level = 'logical'; then RESTART Postgres.
|
|
280578
|
-
On RDS/Aurora set rds.logical_replication = 1 in the parameter group, then reboot
|
|
280610
|
+
On RDS/Aurora set rds.logical_replication = 1 in the parameter group, then reboot.
|
|
280611
|
+
On Neon enable Logical Replication in the project (Console \u2192 Settings \u2192 Logical Replication, or the API) \u2014 Neon forbids ALTER SYSTEM; the toggle sets wal_level=logical.`
|
|
280579
280612
|
}
|
|
280580
280613
|
);
|
|
280581
280614
|
const pubRows = await sql.unsafe(
|
|
@@ -280636,17 +280669,35 @@ On RDS: GRANT rds_replication TO <your_role>;`
|
|
|
280636
280669
|
}
|
|
280637
280670
|
return items;
|
|
280638
280671
|
}
|
|
280639
|
-
async function
|
|
280672
|
+
async function auditTenantSyncInfra(sql) {
|
|
280673
|
+
const artifacts = [];
|
|
280674
|
+
for (const name of SYNC_INFRA_RELATIONS) {
|
|
280675
|
+
const rows = await sql.unsafe(
|
|
280676
|
+
`SELECT to_regclass($1)::text AS reg`,
|
|
280677
|
+
[`public.${name}`]
|
|
280678
|
+
);
|
|
280679
|
+
artifacts.push({ kind: "relation", name, present: rows[0]?.reg != null });
|
|
280680
|
+
}
|
|
280681
|
+
for (const name of SYNC_INFRA_TYPES) {
|
|
280682
|
+
const rows = await sql.unsafe(
|
|
280683
|
+
`SELECT to_regtype($1)::text AS reg`,
|
|
280684
|
+
[`public.${name}`]
|
|
280685
|
+
);
|
|
280686
|
+
artifacts.push({ kind: "type", name, present: rows[0]?.reg != null });
|
|
280687
|
+
}
|
|
280688
|
+
return artifacts;
|
|
280689
|
+
}
|
|
280690
|
+
function requireDatabaseUrl(verb) {
|
|
280640
280691
|
const dbUrl = readProjectDatabaseUrl();
|
|
280641
280692
|
if (!dbUrl) {
|
|
280642
280693
|
console.error(
|
|
280643
|
-
import_picocolors5.default.red(" No DATABASE_URL found (checked process env, .env.local, .env).") + import_picocolors5.default.dim(` Set it to the Postgres you want Ablo to read, then re-run ${import_picocolors5.default.bold(
|
|
280694
|
+
import_picocolors5.default.red(" No DATABASE_URL found (checked process env, .env.local, .env).") + import_picocolors5.default.dim(` Set it to the Postgres you want Ablo to read, then re-run ${import_picocolors5.default.bold(`ablo connect ${verb}`)}.`)
|
|
280644
280695
|
);
|
|
280645
280696
|
process.exit(1);
|
|
280646
280697
|
}
|
|
280647
|
-
|
|
280648
|
-
|
|
280649
|
-
|
|
280698
|
+
return dbUrl;
|
|
280699
|
+
}
|
|
280700
|
+
async function probeAndReport(dbUrl) {
|
|
280650
280701
|
const sql = src_default(dbUrl, { max: 1, prepare: false, onnotice: () => {
|
|
280651
280702
|
} });
|
|
280652
280703
|
let items;
|
|
@@ -280660,7 +280711,14 @@ async function runCheck() {
|
|
|
280660
280711
|
}
|
|
280661
280712
|
await sql.end({ timeout: 2 });
|
|
280662
280713
|
for (const item of items) printCheckItem(item);
|
|
280663
|
-
|
|
280714
|
+
return items.filter((i) => !i.ok).length;
|
|
280715
|
+
}
|
|
280716
|
+
async function runCheck() {
|
|
280717
|
+
const dbUrl = requireDatabaseUrl("--check");
|
|
280718
|
+
console.log(`
|
|
280719
|
+
${brand("ablo")} ${import_picocolors5.default.dim("connect --check")} ${import_picocolors5.default.dim("logical-replication readiness")}
|
|
280720
|
+
`);
|
|
280721
|
+
const failures = await probeAndReport(dbUrl);
|
|
280664
280722
|
console.log();
|
|
280665
280723
|
if (failures === 0) {
|
|
280666
280724
|
console.log(` ${import_picocolors5.default.green("\u2713")} Ready \u2014 Ablo can connect and tail this database's WAL.
|
|
@@ -280673,6 +280731,104 @@ async function runCheck() {
|
|
|
280673
280731
|
);
|
|
280674
280732
|
process.exit(1);
|
|
280675
280733
|
}
|
|
280734
|
+
async function runRegister() {
|
|
280735
|
+
const dbUrl = requireDatabaseUrl("--register");
|
|
280736
|
+
const apiKey = resolveApiKey();
|
|
280737
|
+
if (!apiKey) {
|
|
280738
|
+
console.error(
|
|
280739
|
+
import_picocolors5.default.red(" Not logged in.") + import_picocolors5.default.dim(` Run ${import_picocolors5.default.bold("ablo login")} (or set ${import_picocolors5.default.bold("ABLO_API_KEY")}) so Ablo knows which project to register this database for.`)
|
|
280740
|
+
);
|
|
280741
|
+
process.exit(1);
|
|
280742
|
+
}
|
|
280743
|
+
console.log(`
|
|
280744
|
+
${brand("ablo")} ${import_picocolors5.default.dim("connect --register")} ${import_picocolors5.default.dim("register this database for replication")}
|
|
280745
|
+
`);
|
|
280746
|
+
const failures = await probeAndReport(dbUrl);
|
|
280747
|
+
if (failures > 0) {
|
|
280748
|
+
console.log(
|
|
280749
|
+
`
|
|
280750
|
+
${import_picocolors5.default.red(`${failures} item${failures === 1 ? "" : "s"} to fix`)} ${import_picocolors5.default.dim("\u2014 a database that isn\u2019t replication-ready can\u2019t stream. Fix the above, then re-run.")}
|
|
280751
|
+
`
|
|
280752
|
+
);
|
|
280753
|
+
process.exit(1);
|
|
280754
|
+
}
|
|
280755
|
+
const apiUrl2 = (process.env.ABLO_API_URL ?? DEFAULT_URL).replace(/\/+$/, "");
|
|
280756
|
+
let res;
|
|
280757
|
+
try {
|
|
280758
|
+
res = await fetch(`${apiUrl2}/v1/datasources`, {
|
|
280759
|
+
method: "POST",
|
|
280760
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
|
|
280761
|
+
body: JSON.stringify({ connectionString: dbUrl })
|
|
280762
|
+
});
|
|
280763
|
+
} catch (err) {
|
|
280764
|
+
console.error(import_picocolors5.default.red(`
|
|
280765
|
+
Couldn't reach ${apiUrl2}: ${err instanceof Error ? err.message : String(err)}
|
|
280766
|
+
`));
|
|
280767
|
+
process.exit(1);
|
|
280768
|
+
}
|
|
280769
|
+
if (res.ok) {
|
|
280770
|
+
const body2 = await res.json().catch(() => ({}));
|
|
280771
|
+
console.log(
|
|
280772
|
+
`
|
|
280773
|
+
${import_picocolors5.default.green("\u2713")} Registered${body2.host ? ` ${import_picocolors5.default.dim(body2.host)}` : ""}${body2.id ? ` ${import_picocolors5.default.dim(`(${body2.id})`)}` : ""} \u2014 Ablo will replicate this database on the next sync.
|
|
280774
|
+
`
|
|
280775
|
+
);
|
|
280776
|
+
process.exit(0);
|
|
280777
|
+
}
|
|
280778
|
+
const body = await res.json().catch(() => ({}));
|
|
280779
|
+
const code = body.error?.code;
|
|
280780
|
+
const message = body.error?.message ?? `HTTP ${res.status}`;
|
|
280781
|
+
console.error(import_picocolors5.default.red(`
|
|
280782
|
+
Registration failed: ${message}`));
|
|
280783
|
+
if (code === "forbidden") {
|
|
280784
|
+
console.error(import_picocolors5.default.dim(` Registering a database needs a ${import_picocolors5.default.bold("secret")} key (sk_\u2026). Run ${import_picocolors5.default.bold("ablo login")} for one.`));
|
|
280785
|
+
} else if (code === "datasource_direct_deprecated") {
|
|
280786
|
+
console.error(
|
|
280787
|
+
import_picocolors5.default.dim(
|
|
280788
|
+
` This deployment doesn\u2019t accept new connection-string registrations yet. The operator must enable it (${import_picocolors5.default.bold("ABLO_ALLOW_DIRECT_DATASOURCE=true")}) \u2014 see ${import_picocolors5.default.bold("docs/runbooks/connect-customer-database-byo-replication.md")}.`
|
|
280789
|
+
)
|
|
280790
|
+
);
|
|
280791
|
+
} else if (code === "datasource_connection_unsupported") {
|
|
280792
|
+
console.error(
|
|
280793
|
+
import_picocolors5.default.dim(` This deployment can\u2019t accept connection strings \u2014 use a self-hosted/hosted engine, or the signed endpoint fallback.`)
|
|
280794
|
+
);
|
|
280795
|
+
}
|
|
280796
|
+
console.error();
|
|
280797
|
+
process.exit(1);
|
|
280798
|
+
}
|
|
280799
|
+
async function runAuditInfra() {
|
|
280800
|
+
const dbUrl = requireDatabaseUrl("--audit-infra");
|
|
280801
|
+
const sql = src_default(dbUrl, { max: 1, prepare: false, onnotice: () => {
|
|
280802
|
+
} });
|
|
280803
|
+
let artifacts;
|
|
280804
|
+
try {
|
|
280805
|
+
artifacts = await auditTenantSyncInfra(sql);
|
|
280806
|
+
} catch (err) {
|
|
280807
|
+
const pg = err ?? {};
|
|
280808
|
+
console.error(import_picocolors5.default.red(` Couldn't audit the database: ${pg.message ?? String(err)}`));
|
|
280809
|
+
await sql.end({ timeout: 2 });
|
|
280810
|
+
process.exit(1);
|
|
280811
|
+
}
|
|
280812
|
+
await sql.end({ timeout: 2 });
|
|
280813
|
+
console.log(`
|
|
280814
|
+
${brand("ablo")} ${import_picocolors5.default.dim("connect --audit-infra")} ${import_picocolors5.default.dim("Stage 5 tenant DB sync-infra audit")}
|
|
280815
|
+
`);
|
|
280816
|
+
const present = artifacts.filter((a) => a.present);
|
|
280817
|
+
if (present.length === 0) {
|
|
280818
|
+
console.log(` ${import_picocolors5.default.green("\u2713")} No deprecated Ablo sync infrastructure found in public.
|
|
280819
|
+
`);
|
|
280820
|
+
process.exit(0);
|
|
280821
|
+
}
|
|
280822
|
+
for (const artifact of present) {
|
|
280823
|
+
const label = artifact.kind === "type" ? "type" : "relation";
|
|
280824
|
+
console.log(` ${import_picocolors5.default.yellow("!")} ${label} ${import_picocolors5.default.bold(`public.${artifact.name}`)} exists`);
|
|
280825
|
+
}
|
|
280826
|
+
console.log(
|
|
280827
|
+
`
|
|
280828
|
+
${import_picocolors5.default.yellow(`${present.length} artifact${present.length === 1 ? "" : "s"} found`)} ` + import_picocolors5.default.dim("\u2014 do not drop automatically. Confirm the org/environment is log-authoritative, then follow ") + import_picocolors5.default.bold("docs/runbooks/wal-stage5-customer-db-infra-cleanup.md") + import_picocolors5.default.dim(".\n")
|
|
280829
|
+
);
|
|
280830
|
+
process.exit(1);
|
|
280831
|
+
}
|
|
280676
280832
|
async function connect(argv) {
|
|
280677
280833
|
let args;
|
|
280678
280834
|
try {
|
|
@@ -280685,18 +280841,28 @@ async function connect(argv) {
|
|
|
280685
280841
|
await runCheck();
|
|
280686
280842
|
return;
|
|
280687
280843
|
}
|
|
280844
|
+
if (args.register) {
|
|
280845
|
+
await runRegister();
|
|
280846
|
+
return;
|
|
280847
|
+
}
|
|
280848
|
+
if (args.auditInfra) {
|
|
280849
|
+
await runAuditInfra();
|
|
280850
|
+
return;
|
|
280851
|
+
}
|
|
280688
280852
|
printConnectRecipe(args);
|
|
280689
280853
|
}
|
|
280690
|
-
var CONNECT_USAGE = ` ablo connect \u2014 connect
|
|
280854
|
+
var CONNECT_USAGE = ` ablo connect \u2014 connect your database for the read path, via logical replication
|
|
280691
280855
|
|
|
280692
|
-
Ablo
|
|
280693
|
-
|
|
280856
|
+
The Electric/PowerSync/Zero model: Ablo consumes your WAL via logical replication and
|
|
280857
|
+
never runs DDL, owns, hosts, or migrates your schema. Your app continues to own the write path.
|
|
280694
280858
|
|
|
280695
280859
|
Usage:
|
|
280696
280860
|
npx ablo connect Print the exact setup SQL for your Postgres
|
|
280697
280861
|
npx ablo connect --tables a,b,c Publish only these tables (default: all tables)
|
|
280698
280862
|
npx ablo connect --role <name> Name the replication role (default: ablo_replicator)
|
|
280699
|
-
npx ablo connect --check Validate DATABASE_URL is replication-ready
|
|
280863
|
+
npx ablo connect --check Validate DATABASE_URL is replication-ready
|
|
280864
|
+
npx ablo connect --register Register DATABASE_URL so Ablo replicates it (one self-service step)
|
|
280865
|
+
npx ablo connect --audit-infra Read-only Stage 5 audit for deprecated Ablo sync tables/types`;
|
|
280700
280866
|
|
|
280701
280867
|
// src/cli/generate.ts
|
|
280702
280868
|
init_cjs_shims();
|
|
@@ -281445,6 +281611,45 @@ async function fetchPushedSchema(apiUrl2, apiKey) {
|
|
|
281445
281611
|
clearTimeout(t);
|
|
281446
281612
|
}
|
|
281447
281613
|
}
|
|
281614
|
+
async function sampleRead(apiUrl2, apiKey, modelTypename, n) {
|
|
281615
|
+
const ctrl = new AbortController();
|
|
281616
|
+
const t = setTimeout(() => ctrl.abort(), 4e3);
|
|
281617
|
+
try {
|
|
281618
|
+
const res = await fetch(
|
|
281619
|
+
`${apiUrl2}/v1/models/${encodeURIComponent(modelTypename)}/__ablo_health_probe_${n}__`,
|
|
281620
|
+
{ headers: { authorization: `Bearer ${apiKey}` }, signal: ctrl.signal }
|
|
281621
|
+
);
|
|
281622
|
+
if (res.ok) return "routed";
|
|
281623
|
+
let code;
|
|
281624
|
+
try {
|
|
281625
|
+
const body = await res.json();
|
|
281626
|
+
code = body.code ?? body.error?.code;
|
|
281627
|
+
} catch {
|
|
281628
|
+
}
|
|
281629
|
+
if (code === "entity_not_found") return "routed";
|
|
281630
|
+
if (code === "tenant_routing_failed") return "no_route";
|
|
281631
|
+
if (res.status === 401 || res.status === 403) return { forbidden: code };
|
|
281632
|
+
return { other: `${res.status}${code ? ` ${code}` : ""}` };
|
|
281633
|
+
} catch {
|
|
281634
|
+
return { other: "unreachable" };
|
|
281635
|
+
} finally {
|
|
281636
|
+
clearTimeout(t);
|
|
281637
|
+
}
|
|
281638
|
+
}
|
|
281639
|
+
async function probeDataPlane(apiUrl2, apiKey, modelTypename) {
|
|
281640
|
+
if (!apiKey) return { status: "skipped" };
|
|
281641
|
+
const samples = [];
|
|
281642
|
+
for (let i = 0; i < 3; i++) samples.push(await sampleRead(apiUrl2, apiKey, modelTypename, i));
|
|
281643
|
+
const forbidden = samples.find((s) => typeof s === "object" && "forbidden" in s);
|
|
281644
|
+
if (forbidden) return { status: "forbidden", detail: forbidden.forbidden };
|
|
281645
|
+
const routed = samples.filter((s) => s === "routed").length;
|
|
281646
|
+
const noRoute = samples.filter((s) => s === "no_route").length;
|
|
281647
|
+
if (routed === samples.length) return { status: "ok" };
|
|
281648
|
+
if (noRoute === samples.length) return { status: "no_database" };
|
|
281649
|
+
if (routed > 0 && noRoute > 0) return { status: "intermittent", ok: routed, failed: noRoute };
|
|
281650
|
+
const other = samples.find((s) => typeof s === "object" && "other" in s);
|
|
281651
|
+
return { status: "unknown", detail: other?.other ?? "inconclusive" };
|
|
281652
|
+
}
|
|
281448
281653
|
function formatConflict(conflict) {
|
|
281449
281654
|
if (!conflict) return "";
|
|
281450
281655
|
const parts = ["user", "agent", "system"].flatMap((k3) => conflict[k3] ? [`${k3}:${conflict[k3]}`] : []);
|
|
@@ -281550,6 +281755,28 @@ async function status(args = []) {
|
|
|
281550
281755
|
} else if (pushed && !pushed.active) {
|
|
281551
281756
|
console.log(` ${import_picocolors11.default.dim("schema")} ${import_picocolors11.default.yellow("none pushed")} ${import_picocolors11.default.dim(`(run ${import_picocolors11.default.bold("ablo push")} or ${import_picocolors11.default.bold("ablo dev")})`)}`);
|
|
281552
281757
|
}
|
|
281758
|
+
if (pushed && pushed.active && pushed.models.length > 0) {
|
|
281759
|
+
const probe = await probeDataPlane(apiUrl2, introspectKey, pushed.models[0].typename);
|
|
281760
|
+
if (probe.status === "no_database") {
|
|
281761
|
+
console.log(` ${import_picocolors11.default.dim("data")} ${import_picocolors11.default.red("\u2717 no database registered")}${org ? import_picocolors11.default.dim(` for org ${org}`) : ""}`);
|
|
281762
|
+
console.log(
|
|
281763
|
+
` ${import_picocolors11.default.dim(
|
|
281764
|
+
`reads/writes will fail with ${import_picocolors11.default.bold("tenant_routing_failed")}. Connect one with ${import_picocolors11.default.bold("ablo connect")}, or point ${import_picocolors11.default.bold("ABLO_API_KEY")} at an org that has a database.`
|
|
281765
|
+
)}`
|
|
281766
|
+
);
|
|
281767
|
+
} else if (probe.status === "intermittent") {
|
|
281768
|
+
console.log(` ${import_picocolors11.default.dim("data")} ${import_picocolors11.default.red(`\u2717 database routing is intermittent`)} ${import_picocolors11.default.dim(`(${probe.ok} ok / ${probe.failed} failed of ${probe.ok + probe.failed})`)}${org ? import_picocolors11.default.dim(` for org ${org}`) : ""}`);
|
|
281769
|
+
console.log(
|
|
281770
|
+
` ${import_picocolors11.default.dim(
|
|
281771
|
+
`some reads/writes fail with ${import_picocolors11.default.bold("tenant_routing_failed")} \u2014 the registration is unstable. Re-establish it with ${import_picocolors11.default.bold("ablo connect")} (or check for a recent server redeploy).`
|
|
281772
|
+
)}`
|
|
281773
|
+
);
|
|
281774
|
+
} else if (probe.status === "forbidden") {
|
|
281775
|
+
console.log(` ${import_picocolors11.default.dim("data")} ${import_picocolors11.default.yellow("? key not authorized to read")}${probe.detail ? import_picocolors11.default.dim(` (${probe.detail})`) : ""}`);
|
|
281776
|
+
} else if (probe.status === "unknown") {
|
|
281777
|
+
console.log(` ${import_picocolors11.default.dim("data")} ${import_picocolors11.default.yellow(`? data-plane check inconclusive (${probe.detail})`)}`);
|
|
281778
|
+
}
|
|
281779
|
+
}
|
|
281553
281780
|
}
|
|
281554
281781
|
console.log();
|
|
281555
281782
|
}
|
|
@@ -283055,6 +283282,7 @@ async function main() {
|
|
|
283055
283282
|
console.log(` npx ablo check Check your existing database fits the schema (read-only, creates no tables)`);
|
|
283056
283283
|
console.log(` npx ablo connect Connect a real database \u2014 prints the logical-replication setup SQL (the one way)`);
|
|
283057
283284
|
console.log(` npx ablo connect --check Validate DATABASE_URL is replication-ready (wal_level, publication, role, replica identity)`);
|
|
283285
|
+
console.log(` npx ablo connect --audit-infra Read-only audit for deprecated Ablo sync infra in a customer DB`);
|
|
283058
283286
|
console.log(` npx ablo migrate Provision your synced-model tables in your own Postgres (optional escape hatch \u2014 \`connect\` is the way)`);
|
|
283059
283287
|
console.log(` npx ablo migrate --dry-run Print the SQL without executing (preview)`);
|
|
283060
283288
|
console.log(` npx ablo push Upload your schema definition to Ablo (metadata only \u2014 rows stay in your DB)`);
|
|
@@ -283080,7 +283308,7 @@ function bailIfCancelled(value) {
|
|
|
283080
283308
|
}
|
|
283081
283309
|
var INIT_FRAMEWORKS = ["nextjs", "vite", "remix", "vanilla"];
|
|
283082
283310
|
var INIT_AUTHS = ["apikey", "firebase", "auth0", "clerk", "supabase", "betterauth", "jwt"];
|
|
283083
|
-
var INIT_STORAGES = ["endpoint", "direct", "datasource"];
|
|
283311
|
+
var INIT_STORAGES = ["replication", "endpoint", "direct", "datasource"];
|
|
283084
283312
|
function parseInitArgs(args) {
|
|
283085
283313
|
const has = (flag2) => args.includes(flag2);
|
|
283086
283314
|
const val = (flag2) => {
|
|
@@ -283206,15 +283434,15 @@ async function init(args = []) {
|
|
|
283206
283434
|
const storageChoice = await chooseOption(
|
|
283207
283435
|
"storage",
|
|
283208
283436
|
opts.storage,
|
|
283209
|
-
"
|
|
283437
|
+
"replication",
|
|
283210
283438
|
INIT_STORAGES,
|
|
283211
283439
|
false,
|
|
283212
|
-
() => Promise.resolve("
|
|
283440
|
+
() => Promise.resolve("replication")
|
|
283213
283441
|
);
|
|
283214
283442
|
const storage = storageChoice === "datasource" ? "endpoint" : storageChoice;
|
|
283215
283443
|
if (storage === "direct") {
|
|
283216
283444
|
Me(
|
|
283217
|
-
"`--storage direct` uses the deprecated databaseUrl connector.
|
|
283445
|
+
"`--storage direct` uses the deprecated databaseUrl connector. Logical replication\n(`npx ablo connect`) is the supported path; a signed Data Source endpoint is the fallback.",
|
|
283218
283446
|
import_picocolors20.default.yellow("Deprecated")
|
|
283219
283447
|
);
|
|
283220
283448
|
}
|
|
@@ -283335,7 +283563,9 @@ async function init(args = []) {
|
|
|
283335
283563
|
`Run ${import_picocolors20.default.bold("npx ablo login")} (or add ${import_picocolors20.default.bold("ABLO_API_KEY")} to ${import_picocolors20.default.bold(envFile)})`,
|
|
283336
283564
|
`Set ${import_picocolors20.default.bold("DATABASE_URL")} in ${import_picocolors20.default.bold(envFile)} \u2014 your Postgres is the system of record; rows live there, never with Ablo`,
|
|
283337
283565
|
`Run ${import_picocolors20.default.bold("npx ablo dev")} \u2014 pushes your schema definition and watches for changes`,
|
|
283338
|
-
...storage === "
|
|
283566
|
+
...storage === "replication" ? [
|
|
283567
|
+
`Connect your database: ${import_picocolors20.default.bold("npx ablo connect")} prints the logical-replication setup SQL (run it once on your Postgres), then ${import_picocolors20.default.bold("npx ablo connect --register")} tells Ablo to replicate it \u2014 your app keeps writing through your own backend, Ablo tails the WAL`
|
|
283568
|
+
] : storage === "direct" ? [
|
|
283339
283569
|
`Provision your DB: ${import_picocolors20.default.bold("npx ablo migrate")} (creates your synced-model tables with row-level security; keep your own migrations for everything else)`
|
|
283340
283570
|
] : [
|
|
283341
283571
|
`Provision your DB: ${import_picocolors20.default.bold("npx ablo migrate")} (creates your Ablo-model tables + the adapter tables; keep your own migrations for everything else), then mount ${import_picocolors20.default.bold(`${abloDir}/data-source.ts`)} at ${import_picocolors20.default.bold("/api/ablo/source")}`
|
|
@@ -283440,7 +283670,7 @@ export {};
|
|
|
283440
283670
|
}
|
|
283441
283671
|
function generateEnv(storage, opts = {}) {
|
|
283442
283672
|
const { includeApiKey = true } = opts;
|
|
283443
|
-
const databaseBlock = storage === "direct" ? "# DEPRECATED direct connector. The client registers this connection (sent once\n# over TLS, stored sealed) so Ablo dials into your Postgres. Prefer
|
|
283673
|
+
const databaseBlock = storage === "direct" ? "# DEPRECATED direct connector. The client registers this connection (sent once\n# over TLS, stored sealed) so Ablo dials into your Postgres. Prefer logical\n# replication (`npx ablo connect`); to keep the transaction log in your infra too,\n# self-host the engine. Use a dedicated non-superuser role; the browser never sees this.\nDATABASE_URL=postgres://user:password@host:5432/db\n" : 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";
|
|
283444
283674
|
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" : "";
|
|
283445
283675
|
const apiKeyBlock = includeApiKey ? "# Ablo Sync Engine \u2014 use a sk_test_ key for local dev (`npx ablo push`)\nABLO_API_KEY=sk_test_your_key_here\n" : "";
|
|
283446
283676
|
return `${apiKeyBlock}${webhookBlock}${databaseBlock}`;
|
package/docs/data-sources.md
CHANGED
|
@@ -6,7 +6,8 @@ schema, and never migrates it**. Your application keeps writing to its own
|
|
|
6
6
|
Postgres through its own backend, exactly as it does today; Ablo only tails the
|
|
7
7
|
changes and fans the confirmed rows out to every connected human and agent. This
|
|
8
8
|
is the same model ElectricSQL, PowerSync, and Zero use — a publication plus a
|
|
9
|
-
replication slot
|
|
9
|
+
replication slot. Ablo consumes the logical-replication stream; your application
|
|
10
|
+
continues to own the write path.
|
|
10
11
|
|
|
11
12
|
> **Just trying Ablo?** You don't need a database at all to start. The hosted
|
|
12
13
|
> **sandbox** can host rows in Ablo's test plane — pass an `apiKey` only and omit
|
|
@@ -94,8 +95,10 @@ Re-run it until every item is green.
|
|
|
94
95
|
|
|
95
96
|
### 4. Point Ablo at the database with the replication role
|
|
96
97
|
|
|
97
|
-
Give Ablo the connection string for the **replication role** you created
|
|
98
|
-
|
|
98
|
+
Give Ablo the connection string for the **replication role** you created — a
|
|
99
|
+
`REPLICATION`-attributed role that streams the WAL and `SELECT`s, nothing more
|
|
100
|
+
(not a read-only account; see the privilege note below). The same value `--check`
|
|
101
|
+
validated:
|
|
99
102
|
|
|
100
103
|
```bash
|
|
101
104
|
# .env — server runtime only, never the browser
|
|
@@ -156,8 +159,12 @@ Operational reality you should know up front:
|
|
|
156
159
|
WAL accumulates and consumes disk. **Ablo monitors slot lag and WAL retention**
|
|
157
160
|
and surfaces it so you're never surprised by disk pressure; an abandoned slot is
|
|
158
161
|
dropped rather than left to grow unbounded.
|
|
159
|
-
- **The role
|
|
160
|
-
|
|
162
|
+
- **The role's privilege footprint is narrow and precise — not a "read-only"
|
|
163
|
+
account.** It carries the `REPLICATION` attribute, which lets it stream the WAL
|
|
164
|
+
and `SELECT`; it cannot `INSERT`/`UPDATE`/`DELETE`, run DDL, or own objects, and
|
|
165
|
+
the recipe never grants it more. (For a security review, state it that way — a
|
|
166
|
+
logical-replication role is a real privilege, just a tightly-scoped one — rather
|
|
167
|
+
than calling it "read-only", which a reviewer will correctly push back on.)
|
|
161
168
|
|
|
162
169
|
What Ablo explicitly does **not** do:
|
|
163
170
|
|
package/llms.txt
CHANGED
|
@@ -122,9 +122,11 @@ A schema is model fields and relations. Advanced schema helpers such as `mutable
|
|
|
122
122
|
|
|
123
123
|
## Storage Boundary
|
|
124
124
|
|
|
125
|
-
|
|
125
|
+
THE ONE PATH IS LOGICAL REPLICATION. In production every schema model is backed by YOUR OWN database, and Ablo connects to it by CONSUMING ITS WRITE-AHEAD LOG (logical replication) — the Electric/PowerSync/Zero model. Ablo tails the WAL and fans changes out; YOUR APPLICATION CONTINUES TO OWN THE WRITE PATH (your own backend, your existing API). Ablo never runs DDL on, writes to, owns, or migrates your database. Connect once with `npx ablo connect` (prints the setup SQL: `wal_level=logical`, a publication, a `REPLICATION` role) then `npx ablo connect --register`. Validate with `npx ablo connect --check`. The role's privilege footprint is narrow but real — a `REPLICATION`-attributed role that streams the WAL and `SELECT`s; do NOT describe it as "read-only" (a security reviewer will push back — it is a replication privilege, just tightly scoped).
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
FALLBACK (only when the database can't grant a `REPLICATION` role, e.g. a locked-down managed DB): a signed Data Source endpoint that hands Ablo an ORM `adapter` (Drizzle default; Prisma and Kysely supported) — it owns the transaction, exactly-once idempotency, and outbox in ONE pass; your credentials never leave your infrastructure.
|
|
128
|
+
|
|
129
|
+
DEPRECATED — being removed, do NOT recommend or scaffold: the `databaseUrl` constructor option (the dial-in, where Ablo held a read/write connection and committed directly to your DB). The end-state constructor is `Ablo({ schema, apiKey })`; the database is connected out of band via `ablo connect`, not through `databaseUrl`. (Note: `ablo init` still defaults to `--storage direct` scaffolding `databaseUrl` — that default is itself slated to lead with `ablo connect`; prefer logical replication regardless of what `init` scaffolds.)
|
|
128
130
|
|
|
129
131
|
GOTCHA the user WILL hit: `DATABASE_URL` must use a NON-superuser, NON-BYPASSRLS role (Ablo enforces row-level security; owner roles are rejected with `database_role_cannot_enforce_rls`). Neon's and Supabase's default dashboard connection strings use the database OWNER (e.g. `neondb_owner`) and are rejected. EASIEST: `npx ablo migrate` detects the unsafe role and creates the scoped one automatically from the user's machine (owner credential never reaches Ablo; new DATABASE_URL written to the env file). Manual alternative — create a scoped role first: `CREATE ROLE ablo_app LOGIN PASSWORD '...' NOSUPERUSER NOBYPASSRLS; GRANT CREATE, CONNECT ON DATABASE <db> TO ablo_app; GRANT CREATE, USAGE ON SCHEMA public TO ablo_app;` — then swap user/password into the same host/db string.
|
|
130
132
|
|