turbine-orm 0.55.0 → 0.57.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 (52) hide show
  1. package/README.md +16 -1
  2. package/dist/cjs/cli/index.d.ts +3 -1
  3. package/dist/cjs/cli/index.js +341 -14
  4. package/dist/cjs/client.d.ts +16 -1
  5. package/dist/cjs/client.js +7 -39
  6. package/dist/cjs/dialect.d.ts +9 -0
  7. package/dist/cjs/index-stats.d.ts +46 -0
  8. package/dist/cjs/index-stats.js +42 -1
  9. package/dist/cjs/plan-divergence.d.ts +511 -0
  10. package/dist/cjs/plan-divergence.js +790 -0
  11. package/dist/cjs/powql.d.ts +11 -0
  12. package/dist/cjs/powql.js +22 -0
  13. package/dist/cjs/prisma-compat.d.ts +32 -1
  14. package/dist/cjs/prisma-compat.js +297 -41
  15. package/dist/cjs/query/builder.d.ts +45 -0
  16. package/dist/cjs/query/builder.js +90 -17
  17. package/dist/cjs/query/deferred.d.ts +9 -0
  18. package/dist/cjs/query/index.d.ts +2 -0
  19. package/dist/cjs/query/index.js +18 -1
  20. package/dist/cjs/query/option-surface.d.ts +100 -0
  21. package/dist/cjs/query/option-surface.js +214 -0
  22. package/dist/cjs/query/types.d.ts +140 -0
  23. package/dist/cjs/query/utils.d.ts +30 -0
  24. package/dist/cjs/query/utils.js +67 -3
  25. package/dist/cjs/query/warn-registry.d.ts +8 -0
  26. package/dist/cjs/query/warn-registry.js +8 -0
  27. package/dist/cli/index.d.ts +3 -1
  28. package/dist/cli/index.js +341 -14
  29. package/dist/client.d.ts +16 -1
  30. package/dist/client.js +8 -40
  31. package/dist/dialect.d.ts +9 -0
  32. package/dist/index-stats.d.ts +46 -0
  33. package/dist/index-stats.js +42 -1
  34. package/dist/plan-divergence.d.ts +511 -0
  35. package/dist/plan-divergence.js +783 -0
  36. package/dist/powql.d.ts +11 -0
  37. package/dist/powql.js +22 -0
  38. package/dist/prisma-compat.d.ts +32 -1
  39. package/dist/prisma-compat.js +297 -41
  40. package/dist/query/builder.d.ts +45 -0
  41. package/dist/query/builder.js +90 -17
  42. package/dist/query/deferred.d.ts +9 -0
  43. package/dist/query/index.d.ts +2 -0
  44. package/dist/query/index.js +1 -0
  45. package/dist/query/option-surface.d.ts +100 -0
  46. package/dist/query/option-surface.js +209 -0
  47. package/dist/query/types.d.ts +140 -0
  48. package/dist/query/utils.d.ts +30 -0
  49. package/dist/query/utils.js +66 -3
  50. package/dist/query/warn-registry.d.ts +8 -0
  51. package/dist/query/warn-registry.js +8 -0
  52. package/package.json +1 -1
@@ -148,50 +148,14 @@ const CONFIG_KEY_SET = new Set(Object.keys(TURBINE_CONFIG_KEYS));
148
148
  * engine factories' first argument. Same story as `schema`.
149
149
  */
150
150
  const NON_CONFIG_KEYS = new Set(['queryInterfaceFactory', 'schema', 'url']);
151
- /** camelCase name → its lowercased words (`logQueryParams` → log, query, params). */
152
- function camelWords(name) {
153
- return name
154
- .split(/(?=[A-Z])/)
155
- .map((w) => w.toLowerCase())
156
- .filter(Boolean);
157
- }
158
151
  /**
159
152
  * The real config key `key` most likely meant, or null when nothing is close.
160
153
  *
161
- * {@link closestName} (the same helper the unknown-COLUMN message uses) decides
162
- * first, so both diagnostics rank near-misses identically. It is bounded by edit
163
- * distance, which covers typos but not the miss this warning exists for: a
164
- * guessed name that omits a whole word. `logParams` is five edits from
165
- * `logQueryParams`, past the bound, yet it names the same words in the same
166
- * order, so a second pass accepts a candidate whose camelCase words CONTAIN the
167
- * guess's words in order, preferring the one that adds fewest words.
154
+ * {@link suggestKey} is shared with the prisma-compat query-option warner, so
155
+ * both diagnostics rank near-misses identically.
168
156
  */
