idb + dexie + zustand state persistance
idb-service.tsTypeScriptrecord-store.tsTypeScript
idb-service.ts
// lib/idb-service.ts
import Dexie, { type Table } from "dexie";
/* ---------------------------
* Event Emitter
* --------------------------- */
type TEventCallback = (...args: unknown[]) => void;
class EventEmitter {
private events: Record<string, TEventCallback[]> = {};
on(event: string, callback: TEventCallback) {
if (!this.events[event]) this.events[event] = [];
this.events[event].push(callback);
}
off(event: string, callback: TEventCallback) {
if (!this.events[event]) return;
this.events[event] = this.events[event].filter((cb) => cb !== callback);
}
emit(event: string, ...args: unknown[]) {
if (!this.events[event]) return;
this.events[event].forEach((callback) => callback(...args));
}
}
export const appEventEmitter = new EventEmitter();
/* ---------------------------
* Base Types
* --------------------------- */
export type TBaseRecord = {
id: string;
name: string;
createdAt: number;
updatedAt: number;
size?: number;
};
export type TStoredRecord<TContent = unknown> = TBaseRecord & {
content: TContent;
};
export type TRecord<TContent = unknown> = Omit<TStoredRecord<TContent>, "createdAt" | "updatedAt"> & {
createdAt: Date;
updatedAt: Date;
};
/* ---------------------------
* Dexie Database Class
* --------------------------- */
export class IdbDatabase<TContent> extends Dexie {
records!: Table<TStoredRecord<TContent>>;
constructor(name: string) {
super(name);
this.version(1).stores({
records: "id, name, createdAt, updatedAt",
});
}
}
/* ---------------------------
* IDB Service
* --------------------------- */
export class IdbService<TContent> {
private db: IdbDatabase<TContent>;
constructor(dbName: string) {
this.db = new IdbDatabase<TContent>(dbName);
}
private toEntity(stored: TStoredRecord<TContent>): TRecord<TContent> {
return {
...stored,
createdAt: new Date(stored.createdAt),
updatedAt: new Date(stored.updatedAt),
};
}
private toStored(entity: TRecord<TContent>): TStoredRecord<TContent> {
return {
...entity,
createdAt: entity.createdAt.getTime(),
updatedAt: entity.updatedAt.getTime(),
};
}
async init(): Promise<void> {
await this.db.open();
}
async save(entity: TRecord<TContent>): Promise<void> {
await this.db.records.put(this.toStored(entity));
appEventEmitter.emit("record-saved", entity.id);
}
async get(id: string): Promise<TRecord<TContent> | null> {
const stored = await this.db.records.get(id);
return stored ? this.toEntity(stored) : null;
}
async getAll(): Promise<TRecord<TContent>[]> {
const stored = await this.db.records.orderBy("updatedAt").reverse().toArray();
return stored.map((s) => this.toEntity(s));
}
async delete(id: string): Promise<void> {
await this.db.records.delete(id);
appEventEmitter.emit("record-deleted", id);
}
async update(id: string, update: Partial<TStoredRecord<TContent>>): Promise<void> {
const result = await this.db.records.update(id, { ...update, updatedAt: Date.now() });
if (result === 0) throw new Error("Record not found");
appEventEmitter.emit("record-updated", id);
}
async search(query: string): Promise<TRecord<TContent>[]> {
const lower = query.toLowerCase();
const stored = await this.db.records
.filter((e) => e.name.toLowerCase().includes(lower) || String(e.content).toLowerCase().includes(lower))
.sortBy("updatedAt");
return stored.reverse().map((s) => this.toEntity(s));
}
}
record-store.ts
// stores/record-store.ts
import { create } from "zustand";
import { IdbService, appEventEmitter, type TRecord } from "@/lib/idb-service";
/* ---------------------------
* Example Record Type
* --------------------------- */
export type TNote = TRecord<string>; // e.g. string-based content
/* ---------------------------
* Store Shape
* --------------------------- */
type TRecordStore<T> = {
records: T[];
activeId: string | null;
isLoading: boolean;
error?: string;
create: (name?: string, content?: T["content"]) => Promise<string>;
load: (id: string) => Promise<void>;
loadAll: () => Promise<void>;
save: (id: string, content: T["content"]) => Promise<void>;
remove: (id: string) => Promise<void>;
rename: (id: string, newName: string) => Promise<void>;
getActive: () => T | null;
getAll: () => T[];
};
/* ---------------------------
* Store Factory
* --------------------------- */
export const createRecordStore = <TContent>(dbName: string) => {
const db = new IdbService<TContent>(dbName);
return create<TRecordStore<TRecord<TContent>>>()((set, get) => ({
records: [],
activeId: null,
isLoading: false,
error: undefined,
create: async (name = "Untitled", content?: TContent) => {
const now = new Date();
const record: TRecord<TContent> = {
id: crypto.randomUUID(),
name,
content: content ?? ("" as TContent),
createdAt: now,
updatedAt: now,
size: content ? new Blob([JSON.stringify(content)]).size : 0,
};
await db.save(record);
set((s) => ({ records: [...s.records, record], activeId: record.id }));
return record.id;
},
load: async (id) => {
const record = await db.get(id);
if (!record) throw new Error("Record not found");
set((s) => ({
records: [...s.records.filter((e) => e.id !== id), record],
activeId: id,
}));
},
loadAll: async () => {
const records = await db.getAll();
set({ records });
},
save: async (id, content) => {
const now = new Date();
set((s) => ({
records: s.records.map((r) =>
r.id === id ? { ...r, content, updatedAt: now } : r
),
}));
await db.update(id, { content });
},
remove: async (id) => {
await db.delete(id);
set((s) => ({
records: s.records.filter((r) => r.id !== id),
activeId: s.activeId === id ? null : s.activeId,
}));
},
rename: async (id, newName) => {
await db.update(id, { name: newName });
set((s) => ({
records: s.records.map((r) =>
r.id === id ? { ...r, name: newName } : r
),
}));
},
getActive: () => {
const activeId = get().activeId;
return activeId ? get().records.find((r) => r.id === activeId) ?? null : null;
},
getAll: () => get().records,
}));
};
Updated: 8/23/2025