fix: address all five open issues (#257, #258, #262, #263, #264) - #265
Draft
antosubash wants to merge 2 commits into
Draft
fix: address all five open issues (#257, #258, #262, #263, #264)#265antosubash wants to merge 2 commits into
antosubash wants to merge 2 commits into
Conversation
…CSS sourcing Closes #257, #258, #262, #263, #264. #263 — expression-based index emitted twice under PostgreSQL. The dedup guard in make_process_revision_directives scanned only the top level of upgrade_ops, but autogenerate nests CreateIndexOp inside a ModifyTableOps group next to the CreateTableOp. The guard therefore never saw the index the dialect had already emitted and re-injected it, so a fresh Postgres app died on its first `make migrate` with DuplicateTable. The scan now recurses through nested op groups; the same fix applies to DropIndexOp in the downgrade. #262 — scaffolded apps migrated the wrong database. The scaffold's alembic.ini resolved script_location against the invocation cwd, forcing the Makefile to `cd host`, where BootstrapSettings' cwd-relative `.env` lookup missed the repo-root file and silently fell back to the default SQLite URL. Ported this repo's two corrections into the templates (`%(here)s/migrations`, migrate from the repo root) and made env.py log the resolved URL, password masked, so a cwd mismatch is visible on the first migration instead of silent. #264 — `create-module` always wrote tests/test_module.py. With no tests/__init__.py, pytest derives the module name from the basename alone, so the second module in a repo broke collection with "import file mismatch" — and root-level pytest is what the scaffold's own `make test` runs. The template is now named after the package. #257 — get_db committed in its yield-teardown, which FastAPI runs after the response has been delivered, so create-then-immediately-use lost the race and 404'd deterministically. Added CommitBeforeResponseMiddleware, which finalizes the request's sessions at the ASGI http.response.start message — the last point still inside the request, late enough that response serialization has already run and early enough that a commit failure can still become a 500. get_db keeps its finalize as a fallback for when the middleware isn't in the stack; the session is claimed once, so the error path (which unwinds the dependency, and therefore rolls back, before the error response is sent) is unaffected. #258 — gen-pages emitted @source only for a wheel module's pages/, leaving widgets under components/ uncompiled unless the host hand-wrote a .venv-relative glob. That path cannot be spelled portably (Windows uses Lib/site-packages) and Tailwind drops a non-matching glob silently, so every widget class vanished from the build on Windows with no error. components/ is now sourced by absolute path like pages/, and surfaces in modules.assets.json. Claude-Session: https://claude.ai/code/session_015fCFfMiqGce8unVpVezJG7
Deploying simple-module-python with
|
| Latest commit: |
05f70b7
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://8b0b9b37.simple-module-python.pages.dev |
| Branch Preview URL: | https://claude-github-issues-07jlrm.simple-module-python.pages.dev |
Follow-up to the previous commit, from review findings. **Write loss (regression in the #257 fix).** Finalizing at http.response.start claimed the session one-shot, but work legitimately continues after that message: Starlette runs BackgroundTasks once the body is sent, and a StreamingResponse writes its body afterwards — both on the same session. Those writes flushed and never committed, and get_db's fallback no-op'd because the session was already claimed. Reproduced: a background task that adds a row persisted nothing with the middleware and persisted correctly without it. finalize_session is now re-armable rather than one-shot — each call commits whatever is pending at that moment, and get_db's exit code still runs to catch work done after the response started. The pending-write marker is cleared on settle so a committed session is never re-committed, and a failed commit is settled too so the fallback cannot retry it. Read-only requests still pay for exactly one rollback. Tests cover both the BackgroundTasks and StreamingResponse paths; both fail against the previous commit. **Test that did not test what it claimed.** test_commit_failure_becomes_a_500 stubbed finalize_session, bypassing the bookkeeping, so get_db's fallback committed the row the test called lost — it asserted only the status code and would have passed with the guarantee broken. It now injects the failure at AsyncSession.commit and asserts the table is empty. **#262 was only half fixed.** The Makefile hunk fixed later `make migrate` runs, but `smpy new` still ran the baseline autogenerate and `upgrade heads` with cwd=host/. The scaffolded default URL is itself root-relative (sqlite+aiosqlite:///./host/app.db), so that bootstrap wrote host/host/app.db while the app and the now-fixed `make migrate` use host/app.db — the same defect one step earlier, and newly divergent. Both now run from the project root, with the ini path collapsing for the flat create-host layout. **#258 was only half fixed.** Tailwind now scans a wheel module's components/, but Vite's bare-specifier fallback resolver only fires for importers under a module's pages/ prefix, so a widget's `@simple-module-py/ui` import failed to resolve. components/ now contributes its prefix and an optimizeDeps entry, in the host and in the scaffold template. Splitting the scaffold's vite.config.ts into module-assets.ts mirrors the split the host already made for the same reason, and keeps both under the 300-line cap; the migration-bootstrap tests move to their own file on the same grounds. Known limitation, now documented in the middleware: with more than one enlisted session (Depends(get_db, use_cache=False)), a failure part-way leaves earlier commits durable while the client sees a 500. Claude-Session: https://claude.ai/code/session_015fCFfMiqGce8unVpVezJG7
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #257, closes #258, closes #262, closes #263, closes #264.
Two commits: the five fixes, then a follow-up correcting a regression and finishing two fixes that were only half done. This body describes the final state.
#263 — expression-based index emitted twice under PostgreSQL
The idempotency guard the issue asks for was already in
make_process_revision_directives— it just never fired. It scanned only the top level ofupgrade_ops.ops, but autogenerate doesn't hand us a flat list: index operations are nested inside aModifyTableOpsgroup alongside the top-levelCreateTableOp. Verified against alembic directly:So
existing_index_nameswas always empty, the injection re-added an index PostgreSQL had already emitted, and the firstmake migratedied withDuplicateTable. The scan now recurses through nested op groups; same forDropIndexOpin the downgrade. Name-based and dialect-agnostic, as suggested.The pre-existing
test_does_not_double_inject_when_already_presentpassed throughout because it built a flat op list — the two new tests use the real nested shape and fail without the fix.#262 — scaffolded apps migrate the wrong database
Ported this repo's two corrections into the templates:
templates/host/alembic.ini→script_location = %(here)s/migrationstemplates/workspace/Makefile→migrate/migrationrun from the repo rootThat alone was not enough, and briefly made things worse:
smpy newitself still ran the baseline autogenerate andupgrade headswithcwd=host/. The scaffolded default URL is root-relative (sqlite+aiosqlite:///./host/app.db), so that bootstrap wrotehost/host/app.dbwhile the app — and the newly-fixedmake migrate— usehost/app.db. Same defect one step earlier, and newly divergent from the Makefile. Both now run from the project root, with the ini path collapsing toalembic.inifor the flatcreate-hostlayout.Also took the issue's optional suggestion to make the mismatch loud:
env.pylogs the resolved database URL (password masked viarender_as_string(hide_password=True)) and the cwd. Chose that over havingSettingswalk parent directories for.env— that changes settings resolution for every caller, a bigger blast radius than this issue warrants.#264 — identical
tests/test_module.pybasenameWent with your option (1): the template is now
tests/test___PACKAGE__.py.tpl, and the existing__PACKAGE__path-rewrite renders it per module — a module namedAlphagetstests/test_alpha.py. Option (2) is ruled out by an existing comment in the scaffolding tests — a shippedtests/__init__.pymakes pytest registertests.conftesttwice under importlib mode.Note one residual: this fixes the scaffolded file, but two modules that later add their own
tests/test_service.pywould collide the same way. Option (3) (importmode = "importlib"in the workspace template) is the fix for that whole class. I left it out because I can't exercise a scaffolded workspace's pytest run in this environment, and it interacts with the conftest behaviour noted above — happy to add it if you want it.#257 —
get_dbcommits after the response is sentConfirmed the mechanism rather than assuming it — with a
send-spy around a FastAPI app, teardown ordering is:['response.start', 'teardown']← the bug['teardown-exception', 'response.start']Took fix option 1 (commit before the response is sent), so every caller benefits rather than just the one workflow.
CommitBeforeResponseMiddlewareis pure ASGI and interceptshttp.response.start, installed innermost (added first —add_middlewareis LIFO). That point is deliberate: late enough that FastAPI has already serialized the response, so committing can't interact with attribute expiry; early enough that nothing has reached the client, so a commit failure can still become a 500.The first attempt at this had a write-loss bug, caught in review and fixed in the second commit. Finalizing at
response.startclaimed the session one-shot, but work legitimately continues after that message: Starlette runsBackgroundTasksonce the body is sent, and aStreamingResponsewrites its body afterwards — both on the same session. Those writes flushed and never committed, andget_db's fallback no-op'd because the session was already claimed. Reproduced: a background task adding a row persisted nothing with the middleware and persisted correctly without it.finalize_sessionis now re-armable rather than one-shot — each call commits whatever is pending at that moment, andget_db's exit code still runs to catch work done after the response started. The pending-write marker is cleared on settle so a committed session is never re-committed, and a failed commit is settled too so the fallback cannot retry it. Read-only requests still pay for exactly one rollback. The error path is unchanged: FastAPI unwinds the dependency — rolling back — before the error response is sent.An APIRoute subclass was the other candidate and was rejected: FastAPI 0.141 includes routers lazily via
_IncludedRouter, so aroute_classset onapp.routerno longer propagates to included module routers.Known limitation, documented in the middleware: with more than one enlisted session (
Depends(get_db, use_cache=False)), a failure part-way leaves earlier commits durable while the client sees a 500. No cross-session atomicity short of two-phase commit.#258 —
gen-pagesomits wheel modules'components/Same CSS fix as your PR #259 — that PR is still open, so this branch carries its own copy; drop whichever lands second.
components/now gets an absolute@sourceexactly aspages/does, and appears inmodules.assets.json. In-repo modules stay covered by the host's static glob.That was also only half the fix. Vite's bare-specifier fallback resolver only fires for importers under a module's
pages/prefix, so a wheel widget's@simple-module-py/uiimport still died with "Failed to resolve import" — the classes were compiled but the component couldn't build.components/now contributes its prefix and anoptimizeDepsentry too, in the host and in the scaffold template.Notes on test honesty
test_created_row_is_readable_on_the_very_next_requestdocuments the client-visible contract but does not fail without the fix — httpx's in-process ASGI transport awaits the full request, teardown included, before issuing the next one, so the follow-up can never lose the race there. The real regression guard istest_write_is_durable_before_the_response_is_delivered. Both docstrings say so.test_commit_failure_becomes_a_500originally stubbedfinalize_session, bypassing its bookkeeping, soget_db's fallback committed the very row the test called lost — it asserted only the status code and would have passed with the guarantee broken. It now injects atAsyncSession.commitand asserts the table is empty.Structural changes
Splitting the scaffold's
vite.config.tsintomodule-assets.tsmirrors the split the host already made for the same reason, and keeps both under the 300-line cap. The migration-bootstrap tests move to their own file on the same grounds.Verification
pytest: 1918 passed, 2 skippedvitest: 48 passed ·biome ci .: clean ·tsc --noEmit: cleanruff format --check+ruff check+ty check: cleanmake doctor: 0 errors, 1 warning — a pre-existingSM003onAuditLog/Browse.tsx, confirmed present onmainbefore these changesEach fix's regression test was run against the unpatched code to confirm it actually fails, except where noted above. Generated frontend files are gitignored, so there's nothing to regenerate.