From 4ba43d6cd526bddb61cf1a4aaa1127bcf733fdd3 Mon Sep 17 00:00:00 2001 From: jubnl Date: Mon, 21 Sep 2026 08:50:56 +0200 Subject: [PATCH] feat(db): DbTimestampType keeps DATETIME columns as the CURRENT_TIMESTAMP text --- server/src/db/types/db-timestamp.type.ts | 74 +++++++++++++++++++ server/src/db/types/index.ts | 1 + .../unit/db/types/db-timestamp.type.test.ts | 50 +++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 server/src/db/types/db-timestamp.type.ts create mode 100644 server/src/db/types/index.ts create mode 100644 server/tests/unit/db/types/db-timestamp.type.test.ts diff --git a/server/src/db/types/db-timestamp.type.ts b/server/src/db/types/db-timestamp.type.ts new file mode 100644 index 000000000..a3b41356f --- /dev/null +++ b/server/src/db/types/db-timestamp.type.ts @@ -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 { + 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'; + } +} diff --git a/server/src/db/types/index.ts b/server/src/db/types/index.ts new file mode 100644 index 000000000..56a3d3162 --- /dev/null +++ b/server/src/db/types/index.ts @@ -0,0 +1 @@ +export { DB_TIMESTAMP_RE, DbTimestampType, dbNow } from './db-timestamp.type'; diff --git a/server/tests/unit/db/types/db-timestamp.type.test.ts b/server/tests/unit/db/types/db-timestamp.type.test.ts new file mode 100644 index 000000000..fbf863967 --- /dev/null +++ b/server/tests/unit/db/types/db-timestamp.type.test.ts @@ -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'); + }); +});