@voltro/local-first 0.51.0 → 0.53.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/dist/index.d.ts CHANGED
@@ -62,18 +62,29 @@ export declare interface ConnectionState {
62
62
  readonly attempt: number;
63
63
  }
64
64
 
65
- /**
66
- * A CRDT implementation. One per library. The default is {@link yjsBackend}.
67
- *
68
- * `merge` and `decodeText` are STATELESS with respect to any live handle: they
69
- * operate purely on encoded bytes, which is what lets the sync layer and tests
70
- * converge two states without holding a document open.
71
- */
72
65
  export declare interface CrdtBackend {
73
66
  /** Identifies the library, e.g. `"yjs"`. Surfaced for diagnostics/telemetry. */
74
67
  readonly name: string;
75
68
  /** Create a fresh text buffer, optionally seeded with `initial`. */
76
69
  createText: (initial?: string) => CrdtTextHandle;
70
+ /** Create a whole-document handle, optionally seeded from encoded state. */
71
+ createDoc: (initial?: CrdtState) => CrdtDocHandle;
72
+ /**
73
+ * Re-encode a state through a live document so the backend garbage-collects
74
+ * what it can (Yjs: `mergeUpdates` never GCs tombstones, so a hot document's
75
+ * blob grows monotonically without this). Loses UPDATE HISTORY by design —
76
+ * a version-history feature must snapshot BEFORE compacting.
77
+ */
78
+ compact: (state: CrdtState) => CrdtState;
79
+ /**
80
+ * HARD compaction for a text state: re-create the document from its
81
+ * MATERIALISED text, dropping all history, tombstones and client ids. The
82
+ * result is minimal — and a NEW EPOCH: it does not merge with updates from
83
+ * the old lineage, so the server treats a rebase as a fresh snapshot for
84
+ * every subscriber (which the subscription lane already handles as a
85
+ * reset). Use when `compact` no longer bounds a hot document.
86
+ */
87
+ rebaseText: (state: CrdtState) => CrdtState;
77
88
  /**
78
89
  * Merge two encoded states into one converged encoded state.
79
90
  *
@@ -89,6 +100,54 @@ export declare interface CrdtBackend {
89
100
  emptyState: () => CrdtState;
90
101
  }
91
102
 
103
+ /**
104
+ * A CRDT implementation. One per library. The default is {@link yjsBackend}.
105
+ *
106
+ * `merge` and `decodeText` are STATELESS with respect to any live handle: they
107
+ * operate purely on encoded bytes, which is what lets the sync layer and tests
108
+ * converge two states without holding a document open.
109
+ */
110
+ /**
111
+ * A live, mutable CRDT DOCUMENT — the whole-doc generalisation `crdtDoc()`
112
+ * stores (rich text, maps, arrays; whatever the backend's document model
113
+ * holds). `crdtText()` remains the single-field specialisation over the same
114
+ * backend.
115
+ */
116
+ declare interface CrdtDocHandle {
117
+ /**
118
+ * The underlying library document (a `Y.Doc` for the yjs backend), typed
119
+ * opaquely: an EDITOR BINDING needs the real object (Tiptap's Collaboration
120
+ * extension takes a Y.Doc), while every sync-layer consumer only moves
121
+ * encoded bytes and must never reach in. Cast at the binding, nowhere else.
122
+ */
123
+ readonly raw: unknown;
124
+ /** Full state, encoded for transport or merge. */
125
+ encodeState: () => CrdtState;
126
+ /** This doc's state VECTOR — the compact "what I have" summary a peer
127
+ * diffs against. */
128
+ stateVector: () => Uint8Array;
129
+ /**
130
+ * Encode only what a peer holding `sinceVector` is missing — the
131
+ * INCREMENTAL update lane. A 1-character edit against a 100 KB doc encodes
132
+ * to a few dozen bytes, not the full state.
133
+ */
134
+ encodeUpdateSince: (sinceVector: Uint8Array) => CrdtState;
135
+ /** Fold an encoded state or incremental update into this doc, in place. */
136
+ applyState: (state: CrdtState) => void;
137
+ /** Subscribe to update blobs this doc produces (local edits AND applied
138
+ * remote states). Returns the unsubscribe. */
139
+ onUpdate: (handler: (update: CrdtState) => void) => () => void;
140
+ /**
141
+ * Encode a stable ANCHOR at `index` of the named text field — a position
142
+ * that survives concurrent edits (the primitive an inline-comment UI pins
143
+ * threads with; the UI itself is the comments plugin's business).
144
+ */
145
+ encodeAnchor: (field: string, index: number) => Uint8Array;
146
+ /** Resolve an anchor back to its current index, or `undefined` when the
147
+ * anchored region was deleted. */
148
+ resolveAnchor: (encoded: Uint8Array) => number | undefined;
149
+ }
150
+
92
151
  /** Identifies one CRDT-managed cell: a `crdtText()` COLUMN of one ROW of one TABLE. */
93
152
  export declare interface CrdtDocKey {
94
153
  readonly table: string;
@@ -96,6 +155,14 @@ export declare interface CrdtDocKey {
96
155
  readonly column: string;
97
156
  }
98
157
 
158
+ /**
159
+ * The client half of the CRDT downstream lane (plan 18): fold a `mergeCells`
160
+ * delta op's INCREMENTAL update into the held cell state. Pass as
161
+ * `@voltro/client`'s `SubscriptionCacheOptions.mergeCell`. A held cell that
162
+ * is not bytes (never delivered yet) takes the update as the new state.
163
+ */
164
+ export declare const crdtMergeCell: (_column: string, prevValue: unknown, update: unknown) => unknown;
165
+
99
166
  /**
100
167
  * An encoded CRDT state or delta, as opaque bytes. Produced by
101
168
  * {@link CrdtBackend.encodeState} / {@link CrdtTextHandle.encodeState},
@@ -168,6 +235,26 @@ export declare interface CrdtWritePayload extends CrdtDocKey {
168
235
  readonly update: CrdtState;
169
236
  }
170
237
 
238
+ /**
239
+ * The fallback-aware entry: durable when the environment allows it, in-memory
240
+ * + online-only otherwise — the plan's storage-fallback rule. The caller gets
241
+ * told which one it got; silence here would make a lockdown browser look like
242
+ * a working offline app until the first reload lost everything.
243
+ */
244
+ export declare const createDurableKv: (options?: {
245
+ readonly factory?: KvIndexedDbFactory;
246
+ readonly databaseName?: string;
247
+ }) => Promise<DurableKvResult>;
248
+
249
+ /**
250
+ * A durable KvStore over IndexedDB. Throws when no factory is available —
251
+ * `createDurableKv` below is the fallback-aware entry callers should use.
252
+ */
253
+ export declare const createIndexedDbKv: (options?: {
254
+ readonly factory?: KvIndexedDbFactory;
255
+ readonly databaseName?: string;
256
+ }) => Promise<KvStore>;
257
+
171
258
  /**
172
259
  * Create a durable {@link PersistenceAdapter} over IndexedDB.
173
260
  *
@@ -181,6 +268,9 @@ export declare const createIndexedDbPersistence: (options?: {
181
268
  readonly databaseName?: string;
182
269
  }) => Promise<PersistenceAdapter>;
183
270
 
271
+ /** In-memory KvStore — real (copies nothing; values are caller-owned JSON). */
272
+ export declare const createInMemoryKv: () => KvStore;
273
+
184
274
  /**
185
275
  * An in-memory {@link PersistenceAdapter} backed by Maps. Real, not a stub: it
186
276
  * fully satisfies the contract, and copies bytes on the way in and out so a
@@ -214,6 +304,18 @@ export declare const createInMemoryPresenceChannel: (bus?: InMemoryPresenceBus)
214
304
  */
215
305
  export declare const createPresenceRoom: <TPresence>(options: PresenceRoomOptions<TPresence>) => PresenceRoom<TPresence>;
216
306
 
307
+ export declare const createQueryMirror: (kv: KvStore, partition: MirrorPartition, options?: QueryMirrorOptions, now?: () => number) => QueryMirror;
308
+
309
+ /**
310
+ * Bind a partitioned {@link QueryMirror} to the subscription cache's mirror
311
+ * seam. Only declared tags persist; the mirror's own encrypted-column strip
312
+ * runs through `metadata` via the tag→table map.
313
+ *
314
+ * NOTE the mirror instance must already carry the CURRENT subject's
315
+ * partition — see the partition-switch note in the file header.
316
+ */
317
+ export declare const createSubscriptionMirrorBinding: (mirror: QueryMirror, options: MirrorBindingOptions) => SubscriptionMirrorShape;
318
+
217
319
  /**
218
320
  * Create a sync client over a {@link SyncTransport}.
219
321
  *
@@ -245,6 +347,26 @@ export declare const defaultCrdtBackend: CrdtBackend;
245
347
  */
246
348
  export declare const deriveSyncStatus: (phase: ConnectionPhase, outstanding: number) => SyncStatus;
247
349
 
350
+ export declare interface DrainLockOptions {
351
+ /** Injectable locks implementation. Defaults to `navigator.locks` (which
352
+ * node 22+ and every modern browser provide). Pass `null` to force the
353
+ * no-locks path — a host genuinely without the API, or a test. */
354
+ readonly locks?: WebLocksLike | null;
355
+ /** Partition discriminator — one drainer per (subject, tenant) queue. */
356
+ readonly name: string;
357
+ }
358
+
359
+ export declare type DrainOutcome = 'drained' | 'held-elsewhere' | 'no-locks';
360
+
361
+ export declare interface DurableKvResult {
362
+ readonly kv: KvStore;
363
+ /** `'durable'` when IndexedDB opened; `'memory'` when the environment has
364
+ * none (non-browser host, lockdown mode) or the open failed (quota, a
365
+ * private tab that refuses at open time). VISIBLE degradation — render it
366
+ * in a sync status — never a crash. */
367
+ readonly durability: 'durable' | 'memory';
368
+ }
369
+
248
370
  /** The encoded empty state — the identity element for {@link mergeCrdtStates}. */
249
371
  export declare const emptyCrdtState: (backend?: CrdtBackend) => CrdtState;
250
372
 
@@ -268,16 +390,35 @@ declare interface IDBDatabaseLike {
268
390
  createObjectStore: (name: string) => IDBObjectStoreLike;
269
391
  }
270
392
 
393
+ declare interface IDBDatabaseLike_2 {
394
+ transaction(name: string, mode: 'readonly' | 'readwrite'): IDBTransactionLike_2;
395
+ objectStoreNames: {
396
+ contains(name: string): boolean;
397
+ };
398
+ createObjectStore(name: string): unknown;
399
+ }
400
+
271
401
  declare interface IDBObjectStoreLike {
272
402
  get: (key: string) => IDBRequestLike<unknown>;
273
403
  put: (value: unknown, key: string) => IDBRequestLike<unknown>;
274
404
  delete: (key: string) => IDBRequestLike<unknown>;
275
405
  }
276
406
 
407
+ declare interface IDBObjectStoreLike_2 {
408
+ get(key: string): IDBRequestLike_2<unknown>;
409
+ put(value: unknown, key: string): IDBRequestLike_2<unknown>;
410
+ delete(key: string): IDBRequestLike_2<unknown>;
411
+ getAllKeys(range?: unknown): IDBRequestLike_2<ReadonlyArray<string>>;
412
+ }
413
+
277
414
  declare interface IDBOpenRequestLike extends IDBRequestLike<IDBDatabaseLike> {
278
415
  onupgradeneeded: ((this: unknown, ev: unknown) => void) | null;
279
416
  }
280
417
 
418
+ declare interface IDBOpenRequestLike_2 extends IDBRequestLike_2<IDBDatabaseLike_2> {
419
+ onupgradeneeded: ((this: unknown, ev: unknown) => void) | null;
420
+ }
421
+
281
422
  declare interface IDBRequestLike<T> {
282
423
  result: T;
283
424
  error: unknown;
@@ -285,18 +426,36 @@ declare interface IDBRequestLike<T> {
285
426
  onerror: ((this: unknown, ev: unknown) => void) | null;
286
427
  }
287
428
 
429
+ declare interface IDBRequestLike_2<T> {
430
+ result: T;
431
+ error: unknown;
432
+ onsuccess: ((this: unknown, ev: unknown) => void) | null;
433
+ onerror: ((this: unknown, ev: unknown) => void) | null;
434
+ }
435
+
288
436
  declare interface IDBTransactionLike {
289
437
  objectStore: (name: string) => IDBObjectStoreLike;
290
438
  }
291
439
 
440
+ declare interface IDBTransactionLike_2 {
441
+ objectStore(name: string): IDBObjectStoreLike_2;
442
+ }
443
+
292
444
  /**
293
445
  * DONE — Durable persistence.
294
446
  * `createIndexedDbPersistence` (./persistence/indexedDb) is a durable
295
447
  * `PersistenceAdapter` over IndexedDB — no WASM, no added dependency, tested
296
448
  * against a fake IDB backend that survives a reopen. `createInMemoryPersistence`
297
- * remains the ephemeral default. REMAINING (optional): a wa-sqlite / Turso
298
- * adapter for cross-tab SQL, a sibling file behind the SAME interface + the
299
- * plan's LRU eviction. Nothing above the adapter changes when it lands.
449
+ * remains the ephemeral default. The engine's query mirror adds `KvStore`
450
+ * (in-memory + IndexedDB + `createDurableKv` with a VISIBLE memory
451
+ * fallback) beneath `createQueryMirror` budget eviction included
452
+ * (`enforceBudget`, oldest-saved first). wa-sqlite was considered and
453
+ * REJECTED with the reasoning recorded in `mirror/queryMirror.ts`: the
454
+ * client's query surface is `(tag, input)`, predicates never exist
455
+ * client-side, so a browser SQL engine would evaluate a language the
456
+ * client never sees. A SQLite BACKING behind `KvStore` remains possible
457
+ * without touching a consumer (mobile's expo-sqlite adapter takes exactly
458
+ * that seam).
300
459
  *
301
460
  * DONE — Bi-directional sync wire.
302
461
  * `createSyncClient` (./sync/syncClient) maps the pure sync-queue reducer onto
@@ -323,10 +482,14 @@ declare interface IDBTransactionLike {
323
482
  * ephemeral awareness over a `PresenceChannel` — join/leave, announce-back
324
483
  * discovery, cursor propagation, TTL expiry. Tested over the in-memory channel
325
484
  * (`createInMemoryPresenceChannel`), which is the SAME dumb string-payload
326
- * shape as the framework's `BroadcastProvider`. REMAINING (runtime binding):
327
- * a `PresenceChannel` that forwards onto the app's provisioned broker
328
- * (in-memory locally; Redis / NATS at scale both already shipped in
329
- * `@voltro/plugin-broadcast`). That is a network hop, not new logic.
485
+ * shape as the framework's `BroadcastProvider`. The runtime binding is DONE
486
+ * (plan 02 phase 1): `usePresenceChannel` in `@voltro/plugin-presence/web`
487
+ * rides the framework's EXISTING presence lane`presence.heartbeat`
488
+ * carries the payload as member meta, the push-driven `presence.list`
489
+ * roster delivers it — so cross-replica fan-out is plugin-broadcast's and
490
+ * there is ONE presence wire, not two. The shape parity is pinned by
491
+ * `presence/channelParity.test-d.ts` (structural restatement, no
492
+ * production dependency edge).
330
493
  *
331
494
  * DONE — localFirst mixin + discovery.
332
495
  * `localFirst()` (`@voltro/database`) marks a table; the runtime SchemaRegistry
@@ -355,6 +518,20 @@ export declare interface InMemoryPresenceBus {
355
518
  readonly rooms: Map<string, Set<(payload: string) => void>>;
356
519
  }
357
520
 
521
+ export declare interface KvIndexedDbFactory {
522
+ open(name: string, version: number): IDBOpenRequestLike_2;
523
+ }
524
+
525
+ export declare interface KvStore {
526
+ /** Identifies the backing (`"in-memory"` / `"indexeddb"`). Diagnostics. */
527
+ readonly kind: string;
528
+ readonly get: (key: string) => Promise<unknown>;
529
+ readonly put: (key: string, value: unknown) => Promise<void>;
530
+ readonly del: (key: string) => Promise<void>;
531
+ /** All stored keys starting with `prefix`, unordered. */
532
+ readonly keysWithPrefix: (prefix: string) => Promise<ReadonlyArray<string>>;
533
+ }
534
+
358
535
  /**
359
536
  * Last-write-wins: newer `updatedAt` wins; exact ties break on the (stable,
360
537
  * symmetric) tiebreak key so both peers converge on the same value.
@@ -372,15 +549,82 @@ export declare const lastWriteWins: ConflictResolver;
372
549
  */
373
550
  export declare const loadPersistedSyncQueue: <T = unknown>(adapter: PersistenceAdapter, online?: boolean) => Promise<PersistedSyncQueue<T>>;
374
551
 
552
+ export declare const mergeCrdtStates: (a: CrdtState, b: CrdtState, backend?: CrdtBackend) => CrdtState;
553
+
554
+ export declare interface MirrorBindingOptions {
555
+ /** Which rpc tags are backed by which `localFirst()` table — the app's
556
+ * declaration of its sync set. A tag absent here is NOT mirrored. */
557
+ readonly tags: Readonly<Record<string, string>>;
558
+ /** The codegen-emitted metadata (`localFirstTables` from
559
+ * `.framework/localFirst.generated.ts`): table → columns to strip. */
560
+ readonly metadata?: Readonly<Record<string, {
561
+ readonly encryptedColumns: ReadonlyArray<string>;
562
+ }>>;
563
+ /**
564
+ * Schema fingerprint of the CLIENT build (any stable string — a build hash,
565
+ * an app version, `JSON.stringify(localFirstTables)`). The local-DB
566
+ * migration path: entries persist under the fingerprint they were written
567
+ * with, and a load under a DIFFERENT fingerprint returns nothing — a
568
+ * v1-mirrored row set is never rendered into a v2 UI whose columns moved.
569
+ * The DISCARD is visible as a plain cold start (loading, then the server's
570
+ * fresh snapshot), never a crash or a mixed-shape render. The offline
571
+ * QUEUE deliberately does not gate on this: a queued v1 write replays
572
+ * against the v2 server, whose input schema is the authority — a write it
573
+ * rejects surfaces as a failed/conflicted outbox entry the user can see
574
+ * and resolve, which beats silently dropping their work.
575
+ */
576
+ readonly schemaFingerprint?: string;
577
+ }
578
+
579
+ export declare interface MirroredQuery {
580
+ /** The rpc tag — kept for diagnostics and schema-migration checks. */
581
+ readonly tag: string;
582
+ /** The materialised rows (or single value) as the client would render them. */
583
+ readonly data: unknown;
584
+ /** The subscription revision `data` corresponds to — presented on
585
+ * re-subscribe as `voltro-resume-from`, so a reload inside the resume
586
+ * window continues with deltas instead of a snapshot. */
587
+ readonly revision: number;
588
+ /** When this entry was last written (ms epoch) — budget eviction input. */
589
+ readonly savedAt: number;
590
+ /** Schema fingerprint of the build that wrote the entry (see the binding's
591
+ * `schemaFingerprint` — the local-DB migration gate). Absent when the app
592
+ * does not stamp builds. */
593
+ readonly fingerprint?: string;
594
+ }
595
+
596
+ export declare interface MirrorPartition {
597
+ readonly subjectId: string;
598
+ readonly tenantId: string;
599
+ }
600
+
601
+ /** Structural mirror of `@voltro/client`'s `OutboxEntry` — kept structural so
602
+ * this package does not depend on the client package (the client consumes
603
+ * US through its `OutboxPersistence` seam, not the other way around). */
604
+ export declare interface OutboxEntryShape {
605
+ readonly id: string;
606
+ readonly tag: string;
607
+ readonly input: unknown;
608
+ readonly status: 'pending' | 'sent' | 'failed' | 'conflict';
609
+ readonly attempts: number;
610
+ readonly error?: unknown;
611
+ }
612
+
375
613
  /**
376
- * The heart of the package: converge two encoded CRDT states into one.
614
+ * Bind an outbox to a {@link PersistenceAdapter}. Pass the result as
615
+ * `useOutbox({ persistence })`.
377
616
  *
378
- * Deterministic and order-independent up to the resulting text
379
- * (`decodeCrdtText(mergeCrdtStates(a, b))` equals the same for `(b, a)`),
380
- * idempotent (re-merging a state already contained is a no-op on the text), and
381
- * treats {@link CrdtBackend.emptyState} as identity.
617
+ * `now` is injectable for determinism (the adapter's `QueuedWrite` carries an
618
+ * `enqueuedAt`); entries that do not decode as outbox entries are DROPPED on
619
+ * load rather than crashing the hydration a queue poisoned by another
620
+ * writer's shape degrades to the entries it can vouch for.
382
621
  */
383
- export declare const mergeCrdtStates: (a: CrdtState, b: CrdtState, backend?: CrdtBackend) => CrdtState;
622
+ export declare const outboxPersistence: (adapter: PersistenceAdapter, now?: () => number) => OutboxPersistenceBinding;
623
+
624
+ export declare interface OutboxPersistenceBinding {
625
+ readonly load: () => Promise<ReadonlyArray<OutboxEntryShape>>;
626
+ readonly save: (queue: ReadonlyArray<OutboxEntryShape>) => Promise<void>;
627
+ }
384
628
 
385
629
  /** Total outstanding writes (pending + the one in flight). 0 means fully synced. */
386
630
  export declare const outstandingCount: (state: SyncQueueState) => number;
@@ -472,6 +716,25 @@ export declare interface PresenceRoomOptions<TPresence> {
472
716
  readonly now?: () => number;
473
717
  }
474
718
 
719
+ export declare interface QueryMirror {
720
+ readonly partition: MirrorPartition;
721
+ readonly save: (queryKey: string, entry: Omit<MirroredQuery, 'savedAt'>) => Promise<void>;
722
+ readonly load: (queryKey: string) => Promise<MirroredQuery | undefined>;
723
+ /** Drop every mirrored query of THIS partition (logout, revocation). */
724
+ readonly purge: () => Promise<void>;
725
+ /** Enforce the storage budget: evict least-recently-saved entries until the
726
+ * serialised size estimate fits `maxBytes`. Returns evicted keys. */
727
+ readonly enforceBudget: (maxBytes: number) => Promise<ReadonlyArray<string>>;
728
+ }
729
+
730
+ export declare interface QueryMirrorOptions {
731
+ /** Column-exposure metadata for `localFirst()` tables, as emitted by the
732
+ * codegen: table → columns that must NEVER be persisted locally
733
+ * (`.encrypted()`). The mirror strips them from every row of a saved
734
+ * entry when `tableOf` resolves the entry's tag to a table. */
735
+ readonly encryptedColumnsOf?: (tag: string) => ReadonlyArray<string> | undefined;
736
+ }
737
+
475
738
  /** A single queued write. `payload` is opaque to the queue — the wire seam owns its shape. */
476
739
  export declare interface QueuedWrite<T = unknown> {
477
740
  readonly id: string;
@@ -487,6 +750,20 @@ export declare interface RemoteCrdtState extends CrdtDocKey {
487
750
  readonly state: CrdtState;
488
751
  }
489
752
 
753
+ /**
754
+ * Resolve a conflicted row write: CRDT columns merge, the rest go through the
755
+ * policy. `local`/`remote` carry per-field `{ value, updatedAt, writer }`;
756
+ * CRDT columns' values are their encoded states.
757
+ */
758
+ export declare const resolveWithPolicy: (policy: ConflictPolicy, local: VersionedRecord, remote: VersionedRecord, options?: ResolveWithPolicyOptions) => Record<string, unknown>;
759
+
760
+ export declare interface ResolveWithPolicyOptions {
761
+ /** Column names that are `crdtText()` cells — merged, never policy-resolved.
762
+ * Comes from the codegen's localFirst column metadata. */
763
+ readonly crdtColumns?: ReadonlyArray<string>;
764
+ readonly backend?: CrdtBackend;
765
+ }
766
+
490
767
  /**
491
768
  * The genuinely REMAINING runtime seams — provisioned infra + the two app-level
492
769
  * NAMES nothing can derive, not un-built framework code:
@@ -499,6 +776,20 @@ export declare const RUNTIME_SEAMS: readonly ["sync-transport-app-tags", "presen
499
776
 
500
777
  export declare type RuntimeSeam = (typeof RUNTIME_SEAMS)[number];
501
778
 
779
+ /** Structural mirror of `@voltro/client`'s SubscriptionMirror — restated so
780
+ * this package carries no client dependency; the client consumes US. */
781
+ export declare interface SubscriptionMirrorShape {
782
+ readonly load: (key: string) => Promise<{
783
+ readonly data: unknown;
784
+ readonly revision: number;
785
+ } | undefined>;
786
+ readonly save: (key: string, snap: {
787
+ readonly tag: string | undefined;
788
+ readonly data: unknown;
789
+ readonly revision: number;
790
+ }) => Promise<void>;
791
+ }
792
+
502
793
  export declare interface SyncClient {
503
794
  /** Apply a local edit: merge it into local state immediately (optimistic),
504
795
  * persist, queue it, and start draining if online. */
@@ -609,6 +900,24 @@ export declare interface SyncTransport {
609
900
  /** A record's fields, each carrying its version metadata. */
610
901
  export declare type VersionedRecord = Record<string, FieldValue>;
611
902
 
903
+ export declare interface WebLocksLike {
904
+ readonly request: (name: string, options: {
905
+ readonly mode: 'exclusive';
906
+ readonly ifAvailable: boolean;
907
+ }, callback: (lock: unknown | null) => Promise<void>) => Promise<void>;
908
+ }
909
+
910
+ /**
911
+ * Run `drain` under the partition's exclusive cross-tab lock.
912
+ *
913
+ * `ifAvailable: true` — a tab that finds the lock held does NOT queue behind
914
+ * it (the holder is already draining the same shared queue; a second run the
915
+ * moment it finishes would replay an already-compacted queue). It reports
916
+ * `held-elsewhere` and relies on the holder's drain + its own next trigger
917
+ * (reconnect, enqueue) to try again.
918
+ */
919
+ export declare const withDrainLock: (options: DrainLockOptions, drain: () => Promise<void>) => Promise<DrainOutcome>;
920
+
612
921
  export declare const yjsBackend: CrdtBackend;
613
922
 
614
923
  export { }