@datafn/client 0.0.1 → 0.0.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @datafn/client
2
2
 
3
- A comprehensive, offline-first client for DataFn with reactive signals, event bus, and synchronization support.
3
+ Offline-first, reactive client for DataFn. Provides fluent Table and KV APIs, reactive signals for UI binding, local storage with IndexedDB, bidirectional synchronization, an event bus, transactions, plugins, and multi-user data isolation.
4
4
 
5
5
  ## Installation
6
6
 
@@ -10,190 +10,835 @@ npm install @datafn/client @datafn/core
10
10
 
11
11
  ## Features
12
12
 
13
- - **Fluent Table API**: Ergonomic interface for querying and mutating specific resources.
14
- - **Reactive Signals**: Subscribable data sources that automatically update on changes (perfect for UI binding).
15
- - **Offline Storage**: Built-in support for persistent storage (IndexedDB, Memory) with hydration.
16
- - **Synchronization**: Integrated `clone`, `pull`, and `push` mechanisms that automatically update local storage.
17
- - **Event Bus**: Global event system for mutations, sync lifecycle, and error handling.
18
- - **Transaction Support**: Atomic operations across multiple resources.
19
- - **Plugins**: Extensible architecture for custom behavior.
20
- - **Type-Safe**: Built with TypeScript for full type inference.
13
+ | Feature | Description |
14
+ |---------|-------------|
15
+ | **Fluent Table API** | `client.table("resource")` scoped queries, mutations, signals |
16
+ | **Reactive Signals** | Live queries that auto-update when data changes |
17
+ | **KV Store** | Built-in key-value API with signal support (`client.kv`) |
18
+ | **Offline Storage** | IndexedDB and Memory adapters with changelog-based offline mutations |
19
+ | **Synchronization** | Clone, pull, push, cloneUp, reconcile — with hydration plans |
20
+ | **Offline-Only Mode** | `sync.mode: "local-only"` no server required |
21
+ | **Event Bus** | Global event stream for mutations and sync lifecycle |
22
+ | **Transactions** | Atomic multi-step operations across resources |
23
+ | **Plugin System** | Intercept queries, mutations, and sync with custom logic |
24
+ | **Date Codec** | Automatic serialization/parsing of Date fields |
25
+ | **Multi-User Isolation** | Per-user IndexedDB databases via `authContext` + storage factory |
26
+ | **Extension Adapter** | Browser-extension support via `remoteAdapter` with remote subscriptions |
27
+ | **Type-Safe** | Full TypeScript inference from your schema |
28
+
29
+ ---
21
30
 
22
31
  ## Quick Start
23
32
 
