fix(diff): converge live projection on compose domains and is_static #69

Merged
dan-claude-bot merged 1 commit from fix/live-projection-compose-static into main 2026-07-15 17:28:57 +00:00
dan-claude-bot commented 2026-07-15 15:47:06 +00:00 (Migrated from github.com)

Against a live Coolify 4.1.2, cast diff/apply never converged on two application fields — they re-diffed and redeployed on every apply. A live probe pinned two distinct root causes; both are fixed here.

Fix 1 — docker_compose_domains parse bug

Coolify 4.1.2 returns this field as a JSON-encoded, service-keyed object ({"api":{"domain":"https://…"},…}), not the [{name,domain}] array cast expected. The array-only parser bailed to undefined, so cast diffed the desired map against nothing on every run. parseDockerComposeDomains now decodes the real object shape into the internal {service: string[]} map, while still tolerating the legacy array shape (the write-side round-trip). Malformed / scalar / empty still collapse to undefined.

Fix 2 — is_static unreadable

Coolify 4.1.2 returns is_static: null on the read path even for a genuinely-static app, so projecting false diffed false → true forever. projectLiveFields now omits is_static when the live value is null/absent; fetchLive flags the application staticNotCompared; computeDiff skips the comparison (emitting a once-per-run warn), degrading is_static to a create-time-only setting — the create path still sends it. A real live boolean is still projected and diffed normally, preserving #63's intent as far as the live API allows.

Tests

  • parseDockerComposeDomains: real object shape → correct map; legacy array shape still parses; malformed → undefined.
  • Idempotency: a compose app whose live docker_compose_domains is the object-shape string matching the manifest yields zero field diff.
  • is_static: live null + manifest static: true → no diff + warn fires once; a real live boolean still diffs normally.

Docs

docs/semantics.md updated for the real read shape of docker_compose_domains and the is_static create-time-only degradation.

Gates: npm run build, npm test (508 passing), npm run check all green.

Closes #68

🤖 Generated with Claude Code

Against a live Coolify 4.1.2, `cast diff`/`apply` never converged on two application fields — they re-diffed and redeployed on every apply. A live probe pinned two distinct root causes; both are fixed here. ## Fix 1 — `docker_compose_domains` parse bug Coolify 4.1.2 returns this field as a JSON-encoded, service-**keyed object** (`{"api":{"domain":"https://…"},…}`), not the `[{name,domain}]` array cast expected. The array-only parser bailed to `undefined`, so cast diffed the desired map against nothing on every run. `parseDockerComposeDomains` now decodes the real object shape into the internal `{service: string[]}` map, while still tolerating the legacy array shape (the write-side round-trip). Malformed / scalar / empty still collapse to `undefined`. ## Fix 2 — `is_static` unreadable Coolify 4.1.2 returns `is_static: null` on the read path even for a genuinely-static app, so projecting `false` diffed `false → true` forever. `projectLiveFields` now omits `is_static` when the live value is `null`/absent; `fetchLive` flags the application `staticNotCompared`; `computeDiff` skips the comparison (emitting a once-per-run warn), degrading `is_static` to a **create-time-only setting** — the create path still sends it. A real live boolean is still projected and diffed normally, preserving #63's intent as far as the live API allows. ## Tests - `parseDockerComposeDomains`: real object shape → correct map; legacy array shape still parses; malformed → undefined. - Idempotency: a compose app whose live `docker_compose_domains` is the object-shape string matching the manifest yields **zero** field diff. - `is_static`: live `null` + manifest `static: true` → no diff + warn fires once; a real live boolean still diffs normally. ## Docs `docs/semantics.md` updated for the real read shape of `docker_compose_domains` and the `is_static` create-time-only degradation. Gates: `npm run build`, `npm test` (508 passing), `npm run check` all green. Closes #68 🤖 Generated with [Claude Code](https://claude.com/claude-code)
dan-claude-bot commented 2026-07-15 16:05:40 +00:00 (Migrated from github.com)

Verify-on-a-live-instance probe

A snippet to double-check the assumptions this PR encodes against a real Coolify 4.1.2 — specifically whether is_static / docker_compose_domains are actually present on the resource (and in what shape), not just what a filtered jq shows. null from jq is ambiguous (key-absent vs present-and-null), so this uses has() to disambiguate and dumps the full object:

