uql-orm 0.82.0 → 0.83.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/cockroachdb/crdbQuerierPool.js +2 -2
  2. package/dist/dialect/abstractSqlDialect.d.ts +1 -1
  3. package/dist/dialect/abstractSqlDialect.js +1 -1
  4. package/dist/dialect/hydrateColumn.js +2 -12
  5. package/dist/dialect/mysqlLikeSqlDialect.d.ts +2 -0
  6. package/dist/dialect/mysqlLikeSqlDialect.js +5 -0
  7. package/dist/dialect/pgLikeSqlDialect.d.ts +1 -0
  8. package/dist/dialect/pgLikeSqlDialect.js +13 -4
  9. package/dist/maria/mariadbQuerierPool.js +4 -2
  10. package/dist/migrate/builder/expressions.d.ts +2 -0
  11. package/dist/migrate/builder/expressions.js +18 -9
  12. package/dist/migrate/builder/tableBuilder.js +1 -1
  13. package/dist/migrate/generator/mongoSchemaGenerator.d.ts +1 -1
  14. package/dist/migrate/generator/mongoSchemaGenerator.js +1 -1
  15. package/dist/migrate/introspection/mongoIntrospector.d.ts +1 -1
  16. package/dist/migrate/introspection/mongoIntrospector.js +1 -1
  17. package/dist/migrate/introspection/mssqlIntrospector.js +13 -1
  18. package/dist/migrate/introspection/mysqlIntrospector.d.ts +2 -0
  19. package/dist/migrate/introspection/mysqlIntrospector.js +6 -2
  20. package/dist/migrate/introspection/postgresIntrospector.js +1 -1
  21. package/dist/migrate/storage/databaseStorage.js +1 -1
  22. package/dist/mongo/mongodbQuerier.js +4 -3
  23. package/dist/mssql/mssqlQuerier.d.ts +2 -0
  24. package/dist/mssql/mssqlQuerier.js +8 -5
  25. package/dist/mysql/mysql2QuerierPool.d.ts +1 -0
  26. package/dist/mysql/mysql2QuerierPool.js +20 -2
  27. package/dist/neon/neonQuerierPool.js +2 -2
  28. package/dist/pglite/pgliteQuerierPool.js +10 -4
  29. package/dist/postgres/pgQuerierPool.js +2 -2
  30. package/dist/postgres/{pgNumericTypes.d.ts → pgWireTypes.d.ts} +4 -3
  31. package/dist/postgres/{pgNumericTypes.js → pgWireTypes.js} +7 -3
  32. package/dist/schema/canonicalType.d.ts +3 -0
  33. package/dist/schema/canonicalType.js +31 -9
  34. package/dist/schema/schemaASTDiffer.js +4 -2
  35. package/dist/sqlite/sqliteDialect.d.ts +1 -3
  36. package/dist/sqlite/sqliteDialect.js +3 -6
  37. package/dist/type/entity.d.ts +1 -1
  38. package/dist/util/date.d.ts +11 -0
  39. package/dist/util/date.js +19 -0
  40. package/dist/util/fieldOption.util.d.ts +5 -3
  41. package/dist/util/fieldOption.util.js +6 -5
  42. package/dist/util/sqlLiteral.d.ts +8 -1
  43. package/dist/util/sqlLiteral.js +11 -7
  44. package/package.json +1 -1
  45. package/skills/uql-orm/SKILL.md +1 -1
@@ -1,13 +1,13 @@
1
1
  import { Pool, types } from 'pg';
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 { CockroachDialect } from './cockroachDialect.js';
6
6
  /**
7
7
  * QuerierPool for CockroachDB using the `pg` driver Pool.
8
8
  */
9
9
  export class CrdbQuerierPool extends AbstractPgQuerierPool {
10
10
  constructor(opts, extra) {
11
- super(new CockroachDialect(dialectOptionsFrom(extra)), new Pool({ keepAlive: true, types: numericTypes(types), ...opts }), extra);
11
+ super(new CockroachDialect(dialectOptionsFrom(extra)), new Pool({ keepAlive: true, types: wireTypes(types), ...opts }), extra);
12
12
  }
13
13
  }
