@pixygon/savedata
Unified save data system for all Pixygon applications — auto-sync, conflict detection, ownership guard
Install
# .npmrc @pixygon:registry=https://ppm.pixygon.io/ npm install @pixygon/savedata@1.1.3
Versions
| Version | Published | Integrity |
|---|---|---|
| 1.1.3 latest | 2026-09-30 | sha512-8IatpBfhsd9D/3m… |
| 1.1.2 | 2026-05-01 | sha512-XcvE8+ralQGVI8k… |
| 1.1.1 | 2026-04-30 | sha512-q4ctPTBrG1gzfRC… |
| 1.1.0 | 2026-04-25 | sha512-lHTR6JVx5rcE3/E… |
| 1.0.0 | 2026-04-25 | sha512-va8N+rgpHU6zgUs… |
@pixygon/savedata
Unified, server-synced save data for Pixygon apps — auto-save, dirty tracking,
revision-based conflict detection, and an ownership guard. 6 repos depend
on it (Tastebud, Kartograf, CoreFeed, SoloBattle, pixygon.io, pixiel.ai), and
several other pearls (@pixygon/social, /quests, /inventory, /anima)
store their state through the same endpoints.
Backed by /v1/savedata/:projectId/:userId on the Pixygon API. The Unity
counterpart is com.pixygon.saving (related, deliberately separate).
The mental model
A save is projectId → userId → slot → { section: {...}, section: {...} }.
projectIdis the save namespace, a free-form string the server keys on. The estate convention is the repo's.pixygon.jsonObjectId. Changing it later orphans every existing save — treat it as permanent.- Apps normally mount two providers: the shared global project
65481fec93ee5f866ade917f(cross-app skills/XP/inventory) and the app's own. - Sections are independent; only dirty ones are sent.
Retrofitting it into an existing app (≈15 minutes)
1. Install
npm i @pixygon/savedata
Requires @pixygon/auth (or any token source) — the config needs a live token
and user id.
2. Mount the provider(s) — inside AuthProvider
// src/providers/SaveDataProvider.tsx — the estate pattern (Tastebud/Kartograf)
import { SaveProvider } from '@pixygon/savedata'
import { useToken, useAuth } from '@pixygon/auth'
import type { SaveConfig } from '@pixygon/savedata'
const API_URL = import.meta.env.VITE_API_URL || 'https://api.pixygon.com/v1'
function GlobalSaveProvider({ children }: { children: ReactNode }) {
const { getAccessToken } = useToken()
const { user } = useAuth()
const config: SaveConfig = {
baseUrl: API_URL,
projectId: '65481fec93ee5f866ade917f', // shared cross-app project
getToken: getAccessToken,
getUserId: () => (user as any)?._id ?? null,
autoSaveInterval: 15_000,
}
return <SaveProvider config={config}>{children}</SaveProvider>
}
function AppSaveProvider({ children }: { children: ReactNode }) {
/* same, with projectId = this repo's .pixygon.json ObjectId, 10s interval */
}
export function SaveDataProvider({ children }: { children: ReactNode }) {
return <GlobalSaveProvider><AppSaveProvider>{children}</AppSaveProvider></GlobalSaveProvider>
}
Providers nest; each gets its own React context keyed by projectId.
All four of baseUrl, projectId, getToken, getUserId are required.
With no getUserId the provider never loads and never saves — silently.
3. Read and write
import { useSavedata, useSavedataSection } from '@pixygon/savedata'
// whole save for one namespace
const { data, isLoaded, syncStatus, revision, updateSection, mergeSection, forceSave, forceLoad } =
useSavedata<MySave>('69ee9b3f7a116f8ba90d0890')
updateSection('lists', { items }) // replace a section
mergeSection('prefs', { theme: 'dark' }) // shallow-merge into a section
// one section, with a default
const { data: skills, update, merge } =
useSavedataSection('skills', { skillXp: {}, totalXp: 0 }, '65481fec93ee5f866ade917f')
⚠ useSavedata('<id>') with an id that has no mounted provider THROWS
(no SaveProvider for projectId "…"). With multiple providers mounted, always
pass the id explicitly — the no-arg form resolves to the last mounted
provider, which is order-dependent and easy to get wrong.
4. Verify it worked
node node_modules/@pixygon/savedata/verify.mjs
# or:
pearl verify savedata
Checks: installed, <SaveProvider> mounted, all four SaveConfig fields
present, a non-placeholder projectId (flagging slugs and unexpected
ObjectIds), a real baseUrl, and that every useSavedata(projectId) call
targets a mounted provider. Exit 0 pass, 1 fail.
What verify cannot prove: that a save round-trips. Do it by hand once:
sign in → change something → wait for syncStatus: 'synced' → hard-reload →
confirm it survived. Watch the network tab for 409 (revision conflict) and
403 (ownership guard).
API
| Export | Use |
|---|---|
<SaveProvider config> | the mount (one per save namespace) |
useSavedata<T>(projectId?) | data, isLoaded, syncStatus, revision, lastSyncedAt, error, updateSection, mergeSection, forceSave, forceLoad |
useSavedataSection<T>(key, default, projectId?) | data, isLoaded, syncStatus, update, merge |
useSaveContext(projectId?) | raw context (throws if not mounted) |
loadFromServer / mergeToServer / updateSectionOnServer | imperative, outside React |
SaveConfig
{
baseUrl: string // https://api.pixygon.com/v1
projectId: string // the save namespace — permanent
getToken: () => string | null
getUserId: () => string | null
slot?: number // default 0
autoSaveInterval?: number // default 10_000 ms (0 = off)
saveOnVisibilityHidden?: boolean // default true
saveOnBeforeUnload?: boolean // default true
debug?: boolean
}
syncStatus: idle | loading | synced | saving | conflict | error.
Safety features (and what they mean when they fire)
- Ownership guard — saves are blocked when
getUserId()changes mid-session (account switch). Server-sideenforceOwnershiprejects cross-user writes with403. - Revision tracking — the client sends
expectedRevision; a409means another tab/device wrote first.syncStatusbecomesconflictand the server's data comes back in the error payload. - Merge-on-load — for a same-user reload, local and server data are merged (max wins) instead of blindly overwriting.
- Dirty tracking — only changed sections are sent.
Gotchas
- The namespace is permanent. Changing
projectIdsilently orphans every existing save. Never "tidy" an ObjectId into a slug. keepalivecaps at ~60 KB. Saves larger than that lose their best-effort chance of surviving a tab close — callforceSave()earlier.- The no-arg hook is order-dependent. See step 3.
- Cross-app XP lives in the global project on purpose.
@pixygon/social'suseSkillsSyncwrites to65481fec93ee5f866ade917fno matter which app it runs in. Don't repoint it at your app. - Some repos mount only the global provider (e.g. CoreFeed) — that means the app has no private save namespace. Fine if deliberate; check it is.
Publishing
npm run build && npm publish --access public