@hublo/sentinel 1.5.0-alpha.8 → 1.5.0

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 (41) hide show
  1. package/README.md +9 -7
  2. package/dist/bin/sentinel.js +61 -16
  3. package/dist/{chunk-KLEXJFJ6.js → chunk-43HQ6BVJ.js} +61 -15
  4. package/dist/{chunk-YQRDKWSH.js → chunk-IL5PPLXE.js} +11 -1
  5. package/dist/chunk-IL5PPLXE.js.map +1 -0
  6. package/dist/chunk-MOELAQPC.js +10365 -0
  7. package/dist/chunk-WBDBRGLV.js +214 -0
  8. package/dist/chunk-WBDBRGLV.js.map +1 -0
  9. package/dist/{chunk-AMCGMLPS.js → chunk-Y57KA4QS.js} +2 -2
  10. package/dist/index.d.ts +28 -1
  11. package/dist/index.js +4 -4
  12. package/dist/roles/build/nest/toolchain.js +68 -18
  13. package/dist/roles/build/nest/toolchain.js.map +1 -1
  14. package/dist/roles/static-analysis/config.d.ts +15 -1
  15. package/dist/roles/static-analysis/config.js +1 -1
  16. package/dist/roles/test/setup/jest-parity.js +2 -2
  17. package/dist/roles/test/setup/workspace-entry.js +2 -2
  18. package/dist/{validate-AWWMBSH5.js → validate-FRMENKHM.js} +2 -2
  19. package/docs/bundle-analysis-spec.md +272 -0
  20. package/docs/sql-verification-study.md +401 -0
  21. package/docs/static-analysis-adoption.md +285 -0
  22. package/docs/static-analysis-study.md +824 -18
  23. package/docs/typescript-adoption.md +1 -1
  24. package/docs/typescript-traces.md +27 -12
  25. package/docs/using-sentinel.md +22 -12
  26. package/docs/validating-a-change.md +185 -0
  27. package/lint/nest.json +98 -18
  28. package/lint/node.json +83 -3
  29. package/lint/react-lib.json +121 -31
  30. package/lint/react.json +101 -2
  31. package/package.json +27 -6
  32. package/plugins/hublo/no-palette-common.js +76 -0
  33. package/plugins/hublo/no-regex-lookbehind.js +70 -0
  34. package/plugins/hublo/require-reload-document.js +70 -0
  35. package/plugins/hublo.js +20 -145
  36. package/plugins/sonar-cognitive.js +108 -0
  37. package/dist/chunk-DKLQYGPI.js +0 -6466
  38. package/dist/chunk-EDFIVLUA.js +0 -100
  39. package/dist/chunk-EDFIVLUA.js.map +0 -1
  40. package/dist/chunk-YQRDKWSH.js.map +0 -1
  41. /package/dist/{chunk-AMCGMLPS.js.map → chunk-Y57KA4QS.js.map} +0 -0
