DX hardening: runtime safety, routing, and multi-tenancy opt-in - #18
Merged
Conversation
- make doctor: add simple_module_core.__main__ so the target actually runs - .env.example: use SM_AUTH_* prefix so AuthSettings binds the values - pages.ts: PascalCase snake_case module dirs so inertia keys match backend - create_module_base: auto-detect provider from SM_DATABASE_URL so models work on both SQLite and PostgreSQL without code changes - permissions: drop example module names from DEFAULT_ROLE_PERMISSIONS so the framework no longer couples to plugin permissions - EventBus: dispatch across MRO (base-class subscriptions see subclass events); switch publish_nowait to get_running_loop - app_builder: exact-match app.state key check in SM012 (prev. substring match caused false positives/negatives for prefix-overlapping modules) - diagnostics: split reused SM009/SM010 codes (SM009=coupling, SM010=revision mismatch, SM011=table coverage, SM012=missing settings registration) - PermissionRegistry.has: O(n) lookup without allocating a sorted set - new_module scaffold: generate Browse/Create/Edit .tsx stubs so a fresh module boots without SM004 phantom-render warnings
Design + implementation plan for the nine deferred items flagged in the 2026-04-14 framework review: get_db commit semantics, strict module discovery, meta enforcement, opt-in tenant middleware, dashboard routing, docs, and assorted nits.
…Meta In strict mode (production, triggered by settings.is_development=False): - Missing/invalid `meta` → InvalidModuleError at boot - Not a ModuleBase subclass → InvalidModuleError - Entry-point load failure → InvalidModuleError - Instantiation failure → InvalidModuleError In non-strict mode (dev), behaviour is unchanged: log + skip the bad module. `host/migrations/env.py` and `conftest.py` both stay on the lenient default because they're dev-only callers.
…ng page - DashboardModule.meta.view_prefix: "" -> "/dashboard" - Dashboard endpoints no longer define a "/" route - modules/dashboard/pages/Landing.tsx -> host/client_app/pages/Landing.tsx (Inertia component key: "Landing" — host-level keys are just the filename) - New host/routes.py mounts the public landing page at "/" - host/main.py includes the host router after create_app() returns
Two settings, both defaulting to safe-for-single-tenant: - multi_tenant (default False) gates whether the middleware is added to the pipeline at all. - tenant_header (default "") controls the header fallback. Empty string disables the header source entirely — production deployments should lean on auth-token claims. TenantMiddleware.__init__ now takes header=None; callers that want header-based resolution pass the header name explicitly. Test fixture keeps multi_tenant=True so the existing header-driven tests continue to work; one new test asserts the middleware is absent when the setting is off.
Read-only endpoints no longer issue a round-trip COMMIT on exit — they release the implicit transaction via rollback, which is cheaper and keeps the session out of pg_stat_statements as a writer. Decision on "did we write?" comes from two sources: 1. An ``after_flush`` listener stamps ``session.info['has_writes'] = True`` — flush empties ``session.new/dirty/deleted`` so the live collections alone aren't enough (services flush + refresh inside their create() path, leaving the session visually clean at dep-exit time). 2. Fallback check on ``session.new/dirty/deleted`` catches the add-without-flush case. Log events: - db.session.commit (INFO) — pending writes committed - db.session.read_only (DEBUG) — no writes, rolled back - db.session.rollback (WARNING) — exception raised, rolled back
- deps.py: hoist duration_ms out of three branches; consolidate commit vs. read-only log call; import SESSION_HAS_WRITES_KEY from listeners - listeners.py: extract SESSION_HAS_WRITES_KEY constant so the session flag isn't a stringly-typed cross-module contract - discovery.py: factor the 4x-repeated strict/log pattern into a nested `fail()` helper - test_core.py: hoist per-method imports to module level; simplify _FakeEntryPoint to accept a class directly (no more lambda wrappers) - test_app.py: replace the two filesystem-dependent fallback tests with one that doesn't rely on the workspace layout - test_db.py: rewrite TestGetDbLogging to use the db_state fixture instead of hand-rolling init_db + dispose try/finally - packages/ui/src/lib/utils.ts: add shared toPascalCase util, mirrors the Python to_class_name() single source of truth - pages.ts: use the shared toPascalCase; drop the inline copy
Integrates the pyee-backed EventBus and event-driven dashboard stats (#17) with this branch's DX hardening work. Conflict resolutions: - framework/core/simple_module_core/events.py Took main's pyee-backed implementation wholesale. My DX fix added an MRO walk to deliver subclass events to base-class subscribers and switched publish_nowait to asyncio.get_running_loop(). Main took a different direction: exact-type dispatch (a new test explicitly asserts `test_subclass_events_do_not_match_parent_subscription`) and delegates loop handling to pyee. The MRO feature is incompatible with that design; dropped it. publish_nowait's loop concern is now encapsulated by pyee. - modules/dashboard/dashboard/module.py Combined both sides' ModuleMeta changes: * view_prefix="/dashboard" (this branch, task 7) * route_prefix="/api/dashboard" (main, for /api/dashboard/stats) * depends_on=["Products"] (main, for event-order dependency) No changes to tests; 267/267 still pass, ruff/ty/biome/tsc all clean.
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.
Implements the design decisions from
docs/plans/2026-04-14-dx-hardening-design.mdto close deferred review items and improve developer experience and production safety.Summary
This PR hardens the framework across nine areas: runtime safety (strict module discovery, auto-commit only on writes), multi-tenancy opt-in, routing fixes, and documentation. Each change is independently committable and preserves backward compatibility where possible (strict mode defaults to off in dev, multi-tenancy is opt-in).
Key changes
Runtime safety:
discover_modules(strict=...)now fails loudly in production on bad modules (import errors, missingmeta, wrong base class) while remaining lenient in devModuleBase.metavalidation moved to discovery time with clear error messagesget_dbnow commits only when the session has pending writes; read-only requests rollback instead (cheaper, cleaner profiling)Multi-tenancy:
TenantMiddlewareis now opt-in viaSettings.multi_tenant(defaults toFalse)Settings.tenant_headercontrols header-based tenant resolution (empty string disables it)Routing:
DashboardModule.view_prefixchanged from""to"/dashboard"(honest routing)host/routes.pyat/Landing.tsxmoved tohost/client_app/pages/(co-located with host routes)Nits & docs:
_PROJECT_ROOTnow respectsSM_PROJECT_ROOTenv var (set byhost/main.py) for wheel deploymentsall_module_basesdeduped via keyed dict to prevent unbounded growth on repeated importsprint_diagnosticswrites to stderr instead of stdoutmodules/products/products/validation.tsmoved tomodules/products/products/pages/validation.ts(co-located with consumers)docs/framework-conventions.md(reference for module authors)docs/plans/2026-04-14-dx-hardening-design.md(design rationale)docs/plans/2026-04-14-dx-hardening.md(implementation plan)README.mdwith quickstart, project layout, and common commandsframework/core/simple_module_core/__main__.pyfor CLI diagnostics (python -m simple_module_core)Test coverage:
meta, load failuresget_dbcommit/rollback behavior_resolve_project_rootenv var handlingall_module_basesdeduplicationNotable implementation details
discover_modules(strict=False)is the default, preserving dev ergonomics;app_builder.create_apppassesstrict=not settings.is_developmentget_dbcheckssession.new / session.dirty / session.deletedbefore committing; a pure-read session exits via rollback__getattr__(PEP 562) exposesall_module_basesfrom the deduped dict, preserving the import path forhost/migrations/env.pyandconftest.pyTenantMiddlewareis only installed whensettings.multi_tenant=True; existing tests keep it on for backward compatibilityhost/main.pysetsSM_PROJECT_ROOTbefore importing the hosting layer, soapp_buildercan find static/template assets in wheel deploymentshttps://claude.ai/code/session_01C9L3h13CsmwiQD7yetZuRA