169
157
  function suggestConfigKey(key) {
170
- const direct = (0, utils_js_1.closestName)(key, CONFIG_KEY_SET);
171
- if (direct)
172
- return direct;
173
- const wanted = camelWords(key);
174
- if (wanted.length < 2)
175
- return null;
176
- let best = null;
177
- let bestExtra = Number.POSITIVE_INFINITY;
178
- for (const candidate of CONFIG_KEY_SET) {
179
- const words = camelWords(candidate);
180
- if (words.length <= wanted.length)
181
- continue;
182
- let i = 0;
183
- for (const w of words)
184
- if (w === wanted[i])
185
- i++;
186
- if (i !== wanted.length)
187
- continue;
188
- const extra = words.length - wanted.length;
189
- if (extra < bestExtra) {
190
- bestExtra = extra;
191
- best = candidate;
192
- }
193
- }
194
- return best;
158
+ return (0, utils_js_1.suggestKey)(key, CONFIG_KEY_SET);
195
159
  }
196
160
  /**
197
161
  * Dev-mode notice for a key on the config object that is not part of the config
@@ -658,6 +622,10 @@ class TurbineClient {
658
622
  jsonEncoding: config.jsonEncoding,
659
623
  globalFilters: config.globalFilters,
660
624
  preparedStatements: envDisablePrepared ? false : (config.preparedStatements ?? !config.pool),
625
+ // Forwarded so a per-query `forceCustomPlan` can refuse the combination
626
+ // this client's own connections would silently defeat, see the
627
+ // `planCacheMode` note on QueryInterfaceOptions.
628
+ planCacheMode,
661
629
  sqlCache: config.sqlCache ?? true,
662
630
  sqlCacheSize: config.sqlCacheSize,
663
631
  dialect: config.dialect,
@@ -375,6 +375,15 @@ export interface Dialect {
375
375
  * setting (`SET plan_cache_mode = auto | force_custom_plan |
376
376
  * force_generic_plan`). Gates the opt-in `planCacheMode` client option.
377
377
  *
378
+ * It ALSO gates the per-query `forceCustomPlan` read option, which uses a
379
+ * different mechanism (it withholds the prepared-statement name, so the
380
+ * driver re-parses the statement on every execution and the counter that
381
+ * generic-plan promotion depends on never reaches its threshold) but
382
+ * asks the identical capability question: does this engine have a PostgreSQL
383
+ * plan cache whose generic-plan promotion is worth pinning? An engine that
384
+ * answers no cannot honour either option, so both refuse on the same flag
385
+ * rather than on two flags that could never disagree.
386
+ *
378
387
  * A capability flag rather than a `dialect.name === 'postgresql'` test, for
379
388
  * the same reason every other refusal here is one: the setting is a property
380
389
  * of the PostgreSQL PLAN CACHE, not of the SQL string, so a
@@ -86,6 +86,12 @@ export interface TableStats {
86
86
  table: string;
87
87
  /** pg_class.reltuples. 0 or -1 means never-analyzed → treated as UNKNOWN (null rows). */
88
88
  reltuples: number;
89
+ /**
90
+ * pg_class.relpages. The SIZE input the plan-divergence crossover is computed
91
+ * from (a plan boundary tracks pages relative to the LIMIT, not rows).
92
+ * Absent when the pg_class read degraded; 0 means never analyzed.
93
+ */
94
+ relpages?: number;
89
95
  /** pg_stat_user_tables.n_live_tup, a cross-check for reltuples. */
90
96
  nLiveTup?: number;
91
97
  nTupIns?: number;
