apical-store
Advanced tools
Comparing version 0.0.6 to 0.0.7
{ | ||
"name": "apical-store", | ||
"version": "0.0.6", | ||
"version": "0.0.7", | ||
"description": "Mobx-Syncable-IndexedDB", | ||
@@ -5,0 +5,0 @@ "main": "dist/bundle.js", |
# Apical-Store | ||
// TODO: check version in conflicts for each row | ||
// TODO: check next page when fetching (are we?) | ||
Example workflow | ||
```typescript | ||
import { Document, Store, SubDocument, mapSubModel, observe } from "apical-store"; | ||
import { Document, Store, SubDocument, mapSubModel, observe, CloudFlareApexoDB, IDB } from "apical-store"; | ||
@@ -24,7 +21,11 @@ class Department extends SubDocument { | ||
const myStore = new Store<Employee>({ | ||
name: "my-store", // remote table name and local indexedDB | ||
localPersistance: new IDB({ | ||
name: "my-database" | ||
}), | ||
remotePersistence: new CloudFlareApexoDB({ | ||
endpoint: "http://someurl", | ||
token: "token", | ||
name: "my-database", | ||
}), | ||
model: Employee, | ||
persist: true, | ||
endpoint: "http://api.myendpoint.com", | ||
token: "my-token", | ||
debounceRate: 1000, | ||
@@ -35,3 +36,3 @@ encode: (data: any) => JSON.stringify(data), | ||
@observe(myStore) | ||
@observe([myStore]) | ||
class MyComponent extends React.Component { | ||
@@ -38,0 +39,0 @@ render() { |
export { Store } from "./store"; | ||
export { Document, SubDocument, mapSubModel } from "./model"; | ||
export { observe } from "./react"; | ||
export { observe } from "./react"; | ||
export { LocalPersistence, IDB, deferredArray } from "./persistence/local"; | ||
export { CloudFlareApexoDB } from "./persistence/remote"; |
import { Document } from "./model"; | ||
import { Store } from "./store"; | ||
/** | ||
* Enhances a React component to automatically re-render when the observed store changes. | ||
* @param store - An instance of Store that extends Document. | ||
* @returns A higher-order function that takes a React component as an argument. | ||
*/ | ||
export function observe<D extends Document, G extends Store<D>>( | ||
store: G | ||
store: G | G[] | ||
): (component: any) => any { | ||
return function (component: any) { | ||
let oCDM = component.prototype.componentDidMount || (() => {}); | ||
const originalComponentDidMount = | ||
component.prototype.componentDidMount || (() => {}); | ||
component.prototype.componentDidMount = function () { | ||
let unObservers: (() => void)[] = []; | ||
this.setState({}); | ||
const observer = () => this.setState({}); | ||
(store as any).$$observableObject.observe(observer); | ||
unObservers.push(() => (store as any).$$observableObject.unobserve(observer)); | ||
const oCWU = this.componentWillUnmount || (() => {}); | ||
this.componentWillUnmount = () => { | ||
unObservers.forEach((u) => u()); | ||
oCWU.call(this); | ||
} | ||
oCDM.call(this); | ||
const unObservers: (() => void)[] = []; | ||
this.setState({}); | ||
const observer = () => this.setState({}); | ||
if (Array.isArray(store)) { | ||
store.forEach((singleStore) => { | ||
// @ts-ignore | ||
singleStore.$$observableObject.observe(observer); | ||
unObservers.push(() => | ||
// @ts-ignore | ||
singleStore.$$observableObject.unobserve(observer) | ||
); | ||
}); | ||
} else { | ||
// @ts-ignore | ||
store.$$observableObject.observe(observer); | ||
// @ts-ignore | ||
store.$$observableObject.unobserve(observer); | ||
} | ||
const originalComponentWillUnmount = | ||
this.componentWillUnmount || (() => {}); | ||
this.componentWillUnmount = () => { | ||
unObservers.forEach((unObserver) => unObserver()); | ||
originalComponentWillUnmount.call(this); | ||
}; | ||
originalComponentDidMount.call(this); | ||
}; | ||
return component; | ||
return component; | ||
}; | ||
} |
191
src/store.ts
@@ -1,10 +0,10 @@ | ||
import { Change, observable, ObservableArray } from "./observable"; | ||
import { IDB } from "./idb"; | ||
import { SyncService } from "./sync-service"; | ||
import { Change, Observable } from "./observable"; | ||
import { deferredArray, LocalPersistence } from "./persistence/local"; | ||
import { debounce } from "./debounce"; | ||
import { Document } from "./model"; | ||
import { RemotePersistence } from "./persistence/remote"; | ||
export type deferredArray = { ts: number; data: string }[]; | ||
export class Store<T extends Document> { | ||
export class Store< | ||
T extends Document, | ||
> { | ||
public isOnline = true; | ||
@@ -14,7 +14,7 @@ public deferredPresent: boolean = false; | ||
public onSyncEnd: () => void = () => {}; | ||
private $$idb: IDB | undefined; | ||
private $$observableObject: ObservableArray<T[]> = observable([] as T[]); | ||
private $$observableObject: Observable<T> = new Observable([] as T[]); | ||
private $$changes: Change<T[]>[] = []; | ||
private $$token: string | undefined; | ||
private $$syncService: SyncService | null = null; | ||
private $$localPersistence: LocalPersistence | undefined; | ||
private $$remotePersistence: RemotePersistence | undefined; | ||
private $$debounceRate: number = 100; | ||
@@ -27,6 +27,2 @@ private $$lastProcessChanges: number = 0; | ||
constructor({ | ||
name, | ||
token, | ||
persist = true, | ||
endpoint, | ||
debounceRate, | ||
@@ -38,7 +34,5 @@ model, | ||
onSyncEnd, | ||
localPersistence, | ||
remotePersistence, | ||
}: { | ||
name?: string; | ||
token?: string; | ||
persist?: boolean; | ||
endpoint?: string; | ||
debounceRate?: number; | ||
@@ -50,3 +44,5 @@ model?: typeof Document; | ||
onSyncEnd?: () => void; | ||
}) { | ||
localPersistence?: LocalPersistence; | ||
remotePersistence?: RemotePersistence; | ||
} = {}) { | ||
this.$$model = model || Document; | ||
@@ -68,14 +64,19 @@ if (onSyncStart) { | ||
} | ||
if (name && persist) { | ||
this.$$idb = new IDB(name); | ||
if (localPersistence) { | ||
this.$$localPersistence = localPersistence; | ||
this.$$loadFromLocal(); | ||
this.$$setupObservers(); | ||
} | ||
if (token && endpoint && name && persist) { | ||
this.$$token = token; | ||
this.$$syncService = new SyncService(endpoint, this.$$token, name); | ||
if (remotePersistence) { | ||
this.$$remotePersistence = remotePersistence; | ||
} | ||
} | ||
private $$serialize(item: T) { | ||
/** | ||
* Serializes an item of type T into an encoded JSON string. | ||
* Date objects are converted to a custom format before encoding. | ||
* @param item An instance of type T which extends Document. | ||
* @returns An encoded JSON string representing the item. | ||
*/ | ||
private $$serialize(item: T): string { | ||
const stripped = item._stripDefaults ? item._stripDefaults() : item; | ||
@@ -92,7 +93,12 @@ const str = JSON.stringify(stripped, function (key, value) { | ||
private $$deserialize(line: string) { | ||
/** | ||
* Decodes a serialized string, parses it into a JavaScript object, and converts custom date formats back into Date objects. | ||
* @param line A string representing the serialized data. | ||
* @returns A new instance of the model with the deserialized data. | ||
*/ | ||
private $$deserialize(line: string): any { | ||
line = this.$$decode(line); | ||
const item = JSON.parse(line, function (key, val) { | ||
const item = JSON.parse(line, (key, val) => { | ||
if (key === "$$date") return new Date(val); | ||
let t = typeof val; | ||
const t = typeof val; | ||
if (t === "string" || t === "number" || t === "boolean" || val === null) | ||
@@ -106,7 +112,15 @@ return val; | ||
private async $$loadFromLocal() { | ||
if (!this.$$idb) return; | ||
const deserialized = (await this.$$idb.values()).map((x) => | ||
this.$$deserialize(x) | ||
) as T[]; | ||
/** | ||
* Loads data from an IndexedDB instance, deserializes it, and updates the observable array silently without triggering observers. | ||
*/ | ||
private async $$loadFromLocal(): Promise<void> { | ||
// Check if IndexedDB instance is available | ||
if (!this.$$localPersistence) return; | ||
// Retrieve values from IndexedDB and deserialize them | ||
const deserialized: T[] = await Promise.all( | ||
(await this.$$localPersistence.getAll()).map((x) => this.$$deserialize(x)) | ||
); | ||
// Update the observable array silently with deserialized data | ||
this.$$observableObject.silently((o) => { | ||
@@ -118,3 +132,3 @@ o.splice(0, o.length, ...deserialized); | ||
private async $$processChanges() { | ||
if (!this.$$idb) return; | ||
if (!this.$$localPersistence) return; | ||
if (this.$$changes.length === 0) return; | ||
@@ -124,4 +138,3 @@ this.onSyncStart(); | ||
const toWriteLocally: [string, string][] = []; | ||
const toSendRemotely: { [key: string]: string } = {}; | ||
const toWrite: [string, string][] = []; | ||
const toDeffer: deferredArray = []; | ||
@@ -136,4 +149,3 @@ const changesToProcess = [...this.$$changes]; // Create a copy of changes to process | ||
const serializedLine = this.$$serialize(item); | ||
toWriteLocally.push([item.id, serializedLine]); | ||
toSendRemotely[item.id] = serializedLine; | ||
toWrite.push([item.id, serializedLine]); | ||
toDeffer.push({ | ||
@@ -144,9 +156,12 @@ ts: Date.now(), | ||
} | ||
await this.$$idb.setBulk(toWriteLocally); | ||
const deferred = (await this.$$idb.getMetadata("deferred")) || "[]"; | ||
let deferredArray = JSON.parse(deferred) as deferredArray; | ||
if (this.isOnline && this.$$syncService && deferredArray.length === 0) { | ||
await this.$$localPersistence.put(toWrite); | ||
let deferredArray = await this.$$localPersistence.getDeferred(); | ||
if ( | ||
this.isOnline && | ||
this.$$remotePersistence && | ||
deferredArray.length === 0 | ||
) { | ||
try { | ||
await this.$$syncService.sendUpdates(toSendRemotely); | ||
await this.$$remotePersistence.put(toWrite); | ||
this.onSyncEnd(); | ||
@@ -166,4 +181,5 @@ return; | ||
*/ | ||
deferredArray = deferredArray.concat(...toDeffer); | ||
await this.$$idb.setMetadata("deferred", JSON.stringify(deferredArray)); | ||
await this.$$localPersistence.putDeferred( | ||
deferredArray.concat(...toDeffer) | ||
); | ||
this.deferredPresent = true; | ||
@@ -197,7 +213,2 @@ this.onSyncEnd(); | ||
private async $$localVersion() { | ||
if (!this.$$idb) return 0; | ||
return Number((await this.$$idb.getMetadata("version")) || 0); | ||
} | ||
/** | ||
@@ -240,10 +251,10 @@ * | ||
}> { | ||
if (!this.$$idb) { | ||
if (!this.$$localPersistence) { | ||
return { | ||
exception: "IDB not available", | ||
exception: "Local persistence not available", | ||
}; | ||
} | ||
if (!this.$$syncService) { | ||
if (!this.$$remotePersistence) { | ||
return { | ||
exception: "Sync service not available", | ||
exception: "Remote persistence not available", | ||
}; | ||
@@ -257,6 +268,5 @@ } | ||
try { | ||
const localVersion = await this.$$localVersion(); | ||
const remoteVersion = await this.$$syncService.latestVersion(); | ||
const deferred = (await this.$$idb.getMetadata("deferred")) || "[]"; | ||
let deferredArray = JSON.parse(deferred) as deferredArray; | ||
const localVersion = await this.$$localPersistence.getVersion(); | ||
const remoteVersion = await this.$$remotePersistence.getVersion(); | ||
let deferredArray = await this.$$localPersistence.getDeferred(); | ||
@@ -270,3 +280,5 @@ if (localVersion === remoteVersion && deferredArray.length === 0) { | ||
// fetch updates since our local version | ||
const remoteUpdates = await this.$$syncService.fetchData(localVersion); | ||
const remoteUpdates = await this.$$remotePersistence.getSince( | ||
localVersion | ||
); | ||
@@ -277,2 +289,3 @@ // check for conflicts | ||
const conflict = remoteUpdates.rows.findIndex((y) => y.id === item.id); | ||
// take row-specific version if available, otherwise rely on latest version | ||
const comparison = Number( | ||
@@ -299,21 +312,25 @@ ( | ||
// we should start with remote | ||
for (const remote of remoteUpdates.rows) { | ||
await this.$$idb.set(remote.id, remote.data); | ||
} | ||
await this.$$localPersistence.put( | ||
remoteUpdates.rows.map((row) => [row.id, row.data]) | ||
); | ||
// then local | ||
const updatedRows: { [key: string]: string } = {}; | ||
const updatedRows = new Map(); | ||
for (const local of deferredArray) { | ||
let item = this.$$deserialize(local.data); | ||
updatedRows[item.id] = local.data; | ||
updatedRows.set(item.id, local.data); | ||
// latest deferred write wins since it would overwrite the previous one | ||
} | ||
await this.$$syncService.sendUpdates(updatedRows); | ||
await this.$$remotePersistence.put( | ||
[...updatedRows.keys()].map((x) => [x, updatedRows.get(x)]) | ||
); | ||
// reset deferred | ||
await this.$$idb.setMetadata("deferred", "[]"); | ||
await this.$$localPersistence.putDeferred([]); | ||
this.deferredPresent = false; | ||
// set local version | ||
await this.$$idb.setMetadata("version", remoteUpdates.version.toString()); | ||
// set local version to the version given by the current request | ||
// this might be outdated as soon as this functions ends | ||
// that's why this function will run on a while loop (below) | ||
await this.$$localPersistence.putVersion(remoteUpdates.version); | ||
@@ -366,18 +383,20 @@ // but if we had deferred updates then the remoteUpdates.version is outdated | ||
get list() { | ||
return this.$$observableObject.observable.filter((x) => !x.$$deleted); | ||
return this.$$observableObject.target.filter((x) => !x.$$deleted); | ||
} | ||
copy = this.$$observableObject.copy; | ||
getByID(id: string) { | ||
return this.$$observableObject.observable.find((x) => x.id === id); | ||
return this.$$observableObject.target.find((x) => x.id === id); | ||
} | ||
add(item: T) { | ||
if (this.$$observableObject.observable.find((x) => x.id === item.id)) { | ||
if (this.$$observableObject.target.find((x) => x.id === item.id)) { | ||
throw new Error("Duplicate ID detected: " + JSON.stringify(item.id)); | ||
} | ||
this.$$observableObject.observable.push(item); | ||
this.$$observableObject.target.push(item); | ||
} | ||
delete(item: T) { | ||
const index = this.$$observableObject.observable.findIndex( | ||
const index = this.$$observableObject.target.findIndex( | ||
(x) => x.id === item.id | ||
@@ -392,12 +411,10 @@ ); | ||
deleteByIndex(index: number) { | ||
if (!this.$$observableObject.observable[index]) { | ||
if (!this.$$observableObject.target[index]) { | ||
throw new Error("Item not found."); | ||
} | ||
this.$$observableObject.observable[index].$$deleted = true; | ||
this.$$observableObject.target[index].$$deleted = true; | ||
} | ||
deleteByID(id: string) { | ||
const index = this.$$observableObject.observable.findIndex( | ||
(x) => x.id === id | ||
); | ||
const index = this.$$observableObject.target.findIndex((x) => x.id === id); | ||
if (index === -1) { | ||
@@ -410,9 +427,9 @@ throw new Error("Item not found."); | ||
updateByIndex(index: number, item: T) { | ||
if (!this.$$observableObject.observable[index]) { | ||
if (!this.$$observableObject.target[index]) { | ||
throw new Error("Item not found."); | ||
} | ||
if (this.$$observableObject.observable[index].id !== item.id) { | ||
if (this.$$observableObject.target[index].id !== item.id) { | ||
throw new Error("ID mismatch."); | ||
} | ||
this.$$observableObject.observable[index] = item; | ||
this.$$observableObject.target[index] = item; | ||
} | ||
@@ -423,7 +440,9 @@ | ||
async isUpdated() { | ||
return this.$$syncService | ||
? (await this.$$syncService.latestVersion()) === | ||
(await this.$$localVersion()) | ||
: true; | ||
if (this.$$localPersistence && this.$$remotePersistence) { | ||
return ( | ||
(await this.$$localPersistence.getVersion()) === | ||
(await this.$$remotePersistence.getVersion()) | ||
); | ||
} else return false; | ||
} | ||
} | ||
} |
@@ -1,89 +0,91 @@ | ||
import { IDB } from '../src/idb'; | ||
import { describe, test, expect } from 'vitest'; | ||
import { deferredArray, IDB } from '../src/persistence/local'; | ||
import "fake-indexeddb/auto"; | ||
describe('IDB', () => { | ||
test('get', async () => { | ||
const idb = new IDB('testDB'); | ||
await idb.set('key1', 'value1'); | ||
const result = await idb.get('key1'); | ||
expect(result).toBe('value1'); | ||
}); | ||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; | ||
test('getBulk', async () => { | ||
const idb = new IDB('testDB'); | ||
await idb.set('key1', 'value1'); | ||
await idb.set('key2', 'value2'); | ||
const result = await idb.getBulk(['key1', 'key2']); | ||
expect(result).toEqual(['value1', 'value2']); | ||
}); | ||
describe('IDB Class', () => { | ||
const dbName = 'testDB'; | ||
let idb: IDB; | ||
test('set', async () => { | ||
const idb = new IDB('testDB'); | ||
await idb.set('key1', 'value1'); | ||
const result = await idb.get('key1'); | ||
expect(result).toBe('value1'); | ||
}); | ||
beforeEach(async () => { | ||
idb = new IDB({ name: dbName }); | ||
}); | ||
test('setBulk', async () => { | ||
const idb = new IDB('testDB'); | ||
await idb.setBulk([['key1', 'value1'], ['key2', 'value2']]); | ||
const result1 = await idb.get('key1'); | ||
const result2 = await idb.get('key2'); | ||
expect(result1).toBe('value1'); | ||
expect(result2).toBe('value2'); | ||
}); | ||
afterEach(async () => { | ||
await idb.clear(); | ||
await idb.clearMetadata() | ||
}); | ||
test('delBulk', async () => { | ||
const idb = new IDB('testDB'); | ||
await idb.set('key1', 'value1'); | ||
await idb.set('key2', 'value2'); | ||
await idb.delBulk(['key1', 'key2']); | ||
const result1 = await idb.get('key1'); | ||
const result2 = await idb.get('key2'); | ||
expect(result1).toBeUndefined(); | ||
expect(result2).toBeUndefined(); | ||
it('should initialize the database and object stores', async () => { | ||
// Simulate the request to ensure the database and object stores are created | ||
const request = indexedDB.open(dbName); | ||
const result = await new Promise<IDBDatabase>((resolve, reject) => { | ||
request.onsuccess = () => resolve(request.result); | ||
request.onerror = () => reject(request.error); | ||
}); | ||
test('clear', async () => { | ||
const idb = new IDB('testDB'); | ||
await idb.set('key1', 'value1'); | ||
await idb.set('key2', 'value2'); | ||
await idb.clear(); | ||
const result1 = await idb.get('key1'); | ||
const result2 = await idb.get('key2'); | ||
expect(result1).toBeUndefined(); | ||
expect(result2).toBeUndefined(); | ||
}); | ||
expect(result.objectStoreNames.contains(dbName)).toBe(true); | ||
expect(result.objectStoreNames.contains('metadata')).toBe(true); | ||
}); | ||
test('keys', async () => { | ||
const idb = new IDB('testDB'); | ||
await idb.set('key1', 'value1'); | ||
await idb.set('key2', 'value2'); | ||
const result = await idb.keys(); | ||
expect(result).toEqual(['key1', 'key2']); | ||
}); | ||
it('should store and retrieve multiple entries', async () => { | ||
const entries = [['key1', 'value1'], ['key2', 'value2']] as [string, string][]; | ||
await idb.put(entries); | ||
test('values', async () => { | ||
const idb = new IDB('testDB'); | ||
await idb.set('key1', 'value1'); | ||
await idb.set('key2', 'value2'); | ||
const result = await idb.values(); | ||
expect(result).toEqual(['value1', 'value2']); | ||
}); | ||
const allEntries = await idb.getAll(); | ||
expect(allEntries).toContain('value1'); | ||
expect(allEntries).toContain('value2'); | ||
}); | ||
test('setMetadata', async () => { | ||
const idb = new IDB('testDB'); | ||
await idb.setMetadata('metadata1', 'value1'); | ||
const result = await idb.getMetadata('metadata1'); | ||
expect(result).toBe('value1'); | ||
}); | ||
it('should store and retrieve metadata', async () => { | ||
await idb.setMetadata('testKey', 'testValue'); | ||
const value = await idb.getMetadata('testKey'); | ||
test('getMetadata', async () => { | ||
const idb = new IDB('testDB'); | ||
await idb.setMetadata('metadata1', 'value1'); | ||
const result = await idb.getMetadata('metadata1'); | ||
expect(result).toBe('value1'); | ||
}); | ||
}); | ||
expect(value).toBe('testValue'); | ||
}); | ||
it('should store and retrieve version', async () => { | ||
await idb.putVersion(1); | ||
const version = await idb.getVersion(); | ||
expect(version).toBe(1); | ||
}); | ||
it('should store and retrieve deferred array', async () => { | ||
const deferredArray: deferredArray = [{ data: "data", ts: 12 }, {data: "data2", ts: 24}]; | ||
await idb.putDeferred(deferredArray); | ||
const retrievedArray = await idb.getDeferred(); | ||
expect(retrievedArray).toEqual(deferredArray); | ||
}); | ||
it('should clear all entries', async () => { | ||
const entries = [['key1', 'value1'], ['key2', 'value2']] as [string, string][]; | ||
await idb.put(entries); | ||
await idb.clear(); | ||
const allEntries = await idb.getAll(); | ||
expect(allEntries.length).toBe(0); | ||
}); | ||
it('should clear metadata', async () => { | ||
await idb.setMetadata('testKey', 'testValue'); | ||
await idb.clearMetadata(); | ||
const value = await idb.getMetadata('testKey'); | ||
expect(value).toBeUndefined(); | ||
}); | ||
it('should handle concurrent transactions', async () => { | ||
const entries1 = [['key1', 'value1']] as [string, string][]; | ||
const entries2 = [['key2', 'value2']] as [string, string][]; | ||
await Promise.all([idb.put(entries1), idb.put(entries2)]); | ||
const allEntries = await idb.getAll(); | ||
expect(allEntries).toContain('value1'); | ||
expect(allEntries).toContain('value2'); | ||
}); | ||
}); |
@@ -1,118 +0,330 @@ | ||
import { describe, test, it, expect } from "vitest"; | ||
import { | ||
observable, | ||
isObservable, | ||
Change, | ||
ObservableArray, | ||
} from "../src/observable"; | ||
import { describe, test, it, expect, vi } from "vitest"; | ||
import { Change, Observable } from "../src/observable"; | ||
describe("observable", () => { | ||
test("should create an observable array", () => { | ||
const arr = [1, 2, 3]; | ||
const { observable: obsArr } = observable(arr); | ||
expect(isObservable(obsArr)).toBe(true); | ||
describe("initialization", () => { | ||
it("should initialize correctly with a regular array", () => { | ||
const arr = [1, 2, 3]; | ||
const observableArray = new Observable(arr); | ||
expect(Observable.isObservable(observableArray.target)).toBe( | ||
true | ||
); | ||
expect(JSON.stringify(observableArray.target)).toEqual( | ||
JSON.stringify(arr) | ||
); | ||
}); | ||
it("should initialize correctly with an observable array", () => { | ||
const arr = [1, 2, 3]; | ||
const observableArray = new Observable(arr); | ||
expect(Observable.isObservable(observableArray.target)).toBe( | ||
true | ||
); | ||
expect(JSON.stringify(observableArray.target)).toEqual( | ||
JSON.stringify(arr) | ||
); | ||
}); | ||
it("should maintain array methods and properties when adding elements", () => { | ||
const arr = [1, 2, 3]; | ||
const observableArray = new Observable(arr); | ||
observableArray.target.push(4); | ||
expect(observableArray.target.length).toBe(4); | ||
expect(observableArray.target.includes(2)).toBe(true); | ||
}); | ||
}); | ||
test("should observe changes in the array", async () => { | ||
const arr = [1, 2, 3]; | ||
const { observable: obsArr, observe } = observable(arr); | ||
let changes: Change<number[]>[] = []; | ||
describe("isObservable", () => { | ||
it("should identify non-observable array", () => { | ||
const arr = [1, 2, 3]; | ||
const observableArray = new Observable(arr); | ||
expect(Observable.isObservable(arr)).toBe(false); | ||
}); | ||
}); | ||
observe((c) => { | ||
changes = c; | ||
describe("observe", () => { | ||
it("should add an observer successfully when calling observe method", () => { | ||
const observer = (changes) => console.info(changes); | ||
const observableArray = new Observable([]); | ||
observableArray.observe(observer); | ||
expect(observableArray.observers).toContain(observer); | ||
}); | ||
test("should observe changes in the array", async () => { | ||
const arr = [1, 2, 3]; | ||
const o = new Observable(arr); | ||
let changes: Change<number[]>[] = []; | ||
obsArr.push(4); | ||
await new Promise((r) => setTimeout(r, 0)); | ||
o.observe((c) => { | ||
changes = c; | ||
}); | ||
expect(changes.length).toBe(1); | ||
expect(changes[0].type).toBe("insert"); | ||
expect(changes[0].path).toEqual([3]); | ||
expect(changes[0].value).toBe(4); | ||
}); | ||
o.target.push(4); | ||
await new Promise((r) => setTimeout(r, 100)); | ||
test("should unobserve changes in the array", async () => { | ||
const arr = [1, 2, 3]; | ||
const { observable: obsArr, observe, unobserve } = observable(arr); | ||
let changes: Change<number[]>[] = []; | ||
expect(changes.length).toBe(1); | ||
expect(changes[0].type).toBe("insert"); | ||
expect(changes[0].path).toEqual([3]); | ||
expect(changes[0].value).toBe(4); | ||
}); | ||
const observer = (c: Change<number[]>[]) => { | ||
changes = c; | ||
}; | ||
test("should observe multiple changes in the array", async () => { | ||
const arr = [1, 2, 3]; | ||
const o = new Observable(arr); | ||
let changes: Change<number[]>[] = []; | ||
observe(observer); | ||
o.observe((c) => { | ||
changes = c; | ||
}); | ||
obsArr.push(4); | ||
o.target.push(4); | ||
o.target.pop(); | ||
o.target.unshift(0); | ||
await new Promise((r) => setTimeout(r, 0)); | ||
await new Promise((r) => setTimeout(r, 0)); | ||
expect(changes.length).toBe(3); | ||
expect(changes[0].type).toBe("insert"); | ||
expect(changes[0].path).toEqual([3]); | ||
expect(changes[0].value).toBe(4); | ||
expect(changes[1].type).toBe("delete"); | ||
expect(changes[1].path).toEqual([3]); | ||
expect(changes[1].oldValue).toBe(4); | ||
expect(changes[2].type).toBe("insert"); | ||
expect(changes[2].path).toEqual([0]); | ||
expect(changes[2].value).toBe(0); | ||
}); | ||
expect(changes.length).toBe(1); | ||
test("should handle array modifications inside a nested array", async () => { | ||
const arr = [ | ||
[1, 2], | ||
[3, 4], | ||
]; | ||
const o = new Observable(arr); | ||
let changes: Change<number[][]>[] = []; | ||
await unobserve(observer); | ||
o.observe((c) => { | ||
changes = c; | ||
}); | ||
obsArr.push(5); | ||
o.target[0].push(5); | ||
await new Promise((r) => setTimeout(r, 0)); | ||
expect(changes.length).toBe(1); | ||
expect(changes.length).toBe(1); | ||
expect(changes[0].type).toBe("insert"); | ||
expect(changes[0].path).toEqual([0, 2]); | ||
expect(changes[0].value).toBe(5); | ||
}); | ||
}); | ||
test("should silently modify the array without notifying observers", () => { | ||
const arr = [1, 2, 3]; | ||
const { observable: obsArr, observe, silently } = observable(arr); | ||
let changes: Change<number[]>[] = []; | ||
describe("unobserve", () => { | ||
test("should unobserve changes in the array", async () => { | ||
const arr = [1, 2, 3]; | ||
const o = new Observable(arr); | ||
let changes: Change<number[]>[] = []; | ||
observe((c) => { | ||
changes = c; | ||
const observer = (c: Change<number[]>[]) => { | ||
changes = c; | ||
}; | ||
o.observe(observer); | ||
o.target.push(4); | ||
await new Promise((r) => setTimeout(r, 0)); | ||
expect(changes.length).toBe(1); | ||
o.unobserve(observer); | ||
o.target.push(5); | ||
expect(changes.length).toBe(1); | ||
}); | ||
silently((o) => { | ||
o.push(4); | ||
o.pop(); | ||
it("should handle removing non-existent observers gracefully", () => { | ||
const arr = [1, 2, 3]; | ||
const observableArray = new Observable(arr); | ||
const observer = (changes) => {}; | ||
observableArray.observe(() => {}); | ||
observableArray.unobserve(observer); // Trying to unobserve a non-existent observer | ||
expect(observableArray.observers.length).toBe(1); | ||
}); | ||
expect(changes.length).toBe(0); | ||
}); | ||
it("should remove a specific observer when unobserve is called with that observer", () => { | ||
const observer1 = (changes) => console.info("Observer 1:", changes); | ||
const observer2 = (changes) => console.info("Observer 2:", changes); | ||
const arr = [1, 2, 3]; | ||
const observableArray = new Observable(arr); | ||
test("should observe multiple changes in the array", async () => { | ||
const arr = [1, 2, 3]; | ||
const { observable: obsArr, observe } = observable(arr); | ||
let changes: Change<number[]>[] = []; | ||
observableArray.observe(observer1); | ||
observableArray.observe(observer2); | ||
expect(observableArray.observers.length).toBe(2); | ||
observe((c) => { | ||
changes = c; | ||
observableArray.unobserve(observer1); | ||
expect(observableArray.observers.length).toBe(1); | ||
expect(observableArray.observers[0]).toBe(observer2); | ||
}); | ||
obsArr.push(4); | ||
obsArr.pop(); | ||
obsArr.unshift(0); | ||
await new Promise((r) => setTimeout(r, 0)); | ||
it("should remove all observers when no argument is provided", () => { | ||
const observer1 = (changes) => console.info("Observer 1 called"); | ||
const observer2 = (changes) => console.info("Observer 2 called"); | ||
const observableArray = new Observable([1, 2, 3]); | ||
observableArray.observe(observer1); | ||
observableArray.observe(observer2); | ||
expect(changes.length).toBe(3); | ||
expect(changes[0].type).toBe("insert"); | ||
expect(changes[0].path).toEqual([3]); | ||
expect(changes[0].value).toBe(4); | ||
expect(changes[1].type).toBe("delete"); | ||
expect(changes[1].path).toEqual([3]); | ||
expect(changes[1].oldValue).toBe(4); | ||
expect(changes[2].type).toBe("insert"); | ||
expect(changes[2].path).toEqual([0]); | ||
expect(changes[2].value).toBe(0); | ||
observableArray.unobserve(); | ||
expect(observableArray.observers).toEqual([]); | ||
}); | ||
it("should not alter observers list if observer is not found", () => { | ||
const observer = (changes: Change<number[]>[]) => {}; | ||
const observableArray = new Observable<number>([]); | ||
observableArray.observe(observer); | ||
const result = observableArray.unobserve( | ||
(changes: Change<number[]>[]) => {} | ||
); | ||
expect(result).toEqual([]); | ||
}); | ||
it("should return removed observers", () => { | ||
const observer = (changes: Change<number[]>[]) => {}; | ||
const observableArray = new Observable([1, 2, 3]); | ||
observableArray.observe(observer); | ||
const result = observableArray.unobserve(); | ||
expect(result).toEqual([observer]); | ||
}); | ||
it("should return an empty array when no observers are removed", () => { | ||
const observer = (changes: Change<number[]>[]) => {}; | ||
const observableArray = new Observable([1, 2, 3]); | ||
observableArray.observe(observer); | ||
const result = observableArray.unobserve([]); | ||
expect(result).toEqual([]); | ||
}); | ||
}); | ||
test("should handle array modifications inside a nested array", async () => { | ||
const arr = [[1, 2], [3, 4]]; | ||
const { observable: obsArr, observe } = observable(arr); | ||
let changes: Change<number[][]>[] = []; | ||
describe("silently", () => { | ||
test("should silently modify the array without notifying observers", () => { | ||
const arr = [1, 2, 3]; | ||
const o = new Observable(arr); | ||
let changes: Change<number[]>[] = []; | ||
observe((c) => { | ||
changes = c; | ||
o.observe((c) => { | ||
changes = c; | ||
}); | ||
o.silently((o) => { | ||
o.push(4); | ||
o.pop(); | ||
}); | ||
expect(changes.length).toBe(0); | ||
}); | ||
obsArr[0].push(5); | ||
await new Promise((r) => setTimeout(r, 0)); | ||
it("should temporarily disable observers and re-enable them after execution", async () => { | ||
const arr = [1, 2, 3]; | ||
const observableArray = new Observable(arr); | ||
let observerCalled = false; | ||
const observer = (changes: Change<number[]>[]) => { | ||
observerCalled = true; | ||
}; | ||
observableArray.observe(observer); | ||
observableArray.silently((o) => { | ||
o[0] = 10; | ||
}); | ||
await new Promise((r) => setTimeout(r, 10)); | ||
expect(observerCalled).toBe(false); | ||
expect(changes.length).toBe(1); | ||
expect(changes[0].type).toBe("insert"); | ||
expect(changes[0].path).toEqual([0, 2]); | ||
expect(changes[0].value).toBe(5); | ||
observableArray.target.push(100); | ||
await new Promise((r) => setTimeout(r, 10)); | ||
expect(observerCalled).toBe(true); | ||
}); | ||
it("should temporarily disable observers and re-enable them after execution (deep)", async () => { | ||
const arr = [{ numbers: [1] }, { numbers: [2] }, { numbers: [3] }]; | ||
const observableArray = new Observable(arr); | ||
let observerCalled = false; | ||
observableArray.observe((changes) => { | ||
observerCalled = true; | ||
}); | ||
observableArray.silently((o) => { | ||
o[0].numbers[0] = 10; | ||
}); | ||
await new Promise((r) => setTimeout(r, 10)); | ||
expect(observerCalled).toBe(false); | ||
observableArray.target[2].numbers[0] = 30; | ||
await new Promise((r) => setTimeout(r, 10)); | ||
expect(observerCalled).toBe(true); | ||
}); | ||
it("should persist changes made during the work function", async () => { | ||
const arr = [1, 2, 3]; | ||
const observableArray = new Observable(arr); | ||
const observer = vi.fn(); | ||
observableArray.observe(observer); | ||
observableArray.silently((o) => { | ||
o[0] = 10; | ||
o.push(4); | ||
}); | ||
await new Promise((r) => setTimeout(r, 10)); | ||
expect(observer).toHaveBeenCalledTimes(0); | ||
expect(observableArray.copy).toEqual([10, 2, 3, 4]); | ||
}); | ||
it("should re-enable observers even if work function throws an exception", async () => { | ||
const arr = [1, 2, 3]; | ||
const observableArray = new Observable(arr); | ||
let observerCalled = false; | ||
const observer = (changes: Change<number[]>[]) => { | ||
observerCalled = true; | ||
}; | ||
observableArray.observe(observer); | ||
expect(Observable.isObservable(observableArray.target)).toBe( | ||
true | ||
); | ||
expect(observableArray.copy).toEqual(arr); | ||
try { | ||
observableArray.silently((o) => { | ||
throw new Error("Exception in work function"); | ||
}); | ||
} catch (e) {} | ||
await new Promise((r) => setTimeout(r, 10)); | ||
expect(observerCalled).toBe(false); | ||
observableArray.target[0] = 12; | ||
await new Promise((r) => setTimeout(r, 10)); | ||
expect(observerCalled).toBe(true); | ||
}); | ||
// Changes made before the exception should persist | ||
it("should persist changes made before an exception is thrown during the work function execution", () => { | ||
const arr = [1, 2, 3]; | ||
const observableArray = new Observable(arr); | ||
try { | ||
observableArray.silently((o) => { | ||
o[0] = 10; | ||
throw new Error("Exception during work function"); | ||
}); | ||
} catch (e) { | ||
// Exception thrown intentionally | ||
} | ||
expect(observableArray.target[0]).toBe(10); | ||
}); | ||
it("should propagate exception when work function throws an error", () => { | ||
const arr = [1, 2, 3]; | ||
const observableArray = new Observable(arr); | ||
const error = new Error("Test Error"); | ||
expect(() => { | ||
observableArray.silently(() => { | ||
throw error; | ||
}); | ||
}).toThrow(error); | ||
}); | ||
}); | ||
}); |
import { Store } from "../src/store"; | ||
import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
import { Miniflare } from "miniflare"; | ||
import "fake-indexeddb/auto"; | ||
import { D1Database, KVNamespace } from "@cloudflare/workers-types"; | ||
import { readFileSync, writeFileSync } from "fs"; | ||
import { IDB } from "../src/persistence/local"; | ||
import { CloudFlareApexoDB } from "../src/persistence/remote"; | ||
import "fake-indexeddb/auto"; | ||
@@ -19,6 +21,2 @@ describe("Store", () => { | ||
store = new Store({ | ||
name: Math.random().toString(36).substring(7), | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
}); | ||
@@ -30,31 +28,5 @@ | ||
).replace( | ||
`var Auth = class { | ||
static async authenticate(token) { | ||
try { | ||
const response = await fetch("https://auth1.apexo.app", { | ||
method: "PUT", | ||
body: JSON.stringify({ operation: "jwt", token }) | ||
}); | ||
const result = await response.json(); | ||
if (!result.success) { | ||
return { success: false }; | ||
} | ||
const account = JSON.parse(atob(token)).payload.prefix; | ||
return { success: true, account }; | ||
} catch (e) { | ||
return { success: false }; | ||
} | ||
} | ||
};`, | ||
`var Auth = class { | ||
static async authenticate(token) { | ||
try { | ||
return { success: true, account: "ali" }; | ||
} catch (e) { | ||
return { success: false }; | ||
} | ||
} | ||
}` | ||
/const response(.|\n)*return \{ success: true, account \};/, | ||
`return {success: true, account: "ali"}` | ||
); | ||
writeFileSync("./worker.js", workerFile); | ||
@@ -70,2 +42,3 @@ | ||
env.DB = await mf.getD1Database("DB"); | ||
global.fetch = mf.dispatchFetch as any; | ||
@@ -78,3 +51,2 @@ await env.DB.exec( | ||
); | ||
global.fetch = mf.dispatchFetch as any; | ||
}); | ||
@@ -148,6 +120,10 @@ | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
@@ -168,3 +144,3 @@ | ||
expect(await (store as any).$$localVersion()).toBe(99); | ||
expect(await (store as any).$$localPersistence.getVersion()).toBe(99); | ||
}); | ||
@@ -184,6 +160,10 @@ | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
@@ -204,3 +184,3 @@ | ||
expect(await (store as any).$$localVersion()).toBe(123); | ||
expect(await (store as any).$$localPersistence.getVersion()).toBe(123); | ||
@@ -235,3 +215,3 @@ await env.DB.prepare( | ||
expect(await (store as any).$$localVersion()).toBe(124); | ||
expect(await (store as any).$$localPersistence.getVersion()).toBe(124); | ||
}); | ||
@@ -243,15 +223,23 @@ | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
await (store as any).$$idb.clear(); | ||
await (store as any).$$idb.clearMetadata(); | ||
await (store as any).$$localPersistence.clear(); | ||
await (store as any).$$localPersistence.clearMetadata(); | ||
} | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
@@ -288,15 +276,23 @@ await new Promise((r) => setTimeout(r, 300)); | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
await (store as any).$$idb.clear(); | ||
await (store as any).$$idb.clearMetadata(); | ||
await (store as any).$$localPersistence.clear(); | ||
await (store as any).$$localPersistence.clearMetadata(); | ||
} | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
@@ -330,15 +326,23 @@ | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
await (store as any).$$idb.clear(); | ||
await (store as any).$$idb.clearMetadata(); | ||
await (store as any).$$localPersistence.clear(); | ||
await (store as any).$$localPersistence.clearMetadata(); | ||
} | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
@@ -372,15 +376,23 @@ { | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
await (store as any).$$idb.clear(); | ||
await (store as any).$$idb.clearMetadata(); | ||
await (store as any).$$localPersistence.clear(); | ||
await (store as any).$$localPersistence.clearMetadata(); | ||
} | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
@@ -418,3 +430,3 @@ | ||
expect(store.list[0].id).toBe("12"); | ||
expect(await (store as any).$$localVersion()).toBe(version); | ||
expect(await (store as any).$$localPersistence.getVersion()).toBe(version); | ||
}); | ||
@@ -426,15 +438,23 @@ | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
await (store as any).$$idb.clear(); | ||
await (store as any).$$idb.clearMetadata(); | ||
await (store as any).$$localPersistence.clear(); | ||
await (store as any).$$localPersistence.clearMetadata(); | ||
} | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
@@ -477,3 +497,3 @@ { | ||
expect(store.list[0].name).toBe("alex2"); | ||
expect(await (store as any).$$localVersion()).toBe(version); | ||
expect(await (store as any).$$localPersistence.getVersion()).toBe(version); | ||
}); | ||
@@ -485,15 +505,23 @@ | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
await (store as any).$$idb.clear(); | ||
await (store as any).$$idb.clearMetadata(); | ||
await (store as any).$$localPersistence.clear(); | ||
await (store as any).$$localPersistence.clearMetadata(); | ||
} | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
@@ -534,5 +562,5 @@ | ||
expect((store as any).$$observableObject.observable.length).toBe(1); | ||
expect((store as any).$$observableObject.target.length).toBe(1); | ||
expect(store.list.length).toBe(0); | ||
expect(await (store as any).$$localVersion()).toBe(version); | ||
expect(await (store as any).$$localPersistence.getVersion()).toBe(version); | ||
}); | ||
@@ -544,15 +572,23 @@ | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
await (store as any).$$idb.clear(); | ||
await (store as any).$$idb.clearMetadata(); | ||
await (store as any).$$localPersistence.clear(); | ||
await (store as any).$$localPersistence.clearMetadata(); | ||
} | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
@@ -610,15 +646,23 @@ { | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
await (store as any).$$idb.clear(); | ||
await (store as any).$$idb.clearMetadata(); | ||
await (store as any).$$localPersistence.clear(); | ||
await (store as any).$$localPersistence.clearMetadata(); | ||
} | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
@@ -688,15 +732,23 @@ { | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
await (store as any).$$idb.clear(); | ||
await (store as any).$$idb.clearMetadata(); | ||
await (store as any).$$localPersistence.clear(); | ||
await (store as any).$$localPersistence.clearMetadata(); | ||
} | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
@@ -720,2 +772,12 @@ { | ||
it("should not sync if not online", async () => { | ||
store = new Store({ | ||
remotePersistence: new CloudFlareApexoDB({ | ||
endpoint: "https://apexo-database.vercel.app", | ||
token: "any", | ||
name: "staff" | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff" | ||
}) | ||
}); | ||
store.isOnline = false; | ||
@@ -728,10 +790,28 @@ { | ||
it("should not sync if sync service is not available", async () => { | ||
(store as any).$$syncService = null; | ||
it("should not sync if local persistence is not available", async () => { | ||
store = new Store({ | ||
remotePersistence: new CloudFlareApexoDB({ | ||
endpoint: "https://apexo-database.vercel.app", | ||
token: "any", | ||
name: "staff" | ||
}) | ||
}); | ||
{ | ||
const tries = await store.sync(); | ||
expect(tries[0].exception).toBe("Sync service not available"); | ||
expect(tries[0].exception).toBe("Local persistence not available"); | ||
} | ||
}); | ||
it("should not sync if remote persistence is not available", async () => { | ||
store = new Store({ | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
{ | ||
const tries = await store.sync(); | ||
expect(tries[0].exception).toBe("Remote persistence not available"); | ||
} | ||
}); | ||
it("should sync push (deferred) and pull at the same time", async () => { | ||
@@ -741,15 +821,23 @@ { | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
await (store as any).$$idb.clear(); | ||
await (store as any).$$idb.clearMetadata(); | ||
await (store as any).$$localPersistence.clear(); | ||
await (store as any).$$localPersistence.clearMetadata(); | ||
} | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
debounceRate: 1, | ||
@@ -805,15 +893,23 @@ }); | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
await (store as any).$$idb.clear(); | ||
await (store as any).$$idb.clearMetadata(); | ||
await (store as any).$$localPersistence.clear(); | ||
await (store as any).$$localPersistence.clearMetadata(); | ||
} | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
@@ -868,15 +964,23 @@ store.add({ id: "0", name: "ali" }); | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
await (store as any).$$idb.clear(); | ||
await (store as any).$$idb.clearMetadata(); | ||
await (store as any).$$localPersistence.clear(); | ||
await (store as any).$$localPersistence.clearMetadata(); | ||
} | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
@@ -940,19 +1044,27 @@ { | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
await (store as any).$$idb.clear(); | ||
await (store as any).$$idb.clearMetadata(); | ||
await (store as any).$$localPersistence.clear(); | ||
await (store as any).$$localPersistence.clearMetadata(); | ||
} | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
expect(await (store as any).$$localVersion()).toBe(0); | ||
expect(await (store as any).$$syncService.latestVersion()).toBe(0); | ||
expect(await (store as any).$$localPersistence.getVersion()).toBe(0); | ||
expect(await (store as any).$$remotePersistence.getVersion()).toBe(0); | ||
{ | ||
@@ -962,4 +1074,4 @@ const tries = await store.sync(); | ||
} | ||
expect(await (store as any).$$localVersion()).toBe(0); | ||
expect(await (store as any).$$syncService.latestVersion()).toBe(0); | ||
expect(await (store as any).$$localPersistence.getVersion()).toBe(0); | ||
expect(await (store as any).$$remotePersistence.getVersion()).toBe(0); | ||
}); | ||
@@ -971,16 +1083,24 @@ | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
await (store as any).$$idb.clear(); | ||
await (store as any).$$idb.clearMetadata(); | ||
await (store as any).$$localPersistence.clear(); | ||
await (store as any).$$localPersistence.clearMetadata(); | ||
} | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
debounceRate: 1000, | ||
@@ -992,21 +1112,21 @@ }); | ||
store.add({ id: "1", name: "alex" }); | ||
expect((await (store as any).$$idb.values()).length).toBe(1); // 1 | ||
expect((await (store as any).$$localPersistence.getAll()).length).toBe(1); // 1 | ||
await new Promise((r) => setTimeout(r, 100)); | ||
expect((await (store as any).$$idb.values()).length).toBe(1); // 2 | ||
expect((await (store as any).$$localPersistence.getAll()).length).toBe(1); // 2 | ||
await new Promise((r) => setTimeout(r, 100)); | ||
expect((await (store as any).$$idb.values()).length).toBe(1); // 3 | ||
expect((await (store as any).$$localPersistence.getAll()).length).toBe(1); // 3 | ||
await new Promise((r) => setTimeout(r, 100)); | ||
expect((await (store as any).$$idb.values()).length).toBe(1); // 4 | ||
expect((await (store as any).$$localPersistence.getAll()).length).toBe(1); // 4 | ||
await new Promise((r) => setTimeout(r, 100)); | ||
expect((await (store as any).$$idb.values()).length).toBe(1); // 5 | ||
expect((await (store as any).$$localPersistence.getAll()).length).toBe(1); // 5 | ||
await new Promise((r) => setTimeout(r, 100)); | ||
expect((await (store as any).$$idb.values()).length).toBe(1); // 6 | ||
expect((await (store as any).$$localPersistence.getAll()).length).toBe(1); // 6 | ||
await new Promise((r) => setTimeout(r, 100)); | ||
expect((await (store as any).$$idb.values()).length).toBe(1); // 7 | ||
expect((await (store as any).$$localPersistence.getAll()).length).toBe(1); // 7 | ||
await new Promise((r) => setTimeout(r, 100)); | ||
expect((await (store as any).$$idb.values()).length).toBe(1); // 8 | ||
expect((await (store as any).$$localPersistence.getAll()).length).toBe(1); // 8 | ||
await new Promise((r) => setTimeout(r, 100)); | ||
expect((await (store as any).$$idb.values()).length).toBe(1); // 9 | ||
expect((await (store as any).$$localPersistence.getAll()).length).toBe(1); // 9 | ||
await new Promise((r) => setTimeout(r, 150)); | ||
expect((await (store as any).$$idb.values()).length).toBe(2); // 10.5 | ||
expect((await (store as any).$$localPersistence.getAll()).length).toBe(2); // 10.5 | ||
}); | ||
@@ -1018,16 +1138,24 @@ | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
await (store as any).$$idb.clear(); | ||
await (store as any).$$idb.clearMetadata(); | ||
await (store as any).$$localPersistence.clear(); | ||
await (store as any).$$localPersistence.clearMetadata(); | ||
} | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
debounceRate: 500, | ||
@@ -1039,11 +1167,11 @@ }); | ||
store.add({ id: "1", name: "alex" }); | ||
expect((await (store as any).$$idb.values()).length).toBe(1); // 1 | ||
expect((await (store as any).$$localPersistence.getAll()).length).toBe(1); // 1 | ||
await new Promise((r) => setTimeout(r, 100)); | ||
expect((await (store as any).$$idb.values()).length).toBe(1); // 2 | ||
expect((await (store as any).$$localPersistence.getAll()).length).toBe(1); // 2 | ||
await new Promise((r) => setTimeout(r, 100)); | ||
expect((await (store as any).$$idb.values()).length).toBe(1); // 3 | ||
expect((await (store as any).$$localPersistence.getAll()).length).toBe(1); // 3 | ||
await new Promise((r) => setTimeout(r, 100)); | ||
expect((await (store as any).$$idb.values()).length).toBe(1); // 4 | ||
expect((await (store as any).$$localPersistence.getAll()).length).toBe(1); // 4 | ||
await new Promise((r) => setTimeout(r, 150)); | ||
expect((await (store as any).$$idb.values()).length).toBe(2); // 5.5 | ||
expect((await (store as any).$$localPersistence.getAll()).length).toBe(2); // 5.5 | ||
}); | ||
@@ -1055,15 +1183,23 @@ | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
await (store as any).$$idb.clear(); | ||
await (store as any).$$idb.clearMetadata(); | ||
await (store as any).$$localPersistence.clear(); | ||
await (store as any).$$localPersistence.clearMetadata(); | ||
} | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
@@ -1136,15 +1272,23 @@ { | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
await (store as any).$$idb.clear(); | ||
await (store as any).$$idb.clearMetadata(); | ||
await (store as any).$$localPersistence.clear(); | ||
await (store as any).$$localPersistence.clearMetadata(); | ||
} | ||
store = new Store({ | ||
name: "staff", | ||
token: token, | ||
persist: true, | ||
endpoint: "http://example.com", | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
@@ -1216,2 +1360,93 @@ { | ||
}); | ||
it("Rely on the specific version of the row when it is available", async () => { | ||
{ | ||
// clearing local database before starting | ||
store = new Store({ | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
await (store as any).$$localPersistence.clear(); | ||
await (store as any).$$localPersistence.clearMetadata(); | ||
} | ||
store = new Store({ | ||
remotePersistence: new CloudFlareApexoDB({ | ||
token, | ||
endpoint: "https://apexo-database.vercel.app", | ||
name: "staff", | ||
}), | ||
localPersistence: new IDB({ | ||
name: "staff", | ||
}), | ||
}); | ||
{ | ||
const tries = await store.sync(); | ||
expect(tries[0].exception).toBe("Nothing to sync"); | ||
} | ||
store.add({ id: "1", name: "alex" }); | ||
await new Promise((r) => setTimeout(r, 300)); | ||
{ | ||
const tries = await store.sync(); | ||
expect(tries[0].pulled).toBe(1); | ||
expect(tries[0].pushed).toBe(0); | ||
expect(tries[1].exception).toBe("Nothing to sync"); | ||
} | ||
store.isOnline = false; | ||
store.updateByIndex(0, { id: "1", name: "mathew" }); | ||
await new Promise((r) => setTimeout(r, 300)); | ||
expect(store.deferredPresent).toBe(true); | ||
await env.DB.exec( | ||
`UPDATE staff SET data = '{"id":"1","name":"john"}' WHERE id = 1` | ||
); | ||
await env.DB.exec( | ||
'INSERT INTO staff (id, account, data) VALUES (\'2\', \'ali\', \'{"id":"2","name":"ron"}\');' | ||
); | ||
const deferredVersion = Number( | ||
JSON.parse( | ||
await (store as any).$$localPersistence.getMetadata("deferred") | ||
)[0].ts | ||
); | ||
const localVersion = Number(await (store as any).$$localPersistence.getVersion()); | ||
expect(deferredVersion).toBeGreaterThan(localVersion); | ||
const remoteConflictVersion = (deferredVersion + localVersion) / 2; | ||
await env.DB.exec( | ||
`INSERT INTO staff_changes (version, account, ids) VALUES (${remoteConflictVersion}, 'ali', '1');` | ||
); | ||
await env.DB.exec( | ||
`INSERT INTO staff_changes (version, account, ids) VALUES (${ | ||
deferredVersion + 1000 | ||
}, 'ali', '2');` | ||
); | ||
store.isOnline = true; | ||
const keys = (await env.CACHE.list()).keys.map((x) => x.name); | ||
for (let index = 0; index < keys.length; index++) { | ||
const element = keys[index]; | ||
await env.CACHE.delete(element); | ||
} | ||
{ | ||
const tries = await store.sync(); | ||
expect(tries[0].pulled).toBe(1); | ||
expect(tries[0].pushed).toBe(1); // deferred won | ||
expect(tries[1].exception).toBe("Nothing to sync"); | ||
} | ||
expect(JSON.stringify(store.list)).toBe( | ||
`[{"id":"1","name":"mathew"},{"id":"2","name":"ron"}]` | ||
); | ||
}); | ||
}); |
231621
45
6348
43