simple-module-settings 0.0.39__tar.gz → 0.0.41__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/PKG-INFO +4 -4
  2. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/package.json +1 -1
  3. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/pyproject.toml +4 -4
  4. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/endpoints/module_api.py +2 -8
  5. simple_module_settings-0.0.41/settings/invalidation.py +70 -0
  6. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/module.py +14 -0
  7. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/ModulesEdit.tsx +5 -1
  8. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/components/FieldSource.tsx +3 -3
  9. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/components/ModuleFieldRow.tsx +11 -11
  10. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/components/ModuleForm.tsx +22 -10
  11. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/components/TestConnectionButton.tsx +16 -4
  12. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/reload.py +11 -7
  13. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/store.py +4 -0
  14. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_reload.py +17 -1
  15. simple_module_settings-0.0.41/tests/test_settings_commit_invalidation.py +99 -0
  16. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_settings_secret_json_roundtrip.py +7 -0
  17. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests-js/ModuleForm.test.tsx +1 -1
  18. simple_module_settings-0.0.41/tests-js/settings_ModuleFieldLayout.test.tsx +62 -0
  19. simple_module_settings-0.0.41/tests-js/settings_ModuleFormRevert.test.tsx +88 -0
  20. simple_module_settings-0.0.41/tests-js/settings_TestConnectionButton.test.tsx +111 -0
  21. simple_module_settings-0.0.39/tests-js/settings_TestConnectionButton.test.tsx +0 -47
  22. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/.gitignore +0 -0
  23. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/LICENSE +0 -0
  24. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/README.md +0 -0
  25. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/__init__.py +0 -0
  26. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/_announce.py +0 -0
  27. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/_field_meta.py +0 -0
  28. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/_listing.py +0 -0
  29. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/_managed_keys.py +0 -0
  30. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/_module_settings.py +0 -0
  31. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/_module_settings_props.py +0 -0
  32. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/_row_masking.py +0 -0
  33. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/_secrets.py +0 -0
  34. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/_unique_write.py +0 -0
  35. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/browse_query.py +0 -0
  36. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/cli.py +0 -0
  37. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/constants.py +0 -0
  38. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/contracts/__init__.py +0 -0
  39. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/contracts/accessor.py +0 -0
  40. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/contracts/events.py +0 -0
  41. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/contracts/invalidation.py +0 -0
  42. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/contracts/registry.py +0 -0
  43. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/contracts/schemas.py +0 -0
  44. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/deps.py +0 -0
  45. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/endpoints/__init__.py +0 -0
  46. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/endpoints/_create_form.py +0 -0
  47. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/endpoints/api.py +0 -0
  48. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/endpoints/tenant_api.py +0 -0
  49. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/endpoints/views.py +0 -0
  50. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/env_vars.py +0 -0
  51. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/errors.py +0 -0
  52. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/exceptions.py +0 -0
  53. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/hydrate.py +0 -0
  54. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/known_keys.py +0 -0
  55. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/locales/de.json +0 -0
  56. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/locales/en.json +0 -0
  57. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/models.py +0 -0
  58. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/module_registry.py +0 -0
  59. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/Browse.tsx +0 -0
  60. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/Create.tsx +0 -0
  61. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/Edit.tsx +0 -0
  62. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/api.generated.ts +0 -0
  63. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/api.ts +0 -0
  64. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/components/FieldInput.tsx +0 -0
  65. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/components/JsonInput.tsx +0 -0
  66. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/components/KeyField.tsx +0 -0
  67. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/components/ResolvedValue.tsx +0 -0
  68. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/components/ScopeTabs.tsx +0 -0
  69. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/components/StoreCards.tsx +0 -0
  70. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/components/StoreTable.tsx +0 -0
  71. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/components/ValueInput.tsx +0 -0
  72. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/components/last-test.ts +0 -0
  73. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/components/validation-errors.test.ts +0 -0
  74. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/components/validation-errors.ts +0 -0
  75. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/routes.ts +0 -0
  76. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/pages/types.ts +0 -0
  77. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/py.typed +0 -0
  78. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/registration.py +0 -0
  79. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/service.py +0 -0
  80. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/services.py +0 -0
  81. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/settings.py +0 -0
  82. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/tenant_scope.py +0 -0
  83. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/settings/tenant_view.py +0 -0
  84. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_clear_route_per_scope.py +0 -0
  85. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_cli_import.py +0 -0
  86. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_concurrent_overrides.py +0 -0
  87. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_duplicate_and_race.py +0 -0
  88. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_env_pinned.py +0 -0
  89. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_hydrate.py +0 -0
  90. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_hydrate_related_fields.py +0 -0
  91. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_known_keys_meta.py +0 -0
  92. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_lenient_related_fields_validators.py +0 -0
  93. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_module_api.py +0 -0
  94. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_module_api_json_fields.py +0 -0
  95. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_module_registry.py +0 -0
  96. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_module_settings.py +0 -0
  97. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_module_settings_render.py +0 -0
  98. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_module_settings_serialize.py +0 -0
  99. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_registration.py +0 -0
  100. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_settings_accessor.py +0 -0
  101. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_settings_api.py +0 -0
  102. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_settings_default_clears_override.py +0 -0
  103. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_settings_domain_errors.py +0 -0
  104. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_settings_env_unprefixed.py +0 -0
  105. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_settings_events.py +0 -0
  106. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_settings_field_sources.py +0 -0
  107. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_settings_framework_reexports.py +0 -0
  108. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_settings_group_metadata.py +0 -0
  109. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_settings_host_restart_flags.py +0 -0
  110. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_settings_module.py +0 -0
  111. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_settings_schemas.py +0 -0
  112. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_settings_secret_masking.py +0 -0
  113. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_settings_secret_urls.py +0 -0
  114. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_settings_service.py +0 -0
  115. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_settings_typed_secret_masking.py +0 -0
  116. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_settings_view_authz.py +0 -0
  117. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_store.py +0 -0
  118. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_store_filters.py +0 -0
  119. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_store_secret_masking.py +0 -0
  120. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_tenant_settings.py +0 -0
  121. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_testable_checks.py +0 -0
  122. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests/test_upload_key_delete.py +0 -0
  123. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests-js/JsonFields.test.tsx +0 -0
  124. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests-js/ModulesEdit.test.tsx +0 -0
  125. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests-js/RestartHint.test.tsx +0 -0
  126. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tests-js/ScopeTabs.test.tsx +0 -0
  127. {simple_module_settings-0.0.39 → simple_module_settings-0.0.41}/tsconfig.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_settings
