@byearlybird/starling 0.10.0 → 0.11.1

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.
@@ -0,0 +1,139 @@
1
+ import { a as AnyObject, s as JsonDocument } from "./index-D7bXWDg6.js";
2
+ import { g as SchemasMap, r as DatabasePlugin } from "./db-DJ_6dO-K.js";
3
+
4
+ //#region src/plugins/http/index.d.ts
5
+
6
+ /**
7
+ * Context provided to the onRequest hook
8
+ */
9
+ type RequestContext<T extends AnyObject = AnyObject> = {
10
+ collection: string;
11
+ operation: "GET" | "PATCH";
12
+ url: string;
13
+ document?: JsonDocument<T>;
14
+ };
15
+ /**
16
+ * Result returned by the onRequest hook
17
+ */
18
+ type RequestHookResult<T extends AnyObject = AnyObject> = {
19
+ skip: true;
20
+ } | {
21
+ headers?: Record<string, string>;
22
+ document?: JsonDocument<T>;
23
+ } | undefined;
24
+ /**
25
+ * Result returned by the onResponse hook
26
+ */
27
+ type ResponseHookResult<T extends AnyObject = AnyObject> = {
28
+ document: JsonDocument<T>;
29
+ } | {
30
+ skip: true;
31
+ } | undefined;
32
+ /**
33
+ * Configuration for the HTTP plugin
34
+ */
35
+ type HttpPluginConfig<_Schemas extends SchemasMap> = {
36
+ /**
37
+ * Base URL for the HTTP server (e.g., "https://api.example.com")
38
+ */
39
+ baseUrl: string;
40
+ /**
41
+ * Interval in milliseconds to poll for server updates
42
+ * @default 5000
43
+ */
44
+ pollingInterval?: number;
45
+ /**
46
+ * Delay in milliseconds to debounce local mutations before pushing
47
+ * @default 1000
48
+ */
49
+ debounceDelay?: number;
50
+ /**
51
+ * Hook called before each HTTP request
52
+ * Return { skip: true } to abort the request
53
+ * Return { headers } to add custom headers
54
+ * Return { document } to transform the document (PATCH only)
55
+ */
56
+ onRequest?: <T extends AnyObject>(context: RequestContext<T>) => RequestHookResult<T>;
57
+ /**
58
+ * Hook called after each successful HTTP response
59
+ * Return { skip: true } to skip merging the response
60
+ * Return { document } to transform the document before merging
61
+ */
62
+ onResponse?: <T extends AnyObject>(context: {
63
+ collection: string;
64
+ document: JsonDocument<T>;
65
+ }) => ResponseHookResult<T>;
66
+ /**
67
+ * Retry configuration for failed requests
68
+ */
69
+ retry?: {
70
+ /**
71
+ * Maximum number of retry attempts
72
+ * @default 3
73
+ */
74
+ maxAttempts?: number;
75
+ /**
76
+ * Initial delay in milliseconds before first retry
77
+ * @default 1000
78
+ */
79
+ initialDelay?: number;
80
+ /**
81
+ * Maximum delay in milliseconds between retries
82
+ * @default 30000
83
+ */
84
+ maxDelay?: number;
85
+ };
86
+ };
87
+ /**
88
+ * Create an HTTP sync plugin for Starling databases.
89
+ *
90
+ * The plugin:
91
+ * - Fetches all collections from the server on init (single attempt)
92
+ * - Polls the server at regular intervals to fetch updates (with retry)
93
+ * - Debounces local mutations and pushes them to the server (with retry)
94
+ * - Supports request/response hooks for authentication, encryption, etc.
95
+ *
96
+ * @param config - HTTP plugin configuration
97
+ * @returns A DatabasePlugin instance
98
+ *
99
+ * @example
100
+ * ```typescript
101
+ * const db = await createDatabase({
102
+ * name: "my-app",
103
+ * schema: {
104
+ * tasks: { schema: taskSchema, getId: (task) => task.id },
105
+ * },
106
+ * })
107
+ * .use(httpPlugin({
108
+ * baseUrl: "https://api.example.com",
109
+ * onRequest: () => ({
110
+ * headers: { Authorization: `Bearer ${token}` }
111
+ * })
112
+ * }))
113
+ * .init();
114
+ * ```
115
+ *
116
+ * @example With encryption
117
+ * ```typescript
118
+ * const db = await createDatabase({
119
+ * name: "my-app",
120
+ * schema: {
121
+ * tasks: { schema: taskSchema, getId: (task) => task.id },
122
+ * },
123
+ * })
124
+ * .use(httpPlugin({
125
+ * baseUrl: "https://api.example.com",
126
+ * onRequest: ({ document }) => ({
127
+ * headers: { Authorization: `Bearer ${token}` },
128
+ * document: document ? encrypt(document) : undefined
129
+ * }),
130
+ * onResponse: ({ document }) => ({
131
+ * document: decrypt(document)
132
+ * })
133
+ * }))
134
+ * .init();
135
+ * ```
136
+ */
137
+ declare function httpPlugin<Schemas extends SchemasMap>(config: HttpPluginConfig<Schemas>): DatabasePlugin<Schemas>;
138
+ //#endregion
139
+ export { HttpPluginConfig, RequestContext, RequestHookResult, ResponseHookResult, httpPlugin };
@@ -0,0 +1,191 @@
1
+ //#region src/plugins/http/index.ts
2
+ /**
3
+ * Create an HTTP sync plugin for Starling databases.
4
+ *
5
+ * The plugin:
6
+ * - Fetches all collections from the server on init (single attempt)
7
+ * - Polls the server at regular intervals to fetch updates (with retry)
8
+ * - Debounces local mutations and pushes them to the server (with retry)
9
+ * - Supports request/response hooks for authentication, encryption, etc.
10
+ *
11
+ * @param config - HTTP plugin configuration
12
+ * @returns A DatabasePlugin instance
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * const db = await createDatabase({
17
+ * name: "my-app",
18
+ * schema: {
19
+ * tasks: { schema: taskSchema, getId: (task) => task.id },
20
+ * },
21
+ * })
22
+ * .use(httpPlugin({
23
+ * baseUrl: "https://api.example.com",
24
+ * onRequest: () => ({
25
+ * headers: { Authorization: `Bearer ${token}` }
26
+ * })
27
+ * }))
28
+ * .init();
29
+ * ```
30
+ *
31
+ * @example With encryption
32
+ * ```typescript
33
+ * const db = await createDatabase({
34
+ * name: "my-app",
35
+ * schema: {
36
+ * tasks: { schema: taskSchema, getId: (task) => task.id },
37
+ * },
38
+ * })
39
+ * .use(httpPlugin({
40
+ * baseUrl: "https://api.example.com",
41
+ * onRequest: ({ document }) => ({
42
+ * headers: { Authorization: `Bearer ${token}` },
43
+ * document: document ? encrypt(document) : undefined
44
+ * }),
45
+ * onResponse: ({ document }) => ({
46
+ * document: decrypt(document)
47
+ * })
48
+ * }))
49
+ * .init();
50
+ * ```
51
+ */
52
+ function httpPlugin(config) {
53
+ const { baseUrl, pollingInterval = 5e3, debounceDelay = 1e3, onRequest, onResponse, retry = {} } = config;
54
+ const { maxAttempts = 3, initialDelay = 1e3, maxDelay = 3e4 } = retry;
55
+ let pollingTimer = null;
56
+ let unsubscribe = null;
57
+ const debounceTimers = /* @__PURE__ */ new Map();
58
+ return { handlers: {
59
+ async init(db) {
60
+ const collectionNames = db.collectionKeys();
61
+ for (const collectionName of collectionNames) try {
62
+ await fetchCollection(db, collectionName, baseUrl, onRequest, onResponse, false);
63
+ } catch (error) {
64
+ console.error(`Failed to fetch collection "${String(collectionName)}" during init:`, error);
65
+ }
66
+ pollingTimer = setInterval(async () => {
67
+ for (const collectionName of collectionNames) try {
68
+ await fetchCollection(db, collectionName, baseUrl, onRequest, onResponse, true, maxAttempts, initialDelay, maxDelay);
69
+ } catch (error) {
70
+ console.error(`Failed to poll collection "${String(collectionName)}":`, error);
71
+ }
72
+ }, pollingInterval);
73
+ unsubscribe = db.on("mutation", (event) => {
74
+ const collectionName = event.collection;
75
+ const key = String(collectionName);
76
+ const existingTimer = debounceTimers.get(key);
77
+ if (existingTimer) clearTimeout(existingTimer);
78
+ const timer = setTimeout(async () => {
79
+ debounceTimers.delete(key);
80
+ try {
81
+ await pushCollection(db, collectionName, baseUrl, onRequest, onResponse, maxAttempts, initialDelay, maxDelay);
82
+ } catch (error) {
83
+ console.error(`Failed to push collection "${String(collectionName)}":`, error);
84
+ }
85
+ }, debounceDelay);
86
+ debounceTimers.set(key, timer);
87
+ });
88
+ },
89
+ async dispose(_db) {
90
+ if (pollingTimer) {
91
+ clearInterval(pollingTimer);
92
+ pollingTimer = null;
93
+ }
94
+ for (const timer of debounceTimers.values()) clearTimeout(timer);
95
+ debounceTimers.clear();
96
+ if (unsubscribe) {
97
+ unsubscribe();
98
+ unsubscribe = null;
99
+ }
100
+ }
101
+ } };
102
+ }
103
+ /**
104
+ * Fetch a collection from the server (GET request)
105
+ */
106
+ async function fetchCollection(db, collectionName, baseUrl, onRequest, onResponse, enableRetry, maxAttempts = 3, initialDelay = 1e3, maxDelay = 3e4) {
107
+ const url = `${baseUrl}/${db.name}/${String(collectionName)}`;
108
+ const requestResult = onRequest?.({
109
+ collection: String(collectionName),
110
+ operation: "GET",
111
+ url
112
+ });
113
+ if (requestResult && "skip" in requestResult && requestResult.skip) return;
114
+ const headers = requestResult && "headers" in requestResult ? requestResult.headers : void 0;
115
+ const executeRequest = async () => {
116
+ const response = await fetch(url, {
117
+ method: "GET",
118
+ headers: {
119
+ "Content-Type": "application/json",
120
+ ...headers
121
+ }
122
+ });
123
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
124
+ const document = await response.json();
125
+ const responseResult = onResponse?.({
126
+ collection: String(collectionName),
127
+ document
128
+ });
129
+ if (responseResult && "skip" in responseResult && responseResult.skip) return;
130
+ const finalDocument = responseResult && "document" in responseResult ? responseResult.document : document;
131
+ db[collectionName].merge(finalDocument);
132
+ };
133
+ if (enableRetry) await withRetry(executeRequest, maxAttempts, initialDelay, maxDelay);
134
+ else await executeRequest();
135
+ }
136
+ /**
137
+ * Push a collection to the server (PATCH request)
138
+ */
139
+ async function pushCollection(db, collectionName, baseUrl, onRequest, onResponse, maxAttempts = 3, initialDelay = 1e3, maxDelay = 3e4) {
140
+ const url = `${baseUrl}/${db.name}/${String(collectionName)}`;
141
+ const document = db[collectionName].toDocument();
142
+ const requestResult = onRequest?.({
143
+ collection: String(collectionName),
144
+ operation: "PATCH",
145
+ url,
146
+ document
147
+ });
148
+ if (requestResult && "skip" in requestResult && requestResult.skip) return;
149
+ const headers = requestResult && "headers" in requestResult ? requestResult.headers : void 0;
150
+ const requestDocument = requestResult && "document" in requestResult ? requestResult.document : document;
151
+ const executeRequest = async () => {
152
+ const response = await fetch(url, {
153
+ method: "PATCH",
154
+ headers: {
155
+ "Content-Type": "application/json",
156
+ ...headers
157
+ },
158
+ body: JSON.stringify(requestDocument)
159
+ });
160
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
161
+ const responseDocument = await response.json();
162
+ const responseResult = onResponse?.({
163
+ collection: String(collectionName),
164
+ document: responseDocument
165
+ });
166
+ if (responseResult && "skip" in responseResult && responseResult.skip) return;
167
+ const finalDocument = responseResult && "document" in responseResult ? responseResult.document : responseDocument;
168
+ db[collectionName].merge(finalDocument);
169
+ };
170
+ await withRetry(executeRequest, maxAttempts, initialDelay, maxDelay);
171
+ }
172
+ /**
173
+ * Execute a function with exponential backoff retry logic
174
+ */
175
+ async function withRetry(fn, maxAttempts, initialDelay, maxDelay) {
176
+ let lastError;
177
+ let delay = initialDelay;
178
+ for (let attempt = 0; attempt < maxAttempts; attempt++) try {
179
+ return await fn();
180
+ } catch (error) {
181
+ lastError = error instanceof Error ? error : new Error(String(error));
182
+ if (attempt < maxAttempts - 1) {
183
+ await new Promise((resolve) => setTimeout(resolve, delay));
184
+ delay = Math.min(delay * 2, maxDelay);
185
+ }
186
+ }
187
+ throw lastError;
188
+ }
189
+
190
+ //#endregion
191
+ export { httpPlugin };
@@ -0,0 +1,59 @@
1
+ import "./index-D7bXWDg6.js";
2
+ import { r as DatabasePlugin } from "./db-DJ_6dO-K.js";
3
+
4
+ //#region src/plugins/idb/index.d.ts
5
+ type IdbPluginConfig = {
6
+ /**
7
+ * Version of the IndexedDB database
8
+ * @default 1
9
+ */
10
+ version?: number;
11
+ /**
12
+ * Use BroadcastChannel API for instant cross-tab sync
13
+ * @default true
14
+ */
15
+ useBroadcastChannel?: boolean;
16
+ };
17
+ /**
18
+ * Create an IndexedDB persistence plugin for Starling databases.
19
+ *
20
+ * The plugin:
21
+ * - Loads existing documents from IndexedDB on init
22
+ * - Persists all documents to IndexedDB on every mutation
23
+ * - Enables instant cross-tab sync via BroadcastChannel API
24
+ * - Gracefully closes the database connection on dispose
25
+ *
26
+ * Cross-tab sync uses the BroadcastChannel API to notify other tabs
27
+ * of changes in real-time. When a mutation occurs in one tab, other tabs
28
+ * are instantly notified and reload the data from IndexedDB.
29
+ *
30
+ * @param config - IndexedDB configuration
31
+ * @returns A DatabasePlugin instance
32
+ *
33
+ * @example
34
+ * ```typescript
35
+ * const db = await createDatabase({
36
+ * name: "my-app",
37
+ * schema: {
38
+ * tasks: { schema: taskSchema, getId: (task) => task.id },
39
+ * },
40
+ * })
41
+ * .use(idbPlugin())
42
+ * .init();
43
+ * ```
44
+ *
45
+ * @example Disable BroadcastChannel
46
+ * ```typescript
47
+ * const db = await createDatabase({
48
+ * name: "my-app",
49
+ * schema: {
50
+ * tasks: { schema: taskSchema, getId: (task) => task.id },
51
+ * },
52
+ * })
53
+ * .use(idbPlugin({ useBroadcastChannel: false }))
54
+ * .init();
55
+ * ```
56
+ */
57
+ declare function idbPlugin(config?: IdbPluginConfig): DatabasePlugin<any>;
58
+ //#endregion
59
+ export { IdbPluginConfig, idbPlugin };
@@ -0,0 +1,169 @@
1
+ //#region src/plugins/idb/index.ts
2
+ /**
3
+ * Create an IndexedDB persistence plugin for Starling databases.
4
+ *
5
+ * The plugin:
6
+ * - Loads existing documents from IndexedDB on init
7
+ * - Persists all documents to IndexedDB on every mutation
8
+ * - Enables instant cross-tab sync via BroadcastChannel API
9
+ * - Gracefully closes the database connection on dispose
10
+ *
11
+ * Cross-tab sync uses the BroadcastChannel API to notify other tabs
12
+ * of changes in real-time. When a mutation occurs in one tab, other tabs
13
+ * are instantly notified and reload the data from IndexedDB.
14
+ *
15
+ * @param config - IndexedDB configuration
16
+ * @returns A DatabasePlugin instance
17
+ *
18
+ * @example
19
+ * ```typescript
20
+ * const db = await createDatabase({
21
+ * name: "my-app",
22
+ * schema: {
23
+ * tasks: { schema: taskSchema, getId: (task) => task.id },
24
+ * },
25
+ * })
26
+ * .use(idbPlugin())
27
+ * .init();
28
+ * ```
29
+ *
30
+ * @example Disable BroadcastChannel
31
+ * ```typescript
32
+ * const db = await createDatabase({
33
+ * name: "my-app",
34
+ * schema: {
35
+ * tasks: { schema: taskSchema, getId: (task) => task.id },
36
+ * },
37
+ * })
38
+ * .use(idbPlugin({ useBroadcastChannel: false }))
39
+ * .init();
40
+ * ```
41
+ */
42
+ function idbPlugin(config = {}) {
43
+ const { version = 1, useBroadcastChannel = true } = config;
44
+ let dbInstance = null;
45
+ let unsubscribe = null;
46
+ let broadcastChannel = null;
47
+ const instanceId = crypto.randomUUID();
48
+ return { handlers: {
49
+ async init(db) {
50
+ const collectionNames = db.collectionKeys();
51
+ dbInstance = await openDatabase(db.name, version, collectionNames);
52
+ const savedDocs = await loadDocuments(dbInstance, collectionNames);
53
+ for (const collectionName of Object.keys(savedDocs)) {
54
+ const doc = savedDocs[collectionName];
55
+ const collection = db[collectionName];
56
+ if (doc && collection) collection.merge(doc);
57
+ }
58
+ unsubscribe = db.on("mutation", async () => {
59
+ if (dbInstance) {
60
+ const docs = db.toDocuments();
61
+ await saveDocuments(dbInstance, docs);
62
+ if (broadcastChannel) broadcastChannel.postMessage({
63
+ type: "mutation",
64
+ instanceId,
65
+ timestamp: Date.now()
66
+ });
67
+ }
68
+ });
69
+ if (useBroadcastChannel && typeof BroadcastChannel !== "undefined") {
70
+ broadcastChannel = new BroadcastChannel(`starling:${db.name}`);
71
+ broadcastChannel.onmessage = async (event) => {
72
+ if (event.data.instanceId === instanceId) return;
73
+ if (event.data.type === "mutation" && dbInstance) {
74
+ const savedDocs$1 = await loadDocuments(dbInstance, collectionNames);
75
+ for (const collectionName of Object.keys(savedDocs$1)) {
76
+ const doc = savedDocs$1[collectionName];
77
+ const collection = db[collectionName];
78
+ if (doc && collection) collection.merge(doc);
79
+ }
80
+ }
81
+ };
82
+ }
83
+ },
84
+ async dispose(db) {
85
+ if (broadcastChannel) {
86
+ broadcastChannel.close();
87
+ broadcastChannel = null;
88
+ }
89
+ if (unsubscribe) {
90
+ unsubscribe();
91
+ unsubscribe = null;
92
+ }
93
+ if (dbInstance) {
94
+ const docs = db.toDocuments();
95
+ await saveDocuments(dbInstance, docs);
96
+ dbInstance.close();
97
+ dbInstance = null;
98
+ }
99
+ }
100
+ } };
101
+ }
102
+ /**
103
+ * Open an IndexedDB database and create object stores for each collection
104
+ */
105
+ function openDatabase(dbName, version, collectionNames) {
106
+ return new Promise((resolve, reject) => {
107
+ const request = indexedDB.open(dbName, version);
108
+ request.onerror = () => {
109
+ reject(/* @__PURE__ */ new Error(`Failed to open IndexedDB: ${request.error?.message}`));
110
+ };
111
+ request.onsuccess = () => {
112
+ resolve(request.result);
113
+ };
114
+ request.onupgradeneeded = (event) => {
115
+ const db = event.target.result;
116
+ for (const collectionName of collectionNames) if (!db.objectStoreNames.contains(collectionName)) db.createObjectStore(collectionName);
117
+ };
118
+ });
119
+ }
120
+ /**
121
+ * Load documents from IndexedDB for all collections
122
+ */
123
+ async function loadDocuments(db, collectionNames) {
124
+ const documents = {};
125
+ for (const collectionName of collectionNames) if (db.objectStoreNames.contains(collectionName)) {
126
+ const doc = await getFromStore(db, collectionName, "document");
127
+ if (doc) documents[collectionName] = doc;
128
+ }
129
+ return documents;
130
+ }
131
+ /**
132
+ * Save documents to IndexedDB for all collections
133
+ */
134
+ async function saveDocuments(db, documents) {
135
+ const promises = [];
136
+ for (const collectionName of Object.keys(documents)) if (db.objectStoreNames.contains(collectionName)) promises.push(putToStore(db, collectionName, "document", documents[collectionName]));
137
+ await Promise.all(promises);
138
+ }
139
+ /**
140
+ * Get a value from an IndexedDB object store
141
+ */
142
+ function getFromStore(db, storeName, key) {
143
+ return new Promise((resolve, reject) => {
144
+ const request = db.transaction(storeName, "readonly").objectStore(storeName).get(key);
145
+ request.onerror = () => {
146
+ reject(/* @__PURE__ */ new Error(`Failed to get from store ${storeName}: ${request.error?.message}`));
147
+ };
148
+ request.onsuccess = () => {
149
+ resolve(request.result ?? null);
150
+ };
151
+ });
152
+ }
153
+ /**
154
+ * Put a value into an IndexedDB object store
155
+ */
156
+ function putToStore(db, storeName, key, value) {
157
+ return new Promise((resolve, reject) => {
158
+ const request = db.transaction(storeName, "readwrite").objectStore(storeName).put(value, key);
159
+ request.onerror = () => {
160
+ reject(/* @__PURE__ */ new Error(`Failed to put to store ${storeName}: ${request.error?.message}`));
161
+ };
162
+ request.onsuccess = () => {
163
+ resolve();
164
+ };
165
+ });
166
+ }
167
+
168
+ //#endregion
169
+ export { idbPlugin };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@byearlybird/starling",
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.",
3
+ "version": "0.11.1",
4
+ "description": "Local-first data sync for JavaScript apps. Typed collections, transactions, and automatic conflict resolution.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "main": "./dist/index.js",
@@ -11,6 +11,21 @@
11
11
  "types": "./dist/index.d.ts",
12
12
  "import": "./dist/index.js",
13
13
  "default": "./dist/index.js"
14
+ },
15
+ "./core": {
16
+ "types": "./dist/core.d.ts",
17
+ "import": "./dist/core.js",
18
+ "default": "./dist/core.js"
19
+ },
20
+ "./plugin-idb": {
21
+ "types": "./dist/plugin-idb.d.ts",
22
+ "import": "./dist/plugin-idb.js",
23
+ "default": "./dist/plugin-idb.js"
24
+ },
25
+ "./plugin-http": {
26
+ "types": "./dist/plugin-http.d.ts",
27
+ "import": "./dist/plugin-http.js",
28
+ "default": "./dist/plugin-http.js"
14
29
  }
15
30
  },
16
31
  "files": [
@@ -22,5 +37,10 @@
22
37
  },
23
38
  "publishConfig": {
24
39
  "access": "public"
40
+ },
41
+ "devDependencies": {
42
+ "fake-indexeddb": "^6.0.0",
43
+ "happy-dom": "^20.0.10",
44
+ "zod": "^4.1.12"
25
45
  }
26
46
  }