2025-11-03 21:46:15 +01:00
|
|
|
|
# Tutorial 7: Persistence Patterns
|
|
|
|
|
|
|
|
|
|
|
|
This tutorial teaches both **localStorage** and **IndexedDB** persistence patterns for your reactive store.
|
|
|
|
|
|
|
|
|
|
|
|
### “Saving Greetings” — Making the store survive reloads
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 🎯 Goal
|
|
|
|
|
|
|
|
|
|
|
|
Extend the `helloStore` so that all greetings persist between browser sessions.
|
|
|
|
|
|
When the page reloads, previously added greeters are restored automatically.
|
|
|
|
|
|
|
|
|
|
|
|
We’ll implement this in **two steps**:
|
|
|
|
|
|
|
|
|
|
|
|
1. Start with simple **`localStorage`** persistence (fast, synchronous).
|
|
|
|
|
|
2. Upgrade to **`IndexedDB`** for larger data or structured offline storage.
|
|
|
|
|
|
|
|
|
|
|
|
Both variants will keep your TestDrive-UI environment self-contained and testable.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 🧪 Step 1 — Write the failing tests
|
|
|
|
|
|
|
|
|
|
|
|
Create a new file:
|
|
|
|
|
|
`src/store/hello-store.persistence.test.js`
|
|
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
import { helloStore } from "./hello-store.js";
|
|
|
|
|
|
|
|
|
|
|
|
describe("helloStore persistence", () => {
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
// Clean simulated storage
|
|
|
|
|
|
global.localStorage = {
|
|
|
|
|
|
store: {},
|
|
|
|
|
|
getItem(k) { return this.store[k] || null; },
|
|
|
|
|
|
setItem(k, v) { this.store[k] = v; },
|
|
|
|
|
|
clear() { this.store = {}; }
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("saves state to localStorage after modification", () => {
|
|
|
|
|
|
helloStore.addGreeter("Persisty");
|
|
|
|
|
|
const saved = JSON.parse(localStorage.getItem("helloStore"));
|
|
|
|
|
|
expect(saved.greeters).to.include("Persisty");
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it("restores saved state when re-initialized", () => {
|
|
|
|
|
|
localStorage.setItem(
|
|
|
|
|
|
"helloStore",
|
|
|
|
|
|
JSON.stringify({ greeters: ["Restored"] })
|
|
|
|
|
|
);
|
|
|
|
|
|
const { HelloStore } = await import("./hello-store.js");
|
|
|
|
|
|
const freshStore = new HelloStore();
|
|
|
|
|
|
const state = freshStore.getState();
|
|
|
|
|
|
expect(state.greeters).to.deep.equal(["Restored"]);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Run:
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
npm test
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
All tests will fail initially — perfect.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## ⚙️ Step 2 — Implement `localStorage` persistence
|
|
|
|
|
|
|
|
|
|
|
|
Open `src/store/hello-store.js` and extend it like this:
|
|
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
class HelloStore {
|
|
|
|
|
|
constructor() {
|
|
|
|
|
|
const saved = localStorage.getItem("helloStore");
|
|
|
|
|
|
this.state = saved ? JSON.parse(saved) : { greeters: ["World"] };
|
|
|
|
|
|
this.listeners = new Set();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
getState() {
|
|
|
|
|
|
return this.state;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_notify() {
|
|
|
|
|
|
// Save current state before notifying
|
|
|
|
|
|
localStorage.setItem("helloStore", JSON.stringify(this.state));
|
|
|
|
|
|
for (const cb of this.listeners) cb(this.state);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
subscribe(callback) {
|
|
|
|
|
|
this.listeners.add(callback);
|
|
|
|
|
|
callback(this.state);
|
|
|
|
|
|
return () => this.listeners.delete(callback);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
addGreeter(name) {
|
|
|
|
|
|
this.state = { ...this.state, greeters: [...this.state.greeters, name] };
|
|
|
|
|
|
this._notify();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
renameGreeter(oldName, newName) {
|
|
|
|
|
|
const updated = this.state.greeters.map((n) =>
|
|
|
|
|
|
n === oldName ? newName : n
|
|
|
|
|
|
);
|
|
|
|
|
|
this.state = { ...this.state, greeters: updated };
|
|
|
|
|
|
this._notify();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const helloStore = new HelloStore();
|
|
|
|
|
|
export { HelloStore }; // exported for testing
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
✅ Run `npm test` again — tests should now pass.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 🌐 Step 3 — Try it live
|
|
|
|
|
|
|
|
|
|
|
|
Start your dev server:
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
npm run dev
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
1. Add new greeters in the dashboard.
|
|
|
|
|
|
2. Reload the browser.
|
|
|
|
|
|
→ The same greeters reappear immediately!
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 🧱 Step 4 — Optional: Switch to IndexedDB for larger data
|
|
|
|
|
|
|
|
|
|
|
|
`localStorage` is fine for small JSON objects.
|
|
|
|
|
|
To persist more complex data or avoid blocking the main thread, you can use **IndexedDB** (via the tiny `idb-keyval` helper or native API).
|
|
|
|
|
|
|
|
|
|
|
|
Create `src/store/storage.js`:
|
|
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
// Minimal IndexedDB wrapper using idb-keyval style API
|
|
|
|
|
|
const DB_NAME = "testdrive-ui";
|
|
|
|
|
|
const STORE_NAME = "hello";
|
|
|
|
|
|
|
|
|
|
|
|
export async function saveState(state) {
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
const req = indexedDB.open(DB_NAME, 1);
|
|
|
|
|
|
req.onupgradeneeded = () => req.result.createObjectStore(STORE_NAME);
|
|
|
|
|
|
req.onsuccess = () => {
|
|
|
|
|
|
const tx = req.result.transaction(STORE_NAME, "readwrite");
|
|
|
|
|
|
tx.objectStore(STORE_NAME).put(state, "store");
|
|
|
|
|
|
tx.oncomplete = resolve;
|
|
|
|
|
|
tx.onerror = reject;
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function loadState() {
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
|
const req = indexedDB.open(DB_NAME, 1);
|
|
|
|
|
|
req.onupgradeneeded = () => req.result.createObjectStore(STORE_NAME);
|
|
|
|
|
|
req.onsuccess = () => {
|
|
|
|
|
|
const tx = req.result.transaction(STORE_NAME, "readonly");
|
|
|
|
|
|
const get = tx.objectStore(STORE_NAME).get("store");
|
|
|
|
|
|
get.onsuccess = () => resolve(get.result);
|
|
|
|
|
|
get.onerror = reject;
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Now update the store’s `_notify()` and constructor to use these async calls:
|
|
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
import { saveState, loadState } from "./storage.js";
|
|
|
|
|
|
|
|
|
|
|
|
class HelloStore {
|
|
|
|
|
|
constructor() {
|
|
|
|
|
|
this.listeners = new Set();
|
|
|
|
|
|
loadState().then(
|
|
|
|
|
|
(saved) =>
|
|
|
|
|
|
(this.state = saved || { greeters: ["World"] }) &&
|
|
|
|
|
|
this._notify()
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async _notify() {
|
|
|
|
|
|
await saveState(this.state);
|
|
|
|
|
|
for (const cb of this.listeners) cb(this.state);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
💡 Tip: because IndexedDB operations are async, wrap state changes in `async` methods and adjust your tests with `await`.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 🧠 Step 5 — Testing IndexedDB logic
|
|
|
|
|
|
|
|
|
|
|
|
In Node environments, you can simulate IndexedDB with `fake-indexeddb`:
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
npm install --save-dev fake-indexeddb
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Then in your test setup:
|
|
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
|
import "fake-indexeddb/auto";
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
Your tests now work without a browser.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## ✅ Outcome
|
|
|
|
|
|
|
|
|
|
|
|
Your TestDrive-UI environment now supports:
|
|
|
|
|
|
|
|
|
|
|
|
| Persistence Layer | Use Case | Pros |
|
|
|
|
|
|
| ----------------- | ------------------------------------- | ------------------ |
|
|
|
|
|
|
| **localStorage** | Small, synchronous, quick persistence | Simple & immediate |
|
|
|
|
|
|
| **IndexedDB** | Large, structured, async persistence | Scalable & robust |
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 🔍 Reflection
|
|
|
|
|
|
|
|
|
|
|
|
Persistence transforms your store into a **long-term memory layer**.
|
|
|
|
|
|
With this, TestDrive-UI crosses into **progressive-web-app territory** — offline-ready, recoverable, and reliable.
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
Continue to Tutorial 8 on Agent Automation.
|
|
|
|
|
|
|
|
|
|
|
|
xxx
|