Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# Bundle Dependency Compatibility View — Design

Date: 2026-05-30
Status: Approved for implementation
Related PR (precursor): #2373 (fixed empty compare picker by gating on `native_packages`)

## Problem

The bundle **Dependencies** tab (`src/pages/app/[app].bundle.[bundle].dependencies.vue`)
can compare two bundles, but it only shows a **version diff** ("changed" vs
"unchanged" counts, and lists only changed packages). It does not tell the user
the thing that actually matters: **is shipping this bundle over-the-air (OTA) safe,
or does it require an app-store update?** — which is exactly what the CLI
`capgo bundle compatibility` command answers.

This design upgrades the tab to:
1. Show every package with an explicit, color-coded status (changed / added /
removed / unchanged), each with the relevant version(s).
2. Show a CLI-grade **compatibility verdict** banner (compatible ✅ / not
compatible ❌ + reasons).

## Direction (decided)

The verdict is directional. The **viewed bundle is the candidate update**; the
bundle chosen in "Compare with bundle" is the **installed baseline**. The verdict
answers: *"Can devices running the compare (baseline) bundle safely receive the
viewed (candidate) bundle over-the-air?"* This matches the CLI semantics
(`local` = candidate being uploaded, `remote` = what's already deployed).

Implication: a **newly added** native plugin in the candidate is the cause of
incompatibility (the installed app lacks that native code → needs app-store
update). A **removed** plugin is OTA-safe.

## Data model

`app_versions.native_packages` is `jsonb[]`; each entry:

```ts
interface NativePackage {
name: string
version: string
ios_checksum?: string
android_checksum?: string
}
```

Confirmed against prod data: entries can carry `ios_checksum` / `android_checksum`
(not just version). The verdict uses these — a version match with a changed native
checksum is still incompatible (native code changed).

## Compatibility algorithm (ported from CLI `getCompatibilityDetails`)

For each package, compare candidate (`local`) vs baseline (`remote`):

- **No candidate version (removed):** compatible. "Package only exists on baseline
(will be removed)" — OTA-safe.
- **Candidate present, no baseline version (added/new plugin):** **incompatible**,
reason `new_plugin` — requires app-store update.
- **Both present:**
- version ranges don't intersect (`@std/semver` `parseRange` + `rangeIntersects`)
→ reason `version_mismatch`.
- `ios_checksum` differs (both present) → reason `ios_code_changed`.
- `android_checksum` differs (both present) → reason `android_code_changed`.
- both checksums differ → reason `both_platforms_changed`.
- no reasons → compatible.

Overall verdict = incompatible if **any** package is incompatible.

This logic lives in a new shared util so the page stays thin and the logic is
unit-testable: `src/services/bundleCompatibility.ts`.

## Per-package status + color scheme (decided)

| Status | Color | Display | Meaning |
|---|---|---|---|
| changed | blue | `old → new` | present in both, version differs |
| added | green | `New · <version>` | only in candidate (cause of incompat) |
| removed | red | `Removed · <version>` | only in baseline (OTA-safe) |
| unchanged | gray | `Unchanged · <version>` | present in both, identical version |

- When no compare bundle is selected, fall back to current behavior: a plain list
of the viewed bundle's packages (status concept doesn't apply with one bundle).
- With a compare bundle selected: list **all** packages (changed first, then
added/removed, then unchanged), each with a colored pill + left-border accent.
- Counts row: Changed / Added / Removed / Unchanged / Total.

Color tokens follow existing Tailwind/DaisyUI usage already in the file
(blue-100/800, emerald, red, slate for gray), dark-mode variants included.

## Compatibility verdict banner (decided)

Shown only when a compare bundle is selected (a verdict needs a baseline):

- ✅ **Compatible** (green banner): "This bundle can be delivered over-the-air to
devices running {baseline}."
- ❌ **Not compatible** (red banner): "{n} package(s) require an app-store update."
followed by the offending packages and their reason text (new native plugin /
version change / iOS or Android native code changed), reusing the CLI's reason
→ message mapping.

Note on the green-added / red-verdict tension: an added package shows green in the
diff (it *was* added) but is named in the red verdict banner as a cause of
incompatibility. The banner lists offending packages by name so the two readings
never contradict.

