|
| 1 | +# ____name____ |
| 2 | + |
| 3 | +____description____ |
| 4 | + |
| 5 | +``` |
| 6 | +handlers/handler.json what this feature is, and what it may reach |
| 7 | +handlers/handler.py its functions — the image serves every public coroutine |
| 8 | +handlers/requirements.txt its pip dependencies, like any other python project |
| 9 | +__tests__/ the manifest, the image and the queue, end to end |
| 10 | +``` |
| 11 | + |
| 12 | +## The surface |
| 13 | + |
| 14 | +This is a **gql** feature, written in python: it is served over HTTP/GraphQL and |
| 15 | +**has no database connection**. It reaches the tenant's data through the |
| 16 | +tenant's own API, which applies the caller's permissions, and its declared |
| 17 | +buckets, secrets and models through the rest of the context — never Postgres |
| 18 | +directly. The handler's `ctx` is typed as a protocol that has no `db`, so a |
| 19 | +query is a type error rather than a habit. A feature that reads the tenant's |
| 20 | +database directly is a **sql** feature |
| 21 | +(`fun init <name> --surface sql --lang python`). |
| 22 | + |
| 23 | +## The python image |
| 24 | + |
| 25 | +The image is `constructive_runtime` plus a FastAPI entry point that imports |
| 26 | +`handlers/handler.py` and serves **every public coroutine in it** as |
| 27 | +`POST /<name>` — the route the platform addresses `____name____:<name>` through. |
| 28 | +So a second function is a second `async def` and a second entry in `methods[]`; |
| 29 | +nothing else changes. A helper that must not become a route is either a plain |
| 30 | +`def` or lives in another module. |
| 31 | + |
| 32 | +The context is the same surface a TypeScript handler gets — `ctx.secrets`, |
| 33 | +`ctx.storage`, `ctx.agent`, `ctx.log`, `ctx.job` — because it is the same |
| 34 | +runtime, mirrored in python rather than reimplemented. The declared inputs are |
| 35 | +compiled once, at generation, into the JSON Schema both languages enforce, so a |
| 36 | +payload the node runtime would refuse is refused here too. |
| 37 | + |
| 38 | +The **kind** (`job`, `sync`, `page`) is not a different template, only a |
| 39 | +different way in: a job is enqueued and run by the worker, a sync is invoked |
| 40 | +through the gateway on the caller's connection. It is expressed in `handler.json` |
| 41 | +as `accessChannels` plus `route`, and `fun init --kind` fills it. A page is a |
| 42 | +node image (`type: "node-page"`), so a python feature serving one puts those |
| 43 | +methods in a node manifest beside this one. |
| 44 | + |
| 45 | +## What it may reach |
| 46 | + |
| 47 | +`handlers/handler.json` is this feature's declaration and the only place its |
| 48 | +identity and capabilities are written down — the platform reads it at deploy |
| 49 | +time, and the test reads the same file, so the two cannot drift. |
| 50 | + |
| 51 | +Anything undeclared is unreachable: `ctx.storage` and `ctx.secrets` raise on a |
| 52 | +key this file never declared rather than answering `None`. Declare what you use, |
| 53 | +as you write it: |
| 54 | + |
| 55 | +```json |
| 56 | +"requires": { |
| 57 | + "buckets": ["exports"], |
| 58 | + "secrets": [{ "name": "STRIPE_KEY", "required": true }], |
| 59 | + "configs": [{ "name": "EXPORT_ROW_LIMIT", "required": false }], |
| 60 | + "modules": ["notifications_module"], |
| 61 | + "models": ["gpt-4o"] |
| 62 | +} |
| 63 | +``` |
| 64 | + |
| 65 | +Always the **logical** key, never a physical name: |
| 66 | +`ctx.storage.write('exports', …)` resolves to this tenant's bucket per |
| 67 | +invocation, and `ctx.secrets.get('STRIPE_KEY')` reads from this tenant's own |
| 68 | +store. Secret *values* never travel in the manifest, the capability bundle, the |
| 69 | +payload, the logs, or the pod's environment. |
| 70 | + |
| 71 | +pip dependencies go in `handlers/requirements.txt`, where python already keeps |
| 72 | +them — the image installs it on top of the runtime's own. `handler.json` carries |
| 73 | +only what the platform reads, and system packages belong to the Dockerfile. |
| 74 | + |
| 75 | +## Running it |
| 76 | + |
| 77 | +```bash |
| 78 | +pgpm docker start --image docker.io/constructiveio/postgres-plus:18 |
| 79 | +eval "$(pgpm env)" |
| 80 | +pnpm --filter "@constructive-functions/feature-____name____" test |
| 81 | +``` |
| 82 | + |
| 83 | +The suite is TypeScript because the platform it drives is: it registers this |
| 84 | +feature from `handler.json`, stages and starts the real python image, and |
| 85 | +invokes it through the real queue. The first run builds the image's venv under |
| 86 | +`.image/` (git-ignored) and later runs reuse it, so only the first is slow. |
| 87 | + |
| 88 | +## Next |
| 89 | + |
| 90 | +The test above is the loop: it clones a seeded template database, needs no |
| 91 | +cluster, and is the only thing you need while writing the handler. When you want |
| 92 | +this feature on a real stack, from the root of the checkout it lives in: |
| 93 | + |
| 94 | +```bash |
| 95 | +pnpm fun up --k8s # brings the platform up and registers every feature here |
| 96 | +``` |
| 97 | + |
| 98 | +Registration reads `handlers/handler.json` — the same file the test reads — so a |
| 99 | +manifest-only change needs no rebuild: |
| 100 | + |
| 101 | +```bash |
| 102 | +pnpm fun register --apply # write the declaration; --dry-run prints the SQL |
| 103 | +``` |
| 104 | + |
| 105 | +A registration failure aborts the bring-up rather than being reported as |
| 106 | +skipped, which it once was: an unregistered method has no symptom of its own |
| 107 | +until something calls it and gets |
| 108 | +`No service URL for "____name____:____method____"`. |
0 commit comments