Skip to content

Latest commit

 

History

History
415 lines (358 loc) · 25.8 KB

plan.md

File metadata and controls

415 lines (358 loc) · 25.8 KB

"EdgeTable" — Local-First Airtable Demo · Build Plan

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. See notes/FINDINGS.md for what the spike measured, and edgetable/README.md for 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 fields table (§3) — each marked where it appears.


1. The 5-minute demo script (build everything to serve this)

  1. 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.
  2. 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."
  3. 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.
  4. 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.
  5. 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."
  6. Second device — Wi-Fi back on, press Sync and wait for the chip to read Synced (not Syncing… — 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 raw sqlite3 CLI querying the same file the pretty UI shows: "the UI took two weeks; the engine underneath is the product."

2. Architecture

┌─ 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 and vector_full_scan. Rust serialises writes across the two. The UI talks to them via Tauri invoke() commands (list_rows, insert_row, update_cell, add_ai_field, semantic_search, sync_now).
  • Every connection must run cloudsync_init and vector_init itself for every table it touches — both are per-connection, both are idempotent, and skipping cloudsync_init makes 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 NULL so rows arriving via sync (already enriched) are never recomputed — plus AND 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.7B Q4_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 with gpu_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-v2 q8_0 for embeddings (384 dims, ~3 ms/row). Both bundled in the app resources — no download step on stage.

3. Data model

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

4. Extension wiring (real API calls)

Startup (Rust, once per launch)

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 connection

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

AI fields as triggers (the WOW core)

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_CATEGORY in spike.py holds hand labels for the seed rows; make spike scores against them and fails the run below 85%. Re-run it after any prompt or model change, and extend it as seed.sql grows.
  • Qwen3 is a reasoning model, but the grammar forbids a <think> block (the first token must be {), so no /no_think handling is required.

Semantic search

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

Sync

SELECT cloudsync_network_sync();   -- called ONLY when the presenter presses Sync
SELECT cloudsync_terminate();      -- on app quit

CHANGED: 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_init the same tables — which now means feedback alone (the server enforces a schema hash over exactly the enabled tables).
  • All inserts use cloudsync_uuid() for id.
  • 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 for Synced, then sync device B. Do not script the two back to back.

5. UI spec (minimum Airtable feel, nothing more)

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 k near the row count degenerates into re-sorting the table — a default of 25 did exactly that to a 26-row demo. k is 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.

6. Build phases

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.

7. Risks & open items

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.

8. The positioning table (for the talk track / slide)

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