place

@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.

Operates on a State, not an opaque store
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.

ts
// 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.

ts
// 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.

ts
// 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.

ts
// 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.

ts
// 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