@abloatai/ablo 0.10.1 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +63 -23
  3. package/dist/BaseSyncedStore.d.ts +75 -0
  4. package/dist/BaseSyncedStore.js +193 -8
  5. package/dist/Database.d.ts +10 -2
  6. package/dist/Database.js +15 -1
  7. package/dist/SyncClient.d.ts +12 -1
  8. package/dist/SyncClient.js +110 -26
  9. package/dist/agent/Agent.d.ts +9 -9
  10. package/dist/agent/Agent.js +16 -16
  11. package/dist/agent/index.d.ts +1 -1
  12. package/dist/agent/index.js +2 -2
  13. package/dist/agent/types.d.ts +1 -1
  14. package/dist/agent/types.js +1 -1
  15. package/dist/ai-sdk/{intent-broadcast.d.ts → claim-broadcast.d.ts} +10 -10
  16. package/dist/ai-sdk/{intent-broadcast.js → claim-broadcast.js} +6 -6
  17. package/dist/ai-sdk/coordination-context.d.ts +9 -9
  18. package/dist/ai-sdk/coordination-context.js +8 -8
  19. package/dist/ai-sdk/index.d.ts +1 -1
  20. package/dist/ai-sdk/index.js +1 -1
  21. package/dist/ai-sdk/wrap.d.ts +4 -4
  22. package/dist/ai-sdk/wrap.js +4 -4
  23. package/dist/api/index.d.ts +2 -2
  24. package/dist/cli.cjs +369 -67
  25. package/dist/client/Ablo.d.ts +30 -63
  26. package/dist/client/Ablo.js +124 -103
  27. package/dist/client/ApiClient.d.ts +6 -5
  28. package/dist/client/ApiClient.js +86 -62
  29. package/dist/client/auth.d.ts +9 -4
  30. package/dist/client/auth.js +40 -5
  31. package/dist/client/createModelProxy.d.ts +41 -54
  32. package/dist/client/createModelProxy.js +123 -20
  33. package/dist/client/httpClient.d.ts +2 -0
  34. package/dist/client/httpClient.js +1 -1
  35. package/dist/client/index.d.ts +3 -3
  36. package/dist/client/writeOptionsSchema.d.ts +4 -4
  37. package/dist/client/writeOptionsSchema.js +4 -4
  38. package/dist/coordination/schema.d.ts +249 -38
  39. package/dist/coordination/schema.js +172 -39
  40. package/dist/core/index.d.ts +2 -2
  41. package/dist/core/index.js +4 -4
  42. package/dist/errorCodes.d.ts +9 -9
  43. package/dist/errorCodes.js +16 -16
  44. package/dist/errors.d.ts +51 -2
  45. package/dist/errors.js +94 -5
  46. package/dist/interfaces/index.d.ts +8 -4
  47. package/dist/policy/index.d.ts +1 -1
  48. package/dist/policy/types.d.ts +13 -13
  49. package/dist/policy/types.js +8 -8
  50. package/dist/react/AbloProvider.d.ts +51 -4
  51. package/dist/react/AbloProvider.js +95 -11
  52. package/dist/react/context.d.ts +26 -9
  53. package/dist/react/context.js +2 -2
  54. package/dist/react/index.d.ts +4 -4
  55. package/dist/react/index.js +4 -4
  56. package/dist/react/useAblo.js +5 -5
  57. package/dist/react/{useIntent.d.ts → useClaim.d.ts} +9 -9
  58. package/dist/react/useClaim.js +42 -0
  59. package/dist/schema/index.js +1 -1
  60. package/dist/schema/schema.d.ts +3 -3
  61. package/dist/schema/sugar.d.ts +3 -3
  62. package/dist/schema/sugar.js +3 -3
  63. package/dist/schema/sync-delta-wire.d.ts +8 -8
  64. package/dist/server/commit.d.ts +2 -2
  65. package/dist/sync/AreaOfInterestManager.d.ts +162 -0
  66. package/dist/sync/AreaOfInterestManager.js +233 -0
  67. package/dist/sync/BootstrapHelper.d.ts +9 -1
  68. package/dist/sync/BootstrapHelper.js +15 -5
  69. package/dist/sync/NetworkProbe.d.ts +1 -1
  70. package/dist/sync/NetworkProbe.js +1 -1
  71. package/dist/sync/SyncWebSocket.d.ts +59 -25
  72. package/dist/sync/SyncWebSocket.js +123 -26
  73. package/dist/sync/awaitClaimGrant.d.ts +40 -0
  74. package/dist/sync/awaitClaimGrant.js +86 -0
  75. package/dist/sync/createClaimStream.d.ts +34 -0
  76. package/dist/sync/{createIntentStream.js → createClaimStream.js} +92 -81
  77. package/dist/sync/createPresenceStream.js +3 -2
  78. package/dist/sync/participants.d.ts +10 -10
  79. package/dist/sync/participants.js +17 -10
  80. package/dist/sync/schemas.d.ts +8 -8
  81. package/dist/transactions/TransactionQueue.d.ts +23 -0
  82. package/dist/transactions/TransactionQueue.js +186 -12
  83. package/dist/types/global.d.ts +18 -13
  84. package/dist/types/global.js +11 -6
  85. package/dist/types/index.d.ts +9 -7
  86. package/dist/types/index.js +2 -2
  87. package/dist/types/streams.d.ts +114 -98
  88. package/dist/types/streams.js +1 -1
  89. package/dist/utils/asyncIterator.d.ts +1 -1
  90. package/dist/utils/asyncIterator.js +1 -1
  91. package/dist/wire/frames.d.ts +2 -2
  92. package/docs/api.md +3 -3
  93. package/docs/client-behavior.md +6 -3
  94. package/docs/coordination.md +13 -3
  95. package/docs/data-sources.md +29 -9
  96. package/docs/migration.md +40 -0
  97. package/docs/quickstart.md +61 -33
  98. package/docs/react.md +46 -0
  99. package/llms-full.txt +25 -8
  100. package/llms.txt +11 -9
  101. package/package.json +3 -2
  102. package/dist/react/useIntent.js +0 -42
  103. package/dist/sync/awaitIntentGrant.d.ts +0 -40
  104. package/dist/sync/awaitIntentGrant.js +0 -62
  105. package/dist/sync/createIntentStream.d.ts +0 -34
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 7f91f6e: DX hardening from a real onboarding session — onboarding, CLI, coordination, types, and docs.
8
+
9
+ **Client behavior**
10
+ - `databaseUrl` is now an explicit, server-only option: `Ablo(...)` no longer auto-reads `process.env.DATABASE_URL`. A stray `DATABASE_URL` (common — Prisma/Drizzle/docker set it) no longer silently flips the client into connection-string mode; a one-time warning points at the explicit option. Passing `databaseUrl: process.env.DATABASE_URL` explicitly is unchanged.
11
+ - Claims/presence are now observable from any client (including Node agents): reading a row enters its entity sync group (read-interest) and claiming pins it (write-intent), so `ablo.<model>.claim.state({ id })` reports co-participants without any manual subscribe step — whether the observer arrives before the claim (live delta) or after it (subscribe-time backfill). The claim **holder** now also sees its own claim via `claim.state`. **Requires a coordinated `sync-server` deploy** (the subscribe-time claim backfill + the entity-scope subscription gate that lets an org-authority agent key narrow into a row's group live server-side); the client package change alone does not deliver cross-client agent observation.
12
+
13
+ **CLI**
14
+ - `ablo init` detects the `src/app` layout (routes + the `@/ablo` import alias resolve correctly), writes the **real** stored sandbox key into `.env.local` instead of a placeholder, and scaffolds `ablo/register.ts` (a regular module, not a colliding `ablo.d.ts`).
15
+ - `ablo <command> --help` / `-h` now prints usage instead of erroring with "unknown flag", and `migrate` is listed in the top-level help.
16
+ - `ablo dev --no-watch` now exits after one push instead of watching forever.
17
+
18
+ **Types**
19
+ - Name the client with `typeof sync` (the value-inferred idiom, like tRPC's `typeof appRouter` / Drizzle's `typeof db`) — `ReturnType<typeof Ablo>` collapses to the untyped client and should not be used. No bespoke client-type generic is needed.
20
+ - `model_claim_not_configured` message clarified: claiming needs no per-model schema configuration; every model is claimable through the standard client.
21
+
22
+ **Docs**
23
+ - Reconciled the self-contradictory `databaseUrl` story (it is an explicit, server-only option, not auto-read from the environment; consistent casing), documented that the sandbox can host rows (apiKey only, no database), explained why a localhost Postgres can't be the system of record, and led the connect-your-database flow with `ablo pull`/`ablo check` over `ablo migrate`. Fixed stale `api.md` vocabulary (`object: 'claim'`, `participantKind: 'user' | 'agent' | 'system'`).
24
+
25
+ - 7f91f6e: Docs: document the completed `intent` → `claim` rename. Adds a 0.11.0 migration entry (`useIntent` → `useClaim`, `Register.Intents` → `Register.Claims`, `Ablo.Intent.*` → `Ablo.Claim.*`, and the coordinated client/server deploy for the `claim_*` wire frames), a `useClaim` section in the React reference, and fixes the stale `participantKind` union to the canonical `'user' | 'agent' | 'system'`.
26
+
27
+ ## 0.11.0
28
+
29
+ ### Minor Changes
30
+
31
+ - Canonical `claim` vocabulary, sync-group area-of-interest, and richer claim-rejection errors.
32
+ - **`intent` → `claim` everywhere.** The coordination primitive is now a `Claim` across the public surface: `useClaim` replaces `useIntent`, the `Ablo.Claim.*` namespace replaces `Ablo.Intent.*`, and module augmentation registers `Claims` instead of `Intents` on the `Register` interface. The underlying wire frames moved from `intent_*` to `claim_*` — clients and servers must run a `claim_*`-aware build together.
33
+ - **Sync-group area of interest.** A client's read interest is no longer frozen at connect: the new `update_subscription` frame drives live re-indexing, and `enterScope` / `leaveScope` / `pinScope` / `unpinScope` let a store narrow or widen what it streams. `AreaOfInterestManager` adds hysteresis (warm-TTL), claim-pinning, reconcile coalescing, and an LRU cap so narrowing the view never shrinks the write allowlist.
34
+ - **Richer claim-rejection errors.** Rejections (over WebSocket and HTTP) now carry `heldByClaim` and `policyReason`, and `AbloClaimedError` exposes a typed `claims` array so callers can see exactly who holds the contested rows.
35
+ - **Coordination vocabulary consolidation.** Participant identity is canonical `user` | `agent` | `system`; the server stamps `participantKind` on every presence emit and clients read it, so non-human peers surface correctly.
36
+
3
37
  ## 0.10.1
4
38
 
5
39
  ### Patch Changes
package/README.md CHANGED
@@ -54,10 +54,12 @@ claims are visible while the work is still in progress.
54
54
  `llms.txt` &nbsp;·&nbsp; **upgrading?** see the
55
55
  [Version History &amp; Migration Guide](./docs/migration.md)
56
56
 
57
- It works with the auth and database you already have. **Your database is the
58
- system of record — Ablo never hosts your data.** Ablo is the transaction layer
59
- on top of it: realtime data is scoped to *sync groups* from your own identity,
60
- and every committed row lives in your Postgres.
57
+ It works with the auth and database you already have. **In production, your
58
+ database is the system of record.** Ablo is the transaction layer on top of it:
59
+ realtime data is scoped to *sync groups* from your own identity, and every
60
+ committed row lives in your Postgres. (Trying Ablo with no database yet? The
61
+ hosted **sandbox** can host rows in Ablo's test plane — apiKey only, like
62
+ Stripe test mode — so you can explore before pointing it at your Postgres.)
61
63
 
62
64
  **Built for** collaborative editors, AI agent workflows, and internal tools —
63
65
  anywhere people and agents change shared state and everyone has to see it live.
@@ -65,18 +67,24 @@ anywhere people and agents change shared state and everyone has to see it live.
65
67
  ## Set up
66
68
 
67
69
  The CLI takes you from nothing to a synced schema — it handles the account,
68
- the key, and the env file. You bring one thing: a Postgres `DATABASE_URL`
69
- (local, Neon, RDS — any will do; **your database is the system of record,
70
- Ablo never hosts your data**).
70
+ the key, and the env file. You bring one thing: a Postgres you already have —
71
+ the same `DATABASE_URL` (local, Neon, RDS — any will do) that backs your auth,
72
+ audit, and log tables. Ablo syncs a *subset* of models against it; **in
73
+ production, your database is the system of record**.
71
74
 
72
75
  ```bash
73
76
  npm install @abloatai/ablo
74
77
  npx ablo login # opens the browser: sign in (or sign up) → a sk_test_ key is saved locally
75
78
  npx ablo init # scaffolds ablo/schema.ts (offers to log in if you skipped it)
76
- npx ablo migrate # creates the synced tables in YOUR Postgres (reads DATABASE_URL)
77
- npx ablo push # pushes your schema (sandbox), writes ABLO_API_KEY to .env.local, watches for changes
79
+ npx ablo push # pushes your schema (sandbox), writes ABLO_API_KEY to .env.local, watches for changes
78
80
  ```
79
81
 
82
+ Then point Ablo at the tables for your synced models. Most teams **already
83
+ have those tables** (often Prisma- or Drizzle-managed) — adopt them with
84
+ `npx ablo pull` / `npx ablo check`, the common case. Let Ablo own its own
85
+ tables instead? `npx ablo migrate` provisions them in your Postgres (reads
86
+ `DATABASE_URL`). Either way your other tables are left untouched.
87
+
80
88
  After `ablo push`, the [Quick Start](#quick-start) below runs as-is —
81
89
  `ABLO_API_KEY` is already in `.env.local` (frameworks load it automatically;
82
90
  plain Node: `node --env-file=.env.local app.ts`). `npx ablo status` shows
@@ -104,19 +112,26 @@ instead of guessing:
104
112
  ```ts
105
113
  import Ablo from '@abloatai/ablo';
106
114
  import { defineSchema, model, z } from '@abloatai/ablo/schema';
115
+ ```
107
116
 
108
- Register the schema once (init scaffolds this `ablo.d.ts`), and every type
109
- is one parameter away — no `typeof schema` re-stating, anywhere:
117
+ The schema is registered once (init scaffolds `ablo/register.ts` for you), and
118
+ every type is one parameter away — no `typeof schema` re-stating, anywhere:
110
119
 
111
120
  ```ts
112
- // ablo.d.ts — once per project
113
- import type { schema } from './ablo/schema';
121
+ // ablo/register.ts — scaffolded by `npx ablo init`, sits beside ablo/schema.ts
122
+ import type { schema } from './schema';
114
123
  declare module '@abloatai/ablo' {
115
124
  interface Register { Schema: typeof schema }
116
125
  }
117
126
  export {};
118
127
  ```
119
128
 
129
+ It's a regular `.ts` module, not a hand-authored `.d.ts`. The top-level
130
+ `import type { schema }` makes the `declare module` block *merge* into (augment)
131
+ the SDK's `Register` interface instead of colliding with it — the same shape
132
+ [TanStack Router uses in `src/router.tsx`](https://tanstack.com/router/latest/docs/framework/react/guide/type-safety). Any `.ts` file in your
133
+ `tsconfig` `include` works; it never needs to be imported.
134
+
120
135
  ```ts
121
136
  import type { Model } from '@abloatai/ablo/schema';
122
137
 
@@ -127,7 +142,26 @@ type WeatherReport = Model<'weatherReports'>; // fully typed from YOUR schema
127
142
  TanStack-Router pattern: declare the source of truth once, everything
128
143
  infers from it.)
129
144
 
145
+ ### Naming the client type
146
+
147
+ When you need to pass the client around (a function parameter, a context value),
148
+ **infer the type from the value** — `type Sync = typeof sync`:
149
+
150
+ ```ts
151
+ export const sync = Ablo({ schema, apiKey: process.env.ABLO_API_KEY });
152
+ export type Sync = typeof sync; // fully-typed, schema-aware
130
153
 
154
+ function persist(client: Sync) { /* ... */ }
155
+ ```
156
+
157
+ This is the same idiom as tRPC's `type AppRouter = typeof appRouter` and
158
+ Drizzle's `typeof db` — the factory resolves the typed overload at the call
159
+ site, so `typeof sync` carries your schema. Do **not** write
160
+ `ReturnType<typeof Ablo>`: that collapses to the untyped last overload and
161
+ loses your model types. There is no bespoke client-type generic to import —
162
+ `typeof` your client value is the type.
163
+
164
+ ```ts
131
165
  const schema = defineSchema({
132
166
  weatherReports: model({
133
167
  location: z.string(),
@@ -139,7 +173,7 @@ const schema = defineSchema({
139
173
  const ablo = Ablo({
140
174
  schema,
141
175
  apiKey: process.env.ABLO_API_KEY, // written to .env.local by `npx ablo push`
142
- databaseUrl: process.env.DATABASE_URL, // your Postgres — rows live here, never with Ablo
176
+ databaseUrl: process.env.DATABASE_URL, // your Postgres, passed explicitly — rows live here
143
177
  });
144
178
 
145
179
  await ablo.ready();
@@ -387,29 +421,35 @@ curl https://api.abloatai.com/v1/commits \
387
421
 
388
422
  ## Your Database
389
423
 
390
- Every schema model is backed by **your own database** — Ablo is the transaction
391
- layer on top of it, never the home for your rows. Two ways to connect it:
424
+ In production, every schema model is backed by **your own database** — Ablo is
425
+ the transaction layer on top of it. Two ways to connect it:
392
426
 
393
427
  | | How Ablo reaches your Postgres | Use when |
394
428
  | --- | --- | --- |
395
- | **Connection string** (default) | `databaseUrl` at init. Ablo registers the connection once (sent over TLS, stored sealed, never echoed back) and commits each write directly — through a non-superuser role, behind row-level security. | You can hand over a scoped connection string. |
429
+ | **Connection string** (primary) | `databaseUrl` at init — passed explicitly, never auto-read from the environment. Ablo registers the connection once (sent over TLS, stored sealed, never echoed back) and commits each write directly — through a non-superuser role, behind row-level security. | You can hand over a scoped connection string. |
396
430
  | **Signed endpoint** | Your app exposes one route built from an ORM adapter (`prismaDataSource` / `drizzleDataSource`); Ablo sends signed commit requests and your app writes its own database. | Database credentials must never leave your infrastructure. |
397
431
 
398
- Same product, same truth either way: your database is the system of record. See
432
+ (No database yet? The hosted **sandbox** can host rows in Ablo's test plane
433
+ omit `databaseUrl` and pass an `apiKey` only, like Stripe test mode — so you can
434
+ try Ablo before connecting your Postgres.)
435
+
436
+ Same product, same truth either way: in production your database is the system of
437
+ record. See
399
438
  [Connect Your Database](./docs/data-sources.md) for both shapes.
400
439
 
401
440
  ## Configuration
402
441
 
403
- `Ablo({ ... })` takes three things: your schema, your key, and your database
404
- the last either as `databaseUrl` here or as a signed
405
- [Data Source endpoint](./docs/data-sources.md) in your app. Every other option
406
- has correct defaults:
442
+ `Ablo({ ... })` takes your schema, your key, and — in production — your database,
443
+ either as an explicit `databaseUrl` here or as a signed
444
+ [Data Source endpoint](./docs/data-sources.md) in your app. (`databaseUrl` is
445
+ never auto-read from the environment; omit it to try Ablo against the hosted
446
+ sandbox.) Every other option has correct defaults:
407
447
 
408
448
  | Option | Type | Default | Purpose |
409
449
  | --- | --- | --- | --- |
410
450
  | `schema` | `Schema` | — (required) | Typed model proxies (`ablo.<model>.*`) |
411
451
  | `apiKey` | `string \| ApiKeySetter \| null` | `process.env.ABLO_API_KEY` | Server key — a string, or an async function for rotation |
412
- | `databaseUrl` | `string \| null` | `process.env.DATABASE_URL` | Your Postgres, registered as the data plane. Server runtimes only — the SDK throws if it sees this in a browser. Omit it when your app exposes a signed [Data Source endpoint](./docs/data-sources.md) instead. |
452
+ | `databaseUrl` | `string \| null` | `—` | Your Postgres, registered as the data plane. **Must be passed explicitly — it is not auto-read from the environment.** If you have a `DATABASE_URL` set for another tool (Prisma, Drizzle, docker-compose), `Ablo()` ignores it unless you pass `databaseUrl` explicitly. Server runtimes only — the SDK throws if it sees this in a browser. Omit it when your app exposes a signed [Data Source endpoint](./docs/data-sources.md) instead, or when trying Ablo against the hosted sandbox. |
413
453
 
414
454
  Keep `apiKey` in trusted server runtimes. In the browser, `<AbloProvider>`
415
455
  authenticates with the signed-in user's session; the raw-key path is gated
@@ -12,6 +12,8 @@
12
12
  * pull generic methods into this base class.
13
13
  */
14
14
  import { ConnectionManager } from './sync/ConnectionManager.js';
15
+ import { AreaOfInterestManager } from './sync/AreaOfInterestManager.js';
16
+ import { type ParticipantScope } from './sync/participants.js';
15
17
  import type { SyncClient } from './SyncClient.js';
16
18
  import type { Database, BootstrapResult } from './Database.js';
17
19
  import type { ObjectPool } from './ObjectPool.js';
@@ -237,6 +239,20 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
237
239
  */
238
240
  protected readonly schema?: TSchema;
239
241
  protected syncWebSocket: SyncWebSocket<TCollaboration> | null;
242
+ /**
243
+ * Dynamic read interest (area-of-interest) over the connection's sync
244
+ * groups. Lives alongside `syncWebSocket` and is recreated with it; the
245
+ * stable `enterScope`/`leaveScope`/`pinScope`/`unpinScope` methods forward
246
+ * to whichever instance is current, so callers (the React participant
247
+ * hook) never hold a stale reference. Null until `setupWebSocketSync`.
248
+ */
249
+ protected areaOfInterest: AreaOfInterestManager | null;
250
+ /** Sync groups whose current state has been backfilled into the pool
251
+ * (hydrate-on-enter). Cleared when the pool is reset on (re)bootstrap. */
252
+ private readonly hydratedGroups;
253
+ /** In-flight scoped hydrations, keyed by group — single-flights concurrent
254
+ * enters of the same scope so they share one fetch. */
255
+ private readonly hydratingGroups;
240
256
  private _syncServerUrl?;
241
257
  /**
242
258
  * Public accessor for the underlying SyncWebSocket. Used by the
@@ -247,6 +263,32 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
247
263
  * `initialize()`.
248
264
  */
249
265
  getSyncWebSocket(): SyncWebSocket<TCollaboration> | null;
266
+ private scopeToGroups;
267
+ /**
268
+ * Bring a scope into view → subscribe to its groups. With
269
+ * `{ hydrate: true }`, ALSO backfill the groups' current state into the pool
270
+ * after the subscription is active (the game "spawn snapshot + delta stream"
271
+ * pattern): subscribe-first so no live delta is missed in the gap, then
272
+ * snapshot. Hydration is soft — a failed backfill never rejects `enterScope`
273
+ * and the live tail still flows.
274
+ */
275
+ enterScope(scope: ParticipantScope, opts?: {
276
+ hydrate?: boolean;
277
+ }): Promise<void>;
278
+ /**
279
+ * Backfill the current state of `syncGroups` into the pool via a PURE scoped
280
+ * snapshot fetch + the version-guarded, ghost-free scoped apply. Idempotent
281
+ * (skips groups already hydrated) and single-flight (concurrent enters of the
282
+ * same group share one fetch). Soft-fails: on error the groups are NOT marked
283
+ * hydrated, so a later re-enter retries.
284
+ */
285
+ protected hydrateGroups(syncGroups: readonly string[]): Promise<void>;
286
+ /** Leave a scope → its groups go warm (hysteresis), then drop on sweep. */
287
+ leaveScope(scope: ParticipantScope): Promise<void>;
288
+ /** Pin a scope (active claim / prominence) → never warms while pinned. */
289
+ pinScope(scope: ParticipantScope): Promise<void>;
290
+ /** Release a pin → the group transitions to warm rather than dropping. */
291
+ unpinScope(scope: ParticipantScope): Promise<void>;
250
292
  protected readonly queryProcessor: QueryProcessor;
251
293
  /**
252
294
  * Runtime behavior flags only — the three schema/config arrays
@@ -630,6 +672,39 @@ export declare class BaseSyncedStore<TCollaboration extends EventMap<TCollaborat
630
672
  protected deduplicateDeltas(deltas: SyncDelta[]): SyncDelta[];
631
673
  /** Process incoming delta with smart batching */
632
674
  protected processDeltaWithBatching(delta: SyncDelta): void;
675
+ /**
676
+ * Apply a complete, server-delivered delta frame atomically.
677
+ *
678
+ * A `delta_batch` WS event (reconnect/catch-up replay) already carries
679
+ * the FULL set of missed deltas. Routing it through the per-delta
680
+ * `processDeltaWithBatching` path re-chunks it via the live-traffic
681
+ * debounce timer + `maxBatchSize` force-flush, so a 300-delta catch-up
682
+ * fans out into ~6 separate `flushPendingDeltas` cycles — each its own
683
+ * IDB write, pool mutation, `models:changed` emit, and React re-render.
684
+ * The decks gallery visibly re-sorts and "pops in" once per chunk.
685
+ *
686
+ * Here we run the per-delta bookkeeping (dedup, ack, version vector,
687
+ * watermark, G/S routing, D cascade) for every delta WITHOUT scheduling
688
+ * a flush, then flush ONCE — collapsing the whole frame into a single
689
+ * IDB write + pool mutation + `models:changed` + re-render. Same code
690
+ * for the post-bootstrap replay of deltas queued during bootstrap.
691
+ *
692
+ * (Named `applyDeltaFrame`, not `processDeltaBatch`, to avoid confusion
693
+ * with `Database.processDeltaBatch` — the lower-level IDB write this
694
+ * eventually drives through `flushPendingDeltas`.)
695
+ */
696
+ protected applyDeltaFrame(deltas: SyncDelta[]): void;
697
+ /**
698
+ * Per-delta bookkeeping + enqueue. Returns `true` when the delta was
699
+ * pushed onto `pendingDeltas` (a regular batchable I/U/C/D delta that a
700
+ * subsequent flush must drain), `false` when it was skipped (dedup),
701
+ * deferred (bootstrap queue), or handled immediately out-of-band (G/S
702
+ * sync-group mutations). Does NOT schedule a flush — callers decide
703
+ * whether to debounce (live) or flush atomically (catch-up frame).
704
+ */
705
+ protected enqueueDelta(delta: SyncDelta): boolean;
706
+ /** Debounce a flush for live single-delta traffic. */
707
+ protected scheduleDeltaFlush(): void;
633
708
  /**
634
709
  * Cancel pending transactions for child entities when a parent is deleted.
635
710
  *
@@ -14,6 +14,8 @@
14
14
  import { makeObservable, observable, computed, runInAction } from 'mobx';
15
15
  import { AbloConnectionError, AbloValidationError, toAbloError } from './errors.js';
16
16
  import { ConnectionManager } from './sync/ConnectionManager.js';
17
+ import { AreaOfInterestManager } from './sync/AreaOfInterestManager.js';
18
+ import { resolveParticipantSyncGroups, } from './sync/participants.js';
17
19
  import { PropertyType } from './types/index.js';
18
20
  import { SyncWebSocket, } from './sync/SyncWebSocket.js';
19
21
  import { QueryProcessor } from './core/QueryProcessor.js';
@@ -131,6 +133,20 @@ export class BaseSyncedStore {
131
133
  schema;
132
134
  // ── Real-time sync ──
133
135
  syncWebSocket = null;
136
+ /**
137
+ * Dynamic read interest (area-of-interest) over the connection's sync
138
+ * groups. Lives alongside `syncWebSocket` and is recreated with it; the
139
+ * stable `enterScope`/`leaveScope`/`pinScope`/`unpinScope` methods forward
140
+ * to whichever instance is current, so callers (the React participant
141
+ * hook) never hold a stale reference. Null until `setupWebSocketSync`.
142
+ */
143
+ areaOfInterest = null;
144
+ /** Sync groups whose current state has been backfilled into the pool
145
+ * (hydrate-on-enter). Cleared when the pool is reset on (re)bootstrap. */
146
+ hydratedGroups = new Set();
147
+ /** In-flight scoped hydrations, keyed by group — single-flights concurrent
148
+ * enters of the same scope so they share one fetch. */
149
+ hydratingGroups = new Map();
134
150
  _syncServerUrl;
135
151
  /**
136
152
  * Public accessor for the underlying SyncWebSocket. Used by the
@@ -143,6 +159,98 @@ export class BaseSyncedStore {
143
159
  getSyncWebSocket() {
144
160
  return this.syncWebSocket;
145
161
  }
162
+ // ── Area-of-interest (dynamic read subscription) ─────────────────
163
+ //
164
+ // `enterScope`/`leaveScope` move the connection's read interest as the
165
+ // user navigates (open/close a deck, sheet, doc); `pinScope`/`unpinScope`
166
+ // express prominence (an active claim keeps a group subscribed). All four
167
+ // resolve the scope to sync-group strings through the SAME resolver the
168
+ // claim path uses (`resolveParticipantSyncGroups`), so read interest and
169
+ // write claims always agree on the string for a given entity. No-ops
170
+ // before the socket exists. Soft state — they never reject for an offline
171
+ // transport (see `AreaOfInterestManager.reconcile`).
172
+ scopeToGroups(scope) {
173
+ return resolveParticipantSyncGroups(scope, this.schema);
174
+ }
175
+ /**
176
+ * Bring a scope into view → subscribe to its groups. With
177
+ * `{ hydrate: true }`, ALSO backfill the groups' current state into the pool
178
+ * after the subscription is active (the game "spawn snapshot + delta stream"
179
+ * pattern): subscribe-first so no live delta is missed in the gap, then
180
+ * snapshot. Hydration is soft — a failed backfill never rejects `enterScope`
181
+ * and the live tail still flows.
182
+ */
183
+ enterScope(scope, opts) {
184
+ const mgr = this.areaOfInterest;
185
+ if (!mgr)
186
+ return Promise.resolve();
187
+ const groups = this.scopeToGroups(scope);
188
+ const subscribed = Promise.all(groups.map((g) => mgr.enter(g))).then(() => undefined);
189
+ if (!opts?.hydrate)
190
+ return subscribed;
191
+ return subscribed.then(() => this.hydrateGroups(groups));
192
+ }
193
+ /**
194
+ * Backfill the current state of `syncGroups` into the pool via a PURE scoped
195
+ * snapshot fetch + the version-guarded, ghost-free scoped apply. Idempotent
196
+ * (skips groups already hydrated) and single-flight (concurrent enters of the
197
+ * same group share one fetch). Soft-fails: on error the groups are NOT marked
198
+ * hydrated, so a later re-enter retries.
199
+ */
200
+ async hydrateGroups(syncGroups) {
201
+ const need = syncGroups.filter((g) => !this.hydratedGroups.has(g) && !this.hydratingGroups.has(g));
202
+ if (need.length === 0) {
203
+ // Nothing new to fetch, but await any in-flight hydration for the
204
+ // requested groups so callers can sequence on completion.
205
+ await Promise.all(syncGroups
206
+ .map((g) => this.hydratingGroups.get(g))
207
+ .filter((p) => p !== undefined));
208
+ return;
209
+ }
210
+ const work = (async () => {
211
+ try {
212
+ const data = await this.database.fetchScopedBootstrapData(need);
213
+ this.syncClient.applyBootstrapDataToPool(data, undefined, { scoped: true });
214
+ for (const g of need)
215
+ this.hydratedGroups.add(g);
216
+ }
217
+ catch (err) {
218
+ getContext().logger.warn('[BaseSyncedStore] scoped hydrate failed', {
219
+ syncGroups: need,
220
+ error: err instanceof Error ? err.message : String(err),
221
+ });
222
+ // Soft-fail — leave `need` un-hydrated so a re-enter retries.
223
+ }
224
+ finally {
225
+ for (const g of need)
226
+ this.hydratingGroups.delete(g);
227
+ }
228
+ })();
229
+ for (const g of need)
230
+ this.hydratingGroups.set(g, work);
231
+ await work;
232
+ }
233
+ /** Leave a scope → its groups go warm (hysteresis), then drop on sweep. */
234
+ leaveScope(scope) {
235
+ const mgr = this.areaOfInterest;
236
+ if (!mgr)
237
+ return Promise.resolve();
238
+ return Promise.all(this.scopeToGroups(scope).map((g) => mgr.leave(g))).then(() => undefined);
239
+ }
240
+ /** Pin a scope (active claim / prominence) → never warms while pinned. */
241
+ pinScope(scope) {
242
+ const mgr = this.areaOfInterest;
243
+ if (!mgr)
244
+ return Promise.resolve();
245
+ return Promise.all(this.scopeToGroups(scope).map((g) => mgr.pin(g))).then(() => undefined);
246
+ }
247
+ /** Release a pin → the group transitions to warm rather than dropping. */
248
+ unpinScope(scope) {
249
+ const mgr = this.areaOfInterest;
250
+ if (!mgr)
251
+ return Promise.resolve();
252
+ return Promise.all(this.scopeToGroups(scope).map((g) => mgr.unpin(g))).then(() => undefined);
253
+ }
146
254
  // ── Internal helpers ──
147
255
  queryProcessor;
148
256
  /**
@@ -519,6 +627,10 @@ export class BaseSyncedStore {
519
627
  runInAction(() => { this.dataReady = false; });
520
628
  this.modelTypesHydrated.clear();
521
629
  this.modelTypeHydrationInFlight.clear();
630
+ // The pool is being wiped + re-bootstrapped, so the scoped-hydrate ledger
631
+ // is stale — clear it so re-entered groups backfill again.
632
+ this.hydratedGroups.clear();
633
+ this.hydratingGroups.clear();
522
634
  getContext().logger.info('[BaseSyncedStore] Bootstrap state reset complete');
523
635
  }
524
636
  catch {
@@ -1127,8 +1239,10 @@ export class BaseSyncedStore {
1127
1239
  this.bootstrapDeltaQueue = null;
1128
1240
  if (!queue || queue.length === 0)
1129
1241
  return;
1130
- for (const delta of queue)
1131
- this.processDeltaWithBatching(delta);
1242
+ // Deltas that landed during bootstrap are a complete frame — apply
1243
+ // them atomically (one flush, one re-render) rather than dribbling
1244
+ // each back through the live debounce path.
1245
+ this.applyDeltaFrame(queue);
1132
1246
  }
1133
1247
  /**
1134
1248
  * Factory for the internal `ConnectionManager`. Override to return
@@ -1300,6 +1414,14 @@ export class BaseSyncedStore {
1300
1414
  batchedDeltas: true,
1301
1415
  },
1302
1416
  });
1417
+ // Area-of-interest manager — owns dynamic read-subscription over this
1418
+ // connection. baseGroups (the org/user scopes) are always subscribed;
1419
+ // enterScope/leaveScope move per-entity interest. Recreated with the
1420
+ // socket; torn down via the disposer pushed below.
1421
+ this.areaOfInterest = new AreaOfInterestManager({
1422
+ transport: this.syncWebSocket,
1423
+ baseGroups: this.resolveSyncGroups(context),
1424
+ });
1303
1425
  // Connection events → forward to connection lifecycle callback
1304
1426
  const onConnected = this.syncWebSocket.subscribe('connected', () => {
1305
1427
  this.syncClient.markConnected();
@@ -1310,6 +1432,12 @@ export class BaseSyncedStore {
1310
1432
  else {
1311
1433
  this.updateSyncStatus({ offlineSince: undefined });
1312
1434
  }
1435
+ // Re-assert read interest on every (re)connect. After a transient
1436
+ // reconnect the socket re-sends its URL groups, but interest may have
1437
+ // changed while offline; after a full reconnect the new socket's URL
1438
+ // carries only base groups. `resync` re-pushes the current desired set
1439
+ // so the server-side index matches what the user is actually viewing.
1440
+ void this.areaOfInterest?.resync();
1313
1441
  });
1314
1442
  const onDisconnected = this.syncWebSocket.subscribe('disconnected', () => {
1315
1443
  this.syncClient.disconnect();
@@ -1325,7 +1453,10 @@ export class BaseSyncedStore {
1325
1453
  this.processDeltaWithBatching(delta);
1326
1454
  });
1327
1455
  const onDeltaBatch = this.syncWebSocket.subscribe('delta_batch', (deltas) => {
1328
- deltas.forEach((delta) => this.processDeltaWithBatching(delta));
1456
+ // A catch-up/reconnect frame is already complete — apply it as ONE
1457
+ // atomic flush so the gallery re-renders once, not once per 50-delta
1458
+ // chunk. See `applyDeltaFrame`.
1459
+ this.applyDeltaFrame(deltas);
1329
1460
  });
1330
1461
  // Bootstrap events
1331
1462
  const onBootstrapRequired = this.syncWebSocket.subscribe('bootstrap_required', (hint) => { this.handleBootstrapRequired(hint); });
@@ -1374,7 +1505,7 @@ export class BaseSyncedStore {
1374
1505
  getContext().logger.warn('[BaseSyncedStore] WebSocket reconnection gave up', { attempts });
1375
1506
  this.updateSyncStatus({ state: 'reconnecting' });
1376
1507
  });
1377
- this.disposers.push(onConnected, onDisconnected, onReconnecting, onDelta, onDeltaBatch, onBootstrapRequired, onBootstrapData, onPresenceUpdate, onError, onSessionError, onHandshakeFailed, onReconnectFailed);
1508
+ this.disposers.push(onConnected, onDisconnected, onReconnecting, onDelta, onDeltaBatch, onBootstrapRequired, onBootstrapData, onPresenceUpdate, onError, onSessionError, onHandshakeFailed, onReconnectFailed, () => { this.areaOfInterest?.dispose(); this.areaOfInterest = null; });
1378
1509
  // ── Connection FSM ────────────────────────────────────────────
1379
1510
  // Instantiate + start the SDK's ConnectionManager so every
1380
1511
  // consumer gets correct online/offline recovery. Previously this
@@ -1547,9 +1678,59 @@ export class BaseSyncedStore {
1547
1678
  }
1548
1679
  /** Process incoming delta with smart batching */
1549
1680
  processDeltaWithBatching(delta) {
1681
+ if (!this.enqueueDelta(delta))
1682
+ return;
1683
+ this.scheduleDeltaFlush();
1684
+ }
1685
+ /**
1686
+ * Apply a complete, server-delivered delta frame atomically.
1687
+ *
1688
+ * A `delta_batch` WS event (reconnect/catch-up replay) already carries
1689
+ * the FULL set of missed deltas. Routing it through the per-delta
1690
+ * `processDeltaWithBatching` path re-chunks it via the live-traffic
1691
+ * debounce timer + `maxBatchSize` force-flush, so a 300-delta catch-up
1692
+ * fans out into ~6 separate `flushPendingDeltas` cycles — each its own
1693
+ * IDB write, pool mutation, `models:changed` emit, and React re-render.
1694
+ * The decks gallery visibly re-sorts and "pops in" once per chunk.
1695
+ *
1696
+ * Here we run the per-delta bookkeeping (dedup, ack, version vector,
1697
+ * watermark, G/S routing, D cascade) for every delta WITHOUT scheduling
1698
+ * a flush, then flush ONCE — collapsing the whole frame into a single
1699
+ * IDB write + pool mutation + `models:changed` + re-render. Same code
1700
+ * for the post-bootstrap replay of deltas queued during bootstrap.
1701
+ *
1702
+ * (Named `applyDeltaFrame`, not `processDeltaBatch`, to avoid confusion
1703
+ * with `Database.processDeltaBatch` — the lower-level IDB write this
1704
+ * eventually drives through `flushPendingDeltas`.)
1705
+ */
1706
+ applyDeltaFrame(deltas) {
1707
+ let enqueuedAny = false;
1708
+ for (const delta of deltas) {
1709
+ if (this.enqueueDelta(delta))
1710
+ enqueuedAny = true;
1711
+ }
1712
+ if (!enqueuedAny)
1713
+ return;
1714
+ // Cancel any pending live-traffic timer — the frame is complete, so
1715
+ // there is nothing to wait for. Flush everything in one pass.
1716
+ if (this.batchTimer) {
1717
+ clearTimeout(this.batchTimer);
1718
+ this.batchTimer = null;
1719
+ }
1720
+ void this.flushPendingDeltas().catch(this.handleFlushError);
1721
+ }
1722
+ /**
1723
+ * Per-delta bookkeeping + enqueue. Returns `true` when the delta was
1724
+ * pushed onto `pendingDeltas` (a regular batchable I/U/C/D delta that a
1725
+ * subsequent flush must drain), `false` when it was skipped (dedup),
1726
+ * deferred (bootstrap queue), or handled immediately out-of-band (G/S
1727
+ * sync-group mutations). Does NOT schedule a flush — callers decide
1728
+ * whether to debounce (live) or flush atomically (catch-up frame).
1729
+ */
1730
+ enqueueDelta(delta) {
1550
1731
  // Dedup guard — skip already-processed deltas
1551
1732
  if (delta.id > 0 && delta.id <= this.highestProcessedSyncId)
1552
- return;
1733
+ return false;
1553
1734
  // Confirm awaiting transactions via sync ID threshold (before batching)
1554
1735
  this.syncClient.onDeltaReceived(delta.id);
1555
1736
  // Update version vector
@@ -1560,7 +1741,7 @@ export class BaseSyncedStore {
1560
1741
  // Queue during active bootstrap
1561
1742
  if (this.bootstrapDeltaQueue !== null) {
1562
1743
  this.bootstrapDeltaQueue.push(delta);
1563
- return;
1744
+ return false;
1564
1745
  }
1565
1746
  // Advance watermark
1566
1747
  this.syncClient.position.advanceApplied(delta.id);
@@ -1568,13 +1749,13 @@ export class BaseSyncedStore {
1568
1749
  // (addedGroups/removedGroups) and incremental (group/userId) payloads.
1569
1750
  if (delta.actionType === 'G') {
1570
1751
  void this.handleSyncGroupChange(delta);
1571
- return;
1752
+ return false;
1572
1753
  }
1573
1754
  // Sync group removed — handle immediately. Clears affected local state
1574
1755
  // and forces re-bootstrap with the updated group list.
1575
1756
  if (delta.actionType === 'S') {
1576
1757
  void this.handleGroupRemoved(delta);
1577
- return;
1758
+ return false;
1578
1759
  }
1579
1760
  // DELETE — fire the cascade cancel immediately (O(1) via FK index;
1580
1761
  // must run BEFORE any subsequent update on the same model lands so
@@ -1590,6 +1771,10 @@ export class BaseSyncedStore {
1590
1771
  this.cascadeCancelTransactionsForDeletedParent(delta.modelName, delta.modelId);
1591
1772
  }
1592
1773
  this.pendingDeltas.push(delta);
1774
+ return true;
1775
+ }
1776
+ /** Debounce a flush for live single-delta traffic. */
1777
+ scheduleDeltaFlush() {
1593
1778
  if (this.batchTimer)
1594
1779
  clearTimeout(this.batchTimer);
1595
1780
  if (this.pendingDeltas.length >= this.smartSyncOptions.maxBatchSize) {
@@ -91,6 +91,14 @@ export declare class Database {
91
91
  private bootstrapHelper;
92
92
  /** The pre-configured query helper for lazy-loading data from the sync server. */
93
93
  get helper(): BootstrapHelper;
94
+ /**
95
+ * PURE scoped snapshot fetch for hydrate-on-enter (P4). Returns the FULL
96
+ * current rows of the given sync groups, with NO side effects — unlike
97
+ * {@link bootstrapFromServer}, it does not persist to IndexedDB and does not
98
+ * touch the connection's `subscribedSyncGroups` (which the shrinkage check
99
+ * owns). The caller applies the result to the pool via the SCOPED apply path.
100
+ */
101
+ fetchScopedBootstrapData(syncGroups: readonly string[]): Promise<BootstrapData>;
94
102
  private currentDbInfo;
95
103
  private workspaceDb;
96
104
  /**
@@ -195,7 +203,7 @@ export declare class Database {
195
203
  * but the switch returns a no-op verify if one slips through (e.g.
196
204
  * replayed from the bootstrap queue) rather than crashing the engine.
197
205
  */
198
- actionType: 'I' | 'U' | 'D' | 'A' | 'V' | 'C' | 'G' | 'S';
206
+ actionType: 'I' | 'U' | 'D' | 'A' | 'V' | 'C' | 'G' | 'S' | 'M';
199
207
  modelName: string;
200
208
  modelId: string;
201
209
  data: ModelData | null;
@@ -235,7 +243,7 @@ export declare class Database {
235
243
  * shouldn't reach batch processing, but the switch inside returns
236
244
  * no-op verify for them if one slips through.
237
245
  */
238
- actionType: 'I' | 'U' | 'D' | 'A' | 'V' | 'C' | 'G' | 'S';
246
+ actionType: 'I' | 'U' | 'D' | 'A' | 'V' | 'C' | 'G' | 'S' | 'M';
239
247
  modelName: string;
240
248
  modelId: string;
241
249
  data: ModelData | null;