@omnistreamai/data-core
Version:
647 lines (642 loc) • 18.9 kB
JavaScript
//#region src/types.ts
/**
* Adapter type: local or remote
*/
let AdapterType = /* @__PURE__ */ function(AdapterType$1) {
AdapterType$1["Local"] = "local";
AdapterType$1["Remote"] = "remote";
return AdapterType$1;
}({});
/**
* Authentication type
*/
let AuthType = /* @__PURE__ */ function(AuthType$1) {
AuthType$1["Token"] = "token";
AuthType$1["Basic"] = "basic";
AuthType$1["Bearer"] = "bearer";
return AuthType$1;
}({});
let SyncOperation = /* @__PURE__ */ function(SyncOperation$1) {
SyncOperation$1["Create"] = "create";
SyncOperation$1["Update"] = "update";
SyncOperation$1["Delete"] = "delete";
return SyncOperation$1;
}({});
let SyncStatus = /* @__PURE__ */ function(SyncStatus$1) {
SyncStatus$1["Pending"] = "pending";
SyncStatus$1["Syncing"] = "syncing";
SyncStatus$1["Success"] = "success";
SyncStatus$1["Failed"] = "failed";
return SyncStatus$1;
}({});
const DEFAULT_RETRY_POLICY = {
maxRetries: 3,
baseDelayMs: 5e3,
maxDelayMs: 3e5,
backoff: "exponential",
jitter: true,
retryableStatuses: [
408,
429,
500,
502,
503,
504
],
retryableErrors: [
"ECONNREFUSED",
"ENOTFOUND",
"ETIMEDOUT",
"timeout",
"network",
"fetch failed"
]
};
//#endregion
//#region src/pending-queue.ts
var PendingQueue = class {
QUEUE_STORE = "__sync_pending_queue";
policy;
localAdapter;
initialized = false;
constructor(localAdapter, config = {}) {
this.localAdapter = localAdapter;
this.policy = this.mergePolicy(config.defaultPolicy);
}
mergePolicy(userPolicy) {
const policy = {
...DEFAULT_RETRY_POLICY,
...userPolicy
};
return {
maxRetries: policy.maxRetries,
baseDelayMs: policy.baseDelayMs,
maxDelayMs: policy.maxDelayMs ?? DEFAULT_RETRY_POLICY.maxDelayMs,
backoff: policy.backoff,
jitter: policy.jitter ?? true,
retryableStatuses: policy.retryableStatuses ?? [
408,
429,
500,
502,
503,
504
],
retryableErrors: policy.retryableErrors ?? [
"ECONNREFUSED",
"ENOTFOUND",
"ETIMEDOUT",
"timeout",
"network",
"fetch failed"
]
};
}
async ensureInitialized() {
if (this.initialized) return;
await this.localAdapter.initStore(this.QUEUE_STORE, [], "id");
this.initialized = true;
}
isRetryable(error, statusCode) {
if (statusCode) {
if (this.policy.retryableStatuses?.includes(statusCode)) return true;
if (statusCode >= 400 && statusCode < 500 && ![408, 429].includes(statusCode)) return false;
}
const errorMessage = error instanceof Error ? error.message : String(error);
return this.policy.retryableErrors?.some((e) => errorMessage.toLowerCase().includes(e.toLowerCase())) ?? false;
}
extractStatusCode(error) {
const match = (error instanceof Error ? error.message : String(error)).match(/(?:http[:\s]*)?(\d{3})/i);
if (!match) return void 0;
const statusCode = match[1];
return statusCode ? parseInt(statusCode, 10) : void 0;
}
async add(item) {
await this.ensureInitialized();
const existing = await this.findExisting(item.storeName, item.recordId, item.operation);
if (existing) {
existing.attempts += 1;
existing.lastAttempt = Date.now();
existing.lastError = item.error;
existing.status = SyncStatus.Pending;
await this.localAdapter.update(this.QUEUE_STORE, existing.id, {
attempts: existing.attempts,
lastAttempt: existing.lastAttempt,
lastError: existing.lastError,
status: SyncStatus.Pending
}, "id");
return {
item: existing,
isNew: false
};
}
const newItem = {
...item,
id: crypto.randomUUID(),
attempts: 1,
status: SyncStatus.Pending,
createdAt: Date.now(),
lastAttempt: Date.now(),
maxRetries: this.policy.maxRetries
};
await this.localAdapter.add(this.QUEUE_STORE, newItem, "id");
return {
item: newItem,
isNew: true
};
}
async findExisting(storeName, recordId, operation) {
return (await this.localAdapter.getList(this.QUEUE_STORE, { where: {
storeName,
recordId,
operation
} }, "id")).find((item) => item.status === SyncStatus.Pending) ?? null;
}
async getPendingItems(storeName) {
await this.ensureInitialized();
return (await this.localAdapter.getList(this.QUEUE_STORE, { where: { storeName } }, "id")).filter((item) => item.status === SyncStatus.Pending);
}
async getRetryableItems(storeName) {
const items = await this.getPendingItems(storeName);
const now = Date.now();
return items.filter((item) => {
return now >= this.calculateNextRetryTime(item.attempts);
});
}
calculateNextRetryTime(attempt) {
if (attempt <= 1) return Date.now();
let delay;
switch (this.policy.backoff) {
case "fixed":
delay = this.policy.baseDelayMs;
break;
case "linear":
delay = this.policy.baseDelayMs * (attempt - 1);
break;
case "exponential":
default:
delay = this.policy.baseDelayMs * Math.pow(2, attempt - 2);
break;
}
if (this.policy.jitter) delay += Math.random() * this.policy.baseDelayMs * .5;
if (this.policy.maxDelayMs) delay = Math.min(delay, this.policy.maxDelayMs);
return Date.now() + delay;
}
async markSyncing(id) {
await this.localAdapter.update(this.QUEUE_STORE, id, {
status: SyncStatus.Syncing,
lastAttempt: Date.now()
}, "id");
}
async markSuccess(id) {
await this.localAdapter.update(this.QUEUE_STORE, id, {
status: SyncStatus.Success,
completedAt: Date.now()
}, "id");
}
async markFailed(id, error) {
await this.localAdapter.update(this.QUEUE_STORE, id, {
status: SyncStatus.Failed,
lastError: error,
completedAt: Date.now()
}, "id");
}
async remove(id) {
await this.localAdapter.delete(this.QUEUE_STORE, id, "id");
}
async getPendingCount(storeName) {
return (await this.getPendingItems(storeName)).length;
}
async clear(storeName) {
const items = await this.getPendingItems(storeName);
await Promise.all(items.map((item) => this.remove(item.id)));
}
updatePolicy(config) {
if (config.defaultPolicy) this.policy = this.mergePolicy(config.defaultPolicy);
}
getPolicy() {
return this.policy;
}
};
//#endregion
//#region src/store.ts
/**
* Store class
*/
var Store = class {
localAdapter;
remoteAdapter;
storeName;
idKey;
indexes;
maxLocalEntries;
sortByKey;
onChange;
notThrowLocalErrorProps;
notThrowLocalError;
initialized = false;
/** Pending queue for failed sync operations */
pendingQueue;
/** Sync configuration */
syncConfig;
/** Sync callbacks */
syncCallbacks;
constructor(storeName, options) {
this.storeName = storeName;
this.localAdapter = options.localAdapter;
this.remoteAdapter = options.remoteAdapter;
this.idKey = options.idKey;
this.indexes = options.indexes;
this.maxLocalEntries = options.maxLocalEntries;
this.sortByKey = options.sortByKey;
this.onChange = options.onChange;
this.notThrowLocalErrorProps = options.notThrowLocalError;
this.notThrowLocalError = options.notThrowLocalError && !!this.remoteAdapter;
this.syncConfig = options.syncConfig;
this.syncCallbacks = options.syncCallbacks;
if (this.syncConfig && this.localAdapter) this.pendingQueue = new PendingQueue(this.localAdapter, this.syncConfig);
}
/**
* Initialize Store
*/
async init() {
if (this.initialized) return;
const promises = [];
if (this.localAdapter) promises.push(this.executeLocal(() => this.localAdapter.initStore(this.storeName, this.indexes, this.idKey)));
if (this.remoteAdapter) promises.push(this.executeRemote(() => this.remoteAdapter.initStore(this.storeName, this.indexes, this.idKey)));
await Promise.all(promises);
this.initialized = true;
}
async ensureInitialized() {
if (!this.initialized) await this.init();
}
triggerChange() {
if (this.onChange) this.onChange();
}
async executeLocal(operation) {
if (!this.localAdapter) return null;
try {
return await operation();
} catch (error) {
if (this.notThrowLocalError) {
console.warn("Local adapter error:", error);
return null;
}
throw error;
}
}
async executeRemote(operation) {
return await operation();
}
/**
* Enforce maximum local storage entries limit
*/
async enforceMaxLocalEntries() {
if (!this.localAdapter || !this.maxLocalEntries) return;
try {
let currentEntries = await this.localAdapter.getList(this.storeName, {}, this.idKey);
if (currentEntries.length > this.maxLocalEntries) {
if (this.sortByKey) currentEntries = this.sortEntries(currentEntries, this.sortByKey);
const entriesToDelete = currentEntries.length - this.maxLocalEntries;
for (let i = 0; i < entriesToDelete; i++) {
const entryToDelete = currentEntries[i];
if (entryToDelete) {
const id = String(entryToDelete[this.idKey]);
await this.executeLocal(() => this.localAdapter.delete(this.storeName, id, this.idKey));
}
}
}
} catch (error) {
if (this.notThrowLocalError) console.warn("Failed to enforce max local entries:", error);
else throw error;
}
}
/**
* Sort entries by specified field
* @param entries Array of entries to sort
* @param key Sorting field name
* @returns Sorted array of entries
*/
sortEntries(entries, key) {
return [...entries].sort((a, b) => {
const aValue = a[key];
const bValue = b[key];
if (aValue == null && bValue == null) return 0;
if (aValue == null) return 1;
if (bValue == null) return -1;
if (typeof aValue === "number" && typeof bValue === "number") return aValue - bValue;
if (typeof aValue === "string" && typeof bValue === "string") return aValue.localeCompare(bValue);
if (typeof aValue === "string" && typeof bValue === "string") {
const aDate = new Date(aValue);
const bDate = new Date(bValue);
if (!isNaN(aDate.getTime()) && !isNaN(bDate.getTime())) return aDate.getTime() - bDate.getTime();
}
return String(aValue).localeCompare(String(bValue));
});
}
/**
* Handle remote error - add to pending queue if retryable
*/
async handleRemoteError(error, operation, data) {
if (!this.pendingQueue) return;
const statusCode = this.pendingQueue.extractStatusCode(error);
if (this.pendingQueue.isRetryable(error, statusCode)) {
const { item } = await this.pendingQueue.add({
storeName: this.storeName,
operation,
data,
idKey: this.idKey,
recordId: String(data[this.idKey]),
error: error instanceof Error ? error.message : String(error)
});
if (this.syncCallbacks?.onSyncFailed) {
const retryInfo = {
item,
error: error instanceof Error ? error : new Error(String(error)),
isMaxRetriesExceeded: item.attempts >= item.maxRetries,
attempt: item.attempts,
nextRetryDelayMs: this.pendingQueue.calculateNextRetryTime(item.attempts)
};
this.syncCallbacks.onSyncFailed(retryInfo);
}
}
}
/**
/**
* Add data
*/
async add(data) {
await this.ensureInitialized();
if (this.localAdapter) {
if (await this.executeLocal(() => this.localAdapter.add(this.storeName, data, this.idKey))) await this.enforceMaxLocalEntries();
}
if (this.remoteAdapter) try {
await this.remoteAdapter.add(this.storeName, data, this.idKey);
} catch (error) {
await this.handleRemoteError(error, SyncOperation.Create, data);
}
this.triggerChange();
return data;
}
/**
* Update data
*/
async update(id, data) {
await this.ensureInitialized();
let localResult = null;
if (this.localAdapter) localResult = await this.executeLocal(() => this.localAdapter.update(this.storeName, id, data, this.idKey));
if (this.remoteAdapter) try {
await this.remoteAdapter.update(this.storeName, id, data, this.idKey);
} catch (error) {
await this.handleRemoteError(error, SyncOperation.Update, {
id,
...data
});
}
this.triggerChange();
return localResult;
}
/**
* Delete data
*/
async delete(id) {
await this.ensureInitialized();
if (this.localAdapter) await this.executeLocal(() => this.localAdapter.delete(this.storeName, id, this.idKey));
if (this.remoteAdapter) try {
await this.remoteAdapter.delete(this.storeName, id, this.idKey);
} catch (error) {
await this.handleRemoteError(error, SyncOperation.Delete, { [this.idKey]: id });
}
this.triggerChange();
}
/**
* Get data by ID
*/
async getData(id) {
await this.ensureInitialized();
if (this.localAdapter) try {
const localResult = await this.executeLocal(() => this.localAdapter.getData(this.storeName, id, this.idKey));
if (localResult) return localResult;
} catch (error) {
if (!this.notThrowLocalError) throw error;
}
if (this.remoteAdapter) return await this.remoteAdapter.getData(this.storeName, id, this.idKey);
return null;
}
/**
* Get list data
*/
async getList(options = {}) {
await this.ensureInitialized();
const source = options.source;
if (source === "local" && this.localAdapter) return await this.localAdapter.getList(this.storeName, options, this.idKey);
if (source === "remote" && this.remoteAdapter) return (await this.remoteAdapter.getList(this.storeName, options, this.idKey)).data;
if (this.remoteAdapter) try {
const result = await this.remoteAdapter.getList(this.storeName, options, this.idKey);
if (this.localAdapter && result.data.length > 0) {
await Promise.all(result.data.map((item) => this.executeLocal(() => this.localAdapter.add(this.storeName, item, this.idKey))));
await this.enforceMaxLocalEntries();
}
return result.data;
} catch (error) {
if (this.localAdapter) return await this.localAdapter.getList(this.storeName, options, this.idKey);
throw error;
}
if (this.localAdapter) return await this.localAdapter.getList(this.storeName, options, this.idKey);
return [];
}
/**
* Get query object for list data
*/
getListQuery(options = {}) {
const source = options.source;
const queryKey = [
"store",
this.storeName,
source || "default",
options
];
const queryFn = async () => {
return this.getList(options);
};
const getInitialData = async () => {
await this.ensureInitialized();
if (this.localAdapter) try {
return await this.localAdapter.getList(this.storeName, options, this.idKey);
} catch (error) {
return [];
}
return [];
};
return {
queryKey,
queryFn,
getInitialData
};
}
/**
* Clear store
*/
async clear() {
await this.ensureInitialized();
const promises = [];
if (this.localAdapter) await this.executeLocal(() => this.localAdapter.clear(this.storeName));
if (this.remoteAdapter) promises.push(this.remoteAdapter.clear(this.storeName));
await Promise.all(promises);
this.triggerChange();
}
setLocalAdapter(adapter) {
this.localAdapter = adapter;
this.initialized = false;
return this.init();
}
setRemoteAdapter(adapter) {
this.remoteAdapter = adapter;
this.notThrowLocalError = this.notThrowLocalErrorProps && !!adapter;
this.initialized = false;
return this.init();
}
/**
* Get pending sync items count
*/
async getPendingCount() {
if (!this.pendingQueue) return 0;
return await this.pendingQueue.getPendingCount(this.storeName);
}
/**
* Sync all pending items
*/
async syncPending() {
if (!this.pendingQueue || !this.remoteAdapter) return {
success: 0,
failed: 0
};
const pendingItems = await this.pendingQueue.getRetryableItems(this.storeName);
let success = 0;
let failed = 0;
for (const item of pendingItems) {
await this.pendingQueue.markSyncing(item.id);
try {
switch (item.operation) {
case SyncOperation.Create:
await this.remoteAdapter.add(item.storeName, item.data, item.idKey);
break;
case SyncOperation.Update:
await this.remoteAdapter.update(item.storeName, item.recordId, item.data, item.idKey);
break;
case SyncOperation.Delete:
await this.remoteAdapter.delete(item.storeName, item.recordId, item.idKey);
break;
}
await this.pendingQueue.markSuccess(item.id);
success++;
if (this.syncCallbacks?.onSyncSuccess) this.syncCallbacks.onSyncSuccess(item);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const policy = this.pendingQueue.getPolicy();
if (item.attempts >= policy.maxRetries) {
await this.pendingQueue.markFailed(item.id, errorMessage);
failed++;
if (this.syncCallbacks?.onSyncFailed) this.syncCallbacks.onSyncFailed({
item,
error: error instanceof Error ? error : new Error(errorMessage),
isMaxRetriesExceeded: true,
attempt: item.attempts
});
} else {
const { item: updatedItem } = await this.pendingQueue.add({
storeName: item.storeName,
operation: item.operation,
data: item.data,
idKey: item.idKey,
recordId: item.recordId,
error: errorMessage
});
if (this.syncCallbacks?.onSyncFailed) this.syncCallbacks.onSyncFailed({
item: updatedItem,
error: error instanceof Error ? error : new Error(errorMessage),
isMaxRetriesExceeded: false,
attempt: updatedItem.attempts
});
}
}
}
return {
success,
failed
};
}
};
//#endregion
//#region src/sync-manager.ts
/**
* Sync manager
*/
var SyncManager = class {
localAdapter = null;
remoteAdapter = null;
notThrowLocalError;
stores = /* @__PURE__ */ new Map();
constructor(options = {}) {
this.localAdapter = options.localAdapter ?? null;
this.remoteAdapter = options.remoteAdapter ?? null;
this.notThrowLocalError = options.notThrowLocalError ?? false;
}
/**
* Set local adapter
*/
async setLocalAdapter(adapter) {
this.localAdapter = adapter;
for (const [_, store] of this.stores) await store.setLocalAdapter(adapter);
}
/**
* Set remote adapter
*/
async setRemoteAdapter(adapter) {
this.remoteAdapter = adapter;
for (const [_, store] of this.stores) await store.setRemoteAdapter(adapter);
}
/**
* Create Store
*/
createStore(storeName, config = {}) {
const existingStore = this.stores.get(storeName);
if (existingStore) return existingStore;
const store = new Store(storeName, {
localAdapter: this.localAdapter,
remoteAdapter: this.remoteAdapter,
idKey: config.idKey ?? "id",
indexes: config.indexes,
maxLocalEntries: config.maxLocalEntries,
onChange: config.onChange,
notThrowLocalError: this.notThrowLocalError,
syncConfig: config.syncConfig,
syncCallbacks: config.syncCallbacks
});
this.stores.set(storeName, store);
return store;
}
initStore() {
const promiseList = [];
this.stores.forEach((store) => {
promiseList.push(store.init());
});
return Promise.all(promiseList);
}
};
/**
* Create sync manager
*/
function createSyncManager(options = {}) {
return new SyncManager(options);
}
//#endregion
exports.AdapterType = AdapterType;
exports.AuthType = AuthType;
exports.DEFAULT_RETRY_POLICY = DEFAULT_RETRY_POLICY;
exports.Store = Store;
exports.SyncManager = SyncManager;
exports.SyncOperation = SyncOperation;
exports.SyncStatus = SyncStatus;
exports.createSyncManager = createSyncManager;
//# sourceMappingURL=index.js.map