@@ -100,6 +106,13 @@ export interface TableStats {
100
106
  tableSizeBytes?: number;
101
107
  /** Count of indexes already on the table (pg_index). */
102
108
  existingIndexCount?: number;
109
+ /**
110
+ * The later of pg_stat_user_tables.last_analyze / last_autoanalyze: when the
111
+ * planner's column statistics were last refreshed. This, NOT stats_reset, is
112
+ * the freshness that matters for anything read out of pg_stats. Null when
113
+ * never analyzed; absent when the pg_stat read degraded.
114
+ */
115
+ lastAnalyze?: Date | null;
103
116
  }
104
117
  /**
105
118
  * Placeholder for an index column that is an EXPRESSION, not a plain column
@@ -133,6 +146,27 @@ export interface IndexStat {
133
146
  /** pg_relation_size of the index heap, bytes. Size reclaimed by a drop. */
134
147
  sizeBytes?: number;
135
148
  }
149
+ /**
150
+ * The value distribution of one column, read from pg_stats with
151
+ * `inherited = false`. Consumed by the plan-divergence advisor, which needs to
152
+ * reproduce the planner's OWN estimates rather than approximate them.
153
+ */
154
+ export interface ColumnDistribution {
155
+ table: string;
156
+ column: string;
157
+ /**
158
+ * pg_stats.n_distinct, RAW: a positive value is a count, a negative value is a
159
+ * fraction of the row count. Kept undecoded so the consumer decodes it exactly
160
+ * the way the planner does. 0 means the column was never analyzed.
161
+ */
162
+ nDistinct: number;
163
+ /** pg_stats.correlation, signed. NULL for types with no ordering. */
164
+ correlation: number | null;
165
+ /** pg_stats.most_common_freqs, or null when the column has no MCV list. */
166
+ mostCommonFreqs: number[] | null;
167
+ /** cardinality(most_common_vals): how many values the MCV list actually covers. */
168
+ mcvCount: number;
169
+ }
136
170
  /**
137
171
  * A point-in-time read of the statistics the triage needs. Every part is
138
172
  * optional at the field level so the pure scorer degrades honestly.
@@ -150,6 +184,13 @@ export interface StatsSnapshot {
150
184
  indexes: IndexStat[];
151
185
  /** null_frac per probed column, keyed `table.column`. */
152
186
  nullFrac: Record<string, number>;
187
+ /**
188
+ * Value distribution per candidate column, keyed `table.column`. Optional at
189
+ * the snapshot level: a caller that never asked for distribution statistics
190
+ * (or whose pg_stats read degraded) leaves it absent, and the plan-divergence
191
+ * advisor reports that as a suppressed candidate rather than scoring a guess.
192
+ */
193
+ columnStats?: Record<string, ColumnDistribution>;
153
194
  /** Per-signal degradation notices (privileges, catalog gaps, timeouts). */
154
195
  notices: string[];
155
196
  }
@@ -347,6 +388,11 @@ export interface CollectSnapshotOptions {
347
388
  tables: string[];
348
389
  /** Columns to read null_frac for (single-column probes). */
349
390
  columns: ProbedColumn[];
391
+ /**
392
+ * Columns to read the full value distribution for (n_distinct, correlation,
393
+ * MCV frequencies). One extra pg_stats query; empty skips it entirely.
394
+ */
395
+ distributionColumns?: ProbedColumn[];
350
396
  /** statement_timeout for each catalog read. Default 5000ms. */
351
397
  statementTimeoutMs?: number;
352
398
  }
@@ -148,6 +148,7 @@ function emptyStatsSnapshot(notices = []) {
148
148
  tables: {},
149
149
  indexes: [],
150
150
  nullFrac: {},
151
+ columnStats: {},
151
152
  notices,
152
153
  };
153
154
  }
@@ -590,12 +591,14 @@ async function collectStatsSnapshot(options) {
590
591
  }
591
592
  }
592
593
  // --- table stats (pg_stat_user_tables) ---------------------------------
