turbine-orm 0.81.0 → 0.90.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 (295) hide show
  1. package/README.md +26 -25
  2. package/dist/checkout.d.ts +27 -2
  3. package/dist/checkout.js +75 -4
  4. package/dist/cjs/checkout.d.ts +27 -2
  5. package/dist/cjs/checkout.js +76 -3
  6. package/dist/cjs/cli/code-first-tags.d.ts +62 -0
  7. package/dist/cjs/cli/code-first-tags.js +124 -0
  8. package/dist/cjs/cli/compile-query.js +9 -0
  9. package/dist/cjs/cli/config.d.ts +35 -6
  10. package/dist/cjs/cli/config.js +17 -1
  11. package/dist/cjs/cli/destructive.d.ts +7 -4
  12. package/dist/cjs/cli/destructive.js +224 -20
  13. package/dist/cjs/cli/error-catalog.js +36 -0
  14. package/dist/cjs/cli/error-text.d.ts +15 -0
  15. package/dist/cjs/cli/error-text.js +40 -0
  16. package/dist/cjs/cli/index.d.ts +62 -4
  17. package/dist/cjs/cli/index.js +568 -108
  18. package/dist/cjs/cli/local-server-guard.d.ts +53 -0
  19. package/dist/cjs/cli/local-server-guard.js +119 -0
  20. package/dist/cjs/cli/local-server-output.d.ts +41 -0
  21. package/dist/cjs/cli/local-server-output.js +103 -0
  22. package/dist/cjs/cli/mcp.d.ts +8 -0
  23. package/dist/cjs/cli/mcp.js +121 -32
  24. package/dist/cjs/cli/migrate.d.ts +113 -5
  25. package/dist/cjs/cli/migrate.js +270 -21
  26. package/dist/cjs/cli/observe.d.ts +6 -1
  27. package/dist/cjs/cli/observe.js +85 -55
  28. package/dist/cjs/cli/pii-predicate-guard.d.ts +43 -23
  29. package/dist/cjs/cli/pii-predicate-guard.js +98 -37
  30. package/dist/cjs/cli/prisma-resolve.js +17 -13
  31. package/dist/cjs/cli/prisma-schema.js +12 -4
  32. package/dist/cjs/cli/studio-ui.generated.js +1 -1
  33. package/dist/cjs/cli/studio.d.ts +14 -2
  34. package/dist/cjs/cli/studio.js +244 -69
  35. package/dist/cjs/cli/ui.d.ts +1 -1
  36. package/dist/cjs/client.d.ts +558 -85
  37. package/dist/cjs/client.js +1270 -395
  38. package/dist/cjs/connection-guard.d.ts +13 -0
  39. package/dist/cjs/connection-guard.js +28 -0
  40. package/dist/cjs/connection-url.d.ts +51 -16
  41. package/dist/cjs/connection-url.js +67 -34
  42. package/dist/cjs/diagnostics.d.ts +81 -0
  43. package/dist/cjs/diagnostics.js +147 -0
  44. package/dist/cjs/dialect.d.ts +449 -20
  45. package/dist/cjs/dialect.js +117 -76
  46. package/dist/cjs/engine-config.d.ts +7 -6
  47. package/dist/cjs/errors.d.ts +126 -6
  48. package/dist/cjs/errors.js +256 -13
  49. package/dist/cjs/generate.d.ts +57 -7
  50. package/dist/cjs/generate.js +635 -279
  51. package/dist/cjs/global-filters-config.d.ts +59 -0
  52. package/dist/cjs/global-filters-config.js +367 -0
  53. package/dist/cjs/index.d.ts +9 -4
  54. package/dist/cjs/index.js +10 -2
  55. package/dist/cjs/introspect.d.ts +31 -6
  56. package/dist/cjs/introspect.js +108 -50
  57. package/dist/cjs/log-codes.d.ts +12 -0
  58. package/dist/cjs/log-codes.js +77 -0
  59. package/dist/cjs/logger.d.ts +87 -0
  60. package/dist/cjs/logger.js +98 -0
  61. package/dist/cjs/mssql.d.ts +8 -5
  62. package/dist/cjs/mssql.js +352 -65
  63. package/dist/cjs/mysql.d.ts +3 -5
  64. package/dist/cjs/mysql.js +420 -70
  65. package/dist/cjs/nested-write.d.ts +3 -0
  66. package/dist/cjs/nested-write.js +179 -114
  67. package/dist/cjs/pipeline-submittable.d.ts +12 -0
  68. package/dist/cjs/pipeline-submittable.js +118 -44
  69. package/dist/cjs/pipeline.js +21 -26
  70. package/dist/cjs/powdb-introspect.d.ts +5 -2
  71. package/dist/cjs/powdb-introspect.js +20 -15
  72. package/dist/cjs/powdb-shared.d.ts +36 -5
  73. package/dist/cjs/powdb-shared.js +67 -8
  74. package/dist/cjs/powdb.d.ts +4 -0
  75. package/dist/cjs/powdb.js +183 -51
  76. package/dist/cjs/powql.d.ts +394 -51
  77. package/dist/cjs/powql.js +1829 -284
  78. package/dist/cjs/prisma-compat.d.ts +23 -15
  79. package/dist/cjs/prisma-compat.js +507 -372
  80. package/dist/cjs/query/aggregates.d.ts +17 -1
  81. package/dist/cjs/query/aggregates.js +261 -29
  82. package/dist/cjs/query/batched-loader.d.ts +25 -6
  83. package/dist/cjs/query/batched-loader.js +103 -20
  84. package/dist/cjs/query/builder.d.ts +325 -184
  85. package/dist/cjs/query/builder.js +1452 -490
  86. package/dist/cjs/query/column-ref.d.ts +61 -0
  87. package/dist/cjs/query/column-ref.js +73 -0
  88. package/dist/cjs/query/compound-unique.d.ts +21 -0
  89. package/dist/cjs/query/compound-unique.js +98 -18
  90. package/dist/cjs/query/deferred.d.ts +53 -16
  91. package/dist/cjs/query/expr.d.ts +217 -0
  92. package/dist/cjs/query/expr.js +859 -0
  93. package/dist/cjs/query/filters.d.ts +155 -53
  94. package/dist/cjs/query/filters.js +613 -76
  95. package/dist/cjs/query/index.d.ts +2 -0
  96. package/dist/cjs/query/index.js +7 -1
  97. package/dist/cjs/query/json-value.d.ts +43 -0
  98. package/dist/cjs/query/json-value.js +59 -0
  99. package/dist/cjs/query/normalize-args.d.ts +87 -0
  100. package/dist/cjs/query/normalize-args.js +527 -0
  101. package/dist/cjs/query/option-surface.d.ts +16 -4
  102. package/dist/cjs/query/option-surface.js +57 -14
  103. package/dist/cjs/query/relation-names.d.ts +14 -0
  104. package/dist/cjs/query/relation-names.js +77 -18
  105. package/dist/cjs/query/relations.d.ts +30 -3
  106. package/dist/cjs/query/relations.js +219 -63
  107. package/dist/cjs/query/row-lock.d.ts +49 -0
  108. package/dist/cjs/query/row-lock.js +138 -0
  109. package/dist/cjs/query/sql-comment.d.ts +161 -0
  110. package/dist/cjs/query/sql-comment.js +381 -0
  111. package/dist/cjs/query/stream-idle.d.ts +132 -0
  112. package/dist/cjs/query/stream-idle.js +212 -0
  113. package/dist/cjs/query/table-client.d.ts +101 -0
  114. package/dist/cjs/query/table-client.js +74 -0
  115. package/dist/cjs/query/types.d.ts +677 -298
  116. package/dist/cjs/query/utils.d.ts +105 -6
  117. package/dist/cjs/query/utils.js +426 -19
  118. package/dist/cjs/query/warn-registry.d.ts +18 -0
  119. package/dist/cjs/query/warn-registry.js +18 -0
  120. package/dist/cjs/query/where-compile.d.ts +138 -14
  121. package/dist/cjs/query/where-compile.js +513 -29
  122. package/dist/cjs/query/where.d.ts +159 -59
  123. package/dist/cjs/query/where.js +716 -246
  124. package/dist/cjs/query/writes.d.ts +112 -50
  125. package/dist/cjs/query/writes.js +1498 -200
  126. package/dist/cjs/query-events.d.ts +4 -2
  127. package/dist/cjs/query-events.js +3 -1
  128. package/dist/cjs/realtime.d.ts +7 -3
  129. package/dist/cjs/realtime.js +13 -2
  130. package/dist/cjs/schema-builder.d.ts +55 -4
  131. package/dist/cjs/schema-builder.js +382 -18
  132. package/dist/cjs/schema-metadata.d.ts +21 -1
  133. package/dist/cjs/schema-metadata.js +29 -7
  134. package/dist/cjs/schema-sql.d.ts +75 -5
  135. package/dist/cjs/schema-sql.js +497 -45
  136. package/dist/cjs/schema.d.ts +26 -5
  137. package/dist/cjs/schema.js +110 -13
  138. package/dist/cjs/sql-placeholders.d.ts +56 -0
  139. package/dist/cjs/sql-placeholders.js +204 -0
  140. package/dist/cjs/sqlite.d.ts +75 -20
  141. package/dist/cjs/sqlite.js +593 -95
  142. package/dist/cjs/supplied-pool-types.d.ts +48 -0
  143. package/dist/cjs/supplied-pool-types.js +256 -0
  144. package/dist/cjs/transaction-control.d.ts +51 -0
  145. package/dist/cjs/transaction-control.js +316 -0
  146. package/dist/cjs/transaction-state.d.ts +342 -0
  147. package/dist/cjs/transaction-state.js +707 -0
  148. package/dist/cjs/typed-sql.d.ts +37 -3
  149. package/dist/cjs/typed-sql.js +102 -8
  150. package/dist/cli/code-first-tags.d.ts +62 -0
  151. package/dist/cli/code-first-tags.js +120 -0
  152. package/dist/cli/compile-query.js +9 -0
  153. package/dist/cli/config.d.ts +35 -6
  154. package/dist/cli/config.js +16 -1
  155. package/dist/cli/destructive.d.ts +7 -4
  156. package/dist/cli/destructive.js +224 -20
  157. package/dist/cli/error-catalog.js +36 -0
  158. package/dist/cli/error-text.d.ts +15 -0
  159. package/dist/cli/error-text.js +37 -0
  160. package/dist/cli/index.d.ts +62 -4
  161. package/dist/cli/index.js +569 -112
  162. package/dist/cli/local-server-guard.d.ts +53 -0
  163. package/dist/cli/local-server-guard.js +113 -0
  164. package/dist/cli/local-server-output.d.ts +41 -0
  165. package/dist/cli/local-server-output.js +98 -0
  166. package/dist/cli/mcp.d.ts +8 -0
  167. package/dist/cli/mcp.js +121 -34
  168. package/dist/cli/migrate.d.ts +113 -5
  169. package/dist/cli/migrate.js +261 -21
  170. package/dist/cli/observe.d.ts +6 -1
  171. package/dist/cli/observe.js +84 -54
  172. package/dist/cli/pii-predicate-guard.d.ts +43 -23
  173. package/dist/cli/pii-predicate-guard.js +100 -35
  174. package/dist/cli/prisma-resolve.js +15 -11
  175. package/dist/cli/prisma-schema.js +12 -4
  176. package/dist/cli/studio-ui.generated.js +1 -1
  177. package/dist/cli/studio.d.ts +14 -2
  178. package/dist/cli/studio.js +246 -71
  179. package/dist/cli/ui.d.ts +1 -1
  180. package/dist/client.d.ts +558 -85
  181. package/dist/client.js +1275 -400
  182. package/dist/connection-guard.d.ts +13 -0
  183. package/dist/connection-guard.js +27 -0
  184. package/dist/connection-url.d.ts +51 -16
  185. package/dist/connection-url.js +63 -34
  186. package/dist/diagnostics.d.ts +81 -0
  187. package/dist/diagnostics.js +144 -0
  188. package/dist/dialect.d.ts +449 -20
  189. package/dist/dialect.js +115 -43
  190. package/dist/engine-config.d.ts +7 -6
  191. package/dist/errors.d.ts +126 -6
  192. package/dist/errors.js +252 -12
  193. package/dist/generate.d.ts +57 -7
  194. package/dist/generate.js +635 -281
  195. package/dist/global-filters-config.d.ts +59 -0
  196. package/dist/global-filters-config.js +361 -0
  197. package/dist/index.d.ts +9 -4
  198. package/dist/index.js +4 -2
  199. package/dist/introspect.d.ts +31 -6
  200. package/dist/introspect.js +106 -50
  201. package/dist/log-codes.d.ts +12 -0
  202. package/dist/log-codes.js +74 -0
  203. package/dist/logger.d.ts +87 -0
  204. package/dist/logger.js +93 -0
  205. package/dist/mssql.d.ts +8 -5
  206. package/dist/mssql.js +354 -67
  207. package/dist/mysql.d.ts +3 -5
  208. package/dist/mysql.js +423 -73
  209. package/dist/nested-write.d.ts +3 -0
  210. package/dist/nested-write.js +177 -112
  211. package/dist/pipeline-submittable.d.ts +12 -0
  212. package/dist/pipeline-submittable.js +117 -44
  213. package/dist/pipeline.js +21 -26
  214. package/dist/powdb-introspect.d.ts +5 -2
  215. package/dist/powdb-introspect.js +21 -16
  216. package/dist/powdb-shared.d.ts +36 -5
  217. package/dist/powdb-shared.js +65 -8
  218. package/dist/powdb.d.ts +4 -0
  219. package/dist/powdb.js +183 -51
  220. package/dist/powql.d.ts +394 -51
  221. package/dist/powql.js +1836 -291
  222. package/dist/prisma-compat.d.ts +23 -15
  223. package/dist/prisma-compat.js +508 -373
  224. package/dist/query/aggregates.d.ts +17 -1
  225. package/dist/query/aggregates.js +263 -32
  226. package/dist/query/batched-loader.d.ts +25 -6
  227. package/dist/query/batched-loader.js +101 -21
  228. package/dist/query/builder.d.ts +325 -184
  229. package/dist/query/builder.js +1458 -495
  230. package/dist/query/column-ref.d.ts +61 -0
  231. package/dist/query/column-ref.js +66 -0
  232. package/dist/query/compound-unique.d.ts +21 -0
  233. package/dist/query/compound-unique.js +97 -19
  234. package/dist/query/deferred.d.ts +53 -16
  235. package/dist/query/expr.d.ts +217 -0
  236. package/dist/query/expr.js +848 -0
  237. package/dist/query/filters.d.ts +155 -53
  238. package/dist/query/filters.js +597 -72
  239. package/dist/query/index.d.ts +2 -0
  240. package/dist/query/index.js +4 -0
  241. package/dist/query/json-value.d.ts +43 -0
  242. package/dist/query/json-value.js +54 -0
  243. package/dist/query/normalize-args.d.ts +87 -0
  244. package/dist/query/normalize-args.js +517 -0
  245. package/dist/query/option-surface.d.ts +16 -4
  246. package/dist/query/option-surface.js +56 -13
  247. package/dist/query/relation-names.d.ts +14 -0
  248. package/dist/query/relation-names.js +77 -19
  249. package/dist/query/relations.d.ts +30 -3
  250. package/dist/query/relations.js +220 -67
  251. package/dist/query/row-lock.d.ts +49 -0
  252. package/dist/query/row-lock.js +131 -0
  253. package/dist/query/sql-comment.d.ts +161 -0
  254. package/dist/query/sql-comment.js +374 -0
  255. package/dist/query/stream-idle.d.ts +132 -0
  256. package/dist/query/stream-idle.js +203 -0
  257. package/dist/query/table-client.d.ts +101 -0
  258. package/dist/query/table-client.js +70 -0
  259. package/dist/query/types.d.ts +677 -298
  260. package/dist/query/utils.d.ts +105 -6
  261. package/dist/query/utils.js +414 -18
  262. package/dist/query/warn-registry.d.ts +18 -0
  263. package/dist/query/warn-registry.js +18 -0
  264. package/dist/query/where-compile.d.ts +138 -14
  265. package/dist/query/where-compile.js +508 -31
  266. package/dist/query/where.d.ts +159 -59
  267. package/dist/query/where.js +711 -248
  268. package/dist/query/writes.d.ts +112 -50
  269. package/dist/query/writes.js +1496 -202
  270. package/dist/query-events.d.ts +4 -2
  271. package/dist/query-events.js +3 -1
  272. package/dist/realtime.d.ts +7 -3
  273. package/dist/realtime.js +13 -2
  274. package/dist/schema-builder.d.ts +55 -4
  275. package/dist/schema-builder.js +381 -18
  276. package/dist/schema-metadata.d.ts +21 -1
  277. package/dist/schema-metadata.js +28 -7
  278. package/dist/schema-sql.d.ts +75 -5
  279. package/dist/schema-sql.js +494 -44
  280. package/dist/schema.d.ts +26 -5
  281. package/dist/schema.js +109 -13
  282. package/dist/sql-placeholders.d.ts +56 -0
  283. package/dist/sql-placeholders.js +200 -0
  284. package/dist/sqlite.d.ts +75 -20
  285. package/dist/sqlite.js +597 -99
  286. package/dist/supplied-pool-types.d.ts +48 -0
  287. package/dist/supplied-pool-types.js +247 -0
  288. package/dist/transaction-control.d.ts +51 -0
  289. package/dist/transaction-control.js +311 -0
  290. package/dist/transaction-state.d.ts +342 -0
  291. package/dist/transaction-state.js +696 -0
  292. package/dist/typed-sql.d.ts +37 -3
  293. package/dist/typed-sql.js +100 -8
  294. package/package.json +7 -4
  295. package/skills/turbine-orm/SKILL.md +56 -6
