Tables / database feature from atomic-server, but state management is done using Nextgraph-rs.
  • TypeScript 98.5%
  • JavaScript 1.2%
  • HTML 0.3%
Find a file
2026-06-11 14:27:41 +02:00
src init 2026-06-11 13:30:07 +02:00
.gitignore init 2026-06-11 13:30:07 +02:00
eslint.config.js init 2026-06-11 13:30:07 +02:00
index.html init 2026-06-11 13:30:07 +02:00
package.json init 2026-06-11 13:30:07 +02:00
pnpm-lock.yaml init 2026-06-11 13:30:07 +02:00
pnpm-workspace.yaml init 2026-06-11 13:30:07 +02:00
README.md Initial commit: ported atomic-server table editor to NextGraph 2026-06-11 14:27:41 +02:00
tsconfig.app.json init 2026-06-11 13:30:07 +02:00
tsconfig.json init 2026-06-11 13:30:07 +02:00
tsconfig.node.json init 2026-06-11 13:30:07 +02:00
vite.config.ts init 2026-06-11 13:30:07 +02:00

elfa-tables

A port of atomic-server's spreadsheet-style table editor to run on NextGraph (RDF + CRDT graph store) instead of Atomic Data.

Remote origin: git@git.elfaconsortium.eu:joepio/elfa-tables-atomic.git

This README is the entrypoint for continuing the work. If a session is interrupted, read this top-to-bottom: it records the architecture, every non-obvious decision, and the exact remaining steps.


What this is

Atomic-server has a polished, keyboard-driven editable data grid. The grid component itself (FancyTable) is completely headless — it knows nothing about the backend; all data coupling lived in a separate integration layer.

The port therefore = lift the headless grid verbatim + rewrite the thin data layer against NextGraph's React ORM (@ng-org/orm/react).

  • Source grid: atomic-server/browser/data-browser/src/chunks/TableEditor/
  • Source integration (reference only, NOT copied): atomic-server/.../chunks/TablePage/
  • NextGraph reference example: ../nextgraph-rs/sdk/js/examples/test-react-sdk/ (React + useShape)

Decisions (locked in with the user)

  1. Fresh standalone repo at /Users/joep/dev/elfa-tables.
  2. React (not Svelte) — lets us reuse the grid almost verbatim.
  3. Typed/static ShEx shape (MVP) — columns come from a codegen'd shape, not editable at runtime.
  4. Start minimal — display/edit/keyboard/copy-paste over one shape; advanced features deferred (see below).

The full plan lives at ~/.claude/plans/typed-floating-dewdrop.md.


Architecture

The bridge: ShEx shape → columns

A NextGraph ORM ShapeType is just { schema, shape } where schema[shape].predicates[] carries each field's readablePredicate, valType, min/maxCardinality, and (for enums) literals. That predicate list is the runtime bridge — it plays the role Atomic's Property[] played, so the grid's generic columns: T[] prop is filled dynamically without hardcoding field names. See src/table/columns.ts.

Dynamic schema — built at runtime, NO codegen

ShapeType/Schema are plain runtime objects; useShape only reads .schema and .shape. So we build the shape in JS instead of authoring a .shex file and running the rdf-orm codegen. src/table/defineShape.ts is a typed builder: a concise FieldSpec[] → a full ShapeType (incl. the @type predicate and per-literal enum dataTypes). src/shapes/taskShape.ts defines the demo shape with it, using the same IRIs the old codegen produced (so existing data loads).

This is the foundation for user-editable columns: lift the FieldSpec[] into state, rebuild the ShapeType on change, and useShape re-subscribes — the analog of editing an Atomic Class's requires/recommends. (The editing UI + where the schema persists is the next step; see Deferred.)

Data flow

