@statedelta-apex/record-state
v3.0.0
Published
ApexStore Record primitive - typed key-value state with deep path access
Maintainers
Readme
@statedelta-apex/record-state
Record primitive — armazenamento key-value tipado com acesso deep path, transações aninhadas e imutabilidade por convenção.
Filosofia
State managers tradicionais (Redux, Zustand) armazenam objetos genéricos — o consumer escreve reducers pra mutar e selectors pra ler. O Record inverte isso: é um store completo e autossuficiente que já sabe como armazenar, mutar, fazer transaction/rollback, notificar listeners e serializar seu próprio estado.
O consumer só chama actions tipadas (set, merge, update) e getters tipados (get, pick, keys). Toda mecânica interna (shallow clone, strategy swap, middleware chain) é invisível.
import { createRecord } from "@statedelta-apex/record-state";
const user = createRecord("user", {
name: "Anderson",
age: 30,
role: "admin",
});
user.set("name", "Rosa"); // type-safe: key e value validados via keyof T
user.update("age", (v) => v + 1); // functional update atômico
user.merge({ role: "superadmin" }); // batch: um clone, um notify
user.get("name"); // 'Rosa'
user.pick("name", "age"); // { name: 'Rosa', age: 31 }Princípios
- Determinístico — mesmas actions no mesmo estado produzem sempre o mesmo resultado.
- Imutável por convenção — toda mutação gera uma nova referência via shallow clone. Referências anteriores permanecem intactas.
- Autossuficiente — funciona standalone ou gerenciado por um Store. Transaction, subscribe, middleware, snapshot — tudo built-in.
- Zero branches no hot path — o pipeline de mutação usa strategy swap. Quem não usa subscribe/middleware/hook não paga nada.
- Type-safe — actions e getters top-level são tipados via
keyof T. Deep path é viastring[]com validação runtime.
Instalação
pnpm add @statedelta-apex/record-stateRequer @statedelta-apex/store como dependência (instalada automaticamente).
Quick Start
import { createRecord } from "@statedelta-apex/record-state";
// Criar
const config = createRecord("config", {
volume: 80,
difficulty: "hard",
muted: false,
});
// Mutar
config.set("volume", 50);
config.merge({ muted: true, difficulty: "easy" });
// Ler
config.get("volume"); // 50
config.keys(); // ['volume', 'difficulty', 'muted']
config.pick("volume", "muted"); // { volume: 50, muted: true }
// Reagir
const unsub = config.subscribe((state, prev) => {
console.log("config mudou:", state);
});
// Transacionar
config.transaction(() => {
config.set("volume", 0);
config.set("muted", false);
// throw aqui → rollback ambos os sets
});
// Simular (what-if)
const result = config.simulate(() => {
config.set("volume", 100);
return config.get("volume");
});
// result === 100, config.get('volume') ainda 50API
createRecord(id, initialData, config?)
Cria um Record primitive.
function createRecord<T extends Record<string, unknown>>(
id: string,
initialData: T,
config?: { trackDeltas?: boolean },
): RecordPrimitive<T>;| Parâmetro | Tipo | Descrição |
| ------------- | ----------------- | ------------------------------------- |
| id | string | Identificador único |
| initialData | T | Estado inicial (clonado internamente) |
| config | PrimitiveConfig | Configuração opcional |
const record = createRecord("settings", { volume: 80, theme: "dark" });Actions (mutações)
Toda action produz uma nova referência via shallow clone e dispara o pipeline de mutação (hook → middleware → state → listeners). Uma action = um clone = um notify.
set(key, value)
Define o valor de uma key top-level. Type-safe: key é keyof T, value é T[K].
set<K extends keyof T>(key: K, value: T[K]): voidconst user = createRecord("user", { name: "Anderson", age: 30 });
user.set("name", "Rosa");
user.set("age", 31);
// user.set('age', 'string') // ← TS error: Type 'string' is not assignable to type 'number'
// user.set('invalid', 1) // ← TS error: Argument of type '"invalid"' is not assignablesetPath(path, value)
Define valor em path aninhado. Path é string[] — sem parsing de dot-notation.
setPath(path: readonly string[], value: unknown): voidconst player = createRecord("player", {
stats: { hp: 100, mp: 50 },
position: { x: 0, y: 0 },
});
player.setPath(["stats", "hp"], 70);
player.setPath(["position", "x"], 10);
// Path como const para reuso (zero alocação por frame)
const HP_PATH = ["stats", "hp"] as const;
player.setPath(HP_PATH, 50);update(key, fn)
Functional update atômico. Recebe o valor atual e retorna o novo. Evita o padrão read-then-write.
update<K extends keyof T>(key: K, fn: (prev: T[K]) => T[K]): voidconst counter = createRecord("counter", { count: 0, label: "clicks" });
// Atômico — sem read-then-write
counter.update("count", (v) => v + 1);
counter.update("count", (v) => v * 2);
counter.update("label", (v) => v.toUpperCase());updatePath(path, fn)
Functional update em path aninhado.
updatePath(path: readonly string[], fn: (prev: unknown) => unknown): voidconst game = createRecord("game", {
stats: { hp: 100, mp: 50 },
});
game.updatePath(["stats", "hp"], (v) => (v as number) - 30);
game.updatePath(["stats", "mp"], (v) => (v as number) + 10);merge(partial)
Shallow merge de múltiplas keys em uma única mutação. Uma clone, um notify.
merge(partial: Partial<T>): voidconst settings = createRecord("settings", {
volume: 80,
brightness: 100,
muted: false,
});
// 3 keys alteradas, 1 mutação
settings.merge({ volume: 50, muted: true, brightness: 75 });delete(key)
Remove uma key top-level. O consumer assume responsabilidade de tipo — o objeto runtime pode não satisfazer T após a remoção.
delete<K extends keyof T>(key: K): voidconst data = createRecord("data", { a: 1, b: 2, c: 3 });
data.delete("c");
data.has("c"); // false
data.keys(); // ['a', 'b']deletePath(path)
Remove valor em path aninhado. Noop se o path não existe.
deletePath(path: readonly string[]): voidconst config = createRecord("config", {
features: { darkMode: true, animations: true, beta: false },
});
config.deletePath(["features", "beta"]);
config.hasPath(["features", "beta"]); // false
config.hasPath(["features", "darkMode"]); // truereplace(data)
Substitui o estado inteiro. O input é clonado internamente.
replace(data: T): voidconst state = createRecord("state", { x: 1, y: 2 });
state.replace({ x: 99, y: 99 });
state.getState(); // { x: 99, y: 99 }
// Input é clonado — mutação externa não afeta o record
const newData = { x: 0, y: 0 };
state.replace(newData);
newData.x = 999;
state.get("x"); // 0 (não 999)reset()
Restaura o estado inicial (passado na criação). Cada reset produz um clone independente.
reset(): voidconst record = createRecord("hp", { value: 100, max: 100 });
record.set("value", 30);
record.get("value"); // 30
record.reset();
record.get("value"); // 100Getters (leitura)
Getters são leituras puras. Sem side effects, sem mutações. Podem ser chamados em qualquer ordem, qualquer número de vezes.
get(key)
Retorna o valor de uma key top-level. Type-safe: retorno é T[K].
get<K extends keyof T>(key: K): T[K]const user = createRecord("user", { name: "Anderson", age: 30 });
const name: string = user.get("name"); // 'Anderson'
const age: number = user.get("age"); // 30
// const x = user.get('invalid'); // ← TS errorgetPath(path)
Retorna valor em path aninhado. Retorna undefined se qualquer nível intermediário não existe.
getPath(path: readonly string[]): unknownconst player = createRecord("player", {
stats: { hp: 100, mp: 50 },
inventory: [{ id: "sword" }, { id: "shield" }],
});
player.getPath(["stats", "hp"]); // 100
player.getPath(["inventory", "0", "id"]); // 'sword'
player.getPath(["stats", "nonexistent"]); // undefined
player.getPath(["x", "y", "z"]); // undefinedgetOr(key, fallback)
Retorna o valor de uma key, ou o fallback se o valor é undefined.
getOr<K extends keyof T>(key: K, fallback: T[K]): T[K]const config = createRecord<{ theme?: string; lang?: string }>("config", {});
config.getOr("theme", "light"); // 'light' (key não existe)
config.set("theme", "dark");
config.getOr("theme", "light"); // 'dark' (key existe)pick(...keys)
Retorna um novo objeto com apenas as keys especificadas.
pick<K extends keyof T>(...keys: K[]): Pick<T, K>const user = createRecord("user", {
name: "Anderson",
age: 30,
role: "admin",
email: "[email protected]",
});
user.pick("name", "email");
// { name: 'Anderson', email: '[email protected]' }
user.pick("age");
// { age: 30 }omit(...keys)
Retorna um novo objeto sem as keys especificadas.
omit<K extends keyof T>(...keys: K[]): Omit<T, K>const user = createRecord("user", {
name: "Anderson",
age: 30,
password: "secret",
});
user.omit("password");
// { name: 'Anderson', age: 30 }has(key)
Verifica se uma key top-level existe (via operador in).
has(key: string): booleanconst record = createRecord("r", { a: 1, b: undefined });
record.has("a"); // true
record.has("b"); // true (key existe, mesmo com valor undefined)
record.has("c"); // falsehasPath(path)
Verifica se um valor existe em path aninhado. Usa in no último nível — retorna true mesmo se o valor é undefined.
hasPath(path: readonly string[]): booleanconst data = createRecord("data", { a: { b: { c: 1 } } });
data.hasPath(["a", "b", "c"]); // true
data.hasPath(["a", "b", "x"]); // false
data.hasPath(["x", "y"]); // false
data.hasPath([]); // truekeys()
Retorna todas as keys top-level.
keys(): (keyof T)[]const record = createRecord("r", { name: "A", age: 30, role: "admin" });
record.keys(); // ['name', 'age', 'role']values()
Retorna todos os valores top-level.
values(): T[keyof T][]const record = createRecord("r", { name: "A", age: 30 });
record.values(); // ['A', 30]entries()
Retorna todas as entries top-level como [key, value][].
entries(): [keyof T, T[keyof T]][]const record = createRecord("r", { name: "A", age: 30 });
record.entries(); // [['name', 'A'], ['age', 30]]Properties
size
Número de keys top-level. Getter — avaliado sob demanda.
readonly size: numberconst record = createRecord("r", { a: 1, b: 2, c: 3 });
record.size; // 3isEmpty
true se o record não tem nenhuma key. Getter.
readonly isEmpty: booleanconst empty = createRecord("e", {});
empty.isEmpty; // true
const full = createRecord("f", { a: 1 });
full.isEmpty; // falseEstado
getState()
Retorna o estado completo. A referência é imutável por convenção — não modifique diretamente.
getState(): Tconst record = createRecord("r", { a: 1, b: 2 });
const state = record.getState(); // { a: 1, b: 2 }
// Cada mutação gera uma nova referência
record.set("a", 99);
const newState = record.getState();
state !== newState; // true — referências diferentes
state.a; // 1 — original intacto
newState.a; // 99id / type
Identificadores do primitivo.
readonly id: string // ID passado na criação
readonly type: string // 'record'Transactions
O Record suporta transações aninhadas via stack de snapshots. Cada beginTransaction() empilha o estado atual. rollback() restaura, commitTransaction() descarta o snapshot.
API básica
const record = createRecord("hp", { value: 100, max: 100 });
record.beginTransaction();
record.set("value", 30);
record.get("value"); // 30
record.rollback();
record.get("value"); // 100 — restauradorecord.beginTransaction();
record.set("value", 50);
record.commitTransaction();
record.get("value"); // 50 — mantidotransaction(fn)
Commit automático no sucesso, rollback automático no throw.
transaction<R>(fn: () => R): Rconst record = createRecord("data", { a: 1, b: 2, c: 3 });
// Sucesso → commit
const result = record.transaction(() => {
record.set("a", 10);
record.set("b", 20);
return record.get("a") + record.get("b");
});
// result === 30, record.get('a') === 10
// Falha → rollback
try {
record.transaction(() => {
record.set("a", 999);
throw new Error("abort");
});
} catch {
// ignorado
}
record.get("a"); // 10 — rollback restaurousimulate(fn)
Sempre rollback. Para cenários "what if?" sem mutar estado.
simulate<R>(fn: () => R): Rconst hp = createRecord("hp", { value: 100, min: 0 });
const wouldDie = hp.simulate(() => {
hp.set("value", 0);
return hp.get("value") <= hp.get("min");
});
wouldDie; // true
hp.get("value"); // 100 — intactoNesting
Transações suportam nesting arbitrário. Cada chamada abre um nível na stack.
const record = createRecord("state", { x: 1 });
record.transaction(() => {
record.set("x", 10);
// Simula dentro de transaction
const safe = record.simulate(() => {
record.set("x", -1);
return record.get("x") >= 0;
});
// record.get('x') ainda 10
if (!safe) throw new Error("invalid state");
});inTransaction / transactionDepth
readonly inTransaction: boolean // true se algum nível está ativo
readonly transactionDepth: number // 0 = sem transactionconst record = createRecord("r", { a: 1 });
record.inTransaction; // false
record.transactionDepth; // 0
record.beginTransaction();
record.inTransaction; // true
record.transactionDepth; // 1
record.beginTransaction();
record.transactionDepth; // 2
record.rollback();
record.transactionDepth; // 1Subscribe (reatividade)
Registra um listener que é chamado a cada mutação. Recebe (state, prevState).
subscribe(listener: (state: T, prevState: T) => void): () => voidRetorna uma função de unsubscribe.
const record = createRecord("hp", { value: 100, max: 100 });
const unsub = record.subscribe((state, prev) => {
console.log(`HP: ${prev.value} → ${state.value}`);
});
record.set("value", 70); // log: "HP: 100 → 70"
record.set("value", 50); // log: "HP: 70 → 50"
unsub();
record.set("value", 30); // sem logprevState de graça
Como toda mutação é shallow clone, a referência anterior já existe em memória. O listener recebe prevState sem custo extra de cópia.
Múltiplos listeners
const a = record.subscribe((state) => console.log("A:", state.value));
const b = record.subscribe((state) => console.log("B:", state.value));
record.set("value", 42);
// log: "A: 42"
// log: "B: 42"
a(); // unsubscribe A
record.set("value", 0);
// log: "B: 0" (só B)Zero overhead sem subscribers
Quando nenhum listener está registrado, o pipeline de mutação usa a strategy mutateBare (ou variante sem listeners). Sem iteração, sem checagem — puro state = newState.
Middleware
Interceptors de mutação. Podem observar, validar, bloquear ou transformar mutações.
use(middleware: (current: T, next: T, proceed: (override?: T) => void) => void): () => voidRetorna uma função de remoção.
| Chamada | Comportamento |
| -------------------- | ----------------------------------------- |
| proceed() | Aplica o valor original (next) |
| proceed(override) | Substitui next pelo override e aplica |
| sem chamar proceed | Bloqueia a mutação |
Logging
const record = createRecord("data", { count: 0 });
const remove = record.use((current, next, proceed) => {
console.log("before:", current.count, "→", next.count);
proceed();
console.log("applied");
});
record.set("count", 1);
// log: "before: 0 → 1"
// log: "applied"
remove(); // remove middlewareValidação (bloquear mutação)
Não chamar proceed() bloqueia a mutação. O estado permanece inalterado e listeners não são notificados.
const hp = createRecord("hp", { value: 100, min: 0, max: 100 });
hp.use((current, next, proceed) => {
if (next.value < next.min) return; // bloqueia
if (next.value > next.max) return; // bloqueia
proceed(); // permite
});
hp.set("value", 50); // permitido → value === 50
hp.set("value", -10); // bloqueado → value === 50
hp.set("value", 200); // bloqueado → value === 50Normalização — proceed(override)
proceed(override) substitui o valor antes de aplicar. Para clamping, arredondamento, sanitização.
const hp = createRecord("hp", { value: 100, min: 0, max: 100 });
hp.use((_current, next, proceed) => {
const clamped = Math.max(next.min, Math.min(next.max, next.value));
if (clamped !== next.value) {
proceed({ ...next, value: clamped }); // normaliza
} else {
proceed();
}
});
hp.set("value", 200); // normalizado → value === 100
hp.set("value", -50); // normalizado → value === 0
hp.set("value", 50); // passthrough → value === 50Chain de transformações
O override flui pela chain — cada middleware recebe o valor já transformado pelo anterior.
const stats = createRecord("stats", { hp: 100 });
// mw1: arredonda
stats.use((_cur, next, proceed) => {
proceed({ ...next, hp: Math.round(next.hp) });
});
// mw2: clamp 0..100
stats.use((_cur, next, proceed) => {
proceed({ ...next, hp: Math.max(0, Math.min(100, next.hp)) });
});
stats.set("hp", 150.7);
// mw1 recebe 150.7, passa 151 → mw2 recebe 151, passa 100 → aplica 100
stats.set("hp", -3.2);
// mw1 recebe -3.2, passa -3 → mw2 recebe -3, passa 0 → aplica 0Diff capture (pós-proceed)
Middleware pode capturar diffs entre o estado anterior e o novo após proceed().
const record = createRecord("data", { count: 0 });
record.use((current, _next, proceed) => {
proceed();
const after = record.getState();
saveDelta(current, after); // captura diff antes dos listeners
});Cadeia de middleware
Múltiplos middleware executam em ordem de registro (FIFO). Cada um decide se chama proceed().
record.use((current, next, proceed) => {
console.log("mw1 before");
proceed();
console.log("mw1 after");
});
record.use((current, next, proceed) => {
console.log("mw2 before");
proceed();
console.log("mw2 after");
});
record.set("count", 1);
// "mw1 before"
// "mw2 before"
// "mw2 after"
// "mw1 after"Middleware bloqueando listeners
Se middleware não chama proceed(), a mutação é bloqueada e listeners não são notificados.
const listener = vi.fn();
record.subscribe(listener);
record.use(() => {
/* não chama proceed */
});
record.set("count", 1);
listener; // não chamadoSnapshot / Restore
Serialização e restauração de estado para persistência.
snapshot()
Retorna um objeto serializado com tipo, ID e dados.
snapshot(): PrimitiveSnapshot
// { type: 'record', id: string, data: unknown }const record = createRecord("settings", { volume: 80, theme: "dark" });
const snap = record.snapshot();
// {
// type: 'record',
// id: 'settings',
// data: { volume: 80, theme: 'dark' }
// }
// Persistir
localStorage.setItem("settings", JSON.stringify(snap));restore(snapshot)
Restaura estado de um snapshot.
restore(snapshot: PrimitiveSnapshot): void// Restaurar
const saved = JSON.parse(localStorage.getItem("settings")!);
record.restore(saved);
record.get("volume"); // 80Store Integration
O Record funciona standalone por padrão. Quando registrado em um Store (orquestrador), o Store seta hooks internos para coordenar transações entre múltiplos primitivos.
// Standalone — sem Store
const hp = createRecord("hp", { value: 100 });
hp.set("value", 70); // funciona normalmente
// Com Store — coordenação entre primitivos
store.register(hp);
store.transaction(() => {
hp.set("value", 50);
board.setCell(0, 0, "X");
// throw → rollback AMBOS
});_onBeforeMutate / _updateStrategy()
Hooks internos usados pelo Store. O consumer não precisa usar diretamente.
_onBeforeMutate: (() => void) | null // hook pré-mutação (lazy transaction)
_updateStrategy(): void // recompila pipeline de mutaçãoExtensão via Composição
O Record é um primitivo — ele cuida de storage, transaction, snapshot, reactivity. Tipos derivados compõem com o Record, adicionando actions/getters de domínio sem se preocupar com mecânica interna.
Exemplo: Counter
Um Counter é um Record { value, min, max, step } com actions de domínio (increment, decrement, clamp) e getters de domínio (percent).
import { createRecord } from "@statedelta-apex/record-state";
import type { RecordPrimitive } from "@statedelta-apex/record-state";
interface CounterState {
value: number;
min: number;
max: number;
step: number;
}
interface Counter extends RecordPrimitive<CounterState> {
increment(amount?: number): void;
decrement(amount?: number): void;
clamp(): void;
readonly percent: number;
}
function createCounter(
id: string,
initial: { value: number; min?: number; max?: number; step?: number },
): Counter {
const data: CounterState = {
value: initial.value,
min: initial.min ?? -Infinity,
max: initial.max ?? Infinity,
step: initial.step ?? 1,
};
const record = createRecord<CounterState>(id, data);
return {
...record,
increment(amount?: number) {
const state = record.getState();
const delta = amount ?? state.step;
record.set("value", Math.min(state.value + delta, state.max));
},
decrement(amount?: number) {
const state = record.getState();
const delta = amount ?? state.step;
record.set("value", Math.max(state.value - delta, state.min));
},
clamp() {
const state = record.getState();
record.set(
"value",
Math.max(state.min, Math.min(state.value, state.max)),
);
},
get percent() {
const state = record.getState();
if (state.max === state.min) return state.value >= state.max ? 1 : 0;
return (state.value - state.min) / (state.max - state.min);
},
};
}const hp = createCounter("hp", { value: 100, min: 0, max: 100 });
hp.get("value"); // 100 (getter do Record)
hp.percent; // 1.0 (getter do Counter)
hp.decrement(30);
hp.get("value"); // 70
hp.percent; // 0.7
hp.decrement(999);
hp.get("value"); // 0 (clamped pelo min)
// Transaction, subscribe, middleware — tudo funciona
hp.transaction(() => {
hp.decrement(50);
if (hp.get("value") <= 0) throw new Error("would die");
});
hp.subscribe((state, prev) => {
console.log(`HP: ${prev.value} → ${state.value}`);
});
// Snapshot inclui tudo
hp.snapshot();
// { type: 'record', id: 'hp', data: { value: 0, min: 0, max: 100, step: 1 } }Exemplo: AppSettings
Um derivado que adiciona validação e defaults sobre um Record.
interface Settings {
locale: string;
timezone: string;
notifications: boolean;
maxRetries: number;
}
const DEFAULTS: Settings = {
locale: "pt-BR",
timezone: "America/Sao_Paulo",
notifications: true,
maxRetries: 3,
};
function createAppSettings(id: string, overrides?: Partial<Settings>) {
const record = createRecord<Settings>(id, { ...DEFAULTS, ...overrides });
// Middleware de validação
record.use((_current, next, proceed) => {
if (next.maxRetries < 0 || next.maxRetries > 10) return; // bloqueia
if (!next.locale || !next.timezone) return; // bloqueia
proceed();
});
return {
...record,
resetToDefaults() {
record.replace(DEFAULTS);
},
isDefault<K extends keyof Settings>(key: K): boolean {
return record.get(key) === DEFAULTS[key];
},
};
}const settings = createAppSettings("app-settings");
settings.set("maxRetries", 5); // permitido
settings.set("maxRetries", -1); // bloqueado pelo middleware
settings.get("maxRetries"); // 5
settings.isDefault("locale"); // true
settings.set("locale", "en-US");
settings.isDefault("locale"); // false
settings.resetToDefaults();
settings.isDefault("locale"); // trueO padrão
O derivado:
- Cria um Record internamente
- Adiciona actions/getters de domínio
- Registra middleware se precisa de validação
- Expõe tudo via spread (
...record) + métodos adicionais
O derivado não sabe como transaction funciona, como snapshot serializa, como subscribe notifica. O Record cuida de tudo.
Configuração
trackDeltas
Habilita rastreamento de diffs a cada mutação. Default: false.
// Performance mode (default) — zero overhead
const fast = createRecord("fast", { x: 1 });
// Audit mode — rastreia diffs
const audited = createRecord("audited", { x: 1 }, { trackDeltas: true });| Modo | Overhead | Uso | | --------------------- | ---------------- | ---------------------------- | | Performance (default) | Zero | Games, FPS, simulações | | Audit | Diff por mutação | Undo/redo, histórico, replay |
Tipos Exportados
import type {
RecordPrimitive,
RecordConfig,
} from "@statedelta-apex/record-state";
import { createRecord } from "@statedelta-apex/record-state";| Export | Tipo | Descrição |
| -------------------- | ---------- | --------------------------------- |
| createRecord | function | Factory principal |
| RecordPrimitive<T> | interface | Tipo do retorno de createRecord |
| RecordConfig | type alias | Alias para PrimitiveConfig |
