Schema sync lets you describe your lists and their indexes in a file, keep that file in your repository, and apply it to your account. JSONPad works out what's different, then creates and updates lists and indexes until the account matches. That makes it easy to set up the same lists in a new account or environment, review structural changes in a pull request, and apply them as part of a deploy.
A sync creates and updates. It only deletes lists and indexes you've removed from the file if you ask it to prune.
A schema document is a JSON file, usually called jsonpad-schema.json. Lists are keyed by their path name, and each list's indexes are keyed by their path name within the list, so the file doesn't contain any ids and works the same in any account.
{$schema: "https://jsonpad.io/schema/sync-v1.json"scope: "recipe-app"lists: {recipes: {name: "Recipes"description: "Every recipe in the app"indexable: truerealtime: trueschema: {type: "object"properties: {slug: {type: "string"}title: {type: "string"}minutes: {type: "number"}}required: [0: "slug"1: "title"]}indexes: {slug: {name: "Slug"pointer: "/slug"alias: true}minutes: {name: "Cooking time"pointer: "/minutes"valueType: "number"sorting: truefiltering: true}}}ratings: {name: "Ratings"indexable: true}}}$schema is optional. It points editors at the document's JSON schema, which gives you autocomplete and validation as you type.scope is optional. See scopes.name, description, tags, schema, rules, rulesTests, readonly, realtime, protected, indexable and indexes, and an index can set name, description, tags, pointer, valueType, alias, sorting, filtering, searching, guard and defaultOrderDirection. They mean the same as they do when you create a list or create an index."schema": null or "tags": []. Likewise, lists and indexes that aren't in the file are left alone, unless you prune.pinned and activated can't be synced, and any other unknown field is refused, so a typo like "realtme" is caught rather than ignored. Lists and indexes that a sync creates are always activated.pointer when the sync creates it.A list's write rules sync like anything else. rules is the rule text, and rulesTests is its test document. Because rules are many lines, rules also accepts an array of lines, which is what exporting writes, so the file stays readable in a diff.
The rules are compiled and their tests are run before anything is applied. A rule set that doesn't compile, or whose tests fail, fails the whole sync — nothing is applied, as with any other error.
The command line tool adds two fields of its own, rulesFile and rulesTestsFile, which point at files beside the document. It reads them, checks and tests the rules locally before sending anything, and shows rule changes as a line diff. The API refuses those two fields, because only the tool knows where your files are.
A document can declare flows too, in a flows object keyed by the flow's name. Each has the flow document, optionally its tests (which must pass, as for rules), and optionally whether it's activated. The command line tool reads flowFile and flowTestsFile from beside the document instead.
Flows are matched by name, and the lists a flow uses can be declared in the same document. Where the nodes sit in the editor (the document's layout) isn't compared, and the layout you've arranged in the dashboard is kept when a flow is updated. A document without a flows object doesn't touch flows at all, even when pruning; one with "flows": {} prunes the flows its scope manages. Syncing flows needs you, or a token that is allowed everything, because a flow runs with your full privileges.
Send the document to POST /sync-schema, or use the command line tool.
npx @basementuniverse/jsonpad-cli sync-schema --dry-run
npx @basementuniverse/jsonpad-cli sync-schemaThe response describes every list and index in the document, and what the sync did (or, in a dry run, would do) to it:
{syncId: "6f0e3e1c-9a53-4b53-9d0b-3e0c7bbd8a41"dryRun: trueapplied: falsescope: "recipe-app"summary: {create: 1update: 1adopt: 0noChange: 2error: 0builds: 1}changes: [0: {resourceType: "list"list: "recipes"listId: "1bbd0b1e-4b3a-4c52-8f2a-0b8b0a2b5d0e"action: "update"fields: {description: {from: "Recipes"to: "Every recipe in the app"}}}1: {resourceType: "index"list: "recipes"index: "minutes"action: "create"fields: {pointer: {from: nullto: "/minutes"}valueType: {from: nullto: "number"}}build: {reason: "created"items: 1240requiresConfirmation: false}}]blockedBy: null}Each change's action is create, update, adopt (see scopes), no-change or error. Updates list each field that changes, with its current and new values. Changes can also have warnings, e.g. that existing items haven't been re-validated against a list's new schema.
Add ?dryRun=true (or --dry-run) to see what a sync would do without changing anything. Running a dry run in a pull request, and the real sync when it's merged, means structural changes get reviewed like any other change.
Every change is checked against the same rules as the equivalent API request: plan limits, reserved path names, schema validity, alias and guard index rules, readonly and locked lists, and the token's permissions. If any change would be refused, the whole sync is refused with a 422 response and nothing is written. The response still contains the plan, with an errors array on each change that was refused, so you can see everything that needs fixing at once.
Creating an index, or changing an index's pointer, builds it in the background (see index builds). While an index is being rebuilt, filtering, ordering and alias lookups that use it are refused, so changing the pointer of an index in a list that already has items could break an app that relies on it.
A sync that would do that is refused unless you add ?allowRebuild=true (or --allow-rebuild). Creating a new index doesn't need it, because nothing can be using the index yet. The command line tool's --wait option waits for builds to finish, and fails if one of them does.
A sync never retries an index whose last build failed, because a build that failed because of the data in the list would only fail again on every deploy. The plan warns about it instead. Fix the problem, then rebuild the index.
If you use JSONPad for more than one app, give each app's document a scope, e.g. the app's name. A scope does three things:
#recipe-app.adopt, so a dry run shows when that's about to happen.Deleting a list or an index also removes the record of which scope managed it.
When you remove a list or an index from a scoped document, a normal sync leaves it alone. Add ?prune=true (or --prune) to delete it too. Pruning needs a scope, because a sync only ever deletes what its scope manages:
npx @basementuniverse/jsonpad-cli sync-schema --prune --dry-run
npx @basementuniverse/jsonpad-cli sync-schema --prune --allow-destructiveIn continuous integration, pass --prune to the pull request's dry run as well as to the real sync, so the check shows what would be deleted.
A list or index is only deleted if all of these are true, and anything else is left alone:
A list's indexes are only pruned if its definition has an indexes key, so a definition that only sets a list's own fields never deletes its indexes. With "indexes": {}, every index the scope manages in that list is deleted. Indexes that were added to a managed list by hand are never pruned.
Deleted lists and indexes show up in the plan with the delete action, so a dry run shows exactly what a prune would delete. The same rules as deleting them with the API apply: readonly lists, and protected lists that have items, can't be deleted, and a token needs permission to delete each list and index.
Deleting a list that has items deletes the items for good, and deleting a guard index makes the values it hides visible to API tokens. A prune that would do either is refused unless you add ?allowDestructive=true (or --allow-destructive). Deleting an alias index doesn't need it, but the plan warns that item URLs using its values will stop working.
A prune that would delete every list a scope manages, because the document declares no lists at all, is always refused. That stops an empty or wrong file from wiping out a scope. To remove a scope's lists on purpose, release them and delete them yourself.
POST /sync-schema/move (or jsonpad move-lists) changes which scope manages a list, without deleting and recreating it:
# Move lists to another scope (or assign lists that no scope manages)
npx @basementuniverse/jsonpad-cli move-lists recipes ratings --to cookbook-app
# Rename a scope
npx @basementuniverse/jsonpad-cli move-lists --from-scope recipe-app --to cookbook-app
# Stop managing a list with schema sync
npx @basementuniverse/jsonpad-cli move-lists recipes --releaseTo move a list from one app's document to another's, move the list first, then move its definition from one file to the other. If you sync the new file first, the sync is refused with an ownership conflict that tells you to move the list. The old document can't prune a list once it's been moved, so it doesn't matter which app deploys first.
You don't have to write a document by hand for lists you already have. GET /sync-schema exports existing lists and their indexes as a document, with every field written out, so syncing it straight back changes nothing. You can export a scope's lists, lists with certain tags, or lists by path name.
For example, to start managing the lists you've tagged recipe-app with schema sync:
npx @basementuniverse/jsonpad-cli export-schema --tagged recipe-app --out jsonpad-schema.json
# Add "scope": "recipe-app" to jsonpad-schema.json, then:
npx @basementuniverse/jsonpad-cli sync-schema --dry-run
npx @basementuniverse/jsonpad-cli sync-schemaLists that a document couldn't sync, e.g. lists without a path name, are left out of the export with a warning.
The command line tool, jsonpad, has a command for each of these endpoints. Run it with npx @basementuniverse/jsonpad-cli without installing anything, or install it globally or in your project. It needs Node.js 22.12 or later, and in CI it reads your API token from the JSONPAD_TOKEN environment variable. Its page covers installing it, profiles, and everything else it can do.
# Sync jsonpad-schema.json (or pass a different file)
jsonpad sync-schema [file] [--dry-run] [--allow-rebuild] [--prune] [--allow-destructive] [--wait] [--show-unchanged] [--json]
# Write a document for existing lists
jsonpad export-schema [--scope recipe-app] [--tagged recipe-app] [--lists recipes,ratings] [--out file]
# Move lists to another scope, or release them from their scope
jsonpad move-lists [list...] (--to cookbook-app | --release) [--from-scope recipe-app] [--dry-run] [--json]
# Rebuild an index whose last build failed
jsonpad rebuild-index <list> <index> [--wait]sync-schema exits with 0 when the sync was applied (or a dry run found nothing wrong), 1 when a change has errors, 2 when the sync needs --allow-rebuild, 3 when an index build fails while waiting, and 4 when the sync needs --allow-destructive. A dry run exits the same way the real sync would, so it can fail a pull request check.
For example, with GitHub Actions, and your token stored as a repository secret:
name: Sync JSONPad schema
on:
pull_request:
push:
branches: [main]
jobs:
sync:
runs-on: ubuntu-latest
env:
JSONPAD_TOKEN: ${{ secrets.JSONPAD_TOKEN }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Check the schema
if: github.event_name == 'pull_request'
run: npx @basementuniverse/jsonpad-cli sync-schema --dry-run
- name: Sync the schema
if: github.event_name == 'push'
run: npx @basementuniverse/jsonpad-cli sync-schema --waitA token needs the sync-schema permission to sync, export or move a schema. It can read the structure of every list (not its items) with that permission alone. Each change a sync makes also needs the permission the equivalent request would need, e.g. permission to create lists, to update a particular index, or to delete a list a prune removes, so a token can't use a sync to change a list it isn't otherwise allowed to change. Moving a list needs permission to update it.
Every change a sync makes is recorded in the event log as a normal list-created, index-updated (etc.) event, with the sync's syncId and scope in its attachments. The index-built and index-build-failed events for builds that a sync started carry the syncId too. Pruned lists and indexes are logged as list-deleted and index-deleted events with pruned: true, and a move logs a list-updated event for each list, with the moveId and the scope it moved from and to.