@place-ts/data
Data primitives over @place-ts/reactivity. v0.1 ships exactly one helper — collection<T>() — the keyed-CRUD shape that every entity store hand-rolls. The position is deliberate: most app-level "data" problems collapse to a typed array in a State<T[]> plus the existing capability, persistence, and cache primitives. A typed-query layer lands only if a real workload demands one the reactivity primitives can't satisfy.
collection() takes a State<T[]> and returns CRUD helpers over it. The state stays exposed — compose it with persistedState, history(), crossTabAdapter unchanged. Domain logic (id generation, timestamps, validation) lives in the consumer, not the primitive.collection<T>(state, options?)
Builds a keyed-CRUD interface over a State<T[]>. all() and get(key) are reactive reads; add / update / remove are writes. add throws on a duplicate key; update throws if the patch would change the key (use remove + add for a rename) — loud failures over silent corruption.
// collection<T>(state, options?) — keyed CRUD over a State<T[]>.
// The collection operates on a reactive array; the State stays
// exposed so you can compose it with persistedState, history, etc.
import { state } from '@place-ts/reactivity'
import { collection } from '@place-ts/data'
interface Note { id: string; title: string; tags: string[] }
const notes = state<Note[]>([])
const c = collection<Note>(notes, {
// sort comparator for all(); omit for insertion order.
sortBy: (a, b) => a.title.localeCompare(b.title),
})
c.add({ id: 'a', title: 'first', tags: [] })
c.get('a') // → { id: 'a', … } | null (reactive)
c.update('a', { title: 'edit' }) // merge patch into item 'a'
c.remove('a')
c.all() // → readonly Note[], sorted (reactive)
Custom & composite keys
The key extractor defaults to (item) => item.id. Pass id for a differently-named property or a composite key.
// The key defaults to (item) => item.id. Pass `id` for a
// differently-named or composite key.
const users = collection<User>(state<User[]>([]), {
id: (u) => u.uuid,
})
const items = collection<Item>(state<Item[]>([]), {
id: (it) => `${it.org}:${it.slug}`, // composite key
})
Composing with persistence
Because the collection wraps a plain State<T[]>, wrapping that state with persistedState makes every mutation durable — no special integration.
// The underlying State<T[]> stays exposed — wrap it with
// persistedState so the whole collection survives reloads.
import { state } from '@place-ts/reactivity'
import { collection } from '@place-ts/data'
import { persistedState, localStorageAdapter } from '@place-ts/persistence'
const { state: noteState } = persistedState(
localStorageAdapter<Note[]>('notes', []),
)
const notes = collection<Note>(noteState)
// Every add / update / remove now persists automatically — the
// collection mutates the state, persistedState's watch saves it.
Soft delete — trash / restore (0.2.0)
trash(key) marks an item as trashed without removing it from the underlying array; restore(key) un-marks it. The reactive trash set lives in its own state cell, so all() / get() / cursor() re-evaluate when an item flips in or out of trash. Default reads filter trashed items out; pass { includeTrash: true } for "trash bin" UI.
// Soft delete (0.2.0). trash(key) marks an item as trashed without
// removing it from the underlying array; restore(key) un-marks. The
// reactive trash set is its own state cell, so all() / get() / cursor()
// re-evaluate when an item goes in or out of trash.
c.trash('a') // mark 'a' as trashed (no-op if absent / already trashed)
c.restore('a') // un-mark
c.trashedKeys() // → readonly string[] — reactive list of trashed keys
// Default reads filter out trashed items:
c.all() // → all non-trashed
c.get('a') // → null if 'a' is trashed
c.cursor() // → only non-trashed items
// Opt-in: include trash in a one-off read (useful for "trash bin" UI).
c.all({ includeTrash: true }) // → everything, including trashed
c.get('a', { includeTrash: true }) // → item even if trashed
Cursor-based pagination — cursor() (0.2.0)
Returns { items, next } where next is the key to pass back for the following page (or null if the current page is the last). Stable under inserts: items added after the current page boundary appear on later cursor() calls; items added before don't shift your existing pages. Reactive — re-evaluates when the underlying state changes. Stale after keys (item deleted since the previous call) gracefully fall back to position-based equivalence.
// Cursor-based pagination (0.2.0). Returns { items, next } where
// 'next' is the key to pass back for the next page (or null if the
// current page is the last). Stable under inserts: if items are added
// AFTER your current page boundary, they appear on later cursor()
// calls; if added BEFORE, they don't shift your existing pages.
//
// Cursor pagination is the right shape over offset-based for reactive
// collections because the item set can change between page requests.
// Stale 'after' keys (item deleted since the previous call) are
// gracefully handled — the framework falls back to the position-based
// equivalent so you don't get a 500 on a stale handle.
const page1 = c.cursor({ limit: 20 })
// page1.items: readonly T[] — at most 20 items
// page1.next: string | null — pass to load the next page
const page2 = c.cursor({ after: page1.next ?? undefined, limit: 20 })
const page3 = c.cursor({ after: page2.next ?? undefined, limit: 20 })
// Reactive: cursor() re-evaluates when the underlying state changes,
// so deletions, additions, and trash flips all flow through.
// includeTrash: defaults to false (matches all() / get()).
c.cursor({ limit: 50, includeTrash: true })
See also
- state · watch · derived
- @place-ts/persistence
- @place-ts/search — reactive search over a collection