24
33
  ```typescript
25
34
  import { createDatafnClient, IndexedDbStorageAdapter } from "@datafn/client";
35
+ import type { DatafnSchema } from "@datafn/core";
36
+
37
+ const schema: DatafnSchema = {
38
+ resources: [
39
+ {
40
+ name: "tasks",
41
+ version: 1,
42
+ fields: [
43
+ { name: "id", type: "string", required: true, unique: true },
44
+ { name: "title", type: "string", required: true },
45
+ { name: "completed", type: "boolean", required: true, default: false },
46
+ ],
47
+ },
48
+ ],
49
+ };
26
50
 
27
- // 1. Configure the client
28
51
  const client = createDatafnClient({
29
- clientId: "device-uuid-123", // Required for offline/sync
30
- schema: mySchema, // Your DataFn schema definition
31
- storage: new IndexedDbStorageAdapter("my-app-db"), // Persist data locally
32
- remote: {
33
- // Your network layer (fetch, axios, etc.) to talk to DataFn server
34
- async query(q) { /* ... */ },
35
- async mutation(m) { /* ... */ },
36
- async pull(p) { /* ... */ },
37
- // ... other methods
52
+ schema,
53
+ clientId: "device-" + crypto.randomUUID(),
54
+ storage: new IndexedDbStorageAdapter("my-app-db"),
55
+ sync: {
56
+ offlinability: true,
57
+ remote: "http://localhost:3000/datafn",
38
58
  },
39
59
  });
40
60
 
41
- // 2. Work with a specific table (Resource)
42
- const tasks = client.table("task");
61
+ // Start sync (clone + pull + push engine)
62
+ await client.sync.start();
43
63
 
44
- // 3. Create a reactive signal (Auto-updates when data changes)
45
- const activeTasksSignal = tasks.signal({
46
- select: ["id", "title"],
47
- filters: { completed: false }
64
+ // Insert a record
65
+ await client.table("tasks").mutate({
66
+ operation: "insert",
67
+ record: { title: "Hello DataFn", completed: false },
48
68
  });
49
69
 
50
- // Subscribe to changes
51
- const unsubscribe = activeTasksSignal.subscribe((data) => {
52
- console.log("Active tasks:", data);
70
+ // Create a reactive signal
71
+ const signal = client.table("tasks").signal({
72
+ filters: { completed: false },
73
+ sort: ["-createdAt"],
74
+ });
75
+
76
+ signal.subscribe((result) => {
77
+ console.log("Active tasks:", result.data);
53
78
  });
79
+ ```
80
+
81
+ ---
82
+
83
+ ## Client Configuration
84
+
85
+ ### createDatafnClient(config)
86
+
87
+ ```typescript
88
+ interface DatafnClientConfig<S extends DatafnSchema> {
89
+ /** Your DataFn schema definition */
90
+ schema: S;
91
+
92
+ /** Stable client/device identifier — required for offline + idempotency */
93
+ clientId: string;
94
+
95
+ /** Sync configuration (see below) */
96
+ sync?: DatafnSyncConfig;
97
+
98
+ /**
99
+ * Local persistence adapter.
100
+ * Can be a direct adapter instance or a factory function for multi-user isolation.
101
+ */
102
+ storage?: DatafnStorageAdapter | DatafnStorageFactory;
103
+
104
+ /** Auth context for multi-user/multi-tenant data isolation */
105
+ authContext?: AuthContext | AuthContextProvider;
106
+
107
+ /** Optional plugins for hook execution */
108
+ plugins?: DatafnPlugin[];
109
+
110
+ /** Custom timestamp function (for testing) */
111
+ getTimestamp?: () => number;
112
+
113
+ /**
114
+ * Custom ID generator for insert operations.
115
+ * Default: `${idPrefix || resource}:${crypto.randomUUID()}`
116
+ */
117
+ generateId?: (params: { resource: string; idPrefix?: string }) => string;
118
+ }
119
+ ```
120
+
121
+ ### DatafnSyncConfig
122
+
123
+ ```typescript
124
+ interface DatafnSyncConfig {
125
+ /**
126
+ * Explicit mode selection.
127
+ * - "sync": requires remote or remoteAdapter
128
+ * - "local-only": no server required; all tables start as "ready"
129
+ */
130
+ mode?: "sync" | "local-only";
131
+
132
+ /** Enable offline support (requires storage) */
133
+ offlinability?: boolean;
134
+
135
+ /** Remote server URL for the default HTTP transport */
136
+ remote?: string;
137
+
138
+ /** Injected remote adapter (takes precedence over remote URL) */
139
+ remoteAdapter?: DatafnRemoteAdapter;
140
+
141
+ /** Enable WebSocket for real-time server-push updates */
142
+ ws?: boolean;
143
+
144
+ /** WebSocket URL (derived from remote if omitted) */
145
+ wsUrl?: string;
146
+
147
+ /** Push engine: interval between batches (ms). Default 2000. */
148
+ pushInterval?: number;
149
+
150
+ /** Push engine: records per batch. Default 100. */
151
+ pushBatchSize?: number;
152
+
153
+ /** Push engine: max retries per mutation. Default 3. */
154
+ pushMaxRetries?: number;
155
+
156
+ /** Hydration plan for large datasets */
157
+ hydration?: {
158
+ /** Resources that MUST be cloned before the app is considered "ready" */
159
+ bootResources?: string[];
160
+ /** Resources that hydrate in the background after boot */
161
+ backgroundResources?: string[];
162
+ /** Per-resource clone page size (or a single number for all) */
163
+ clonePageSize?: number | Record<string, number>;
164
+ };
165
+ }
166
+ ```
167
+
168
+ ---
169
+
170
+ ## Table API
171
+
172
+ The `client.table(name)` method returns a scoped handle for a specific resource. You can also access tables as properties: `client.tasks` is equivalent to `client.table("tasks")`.
173
+
174
+ ```typescript
175
+ const tasks = client.table("tasks");
176
+ // or equivalently:
177
+ const tasks = client.tasks;
178
+ ```
179
+
180
+ ### table.query(fragment)
181
+
182
+ Execute a query scoped to this resource.
183
+
184
+ ```typescript
185
+ const result = await tasks.query({
186
+ select: ["id", "title", "completed"],
187
+ filters: { completed: false },
188
+ sort: ["-createdAt"],
189
+ limit: 20,
190
+ });
191
+ // result.data = [{ id: "task:...", title: "...", completed: false }, ...]
192
+ ```
193
+
194
+ **Query features:**
195
+ - `select` / `omit` — field selection
196
+ - `filters` — operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`, `is_null`, `is_not_null`, `in`, `nin`, `contains`
197
+ - Logical groups: `$and`, `$or`
198
+ - `sort` — multi-field: `["name", "-createdAt"]` (prefix `-` = descending)
199
+ - `limit` / `offset` — offset-based pagination
200
+ - `cursor` — cursor-based pagination (`{ after: {...} }`)
201
+ - `count` — return count only
202
+ - `groupBy` / `aggregations` / `having` — aggregation queries
203
+ - `search` — full-text search
204
+
205
+ ### Search (Provider-Backed, Local-First)
206
+
207
+ DataFn search is provider-backed when `searchProvider` is configured. In sync mode, search is local-first after hydration is ready.
208
+
209
+ `table.query()` search block options:
210
+
211
+ ```typescript
212
+ const result = await tasks.query({
213
+ search: {
214
+ query: "test",
215
+ prefix: true,
216
+ fuzzy: 0.2,
217
+ fieldBoosts: { title: 2, description: 1 },
218
+ },
219
+ });
220
+ ```
221
+
222
+ Cross-resource search with explicit source routing:
223
+
224
+ ```typescript
225
+ const result = await client.search({
226
+ query: "test",
227
+ resources: ["tasks", "projects"],
228
+ prefix: true,
229
+ fuzzy: 0.2,
230
+ fieldBoosts: { title: 2, name: 1 },
231
+ source: "auto", // auto | local | remote
232
+ });
233
+ ```
234
+
235
+ `source` semantics:
236
+
237
+ - `auto` (default): local-first; falls back to remote if local provider path is unavailable.
238
+ - `local`: force local provider execution.
239
+ - `remote`: force remote `/datafn/search` execution.
240
+
241
+ If the requested source is unavailable, DataFn returns `DFQL_UNSUPPORTED`.
242
+
243
+ MiniSearch-only plugin mode is still supported for compatibility, but provider-backed mode is the recommended path.
244
+
245
+ ### table.mutate(fragment)
54
246
 
55
- // 4. Mutate data (Optimistic updates & Event emission)
247
+ Execute a mutation scoped to this resource.
248
+
249
+ ```typescript
250
+ // Insert
56
251
  await tasks.mutate({
57
252
  operation: "insert",
58
- record: { title: "New Task", completed: false }
253
+ record: { title: "New task", completed: false },
254
+ });
255
+
256
+ // Merge (partial update)
257
+ await tasks.mutate({
258
+ operation: "merge",
259
+ id: "task:abc",
260
+ record: { completed: true },
261
+ });
262
+
263
+ // Replace (full update)
264
+ await tasks.mutate({
265
+ operation: "replace",
266
+ id: "task:abc",
267
+ record: { title: "Updated", completed: true },
268
+ });
269
+
270
+ // Delete
271
+ await tasks.mutate({
272
+ operation: "delete",
273
+ id: "task:abc",
274
+ });
275
+ ```
276
+
277
+ **Mutation operations:**
278
+
279
+ | Operation | Description |
280
+ |-----------|-------------|
281
+ | `insert` | Create a new record |
282
+ | `merge` | Partial update (only specified fields) |
283
+ | `replace` | Full update (replaces entire record) |
284
+ | `delete` | Delete a record |
285
+
286
+ **Relation operations** (use `client.mutate()` with full resource/version):
287
+
288
+ | Operation | Description |
289
+ |-----------|-------------|
290
+ | `relate` | Create a relation between records |
291
+ | `unrelate` | Remove a relation between records |
292
+ | `modifyRelation` | Update relation metadata |
293
+
294
+ ```typescript
295
+ // Tag a todo with a category (many-many relation)
296
+ await client.mutate({
297
+ resource: "todos",
298
+ version: 1,
299
+ operation: "relate",
300
+ id: "todo:1",
301
+ relation: "tags",
302
+ targetId: "cat:work",
303
+ });
304
+
305
+ // Remove a tag
306
+ await client.mutate({
307
+ resource: "todos",
308
+ version: 1,
309
+ operation: "unrelate",
310
+ id: "todo:1",
311
+ relation: "tags",
312
+ targetId: "cat:work",
313
+ });
314
+ ```
315
+
316
+ **Advanced mutation features:**
317
+ - **Idempotency**: `clientId` + `mutationId` for deduplication
318
+ - **Optimistic concurrency**: `if` guards prevent conflicts
319
+ - **Context**: pass arbitrary context data to plugins and events
320
+
321
+ ### table.signal(fragment)
322
+
323
+ Create a reactive signal — a live query that auto-refreshes when data changes.
324
+
325
+ ```typescript
326
+ const activeTasks = tasks.signal({
327
+ filters: { completed: false },
328
+ sort: ["-createdAt"],
329
+ });
330
+
331
+ // Get current value
332
+ console.log(activeTasks.get());
333
+
334
+ // Subscribe to changes
335
+ const unsub = activeTasks.subscribe((result) => {
336
+ console.log("Tasks:", result.data);
337
+ console.log("Loading:", result.loading);
59
338
  });
