@abloatai/ablo 0.10.0 → 0.11.0
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 +16 -0
- package/README.md +2 -1
- 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 +254 -48
- package/dist/client/Ablo.d.ts +30 -63
- package/dist/client/Ablo.js +108 -102
- package/dist/client/ApiClient.d.ts +6 -5
- package/dist/client/ApiClient.js +83 -62
- package/dist/client/createModelProxy.d.ts +16 -54
- package/dist/client/createModelProxy.js +44 -16
- 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 +15 -15
- 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/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 +12 -0
- package/dist/transactions/TransactionQueue.js +126 -8
- package/dist/types/global.d.ts +10 -10
- package/dist/types/global.js +3 -3
- 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/migration.md +52 -0
- 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,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.11.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Canonical `claim` vocabulary, sync-group area-of-interest, and richer claim-rejection errors.
|
|
8
|
+
- **`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.
|
|
9
|
+
- **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.
|
|
10
|
+
- **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.
|
|
11
|
+
- **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.
|
|
12
|
+
|
|
13
|
+
## 0.10.1
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- Docs: add the 0.10.0 entry to the Version History & Migration Guide — the `test`/`live` → `sandbox`/`production` environment enum rename (key prefixes unchanged) and the new `transport: 'http'` stateless client.
|
|
18
|
+
|
|
3
19
|
## 0.10.0
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -104,6 +104,7 @@ instead of guessing:
|
|
|
104
104
|
```ts
|
|
105
105
|
import Ablo from '@abloatai/ablo';
|
|
106
106
|
import { defineSchema, model, z } from '@abloatai/ablo/schema';
|
|
107
|
+
```
|
|
107
108
|
|
|
108
109
|
Register the schema once (init scaffolds this `ablo.d.ts`), and every type
|
|
109
110
|
is one parameter away — no `typeof schema` re-stating, anywhere:
|
|
@@ -127,7 +128,7 @@ type WeatherReport = Model<'weatherReports'>; // fully typed from YOUR schema
|
|
|
127
128
|
TanStack-Router pattern: declare the source of truth once, everything
|
|
128
129
|
infers from it.)
|
|
129
130
|
|
|
130
|
-
|
|
131
|
+
```ts
|
|
131
132
|
const schema = defineSchema({
|
|
132
133
|
weatherReports: model({
|
|
133
134
|
location: z.string(),
|
|
@@ -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;
|
package/dist/Database.js
CHANGED
|
@@ -20,6 +20,17 @@ export class Database {
|
|
|
20
20
|
get helper() {
|
|
21
21
|
return this.bootstrapHelper;
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* PURE scoped snapshot fetch for hydrate-on-enter (P4). Returns the FULL
|
|
25
|
+
* current rows of the given sync groups, with NO side effects — unlike
|
|
26
|
+
* {@link bootstrapFromServer}, it does not persist to IndexedDB and does not
|
|
27
|
+
* touch the connection's `subscribedSyncGroups` (which the shrinkage check
|
|
28
|
+
* owns). The caller applies the result to the pool via the SCOPED apply path.
|
|
29
|
+
*/
|
|
30
|
+
async fetchScopedBootstrapData(syncGroups) {
|
|
31
|
+
// No lastSyncId → a full snapshot of exactly these groups.
|
|
32
|
+
return this.bootstrapHelper.fetchBootstrap(undefined, syncGroups);
|
|
33
|
+
}
|
|
23
34
|
// Database state
|
|
24
35
|
currentDbInfo = null;
|
|
25
36
|
workspaceDb = null;
|
|
@@ -764,7 +775,10 @@ export class Database {
|
|
|
764
775
|
// ========================================================================
|
|
765
776
|
// CONFLICT CHECK: Skip UPDATE/INSERT if DELETE exists with higher syncId
|
|
766
777
|
// ========================================================================
|
|
767
|
-
if (delta.actionType === 'U' ||
|
|
778
|
+
if (delta.actionType === 'U' ||
|
|
779
|
+
delta.actionType === 'I' ||
|
|
780
|
+
delta.actionType === 'C' ||
|
|
781
|
+
delta.actionType === 'M') {
|
|
768
782
|
const key = `${delta.modelName}:${delta.modelId}`;
|
|
769
783
|
const deleteSyncId = deleteSyncIds.get(key);
|
|
770
784
|
if (deleteSyncId !== undefined) {
|
package/dist/SyncClient.d.ts
CHANGED
|
@@ -503,7 +503,18 @@ export declare class SyncClient extends EventEmitter {
|
|
|
503
503
|
applyBootstrapDataToPool(bootstrapData: {
|
|
504
504
|
models?: Record<string, unknown[]>;
|
|
505
505
|
failedModels?: string[];
|
|
506
|
-
}, protectedIds?: ReadonlySet<string
|
|
506
|
+
}, protectedIds?: ReadonlySet<string>, options?: {
|
|
507
|
+
/**
|
|
508
|
+
* SCOPED backfill (P4 hydrate-on-enter): the snapshot covers only the
|
|
509
|
+
* groups just entered, NOT the whole type. Two behaviors change to keep
|
|
510
|
+
* it from corrupting the pool:
|
|
511
|
+
* - upsert is version-guarded ({@link ObjectPool.upsertIfNewer}) so a
|
|
512
|
+
* concurrent live delta isn't clobbered back to the snapshot version;
|
|
513
|
+
* - ghost removal is SKIPPED — a subset snapshot must never evict rows
|
|
514
|
+
* of the same type that belong to other (unhydrated) groups.
|
|
515
|
+
*/
|
|
516
|
+
scoped?: boolean;
|
|
517
|
+
}): {
|
|
507
518
|
added: number;
|
|
508
519
|
updated: number;
|
|
509
520
|
removed: number;
|