WIP re-implement persistence with compaction
This commit is contained in:
@@ -0,0 +1,149 @@
|
|||||||
|
import * as Y from 'yjs';
|
||||||
|
import { docsDb, LOCAL_DELTA_STORE } from './sync';
|
||||||
|
import { assert } from './util';
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
const COMPACTION_THRESHOLD = 200;
|
||||||
|
|
||||||
|
export async function openDoc(id: string): Promise<Y.Doc> {
|
||||||
|
// Return existing doc if we still have it open
|
||||||
|
const previousState = idToState.get(id);
|
||||||
|
if (previousState) {
|
||||||
|
previousState.refCount += 1;
|
||||||
|
return previousState.ydoc;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await initialLoad(id);
|
||||||
|
idToState.set(id, state);
|
||||||
|
docToState.set(state.ydoc, state);
|
||||||
|
|
||||||
|
return state.ydoc;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeDoc(doc: Y.Doc) {
|
||||||
|
const state = docToState.get(doc);
|
||||||
|
assert(state, 'Document is not already open');
|
||||||
|
|
||||||
|
state.refCount -= 1;
|
||||||
|
if (state.refCount <= 0) {
|
||||||
|
idToState.delete(state.id);
|
||||||
|
docToState.delete(doc);
|
||||||
|
doc.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const idToState = new Map<string, State>();
|
||||||
|
const docToState = new Map<Y.Doc, State>();
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
// TODO: Move more of this into class
|
||||||
|
id: string;
|
||||||
|
deltaBuffer: Uint8Array[];
|
||||||
|
numRecords: number;
|
||||||
|
refCount: number;
|
||||||
|
ydoc: Y.Doc;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function initialLoad(id: string): Promise<State> {
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
|
const records = await docsDb.getAll(
|
||||||
|
LOCAL_DELTA_STORE,
|
||||||
|
IDBKeyRange.bound([id, -Infinity], [id, Infinity])
|
||||||
|
);
|
||||||
|
Y.applyUpdate(ydoc, Y.mergeUpdates(records));
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
deltaBuffer: [],
|
||||||
|
numRecords: records.length,
|
||||||
|
refCount: 1,
|
||||||
|
ydoc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class BufferedWriter {
|
||||||
|
private deltaBuffer: Uint8Array[];
|
||||||
|
private docId: string;
|
||||||
|
private numRecords: number;
|
||||||
|
private debouncedFlush: () => void;
|
||||||
|
private ydoc: Y.Doc;
|
||||||
|
|
||||||
|
constructor(docId: string) {
|
||||||
|
this.deltaBuffer = [];
|
||||||
|
this.docId = docId;
|
||||||
|
this.numRecords = 0;
|
||||||
|
this.debouncedFlush = _.debounce(
|
||||||
|
() => {
|
||||||
|
this.flush();
|
||||||
|
},
|
||||||
|
1_000,
|
||||||
|
{ maxWait: 30_000 }
|
||||||
|
);
|
||||||
|
this.ydoc = new Y.Doc();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async connect(): Promise<Y.Doc> {
|
||||||
|
const records = await docsDb.getAll(
|
||||||
|
LOCAL_DELTA_STORE,
|
||||||
|
IDBKeyRange.bound([this.docId, -Infinity], [this.docId, Infinity])
|
||||||
|
);
|
||||||
|
this.numRecords = records.length;
|
||||||
|
Y.applyUpdate(this.ydoc, Y.mergeUpdates(records));
|
||||||
|
this.ydoc.on('update', (blob) => this.addUpdate(blob));
|
||||||
|
return this.ydoc;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addUpdate(update: Uint8Array) {
|
||||||
|
this.deltaBuffer.push(update);
|
||||||
|
this.debouncedFlush();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async flush(): Promise<void> {
|
||||||
|
if (this.deltaBuffer.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write delta
|
||||||
|
const delta = Y.mergeUpdates(this.deltaBuffer.splice(0));
|
||||||
|
try {
|
||||||
|
await docsDb.put(LOCAL_DELTA_STORE, delta, [
|
||||||
|
this.docId,
|
||||||
|
new Date().valueOf(),
|
||||||
|
]);
|
||||||
|
this.numRecords += 1;
|
||||||
|
} catch (err) {
|
||||||
|
this.addUpdate(delta);
|
||||||
|
console.log(`Error flushing document ${this.docId}: ${err}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact if necessary
|
||||||
|
try {
|
||||||
|
await this.compact();
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Error compacting document ${this.docId}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async compact() {
|
||||||
|
if (this.numRecords < COMPACTION_THRESHOLD) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tx = docsDb.transaction(LOCAL_DELTA_STORE, 'readwrite');
|
||||||
|
|
||||||
|
// Make sure we're not missing anything by pulling in all changes.
|
||||||
|
// This could happen if a document is being edited in two different tabs.
|
||||||
|
const fullRange = IDBKeyRange.bound(
|
||||||
|
[this.docId, -Infinity],
|
||||||
|
[this.docId, Infinity]
|
||||||
|
);
|
||||||
|
const records = await tx.store.getAll(fullRange);
|
||||||
|
Y.applyUpdate(this.ydoc, Y.mergeUpdates(records));
|
||||||
|
|
||||||
|
// Replace with a compacted blob
|
||||||
|
const compacted = Y.encodeStateAsUpdate(this.ydoc);
|
||||||
|
await tx.store.delete(fullRange);
|
||||||
|
await tx.store.put(compacted, [this.docId, new Date().valueOf()]);
|
||||||
|
tx.commit();
|
||||||
|
this.numRecords = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
+8
-8
@@ -5,8 +5,8 @@ import { assert, generateId } from './util';
|
|||||||
import { getDocsMap, getVaultMap, type DocMap } from './vault';
|
import { getDocsMap, getVaultMap, type DocMap } from './vault';
|
||||||
|
|
||||||
const DB_NAME = 'synced-docs';
|
const DB_NAME = 'synced-docs';
|
||||||
const LOCAL_DELTA_STORE = 'local-updates';
|
export const LOCAL_DELTA_STORE = 'local-updates';
|
||||||
const VAULT_STORE = 'vaults';
|
export const VAULT_STORE = 'vaults';
|
||||||
|
|
||||||
type LocalDeltaKey = [string, number];
|
type LocalDeltaKey = [string, number];
|
||||||
type VaultRecord = { id: string };
|
type VaultRecord = { id: string };
|
||||||
@@ -22,7 +22,7 @@ interface DocsDatabase extends DBSchema {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = await openDB<DocsDatabase>(DB_NAME, 3, {
|
export const docsDb = await openDB<DocsDatabase>(DB_NAME, 3, {
|
||||||
upgrade(db, oldVersion, newVersion) {
|
upgrade(db, oldVersion, newVersion) {
|
||||||
console.log(`Running upgrade from ${oldVersion} to ${newVersion}`);
|
console.log(`Running upgrade from ${oldVersion} to ${newVersion}`);
|
||||||
if (oldVersion < 2) {
|
if (oldVersion < 2) {
|
||||||
@@ -59,7 +59,7 @@ export class LocalDocument {
|
|||||||
|
|
||||||
public static async load(id: string): Promise<LocalDocument> {
|
public static async load(id: string): Promise<LocalDocument> {
|
||||||
// Build Y.Doc from records on disk
|
// Build Y.Doc from records on disk
|
||||||
const updates: Uint8Array[] = await db.getAll(
|
const updates: Uint8Array[] = await docsDb.getAll(
|
||||||
LOCAL_DELTA_STORE,
|
LOCAL_DELTA_STORE,
|
||||||
IDBKeyRange.bound([id, -Infinity], [id, Infinity])
|
IDBKeyRange.bound([id, -Infinity], [id, Infinity])
|
||||||
);
|
);
|
||||||
@@ -78,7 +78,7 @@ export class LocalDocument {
|
|||||||
if (batch.length > 0) {
|
if (batch.length > 0) {
|
||||||
const update = Y.mergeUpdates(batch);
|
const update = Y.mergeUpdates(batch);
|
||||||
const key: LocalDeltaKey = [this.id, new Date().valueOf()];
|
const key: LocalDeltaKey = [this.id, new Date().valueOf()];
|
||||||
await db.add(LOCAL_DELTA_STORE, update, key);
|
await docsDb.add(LOCAL_DELTA_STORE, update, key);
|
||||||
this.totalUpdates += 1;
|
this.totalUpdates += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,13 +90,13 @@ export class LocalDocument {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getOrCreateVaultDoc() {
|
async function getOrCreateVaultDoc() {
|
||||||
const records = await db.getAll(VAULT_STORE);
|
const records = await docsDb.getAll(VAULT_STORE);
|
||||||
if (records.length > 1) {
|
if (records.length > 1) {
|
||||||
throw new Error(`Expected 1 vault record, got ${records.length}`);
|
throw new Error(`Expected 1 vault record, got ${records.length}`);
|
||||||
} else if (records.length === 0) {
|
} else if (records.length === 0) {
|
||||||
const key = generateId();
|
const key = generateId();
|
||||||
const value = { id: key };
|
const value = { id: key };
|
||||||
await db.add(VAULT_STORE, value, key);
|
await docsDb.add(VAULT_STORE, value, key);
|
||||||
records.push(value);
|
records.push(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +135,7 @@ if (docsMap.size === 0) {
|
|||||||
const docInfo = new Y.Map([['title', oldKey]]) as DocMap;
|
const docInfo = new Y.Map([['title', oldKey]]) as DocMap;
|
||||||
docsMap.set(newId, docInfo);
|
docsMap.set(newId, docInfo);
|
||||||
|
|
||||||
const tx = db.transaction(LOCAL_DELTA_STORE, 'readwrite');
|
const tx = docsDb.transaction(LOCAL_DELTA_STORE, 'readwrite');
|
||||||
const recordKeys = (await tx.store.getAllKeys(
|
const recordKeys = (await tx.store.getAllKeys(
|
||||||
IDBKeyRange.bound([oldKey, -Infinity], [oldKey, Infinity])
|
IDBKeyRange.bound([oldKey, -Infinity], [oldKey, Infinity])
|
||||||
)) as LocalDeltaKey[];
|
)) as LocalDeltaKey[];
|
||||||
|
|||||||
Reference in New Issue
Block a user