@@ -698,7 +698,7 @@ export declare abstract class AbstractSqlDialect extends VectorSqlDialect implem
698
698
  private sizeCondition;
699
699
  /** `<distance> <op> ?`, the `$where` half of a vector search, its bounds checked here since `/http` input is untyped. */
700
700
  private vectorNearCondition;
701
- /** ANSI-style single-quote escaping. MySQL-family dialects override this for backslash escaping. */
701
+ /** ANSI-style single-quote escaping. */
702
702
  escape(value: unknown): string;
703
703
  protected get regexpOp(): string;
704
704
  /**
@@ -1953,7 +1953,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1953
1953
  const distance = (fragmentCtx) => this.appendVectorDistance(fragmentCtx, meta, key, near, prefix);
1954
1954
  return this.boundConditions(ctx, distance, bounds, (operand, op, val) => (isOrderedOp(op) ? this.operatorCondition(ctx, operand, op, val) : undefined), 'unsupported $near bound');
1955
1955
  }
1956
- /** ANSI-style single-quote escaping. MySQL-family dialects override this for backslash escaping. */
1956
+ /** ANSI-style single-quote escaping. */
1957
1957
  escape(value) {
1958
1958
  return escapeAnsiSqlLiteral(value);
1959
1959
  }
@@ -1,3 +1,4 @@
1
+ import { decodeDate } from '../util/date.js';
1
2
  import { decodeWideNumber } from '../util/wideNumber.js';
2
3
  import { decodeFloat32s, parseVectorLiteral } from './vectorCast.js';
3
4
  /**
@@ -31,7 +32,7 @@ const float32Decoder = (value) => {
31
32
  const DECODERS = {
32
33
  // 0/1 from SQLite's INTEGER or MySQL's TINYINT(1). Already a boolean on Postgres.
33
34
  boolean: (value) => (typeof value === 'boolean' ? value : Boolean(value)),
34
- date: (value) => (typeof value === 'string' ? (parseDate(value) ?? value) : value),
35
+ date: (value) => (typeof value === 'string' ? decodeDate(value) : value),
35
36
  // Only a string can be bytes that crossed JSON: bytes a driver already decoded stay as they are.
36
37
  bytes: (value) => typeof value === 'string' && value.startsWith(BYTES_PREFIX) ? hexBytes(value.slice(BYTES_PREFIX.length)) : value,
37
38
  // A number too, not just text: `type: BigInt` is BIGINT, which the pg pools decode at the wire.
@@ -61,17 +62,6 @@ const DECODERS = {
61
62
  halfvec: vectorDecoder('halfvec'),
62
63
  sparsevec: vectorDecoder('sparsevec'),
63
64
  };
64
- /**
65
- * An ISO 8601 timestamp as a `Date`, its fraction cut to the milliseconds one holds, and a bare date at
66
- * local midnight, which is how `pg` reads a `date`. `undefined` for text that is neither.
67
- */
68
- function parseDate(text) {
69
- const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
70
- const date = day
71
- ? new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3]))
72
- : new Date(text.replace(/(\.\d{3})\d+/, '$1'));
73
- return Number.isNaN(date.getTime()) ? undefined : date;
74
- }
75
65
  /**
76
66
  * What bytes crossing JSON start with, before two hex digits per byte: Postgres's own text for `bytea`,
77
67
  * which every dialect spells, so a string a driver reads from a column on its own is never mistaken.
@@ -78,6 +78,8 @@ export declare abstract class MysqlLikeSqlDialect extends AbstractSqlDialect {
78
78
  /** Bytes as the hex text `decodeColumn` reads back, whole. */
79
79
  protected bytesAsText(expr: string): string;
80
80
  escape(value: unknown): string;
