@byearlybird/starling 0.9.3 → 0.11.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/dist/core-DI0FfUjX.js +423 -0
- package/dist/core.d.ts +2 -0
- package/dist/core.js +3 -0
- package/dist/db-qQgPYE41.d.ts +199 -0
- package/dist/index-D7bXWDg6.d.ts +270 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +405 -580
- package/dist/plugin-http.d.ts +139 -0
- package/dist/plugin-http.js +191 -0
- package/dist/plugin-idb.d.ts +59 -0
- package/dist/plugin-idb.js +169 -0
- package/package.json +21 -13
- package/dist/plugins/unstorage/plugin.d.ts +0 -54
- package/dist/plugins/unstorage/plugin.js +0 -104
- package/dist/store-bS1Nb57l.d.ts +0 -365
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { a as AnyObject, s as JsonDocument } from "./index-D7bXWDg6.js";
|
|
2
|
+
import { h as SchemasMap, r as DatabasePlugin } from "./db-qQgPYE41.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-qQgPYE41.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,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@byearlybird/starling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
|
+
"description": "Local-first data sync for JavaScript apps. Typed collections, transactions, and automatic conflict resolution.",
|
|
4
5
|
"type": "module",
|
|
5
6
|
"license": "MIT",
|
|
6
7
|
"main": "./dist/index.js",
|
|
@@ -11,10 +12,20 @@
|
|
|
11
12
|
"import": "./dist/index.js",
|
|
12
13
|
"default": "./dist/index.js"
|
|
13
14
|
},
|
|
14
|
-
"./
|
|
15
|
-
"types": "./dist/
|
|
16
|
-
"import": "./dist/
|
|
17
|
-
"default": "./dist/
|
|
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"
|
|
18
29
|
}
|
|
19
30
|
},
|
|
20
31
|
"files": [
|
|
@@ -24,15 +35,12 @@
|
|
|
24
35
|
"build": "bun run build.ts",
|
|
25
36
|
"prepublishOnly": "bun run build.ts"
|
|
26
37
|
},
|
|
27
|
-
"peerDependencies": {
|
|
28
|
-
"unstorage": "^1.17.1"
|
|
29
|
-
},
|
|
30
|
-
"peerDependenciesMeta": {
|
|
31
|
-
"unstorage": {
|
|
32
|
-
"optional": true
|
|
33
|
-
}
|
|
34
|
-
},
|
|
35
38
|
"publishConfig": {
|
|
36
39
|
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"fake-indexeddb": "^6.0.0",
|
|
43
|
+
"happy-dom": "^20.0.10",
|
|
44
|
+
"zod": "^4.1.12"
|
|
37
45
|
}
|
|
38
46
|
}
|
|
@@ -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 };
|