Reference Data
Seed the fixed rows your application's logic depends on — category enums, country lists, plan definitions — from a file you commit, applied to any project and safe to re-run.
Why this is separate from Promote
Promote copies a project's structure and deliberately carries no records. That is right for user data — you do not want one project's users appearing in another.
But some rows are not user data. If a Function validates an article's category_code against a content_categories collection, a cloned project with an empty categories table is structurally complete and functionally dead: every write fails on an unknown category. Those eight rows are as much a part of the schema as the collection holding them.
Seed files close that gap and stay a separate artifact on purpose. A snapshot describes a project; a seed file declares intended data. Neither can quietly become the other, and no setting on a collection can turn a structure export into a data export.
The files
One manifest, one file per collection. Separate files rather than one large one so diffs stay readable and collections can be owned by whoever owns that part of the app.
koolbase/
seeds/
manifest.json
content_categories.json
country_config.json{
"version": 1,
"collections": [
{
"name": "content_categories",
"file": "content_categories.json",
"key": ["code"]
}
]
}{
"collection": "content_categories",
"key": ["code"],
"rows": [
{ "code": "pregnancy", "display_name_key": "content.categories.pregnancy", "display_order": 10 },
{ "code": "nutrition", "display_name_key": "content.categories.nutrition", "display_order": 20 }
]
}Rows carry whatever fields the collection holds. Only the fields named in key identify a row; the rest are values. The key is repeated in both files so a seed file stays self-describing if it travels alone — a mismatch between the two is reported rather than silently resolved.
Identity: the declared key
Re-applying a seed file has to update the same rows rather than duplicate them, so every row needs a stable identity. You declare which fields carry it, and Koolbase checks that those fields have a real unique constraint on the collection before applying anything.
A project set up with snapshot apply already has the source project's unique constraints: they travel with the snapshot, so there is nothing to declare. On any other project, declare them first:
No unique constraint, no seeding
Without one, the same key could match several records and each apply would update an arbitrary one — silently, and differently on different runs. Declare the constraint first:
koolbase unique-constraints declare content_categories \
--fields code --project <project-id>For a composite key, list the fields together — the constraint must cover exactly the fields the seed file declares:
koolbase unique-constraints declare memberships \
--fields tenant,code --project <project-id>Composite keys are ordinary — ["tenant", "code"] — and must match a constraint on exactly those fields. A constraint on (tenant, code) does not make code unique on its own, so it will not satisfy a key of just code.
Key fields must be strings, numbers, or booleans. An object or array as identity would make uniqueness depend on serialisation order, which is not a stable identity.
Three commands
Inspection and mutation are separate on purpose. Only apply writes.
# Structural checks. Never contacts the server.
koolbase seed validate
# What would change in this project. Writes nothing.
koolbase seed plan --project <project-id>
# Make it so.
koolbase seed apply --project <project-id>Add --collection <name> to work on one collection at a time, and --verbose to list every row rather than only those needing attention.
content_categories: 8 create
$ koolbase seed apply --project <id>
content_categories: 8 create
$ koolbase seed plan --project <id>
content_categories: 8 unchangedRe-applying is the point
unchanged and writes nothing. That is what makes this safe to run on every deploy rather than once by hand.When things have moved
Because Koolbase remembers what it applied, it can tell which side changed — and that distinction decides what is safe to do:
update — you changed the file
The row is untouched in the project. Applied by default; this is the ordinary case for editing a seed file.
drifted — the project changed
Someone edited the row directly. The file asserts nothing new, so re-applying it would revert a deliberate edit. Reported and left alone.
conflict — both changed
The one case where no default is safe. Refused until you decide, with the differing fields and both values shown.
adopt — the row was already there
A record matching the key that seeding did not create. Applying would take ownership of it, so it asks first.
content_categories: 7 unchanged · 1 conflict
! pregnancy
changed in both the seed file and the target: display_order
display_order: file=10 target=5# the project's version wins
koolbase seed apply --project <id> --on-conflict=keep-existing
# the file's version wins
koolbase seed apply --project <id> --on-conflict=overwrite --force-conflicts
# take ownership of rows that already existed
koolbase seed apply --project <id> --adopt-existingWhy overwrite needs two flags
--on-conflict=overwrite covers rows where only the file moved — nothing is lost. Overwriting a row that also changed in the project discards a deliberate edit, permanently, so it needs saying separately with --force-conflicts. One flag would make those two situations indistinguishable at the point of typing them.What apply does and does not do
- Updates merge. A live record may carry fields your seed file never mentions — a counter, a column added later. Those survive; only the fields you assert are written.
- Nothing is deleted. Removing a row from a seed file does not remove it from the project. Absence means "no longer asserted", not "delete this".
- Rows outside the file are untouched. Records your seed file says nothing about are left exactly as they are.
- Schema rules still apply. Seeding writes through the same validation as any other write: required fields, types, and unique constraints all hold.
Fix the project rather than editing a seed file to match it. A hand-edited artifact stops describing anything real, and the next person to read it has no way to know.
In CI
Seeding needs a write-scoped API key; --on-conflict=overwrite needs admin, since it discards data in the target.
- run: koolbase seed plan --project ${{ vars.KOOLBASE_PROD_PROJECT_ID }}
- run: koolbase seed apply --project ${{ vars.KOOLBASE_PROD_PROJECT_ID }}Both exit non-zero when anything needs a decision, so a pull request that would conflict fails the build rather than merging and surprising someone later.