3
- Version: 0.0.39
3
+ Version: 0.0.41
4
4
  Summary: Runtime settings UI — modules plug their own settings panels into a shared admin view
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -21,9 +21,9 @@ Classifier: Topic :: Internet :: WWW/HTTP
21
21
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
- Requires-Dist: simple-module-core==0.0.39
25
- Requires-Dist: simple-module-db==0.0.39
26
- Requires-Dist: simple-module-hosting==0.0.39
24
+ Requires-Dist: simple-module-core==0.0.41
25
+ Requires-Dist: simple-module-db==0.0.41
26
+ Requires-Dist: simple-module-hosting==0.0.41
27
27
  Requires-Dist: typer<0.28,>=0.12
28
28
  Description-Content-Type: text/markdown
29
29
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simple-module-py/settings",
3
- "version": "0.0.39",
3
+ "version": "0.0.41",
4
4
  "private": true,
5
5
  "description": "Frontend assets for the Settings module",
6
6
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_settings"
3
- version = "0.0.39"
3
+ version = "0.0.41"
4
4
  description = "Runtime settings UI — modules plug their own settings panels into a shared admin view"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -21,9 +21,9 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "simple_module_core==0.0.39",
25
- "simple_module_db==0.0.39",
26
- "simple_module_hosting==0.0.39",
24
+ "simple_module_core==0.0.41",
25
+ "simple_module_db==0.0.41",
26
+ "simple_module_hosting==0.0.41",
27
27
  "typer>=0.12,<0.28",
28
28
  ]
29
29
 
@@ -12,7 +12,6 @@ from typing import Any
12
12
  from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, Response, status
13
13
  from pydantic import ValidationError
14
14
  from simple_module_core.module_settings import module_settings_registry
15
- from simple_module_core.settings_hydrate import hydrate_settings
16
15
  from simple_module_hosting.i18n_deps import TranslatorDep
17
16
  from simple_module_hosting.permissions import RequiresPermission
18
17
 
@@ -23,8 +22,8 @@ from settings._module_settings import (
23
22
  )
24
23
  from settings._module_settings_props import serialize
25
24
  from settings.constants import PERM_DELETE, PERM_EDIT, PERM_VIEW
