@abloatai/ablo 0.9.2 → 0.9.4

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 (67) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +47 -27
  4. package/dist/BaseSyncedStore.d.ts +7 -38
  5. package/dist/BaseSyncedStore.js +20 -67
  6. package/dist/Database.js +7 -1
  7. package/dist/NetworkMonitor.js +4 -1
  8. package/dist/SyncClient.d.ts +18 -5
  9. package/dist/SyncClient.js +72 -1
  10. package/dist/SyncEngineContext.js +5 -1
  11. package/dist/auth/index.js +3 -1
  12. package/dist/cli.cjs +282241 -0
  13. package/dist/client/Ablo.d.ts +12 -3
  14. package/dist/client/Ablo.js +36 -3
  15. package/dist/client/ApiClient.js +39 -6
  16. package/dist/client/auth.d.ts +1 -1
  17. package/dist/client/auth.js +14 -5
  18. package/dist/client/createInternalComponents.js +1 -1
  19. package/dist/client/createModelProxy.d.ts +9 -0
  20. package/dist/client/createModelProxy.js +34 -10
  21. package/dist/client/persistence.d.ts +6 -1
  22. package/dist/client/persistence.js +1 -1
  23. package/dist/client/registerDataSource.d.ts +4 -4
  24. package/dist/client/registerDataSource.js +39 -31
  25. package/dist/client/writeOptionsSchema.d.ts +50 -0
  26. package/dist/client/writeOptionsSchema.js +57 -0
  27. package/dist/core/index.d.ts +18 -26
  28. package/dist/core/index.js +22 -46
  29. package/dist/errorCodes.d.ts +13 -0
  30. package/dist/errorCodes.js +16 -1
  31. package/dist/index.d.ts +3 -0
  32. package/dist/index.js +7 -0
  33. package/dist/interfaces/index.d.ts +10 -0
  34. package/dist/mutators/UndoManager.d.ts +31 -5
  35. package/dist/mutators/UndoManager.js +113 -1
  36. package/dist/schema/ddl.js +12 -3
  37. package/dist/schema/field.js +2 -1
  38. package/dist/schema/model.d.ts +9 -7
  39. package/dist/schema/model.js +1 -1
  40. package/dist/schema/schema.js +7 -1
  41. package/dist/schema/serialize.js +2 -1
  42. package/dist/server/storage-mode.d.ts +7 -0
  43. package/dist/server/storage-mode.js +6 -0
  44. package/dist/source/adapters/drizzle.js +3 -2
  45. package/dist/source/adapters/kysely.d.ts +68 -0
  46. package/dist/source/adapters/kysely.js +210 -0
  47. package/dist/source/adapters/memory.js +2 -1
  48. package/dist/source/adapters/prisma.js +3 -2
  49. package/dist/source/index.js +2 -1
  50. package/dist/sync/syncPosition.d.ts +78 -0
  51. package/dist/sync/syncPosition.js +111 -0
  52. package/dist/transactions/TransactionQueue.d.ts +22 -8
  53. package/dist/transactions/TransactionQueue.js +76 -34
  54. package/dist/utils/duration.js +3 -2
  55. package/docs/api-keys.md +4 -4
  56. package/docs/cli.md +6 -6
  57. package/docs/client-behavior.md +1 -1
  58. package/docs/data-sources.md +61 -42
  59. package/docs/guarantees.md +2 -2
  60. package/docs/index.md +2 -2
  61. package/docs/integration-guide.md +4 -7
  62. package/docs/mcp.md +1 -1
  63. package/docs/quickstart.md +84 -37
  64. package/docs/schema-contract.md +2 -4
  65. package/llms-full.txt +365 -0
  66. package/llms.txt +14 -9
  67. package/package.json +26 -4
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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.4
4
+
5
+ ### Patch Changes
6
+
7
+ - Sync-position correctness + CLI hardening. Consolidate five scattered sync cursors into one typed `syncPosition` (persisted/applied/acked with a derived `readFloor`), fixing a claim taken right after an ack-confirmed write reading stale against that write's own delta. Add transaction ack-confirmation, schema DDL-first-push, and a reworked CLI (config/dev/login/mode/drizzle-pull).
8
+
9
+ ## 0.9.3
10
+
11
+ ### Patch Changes
12
+
13
+ - Onboarding: quickstart leads with your-own-database (Drizzle Data Source), drop Ablo-managed mode, add `ablo push` step; context7 library-claim config.
14
+
3
15
  ## 0.9.2
4
16
 