81
+ /** A date as UTC text, which a `DATETIME` stores as is, where a driver would convert it to its own zone. */
82
+ normalizeValue(value: unknown): unknown;
81
83
  /**
82
84
  * `MATCH(cols) AGAINST(?)`, which needs a `FULLTEXT` index over exactly those columns: without one
83
85
  * the server answers "Can't find FULLTEXT index matching the column list". Declare it with
@@ -1,4 +1,5 @@
1
1
  import { getMeta } from '../entity/index.js';
2
+ import { utcTimestamp } from '../util/date.js';
2
3
  import { textSearchFields } from '../util/index.js';
3
4
  import { escapeMysqlSqlLiteral, escapeSingleQuotes } from '../util/sqlLiteral.js';
4
5
  import { AbstractSqlDialect, } from './abstractSqlDialect.js';
@@ -187,6 +188,10 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
187
188
  escape(value) {
188
189
  return escapeMysqlSqlLiteral(value);
189
190
  }
191
+ /** A date as UTC text, which a `DATETIME` stores as is, where a driver would convert it to its own zone. */
192
+ normalizeValue(value) {
193
+ return value instanceof Date ? utcTimestamp(value) : super.normalizeValue(value);
194
+ }
190
195
  /**
191
196
  * `MATCH(cols) AGAINST(?)`, which needs a `FULLTEXT` index over exactly those columns: without one
192
197
  * the server answers "Can't find FULLTEXT index matching the column list". Declare it with
@@ -105,6 +105,7 @@ export declare abstract class PgLikeSqlDialect extends AbstractSqlDialect {
105
105
  * array takes its type from `operand`, so it needs none of the casts `bind` would give each value.
106
106
  */
107
107
  protected formatIn(ctx: QueryContext, operand: string, values: unknown[], negate: boolean, bind: (value: unknown) => string): string;
108
+ escape(value: unknown): string;
108
109
  protected numericCast(expr: string): string;
109
110
  protected appendJsonValue(ctx: QueryContext, value: unknown, type: JsonColumnType): void;
110
111
  /**
@@ -1,6 +1,7 @@
1
1
  import { QueryRaw, } from '../type/index.js';
2
+ import { utcTimestamp } from '../util/date.js';
2
3
  import { fulltextConfig, fulltextIndexOver, hasVectorNear, textSearchFields } from '../util/dialect.util.js';
3
- import { escapeSingleQuotes } from '../util/sqlLiteral.js';
4
+ import { escapePgSqlLiteral, escapeSingleQuotes, PG_UTC } from '../util/sqlLiteral.js';
4
5
  import { AbstractSqlDialect } from './abstractSqlDialect.js';
5
6
  import { JSON_PULL_ALIAS, RELATION_ROW_ALIAS } from './aliases.js';
6
7
  import { BYTES_PREFIX } from './hydrateColumn.js';
@@ -128,10 +129,11 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
128
129
  return statements;
129
130
  }
130
131
  normalizeValue(value) {
131
- if (value != null && typeof value === 'object' && Array.isArray(value)) {
132
- return this.driverCapabilities.nativeArrays ? value : toPgArray(value);
132
+ if (Array.isArray(value)) {
133
+ const values = value.map(utcDate);
134
+ return this.driverCapabilities.nativeArrays ? values : toPgArray(values);
133
135
  }
134
- return super.normalizeValue(value);
136
+ return super.normalizeValue(utcDate(value));
135
137
  }
136
138
  placeholder(index) {
137
139
  return `$${index}`;
@@ -240,6 +242,9 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
240
242
  const ph = this.addValue(ctx, values);
241
243
  return negate ? `${operand} <> ALL(${ph})` : `${operand} = ANY(${ph})`;
242
244
  }
245
+ escape(value) {
246
+ return escapePgSqlLiteral(value);
247
+ }
243
248
  numericCast(expr) {
244
249
  return `(${expr})::numeric`;
245
250
  }
@@ -306,6 +311,10 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
306
311
  return this.driverCapabilities.explicitJsonCast ? `(${ph}::text)::${type}` : `${ph}::${type}`;
307
312
  }
308
313
  }
314
+ /** A date as UTC text, which a zoneless `TIMESTAMP` stores as is, where each driver would pick its own zone. */
315
+ function utcDate(value) {
316
+ return value instanceof Date ? utcTimestamp(value, PG_UTC) : value;
317
+ }
309
318
  /**
310
319
  * Converts a JS array to a Postgres array literal string: `{"val1","val2"}`.
311
320
  * Safely handles nesting and escaping of special characters.
@@ -9,8 +9,10 @@ export class MariadbQuerierPool extends AbstractSqlQuerierPool {
9
9
  constructor(opts, extra) {
10
10
  super(new MariaDialect(dialectOptionsFrom(extra)), extra);
11
11
  // BIGINT stays the driver's `bigint`, which `MariadbQuerier` decodes by the rule every driver here
12
- // shares (`decodeWideNumber`) - not `bigIntAsNumber`, which rounds past 2^53 without a word.
13
- this.pool = createPool(opts);
12
+ // shares (`decodeWideNumber`) - not `bigIntAsNumber`, which rounds past 2^53 without a word. A date
13
+ // reads as its UTC text, which hydration decodes, since the connector would take it for local time
14
+ // whatever `timezone` says; that only sets the session's zone, UTC, so `NOW()` agrees.
15
+ this.pool = createPool({ timezone: 'Z', dateStrings: true, ...opts });
14
16
  // `mariadb` fires 'error' at runtime without declaring it, hence the cast; this makes it visible.
15
17
  attachPoolErrorHandler(this.pool, 'Idle MariaDB pool connection encountered an error', extra?.logger);
16
18
  }
@@ -13,6 +13,8 @@ export type DialectDefaults = {
13
13
  readonly expressions: SqlExpressionMap;
14
14
  /** Column types whose `DEFAULT` this engine takes only as a parenthesized expression. */