26
- from settings.contracts.events import SettingsReloaded
27
25
  from settings.deps import get_setting_service
26
+ from settings.invalidation import reload_on_commit
28
27
  from settings.reload import EnvPinnedError, apply_changes_and_reload
29
28
  from settings.service import SettingService
30
29
  from settings.store import SettingsStore
@@ -162,11 +161,6 @@ async def clear_module_field(
162
161
  store = SettingsStore(service)
163
162
  await store.clear_override(package, field)
164
163
 
165
- hydrated = await hydrate_settings(cls, store, package)
166
- services = getattr(request.app.state, package)
167
- services.settings = hydrated
168
-
169
- bus = request.app.state.sm.event_bus
170
- await bus.publish(SettingsReloaded(package=package, changed=(field,)))
164
+ reload_on_commit(request.app, request.app.state.sm.event_bus, store, package)
171
165
 
172
166
  return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -0,0 +1,70 @@
1
+ """Rehydrate module settings and notify local consumers only after durable writes."""
2
+
3
+ import logging
4
+
5
+ from fastapi import FastAPI
6
+ from simple_module_core.app_state import get_services
7
+ from simple_module_core.events import EventBus
8
+ from simple_module_core.invalidation import InvalidationBus
9
+ from simple_module_core.invalidation_refresh import InvalidationRefresh
10
+ from simple_module_core.module_settings import module_settings_registry
11
+ from simple_module_core.settings_hydrate import hydrate_settings
12
+ from simple_module_db.stored_settings import settings_table_exists
13
+
14
+ from settings.contracts.events import SettingsReloaded
15
+ from settings.store import SettingsStore
16
+
17
+ CHANNEL = "settings.modules"
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ async def reload_package(app: FastAPI, bus: EventBus, store: SettingsStore, package: str) -> None:
22
+ cls = module_settings_registry(app).get(package)
23
+ if cls is None:
24
+ return
25
+ services = getattr(app.state, package, None)
26
+ if services is None:
27
+ return
28
+ hydrated = await hydrate_settings(cls, store, package, lenient=True)
29
+ changed = tuple(
30
+ sorted(
31
+ name
32
+ for name in cls.model_fields
33
+ if getattr(services.settings, name) != getattr(hydrated, name)
34
+ )
35
+ )
36
+ services.settings = hydrated
37
+ if changed:
38
+ await bus.publish(SettingsReloaded(package=package, changed=changed))
39
+
40
+
41
+ def reload_on_commit(app: FastAPI, bus: EventBus, store: SettingsStore, package: str) -> None:
42
+ async def committed() -> None:
43
+ invalidation = getattr(get_services(app), "invalidation", None)
44
+ if invalidation is None:
45
+ await reload_package(app, bus, store, package)
46
+ else:
47
+ await invalidation.publish(CHANNEL, key=package)
48
+
49
+ store.on_commit(committed)
50
+
51
+
52
+ def subscribe(bus: InvalidationBus, app: FastAPI) -> InvalidationRefresh:
53
+ from settings.service import SettingService
54
+
55
+ async def reload(key: str | None) -> None:
56
+ async with app.state.sm.db.session_factory() as db:
57
+ if not await settings_table_exists(db): # fresh install, before migrations
58
+ return
59
+ store = SettingsStore(SettingService(db))
60
+ packages = [key] if key is not None else module_settings_registry(app).all_packages()
61
+ for package in packages:
62
+ # One package's failed read or invalid cross-field rule must not
63
+ # starve the packages after it on every later cycle.
64
+ try:
65
+ await reload_package(app, app.state.sm.event_bus, store, package)
66
+ except Exception:
67
+ logger.exception("Reloading %s settings failed; keeping current ones", package)
68
+ await db.rollback()
69
+
70
+ return InvalidationRefresh(bus, CHANNEL, reload)
@@ -29,6 +29,7 @@ from settings.constants import (
29
29
  )
30
30
 
31
31
  if TYPE_CHECKING:
32
+ from simple_module_core.invalidation import InvalidationBus
32
33
  from sqlalchemy.ext.asyncio import AsyncSession
33
34
 
34
35
 
@@ -79,6 +80,19 @@ class SettingsModule(ModuleBase):
79
80
  # Self-register so the UI lists our own settings alongside other modules.
80
81
  services.module_registry.register(MODULE_PACKAGE, SettingsSettings)
81
82
 
83
+ def register_invalidations(self, bus: InvalidationBus, app: FastAPI) -> None:
84
+ from settings.invalidation import subscribe
85
+
86
+ self._refresh = subscribe(bus, app)
87
+
88
+ async def on_startup(self, app: FastAPI) -> None:
89
+ if hasattr(self, "_refresh"):
90
+ self._refresh.start()
91
+
92
+ async def on_shutdown(self, app: FastAPI) -> None:
93
+ if hasattr(self, "_refresh"):
94
+ await self._refresh.stop()
95
+
82
96
  def register_exception_handlers(self, app: FastAPI) -> None:
83
97
  from settings.errors import install_exception_handlers
84
98
 
@@ -142,7 +142,11 @@ function ModulesEdit({ modules, testable = {} }: Props) {
142
142
  </div>
143
143
  </Card>
144
144
  ) : current ? (
145
- <ModuleForm module={current} checks={testable[current.package] ?? []} />
145
+ <ModuleForm
146
+ key={current.package}
147
+ module={current}
148
+ checks={testable[current.package] ?? []}
149
+ />
146
150
  ) : (
147
151
  <p className="text-muted-foreground">{t(keys.settings.modules.empty_title)}</p>
148
152
  )}
