@byearlybird/starling 0.9.3 → 0.10.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/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@byearlybird/starling",
3
- "version": "0.9.3",
3
+ "version": "0.10.0",
4
+ "description": "Lightweight local-first data store with automatic cross-device sync. Plain JavaScript queries, framework-agnostic, zero dependencies. Offline-first apps without the complexity, in just 4KB.",
4
5
  "type": "module",
5
6
  "license": "MIT",
6
7
  "main": "./dist/index.js",
@@ -10,11 +11,6 @@
10
11
  "types": "./dist/index.d.ts",
11
12
  "import": "./dist/index.js",
12
13
  "default": "./dist/index.js"
13
- },
14
- "./plugin-unstorage": {
15
- "types": "./dist/plugins/unstorage/plugin.d.ts",
16
- "import": "./dist/plugins/unstorage/plugin.js",
17
- "default": "./dist/plugins/unstorage/plugin.js"
18
14
  }
19
15
  },
20
16
  "files": [
@@ -24,14 +20,6 @@
24
20
  "build": "bun run build.ts",
25
21
  "prepublishOnly": "bun run build.ts"
26
22
  },
27
- "peerDependencies": {
28
- "unstorage": "^1.17.1"
29
- },
30
- "peerDependenciesMeta": {
31
- "unstorage": {
32
- "optional": true
33
- }
34
- },
35
23
  "publishConfig": {
36
24
  "access": "public"
37
25
  }
