refactor: Standardize database timestamp fields to bigint epoch milliseconds and update schema migrations.

This commit is contained in:
Richard R 2026-02-17 21:00:36 -07:00
parent c2188b476f
commit 021533223b
20 changed files with 180 additions and 147 deletions

View file

@ -18,7 +18,7 @@ CREATE TABLE "audiobooks" (
"description" text,
"cover_path" text,
"duration" real DEFAULT 0,
"created_at" timestamp DEFAULT now(),
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
CONSTRAINT "audiobooks_id_user_id_pk" PRIMARY KEY("id","user_id")
);
--> statement-breakpoint
@ -51,7 +51,7 @@ CREATE TABLE "documents" (
"size" bigint NOT NULL,
"last_modified" bigint NOT NULL,
"file_path" text NOT NULL,
"created_at" timestamp DEFAULT now(),
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
CONSTRAINT "documents_id_user_id_pk" PRIMARY KEY("id","user_id")
);
--> statement-breakpoint
@ -62,8 +62,8 @@ CREATE TABLE "user_document_progress" (
"location" text NOT NULL,
"progress" real,
"client_updated_at_ms" bigint DEFAULT 0 NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
"updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
CONSTRAINT "user_document_progress_user_id_document_id_pk" PRIMARY KEY("user_id","document_id")
);
--> statement-breakpoint
@ -71,16 +71,16 @@ CREATE TABLE "user_preferences" (
"user_id" text PRIMARY KEY NOT NULL,
"data_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
"client_updated_at_ms" bigint DEFAULT 0 NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now()
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
"updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint
);
--> statement-breakpoint
CREATE TABLE "user_tts_chars" (
"user_id" text NOT NULL,
"date" date NOT NULL,
"char_count" bigint DEFAULT 0,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
"updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
CONSTRAINT "user_tts_chars_user_id_date_pk" PRIMARY KEY("user_id","date")
);
--> statement-breakpoint

View file

@ -1,5 +1,5 @@
{
"id": "ed6cbfef-d016-4265-999b-239f10f9e4e1",
"id": "a6243c0b-4032-4549-aa08-1f7602362f15",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@ -152,10 +152,10 @@
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"default": "now()"
"default": "(extract(epoch from now()) * 1000)::bigint"
}
},
"indexes": {},
@ -391,10 +391,10 @@
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"default": "now()"
"default": "(extract(epoch from now()) * 1000)::bigint"
}
},
"indexes": {
@ -507,17 +507,17 @@
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"default": "now()"
"default": "(extract(epoch from now()) * 1000)::bigint"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"default": "now()"
"default": "(extract(epoch from now()) * 1000)::bigint"
}
},
"indexes": {
@ -598,17 +598,17 @@
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"default": "now()"
"default": "(extract(epoch from now()) * 1000)::bigint"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"default": "now()"
"default": "(extract(epoch from now()) * 1000)::bigint"
}
},
"indexes": {},
@ -658,17 +658,17 @@
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"default": "now()"
"default": "(extract(epoch from now()) * 1000)::bigint"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"default": "now()"
"default": "(extract(epoch from now()) * 1000)::bigint"
}
},
"indexes": {

View file

@ -5,8 +5,8 @@
{
"idx": 0,
"version": "7",
"when": 1771284020206,
"tag": "0000_fearless_vargas",
"when": 1771386480954,
"tag": "0000_black_lucky_pierre",
"breakpoints": true
}
]

View file

