vibecodeagent 0.0.0-stage → 0.1.65

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 (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -2
  3. package/THIRD-PARTY-NOTICES.txt +1427 -0
  4. package/dist/agen.js +138920 -0
  5. package/dist/bin.js +1245 -0
  6. package/dist/daemon.js +163701 -0
  7. package/dist/node_modules/node-ensure/index.js +10 -0
  8. package/dist/node_modules/node-ensure/package.json +31 -0
  9. package/dist/node_modules/pdf-parse/lib/pdf-parse.js +102 -0
  10. package/dist/node_modules/pdf-parse/lib/pdf.js/v1.10.100/build/pdf.js +18538 -0
  11. package/dist/node_modules/pdf-parse/lib/pdf.js/v1.10.100/build/pdf.worker.js +45507 -0
  12. package/dist/node_modules/pdf-parse/package.json +49 -0
  13. package/dist/node_modules/sql.js/dist/sql-wasm.js +186 -0
  14. package/dist/node_modules/sql.js/dist/sql-wasm.wasm +0 -0
  15. package/dist/node_modules/sql.js/package.json +58 -0
  16. package/dist/resources/skills/builtin/.gitkeep +0 -0
  17. package/dist/resources/skills/builtin/NOTICE.md +35 -0
  18. package/dist/resources/skills/builtin/README.md +74 -0
  19. package/dist/resources/skills/builtin/backend-engineering/SKILL.md +102 -0
  20. package/dist/resources/skills/builtin/backend-engineering/references/api-contracts.md +320 -0
  21. package/dist/resources/skills/builtin/backend-engineering/references/auth-and-secrets.md +268 -0
  22. package/dist/resources/skills/builtin/backend-engineering/references/concurrency-and-jobs.md +386 -0
  23. package/dist/resources/skills/builtin/backend-engineering/references/data-modelling.md +367 -0
  24. package/dist/resources/skills/builtin/backend-engineering/references/deploy-and-config.md +228 -0
  25. package/dist/resources/skills/builtin/backend-engineering/references/migrations.md +320 -0
  26. package/dist/resources/skills/builtin/backend-engineering/references/observability.md +301 -0
  27. package/dist/resources/skills/builtin/backend-engineering/references/plan-template.md +152 -0
  28. package/dist/resources/skills/builtin/backend-engineering/references/review-audit.md +213 -0
  29. package/dist/resources/skills/builtin/backend-engineering/references/security.md +251 -0
  30. package/dist/resources/skills/builtin/backend-engineering/references/stack-go.md +238 -0
  31. package/dist/resources/skills/builtin/backend-engineering/references/stack-node.md +274 -0
  32. package/dist/resources/skills/builtin/backend-engineering/references/stack-python.md +296 -0
  33. package/dist/resources/skills/builtin/backend-engineering/references/testing-and-perf.md +298 -0
  34. package/dist/resources/skills/builtin/backend-engineering/references/validation-and-errors.md +266 -0
  35. package/dist/resources/skills/builtin/dasar-vibecode/SKILL.md +312 -0
  36. package/dist/resources/skills/builtin/excel-maker/SKILL.md +71 -0
  37. package/dist/resources/skills/builtin/frontend-design/SKILL.md +131 -0
  38. package/dist/resources/skills/builtin/frontend-design/data/app-interface.csv +33 -0
  39. package/dist/resources/skills/builtin/frontend-design/data/catalog-summary.json +78 -0
  40. package/dist/resources/skills/builtin/frontend-design/data/charts.csv +26 -0
  41. package/dist/resources/skills/builtin/frontend-design/data/colors.csv +193 -0
  42. package/dist/resources/skills/builtin/frontend-design/data/data-provenance.json +1348 -0
  43. package/dist/resources/skills/builtin/frontend-design/data/google-font-licenses.json +20185 -0
  44. package/dist/resources/skills/builtin/frontend-design/data/google-fonts.csv +1935 -0
  45. package/dist/resources/skills/builtin/frontend-design/data/icons.csv +106 -0
  46. package/dist/resources/skills/builtin/frontend-design/data/landing.csv +35 -0
  47. package/dist/resources/skills/builtin/frontend-design/data/motion.csv +18 -0
  48. package/dist/resources/skills/builtin/frontend-design/data/phosphor-icons-upstream.json +32582 -0
  49. package/dist/resources/skills/builtin/frontend-design/data/products.csv +193 -0
  50. package/dist/resources/skills/builtin/frontend-design/data/react-performance.csv +45 -0
  51. package/dist/resources/skills/builtin/frontend-design/data/stacks/angular.csv +51 -0
  52. package/dist/resources/skills/builtin/frontend-design/data/stacks/astro.csv +54 -0
  53. package/dist/resources/skills/builtin/frontend-design/data/stacks/avalonia.csv +57 -0
  54. package/dist/resources/skills/builtin/frontend-design/data/stacks/flutter.csv +53 -0
  55. package/dist/resources/skills/builtin/frontend-design/data/stacks/html-tailwind.csv +60 -0
  56. package/dist/resources/skills/builtin/frontend-design/data/stacks/javafx.csv +76 -0
  57. package/dist/resources/skills/builtin/frontend-design/data/stacks/jetpack-compose.csv +53 -0
  58. package/dist/resources/skills/builtin/frontend-design/data/stacks/laravel.csv +51 -0
  59. package/dist/resources/skills/builtin/frontend-design/data/stacks/nextjs.csv +62 -0
  60. package/dist/resources/skills/builtin/frontend-design/data/stacks/nuxt-ui.csv +71 -0
  61. package/dist/resources/skills/builtin/frontend-design/data/stacks/nuxtjs.csv +68 -0
  62. package/dist/resources/skills/builtin/frontend-design/data/stacks/react-native.csv +52 -0
  63. package/dist/resources/skills/builtin/frontend-design/data/stacks/react.csv +62 -0
  64. package/dist/resources/skills/builtin/frontend-design/data/stacks/shadcn.csv +69 -0
  65. package/dist/resources/skills/builtin/frontend-design/data/stacks/svelte.csv +56 -0
  66. package/dist/resources/skills/builtin/frontend-design/data/stacks/swiftui.csv +51 -0
  67. package/dist/resources/skills/builtin/frontend-design/data/stacks/threejs.csv +54 -0
  68. package/dist/resources/skills/builtin/frontend-design/data/stacks/uno.csv +60 -0
  69. package/dist/resources/skills/builtin/frontend-design/data/stacks/uwp.csv +56 -0
  70. package/dist/resources/skills/builtin/frontend-design/data/stacks/vue.csv +50 -0
  71. package/dist/resources/skills/builtin/frontend-design/data/stacks/winui.csv +60 -0
  72. package/dist/resources/skills/builtin/frontend-design/data/stacks/wpf.csv +57 -0
  73. package/dist/resources/skills/builtin/frontend-design/data/styles.csv +89 -0
  74. package/dist/resources/skills/builtin/frontend-design/data/typography.csv +75 -0
  75. package/dist/resources/skills/builtin/frontend-design/data/ui-reasoning.csv +193 -0
  76. package/dist/resources/skills/builtin/frontend-design/data/ux-guidelines.csv +120 -0
  77. package/dist/resources/skills/builtin/frontend-design/references/animation-patterns.md +705 -0
  78. package/dist/resources/skills/builtin/frontend-design/references/app-polish.md +117 -0
  79. package/dist/resources/skills/builtin/frontend-design/references/apple-fluid.md +278 -0
  80. package/dist/resources/skills/builtin/frontend-design/references/art-direction.md +280 -0
  81. package/dist/resources/skills/builtin/frontend-design/references/case-studies.md +599 -0
  82. package/dist/resources/skills/builtin/frontend-design/references/craft.md +128 -0
  83. package/dist/resources/skills/builtin/frontend-design/references/expo.md +251 -0
  84. package/dist/resources/skills/builtin/frontend-design/references/layout-systems.md +533 -0
  85. package/dist/resources/skills/builtin/frontend-design/references/libraries.md +72 -0
  86. package/dist/resources/skills/builtin/frontend-design/references/motion.md +222 -0
  87. package/dist/resources/skills/builtin/frontend-design/references/picker.md +197 -0
  88. package/dist/resources/skills/builtin/frontend-design/references/plan-template.md +73 -0
  89. package/dist/resources/skills/builtin/frontend-design/references/prototype.md +85 -0
  90. package/dist/resources/skills/builtin/frontend-design/references/recipes-expo.md +385 -0
  91. package/dist/resources/skills/builtin/frontend-design/references/recipes-web.md +421 -0
  92. package/dist/resources/skills/builtin/frontend-design/references/review-audit.md +107 -0
  93. package/dist/resources/skills/builtin/frontend-design/references/sonner.md +143 -0
  94. package/dist/resources/skills/builtin/frontend-design/references/technical-stack.md +543 -0
  95. package/dist/resources/skills/builtin/frontend-design/references/theming.md +60 -0
  96. package/dist/resources/skills/builtin/frontend-design/references/typography.md +393 -0
  97. package/dist/resources/skills/builtin/frontend-design/references/ux-rules.md +256 -0
  98. package/dist/resources/skills/builtin/frontend-design/references/vocabulary.md +169 -0
  99. package/dist/resources/skills/builtin/frontend-design/scripts/core.py +993 -0
  100. package/dist/resources/skills/builtin/frontend-design/scripts/design_system.py +1643 -0
  101. package/dist/resources/skills/builtin/frontend-design/scripts/reasoning_contract.py +123 -0
  102. package/dist/resources/skills/builtin/frontend-design/scripts/search.py +171 -0
  103. package/dist/resources/skills/builtin/frontend-design/scripts/validate_data.py +1091 -0
  104. package/dist/resources/skills/builtin/kerja-agentik/SKILL.md +117 -0
  105. package/dist/resources/skills/builtin/pdf-maker/SKILL.md +51 -0
  106. package/dist/resources/skills/builtin/tinjau-keamanan/SKILL.md +53 -0
  107. package/dist/resources/skills/builtin/word-maker/SKILL.md +58 -0
  108. package/dist/resources/skills/builtin/write-swift/SKILL.md +388 -0
  109. package/package.json +35 -4
@@ -0,0 +1,274 @@
1
+ # Node and TypeScript
2
+
3
+ This file owns what is **true of the Node runtime and would be wrong in Python or Go**: the event loop, promise semantics, `AbortSignal`, streams and backpressure, the driver and ORM landscape, and the fact that TypeScript types do not exist at runtime. Everything stack-neutral stays with its owner and is restated here only where the Node mechanics differ. The parse-don't-validate doctrine, unknown-field policy, and body-size limits are [validation-and-errors.md](validation-and-errors.md) — the zod section below is only how that doctrine is spelled in this stack. Retry counts, backoff formula, DLQ policy, and idempotent-processor design are [concurrency-and-jobs.md](concurrency-and-jobs.md); BullMQ here is only the mapping onto that policy. Log fields, redaction targets, and alert thresholds are [observability.md](observability.md); pino here is only the transport mechanics. Timeout values, pool sizing, and graceful shutdown are [deploy-and-config.md](deploy-and-config.md) and [testing-and-perf.md](testing-and-perf.md) — every number quoted from them is restated verbatim, never re-derived. N+1 diagnosis is [data-modelling.md](data-modelling.md); the per-ORM N+1 *behaviour* is here.
4
+
5
+ `kerja-agentik` owns running the suite and verification discipline; `dasar-vibecode` owns shell and git. This file adds only the Node-specific diagnostics those two cannot know about.
6
+
7
+ ## Operating Posture
8
+
9
+ Three failure modes, ranked, and the first two are silent:
10
+
11
+ 1. **A promise nobody awaited.** The handler returns `200`, the write never happened, and the rejection surfaces as a process-wide crash minutes later in an unrelated request — or, under Express 4, as a socket that hangs until the client times out. No stack points at the line that dropped it.
12
+ 2. **One synchronous line eating the event loop.** A `JSON.stringify` over 80k rows, a `scryptSync`, a `readFileSync` in a hot path. Every concurrent request queues behind it. The symptom reads as "the database got slow", because every timing you have is measured from inside the same blocked loop.
13
+ 3. **A pool multiplied by a process count nobody counted.** Cluster workers × replicas × `pool.max` against a Postgres `max_connections` of 100. Loud, at least.
14
+
15
+ **Never invent a Node default.** Not a `highWaterMark`, not an undici timeout, not a Prisma pool size. Every number below is a real default you can print with `node -p`, or a config value from the project. Writing `timeout: 30000` because it looks familiar is the same defect class as inventing a bcrypt cost.
16
+
17
+ **Producing nothing is often right.** If the project is on Fastify with `@fastify/type-provider-typebox`, do not add zod beside it. If it already has a `pg` `Pool` and hand-written SQL that works, adding Prisma is a migration, not an improvement. Say so and stop.
18
+
19
+ ## 1. Framework Choice
20
+
21
+ Pick on the constraint that actually decides it, not on ergonomics — all four are fine at writing a route.
22
+
23
+ | The deciding constraint | Framework |
24
+ | --- | --- |
25
+ | Team already has one, and it works | **The one they have.** Not a rewrite. |
26
+ | Throughput matters, JSON in / JSON out, you control the code | **Fastify** — schema-compiled serialization (`fast-json-stringify`) and validation (`ajv`) are the speed, not the router |
27
+ | Deploying to a Workers / Deno / Bun / edge runtime, or you want the same code in a test with no server | **Hono** — Web Standard `Request`/`Response`, so `app.fetch(new Request(...))` *is* the integration test |
28
+ | Large team, many modules, DI and a fixed layout are worth the ceremony | **NestJS** — and run it on the Fastify adapter, not the default Express one |
29
+ | Middleware you must have exists only for Express, or the app is ten routes | **Express 5** — never a new Express 4 project |
30
+ | Deciding between Fastify and Hono on "which is faster" | Neither. Decide on the runtime target; the difference is noise next to one N+1 |
31
+
32
+ **Express 4 is disqualifying for new work for one reason: a rejected promise from an async handler is not routed to the error middleware.** It becomes an unhandled rejection and the request hangs. Express 5 awaits handler return values and forwards rejections to `next(err)`. Fastify and Hono have always done this. If you are stuck on Express 4, every async handler is wrapped or the wrapper is the bug:
33
+
34
+ ```ts
35
+ const h = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
36
+ app.post("/orders", h(createOrder)); // unwrapped === hangs on rejection
37
+ ```
38
+
39
+ Serialization is where Fastify's number comes from: declare a `response` schema and it compiles a serializer that emits **only the declared fields**. That is simultaneously the response-hygiene rule from [validation-and-errors.md](validation-and-errors.md) enforced by the framework — an added `password_hash` column cannot leak through a declared response schema.
40
+
41
+ ## 2. zod at the Edge
42
+
43
+ The doctrine is [validation-and-errors.md](validation-and-errors.md). These are the zod specifics that bite in Node:
44
+
45
+ ```ts
46
+ const CreateOrder = z.object({
47
+ customerId: z.string().uuid(),
48
+ currency: z.enum(["IDR", "USD"]),
49
+ amountMinor: z.number().int().positive().max(100_000_000),
50
+ }).strict(); // v4: z.strictObject({...})
51
+
52
+ type CreateOrder = z.infer<typeof CreateOrder>; // never a parallel interface
53
+ ```
54
+
55
+ | Trap | What actually happens | Do |
56
+ | --- | --- | --- |
57
+ | Bare `z.object()` on a request body | Unknown keys are **stripped silently**, not rejected — the request looks accepted | `.strict()` / `z.strictObject` |
58
+ | `z.coerce.number()` on a query param | It is `Number(v)`: `""` → `0`, `null` → `0`, `" 3 "` → `3` | `z.string().regex(/^\d+$/).transform(Number)`, or `.pipe(z.number().int())` |
59
+ | `z.coerce.boolean()` on `"false"` | It is `Boolean(v)`: **`"false"` → `true`** | `z.enum(["true","false"]).transform(v => v === "true")` |
60
+ | `.parse()` inside a handler | Throws `ZodError`; on Express 4 an async throw is the hang above | `.safeParse()` and branch, or parse in middleware with a real error handler |
61
+ | `schema.parse(x)` in the service layer too | Two validators drift; the looser one is the one that gets found | Parse **once**, at the edge. Downstream takes `CreateOrder`, not `unknown` |
62
+
63
+ **Re-validation stops at the process boundary, and starts again at every re-entry.** Inside one process a value that was parsed is trusted. A job payload pulled off Redis, a webhook body, a row of `jsonb`, and a cache hit are all **untrusted again** — they crossed a boundary and a previous deploy may have written them. Parse job payloads in the processor with the schema, not with `as JobData`.
64
+
65
+ ## 3. ORM and Query Builder
66
+
67
+ | | **Prisma** | **Drizzle** | **Kysely** | **raw `pg`** |
68
+ | --- | --- | --- | --- | --- |
69
+ | Relations / N+1 | `include` fetches relations; without `relationJoins` these are separate queries stitched in JS — a loop over results with a lazy access is still N+1 | `db.query.x.findMany({ with: {...} })` emits one statement with lateral joins | **None.** You write the `JOIN`, so N+1 is a thing you typed | None. Same |
70
+ | Transactions | `$transaction([...])` batches sequentially; `$transaction(async tx => …)` is interactive with **`maxWait` 2000 ms, `timeout` 5000 ms** defaults — exceed it and you get `P2028` | `db.transaction(async tx => …)`; use `tx`, not `db`, inside | `db.transaction().execute(async trx => …)` | `client.query('BEGIN')` on a **checked-out client** |
71
+ | Migrations | `prisma migrate` owns the schema file; generated SQL is editable before apply | `drizzle-kit generate` diffs the TS schema, you review the SQL | Hand-written up/down through its `Migrator` | Whatever you bring (`node-pg-migrate`, plain SQL) |
72
+ | Type safety is | Generated from the DB schema — verified | Generated from your TS schema — verified against what you declared | **Asserted.** The `Database` interface is a claim; regenerate with `kysely-codegen` or it silently lies | `any` rows. Parse them |
73
+ | Pool caveat | Engine pool defaults to `num_physical_cpus * 2 + 1`, set via `?connection_limit=` in the URL — **it is not your `pg` pool** | Your `pg`/`postgres.js` pool, sized by you | Same | Same |
74
+
75
+ Whatever the answer, every migration goes through [migrations.md](migrations.md) before it touches a live table, and index design through [data-modelling.md](data-modelling.md).
76
+
77
+ **The per-request client is the trap that unites all four.** `new PrismaClient()` or `new Pool()` inside a handler, a factory, or a module that a serverless bundler re-evaluates opens a fresh pool per request. Under any load the database hits `max_connections` and starts refusing *everything*, including the health check. In Next.js dev, HMR re-evaluates the module on every save and reproduces this on a laptop.
78
+
79
+ ```ts
80
+ // db.ts — module scope, one instance for the process lifetime.
81
+ // globalThis guard survives HMR/module re-evaluation in dev; it is not a prod hack.
82
+ export const db = globalThis.__db ?? (globalThis.__db = new Pool({
83
+ max: 10, // pg default; the ceiling formula is testing-and-perf.md §8
84
+ idleTimeoutMillis: 10_000, // pg default
85
+ connectionTimeoutMillis: 2_000, // pg default is 0 = wait forever; 2s = deploy-and-config.md pool acquire
86
+ }));
87
+ ```
88
+
89
+ `pool.query()` checks out a connection per call and returns it. **A transaction must therefore hold one client**: `BEGIN` via `pool.query()` and `COMMIT` via `pool.query()` can land on two different connections, leaving an open transaction to be reaped by `idle_in_transaction_session_timeout`. Use `const c = await pool.connect()` … `finally { c.release() }`.
90
+
91
+ ## 4. Promises Without Holes
92
+
93
+ | Symptom | Cause | Fix |
94
+ | --- | --- | --- |
95
+ | Process exits with `ERR_UNHANDLED_REJECTION`, stack points nowhere useful | **Since Node 15 the default is `--unhandled-rejections=throw`** — an unhandled rejection terminates the process | Await it, or `.catch()` it at the site that created it |
96
+ | Handler returns `200`, the side effect never happened, log is clean | Floating promise: `sendEmail(x)` with no `await`. The `try/catch` around it catches nothing — the frame is gone before the rejection | `await`, or `void p.catch(err => log.error({err}))` — `void` alone is not enough |
97
+ | One slow branch fails and an unrelated rejection crashes the process later | `Promise.all` rejects on the **first** failure but does **not cancel the siblings**; a second failure lands with no handler | `allSettled` when partial success is meaningful, or an `AbortController` shared by all branches |
98
+ | Latency is the sum of the calls, not the max | Sequential `await` in a loop over independent work | `Promise.all` (bounded — see below) |
99
+
100
+ ```ts
101
+ // Two literals with reasons:
102
+ await Promise.all(ids.map(fetchOne)); // unbounded: 5k ids = 5k sockets = EMFILE
103
+ // and it drains the pool testing-and-perf.md sized
104
+ // Bound it. 10 matches the pg pool default above, so DB work can never out-issue connections.
105
+ const limit = pLimit(10);
106
+ await Promise.all(ids.map(id => limit(() => fetchOne(id))));
107
+ ```
108
+
109
+ Turn this on and treat it as an error, not a warning — it is the only thing that finds a floating promise mechanically: `@typescript-eslint/no-floating-promises` plus `no-misused-promises` (which catches an `async` function passed where a `void` callback is expected, e.g. `app.get('/x', asyncFn)` on Express 4). Both require type-aware linting (`parserOptions.project`); without it they are silently inert.
110
+
111
+ **`process.on('unhandledRejection')` is a logging hook, not a fix.** Log the error with the trace id, then let the process die and be restarted ([deploy-and-config.md](deploy-and-config.md)). Swallowing it keeps a process running with unknown state, which is worse than a restart.
112
+
113
+ ## 5. AbortSignal, End to End
114
+
115
+ **`fetch` in Node has no total-request timeout.** Restated from [deploy-and-config.md](deploy-and-config.md): undici's `connectTimeout` is 10s, `headersTimeout` and `bodyTimeout` are 300s, and none of them cap the whole call. A hung upstream holds your handler, its worker, and anything it checked out until the socket dies.
116
+
117
+ ```ts
118
+ // AbortSignal.any: Node >= 20.3. Whichever fires first wins.
119
+ // 5000 = the service -> third-party budget from deploy-and-config.md, not a guess.
120
+ const res = await fetch(url, {
121
+ signal: AbortSignal.any([req.signal, AbortSignal.timeout(5000)]),
122
+ });
123
+ ```
124
+
125
+ | Where the signal comes from | Framework |
126
+ | --- | --- |
127
+ | `c.req.raw.signal` | Hono (Web Standard `Request`) |
128
+ | `request.raw.on("close", …)` → your own `AbortController` | Fastify, Express |
129
+ | `signal` on the job's context, or your own per-job controller | BullMQ (no built-in signal) |
130
+
131
+ Two consequences worth naming:
132
+
133
+ - **Classify the abort or you will report a client disconnect as your own outage.** `AbortSignal.timeout` rejects with a `TimeoutError` `DOMException`; `controller.abort()` gives an `AbortError`. `TimeoutError` maps to the gateway-timeout row in [api-contracts.md](api-contracts.md). `AbortError` from a client disconnect maps to **no response at all** — the socket is gone; log it at INFO and stop. Do not invent a status code for it.
134
+ - **Aborting the fetch does not abort the query.** `pg`, Prisma, and Drizzle do not cancel an in-flight statement on an `AbortSignal`. The database-side stop is `statement_timeout` — **5s**, restated from [deploy-and-config.md](deploy-and-config.md). A request-scoped signal that stops HTTP work but leaves a 40-second sequential scan running is a pool leak wearing a cancellation costume.
135
+
136
+ ## 6. Streams and Backpressure
137
+
138
+ **`res.write()` returns `false` when the socket's buffer is past its `highWaterMark` (16 KiB; 64 KiB since Node 22). Ignoring that return value is how a large response becomes an OOM kill.** The loop keeps producing, Node keeps buffering in heap, RSS climbs, and the container is `OOMKilled` with exit code 137 — with no exception, no stack, and a log that ends mid-request. A slow client makes it happen at a tenth of the data.
139
+
140
+ ```ts
141
+ import { pipeline } from "node:stream/promises";
142
+
143
+ // pipeline propagates backpressure AND destroys every stream on error or client abort.
144
+ await pipeline(queryStream, jsonEncoder, res, { signal: req.signal });
145
+ ```
146
+
147
+ Never `res.json(await db.query(...))` for an unbounded result set. Two separate cliffs: the array is fully materialised in heap, and `JSON.stringify` on it is a single synchronous call that blocks the loop (§7) and throws `RangeError: Invalid string length` past **V8's maximum string length of 536,870,888 characters** on 64-bit. The fix is a cursor plus a streaming encoder — or, better, the cursor pagination the hub already mandates.
148
+
149
+ `.pipe()` is not `pipeline()`: on an error it leaves the source stream open and the file descriptor leaking. Use `pipeline` for every chain, including `fs` → `res` file downloads.
150
+
151
+ ## 7. The Event Loop
152
+
153
+ One loop per process. Anything synchronous stops **every** concurrent request, and every latency number you collect is measured from inside the stall, so the blame lands on whatever was awaiting when the loop froze.
154
+
155
+ | Blocks the loop | Instead |
156
+ | --- | --- |
157
+ | `JSON.parse` / `JSON.stringify` on multi-MB payloads | Stream it; cap the body at the limit in [validation-and-errors.md](validation-and-errors.md) |
158
+ | `crypto.pbkdf2Sync`, `scryptSync`, `bcrypt.hashSync` | The async form — it runs on the libuv threadpool |
159
+ | `fs.readFileSync`, `zlib.gzipSync` in a request path | `fs/promises`, `zlib` async, or read once at boot |
160
+ | A catastrophic-backtracking regex over user input | Cap the string length **before** the regex ([validation-and-errors.md](validation-and-errors.md)) |
161
+ | Sorting or mapping 10⁶ rows in JS | `ORDER BY` in the database, which is what it is for |
162
+
163
+ The async crypto/`fs`/`dns.lookup` forms are not free either: they share the libuv threadpool, **default `UV_THREADPOOL_SIZE=4`** (max 1024). Five concurrent `scrypt` calls serialize into two rounds *and* stall unrelated file reads. Raise it deliberately against core count, or move the work out.
164
+
165
+ ```ts
166
+ import { monitorEventLoopDelay } from "node:perf_hooks";
167
+ const h = monitorEventLoopDelay({ resolution: 10 }); // ms sample interval
168
+ h.enable();
169
+ // h.percentile(99) is in NANOSECONDS. Export as a gauge — observability.md owns the alert.
170
+ setInterval(() => metrics.eventLoopDelayP99.set(h.percentile(99) / 1e6), 10_000);
171
+ ```
172
+
173
+ libuv's timer granularity is ~1ms, so a p99 delay in the **hundreds of milliseconds is your code, never scheduling noise** — that is a diagnostic tripwire, not an SLO. On Fastify, `@fastify/under-pressure` turns the same measurement into load shedding: over `maxEventLoopDelay` it returns `503` with `Retry-After` instead of accepting work it cannot do.
174
+
175
+ **`worker_threads` is the escape hatch for CPU-bound work** — image resizing, a large parse, a report render. Use a pool (`piscina`) rather than spawning per request; thread startup is tens of milliseconds and unbounded spawning is the original problem with extra memory. `child_process` is for running a *different program*, not for offloading JS. Neither is a fix for a slow query.
176
+
177
+ ## 8. BullMQ
178
+
179
+ Policy — attempt counts, the backoff formula, DLQ ownership, "cron only enqueues" — is [concurrency-and-jobs.md](concurrency-and-jobs.md). This is the mapping.
180
+
181
+ ```ts
182
+ new Worker("payments", async (job) => {
183
+ // Idempotent by construction: the job id IS the idempotency key, and the DB
184
+ // constraint is what enforces it. "Check if it ran already" is a race, not a guard.
185
+ await db.charge({ idempotencyKey: job.id, ...Payload.parse(job.data) });
186
+ }, {
187
+ connection: new IORedis(url, { maxRetriesPerRequest: null }), // BullMQ requires null
188
+ concurrency: 5, // PER WORKER INSTANCE. 4 replicas x 5 = 20 concurrent jobs
189
+ }); // against a pool sized in testing-and-perf.md §8
190
+ ```
191
+
192
+ | BullMQ reality | Consequence |
193
+ | --- | --- |
194
+ | `backoff: { type: "exponential", delay: 1000 }` is `delay * 2^(attempts-1)` — **no jitter** | It violates the jitter rule in [concurrency-and-jobs.md](concurrency-and-jobs.md). Register a custom `settings.backoffStrategy` implementing `random(0, min(300s, 1s * 2**n))` |
195
+ | A `Worker` holds a **blocking** Redis connection plus a regular one, and cannot share the blocking one | Connections ≈ (workers × 2) + queues + `QueueEvents`, × replicas. On a managed Redis with a low `maxclients`, ten workers per pod is an outage, not a config choice |
196
+ | `attempts` defaults to 1 | A transient failure is a permanent failure unless you set it |
197
+ | Completed and failed jobs are retained in Redis | Set `removeOnComplete: { age: 3600, count: 1000 }` and `removeOnFail: { age: 86_400 }` explicitly. Unbounded retention is a Redis memory incident that arrives as an eviction |
198
+ | `job.data` is JSON from Redis, written by a previous deploy | Parse it with a schema (§2). `job.data as Payload` is a lie the compiler cannot catch |
199
+
200
+ Shut down with `await worker.close()` inside the `SIGTERM` handler so in-flight jobs finish — the drain deadline is [deploy-and-config.md](deploy-and-config.md).
201
+
202
+ ## 9. pino, and Why `console.log` Is a Performance Bug
203
+
204
+ Field names, required keys, redaction targets, and sampling are [observability.md](observability.md). The Node-specific part is that **`console.log` is synchronous on POSIX when stdout is a file or a TTY** (async only when it is a pipe). Every line is a blocking `write(2)` on the event loop, in the hot path, from every request. It is simultaneously an observability bug: `console.log(obj)` runs `util.inspect` with `depth: 2`, so nested fields render as `[Object]` and multi-line output breaks one event into many unparseable log lines.
205
+
206
+ ```ts
207
+ export const log = pino({
208
+ level: process.env.LOG_LEVEL ?? "info",
209
+ timestamp: pino.stdTimeFunctions.isoTime, // default is epoch ms
210
+ redact: { paths: [/* observability.md owns this list */], censor: "[REDACTED]" },
211
+ });
212
+ ```
213
+
214
+ Formatting and transports (`pino-pretty`, file, HTTP shippers) run in a **worker thread**, off the request path. Corollary: `pino-pretty` must never be enabled in production — you pay serialization plus prettification to produce something no log pipeline can parse. Pretty in dev, JSON to stdout everywhere else, and the platform ships it.
215
+
216
+ ## 10. Cluster, PM2, and Replicas
217
+
218
+ **Prefer one Node process per container and let the orchestrator run N containers.** `cluster` and PM2 cluster mode hide the process count from the platform: the scheduler sees one pod, the database sees `workers × replicas` pools, restarts are managed by two systems that disagree, and per-process memory limits stop matching the container's.
219
+
220
+ ```
221
+ total connections = replicas × processes_per_replica × pool.max
222
+ ≤ (max_connections − superuser_reserved_connections − other_consumers)
223
+ Postgres defaults: max_connections = 100, superuser_reserved_connections = 3
224
+ ```
225
+
226
+ That is the ceiling from [testing-and-perf.md](testing-and-perf.md) §8 with the Node multiplier made explicit. Four replicas × four cluster workers × `pool.max: 10` is 160 connections against a ceiling of 97 — and the failure is not gradual: the pool that loses the race gets `too many clients already` on **every** query, including `/readyz`.
227
+
228
+ **Never size workers from `os.cpus().length`.** It reports the host's CPUs, not the cgroup quota — a container limited to `cpu: "500m"` on a 64-core node forks 64 workers. `os.availableParallelism()` honours the CPU affinity mask but still not a CFS quota. Take the worker count from validated config ([deploy-and-config.md](deploy-and-config.md)), and divide `pool.max` by it.
229
+
230
+ ## 11. TypeScript Facts That Matter at Runtime
231
+
232
+ **Types are erased. They validate nothing at any boundary.** A `.d.ts` is a claim about data you did not check.
233
+
234
+ | Never | Instead | Because |
235
+ | --- | --- | --- |
236
+ | `JSON.parse(body) as CreateOrder` | `CreateOrder.parse(JSON.parse(body))` | `JSON.parse` returns `any`; the cast is a comment with syntax highlighting |
237
+ | `as` to make an error go away | `satisfies` (TS 4.9+) | `satisfies` checks the value against the type **and** keeps the narrow inferred literal types; `as` silences the check |
238
+ | `x as unknown as T` | Parse it | The double cast is the compiler telling you the claim is false |
239
+ | A hand-written `interface` beside a zod schema | `z.infer<typeof S>` | Two sources of truth drift silently — [validation-and-errors.md](validation-and-errors.md) |
240
+ | `function isUser(x): x is User { return !!x }` | A schema | A type predicate is an unchecked promise, not a check |
241
+ | `process.env.FOO!` | The validated config object | `!` asserts non-null on a value that is `undefined` in the environment that matters |
242
+
243
+ Compiler settings that change what ships: `strict: true` is the floor; `noUncheckedIndexedAccess` makes `rows[0]` `T | undefined`, which is what a query actually returns; `exactOptionalPropertyTypes` stops `{ note: undefined }` from satisfying `note?: string` and writing a `null` you did not intend. If the project runs `node --experimental-strip-types` (22.6+; unflagged in 23.6), set `erasableSyntaxOnly` — `enum`, `namespace`, and constructor parameter properties emit runtime code and will not strip. Prefer `as const` objects over `enum` regardless.
244
+
245
+ ## Node-Specific Diagnostics
246
+
247
+ Verification method is `kerja-agentik`'s. These are the flags it cannot know:
248
+
249
+ ```bash
250
+ node --trace-warnings app.js # turns a bare "Warning:" into a stack — finds the leaked listener
251
+ node --enable-source-maps app.js # otherwise every prod stack points at dist/ line 1
252
+ node --cpu-prof --cpu-prof-dir=/tmp # writes a .cpuprofile on exit; open in Chrome DevTools
253
+ node --trace-uncaught app.js # stack for a thrown non-Error
254
+ NODE_DEBUG=undici,net node app.js # what the HTTP client actually did
255
+ ```
256
+
257
+ ## Never Ship
258
+
259
+ | Never | Instead |
260
+ |---|---|
261
+ | Express 4 with an unwrapped async handler | Express 5, or a `.catch(next)` wrapper on every route |
262
+ | A promise created and not awaited or `.catch`ed | `await`, or `void p.catch(log)`; enable `no-floating-promises` |
263
+ | `fetch()` with no `signal` | `AbortSignal.any([req.signal, AbortSignal.timeout(5000)])` |
264
+ | `new PrismaClient()` / `new Pool()` per request or per module reload | One module-scoped instance behind a `globalThis` guard |
265
+ | `pool.query('BEGIN')` | `pool.connect()`, transact on the client, `release()` in `finally` |
266
+ | `res.write()` in a loop with the return value ignored | `pipeline()` from `node:stream/promises` |
267
+ | `res.json(await allRows)` on an unbounded query | Cursor + streaming encoder |
268
+ | A `*Sync` crypto/fs/zlib call in a request path | The async form; CPU work to `worker_threads` |
269
+ | `console.log` in a server | pino to stdout, pretty only in dev |
270
+ | Unbounded `Promise.all` over user-sized input | A concurrency limit tied to the pool size |
271
+ | BullMQ built-in `exponential` backoff | A custom strategy with full jitter |
272
+ | Worker count or pool size from `os.cpus().length` | Validated config, divided by processes per container |
273
+ | `as` on anything parsed from JSON, Redis, or `jsonb` | Schema `.parse()` at every re-entry |
274
+ | A hand-written type beside a zod schema | `z.infer` |
@@ -0,0 +1,296 @@
1
+ # Python
2
+
3
+ This file owns only what is **true of Python** and false elsewhere: the async/sync boundary in a FastAPI worker, pydantic v2 as the edge schema, SQLAlchemy 2.0 session lifecycle, Alembic autogenerate, ASGI process topology, the GIL, Celery's Python-specific footguns, and the virtualenv/lockfile rules the harness enforces. Everything stack-neutral belongs to a sibling and is not re-derived here.
4
+
5
+ Neighbours, so you don't half-solve a problem here: what to validate and what an error body may contain is [validation-and-errors.md](validation-and-errors.md) — this file only says how pydantic v2 expresses it. Index design, N+1 detection, and the query-count budget are [data-modelling.md](data-modelling.md); the `selectinload`/`joinedload` choice here is that file's to-one/to-many table in SQLAlchemy syntax. Lock modes and expand/contract are [migrations.md](migrations.md); Alembic below only decides what the generator got wrong before you run it. Retry policy, backoff, visibility timeouts, and idempotent-consumer design are [concurrency-and-jobs.md](concurrency-and-jobs.md) — Celery below is that doctrine's config surface, never a second policy. Timeout values, pool sizing, and graceful shutdown are [deploy-and-config.md](deploy-and-config.md) and [testing-and-perf.md](testing-and-perf.md); the worker topology here is what multiplies those numbers.
6
+
7
+ ## Operating Posture
8
+
9
+ Three failure modes, ranked. The first is worse because nothing is red.
10
+
11
+ 1. **A blocking call inside `async def`.** No exception, no traceback, no CPU saturation. Throughput stops scaling with concurrency and *every* endpoint on that worker slows together, including `/livez`. Reviewers read the handler, see correct-looking `await`s, and move on. Section 2 exists entirely for this.
12
+ 2. **The ORM object that outlives its session.** `DetachedInstanceError`, `MissingGreenlet`, or a serialiser quietly emitting a column you added to the table last week. Loud when it crashes, dangerous when it doesn't.
13
+ 3. **An environment nobody can reproduce.** Unpinned deps, `pip install` into a system interpreter, "works on my machine". Cheap to prevent, expensive to debug at 02:00.
14
+
15
+ **Never invent a Python default.** Not the threadpool size, not Celery's visibility timeout, not gunicorn's worker formula. Every number below is a real default or a documented recommendation and is checkable in the source. `worker_prefetch_multiplier` guessed at from memory reads exactly like one that was measured.
16
+
17
+ ## 1. Framework Choice
18
+
19
+ Keyed on what actually decides it, not on taste:
20
+
21
+ | What decides it | Answer |
22
+ | --- | --- |
23
+ | The app is CRUD over a relational schema with an admin UI, auth, and forms | **Django** — you get the admin, the ORM, migrations, sessions, and CSRF for free. Rebuilding those in FastAPI is a quarter you will not get back. |
24
+ | The service is an API in front of models, streaming, or async I/O fan-out (any LLM/AI-adjacent work) | **FastAPI** — pydantic-typed contracts, native async, OpenAPI generated from the signatures |
25
+ | Genuinely tiny: one webhook receiver, a health shim, a sidecar | **Flask** (or FastAPI anyway — the cost is one dependency) |
26
+ | Existing Django app needs one async endpoint | **Stay in Django.** Django 4.1+ has async views and an async ORM interface. Do not add a second framework to one process. |
27
+ | "We should modernise it to FastAPI" | **No.** Port when you can name the limit you hit — request concurrency, WebSockets, streaming. "It's more modern" is not a limit. |
28
+
29
+ **Producing no framework change is the common correct answer.** A working Django service is not a defect.
30
+
31
+ ## 2. The async/sync Trap
32
+
33
+ This costs more real throughput than every other item in this file combined, and it is invisible in review.
34
+
35
+ **The two paths, exactly:**
36
+
37
+ - A path operation declared **`def`** is run by Starlette via `anyio.to_thread.run_sync`, in a threadpool bounded by anyio's default capacity limiter: **40 threads per process**. Request 41 queues. The event loop stays free.
38
+ - A path operation declared **`async def`** runs **directly on the event loop**. There is one loop per worker process. Anything in it that does not yield stalls every other request on that worker, plus the health endpoint, plus the metrics scrape.
39
+
40
+ Dependencies obey the same rule independently: an `async def` dependency runs on the loop even when the path operation is `def`. So does a `BackgroundTask`, and so does a `def` dependency (threadpool) attached to an `async def` route.
41
+
42
+ ### The decision rule
43
+
44
+ | What the handler body actually does | Declare it |
45
+ | --- | --- |
46
+ | Only awaits async I/O — `httpx.AsyncClient`, `asyncpg`, `AsyncSession`, `redis.asyncio` | `async def` |
47
+ | Contains any blocking call — `requests`, sync `psycopg`, `boto3`, `time.sleep`, `open().read()` on a large file, `subprocess.run`, PIL, pandas | `def` |
48
+ | Mostly async with one unavoidable blocking call | `async def`, and wrap that call: `await anyio.to_thread.run_sync(fn, arg)` |
49
+ | Pure-Python CPU work over ~50 ms (hashing, parsing, image work, tokenising) | Neither — section 6. A threadpool does not help; the GIL is the limit. |
50
+
51
+ ```python
52
+ # WRONG — one loop, every request on this worker waits for this socket.
53
+ @app.get("/quote")
54
+ async def quote():
55
+ return requests.get(UPSTREAM, timeout=(2, 5)).json() # blocking, on the loop
56
+
57
+ # RIGHT if you must keep the sync client — the threadpool absorbs it (40 concurrent).
58
+ @app.get("/quote")
59
+ def quote():
60
+ return requests.get(UPSTREAM, timeout=(2, 5)).json()
61
+
62
+ # BEST — async client, async handler, no thread hop.
63
+ @app.get("/quote")
64
+ async def quote(client: httpx.AsyncClient = Depends(get_client)):
65
+ r = await client.get(UPSTREAM, timeout=5.0)
66
+ return r.json()
67
+ ```
68
+
69
+ The reverse mistake is quieter: a handler that only `await`s, declared `def`, is capped at 40 concurrent for no reason and pays a thread hop per request. Both directions are wrong; only one takes the service down.
70
+
71
+ ### The symptom, so you recognise it before you profile
72
+
73
+ - Throughput flattens as concurrency rises while **CPU sits well under saturation** — the classic reading is 25% CPU and a queue.
74
+ - p99 climbs on endpoints that share nothing with the slow one, including `/livez`, because they share a loop.
75
+ - Under gunicorn's async worker: `[CRITICAL] WORKER TIMEOUT`. Async workers keep a heartbeat file that the loop must refresh; a blocked loop stops the heartbeat, so this message means *you blocked the event loop*, not *the request was slow*. Raising `--timeout` hides it.
76
+
77
+ **Detection, not inspection.** Reading the handler is how this survives three refactors:
78
+
79
+ ```python
80
+ # Any callback holding the loop longer than this is logged with its duration.
81
+ loop = asyncio.get_running_loop()
82
+ loop.set_debug(True)
83
+ loop.slow_callback_duration = 0.1 # asyncio's own default is 0.1s; keep it, don't raise it
84
+ # -> "Executing <Handle ...> took 0.512 seconds"
85
+ ```
86
+
87
+ Run it in dev and in load tests. Export event-loop lag as a gauge in production ([observability.md](observability.md)).
88
+
89
+ **Forbidden by name:** never add `async` in front of a function to "make it faster". `async def` around `requests.get` is strictly worse than the `def` it replaced. Never call `asyncio.run()` inside a running loop (`RuntimeError: asyncio.run() cannot be called from a running event loop`), and never `loop.run_until_complete` from inside a handler. Never raise the anyio thread limiter above 40 to fix a stall — a bigger threadpool against a fixed pool of database connections just moves the queue ([testing-and-perf.md](testing-and-perf.md) §8).
90
+
91
+ ## 3. pydantic v2 at the Edge
92
+
93
+ The schema is the boundary; per [validation-and-errors.md](validation-and-errors.md) it is the only thing that touches the raw body, and unknown keys are rejected.
94
+
95
+ ```python
96
+ class OrderCreate(BaseModel):
97
+ model_config = ConfigDict(extra="forbid", strict=False)
98
+ customer_id: UUID
99
+ currency: Literal["IDR", "USD"] # closed set, never str
100
+ amount_minor: int = Field(gt=0, le=100_000_000) # minor units, per data-modelling.md
101
+
102
+ @field_validator("currency", mode="after")
103
+ @classmethod
104
+ def upper(cls, v: str) -> str: return v.upper()
105
+
106
+ @model_validator(mode="after")
107
+ def cross_field(self) -> "OrderCreate": # mode="after" gets the model, and must return it
108
+ ...
109
+ return self
110
+ ```
111
+
112
+ **Three models per resource, not one.** `XCreate` (input), `XUpdate` (every field optional), `XPublic` (output, a hand-written field list). **Never return the ORM instance and never derive the response model from the table.** Two failures, both real:
113
+
114
+ - **Silent disclosure.** A response model built by mirroring the ORM columns exposes every column added later. The day someone adds `password_hash` or `internal_notes`, the API starts returning it and no diff touched the router.
115
+ - **Serialisation-time I/O.** FastAPI serialises after the request-scoped session has closed. A lazy relationship touched during serialisation raises `DetachedInstanceError` (sync) or `sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called` (async) — an error that points at the serialiser and is caused by the loader config in section 4.
116
+
117
+ Facts worth carrying:
118
+
119
+ - v2 is **lax by default**: `"123"` validates into `int`, and `bool` accepts `"yes"`, `"on"`, `"t"`, `1`. Where a coercion would hide a client bug, set `strict=True` on the field or `ConfigDict(strict=True)` — and decide it, don't inherit it.
120
+ - `from_attributes=True` (v1's `orm_mode`) lets a model read a SQLAlchemy object. It reads whatever attributes it names — so name them.
121
+ - `Field(exclude=True)` is a serialisation preference, not a security control. If a value must never leave, it is not on the output model at all.
122
+ - v1→v2 renames that produce confusing errors: `@validator`→`@field_validator`, `@root_validator`→`@model_validator`, `.dict()`→`.model_dump()`, `.parse_obj()`→`.model_validate()`, `class Config`→`model_config = ConfigDict(...)`, `allow_population_by_field_name`→`populate_by_name`.
123
+ - Validation runs in Rust (`pydantic-core`); a `mode="before"` validator drops back into Python per field. On a hot list endpoint, that is where the serialisation cost went.
124
+
125
+ ## 4. SQLAlchemy 2.0
126
+
127
+ ### Session scope
128
+
129
+ One session per request, opened and closed by the framework, never a module-level global:
130
+
131
+ ```python
132
+ engine = create_async_engine(DSN, pool_size=9, max_overflow=0) # size per testing-and-perf.md §8
133
+ SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
134
+
135
+ async def get_session() -> AsyncIterator[AsyncSession]:
136
+ async with SessionLocal() as session:
137
+ yield session # commit in the service layer, not here
138
+ ```
139
+
140
+ ### expire_on_commit — the surprise
141
+
142
+ `Session(expire_on_commit=True)` is the **default**. After `commit()`, every attribute on every instance in the identity map is expired; the next attribute access emits a fresh `SELECT`. Symptoms, in order of how often they appear:
143
+
144
+ - `MissingGreenlet` on an async session, because that refresh is implicit I/O outside a greenlet context.
145
+ - `DetachedInstanceError: Instance <Order> is not bound to a Session` when the session already closed — typically at response-serialisation time.
146
+ - No error at all: one extra `SELECT` per committed object, so a bulk endpoint doubles its query count invisibly.
147
+
148
+ The fix is `expire_on_commit=False` (the documented recommendation for asyncio) plus building the response model **before** commit, or an explicit `await session.refresh(obj, ["status"])` when you genuinely need the database's value back.
149
+
150
+ ### Lazy loading is the N+1 engine
151
+
152
+ Relationships default to `lazy="select"`: touching `order.items` in a loop emits one query per order. [data-modelling.md](data-modelling.md) owns detection and the query-count budget; here is the SQLAlchemy expression of its fix table.
153
+
154
+ | Relationship shape | Loader | What it emits |
155
+ | --- | --- | --- |
156
+ | to-many collection (`order.items`) | `selectinload(Order.items)` | second `SELECT ... WHERE parent_id IN (...)`, batched 500 ids at a time, no row multiplication |
157
+ | many-to-one / one-to-one (`order.customer`) | `joinedload(Order.customer)` | one `LEFT OUTER JOIN` |
158
+ | to-many **with `LIMIT` on the parent** | `selectinload` only | `joinedload` multiplies rows and forces a subquery wrap |
159
+ | you must **filter** on the related table | `.join(Order.items).options(contains_eager(Order.items))` | one query, filtered collection |
160
+
161
+ `joinedload` does **not** filter. `.options(joinedload(Order.items)).where(Item.sku == x)` returns every item of the matching orders while looking like it filtered — a wrong-results bug, not a slow one. Use `contains_eager` when the join is also the filter.
162
+
163
+ **Make an accidental lazy load loud** rather than slow: `relationship(..., lazy="raise_on_sql")` raises `InvalidRequestError` when the loader would issue SQL, so the N+1 fails in CI instead of degrading in production. Async already does this via `MissingGreenlet`; sync needs the flag.
164
+
165
+ ### A session is not thread safe and not task safe
166
+
167
+ One `Session`/`AsyncSession` is a single connection and a single unit of work. Two coroutines using it concurrently — the seductive `asyncio.gather(fetch_a(s), fetch_b(s))` — produce `IllegalStateChangeError` / `InvalidRequestError: this session is provisioning a new connection; concurrent operations are not permitted`, or asyncpg's `InterfaceError: cannot perform operation: another operation is in progress`. Under load these arrive intermittently and get filed as "flaky database".
168
+
169
+ **Concurrency means a session each**, from the same engine and pool — and remember each one occupies a pool slot, so `gather` over 20 sessions against a pool of 9 is a queue by construction. For WSGI/threads use `scoped_session`; for asyncio prefer per-request injection over `async_scoped_session`.
170
+
171
+ Also: 2.0 style is `select()` + `session.execute()` / `session.scalars()`. `session.query()` is legacy and its loader defaults differ; do not mix the two in one codebase. Use `session.get(Order, id)` for primary-key lookups — it checks the identity map first.
172
+
173
+ ## 5. Alembic
174
+
175
+ **`--autogenerate` produces a draft, never a migration.** Read the generated file, then print the SQL it will run — `alembic upgrade <rev> --sql` — before it touches anything. [migrations.md](migrations.md) owns the lock analysis; this is the list of what the generator does not see.
176
+
177
+ | Blind spot | What actually happens |
178
+ | --- | --- |
179
+ | **Renames** | Emitted as `drop_column` + `add_column`. Silent data loss, and it looks like a normal diff. Same for renamed tables and indexes. |
180
+ | **Server defaults** | Not compared unless `compare_server_default=True`, and even then comparison across types is unreliable. `compare_type` defaults to `True` since Alembic 1.12; `compare_server_default` still does not. |
181
+ | **Postgres enums** | Adding a value to a native enum is never generated. Neither is `CREATE TYPE` for a new enum used by an `ALTER`. You write `op.execute("ALTER TYPE ... ADD VALUE ...")` yourself. |
182
+ | **Unmodelled objects** | Tables, views, extensions, and PostGIS's `spatial_ref_sys` that `target_metadata` does not know about are diffed as *drops*. Filter with `include_object` / `include_name` or autogenerate will offer to delete your database. |
183
+ | **CHECK constraints, partial indexes, expression indexes** | Generally not reflected or compared. Hand-write them. |
184
+ | **Concurrent DDL** | `CREATE INDEX CONCURRENTLY` cannot run inside the runner's transaction — SQLSTATE `25001`. Alembic's escape hatch is an autocommit block, and nothing else goes in that file. |
185
+
186
+ Two heads (`alembic heads` returning more than one) is a merge, not a rerun: `alembic merge`. And per [migrations.md](migrations.md), a migration that has run anywhere — CI included — is never edited.
187
+
188
+ ## 6. ASGI, WSGI, and Worker Topology
189
+
190
+ WSGI is one synchronous callable per request: no WebSockets, no streaming request bodies, no lifespan. ASGI adds all three and lets one thread hold thousands of open connections. FastAPI/Starlette are ASGI-only; Django serves either and only its ASGI entrypoint runs async views concurrently.
191
+
192
+ | Situation | Run it as |
193
+ | --- | --- |
194
+ | Container orchestrated (Kubernetes, ECS), one process per container | `uvicorn app:app --host 0.0.0.0 --port 8000` — let the orchestrator scale, and keep pool arithmetic honest |
195
+ | One VM, need process supervision, worker recycling, `--max-requests` for a leak | `gunicorn -k uvicorn_worker.UvicornWorker -w N app:app` (the `uvicorn.workers` module was deprecated in uvicorn 0.30 and moved to the `uvicorn-worker` package) |
196
+ | Legacy WSGI app (Flask, Django sync) | `gunicorn -w N app:app`, sync workers, `2 * cores + 1` — gunicorn's own documented formula, and it applies **only** to sync workers |
197
+ | Async workers | 1 per core at most. One async worker already multiplexes; more workers only multiply the DB pool. |
198
+
199
+ **Workers multiply everything.** `-w 4` with `pool_size=9` is 36 connections from one container, before replicas. That is the arithmetic in [testing-and-perf.md](testing-and-perf.md) §8, and the reason [deploy-and-config.md](deploy-and-config.md) calls the process-model concurrency factor aged. Never fork after opening the pool: create the engine in a lifespan handler or per worker, or children inherit a socket and you get `InterfaceError` on the second request.
200
+
201
+ ## 7. The GIL, Honestly
202
+
203
+ One thread executes Python bytecode at a time per interpreter. What it does **not** stop is the case that matters most here:
204
+
205
+ - **Released around blocking I/O** — sockets, files, `time.sleep`. This is why FastAPI's 40-thread pool genuinely serves concurrent sync database calls.
206
+ - **Released by C extensions that ask** (`Py_BEGIN_ALLOW_THREADS`): numpy on large arrays, `hashlib` on big buffers, zlib, most DB drivers while waiting on the wire.
207
+ - **Not released for pure-Python CPU work.** Four threads doing Python arithmetic on four cores are *slower* than one, because the interpreter hands the GIL around every `sys.setswitchinterval` — 5 ms by default — and each handoff is pure overhead.
208
+
209
+ | Work | Answer |
210
+ | --- | --- |
211
+ | Blocking I/O | Threads. `def` handler, or `anyio.to_thread.run_sync`. |
212
+ | Pure-Python CPU, > ~50 ms, small arguments and results | `ProcessPoolExecutor` — but everything crosses via `pickle`, so a 200 MB DataFrame argument costs more than the compute |
213
+ | Pure-Python CPU, large data or long-running | Not in the request path at all. Queue it ([concurrency-and-jobs.md](concurrency-and-jobs.md)). |
214
+ | Numeric work | A library that releases the GIL (numpy, polars). Threads then actually scale. |
215
+
216
+ The GIL is **not** a lock you can borrow: `counter += 1` and `d[k] += 1` are multiple bytecodes and interleave. Use `threading.Lock`, or don't share the object.
217
+
218
+ **Free-threaded CPython (PEP 703, `python3.13t`, officially supported from 3.14) is not the fix for a CPU problem today.** It is not the default build, single-threaded performance is lower, and a large share of C-extension wheels still assume the GIL. Do not put a service on it to avoid a process pool.
219
+
220
+ ## 8. Celery
221
+
222
+ The policy — at-least-once, idempotent consumers, backoff with jitter, DLQ ownership — is [concurrency-and-jobs.md](concurrency-and-jobs.md) and is not restated. These are the Celery-specific ways to get it wrong.
223
+
224
+ ```python
225
+ @app.task(
226
+ bind=True,
227
+ acks_late=True, # ack AFTER the task completes; without it a worker
228
+ task_reject_on_worker_lost=True, # crash loses the task silently, with nothing in the log
229
+ autoretry_for=(TransientError,),
230
+ retry_backoff=True, retry_jitter=True, max_retries=5,
231
+ soft_time_limit=55, time_limit=60, # both default to None = a task may run forever
232
+ )
233
+ def charge(self, payment_id: str) -> None: # an id, never an ORM object or a dict of one
234
+ ...
235
+ ```
236
+
237
+ `acks_late=True` makes delivery at-least-once by construction, which is why the sibling's idempotency rule is not optional. Then:
238
+
239
+ - **Redis broker has no real ack.** Celery emulates one with `broker_transport_options={"visibility_timeout": N}`, **default 3600 seconds**. A task that runs longer than that is handed to a second worker while the first is still running — the duplicate-execution mechanism the sibling's "3× p99" rule exists to prevent. It also breaks `countdown`/`eta`: a task scheduled 2 hours out on Redis is redelivered and re-executed every hour until it fires. Use a broker with real acks (RabbitMQ, SQS) for delayed work, or raise the timeout above the longest ETA and know you did.
240
+ - **`worker_prefetch_multiplier` defaults to 4.** Each worker reserves 4 × concurrency messages up front, so short tasks queue behind a long one on a worker while other workers idle. For long or uneven tasks, set it to `1` — Celery's own documented advice.
241
+ - **Result backend is a write per task.** Unless something reads it, set `task_ignore_result=True`. A million-task fan-out otherwise writes a million Redis keys that expire at `result_expires`, default 86400 s. And `AsyncResult.get()` inside a task is a deadlock: Celery raises `RuntimeError: Never call result.get() within a task!` — chain or chord instead.
242
+ - **Pool choice**: prefork (default) for CPU-ish tasks, `--pool=gevent`/`eventlet` for I/O-bound fan-out, `--pool=solo` only to debug. Prefork workers each hold their own database pool; count them in the arithmetic from section 6.
243
+
244
+ For a service already async end to end, `arq` or `dramatiq` are smaller and speak asyncio natively. Celery's argument is operational maturity, not ergonomics.
245
+
246
+ ## 9. Packaging and the Environment
247
+
248
+ **A virtualenv, always. `pip install` outside one is the failure mode the harness escalates on** — it mutates the machine's interpreter, and on macOS and Debian PEP 668 blocks it outright with `error: externally-managed-environment`. Where it is *not* blocked is worse: you have quietly changed the OS's Python.
249
+
250
+ ```bash
251
+ uv venv && uv pip sync requirements.txt # exact: also REMOVES anything not in the file
252
+ # or
253
+ python3 -m venv .venv && .venv/bin/python -m pip install -r requirements.txt
254
+ ```
255
+
256
+ - **A lockfile, resolved and committed** — `uv.lock`, `poetry.lock`, or pip-tools output with `--generate-hashes`. An unpinned `fastapi` in `requirements.txt` means a different build every day; the symptom is "it worked yesterday" and a major-version import error nobody's diff caused. CI runs the frozen form (`uv sync --frozen`) so a stale lock fails the build instead of resolving around it.
257
+ - **Pin the interpreter too**: `requires-python` in `pyproject.toml`, and a base image of `python:3.12-slim-bookworm`, never `python:latest`.
258
+ - **Never `pip install` something to see if it fixes the bug** and leave it out of the lock. The next container does not have it.
259
+ - **`sys.path.append` and `PYTHONPATH` juggling** mean the project is not installed. `pip install -e .` with a real `[project]` table, and import by package name.
260
+ - Secrets reach the process through the environment and are parsed at boot ([deploy-and-config.md](deploy-and-config.md)); `pydantic-settings` failing at import is the correct behaviour, not an inconvenience.
261
+
262
+ ## Never Ship
263
+
264
+ | Never | Instead |
265
+ | --- | --- |
266
+ | A blocking call inside `async def` | `def` handler, or `await anyio.to_thread.run_sync(...)` |
267
+ | `async` added to a sync function "for speed" | Leave it `def`; the threadpool is the mechanism |
268
+ | Raising the anyio thread limiter to fix a stall | Find the blocked loop; the pool is downstream |
269
+ | Returning an ORM instance from a handler | An explicit `XPublic` model with a hand-written field list |
270
+ | A pydantic model without `extra="forbid"` | `model_config = ConfigDict(extra="forbid")` |
271
+ | A response model mirroring the table's columns | Name the fields; new columns must not auto-publish |
272
+ | Default `expire_on_commit=True` on an async session | `expire_on_commit=False` + build the response before commit |
273
+ | Relationship left at `lazy="select"` on a list endpoint | `selectinload` / `joinedload` per the table in §4 |
274
+ | `joinedload` used to filter a collection | `.join(...)` + `contains_eager(...)` |
275
+ | One session shared across `asyncio.gather` | A session per task, counted against the pool |
276
+ | An autogenerated Alembic migration run unread | Read it, then `alembic upgrade <rev> --sql` |
277
+ | `target_metadata` with no `include_object` filter | Filter, or autogenerate proposes dropping unmodelled tables |
278
+ | Async workers sized with `2 * cores + 1` | ≤1 per core; that formula is for sync workers only |
279
+ | Threads for pure-Python CPU work | `ProcessPoolExecutor`, or a queue |
280
+ | Celery without `acks_late` | `acks_late=True` + `task_reject_on_worker_lost=True` + idempotent task |
281
+ | `visibility_timeout` left at Redis's 3600s under longer ETAs | A broker with real acks, or a timeout above the longest ETA |
282
+ | `task_time_limit` / `soft_time_limit` unset | Both set, soft strictly below hard |
283
+ | `result.get()` inside a task | A chain or chord |
284
+ | `pip install` outside a virtualenv | `uv venv` / `python3 -m venv .venv` first |
285
+ | Unpinned dependencies, or an unlocked ad-hoc install | A committed lockfile, installed frozen in CI |
286
+
287
+ ## No Guessing
288
+
289
+ - **Never claim a handler is non-blocking because it is declared `async def`.** Enable `loop.set_debug(True)` with `slow_callback_duration = 0.1` and read what it logs, or measure throughput at two concurrency levels and watch CPU.
290
+ - **Never state a query count from reading the mapping.** Count them — [data-modelling.md](data-modelling.md) owns the budget and the two-page-size assertion.
291
+ - **Never trust an autogenerated migration's SQL.** Print it.
292
+ - **Never invent a Celery, uvicorn, gunicorn, or anyio default.** They are in this file or in the project's config; a plausible number here reads identical to a measured one.
293
+
294
+ ## Output
295
+
296
+ Write the code. Then, briefly: for every path operation you touched, whether it is `async def` or `def` and the one blocking call that decided it; the session scope and the loader options on any relationship the response reads; what the Alembic diff got wrong when you read it; and the verification — the request you actually made, the status code, and the query count or loop-lag reading if the change was about either. If the honest answer was "leave it on Django", "this doesn't need to be async", or "no migration", say it in one line and write nothing further.