@markwasfy/loko 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +51 -0
- package/dist/index.cjs +853 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +367 -0
- package/dist/index.d.ts +367 -0
- package/dist/index.js +840 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core type definitions for loko.
|
|
3
|
+
*
|
|
4
|
+
* Design notes:
|
|
5
|
+
* - Server-assigned `version` is the authority for conflict resolution and delta
|
|
6
|
+
* sync, NOT client wall-clock time. Device clocks drift, so `updatedAt` is kept
|
|
7
|
+
* purely for display and as a hint for the default resolver.
|
|
8
|
+
* - `dirty` is never stored: it is derived as `localRev > lastPushedLocalRev`.
|
|
9
|
+
*/
|
|
10
|
+
/** A record stored locally, wrapping the user's data `T` with sync metadata. */
|
|
11
|
+
interface StoredDoc<T = unknown> {
|
|
12
|
+
/** Primary key within the collection. */
|
|
13
|
+
id: string;
|
|
14
|
+
/** Collection name this record belongs to. */
|
|
15
|
+
collection: string;
|
|
16
|
+
/** The user's payload. `null` only ever paired with a tombstone (`deletedAt`). */
|
|
17
|
+
data: T;
|
|
18
|
+
/** Wall-clock ms of the last local edit. Display/hint only — never the sync authority. */
|
|
19
|
+
updatedAt: number;
|
|
20
|
+
/** Tombstone marker (soft delete) so deletions propagate. `null` = live record. */
|
|
21
|
+
deletedAt: number | null;
|
|
22
|
+
/** Bumped on every local edit. */
|
|
23
|
+
localRev: number;
|
|
24
|
+
/** Value of `localRev` at the last successful push ack. */
|
|
25
|
+
lastPushedLocalRev: number;
|
|
26
|
+
/** Server-assigned version this record was last synced to/from (0 = never synced). */
|
|
27
|
+
remoteVersion: number;
|
|
28
|
+
/** Wall-clock ms of the last successful sync touching this record. */
|
|
29
|
+
syncedAt: number | null;
|
|
30
|
+
}
|
|
31
|
+
/** `true` when a record has local edits not yet acknowledged by the server. */
|
|
32
|
+
declare function isDirty(doc: Pick<StoredDoc, "localRev" | "lastPushedLocalRev">): boolean;
|
|
33
|
+
/** A change transmitted over the wire (push/pull). */
|
|
34
|
+
interface Change<T = unknown> {
|
|
35
|
+
collection: string;
|
|
36
|
+
id: string;
|
|
37
|
+
/** Payload, or `null` for a deletion. */
|
|
38
|
+
data: T | null;
|
|
39
|
+
/** Server-assigned version. For outgoing (push) changes this is the client's
|
|
40
|
+
* last-known `remoteVersion` (the base the edit was made against). */
|
|
41
|
+
version: number;
|
|
42
|
+
/** Display value / resolver hint only. */
|
|
43
|
+
updatedAt: number;
|
|
44
|
+
/** Whether this change is a deletion. */
|
|
45
|
+
deleted: boolean;
|
|
46
|
+
}
|
|
47
|
+
/** Opaque, monotonically increasing server sequence used as a pull cursor. */
|
|
48
|
+
type Cursor = string | number;
|
|
49
|
+
/** Result of pushing local changes to the server. */
|
|
50
|
+
interface PushResult {
|
|
51
|
+
/** Records the server accepted, with their newly committed versions. */
|
|
52
|
+
acked: Array<{
|
|
53
|
+
id: string;
|
|
54
|
+
collection: string;
|
|
55
|
+
version: number;
|
|
56
|
+
}>;
|
|
57
|
+
/**
|
|
58
|
+
* Records the server rejected because it was already ahead (e.g. client sent
|
|
59
|
+
* v5 but server has v6). Surfaced at push time so conflicts aren't only
|
|
60
|
+
* discovered on the next pull. May be omitted/empty.
|
|
61
|
+
*/
|
|
62
|
+
conflicts?: Array<Change>;
|
|
63
|
+
/** Server sequence after this push; lets the client advance its pull cursor. */
|
|
64
|
+
cursor: Cursor;
|
|
65
|
+
}
|
|
66
|
+
/** Result of pulling remote changes since a cursor. */
|
|
67
|
+
interface PullResult {
|
|
68
|
+
changes: Array<Change>;
|
|
69
|
+
cursor: Cursor;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Transport between the local store and a backend. Bring your own — implement
|
|
73
|
+
* this against REST, Supabase, Firebase, WebSockets, anything.
|
|
74
|
+
*/
|
|
75
|
+
interface SyncAdapter {
|
|
76
|
+
push(changes: Change[]): Promise<PushResult>;
|
|
77
|
+
pull(since: Cursor): Promise<PullResult>;
|
|
78
|
+
}
|
|
79
|
+
/** A handle for atomic multi-record writes within {@link StorageAdapter.transact}. */
|
|
80
|
+
interface StorageTx {
|
|
81
|
+
put(doc: StoredDoc): void;
|
|
82
|
+
delete(collection: string, id: string): void;
|
|
83
|
+
setMeta(key: string, value: unknown): void;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Pluggable persistence layer. Ships with `indexedDBStorage()` (browser default)
|
|
87
|
+
* and `memoryStorage()` (tests/Node/SSR).
|
|
88
|
+
*/
|
|
89
|
+
interface StorageAdapter {
|
|
90
|
+
/** Open/prepare the store for the given database name. */
|
|
91
|
+
init(name: string): Promise<void>;
|
|
92
|
+
get(collection: string, id: string): Promise<StoredDoc | undefined>;
|
|
93
|
+
/** All records in a collection, including tombstones. */
|
|
94
|
+
getAll(collection: string): Promise<StoredDoc[]>;
|
|
95
|
+
put(doc: StoredDoc): Promise<void>;
|
|
96
|
+
bulkPut(docs: StoredDoc[]): Promise<void>;
|
|
97
|
+
/**
|
|
98
|
+
* Run `fn` as an atomic, all-or-nothing transaction. Underpins `bulkPut`
|
|
99
|
+
* today and `sync.transaction()` (reserved) tomorrow. A throw rolls back.
|
|
100
|
+
*/
|
|
101
|
+
transact(fn: (tx: StorageTx) => void | Promise<void>): Promise<void>;
|
|
102
|
+
/** All records with unsynced local edits, across every collection. */
|
|
103
|
+
getDirty(): Promise<StoredDoc[]>;
|
|
104
|
+
getMeta<V = unknown>(key: string): Promise<V | undefined>;
|
|
105
|
+
setMeta(key: string, value: unknown): Promise<void>;
|
|
106
|
+
/** Release resources (close DB, channels). */
|
|
107
|
+
close(): Promise<void>;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Decides the winner when a local edit and a remote change collide. The default
|
|
111
|
+
* is {@link lastWriteWins}. Conflict resolution is per-record, not per-field:
|
|
112
|
+
* to merge fields, return a record built from both sides.
|
|
113
|
+
*/
|
|
114
|
+
type ConflictResolver = <T = unknown>(local: StoredDoc<T> | undefined, remote: Change<T>) => StoredDoc<T>;
|
|
115
|
+
/** Options declared once per collection via {@link Sync.defineCollection}. */
|
|
116
|
+
interface CollectionOptions {
|
|
117
|
+
/** Field on the record used as the id. Defaults to `"id"`. */
|
|
118
|
+
primaryKey?: string;
|
|
119
|
+
/**
|
|
120
|
+
* Indexed fields. Accepted and validated in v1 but not yet used for querying
|
|
121
|
+
* (reserved for indexed reads on the roadmap).
|
|
122
|
+
*/
|
|
123
|
+
indexes?: string[];
|
|
124
|
+
}
|
|
125
|
+
type SyncStatus = "idle" | "syncing" | "offline" | "error";
|
|
126
|
+
/** Map of event name -> payload for the typed emitter. */
|
|
127
|
+
interface SyncEvents {
|
|
128
|
+
/** A record changed locally or via sync. */
|
|
129
|
+
change: {
|
|
130
|
+
collection: string;
|
|
131
|
+
id: string;
|
|
132
|
+
doc: StoredDoc | undefined;
|
|
133
|
+
};
|
|
134
|
+
/** A full sync cycle completed. */
|
|
135
|
+
sync: {
|
|
136
|
+
pushed: number;
|
|
137
|
+
pulled: number;
|
|
138
|
+
};
|
|
139
|
+
/** Sync status transitioned. */
|
|
140
|
+
status: {
|
|
141
|
+
status: SyncStatus;
|
|
142
|
+
};
|
|
143
|
+
/** Something went wrong (sync, storage, transport). */
|
|
144
|
+
error: {
|
|
145
|
+
error: unknown;
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
interface SyncConfig {
|
|
149
|
+
/** Logical database name; also namespaces the multi-tab BroadcastChannel. */
|
|
150
|
+
name: string;
|
|
151
|
+
/** Where records live. Defaults to `indexedDBStorage()` in the browser. */
|
|
152
|
+
storage: StorageAdapter;
|
|
153
|
+
/** How to talk to the backend. Optional — omit for a purely local store. */
|
|
154
|
+
adapter?: SyncAdapter;
|
|
155
|
+
/** Conflict strategy. Defaults to {@link lastWriteWins}. */
|
|
156
|
+
conflict?: ConflictResolver;
|
|
157
|
+
/**
|
|
158
|
+
* When `true` (default), sync on reconnect and on an interval (leader tab only).
|
|
159
|
+
* Pass a number to override the polling interval in ms (0 disables polling).
|
|
160
|
+
*/
|
|
161
|
+
autoSync?: boolean | number;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Internal wiring handed to each {@link Collection} by the owning Sync instance. */
|
|
165
|
+
interface CollectionContext {
|
|
166
|
+
storage: StorageAdapter;
|
|
167
|
+
primaryKey: string;
|
|
168
|
+
indexes: string[];
|
|
169
|
+
/** Resolves once storage is initialized; awaited before every storage access. */
|
|
170
|
+
ready: Promise<void>;
|
|
171
|
+
/**
|
|
172
|
+
* Called after a local write. The Sync engine emits a `change` event,
|
|
173
|
+
* broadcasts to other tabs, and schedules autoSync.
|
|
174
|
+
*/
|
|
175
|
+
onLocalWrite(doc: StoredDoc): void;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* A typed view over one collection of records. Reads/writes the user's payload
|
|
179
|
+
* `T` and tracks sync metadata under the hood. `T` must carry the primary key
|
|
180
|
+
* field (default `"id"`).
|
|
181
|
+
*/
|
|
182
|
+
declare class Collection<T extends Record<string, unknown> = Record<string, unknown>> {
|
|
183
|
+
readonly name: string;
|
|
184
|
+
private readonly ctx;
|
|
185
|
+
private readonly subscribers;
|
|
186
|
+
private readonly oneSubscribers;
|
|
187
|
+
private refreshScheduled;
|
|
188
|
+
constructor(name: string, ctx: CollectionContext);
|
|
189
|
+
private idOf;
|
|
190
|
+
/** Read a single live record by id (tombstones return `undefined`). */
|
|
191
|
+
get(id: string): Promise<T | undefined>;
|
|
192
|
+
/** All live records in the collection (tombstones excluded). */
|
|
193
|
+
all(): Promise<T[]>;
|
|
194
|
+
/** Insert or update a record. Returns the stored record. */
|
|
195
|
+
put(record: T): Promise<T>;
|
|
196
|
+
/** Insert or update many records atomically (one transaction). */
|
|
197
|
+
bulkPut(records: T[]): Promise<void>;
|
|
198
|
+
/** Soft-delete a record (writes a tombstone so the deletion syncs). */
|
|
199
|
+
delete(id: string): Promise<void>;
|
|
200
|
+
/** Build the next StoredDoc for a local write, bumping `localRev`. */
|
|
201
|
+
private nextDoc;
|
|
202
|
+
/**
|
|
203
|
+
* Subscribe to the whole collection. The callback fires immediately with the
|
|
204
|
+
* current records, then on every change (local, sync, or other tab).
|
|
205
|
+
* Returns an unsubscribe function.
|
|
206
|
+
*/
|
|
207
|
+
subscribe(cb: (items: T[]) => void): () => void;
|
|
208
|
+
/** Subscribe to a single record by id. */
|
|
209
|
+
subscribeOne(id: string, cb: (item: T | undefined) => void): () => void;
|
|
210
|
+
/**
|
|
211
|
+
* Notify subscribers that this collection changed. Coalesces bursts (e.g.
|
|
212
|
+
* bulkPut, a sync batch) into a single refresh per microtask. Called by the
|
|
213
|
+
* Sync engine for local writes, pulled changes, and cross-tab updates.
|
|
214
|
+
*/
|
|
215
|
+
notify(): void;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
declare class Sync {
|
|
219
|
+
private readonly storage;
|
|
220
|
+
private readonly adapter;
|
|
221
|
+
private readonly resolver;
|
|
222
|
+
private readonly emitter;
|
|
223
|
+
private readonly collections;
|
|
224
|
+
private readonly options;
|
|
225
|
+
private readonly tabs;
|
|
226
|
+
private readonly background;
|
|
227
|
+
private readonly autoSync;
|
|
228
|
+
private readonly ready;
|
|
229
|
+
private _status;
|
|
230
|
+
private inFlight;
|
|
231
|
+
private clientId;
|
|
232
|
+
constructor(config: SyncConfig);
|
|
233
|
+
private init;
|
|
234
|
+
/** Declare a collection's options once (at init). Returns the collection. */
|
|
235
|
+
defineCollection<T extends Record<string, unknown>>(name: string, options?: CollectionOptions): Collection<T>;
|
|
236
|
+
/** Access a collection. Auto-defines with defaults if not yet declared. */
|
|
237
|
+
collection<T extends Record<string, unknown>>(name: string): Collection<T>;
|
|
238
|
+
private getOrCreate;
|
|
239
|
+
/**
|
|
240
|
+
* Convenience for simple key -> value cases. Stored as a SINGLE record in the
|
|
241
|
+
* reserved `_kv` collection, so it syncs as one coarse record. Not smart
|
|
242
|
+
* per-item sync — use real collections (`collection().put`) for that.
|
|
243
|
+
*/
|
|
244
|
+
store<V>(key: string, value: V): Promise<void>;
|
|
245
|
+
/** Read a value previously written with {@link store}. */
|
|
246
|
+
get<V>(key: string): Promise<V | undefined>;
|
|
247
|
+
get status(): SyncStatus;
|
|
248
|
+
/** Resolves once storage is initialized (clientId loaded, leader elected). */
|
|
249
|
+
whenReady(): Promise<void>;
|
|
250
|
+
/** Subscribe to a lifecycle event. Returns an unsubscribe function. */
|
|
251
|
+
on<K extends keyof SyncEvents>(event: K, fn: (payload: SyncEvents[K]) => void): () => void;
|
|
252
|
+
/**
|
|
253
|
+
* Run a full sync cycle: push local changes, then pull remote ones. Concurrent
|
|
254
|
+
* calls share the same in-flight run. No-op when no adapter is configured.
|
|
255
|
+
*/
|
|
256
|
+
sync(): Promise<{
|
|
257
|
+
pushed: number;
|
|
258
|
+
pulled: number;
|
|
259
|
+
}>;
|
|
260
|
+
private push;
|
|
261
|
+
private pull;
|
|
262
|
+
/** Apply one remote change, returning the StoredDoc to write, or `undefined` to skip. */
|
|
263
|
+
private resolveRemote;
|
|
264
|
+
private applyRemote;
|
|
265
|
+
private handleLocalWrite;
|
|
266
|
+
private notifyAndBroadcast;
|
|
267
|
+
private onTabMessage;
|
|
268
|
+
private setStatus;
|
|
269
|
+
/** Opt into Service Worker Background Sync (no-op if unsupported). */
|
|
270
|
+
registerBackgroundSync(tag?: string): Promise<boolean>;
|
|
271
|
+
/** Tear down listeners, timers, and channels, and close storage. */
|
|
272
|
+
close(): Promise<void>;
|
|
273
|
+
}
|
|
274
|
+
/** Create a Sync instance. See {@link SyncConfig}. */
|
|
275
|
+
declare function createSync(config: SyncConfig): Sync;
|
|
276
|
+
|
|
277
|
+
interface IndexedDBStorageOptions {
|
|
278
|
+
/** Override the IDBFactory (e.g. inject fake-indexeddb in tests). */
|
|
279
|
+
indexedDB?: IDBFactory;
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Default browser {@link StorageAdapter}, backed by IndexedDB. Zero dependencies.
|
|
283
|
+
*
|
|
284
|
+
* Records live in a `docs` store keyed by `[collection, id]` with a `collection`
|
|
285
|
+
* index for range scans; key/value meta lives in a `meta` store.
|
|
286
|
+
*
|
|
287
|
+
* `transact` buffers writes synchronously inside the callback, then applies them
|
|
288
|
+
* in a single IndexedDB transaction — so a throw rolls everything back, and we
|
|
289
|
+
* never hold an IDB transaction open across an `await` (which would auto-close it).
|
|
290
|
+
*/
|
|
291
|
+
declare function indexedDBStorage(options?: IndexedDBStorageOptions): StorageAdapter;
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* In-memory {@link StorageAdapter} for tests, Node, and SSR. Not persistent.
|
|
295
|
+
*
|
|
296
|
+
* `transact` buffers writes and applies them atomically: if `fn` throws, no
|
|
297
|
+
* change is committed (the rollback guarantee `bulkPut` and `transaction` rely on).
|
|
298
|
+
*/
|
|
299
|
+
declare function memoryStorage(): StorageAdapter;
|
|
300
|
+
|
|
301
|
+
interface RestAdapterOptions {
|
|
302
|
+
/**
|
|
303
|
+
* Base URL of the sync endpoints. The adapter calls:
|
|
304
|
+
* - `POST {url}/push` body `{ changes }` -> `{ acked, conflicts?, cursor }`
|
|
305
|
+
* - `GET {url}/pull?since={cursor}` -> `{ changes, cursor }`
|
|
306
|
+
*/
|
|
307
|
+
url: string;
|
|
308
|
+
/** Extra headers (e.g. auth). Called per request so tokens can refresh. */
|
|
309
|
+
headers?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
310
|
+
/** Override `fetch` (e.g. for tests or custom retry/agent behavior). */
|
|
311
|
+
fetch?: typeof fetch;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Default HTTP {@link SyncAdapter}. Implements a tiny, copy-pasteable protocol;
|
|
315
|
+
* see `examples/server` for a reference backend.
|
|
316
|
+
*/
|
|
317
|
+
declare function restAdapter(options: RestAdapterOptions): SyncAdapter;
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* A minimal in-memory "server". Assigns a monotonically increasing `version` to
|
|
321
|
+
* every committed change (this `version` is the conflict-resolution authority,
|
|
322
|
+
* not client clocks) and exposes it as the pull cursor. Share one server across
|
|
323
|
+
* several {@link memoryAdapter} instances to simulate multiple clients in tests.
|
|
324
|
+
*/
|
|
325
|
+
declare class MemoryServer {
|
|
326
|
+
private records;
|
|
327
|
+
private seq;
|
|
328
|
+
private key;
|
|
329
|
+
commit(changes: Change[]): PushResult;
|
|
330
|
+
changesSince(since: Cursor): {
|
|
331
|
+
changes: Change[];
|
|
332
|
+
cursor: Cursor;
|
|
333
|
+
};
|
|
334
|
+
private toChange;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* In-memory {@link SyncAdapter} for tests and demos. Pass a shared
|
|
338
|
+
* {@link MemoryServer} to connect multiple simulated clients to one backend.
|
|
339
|
+
*/
|
|
340
|
+
declare function memoryAdapter(server?: MemoryServer): SyncAdapter;
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Build a clean local record by fully adopting a remote change. Used for
|
|
344
|
+
* fast-forward (non-conflicting) updates and when the remote wins a conflict.
|
|
345
|
+
* The resulting record is NOT dirty (`localRev === lastPushedLocalRev`).
|
|
346
|
+
*/
|
|
347
|
+
declare function docFromRemote<T>(remote: Change<T>, now?: number): StoredDoc<T>;
|
|
348
|
+
/**
|
|
349
|
+
* Default conflict strategy: **last write wins** by wall-clock `updatedAt`, with
|
|
350
|
+
* the already-committed remote winning ties. Conflict resolution is per-record:
|
|
351
|
+
* the winning record replaces the loser wholesale (no field merge).
|
|
352
|
+
*
|
|
353
|
+
* - Remote wins -> adopt remote (record becomes clean).
|
|
354
|
+
* - Local wins -> keep local data, rebased onto the remote's version, and left
|
|
355
|
+
* dirty so it re-pushes and overwrites the server on the next sync.
|
|
356
|
+
*/
|
|
357
|
+
declare function lastWriteWins(): ConflictResolver;
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Opt-in helper to register a Service Worker Background Sync trigger, for true
|
|
361
|
+
* background sync when the page is closed. The core works fully without this;
|
|
362
|
+
* your service worker must listen for the `sync` event with the same tag and
|
|
363
|
+
* message clients to call `sync.sync()`.
|
|
364
|
+
*/
|
|
365
|
+
declare function registerBackgroundSync(tag?: string): Promise<boolean>;
|
|
366
|
+
|
|
367
|
+
export { type Change, Collection, type CollectionOptions, type ConflictResolver, type Cursor, type IndexedDBStorageOptions, MemoryServer, type PullResult, type PushResult, type RestAdapterOptions, type StorageAdapter, type StorageTx, type StoredDoc, Sync, type SyncAdapter, type SyncConfig, type SyncEvents, type SyncStatus, createSync, docFromRemote, indexedDBStorage, isDirty, lastWriteWins, memoryAdapter, memoryStorage, registerBackgroundSync, restAdapter };
|