## Components / files

- **New:** `src/services/bundleCompatibility.ts` — pure functions:
- `NativePackage`, `CompatibilityReason`, `PackageComparison` types
- `comparePackages(candidate, baseline)` → `PackageComparison[]` (status + reasons + versions)
- `summarizeCompatibility(comparisons)` → `{ compatible, incompatibleCount, offenders }`
- Uses `@std/semver` `parseRange` + `rangeIntersects` (same as CLI).
- **Edit:** `src/pages/app/[app].bundle.[bundle].dependencies.vue` — replace the
diff-only computeds + table with status-aware rendering and the verdict banner.
Keep existing data fetching, `BundleCompareSelect`, caching, request-id guards.
- **i18n:** add keys to `messages/en.json` (source of truth; `fallbackLocale: 'en'`
so other locales fall back gracefully — no need to translate 15 files in this PR).

## Out of scope

- No backend/schema/API changes.
- No changes to the Manifest tab.
- Not wiring the verdict into the channel-set flow (that already has its own toast).

## Test plan

- Unit tests for `bundleCompatibility.ts`: added/removed/changed/unchanged,
version-range intersect, checksum-only change, new-plugin incompatibility,
overall verdict aggregation.
- Manual: on `me.wcaleniewolny.test.ionic.vue2`, view `1.0.7-c`, compare with
`0.0.0`:
- 3 changed rows (blue, `8.3.4 → 8.1.0` ×2, `8.46.1 → 8.45.9`).
- Verdict ❌ Not compatible (version mismatches + updater checksum change).
- Manual: compare two identical bundles → all gray unchanged, verdict ✅.
- Dev server points at **prod DB** for manual testing (plain `vite`, default branch
→ prod config; do NOT use `serve:dev`).
24 changes: 18 additions & 6 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -888,22 +888,34 @@
"demo-select-role": "Select a role",
"demo-teleport-desc": "This input is teleported into the dialog content area",
"demo-text-placeholder": "Enter your text here...",
"compat-reason-android-changed": "Android native code changed (requires app store update)",
"compat-reason-both-changed": "iOS and Android native code changed (requires app store update)",
"compat-reason-ios-changed": "iOS native code changed (requires app store update)",
"compat-reason-new-plugin": "New native plugin (requires app store update)",
"compat-reason-removed-plugin": "Plugin removed",
"compat-reason-version-mismatch": "Native version changed (requires app store update)",
"compat-verdict-compatible": "Compatible",
"compat-verdict-compatible-detail": "This bundle can be delivered over-the-air to devices running {bundle}.",
"compat-verdict-incompatible": "Not compatible ({count})",
"compat-verdict-incompatible-detail": "Some packages require an app store update, so this bundle cannot be delivered over-the-air.",
"dependencies": "Dependencies",
"dependencies-changed-packages": "Changed packages",
"dependencies-added-packages": "Added",
"dependencies-changed-packages": "Changed",
"dependencies-compare-label": "Compare with bundle",
"dependencies-compare-latest": "Latest bundles",
"dependencies-compare-none": "All packages (no comparison)",
"dependencies-compare-note": "Only changed dependencies are listed.",
"dependencies-compare-results": "Search results",
"dependencies-diff-empty": "All {unchanged} dependencies are identical.",
"dependencies-no-changes": "No dependency changes",
"dependencies-removed-packages": "Removed",
"dependencies-status-added": "Added",
"dependencies-status-changed": "Changed",
"dependencies-status-compare-empty": "We could not find any dependencies for {bundle}.",
"dependencies-status-diff": "Showing {count} dependencies that differ from {bundle} ({unchanged} unchanged).",
"dependencies-status-full": "Showing all dependencies for this bundle.",
"dependencies-status-removed": "Removed",
"dependencies-status-unchanged": "Unchanged",
"dependencies-summary-packages": "Packages",
"dependencies-summary-versions": "Unique versions",
"dependencies-total-packages": "Total packages",
"dependencies-unchanged-packages": "Unchanged packages",
"dependencies-unchanged-packages": "Unchanged",
"deploy-confirm": "Deploy",
"deploy-date": "Deploy Date",
"deploy-default-channels-help": "Select the default channels that should receive this bundle.",
Expand Down
48 changes: 34 additions & 14 deletions src/components/bundle/BundleCompareSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,23 @@ function resetSearchState() {
function selectCompareVersion(option: VersionRow | null) {
resetSearchState()
emit('update:modelValue', option)
// The DaisyUI dropdown is CSS-only (opens on :focus-within), so blur the
// active element to close it after a selection.
if (typeof document !== 'undefined' && document.activeElement instanceof HTMLElement)
document.activeElement.blur()
}

// The manifest tab compares per-file manifest entries (manifest_count), while the
// dependencies tab compares native_packages. Only offer bundles that actually carry
// the data the current tab diffs on, so gate the candidate list per mode.
// the data the current tab diffs on, so gate the candidate list per mode. Deleted
// bundles are excluded — their storage may be gone (and is purged after 90 days),
// so they are not meaningful comparison targets.
function buildCompareBaseQuery() {
const query = supabase
.from('app_versions')
.select('id, name, created_at, manifest_count, app_id')
.eq('app_id', props.appId)
.eq('deleted', false)
return props.compareMode === 'dependencies'
? query.not('native_packages', 'is', null)
: query.gt('manifest_count', 0)
Expand Down Expand Up @@ -164,7 +171,12 @@ async function loadPreferredCompareVersions() {
if (channelIds.size === 0)
return

const preferredHistory: Array<{ versionId: number, deployedAt: string | null }> = []
// Fetch several recent prior deployments per channel (not just one): if the
// most recent points to a now-deleted bundle, the deleted filter below would
// otherwise leave the channel with no baseline. Keeping a small lookback lets
// us fall back to the next older non-deleted deployment instead.
const PREFERRED_LOOKBACK = 20
const candidatesByChannel: Array<Array<{ versionId: number, deployedAt: string | null }>> = []
for (const channelId of channelIds) {
const cutoff = deployedAtByChannel.get(channelId)
let query = supabase
Expand All @@ -179,7 +191,7 @@ async function loadPreferredCompareVersions() {

const { data, error } = await query
.order('created_at', { ascending: false })
.limit(1)
.limit(PREFERRED_LOOKBACK)

if (requestId !== preferredCompareRequestId)
return
Expand All @@ -189,19 +201,18 @@ async function loadPreferredCompareVersions() {
continue
}

const entry = (data ?? [])[0] as DeployHistoryRow | undefined
if (!entry)
continue
preferredHistory.push({
const candidates = ((data ?? []) as DeployHistoryRow[]).map(entry => ({
versionId: entry.version_id,
deployedAt: entry.created_at ?? entry.deployed_at ?? null,
})
}))
if (candidates.length)
candidatesByChannel.push(candidates)
}

if (!preferredHistory.length)
if (!candidatesByChannel.length)
return

const uniqueIds = [...new Set(preferredHistory.map(entry => entry.versionId))]
const uniqueIds = [...new Set(candidatesByChannel.flat().map(entry => entry.versionId))]
const { data: versions, error } = await buildCompareBaseQuery()
.in('id', uniqueIds)

Expand All @@ -214,11 +225,20 @@ async function loadPreferredCompareVersions() {
}

const versionMap = new Map((versions ?? []).map(version => [version.id, version]))
const sorted = preferredHistory
.filter(entry => versionMap.has(entry.versionId))
// Per channel, keep the most recent deployment whose bundle survived the
// deleted filter (candidates are already ordered newest-first).
const seen = new Set<number>()
preferredCompareVersions.value = candidatesByChannel
.map(candidates => candidates.find(entry => versionMap.has(entry.versionId)))
.filter((entry): entry is { versionId: number, deployedAt: string | null } => Boolean(entry))
.sort((a, b) => (b.deployedAt ?? '').localeCompare(a.deployedAt ?? ''))

preferredCompareVersions.value = sorted
.filter((entry) => {
// Dedupe: the same bundle can be the surviving pick for multiple channels.
if (seen.has(entry.versionId))
return false
seen.add(entry.versionId)
return true
})
.map(entry => versionMap.get(entry.versionId))
.filter((version): version is VersionRow => Boolean(version))
}
Expand Down
Loading
Loading