593
- const statRows = await run('pg_stat_user_tables', `SELECT relname, n_tup_ins, n_tup_upd, n_tup_del, n_tup_hot_upd, seq_scan, seq_tup_read, n_live_tup
594
+ const statRows = await run('pg_stat_user_tables', `SELECT relname, n_tup_ins, n_tup_upd, n_tup_del, n_tup_hot_upd, seq_scan, seq_tup_read, n_live_tup,
595
+ greatest(last_analyze, last_autoanalyze) AS last_analyze
594
596
  FROM pg_stat_user_tables
595
597
  WHERE schemaname = $1 AND relname = ANY($2)`, [options.schema, options.tables]);
596
598
  // --- class size + existing index count (pg_class) ----------------------
597
599
  const classRows = await run('pg_class size', `SELECT c.relname,
598
600
  c.reltuples::bigint::text AS reltuples,
601
+ c.relpages::bigint::text AS relpages,
599
602
  pg_total_relation_size(c.oid)::text AS total_size,
600
603
  pg_relation_size(c.oid)::text AS table_size,
601
604
  (SELECT count(*) FROM pg_index i WHERE i.indrelid = c.oid)::text AS index_count
@@ -618,6 +621,7 @@ async function collectStatsSnapshot(options) {
618
621
  for (const row of classRows) {
619
622
  const s = ensure(row.relname);
620
623
  s.reltuples = Number(row.reltuples);
624
+ s.relpages = Number(row.relpages);
621
625
  s.totalSizeBytes = Number(row.total_size);
622
626
  s.tableSizeBytes = Number(row.table_size);
623
627
  s.existingIndexCount = Number(row.index_count);
@@ -634,6 +638,7 @@ async function collectStatsSnapshot(options) {
634
638
  s.seqScan = Number(row.seq_scan);
635
639
  s.seqTupRead = Number(row.seq_tup_read);
636
640
  s.nLiveTup = Number(row.n_live_tup);
641
+ s.lastAnalyze = row.last_analyze == null ? null : new Date(row.last_analyze);
637
642
  }
638
643
  }
639
644
  // --- invalid + all indexes (whole schema, for invalid detection) -------
@@ -697,6 +702,42 @@ async function collectStatsSnapshot(options) {
697
702
  }
698
703
  }
699
704
  }
705
+ // --- value distribution for divergence candidates (pg_stats) -----------
706
+ const distCols = options.distributionColumns ?? [];
707
+ if (distCols.length > 0) {
708
+ // `inherited = false` is required: on a partitioned parent the inherited
709
+ // row describes the whole tree, and a per-partition plan is not chosen
710
+ // from it. most_common_vals is an anyarray, so its cardinality is read
711
+ // through a text[] cast (array_length on anyarray cannot resolve a type).
712
+ const distRows = await run('pg_stats.distribution', `SELECT s.tablename, s.attname,
713
+ s.n_distinct::text AS n_distinct,
714
+ s.correlation::text AS correlation,
715
+ s.most_common_freqs,
716
+ coalesce(array_length(s.most_common_vals::text::text[], 1), 0)::text AS mcv_count
717
+ FROM pg_stats s
718
+ JOIN unnest($2::text[], $3::text[]) AS probe(t, c)
719
+ ON probe.t = s.tablename AND probe.c = s.attname
720
+ WHERE s.schemaname = $1 AND s.inherited = false`, [options.schema, distCols.map((c) => c.table), distCols.map((c) => c.column)]);
721
+ if (distRows) {
722
+ const byKey = snapshot.columnStats ?? {};
723
+ for (const row of distRows) {
724
+ byKey[`${row.tablename}.${row.attname}`] = {
725
+ table: row.tablename,
726
+ column: row.attname,
727
+ nDistinct: Number(row.n_distinct),
728
+ correlation: row.correlation == null ? null : Number(row.correlation),
729
+ mostCommonFreqs: row.most_common_freqs == null ? null : row.most_common_freqs.map(Number),
730
+ mcvCount: row.mcv_count == null ? 0 : Number(row.mcv_count),
731
+ };
732
+ }
733
+ snapshot.columnStats = byKey;
734
+ }
735
+ else {
736
+ // The read failed (privileges, catalog gap). Leave columnStats absent so
737
+ // the advisor suppresses every candidate instead of scoring zeros.
738
+ snapshot.columnStats = undefined;
739
+ }
740
+ }
700
741
  }
701
742
  finally {
702
743
  try {