@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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +12 -0
- package/README.md +47 -27
- package/dist/BaseSyncedStore.d.ts +7 -38
- package/dist/BaseSyncedStore.js +20 -67
- package/dist/Database.js +7 -1
- package/dist/NetworkMonitor.js +4 -1
- package/dist/SyncClient.d.ts +18 -5
- package/dist/SyncClient.js +72 -1
- package/dist/SyncEngineContext.js +5 -1
- package/dist/auth/index.js +3 -1
- package/dist/cli.cjs +282241 -0
- package/dist/client/Ablo.d.ts +12 -3
- package/dist/client/Ablo.js +36 -3
- package/dist/client/ApiClient.js +39 -6
- package/dist/client/auth.d.ts +1 -1
- package/dist/client/auth.js +14 -5
- package/dist/client/createInternalComponents.js +1 -1
- package/dist/client/createModelProxy.d.ts +9 -0
- package/dist/client/createModelProxy.js +34 -10
- package/dist/client/persistence.d.ts +6 -1
- package/dist/client/persistence.js +1 -1
- package/dist/client/registerDataSource.d.ts +4 -4
- package/dist/client/registerDataSource.js +39 -31
- package/dist/client/writeOptionsSchema.d.ts +50 -0
- package/dist/client/writeOptionsSchema.js +57 -0
- package/dist/core/index.d.ts +18 -26
- package/dist/core/index.js +22 -46
- package/dist/errorCodes.d.ts +13 -0
- package/dist/errorCodes.js +16 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/interfaces/index.d.ts +10 -0
- package/dist/mutators/UndoManager.d.ts +31 -5
- package/dist/mutators/UndoManager.js +113 -1
- package/dist/schema/ddl.js +12 -3
- package/dist/schema/field.js +2 -1
- package/dist/schema/model.d.ts +9 -7
- package/dist/schema/model.js +1 -1
- package/dist/schema/schema.js +7 -1
- package/dist/schema/serialize.js +2 -1
- package/dist/server/storage-mode.d.ts +7 -0
- package/dist/server/storage-mode.js +6 -0
- package/dist/source/adapters/drizzle.js +3 -2
- package/dist/source/adapters/kysely.d.ts +68 -0
- package/dist/source/adapters/kysely.js +210 -0
- package/dist/source/adapters/memory.js +2 -1
- package/dist/source/adapters/prisma.js +3 -2
- package/dist/source/index.js +2 -1
- package/dist/sync/syncPosition.d.ts +78 -0
- package/dist/sync/syncPosition.js +111 -0
- package/dist/transactions/TransactionQueue.d.ts +22 -8
- package/dist/transactions/TransactionQueue.js +76 -34
- package/dist/utils/duration.js +3 -2
- package/docs/api-keys.md +4 -4
- package/docs/cli.md +6 -6
- package/docs/client-behavior.md +1 -1
- package/docs/data-sources.md +61 -42
- package/docs/guarantees.md +2 -2
- package/docs/index.md +2 -2
- package/docs/integration-guide.md +4 -7
- package/docs/mcp.md +1 -1
- package/docs/quickstart.md +84 -37
- package/docs/schema-contract.md +2 -4
- package/llms-full.txt +365 -0
- package/llms.txt +14 -9
- 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|…>`, `--
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
##
|
|
344
|
+
## Your Database
|
|
328
345
|
|
|
329
|
-
Every schema model
|
|
330
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
| `
|
|
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.
|
|
353
|
-
|
|
354
|
-
|
|
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) —
|
|
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) —
|
|
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.
|
|
248
|
-
*
|
|
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
|
-
|
|
292
|
-
|
|
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 */
|
package/dist/BaseSyncedStore.js
CHANGED
|
@@ -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.
|
|
130
|
-
*
|
|
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
|
-
|
|
189
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
1957
|
-
|
|
1958
|
-
|
|
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
|
-
|
|
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
|
//
|
package/dist/NetworkMonitor.js
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/dist/SyncClient.d.ts
CHANGED
|
@@ -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 {
|
|
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?:
|
|
188
|
+
add(model: Model, options?: WriteOptions): void;
|
|
176
189
|
/** Update existing model (UPDATE) - works offline */
|
|
177
|
-
update(model: Model, options?:
|
|
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?:
|
|
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
|
package/dist/SyncClient.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
package/dist/auth/index.js
CHANGED
|
@@ -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('
|
|
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' });
|