useShape(TaskShapeType, graph)  →  DeepSignalSet<Task>   (src/table/NgTablePage.tsx)
  (graph = did:ng:<private_store_id>; new rows also carry @graph = graph)
        │                                     │
        │ deriveColumns(TaskShapeType)        │ rows = [...set]
        ▼                                     ▼
   ShapeColumn[]  ───────────────►  FancyTable (headless grid, src/table/TableEditor/)
                                          │ children({index}) renders NgTableCell per column
                                          ▼
                                    NgTableCell  (src/table/NgTableCell.tsx)
                                          │ read:  row[col.key]
                                          │ write: row[col.key] = v   (auto-commits to CRDT)
                                          │ new row: set.add({...})
                                          ▼
                                    cell editors (src/table/cells.tsx)

Key facts about the NextGraph ORM (installed alpha versions)

  • Versions (must match the deployed wallet — see version-skew bug below): @ng-org/orm@0.1.2-alpha.19, @ng-org/alien-deepsignals@0.1.2-alpha.12, @ng-org/web@0.1.2-alpha.13, @ng-org/shex-orm@0.1.2-alpha.8 (dev, TYPES only).
  • useShape(shape, scope): scope may be a string graph NURI or {graphs, subjects}. We pass did:ng:<private_store_id> (matches the canonical expense-tracker-rdf example). Returns a DeepSignalSet<T>. scope === undefined → a read-only set that throws on mutation. initNg is exported as initNgSignals. Under the hood alpha.19 calls the WASM methods orm_start_graph / graph_orm_update.
  • ORM objects are keyed by readablePredicate (task.title), NOT by IRI. Columns therefore carry both iri (React key) and key (object access).
  • Writes = direct mutation: task.title = 'x' auto-commits to the graph and triggers a rerender. No useValue/useDebouncedSave equivalent needed.
  • New row = set.add({ '@graph': graph, '@type': shape, '@id': '', ... }). '@id': '' auto-assigns a fresh subject IRI.
  • Session/auth (@ng-org/web init): at top level it redirects to a NextGraph wallet/broker (nextgraph.net by default, or a local dev broker via NG_DEV* env vars), which authenticates and reloads this app inside an iframe, posting the session back over a MessageChannel. No custom login UI required.

Repo layout

src/
  main.tsx                 # initNg() then render <App/>
  App.tsx                  # session gate + ThemeProvider + <NgTablePage/>      ✅
  styled.d.ts              # styled-components DefaultTheme augmentation
  ng/session.ts            # NextGraph SDK bootstrap (sessionPromise, session)
  hooks/                   # useClickAwayListener, useControlLock (relative deps of the grid)
  shapes/
    taskShape.ts           # the demo "Task" shape, built via defineShape (runtime, no codegen)
  table/
    TableEditor/           # COPIED headless grid (verbatim from atomic-server)
    lib/{components,helpers,hooks}/   # COPIED transitive deps of the grid
    theme.ts               # minimal styled-components theme the grid needs
    defineShape.ts         # FieldSpec[] → runtime ShapeType (replaces rdf-orm codegen) ✅
    columns.ts             # deriveColumns(shapeType) → ShapeColumn[]   ✅
    cells.tsx              # valType → {Display, Edit} editors + alignment ✅
    NgTableCell.tsx        # binds a column+row to the headless Cell       ✅
    NgTablePage.tsx        # orchestrator (useShape + FancyTable)           ✅

Path aliases (vite.config.ts + tsconfig.app.json)