60
- // -> activeTasksSignal listeners are automatically notified!
339
+
340
+ // Check states
341
+ activeTasks.loading; // true while initial fetch is in progress
342
+ activeTasks.error; // non-null if last fetch failed
343
+ activeTasks.refreshing; // true while background refresh is in progress
61
344
  ```
62
345
 
63
- ## Core Concepts
346
+ **Signal features:**
347
+ - Lazy fetch: only loads data when first subscribed
348
+ - Auto-refresh: re-runs when mutations affect the query footprint
349
+ - Debounced batching: multiple rapid mutations trigger a single refresh
350
+ - Caching: signals with the same query share a single cached instance (via `dfqlKey`)
64
351
 
65
- ### 1. The Client Instance
352
+ ### table.subscribe(handler, filter?)
66
353
 
67
- The client is the central hub. It coordinates the **Schema**, **Storage**, **Remote** adapter, and **Event Bus**.
354
+ Subscribe to events for this resource only.
355
+
356
+ ```typescript
357
+ tasks.subscribe((event) => {
358
+ console.log(`${event.action} on ${event.resource}:`, event.ids);
359
+ });
360
+ ```
361
+
362
+ ---
363
+
364
+ ## KV API
365
+
366
+ The built-in key-value store provides a schemaless storage layer that syncs alongside your typed resources. Access it via `client.kv`.
367
+
368
+ ```typescript
369
+ // Set a value
370
+ await client.kv.set("user:theme", "dark");
371
+
372
+ // Get a value
373
+ const theme = await client.kv.get<string>("user:theme");
374
+ // → "dark"
375
+
376
+ // Merge into an object value
377
+ await client.kv.set("user:prefs", { fontSize: 14, lang: "en" });
378
+ await client.kv.merge("user:prefs", { fontSize: 16 });
379
+ // → { fontSize: 16, lang: "en" }
380
+
381
+ // Delete a key
382
+ await client.kv.delete("user:theme");
383
+
384
+ // Reactive signal for a key
385
+ const themeSignal = client.kv.signal<string>("user:theme", {
386
+ defaultValue: "dark",
387
+ });
388
+
389
+ themeSignal.subscribe((value) => {
390
+ document.body.className = value; // Updates reactively
391
+ });
392
+ ```
393
+
394
+ ### KV API Reference
395
+
396
+ | Method | Signature | Description |
397
+ |--------|-----------|-------------|
398
+ | `get` | `get<T>(key): Promise<T \| null>` | Read a value |
399
+ | `set` | `set<T>(key, value, params?): Promise<Result>` | Write a value (replace semantics) |
400
+ | `merge` | `merge(key, patch, params?): Promise<Result>` | Shallow-merge into existing object |
401
+ | `delete` | `delete(key, params?): Promise<Result>` | Remove a key |
402
+ | `signal` | `signal<T>(key, options?): DatafnSignal<T>` | Reactive signal for a key |
403
+
404
+ KV data is stored in the built-in `kv` resource and participates in sync (clone/pull/push) like any other resource.
405
+
406
+ ---
407
+
408
+ ## Offline-Only Mode
409
+
410
+ Run the client with no server at all. All data lives in local storage only.
68
411
 
69
412
  ```typescript
