convex
Version:
Client for the Convex Cloud
125 lines (110 loc) • 3.48 kB
text/typescript
type ParsedExpirationSuccess =
| { kind: "none" }
| { kind: "absolute"; timestampMs: number }
| { kind: "relative"; amount: number; unit: "minute" | "hour" | "day" };
type ParsedExpiration =
| ParsedExpirationSuccess
| { kind: "error"; message: string };
const PARSE_ERROR_MESSAGE =
`Supported formats:\n` +
` "none" — no expiration\n` +
` "in 7 days" — relative (minutes, hours, days)\n` +
` "2026-04-01T00:00:00Z" — UTC datetime\n` +
` "1711828382" — Unix timestamp (seconds)\n` +
` "1711828382000" — Unix timestamp (milliseconds)`;
const UNIT_MS = {
minute: 60 * 1000,
hour: 60 * 60 * 1000,
day: 24 * 60 * 60 * 1000,
} as const;
/**
* Parse an expiration input string into a structured representation.
* Does not depend on the current time.
*/
export function parseExpiration(input: string): ParsedExpiration {
const trimmed = input.trim();
if (trimmed.toLowerCase() === "none") {
return { kind: "none" };
}
// All digits → Unix timestamp
if (/^\d+$/.test(trimmed)) {
const n = Number(trimmed);
return {
kind: "absolute",
timestampMs:
n < 1e12 // 1e12 milliseconds is a date in 2001 → unambiguous
? n * 1000 // seconds → convert to ms
: n, // already milliseconds
};
}
// UTC datetime: "2026-04-01T00:00:00Z"
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(trimmed)) {
const date = new Date(trimmed);
if (isNaN(date.getTime())) {
return {
kind: "error",
message: `Invalid UTC datetime: "${trimmed}". ${PARSE_ERROR_MESSAGE}`,
};
}
return { kind: "absolute", timestampMs: date.getTime() };
}
// Relative: "in 3 hours", "in 1 day", "in 45 minutes"
const relativeMatch = trimmed.match(/^in\s+(\d+)\s+(minute|hour|day)s?$/i);
if (relativeMatch) {
const amount = Number(relativeMatch[1]);
const unit = relativeMatch[2].toLowerCase() as "minute" | "hour" | "day";
return { kind: "relative", amount, unit };
}
return {
kind: "error",
message: `Invalid expiration format: "${trimmed}". ${PARSE_ERROR_MESSAGE}`,
};
}
/**
* Resolve a parsed expiration into a timestamp in milliseconds, or null for "none".
*/
export function resolveExpiration(
parsed: ParsedExpirationSuccess,
now?: number,
): number | null {
switch (parsed.kind) {
case "none":
return null;
case "absolute":
return parsed.timestampMs;
case "relative": {
const base = now ?? Date.now();
return base + parsed.amount * UNIT_MS[parsed.unit];
}
}
}
type ValidationResult =
| { kind: "success" }
| { kind: "error"; message: string };
/**
* Validate that a resolved expiration timestamp is acceptable.
*/
export function validateExpiration(
timestampMs: number,
now?: number,
): ValidationResult {
const base = now ?? Date.now();
const thirtyMinutes = 30 * 60 * 1000;
const oneYear = 365 * 24 * 60 * 60 * 1000;
if (timestampMs <= base) {
return { kind: "error", message: "Expiration must be in the future." };
}
if (timestampMs - base < thirtyMinutes) {
return {
kind: "error",
message: "Expiration must be at least 30 minutes from now.",
};
}
if (timestampMs - base > oneYear) {
return {
kind: "error",
message: "Expiration must be at most 1 year from now.",
};
}
return { kind: "success" };
}