@@ -5,10 +5,12 @@
5
5
  * _turbine_metrics. Same security model as Studio: loopback binding,
6
6
  * random token, HttpOnly cookie, CSP headers, read-only transactions.
7
7
  */
8
- import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
8
+ import { randomBytes } from 'node:crypto';
9
9
  import { createServer } from 'node:http';
10
10
  import pg from 'pg';
11
11
  import { absorbCheckedOutErrors } from '../connection-guard.js';
12
+ import { constantTimeEqual, createTicketGate, isAllowedHostHeader, redeemTicket, } from './local-server-guard.js';
13
+ import { openInBrowser, responseErrorText } from './local-server-output.js';
12
14
  import { OBSERVE_HTML } from './observe-ui.js';
13
15
  import { callerKey, checkRateLimit } from './rate-limit.js';
14
16
  import { redactUrl } from './ui.js';
@@ -27,31 +29,41 @@ export async function startObserve(options) {
27
29
  console.error(`[turbine] observe pool error: ${redactUrl(err.message)}`);
28
30
  });
29
31
  absorbCheckedOutErrors(pool);
30
- const probe = await pool.connect();
31
- try {
32
- await probe.query('SELECT 1');
33
- }
34
- finally {
35
- probe.release();
36
- }
37
32
  const authToken = randomBytes(24).toString('hex');
