turbine-orm 0.33.0 → 0.34.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.
@@ -4491,7 +4491,18 @@ export class QueryInterface {
4491
4491
  const extract = this.dialect.buildJsonPathExtract(`${prefix}${this.q(col)}`, this.p(params.length));
4492
4492
  const lhs = spec.type === 'numeric' ? this.castJsonNumeric(extract) : extract;
4493
4493
  const dir = spec.direction?.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
4494
- return `${lhs} ${dir}${this.nullsSuffix(spec.nulls)}`;
4494
+ // Rows whose document lacks the path extract to NULL. Without a nulls
4495
+ // clause, Postgres DESC defaults to NULLS FIRST, which both diverges from
4496
+ // pick-row ordering (NULLS LAST both directions since 0.33) and from
4497
+ // engines whose path ordering is nulls-last in both directions. Default to
4498
+ // NULLS LAST in BOTH directions unless the caller set `nulls` explicitly;
4499
+ // the grammar gate matches nullsSuffix.
4500
+ const nullsSql = spec.nulls
4501
+ ? this.nullsSuffix(spec.nulls)
4502
+ : this.dialect.name === 'postgresql' || this.dialect.name === 'sqlite'
4503
+ ? ' NULLS LAST'
4504
+ : '';
4505
+ return `${lhs} ${dir}${nullsSql}`;
4495
4506
  }
4496
4507
  /**
4497
4508
  * Compile a relation ordering term. For a to-many relation the only allowed
@@ -618,6 +618,11 @@ export interface JsonPathGroupKey {
618
618
  * json/jsonb column, e.g. `SUM((col #>> $n::text[])::numeric)`. The arg key
619
619
  * is the result alias. `_sum`/`_avg` always cast numeric (a text sum is
620
620
  * meaningless); `_min`/`_max` compare as text unless `type: 'numeric'`.
621
+ *
622
+ * Engine note: when a group has NO value at the path, SQL engines return
623
+ * `null` for `_sum` (SUM over zero rows), while PowDB returns `0` (engine
624
+ * sum semantics). Treat `null` and `0` totals as equivalent when a group can
625
+ * be empty at the path.
621
626
  */
622
627
  export interface JsonPathAggregateTarget {
623
628
  /** json/jsonb column (camelCase field name, columnMap-resolved). */
@@ -758,15 +763,37 @@ export interface RelationFilter {
758
763
  is?: Record<string, unknown>;
759
764
  isNot?: Record<string, unknown>;
760
765
  }
761
- /** JSONB query operators for where clauses */
766
+ /**
767
+ * JSONB query operators for where clauses.
768
+ *
769
+ * PowDB (`turbine-orm/powdb`) semantic deltas. The PowDB engine evaluates
770
+ * `->` path filters with full type knowledge, so a few behaviours differ from
771
+ * the Postgres `#>>`-text driver (documented, never silently wrong):
772
+ * - `{ path, equals: null }` matches JSON null OR a MISSING key on PowDB
773
+ * (compiles to `is null`), whereas the PG driver compares extracted text
774
+ * against the string `'null'` and matches only a JSON string `"null"`.
775
+ * - equality is TYPE-STRICT on PowDB: `{ path, equals: 7 }` matches a stored
776
+ * JSON int `7` but not `7.0` or the JSON string `"7"` (PG text-extraction
777
+ * matches `equals: 7` against the string `"7"`). Range ops (`gt`/`lt`/…)
778
+ * still coerce int/float numerically.
779
+ * - a digit-only path segment (`path: ['tags', '0']`) is an ARRAY INDEX on
780
+ * both PowDB and the SQL engines (a json object key that is literally `"0"`
781
+ * is likewise addressed by index).
782
+ * - `contains`, and `equals` WITHOUT a `path` (whole-document containment),
783
+ * throw `UnsupportedFeatureError` (E017) on PowDB: PowQL has no containment
784
+ * operator.
785
+ */
762
786
  export interface JsonFilter {
763
- /** Access nested path via #>> operator */
787
+ /**
788
+ * Access nested path via `#>>` operator (Postgres) / `->` path (PowDB). A
789
+ * digit-only segment (`'0'`) is treated as an array index on every engine.
790
+ */
764
791
  path?: string[];
765
- /** Exact match: column @> value::jsonb (containment) */
792
+ /** Exact match: `column @> value::jsonb` (containment). On PowDB, requires `path` and compares the typed value (throws E017 without `path`). */
766
793
  equals?: unknown;
767
- /** Containment check: column @> value::jsonb */
794
+ /** Containment check: `column @> value::jsonb`. Unsupported on PowDB (E017: PowQL has no containment operator). */
768
795
  contains?: unknown;
769
- /** Key existence check: column ? key */
796
+ /** Key existence check: `column ? key`. */
770
797
  hasKey?: string;
771
798
  /**
772
799
  * Greater-than comparison of the value at `path` (required). Numbers cast
@@ -897,7 +924,13 @@ export interface JsonPathOrderBy {
897
924
  direction?: OrderDirection;
898
925
  /** Comparison kind for the extracted value. Defaults to `'text'`; `'numeric'` adds a numeric cast. */
899
926
  type?: 'numeric' | 'text';
900
- /** NULLS placement (PostgreSQL / SQLite only: see {@link OrderBySpec}). */
927
+ /**
928
+ * NULLS placement (PostgreSQL / SQLite only: see {@link OrderBySpec}).
929
+ * Rows whose document lacks the path extract to NULL and sort LAST in BOTH
930
+ * directions by default (matching pick-row ordering and the PowDB engine
931
+ * contract, so ordering is predictable across drivers); set `nulls` to
932
+ * override on PostgreSQL / SQLite.
933
+ */
901
934
  nulls?: 'first' | 'last';
902
935
  }
903
936
  /**
@@ -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
  /**
@@ -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` is always `[]` SchemaDef cannot express indexes, and an
28
- * empty list keeps `schemaHasIndexInfo()` false so the index advisor
29
- * and the dev-mode missing-index warning stay silent instead of
30
- * producing blanket false positives.
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
  */
@@ -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` is always `[]` SchemaDef cannot express indexes, and an
28
- * empty list keeps `schemaHasIndexInfo()` false so the index advisor
29
- * and the dev-mode missing-index warning stay silent instead of
30
- * producing blanket false positives.
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
- // SchemaDef cannot express indexes. An empty list keeps
303
- // schemaHasIndexInfo() false no index-advisor false positives.
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.33.0",
3
+ "version": "0.34.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.13.0",
107
+ "@zvndev/powdb-embedded": "^0.13.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",