diff --git a/docs/superpowers/specs/2026-05-30-bundle-dependency-compatibility-design.md b/docs/superpowers/specs/2026-05-30-bundle-dependency-compatibility-design.md new file mode 100644 index 0000000000..d68f99d3cb --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-bundle-dependency-compatibility-design.md @@ -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 · ` | only in candidate (cause of incompat) | +| removed | red | `Removed · ` | only in baseline (OTA-safe) | +| unchanged | gray | `Unchanged · ` | 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`). diff --git a/messages/en.json b/messages/en.json index 19116463e4..067e337523 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/src/components/bundle/BundleCompareSelect.vue b/src/components/bundle/BundleCompareSelect.vue index 4569b0db96..82dcd4fc88 100644 --- a/src/components/bundle/BundleCompareSelect.vue +++ b/src/components/bundle/BundleCompareSelect.vue @@ -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) @@ -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> = [] for (const channelId of channelIds) { const cutoff = deployedAtByChannel.get(channelId) let query = supabase @@ -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 @@ -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) @@ -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() + 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)) } diff --git a/src/pages/app/[app].bundle.[bundle].dependencies.vue b/src/pages/app/[app].bundle.[bundle].dependencies.vue index 5a702a0eb2..6d62239a4d 100644 --- a/src/pages/app/[app].bundle.[bundle].dependencies.vue +++ b/src/pages/app/[app].bundle.[bundle].dependencies.vue @@ -1,21 +1,40 @@