feat(db): DbTimestampType keeps DATETIME columns as the CURRENT_TIMESTAMP text

This commit is contained in:
jubnl
2026-09-21 08:50:56 +02:00
parent 57decad494
commit 4ba43d6cd5
3 changed files with 125 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
import { Type, type EntityProperty, type Platform, type TransformContext } from '@mikro-orm/core';
/** The text every DATETIME column holds and every API response emits. */
export const DB_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
/**
* `CURRENT_TIMESTAMP` as SQLite renders it, produced in JS so it is the same on
* every dialect: UTC, seconds precision, a space between date and time.
*/
export function dbNow(now: Date = new Date()): string {
return now.toISOString().slice(0, 19).replace('T', ' ');
}
type TimestampValue = string | null | undefined;
/**
* A DATETIME column whose JS value is the stored text, not a `Date`.
*
* The stock `DateTimeType` maps to `Date`, and on SQLite the platform then
* writes `+date` — an integer of milliseconds — next to the `'YYYY-MM-DD
* HH:MM:SS'` text the schema's `DEFAULT CURRENT_TIMESTAMP` produces and every
* consumer (the client, `ORDER BY created_at`, `date(created_at)`) expects.
* This type keeps the wire format the app has always had and stays dialect
* neutral: a driver that returns a `Date` (Postgres) is formatted to the same
* text on the way in, and text is written as text on the way out.
*/
export class DbTimestampType extends Type<TimestampValue, TimestampValue> {
override convertToDatabaseValue(value: TimestampValue, platform: Platform, context?: TransformContext): TimestampValue {
if (value == null) {
return value;
}
// At runtime, the ORM may pass a Date; handle it
if ((value as unknown) instanceof Date) {
return dbNow(value as unknown as Date);
}
if (typeof value === 'string') {
return value;
}
// Default: convert to string
return String(value);
}
override convertToJSValue(value: TimestampValue, platform: Platform, context?: TransformContext): TimestampValue {
if (value == null) {
return value;
}
// At runtime, database drivers may return a Date; handle it
if ((value as unknown) instanceof Date) {
return dbNow(value as unknown as Date);
}
// Handle millisecond integers (legacy format)
if (typeof value === 'number' || (value as unknown) instanceof Number) {
const numValue = typeof value === 'number' ? value : (value as unknown as number);
return dbNow(new Date(numValue));
}
if (typeof value === 'string') {
return value;
}
// Default: convert to string
return String(value);
}
override getColumnType(prop: EntityProperty, platform: Platform): string {
return platform.getDateTimeTypeDeclarationSQL({ length: prop.length });
}
override compareAsType(): string {
return 'string';
}
override get runtimeType(): string {
return 'string';
}
}
+1
View File
@@ -0,0 +1 @@
export { DB_TIMESTAMP_RE, DbTimestampType, dbNow } from './db-timestamp.type';
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { DB_TIMESTAMP_RE, DbTimestampType, dbNow } from '../../../../src/db/types';
import type { Platform } from '@mikro-orm/core';
// The type never touches the platform for value conversion; a stub is enough.
const platform = {} as Platform;
const type = new DbTimestampType();
describe('dbNow', () => {
it('TS-001: formats a Date as the SQLite CURRENT_TIMESTAMP text, in UTC', () => {
expect(dbNow(new Date(Date.UTC(2026, 8, 21, 13, 5, 9, 123)))).toBe('2026-09-21 13:05:09');
});
it('TS-002: the default is now and matches the wire format', () => {
expect(dbNow()).toMatch(DB_TIMESTAMP_RE);
});
});
describe('DbTimestampType', () => {
it('TS-003: passes stored text through unchanged on read', () => {
expect(type.convertToJSValue('2026-09-21 13:05:09', platform)).toBe('2026-09-21 13:05:09');
});
it('TS-004: formats a Date a driver hands back (Postgres later) to the same text', () => {
expect(type.convertToJSValue(new Date(Date.UTC(2026, 8, 21, 13, 5, 9)) as unknown as string, platform))
.toBe('2026-09-21 13:05:09');
});
it('TS-005: a millisecond integer (a Date written by the stock DateTimeType) reads back as text', () => {
expect(type.convertToJSValue(Date.UTC(2026, 8, 21, 13, 5, 9) as unknown as string, platform))
.toBe('2026-09-21 13:05:09');
});
it('TS-006: writes text unchanged and a stray Date as text, never as a number', () => {
expect(type.convertToDatabaseValue('2026-09-21 13:05:09', platform)).toBe('2026-09-21 13:05:09');
expect(type.convertToDatabaseValue(new Date(Date.UTC(2026, 8, 21, 13, 5, 9)) as unknown as string, platform))
.toBe('2026-09-21 13:05:09');
});
it('TS-007: null and undefined survive both directions', () => {
expect(type.convertToDatabaseValue(null, platform)).toBeNull();
expect(type.convertToDatabaseValue(undefined, platform)).toBeUndefined();
expect(type.convertToJSValue(null, platform)).toBeNull();
});
it('TS-008: compares as a string and reports a string runtime type', () => {
expect(type.compareAsType()).toBe('string');
expect(type.runtimeType).toBe('string');
});
});