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/CHANGELOG.md +27 -0
- package/COMPATIBILITY.md +398 -0
- package/LICENSE +21 -0
- package/MIGRATING.md +238 -0
- package/README.md +897 -0
- package/RELEASE_NOTES.md +68 -0
- package/dist/arrow.d.ts +93 -0
- package/dist/arrow.js +5366 -0
- package/dist/arrow.js.map +1 -0
- package/dist/broadcast.d.ts +173 -0
- package/dist/broadcast.js +319 -0
- package/dist/broadcast.js.map +1 -0
- package/dist/collection-DGlKgOGi.d.ts +1932 -0
- package/dist/idb.d.ts +146 -0
- package/dist/idb.js +6193 -0
- package/dist/idb.js.map +1 -0
- package/dist/index.d.ts +1802 -0
- package/dist/index.js +13924 -0
- package/dist/index.js.map +1 -0
- package/dist/query-CzV9E57Y.d.ts +279 -0
- package/dist/react.d.ts +51 -0
- package/dist/react.js +1633 -0
- package/dist/react.js.map +1 -0
- package/dist/tanstack.d.ts +51 -0
- package/dist/tanstack.js +191 -0
- package/dist/tanstack.js.map +1 -0
- package/dist/types-CSoU6uED.d.ts +50 -0
- package/package.json +139 -0
package/README.md
ADDED
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
# ArrowBase
|
|
2
|
+
|
|
3
|
+
[](https://github.com/natanelia/arrowbase/actions/workflows/ci.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/arrowbase)
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
[](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).
|