@abloatai/ablo 0.11.0 → 0.11.1

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 CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 7f91f6e: DX hardening from a real onboarding session — onboarding, CLI, coordination, types, and docs.
8
+
9
+ **Client behavior**
10
+ - `databaseUrl` is now an explicit, server-only option: `Ablo(...)` no longer auto-reads `process.env.DATABASE_URL`. A stray `DATABASE_URL` (common — Prisma/Drizzle/docker set it) no longer silently flips the client into connection-string mode; a one-time warning points at the explicit option. Passing `databaseUrl: process.env.DATABASE_URL` explicitly is unchanged.
11
+ - Claims/presence are now observable from any client (including Node agents): reading a row enters its entity sync group (read-interest) and claiming pins it (write-intent), so `ablo.<model>.claim.state({ id })` reports co-participants without any manual subscribe step — whether the observer arrives before the claim (live delta) or after it (subscribe-time backfill). The claim **holder** now also sees its own claim via `claim.state`. **Requires a coordinated `sync-server` deploy** (the subscribe-time claim backfill + the entity-scope subscription gate that lets an org-authority agent key narrow into a row's group live server-side); the client package change alone does not deliver cross-client agent observation.
12
+
13
+ **CLI**
14
+ - `ablo init` detects the `src/app` layout (routes + the `@/ablo` import alias resolve correctly), writes the **real** stored sandbox key into `.env.local` instead of a placeholder, and scaffolds `ablo/register.ts` (a regular module, not a colliding `ablo.d.ts`).
15
+ - `ablo <command> --help` / `-h` now prints usage instead of erroring with "unknown flag", and `migrate` is listed in the top-level help.
16
+ - `ablo dev --no-watch` now exits after one push instead of watching forever.
17
+
18
+ **Types**
19
+ - Name the client with `typeof sync` (the value-inferred idiom, like tRPC's `typeof appRouter` / Drizzle's `typeof db`) — `ReturnType<typeof Ablo>` collapses to the untyped client and should not be used. No bespoke client-type generic is needed.
20
+ - `model_claim_not_configured` message clarified: claiming needs no per-model schema configuration; every model is claimable through the standard client.
21
+
22
+ **Docs**
23
+ - Reconciled the self-contradictory `databaseUrl` story (it is an explicit, server-only option, not auto-read from the environment; consistent casing), documented that the sandbox can host rows (apiKey only, no database), explained why a localhost Postgres can't be the system of record, and led the connect-your-database flow with `ablo pull`/`ablo check` over `ablo migrate`. Fixed stale `api.md` vocabulary (`object: 'claim'`, `participantKind: 'user' | 'agent' | 'system'`).
24
+
25
+ - 7f91f6e: Docs: document the completed `intent` → `claim` rename. Adds a 0.11.0 migration entry (`useIntent` → `useClaim`, `Register.Intents` → `Register.Claims`, `Ablo.Intent.*` → `Ablo.Claim.*`, and the coordinated client/server deploy for the `claim_*` wire frames), a `useClaim` section in the React reference, and fixes the stale `participantKind` union to the canonical `'user' | 'agent' | 'system'`.
26
+
3
27
  ## 0.11.0
4
28
 
5
29
  ### Minor Changes
package/README.md CHANGED
@@ -54,10 +54,12 @@ claims are visible while the work is still in progress.
54
54
  `llms.txt` &nbsp;·&nbsp; **upgrading?** see the
55
55
  [Version History &amp; Migration Guide](./docs/migration.md)
56
56
 
57
- It works with the auth and database you already have. **Your database is the
58
- system of record — Ablo never hosts your data.** Ablo is the transaction layer
59
- on top of it: realtime data is scoped to *sync groups* from your own identity,
60
- and every committed row lives in your Postgres.
57
+ It works with the auth and database you already have. **In production, your
58
+ database is the system of record.** Ablo is the transaction layer on top of it:
59
+ realtime data is scoped to *sync groups* from your own identity, and every
60
+ committed row lives in your Postgres. (Trying Ablo with no database yet? The
61
+ hosted **sandbox** can host rows in Ablo's test plane — apiKey only, like
62
+ Stripe test mode — so you can explore before pointing it at your Postgres.)
61
63
 
62
64
  **Built for** collaborative editors, AI agent workflows, and internal tools —
63
65
  anywhere people and agents change shared state and everyone has to see it live.
@@ -65,18 +67,24 @@ anywhere people and agents change shared state and everyone has to see it live.
65
67
  ## Set up
66
68
 
67
69
  The CLI takes you from nothing to a synced schema — it handles the account,
68
- the key, and the env file. You bring one thing: a Postgres `DATABASE_URL`
69
- (local, Neon, RDS — any will do; **your database is the system of record,
70
- Ablo never hosts your data**).
70
+ the key, and the env file. You bring one thing: a Postgres you already have —
71
+ the same `DATABASE_URL` (local, Neon, RDS — any will do) that backs your auth,
72
+ audit, and log tables. Ablo syncs a *subset* of models against it; **in
73
+ production, your database is the system of record**.
71
74
 
72
75
  ```bash
73
76
  npm install @abloatai/ablo
74
77
  npx ablo login # opens the browser: sign in (or sign up) → a sk_test_ key is saved locally
75
78
  npx ablo init # scaffolds ablo/schema.ts (offers to log in if you skipped it)
76
- npx ablo migrate # creates the synced tables in YOUR Postgres (reads DATABASE_URL)
77
- npx ablo push # pushes your schema (sandbox), writes ABLO_API_KEY to .env.local, watches for changes
79
+ npx ablo push # pushes your schema (sandbox), writes ABLO_API_KEY to .env.local, watches for changes
78
80
  ```
79
81
 
82
+ Then point Ablo at the tables for your synced models. Most teams **already
83
+ have those tables** (often Prisma- or Drizzle-managed) — adopt them with
84
+ `npx ablo pull` / `npx ablo check`, the common case. Let Ablo own its own
85
+ tables instead? `npx ablo migrate` provisions them in your Postgres (reads
86
+ `DATABASE_URL`). Either way your other tables are left untouched.
87
+
80
88
  After `ablo push`, the [Quick Start](#quick-start) below runs as-is —
81
89
  `ABLO_API_KEY` is already in `.env.local` (frameworks load it automatically;
82
90
  plain Node: `node --env-file=.env.local app.ts`). `npx ablo status` shows
@@ -106,18 +114,24 @@ import Ablo from '@abloatai/ablo';
106
114
  import { defineSchema, model, z } from '@abloatai/ablo/schema';
107
115
  ```
108
116
 
109
- Register the schema once (init scaffolds this `ablo.d.ts`), and every type
110
- is one parameter away — no `typeof schema` re-stating, anywhere:
117
+ The schema is registered once (init scaffolds `ablo/register.ts` for you), and
118
+ every type is one parameter away — no `typeof schema` re-stating, anywhere:
111
119
 
112
120
  ```ts
113
- // ablo.d.ts — once per project
114
- import type { schema } from './ablo/schema';
121
+ // ablo/register.ts — scaffolded by `npx ablo init`, sits beside ablo/schema.ts
122
+ import type { schema } from './schema';
115
123
  declare module '@abloatai/ablo' {
116
124
  interface Register { Schema: typeof schema }
117
125
  }
118
126
  export {};
119
127
  ```
120
128
 
129
+ It's a regular `.ts` module, not a hand-authored `.d.ts`. The top-level
130
+ `import type { schema }` makes the `declare module` block *merge* into (augment)
131
+ the SDK's `Register` interface instead of colliding with it — the same shape
132
+ [TanStack Router uses in `src/router.tsx`](https://tanstack.com/router/latest/docs/framework/react/guide/type-safety). Any `.ts` file in your
133
+ `tsconfig` `include` works; it never needs to be imported.
134
+
121
135
  ```ts
122
136
  import type { Model } from '@abloatai/ablo/schema';
123
137
 
@@ -128,6 +142,25 @@ type WeatherReport = Model<'weatherReports'>; // fully typed from YOUR schema
128
142
  TanStack-Router pattern: declare the source of truth once, everything
129
143
  infers from it.)
130
144
 
145
+ ### Naming the client type
146
+
147
+ When you need to pass the client around (a function parameter, a context value),
148
+ **infer the type from the value** — `type Sync = typeof sync`:
149
+
150
+ ```ts
151
+ export const sync = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
152
+ export type Sync = typeof sync; // fully-typed, schema-aware
153
+
154
+ function persist(client: Sync) { /* ... */ }
155
+ ```
156
+
157
+ This is the same idiom as tRPC's `type AppRouter = typeof appRouter` and
158
+ Drizzle's `typeof db` — the factory resolves the typed overload at the call
159
+ site, so `typeof sync` carries your schema. Do **not** write
160
+ `ReturnType<typeof Ablo>`: that collapses to the untyped last overload and
161
+ loses your model types. There is no bespoke client-type generic to import —
162
+ `typeof` your client value is the type.
163
+
131
164
  ```ts
132
165
  const schema = defineSchema({
133
166
  weatherReports: model({
@@ -140,7 +173,7 @@ const schema = defineSchema({
140
173
  const ablo = Ablo({
141
174
  schema,
142
175
  apiKey: process.env.ABLO_API_KEY, // written to .env.local by `npx ablo push`
143
- databaseUrl: process.env.DATABASE_URL, // your Postgres — rows live here, never with Ablo
176
+ databaseUrl: process.env.DATABASE_URL, // your Postgres, passed explicitly — rows live here
144
177
  });
145
178
 
146
179
  await ablo.ready();
@@ -388,29 +421,35 @@ curl https://api.abloatai.com/v1/commits \
388
421
 
389
422
  ## Your Database
390
423
 
391
- Every schema model is backed by **your own database** — Ablo is the transaction
392
- layer on top of it, never the home for your rows. Two ways to connect it:
424
+ In production, every schema model is backed by **your own database** — Ablo is
425
+ the transaction layer on top of it. Two ways to connect it:
393
426
 
394
427
  | | How Ablo reaches your Postgres | Use when |
395
428
  | --- | --- | --- |
396
- | **Connection string** (default) | `databaseUrl` at init. Ablo registers the connection once (sent over TLS, stored sealed, never echoed back) and commits each write directly — through a non-superuser role, behind row-level security. | You can hand over a scoped connection string. |
429
+ | **Connection string** (primary) | `databaseUrl` at init — passed explicitly, never auto-read from the environment. Ablo registers the connection once (sent over TLS, stored sealed, never echoed back) and commits each write directly — through a non-superuser role, behind row-level security. | You can hand over a scoped connection string. |
397
430
  | **Signed endpoint** | Your app exposes one route built from an ORM adapter (`prismaDataSource` / `drizzleDataSource`); Ablo sends signed commit requests and your app writes its own database. | Database credentials must never leave your infrastructure. |
398
431
 
399
- Same product, same truth either way: your database is the system of record. See
432
+ (No database yet? The hosted **sandbox** can host rows in Ablo's test plane
433
+ omit `databaseUrl` and pass an `apiKey` only, like Stripe test mode — so you can
434
+ try Ablo before connecting your Postgres.)
435
+
436
+ Same product, same truth either way: in production your database is the system of
437
+ record. See
400
438
  [Connect Your Database](./docs/data-sources.md) for both shapes.
401
439
 
402
440
  ## Configuration
403
441
 
404
- `Ablo({ ... })` takes three things: your schema, your key, and your database
405
- the last either as `databaseUrl` here or as a signed
406
- [Data Source endpoint](./docs/data-sources.md) in your app. Every other option
407
- has correct defaults:
442
+ `Ablo({ ... })` takes your schema, your key, and — in production — your database,
443
+ either as an explicit `databaseUrl` here or as a signed
444
+ [Data Source endpoint](./docs/data-sources.md) in your app. (`databaseUrl` is
445
+ never auto-read from the environment; omit it to try Ablo against the hosted
446
+ sandbox.) Every other option has correct defaults:
408
447
 
409
448
  | Option | Type | Default | Purpose |
410
449
  | --- | --- | --- | --- |
411
450
  | `schema` | `Schema` | — (required) | Typed model proxies (`ablo.<model>.*`) |
412
451
  | `apiKey` | `string \| ApiKeySetter \| null` | `process.env.ABLO_API_KEY` | Server key — a string, or an async function for rotation |
413
- | `databaseUrl` | `string \| null` | `process.env.DATABASE_URL` | Your Postgres, registered as the data plane. Server runtimes only — the SDK throws if it sees this in a browser. Omit it when your app exposes a signed [Data Source endpoint](./docs/data-sources.md) instead. |
452
+ | `databaseUrl` | `string \| null` | `—` | Your Postgres, registered as the data plane. **Must be passed explicitly — it is not auto-read from the environment.** If you have a `DATABASE_URL` set for another tool (Prisma, Drizzle, docker-compose), `Ablo()` ignores it unless you pass `databaseUrl` explicitly. Server runtimes only — the SDK throws if it sees this in a browser. Omit it when your app exposes a signed [Data Source endpoint](./docs/data-sources.md) instead, or when trying Ablo against the hosted sandbox. |
414
453
 
415
454
  Keep `apiKey` in trusted server runtimes. In the browser, `<AbloProvider>`
416
455
  authenticates with the signed-in user's session; the raw-key path is gated
package/dist/cli.cjs CHANGED
@@ -276803,7 +276803,7 @@ var ERROR_CODES = {
276803
276803
  malformed_subscription: wire("validation", 400, false, "The update_subscription payload was malformed; expected { syncGroups: string[] }."),
276804
276804
  model_claimed: wire("claim", 409, false, "The model instance is claimed by another participant."),
276805
276805
  model_claimed_timeout: wire("claim", 409, false, "Timed out waiting for a model claim to clear."),
276806
- model_claim_not_configured: client("claim", "Claiming was requested on a model that has no claim configuration."),
276806
+ model_claim_not_configured: client("claim", "Claiming requires the collaboration runtime, which the standard Ablo({ schema, apiKey }) client wires up for every model automatically \u2014 there is no per-model claim configuration to add. This appears only when a model proxy is constructed directly without that runtime (an internal/advanced path)."),
276807
276807
  // ── stale context / idempotency (409) ──────────────────────────────
276808
276808
  stale_context: wire("conflict", 409, true, "The write carried a readAt watermark that is now stale; re-read and retry."),
276809
276809
  idempotency_conflict: wire("conflict", 409, false, "The same Idempotency-Key was reused with a different request body."),
@@ -279942,6 +279942,14 @@ async function push(argv) {
279942
279942
  }
279943
279943
 
279944
279944
  // src/cli/migrate.ts
279945
+ var MIGRATE_USAGE = ` ablo migrate \u2014 provision your schema's tables in your own Postgres (DATABASE_URL)
279946
+
279947
+ Usage:
279948
+ npx ablo migrate Create the synced-model tables (with row-level security)
279949
+ npx ablo migrate --dry-run Print the SQL without executing it
279950
+ npx ablo migrate --output schema.sql Write the SQL to a file instead of applying
279951
+ npx ablo migrate --schema <path> Use a schema file other than ablo/schema.ts
279952
+ npx ablo migrate --export <name> Use a named export other than \`schema\``;
279945
279953
  var DEFAULT_SCHEMA_PATH2 = "ablo/schema.ts";
279946
279954
  var DEFAULT_EXPORT2 = "schema";
279947
279955
  function parseMigrateArgs(argv) {
@@ -280478,7 +280486,10 @@ ${import_picocolors7.default.dim(url)}`, "Approve in your browser");
280478
280486
  s.message("Provisioning a sandbox key\u2026");
280479
280487
  const provRes = await fetch(`${AUTH_URL}/api/cli/provision-key`, {
280480
280488
  method: "POST",
280481
- headers: { authorization: `Bearer ${accessToken}` }
280489
+ headers: { authorization: `Bearer ${accessToken}`, "content-type": "application/json" },
280490
+ // Pass the device_code so the server can scope the minted keys to the
280491
+ // project the user picked at /cli (login project picker). Harmless if none.
280492
+ body: JSON.stringify({ device_code: code.device_code })
280482
280493
  }).catch(() => null);
280483
280494
  if (!provRes || !provRes.ok) {
280484
280495
  s.stop("Could not provision a key.");
@@ -280706,6 +280717,39 @@ async function projects(argv) {
280706
280717
  );
280707
280718
  return;
280708
280719
  }
280720
+ if (sub === "rename") {
280721
+ const ref = argv[1];
280722
+ const name = argv.slice(2).join(" ").trim();
280723
+ if (!ref || ref.startsWith("-") || !name) {
280724
+ console.error(import_picocolors9.default.red(" usage: ablo projects rename <slug|id> <new name>"));
280725
+ process.exit(1);
280726
+ }
280727
+ const all = await fetchProjects();
280728
+ const target = all.find((p2) => p2.slug === ref || p2.id === ref);
280729
+ if (!target) {
280730
+ console.error(import_picocolors9.default.red(` No project "${ref}".`) + import_picocolors9.default.dim(" Run ablo projects list."));
280731
+ process.exit(1);
280732
+ }
280733
+ if (target.default) {
280734
+ console.error(import_picocolors9.default.red(" The default project cannot be renamed."));
280735
+ process.exit(1);
280736
+ }
280737
+ const { status: status2, body } = await request(`/api/v1/projects/${target.id}`, requireKey(), {
280738
+ method: "PATCH",
280739
+ body: { name }
280740
+ });
280741
+ if (status2 !== 200) {
280742
+ console.error(
280743
+ import_picocolors9.default.red(` Rename failed (${status2}): ${String(body.message ?? body.code ?? "")}`)
280744
+ );
280745
+ process.exit(1);
280746
+ }
280747
+ const updated = body;
280748
+ console.log(
280749
+ ` ${import_picocolors9.default.green("\u2713")} Renamed ${import_picocolors9.default.bold(updated.slug)} \u2192 ${import_picocolors9.default.bold(updated.name ?? updated.slug)} ${import_picocolors9.default.dim(`(${updated.id})`)}`
280750
+ );
280751
+ return;
280752
+ }
280709
280753
  if (sub === "use") {
280710
280754
  const ref = argv[1];
280711
280755
  if (!ref) {
@@ -280733,7 +280777,9 @@ async function projects(argv) {
280733
280777
  return;
280734
280778
  }
280735
280779
  console.error(
280736
- import_picocolors9.default.red(` unknown subcommand: ${sub}`) + import_picocolors9.default.dim(` (expected ${import_picocolors9.default.bold("list")}, ${import_picocolors9.default.bold("create")}, or ${import_picocolors9.default.bold("use")})`)
280780
+ import_picocolors9.default.red(` unknown subcommand: ${sub}`) + import_picocolors9.default.dim(
280781
+ ` (expected ${import_picocolors9.default.bold("list")}, ${import_picocolors9.default.bold("create")}, ${import_picocolors9.default.bold("rename")}, or ${import_picocolors9.default.bold("use")})`
280782
+ )
280737
280783
  );
280738
280784
  process.exit(1);
280739
280785
  }
@@ -282130,8 +282176,18 @@ async function drizzlePull(argv) {
282130
282176
  var LOGO = `
282131
282177
  ${brand("ablo")} ${import_picocolors18.default.dim("sync engine")}
282132
282178
  `;
282179
+ var SUBCOMMAND_USAGE = {
282180
+ migrate: MIGRATE_USAGE
282181
+ };
282133
282182
  async function main() {
282134
- const command = process.argv[2];
282183
+ let command = process.argv[2];
282184
+ if (command && process.argv.slice(3).some((a) => a === "--help" || a === "-h")) {
282185
+ if (SUBCOMMAND_USAGE[command]) {
282186
+ console.log(SUBCOMMAND_USAGE[command]);
282187
+ return;
282188
+ }
282189
+ command = void 0;
282190
+ }
282135
282191
  if (command === "init") {
282136
282192
  await init(process.argv.slice(3));
282137
282193
  } else if (command === "login") {
@@ -282149,8 +282205,14 @@ async function main() {
282149
282205
  } else if (command === "webhooks") {
282150
282206
  await webhooks(process.argv.slice(3));
282151
282207
  } else if (command === "dev") {
282152
- console.log(import_picocolors18.default.dim(" `ablo dev` is now `ablo push --watch` \u2014 running that."));
282153
- await dev([...process.argv.slice(3), "--watch"]);
282208
+ const devArgs = process.argv.slice(3);
282209
+ const oneShot = devArgs.includes("--no-watch");
282210
+ console.log(
282211
+ import_picocolors18.default.dim(
282212
+ oneShot ? " `ablo dev --no-watch` is `ablo push` (push once, no watcher) \u2014 running that." : " `ablo dev` is now `ablo push --watch` \u2014 running that."
282213
+ )
282214
+ );
282215
+ await dev(oneShot ? devArgs : [...devArgs, "--watch"]);
282154
282216
  } else if (command === "check") {
282155
282217
  await check(process.argv.slice(3));
282156
282218
  } else if (command === "pull") {
@@ -282208,6 +282270,8 @@ async function main() {
282208
282270
  console.log(` npx ablo pull prisma [path] Generate schema.ts from a Prisma schema (keeps enums + relations)`);
282209
282271
  console.log(` npx ablo pull drizzle <module> Generate schema.ts from a Drizzle schema (keeps enums + relations)`);
282210
282272
  console.log(` npx ablo check Check your existing database fits the schema (read-only, creates no tables)`);
282273
+ console.log(` npx ablo migrate Provision your synced-model tables in your own Postgres (DATABASE_URL)`);
282274
+ console.log(` npx ablo migrate --dry-run Print the SQL without executing (preview)`);
282211
282275
  console.log(` npx ablo push Upload your schema definition to Ablo (metadata only \u2014 rows stay in your DB)`);
282212
282276
  console.log(` npx ablo push --force Allow destructive/unexecutable changes`);
282213
282277
  console.log(` npx ablo push --rename a:b Treat model "a" as renamed to "b"`);
@@ -282285,6 +282349,10 @@ function detectOrm(override) {
282285
282349
  }
282286
282350
  return "none";
282287
282351
  }
282352
+ function detectNextLayout() {
282353
+ const useSrc = (0, import_fs12.existsSync)((0, import_path7.join)("src", "app")) || !(0, import_fs12.existsSync)("app") && (0, import_fs12.existsSync)("src");
282354
+ return useSrc ? { appBase: (0, import_path7.join)("src", "app"), aliasBase: "src" } : { appBase: "app", aliasBase: "." };
282355
+ }
282288
282356
  async function chooseOption(name, flagValue, fallback, allowed, interactive, prompt) {
282289
282357
  if (flagValue !== void 0) {
282290
282358
  if (!allowed.includes(flagValue)) {
@@ -282384,7 +282452,8 @@ async function init(args = []) {
282384
282452
  "Non-interactive (no TTY / --yes)"
282385
282453
  );
282386
282454
  }
282387
- const abloDir = "ablo";
282455
+ const layout = framework === "nextjs" ? detectNextLayout() : { appBase: "app", aliasBase: "." };
282456
+ const abloDir = (0, import_path7.join)(layout.aliasBase, "ablo");
282388
282457
  (0, import_fs12.mkdirSync)(abloDir, { recursive: true });
282389
282458
  const created = [];
282390
282459
  let schemaSource = generateSchema();
@@ -282415,41 +282484,51 @@ async function init(args = []) {
282415
282484
  created.push(`${abloDir}/schema.ts${schemaNote}`);
282416
282485
  (0, import_fs12.writeFileSync)((0, import_path7.join)(abloDir, "index.ts"), generateSyncConfig(auth, storage));
282417
282486
  created.push(`${abloDir}/index.ts`);
282487
+ (0, import_fs12.writeFileSync)((0, import_path7.join)(abloDir, "register.ts"), generateRegister());
282488
+ created.push(`${abloDir}/register.ts`);
282418
282489
  const orm = detectOrm(opts.orm);
282419
282490
  if (storage === "endpoint") {
282420
282491
  (0, import_fs12.writeFileSync)((0, import_path7.join)(abloDir, "data-source.ts"), generateDataSource(orm));
282421
282492
  created.push(`${abloDir}/data-source.ts${orm === "drizzle" ? " (Drizzle)" : " (Prisma)"}`);
282422
282493
  }
282423
282494
  const envFile = framework === "nextjs" ? ".env.local" : ".env";
282495
+ const resolvedKey = process.env.ABLO_API_KEY ? void 0 : resolveApiKey("sandbox");
282496
+ const wireRealKey = envFile === ".env.local" && Boolean(resolvedKey);
282497
+ const envBody = generateEnv(storage, { includeApiKey: !wireRealKey });
282424
282498
  if (!(0, import_fs12.existsSync)(envFile)) {
282425
- (0, import_fs12.writeFileSync)(envFile, generateEnv(storage));
282499
+ (0, import_fs12.writeFileSync)(envFile, envBody);
282426
282500
  created.push(envFile);
282427
282501
  } else {
282428
282502
  const existing = (0, import_fs12.readFileSync)(envFile, "utf-8");
282429
282503
  if (!existing.includes("ABLO_")) {
282430
- (0, import_fs12.writeFileSync)(envFile, existing + "\n" + generateEnv(storage));
282504
+ (0, import_fs12.writeFileSync)(envFile, existing + "\n" + envBody);
282431
282505
  created.push(`${envFile} ${import_picocolors18.default.dim("(appended)")}`);
282432
282506
  } else {
282433
282507
  created.push(`${envFile} ${import_picocolors18.default.dim("(already configured)")}`);
282434
282508
  }
282435
282509
  }
282510
+ if (wireRealKey && resolvedKey) {
282511
+ wireEnvLocal(resolvedKey);
282512
+ created.push(`.env.local ${import_picocolors18.default.dim("(ABLO_API_KEY set from your login)")}`);
282513
+ }
282436
282514
  if (agent) {
282437
282515
  (0, import_fs12.writeFileSync)((0, import_path7.join)(abloDir, "agent.ts"), generateAgent());
282438
282516
  created.push(`${abloDir}/agent.ts`);
282439
282517
  }
282440
282518
  if (framework === "nextjs") {
282441
282519
  if (storage === "endpoint") {
282442
- const webhookDir = (0, import_path7.join)("app", "api", "ablo", "webhooks");
282520
+ const webhookDir = (0, import_path7.join)(layout.appBase, "api", "ablo", "webhooks");
282443
282521
  (0, import_fs12.mkdirSync)(webhookDir, { recursive: true });
282444
282522
  (0, import_fs12.writeFileSync)((0, import_path7.join)(webhookDir, "route.ts"), generateWebhookRoute(orm));
282445
282523
  created.push(`${webhookDir}/route.ts${orm === "prisma" ? " (Prisma mirror)" : " (add your database write)"}`);
282446
282524
  }
282447
- (0, import_fs12.writeFileSync)((0, import_path7.join)("app", "providers.tsx"), generateProviders());
282448
- created.push(`app/providers.tsx ${import_picocolors18.default.dim("(wrap app/layout.tsx in <Providers>)")}`);
282449
- const sessionDir = (0, import_path7.join)("app", "api", "ablo-session");
282525
+ const providersPath = (0, import_path7.join)(layout.appBase, "providers.tsx");
282526
+ (0, import_fs12.writeFileSync)(providersPath, generateProviders());
282527
+ created.push(`${providersPath} ${import_picocolors18.default.dim(`(wrap ${(0, import_path7.join)(layout.appBase, "layout.tsx")} in <Providers>)`)}`);
282528
+ const sessionDir = (0, import_path7.join)(layout.appBase, "api", "ablo-session");
282450
282529
  (0, import_fs12.mkdirSync)(sessionDir, { recursive: true });
282451
282530
  (0, import_fs12.writeFileSync)((0, import_path7.join)(sessionDir, "route.ts"), generateSessionRoute());
282452
- created.push(`app/api/ablo-session/route.ts ${import_picocolors18.default.dim("(wire your auth)")}`);
282531
+ created.push(`${(0, import_path7.join)(sessionDir, "route.ts")} ${import_picocolors18.default.dim("(wire your auth)")}`);
282453
282532
  }
282454
282533
  if (framework !== "vanilla") {
282455
282534
  (0, import_fs12.writeFileSync)((0, import_path7.join)(abloDir, "TaskList.tsx"), generateComponent());
@@ -282478,7 +282557,7 @@ async function init(args = []) {
282478
282557
  `Provision your DB: ${import_picocolors18.default.bold("npx ablo migrate")} (creates your Ablo-model tables + the adapter tables; keep your own migrations for everything else), then mount ${import_picocolors18.default.bold(`${abloDir}/data-source.ts`)} at ${import_picocolors18.default.bold("/api/ablo/source")}`
282479
282558
  ],
282480
282559
  ...framework === "nextjs" ? [
282481
- `Wrap ${import_picocolors18.default.bold("app/layout.tsx")} in ${import_picocolors18.default.bold("<Providers>")} (app/providers.tsx) and add your auth to ${import_picocolors18.default.bold("app/api/ablo-session/route.ts")}`
282560
+ `Wrap ${import_picocolors18.default.bold((0, import_path7.join)(layout.appBase, "layout.tsx"))} in ${import_picocolors18.default.bold("<Providers>")} (${(0, import_path7.join)(layout.appBase, "providers.tsx")}) and add your auth to ${import_picocolors18.default.bold((0, import_path7.join)(layout.appBase, "api", "ablo-session", "route.ts"))}`
282482
282561
  ] : [],
282483
282562
  `Run ${import_picocolors18.default.bold(`${pm} run dev`)} and open two browser tabs \u2014 changes sync in real-time`,
282484
282563
  ...agent ? [
@@ -282557,14 +282636,31 @@ export const sync = Ablo({
282557
282636
  apiKey: process.env.ABLO_API_KEY,${databaseLine}${authLine}
282558
282637
  schema,
282559
282638
  });
282639
+
282640
+ // Name the client's type off the constructed value \u2014 the overload resolves at
282641
+ // this call site, so this carries the full typed surface. (Like tRPC's
282642
+ // \`typeof appRouter\`, Drizzle's \`typeof db\`.) Prefer this over \`ReturnType<typeof Ablo>\`.
282643
+ export type Sync = typeof sync;
282644
+ `;
282645
+ }
282646
+ function generateRegister() {
282647
+ return `import type { schema } from './schema';
282648
+
282649
+ declare module '@abloatai/ablo' {
282650
+ interface Register {
282651
+ Schema: typeof schema;
282652
+ }
282653
+ }
282654
+
282655
+ export {};
282560
282656
  `;
282561
282657
  }
282562
- function generateEnv(storage) {
282658
+ function generateEnv(storage, opts = {}) {
282659
+ const { includeApiKey = true } = opts;
282563
282660
  const databaseBlock = storage === "direct" ? "# Your Postgres \u2014 the system of record. The client registers this connection\n# (sent once over TLS, stored sealed) and every row lives HERE, never with Ablo.\n# Use a dedicated non-superuser role; the browser never sees this value.\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";
282564
282661
  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" : "";
282565
- return `# Ablo Sync Engine \u2014 use a sk_test_ key for local dev (\`npx ablo dev\`)
282566
- ABLO_API_KEY=sk_test_your_key_here
282567
- ${webhookBlock}${databaseBlock}`;
282662
+ 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" : "";
282663
+ return `${apiKeyBlock}${webhookBlock}${databaseBlock}`;
282568
282664
  }
282569
282665
  function generateDataSource(orm) {
282570
282666
  return orm === "drizzle" ? drizzleDataSourceScaffold() : prismaDataSourceScaffold();
@@ -42,7 +42,7 @@ import { createProtocolClient, } from './ApiClient.js';
42
42
  // Value import is cycle-safe: httpClient.js only value-imports ApiClient.js,
43
43
  // which imports this module type-only.
44
44
  import { createAbloHttpClient, } from './httpClient.js';
45
- import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, } from './auth.js';
45
+ import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, warnIfDatabaseUrlEnvIgnored, } from './auth.js';
46
46
  import { registerDataSource } from './registerDataSource.js';
47
47
  import { shouldUseInMemoryPersistence, } from './persistence.js';
48
48
  import { createModelProxy } from './createModelProxy.js';
@@ -725,6 +725,9 @@ export function Ablo(options) {
725
725
  dangerouslyAllowBrowser: options.dangerouslyAllowBrowser,
726
726
  });
