@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.
- package/CHANGELOG.md +34 -0
- package/README.md +63 -23
- package/dist/BaseSyncedStore.d.ts +75 -0
- package/dist/BaseSyncedStore.js +193 -8
- package/dist/Database.d.ts +10 -2
- package/dist/Database.js +15 -1
- package/dist/SyncClient.d.ts +12 -1
- package/dist/SyncClient.js +110 -26
- package/dist/agent/Agent.d.ts +9 -9
- package/dist/agent/Agent.js +16 -16
- package/dist/agent/index.d.ts +1 -1
- package/dist/agent/index.js +2 -2
- package/dist/agent/types.d.ts +1 -1
- package/dist/agent/types.js +1 -1
- package/dist/ai-sdk/{intent-broadcast.d.ts → claim-broadcast.d.ts} +10 -10
- package/dist/ai-sdk/{intent-broadcast.js → claim-broadcast.js} +6 -6
- package/dist/ai-sdk/coordination-context.d.ts +9 -9
- package/dist/ai-sdk/coordination-context.js +8 -8
- package/dist/ai-sdk/index.d.ts +1 -1
- package/dist/ai-sdk/index.js +1 -1
- package/dist/ai-sdk/wrap.d.ts +4 -4
- package/dist/ai-sdk/wrap.js +4 -4
- package/dist/api/index.d.ts +2 -2
- package/dist/cli.cjs +369 -67
- package/dist/client/Ablo.d.ts +30 -63
- package/dist/client/Ablo.js +124 -103
- package/dist/client/ApiClient.d.ts +6 -5
- package/dist/client/ApiClient.js +86 -62
- package/dist/client/auth.d.ts +9 -4
- package/dist/client/auth.js +40 -5
- package/dist/client/createModelProxy.d.ts +41 -54
- package/dist/client/createModelProxy.js +123 -20
- package/dist/client/httpClient.d.ts +2 -0
- package/dist/client/httpClient.js +1 -1
- package/dist/client/index.d.ts +3 -3
- package/dist/client/writeOptionsSchema.d.ts +4 -4
- package/dist/client/writeOptionsSchema.js +4 -4
- package/dist/coordination/schema.d.ts +249 -38
- package/dist/coordination/schema.js +172 -39
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.js +4 -4
- package/dist/errorCodes.d.ts +9 -9
- package/dist/errorCodes.js +16 -16
- package/dist/errors.d.ts +51 -2
- package/dist/errors.js +94 -5
- package/dist/interfaces/index.d.ts +8 -4
- package/dist/policy/index.d.ts +1 -1
- package/dist/policy/types.d.ts +13 -13
- package/dist/policy/types.js +8 -8
- package/dist/react/AbloProvider.d.ts +51 -4
- package/dist/react/AbloProvider.js +95 -11
- package/dist/react/context.d.ts +26 -9
- package/dist/react/context.js +2 -2
- package/dist/react/index.d.ts +4 -4
- package/dist/react/index.js +4 -4
- package/dist/react/useAblo.js +5 -5
- package/dist/react/{useIntent.d.ts → useClaim.d.ts} +9 -9
- package/dist/react/useClaim.js +42 -0
- package/dist/schema/index.js +1 -1
- package/dist/schema/schema.d.ts +3 -3
- package/dist/schema/sugar.d.ts +3 -3
- package/dist/schema/sugar.js +3 -3
- package/dist/schema/sync-delta-wire.d.ts +8 -8
- package/dist/server/commit.d.ts +2 -2
- package/dist/sync/AreaOfInterestManager.d.ts +162 -0
- package/dist/sync/AreaOfInterestManager.js +233 -0
- package/dist/sync/BootstrapHelper.d.ts +9 -1
- package/dist/sync/BootstrapHelper.js +15 -5
- package/dist/sync/NetworkProbe.d.ts +1 -1
- package/dist/sync/NetworkProbe.js +1 -1
- package/dist/sync/SyncWebSocket.d.ts +59 -25
- package/dist/sync/SyncWebSocket.js +123 -26
- package/dist/sync/awaitClaimGrant.d.ts +40 -0
- package/dist/sync/awaitClaimGrant.js +86 -0
- package/dist/sync/createClaimStream.d.ts +34 -0
- package/dist/sync/{createIntentStream.js → createClaimStream.js} +92 -81
- package/dist/sync/createPresenceStream.js +3 -2
- package/dist/sync/participants.d.ts +10 -10
- package/dist/sync/participants.js +17 -10
- package/dist/sync/schemas.d.ts +8 -8
- package/dist/transactions/TransactionQueue.d.ts +23 -0
- package/dist/transactions/TransactionQueue.js +186 -12
- package/dist/types/global.d.ts +18 -13
- package/dist/types/global.js +11 -6
- package/dist/types/index.d.ts +9 -7
- package/dist/types/index.js +2 -2
- package/dist/types/streams.d.ts +114 -98
- package/dist/types/streams.js +1 -1
- package/dist/utils/asyncIterator.d.ts +1 -1
- package/dist/utils/asyncIterator.js +1 -1
- package/dist/wire/frames.d.ts +2 -2
- package/docs/api.md +3 -3
- package/docs/client-behavior.md +6 -3
- package/docs/coordination.md +13 -3
- package/docs/data-sources.md +29 -9
- package/docs/migration.md +40 -0
- package/docs/quickstart.md +61 -33
- package/docs/react.md +46 -0
- package/llms-full.txt +25 -8
- package/llms.txt +11 -9
- package/package.json +3 -2
- package/dist/react/useIntent.js +0 -42
- package/dist/sync/awaitIntentGrant.d.ts +0 -40
- package/dist/sync/awaitIntentGrant.js +0 -62
- 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` · **upgrading?** see the
|
|
55
55
|
[Version History & Migration Guide](./docs/migration.md)
|
|
56
56
|
|
|
57
|
-
It works with the auth and database you already have. **
|
|
58
|
-
system of record
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
69
|
-
(local, Neon, RDS — any will do
|
|
70
|
-
Ablo
|
|
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
|
|
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
|
-
|
|
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.
|
|
113
|
-
import type { schema } from './
|
|
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
|
|
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
|
-
|
|
391
|
-
layer on top of 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** (
|
|
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
|
-
|
|
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
|
|
404
|
-
|
|
405
|
-
[Data Source endpoint](./docs/data-sources.md) in your app.
|
|
406
|
-
|
|
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` |
|
|
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
|
*
|
package/dist/BaseSyncedStore.js
CHANGED
|
@@ -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
|
-
|
|
1131
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/Database.d.ts
CHANGED
|
@@ -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;
|