uql-orm 0.82.0 → 0.83.1

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 (57) hide show
  1. package/README.md +116 -5
  2. package/dist/browser/uql-browser.min.js +2 -2
  3. package/dist/browser/uql-browser.min.js.map +3 -3
  4. package/dist/cockroachdb/crdbQuerierPool.js +2 -2
  5. package/dist/dialect/abstractSqlDialect.d.ts +1 -2
  6. package/dist/dialect/abstractSqlDialect.js +15 -10
  7. package/dist/dialect/hydrateColumn.js +2 -12
  8. package/dist/dialect/mysqlLikeSqlDialect.d.ts +2 -0
  9. package/dist/dialect/mysqlLikeSqlDialect.js +5 -0
  10. package/dist/dialect/operators.d.ts +16 -0
  11. package/dist/dialect/operators.js +47 -4
  12. package/dist/dialect/pgLikeSqlDialect.d.ts +1 -0
  13. package/dist/dialect/pgLikeSqlDialect.js +13 -4
  14. package/dist/http/handler.js +17 -23
  15. package/dist/http/query.d.ts +3 -3
  16. package/dist/http/query.js +6 -3
  17. package/dist/maria/mariadbQuerierPool.js +4 -2
  18. package/dist/migrate/builder/expressions.d.ts +2 -0
  19. package/dist/migrate/builder/expressions.js +18 -9
  20. package/dist/migrate/builder/tableBuilder.js +1 -1
  21. package/dist/migrate/generator/mongoSchemaGenerator.d.ts +1 -1
  22. package/dist/migrate/generator/mongoSchemaGenerator.js +1 -1
  23. package/dist/migrate/introspection/mongoIntrospector.d.ts +1 -1
  24. package/dist/migrate/introspection/mongoIntrospector.js +1 -1
  25. package/dist/migrate/introspection/mssqlIntrospector.js +13 -1
  26. package/dist/migrate/introspection/mysqlIntrospector.d.ts +2 -0
  27. package/dist/migrate/introspection/mysqlIntrospector.js +6 -2
  28. package/dist/migrate/introspection/postgresIntrospector.js +1 -1
  29. package/dist/migrate/storage/databaseStorage.js +1 -1
  30. package/dist/mongo/mongoDialect.js +12 -24
  31. package/dist/mongo/mongodbQuerier.js +4 -3
  32. package/dist/mssql/mssqlQuerier.d.ts +2 -0
  33. package/dist/mssql/mssqlQuerier.js +8 -5
  34. package/dist/mysql/mysql2QuerierPool.d.ts +1 -0
  35. package/dist/mysql/mysql2QuerierPool.js +20 -2
  36. package/dist/neon/neonQuerierPool.js +2 -2
  37. package/dist/pglite/pgliteQuerierPool.js +10 -4
  38. package/dist/postgres/pgQuerierPool.js +2 -2
  39. package/dist/postgres/{pgNumericTypes.d.ts → pgWireTypes.d.ts} +4 -3
  40. package/dist/postgres/{pgNumericTypes.js → pgWireTypes.js} +7 -3
  41. package/dist/schema/canonicalType.d.ts +3 -0
  42. package/dist/schema/canonicalType.js +31 -9
  43. package/dist/schema/schemaASTDiffer.js +4 -2
  44. package/dist/sqlite/sqliteDialect.d.ts +1 -3
  45. package/dist/sqlite/sqliteDialect.js +3 -6
  46. package/dist/type/entity.d.ts +1 -1
  47. package/dist/type/queryWhere.d.ts +10 -8
  48. package/dist/util/date.d.ts +11 -0
  49. package/dist/util/date.js +19 -0
  50. package/dist/util/dialect.util.d.ts +2 -5
  51. package/dist/util/dialect.util.js +3 -6
  52. package/dist/util/fieldOption.util.d.ts +5 -3
  53. package/dist/util/fieldOption.util.js +6 -5
  54. package/dist/util/sqlLiteral.d.ts +8 -1
  55. package/dist/util/sqlLiteral.js +11 -7
  56. package/package.json +1 -1
  57. package/skills/uql-orm/SKILL.md +1 -1
@@ -5,14 +5,32 @@ import { MySql2Querier } from './mysql2Querier.js';
5
5
  import { MySqlDialect } from './mysqlDialect.js';
