Bundle format
The bundle is a project’s shape projected onto the filesystem, one file per resource, so it reads as a diff in a pull request.
Layout
Section titled “Layout”groveback/ meta.json { formatVersion, project: { name } } roles.json member roles enduser-roles.json end-user roles oauth.json OAuth config (only when present) collections/<name>.json schema, policy, indexes functions/<name>.json metadata + a codeFile pointer functions/<name>.ts the handler source integrations/<name>.json webhooks/<name>.json vectors/<collection>.json keyed by collection, not by a config name buckets/<name>.jsonFiles are sorted by path and written with a fixed key order, so two bundles of the same shape
are byte-identical. That determinism is what keeps git status clean after a no-op pull.
meta.json
Section titled “meta.json”{ "formatVersion": 1, "project": { "name": "my-app" } }Missing, and the CLI asks whether this is a groveback bundle at all.
collections/<name>.json
Section titled “collections/<name>.json”{ "name": "posts", "schema": { "type": "object", "required": ["title"], "properties": { "title": { "type": "string", "maxLength": 200 } } }, "policy": { "read": { "filter": { "ownerId": "$auth.uid" } } }, "indexes": [{ "name": "by_slug", "keys": { "slug": 1 }, "unique": true }]}functions/<name>.json and .ts
Section titled “functions/<name>.json and .ts”{ "name": "ingest", "trigger": { "type": "http", "method": "POST", "path": "/ingest" }, "timing": "post", "codeFile": "ingest.ts", "enabled": true, "timeoutMs": 15000}The .ts sibling holds the handler and always ends in exactly one newline. This split is the
point of the format: a function stops being a JSON string field and becomes a real file you
edit in an editor and test with the testkit.
timeoutMs is read with an in check, not truthiness: explicit null clears the override,
absent means leave it alone.
Flow-backed functions still get their generated code on disk — a reviewer can read it, the testkit can run it — prefixed with:
// GENERATED from flow — edits are ignored on push. Eject in the dashboard to edit code.push recompiles from flow and ignores the file’s contents in that case.
roles.json and enduser-roles.json
Section titled “roles.json and enduser-roles.json”Top-level JSON arrays:
[{ "name": "Content Editor", "permissions": ["data:*:read", "data:posts:update"] }]Per-resource directory files must be JSON objects, and no nesting is allowed inside the directories.
Validation
Section titled “Validation”fromBundle collects every broken file and throws one error listing all of them, so a
bundle is fixed in one pass rather than one error at a time. Resource names are validated as
safe file names — no /, \, . or ...
Round-trip notes
Section titled “Round-trip notes”exportedAt and the server version are stripped on the way out. They are the only
non-deterministic fields, and keeping them would dirty the working tree on every pull of an
unchanged project. Reading a bundle back re-synthesizes placeholders.
Optional sections stay absent when empty, matching how the server emits them.
The single-document form
Section titled “The single-document form”The same content, unsplit, is what GET /api/v1/admin/config-export returns and
POST /api/v1/admin/config-import accepts:
{ "formatVersion": 1, "exportedAt": "…", "groveback": "<server version>", "project": { "name": "acme" }, "collections": [ … ], "roles": [ … ], "enduserRoles": [ … ], "functions": [ … ], "integrations": [ … ], "webhooks": [ … ], "vectors": [ … ], "buckets": [{ "name": "avatars", "access": "public" }], "oauth": { "providers": [{ "provider": "google", "clientId": "…", "enabled": true }], "allowedRedirects": ["https://app.acme.com"] }}What is never in it
Section titled “What is never in it”No data. No documents, no files, no embeddings — only shape. This is what makes it safe to promote between environments.
No secrets. OAuth exports carry clientId and enabled only. Integration secrets, webhook
signing secrets and BYO Mongo URLs never appear. Import rejects any document containing
clientSecret or secretEnc, so a hand-edited commit cannot smuggle one in either.
Two consequences to plan for:
- A newly imported OAuth provider has no secret, and its sign-in flow stays broken until an operator enters one.
- An imported webhook arrives disabled with no secret, and enabling it requires a secret on file.
No connections. The connection registry is credentials, and exports never carry credentials.
Import semantics
Section titled “Import semantics”Merge-upsert by name: create what is missing, update what exists, never delete what is absent. A second import of the same document plans as all-unchanged.
Everything validates before anything applies. Schemas, policies, function-code syntax, role shapes, OAuth entries, duplicate names, plus state-dependent checks like built-in role collisions and HTTP endpoint conflicts — all collected and reported together. Nothing applies if any problem exists.
It is not transactional. Steps apply in dependency order — collections → schemas → policies → indexes → vectors → roles → end-user roles → integrations → functions → webhooks → buckets → oauth. A mid-apply failure leaves earlier upserts in place; re-running converges.
Add ?dryRun=1 to get the plan without applying. Each planned change carries before and
after fragments built from the same redacted views as the export, so a fragment can never
contain a secret.
Deleting
Section titled “Deleting”Import never deletes. grove push --prune does, and only for functions, webhooks,
integrations, roles and end-user roles. Collections and buckets hold data and are never
pruned; vector configs and OAuth providers attach to a collection or a provider slot, so they
are excluded too.