Creating a ryOS Store
ryOS state lives in Zustand stores under src/stores/, named use<Name>Store.ts. Most persist to localStorage via the persist middleware. This skill covers the conventions: persistence, partialize, versioned migrations, the debounced storage adapter, and cloud-sync tombstones.
Quick Start Checklist
- [ ] 1. Create src/stores/use<Name>Store.ts
- [ ] 2. Define State interface (data + actions together)
- [ ] 3. create<State>()(persist((set, get) => ({...}), { name: "ryos:<name>" }))
- [ ] 4. Add version + migrate if the shape may evolve
- [ ] 5. Use the debounced storage adapter for large/hot slices
- [ ] 6. partialize to persist only what's needed
- [ ] 7. If synced: mark deletions as tombstones via useCloudSyncStore
- [ ] 8. Read in non-React code with useXStore.getState()
Basic Store
Keep data and actions in one interface. Actions use set/get. Generate IDs with crypto.randomUUID().
import { create } from "zustand";
import { persist } from "zustand/middleware";
export interface Item {
id: string;
content: string;
createdAt: number;
updatedAt: number;
}
interface MyState {
items: Item[];
addItem: (content: string) => string;
updateItem: (id: string, updates: Partial<Omit<Item, "id" | "createdAt">>) => void;
deleteItem: (id: string) => void;
clearAll: () => void;
}
export const useMyStore = create<MyState>()(
persist(
(set, get) => ({
items: [],
addItem: (content) => {
const id = crypto.randomUUID();
const now = Date.now();
set((s) => ({ items: [...s.items, { id, content, createdAt: now, updatedAt: now }] }));
return id;
},
updateItem: (id, updates) =>
set((s) => ({
items: s.items.map((it) =>
it.id === id ? { ...it, ...updates, updatedAt: Date.now() } : it
),
})),
deleteItem: (id) => set((s) => ({ items: s.items.filter((it) => it.id !== id) })),
clearAll: () => set({ items: [] }),
}),
{
name: "ryos:my-feature", // every app-owned storage key uses `ryos:`
}
)
);
Accessing stores
- In React:
const items = useMyStore((s) => s.items); (selector — avoid subscribing to the whole store).
- Outside React (tool handlers, utils, other stores):
useMyStore.getState() / useMyStore.getState().addItem(...).
Persisted Config Options
Inside the second persist argument:
| Option |
Use |
name |
Required. Namespaced storage key, "ryos:<feature>". |
partialize |
Persist only the needed fields (omit transient UI/derived state). |
version + migrate |
Required when the persisted shape can change over time. |
storage |
Use the debounced adapter for large/hot slices (see below). |
partialize
partialize: (state) => ({
items: state.items,
// omit transient fields like isLoading, selectedId, etc.
}),
Versioned migrations
Bump version whenever the persisted shape changes; handle older payloads in migrate. Reference: useChatsStore.ts.
const STORE_VERSION = 2;
// ...inside persist config:
version: STORE_VERSION,
migrate: (persistedState, version) => {
if (version < 2) {
// transform old shape → new shape
}
return persistedState as MyState;
},
Debounced Storage Adapter (large / hot slices)
createJSONStorage(() => localStorage) re-serializes the entire partialized slice synchronously on every mutation. For large or frequently-written slices (chat history, Files VFS, iPod library), use the write-behind adapter from src/utils/debouncedPersistStorage.ts:
import { createDebouncedPersistStorage } from "@/utils/debouncedPersistStorage";
// ...inside persist config:
storage: createDebouncedPersistStorage(),
It keeps localStorage authoritative but batches writes per quiet window and flushes on pagehide/tab-hidden. Flows that read raw localStorage keys directly (system reset, manual backup) must call flushDebouncedPersistWrites() first. Small slices (settings, a handful of notes) don't need this — plain name is fine.
IndexedDB Storage Adapter (slices that overflow the localStorage quota)
localStorage has a hard ~5–10MB per-origin quota; a slice that inlines large binary-ish data (e.g. Soundboard recordings store base64 audio in boards) will silently throw QuotaExceededError and lose writes (historically crashing on mobile Safari). For those, persist to IndexedDB via src/utils/indexedDBPersistStorage.ts:
import { createIndexedDBPersistStorage } from "@/utils/indexedDBPersistStorage";
// ...inside persist config:
storage: createIndexedDBPersistStorage(),
It mirrors the debounced localStorage adapter (write-behind, read-your-writes, shared flushDebouncedPersistWrites()/haltDebouncedPersistWrites() hooks) but hydrates asynchronously. Two consequences:
- Records live in the
persisted_state IndexedDB object store, keyed by the
persist name. On first read it transparently migrates the slice's legacy localStorage value, then drops the localStorage key.
- Because hydration is async, gate any "seed defaults if empty" logic on
useXStore.persist.hasHydrated() / onFinishHydration so you don't clobber restored data. Avoid for boot-critical stores read synchronously via getState() before hydration completes.
Manual backup must await settlePersistWrites() (not just the sync flush) before reading the raw persisted_state records, and the store name must be listed in the backup's IndexedDB store set.
Normalized IndexedDB persistence
If a slice contains a large entity collection or binary payloads, use createSplitIndexedDBPersistStorage from src/utils/splitIndexedDBPersistStorage.ts. It keeps scalar metadata in persistedstate, migrates old monolithic snapshots on hydration, and writes changed entities to dedicated object stores. Add each object store to STORES, bump DBVERSION, include it in MANUALBACKUPINDEXEDDB_STORES, and cover migration + row deletion in tests. Soundboard, Chats, TextEdit, and Files are the reference implementations.
Cloud Sync: Deletion Tombstones
If the store's data participates in cloud sync, a plain local delete isn't enough — the deletion must be recorded as a tombstone so other devices remove it too. Call useCloudSyncStore.getState().markDeletedKeys(bucket, ids) when deleting (pattern from useStickiesStore.ts):
import { useCloudSyncStore } from "@/stores/useCloudSyncStore";
deleteItem: (id) => {
set((s) => ({ items: s.items.filter((it) => it.id !== id) }));
useCloudSyncStore.getState().markDeletedKeys("stickyNoteIds", [id]);
},
clearAll: () => {
const ids = get().items.map((it) => it.id);
useCloudSyncStore.getState().markDeletedKeys("stickyNoteIds", ids);
set({ items: [] });
},
Use an existing CloudSyncDeletionBucket value (see useCloudSyncStore.ts); only introduce a new bucket when adding a genuinely new synced collection, and wire it through the sync engine. Stores that don't sync skip this entirely.
Conventions Summary
- One interface holding both state and actions; export
use<Name>Store.
name: "ryos:<feature>"; partialize to the minimum needed.
- Add
version + migrate for any shape that can evolve.
- Debounced storage for large/hot slices; plain otherwise.
- Synced collections must tombstone deletions via
useCloudSyncStore.
crypto.randomUUID() for ids; track createdAt/updatedAt where useful.
- Read outside React with
getState().
Testing
Store logic (reducers, migrations, tombstone calls) is unit-testable without a server — see the write-tests skill and examples like tests/unit/apps/test-stickies-tool-reducer.test.ts and tests/unit/sync/test-debounced-persist-storage.test.ts. Place new unit suites under tests/unit/<domain>/ (auto-discovered by test:unit).