Compare commits

...

9 Commits

Author SHA1 Message Date
ad99bca7fd style: rename unused param 2025-11-08 18:41:30 +00:00
b241f4e211 feat: add clear uploads button
add a clear upload button that is visible when there are upload errors

Co-authored-by: Ona <no-reply@ona.com>
2025-11-08 18:38:43 +00:00
027a315a04 style: apply biome formatting to config and generated files
- Convert spaces to tabs in tsconfig files
- Format CLI commands and prompts
- Update generated Convex files
- Format betterauth adapter and schema

Co-authored-by: Ona <no-reply@ona.com>
2025-11-08 18:03:32 +00:00
015524cd63 style: apply biome formatting to UI components
- Convert spaces to tabs for consistency
- Add 'type' modifier to React imports
- Format component code

Co-authored-by: Ona <no-reply@ona.com>
2025-11-08 18:03:26 +00:00
4ebb3fe620 style: format imports and code style
- Consolidate multi-line imports
- Apply consistent formatting

Co-authored-by: Ona <no-reply@ona.com>
2025-11-08 18:03:21 +00:00
b8c46217f7 chore: remove debug console.logs and add error handling
- Remove console.log statements from upload file dialog
- Add onError handler to display error toasts
- Update ErrorCode reference in use-file-drop

Co-authored-by: Ona <no-reply@ona.com>
2025-11-08 18:03:15 +00:00
94d6a22ab2 refactor: update remaining error imports to use ErrorCode
- Replace Err.Code with ErrorCode throughout convex model files
- Update error() function calls to use new signature
- Remove unused Err namespace imports

Co-authored-by: Ona <no-reply@ona.com>
2025-11-08 18:03:10 +00:00
f20f1a93c7 refactor: replace namespace import with direct type import
- Replace 'import type * as Err' with direct ApplicationErrorData import
- Update Err.ApplicationErrorData references to ApplicationErrorData

This improves code clarity and follows the project's import conventions.

Co-authored-by: Ona <no-reply@ona.com>
2025-11-08 17:56:42 +00:00
acfe1523df refactor: wrap all errors in ConvexError
- Update error() helper to throw ConvexError instead of plain objects
- Add isApplicationConvexError() type guard for client-side error checking
- Fix Vite config to include convex/values in optimizeDeps for proper instanceof checks
- Update error handling to check ConvexError wrapper and extract data property

This ensures all application errors are properly typed and can be identified
using instanceof ConvexError on the client side.

Co-authored-by: Ona <no-reply@ona.com>
2025-11-08 17:56:28 +00:00
41 changed files with 909 additions and 780 deletions

View File

@@ -1,11 +1,7 @@
import { generateApiKey, newPrefix } from "@drexa/auth" import { generateApiKey, newPrefix } from "@drexa/auth"
import chalk from "chalk" import chalk from "chalk"
import { Command } from "commander" import { Command } from "commander"
import { import { promptNumber, promptOptionalDate, promptText } from "../../prompts.ts"
promptNumber,
promptOptionalDate,
promptText,
} from "../../prompts.ts"
export const apikeyCommand = new Command("apikey") export const apikeyCommand = new Command("apikey")
.description("Generate a new API key") .description("Generate a new API key")
@@ -53,7 +49,8 @@ export const apikeyCommand = new Command("apikey")
console.log(chalk.green(` ${result.unhashedKey}\n`)) console.log(chalk.green(` ${result.unhashedKey}\n`))
console.log(chalk.gray("─".repeat(60))) console.log(chalk.gray("─".repeat(60)))
console.log( console.log(
chalk.bold("\nHashed Key ") + chalk.dim("(store this in your database):"), chalk.bold("\nHashed Key ") +
chalk.dim("(store this in your database):"),
) )
console.log(chalk.dim(` ${result.hashedKey}\n`)) console.log(chalk.dim(` ${result.hashedKey}\n`))
console.log(chalk.bold("Description:")) console.log(chalk.bold("Description:"))

View File

@@ -29,7 +29,9 @@ export async function promptNumber(
): Promise<number> { ): Promise<number> {
const rl = createReadlineInterface() const rl = createReadlineInterface()
try { try {
const defaultStr = defaultValue ? chalk.dim(` (default: ${defaultValue})`) : "" const defaultStr = defaultValue
? chalk.dim(` (default: ${defaultValue})`)
: ""
const input = await rl.question(chalk.cyan(`${message}${defaultStr} `)) const input = await rl.question(chalk.cyan(`${message}${defaultStr} `))
if ((!input || input.trim() === "") && defaultValue !== undefined) { if ((!input || input.trim() === "") && defaultValue !== undefined) {
@@ -59,7 +61,8 @@ export async function promptOptionalDate(
const rl = createReadlineInterface() const rl = createReadlineInterface()
try { try {
const input = await rl.question( const input = await rl.question(
chalk.cyan(`${message} `) + chalk.dim("(optional, format: YYYY-MM-DD) "), chalk.cyan(`${message} `) +
chalk.dim("(optional, format: YYYY-MM-DD) "),
) )
if (!input || input.trim() === "") { if (!input || input.trim() === "") {
@@ -68,7 +71,9 @@ export async function promptOptionalDate(
const date = new Date(input.trim()) const date = new Date(input.trim())
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
console.error(chalk.red("✗ Invalid date format. Please use YYYY-MM-DD")) console.error(
chalk.red("✗ Invalid date format. Please use YYYY-MM-DD"),
)
process.exit(1) process.exit(1)
} }

83
apps/cli/test-example.md Normal file
View File

@@ -0,0 +1,83 @@
# Testing the CLI
To test the API key generation interactively, run:
```bash
bun drexa generate apikey
```
## Example Session
The CLI now uses **chalk** for beautiful colored output!
```
$ bun drexa generate apikey
🔑 Generate API Key
Enter API key prefix (e.g., 'proxy', 'admin'): testkey
Enter key byte length: (default: 32)
Enter description: Test API Key for development
Enter expiration date (optional, format: YYYY-MM-DD):
⏳ Generating API key...
✓ API Key Generated Successfully!
────────────────────────────────────────────────────────────
⚠️ IMPORTANT: Save the unhashed key now. It won't be shown again!
Unhashed Key (save this):
sk-testkey-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789
────────────────────────────────────────────────────────────
Hashed Key (store this in your database):
$argon2id$v=19$m=4,t=3,p=1$...
Description:
Test API Key for development
Expires At:
Never
────────────────────────────────────────────────────────────
```
### Color Scheme
- **Prompts**: Cyan text with dimmed hints
- **Success messages**: Green with checkmark
- **Warnings**: Yellow with warning icon
- **Errors**: Red with X mark
- **Important data**: Green (unhashed key), dimmed (hashed key)
- **Separators**: Gray lines
## Testing with Invalid Input
### Invalid prefix (contains dash)
```bash
$ bun drexa generate apikey
Enter API key prefix (e.g., 'proxy', 'admin'): test-key
✗ Invalid prefix: cannot contain "-" character. Please use alphanumeric characters only.
```
### Invalid key byte length
```bash
$ bun drexa generate apikey
Enter API key prefix (e.g., 'proxy', 'admin'): testkey
Enter key byte length: (default: 32) -5
✗ Please enter a valid positive number
```
### Invalid date format
```bash
$ bun drexa generate apikey
Enter API key prefix (e.g., 'proxy', 'admin'): testkey
Enter key byte length: (default: 32)
Enter description: Test
Enter expiration date (optional, format: YYYY-MM-DD): invalid-date
✗ Invalid date format. Please use YYYY-MM-DD
```
All error messages are displayed in red for better visibility.

View File

@@ -1,28 +1,28 @@
{ {
"compilerOptions": { "compilerOptions": {
// Environment setup & latest features // Environment setup & latest features
"lib": ["ESNext"], "lib": ["ESNext"],
"target": "ESNext", "target": "ESNext",
"module": "Preserve", "module": "Preserve",
"moduleDetection": "force", "moduleDetection": "force",
"allowJs": true, "allowJs": true,
// Bundler mode // Bundler mode
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"noEmit": true, "noEmit": true,
// Best practices // Best practices
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"noImplicitOverride": true, "noImplicitOverride": true,
// Some stricter flags (disabled by default) // Some stricter flags (disabled by default)
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false, "noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false "noPropertyAccessFromIndexSignature": false
} }
} }

View File

@@ -1,39 +1,50 @@
import { useRef, type FormEvent } from "react"; import { type FormEvent, useRef } from "react"
export function APITester() { export function APITester() {
const responseInputRef = useRef<HTMLTextAreaElement>(null); const responseInputRef = useRef<HTMLTextAreaElement>(null)
const testEndpoint = async (e: FormEvent<HTMLFormElement>) => { const testEndpoint = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault()
try { try {
const form = e.currentTarget; const form = e.currentTarget
const formData = new FormData(form); const formData = new FormData(form)
const endpoint = formData.get("endpoint") as string; const endpoint = formData.get("endpoint") as string
const url = new URL(endpoint, location.href); const url = new URL(endpoint, location.href)
const method = formData.get("method") as string; const method = formData.get("method") as string
const res = await fetch(url, { method }); const res = await fetch(url, { method })
const data = await res.json(); const data = await res.json()
responseInputRef.current!.value = JSON.stringify(data, null, 2); responseInputRef.current!.value = JSON.stringify(data, null, 2)
} catch (error) { } catch (error) {
responseInputRef.current!.value = String(error); responseInputRef.current!.value = String(error)
} }
}; }
return ( return (
<div className="api-tester"> <div className="api-tester">
<form onSubmit={testEndpoint} className="endpoint-row"> <form onSubmit={testEndpoint} className="endpoint-row">
<select name="method" className="method"> <select name="method" className="method">
<option value="GET">GET</option> <option value="GET">GET</option>
<option value="PUT">PUT</option> <option value="PUT">PUT</option>
</select> </select>
<input type="text" name="endpoint" defaultValue="/api/hello" className="url-input" placeholder="/api/hello" /> <input
<button type="submit" className="send-button"> type="text"
Send name="endpoint"
</button> defaultValue="/api/hello"
</form> className="url-input"
<textarea ref={responseInputRef} readOnly placeholder="Response will appear here..." className="response-area" /> placeholder="/api/hello"
</div> />
); <button type="submit" className="send-button">
Send
</button>
</form>
<textarea
ref={responseInputRef}
readOnly
placeholder="Response will appear here..."
className="response-area"
/>
</div>
)
} }

View File

@@ -1,92 +1,92 @@
import * as React from "react" import type * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) { function Card({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", "bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className className,
)} )}
{...props} {...props}
/> />
) )
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className className,
)} )}
{...props} {...props}
/> />
) )
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-title" data-slot="card-title"
className={cn("leading-none font-semibold", className)} className={cn("leading-none font-semibold", className)}
{...props} {...props}
/> />
) )
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-description" data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) )
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-action" data-slot="card-action"
className={cn( className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end", "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className className,
)} )}
{...props} {...props}
/> />
) )
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-content" data-slot="card-content"
className={cn("px-6", className)} className={cn("px-6", className)}
{...props} {...props}
/> />
) )
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-footer" data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)} className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props} {...props}
/> />
) )
} }
export { export {
Card, Card,
CardHeader, CardHeader,
CardFooter, CardFooter,
CardTitle, CardTitle,
CardAction, CardAction,
CardDescription, CardDescription,
CardContent, CardContent,
} }

