refactor: Standardize database timestamp fields to bigint epoch milliseconds and update schema migrations.
This commit is contained in:
parent
c2188b476f
commit
021533223b
20 changed files with 180 additions and 147 deletions
|
|
@ -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
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1771284020206,
|
||||
"tag": "0000_fearless_vargas",
|
||||
"when": 1771386480954,
|
||||
"tag": "0000_black_lucky_pierre",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1771284019941,
|
||||
"tag": "0000_lucky_inertia",
|
||||
"when": 1771386480690,
|
||||
"tag": "0000_familiar_caretaker",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
36
src/lib/shared/timestamps.ts
Normal file
36
src/lib/shared/timestamps.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue