turbine-orm 0.49.0 → 0.50.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 (157) hide show
  1. package/README.md +122 -39
  2. package/dist/cjs/adapters/cockroachdb.d.ts +39 -0
  3. package/dist/cjs/adapters/index.d.ts +110 -0
  4. package/dist/cjs/adapters/yugabytedb.d.ts +51 -0
  5. package/dist/cjs/cli/config.d.ts +181 -0
  6. package/dist/cjs/cli/config.js +32 -6
  7. package/dist/cjs/cli/destructive.d.ts +38 -0
  8. package/dist/cjs/cli/index.d.ts +359 -0
  9. package/dist/cjs/cli/index.js +228 -56
  10. package/dist/cjs/cli/loader.d.ts +61 -0
  11. package/dist/cjs/cli/mcp.d.ts +42 -0
  12. package/dist/cjs/cli/migrate.d.ts +356 -0
  13. package/dist/cjs/cli/migrate.js +131 -40
  14. package/dist/cjs/cli/observe-ui.d.ts +1 -0
  15. package/dist/cjs/cli/observe-ui.js +14 -5
  16. package/dist/cjs/cli/observe.d.ts +25 -0
  17. package/dist/cjs/cli/observe.js +49 -12
  18. package/dist/cjs/cli/pii-tags.d.ts +53 -0
  19. package/dist/cjs/cli/prisma-report.d.ts +33 -0
  20. package/dist/cjs/cli/prisma-report.js +73 -0
  21. package/dist/cjs/cli/prisma-resolve.d.ts +106 -0
  22. package/dist/cjs/cli/prisma-resolve.js +1 -0
  23. package/dist/cjs/cli/prisma-schema.d.ts +176 -0
  24. package/dist/cjs/cli/prisma-schema.js +82 -4
  25. package/dist/cjs/cli/rate-limit.d.ts +32 -0
  26. package/dist/cjs/cli/rate-limit.js +45 -0
  27. package/dist/cjs/cli/studio-demo.d.ts +43 -0
  28. package/dist/cjs/cli/studio-ui.generated.d.ts +1 -0
  29. package/dist/cjs/cli/studio.d.ts +207 -0
  30. package/dist/cjs/cli/studio.js +136 -71
  31. package/dist/cjs/cli/ui.d.ts +73 -0
  32. package/dist/cjs/cli/ui.js +51 -9
  33. package/dist/cjs/client.d.ts +837 -0
  34. package/dist/cjs/client.js +3 -0
  35. package/dist/cjs/dialect.d.ts +516 -0
  36. package/dist/cjs/dialect.js +37 -12
  37. package/dist/cjs/errors.d.ts +370 -0
  38. package/dist/cjs/generate.d.ts +137 -0
  39. package/dist/cjs/generate.js +39 -6
  40. package/dist/cjs/index-advisor.d.ts +153 -0
  41. package/dist/cjs/index-stats.d.ts +384 -0
  42. package/dist/cjs/index.d.ts +55 -0
  43. package/dist/cjs/index.js +7 -2
  44. package/dist/cjs/introspect.d.ts +269 -0
  45. package/dist/cjs/mssql.d.ts +232 -0
  46. package/dist/cjs/mssql.js +6 -0
  47. package/dist/cjs/mysql.d.ts +173 -0
  48. package/dist/cjs/mysql.js +16 -0
  49. package/dist/cjs/nested-write.d.ts +96 -0
  50. package/dist/cjs/nested-write.js +414 -24
  51. package/dist/cjs/observe.d.ts +115 -0
  52. package/dist/cjs/optional-peer-import.d.cts +72 -0
  53. package/dist/cjs/pipeline-submittable.d.ts +93 -0
  54. package/dist/cjs/pipeline.d.ts +71 -0
  55. package/dist/cjs/powdb-introspect.d.ts +84 -0
  56. package/dist/cjs/powdb.d.ts +931 -0
  57. package/dist/cjs/powdb.js +106 -21
  58. package/dist/cjs/powql.d.ts +592 -0
  59. package/dist/cjs/powql.js +42 -6
  60. package/dist/cjs/prisma-compat.d.ts +283 -0
  61. package/dist/cjs/prisma-compat.js +167 -9
  62. package/dist/cjs/query/aggregates.d.ts +92 -0
  63. package/dist/cjs/query/aggregates.js +7 -3
  64. package/dist/cjs/query/batched-loader.d.ts +193 -0
  65. package/dist/cjs/query/builder.d.ts +849 -0
  66. package/dist/cjs/query/builder.js +571 -65
  67. package/dist/cjs/query/compound-unique.d.ts +51 -0
  68. package/dist/cjs/query/deferred.d.ts +223 -0
  69. package/dist/cjs/query/filters.d.ts +201 -0
  70. package/dist/cjs/query/index.d.ts +14 -0
  71. package/dist/cjs/query/index.js +6 -1
  72. package/dist/cjs/query/relations.d.ts +609 -0
  73. package/dist/cjs/query/relations.js +693 -46
  74. package/dist/cjs/query/types.d.ts +1300 -0
  75. package/dist/cjs/query/utils.d.ts +209 -0
  76. package/dist/cjs/query/utils.js +208 -1
  77. package/dist/cjs/query/warn-registry.d.ts +68 -0
  78. package/dist/cjs/query/warn-registry.js +9 -0
  79. package/dist/cjs/query/where-compile.d.ts +139 -0
  80. package/dist/cjs/query/where.d.ts +548 -0
  81. package/dist/cjs/query/where.js +58 -22
  82. package/dist/cjs/query/writes.d.ts +172 -0
  83. package/dist/cjs/query/writes.js +105 -12
  84. package/dist/cjs/realtime.d.ts +70 -0
  85. package/dist/cjs/schema-builder.d.ts +354 -0
  86. package/dist/cjs/schema-metadata.d.ts +83 -0
  87. package/dist/cjs/schema-sql.d.ts +217 -0
  88. package/dist/cjs/schema-sql.js +23 -5
  89. package/dist/cjs/schema.d.ts +356 -0
  90. package/dist/cjs/schema.js +125 -0
  91. package/dist/cjs/seed.d.ts +15 -0
  92. package/dist/cjs/serverless.d.ts +142 -0
  93. package/dist/cjs/sqlite.d.ts +143 -0
  94. package/dist/cjs/sqlite.js +4 -0
  95. package/dist/cjs/typed-sql.d.ts +102 -0
  96. package/dist/cli/config.d.ts +18 -4
  97. package/dist/cli/config.js +31 -6
  98. package/dist/cli/index.d.ts +123 -0
  99. package/dist/cli/index.js +223 -58
  100. package/dist/cli/migrate.d.ts +59 -10
  101. package/dist/cli/migrate.js +128 -41
  102. package/dist/cli/observe-ui.d.ts +1 -1
  103. package/dist/cli/observe-ui.js +14 -5
  104. package/dist/cli/observe.d.ts +7 -1
  105. package/dist/cli/observe.js +48 -12
  106. package/dist/cli/prisma-report.d.ts +14 -0
  107. package/dist/cli/prisma-report.js +72 -0
  108. package/dist/cli/prisma-resolve.d.ts +6 -0
  109. package/dist/cli/prisma-resolve.js +1 -0
  110. package/dist/cli/prisma-schema.d.ts +62 -2
  111. package/dist/cli/prisma-schema.js +81 -4
  112. package/dist/cli/rate-limit.d.ts +32 -0
  113. package/dist/cli/rate-limit.js +40 -0
  114. package/dist/cli/studio.d.ts +5 -5
  115. package/dist/cli/studio.js +135 -70
  116. package/dist/cli/ui.d.ts +1 -1
  117. package/dist/cli/ui.js +51 -9
  118. package/dist/client.d.ts +40 -0
  119. package/dist/client.js +3 -0
  120. package/dist/dialect.d.ts +17 -1
  121. package/dist/dialect.js +37 -12
  122. package/dist/generate.js +40 -7
  123. package/dist/index.d.ts +1 -1
  124. package/dist/index.js +1 -1
  125. package/dist/mssql.js +6 -0
  126. package/dist/mysql.js +16 -0
  127. package/dist/nested-write.d.ts +2 -0
  128. package/dist/nested-write.js +415 -25
  129. package/dist/powdb.d.ts +4 -2
  130. package/dist/powdb.js +106 -21
  131. package/dist/powql.d.ts +5 -0
  132. package/dist/powql.js +42 -6
  133. package/dist/prisma-compat.d.ts +2 -0
  134. package/dist/prisma-compat.js +166 -8
  135. package/dist/query/aggregates.js +7 -3
  136. package/dist/query/builder.d.ts +292 -21
  137. package/dist/query/builder.js +570 -64
  138. package/dist/query/deferred.d.ts +39 -0
  139. package/dist/query/index.d.ts +1 -1
  140. package/dist/query/index.js +1 -1
  141. package/dist/query/relations.d.ts +173 -5
  142. package/dist/query/relations.js +688 -47
  143. package/dist/query/types.d.ts +123 -39
  144. package/dist/query/utils.d.ts +116 -0
  145. package/dist/query/utils.js +198 -0
  146. package/dist/query/warn-registry.d.ts +9 -0
  147. package/dist/query/warn-registry.js +9 -0
  148. package/dist/query/where.d.ts +38 -1
  149. package/dist/query/where.js +58 -23
  150. package/dist/query/writes.d.ts +42 -1
  151. package/dist/query/writes.js +104 -13
  152. package/dist/schema-sql.d.ts +14 -0
  153. package/dist/schema-sql.js +23 -5
  154. package/dist/schema.d.ts +38 -0
  155. package/dist/schema.js +123 -0
  156. package/dist/sqlite.js +4 -0
  157. package/package.json +77 -28
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  /**
3
- * turbine-orm CLI Studio
3
+ * turbine-orm CLI: Studio
4
4
  *
5
5
  * A local web UI for browsing databases, exploring relations, and composing
6
6
  * queries visually. ORM-native since v0.19: there is no raw-SQL input surface.
@@ -65,7 +65,11 @@ const pg_1 = __importDefault(require("pg"));
65
65
  const errors_js_1 = require("../errors.js");
66
66
  const introspect_js_1 = require("../introspect.js");
67
67
  const index_js_1 = require("../query/index.js");
68
+ // `ownLookup` is not re-exported from the query barrel, so it is imported from
69
+ // its defining leaf module rather than duplicated here.
70
+ const utils_js_1 = require("../query/utils.js");
68
71
  const pii_tags_js_1 = require("./pii-tags.js");
72
+ const rate_limit_js_1 = require("./rate-limit.js");
69
73
  const studio_demo_js_1 = require("./studio-demo.js");
70
74
  const studio_ui_generated_js_1 = require("./studio-ui.generated.js");
71
75
  // ---------------------------------------------------------------------------
@@ -103,10 +107,10 @@ async function startStudio(options) {
103
107
  // seam in client.ts); the cast keeps one typed pool field for both modes.
104
108
  pool = new pg_1.default.Pool({
105
109
  connectionString: options.url,
106
- max: 4, // small pool single-user tool
110
+ max: 4, // small pool, single-user tool
107
111
  idleTimeoutMillis: 10_000,
108
112
  });
109
- // Verify connectivity before starting the server fail fast.
113
+ // Verify connectivity before starting the server, fail fast.
110
114
  const probe = await pool.connect();
111
115
  try {
112
116
  await probe.query('SELECT 1');
@@ -201,7 +205,7 @@ function originFor(host, port) {
201
205
  // ---------------------------------------------------------------------------
202
206
  async function handleRequest(req, res, ctx) {
203
207
  const expectedOrigin = originFor(ctx.options.host, ctx.options.port);
204
- // CORS: not needed same-origin only. Explicitly refuse cross-origin.
208
+ // CORS: not needed, same-origin only. Explicitly refuse cross-origin.
205
209
  const origin = req.headers.origin;
206
210
  if (origin && origin !== expectedOrigin) {
207
211
  sendJson(res, 403, { error: 'cross-origin requests not allowed' });
@@ -230,26 +234,32 @@ async function handleRequest(req, res, ctx) {
230
234
  sendHtml(res, 200, studio_ui_generated_js_1.STUDIO_HTML, cspNonce());
231
235
  return;
232
236
  }
233
- // Favicon answered before the auth gate so the browser's automatic request
237
+ // Favicon: answered before the auth gate so the browser's automatic request
234
238
  // doesn't 401/404 on every load. No icon body needed (204).
235
239
  if (pathname === '/favicon.ico') {
236
240
  res.writeHead(204, { 'Content-Length': '0' });
237
241
  res.end();
238
242
  return;
239
243
  }
240
- // API routes all require auth.
241
- if (!isAuthorized(req, ctx.authToken)) {
242
- sendJson(res, 401, { error: 'unauthorized use the URL printed in the terminal' });
243
- return;
244
- }
245
- // Rate limiting 100 requests per 60 seconds per authenticated session.
246
- const rateLimitResult = checkRateLimit(ctx.rateLimiter, ctx.authToken);
244
+ // Rate limiting: 100 requests per 60 seconds, applied BEFORE the auth gate so
245
+ // an unauthenticated caller is throttled too (it used to run after, leaving
246
+ // token guessing unmetered). Keyed per caller rather than on `ctx.authToken`:
247
+ // that token is a single constant, so keying on it made one global bucket that
248
+ // every session shared. Authenticated and unauthenticated callers get separate
249
+ // buckets so a probing client cannot spend the real session's budget.
250
+ const authorized = isAuthorized(req, ctx.authToken);
251
+ const rateLimitResult = (0, rate_limit_js_1.checkRateLimit)(ctx.rateLimiter, `${authorized ? 'session' : 'anon'}:${(0, rate_limit_js_1.callerKey)(req)}`);
247
252
  if (!rateLimitResult.allowed) {
248
253
  const retryAfter = Math.ceil((rateLimitResult.resetAt - Date.now()) / 1000);
249
254
  res.setHeader('Retry-After', String(retryAfter));
250
255
  sendJson(res, 429, { error: 'Rate limit exceeded', retryAfter });
251
256
  return;
252
257
  }
258
+ // API routes: all require auth.
259
+ if (!authorized) {
260
+ sendJson(res, 401, { error: 'unauthorized: use the URL printed in the terminal' });
261
+ return;
262
+ }
253
263
  if (pathname === '/api/schema' && req.method === 'GET') {
254
264
  return apiSchema(res, ctx);
255
265
  }
@@ -332,32 +342,15 @@ function isAuthorized(req, expectedToken) {
332
342
  return true;
333
343
  }
334
344
  const cookieHeader = req.headers.cookie ?? '';
335
- const match = /turbine_studio_token=([a-f0-9]+)/.exec(cookieHeader);
345
+ // Anchored on a cookie boundary: unanchored, a decoy cookie named
346
+ // `x_turbine_studio_token=...` matched first and the real cookie was never
347
+ // compared, locking the legitimate session out of its own Studio.
348
+ const match = /(?:^|;\s*)turbine_studio_token=([a-f0-9]+)/.exec(cookieHeader);
336
349
  if (match?.[1] && constantTimeEqual(match[1], expectedToken)) {
337
350
  return true;
338
351
  }
339
352
  return false;
340
353
  }
341
- // ---------------------------------------------------------------------------
342
- // Rate limiting
343
- // ---------------------------------------------------------------------------
344
- const RATE_LIMIT_WINDOW_MS = 60_000; // 60 seconds
345
- const RATE_LIMIT_MAX_REQUESTS = 100;
346
- function checkRateLimit(limiter, token) {
347
- const now = Date.now();
348
- const entry = limiter.get(token);
349
- if (!entry || now >= entry.resetAt) {
350
- // Start a new window
351
- const resetAt = now + RATE_LIMIT_WINDOW_MS;
352
- limiter.set(token, { count: 1, resetAt });
353
- return { allowed: true, resetAt };
354
- }
355
- entry.count++;
356
- if (entry.count > RATE_LIMIT_MAX_REQUESTS) {
357
- return { allowed: false, resetAt: entry.resetAt };
358
- }
359
- return { allowed: true, resetAt: entry.resetAt };
360
- }
361
354
  function constantTimeEqual(a, b) {
362
355
  // Hash both inputs to fixed-length 32-byte SHA-256 digests before comparing.
363
356
  // This makes the comparison constant-length (timingSafeEqual never throws on a
@@ -437,10 +430,6 @@ async function apiSchema(res, ctx) {
437
430
  demo: ctx.demo === true,
438
431
  });
439
432
  }
440
- /** Own-property lookup: a key like `constructor` must not resolve off the prototype. */
441
- function ownLookup(map, key) {
442
- return Object.hasOwn(map, key) ? map[key] : undefined;
443
- }
444
433
  /** True when `columnName` on `table` is PII-tagged and currently redacted. */
445
434
  function isRedactedColumn(table, columnName, showPii) {
446
435
  if (showPii)
@@ -526,7 +515,9 @@ function relationLinksForTable(table, metadata, showPii) {
526
515
  // API: /api/tables/:name?limit=&offset=&orderBy=&dir=
527
516
  // ---------------------------------------------------------------------------
528
517
  async function apiTableRows(res, ctx, rawTableName, params) {
529
- const table = ctx.metadata.tables[rawTableName];
518
+ // Own-property lookup: `/api/tables/constructor` must 404 like any other
519
+ // unknown table rather than resolving Object.prototype and crashing.
520
+ const table = (0, utils_js_1.ownLookup)(ctx.metadata.tables, rawTableName);
530
521
  if (!table) {
531
522
  sendJson(res, 404, { error: unknownTableMessage(rawTableName, ctx) });
532
523
  return;
@@ -535,7 +526,7 @@ async function apiTableRows(res, ctx, rawTableName, params) {
535
526
  const offset = clampInt(params.get('offset'), 0, 0, 10_000_000);
536
527
  const orderByRaw = params.get('orderBy');
537
528
  const dir = params.get('dir')?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
538
- // orderBy accept either the Postgres column name (snake) or the TS field
529
+ // orderBy: accept either the Postgres column name (snake) or the TS field
539
530
  // name (camel). Always emit the Postgres column in the SQL.
540
531
  // When redaction is on, PII columns are excluded from orderBy (and from the
541
532
  // search OR-set below): a redacted value must not be inferable through sort
@@ -562,7 +553,7 @@ async function apiTableRows(res, ctx, rawTableName, params) {
562
553
  // Per-column filters: `filters` is a JSON array of { column, op, value }
563
554
  // composed by the Data tab's filter bar. Every column is validated against
564
555
  // the metadata, every op against a fixed whitelist, and every value is a
565
- // parameter same discipline as the builder route.
556
+ // parameter, same discipline as the builder route.
566
557
  let filters;
567
558
  try {
568
559
  filters = parseTableFilters(params.get('filters'), table, redactedPii);
@@ -701,7 +692,7 @@ const FILTER_OPS = {
701
692
  lte: '<=',
702
693
  };
703
694
  const FILTER_OP_NAMES = new Set([...Object.keys(FILTER_OPS), 'contains', 'isNull', 'notNull']);
704
- /** Hard cap on filter clauses per request the UI never composes more. */
695
+ /** Hard cap on filter clauses per request; the UI never composes more. */
705
696
  const MAX_TABLE_FILTERS = 10;
706
697
  /**
707
698
  * Parse + validate the Data tab's `filters` query param. Throws with a clear
@@ -761,53 +752,127 @@ function parseTableFilters(raw, table, redactedPii) {
761
752
  return out;
762
753
  }
763
754
  // ---------------------------------------------------------------------------
764
- // API: /api/builder Turbine ORM findMany spec runner
755
+ // API: /api/builder: Turbine ORM findMany spec runner
765
756
  // ---------------------------------------------------------------------------
757
+ /** Relation-filter wrappers whose body is a clause against the relation's target. */
758
+ const RELATION_FILTER_WRAPPERS = ['some', 'none', 'every', 'is', 'isNot'];
759
+ /**
760
+ * Recursion bound for the PII guard walk.
761
+ *
762
+ * This number is NOT the security boundary: reaching it REFUSES the request
763
+ * (see `assertWithinDepth`). It only bounds the walk on a pathological payload.
764
+ * It sits well above the query builder's own depth-10 relation cap
765
+ * (`CircularRelationError`) and far above any hand-composed boolean nesting, so
766
+ * nothing the builder would accept is refused here for depth alone.
767
+ */
768
+ const PII_GUARD_MAX_DEPTH = 32;
766
769
  /**
767
- * Refuse a builder query that FILTERS or SORTS on a redacted PII column.
770
+ * Refuse a builder query that FILTERS, SORTS, PAGES, or DE-DUPLICATES on a
771
+ * redacted PII column.
768
772
  *
769
773
  * Redacting the cells is not enough on its own: `where: { email: { startsWith:
770
774
  * 'a' } }` answers a question about the hidden value, and so does an `isNull`,
771
- * and so does an `orderBy`. The Data tab already refuses all three
772
- * (`parseTableFilters`); the builder route accepted them, which mattered the
773
- * moment PII tags actually started reaching Studio's metadata.
775
+ * and so does an `orderBy`. So does a `cursor`, which compiles into a WHERE
776
+ * range comparison (`"email" > $1`) and gives a clean binary search over the
777
+ * byte range even though every returned cell is redacted. So, more weakly, does
778
+ * `distinct`, which reports the cardinality of the hidden values. The Data tab
779
+ * already refuses its own equivalents (`parseTableFilters`); the builder route
780
+ * has to refuse all of them too.
781
+ *
782
+ * Walks the whole args tree: `where`, `orderBy` (object AND array form),
783
+ * `cursor`, `distinct`, boolean combinators, relation filters, and each `with`
784
+ * level against that relation's target table. `select` / `omit` are NOT
785
+ * refused: they return values, and those values are redacted on the way out.
774
786
  *
775
- * Walks the whole args tree: top-level `where` / `orderBy`, boolean
776
- * combinators, and each `with` level against that relation's target table.
777
- * `select` is NOT refused: it returns values, and those values are redacted on
778
- * the way out.
787
+ * Every depth check FAILS CLOSED. Returning quietly at the cap (what this used
788
+ * to do) meant padding a payload with, for example, eleven nested `NOT`
789
+ * wrappers walked the guard off the end of its own recursion and then handed
790
+ * the untouched predicate to the builder.
779
791
  */
780
792
  function assertNoPiiPredicates(args, tableName, metadata, showPii) {
781
793
  if (showPii)
782
794
  return;
795
+ const assertWithinDepth = (depth) => {
796
+ if (depth <= PII_GUARD_MAX_DEPTH)
797
+ return;
798
+ throw new errors_js_1.ValidationError(`[turbine] Query is nested more than ${PII_GUARD_MAX_DEPTH} levels deep, which is past the point where ` +
799
+ `Studio can prove it does not filter or sort on a PII-tagged and redacted column, so it is refused. ` +
800
+ `Flatten the query, or restart Studio with --show-pii.`);
801
+ };
802
+ const refuse = (table, column) => {
803
+ throw new errors_js_1.ValidationError(`[turbine] Column "${column}" on "${table.name}" is PII-tagged and redacted, so it cannot be used ` +
804
+ `in a where, orderBy, cursor, or distinct: filtering, sorting, paging, or de-duplicating on a hidden ` +
805
+ `value reveals it. Restart Studio with --show-pii to query it.`);
806
+ };
783
807
  const visitClause = (node, table, depth) => {
784
- if (!table || depth > 10 || node === null || typeof node !== 'object')
808
+ assertWithinDepth(depth);
809
+ if (!table || node === null || typeof node !== 'object')
810
+ return;
811
+ // `orderBy` accepts a Prisma-style array of single-key objects, and so does
812
+ // a `NOT` list. Element order carries no nesting, so the depth is unchanged.
813
+ if (Array.isArray(node)) {
814
+ for (const item of node)
815
+ visitClause(item, table, depth);
785
816
  return;
817
+ }
786
818
  for (const [key, value] of Object.entries(node)) {
787
819
  if (key === 'AND' || key === 'OR' || key === 'NOT') {
788
- for (const item of Array.isArray(value) ? value : [value])
789
- visitClause(item, table, depth + 1);
820
+ visitClause(value, table, depth + 1);
790
821
  continue;
791
822
  }
792
823
  const relation = Object.hasOwn(table.relations, key) ? table.relations[key] : undefined;
793
824
  if (relation) {
794
- // some / none / every / is / isNot wrappers all resolve against the target.
795
- visitClause(value, metadata.tables[relation.to], depth + 1);
825
+ visitRelationValue(value, metadata.tables[relation.to], depth + 1);
796
826
  continue;
797
827
  }
798
- const column = ownLookup(table.columnMap, key) ?? key;
799
- if (isRedactedColumn(table, column, showPii)) {
800
- throw new errors_js_1.ValidationError(`[turbine] Column "${column}" on "${table.name}" is PII-tagged and redacted, so it cannot be used ` +
801
- `in a where or orderBy: filtering or sorting on a hidden value reveals it. ` +
802
- `Restart Studio with --show-pii to query it.`);
803
- }
828
+ const column = (0, utils_js_1.ownLookup)(table.columnMap, key) ?? key;
829
+ if (isRedactedColumn(table, column, showPii))
830
+ refuse(table, column);
831
+ }
832
+ };
833
+ /**
834
+ * A relation predicate arrives in one of two shapes, and BOTH resolve against
835
+ * the relation's target table: bare (`{ user: { email: {...} } }`) or wrapped
836
+ * in a cardinality / to-one operator (`{ user: { is: { email: {...} } } }`,
837
+ * likewise `some` / `none` / `every` / `isNot`). Handing the wrapper straight
838
+ * to `visitClause` walked its keys as if `is` were a column of the target, so
839
+ * the inner clause was never visited and a PII predicate slipped through.
840
+ * Descend into every wrapper member AND into the value itself.
841
+ */
842
+ const visitRelationValue = (value, target, depth) => {
843
+ assertWithinDepth(depth);
844
+ if (!target || value === null || typeof value !== 'object')
845
+ return;
846
+ const node = value;
847
+ for (const wrapper of RELATION_FILTER_WRAPPERS) {
848
+ if (Object.hasOwn(node, wrapper))
849
+ visitClause(node[wrapper], target, depth + 1);
850
+ }
851
+ visitClause(node, target, depth);
852
+ };
853
+ /** Field-name lists (`distinct`) name columns directly rather than in a clause. */
854
+ const visitFieldList = (value, table) => {
855
+ if (!Array.isArray(value))
856
+ return;
857
+ for (const field of value) {
858
+ if (typeof field !== 'string')
859
+ continue;
860
+ const column = (0, utils_js_1.ownLookup)(table.columnMap, field) ?? field;
861
+ if (isRedactedColumn(table, column, showPii))
862
+ refuse(table, column);
804
863
  }
805
864
  };
806
865
  const visitLevel = (level, table, depth) => {
807
- if (!table || depth > 10)
866
+ assertWithinDepth(depth);
867
+ if (!table)
808
868
  return;
809
869
  visitClause(level.where, table, depth);
810
870
  visitClause(level.orderBy, table, depth);
871
+ // `cursor` is a flat `{ field: value }` seek key that the builder turns into
872
+ // a WHERE range comparison against the sort key, so it reads exactly like a
873
+ // where on the same column.
874
+ visitClause(level.cursor, table, depth);
875
+ visitFieldList(level.distinct, table);
811
876
  const withClause = level.with;
812
877
  if (!withClause || typeof withClause !== 'object')
813
878
  return;
@@ -824,7 +889,7 @@ async function apiBuilder(req, res, ctx) {
824
889
  const body = await readJsonBody(req);
825
890
  const tableName = typeof body?.table === 'string' ? body.table : '';
826
891
  const args = (body?.args ?? {});
827
- if (!tableName || !ctx.metadata.tables[tableName]) {
892
+ if (!tableName || !(0, utils_js_1.ownLookup)(ctx.metadata.tables, tableName)) {
828
893
  sendJson(res, 400, { error: unknownTableMessage(tableName, ctx) });
829
894
  return;
830
895
  }
@@ -903,18 +968,18 @@ async function apiBuilder(req, res, ctx) {
903
968
  // from those PK values alone, so a statement can only ever touch one row.
904
969
  //
905
970
  // Bulk form (insert/delete only): pass `rows: [...]` instead of `data`/`where`
906
- // an array of data objects (insert) or PK-where objects (delete). Each entry
971
+ // (an array of data objects for insert, or PK-where objects for delete). Each entry
907
972
  // goes through the exact same per-row validation and compiles to its own
908
973
  // single-row statement; all statements run in ONE transaction (all-or-nothing,
909
974
  // capped at MAX_BULK_ROWS). Predicate-based bulk writes stay deliberately
910
- // unsupported every row is still addressed by its full primary key.
975
+ // unsupported: every row is still addressed by its full primary key.
911
976
  // ---------------------------------------------------------------------------
912
977
  /** Hard cap on rows per bulk insert/delete request (matches the max page size). */
913
978
  const MAX_BULK_ROWS = 500;
914
979
  async function apiRowWrite(req, res, ctx, op) {
915
980
  const body = await readJsonBody(req);
916
981
  const tableName = typeof body?.table === 'string' ? body.table : '';
917
- const table = ctx.metadata.tables[tableName];
982
+ const table = (0, utils_js_1.ownLookup)(ctx.metadata.tables, tableName);
918
983
  if (!table) {
919
984
  sendJson(res, 400, { error: unknownTableMessage(tableName, ctx) });
920
985
  return;
@@ -1138,7 +1203,7 @@ function savedQueriesPath(ctx) {
1138
1203
  /** One-shot flag so the legacy saved-query notice isn't logged on every request. */
1139
1204
  let legacyDropNoticeShown = false;
1140
1205
  function loadSavedQueries(ctx) {
1141
- // Demo mode: in-memory only never read the user's real saved-query file.
1206
+ // Demo mode: in-memory only, never read the user's real saved-query file.
1142
1207
  if (ctx.demo) {
1143
1208
  if (!ctx.memorySavedQueries)
1144
1209
  ctx.memorySavedQueries = { version: 1, queries: [] };
@@ -1152,14 +1217,14 @@ function loadSavedQueries(ctx) {
1152
1217
  const parsed = JSON.parse(raw);
1153
1218
  if (!parsed.queries || !Array.isArray(parsed.queries))
1154
1219
  return { version: 1, queries: [] };
1155
- // Drop any legacy raw-SQL entries Studio is builder-only now. Tell the
1220
+ // Drop any legacy raw-SQL entries: Studio is builder-only now. Tell the
1156
1221
  // user instead of silently discarding their saved work (the file on disk
1157
1222
  // is only rewritten when a new query is saved, so this is recoverable).
1158
1223
  const queries = parsed.queries.filter((q) => q && q.kind === 'builder');
1159
1224
  const dropped = parsed.queries.length - queries.length;
1160
1225
  if (dropped > 0 && !legacyDropNoticeShown) {
1161
1226
  legacyDropNoticeShown = true;
1162
- console.warn(`[turbine studio] Ignoring ${dropped} legacy raw-SQL saved quer${dropped === 1 ? 'y' : 'ies'} in ${file} ` +
1227
+ console.warn(`[turbine studio] Ignoring ${dropped} legacy raw-SQL saved quer${dropped === 1 ? 'y' : 'ies'} in ${file}. ` +
1163
1228
  'Studio is builder-only since v0.19. The entries remain in the file until a new query is saved.');
1164
1229
  }
1165
1230
  return { version: 1, queries };
@@ -1169,7 +1234,7 @@ function loadSavedQueries(ctx) {
1169
1234
  }
1170
1235
  }
1171
1236
  function writeSavedQueries(ctx, data) {
1172
- // Demo mode: in-memory only nothing is ever written to disk.
1237
+ // Demo mode: in-memory only, nothing is ever written to disk.
1173
1238
  if (ctx.demo) {
1174
1239
  ctx.memorySavedQueries = data;
1175
1240
  return;
@@ -1471,6 +1536,6 @@ function openUrl(url) {
1471
1536
  child.unref();
1472
1537
  }
1473
1538
  catch {
1474
- // Non-fatal user can click the URL manually.
1539
+ // Non-fatal: user can click the URL manually.
1475
1540
  }
1476
1541
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * turbine-orm CLI — UI utilities
3
+ *
4
+ * ANSI colors, spinners, box-drawing, and formatting helpers.
5
+ * Zero dependencies — raw escape codes only.
6
+ */
7
+ export declare const bold: (s: string) => string;
8
+ export declare const dim: (s: string) => string;
9
+ export declare const italic: (s: string) => string;
10
+ export declare const underline: (s: string) => string;
11
+ export declare const red: (s: string) => string;
12
+ export declare const green: (s: string) => string;
13
+ export declare const yellow: (s: string) => string;
14
+ export declare const blue: (s: string) => string;
15
+ export declare const magenta: (s: string) => string;
16
+ export declare const cyan: (s: string) => string;
17
+ export declare const white: (s: string) => string;
18
+ export declare const gray: (s: string) => string;
19
+ export declare const greenBright: (s: string) => string;
20
+ export declare const cyanBright: (s: string) => string;
21
+ export declare const yellowBright: (s: string) => string;
22
+ export declare const redBright: (s: string) => string;
23
+ export declare const bgGreen: (s: string) => string;
24
+ export declare const bgRed: (s: string) => string;
25
+ export declare const bgYellow: (s: string) => string;
26
+ export declare const bgCyan: (s: string) => string;
27
+ export declare const symbols: {
28
+ readonly check: "v" | "✓";
29
+ readonly cross: "✗" | "x";
30
+ readonly bullet: "*" | "•";
31
+ readonly arrow: "→" | "->";
32
+ readonly arrowRight: ">" | "▸";
33
+ readonly info: "i" | "ℹ";
34
+ readonly warning: "⚠" | "!";
35
+ readonly dot: "." | "∙";
36
+ readonly line: "-" | "─";
37
+ readonly vertLine: "|" | "│";
38
+ readonly topLeft: "+" | "╭";
39
+ readonly topRight: "+" | "╮";
40
+ readonly bottomLeft: "+" | "╰";
41
+ readonly bottomRight: "+" | "╯";
42
+ readonly tee: "|" | "├";
43
+ readonly teeEnd: "\\" | "└";
44
+ };
45
+ export declare function box(content: string, options?: {
46
+ title?: string;
47
+ padding?: number;
48
+ }): string;
49
+ export declare function table(headers: string[], rows: string[][]): string;
50
+ export declare class Spinner {
51
+ private frames;
52
+ private frameIndex;
53
+ private interval;
54
+ private message;
55
+ constructor(message: string);
56
+ start(): this;
57
+ succeed(msg?: string): void;
58
+ fail(msg?: string): void;
59
+ info(msg?: string): void;
60
+ stop(): void;
61
+ }
62
+ export declare function header(text: string): void;
63
+ export declare function success(msg: string): void;
64
+ export declare function error(msg: string): void;
65
+ export declare function warn(msg: string): void;
66
+ export declare function info(msg: string): void;
67
+ export declare function label(key: string, value: string): void;
68
+ export declare function newline(): void;
69
+ export declare function divider(): void;
70
+ export declare function banner(): void;
71
+ export declare function elapsed(startMs: number): string;
72
+ export declare function stripAnsi(s: string): string;
73
+ export declare function redactUrl(url: string): string;
@@ -229,16 +229,58 @@ function stripAnsi(s) {
229
229
  // Redact password from connection URL
230
230
  // ---------------------------------------------------------------------------
231
231
  function redactUrl(url) {
232
- return (url
233
- // Userinfo credentials. Anchored on `<scheme>://<user>:` and consuming up
234
- // to the LAST `@` before the next `/` (or end of authority), because a
235
- // password may legally contain `:`, `/`, and even `@` in percent-decoded
236
- // form. The previous `:([^@/:]+)@` could not span any of those, so
237
- // `postgres://u:pa/ss@host/db` came through completely unredacted and
238
- // `postgres://u:a@b@host/db` leaked the tail. Global: one string may
239
- // carry several URLs (a primary + replica pair).
240
- .replace(/(\w+:\/\/[^/@\s]*?:)[^\s]*?@(?=[^@\s]*(?:[/?#]|$))/g, '$1***@')
232
+ return (redactUserinfo(url)
241
233
  // Query-string password params: `password=`, `sslpassword=`, and similar,
242
234
  // case-insensitive. Value runs up to the next `&`, `#`, or end of string.
243
235
  .replace(/([?&][^=&#]*password)=([^&#]*)/gi, '$1=***'));
244
236
  }
237
+ /**
238
+ * Replace `<scheme>://<user>:<password>@` with `<scheme>://<user>:***@`, for
239
+ * every occurrence in the string (one message may carry a primary + replica
240
+ * pair). A password may legally contain `:`, `/`, and even `@` in
241
+ * percent-decoded form, so the credential runs to the LAST `@` of the authority.
242
+ *
243
+ * Deliberately a single forward scan rather than one regex: the previous
244
+ * `(\w+:\/\/[^/@\s]*?:)[^\s]*?@(?=[^@\s]*(?:[/?#]|$))` nested two lazy
245
+ * quantifiers under a lookahead and backtracked quadratically on input with no
246
+ * `@` at all (a 60 KB error message took ~16s). `redactUrl` runs on error text
247
+ * echoed back from Postgres, so that stalled the CLI. This version visits every
248
+ * character at most once.
249
+ */
250
+ function redactUserinfo(input) {
251
+ const scheme = /\w+:\/\//g;
252
+ let out = '';
253
+ let copied = 0;
254
+ for (let m = scheme.exec(input); m; m = scheme.exec(input)) {
255
+ const start = m.index + m[0].length;
256
+ // Scan the run of non-whitespace characters that could hold userinfo,
257
+ // remembering the last `@`. A `/`, `?`, or `#` only ends the run once an `@`
258
+ // has been seen, so a password containing one is still covered; a URL with
259
+ // no credentials simply reaches the end of the run with no `@` recorded.
260
+ let i = start;
261
+ let lastAt = -1;
262
+ for (; i < input.length; i++) {
263
+ const ch = input.charAt(i);
264
+ if (/\s/.test(ch))
265
+ break;
266
+ if (ch === '@') {
267
+ lastAt = i;
268
+ }
269
+ else if (lastAt !== -1 && (ch === '/' || ch === '?' || ch === '#')) {
270
+ break;
271
+ }
272
+ }
273
+ if (lastAt !== -1) {
274
+ const colon = input.slice(start, lastAt).indexOf(':');
275
+ if (colon !== -1) {
276
+ out += `${input.slice(copied, start + colon + 1)}***@`;
277
+ copied = lastAt + 1;
278
+ }
279
+ }
280
+ // Resume after the scanned run. Every character in it has been accounted
281
+ // for, and a credential cannot span whitespace, so nothing is missed and the
282
+ // total work stays linear however many `scheme://` prefixes the input holds.
283
+ scheme.lastIndex = Math.max(i, start);
284
+ }
285
+ return copied === 0 ? input : out + input.slice(copied);
286
+ }