Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduces versioned schema to invalidate staled data on clients #142

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/arbor-plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ 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 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

> [!WARNING]
Expand Down
48 changes: 47 additions & 1 deletion packages/arbor-plugins/src/LocalStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,46 @@ export interface Config<T extends object> {
* @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<object>

constructor(config: Config<object>) {
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)
}
}

/**
Expand All @@ -51,8 +91,11 @@ export interface Config<T extends object> {
* ```
*/
export default class LocalStorage<T extends object> extends Storage<T> {
versionedSchema: VersionedSchema

constructor(readonly config: Config<T>) {
super(config)
this.versionedSchema = new VersionedSchema(config)
}

load() {
Expand All @@ -63,7 +106,7 @@ export default class LocalStorage<T extends object> extends Storage<T> {
this.config.deserialize ||
(JSON.parse as typeof this.config.deserialize)

resolve(deserialize(data))
resolve(this.versionedSchema.shouldLoad ? deserialize(data) : null)
} catch (e) {
reject(e)
}
Expand All @@ -75,6 +118,9 @@ export default class LocalStorage<T extends object> extends Storage<T> {
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)
Expand Down
101 changes: 99 additions & 2 deletions packages/arbor-plugins/tests/LocalStorage.test.ts
Original file line number Diff line number Diff line change
@@ -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")
Expand Down Expand Up @@ -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")
})
})
})
Loading