feat: implement vue-sync-engine with tab synchronization and transport layers

This commit is contained in:
2026-05-28 14:14:29 +07:00
parent fd228db820
commit 654bca0a00
43 changed files with 4128 additions and 0 deletions
@@ -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'),
}
}