33
+ const ticketGate = createTicketGate();
38
34
  const rateLimiter = new Map();
39
35
  const server = createServer((req, res) => {
40
- handleRequest(req, res, pool, options, authToken, rateLimiter).catch((err) => {
41
- sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
36
+ handleRequest(req, res, pool, options, authToken, rateLimiter, ticketGate).catch((err) => {
37
+ sendJson(res, 500, { error: responseErrorText(err) });
42
38
  });
43
39
  });
44
- await new Promise((resolve, reject) => {
45
- server.once('error', reject);
46
- server.listen(options.port, options.host, () => {
47
- server.off('error', reject);
48
- resolve();
40
+ try {
41
+ const probe = await pool.connect();
42
+ try {
43
+ await probe.query('SELECT 1');
44
+ }
45
+ finally {
46
+ probe.release();
47
+ }
48
+ await new Promise((resolve, reject) => {
49
+ server.once('error', reject);
50
+ server.listen(options.port, options.host, () => {
51
+ server.off('error', reject);
52
+ resolve();
53
+ });
49
54
  });
50
- });
55
+ }
56
+ catch (err) {
57
+ // A start that fails (probe refused, port taken) must not leave the probe
58
+ // connection idling in a pool nobody holds: it would keep a database session
59
+ // open and the process alive until the idle timeout.
60
+ await pool.end().catch(() => { });
61
+ throw err;
62
+ }
51
63
  const hostPart = options.host.includes(':') && !options.host.startsWith('[') ? `[${options.host}]` : options.host;
52
- const url = `http://${hostPart}:${options.port}/?token=${authToken}`;
64
+ const url = `http://${hostPart}:${options.port}/?token=${ticketGate.token}`;
53
65
  if (options.openBrowser) {
54
- openUrl(url);
66
+ openInBrowser(url);
55
67
  }
56
68
  return {
57
69
  authToken,
@@ -65,15 +77,31 @@ export async function startObserve(options) {
65
77
  // ---------------------------------------------------------------------------
66
78
  // Request routing
67
79
  // ---------------------------------------------------------------------------
68
- export async function handleRequest(req, res, pool, options, authToken, rateLimiter) {
80
+ export async function handleRequest(req, res, pool, options, authToken, rateLimiter,
81
+ /** The one-time login ticket. Absent on a hand-built call: the ticket is then `authToken` itself. */
82
+ ticketGate = gateFor(authToken)) {
69
83
  const hostPart = options.host.includes(':') && !options.host.startsWith('[') ? `[${options.host}]` : options.host;
70
84
  const expectedOrigin = `http://${hostPart}:${options.port}`;
85
+ // DNS rebinding: a page on another domain that re-resolves to this port still
86
+ // sends its own hostname in `Host`. Checked before any route or token logic.
87
+ if (!isAllowedHostHeader(req.headers.host, options.host, options.port)) {
88
+ sendJson(res, 403, { error: 'host not allowed: open Observe at the address printed in the terminal' });
89
+ return;
90
+ }
71
91
  const origin = req.headers.origin;
72
92
  if (origin && origin !== expectedOrigin) {
73
93
  sendJson(res, 403, { error: 'cross-origin requests not allowed' });
74
94
  return;
75
95
  }
76
- const url = new URL(req.url ?? '/', expectedOrigin);
96
+ // A request target that is not a URL (`GET //`) is a client error, not a 500.
97
+ let url;
98
+ try {
99
+ url = new URL(req.url ?? '/', expectedOrigin);
100
+ }
101
+ catch {
102
+ sendJson(res, 400, { error: 'malformed request target' });
103
+ return;
104
+ }
77
105
  const pathname = url.pathname;
78
106
  if (pathname === '/' || pathname === '/index.html') {
79
107
  if (req.method !== 'GET' && req.method !== 'HEAD') {
@@ -81,13 +109,28 @@ export async function handleRequest(req, res, pool, options, authToken, rateLimi
81
109
  return;
82
110
  }
83
111
  const queryToken = url.searchParams.get('token');
84
- if (queryToken && constantTimeEqual(queryToken, authToken)) {
85
- res.writeHead(302, {
86
- Location: '/',
87
- 'Set-Cookie': `turbine_observe_token=${authToken}; Path=/; HttpOnly; SameSite=Strict`,
88
- });
89
- res.end();
90
- return;
112
+ if (queryToken) {
113
+ // The printed URL's token is a ONE-TIME ticket, exchanged here for the
114
+ // session cookie by the first GET that carries it. HEAD never spends it.
115
+ const outcome = req.method === 'GET' ? redeemTicket(ticketGate, queryToken) : 'invalid';
116
+ if (outcome === 'granted') {
117
+ res.writeHead(302, {
118
+ Location: '/',
119
+ 'Set-Cookie': `turbine_observe_token=${authToken}; Path=/; HttpOnly; SameSite=Strict`,
120
+ });
121
+ res.end();
122
+ return;
123
+ }
124
+ if (outcome === 'used') {
125
+ if (isAuthorized(req, authToken)) {
126
+ res.writeHead(302, { Location: '/' });
127
+ res.end();
128
+ }
129
+ else {
130
+ sendText(res, 403, 'This login link was already used. Restart Observe to get a new one.');
131
+ }
132
+ return;
133
+ }
91
134
  }
92
135
  sendHtml(res, 200, OBSERVE_HTML, cspNonce());
93
136
  return;
@@ -133,13 +176,18 @@ function isAuthorized(req, expectedToken) {
133
176
  }
134
177
  return false;
135
178
  }
136
- function constantTimeEqual(a, b) {
137
- // Hash both inputs to fixed-length 32-byte SHA-256 digests before comparing.
138
- // This makes the comparison constant-length (timingSafeEqual never throws on a
139
- // length mismatch) and leaks neither length nor content via timing.
140
- const ah = createHash('sha256').update(a).digest();
141
- const bh = createHash('sha256').update(b).digest();
142
- return timingSafeEqual(ah, bh);
179
+ /**
180
+ * The ticket for a caller that passed none (a hand-built dispatch): `authToken`
181
+ * itself, remembered per token so it is still spent exactly once.
182
+ */
183
+ const FALLBACK_GATES = new Map();
184
+ function gateFor(authToken) {
185
+ let gate = FALLBACK_GATES.get(authToken);
186
+ if (!gate) {
187
+ gate = createTicketGate(authToken);
188
+ FALLBACK_GATES.set(authToken, gate);
189
+ }
190
+ return gate;
143
191
  }
144
192
  // ---------------------------------------------------------------------------
145
193
  // API handlers
@@ -188,7 +236,7 @@ async function apiLatency(res, pool, params) {
188
236
  // this server writes no log of its own, so the HTTP response is the only
189
237
  // channel either error could reach. Same shape as cli/studio.ts.
190
238
  }
191
- sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
239
+ sendJson(res, 500, { error: responseErrorText(err) });
192
240
  }
193
241
  finally {
194
242
  client.release();
@@ -229,7 +277,7 @@ async function apiModels(res, pool, params) {
229
277
  // this server writes no log of its own, so the HTTP response is the only
230
278
  // channel either error could reach. Same shape as cli/studio.ts.
231
279
  }
232
- sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
280
+ sendJson(res, 500, { error: responseErrorText(err) });
233
281
  }
234
282
  finally {
235
283
  client.release();
@@ -277,21 +325,3 @@ function sendText(res, status, text) {
277
325
  res.writeHead(status, { ...SECURITY_HEADERS, 'Content-Security-Policy': JSON_CSP, 'Content-Type': 'text/plain' });
278
326
  res.end(text);
279
327
  }
280
- // ---------------------------------------------------------------------------
281
- // Browser open
282
- // ---------------------------------------------------------------------------
283
- function openUrl(url) {
284
- const { platform: os } = process;
285
- const { spawn } = require('node:child_process');
286
- try {
287
- if (os === 'darwin')
288
- spawn('open', [url], { stdio: 'ignore', detached: true }).unref();
289
- else if (os === 'win32')
290
- spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true }).unref();
291
- else
292
- spawn('xdg-open', [url], { stdio: 'ignore', detached: true }).unref();
293
- }
294
- catch {
295
- // Best effort
296
- }
297
- }
@@ -62,32 +62,15 @@
62
62
  * name-carrying position that is not a string is refused as a SHAPE here,
63
63
  * never skipped: skipping is the branch that leaks.
64
64
  */
65
+ import { RELATION_FILTER_WRAPPERS } from '../query/filters.js';
65
66
  import type { SchemaMetadata, TableMetadata } from '../schema.js';
66
67
  /**
67
- * Relation-filter wrappers whose body is a clause against the relation's target.
68
- *
69
- * THREE copies of this list exist in the tree, and they have to agree: a wrapper
70
- * the compiler understands and this guard does not is a wrapper that reaches the
71
- * builder unguarded.
72
- *
73
- * 1. this one, the only NAMED definition;
74
- * 2. `query/where-compile.ts`, inlined as `'some' in x || 'every' in x || …`;
75
- * 3. `prisma-compat.ts`, SPLIT across `RELATION_QUANTIFIERS`
76
- * (`some`/`every`/`none`) and two inline `k === 'is' || k === 'isNot'`
77
- * tests, which is the copy most likely to drift because half of it does
78
- * not look like a list at all.
79
- *
80
- * Copy 2 belongs to the SQL compiler and cannot import this one (a query-path
81
- * module must not depend on `cli/`), so the deduplication has to go the other
82
- * way: the list wants to live in `query/filters.ts` and be imported here, the
83
- * way {@link COLUMN_REF_OPERATORS} above now is.
84
- *
85
- * That destination now EXISTS (`RELATION_FILTER_WRAPPERS` in `query/filters.ts`)
86
- * and nothing imports it, including this file, so it is a fourth copy rather
87
- * than a deduplication and this comment is still the only thing holding them in
88
- * step. Moving each walk onto it is a change per walk, each with its own tests.
68
+ * Relation-filter wrappers whose body is a clause against the relation's target:
69
+ * the SQL compiler's own list (`query/filters.ts`), imported so a wrapper the
70
+ * compiler understands can never reach the builder unguarded. Re-exported for
71
+ * callers that still import it from here.
89
72
  */
90
- export declare const RELATION_FILTER_WRAPPERS: readonly ["some", "none", "every", "is", "isNot"];
73
+ export { RELATION_FILTER_WRAPPERS };
91
74
  /**
92
75
  * Recursion bound for the guard walk.
93
76
  *
@@ -101,6 +84,43 @@ export declare const RELATION_FILTER_WRAPPERS: readonly ["some", "none", "every"
101
84
  * nothing the builder would accept is refused here for depth alone.
102
85
  */
103
86
  export declare const PII_GUARD_MAX_DEPTH = 32;
87
+ /**
88
+ * Find a `lock` option anywhere a query level can carry one: the args
89
+ * themselves, or the options of any `with` entry at any depth. Returns the
90
+ * dotted path to the shallowest hit (`lock`, `with.posts.lock`), or `null`.
91
+ *
92
+ * WHY THIS IS NOT PART OF {@link assertNoPiiPredicates}. A locking read is not
93
+ * a hidden-column question, and the PII walk is conditional by design: Studio
94
+ * skips it under `--show-pii`, `explain_query` hands it an allowlisted subset
95
+ * of its arguments, and `compile_query` hands it only `where` for an aggregate.
96
+ * A read-only refusal has to hold in every one of those, so each surface calls
97
+ * this directly and unconditionally. It lives in this module because both
98
+ * surfaces already import it, which is what keeps the rule and its wording in
99
+ * one place.
100
+ *
101
+ * ANY VALUE other than `undefined` counts, `null` and `false` included. The
102
+ * builder treats exactly `undefined` as "no lock" and refuses every other
103
+ * non-lock value itself (E003), so answering "present" for all of them means
104
+ * the verdict here never depends on what the builder would have made of it.
105
+ *
106
+ * A `with` MAP is keyed by relation names, so a relation that happens to be
107
+ * called `lock` is a level to descend into and never a hit; only the option
108
+ * position on a level is read. A table column called `lock` is likewise safe:
109
+ * columns live under `where` / `orderBy` / `select`, which are not searched.
110
+ *
111
+ * Iterative and breadth-first, so a deeply nested payload costs time and no
112
+ * stack, and the shallowest hit is the one reported. The visited set makes it
113
+ * terminate on a cyclic object, which parsed JSON cannot produce but a caller
114
+ * of a pure helper can.
115
+ */
116
+ export declare function findRowLockOption(args: Record<string, unknown>): string | null;
117
+ /**
118
+ * The refusal text for a `lock` found by {@link findRowLockOption}, shared so
119
+ * the two surfaces cannot word the same rule two ways. `path` is interpolated
120
+ * as given: it can carry a caller-chosen relation name, so each surface passes
121
+ * it through its own printable-name escaping first.
122
+ */
123
+ export declare function rowLockRefusalMessage(path: string, tool: string): string;
104
124
  /**
105
125
  * The two things Studio and the MCP server genuinely do differently, plus the
106
126
  * schema the walk resolves relation targets against.
@@ -62,34 +62,16 @@
62
62
  * name-carrying position that is not a string is refused as a SHAPE here,
63
63
  * never skipped: skipping is the branch that leaks.
64
64
  */
65
- import { COLUMN_REF_OPERATORS } from '../query/filters.js';
65
+ import { isPlainColumnRefShape } from '../query/column-ref.js';
66
+ import { COLUMN_REF_OPERATORS, isColumnRef, RELATION_FILTER_WRAPPER_SET, RELATION_FILTER_WRAPPERS, } from '../query/filters.js';
66
67
  import { ownLookup, resolveColumnName, resolveRelation } from '../query/utils.js';
67
68
  /**
68
- * Relation-filter wrappers whose body is a clause against the relation's target.
69
- *
70
- * THREE copies of this list exist in the tree, and they have to agree: a wrapper
71
- * the compiler understands and this guard does not is a wrapper that reaches the
72
- * builder unguarded.
73
- *
74
- * 1. this one, the only NAMED definition;
75
- * 2. `query/where-compile.ts`, inlined as `'some' in x || 'every' in x || …`;
76
- * 3. `prisma-compat.ts`, SPLIT across `RELATION_QUANTIFIERS`
77
- * (`some`/`every`/`none`) and two inline `k === 'is' || k === 'isNot'`
78
- * tests, which is the copy most likely to drift because half of it does
79
- * not look like a list at all.
80
- *
81
- * Copy 2 belongs to the SQL compiler and cannot import this one (a query-path
82
- * module must not depend on `cli/`), so the deduplication has to go the other
83
- * way: the list wants to live in `query/filters.ts` and be imported here, the
84
- * way {@link COLUMN_REF_OPERATORS} above now is.
85
- *
86
- * That destination now EXISTS (`RELATION_FILTER_WRAPPERS` in `query/filters.ts`)
87
- * and nothing imports it, including this file, so it is a fourth copy rather
88
- * than a deduplication and this comment is still the only thing holding them in
89
- * step. Moving each walk onto it is a change per walk, each with its own tests.
69
+ * Relation-filter wrappers whose body is a clause against the relation's target:
70
+ * the SQL compiler's own list (`query/filters.ts`), imported so a wrapper the
71
+ * compiler understands can never reach the builder unguarded. Re-exported for
72
+ * callers that still import it from here.
90
73
  */
91
- export const RELATION_FILTER_WRAPPERS = ['some', 'none', 'every', 'is', 'isNot'];
92
- const RELATION_FILTER_WRAPPER_SET = new Set(RELATION_FILTER_WRAPPERS);
74
+ export { RELATION_FILTER_WRAPPERS };
93
75
  /**
94
76
  * Recursion bound for the guard walk.
95
77
  *
@@ -129,6 +111,78 @@ const LEVEL_OBJECT_KEYS = new Set([
129
111
  * which is what the builder itself branches on.
130
112
  */
131
113
  const RELATION_PICK_KEYS = new Set(['pick', 'by', 'direction', 'nulls', 'plan']);
114
+ /**
115
+ * Find a `lock` option anywhere a query level can carry one: the args
116
+ * themselves, or the options of any `with` entry at any depth. Returns the
117
+ * dotted path to the shallowest hit (`lock`, `with.posts.lock`), or `null`.
118
+ *
119
+ * WHY THIS IS NOT PART OF {@link assertNoPiiPredicates}. A locking read is not
120
+ * a hidden-column question, and the PII walk is conditional by design: Studio
121
+ * skips it under `--show-pii`, `explain_query` hands it an allowlisted subset
122
+ * of its arguments, and `compile_query` hands it only `where` for an aggregate.
123
+ * A read-only refusal has to hold in every one of those, so each surface calls
124
+ * this directly and unconditionally. It lives in this module because both
125
+ * surfaces already import it, which is what keeps the rule and its wording in
126
+ * one place.
127
+ *
128
+ * ANY VALUE other than `undefined` counts, `null` and `false` included. The
129
+ * builder treats exactly `undefined` as "no lock" and refuses every other
130
+ * non-lock value itself (E003), so answering "present" for all of them means
131
+ * the verdict here never depends on what the builder would have made of it.
132
+ *
133
+ * A `with` MAP is keyed by relation names, so a relation that happens to be
134
+ * called `lock` is a level to descend into and never a hit; only the option
135
+ * position on a level is read. A table column called `lock` is likewise safe:
136
+ * columns live under `where` / `orderBy` / `select`, which are not searched.
137
+ *
138
+ * Iterative and breadth-first, so a deeply nested payload costs time and no
139
+ * stack, and the shallowest hit is the one reported. The visited set makes it
140
+ * terminate on a cyclic object, which parsed JSON cannot produce but a caller
141
+ * of a pure helper can.
142
+ */
143
+ export function findRowLockOption(args) {
144
+ const queue = [{ level: args, parent: null, relation: '' }];
145
+ const seen = new Set();
146
+ for (let next = 0; next < queue.length; next++) {
147
+ const frame = queue[next];
148
+ if (seen.has(frame.level))
149
+ continue;
150
+ seen.add(frame.level);
151
+ if (frame.level.lock !== undefined)
152
+ return lockPath(frame);
153
+ const withClause = frame.level.with;
154
+ if (!isPlainObject(withClause))
155
+ continue;
156
+ for (const [relation, spec] of Object.entries(withClause)) {
157
+ if (isPlainObject(spec))
158
+ queue.push({ level: spec, parent: frame, relation });
159
+ }
160
+ }
161
+ return null;
162
+ }
163
+ /** `lock`, `with.posts.lock`, `with.posts.with.author.lock`: the path from the root to a hit. */
164
+ function lockPath(frame) {
165
+ const relations = [];
166
+ for (let at = frame; at !== null && at.parent !== null; at = at.parent) {
167
+ relations.push(at.relation);
168
+ }
169
+ const parts = [];
170
+ for (let i = relations.length - 1; i >= 0; i--)
171
+ parts.push('with', relations[i]);
172
+ parts.push('lock');
173
+ return parts.join('.');
174
+ }
175
+ /**
176
+ * The refusal text for a `lock` found by {@link findRowLockOption}, shared so
177
+ * the two surfaces cannot word the same rule two ways. `path` is interpolated
178
+ * as given: it can carry a caller-chosen relation name, so each surface passes
179
+ * it through its own printable-name escaping first.
180
+ */
181
+ export function rowLockRefusalMessage(path, tool) {
182
+ return (`${tool} does not accept \`${path}\`: it is read-only, and a locking read takes row locks, which needs a ` +
183
+ 'write-capable transaction in your application. Remove it from the query here (nothing else about the query ' +
184
+ 'changes), and run the locking read inside db.$transaction(async (tx) => ...) in your code.');
185
+ }
132
186
  /** An object or array value, i.e. something that can carry a nested name. */
133
187
  function isObjectLike(value) {
134
188
  return value !== null && typeof value === 'object';
@@ -224,23 +278,34 @@ export function assertNoPiiPredicates(args, rootTable, host) {
224
278
  /**
225
279
  * A column-keyed operator object (`{ gt: 5 }`, `{ sort, nulls }`, a JSON path,
226
280
  * a vector distance) holds values, not names, with ONE exception: the
227
- * comparison operators also accept `{ col: 'otherField' }`, which compiles to
228
- * a column-to-column comparison in the same table. Scan for that.
281
+ * comparison operators also accept a column reference (`col('otherField')`),
282
+ * which compiles to a column-to-column comparison in the same table. Scan for
283
+ * that, including inside a `not` operator object, which compiles to
284
+ * `NOT (<inner>)` with the same operand positions.
285
+ *
286
+ * A reference carries a symbol brand that parsed JSON cannot produce, so the
287
+ * request data these tools compile never holds one, and the builder refuses
288
+ * the plain `{ col }` spelling. That spelling is refused HERE as well, as a
289
+ * shape: the guard must not depend on the builder to close a channel it is
290
+ * responsible for.
229
291
  *
230
292
  * The operator list is IMPORTED from `query/filters.ts` rather than copied:
231
293
  * that is the set the SQL compiler itself branches on, and a guard that
232
294
  * disagreed with it would be guarding a different query than the one that
233
- * runs. A copy that drifted short by one operator reopens the operand-position
234
- * channel this scan exists to close, silently.
295
+ * runs.
235
296
  */
236
- const checkColumnRefs = (table, operatorNode) => {
297
+ const checkColumnRefs = (table, operatorNode, depth) => {
298
+ assertWithinDepth(depth);
237
299
  for (const opKey of COLUMN_REF_OPERATORS) {
238
300
  const operand = ownLookup(operatorNode, opKey);
239
- if (!isPlainObject(operand))
301
+ if (isColumnRef(operand)) {
302
+ checkColumnName(table, operand.col);
240
303
  continue;
241
- const ref = operand.col;
242
- if (typeof ref === 'string')
243
- checkColumnName(table, ref);
304
+ }
305
+ if (isPlainColumnRefShape(operand))
306
+ host.refuseShape(table, `${opKey}: { col }`);
307
+ if (opKey === 'not' && isPlainObject(operand))
308
+ checkColumnRefs(table, operand, depth + 1);
244
309
  }
245
310
  };
246
311
  /**
@@ -295,7 +360,7 @@ export function assertNoPiiPredicates(args, rootTable, host) {
295
360
  if (scope === 'relation' && !table.allColumns.includes(column))
296
361
  host.refuseShape(table, key);
297
362
  if (isPlainObject(value))
298
- checkColumnRefs(table, value);
363
+ checkColumnRefs(table, value, depth + 1);
299
364
  };
300
365
  /** A where / orderBy / cursor object (or a Prisma-style array of them). */
301
366
  const visitClause = (node, table, depth) => {
@@ -12,6 +12,8 @@
12
12
  * Pure leaf: no filesystem, database, or process access. Passing `schema: null`
13
13
  * yields a parse-only report (every item `parsed`, no map) for `--no-db`.
14
14
  */
15
+ import { nullRecord } from '../introspect.js';
16
+ import { ownLookup } from '../query/utils.js';
15
17
  import { camelToSnake, snakeToCamel, withDbFieldNames } from '../schema.js';
16
18
  export { DEFAULT_EXCLUDED_TABLES } from '../introspect.js';
17
19
  // ---------------------------------------------------------------------------
@@ -101,7 +103,9 @@ export function resolvePrismaSchema(ast, schema, options = {}) {
101
103
  const result = {
102
104
  models: [],
103
105
  enums: [],
104
- map: { models: {}, enums: {} },
106
+ // Prototype-less: models, fields, relations and enums are keyed by names the
107
+ // Prisma schema chose, and `__proto__` / `constructor` are legal names there.
108
+ map: { models: nullRecord(), enums: nullRecord() },
105
109
  hasUnresolved: false,
106
110
  parseWarnings: ast.warnings,
107
111
  noDb,
@@ -110,7 +114,7 @@ export function resolvePrismaSchema(ast, schema, options = {}) {
110
114
  for (const model of ast.models) {
111
115
  const rt = modelTable.get(model.name);
112
116
  const table = rt.table;
113
- const tableMeta = table && resolvedSchema ? resolvedSchema.tables[table] : undefined;
117
+ const tableMeta = table && resolvedSchema ? ownLookup(resolvedSchema.tables, table) : undefined;
114
118
  const accessor = table ? snakeToCamel(table) : null;
115
119
  const status = noDb ? 'parsed' : table ? 'resolved' : 'unresolved';
116
120
  const resolved = {
@@ -143,23 +147,23 @@ export function resolvePrismaSchema(ast, schema, options = {}) {
143
147
  result.models.push(resolved);
144
148
  // Build the verified map entry (skip in --no-db and for unresolved tables).
145
149
  if (!noDb && table && accessor) {
146
- const fields = {};
150
+ const fields = nullRecord();
147
151
  for (const f of resolved.fields) {
148
152
  if (f.status === 'resolved' && f.turbineField)
149
153
  fields[f.prismaName] = f.turbineField;
150
154
  }
151
- const relations = {};
155
+ const relations = nullRecord();
152
156
  for (const r of resolved.relations) {
153
157
  if (r.status === 'resolved' && r.turbineName && r.cardinality) {
154
158
  relations[r.prismaName] = { name: r.turbineName, cardinality: r.cardinality };
155
159
  }
156
160
  }
157
- const compoundUniques = {};
161
+ const compoundUniques = nullRecord();
158
162
  for (const c of resolved.compoundUniques) {
159
163
  if (c.status === 'resolved' && c.turbineFields)
160
164
  compoundUniques[c.selector] = c.turbineFields;
161
165
  }
162
- const clientDefaults = collectClientDefaults(model, fields, resolvedSchema?.tables[table]);
166
+ const clientDefaults = collectClientDefaults(model, fields, ownLookup(resolvedSchema?.tables ?? {}, table));
163
167
  result.map.models[model.name] = {
164
168
  table,
165
169
  accessor,
@@ -259,7 +263,7 @@ function resolveScalarField(model, fieldName, tableMeta, noDb) {
259
263
  if (noDb || !tableMeta) {
260
264
  return { prismaName: fieldName, turbineField: null, column: null, status: noDb ? 'parsed' : 'unresolved' };
261
265
  }
262
- const turbineField = tableMeta.reverseColumnMap[column];
266
+ const turbineField = ownLookup(tableMeta.reverseColumnMap, column);
263
267
  if (turbineField) {
264
268
  return { prismaName: fieldName, turbineField, column, status: 'resolved' };
265
269
  }
@@ -305,9 +309,9 @@ function relationFkColumns(model, field) {
305
309
  function collectClientDefaults(model, resolvedFields, tableMeta) {
306
310
  if (!tableMeta)
307
311
  return undefined;
308
- const out = {};
312
+ const out = nullRecord();
309
313
  for (const field of model.fields) {
310
- const turbineField = resolvedFields[field.name];
314
+ const turbineField = ownLookup(resolvedFields, field.name);
311
315
  if (!turbineField)
312
316
  continue;
313
317
  const col = tableMeta.columns.find((c) => c.field === turbineField || c.name === turbineField);
@@ -467,11 +471,11 @@ function resolveCompoundUnique(model, key, tableMeta, noDb) {
467
471
  if (noDb || !tableMeta)
468
472
  return base;
469
473
  const columns = key.fields.map((f) => fieldColumn(model, f));
470
- const missing = columns.filter((c) => !tableMeta.reverseColumnMap[c]);
474
+ const missing = columns.filter((c) => !ownLookup(tableMeta.reverseColumnMap, c));
471
475
  if (missing.length > 0) {
472
476
  return { ...base, reason: `column(s) not found: ${missing.join(', ')}` };
473
477
  }
474
- const turbineFields = columns.map((c) => tableMeta.reverseColumnMap[c]);
478
+ const turbineFields = columns.map((c) => ownLookup(tableMeta.reverseColumnMap, c));
475
479
  const want = [...columns].sort().join(',');
476
480
  const matches = (setList) => setList.some((s) => [...s].sort().join(',') === want);
477
481
  if (key.kind === 'id') {
@@ -87,11 +87,19 @@ function lineAt(src, offset) {
87
87
  // ---------------------------------------------------------------------------
88
88
  // Attribute-argument tokenizer
89
89
  // ---------------------------------------------------------------------------
90
- /** Unquote a `"..."` literal, resolving the escapes Prisma supports. */
90
+ const QUOTED_ESCAPES = { '"': '"', '\\': '\\', n: '\n', t: '\t' };
91
+ /**
92
+ * Unquote a `"..."` literal, resolving the escapes Prisma supports.
93
+ *
94
+ * ONE left-to-right pass, because the escapes are not independent: after `\\`
95
+ * has become a backslash, that backslash must not be read again as the start of
96
+ * `\n` or `\t`. A chain of `.replace` calls does exactly that, so the literal
97
+ * `"C:\\new"` came out as `C:` + newline + `ew`.
98
+ */
91
99
  function unquote(raw) {
92
100
  const s = raw.trim();
93
101
  if (s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"') {
94
- return s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\').replace(/\\n/g, '\n').replace(/\\t/g, '\t');
102
+ return s.slice(1, -1).replace(/\\(["\\nt])/g, (_, escaped) => QUOTED_ESCAPES[escaped]);
95
103
  }
96
104
  return s;
97
105
  }
@@ -423,7 +431,7 @@ function parseFieldLine(text, line, warnings) {
423
431
  `Turbine generates the column from the database, so it IS present on the generated client.`);
424
432
  }
425
433
  for (const attr of attrs) {
426
- const note = NOTED_FIELD_ATTRS[attr.name];
434
+ const note = Object.hasOwn(NOTED_FIELD_ATTRS, attr.name) ? NOTED_FIELD_ATTRS[attr.name] : undefined;
427
435
  if (note)
428
436
  warnings.push(`Line ${line}: field "${name}": ${note}`);
429
437
  }
@@ -457,7 +465,7 @@ function parseModelBody(block, kind, src, warnings) {
457
465
  // @@index and anything else: recorded in blockAttrs, unused. The
458
466
  // few that change what the model MEANS get a warning rather than
459
467
  // silence (see NOTED_BLOCK_ATTRS).
460
- const note = NOTED_BLOCK_ATTRS[attr.name];
468
+ const note = Object.hasOwn(NOTED_BLOCK_ATTRS, attr.name) ? NOTED_BLOCK_ATTRS[attr.name] : undefined;
461
469
  if (note)
462
470
  warnings.push(`Line ${line}: model "${block.name}": ${note}`);
463
471
  }