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 +1 -1
- package/dist/main.py +50 -12
- package/dist/tests/test_main_helpers.py +48 -4
- package/package.json +1 -1
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
|
|
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
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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):
|