Skip to content

The local loop

The workflow Groveback is built around: a real backend on your machine, your project’s shape in git, and a diff you confirm before anything reaches a deployed project.

YOUR REPO THE PROJECT
(committed, reviewed in PRs) (api.example.com)
grove init ──────▶ grove.json ─────────────────────▶ linked
url · project · outDir
grove gen ◀────── <outDir>/grove.ts ◀───────────── live schema
typed client (shape only)
│ imported by your app
┌──────────┐ GROVE_URL=http://localhost:8099
│ your app │ ───────────────────────────────┐
└──────────┘ (env var — no regeneration) │
grove pull ◀────── groveback/ ◀───────────────────── shape │
collections · functions · roles │
│ │
│ applied at boot │
▼ │
grove dev ──────▶ ┌──────────────┐ ◀───────────────────────────┘
│ localhost: │
grove seed ──────▶ │ 8099 │ in-memory · one project
(--seed) │ │ (--persist writes it all back)
▲ │ + dashboard │ key-only dashboard
│ └──────────────┘
seeds/ │
fixtures │ you edit groveback/ while it runs
grove status ─────▶ what would change?
grove push ──────▶ groveback/ ─────────────────────▶ shape updated
(plans first · refuses if the project moved)

Two columns: everything on the left is a file in your repo, everything on the right is the running project. The CLI is what crosses between them.

Local-first collapses the right column — there is no deployed project yet, so grove dev is the only server and groveback/ grows from what you build in it. Add the right column later with grove init and grove push; nothing you built has to change.

What it is Which way it travels
Shape collections, policies, functions, roles, integrations both ways — pull down, push up
Data the actual documents never between projects; local data comes from committed fixtures

That split is deliberate. A config export is shape-only, so there is no path that pulls documents out of production or writes documents into it.

Mirrors the project’s shape onto disk, one file per resource:

groveback/
collections/
posts.json
functions/
ingest.ts ← the real handler, editable in your editor
ingest.json ← everything else; points at the .ts via codeFile
integrations/
roles.json

Functions land as actual .ts files, so a handler is something you edit and test with @groveback/testkit instead of a string field in a JSON blob.

Commit groveback/ — shape changes then show up as a reviewable diff in a pull request.

Two behaviors to know: pull replaces, so a resource deleted upstream is deleted locally; and it refuses to overwrite a bundle with uncommitted changes (--force overrides). Every pull records a sha256 baseline per file in .grove/pull.json, which is how push later detects that someone else moved the project.

Terminal window
grove dev --seed --persist # what you almost always want
grove dev # port 8099, in-memory, nothing loaded or saved
grove dev --mongo mongodb://localhost:27017/dev # real Mongo (indexes, vector search)

--seed and --persist are one pair: load seeds/ on start, write everything back on exit. Alone, --persist saves work you will never see again, and --seed loads fixtures you will lose.

What happens, in order:

  1. The CLI mints a local admin key (gb_sk_<projectId>.<secret>) — it has to exist before the server does, because the server installs it at boot.
  2. It spawns @groveback/server as a separate process. Not embedded: the server is the whole backend (MongoDB, GraphQL, a Bun runtime) shipped as one standalone binary per platform, while the CLI stays plain Node and dependency-free. npm installs only the binary matching your machine.
  3. The server refuses to start in a production posture — a real JWT_SECRET without GROVEBACK_DEV=1 aborts the boot. A known admin key on a production deployment is not a warning-level mistake.
  4. It applies groveback/ to the fresh, empty server.
  5. It writes .grove/ and prints the dashboard URL with ?key=… in it.

It also refuses to start if something already answers /api/v1/health on the port — otherwise everything downstream would be talking to a stranger.

Storage is in-memory by default, so data is gone when you stop. That is the point: your fixtures are the source of truth, not a local database you have been mutating for a month.

--seed --persist makes that painless. On exit, --persist runs both halves of “save my work” — the shape into groveback/, the documents into seeds/ — and --seed loads them back next time:

saved: 1 config file(s), 3 document(s)

Persistence made of files, not a database volume: what you built goes into the repo, and the rest of the team gets it from git.

grove dev prints:

http://localhost:8099/dashboard?key=gb_sk_proj_dev.…#/

There is no login form, because there is nothing to log into: the dashboard’s login goes through the control plane, and a grove dev server has no control plane — it has exactly one project, and the key embeds its id. The dashboard stores the key and strips it from the address bar so it does not linger in history. After the first visit, http://localhost:8099/dashboard#/ is enough.

Sections that need the control plane (Connections, Environments) are hidden rather than left to fail with a 401 you cannot act on.

The Local section shows how your bundle differs from the real project. That comparison is computed by the CLI, not the browser — it needs the project’s admin key, and that key must never reach a web page. grove dev runs the comparison, writes a credential-free snapshot, and the dashboard renders it. Applying stays in the terminal.

Terminal window
grove status # what would change, without changing anything
grove push --dry-run
grove push

Always plans first and asks before applying. A non-TTY stdin answers “no” — silence is never consent.

Three guards worth knowing:

  • It refuses if the project moved since your last pull. Pull records a hash per file; if someone else pushed in the meantime, applying yours would silently clobber theirs. --force overrides.
  • Config import never deletes. Removing a resource from the bundle does not remove it from the project. Deletion is --prune, which names every resource before removing it, and which never offers to delete a collection or a bucket.
  • --dry-run exits 2 when there are changes, so CI can gate on it without parsing output.

Shape comes from groveback/. Data comes from fixtures you commit:

seeds/
posts.json # a JSON array, or NDJSON — the format is sniffed
authors.json
Terminal window
grove seed # load seeds/ into the local project
grove seed --reset # wipe those collections first
grove seed --dump # local documents → seeds/

grove dev --seed --persist does the load and the dump for you at the two moments that matter. These are the manual versions, for when you want one half on its own.

Fixtures keep their id, which makes a seed run idempotent and keeps cross-references between fixtures stable — but it also means seeding a collection that already has them collides. --reset is the way to reload.

--reset deletes documents, so it refuses any target that is not demonstrably a local server. A mistyped --target wiping production is the one catastrophic failure this command could have.

The URL baked into the generated client is always the one in grove.json, never the --target you generated against. Baking localhost:8099 into a committed file would break the app for everyone else on the team.

To point the app at a local server, set an environment variable — no regeneration:

Terminal window
GROVE_URL=http://localhost:8099 npm run dev

Resolution order: an explicit baseUrl option → $GROVE_URL → the URL baked at generation.

Path Commit? Why
grove.json yes No secrets by design.
groveback/ yes The project’s shape, reviewed in PRs.
seeds/ yes Everyone starts from the same data.
<outDir>/grove.ts yes The generated client.
.grove/ no Local state — it is gitignored and writes its own .gitignore.

.grove/ holds the running server and its key (dev.json), the divergence baseline (pull.json) and the dashboard’s diff snapshot (local-status.json). It self-ignores with a .gitignore containing *, so a missing app-level .gitignore can never leak the dev key.