5
17
  ### 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 (sandbox), 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,34 +341,37 @@ 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
 
342
- `Ablo({ ... })` takes one required option and a couple of transport overrides:
359
+ `Ablo({ ... })` takes three things: your schema, your key, and your database —
360
+ the last either as `databaseUrl` here or as a signed
361
+ [Data Source endpoint](./docs/data-sources.md) in your app. Every other option
362
+ has correct defaults:
343
363
 
344
364
  | Option | Type | Default | Purpose |
345
365
  | --- | --- | --- | --- |
346
366
  | `schema` | `Schema` | — (required) | Typed model proxies (`ablo.<model>.*`) |
347
367
  | `apiKey` | `string \| ApiKeySetter \| null` | `process.env.ABLO_API_KEY` | Server key — a string, or an async function for rotation |
348
- | `baseURL` | `string` | `wss://api.abloatai.com` | Point at a self-hosted or private API |
368
+ | `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. |
349
369
 
350
370
  Keep `apiKey` in trusted server runtimes. In the browser, `<AbloProvider>`
351
371
  authenticates with the signed-in user's session; the raw-key path is gated
352
- behind `dangerouslyAllowBrowser` for server-proxy setups only. Self-hosted
353
- deployments can pass `authToken` instead of `apiKey`. Advanced hooks (custom
354
- `fetch`, logging, observability) live in [Client Behavior](./docs/client-behavior.md).
372
+ behind `dangerouslyAllowBrowser` for server-proxy setups only. Advanced hooks
373
+ (custom `fetch`, logging, observability, transport overrides) live in
374
+ [Client Behavior](./docs/client-behavior.md).
355
375
 
356
376
  ## Errors
357
377
 
@@ -395,11 +415,11 @@ contract; there are no retry or timeout knobs to tune.
395
415
  - [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
416
  - [Schema Contract](./docs/schema-contract.md) — one schema becomes typed model clients, React reads, agent writes, Data Source shape, and schema push.
397
417
  - [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.
418
+ - [Integration Guide](./docs/integration-guide.md) — integrate React, your database, multiplayer, and agents.
399
419
  - [React](./docs/react.md) — `<AbloProvider>`, `useAblo`, presence, status, and bootstrap gating.
400
420
  - [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
421
  - [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.
422
+ - [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
423
  - [Existing Python Backend](./docs/examples/existing-python-backend.md) — migrate existing Python endpoints to multiplayer and agent-safe writes gradually.
404
424
  - [AI SDK Tool](./docs/examples/ai-sdk-tool.md) — use Ablo inside an AI SDK tool call.
405
425
  - [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
  /**
@@ -288,8 +272,11 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
288
272
  protected pendingDeltas: SyncDelta[];
289
273
  protected batchTimer: ReturnType<typeof setTimeout> | null;
290
274
  protected syncPromise: Promise<void> | null;
291
- protected lastAckedId: number;
292
- protected highestProcessedSyncId: number;
275
+ /** Resume/ack cursor — delegates to the shared SyncPosition (see
276
+ * sync/syncPosition.ts). Advances only after IDB persistence. */
277
+ protected get lastAckedId(): number;
278
+ /** Pool-applied cursor — delegates to the shared SyncPosition. */
279
+ protected get highestProcessedSyncId(): number;
293
280
  protected bootstrapDeltaQueue: SyncDelta[] | null;
294
281
  protected activeBootstrapCount: number;
295
282
  protected pendingDeletes: Set<string>;
@@ -694,24 +681,6 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
694
681
  id: string;
695
682
  archivedAt?: Date | null;
696
683
  }>(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
684
  /** Retrieve a single entity by id. Synchronous pool read. */
716
685
  retrieve(_modelClass: ModelConstructor<Model>, id: string): Model | undefined;
717
686
  /** 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;
@@ -185,8 +181,15 @@ export class BaseSyncedStore {
185
181
  pendingDeltas = [];
186
182
  batchTimer = null;
187
183
  syncPromise = null;
188
- lastAckedId = 0;
189
- highestProcessedSyncId = 0;
184
+ /** Resume/ack cursor — delegates to the shared SyncPosition (see
185
+ * sync/syncPosition.ts). Advances only after IDB persistence. */
186
+ get lastAckedId() {
187
+ return this.syncClient.position.persisted;
188
+ }
189
+ /** Pool-applied cursor — delegates to the shared SyncPosition. */
190
+ get highestProcessedSyncId() {
191
+ return this.syncClient.position.applied;
192
+ }
190
193
  // ── Delta queuing during bootstrap ──
191
194
  bootstrapDeltaQueue = null;
192
195
  activeBootstrapCount = 0;
@@ -961,8 +964,7 @@ export class BaseSyncedStore {
961
964
  }
962
965
  // Get sync baseline for WebSocket
963
966
  const lastSyncId = (yield this.database.getLastSyncId());
964
- this.lastAckedId = Math.max(this.lastAckedId, lastSyncId || 0);
965
- this.highestProcessedSyncId = this.lastAckedId;
967
+ this.syncClient.position.advancePersisted(lastSyncId || 0);
966
968
  try {
967
969
  const versions = (yield this.database.getVersionVector());
968
970
  if (versions && typeof versions === 'object')
@@ -1561,9 +1563,7 @@ export class BaseSyncedStore {
1561
1563
  return;
1562
1564
  }
1563
1565
  // Advance watermark
1564
- if (delta.id > this.highestProcessedSyncId) {
1565
- this.highestProcessedSyncId = delta.id;
1566
- }
1566
+ this.syncClient.position.advanceApplied(delta.id);
1567
1567
  // Sync group added — handle immediately. Supports both legacy
1568
1568
  // (addedGroups/removedGroups) and incremental (group/userId) payloads.
1569
1569
  if (delta.actionType === 'G') {
@@ -1703,8 +1703,7 @@ export class BaseSyncedStore {
1703
1703
  const persistedSyncId = batch.persistedSyncId;
1704
1704
  if (persistedSyncId > this.lastAckedId) {
1705
1705
  this.syncWebSocket?.acknowledge?.(persistedSyncId);
1706
- this.lastAckedId = persistedSyncId;
1707
- this.highestProcessedSyncId = Math.max(this.highestProcessedSyncId, persistedSyncId);
1706
+ this.syncClient.position.advancePersisted(persistedSyncId);
1708
1707
  }
1709
1708
  // Cache invalidation is automatic via SyncClient 'models:changed' event
1710
1709
  this.pendingDeltas = [];
@@ -1789,53 +1788,9 @@ export class BaseSyncedStore {
1789
1788
  this.syncClient.update(model);
1790
1789
  }
1791
1790
  // ── 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
- }
1791
+ // `store.query.<model>.*` was DELETED — `ablo.<model>.get/getAll` is the
1792
+ // one read surface. Custom mutators still read transactionally through
1793
+ // `tx.<model>` (mutators/Transaction.ts), which owns `createReaderActions`.
1839
1794
  /** Retrieve a single entity by id. Synchronous pool read. */
1840
1795
  retrieve(_modelClass, id) {
1841
1796
  return this.objectPool.get(id);
@@ -1953,11 +1908,9 @@ export class BaseSyncedStore {
1953
1908
  }
1954
1909
  // Delegate pool writes to SyncClient (auto-invalidates cache via 'models:changed' event)
1955
1910
  this.syncClient.applyDeltaBatchToPool([dbResult], (name, data) => this.enrichRelations(name, data));
1956
- // Advance sync ID
1957
- if (delta.id > this.lastAckedId) {
1958
- this.lastAckedId = delta.id;
1959
- this.highestProcessedSyncId = Math.max(this.highestProcessedSyncId, delta.id);
1960
- }
1911
+ // This path runs after the delta was written to IDB — advance both
1912
+ // cursors through the shared position.
1913
+ this.syncClient.position.advancePersisted(delta.id);
1961
1914
  }
1962
1915
  /** Handle bootstrap_required event */
1963
1916
  handleBootstrapRequired(_hint) {
package/dist/Database.js CHANGED
@@ -8,6 +8,7 @@ import { LoadStrategy } from './types/index.js';
8
8
  import { getContext } from './context.js';
9
9
  import { AbloConnectionError, AbloValidationError } from './errors.js';
10
10
  import { InMemoryObjectStore } from './adapters/inMemoryStorage.js';
11
+ import { syncPositionSchema } from './sync/syncPosition.js';
11
12
  export class Database {
12
13
  // Core database components
13
14
  databaseManager;
@@ -252,7 +253,12 @@ export class Database {
252
253
  const instantModels = this.modelRegistry.getModelsByLoadStrategy(LoadStrategy.instant);
253
254
  const lazyModels = this.modelRegistry.getModelsByLoadStrategy(LoadStrategy.lazy);
254
255
  const modelsToLoad = [...instantModels, ...lazyModels];
255
- const metadataLastSyncId = metadata?.lastSyncId || 0;
256
+ // Gate the PERSISTED cursor through the sync-position schema field —
257
+ // the one trust boundary for resume state. IDB can hand back anything
258
+ // (a corrupted negative/float cursor would previously pass `|| 0`,
259
+ // which only catches falsy, and get sent to the server as the resume
260
+ // point). Invalid → 0 → full bootstrap, the safe degradation.
261
+ const metadataLastSyncId = syncPositionSchema.shape.persisted.safeParse(metadata?.lastSyncId).data ?? 0;
256
262
  const dataAge = metadata?.updatedAt ? Date.now() - metadata.updatedAt.getTime() : Infinity;
257
263
  // ── Zero-style cache-validity check ──────────────────────────
258
264
  //
@@ -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,8 @@ 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
+ import { SyncPosition } from './sync/syncPosition.js';
17
18
  interface SyncObserver {
18
19
  onSync?: (event: SyncEvent) => void;
19
20
  }
@@ -38,7 +39,6 @@ export interface RehydrationStats {
38
39
  healed: number;
39
40
  elapsedMs: number;
40
41
  }
41
- type MutationWriteOptions = Pick<MutationOptions, 'readAt' | 'onStale'>;
42
42
  export declare class SyncClient extends EventEmitter {
43
43
  private objectPool;
44
44
  private database;
@@ -69,6 +69,13 @@ export declare class SyncClient extends EventEmitter {
69
69
  private offlineSince?;
70
70
  private maxRetries;
71
71
  private isDisposed;
72
+ /**
73
+ * THE client's place in the global delta order — the one canonical
74
+ * instance (see `sync/syncPosition.ts`). The store advances
75
+ * `applied`/`persisted` as deltas land; the queue advances `acked` on
76
+ * commit responses; snapshots/claims read `readFloor`.
77
+ */
78
+ readonly position: SyncPosition;
72
79
  constructor(objectPool: ObjectPool, database: Database);
73
80
  /**
74
81
  * Setup network monitoring handlers
@@ -114,6 +121,12 @@ export declare class SyncClient extends EventEmitter {
114
121
  * Initialize sync client with authentication
115
122
  */
116
123
  initialize(userId: string, organizationId: string): Promise<void>;
124
+ /**
125
+ * The organization this client writes under (set by `initialize`).
126
+ * Read by the model proxy so `create()` defaults `organizationId` the
127
+ * same way the mutator path does — `null` until identity is wired.
128
+ */
129
+ getOrganizationId(): string | null;
117
130
  /**
118
131
  * Self-healing helper for individual model records.
119
132
  *
@@ -172,9 +185,9 @@ export declare class SyncClient extends EventEmitter {
172
185
  */
173
186
  private captureModelChanges;
174
187
  /** Add new model (CREATE) - works offline */
175
- add(model: Model, options?: MutationWriteOptions): void;
188
+ add(model: Model, options?: WriteOptions): void;
176
189
  /** Update existing model (UPDATE) - works offline */
177
- update(model: Model, options?: MutationWriteOptions): void;
190
+ update(model: Model, options?: WriteOptions): void;
178
191
  /**
179
192
  * Update existing model with pre-computed changes.
180
193
  * Used by saveManyOptimized when incoming models have empty change-tracking
@@ -186,7 +199,7 @@ export declare class SyncClient extends EventEmitter {
186
199
  * but still need optimistic pool updates at the sync layer. */
187
200
  get gql(): import("./interfaces/index.js").MutationExecutor;
188
201
  /** Delete model (DELETE) - works offline */
189
- delete(model: Model, options?: MutationWriteOptions): void;
202
+ delete(model: Model, options?: WriteOptions): void;
190
203
  /**
191
204
  * Clear all pending mutations for a specific model
192
205
  * Called before deletion to prevent "layer not found" errors on the server
@@ -16,6 +16,7 @@ import { EventEmitter } from 'events';
16
16
  import { NetworkMonitor } from './NetworkMonitor.js';
17
17
  import { TransactionQueue } from './transactions/TransactionQueue.js';
18
18
  import { OptimisticEchoTracker, } from './transactions/OptimisticEchoTracker.js';
19
+ import { SyncPosition } from './sync/syncPosition.js';
19
20
  export class SyncClient extends EventEmitter {
20
21
  objectPool;
21
22
  database;
@@ -50,6 +51,13 @@ export class SyncClient extends EventEmitter {
50
51
  // Configuration
51
52
  maxRetries = 3;
52
53
  isDisposed = false;
54
+ /**
55
+ * THE client's place in the global delta order — the one canonical
56
+ * instance (see `sync/syncPosition.ts`). The store advances
57
+ * `applied`/`persisted` as deltas land; the queue advances `acked` on
58
+ * commit responses; snapshots/claims read `readFloor`.
59
+ */
60
+ position = new SyncPosition();
53
61
  constructor(objectPool, database) {
54
62
  super();
55
63
  this.objectPool = objectPool;
@@ -57,6 +65,7 @@ export class SyncClient extends EventEmitter {
57
65
  this.networkMonitor = new NetworkMonitor();
58
66
  // Initialize TransactionQueue with proper configuration
59
67
  this.transactionQueue = new TransactionQueue({
68
+ position: this.position,
60
69
  maxBatchSize: 50, // Increased from 10 to reduce batch count for large operations
61
70
  // Lower delay for snappier dev UX; batching still happens via coalescing
62
71
  batchDelay: 150,
@@ -313,6 +322,14 @@ export class SyncClient extends EventEmitter {
313
322
  this.emit('sync:offline');
314
323
  }
315
324
  }
325
+ /**
326
+ * The organization this client writes under (set by `initialize`).
327
+ * Read by the model proxy so `create()` defaults `organizationId` the
328
+ * same way the mutator path does — `null` until identity is wired.
329
+ */
330
+ getOrganizationId() {
331
+ return this.organizationId;
332
+ }
316
333
  /**
317
334
  * Self-healing helper for individual model records.
318
335
  *
@@ -1311,7 +1328,61 @@ export class SyncClient extends EventEmitter {
1311
1328
  */
1312
1329
  onLocalTransaction(listener) {
1313
1330
  this.transactionQueue.on('transaction:created', listener);
1314
- return () => this.transactionQueue.off('transaction:created', listener);
1331
+ // Commit-lane writes (`ablo.commits.create` — the agent/atomic door) ride
1332
+ // their own `commit:created` event: they have no optimistic pool apply,
1333
+ // so they must not feed the echo tracker's `transaction:created` path.
1334
+ // Enrich each operation with previous state captured from the pool HERE
1335
+ // (the queue is pool-free) and hand the synthesized transaction to the
1336
+ // same listener, so undo observes every write door — one stream.
1337
+ const onCommitCreated = (payload) => {
1338
+ const TYPE_BY_WIRE = {
1339
+ CREATE: 'create',
1340
+ UPDATE: 'update',
1341
+ DELETE: 'delete',
1342
+ ARCHIVE: 'archive',
1343
+ UNARCHIVE: 'unarchive',
1344
+ };
1345
+ payload.operations.forEach((op, index) => {
1346
+ const type = TYPE_BY_WIRE[op.type];
1347
+ if (!type || !op.id)
1348
+ return;
1349
+ const resident = this.objectPool.get(op.id);
1350
+ const snapshot = type === 'create' ? undefined : resident?.toJSON();
1351
+ // A DELETE of a row the local graph never saw is not invertible —
1352
+ // recording it would make undo "restore" an empty husk. Skip it.
1353
+ if (type === 'delete' && !snapshot)
1354
+ return;
1355
+ // UPDATE inverse must only revert the fields this op actually wrote;
1356
+ // handing undo the FULL row would clobber concurrent edits to
1357
+ // unrelated fields on revert.
1358
+ const previousData = type === 'update' && snapshot && op.input
1359
+ ? Object.fromEntries(Object.keys(op.input).map((key) => [key, snapshot[key]]))
1360
+ : snapshot ?? null;
1361
+ listener({
1362
+ id: `${payload.clientTxId}_op${index}`,
1363
+ type,
1364
+ modelName: op.model,
1365
+ modelId: op.id,
1366
+ modelKey: op.model,
1367
+ data: op.input ?? undefined,
1368
+ previousData,
1369
+ context: {
1370
+ userId: this.userId ?? '',
1371
+ organizationId: this.organizationId ?? '',
1372
+ },
1373
+ status: 'pending',
1374
+ createdAt: Date.now(),
1375
+ attempts: 0,
1376
+ priority: 'normal',
1377
+ priorityScore: 0,
1378
+ });
1379
+ });
1380
+ };
1381
+ this.transactionQueue.on('commit:created', onCommitCreated);
1382
+ return () => {
1383
+ this.transactionQueue.off('transaction:created', listener);
1384
+ this.transactionQueue.off('commit:created', onCommitCreated);
1385
+ };
1315
1386
  }
1316
1387
  /**
1317
1388
  * 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' });