@abloatai/ablo 0.9.2 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +6 -0
  3. package/README.md +40 -22
  4. package/dist/BaseSyncedStore.d.ts +2 -36
  5. package/dist/BaseSyncedStore.js +5 -53
  6. package/dist/NetworkMonitor.js +4 -1
  7. package/dist/SyncClient.d.ts +10 -5
  8. package/dist/SyncClient.js +63 -1
  9. package/dist/SyncEngineContext.js +5 -1
  10. package/dist/auth/index.js +3 -1
  11. package/dist/cli.cjs +302645 -0
  12. package/dist/client/Ablo.d.ts +12 -3
  13. package/dist/client/Ablo.js +28 -2
  14. package/dist/client/ApiClient.js +39 -6
  15. package/dist/client/createInternalComponents.js +1 -1
  16. package/dist/client/createModelProxy.d.ts +9 -0
  17. package/dist/client/createModelProxy.js +34 -10
  18. package/dist/client/persistence.d.ts +6 -1
  19. package/dist/client/persistence.js +1 -1
  20. package/dist/client/registerDataSource.d.ts +4 -4
  21. package/dist/client/registerDataSource.js +39 -31
  22. package/dist/client/writeOptionsSchema.d.ts +50 -0
  23. package/dist/client/writeOptionsSchema.js +57 -0
  24. package/dist/core/index.d.ts +18 -26
  25. package/dist/core/index.js +22 -46
  26. package/dist/errorCodes.d.ts +13 -0
  27. package/dist/errorCodes.js +16 -1
  28. package/dist/index.d.ts +3 -0
  29. package/dist/index.js +7 -0
  30. package/dist/interfaces/index.d.ts +10 -0
  31. package/dist/mutators/UndoManager.d.ts +31 -5
  32. package/dist/mutators/UndoManager.js +113 -1
  33. package/dist/schema/ddl.js +2 -1
  34. package/dist/schema/field.js +2 -1
  35. package/dist/schema/serialize.js +2 -1
  36. package/dist/server/storage-mode.d.ts +7 -0
  37. package/dist/server/storage-mode.js +6 -0
  38. package/dist/source/adapters/drizzle.js +3 -2
  39. package/dist/source/adapters/kysely.d.ts +68 -0
  40. package/dist/source/adapters/kysely.js +210 -0
  41. package/dist/source/adapters/memory.js +2 -1
  42. package/dist/source/adapters/prisma.js +3 -2
  43. package/dist/source/index.js +2 -1
  44. package/dist/transactions/TransactionQueue.d.ts +6 -7
  45. package/dist/transactions/TransactionQueue.js +33 -9
  46. package/dist/utils/duration.js +3 -2
  47. package/docs/client-behavior.md +1 -1
  48. package/docs/data-sources.md +61 -42
  49. package/docs/guarantees.md +2 -2
  50. package/docs/index.md +2 -2
  51. package/docs/integration-guide.md +4 -7
  52. package/docs/mcp.md +1 -1
  53. package/docs/quickstart.md +84 -37
  54. package/docs/schema-contract.md +2 -4
  55. package/llms-full.txt +360 -0
  56. package/llms.txt +14 -9
  57. package/package.json +22 -2
package/AGENTS.md CHANGED
@@ -8,7 +8,7 @@ Claims don't lock. If another writer holds the row, `claim` waits for them and r
8
8
 
9
9
  Don't hand-write the integration. Run the CLI; it generates the current-API schema, client, Data Source endpoint, and (for Next.js) the browser provider + session route:
10
10
 
11
- - **Scaffold:** `npx ablo init --yes` — flag-driven, never prompts. Override defaults with `--framework <nextjs|vite|remix|vanilla>`, `--auth <apikey|…>`, `--storage <datasource|managed>`, `--no-agent`, `--no-pull`, `--no-install`, `--no-login`. (Plain `ablo init` needs a TTY and will **HANG** in an agent/CI run — always pass `--yes`.)
11
+ - **Scaffold:** `npx ablo init --yes` — flag-driven, never prompts. Override defaults with `--framework <nextjs|vite|remix|vanilla>`, `--auth <apikey|…>`, `--no-agent`, `--no-pull`, `--no-install`, `--no-login`. (Plain `ablo init` needs a TTY and will **HANG** in an agent/CI run — always pass `--yes`.)
12
12
  - **Auth:** set `ABLO_API_KEY` in the environment. Do **NOT** run `ablo login` — it opens a browser device flow and blocks an agent.
