timer-db
Version:
A library for cubing timer results storage with CouchDB-based synchronization.
176 lines (157 loc) • 5.59 kB
text/typescript
import PouchDB, { emit } from "pouchdb";
import PouchDBFind from "pouchdb-find";
import { Attempt, StoredAttempt } from "../data/Attempt";
import {
SessionMetadata,
StoredSessionMetadata,
} from "../data/SessionMetadata";
import { newAttemptUUID, newSessionUUID, SessionUUID } from "../UUID";
import {
isValidAttemptData,
isValidStoredSessionMetadata,
isValidSessionMetadata,
} from "../data/validate";
import { SyncChangeListener } from "./storage";
PouchDB.plugin(PouchDBFind);
// TODO: Make configurable.
const DB_URL = "https://couchdb.api.cubing.net/";
type PouchDocument = Attempt | SessionMetadata;
export class PouchDBStorage {
// TODO: Change back to private fields once we figure out how to make it compatible with jest.
private localDB: PouchDB.Database<PouchDocument>;
private remoteDB?: PouchDB.Database<PouchDocument> = null;
private sync?: PouchDB.Replication.Sync<PouchDocument>;
private listeners: SyncChangeListener[] = [];
constructor() {
this.localDB = new PouchDB("timer_db_default"); // TODO
// TODO: Ensure indices are being used.
this.localDB.createIndex({
index: { fields: ["sessionID"] }, // TODO
});
// this.localDB.createIndex({
// index: { fields: ["resultTotalMs"] }, // TODO
// });
}
connectRemoteDB(username: string, password: string): void {
const url = new URL(DB_URL);
url.username = username;
url.password = password;
url.pathname = `results-${localStorage.timerDBUsername}`;
const authedURL = url.toString();
this.remoteDB = new PouchDB(authedURL);
this.sync = this.localDB.sync(this.remoteDB, {
live: true,
retry: true,
});
this.sync.on("change", this.onSyncChange.bind(this));
this.sync.on("error", this.onSyncError.bind(this));
}
addListener(listener: SyncChangeListener): void {
this.listeners.push(listener);
}
removeListener(listener: SyncChangeListener): void {
for (let i = 0; i < this.listeners.length; i++) {
if (this.listeners[i] === listener) {
this.listeners = this.listeners
.slice(0, i)
.concat(this.listeners.slice(i + 1));
return;
}
}
throw new Error(
"Tried to remove a sync change listener that wasn't registered!"
);
}
private onSyncChange(event: { change: { docs: StoredAttempt[] } }): void {
for (const listener of this.listeners) {
listener(event.change.docs);
}
}
private onSyncError(err: Error): void {
console.error("timer-db sync error", err);
}
// Modifies the attempt to add the ID and rev.
async addNewAttempt(attempt: Attempt): Promise<StoredAttempt> {
if (!isValidAttemptData(attempt)) {
throw new Error("Attempted to store invalid attempt data");
}
const storedAttempt = attempt as StoredAttempt;
storedAttempt._id = await newAttemptUUID(attempt.unixDate);
const response = await this.localDB.put(storedAttempt);
if (!response.ok) {
throw new Error("Could not add attempt to session");
}
storedAttempt._rev = response.rev;
return storedAttempt;
}
async updateAttempt(storedAttempt: StoredAttempt): Promise<void> {
await this.localDB.put(storedAttempt);
}
async deleteAttempt(storedAttempt: StoredAttempt): Promise<void> {
// (storedAttempt as any)._deleted = true; // TODO: safer typing.
// TODO: put with _deleted = true?
await this.localDB.remove(storedAttempt);
}
// Returns the n most recent attempts if there are at least n attempts, else returns all attempts.
// Sorted by time (increasing).
// TODO: Replace iteratore
async latestAttempts(
sessionID: SessionUUID,
n: number
): Promise<StoredAttempt[]> {
const dbResponse = (await this.localDB.find({
limit: n,
selector: {
sessionID,
},
sort: [{ _id: "desc" }],
})) as PouchDB.Find.FindResponse<StoredAttempt>;
return dbResponse.docs.map((attempt) => {
if (!isValidAttemptData(attempt)) {
console.error("WARNING: Invalid attempt in database!", attempt);
}
return attempt;
});
}
async createSession(
sessionMetadata: SessionMetadata
): Promise<StoredSessionMetadata> {
if (!isValidSessionMetadata(sessionMetadata)) {
throw new Error("Attempted to store invalid attempt data");
}
const storedSessionMetadata = sessionMetadata as StoredSessionMetadata;
if (!storedSessionMetadata._id) {
storedSessionMetadata._id = await newSessionUUID();
}
const response = await this.localDB.put(storedSessionMetadata);
if (!response.ok) {
throw new Error("Could not add attempt to session");
}
storedSessionMetadata._rev = response.rev;
return storedSessionMetadata;
}
async getAllSessions(): Promise<StoredSessionMetadata[]> {
const dbResponse = (await this.localDB.allDocs({
include_docs: true,
startkey: "s_", // TODO: key: "s_"?
endkey: "s_\ufff0",
})) as PouchDB.Core.AllDocsResponse<StoredSessionMetadata>;
return dbResponse.rows.map((row) => {
const sessionMetadata = row.doc;
if (!isValidStoredSessionMetadata(sessionMetadata)) {
console.error("WARNING: Invalid attempt in database!", sessionMetadata);
}
return sessionMetadata;
});
}
async sessionNumAttempts(sessionID: SessionUUID): Promise<number> {
// TODO: Can we do this without getting the docs?
const dbResponse = await this.localDB.find({
fields: [],
selector: {
sessionID,
},
});
return dbResponse.docs.length;
}
}