@@ -55,10 +55,10 @@ export function FieldSource({ field, onReset }: { field: FieldMeta; onReset: ()
55
55
  const showDefault = !isBlank(field.default) && String(field.default) !== String(field.value);
56
56
  return (
57
57
  // Wraps rather than clips: `SM_FILE_STORAGE_QUOTA_BYTES` has no space to
58
- // break at, so a 210px column cut it mid-word with nothing to say so.
59
- // `break-words` breaks only the tokens that do not fit.
58
+ // break at, and as an unbreakable token it would set the column's minimum
59
+ // width; `overflow-wrap:anywhere` lets it break inside the identifier.
60
60
  <span
61
- className="block break-words font-mono text-[11.5px] leading-snug text-muted-foreground"
61
+ className="block [overflow-wrap:anywhere] font-mono text-[11.5px] leading-snug text-muted-foreground"
62
62
  title={
63
63
  field.env_readable
64
64
  ? t(keys.settings.modules.env_var_hint)
@@ -16,10 +16,11 @@ interface Props {
16
16
  /**
17
17
  * One settings row: label, control, trailing meta.
18
18
  *
19
- * Three columns rather than a stacked label block, because the trailing column
20
- * is what makes the screen readable at a glance — every row answers "where is
21
- * this value from?" in the same place, so the exceptions stand out instead of
22
- * having to be hunted for.
19
+ * Three columns when the editor card has room, because the trailing column is
20
+ * what makes the screen readable at a glance — every row answers "where is
21
+ * this value from?" in the same place, so the exceptions stand out. The tracks
22
+ * follow the card's width (container query), not the viewport: two sidebars
23
+ * can leave a narrow card on a wide screen, and the row stacks instead.
23
24
  */
24
25
  export function ModuleFieldRow({
25
26
  field,
@@ -37,15 +38,14 @@ export function ModuleFieldRow({
37
38
  const showDescription = field.description && field.type !== 'bool';
38
39
 
39
40
  return (
40
- // `minmax(170px, auto)`: a fixed 170px track cannot grow, so a long field
41
- // name overflowed it and printed straight over the input beside it.
42
- <div className="grid gap-3 sm:grid-cols-[minmax(170px,auto)_1fr] lg:grid-cols-[minmax(170px,auto)_1fr_210px] lg:items-center">
41
+ // Query the editor card: two sidebars can leave little space at a wide viewport.
42
+ <div className="grid grid-cols-1 gap-3 @md:grid-cols-[minmax(0,140px)_minmax(180px,1fr)] @xl:grid-cols-[minmax(0,140px)_minmax(180px,1fr)_minmax(0,160px)] @xl:items-center">
43
43
  <div className="min-w-0">
44
- <label htmlFor={id} className="block break-words text-[13px] font-medium">
44
+ <label htmlFor={id} className="block [overflow-wrap:anywhere] text-[13px] font-medium">
45
45
  {field.name}
46
46
  </label>
47
47
  {showDescription && (
48
- <p className="mt-0.5 text-[11.5px] leading-snug text-muted-foreground">
48
+ <p className="mt-0.5 [overflow-wrap:anywhere] text-[11.5px] leading-snug text-muted-foreground">
49
49
  {field.description}
50
50
  </p>
51
51
  )}
@@ -65,7 +65,7 @@ export function ModuleFieldRow({
65
65
  </fieldset>
66
66
  </div>
67
67
 
68
- <div className="sm:col-span-2 lg:col-span-1 lg:justify-self-start">
68
+ <div className="min-w-0 [overflow-wrap:anywhere] @md:col-span-2 @xl:col-span-1 @xl:justify-self-start">
69
69
  <FieldSource field={field} onReset={onReset} />
70
70
  </div>
71
71
 
@@ -75,7 +75,7 @@ export function ModuleFieldRow({
75
75
  {error && (
76
76
  <p
77
77
  data-testid={`field-error-${field.name}`}
78
- className="break-words text-xs text-destructive sm:col-span-1 sm:col-start-2 lg:col-span-2"
78
+ className="min-w-0 [overflow-wrap:anywhere] text-xs text-destructive @md:col-span-1 @md:col-start-2 @xl:col-span-2"
79
79
  >
80
80
  {error}
81
81
  </p>
@@ -42,18 +42,23 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
42
42
  return o;
43
43
  }, [m.fields]);
44
44
 
45
- const [values, setValues] = useState<Record<string, unknown>>(initial);
46
- const [baseline, setBaseline] = useState<Record<string, unknown>>(initial);
45
+ const [{ values, baseline }, setBuffer] = useState({ values: initial, baseline: initial });
47
46
  const [errors, setErrors] = useState<Record<string, string>>({});
48
47
  const [busy, setBusy] = useState(false);
49
48
  const [saved, setSaved] = useState(false);
50
49
  const [formErrors, setFormErrors] = useState<string[]>([]);
51
50
 
52
- // Reset the edit buffer whenever the underlying module changes (package
53
- // switch, or server-reloaded props after a save/reset).
51
+ // Refresh saved values without discarding drafts in other fields (#637).
54
52
  useEffect(() => {
55
- setValues(initial);
56
- setBaseline(initial);
53
+ setBuffer((prev) => ({
54
+ values: Object.fromEntries(
55
+ Object.entries(initial).map(([name, value]) => [
56
+ name,
57
+ notEqual(prev.values[name], prev.baseline[name]) ? prev.values[name] : value,
58
+ ]),
59
+ ),
60
+ baseline: initial,
61
+ }));
57
62
  setErrors({});
58
63
  setSaved(false);
59
64
  }, [initial]);
@@ -107,7 +112,7 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
107
112
  } else {
108
113
  // Keeping the form mounted lets the confirmation remain visible instead
109
114
  // of being discarded by an immediate Inertia reload.
110
- setBaseline({ ...values });
115
+ setBuffer((prev) => ({ ...prev, baseline: { ...values } }));
111
116
  setSaved(true);
112
117
  }
113
118
  setBusy(false);
@@ -121,11 +126,15 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
121
126
  params: { path: { package: m.package, field: name } },
122
127
  }),
123
128
  );
129
+ setBuffer((prev) => ({
130
+ ...prev,
131
+ values: { ...prev.values, [name]: prev.baseline[name] },
132
+ }));
124
133
  router.reload({ only: ['modules'] });
125
134
  }
126
135
 
127
136
  return (
128
- <div className="flex flex-1 flex-col gap-4">
137
+ <div className="flex min-w-0 flex-1 flex-col gap-4">
129
138
  {/* Outside the card: the heading names the pane, and the deck keeps the
130
139
  card for the fields alone. */}
131
140
  <header className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
@@ -169,7 +178,7 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
169
178
  </div>
170
179
  )}
171
180
 
172
- <Card className="flex flex-1 flex-col gap-4 border-border p-6">
181
+ <Card className="@container flex min-w-0 flex-1 flex-col gap-4 border-border p-6">
173
182
  {/* No inner scroller: the card grows and the page scrolls, which is
174
183
  one scrollbar for the screen instead of three nested ones. */}
175
184
  <div className="flex flex-col gap-4">
@@ -187,7 +196,10 @@ export function ModuleForm({ module: m, checks = [] }: Props) {
187
196
  onReset={() => onReset(f.name)}
188
197
  onChange={(name, v) => {
189
198
  setSaved(false);
190
- setValues((prev) => ({ ...prev, [name]: v }));
199
+ setBuffer((prev) => ({
200
+ ...prev,
201
+ values: { ...prev.values, [name]: v },
202
+ }));
191
203
  }}
192
204
  />
193
205
  ))}
@@ -3,7 +3,7 @@ import { Button } from '@simple-module-py/ui/components/ui/button';
3
3
  import { useRelativeTime } from '@simple-module-py/ui/hooks/use-relative-time';
4
4
  import { apiFetch } from '@simple-module-py/ui/lib/api';
5
5
  import { CheckCircle2, PlugZap, XCircle } from 'lucide-react';
6
- import { useEffect, useState } from 'react';
6
+ import { useEffect, useRef, useState } from 'react';
7
7
  import { ROUTES } from '../routes';
8
8
  import { type LastTest, readLastTest, writeLastTest } from './last-test';
9
9
 
@@ -35,9 +35,19 @@ export function TestConnectionButton({ pkg, checks }: Props) {
35
35
  const [error, setError] = useState<string | null>(null);
36
36
  const [last, setLast] = useState<LastTest | null>(null);
37
37
 
38
+ const requestId = useRef(0);
39
+
38
40
  // Read on mount rather than during render: the result is per-tab session
39
41
  // state, and touching sessionStorage while rendering breaks SSR hydration.
40
- useEffect(() => setLast(readLastTest(pkg)), [pkg]);
42
+ useEffect(() => {
43
+ setLast(readLastTest(pkg));
44
+ setResults(null);
45
+ setError(null);
46
+ setBusy(false);
47
+ return () => {
48
+ requestId.current++;
49
+ };
50
+ }, [pkg]);
41
51
 
42
52
  // "users.mailer" → "mailer". Naming the check is the difference between
43
53
  // "Test connection" and knowing an SMTP dial is about to happen; with two
@@ -50,6 +60,7 @@ export function TestConnectionButton({ pkg, checks }: Props) {
50
60
  : t(keys.settings.modules.test_connection_generic);
51
61
 
52
62
  async function run() {
63
+ const id = ++requestId.current;
53
64
  setBusy(true);
54
65
  setError(null);
55
66
  setResults(null);
@@ -57,8 +68,9 @@ export function TestConnectionButton({ pkg, checks }: Props) {
57
68
  const result = await apiFetch<{ checks?: CheckResult[] }>(ROUTES.testConnection(pkg), {
58
69
  method: 'POST',
59
70
  });
60
- if (result.ok) {
61
- const checkResults = result.data?.checks ?? [];
71
+ if (id !== requestId.current) return;
72
+ const checkResults = result.data?.checks;
73
+ if (result.ok && Array.isArray(checkResults) && checkResults.length > 0) {
62
74
  setResults(checkResults);
63
75
  setLast(
64
76
  writeLastTest(
@@ -11,9 +11,13 @@ from pydantic_core import to_jsonable_python
11
11
  from pydantic_settings import BaseSettings
12
12
  from simple_module_core.events import EventBus
13
13
  from simple_module_core.module_settings import module_settings_registry
14
- from simple_module_core.settings_hydrate import env_supplied_fields, value_type_for_field
14
+ from simple_module_core.settings_hydrate import (
15
+ env_supplied_fields,
16
+ hydrate_settings,
17
+ value_type_for_field,
18
+ )
15
19
 
16
- from settings.contracts.events import SettingsReloaded
20
+ from settings.invalidation import reload_on_commit
17
21
  from settings.store import SettingsStore
18
22
 
19
23
 
@@ -87,7 +91,7 @@ async def apply_changes_and_reload(
87
91
  package: str,
88
92
  changes: dict[str, Any],
89
93
  ) -> BaseSettings:
90
- """Validate, persist, hot-swap, and publish ``SettingsReloaded``."""
94
+ """Validate and persist; reload and publish ``SettingsReloaded`` after commit."""
91
95
  registry = module_settings_registry(app)
92
96
  cls = registry.get(package)
93
97
  if cls is None:
@@ -97,8 +101,8 @@ async def apply_changes_and_reload(
97
101
  if unknown:
98
102
  raise KeyError(f"Unknown field(s) for {package!r}: {sorted(unknown)}")
99
103
 
100
- services = getattr(app.state, package)
101
- current = services.settings
104
+ # Include prior writes in this transaction without exposing them to other requests.
105
+ current = await hydrate_settings(cls, store, package, lenient=True)
102
106
  # Compare against the *dumped* current value, not the attribute: a field
103
107
  # typed as a list of models (branding's ``footer_links``) holds model
104
108
  # instances, while ``changes`` carries the plain dicts a DTO dumps to. The
@@ -118,6 +122,7 @@ async def apply_changes_and_reload(
118
122
  await store.clear_override(package, name)
119
123
 
120
124
  if not diff:
125
+ reload_on_commit(app, bus, store, package)
121
126
  return current
122
127
 
123
128
  # Only a field that would actually change is refused: a client that echoes
@@ -153,6 +158,5 @@ async def apply_changes_and_reload(
153
158
  except ValidationError as exc:
154
159
  raise _relocate(exc, field_name) from exc
155
160
 
156
- services.settings = validated
157
- await bus.publish(SettingsReloaded(package=package, changed=tuple(sorted(diff))))
161
+ reload_on_commit(app, bus, store, package)
158
162
  return validated
@@ -18,6 +18,7 @@ see ``_module_settings._field_view``.
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
+ from simple_module_db.callbacks import OnCommitCallback
21
22
  from simple_module_db.stored_settings import ( # noqa: F401 (re-exported, #500)
22
23
  SYSTEM_SCOPE_ID,
23
24
  get_overrides_sync,
@@ -41,6 +42,9 @@ class SettingsStore:
41
42
  def __init__(self, service: SettingService) -> None:
42
43
  self._service = service
43
44
 
45
+ def on_commit(self, callback: OnCommitCallback) -> None:
46
+ self._service.db.on_commit(callback)
47
+
44
48
  async def get_overrides(self, package: str) -> dict[str, tuple[str, str]]:
45
49
  """Return ``{field_name: (raw_value, value_type)}`` for a package."""
46
50
  items = await self._service.list_by_scope_unmasked(SettingScope.SYSTEM, SYSTEM_SCOPE_ID)
@@ -14,6 +14,7 @@ from settings.settings import SettingsSettings
14
14
  from settings.store import SettingsStore
15
15
  from simple_module_core.events import EventBus
16
16
  from simple_module_core.module_settings import register_module_settings
17
+ from simple_module_db.transaction import finalize_session
17
18
 
18
19
 
19
20
  class _UsersCfg(BaseSettings):
@@ -56,7 +57,10 @@ async def test_apply_changes_updates_app_state_and_fires_event(app_and_bus, db_s
56
57
 
57
58
  assert new_settings.allow_signup is True
58
59
  assert new_settings.smtp_port == 587
59
- assert app.state.users.settings is new_settings
60
+ assert app.state.users.settings.allow_signup is False
61
+ assert received == []
62
+ await finalize_session(db_session)
63
+ assert app.state.users.settings == new_settings
60
64
  assert received == [SettingsReloaded(package="users", changed=("allow_signup", "smtp_port"))]
61
65
 
62
66
  persisted = await store.get_overrides("users")
@@ -96,3 +100,15 @@ async def test_apply_changes_unknown_package_raises(app_and_bus, db_session):
96
100
  package="unknown",
97
101
  changes={"x": 1},
98
102
  )
103
+
104
+
105
+ async def test_multiple_settings_edits_in_one_transaction_keep_both(app_and_bus, db_session):
106
+ app, bus = app_and_bus
107
+ store = SettingsStore(SettingService(db_session))
108
+ await apply_changes_and_reload(app, bus, store, package="users", changes={"allow_signup": True})
109
+ await apply_changes_and_reload(app, bus, store, package="users", changes={"smtp_port": 587})
110
+ assert app.state.users.settings.allow_signup is False
111
+ assert app.state.users.settings.smtp_port == 25
112
+ await finalize_session(db_session)
113
+ assert app.state.users.settings.allow_signup is True
114
+ assert app.state.users.settings.smtp_port == 587
@@ -0,0 +1,99 @@
1
+ """Module settings and reload events only reflect committed state."""
2
+
3
+ import pytest
4
+ from settings.contracts.events import SettingsReloaded
5
+ from settings.reload import apply_changes_and_reload
6
+ from settings.service import SettingService
7
+ from settings.store import SettingsStore
8
+ from simple_module_core.invalidation import Invalidation
9
+ from simple_module_db.transaction import finalize_session, rollback_session
10
+
11
+
12
+ @pytest.mark.parametrize("commit", [False, True])
13
+ async def test_settings_and_events_wait_for_commit(app, commit):
14
+ received = []
15
+ bus = app.state.sm.event_bus
16
+
17
+ async def record(event):
18
+ received.append(event)
19
+
20
+ bus.subscribe(SettingsReloaded, record)
21
+ original = app.state.users.settings
22
+ wanted = not original.allow_signup
23
+ async with app.state.sm.db.session_factory() as db:
24
+ await apply_changes_and_reload(
25
+ app,
26
+ bus,
27
+ SettingsStore(SettingService(db)),
28
+ package="users",
29
+ changes={"allow_signup": wanted},
30
+ )
31
+ assert app.state.users.settings is original
32
+ assert received == []
33
+ if commit:
34
+ await finalize_session(db)
35
+ else:
36
+ await rollback_session(db)
37
+ assert app.state.users.settings.allow_signup is (wanted if commit else original.allow_signup)
38
+ assert len(received) == int(commit)
39
+ if commit:
40
+ assert received[0].changed == ("allow_signup",)
41
+
42
+
43
+ async def test_remote_settings_changes_and_clears_reload_and_emit_event(app):
44
+ received = []
45
+
46
+ async def record(event):
47
+ received.append(event)
48
+
49
+ app.state.sm.event_bus.subscribe(SettingsReloaded, record)
50
+ original = app.state.users.settings.allow_signup
51
+ factory = app.state.sm.db.session_factory
52
+ async with factory() as db:
53
+ await SettingsStore(SettingService(db)).set_override(
54
+ "users", "allow_signup", str(not original), "bool"
55
+ )
56
+ await db.commit()
57
+ message = Invalidation(channel="settings.modules", key="users", origin="other")
58
+ await app.state.sm.invalidation.deliver(message.to_wire())
59
+ assert app.state.users.settings.allow_signup is not original
60
+ assert received[-1].changed == ("allow_signup",)
61
+ async with factory() as db:
62
+ await SettingsStore(SettingService(db)).clear_override("users", "allow_signup")
63
+ await db.commit()
64
+ await app.state.sm.invalidation.deliver(message.to_wire())
65
+ assert app.state.users.settings.allow_signup is original
66
+ assert len(received) == 2
67
+
68
+
69
+ @pytest.mark.parametrize("commit", [False, True])
70
+ async def test_clearing_module_setting_waits_for_commit(app, commit):
71
+ from settings.endpoints.module_api import clear_module_field
72
+ from starlette.requests import Request
73
+
74
+ original = app.state.users.settings.allow_signup
75
+ factory = app.state.sm.db.session_factory
76
+ async with factory() as db:
77
+ await SettingsStore(SettingService(db)).set_override(
78
+ "users", "allow_signup", str(not original), "bool"
79
+ )
80
+ await db.commit()
81
+ await app.state.sm.invalidation.publish("settings.modules", key="users")
82
+ received = []
83
+
84
+ async def record(event):
85
+ received.append(event)
86
+
87
+ app.state.sm.event_bus.subscribe(SettingsReloaded, record)
88
+ async with factory() as db:
89
+ await clear_module_field(
90
+ "users", "allow_signup", Request({"type": "http", "app": app}), SettingService(db)
91
+ )
92
+ assert app.state.users.settings.allow_signup is not original
93
+ assert received == []
94
+ if commit:
95
+ await finalize_session(db)
96
+ else:
97
+ await rollback_session(db)
98
+ assert app.state.users.settings.allow_signup is (original if commit else not original)
99
+ assert len(received) == int(commit)
@@ -25,6 +25,13 @@ class _SecretCfg(BaseSettings):
25
25
  class _Store:
26
26
  def __init__(self) -> None:
27
27
  self.rows: dict[str, tuple[str, str]] = {}
28
+ self.callbacks = []
29
+
30
+ def on_commit(self, callback) -> None:
31
+ self.callbacks.append(callback)
32
+
33
+ async def get_overrides(self, package):
34
+ return dict(self.rows)
28
35
 
29
36
  async def set_override(self, package, field, raw, value_type) -> None:
30
37
  self.rows[field] = (raw, value_type)
@@ -105,7 +105,7 @@ describe('ModuleForm', () => {
105
105
  // control's cell, where it wrapped a word per line.
106
106
  const error = screen.getByText(/item rejected/);
107
107
  expect(error.contains(screen.getByLabelText('retention_days'))).toBe(false);
108
- expect(error.className).toContain('lg:col-span-2');
108
+ expect(error.className).toContain('@xl:col-span-2');
109
109
  });
110
110
 
111
111
  test('shows an error whose loc names no field at form level', async () => {