@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 +749 -104
- package/dist/index.cjs +6162 -571
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +577 -43
- package/dist/index.d.ts +577 -43
- package/dist/index.js +6161 -572
- package/dist/index.js.map +1 -1
- package/package.json +8 -6
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @datafn/client
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
storage: new IndexedDbStorageAdapter("my-app-db"),
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
//
|
|
42
|
-
|
|
61
|
+
// Start sync (clone + pull + push engine)
|
|
62
|
+
await client.sync.start();
|
|
43
63
|
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
//
|
|
51
|
-
const
|
|
52
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
352
|
+
### table.subscribe(handler, filter?)
|
|
66
353
|
|
|
67
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
storage
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
439
|
+
## Synchronization
|
|
440
|
+
|
|
441
|
+
### Sync Facade
|
|
82
442
|
|
|
83
443
|
```typescript
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
const allUsers = await users.query({ select: ["id", "name"] });
|
|
451
|
+
### Sync Engine
|
|
88
452
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
users.subscribe(event => console.log("User changed:", event));
|
|
508
|
+
console.log(result.uploadedCount);
|
|
98
509
|
```
|
|
99
510
|
|
|
100
|
-
|
|
511
|
+
---
|
|
101
512
|
|
|
102
|
-
|
|
513
|
+
## Event Bus
|
|
514
|
+
|
|
515
|
+
Subscribe to global events or filter by resource, type, action, and more.
|
|
103
516
|
|
|
104
517
|
```typescript
|
|
105
|
-
|
|
106
|
-
const
|
|
518
|
+
// Global subscription
|
|
519
|
+
const unsub = client.subscribe((event) => {
|
|
520
|
+
console.log(event.type, event.resource, event.ids);
|
|
521
|
+
});
|
|
107
522
|
|
|
108
|
-
//
|
|
109
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
601
|
+
---
|
|
602
|
+
|
|
603
|
+
## Storage Adapters
|
|
118
604
|
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
634
|
+
### DatafnStorageAdapter Interface
|
|
135
635
|
|
|
136
|
-
|
|
636
|
+
Implement this interface for custom storage backends:
|
|
137
637
|
|
|
138
638
|
```typescript
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
await client.sync.pull({ ... });
|
|
674
|
+
Isolate data per user in separate IndexedDB databases.
|
|
144
675
|
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
691
|
+
### Option 2: Direct AuthContext
|
|
150
692
|
|
|
151
|
-
|
|
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
|
-
|
|
706
|
+
---
|
|
707
|
+
|
|
708
|
+
## Date Codec
|
|
709
|
+
|
|
710
|
+
Automatic serialization and parsing of `date` fields.
|
|
154
711
|
|
|
155
712
|
```typescript
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
-
|
|
748
|
+
afterMutation(ctx, mutation, result) {
|
|
749
|
+
console.log("Mutation:", mutation, "Result:", result);
|
|
750
|
+
},
|
|
166
751
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
757
|
+
const client = createDatafnClient({
|
|
758
|
+
schema,
|
|
759
|
+
clientId: "...",
|
|
760
|
+
plugins: [loggingPlugin],
|
|
761
|
+
// ...
|
|
762
|
+
});
|
|
763
|
+
```
|
|
175
764
|
|
|
176
|
-
|
|
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
|
-
|
|
767
|
+
## Remote Adapter
|
|
182
768
|
|
|
183
|
-
|
|
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(
|
|
191
|
-
clone(
|
|
192
|
-
pull(
|
|
193
|
-
push(
|
|
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
|