5. Persistence Module
What we want
Section titled “What we want”A notesModule that:
- Loads notes from localStorage on startup
- Handles
note.create,note.update,note.deletecommands - Emits
note.created,note.updated,note.deletedevents for observers - Exposes a
NotesTokenprovider so the UI can read the current notes
src/notes-module.ts
Section titled “src/notes-module.ts”import { createProviderToken, defineKitModule, type KitRuntime,} from '@atheory-ai/kitsune-core'import type { Note } from './notes.js'
const STORAGE_KEY = 'quill.notes.v1'
export type NotesService = { list(): Note[] get(id: string): Note | undefined subscribe(listener: () => void): () => void}
export const NotesToken = createProviderToken<NotesService>('notes')
export function notesModule(seed: Note[] = []) { let notes: Note[] = load() ?? seed const listeners = new Set<() => void>()
const notify = () => listeners.forEach((listener) => listener())
const persist = () => { localStorage.setItem(STORAGE_KEY, JSON.stringify(notes)) notify() }
const service: NotesService = { list: () => notes.slice(), get: (id) => notes.find((note) => note.id === id), subscribe: (listener) => { listeners.add(listener) return () => listeners.delete(listener) }, }
return defineKitModule({ name: 'notes', setup(runtime: KitRuntime) { runtime.provide(NotesToken, service) }, commands: { 'note.create': (command) => { const note: Note = { id: `n_${crypto.randomUUID().slice(0, 8)}`, title: String(command.payload?.title ?? '').trim() || 'Untitled', body: String(command.payload?.body ?? '').trim(), updatedAt: Date.now(), } notes = [note, ...notes] persist() emit(command, runtime, 'note.created', { note }) return { id: note.id } },
'note.update': (command) => { const id = String(command.payload?.id ?? '') const next = notes.map((note) => note.id === id ? { ...note, title: String(command.payload?.title ?? note.title).trim() || note.title, body: String(command.payload?.body ?? note.body).trim(), updatedAt: Date.now(), } : note, ) if (next === notes) return { updated: false } notes = next persist() emit(command, runtime, 'note.updated', { id }) return { updated: true, id } },
'note.delete': (command) => { const id = String(command.payload?.id ?? '') const removed = notes.find((note) => note.id === id) if (!removed) return { deleted: false } notes = notes.filter((note) => note.id !== id) persist() emit(command, runtime, 'note.deleted', { note: removed }) return { deleted: true, id } }, }, })}
function emit( command: { context?: unknown; entity?: unknown }, runtime: KitRuntime, type: string, payload: Record<string, unknown>,) { runtime.emit({ type, context: command.context as never, payload, })}
function load(): Note[] | undefined { try { const raw = localStorage.getItem(STORAGE_KEY) return raw ? (JSON.parse(raw) as Note[]) : undefined } catch { return undefined }}A few patterns worth pointing out:
notesModuleis a factory (returns a module). Closure-scoped state holds the notes array and the listener set.- The
serviceexposed viaNotesTokenis read-only-ish — it haslist,get, andsubscribe. Mutation goes through commands. This is a deliberate seam. - After every write,
persist()writes to localStorage and notifies subscribers. - After every command, the module emits a corresponding event (
note.created,note.updated,note.deleted). Observers (audit, analytics, UI rerender) can subscribe to these.
Wire it into main.ts
Section titled “Wire it into main.ts”import { notesModule, NotesToken } from './notes-module.js'
shell.modules = [ notesModule(seedNotes), notificationModule(), dialogModule(), debugModule(),]
await customElements.whenDefined('kit-shell')// ...mount happens automatically...
// Wait for the runtime to start, then read the notes service:queueMicrotask(() => { const service = shell.runtime.inject(NotesToken) if (!service) return
const draw = () => { document.getElementById('main')!.innerHTML = renderApp(service.list()) }
draw() service.subscribe(draw)})The UI now subscribes to the service. Whenever notes change, it redraws.
The form’s submit handler from chapter 4 doesn’t change — note.create is still dispatched the same way. What changed is who handles it. We’ve moved the work from the page into a module.
Drop the temporary handler
Section titled “Drop the temporary handler”Delete the shell.runtime.handleCommand('note.create', ...) block from chapter 4. The module covers it now.
What we have now
Section titled “What we have now”- Notes survive a page refresh.
- Three commands (
note.create,note.update,note.delete) are handled in one module. - Three events (
note.created,note.updated,note.deleted) are emitted for anything that wants to observe writes. - The UI reads notes through
NotesToken. It doesn’t import the module’s internals.
The form, the editor, the cards — none of them import this file. They speak through commands. The seam is clean.
What’s next
Section titled “What’s next”An audit log. The hardest version of this in a typical React codebase is “every component that writes anything also calls audit.record”. Ours is one module that observes events and renders a panel. Zero UI changes.