@@ -1,54 +0,0 @@
1
- import { c as Collection, t as Plugin } from "../../store-bS1Nb57l.js";
2
- import { Storage } from "unstorage";
3
-
4
- //#region src/plugins/unstorage/plugin.d.ts
5
- type MaybePromise<T> = T | Promise<T>;
6
- type UnstorageOnBeforeSet = (data: Collection) => MaybePromise<Collection>;
7
- type UnstorageOnAfterGet = (data: Collection) => MaybePromise<Collection>;
8
- /**
9
- * Configuration options for the unstorage persistence plugin.
10
- */
11
- type UnstorageConfig = {
12
- /** Delay in ms to collapse rapid mutations into a single write. Default: 0 (immediate) */
13
- debounceMs?: number;
14
- /** Interval in ms to poll storage for external changes. When set, enables automatic sync. */
15
- pollIntervalMs?: number;
16
- /** Hook invoked before persisting to storage. Use for encryption, compression, etc. */
17
- onBeforeSet?: UnstorageOnBeforeSet;
18
- /** Hook invoked after loading from storage. Use for decryption, validation, etc. */
19
- onAfterGet?: UnstorageOnAfterGet;
20
- /** Function that returns true to skip persistence operations. Use for conditional sync. */
21
- skip?: () => boolean;
22
- };
23
- /**
24
- * Persistence plugin for Starling using unstorage backends.
25
- *
26
- * Automatically persists store snapshots and optionally polls for external changes.
27
- *
28
- * @param key - Storage key for this dataset
29
- * @param storage - Unstorage instance (localStorage, HTTP, filesystem, etc.)
30
- * @param config - Optional configuration for debouncing, polling, hooks, and conditional sync
31
- * @returns Plugin instance for store.use()
32
- *
33
- * @example
34
- * ```ts
35
- * import { unstoragePlugin } from "@byearlybird/starling/plugin-unstorage";
36
- * import { createStorage } from "unstorage";
37
- * import localStorageDriver from "unstorage/drivers/localstorage";
38
- *
39
- * const store = await new Store<Todo>()
40
- * .use(unstoragePlugin('todos', createStorage({
41
- * driver: localStorageDriver({ base: 'app:' })
42
- * }), {
43
- * debounceMs: 300,
44
- * pollIntervalMs: 5000,
45
- * skip: () => !navigator.onLine
46
- * }))
47
- * .init();
48
- * ```
49
- *
50
- * @see {@link ../../../../docs/plugins/unstorage.md} for detailed configuration guide
51
- */
52
- declare function unstoragePlugin<T>(key: string, storage: Storage<Collection>, config?: UnstorageConfig): Plugin<T>;
53
- //#endregion
54
- export { type UnstorageConfig, unstoragePlugin };
@@ -1,104 +0,0 @@
1
- //#region src/plugins/unstorage/plugin.ts
2
- /**
3
- * Persistence plugin for Starling using unstorage backends.
4
- *
5
- * Automatically persists store snapshots and optionally polls for external changes.
6
- *
7
- * @param key - Storage key for this dataset
8
- * @param storage - Unstorage instance (localStorage, HTTP, filesystem, etc.)
9
- * @param config - Optional configuration for debouncing, polling, hooks, and conditional sync
10
- * @returns Plugin instance for store.use()
11
- *
12
- * @example
13
- * ```ts
14
- * import { unstoragePlugin } from "@byearlybird/starling/plugin-unstorage";
15
- * import { createStorage } from "unstorage";
16
- * import localStorageDriver from "unstorage/drivers/localstorage";
17
- *
18
- * const store = await new Store<Todo>()
19
- * .use(unstoragePlugin('todos', createStorage({
20
- * driver: localStorageDriver({ base: 'app:' })
21
- * }), {
22
- * debounceMs: 300,
23
- * pollIntervalMs: 5000,
24
- * skip: () => !navigator.onLine
25
- * }))
26
- * .init();
27
- * ```
28
- *
29
- * @see {@link ../../../../docs/plugins/unstorage.md} for detailed configuration guide
30
- */
31
- function unstoragePlugin(key, storage, config = {}) {
32
- const { debounceMs = 0, pollIntervalMs, onBeforeSet, onAfterGet, skip } = config;
33
- let debounceTimer = null;
34
- let pollInterval = null;
35
- let store = null;
36
- let persistPromise = null;
37
- const persistSnapshot = async () => {
38
- if (!store) return;
39
- const data = store.collection();
40
- const persisted = onBeforeSet !== void 0 ? await onBeforeSet(data) : data;
41
- await storage.set(key, persisted);
42
- };
43
- const runPersist = async () => {
44
- debounceTimer = null;
45
- persistPromise = persistSnapshot();
46
- await persistPromise;
47
- persistPromise = null;
48
- };
49
- const schedulePersist = () => {
50
- if (skip?.()) return;
51
- if (debounceMs === 0) {
52
- persistPromise = persistSnapshot().finally(() => {
53
- persistPromise = null;
54
- });
55
- return;
56
- }
57
- if (debounceTimer !== null) clearTimeout(debounceTimer);
58
- debounceTimer = setTimeout(() => {
59
- runPersist();
60
- }, debounceMs);
61
- };
62
- const pollStorage = async () => {
63
- if (!store) return;
64
- if (skip?.()) return;
65
- const persisted = await storage.get(key);
66
- if (!persisted) return;
67
- const data = onAfterGet !== void 0 ? await onAfterGet(persisted) : persisted;
68
- store.merge(data);
69
- };
70
- return {
71
- onInit: async (s) => {
72
- store = s;
73
- await pollStorage();
74
- if (pollIntervalMs !== void 0 && pollIntervalMs > 0) pollInterval = setInterval(() => {
75
- pollStorage();
76
- }, pollIntervalMs);
77
- },
78
- onDispose: async () => {
79
- if (debounceTimer !== null) {
80
- clearTimeout(debounceTimer);
81
- debounceTimer = null;
82
- await runPersist();
83
- }
84
- if (pollInterval !== null) {
85
- clearInterval(pollInterval);
86
- pollInterval = null;
87
- }
88
- if (persistPromise !== null) await persistPromise;
89
- store = null;
90
- },
91
- onAdd: () => {
92
- schedulePersist();
93
- },
94
- onUpdate: () => {
95
- schedulePersist();
96
- },
97
- onDelete: () => {
98
- schedulePersist();
99
- }
100
- };
101
- }
102
-
103
- //#endregion
104
- export { unstoragePlugin };
@@ -1,365 +0,0 @@
1
- //#region src/crdt/value.d.ts
2
- /**
3
- * A primitive value wrapped with its eventstamp for Last-Write-Wins conflict resolution.
4
- * Used as the leaf nodes in the CRDT data structure.
5
- *
6
- * @template T - The type of the wrapped value (primitive or complex type)
7
- */
8
- type EncodedValue<T> = {
9
- /** The actual value being stored */
10
- "~value": T;
11
- /** The eventstamp indicating when this value was last written (ISO|counter|nonce) */
12
- "~eventstamp": string;
13
- };
14
- //#endregion
15
- //#region src/crdt/record.d.ts
16
- /**
17
- * A nested object structure where each field is either an EncodedValue (leaf)
18
- * or another EncodedRecord (nested object). This enables field-level
19
- * Last-Write-Wins merging for complex data structures.
20
- *
21
- * Each field maintains its own eventstamp, allowing concurrent updates to
22
- * different fields to be preserved during merge operations.
23
- */
24
- type EncodedRecord = {
25
- [key: string]: EncodedValue<unknown> | EncodedRecord;
26
- };
27
- //#endregion
28
- //#region src/crdt/document.d.ts
29
- /**
30
- * Top-level document structure with system metadata for tracking identity,
31
- * data, and deletion state. Documents are the primary unit of storage and
32
- * synchronization in Starling.
33
- *
34
- * The tilde prefix (~) distinguishes system metadata from user-defined data.
35
- */
36
- type EncodedDocument = {
37
- /** Unique identifier for this document */
38
- "~id": string;
39
- /** The document's data, either a primitive value or nested object structure */
40
- "~data": EncodedValue<unknown> | EncodedRecord;
41
- /** Eventstamp when this document was soft-deleted, or null if not deleted */
42
- "~deletedAt": string | null;
43
- };
44
- /**
45
- * Transform all values in a document using a provided function.
46
- *
47
- * Useful for custom serialization in plugin hooks (encryption, compression, etc.)
48
- *
49
- * @param doc - Document to transform
50
- * @param process - Function to apply to each leaf value
51
- * @returns New document with transformed values
52
- *
53
- * @example
54
- * ```ts
55
- * // Encrypt all values before persisting
56
- * const encrypted = processDocument(doc, (value) => ({
57
- * ...value,
58
- * "~value": encrypt(value["~value"])
59
- * }));
60
- * ```
61
- */
62
- declare function processDocument(doc: EncodedDocument, process: (value: EncodedValue<unknown>) => EncodedValue<unknown>): EncodedDocument;
63
- //#endregion
64
- //#region src/crdt/collection.d.ts
65
- /**
66
- * A collection represents the complete state of a store:
67
- * - A set of documents (including soft-deleted ones)
68
- * - The highest eventstamp observed across all operations
69
- *
70
- * Collections are the unit of synchronization between store replicas.
71
- */
72
- type Collection = {
73
- /** Array of encoded documents with eventstamps and metadata */
74
- "~docs": EncodedDocument[];
75
- /** Latest eventstamp observed by this collection for clock synchronization */
76
- "~eventstamp": string;
77
- };
78
- /**
79
- * Change tracking information returned by mergeCollections.
80
- * Categorizes documents by mutation type for hook notifications.
81
- */
82
- type CollectionChanges = {
83
- /** Documents that were newly added (didn't exist before or were previously deleted) */
84
- added: Map<string, EncodedDocument>;
85
- /** Documents that were modified (existed before and changed) */
86
- updated: Map<string, EncodedDocument>;
87
- /** Documents that were deleted (newly marked with ~deletedAt) */
88
- deleted: Set<string>;
89
- };
90
- /**
91
- * Result of merging two collections.
92
- */
93
- type MergeCollectionsResult = {
94
- /** The merged collection with updated documents and forwarded clock */
95
- collection: Collection;
96
- /** Change tracking for plugin hook notifications */
97
- changes: CollectionChanges;
98
- };
99
- /**
100
- * Merges two collections using field-level Last-Write-Wins semantics.
101
- *
102
- * The merge operation:
103
- * 1. Forwards the clock to the newest eventstamp from either collection
104
- * 2. Merges each document pair using field-level LWW (via mergeDocs)
105
- * 3. Tracks what changed for hook notifications (added/updated/deleted)
106
- *
107
- * Deletion is final: once a document is deleted, updates to it are merged into
108
- * the document's data but don't restore visibility. Only new documents or
109
- * transitions into the deleted state are tracked.
110
- *
111
- * @param into - The base collection to merge into
112
- * @param from - The source collection to merge from
113
- * @returns Merged collection and categorized changes
114
- *
115
- * @example
116
- * ```typescript
117
- * const into = {
118
- * "~docs": [{ "~id": "doc1", "~data": {...}, "~deletedAt": null }],
119
- * "~eventstamp": "2025-01-01T00:00:00.000Z|0001|a1b2"
120
- * };
121
- *
122
- * const from = {
123
- * "~docs": [
124
- * { "~id": "doc1", "~data": {...}, "~deletedAt": null }, // updated
125
- * { "~id": "doc2", "~data": {...}, "~deletedAt": null } // new
126
- * ],
127
- * "~eventstamp": "2025-01-01T00:05:00.000Z|0001|c3d4"
128
- * };
129
- *
130
- * const result = mergeCollections(into, from);
131
- * // result.collection.~eventstamp === "2025-01-01T00:05:00.000Z|0001|c3d4"
132
- * // result.changes.added has "doc2"
133
- * // result.changes.updated has "doc1"
134
- * ```
135
- */
136
- declare function mergeCollections(into: Collection, from: Collection): MergeCollectionsResult;
137
- //#endregion
138
- //#region src/store.d.ts
139
- type NotPromise<T> = T extends Promise<any> ? never : T;
140
- type DeepPartial<T> = T extends object ? { [P in keyof T]?: DeepPartial<T[P]> } : T;
141
- /**
142
- * Options for adding documents to the store.
143
- */
144
- type StoreAddOptions = {
145
- /** Provide a custom ID instead of generating one */
146
- withId?: string;
147
- };
148
- /**
149
- * Configuration options for creating a Store instance.
150
- */
151
- type StoreConfig = {
152
- /** Custom ID generator. Defaults to crypto.randomUUID() */
153
- getId?: () => string;
154
- };
155
- /**
156
- * Transaction context for batching multiple operations with rollback support.
157
- *
158
- * Transactions allow you to group multiple mutations together and optionally
159
- * abort all changes if validation fails.
160
- *
161
- * @example
162
- * ```ts
163
- * store.begin((tx) => {
164
- * const id = tx.add({ name: 'Alice' });
165
- * if (!isValid(tx.get(id))) {
166
- * tx.rollback(); // Abort all changes
167
- * }
168
- * });
169
- * ```
170
- */
171
- type StoreSetTransaction<T> = {
172
- /** Add a document and return its ID */
173
- add: (value: T, options?: StoreAddOptions) => string;
174
- /** Update a document with a partial value (field-level merge) */
175
- update: (key: string, value: DeepPartial<T>) => void;
176
- /** Merge an encoded document (used by sync/persistence plugins) */
177
- merge: (doc: EncodedDocument) => void;
178
- /** Soft-delete a document */
179
- del: (key: string) => void;
180
- /** Get a document within this transaction */
181
- get: (key: string) => T | null;
182
- /** Abort the transaction and discard all changes */
183
- rollback: () => void;
184
- };
185
- /**
186
- * Plugin interface for extending store behavior with persistence, analytics, etc.
187
- *
188
- * All hooks are optional. Mutation hooks receive batched entries after each
189
- * transaction commits.
190
- *
191
- * @example
192
- * ```ts
193
- * const loggingPlugin: Plugin<Todo> = {
194
- * onInit: async () => console.log('Store initialized'),
195
- * onAdd: (entries) => console.log('Added:', entries.length),
196
- * onDispose: async () => console.log('Store disposed')
197
- * };
198
- * ```
199
- */
200
- type Plugin<T> = {
201
- /** Called once when store.init() runs */
202
- onInit: (store: Store<T>) => Promise<void> | void;
203
- /** Called once when store.dispose() runs */
204
- onDispose: () => Promise<void> | void;
205
- /** Called after documents are added (batched per transaction) */
206
- onAdd?: (entries: ReadonlyArray<readonly [string, T]>) => void;
207
- /** Called after documents are updated (batched per transaction) */
208
- onUpdate?: (entries: ReadonlyArray<readonly [string, T]>) => void;
209
- /** Called after documents are deleted (batched per transaction) */
210
- onDelete?: (keys: ReadonlyArray<string>) => void;
211
- };
212
- /**
213
- * Configuration for creating a reactive query.
214
- *
215
- * Queries automatically update when matching documents change.
216
- *
217
- * @example
218
- * ```ts
219
- * const config: QueryConfig<Todo> = {
220
- * where: (todo) => !todo.completed,
221
- * select: (todo) => todo.text,
222
- * order: (a, b) => a.localeCompare(b)
223
- * };
224
- * ```
225
- */
226
- type QueryConfig<T, U = T> = {
227
- /** Filter predicate - return true to include document in results */
228
- where: (data: T) => boolean;
229
- /** Optional projection - transform documents before returning */
230
- select?: (data: T) => U;
231
- /** Optional comparator for stable ordering of results */
232
- order?: (a: U, b: U) => number;
233
- };
234
- /**
235
- * A reactive query handle that tracks matching documents and notifies on changes.
236
- *
237
- * Call `dispose()` when done to clean up listeners and remove from the store.
238
- */
239
- type Query<U> = {
240
- /** Get current matching documents as [id, document] tuples */
241
- results: () => Array<readonly [string, U]>;
242
- /** Register a change listener. Returns unsubscribe function. */
243
- onChange: (callback: () => void) => () => void;
244
- /** Remove this query from the store and clear all listeners */
245
- dispose: () => void;
246
- };
247
- /**
248
- * Lightweight local-first data store with built-in sync and reactive queries.
249
- *
250
- * Stores plain JavaScript objects with automatic field-level conflict resolution
251
- * using Last-Write-Wins semantics powered by hybrid logical clocks.
252
- *
253
- * @template T - The type of documents stored in this collection
254
- *
255
- * @example
256
- * ```ts
257
- * const store = await new Store<{ text: string; completed: boolean }>()
258
- * .use(unstoragePlugin('todos', storage))
259
- * .init();
260
- *
261
- * // Add, update, delete
262
- * const id = store.add({ text: 'Buy milk', completed: false });
263
- * store.update(id, { completed: true });
264
- * store.del(id);
265
- *
266
- * // Reactive queries
267
- * const activeTodos = store.query({ where: (todo) => !todo.completed });
268
- * activeTodos.onChange(() => console.log('Todos changed!'));
269
- * ```
270
- */
271
- declare class Store<T> {
272
- #private;
273
- constructor(config?: StoreConfig);
274
- /**
275
- * Get a document by ID.
276
- * @returns The document, or null if not found or deleted
277
- */
278
- get(key: string): T | null;
279
- /**
280
- * Iterate over all non-deleted documents as [id, document] tuples.
281
- */
282
- entries(): IterableIterator<readonly [string, T]>;
283
- /**
284
- * Get the complete store state as a Collection for persistence or sync.
285
- * @returns Collection containing all documents and the latest eventstamp
286
- */
287
- collection(): Collection;
288
- /**
289
- * Merge a collection from storage or another replica using field-level LWW.
290
- * @param collection - Collection from storage or another store instance
291
- */
292
- merge(collection: Collection): void;
293
- /**
294
- * Run multiple operations in a transaction with rollback support.
295
- *
296
- * @param callback - Function receiving a transaction context
297
- * @param opts - Optional config. Use `silent: true` to skip plugin hooks.
298
- * @returns The callback's return value
299
- *
300
- * @example
301
- * ```ts
302
- * const id = store.begin((tx) => {
303
- * const newId = tx.add({ text: 'Buy milk' });
304
- * tx.update(newId, { priority: 'high' });
305
- * return newId; // Return value becomes begin()'s return value
306
- * });
307
- * ```
308
- */
309
- begin<R = void>(callback: (tx: StoreSetTransaction<T>) => NotPromise<R>, opts?: {
310
- silent?: boolean;
311
- }): NotPromise<R>;
312
- /**
313
- * Add a document to the store.
314
- * @returns The document's ID (generated or provided via options)
315
- */
316
- add(value: T, options?: StoreAddOptions): string;
317
- /**
318
- * Update a document with a partial value.
319
- *
320
- * Uses field-level merge - only specified fields are updated.
321
- */
322
- update(key: string, value: DeepPartial<T>): void;
323
- /**
324
- * Soft-delete a document.
325
- *
326
- * Deleted docs remain in snapshots for sync purposes but are
327
- * excluded from queries and reads.
328
- */
329
- del(key: string): void;
330
- /**
331
- * Register a plugin for persistence, analytics, etc.
332
- * @returns This store instance for chaining
333
- */
334
- use(plugin: Plugin<T>): this;
335
- /**
336
- * Initialize the store and run plugin onInit hooks.
337
- *
338
- * Must be called before using the store. Runs plugin setup (hydrate
339
- * snapshots, start pollers, etc.) and hydrates existing queries.
340
- *
341
- * @returns This store instance for chaining
342
- */
343
- init(): Promise<this>;
344
- /**
345
- * Dispose the store and run plugin cleanup.
346
- *
347
- * Flushes pending operations, clears queries, and runs plugin teardown.
348
- * Call when shutting down to avoid memory leaks.
349
- */
350
- dispose(): Promise<void>;
351
- /**
352
- * Create a reactive query that auto-updates when matching docs change.
353
- *
354
- * @example
355
- * ```ts
356
- * const active = store.query({ where: (todo) => !todo.completed });
357
- * active.results(); // [[id, todo], ...]
358
- * active.onChange(() => console.log('Updated!'));
359
- * active.dispose(); // Clean up when done
360
- * ```
361
- */
362
- query<U = T>(config: QueryConfig<T, U>): Query<U>;
363
- }
364
- //#endregion
365
- export { StoreAddOptions as a, Collection as c, processDocument as d, EncodedRecord as f, Store as i, mergeCollections as l, Query as n, StoreConfig as o, EncodedValue as p, QueryConfig as r, StoreSetTransaction as s, Plugin as t, EncodedDocument as u };