Backup schedules were write-only, filed under "known limitations" on the
claim that "live Coolify state doesn't expose it back". The parenthesis was
load-bearing and false: a schedule is not on the database's own GET, but it
was never meant to be — it has its own route, GET /databases/{uuid}/backups,
which cast had been POSTing to all along and had simply never read.
The cost was exact. A database created before its `backup:` block was
declared never got one (apply set the schedule only inside the create
branch); a schedule deleted in the UI was invisible; and the `--full` diff
that gates a production cutover passed with an unbacked-up production
database.
Shape settled from the source rather than the vendored spec, which documents
the body as "Content is very complex. Will be implemented later.":
DatabasesController@database_backup_details_uuid (v4.1.2) returns a raw
Eloquent collection — a JSON array of ScheduledDatabaseBackup rows, columns
per $fillable (uuid, enabled, frequency,
database_backup_retention_amount_locally). `frequency` round-trips verbatim:
the controller validates it and stores $request->only(...) unchanged, with no
mutator on the model. The "diffing it would flag spurious drift" fear was a
guess about a read nobody had performed.
- `backup` becomes a diffed field like any other (resolve.ts), replacing the
side channel that carried it around the diff.
- The live side reads the route (coolify.ts, fetchLive), and apply sets the
schedule on UPDATE as well as create — POST or PATCH, decided by a read.
- A disabled schedule is a row that backs nothing up: neither clean nor
absent. cast diffs it and re-enables it.
Degrades honestly, since no live box was probed: an unreachable or
unrecognized response can only ever produce "declared, NOT compared — verify
in the Coolify UI", never invented drift and never a clean bill on an
unread database. On the write side the same failure raises rather than
guessing — POSTing blind would duplicate a schedule that may already exist.
`diff` could not tell "this project does not exist" from "this project is
empty" — both came back as [] from fetchLive. That is right for `apply` (a
first apply legitimately creates the project and its environment) and quietly
wrong for `diff`: computeDiff(desired, []) means "every desired resource is
missing", rendered as a confident full-create plan. So a diff aimed at a name
that does not exist reported a CLEAN-LOOKING plan that verified nothing.
Same shape of lie as the wrong-team token #10 closed — an unverifiable read
that answers "absent" and invites a create — reached through the project name
instead of the team. It matters more now: with a single Root Team the team
assert can never fire, so it is no longer guarding this class of bug at all.
There are two roads to it, not one. The project name may be wrong, and so may
the environment name: cast names environments after --env, but a project built
by hand in the Coolify UI uses whatever someone typed (Coolify's own default is
`production`, not `prod`). Both are gated.
- fetchLive returns a LiveLookup union, so absence is its own answer rather
than a value that happens to equal "empty". diff refuses (exit 2) and names
what it looked for, where that name came from, and what exists instead;
apply keeps today's tolerant behaviour, which is the whole point of the split.
- --project <name> overrides the repo-derived project name, for an instance
that names it differently. It overrides ONLY that: secrets stay keyed by the
repo, a state-repo convention we own.
#6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is
now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so
existing state files keep working. A short name is unique only *within* an org,
so two orgs' same-named repos collapsed onto one entry and whichever App was
bound there would clone both — silently, because a wrong-but-existing App still
resolves to a real uuid and the create succeeds.
Verified end-to-end against a fake Coolify, driving the real binary: an absent
project refuses (exit 2), --project recovers it (exit 0), an absent environment
refuses (exit 2). 12 new tests; 98 pass; check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>