70
413
  const client = createDatafnClient({
71
- schema: DatafnSchema;
72
- remote: DatafnRemoteAdapter;
73
- storage?: DatafnStorageAdapter; // Optional: Enable offline mode
74
- plugins?: DatafnPlugin[]; // Optional: Custom logic
75
- clientId?: string; // Optional: Unique ID for this client
414
+ schema,
415
+ clientId: "local-device",
416
+ storage: new IndexedDbStorageAdapter("my-app-local"),
417
+ sync: {
418
+ mode: "local-only",
419
+ },
420
+ });
421
+
422
+ // No sync.start() needed — all tables are immediately "ready"
423
+ // Queries and mutations work against local storage
424
+ await client.table("tasks").mutate({
425
+ operation: "insert",
426
+ record: { title: "Offline task" },
76
427
  });
77
428
  ```
78
429
 
79
- ### 2. Table API
430
+ When in `local-only` mode:
431
+ - All resource hydration states are set to `"ready"` immediately
432
+ - Queries execute against local storage only
433
+ - Mutations apply optimistically to local storage
434
+ - No network calls are made
435
+ - The sync facade methods (`clone`, `pull`, `push`) throw if called
436
+
437
+ ---
80
438
 
81
- The `client.table(name)` method provides a scoped interface for a specific resource defined in your schema. It delegates to the main client but automatically handles resource naming and versioning.
439
+ ## Synchronization
440
+
441
+ ### Sync Facade
82
442
 
83
443
  ```typescript
