@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.
- package/README.md +9 -7
- package/dist/bin/sentinel.js +61 -16
- package/dist/{chunk-KLEXJFJ6.js → chunk-43HQ6BVJ.js} +61 -15
- package/dist/{chunk-YQRDKWSH.js → chunk-IL5PPLXE.js} +11 -1
- package/dist/chunk-IL5PPLXE.js.map +1 -0
- package/dist/chunk-MOELAQPC.js +10365 -0
- package/dist/chunk-WBDBRGLV.js +214 -0
- package/dist/chunk-WBDBRGLV.js.map +1 -0
- package/dist/{chunk-AMCGMLPS.js → chunk-Y57KA4QS.js} +2 -2
- package/dist/index.d.ts +28 -1
- package/dist/index.js +4 -4
- package/dist/roles/build/nest/toolchain.js +68 -18
- package/dist/roles/build/nest/toolchain.js.map +1 -1
- package/dist/roles/static-analysis/config.d.ts +15 -1
- package/dist/roles/static-analysis/config.js +1 -1
- package/dist/roles/test/setup/jest-parity.js +2 -2
- package/dist/roles/test/setup/workspace-entry.js +2 -2
- package/dist/{validate-AWWMBSH5.js → validate-FRMENKHM.js} +2 -2
- package/docs/bundle-analysis-spec.md +272 -0
- package/docs/sql-verification-study.md +401 -0
- package/docs/static-analysis-adoption.md +285 -0
- package/docs/static-analysis-study.md +824 -18
- package/docs/typescript-adoption.md +1 -1
- package/docs/typescript-traces.md +27 -12
- package/docs/using-sentinel.md +22 -12
- package/docs/validating-a-change.md +185 -0
- package/lint/nest.json +98 -18
- package/lint/node.json +83 -3
- package/lint/react-lib.json +121 -31
- package/lint/react.json +101 -2
- package/package.json +27 -6
- package/plugins/hublo/no-palette-common.js +76 -0
- package/plugins/hublo/no-regex-lookbehind.js +70 -0
- package/plugins/hublo/require-reload-document.js +70 -0
- package/plugins/hublo.js +20 -145
- package/plugins/sonar-cognitive.js +108 -0
- package/dist/chunk-DKLQYGPI.js +0 -6466
- package/dist/chunk-EDFIVLUA.js +0 -100
- package/dist/chunk-EDFIVLUA.js.map +0 -1
- package/dist/chunk-YQRDKWSH.js.map +0 -1
- /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>
|