Skip to content

5. Persistence Module

A notesModule that:

  • Loads notes from localStorage on startup
  • Handles note.create, note.update, note.delete commands
  • Emits note.created, note.updated, note.deleted events for observers
  • Exposes a NotesToken provider so the UI can read the current notes
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:

  • notesModule is a factory (returns a module). Closure-scoped state holds the notes array and the listener set.
  • The service exposed via NotesToken is read-only-ish — it has list, get, and subscribe. 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.
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.

Delete the shell.runtime.handleCommand('note.create', ...) block from chapter 4. The module covers it 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.

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.

Next: Chapter 6 — Audit Module →