@abloatai/ablo 0.23.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.
@@ -20,6 +20,7 @@
20
20
  */
21
21
  import { z } from 'zod';
22
22
  import { baseFieldsSchema } from '../schema/schema.js';
23
+ import { schemaHash } from '../schema/serialize.js';
23
24
  import { AbloError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, AbloNotFoundError, translateHttpError, toAbloError, claimedError } from '../errors.js';
24
25
  import { descriptionFromMeta } from '../coordination/schema.js';
25
26
  import { LoadStrategy, PropertyType } from '../types/index.js';
@@ -222,6 +223,9 @@ function deriveConfigFromSchema(schema) {
222
223
  defaultNonCreatePriority: 50,
223
224
  essentialFields: {},
224
225
  classNameFallbackMap: {},
226
+ // Hash this client's schema once so bootstrap can detect drift against the
227
+ // server's active hash (same `schemaHash` the CLI push + server compute).
228
+ expectedSchemaHash: schemaHash(schema),
225
229
  };
226
230
  }
227
231
  // ── Auto model registration from schema ───────────────────────────────────
@@ -380,6 +380,14 @@ export interface SyncEngineConfig {
380
380
  * e.g., { TaskModel: 'Task', ProjectModel: 'Project' }
381
381
  */
382
382
  classNameFallbackMap: Readonly<Record<string, string>>;
383
+ /**
384
+ * Content hash of the schema THIS client was built against (the same
385
+ * `schemaHash()` the CLI push + server compute). Used purely to detect
386
+ * schema drift: when the server reports a different active hash on bootstrap,
387
+ * the SDK warns the developer to run `ablo push` — otherwise drift only
388
+ * surfaces later as an opaque DB constraint error. Advisory, not enforced.
389
+ */
390
+ expectedSchemaHash?: string;
383
391
  }
384
392
  /**
385
393
  * Allows consumers to extend the WebSocket event map with
@@ -18,6 +18,12 @@ export interface BootstrapData {
18
18
  /** Model types whose server-side query failed (timeout, RLS error, etc.) */
19
19
  failedModels?: string[];
20
20
  timestamp: number;
21
+ /**
22
+ * The server's ACTIVE schema content hash for this tenant (same `schemaHash`
23
+ * the CLI push computes). Present once the tenant has pushed a schema; the
24
+ * client compares it to its own `config.expectedSchemaHash` to warn on drift.
25
+ */
26
+ schemaHash?: string;
21
27
  }