15
15
  readonly wrapTypes?: RegExp;
16
+ /** Column types whose `CURRENT_TIMESTAMP` default must repeat their precision, captured by the pattern. */
17
+ readonly preciseTypes?: RegExp;
16
18
  };
17
19
  /**
18
20
  * Looked up by name rather than carried on the dialect, which keeps DDL data out of the query
@@ -15,6 +15,8 @@ const MYSQL = {
15
15
  };
16
16
  /** MySQL 8.0.13+ rejects `DEFAULT 'x'` on these but accepts `DEFAULT ('x')`, whatever the value. */
17
17
  const MYSQL_LARGE_TYPES = /^\s*(TINY|MEDIUM|LONG)?(TEXT|BLOB)|^\s*(JSON|GEOMETRY)\b/i;
18
+ /** A `DATETIME(3)` or `TIMESTAMP(3)`, whose fractional-second precision is captured. */
19
+ const MYSQL_PRECISE_TYPES = /^\s*(?:DATETIME|TIMESTAMP)\((\d)\)/i;
18
20
  /**
19
21
  * Looked up by name rather than carried on the dialect, which keeps DDL data out of the query
20
22
  * bundle - the same split that keeps `CANONICAL_TO_SQL` in `schema/canonicalType.ts`. `uuidv7()` is
@@ -24,8 +26,12 @@ const MYSQL_LARGE_TYPES = /^\s*(TINY|MEDIUM|LONG)?(TEXT|BLOB)|^\s*(JSON|GEOMETRY
24
26
  export const DIALECT_DEFAULTS = {
25
27
  postgres: { expressions: { ...PG, uuidv7: 'uuidv7()' } },
26
28
  cockroachdb: { expressions: PG },
27
- mysql: { expressions: MYSQL, wrapTypes: MYSQL_LARGE_TYPES },
28
- mariadb: { expressions: { ...MYSQL, uuidv7: 'UUID_v7()' }, wrapTypes: MYSQL_LARGE_TYPES },
29
+ mysql: { expressions: MYSQL, wrapTypes: MYSQL_LARGE_TYPES, preciseTypes: MYSQL_PRECISE_TYPES },
30
+ mariadb: {
31
+ expressions: { ...MYSQL, uuidv7: 'UUID_v7()' },
32
+ wrapTypes: MYSQL_LARGE_TYPES,
33
+ preciseTypes: MYSQL_PRECISE_TYPES,
34
+ },
29
35
  sqlite: { expressions: ANSI },
30
36
  // `SYSUTCDATETIME()` over `CURRENT_TIMESTAMP`, which is local time in the server's zone. No
31
37
  // `uuidv7`: `NEWSEQUENTIALID()` is an ordered v4 GUID, so it carries no readable timestamp and
@@ -80,7 +86,7 @@ export const expr = {
80
86
  * the result needs wrapping, which MySQL demands on its large types whatever the value.
81
87
  */
