turbine-orm 0.33.0 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cjs/client.js +26 -4
- package/dist/cjs/dialect.js +1 -0
- package/dist/cjs/errors.js +41 -1
- package/dist/cjs/index-advisor.js +0 -0
- package/dist/cjs/index.js +4 -2
- package/dist/cjs/mssql.js +5 -0
- package/dist/cjs/mysql.js +4 -0
- package/dist/cjs/optional-peer-import.cjs +28 -0
- package/dist/cjs/powdb-introspect.js +222 -0
- package/dist/cjs/powdb.js +592 -72
- package/dist/cjs/powql.js +998 -134
- package/dist/cjs/query/builder.js +72 -1
- package/dist/cjs/schema-builder.js +16 -0
- package/dist/cjs/schema-metadata.js +81 -10
- package/dist/cjs/sqlite.js +3 -0
- package/dist/client.d.ts +32 -5
- package/dist/client.js +26 -4
- package/dist/dialect.d.ts +13 -0
- package/dist/dialect.js +1 -0
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +39 -0
- package/dist/index-advisor.d.ts +15 -1
- package/dist/index-advisor.js +0 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/mssql.js +5 -0
- package/dist/mysql.js +4 -0
- package/dist/optional-peer-import.cjs +28 -0
- package/dist/optional-peer-import.d.cts +19 -0
- package/dist/powdb-introspect.d.ts +84 -0
- package/dist/powdb-introspect.js +219 -0
- package/dist/powdb.d.ts +361 -19
- package/dist/powdb.js +585 -72
- package/dist/powql.d.ts +245 -8
- package/dist/powql.js +1001 -137
- package/dist/query/builder.d.ts +36 -1
- package/dist/query/builder.js +72 -1
- package/dist/query/deferred.d.ts +6 -2
- package/dist/query/types.d.ts +49 -12
- package/dist/schema-builder.d.ts +46 -1
- package/dist/schema-builder.js +15 -0
- package/dist/schema-metadata.d.ts +13 -7
- package/dist/schema-metadata.js +82 -11
- package/dist/schema.d.ts +25 -0
- package/dist/sqlite.js +3 -0
- package/package.json +3 -3
package/dist/query/builder.d.ts
CHANGED
|
@@ -36,7 +36,12 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
36
36
|
private readonly warnOnUnlimited;
|
|
37
37
|
private readonly utcTimestamps;
|
|
38
38
|
private readonly preparedStatementsEnabled;
|
|
39
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Whether the SQL template cache is active. Set once in the constructor.
|
|
41
|
+
* Mutable (not `readonly`) only so {@link withSqlCacheDisabled} can flip it
|
|
42
|
+
* off around a single synchronous compile (see {@link explain}).
|
|
43
|
+
*/
|
|
44
|
+
private sqlCacheEnabled;
|
|
40
45
|
private readonly dialect;
|
|
41
46
|
/** Client-level default relation-loading strategy ('join' unless configured). */
|
|
42
47
|
private readonly relationLoadStrategy;
|
|
@@ -290,6 +295,36 @@ export declare class QueryInterface<T extends object, R extends object = {}> {
|
|
|
290
295
|
private runFindUniqueBatched;
|
|
291
296
|
buildFindUnique<W extends TypedWithClause<R> = {}>(args: FindUniqueArgs<T, R, W, Record<string, boolean> | undefined, Record<string, boolean> | undefined>): DeferredQuery<T | null>;
|
|
292
297
|
findMany<W extends TypedWithClause<R> = {}, S extends Record<string, boolean> | undefined = undefined, O extends Record<string, boolean> | undefined = undefined>(args?: FindManyArgs<T, R, W, S, O>): Promise<QueryResult<T, R, W, S, O>[]>;
|
|
298
|
+
/**
|
|
299
|
+
* Return the engine's query plan for a {@link findMany}-shaped query as plain
|
|
300
|
+
* text lines: a diagnostic surface for inspecting how the database will run a
|
|
301
|
+
* query (index usage, join strategy, scan type).
|
|
302
|
+
*
|
|
303
|
+
* The compiled SELECT is prefixed with the dialect's explain syntax
|
|
304
|
+
* (Postgres `EXPLAIN`, SQLite `EXPLAIN QUERY PLAN`, MySQL `EXPLAIN
|
|
305
|
+
* FORMAT=TREE`) and run as a read. The findMany args are compiled with the
|
|
306
|
+
* SQL template cache disabled, so an explain never reads or writes the shared
|
|
307
|
+
* cache. Middleware is NOT applied: the returned rows are plan text, not
|
|
308
|
+
* entity rows. Each result row is flattened to one line by joining its column
|
|
309
|
+
* values with a single space (Postgres returns one `QUERY PLAN` text column,
|
|
310
|
+
* SQLite's `EXPLAIN QUERY PLAN` returns four, MySQL's tree format one).
|
|
311
|
+
*
|
|
312
|
+
* Only `findMany` shapes are supported (where / orderBy / with / limit /
|
|
313
|
+
* pagination). Engines whose plan cannot be requested in-band from a compiled
|
|
314
|
+
* query (SQL Server, whose SHOWPLAN is a session toggle) throw
|
|
315
|
+
* {@link UnsupportedFeatureError} (E017).
|
|
316
|
+
*
|
|
317
|
+
* The plan text itself is engine-owned and NOT covered by semver: its content
|
|
318
|
+
* and formatting can change with the underlying database version.
|
|
319
|
+
*/
|
|
320
|
+
explain(args?: FindManyArgs<T, R>): Promise<string[]>;
|
|
321
|
+
/**
|
|
322
|
+
* Run `fn` with the SQL template cache forced off, restoring the prior state
|
|
323
|
+
* afterward. Used by {@link explain}, whose one-off prefixed statement must
|
|
324
|
+
* neither read nor write the shared cache. `fn` is synchronous, so no query
|
|
325
|
+
* interleaves between the toggle and its restore.
|
|
326
|
+
*/
|
|
327
|
+
private withSqlCacheDisabled;
|
|
293
328
|
/**
|
|
294
329
|
* Emit a one-time `console.warn` when {@link findMany} is called without an
|
|
295
330
|
* explicit `limit`/`take` and `warnOnUnlimited` has not been disabled.
|
package/dist/query/builder.js
CHANGED
|
@@ -141,6 +141,11 @@ export class QueryInterface {
|
|
|
141
141
|
warnOnUnlimited;
|
|
142
142
|
utcTimestamps;
|
|
143
143
|
preparedStatementsEnabled;
|
|
144
|
+
/**
|
|
145
|
+
* Whether the SQL template cache is active. Set once in the constructor.
|
|
146
|
+
* Mutable (not `readonly`) only so {@link withSqlCacheDisabled} can flip it
|
|
147
|
+
* off around a single synchronous compile (see {@link explain}).
|
|
148
|
+
*/
|
|
144
149
|
sqlCacheEnabled;
|
|
145
150
|
dialect;
|
|
146
151
|
/** Client-level default relation-loading strategy ('join' unless configured). */
|
|
@@ -834,6 +839,61 @@ export class QueryInterface {
|
|
|
834
839
|
return deferred.transform(result);
|
|
835
840
|
});
|
|
836
841
|
}
|
|
842
|
+
/**
|
|
843
|
+
* Return the engine's query plan for a {@link findMany}-shaped query as plain
|
|
844
|
+
* text lines: a diagnostic surface for inspecting how the database will run a
|
|
845
|
+
* query (index usage, join strategy, scan type).
|
|
846
|
+
*
|
|
847
|
+
* The compiled SELECT is prefixed with the dialect's explain syntax
|
|
848
|
+
* (Postgres `EXPLAIN`, SQLite `EXPLAIN QUERY PLAN`, MySQL `EXPLAIN
|
|
849
|
+
* FORMAT=TREE`) and run as a read. The findMany args are compiled with the
|
|
850
|
+
* SQL template cache disabled, so an explain never reads or writes the shared
|
|
851
|
+
* cache. Middleware is NOT applied: the returned rows are plan text, not
|
|
852
|
+
* entity rows. Each result row is flattened to one line by joining its column
|
|
853
|
+
* values with a single space (Postgres returns one `QUERY PLAN` text column,
|
|
854
|
+
* SQLite's `EXPLAIN QUERY PLAN` returns four, MySQL's tree format one).
|
|
855
|
+
*
|
|
856
|
+
* Only `findMany` shapes are supported (where / orderBy / with / limit /
|
|
857
|
+
* pagination). Engines whose plan cannot be requested in-band from a compiled
|
|
858
|
+
* query (SQL Server, whose SHOWPLAN is a session toggle) throw
|
|
859
|
+
* {@link UnsupportedFeatureError} (E017).
|
|
860
|
+
*
|
|
861
|
+
* The plan text itself is engine-owned and NOT covered by semver: its content
|
|
862
|
+
* and formatting can change with the underlying database version.
|
|
863
|
+
*/
|
|
864
|
+
async explain(args) {
|
|
865
|
+
const explainSyntax = this.dialect.explainQuery;
|
|
866
|
+
if (!explainSyntax) {
|
|
867
|
+
throw new UnsupportedFeatureError('explain()', this.dialect.name, 'This engine cannot explain a compiled query in-band.');
|
|
868
|
+
}
|
|
869
|
+
// Compile the findMany SQL with the cache disabled: the prefixed EXPLAIN
|
|
870
|
+
// statement is a one-off diagnostic and must never read or write the shared
|
|
871
|
+
// query-template cache.
|
|
872
|
+
const deferred = this.withSqlCacheDisabled(() => this.buildFindMany(args));
|
|
873
|
+
const sql = `${explainSyntax.prefix} ${deferred.sql}`;
|
|
874
|
+
this.currentAction = 'explain';
|
|
875
|
+
// No preparedName: keep the diagnostic statement out of the prepared path.
|
|
876
|
+
const result = await this.queryWithTimeout(sql, deferred.params, args?.timeout);
|
|
877
|
+
return result.rows.map((row) => Object.values(row)
|
|
878
|
+
.map((value) => (typeof value === 'string' ? value : String(value)))
|
|
879
|
+
.join(' '));
|
|
880
|
+
}
|
|
881
|
+
/**
|
|
882
|
+
* Run `fn` with the SQL template cache forced off, restoring the prior state
|
|
883
|
+
* afterward. Used by {@link explain}, whose one-off prefixed statement must
|
|
884
|
+
* neither read nor write the shared cache. `fn` is synchronous, so no query
|
|
885
|
+
* interleaves between the toggle and its restore.
|
|
886
|
+
*/
|
|
887
|
+
withSqlCacheDisabled(fn) {
|
|
888
|
+
const prev = this.sqlCacheEnabled;
|
|
889
|
+
this.sqlCacheEnabled = false;
|
|
890
|
+
try {
|
|
891
|
+
return fn();
|
|
892
|
+
}
|
|
893
|
+
finally {
|
|
894
|
+
this.sqlCacheEnabled = prev;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
837
897
|
/**
|
|
838
898
|
* Emit a one-time `console.warn` when {@link findMany} is called without an
|
|
839
899
|
* explicit `limit`/`take` and `warnOnUnlimited` has not been disabled.
|
|
@@ -4491,7 +4551,18 @@ export class QueryInterface {
|
|
|
4491
4551
|
const extract = this.dialect.buildJsonPathExtract(`${prefix}${this.q(col)}`, this.p(params.length));
|
|
4492
4552
|
const lhs = spec.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
|
|
4493
4553
|
const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
|
4494
|
-
|
|
4554
|
+
// Rows whose document lacks the path extract to NULL. Without a nulls
|
|
4555
|
+
// clause, Postgres DESC defaults to NULLS FIRST, which both diverges from
|
|
4556
|
+
// pick-row ordering (NULLS LAST both directions since 0.33) and from
|
|
4557
|
+
// engines whose path ordering is nulls-last in both directions. Default to
|
|
4558
|
+
// NULLS LAST in BOTH directions unless the caller set `nulls` explicitly;
|
|
4559
|
+
// the grammar gate matches nullsSuffix.
|
|
4560
|
+
const nullsSql = spec.nulls
|
|
4561
|
+
? this.nullsSuffix(spec.nulls)
|
|
4562
|
+
: this.dialect.name === 'postgresql' || this.dialect.name === 'sqlite'
|
|
4563
|
+
? ' NULLS LAST'
|
|
4564
|
+
: '';
|
|
4565
|
+
return `${lhs} ${dir}${nullsSql}`;
|
|
4495
4566
|
}
|
|
4496
4567
|
/**
|
|
4497
4568
|
* Compile a relation ordering term. For a to-many relation the only allowed
|
package/dist/query/deferred.d.ts
CHANGED
|
@@ -111,8 +111,12 @@ export interface QueryInterfaceOptions {
|
|
|
111
111
|
*/
|
|
112
112
|
utcTimestamps?: boolean;
|
|
113
113
|
/**
|
|
114
|
-
* Client-level default relation-loading strategy for `with` clauses
|
|
115
|
-
* `relationLoadStrategy`
|
|
114
|
+
* Client-level default relation-loading strategy for `with` clauses; a
|
|
115
|
+
* per-query `relationLoadStrategy` arg overrides it. On SQL engines the default
|
|
116
|
+
* is `'join'` (one single-statement `json_agg` query). On PowDB the default is
|
|
117
|
+
* the batched loaders, and `'join'` opts INTO native server-side joins where
|
|
118
|
+
* eligible (ineligible relations fall back to the loaders per-relation and
|
|
119
|
+
* silently; see the PowDB docs).
|
|
116
120
|
*/
|
|
117
121
|
relationLoadStrategy?: RelationLoadStrategy;
|
|
118
122
|
/**
|
package/dist/query/types.d.ts
CHANGED
|
@@ -7,15 +7,19 @@ export type OrderDirection = 'asc' | 'desc';
|
|
|
7
7
|
/**
|
|
8
8
|
* How a query resolves its `with` relations.
|
|
9
9
|
*
|
|
10
|
-
* - `'join'
|
|
11
|
-
* `json_agg(json_build_object(...))` subqueries. One round-trip
|
|
12
|
-
* seek per parent row when the child FK is indexed.
|
|
13
|
-
*
|
|
10
|
+
* - `'join'`: one SQL statement with correlated
|
|
11
|
+
* `json_agg(json_build_object(...))` subqueries. One round-trip, an index
|
|
12
|
+
* seek per parent row when the child FK is indexed. On PowDB, `'join'`
|
|
13
|
+
* instead opts into native PowQL server-side joins where eligible.
|
|
14
|
+
* - `'batched'`: run the base query, then ONE flat follow-up query per
|
|
14
15
|
* relation (`WHERE fk = ANY($1)`), stitching children client-side. D levels
|
|
15
16
|
* cost D extra round-trips, but each is a single key-set lookup and rows come
|
|
16
|
-
* back flat
|
|
17
|
+
* back flat (a win when FK columns are unindexed or result sets are huge).
|
|
17
18
|
*
|
|
18
|
-
* Precedence: per-query arg > client `relationLoadStrategy` config >
|
|
19
|
+
* Precedence: per-query arg > client `relationLoadStrategy` config > the engine
|
|
20
|
+
* default. On SQL engines the default is `'join'`; on PowDB the default is the
|
|
21
|
+
* batched loaders (an ineligible relation falls back to them per-relation and
|
|
22
|
+
* silently even when `'join'` is requested).
|
|
19
23
|
*/
|
|
20
24
|
export type RelationLoadStrategy = 'join' | 'batched';
|
|
21
25
|
/**
|
|
@@ -618,6 +622,11 @@ export interface JsonPathGroupKey {
|
|
|
618
622
|
* json/jsonb column, e.g. `SUM((col #>> $n::text[])::numeric)`. The arg key
|
|
619
623
|
* is the result alias. `_sum`/`_avg` always cast numeric (a text sum is
|
|
620
624
|
* meaningless); `_min`/`_max` compare as text unless `type: 'numeric'`.
|
|
625
|
+
*
|
|
626
|
+
* Engine note: when a group has NO value at the path, SQL engines return
|
|
627
|
+
* `null` for `_sum` (SUM over zero rows), while PowDB returns `0` (engine
|
|
628
|
+
* sum semantics). Treat `null` and `0` totals as equivalent when a group can
|
|
629
|
+
* be empty at the path.
|
|
621
630
|
*/
|
|
622
631
|
export interface JsonPathAggregateTarget {
|
|
623
632
|
/** json/jsonb column (camelCase field name, columnMap-resolved). */
|
|
@@ -758,15 +767,37 @@ export interface RelationFilter {
|
|
|
758
767
|
is?: Record<string, unknown>;
|
|
759
768
|
isNot?: Record<string, unknown>;
|
|
760
769
|
}
|
|
761
|
-
/**
|
|
770
|
+
/**
|
|
771
|
+
* JSONB query operators for where clauses.
|
|
772
|
+
*
|
|
773
|
+
* PowDB (`turbine-orm/powdb`) semantic deltas. The PowDB engine evaluates
|
|
774
|
+
* `->` path filters with full type knowledge, so a few behaviours differ from
|
|
775
|
+
* the Postgres `#>>`-text driver (documented, never silently wrong):
|
|
776
|
+
* - `{ path, equals: null }` matches JSON null OR a MISSING key on PowDB
|
|
777
|
+
* (compiles to `is null`), whereas the PG driver compares extracted text
|
|
778
|
+
* against the string `'null'` and matches only a JSON string `"null"`.
|
|
779
|
+
* - equality is TYPE-STRICT on PowDB: `{ path, equals: 7 }` matches a stored
|
|
780
|
+
* JSON int `7` but not `7.0` or the JSON string `"7"` (PG text-extraction
|
|
781
|
+
* matches `equals: 7` against the string `"7"`). Range ops (`gt`/`lt`/…)
|
|
782
|
+
* still coerce int/float numerically.
|
|
783
|
+
* - a digit-only path segment (`path: ['tags', '0']`) is an ARRAY INDEX on
|
|
784
|
+
* both PowDB and the SQL engines (a json object key that is literally `"0"`
|
|
785
|
+
* is likewise addressed by index).
|
|
786
|
+
* - `contains`, and `equals` WITHOUT a `path` (whole-document containment),
|
|
787
|
+
* throw `UnsupportedFeatureError` (E017) on PowDB: PowQL has no containment
|
|
788
|
+
* operator.
|
|
789
|
+
*/
|
|
762
790
|
export interface JsonFilter {
|
|
763
|
-
/**
|
|
791
|
+
/**
|
|
792
|
+
* Access nested path via `#>>` operator (Postgres) / `->` path (PowDB). A
|
|
793
|
+
* digit-only segment (`'0'`) is treated as an array index on every engine.
|
|
794
|
+
*/
|
|
764
795
|
path?: string[];
|
|
765
|
-
/** Exact match: column @> value::jsonb (containment) */
|
|
796
|
+
/** Exact match: `column @> value::jsonb` (containment). On PowDB, requires `path` and compares the typed value (throws E017 without `path`). */
|
|
766
797
|
equals?: unknown;
|
|
767
|
-
/** Containment check: column @> value::jsonb */
|
|
798
|
+
/** Containment check: `column @> value::jsonb`. Unsupported on PowDB (E017: PowQL has no containment operator). */
|
|
768
799
|
contains?: unknown;
|
|
769
|
-
/** Key existence check: column ? key */
|
|
800
|
+
/** Key existence check: `column ? key`. */
|
|
770
801
|
hasKey?: string;
|
|
771
802
|
/**
|
|
772
803
|
* Greater-than comparison of the value at `path` (required). Numbers cast
|
|
@@ -897,7 +928,13 @@ export interface JsonPathOrderBy {
|
|
|
897
928
|
direction?: OrderDirection;
|
|
898
929
|
/** Comparison kind for the extracted value. Defaults to `'text'`; `'numeric'` adds a numeric cast. */
|
|
899
930
|
type?: 'numeric' | 'text';
|
|
900
|
-
/**
|
|
931
|
+
/**
|
|
932
|
+
* NULLS placement (PostgreSQL / SQLite only: see {@link OrderBySpec}).
|
|
933
|
+
* Rows whose document lacks the path extract to NULL and sort LAST in BOTH
|
|
934
|
+
* directions by default (matching pick-row ordering and the PowDB engine
|
|
935
|
+
* contract, so ordering is predictable across drivers); set `nulls` to
|
|
936
|
+
* override on PostgreSQL / SQLite.
|
|
937
|
+
*/
|
|
901
938
|
nulls?: 'first' | 'last';
|
|
902
939
|
}
|
|
903
940
|
/**
|
package/dist/schema-builder.d.ts
CHANGED
|
@@ -126,6 +126,42 @@ export interface CheckDef {
|
|
|
126
126
|
/** Raw SQL boolean expression, e.g. `price > cost`. */
|
|
127
127
|
expression: string;
|
|
128
128
|
}
|
|
129
|
+
/** A plain (column-list) index declaration. */
|
|
130
|
+
export interface ColumnIndexDef {
|
|
131
|
+
/** camelCase field name(s) the index covers. */
|
|
132
|
+
columns: string[];
|
|
133
|
+
/** Whether the index enforces uniqueness. */
|
|
134
|
+
unique?: boolean;
|
|
135
|
+
/** Optional explicit index name (auto-derived when omitted). */
|
|
136
|
+
name?: string;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* A doc-field expression index on a JSON document column (PowDB ≥ 0.13).
|
|
140
|
+
* Indexes the value at `docField-><path>` inside the json document, so a
|
|
141
|
+
* `JsonFilter`/`orderBy` on that path can use an index instead of a scan.
|
|
142
|
+
*/
|
|
143
|
+
export interface DocFieldIndexDef {
|
|
144
|
+
/** camelCase field name of the json document column. */
|
|
145
|
+
docField: string;
|
|
146
|
+
/** JSON path into the document: string keys and integer array indexes. */
|
|
147
|
+
path: (string | number)[];
|
|
148
|
+
/** Whether the expression index enforces uniqueness. */
|
|
149
|
+
unique?: boolean;
|
|
150
|
+
/** Optional explicit index name (auto-derived when omitted). */
|
|
151
|
+
name?: string;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* A single index declaration on a table: either a plain column-list index
|
|
155
|
+
* ({@link ColumnIndexDef}) or a doc-field expression index into a json column
|
|
156
|
+
* ({@link DocFieldIndexDef}).
|
|
157
|
+
*
|
|
158
|
+
* Consumed today by the PowDB DDL generator (`powqlSchemaDDL`) and carried onto
|
|
159
|
+
* {@link import('./schema.js').IndexMetadata} by `schemaDefToMetadata`. The SQL
|
|
160
|
+
* DDL generators (`schema-sql.ts` / `schemaDiff`) do NOT consume these yet.
|
|
161
|
+
*/
|
|
162
|
+
export type SchemaIndexDef = ColumnIndexDef | DocFieldIndexDef;
|
|
163
|
+
/** Type guard: is this index declaration a doc-field expression index? */
|
|
164
|
+
export declare function isDocFieldIndexDef(idx: SchemaIndexDef): idx is DocFieldIndexDef;
|
|
129
165
|
export interface TableDef {
|
|
130
166
|
/**
|
|
131
167
|
* DDL-facing table name (snake_case). This is the name used when generating
|
|
@@ -159,6 +195,13 @@ export interface TableDef {
|
|
|
159
195
|
manyToMany?: readonly ManyToManyDef[];
|
|
160
196
|
/** Table-level `CHECK` constraints. */
|
|
161
197
|
checks?: readonly CheckDef[];
|
|
198
|
+
/**
|
|
199
|
+
* Index declarations for this table (plain column indexes and/or PowDB
|
|
200
|
+
* doc-field expression indexes). Consumed by the PowDB DDL generator
|
|
201
|
+
* (`powqlSchemaDDL`) and carried onto `IndexMetadata` by
|
|
202
|
+
* `schemaDefToMetadata`; the SQL DDL generators do not consume them yet.
|
|
203
|
+
*/
|
|
204
|
+
indexes?: readonly SchemaIndexDef[];
|
|
162
205
|
}
|
|
163
206
|
/**
|
|
164
207
|
* User-facing input shape for a single table when using the object format.
|
|
@@ -171,8 +214,10 @@ export interface TableInput {
|
|
|
171
214
|
manyToMany?: readonly ManyToManyDef[];
|
|
172
215
|
/** Optional table-level CHECK constraints */
|
|
173
216
|
checks?: readonly CheckDef[];
|
|
217
|
+
/** Optional index declarations (plain column and/or doc-field expression) */
|
|
218
|
+
indexes?: readonly SchemaIndexDef[];
|
|
174
219
|
/** Column definitions keyed by camelCase field name */
|
|
175
|
-
[columnName: string]: ColumnDef | readonly string[] | readonly ManyToManyDef[] | readonly CheckDef[] | undefined;
|
|
220
|
+
[columnName: string]: ColumnDef | readonly string[] | readonly ManyToManyDef[] | readonly CheckDef[] | readonly SchemaIndexDef[] | undefined;
|
|
176
221
|
}
|
|
177
222
|
export interface SchemaDef {
|
|
178
223
|
/**
|
package/dist/schema-builder.js
CHANGED
|
@@ -96,6 +96,10 @@ function resolveColumn(def) {
|
|
|
96
96
|
check: def.check ?? null,
|
|
97
97
|
};
|
|
98
98
|
}
|
|
99
|
+
/** Type guard: is this index declaration a doc-field expression index? */
|
|
100
|
+
export function isDocFieldIndexDef(idx) {
|
|
101
|
+
return 'docField' in idx && typeof idx.docField === 'string';
|
|
102
|
+
}
|
|
99
103
|
/** Check if a value is a TableDef (from legacy table() builder) */
|
|
100
104
|
function isTableDef(v) {
|
|
101
105
|
return typeof v === 'object' && v !== null && 'columns' in v && 'name' in v;
|
|
@@ -139,7 +143,17 @@ export function defineSchema(input, options) {
|
|
|
139
143
|
let pk;
|
|
140
144
|
let m2m;
|
|
141
145
|
let checks;
|
|
146
|
+
let indexes;
|
|
142
147
|
for (const [fieldName, def] of Object.entries(raw)) {
|
|
148
|
+
if (fieldName === 'indexes') {
|
|
149
|
+
if (def !== undefined) {
|
|
150
|
+
if (!Array.isArray(def)) {
|
|
151
|
+
throw new Error(`Table "${accessor}": "indexes" must be an array of index declarations`);
|
|
152
|
+
}
|
|
153
|
+
indexes = def;
|
|
154
|
+
}
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
143
157
|
if (fieldName === 'manyToMany') {
|
|
144
158
|
if (def !== undefined) {
|
|
145
159
|
if (!Array.isArray(def)) {
|
|
@@ -199,6 +213,7 @@ export function defineSchema(input, options) {
|
|
|
199
213
|
...(pk && pk.length > 0 ? { primaryKey: pk } : {}),
|
|
200
214
|
...(m2m && m2m.length > 0 ? { manyToMany: m2m } : {}),
|
|
201
215
|
...(checks && checks.length > 0 ? { checks } : {}),
|
|
216
|
+
...(indexes && indexes.length > 0 ? { indexes } : {}),
|
|
202
217
|
};
|
|
203
218
|
}
|
|
204
219
|
}
|
|
@@ -24,10 +24,12 @@
|
|
|
24
24
|
* the same conservative auto-`manyToMany` treatment as introspection.
|
|
25
25
|
* - Explicit `manyToMany` declarations on the SchemaDef are merged via
|
|
26
26
|
* {@link applyManyToManyRelations} (additive, never clobbering).
|
|
27
|
-
* - `indexes`
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
27
|
+
* - `indexes` carries any declared `TableDef.indexes` (plain column and/or
|
|
28
|
+
* PowDB doc-field expression indexes); a table with none declared gets
|
|
29
|
+
* `[]`, which keeps `schemaHasIndexInfo()` false so the index advisor and
|
|
30
|
+
* the dev-mode missing-index warning stay silent instead of producing
|
|
31
|
+
* blanket false positives. Doc-field (docPath) indexes are ignored by the
|
|
32
|
+
* advisor, so a doc-only index set never flips `schemaHasIndexInfo()`.
|
|
31
33
|
*
|
|
32
34
|
* @example
|
|
33
35
|
* ```ts
|
|
@@ -67,10 +69,14 @@ import { type SchemaDef } from './schema-builder.js';
|
|
|
67
69
|
* - Explicit `manyToMany` declarations → merged additively.
|
|
68
70
|
* - Schema-level `enums`.
|
|
69
71
|
*
|
|
72
|
+
* What maps (continued):
|
|
73
|
+
* - `indexes` → declared `TableDef.indexes` become `IndexMetadata` (plain
|
|
74
|
+
* column indexes and PowDB doc-field expression indexes, the latter
|
|
75
|
+
* carrying `docPath`). A table with no declared indexes gets `[]`, keeping
|
|
76
|
+
* `schemaHasIndexInfo()` false so index-advisor consumers produce no false
|
|
77
|
+
* positives on index-less code-first metadata.
|
|
78
|
+
*
|
|
70
79
|
* What SchemaDef cannot express (and how it degrades):
|
|
71
|
-
* - Indexes → every table gets `indexes: []`, which keeps
|
|
72
|
-
* `schemaHasIndexInfo()` false so index-advisor consumers produce no
|
|
73
|
-
* false positives on code-first metadata.
|
|
74
80
|
* - Views → never marked (`isView` is introspection-only).
|
|
75
81
|
* - Composite foreign keys → `references:` is single-column by design.
|
|
76
82
|
*/
|
package/dist/schema-metadata.js
CHANGED
|
@@ -24,10 +24,12 @@
|
|
|
24
24
|
* the same conservative auto-`manyToMany` treatment as introspection.
|
|
25
25
|
* - Explicit `manyToMany` declarations on the SchemaDef are merged via
|
|
26
26
|
* {@link applyManyToManyRelations} (additive, never clobbering).
|
|
27
|
-
* - `indexes`
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
27
|
+
* - `indexes` carries any declared `TableDef.indexes` (plain column and/or
|
|
28
|
+
* PowDB doc-field expression indexes); a table with none declared gets
|
|
29
|
+
* `[]`, which keeps `schemaHasIndexInfo()` false so the index advisor and
|
|
30
|
+
* the dev-mode missing-index warning stay silent instead of producing
|
|
31
|
+
* blanket false positives. Doc-field (docPath) indexes are ignored by the
|
|
32
|
+
* advisor, so a doc-only index set never flips `schemaHasIndexInfo()`.
|
|
31
33
|
*
|
|
32
34
|
* @example
|
|
33
35
|
* ```ts
|
|
@@ -42,9 +44,10 @@
|
|
|
42
44
|
* // → usable anywhere SchemaMetadata is expected (e.g. turbinePowDB, TurbineClient)
|
|
43
45
|
* ```
|
|
44
46
|
*/
|
|
47
|
+
import { ValidationError } from './errors.js';
|
|
45
48
|
import { addAutoManyToManyRelations, buildRelationsFromForeignKeys, isUnknownTsType, } from './introspect.js';
|
|
46
49
|
import { camelToSnake, isDateType, pgArrayType, pgTypeToTs, } from './schema.js';
|
|
47
|
-
import { applyManyToManyRelations } from './schema-builder.js';
|
|
50
|
+
import { applyManyToManyRelations, isDocFieldIndexDef, } from './schema-builder.js';
|
|
48
51
|
// ---------------------------------------------------------------------------
|
|
49
52
|
// DDL type → Postgres udt_name (what introspection reads from the catalog)
|
|
50
53
|
// ---------------------------------------------------------------------------
|
|
@@ -94,6 +97,67 @@ function resolveColumnName(raw, target) {
|
|
|
94
97
|
return camelToSnake(raw);
|
|
95
98
|
}
|
|
96
99
|
// ---------------------------------------------------------------------------
|
|
100
|
+
// Index declarations → IndexMetadata
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
/**
|
|
103
|
+
* Render a doc-field JSON path into an illustrative PowQL fragment for the
|
|
104
|
+
* `IndexMetadata.definition` field (debuggability only; the authoritative,
|
|
105
|
+
* lexer-exact emission lives in `powqlSchemaDDL`). String segments are shown
|
|
106
|
+
* double-quoted, integer array indexes bare.
|
|
107
|
+
*/
|
|
108
|
+
function docPathFragment(column, path) {
|
|
109
|
+
const segs = path.map((s) => (typeof s === 'number' ? `->${s}` : `->"${s}"`)).join('');
|
|
110
|
+
return `(.${column}${segs})`;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Convert a table's {@link SchemaIndexDef} list into {@link IndexMetadata}.
|
|
114
|
+
* A doc-field index carries `docPath` and `columns: [<json column>]`; a plain
|
|
115
|
+
* column index carries its snake_case column list and no `docPath`. Names are
|
|
116
|
+
* auto-derived (`<table>_<cols>_idx`) when not supplied.
|
|
117
|
+
*/
|
|
118
|
+
function mapIndexes(tableDef, declared) {
|
|
119
|
+
if (!declared || declared.length === 0)
|
|
120
|
+
return [];
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const idx of declared) {
|
|
123
|
+
if (isDocFieldIndexDef(idx)) {
|
|
124
|
+
const column = camelToSnake(idx.docField);
|
|
125
|
+
const segPart = idx.path.map((s) => (typeof s === 'number' ? String(s) : s)).join('_');
|
|
126
|
+
const name = idx.name ?? `${tableDef.name}_${column}_${segPart}_idx`;
|
|
127
|
+
// Validate numeric (array-index) segments up front: PowDB rejects a
|
|
128
|
+
// negative / fractional / NaN JSON-path index at migration time with an
|
|
129
|
+
// opaque parse error, so fail here with a typed ValidationError naming the
|
|
130
|
+
// index instead of emitting malformed PowQL later.
|
|
131
|
+
for (const seg of idx.path) {
|
|
132
|
+
if (typeof seg === 'number' && (!Number.isInteger(seg) || seg < 0)) {
|
|
133
|
+
throw new ValidationError(`[turbine] Doc-field index "${name}" on "${tableDef.name}": array-index path segment ${seg} must be a ` +
|
|
134
|
+
'non-negative integer (a JSON array index). Use a string for an object key.');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
out.push({
|
|
138
|
+
name,
|
|
139
|
+
columns: [column],
|
|
140
|
+
unique: idx.unique ?? false,
|
|
141
|
+
definition: `${idx.unique ? 'unique ' : 'index '}${docPathFragment(column, idx.path)}`,
|
|
142
|
+
docPath: [...idx.path],
|
|
143
|
+
declared: true,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
const columns = idx.columns.map(camelToSnake);
|
|
148
|
+
const name = idx.name ?? `${tableDef.name}_${columns.join('_')}_idx`;
|
|
149
|
+
out.push({
|
|
150
|
+
name,
|
|
151
|
+
columns,
|
|
152
|
+
unique: idx.unique ?? false,
|
|
153
|
+
definition: `${idx.unique ? 'unique ' : 'index '}(${columns.join(', ')})`,
|
|
154
|
+
declared: true,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
97
161
|
// The converter
|
|
98
162
|
// ---------------------------------------------------------------------------
|
|
99
163
|
/**
|
|
@@ -119,10 +183,14 @@ function resolveColumnName(raw, target) {
|
|
|
119
183
|
* - Explicit `manyToMany` declarations → merged additively.
|
|
120
184
|
* - Schema-level `enums`.
|
|
121
185
|
*
|
|
186
|
+
* What maps (continued):
|
|
187
|
+
* - `indexes` → declared `TableDef.indexes` become `IndexMetadata` (plain
|
|
188
|
+
* column indexes and PowDB doc-field expression indexes, the latter
|
|
189
|
+
* carrying `docPath`). A table with no declared indexes gets `[]`, keeping
|
|
190
|
+
* `schemaHasIndexInfo()` false so index-advisor consumers produce no false
|
|
191
|
+
* positives on index-less code-first metadata.
|
|
192
|
+
*
|
|
122
193
|
* What SchemaDef cannot express (and how it degrades):
|
|
123
|
-
* - Indexes → every table gets `indexes: []`, which keeps
|
|
124
|
-
* `schemaHasIndexInfo()` false so index-advisor consumers produce no
|
|
125
|
-
* false positives on code-first metadata.
|
|
126
194
|
* - Views → never marked (`isView` is introspection-only).
|
|
127
195
|
* - Composite foreign keys → `references:` is single-column by design.
|
|
128
196
|
*/
|
|
@@ -299,9 +367,12 @@ export function schemaDefToMetadata(def) {
|
|
|
299
367
|
primaryKey: pk,
|
|
300
368
|
uniqueColumns,
|
|
301
369
|
relations: relationsByTable.get(tableDef.name) ?? {},
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
indexes
|
|
370
|
+
// Declared `indexes` (plain column + doc-field expression) carry through;
|
|
371
|
+
// an undeclared table gets `[]`, which keeps schemaHasIndexInfo() false so
|
|
372
|
+
// the index advisor stays silent. Doc-field (docPath) indexes are ignored
|
|
373
|
+
// by the advisor entirely (see index-advisor.ts), so a doc-only index set
|
|
374
|
+
// never flips schemaHasIndexInfo() and never produces FK false positives.
|
|
375
|
+
indexes: mapIndexes(tableDef, tableDef.indexes),
|
|
305
376
|
};
|
|
306
377
|
}
|
|
307
378
|
const enums = {};
|
package/dist/schema.d.ts
CHANGED
|
@@ -170,6 +170,31 @@ export interface IndexMetadata {
|
|
|
170
170
|
columns: string[];
|
|
171
171
|
unique: boolean;
|
|
172
172
|
definition: string;
|
|
173
|
+
/**
|
|
174
|
+
* Set only for a PowDB doc-field expression index: the JSON path (string keys
|
|
175
|
+
* and integer array indexes) into the single json document column named by
|
|
176
|
+
* `columns[0]`. When present, `columns` is `[<json column>]` and the index
|
|
177
|
+
* targets `columns[0]-><segments>` rather than the raw column.
|
|
178
|
+
*
|
|
179
|
+
* Consumed by the PowDB DDL generator (`powqlSchemaDDL` emits
|
|
180
|
+
* `alter T add index (.col->"seg")`). The missing-FK index advisor ignores
|
|
181
|
+
* doc-field indexes entirely (a JSON expression index never covers an
|
|
182
|
+
* equality probe on the raw column). Doc-field indexes are invisible to
|
|
183
|
+
* `describe`-based introspection, so they do NOT round-trip through
|
|
184
|
+
* introspection.
|
|
185
|
+
*/
|
|
186
|
+
docPath?: (string | number)[];
|
|
187
|
+
/**
|
|
188
|
+
* Set for indexes DECLARED in a code-first `defineSchema` (`TableDef.indexes`)
|
|
189
|
+
* rather than read from a live database by introspection. The SQL DDL
|
|
190
|
+
* generators (`schema-sql.ts` / `schemaDiff`) do NOT emit these yet, so a
|
|
191
|
+
* declared index does not reflect a real database index on the SQL engines.
|
|
192
|
+
* The missing-FK index advisor therefore treats declared indexes as
|
|
193
|
+
* "index-info unknown" (same as an index-less schema): counting them would
|
|
194
|
+
* both arm blanket FK false positives and suppress warnings for indexes that
|
|
195
|
+
* were never created. Introspected metadata never sets this.
|
|
196
|
+
*/
|
|
197
|
+
declared?: boolean;
|
|
173
198
|
}
|
|
174
199
|
/** Map a Postgres type to its TypeScript equivalent */
|
|
175
200
|
export declare function pgTypeToTs(pgType: string, nullable: boolean): string;
|
package/dist/sqlite.js
CHANGED
|
@@ -372,6 +372,9 @@ export const sqliteDialect = {
|
|
|
372
372
|
supportsAdvisoryLock: false,
|
|
373
373
|
// No FROM-clause LATERAL: the opt-in lateral pick plan is Postgres-only.
|
|
374
374
|
supportsLateralJoin: false,
|
|
375
|
+
// SQLite explains a compiled query with `EXPLAIN QUERY PLAN` (four columns:
|
|
376
|
+
// id, parent, notused, detail), overriding the inherited Postgres `EXPLAIN`.
|
|
377
|
+
explainQuery: { prefix: 'EXPLAIN QUERY PLAN' },
|
|
375
378
|
// json_group_array / json_object have no inline ORDER BY argument, so every
|
|
376
379
|
// ordered to-many relation is forced through the inner-subquery rewrite.
|
|
377
380
|
aggSupportsInlineOrderBy: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "turbine-orm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -103,11 +103,11 @@
|
|
|
103
103
|
"@size-limit/esbuild": "^12.1.0",
|
|
104
104
|
"@size-limit/file": "^12.1.0",
|
|
105
105
|
"@types/node": "^26.1.0",
|
|
106
|
+
"@zvndev/powdb-client": "^0.15.0",
|
|
107
|
+
"@zvndev/powdb-embedded": "^0.15.0",
|
|
106
108
|
"c8": "^11.0.0",
|
|
107
109
|
"husky": "^9.1.7",
|
|
108
110
|
"lint-staged": "^17.0.8",
|
|
109
|
-
"@zvndev/powdb-client": "^0.8.0",
|
|
110
|
-
"@zvndev/powdb-embedded": "^0.8.0",
|
|
111
111
|
"mssql": "^12.7.0",
|
|
112
112
|
"mysql2": "^3.22.5",
|
|
113
113
|
"size-limit": "^12.1.0",
|