@statedelta-apex/collection-state
v3.0.0
Published
ApexStore Collection primitive - typed ordered list state with item management
Maintainers
Readme
@statedelta-apex/collection-state
Collection primitive — lista ordenada de items tipados com identidade por ID único, 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. A Collection inverte isso: é um store completo e autossuficiente que já sabe como armazenar, mutar, fazer transaction/rollback, notificar listeners e serializar seu próprio estado.
Diferente de um array genérico, cada item possui um campo de identidade único. Todas as operações CRUD são baseadas nesse ID. A Collection não é um wrapper de array — é um storage especializado onde a identidade dos items é first-class citizen.
import { createCollection } from "@statedelta-apex/collection-state";
const users = createCollection<User>("users", [
{ id: "1", name: "Anderson", role: "admin" },
{ id: "2", name: "Rosa", role: "user" },
]);
users.add({ id: "3", name: "Carlos", role: "user" });
users.update("1", { role: "superadmin" });
users.remove("2");
users.getById("1"); // { id: '1', name: 'Anderson', role: 'superadmin' }
users.size; // 2Princí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.
- Identidade por ID — items são identificados por um campo configurável (default
"id"). Toda operação CRUD é ID-based.
Instalação
pnpm add @statedelta-apex/collection-stateRequer @statedelta-apex/store como dependência (instalada automaticamente).
Quick Start
import { createCollection } from "@statedelta-apex/collection-state";
// Criar
const inventory = createCollection<Item>("inventory", [
{ id: "sword", name: "Sword", damage: 10 },
{ id: "shield", name: "Shield", defense: 5 },
]);
// Mutar
inventory.add({ id: "potion", name: "Potion", heal: 20 });
inventory.update("sword", { damage: 15 });
inventory.remove("shield");
// Ler
inventory.getById("sword"); // { id: 'sword', name: 'Sword', damage: 15 }
inventory.has("shield"); // false
inventory.size; // 2
// Reagir
const unsub = inventory.subscribe((items, prevItems) => {
console.log(`Inventory: ${prevItems.length} → ${items.length} items`);
});
// Transacionar
inventory.transaction(() => {
inventory.add({ id: "bow", name: "Bow", damage: 8 });
inventory.remove("potion");
// throw aqui → rollback ambos
});
// Simular (what-if)
const wouldOverflow = inventory.simulate(() => {
inventory.add({ id: "armor", name: "Armor", defense: 20 });
return inventory.size > MAX_ITEMS;
});
// inventory intactoCriação
createCollection(id, initialItems, config?)
function createCollection<T extends Record<string, unknown>>(
id: string,
initialItems: T[],
config?: CollectionConfig,
): CollectionState<T>;| Parâmetro | Tipo | Descrição |
| -------------- | ------------------ | -------------------------------------- |
| id | string | Identificador único do primitivo |
| initialItems | T[] | Items iniciais (clonados internamente) |
| config | CollectionConfig | Configuração opcional |
// Default — idField é 'id'
const col = createCollection<User>("users", initialUsers);
// Custom idField
const cards = createCollection<Card>("cards", initialCards, {
idField: "cardId",
});
// Strict mode
const strict = createCollection<Item>("items", data, { strict: true });Também disponível via new:
import { CollectionState } from "@statedelta-apex/collection-state";
const col = new CollectionState<User>("users", initialUsers);Actions (mutações)
Toda action produz uma nova referência do array e dispara o pipeline de mutação (hook → middleware → state → listeners). Uma action = um clone = um notify.
add(item)
Insere um item no final. Valida unicidade por ID.
add(item: T): voidusers.add({ id: "3", name: "Carlos", role: "user" });
// users.add({ id: "1", ... }) // ← throw: already existsaddMany(items)
Batch insert — N items numa única mutação. Valida todos antes de aplicar (fail-fast no batch inteiro). Um clone, um notify.
addMany(items: T[]): voidusers.addMany([
{ id: "4", name: "Maria", role: "user" },
{ id: "5", name: "Pedro", role: "admin" },
]);upsert(item)
Add se não existe, shallow merge se existe. Operação single que evita o padrão has → add/update.
upsert(item: T): void// Não existe → adiciona
users.upsert({ id: "6", name: "Lucas", role: "user" });
// Já existe → merge
users.upsert({ id: "6", name: "Lucas Silva", role: "admin" });update(id, partial)
Busca por ID, faz shallow merge do partial. Protege o campo de identidade — não permite alterar o ID.
update(id: unknown, partial: Partial<T>): voidusers.update("1", { role: "superadmin" });
// users.update("1", { id: "999" }) // ← throw: Cannot change id fieldupdate(id, fn) — overload funcional
Functional update atômico. Recebe o item atual e retorna o novo.
update(id: unknown, fn: (item: T) => T): voidusers.update("1", (user) => ({ ...user, loginCount: user.loginCount + 1 }));updateBy(predicate, fn)
Update funcional em todos os items que satisfazem o predicado.
updateBy(predicate: (item: T) => boolean, fn: (item: T) => T): voidusers.updateBy(
(user) => user.role === "user",
(user) => ({ ...user, role: "admin" }),
);remove(id)
Remove item por ID. Noop se o ID não existe.
remove(id: unknown): voidusers.remove("3");removeBy(predicate)
Remove todos os items que satisfazem o predicado.
removeBy(predicate: (item: T) => boolean): voidusers.removeBy((user) => user.role === "admin");sort(comparator)
Reordena os items. Comparator genérico — o consumer decide a lógica.
sort(comparator: (a: T, b: T) => number): voidusers.sort((a, b) => a.name.localeCompare(b.name));clear()
Remove todos os items. Noop se já está vazio.
clear(): voidreplace(items)
Substitui todos os items. Input é clonado internamente.
replace(items: T[]): voidusers.replace([{ id: "1", name: "New", role: "admin" }]);reset()
Restaura os items iniciais (passados na criação). Cada reset produz um clone independente.
reset(): voidGetters (leitura)
Getters são leituras puras. Sem side effects, sem mutações.
getById(id)
Lookup por ID. Retorna undefined se não encontrado.
getById(id: unknown): T | undefinedusers.getById("1"); // { id: '1', name: 'Anderson', ... }
users.getById("999"); // undefinedhas(id)
Verifica existência por ID.
has(id: unknown): booleanat(index)
Acesso posicional. Suporta índices negativos (-1 = último).
at(index: number): T | undefinedusers.at(0); // primeiro item
users.at(-1); // último item
users.at(999); // undefinedindexOf(id)
Retorna o índice de um item por ID. -1 se não encontrado.
indexOf(id: unknown): numberfindBy(predicate)
Primeiro item que satisfaz o predicado.
findBy(predicate: (item: T) => boolean): T | undefinedconst admin = users.findBy((u) => u.role === "admin");filterBy(predicate)
Retorna novo array com items que satisfazem o predicado. Não muta estado.
filterBy(predicate: (item: T) => boolean): T[]const admins = users.filterBy((u) => u.role === "admin");every(predicate)
true se todos os items satisfazem o predicado.
every(predicate: (item: T) => boolean): booleansome(predicate)
true se pelo menos um item satisfaz o predicado.
some(predicate: (item: T) => boolean): booleanmap(fn)
Projeta todos os items. Retorna novo array sem mutar estado.
map<R>(fn: (item: T) => R): R[]const names = users.map((u) => u.name); // ['Anderson', 'Rosa']Properties
size
Número de items. Getter.
readonly size: numberisEmpty
true se zero items. Getter.
readonly isEmpty: booleanfirst
Primeiro item ou undefined. Getter.
readonly first: T | undefinedlast
Último item ou undefined. Getter.
readonly last: T | undefineditems
Referência ao array de items (alias pra getState()). Imutável por convenção.
readonly items: T[]idField
Campo de identidade configurado. Imutável.
readonly idField: stringEstado
getState()
Retorna o array de items completo. A referência é imutável por convenção — não modifique diretamente.
getState(): T[]const items = users.getState(); // [{ id: '1', ... }, { id: '2', ... }]
// Cada mutação gera uma nova referência
users.add({ id: "3", name: "Carlos", role: "user" });
const newItems = users.getState();
items !== newItems; // true — referências diferentes
items.length; // 2 — original intactoid / type
readonly id: string // ID passado na criação
readonly type: string // 'collection'Transactions
A Collection suporta transações aninhadas via stack de snapshots. Cada beginTransaction() empilha o estado atual. rollback() restaura, commitTransaction() descarta o snapshot.
API básica
const col = createCollection<Item>("items", initialItems);
col.beginTransaction();
col.add({ id: "x", name: "X", value: 1 });
col.get("x"); // encontrado
col.rollback();
col.has("x"); // false — restauradotransaction(fn)
Commit automático no sucesso, rollback automático no throw.
transaction<R>(fn: () => R): R// Sucesso → commit
const result = col.transaction(() => {
col.add({ id: "x", name: "X", value: 1 });
col.add({ id: "y", name: "Y", value: 2 });
return col.size;
});
// result === initialSize + 2
// Falha → rollback
try {
col.transaction(() => {
col.add({ id: "z", name: "Z", value: 3 });
throw new Error("abort");
});
} catch {}
col.has("z"); // false — rollback restaurousimulate(fn)
Sempre rollback. Para cenários "what if?" sem mutar estado.
simulate<R>(fn: () => R): Rconst wouldOverflow = inventory.simulate(() => {
inventory.add({ id: "heavy", name: "Heavy", weight: 999 });
return inventory.size > MAX_ITEMS;
});
// wouldOverflow calculado, inventory intactoNesting
Transações suportam nesting arbitrário.
col.transaction(() => {
col.add({ id: "x", name: "X", value: 1 });
const safe = col.simulate(() => {
col.add({ id: "y", name: "Y", value: -1 });
return col.every((i) => i.value >= 0);
});
// col.has('y') === false
if (!safe) throw new Error("invalid state");
});inTransaction / transactionDepth
readonly inTransaction: boolean // true se algum nível está ativo
readonly transactionDepth: number // 0 = sem transactionSubscribe (reatividade)
Registra um listener chamado a cada mutação. Recebe (items, prevItems).
subscribe(listener: (items: T[], prevItems: T[]) => void): () => voidRetorna uma função de unsubscribe.
const unsub = inventory.subscribe((items, prev) => {
console.log(`Inventory: ${prev.length} → ${items.length} items`);
});
inventory.add({ id: "potion", name: "Potion", heal: 20 });
// log: "Inventory: 2 → 3 items"
unsub();
inventory.add({ id: "arrow", name: "Arrow", damage: 1 });
// sem logZero overhead sem subscribers
Quando nenhum listener está registrado, o pipeline de mutação usa a strategy sem listeners. Sem iteração, sem checagem.
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 remove = inventory.use((current, next, proceed) => {
console.log(`${current.length} → ${next.length} items`);
proceed();
});Validação (bloquear mutação)
Não chamar proceed() bloqueia a mutação. O estado permanece inalterado e listeners não são notificados.
inventory.use((current, next, proceed) => {
if (next.length > MAX_ITEMS) return; // bloqueia
proceed();
});
inventory.add({ id: "overflow", name: "Overflow", value: 0 });
// bloqueado se excede MAX_ITEMSNormalização — proceed(override)
proceed(override) substitui o valor antes de aplicar. Para normalização, ordenação automática, limitação de tamanho.
// Normalizar nomes para uppercase
inventory.use((_current, next, proceed) => {
const normalized = next.map((item) => ({
...item,
name: item.name.toUpperCase(),
}));
proceed(normalized);
});
inventory.add({ id: "sword", name: "sword", value: 10 });
inventory.getById("sword")!.name; // "SWORD"
// Limitar tamanho da collection
inventory.use((_current, next, proceed) => {
if (next.length > MAX_ITEMS) {
proceed(next.slice(0, MAX_ITEMS)); // trunca
} else {
proceed();
}
});
// Auto-sort por valor após cada mutação
inventory.use((_current, next, proceed) => {
proceed([...next].sort((a, b) => b.value - a.value));
});O override flui pela chain — cada middleware recebe o valor já transformado pelo anterior.
Snapshot / Restore
Serialização e restauração de estado para persistência.
snapshot()
snapshot(): PrimitiveSnapshot
// { type: 'collection', id: string, data: T[] }const snap = inventory.snapshot();
// { type: 'collection', id: 'inventory', data: [...items] }
localStorage.setItem("inventory", JSON.stringify(snap));restore(snapshot)
restore(snapshot: PrimitiveSnapshot): voidconst saved = JSON.parse(localStorage.getItem("inventory")!);
inventory.restore(saved);Store Integration
A Collection funciona standalone por padrão. Quando registrada em um Store, o Store seta hooks internos para coordenar transações entre múltiplos primitivos.
// Standalone — sem Store
const inventory = createCollection<Item>("inventory", items);
inventory.add({ id: "sword", name: "Sword", damage: 10 });
// Com Store — coordenação entre primitivos
store.register(inventory);
store.transaction(() => {
inventory.add({ id: "sword", name: "Sword", damage: 10 });
hp.set("value", 50);
// throw → rollback AMBOS
});Extensão via Composição
A Collection é um primitivo — cuida de storage, transaction, snapshot, reactivity. Tipos derivados compõem com a Collection (via extends), adicionando actions/getters de domínio sem se preocupar com mecânica interna.
Exemplo: Deck
Um Deck é uma Collection de cartas com ações de domínio (shuffle, draw, peek).
import { CollectionState } from "@statedelta-apex/collection-state";
interface Card {
id: string;
suit: string;
rank: string;
}
class Deck extends CollectionState<Card> {
shuffle(): void {
const shuffled = [...this.getState()];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!];
}
this.replace(shuffled);
}
draw(count: number = 1): Card[] {
const items = this.getState();
const drawn = items.slice(0, count);
this.replace(items.slice(count));
return drawn;
}
peek(count: number = 1): Card[] {
return this.getState().slice(0, count);
}
}
function createDeck(id: string, cards: Card[]): Deck {
return new Deck(id, cards);
}const deck = createDeck("main", allCards);
deck.shuffle();
const hand = deck.draw(5);
deck.size; // allCards.length - 5
// Transaction, subscribe, middleware — tudo funciona
deck.transaction(() => {
const drawn = deck.draw(3);
if (drawn.some((c) => c.rank === "Joker")) {
throw new Error("no jokers allowed");
}
});
deck.subscribe((cards, prev) => {
console.log(`Deck: ${prev.length} → ${cards.length} cards`);
});O Deck não sabe como transaction funciona, como snapshot serializa, como subscribe notifica. A Collection cuida de tudo.
Configuração
interface CollectionConfig {
/** Campo de identidade dos items. Default: 'id' */
idField?: string;
/** Validação de serializabilidade a cada mutação. Default: false */
strict?: boolean;
/** Rastreamento de diffs (audit mode). Default: false */
trackDeltas?: boolean;
}| Config | Default | Descrição |
| ------------- | ------- | --------------------------------------------- |
| idField | "id" | Campo usado como identidade única dos items |
| strict | false | Validação de serializabilidade a cada mutação |
| trackDeltas | false | Rastreamento de diffs (audit mode) |
Tipos Exportados
import {
createCollection,
CollectionState,
} from "@statedelta-apex/collection-state";
import type { CollectionConfig } from "@statedelta-apex/collection-state";| Export | Tipo | Descrição |
| -------------------- | --------- | ----------------------------------------------------- |
| createCollection | function | Factory principal |
| CollectionState<T> | class | Classe do primitivo (usável com new ou via factory) |
| CollectionConfig | interface | Configuração (extends PrimitiveConfig) |