727
727
  const { logger = consoleLogger } = internalOptions;
728
+ // Nudge (once) if a stray DATABASE_URL is in the env but `databaseUrl` wasn't
729
+ // passed — the env value is no longer auto-adopted (see resolveDatabaseUrl).
730
+ warnIfDatabaseUrlEnvIgnored(authInput, (m) => logger.warn(m));
728
731
  const schema = options.schema;
729
732
  const url = resolveBaseURL(authInput);
730
733
  // 1. Derive config from schema
@@ -1370,6 +1373,18 @@ export function Ablo(options) {
1370
1373
  },
1371
1374
  waitFor: (target, waitOptions) => publicClaims.waitFor({ model: target.model, id: target.id }, waitOptions),
1372
1375
  selfParticipantId: participantId,
1376
+ selfParticipantKind: kind,
1377
+ // Read-interest / write-intent enrolment for the typed surface.
1378
+ // `enterScope`/`pinScope` resolve the `{ [schemaKey]: id }` scope
1379
+ // through the SAME resolver the claim path uses, landing this client in
1380
+ // the entity-scoped group the holder's claim presence fans out on.
1381
+ // Return the store promise so the claim write path can AWAIT pinScope
1382
+ // BEFORE acquiring the lease (closing the subscribe-vs-broadcast race);
1383
+ // read-interest callers (`retrieve`/`claim.state`) still `void` it and
1384
+ // stay fire-and-forget. SOFT either way — the store swallows reconcile
1385
+ // errors so read interest never makes a read reject or stall.
1386
+ enterScope: (scope) => store.enterScope(scope),
1387
+ pinScope: (scope) => store.pinScope(scope),
1373
1388
  });