84
- const users = client.table("user");
444
+ client.sync.seed(payload) // Seed data to server
445
+ client.sync.clone(payload) // Full data download
446
+ client.sync.pull(payload) // Incremental sync (cursor-based)
447
+ client.sync.push(payload) // Upload local mutations
448
+ client.sync.cloneUp(options?) // Upload local data to server
449
+ ```
85
450
 
86
- // Query
87
- const allUsers = await users.query({ select: ["id", "name"] });
451
+ ### Sync Engine
88
452
 
89
- // Mutate
90
- await users.mutate({
91
- operation: "merge",
92
- id: "user:123",
93
- record: { name: "New Name" }
453
+ When `offlinability` is true, the sync engine manages the full lifecycle:
454
+
455
+ ```typescript
456
+ await client.sync.start(); // Start sync engine (clone → pull loop → push loop)
457
+ client.sync.stop(); // Stop sync engine
458
+ await client.sync.pullNow(); // Trigger immediate pull
459
+ await client.sync.cloneNow(); // Trigger immediate clone
460
+ await client.sync.reconcileNow(); // Trigger reconcile
461
+ ```
462
+
463
+ **Sync engine behavior:**
464
+ - On `start()`: clones boot resources, then starts pull and push intervals
465
+ - Pull on visibility change: re-fetches when tab becomes visible
466
+ - Push batching: queues mutations and pushes in batches at `pushInterval`
467
+ - Push retries: retries failed pushes up to `pushMaxRetries` times
468
+
469
+ ### Hydration States
470
+
471
+ Each resource tracks its hydration state:
472
+
473
+ | State | Description |
474
+ |-------|-------------|
475
+ | `notStarted` | No data has been cloned yet |
476
+ | `hydrating` | Clone is in progress |
477
+ | `ready` | Data is available for queries |
478
+
479
+ Configure boot vs background resources to control app readiness:
480
+
481
+ ```typescript
482
+ sync: {
483
+ hydration: {
484
+ bootResources: ["tasks", "projects"], // Must clone before app is ready
485
+ backgroundResources: ["audit_log"], // Hydrates after boot
486
+ clonePageSize: { tasks: 500, audit_log: 100 },
487
+ },
488
+ }
489
+ ```
490
+
491
+ ### CloneUp
492
+
493
+ Upload local data to the server (e.g. after working offline):
494
+
495
+ ```typescript
496
+ const result = await client.sync.cloneUp({
497
+ resources: ["tasks"], // Which resources to upload (default: all)
498
+ includeManyMany: true, // Upload join rows too
499
+ recordOperation: "merge", // "merge" | "replace" | "insert"
500
+ batchSize: 100, // Records per batch
501
+ maxRetries: 3, // Retries per batch
502
+ failFast: false, // Stop on first error?
503
+ clearChangelogOnSuccess: true, // Drain changelog after upload
504
+ setGlobalCursorOnSuccess: true,// Update cursors
505
+ pullAfter: true, // Pull new data after upload
94
506
  });
95
507
 
96
- // Subscribe to events for this table only
97
- users.subscribe(event => console.log("User changed:", event));
508
+ console.log(result.uploadedCount);
98
509
  ```
99
510
 
100
- ### 3. Reactive Signals
511
+ ---
101
512
 
102
- Signals are the bridge between your data and your UI. A signal represents a live query. When data changes (locally or via sync), signals automatically re-run and notify subscribers.
513
+ ## Event Bus
514
+
515
+ Subscribe to global events or filter by resource, type, action, and more.
103
516
 
104
517
  ```typescript
105
- const query = { select: ["id"], filters: { status: "active" } };
106
- const signal = client.table("todo").signal(query);
518
+ // Global subscription
519
+ const unsub = client.subscribe((event) => {
520
+ console.log(event.type, event.resource, event.ids);
521
+ });
107
522
 
