@loro-dev/flock-wasm 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1281 -6
- package/base64/flock_wasm.js +9 -4
- package/base64/index.d.ts +11 -6
- package/base64/index.js +11 -6
- package/base64/wasm.js +1 -1
- package/bundler/flock_wasm.d.ts +9 -4
- package/bundler/flock_wasm_bg.js +9 -4
- package/bundler/flock_wasm_bg.wasm +0 -0
- package/bundler/index.d.ts +11 -6
- package/bundler/index.js +11 -6
- package/nodejs/flock_wasm.d.ts +9 -4
- package/nodejs/flock_wasm.js +9 -4
- package/nodejs/flock_wasm_bg.wasm +0 -0
- package/nodejs/index.d.ts +11 -6
- package/nodejs/index.js +11 -6
- package/package.json +2 -1
- package/web/flock_wasm.d.ts +9 -4
- package/web/flock_wasm.js +9 -4
- package/web/flock_wasm_bg.wasm +0 -0
- package/web/index.d.ts +11 -6
- package/web/index.js +11 -6
package/README.md
CHANGED
|
@@ -1,13 +1,1288 @@
|
|
|
1
1
|
# @loro-dev/flock-wasm
|
|
2
2
|
|
|
3
|
-
WASM bindings for `flock-rs
|
|
3
|
+
WASM bindings for `flock-rs` — a distributed, conflict-free key-value store built on CRDT (Conflict-free Replicated Data Type) principles.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Flock enables multiple replicas to independently read and write data, merge changes without coordination, and converge deterministically to the same state. It is designed for offline-first, peer-to-peer, and decentralized applications where network partitions are expected and no central authority is required.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
- [Install](#install)
|
|
10
|
+
- [Core Concepts](#core-concepts)
|
|
11
|
+
- [Quick Start](#quick-start)
|
|
12
|
+
- [Runtime Modes and Buffering](#runtime-modes-and-buffering)
|
|
13
|
+
- [API Reference](#api-reference)
|
|
14
|
+
- [Top-Level Exports](#top-level-exports)
|
|
15
|
+
- [Flock Constructors](#flock-constructors)
|
|
16
|
+
- [Identity and Diagnostics](#identity-and-diagnostics)
|
|
17
|
+
- [Data Operations](#data-operations)
|
|
18
|
+
- [Multi-Value Register (MVR)](#multi-value-register-mvr)
|
|
19
|
+
- [Scan Operations](#scan-operations)
|
|
20
|
+
- [Replication and Import/Export](#replication-and-importexport)
|
|
21
|
+
- [Hooks API](#hooks-api)
|
|
22
|
+
- [Events and Subscriptions](#events-and-subscriptions)
|
|
23
|
+
- [Debounced Event Batching](#debounced-event-batching)
|
|
24
|
+
- [Transactions](#transactions)
|
|
25
|
+
- [Binary File Export/Import](#binary-file-exportimport)
|
|
26
|
+
- [Type Reference](#type-reference)
|
|
27
|
+
- [Conflict Resolution Deep Dive](#conflict-resolution-deep-dive)
|
|
28
|
+
- [Version Vectors Explained](#version-vectors-explained)
|
|
29
|
+
- [Best Practices](#best-practices)
|
|
30
|
+
- [Common Pitfalls](#common-pitfalls)
|
|
31
|
+
- [Build](#build)
|
|
32
|
+
- [Test](#test)
|
|
33
|
+
- [License](#license)
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm add @loro-dev/flock-wasm
|
|
39
|
+
# or
|
|
40
|
+
pnpm add @loro-dev/flock-wasm
|
|
41
|
+
# or
|
|
42
|
+
yarn add @loro-dev/flock-wasm
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Core Concepts
|
|
46
|
+
|
|
47
|
+
### CRDT with Last-Writer-Wins (LWW) Semantics
|
|
48
|
+
|
|
49
|
+
Every entry in Flock carries a **clock** consisting of three components:
|
|
50
|
+
|
|
51
|
+
1. **Physical time** — wall-clock timestamp in milliseconds.
|
|
52
|
+
2. **Logical counter** — monotonic counter to break ties at the same physical time.
|
|
53
|
+
3. **Peer ID** — unique identifier of the writer.
|
|
54
|
+
|
|
55
|
+
When two replicas have conflicting values for the same key, Flock resolves deterministically:
|
|
56
|
+
|
|
57
|
+
1. Higher `physicalTime` wins.
|
|
58
|
+
2. On tie, higher `logicalCounter` wins.
|
|
59
|
+
3. On tie, lexicographically greater `peerId` wins.
|
|
60
|
+
|
|
61
|
+
All replicas applying the same rule to the same set of entries converge to the identical value — no coordination needed.
|
|
62
|
+
|
|
63
|
+
### Keys and Values
|
|
64
|
+
|
|
65
|
+
- **Keys** are arrays of JSON-serializable parts: `["users", 42, "name"]`. Keys are encoded into a memcomparable binary format, enabling efficient sorted storage and prefix-based range scans.
|
|
66
|
+
- **Values** are any JSON-serializable type: strings, numbers, booleans, null, arrays, or nested objects.
|
|
67
|
+
- **Metadata** is an optional `Record<string, unknown>` attached to each entry, useful for signatures, policy hints, or audit information.
|
|
68
|
+
|
|
69
|
+
### Tombstones
|
|
70
|
+
|
|
71
|
+
Deletions create **tombstone** entries rather than removing data. A tombstone carries a clock but no data. Tombstones:
|
|
72
|
+
|
|
73
|
+
- Are visible in `scan()` results (with `value: undefined`).
|
|
74
|
+
- Participate in conflict resolution — a delete with a higher clock wins over an older write.
|
|
75
|
+
- Can be pruned during export via `pruneTombstonesBefore`.
|
|
76
|
+
- Ensure that "deleted" and "never existed" are distinguishable via `getEntry()`.
|
|
77
|
+
|
|
78
|
+
### Version Vectors
|
|
79
|
+
|
|
80
|
+
A version vector is a compact summary of the visible state: `{ [peerId]: { physicalTime, logicalCounter } }`. Two types exist:
|
|
81
|
+
|
|
82
|
+
- **Exclusive version** (`version()`): Only includes peers that currently own at least one visible entry. Use this as the baseline for incremental sync.
|
|
83
|
+
- **Inclusive version** (`inclusiveVersion()`): Tracks max seen clocks across all peers ever encountered. Use this for completeness checks.
|
|
84
|
+
|
|
85
|
+
## Quick Start
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
import { Flock } from "@loro-dev/flock-wasm";
|
|
89
|
+
|
|
90
|
+
// Create two independent replicas
|
|
91
|
+
const alice = new Flock("alice");
|
|
92
|
+
const bob = new Flock("bob");
|
|
93
|
+
|
|
94
|
+
// Alice writes some data
|
|
95
|
+
alice.put(["todos", 1], { title: "Write docs", done: false });
|
|
96
|
+
alice.put(["todos", 2], { title: "Ship feature", done: false });
|
|
97
|
+
|
|
98
|
+
// Export Alice's state and import into Bob
|
|
99
|
+
const snapshot = alice.exportJson();
|
|
100
|
+
bob.importJson(snapshot);
|
|
101
|
+
|
|
102
|
+
// Bob sees Alice's data
|
|
103
|
+
console.log(bob.get(["todos", 1])); // { title: "Write docs", done: false }
|
|
104
|
+
|
|
105
|
+
// Both edit concurrently
|
|
106
|
+
alice.put(["todos", 1], { title: "Write docs", done: true });
|
|
107
|
+
bob.put(["todos", 1], { title: "Write better docs", done: false });
|
|
108
|
+
|
|
109
|
+
// Merge resolves conflict via LWW (higher timestamp wins)
|
|
110
|
+
alice.merge(bob);
|
|
111
|
+
bob.merge(alice);
|
|
112
|
+
|
|
113
|
+
// Both converge to the same value
|
|
114
|
+
console.log(alice.get(["todos", 1])); // same result
|
|
115
|
+
console.log(bob.get(["todos", 1])); // same result
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Runtime Modes and Buffering
|
|
119
|
+
|
|
120
|
+
All `Flock` constructors (`new`, `fromJson`, `fromFile`) use **buffered mode** by default:
|
|
121
|
+
|
|
122
|
+
- **Memtable enabled**: Writes are accumulated in an in-memory buffer.
|
|
123
|
+
- **Flushed on commit**: Buffered writes are flushed when `txnCommit()` is called, when the storage transaction commits, or when exporting file bytes.
|
|
124
|
+
- **Implication**: For strongest durability semantics, explicitly commit transactions or call `exportFile()` more frequently.
|
|
125
|
+
|
|
126
|
+
## API Reference
|
|
127
|
+
|
|
128
|
+
### Top-Level Exports
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
import {
|
|
132
|
+
Flock,
|
|
133
|
+
encodeVersionVector,
|
|
134
|
+
decodeVersionVector,
|
|
135
|
+
type VersionVector,
|
|
136
|
+
type VersionVectorEntry,
|
|
137
|
+
type ExportBundle,
|
|
138
|
+
type ImportReport,
|
|
139
|
+
type ScanOptions,
|
|
140
|
+
type ScanRow,
|
|
141
|
+
type ScanBound,
|
|
142
|
+
type EventBatch,
|
|
143
|
+
type Event,
|
|
144
|
+
type EventPayload,
|
|
145
|
+
type EntryInfo,
|
|
146
|
+
type EntryClock,
|
|
147
|
+
type ExportRecord,
|
|
148
|
+
type MetadataMap,
|
|
149
|
+
type Value,
|
|
150
|
+
type KeyPart,
|
|
151
|
+
type ExportPayload,
|
|
152
|
+
type ExportHookContext,
|
|
153
|
+
type ExportHooks,
|
|
154
|
+
type ImportPayload,
|
|
155
|
+
type ImportHookContext,
|
|
156
|
+
type ImportAccept,
|
|
157
|
+
type ImportSkip,
|
|
158
|
+
type ImportDecision,
|
|
159
|
+
type ImportHooks,
|
|
160
|
+
type PutPayload,
|
|
161
|
+
type PutHookContext,
|
|
162
|
+
type PutHooks,
|
|
163
|
+
type PutWithMetaOptions,
|
|
164
|
+
} from "@loro-dev/flock-wasm";
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
### `encodeVersionVector(vector: VersionVector): Uint8Array`
|
|
170
|
+
|
|
171
|
+
Encodes a version vector into a compact binary representation.
|
|
172
|
+
|
|
173
|
+
- **Input**: A `VersionVector` object mapping peer IDs to `{ physicalTime, logicalCounter }`.
|
|
174
|
+
- **Output**: `Uint8Array` with `VEVE` magic prefix, ULEB128-encoded timestamps, and delta-compressed entries.
|
|
175
|
+
- **Use case**: Persist or transmit version vectors efficiently. The binary format is deterministic — same logical content always produces identical bytes.
|
|
176
|
+
- **Skips**: Entries with invalid peer IDs (non-string or >= 128 UTF-8 bytes) or non-finite timestamps are silently ignored.
|
|
177
|
+
|
|
178
|
+
### `decodeVersionVector(bytes: Uint8Array): VersionVector`
|
|
179
|
+
|
|
180
|
+
Decodes a binary version vector back into a `VersionVector` object.
|
|
181
|
+
|
|
182
|
+
- **Input**: `Uint8Array` produced by `encodeVersionVector`.
|
|
183
|
+
- **Output**: `VersionVector` object.
|
|
184
|
+
- **Compatibility**: Handles both the current `VEVE`-prefixed format and the legacy format (no magic prefix) transparently.
|
|
185
|
+
- **Throws**: `TypeError` if peer IDs are invalid or timestamps are non-monotonic.
|
|
186
|
+
|
|
187
|
+
---
|
|
188
|
+
|
|
189
|
+
### Flock Constructors
|
|
190
|
+
|
|
191
|
+
#### `new Flock(peerId?: string)`
|
|
192
|
+
|
|
193
|
+
Creates a new empty Flock instance.
|
|
194
|
+
|
|
195
|
+
- **`peerId`** (optional): A UTF-8 string under 128 bytes identifying this peer. If omitted, a cryptographically random 64-character hex string is generated.
|
|
196
|
+
- **Returns**: A new `Flock` instance in buffered mode.
|
|
197
|
+
- **Throws**: `TypeError` if `peerId` is not a valid string or exceeds 127 UTF-8 bytes.
|
|
198
|
+
- **Side effects**: None. The instance starts with empty state and no version history.
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
const named = new Flock("peer-alice");
|
|
202
|
+
const anonymous = new Flock(); // random 64-char hex ID
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
#### `Flock.fromJson(bundle: ExportBundle, peerId: string): Flock`
|
|
206
|
+
|
|
207
|
+
Creates a new Flock instance pre-populated with state from a JSON export bundle.
|
|
208
|
+
|
|
209
|
+
- **`bundle`**: An `ExportBundle` obtained from `exportJson()` on another instance.
|
|
210
|
+
- **`peerId`**: The peer ID for the new instance (required).
|
|
211
|
+
- **Returns**: A new `Flock` with merged state from the bundle.
|
|
212
|
+
- **Throws**: If the bundle format is invalid or import fails.
|
|
213
|
+
- **Use case**: Bootstrap a new replica from a snapshot received over the network.
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
const snapshot = source.exportJson();
|
|
217
|
+
const replica = Flock.fromJson(snapshot, "new-peer");
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
#### `Flock.fromFile(bytes: Uint8Array, peerId?: string): Flock`
|
|
221
|
+
|
|
222
|
+
Creates a new Flock instance from a binary file export. This is the **fastest way to load state** — the pre-built B+Tree pages are used directly without re-parsing or re-indexing (O(1) open time vs. O(n log n) for `fromJson`).
|
|
223
|
+
|
|
224
|
+
- **`bytes`**: `Uint8Array` produced by `exportFile()`.
|
|
225
|
+
- **`peerId`** (optional): Peer ID for the new instance. Random if omitted.
|
|
226
|
+
- **Returns**: A new `Flock` with full state restored from the binary format.
|
|
227
|
+
- **Throws**: If the binary format is invalid (wrong magic bytes, corrupted pages, CRC mismatch).
|
|
228
|
+
- **Use case**: Fast cold-start, restore from disk, or load a persisted checkpoint. Prefer this over `fromJson` when loading full state.
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
const bytes = fs.readFileSync("state.db");
|
|
232
|
+
const restored = Flock.fromFile(new Uint8Array(bytes), "restored-peer");
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
### Identity and Diagnostics
|
|
238
|
+
|
|
239
|
+
#### `peerId(): string`
|
|
240
|
+
|
|
241
|
+
Returns the current peer ID of this Flock instance.
|
|
242
|
+
|
|
243
|
+
- **Returns**: The peer ID string (always valid UTF-8, under 128 bytes).
|
|
244
|
+
- **Throws**: `TypeError` if the internal FFI returns an unexpected value (should not happen in normal operation).
|
|
245
|
+
- **Note**: For anonymous instances, returns the auto-generated 64-character hex string.
|
|
246
|
+
|
|
247
|
+
#### `setPeerId(peerId: string): void`
|
|
248
|
+
|
|
249
|
+
Changes the peer ID for subsequent operations.
|
|
250
|
+
|
|
251
|
+
- **`peerId`**: New peer ID (UTF-8, under 128 bytes).
|
|
252
|
+
- **Effect**: All future `put`, `delete`, and `putWithMeta` calls will be attributed to the new peer ID.
|
|
253
|
+
- **Does NOT retroactively change** previous entries — those remain attributed to the original peer.
|
|
254
|
+
- **Throws**: `TypeError` if the peer ID is invalid.
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
flock.setPeerId("peer-a");
|
|
258
|
+
flock.put(["k"], "by-a"); // attributed to "peer-a"
|
|
259
|
+
flock.setPeerId("peer-b");
|
|
260
|
+
flock.put(["k"], "by-b"); // attributed to "peer-b", overwrites "by-a" if clock is higher
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
#### `checkInvariants(): void`
|
|
264
|
+
|
|
265
|
+
Validates the internal CRDT state for consistency.
|
|
266
|
+
|
|
267
|
+
- **Effect**: Exercises `version()`, `inclusiveVersion()`, and `scan()` to check structural integrity.
|
|
268
|
+
- **Throws**: If any internal invariant is violated.
|
|
269
|
+
- **Use case**: Debugging and testing. Not needed in production.
|
|
270
|
+
|
|
271
|
+
#### `getMaxPhysicalTime(): number`
|
|
272
|
+
|
|
273
|
+
Returns the maximum physical timestamp across all entries.
|
|
274
|
+
|
|
275
|
+
- **Returns**: A `number` (milliseconds). Returns `0` for an empty Flock.
|
|
276
|
+
- **Use case**: Determine a safe threshold for tombstone pruning. For example, `pruneTombstonesBefore: flock.getMaxPhysicalTime() - 86400000` prunes tombstones older than 24 hours.
|
|
277
|
+
- **Note**: This value only increases monotonically during the lifetime of the instance.
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
### Data Operations
|
|
282
|
+
|
|
283
|
+
#### `put(key: KeyPart[], value: Value, now?: number): void`
|
|
284
|
+
|
|
285
|
+
Stores a value at the specified key using LWW semantics.
|
|
286
|
+
|
|
287
|
+
- **`key`**: Array of JSON-serializable parts (e.g., `["users", 42]`).
|
|
288
|
+
- **`value`**: Any JSON-serializable value (string, number, boolean, null, array, object).
|
|
289
|
+
- **`now`** (optional): Explicit physical timestamp in milliseconds. If omitted, `Date.now()` is used.
|
|
290
|
+
- **Effect**: Creates or overwrites the entry at `key`. If the key already exists, the new entry wins only if its clock is higher than the existing entry's clock.
|
|
291
|
+
- **Event**: Emits a change event to all subscribers (subject to debounce/transaction batching).
|
|
292
|
+
- **Throws**: If the key or value is not JSON-serializable.
|
|
293
|
+
|
|
294
|
+
**Important**: `put` always succeeds locally — the LWW comparison happens during merge with remote state. Locally, `put` always overwrites because the local clock is guaranteed to advance.
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
flock.put(["config", "theme"], "dark");
|
|
298
|
+
flock.put(["scores", 1], 100, Date.now());
|
|
299
|
+
flock.put(["nested"], { a: { b: [1, 2, 3] } });
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
#### `set(key: KeyPart[], value: Value, now?: number): void`
|
|
303
|
+
|
|
304
|
+
Alias for `put()`. Identical behavior.
|
|
305
|
+
|
|
306
|
+
#### `putWithMeta(key: KeyPart[], value: Value, options?: PutWithMetaOptions): void | Promise<void>`
|
|
307
|
+
|
|
308
|
+
Stores a value with optional metadata and/or transform hooks.
|
|
309
|
+
|
|
310
|
+
- **`key`**: Key array.
|
|
311
|
+
- **`value`**: Value to store.
|
|
312
|
+
- **`options.metadata`** (optional): A `MetadataMap` (`Record<string, unknown>`) to associate with this entry. Useful for signatures, audit trails, or policy tags.
|
|
313
|
+
- **`options.now`** (optional): Explicit timestamp.
|
|
314
|
+
- **`options.hooks.transform`** (optional): An async function `(context, payload) => payload | void` that can modify the data and metadata before storage. If provided, the method returns a `Promise<void>`.
|
|
315
|
+
- **Returns**: `void` if no hooks; `Promise<void>` if hooks are present.
|
|
316
|
+
- **Throws**: `TypeError` if the transform hook returns a payload without a `data` field.
|
|
317
|
+
- **Event**: Emits a change event after the (possibly async) operation completes.
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
// Simple metadata
|
|
321
|
+
flock.putWithMeta(["doc", 1], "content", {
|
|
322
|
+
metadata: { author: "alice", signature: "abc123" },
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
// With transform hook (async)
|
|
326
|
+
await flock.putWithMeta(["doc", 1], "content", {
|
|
327
|
+
metadata: { sig: "pending" },
|
|
328
|
+
hooks: {
|
|
329
|
+
transform: async (ctx, payload) => {
|
|
330
|
+
payload.metadata = { ...payload.metadata, sig: await sign(payload.data) };
|
|
331
|
+
return payload;
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
#### `delete(key: KeyPart[], now?: number): void`
|
|
338
|
+
|
|
339
|
+
Deletes the value at the specified key by creating a tombstone.
|
|
340
|
+
|
|
341
|
+
- **`key`**: Key array.
|
|
342
|
+
- **`now`** (optional): Explicit timestamp.
|
|
343
|
+
- **Effect**: Creates a tombstone entry. `get(key)` will return `undefined` after deletion.
|
|
344
|
+
- **Important**: The tombstone participates in conflict resolution. A delete with a lower clock than an existing write will be ignored during merge. A delete with a higher clock will win.
|
|
345
|
+
- **No-op on missing keys**: Deleting a key that has never been written is a no-op — no tombstone is created and no event is emitted. Deleting an already-deleted key is also a no-op (the existing tombstone is kept).
|
|
346
|
+
- **Event**: Emits a change event with `value: undefined` when an existing entry is deleted.
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
flock.put(["temp"], "data");
|
|
350
|
+
flock.delete(["temp"]);
|
|
351
|
+
flock.get(["temp"]); // undefined
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
#### `get(key: KeyPart[]): Value | undefined`
|
|
355
|
+
|
|
356
|
+
Retrieves the current value at the specified key.
|
|
357
|
+
|
|
358
|
+
- **`key`**: Key array.
|
|
359
|
+
- **Returns**: The stored value, or `undefined` if the key does not exist or has been deleted.
|
|
360
|
+
- **Note**: Does not distinguish between "never set" and "deleted". Use `getEntry()` for that distinction.
|
|
361
|
+
- **Graceful**: If the key array cannot be decoded (e.g., contains non-serializable types), returns `undefined` instead of throwing.
|
|
362
|
+
|
|
363
|
+
```ts
|
|
364
|
+
flock.put(["k"], 42);
|
|
365
|
+
flock.get(["k"]); // 42
|
|
366
|
+
flock.get(["missing"]); // undefined
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
#### `getEntry(key: KeyPart[]): EntryInfo | undefined`
|
|
370
|
+
|
|
371
|
+
Retrieves the full entry including data, metadata, and clock information.
|
|
372
|
+
|
|
373
|
+
- **`key`**: Key array.
|
|
374
|
+
- **Returns**: An `EntryInfo` object `{ data?, metadata, clock }`, or `undefined` if the key has never been written.
|
|
375
|
+
- **Tombstone distinction**: For deleted keys, returns an object with `data` omitted but `clock` and `metadata` present. For never-set keys, returns `undefined`.
|
|
376
|
+
- **Metadata**: Always present as an object (defaults to `{}` when no metadata was stored).
|
|
377
|
+
- **Use case**: Auditing (who wrote when), tombstone detection, signature verification.
|
|
378
|
+
|
|
379
|
+
```ts
|
|
380
|
+
flock.putWithMeta(["k"], "value", { metadata: { author: "alice" } });
|
|
381
|
+
const entry = flock.getEntry(["k"]);
|
|
382
|
+
// {
|
|
383
|
+
// data: "value",
|
|
384
|
+
// metadata: { author: "alice" },
|
|
385
|
+
// clock: { physicalTime: 1700000000000, logicalCounter: 0, peerId: "alice" }
|
|
386
|
+
// }
|
|
387
|
+
|
|
388
|
+
flock.delete(["k"]);
|
|
389
|
+
const tombstone = flock.getEntry(["k"]);
|
|
390
|
+
// {
|
|
391
|
+
// metadata: {},
|
|
392
|
+
// clock: { physicalTime: 1700000000001, logicalCounter: 0, peerId: "alice" }
|
|
393
|
+
// }
|
|
394
|
+
// Note: `data` field is absent (not `undefined` — it is omitted)
|
|
395
|
+
|
|
396
|
+
flock.getEntry(["never-set"]); // undefined
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
401
|
+
### Multi-Value Register (MVR)
|
|
402
|
+
|
|
403
|
+
MVR is a pattern built on top of Flock's LWW key-value store that allows concurrent writes from different peers to coexist instead of one winning. It works by appending the value to the key and storing `true` as a marker.
|
|
404
|
+
|
|
405
|
+
#### `putMvr(key: KeyPart[], value: Value, now?: number): void`
|
|
406
|
+
|
|
407
|
+
Stores a scalar value using the MVR pattern.
|
|
408
|
+
|
|
409
|
+
- **`key`**: Base key array (the MVR "slot").
|
|
410
|
+
- **`value`**: Must be a **scalar** JSON value: `string`, `number`, or `boolean`. Objects, arrays, and `null` are rejected.
|
|
411
|
+
- **`now`** (optional): Explicit timestamp.
|
|
412
|
+
- **Effect**: Deletes all existing MVR entries under the base key (where `value === true`), regardless of which peer wrote them, then writes `true` at `[...key, value]`.
|
|
413
|
+
- **Throws**: `TypeError` if `value` is null, an array, or an object.
|
|
414
|
+
- **Event**: Emits delete events for removed entries and a put event for the new entry.
|
|
415
|
+
|
|
416
|
+
**How it works internally**:
|
|
417
|
+
|
|
418
|
+
```
|
|
419
|
+
putMvr(["mv"], "one") →
|
|
420
|
+
scan prefix ["mv"] and delete all entries where value === true
|
|
421
|
+
put(["mv", "one"], true)
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
#### `getMvr(key: KeyPart[]): Value[]`
|
|
425
|
+
|
|
426
|
+
Retrieves all concurrent MVR values for a base key.
|
|
427
|
+
|
|
428
|
+
- **`key`**: Base key array.
|
|
429
|
+
- **Returns**: Array of scalar values. Empty `[]` if no values exist.
|
|
430
|
+
- **Behavior**: Scans all entries with prefix `key`, filters for entries at depth `key.length + 1` with `value === true`, and extracts the appended value part.
|
|
431
|
+
|
|
432
|
+
```ts
|
|
433
|
+
// Single writer
|
|
434
|
+
flock.putMvr(["color"], "red");
|
|
435
|
+
flock.getMvr(["color"]); // ["red"]
|
|
436
|
+
|
|
437
|
+
// Concurrent writers merge
|
|
438
|
+
const a = new Flock("a");
|
|
439
|
+
const b = new Flock("b");
|
|
440
|
+
a.putMvr(["color"], "red");
|
|
441
|
+
b.putMvr(["color"], "blue");
|
|
442
|
+
a.merge(b);
|
|
443
|
+
a.getMvr(["color"]); // ["red", "blue"] (order may vary)
|
|
444
|
+
|
|
445
|
+
// Calling putMvr replaces ALL values (not just the local peer's)
|
|
446
|
+
a.putMvr(["color"], "green");
|
|
447
|
+
a.getMvr(["color"]); // ["green"] — both "red" and "blue" were deleted
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
---
|
|
451
|
+
|
|
452
|
+
### Scan Operations
|
|
453
|
+
|
|
454
|
+
#### `scan(options?: ScanOptions): ScanRow[]`
|
|
455
|
+
|
|
456
|
+
Scans entries within the specified key range and/or prefix.
|
|
457
|
+
|
|
458
|
+
- **`options.start`** (optional): Start bound for the scan range.
|
|
459
|
+
- **`options.end`** (optional): End bound for the scan range.
|
|
460
|
+
- **`options.prefix`** (optional): Key prefix to filter by. Only entries whose key starts with this prefix are returned.
|
|
461
|
+
- **Returns**: Array of `ScanRow` objects sorted by key in lexicographic order.
|
|
462
|
+
- **Tombstones**: Included in results with `value: undefined`. Use `row.value === undefined` to detect them.
|
|
463
|
+
- **Empty result**: Returns `[]` when no entries match.
|
|
464
|
+
|
|
465
|
+
**Bound types**:
|
|
466
|
+
|
|
467
|
+
```ts
|
|
468
|
+
// Inclusive: includes the boundary key
|
|
469
|
+
{ kind: "inclusive", key: ["b"] }
|
|
470
|
+
|
|
471
|
+
// Exclusive: excludes the boundary key
|
|
472
|
+
{ kind: "exclusive", key: ["d"] }
|
|
473
|
+
|
|
474
|
+
// Unbounded: no limit in this direction
|
|
475
|
+
{ kind: "unbounded" }
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
**Examples**:
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
// Full scan (all entries including tombstones)
|
|
482
|
+
const all = flock.scan();
|
|
483
|
+
|
|
484
|
+
// Prefix scan
|
|
485
|
+
const users = flock.scan({ prefix: ["users"] });
|
|
486
|
+
|
|
487
|
+
// Range scan with bounds
|
|
488
|
+
const range = flock.scan({
|
|
489
|
+
start: { kind: "inclusive", key: ["b"] },
|
|
490
|
+
end: { kind: "exclusive", key: ["d"] },
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// Combine prefix and bounds
|
|
494
|
+
const page = flock.scan({
|
|
495
|
+
prefix: ["users"],
|
|
496
|
+
start: { kind: "exclusive", key: ["users", 100] },
|
|
497
|
+
end: { kind: "inclusive", key: ["users", 200] },
|
|
498
|
+
});
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
**ScanRow structure**:
|
|
502
|
+
|
|
503
|
+
```ts
|
|
504
|
+
{
|
|
505
|
+
key: KeyPart[]; // The full key
|
|
506
|
+
raw: ExportRecord; // Raw record with clock string and optional data/metadata
|
|
507
|
+
value?: Value; // The data value; undefined for tombstones
|
|
508
|
+
}
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
---
|
|
512
|
+
|
|
513
|
+
### Replication and Import/Export
|
|
514
|
+
|
|
515
|
+
#### `version(): VersionVector`
|
|
516
|
+
|
|
517
|
+
Returns the **exclusive** (visible) version vector.
|
|
518
|
+
|
|
519
|
+
- **Returns**: `VersionVector` — only includes peers that currently own at least one visible entry (not overwritten by a later entry from another peer).
|
|
520
|
+
- **Use case**: Pass this to a remote peer as the `from` parameter of `exportJson()` to get only the updates the local replica is missing.
|
|
521
|
+
- **Behavior after overwrites**: If peer A's only entry is overwritten by peer B, peer A is removed from the exclusive version.
|
|
522
|
+
|
|
523
|
+
```ts
|
|
524
|
+
flock.setPeerId("a");
|
|
525
|
+
flock.put(["k"], "v1");
|
|
526
|
+
flock.version(); // { a: { physicalTime: ..., logicalCounter: ... } }
|
|
527
|
+
|
|
528
|
+
flock.setPeerId("b");
|
|
529
|
+
flock.put(["k"], "v2"); // overwrites peer a's entry
|
|
530
|
+
flock.version(); // { b: { ... } } — peer a is gone
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
#### `inclusiveVersion(): VersionVector`
|
|
534
|
+
|
|
535
|
+
Returns the **inclusive** (max-seen) version vector.
|
|
536
|
+
|
|
537
|
+
- **Returns**: `VersionVector` — tracks the highest clock seen from every peer during this instance's lifetime, including peers whose entries have been overwritten.
|
|
538
|
+
- **Use case**: Completeness checks, debugging, or determining whether a remote update has been "seen" even if it was subsequently overwritten.
|
|
539
|
+
- **Guarantee**: Always a superset of `version()`.
|
|
540
|
+
|
|
541
|
+
```ts
|
|
542
|
+
flock.setPeerId("a");
|
|
543
|
+
flock.put(["k"], "v1");
|
|
544
|
+
flock.setPeerId("b");
|
|
545
|
+
flock.put(["k"], "v2");
|
|
546
|
+
|
|
547
|
+
flock.version(); // { b: { ... } }
|
|
548
|
+
flock.inclusiveVersion(); // { a: { ... }, b: { ... } }
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
#### `merge(other: Flock): void`
|
|
552
|
+
|
|
553
|
+
Merges all state from another Flock instance into this one.
|
|
554
|
+
|
|
555
|
+
- **`other`**: The source Flock instance.
|
|
556
|
+
- **Effect**: Applies all entries from `other` into `this` using LWW conflict resolution. After merge, `this` contains the union of both states.
|
|
557
|
+
- **Idempotent**: Calling `merge(other)` multiple times produces the same result as calling it once.
|
|
558
|
+
- **Events**: Emits an event batch for imported entries.
|
|
559
|
+
- **Note**: This is a one-directional merge. To fully sync two replicas, call `a.merge(b)` and `b.merge(a)`.
|
|
560
|
+
|
|
561
|
+
#### `exportJson(): ExportBundle`
|
|
562
|
+
|
|
563
|
+
Exports the full state as a JSON bundle.
|
|
564
|
+
|
|
565
|
+
- **Returns**: `ExportBundle` containing all entries (including tombstones), keyed by stringified key arrays.
|
|
566
|
+
- **Format**: `{ version: number, entries: { [key: string]: ExportRecord } }`.
|
|
567
|
+
- **Use case**: Full snapshot for backup, initial sync, or cold storage.
|
|
568
|
+
|
|
569
|
+
#### `exportJson(from: VersionVector): ExportBundle`
|
|
570
|
+
|
|
571
|
+
Exports only entries newer than the given version vector (incremental/delta export).
|
|
572
|
+
|
|
573
|
+
- **`from`**: The remote peer's current version vector (obtained via `version()`).
|
|
574
|
+
- **Returns**: Bundle containing only entries the remote peer hasn't seen.
|
|
575
|
+
- **Use case**: Bandwidth-efficient incremental sync. Send your `version()` to the remote, get back only what's new.
|
|
576
|
+
|
|
577
|
+
```ts
|
|
578
|
+
// Incremental sync pattern
|
|
579
|
+
const remoteVersion = remotePeer.version();
|
|
580
|
+
const delta = localPeer.exportJson(remoteVersion);
|
|
581
|
+
remotePeer.importJson(delta);
|
|
582
|
+
```
|
|
583
|
+
|
|
584
|
+
#### `exportJson(from: VersionVector, pruneTombstonesBefore: number): ExportBundle`
|
|
585
|
+
|
|
586
|
+
Exports with tombstone pruning.
|
|
587
|
+
|
|
588
|
+
- **`pruneTombstonesBefore`**: Millisecond timestamp. Tombstones with `physicalTime` older than this value are excluded from the export.
|
|
589
|
+
- **Use case**: Reduce export size by omitting old tombstones that all replicas have already seen.
|
|
590
|
+
- **Caution**: If a replica imports a pruned export, it may not know about deletions that happened before the prune threshold. Only use when you're confident all replicas have already processed those deletions.
|
|
591
|
+
|
|
592
|
+
#### `exportJson(options: ExportOptions): Promise<ExportBundle>`
|
|
593
|
+
|
|
594
|
+
Exports with hooks and/or additional options (async).
|
|
595
|
+
|
|
596
|
+
- **`options.from`** (optional): Version vector for incremental export.
|
|
597
|
+
- **`options.pruneTombstonesBefore`** (optional): Tombstone prune timestamp.
|
|
598
|
+
- **`options.peerId`** (optional): Export only entries written by this peer.
|
|
599
|
+
- **`options.hooks.transform`** (optional): Async function to transform each entry during export.
|
|
600
|
+
- **Returns**: `Promise<ExportBundle>`.
|
|
601
|
+
- **Use case**: Sign entries, redact sensitive data, or filter entries during export.
|
|
602
|
+
|
|
603
|
+
```ts
|
|
604
|
+
const signed = await flock.exportJson({
|
|
605
|
+
hooks: {
|
|
606
|
+
transform: async (ctx, payload) => {
|
|
607
|
+
return {
|
|
608
|
+
...payload,
|
|
609
|
+
metadata: { ...payload.metadata, sig: await sign(payload.data) },
|
|
610
|
+
};
|
|
611
|
+
},
|
|
612
|
+
},
|
|
613
|
+
});
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
#### `importJson(bundle: ExportBundle): ImportReport`
|
|
617
|
+
|
|
618
|
+
Imports a JSON bundle, merging entries using LWW semantics.
|
|
619
|
+
|
|
620
|
+
- **`bundle`**: An `ExportBundle` from `exportJson()`.
|
|
621
|
+
- **Returns**: `ImportReport` with `{ accepted: number, skipped: Array<{ key, reason }> }`.
|
|
622
|
+
- **`accepted` counts records processed**, not records that changed state. Re-importing the same bundle may still report a positive `accepted` count even though no state changed and no events are emitted. Do not use `accepted` as a reliable indicator of whether new data was applied — use event listeners for that.
|
|
623
|
+
- **Events**: Emits event batches for newly accepted entries.
|
|
624
|
+
- **Effect on debounce**: If `autoDebounceCommit` is active, any pending local events are flushed before the import events are delivered.
|
|
625
|
+
|
|
626
|
+
#### `importJson(options: ImportOptions): Promise<ImportReport>`
|
|
627
|
+
|
|
628
|
+
Imports with preprocessing hooks (async).
|
|
629
|
+
|
|
630
|
+
- **`options.bundle`**: The export bundle.
|
|
631
|
+
- **`options.hooks.preprocess`**: Async function `(context, payload) => ImportDecision` to validate each entry before import.
|
|
632
|
+
- **Returns**: `Promise<ImportReport>`.
|
|
633
|
+
- **Use case**: Validate signatures, check authorization, or reject untrusted entries.
|
|
634
|
+
|
|
635
|
+
```ts
|
|
636
|
+
const report = await flock.importJson({
|
|
637
|
+
bundle,
|
|
638
|
+
hooks: {
|
|
639
|
+
preprocess: async (ctx, payload) => {
|
|
640
|
+
if (!await verify(payload.metadata?.sig, payload.data)) {
|
|
641
|
+
return { accept: false, reason: "invalid signature" };
|
|
642
|
+
}
|
|
643
|
+
return { accept: true };
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
console.log(report.skipped); // entries rejected by the hook
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
#### `importJsonStr(bundle: string): ImportReport`
|
|
651
|
+
|
|
652
|
+
Convenience method that parses a JSON string and imports it.
|
|
653
|
+
|
|
654
|
+
- **`bundle`**: JSON string representation of an `ExportBundle`.
|
|
655
|
+
- **Returns**: `ImportReport`.
|
|
656
|
+
- **Use case**: Import directly from a network response or file read without manual parsing.
|
|
657
|
+
|
|
658
|
+
#### `kvToJson(): ExportBundle`
|
|
659
|
+
|
|
660
|
+
Alias for `exportJson()` with no arguments. Returns the full state as a JSON bundle.
|
|
661
|
+
|
|
662
|
+
---
|
|
663
|
+
|
|
664
|
+
### Hooks API
|
|
665
|
+
|
|
666
|
+
Hooks provide extension points for signing, validating, transforming, or filtering data during put, export, and import operations.
|
|
667
|
+
|
|
668
|
+
#### Put Hooks (`PutWithMetaOptions`)
|
|
669
|
+
|
|
670
|
+
```ts
|
|
671
|
+
type PutWithMetaOptions = {
|
|
672
|
+
metadata?: MetadataMap;
|
|
673
|
+
now?: number;
|
|
674
|
+
hooks?: {
|
|
675
|
+
transform?: (context: PutHookContext, payload: PutPayload) => MaybePromise<PutPayload | void>;
|
|
676
|
+
};
|
|
677
|
+
};
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
- **`context`**: `{ key: KeyPart[], now?: number }`.
|
|
681
|
+
- **`payload`**: `{ data?: Value, metadata?: MetadataMap }` — a working copy.
|
|
682
|
+
- **Behavior**: The hook receives a cloned payload. It can mutate it in-place or return a new payload. If it returns `void`, the (possibly mutated) working copy is used.
|
|
683
|
+
- **Constraint**: The final payload must have a `data` field; omitting it throws `TypeError`.
|
|
684
|
+
|
|
685
|
+
#### Export Hooks (`ExportOptions`)
|
|
686
|
+
|
|
687
|
+
```ts
|
|
688
|
+
type ExportOptions = {
|
|
689
|
+
from?: VersionVector;
|
|
690
|
+
pruneTombstonesBefore?: number;
|
|
691
|
+
peerId?: string;
|
|
692
|
+
hooks?: {
|
|
693
|
+
transform?: (context: ExportHookContext, payload: ExportPayload) => MaybePromise<ExportPayload | void>;
|
|
694
|
+
};
|
|
695
|
+
};
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
- **`context`**: `{ key: KeyPart[], clock: EntryClock, raw: ExportRecord }`.
|
|
699
|
+
- **`payload`**: `{ data?: Value, metadata?: MetadataMap }` — a working copy.
|
|
700
|
+
- **Behavior**: Called once per entry in the export. Does not modify stored state; only affects the exported bundle.
|
|
701
|
+
- **Use case**: Encrypt data, redact PII, or append signatures before sharing.
|
|
702
|
+
|
|
703
|
+
#### Import Hooks (`ImportOptions`)
|
|
704
|
+
|
|
705
|
+
```ts
|
|
706
|
+
type ImportOptions = {
|
|
707
|
+
bundle: ExportBundle;
|
|
708
|
+
hooks?: {
|
|
709
|
+
preprocess?: (context: ImportHookContext, payload: ImportPayload) => MaybePromise<ImportDecision>;
|
|
710
|
+
};
|
|
711
|
+
};
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
- **`context`**: `{ key: KeyPart[], clock: EntryClock, raw: ExportRecord }`.
|
|
715
|
+
- **`payload`**: `{ data?: Value, metadata?: MetadataMap }` — a cloned copy for inspection (not mutation).
|
|
716
|
+
- **`ImportDecision`**: Return `{ accept: true }`, `{ accept: false, reason: string }`, or `void` (treated as accept).
|
|
717
|
+
- **Behavior**: Called once per entry before import. Rejected entries are excluded from the merge and reported in `ImportReport.skipped`.
|
|
718
|
+
- **Use case**: Validate signatures, enforce ACLs, or reject entries from untrusted peers.
|
|
719
|
+
|
|
720
|
+
---
|
|
721
|
+
|
|
722
|
+
### Events and Subscriptions
|
|
723
|
+
|
|
724
|
+
#### `subscribe(listener: (batch: EventBatch) => void): () => void`
|
|
725
|
+
|
|
726
|
+
Subscribes to change events.
|
|
727
|
+
|
|
728
|
+
- **`listener`**: Callback receiving `EventBatch` objects.
|
|
729
|
+
- **Returns**: An unsubscribe function. Call it to stop receiving events.
|
|
730
|
+
- **Event delivery**: Events are delivered synchronously after each state-changing operation (unless debounce or transactions are active).
|
|
731
|
+
- **Re-entrancy safe**: You can call `put`, `delete`, `get`, `merge`, or `importJson` from within a listener. New events from re-entrant operations are queued and delivered after the current batch completes.
|
|
732
|
+
- **Error isolation**: If a listener throws, other listeners still receive the event. The error does not propagate to the caller of the operation that triggered the event.
|
|
733
|
+
- **Automatic cleanup**: When all listeners are removed, the native WASM subscription is cleaned up.
|
|
734
|
+
|
|
735
|
+
**EventBatch structure**:
|
|
736
|
+
|
|
737
|
+
```ts
|
|
738
|
+
{
|
|
739
|
+
source: string; // "local" for put/delete/set, or peer identifier for import/merge
|
|
740
|
+
events: Event[]; // Array of individual change events
|
|
741
|
+
}
|
|
742
|
+
```
|
|
743
|
+
|
|
744
|
+
**Event structure**:
|
|
745
|
+
|
|
746
|
+
```ts
|
|
747
|
+
{
|
|
748
|
+
key: KeyPart[]; // The affected key
|
|
749
|
+
value?: Value; // New value; undefined for deletions
|
|
750
|
+
metadata?: MetadataMap; // Associated metadata
|
|
751
|
+
payload: EventPayload; // { data?: Value, metadata?: MetadataMap }
|
|
752
|
+
}
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
```ts
|
|
756
|
+
const unsub = flock.subscribe((batch) => {
|
|
757
|
+
for (const event of batch.events) {
|
|
758
|
+
console.log(`${batch.source}: ${event.key} → ${event.value}`);
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
flock.put(["x"], 1); // listener fires: source="local", key=["x"], value=1
|
|
763
|
+
unsub(); // stop listening
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
---
|
|
767
|
+
|
|
768
|
+
### Debounced Event Batching
|
|
769
|
+
|
|
770
|
+
Debouncing accumulates local events and delivers them as a single batch after a period of inactivity, reducing event noise during rapid-fire edits.
|
|
771
|
+
|
|
772
|
+
#### `autoDebounceCommit(timeout: number, options?: { maxDebounceTime?: number }): void`
|
|
773
|
+
|
|
774
|
+
Enables debounced event batching.
|
|
775
|
+
|
|
776
|
+
- **`timeout`**: Debounce window in milliseconds. Events are held until `timeout` ms of inactivity pass.
|
|
777
|
+
- **`options.maxDebounceTime`** (default: `10000`): Maximum time in ms that events can be held, even if operations keep coming. This prevents unbounded delays during sustained writes.
|
|
778
|
+
- **Effect**: Local `put`/`delete`/`set` events are buffered instead of immediately delivered. Import and merge events are NOT debounced — they flush pending local events first, then deliver immediately.
|
|
779
|
+
- **Throws**: If a transaction is currently active, or if debounce is already enabled.
|
|
780
|
+
|
|
781
|
+
```ts
|
|
782
|
+
flock.autoDebounceCommit(100, { maxDebounceTime: 5000 });
|
|
783
|
+
flock.put(["a"], 1); // buffered
|
|
784
|
+
flock.put(["b"], 2); // buffered, timer reset
|
|
785
|
+
// After 100ms of inactivity → single batch with both events
|
|
786
|
+
// OR after 5000ms even if operations keep coming
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
#### `commit(): void`
|
|
790
|
+
|
|
791
|
+
Forces immediate emission of any pending debounced events.
|
|
792
|
+
|
|
793
|
+
- **Effect**: Flushes the debounce buffer and delivers all pending events as a batch.
|
|
794
|
+
- **Does NOT disable** debounce mode — subsequent operations continue to be debounced.
|
|
795
|
+
- **No-op**: If debounce is not active or no events are pending.
|
|
796
|
+
|
|
797
|
+
#### `disableAutoDebounceCommit(): void`
|
|
798
|
+
|
|
799
|
+
Disables debounce mode and flushes any pending events.
|
|
800
|
+
|
|
801
|
+
- **Effect**: Pending events are delivered immediately. Future operations emit events synchronously.
|
|
802
|
+
- **No-op**: If debounce is not active.
|
|
803
|
+
|
|
804
|
+
#### `isAutoDebounceActive(): boolean`
|
|
805
|
+
|
|
806
|
+
Returns whether debounce mode is currently enabled.
|
|
807
|
+
|
|
808
|
+
---
|
|
809
|
+
|
|
810
|
+
### Transactions
|
|
811
|
+
|
|
812
|
+
Transactions batch multiple operations into a single atomic event delivery. All put/delete operations inside a transaction are delivered as one `EventBatch` on commit.
|
|
813
|
+
|
|
814
|
+
#### `txn<T>(callback: () => T): T`
|
|
815
|
+
|
|
816
|
+
Executes a synchronous callback within a transaction.
|
|
817
|
+
|
|
818
|
+
- **`callback`**: Synchronous function containing put/delete operations.
|
|
819
|
+
- **Returns**: The return value of the callback.
|
|
820
|
+
- **Event batching**: All events from operations inside the callback are collected and delivered as a single `EventBatch` when the transaction commits.
|
|
821
|
+
- **Error handling**: If the callback throws, the transaction is rolled back (event emission is canceled) and the error re-thrown. **Important caveat**: Data changes (puts/deletes) may NOT be rolled back — only event emission is affected. The underlying writes may have already been applied to the store.
|
|
822
|
+
- **Throws**: `Error` if called while `autoDebounceCommit` is active (mutual exclusion).
|
|
823
|
+
- **Throws**: `Error` if nested transactions are attempted.
|
|
824
|
+
- **Constraint**: The callback must be synchronous. Async operations inside `txn()` are not supported.
|
|
825
|
+
|
|
826
|
+
```ts
|
|
827
|
+
flock.subscribe((batch) => {
|
|
828
|
+
console.log(`Received ${batch.events.length} events`);
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
flock.txn(() => {
|
|
832
|
+
flock.put(["a"], 1);
|
|
833
|
+
flock.put(["b"], 2);
|
|
834
|
+
flock.put(["c"], 3);
|
|
835
|
+
});
|
|
836
|
+
// Listener receives: "Received 3 events" — single batch
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
#### `isInTxn(): boolean`
|
|
840
|
+
|
|
841
|
+
Returns whether a transaction is currently active.
|
|
842
|
+
|
|
843
|
+
---
|
|
844
|
+
|
|
845
|
+
### Binary File Export/Import
|
|
846
|
+
|
|
847
|
+
The binary file format is Flock's most efficient serialization. Unlike JSON export (which requires parsing every entry and re-inserting them one by one into a B+Tree), the binary format stores pre-built B+Tree pages directly. This makes **loading from binary dramatically faster** — especially for large datasets.
|
|
848
|
+
|
|
849
|
+
| Operation | `fromJson` (JSON bundle) | `fromFile` (binary) |
|
|
850
|
+
|-----------|--------------------------|---------------------|
|
|
851
|
+
| Open cost | O(n log n) — parse JSON, encode each key, insert into B+Tree one by one | O(1) — read file header, done |
|
|
852
|
+
| Size | Larger (JSON text, repeated key strings, base-10 numbers) | Smaller (binary-encoded keys, prefix-compressed leaves, compact integers) |
|
|
853
|
+
| Use case | Network sync, incremental delta exchange | Persistence, checkpoints, full-state transfer |
|
|
854
|
+
|
|
855
|
+
**Rule of thumb**: Use `exportJson`/`importJson` for replication and sync. Use `exportFile`/`fromFile` for persistence and fast cold-start loading.
|
|
856
|
+
|
|
857
|
+
#### `exportFile(): Uint8Array`
|
|
858
|
+
|
|
859
|
+
Exports the entire Flock state as a binary file.
|
|
860
|
+
|
|
861
|
+
- **Returns**: `Uint8Array` containing the complete state in Flock's B+Tree page format.
|
|
862
|
+
- **Format**: Binary format with `FLOK` magic header, B+Tree pages with prefix-compressed keys, and optional WAL data. Significantly more compact than JSON export.
|
|
863
|
+
- **Performance**: The exported bytes contain ready-to-use B+Tree pages. When loaded via `Flock.fromFile()`, the pages are used directly without any re-indexing or re-encoding — opening is O(1) regardless of data size.
|
|
864
|
+
- **Use case**: Persist state to disk, transfer as a file, create compact backups, or achieve the fastest possible cold-start.
|
|
865
|
+
- **Side effect**: Flushes any pending memtable writes before exporting.
|
|
866
|
+
|
|
867
|
+
```ts
|
|
868
|
+
const bytes = flock.exportFile();
|
|
869
|
+
fs.writeFileSync("state.flock", bytes);
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
Restoration is done via `Flock.fromFile()`:
|
|
873
|
+
|
|
874
|
+
```ts
|
|
875
|
+
const bytes = fs.readFileSync("state.flock");
|
|
876
|
+
const flock = Flock.fromFile(new Uint8Array(bytes), "my-peer");
|
|
877
|
+
// Ready instantly — no re-parsing or re-indexing needed
|
|
878
|
+
```
|
|
879
|
+
|
|
880
|
+
---
|
|
881
|
+
|
|
882
|
+
## Type Reference
|
|
883
|
+
|
|
884
|
+
### Version Vector Types
|
|
885
|
+
|
|
886
|
+
```ts
|
|
887
|
+
type VersionVectorEntry = {
|
|
888
|
+
physicalTime: number; // Wall-clock timestamp in milliseconds
|
|
889
|
+
logicalCounter: number; // Monotonic tie-breaking counter
|
|
890
|
+
};
|
|
891
|
+
|
|
892
|
+
interface VersionVector {
|
|
893
|
+
[peer: string]: VersionVectorEntry | undefined;
|
|
894
|
+
}
|
|
895
|
+
```
|
|
896
|
+
|
|
897
|
+
### Data Types
|
|
898
|
+
|
|
899
|
+
```ts
|
|
900
|
+
// Any JSON-serializable value
|
|
901
|
+
type Value = string | number | boolean | null | Array<Value> | { [key: string]: Value };
|
|
902
|
+
|
|
903
|
+
// A single part of a compound key
|
|
904
|
+
type KeyPart = Value;
|
|
905
|
+
|
|
906
|
+
// Arbitrary metadata stored alongside an entry
|
|
907
|
+
type MetadataMap = Record<string, unknown>;
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
### Entry Types
|
|
911
|
+
|
|
912
|
+
```ts
|
|
913
|
+
type EntryClock = {
|
|
914
|
+
physicalTime: number; // When the entry was written
|
|
915
|
+
logicalCounter: number; // Tie-breaking counter
|
|
916
|
+
peerId: string; // Who wrote the entry
|
|
917
|
+
};
|
|
918
|
+
|
|
919
|
+
type EntryInfo = {
|
|
920
|
+
data?: Value; // Omitted for tombstones
|
|
921
|
+
metadata: MetadataMap; // Always present (defaults to {})
|
|
922
|
+
clock: EntryClock; // Clock of the winning write
|
|
923
|
+
};
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
### Export/Import Types
|
|
927
|
+
|
|
928
|
+
```ts
|
|
929
|
+
type ExportRecord = {
|
|
930
|
+
c: string; // Clock string: "physicalTime,logicalCounter,peerId"
|
|
931
|
+
d?: Value; // Data value (omitted for tombstones)
|
|
932
|
+
m?: MetadataMap; // Metadata (omitted if empty)
|
|
933
|
+
};
|
|
934
|
+
|
|
935
|
+
type ExportBundle = {
|
|
936
|
+
version: number; // Format version number
|
|
937
|
+
entries: Record<string, ExportRecord>; // Key (stringified) → record
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
type ImportReport = {
|
|
941
|
+
accepted: number; // Number of entries processed (not necessarily changed)
|
|
942
|
+
skipped: Array<{ key: KeyPart[]; reason: string }>; // Rejected entries with reasons
|
|
943
|
+
};
|
|
944
|
+
```
|
|
945
|
+
|
|
946
|
+
### Hook Types
|
|
947
|
+
|
|
948
|
+
```ts
|
|
949
|
+
type ExportPayload = { data?: Value; metadata?: MetadataMap };
|
|
950
|
+
type ExportHookContext = { key: KeyPart[]; clock: EntryClock; raw: ExportRecord };
|
|
951
|
+
type ExportHooks = {
|
|
952
|
+
transform?: (context: ExportHookContext, payload: ExportPayload) => MaybePromise<ExportPayload | void>;
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
type ImportPayload = ExportPayload;
|
|
956
|
+
type ImportHookContext = ExportHookContext;
|
|
957
|
+
type ImportAccept = { accept: true };
|
|
958
|
+
type ImportSkip = { accept: false; reason: string };
|
|
959
|
+
type ImportDecision = ImportAccept | ImportSkip | ImportPayload | void;
|
|
960
|
+
type ImportHooks = {
|
|
961
|
+
preprocess?: (context: ImportHookContext, payload: ImportPayload) => MaybePromise<ImportDecision>;
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
type PutPayload = ExportPayload;
|
|
965
|
+
type PutHookContext = { key: KeyPart[]; now?: number };
|
|
966
|
+
type PutHooks = {
|
|
967
|
+
transform?: (context: PutHookContext, payload: PutPayload) => MaybePromise<PutPayload | void>;
|
|
968
|
+
};
|
|
969
|
+
|
|
970
|
+
type PutWithMetaOptions = {
|
|
971
|
+
metadata?: MetadataMap;
|
|
972
|
+
now?: number;
|
|
973
|
+
hooks?: PutHooks;
|
|
974
|
+
};
|
|
975
|
+
```
|
|
976
|
+
|
|
977
|
+
### Scan Types
|
|
978
|
+
|
|
979
|
+
```ts
|
|
980
|
+
type ScanBound =
|
|
981
|
+
| { kind: "inclusive"; key: KeyPart[] }
|
|
982
|
+
| { kind: "exclusive"; key: KeyPart[] }
|
|
983
|
+
| { kind: "unbounded" };
|
|
984
|
+
|
|
985
|
+
type ScanOptions = {
|
|
986
|
+
start?: ScanBound;
|
|
987
|
+
end?: ScanBound;
|
|
988
|
+
prefix?: KeyPart[];
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
type ScanRow = {
|
|
992
|
+
key: KeyPart[];
|
|
993
|
+
raw: ExportRecord;
|
|
994
|
+
value?: Value;
|
|
995
|
+
};
|
|
996
|
+
```
|
|
997
|
+
|
|
998
|
+
### Event Types
|
|
999
|
+
|
|
1000
|
+
```ts
|
|
1001
|
+
type EventPayload = { data?: Value; metadata?: MetadataMap };
|
|
1002
|
+
|
|
1003
|
+
type Event = {
|
|
1004
|
+
key: KeyPart[];
|
|
1005
|
+
value?: Value;
|
|
1006
|
+
metadata?: MetadataMap;
|
|
1007
|
+
payload: EventPayload;
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
type EventBatch = {
|
|
1011
|
+
source: string; // "local" or peer identifier
|
|
1012
|
+
events: Event[];
|
|
1013
|
+
};
|
|
1014
|
+
```
|
|
1015
|
+
|
|
1016
|
+
---
|
|
1017
|
+
|
|
1018
|
+
## Conflict Resolution Deep Dive
|
|
1019
|
+
|
|
1020
|
+
### How LWW Works in Practice
|
|
1021
|
+
|
|
1022
|
+
```ts
|
|
1023
|
+
// Both peers start with the same state
|
|
1024
|
+
const a = new Flock("peer-a");
|
|
1025
|
+
const b = new Flock("peer-b");
|
|
1026
|
+
|
|
1027
|
+
// Concurrent writes at the same physical time
|
|
1028
|
+
a.put(["key"], "alice-value", 1000);
|
|
1029
|
+
b.put(["key"], "bob-value", 1000);
|
|
1030
|
+
|
|
1031
|
+
// After merge, both converge
|
|
1032
|
+
a.merge(b);
|
|
1033
|
+
b.merge(a);
|
|
1034
|
+
|
|
1035
|
+
// Who wins? Clock comparison:
|
|
1036
|
+
// 1. physicalTime: both 1000 → tie
|
|
1037
|
+
// 2. logicalCounter: both 0 → tie
|
|
1038
|
+
// 3. peerId: "peer-b" > "peer-a" lexicographically → peer-b wins
|
|
1039
|
+
console.log(a.get(["key"])); // "bob-value"
|
|
1040
|
+
console.log(b.get(["key"])); // "bob-value"
|
|
1041
|
+
```
|
|
1042
|
+
|
|
1043
|
+
### Delete vs. Write Conflicts
|
|
1044
|
+
|
|
1045
|
+
```ts
|
|
1046
|
+
// A writes, B deletes at a later time
|
|
1047
|
+
a.put(["key"], "value", 1000);
|
|
1048
|
+
b.delete(["key"], 2000);
|
|
1049
|
+
|
|
1050
|
+
a.merge(b);
|
|
1051
|
+
// Delete wins because physicalTime 2000 > 1000
|
|
1052
|
+
a.get(["key"]); // undefined
|
|
1053
|
+
|
|
1054
|
+
// A writes again at an even later time
|
|
1055
|
+
a.put(["key"], "new-value", 3000);
|
|
1056
|
+
a.get(["key"]); // "new-value" — write wins because 3000 > 2000
|
|
1057
|
+
```
|
|
1058
|
+
|
|
1059
|
+
### Version Vector After Overwrites
|
|
1060
|
+
|
|
1061
|
+
When a peer's entries are fully overwritten by another peer, the exclusive version vector drops the original peer:
|
|
1062
|
+
|
|
1063
|
+
```ts
|
|
1064
|
+
const f = new Flock("a");
|
|
1065
|
+
f.put(["k1"], "v1"); // version: { a: {...} }
|
|
1066
|
+
f.setPeerId("b");
|
|
1067
|
+
f.put(["k1"], "v2"); // version: { b: {...} } — a is dropped
|
|
1068
|
+
|
|
1069
|
+
// But inclusive version retains both:
|
|
1070
|
+
f.inclusiveVersion(); // { a: {...}, b: {...} }
|
|
1071
|
+
```
|
|
1072
|
+
|
|
1073
|
+
---
|
|
1074
|
+
|
|
1075
|
+
## Version Vectors Explained
|
|
1076
|
+
|
|
1077
|
+
### Incremental Sync Protocol
|
|
1078
|
+
|
|
1079
|
+
```ts
|
|
1080
|
+
// Step 1: Bob sends his version to Alice
|
|
1081
|
+
const bobVersion = bob.version();
|
|
1082
|
+
|
|
1083
|
+
// Step 2: Alice exports only what Bob hasn't seen
|
|
1084
|
+
const delta = alice.exportJson(bobVersion);
|
|
1085
|
+
|
|
1086
|
+
// Step 3: Bob imports the delta
|
|
1087
|
+
const report = bob.importJson(delta);
|
|
1088
|
+
console.log(`Imported ${report.accepted} new entries`);
|
|
1089
|
+
|
|
1090
|
+
// Step 4: Reverse direction
|
|
1091
|
+
const aliceVersion = alice.version();
|
|
1092
|
+
const reverseDelta = bob.exportJson(aliceVersion);
|
|
1093
|
+
alice.importJson(reverseDelta);
|
|
1094
|
+
|
|
1095
|
+
// Both are now fully synced
|
|
1096
|
+
```
|
|
1097
|
+
|
|
1098
|
+
### When to Use Which Version
|
|
1099
|
+
|
|
1100
|
+
| Method | Includes overwritten peers | Use for |
|
|
1101
|
+
|---------------------|---------------------------|----------------------------------|
|
|
1102
|
+
| `version()` | No | Incremental sync, delta export |
|
|
1103
|
+
| `inclusiveVersion()`| Yes | Completeness checks, debugging |
|
|
1104
|
+
|
|
1105
|
+
---
|
|
1106
|
+
|
|
1107
|
+
## Best Practices
|
|
1108
|
+
|
|
1109
|
+
### 1. Use Explicit Timestamps for Deterministic Testing
|
|
1110
|
+
|
|
1111
|
+
```ts
|
|
1112
|
+
// In tests, always provide explicit timestamps for reproducible results
|
|
1113
|
+
flock.put(["k"], "v", 1000);
|
|
1114
|
+
|
|
1115
|
+
// In production, omit the timestamp to use Date.now()
|
|
1116
|
+
flock.put(["k"], "v");
|
|
1117
|
+
```
|
|
1118
|
+
|
|
1119
|
+
### 2. Use Transactions for Batch Updates
|
|
1120
|
+
|
|
1121
|
+
```ts
|
|
1122
|
+
// Bad: subscribers receive 100 separate event batches
|
|
1123
|
+
for (let i = 0; i < 100; i++) {
|
|
1124
|
+
flock.put(["item", i], data[i]);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// Good: subscribers receive one batch with 100 events
|
|
1128
|
+
flock.txn(() => {
|
|
1129
|
+
for (let i = 0; i < 100; i++) {
|
|
1130
|
+
flock.put(["item", i], data[i]);
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
1133
|
+
```
|
|
1134
|
+
|
|
1135
|
+
### 3. Use Debounce for Interactive Editing
|
|
1136
|
+
|
|
1137
|
+
```ts
|
|
1138
|
+
// Accumulate rapid keystrokes into periodic batches
|
|
1139
|
+
flock.autoDebounceCommit(200, { maxDebounceTime: 2000 });
|
|
1140
|
+
|
|
1141
|
+
// On each keystroke
|
|
1142
|
+
flock.put(["doc", "content"], currentContent);
|
|
1143
|
+
|
|
1144
|
+
// Listeners receive updates at most every 200ms,
|
|
1145
|
+
// or at least every 2000ms during sustained typing
|
|
1146
|
+
```
|
|
1147
|
+
|
|
1148
|
+
### 4. Prune Tombstones Periodically
|
|
1149
|
+
|
|
1150
|
+
```ts
|
|
1151
|
+
// When syncing, prune tombstones older than 7 days
|
|
1152
|
+
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
|
|
1153
|
+
const bundle = flock.exportJson(
|
|
1154
|
+
remoteVersion,
|
|
1155
|
+
sevenDaysAgo,
|
|
1156
|
+
);
|
|
1157
|
+
|
|
1158
|
+
// Only safe when you know all replicas have synced within 7 days
|
|
1159
|
+
```
|
|
1160
|
+
|
|
1161
|
+
### 5. Use Metadata for Audit Trails
|
|
1162
|
+
|
|
1163
|
+
```ts
|
|
1164
|
+
flock.putWithMeta(["transaction", txId], amount, {
|
|
1165
|
+
metadata: {
|
|
1166
|
+
author: currentUser,
|
|
1167
|
+
sig: await sign(amount),
|
|
1168
|
+
timestamp: Date.now(),
|
|
1169
|
+
},
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
// Verify on import
|
|
1173
|
+
const report = await flock.importJson({
|
|
1174
|
+
bundle,
|
|
1175
|
+
hooks: {
|
|
1176
|
+
preprocess: async (ctx, payload) => {
|
|
1177
|
+
if (!await verify(payload.metadata?.sig, payload.data)) {
|
|
1178
|
+
return { accept: false, reason: "signature verification failed" };
|
|
1179
|
+
}
|
|
1180
|
+
},
|
|
1181
|
+
},
|
|
1182
|
+
});
|
|
1183
|
+
```
|
|
1184
|
+
|
|
1185
|
+
### 6. Use Incremental Sync for Bandwidth Efficiency
|
|
1186
|
+
|
|
1187
|
+
```ts
|
|
1188
|
+
// Don't do this (sends everything every time):
|
|
1189
|
+
const full = flock.exportJson();
|
|
1190
|
+
remotePeer.importJson(full);
|
|
1191
|
+
|
|
1192
|
+
// Do this instead:
|
|
1193
|
+
const delta = flock.exportJson(remotePeer.version());
|
|
1194
|
+
remotePeer.importJson(delta);
|
|
1195
|
+
```
|
|
1196
|
+
|
|
1197
|
+
### 7. Design Keys for Scan Efficiency
|
|
1198
|
+
|
|
1199
|
+
```ts
|
|
1200
|
+
// Good: hierarchical keys enable prefix scans
|
|
1201
|
+
flock.put(["users", userId, "name"], "Alice");
|
|
1202
|
+
flock.put(["users", userId, "email"], "alice@example.com");
|
|
1203
|
+
const userFields = flock.scan({ prefix: ["users", userId] });
|
|
1204
|
+
|
|
1205
|
+
// Bad: flat keys require full scans
|
|
1206
|
+
flock.put(["user_name_" + userId], "Alice");
|
|
1207
|
+
flock.put(["user_email_" + userId], "alice@example.com");
|
|
1208
|
+
```
|
|
1209
|
+
|
|
1210
|
+
---
|
|
1211
|
+
|
|
1212
|
+
## Common Pitfalls
|
|
1213
|
+
|
|
1214
|
+
### 1. Debounce and Transactions Are Mutually Exclusive
|
|
1215
|
+
|
|
1216
|
+
```ts
|
|
1217
|
+
flock.autoDebounceCommit(100);
|
|
1218
|
+
flock.txn(() => { /* ... */ }); // Throws Error!
|
|
1219
|
+
|
|
1220
|
+
// Disable debounce first, or don't use both at the same time
|
|
1221
|
+
```
|
|
1222
|
+
|
|
1223
|
+
### 2. Transaction Rollback Does NOT Undo Data Changes
|
|
1224
|
+
|
|
1225
|
+
```ts
|
|
1226
|
+
flock.txn(() => {
|
|
1227
|
+
flock.put(["k"], "value");
|
|
1228
|
+
throw new Error("oops");
|
|
1229
|
+
});
|
|
1230
|
+
// Error is re-thrown, events are NOT emitted
|
|
1231
|
+
// But flock.get(["k"]) may still return "value" — data persists
|
|
1232
|
+
```
|
|
1233
|
+
|
|
1234
|
+
### 3. MVR Only Accepts Scalar Values
|
|
1235
|
+
|
|
1236
|
+
```ts
|
|
1237
|
+
flock.putMvr(["k"], "string"); // OK
|
|
1238
|
+
flock.putMvr(["k"], 42); // OK
|
|
1239
|
+
flock.putMvr(["k"], true); // OK
|
|
1240
|
+
flock.putMvr(["k"], null); // Throws TypeError
|
|
1241
|
+
flock.putMvr(["k"], [1, 2]); // Throws TypeError
|
|
1242
|
+
flock.putMvr(["k"], { a: 1 }); // Throws TypeError
|
|
1243
|
+
```
|
|
1244
|
+
|
|
1245
|
+
### 4. merge() Is One-Directional
|
|
1246
|
+
|
|
1247
|
+
```ts
|
|
1248
|
+
a.merge(b); // a now has everything from b
|
|
1249
|
+
// But b does NOT have a's changes — you need:
|
|
1250
|
+
b.merge(a);
|
|
1251
|
+
```
|
|
1252
|
+
|
|
1253
|
+
### 5. Tombstones Appear in Scan Results
|
|
1254
|
+
|
|
1255
|
+
```ts
|
|
1256
|
+
flock.put(["k"], "value");
|
|
1257
|
+
flock.delete(["k"]);
|
|
1258
|
+
const rows = flock.scan();
|
|
1259
|
+
// rows includes { key: ["k"], value: undefined, raw: {...} }
|
|
1260
|
+
// Filter tombstones if you only want live data:
|
|
1261
|
+
const live = rows.filter(row => row.value !== undefined);
|
|
1262
|
+
```
|
|
1263
|
+
|
|
1264
|
+
### 6. Import Flushes Debounced Events First
|
|
1265
|
+
|
|
1266
|
+
```ts
|
|
1267
|
+
flock.autoDebounceCommit(5000);
|
|
1268
|
+
flock.put(["local"], 1); // Buffered, not emitted yet
|
|
1269
|
+
|
|
1270
|
+
flock.importJson(remoteDelta); // Forces flush of local batch FIRST,
|
|
1271
|
+
// then delivers import batch
|
|
1272
|
+
// Result: listener receives 2 batches in sequence
|
|
1273
|
+
```
|
|
1274
|
+
|
|
1275
|
+
### 7. Peer ID Changes Don't Affect Past Entries
|
|
1276
|
+
|
|
1277
|
+
```ts
|
|
1278
|
+
flock.setPeerId("a");
|
|
1279
|
+
flock.put(["k"], "by-a");
|
|
1280
|
+
flock.setPeerId("b");
|
|
1281
|
+
// The entry at ["k"] is still attributed to peer "a"
|
|
1282
|
+
// Only new operations will use peer "b"
|
|
1283
|
+
```
|
|
1284
|
+
|
|
1285
|
+
---
|
|
11
1286
|
|
|
12
1287
|
## Build
|
|
13
1288
|
|