create-caspian-app 1.5.4 → 1.5.5

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.
package/dist/AGENTS.md CHANGED
@@ -312,7 +312,7 @@ Use `.github/copilot-instructions.md` for the repo-wide implementation rules. Th
312
312
  - Use `node_modules/caspian-utils/dist/docs/file-conventions.md` for the general special-file model, then verify the completed Python migration in `main.py` and `.venv/Lib/site-packages/casp/**`: routes use `index.py`, layouts use `layout.py`, navigation loading UI uses `loading.py`, and global fallback pages use `not_found.py` and `error.py`. This app has no authored `.html` special files.
313
313
  - **If navigation loading UI is wanted, it is `loading.py` — never hand-roll it.** The file itself is optional and most subtrees do not have one; this rule governs the implementation, not whether to add the feature. A spinner component, a global `isLoading` store, a `pp:navigation:start`/`pp:navigation:complete` listener, or a manual overlay built for route-to-route navigation is a reimplementation of the shipped runtime (`casp/loading.py` collects the files, `caspian_config.py` derives their URL scopes, the browser runtime resolves the closest ancestor scope and swaps the `pp-loading-content="true"` pane). The full contract is in the "Special files" block above; the shipped example is `src/app/dashboard/loading.py` plus the `pp-loading-content="true"` bar in `src/app/dashboard/layout.py`. In-page waits (RPC, submit, filter, upload) are `pp.state` in the owning component and are not this feature.
314
314
  - When `caspian.config.json` has `prisma: true`, database reads and writes from Python routes, layouts, RPC actions, upload flows, auth flows, and helpers must use the generated Prisma Python ORM in `src/lib/prisma/**`. Do not create a separate database fetch layer with raw drivers, hand-written SQL helpers, JSON manifests, app-specific HTTP fetches, or browser-side data fetches to replace the ORM. Use raw SQL only as a narrow Prisma ORM fallback when the generated client cannot express a query clearly.
315
- - Development restarts must preserve the database lifecycle. `settings/python-server.ts` sends an authenticated shutdown command through a private child stdin pipe and waits for Uvicorn to exit before force-kill is considered; `main.py` handles that command with `uvicorn.Server.should_exit`, and, only when `cfg.prisma` is true, conditionally imports the generated client and registers a Prisma lifespan that awaits `prisma.disconnect()` during shutdown. A Prisma-disabled project may have no `src.lib.prisma` package, so never move that import outside the feature gate. Keep Prisma's lifespan ahead of later lifespans so it exits last, do not make it connect eagerly, and do not replace the normal restart path with immediate `taskkill /F` / `SIGKILL`—that bypasses FastAPI cleanup and churns database connections during source edits.
315
+ - Development restarts must preserve the database lifecycle. `settings/python-server.ts` sends an authenticated shutdown command through a private child stdin pipe and waits for Uvicorn to exit before force-kill is considered; `main.py` handles that command with `uvicorn.Server.should_exit`, and, only when `cfg.prisma` is true, conditionally imports the generated client and registers a Prisma lifespan that awaits `prisma.disconnect()` during shutdown. **Two independent conditions gate that import, not one.** `cfg.prisma` says the feature is _enabled_; `src/lib/prisma/` existing on disk says the ORM has actually been _generated_. A Prisma-disabled project has no such package, and neither does a freshly scaffolded or freshly cloned project whose `prisma/schema.prisma` exists but where `npx ppy generate` has never run — so the import is guarded by `PRISMA_PACKAGE_DIR.is_dir()` inside the `cfg.prisma` gate, and the lifespan is registered on `prisma is not None` rather than on the flag. Never move the import outside either guard, and do not collapse them back into one: a bare `ModuleNotFoundError` at import kills the dev stack before a single route renders, with a traceback that names no fix. A directory that exists but fails to import is a genuinely broken install (missing driver dependency, half-written generation) and keeps its own traceback. Missing-and-enabled warns and keeps serving in development but raises at boot in production, where every database call would fail at request time anyway — the same fail-closed rule as `APP_ENV`. Every consumer must therefore treat `main.prisma` as possibly `None`. Keep Prisma's lifespan ahead of later lifespans so it exits last, do not make it connect eagerly, and do not replace the normal restart path with immediate `taskkill /F` / `SIGKILL`—that bypasses FastAPI cleanup and churns database connections during source edits.
316
316
  - **After any `prisma/schema.prisma` change, exactly two commands are required, in order.** Step 1 — sync the database, pick one: `npx prisma migrate dev` (development default, creates and applies a migration) or `npx prisma db push` (migration-less direct sync). Step 2 — always: `npx ppy generate`, the **only** command that regenerates the Python ORM the app imports (`src/lib/prisma/__init__.py`, `db.py`, `models.py`, `settings/prisma-schema.json`). The two generators are different toolchains from the same schema: `npx prisma generate` builds the Node/TypeScript `@prisma/client` used only by `prisma/seed.ts` and writes zero Python — it is never a substitute for `npx ppy generate`. Never hand-write or patch the generated Python ORM instead of regenerating it; the generated client is ready to import from `src.lib.prisma`. See `node_modules/caspian-utils/dist/docs/database.md` "Two Generators, One Schema".