108
- // Get current value
109
- console.log(signal.get());
523
+ // Filtered subscription
524
+ const unsub2 = client.subscribe(
525
+ (event) => console.log("Task mutated:", event),
526
+ {
527
+ type: "mutation_applied",
528
+ resource: "tasks",
529
+ action: ["insert", "merge"],
530
+ },
531
+ );
532
+ ```
110
533
 
111
- // Subscribe
112
- const unsub = signal.subscribe(newValue => {
113
- // Update UI efficiently
534
+ ### EventFilter
535
+
536
+ ```typescript
537
+ type EventFilter = {
538
+ type?: string | string[];
539
+ resource?: string | string[];
540
+ ids?: string | string[];
541
+ mutationId?: string | string[];
542
+ action?: string | string[];
543
+ fields?: string | string[];
544
+ contextKeys?: string[];
545
+ context?: Record<string, unknown>;
546
+ };
547
+ ```
548
+
549
+ ### matchesFilter
550
+
551
+ Utility to check if an event matches a filter programmatically:
552
+
553
+ ```typescript
554
+ import { matchesFilter } from "@datafn/client";
555
+
556
+ if (matchesFilter(event, { resource: "tasks", type: "mutation_applied" })) {
557
+ // handle
558
+ }
559
+ ```
560
+
561
+ ---
562
+
563
+ ## Transactions
564
+
565
+ Execute atomic multi-step operations:
566
+
567
+ ```typescript
568
+ const result = await client.transact({
569
+ transactionId: "tx-complete-all",
570
+ atomic: true,
571
+ steps: [
572
+ {
573
+ query: {
574
+ resource: "tasks",
575
+ version: 1,
576
+ select: ["id"],
577
+ filters: { completed: false },
578
+ },
579
+ },
580
+ {
581
+ mutation: {
582
+ resource: "tasks",
583
+ version: 1,
584
+ operation: "merge",
585
+ id: "task:1",
586
+ record: { completed: true },
587
+ },
588
+ },
589
+ {
590
+ mutation: {
591
+ resource: "tasks",
592
+ version: 1,
593
+ operation: "delete",
594
+ id: "task:2",
595
+ },
596
+ },
597
+ ],
114
598
  });
115
599
  ```
116
600
 
117
- ### 4. Offline & Storage
601
+ ---
602
+
603
+ ## Storage Adapters
118
604
 
119
- Pass a `storage` adapter to enable offline capabilities. The client will automatically:
120
- - Hydrate signals from local storage on load.
121
- - Apply `clone` and `pull` results to local storage.
122
- - (Future) Queue mutations when offline.
605
+ ### IndexedDbStorageAdapter
123
606
 
124
- **Available Adapters:**
125
- - `MemoryStorageAdapter`: Transient, in-memory storage (great for testing).
126
- - `IndexedDbStorageAdapter`: Persistent browser storage.
607
+ Persistent browser storage backed by IndexedDB. Supports multi-user isolation.
127
608
 
128
609
  ```typescript
129
610
  import { IndexedDbStorageAdapter } from "@datafn/client";
130
611
 
131
- const storage = new IndexedDbStorageAdapter("my-db");
612
+ // Simple usage
613
+ const storage = new IndexedDbStorageAdapter("my-app-db");
614
+
615
+ // Multi-user isolation
616
+ const storage = IndexedDbStorageAdapter.createForUser(
617
+ "my-app-db",
618
+ userId,
619
+ tenantId, // optional
620
+ );
621
+ // Creates database: "my-app-db_tenant-456_user-123"
622
+ ```
623
+
624
+ ### MemoryStorageAdapter
625
+
626
+ In-memory storage for testing — data is lost on page refresh.
627
+
628
+ ```typescript
629
+ import { MemoryStorageAdapter } from "@datafn/client";
630
+
631
+ const storage = new MemoryStorageAdapter();
132
632
  ```
133
633
 
134
- ### 5. Synchronization
634
+ ### DatafnStorageAdapter Interface
135
635
 
136
- The `client.sync` facade manages data consistency with the server.
636
+ Implement this interface for custom storage backends:
137
637
 
138
638
  ```typescript