82
88
  export function formatDefaultValue(value, dialect, columnType) {
83
- const sql = defaultLiteral(value, dialect);
89
+ const sql = defaultLiteral(value, dialect, columnType);
84
90
  const { wrapTypes } = DIALECT_DEFAULTS[dialect.dialectName];
85
91
  return columnType !== undefined && wrapTypes?.test(columnType) ? `(${sql})` : sql;
86
92
  }
@@ -114,23 +120,26 @@ export function sameDefault(desired, current, dialect) {
114
120
  * cannot serve stay here: a boolean is `1` where booleans are integers, and a plain object or array
115
121
  * is JSON rather than the throw and the IN-list `escape` gives them.
116
122
  */
117
- function defaultLiteral(value, dialect) {
123
+ function defaultLiteral(value, dialect, columnType) {
118
124
  if (value === undefined || value === null) {
119
125
  return 'NULL';
120
126
  }
121
127
  if (SqlExpression.isExpression(value)) {
122
- return expressionSql(value, dialect);
128
+ return expressionSql(value, dialect, columnType);
123
129
  }
124
130
  if (typeof value === 'boolean') {
125
131
  return dialect.booleanLiteral === 'native' ? (value ? 'TRUE' : 'FALSE') : value ? '1' : '0';
126
132
  }
127
133
  return dialect.escape(typeof value === 'object' && !(value instanceof Date) ? JSON.stringify(value) : value);
128
134
  }
129
- function expressionSql(expression, dialect) {
130
- const { expressions } = DIALECT_DEFAULTS[dialect.dialectName];
131
- const sql = expression.kind === 'raw' ? expression.sql : expressions[expression.kind];
135
+ function expressionSql(expression, dialect, columnType) {
136
+ const { expressions, preciseTypes } = DIALECT_DEFAULTS[dialect.dialectName];
137
+ const raw = expression.kind === 'raw';
138
+ const sql = raw ? expression.sql : expressions[expression.kind];
132
139
  if (sql == null) {
133
140
  throw new UqlUsageError(`${dialect.dialectName} has no '${expression.kind}' default; pass expr.raw(...) with SQL this engine accepts`);
134
141
  }
135
- return sql;
142
+ // A raw default is the caller's SQL, never rewritten.
143
+ const precision = raw || columnType === undefined ? undefined : preciseTypes?.exec(columnType)?.[1];
144
+ return precision === undefined ? sql : sql.replaceAll('CURRENT_TIMESTAMP', `CURRENT_TIMESTAMP(${precision})`);
136
145
  }
@@ -144,7 +144,7 @@ export class TableBuilder {
144
144
  return this.timestampNow('updatedAt');
145
145
  }
146
146
  timestampNow(name) {
147
- return this.add(name, { category: 'timestamp' }, { defaultValue: expr.now() });
147
+ return this.timestamptz(name, { defaultValue: expr.now() });
148
148
  }
149
149
  timestamps() {
150
150
  this.createdAt();
@@ -33,7 +33,7 @@ export declare class MongoSchemaGenerator extends MongoDialect implements Schema
33
33
  generateDropTable(tableName: string): string;
34
34
  /** A collection's indexes: each dropped, then each created, an alter as both. */
35
35
  generateAlterTable(diff: SchemaDiff): string[];
36
- /** MongoDB has no triggers, and a write to an entity declaring one is refused, so there is none to reconcile. */
36
+ /** MongoDB runs no trigger within a write, and a write to an entity declaring one is refused, so there is none to reconcile. */
37
37
  generateTriggers(): string[];
38
38
  generateTriggersDown(): string[];
39
39
  generateTriggerDrops(): string[];
@@ -115,7 +115,7 @@ export class MongoSchemaGenerator extends MongoDialect {
115
115
  ...sides(diff.indexes, 'to').map((index) => this.generateCreateIndex(diff.tableName, index)),
116
116
  ];
117
117
  }
118
- /** MongoDB has no triggers, and a write to an entity declaring one is refused, so there is none to reconcile. */
118
+ /** MongoDB runs no trigger within a write, and a write to an entity declaring one is refused, so there is none to reconcile. */
119
119
  generateTriggers() {
120
120
  return [];
121
121
  }
@@ -10,7 +10,7 @@ export declare class MongoSchemaIntrospector implements SchemaIntrospector {
10
10
  /** `listIndexes` reports keys, uniqueness and text weights; a `partialFilterExpression` is no SQL predicate. */
11
11
  readonly indexFacets: ReadonlySet<IndexFacet>;
12
12
  constructor(pool: QuerierPool);
13
- /** MongoDB has no triggers, so none is ever installed. */
13
+ /** MongoDB runs no trigger within a write, so uql installs none. */
14
14
  ownedTriggers(): Promise<InstalledTriggers>;
15
15
  introspect(tables?: readonly string[]): Promise<SchemaAST>;
16
16
  getTableSchema(tableName: string): Promise<TableSchema | undefined>;
@@ -15,7 +15,7 @@ export class MongoSchemaIntrospector {
15
15
  constructor(pool) {
16
16
  this.pool = pool;
17
17
  }
18
- /** MongoDB has no triggers, so none is ever installed. */
18
+ /** MongoDB runs no trigger within a write, so uql installs none. */
19
19
  async ownedTriggers() {
20
20
  return new Map();
21
21
  }
@@ -1,3 +1,4 @@
1
+ import { defaultTimestampPrecision } from '../../schema/canonicalType.js';
1
2
  import { AbstractSqlSchemaIntrospector, } from './abstractSqlSchemaIntrospector.js';
2
3
  /**
3
4
  * SQL Server schema introspector.
@@ -137,7 +138,7 @@ export class MsSqlSchemaIntrospector extends AbstractSqlSchemaIntrospector {
137
138
  const bytes = this.toNumber(row.max_length);
138
139
  return {
139
140
  name: row.column_name,
140
- type: bytes === -1 && CHARACTER_TYPES.has(type) ? `${type}(MAX)` : type,
141
+ type: spelledType(type, bytes, this.toNumber(row.numeric_scale)),
141
142
  nullable: Boolean(row.is_nullable),
142
143
  defaultValue: this.parseDefaultValue(row.column_default),
143
144
  isAutoIncrement: Boolean(row.is_identity),
@@ -183,6 +184,17 @@ export class MsSqlSchemaIntrospector extends AbstractSqlSchemaIntrospector {
183
184
  /** The types whose `max_length` is a width rather than a fixed storage size. */
184
185
  const CHARACTER_TYPES = new Set(['CHAR', 'NCHAR', 'VARCHAR', 'NVARCHAR', 'BINARY', 'VARBINARY']);
185
186
  const NUMERIC_TYPES = new Set(['DECIMAL', 'NUMERIC']);
187
+ /** `(MAX)` on an unbounded character type, and a timestamp's fractional digits where not the engine's default. */
188
+ function spelledType(type, bytes, scale) {
189
+ if (bytes === -1 && CHARACTER_TYPES.has(type)) {
190
+ return `${type}(MAX)`;
191
+ }
192
+ return (type === 'DATETIME2' || type === 'DATETIMEOFFSET') &&
193
+ scale !== undefined &&
194
+ scale !== defaultTimestampPrecision('mssql')
195
+ ? `${type}(${scale})`
196
+ : type;
197
+ }
186
198
  /**
187
199
  * A column's declared width from `max_length`, which is bytes: an `N` type holds two a character, a
188
200
  * `VECTOR` is an 8-byte header and four a dimension, and `MAX` is `-1`. Read as bytes, every Unicode
@@ -29,6 +29,7 @@ export declare class MysqlSchemaIntrospector extends AbstractSqlSchemaIntrospect
29
29
  }
30
30
  type MysqlColumnRow = {
31
31
  column_name: string;
32
+ data_type: string;
32
33
  column_type: string;
33
34
  is_nullable: string;
34
35
  column_default: string | null;
@@ -36,6 +37,7 @@ type MysqlColumnRow = {
36
37
  extra: string;
37
38
  character_maximum_length: number | bigint | null;
38
39
  numeric_precision: number | bigint | null;
40
+ datetime_precision: number | bigint | null;
39
41
  numeric_scale: number | null;
40
42
  column_comment: string | null;
41
43
  generated_as: string | null;
@@ -47,6 +47,7 @@ export class MysqlSchemaIntrospector extends AbstractSqlSchemaIntrospector {
47
47
  CHARACTER_MAXIMUM_LENGTH as character_maximum_length,
48
48
  NUMERIC_PRECISION as numeric_precision,
49
49
  NUMERIC_SCALE as numeric_scale,
50
+ DATETIME_PRECISION as datetime_precision,
50
51
  COLUMN_KEY as column_key,
51
52
  EXTRA as extra,
52
53
  CASE WHEN EXTRA LIKE '%STORED GENERATED%' THEN GENERATION_EXPRESSION END as generated_as,
@@ -113,7 +114,8 @@ export class MysqlSchemaIntrospector extends AbstractSqlSchemaIntrospector {
113
114
  isUnique: row.column_key === 'UNI',
114
115
  // A `VECTOR`'s is its bytes, four a dimension, which `column_type` already states as dimensions.
115
116
  length: /^vector/i.test(row.column_type) ? undefined : this.toNumber(row.character_maximum_length),
116
- precision: this.toNumber(row.numeric_precision),
117
+ // A timestamp's fractional digits, stated even when 0, which uql's own unstated `DATETIME(3)` is not.
118
+ precision: this.toNumber(TIMESTAMP_TYPES.has(row.data_type) ? row.datetime_precision : row.numeric_precision),
117
119
  scale: this.toNumber(row.numeric_scale),
118
120
  comment: row.column_comment || undefined,
119
121
  generatedAs: row.generated_as ?? undefined,
@@ -146,7 +148,8 @@ export class MysqlSchemaIntrospector extends AbstractSqlSchemaIntrospector {
146
148
  if (normalized === 'NULL') {
147
149
  return null;
148
150
  }
149
- if (normalized === 'CURRENT_TIMESTAMP' || normalized === 'CURRENT_TIMESTAMP()') {
151
+ // Whatever precision it repeats from its column, which the column's own type already states.
152
+ if (/^CURRENT_TIMESTAMP(?:\(\d?\))?$/.test(normalized)) {
150
153
  return 'CURRENT_TIMESTAMP';
151
154
  }
152
155
  if (/^-?\d+(\.\d+)?$/.test(defaultValue)) {
@@ -158,6 +161,7 @@ export class MysqlSchemaIntrospector extends AbstractSqlSchemaIntrospector {
158
161
  return quoted ? unescapeMysqlString(quoted[1]) : literal;
159
162
  }
160
163
  }
164
+ const TIMESTAMP_TYPES = new Set(['datetime', 'timestamp']);
161
165
  /**
162
166
  * MariaDB reads out of the same `information_schema` as MySQL, save for one column type it does not
163
167
  * have: `JSON` there is an alias for `LONGTEXT` plus a `json_valid()` check constraint named after
@@ -75,7 +75,7 @@ export class PostgresSchemaIntrospector extends AbstractSqlSchemaIntrospector {
75
75
  c.is_identity,
76
76
  c.identity_generation,
77
77
  CASE WHEN a.attgenerated = 's' THEN c.generation_expression END AS generated_as,
78
- CASE WHEN c.data_type IN ('USER-DEFINED', 'vector') AND a.atttypmod > -1
78
+ CASE WHEN (c.data_type IN ('USER-DEFINED', 'vector') OR c.data_type LIKE 'timestamp%') AND a.atttypmod > -1
79
79
  THEN format_type(a.atttypid, a.atttypmod) END AS formatted_type,
80
80
  EXISTS (
81
81
  SELECT 1 FROM information_schema.table_constraints tc
@@ -32,7 +32,7 @@ export class DatabaseMigrationStorage {
32
32
  async createTableIfNotExists(querier) {
33
33
  const table = new TableBuilder(this.tableName);
34
34
  table.string('name', { length: 255, primaryKey: true });
35
- table.timestamp('executed_at', { defaultValue: expr.now() });
35
+ table.timestamptz('executed_at', { defaultValue: expr.now() });
36
36
  const generator = new SqlSchemaGenerator(querier.dialect);
37
37
  for (const sql of generator.generateCreateTableFromDefinition(table.build(), { ifNotExists: true })) {
38
38
  await querier.run(sql);
@@ -13,12 +13,13 @@ function asksForNoRows(q) {
13
13
  return q.$limit === 0;
14
14
  }
15
15
  /**
16
- * MongoDB has no triggers, so a write to an entity declaring one - a stamp included - would skip it
17
- * silently. Refused instead, as a query naming SQL is.
16
+ * MongoDB runs no trigger within a write (Atlas Database Triggers fire after the commit), so a write to
17
+ * an entity declaring one - a stamp included - would skip it silently. Refused instead, as a query naming
18
+ * SQL is. The why, in `architecture/triggers.md`.
18
19
  */
19
20
  function refuseTriggers(entity) {
20
21
  if (hasTriggers(getMeta(entity))) {
21
- throw new UqlUsageError(`'${entity.name}' declares triggers, which MongoDB has none of: a write here would skip them. ` +
22
+ throw new UqlUsageError(`'${entity.name}' declares triggers, which MongoDB cannot run within a write: a write here would skip them. ` +
22
23
  'Keep the entity on a SQL engine, or drop its triggers and stamps.');
23
24
  }
24
25
  }
@@ -1,3 +1,4 @@
1
+ import { DateTime2 } from 'mssql';
1
2
  import { AbstractPoolQuerier } from '../querier/abstractPoolQuerier.js';
2
3
  import type { QueryUpdateResult, RawRow, TransactionOptions } from '../type/index.js';
3
4
  /** What `tedious` hands back for one statement, whichever shape it took. */
@@ -17,6 +18,7 @@ type MsSqlRowStream = AsyncIterable<unknown> & {
17
18
  /** The part of an `mssql` `Request` a querier drives. */
18
19
  type MsSqlRequest = {
19
20
  input(name: string, value: unknown): unknown;
21
+ input(name: string, type: typeof DateTime2, value: unknown): unknown;
20
22
  query(command: string): Promise<MsSqlResult>;
21
23
  toReadableStream(): MsSqlRowStream;
22
24
  cancel(): unknown;
@@ -1,4 +1,4 @@
1
- import { ISOLATION_LEVEL } from 'mssql';
1
+ import { DateTime2, ISOLATION_LEVEL } from 'mssql';
2
2
  import { AbstractPoolQuerier } from '../querier/abstractPoolQuerier.js';
3
3
  import { decodeWireTypes } from './mssqlWireTypes.js';
4
4
  /**
@@ -9,13 +9,16 @@ import { decodeWireTypes } from './mssqlWireTypes.js';
9
9
  export class MsSqlQuerier extends AbstractPoolQuerier {
10
10
  #transaction;
11
11
  /**
12
- * Values bind by name, `@p1` upward, matching {@link MsSqlDialect.placeholder}. `tedious` infers
13
- * a type from the JS value, which is why a `Date` and a `Uint8Array` reach it unconverted - the
14
- * inference is right for both, and wrong only for a bare `null`, which it calls `NVarChar`.
12
+ * Values bind by name, `@p1` upward, matching {@link MsSqlDialect.placeholder}. `mssql` infers a type
13
+ * from the JS value, right for a `Uint8Array` and harmlessly wrong for a bare `null` (`NVarChar`), but
14
+ * a `Date` it binds as the legacy `DATETIME`, whose 1/300 s steps no `DATETIME2` column compares equal to.
15
15
  */
16
16
  #request(values) {
17
17
  const request = this.#transaction ? this.#transaction.request() : this.getConn().request();
18
- values?.forEach((value, index) => request.input(`p${index + 1}`, value));
18
+ values?.forEach((value, index) => {
19
+ const name = `p${index + 1}`;
20
+ return value instanceof Date ? request.input(name, DateTime2, value) : request.input(name, value);
21
+ });
19
22
  return request;
20
23
  }
21
24
  async internalAll(query, values) {
@@ -4,6 +4,7 @@ import type { ExtraOptions } from '../type/index.js';
4
4
  import { MySql2Querier } from './mysql2Querier.js';
5
5
  import { MySqlDialect } from './mysqlDialect.js';
6
6
  export declare class MySql2QuerierPool extends AbstractSqlQuerierPool<MySql2Querier, MySqlDialect> {
7
+ #private;
7
8
  readonly pool: Pool;
8
9
  constructor(opts: PoolOptions, extra?: ExtraOptions);
9
10
  getQuerier(): Promise<MySql2Querier>;
@@ -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;
@@ -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
+ }
@@ -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.0",
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`.