Pitch: an Airtable-looking app where the AI fields run on-device ($0, offline, transactional), search is semantic, and devices sync when they can. Same face as Airtable, opposite architecture: their cloud database has a spreadsheet face — this is a local AI engine with a spreadsheet face.
Stack: Tauri 2 (React + Glide Data Grid) · sqlite-ai · sqlite-vector · sqlite-sync Dataset: customer feedback for a fictional SaaS product
Status: Phases 0–2 built. The engine (
edgetable-core) and the Tauri + React grid both run; live sync is verified end to end against SQLite Cloud. Seenotes/FINDINGS.mdfor what the spike measured, andedgetable/README.mdfor how to run it.Sections 2–5 and 7 have been corrected against what was actually built. Where this document and the code disagree, the code is what ran. Three decisions below deliberately overrule the original plan — the sync timer (§4), start-up seeding (§5), and syncing the
fieldstable (§3) — each marked where it appears.
- Before the audience is looking — open the app and press Add sample data. The grid starts empty by design (§5), and seeding takes a moment. Alternatively press it on stage: an empty table filling instantly with 200 rows is a fine opening, and it makes the point that nothing was preloaded.
- Open the app — a familiar grid of customer feedback:
author,channel(email/twitter/app-review as colored pills),body,created_at. "Looks like Airtable. It's a SQLite file on this laptop." - Add AI fields — from the column "+" menu add AI Summary, AI Sentiment, AI Category. Columns backfill live, row by row. Network monitor visible and silent the whole time.
- Semantic search — type "complaints about billing" in the search box. Rows match that never contain the word "billing" ("charged twice", "invoice is wrong", "cancel my subscription"). Toggle to keyword mode to show the difference.
- Kill the network — Wi-Fi off. Paste 5 new feedback rows. AI columns still populate. "Airtable's AI fields are a cloud queue and a credit meter. Ours is a database trigger."
- Second device — Wi-Fi back on, press Sync and wait for the chip to read
Synced(notSyncing…— see §4; a peer syncing too early receives nothing). Then sync device B: the rows added offline appear there with their AI values already computed, on a device that never ran the model. Close with the rawsqlite3CLI querying the same file the pretty UI shows: "the UI took two weeks; the engine underneath is the product."
┌─ Tauri app (per device) ──────────────────────────────┐
│ React + Glide Data Grid (webview UI) │
│ │ invoke() IPC │
│ Rust core (rusqlite, single writer connection) │
│ feedback.db ── ai.dylib · vector.dylib · sync.dylib│
│ models/ qwen3-1.7b-q4_k_m.gguf · minilm-l6-v2.gguf│
└──────────────┬────────────────────────────────────────┘
│ cloudsync_network_sync()
SQLite Cloud (managed sync backend + RLS)
│
┌─ Device B ───┴───────────────────────────────────────┐
│ Same Tauri app (or thin Flutter companion on phone) │
└───────────────────────────────────────────────────────┘
Key decisions:
- Two model-holding connections, not one. A model belongs to a connection and
llm_model_load()frees whatever that connection already had, so generation and embedding cannot share one.GEN(Qwen3-0.6B) owns inserts, the enrichment trigger and sync;EMB(MiniLM) owns the embedding backfill andvector_full_scan. Rust serialises writes across the two. The UI talks to them via Tauriinvoke()commands (list_rows,insert_row,update_cell,add_ai_field,semantic_search,sync_now). - Every connection must run
cloudsync_initandvector_inititself for every table it touches — both are per-connection, both are idempotent, and skippingcloudsync_initmakes writes fail with a message that reads like corruption. - Close model-holding connections oldest first. sqlite-ai registers a process-global llama log callback bound to one connection's context; closing the youngest connection first segfaults the process. FINDINGS.md #1.
- AI values are computed on the originating device and carried by sync as plain
columns. Guard every AI trigger with
WHEN NEW.<ai_col> IS NULLso rows arriving via sync (already enriched) are never recomputed — plusAND NEW.body <> ''so a column-by-column sync apply can never enrich a body that hasn't arrived yet. This is the demo's best line: "local-first intelligence, not just local-first data." - Models:
Qwen3-1.7BQ4_K_M GGUF for summary/sentiment/category — not the 0.6B this plan originally specified. Measured at 0.92 s/row for all three fields together on Apple Silicon withgpu_layers=99, which is faster than 0.6B (shorter summaries, and token count dominates), at 92% category accuracy against hand labels versus 58%. The 0.6B model produces valid-but-wrong pills, which the audience reads. Cost is bundle size: 1.1 GB vs 300 MB.all-MiniLM-L6-v2q8_0 for embeddings (384 dims, ~3 ms/row). Both bundled in the app resources — no download step on stage.
-- Synced tables need TEXT UUID PKs and identical schema on every device.
-- Every NOT NULL column needs a DEFAULT too: cloudsync_init() rejects the table
-- otherwise, because CRDT merges arrive column by column.
CREATE TABLE feedback (
id TEXT PRIMARY KEY, -- cloudsync_uuid()
author TEXT NOT NULL DEFAULT '',
channel TEXT NOT NULL DEFAULT 'email', -- 'email' | 'twitter' | 'app_review' | 'support'
body TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT '', -- ISO8601, set by app
-- AI fields (NULL until enriched; guard triggers on IS NULL)
ai_json TEXT, -- the one grammar-constrained generation
summary TEXT,
sentiment TEXT, -- 'positive' | 'neutral' | 'negative'
category TEXT, -- 'billing' | 'bugs' | 'feature_request' | 'ux' | 'performance' | 'other'
embedding BLOB -- FLOAT32 x 384, from llm_embed_generate
);
-- Field metadata so the "+ add field" menu is data-driven.
--
-- CHANGED: this is NOT synced. `visible` is per-device state -- revealing an AI
-- field on the laptop should not pre-open the columns on the second device, which
-- is exactly what happened when it was synced. Everything else here is derived
-- from a constant in code, so every device builds an identical list on its own
-- and nothing is lost by keeping it local. It also shrinks the schema hash
-- contract with the server to the one table that has to be in it.
CREATE TABLE fields (
id TEXT PRIMARY KEY,
table_name TEXT NOT NULL DEFAULT 'feedback',
column_name TEXT NOT NULL DEFAULT '',
label TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL DEFAULT 'text', -- 'text'|'select'|'date'|'ai_summary'|'ai_sentiment'|'ai_category'
config TEXT NOT NULL DEFAULT '{}',
position INTEGER NOT NULL DEFAULT 0,
visible INTEGER NOT NULL DEFAULT 0 -- AI fields start hidden: that is beat 2
);Seed data: ~200 rows of realistic fictional feedback (mix of praise, bug reports,
billing complaints phrased without the word "billing", feature requests). Generate
once with a cloud LLM during development, ship as seed.sql. The first 24 rows
exist in examples/seed.sql; extend that
file rather than starting a new one, and keep the property that makes beat 3 work —
no billing complaint may contain the word "billing". Verified today: semantic
search returns six billing rows for "complaints about billing" while
LIKE '%billing%' returns zero.
Two connections, because one model per connection is a hard rule. Both load all
three extensions and both run every *_init — they are per-connection and
idempotent. PRAGMA trusted_schema = ON is required or the trigger cannot call the
model; PRAGMA journal_mode = WAL so EMB can read while GEN generates.
-- ---- connection GEN (opened first) ----
PRAGMA journal_mode = WAL; PRAGMA trusted_schema = ON;
SELECT llm_model_load('models/qwen3-1.7b-q4_k_m.gguf', 'gpu_layers=99'); -- 1.7B, not 0.6B: FINDINGS.md #4
SELECT llm_context_create_textgen('context_size=2048');
SELECT llm_sampler_create();
SELECT llm_sampler_init_grammar('<the JSON grammar>', 'root');
SELECT llm_sampler_init_greedy(); -- greedy/dist registers LAST
SELECT vector_init('feedback', 'embedding', 'type=FLOAT32,dimension=384,distance=cosine');
SELECT cloudsync_init('feedback'); -- `feedback` only; `fields` is local
SELECT cloudsync_network_init('<managed-database-id>');
SELECT cloudsync_network_set_apikey('<api-key>');
-- ---- connection EMB (opened second, closed LAST -- see FINDINGS.md #1) ----
PRAGMA journal_mode = WAL; PRAGMA trusted_schema = ON;
SELECT llm_model_load('models/minilm-l6-v2.gguf');
SELECT llm_context_create_embedding('embedding_type=FLOAT32'); -- mandatory option
SELECT vector_init('feedback', 'embedding', 'type=FLOAT32,dimension=384,distance=cosine');
SELECT cloudsync_init('feedback'); -- or every write fails on this connectionThe cloud database needs the feedback table only, with the same columns in
the same order, and OffSync enabled on it alone. An extra synced table there
changes the schema hash just as surely as a missing one. Only column name,
position, type affinity and primary-key flag feed that hash — NOT NULL and
DEFAULT do not, though they are still required for correct CRDT merges.
One grammar-constrained generation produces all three text fields — ~3x faster
than three calls, and the label and summary come from the same forward pass so they
cannot contradict each other. The embedding is not in the trigger: a trigger runs
on whichever connection did the insert, which is GEN, and GEN holds the
generation model. Vectors are a worker pass on EMB.
CREATE TRIGGER feedback_ai AFTER INSERT ON feedback
WHEN NEW.summary IS NULL -- skip rows arriving via sync already enriched
AND NEW.body <> '' -- and rows whose body hasn't arrived yet: sqlite-sync
-- may apply columns separately, and body has a DEFAULT ''.
-- A late-body row stays NULL; the bulk worker sweeps it up.
BEGIN
UPDATE feedback SET ai_json = llm_text_generate(
'<|im_start|>system' || char(10) || -- no chat template is applied,
'...labelling instructions...<|im_end|>' || char(10) || -- so carry ChatML
'<|im_start|>user' || char(10) || NEW.body || '<|im_end|>' || char(10) ||
'<|im_start|>assistant' || char(10), 'n_predict=140')
WHERE id = NEW.id;
UPDATE feedback SET -- guaranteed-valid JSON -> columns
summary = json_extract(ai_json, '$.summary'),
sentiment = json_extract(ai_json, '$.sentiment'),
category = json_extract(ai_json, '$.category')
WHERE id = NEW.id;
END;The GBNF grammar (see spike.py) pins sentiment
and category to their enums, so off-vocabulary output is unrepresentable rather
than merely unlikely. Measured 24/24 valid JSON and 48/48 in-enum labels.
Notes:
- Trigger runs inside the insert's transaction → "INSERT, then SELECT — it's already
there" is literally true. Measured at 0.92 s/row (median 0.93 s, max 1.32 s
over 24 rows), so the trigger stays on the insert path for single rows. Bulk paste
still goes through a Rust worker over
WHERE summary IS NULL— not for correctness but because progressive column fill looks better. Add a Rust-side normalizer so pills never show junk.Not needed — but not for the reason assumed. The grammar makes off-enum output impossible, so there is nothing to normalise. It does nothing about a valid label that is simply the wrong label; that is a model-and-prompt problem, measured separately below.- Label accuracy is a first-class demo risk and needs regression testing. The
category prompt uses numbered precedence rules ("test in order, stop at the first
match"), which is what takes 1.7B from 62% to 92%.
EXPECTED_CATEGORYinspike.pyholds hand labels for the seed rows;make spikescores against them and fails the run below 85%. Re-run it after any prompt or model change, and extend it asseed.sqlgrows. - Qwen3 is a reasoning model, but the grammar forbids a
<think>block (the first token must be{), so no/no_thinkhandling is required.
-- query text → embedding → top-k scan
SELECT f.*, v.distance
FROM vector_full_scan('feedback', 'embedding', llm_embed_generate(:query), 20) AS v
JOIN feedback f ON f.rowid = v.rowid
ORDER BY v.distance;200 rows → vector_full_scan is more than enough; mention vector_quantize_scan
in the talk track as the path to millions of rows.
SELECT cloudsync_network_sync(); -- called ONLY when the presenter presses Sync
SELECT cloudsync_terminate(); -- on app quitCHANGED: sync is manual. No timer, and none on app start. The original line here read "app start, Sync button, timer when online". A timer is wrong for a stage tool on three counts: beat 1 asks an audience to watch the network stay silent while the app would be quietly phoning home; it makes resetting the demo non-local, because a fresh seed pushes a fresh set of rows to the cloud thirty seconds later, every rehearsal; and it takes the running order out of the presenter's hands.
- Both devices create identical schema and
cloudsync_initthe same tables — which now meansfeedbackalone (the server enforces a schema hash over exactly the enabled tables). - All inserts use
cloudsync_uuid()forid. - The chip is a status and the only trigger:
Local only(no credentials, disabled) ·Sync now·Syncing…·Synced · 12s ago·Offline — changes kept. Offline detection:cloudsync_network_sync()failure → flip the chip, keep working. "syncing"is not"synced". A push is accepted before the server settles it and reports{"send":{"status":"syncing"…}}until it has; a peer syncing inside that window receives nothing. The chip retries after 2.5 s rather than claiming success. On stage: press Sync on device A, wait forSynced, then sync device B. Do not script the two back to back.
Keep: left sidebar listing tables ("Feedback", "Tasks" stub) · toolbar with search box (keyword ⇄ semantic toggle), Sort, Filter (category/sentiment only), Sync chip · grid via Glide Data Grid: sticky header, column resize, inline edit, colored select pills, row hover, "+" column-header menu with field types (Text, Number, Select, Date, ✨ AI Summary, ✨ AI Sentiment, ✨ AI Category) · "+ New row" footer and CSV paste (the offline-bulk-insert moment).
One deliberate semantic: the menu offers three ✨ types but the engine computes all
three in a single generation — adding the first AI field pays the ~1 s/row
backfill and silently fills ai_json; adding the other two just reveals
already-computed columns instantly. That's good stagecraft (beat 2 gets visibly
faster as it goes), and it is implemented as designed behaviour: the fields exist
from the start with visible = 0, and "adding" one flips that flag.
CHANGED: the app starts empty, and nothing populates it unprompted. The grid opens with no rows and no network traffic; three explicit controls decide what it becomes:
| Control | Where | Does |
|---|---|---|
| Add sample data | empty state (primary action) and the status bar | Inserts the sample set, unlabelled but already embedded. Press again to add more. |
| Clear all rows | status bar | Empties the table. A distributed delete when synced — tombstones propagate. |
| Sync | toolbar chip | Pushes and pulls, once. |
This costs one click before beat 1 and buys the running order: seed first (the device-A story) or sync first (device B receiving rows that arrive already enriched). It also makes resetting between rehearsals a click rather than a database deletion.
Three details that only became apparent once it was running:
- Semantic search is top-k, not matching. Every row has a distance, so a
knear the row count degenerates into re-sorting the table — a default of 25 did exactly that to a 26-row demo.kis 8. An optional distance-cutoff slider sits beside the mode toggle; treat it as an exploration knob, not a relevance mechanism, since distances are not calibrated across queries. - Nothing may shift under the cursor. Transient banners are bottom-left toasts overlaying the grid, not bars above it — as a bar, "Added 3 rows" pushed every row down 28 px and turned a click on row 1 into a click on the header. The same rule put the cutoff slider before the hit readout rather than after it.
- Row delete: select row markers, press Delete. No confirmation — the plan skips undo, so it is final either way, and a modal mid-demo buys nothing.
Skip: views (kanban/calendar), linked records, formulas, permissions,
collaboration cursors, attachments, undo history. Still unbuilt from the Keep list:
Sort, and CSV paste (the bulk-insert moment — the insert_rows_bulk command exists
and is wired, but no paste handler calls it yet). If asked: "product surface —
teams build surface; they can't build the engine."
Look: clean Airtable-adjacent theme (white grid, subtle borders, colored pills, Inter font). AI columns get a subtle ✨ in the header. Don't clone Airtable's brand.
Phase 0 — Spike. DONE (make spike, ~40 s). All three extensions in one
connection, the trigger over 24 rows timed, semantic search, and a two-device sync
round trip proved offline with cloudsync_payload_encode/_apply — which turned out
to be a better test than the cloud one, since it isolates sync semantics from the
network. Results and the four corrections to this document:
notes/FINDINGS.md. Only the live
SQLite Cloud round trip is outstanding.
Phase 1 — Engine. DONE. Rust core: two-connection management with the
close-order rule, model loading, schema + seed, enrichment worker (bulk backfill),
embedding worker on EMB, Tauri commands. Output normalizer — dropped, the
grammar covers it. Port the schema, grammar, prompt and trigger from spike.py
rather than rewriting them; they are the measured versions. Grow seed.sql from 24
rows to ~200, preserving the no-"billing" property.
Phase 2 — Grid UI. DONE apart from Sort and CSV paste. React + Glide Data Grid wired to the Tauri commands: grid rendering, inline edit, row delete, add-field menu with the ✨ types and progressive backfill, search with the keyword/semantic toggle and distance cutoff, pills, sidebar, toolbar, seed/clear controls.
Backed by npm test — Playwright drives the real App and Grid in a browser with
only the Tauri boundary stubbed. That suite exists because the engine's selftest
covers Rust exhaustively and the UI not at all, which is where every user-visible
bug in this project came from: an editor that could never open (a missing
<div id="portal">, required by glide and absent since the first commit), a grid
that silently stopped editing, a toast that displaced every click target. None were
visible to the engine tests; all are regression-tested now.
Phase 3 — Sync + polish (2–3 days). SQLite Cloud project done — schema
created server-side, round trip verified: a bare sqlite3 holding only cloudsync,
with no model and no sqlite-ai, pulled 26 rows with every summary, sentiment and
category populated and every embedding intact at 1536 bytes. Sync chip and
offline handling done.
Repository independence done: the extensions are downloaded as pinned release
binaries (extensions.lock, make install) instead of being resolved from sibling
working copies, and the spike, seed data, search example, plan and findings all live
in this repository. That also moved the demo onto sqlite-vector 1.1.0 and
sqlite-sync 1.1.2, two releases newer than the local builds it had been using.
Remaining: second-device build, Sort, CSV paste, rehearse the full five-minute
script, add the sqlite3 CLI closer, and package the .app for two Macs (no Apple
dev license needed for local/dev distribution). Two packaging hazards to expect —
extension loading under macOS quarantine, and the quit-time abort in FINDINGS #8,
whose fix lives in the Tauri exit hook and must survive bundling.
Phase 4 (optional) — Phone companion (2–3 days). Thin Flutter app (list view, add-feedback form, sync button) using the published sqlite-ai/sync Flutter packages — phone-in-hand sync beats two laptops on stage, and quietly proves the bindings.
Total: ~8–11 days for the desktop demo; +2–3 for the phone moment.
Phase 0 settled the original list; Phases 1–2 added four that the spike could not
have found, because they only exist once there is an app. Full detail in
notes/FINDINGS.md.
| Risk | Outcome |
|---|---|
| Two models (textgen + embedding) in one connection | Settled — impossible. One model per connection, and the second load kills the first silently (llm_text_generate returns ''). Two connections, as mitigated. |
| Trigger latency on bulk paste | Settled. 0.92 s/row for all three fields via one grammar-constrained call. Trigger for single rows, Rust worker for bulk. |
| Do cloudsync-applied rows fire triggers? | Settled — yes, and the guard does not save you. The spike's "enriched rows skip it, 24 rows in 0.01 s" held only for a single-statement apply. Real sync writes a row column by column and re-enters AFTER INSERT on each write, and on the write carrying body the summary has not arrived, so the IS NULL guard passes and the row is inferred anyway: 101.3 s vs 4.6 s for a 24-row first sync, for values that were then overwritten. Sync switches the trigger off while applying; unlabelled arrivals are left to the backfill worker. FINDINGS #13. |
| LLM outputs off-enum | Settled — cannot happen. GBNF: 24/24 valid JSON, 48/48 in-enum. Normalizer dropped. |
| LLM outputs the wrong enum value (not in the original plan) | Settled, after a model change. Qwen3-0.6B was only 58% accurate on category — valid pills, wrong labels, on screen all demo. Qwen3-1.7B with ordered precedence rules reaches 92% at no latency cost. Scored on every spike run; keep it above 85%. |
| Multi-connection teardown segfaults the process | New, and the sharpest one. A process-global llama log callback bound to one connection's context (src/sqlite-ai.c:3806). Workaround: close model-holding connections oldest first. Worth fixing in sqlite-ai — it is also the root cause of DEMO-FINDINGS #7. |
| Quitting the app aborts the process | New, found from a crash report, fixed. NSApplication terminate: calls exit(), which runs atexit handlers without unwinding, so no Rust Drop fires and no model is freed; GGML then asserts its Metal resource sets are empty and the process aborts after the user clicked Quit. Freed from Tauri's RunEvent::Exit instead. A/B verified: exit 0 with the hook, SIGABRT without. FINDINGS #8. |
| Cell editors never open | New, fixed. glide renders editors into a <div id="portal"> that index.html never had, so editing was dead from the first commit and failed silently — no error, cells just selected. Regression-tested. |
| Extension load in Tauri-bundled app (macOS signing/quarantine) | Still open. Untouched so far. Test the .app on the second Mac early in Phase 3; the quit-time fix above also has to survive bundling. |
| Live sync round trip against SQLite Cloud | Settled. Schema created server-side, credentials in .env, round trip verified onto a device with no model at all. The one gotcha is "syncing" vs "synced" — see §4. |
| Live-demo network dependence | Sync is the only online step, and now the only one that can happen at all without a click (§4). Everything else rehearses fully offline, including the second device. |
| Rehearsals polluting the cloud database | New. Every seed mints fresh UUIDs, so a synced rehearsal adds another full set of rows to the cloud — three run-throughs left 75 rows behind 25 distinct ones. Rehearse with EDGETABLE_DB_ID commented out, or against a scratch database, and keep the demo database clean until the day. |
| Airtable | This demo | |
|---|---|---|
| Data lives | Their cloud | A local SQLite file you own |
| Offline | Read-only at best | Full edit + query + AI |
| AI fields | Cloud LLM, credits, queue | Local GGUF, $0, transactional trigger |
| Private data | Sent to third-party LLMs | Never leaves the device |
| Search | Keyword filter | Semantic (sqlite-vector) |
| Sync | Always on their server | CRDT, offline-first, AI values included |
| Extensibility | Their API limits | It's SQLite — embed it anywhere |