13
13
  - **Provision your DB (Data Source mode):** `npx ablo migrate` creates the tables for your Ablo models plus the adapter's bookkeeping tables (`ablo_outbox`, `ablo_idempotency`). It does **not** touch your other tables — keep your own migrations (drizzle-kit / prisma migrate) for auth and anything not in the Ablo schema.
14
14
  - **Adopt an existing DB schema:** `npx ablo pull prisma [path]` / `pull drizzle <module>` (lossless) or `pull` (live DB, lossy). Writes `ablo/schema.ts`.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.3
4
+
5
+ ### Patch Changes
6
+
7
+ - Onboarding: quickstart leads with your-own-database (Drizzle Data Source), drop Ablo-managed mode, add `ablo push` step; context7 library-claim config.
8
+
3
9
  ## 0.9.2
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -33,27 +33,43 @@ claims are visible while the work is still in progress.
33
33
 
34
34
  [Get started ↓](#quick-start) · point your coding agent at the shipped `llms.txt`
35
35
 
36
- It works with the auth and database you already have: realtime data is scoped to
37
- *sync groups* from your own identity, and your database can stay the source of
38
- truth via a Data Source.
36
+ It works with the auth and database you already have. **Your database is the
37
+ system of record Ablo never hosts your data.** Ablo is the transaction layer
38
+ on top of it: realtime data is scoped to *sync groups* from your own identity,
39
+ and every committed row lives in your Postgres.
39
40
 
40
41
  **Built for** collaborative editors, AI agent workflows, and internal tools —
41
42
  anywhere people and agents change shared state and everyone has to see it live.
42
43
 
43
44
  ## Set up
44
45
 
46
+ The CLI takes you from nothing to a synced schema — it handles the account,
47
+ the key, and the env file. You bring one thing: a Postgres `DATABASE_URL`
48
+ (local, Neon, RDS — any will do; **your database is the system of record,
49
+ Ablo never hosts your data**).
50
+
45
51
  ```bash
46
52
  npm install @abloatai/ablo
53
+ npx ablo login # opens the browser: sign in (or sign up) → a sk_test_ key is saved locally
54
+ npx ablo init # scaffolds ablo/schema.ts (offers to log in if you skipped it)
55
+ npx ablo migrate # creates the synced tables in YOUR Postgres (reads DATABASE_URL)
56
+ npx ablo dev # pushes your schema (test mode), writes ABLO_API_KEY to .env.local, watches for changes
47
57
  ```
48
58
 
49
- **Keys & runtime.** Ablo needs Node 24+ and TypeScript 5+. Grab an `sk_test_*`
50
- key for a sandbox
51
- (`export ABLO_API_KEY=sk_test_...`); keep keys in trusted server runtimes only.
59
+ After `ablo dev`, the [Quick Start](#quick-start) below runs as-is
60
+ `ABLO_API_KEY` is already in `.env.local` (frameworks load it automatically;
61
+ plain Node: `node --env-file=.env.local app.ts`). `npx ablo status` shows
62
+ what's configured at any time.
63
+
64
+ **Keys & runtime.** Ablo needs Node 24+ and TypeScript 5+. Keys come in two of
65
+ *your* environments — `sk_test_` and `sk_live_`, like Stripe — and `ablo login`
66
+ mints both. Keep the key and the database URL in trusted server runtimes only.
52
67
  In the browser, `<AbloProvider>` authenticates with the signed-in user's
53
- session — never the raw key.
68
+ session — never the raw key, never the database URL. Prefer the connection
69
+ string never leaving your infrastructure? Expose a signed
70
+ [Data Source endpoint](./docs/data-sources.md) instead and omit `databaseUrl`.
54
71
 
55
- Then wire it by hand the [Quick Start](#quick-start) below is the shape to
56
- copy. For production (React, an existing backend, Data Source, agents), the
72
+ For production (React, an existing backend, Data Source, agents), the
57
73
  [Integration Guide](./docs/integration-guide.md) is the deeper map.
58
74
 
59
75
  **Prefer to let an agent wire it?** The package ships an `llms.txt` — a precise
@@ -78,7 +94,8 @@ const schema = defineSchema({
78
94
 
79
95
  const ablo = Ablo({
80
96
  schema,
81
- apiKey: process.env.ABLO_API_KEY,
97
+ apiKey: process.env.ABLO_API_KEY, // written to .env.local by `npx ablo dev`
98
+ databaseUrl: process.env.DATABASE_URL, // your Postgres — rows live here, never with Ablo
82
99
  });
83
100
 
84
101
  await ablo.ready();
@@ -99,7 +116,7 @@ const forecast = await fetchForecast(report.location); // slow: API or LLM call
99
116
  await ablo.weatherReports.update({ id: report.id, data: { status: 'ready', forecast } });
100
117
 
101
118
  const ready = ablo.weatherReports.get(created.id);
102
- console.log({ id: ready.id, status: ready.status });
119
+ console.log({ id: ready?.id, status: ready?.status });
103
120
 
104
121
  await ablo.dispose();
105
122
  ```
@@ -324,18 +341,18 @@ curl https://api.abloatai.com/v1/commits \
324
341
  { "object": "commit_receipt", "status": "confirmed", "serverTxId": "tx_…", "lastSyncId": 1042, "ops": 1 }
325
342
  ```
326
343
 
327
- ## Connect Your Database
344
+ ## Your Database
328
345
 
329
- Every schema model has a backing store. By default, Ablo stores rows for the
330
- models you declare, so `ablo.weatherReports.create({ data })` and `ablo.weatherReports.update({ id, data })`
331
- write to Ablo-managed state.
346
+ Every schema model is backed by **your own database** Ablo is the transaction
347
+ layer on top of it, never the home for your rows. Two ways to connect it:
332
348
 
333
- If your existing database stays the source of truth, connect it as a Data
334
- Source: Ablo sends signed commit requests to an endpoint you host, and your app
335
- writes its own database. Your `DATABASE_URL` stays in your appAblo only ever
336
- sees the API key.
349
+ | | How Ablo reaches your Postgres | Use when |
350
+ | --- | --- | --- |
351
+ | **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. |
352
+ | **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. |
337
353
 
338
- See [Connect Your Database](./docs/data-sources.md) for the integration shape.
354
+ Same product, same truth either way: your database is the system of record. See
355
+ [Connect Your Database](./docs/data-sources.md) for both shapes.
339
356
 
340
357
  ## Configuration
341
358
 
@@ -345,6 +362,7 @@ See [Connect Your Database](./docs/data-sources.md) for the integration shape.
345
362
  | --- | --- | --- | --- |
346
363
  | `schema` | `Schema` | — (required) | Typed model proxies (`ablo.<model>.*`) |
347
364
  | `apiKey` | `string \| ApiKeySetter \| null` | `process.env.ABLO_API_KEY` | Server key — a string, or an async function for rotation |
365
+ | `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. |
348
366
  | `baseURL` | `string` | `wss://api.abloatai.com` | Point at a self-hosted or private API |
349
367
 
350
368
  Keep `apiKey` in trusted server runtimes. In the browser, `<AbloProvider>`
@@ -395,11 +413,11 @@ contract; there are no retry or timeout knobs to tune.
395
413
  - [Identity & Sync Groups](./docs/identity.md) — use your own authentication; tell Ablo who's connecting and how org / team / user map to sync-group scope.
396
414
  - [Schema Contract](./docs/schema-contract.md) — one schema becomes typed model clients, React reads, agent writes, Data Source shape, and schema push.
397
415
  - [Guarantees](./docs/guarantees.md) — confirmed writes, stale-write protection, claim coordination, and agent lifecycle.
398
- - [Integration Guide](./docs/integration-guide.md) — pick the backing mode and integrate React, Data Source, multiplayer, and agents.
416
+ - [Integration Guide](./docs/integration-guide.md) — integrate React, your database, multiplayer, and agents.
399
417
  - [React](./docs/react.md) — `<AbloProvider>`, `useAblo`, presence, status, and bootstrap gating.
400
418
  - [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.
401
419
  - [Client Behavior](./docs/client-behavior.md) — options, errors, retries, timeouts, and public imports.
402
- - [Connect Your Database](./docs/data-sources.md) — keep canonical rows in your app database without giving Ablo database credentials.
420
+ - [Connect Your Database](./docs/data-sources.md) — connect your Postgres by connection string (`databaseUrl`) or signed endpoint; your database is the system of record either way.
403
421
  - [Existing Python Backend](./docs/examples/existing-python-backend.md) — migrate existing Python endpoints to multiplayer and agent-safe writes gradually.
404
422
  - [AI SDK Tool](./docs/examples/ai-sdk-tool.md) — use Ablo inside an AI SDK tool call.
405
423
  - [Server Agent](./docs/examples/server-agent.md) — schema-backed worker.
@@ -21,7 +21,6 @@ import { QueryProcessor } from './core/QueryProcessor.js';
21
21
  import { Model } from './Model.js';
22
22
  import { ModelScope } from './ObjectPool.js';
23
23
  import type { Schema } from './schema/schema.js';
24
- import { type ReaderActions } from './mutators/readerActions.js';
25
24
  import type { LocalMutation } from './react/context.js';
26
25
  import type { AuthCredentialSource } from './auth/credentialSource.js';
27
26
  /** Constructor type for Model subclasses (accepts abstract classes) */
@@ -224,18 +223,6 @@ export declare function deriveSyncPlanFromSchema(schema: Schema): {
224
223
  enrichmentPlan: EnrichmentPlanEntry[];
225
224
  foreignKeyIndexes: ForeignKeyIndexSpec[];
226
225
  };
227
- /**
228
- * Schema-derived accessor namespace exposed on `store.query`. Each key is
229
- * a model name from the schema and resolves to a `ReaderActions<S, K>`
230
- * with `findById` / `findMany` / `findFirst` / `count`. Return types are
231
- * inferred from the schema (`InferModel<S, K>`) so callers don't need to
232
- * cast or pass class constructors.
233
- *
234
- * Prisma / Drizzle / Zero all use this shape: `store.query.<modelKey>.*`.
235
- */
236
- export type QueryNamespace<S extends Schema> = {
237
- readonly [K in keyof S['models'] & string]: ReaderActions<S, K>;
238
- };
239
226
  export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaboration> = DefaultCollaborationEvents, TSchema extends Schema = Schema> {
240
227
  syncStatus: SyncStatus;
241
228
  protected readonly syncClient: SyncClient;
@@ -244,13 +231,10 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
244
231
  protected readonly modelRegistry: ModelRegistry;
245
232
  protected readonly auth?: AuthCredentialSource;
246
233
  /**
247
- * Schema the store was constructed with. Persisted so the `query`
248
- * accessor namespace can build typed per-model reader actions lazily
249
- * without callers having to pass the schema at every lookup site.
234
+ * Schema the store was constructed with. Used by the schema-typed
235
+ * `create(key, data)` factory and model self-healing.
250
236
  */
251
237
  protected readonly schema?: TSchema;
252
- /** Lazily-built `query.<modelKey>.*` accessor namespace. */
253
- private _queryProxy?;
254
238
  protected syncWebSocket: SyncWebSocket<TCollaboration> | null;
255
239
  private _syncServerUrl?;
256
240
  /**
@@ -694,24 +678,6 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
694
678
  id: string;
695
679
  archivedAt?: Date | null;
696
680
  }>(entity: T): Promise<void>;
697
- /**
698
- * Schema-keyed accessor namespace — the primary type-safe lookup surface.
699
- *
700
- * ```ts
701
- * const chat = store.query.chats.retrieve(chatId); // Chat | undefined
702
- * const slides = store.query.slides.findMany({ where: { deckId } }); // Slide[]
703
- * ```
704
- *
705
- * Each `query.<modelKey>` is a `ReaderActions<Schema, K>` built lazily on
706
- * first access via `createReaderActions`. The returned types are inferred
707
- * from the schema (`InferModel<S, K>`), including `InferRelations` — so
708
- * `chat.messages`, `slide.layers`, etc. are typed without a cast.
709
- *
710
- * Throws if the store was constructed without a schema (class-based
711
- * subclasses that wire models via `modelRegistry.registerModel` directly
712
- * don't have access to schema-derived inference).
713
- */
714
- get query(): QueryNamespace<TSchema>;
715
681
  /** Retrieve a single entity by id. Synchronous pool read. */
716
682
  retrieve(_modelClass: ModelConstructor<Model>, id: string): Model | undefined;
717
683
  /** Find any entity by ID regardless of type */
@@ -22,7 +22,6 @@ import { getContext } from './context.js';
22
22
  import { SyncSessionError } from './errors.js';
23
23
  import { ModelScope } from './ObjectPool.js';
24
24
  import { LazyReferenceCollection } from './LazyReferenceCollection.js';
25
- import { createReaderActions } from './mutators/readerActions.js';
26
25
  /** Bootstrap timeout configuration */
27
26
  export const BOOTSTRAP_CONFIG = {
28
27
  OVERALL_TIMEOUT_MS: 15_000,
@@ -126,13 +125,10 @@ export class BaseSyncedStore {
126
125
  modelRegistry;
127
126
  auth;
128
127
  /**
129
- * Schema the store was constructed with. Persisted so the `query`
130
- * accessor namespace can build typed per-model reader actions lazily
131
- * without callers having to pass the schema at every lookup site.
128
+ * Schema the store was constructed with. Used by the schema-typed
129
+ * `create(key, data)` factory and model self-healing.
132
130
  */
133
131
  schema;
134
- /** Lazily-built `query.<modelKey>.*` accessor namespace. */
135
- _queryProxy;
136
132
  // ── Real-time sync ──
137
133
  syncWebSocket = null;
138
134
  _syncServerUrl;
@@ -1789,53 +1785,9 @@ export class BaseSyncedStore {
1789
1785
  this.syncClient.update(model);
1790
1786
  }
1791
1787
  // ── Query API ────────────────────────────────────────────────────────────
1792
- /**
1793
- * Schema-keyed accessor namespace the primary type-safe lookup surface.
1794
- *
1795
- * ```ts
1796
- * const chat = store.query.chats.retrieve(chatId); // Chat | undefined
1797
- * const slides = store.query.slides.findMany({ where: { deckId } }); // Slide[]
1798
- * ```
1799
- *
1800
- * Each `query.<modelKey>` is a `ReaderActions<Schema, K>` built lazily on
1801
- * first access via `createReaderActions`. The returned types are inferred
1802
- * from the schema (`InferModel<S, K>`), including `InferRelations` — so
1803
- * `chat.messages`, `slide.layers`, etc. are typed without a cast.
1804
- *
1805
- * Throws if the store was constructed without a schema (class-based
1806
- * subclasses that wire models via `modelRegistry.registerModel` directly
1807
- * don't have access to schema-derived inference).
1808
- */
1809
- get query() {
1810
- if (!this.schema) {
1811
- throw new AbloValidationError('store.query requires a schema to be passed to the BaseSyncedStore constructor. ' +
1812
- 'Pass `{ schema }` in the dependencies argument.', { code: 'store_query_schema_missing' });
1813
- }
1814
- if (!this._queryProxy) {
1815
- const schema = this.schema;
1816
- // BaseSyncedStore satisfies SyncStoreContract structurally via
1817
- // `findById` / `queryByClass` / `save` / `delete`. Pass `this`
1818
- // directly — `createReaderActions` accepts the contract shape.
1819
- const store = this;
1820
- const cache = new Map();
1821
- this._queryProxy = new Proxy({}, {
1822
- get: (_target, prop) => {
1823
- if (typeof prop !== 'string')
1824
- return undefined;
1825
- const cached = cache.get(prop);
1826
- if (cached)
1827
- return cached;
1828
- if (!(prop in schema.models)) {
1829
- throw new AbloValidationError(`store.query: unknown model key "${prop}". Known keys: ${Object.keys(schema.models).join(', ')}`, { code: 'store_query_unknown_model' });
1830
- }
1831
- const actions = createReaderActions(schema, prop, store);
1832
- cache.set(prop, actions);
1833
- return actions;
1834
- },
1835
- });
1836
- }
1837
- return this._queryProxy;
1838
- }
1788
+ // `store.query.<model>.*` was DELETED — `ablo.<model>.get/getAll` is the
1789
+ // one read surface. Custom mutators still read transactionally through
1790
+ // `tx.<model>` (mutators/Transaction.ts), which owns `createReaderActions`.
1839
1791
  /** Retrieve a single entity by id. Synchronous pool read. */
1840
1792
  retrieve(_modelClass, id) {
1841
1793
  return this.objectPool.get(id);
@@ -9,7 +9,10 @@
9
9
  import { EventEmitter } from 'events';
10
10
  import { getContext } from './context.js';
11
11
  export class NetworkMonitor extends EventEmitter {
12
- isOnline = typeof navigator !== 'undefined' ? navigator.onLine : true;
12
+ // Only `navigator.onLine === false` means offline. Node 18+ exposes a global
13
+ // `navigator` with `onLine === undefined`, so the naive `navigator.onLine`
14
+ // would seed `false` (offline) on every server client — start optimistic.
15
+ isOnline = !(typeof navigator !== 'undefined' && navigator.onLine === false);
13
16
  lastOnlineCheck = new Date();
14
17
  constructor() {
15
18
  super();
@@ -13,7 +13,7 @@ import { EventEmitter } from 'events';
13
13
  import { TransactionQueue } from './transactions/TransactionQueue.js';
14
14
  import { type OptimisticEchoMetrics } from './transactions/OptimisticEchoTracker.js';
15
15
  import type { Database } from './Database.js';
16
- import type { MutationOptions } from './interfaces/index.js';
16
+ import type { WriteOptions } from './interfaces/index.js';
17
17
  interface SyncObserver {
18
18
  onSync?: (event: SyncEvent) => void;
19
19
  }
@@ -38,7 +38,6 @@ export interface RehydrationStats {
38
38
  healed: number;
39
39
  elapsedMs: number;
40
40
  }
41
- type MutationWriteOptions = Pick<MutationOptions, 'readAt' | 'onStale'>;
42
41
  export declare class SyncClient extends EventEmitter {
43
42
  private objectPool;
44
43
  private database;
@@ -114,6 +113,12 @@ export declare class SyncClient extends EventEmitter {
114
113
  * Initialize sync client with authentication
115
114
  */
116
115
  initialize(userId: string, organizationId: string): Promise<void>;
116
+ /**
117
+ * The organization this client writes under (set by `initialize`).
118
+ * Read by the model proxy so `create()` defaults `organizationId` the
119
+ * same way the mutator path does — `null` until identity is wired.
120
+ */
121
+ getOrganizationId(): string | null;
117
122
  /**
118
123
  * Self-healing helper for individual model records.
119
124
  *
@@ -172,9 +177,9 @@ export declare class SyncClient extends EventEmitter {
172
177
  */
173
178
  private captureModelChanges;
174
179
  /** Add new model (CREATE) - works offline */
175
- add(model: Model, options?: MutationWriteOptions): void;
180
+ add(model: Model, options?: WriteOptions): void;
176
181
  /** Update existing model (UPDATE) - works offline */
177
- update(model: Model, options?: MutationWriteOptions): void;
182
+ update(model: Model, options?: WriteOptions): void;
178
183
  /**
179
184
  * Update existing model with pre-computed changes.
180
185
  * Used by saveManyOptimized when incoming models have empty change-tracking
@@ -186,7 +191,7 @@ export declare class SyncClient extends EventEmitter {
186
191
  * but still need optimistic pool updates at the sync layer. */
187
192
  get gql(): import("./interfaces/index.js").MutationExecutor;
188
193
  /** Delete model (DELETE) - works offline */
189
- delete(model: Model, options?: MutationWriteOptions): void;
194
+ delete(model: Model, options?: WriteOptions): void;
190
195
  /**
191
196
  * Clear all pending mutations for a specific model
192
197
  * Called before deletion to prevent "layer not found" errors on the server
@@ -313,6 +313,14 @@ export class SyncClient extends EventEmitter {
313
313
  this.emit('sync:offline');
314
314
  }
315
315
  }
316
+ /**
317
+ * The organization this client writes under (set by `initialize`).
318
+ * Read by the model proxy so `create()` defaults `organizationId` the
319
+ * same way the mutator path does — `null` until identity is wired.
320
+ */
321
+ getOrganizationId() {
322
+ return this.organizationId;
323
+ }
316
324
  /**
317
325
  * Self-healing helper for individual model records.
318
326
  *
@@ -1311,7 +1319,61 @@ export class SyncClient extends EventEmitter {
1311
1319
  */
1312
1320
  onLocalTransaction(listener) {
1313
1321
  this.transactionQueue.on('transaction:created', listener);
1314
- return () => this.transactionQueue.off('transaction:created', listener);
1322
+ // Commit-lane writes (`ablo.commits.create` — the agent/atomic door) ride
1323
+ // their own `commit:created` event: they have no optimistic pool apply,
1324
+ // so they must not feed the echo tracker's `transaction:created` path.
1325
+ // Enrich each operation with previous state captured from the pool HERE
1326
+ // (the queue is pool-free) and hand the synthesized transaction to the
1327
+ // same listener, so undo observes every write door — one stream.
1328
+ const onCommitCreated = (payload) => {
1329
+ const TYPE_BY_WIRE = {
1330
+ CREATE: 'create',
1331
+ UPDATE: 'update',
1332
+ DELETE: 'delete',
1333
+ ARCHIVE: 'archive',
1334
+ UNARCHIVE: 'unarchive',
1335
+ };
1336
+ payload.operations.forEach((op, index) => {
1337
+ const type = TYPE_BY_WIRE[op.type];
1338
+ if (!type || !op.id)
1339
+ return;
1340
+ const resident = this.objectPool.get(op.id);
1341
+ const snapshot = type === 'create' ? undefined : resident?.toJSON();
1342
+ // A DELETE of a row the local graph never saw is not invertible —
1343
+ // recording it would make undo "restore" an empty husk. Skip it.
1344
+ if (type === 'delete' && !snapshot)
1345
+ return;
1346
+ // UPDATE inverse must only revert the fields this op actually wrote;
1347
+ // handing undo the FULL row would clobber concurrent edits to
1348
+ // unrelated fields on revert.
1349
+ const previousData = type === 'update' && snapshot && op.input
1350
+ ? Object.fromEntries(Object.keys(op.input).map((key) => [key, snapshot[key]]))
1351
+ : snapshot ?? null;
1352
+ listener({
1353
+ id: `${payload.clientTxId}_op${index}`,
1354
+ type,
1355
+ modelName: op.model,
1356
+ modelId: op.id,
1357
+ modelKey: op.model,
1358
+ data: op.input ?? undefined,
1359
+ previousData,
1360
+ context: {
1361
+ userId: this.userId ?? '',
1362
+ organizationId: this.organizationId ?? '',
1363
+ },
1364
+ status: 'pending',
1365
+ createdAt: Date.now(),
1366
+ attempts: 0,
1367
+ priority: 'normal',
1368
+ priorityScore: 0,
1369
+ });
1370
+ });
1371
+ };
1372
+ this.transactionQueue.on('commit:created', onCommitCreated);
1373
+ return () => {
1374
+ this.transactionQueue.off('transaction:created', listener);
1375
+ this.transactionQueue.off('commit:created', onCommitCreated);
1376
+ };
1315
1377
  }
1316
1378
  /**
1317
1379
  * Wait for the latest in-flight transaction for (modelName, modelId)
@@ -43,7 +43,11 @@ export const noopAnalytics = {
43
43
  /** Browser-native online status provider */
44
44
  export const browserOnlineStatus = {
45
45
  isOnline() {
46
- return typeof navigator !== 'undefined' ? navigator.onLine : true;
46
+ // Only `navigator.onLine === false` is the MDN-reliable "definitely offline"
47
+ // signal. Don't use `!navigator.onLine`: Node 18+ exposes a global
48
+ // `navigator` whose `onLine` is `undefined`, which `!` would read as offline —
49
+ // wedging every Node/server client (agents, worker, MCP) into a false offline.
50
+ return !(typeof navigator !== 'undefined' && navigator.onLine === false);
47
51
  },
48
52
  };
49
53
  /** Session error detector — delegates to SyncSessionError so detection is
@@ -15,7 +15,9 @@ import { parseCapabilityExchangeResponse, parseIdentityResolveResponse, } from '
15
15
  import { AbloAuthenticationError, hasWireCode, translateHttpError } from '../errors.js';
16
16
  export async function exchangeApiKey(options) {
17
17
  if (!options.apiKey) {
18
- throw new AbloAuthenticationError('apiKey is required for capability exchange', { code: 'apikey_missing' });
18
+ throw new AbloAuthenticationError('No API key found. Set ABLO_API_KEY in your environment — `npx ablo login` ' +
19
+ 'then `npx ablo dev` writes it into .env.local for you — or pass ' +
20
+ '`apiKey` to Ablo({ ... }) directly.', { code: 'apikey_missing' });
19
21
  }
20
22
  if (!options.baseUrl) {
21
23
  throw new AbloAuthenticationError('baseUrl is required for capability exchange', { code: 'base_url_missing' });