From b5a54c616037b4d287f0cbfd617a01373ba6c5b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adel=20Rodr=C3=ADguez?= Date: Fri, 31 Jul 2026 20:57:53 -0400 Subject: [PATCH] feat: implement Files SDK asset lifecycle with owner/uploader schema --- .plans/files-gateway-asset-index.md | 116 ++++++------- CONTEXT.md | 11 ++ apps/api/README.md | 2 +- apps/api/src/routes/files.ts | 40 +++++ apps/api/src/routes/index.ts | 4 + apps/api/src/routes/v1/files.ts | 30 ---- apps/api/src/routes/v1/index.ts | 2 - apps/api/src/shared/files.ts | 80 +++++++++ apps/api/src/shared/types.ts | 2 + docs/architecture/file-service.md | 2 +- docs/generators.md | 6 +- ...cy_rhodey.sql => 0000_puzzling_vermin.sql} | 26 +-- .../db/migrations/meta/0000_snapshot.json | 161 ++++-------------- packages/db/migrations/meta/_journal.json | 4 +- packages/db/src/helpers.ts | 2 + packages/db/src/schema.ts | 67 +++----- packages/env/src/presets.ts | 2 +- turbo/generators/commands/files-client.ts | 14 +- 18 files changed, 280 insertions(+), 291 deletions(-) create mode 100644 apps/api/src/routes/files.ts delete mode 100644 apps/api/src/routes/v1/files.ts rename packages/db/migrations/{0000_fancy_rhodey.sql => 0000_puzzling_vermin.sql} (87%) diff --git a/.plans/files-gateway-asset-index.md b/.plans/files-gateway-asset-index.md index 0f4b628d..8cd0a3f6 100644 --- a/.plans/files-gateway-asset-index.md +++ b/.plans/files-gateway-asset-index.md @@ -2,7 +2,7 @@ ## Status -Proposed. +Implemented; awaiting review. ## Objective @@ -13,7 +13,7 @@ Exercise one complete authenticated file lifecycle through the API application: 3. a Files SDK `onAction` hook records the object's ownership and metadata in the `storage.assets` table; 4. another user cannot access the object; and -5. deleting the object marks the asset record as deleted. +5. deleting the object removes the asset record. Keep the Files SDK gateway at the versionless `/files` transport seam. Reserve `/v1/assets` for a future application-owned HTTP interface over asset IDs, ownership, @@ -33,14 +33,15 @@ organizations, metadata, and references. - The route does not perform a second Better Auth session lookup inside `authorize`. - Successful upload completion creates or updates one `storage.assets` row owned by the authenticated user. -- Successful deletion marks the matching asset row as `deleted`. +- Successful deletion removes the matching asset row owned by the authenticated user. - `onAction` dispatches successful events to local `handleUpload` and `handleDelete` functions in the singleton module; there is no persistence service or custom Files SDK plugin. -- Repeated upload-completion events are idempotent through the existing unique index - on `(bucket, key)`. +- Repeated upload-completion events are idempotent through the unique index on `key`. - Database failures from the fire-and-forget hook are caught and logged without producing unhandled promise rejections. +- The template contains exactly one baseline database migration generated from the + current schema. - Formatting, static checks, dependency analysis, and relevant build verification pass. @@ -91,26 +92,27 @@ Use a switch to keep the hook concise. Single upload and head events call `handleDelete`. These handlers remain private functions in `shared/files.ts` and execute the Drizzle operations directly. -### Keep the current asset schema +### Use a Files-SDK-shaped asset record -The existing `storage.assets` table already contains the fields required by this -slice: +The `storage.assets` table stores the stable asset ID, Files SDK metadata, and two +distinct user roles: -- `(bucket, key)` provides storage identity and idempotency; -- `uploaderId` records the authenticated owner for the current model; -- `provider`, `mimeType`, `size`, `name`, and `metadata` describe the object; and -- `status` represents `available` and `deleted` lifecycle states. +- `key`, `name`, `size`, `type`, `etag`, `lastModified`, and `metadata` mirror the + stored file returned by Files SDK; +- `ownerId` records the user to whom the asset belongs; and +- `uploaderId` records the user who placed the asset in managed storage. -Do not rename `uploaderId`, remove organization support, or otherwise redesign the -schema in this change. Those decisions can be made when the application-owned -`/v1/assets` interface is designed. +The current upload flow assigns the authenticated user to both roles. Keeping them +separate preserves creation provenance if ownership changes later. The configured +Files singleton owns one bucket, so the storage key is the unique object identity and +bucket/provider columns are unnecessary. ### Observe upload and head completion Handle successful `upload` events and successful `head` events. Direct/proxied uploads can produce an upload result, while completion of a direct or presigned upload can be -confirmed through a follow-up `head`. The `(bucket, key)` conflict target makes both -paths idempotent. +confirmed through a follow-up `head`. The unique `key` conflict target makes both paths +idempotent. Do not support copy or move until their destination-ownership behavior is explicitly defined. Do not build the index from list results. @@ -196,9 +198,9 @@ export const requireSession = createMiddleware(async (c Replace `apps/api/src/shared/files.ts` with this proposed implementation: ```ts -import { assets } from "@init/db/schema" +import { assets, type UserId } from "@init/db/schema" import * as z from "@init/utils/schema" -import { and, eq, inArray } from "drizzle-orm" +import { helpers } from "@init/db/helpers" import type { DeleteManyResult, StoredFile, UploadResult } from "files-sdk" import { createFiles } from "files-sdk" import { bunS3 } from "files-sdk/bun-s3" @@ -238,6 +240,8 @@ export const files = createFiles({ handleDelete(keys) break } + default: + break } }, }, @@ -271,27 +275,27 @@ function handleUpload(key: string, file: UploadResult | StoredFile) { void ctx.var.db .insert(assets) .values({ - bucket: env.S3_BUCKET, + etag: file.etag, key, - metadata: file.etag ? { etag: file.etag } : null, - mimeType, + lastModified: file.lastModified, + metadata: "metadata" in file ? file.metadata : undefined, name, - provider: "bun-s3", + ownerId: ctx.var.session.user.id as UserId, size: file.size, - status: "available", - uploaderId: ctx.var.session.user.id, + type: mimeType, + uploaderId: ctx.var.session.user.id as UserId, }) .onConflictDoUpdate({ - target: [assets.bucket, assets.key], set: { - errorMessage: null, - metadata: file.etag ? { etag: file.etag } : null, - mimeType, + etag: file.etag, + lastModified: file.lastModified, + metadata: "metadata" in file ? file.metadata : undefined, name, size: file.size, - status: "available", + type: mimeType, updatedAt: new Date(), }, + target: assets.key, }) .catch((error: unknown) => { ctx.var.logger.error(`Failed to record asset: ${String(error)}`) @@ -304,20 +308,15 @@ function handleDelete(keys: string[]) { const ctx = context() void ctx.var.db - .update(assets) - .set({ - status: "deleted", - updatedAt: new Date(), - }) + .delete(assets) .where( - and( - eq(assets.bucket, env.S3_BUCKET), - inArray(assets.key, keys), - eq(assets.uploaderId, ctx.var.session.user.id) + helpers.and( + helpers.inArray(assets.key, keys), + helpers.eq(assets.ownerId, ctx.var.session.user.id as UserId) ) ) .catch((error: unknown) => { - ctx.var.logger.error(`Failed to mark asset as deleted: ${String(error)}`) + ctx.var.logger.error(`Failed to delete asset records: ${String(error)}`) }) } ``` @@ -328,8 +327,8 @@ an authenticated request. This intentionally makes action-producing use of this singleton request-bound: a future caller that invokes it outside Hono context would not have a session to assign -as `uploaderId` and must use a separately configured Files instance or establish an -equivalent ownership context. +as `ownerId` and `uploaderId` and must use a separately configured Files instance or +establish an equivalent ownership context. ### 4. Implement the complete versionless gateway route @@ -390,22 +389,18 @@ keys that were successfully removed change status. `onAction` to read the session from Hono context storage. Remove the old direct `auth` import, `auth.api.getSession()` call, and `FilesError` authorization branch. -### 5. Declare the direct Drizzle dependency +### 5. Expose Drizzle through the database package -Because the shared Files module constructs Drizzle expressions itself, add the -installed Drizzle version as a direct dependency of `apps/api`: +Keep Drizzle's package identity behind `@init/db` and expose its helpers as a namespace +from `packages/db/src/helpers.ts`: -```diff - "dependencies": { - "@init/workflows": "workspace:*", - "@scalar/hono-api-reference": "^0.9.48", - "@trpc/server": "11.8.1", -+ "drizzle-orm": "0.45.2", - "files-sdk": "2.2.2", - "hono": "4.12.32" - } +```ts +export * as helpers from "drizzle-orm" ``` +The API imports `helpers` from `@init/db/helpers`; it does not declare a separate direct +Drizzle dependency. + ### 6. Limit the initial operation surface The concrete `operations` list above permits upload, direct/presigned upload, metadata, @@ -413,7 +408,13 @@ listing, download, URL, and deletion. It excludes `copy` and `move` because the does not update destination ownership, and excludes versioning and trash operations because the configured Files instance does not install those plugins. -### 7. Verify the change +### 7. Regenerate the baseline migration + +Delete the existing migration SQL and metadata, then run `bun run generate` from +`packages/db`. The template deliberately keeps one starting migration and does not +preserve migration history for databases created from earlier template revisions. + +### 8. Verify the change Run the repository-managed static checks: @@ -430,7 +431,6 @@ this change. ## Explicitly out of scope - Implementing `/v1/assets`. -- Renaming `uploaderId` or redesigning the asset schema. - Making the asset table the source of truth for object existence. - Transactional guarantees between object storage and Postgres. - A reconciliation worker for missing or stale asset rows. @@ -442,8 +442,8 @@ this change. - Design the application-owned `/v1/assets` interface around asset IDs rather than raw storage keys. -- Decide whether ownership should become `userId`, a polymorphic owner, or separate - user/organization ownership records. +- Decide whether ownership should expand from a User owner to a polymorphic owner or + separate user/organization ownership records. - Add reconciliation if production requirements demand repair of missed hook writes. - Define database behavior for copy, move, bulk operations, and failed uploads. - Add domain references from future records to `assets.id` instead of duplicating diff --git a/CONTEXT.md b/CONTEXT.md index fa0ee733..7dde604d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -45,3 +45,14 @@ _Avoid_: Required backend, backend layer **Preset**: A reusable environment or tooling configuration selected by a local project generator. _Avoid_: Template recipe + +**Asset**: +A stored file represented by a stable identity so other domain records can refer to +it. An Asset has one Owner and records the Uploader who placed it in managed storage. + +**Asset Owner**: +The User to whom an Asset belongs. Ownership can differ from creation provenance. + +**Asset Uploader**: +The User who placed an Asset in managed storage. The Uploader does not necessarily +remain its Owner. diff --git a/apps/api/README.md b/apps/api/README.md index fa1bb53e..fc91bbc6 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -4,6 +4,6 @@ API server built with [Hono](https://hono.dev/). -The versioned router includes the authenticated Files SDK gateway at `/v1/files`. +The API server includes the authenticated Files SDK gateway at `/files`. Provider composition and upload policy live in `src/shared/files.ts`; local development uses the MinIO service and `assets` bucket from `infra/local/docker-compose.yml`. diff --git a/apps/api/src/routes/files.ts b/apps/api/src/routes/files.ts new file mode 100644 index 00000000..364b6a41 --- /dev/null +++ b/apps/api/src/routes/files.ts @@ -0,0 +1,40 @@ +import { createFilesRouter } from "files-sdk/api" +import { createRouteHandler } from "files-sdk/hono" +import type { AuthenticatedAppContext } from "#shared/types.ts" +import env from "#shared/env.ts" +import { files, FILES_MAX_UPLOAD_SIZE, FILES_MAX_URL_AGE } from "#shared/files.ts" +import { requireSession } from "#shared/middleware.ts" +import { context, factory } from "#shared/utils.ts" + +const router = createFilesRouter({ + allowedOrigins: env.ALLOWED_API_ORIGINS, + authorize: () => { + const ctx = context() + + return { + disposition: "attachment", + keyPrefix: `users/${ctx.var.session.user.id}/`, + maxExpiresIn: FILES_MAX_URL_AGE, + maxResults: 100, + } + }, + files, + maxListLimit: 100, + maxSearchResults: 100, + maxUploadSize: FILES_MAX_UPLOAD_SIZE, + operations: [ + "capabilities", + "delete", + "download", + "exists", + "head", + "list", + "search", + "signedUploadUrl", + "upload", + "url", + ], + secret: env.FILES_API_SECRET, +}) + +export default factory.createApp().all("/", requireSession, createRouteHandler(router)) diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 8572b88a..91d4b6e3 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -8,12 +8,14 @@ import { contextStorage } from "hono/context-storage" import { cors } from "hono/cors" import { HTTPException } from "hono/http-exception" import { secureHeaders } from "hono/secure-headers" +import filesRoutes from "#routes/files.ts" import healthRoutes from "#routes/health.ts" import trpcRoutes from "#routes/trpc.ts" import v1Routes from "#routes/v1/index.ts" import workflowRoutes from "#routes/workflows.ts" import { auth } from "#shared/auth.ts" import env from "#shared/env.ts" +import { files } from "#shared/files.ts" import { LoggerCategory, logger } from "#shared/logger.ts" import { withLanguageDetection } from "#shared/middleware.ts" import { factory } from "#shared/utils.ts" @@ -42,6 +44,7 @@ app.use( app.use(async (c, next) => { c.set("auth", auth) c.set("db", database()) + c.set("files", files) c.set("kv", kv()) c.set("logger", logger) await next() @@ -82,6 +85,7 @@ export const router = app }) ) .route("/health", healthRoutes) + .route("/files", filesRoutes) .route("/workflows", workflowRoutes) .route("/trpc", trpcRoutes) .route("/v1", v1Routes) diff --git a/apps/api/src/routes/v1/files.ts b/apps/api/src/routes/v1/files.ts deleted file mode 100644 index 07204d76..00000000 --- a/apps/api/src/routes/v1/files.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { FilesError } from "files-sdk" -import { createFilesRouter } from "files-sdk/api" -import { createRouteHandler } from "files-sdk/hono" -import { auth } from "#shared/auth.ts" -import env from "#shared/env.ts" -import { files, FILES_MAX_UPLOAD_SIZE, FILES_MAX_URL_AGE } from "#shared/files.ts" -import { factory } from "#shared/utils.ts" - -const router = createFilesRouter({ - allowedOrigins: env.ALLOWED_API_ORIGINS, - authorize: async ({ req }) => { - const session = await auth.api.getSession({ headers: req.headers }) - - if (!session) throw new FilesError("Unauthorized", "Sign in to access files") - - return { - disposition: "attachment", - keyPrefix: `users/${session.user.id}/`, - maxExpiresIn: FILES_MAX_URL_AGE, - maxResults: 100, - } - }, - files, - maxListLimit: 100, - maxSearchResults: 100, - maxUploadSize: FILES_MAX_UPLOAD_SIZE, - secret: env.FILES_API_SECRET, -}) - -export default factory.createApp().all("/", createRouteHandler(router)) diff --git a/apps/api/src/routes/v1/index.ts b/apps/api/src/routes/v1/index.ts index 2f2c1226..d28149aa 100644 --- a/apps/api/src/routes/v1/index.ts +++ b/apps/api/src/routes/v1/index.ts @@ -1,13 +1,11 @@ import * as z from "@init/utils/schema" import { describeRoute, resolver, validator } from "hono-openapi" -import filesRoutes from "#routes/v1/files.ts" import { m } from "#shared/internationalization/messages.js" import { requireSession } from "#shared/middleware.ts" import { factory } from "#shared/utils.ts" export default factory .createApp() - .route("/files", filesRoutes) .get( "/hello", describeRoute({ diff --git a/apps/api/src/shared/files.ts b/apps/api/src/shared/files.ts index 261d1ba8..edf99bf6 100644 --- a/apps/api/src/shared/files.ts +++ b/apps/api/src/shared/files.ts @@ -1,10 +1,15 @@ +import type { DeleteManyResult, StoredFile, UploadResult } from "files-sdk" +import { operators } from "@init/db/helpers" +import { assets, type UserId } from "@init/db/schema" import * as z from "@init/utils/schema" import { createFiles } from "files-sdk" import { bunS3 } from "files-sdk/bun-s3" import { contentType } from "files-sdk/content-type" import { signedUrlPolicy } from "files-sdk/signed-url-policy" import { validation } from "files-sdk/validation" +import type { AuthenticatedAppContext } from "#shared/types.ts" import env from "#shared/env.ts" +import { context } from "#shared/utils.ts" export const FILES_MAX_UPLOAD_SIZE = 10 * 1024 * 1024 export const FILES_MAX_URL_AGE = 15 * 60 @@ -18,6 +23,28 @@ export const files = createFiles({ secretAccessKey: env.S3_SECRET_ACCESS_KEY, virtualHostedStyle: !env.S3_ENDPOINT, }), + hooks: { + onAction(event) { + if (event.status !== "success") return + + switch (event.type) { + case "upload": + if (event.key) handleUpload(event.key, event.result as UploadResult) + break + case "head": + if (event.key) handleUpload(event.key, event.result as StoredFile) + break + case "delete": { + const keys = event.key ? [event.key] : (event.result as DeleteManyResult).deleted + + handleDelete(keys) + break + } + default: + break + } + }, + }, plugins: [ signedUrlPolicy({ maxExpiresIn: FILES_MAX_URL_AGE, @@ -39,3 +66,56 @@ export const files = createFiles({ contentType({ onMismatch: "reject" }), ], }) + +function handleUpload(key: string, file: UploadResult | StoredFile) { + const ctx = context() + const mimeType = "contentType" in file ? file.contentType : file.type + const name = "name" in file ? file.name : (key.split("/").at(-1) ?? key) + + void ctx.var.db + .insert(assets) + .values({ + etag: file.etag, + key, + lastModified: file.lastModified, + metadata: "metadata" in file ? file.metadata : undefined, + name, + ownerId: ctx.var.session.user.id as UserId, + size: file.size, + type: mimeType, + uploaderId: ctx.var.session.user.id as UserId, + }) + .onConflictDoUpdate({ + set: { + etag: file.etag, + lastModified: file.lastModified, + metadata: "metadata" in file ? file.metadata : undefined, + name, + size: file.size, + type: mimeType, + updatedAt: new Date(), + }, + target: assets.key, + }) + .catch((error: unknown) => { + ctx.var.logger.error(`Failed to record asset: ${String(error)}`) + }) +} + +function handleDelete(keys: string[]) { + if (keys.length === 0) return + + const ctx = context() + + void ctx.var.db + .delete(assets) + .where( + operators.and( + operators.inArray(assets.key, keys), + operators.eq(assets.ownerId, ctx.var.session.user.id as UserId) + ) + ) + .catch((error: unknown) => { + ctx.var.logger.error(`Failed to delete asset records: ${String(error)}`) + }) +} diff --git a/apps/api/src/shared/types.ts b/apps/api/src/shared/types.ts index 7d8588f6..405c9972 100644 --- a/apps/api/src/shared/types.ts +++ b/apps/api/src/shared/types.ts @@ -1,6 +1,7 @@ import type { Database } from "@init/db/client" import type { KeyValue } from "@init/kv/client" import type { DeepMerge } from "@init/utils/type" +import type { Files } from "files-sdk" import type { Auth, Session } from "#shared/auth.ts" import type { Locale } from "#shared/internationalization/runtime.js" import type { logger } from "#shared/logger.ts" @@ -11,6 +12,7 @@ export type AppContext = { Variables: { auth: Auth db: Database + files: Files kv: KeyValue language: Locale logger: AppLogger diff --git a/docs/architecture/file-service.md b/docs/architecture/file-service.md index 065ec2bb..85564647 100644 --- a/docs/architecture/file-service.md +++ b/docs/architecture/file-service.md @@ -1,7 +1,7 @@ # File service When `apps/api` is selected, it includes an authenticated Files SDK gateway at -`/v1/files`. +`/files`. ## Composition diff --git a/docs/generators.md b/docs/generators.md index 5da3ef85..77a7016b 100644 --- a/docs/generators.md +++ b/docs/generators.md @@ -126,7 +126,7 @@ applied consistently. ## Add a Files SDK client -`apps/api` always includes the authenticated Files SDK gateway at `/v1/files`, alongside +`apps/api` always includes the authenticated Files SDK gateway at `/files`, alongside the built-in tRPC and Hono routes. Its `src/shared/files.ts` composition uses Bun's native S3 adapter with local MinIO defaults. Every operation requires the existing Init session and is scoped to `users//`. Uploads are limited to 10 MiB and accept @@ -136,8 +136,8 @@ Generate the optional client in an app that consumes the API: ```bash bun run generate files-client -bun run generate files-client --args app http://localhost:3000/v1/files -bun run generate files-client --args web http://localhost:3000/v1/files +bun run generate files-client --args app http://localhost:3000/files +bun run generate files-client --args web http://localhost:3000/files ``` The generator can target any workspace under `apps/`. It creates diff --git a/packages/db/migrations/0000_fancy_rhodey.sql b/packages/db/migrations/0000_puzzling_vermin.sql similarity index 87% rename from packages/db/migrations/0000_fancy_rhodey.sql rename to packages/db/migrations/0000_puzzling_vermin.sql index 5a2378be..bc31ce09 100644 --- a/packages/db/migrations/0000_fancy_rhodey.sql +++ b/packages/db/migrations/0000_puzzling_vermin.sql @@ -4,7 +4,6 @@ CREATE SCHEMA "organization"; --> statement-breakpoint CREATE SCHEMA "storage"; --> statement-breakpoint -CREATE TYPE "storage"."asset_status" AS ENUM('pending', 'uploading', 'available', 'processing', 'failed', 'deleted');--> statement-breakpoint CREATE TYPE "organization"."invitation_status" AS ENUM('pending', 'accepted', 'rejected', 'canceled');--> statement-breakpoint CREATE TYPE "organization"."member_role" AS ENUM('member', 'admin', 'owner');--> statement-breakpoint CREATE TYPE "auth"."user_role" AS ENUM('user', 'admin');--> statement-breakpoint @@ -38,18 +37,15 @@ CREATE TABLE "storage"."assets" ( "id" text PRIMARY KEY NOT NULL, "created_at" timestamp with time zone DEFAULT now() NOT NULL, "updated_at" timestamp with time zone DEFAULT now() NOT NULL, - "bucket" text NOT NULL, - "error_message" text, - "expires_at" timestamp with time zone, + "uploader_id" text, + "etag" text, "key" text NOT NULL, + "last_modified" bigint, "metadata" jsonb, - "mime_type" text NOT NULL, "name" text NOT NULL, - "organization_id" text, - "provider" text NOT NULL, + "owner_id" text NOT NULL, "size" integer NOT NULL, - "status" "storage"."asset_status" DEFAULT 'pending' NOT NULL, - "uploader_id" text NOT NULL + "type" text NOT NULL ); --> statement-breakpoint CREATE TABLE "documents" ( @@ -134,8 +130,8 @@ CREATE TABLE "auth"."verifications" ( ALTER TABLE "auth"."accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint ALTER TABLE "organization"."activity_logs" ADD CONSTRAINT "activity_logs_member_id_members_id_fk" FOREIGN KEY ("member_id") REFERENCES "organization"."members"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint ALTER TABLE "organization"."activity_logs" ADD CONSTRAINT "activity_logs_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "organization"."organizations"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint -ALTER TABLE "storage"."assets" ADD CONSTRAINT "assets_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "organization"."organizations"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint -ALTER TABLE "storage"."assets" ADD CONSTRAINT "assets_uploader_id_users_id_fk" FOREIGN KEY ("uploader_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "storage"."assets" ADD CONSTRAINT "assets_uploader_id_users_id_fk" FOREIGN KEY ("uploader_id") REFERENCES "auth"."users"("id") ON DELETE set null ON UPDATE cascade;--> statement-breakpoint +ALTER TABLE "storage"."assets" ADD CONSTRAINT "assets_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint ALTER TABLE "organization"."invitations" ADD CONSTRAINT "invitations_inviter_id_members_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "organization"."members"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint ALTER TABLE "organization"."invitations" ADD CONSTRAINT "invitations_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "organization"."organizations"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint ALTER TABLE "organization"."members" ADD CONSTRAINT "members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE cascade;--> statement-breakpoint @@ -149,13 +145,9 @@ CREATE UNIQUE INDEX "auth_accounts_provider_account_unique_idx" ON "auth"."accou CREATE INDEX "organization_activity_logs_organization_id_idx" ON "organization"."activity_logs" USING btree ("organization_id");--> statement-breakpoint CREATE INDEX "organization_activity_logs_member_id_idx" ON "organization"."activity_logs" USING btree ("member_id");--> statement-breakpoint CREATE INDEX "organization_activity_logs_type_idx" ON "organization"."activity_logs" USING btree ("type");--> statement-breakpoint +CREATE UNIQUE INDEX "storage_assets_key_unique_idx" ON "storage"."assets" USING btree ("key");--> statement-breakpoint +CREATE INDEX "storage_assets_owner_id_idx" ON "storage"."assets" USING btree ("owner_id");--> statement-breakpoint CREATE INDEX "storage_assets_uploader_id_idx" ON "storage"."assets" USING btree ("uploader_id");--> statement-breakpoint -CREATE INDEX "storage_assets_organization_id_idx" ON "storage"."assets" USING btree ("organization_id");--> statement-breakpoint -CREATE INDEX "storage_assets_bucket_idx" ON "storage"."assets" USING btree ("bucket");--> statement-breakpoint -CREATE INDEX "storage_assets_provider_idx" ON "storage"."assets" USING btree ("provider");--> statement-breakpoint -CREATE INDEX "storage_assets_status_idx" ON "storage"."assets" USING btree ("status");--> statement-breakpoint -CREATE INDEX "storage_assets_expires_at_idx" ON "storage"."assets" USING btree ("expires_at");--> statement-breakpoint -CREATE UNIQUE INDEX "storage_assets_bucket_key_unique_idx" ON "storage"."assets" USING btree ("bucket","key");--> statement-breakpoint CREATE UNIQUE INDEX "organization_invitations_organization_email_unique_idx" ON "organization"."invitations" USING btree ("organization_id","email");--> statement-breakpoint CREATE INDEX "organization_invitations_organization_id_idx" ON "organization"."invitations" USING btree ("organization_id");--> statement-breakpoint CREATE INDEX "organization_invitations_email_idx" ON "organization"."invitations" USING btree ("email");--> statement-breakpoint diff --git a/packages/db/migrations/meta/0000_snapshot.json b/packages/db/migrations/meta/0000_snapshot.json index b3ed3923..c4fbc1ca 100644 --- a/packages/db/migrations/meta/0000_snapshot.json +++ b/packages/db/migrations/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "a121f2e2-a252-4054-9aec-f9c8778a3780", + "id": "dd917b72-5c1b-4f9a-b7b2-c782e966dd26", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -307,21 +307,15 @@ "notNull": true, "default": "now()" }, - "bucket": { - "name": "bucket", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "error_message": { - "name": "error_message", + "uploader_id": { + "name": "uploader_id", "type": "text", "primaryKey": false, "notNull": false }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", + "etag": { + "name": "etag", + "type": "text", "primaryKey": false, "notNull": false }, @@ -331,32 +325,26 @@ "primaryKey": false, "notNull": true }, + "last_modified": { + "name": "last_modified", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, "metadata": { "name": "metadata", "type": "jsonb", "primaryKey": false, "notNull": false }, - "mime_type": { - "name": "mime_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, "name": { "name": "name", "type": "text", "primaryKey": false, "notNull": true }, - "organization_id": { - "name": "organization_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "provider": { - "name": "provider", + "owner_id": { + "name": "owner_id", "type": "text", "primaryKey": false, "notNull": true @@ -367,87 +355,34 @@ "primaryKey": false, "notNull": true }, - "status": { - "name": "status", - "type": "asset_status", - "typeSchema": "storage", - "primaryKey": false, - "notNull": true, - "default": "'pending'" - }, - "uploader_id": { - "name": "uploader_id", + "type": { + "name": "type", "type": "text", "primaryKey": false, "notNull": true } }, "indexes": { - "storage_assets_uploader_id_idx": { - "name": "storage_assets_uploader_id_idx", - "columns": [ - { - "expression": "uploader_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "storage_assets_organization_id_idx": { - "name": "storage_assets_organization_id_idx", + "storage_assets_key_unique_idx": { + "name": "storage_assets_key_unique_idx", "columns": [ { - "expression": "organization_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "storage_assets_bucket_idx": { - "name": "storage_assets_bucket_idx", - "columns": [ - { - "expression": "bucket", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "storage_assets_provider_idx": { - "name": "storage_assets_provider_idx", - "columns": [ - { - "expression": "provider", + "expression": "key", "isExpression": false, "asc": true, "nulls": "last" } ], - "isUnique": false, + "isUnique": true, "concurrently": false, "method": "btree", "with": {} }, - "storage_assets_status_idx": { - "name": "storage_assets_status_idx", + "storage_assets_owner_id_idx": { + "name": "storage_assets_owner_id_idx", "columns": [ { - "expression": "status", + "expression": "owner_id", "isExpression": false, "asc": true, "nulls": "last" @@ -458,11 +393,11 @@ "method": "btree", "with": {} }, - "storage_assets_expires_at_idx": { - "name": "storage_assets_expires_at_idx", + "storage_assets_uploader_id_idx": { + "name": "storage_assets_uploader_id_idx", "columns": [ { - "expression": "expires_at", + "expression": "uploader_id", "isExpression": false, "asc": true, "nulls": "last" @@ -472,46 +407,25 @@ "concurrently": false, "method": "btree", "with": {} - }, - "storage_assets_bucket_key_unique_idx": { - "name": "storage_assets_bucket_key_unique_idx", - "columns": [ - { - "expression": "bucket", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} } }, "foreignKeys": { - "assets_organization_id_organizations_id_fk": { - "name": "assets_organization_id_organizations_id_fk", + "assets_uploader_id_users_id_fk": { + "name": "assets_uploader_id_users_id_fk", "tableFrom": "assets", - "tableTo": "organizations", - "schemaTo": "organization", - "columnsFrom": ["organization_id"], + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": ["uploader_id"], "columnsTo": ["id"], - "onDelete": "cascade", + "onDelete": "set null", "onUpdate": "cascade" }, - "assets_uploader_id_users_id_fk": { - "name": "assets_uploader_id_users_id_fk", + "assets_owner_id_users_id_fk": { + "name": "assets_owner_id_users_id_fk", "tableFrom": "assets", "tableTo": "users", "schemaTo": "auth", - "columnsFrom": ["uploader_id"], + "columnsFrom": ["owner_id"], "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "cascade" @@ -1349,11 +1263,6 @@ } }, "enums": { - "storage.asset_status": { - "name": "asset_status", - "schema": "storage", - "values": ["pending", "uploading", "available", "processing", "failed", "deleted"] - }, "organization.invitation_status": { "name": "invitation_status", "schema": "organization", diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 76f4bb05..14de0e3b 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "7", - "when": 1785271752014, - "tag": "0000_fancy_rhodey", + "when": 1785541896234, + "tag": "0000_puzzling_vermin", "breakpoints": true } ] diff --git a/packages/db/src/helpers.ts b/packages/db/src/helpers.ts index 3392fc3f..caff1ffb 100644 --- a/packages/db/src/helpers.ts +++ b/packages/db/src/helpers.ts @@ -2,6 +2,8 @@ import * as z from "@init/utils/schema" import { type AnyColumn, sql } from "drizzle-orm" import { createSchemaFactory } from "drizzle-zod" +export * as operators from "drizzle-orm/sql/expressions/conditions" + export function increment(column: AnyColumn, value = 1) { return sql`${column} + ${value}` } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 76dcb59c..72ce9bed 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -371,50 +371,31 @@ export type ActivityLogType = ActivityLog["type"] // ==========================STORAGE========================== export const storageSchema = pg.pgSchema("storage") -export const assetStatus = storageSchema.enum("asset_status", [ - "pending", - "uploading", - "available", - "processing", - "failed", - "deleted", -]) - export const assets = storageSchema.table( "assets", { ...id("AssetId", "asst"), ...timestamps, - bucket: pg.text().notNull(), - - errorMessage: pg.text(), + uploaderId: pg + .text() + .references(() => users.id, { + onDelete: "set null", + onUpdate: "cascade", + }) + .$type(), - expiresAt: pg.timestamp({ withTimezone: true }), + etag: pg.text(), key: pg.text().notNull(), - metadata: pg.jsonb(), + lastModified: pg.bigint({ mode: "number" }), - mimeType: pg.text().notNull(), + metadata: pg.jsonb().$type>(), name: pg.text().notNull(), - organizationId: pg - .text() - .references(() => organizations.id, { - onDelete: "cascade", - onUpdate: "cascade", - }) - .$type(), - - provider: pg.text().notNull(), - - size: pg.integer().notNull(), - - status: assetStatus().notNull().default("pending"), - - uploaderId: pg + ownerId: pg .text() .notNull() .references(() => users.id, { @@ -422,23 +403,21 @@ export const assets = storageSchema.table( onUpdate: "cascade", }) .$type(), + + size: pg.integer().notNull(), + + type: pg.text().notNull(), }, (table) => [ + pg.uniqueIndex("storage_assets_key_unique_idx").on(table.key), + pg.index("storage_assets_owner_id_idx").on(table.ownerId), pg.index("storage_assets_uploader_id_idx").on(table.uploaderId), - pg.index("storage_assets_organization_id_idx").on(table.organizationId), - pg.index("storage_assets_bucket_idx").on(table.bucket), - pg.index("storage_assets_provider_idx").on(table.provider), - pg.index("storage_assets_status_idx").on(table.status), - pg.index("storage_assets_expires_at_idx").on(table.expiresAt), - pg.uniqueIndex("storage_assets_bucket_key_unique_idx").on(table.bucket, table.key), ] ) export type Asset = typeof assets.$inferSelect export type NewAsset = typeof assets.$inferInsert export type AssetId = Asset["id"] -export type AssetStatus = Asset["status"] -export type StorageProvider = Asset["provider"] // Relations @@ -448,8 +427,9 @@ export const userRelations = relations(users, ({ many }) => ({ relationName: "impersonator", }), members: many(members), + ownedAssets: many(assets, { relationName: "assetOwner" }), sessions: many(sessions, { relationName: "user" }), - uploadedAssets: many(assets), + uploadedAssets: many(assets, { relationName: "assetUploader" }), })) export const accountRelations = relations(accounts, ({ one }) => ({ @@ -492,7 +472,6 @@ export const memberRelations = relations(members, ({ one, many }) => ({ export const organizationRelations = relations(organizations, ({ many }) => ({ activityLogs: many(activityLogs), - assets: many(assets), invitations: many(invitations), members: many(members), })) @@ -520,12 +499,14 @@ export const activityLogRelations = relations(activityLogs, ({ one }) => ({ })) export const assetRelations = relations(assets, ({ one }) => ({ - organization: one(organizations, { - fields: [assets.organizationId], - references: [organizations.id], + owner: one(users, { + fields: [assets.ownerId], + references: [users.id], + relationName: "assetOwner", }), uploader: one(users, { fields: [assets.uploaderId], references: [users.id], + relationName: "assetUploader", }), })) diff --git a/packages/env/src/presets.ts b/packages/env/src/presets.ts index 6c79333a..f0f5a85a 100644 --- a/packages/env/src/presets.ts +++ b/packages/env/src/presets.ts @@ -130,7 +130,7 @@ export const s3 = () => runtimeEnv: env, server: { S3_ACCESS_KEY_ID: z.string(), - S3_BUCKET: z.string().optional(), + S3_BUCKET: z.string(), S3_ENDPOINT: z.string().optional(), S3_REGION: z.string().optional(), S3_SECRET_ACCESS_KEY: z.string(), diff --git a/turbo/generators/commands/files-client.ts b/turbo/generators/commands/files-client.ts index 27aa1d49..8c55f83b 100644 --- a/turbo/generators/commands/files-client.ts +++ b/turbo/generators/commands/files-client.ts @@ -39,23 +39,23 @@ export function registerFilesClientGenerator(plop: PlopTypes.NodePlopAPI): void async () => { const requiredPaths = [ "apps/api/src/shared/files.ts", - "apps/api/src/routes/v1/files.ts", + "apps/api/src/routes/files.ts", `${appPath}/package.json`, ] const missingPaths = await getMissingPaths(requiredPaths) if (missingPaths.length > 0) throw new Error( - `The files-client generator requires the /v1/files server from apps/api and the selected app workspace. Restore the API with \`bun template add app api\`. Missing: ${missingPaths.join(", ")}` + `The files-client generator requires the /files server from apps/api and the selected app workspace. Restore the API with \`bun template add app api\`. Missing: ${missingPaths.join(", ")}` ) - const [v1Routes, packageJson, hasClient] = await Promise.all([ - Bun.file("apps/api/src/routes/v1/index.ts").text(), + const [routes, packageJson, hasClient] = await Promise.all([ + Bun.file("apps/api/src/routes/index.ts").text(), Bun.file(`${appPath}/package.json`).json() as Promise, Bun.file(clientPath).exists(), ]) - if (!v1Routes.includes('.route("/files", filesRoutes)')) + if (!routes.includes('.route("/files", filesRoutes)')) throw new Error( - "The files-client generator requires the Files SDK server mounted at /v1/files in apps/api." + "The files-client generator requires the Files SDK server mounted at /files in apps/api." ) const installedPackages = { @@ -103,7 +103,7 @@ export function registerFilesClientGenerator(plop: PlopTypes.NodePlopAPI): void type: "list", }, { - default: "http://localhost:3000/v1/files", + default: "http://localhost:3000/files", message: "What is the Files SDK endpoint?", name: "endpoint", type: "input",