arrowbase 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,897 @@
1
+ # ArrowBase
2
+
3
+ [![CI](https://github.com/natanelia/arrowbase/actions/workflows/ci.yml/badge.svg)](https://github.com/natanelia/arrowbase/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/arrowbase.svg)](https://www.npmjs.com/package/arrowbase)
5
+ [![license: MIT](https://img.shields.io/badge/license-MIT-171a18.svg)](./LICENSE)
6
+ [![playground](https://img.shields.io/badge/playground-open-df542d.svg)](https://natanelia.github.io/arrowbase/)
7
+
8
+ > ArrowBase — columnar Arrow store with @tanstack/db-compatible API.
9
+
10
+ ArrowBase is a local-first TypeScript database built on Arrow-style column
11
+ buffers. It keeps the TanStack DB mental model—collections, transactions,
12
+ live queries, persistence, and React hooks—while adding vectorized filters,
13
+ spatial indexes, Arrow IPC, snapshots, and cross-tab sync.
14
+
15
+ **v0.1.1:** the compatibility catalog is 294/294 green
16
+ against `@tanstack/db@0.6.16`. Exact public-export and operator-manifest tests
17
+ guard that surface. Pre-1.0 means the API can still evolve between minor
18
+ versions; documented tolerances cover fixed capacity, safe-integer key
19
+ normalization, and floating-point aggregates.
20
+
21
+ [Open the live playground](https://natanelia.github.io/arrowbase/) ·
22
+ [Read the migration guide](./MIGRATING.md) ·
23
+ [Inspect compatibility](./COMPATIBILITY.md)
24
+
25
+ **Project docs**:
26
+
27
+ - [COMPATIBILITY.md](./COMPATIBILITY.md) — all 294 audited upstream exports.
28
+ - [MIGRATING.md](./MIGRATING.md) — side-by-side TanStack DB replacements.
29
+ - [docs/showcase.md](./docs/showcase.md) — playground and benchmark contract.
30
+ - [docs/ivm.md](./docs/ivm.md) — incremental query engine boundaries.
31
+ - [SECURITY.md](./SECURITY.md) and [CONTRIBUTING.md](./CONTRIBUTING.md).
32
+ - [RELEASE_NOTES.md](./RELEASE_NOTES.md) and [CHANGELOG.md](./CHANGELOG.md).
33
+
34
+ ## Requirements
35
+
36
+ - Node.js 20 or newer.
37
+ - Modern browsers. ArrowBase uses `SharedArrayBuffer` when cross-origin
38
+ isolation is available and safely falls back to `ArrayBuffer` otherwise.
39
+
40
+ ## Install
41
+
42
+ ```sh
43
+ npm install arrowbase
44
+ ```
45
+
46
+ Optional integrations are installed only when you use their subpaths:
47
+
48
+ ```sh
49
+ npm install @tanstack/db # arrowbase/tanstack
50
+ npm install apache-arrow # arrowbase/arrow
51
+ npm install react # arrowbase/react
52
+ ```
53
+
54
+ ## Playground
55
+
56
+ The hosted [ArrowBase City Ops Console](https://natanelia.github.io/arrowbase/)
57
+ is an interactive ArrowBase vs TanStack DB playground. It runs deterministic
58
+ spatial, reactive, transactional, and IPC workloads in browser workers. A
59
+ correctness hash must match before timing or memory ratios unlock.
60
+
61
+ See [docs/showcase.md](./docs/showcase.md) for the benchmark contract, URL
62
+ controls, operation registry, and local or tunnel workflows.
63
+
64
+ ## Development
65
+
66
+ ```sh
67
+ pnpm install --frozen-lockfile
68
+ pnpm check # typecheck + 700+ behavioral tests
69
+ pnpm playground:check # UI typecheck + tests + production build
70
+ pnpm playground:e2e # desktop and mobile Playwright smoke
71
+ pnpm bench:vs-tanstack # head-to-head benchmark
72
+ pnpm build
73
+ pnpm verify:package-exports
74
+ ```
75
+
76
+ Use these focused playground commands during UI work:
77
+
78
+ ```sh
79
+ pnpm playground:dev
80
+ pnpm playground:typecheck
81
+ pnpm playground:test
82
+ pnpm playground:build
83
+ pnpm playground:e2e
84
+ ```
85
+
86
+ Performance ratios are evidence, not promises: the showcase compares equal
87
+ result hashes before presenting speed or memory measurements.
88
+
89
+ ## What's in the box
90
+
91
+ ### Phase 1 — Core primitives
92
+
93
+ Fixed-width columns (`int8..int64`, `uint8..uint64`, `float32/64`, `bool`)
94
+ backed by a 64-byte-aligned SAB bump allocator. Dense `uint32` primary key,
95
+ soft-delete, per-column + global version counters, bounded undo ring with
96
+ mark/rollback.
97
+
98
+ ```ts
99
+ import { Collection, defineSchema } from 'arrowbase';
100
+
101
+ const schema = defineSchema({
102
+ name: 'features',
103
+ columns: {
104
+ __id: { type: 'uint32' },
105
+ __deleted: { type: 'bool' },
106
+ speed: { type: 'int32', nullable: true },
107
+ weight: { type: 'float64' },
108
+ },
109
+ });
110
+
111
+ const features = Collection.create(schema, { capacity: 10_000 });
112
+ features.insert({ __id: 1, __deleted: false, speed: 50, weight: 1.5 });
113
+ features.update(1, { speed: 60 });
114
+ features.delete(1); // soft; becomes hard in hard-delete mode
115
+
116
+ const mark = features.beginTransaction();
117
+ features.update(1, { speed: 999 });
118
+ features.rollback(mark);
119
+ ```
120
+
121
+ ### Phase 2 — Reactive queries
122
+
123
+ Immutable query builder (`where`/`select`/`orderBy`/`limit`/`offset`/`deps`).
124
+ `QueryExecutor` caches snapshots keyed on observed-column versions. A
125
+ passive change listener invalidates the cache on insert/delete/compact/
126
+ rollback; updates that touch only unobserved columns keep the cached
127
+ snapshot identity.
128
+
129
+ ```ts
130
+ import { query, run } from 'arrowbase';
131
+
132
+ const q = query(features)
133
+ .where((r) => r.speed !== null && (r.speed as number) > 50)
134
+ .orderBy('weight', 'desc')
135
+ .select(['__id', 'speed'])
136
+ .limit(20);
137
+
138
+ const exec = run(q);
139
+ const unsub = exec.subscribe((snap) => console.log(snap.rows));
140
+ // unsub() when done; exec.dispose() to detach the passive listener.
141
+ ```
142
+
143
+ Predicate deps are inferred via a tracking `Proxy`. Override with
144
+ `.deps(['surface', 'speed'])` when inference is incomplete.
145
+
146
+ ### Phase 3 — Variable-width + dict columns
147
+
148
+ `utf8`, `binary`, and dictionary-encoded `dict_utf8`. Utf8/binary use a
149
+ ranged-offsets layout (`Int32Array(capacity * 2)` pairs + append-only
150
+ `ValuesHeap`) so updates are O(1) at the cost of leaked bytes until
151
+ `compact()` runs.
152
+
153
+ ```ts
154
+ const schema = defineSchema({
155
+ name: 'road',
156
+ columns: {
157
+ __id: { type: 'uint32' },
158
+ name: { type: 'utf8', nullable: true, valuesHeapBytes: 512 * 1024 },
159
+ surface: { type: 'dict_utf8' },
160
+ blob: { type: 'binary' },
161
+ },
162
+ });
163
+ ```
164
+
165
+ ### Deferred cleanup — compaction
166
+
167
+ `Collection.compact()` reclaims leaked utf8/binary heap bytes and hard-
168
+ deleted row slots, rebuilds the row index, clears the undo ring, and fires
169
+ a `compact` `ChangeEvent` so reactive queries recompute.
170
+
171
+ ```ts
172
+ const { reclaimedRows, reclaimedHeapBytes } = features.compact();
173
+ ```
174
+
175
+ ### Phase 4 — TanStack DB adapter
176
+
177
+ `arrowbase/tanstack` exposes an ArrowBase collection as a first-class
178
+ TanStack DB collection. Use `@tanstack/db`'s native live-query builder to
179
+ join ArrowBase with native TanStack collections.
180
+
181
+ This adapter prioritizes drop-in native interoperability. TanStack's public
182
+ collection boundary exchanges JavaScript row objects, so this route does not
183
+ preserve zero-copy source reads or push ArrowBase spatial indexes into
184
+ TanStack's native query compiler. Use ArrowBase's root
185
+ `createLiveQueryCollection` API when columnar differential execution and
186
+ spatial pushdown matter; it keeps the same builder shape and runs structural
187
+ queries through `@tanstack/db-ivm`. See [docs/ivm.md](./docs/ivm.md) for the
188
+ exact supported/fallback boundary.
189
+
190
+ ```ts
191
+ import { createCollection, createLiveQueryCollection, eq } from '@tanstack/db';
192
+ import { toTanstackCollection } from 'arrowbase/tanstack';
193
+
194
+ const abFeatures = Collection.create(/* ... */);
195
+ const features = createCollection(toTanstackCollection(abFeatures));
196
+
197
+ const tags = createCollection({ /* native TS collection with tags */ });
198
+
199
+ const joined = createLiveQueryCollection({
200
+ query: (q) =>
201
+ q
202
+ .from({ t: tags })
203
+ .join({ f: features }, ({ t, f }) => eq(t.featureId, f.__id))
204
+ .select(({ t, f }) => ({ label: t.label, name: f.name })),
205
+ });
206
+ ```
207
+
208
+ `onInsert`/`onUpdate`/`onDelete` forward TanStack mutations into ArrowBase;
209
+ the sync listener mirrors ArrowBase changes back into TanStack. `compact`
210
+ and `rollback` trigger a `truncate()` + replay for full consistency.
211
+
212
+ Options:
213
+ - `id?: string` — TanStack collection id. Default `'arrowbase:<schemaName>'`.
214
+ - `includeSoftDeleted?: boolean` — mirror tombstones as regular rows.
215
+
216
+ ### Phase 5 — Snapshot + sync
217
+
218
+ Custom binary snapshot (`ARBS` magic, format v1) preserves schema, data,
219
+ dictionaries, and `globalVersion` across round-trips. Sync adapter
220
+ interface + in-process `MemorySyncAdapter` for tests; `ChangeLog`
221
+ records outgoing deltas; `applyDeltas` handles inbound with upsert
222
+ promotion.
223
+
224
+ ```ts
225
+ import {
226
+ exportSnapshot,
227
+ importSnapshot,
228
+ ChangeLog,
229
+ MemorySyncAdapter,
230
+ SharedDeltaLog,
231
+ applyDeltas,
232
+ } from 'arrowbase';
233
+
234
+ // Persistence
235
+ const buf = exportSnapshot(features, { tag: 'nightly' });
236
+ const restored = importSnapshot(buf, (schema, capacity) =>
237
+ Collection.create(schema, { capacity }),
238
+ );
239
+
240
+ // Sync
241
+ const log = new SharedDeltaLog();
242
+ const outbound = new ChangeLog(features);
243
+ const adapter = new MemorySyncAdapter(features, log, outbound);
244
+
245
+ const { deltas, upTo } = await adapter.pull(0);
246
+ await adapter.apply(deltas);
247
+ ```
248
+
249
+ ### Phase 6 — Devtools + subset push-down
250
+
251
+ `describeCollection(abColl)` returns a framework-free JSON snapshot of
252
+ schema + per-column metadata (version, nullCount, heap usage, dict sample).
253
+ `pushDown(expr, knownColumns)` translates TanStack DB `BasicExpression`
254
+ ASTs (`eq`, `and`, `or`, `not`, boolean ref) into an ArrowBase
255
+ `Predicate`; unsupported expressions return `null` so the caller falls
256
+ back gracefully.
257
+
258
+ ```ts
259
+ import { describeCollection, pushDown } from 'arrowbase';
260
+
261
+ console.log(describeCollection(features));
262
+ // → { schemaName, primaryKey, rowCount, liveRowCount, globalVersion,
263
+ // hasSoftDelete, schemaFingerprint, columns: [...] }
264
+ ```
265
+
266
+ ### Phase 7 — Vectorized filter + sort kernels
267
+
268
+ `.filter(expr)` takes a column-op DSL expression that compiles to a
269
+ row-test closure reading directly from typed-array column buffers. No
270
+ per-row proxy, no row-object allocation during filter, numeric sort via
271
+ a `Float64Array` shadow-key buffer, materialization only of the
272
+ `[offset, offset+limit)` window. The fn-predicate path via `.where(fn)`
273
+ still works and can be combined with `.filter()` — the kernel runs
274
+ first, the fn runs only on survivors.
275
+
276
+ ```ts
277
+ import { query, run, f, fEq, fGt, fAnd } from 'arrowbase';
278
+
279
+ const q = query(features)
280
+ .filter(fAnd(fEq(f('surface'), 'asphalt'), fGt(f('speed'), 50)))
281
+ .orderBy('weight', 'desc')
282
+ .select(['__id', 'speed', 'weight'])
283
+ .limit(20);
284
+
285
+ const snap = run(q).snapshot();
286
+ ```
287
+
288
+ Operators: `fEq`, `fNeq`, `fGt`, `fGte`, `fLt`, `fLte`, `fAnd`, `fOr`,
289
+ `fNot`, `fIsNull`, `fIsNotNull`. Supported types: all fixed-width
290
+ numerics, `bool`, `int64`/`uint64` (with number→bigint literal
291
+ promotion), `utf8` (eq/neq), `dict_utf8` (eq/neq via O(1) dictionary
292
+ code lookup).
293
+
294
+ **String predicates.** `fContains`, `fStartsWith`, `fEndsWith`, `fLike`,
295
+ `fMatches` work on `utf8` and `dict_utf8` columns. On dict-encoded
296
+ columns the kernel walks the dictionary once up-front to build a
297
+ `Set<code>` of matching entries, then each row is a single `Set.has()`
298
+ — ~4× faster than per-row utf8 substring search on 10k rows.
299
+
300
+ ```ts
301
+ import {
302
+ query, run, f,
303
+ fContains, fStartsWith, fEndsWith, fLike, fMatches, fAnd,
304
+ } from 'arrowbase';
305
+
306
+ // Case-insensitive substring (ASCII-fold).
307
+ query(c).filter(fContains(f('tag'), 'asp', { caseInsensitive: true }));
308
+
309
+ // SQL LIKE. `%` = any sequence, `_` = any single char, `\` escapes.
310
+ query(c).filter(fLike(f('path'), '/users/%/active'));
311
+
312
+ // Raw regex (pattern string or RegExp).
313
+ query(c).filter(fMatches(f('url'), /^https?:/));
314
+
315
+ // Composable, negatable.
316
+ query(c).filter(fAnd(
317
+ fStartsWith(f('tag'), 'A'),
318
+ fContains(f('text'), 'road', { caseInsensitive: true, negated: true }),
319
+ ));
320
+ ```
321
+
322
+ The fn-compiler auto-lowers the three common JS method forms into the
323
+ equivalent DSL node:
324
+
325
+ ```ts
326
+ // Both run through the vectorized kernel:
327
+ query(c).where(r => r.tag.includes('asphalt')); // → fContains
328
+ query(c).where(r => r.tag.startsWith('A')); // → fStartsWith
329
+ query(c).where(r => r.tag.endsWith('vel')); // → fEndsWith
330
+ ```
331
+
332
+ ### Phase 8 — List + Struct column types
333
+
334
+ Two nested column shapes landed, rounding out the type system:
335
+
336
+ - `list<T>` — each cell is an array of T. Arrow-style offsets + child
337
+ buffer; O(1) updates with leak-until-compact semantics. v1 item types:
338
+ every leaf (all primitives, `bool`, `utf8`, `dict_utf8`, `binary`).
339
+ - `struct<{f1, f2, ...}>` — each cell is a record of fixed named fields.
340
+ Parent validity bitmap in SAB + one regular child column per field.
341
+ v1 field types: leaves only.
342
+
343
+ Nested list-in-list, struct-in-struct, list-in-struct, struct-in-list
344
+ are rejected at schema compile time. Snapshot format bumped to v2 so
345
+ list offsets + child buffers and per-field struct buffers round-trip.
346
+
347
+ ```ts
348
+ const schema = defineSchema({
349
+ name: 'features',
350
+ columns: {
351
+ __id: { type: 'uint32' },
352
+ tags: { type: 'list', items: { type: 'utf8' } },
353
+ geo: {
354
+ type: 'struct',
355
+ fields: {
356
+ lat: { type: 'float64' },
357
+ lng: { type: 'float64' },
358
+ label: { type: 'utf8', nullable: true },
359
+ },
360
+ },
361
+ attrs: {
362
+ type: 'list',
363
+ items: { type: 'int32', nullable: true }, // list items may be null
364
+ },
365
+ },
366
+ });
367
+ const c = Collection.create(schema, { capacity: 1_000 });
368
+ c.insert({
369
+ __id: 1,
370
+ tags: ['road', 'highway'],
371
+ geo: { lat: 51.5, lng: -0.12, label: 'London' },
372
+ attrs: [10, null, 30],
373
+ });
374
+ // update replaces the full list/struct cell
375
+ c.update(1, { tags: ['updated'] });
376
+ // compact reclaims leaked list child bytes + soft-delete slots
377
+ const { reclaimedHeapBytes } = c.compact();
378
+ ```
379
+
380
+ List child storage lives in JS heap (outside the SAB segment) because
381
+ its size is unbounded at schema-compile time. This is a deliberate v1
382
+ tradeoff — zero-copy cross-worker sharing for list columns is deferred.
383
+ Struct field buffers and parent validity stay in the SAB segment.
384
+
385
+ ### Auto-lowering `.where(fn)` to the vectorized kernel
386
+
387
+ `.where(fn)` attempts to compile the predicate closure into a
388
+ `FilterExpr` tree via a tiny recursive-descent parser over
389
+ `Function.prototype.toString()`. When it succeeds, the fn is discarded
390
+ and the vectorized kernel runs — same fast path as `.filter(expr)`,
391
+ no caller-side rewrite needed:
392
+
393
+ ```ts
394
+ // Both forms take the vectorized path; second one compiles the closure
395
+ // into the equivalent FilterExpr at query-build time.
396
+ query(c).filter(fAnd(fEq(f('active'), true), fGt(f('age'), 18)));
397
+ query(c).where((r) => r.active === true && r.age > 18);
398
+ ```
399
+
400
+ Supported shapes: `===`/`!==`/`==`/`!=`/`>`/`>=`/`<`/`<=`/`&&`/`||`/`!`,
401
+ param property or bracket access (`r.col` or `r['col']`), string/number/
402
+ bool/null literals, `row.col == null` → `isNull`. Anything else
403
+ (captures, method calls, arithmetic, nested field access, ternary, etc.)
404
+ falls back silently to the legacy row-scan path:
405
+
406
+ ```ts
407
+ // Falls back — outer variable is a capture the compiler can't inline.
408
+ const min = 18;
409
+ query(c).where((r) => r.age > min);
410
+ ```
411
+
412
+ The end-to-end speed-up on a 10k row filter+sort+paginate is ~5× (0.5 ms
413
+ vs 2.5 ms); reactive notifications drop from ~300 µs to ~60 µs. See
414
+ [Benchmarks](#benchmarks).
415
+
416
+ ### Hash joins
417
+
418
+ Inner, left, right, and full outer joins between two collections. The
419
+ right side is hashed; the left side (post-filter) probes into the
420
+ hash. Nested row shape — the right row is attached at `row[as]` — so
421
+ column-name collisions never matter.
422
+
423
+ ```ts
424
+ import { query, join, runJoin, f, fEq } from 'arrowbase';
425
+
426
+ const plan = join(
427
+ query(orders).filter(fEq(f('status'), 'paid')),
428
+ users,
429
+ { on: { left: 'userId', right: '__id' }, as: 'user', type: 'left' },
430
+ )
431
+ .where((r) => r.user === null || r.user.region === 'us')
432
+ .orderBy('user.name')
433
+ .select(['__id', 'amount', 'user.name']);
434
+
435
+ const exec = runJoin(plan);
436
+ exec.subscribe((snap) => render(snap.rows));
437
+ // Re-runs on mutations to EITHER collection.
438
+ ```
439
+
440
+ - **Join types** — `'inner'` (default), `'left'`, `'right'`, `'full'`.
441
+ - `left` / `full` — unmatched left rows emit `{...leftRow, [as]: null}`.
442
+ Detect via `row[as] === null`.
443
+ - `right` / `full` — unmatched right rows emit `{[as]: rightRow}`
444
+ with left columns absent (not null — `undefined`), matching SQL
445
+ projection semantics.
446
+ - **Key normalization.** Safe-range BigInts are unified with Numbers
447
+ during hashing, so an `int64` foreign-key column on one side joins
448
+ cleanly against a `uint32` PK on the other.
449
+ - **Null keys never join** (SQL semantics). For `left` / `full`, a
450
+ left row with a null key is still emitted as unmatched.
451
+ - **Left-side filters push down.** Any `.filter()` / `.where()` on
452
+ the left query runs through the vectorized kernel before the probe
453
+ — the hash only sees survivors.
454
+ - **Chainable builder.** `query(orders).join(users, …)` is equivalent
455
+ to the standalone `join(…)` function; both return a `JoinQuery`
456
+ supporting the full post-join surface (`where` / `select` /
457
+ `orderBy` / `limit` / `offset`).
458
+ - **Paths.** Selection and ordering accept plain column names (left
459
+ side) or `'<as>.<col>'` dotted paths (right side). Dotted access
460
+ into a null namespace yields `undefined`, so nulls sort last by
461
+ default.
462
+ - **Reactivity.** The executor subscribes to both collections;
463
+ mutations on either trigger a recompute. Cache keyed by
464
+ `(leftVersion, rightVersion)` — calling `snapshot()` twice with no
465
+ mutations returns the same object.
466
+
467
+ **Limitations.** Single-column equi-key per side (no composite keys),
468
+ full recompute on mutation (no incremental update). The kernel is
469
+ scalar JS, not SIMD — 1k × 1k runs in ~0.8 ms on the reference
470
+ machine.
471
+
472
+ ### GeoArrowBase — geo columns, predicates, and spatial indexes
473
+
474
+ ArrowBase has dedicated geometry columns for local-first map UIs:
475
+ `point`, `linestring`, and `polygon`. Coordinates are `[lng, lat]` in
476
+ EPSG:4326. Bbox predicates use planar lon/lat bboxes; point `dwithin`
477
+ uses Haversine meters. Geo helpers are ArrowBase extensions and do not
478
+ change the TanStack-compatible `operators` / `comparisonFunctions`
479
+ export lists.
480
+
481
+ ```ts
482
+ import {
483
+ Collection,
484
+ bboxIntersects,
485
+ bboxWithin,
486
+ createEffect,
487
+ createTransaction,
488
+ defineSchema,
489
+ dwithin,
490
+ queryOnce,
491
+ within,
492
+ } from 'arrowbase';
493
+ import { exportArrowIPC, importArrowIPC } from 'arrowbase/arrow';
494
+
495
+ const featureSchema = defineSchema({
496
+ name: 'geo_features',
497
+ columns: {
498
+ __id: { type: 'uint32' },
499
+ name: { type: 'utf8', valuesHeapBytes: 64 * 1024 },
500
+ geometry: { type: 'point', nullable: true },
501
+ route: { type: 'linestring', nullable: true },
502
+ footprint: { type: 'polygon', nullable: true, autoCloseRings: true },
503
+ },
504
+ });
505
+
506
+ const features = Collection.create(featureSchema, {
507
+ capacity: 100_000,
508
+ spatialIndex: { column: 'geometry', type: 'rtree', eager: true },
509
+ });
510
+
511
+ features.insert({
512
+ __id: 1,
513
+ name: 'Singapore depot',
514
+ geometry: [103.8198, 1.3521],
515
+ route: [
516
+ [103.72, 1.30],
517
+ [103.82, 1.35],
518
+ [103.92, 1.40],
519
+ ],
520
+ footprint: [[
521
+ [103.80, 1.30],
522
+ [103.90, 1.30],
523
+ [103.90, 1.40],
524
+ [103.80, 1.40],
525
+ // autoCloseRings adds the closing coordinate.
526
+ ]],
527
+ });
528
+ ```
529
+
530
+ Spatial query helpers work in `queryOnce(...)`, live queries, and effects:
531
+
532
+ ```ts
533
+ const viewport = [103.6, 1.2, 104.0, 1.5] as const;
534
+ const singapore = [103.8198, 1.3521] as const;
535
+
536
+ const visible = await queryOnce((q) =>
537
+ q
538
+ .from({ feature: features })
539
+ .where(({ feature }) => bboxIntersects(feature.geometry, viewport))
540
+ .select(({ feature }) => ({ id: feature.__id, name: feature.name }))
541
+ .orderBy(({ $selected }) => $selected.id, 'asc'),
542
+ );
543
+
544
+ const fullyInsideBbox = await queryOnce((q) =>
545
+ q
546
+ .from({ feature: features })
547
+ .where(({ feature }) => bboxWithin(feature.geometry, viewport))
548
+ .select(({ feature }) => ({ id: feature.__id, name: feature.name }))
549
+ .orderBy(({ $selected }) => $selected.id, 'asc'),
550
+ );
551
+
552
+ const nearby = await queryOnce((q) =>
553
+ q
554
+ .from({ feature: features })
555
+ .where(({ feature }) => dwithin(feature.geometry, singapore, 30_000))
556
+ .select(({ feature }) => ({ id: feature.__id, name: feature.name }))
557
+ .orderBy(({ $selected }) => $selected.id, 'asc'),
558
+ );
559
+
560
+ const pointsInsideTheirPolygon = await queryOnce((q) =>
561
+ q
562
+ .from({ feature: features })
563
+ .where(({ feature }) => within(feature.geometry, feature.footprint))
564
+ .select(({ feature }) => ({ id: feature.__id, name: feature.name })),
565
+ );
566
+ ```
567
+
568
+ Use `createEffect` for viewport enter/update/exit deltas:
569
+
570
+ ```ts
571
+ const viewportEffect = createEffect({
572
+ id: 'visible-features',
573
+ query: (q) =>
574
+ q
575
+ .from({ feature: features })
576
+ .where(({ feature }) => bboxIntersects(feature.geometry, viewport))
577
+ .select(({ feature }) => ({
578
+ id: feature.__id,
579
+ name: feature.name,
580
+ geometry: feature.geometry,
581
+ }))
582
+ .orderBy(({ $selected }) => $selected.id, 'asc'),
583
+ onEnter: (event) => console.log('entered viewport', event.key),
584
+ onUpdate: (event) => console.log('moved in viewport', event.previousValue, event.value),
585
+ onExit: (event) => console.log('left viewport', event.key),
586
+ onBatch: (events, ctx) => console.log(ctx.effectId, events.length),
587
+ });
588
+
589
+ await viewportEffect.dispose();
590
+ ```
591
+
592
+ Optimistic edits use the same transaction shape as non-geo rows.
593
+ Rollback restores geometry values, bbox cache, dirty index overlay state,
594
+ and live-query/effect deltas:
595
+
596
+ ```ts
597
+ const tx = createTransaction({
598
+ mutationFn: async ({ transaction }) => {
599
+ const res = await fetch('/api/features/sync', {
600
+ method: 'POST',
601
+ headers: { 'content-type': 'application/json' },
602
+ body: JSON.stringify(transaction.mutations),
603
+ });
604
+ if (!res.ok) throw new Error('sync failed');
605
+ },
606
+ });
607
+
608
+ tx.mutate(() => {
609
+ features.update(1, (draft) => {
610
+ draft.geometry = [103.85, 1.31];
611
+ });
612
+ });
613
+
614
+ await tx.isPersisted.promise;
615
+ ```
616
+
617
+ `spatialIndex` supports point, linestring, and polygon envelopes. It maintains
618
+ a compact Flatbush base plus immutable mini-tree segments and a small dirty
619
+ overlay, so sustained inserts/updates do not degrade into a full linear dirty
620
+ scan and do not require `compact()`. Indexed `bboxIntersects`, `bboxWithin`,
621
+ and `dwithin` queries still run the original predicate as a residual exact
622
+ filter after candidate lookup.
623
+
624
+ Arrow IPC is not Parquet. `exportArrowIPC()` and `importArrowIPC()` read
625
+ and write Arrow IPC streams/files with GeoArrow extension metadata
626
+ (`geoarrow.point`, `geoarrow.linestring`, `geoarrow.polygon`). GeoParquet
627
+ would be a separate export path; do not treat these bytes as Parquet.
628
+
629
+ ```ts
630
+ const bytes = await exportArrowIPC(features);
631
+ const restored = await importArrowIPC(bytes, { name: 'restored_features' });
632
+ ```
633
+
634
+ ### Apache Arrow IPC — `arrowbase/arrow`
635
+
636
+ Optional subpath for interop with the broader Arrow ecosystem
637
+ (arrow-js, DuckDB-WASM, Polars-WASM, pyarrow, Arrow Flight…). Install
638
+ the peer dependency once:
639
+
640
+ ```bash
641
+ pnpm add apache-arrow
642
+ ```
643
+
644
+ Export a collection as an IPC byte stream or file:
645
+
646
+ ```ts
647
+ import {
648
+ exportArrowIPC,
649
+ importArrowIPC,
650
+ exportArrowTable,
651
+ } from 'arrowbase/arrow';
652
+
653
+ const bytes = await exportArrowIPC(collection); // stream (default)
654
+ const file = await exportArrowIPC(collection, { format: 'file' });
655
+ const table = await exportArrowTable(collection); // arrow.Table directly
656
+
657
+ // Anywhere downstream — arrow-js, DuckDB-WASM, pyarrow, Arrow Flight:
658
+ // tableFromIPC(bytes) consumes this byte stream.
659
+
660
+ const rehydrated = await importArrowIPC(bytes);
661
+ ```
662
+
663
+ Supported type mapping (both directions):
664
+
665
+ | ArrowBase | arrow-js |
666
+ |---------------------|-------------------------|
667
+ | `bool` | `Bool` |
668
+ | `int8/16/32/64` | `Int8/16/32/64` |
669
+ | `uint8/16/32/64` | `Uint8/16/32/64` |
670
+ | `float32/64` | `Float32/Float64` |
671
+ | `utf8` | `Utf8` |
672
+ | `dict_utf8` | `Dictionary<Int32,Utf8>`|
673
+ | `binary` | `Binary` |
674
+ | `list<T>` | `List<map(T)>` |
675
+ | `struct<…>` | `Struct<…>` |
676
+ | `point` | `geoarrow.point` (`FixedSizeList<Float64>[2]`) |
677
+ | `linestring` | `geoarrow.linestring` (`List<FixedSizeList<Float64>[2]>`) |
678
+ | `polygon` | `geoarrow.polygon` (`List<List<FixedSizeList<Float64>[2]>>`) |
679
+
680
+ Geo columns use GeoArrow extension metadata with `{ crs: 'EPSG:4326',
681
+ edges: 'planar' }`. Arrow IPC is not Parquet: this subpath serializes
682
+ Arrow streams/files only, not GeoParquet files.
683
+
684
+ **Internal columns.** `__deleted` is stripped from the Arrow schema by
685
+ default and soft-deleted rows are dropped. Pass
686
+ `{ includeInternalColumns: true }` for a loss-less round-trip.
687
+
688
+ **Import inference.** `importArrowIPC()` synthesizes an ArrowBase
689
+ schema from the Arrow `Schema`. Columns using `Timestamp`, `Decimal`,
690
+ `Date_`, `Map`, etc. throw with a clear error — those map-to-ArrowBase
691
+ semantics are out of scope for v1. Pass `{ capacity, name }` to size
692
+ or label the rehydrated collection.
693
+
694
+ **Not zero-copy.** Export materializes rows and feeds `vectorFromArray`;
695
+ import walks `table.toArray()`. ArrowBase uses ranged offsets for O(1)
696
+ updates and keeps dict tables in JS heap, so wire compatibility with
697
+ Arrow's dense-offset layout needs a rebuild. This is a serialization
698
+ path, not a hot one.
699
+
700
+ ### IndexedDB persistence — `arrowbase/idb`
701
+
702
+ Durable storage for long-lived client apps. Loads a snapshot on boot,
703
+ auto-saves with debounce on every mutation, flushes on demand.
704
+
705
+ ```ts
706
+ import { defineSchema } from 'arrowbase';
707
+ import { IdbPersistence } from 'arrowbase/idb';
708
+
709
+ const schema = defineSchema({ /* ... */ });
710
+
711
+ const persist = await IdbPersistence.open(schema, {
712
+ dbName: 'my-app',
713
+ key: 'features',
714
+ capacity: 10_000,
715
+ // format: 'arbs' (default) or 'arrow-ipc' (requires apache-arrow).
716
+ });
717
+
718
+ const collection = persist.collection; // either restored or fresh
719
+ persist.start({ debounceMs: 500 });
720
+
721
+ window.addEventListener('beforeunload', () => { persist.flush(); });
722
+ ```
723
+
724
+ Snapshot format is pluggable:
725
+
726
+ - `'arbs'` (default) — native binary snapshot. Fast, preserves ranged
727
+ offsets, round-trips soft-deletes byte-for-byte.
728
+ - `'arrow-ipc'` — Apache Arrow IPC file, portable across arrow-js,
729
+ DuckDB-WASM, pyarrow, etc. Requires the `apache-arrow` peer dep
730
+ (dynamically imported only when this format is selected).
731
+
732
+ Storage model: one IDB object store (default `'snapshots'`), caller
733
+ supplies a `key` per collection. Each record holds
734
+ `{ format, bytes, version, savedAt }`, keeping snapshots
735
+ self-describing so `IdbPersistence.open()` picks the right decoder.
736
+ Schema-fingerprint mismatches are rejected with an actionable error.
737
+
738
+ Lifecycle:
739
+
740
+ - `start({ debounceMs, saveImmediately })` — subscribe to the
741
+ collection and auto-save. Bursts of mutations coalesce into a
742
+ single IDB write per window (default 250 ms).
743
+ - `flush()` — bypass the debounce and await the IDB transaction.
744
+ - `stop()` — cancel any pending write + unsubscribe. Does not close
745
+ the underlying IDB connection.
746
+ - `close()` — `stop()` + release the IDB connection. Idempotent.
747
+
748
+ For cache-wipe UX:
749
+
750
+ ```ts
751
+ import { deleteStoredSnapshot } from 'arrowbase/idb';
752
+ await deleteStoredSnapshot('features', { dbName: 'my-app' });
753
+ ```
754
+
755
+ ### Cross-tab sync — `arrowbase/broadcast`
756
+
757
+ Propagate mutations between same-origin tabs (or iframes, or shared
758
+ workers) via the platform `BroadcastChannel` API. Every instance on
759
+ the same channel name shares state; no server round-trip.
760
+
761
+ ```ts
762
+ import { Collection, defineSchema } from 'arrowbase';
763
+ import { BroadcastSync } from 'arrowbase/broadcast';
764
+
765
+ const schema = defineSchema({ /* ... */ });
766
+ const collection = Collection.create(schema, { capacity: 10_000 });
767
+
768
+ const sync = new BroadcastSync(collection, { channelName: 'users' });
769
+ sync.start();
770
+ // …mutations here are mirrored to every other tab in real time…
771
+
772
+ // Teardown:
773
+ sync.stop();
774
+ ```
775
+
776
+ Protocol: four message types (`delta`, `sync-request`, `sync-response`,
777
+ `resync`) tagged with a per-instance `nodeId`. Self-echoes are
778
+ filtered; re-broadcast of inbound deltas is suppressed by a
779
+ reentrancy counter; a per-peer cursor dedupes deltas that arrive both
780
+ via the live stream and a racing `sync-response`. Late joiners
781
+ automatically send a `sync-request(version=0)` on `start()` and peers
782
+ reply with the relevant log slice — pass `{ requestSyncOnStart: false }`
783
+ to opt out.
784
+
785
+ Compact / rollback events publish a `resync` sentinel (those break
786
+ incremental delta coherence). Peers receive via the `onResync` hook
787
+ and should refetch a snapshot out of band, typically via
788
+ [`IdbPersistence`](#indexeddb-persistence--arrowbaseidb), then rejoin:
789
+
790
+ ```ts
791
+ const sync = new BroadcastSync(collection, {
792
+ channelName: 'users',
793
+ onResync: async ({ fromNodeId, version }) => {
794
+ // Rehydrate from IDB, then rejoin.
795
+ await persist.restore();
796
+ sync.requestSync(collection.globalVersion);
797
+ },
798
+ });
799
+ ```
800
+
801
+ **Non-goals.** Convergence under concurrent edits (last-writer-wins,
802
+ no CRDT merge), delivery guarantees (`BroadcastChannel` is
803
+ best-effort), and security (every same-origin document on the channel
804
+ sees every message — don't broadcast secrets).
805
+
806
+ ### React — `arrowbase/react`
807
+
808
+ Three hooks, zero boilerplate, concurrent-mode safe (`useSyncExternalStore`):
809
+
810
+ - `useLiveQuery(query)` — subscribe to a Query, returns the latest
811
+ `{ rows, version }` snapshot, re-renders only when an observed column
812
+ moves.
813
+ - `useLiveQueryResult(collection, build, deps)` — builder-closure
814
+ variant that rebuilds the query when `deps` change. Saves callers
815
+ from memoizing the Query object themselves.
816
+ - `useCollectionVersion(collection)` — thin subscription to
817
+ `collection.globalVersion`; useful as a cache-bust key.
818
+
819
+ React is an optional `peerDependency` (^18 or ^19). Add it to your app
820
+ dependencies and import from the subpath:
821
+
822
+ ```tsx
823
+ import { useLiveQuery, useLiveQueryResult, useCollectionVersion } from 'arrowbase/react';
824
+ import { query, f, fEq } from 'arrowbase';
825
+
826
+ function ActiveUsers({ users }) {
827
+ const { rows } = useLiveQueryResult(
828
+ users,
829
+ (q) => q.filter(fEq(f('active'), true)).orderBy('name'),
830
+ );
831
+ return <ul>{rows.map((r) => <li key={r.__id}>{r.name}</li>)}</ul>;
832
+ }
833
+ ```
834
+
835
+ Re-render economy is inherited from the executor's dep-tracking: a
836
+ mutation to an unobserved column produces zero extra renders.
837
+
838
+ ## Benchmarks
839
+
840
+ `pnpm bench` runs the regression benches and fails if any exceeds
841
+ its soft ceiling. `pnpm bench:operations` runs a manifest-checked matrix of
842
+ all structural query operators, relational stages, join modes, GeoArrow
843
+ predicates, differential backend shapes, and public CRUD operations.
844
+ `pnpm bench:all` runs every benchmark suite in sequence.
845
+ `pnpm bench:spatial` runs bounded GeoArrowBase scan,
846
+ indexed small-candidate, and dirty-overlay query benches.
847
+ `pnpm bench:vs-tanstack` runs the published head-to-head release bench
848
+ against `@tanstack/db` for aggregate scan and memory-footprint
849
+ comparisons. Latest local validation (Node 24):
850
+
851
+ | bench | mean | budget |
852
+ |-----------------------------------------------------|----------|--------|
853
+ | insert 100k int32 rows | 60.5 ms | 100 ms |
854
+ | insert 10k utf8 rows | 12.7 ms | 50 ms |
855
+ | filter+sort+paginate 10k (fn predicate, auto) | 0.76 ms | 1 ms |
856
+ | filter+sort+paginate 10k (fn predicate, fallback) | 2.84 ms | 5 ms |
857
+ | filter+sort+paginate 10k (vectorized DSL) | 0.56 ms | 1 ms |
858
+ | reactive notify (fn predicate, auto) | 116 µs | 150 µs |
859
+ | reactive notify (fn predicate, fallback) | 328 µs | 500 µs |
860
+ | reactive notify (vectorized DSL) | 94 µs | 150 µs |
861
+ | string contains 10k (dict_utf8, pre-resolved) | 0.67 ms | 1 ms |
862
+ | string contains 10k (utf8, per-row decode) | 2.74 ms | 5 ms |
863
+ | string LIKE 10k (utf8, simple-pattern specialization) | 3.22 ms | 5 ms |
864
+ | string startsWith 10k (dict_utf8) | 0.58 ms | 1 ms |
865
+ | snapshot export + import 10k | 101.1 ms | info |
866
+
867
+ Insert benches use the raw column-buffer path so the regression guard tracks
868
+ storage-kernel throughput separately from TanStack-compatible optimistic
869
+ transaction overhead, which is covered by the transaction suites.
870
+
871
+ Vectorized filter+sort and reactive-notify paths beat the PRD v2.1 §10
872
+ aspirational targets (1 ms filter+sort, 100 µs reactive notify), while
873
+ auto-lowered `.where(fn)` remains inside the release budget. Auto-lowering
874
+ keeps the gap between common `.where(fn)` predicates and hand-written DSL
875
+ small enough for ergonomic app code. On dict-encoded columns, the string
876
+ predicates pre-resolve matching codes once and drop to a single `Set.has()` per
877
+ row — ~4× faster than the utf8 per-row decode path.
878
+
879
+ ## Non-goals (see [`PRD.md §2.1`](./PRD.md))
880
+
881
+ - Cross-tab *shared memory* (SAB is same-document only). Cross-tab
882
+ *state replication* is available via
883
+ [`arrowbase/broadcast`](#cross-tab-sync--arrowbasebroadcast)
884
+ (message-based, last-writer-wins).
885
+ - Server-side durable SQL storage (use IndexedDB/localStorage client persistence, or bring your own server layer).
886
+ - Full SQL (query surface is TanStack-DB-shaped).
887
+ - Server-side use (browser + Node `worker_threads` only).
888
+ - Arrow compute kernels (possible later, out of scope v1).
889
+
890
+ ## Future work
891
+
892
+ - Nested `list<struct>` / `list<list>` / `struct<struct>`.
893
+ - SAB-resident dictionary + list child for cross-worker visibility.
894
+
895
+ ## License
896
+
897
+ MIT. See [`LICENSE`](./LICENSE).