Tooling

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
koolbase/seeds/manifest.json
json
{
  "version": 1,
  "collections": [
    {
      "name": "content_categories",
      "file": "content_categories.json",
      "key": ["code"]
    }
  ]
}
koolbase/seeds/content_categories.json
json
{
  "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 unchanged

Re-applying is the point

Koolbase records what it last applied for each row, so a second run reports 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-existing

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