139
- // Initial data load
140
- await client.sync.clone({ ... });
639
+ interface DatafnStorageAdapter {
640
+ // Records
641
+ getRecord(resource: string, id: string): Promise<Record<string, unknown> | null>;
642
+ listRecords(resource: string): Promise<Record<string, unknown>[]>;
643
+ upsertRecord(resource: string, record: Record<string, unknown>): Promise<void>;
644
+ deleteRecord(resource: string, id: string): Promise<void>;
645
+ findRecords(resource: string, field: string, value: unknown): Promise<Record<string, unknown>[]>;
646
+ countRecords(resource: string): Promise<number>;
647
+
648
+ // Join rows (many-many relations)
649
+ listJoinRows(relationKey: string): Promise<Array<Record<string, unknown>>>;
650
+ getJoinRows(relationKey: string, fromId: string): Promise<Array<Record<string, unknown>>>;
651
+ getJoinRowsInverse(relationKey: string, toId: string): Promise<Array<Record<string, unknown>>>;
652
+ upsertJoinRow(relationKey: string, row: Record<string, unknown>): Promise<void>;
653
+ setJoinRows(relationKey: string, rows: Array<Record<string, unknown>>): Promise<void>;
654
+ deleteJoinRow(relationKey: string, from: string, to: string): Promise<void>;
655
+ countJoinRows(relationKey: string): Promise<number>;
656
+
657
+ // Sync state
658
+ getCursor(resource: string): Promise<string | null>;
659
+ setCursor(resource: string, cursor: string | null): Promise<void>;
660
+ getHydrationState(resource: string): Promise<DatafnHydrationState>;
661
+ setHydrationState(resource: string, state: DatafnHydrationState): Promise<void>;
662
+
663
+ // Offline changelog
664
+ changelogAppend(entry: Omit<DatafnChangelogEntry, "seq">): Promise<DatafnChangelogEntry>;
665
+ changelogList(options?: { limit?: number }): Promise<DatafnChangelogEntry[]>;
666
+ changelogAck(options: { throughSeq: number }): Promise<void>;
667
+ }
668
+ ```
669
+
670
+ ---
671
+
672
+ ## Multi-User / Multi-Tenant Isolation
141
673
 
142
- // Fetch incremental updates
143
- await client.sync.pull({ ... });
674
+ Isolate data per user in separate IndexedDB databases.
144
675
 
145
- // Push local changes (if using offline queue)
146
- await client.sync.push({ ... });
676
+ ### Option 1: AuthContextProvider (recommended)
677
+
678
+ ```typescript
679
+ import { createDatafnClient, IndexedDbStorageAdapter } from "@datafn/client";
680
+
681
+ const client = createDatafnClient({
682
+ schema,
683
+ clientId: "device-uuid",
684
+ authContext: authClient.contextProvider, // implements { getContext(): AuthContext }
685
+ storage: (ctx) =>
686
+ IndexedDbStorageAdapter.createForUser("my-app", ctx.userId, ctx.tenantId),
687
+ sync: { remote: "http://localhost:3000/datafn" },
688
+ });
147
689
  ```
148
690
 
149
- If `storage` is configured, `clone` and `pull` operations automatically update the local database, which in turn triggers all relevant reactive signals.
691
+ ### Option 2: Direct AuthContext
150
692
 
151
- ### 6. Event Bus
693
+ ```typescript
694
+ const client = createDatafnClient({
695
+ schema,
696
+ clientId: "device-uuid",
697
+ authContext: { userId: "user-123", tenantId: "tenant-456" },
698
+ storage: (ctx) =>
699
+ IndexedDbStorageAdapter.createForUser("my-app", ctx.userId, ctx.tenantId),
700
+ sync: { remote: "http://localhost:3000/datafn" },
701
+ });
702
+ ```
703
+
704
+ When a user logs out and another logs in, create a new client instance. Each user's data remains isolated in their own IndexedDB database.
152
705
 
153
- Listen to global or scoped events.
706
+ ---
707
+
708
+ ## Date Codec
709
+
710
+ Automatic serialization and parsing of `date` fields.
154
711
 
155
712
  ```typescript
