Skip to main content
← Back to list
01Issue
BugShippedSwamp CLIPublic
Assigneeshammz

Relationships

#2490 Extension catalog keeps stale rows after install migration and rm; model type describe crashes with ENOENT

Opened by hammz · 9/24/2026· Shipped 9/29/2026

Description

Split from swamp-club#2483 (bug 3 there). It blocks swamp-club#2429.

Stale extension-catalog rows survive an install migration and an extension rm. swamp model type describe then crashes with ENOENT.

Repro (reproduced on 20260924.122519.0)

  1. Filesystem datastore outside the repo. Pull @swamp/aws/cur while managedConfig is still false.
  2. Run swamp datastore config migrate.
  3. Run swamp extension install --json. It reports orphans_pruned, then installed: 1.
    • It re-extracts to <repo>/.swamp/config/pulled-extensions/… and deletes the old .swamp/pulled-extensions/… sources.
    • The catalog is unchanged: the old Indexed row remains.
  4. Run swamp model type describe @swamp/aws/cur/report-definition. It exits 1 with No such file or directory (os error 2): readfile '<repo>/.swamp/pulled-extensions/@swamp/aws/cur/models/report_definition.ts', via bundleExtension (bundle.ts:636) ← ExtensionLoader.recoverMissingBundle ← importBundleByPath ← loadSingleType.
    • A second describe succeeds.
    • The new row under .swamp/config/pulled-extensions has an empty extension_name.
  5. Run swamp extension rm @swamp/aws/cur, then describe again. It exits 1 with ENOENT on the .swamp/config/pulled-extensions/… path, because the empty-identity row survived the rm.

Root causes

  • extension install uses the free-function installExtension with no catalog (create_extension_install_deps.ts ~80-96). Its orphan prune (pull.ts ~1185) deletes sources whose rows stay Indexed. The deletion comes from the orphan prune, not sweepLegacyPaths.
  • deriveExtensionIdentity (derive_extension_identity.ts ~109) only knows .swamp/pulled-extensions/. Rows under .swamp/config/pulled-extensions/ get no identity, so rm's loadByName misses them.
  • registerLazyFromCatalog (extension_loader.ts ~899-924) registers Indexed rows without checking the source. The cold path never removes missing-source rows, because catalog.invalidate only clears a flag.
  • A stale local-origin row makes resolveOriginConflicts clear the new pulled row's type_normalized (extension_catalog_store.ts ~1361-1394). That clear is sticky, so the type can stay unregistered even after the stale row is gone. Catalogs already damaged this way need a one-time heal.
  • datastore config migrate copies the old tree (it does not move it) and copies the lockfile verbatim. Duplicate old-root rows then share (kind, type) with new-root rows, which trips I-Repo-1 on every saveAll (rolling back unrelated pulls) and lets first-wins registration pick the stale row.

Decision already made (on #2483)

Heal on the loader side. extension install stays on the free-function path; the catalog heals itself instead of install being routed through InstallExtensionService.

Design notes from the #2483 adversarial reviews

Findings ADV-2 to ADV-4, 19-21, 30 and 34-37/43-45/65-67 are recorded on the #2483 lifecycle.

  • Identity only.
    • Teach deriveExtensionIdentity the managed prefix, checked before the local /extensions/<kind>/ rule so @org/extensions is not treated as local.
    • Do not change computeExtensionRoot per row. Mixed roots within one name::version would throw SourceExtensionRootMismatch (invariant I1) at startup reconcile, in rm, in install and in the loader.
  • One prune rule, called at the top of resolveOriginConflicts (all kinds, before its hasPulled early return). It covers the loader cold and warm paths and saveAll.
    • It drops rows whose source is missing (NotFound only), or which sit under the inactive pulled root.
    • It honours protectedPaths and exempts only OrphanedBundleOnly.
    • It deletes by raw source path, because canonicalizePath on Windows does not match keys written under WSL.
  • Loader-only scanned-set prune: active-root rows outside every enumerated <root>/<name>/<kind> dir, applied only when the lockfile view is resolved.
  • Heal already-damaged catalogs:
    • add a one-time per-kind heal marker in bundle_meta, not a BUNDLE_LAYOUT_VERSION bump, which evicts every bundle;
    • when findStaleFiles deletes a typed row on the warm path, fall through to the cold path so the shadowed types are re-upserted.
  • Type-load time:
    • check the source exists before importing, whether or not the bundle exists (a surviving bundle silently imports old code, and serve dispatch then fails in bundleSourceFactory);
    • remove stale rows in a loop until a live row appears or none remain, then return quietly;
    • make the vault, datastore, report and webhook registries drop the lazy entry when it is not promoted, as ModelRegistry does;
    • apply the same to extension-kind rows (findExtensionsForType → importAndExtendBundle) and attachPendingExtensionsForType.
  • Serve hot reload: skip and remove missing-source rows before re-bundle and invalidateType.
  • isGhostRow becomes dead code.
  • The auto-resolver's and the loadUserModels fallback's ExtensionRepository use an empty lockfile snapshot, so give them the real one. With #2483 that is the tiered view.

Expected

  • No typed row with a missing source survives an install migration or an rm.
  • A stale row never crashes a command.
  • An already-damaged catalog heals on the first run after upgrading.

Tests

  • Forced cold path and a pre-corrupted warm-path catalog.
  • Fixtures that copy the old tree.
  • gen-1 to current.
  • Per-kind registry cases.
  • describe resolves the type from the new root.
02Bog Flow
✓OPEN✓TRIAGED✓IN PROGRESS✓SHIPPED+ 1 MOREASSIGNED+ 14 MOREREVIEW+ 15 MOREPR_MERGED+ 2 MORESESSION_SUMMARIZED

Shipped

9/29/2026, 4:42:17 PM

Click a lifecycle step above to view its details.

03Sludge Pulse
hammz assigned hammz9/29/2026, 12:44:56 PM

Sign in to post a ripple.