All notable changes to this project are documented in this file. The format is based on Keep a Changelog and this project adheres to Semantic Versioning post-1.0.
Coverage note. Between 0.0.1 and 0.0.27 this file carried no per-version sections, and several already-released fixes sat under
[Unreleased]long after shipping — which led a downstream app to conclude a fix it depended on was still unreleased (GH issue #253). Those entries have been moved to the release they actually shipped in, verified withgit tag --contains. Versions not listed below still have no entry; consult the commit log.
-
Postgres test runs (#343) —
SM_TEST_DATABASE_URLpoints thesimple_module_testfixtures at Postgres, andmake test-py-pgruns the whole Python suite there. The schema is reset once per test, soappanddb_sessionsee each other's rows as they would in production. -
tenantsmodule — SaaS organisations: tenants, many-to-many memberships with per-tenant roles (owner/admin/member, surfaced astenant:<role>on the active tenant only), email-bound invitations, platform suspend / reactivate, and the membership-validated tenant resolver. Ships the seams a billing module needs: anEntitlementProvideronapp.state.tenants.entitlements(seat limits enforced, HTTP 402), lifecycle viaTenantService.set_status, and after-commit domain events. See docs/framework/multi-tenancy.md. -
simple_module_db.tenant_context()/all_tenants()and theall_tenants=Trueexecution option, for acting as one tenant — or deliberately across tenants — outside a request. -
TenantMiddlewareconsultsapp.state.tenant_resolverwhen a module registers one. -
background_taskscarries the enqueuing request's tenant into the Celery task and restores it around the task body. -
Doctor check
SM024: a unique key on aMultiTenantMixintable that omitstenant_id. -
InvalidationBus— a framework-level cache-invalidation channel any module can publish on (ModuleBase.register_invalidations,app.state.sm.invalidation). In-process by default;background_tasksinstalls a Redis pub/sub transport on the connection it already configures, so a write drops the matching entry in every worker instead of only the one that performed it. Turn it off withSM_BG_TASKS_BROADCAST_INVALIDATIONS=false; rename the channel withSM_BG_TASKS_INVALIDATION_CHANNELfor every app on a shared Redis server (pub/sub ignores the database index, so DB 4 vs DB 5 does not isolate it). See docs/framework/invalidation.md (GH #318). -
userspublishes itssession_versionbump on that bus, so "sign out everywhere" and a password change stop being honoured across every worker at once rather than after each worker'susers.session_version_cache_ttl_secondswindow. The TTL now bounds a dropped message rather than every cross-worker revocation; installs without a reachable Redis keep the previous behaviour. -
Request-scoped database sessions now expose
session.on_commit(callback)for synchronous or asynchronous cache refreshes and other derived state. The framework invokes callbacks only after a successful commit and discards them on rollback or commit failure. -
Every
smpy newscaffold now ships Docker assets by default: a multi-stagedocker/host.Dockerfile(uv + Node builder that runsgen-pagesbefore the Vite build, slim non-root runtime that applies migrations on start), adocker-compose.ymlmatched to the--dbchoice (appon a SQLite named volume, orpostgres+app— migration histories are dialect-frozen at autogenerate time, so containers run the same DB the migrations were generated against), plusredis/worker/beatreusing the app image whenbackground_tasksis selected, a.dockerignore, andmake docker-up/docker-build/docker-downtargets. Previously Docker files only appeared withbackground_tasks, and their frontend stage couldn't build real apps (nogen-pagesstep). The separateworker.Dockerfileis gone; worker/beat run the same image with a celery command.smpy newalso generates realSM_USERS_*_TOKEN_SECRETvalues into.env.exampleso the production-mode containers passUsersSettingsboot validation.
- Tenant isolation fails closed. With
multi_tenanton, a query, bulkupdate()/delete()or insert on aMultiTenantMixinmodel with no tenant context raisesTenantIsolationErrorinstead of reading or writing every tenant's rows. ORMupdate()/delete()are now tenant-scoped too; they were not before. - Changing a row's
tenant_idis refused whether or not a tenant is bound (it used to be checked only inside a tenant context); only anall_tenants()block may move a row between tenants. - Tenant rules now cover every ORM write path, not only
session.add: an ORMinsert(Model)(bulk or.values()) is stamped with the bound tenant and refused for a different one (#357);update(Model).values(tenant_id=…)is refused; a flush that writes or deletes an object belonging to another tenant (e.g. one returned from the identity map after atenant_contextswitch) is refused. tenant_context()nested inall_tenants()now scopes its block; it used to be ignored there, so a per-tenant loop inside a platform job ran unscoped.- Strict mode is held per engine, so a second
DatabaseStatein the process no longer switches it off for the first. The Celery worker's session gets the tenant listeners and the host'smulti_tenantsetting too (#371). - New
MissingTenantError(aTenantIsolationError) for "no tenant bound". - Tenant and soft-delete criteria reach join targets, subqueries (including a
bare Core
exists().where(...)),count().select_from()and top-level Core statements onModel.__table__(#332). Behaviour change: a join or count over a soft-deletable model now excludes trashed rows, as a plainselectalready did;include_deleted=Truestill reveals them. HostSettings.default_tenant: single-tenant hosts run mixin tables as one tenant (#359).bind_current_tenant(fn)carries the tenant into work a module defers past the request (#364). Thetenantsmodule resolves a tenant from the subdomain (subdomain_base), anonymous visitors included (#363).
- The tenant header (
tenant_header) is no longer honoured for an authenticated user without a tenant of their own: such a user could name any tenant. On the legacy path it applies to anonymous requests only; with thetenantsresolver it selects among the user's own memberships.
-
Public pages no longer reload the whole document when a visitor clicks a link in authored content. A simple_module app is client-rendered — the root template ships
<div id="app"></div>empty — so a navigation that creates a new document paints a blank white body until the bundle has booted. Admin screens were never affected because the shell navigates with Inertia's<Link>, but pagebuilder widgets and their markdown/rich-text fields render author-entered URLs as plain<a href>, and there is no component to swap for a<Link>when the anchor comes out of a markdown parser. A downstream site measured ~330ms of blank viewport per click on a throttled connection.@simple-module-py/uinow exportsstartSpaLinkInterception(), a delegated click handler that routes same-origin page links through Inertia whatever produced the anchor; thesmpy newapp template calls it. It deliberately leaves alone anything Inertia cannot render — other origins, non-http schemes, paths that look like a file, in-page anchors,download/target/rel="external"/data-native-link, and anchors inside a Puck editor surface — and falls back to a hard navigation if a visit returns without anx-inertiaheader, so a media download can never be replaced by an error modal. Existing apps must add the one-line call to their ownhost/client_app/app.tsx, which is scaffold output and so is not upgraded for them. -
smpy gen-pagesnow emits module stylesheet@importlines as absolute paths instead of#module/<pkg>alias specifiers. The alias only resolved if the host'svite.config.tsdefined a matchingresolve.alias— but that file is scaffold output, written into an app once and then owned and edited there, so it is versioned independently of these Python packages. Upgradingsimple_module_*0.0.26 → 0.0.27 therefore brokevite buildin every app scaffolded earlier, failing withCan't resolve '#module/<pkg>/styles.css'— naming a specifier that appears nowhere in the app's own sources.modules.generated.cssis now self-contained: it resolves under anyvite.config.ts, with no alias configured at all, exactly as the@sourcelines in the same file already did. No host action is required — upgrade and re-rungen-pages. The scaffold template still defines the#module/<pkg>alias for hand-written imports, but nothing generated depends on it any more (GH issue #253).
-
A module can now import another module's TS/TSX by npm package name:
import x from '@simple-module-py/pagebuilder/components/blockRegistry'. Nothing in Node's own resolution made this work — a wheel-installed module is not an npm workspace member, so it never lands innode_modulesat all; a workspace member is symlinked, but onto the source-tree module root, one level above the Python package, so subpaths landed somewhere nonexistent.gen-pagesnow records each module'snpm_nameinmodules.assets.json, and the host aliases it onto the module's Python package directory. That anchor is forced, not chosen: a wheel shipssite-packages/<pkg>/**and nothing above it, so the module root does not survive installation and the package directory is the only anchor both layouts share. The practical consequence is that the subpath is relative to the package:import x from '@simple-module-py/foo/components/Widget'; // ✅ both layouts import x from '@simple-module-py/foo/foo/components/Widget'; // ❌ workspace-only
If you previously hand-rolled this alias against the module root, drop the duplicated path segment. Existing hosts need a
vite.config.tschange — unlike the CSS fix above, the import lives in module source rather than a generated file, so it cannot be made self-contained. In the loop overmodules.assets.jsonentries, add:if (entry.npm_name) { moduleAliases.push({ find: entry.npm_name, replacement: entry.package }); }
and skip those names when collecting
optimizeDeps.include— they resolve to source directories, not to pre-bundlable packages (GH issue #253).
0.0.27 — 2026-08-06
- Fixed in the
[Unreleased]entry above.gen-pagesemitted@import "#module/<pkg>/…"intomodules.generated.css, which resolves only in hosts scaffolded at 0.0.27 or later; apps scaffolded earlier failvite buildafter a Python-only upgrade. Either upgrade past this release, or add the alias tohost/client_app/vite.config.tsby hand — build{ find: '#module/' + package_name, replacement: package }from each entry inclient_app/modules.assets.jsonand pass the list asresolve.alias(GH issue #253).
0.0.16 — 2026-05-25
- The
usersmodule's post-login redirect (login_redirect_url) no longer hard-codes a/fallback when the Dashboard module isn't installed —/404s on apps without a root route (e.g.smpy_gis,--preset minimal). It now redirects to the first sibling module that exposes view routes, falling back to/only as an absolute last resort. Operator-set overrides are always preserved (GH issue #173).
0.0.15 — 2026-05-21
- The
moduleBareImportResolverVite plugin no longer short-circuits onfsRoot/projectRootcontainment, so workspace-member modules atmodules/<name>/<pkg>/pages/get the same workspace-root re-resolution as wheel-installed modules. In an npm-workspaces layout the workspace root is the resolver root, so the previous early-return excluded the very modules that need it. Cross-package bare imports (maplibre-gl,pmtiles, peer deps) now resolve in both wheel and workspace install modes (GH issue #156). - The framework repo (Vite 8) seeds
optimizeDeps.rolldownOptions.resolve.moduleswith the workspacenode_modules/as a NODE_PATH-style fallback for the dep scanner (GH issue #155).
0.0.13 — 2026-05-15
- Vite's dev-mode dependency pre-bundling now resolves cross-package bare
imports (e.g.
maplibre-gl,pmtiles) from module pages whose importers sit outside the host'sclient_app/. The scaffold template (Vite 6) seedsoptimizeDeps.esbuildOptions.nodePathswith the workspacenode_modules/as a NODE_PATH-style fallback for the dep scanner (GH issue #152).
0.0.1 — 2026-04-21
Initial public release. All 12 Python packages publish to PyPI and all 3 JS packages publish to npm under the @simple-module-py scope.
simple_module_coresimple_module_dbsimple_module_hostingsimple_module_testsimple_module_authsimple_module_background_taskssimple_module_dashboardsimple_module_feature_flagssimple_module_file_storagesimple_module_permissionssimple_module_settingssimple_module_users
@simple-module-py/ui@simple-module-py/i18n@simple-module-py/tsconfig
smpy new <app>CLI generator (shipped via thesimple_module_cliPyPI distribution) scaffolding a working app withusers + dashboard + permissionspre-wired.- PyPI Trusted Publishing workflow (
.github/workflows/release.yml) for zero-secret releases. - npm Trusted Publishing for all three JS packages.