Files
zeekayandHanzo Dev 92b2a43c17 fix(prisma): finish the move to Prisma 7, and keep DateTime an integer
The client and CLI were raised to Prisma 7 without the migration that major
requires, so `prisma generate` stopped at P1012 and nothing downstream of it
could build.

Prisma 7 takes the connection URL out of the schema and has no query engine:
the CLI reads prisma.config.ts, and the client is handed a driver adapter.
Both read the one DATABASE_URL. The adapter holds a single connection, which
is the single writer SQLite wants by construction — the `?connection_limit=1`
suffix the old engine needed is gone, and had to be: the adapter strips
`file:` and takes the rest as the file's name.

THE TRAP IS THE ADAPTER'S DEFAULT. The engine this replaces wrote every
DateTime as an epoch-millisecond INTEGER; that is what a deployed sign.db
holds and what sqlite-sql.ts divides by 1000 to read. The better-sqlite3
adapter defaults to ISO-8601 TEXT. Left alone it would write strings into
columns full of integers, SQLite would order them by storage class rather
than by time, and nothing would throw. `timestampFormat: 'unixepoch-ms'`
keeps one representation, and timestamp.test.ts reads the column's storage
class over a second connection. Checked both ways: on the default it fails
with `createdAt is stored as text: 2026-03-04T05:06:07.890+00:00`.

packages/prisma: 52 of 52.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-18 17:58:13 -07:00

107 lines
4.1 KiB
TypeScript

/**
* A `DateTime` is an epoch-millisecond INTEGER on disk, under every client.
*
* The rows in a deployed sign.db were written by Prisma's own query engine,
* which stored `DateTime` as an integer of milliseconds. Prisma 7 has no engine:
* the client talks through a driver adapter, and the better-sqlite3 adapter's
* DEFAULT is ISO-8601 TEXT. A client built on that default keeps working — it
* reads its own strings back — while writing a second representation into
* columns that already hold the first. SQLite orders an INTEGER before any TEXT,
* so every comparison across the two sorts by type instead of by time, and
* ./sqlite-sql.ts (`column / 1000, 'unixepoch'`) reads the new rows as year 1970.
* Nothing throws. That is why this is a test and not a comment.
*
* It runs the REAL migration into a temp file and reads the column's storage
* class with a second, independent connection.
*
* Run: npx tsx --test packages/prisma/__tests__/timestamp.test.ts
*/
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import assert from 'node:assert/strict';
import { after, before, describe, test } from 'node:test';
import { prisma } from '../index';
let dir: string;
let dbPath: string;
before(async () => {
dir = mkdtempSync(path.join(tmpdir(), 'esign-timestamp-'));
dbPath = path.join(dir, 'sign.db');
const migration = readFileSync(
path.join(__dirname, '..', 'migrations', '0_init', 'migration.sql'),
'utf8',
);
const raw = new DatabaseSync(dbPath);
raw.exec('PRAGMA foreign_keys=ON');
raw.exec(migration);
raw.close();
process.env.DATABASE_URL = `file:${dbPath}`;
await prisma.$connect();
}, { timeout: 60_000 });
after(async () => {
await prisma?.$disconnect();
rmSync(dir, { recursive: true, force: true });
});
/** The column as SQLite holds it, read by a connection Prisma knows nothing of. */
const stored = (email: string) => {
const raw = new DatabaseSync(dbPath, { readOnly: true });
try {
return raw
.prepare('SELECT typeof("createdAt") AS kind, "createdAt" AS value FROM "User" WHERE email = ?')
.get(email) as { kind: string; value: number | string };
} finally {
raw.close();
}
};
describe('DateTime storage', () => {
test('a written DateTime is an epoch-millisecond integer', async () => {
const at = new Date('2026-03-04T05:06:07.890Z');
await prisma.user.create({ data: { email: '[email protected]', createdAt: at } });
const row = stored('[email protected]');
assert.equal(row.kind, 'integer', `createdAt is stored as ${row.kind}: ${String(row.value)}`);
assert.equal(row.value, at.getTime());
});
test('a row the old engine wrote reads back as the same instant', async () => {
// Exactly what Prisma 6 left on disk: the integer, and nothing else.
const at = new Date('2025-11-22T10:00:00.000Z');
const raw = new DatabaseSync(dbPath);
raw
.prepare(
'INSERT INTO "User" (email, "createdAt", "updatedAt", "lastSignedIn") VALUES (?, ?, ?, ?)',
)
.run('[email protected]', at.getTime(), at.getTime(), at.getTime());
raw.close();
const user = await prisma.user.findUniqueOrThrow({ where: { email: '[email protected]' } });
assert.equal(user.createdAt.getTime(), at.getTime());
});
test('old and new rows order by time, not by storage class', async () => {
// The legacy row is earlier. Were the new row TEXT, SQLite would sort the
// INTEGER first regardless of value and this would pass by accident — so the
// assertion is made in BOTH directions with a filter that must exclude one.
const cutoff = new Date('2026-01-01T00:00:00.000Z');
const after = await prisma.user.findMany({
where: { createdAt: { gt: cutoff } },
select: { email: true },
});
const before = await prisma.user.findMany({
where: { createdAt: { lt: cutoff } },
select: { email: true },
});
assert.deepEqual(after.map((u) => u.email), ['[email protected]']);
assert.deepEqual(before.map((u) => u.email), ['[email protected]']);
});
});