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,
|
"description" text,
|
||||||
"cover_path" text,
|
"cover_path" text,
|
||||||
"duration" real DEFAULT 0,
|
"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")
|
CONSTRAINT "audiobooks_id_user_id_pk" PRIMARY KEY("id","user_id")
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
|
|
@ -51,7 +51,7 @@ CREATE TABLE "documents" (
|
||||||
"size" bigint NOT NULL,
|
"size" bigint NOT NULL,
|
||||||
"last_modified" bigint NOT NULL,
|
"last_modified" bigint NOT NULL,
|
||||||
"file_path" text 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")
|
CONSTRAINT "documents_id_user_id_pk" PRIMARY KEY("id","user_id")
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
|
|
@ -62,8 +62,8 @@ CREATE TABLE "user_document_progress" (
|
||||||
"location" text NOT NULL,
|
"location" text NOT NULL,
|
||||||
"progress" real,
|
"progress" real,
|
||||||
"client_updated_at_ms" bigint DEFAULT 0 NOT NULL,
|
"client_updated_at_ms" bigint DEFAULT 0 NOT NULL,
|
||||||
"created_at" timestamp DEFAULT now(),
|
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
|
||||||
"updated_at" timestamp DEFAULT now(),
|
"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")
|
CONSTRAINT "user_document_progress_user_id_document_id_pk" PRIMARY KEY("user_id","document_id")
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
|
|
@ -71,16 +71,16 @@ CREATE TABLE "user_preferences" (
|
||||||
"user_id" text PRIMARY KEY NOT NULL,
|
"user_id" text PRIMARY KEY NOT NULL,
|
||||||
"data_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
"data_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
"client_updated_at_ms" bigint DEFAULT 0 NOT NULL,
|
"client_updated_at_ms" bigint DEFAULT 0 NOT NULL,
|
||||||
"created_at" timestamp DEFAULT now(),
|
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
|
||||||
"updated_at" timestamp DEFAULT now()
|
"updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE TABLE "user_tts_chars" (
|
CREATE TABLE "user_tts_chars" (
|
||||||
"user_id" text NOT NULL,
|
"user_id" text NOT NULL,
|
||||||
"date" date NOT NULL,
|
"date" date NOT NULL,
|
||||||
"char_count" bigint DEFAULT 0,
|
"char_count" bigint DEFAULT 0,
|
||||||
"created_at" timestamp DEFAULT now(),
|
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
|
||||||
"updated_at" timestamp DEFAULT now(),
|
"updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint,
|
||||||
CONSTRAINT "user_tts_chars_user_id_date_pk" PRIMARY KEY("user_id","date")
|
CONSTRAINT "user_tts_chars_user_id_date_pk" PRIMARY KEY("user_id","date")
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"id": "ed6cbfef-d016-4265-999b-239f10f9e4e1",
|
"id": "a6243c0b-4032-4549-aa08-1f7602362f15",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"dialect": "postgresql",
|
"dialect": "postgresql",
|
||||||
|
|
@ -152,10 +152,10 @@
|
||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "timestamp",
|
"type": "bigint",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"default": "now()"
|
"default": "(extract(epoch from now()) * 1000)::bigint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
|
|
@ -391,10 +391,10 @@
|
||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "timestamp",
|
"type": "bigint",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"default": "now()"
|
"default": "(extract(epoch from now()) * 1000)::bigint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {
|
||||||
|
|
@ -507,17 +507,17 @@
|
||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "timestamp",
|
"type": "bigint",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"default": "now()"
|
"default": "(extract(epoch from now()) * 1000)::bigint"
|
||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "timestamp",
|
"type": "bigint",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"default": "now()"
|
"default": "(extract(epoch from now()) * 1000)::bigint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {
|
||||||
|
|
@ -598,17 +598,17 @@
|
||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "timestamp",
|
"type": "bigint",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"default": "now()"
|
"default": "(extract(epoch from now()) * 1000)::bigint"
|
||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "timestamp",
|
"type": "bigint",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"default": "now()"
|
"default": "(extract(epoch from now()) * 1000)::bigint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
|
|
@ -658,17 +658,17 @@
|
||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"name": "created_at",
|
"name": "created_at",
|
||||||
"type": "timestamp",
|
"type": "bigint",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"default": "now()"
|
"default": "(extract(epoch from now()) * 1000)::bigint"
|
||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "timestamp",
|
"type": "bigint",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"default": "now()"
|
"default": "(extract(epoch from now()) * 1000)::bigint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "7",
|
"version": "7",
|
||||||
"when": 1771284020206,
|
"when": 1771386480954,
|
||||||
"tag": "0000_fearless_vargas",
|
"tag": "0000_black_lucky_pierre",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ CREATE TABLE `audiobooks` (
|
||||||
`description` text,
|
`description` text,
|
||||||
`cover_path` text,
|
`cover_path` text,
|
||||||
`duration` real DEFAULT 0,
|
`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`),
|
PRIMARY KEY(`id`, `user_id`),
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
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,
|
`size` integer NOT NULL,
|
||||||
`last_modified` integer NOT NULL,
|
`last_modified` integer NOT NULL,
|
||||||
`file_path` text 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`),
|
PRIMARY KEY(`id`, `user_id`),
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
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,
|
`location` text NOT NULL,
|
||||||
`progress` real,
|
`progress` real,
|
||||||
`client_updated_at_ms` integer DEFAULT 0 NOT NULL,
|
`client_updated_at_ms` integer DEFAULT 0 NOT NULL,
|
||||||
`created_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(strftime('%s','now') as int) * 1000),
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
|
||||||
PRIMARY KEY(`user_id`, `document_id`),
|
PRIMARY KEY(`user_id`, `document_id`),
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
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,
|
`user_id` text PRIMARY KEY NOT NULL,
|
||||||
`data_json` text DEFAULT '{}' NOT NULL,
|
`data_json` text DEFAULT '{}' NOT NULL,
|
||||||
`client_updated_at_ms` integer DEFAULT 0 NOT NULL,
|
`client_updated_at_ms` integer DEFAULT 0 NOT NULL,
|
||||||
`created_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(strftime('%s','now') as int) * 1000),
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
|
|
@ -89,8 +89,8 @@ CREATE TABLE `user_tts_chars` (
|
||||||
`user_id` text NOT NULL,
|
`user_id` text NOT NULL,
|
||||||
`date` text NOT NULL,
|
`date` text NOT NULL,
|
||||||
`char_count` integer DEFAULT 0,
|
`char_count` integer DEFAULT 0,
|
||||||
`created_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(strftime('%s','now') as int) * 1000),
|
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
|
||||||
PRIMARY KEY(`user_id`, `date`)
|
PRIMARY KEY(`user_id`, `date`)
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "2406985e-1b41-49e8-9add-304814080095",
|
"id": "23271fb3-935b-4a9b-a890-e73fc9943bef",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"tables": {
|
"tables": {
|
||||||
"audiobook_chapters": {
|
"audiobook_chapters": {
|
||||||
|
|
@ -167,7 +167,7 @@
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": "(cast(strftime('%s','now') as int) * 1000)"
|
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
|
|
@ -412,7 +412,7 @@
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": "(cast(strftime('%s','now') as int) * 1000)"
|
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {
|
||||||
|
|
@ -511,7 +511,7 @@
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": "(cast(strftime('%s','now') as int) * 1000)"
|
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
|
|
@ -519,7 +519,7 @@
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": "(cast(strftime('%s','now') as int) * 1000)"
|
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {
|
||||||
|
|
@ -591,7 +591,7 @@
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": "(cast(strftime('%s','now') as int) * 1000)"
|
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
|
|
@ -599,7 +599,7 @@
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": "(cast(strftime('%s','now') as int) * 1000)"
|
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
|
|
@ -653,7 +653,7 @@
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": "(cast(strftime('%s','now') as int) * 1000)"
|
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
|
|
@ -661,7 +661,7 @@
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": "(cast(strftime('%s','now') as int) * 1000)"
|
"default": "(cast(unixepoch('subsecond') * 1000 as integer))"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
{
|
{
|
||||||
"idx": 0,
|
"idx": 0,
|
||||||
"version": "6",
|
"version": "6",
|
||||||
"when": 1771284019941,
|
"when": 1771386480690,
|
||||||
"tag": "0000_lucky_inertia",
|
"tag": "0000_familiar_caretaker",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ async function scanLibraryRoot(root: string, rootIndex: number, limit: number):
|
||||||
id,
|
id,
|
||||||
name: relativePath,
|
name: relativePath,
|
||||||
size: fileStat.size,
|
size: fileStat.size,
|
||||||
lastModified: fileStat.mtimeMs,
|
lastModified: Math.floor(fileStat.mtimeMs),
|
||||||
type: libraryDocumentTypeFromName(relativePath),
|
type: libraryDocumentTypeFromName(relativePath),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,12 @@ import { headers } from 'next/headers';
|
||||||
import { isAuthEnabled } from '@/lib/server/auth/config';
|
import { isAuthEnabled } from '@/lib/server/auth/config';
|
||||||
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
|
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
|
||||||
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
|
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
|
||||||
|
import { nextUtcMidnightTimestampMs } from '@/lib/shared/timestamps';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
function getUtcResetTimeIso(): string {
|
function getUtcResetTimeMs(): number {
|
||||||
const now = new Date();
|
return nextUtcMidnightTimestampMs();
|
||||||
const tomorrow = new Date(now);
|
|
||||||
tomorrow.setUTCDate(now.getUTCDate() + 1);
|
|
||||||
tomorrow.setUTCHours(0, 0, 0, 0);
|
|
||||||
return tomorrow.toISOString();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
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 auth is not enabled, return unlimited status
|
||||||
if (!isAuthEnabled() || !auth) {
|
if (!isAuthEnabled() || !auth) {
|
||||||
const resetTime = getUtcResetTimeIso();
|
const resetTimeMs = getUtcResetTimeMs();
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
allowed: true,
|
allowed: true,
|
||||||
currentCount: 0,
|
currentCount: 0,
|
||||||
|
|
@ -30,7 +27,7 @@ export async function GET(req: NextRequest) {
|
||||||
// because authEnabled=false, but we keep it finite to prevent surprises.
|
// because authEnabled=false, but we keep it finite to prevent surprises.
|
||||||
limit: Number.MAX_SAFE_INTEGER,
|
limit: Number.MAX_SAFE_INTEGER,
|
||||||
remainingChars: Number.MAX_SAFE_INTEGER,
|
remainingChars: Number.MAX_SAFE_INTEGER,
|
||||||
resetTime,
|
resetTimeMs,
|
||||||
userType: 'unauthenticated',
|
userType: 'unauthenticated',
|
||||||
authEnabled: false
|
authEnabled: false
|
||||||
});
|
});
|
||||||
|
|
@ -43,13 +40,13 @@ export async function GET(req: NextRequest) {
|
||||||
|
|
||||||
// No session means unauthenticated
|
// No session means unauthenticated
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
const resetTime = getUtcResetTimeIso();
|
const resetTimeMs = getUtcResetTimeMs();
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
allowed: true,
|
allowed: true,
|
||||||
currentCount: 0,
|
currentCount: 0,
|
||||||
limit: ttsRateLimitEnabled ? RATE_LIMITS.ANONYMOUS : Number.MAX_SAFE_INTEGER,
|
limit: ttsRateLimitEnabled ? RATE_LIMITS.ANONYMOUS : Number.MAX_SAFE_INTEGER,
|
||||||
remainingChars: ttsRateLimitEnabled ? RATE_LIMITS.ANONYMOUS : Number.MAX_SAFE_INTEGER,
|
remainingChars: ttsRateLimitEnabled ? RATE_LIMITS.ANONYMOUS : Number.MAX_SAFE_INTEGER,
|
||||||
resetTime,
|
resetTimeMs,
|
||||||
userType: 'unauthenticated',
|
userType: 'unauthenticated',
|
||||||
authEnabled: true
|
authEnabled: true
|
||||||
});
|
});
|
||||||
|
|
@ -76,7 +73,7 @@ export async function GET(req: NextRequest) {
|
||||||
currentCount: result.currentCount,
|
currentCount: result.currentCount,
|
||||||
limit: result.limit,
|
limit: result.limit,
|
||||||
remainingChars: result.remainingChars,
|
remainingChars: result.remainingChars,
|
||||||
resetTime: result.resetTime.toISOString(),
|
resetTimeMs: result.resetTimeMs,
|
||||||
userType: isAnonymous ? 'anonymous' : 'authenticated',
|
userType: isAnonymous ? 'anonymous' : 'authenticated',
|
||||||
authEnabled: true
|
authEnabled: true
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -185,10 +185,10 @@ export async function POST(req: NextRequest) {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!rateLimitResult.allowed) {
|
if (!rateLimitResult.allowed) {
|
||||||
const resetTime = rateLimitResult.resetTime.toISOString();
|
const resetTimeMs = rateLimitResult.resetTimeMs;
|
||||||
const retryAfterSeconds = Math.max(
|
const retryAfterSeconds = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.ceil((rateLimitResult.resetTime.getTime() - Date.now()) / 1000)
|
Math.ceil((resetTimeMs - Date.now()) / 1000)
|
||||||
);
|
);
|
||||||
|
|
||||||
const problem: ProblemDetails = {
|
const problem: ProblemDetails = {
|
||||||
|
|
@ -200,7 +200,7 @@ export async function POST(req: NextRequest) {
|
||||||
currentCount: rateLimitResult.currentCount,
|
currentCount: rateLimitResult.currentCount,
|
||||||
limit: rateLimitResult.limit,
|
limit: rateLimitResult.limit,
|
||||||
remainingChars: rateLimitResult.remainingChars,
|
remainingChars: rateLimitResult.remainingChars,
|
||||||
resetTime,
|
resetTimeMs,
|
||||||
userType: isAnonymous ? 'anonymous' : 'authenticated',
|
userType: isAnonymous ? 'anonymous' : 'authenticated',
|
||||||
upgradeHint: isAnonymous
|
upgradeHint: isAnonymous
|
||||||
? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day`
|
? `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 { getAudiobookObjectStream, listAudiobookObjects } from '@/lib/server/audiobooks/blobstore';
|
||||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||||
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||||
|
import { nowTimestampMs } from '@/lib/shared/timestamps';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
|
@ -100,17 +101,17 @@ export async function GET(req: NextRequest) {
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const exportedAtIso = new Date().toISOString();
|
const exportedAtMs = nowTimestampMs();
|
||||||
const profileData = {
|
const profileData = {
|
||||||
user: session.user,
|
user: session.user,
|
||||||
session: session.session,
|
session: session.session,
|
||||||
exportedAt: exportedAtIso,
|
exportedAtMs,
|
||||||
};
|
};
|
||||||
|
|
||||||
await appendUserExportArchive({
|
await appendUserExportArchive({
|
||||||
archive,
|
archive,
|
||||||
userId,
|
userId,
|
||||||
exportedAtIso,
|
exportedAtMs,
|
||||||
profileData,
|
profileData,
|
||||||
preferences: prefs[0] ?? null,
|
preferences: prefs[0] ?? null,
|
||||||
readingHistory: progress,
|
readingHistory: progress,
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,10 @@ import { db } from '@/db';
|
||||||
import { userPreferences } from '@/db/schema';
|
import { userPreferences } from '@/db/schema';
|
||||||
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferencesPatch } from '@/types/user-state';
|
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferencesPatch } from '@/types/user-state';
|
||||||
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
|
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
|
||||||
|
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
function nowForDb(): Date | number {
|
|
||||||
return process.env.POSTGRES_URL ? new Date() : Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializePreferencesForDb(patch: SyncedPreferencesPatch): SyncedPreferencesPatch | string {
|
function serializePreferencesForDb(patch: SyncedPreferencesPatch): SyncedPreferencesPatch | string {
|
||||||
if (process.env.POSTGRES_URL) return patch;
|
if (process.env.POSTGRES_URL) return patch;
|
||||||
return JSON.stringify(patch);
|
return JSON.stringify(patch);
|
||||||
|
|
@ -92,10 +89,9 @@ function sanitizePreferencesPatch(input: unknown): SyncedPreferencesPatch {
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeClientUpdatedAtMs(value: unknown): number {
|
function normalizeClientUpdatedAtMs(value: unknown): number {
|
||||||
if (!Number.isFinite(value)) return Date.now();
|
const normalized = coerceTimestampMs(value, nowTimestampMs());
|
||||||
const normalized = Number(value);
|
if (normalized <= 0) return nowTimestampMs();
|
||||||
if (normalized <= 0) return Date.now();
|
return normalized;
|
||||||
return Math.floor(normalized);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
|
@ -164,7 +160,7 @@ export async function PUT(req: NextRequest) {
|
||||||
|
|
||||||
const mergedPatch = { ...existingPatch, ...patch };
|
const mergedPatch = { ...existingPatch, ...patch };
|
||||||
const dataJson = serializePreferencesForDb(mergedPatch);
|
const dataJson = serializePreferencesForDb(mergedPatch);
|
||||||
const updatedAt = nowForDb();
|
const updatedAt = nowTimestampMs();
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(userPreferences)
|
.insert(userPreferences)
|
||||||
|
|
|
||||||
|
|
@ -5,29 +5,19 @@ import { userDocumentProgress } from '@/db/schema';
|
||||||
import type { ReaderType } from '@/types/user-state';
|
import type { ReaderType } from '@/types/user-state';
|
||||||
import { isValidDocumentId } from '@/lib/server/documents/blobstore';
|
import { isValidDocumentId } from '@/lib/server/documents/blobstore';
|
||||||
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
|
import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope';
|
||||||
|
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
function nowForDb(): Date | number {
|
|
||||||
return process.env.POSTGRES_URL ? new Date() : Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeReaderType(value: unknown): ReaderType | null {
|
function normalizeReaderType(value: unknown): ReaderType | null {
|
||||||
if (value === 'pdf' || value === 'epub' || value === 'html') return value;
|
if (value === 'pdf' || value === 'epub' || value === 'html') return value;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeClientUpdatedAtMs(value: unknown): number {
|
function normalizeClientUpdatedAtMs(value: unknown): number {
|
||||||
if (!Number.isFinite(value)) return Date.now();
|
const normalized = coerceTimestampMs(value, nowTimestampMs());
|
||||||
const normalized = Number(value);
|
if (normalized <= 0) return nowTimestampMs();
|
||||||
if (normalized <= 0) return Date.now();
|
return normalized;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
|
|
@ -68,7 +58,7 @@ export async function GET(req: NextRequest) {
|
||||||
location: row.location,
|
location: row.location,
|
||||||
progress: row.progress == null ? null : Number(row.progress),
|
progress: row.progress == null ? null : Number(row.progress),
|
||||||
clientUpdatedAtMs: Number(row.clientUpdatedAtMs ?? 0),
|
clientUpdatedAtMs: Number(row.clientUpdatedAtMs ?? 0),
|
||||||
updatedAtMs: toUpdatedAtMs(row.updatedAt),
|
updatedAtMs: coerceTimestampMs(row.updatedAt, nowTimestampMs()),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -140,13 +130,13 @@ export async function PUT(req: NextRequest) {
|
||||||
location: existing.location,
|
location: existing.location,
|
||||||
progress: existing.progress == null ? null : Number(existing.progress),
|
progress: existing.progress == null ? null : Number(existing.progress),
|
||||||
clientUpdatedAtMs: existingUpdated,
|
clientUpdatedAtMs: existingUpdated,
|
||||||
updatedAtMs: toUpdatedAtMs(existing.updatedAt),
|
updatedAtMs: coerceTimestampMs(existing.updatedAt, nowTimestampMs()),
|
||||||
},
|
},
|
||||||
applied: false,
|
applied: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedAt = nowForDb();
|
const updatedAt = nowTimestampMs();
|
||||||
await db
|
await db
|
||||||
.insert(userDocumentProgress)
|
.insert(userDocumentProgress)
|
||||||
.values({
|
.values({
|
||||||
|
|
@ -177,7 +167,7 @@ export async function PUT(req: NextRequest) {
|
||||||
location,
|
location,
|
||||||
progress,
|
progress,
|
||||||
clientUpdatedAtMs,
|
clientUpdatedAtMs,
|
||||||
updatedAtMs: toUpdatedAtMs(updatedAt),
|
updatedAtMs: updatedAt,
|
||||||
},
|
},
|
||||||
applied: true,
|
applied: true,
|
||||||
});
|
});
|
||||||
|
|
@ -186,4 +176,3 @@ export async function PUT(req: NextRequest) {
|
||||||
return NextResponse.json({ error: 'Failed to update user progress' }, { status: 500 });
|
return NextResponse.json({ error: 'Failed to update user progress' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, useEffect, useCallback, useRef, ReactNode } from 'react';
|
import React, { createContext, useContext, useState, useEffect, useCallback, useRef, ReactNode } from 'react';
|
||||||
|
import { coerceTimestampMs, nextUtcMidnightTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
|
||||||
|
|
||||||
export interface RateLimitStatus {
|
export interface RateLimitStatus {
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
currentCount: number;
|
currentCount: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
remainingChars: number;
|
remainingChars: number;
|
||||||
resetTime: Date;
|
resetTimeMs: number;
|
||||||
userType: 'anonymous' | 'authenticated' | 'unauthenticated';
|
userType: 'anonymous' | 'authenticated' | 'unauthenticated';
|
||||||
authEnabled: boolean;
|
authEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -52,9 +53,8 @@ export function useRateLimit() {
|
||||||
return useAuthRateLimit();
|
return useAuthRateLimit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateTimeUntilReset(resetTime: Date): string {
|
function calculateTimeUntilReset(resetTimeMs: number): string {
|
||||||
const now = new Date();
|
const timeDiff = resetTimeMs - nowTimestampMs();
|
||||||
const timeDiff = resetTime.getTime() - now.getTime();
|
|
||||||
|
|
||||||
if (timeDiff <= 0) {
|
if (timeDiff <= 0) {
|
||||||
return 'Soon';
|
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 {
|
export function formatCharCount(count: number): string {
|
||||||
if (count >= 1_000_000) {
|
if (count >= 1_000_000) {
|
||||||
const m = count / 1_000_000;
|
const m = count / 1_000_000;
|
||||||
|
|
@ -110,18 +131,13 @@ export function AuthRateLimitProvider({
|
||||||
const fetchStatus = useCallback(async () => {
|
const fetchStatus = useCallback(async () => {
|
||||||
// Skip if auth is not enabled
|
// Skip if auth is not enabled
|
||||||
if (!authEnabled) {
|
if (!authEnabled) {
|
||||||
const now = new Date();
|
|
||||||
const tomorrow = new Date(now);
|
|
||||||
tomorrow.setUTCDate(now.getUTCDate() + 1);
|
|
||||||
tomorrow.setUTCHours(0, 0, 0, 0);
|
|
||||||
|
|
||||||
setStatus({
|
setStatus({
|
||||||
allowed: true,
|
allowed: true,
|
||||||
currentCount: 0,
|
currentCount: 0,
|
||||||
// Avoid Infinity to prevent JSON/serialization edge cases elsewhere.
|
// Avoid Infinity to prevent JSON/serialization edge cases elsewhere.
|
||||||
limit: Number.MAX_SAFE_INTEGER,
|
limit: Number.MAX_SAFE_INTEGER,
|
||||||
remainingChars: Number.MAX_SAFE_INTEGER,
|
remainingChars: Number.MAX_SAFE_INTEGER,
|
||||||
resetTime: tomorrow,
|
resetTimeMs: nextUtcMidnightTimestampMs(),
|
||||||
userType: 'unauthenticated',
|
userType: 'unauthenticated',
|
||||||
authEnabled: false
|
authEnabled: false
|
||||||
});
|
});
|
||||||
|
|
@ -140,11 +156,7 @@ export function AuthRateLimitProvider({
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
setStatus(parseRateLimitStatus(data));
|
||||||
setStatus({
|
|
||||||
...data,
|
|
||||||
resetTime: new Date(data.resetTime)
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching rate limit status:', err);
|
console.error('Error fetching rate limit status:', err);
|
||||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||||
|
|
@ -158,7 +170,7 @@ export function AuthRateLimitProvider({
|
||||||
}, [fetchStatus]);
|
}, [fetchStatus]);
|
||||||
|
|
||||||
// Calculate time until reset
|
// 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.
|
// 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.
|
// The server allows the final request that may cross the limit, then blocks subsequent ones.
|
||||||
const isAtLimit = status ? (status.remainingChars <= 0 || !status.allowed) : false;
|
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';
|
import { user } from './schema_auth_postgres';
|
||||||
|
|
||||||
|
const PG_NOW_MS = sql`(extract(epoch from now()) * 1000)::bigint`;
|
||||||
|
|
||||||
export const documents = pgTable('documents', {
|
export const documents = pgTable('documents', {
|
||||||
id: text('id').notNull(),
|
id: text('id').notNull(),
|
||||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
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(),
|
size: bigint('size', { mode: 'number' }).notNull(),
|
||||||
lastModified: bigint('last_modified', { mode: 'number' }).notNull(),
|
lastModified: bigint('last_modified', { mode: 'number' }).notNull(),
|
||||||
filePath: text('file_path').notNull(),
|
filePath: text('file_path').notNull(),
|
||||||
createdAt: timestamp('created_at').defaultNow(),
|
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
primaryKey({ columns: [table.id, table.userId] }),
|
primaryKey({ columns: [table.id, table.userId] }),
|
||||||
index('idx_documents_user_id').on(table.userId),
|
index('idx_documents_user_id').on(table.userId),
|
||||||
|
|
@ -24,7 +27,7 @@ export const audiobooks = pgTable('audiobooks', {
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
coverPath: text('cover_path'),
|
coverPath: text('cover_path'),
|
||||||
duration: real('duration').default(0),
|
duration: real('duration').default(0),
|
||||||
createdAt: timestamp('created_at').defaultNow(),
|
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
primaryKey({ columns: [table.id, table.userId] }),
|
primaryKey({ columns: [table.id, table.userId] }),
|
||||||
]);
|
]);
|
||||||
|
|
@ -54,8 +57,8 @@ export const userTtsChars = pgTable("user_tts_chars", {
|
||||||
userId: text('user_id').notNull(),
|
userId: text('user_id').notNull(),
|
||||||
date: date('date').notNull(),
|
date: date('date').notNull(),
|
||||||
charCount: bigint('char_count', { mode: 'number' }).default(0),
|
charCount: bigint('char_count', { mode: 'number' }).default(0),
|
||||||
createdAt: timestamp('created_at').defaultNow(),
|
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
|
||||||
updatedAt: timestamp('updated_at').defaultNow(),
|
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
primaryKey({ columns: [table.userId, table.date] }),
|
primaryKey({ columns: [table.userId, table.date] }),
|
||||||
index('idx_user_tts_chars_date').on(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' }),
|
userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }),
|
||||||
dataJson: jsonb('data_json').notNull().default({}),
|
dataJson: jsonb('data_json').notNull().default({}),
|
||||||
clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).notNull().default(0),
|
clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).notNull().default(0),
|
||||||
createdAt: timestamp('created_at').defaultNow(),
|
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
|
||||||
updatedAt: timestamp('updated_at').defaultNow(),
|
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userDocumentProgress = pgTable('user_document_progress', {
|
export const userDocumentProgress = pgTable('user_document_progress', {
|
||||||
|
|
@ -76,8 +79,8 @@ export const userDocumentProgress = pgTable('user_document_progress', {
|
||||||
location: text('location').notNull(),
|
location: text('location').notNull(),
|
||||||
progress: real('progress'),
|
progress: real('progress'),
|
||||||
clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).notNull().default(0),
|
clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).notNull().default(0),
|
||||||
createdAt: timestamp('created_at').defaultNow(),
|
createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS),
|
||||||
updatedAt: timestamp('updated_at').defaultNow(),
|
updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
primaryKey({ columns: [table.userId, table.documentId] }),
|
primaryKey({ columns: [table.userId, table.documentId] }),
|
||||||
index('idx_user_document_progress_user_id_updated_at').on(table.userId, table.updatedAt),
|
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 { sql } from 'drizzle-orm';
|
||||||
import { user } from './schema_auth_sqlite';
|
import { user } from './schema_auth_sqlite';
|
||||||
|
|
||||||
|
const SQLITE_NOW_MS = sql`(cast(unixepoch('subsecond') * 1000 as integer))`;
|
||||||
|
|
||||||
export const documents = sqliteTable('documents', {
|
export const documents = sqliteTable('documents', {
|
||||||
id: text('id').notNull(),
|
id: text('id').notNull(),
|
||||||
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
|
@ -10,7 +12,7 @@ export const documents = sqliteTable('documents', {
|
||||||
size: integer('size').notNull(),
|
size: integer('size').notNull(),
|
||||||
lastModified: integer('last_modified').notNull(),
|
lastModified: integer('last_modified').notNull(),
|
||||||
filePath: text('file_path').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) => [
|
}, (table) => [
|
||||||
primaryKey({ columns: [table.id, table.userId] }),
|
primaryKey({ columns: [table.id, table.userId] }),
|
||||||
index('idx_documents_user_id').on(table.userId),
|
index('idx_documents_user_id').on(table.userId),
|
||||||
|
|
@ -25,7 +27,7 @@ export const audiobooks = sqliteTable('audiobooks', {
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
coverPath: text('cover_path'),
|
coverPath: text('cover_path'),
|
||||||
duration: real('duration').default(0),
|
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) => [
|
}, (table) => [
|
||||||
primaryKey({ columns: [table.id, table.userId] }),
|
primaryKey({ columns: [table.id, table.userId] }),
|
||||||
]);
|
]);
|
||||||
|
|
@ -55,8 +57,8 @@ export const userTtsChars = sqliteTable("user_tts_chars", {
|
||||||
userId: text('user_id').notNull(),
|
userId: text('user_id').notNull(),
|
||||||
date: text('date').notNull(), // SQLite doesn't have native DATE type, text YYYY-MM-DD is standard
|
date: text('date').notNull(), // SQLite doesn't have native DATE type, text YYYY-MM-DD is standard
|
||||||
charCount: integer('char_count').default(0),
|
charCount: integer('char_count').default(0),
|
||||||
createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
|
createdAt: integer('created_at').default(SQLITE_NOW_MS),
|
||||||
updatedAt: integer('updated_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
|
updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
primaryKey({ columns: [table.userId, table.date] }),
|
primaryKey({ columns: [table.userId, table.date] }),
|
||||||
index('idx_user_tts_chars_date').on(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' }),
|
userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }),
|
||||||
dataJson: text('data_json').notNull().default('{}'),
|
dataJson: text('data_json').notNull().default('{}'),
|
||||||
clientUpdatedAtMs: integer('client_updated_at_ms').notNull().default(0),
|
clientUpdatedAtMs: integer('client_updated_at_ms').notNull().default(0),
|
||||||
createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
|
createdAt: integer('created_at').default(SQLITE_NOW_MS),
|
||||||
updatedAt: integer('updated_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
|
updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userDocumentProgress = sqliteTable('user_document_progress', {
|
export const userDocumentProgress = sqliteTable('user_document_progress', {
|
||||||
|
|
@ -77,8 +79,8 @@ export const userDocumentProgress = sqliteTable('user_document_progress', {
|
||||||
location: text('location').notNull(),
|
location: text('location').notNull(),
|
||||||
progress: real('progress'),
|
progress: real('progress'),
|
||||||
clientUpdatedAtMs: integer('client_updated_at_ms').notNull().default(0),
|
clientUpdatedAtMs: integer('client_updated_at_ms').notNull().default(0),
|
||||||
createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
|
createdAt: integer('created_at').default(SQLITE_NOW_MS),
|
||||||
updatedAt: integer('updated_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
|
updatedAt: integer('updated_at').default(SQLITE_NOW_MS),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
primaryKey({ columns: [table.userId, table.documentId] }),
|
primaryKey({ columns: [table.userId, table.documentId] }),
|
||||||
index('idx_user_document_progress_user_id_updated_at').on(table.userId, table.updatedAt),
|
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;
|
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') {
|
if (status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED') {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { db } from '@/db';
|
||||||
import { userTtsChars } from '@/db/schema';
|
import { userTtsChars } from '@/db/schema';
|
||||||
import { isAuthEnabled } from '@/lib/server/auth/config';
|
import { isAuthEnabled } from '@/lib/server/auth/config';
|
||||||
import { eq, and, lt, sql } from 'drizzle-orm';
|
import { eq, and, lt, sql } from 'drizzle-orm';
|
||||||
|
import { nextUtcMidnightTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
|
||||||
|
|
||||||
function readPositiveIntEnv(name: string, fallback: number): number {
|
function readPositiveIntEnv(name: string, fallback: number): number {
|
||||||
const raw = process.env[name];
|
const raw = process.env[name];
|
||||||
|
|
@ -52,7 +53,7 @@ export interface RateLimitResult {
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
currentCount: number;
|
currentCount: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
resetTime: Date;
|
resetTimeMs: number;
|
||||||
remainingChars: number;
|
remainingChars: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,8 +134,8 @@ export class RateLimiter {
|
||||||
return Boolean(process.env.POSTGRES_URL);
|
return Boolean(process.env.POSTGRES_URL);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getUpdatedAtValue(): Date | number {
|
private getUpdatedAtValue(): number {
|
||||||
return this.isPostgres() ? new Date() : Date.now();
|
return nowTimestampMs();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use a transaction only when running with Postgres.
|
// Use a transaction only when running with Postgres.
|
||||||
|
|
@ -157,7 +158,7 @@ export class RateLimiter {
|
||||||
allowed: true,
|
allowed: true,
|
||||||
currentCount: 0,
|
currentCount: 0,
|
||||||
limit: Number.MAX_SAFE_INTEGER,
|
limit: Number.MAX_SAFE_INTEGER,
|
||||||
resetTime: this.getResetTime(),
|
resetTimeMs: this.getResetTimeMs(),
|
||||||
remainingChars: Number.MAX_SAFE_INTEGER
|
remainingChars: Number.MAX_SAFE_INTEGER
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -236,7 +237,7 @@ export class RateLimiter {
|
||||||
allowed: true,
|
allowed: true,
|
||||||
currentCount: effective.currentCount,
|
currentCount: effective.currentCount,
|
||||||
limit: effective.limit,
|
limit: effective.limit,
|
||||||
resetTime: this.getResetTime(),
|
resetTimeMs: this.getResetTimeMs(),
|
||||||
remainingChars: effective.remainingChars,
|
remainingChars: effective.remainingChars,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -258,7 +259,7 @@ export class RateLimiter {
|
||||||
allowed: true,
|
allowed: true,
|
||||||
currentCount: 0,
|
currentCount: 0,
|
||||||
limit: Number.MAX_SAFE_INTEGER,
|
limit: Number.MAX_SAFE_INTEGER,
|
||||||
resetTime: this.getResetTime(),
|
resetTimeMs: this.getResetTimeMs(),
|
||||||
remainingChars: Number.MAX_SAFE_INTEGER
|
remainingChars: Number.MAX_SAFE_INTEGER
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -300,7 +301,7 @@ export class RateLimiter {
|
||||||
allowed: effective.allowed,
|
allowed: effective.allowed,
|
||||||
currentCount: effective.currentCount,
|
currentCount: effective.currentCount,
|
||||||
limit: effective.limit,
|
limit: effective.limit,
|
||||||
resetTime: this.getResetTime(),
|
resetTimeMs: this.getResetTimeMs(),
|
||||||
remainingChars: effective.remainingChars,
|
remainingChars: effective.remainingChars,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -356,12 +357,8 @@ export class RateLimiter {
|
||||||
await safeDb().delete(userTtsChars).where(lt(userTtsChars.date, cutoffDateValue));
|
await safeDb().delete(userTtsChars).where(lt(userTtsChars.date, cutoffDateValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
private getResetTime(): Date {
|
private getResetTimeMs(): number {
|
||||||
const now = new Date();
|
return nextUtcMidnightTimestampMs();
|
||||||
const tomorrow = new Date(now);
|
|
||||||
tomorrow.setUTCDate(now.getUTCDate() + 1);
|
|
||||||
tomorrow.setUTCHours(0, 0, 0, 0); // Start of next day in UTC
|
|
||||||
return tomorrow;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ type AudiobookRow = {
|
||||||
description: string | null;
|
description: string | null;
|
||||||
coverPath: string | null;
|
coverPath: string | null;
|
||||||
duration: number | null;
|
duration: number | null;
|
||||||
createdAt: unknown;
|
createdAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AudiobookChapterRow = {
|
type AudiobookChapterRow = {
|
||||||
|
|
@ -38,8 +38,8 @@ type UserPreferenceRow = {
|
||||||
userId: string;
|
userId: string;
|
||||||
dataJson: unknown;
|
dataJson: unknown;
|
||||||
clientUpdatedAtMs: number;
|
clientUpdatedAtMs: number;
|
||||||
createdAt: unknown;
|
createdAt: number;
|
||||||
updatedAt: unknown;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UserDocumentProgressRow = {
|
type UserDocumentProgressRow = {
|
||||||
|
|
@ -49,8 +49,8 @@ type UserDocumentProgressRow = {
|
||||||
location: string;
|
location: string;
|
||||||
progress: number | null;
|
progress: number | null;
|
||||||
clientUpdatedAtMs: number;
|
clientUpdatedAtMs: number;
|
||||||
createdAt: unknown;
|
createdAt: number;
|
||||||
updatedAt: unknown;
|
updatedAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function contentTypeForAudiobookObject(fileName: string): string {
|
function contentTypeForAudiobookObject(fileName: string): string {
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ type ExportAudiobookObject = {
|
||||||
export type AppendUserExportArchiveInput = {
|
export type AppendUserExportArchiveInput = {
|
||||||
archive: Archiver;
|
archive: Archiver;
|
||||||
userId: string;
|
userId: string;
|
||||||
exportedAtIso: string;
|
exportedAtMs: number;
|
||||||
profileData: unknown;
|
profileData: unknown;
|
||||||
preferences: unknown | null;
|
preferences: unknown | null;
|
||||||
readingHistory: unknown[];
|
readingHistory: unknown[];
|
||||||
|
|
@ -119,7 +119,7 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu
|
||||||
const {
|
const {
|
||||||
archive,
|
archive,
|
||||||
userId,
|
userId,
|
||||||
exportedAtIso,
|
exportedAtMs,
|
||||||
profileData,
|
profileData,
|
||||||
preferences,
|
preferences,
|
||||||
readingHistory,
|
readingHistory,
|
||||||
|
|
@ -217,7 +217,7 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu
|
||||||
|
|
||||||
const manifest = {
|
const manifest = {
|
||||||
formatVersion: 2,
|
formatVersion: 2,
|
||||||
exportedAt: exportedAtIso,
|
exportedAtMs,
|
||||||
userId,
|
userId,
|
||||||
scope: 'owned',
|
scope: 'owned',
|
||||||
counts: {
|
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