@omnistreamai/data-core 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/src/store.ts ADDED
@@ -0,0 +1,469 @@
1
+ import {
2
+ type LocalAdapter,
3
+ type RemoteAdapter,
4
+ type QueryOptions,
5
+ type QueryResult,
6
+ } from "./types.js";
7
+
8
+ /**
9
+ * Store configuration
10
+ */
11
+ export interface StoreOptions<_T extends Record<string, unknown>> {
12
+ localAdapter: LocalAdapter | null;
13
+ remoteAdapter: RemoteAdapter | null;
14
+ idKey: string;
15
+ indexes?: string[];
16
+ /**
17
+ * Maximum number of local storage entries
18
+ * When local storage entries exceed this limit, oldest entries will be automatically deleted
19
+ * Can be used with sortByKey parameter to specify sorting field for deletion order
20
+ * If not set, no limit on local storage entries
21
+ */
22
+ maxLocalEntries?: number;
23
+ /**
24
+ * Field name for sorting when maxLocalEntries is in effect
25
+ * If this field is specified, entries will be sorted by this field's value before deleting oldest entries
26
+ * If not specified, entries will be deleted in default array order (usually insertion order)
27
+ * Supports number and string type field values
28
+ */
29
+ sortByKey?: string;
30
+ onChange?: () => void;
31
+ notThrowLocalError: boolean;
32
+ }
33
+
34
+ /**
35
+ * Store class
36
+ */
37
+ export class Store<T extends Record<string, unknown>> {
38
+ private storeName: string;
39
+ private localAdapter: LocalAdapter | null;
40
+ private remoteAdapter: RemoteAdapter | null;
41
+ private idKey: string;
42
+ private indexes?: string[];
43
+ private maxLocalEntries?: number;
44
+ private sortByKey?: string;
45
+ private onChange?: () => void;
46
+ private notThrowLocalError: boolean;
47
+ private initialized = false;
48
+
49
+ constructor(storeName: string, options: StoreOptions<T>) {
50
+ this.storeName = storeName;
51
+ this.localAdapter = options.localAdapter;
52
+ this.remoteAdapter = options.remoteAdapter;
53
+ this.idKey = options.idKey;
54
+ this.indexes = options.indexes;
55
+ this.maxLocalEntries = options.maxLocalEntries;
56
+ this.sortByKey = options.sortByKey;
57
+ this.onChange = options.onChange;
58
+ this.notThrowLocalError = options.notThrowLocalError;
59
+ }
60
+
61
+ /**
62
+ * Initialize Store
63
+ */
64
+ async init(): Promise<void> {
65
+ if (this.initialized) {
66
+ return;
67
+ }
68
+
69
+ const promises: Promise<void | null>[] = [];
70
+
71
+ if (this.localAdapter) {
72
+ promises.push(
73
+ this.executeLocal(() =>
74
+ this.localAdapter!.initStore(
75
+ this.storeName,
76
+ this.indexes,
77
+ this.idKey,
78
+ ),
79
+ ),
80
+ );
81
+ }
82
+
83
+ if (this.remoteAdapter) {
84
+ promises.push(
85
+ this.executeRemote(() =>
86
+ this.remoteAdapter!.initStore(
87
+ this.storeName,
88
+ this.indexes,
89
+ this.idKey,
90
+ ),
91
+ ),
92
+ );
93
+ }
94
+
95
+ await Promise.all(promises);
96
+ this.initialized = true;
97
+ }
98
+
99
+ private async ensureInitialized(): Promise<void> {
100
+ if (!this.initialized) {
101
+ await this.init();
102
+ }
103
+ }
104
+
105
+ private triggerChange(): void {
106
+ if (this.onChange) {
107
+ this.onChange();
108
+ }
109
+ }
110
+
111
+ private async executeLocal<TResult>(
112
+ operation: () => Promise<TResult>,
113
+ ): Promise<TResult | null> {
114
+ if (!this.localAdapter) {
115
+ return null;
116
+ }
117
+
118
+ try {
119
+ return await operation();
120
+ } catch (error) {
121
+ if (this.notThrowLocalError) {
122
+ console.warn("Local adapter error:", error);
123
+ return null;
124
+ }
125
+ throw error;
126
+ }
127
+ }
128
+
129
+ private async executeRemote<TResult>(
130
+ operation: () => Promise<TResult>,
131
+ ): Promise<TResult> {
132
+ return await operation();
133
+ }
134
+
135
+ /**
136
+ * Enforce maximum local storage entries limit
137
+ */
138
+ private async enforceMaxLocalEntries(): Promise<void> {
139
+ if (!this.localAdapter || !this.maxLocalEntries) {
140
+ return;
141
+ }
142
+
143
+ try {
144
+ let currentEntries = await this.localAdapter.getList<T>(
145
+ this.storeName,
146
+ {},
147
+ this.idKey,
148
+ );
149
+
150
+ if (currentEntries.length > this.maxLocalEntries) {
151
+ // If sorting field is specified, sort by that field
152
+ if (this.sortByKey) {
153
+ currentEntries = this.sortEntries(currentEntries, this.sortByKey);
154
+ }
155
+
156
+ // Calculate number of entries to delete
157
+ const entriesToDelete = currentEntries.length - this.maxLocalEntries;
158
+
159
+ // Delete oldest entries (first element after sorting is the oldest)
160
+ for (let i = 0; i < entriesToDelete; i++) {
161
+ const entryToDelete = currentEntries[i];
162
+ if (entryToDelete) {
163
+ const id = String(entryToDelete[this.idKey]);
164
+ await this.executeLocal(() =>
165
+ this.localAdapter!.delete(this.storeName, id, this.idKey),
166
+ );
167
+ }
168
+ }
169
+ }
170
+ } catch (error) {
171
+ if (this.notThrowLocalError) {
172
+ console.warn("Failed to enforce max local entries:", error);
173
+ } else {
174
+ throw error;
175
+ }
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Sort entries by specified field
181
+ * @param entries Array of entries to sort
182
+ * @param key Sorting field name
183
+ * @returns Sorted array of entries
184
+ */
185
+ private sortEntries(entries: T[], key: string): T[] {
186
+ return [...entries].sort((a, b) => {
187
+ const aValue = a[key];
188
+ const bValue = b[key];
189
+
190
+ // Handle undefined or null values
191
+ if (aValue == null && bValue == null) return 0;
192
+ if (aValue == null) return 1; // null/undefined at the end
193
+ if (bValue == null) return -1; // null/undefined at the end
194
+
195
+ // Number type sorting
196
+ if (typeof aValue === "number" && typeof bValue === "number") {
197
+ return aValue - bValue;
198
+ }
199
+
200
+ // String type sorting
201
+ if (typeof aValue === "string" && typeof bValue === "string") {
202
+ return aValue.localeCompare(bValue);
203
+ }
204
+
205
+ // Date type sorting (if string format date)
206
+ if (typeof aValue === "string" && typeof bValue === "string") {
207
+ const aDate = new Date(aValue);
208
+ const bDate = new Date(bValue);
209
+ if (!isNaN(aDate.getTime()) && !isNaN(bDate.getTime())) {
210
+ return aDate.getTime() - bDate.getTime();
211
+ }
212
+ }
213
+
214
+ // Convert other types to string for comparison
215
+ return String(aValue).localeCompare(String(bValue));
216
+ });
217
+ }
218
+
219
+ /**
220
+ * Add data
221
+ */
222
+ async add(data: T): Promise<T> {
223
+ await this.ensureInitialized();
224
+
225
+ // Write to both local and remote simultaneously
226
+ const promises: Promise<T>[] = [];
227
+
228
+ if (this.localAdapter) {
229
+ const localResult = await this.executeLocal(() =>
230
+ this.localAdapter!.add(this.storeName, data, this.idKey),
231
+ );
232
+ if (localResult) {
233
+ promises.push(Promise.resolve(localResult));
234
+
235
+ // Check local storage entries limit
236
+ await this.enforceMaxLocalEntries();
237
+ }
238
+ }
239
+
240
+ if (this.remoteAdapter) {
241
+ promises.push(this.remoteAdapter.add(this.storeName, data, this.idKey));
242
+ }
243
+
244
+ await Promise.all(promises);
245
+ this.triggerChange();
246
+ return data;
247
+ }
248
+
249
+ /**
250
+ * Update data
251
+ */
252
+ async update(id: string, data: Partial<T>): Promise<T> {
253
+ await this.ensureInitialized();
254
+
255
+ const promises: Promise<T>[] = [];
256
+
257
+ if (this.localAdapter) {
258
+ const localResult = await this.executeLocal(() =>
259
+ this.localAdapter!.update<T>(this.storeName, id, data, this.idKey),
260
+ );
261
+ if (localResult) {
262
+ promises.push(Promise.resolve(localResult));
263
+ }
264
+ }
265
+
266
+ if (this.remoteAdapter) {
267
+ promises.push(
268
+ this.remoteAdapter.update<T>(this.storeName, id, data, this.idKey),
269
+ );
270
+ }
271
+
272
+ const results = await Promise.all(promises);
273
+ this.triggerChange();
274
+ // Return first non-null result
275
+ const firstResult = results.find(
276
+ (result) => result !== null && result !== undefined,
277
+ );
278
+ return firstResult as T;
279
+ }
280
+
281
+ /**
282
+ * Delete data
283
+ */
284
+ async delete(id: string): Promise<void> {
285
+ await this.ensureInitialized();
286
+
287
+ const promises: Promise<void | null>[] = [];
288
+
289
+ if (this.localAdapter) {
290
+ promises.push(
291
+ this.executeLocal(() =>
292
+ this.localAdapter!.delete(this.storeName, id, this.idKey),
293
+ ),
294
+ );
295
+ }
296
+
297
+ if (this.remoteAdapter) {
298
+ promises.push(this.remoteAdapter.delete(this.storeName, id, this.idKey));
299
+ }
300
+
301
+ await Promise.all(promises);
302
+ this.triggerChange();
303
+ }
304
+
305
+ /**
306
+ * Get data by ID
307
+ */
308
+ async getData(id: string): Promise<T | null> {
309
+ await this.ensureInitialized();
310
+
311
+ // Prioritize getting from local
312
+ if (this.localAdapter) {
313
+ try {
314
+ const localResult = await this.executeLocal(() =>
315
+ this.localAdapter!.getData(this.storeName, id, this.idKey),
316
+ );
317
+ if (localResult) {
318
+ return localResult as T;
319
+ }
320
+ } catch (error) {
321
+ if (!this.notThrowLocalError) {
322
+ throw error;
323
+ }
324
+ }
325
+ }
326
+
327
+ // Get from remote
328
+ if (this.remoteAdapter) {
329
+ return await this.remoteAdapter.getData(this.storeName, id, this.idKey);
330
+ }
331
+
332
+ return null;
333
+ }
334
+
335
+ /**
336
+ * Get list data
337
+ */
338
+ async getList(options: QueryOptions<T> = {}): Promise<T[]> {
339
+ await this.ensureInitialized();
340
+
341
+ const source = options.source;
342
+
343
+ // If source is specified
344
+ if (source === "local" && this.localAdapter) {
345
+ return await this.localAdapter.getList<T>(
346
+ this.storeName,
347
+ options,
348
+ this.idKey,
349
+ );
350
+ }
351
+
352
+ if (source === "remote" && this.remoteAdapter) {
353
+ const result = await this.remoteAdapter.getList<T>(
354
+ this.storeName,
355
+ options,
356
+ this.idKey,
357
+ );
358
+ return result.data;
359
+ }
360
+
361
+ // Default to getting from remote
362
+ if (this.remoteAdapter) {
363
+ try {
364
+ const result = await this.remoteAdapter.getList<T>(
365
+ this.storeName,
366
+ options,
367
+ this.idKey,
368
+ );
369
+
370
+ // Cache to local
371
+ if (this.localAdapter && result.data.length > 0) {
372
+ await Promise.all(
373
+ result.data.map((item) =>
374
+ this.executeLocal(() =>
375
+ this.localAdapter!.add(this.storeName, item, this.idKey),
376
+ ),
377
+ ),
378
+ );
379
+
380
+ // Check local storage entries limit
381
+ await this.enforceMaxLocalEntries();
382
+ }
383
+
384
+ return result.data;
385
+ } catch (error) {
386
+ // If remote fails, try to get from local
387
+ if (this.localAdapter) {
388
+ return await this.localAdapter.getList<T>(
389
+ this.storeName,
390
+ options,
391
+ this.idKey,
392
+ );
393
+ }
394
+ throw error;
395
+ }
396
+ }
397
+
398
+ // Only local adapter
399
+ if (this.localAdapter) {
400
+ return await this.localAdapter.getList<T>(
401
+ this.storeName,
402
+ options,
403
+ this.idKey,
404
+ );
405
+ }
406
+
407
+ return [];
408
+ }
409
+
410
+ /**
411
+ * Get query object for list data
412
+ */
413
+ async getListQuery(options: QueryOptions<T> = {}): Promise<QueryResult<T[]>> {
414
+ await this.ensureInitialized();
415
+
416
+ const source = options.source;
417
+
418
+ // Build query key
419
+ const queryKey = ["store", this.storeName, source || "default", options];
420
+
421
+ // Build query function
422
+ const queryFn = async (): Promise<T[]> => {
423
+ return this.getList(options);
424
+ };
425
+
426
+ // Build initial data fetch function
427
+ const getInitialData = async (): Promise<T[]> => {
428
+ if (this.localAdapter) {
429
+ try {
430
+ return await this.localAdapter.getList<T>(
431
+ this.storeName,
432
+ options,
433
+ this.idKey,
434
+ );
435
+ } catch (error) {
436
+ // If local fetch fails, return empty array
437
+ return [];
438
+ }
439
+ }
440
+ return [];
441
+ };
442
+
443
+ return {
444
+ queryKey,
445
+ queryFn,
446
+ getInitialData,
447
+ };
448
+ }
449
+
450
+ /**
451
+ * Clear store
452
+ */
453
+ async clear(): Promise<void> {
454
+ await this.ensureInitialized();
455
+
456
+ const promises: Promise<void>[] = [];
457
+
458
+ if (this.localAdapter) {
459
+ await this.executeLocal(() => this.localAdapter!.clear(this.storeName));
460
+ }
461
+
462
+ if (this.remoteAdapter) {
463
+ promises.push(this.remoteAdapter.clear(this.storeName));
464
+ }
465
+
466
+ await Promise.all(promises);
467
+ this.triggerChange();
468
+ }
469
+ }
@@ -0,0 +1,212 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { createSyncManager, SyncManager } from './sync-manager.js';
3
+ import { AdapterType, type QueryResult, type QueryOptions } from './types.js';
4
+ import type { LocalAdapter, RemoteAdapter } from './types.js';
5
+
6
+ // Mock adapters
7
+ class MockLocalAdapter implements LocalAdapter {
8
+ readonly type = AdapterType.Local;
9
+ readonly name = 'MockLocalAdapter';
10
+
11
+ async add<T extends Record<string, unknown>>(storeName: string, data: T, idKey?: string): Promise<T> {
12
+ return data;
13
+ }
14
+
15
+ async update<T extends Record<string, unknown>>(storeName: string, id: string, data: Partial<T>, idKey?: string): Promise<T> {
16
+ return { ...data, id } as unknown as T;
17
+ }
18
+
19
+ async delete(storeName: string, id: string, idKey?: string): Promise<void> {
20
+ // Mock implementation
21
+ }
22
+
23
+ async getData<T extends Record<string, unknown>>(storeName: string, id: string, idKey?: string): Promise<T | null> {
24
+ return null;
25
+ }
26
+
27
+ async getList<T extends Record<string, unknown>>(storeName: string, options?: unknown, idKey?: string): Promise<T[]> {
28
+ return [];
29
+ }
30
+
31
+
32
+
33
+ async clear(storeName: string): Promise<void> {
34
+ // Mock implementation
35
+ }
36
+
37
+ async initStore(storeName: string, indexes?: string[], idKey?: string): Promise<void> {
38
+ // Mock implementation
39
+ }
40
+ }
41
+
42
+ class MockRemoteAdapter implements RemoteAdapter {
43
+ readonly type = AdapterType.Remote;
44
+ readonly name = 'MockRemoteAdapter';
45
+ private data: Map<string, Record<string, unknown>> = new Map();
46
+
47
+ async add<T extends Record<string, unknown>>(storeName: string, data: T, idKey?: string): Promise<T> {
48
+ const key = String(data[idKey ?? "id"]);
49
+ this.data.set(key, data);
50
+ return data;
51
+ }
52
+
53
+ async update<T extends Record<string, unknown>>(storeName: string, id: string, data: Partial<T>, idKey?: string): Promise<T> {
54
+ const existing = this.data.get(id) as T | undefined;
55
+ if (!existing) {
56
+ throw new Error(`Data with id ${id} not found`);
57
+ }
58
+ const updated = { ...existing, ...data } as T;
59
+ this.data.set(id, updated);
60
+ return updated;
61
+ }
62
+
63
+ async delete(storeName: string, id: string, idKey?: string): Promise<void> {
64
+ this.data.delete(id);
65
+ }
66
+
67
+ async getData<T extends Record<string, unknown>>(storeName: string, id: string, idKey?: string): Promise<T | null> {
68
+ return (this.data.get(id) as T) || null;
69
+ }
70
+
71
+ async getList<T extends Record<string, unknown>>(storeName: string, options?: QueryOptions<T>, idKey?: string) {
72
+ let allData = Array.from(this.data.values()) as T[];
73
+
74
+ // 应用条件筛选
75
+ if (options?.where) {
76
+ const whereKeys = Object.keys(options.where);
77
+ allData = allData.filter((item) => {
78
+ return whereKeys.every((key) => {
79
+ return item[key] === options.where?.[key];
80
+ });
81
+ });
82
+ }
83
+
84
+ // 应用分页
85
+ let page = options?.page ?? 1;
86
+ let limit = options?.limit ?? (allData.length || 10);
87
+ const start = (page - 1) * limit;
88
+ const end = start + limit;
89
+ const paginatedData = allData.slice(start, end);
90
+
91
+ return {
92
+ data: paginatedData,
93
+ totalCount: allData.length,
94
+ page,
95
+ limit,
96
+ };
97
+ }
98
+
99
+ async getListQuery<T extends Record<string, unknown>>(storeName: string, options?: QueryOptions<T>, idKey?: string) {
100
+ return {
101
+ queryKey: ["store", storeName, "remote", options],
102
+ queryFn: async () => {
103
+ const result = await this.getList<T>(storeName, options, idKey);
104
+ return result.data;
105
+ },
106
+ getInitialData: async () => [],
107
+ };
108
+ }
109
+
110
+ async clear(storeName: string): Promise<void> {
111
+ this.data.clear();
112
+ }
113
+
114
+ async initStore(storeName: string, indexes?: string[], idKey?: string): Promise<void> {
115
+ // Mock implementation
116
+ }
117
+
118
+ setService(service: unknown): void {
119
+ // Mock implementation
120
+ }
121
+ }
122
+
123
+ describe('SyncManager', () => {
124
+ let localAdapter: LocalAdapter;
125
+ let remoteAdapter: RemoteAdapter;
126
+
127
+ beforeEach(() => {
128
+ localAdapter = new MockLocalAdapter();
129
+ remoteAdapter = new MockRemoteAdapter();
130
+ });
131
+
132
+ describe('createSyncManager', () => {
133
+ it('应该能够创建 SyncManager 实例', () => {
134
+ const manager = createSyncManager({
135
+ localAdapter,
136
+ remoteAdapter,
137
+ });
138
+
139
+ expect(manager).toBeInstanceOf(SyncManager);
140
+ });
141
+
142
+ it('应该支持可选参数', () => {
143
+ const manager = createSyncManager({});
144
+ expect(manager).toBeInstanceOf(SyncManager);
145
+ });
146
+
147
+ it('应该支持 notThrowLocalError 选项', () => {
148
+ const manager = createSyncManager({
149
+ notThrowLocalError: true,
150
+ });
151
+ expect(manager).toBeInstanceOf(SyncManager);
152
+ });
153
+ });
154
+
155
+ describe('setLocalAdapter', () => {
156
+ it('应该能够设置本地适配器', () => {
157
+ const manager = createSyncManager({});
158
+ manager.setLocalAdapter(localAdapter);
159
+ expect(manager).toBeDefined();
160
+ });
161
+ });
162
+
163
+ describe('setRemoteAdapter', () => {
164
+ it('应该能够设置远程适配器', () => {
165
+ const manager = createSyncManager({});
166
+ manager.setRemoteAdapter(remoteAdapter);
167
+ expect(manager).toBeDefined();
168
+ });
169
+ });
170
+
171
+ describe('createStore', () => {
172
+ it('应该能够创建 Store', () => {
173
+ const manager = createSyncManager({
174
+ localAdapter,
175
+ remoteAdapter,
176
+ });
177
+
178
+ const store = manager.createStore<{ id: string; name: string }>('test-store', {
179
+ idKey: 'id',
180
+ indexes: ['name'],
181
+ });
182
+
183
+ expect(store).toBeDefined();
184
+ });
185
+
186
+ it('应该支持可选配置', () => {
187
+ const manager = createSyncManager({
188
+ localAdapter,
189
+ remoteAdapter,
190
+ });
191
+
192
+ const store = manager.createStore<{ id: string }>('test-store');
193
+ expect(store).toBeDefined();
194
+ });
195
+
196
+ it('应该支持 onChange 回调', () => {
197
+ const onChange = vi.fn();
198
+ const manager = createSyncManager({
199
+ localAdapter,
200
+ remoteAdapter,
201
+ });
202
+
203
+ const store = manager.createStore<{ id: string }>('test-store', {
204
+ onChange,
205
+ });
206
+
207
+ expect(store).toBeDefined();
208
+ });
209
+ });
210
+ });
211
+
212
+