From 67f1a45af477e93e3c53de2d0a09584d523b8061 Mon Sep 17 00:00:00 2001 From: Guilherme Moretti Date: Tue, 7 Jan 2025 10:23:54 -0300 Subject: [PATCH 1/3] Implements versioned schema Creates a way to invalidate localStorage when it can't be loaded by the store anymore --- packages/arbor-plugins/src/LocalStorage.ts | 48 ++++++++- .../arbor-plugins/tests/LocalStorage.test.ts | 101 +++++++++++++++++- 2 files changed, 146 insertions(+), 3 deletions(-) diff --git a/packages/arbor-plugins/src/LocalStorage.ts b/packages/arbor-plugins/src/LocalStorage.ts index 9f172a1c..73675986 100644 --- a/packages/arbor-plugins/src/LocalStorage.ts +++ b/packages/arbor-plugins/src/LocalStorage.ts @@ -33,6 +33,46 @@ export interface Config { * @returns the deserialized version of the data. */ deserialize?: (serialized: string) => T + /** + * If schemaVersion persisted in localStorage is different then the defined by this property. + * It will ignore persisted data. Change this value when there's a breaking change in the schema. + * + * The plugin will not compare schemaVersions if this value is not defined. + */ + schemaVersion?: string + /** + * Overrides default key name for persisted schemaVersion + */ + schemaVersionKey?: string +} + +export class VersionedSchema { + config: Config + + constructor(config: Config) { + this.config = config + } + + get schemaKey() { + return this.config.schemaVersionKey || `${this.config.key}.schemaVersion` + } + + get shouldLoad() { + const { schemaVersion } = this.config + return !schemaVersion || this.persistedSchemaVersion === schemaVersion + } + + get persistedSchemaVersion() { + return window.localStorage.getItem(this.schemaKey) + } + + persist() { + if (!this.config.schemaVersion) { + return + } + + window.localStorage.setItem(this.schemaKey, this.config.schemaVersion) + } } /** @@ -51,8 +91,11 @@ export interface Config { * ``` */ export default class LocalStorage extends Storage { + versionedSchema: VersionedSchema + constructor(readonly config: Config) { super(config) + this.versionedSchema = new VersionedSchema(config) } load() { @@ -63,7 +106,7 @@ export default class LocalStorage extends Storage { this.config.deserialize || (JSON.parse as typeof this.config.deserialize) - resolve(deserialize(data)) + resolve(this.versionedSchema.shouldLoad ? deserialize(data) : null) } catch (e) { reject(e) } @@ -75,6 +118,9 @@ export default class LocalStorage extends Storage { try { const serialize = this.config.serialize || JSON.stringify window.localStorage.setItem(this.config.key, serialize(event.state)) + + this.versionedSchema.persist() + resolve() } catch (e) { reject(e) diff --git a/packages/arbor-plugins/tests/LocalStorage.test.ts b/packages/arbor-plugins/tests/LocalStorage.test.ts index d3ac0ef9..6a94fd54 100644 --- a/packages/arbor-plugins/tests/LocalStorage.test.ts +++ b/packages/arbor-plugins/tests/LocalStorage.test.ts @@ -1,15 +1,39 @@ // @vitest-environment jsdom import { Arbor } from "@arborjs/store" -import { beforeEach, describe, expect, it } from "vitest" +import { beforeEach, describe, expect, it, afterEach } from "vitest" -import LocalStorage from "../src/LocalStorage" +import LocalStorage, { VersionedSchema } from "../src/LocalStorage" const timeout = (period = 0) => new Promise((resolve) => { setTimeout(resolve, period) }) +afterEach(() => { + window.localStorage.clear() +}) + +describe("VersionedSchema", () => { + it("should load when schemaVersion is not defined", () => { + const versionedSchema = new VersionedSchema({ key: "the-key" }) + + expect(versionedSchema.shouldLoad).toBe(true) + }) + + describe("when schemaVersion is defined", () => { + it("loads when is the same as persisted version", () => { + const versionedSchema = new VersionedSchema({ + key: "the-key", + schemaVersion: "1", + }) + window.localStorage.setItem(versionedSchema.schemaKey, "1") + + expect(versionedSchema.shouldLoad).toBe(true) + }) + }) +}) + describe("LocalStorage", () => { beforeEach(() => { window.localStorage.removeItem("the-key") @@ -119,4 +143,77 @@ describe("LocalStorage", () => { JSON.stringify({ text: "some initial state" }) ) }) + + describe("schemaVersion", () => { + it("always loads when schemaVersion config is not defined", async () => { + const store = new Arbor({ text: "" }) + const plugin = new LocalStorage<{ text: string }>({ key: "the-key" }) + + window.localStorage.setItem( + "the-key", + JSON.stringify({ text: "the app state" }) + ) + window.localStorage.setItem("the-key.schemaVersion", "1") + + await store.use(plugin) + + expect(store.state.text).toEqual("the app state") + }) + + it("loads when persisted schemaVersion is the same as the configuration", async () => { + const store = new Arbor({ text: "" }) + const plugin = new LocalStorage<{ text: string }>({ + key: "the-key", + schemaVersion: "1", + }) + + window.localStorage.setItem( + "the-key", + JSON.stringify({ text: "the app state" }) + ) + window.localStorage.setItem("the-key.schemaVersion", "1") + + await store.use(plugin) + + expect(store.state.text).toEqual("the app state") + }) + + it("doesn't load when persisted schemaVersion is different from the configuration", async () => { + const store = new Arbor({ text: "" }) + const plugin = new LocalStorage<{ text: string }>({ + key: "the-key", + schemaVersion: "2", + }) + + window.localStorage.setItem( + "the-key", + JSON.stringify({ text: "the app state" }) + ) + window.localStorage.setItem("the-key.schemaVersion", "1") + + await store.use(plugin) + + expect(store.state.text).toEqual("") + }) + + it("persists schemaVersion when its defined", async () => { + const store = new Arbor({ text: "" }) + const plugin = new LocalStorage<{ text: string }>({ + key: "the-key", + schemaVersion: "2", + }) + + window.localStorage.setItem( + "the-key", + JSON.stringify({ text: "the app state" }) + ) + + await store.use(plugin) + + store.state.text = "Hello World!" + await timeout() + + expect(window.localStorage.getItem("the-key.schemaVersion")).toEqual("2") + }) + }) }) From b62b14b2ef90182b933b70aa3387405192e2fab2 Mon Sep 17 00:00:00 2001 From: Guilherme Moretti Date: Tue, 7 Jan 2025 10:28:54 -0300 Subject: [PATCH 2/3] Updates README.md --- packages/arbor-plugins/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/arbor-plugins/README.md b/packages/arbor-plugins/README.md index a7064fad..21cdea74 100644 --- a/packages/arbor-plugins/README.md +++ b/packages/arbor-plugins/README.md @@ -81,6 +81,10 @@ store.use( Using `@arborjs/json` to handle serialization/deserialization means that your application state will be serialized and save into local storage with type information preserved so when deserialized, what you get back are instances of the types composing the state, rather than raw literal objects and arrays. +Sometimes, when there are changes in the data schema, the persisted data on the clients can't be deserialized anymore. To avoid parsing exceptions, you can use the `schemaVersion` config; when it's different from the schema persisted, it will silently not load anything from LocalStorage. + +```ts + ## Custom Plugins > [!WARNING] From 1973a66059f57946049f76943e434aff219c7dbd Mon Sep 17 00:00:00 2001 From: Guilherme Burille Moretti Date: Tue, 7 Jan 2025 10:32:25 -0300 Subject: [PATCH 3/3] Update README.md --- packages/arbor-plugins/README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/arbor-plugins/README.md b/packages/arbor-plugins/README.md index 21cdea74..54d427d7 100644 --- a/packages/arbor-plugins/README.md +++ b/packages/arbor-plugins/README.md @@ -81,9 +81,7 @@ store.use( Using `@arborjs/json` to handle serialization/deserialization means that your application state will be serialized and save into local storage with type information preserved so when deserialized, what you get back are instances of the types composing the state, rather than raw literal objects and arrays. -Sometimes, when there are changes in the data schema, the persisted data on the clients can't be deserialized anymore. To avoid parsing exceptions, you can use the `schemaVersion` config; when it's different from the schema persisted, it will silently not load anything from LocalStorage. - -```ts +Sometimes, when the data schema changes, the persisted data on the clients can't be deserialized anymore. To avoid parsing exceptions, you can use the `schemaVersion` config; when it's different from the schema persisted, it will silently not load anything from LocalStorage. ## Custom Plugins