317
317
  - Treat `npx prisma db seed` as a delicate, potentially destructive operation. In this workspace, seed scripts may clear tables before inserting fresh records. Before running that command, an AI agent must propose the exact command, warn that it can delete or overwrite database data including production data if the datasource is wrong, confirm the datasource when practical, and wait for explicit user approval.
318
318
  - Component-first page composition is the highest-priority authoring rule for this workspace (see `.github/copilot-instructions.md`). Build pages as a short assembly of `x-*` chunk components (top menu, sidebar, header, content sections, cards, forms, footer) and keep each chunk's long markup inside its own focused single-file `html(...)` component, so the page template in `src/app/**/index.py` stays small instead of holding a wall of HTML. Plan the chunk breakdown before writing the route, not as a later cleanup pass.
package/dist/main.py CHANGED
@@ -81,19 +81,55 @@ from collections.abc import Callable
81
81
  load_dotenv()
82
82
  cfg = get_config()
83
83
 
84
- # Prisma is optional. A project generated with `prisma: false` has no
85
- # `src.lib.prisma` package, so both the import and lifespan registration must be
86
- # behind the feature gate.
87
- prisma: Any = None
88
- if cfg.prisma:
89
- from src.lib.prisma import prisma as configured_prisma
84
+ # Declared before the Prisma and MCP blocks below, which need it to decide
85
+ # whether a missing generated client, an unauthenticated endpoint, or an open
86
+ # CORS policy is tolerable. Resolved fail-closed: only an explicit development
87
+ # APP_ENV turns the relaxations on.
88
+ IS_PRODUCTION = is_production_environment()
90
89
 
91
- prisma = configured_prisma
90
+ # Prisma is optional, and its Python ORM is *generated* rather than authored, so
91
+ # there are two independent conditions -- the feature flag in
92
+ # `caspian.config.json`, and whether `npx ppy generate` has actually produced
93
+ # `src/lib/prisma/`. A project generated with `prisma: false` has no such
94
+ # package; neither does a freshly cloned or freshly scaffolded project whose
95
+ # schema exists but has never been generated. Both must leave the app importable,
96
+ # so the import, the lifespan registration, and every consumer treat `prisma` as
97
+ # possibly `None`.
98
+ PRISMA_PACKAGE_DIR = Path(__file__).resolve().parent / "src" / "lib" / "prisma"
99
+
100
+ PRISMA_NOT_GENERATED_MESSAGE = (
101
+ "Prisma is enabled in caspian.config.json but the Python ORM has not been "
102
+ "generated, so src/lib/prisma/ does not exist. Sync the database with "
103
+ "`npx prisma migrate dev` (or `npx prisma db push`), then run "
104
+ "`npx ppy generate` to generate the client."
105
+ )
92
106
 
93
- # Declared before the MCP block below, which needs it to decide whether an
94
- # unauthenticated endpoint or an open CORS policy is tolerable. Resolved
95
- # fail-closed: only an explicit development APP_ENV turns the relaxations on.
96
- IS_PRODUCTION = is_production_environment()
107
+ prisma: Any = None
108
+ if cfg.prisma:
109
+ if PRISMA_PACKAGE_DIR.is_dir():
110
+ # Present but unimportable is a genuinely broken install (a missing
111
+ # driver dependency, a half-written generation). Let that traceback
112
+ # through rather than degrading to a confusing "not generated" hint.
113
+ # The ignore matches the optional MCP import below: a generated module
114
+ # is absent from a static checkout, so the type checker cannot see it.
115
+ from src.lib.prisma import prisma as configured_prisma # type: ignore[import-not-found]
116
+
117
+ prisma = configured_prisma
118
+ elif IS_PRODUCTION:
119
+ # A deployment that enabled Prisma and shipped without the generated
120
+ # client is broken: every database call would fail at request time.
121
+ # Fail at boot instead, consistent with the fail-closed APP_ENV rule.
122
+ raise RuntimeError(PRISMA_NOT_GENERATED_MESSAGE)
123
+ else:
124
+ # In development this is the ordinary "I have not generated it yet"
125
+ # state. Warn once at boot and keep serving, so the dev stack does not
126
+ # die with a bare ModuleNotFoundError before a single route renders.
127
+ print(
128
+ f"[caspian] WARNING: {PRISMA_NOT_GENERATED_MESSAGE} "
129
+ "Database access is unavailable until then; the rest of the app runs normally.",
130
+ file=sys.stderr,
131
+ flush=True,
132
+ )
97
133
 