View File

@@ -1,242 +1,241 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { useMemo } from "react"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return ( return (
<fieldset <fieldset
data-slot="field-set" data-slot="field-set"
className={cn( className={cn(
"flex flex-col gap-6", "flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", "has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className className,
)} )}
{...props} {...props}
/> />
) )
} }
function FieldLegend({ function FieldLegend({
className, className,
variant = "legend", variant = "legend",
...props ...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { }: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return ( return (
<legend <legend
data-slot="field-legend" data-slot="field-legend"
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"mb-3 font-medium", "mb-3 font-medium",
"data-[variant=legend]:text-base", "data-[variant=legend]:text-base",
"data-[variant=label]:text-sm", "data-[variant=label]:text-sm",
className className,
)} )}
{...props} {...props}
/> />
) )
} }
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="field-group" data-slot="field-group"
className={cn( className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4", "group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className className,
)} )}
{...props} {...props}
/> />
) )
} }
const fieldVariants = cva( const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive", "group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{ {
variants: { variants: {
orientation: { orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"], vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [ horizontal: [
"flex-row items-center", "flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto", "[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", "has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
], ],
responsive: [ responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto", "flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto", "@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", "@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
], ],
}, },
}, },
defaultVariants: { defaultVariants: {
orientation: "vertical", orientation: "vertical",
}, },
} },
) )
function Field({ function Field({
className, className,
orientation = "vertical", orientation = "vertical",
...props ...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) { }: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return ( return (
<div <div
role="group" role="group"
data-slot="field" data-slot="field"
data-orientation={orientation} data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)} className={cn(fieldVariants({ orientation }), className)}
{...props} {...props}
/> />
) )
} }
function FieldContent({ className, ...props }: React.ComponentProps<"div">) { function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="field-content" data-slot="field-content"
className={cn( className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug", "group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className className,
)} )}
{...props} {...props}
/> />
) )
} }
function FieldLabel({ function FieldLabel({
className, className,
...props ...props
}: React.ComponentProps<typeof Label>) { }: React.ComponentProps<typeof Label>) {
return ( return (
<Label <Label
data-slot="field-label" data-slot="field-label"
className={cn( className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50", "group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4", "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10", "has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className className,
)} )}
{...props} {...props}
/> />
) )
} }
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) { function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="field-label" data-slot="field-label"
className={cn( className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50", "flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className className,
)} )}
{...props} {...props}
/> />
) )
} }
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) { function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return ( return (
<p <p
data-slot="field-description" data-slot="field-description"
className={cn( className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance", "text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5", "last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4", "[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className className,
)} )}
{...props} {...props}
/> />
) )
} }
function FieldSeparator({ function FieldSeparator({
children, children,
className, className,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
children?: React.ReactNode children?: React.ReactNode
}) { }) {
return ( return (
<div <div
data-slot="field-separator" data-slot="field-separator"
data-content={!!children} data-content={!!children}
className={cn( className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2", "relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className className,
)} )}
{...props} {...props}
> >
<Separator className="absolute inset-0 top-1/2" /> <Separator className="absolute inset-0 top-1/2" />
{children && ( {children && (
<span <span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2" className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content" data-slot="field-separator-content"
> >
{children} {children}
</span> </span>
)} )}
</div> </div>
) )
} }
function FieldError({ function FieldError({
className, className,
children, children,
errors, errors,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined> errors?: Array<{ message?: string } | undefined>
}) { }) {
const content = useMemo(() => { const content = useMemo(() => {
if (children) { if (children) {
return children return children
} }
if (!errors) { if (!errors) {
return null return null
} }
if (errors?.length === 1 && errors[0]?.message) { if (errors?.length === 1 && errors[0]?.message) {
return errors[0].message return errors[0].message
} }
return ( return (
<ul className="ml-4 flex list-disc flex-col gap-1"> <ul className="ml-4 flex list-disc flex-col gap-1">
{errors.map( {errors.map(
(error, index) => (error, index) =>
error?.message && <li key={index}>{error.message}</li> error?.message && <li key={index}>{error.message}</li>,
)} )}
</ul> </ul>
) )
}, [children, errors]) }, [children, errors])
if (!content) { if (!content) {
return null return null
} }
return ( return (
<div <div
role="alert" role="alert"
data-slot="field-error" data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)} className={cn("text-destructive text-sm font-normal", className)}
{...props} {...props}
> >
{content} {content}
</div> </div>
) )
} }
export { export {
Field, Field,
FieldLabel, FieldLabel,
FieldDescription, FieldDescription,
FieldError, FieldError,
FieldGroup, FieldGroup,
FieldLegend, FieldLegend,
FieldSeparator, FieldSeparator,
FieldSet, FieldSet,
FieldContent, FieldContent,
FieldTitle, FieldTitle,
} }

View File

@@ -1,22 +1,22 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from "@radix-ui/react-label"
import type * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Label({ function Label({
className, className,
...props ...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) { }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return ( return (
<LabelPrimitive.Root <LabelPrimitive.Root
data-slot="label" data-slot="label"
className={cn( className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className className,
)} )}
{...props} {...props}
/> />
) )
} }
export { Label } export { Label }

View File

@@ -1,29 +1,29 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress" import * as ProgressPrimitive from "@radix-ui/react-progress"
import type * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Progress({ function Progress({
className, className,
value, value,
...props ...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) { }: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return ( return (
<ProgressPrimitive.Root <ProgressPrimitive.Root
data-slot="progress" data-slot="progress"
className={cn( className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", "bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className className,
)} )}
{...props} {...props}
> >
<ProgressPrimitive.Indicator <ProgressPrimitive.Indicator
data-slot="progress-indicator" data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all" className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/> />
</ProgressPrimitive.Root> </ProgressPrimitive.Root>
) )
} }
export { Progress } export { Progress }

View File

@@ -1,28 +1,28 @@
"use client" "use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator" import * as SeparatorPrimitive from "@radix-ui/react-separator"
import type * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Separator({ function Separator({
className, className,
orientation = "horizontal", orientation = "horizontal",
decorative = true, decorative = true,
...props ...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) { }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return ( return (
<SeparatorPrimitive.Root <SeparatorPrimitive.Root
data-slot="separator" data-slot="separator"
decorative={decorative} decorative={decorative}
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px", "bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className className,
)} )}
{...props} {...props}
/> />
) )
} }
export { Separator } export { Separator }

View File

@@ -1,8 +1,5 @@
import type { Id } from "@fileone/convex/dataModel" import type { Id } from "@fileone/convex/dataModel"
import type { import type { DirectoryItem, DirectoryItemKind } from "@fileone/convex/types"
DirectoryItem,
DirectoryItemKind,
} from "@fileone/convex/types"
import type { RowSelectionState } from "@tanstack/react-table" import type { RowSelectionState } from "@tanstack/react-table"
import { atom } from "jotai" import { atom } from "jotai"

View File