156
- client.subscribe((event) => {
157
- if (event.type === "mutation_applied") {
158
- console.log(`Resource ${event.resource} updated!`);
159
- }
713
+ import {
714
+ serializeDateFields,
715
+ parseDateFields,
716
+ parseQueryResultDates,
717
+ } from "@datafn/client";
718
+
719
+ // Serialize Date objects to timestamps for mutations
720
+ const serialized = serializeDateFields(schema, "tasks", {
721
+ title: "Hello",
722
+ createdAt: new Date(),
160
723
  });
724
+
725
+ // Parse timestamps back to Date objects
726
+ const parsed = parseDateFields(schema, "tasks", {
727
+ title: "Hello",
728
+ createdAt: 1707000000000,
729
+ });
730
+
731
+ // Parse all date fields in a query result
732
+ const result = parseQueryResultDates(schema, "tasks", queryResult);
161
733
  ```
162
734
 
163
- ## API Reference
735
+ ---
736
+
737
+ ## Plugins
738
+
739
+ Extend client behavior with plugins that intercept queries, mutations, and sync.
740
+
741
+ ```typescript
742
+ import type { DatafnPlugin } from "@datafn/core";
743
+
744
+ const loggingPlugin: DatafnPlugin = {
745
+ name: "logger",
746
+ runsOn: ["client"],
164
747
 
165
- ### `DatafnClient`
748
+ afterMutation(ctx, mutation, result) {
749
+ console.log("Mutation:", mutation, "Result:", result);
750
+ },
166
751
 
167
- - `table(name: string): DatafnTable` - Get a table handle.
168
- - `query(q: unknown): Promise<unknown>` - Execute a raw query.
169
- - `mutate(m: unknown): Promise<unknown>` - Execute a raw mutation.
170
- - `transact(t: unknown): Promise<unknown>` - Execute a transaction.
171
- - `sync`: Sync facade (`clone`, `pull`, `push`, `seed`).
172
- - `subscribe(handler, filter?)`: Global event subscription.
752
+ afterSync(ctx, phase, payload, result) {
753
+ console.log(`Sync ${phase}:`, result);
754
+ },
755
+ };
173
756
 
174
- ### `DatafnTable`
757
+ const client = createDatafnClient({
758
+ schema,
759
+ clientId: "...",
760
+ plugins: [loggingPlugin],
761
+ // ...
762
+ });
763
+ ```
175
764
 
176
- - `query(fragment)`: Execute query for this resource.
177
- - `mutate(fragment)`: Execute mutation for this resource.
178
- - `signal(fragment)`: Create a reactive signal.
179
- - `subscribe(handler)`: Subscribe to events for this resource.
765
+ ---
180
766
 
181
- ### `DatafnRemoteAdapter`
767
+ ## Remote Adapter
182
768
 
183
- Interface for your network layer. You must implement this to connect `datafn/client` to your backend.
769
+ The default HTTP transport is used when you provide `sync.remote`. For custom transport (WebSocket-only, browser extension, etc.), implement `DatafnRemoteAdapter`:
184
770
 
185
771
  ```typescript
186
772
  interface DatafnRemoteAdapter {
187
773
  query(q: unknown): Promise<unknown>;
188
774
  mutation(m: unknown): Promise<unknown>;
189
775
  transact(t: unknown): Promise<unknown>;
190
- seed(p: unknown): Promise<unknown>;
191
- clone(p: unknown): Promise<unknown>;
192
- pull(p: unknown): Promise<unknown>;
193
- push(p: unknown): Promise<unknown>;
776
+ seed(payload: unknown): Promise<unknown>;
777
+ clone(payload: unknown): Promise<unknown>;
778
+ pull(payload: unknown): Promise<unknown>;
779
+ push(payload: unknown): Promise<unknown>;
780
+ reconcile(payload: unknown): Promise<unknown>;
194
781
  }
195
782
  ```
196
783
 
784
+ ### Extension Adapter
785
+
786
+ For browser extensions, the remote adapter can include event subscription support:
787
+
788
+ ```typescript
789
+ const client = createDatafnClient({
790
+ schema,
791
+ clientId: "extension-popup",
792
+ sync: {
793
+ remoteAdapter: {
794
+ ...transportMethods,
795
+ onEvent(handler) { /* wire inbound events */ },
796
+ subscribeRemote(filter) { /* register subscription */ },
797
+ unsubscribeRemote(id) { /* remove subscription */ },
798
+ },
799
+ },
800
+ });
801
+ ```
802
+
803
+ ---
804
+
805
+ ## Exports
806
+
807
+ ```typescript
808
+ // Client factory and types
809
+ export { createDatafnClient, type DatafnClient, type DatafnClientConfig, type DatafnRemoteAdapter }
810
+
811
+ // Table API
812
+ export { type DatafnTable }
813
+
814
+ // Event system
815
+ export { EventBus, type EventHandler }
816
+ export { matchesFilter, type EventFilter }
817
+
818
+ // Storage
819
+ export { type DatafnStorageAdapter, type DatafnStorageFactory }
820
+ export { type DatafnHydrationState, type DatafnChangelogEntry }
821
+ export { MemoryStorageAdapter }
822
+ export { IndexedDbStorageAdapter }
823
+
824
+ // KV API
825
+ export type { DatafnKvApi }
826
+ export { kvId, KV_RESOURCE_NAME }
827
+
828
+ // CloneUp
829
+ export type { CloneUpOptions, CloneUpResult }
830
+
831
+ // Date Codec
832
+ export { serializeDateFields, parseDateFields, parseQueryResultDates }
833
+
834
+ // Auth (re-exported from @superfunctions/auth)
835
+ export type { AuthContext, AuthContextProvider }
836
+
837
+ // Errors
838
+ export { type DatafnClientError, createClientError }
839
+ export { unwrapRemoteSuccess }
840
+ ```
841
+
197
842
  ## License
198
843
 
199
- MIT
844
+ MIT