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
@@ -0,0 +1,53 @@
1
+ /**
2
+ * turbine-orm CLI: shared guards for the local servers
3
+ *
4
+ * A leaf module (Node's crypto and nothing else), the same role as
5
+ * `cli/rate-limit.ts`: Studio and Observe both bind a loopback port behind a
6
+ * random token, and the checks below are the parts of that perimeter that are
7
+ * identical for both, so neither server grows its own copy.
8
+ *
9
+ * - {@link isAllowedHostHeader}: a request whose `Host` names anything but this
10
+ * server is refused. This is what stops DNS rebinding: a page on an attacker's
11
+ * domain that re-resolves to 127.0.0.1 still sends `Host: attacker.example`.
12
+ * - {@link createTicketGate} / {@link redeemTicket}: the URL printed in the
13
+ * terminal carries a ONE-TIME login token, separate from the credential the
14
+ * session then uses, so a URL that lands in browser history, a process
15
+ * listing or a log is a spent ticket and not a standing key.
16
+ * - {@link constantTimeEqual}: the one token comparison.
17
+ */
18
+ /**
19
+ * Compare two secrets without leaking length or content through timing. Both
20
+ * sides are hashed to fixed-length digests first, so `timingSafeEqual` never
21
+ * throws on a length mismatch.
22
+ */
23
+ export declare function constantTimeEqual(a: string, b: string): boolean;
24
+ /**
25
+ * Is this `Host` header one this server should answer to?
26
+ *
27
+ * Allowed: the loopback names (`127.0.0.1`, `localhost`, `[::1]`) and the host
28
+ * the server was bound to, each on the bound port. A server bound to a wildcard
29
+ * address (`0.0.0.0`, `::`) also accepts an IP literal, because it has no single
30
+ * name and a literal address cannot be rebound: rebinding needs a DNS name the
31
+ * attacker controls.
32
+ *
33
+ * An ABSENT header is allowed. HTTP/1.1 requires it and Node answers 400 itself
34
+ * when it is missing, so the only request that reaches this check without one is
35
+ * an HTTP/1.0 client, which is not a browser and can say anything anyway. The
36
+ * attack this refuses needs a browser, and a browser always sends one.
37
+ */
38
+ export declare function isAllowedHostHeader(header: string | string[] | undefined, boundHost: string, port: number): boolean;
39
+ /** The one-time ticket carried by the URL a server prints. */
40
+ export interface TicketGate {
41
+ /** The value in the printed URL's `?token=`. Never a session credential. */
42
+ readonly token: string;
43
+ /** True once a GET carrying the token has been exchanged for a session cookie. */
44
+ used: boolean;
45
+ }
46
+ export type TicketOutcome = 'granted' | 'used' | 'invalid';
47
+ /** Mint a gate. `token` is only passed by a caller that has no separate one to give. */
48
+ export declare function createTicketGate(token?: string): TicketGate;
49
+ /**
50
+ * Try to exchange `candidate` for a session. `granted` happens at most once per
51
+ * gate; afterwards the same token reports `used`, which a server must refuse.
52
+ */
53
+ export declare function redeemTicket(gate: TicketGate, candidate: string): TicketOutcome;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * turbine-orm CLI: shared guards for the local servers
3
+ *
4
+ * A leaf module (Node's crypto and nothing else), the same role as
5
+ * `cli/rate-limit.ts`: Studio and Observe both bind a loopback port behind a
6
+ * random token, and the checks below are the parts of that perimeter that are
7
+ * identical for both, so neither server grows its own copy.
8
+ *
9
+ * - {@link isAllowedHostHeader}: a request whose `Host` names anything but this
10
+ * server is refused. This is what stops DNS rebinding: a page on an attacker's
11
+ * domain that re-resolves to 127.0.0.1 still sends `Host: attacker.example`.
12
+ * - {@link createTicketGate} / {@link redeemTicket}: the URL printed in the
13
+ * terminal carries a ONE-TIME login token, separate from the credential the
14
+ * session then uses, so a URL that lands in browser history, a process
15
+ * listing or a log is a spent ticket and not a standing key.
16
+ * - {@link constantTimeEqual}: the one token comparison.
17
+ */
18
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
19
+ /**
20
+ * Compare two secrets without leaking length or content through timing. Both
21
+ * sides are hashed to fixed-length digests first, so `timingSafeEqual` never
22
+ * throws on a length mismatch.
23
+ */
24
+ export function constantTimeEqual(a, b) {
25
+ const ah = createHash('sha256').update(a).digest();
26
+ const bh = createHash('sha256').update(b).digest();
27
+ return timingSafeEqual(ah, bh);
28
+ }
29
+ // ---------------------------------------------------------------------------
30
+ // Host header allowlist
31
+ // ---------------------------------------------------------------------------
32
+ const LOOPBACK_HOSTNAMES = ['127.0.0.1', 'localhost', '[::1]'];
33
+ const WILDCARD_HOSTNAMES = new Set(['0.0.0.0', '[::]', '::']);
34
+ const IPV4_LITERAL = /^\d{1,3}(?:\.\d{1,3}){3}$/;
35
+ /** `host`, `host:port`, `[v6]` or `[v6]:port`, or null when it is none of those. */
36
+ function parseHostHeader(value) {
37
+ const trimmed = value.trim().toLowerCase();
38
+ if (trimmed === '')
39
+ return null;
40
+ if (trimmed.startsWith('[')) {
41
+ const end = trimmed.indexOf(']');
42
+ if (end < 0)
43
+ return null;
44
+ const rest = trimmed.slice(end + 1);
45
+ if (rest === '')
46
+ return { hostname: trimmed.slice(0, end + 1), port: null };
47
+ if (!/^:\d+$/.test(rest))
48
+ return null;
49
+ return { hostname: trimmed.slice(0, end + 1), port: rest.slice(1) };
50
+ }
51
+ const colon = trimmed.indexOf(':');
52
+ if (colon < 0)
53
+ return { hostname: trimmed, port: null };
54
+ // A bare name never contains a second colon: that would be an unbracketed IPv6.
55
+ if (trimmed.indexOf(':', colon + 1) >= 0)
56
+ return null;
57
+ const port = trimmed.slice(colon + 1);
58
+ if (!/^\d+$/.test(port))
59
+ return null;
60
+ return { hostname: trimmed.slice(0, colon), port };
61
+ }
62
+ function bracketIfIpv6(host) {
63
+ const lower = host.toLowerCase();
64
+ return lower.includes(':') && !lower.startsWith('[') ? `[${lower}]` : lower;
65
+ }
66
+ /**
67
+ * Is this `Host` header one this server should answer to?
68
+ *
69
+ * Allowed: the loopback names (`127.0.0.1`, `localhost`, `[::1]`) and the host
70
+ * the server was bound to, each on the bound port. A server bound to a wildcard
71
+ * address (`0.0.0.0`, `::`) also accepts an IP literal, because it has no single
72
+ * name and a literal address cannot be rebound: rebinding needs a DNS name the
73
+ * attacker controls.
74
+ *
75
+ * An ABSENT header is allowed. HTTP/1.1 requires it and Node answers 400 itself
76
+ * when it is missing, so the only request that reaches this check without one is
77
+ * an HTTP/1.0 client, which is not a browser and can say anything anyway. The
78
+ * attack this refuses needs a browser, and a browser always sends one.
79
+ */
80
+ export function isAllowedHostHeader(header, boundHost, port) {
81
+ if (header === undefined)
82
+ return true;
83
+ if (typeof header !== 'string')
84
+ return false;
85
+ const parsed = parseHostHeader(header);
86
+ if (!parsed)
87
+ return false;
88
+ if (parsed.port === null ? port !== 80 : parsed.port !== String(port))
89
+ return false;
90
+ const bound = bracketIfIpv6(boundHost);
91
+ if (parsed.hostname === bound || LOOPBACK_HOSTNAMES.includes(parsed.hostname))
92
+ return true;
93
+ if (WILDCARD_HOSTNAMES.has(boundHost.toLowerCase()) || WILDCARD_HOSTNAMES.has(bound)) {
94
+ return IPV4_LITERAL.test(parsed.hostname) || parsed.hostname.startsWith('[');
95
+ }
96
+ return false;
97
+ }
98
+ /** Mint a gate. `token` is only passed by a caller that has no separate one to give. */
99
+ export function createTicketGate(token = randomBytes(24).toString('hex')) {
100
+ return { token, used: false };
101
+ }
102
+ /**
103
+ * Try to exchange `candidate` for a session. `granted` happens at most once per
104
+ * gate; afterwards the same token reports `used`, which a server must refuse.
105
+ */
106
+ export function redeemTicket(gate, candidate) {
107
+ if (!constantTimeEqual(candidate, gate.token))
108
+ return 'invalid';
109
+ if (gate.used)
110
+ return 'used';
111
+ gate.used = true;
112
+ return 'granted';
113
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * What the two local servers (Studio and Observe) send OUT of the process: the
3
+ * text of an error response, and the command that opens a browser. A leaf that
4
+ * imports only `node:child_process`, so both servers share one copy.
5
+ *
6
+ * Both exist because the input to them is not entirely ours. A caught value can
7
+ * be anything a dependency threw, and the host in the printed URL comes from a
8
+ * command-line flag.
9
+ */
10
+ import { spawn } from 'node:child_process';
11
+ /**
12
+ * The text a Studio or Observe error response may carry for a caught value.
13
+ *
14
+ * An `Error` contributes its own message, and only that: never `stack`, and
15
+ * never the result of stringifying the object (a `toString` on a foreign error
16
+ * type is free to print one). When the message is empty, which is what Node's
17
+ * `AggregateError` has for a refused connection, the messages of the errors it
18
+ * aggregates or its `cause` are used, then its `code`, then its name. A thrown
19
+ * value that is not an `Error` has nothing safe to say, so it gets a fixed
20
+ * sentence rather than a rendering of whatever it was.
21
+ */
22
+ export declare function responseErrorText(err: unknown): string;
23
+ /** @internal exported for tests. */
24
+ export declare function isLaunchableUrl(url: string): boolean;
25
+ /** @internal the seams the tests replace; production passes neither. */
26
+ export interface BrowserLaunch {
27
+ platform: string;
28
+ spawn: typeof spawn;
29
+ }
30
+ /**
31
+ * Open `url` in the user's browser. Best effort: false when the URL is not one
32
+ * of ours to launch or the launcher could not be started, and the caller has
33
+ * already printed the URL either way.
34
+ *
35
+ * The launcher is started directly with an argument list and no shell
36
+ * (`xdg-open <url>`, `open <url>`, and on Windows `cmd /c start "" <url>`).
37
+ * A launcher that is not installed is reported as an 'error' EVENT rather than
38
+ * a throw, and with no listener Node turns it into an uncaught exception that
39
+ * ends the server right after it printed its address.
40
+ */
41
+ export declare function openInBrowser(url: string, launch?: BrowserLaunch): boolean;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * What the two local servers (Studio and Observe) send OUT of the process: the
3
+ * text of an error response, and the command that opens a browser. A leaf that
4
+ * imports only `node:child_process`, so both servers share one copy.
5
+ *
6
+ * Both exist because the input to them is not entirely ours. A caught value can
7
+ * be anything a dependency threw, and the host in the printed URL comes from a
8
+ * command-line flag.
9
+ */
10
+ import { spawn } from 'node:child_process';
11
+ // ---------------------------------------------------------------------------
12
+ // Error text for an HTTP response
13
+ // ---------------------------------------------------------------------------
14
+ const UNEXPECTED_ERROR = 'unexpected error';
15
+ /**
16
+ * The text a Studio or Observe error response may carry for a caught value.
17
+ *
18
+ * An `Error` contributes its own message, and only that: never `stack`, and
19
+ * never the result of stringifying the object (a `toString` on a foreign error
20
+ * type is free to print one). When the message is empty, which is what Node's
21
+ * `AggregateError` has for a refused connection, the messages of the errors it
22
+ * aggregates or its `cause` are used, then its `code`, then its name. A thrown
23
+ * value that is not an `Error` has nothing safe to say, so it gets a fixed
24
+ * sentence rather than a rendering of whatever it was.
25
+ */
26
+ export function responseErrorText(err) {
27
+ return err instanceof Error ? errorMessage(err, 0) : UNEXPECTED_ERROR;
28
+ }
29
+ function errorMessage(err, depth) {
30
+ if (err.message)
31
+ return err.message;
32
+ if (depth < 4) {
33
+ const inner = err.errors;
34
+ if (Array.isArray(inner)) {
35
+ const parts = new Set();
36
+ for (const e of inner) {
37
+ const text = e instanceof Error ? errorMessage(e, depth + 1) : '';
38
+ if (text !== '')
39
+ parts.add(text);
40
+ }
41
+ if (parts.size > 0)
42
+ return [...parts].join('; ');
43
+ }
44
+ if (err.cause instanceof Error) {
45
+ const cause = errorMessage(err.cause, depth + 1);
46
+ if (cause !== '')
47
+ return cause;
48
+ }
49
+ }
50
+ const code = err.code;
51
+ if (typeof code === 'string' && code !== '')
52
+ return code;
53
+ return err.name || 'Error';
54
+ }
55
+ // ---------------------------------------------------------------------------
56
+ // Browser launch
57
+ // ---------------------------------------------------------------------------
58
+ /**
59
+ * The only URL shape a server launches: plain `http`, a host name or IP
60
+ * literal, a port, and optionally the one-time `?token=`. The host comes from
61
+ * `--host`, and on Windows the URL is an argument of `cmd /c start`, where `&`,
62
+ * `|`, `^`, `%` and `"` are command syntax rather than data, so anything outside
63
+ * this set is not handed to a launcher at all. The URL is still printed.
64
+ */
65
+ const LAUNCHABLE_URL = /^http:\/\/(?:[A-Za-z0-9._-]+|\[[0-9A-Fa-f:.]+\]):\d{1,5}\/(?:\?token=[A-Za-z0-9_-]+)?$/;
66
+ /** @internal exported for tests. */
67
+ export function isLaunchableUrl(url) {
68
+ return LAUNCHABLE_URL.test(url);
69
+ }
70
+ /**
71
+ * Open `url` in the user's browser. Best effort: false when the URL is not one
72
+ * of ours to launch or the launcher could not be started, and the caller has
73
+ * already printed the URL either way.
74
+ *
75
+ * The launcher is started directly with an argument list and no shell
76
+ * (`xdg-open <url>`, `open <url>`, and on Windows `cmd /c start "" <url>`).
77
+ * A launcher that is not installed is reported as an 'error' EVENT rather than
78
+ * a throw, and with no listener Node turns it into an uncaught exception that
79
+ * ends the server right after it printed its address.
80
+ */
81
+ export function openInBrowser(url, launch = { platform: process.platform, spawn }) {
82
+ if (!isLaunchableUrl(url))
83
+ return false;
84
+ const [command, args] = launch.platform === 'darwin'
85
+ ? ['open', [url]]
86
+ : launch.platform === 'win32'
87
+ ? ['cmd', ['/c', 'start', '', url]]
88
+ : ['xdg-open', [url]];
89
+ try {
90
+ const child = launch.spawn(command, args, { stdio: 'ignore', detached: true });
91
+ child.on('error', () => { });
92
+ child.unref();
93
+ return true;
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ }
package/dist/cli/mcp.d.ts CHANGED
@@ -43,6 +43,14 @@ export interface McpServerHandle {
43
43
  */
44
44
  closed: Promise<McpCloseReason>;
45
45
  }
46
+ /**
47
+ * Split an identifier into lowercase words on separators, case changes
48
+ * (`refreshToken`), acronym edges (`APIKey` -> `api`, `key`) and letter/digit
49
+ * edges (`token2`).
50
+ */
51
+ export declare function secretNameWords(name: string): string[];
52
+ /** True when `name` looks like it holds a credential. See {@link SECRET_WORDS}. */
53
+ export declare function looksLikeSecretName(name: string): boolean;
46
54
  /**
47
55
  * The most bytes the stdio reader will hold WITHOUT seeing a newline.
48
56
  *
package/dist/cli/mcp.js CHANGED
@@ -5,14 +5,14 @@ import pg from 'pg';
5
5
  import { absorbCheckedOutErrors } from '../connection-guard.js';
6
6
  import { findMissingRelationIndexes } from '../index-advisor.js';
7
7
  import { formatBytes } from '../index-stats.js';
8
- import { deriveCatalogRelations, indexKeyColumn, parseIndexKeyEntries } from '../introspect.js';
8
+ import { deriveCatalogRelations, indexKeyColumn, parseIndexKeyEntries, printableCatalogName, } from '../introspect.js';
9
9
  import { QueryInterface, quoteIdent } from '../query/index.js';
10
10
  import { ownLookup, registerUtcTemporalParsers } from '../query/utils.js';
11
11
  import { isDateType, pgArrayType, pgTypeToTs, snakeToCamel, } from '../schema.js';
12
12
  import { COMPILE_OPERATIONS, carriesColumnNamingArg, collectAggregateColumnNames, compileQueryPlan, isAggregateShaped, } from './compile-query.js';
13
13
  import { CATALOGUED_ERROR_CODES, explainErrorCode } from './error-catalog.js';
14
14
  import { listMigrationFiles } from './migrate.js';
15
- import { assertNoPiiPredicates as assertNoPiiPredicatesShared } from './pii-predicate-guard.js';
15
+ import { assertNoPiiPredicates as assertNoPiiPredicatesShared, findRowLockOption, rowLockRefusalMessage, } from './pii-predicate-guard.js';
16
16
  import { applyPiiTags, loadPiiTags } from './pii-tags.js';
17
17
  import { redactUrl } from './ui.js';
18
18
  /**
@@ -51,6 +51,12 @@ function readOwnVersion() {
51
51
  const PROTOCOL_VERSION = '2025-06-18';
52
52
  const STATEMENT_TIMEOUT = '30s';
53
53
  const TRACKING_TABLE = '_turbine_migrations';
54
+ /**
55
+ * A name made safe to quote in a message. Catalog names can carry control or
56
+ * bidi characters, and an error message or stderr line can reach a terminal or
57
+ * a log. Same helper `generate`, `status` and `doctor` print through.
58
+ */
59
+ const shown = printableCatalogName;
54
60
  /**
55
61
  * Marker written in place of a hidden cell. Never `null` and never the value,
56
62
  * so the agent can tell "hidden" from "empty" (same string Studio uses).
@@ -78,36 +84,93 @@ const REDACTED = '•• redacted ••';
78
84
  /**
79
85
  * Column names that look like they hold a credential.
80
86
  *
81
- * Anchored to IDENTIFIER SEGMENT boundaries (start, end, or an underscore), not
82
- * a bare substring. Unanchored, `secret` matched `secretary_id` and `token`
83
- * matched a perfectly ordinary `token_count`, so the guard refused legitimate
84
- * queries: a false refusal is not free here, it degrades the tool and trains
85
- * people to route around it.
87
+ * A name is split into WORDS (see {@link secretNameWords}) and tested by word
88
+ * membership, never by substring. Unanchored, `secret` matched `secretary_id`
89
+ * and `token` matched a perfectly ordinary `token_count`, so the guard refused
90
+ * legitimate queries: a false refusal is not free here, it degrades the tool
91
+ * and trains people to route around it. Word splitting reads snake_case,
92
+ * kebab-case, camelCase, PascalCase and SCREAMING_CASE the same way, which the
93
+ * earlier underscore-anchored pattern did not: on a camelCase database (the
94
+ * default naming of a Prisma-created one) it caught `apiKey` only by accident
95
+ * of the whole name being a listed word and returned `refreshToken`,
96
+ * `passwordHash` and `clientSecret` verbatim.
86
97
  *
87
98
  * Deliberately still conservative in the other direction. A column whose name
88
- * genuinely carries a segment like `token` or `secret` is refused even when it
99
+ * genuinely carries a word like `token` or `secret` is refused even when it
89
100
  * holds nothing sensitive, because this gate decides whether raw bytes, or a
90
101
  * row-count oracle over them, reach an LLM context. Renaming the column is a
91
102
  * cheaper fix than the disclosure it prevents.
92
103
  */
93
- const SECRET_WORDS = [
104
+ const SECRET_WORDS = new Set([
94
105
  'passwd',
95
106
  'password',
107
+ 'passphrase',
108
+ 'passcode',
96
109
  'secret',
97
110
  'token',
98
111
  'apikey',
99
- 'api_key',
100
112
  'privatekey',
101
- 'private_key',
113
+ 'accesskey',
102
114
  'credential',
103
- 'credentials',
104
115
  'sessionid',
105
- 'session_id',
106
116
  'otp',
107
117
  'mfa',
108
118
  'totp',
109
- ];
110
- const SECRET_NAME_PATTERN = new RegExp(`(^|_)(${SECRET_WORDS.join('|')})(_|$)`, 'i');
119
+ 'jwt',
120
+ 'salt',
121
+ ]);
122
+ /** Word pairs that are secret only together (`api` + `key`), as one joined string. */
123
+ const SECRET_WORD_PAIRS = new Set(['apikey', 'privatekey', 'accesskey', 'sessionid']);
124
+ /**
125
+ * Suffixes and prefixes for a name that arrives as ONE lowercase word
126
+ * (`refreshtoken`, `passwordhash`), where there is no boundary left to split
127
+ * on. Suffix-only for the short, common words so `secretary` and `tokenizer`
128
+ * stay unflagged; the prefix list is limited to words nothing else starts with.
129
+ */
130
+ const SECRET_SUFFIXES = ['token', 'secret', 'password', 'passwd', 'passphrase', 'apikey', 'privatekey', 'sessionid'];
131
+ const SECRET_PREFIXES = ['password', 'passwd', 'passphrase', 'apikey', 'privatekey', 'accesskey'];
132
+ /**
133
+ * Split an identifier into lowercase words on separators, case changes
134
+ * (`refreshToken`), acronym edges (`APIKey` -> `api`, `key`) and letter/digit
135
+ * edges (`token2`).
136
+ */
137
+ export function secretNameWords(name) {
138
+ return name
139
+ .replace(/([a-z\d])([A-Z])/g, '$1 $2')
140
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
141
+ .replace(/([A-Za-z])(\d)/g, '$1 $2')
142
+ .replace(/(\d)([A-Za-z])/g, '$1 $2')
143
+ .toLowerCase()
144
+ .split(/[^a-z0-9]+/)
145
+ .filter((word) => word !== '');
146
+ }
147
+ function isSecretWord(word) {
148
+ if (SECRET_WORDS.has(word))
149
+ return true;
150
+ // Plural (`tokens`, `api_keys` via the pair join, `credentials`).
151
+ return word.length > 3 && word.endsWith('s') && SECRET_WORDS.has(word.slice(0, -1));
152
+ }
153
+ /** True when `name` looks like it holds a credential. See {@link SECRET_WORDS}. */
154
+ export function looksLikeSecretName(name) {
155
+ const words = secretNameWords(name);
156
+ for (let i = 0; i < words.length; i++) {
157
+ const word = words[i];
158
+ if (isSecretWord(word))
159
+ return true;
160
+ const next = words[i + 1];
161
+ if (next !== undefined && SECRET_WORD_PAIRS.has(`${word}${next.replace(/s$/, '')}`))
162
+ return true;
163
+ // One unsplittable lowercase word: only worth a second look when it is long
164
+ // enough to be a compound, and only against the safe suffix/prefix lists.
165
+ if (word.length > 5 && /^[a-z]+$/.test(word)) {
166
+ if (SECRET_SUFFIXES.some((suffix) => word.length > suffix.length && word.endsWith(suffix)))
167
+ return true;
168
+ if (SECRET_PREFIXES.some((prefix) => word.length > prefix.length && word.startsWith(prefix)))
169
+ return true;
170
+ }
171
+ }
172
+ return false;
173
+ }
111
174
  /**
112
175
  * The most bytes the stdio reader will hold WITHOUT seeing a newline.
113
176
  *
@@ -149,7 +212,7 @@ const TOOLS = [
149
212
  },
150
213
  {
151
214
  name: 'explain_query',
152
- description: 'Run EXPLAIN (FORMAT JSON) for a schema-validated findMany query. Pass table + optional where/orderBy/limit/select, free-form SQL is rejected. A where or orderBy on a PII-tagged or secret-named column is refused: the planner row estimate would leak the value. Naming such a column in select is allowed, since EXPLAIN returns no rows.',
215
+ description: 'Run EXPLAIN (FORMAT JSON) for a schema-validated findMany query. Pass table + optional where/orderBy/limit/select, free-form SQL is rejected. A where or orderBy on a PII-tagged or secret-named column is refused: the planner row estimate would leak the value. Naming a PII-tagged column in select is refused, as it is in the library without includePii: UNSAFE, which this server cannot pass. A `lock` option is refused: this server is read-only and a locking read needs a write-capable transaction in your application.',
153
216
  inputSchema: {
154
217
  type: 'object',
155
218
  properties: {
@@ -174,7 +237,7 @@ const TOOLS = [
174
237
  },
175
238
  {
176
239
  name: 'compile_query',
177
- description: 'Compile a Turbine read query to the EXACT SQL it would send, WITHOUT running it: no statement is executed and no row is read, so this is safe to call on production and safe to call in a loop. Use it before you write the query into code. Pass `table`, an `operation` (findMany / findUnique / findFirst / count / aggregate / groupBy, default findMany) and `args`, the same object you would pass to that method. Returns the SQL, the bound parameters, how many STATEMENTS the query costs at execution (a `with` clause can be one join or one follow-up per relation), which relation-load strategy it takes, whether it is bounded by a LIMIT or reads the whole table, the relation depth, and warnings such as a relation whose correlation column has no index. A query that FAILS to compile is a successful answer, not an error: an unknown column (TURBINE_E003), an unknown relation (TURBINE_E005) or the empty-where guard comes back as `ok: false` with the code, the message and how to fix it. READ OPERATIONS ONLY, deliberately: this server has no write surface and compiling one would be the first. A where or orderBy on a PII-tagged or secret-named column is refused, exactly as in explain_query.',
240
+ description: 'Compile a Turbine read query to the EXACT SQL it would send, WITHOUT running it: no statement is executed and no row is read, so this is safe to call on production and safe to call in a loop. Use it before you write the query into code. Pass `table`, an `operation` (findMany / findUnique / findFirst / count / aggregate / groupBy, default findMany) and `args`, the same object you would pass to that method. Returns the SQL, the bound parameters, how many STATEMENTS the query costs at execution (a `with` clause can be one join or one follow-up per relation), which relation-load strategy it takes, whether it is bounded by a LIMIT or reads the whole table, the relation depth, and warnings such as a relation whose correlation column has no index. A query that FAILS to compile is a successful answer, not an error: an unknown column (TURBINE_E003), an unknown relation (TURBINE_E005) or the empty-where guard comes back as `ok: false` with the code, the message and how to fix it. READ OPERATIONS ONLY, deliberately: this server has no write surface and compiling one would be the first. A where or orderBy on a PII-tagged or secret-named column is refused, exactly as in explain_query, and so is a `lock` option (on the query or on any `with` entry): compile the query without it.',
178
241
  inputSchema: {
179
242
  type: 'object',
180
243
  properties: {
@@ -753,6 +816,9 @@ async function explainQuery(ctx, args) {
753
816
  throw jsonRpcError(-32602, 'explain_query no longer accepts free-form SQL; pass table + findMany-style args (where/orderBy/limit/select)');
754
817
  }
755
818
  const tableName = requiredString(args, 'table');
819
+ // Before the allowlist below, which would otherwise drop `lock` unseen and
820
+ // plan an UNLOCKED query for a caller who asked for a locking one.
821
+ assertNoLockingRead(args, 'explain_query');
756
822
  const findManyArgs = parseExplainFindManyArgs(args);
757
823
  return withReadOnly(ctx, async (client) => {
758
824
  const { metadata, piiTags } = await loadSchemaMetadata(client, ctx.options);
@@ -789,7 +855,7 @@ async function explainQuery(ctx, args) {
789
855
  }
790
856
  /**
791
857
  * Refuse an `explain_query` that filters or sorts on a hidden column: one that
792
- * is PII-tagged, or one whose NAME matches {@link SECRET_NAME_PATTERN}.
858
+ * is PII-tagged, or one whose NAME matches {@link looksLikeSecretName}.
793
859
  *
794
860
  * The name half is not a second-best approximation of the tag half, it is the
795
861
  * same rule `sample_rows` applies, applied to the other value-bearing path.
@@ -815,8 +881,10 @@ async function explainQuery(ctx, args) {
815
881
  * What stays here is the part that is genuinely the MCP server's: the two
816
882
  * reasons a column is hidden, and a JSON-RPC refusal.
817
883
  *
818
- * `select` is NOT refused: explain returns no rows, and naming a column reveals
819
- * nothing about its contents.
884
+ * `select` is not judged HERE: explain returns no rows, and naming a column reveals
885
+ * nothing about its contents. The library itself refuses a PII-tagged column in
886
+ * `select` without `includePii: UNSAFE`, which no JSON-RPC caller can supply, so
887
+ * the builder's own E003 reaches the caller as an invalid-params error.
820
888
  *
821
889
  * FAILS CLOSED when the tag scan failed: with no trustworthy tag list, no
822
890
  * column can be shown to be safe, so every where/orderBy is refused rather than
@@ -839,7 +907,7 @@ async function explainQuery(ctx, args) {
839
907
  function hiddenColumnReason(owner, column) {
840
908
  if (owner.columns.some((col) => col.name === column && col.pii === true))
841
909
  return 'is PII-tagged';
842
- if (SECRET_NAME_PATTERN.test(column))
910
+ if (looksLikeSecretName(column))
843
911
  return 'has a secret-looking name';
844
912
  return null;
845
913
  }
@@ -854,7 +922,7 @@ function assertNoPiiPredicates(args, table, metadata, piiTags) {
854
922
  metadata,
855
923
  hiddenReason: hiddenColumnReason,
856
924
  refuseColumn: (owner, column, why) => {
857
- throw jsonRpcError(-32602, `Column "${column}" on "${owner.name}" ${why}, so it cannot be used in a where or orderBy here: ` +
925
+ throw jsonRpcError(-32602, `Column "${shown(column)}" on "${shown(owner.name)}" ${why}, so it cannot be used in a where or orderBy here: ` +
858
926
  `EXPLAIN reports the planner's row estimate, and the estimate for a predicate on a hidden value ` +
859
927
  `reveals that value one character at a time. Filter on a visible column instead.`);
860
928
  },
@@ -863,7 +931,7 @@ function assertNoPiiPredicates(args, table, metadata, piiTags) {
863
931
  `prove it does not filter or sort on a tagged column, so it is refused. Flatten the query.`);
864
932
  },
865
933
  refuseShape: (owner, key) => {
866
- throw jsonRpcError(-32602, `The PII guard does not recognize "${key}" in a query on "${owner.name}", so it cannot prove the ` +
934
+ throw jsonRpcError(-32602, `The PII guard does not recognize "${shown(key)}" in a query on "${shown(owner.name)}", so it cannot prove the ` +
867
935
  `query does not filter or sort on a hidden column, and refuses it rather than guessing. ` +
868
936
  `Remove it and explain the query without it.`);
869
937
  },
@@ -909,6 +977,10 @@ async function compileQuery(ctx, args) {
909
977
  const tableName = requiredString(args, 'table');
910
978
  const operation = parseCompileOperation(args.operation);
911
979
  const queryArgs = parseCompileArgs(args.args);
980
+ // Whatever the operation: count / aggregate / groupBy take no `lock`, and
981
+ // compiling them would drop it unseen, so the SQL shown would not be the
982
+ // query the caller wrote. Refused before the catalog read.
983
+ assertNoLockingRead(queryArgs, 'compile_query');
912
984
  const { metadata, piiTags } = await withReadOnly(ctx, (client) => loadSchemaMetadata(client, ctx.options));
913
985
  const table = requireTable(metadata, tableName);
914
986
  assertCompileHidesNothing(queryArgs, operation, table, metadata, piiTags);
@@ -986,7 +1058,7 @@ function assertCompileHidesNothing(args, operation, table, metadata, piiTags) {
986
1058
  metadata,
987
1059
  hiddenReason: hiddenColumnReason,
988
1060
  refuseColumn: (owner, column, why) => {
989
- throw jsonRpcError(-32602, `Column "${column}" on "${owner.name}" ${why}, so compile_query will not build a statement that filters, ` +
1061
+ throw jsonRpcError(-32602, `Column "${shown(column)}" on "${shown(owner.name)}" ${why}, so compile_query will not build a statement that filters, ` +
990
1062
  `sorts, pages or groups on it. This server does not author queries that interrogate a hidden value, ` +
991
1063
  `wherever that statement would eventually run. Use a visible column.`);
992
1064
  },
@@ -995,7 +1067,7 @@ function assertCompileHidesNothing(args, operation, table, metadata, piiTags) {
995
1067
  `not name a hidden column, so it is refused. Flatten the query.`);
996
1068
  },
997
1069
  refuseShape: (owner, key) => {
998
- throw jsonRpcError(-32602, `The PII guard does not recognize "${key}" in a query on "${owner.name}", so it cannot prove the query ` +
1070
+ throw jsonRpcError(-32602, `The PII guard does not recognize "${shown(key)}" in a query on "${shown(owner.name)}", so it cannot prove the query ` +
999
1071
  `does not name a hidden column, and refuses it rather than guessing. Remove it and compile without it.`);
1000
1072
  },
1001
1073
  });
@@ -1015,11 +1087,26 @@ function assertCompileHidesNothing(args, operation, table, metadata, piiTags) {
1015
1087
  const why = hiddenColumnReason(table, column);
1016
1088
  if (!why)
1017
1089
  continue;
1018
- throw jsonRpcError(-32602, `Column "${column}" on "${table.name}" ${why}, so it cannot be used as a group key, an aggregate target, a ` +
1090
+ throw jsonRpcError(-32602, `Column "${shown(column)}" on "${shown(table.name)}" ${why}, so it cannot be used as a group key, an aggregate target, a ` +
1019
1091
  `HAVING term or an ordering in compile_query. _count over the table is unaffected; group and aggregate on ` +
1020
1092
  `a visible column instead.`);
1021
1093
  }
1022
1094
  }
1095
+ /**
1096
+ * Refuse a `lock` option, at the top level or on any `with` entry, by name.
1097
+ *
1098
+ * Both tools that take query args are read-only, and a locking read takes row
1099
+ * locks, which needs a write-capable transaction in the application. The search
1100
+ * and the wording are the shared ones in `cli/pii-predicate-guard.ts`, the same
1101
+ * pair Studio's Query tab uses, so the rule is stated once. It runs on the RAW
1102
+ * args, ahead of every parser and guard that might drop or reshape the key.
1103
+ */
1104
+ function assertNoLockingRead(args, tool) {
1105
+ const locked = findRowLockOption(args);
1106
+ if (locked === null)
1107
+ return;
1108
+ throw jsonRpcError(-32602, rowLockRefusalMessage(shown(locked), tool));
1109
+ }
1023
1110
  /**
1024
1111
  * Extract the allowed findMany subset for explain_query (no `with` / raw SQL).
1025
1112
  * Returns a plain object cast at the buildFindMany call site, same pattern as Studio.
@@ -1074,7 +1161,7 @@ function classifyHiddenColumns(table, piiTags) {
1074
1161
  ? 'tags-unreadable'
1075
1162
  : col.pii === true
1076
1163
  ? 'pii'
1077
- : SECRET_NAME_PATTERN.test(col.name)
1164
+ : looksLikeSecretName(col.name)
1078
1165
  ? 'secret-name'
1079
1166
  : null;
1080
1167
  if (reason) {
@@ -1469,7 +1556,7 @@ async function findJoinPath(ctx, args) {
1469
1556
  paths,
1470
1557
  pathsTruncated: false,
1471
1558
  notes: [
1472
- `"${fromTable.name}" is both ends of this query, so no join is needed to read its own columns.`,
1559
+ `"${shown(fromTable.name)}" is both ends of this query, so no join is needed to read its own columns.`,
1473
1560
  paths.length > 0
1474
1561
  ? 'The paths below are its SELF-relations: a relation whose target is the same table, which is the only way to join it to itself.'
1475
1562
  : 'It declares no self-relation, so there is nothing to join it to itself through.',
@@ -1491,12 +1578,12 @@ async function findJoinPath(ctx, args) {
1491
1578
  pathsTruncated: false,
1492
1579
  searchExhausted: exhausted,
1493
1580
  reason: exhausted
1494
- ? `The search for a chain from "${fromTable.name}" to "${toTable.name}" hit this tool's node budget before ` +
1581
+ ? `The search for a chain from "${shown(fromTable.name)}" to "${shown(toTable.name)}" hit this tool's node budget before ` +
1495
1582
  `it finished, so this is NOT an answer that they are unconnected. Lower maxDepth (it is ${maxDepth}) to ` +
1496
- `bound the search, or call relation_graph on "${fromTable.name}" and walk it a hop at a time.`
1497
- : `No relation chain connects "${fromTable.name}" to "${toTable.name}" within ${maxDepth} hop(s). Either the ` +
1583
+ `bound the search, or call relation_graph on "${shown(fromTable.name)}" and walk it a hop at a time.`
1584
+ : `No relation chain connects "${shown(fromTable.name)}" to "${shown(toTable.name)}" within ${maxDepth} hop(s). Either the ` +
1498
1585
  `schema declares no foreign key path between them, or the path is longer than the search depth. Raise ` +
1499
- `maxDepth, or call relation_graph on "${fromTable.name}" to see what it does reach.`,
1586
+ `maxDepth, or call relation_graph on "${shown(fromTable.name)}" to see what it does reach.`,
1500
1587
  notes: exhausted
1501
1588
  ? ['The search did not complete. Do not report these tables as unconnected on the strength of this reply.']
1502
1589
  : [
@@ -2016,8 +2103,8 @@ function estimatedRowCount(stats) {
2016
2103
  function requireTable(metadata, tableName) {
2017
2104
  const table = ownLookup(metadata.tables, tableName);
2018
2105
  if (!table) {
2019
- const available = Object.keys(metadata.tables).join(', ') || '(none)';
2020
- throw jsonRpcError(-32602, `Unknown table "${tableName}". Available: ${available}`);
2106
+ const available = Object.keys(metadata.tables).map(shown).join(', ') || '(none)';
2107
+ throw jsonRpcError(-32602, `Unknown table "${shown(tableName)}". Available: ${available}`);
2021
2108
  }
2022
2109
  return table;
2023
2110
  }