Skip to content

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.

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

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

{ "formatVersion": 1, "project": { "name": "my-app" } }

Missing, and the CLI asks whether this is a groveback bundle at all.

{
"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 }]
}
{
"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.

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.

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

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 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"]
}
}

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.

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.

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.