98
134
  # Resolve APP_TIMEZONE once at import so an unknown zone name fails at boot with
99
135
  # a named error, rather than on whichever request first formats a date. Only the
@@ -324,7 +360,9 @@ def get_app_lifespans() -> list[LifespanFactory]:
324
360
 
325
361
  # Keep the database alive until every later lifespan has shut down. This does
326
362
  # not connect eagerly; it only guarantees cleanup when Uvicorn exits cleanly.
327
- if cfg.prisma:
363
+ # Gated on the client being importable, not just on the feature flag: with
364
+ # Prisma enabled but not yet generated there is nothing to disconnect.
365
+ if prisma is not None:
328
366
  lifespans.append(prisma_lifespan)
329
367
 
330
368
  # MCP lifecycle
@@ -16,9 +16,21 @@ from conftest import run_async
16
16
 
17
17
 
18
18
  class TestPrismaLifespan:
19
- def test_disconnects_on_shutdown(self, monkeypatch):
19
+ @staticmethod
20
+ def _install_client(monkeypatch) -> AsyncMock:
21
+ """Stand in for the generated Prisma client.
22
+
23
+ `src/lib/prisma/` is generated by `npx ppy generate`, so it is absent in
24
+ a fresh checkout and `main.prisma` is then `None`. The lifespan contract
25
+ is about whatever object `main.prisma` holds, so the tests supply one
26
+ rather than depending on generation having been run.
27
+ """
20
28
  disconnect = AsyncMock()
21
- monkeypatch.setattr(main.prisma, "disconnect", disconnect)
29
+ monkeypatch.setattr(main, "prisma", SimpleNamespace(disconnect=disconnect))
30
+ return disconnect
31
+
32
+ def test_disconnects_on_shutdown(self, monkeypatch):
33
+ disconnect = self._install_client(monkeypatch)
22
34
 
23
35
  async def exercise_lifespan():
24
36
  async with main.prisma_lifespan(main.app):
@@ -29,12 +41,29 @@ class TestPrismaLifespan:
29
41
 
30
42
  def test_disabled_prisma_is_not_registered(self, monkeypatch):
31
43
  monkeypatch.setattr(main, "cfg", replace(main.cfg, prisma=False))
44
+ monkeypatch.setattr(main, "prisma", None)
32
45
 
33
46
  assert main.prisma_lifespan not in main.get_app_lifespans()
34
47
 
48
+ def test_enabled_but_ungenerated_prisma_is_not_registered(self, monkeypatch):
49
+ """Prisma enabled in config but never generated leaves nothing to close.
50
+
51
+ Registration is gated on the client being importable, not on the flag,
52
+ so the app still boots when `npx ppy generate` has not been run.
53
+ """
54
+ monkeypatch.setattr(main, "cfg", replace(main.cfg, prisma=True))
55
+ monkeypatch.setattr(main, "prisma", None)
56
+
57
+ assert main.prisma_lifespan not in main.get_app_lifespans()
58
+
59
+ def test_generated_prisma_is_registered(self, monkeypatch):
60
+ monkeypatch.setattr(main, "cfg", replace(main.cfg, prisma=True))
61
+ self._install_client(monkeypatch)
62
+
63
+ assert main.prisma_lifespan in main.get_app_lifespans()
64
+
35
65
  def test_disconnects_when_another_lifespan_raises(self, monkeypatch):
36
- disconnect = AsyncMock()
37
- monkeypatch.setattr(main.prisma, "disconnect", disconnect)
66
+ disconnect = self._install_client(monkeypatch)
38
67
 
39
68
  async def exercise_lifespan():
40
69
  try:
@@ -46,6 +75,21 @@ class TestPrismaLifespan:
46
75
  run_async(exercise_lifespan())
47
76
  disconnect.assert_awaited_once_with()
48
77
 
78
+ def test_lifespan_is_a_noop_without_a_generated_client(self, monkeypatch):
79
+ """The shutdown half must tolerate `prisma` being `None`.
80
+
81
+ `get_app_lifespans` already skips registration in that state, but the
82
+ lifespan is public and is exercised directly by tests and by anyone
83
+ composing lifespans by hand.
84
+ """
85
+ monkeypatch.setattr(main, "prisma", None)
86
+
87
+ async def exercise_lifespan():
88
+ async with main.prisma_lifespan(main.app):
89
+ pass
90
+
91
+ run_async(exercise_lifespan())
92
+
49
93
 
50
94
  class TestDevControlPipe:
51
95
  def test_valid_shutdown_command_stops_server(self):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-caspian-app",
3
- "version": "1.5.4",
3
+ "version": "1.5.5",
4
4
  "description": "Scaffold a new Caspian project (FastAPI-powered reactive Python framework).",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",