refactor: Simplify store persistence enablement and clearing

- Move the persistable guard into Store.enablePersistence so RootStore
  can call it unconditionally on every store
- StorePersistence derives its own database name from the store and
  teamId rather than receiving a prebuilt name
- StorePersistence.clear returns a promise, making the test deterministic
  instead of sleeping

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XisV6sRdexfnPSiehurKoV
This commit is contained in:
Claude
2026-07-26 16:50:52 -04:00
committed by Tom Moor
parent e1e71bc75b
commit 285a0eac5d
4 changed files with 41 additions and 41 deletions
+1 -1
View File
@@ -131,7 +131,7 @@ export default class RootStore {
}
Object.values(this).forEach((store) => {
if (store instanceof Store && store.persistable) {
if (store instanceof Store) {
void store.enablePersistence(teamId);
}
});
+9 -7
View File
@@ -107,26 +107,28 @@ export default abstract class Store<T extends Model> {
@action
clear() {
this.data.clear();
this.persistence?.clear();
void this.persistence?.clear();
}
/**
* Enables persistence of this store's data to IndexedDB, scoped to the
* given team, and hydrates any previously persisted records into the store.
* Safe to call multiple times, subsequent calls are a no-op.
* A no-op if the store is not persistable, persistence is unsupported, or it
* is already enabled, so it is safe to call on every store and more than once.
*
* @param teamId the ID of the team the persisted data belongs to.
* @returns a promise that resolves when hydration is complete.
*/
async enablePersistence(teamId: string): Promise<void> {
if (this.persistence || !StorePersistence.isSupported) {
if (
!this.persistable ||
this.persistence ||
!StorePersistence.isSupported
) {
return;
}
this.persistence = new StorePersistence<T>(
this,
StorePersistence.databaseName(this.apiEndpoint, teamId)
);
this.persistence = new StorePersistence<T>(this, teamId);
await this.persistence.hydrate();
}
+13 -16
View File
@@ -10,9 +10,9 @@ import StorePersistence from "./StorePersistence";
describe("StorePersistence", () => {
test("round-trips models through IndexedDB into a fresh store", async () => {
const name = StorePersistence.databaseName("policies", "team-1");
const teamId = "team-1";
const source = new RootStore();
const persistence = new StorePersistence(source.policies, name);
const persistence = new StorePersistence(source.policies, teamId);
source.policies.add({
id: "doc-1",
@@ -22,7 +22,7 @@ describe("StorePersistence", () => {
await persistence.flush();
const target = new RootStore();
const targetPersistence = new StorePersistence(target.policies, name);
const targetPersistence = new StorePersistence(target.policies, teamId);
await targetPersistence.hydrate();
expect(toJS(target.policies.get("doc-1")?.abilities)).toEqual({
@@ -34,9 +34,9 @@ describe("StorePersistence", () => {
});
test("does not overwrite models already in the store when hydrating", async () => {
const name = StorePersistence.databaseName("policies", "team-2");
const teamId = "team-2";
const source = new RootStore();
const persistence = new StorePersistence(source.policies, name);
const persistence = new StorePersistence(source.policies, teamId);
source.policies.add({ id: "doc-1", abilities: { read: false } });
persistence.persist("doc-1");
@@ -44,16 +44,16 @@ describe("StorePersistence", () => {
const target = new RootStore();
target.policies.add({ id: "doc-1", abilities: { read: true } });
const targetPersistence = new StorePersistence(target.policies, name);
const targetPersistence = new StorePersistence(target.policies, teamId);
await targetPersistence.hydrate();
expect(target.policies.get("doc-1")?.abilities).toEqual({ read: true });
});
test("deletes the persisted record when the model has been removed", async () => {
const name = StorePersistence.databaseName("policies", "team-3");
const teamId = "team-3";
const source = new RootStore();
const persistence = new StorePersistence(source.policies, name);
const persistence = new StorePersistence(source.policies, teamId);
source.policies.add({ id: "doc-1", abilities: { read: true } });
persistence.persist("doc-1");
@@ -64,16 +64,16 @@ describe("StorePersistence", () => {
await persistence.flush();
const target = new RootStore();
const targetPersistence = new StorePersistence(target.policies, name);
const targetPersistence = new StorePersistence(target.policies, teamId);
await targetPersistence.hydrate();
expect(target.policies.get("doc-1")).toBeUndefined();
});
test("clear removes all persisted records", async () => {
const name = StorePersistence.databaseName("policies", "team-4");
const teamId = "team-4";
const source = new RootStore();
const persistence = new StorePersistence(source.policies, name);
const persistence = new StorePersistence(source.policies, teamId);
source.policies.add({ id: "doc-1", abilities: { read: true } });
source.policies.add({ id: "doc-2", abilities: { read: true } });
@@ -81,13 +81,10 @@ describe("StorePersistence", () => {
persistence.persist("doc-2");
await persistence.flush();
persistence.clear();
// clear() runs asynchronously in the background, wait for it to finish.
await new Promise((resolve) => setTimeout(resolve, 50));
await persistence.clear();
const target = new RootStore();
const targetPersistence = new StorePersistence(target.policies, name);
const targetPersistence = new StorePersistence(target.policies, teamId);
await targetPersistence.hydrate();
expect(target.policies.orderedData).toHaveLength(0);
+18 -17
View File
@@ -42,9 +42,9 @@ export default class StorePersistence<T extends Model> {
return `outline.${storeName}.${teamId}`;
}
constructor(store: Store<T>, databaseName: string) {
constructor(store: Store<T>, teamId: string) {
this.store = store;
this.name = databaseName;
this.name = StorePersistence.databaseName(store.apiEndpoint, teamId);
}
/**
@@ -130,29 +130,30 @@ export default class StorePersistence<T extends Model> {
/**
* Removes all persisted records, used when the store is cleared on logout.
*
* @returns a promise that resolves when the records have been removed.
*/
public clear = () => {
public clear = async (): Promise<void> => {
this.dirty.clear();
if (this.disabled) {
return;
}
void this.open()
.then((database) =>
promisifyRequest(
database
.transaction(OBJECT_STORE_NAME, "readwrite")
.objectStore(OBJECT_STORE_NAME)
.clear()
)
)
.catch((err) => {
Logger.warn("Failed to clear persisted store in IndexedDB", {
database: this.name,
error: err,
});
try {
const database = await this.open();
await promisifyRequest(
database
.transaction(OBJECT_STORE_NAME, "readwrite")
.objectStore(OBJECT_STORE_NAME)
.clear()
);
} catch (err) {
Logger.warn("Failed to clear persisted store in IndexedDB", {
database: this.name,
error: err,
});
}
};
private open = (): Promise<IDBDatabase> => {