Skip to content

Commit cd3fd2e

Browse files
krisnyeclaude
andauthored
feat(ecs): add reactive observe(arg) to index handles (#114)
* feat(ecs): add reactive observe(arg) to index handles Index handles gain observe(arg): Observe<readonly Entity[]>, the reactive counterpart to find. It emits the current sorted bucket on subscribe, then re-emits on the transaction-commit boundary whenever that bucket's membership or order changes — so a sort-key-only reorder (silently swallowed by pairing observe.select with find) is reported. Wired at the database layer via observeTransactions, mirroring observeSelectEntities; the index's per-mutation maintenance is unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(ecs): auto-route ordered select/observe.select through sorted index trySelectViaIndex previously bailed whenever an `order` clause was present, so a `select(where, order)` that a sorted index could serve verbatim always fell back to the archetype scan + re-sort. It now routes when the matched index is sorted with the default comparator on exactly the requested order columns (in sequence, all ascending) — returning the already-sorted bucket with no second sort. Descending, mismatched/partial order columns, and custom-comparator indexes still fall back to the scan. Implemented via a new internal `routableOrder` field on the handle (mirrors `routableColumns`). Covers db.observe.select for free since it routes through the same indexAwareSelect. Red/green tests left in the repo prove the routing and its boundaries. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(ecs): note observe reactivity perf + optimization options; bump to 0.9.61 Document observe(key) reactivity cost (wakeup gate, suppressed-emission recompute, dirty-bucket re-sort) and the two known sub-optimalities — component-coarse wakeup and lazy full re-sort — with their fixes and why they're deferred. Patch-bump the workspace to 0.9.61 in prep for publish. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 217fa5e commit cd3fd2e

7 files changed

Lines changed: 683 additions & 21 deletions

File tree

packages/data/src/ecs/README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ Indexes give O(1) lookup by some derived or column-valued key. Declare them on t
150150

151151
`find(key) → readonly Entity[]` returns every entity in the matching bucket (sorted if `order` is declared). `get(key) → Entity | null` is exposed only on unique indexes; `null` means "we know this key has no entity," never `undefined`. Array values (a `T[]` column, or a `compute` return that is `T[]`) auto-fan-out into one bucket entry per element.
152152

153+
`observe(key) → Observe<readonly Entity[]>` is the reactive form of `find`. It emits the current bucket synchronously on subscribe, then re-emits — on a microtask after a committed transaction — whenever that bucket's membership *or order* changes, and stays silent for transactions that touch only other buckets. Prefer it over pairing `db.observe.select(..., { where })` with `find`: a sort-key-only reorder changes the index's order but not the `where` result, so the `select` form silently swallows the reorder while `observe` reports it.
154+
153155
### Pattern catalogue
154156

155157
#### Raw indexes (identity reads from columns)
@@ -295,7 +297,9 @@ indexes: {
295297

296298
### Auto-routing of `select`
297299

298-
When `db.select(include, { where })` or `db.observe.select(...)` is called with a `where` clause that exactly matches the `key` of a declared raw index by equality, the query is served from the index instead of scanning archetypes. No code-site change required — declare the index and the planner picks it up. Other query shapes fall through to the archetype scan unchanged.
300+
When `db.select(include, { where })` or `db.observe.select(...)` is called with a `where` clause that exactly matches the `key` of a declared raw index by equality, the query is served from the index instead of scanning archetypes. No code-site change required — declare the index and the planner picks it up.
301+
302+
An `order` clause is routed too: when the query also asks for `order` and the matched index is sorted (default comparator) on exactly those columns, in sequence, all ascending, the already-sorted bucket is returned without a second sort. A descending clause, a mismatched/partial column sequence, or an index with a custom comparator falls through to the archetype scan unchanged — as does any non-equality `where`, partial-key match, or function/slot-map-keyed index.
299303

300304
### Maintenance and atomicity
301305

@@ -330,6 +334,22 @@ Reads pay one catch-up sort the first time they touch a dirty bucket:
330334

331335
`B` = total bucket count for the index. A `find` against a bucket that has not received any writes since the previous `find` is free (clean buckets aren't re-sorted).
332336

337+
#### `observe(key)` reactivity cost
338+
339+
`observe` re-emits on the transaction-commit boundary, coalescing every mutation in a tick into a single recompute per subscriber. With `b` = observed bucket size and `N` = live subscribers on the index:
340+
341+
| Event | Cost per subscriber | Notes |
342+
|---|---|---|
343+
| Committed transaction, wakeup gate | `O(min(C, R))` | `C` = changed components, `R` = index read columns. Runs for all `N` subscribers → `O(N·min(C,R))` total. |
344+
| Flush, observed bucket **unchanged** | `O(b)` | Recompute `find` (clean, cached sort) + sequence compare; emission suppressed. |
345+
| Flush, observed bucket **changed** | `O(b log b)` + `O(b)` emit | Dirty bucket pays one catch-up sort, then emits the `b`-element array. |
346+
347+
Emission is `O(b)` and optimal (the API hands back the full array). Two known sub-optimalities, both inherited from layers below `observe` rather than the reactive wiring itself:
348+
349+
1. **Coarse, component-level wakeup.** The gate fires on *any* change to a column the index reads, not on a change to *this* bucket. A mutation to a sibling bucket therefore costs each subscriber an `O(b)` recompute whose emission is then suppressed — `O(N·b)` of suppressed work per transaction in a wide fan-out (many buckets, many observers, one shared column). The optimum is `O(1)`: a per-bucket version stamp the observer compares against a cached value, or an observer registry keyed by bucket so only affected subscribers wake. This would require `createIndex` to track per-bucket versions / hold the observer registry (today `observe` is built one layer up, over `find` + transaction notifications, and stays out of the index internals). It is a contained change but deliberately deferred — it matches the same coarseness `db.observe.select` already has, so the two stay consistent.
350+
351+
2. **Lazy full re-sort.** A changed bucket re-sorts in `O(b log b)` (see the read table) rather than maintaining order incrementally at `O(log b)` per mutation. This is an index-wide design trade-off (full re-sort wins for batched writes that touch ≳ `b/log b` of a bucket); switching a bucket to a persistent ordered structure would help observe-heavy, sparsely-mutated buckets but is a broader change with its own trade-offs.
352+
333353
**Unique-conflict timing:** the conflict check runs *before* the column store mutates. The throw originates from the insert/update call; the rollback path restores any state the transaction touched. Verified by `database.index.test.ts` ("unique conflict on insert is caught up-front — no partial store or index mutation") and `database.index.performance.test.ts`.
334354

335355
## Transactions

0 commit comments

Comments
 (0)