@ -20,7 +20,7 @@ CREATE TABLE `audiobooks` (
`description` text,
`cover_path` text,
`duration` real DEFAULT 0,
`created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
PRIMARY KEY(`id`, `user_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
@ -55,7 +55,7 @@ CREATE TABLE `documents` (
`size` integer NOT NULL,
`last_modified` integer NOT NULL,
`file_path` text NOT NULL,
`created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
PRIMARY KEY(`id`, `user_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
@ -69,8 +69,8 @@ CREATE TABLE `user_document_progress` (
`location` text NOT NULL,
`progress` real,
`client_updated_at_ms` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
`updated_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
PRIMARY KEY(`user_id`, `document_id`),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
@ -80,8 +80,8 @@ CREATE TABLE `user_preferences` (
`user_id` text PRIMARY KEY NOT NULL,
`data_json` text DEFAULT '{}' NOT NULL,
`client_updated_at_ms` integer DEFAULT 0 NOT NULL,
`created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
`updated_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
@ -89,8 +89,8 @@ CREATE TABLE `user_tts_chars` (
`user_id` text NOT NULL,
`date` text NOT NULL,
`char_count` integer DEFAULT 0,
`created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
`updated_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
PRIMARY KEY(`user_id`, `date`)
);
--> statement-breakpoint

View file

@ -1,7 +1,7 @@
{
"version": "6",
"dialect": "sqlite",
"id": "2406985e-1b41-49e8-9add-304814080095",
"id": "23271fb3-935b-4a9b-a890-e73fc9943bef",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"audiobook_chapters": {
@ -167,7 +167,7 @@
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(cast(strftime('%s','now') as int) * 1000)"
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {},
@ -412,7 +412,7 @@
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(cast(strftime('%s','now') as int) * 1000)"
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
@ -511,7 +511,7 @@
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(cast(strftime('%s','now') as int) * 1000)"
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
@ -519,7 +519,7 @@
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(cast(strftime('%s','now') as int) * 1000)"
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {
@ -591,7 +591,7 @@
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(cast(strftime('%s','now') as int) * 1000)"
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
@ -599,7 +599,7 @@
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(cast(strftime('%s','now') as int) * 1000)"
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {},
@ -653,7 +653,7 @@
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(cast(strftime('%s','now') as int) * 1000)"
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
},
"updated_at": {
"name": "updated_at",
@ -661,7 +661,7 @@
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "(cast(strftime('%s','now') as int) * 1000)"
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
}
},
"indexes": {

View file

@ -5,8 +5,8 @@
{
"idx": 0,
"version": "6",
"when": 1771284019941,
"tag": "0000_lucky_inertia",
"when": 1771386480690,
"tag": "0000_familiar_caretaker",
"breakpoints": true
}
]

View file

@ -93,7 +93,7 @@ async function scanLibraryRoot(root: string, rootIndex: number, limit: number):
id,
name: relativePath,
size: fileStat.size,
lastModified: fileStat.mtimeMs,
lastModified: Math.floor(fileStat.mtimeMs),
type: libraryDocumentTypeFromName(relativePath),
});
}

View file

@ -5,15 +5,12 @@ import { headers } from 'next/headers';
import { isAuthEnabled } from '@/lib/server/auth/config';
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
import { nextUtcMidnightTimestampMs } from '@/lib/shared/timestamps';
export const dynamic = 'force-dynamic';
function getUtcResetTimeIso(): string {
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setUTCDate(now.getUTCDate() + 1);
tomorrow.setUTCHours(0, 0, 0, 0);
return tomorrow.toISOString();
function getUtcResetTimeMs(): number {
return nextUtcMidnightTimestampMs();
}
export async function GET(req: NextRequest) {
@ -22,7 +19,7 @@ export async function GET(req: NextRequest) {
// If auth is not enabled, return unlimited status
if (!isAuthEnabled() || !auth) {
const resetTime = getUtcResetTimeIso();
const resetTimeMs = getUtcResetTimeMs();
return NextResponse.json({
allowed: true,
currentCount: 0,
@ -30,7 +27,7 @@ export async function GET(req: NextRequest) {
// because authEnabled=false, but we keep it finite to prevent surprises.
limit: Number.MAX_SAFE_INTEGER,
remainingChars: Number.MAX_SAFE_INTEGER,
resetTime,
resetTimeMs,
userType: 'unauthenticated',
authEnabled: false
});
@ -43,13 +40,13 @@ export async function GET(req: NextRequest) {
// No session means unauthenticated
if (!session?.user) {
const resetTime = getUtcResetTimeIso();
const resetTimeMs = getUtcResetTimeMs();
return NextResponse.json({
allowed: true,
currentCount: 0,
limit: ttsRateLimitEnabled ? RATE_LIMITS.ANONYMOUS : Number.MAX_SAFE_INTEGER,
remainingChars: ttsRateLimitEnabled ? RATE_LIMITS.ANONYMOUS : Number.MAX_SAFE_INTEGER,
resetTime,
resetTimeMs,
userType: 'unauthenticated',
authEnabled: true
});
@ -76,7 +73,7 @@ export async function GET(req: NextRequest) {
currentCount: result.currentCount,
limit: result.limit,
remainingChars: result.remainingChars,
resetTime: result.resetTime.toISOString(),
resetTimeMs: result.resetTimeMs,
userType: isAnonymous ? 'anonymous' : 'authenticated',
authEnabled: true
});

View file

@ -185,10 +185,10 @@ export async function POST(req: NextRequest) {
);
if (!rateLimitResult.allowed) {
const resetTime = rateLimitResult.resetTime.toISOString();
const resetTimeMs = rateLimitResult.resetTimeMs;
const retryAfterSeconds = Math.max(
0,
Math.ceil((rateLimitResult.resetTime.getTime() - Date.now()) / 1000)
Math.ceil((resetTimeMs - Date.now()) / 1000)
);
const problem: ProblemDetails = {
@ -200,7 +200,7 @@ export async function POST(req: NextRequest) {
currentCount: rateLimitResult.currentCount,
limit: rateLimitResult.limit,
remainingChars: rateLimitResult.remainingChars,
resetTime,
resetTimeMs,
userType: isAnonymous ? 'anonymous' : 'authenticated',
upgradeHint: isAnonymous
? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day`

View file

@ -10,6 +10,7 @@ import { getDocumentBlobStream } from '@/lib/server/documents/blobstore';
import { getAudiobookObjectStream, listAudiobookObjects } from '@/lib/server/audiobooks/blobstore';
import { isS3Configured } from '@/lib/server/storage/s3';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { nowTimestampMs } from '@/lib/shared/timestamps';
export const dynamic = 'force-dynamic';
@ -100,17 +101,17 @@ export async function GET(req: NextRequest) {
(async () => {
try {
const exportedAtIso = new Date().toISOString();
const exportedAtMs = nowTimestampMs();
const profileData = {
user: session.user,
session: session.session,
exportedAt: exportedAtIso,
exportedAtMs,
};
await appendUserExportArchive({
archive,
userId,
exportedAtIso,
exportedAtMs,
profileData,
preferences: prefs[0] ?? null,
readingHistory: progress,

View file

@ -4,13 +4,10 @@ import { db } from '@/db';
import { userPreferences } from '@/db/schema';
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferencesPatch } from '@/types/user-state';
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
export const dynamic = 'force-dynamic';
function nowForDb(): Date | number {
return process.env.POSTGRES_URL ? new Date() : Date.now();
}
function serializePreferencesForDb(patch: SyncedPreferencesPatch): SyncedPreferencesPatch | string {
if (process.env.POSTGRES_URL) return patch;
return JSON.stringify(patch);
@ -92,10 +89,9 @@ function sanitizePreferencesPatch(input: unknown): SyncedPreferencesPatch {
}
function normalizeClientUpdatedAtMs(value: unknown): number {
if (!Number.isFinite(value)) return Date.now();
const normalized = Number(value);
if (normalized <= 0) return Date.now();
return Math.floor(normalized);
const normalized = coerceTimestampMs(value, nowTimestampMs());
if (normalized <= 0) return nowTimestampMs();
return normalized;
}
export async function GET(req: NextRequest) {
@ -164,7 +160,7 @@ export async function PUT(req: NextRequest) {
const mergedPatch = { ...existingPatch, ...patch };
const dataJson = serializePreferencesForDb(mergedPatch);
const updatedAt = nowForDb();
const updatedAt = nowTimestampMs();
await db
.insert(userPreferences)

View file

@ -5,29 +5,19 @@ import { userDocumentProgress } from '@/db/schema';
import type { ReaderType } from '@/types/user-state';
import { isValidDocumentId } from '@/lib/server/documents/blobstore';
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
export const dynamic = 'force-dynamic';
function nowForDb(): Date | number {
return process.env.POSTGRES_URL ? new Date() : Date.now();
}
function normalizeReaderType(value: unknown): ReaderType | null {
if (value === 'pdf' || value === 'epub' || value === 'html') return value;
return null;
}
function normalizeClientUpdatedAtMs(value: unknown): number {
if (!Number.isFinite(value)) return Date.now();
const normalized = Number(value);
if (normalized <= 0) return Date.now();
return Math.floor(normalized);
}
function toUpdatedAtMs(value: unknown): number {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (value instanceof Date) return value.getTime();
return Date.now();
const normalized = coerceTimestampMs(value, nowTimestampMs());
if (normalized <= 0) return nowTimestampMs();
return normalized;
}
export async function GET(req: NextRequest) {
@ -68,7 +58,7 @@ export async function GET(req: NextRequest) {
location: row.location,
progress: row.progress == null ? null : Number(row.progress),
clientUpdatedAtMs: Number(row.clientUpdatedAtMs ?? 0),
updatedAtMs: toUpdatedAtMs(row.updatedAt),
updatedAtMs: coerceTimestampMs(row.updatedAt, nowTimestampMs()),
},
});
} catch (error) {
@ -140,13 +130,13 @@ export async function PUT(req: NextRequest) {
location: existing.location,
progress: existing.progress == null ? null : Number(existing.progress),
clientUpdatedAtMs: existingUpdated,
updatedAtMs: toUpdatedAtMs(existing.updatedAt),
updatedAtMs: coerceTimestampMs(existing.updatedAt, nowTimestampMs()),
},
applied: false,
});
}
const updatedAt = nowForDb();
const updatedAt = nowTimestampMs();
await db
.insert(userDocumentProgress)
.values({
@ -177,7 +167,7 @@ export async function PUT(req: NextRequest) {
location,
progress,
clientUpdatedAtMs,
updatedAtMs: toUpdatedAtMs(updatedAt),
updatedAtMs: updatedAt,
},
applied: true,
});
@ -186,4 +176,3 @@ export async function PUT(req: NextRequest) {
return NextResponse.json({ error: 'Failed to update user progress' }, { status: 500 });
}
}

View file

@ -1,13 +1,14 @@
'use client';
import React, { createContext, useContext, useState, useEffect, useCallback, useRef, ReactNode } from 'react';
import { coerceTimestampMs, nextUtcMidnightTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
export interface RateLimitStatus {
allowed: boolean;
currentCount: number;
limit: number;
remainingChars: number;
resetTime: Date;
resetTimeMs: number;
userType: 'anonymous' | 'authenticated' | 'unauthenticated';
authEnabled: boolean;
}
@ -52,9 +53,8 @@ export function useRateLimit() {
return useAuthRateLimit();
}
function calculateTimeUntilReset(resetTime: Date): string {
const now = new Date();
const timeDiff = resetTime.getTime() - now.getTime();
function calculateTimeUntilReset(resetTimeMs: number): string {
const timeDiff = resetTimeMs - nowTimestampMs();
if (timeDiff <= 0) {
return 'Soon';
@ -70,6 +70,27 @@ function calculateTimeUntilReset(resetTime: Date): string {
}
}
function parseRateLimitStatus(raw: unknown): RateLimitStatus | null {
if (!raw || typeof raw !== 'object') return null;
const data = raw as Record<string, unknown>;
const userType = (() => {
const value = data.userType;
if (value === 'anonymous' || value === 'authenticated' || value === 'unauthenticated') return value;
return 'unauthenticated';
})();
return {
allowed: Boolean(data.allowed),
currentCount: Number(data.currentCount ?? 0),
limit: Number(data.limit ?? 0),
remainingChars: Number(data.remainingChars ?? 0),
resetTimeMs: coerceTimestampMs(data.resetTimeMs ?? data.resetTime, nextUtcMidnightTimestampMs()),
userType,
authEnabled: Boolean(data.authEnabled),
};
}
export function formatCharCount(count: number): string {
if (count >= 1_000_000) {
const m = count / 1_000_000;
@ -110,18 +131,13 @@ export function AuthRateLimitProvider({
const fetchStatus = useCallback(async () => {
// Skip if auth is not enabled
if (!authEnabled) {
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setUTCDate(now.getUTCDate() + 1);
tomorrow.setUTCHours(0, 0, 0, 0);
setStatus({
allowed: true,
currentCount: 0,
// Avoid Infinity to prevent JSON/serialization edge cases elsewhere.
limit: Number.MAX_SAFE_INTEGER,
remainingChars: Number.MAX_SAFE_INTEGER,
resetTime: tomorrow,
resetTimeMs: nextUtcMidnightTimestampMs(),
userType: 'unauthenticated',
authEnabled: false
});
@ -140,11 +156,7 @@ export function AuthRateLimitProvider({
}
const data = await response.json();
setStatus({
...data,
resetTime: new Date(data.resetTime)
});
setStatus(parseRateLimitStatus(data));
} catch (err) {
console.error('Error fetching rate limit status:', err);
setError(err instanceof Error ? err.message : 'Unknown error');
@ -158,7 +170,7 @@ export function AuthRateLimitProvider({
}, [fetchStatus]);
// Calculate time until reset
const timeUntilReset = status ? calculateTimeUntilReset(status.resetTime) : '';
const timeUntilReset = status ? calculateTimeUntilReset(status.resetTimeMs) : '';
// Only treat the user as "at limit" when they are truly out of characters.
// The server allows the final request that may cross the limit, then blocks subsequent ones.
const isAtLimit = status ? (status.remainingChars <= 0 || !status.allowed) : false;

View file

@ -1,6 +1,9 @@
import { pgTable, text, integer, real, timestamp, date, bigint, primaryKey, index, jsonb, foreignKey } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { pgTable, text, integer, real, date, bigint, primaryKey, index, jsonb, foreignKey } from 'drizzle-orm/pg-core';
import { user } from './schema_auth_postgres';
const PG_NOW_MS = sql`(extract(epoch from now()) * 1000)::bigint`;
export const documents = pgTable('documents', {
id: text('id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
@ -9,7 +12,7 @@ export const documents = pgTable('documents', {
size: bigint('size', { mode: 'number' }).notNull(),
lastModified: bigint('last_modified', { mode: 'number' }).notNull(),
filePath: text('file_path').notNull(),
createdAt: timestamp('created_at').defaultNow(),
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.id, table.userId] }),
index('idx_documents_user_id').on(table.userId),
@ -24,7 +27,7 @@ export const audiobooks = pgTable('audiobooks', {
description: text('description'),
coverPath: text('cover_path'),
duration: real('duration').default(0),
createdAt: timestamp('created_at').defaultNow(),
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.id, table.userId] }),
]);
@ -54,8 +57,8 @@ export const userTtsChars = pgTable("user_tts_chars", {
userId: text('user_id').notNull(),
date: date('date').notNull(),
charCount: bigint('char_count', { mode: 'number' }).default(0),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.userId, table.date] }),
index('idx_user_tts_chars_date').on(table.date),
@ -65,8 +68,8 @@ export const userPreferences = pgTable('user_preferences', {
userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }),
dataJson: jsonb('data_json').notNull().default({}),
clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).notNull().default(0),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
});
export const userDocumentProgress = pgTable('user_document_progress', {
@ -76,8 +79,8 @@ export const userDocumentProgress = pgTable('user_document_progress', {
location: text('location').notNull(),
progress: real('progress'),
clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).notNull().default(0),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.userId, table.documentId] }),
index('idx_user_document_progress_user_id_updated_at').on(table.userId, table.updatedAt),

View file

@ -2,6 +2,8 @@ import { sqliteTable, text, integer, real, primaryKey, index, foreignKey } from
import { sql } from 'drizzle-orm';
import { user } from './schema_auth_sqlite';
const SQLITE_NOW_MS = sql`(cast(unixepoch('subsecond') * 1000 as integer))`;
export const documents = sqliteTable('documents', {
id: text('id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
@ -10,7 +12,7 @@ export const documents = sqliteTable('documents', {
size: integer('size').notNull(),
lastModified: integer('last_modified').notNull(),
filePath: text('file_path').notNull(),
createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
createdAt: integer('created_at').default(SQLITE_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.id, table.userId] }),
index('idx_documents_user_id').on(table.userId),
@ -25,7 +27,7 @@ export const audiobooks = sqliteTable('audiobooks', {
description: text('description'),
coverPath: text('cover_path'),
duration: real('duration').default(0),
createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
createdAt: integer('created_at').default(SQLITE_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.id, table.userId] }),
]);
@ -55,8 +57,8 @@ export const userTtsChars = sqliteTable("user_tts_chars", {
userId: text('user_id').notNull(),
date: text('date').notNull(), // SQLite doesn't have native DATE type, text YYYY-MM-DD is standard
charCount: integer('char_count').default(0),
createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
updatedAt: integer('updated_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
createdAt: integer('created_at').default(SQLITE_NOW_MS),
updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.userId, table.date] }),
index('idx_user_tts_chars_date').on(table.date),
@ -66,8 +68,8 @@ export const userPreferences = sqliteTable('user_preferences', {
userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }),
dataJson: text('data_json').notNull().default('{}'),
clientUpdatedAtMs: integer('client_updated_at_ms').notNull().default(0),
createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
updatedAt: integer('updated_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
createdAt: integer('created_at').default(SQLITE_NOW_MS),
updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
});
export const userDocumentProgress = sqliteTable('user_document_progress', {
@ -77,8 +79,8 @@ export const userDocumentProgress = sqliteTable('user_document_progress', {
location: text('location').notNull(),
progress: real('progress'),
clientUpdatedAtMs: integer('client_updated_at_ms').notNull().default(0),
createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
updatedAt: integer('updated_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
createdAt: integer('created_at').default(SQLITE_NOW_MS),
updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.userId, table.documentId] }),
index('idx_user_document_progress_user_id_updated_at').on(table.userId, table.updatedAt),

View file

@ -57,7 +57,7 @@ export const withRetry = async <T>(
}
return undefined;
})();
// Do not retry on user quota exceeded (server tells us when to retry via resetTime/Retry-After)
// Do not retry on user quota exceeded (server tells us when to retry via resetTimeMs/Retry-After)
if (status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED') {
break;
}

View file

@ -2,6 +2,7 @@ import { db } from '@/db';
import { userTtsChars } from '@/db/schema';
import { isAuthEnabled } from '@/lib/server/auth/config';
import { eq, and, lt, sql } from 'drizzle-orm';
import { nextUtcMidnightTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
function readPositiveIntEnv(name: string, fallback: number): number {
const raw = process.env[name];
@ -52,7 +53,7 @@ export interface RateLimitResult {
allowed: boolean;
currentCount: number;
limit: number;
resetTime: Date;
resetTimeMs: number;
remainingChars: number;
}
@ -133,8 +134,8 @@ export class RateLimiter {
return Boolean(process.env.POSTGRES_URL);
}
private getUpdatedAtValue(): Date | number {
return this.isPostgres() ? new Date() : Date.now();
private getUpdatedAtValue(): number {
return nowTimestampMs();
}
// Use a transaction only when running with Postgres.
@ -157,7 +158,7 @@ export class RateLimiter {
allowed: true,
currentCount: 0,
limit: Number.MAX_SAFE_INTEGER,
resetTime: this.getResetTime(),
resetTimeMs: this.getResetTimeMs(),
remainingChars: Number.MAX_SAFE_INTEGER
};
}
@ -236,7 +237,7 @@ export class RateLimiter {
allowed: true,
currentCount: effective.currentCount,
limit: effective.limit,
resetTime: this.getResetTime(),
resetTimeMs: this.getResetTimeMs(),
remainingChars: effective.remainingChars,
};
});
@ -258,7 +259,7 @@ export class RateLimiter {
allowed: true,
currentCount: 0,
limit: Number.MAX_SAFE_INTEGER,
resetTime: this.getResetTime(),
resetTimeMs: this.getResetTimeMs(),
remainingChars: Number.MAX_SAFE_INTEGER
};
}
@ -300,7 +301,7 @@ export class RateLimiter {
allowed: effective.allowed,
currentCount: effective.currentCount,
limit: effective.limit,
resetTime: this.getResetTime(),
resetTimeMs: this.getResetTimeMs(),
remainingChars: effective.remainingChars,
};
}
@ -356,12 +357,8 @@ export class RateLimiter {
await safeDb().delete(userTtsChars).where(lt(userTtsChars.date, cutoffDateValue));
}
private getResetTime(): Date {
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setUTCDate(now.getUTCDate() + 1);
tomorrow.setUTCHours(0, 0, 0, 0); // Start of next day in UTC
return tomorrow;
private getResetTimeMs(): number {
return nextUtcMidnightTimestampMs();
}
}

View file

@ -20,7 +20,7 @@ type AudiobookRow = {
description: string | null;
coverPath: string | null;
duration: number | null;
createdAt: unknown;
createdAt: number;
};
type AudiobookChapterRow = {
@ -38,8 +38,8 @@ type UserPreferenceRow = {
userId: string;
dataJson: unknown;
clientUpdatedAtMs: number;
createdAt: unknown;
updatedAt: unknown;
createdAt: number;
updatedAt: number;
};
type UserDocumentProgressRow = {
@ -49,8 +49,8 @@ type UserDocumentProgressRow = {
location: string;
progress: number | null;
clientUpdatedAtMs: number;
createdAt: unknown;
updatedAt: unknown;
createdAt: number;
updatedAt: number;
};
function contentTypeForAudiobookObject(fileName: string): string {

View file

@ -44,7 +44,7 @@ type ExportAudiobookObject = {
export type AppendUserExportArchiveInput = {
archive: Archiver;
userId: string;
exportedAtIso: string;
exportedAtMs: number;
profileData: unknown;
preferences: unknown | null;
readingHistory: unknown[];
@ -119,7 +119,7 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu
const {
archive,
userId,
exportedAtIso,
exportedAtMs,
profileData,
preferences,
readingHistory,
@ -217,7 +217,7 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu
const manifest = {
formatVersion: 2,
exportedAt: exportedAtIso,
exportedAtMs,
userId,
scope: 'owned',
counts: {

View file

@ -0,0 +1,36 @@
export type TimestampMs = number;
export function nowTimestampMs(): TimestampMs {
return Date.now();
}
export function nextUtcMidnightTimestampMs(fromMs: TimestampMs = nowTimestampMs()): TimestampMs {
const now = new Date(fromMs);
const tomorrow = new Date(now);
tomorrow.setUTCDate(now.getUTCDate() + 1);
tomorrow.setUTCHours(0, 0, 0, 0);
return tomorrow.getTime();
}
export function coerceTimestampMs(value: unknown, fallback: TimestampMs = nowTimestampMs()): TimestampMs {
if (typeof value === 'number' && Number.isFinite(value)) {
return Math.floor(value);
}
if (value instanceof Date) {
return value.getTime();
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed.length > 0) {
const asNumber = Number(trimmed);
if (Number.isFinite(asNumber)) return Math.floor(asNumber);
const asDate = Date.parse(trimmed);
if (Number.isFinite(asDate)) return Math.floor(asDate);
}
}
return fallback;
}