Skip to content

Release 0.4.0#571

Merged
Papyszoo merged 105 commits into
mainfrom
version/0.4
Jul 12, 2026
Merged

Release 0.4.0#571
Papyszoo merged 105 commits into
mainfrom
version/0.4

Conversation

@Papyszoo

Copy link
Copy Markdown
Owner

Release 0.4.0 — merges version/0.4 into main.

Note: release-prep PR #570 (version bump to 0.4.0) must be merged into version/0.4 before this PR — this diff picks it up automatically.

Highlights

Unified category sidebar across all asset types (#562, #563, #564)

  • Models, texture sets, environment maps, sounds and sprites all use the standard CategoryTreePanel sidebar with inline category management (create/rename/delete via context menu), drag-to-assign, and true count badges.
  • Recursive branch deletes cascade to Uncategorized; server-side category scoping restored; legacy Manage Categories dialogs removed.

Backend: unit of work + save-pipeline domain events (#568)

  • All repositories stop self-committing; command handlers commit exactly once via IUnitOfWork — multi-repo handlers are now atomic (proven against real Postgres).
  • Domain events dispatch from the save pipeline after successful commit; manual dispatch sites deleted. Architecture fitness test fails the build on any reintroduced repository self-commit.

WebDAV: duplicate names + Blender-save safety (#565)

  • Duplicate asset names allowed by default with id-suffix disambiguation in listings; renames and create-from-FileId endpoints route through the duplicate-name policy.
  • Unresolvable Blender saves are quarantined (uncancellable) instead of deleted; missing blobs 404 loudly; bounded retention for Blender temp/orphan directories; generated-{name}.blend gated on a real cache and generated in background (fix(worker): delete dead legacy pipeline and cancel job timeouts #567 follow-ups included).

Backup trust hardening (#566)

  • Automatic backup before applying pending migrations; nightly restore drill; archive excludes webdav-blend-temp while keeping orphans.

Design system & frontend

  • Design tokens and shared primitives aligned to the app's compact density; Storybook gallery with light/dark switcher; visual-regression suite restored.
  • Shared feedback states and standard ListToolbar adopted on all list surfaces; legacy textureSets tab type removed via persisted-state migration.

Demo

Worker

Docs & CI

Verification

  • Required CI (backend / frontend / asset-processor unit tests) green on every PR into version/0.4.
  • PR-lane E2E green on the constituent PRs; local full-suite runs during the UoW and category work.

Papyszoo added 30 commits July 3, 2026 20:06
Runs the docs feature-video generation locally the same way CI does, but on a
real GPU: silent leading teardown (crash leftovers), e2e stack up, docs/videos
deps installed if missing, generate all 8 videos into docs/static/videos/
(gitignored — inspection only), then teardown; exit code reflects generation.
Verified end-to-end: 8/8 videos produced, clean teardown, exit 0.
chore: add videos:generate script for one-command local video checks
… video-authoring skill

- trim-videos: only frozen tails are trimmed; the manifest cap is a QA
  gate in analyze-videos (with actionable failure hint), not a trim point
  — cap-trimming shipped texture-sets cut mid-demo at exactly 20s
- manifest caps raised to realistic ceilings with CI headroom
- helpers: synthetic cursor overlay (dot + click ripple) injected via
  navigateTo — Playwright screencast renders no pointer, so all mouse
  motion was invisible
- texture-sets spec: prewarm the EXR preview off-camera, replace the
  slow-motion canvas drag with a geometry-switch payoff (software-GL
  render loop delays each synthetic input step by ~2s)
- new video-authoring skill (pipeline, choreography rules, cap
  semantics, selector dependencies), registered in CLAUDE.md
Checks docs claims against sources of truth: format lists vs the
FileType extension registry, user-interface.md tab coverage vs the
TabType union (with reasoned exemptions for disabled features),
quick-start URLs vs FRONTEND_PORT, and video embeds vs the video
manifest. README format check starts as a warning until the README
refresh PR merges. Registered in CLAUDE.md verification + ripples.
The deployed Storybook lives under the /Modelibr/storybook/ sub-path on
GitHub Pages, but initialize() registered the service worker at the origin
root (/mockServiceWorker.js), which 404s there and fails registration with
"[MSW] Failed to register the Service Worker". Pass a relative worker URL so
MSW resolves it against the iframe location, finding the worker under whatever
base Storybook is served from. Works unchanged in dev (served at the root).
fix(videos): never trim content to the duration cap; cursor overlay; video-authoring skill
…egory selection highlights

PrimeReact Tree in selectionMode="single" expects selectionKeys to be the
selected key string; the { key: true } map form is only for multiple/checkbox
modes, so the active category never received its p-highlight tint. Regression
test renders the real Tree and asserts the highlight.
…p's compact density

- tokens.css: document real-app roles per scale step; drop the unused
  --mod-text-3xl (1.5rem is the deliberate heading ceiling)
- ListHeader: replace website-band padding (32/48px) with app density (12/16px)
- categories standard: the quiet scripts-sidebar look (transparent, tight rows,
  primary-tint selection) becomes the CategoryTreePanel default; scripts-page
  overrides and its sidebar/body card frame removed, page padding tightened
- ListToolbarSearchInput chrome moves from FilterPanel.css into ListToolbar.css
  (a primitive's classes must be styled in its own stylesheet)
- preview: toolbar theme global swaps the Lara stylesheet like the app's
  useTheme hook (managed link + color-scheme); canvas tracks theme surfaces;
  html/body overflow unlocked so document-style stories scroll
- Design System / Gallery story: all tokens + shared primitives on one page,
  with pinned Dark and Light variants for the visual suite
- npm run design (root + frontend) opens Storybook straight on the gallery
Canonical rules for visual consistency: compact application density (no
website styling), token vocabulary with real-app roles, shared primitive
catalog with import paths, categories standard, PrimeReact soft-fail traps,
and the live gallery/screenshot verification loop for agents. Registered in
CLAUDE.md; frontend-patterns styling section now points here.
npm run design:snap -- --label <name> screenshots every main page in the
demo build (both themes) into test-report/design-review/<label>/;
design:compare builds a side-by-side + flip-in-place HTML for two labels.
Pages open via the same localStorage navigation seeding the docs-videos
helper uses.
…el sidebar

Slice 1 of the page-unification effort (prompt 13/18 prep): the sounds page
adopts the app-wide categories standard — the quiet CategoryTreePanel
sidebar — replacing its per-feature .category-tab chips. SoundCategoryTabs
deleted; existing category dialogs and drag-to-categorize behavior kept.

The primitive grew an optional renderNodeActions slot (hover-revealed
rename/delete on tree rows, collapsed via max-width so hidden actions don't
reserve width) so sounds' per-category actions fit without forcing.
Bonus correctness: the sidebar now shows the real category hierarchy the
tab strip used to flatten (demo's UI ⊂ Sound Effects).

Ripples updated: sounds e2e page object + steps selectors (scenario names
unchanged; 116/116 @sounds scenarios green locally), sounds docs video
script, feature docs.
… maps list

The dashed primary border + tint rendered on any mouse hover with no drag
in progress — drop feedback must only appear during an actual drag
(.drag-over), matching the ModelGrid rule.
…nel sidebar

Slice 2 of the page-unification effort — mirror of the sounds slice:
SpriteCategoryTabs deleted, quiet sidebar adopted, page scroll moved to the
grid container so the sidebar stays pinned, page padding tightened to the
density standard. The sidebar again surfaces real category hierarchy the
tab strip flattened (demo's HUD ⊂ UI).

Ripples: sprites e2e steps switched to shared sidebar selectors via one
constant (scenario names unchanged; 117/117 @sprites scenarios green
locally incl. the @serial category flows), sprites video script, feature
docs.
… skill

User-set process: search the catalog before writing; grow a primitive's API
instead of forking it; if nothing fits, STOP and propose the standard (API
sketch, existing adopters, which queued prompts reuse or strain it,
non-goals) and get sign-off before implementing. Also: bug in a pattern =
grep for the pattern's clones.
…Sets tab

The page list was derived from the TabType union, which still carries the
legacy 'textureSets' type for old saved layouts — users can only open
Global Materials and Multi-Model Textures since the split. The list now
mirrors the New Tab page's tiles and says so.
…ted-state migration

The textureSets page was split into Global Materials and Multi-Model
Textures, but the legacy TabType stayed in the union with a render case
kept for old saved layouts — a silent alias that showed an unfiltered list
no page in the app offers anymore (and tricked the design-review harness
into snapshotting a nonexistent page).

- navigationStore persist bumps to version 1 with a migration rewriting
  legacy tabs (type, singleton id, label, active-tab pointers) to
  modelTextures across open windows, recently-closed tabs, and archived
  windows; verified live against the demo build with a seeded v0 layout
- legacy URL ids in the compact tab format normalize to modelTextures
- fixes a pre-existing hole the migration exposed: globalMaterials and
  modelTextures were never in the URL-parsing whitelists, so post-split
  URLs threw and fell back to the default layout
- 'textureSets' deleted from the TabType union and every switch
  (TabContent, DraggableTab, getTabLabel, openTab dedupe, NewTabPage icons)
- tests updated to the successor type + new regression tests for the
  migration and the legacy-URL mapping (432 total)
…ack states

The last two pages without the toolbar identity: h2 header + always-open
FilterPanel replaced by the standard toolbar row (search/filters/options/
create + count chip); loading/empty hand-rolled markup replaced by shared
LoadingState/EmptyState. Page density aligned (0.75rem padding). E2E
page-ready waits retargeted to the toolbar/shared-empty selectors.
EmptyState/LoadingState from @/shared/components/feedback replace the
hand-rolled empty/loading markup on models (no-results), sounds, sprites,
scripts, texture sets (both variants), environment maps (incl. loading),
recycled files, and history. Legacy class names stay on the elements as
inert e2e selector aliases (prompt 46 replaces them with testids); their
CSS blocks are deleted so the shared look is the only styling. Models'
dead local EmptyState/ErrorState components and stories removed.

Also: unified card identity — all six card types (model, texture set,
sound, script, sprite, environment map) share --mod-radius-lg, shadow
sm→md, hover translateY(--asset-card-hover-lift) and
--mod-transition-fast, replacing three divergent hover behaviors. Full
AssetTile DOM migration deliberately deferred to prompt 18 (see prompt 13
slice log).
…ts view

The e2e suite injected the legacy 'textureSets' tab via localStorage as its
navigation surface ('kept for the kind-filter e2e tests') — with the type
removed from the app and migrated on load, that surface is gone for real:

- TextureSetsPage.goto and all step-level navigation now open the real
  Multi-Model Textures tab (New Tab tile — no state injection needed)
- 'kind tab' steps redefined: switching kinds = switching between the
  Global Materials / Multi-Model app tabs; active-kind assertion uses the
  dock tab state
- dock-system tab locators follow the pi-images icon of modelTextures
- two scenarios @Skip'd with owners: 'Default tab is Global Materials'
  (no combined view → no default kind) and 'Drag and drop between kind
  tabs' (switcher-only interaction; conversion stays covered by the
  context-menu scenarios in 18-texture-set-kind-conversion.feature, drag
  replacement belongs to prompt 49's DnD matrix)
- navigation-helper drops textureSets from state-injectable types
Second sweep after the full-suite run: texture-types (three blocks),
thumbnail-previews, merge-channel-mapping, recycled-files-common and
recycled-files-textures all drove the removed .kind-filter-select switcher
('switch to Multi-Model if not active') after navigating — a no-op now
that navigation lands directly on the kind-locked Multi-Model Textures
tab. TextureSetsPage.selectKindTab (used by the demo suite) now opens the
matching app tab instead of clicking switcher buttons.

Validated: the previously-failing scopes (00-texture-sets channel-mapping
+ thumbnails, 04-recycled-files) pass 108/108 locally.
- 'Kind persistence' asserted the old switcher semantics (kind resets to
  Global Materials on reload); kinds as separate app tabs persist the
  active tab across reload — assert Multi-Model stays active instead
- recycled-files FilesTab step lost its accidental render wait when the
  obsolete switch-to-Multi-Model block was removed; under full-suite load
  the toolbar-search helper raced page render — replaced with a
  deterministic .texture-set-list wait (other removal sites are already
  gated by TextureSetsPage.goto's built-in wait)

Validated: 216 passed / 0 failed across both scenarios' dependency chains.
Every pull-request E2E run since July 4 timed out (4 min) waiting for real
asset-processor renders on GitHub's GPU-less runners, while identical code
passed push runs and the local lane. Setup scenarios now only create models
and shared state; the render assertions move to the local GPU lane:

- 00-texture-sets/01-setup.feature: thumbnail waits removed (dependents only
  need the models to exist)
- new 01-model-viewer/03-model-card-thumbnail.feature (@serial): carries the
  moved card-render assertion via a shared-state-parameterized step
- 00-texture-sets/03-default-behavior.feature: the regen-wait scenario joins
  @serial (same render-blocking class); audit found no other render waits
  left on the PR lane
- dock-system.steps.ts: commented headroom for the software-GL model-viewer
  mount (tab-dedup stays on PRs — it guards dock logic, not rendering)
- e2e-authoring skill: the rule is now documented so it can't regress

Validated live: setup 8/8 (27.7s, was minutes), full chromium project 102/0,
moved scenarios green in the serial project.
Semantic conflict between #559's video rework and the textureSets tab split:
the spec navigated to the removed combined tab id, which the legacy-URL
mapping now sends to Multi-Model Textures — the wrong page for the global
texture set the video creates. The tab-label map gains both split pages.
The #559 choreography plus split-page navigation measures a stable ~44s
locally (two runs, zero freeze/black frames) against a 40s cap that would
fail the main-push video QA gate. Deliberate raise per the gate's own
guidance, with headroom for CI's slower software-GL rendering.
Papyszoo added 29 commits July 11, 2026 12:57
Mirrors the pack slice: ProjectRepository stops self-committing, and every
Project command handler calls IUnitOfWork.SaveChangesAsync once at the end.
AddModelToProjectCommandHandler (association + batch-upload repair loop) and
AddTextureToProjectWithFileCommandHandler (texture-set creation + project
association + batch-upload record) previously issued 2-3 independent commits
each; the latter still needs an intermediate commit to resolve a freshly
created TextureSet's real id before it's used as a raw FK int, noted inline.
Removes the 6 manual IDomainEventDispatcher.PublishAsync + ClearDomainEvents
call sites (AddModelCommandHandler x2, CreateModelVersionCommandHandler,
CreateModelFromBlendCommandHandler, SetActiveVersionCommandHandler,
FinishThumbnailJobCommandHandler) — DomainEventsInterceptor (added in the
prior commit) now dispatches every event a tracked aggregate raises, so a
handler can no longer forget the boilerplate and silently drop an event.

Two handlers (CreateModelVersionCommandHandler, CreateModelFromBlendCommandHandler)
raised their event AFTER the last repository call that would trigger a save;
reordered so the event is on the aggregate before that save, since dispatch
now only fires post-commit. The other three already raised before their last
save and needed no reordering.

Tests updated to assert the new contract directly: a raised event lands on
the aggregate handed to the repository (no dispatch mock needed in the
handler tests), and a dedicated DomainEventsInterceptorTests suite proves the
save-pipeline mechanism end-to-end — event raised + saved -> handled with
zero dispatch code in the caller, a failed SaveChanges dispatches nothing,
and a handler that stages further writes during dispatch (mirroring
ModelUploadedEventHandler enqueuing a ThumbnailJob) gets them flushed.
…ller

Real-Postgres integration test (ConcurrencyTests.Parallel_Model_To_Pack_
Association_No_Duplicates) caught a regression from the packs slice: the
duplicate-PackModels-association catch was only wired into IUnitOfWork's
explicit interface member, but AddModelToPackCommandHandler's first actual
commit now happens inside BatchUploadRepository's still-self-committing
UpdateAsync (PackRepository.UpdateAsync only stages the association until
that area finishes migrating) — bypassing the catch and surfacing a raw 500
on the known-benign concurrent-add race.

Moving the catch into the SaveChangesAsync override itself (rather than only
the IUnitOfWork member) protects every caller uniformly, including
repositories that still self-commit during the in-progress migration. This
is exactly the kind of gap Category=Integration tests exist to catch —
Application.Tests' mocked repositories can't see it.
…tgres

Adds UnitOfWorkRollbackTests: stages an add on ISettingRepository, then an
add on IPackRepository, then a second Setting with a duplicate Key (Setting.Key
is uniquely indexed) — all before a single IUnitOfWork.SaveChangesAsync call.
Asserts the whole thing throws and NEITHER the Setting nor the unrelated Pack
persisted. Runs against real PostgreSQL (Category=Integration) because EF
Core's InMemory provider doesn't reliably enforce unique constraints, so it
can't distinguish a rolled-back SaveChanges from a row that simply never got
added.

Adding a second test class on ModelibrWebFactory exposed a latent race: each
factory instance drops and recreates the same shared "Modelibr_IntegrationTests"
database in its constructor, and xUnit runs different test classes in
parallel by default, so two classes' factories could race on that drop/create.
Introduces a PostgresIntegrationCollection xUnit collection (both
ConcurrencyTests and UnitOfWorkRollbackTests now carry [Collection(...)]) so
classes using this factory always run sequentially; each still gets its own
factory/database lifecycle.
Source-scan xUnit fact: fails if any file under src/Infrastructure/Repositories
calls SaveChangesAsync, with a failure message pointing at the backend-patterns
skill's "Transactions — unit of work" section. Verified it actually catches a
regression (temporarily reintroduced a self-commit in PackRepository during
development; the test failed with the expected message, removed before
committing).

The full migration lands one bounded area at a time (settings, packs, and
projects so far), so this activates the rule for those with an honest,
commented allowlist for the 22 repositories not yet migrated — every entry
names its area so the list shrinks as each one lands, per skill-authoring's
"dormant gate" convention. A companion test guards the allowlist itself
against stale entries once a file is migrated.
…ract

Replaces the "dispatch is manual and easy to forget" / "there is NO unit of
work" claims with the new contract: IUnitOfWork.SaveChangesAsync (one call
per handler) and DomainEventsInterceptor (dispatch lives in the save
pipeline, never in a handler). Documents the two traps found while
implementing this: temporary EF keys on freshly-Added entities needing an
early SaveChanges in a few handlers, and the migration-in-progress allowlist
in RepositoriesDontSelfCommitTests being the live source of truth rather than
a claim duplicated here.
feat(webdav): allow duplicate asset names with id-suffix disambiguation
fix(worker): delete dead legacy pipeline and cancel job timeouts
The e2e stack recreates its DB fresh every run, so first boot always has
every migration pending — and its webapi has no backup configuration
(no POSTGRES_* env, no BACKUP_STORAGE_PATH), so the automatic
pre-migration backup fails at the psql version probe and the new
abort-on-failure policy correctly refuses to start. That policy is right
for real installs and wrong for this stack: skip explicitly. The
pre-migration path stays covered by backup-restore-e2e spec 06 (real
pg_dump on first boot) and the DatabaseExtensions integration tests.
ModelRepository, ModelTagRepository, and BatchUploadRepository stop
self-committing entirely; ModelVersionRepository's aggregate CRUD does too,
while its ModelVersionTextureSet mapping methods intentionally keep their
immediate commits (the variant-aware AddTextureMappingAsync is an
idempotent-insert primitive that must save at once to catch its unique
violation and recover) — the file stays allowlisted with that reason.

Handlers migrated: every Models/ command handler, both RecycledFiles
handlers (restore + permanent delete, one save per entity-type case), and —
because BatchUploadRepository is consumed by upload handlers of every asset
type — UploadFileCommandHandler and the sound/sprite/texture-set/env-map
*WithFile handlers get their UoW flush now, ahead of their own areas'
slices, so staged batch-upload records can't be silently dropped.

Two id-semantics constraints surfaced (both documented inline):
- Model creation must commit BEFORE Model.CreateVersion for the first
  version: it sets Model.ActiveVersion, and two still-Added entities trip
  EF's circular Model.ActiveVersionId <-> ModelVersion.ModelId FK check in a
  single save. Caught by the Category=Integration ConcurrencyTests against
  real Postgres (the whole upload path 500'd), invisible to mocked unit tests.
- Version inserts commit before their id is copied into raw scalar FKs
  (File.SetModelVersion), the ModelUploadedEvent, or response DTOs.
ThumbnailRepository and ThumbnailJobEventRepository stop self-committing (and
lose their now-unused SaveChangesAsync interface members, along with
IThumbnailJobRepository's). ThumbnailJobRepository's Add/Update stage only;
its GetNextPendingJobAsync keeps the explicit claim transaction permanently,
now with the prompt-27 coordination note (must be wrapped in an execution
strategy if EnableRetryOnFailure lands).

ThumbnailQueue commits its own writes via IUnitOfWork — deliberately, unlike
command handlers: enqueue/complete/fail/retry are durable-queue primitives
that must be persisted before workers are notified over SignalR, and its
callers include the domain-event pipeline where no command handler exists to
commit afterwards. This keeps every caller's semantics unchanged with zero
edits at queue call sites.

Handlers migrated: Finish*JobCommand (model/sound/texture-set),
LogThumbnailJobEventCommand, Upload(Png)ThumbnailCommand,
Regenerate(All)ThumbnailsCommand, SetDefaultTextureSetCommand,
SetMainVariantCommand, AssociateTextureSetWithModelVersionCommand. Every
new-Thumbnail-then-SetThumbnail site commits between the add and the set —
ModelVersion.SetThumbnail copies thumbnail.Id into a raw scalar FK, so it
needs the real id (same temporary-key trap as the models slice).
TextureSetRepository and TextureProxyRepository stop self-committing. All 16
texture-set command handlers (CRUD, tags, kind, channel, tiling, proxy,
associate/disassociate, thumbnail upload/regenerate) commit once via
IUnitOfWork. CreateTextureSetWithFileCommand commits right after the set's
insert-update pair — its real id feeds a BatchUpload raw scalar, the
thumbnail-job enqueue, and the response DTO; UpdateTextureSetKindCommand and
RegenerateTextureSetThumbnailCommand commit before enqueuing so the worker
sees the durable set state.
SoundRepository stops self-committing. Create/Update/Delete/SoftDelete sound
handlers commit once via IUnitOfWork; CreateSoundWithFileCommand commits
right after the sound insert because its real id feeds the waveform-job
enqueue and the BatchUpload raw scalar. FinishSoundWaveformJobCommand's
staged sound update is flushed by the save it already gained in the
thumbnails slice.
SpriteRepository stops self-committing. Create/Update/Delete/SoftDelete
sprite handlers commit once via IUnitOfWork; CreateSpriteWithFileCommand
commits right after the sprite insert because its real id feeds the
BatchUpload raw scalar and the response.
feat(backup): automatic pre-migration backup + gated restore drill
…tOfWork

ScriptRepository and ScriptTemplateRepository stop self-committing. Every
Scripts and ScriptTemplates command handler commits once via IUnitOfWork
at the end of Handle. ScriptCategoryRepository stays self-committing —
category repos migrate together in the categories slice.
EnvironmentMapRepository stops self-committing. Every EnvironmentMaps
command handler, plus the two thumbnail handlers left behind by the
thumbnails slice (FinishEnvironmentMapThumbnailJobCommand,
UploadEnvironmentMapVariantThumbnailCommand) that write through this
repo, commit once via IUnitOfWork.

Create*/AddVariant* handlers need an early commit right after the
variant/environment-map insert: SetPreviewVariant and the variant.Id>0
preview-assignment check both depend on the database-assigned id, which
previously came for free from the repo's internal self-commit. Without
moving that commit up, AddEnvironmentMapVariant(WithFile) would have
silently stopped auto-assigning the preview variant for an environment
map's first variant. EnvironmentMapCategoryRepository stays
self-committing — category repos migrate together in the categories
slice.
All six per-type category repositories (model, texture-set, sound,
sprite, script, environment-map) stop self-committing. The shared
CategoryCommandHandlers.CreateAsync/UpdateAsync/DeleteAsync helper —
used by every category type except Model's and TextureSet's bespoke
Create/Update — now takes IUnitOfWork and commits once per operation;
concrete handlers thread it through. Delete's children-first branch
delete now lands in a single commit instead of one self-commit per
row, so a failure partway through leaves the whole branch untouched
(atomicity gain, not a behavior change to preserve). Create commits
immediately after the insert: category.Id is database-assigned and
feeds the response DTO.
…gh IUnitOfWork

FileRepository, FilePersistence, and StageRepository stop self-committing —
the last three repositories on the fitness gate's allowlist besides the two
permanent exceptions (ModelVersionRepository's texture-mapping upsert,
ThumbnailJobRepository's claim transaction).

UploadFileCommandHandler and CreateStageCommandHandler need an early commit
right after their insert: the database-assigned id feeds a BatchUpload FK /
the response DTO, same shape as every other Create*WithFile handler in the
prior slices. SoftDeleteFileCommand and UpdateStageCommandHandler get a
single commit at the end.

FileCreationService's two orphan-cleanup HardDeleteAsync calls (missing
physical file, stale recycled record) are untouched and now join whichever
caller's handler eventually commits — if that handler fails validation
afterward, the cleanup no longer happens independently the way the old
self-commit guaranteed. This is the same atomicity trade the categories
slice's branch-delete made, and is explicitly in scope per prompt 25;
redesigning FileCreationService's disk<->DB compensation itself stays out
of scope.

Fitness gate allowlist now holds only the two permanent, individually-
justified exceptions.
The last four slices (scripts, env maps, categories, files+stages) closed
out prompt 25 — the fitness gate's allowlist is down to the two permanent,
individually-justified exceptions. Update the skill's stale "in-progress
migration" / "settings, packs, projects done" language accordingly, and
note the ThumbnailQueue self-commit exception and the hierarchical-delete
atomicity gain so the next reader doesn't have to rediscover them.
…ties

CreateTextureSetWithFileCommand (and any Add->mutate->Update->SaveChanges
handler shape) 500'd with "has a temporary value while attempting to
change the entity's state to 'Modified'": since repositories stopped
self-committing, AddAsync leaves an aggregate tracked as Added with a
temporary key, and calling UpdateAsync on that same reference before the
first SaveChanges forced EF to try to move it to Modified.

Add DbContextTrackingExtensions.UpdateIfDetached and route every
repository's UpdateAsync through it — it only attaches + marks Modified
for a genuinely Detached entity; an already-tracked one is persisted in
its current state by the next SaveChangesAsync with no extra call.
Category/Pack/Project repositories already had this guard hand-rolled;
they now share the same helper for consistency.

Covers the fix with an integration test reproducing the exact failing
upload flow plus a second Add->Update flow against ModelRepository (a
different aggregate) and a detached-entity persistence case, plus a fast
InMemory-backed unit test on TextureSetRepository.
…missed

Two silent-persistence regression classes surfaced by the PR e2e lane:

- Handlers with no commit at all: AddSpriteToPackCommand ('Add sprite to
  pack' + downstream 'Remove sprite from pack' failures), the four model
  variant/material/metadata commands, HardDeleteTextureSetCommand, and
  GetSettingsQueryHandler's get-or-create-defaults write (a query handler
  that persists — pre-existing design, persistence restored).
- Commits only on a conditional path: AddTextureToSetCommand committed
  only when a batchId was provided, so the texture-merge flow ('Merge ORM
  packed texture using Split Channels') staged textures and dropped them
  at scope disposal. The commit is now unconditional at the end of the
  mutating path.

Audited the other statically-flagged handlers (ModelCategoryCommands,
UpdateSettingsCommandHandler, TextureSetCategoryCommands,
SetDefaultTextureSet, AddFileToModel, AddModel, RecycledFiles restore/
delete): every mutating path in those already reaches a commit.
Three regression sub-classes from the unit-of-work migration (tracked-
entity Update, missing commit, conditional-only commit) proved that
per-handler commit discipline cannot be guaranteed by review or grep.

Every ICommandHandler<> / ICommandHandler<,> now resolves wrapped in
CommandHandlerUnitOfWorkDecorator: after the inner handler returns a
success Result, IUnitOfWork.SaveChangesAsync flushes anything still
staged; failure Results and exceptions commit nothing. Explicit commits
inside handlers remain preferred (mid-handler id materialization) and
become harmless no-op flushes. Query and domain-event handlers are not
wrapped. No new dependencies — hand-rolled factory registration; the
concrete handler is registered under itself so the decorator factory can
resolve it without recursing.

Architecture test asserts the decorator actually wraps resolved command
handlers so the registration cannot be silently dropped; an integration
regression test proves a commit-less handler still persists through the
decorator, and the no-batchId add-texture flow persists (the exact CI
failure).
Adds a 54-model 'Base Meshes (CC0)' pack to demo mode, streamed from the
Papyszoo/base-meshes fork pinned to a commit SHA — no binaries in this repo.

- baseMeshesSeed.ts generates models/versions/pack with real file sizes and
  credits The Base Mesh (thebasemesh.com) + M3-org/base-meshes (CC0 1.0)
- seedAssetUrl passes absolute URLs through so serveFile streams GLBs from
  GitHub raw (CORS *)
- seedRemoteThumbnails serves pre-rendered animated WebP turntables (made
  with the asset-processor pipeline) instead of generating in-browser;
  checked after IndexedDB so user-customized thumbnails still win
- contract test pins the cross-module id scheme (drift would silently 404
  in the hosted demo)
# Conflicts:
#	src/Application/EnvironmentMaps/CreateEnvironmentMapCommand.cs
#	src/Application/EnvironmentMaps/UpdateEnvironmentMapCommand.cs
#	src/Application/Models/AddFileToVersionCommandHandler.cs
#	src/Application/Models/SetDefaultTextureSetCommand.cs
#	src/Application/Models/SetMainVariantCommand.cs
#	src/Application/Scripts/UpdateScriptCommand.cs
#	src/Application/Sounds/CreateSoundCommand.cs
#	src/Application/Sounds/UpdateSoundCommand.cs
#	src/Application/Sprites/CreateSpriteCommand.cs
#	src/Application/Sprites/UpdateSpriteCommand.cs
#	src/Application/TextureSets/AssociateTextureSetWithModelVersionCommand.cs
#	src/Application/TextureSets/UpdateTextureSetCommand.cs
#	src/Application/TextureSets/UpdateTextureSetKindCommand.cs
#	tests/Application.Tests/Models/SetDefaultTextureSetCommandHandlerTests.cs
feat(demo): seed Base Meshes (CC0) pack from remote fork URLs
refactor(backend): unit of work + save-pipeline domain event dispatch
chore(release): bump internal package versions to 0.4.0
@Papyszoo Papyszoo merged commit 770ca43 into main Jul 12, 2026
35 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant