feat: implement vue-sync-engine with tab synchronization and transport layers
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
export interface StoreSpec {
|
||||
name: string
|
||||
keyPath?: string
|
||||
}
|
||||
|
||||
class IdbManager {
|
||||
readonly dbName: string
|
||||
private pending = new Map<string, StoreSpec>()
|
||||
private dbPromise: Promise<IDBDatabase> | null = null
|
||||
|
||||
constructor(dbName: string) {
|
||||
this.dbName = dbName
|
||||
}
|
||||
|
||||
registerStore(spec: StoreSpec | string): void {
|
||||
const s: StoreSpec = typeof spec === 'string' ? { name: spec } : spec
|
||||
const cur = this.pending.get(s.name)
|
||||
if (cur === undefined || (cur.keyPath === undefined && s.keyPath !== undefined)) {
|
||||
this.pending.set(s.name, s)
|
||||
}
|
||||
}
|
||||
|
||||
async getDb(): Promise<IDBDatabase> {
|
||||
if (this.dbPromise) {
|
||||
const db = await this.dbPromise
|
||||
const missing = this.missing(db)
|
||||
if (missing.length === 0) return db
|
||||
db.close()
|
||||
this.dbPromise = this.open(db.version + 1, missing)
|
||||
return this.dbPromise
|
||||
}
|
||||
this.dbPromise = (async () => {
|
||||
const initial = [...this.pending.values()]
|
||||
const db = await this.open(undefined, initial)
|
||||
const missing = this.missing(db)
|
||||
if (missing.length === 0) return db
|
||||
db.close()
|
||||
return this.open(db.version + 1, missing)
|
||||
})()
|
||||
return this.dbPromise
|
||||
}
|
||||
|
||||
async run<T>(
|
||||
storeName: string,
|
||||
mode: IDBTransactionMode,
|
||||
fn: (store: IDBObjectStore) => IDBRequest<T>,
|
||||
): Promise<T> {
|
||||
const db = await this.getDb()
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, mode)
|
||||
const req = fn(tx.objectStore(storeName))
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error)
|
||||
})
|
||||
}
|
||||
|
||||
async runTx(
|
||||
storeName: string,
|
||||
mode: IDBTransactionMode,
|
||||
fn: (store: IDBObjectStore) => void,
|
||||
): Promise<void> {
|
||||
const db = await this.getDb()
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, mode)
|
||||
fn(tx.objectStore(storeName))
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error)
|
||||
})
|
||||
}
|
||||
|
||||
private missing(db: IDBDatabase): StoreSpec[] {
|
||||
const out: StoreSpec[] = []
|
||||
for (const s of this.pending.values()) if (!db.objectStoreNames.contains(s.name)) out.push(s)
|
||||
return out
|
||||
}
|
||||
|
||||
private open(version: number | undefined, create: readonly StoreSpec[]): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = version === undefined ? indexedDB.open(this.dbName) : indexedDB.open(this.dbName, version)
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result
|
||||
for (const s of create) {
|
||||
if (db.objectStoreNames.contains(s.name)) continue
|
||||
db.createObjectStore(s.name, s.keyPath ? { keyPath: s.keyPath } : undefined)
|
||||
}
|
||||
}
|
||||
req.onsuccess = () => {
|
||||
const db = req.result
|
||||
db.onversionchange = () => db.close()
|
||||
resolve(db)
|
||||
}
|
||||
req.onerror = () => reject(req.error)
|
||||
req.onblocked = () => reject(new Error(`IDB open blocked: ${this.dbName}`))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const managers = new Map<string, IdbManager>()
|
||||
|
||||
export function getIdbManager(dbName: string): IdbManager {
|
||||
let m = managers.get(dbName)
|
||||
if (!m) {
|
||||
m = new IdbManager(dbName)
|
||||
managers.set(dbName, m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
export type { IdbManager }
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { EntityId } from '../core/types'
|
||||
import type { KeyedStore, KeyedStoreFactory } from '../core/keyedStore'
|
||||
import { getIdbManager } from './idbManager'
|
||||
|
||||
export interface IdbStoreOptions {
|
||||
dbName: string
|
||||
storeName?: string
|
||||
}
|
||||
|
||||
export function idbStore<T>(opts: IdbStoreOptions): KeyedStoreFactory<T> {
|
||||
const mgr = getIdbManager(opts.dbName)
|
||||
return (name) => {
|
||||
const store = opts.storeName ?? name
|
||||
mgr.registerStore(store)
|
||||
return {
|
||||
read(key: EntityId) {
|
||||
return mgr.run(store, 'readonly', (s) => s.get(asKey(key)) as IDBRequest<T | undefined>)
|
||||
},
|
||||
async readMany(keys: readonly EntityId[]) {
|
||||
if (keys.length === 0) return []
|
||||
const db = await mgr.getDb()
|
||||
return new Promise<Array<T | undefined>>((resolve, reject) => {
|
||||
const tx = db.transaction(store, 'readonly')
|
||||
const os = tx.objectStore(store)
|
||||
const out: Array<T | undefined> = new Array(keys.length)
|
||||
let pending = keys.length
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const req = os.get(asKey(keys[i]))
|
||||
const idx = i
|
||||
req.onsuccess = () => {
|
||||
out[idx] = req.result as T | undefined
|
||||
if (--pending === 0) resolve(out)
|
||||
}
|
||||
req.onerror = () => reject(req.error)
|
||||
}
|
||||
})
|
||||
},
|
||||
readAll() {
|
||||
return mgr.run(store, 'readonly', (s) => s.getAll() as IDBRequest<T[]>)
|
||||
},
|
||||
write(items) {
|
||||
if (items.length === 0) return Promise.resolve()
|
||||
return mgr.runTx(store, 'readwrite', (os) => {
|
||||
for (let i = 0; i < items.length; i++) os.put(items[i].value, asKey(items[i].key))
|
||||
})
|
||||
},
|
||||
delete(key: EntityId) {
|
||||
return mgr.runTx(store, 'readwrite', (os) => {
|
||||
os.delete(asKey(key))
|
||||
})
|
||||
},
|
||||
} satisfies KeyedStore<T>
|
||||
}
|
||||
}
|
||||
|
||||
function asKey(k: EntityId): IDBValidKey {
|
||||
return typeof k === 'number' ? k : String(k)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { EntityId } from '../core/types'
|
||||
import type { KeyedStore, KeyedStoreFactory } from '../core/keyedStore'
|
||||
|
||||
export function memoryStore<T>(): KeyedStoreFactory<T> {
|
||||
return () => {
|
||||
const m = new Map<EntityId, T>()
|
||||
return {
|
||||
async read(key) {
|
||||
return m.get(key)
|
||||
},
|
||||
async readMany(keys) {
|
||||
const out: Array<T | undefined> = new Array(keys.length)
|
||||
for (let i = 0; i < keys.length; i++) out[i] = m.get(keys[i])
|
||||
return out
|
||||
},
|
||||
async readAll() {
|
||||
return [...m.values()]
|
||||
},
|
||||
async write(items) {
|
||||
for (let i = 0; i < items.length; i++) m.set(items[i].key, items[i].value)
|
||||
},
|
||||
async delete(key) {
|
||||
m.delete(key)
|
||||
},
|
||||
} satisfies KeyedStore<T>
|
||||
}
|
||||
}
|
||||
|
||||
export function noopStore<T>(): KeyedStoreFactory<T> {
|
||||
return () => noop as KeyedStore<T>
|
||||
}
|
||||
|
||||
const noop: KeyedStore<unknown> = {
|
||||
async read() {
|
||||
return undefined
|
||||
},
|
||||
async readMany(keys) {
|
||||
return new Array(keys.length).fill(undefined)
|
||||
},
|
||||
async readAll() {
|
||||
return []
|
||||
},
|
||||
async write() {},
|
||||
async delete() {},
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { QueuedMutation, QuerySnapshot } from '../core/types'
|
||||
import type { KeyedStore } from '../core/keyedStore'
|
||||
import { memoryStore } from './memoryStore'
|
||||
import { idbStore } from './idbStore'
|
||||
|
||||
export interface StorageAdapter {
|
||||
queries: KeyedStore<QuerySnapshot>
|
||||
mutations: KeyedStore<QueuedMutation>
|
||||
}
|
||||
|
||||
export function memoryAdapter(): StorageAdapter {
|
||||
return {
|
||||
queries: memoryStore<QuerySnapshot>()('queries'),
|
||||
mutations: memoryStore<QueuedMutation>()('mutations'),
|
||||
}
|
||||
}
|
||||
|
||||
export interface IndexedDBAdapterOptions {
|
||||
dbName?: string
|
||||
}
|
||||
|
||||
export function indexedDBAdapter(opts: IndexedDBAdapterOptions = {}): StorageAdapter {
|
||||
const dbName = opts.dbName ?? 'sync-engine'
|
||||
return {
|
||||
queries: idbStore<QuerySnapshot>({ dbName })('queries'),
|
||||
mutations: idbStore<QueuedMutation>({ dbName })('mutations'),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user