@@ -0,0 +1,401 @@
1
+ # Verifying our SQL: the study behind the role
2
+
3
+ Research record for the SQL guardrails work (HUB-45374), written 2026-10-07. It is **not a
4
+ specification of an implementation**, it is the record that decides what to implement: what was
5
+ measured, what was decided, and the reason attached to each decision, so that the next person can
6
+ disagree with a reason rather than with a preference.
7
+
8
+ Every figure was measured on the monorepo on `origin/main` on 2026-10-07, with Prisma 6.19.3 and
9
+ `libpg-query` 16.7.3. **Every figure can be re-run, once the harness is rebuilt**: it is kept as an
10
+ instrument in pull request #89, which is deliberately not merged. A closed pull request's diff stays
11
+ readable, and its `experiments/sql-chain/README.md` lists the prerequisites and the commands, with
12
+ two gaps found since and recorded in section 4: the generated client is built locally rather than
13
+ committed, and one prerequisite is missing from the list.
14
+
15
+ ---
16
+
17
+ ## 0. The question
18
+
19
+ Catch a dangerous SQL query before it reaches production, the way lint catches a code defect. At
20
+ the moment the code is written, not after a deploy, and therefore without a database holding data.
21
+
22
+ ---
23
+
24
+ ## 1. Two populations that do not add up
25
+
26
+ The repository's SQL lives in two worlds that never overlap, counted in different units. A
27
+ percentage computed in one does not compare to a percentage computed in the other.
28
+
29
+ | world | what is counted | unit | total |
30
+ | --------------------- | ------------------------------------------------ | ------ | ----: |
31
+ | SQL inside TypeScript | places where code queries the database | a call | 7 181 |
32
+ | `.sql` files | files on disk: migrations, and the few read live | a file | 1 045 |
33
+
34
+ Verified: **no ORM call sits in a `.sql` file.** A third place belongs to neither: **125 objects**
35
+ installed in the database by migrations, which run without any code naming them.
36
+
37
+ ---
38
+
39
+ ## 2. Four regimes of visibility
40
+
41
+ Where SQL is stored says little. What matters for a check is whether its text can be read without
42
+ running the code.
43
+
44
+ | regime | volume | what a static tool sees |
45
+ | -------------------------------- | --------------: | ----------------------- |
46
+ | raw SQL with a constant skeleton | 221 files | the whole text |
47
+ | raw SQL composed at run time | 48 files | fragments only |
48
+ | SQL generated by the ORM | **6 909 calls** | **nothing** |
49
+ | SQL living in the database | **125 objects** | only old migrations |
50
+
51
+ Extracting a tagged template is exact, not approximate: taking the literal parts and numbering the
52
+ holes `$1`, `$2` reproduces what Prisma would send, because the interpolations become bound
53
+ parameters. `Prisma.raw` is the exception, since it injects text rather than a parameter.
54
+
55
+ **The files are not where one expects.** 1 045 `.sql` files, of which **1 036 are migrations** and
56
+ **9 are read at run time** (three of those under `docs/`). What the migrations installed still runs
57
+ every day: **63 triggers and 62 functions** by distinct name, plus 418 indexes and 320 unique
58
+ indexes. **No views**: an earlier count said six, and the pattern was matching a table named
59
+ `user_grid_view` and its indexes.
60
+
61
+ **The hard case.** `network`'s query builder: **1 986 lines of TypeScript, 26 filter strategies**,
62
+ each deciding independently whether it applies. The set of applied strategies is an arbitrary
63
+ subset, so the skeleton space is a lattice, not a list. This disqualifies "freeze the generated SQL
64
+ into versioned `.sql` files": a lattice cannot be enumerated, only sampled.
65
+
66
+ ---
67
+
68
+ ## 3. Stock, flow, and per pull request
69
+
70
+ A write-time check never reads the whole repository. It reads what changed. Three questions, three
71
+ answers, and only the third says whether a tool would earn its place.
72
+
73
+ | | ORM | raw |
74
+ | ----------------------------------------- | -----: | ---------: |
75
+ | the stock, what the code contains | 96,2 % | 3,8 % |
76
+ | the flow, lines added over 90 days | 91,5 % | 8,5 % |
77
+ | per PR, among those touching the database | 75,6 % | **24,4 %** |
78
+
79
+ Measured over **2 886 real pull requests** (first-parent commits, 90 days): 2 481 add no query at
80
+ all, 306 add ORM calls only, 25 add raw SQL only, 74 add both. **405 touch the database (14,0 %),
81
+ and 99 of those (24,4 %) add raw SQL.**
82
+
83
+ > ⚠️ Method correction worth recording: an earlier pass counted merge commits. Only **79 of the
84
+ > last 400** first-parent commits are merges, the rest are squashes, and the merges are mostly
85
+ > back-merges with huge diffs. Counting them measured the wrong population.
86
+
87
+ ---
88
+
89
+ ## 4. The chain, measured end to end
90
+
91
+ A first benchmark measured only compilation, on eleven queries **transcribed by hand**. That is not
92
+ a benchmark of a tool, it is a benchmark of a path a human had already cleared. The two missing
93
+ links were written and everything re-run from files on disk.
94
+
95
+ | link | time | note |
96
+ | --------------------- | -------: | ------------------------------------- |
97
+ | 1, read and translate | 14,80 ms | paid once per **file**, not per query |
98
+ | 2, compile | 3,77 ms | 25 statements produced |
99
+ | 3, parse and check | 1,12 ms | 0 parse failure |
100
+
101
+ **The link that had to be written costs 3,9 times the one that already existed.** On the ten
102
+ queries that pass, the SQL is **identical byte for byte** to the hand transcription. The eleventh
103
+ is refused: a ternary in its `where`, and a placeholder cannot stand in for an unknown predicate.
104
+
105
+ ### Real coverage
106
+
107
+ Over the whole `network` microservice, production code only:
108
+
109
+ | | sites | translated | compile |
110
+ | -------------------------------------- | ------: | ---------------: | --------------: |
111
+ | reads (`find*`, `count`, `aggregate`) | 242 | 215 (88,8 %) | **215 (100 %)** |
112
+ | writes (`create`, `update`, `delete*`) | 231 | 161 (69,7 %) | 160 |
113
+ | **total** | **473** | **376 (79,5 %)** | **375** |
114
+
115
+ **79,5 %, against the 90,5 % a regex estimate predicted.** And a fact no estimate would have
116
+ produced: **no production Prisma call is fully literal. Zero out of 473.** Everything the chain
117
+ handles is an approximation. Refusals fall 65 % on write positions.
118
+
119
+ ### Cost per pull request
120
+
121
+ Ten real merged pull requests replayed, files read at the merge commit, external timing:
122
+
123
+ | | whole touched file | diff only |
124
+ | ---------------- | -----------------: | ---------: |
125
+ | queries examined | 2 778 | 114 |
126
+ | **median time** | **0,73 s** | **0,71 s** |
127
+ | worst case | 1,20 s | 0,75 s |
128
+ | compile failures | **32,8 %** | 6,9 % |
129
+
130
+ **The trade-off is not paid in seconds, it is paid in noise.** The wide mode costs the same time
131
+ for 24 times more queries, and returns five times more failures because touched files carry specs
132
+ and test factories. Both modes have their case: a PR making a column nullable can break a query the
133
+ diff never touched; a 361-file PR that changes no query line makes the wide mode check 1 153
134
+ queries for nothing. **Not decided here.**
135
+
136
+ **A fixed cost is paid once per process, before a single query is examined, and it is module
137
+ loading rather than the interpreter.** Bare Node starts in **33 ms** here, median of 9 runs, while
138
+ a single `typescript` import costs **122 ms**, and a reading of this document measured 127 ms
139
+ independently. The SQL parser and the generated client load on top of that. If the compiling client
140
+ must be generated, add 2,5 s.
141
+
142
+ > The 122 ms is the `typescript` the monorepo root actually **resolves**, and that is not the
143
+ > version its `devDependencies` names: `dependencies` aliases the name onto
144
+ > `@typescript/typescript6` 6.0.2, which is what the symlink points at, while `devDependencies`
145
+ > still asks for 5.9.3. Here it changes nothing, and that was worth measuring rather than
146
+ > assuming: 5.9.3 loads in 129 ms against 122, ranges overlapping, so the two are indistinguishable
147
+ > at this precision. The declaration is a trap for a reader re-measuring, not a correction to make.
148
+
149
+ > ⚠️ **An earlier revision of this section printed "390 ms of the total is Node starting up, 71 %
150
+ > of the clock", and both halves were wrong.** The attribution is off by a factor of twelve, as
151
+ > above. And neither figure can come from the run that produced the medians in the table: that
152
+ > script reports no startup cost at all and starts its own clock after its imports, so the
153
+ > overhead is outside every number it prints. 390 ms over the 0,73 s median would be 53 %, not
154
+ > 71 %; the digits 71 match the diff-only median in the table rather than any ratio. The total
155
+ > overhead is therefore withdrawn rather than corrected. Re-measuring it means **rebuilding** the
156
+ > harness and not merely re-running it: the scripts are in #89, but the generated client and the
157
+ > compiler the translator loads are built locally and committed nowhere.
158
+
159
+ > ⚠️ And one prerequisite of #89 is missing from its own README, found while tracing the figure
160
+ > above: `translate.mjs` resolves `./ts-lib/lib/typescript.js`, a compiler copy sitting beside the
161
+ > scripts, which the four listed prerequisites never mention. A reader following the README stops
162
+ > on a missing module, and the version that produced link 1 is unrecorded. What that costs is
163
+ > bounded and worth naming: the translation timing and the 79,5 % coverage both pass through that
164
+ > compiler, so both are reproducible in method and not to the digit.
165
+
166
+ ### Build cost
167
+
168
+ | component | lines of code |
169
+ | ---------------------- | ------------: |
170
+ | the translator, link 1 | **463** |
171
+ | the schema parser | 73 |
172
+ | the SQL parse and rule | **43** |
173
+
174
+ **The rule costs 43 lines, the translator eleven times more.** Six defects were found in it **by
175
+ measurement, not by reading**. On the eleven starting queries it looked perfect; the 473 call sites
176
+ of `network` revealed the rest.
177
+
178
+ ---
179
+
180
+ ## 5. The result that flips the priority
181
+
182
+ | | ORM SQL | raw SQL |
183
+ | -------------------------- | ------------------------------ | ----------------------------------- |
184
+ | volume | 6 909 calls | 272 calls in 129 files |
185
+ | extracting the query | **463 lines**, 79,5 % coverage | the text is **already in the file** |
186
+ | the 19 May pattern appears | **never observed**, 0 of 2 319 | **yes**, that is where it happened |
187
+ | presence per PR | 75,6 % of database PRs | **24,4 %** |
188
+
189
+ **The difficult road was built toward the half where the incident cannot be seen.** Raw SQL is both
190
+ the easiest to extract and the only place the pattern currently lives.
191
+
192
+ > ⚠️ **What that zero does not say.** It is an observation on our query shapes, not a property of
193
+ > Prisma. Checked in the compiler binary: `UNION` and `UNION ALL` are in its string table, next to
194
+ > `EXISTS` and `NOT EXISTS`. **A code path exists.** Which query shape triggers it was not
195
+ > investigated. A rule there would be silent today, not useless tomorrow.
196
+
197
+ ---
198
+
199
+ ## 6. Why raw SQL is the sensitive place
200
+
201
+ Three supports hold, of three different kinds. A fourth was tested and does not hold.
202
+
203
+ | support | kind |
204
+ | ------------------------------------------------------------------ | -------------------- |
205
+ | the documented incident came from it, both file versions recovered | verified |
206
+ | it loses the schema check the ORM gives for free | structural |
207
+ | composed, it has no text before execution: 48 files | measured |
208
+ | but it does not attract more fixes: 35,8 % against 33,0 % | **tested, negative** |
209
+
210
+ > ⚠️ Those last two figures count a **wider** population than the rest of this record: they take
211
+ > as hand-written SQL the `$queryRaw` and `$executeRaw` calls, their `Unsafe` variants, **and also
212
+ > `Prisma.sql` and `Prisma.raw`**, which are fragments. That is why the share moves from 24,4 % to
213
+ > about a third, and the two numbers must not be read against the ones in section 3. The
214
+ > fix-versus-feature comparison itself uses the same definition on both sides, so it stands.
215
+
216
+ So the defensible claim is not that hand-written SQL breaks more often. It is that it is **the only
217
+ place this class of defect can live, and the only place review has nothing to look at.**
218
+
219
+ ---
220
+
221
+ ## 7. A second family, found on the way
222
+
223
+ The 19 May incident was about performance. Security is a different family and it lives elsewhere.
224
+ Of **25 calls to the `Unsafe` variants**, 13 insert a value into the SQL text. **Twelve are test
225
+ utilities. One is production code**, in the authentication library, where permission names are
226
+ placed between quotes in the query text with no escaping. The path from the HTTP body down to the
227
+ query carries **no runtime validation**: the DTO accepts any string, the use case checks nothing,
228
+ and the entity's type is erased at run time.
229
+
230
+ **Triage: real defect, not an emergency.** The endpoint is behind `SuperAdminAuthGuard`. That
231
+ removes the urgency, not the defect: an injection reaches the whole database on that connection and
232
+ turns an account compromise into a database compromise. The fix is small: validate against the
233
+ permission enumeration, and bind parameters instead of inserting text.
234
+
235
+ > ✅ **Fixed, and the speed is part of the finding.** `4b6c272`, HUB-45606, pull request #22178,
236
+ > five days after the reading that found it: the `$executeRawUnsafe` became a tagged `$executeRaw`
237
+ > over `jsonb_to_recordset`, the payload bound as a single parameter. The case is kept here with
238
+ > its resolution rather than deleted, because it is the evidence for the paragraph below: a defect
239
+ > of this family was found by reading 25 call sites, and closed in a week. Raised in review by
240
+ > Amrou Ouni, whose remark also exposed that the local checkout this record was last re-read
241
+ > against was **15 days behind** `main`.</br>
242
+ > ⚠️ A record that quotes the state of the code has a shelf life. Every figure here is dated
243
+ > 2026-10-07, and none of them is re-verified by this note.
244
+
245
+ This family is **far cheaper to detect** than performance: a value inserted into an `Unsafe` call is
246
+ visible in the TypeScript, with no compilation, no SQL parsing and no database. The perimeter is
247
+ finite: 25 sites.
248
+
249
+ ---
250
+
251
+ ## 8. What is still out of reach
252
+
253
+ | limit | measured or not |
254
+ | ------------------------------------------------------------------------------------------------ | ---------------- |
255
+ | no database, so no `EXPLAIN`: the chain checks shape, never cost | by construction |
256
+ | one schema of the **33** that carry a generator: 54,3 % of candidate PRs have no compilable site | measured |
257
+ | the cost of generating the 31 clients the chain does not build: no duration is claimed, see 11 | not measured |
258
+ | raw SQL is invisible to the chain: 272 calls in 129 files | measured |
259
+ | false positives of the detector need a real TypeScript type checker | partially |
260
+ | equivalence with production SQL proven on 10 queries, not on 375 | measured on 10 |
261
+ | the rule's false negatives: nobody measured what it lets through | **not measured** |
262
+ | everything ran single-process on a laptop: nothing in CI, nothing parallel | not measured |
263
+ | the fixed per-process overhead: the harness must be rebuilt before it can be timed | **withdrawn** |
264
+
265
+ ---
266
+
267
+ ## 9. The nine tools examined
268
+
269
+ Three questions per tool, not to be conflated: **what it checks**, **the input it takes**, and
270
+ **what it requires to run**. A tool can check the right thing and remain unusable because it cannot
271
+ be fed.
272
+
273
+ | tool | checks | input | database | state |
274
+ | ----------------- | ---------------------------------------------------------- | -------------------- | ----------------------------------------------- | ----------------------------------------------- |
275
+ | **squawk** | 54 rules, **100 % migrations**. No query rule | `.sql` files | none | npm 2.67.0, 2026-10-04, Rust, Apache-2.0 |
276
+ | **libpg-query** | nothing. It turns SQL into a tree: PostgreSQL's own parser | SQL | none | active, **WASM only**, one version per PG major |
277
+ | **plpgsql_check** | **the body of PL/pgSQL functions**, triggers included | functions in the DB | **inside the server** | 782 stars, C, MIT, 2026-10-05 |
278
+ | **SafeQL** | a query against the schema: columns, types | **source code** | **yes, always**, shadow DB even with migrations | active, 2026-10-01, MIT |
279
+ | **sqlcheck** | 26 generic query anti-patterns | SQL | none | **dormant** since 2024-02, C++ |
280
+ | **SQLFluff** | 73 rules of **style and structure** | SQL text | none | active, Python, MIT |
281
+ | **pglinter** | 21 global health rules with thresholds | system catalogs | **inside the server** | 170 stars, Rust, 2026-07 |
282
+ | **pgTAP** | **assertions**. It analyses no body | hand-written asserts | **inside the server** | 1 167 stars, active |
283
+ | **Bytebase** | a rule engine with real textual anti-patterns | its own workflow | varies, `EXPLAIN` for some | 14 537 stars, Go, MIT except enterprise |
284
+
285
+ ### Proof by execution
286
+
287
+ Two queries written with a defect no index fixes, passed to the only two tools that run without a
288
+ database:
289
+
290
+ | tool | verdict on those two queries |
291
+ | -------------- | --------------------------------------------------------------- |
292
+ | squawk 2.67.0 | **0 finding.** It parses them and no rule applies |
293
+ | SQLFluff 4.1.0 | 10 findings, **all style or structure**. Silent on both defects |
294
+
295
+ **So the answer to the study's central question is no, and it is now demonstrated rather than
296
+ assumed: no tool judges a query's cost outside a database.**
297
+
298
+ ### What sqlcheck's catalogue teaches, even though we drop the tool
299
+
300
+ Four of our six post-mortem patterns have an equivalent there; **pagination by `OFFSET` and
301
+ repeated computation have none**. In the other direction, five of its rules are worth taking:
302
+ _String Concatenation_ (exactly the security family above), _SELECT \*_, _ORDER BY RAND_,
303
+ _Eliminate Unnecessary DISTINCT_, _Implicit Column Usage_.
304
+
305
+ ### The third regime
306
+
307
+ `plpgsql_check` really analyses PL/pgSQL function bodies, triggers included. **77 of our 78 function
308
+ declarations are `LANGUAGE plpgsql`**, so it would cover essentially all of them. But it is a server
309
+ extension: it does not run on a pull request, it runs against a database where migrations are
310
+ already applied. And there is **no view to cover**, the earlier count was a false positive.
311
+
312
+ > Only squawk, SQLFluff and libpg-query were actually executed. pglinter, plpgsql_check and pgTAP
313
+ > require a server with their extension, which was not set up.
314
+
315
+ ---
316
+
317
+ ## 10. What we take, what we write, what we leave
318
+
319
+ **No overlap.** Each tool covers an area the others do not see, and two areas are covered by
320
+ nobody. Those are what sentinel writes.
321
+
322
+ | what | category | do we take it | where it runs |
323
+ | ------------------------------------------- | ----------- | ------------------- | ----------------------------------------- |
324
+ | **squawk** | third-party | **yes, as is** | in sentinel, on the diff's migrations |
325
+ | **plpgsql_check** | third-party | **yes, as is** | **outside sentinel**, against a database |
326
+ | **libpg-query** | library | yes, as the engine | inside our rules |
327
+ | the rule on `Unsafe` calls | our rules | **to write** | in sentinel, on the diff's TypeScript |
328
+ | raw SQL extraction and its rules | our rules | **to write** | in sentinel, on the diff's TypeScript |
329
+ | the full ORM chain | our rules | **to write, later** | in sentinel, on the diff's TypeScript |
330
+ | SafeQL, sqlcheck, SQLFluff, pglinter, pgTAP | third-party | **no** | sqlcheck's catalogue still inspires rules |
331
+
332
+ Two near-misses, which are complements rather than duplicates. **Injection appears twice, in two
333
+ places**: `plpgsql_check` sees it inside a stored function, our TypeScript rule sees it in
334
+ application code, and neither sees the other. And **we could rewrite squawk with libpg-query**,
335
+ which is the only real duplication possible and precisely what not to do.
336
+
337
+ **plpgsql_check does not go into sentinel.** It is an extension living in the server, analysing
338
+ functions already installed. Adopting it means deciding against which database and how often it
339
+ runs, which is an infrastructure question. It belongs with `pg_stat_statements`.
340
+
341
+ ### The order, cheapest first
342
+
343
+ 1. **squawk on migrations.** The only gain that requires writing nothing.
344
+ 2. **The rule on `Unsafe` calls.** No compilation, no SQL parsing, no database. 25 sites.
345
+ 3. **Raw SQL with libpg-query.** Where the 19 May pattern lives, and the text is already in the file.
346
+ 4. **The ORM chain.** 463 lines for 79,5 % coverage, and silent today. Last.
347
+
348
+ Outside sentinel, in parallel: **plpgsql_check**, to scope with infrastructure.
349
+
350
+ ---
351
+
352
+ ## 11. How it fits into sentinel
353
+
354
+ The role inherits everything the existing roles settled: a target declared in `package.json`,
355
+ adoption through `--init` with declared settings surviving a re-init, a pure adapter with the engine
356
+ owning the IO, banned patterns in the `nest` preset, permissive while adoption runs, and a column on
357
+ the migration status page.
358
+
359
+ **One thing is new.** Every current role only reads files from the repository. The ORM part needs a
360
+ **built artifact**: a Prisma client generated in compiler mode, one per schema.
361
+
362
+ ### In CI
363
+
364
+ 1. the changed files of the pull request
365
+ 2. does any of them contain a query: a text scan, and **86 % of pull requests stop here**
366
+ 3. which schema, resolved from the module's import
367
+ 4. that schema's compiling client, from cache
368
+ 5. compile the changed queries
369
+ 6. check the SQL, and attach each finding to the source line that produced it
370
+
371
+ The intermediate files are those compiling clients, **one per schema carrying a `generator`, of
372
+ which this repository has 33 of 52 `.prisma` files**: 20 under `libs/nest/db-*`, 12 in
373
+ microservices, and one Backstage template that carries a `generator` but no `model` and an
374
+ unresolved `${{ values.dbName }}` output, so it is not a client at all. ⚠️ It matched the count
375
+ because its own path contains `apps/nest/microservices/`. That leaves **32 real clients**, of which
376
+ the chain already builds one, so 31 would have to be generated. An earlier draft said 22 and no
377
+ count supports it.
378
+
379
+ ⛔ The total those 31 would cost is **not** stated here, and the earlier "about 80 seconds" is
380
+ withdrawn rather than adjusted to 78. It rested on a flat 2,5 s per client, which is the one figure
381
+ in section 4 carrying no basis while its neighbours carry theirs, and which the section 7 table
382
+ already marks `not measured`. A flat average cannot carry it either way: these schemas run from
383
+ **218 models and 4 828 lines** (`main-db`) down to **1 model and 23 lines**, a factor of 210.
384
+ Caching the generation is argued below on the clients' number and staleness, not on a duration. They are not committed:
385
+ generated code carrying megabytes of WebAssembly, stale as soon as the schema moves. They are
386
+ cached, with the `.prisma` file as the declared input.
387
+
388
+ > ⚠️ The existing scripts already generate the **runtime** clients into
389
+ > `node_modules/@prisma/<name>-database`, and those run in production with the Rust engine. The
390
+ > compiling client must not overwrite them; it needs its own output.
391
+
392
+ ---
393
+
394
+ ## Sources
395
+
396
+ - The harness and every script: pull request #89, `experiments/sql-chain/`
397
+ - `pganalyze/libpg_query`: <https://github.com/pganalyze/libpg_query>
398
+ - squawk: <https://squawkhq.com/> · plpgsql_check: <https://github.com/okbob/plpgsql_check>
399
+ - SafeQL: <https://safeql.dev/> · SQLFluff: <https://www.sqlfluff.com/>
400
+ - Prisma without the Rust engine:
401
+ <https://www.prisma.io/docs/orm/v6/prisma-client/setup-and-configuration/no-rust-engine>