6
6
  export class MySql2QuerierPool extends AbstractSqlQuerierPool {
7
7
  pool;
8
+ #utcSessions = new WeakSet();
8
9
  constructor(opts, extra) {
9
10
  super(new MySqlDialect(dialectOptionsFrom(extra)), extra);
10
11
  // A BIGINT past 2^53 as its exact text rather than a rounded number, the rule every driver here
11
12
  // decodes by (`decodeWideNumber`); within that range it stays a number, and DECIMAL is untouched.
12
- this.pool = createPool({ supportBigNumbers: true, ...opts });
13
+ // A date reads as the UTC it holds, whichever zone the process runs in.
14
+ this.pool = createPool({ supportBigNumbers: true, timezone: 'Z', ...opts });
13
15
  }
14
16
  async getQuerier() {
15
- return new MySql2Querier(() => this.pool.getConnection(), this.dialect, this.extra);
17
+ return new MySql2Querier(() => this.#connection(), this.dialect, this.extra);
18
+ }
19
+ /** A connection whose session is UTC too, so `NOW()` agrees with a bound date: set once per connection. */
20
+ async #connection() {
21
+ const connection = await this.pool.getConnection();
22
+ if (this.#utcSessions.has(connection.connection)) {
23
+ return connection;
24
+ }
25
+ try {
26
+ await connection.query("SET time_zone = '+00:00'");
27
+ }
28
+ catch (error) {
29
+ connection.release();
30
+ throw error;
31
+ }
32
+ this.#utcSessions.add(connection.connection);
33
+ return connection;
16
34
  }
17
35
  async end() {
18
36
  await this.pool.end();
@@ -1,11 +1,11 @@
1
1
  import { Pool, types } from '@neondatabase/serverless';
2
2
  import { dialectOptionsFrom } from '../dialect/abstractDialect.js';
3
3
  import { AbstractPgQuerierPool } from '../postgres/abstractPgQuerierPool.js';
4
- import { numericTypes } from '../postgres/pgNumericTypes.js';
4
+ import { wireTypes } from '../postgres/pgWireTypes.js';
5
5
  import { PostgresDialect } from '../postgres/postgresDialect.js';
6
6
  export class NeonQuerierPool extends AbstractPgQuerierPool {
7
7
  constructor(opts, extra) {
8
8
  // Neon's own `types`, not `pg`'s: this entry has to load on an edge runtime where `pg` is absent.
9
- super(new PostgresDialect(dialectOptionsFrom(extra)), new Pool({ types: numericTypes(types), ...opts }), extra);
9
+ super(new PostgresDialect(dialectOptionsFrom(extra)), new Pool({ types: wireTypes(types), ...opts }), extra);
10
10
  }
11
11
  }
@@ -1,6 +1,7 @@
1
1
  import { dialectOptionsFrom } from '../dialect/abstractDialect.js';
2
2
  import { PostgresDialect } from '../postgres/postgresDialect.js';
3
3
  import { AbstractSharedHandleQuerierPool } from '../querier/abstractSharedHandleQuerierPool.js';
4
+ import { decodeDate } from '../util/date.js';
4
5
  import { decodeWideNumber } from '../util/wideNumber.js';
5
6
  import { PgliteQuerier } from './pgliteQuerier.js';
6
7
  /**
@@ -18,12 +19,17 @@ export class PgliteQuerierPool extends AbstractSharedHandleQuerierPool {
18
19
  }
19
20
  async openDb() {
20
21
  const { PGlite, types } = await import('@electric-sql/pglite');
21
- // INT8 by the one wide-integer rule, where PGlite's own answers a `bigint` past 2^53; a caller's own
22
- // `parsers` still win. The declared return type is what checks {@link PgliteDatabase} against the
23
- // real driver, so no cast is needed here or anywhere below it.
22
+ // INT8 by the one wide-integer rule, where PGlite's own answers a `bigint` past 2^53, and a zoneless
23
+ // TIMESTAMP or a DATE as UTC, as every pool reads one; a caller's own `parsers` still win. The declared
24
+ // return type is what checks {@link PgliteDatabase} against the real driver, so no cast is needed below it.
24
25
  return PGlite.create(this.dataDir, {
25
26
  ...this.opts,
26
- parsers: { [types.INT8]: decodeWideNumber, ...this.opts?.parsers },
27
+ parsers: {
28
+ [types.INT8]: decodeWideNumber,
29
+ [types.TIMESTAMP]: decodeDate,
30
+ [types.DATE]: decodeDate,
31
+ ...this.opts?.parsers,
32
+ },
27
33
  });
28
34
  }
29
35
  buildQuerier(db) {
@@ -1,12 +1,12 @@
1
1
  import { Pool, types } from 'pg';
2
2
  import { dialectOptionsFrom } from '../dialect/abstractDialect.js';
3
3
  import { AbstractPgQuerierPool } from './abstractPgQuerierPool.js';
4
- import { numericTypes } from './pgNumericTypes.js';
4
+ import { wireTypes } from './pgWireTypes.js';
5
5
  import { PostgresDialect } from './postgresDialect.js';
6
6
  export class PgQuerierPool extends AbstractPgQuerierPool {
7
7
  constructor(opts, extra) {
8
8
  // keepAlive reduces (but can't eliminate) idle connections being silently
9
9
  // dropped by NATs/firewalls on long-lived remote connections.
10
- super(new PostgresDialect(dialectOptionsFrom(extra)), new Pool({ keepAlive: true, types: numericTypes(types), ...opts }), extra);
10
+ super(new PostgresDialect(dialectOptionsFrom(extra)), new Pool({ keepAlive: true, types: wireTypes(types), ...opts }), extra);
11
11
  }
12
12
  }
@@ -11,8 +11,9 @@ type PgTypes = {
11
11
  };
12
12
  /**
13
13
  * Decodes `INT8` by `decodeWideNumber` and `FLOAT8` as the float64 it is, since `type: Number` maps to
14
- * BIGINT. At the wire, which every result crosses; `NUMERIC` is left to hydration, which knows the field.
15
- * Per pool, never a global parser, and a caller's own `types` win.
14
+ * BIGINT, and a zoneless `TIMESTAMP` or a `DATE` as UTC, where `pg` reads both in the process's zone. At
15
+ * the wire, which every result crosses; `NUMERIC` is left to hydration, which knows the field. Per pool,
16
+ * never a global parser, and a caller's own `types` win.
16
17
  */
17
- export declare function numericTypes(types: PgTypes): CustomTypesConfig;
18
+ export declare function wireTypes(types: PgTypes): CustomTypesConfig;
18
19
  export {};
@@ -1,14 +1,18 @@
1
+ import { decodeDate } from '../util/date.js';
1
2
  import { decodeWideNumber } from '../util/wideNumber.js';
2
3
  /**
3
4
  * Decodes `INT8` by `decodeWideNumber` and `FLOAT8` as the float64 it is, since `type: Number` maps to
4
- * BIGINT. At the wire, which every result crosses; `NUMERIC` is left to hydration, which knows the field.
5
- * Per pool, never a global parser, and a caller's own `types` win.
5
+ * BIGINT, and a zoneless `TIMESTAMP` or a `DATE` as UTC, where `pg` reads both in the process's zone. At
6
+ * the wire, which every result crosses; `NUMERIC` is left to hydration, which knows the field. Per pool,
7
+ * never a global parser, and a caller's own `types` win.
6
8
  */
7
- export function numericTypes(types) {
9
+ export function wireTypes(types) {
8
10
  // Text only: in binary mode an INT8 arrives as an 8-byte Buffer, and `Number(buffer)` is `NaN`.
9
11
  const decoders = new Map([
10
12
  [types.builtins['INT8'], decodeWideNumber],
11
13
  [types.builtins['FLOAT8'], Number],
14
+ [types.builtins['TIMESTAMP'], decodeDate],
15
+ [types.builtins['DATE'], decodeDate],
12
16
  ]);
13
17
  return {
14
18
  getTypeParser: (oid, format) => (format === 'text' && decoders.get(oid)) || types.getTypeParser(oid, format),
@@ -1,6 +1,7 @@
1
1
  import type { AbstractDialect } from '../dialect/abstractDialect.js';
2
2
  import type { VectorCast } from '../dialect/vectorCast.js';
3
3
  import type { ColumnType, EntityGetter, FieldMeta, FieldOptions } from '../type/entity.js';
4
+ import { type DialectName } from '../type/index.js';
4
5
  import type { CanonicalType, TypeCategory } from './types.js';
5
6
  /** Whether a category is one of the vector types, narrowing it to the cast pgvector names use. */
6
7
  export declare function isVectorCategory(category: TypeCategory | undefined): category is VectorCast;
@@ -29,6 +30,8 @@ export declare function canonicalToSql(type: CanonicalType, dialect: AbstractDia
29
30
  * Convert a canonical type to a TypeScript type string.
30
31
  */
31
32
  export declare function canonicalToTypeScript(type: CanonicalType): string;
33
+ /** The fractional-second digits an engine's timestamp holds when its type states none; `undefined` where it counts none. */
34
+ export declare function defaultTimestampPrecision(dialectName: DialectName): number | undefined;
32
35
  /**
33
36
  * A type as `dialect` stores it, rendered and read back: several types share one storage type, and only
34
37
  * the engine settles an unstated bound. Migrations and drift both compare through it.
@@ -120,7 +120,8 @@ const MYSQL_SCALAR_MAP = {
120
120
  boolean: 'TINYINT(1)',
121
121
  date: 'DATE',
122
122
  time: 'TIME',
123
- timestamp: 'DATETIME',
123
+ // The milliseconds a `Date` holds, which a bare `DATETIME` rounds away.
124
+ timestamp: 'DATETIME(3)',
124
125
  json: 'JSON',
125
126
  uuid: 'CHAR(36)',
126
127
  blob: 'BLOB',
@@ -193,25 +194,28 @@ const ENGINE_TYPES = {
193
194
  postgres: {
194
195
  scalars: { ...PG_SCALAR_MAP, vector: 'VECTOR', halfvec: 'HALFVEC', sparsevec: 'SPARSEVEC' },
195
196
  sizes: PG_SIZES,
197
+ timestampPrecision: 6,
196
198
  },
197
199
  // CockroachDB's VECTOR is native, no extension needed.
198
- cockroachdb: { scalars: withVectorType(PG_SCALAR_MAP, 'VECTOR'), sizes: PG_SIZES },
200
+ cockroachdb: { scalars: withVectorType(PG_SCALAR_MAP, 'VECTOR'), sizes: PG_SIZES, timestampPrecision: 6 },
199
201
  // MySQL does have a `VECTOR` type (26.7), but no distance function outside HeatWave and no vector
200
202
  // index, so JSON keeps the column queryable with the JSON operators and needs no conversion.
201
203
  mysql: {
202
204
  scalars: withVectorType(MYSQL_SCALAR_MAP, 'JSON'),
203
205
  sizes: MYSQL_SIZES,
204
206
  decimal: { precision: 10, scale: 2 },
207
+ timestampPrecision: 0,
205
208
  },
206
209
  mariadb: {
207
210
  scalars: withVectorType(MYSQL_SCALAR_MAP, 'VECTOR'),
208
211
  sizes: MYSQL_SIZES,
209
212
  decimal: { precision: 10, scale: 2 },
213
+ timestampPrecision: 0,
210
214
  },
211
215
  // SQLite uses affinity, so no size variants. `F32_BLOB` is libSQL's vector type; elsewhere just a name of BLOB affinity.
212
216
  sqlite: { scalars: withVectorType(SQLITE_SCALAR_MAP, 'F32_BLOB') },
213
217
  // 2025 and up; below that the server refuses the type rather than storing it as text.
214
- mssql: { scalars: withVectorType(MSSQL_SCALAR_MAP, 'VECTOR'), sizes: MSSQL_SIZES },
218
+ mssql: { scalars: withVectorType(MSSQL_SCALAR_MAP, 'VECTOR'), sizes: MSSQL_SIZES, timestampPrecision: 7 },
215
219
  mongodb: { scalars: withVectorType(MONGO_SCALAR_MAP, 'array') },
216
220
  };
217
221
  /**
@@ -241,8 +245,11 @@ export function sqlToCanonical(sqlType) {
241
245
  const normalized = sqlType.toLowerCase().trim();
242
246
  const unsigned = normalized.includes('unsigned');
243
247
  const withoutUnsigned = normalized.replace(/\s*unsigned\s*/i, ' ').trim();
244
- // Extract base type and parameters: "VARCHAR(255)" -> ["varchar", "255"]
245
- const match = withoutUnsigned.match(/^([a-z][a-z0-9_ ]*?)(?:\(([^)]+)\))?$/);
248
+ // Extract base type and parameters: "VARCHAR(255)" -> ["varchar", "255"], and Postgres's
249
+ // "timestamp(3) with time zone", whose parameter sits inside the name, as "timestamp with time zone(3)".
250
+ const match = withoutUnsigned
251
+ .replace(/^(\w+)\((\d+)\)\s+(.+)$/, '$1 $3($2)')
252
+ .match(/^([a-z][a-z0-9_ ]*?)(?:\(([^)]+)\))?$/);
246
253
  const base = match ? SQL_TO_CANONICAL[match[1]] : undefined;
247
254
  if (!match || !base) {
248
255
  return { category: 'string', raw: sqlType };
@@ -260,7 +267,7 @@ export function sqlToCanonical(sqlType) {
260
267
  size: measured && params[0] === 'max' ? 'small' : base.size,
261
268
  withTimezone: base.withTimezone,
262
269
  length: measured ? first : undefined,
263
- precision: decimal ? first : undefined,
270
+ precision: decimal || base.category === 'timestamp' ? first : undefined,
264
271
  scale: decimal ? second : undefined,
265
272
  unsigned: unsigned || undefined,
266
273
  };
@@ -317,6 +324,9 @@ export function canonicalToSql(type, dialect) {
317
324
  if (type.category === 'timestamp' && type.withTimezone && features.supportsTimestamptz) {
318
325
  sqlType = 'TIMESTAMPTZ';
319
326
  }
327
+ if (type.category === 'timestamp' && type.precision !== undefined && engine.timestampPrecision !== undefined) {
328
+ sqlType = `${sqlType.replace(/\(\d+\)$/, '')}(${type.precision})`;
329
+ }
320
330
  return type.unsigned && features.supportsUnsigned ? `${sqlType} UNSIGNED` : sqlType;
321
331
  }
322
332
  /** See {@link DialectFeatures.stringSizing} for what each mode means. */
@@ -343,12 +353,22 @@ function formatDecimalSqlType(type, fallback, baseType) {
343
353
  export function canonicalToTypeScript(type) {
344
354
  return CANONICAL_TO_TS[type.category];
345
355
  }
356
+ /** The fractional-second digits an engine's timestamp holds when its type states none; `undefined` where it counts none. */
357
+ export function defaultTimestampPrecision(dialectName) {
358
+ return ENGINE_TYPES[dialectName].timestampPrecision;
359
+ }
346
360
  /**
347
361
  * A type as `dialect` stores it, rendered and read back: several types share one storage type, and only
348
362
  * the engine settles an unstated bound. Migrations and drift both compare through it.
349
363
  */
350
364
  export function engineType(dialect) {
351
- return (type) => sqlToCanonical(canonicalToSql(type, dialect));
365
+ const timestampPrecision = defaultTimestampPrecision(dialect.dialectName);
366
+ return (type) => {
367
+ const stored = sqlToCanonical(canonicalToSql(type, dialect));
368
+ return stored.category === 'timestamp' && stored.precision === undefined
369
+ ? { ...stored, precision: timestampPrecision }
370
+ : stored;
371
+ };
352
372
  }
353
373
  /**
354
374
  * Convert UQL FieldOptions to a canonical type.
@@ -363,14 +383,16 @@ export function fieldOptionsToCanonical(options) {
363
383
  switch (columnFamily(options.type)) {
364
384
  case 'numeric':
365
385
  // BIGINT for every `Number` without a scale, key or not: a 32-bit column is a migration waiting
366
- // to happen, and the pools decode it back to a JS number at the wire (see `pgNumericTypes`).
386
+ // to happen, and the pools decode it back to a JS number at the wire (see `pgWireTypes`).
367
387
  return isIntegerColumn(options)
368
388
  ? { category: 'integer', size: 'big' }
369
389
  : { category: 'decimal', precision: options.precision, scale: options.scale };
370
390
  case 'boolean':
371
391
  return { category: 'boolean' };
392
+ // An instant: uql reads a zoneless timestamp as UTC, but the database's own clock and every other
393
+ // client read one in the session's zone.
372
394
  case 'date':
373
- return { category: 'timestamp' };
395
+ return { category: 'timestamp', withTimezone: true, precision: options.precision };
374
396
  // `String`, and anything a reflected type left unrecognised.
375
397
  default:
376
398
  return { category: 'string', length: options.length };
@@ -124,7 +124,9 @@ function diffColumn(tableName, source, target, opts) {
124
124
  // inconsistently (`BIGINT(20)`, SQLite's `notnull: 0` rowid), so neither is compared.
125
125
  const generatedType = source.isAutoIncrement && target.isAutoIncrement;
126
126
  const impliedNotNull = source.isPrimaryKey && target.isPrimaryKey;
127
- const typeChanged = !generatedType && !areTypesEqual(opts.normalizeType(source.type), opts.normalizeType(target.type));
127
+ const expectedType = opts.normalizeType(source.type);
128
+ const actualType = opts.normalizeType(target.type);
129
+ const typeChanged = !generatedType && !areTypesEqual(expectedType, actualType);
128
130
  if (typeChanged) {
129
131
  differences.push(`type: ${formatType(source.type)} -> ${formatType(target.type)}`);
130
132
  }
@@ -158,7 +160,7 @@ function diffColumn(tableName, source, target, opts) {
158
160
  // Only the type this diff actually reports: a column altered for its default carries no data loss,
159
161
  // and a generated key's type - never compared above - reads as unsigned against an entity that
160
162
  // cannot say so.
161
- isBreaking: (typeChanged || signednessChanged) && isBreakingTypeChange(target.type, source.type),
163
+ isBreaking: (typeChanged || signednessChanged) && isBreakingTypeChange(actualType, expectedType),
162
164
  description: differences.join(', '),
163
165
  };
164
166
  }
@@ -1,4 +1,4 @@
1
- import { AbstractSqlDialect, type DerivedRelation, type HydrateKind, type RelationRows } from '../dialect/abstractSqlDialect.js';
1
+ import { AbstractSqlDialect, type DerivedRelation, type RelationRows } from '../dialect/abstractSqlDialect.js';
2
2
  import { type JsonAccessMode, type JsonSlot } from '../dialect/jsonSql.js';
3
3
  import { type EntityMeta, type FieldOptions, type Query, type QueryContext, type QueryPager, type QueryTextSearchOptions, type QueryWhere, type SqlDialectFeatures, type VectorDistance, type VectorMetric } from '../type/index.js';
4
4
  /** What SQLite and the engines derived from it have. */
@@ -60,8 +60,6 @@ export declare class SqliteDialect extends AbstractSqlDialect {
60
60
  vector: (expr: string) => string;
61
61
  };
62
62
  private bytesAsText;
63
- /** A date reads back as SQLite stored it, a number or text, which JSON carries unchanged. */
64
- protected hydrateKind(field: FieldOptions | undefined): HydrateKind | undefined;
65
63
  /**
66
64
  * FTS5 matches the table itself, so this works only where the table *is* an FTS5 virtual table (UQL does
67
65
  * not create those; declare it outside your entities). The whole query is bound, column filter and all.
@@ -3,9 +3,10 @@ import { BYTES_PREFIX } from '../dialect/hydrateColumn.js';
3
3
  import { chainedCall, groupsPerCall, jsonSetCall, jsonPath, jsonArraySlotArgs, jsonSlotArgs, jsonRemoveCall, jsonSetTarget, } from '../dialect/jsonSql.js';
4
4
  import { QueryRaw, } from '../type/index.js';
5
5
  import { indexDistance, isVectorIndexType } from '../type/vector.js';
6
+ import { utcTimestamp } from '../util/date.js';
6
7
  import { declaredIndexName } from '../util/ddlExpression.util.js';
7
8
  import { findVectorIndex, findVectorSort, textSearchFields, vectorCandidates } from '../util/dialect.util.js';
8
- import { columnFamily, isIntegerColumn } from '../util/field.util.js';
9
+ import { isIntegerColumn } from '../util/field.util.js';
9
10
  /**
10
11
  * An FTS5 query over `columns` for what a person typed: each word a quoted string, which FTS5 reads as a
11
12
  * term to match and never as syntax, and every one required, as the other engines read plain words.
@@ -128,7 +129,7 @@ export class SqliteDialect extends AbstractSqlDialect {
128
129
  }
129
130
  normalizeValue(value) {
130
131
  if (value instanceof Date)
131
- return value.getTime();
132
+ return utcTimestamp(value);
132
133
  return super.normalizeValue(value);
133
134
  }
134
135
  /**
@@ -169,10 +170,6 @@ export class SqliteDialect extends AbstractSqlDialect {
169
170
  bytesAsText(expr) {
170
171
  return `${this.escape(BYTES_PREFIX)} || hex(${expr})`;
171
172
  }
172
- /** A date reads back as SQLite stored it, a number or text, which JSON carries unchanged. */
173
- hydrateKind(field) {
174
- return columnFamily(field?.type) === 'date' ? undefined : super.hydrateKind(field);
175
- }
176
173
  /**
177
174
  * FTS5 matches the table itself, so this works only where the table *is* an FTS5 virtual table (UQL does
178
175
  * not create those; declare it outside your entities). The whole query is bound, column filter and all.
@@ -288,7 +288,7 @@ export type FieldOptions<V = TsTypeOf<FieldType>, E = unknown> = {
288
288
  readonly columnType?: ColumnType | QueryRaw;
289
289
  /** A string column's length. */
290
290
  readonly length?: number;
291
- /** A decimal column's precision. */
291
+ /** A decimal column's digits, or a timestamp's fractional-second digits. */
292
292
  readonly precision?: number;
293
293
  /** A decimal column's scale. */
294
294
  readonly scale?: number;
@@ -138,35 +138,37 @@ export type QueryWhereFieldOperatorMap<T, Raw = QueryRaw> = {
138
138
  */
139
139
  $between?: readonly [ExpandScalar<T>, ExpandScalar<T>];
140
140
  /**
141
- * whether a string begins with the given string (case sensitive).
141
+ * whether a string begins with the given text, taken literally (case sensitive).
142
142
  */
143
143
  $startsWith?: string;
144
144
  /**
145
- * whether a string begins with the given string (case insensitive).
145
+ * whether a string begins with the given text, taken literally (case insensitive).
146
146
  */
147
147
  $istartsWith?: string;
148
148
  /**
149
- * whether a string ends with the given string (case sensitive).
149
+ * whether a string ends with the given text, taken literally (case sensitive).
150
150
  */
151
151
  $endsWith?: string;
152
152
  /**
153
- * whether a string ends with the given string (case insensitive).
153
+ * whether a string ends with the given text, taken literally (case insensitive).
154
154
  */
155
155
  $iendsWith?: string;
156
156
  /**
157
- * whether a string is contained within the given string (case sensitive).
157
+ * whether a string contains the given text, taken literally (case sensitive).
158
158
  */
159
159
  $includes?: string;
160
160
  /**
161
- * whether a string is contained within the given string (case insensitive).
161
+ * whether a string contains the given text, taken literally (case insensitive).
162
162
  */
163
163
  $iincludes?: string;
164
164
  /**
165
- * whether a string fulfills the given pattern (case sensitive).
165
+ * whether a whole string matches the given pattern, the same on every engine: `%` is any run of
166
+ * characters, `_` any one, and `\` makes the next one literal; a last `\` with nothing after it to
167
+ * escape, `'John\'`, is refused (case sensitive).
166
168
  */
167
169
  $like?: string;
168
170
  /**
169
- * whether a string fulfills the given pattern (case insensitive).
171
+ * whether a whole string matches the given pattern, as `$like` reads it (case insensitive).
170
172
  */
171
173
  $ilike?: string;
172
174
  /**
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `YYYY-MM-DD HH:mm:ss.SSS` in UTC, then `zone`: how a date is written, whichever machine writes it. Not
3
+ * `toISOString` as it is, whose `T` and `Z` MySQL rejects outright ("Invalid default value").
4
+ */
5
+ export declare function utcTimestamp(date: Date, zone?: string): string;
6
+ /**
7
+ * A timestamp's text as the `Date` it names, the one rule every driver and hydration read dates by: UTC
8
+ * where it names no zone, its own offset where it does, a bare day at UTC midnight, and the fraction cut
9
+ * to the milliseconds a `Date` holds. Text that is none of these, such as `infinity`, stays text.
10
+ */
11
+ export declare function decodeDate(text: string): Date | string;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `YYYY-MM-DD HH:mm:ss.SSS` in UTC, then `zone`: how a date is written, whichever machine writes it. Not
3
+ * `toISOString` as it is, whose `T` and `Z` MySQL rejects outright ("Invalid default value").
4
+ */
5
+ export function utcTimestamp(date, zone = '') {
6
+ return date.toISOString().replace('T', ' ').replace('Z', zone);
7
+ }
8
+ /** A time of day that names no zone, which `Date` would read in the process's own. */
9
+ const ZONELESS_TIME = /T[\d:.]+$/;
10
+ /**
11
+ * A timestamp's text as the `Date` it names, the one rule every driver and hydration read dates by: UTC
12
+ * where it names no zone, its own offset where it does, a bare day at UTC midnight, and the fraction cut
13
+ * to the milliseconds a `Date` holds. Text that is none of these, such as `infinity`, stays text.
14
+ */
15
+ export function decodeDate(text) {
16
+ const iso = text.replace(' ', 'T').replace(/(\.\d{3})\d+/, '$1');
17
+ const date = new Date(ZONELESS_TIME.test(iso) ? `${iso}Z` : iso);
18
+ return Number.isNaN(date.getTime()) ? text : date;
19
+ }
@@ -63,11 +63,8 @@ export declare function whereEach<E>(keys: readonly FieldKey<E>[], valueOf: (key
63
63
  export declare function whereAnyOf<E>(clauses: QueryWhereArray<E>): QueryWhere<E>;
64
64
  /** `q` selecting nothing but the id: what a write hands its backend's own read builder to settle the rows it will name. */
65
65
  export declare function idOnlyQuery<E>(meta: EntityMeta<E>, q: QuerySearch<E>): Query<E>;
66
- /**
67
- * The map form of a `$select` value, or `undefined` for the raw-array form. Centralizes the one
68
- * narrowing cast: `Array.isArray` does not narrow `readonly` arrays out of a union.
69
- */
70
- export declare function asSelectMap<E>(select: QuerySelectValue<E> | undefined): QuerySelect<E> | undefined;
66
+ /** Whether `select` is the list form `raw()` fills, narrowing both ways, which `Array.isArray` does not for a `readonly` array. */
67
+ export declare function isSelectList<E>(select: QuerySelectValue<E> | undefined): select is readonly QueryRaw[];
71
68
  export declare function normalizeScalarFieldSelection<E>(meta: EntityMeta<E>, select?: QuerySelect<E>, exclude?: QueryExclude<E>): FieldKey<E>[];
72
69
  /** Type guard: checks whether a sort value is a vector similarity search. */
73
70
  export declare function isVectorSearch(value: unknown): value is QueryVectorSearch;
@@ -138,12 +138,9 @@ export function whereAnyOf(clauses) {
138
138
  export function idOnlyQuery(meta, q) {
139
139
  return { ...q, $select: keySet(meta.ids) };
140
140
  }
141
- /**
142
- * The map form of a `$select` value, or `undefined` for the raw-array form. Centralizes the one
143
- * narrowing cast: `Array.isArray` does not narrow `readonly` arrays out of a union.
144
- */
145
- export function asSelectMap(select) {
146
- return Array.isArray(select) ? undefined : select;
141
+ /** Whether `select` is the list form `raw()` fills, narrowing both ways, which `Array.isArray` does not for a `readonly` array. */
142
+ export function isSelectList(select) {
143
+ return Array.isArray(select);
147
144
  }
148
145
  export function normalizeScalarFieldSelection(meta, select, exclude) {
149
146
  // A positive `$select` (the common case) wins outright and returns
@@ -1,6 +1,6 @@
1
1
  import { type ColumnFamily, type FamilyOf, type FieldOptions, QueryRaw, type StampEvent } from '../type/index.js';
2
2
  /**
3
- * The column family each field option means anything on, or `'*'` where it applies to every column.
3
+ * The column families each field option means anything on, or `'*'` where it applies to every column.
4
4
  * Exhaustive over {@link FieldOptions}, so a new option cannot be added without placing it - the
5
5
  * discipline `INDEX_FEATURE_LABELS` uses for index features.
6
6
  */
@@ -23,7 +23,7 @@ declare const FIELD_OPTION_FAMILY: {
23
23
  readonly version: 'numeric';
24
24
  readonly columnType: '*';
25
25
  readonly length: 'string';
26
- readonly precision: 'numeric';
26
+ readonly precision: readonly ["numeric", "date"];
27
27
  readonly scale: 'numeric';
28
28
  readonly nullable: '*';
29
29
  readonly unique: '*';
@@ -91,8 +91,10 @@ type DeadOptions<O> = (O extends {
91
91
  readonly nullable: true;
92
92
  } ? 'nullable' : never);
93
93
  type Given<O> = Extract<keyof O, keyof FieldOptions>;
94
+ /** The families option `K` applies to, `'*'` for every one. */
95
+ type OptionFamilies<K extends keyof FieldOptions> = (typeof FIELD_OPTION_FAMILY)[K] extends readonly (infer F)[] ? F : (typeof FIELD_OPTION_FAMILY)[K];
94
96
  type Offending<O> = {
95
- [K in Given<O>]: (typeof FIELD_OPTION_FAMILY)[K] extends OptionsFamily<O> | '*' ? K extends DeadOptions<O> ? K : never : K;
97
+ [K in Given<O>]: [Extract<OptionFamilies<K>, OptionsFamily<O> | '*'>] extends [never] ? K : K extends DeadOptions<O> ? K : never;
96
98
  }[Given<O>];
97
99
  /**
98
100
  * Every option `O` states but cannot use, mapped to `never`, so one that would be ignored does not compile.
@@ -3,7 +3,7 @@ import { columnFamily, isInlinedExpression } from './field.util.js';
3
3
  import { getKeys } from './object.util.js';
4
4
  import { constantSql } from './raw.js';
5
5
  /**
6
- * The column family each field option means anything on, or `'*'` where it applies to every column.
6
+ * The column families each field option means anything on, or `'*'` where it applies to every column.
7
7
  * Exhaustive over {@link FieldOptions}, so a new option cannot be added without placing it - the
8
8
  * discipline `INDEX_FEATURE_LABELS` uses for index features.
9
9
  */
@@ -26,7 +26,8 @@ const FIELD_OPTION_FAMILY = {
26
26
  version: 'numeric',
27
27
  columnType: '*',
28
28
  length: 'string',
29
- precision: 'numeric',
29
+ // A decimal's digits, or a timestamp's fractional-second digits.
30
+ precision: ['numeric', 'date'],
30
31
  scale: 'numeric',
31
32
  nullable: '*',
32
33
  unique: '*',
@@ -116,11 +117,11 @@ export function fieldOptionConflict(opts) {
116
117
  // conflicts always reports the same one. An option no rule knows is a typo, which `@Field`'s own check
117
118
  // reports where it can still be spelled right.
118
119
  for (const key of getKeys(FIELD_OPTION_FAMILY)) {
119
- const applies = FIELD_OPTION_FAMILY[key];
120
+ const applies = [FIELD_OPTION_FAMILY[key]].flat();
120
121
  if (opts[key] === undefined)
121
122
  continue;
122
- if (family && applies !== '*' && applies !== family) {
123
- return `cannot use '${key}': it applies to a ${applies} column, not to a ${family} one`;
123
+ if (family && !applies.includes('*') && !applies.includes(family)) {
124
+ return `cannot use '${key}': it applies to a ${applies.join(' or ')} column, not to a ${family} one`;
124
125
  }
125
126
  const dead = deadOn(opts, key);
126
127
  if (dead) {
@@ -2,7 +2,14 @@
2
2
  export declare function escapeSingleQuotes(val: string): string;
3
3
  /** The text a MySQL string literal's body stands for: its backslash escapes and doubled quotes undone. */
4
4
  export declare function unescapeMysqlString(body: string): string;
5
- /** Escape `value` for Postgres, SQLite and related dialects (single-quote doubling). */
5
+ /** How a Postgres timestamp says it is UTC. */
6
+ export declare const PG_UTC = "+00";
7
+ /** Escape `value` for SQLite and SQL Server (single-quote doubling). */
6
8
  export declare const escapeAnsiSqlLiteral: (value: unknown) => string;
9
+ /**
10
+ * Escape `value` for the Postgres family: ANSI, a date marked UTC, which a `TIMESTAMPTZ` otherwise reads
11
+ * in the session's zone.
12
+ */
13
+ export declare const escapePgSqlLiteral: (value: unknown) => string;
7
14
  /** Escape `value` for MySQL and MariaDB (backslash escaping). */
8
15
  export declare const escapeMysqlSqlLiteral: (value: unknown) => string;
@@ -1,6 +1,7 @@
1
1
  // SQL literal escaping for `Dialect.escape`: ANSI quote doubling, or MySQL's backslashes. UQL binds
2
2
  // values instead, so this is the hand-written-SQL hatch, and inline MySQL literals break under
3
3
  // `NO_BACKSLASH_ESCAPES` or a GBK-like charset: prefer bound parameters. Postgres arrays are separate.
4
+ import { utcTimestamp } from './date.js';
4
5
  import { UqlUsageError } from './uqlError.js';
5
6
  const SINGLE_QUOTE = /'/g;
6
7
  /** Doubles every single quote in `val`, the ANSI escaping shared by string literals and JSON path keys. */
@@ -43,12 +44,8 @@ function bytesToHexLiteral(bytes) {
43
44
  * A factory, not a function taking `escapeString` as an argument: threading it through every call
44
45
  * measured 1.1-1.5x slower. Rejects unsupported types rather than stringifying them into SQL.
45
46
  */
46
- function createEscaper(escapeString) {
47
- /**
48
- * `YYYY-MM-DD HH:mm:ss.SSS` in UTC, so the SQL is the same whichever machine wrote it. Not `toISOString`
49
- * as it is, whose `T` and `Z` MySQL rejects outright ("Invalid default value").
50
- */
51
- const dateLiteral = (date) => escapeString(date.toISOString().replace('T', ' ').replace('Z', ''));
47
+ function createEscaper(escapeString, zone = '') {
48
+ const dateLiteral = (date) => escapeString(utcTimestamp(date, zone));
52
49
  const sqlList = (arr) => {
53
50
  let sql = '';
54
51
  for (let i = 0; i < arr.length; i++) {
@@ -98,7 +95,14 @@ function createEscaper(escapeString) {
98
95
  };
99
96
  return escapeValue;
100
97
  }
101
- /** Escape `value` for Postgres, SQLite and related dialects (single-quote doubling). */
98
+ /** How a Postgres timestamp says it is UTC. */
99
+ export const PG_UTC = '+00';
100
+ /** Escape `value` for SQLite and SQL Server (single-quote doubling). */
102
101
  export const escapeAnsiSqlLiteral = createEscaper(ansiStringLiteral);
102
+ /**
103
+ * Escape `value` for the Postgres family: ANSI, a date marked UTC, which a `TIMESTAMPTZ` otherwise reads
104
+ * in the session's zone.
105
+ */
106
+ export const escapePgSqlLiteral = createEscaper(ansiStringLiteral, PG_UTC);
103
107
  /** Escape `value` for MySQL and MariaDB (backslash escaping). */
104
108
  export const escapeMysqlSqlLiteral = createEscaper(mysqlStringLiteral);
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "homepage": "https://uql-orm.dev",
4
4
  "description": "The JSON-native TypeScript ORM for Bun, Browsers, Edge, Deno, Node, Workers. Supports PostgreSQL, PGlite, MySQL, MariaDB, SQLite, CockroachDB, SQL Server, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.",
5
5
  "license": "MIT",
6
- "version": "0.82.0",
6
+ "version": "0.83.1",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"
@@ -74,7 +74,7 @@ export class Post {
74
74
  }
75
75
  ```
76
76
 
77
- - Every `@Field` states its `type` (`String`, `Number`, `Boolean`, `Date`, `BigInt`, or a column type such as `'uuid'`, `'text'`, `'jsonb'`), except a foreign key, which takes `references` and inherits the target key's type.
77
+ - Every `@Field` states its `type` (`String`, `Number`, `Boolean`, `Date` (an instant, bound and read as UTC on every engine; `TIMESTAMPTZ` on Postgres and CockroachDB, `DATETIME(3)` on MySQL and MariaDB; `precision` sets its fractional-second digits), `BigInt`, or a column type such as `'uuid'`, `'text'`, `'jsonb'`), except a foreign key, which takes `references` and inherits the target key's type.
78
78
  - A column is nullable unless it says `nullable: false`, and its property must admit `null` to match: `title?: string | null`. A property typed without `| null` on a nullable column is a compile error.
79
79
  - An engine's own column type is a `raw` constant, ``columnType: raw`tsvector` ``, rendered verbatim and carrying its own `length`/`precision`: never a bare string.
80
80
  - Members are named by callbacks, never by strings: `mappedBy: (post) => post.author`, `references: (post) => post.authorId`.