@@ -53,12 +53,9 @@ export const clearFileUploadStatusesAtom = atom(
}, },
) )
export const clearAllFileUploadStatusesAtom = atom( export const clearAllFileUploadStatusesAtom = atom(null, (_, set) => {
null, set(fileUploadStatusesAtom, {})
(get, set) => { })
set(fileUploadStatusesAtom, {})
},
)
export const fileUploadCountAtom = atom( export const fileUploadCountAtom = atom(
(get) => Object.keys(get(fileUploadStatusesAtom)).length, (get) => Object.keys(get(fileUploadStatusesAtom)).length,

View File

@@ -88,7 +88,6 @@ function useUploadFilesAtom({
) )
}, },
}).catch((error) => { }).catch((error) => {
console.log("error", error)
store.set( store.set(
fileUploadStatusAtomFamily(pickedFile.id), fileUploadStatusAtomFamily(pickedFile.id),
{ {
@@ -130,6 +129,9 @@ function useUploadFilesAtom({
toast.success("All files uploaded successfully") toast.success("All files uploaded successfully")
} }
}, },
onError: (error) => {
toast.error(formatError(error))
},
}), }),
[uploadFile, store.set], [uploadFile, store.set],
) )
@@ -270,6 +272,7 @@ export function UploadFileDialog({
onClick={openFilePicker} onClick={openFilePicker}
uploadFilesAtom={uploadFilesAtom} uploadFilesAtom={uploadFilesAtom}
/> />
<ClearUploadErrorsButton />
<UploadButton <UploadButton
uploadFilesAtom={uploadFilesAtom} uploadFilesAtom={uploadFilesAtom}
onClick={onUploadButtonClick} onClick={onUploadButtonClick}
@@ -373,10 +376,10 @@ function SelectMoreFilesButton({
uploadFilesAtom: UploadFilesAtom uploadFilesAtom: UploadFilesAtom
}) { }) {
const pickedFiles = useAtomValue(pickedFilesAtom) const pickedFiles = useAtomValue(pickedFilesAtom)
const { data: uploadResults, isPending: isUploading } = const fileUploadCount = useAtomValue(fileUploadCountAtom)
useAtomValue(uploadFilesAtom) const { isPending: isUploading } = useAtomValue(uploadFilesAtom)
if (pickedFiles.length === 0 || uploadResults) { if (pickedFiles.length === 0 || fileUploadCount > 0) {
return null return null
} }
@@ -387,6 +390,29 @@ function SelectMoreFilesButton({
) )
} }
function ClearUploadErrorsButton() {
const hasUploadErrors = useAtomValue(hasFileUploadsErrorAtom)
const clearAllFileUploadStatuses = useSetAtom(
clearAllFileUploadStatusesAtom,
)
const setPickedFiles = useSetAtom(pickedFilesAtom)
if (!hasUploadErrors) {
return null
}
function clearUploadErrors() {
setPickedFiles([])
clearAllFileUploadStatuses()
}
return (
<Button variant="outline" onClick={clearUploadErrors}>
Clear uploads
</Button>
)
}
function UploadButton({ function UploadButton({
uploadFilesAtom, uploadFilesAtom,
onClick, onClick,
@@ -533,7 +559,6 @@ function PickedFileItem({
}) { }) {
const fileUploadAtom = fileUploadStatusAtomFamily(pickedFile.id) const fileUploadAtom = fileUploadStatusAtomFamily(pickedFile.id)
const fileUpload = useAtomValue(fileUploadAtom) const fileUpload = useAtomValue(fileUploadAtom)
console.log("fileUpload", fileUpload)
const { file, id } = pickedFile const { file, id } = pickedFile
let statusIndicator: React.ReactNode let statusIndicator: React.ReactNode

View File

@@ -54,7 +54,7 @@ export function useFileDrop({
errors: Err.ApplicationErrorData[] errors: Err.ApplicationErrorData[]
}) => { }) => {
const conflictCount = errors.reduce((acc, error) => { const conflictCount = errors.reduce((acc, error) => {
if (error.code === Err.Code.Conflict) { if (error.code === Err.ErrorCode.Conflict) {
return acc + 1 return acc + 1
} }
return acc return acc

View File

@@ -1,7 +1,9 @@
import { import {
Code as ErrorCode, type ApplicationErrorData,
ErrorCode,
isApplicationError, isApplicationError,
} from "@fileone/convex/error" } from "@fileone/convex/error"
import { ConvexError } from "convex/values"
import { toast } from "sonner" import { toast } from "sonner"
const ERROR_MESSAGE = { const ERROR_MESSAGE = {
@@ -9,13 +11,19 @@ const ERROR_MESSAGE = {
[ErrorCode.FileExists]: "File already exists", [ErrorCode.FileExists]: "File already exists",
[ErrorCode.Internal]: "Internal application error", [ErrorCode.Internal]: "Internal application error",
[ErrorCode.Conflict]: "Conflict", [ErrorCode.Conflict]: "Conflict",
[ErrorCode.DirectoryNotFound]: "Directory not found",
[ErrorCode.FileNotFound]: "File not found",
[ErrorCode.Unauthenticated]: "Unauthenticated", [ErrorCode.Unauthenticated]: "Unauthenticated",
[ErrorCode.NotFound]: "Not found",
[ErrorCode.StorageQuotaExceeded]: "Storage is full",
} as const } as const
export function isApplicationConvexError(
error: unknown,
): error is ConvexError<ApplicationErrorData> {
return error instanceof ConvexError && isApplicationError(error.data)
}
export function formatError(error: unknown): string { export function formatError(error: unknown): string {
if (isApplicationError(error)) { if (isApplicationConvexError(error)) {
return ERROR_MESSAGE[error.data.code] return ERROR_MESSAGE[error.data.code]
} }
if (error instanceof Error) { if (error instanceof Error) {
@@ -25,8 +33,12 @@ export function formatError(error: unknown): string {
} }
export function defaultOnError(error: unknown) { export function defaultOnError(error: unknown) {
console.log(error) if (isApplicationConvexError(error)) {
toast.error(formatError(error)) toast.error(formatError(error))
} else {
console.error("Catastrophic error:", error)
toast.error("An unexpected error occurred")
}
} }
export function withDefaultOnError(fn: (error: unknown) => void) { export function withDefaultOnError(fn: (error: unknown) => void) {

View File

@@ -1,7 +1,7 @@
import path from "node:path"
import tailwindcss from "@tailwindcss/vite" import tailwindcss from "@tailwindcss/vite"
import { TanStackRouterVite } from "@tanstack/router-plugin/vite" import { TanStackRouterVite } from "@tanstack/router-plugin/vite"
import react from "@vitejs/plugin-react" import react from "@vitejs/plugin-react"
import path from "path"
import { defineConfig } from "vite" import { defineConfig } from "vite"
export default defineConfig({ export default defineConfig({
@@ -19,7 +19,7 @@ export default defineConfig({
}, },
}, },
optimizeDeps: { optimizeDeps: {
include: ["convex/react", "convex-helpers"], include: ["convex/react", "convex/values", "convex-helpers"],
// Workaround for better-auth bug: https://github.com/better-auth/better-auth/issues/4457 // Workaround for better-auth bug: https://github.com/better-auth/better-auth/issues/4457
// Vite's esbuild incorrectly transpiles better-call dependency causing 'super' keyword errors // Vite's esbuild incorrectly transpiles better-call dependency causing 'super' keyword errors
exclude: ["better-auth", "@convex-dev/better-auth"], exclude: ["better-auth", "@convex-dev/better-auth"],

View File

@@ -1,29 +1,29 @@
{ {
"compilerOptions": { "compilerOptions": {
// Environment setup & latest features // Environment setup & latest features
"lib": ["ESNext"], "lib": ["ESNext"],
"target": "ESNext", "target": "ESNext",
"module": "Preserve", "module": "Preserve",
"moduleDetection": "force", "moduleDetection": "force",
"jsx": "react-jsx", "jsx": "react-jsx",
"allowJs": true, "allowJs": true,
// Bundler mode // Bundler mode
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"noEmit": true, "noEmit": true,
// Best practices // Best practices
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"noImplicitOverride": true, "noImplicitOverride": true,
// Some stricter flags (disabled by default) // Some stricter flags (disabled by default)
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false, "noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false "noPropertyAccessFromIndexSignature": false
} }
} }

View File

@@ -62,10 +62,14 @@ export class WebCryptoSha256Hasher implements PassswordHasher {
} }
return btoa(binary).replace(/[+/=]/g, (char) => { return btoa(binary).replace(/[+/=]/g, (char) => {
switch (char) { switch (char) {
case "+": return "-" case "+":
case "/": return "_" return "-"
case "=": return "" case "/":
default: return char return "_"
case "=":
return ""
default:
return char
} }
}) })
} }

View File

@@ -1,11 +1,11 @@
{ {
"name": "@drexa/auth", "name": "@drexa/auth",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"
}, },
"peerDependencies": { "peerDependencies": {
"typescript": "^5" "typescript": "^5"
} }
} }

View File

@@ -1,29 +1,29 @@
{ {
"compilerOptions": { "compilerOptions": {
// Environment setup & latest features // Environment setup & latest features
"lib": ["ESNext"], "lib": ["ESNext"],
"target": "ESNext", "target": "ESNext",
"module": "Preserve", "module": "Preserve",
"moduleDetection": "force", "moduleDetection": "force",
"jsx": "react-jsx", "jsx": "react-jsx",
"allowJs": true, "allowJs": true,
// Bundler mode // Bundler mode
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"noEmit": true, "noEmit": true,
// Best practices // Best practices
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"noImplicitOverride": true, "noImplicitOverride": true,
// Some stricter flags (disabled by default) // Some stricter flags (disabled by default)
"noUnusedLocals": false, "noUnusedLocals": false,
"noUnusedParameters": false, "noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false "noPropertyAccessFromIndexSignature": false
} }
} }

View File

@@ -1,13 +1,13 @@
import { createApi } from "@convex-dev/better-auth"; import { createApi } from "@convex-dev/better-auth"
import schema from "./schema"; import { createAuth } from "../auth"
import { createAuth } from "../auth"; import schema from "./schema"
export const { export const {
create, create,
findOne, findOne,
findMany, findMany,
updateOne, updateOne,
updateMany, updateMany,
deleteOne, deleteOne,
deleteMany, deleteMany,
} = createApi(schema, createAuth); } = createApi(schema, createAuth)

View File

@@ -1,5 +1,5 @@
import { createAuth } from '../auth' import { getStaticAuth } from "@convex-dev/better-auth"
import { getStaticAuth } from '@convex-dev/better-auth' import { createAuth } from "../auth"
// Export a static instance for Better Auth schema generation // Export a static instance for Better Auth schema generation
export const auth = getStaticAuth(createAuth) export const auth = getStaticAuth(createAuth)

View File

@@ -1,5 +1,5 @@
import { defineComponent } from "convex/server"; import { defineComponent } from "convex/server"
const component = defineComponent("betterAuth"); const component = defineComponent("betterAuth")
export default component; export default component

View File

@@ -2,69 +2,69 @@
// To regenerate the schema, run: // To regenerate the schema, run:
// `npx @better-auth/cli generate --output undefined -y` // `npx @better-auth/cli generate --output undefined -y`
import { defineSchema, defineTable } from "convex/server"; import { defineSchema, defineTable } from "convex/server"
import { v } from "convex/values"; import { v } from "convex/values"
export const tables = { export const tables = {
user: defineTable({ user: defineTable({
name: v.string(), name: v.string(),
email: v.string(), email: v.string(),
emailVerified: v.boolean(), emailVerified: v.boolean(),
image: v.optional(v.union(v.null(), v.string())), image: v.optional(v.union(v.null(), v.string())),
createdAt: v.number(), createdAt: v.number(),
updatedAt: v.number(), updatedAt: v.number(),
userId: v.optional(v.union(v.null(), v.string())), userId: v.optional(v.union(v.null(), v.string())),
}) })
.index("email_name", ["email","name"]) .index("email_name", ["email", "name"])
.index("name", ["name"]) .index("name", ["name"])
.index("userId", ["userId"]), .index("userId", ["userId"]),
session: defineTable({ session: defineTable({
expiresAt: v.number(), expiresAt: v.number(),
token: v.string(), token: v.string(),
createdAt: v.number(), createdAt: v.number(),
updatedAt: v.number(), updatedAt: v.number(),
ipAddress: v.optional(v.union(v.null(), v.string())), ipAddress: v.optional(v.union(v.null(), v.string())),
userAgent: v.optional(v.union(v.null(), v.string())), userAgent: v.optional(v.union(v.null(), v.string())),
userId: v.string(), userId: v.string(),
}) })
.index("expiresAt", ["expiresAt"]) .index("expiresAt", ["expiresAt"])
.index("expiresAt_userId", ["expiresAt","userId"]) .index("expiresAt_userId", ["expiresAt", "userId"])
.index("token", ["token"]) .index("token", ["token"])
.index("userId", ["userId"]), .index("userId", ["userId"]),
account: defineTable({ account: defineTable({
accountId: v.string(), accountId: v.string(),
providerId: v.string(), providerId: v.string(),
userId: v.string(), userId: v.string(),
accessToken: v.optional(v.union(v.null(), v.string())), accessToken: v.optional(v.union(v.null(), v.string())),
refreshToken: v.optional(v.union(v.null(), v.string())), refreshToken: v.optional(v.union(v.null(), v.string())),
idToken: v.optional(v.union(v.null(), v.string())), idToken: v.optional(v.union(v.null(), v.string())),
accessTokenExpiresAt: v.optional(v.union(v.null(), v.number())), accessTokenExpiresAt: v.optional(v.union(v.null(), v.number())),
refreshTokenExpiresAt: v.optional(v.union(v.null(), v.number())), refreshTokenExpiresAt: v.optional(v.union(v.null(), v.number())),
scope: v.optional(v.union(v.null(), v.string())), scope: v.optional(v.union(v.null(), v.string())),
password: v.optional(v.union(v.null(), v.string())), password: v.optional(v.union(v.null(), v.string())),
createdAt: v.number(), createdAt: v.number(),
updatedAt: v.number(), updatedAt: v.number(),
}) })
.index("accountId", ["accountId"]) .index("accountId", ["accountId"])
.index("accountId_providerId", ["accountId","providerId"]) .index("accountId_providerId", ["accountId", "providerId"])
.index("providerId_userId", ["providerId","userId"]) .index("providerId_userId", ["providerId", "userId"])
.index("userId", ["userId"]), .index("userId", ["userId"]),
verification: defineTable({ verification: defineTable({
identifier: v.string(), identifier: v.string(),
value: v.string(), value: v.string(),
expiresAt: v.number(), expiresAt: v.number(),
createdAt: v.number(), createdAt: v.number(),
updatedAt: v.number(), updatedAt: v.number(),
}) })
.index("expiresAt", ["expiresAt"]) .index("expiresAt", ["expiresAt"])
.index("identifier", ["identifier"]), .index("identifier", ["identifier"]),
jwks: defineTable({ jwks: defineTable({
publicKey: v.string(), publicKey: v.string(),
privateKey: v.string(), privateKey: v.string(),
createdAt: v.number(), createdAt: v.number(),
}), }),
}; }
const schema = defineSchema(tables); const schema = defineSchema(tables)
export default schema; export default schema

View File

@@ -9,10 +9,10 @@
*/ */
import type { import type {
ApiFromModules, ApiFromModules,
FilterApi, FilterApi,
FunctionReference, FunctionReference,
} from "convex/server"; } from "convex/server"
/** /**
* A utility for referencing Convex functions in your app's API. * A utility for referencing Convex functions in your app's API.
@@ -22,12 +22,12 @@ import type {
* const myFunctionReference = api.myModule.myFunction; * const myFunctionReference = api.myModule.myFunction;
* ``` * ```
*/ */
declare const fullApi: ApiFromModules<{}>; declare const fullApi: ApiFromModules<{}>
export declare const api: FilterApi< export declare const api: FilterApi<
typeof fullApi, typeof fullApi,
FunctionReference<any, "public"> FunctionReference<any, "public">
>; >
export declare const internal: FilterApi< export declare const internal: FilterApi<
typeof fullApi, typeof fullApi,
FunctionReference<any, "internal"> FunctionReference<any, "internal">
>; >

View File

@@ -8,7 +8,7 @@
* @module * @module
*/ */
import { anyApi } from "convex/server"; import { anyApi } from "convex/server"
/** /**
* A utility for referencing Convex functions in your app's API. * A utility for referencing Convex functions in your app's API.
@@ -18,5 +18,5 @@ import { anyApi } from "convex/server";
* const myFunctionReference = api.myModule.myFunction; * const myFunctionReference = api.myModule.myFunction;
* ``` * ```
*/ */
export const api = anyApi; export const api = anyApi
export const internal = anyApi; export const internal = anyApi

View File

@@ -8,8 +8,8 @@
* @module * @module
*/ */
import { AnyDataModel } from "convex/server"; import { AnyDataModel } from "convex/server"
import type { GenericId } from "convex/values"; import type { GenericId } from "convex/values"
/** /**
* No `schema.ts` file found! * No `schema.ts` file found!
@@ -25,12 +25,12 @@ import type { GenericId } from "convex/values";
/** /**
* The names of all of your Convex tables. * The names of all of your Convex tables.
*/ */
export type TableNames = string; export type TableNames = string
/** /**
* The type of a document stored in Convex. * The type of a document stored in Convex.
*/ */
export type Doc = any; export type Doc = any
/** /**
* An identifier for a document in Convex. * An identifier for a document in Convex.
@@ -43,8 +43,7 @@ export type Doc = any;
* IDs are just strings at runtime, but this type can be used to distinguish them from other * IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking. * strings when type checking.
*/ */
export type Id<TableName extends TableNames = TableNames> = export type Id<TableName extends TableNames = TableNames> = GenericId<TableName>
GenericId<TableName>;
/** /**
* A type describing your Convex data model. * A type describing your Convex data model.
@@ -55,4 +54,4 @@ export type Id<TableName extends TableNames = TableNames> =
* This type is used to parameterize methods like `queryGeneric` and * This type is used to parameterize methods like `queryGeneric` and
* `mutationGeneric` to make them type-safe. * `mutationGeneric` to make them type-safe.
*/ */
export type DataModel = AnyDataModel; export type DataModel = AnyDataModel

View File

@@ -9,17 +9,17 @@
*/ */
import { import {
ActionBuilder, ActionBuilder,
HttpActionBuilder, HttpActionBuilder,
MutationBuilder, MutationBuilder,
QueryBuilder, QueryBuilder,
GenericActionCtx, GenericActionCtx,
GenericMutationCtx, GenericMutationCtx,
GenericQueryCtx, GenericQueryCtx,
GenericDatabaseReader, GenericDatabaseReader,
GenericDatabaseWriter, GenericDatabaseWriter,
} from "convex/server"; } from "convex/server"
import type { DataModel } from "./dataModel.js"; import type { DataModel } from "./dataModel.js"
/** /**
* Define a query in this Convex app's public API. * Define a query in this Convex app's public API.
@@ -29,7 +29,7 @@ import type { DataModel } from "./dataModel.js";
* @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible. * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/ */
export declare const query: QueryBuilder<DataModel, "public">; export declare const query: QueryBuilder<DataModel, "public">
/** /**
* Define a query that is only accessible from other Convex functions (but not from the client). * Define a query that is only accessible from other Convex functions (but not from the client).
@@ -39,7 +39,7 @@ export declare const query: QueryBuilder<DataModel, "public">;
* @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible. * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/ */
export declare const internalQuery: QueryBuilder<DataModel, "internal">; export declare const internalQuery: QueryBuilder<DataModel, "internal">
/** /**
* Define a mutation in this Convex app's public API. * Define a mutation in this Convex app's public API.
@@ -49,7 +49,7 @@ export declare const internalQuery: QueryBuilder<DataModel, "internal">;
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/ */
export declare const mutation: MutationBuilder<DataModel, "public">; export declare const mutation: MutationBuilder<DataModel, "public">
/** /**
* Define a mutation that is only accessible from other Convex functions (but not from the client). * Define a mutation that is only accessible from other Convex functions (but not from the client).
@@ -59,7 +59,7 @@ export declare const mutation: MutationBuilder<DataModel, "public">;
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/ */
export declare const internalMutation: MutationBuilder<DataModel, "internal">; export declare const internalMutation: MutationBuilder<DataModel, "internal">
/** /**
* Define an action in this Convex app's public API. * Define an action in this Convex app's public API.
@@ -72,7 +72,7 @@ export declare const internalMutation: MutationBuilder<DataModel, "internal">;
* @param func - The action. It receives an {@link ActionCtx} as its first argument. * @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible. * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/ */
export declare const action: ActionBuilder<DataModel, "public">; export declare const action: ActionBuilder<DataModel, "public">
/** /**
* Define an action that is only accessible from other Convex functions (but not from the client). * Define an action that is only accessible from other Convex functions (but not from the client).
@@ -80,7 +80,7 @@ export declare const action: ActionBuilder<DataModel, "public">;
* @param func - The function. It receives an {@link ActionCtx} as its first argument. * @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible. * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/ */
export declare const internalAction: ActionBuilder<DataModel, "internal">; export declare const internalAction: ActionBuilder<DataModel, "internal">
/** /**
* Define an HTTP action. * Define an HTTP action.
@@ -92,7 +92,7 @@ export declare const internalAction: ActionBuilder<DataModel, "internal">;
* @param func - The function. It receives an {@link ActionCtx} as its first argument. * @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
*/ */
export declare const httpAction: HttpActionBuilder; export declare const httpAction: HttpActionBuilder
/** /**
* A set of services for use within Convex query functions. * A set of services for use within Convex query functions.
@@ -103,7 +103,7 @@ export declare const httpAction: HttpActionBuilder;
* This differs from the {@link MutationCtx} because all of the services are * This differs from the {@link MutationCtx} because all of the services are
* read-only. * read-only.
*/ */
export type QueryCtx = GenericQueryCtx<DataModel>; export type QueryCtx = GenericQueryCtx<DataModel>
/** /**
* A set of services for use within Convex mutation functions. * A set of services for use within Convex mutation functions.
@@ -111,7 +111,7 @@ export type QueryCtx = GenericQueryCtx<DataModel>;
* The mutation context is passed as the first argument to any Convex mutation * The mutation context is passed as the first argument to any Convex mutation
* function run on the server. * function run on the server.
*/ */
export type MutationCtx = GenericMutationCtx<DataModel>; export type MutationCtx = GenericMutationCtx<DataModel>
/** /**
* A set of services for use within Convex action functions. * A set of services for use within Convex action functions.
@@ -119,7 +119,7 @@ export type MutationCtx = GenericMutationCtx<DataModel>;
* The action context is passed as the first argument to any Convex action * The action context is passed as the first argument to any Convex action
* function run on the server. * function run on the server.
*/ */
export type ActionCtx = GenericActionCtx<DataModel>; export type ActionCtx = GenericActionCtx<DataModel>
/** /**
* An interface to read from the database within Convex query functions. * An interface to read from the database within Convex query functions.
@@ -128,7 +128,7 @@ export type ActionCtx = GenericActionCtx<DataModel>;
* document by its {@link Id}, or {@link DatabaseReader.query}, which starts * document by its {@link Id}, or {@link DatabaseReader.query}, which starts
* building a query. * building a query.
*/ */
export type DatabaseReader = GenericDatabaseReader<DataModel>; export type DatabaseReader = GenericDatabaseReader<DataModel>
/** /**
* An interface to read from and write to the database within Convex mutation * An interface to read from and write to the database within Convex mutation
@@ -139,4 +139,4 @@ export type DatabaseReader = GenericDatabaseReader<DataModel>;
* your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
* for the guarantees Convex provides your functions. * for the guarantees Convex provides your functions.
*/ */
export type DatabaseWriter = GenericDatabaseWriter<DataModel>; export type DatabaseWriter = GenericDatabaseWriter<DataModel>

View File

@@ -9,14 +9,14 @@
*/ */
import { import {
actionGeneric, actionGeneric,
httpActionGeneric, httpActionGeneric,
queryGeneric, internalActionGeneric,
mutationGeneric, internalMutationGeneric,
internalActionGeneric, internalQueryGeneric,
internalMutationGeneric, mutationGeneric,
internalQueryGeneric, queryGeneric,
} from "convex/server"; } from "convex/server"
/** /**
* Define a query in this Convex app's public API. * Define a query in this Convex app's public API.
@@ -26,7 +26,7 @@ import {
* @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible. * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/ */
export const query = queryGeneric; export const query = queryGeneric
/** /**
* Define a query that is only accessible from other Convex functions (but not from the client). * Define a query that is only accessible from other Convex functions (but not from the client).
@@ -36,7 +36,7 @@ export const query = queryGeneric;
* @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible. * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/ */
export const internalQuery = internalQueryGeneric; export const internalQuery = internalQueryGeneric
/** /**
* Define a mutation in this Convex app's public API. * Define a mutation in this Convex app's public API.
@@ -46,7 +46,7 @@ export const internalQuery = internalQueryGeneric;
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/ */
export const mutation = mutationGeneric; export const mutation = mutationGeneric
/** /**
* Define a mutation that is only accessible from other Convex functions (but not from the client). * Define a mutation that is only accessible from other Convex functions (but not from the client).
@@ -56,7 +56,7 @@ export const mutation = mutationGeneric;
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/ */
export const internalMutation = internalMutationGeneric; export const internalMutation = internalMutationGeneric
/** /**
* Define an action in this Convex app's public API. * Define an action in this Convex app's public API.
@@ -69,7 +69,7 @@ export const internalMutation = internalMutationGeneric;
* @param func - The action. It receives an {@link ActionCtx} as its first argument. * @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible. * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/ */
export const action = actionGeneric; export const action = actionGeneric
/** /**
* Define an action that is only accessible from other Convex functions (but not from the client). * Define an action that is only accessible from other Convex functions (but not from the client).
@@ -77,7 +77,7 @@ export const action = actionGeneric;
* @param func - The function. It receives an {@link ActionCtx} as its first argument. * @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible. * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/ */
export const internalAction = internalActionGeneric; export const internalAction = internalActionGeneric
/** /**
* Define a Convex HTTP action. * Define a Convex HTTP action.
@@ -86,4 +86,4 @@ export const internalAction = internalActionGeneric;
* as its second. * as its second.
* @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`.
*/ */
export const httpAction = httpActionGeneric; export const httpAction = httpActionGeneric

View File

@@ -1,5 +1,5 @@
import type { Id } from "@fileone/convex/dataModel" import type { Id } from "@fileone/convex/dataModel"
import { v } from "convex/values" import { ConvexError, v } from "convex/values"
import { import {
authenticatedMutation, authenticatedMutation,
authenticatedQuery, authenticatedQuery,
@@ -8,19 +8,19 @@ import {
import * as Directories from "./model/directories" import * as Directories from "./model/directories"
import * as Files from "./model/files" import * as Files from "./model/files"
import * as User from "./model/user" import * as User from "./model/user"
import * as Err from "./shared/error" import { ErrorCode, error } from "./shared/error"
export const generateUploadUrl = authenticatedMutation({ export const generateUploadUrl = authenticatedMutation({
handler: async (ctx) => { handler: async (ctx) => {
const usageStatistics = await User.queryCachedUsageStatistics(ctx) const userInfo = await User.queryInfo(ctx)
if (!usageStatistics) { if (!userInfo) {
throw Err.create(Err.Code.Internal, "Internal server error") throw new ConvexError({ message: "Internal server error" })
} }
if ( if (userInfo.storageUsageBytes >= userInfo.storageQuotaBytes) {
usageStatistics.storageUsageBytes >= throw new ConvexError({
usageStatistics.storageQuotaBytes code: ErrorCode.StorageQuotaExceeded,
) { message: "Storage quota exceeded",
throw Err.create(Err.Code.Forbidden, "Storage quota exceeded") })
} }
return await ctx.storage.generateUploadUrl() return await ctx.storage.generateUploadUrl()
}, },
@@ -53,7 +53,10 @@ export const fetchDirectory = authenticatedQuery({
handler: async (ctx, { directoryId }) => { handler: async (ctx, { directoryId }) => {
const directory = await authorizedGet(ctx, directoryId) const directory = await authorizedGet(ctx, directoryId)
if (!directory) { if (!directory) {
throw new Error("Directory not found") error({
code: ErrorCode.NotFound,
message: "Directory not found",
})
} }
return await Directories.fetch(ctx, { directoryId }) return await Directories.fetch(ctx, { directoryId })
}, },
@@ -67,7 +70,10 @@ export const createDirectory = authenticatedMutation({
handler: async (ctx, { name, directoryId }): Promise<Id<"directories">> => { handler: async (ctx, { name, directoryId }): Promise<Id<"directories">> => {
const parentDirectory = await authorizedGet(ctx, directoryId) const parentDirectory = await authorizedGet(ctx, directoryId)
if (!parentDirectory) { if (!parentDirectory) {
throw new Error("Parent directory not found") error({
code: ErrorCode.NotFound,
message: "Parent directory not found",
})
} }
return await Directories.create(ctx, { return await Directories.create(ctx, {
@@ -76,56 +82,3 @@ export const createDirectory = authenticatedMutation({
}) })
}, },
}) })
export const saveFile = authenticatedMutation({
args: {
name: v.string(),
directoryId: v.id("directories"),
storageId: v.id("_storage"),
},
handler: async (ctx, { name, storageId, directoryId }) => {
const directory = await authorizedGet(ctx, directoryId)
if (!directory) {
throw new Error("Directory not found")
}
const now = Date.now()
const fileMetadata = await Promise.all([
ctx.db.system.get(storageId),
ctx.user.queryUsageStatistics(),
])
if (!fileMetadata) {
throw Err.create(Err.Code.Internal, "Internal server error")
}
await Promise.all([
ctx.db.insert("files", {
name,
size: fileMetadata.size,
storageId,
directoryId,
userId: ctx.user._id,
mimeType,
createdAt: now,
updatedAt: now,
}),
])
},
})
export const renameFile = authenticatedMutation({
args: {
directoryId: v.optional(v.id("directories")),
itemId: v.id("files"),
newName: v.string(),
},
handler: async (ctx, { directoryId, itemId, newName }) => {
const file = await authorizedGet(ctx, itemId)
if (!file) {
throw new Error("File not found")
}
await Files.renameFile(ctx, { directoryId, itemId, newName })
},
})

View File

@@ -1,4 +1,4 @@
import { v } from "convex/values" import { ConvexError, v } from "convex/values"
import { import {
apiKeyAuthenticatedQuery, apiKeyAuthenticatedQuery,
authenticatedMutation, authenticatedMutation,
@@ -16,7 +16,7 @@ import {
VDirectoryHandle, VDirectoryHandle,
VFileSystemHandle, VFileSystemHandle,
} from "./model/filesystem" } from "./model/filesystem"
import * as Err from "./shared/error" import { createErrorData, ErrorCode, error } from "./shared/error"
import type { import type {
DirectoryHandle, DirectoryHandle,
FileHandle, FileHandle,
@@ -36,10 +36,10 @@ export const moveItems = authenticatedMutation({
targetDirectoryHandle.id, targetDirectoryHandle.id,
) )
if (!targetDirectory) { if (!targetDirectory) {
throw Err.create( error({
Err.Code.DirectoryNotFound, code: ErrorCode.NotFound,
`Directory ${targetDirectoryHandle.id} not found`, message: `Directory ${targetDirectoryHandle.id} not found`,
) })
} }
const directoryHandles: DirectoryHandle[] = [] const directoryHandles: DirectoryHandle[] = []
@@ -81,10 +81,10 @@ export const moveToTrash = authenticatedMutation({
for (const handle of handles) { for (const handle of handles) {
const item = await authorizedGet(ctx, handle.id) const item = await authorizedGet(ctx, handle.id)
if (!item) { if (!item) {
throw Err.create( error({
Err.Code.NotFound, code: ErrorCode.NotFound,
`Item ${handle.id} not found`, message: `Item ${handle.id} not found`,
) })
} }
} }
@@ -105,7 +105,7 @@ export const moveToTrash = authenticatedMutation({
}) })
const results = await Promise.allSettled(promises) const results = await Promise.allSettled(promises)
const errors: Err.ApplicationErrorData[] = [] const errors = []
const okHandles: FileSystemHandle[] = [] const okHandles: FileSystemHandle[] = []
for (const result of results) { for (const result of results) {
switch (result.status) { switch (result.status) {
@@ -113,7 +113,7 @@ export const moveToTrash = authenticatedMutation({
okHandles.push(result.value) okHandles.push(result.value)
break break
case "rejected": case "rejected":
errors.push(Err.createJson(Err.Code.Internal)) errors.push(createErrorData(ErrorCode.Internal))
break break
} }
} }

View File

@@ -14,7 +14,7 @@ import {
} from "convex-helpers/server/customFunctions" } from "convex-helpers/server/customFunctions"
import * as ApiKey from "./model/apikey" import * as ApiKey from "./model/apikey"
import { type AuthUser, userIdentityOrThrow, userOrThrow } from "./model/user" import { type AuthUser, userIdentityOrThrow, userOrThrow } from "./model/user"
import * as Err from "./shared/error" import { ErrorCode, error } from "./shared/error"
export type AuthenticatedQueryCtx = QueryCtx & { export type AuthenticatedQueryCtx = QueryCtx & {
user: AuthUser user: AuthUser
@@ -65,7 +65,10 @@ export const apiKeyAuthenticatedQuery = customQuery(query, {
}, },
input: async (ctx, args) => { input: async (ctx, args) => {
if (!(await ApiKey.verifyApiKey(ctx, args.apiKey))) { if (!(await ApiKey.verifyApiKey(ctx, args.apiKey))) {
throw Err.create(Err.Code.Unauthenticated, "Invalid API key") error({
code: ErrorCode.Unauthenticated,
message: "Invalid API key",
})
} }
return { ctx: ctx as ApiKeyAuthenticatedQueryCtx, args } return { ctx: ctx as ApiKeyAuthenticatedQueryCtx, args }
}, },
@@ -80,7 +83,10 @@ export const apiKeyAuthenticatedMutation = customMutation(mutation, {
}, },
input: async (ctx, args) => { input: async (ctx, args) => {
if (!(await ApiKey.verifyApiKey(ctx, args.apiKey))) { if (!(await ApiKey.verifyApiKey(ctx, args.apiKey))) {
throw Err.create(Err.Code.Unauthenticated, "Invalid API key") error({
code: ErrorCode.Unauthenticated,
message: "Invalid API key",
})
} }
return { ctx, args } return { ctx, args }
}, },

View File

@@ -4,7 +4,8 @@ import type {
AuthenticatedQueryCtx, AuthenticatedQueryCtx,
} from "../functions" } from "../functions"
import { authorizedGet } from "../functions" import { authorizedGet } from "../functions"
import * as Err from "../shared/error" import type { ApplicationErrorData } from "../shared/error"
import { createErrorData, ErrorCode, error } from "../shared/error"
import { import {
type DirectoryHandle, type DirectoryHandle,
type DirectoryPath, type DirectoryPath,
@@ -30,10 +31,10 @@ export async function fetchHandle(
): Promise<Doc<"directories">> { ): Promise<Doc<"directories">> {
const directory = await authorizedGet(ctx, handle.id) const directory = await authorizedGet(ctx, handle.id)
if (!directory) { if (!directory) {
throw Err.create( error({
Err.Code.DirectoryNotFound, code: ErrorCode.NotFound,
`Directory ${handle.id} not found`, message: `Directory ${handle.id} not found`,
) })
} }
return directory return directory
} }
@@ -44,10 +45,10 @@ export async function fetch(
): Promise<DirectoryInfo> { ): Promise<DirectoryInfo> {
const directory = await authorizedGet(ctx, directoryId) const directory = await authorizedGet(ctx, directoryId)
if (!directory) { if (!directory) {
throw Err.create( error({
Err.Code.DirectoryNotFound, code: ErrorCode.NotFound,
`Directory ${directoryId} not found`, message: `Directory ${directoryId} not found`,
) })
} }
const path: DirectoryPath = [ const path: DirectoryPath = [
@@ -66,7 +67,10 @@ export async function fetch(
}) })
parentDirId = parentDir.parentId parentDirId = parentDir.parentId
} else { } else {
throw Err.create(Err.Code.DirectoryNotFound, "Parent directory not found") error({
code: ErrorCode.NotFound,
message: "Parent directory not found",
})
} }
} }
@@ -135,10 +139,10 @@ export async function create(
): Promise<Id<"directories">> { ): Promise<Id<"directories">> {
const parentDir = await authorizedGet(ctx, parentId) const parentDir = await authorizedGet(ctx, parentId)
if (!parentDir) { if (!parentDir) {
throw Err.create( error({
Err.Code.DirectoryNotFound, code: ErrorCode.NotFound,
`Parent directory ${parentId} not found`, message: `Parent directory ${parentId} not found`,
) })
} }
const existing = await ctx.db const existing = await ctx.db
@@ -153,10 +157,10 @@ export async function create(
.first() .first()
if (existing) { if (existing) {
throw Err.create( error({
Err.Code.DirectoryExists, code: ErrorCode.DirectoryExists,
`Directory with name ${name} already exists in ${parentId ? `directory ${parentId}` : "root"}`, message: `Directory with name ${name} already exists in ${parentId ? `directory ${parentId}` : "root"}`,
) })
} }
const now = Date.now() const now = Date.now()
@@ -183,10 +187,10 @@ export async function move(
sourceDirectories.map((directory) => sourceDirectories.map((directory) =>
authorizedGet(ctx, directory.id).then((d) => { authorizedGet(ctx, directory.id).then((d) => {
if (!d) { if (!d) {
throw Err.create( error({
Err.Code.DirectoryNotFound, code: ErrorCode.NotFound,
`Directory ${directory.id} not found`, message: `Directory ${directory.id} not found`,
) })
} }
return ctx.db return ctx.db
.query("directories") .query("directories")
@@ -202,14 +206,14 @@ export async function move(
), ),
) )
const errors: Err.ApplicationErrorData[] = [] const errors: ApplicationErrorData[] = []
const okDirectories: DirectoryHandle[] = [] const okDirectories: DirectoryHandle[] = []
conflictCheckResults.forEach((result, i) => { conflictCheckResults.forEach((result, i) => {
if (result.status === "fulfilled") { if (result.status === "fulfilled") {
if (result.value) { if (result.value) {
errors.push( errors.push(
Err.createJson( createErrorData(
Err.Code.Conflict, ErrorCode.Conflict,
`Directory ${targetDirectory.id} already contains a directory with name ${result.value.name}`, `Directory ${targetDirectory.id} already contains a directory with name ${result.value.name}`,
), ),
) )
@@ -217,7 +221,7 @@ export async function move(
okDirectories.push(sourceDirectories[i]!) okDirectories.push(sourceDirectories[i]!)
} }
} else if (result.status === "rejected") { } else if (result.status === "rejected") {
errors.push(Err.createJson(Err.Code.Internal)) errors.push(createErrorData(ErrorCode.Internal))
} }
}) })
@@ -243,7 +247,7 @@ export async function move(
for (const updateResult of results) { for (const updateResult of results) {
if (updateResult.status === "rejected") { if (updateResult.status === "rejected") {
errors.push(Err.createJson(Err.Code.Internal)) errors.push(createErrorData(ErrorCode.Internal))
} }
} }
@@ -335,11 +339,11 @@ export async function deletePermanently(
const deleteResults = await Promise.allSettled(deleteDirectoryPromises) const deleteResults = await Promise.allSettled(deleteDirectoryPromises)
const errors: Err.ApplicationErrorData[] = [] const errors: ApplicationErrorData[] = []
let successfulDeletions = 0 let successfulDeletions = 0
for (const result of deleteResults) { for (const result of deleteResults) {
if (result.status === "rejected") { if (result.status === "rejected") {
errors.push(Err.createJson(Err.Code.Internal)) errors.push(createErrorData(ErrorCode.Internal))
} else { } else {
successfulDeletions += 1 successfulDeletions += 1
} }
@@ -378,11 +382,11 @@ export async function restore(
const restoreResults = await Promise.allSettled(restoreDirectoryPromises) const restoreResults = await Promise.allSettled(restoreDirectoryPromises)
const errors: Err.ApplicationErrorData[] = [] const errors: ApplicationErrorData[] = []
let successfulRestorations = 0 let successfulRestorations = 0
for (const result of restoreResults) { for (const result of restoreResults) {
if (result.status === "rejected") { if (result.status === "rejected") {
errors.push(Err.createJson(Err.Code.Internal)) errors.push(createErrorData(ErrorCode.Internal))
} else { } else {
successfulRestorations += 1 successfulRestorations += 1
} }

View File

@@ -1,6 +1,7 @@
import type { Doc, Id } from "@fileone/convex/dataModel" import type { Doc, Id } from "@fileone/convex/dataModel"
import { type AuthenticatedMutationCtx, authorizedGet } from "../functions" import { type AuthenticatedMutationCtx, authorizedGet } from "../functions"
import * as Err from "../shared/error" import type { ApplicationErrorData } from "../shared/error"
import { createErrorData, ErrorCode, error } from "../shared/error"
import type { DirectoryHandle, FileHandle } from "../shared/filesystem" import type { DirectoryHandle, FileHandle } from "../shared/filesystem"
export async function renameFile( export async function renameFile(
@@ -27,10 +28,10 @@ export async function renameFile(
.first() .first()
if (existing) { if (existing) {
throw Err.create( error({
Err.Code.FileExists, code: ErrorCode.FileExists,
`File with name ${newName} already exists in ${directoryId ? `directory ${directoryId}` : "root"}`, message: `File with name ${newName} already exists in ${directoryId ? `directory ${directoryId}` : "root"}`,
) })
} }
await ctx.db.patch(itemId, { name: newName, updatedAt: Date.now() }) await ctx.db.patch(itemId, { name: newName, updatedAt: Date.now() })
@@ -50,10 +51,10 @@ export async function move(
items.map((fileHandle) => items.map((fileHandle) =>
authorizedGet(ctx, fileHandle.id).then((f) => { authorizedGet(ctx, fileHandle.id).then((f) => {
if (!f) { if (!f) {
throw Err.create( error({
Err.Code.FileNotFound, code: ErrorCode.NotFound,
`File ${fileHandle.id} not found`, message: `File ${fileHandle.id} not found`,
) })
} }
return ctx.db return ctx.db
.query("files") .query("files")
@@ -69,14 +70,14 @@ export async function move(
), ),
) )
const errors: Err.ApplicationErrorData[] = [] const errors: ApplicationErrorData[] = []
const okFiles: FileHandle[] = [] const okFiles: FileHandle[] = []
conflictCheckResults.forEach((result, i) => { conflictCheckResults.forEach((result, i) => {
if (result.status === "fulfilled") { if (result.status === "fulfilled") {
if (result.value) { if (result.value) {
errors.push( errors.push(
Err.createJson( createErrorData(
Err.Code.Conflict, ErrorCode.Conflict,
`Directory ${targetDirectoryHandle.id} already contains a file with name ${result.value.name}`, `Directory ${targetDirectoryHandle.id} already contains a file with name ${result.value.name}`,
), ),
) )
@@ -84,7 +85,7 @@ export async function move(
okFiles.push(items[i]) okFiles.push(items[i])
} }
} else if (result.status === "rejected") { } else if (result.status === "rejected") {
errors.push(Err.createJson(Err.Code.Internal)) errors.push(createErrorData(ErrorCode.Internal))
} }
}) })
@@ -99,7 +100,7 @@ export async function move(
for (const updateResult of results) { for (const updateResult of results) {
if (updateResult.status === "rejected") { if (updateResult.status === "rejected") {
errors.push(Err.createJson(Err.Code.Internal)) errors.push(createErrorData(ErrorCode.Internal))
} }
} }
@@ -136,11 +137,11 @@ export async function deletePermanently(
const deleteResults = await Promise.allSettled(deleteFilePromises) const deleteResults = await Promise.allSettled(deleteFilePromises)
const errors: Err.ApplicationErrorData[] = [] const errors: ApplicationErrorData[] = []
let successfulDeletions = 0 let successfulDeletions = 0
for (const result of deleteResults) { for (const result of deleteResults) {
if (result.status === "rejected") { if (result.status === "rejected") {
errors.push(Err.createJson(Err.Code.Internal)) errors.push(createErrorData(ErrorCode.Internal))
} else { } else {
successfulDeletions += 1 successfulDeletions += 1
} }
@@ -179,11 +180,11 @@ export async function restore(
const restoreResults = await Promise.allSettled(restoreFilePromises) const restoreResults = await Promise.allSettled(restoreFilePromises)
const errors: Err.ApplicationErrorData[] = [] const errors: ApplicationErrorData[] = []
let successfulRestorations = 0 let successfulRestorations = 0
for (const result of restoreResults) { for (const result of restoreResults) {
if (result.status === "rejected") { if (result.status === "rejected") {
errors.push(Err.createJson(Err.Code.Internal)) errors.push(createErrorData(ErrorCode.Internal))
} else { } else {
successfulRestorations += 1 successfulRestorations += 1
} }

View File

@@ -1,3 +1,4 @@
import { ConvexError } from "convex/values"
import type { Doc, Id } from "../_generated/dataModel" import type { Doc, Id } from "../_generated/dataModel"
import type { MutationCtx } from "../_generated/server" import type { MutationCtx } from "../_generated/server"
import type { import type {
@@ -5,7 +6,7 @@ import type {
AuthenticatedMutationCtx, AuthenticatedMutationCtx,
AuthenticatedQueryCtx, AuthenticatedQueryCtx,
} from "../functions" } from "../functions"
import * as Err from "../shared/error" import { ErrorCode, error } from "../shared/error"
export async function create( export async function create(
ctx: MutationCtx, ctx: MutationCtx,
@@ -22,7 +23,7 @@ export async function create(
}) })
const doc = await ctx.db.get(id) const doc = await ctx.db.get(id)
if (!doc) { if (!doc) {
throw Err.create(Err.Code.Internal, "Failed to create file share") throw new ConvexError({ message: "Failed to create file share" })
} }
return doc return doc
} }
@@ -46,11 +47,17 @@ export async function find(
.withIndex("byShareToken", (q) => q.eq("shareToken", shareToken)) .withIndex("byShareToken", (q) => q.eq("shareToken", shareToken))
.first() .first()
if (!doc) { if (!doc) {
throw Err.create(Err.Code.NotFound, "File share not found") error({
code: ErrorCode.NotFound,
message: "File share not found",
})
} }
if (hasExpired(doc)) { if (hasExpired(doc)) {
throw Err.create(Err.Code.NotFound, "File share not found") error({
code: ErrorCode.NotFound,
message: "File share not found",
})
} }
return doc return doc

View File

@@ -1,11 +1,11 @@
import { v } from "convex/values" import { ConvexError, v } from "convex/values"
import type { Doc, Id } from "../_generated/dataModel" import type { Doc, Id } from "../_generated/dataModel"
import { import {
type AuthenticatedMutationCtx, type AuthenticatedMutationCtx,
type AuthenticatedQueryCtx, type AuthenticatedQueryCtx,
authorizedGet, authorizedGet,
} from "../functions" } from "../functions"
import * as Err from "../shared/error" import { ErrorCode, error } from "../shared/error"
import type { import type {
DirectoryHandle, DirectoryHandle,
FileHandle, FileHandle,
@@ -174,7 +174,10 @@ export async function deleteItemsPermanently(
export async function emptyTrash(ctx: AuthenticatedMutationCtx) { export async function emptyTrash(ctx: AuthenticatedMutationCtx) {
const rootDir = await queryRootDirectory(ctx) const rootDir = await queryRootDirectory(ctx)
if (!rootDir) { if (!rootDir) {
throw Err.create(Err.Code.NotFound, "user root directory not found") error({
code: ErrorCode.NotFound,
message: "user root directory not found",
})
} }
const dirs = await ctx.db const dirs = await ctx.db
@@ -221,12 +224,18 @@ export async function fetchFileUrl(
): Promise<string> { ): Promise<string> {
const file = await authorizedGet(ctx, fileId) const file = await authorizedGet(ctx, fileId)
if (!file) { if (!file) {
throw Err.create(Err.Code.NotFound, "file not found") error({
code: ErrorCode.NotFound,
message: "file not found",
})
} }
const url = await ctx.storage.getUrl(file.storageId) const url = await ctx.storage.getUrl(file.storageId)
if (!url) { if (!url) {
throw Err.create(Err.Code.NotFound, "file not found") error({
code: ErrorCode.NotFound,
message: "file not found",
})
} }
return url return url
@@ -238,7 +247,10 @@ export async function openFile(
) { ) {
const file = await authorizedGet(ctx, fileId) const file = await authorizedGet(ctx, fileId)
if (!file) { if (!file) {
throw Err.create(Err.Code.NotFound, "file not found") error({
code: ErrorCode.NotFound,
message: "file not found",
})
} }
const fileShare = await FilePreview.find(ctx, { const fileShare = await FilePreview.find(ctx, {
@@ -281,7 +293,10 @@ export async function saveFile(
) { ) {
const directory = await authorizedGet(ctx, directoryId) const directory = await authorizedGet(ctx, directoryId)
if (!directory) { if (!directory) {
throw Err.create(Err.Code.NotFound, "directory not found") error({
code: ErrorCode.NotFound,
message: "directory not found",
})
} }
const [fileMetadata, userInfo] = await Promise.all([ const [fileMetadata, userInfo] = await Promise.all([
@@ -289,7 +304,7 @@ export async function saveFile(
User.queryInfo(ctx), User.queryInfo(ctx),
]) ])
if (!fileMetadata || !userInfo) { if (!fileMetadata || !userInfo) {
throw Err.create(Err.Code.Internal, "Internal server error") throw new ConvexError({ message: "Internal server error" })
} }
if ( if (
@@ -297,7 +312,10 @@ export async function saveFile(
userInfo.storageQuotaBytes userInfo.storageQuotaBytes
) { ) {
await ctx.storage.delete(storageId) await ctx.storage.delete(storageId)
throw Err.create(Err.Code.StorageQuotaExceeded, "Storage quota exceeded") error({
code: ErrorCode.StorageQuotaExceeded,
message: "Storage quota exceeded",
})
} }
const now = Date.now() const now = Date.now()

View File

@@ -2,7 +2,7 @@ import type { MutationCtx, QueryCtx } from "@fileone/convex/server"
import type { Doc } from "../_generated/dataModel" import type { Doc } from "../_generated/dataModel"
import { authComponent } from "../auth" import { authComponent } from "../auth"
import { type AuthenticatedQueryCtx, authorizedGet } from "../functions" import { type AuthenticatedQueryCtx, authorizedGet } from "../functions"
import * as Err from "../shared/error" import { ErrorCode, error } from "../shared/error"
export type AuthUser = Awaited<ReturnType<typeof authComponent.getAuthUser>> export type AuthUser = Awaited<ReturnType<typeof authComponent.getAuthUser>>
@@ -12,7 +12,10 @@ export type AuthUser = Awaited<ReturnType<typeof authComponent.getAuthUser>>
export async function userIdentityOrThrow(ctx: QueryCtx | MutationCtx) { export async function userIdentityOrThrow(ctx: QueryCtx | MutationCtx) {
const identity = await ctx.auth.getUserIdentity() const identity = await ctx.auth.getUserIdentity()
if (!identity) { if (!identity) {
throw Err.create(Err.Code.Unauthenticated, "Not authenticated") error({
code: ErrorCode.Unauthenticated,
message: "Not authenticated",
})
} }
return identity return identity
} }

View File

@@ -1,36 +1,44 @@
import { ConvexError } from "convex/values" import { ConvexError } from "convex/values"
export enum Code { export enum ErrorCode {
Conflict = "Conflict", Conflict = "Conflict",
DirectoryExists = "DirectoryExists", DirectoryExists = "DirectoryExists",
DirectoryNotFound = "DirectoryNotFound",
FileExists = "FileExists", FileExists = "FileExists",
FileNotFound = "FileNotFound",
Internal = "Internal", Internal = "Internal",
Unauthenticated = "Unauthenticated", Unauthenticated = "Unauthenticated",
NotFound = "NotFound", NotFound = "NotFound",
StorageQuotaExceeded = "StorageQuotaExceeded", StorageQuotaExceeded = "StorageQuotaExceeded",
} }
export type ApplicationErrorData = { code: Code; message?: string } export type ApplicationErrorData = { code: ErrorCode; message?: string }
export type ApplicationError = ConvexError<ApplicationErrorData>
export function isApplicationError(error: unknown): error is ApplicationError { export function isApplicationError(
return error instanceof ConvexError && "code" in error.data error: unknown,
): error is ApplicationErrorData {
return (
error !== null &&
typeof error === "object" &&
"code" in error &&
"message" in error &&
Object.values(ErrorCode).includes(
(error as { code: string }).code as ErrorCode,
)
)
} }
export function create(code: Code, message?: string): ApplicationError { export function createErrorData(
return new ConvexError({ code: ErrorCode,
code, message?: string,
message: ): ApplicationErrorData {
code === Code.Internal ? "Internal application error" : message,
})
}
export function createJson(code: Code, message?: string): ApplicationErrorData {
return { return {
code, code,
message: message:
code === Code.Internal ? "Internal application error" : message, code === ErrorCode.Internal
? "Internal application error"
: message,
} }
} }
export function error(data: ApplicationErrorData): never {
throw new ConvexError(data)
}

View File

@@ -5,7 +5,7 @@
*/ */
import type { Doc, Id } from "@fileone/convex/dataModel" import type { Doc, Id } from "@fileone/convex/dataModel"
import type * as Err from "./error" import type { ApplicationErrorData } from "./error"
export enum FileType { export enum FileType {
File = "File", File = "File",
@@ -67,7 +67,7 @@ export type DeleteResult = {
files: number files: number
directories: number directories: number
} }
errors: Err.ApplicationErrorData[] errors: ApplicationErrorData[]
} }
export function newFileSystemHandle(item: FileSystemItem): FileSystemHandle { export function newFileSystemHandle(item: FileSystemItem): FileSystemHandle {

View File

@@ -1,25 +1,25 @@
{ {
/* This TypeScript project config describes the environment that /* This TypeScript project config describes the environment that
* Convex functions run in and is used to typecheck them. * Convex functions run in and is used to typecheck them.
* You can modify it, but some settings are required to use Convex. * You can modify it, but some settings are required to use Convex.
*/ */
"compilerOptions": { "compilerOptions": {
/* These settings are not required by Convex and can be modified. */ /* These settings are not required by Convex and can be modified. */
"allowJs": true, "allowJs": true,
"strict": true, "strict": true,
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"jsx": "react-jsx", "jsx": "react-jsx",
"skipLibCheck": true, "skipLibCheck": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
/* These compiler options are required by Convex */ /* These compiler options are required by Convex */
"target": "ESNext", "target": "ESNext",
"lib": ["ES2021", "dom"], "lib": ["ES2021", "dom"],
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"module": "ESNext", "module": "ESNext",
"isolatedModules": true, "isolatedModules": true,
"noEmit": true "noEmit": true
}, },
"include": ["./**/*"], "include": ["./**/*"],
"exclude": ["./_generated"] "exclude": ["./_generated"]
} }