22
28
  export interface BootstrapFetchResult {
23
29
  notModified: boolean;
@@ -72,7 +78,17 @@ import { type ValidatedServerDelta } from './schemas.js';
72
78
  export declare class BootstrapHelper {
73
79
  private options;
74
80
  private abortController;
81
+ /** Warn about schema drift at most once per helper. */
82
+ private schemaDriftWarned;
75
83
  get baseUrl(): string;
84
+ /**
85
+ * Advisory schema-drift check: compare the server's active schema hash (on the
86
+ * bootstrap response) against the hash this client was built with. A mismatch
87
+ * means the app's schema and the deployed schema have diverged — reads/writes
88
+ * relying on undeployed changes will later fail with an opaque DB constraint
89
+ * error. Warn once, actionably; never throws or blocks the bootstrap.
90
+ */
91
+ private warnOnSchemaDrift;
76
92
  constructor(options: BootstrapOptions);
77
93
  /**
78
94
  * Update the offline-cache namespace once auth has resolved the server-side
@@ -10,9 +10,34 @@ import { parseBootstrapResponse } from './schemas.js';
10
10
  export class BootstrapHelper {
11
11
  options;
12
12
  abortController = null;
13
+ /** Warn about schema drift at most once per helper. */
14
+ schemaDriftWarned = false;
13
15
  get baseUrl() {
14
16
  return this.options.baseUrl;
15
17
  }
18
+ /**
19
+ * Advisory schema-drift check: compare the server's active schema hash (on the
20
+ * bootstrap response) against the hash this client was built with. A mismatch
21
+ * means the app's schema and the deployed schema have diverged — reads/writes
22
+ * relying on undeployed changes will later fail with an opaque DB constraint
23
+ * error. Warn once, actionably; never throws or blocks the bootstrap.
24
+ */
25
+ warnOnSchemaDrift(serverHash) {
26
+ if (this.schemaDriftWarned || !serverHash)
27
+ return;
28
+ const clientHash = getContext().config.expectedSchemaHash;
29
+ if (!clientHash || clientHash === serverHash)
30
+ return;
31
+ this.schemaDriftWarned = true;
32
+ // Self-brand the message ("Ablo:") rather than rely on the default logger's
33
+ // `[Ablo]` namespace — consumers wiring their own logger (pino, etc.) lose
34
+ // that prefix, and a drift warning that reads like the app's own log is
35
+ // worse than none. The brand tells them at a glance who is talking.
36
+ getContext().logger.warn(`Ablo: Schema drift detected — this app was built against schema ${clientHash}, ` +
37
+ `but the deployed schema is ${serverHash}. Operations that depend on schema ` +
38
+ `changes not yet deployed will fail later with an opaque database error. Run ` +
39
+ `\`ablo push\` to deploy your schema (or update this app to match the deployed one).`, { clientSchemaHash: clientHash, serverSchemaHash: serverHash });
40
+ }
16
41
  constructor(options) {
17
42
  // Defaults are spread first; the explicit `baseUrl` then takes precedence
18
43
  // and is computed from `options.baseUrl` (or the localhost fallback).
@@ -254,6 +279,7 @@ export class BootstrapHelper {
254
279
  }
255
280
  const rawJson = await res.json();
256
281
  const data = parseBootstrapResponse(rawJson);
282
+ this.warnOnSchemaDrift(data.schemaHash);
257
283
  // Persist payload for offline
258
284
  try {
259
285
  if (this.options.cacheScope) {
@@ -326,6 +352,7 @@ export class BootstrapHelper {
326
352
  }
327
353
  const rawJson = await response.json();
328
354
  const data = parseBootstrapResponse(rawJson);
355
+ this.warnOnSchemaDrift(data.schemaHash);
329
356
  // Save a copy for offline
330
357
  try {
331
358
  if (this.options.cacheScope) {
@@ -70,6 +70,7 @@ export declare const BootstrapResponseSchema: z.ZodObject<{
70
70
  deltaCount: z.ZodOptional<z.ZodNumber>;
71
71
  failedModels: z.ZodOptional<z.ZodArray<z.ZodString>>;
72
72
  timestamp: z.ZodDefault<z.ZodNumber>;
73
+ schemaHash: z.ZodOptional<z.ZodString>;
73
74
  }, z.core.$loose>;
74
75
  export type ValidatedBootstrapResponse = z.infer<typeof BootstrapResponseSchema>;
75
76
  /**
@@ -54,6 +54,9 @@ export const BootstrapResponseSchema = z
54
54
  deltaCount: z.number().optional(),
55
55
  failedModels: z.array(z.string()).optional(),
56
56
  timestamp: z.number().default(() => Date.now()),
57
+ // Server's active schema hash (drift detection). Optional: absent from
58
+ // older servers / tenants that have never pushed a schema.
59
+ schemaHash: z.string().optional(),
57
60
  })
58
61
  .passthrough();
59
62
  // ─── Parse Helpers ───────────────────────────────────────────────────────────
@@ -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, read-only.
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. This is
98
- a read-only WAL connection the same value `--check` validated:
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 is read-only.** It can stream replication and `SELECT`. It cannot
160
- write, and the recipe never grants it more.
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
- `databaseUrl` is an OPTIONAL, server-only constructor option on `Ablo(...)`. It is NOT auto-read from the environment pass it EXPLICITLY to register your Postgres directly. Omit it when you expose a signed Data Source endpoint, or when trying Ablo against the hosted sandbox (apiKey only). A `DATABASE_URL` set for another tool (Prisma, Drizzle, docker-compose) is ignored unless you pass `databaseUrl` explicitly.
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
- In production, every schema model is backed by YOUR OWN database. The PRIMARY path is the connection string: pass `databaseUrl` (most users already have a Postgres — often Prisma- or Drizzle-managed for auth/audit/log tables that are NOT in the Ablo schema; Ablo syncs a SUBSET of models against it). Most users do NOT run `ablo migrate` they ADOPT existing tables with `npx ablo pull` / `npx ablo check`, or keep managing tables with their own migration tool. Run `npx ablo migrate` only when Ablo should OWN the tables (it provisions the synced-model tables in your DB). The alternative to the connection string is a signed Data Source endpoint that hands Ablo an ORM `adapter` (Drizzle is the default; Prisma and Kysely are also supported) — it owns the transaction, exactly-once idempotency, and outbox in ONE pass (no hand-written `commit`/`events`); use it when database credentials must never leave your infrastructure (or for a local/private-range DB, which Ablo's cloud cannot reach over the network).
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abloatai/ablo",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "The Collaboration Layer For AI Agents",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",