To avoid editing imports in the copied grid, atomic's layout is mirrored and aliased:

  • @components/*src/table/lib/components/*
  • @helpers/*src/table/lib/helpers/*
  • @hooks/*src/table/lib/hooks/*
  • relative ../../hooks/* imports resolve to src/hooks/* (useClickAwayListener, useControlLock) — placed there deliberately.

Progress tracker

# Step Status
1 Scaffold repo (package.json, vite, tsconfig, eslint, index.html, main.tsx, ng/session.ts) done
2 Copy headless TableEditor/ + transitive deps into lib/ + src/hooks/; theme + styled.d.ts done
3 Codegen ORM artifacts → replaced by runtime defineShape (dynamic schema, no build step) done
4 Data layer: columns.ts, cells.tsx, NgTableCell.tsx, NgTablePage.tsx, App.tsx done
5 pnpm typecheck + pnpm build + pnpm lint ; runs in browser ; table UI works ; persistence works done

v1 is DONE and verified end-to-end. pnpm typecheck, pnpm build, pnpm lint all pass; the app boots, authenticates against a NextGraph wallet, the table renders + edits, and rows survive a page reload (user-confirmed 2026-06-09, after the version-skew fix below). The temporary diagnostic logging has been removed.

Note: hard-reloading the nextgraph.eu auth page itself shows "Invalid request" — that's the wallet auth page's own error state for a stale/consumed request, NOT an app bug. Reload the app (localhost:5191) instead.

Browser-verified (2026-06-09)

  • App boots; all 66 app modules load HTTP 200 inside the auth iframe.
  • init() redirects to the NextGraph wallet auth flow as designed.
  • Created a real wallet and logged in; the wallet granted the app a session (banner: "Wallet opened for localhost:5191").
  • The table UI works (confirmed by user screenshot in a headed browser): columns derived from the shape, inline cell editing, boolean checkboxes, and the trailing new-row all render and edit correctly.

Bug found & fixed: writes weren't persisting — ORM/wallet VERSION SKEW (2026-06-09)

Symptom: edits showed in the grid but vanished on refresh; broker logged SENDING EVENTS FROM OUTBOX RETURNED: Ok(()) yet a reload read an empty table. Real cause: stale dependency pins. We had pinned @ng-org/orm@0.1.2-alpha.5 (copied from test-react-sdk), which calls the old WASM method names orm_start / orm_update. The deployed nextgraph.eu wallet runs a newer WASM that only exposes orm_start_graph / graph_orm_update (confirmed against nextgraph-rs/sdk/js/orm/src/connector/GraphOrmSubscription.ts and sdk/js/lib-wasm/src/lib.rs). So the orm calls hit non-existent methods on the live broker → reads returned nothing and writes never committed the ORM patch. Fix: upgraded to the latest alphas so the protocol matches the live wallet: @ng-org/orm@0.1.2-alpha.19, @ng-org/alien-deepsignals@0.1.2-alpha.12, @ng-org/web@0.1.2-alpha.13 (shex-orm stays alpha.8 for codegen). The newer useShape(shape, scope) accepts a string scope (did:ng:<private_store_id>), matching the canonical expense-tracker-rdf example — that's what NgTablePage now passes. (An earlier "empty scope" attempt was a red herring and was reverted.) Lesson: pin @ng-org/* to versions that match the broker you target; the example apps in nextgraph-rs may lag the deployed wallet. Status: FIXED & VERIFIED — rows persist across reloads (user-confirmed 2026-06-09). The temporary diagnostic logging has been removed.

Dev wallet (throwaway, on the free nextgraph.eu alpha broker) — reuse it to finish the manual test:

  • username: elfatablesdev
  • password: ElfaTables2026demo!
  • unlock method: username/password (RecoveryKit stored encrypted server-side; no pazzle). The wallet is also saved in the browser's localStorage after first login.

Automation note (for future debugging)

The app runs inside a cross-origin iframe (the wallet at nextgraph.eu frames localhost:5191). Headless Chromium does not paint out-of-process iframes into screenshots nor expose their DOM/AX tree to automation — so a headless tool (e.g. Charlotte) sees only a placeholder glyph even when the app loaded fine. Verify the table in a normal headed browser. To see the app's real runtime errors, open the iframe's devtools console (right-click → inspect the frame); the top page's console won't show them.


Commands

pnpm install          # deps (already installed; esbuild build approved)
pnpm dev              # vite dev server (will redirect to a NextGraph wallet)
pnpm typecheck        # tsc -b --noEmit
pnpm build            # tsc -b && vite build
pnpm lint

Running fully locally (no nextgraph.eu / .net) — optional

By default the app redirects to the hosted wallet at nextgraph.eu. The wallet (not the app) holds keys, runs the WASM engine, and talks to a broker — @ng-org/web delegates to it over a postMessage iframe, so the app can't run the engine itself. "Local" therefore means running the wallet + broker on localhost and pointing the app at them. Running locally also pins the WASM version under our control, which eliminates the version-skew class of bug (see above).

Steps (one-time setup is the Rust/WASM build; then 3 terminals):

  1. Build the WASM + front-end in ../nextgraph-rs (needs Rust + LLVM 17 + NextGraph's wasm-pack fork — see nextgraph-rs/DEV.md):
    pnpm buildfront        # cargo run-script libwasm + builds wallet app + web SDK
    pnpm buildfrontdev     # dev builds incl. sdk/js/web with NG_DEV_LOCAL_BROKER
    
  2. Run the broker ngd: cargo run -p ngd -- -vv --save-key -l 14400 (or use the Docker images in nextgraph-rs/bin/ngd/docker/). It prints an invitation link — open it (swap port 144001421 for the local front-end) to create a local wallet.
  3. Run the local wallet app: cd app/nextgraph && pnpm webdev (serves :1421).
  4. Use the dev-built @ng-org/web (built with NG_DEV_LOCAL_BROKER=1 → redirects to localhost:1421) in this repo — link ../nextgraph-rs/sdk/js/web via pnpm link:/file: instead of the npm version. Then pnpm dev here; the app will redirect to the LOCAL wallet.

Effort: moderate, front-loaded on the first Rust/WASM build (slow/finicky on macOS with LLVM 17). After that it's just three run commands.


Gotchas & notes (hard-won)

  • Pin @ng-org/* to versions that match the deployed wallet. The example apps in nextgraph-rs may lag; an old @ng-org/orm calls renamed WASM methods that the live broker no longer exposes → silent no-persistence. We run the latest alphas (orm alpha.19 etc.). See the version-skew bug above.
  • No codegen. The schema is built at runtime via defineShape (see Dynamic schema above). @ng-org/shex-orm is still a devDependency but only for its Schema/ShapeType/Predicate types. (Historical: its alpha.2 codegen CLI crashed in prettier; alpha.8 worked — moot now that we don't codegen.)
  • date kind → valType: "string" in the schema (the ORM has no dedicated date type), so the due column renders as a plain text input in v1. A date input would need a heuristic (predicate-name match) — deferred.
  • tsconfig is intentionally lenient (verbatimModuleSyntax and erasableSyntaxOnly are OFF) so the copied grid — which uses enums and value-style type imports — compiles unchanged.
  • The grid was written for atomic's React Compiler setup; here it runs without it. Watch for missing-memo perf issues (cheap to add if they surface).
  • react-window@2 Row gets role="listitem"; the grid strips it to use role="row". Don't "fix" that.

Deferred (NOT in v1)

  • User-editable columns — the schema is now dynamic (defineShape), so the remaining work is just the UI (a NewColumnButton + add/edit/remove form that mutates the FieldSpec[]) and deciding where the schema persists (open question): in-app localStorage, or — more Atomic-like — as its own document in NextGraph so the schema travels with the data.
  • Column order/size persistence, CSV export, expanded-row dialog, undo/history, rich relation/markdown/file cells, sorting UI, multi-graph scope selection, date-typed cells. The headless grid already contains hooks for most of these.

Verification checklist (step 5)

  • Table renders one column per shape predicate with correct headers.
  • Edit a cell → reload page → value survives (proves the ORM write committed).
  • Typing in the trailing empty row creates a new row that persists.
  • Arrow-key nav, Enter to edit, Esc to exit, Delete to clear, Ctrl+C/Ctrl+V work.
  • Each datatype edits correctly (text / number / checkbox / enum-select).