From 8f79212814807f5c54509df26a38a6ed7973190b Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Wed, 21 Oct 2026 00:47:22 -0700 Subject: [PATCH 1/5] Match observer stub to model lifecycle events The generated observer imported Model from the uninstalled masoniteorm package, stubbed events that never fire and carried docstrings. It now imports from fastapi_startkit.masoniteorm.models and defines async handlers for the eight dispatched events in order. A test imports the generated file and dispatches every event. --- .../adr/026-observer-stub-lifecycle-events.md | 28 ++++++ docs/adr/AGENTS.md | 1 + .../masoniteorm/commands/stubs/observer.stub | 92 ++----------------- .../commands/test_make_commands.py | 36 ++++++-- 4 files changed, 68 insertions(+), 89 deletions(-) create mode 100644 docs/adr/026-observer-stub-lifecycle-events.md diff --git a/docs/adr/026-observer-stub-lifecycle-events.md b/docs/adr/026-observer-stub-lifecycle-events.md new file mode 100644 index 00000000..180f5ec3 --- /dev/null +++ b/docs/adr/026-observer-stub-lifecycle-events.md @@ -0,0 +1,28 @@ +# 026. Observer stub matches model lifecycle events + +- Date: 2026-10-21 +- Status: Accepted + +## Problem + +`observer.stub`, rendered by `MakeObserverCommand`, imports `Model` from `masoniteorm.models`, a package that is not installed with the framework, so any generated observer that is imported fails with `ModuleNotFoundError`. It stubs `booted`, `booting`, `hydrating` and `hydrated`, which the model never dispatches, and every method carries a Google-style docstring, which the coding standards forbid. ADR 025 defined the events that are dispatched (`saving`, `creating`, `updating`, `created`, `updated`, `saved`, `deleting`, `deleted`) and awaits async handlers. + +## Alternatives + +1. **Fix only the import.** Smallest change, but users keep getting handlers that never fire, plus the docstrings. +2. **Import the generated model class for the type hint.** More precise typing, but the model module and its path are not known to the command, and the observer would fail to import when the model does not exist yet. +3. **Import `Model` from `fastapi_startkit.masoniteorm.models` and stub exactly the dispatched events as async methods.** Always importable, and the handlers match what fires. + +## Decision + +Option 3. The stub imports `Model` from `fastapi_startkit.masoniteorm.models` and defines `async def (self, : Model) -> None` for the eight lifecycle events in dispatch order. It has no docstrings or comments. Handlers are async because the ORM is async-first and `observe_events` awaits them, so a handler can query the database. Sync handlers still work for users who prefer them. + +## Implementation + +- `masoniteorm/commands/stubs/observer.stub` is rewritten; `MakeObserverCommand` is unchanged. +- `model.stub` has the same outdated `masoniteorm.models` import and is left for a separate change. + +## Validation + +- `tests/masoniteorm/commands/test_make_commands.py` checks the import line, the async typed signature, and that the file has no docstrings or comments. It also loads the generated file with `importlib`, asserts that its handlers are exactly the eight events in order, and dispatches each event through `observe_events` on a `Model` subclass. +- In a project created with `uvx fastapi-startkit init`, running the command against the local build generated `app/observers/OrderObserver.py`, and it imported and handled all eight events. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index 87b85ae5..9d2eb6c3 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -31,3 +31,4 @@ Read this index first, then open the records relevant to the change. Before impl | [023](023-interactive-installer-prompts.md) | 2026-10-19 | Interactive installer prompts | `fastapi-startkit init` uses a stdlib `termios` module (`installer/prompts.py`) for Laravel Prompts-style boxed text/select/multiselect/confirm with arrow keys and descriptive questions and option hints; no new dependency; falls back to Cleo numbered prompts on Windows, piped input and in tests. | | [024](024-installer-controller-module.md) | 2026-10-19 | Installer controllers in their own module | `fastapi-startkit init` generates `HomeController` in `app/http/controllers/home_controller.py` (JSON or Inertia variant) instead of inside `routes/api.py`; one shared routes stub imports and registers it, matching the PingCRM layout. | | [025](025-model-lifecycle-events.md) | 2026-10-20 | Model lifecycle events, automatic timestamps and datetime casts | `save()` fires `saving → creating/updating → query → created/updated → saved`. Observer changes are persisted, and `updating` fires only when the model is dirty. A new `Model.delete()` fires `deleting → DELETE → deleted`. `observe_events` is async and awaits async handlers. Mass query-builder writes fire no events. Laravel-style auto timestamps: `created_at`/`updated_at` are filled on create unless supplied, `updated_at` moves on update, and `__timestamps__ = False` disables both. `DateCast.set` returns a UTC datetime; `Connection.prepare_binding` passes it unchanged to Postgres/MySQL, and SQLite converts it to a naive UTC string. Postgres registers an asyncpg `timestamp` codec that stores aware values as naive UTC; `timestamptz` keeps asyncpg's codec. `Annotated[T, Cast]` (a `BaseCast` subclass or instance) overrides a column's cast, and `Attribute.__casts__` is removed. | +| [026](026-observer-stub-lifecycle-events.md) | 2026-10-21 | Observer stub matches model lifecycle events | `observer.stub` (`MakeObserverCommand`) imports `Model` from `fastapi_startkit.masoniteorm.models` instead of the uninstalled `masoniteorm.models`, drops its docstrings and the never-dispatched `booted`/`booting`/`hydrating`/`hydrated` handlers, and stubs the ADR 025 events in dispatch order (`saving`, `creating`, `updating`, `created`, `updated`, `saved`, `deleting`, `deleted`) as `async def (self, : Model) -> None`. A test imports the generated file and dispatches every event. `model.stub` still has the old import. | diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/stubs/observer.stub b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/stubs/observer.stub index 9bdff7d4..7e24ccb0 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/stubs/observer.stub +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/stubs/observer.stub @@ -1,101 +1,27 @@ -"""__CLASS__ Observer""" - -from masoniteorm.models import Model +from fastapi_startkit.masoniteorm.models import Model class __CLASS__Observer: - def created(self, __MODEL_VARIABLE__): - """Handle the __MODEL__ "created" event. - - Args: - __MODEL_VARIABLE__ (masoniteorm.models.Model): __MODEL__ model. - """ + async def saving(self, __MODEL_VARIABLE__: Model) -> None: pass - def creating(self, __MODEL_VARIABLE__): - """Handle the __MODEL__ "creating" event. - - Args: - __MODEL_VARIABLE__ (masoniteorm.models.Model): __MODEL__ model. - """ + async def creating(self, __MODEL_VARIABLE__: Model) -> None: pass - def saving(self, __MODEL_VARIABLE__): - """Handle the __MODEL__ "saving" event. - - Args: - __MODEL_VARIABLE__ (masoniteorm.models.Model): __MODEL__ model. - """ + async def updating(self, __MODEL_VARIABLE__: Model) -> None: pass - def saved(self, __MODEL_VARIABLE__): - """Handle the __MODEL__ "saved" event. - - Args: - __MODEL_VARIABLE__ (masoniteorm.models.Model): __MODEL__ model. - """ + async def created(self, __MODEL_VARIABLE__: Model) -> None: pass - def updating(self, __MODEL_VARIABLE__): - """Handle the __MODEL__ "updating" event. - - Args: - __MODEL_VARIABLE__ (masoniteorm.models.Model): __MODEL__ model. - """ + async def updated(self, __MODEL_VARIABLE__: Model) -> None: pass - def updated(self, __MODEL_VARIABLE__): - """Handle the __MODEL__ "updated" event. - - Args: - __MODEL_VARIABLE__ (masoniteorm.models.Model): __MODEL__ model. - """ + async def saved(self, __MODEL_VARIABLE__: Model) -> None: pass - def booted(self, __MODEL_VARIABLE__): - """Handle the __MODEL__ "booted" event. - - Args: - __MODEL_VARIABLE__ (masoniteorm.models.Model): __MODEL__ model. - """ - pass - - def booting(self, __MODEL_VARIABLE__): - """Handle the __MODEL__ "booting" event. - - Args: - __MODEL_VARIABLE__ (masoniteorm.models.Model): __MODEL__ model. - """ + async def deleting(self, __MODEL_VARIABLE__: Model) -> None: pass - def hydrating(self, __MODEL_VARIABLE__): - """Handle the __MODEL__ "hydrating" event. - - Args: - __MODEL_VARIABLE__ (masoniteorm.models.Model): __MODEL__ model. - """ - pass - - def hydrated(self, __MODEL_VARIABLE__): - """Handle the __MODEL__ "hydrated" event. - - Args: - __MODEL_VARIABLE__ (masoniteorm.models.Model): __MODEL__ model. - """ - pass - - def deleting(self, __MODEL_VARIABLE__): - """Handle the __MODEL__ "deleting" event. - - Args: - __MODEL_VARIABLE__ (masoniteorm.models.Model): __MODEL__ model. - """ - pass - - def deleted(self, __MODEL_VARIABLE__): - """Handle the __MODEL__ "deleted" event. - - Args: - __MODEL_VARIABLE__ (masoniteorm.models.Model): __MODEL__ model. - """ + async def deleted(self, __MODEL_VARIABLE__: Model) -> None: pass diff --git a/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py b/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py index 5ad20843..34cf392e 100644 --- a/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py +++ b/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py @@ -12,6 +12,7 @@ from fastapi_startkit.masoniteorm.commands.MakeObserverCommand import MakeObserverCommand from fastapi_startkit.masoniteorm.commands.MakeSeedCommand import MakeSeedCommand from fastapi_startkit.masoniteorm.migrations.Migration import Migration +from fastapi_startkit.masoniteorm.models import Model from .fixtures.app import create_app, DB_PATH @@ -238,8 +239,14 @@ def test_reports_when_seed_already_exists(self): class TestMakeObserverCommand(_TempCwdTestCase): - # make:observer emits a plain observer class with no schema side effects, so - # the generated file content is the only meaningful contract to assert. + LIFECYCLE_EVENTS = ["saving", "creating", "updating", "created", "updated", "saved", "deleting", "deleted"] + + def _load_observer(self, file_name, class_name): + spec = importlib.util.spec_from_file_location(class_name, os.path.join("app/observers", file_name)) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return getattr(module, class_name) + def test_creates_observer_file(self): tester = self._tester(MakeObserverCommand) tester.execute("Post") @@ -248,17 +255,34 @@ def test_creates_observer_file(self): self.assertIn("Observer created:", output) content = open(os.path.join("app/observers", "PostObserver.py")).read() + self.assertIn("from fastapi_startkit.masoniteorm.models import Model", content) self.assertIn("class PostObserver:", content) - self.assertIn("def created(self, post):", content) + self.assertIn("async def created(self, post: Model) -> None:", content) + self.assertNotIn('"""', content) + self.assertNotIn("#", content) - def test_model_option_controls_variable_and_type(self): + def test_model_option_controls_variable_name(self): tester = self._tester(MakeObserverCommand) tester.execute("Audit --model User") content = open(os.path.join("app/observers", "AuditObserver.py")).read() self.assertIn("class AuditObserver:", content) - self.assertIn("def created(self, user):", content) - self.assertIn("User model.", content) + self.assertIn("async def created(self, user: Model) -> None:", content) + + def test_generated_observer_imports_and_handles_lifecycle_events(self): + self._tester(MakeObserverCommand).execute("Post") + + observer_class = self._load_observer("PostObserver.py", "PostObserver") + handlers = [name for name in vars(observer_class) if not name.startswith("_")] + self.assertEqual(handlers, self.LIFECYCLE_EVENTS) + + class Post(Model): + __observers__ = {} + + Post.observe(observer_class()) + post = Post() + for event in self.LIFECYCLE_EVENTS: + asyncio.run(post.observe_events(post, event)) def test_reports_when_observer_already_exists(self): self._tester(MakeObserverCommand).execute("Post") From fe41a13a61ec4d1136eab9ef432f05f3a693ac90 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Wed, 21 Oct 2026 00:51:38 -0700 Subject: [PATCH 2/5] Fix model stub import and register db:make:observer The model stub imported Model from the uninstalled masoniteorm package, so every generated model failed to import. The observer command was never registered, so artisan could not run it; it is now db:make:observer, matching the other db:make commands, and DatabaseProvider registers it. --- .../adr/026-observer-stub-lifecycle-events.md | 14 +++++++-- docs/adr/AGENTS.md | 2 +- .../commands/MakeObserverCommand.py | 2 +- .../masoniteorm/commands/stubs/model.stub | 6 +--- .../masoniteorm/providers/DatabaseProvider.py | 2 ++ .../commands/test_make_commands.py | 31 ++++++++++++++----- 6 files changed, 40 insertions(+), 17 deletions(-) diff --git a/docs/adr/026-observer-stub-lifecycle-events.md b/docs/adr/026-observer-stub-lifecycle-events.md index 180f5ec3..5f6415b7 100644 --- a/docs/adr/026-observer-stub-lifecycle-events.md +++ b/docs/adr/026-observer-stub-lifecycle-events.md @@ -1,4 +1,4 @@ -# 026. Observer stub matches model lifecycle events +# 026. Observer and model stubs import, and make:observer is registered - Date: 2026-10-21 - Status: Accepted @@ -7,6 +7,8 @@ `observer.stub`, rendered by `MakeObserverCommand`, imports `Model` from `masoniteorm.models`, a package that is not installed with the framework, so any generated observer that is imported fails with `ModuleNotFoundError`. It stubs `booted`, `booting`, `hydrating` and `hydrated`, which the model never dispatches, and every method carries a Google-style docstring, which the coding standards forbid. ADR 025 defined the events that are dispatched (`saving`, `creating`, `updating`, `created`, `updated`, `saved`, `deleting`, `deleted`) and awaits async handlers. +`model.stub`, rendered by `db:make:model`, has the same `masoniteorm.models` import, so every generated model fails on import, and it carries docstrings too. `MakeObserverCommand` is exported but no provider registers it, so `artisan` cannot run it; its name is `observer`, unlike the `db:make:migration` and `db:make:model` commands that `DatabaseProvider` registers. + ## Alternatives 1. **Fix only the import.** Smallest change, but users keep getting handlers that never fire, plus the docstrings. @@ -17,12 +19,18 @@ Option 3. The stub imports `Model` from `fastapi_startkit.masoniteorm.models` and defines `async def (self, : Model) -> None` for the eight lifecycle events in dispatch order. It has no docstrings or comments. Handlers are async because the ORM is async-first and `observe_events` awaits them, so a handler can query the database. Sync handlers still work for users who prefer them. +`model.stub` imports `Model` from the same module and drops its docstrings. `MakeObserverCommand` is renamed `db:make:observer`, following the `db:make:*` convention, and `DatabaseProvider.boot()` registers it next to `MakeModelCommand`. The old `observer` name was never reachable from `artisan`, so the rename breaks nothing. + ## Implementation - `masoniteorm/commands/stubs/observer.stub` is rewritten; `MakeObserverCommand` is unchanged. -- `model.stub` has the same outdated `masoniteorm.models` import and is left for a separate change. +- `masoniteorm/commands/stubs/model.stub` is rewritten to the corrected import and a bare `pass` body. +- `MakeObserverCommand.name` is `db:make:observer`; `DatabaseProvider` imports and registers it. +- Not changed here: `db:make:model --migration` calls a command named `migration`, but it is registered as `db:make:migration`. That needs its own record. ## Validation - `tests/masoniteorm/commands/test_make_commands.py` checks the import line, the async typed signature, and that the file has no docstrings or comments. It also loads the generated file with `importlib`, asserts that its handlers are exactly the eight events in order, and dispatches each event through `observe_events` on a `Model` subclass. -- In a project created with `uvx fastapi-startkit init`, running the command against the local build generated `app/observers/OrderObserver.py`, and it imported and handled all eight events. +- A model test generates `app/Post.py`, checks the import line and the absence of docstrings, then loads the file and asserts that the class subclasses `Model`. +- A console test boots an application with `DatabaseProvider` and runs `app.run("db:make:observer", "Order")`, the same `ConsoleApplication` path that `artisan` uses. It fails when the registration is removed. +- In a project created with `uvx fastapi-startkit init` and run against the local build, `python artisan db:make:model Invoice` and `python artisan db:make:observer Invoice` created `app/Invoice.py` and `app/observers/InvoiceObserver.py`, and both imported. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index 9d2eb6c3..d816bc6c 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -31,4 +31,4 @@ Read this index first, then open the records relevant to the change. Before impl | [023](023-interactive-installer-prompts.md) | 2026-10-19 | Interactive installer prompts | `fastapi-startkit init` uses a stdlib `termios` module (`installer/prompts.py`) for Laravel Prompts-style boxed text/select/multiselect/confirm with arrow keys and descriptive questions and option hints; no new dependency; falls back to Cleo numbered prompts on Windows, piped input and in tests. | | [024](024-installer-controller-module.md) | 2026-10-19 | Installer controllers in their own module | `fastapi-startkit init` generates `HomeController` in `app/http/controllers/home_controller.py` (JSON or Inertia variant) instead of inside `routes/api.py`; one shared routes stub imports and registers it, matching the PingCRM layout. | | [025](025-model-lifecycle-events.md) | 2026-10-20 | Model lifecycle events, automatic timestamps and datetime casts | `save()` fires `saving → creating/updating → query → created/updated → saved`. Observer changes are persisted, and `updating` fires only when the model is dirty. A new `Model.delete()` fires `deleting → DELETE → deleted`. `observe_events` is async and awaits async handlers. Mass query-builder writes fire no events. Laravel-style auto timestamps: `created_at`/`updated_at` are filled on create unless supplied, `updated_at` moves on update, and `__timestamps__ = False` disables both. `DateCast.set` returns a UTC datetime; `Connection.prepare_binding` passes it unchanged to Postgres/MySQL, and SQLite converts it to a naive UTC string. Postgres registers an asyncpg `timestamp` codec that stores aware values as naive UTC; `timestamptz` keeps asyncpg's codec. `Annotated[T, Cast]` (a `BaseCast` subclass or instance) overrides a column's cast, and `Attribute.__casts__` is removed. | -| [026](026-observer-stub-lifecycle-events.md) | 2026-10-21 | Observer stub matches model lifecycle events | `observer.stub` (`MakeObserverCommand`) imports `Model` from `fastapi_startkit.masoniteorm.models` instead of the uninstalled `masoniteorm.models`, drops its docstrings and the never-dispatched `booted`/`booting`/`hydrating`/`hydrated` handlers, and stubs the ADR 025 events in dispatch order (`saving`, `creating`, `updating`, `created`, `updated`, `saved`, `deleting`, `deleted`) as `async def (self, : Model) -> None`. A test imports the generated file and dispatches every event. `model.stub` still has the old import. | +| [026](026-observer-stub-lifecycle-events.md) | 2026-10-21 | Observer and model stubs import, and make:observer is registered | `observer.stub` and `model.stub` import `Model` from `fastapi_startkit.masoniteorm.models` instead of the uninstalled `masoniteorm.models` (every generated model and observer failed to import) and drop their docstrings. The observer stub's handlers are the ADR 025 events in dispatch order (`saving`, `creating`, `updating`, `created`, `updated`, `saved`, `deleting`, `deleted`) as `async def (self, : Model) -> None`; the never-dispatched `booted`/`booting`/`hydrating`/`hydrated` handlers are gone. `MakeObserverCommand` is renamed `db:make:observer` and `DatabaseProvider` registers it. Tests import the generated files and run the command through the console. | diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeObserverCommand.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeObserverCommand.py index 12601b79..98cafd74 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeObserverCommand.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeObserverCommand.py @@ -6,7 +6,7 @@ class MakeObserverCommand(Command): - name = "observer" + name = "db:make:observer" description = "Creates a new observer file." arguments = [argument("name", description="The name of the observer")] diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/stubs/model.stub b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/stubs/model.stub index 6942517d..a5fd3c75 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/stubs/model.stub +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/stubs/model.stub @@ -1,9 +1,5 @@ -""" __CLASS__ Model """ - -from masoniteorm.models import Model +from fastapi_startkit.masoniteorm.models import Model class __CLASS__(Model): - """__CLASS__ Model""" - pass diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/providers/DatabaseProvider.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/providers/DatabaseProvider.py index 64123cd2..5c083de8 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/providers/DatabaseProvider.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/providers/DatabaseProvider.py @@ -9,6 +9,7 @@ DBSeedCommand, MakeMigrationCommand, MakeModelCommand, + MakeObserverCommand, MakeSeedCommand, MigrateRollbackCommand, MigrateFreshCommand, @@ -58,6 +59,7 @@ def boot(self) -> None: MigrateStatusCommand, MigrateRollbackCommand, MakeModelCommand, + MakeObserverCommand, MakeSeedCommand, MigrateFreshCommand, ] diff --git a/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py b/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py index 34cf392e..1d760f4e 100644 --- a/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py +++ b/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py @@ -36,6 +36,12 @@ def tearDown(self): def _tester(self, command_class): return CommandTester(command_class()) + def _load_class(self, path, class_name): + spec = importlib.util.spec_from_file_location(class_name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return getattr(module, class_name) + def _generated_file(self, directory): files = [f for f in os.listdir(directory) if f.endswith(".py")] self.assertEqual(len(files), 1, f"expected one generated file in {directory}, got {files}") @@ -188,6 +194,17 @@ def test_creates_model_file(self): content = open(os.path.join("app", "Post.py")).read() self.assertIn("class Post(Model)", content) + def test_generated_model_imports(self): + os.makedirs("app") + self._tester(MakeModelCommand).execute("Post") + + content = open(os.path.join("app", "Post.py")).read() + self.assertIn("from fastapi_startkit.masoniteorm.models import Model", content) + self.assertNotIn('"""', content) + + post_class = self._load_class(os.path.join("app", "Post.py"), "Post") + self.assertTrue(issubclass(post_class, Model)) + def test_pep_option_uses_underscore_filename(self): os.makedirs("app") tester = self._tester(MakeModelCommand) @@ -241,12 +258,6 @@ def test_reports_when_seed_already_exists(self): class TestMakeObserverCommand(_TempCwdTestCase): LIFECYCLE_EVENTS = ["saving", "creating", "updating", "created", "updated", "saved", "deleting", "deleted"] - def _load_observer(self, file_name, class_name): - spec = importlib.util.spec_from_file_location(class_name, os.path.join("app/observers", file_name)) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return getattr(module, class_name) - def test_creates_observer_file(self): tester = self._tester(MakeObserverCommand) tester.execute("Post") @@ -272,7 +283,7 @@ def test_model_option_controls_variable_name(self): def test_generated_observer_imports_and_handles_lifecycle_events(self): self._tester(MakeObserverCommand).execute("Post") - observer_class = self._load_observer("PostObserver.py", "PostObserver") + observer_class = self._load_class(os.path.join("app/observers", "PostObserver.py"), "PostObserver") handlers = [name for name in vars(observer_class) if not name.startswith("_")] self.assertEqual(handlers, self.LIFECYCLE_EVENTS) @@ -284,6 +295,12 @@ class Post(Model): for event in self.LIFECYCLE_EVENTS: asyncio.run(post.observe_events(post, event)) + def test_runs_through_console_as_db_make_observer(self): + app = create_app() + + self.assertEqual(app.run("db:make:observer", "Order"), 0) + self.assertTrue(os.path.exists(os.path.join("app/observers", "OrderObserver.py"))) + def test_reports_when_observer_already_exists(self): self._tester(MakeObserverCommand).execute("Post") From bc061b72d40675bca07dbe1a3bbd33c74dd543e1 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Wed, 21 Oct 2026 00:52:41 -0700 Subject: [PATCH 3/5] Renumber observer stub ADR to 028 ADR 026 and 027 are taken by open PRs #292 and #293. --- ...ifecycle-events.md => 028-observer-stub-lifecycle-events.md} | 2 +- docs/adr/AGENTS.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename docs/adr/{026-observer-stub-lifecycle-events.md => 028-observer-stub-lifecycle-events.md} (98%) diff --git a/docs/adr/026-observer-stub-lifecycle-events.md b/docs/adr/028-observer-stub-lifecycle-events.md similarity index 98% rename from docs/adr/026-observer-stub-lifecycle-events.md rename to docs/adr/028-observer-stub-lifecycle-events.md index 5f6415b7..2e10d833 100644 --- a/docs/adr/026-observer-stub-lifecycle-events.md +++ b/docs/adr/028-observer-stub-lifecycle-events.md @@ -1,4 +1,4 @@ -# 026. Observer and model stubs import, and make:observer is registered +# 028. Observer and model stubs import, and make:observer is registered - Date: 2026-10-21 - Status: Accepted diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index d816bc6c..82695013 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -31,4 +31,4 @@ Read this index first, then open the records relevant to the change. Before impl | [023](023-interactive-installer-prompts.md) | 2026-10-19 | Interactive installer prompts | `fastapi-startkit init` uses a stdlib `termios` module (`installer/prompts.py`) for Laravel Prompts-style boxed text/select/multiselect/confirm with arrow keys and descriptive questions and option hints; no new dependency; falls back to Cleo numbered prompts on Windows, piped input and in tests. | | [024](024-installer-controller-module.md) | 2026-10-19 | Installer controllers in their own module | `fastapi-startkit init` generates `HomeController` in `app/http/controllers/home_controller.py` (JSON or Inertia variant) instead of inside `routes/api.py`; one shared routes stub imports and registers it, matching the PingCRM layout. | | [025](025-model-lifecycle-events.md) | 2026-10-20 | Model lifecycle events, automatic timestamps and datetime casts | `save()` fires `saving → creating/updating → query → created/updated → saved`. Observer changes are persisted, and `updating` fires only when the model is dirty. A new `Model.delete()` fires `deleting → DELETE → deleted`. `observe_events` is async and awaits async handlers. Mass query-builder writes fire no events. Laravel-style auto timestamps: `created_at`/`updated_at` are filled on create unless supplied, `updated_at` moves on update, and `__timestamps__ = False` disables both. `DateCast.set` returns a UTC datetime; `Connection.prepare_binding` passes it unchanged to Postgres/MySQL, and SQLite converts it to a naive UTC string. Postgres registers an asyncpg `timestamp` codec that stores aware values as naive UTC; `timestamptz` keeps asyncpg's codec. `Annotated[T, Cast]` (a `BaseCast` subclass or instance) overrides a column's cast, and `Attribute.__casts__` is removed. | -| [026](026-observer-stub-lifecycle-events.md) | 2026-10-21 | Observer and model stubs import, and make:observer is registered | `observer.stub` and `model.stub` import `Model` from `fastapi_startkit.masoniteorm.models` instead of the uninstalled `masoniteorm.models` (every generated model and observer failed to import) and drop their docstrings. The observer stub's handlers are the ADR 025 events in dispatch order (`saving`, `creating`, `updating`, `created`, `updated`, `saved`, `deleting`, `deleted`) as `async def (self, : Model) -> None`; the never-dispatched `booted`/`booting`/`hydrating`/`hydrated` handlers are gone. `MakeObserverCommand` is renamed `db:make:observer` and `DatabaseProvider` registers it. Tests import the generated files and run the command through the console. | +| [028](028-observer-stub-lifecycle-events.md) | 2026-10-21 | Observer and model stubs import, and make:observer is registered | `observer.stub` and `model.stub` import `Model` from `fastapi_startkit.masoniteorm.models` instead of the uninstalled `masoniteorm.models` (every generated model and observer failed to import) and drop their docstrings. The observer stub's handlers are the ADR 025 events in dispatch order (`saving`, `creating`, `updating`, `created`, `updated`, `saved`, `deleting`, `deleted`) as `async def (self, : Model) -> None`; the never-dispatched `booted`/`booting`/`hydrating`/`hydrated` handlers are gone. `MakeObserverCommand` is renamed `db:make:observer` and `DatabaseProvider` registers it. Tests import the generated files and run the command through the console. | From eb2d7194566d10572fa717bc2dc468e5d5e781a3 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Wed, 21 Oct 2026 01:05:35 -0700 Subject: [PATCH 4/5] Fix db:make:model --migration and --seeder --migration called the unregistered "migration" command. Both flags also failed because Cleo's call binds its arguments against the application definition, which takes the first word as the command name. Nested calls now pass the command name first. --- .../adr/028-observer-stub-lifecycle-events.md | 12 +++++-- docs/adr/AGENTS.md | 2 +- .../masoniteorm/commands/MakeModelCommand.py | 19 +++++------ .../commands/test_make_commands.py | 33 +++++++++++++++++++ 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/docs/adr/028-observer-stub-lifecycle-events.md b/docs/adr/028-observer-stub-lifecycle-events.md index 2e10d833..3ab53d98 100644 --- a/docs/adr/028-observer-stub-lifecycle-events.md +++ b/docs/adr/028-observer-stub-lifecycle-events.md @@ -1,4 +1,4 @@ -# 028. Observer and model stubs import, and make:observer is registered +# 028. Observer and model stubs import, make:observer is registered, and make:model flags work - Date: 2026-10-21 - Status: Accepted @@ -9,6 +9,8 @@ `model.stub`, rendered by `db:make:model`, has the same `masoniteorm.models` import, so every generated model fails on import, and it carries docstrings too. `MakeObserverCommand` is exported but no provider registers it, so `artisan` cannot run it; its name is `observer`, unlike the `db:make:migration` and `db:make:model` commands that `DatabaseProvider` registers. +Both `db:make:model` flags that chain to another command fail on 0.67.0. `--migration` calls `migration`, but the command is registered as `db:make:migration` (`The command "migration" does not exist.`). `--seeder` calls the registered `seed` command but still fails with `Not enough arguments (missing: "name")`. Cleo's `Command.call(name, args)` binds `args` against the merged application definition, whose first positional argument is `command`, so the first word of `args` is taken as the command name and the real first argument goes missing. + ## Alternatives 1. **Fix only the import.** Smallest change, but users keep getting handlers that never fire, plus the docstrings. @@ -21,16 +23,20 @@ Option 3. The stub imports `Model` from `fastapi_startkit.masoniteorm.models` an `model.stub` imports `Model` from the same module and drops its docstrings. `MakeObserverCommand` is renamed `db:make:observer`, following the `db:make:*` convention, and `DatabaseProvider.boot()` registers it next to `MakeModelCommand`. The old `observer` name was never reachable from `artisan`, so the rename breaks nothing. +`db:make:model` calls `db:make:migration` for `--migration`. Its nested calls go through a `call_command(name, arguments)` method that passes `f"{name} {arguments}"`, so the name fills Cleo's `command` slot and the arguments bind as intended. The prefix stays local to `MakeModelCommand` rather than overriding `call` on the console `Command` base, because this is the only working caller and the base class is core framework code. + ## Implementation - `masoniteorm/commands/stubs/observer.stub` is rewritten; `MakeObserverCommand` is unchanged. - `masoniteorm/commands/stubs/model.stub` is rewritten to the corrected import and a bare `pass` body. - `MakeObserverCommand.name` is `db:make:observer`; `DatabaseProvider` imports and registers it. -- Not changed here: `db:make:model --migration` calls a command named `migration`, but it is registered as `db:make:migration`. That needs its own record. +- `MakeModelCommand` builds the migration arguments once and sends both nested calls through `call_command`. +- Audit of nested `call` uses in `masoniteorm/commands`: the make commands' only chained calls are the two in `db:make:model`, both fixed. Not changed here: `db:migrate:fresh --seed` and `db:migrate:refresh --seed` call `seed:run`, which does not exist (the seeder is `db:seed`). They hit the same positional-argument problem, and they call it from inside `asyncio.run`, where `db:seed` would start a second event loop. `db:migrate:refresh` is also not registered. Fixing them needs the seeding moved out of the running loop plus new fixtures, so it needs its own record. ## Validation - `tests/masoniteorm/commands/test_make_commands.py` checks the import line, the async typed signature, and that the file has no docstrings or comments. It also loads the generated file with `importlib`, asserts that its handlers are exactly the eight events in order, and dispatches each event through `observe_events` on a `Model` subclass. - A model test generates `app/Post.py`, checks the import line and the absence of docstrings, then loads the file and asserts that the class subclasses `Model`. - A console test boots an application with `DatabaseProvider` and runs `app.run("db:make:observer", "Order")`, the same `ConsoleApplication` path that `artisan` uses. It fails when the registration is removed. -- In a project created with `uvx fastapi-startkit init` and run against the local build, `python artisan db:make:model Invoice` and `python artisan db:make:observer Invoice` created `app/Invoice.py` and `app/observers/InvoiceObserver.py`, and both imported. +- Console tests run `db:make:model Post --migration`, `--migration --table` and `--seeder` through `app.run`, and check the generated create/update migration and seeder. All three fail without the name prefix. +- In a project created with `uvx fastapi-startkit init` and run against the local build, `python artisan db:make:model Invoice` and `python artisan db:make:observer Invoice` created `app/Invoice.py` and `app/observers/InvoiceObserver.py`, and both imported. `python artisan db:make:model Receipt --migration --seeder` created the model, migration and seeder, and `python artisan db:migrate` applied the migration. On 0.67.0, the same flags fail with the two errors above. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index 82695013..73f0c066 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -31,4 +31,4 @@ Read this index first, then open the records relevant to the change. Before impl | [023](023-interactive-installer-prompts.md) | 2026-10-19 | Interactive installer prompts | `fastapi-startkit init` uses a stdlib `termios` module (`installer/prompts.py`) for Laravel Prompts-style boxed text/select/multiselect/confirm with arrow keys and descriptive questions and option hints; no new dependency; falls back to Cleo numbered prompts on Windows, piped input and in tests. | | [024](024-installer-controller-module.md) | 2026-10-19 | Installer controllers in their own module | `fastapi-startkit init` generates `HomeController` in `app/http/controllers/home_controller.py` (JSON or Inertia variant) instead of inside `routes/api.py`; one shared routes stub imports and registers it, matching the PingCRM layout. | | [025](025-model-lifecycle-events.md) | 2026-10-20 | Model lifecycle events, automatic timestamps and datetime casts | `save()` fires `saving → creating/updating → query → created/updated → saved`. Observer changes are persisted, and `updating` fires only when the model is dirty. A new `Model.delete()` fires `deleting → DELETE → deleted`. `observe_events` is async and awaits async handlers. Mass query-builder writes fire no events. Laravel-style auto timestamps: `created_at`/`updated_at` are filled on create unless supplied, `updated_at` moves on update, and `__timestamps__ = False` disables both. `DateCast.set` returns a UTC datetime; `Connection.prepare_binding` passes it unchanged to Postgres/MySQL, and SQLite converts it to a naive UTC string. Postgres registers an asyncpg `timestamp` codec that stores aware values as naive UTC; `timestamptz` keeps asyncpg's codec. `Annotated[T, Cast]` (a `BaseCast` subclass or instance) overrides a column's cast, and `Attribute.__casts__` is removed. | -| [028](028-observer-stub-lifecycle-events.md) | 2026-10-21 | Observer and model stubs import, and make:observer is registered | `observer.stub` and `model.stub` import `Model` from `fastapi_startkit.masoniteorm.models` instead of the uninstalled `masoniteorm.models` (every generated model and observer failed to import) and drop their docstrings. The observer stub's handlers are the ADR 025 events in dispatch order (`saving`, `creating`, `updating`, `created`, `updated`, `saved`, `deleting`, `deleted`) as `async def (self, : Model) -> None`; the never-dispatched `booted`/`booting`/`hydrating`/`hydrated` handlers are gone. `MakeObserverCommand` is renamed `db:make:observer` and `DatabaseProvider` registers it. Tests import the generated files and run the command through the console. | +| [028](028-observer-stub-lifecycle-events.md) | 2026-10-21 | Observer and model stubs import, make:observer is registered, and make:model flags work | `observer.stub` and `model.stub` import `Model` from `fastapi_startkit.masoniteorm.models` instead of the uninstalled `masoniteorm.models` and drop their docstrings. Observer handlers are the ADR 025 events in dispatch order as `async def (self, : Model) -> None`. `MakeObserverCommand` is `db:make:observer`, registered by `DatabaseProvider`. `db:make:model --migration` calls `db:make:migration` (it called the unregistered `migration`), and its nested calls pass the command name as the first input word (`call_command`), because Cleo's `call` binds args against the application definition and otherwise drops the first argument (`--seeder` failed with `missing: "name"`). `db:migrate:fresh/refresh --seed` still call the nonexistent `seed:run`; that is left for its own record. | diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeModelCommand.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeModelCommand.py index a4ea9e9c..ac7bbed1 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeModelCommand.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/commands/MakeModelCommand.py @@ -82,20 +82,17 @@ def handle(self) -> int: self.info(f"Model created: {os.path.join(model_directory, file_name)}") if self.option("migration"): - migrations_directory = self.option("migrations-directory") + table = tableize(name) if self.option("table"): - self.call( - "migration", - f"update_{tableize(name)}_table --table {tableize(name)} --directory {migrations_directory}", - ) + arguments = f"update_{table}_table --table {table}" else: - self.call( - "migration", - f"create_{tableize(name)}_table --create {tableize(name)} --directory {migrations_directory}", - ) + arguments = f"create_{table}_table --create {table}" + self.call_command("db:make:migration", f"{arguments} --directory {self.option('migrations-directory')}") if self.option("seeder"): - directory = self.option("seeders-directory") - self.call("seed", f"{self.argument('name')} --directory {directory}") + self.call_command("seed", f"{name} --directory {self.option('seeders-directory')}") return 0 + + def call_command(self, name: str, arguments: str) -> int: + return self.call(name, f"{name} {arguments}") diff --git a/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py b/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py index 1d760f4e..400ce363 100644 --- a/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py +++ b/fastapi_startkit/tests/masoniteorm/commands/test_make_commands.py @@ -205,6 +205,39 @@ def test_generated_model_imports(self): post_class = self._load_class(os.path.join("app", "Post.py"), "Post") self.assertTrue(issubclass(post_class, Model)) + def test_migration_option_creates_migration_through_console(self): + os.makedirs("app") + os.makedirs("databases/migrations") + app = create_app() + + self.assertEqual(app.run("db:make:model", "Post --migration"), 0) + + migration = self._generated_file("databases/migrations") + self.assertTrue(migration.endswith("_create_posts_table.py")) + content = open(migration).read() + self.assertIn('self.schema.create("posts")', content) + + def test_migration_and_table_options_create_update_migration_through_console(self): + os.makedirs("app") + os.makedirs("databases/migrations") + app = create_app() + + self.assertEqual(app.run("db:make:model", "Post --migration --table"), 0) + + migration = self._generated_file("databases/migrations") + self.assertTrue(migration.endswith("_update_posts_table.py")) + content = open(migration).read() + self.assertIn('self.schema.table("posts")', content) + + def test_seeder_option_creates_seeder_through_console(self): + os.makedirs("app") + os.makedirs("databases/seeders") + app = create_app() + + self.assertEqual(app.run("db:make:model", "Post --seeder"), 0) + + self.assertTrue(os.path.exists(os.path.join("databases/seeders", "post_table_seeder.py"))) + def test_pep_option_uses_underscore_filename(self): os.makedirs("app") tester = self._tester(MakeModelCommand) From 835816794fb8a168c666ba83bc45ea055e6b4217 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Wed, 21 Oct 2026 14:01:48 -0700 Subject: [PATCH 5/5] Drop ADR for observer/make:model bug fix This is a bug fix, not a design decision; remove ADR 028 and its docs/adr/AGENTS.md index row while keeping the fixes and tests. Co-Authored-By: Claude Sonnet 5 --- .../adr/028-observer-stub-lifecycle-events.md | 42 ------------------- docs/adr/AGENTS.md | 1 - 2 files changed, 43 deletions(-) delete mode 100644 docs/adr/028-observer-stub-lifecycle-events.md diff --git a/docs/adr/028-observer-stub-lifecycle-events.md b/docs/adr/028-observer-stub-lifecycle-events.md deleted file mode 100644 index 3ab53d98..00000000 --- a/docs/adr/028-observer-stub-lifecycle-events.md +++ /dev/null @@ -1,42 +0,0 @@ -# 028. Observer and model stubs import, make:observer is registered, and make:model flags work - -- Date: 2026-10-21 -- Status: Accepted - -## Problem - -`observer.stub`, rendered by `MakeObserverCommand`, imports `Model` from `masoniteorm.models`, a package that is not installed with the framework, so any generated observer that is imported fails with `ModuleNotFoundError`. It stubs `booted`, `booting`, `hydrating` and `hydrated`, which the model never dispatches, and every method carries a Google-style docstring, which the coding standards forbid. ADR 025 defined the events that are dispatched (`saving`, `creating`, `updating`, `created`, `updated`, `saved`, `deleting`, `deleted`) and awaits async handlers. - -`model.stub`, rendered by `db:make:model`, has the same `masoniteorm.models` import, so every generated model fails on import, and it carries docstrings too. `MakeObserverCommand` is exported but no provider registers it, so `artisan` cannot run it; its name is `observer`, unlike the `db:make:migration` and `db:make:model` commands that `DatabaseProvider` registers. - -Both `db:make:model` flags that chain to another command fail on 0.67.0. `--migration` calls `migration`, but the command is registered as `db:make:migration` (`The command "migration" does not exist.`). `--seeder` calls the registered `seed` command but still fails with `Not enough arguments (missing: "name")`. Cleo's `Command.call(name, args)` binds `args` against the merged application definition, whose first positional argument is `command`, so the first word of `args` is taken as the command name and the real first argument goes missing. - -## Alternatives - -1. **Fix only the import.** Smallest change, but users keep getting handlers that never fire, plus the docstrings. -2. **Import the generated model class for the type hint.** More precise typing, but the model module and its path are not known to the command, and the observer would fail to import when the model does not exist yet. -3. **Import `Model` from `fastapi_startkit.masoniteorm.models` and stub exactly the dispatched events as async methods.** Always importable, and the handlers match what fires. - -## Decision - -Option 3. The stub imports `Model` from `fastapi_startkit.masoniteorm.models` and defines `async def (self, : Model) -> None` for the eight lifecycle events in dispatch order. It has no docstrings or comments. Handlers are async because the ORM is async-first and `observe_events` awaits them, so a handler can query the database. Sync handlers still work for users who prefer them. - -`model.stub` imports `Model` from the same module and drops its docstrings. `MakeObserverCommand` is renamed `db:make:observer`, following the `db:make:*` convention, and `DatabaseProvider.boot()` registers it next to `MakeModelCommand`. The old `observer` name was never reachable from `artisan`, so the rename breaks nothing. - -`db:make:model` calls `db:make:migration` for `--migration`. Its nested calls go through a `call_command(name, arguments)` method that passes `f"{name} {arguments}"`, so the name fills Cleo's `command` slot and the arguments bind as intended. The prefix stays local to `MakeModelCommand` rather than overriding `call` on the console `Command` base, because this is the only working caller and the base class is core framework code. - -## Implementation - -- `masoniteorm/commands/stubs/observer.stub` is rewritten; `MakeObserverCommand` is unchanged. -- `masoniteorm/commands/stubs/model.stub` is rewritten to the corrected import and a bare `pass` body. -- `MakeObserverCommand.name` is `db:make:observer`; `DatabaseProvider` imports and registers it. -- `MakeModelCommand` builds the migration arguments once and sends both nested calls through `call_command`. -- Audit of nested `call` uses in `masoniteorm/commands`: the make commands' only chained calls are the two in `db:make:model`, both fixed. Not changed here: `db:migrate:fresh --seed` and `db:migrate:refresh --seed` call `seed:run`, which does not exist (the seeder is `db:seed`). They hit the same positional-argument problem, and they call it from inside `asyncio.run`, where `db:seed` would start a second event loop. `db:migrate:refresh` is also not registered. Fixing them needs the seeding moved out of the running loop plus new fixtures, so it needs its own record. - -## Validation - -- `tests/masoniteorm/commands/test_make_commands.py` checks the import line, the async typed signature, and that the file has no docstrings or comments. It also loads the generated file with `importlib`, asserts that its handlers are exactly the eight events in order, and dispatches each event through `observe_events` on a `Model` subclass. -- A model test generates `app/Post.py`, checks the import line and the absence of docstrings, then loads the file and asserts that the class subclasses `Model`. -- A console test boots an application with `DatabaseProvider` and runs `app.run("db:make:observer", "Order")`, the same `ConsoleApplication` path that `artisan` uses. It fails when the registration is removed. -- Console tests run `db:make:model Post --migration`, `--migration --table` and `--seeder` through `app.run`, and check the generated create/update migration and seeder. All three fail without the name prefix. -- In a project created with `uvx fastapi-startkit init` and run against the local build, `python artisan db:make:model Invoice` and `python artisan db:make:observer Invoice` created `app/Invoice.py` and `app/observers/InvoiceObserver.py`, and both imported. `python artisan db:make:model Receipt --migration --seeder` created the model, migration and seeder, and `python artisan db:migrate` applied the migration. On 0.67.0, the same flags fail with the two errors above. diff --git a/docs/adr/AGENTS.md b/docs/adr/AGENTS.md index f22ea0f5..737ad4e5 100644 --- a/docs/adr/AGENTS.md +++ b/docs/adr/AGENTS.md @@ -32,6 +32,5 @@ Read this index first, then open the records relevant to the change. Before impl | [024](024-installer-controller-module.md) | 2026-10-19 | Installer controllers in their own module | `fastapi-startkit init` generates `HomeController` in `app/http/controllers/home_controller.py` (JSON or Inertia variant) instead of inside `routes/api.py`; one shared routes stub imports and registers it, matching the PingCRM layout. | | [025](025-model-lifecycle-events.md) | 2026-10-20 | Model lifecycle events, automatic timestamps and datetime casts | `save()` fires `saving → creating/updating → query → created/updated → saved`. Observer changes are persisted, and `updating` fires only when the model is dirty. A new `Model.delete()` fires `deleting → DELETE → deleted`. `observe_events` is async and awaits async handlers. Mass query-builder writes fire no events. Laravel-style auto timestamps: `created_at`/`updated_at` are filled on create unless supplied, `updated_at` moves on update, and `__timestamps__ = False` disables both. `DateCast.set` returns a UTC datetime; `Connection.prepare_binding` passes it unchanged to Postgres/MySQL, and SQLite converts it to a naive UTC string. Postgres registers an asyncpg `timestamp` codec that stores aware values as naive UTC; `timestamptz` keeps asyncpg's codec. `Annotated[T, Cast]` (a `BaseCast` subclass or instance) overrides a column's cast, and `Attribute.__casts__` is removed. | | [026](026-schema-column-change.md) | 2026-10-20 | Altering existing columns with `.change()` | `.change()` fully redefines the column, the way Laravel does. MySQL (`MODIFY`) and SQLite (table rebuild) were already correct. Postgres now emits `TYPE ... USING` casts, correct lengths, quoted defaults or `DROP DEFAULT`, `SET`/`DROP NOT NULL`, and replaces the enum check constraint. MSSQL drops the current default constraint (looked up in `sys.default_constraints`), runs one `ALTER COLUMN` per column, then adds `[DF__]`. Identifiers are bracket- or double-quote-escaped and the MSSQL drop target is built server-side with `QUOTENAME`; changing a Postgres serial column drops its `nextval` default. | -| [028](028-observer-stub-lifecycle-events.md) | 2026-10-21 | Observer and model stubs import, make:observer is registered, and make:model flags work | `observer.stub` and `model.stub` import `Model` from `fastapi_startkit.masoniteorm.models` instead of the uninstalled `masoniteorm.models` and drop their docstrings. Observer handlers are the ADR 025 events in dispatch order as `async def (self, : Model) -> None`. `MakeObserverCommand` is `db:make:observer`, registered by `DatabaseProvider`. `db:make:model --migration` calls `db:make:migration` (it called the unregistered `migration`), and its nested calls pass the command name as the first input word (`call_command`), because Cleo's `call` binds args against the application definition and otherwise drops the first argument (`--seeder` failed with `missing: "name"`). `db:migrate:fresh/refresh --seed` still call the nonexistent `seed:run`; that is left for its own record. | | [032](032-migrate-seed-option.md) | 2026-10-21 | db:migrate:fresh and db:migrate:refresh --seed run seeders in the same event loop | `--seed` called the unregistered `seed:run` through Cleo `call` (which also drops the first argument) inside a running event loop, where `db:seed` would call `asyncio.run` again. The seeder selection moves into async `DBSeedCommand.run_seeders`, which `db:seed`, `db:migrate:fresh` and `db:migrate:refresh` all await. `db:migrate:refresh` is now registered. | | [033](033-async-seeder-stub.md) | 2026-10-21 | Generated seeders have an async run | `create_seed.stub` generated a synchronous `run` with docstrings, but `Seeder` awaits `run()`, so every generated table seeder raised `TypeError`. The stub now generates `async def run(self) -> None` with no docstrings; `Seeder` is unchanged. |