set -a; . .coolify/coolify-box.env; set +a          # COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN
H="Authorization: Bearer $COOLIFY_ACCESS_TOKEN"
PUUID=$(curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/projects" | jq -r '.[]|select(.name=="incubator").uuid')

echo "═══ 1. flat GET /applications — key PRESENCE (has_*) vs value ═══"
curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/applications" \
  | jq '.[] | select(.name|test("^(core|landing)$";"i"))
        | {name, build_pack,
           has_is_static: has("is_static"), is_static,
           has_dcd: has("docker_compose_domains"), docker_compose_domains}'

echo "═══ 2. environment_details embed (what fetchLive reads) — same ═══"
curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/projects/$PUUID/prod" \
  | jq '.applications[] | select(.name|test("^(core|landing)$";"i"))
        | {name,
           has_is_static: has("is_static"), is_static,
           has_dcd: has("docker_compose_domains"), docker_compose_domains}'

echo "═══ 3. anything 'static' / 'compose' / 'domain' anywhere (nested too) ═══"
curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/applications" \
  | jq '.[] | select(.name|test("^(core|landing)$";"i"))' \
  | grep -iE 'static|compose|domain'

echo "═══ 4. FULL raw object for core + landing (eyeball every field) ═══"
curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/applications" \
  | jq '.[] | select(.name|test("^(core|landing)$";"i"))'

(Adjust incubator / prod / core|landing for another project.)

Reading it:

  • has_is_static: true + is_static: null → the column exists but the read path returns null → the warn-and-skip in this PR is correct (nothing readable to compare).
  • has_is_static: false → the top-level key is absent; check parts 3 & 4 for a nested is_static (e.g. under a settings relation). If one exists, the better fix is to read it from there rather than skip — worth revisiting before merge.
  • has_dcd: true + docker_compose_domains: "{\"api\":{\"domain\":…}}" → confirms the service-keyed object read shape this PR now parses (vs the array the OpenAPI implies).
### Verify-on-a-live-instance probe A snippet to double-check the assumptions this PR encodes against a real Coolify 4.1.2 — specifically whether `is_static` / `docker_compose_domains` are actually **present** on the resource (and in what shape), not just what a filtered `jq` shows. `null` from `jq` is ambiguous (key-absent vs present-and-null), so this uses `has()` to disambiguate and dumps the full object: ```bash set -a; . .coolify/coolify-box.env; set +a # COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN H="Authorization: Bearer $COOLIFY_ACCESS_TOKEN" PUUID=$(curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/projects" | jq -r '.[]|select(.name=="incubator").uuid') echo "═══ 1. flat GET /applications — key PRESENCE (has_*) vs value ═══" curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/applications" \ | jq '.[] | select(.name|test("^(core|landing)$";"i")) | {name, build_pack, has_is_static: has("is_static"), is_static, has_dcd: has("docker_compose_domains"), docker_compose_domains}' echo "═══ 2. environment_details embed (what fetchLive reads) — same ═══" curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/projects/$PUUID/prod" \ | jq '.applications[] | select(.name|test("^(core|landing)$";"i")) | {name, has_is_static: has("is_static"), is_static, has_dcd: has("docker_compose_domains"), docker_compose_domains}' echo "═══ 3. anything 'static' / 'compose' / 'domain' anywhere (nested too) ═══" curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/applications" \ | jq '.[] | select(.name|test("^(core|landing)$";"i"))' \ | grep -iE 'static|compose|domain' echo "═══ 4. FULL raw object for core + landing (eyeball every field) ═══" curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/applications" \ | jq '.[] | select(.name|test("^(core|landing)$";"i"))' ``` (Adjust `incubator` / `prod` / `core|landing` for another project.) **Reading it:** - `has_is_static: true` + `is_static: null` → the column exists but the read path returns null → the warn-and-skip in this PR is correct (nothing readable to compare). - `has_is_static: false` → the top-level key is absent; check parts 3 & 4 for a **nested** `is_static` (e.g. under a `settings` relation). If one exists, the better fix is to *read it from there* rather than skip — worth revisiting before merge. - `has_dcd: true` + `docker_compose_domains: "{\"api\":{\"domain\":…}}"` → confirms the service-keyed **object** read shape this PR now parses (vs the array the OpenAPI implies).
dan-claude-bot commented 2026-07-15 16:13:16 +00:00 (Migrated from github.com)

Follow-up: is_static is absent top-level — it's on the ApplicationSetting relation (.settings.is_static)

The live probe above returned has_is_static: false on both routes for both apps — i.e. is_static isn't a top-level key at all; Coolify's is_* toggles hang off .settings. So this PR's Fix 2 (raw.is_static == null → warn-and-skip) is reading the wrong path. If .settings.is_static is present on the route fetchLive reads, the better fix is to read it there — a real reconcile (and it'd detect a UI flip), not a degradation.

Probe to confirm — the deciding question is whether the environment_details embed (what fetchLive reads), not just the flat /applications, carries .settings:

set -a; . .coolify/coolify-box.env; set +a          # COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN
H="Authorization: Bearer $COOLIFY_ACCESS_TOKEN"
PUUID=$(curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/projects" | jq -r '.[]|select(.name=="incubator").uuid')

echo "═══ 1. settings.is_static + all settings keys (flat GET /applications) ═══"
curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/applications" \
  | jq '.[] | select(.name|test("^(core|landing)$";"i"))
        | {name, has_settings: has("settings"),
           settings_is_static: .settings.is_static,
           settings_keys: (.settings // {} | keys)}'

echo "═══ 2. FULL .settings object for each ═══"
curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/applications" \
  | jq '.[] | select(.name|test("^(core|landing)$";"i")) | {name, settings}'

echo "═══ 3. same, from the environment_details embed (what fetchLive reads) ═══"
curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/projects/$PUUID/prod" \
  | jq '.applications[] | select(.name|test("^(core|landing)$";"i")) | {name, settings}'

Decides the fix:

  • settings.is_static present and part 3 (the embed) carries .settings → change Fix 2 from warn-and-skip to read raw.settings.is_static (real reconcile, detects UI flips).
  • Only the flat /applications has .settings, the embed does not → fetchLive can't see it without an extra per-app GET; warn-and-skip may stay the pragmatic call.
  • Part 2 shows the other is_* toggles (is_spa, connect_to_docker_network, include_source_commit_in_build) — same top-level-vs-settings trap if cast ever manages them.
### Follow-up: `is_static` is absent top-level — it's on the `ApplicationSetting` relation (`.settings.is_static`) The live probe above returned `has_is_static: false` on **both** routes for **both** apps — i.e. `is_static` isn't a top-level key at all; Coolify's `is_*` toggles hang off `.settings`. So this PR's Fix 2 (`raw.is_static == null` → warn-and-skip) is reading the **wrong path**. If `.settings.is_static` is present on the route `fetchLive` reads, the better fix is to **read it there** — a real reconcile (and it'd detect a UI flip), not a degradation. Probe to confirm — the deciding question is whether the **`environment_details` embed** (what `fetchLive` reads), not just the flat `/applications`, carries `.settings`: ```bash set -a; . .coolify/coolify-box.env; set +a # COOLIFY_BASE_URL + COOLIFY_ACCESS_TOKEN H="Authorization: Bearer $COOLIFY_ACCESS_TOKEN" PUUID=$(curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/projects" | jq -r '.[]|select(.name=="incubator").uuid') echo "═══ 1. settings.is_static + all settings keys (flat GET /applications) ═══" curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/applications" \ | jq '.[] | select(.name|test("^(core|landing)$";"i")) | {name, has_settings: has("settings"), settings_is_static: .settings.is_static, settings_keys: (.settings // {} | keys)}' echo "═══ 2. FULL .settings object for each ═══" curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/applications" \ | jq '.[] | select(.name|test("^(core|landing)$";"i")) | {name, settings}' echo "═══ 3. same, from the environment_details embed (what fetchLive reads) ═══" curl -s -H "$H" "$COOLIFY_BASE_URL/api/v1/projects/$PUUID/prod" \ | jq '.applications[] | select(.name|test("^(core|landing)$";"i")) | {name, settings}' ``` **Decides the fix:** - `settings.is_static` present **and** part 3 (the embed) carries `.settings` → change Fix 2 from warn-and-skip to **read `raw.settings.is_static`** (real reconcile, detects UI flips). - Only the flat `/applications` has `.settings`, the embed does not → `fetchLive` can't see it without an extra per-app GET; warn-and-skip may stay the pragmatic call. - Part 2 shows the other `is_*` toggles (`is_spa`, `connect_to_docker_network`, `include_source_commit_in_build`) — same top-level-vs-`settings` trap if cast ever manages them.
dan-claude-bot commented 2026-07-15 16:24:17 +00:00 (Migrated from github.com)

Updated the branch: the is_static comments, the computeDiff warn, and docs/semantics.md now cite the coolify v4.1.2 source proving is_static is on the never-serialized ApplicationSetting relation (full trace in cast#68). So the warn-and-skip half is source-confirmed correct — not a guess. Behavior unchanged; 508 tests green. Ready for review.

Updated the branch: the `is_static` comments, the `computeDiff` warn, and `docs/semantics.md` now cite the **coolify v4.1.2 source** proving `is_static` is on the never-serialized `ApplicationSetting` relation (full trace in cast#68). So the warn-and-skip half is source-confirmed correct — not a guess. Behavior unchanged; 508 tests green. Ready for review.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/cast#69
No description provided.