1374
1389
  }
1375
1390
  const commits = {
@@ -6,7 +6,7 @@
6
6
  * nouns directly to HTTP routes on sync-server.
7
7
  */
8
8
  import { AbloClaimedError, AbloAuthenticationError, AbloConnectionError, AbloValidationError, claimedError, translateHttpError, } from '../errors.js';
9
- import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, } from './auth.js';
9
+ import { assertBrowserSafety, readProcessEnv, resolveApiKey, resolveApiKeyValue, resolveAuthToken, resolveBaseURL, resolveBootstrapBaseUrl, resolveDatabaseUrl, warnIfDatabaseUrlEnvIgnored, } from './auth.js';
10
10
  import { registerDataSource } from './registerDataSource.js';
11
11
  import { toSeconds } from '../utils/duration.js';
12
12
  import { assertWriteOptions } from './writeOptionsSchema.js';
@@ -17,6 +17,9 @@ export function createProtocolClient(options) {
17
17
  const configuredApiKey = resolveApiKey(authInput);
18
18
  const configuredAuthToken = resolveAuthToken(authInput);
19
19
  const configuredDatabaseUrl = resolveDatabaseUrl(authInput);
20
+ // Nudge (once) if a stray DATABASE_URL is in the env but `databaseUrl` wasn't
21
+ // passed — no logger on this path, so the helper falls back to console.warn.
22
+ warnIfDatabaseUrlEnvIgnored(authInput);
20
23
  assertBrowserSafety({
21
24
  apiKey: configuredApiKey,
22
25
  databaseUrl: configuredDatabaseUrl,
@@ -45,12 +45,17 @@ export declare function resolveAuthToken(input: AuthResolveInput): string | null
45
45
  /**
46
46
  * Resolve the direct-URL connector's Postgres connection string.
47
47
  *
48
- * The default Data Source path should not call this: the customer keeps
49
- * `DATABASE_URL` in their app and exposes `dataSource(...)`. This helper exists
50
- * only for the opt-in direct connector where Ablo registers a dedicated tenant
51
- * database. Returns null for Ablo-managed storage.
48
+ * `databaseUrl` is an EXPLICIT, opt-in option: Ablo registers a dedicated
49
+ * tenant database only when the caller passes it to `Ablo(...)`. It is NOT
50
+ * read from `process.env.DATABASE_URL` per this module's invariant
51
+ * (`ABLO_API_KEY` is the only environment fallback), an app's `DATABASE_URL`
52
+ * (commonly set for Prisma/Drizzle/docker) must never silently flip the client
53
+ * into connection-string mode. The default Data Source path keeps `DATABASE_URL`
54
+ * in the app and exposes `dataSource(...)`; that path leaves this null.
55
+ * `warnIfDatabaseUrlEnvIgnored` nudges callers who set the env but omitted the option.
52
56
  */
53
57
  export declare function resolveDatabaseUrl(input: AuthResolveInput): string | null;
58
+ export declare function warnIfDatabaseUrlEnvIgnored(input: AuthResolveInput, warn?: (message: string) => void): void;
54
59
  export declare const ABLO_HOSTED_API_DOMAIN = "api.abloatai.com";
55
60
  export declare const ABLO_HOSTED_HTTP_BASE_URL = "https://api.abloatai.com";
56
61
  export declare const ABLO_DEFAULT_BASE_URL = "https://api.abloatai.com";
@@ -30,13 +30,48 @@ export function resolveAuthToken(input) {
30
30
  /**
31
31
  * Resolve the direct-URL connector's Postgres connection string.
32
32
  *
33
- * The default Data Source path should not call this: the customer keeps
34
- * `DATABASE_URL` in their app and exposes `dataSource(...)`. This helper exists
35
- * only for the opt-in direct connector where Ablo registers a dedicated tenant
36
- * database. Returns null for Ablo-managed storage.
33
+ * `databaseUrl` is an EXPLICIT, opt-in option: Ablo registers a dedicated
34
+ * tenant database only when the caller passes it to `Ablo(...)`. It is NOT
35
+ * read from `process.env.DATABASE_URL` per this module's invariant
36
+ * (`ABLO_API_KEY` is the only environment fallback), an app's `DATABASE_URL`
37
+ * (commonly set for Prisma/Drizzle/docker) must never silently flip the client
38
+ * into connection-string mode. The default Data Source path keeps `DATABASE_URL`
39
+ * in the app and exposes `dataSource(...)`; that path leaves this null.
40
+ * `warnIfDatabaseUrlEnvIgnored` nudges callers who set the env but omitted the option.
37
41
  */
38
42
  export function resolveDatabaseUrl(input) {
39
- return input.options.databaseUrl ?? input.env.DATABASE_URL ?? null;
43
+ return input.options.databaseUrl ?? null;
44
+ }
45
+ /**
46
+ * One-time migration nudge for the dropped `DATABASE_URL` env fallback.
47
+ *
48
+ * Earlier versions silently adopted `process.env.DATABASE_URL` when `databaseUrl`
49
+ * was not passed, registering a direct connector behind the caller's back — which
50
+ * surprised any app that keeps `DATABASE_URL` for another tool (Prisma, Drizzle,
51
+ * docker-compose) and, on localhost, tried to register a database Ablo's cloud
52
+ * cannot reach. The env value is now ignored; this points the developer at the
53
+ * explicit option instead of flipping their mode for them. Warns once per process
54
+ * so it never spams, and falls back to `console.warn` when no logger is supplied
55
+ * (the `transport: 'api'` client has none).
56
+ */
57
+ let warnedDatabaseUrlEnvIgnored = false;
58
+ export function warnIfDatabaseUrlEnvIgnored(input, warn) {
59
+ if (warnedDatabaseUrlEnvIgnored)
60
+ return;
61
+ if (input.options.databaseUrl != null)
62
+ return;
63
+ const envUrl = input.env.DATABASE_URL;
64
+ if (typeof envUrl !== 'string' || envUrl.length === 0)
65
+ return;
66
+ warnedDatabaseUrlEnvIgnored = true;
67
+ const message = 'Found DATABASE_URL in the environment but `databaseUrl` was not passed to Ablo(...). ' +
68
+ 'Ablo no longer auto-adopts DATABASE_URL — the environment value is ignored. ' +
69
+ 'To register your Postgres directly, pass `databaseUrl: process.env.DATABASE_URL` explicitly; ' +
70
+ 'otherwise ignore this (the hosted sandbox and signed Data Source endpoints need no databaseUrl).';
71
+ if (warn)
72
+ warn(message);
73
+ else if (typeof console !== 'undefined')
74
+ console.warn('[sync]', message);
40
75
  }
41
76
  export const ABLO_HOSTED_API_DOMAIN = 'api.abloatai.com';
42
77
  export const ABLO_HOSTED_HTTP_BASE_URL = `https://${ABLO_HOSTED_API_DOMAIN}`;
@@ -136,6 +136,31 @@ export interface ModelCollaboration<T> {
136
136
  * from "someone else holds it" in `claimOrWait`.
137
137
  */
138
138
  readonly selfParticipantId: string;
139
+ /**
140
+ * The local participant's kind (`'user' | 'agent' | 'system'`). Used to
141
+ * stamp the synthesized self-claim returned from `claim.state` when the
142
+ * LOCAL proxy holds the lease (server presence frames exclude one's own
143
+ * claims, so the holder must build its own view).
144
+ */
145
+ readonly selfParticipantKind?: 'user' | 'agent' | 'system';
146
+ /**
147
+ * Subscribe the connection to a scope's sync group(s) (read-interest).
148
+ * The typed surface calls this on single-entity reads/claim observation so
149
+ * a Node/agent client lands in the SAME entity-scoped group the holder's
150
+ * claim presence fans out on — otherwise a peer subscribed only to
151
+ * `org:`/`user:` groups never sees claim broadcasts. Fire-and-forget and
152
+ * SOFT: read interest is best-effort and must never make a read reject or
153
+ * stall (see `AreaOfInterestManager.reconcile`). Optional so minimal test
154
+ * doubles can omit it. Forwards to `BaseSyncedStore.enterScope`.
155
+ */
156
+ enterScope?(scope: Record<string, string>): void | Promise<void>;
157
+ /**
158
+ * Pin a scope's sync group(s) (write-intent / prominence): a row this
159
+ * client holds an active claim on stays subscribed regardless of
160
+ * navigation. Same fire-and-forget, soft semantics as `enterScope`.
161
+ * Forwards to `BaseSyncedStore.pinScope`.
162
+ */
163
+ pinScope?(scope: Record<string, string>): void | Promise<void>;
139
164
  }
140
165
  export interface ClaimTargetOptions<T = Record<string, unknown>> {
141
166
  /** Phase shown to observers while held. Defaults to `'editing'`. */