From 7d9650d9d0b338f1181c963fce84a2c58b387bac Mon Sep 17 00:00:00 2001 From: Kingston Tam Date: Fri, 11 Jul 2025 12:01:37 +0200 Subject: [PATCH 01/22] feat: Add session error link to catch scenarios where we have an invalid session token --- .../react-config/react-config.generator.ts | 3 - .../auth-apollo/auth-apollo.generator.ts | 59 +++++++++++++++++++ .../auth/core/generators/auth-apollo/index.ts | 1 + .../templates/session-error-link.ts | 16 +++++ .../templates/routes/auth_/login.tsx | 1 + .../templates/routes/auth_/register.tsx | 1 + plugins/plugin-auth/src/auth/core/node.ts | 2 + 7 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 plugins/plugin-auth/src/auth/core/generators/auth-apollo/auth-apollo.generator.ts create mode 100644 plugins/plugin-auth/src/auth/core/generators/auth-apollo/index.ts create mode 100644 plugins/plugin-auth/src/auth/core/generators/auth-apollo/templates/session-error-link.ts diff --git a/packages/react-generators/src/generators/core/react-config/react-config.generator.ts b/packages/react-generators/src/generators/core/react-config/react-config.generator.ts index ef25a31a1..d2b35d46c 100644 --- a/packages/react-generators/src/generators/core/react-config/react-config.generator.ts +++ b/packages/react-generators/src/generators/core/react-config/react-config.generator.ts @@ -134,9 +134,6 @@ export const reactConfigGenerator = createGenerator({ id: 'development-env', destination: '.env.development', contents: developmentEnvFile, - options: { - shouldNeverOverwrite: true, - }, }); } }, diff --git a/plugins/plugin-auth/src/auth/core/generators/auth-apollo/auth-apollo.generator.ts b/plugins/plugin-auth/src/auth/core/generators/auth-apollo/auth-apollo.generator.ts new file mode 100644 index 000000000..4ad3f6e2c --- /dev/null +++ b/plugins/plugin-auth/src/auth/core/generators/auth-apollo/auth-apollo.generator.ts @@ -0,0 +1,59 @@ +import { + tsCodeFragment, + TsCodeUtils, + tsImportBuilder, +} from '@baseplate-dev/core-generators'; +import { reactApolloConfigProvider } from '@baseplate-dev/react-generators'; +import { createGenerator, createGeneratorTask } from '@baseplate-dev/sync'; +import { z } from 'zod'; + +import { reactSessionImportsProvider } from '../react-session'; + +const descriptorSchema = z.object({}); + +export const authApolloGenerator = createGenerator({ + name: 'auth/core/auth-apollo', + generatorFileUrl: import.meta.url, + descriptorSchema, + buildTasks: () => ({ + main: createGeneratorTask({ + dependencies: { + reactApolloConfig: reactApolloConfigProvider, + reactSession: reactSessionImportsProvider, + }, + run({ reactApolloConfig, reactSession }) { + reactApolloConfig.createApolloClientArguments.add({ + name: 'userSessionClient', + type: reactSession.UserSessionClient.typeFragment(), + reactRenderBody: tsCodeFragment( + 'const { client: userSessionClient } = useUserSessionClient();', + reactSession.useUserSessionClient.declaration(), + ), + }); + + return { + providers: { + authApollo: {}, + }, + build: async (builder) => { + const linkTemplate = await builder.readTemplate( + 'session-error-link.ts', + ); + const sessionErrorLink = TsCodeUtils.extractTemplateSnippet( + linkTemplate, + 'SESSION_ERROR_LINK', + ); + + reactApolloConfig.apolloLinks.add({ + name: 'sessionErrorLink', + bodyFragment: tsCodeFragment(sessionErrorLink, [ + tsImportBuilder(['onError']).from('@apollo/client/link/error'), + ]), + priority: 'error', + }); + }, + }; + }, + }), + }), +}); diff --git a/plugins/plugin-auth/src/auth/core/generators/auth-apollo/index.ts b/plugins/plugin-auth/src/auth/core/generators/auth-apollo/index.ts new file mode 100644 index 000000000..404b33ee6 --- /dev/null +++ b/plugins/plugin-auth/src/auth/core/generators/auth-apollo/index.ts @@ -0,0 +1 @@ +export * from './auth-apollo.generator.js'; diff --git a/plugins/plugin-auth/src/auth/core/generators/auth-apollo/templates/session-error-link.ts b/plugins/plugin-auth/src/auth/core/generators/auth-apollo/templates/session-error-link.ts new file mode 100644 index 000000000..42c884a47 --- /dev/null +++ b/plugins/plugin-auth/src/auth/core/generators/auth-apollo/templates/session-error-link.ts @@ -0,0 +1,16 @@ +// @ts-nocheck + +// SESSION_ERROR_LINK:START +const sessionErrorLink = onError(({ networkError }) => { + const serverError = networkError as ServerError | undefined; + if ( + typeof serverError === 'object' && + serverError.statusCode === 401 && + typeof serverError.result === 'object' && + serverError.result.code === 'invalid-session' + ) { + userSessionClient.signOut(); + } + return; +}); +// SESSION_ERROR_LINK:END diff --git a/plugins/plugin-auth/src/auth/core/generators/auth-routes/templates/routes/auth_/login.tsx b/plugins/plugin-auth/src/auth/core/generators/auth-routes/templates/routes/auth_/login.tsx index e950046f0..e870cbb05 100644 --- a/plugins/plugin-auth/src/auth/core/generators/auth-routes/templates/routes/auth_/login.tsx +++ b/plugins/plugin-auth/src/auth/core/generators/auth-routes/templates/routes/auth_/login.tsx @@ -60,6 +60,7 @@ function LoginPage(): React.JSX.Element { setError: setFormError, } = useForm({ resolver: zodResolver(formSchema), + reValidateMode: 'onBlur', }); const [loginWithEmailPassword, { loading }] = useMutation( LoginWithEmailPasswordDocument, diff --git a/plugins/plugin-auth/src/auth/core/generators/auth-routes/templates/routes/auth_/register.tsx b/plugins/plugin-auth/src/auth/core/generators/auth-routes/templates/routes/auth_/register.tsx index a10e79cae..618aa54ba 100644 --- a/plugins/plugin-auth/src/auth/core/generators/auth-routes/templates/routes/auth_/register.tsx +++ b/plugins/plugin-auth/src/auth/core/generators/auth-routes/templates/routes/auth_/register.tsx @@ -59,6 +59,7 @@ function RegisterPage(): React.JSX.Element { setError: setFormError, } = useForm({ resolver: zodResolver(formSchema), + reValidateMode: 'onBlur', }); const [registerWithEmailPassword, { loading }] = useMutation( RegisterWithEmailPasswordDocument, diff --git a/plugins/plugin-auth/src/auth/core/node.ts b/plugins/plugin-auth/src/auth/core/node.ts index fe8ee2ee5..548b534bb 100644 --- a/plugins/plugin-auth/src/auth/core/node.ts +++ b/plugins/plugin-auth/src/auth/core/node.ts @@ -19,6 +19,7 @@ import { import type { AuthPluginDefinition } from './schema/plugin-definition.js'; +import { authApolloGenerator } from './generators/auth-apollo/auth-apollo.generator.js'; import { authEmailPasswordGenerator } from './generators/auth-email-password/auth-email-password.generator.js'; import { authHooksGenerator } from './generators/auth-hooks/auth-hooks.generator.js'; import { authRoutesGenerator } from './generators/auth-routes/auth-routes.generator.js'; @@ -65,6 +66,7 @@ export default createPlatformPluginExport({ const sharedWebGenerators = { ...createCommonWebAuthGenerators(), + authApollo: authApolloGenerator({}), reactAuth: reactAuthGenerator({}), authHooks: authHooksGenerator({}), reactSession: reactSessionGenerator({}), From 1eeadb594208ceb8543311ea1001bed9f4987370 Mon Sep 17 00:00:00 2001 From: Kingston Tam Date: Fri, 11 Jul 2025 12:20:46 +0200 Subject: [PATCH 02/22] Export root auth apollo generator --- .../auth/core/generators/auth-apollo/auth-apollo.generator.ts | 2 +- plugins/plugin-auth/src/auth/core/generators/index.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/plugin-auth/src/auth/core/generators/auth-apollo/auth-apollo.generator.ts b/plugins/plugin-auth/src/auth/core/generators/auth-apollo/auth-apollo.generator.ts index 4ad3f6e2c..3a93b1027 100644 --- a/plugins/plugin-auth/src/auth/core/generators/auth-apollo/auth-apollo.generator.ts +++ b/plugins/plugin-auth/src/auth/core/generators/auth-apollo/auth-apollo.generator.ts @@ -7,7 +7,7 @@ import { reactApolloConfigProvider } from '@baseplate-dev/react-generators'; import { createGenerator, createGeneratorTask } from '@baseplate-dev/sync'; import { z } from 'zod'; -import { reactSessionImportsProvider } from '../react-session'; +import { reactSessionImportsProvider } from '../react-session/index.js'; const descriptorSchema = z.object({}); diff --git a/plugins/plugin-auth/src/auth/core/generators/index.ts b/plugins/plugin-auth/src/auth/core/generators/index.ts index 70f12699f..fde7722b2 100644 --- a/plugins/plugin-auth/src/auth/core/generators/index.ts +++ b/plugins/plugin-auth/src/auth/core/generators/index.ts @@ -1,3 +1,4 @@ +export * from './auth-apollo/index.js'; export * from './auth-email-password/index.js'; export * from './auth-hooks/index.js'; export * from './auth-module/index.js'; From c46c8ad06733a70414426dec227168e04df1cefd Mon Sep 17 00:00:00 2001 From: Kingston Tam Date: Fri, 11 Jul 2025 12:26:04 +0200 Subject: [PATCH 03/22] Make sure we use NodeNext/Node16 for ts libraries --- packages/tools/tsconfig.vite.lib.json | 3 +++ packages/ui-components/src/components/calendar/calendar.tsx | 2 +- .../ui-components/src/components/combobox/combobox.test.tsx | 2 +- .../auth-email-password/auth-email-password.generator.ts | 2 +- .../auth/core/generators/auth-module/auth-module.generator.ts | 2 +- .../auth/core/generators/auth-routes/auth-routes.generator.ts | 2 +- .../core/generators/react-session/react-session.generator.ts | 2 +- .../generators/react/react-auth0/react-auth0.generator.ts | 2 +- plugins/plugin-auth/src/common/compiler/generator-creators.ts | 2 +- .../placeholder-auth-module/auth-module.generator.ts | 2 +- .../storage/transformers/components/file-transformer-form.tsx | 2 +- 11 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/tools/tsconfig.vite.lib.json b/packages/tools/tsconfig.vite.lib.json index 7a5fab5e6..f9bf621e7 100644 --- a/packages/tools/tsconfig.vite.lib.json +++ b/packages/tools/tsconfig.vite.lib.json @@ -4,6 +4,9 @@ "display": "Vite React Library", "compilerOptions": { "allowImportingTsExtensions": false, + /* Make sure we use NodeNext for mixed-Node/React libraries */ + "module": "NodeNext", + "moduleResolution": "Node16", /* Output */ "noEmit": false, diff --git a/packages/ui-components/src/components/calendar/calendar.tsx b/packages/ui-components/src/components/calendar/calendar.tsx index 06e23034b..0c7fcdb1b 100644 --- a/packages/ui-components/src/components/calendar/calendar.tsx +++ b/packages/ui-components/src/components/calendar/calendar.tsx @@ -9,7 +9,7 @@ import { MdChevronLeft, MdChevronRight, MdExpandMore } from 'react-icons/md'; import { buttonVariants } from '#src/styles/button.js'; import { cn } from '#src/utils/index.js'; -import { Button } from '../button/button'; +import { Button } from '../button/button.js'; /* eslint-disable react/prop-types -- needed for subcomponents */ diff --git a/packages/ui-components/src/components/combobox/combobox.test.tsx b/packages/ui-components/src/components/combobox/combobox.test.tsx index 9144f32ed..9b2f2e639 100644 --- a/packages/ui-components/src/components/combobox/combobox.test.tsx +++ b/packages/ui-components/src/components/combobox/combobox.test.tsx @@ -1,7 +1,7 @@ import type React from 'react'; import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { userEvent } from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { renderWithProviders } from '#src/tests/render.test-helper.js'; diff --git a/plugins/plugin-auth/src/auth/core/generators/auth-email-password/auth-email-password.generator.ts b/plugins/plugin-auth/src/auth/core/generators/auth-email-password/auth-email-password.generator.ts index f7d608b2a..eb47a3743 100644 --- a/plugins/plugin-auth/src/auth/core/generators/auth-email-password/auth-email-password.generator.ts +++ b/plugins/plugin-auth/src/auth/core/generators/auth-email-password/auth-email-password.generator.ts @@ -2,7 +2,7 @@ import { appModuleProvider } from '@baseplate-dev/fastify-generators'; import { createGenerator, createGeneratorTask } from '@baseplate-dev/sync'; import { z } from 'zod'; -import { AUTH_CORE_AUTH_EMAIL_PASSWORD_GENERATED as GENERATED_TEMPLATES } from './generated'; +import { AUTH_CORE_AUTH_EMAIL_PASSWORD_GENERATED as GENERATED_TEMPLATES } from './generated/index.js'; const descriptorSchema = z.object({}); diff --git a/plugins/plugin-auth/src/auth/core/generators/auth-module/auth-module.generator.ts b/plugins/plugin-auth/src/auth/core/generators/auth-module/auth-module.generator.ts index 3541764ab..a7c728d28 100644 --- a/plugins/plugin-auth/src/auth/core/generators/auth-module/auth-module.generator.ts +++ b/plugins/plugin-auth/src/auth/core/generators/auth-module/auth-module.generator.ts @@ -12,7 +12,7 @@ import { } from '@baseplate-dev/sync'; import { z } from 'zod'; -import { AUTH_CORE_AUTH_MODULE_GENERATED as GENERATED_TEMPLATES } from './generated'; +import { AUTH_CORE_AUTH_MODULE_GENERATED as GENERATED_TEMPLATES } from './generated/index.js'; const descriptorSchema = z.object({ userSessionModelName: z.string().min(1), diff --git a/plugins/plugin-auth/src/auth/core/generators/auth-routes/auth-routes.generator.ts b/plugins/plugin-auth/src/auth/core/generators/auth-routes/auth-routes.generator.ts index c4496c286..12fcfad15 100644 --- a/plugins/plugin-auth/src/auth/core/generators/auth-routes/auth-routes.generator.ts +++ b/plugins/plugin-auth/src/auth/core/generators/auth-routes/auth-routes.generator.ts @@ -2,7 +2,7 @@ import { renderTextTemplateFileAction } from '@baseplate-dev/core-generators'; import { createGenerator, createGeneratorTask } from '@baseplate-dev/sync'; import { z } from 'zod'; -import { AUTH_CORE_AUTH_ROUTES_GENERATED as GENERATED_TEMPLATES } from './generated'; +import { AUTH_CORE_AUTH_ROUTES_GENERATED as GENERATED_TEMPLATES } from './generated/index.js'; const descriptorSchema = z.object({}); diff --git a/plugins/plugin-auth/src/auth/core/generators/react-session/react-session.generator.ts b/plugins/plugin-auth/src/auth/core/generators/react-session/react-session.generator.ts index 196b6e203..63a05384b 100644 --- a/plugins/plugin-auth/src/auth/core/generators/react-session/react-session.generator.ts +++ b/plugins/plugin-auth/src/auth/core/generators/react-session/react-session.generator.ts @@ -2,7 +2,7 @@ import { renderTextTemplateFileAction } from '@baseplate-dev/core-generators'; import { createGenerator, createGeneratorTask } from '@baseplate-dev/sync'; import { z } from 'zod'; -import { AUTH_CORE_REACT_SESSION_GENERATED as GENERATED_TEMPLATES } from './generated'; +import { AUTH_CORE_REACT_SESSION_GENERATED as GENERATED_TEMPLATES } from './generated/index.js'; const descriptorSchema = z.object({}); diff --git a/plugins/plugin-auth/src/auth0/generators/react/react-auth0/react-auth0.generator.ts b/plugins/plugin-auth/src/auth0/generators/react/react-auth0/react-auth0.generator.ts index 16b99ca07..f09d728f1 100644 --- a/plugins/plugin-auth/src/auth0/generators/react/react-auth0/react-auth0.generator.ts +++ b/plugins/plugin-auth/src/auth0/generators/react/react-auth0/react-auth0.generator.ts @@ -23,7 +23,7 @@ import { z } from 'zod'; import { AUTH0_PACKAGES } from '#src/auth0/constants/packages.js'; -import { AUTH0_REACT_AUTH0_GENERATED } from './generated'; +import { AUTH0_REACT_AUTH0_GENERATED } from './generated/index.js'; const descriptorSchema = z.object({}); diff --git a/plugins/plugin-auth/src/common/compiler/generator-creators.ts b/plugins/plugin-auth/src/common/compiler/generator-creators.ts index 97f480864..b68c4fc51 100644 --- a/plugins/plugin-auth/src/common/compiler/generator-creators.ts +++ b/plugins/plugin-auth/src/common/compiler/generator-creators.ts @@ -9,7 +9,7 @@ import { } from '@baseplate-dev/fastify-generators'; import { authIdentifyGenerator } from '@baseplate-dev/react-generators'; -import type { AuthRoleDefinition } from '../roles'; +import type { AuthRoleDefinition } from '../roles/index.js'; type BackendAuthGenerators = | 'authContext' diff --git a/plugins/plugin-auth/src/placeholder-auth/core/generators/placeholder-auth-module/auth-module.generator.ts b/plugins/plugin-auth/src/placeholder-auth/core/generators/placeholder-auth-module/auth-module.generator.ts index 092b4931e..953b590e6 100644 --- a/plugins/plugin-auth/src/placeholder-auth/core/generators/placeholder-auth-module/auth-module.generator.ts +++ b/plugins/plugin-auth/src/placeholder-auth/core/generators/placeholder-auth-module/auth-module.generator.ts @@ -1,7 +1,7 @@ import { createGenerator, createGeneratorTask } from '@baseplate-dev/sync'; import { z } from 'zod'; -import { PLACEHOLDER_AUTH_CORE_PLACEHOLDER_AUTH_MODULE_GENERATED as GENERATED_TEMPLATES } from './generated'; +import { PLACEHOLDER_AUTH_CORE_PLACEHOLDER_AUTH_MODULE_GENERATED as GENERATED_TEMPLATES } from './generated/index.js'; const descriptorSchema = z.object({}); diff --git a/plugins/plugin-storage/src/storage/transformers/components/file-transformer-form.tsx b/plugins/plugin-storage/src/storage/transformers/components/file-transformer-form.tsx index fdacac041..4b8f547d2 100644 --- a/plugins/plugin-storage/src/storage/transformers/components/file-transformer-form.tsx +++ b/plugins/plugin-storage/src/storage/transformers/components/file-transformer-form.tsx @@ -7,7 +7,7 @@ import { SelectFieldController } from '@baseplate-dev/ui-components'; import type { StoragePluginDefinition } from '#src/storage/core/schema/plugin-definition.js'; -import type { FileTransformerConfig } from '../types'; +import type { FileTransformerConfig } from '../types.js'; import '#src/styles.css'; From 6aba93bcb104e79eac3d7f49a79c63fb9854573a Mon Sep 17 00:00:00 2001 From: Kingston Tam Date: Fri, 11 Jul 2025 12:26:28 +0200 Subject: [PATCH 04/22] Remove unnecessary module directive --- packages/project-builder-lib/tsconfig.json | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/project-builder-lib/tsconfig.json b/packages/project-builder-lib/tsconfig.json index d3c07f1ff..606c09079 100644 --- a/packages/project-builder-lib/tsconfig.json +++ b/packages/project-builder-lib/tsconfig.json @@ -3,9 +3,7 @@ "compilerOptions": { "outDir": "dist", "rootDir": "src", - "noEmit": true, - "module": "Node16", - "moduleResolution": "node16" + "noEmit": true }, "include": ["src/**/*"] } From 85156da4890c4e6f821311d5d499259070d95469 Mon Sep 17 00:00:00 2001 From: Kingston Tam Date: Fri, 11 Jul 2025 12:30:22 +0200 Subject: [PATCH 05/22] Fix one last reference --- packages/ui-components/.storybook/preview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui-components/.storybook/preview.tsx b/packages/ui-components/.storybook/preview.tsx index 5253271bf..81c61608b 100644 --- a/packages/ui-components/.storybook/preview.tsx +++ b/packages/ui-components/.storybook/preview.tsx @@ -10,7 +10,7 @@ import { DARK_MODE_EVENT_NAME } from 'storybook-dark-mode'; import { Toaster } from '../src/components/toaster/toaster.js'; import CustomTheme from './custom-theme.js'; -import { isDarkModeEnabled, setDarkModeEnabled } from './dark-mode'; +import { isDarkModeEnabled, setDarkModeEnabled } from './dark-mode.js'; import '../src/styles.css'; From ac10e0326de8d757cb0c3affbe6b781b8998c25b Mon Sep 17 00:00:00 2001 From: Kingston Tam Date: Fri, 11 Jul 2025 22:15:53 +0200 Subject: [PATCH 06/22] Add exception for filename casing for $ and - router paths --- .../src/generators/node/eslint/react-rules.ts | 10 ++++++++++ packages/tools/eslint-configs/react.js | 7 +++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/core-generators/src/generators/node/eslint/react-rules.ts b/packages/core-generators/src/generators/node/eslint/react-rules.ts index f021176ea..5b6c43a89 100644 --- a/packages/core-generators/src/generators/node/eslint/react-rules.ts +++ b/packages/core-generators/src/generators/node/eslint/react-rules.ts @@ -50,6 +50,16 @@ export const REACT_ESLINT_RULES = tsCodeFragment( rules: { // We use replace since it is not supported by ES2020 'unicorn/prefer-string-replace-all': 'off', + // Support kebab case with - prefix to support ignored files in routes and $ prefix for Tanstack camelCase files + 'unicorn/filename-case': [ + 'error', + { + cases: { + kebabCase: true, + }, + ignore: [String.raw\`^-[a-z0-9\\-\\.]+$\`, String.raw\`^\\$[a-zA-Z0-9\\.]+$\`], + }, + ], }, }, `, diff --git a/packages/tools/eslint-configs/react.js b/packages/tools/eslint-configs/react.js index d8ebaffdd..d297a3ef8 100644 --- a/packages/tools/eslint-configs/react.js +++ b/packages/tools/eslint-configs/react.js @@ -63,14 +63,17 @@ export const reactEslintConfig = tsEslint.config( rules: { // We use replace since it is not supported by ES2020 'unicorn/prefer-string-replace-all': 'off', - // Support kebab case with - prefix to support ignored files in routes + // Support kebab case with - prefix to support ignored files in routes and $ prefix for Tanstack camelCase files 'unicorn/filename-case': [ 'error', { cases: { kebabCase: true, }, - ignore: [String.raw`^-[a-z0-9\-\.]+$`], + ignore: [ + String.raw`^-[a-z0-9\-\.]+$`, + String.raw`^\$[a-zA-Z0-9\.]+$`, + ], }, ], }, From 923265925e45309071cc385bfb98c76278311bd9 Mon Sep 17 00:00:00 2001 From: Kingston Tam Date: Fri, 11 Jul 2025 22:20:19 +0200 Subject: [PATCH 07/22] Incorporate updated storage plugin adapters --- .changeset/short-vans-judge.md | 6 + .../fastify/storage-module/extractor.json | 16 +- .../generated/template-paths.ts | 4 +- .../generated/typed-templates.ts | 19 +- .../storage-module.generator.ts | 4 +- .../templates/module/adapters/s3.ts | 175 +++++++++++++---- .../templates/module/adapters/types.ts | 176 +++++++++++++++--- .../templates/module/adapters/url.ts | 29 ++- ...osted-url.field.ts => public-url.field.ts} | 4 +- .../services/create-presigned-upload-url.ts | 22 ++- .../module/services/download-file.ts | 6 - .../templates/module/services/upload-file.ts | 13 +- .../src/components/file-input/file-input.tsx | 14 +- .../src/components/file-input/upload.gql | 2 +- 14 files changed, 368 insertions(+), 122 deletions(-) create mode 100644 .changeset/short-vans-judge.md rename plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/{hosted-url.field.ts => public-url.field.ts} (84%) diff --git a/.changeset/short-vans-judge.md b/.changeset/short-vans-judge.md new file mode 100644 index 000000000..65ac70a1f --- /dev/null +++ b/.changeset/short-vans-judge.md @@ -0,0 +1,6 @@ +--- +'@baseplate-dev/react-generators': patch +'@baseplate-dev/tools': patch +--- + +Add exception for filename casing for $ and - router paths diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/extractor.json b/plugins/plugin-storage/src/generators/fastify/storage-module/extractor.json index cfb3b4d0a..08083f627 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/extractor.json +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/extractor.json @@ -85,7 +85,7 @@ "sourceFile": "module/schema/file-upload.input-type.ts", "variables": {} }, - "schema-hosted-url-field": { + "schema-presigned-mutations": { "type": "ts", "fileOptions": { "kind": "singleton" }, "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", @@ -96,11 +96,11 @@ "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/pothos/pothos/generated/ts-import-providers.ts" } }, - "pathRootRelativePath": "{module-root}/schema/hosted-url.field.ts", - "sourceFile": "module/schema/hosted-url.field.ts", + "pathRootRelativePath": "{module-root}/schema/presigned.mutations.ts", + "sourceFile": "module/schema/presigned.mutations.ts", "variables": { "TPL_FILE_OBJECT_TYPE": {} } }, - "schema-presigned-mutations": { + "schema-public-url-field": { "type": "ts", "fileOptions": { "kind": "singleton" }, "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", @@ -111,8 +111,8 @@ "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/pothos/pothos/generated/ts-import-providers.ts" } }, - "pathRootRelativePath": "{module-root}/schema/presigned.mutations.ts", - "sourceFile": "module/schema/presigned.mutations.ts", + "pathRootRelativePath": "{module-root}/schema/public-url.field.ts", + "sourceFile": "module/schema/public-url.field.ts", "variables": { "TPL_FILE_OBJECT_TYPE": {} } }, "services-create-presigned-download-url": { @@ -184,10 +184,6 @@ "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", "group": "services", "importMapProviders": { - "errorHandlerServiceImportsProvider": { - "importName": "errorHandlerServiceImportsProvider", - "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/core/error-handler-service/generated/ts-import-providers.ts" - }, "serviceContextImportsProvider": { "importName": "serviceContextImportsProvider", "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/core/service-context/generated/ts-import-providers.ts" diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/generated/template-paths.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/generated/template-paths.ts index 9717e73ae..62c91145a 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/generated/template-paths.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/generated/template-paths.ts @@ -9,8 +9,8 @@ export interface FastifyStorageModulePaths { constantsAdapters: string; constantsFileCategories: string; schemaFileUploadInputType: string; - schemaHostedUrlField: string; schemaPresignedMutations: string; + schemaPublicUrlField: string; servicesCreatePresignedDownloadUrl: string; servicesCreatePresignedUploadUrl: string; servicesDownloadFile: string; @@ -41,8 +41,8 @@ const fastifyStorageModulePathsTask = createGeneratorTask({ constantsAdapters: `${moduleRoot}/constants/adapters.ts`, constantsFileCategories: `${moduleRoot}/constants/file-categories.ts`, schemaFileUploadInputType: `${moduleRoot}/schema/file-upload.input-type.ts`, - schemaHostedUrlField: `${moduleRoot}/schema/hosted-url.field.ts`, schemaPresignedMutations: `${moduleRoot}/schema/presigned.mutations.ts`, + schemaPublicUrlField: `${moduleRoot}/schema/public-url.field.ts`, servicesCreatePresignedDownloadUrl: `${moduleRoot}/services/create-presigned-download-url.ts`, servicesCreatePresignedUploadUrl: `${moduleRoot}/services/create-presigned-upload-url.ts`, servicesDownloadFile: `${moduleRoot}/services/download-file.ts`, diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/generated/typed-templates.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/generated/typed-templates.ts index 19b6e7345..00267842a 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/generated/typed-templates.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/generated/typed-templates.ts @@ -112,29 +112,29 @@ const schemaFileUploadInputType = createTsTemplateFile({ variables: {}, }); -const schemaHostedUrlField = createTsTemplateFile({ +const schemaPresignedMutations = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, group: 'schema', importMapProviders: { pothosImports: pothosImportsProvider }, - name: 'schema-hosted-url-field', + name: 'schema-presigned-mutations', source: { path: path.join( import.meta.dirname, - '../templates/module/schema/hosted-url.field.ts', + '../templates/module/schema/presigned.mutations.ts', ), }, variables: { TPL_FILE_OBJECT_TYPE: {} }, }); -const schemaPresignedMutations = createTsTemplateFile({ +const schemaPublicUrlField = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, group: 'schema', importMapProviders: { pothosImports: pothosImportsProvider }, - name: 'schema-presigned-mutations', + name: 'schema-public-url-field', source: { path: path.join( import.meta.dirname, - '../templates/module/schema/presigned.mutations.ts', + '../templates/module/schema/public-url.field.ts', ), }, variables: { TPL_FILE_OBJECT_TYPE: {} }, @@ -142,8 +142,8 @@ const schemaPresignedMutations = createTsTemplateFile({ export const schemaGroup = { schemaFileUploadInputType, - schemaHostedUrlField, schemaPresignedMutations, + schemaPublicUrlField, }; const servicesCreatePresignedDownloadUrl = createTsTemplateFile({ @@ -206,10 +206,7 @@ const servicesDownloadFile = createTsTemplateFile({ const servicesUploadFile = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, group: 'services', - importMapProviders: { - errorHandlerServiceImports: errorHandlerServiceImportsProvider, - serviceContextImports: serviceContextImportsProvider, - }, + importMapProviders: { serviceContextImports: serviceContextImportsProvider }, name: 'services-upload-file', projectExports: { uploadFile: {} }, source: { diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/storage-module.generator.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/storage-module.generator.ts index 0aa38b79a..40c3e727d 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/storage-module.generator.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/storage-module.generator.ts @@ -209,7 +209,7 @@ export const storageModuleGenerator = createGenerator({ pothosImports, }, variables: { - schemaHostedUrlField: { + schemaPublicUrlField: { TPL_FILE_OBJECT_TYPE: fileObjectRef.fragment, }, schemaPresignedMutations: { @@ -281,7 +281,7 @@ export const storageModuleGenerator = createGenerator({ const adapterOptions = TsCodeUtils.mergeFragmentsAsObject({ bucket: `config.${adapter.bucketConfigVar}`, region: `config.AWS_DEFAULT_REGION`, - hostedUrl: adapter.hostedUrlConfigVar + publicUrl: adapter.hostedUrlConfigVar ? `config.${adapter.hostedUrlConfigVar}` : undefined, }); diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/s3.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/s3.ts index ea001bc7a..26dea88ac 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/s3.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/s3.ts @@ -3,8 +3,10 @@ import type { Readable } from 'node:stream'; import { + DeleteObjectCommand, DeleteObjectsCommand, GetObjectCommand, + HeadObjectCommand, PutObjectCommand, S3Client, } from '@aws-sdk/client-s3'; @@ -12,73 +14,103 @@ import { createPresignedPost } from '@aws-sdk/s3-presigned-post'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import type { - AdapterPresignedUploadUrlInput, - AdapterPresignedUploadUrlPayload, + CreatePresignedUploadOptions, + FileMetadata, + PresignedUploadUrl, StorageAdapter, } from './types.js'; +/** Options for the S3 adapter. */ interface S3AdapterOptions { + /** AWS region of the bucket. */ region?: string; - /** - * Publicly hosted URL for the S3 bucket, e.g. https://uploads.example.com - */ - hostedUrl?: string; + /** Name of the S3 bucket. */ bucket: string; + /** Publicly hosted URL for the S3 bucket, e.g. https://uploads.example.com */ + publicUrl?: string; } const PRESIGNED_S3_EXPIRATION_SECONDS = 600; +/** + * Create a new S3 adapter. + * + * @param options - Options for the S3 adapter. + * @returns A new S3 adapter. + */ export const createS3Adapter = (options: S3AdapterOptions): StorageAdapter => { - const { region, hostedUrl, bucket } = options; + const { region, publicUrl, bucket } = options; const client = new S3Client({ region }); async function createPresignedUploadUrl( - input: AdapterPresignedUploadUrlInput, - ): Promise { - const { path, contentType, minFileSize, maxFileSize } = input; + options: CreatePresignedUploadOptions, + ): Promise { + const { path, contentType, contentLengthRange, expiresIn } = options; + const [minFileSize, maxFileSize] = contentLengthRange ?? [ + 0, + 1024 * 1024 * 100, + ]; // Default 100MB max + const expirationSeconds = expiresIn ?? PRESIGNED_S3_EXPIRATION_SECONDS; const { url, fields } = await createPresignedPost(client, { Bucket: bucket, Key: path, Conditions: [ - ['content-length-range', minFileSize ?? 0, maxFileSize], + ['content-length-range', minFileSize, maxFileSize], { bucket }, { key: path }, ...(contentType ? [{ 'Content-Type': contentType }] : []), ], - Expires: PRESIGNED_S3_EXPIRATION_SECONDS, + Expires: expirationSeconds, }); return { method: 'POST', url, - fields: Object.entries(fields).map(([name, value]) => ({ name, value })), + fields, + expiresAt: new Date(Date.now() + expirationSeconds * 1000), }; } - async function createPresignedDownloadUrl(path: string): Promise { + async function createPresignedDownloadUrl( + path: string, + expiresIn?: number, + ): Promise { const command = new GetObjectCommand({ Bucket: bucket, Key: path, }); return getSignedUrl(client, command, { - expiresIn: PRESIGNED_S3_EXPIRATION_SECONDS, + expiresIn: expiresIn ?? PRESIGNED_S3_EXPIRATION_SECONDS, }); } - function getHostedUrl(path: string): string | null { - if (!hostedUrl) { + function getPublicUrl(path: string): string | null { + if (!publicUrl) { return null; } - return `${hostedUrl.replace(/\/$/, '')}/${path}`; + return `${publicUrl.replace(/\/$/, '')}/${path}`; } - async function deleteFiles(paths: string[]): Promise { + async function deleteFile(path: string): Promise { + const command = new DeleteObjectCommand({ + Bucket: bucket, + Key: path, + }); + + await client.send(command); + } + + async function deleteFiles(paths: string[]): Promise<{ + succeeded: string[]; + failed: { path: string; error: Error }[]; + }> { if (paths.length > 1000) { throw new Error('Cannot delete more than 1000 files at once'); } + const command = new DeleteObjectsCommand({ Bucket: bucket, Delete: { @@ -87,29 +119,93 @@ export const createS3Adapter = (options: S3AdapterOptions): StorageAdapter => { }); const response = await client.send(command); + const succeeded: string[] = []; + const failed: { path: string; error: Error }[] = []; - // for now, if we encounter a single error, throw the entire operation - // TODO: handle partial failures - if (response.Errors?.length) { - const error = response.Errors[0]; - throw new Error( - `Unable to delete key: ${error.Key ?? ''}, ${error.Message ?? ''}`, + if (response.Deleted) { + succeeded.push( + ...response.Deleted.map((obj) => obj.Key ?? '').filter(Boolean), ); } + + if (response.Errors) { + failed.push( + ...response.Errors.map((error) => ({ + path: error.Key ?? '', + error: new Error(error.Message ?? 'Unknown error'), + })), + ); + } + + return { succeeded, failed }; } - async function uploadFile( - path: string, - contents: Buffer | ReadableStream | string, - ): Promise { - await client.send( - new PutObjectCommand({ + async function fileExists(path: string): Promise { + try { + const command = new HeadObjectCommand({ Bucket: bucket, Key: path, - Body: contents, - ServerSideEncryption: 'AES256', - }), - ); + }); + await client.send(command); + return true; + } catch (error: unknown) { + const err = error as { + name?: string; + $metadata?: { httpStatusCode?: number }; + }; + if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) { + return false; + } + throw error; + } + } + + async function getFileMetadata(path: string): Promise { + try { + const command = new HeadObjectCommand({ + Bucket: bucket, + Key: path, + }); + const response = await client.send(command); + + return { + size: response.ContentLength ?? 0, + contentType: response.ContentType ?? 'application/octet-stream', + lastModified: response.LastModified ?? new Date(), + etag: response.ETag?.replace(/"/g, ''), + }; + } catch (error: unknown) { + const err = error as { + name?: string; + $metadata?: { httpStatusCode?: number }; + }; + if (err.name === 'NotFound' || err.$metadata?.httpStatusCode === 404) { + return null; + } + throw error; + } + } + + async function uploadFile( + path: string, + contents: Buffer | Readable, + ): Promise { + const command = new PutObjectCommand({ + Bucket: bucket, + Key: path, + Body: contents, + ServerSideEncryption: 'AES256', + }); + + await client.send(command); + + // Get metadata after upload to return accurate information + const metadata = await getFileMetadata(path); + if (!metadata) { + throw new Error('Failed to get file metadata after upload'); + } + + return metadata; } async function downloadFile(path: string): Promise { @@ -120,14 +216,21 @@ export const createS3Adapter = (options: S3AdapterOptions): StorageAdapter => { const response = await client.send(command); + if (!response.Body) { + throw new Error(`File ${path} not found or empty`); + } + return response.Body as Readable; } return { createPresignedUploadUrl, createPresignedDownloadUrl, - getHostedUrl, + getPublicUrl, + deleteFile, deleteFiles, + fileExists, + getFileMetadata, uploadFile, downloadFile, }; diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/types.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/types.ts index 30dede812..d4c98f98d 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/types.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/types.ts @@ -2,34 +2,166 @@ import type { Readable } from 'node:stream'; -export interface PresignedUrlField { - name: string; - value: string; +/** + * Metadata about a stored file + */ +export interface FileMetadata { + /** File size in bytes */ + size: number; + /** MIME type of the file */ + contentType: string; + /** Last modification timestamp */ + lastModified: Date; + /** Entity tag for caching (if supported by adapter) */ + etag?: string; } -export interface AdapterPresignedUploadUrlPayload { - url: string; - method: 'POST' | 'PUT'; - fields?: PresignedUrlField[]; -} - -export interface AdapterPresignedUploadUrlInput { +/** + * Configuration for creating a presigned upload URL + */ +export interface CreatePresignedUploadOptions { + /** Storage path where the file will be uploaded */ path: string; + /** Expected MIME type of the upload */ contentType?: string; - minFileSize?: number; - maxFileSize: number; + /** Allowed file size range in bytes [min, max] */ + contentLengthRange?: [min: number, max: number]; + /** URL expiration time in seconds (default: 3600) */ + expiresIn?: number; +} + +/** + * Presigned upload URL details + */ +export interface PresignedUploadUrl { + /** The URL to upload to */ + url: string; + /** HTTP method to use for upload */ + method: 'POST' | 'PUT'; + /** Form fields to include with POST requests (empty for PUT) */ + fields: Record; + /** When this presigned URL expires */ + expiresAt: Date; } +/** + * Storage adapter interface for file operations. + * Implementations may use S3, local filesystem, GCS, etc. + */ export interface StorageAdapter { - createPresignedUploadUrl?: ( - input: AdapterPresignedUploadUrlInput, - ) => Promise; - createPresignedDownloadUrl?: (path: string) => Promise; - getHostedUrl?: (path: string) => string | null; - deleteFiles?: (paths: string[]) => Promise; - uploadFile?: ( + /** + * Upload a file to storage + * + * @param path - Storage path (e.g., 'uploads/avatars/user123.jpg') + * @param contents - File contents as Buffer or Readable stream + * @returns Metadata about the uploaded file + * @throws {Error} If upload fails + * + * @example + * const metadata = await adapter.uploadFile( + * 'uploads/avatar.jpg', + * Buffer.from(imageData) + * ); + */ + uploadFile(path: string, contents: Buffer | Readable): Promise; + + /** + * Download a file from storage + * + * @param path - Storage path of the file + * @returns Readable stream of file contents + * @throws {Error} If file doesn't exist or download fails + * + * @example + * const stream = await adapter.downloadFile('uploads/avatar.jpg'); + * stream.pipe(response); + */ + downloadFile(path: string): Promise; + + /** + * Delete a single file from storage + * + * @param path - Storage path of the file to delete + * @throws {Error} If deletion fails (file not existing is not an error) + */ + deleteFile?(path: string): Promise; + + /** + * Delete multiple files from storage + * + * @param paths - Array of storage paths to delete + * @returns Results indicating which deletions succeeded/failed + * + * @example + * const results = await adapter.deleteFiles(['file1.jpg', 'file2.jpg']); + * console.log(`Deleted: ${results.succeeded.length} files`); + * console.log(`Failed: ${results.failed.length} files`); + */ + deleteFiles?(paths: string[]): Promise<{ + succeeded: string[]; + failed: { path: string; error: Error }[]; + }>; + + /** + * Check if a file exists in storage + * + * @param path - Storage path to check + * @returns true if file exists, false otherwise + */ + fileExists(path: string): Promise; + + /** + * Get metadata about a file without downloading it + * + * @param path - Storage path of the file + * @returns File metadata or null if file doesn't exist + */ + getFileMetadata(path: string): Promise; + + /** + * Create a presigned URL for direct browser uploads. + * Only implement if adapter supports presigned URLs (e.g., S3). + * + * @param options - Upload configuration + * @returns Presigned URL details for client-side upload + * @throws {Error} If adapter doesn't support presigned URLs + * + * @example + * const presigned = await adapter.createPresignedUploadUrl({ + * path: 'uploads/document.pdf', + * contentType: 'application/pdf', + * contentLengthRange: [1024, 10485760], // 1KB to 10MB + * expiresIn: 300 // 5 minutes + * }); + */ + createPresignedUploadUrl?( + options: CreatePresignedUploadOptions, + ): Promise; + + /** + * Create a presigned URL for temporary file access. + * Only implement if adapter supports presigned URLs. + * + * @param path - Storage path of the file + * @param expiresIn - URL expiration in seconds (default: 3600) + * @returns Temporary download URL + * @throws {Error} If file doesn't exist or adapter doesn't support presigned URLs + */ + createPresignedDownloadUrl?( path: string, - contents: Buffer | ReadableStream | string, - ) => Promise; - downloadFile?: (path: string) => Promise; + expiresIn?: number, + ): Promise; + + /** + * Get a permanent public URL for a file. + * Only implement if adapter supports public URLs (e.g., CDN). + * + * @param path - Storage path of the file + * @returns Public URL or null if not publicly accessible + * + * @example + * const publicUrl = adapter.getPublicUrl('assets/logo.png'); + * // Returns: 'https://cdn.example.com/assets/logo.png' + */ + getPublicUrl?(path: string): string | null; } diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/url.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/url.ts index f4b847e7a..af4ec246a 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/url.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/url.ts @@ -1,15 +1,34 @@ // @ts-nocheck +import type { Readable } from 'node:stream'; + +import axios from 'axios'; + import type { StorageAdapter } from './types.js'; /** * Minimal adapter that just converts path to URL directly. + * This adapter is primarily useful for testing or when you want to use + * external URLs as storage paths without actual file operations. */ export const createUrlAdapter = (): StorageAdapter => ({ - getHostedUrl(path) { - return path; - }, - createPresignedDownloadUrl(path) { - return Promise.resolve(path); + uploadFile: () => + Promise.reject(new Error('URL adapter does not support file uploads')), + downloadFile: async (path: string): Promise => { + const response = await axios.get(path, { + responseType: 'stream', + method: 'GET', + }); + return response.data; }, + fileExists: () => + Promise.reject( + new Error('URL adapter does not support file existence checks'), + ), + getFileMetadata: () => + Promise.reject( + new Error('URL adapter does not support file metadata retrieval'), + ), + getPublicUrl: (path: string) => path, + createPresignedDownloadUrl: (path: string) => Promise.resolve(path), }); diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/hosted-url.field.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/public-url.field.ts similarity index 84% rename from plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/hosted-url.field.ts rename to plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/public-url.field.ts index affcdce28..049d58982 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/hosted-url.field.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/public-url.field.ts @@ -6,7 +6,7 @@ import type { StorageAdapterKey } from '../constants/adapters.js'; import { STORAGE_ADAPTERS } from '../constants/adapters.js'; -builder.objectField(TPL_FILE_OBJECT_TYPE, 'hostedUrl', (t) => +builder.objectField(TPL_FILE_OBJECT_TYPE, 'publicUrl', (t) => t.string({ description: 'URL of the file where it is publicly hosted. Returns null if it is not publicly available.', @@ -16,7 +16,7 @@ builder.objectField(TPL_FILE_OBJECT_TYPE, 'hostedUrl', (t) => throw new Error(`Unknown adapter ${adapterName}`); } const adapter = STORAGE_ADAPTERS[adapterName as StorageAdapterKey]; - return adapter.getHostedUrl?.(path) ?? null; + return adapter.getPublicUrl?.(path) ?? null; }, }), ); diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/create-presigned-upload-url.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/create-presigned-upload-url.ts index f397e3046..83a40e9b1 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/create-presigned-upload-url.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/create-presigned-upload-url.ts @@ -4,15 +4,17 @@ import type { ServiceContext } from '%serviceContextImports'; import { BadRequestError } from '%errorHandlerServiceImports'; -import type { AdapterPresignedUploadUrlPayload } from '../adapters/index.js'; import type { UploadDataInput } from '../utils/upload.js'; import { prepareUploadData } from '../utils/upload.js'; type CreatePresignedUploadUrlInput = UploadDataInput; -export interface CreatePresignedUploadUrlPayload - extends AdapterPresignedUploadUrlPayload { +export interface CreatePresignedUploadUrlPayload { + url: string; + method: string; + fields?: { name: string; value: string }[]; + expiresAt: Date; file: TPL_FILE_MODEL_TYPE; } @@ -35,13 +37,21 @@ export async function createPresignedUploadUrl( const result = await adapter.createPresignedUploadUrl({ path: data.path, - minFileSize: fileCategory.minFileSize, - maxFileSize: fileCategory.maxFileSize, + contentLengthRange: [ + fileCategory.minFileSize ?? 0, + fileCategory.maxFileSize, + ], contentType: data.mimeType, }); return { - ...result, + url: result.url, + method: result.method, + fields: Object.entries(result.fields).map(([name, value]) => ({ + name, + value, + })), + expiresAt: result.expiresAt, file, }; } diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/download-file.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/download-file.ts index 860d4738a..b861445b6 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/download-file.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/download-file.ts @@ -38,11 +38,5 @@ export async function downloadFile( const adapter = STORAGE_ADAPTERS[file.adapter as keyof typeof STORAGE_ADAPTERS]; - if (!adapter.downloadFile) { - throw new Error( - `Storage adapter ${file.adapter} does not support downloading`, - ); - } - return adapter.downloadFile(file.path); } diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/upload-file.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/upload-file.ts index 396050824..80a62f9a0 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/upload-file.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/upload-file.ts @@ -2,8 +2,6 @@ import type { ServiceContext } from '%serviceContextImports'; -import { BadRequestError } from '%errorHandlerServiceImports'; - import type { UploadDataInput } from '../utils/upload.js'; import { prepareUploadData } from '../utils/upload.js'; @@ -16,16 +14,7 @@ export async function uploadFile( input: UploadFileInput, context: ServiceContext, ): Promise { - const { data, fileCategory, adapter } = await prepareUploadData( - input, - context, - ); - - if (!adapter.uploadFile) { - throw new BadRequestError( - `Adapter for ${fileCategory.name} does not support uploadFile`, - ); - } + const { data, adapter } = await prepareUploadData(input, context); const file = await TPL_FILE_MODEL.create({ data }); diff --git a/plugins/plugin-storage/src/generators/react/upload-components/templates/src/components/file-input/file-input.tsx b/plugins/plugin-storage/src/generators/react/upload-components/templates/src/components/file-input/file-input.tsx index 35d309b91..06d0bac60 100644 --- a/plugins/plugin-storage/src/generators/react/upload-components/templates/src/components/file-input/file-input.tsx +++ b/plugins/plugin-storage/src/generators/react/upload-components/templates/src/components/file-input/file-input.tsx @@ -20,7 +20,7 @@ import { useUpload } from '../../hooks/use-upload.js'; export interface FileUploadInput { id: string; name: string; - hostedUrl?: string | null; + publicUrl?: string | null; } export interface FileInputProps { @@ -88,7 +88,7 @@ export function FileInput({ onChange({ name: uploadedFile.name, id: uploadedFile.id, - hostedUrl: uploadedFile.hostedUrl, + publicUrl: uploadedFile.publicUrl, }); } }, @@ -140,24 +140,24 @@ export function FileInput({ >
- {imagePreview && value.hostedUrl && ( + {imagePreview && value.publicUrl && ( {`Preview )}
- {value.hostedUrl ? ( + {value.publicUrl ? ( Date: Sat, 12 Jul 2025 10:18:37 +0200 Subject: [PATCH 08/22] Add json deep clone function --- .changeset/plenty-mails-refuse.md | 5 + packages/utils/src/json/json-deep-clone.ts | 52 +++++ .../src/json/json-deep-clone.unit.test.ts | 188 ++++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 .changeset/plenty-mails-refuse.md create mode 100644 packages/utils/src/json/json-deep-clone.ts create mode 100644 packages/utils/src/json/json-deep-clone.unit.test.ts diff --git a/.changeset/plenty-mails-refuse.md b/.changeset/plenty-mails-refuse.md new file mode 100644 index 000000000..f753961a3 --- /dev/null +++ b/.changeset/plenty-mails-refuse.md @@ -0,0 +1,5 @@ +--- +'@baseplate-dev/utils': patch +--- + +Add JSON deep clone function diff --git a/packages/utils/src/json/json-deep-clone.ts b/packages/utils/src/json/json-deep-clone.ts new file mode 100644 index 000000000..303196a3b --- /dev/null +++ b/packages/utils/src/json/json-deep-clone.ts @@ -0,0 +1,52 @@ +/** + * Recursively deep clones JSON-compatible values. + * Throws if the value is not JSON-serializable. + * + * @param value - The value to clone + * @returns A deep-cloned copy of the value + * @throws Error if a non-JSON value is encountered + */ +export function jsonDeepClone(value: T): T { + if ( + value === undefined || + value === null || + typeof value === 'boolean' || + typeof value === 'string' + ) { + return value; + } + + if (typeof value === 'number') { + return value; + } + + if (typeof value === 'object') { + if (value instanceof Date || value instanceof RegExp) { + throw new TypeError( + `Cannot clone value of unsupported type: ${value.constructor.name}`, + ); + } + + if (Array.isArray(value)) { + // Clone array elements + const copy: unknown[] = []; + for (const [i, element] of value.entries()) { + copy[i] = jsonDeepClone(element); + } + return copy as T; + } + + // Clone plain object properties + const copy: Record = {}; + for (const key in value) { + if (Object.prototype.hasOwnProperty.call(value, key)) { + const prop = (value as Record)[key]; + copy[key] = jsonDeepClone(prop); + } + } + return copy as T; + } + + // Reject functions, symbols, BigInt, etc. + throw new Error(`Cannot clone value of unsupported type: ${typeof value}`); +} diff --git a/packages/utils/src/json/json-deep-clone.unit.test.ts b/packages/utils/src/json/json-deep-clone.unit.test.ts new file mode 100644 index 000000000..7c0ed5192 --- /dev/null +++ b/packages/utils/src/json/json-deep-clone.unit.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest'; + +import { jsonDeepClone } from './json-deep-clone.js'; + +describe('jsonDeepClone', () => { + describe('primitive values', () => { + it('should clone null', () => { + const result = jsonDeepClone(null); + expect(result).toBe(null); + }); + + it('should clone undefined', () => { + const input = undefined; + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -- generic returning undefined + const result = jsonDeepClone(input); + expect(result).toBe(undefined); + }); + + it('should clone boolean values', () => { + expect(jsonDeepClone(true)).toBe(true); + expect(jsonDeepClone(false)).toBe(false); + }); + + it('should clone string values', () => { + const original = 'test string'; + const result = jsonDeepClone(original); + expect(result).toBe(original); + }); + + it('should clone number values', () => { + expect(jsonDeepClone(42)).toBe(42); + expect(jsonDeepClone(3.14)).toBe(3.14); + expect(jsonDeepClone(0)).toBe(0); + expect(jsonDeepClone(-1)).toBe(-1); + }); + }); + + describe('arrays', () => { + it('should clone simple arrays', () => { + const original = [1, 2, 3]; + const result = jsonDeepClone(original); + + expect(result).toEqual(original); + expect(result).not.toBe(original); + }); + + it('should clone nested arrays', () => { + const original = [ + [1, 2], + [3, 4], + ]; + const result = jsonDeepClone(original); + + expect(result).toEqual(original); + expect(result).not.toBe(original); + expect(result[0]).not.toBe(original[0]); + expect(result[1]).not.toBe(original[1]); + }); + + it('should clone arrays with mixed types', () => { + const original = [1, 'string', true, null, [1, 2]]; + const result = jsonDeepClone(original); + + expect(result).toEqual(original); + expect(result).not.toBe(original); + expect(result[4]).not.toBe(original[4]); + }); + + it('should clone empty arrays', () => { + const original: unknown[] = []; + const result = jsonDeepClone(original); + + expect(result).toEqual(original); + expect(result).not.toBe(original); + }); + }); + + describe('objects', () => { + it('should clone simple objects', () => { + const original = { a: 1, b: 'test' }; + const result = jsonDeepClone(original); + + expect(result).toEqual(original); + expect(result).not.toBe(original); + }); + + it('should clone nested objects', () => { + const original = { a: { b: 1, c: 2 }, d: 3 }; + const result = jsonDeepClone(original); + + expect(result).toEqual(original); + expect(result).not.toBe(original); + expect(result.a).not.toBe(original.a); + }); + + it('should clone objects with arrays', () => { + const original = { items: [1, 2, 3], count: 3 }; + const result = jsonDeepClone(original); + + expect(result).toEqual(original); + expect(result).not.toBe(original); + expect(result.items).not.toBe(original.items); + }); + + it('should clone empty objects', () => { + const original = {}; + const result = jsonDeepClone(original); + + expect(result).toEqual(original); + expect(result).not.toBe(original); + }); + + it('should handle objects with null values', () => { + const original = { a: null, b: 1 }; + const result = jsonDeepClone(original); + + expect(result).toEqual(original); + expect(result).not.toBe(original); + }); + }); + + describe('complex nested structures', () => { + it('should clone deeply nested structures', () => { + const original = { + users: [ + { id: 1, profile: { name: 'Alice', settings: { theme: 'dark' } } }, + { id: 2, profile: { name: 'Bob', settings: { theme: 'light' } } }, + ], + metadata: { + count: 2, + tags: ['user', 'profile'], + }, + }; + + const result = jsonDeepClone(original); + + expect(result).toEqual(original); + expect(result).not.toBe(original); + expect(result.users).not.toBe(original.users); + expect(result.users[0]).not.toBe(original.users[0]); + expect(result.users[0].profile).not.toBe(original.users[0].profile); + expect(result.users[0].profile.settings).not.toBe( + original.users[0].profile.settings, + ); + expect(result.metadata).not.toBe(original.metadata); + expect(result.metadata.tags).not.toBe(original.metadata.tags); + }); + }); + + describe('error cases', () => { + it('should throw error for functions', () => { + const fn = (): void => { + // Empty function for testing + }; + expect(() => jsonDeepClone(fn)).toThrow( + 'Cannot clone value of unsupported type: function', + ); + }); + + it('should throw error for symbols', () => { + const sym = Symbol('test'); + expect(() => jsonDeepClone(sym)).toThrow( + 'Cannot clone value of unsupported type: symbol', + ); + }); + + it('should throw error for BigInt', () => { + const bigInt = BigInt(123); + expect(() => jsonDeepClone(bigInt)).toThrow( + 'Cannot clone value of unsupported type: bigint', + ); + }); + + it('should throw error for Date objects', () => { + const date = new Date(); + expect(() => jsonDeepClone(date)).toThrow( + 'Cannot clone value of unsupported type: Date', + ); + }); + + it('should throw error for RegExp objects', () => { + const regex = /test/; + expect(() => jsonDeepClone(regex)).toThrow( + 'Cannot clone value of unsupported type: RegExp', + ); + }); + }); +}); From d5db106bcb300058e320c73040630ff8aee08323 Mon Sep 17 00:00:00 2001 From: Kingston Tam Date: Sat, 12 Jul 2025 10:19:25 +0200 Subject: [PATCH 09/22] Refactor model form to fix bug with field creation --- .../models/-components/new-model-dialog.tsx | 123 ++++++++++++++++-- .../models/-hooks/use-edited-model-config.tsx | 20 ++- .../data/models/-hooks/use-model-form.ts | 94 +++---------- .../routes/data/models/edit.$key/graphql.tsx | 4 +- .../routes/data/models/edit.$key/index.tsx | 4 +- .../routes/data/models/edit.$key/service.tsx | 4 +- packages/utils/src/json/index.ts | 1 + 7 files changed, 146 insertions(+), 104 deletions(-) diff --git a/packages/project-builder-web/src/routes/data/models/-components/new-model-dialog.tsx b/packages/project-builder-web/src/routes/data/models/-components/new-model-dialog.tsx index 72b4f10b5..d39f2e7bf 100644 --- a/packages/project-builder-web/src/routes/data/models/-components/new-model-dialog.tsx +++ b/packages/project-builder-web/src/routes/data/models/-components/new-model-dialog.tsx @@ -1,6 +1,17 @@ +import type { ModelConfigInput } from '@baseplate-dev/project-builder-lib'; import type React from 'react'; -import { useBlockBeforeContinue } from '@baseplate-dev/project-builder-lib/web'; +import { + createModelBaseSchema, + FeatureUtils, + modelEntityType, + modelScalarFieldEntityType, +} from '@baseplate-dev/project-builder-lib'; +import { + useBlockBeforeContinue, + useDefinitionSchema, + useProjectDefinition, +} from '@baseplate-dev/project-builder-lib/web'; import { Button, Dialog, @@ -13,8 +24,12 @@ import { DialogTrigger, useControlledState, } from '@baseplate-dev/ui-components'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useNavigate } from '@tanstack/react-router'; +import { sortBy } from 'es-toolkit'; +import { useMemo } from 'react'; +import { useForm } from 'react-hook-form'; -import { useModelForm } from '../-hooks/use-model-form.js'; import { ModelInfoForm } from '../edit.$key/-components/model-info-form.js'; interface NewModelDialogProps { @@ -23,23 +38,109 @@ interface NewModelDialogProps { onOpenChange?: (open: boolean) => void; } +function createNewModel(): ModelConfigInput { + const idFieldId = modelScalarFieldEntityType.generateNewId(); + return { + id: modelEntityType.generateNewId(), + name: '', + featureRef: '', + service: { + create: { enabled: false }, + update: { enabled: false }, + delete: { enabled: false }, + transformers: [], + }, + model: { + primaryKeyFieldRefs: [idFieldId], + fields: [ + { + id: idFieldId, + name: 'id', + type: 'uuid', + isOptional: false, + options: { + default: '', + genUuid: true, + }, + }, + ], + }, + }; +} + export function NewModelDialog({ children, open, onOpenChange, }: NewModelDialogProps): React.JSX.Element { const [isOpen, setIsOpen] = useControlledState(open, onOpenChange, false); - const { - onSubmit, - form: { control }, - } = useModelForm({ - isCreate: true, - modelKey: undefined, - onSubmitSuccess: () => { - setIsOpen(false); - }, + const { definition, saveDefinitionWithFeedback } = useProjectDefinition(); + // memoize it to keep the same key when resetting + const baseModelSchema = useDefinitionSchema(createModelBaseSchema); + + const defaultValues = useMemo(() => createNewModel(), []); + const navigate = useNavigate(); + + const { handleSubmit, reset, setError, control } = useForm({ + resolver: zodResolver(baseModelSchema), + defaultValues, }); + const onSubmit = useMemo( + () => + handleSubmit((data) => { + // check for models with the same name + const existingModel = definition.models.find( + (m) => m.name.toLowerCase() === data.name.toLowerCase(), + ); + if (existingModel) { + setError('name', { + message: `Model with name ${data.name} already exists.`, + }); + return; + } + + return saveDefinitionWithFeedback( + (draftConfig) => { + // create feature if a new feature exists + const updatedModel = { ...data }; + updatedModel.featureRef = + FeatureUtils.ensureFeatureByNameRecursively( + draftConfig, + updatedModel.featureRef, + ); + draftConfig.models = sortBy( + [ + ...draftConfig.models.filter((m) => m.id !== updatedModel.id), + updatedModel, + ], + [(m) => m.name], + ); + }, + { + successMessage: 'Successfully created model!', + onSuccess: () => { + navigate({ + to: '/data/models/edit/$key', + params: { key: modelEntityType.keyFromId(data.id) }, + }); + reset(createNewModel()); + setIsOpen(false); + }, + }, + ); + }), + [ + reset, + setError, + handleSubmit, + saveDefinitionWithFeedback, + navigate, + definition, + setIsOpen, + ], + ); + const blockBeforeContinue = useBlockBeforeContinue(); return ( diff --git a/packages/project-builder-web/src/routes/data/models/-hooks/use-edited-model-config.tsx b/packages/project-builder-web/src/routes/data/models/-hooks/use-edited-model-config.tsx index 1cb53174a..e004ba7a4 100644 --- a/packages/project-builder-web/src/routes/data/models/-hooks/use-edited-model-config.tsx +++ b/packages/project-builder-web/src/routes/data/models/-hooks/use-edited-model-config.tsx @@ -6,8 +6,7 @@ import type React from 'react'; import type { UseFormGetValues, UseFormWatch } from 'react-hook-form'; import type { StoreApi } from 'zustand'; -import { ModelUtils } from '@baseplate-dev/project-builder-lib'; -import { useProjectDefinition } from '@baseplate-dev/project-builder-lib/web'; +import { jsonDeepClone } from '@baseplate-dev/utils'; import { createContext, useContext, useEffect, useMemo } from 'react'; import { createStore, useStore } from 'zustand'; @@ -24,41 +23,40 @@ const EditedModelContext = createContext< >(undefined); export function EditedModelContextProvider({ + originalModel, children, watch, - initialModel, getValues, }: { + originalModel: ModelConfig; children: React.ReactNode; watch: UseFormWatch; getValues: UseFormGetValues; - initialModel: ModelConfigInput; }): React.JSX.Element { - const { definition } = useProjectDefinition(); - const existingModel = ModelUtils.byIdOrThrow(definition, initialModel.id); const store = useMemo( () => createStore((set) => ({ model: { - ...existingModel, - ...initialModel, + ...originalModel, + ...getValues(), }, setModel: (model) => { set({ model: { - ...existingModel, + ...originalModel, ...model, }, }); }, getValues, })), - [initialModel, getValues, existingModel], + [originalModel, getValues], ); useEffect(() => { const { unsubscribe } = watch((data) => { - store.getState().setModel(data as ModelConfig); + // We need to clone the data since React hook form data store is not immutable + store.getState().setModel(jsonDeepClone(data as ModelConfigInput)); }); return unsubscribe; }, [watch, store]); diff --git a/packages/project-builder-web/src/routes/data/models/-hooks/use-model-form.ts b/packages/project-builder-web/src/routes/data/models/-hooks/use-model-form.ts index 5f955e9b3..7d576d1ad 100644 --- a/packages/project-builder-web/src/routes/data/models/-hooks/use-model-form.ts +++ b/packages/project-builder-web/src/routes/data/models/-hooks/use-model-form.ts @@ -9,7 +9,6 @@ import { createModelBaseSchema, FeatureUtils, modelEntityType, - modelScalarFieldEntityType, ModelUtils, } from '@baseplate-dev/project-builder-lib'; import { @@ -19,81 +18,37 @@ import { } from '@baseplate-dev/project-builder-lib/web'; import { toast, useEventCallback } from '@baseplate-dev/ui-components'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useNavigate } from '@tanstack/react-router'; import { sortBy } from 'es-toolkit'; import { useMemo, useRef } from 'react'; -import { logAndFormatError } from '#src/services/error-formatter.js'; -import { NotFoundError } from '#src/utils/error.js'; - interface UseModelFormOptions { schema?: z.ZodTypeAny; omit?: string[]; onSubmitSuccess?: () => void; - isCreate?: boolean; - modelKey: string | undefined; -} - -function createNewModel(): ModelConfig { - const idFieldId = modelScalarFieldEntityType.generateNewId(); - return { - id: modelEntityType.generateNewId(), - name: '', - featureRef: '', - service: { - create: { enabled: false }, - update: { enabled: false }, - delete: { enabled: false }, - transformers: [], - }, - model: { - primaryKeyFieldRefs: [idFieldId], - fields: [ - { - id: idFieldId, - name: 'id', - type: 'uuid', - isOptional: false, - options: { - default: '', - genUuid: true, - }, - }, - ], - }, - }; + modelKey: string; } +/** + * Unifies logic for editing an existing model and updating it. + */ export function useModelForm({ onSubmitSuccess, - isCreate, omit, modelKey, }: UseModelFormOptions): { form: UseFormReturn; onSubmit: () => Promise; - originalModel?: ModelConfig; + originalModel: ModelConfig; defaultValues: ModelConfigInput; } { const { definition, saveDefinitionWithFeedback } = useProjectDefinition(); - const navigate = useNavigate(); - - const urlModelId = isCreate ? undefined : modelEntityType.idFromKey(modelKey); - const model = urlModelId - ? ModelUtils.byIdOrThrow(definition, urlModelId) - : undefined; - - if (!isCreate && !model) { - throw new NotFoundError( - 'The model you were looking for could not be found.', - ); - } - // memoize it to keep the same key when resetting - const newModel = useMemo(() => createNewModel(), []); + const urlModelId = modelEntityType.idFromKey(modelKey); + const model = ModelUtils.byIdOrThrow(definition, urlModelId); const baseModelSchema = useDefinitionSchema(createModelBaseSchema); + // These should remain constant throughout the form's lifecycle const fieldsToOmit = useRef(omit); const finalSchema = useMemo(() => { @@ -108,12 +63,14 @@ export function useModelForm({ return baseModelSchema; }, [baseModelSchema]); - const defaultValues = useMemo(() => { - const modelToUse = model ?? newModel; - return fieldsToOmit.current - ? (finalSchema.parse(modelToUse) as ModelConfigInput) - : modelToUse; - }, [model, newModel, finalSchema]); + // Make sure strip out unused fields + const defaultValues = useMemo( + () => + fieldsToOmit.current + ? (finalSchema.parse(model) as ModelConfigInput) + : model, + [model, finalSchema], + ); const form = useResettableForm({ resolver: zodResolver(finalSchema), @@ -130,8 +87,6 @@ export function useModelForm({ const updatedModel = { ...model, ...data, - // generate new ID if new - id: model?.id ?? modelEntityType.generateNewId(), }; if (updatedModel.model.fields.length === 0) { toast.error('Model must have at least one field.'); @@ -221,19 +176,9 @@ export function useModelForm({ ); }, { - successMessage: isCreate - ? 'Successfully created model!' - : 'Successfully saved model!', + successMessage: 'Successfully saved model!', onSuccess: () => { - if (isCreate) { - navigate({ - to: '/data/models/edit/$key', - params: { key: modelEntityType.keyFromId(updatedModel.id) }, - }).catch(logAndFormatError); - reset(newModel); - } else { - reset(data); - } + reset(data); handleSubmitSuccess?.(); }, }, @@ -244,11 +189,8 @@ export function useModelForm({ setError, handleSubmit, saveDefinitionWithFeedback, - isCreate, - navigate, handleSubmitSuccess, definition, - newModel, model, ], ); diff --git a/packages/project-builder-web/src/routes/data/models/edit.$key/graphql.tsx b/packages/project-builder-web/src/routes/data/models/edit.$key/graphql.tsx index 2c1deaec7..8debb794f 100644 --- a/packages/project-builder-web/src/routes/data/models/edit.$key/graphql.tsx +++ b/packages/project-builder-web/src/routes/data/models/edit.$key/graphql.tsx @@ -19,7 +19,7 @@ export const Route = createFileRoute('/data/models/edit/$key/graphql')({ function ModelEditGraphQLPage(): React.JSX.Element { const { key } = Route.useParams(); - const { form, onSubmit, defaultValues } = useModelForm({ + const { form, onSubmit, originalModel } = useModelForm({ omit: ['name', 'featureRef'], modelKey: key, }); @@ -30,7 +30,7 @@ function ModelEditGraphQLPage(): React.JSX.Element { return ( diff --git a/packages/project-builder-web/src/routes/data/models/edit.$key/index.tsx b/packages/project-builder-web/src/routes/data/models/edit.$key/index.tsx index 566055507..7d351ef31 100644 --- a/packages/project-builder-web/src/routes/data/models/edit.$key/index.tsx +++ b/packages/project-builder-web/src/routes/data/models/edit.$key/index.tsx @@ -34,7 +34,7 @@ export const Route = createFileRoute('/data/models/edit/$key/')({ function ModelEditModelPage(): React.JSX.Element { const { key } = Route.useParams(); - const { form, onSubmit, defaultValues } = useModelForm({ + const { form, onSubmit, originalModel } = useModelForm({ omit: ['name', 'featureRef'], modelKey: key, }); @@ -49,7 +49,7 @@ function ModelEditModelPage(): React.JSX.Element { return ( diff --git a/packages/project-builder-web/src/routes/data/models/edit.$key/service.tsx b/packages/project-builder-web/src/routes/data/models/edit.$key/service.tsx index 3cc6d97f3..a107d7fd1 100644 --- a/packages/project-builder-web/src/routes/data/models/edit.$key/service.tsx +++ b/packages/project-builder-web/src/routes/data/models/edit.$key/service.tsx @@ -35,7 +35,7 @@ export const Route = createFileRoute('/data/models/edit/$key/service')({ function ModelEditServicePage(): React.JSX.Element { const { key } = Route.useParams(); - const { form, onSubmit, defaultValues } = useModelForm({ + const { form, onSubmit, originalModel } = useModelForm({ omit: ['name', 'featureRef'], modelKey: key, }); @@ -45,7 +45,7 @@ function ModelEditServicePage(): React.JSX.Element { return ( diff --git a/packages/utils/src/json/index.ts b/packages/utils/src/json/index.ts index 1aad15023..b6f7aff8a 100644 --- a/packages/utils/src/json/index.ts +++ b/packages/utils/src/json/index.ts @@ -1,2 +1,3 @@ +export * from './json-deep-clone.js'; export * from './stringify-pretty-compact.js'; export * from './stringify-pretty-stable.js'; From ba86683bf3d34207a9dac9ca7bbca3ec10e367b9 Mon Sep 17 00:00:00 2001 From: Kingston Tam Date: Sat, 12 Jul 2025 10:23:32 +0200 Subject: [PATCH 10/22] Small refactor of model field form --- .../-components/fields/model-field-form.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/project-builder-web/src/routes/data/models/edit.$key/-components/fields/model-field-form.tsx b/packages/project-builder-web/src/routes/data/models/edit.$key/-components/fields/model-field-form.tsx index ec0cd6930..c65fb4c03 100644 --- a/packages/project-builder-web/src/routes/data/models/edit.$key/-components/fields/model-field-form.tsx +++ b/packages/project-builder-web/src/routes/data/models/edit.$key/-components/fields/model-field-form.tsx @@ -48,12 +48,13 @@ function ModelFieldForm({ (model) => model.model.relations, ); - const model = useEditedModelConfig((model) => model.model); - const isPartOfPrimaryKey = model.primaryKeyFieldRefs.includes( - watchedField.id, + const primaryKeyFieldRefs = useEditedModelConfig( + (model) => model.model.primaryKeyFieldRefs, ); - const hasCompositePrimaryKey = model.primaryKeyFieldRefs.length > 1; - const uniqueConstraints = model.uniqueConstraints ?? []; + const uniqueConstraints = + useEditedModelConfig((model) => model.model.uniqueConstraints) ?? []; + const isPartOfPrimaryKey = primaryKeyFieldRefs.includes(watchedField.id); + const hasCompositePrimaryKey = primaryKeyFieldRefs.length > 1; const ownUniqueConstraints = uniqueConstraints.filter((uc) => uc.fields.some((f) => f.fieldRef === watchedField.id), @@ -76,7 +77,7 @@ function ModelFieldForm({ } // check unique constraints if ( - model.uniqueConstraints?.some((constraint) => + uniqueConstraints.some((constraint) => constraint.fields.some((f) => f.fieldRef === watchedField.id), ) ) { From 3855f2b7c8b5a68333b46a6ef75e2bae866dc0c9 Mon Sep 17 00:00:00 2001 From: Kingston Tam Date: Sat, 12 Jul 2025 13:01:19 +0200 Subject: [PATCH 11/22] Support broader set of file model fields --- .../src/storage/core/schema/models.ts | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/plugins/plugin-storage/src/storage/core/schema/models.ts b/plugins/plugin-storage/src/storage/core/schema/models.ts index dd860461a..e59860871 100644 --- a/plugins/plugin-storage/src/storage/core/schema/models.ts +++ b/plugins/plugin-storage/src/storage/core/schema/models.ts @@ -12,53 +12,64 @@ const FILE_MODEL_FIELDS: ModelMergerScalarFieldInput[] = [ type: 'uuid', options: { genUuid: true }, }, + // Core fields { - name: 'category', + name: 'filename', type: 'string', }, { - name: 'adapter', - type: 'string', - }, - { - name: 'path', + name: 'mimeType', type: 'string', }, { - name: 'mimeType', + name: 'encoding', type: 'string', + isOptional: true, }, { name: 'size', type: 'int', }, + // Storage info { - name: 'name', + name: 'category', type: 'string', }, { - name: 'shouldDelete', - type: 'boolean', + name: 'adapter', + type: 'string', }, { - name: 'isUsed', - type: 'boolean', + name: 'storagePath', + type: 'string', }, + // Status tracking via timestamps { - name: 'uploaderId', - type: 'uuid', + name: 'referencedAt', + type: 'dateTime', isOptional: true, }, { - name: 'updatedAt', + name: 'expiredAt', type: 'dateTime', - options: { defaultToNow: true, updatedAt: true }, + isOptional: true, }, + // Relations + { + name: 'uploaderId', + type: 'uuid', + }, + // Timestamps { name: 'createdAt', type: 'dateTime', options: { defaultToNow: true }, }, + { + name: 'updatedAt', + type: 'dateTime', + options: { defaultToNow: true, updatedAt: true }, + }, ]; export function createStorageModels( @@ -96,7 +107,7 @@ export function createStorageModels( graphql: { objectType: { enabled: true, - fields: ['id', 'name'], + fields: ['id', 'filename'], }, }, }, From 9ad05eee0914f6f46cb385a43d3f9d1856e39a89 Mon Sep 17 00:00:00 2001 From: Kingston Tam Date: Sat, 12 Jul 2025 13:01:31 +0200 Subject: [PATCH 12/22] Ignore text encoding identifier case eslint errors --- .../src/generators/node/eslint/templates/eslint.config.js | 4 ++++ packages/tools/eslint-configs/typescript.js | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/core-generators/src/generators/node/eslint/templates/eslint.config.js b/packages/core-generators/src/generators/node/eslint/templates/eslint.config.js index 69f43b332..a7d438c48 100644 --- a/packages/core-generators/src/generators/node/eslint/templates/eslint.config.js +++ b/packages/core-generators/src/generators/node/eslint/templates/eslint.config.js @@ -170,6 +170,10 @@ export default tsEslint.config( // Can be too strict if you prefer to have shorter cases for negated conditions 'unicorn/no-negated-condition': 'off', + + // Allow usage of utf-8 text encoding since it's consistent with the WHATWG spec + // and autofixing can cause unexpected changes (https://github.com/sindresorhus/eslint-plugin-unicorn/issues/1926) + 'unicorn/text-encoding-identifier-case': 'off', }, }, diff --git a/packages/tools/eslint-configs/typescript.js b/packages/tools/eslint-configs/typescript.js index f53144cb6..175a7c743 100644 --- a/packages/tools/eslint-configs/typescript.js +++ b/packages/tools/eslint-configs/typescript.js @@ -219,6 +219,10 @@ export function generateTypescriptEslintConfig(options = []) { // Prevents returning undefined from functions which Typescript assumes is void 'unicorn/no-useless-undefined': 'off', + + // Allow usage of utf-8 text encoding since it's consistent with the WHATWG spec + // and autofixing can cause unexpected changes (https://github.com/sindresorhus/eslint-plugin-unicorn/issues/1926) + 'unicorn/text-encoding-identifier-case': 'off', }, }, From 9b889b9208611bae051b50f968be69df912a1e2e Mon Sep 17 00:00:00 2001 From: Kingston Tam Date: Sat, 12 Jul 2025 13:54:40 +0200 Subject: [PATCH 13/22] Implement improved storage module --- plugins/plugin-storage/package.json | 1 + .../prisma-file-transformer.generator.ts | 4 +- .../fastify/storage-module/extractor.json | 192 ++++++----- .../generated/template-paths.ts | 38 +- .../generated/template-renderers.ts | 80 ++--- .../generated/ts-import-providers.ts | 57 ++- .../generated/typed-templates.ts | 326 ++++++++++-------- .../storage-module.generator.ts | 235 +++++-------- .../templates/module/adapters/index.ts | 5 - .../templates/module/adapters/s3.ts | 5 +- .../templates/module/adapters/url.ts | 2 +- .../adapters.ts => config/adapters.config.ts} | 0 .../module/config/categories.config.ts | 23 ++ .../module/constants/file-categories.ts | 23 -- .../module/schema/file-category.enum.ts | 9 + ...input-type.ts => file-input.input-type.ts} | 3 +- .../module/schema/presigned.mutations.ts | 7 +- .../module/schema/public-url.field.ts | 8 +- .../services/create-presigned-download-url.ts | 14 +- .../services/create-presigned-upload-url.ts | 20 +- .../module/services/download-file.ts | 23 +- .../templates/module/services/upload-file.ts | 27 +- .../module/services/validate-file-input.ts | 90 +++++ .../module/services/validate-upload-input.ts | 53 --- .../{adapters/types.ts => types/adapter.ts} | 0 .../templates/module/types/file-category.ts | 54 +++ .../module/utils/create-file-category.ts | 36 ++ .../templates/module/utils/mime.ts | 50 ++- .../templates/module/utils/mime.unit.test.ts | 26 -- .../templates/module/utils/upload.ts | 111 ------ .../utils/validate-file-upload-options.ts | 188 ++++++++++ pnpm-lock.yaml | 3 + 32 files changed, 980 insertions(+), 733 deletions(-) delete mode 100644 plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/index.ts rename plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/{constants/adapters.ts => config/adapters.config.ts} (100%) create mode 100644 plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/config/categories.config.ts delete mode 100644 plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/constants/file-categories.ts create mode 100644 plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/file-category.enum.ts rename plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/{file-upload.input-type.ts => file-input.input-type.ts} (63%) create mode 100644 plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/validate-file-input.ts delete mode 100644 plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/validate-upload-input.ts rename plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/{adapters/types.ts => types/adapter.ts} (100%) create mode 100644 plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/types/file-category.ts create mode 100644 plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/create-file-category.ts delete mode 100644 plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/mime.unit.test.ts delete mode 100644 plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/upload.ts create mode 100644 plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/validate-file-upload-options.ts diff --git a/plugins/plugin-storage/package.json b/plugins/plugin-storage/package.json index c772f4c7a..fe4a1f76e 100644 --- a/plugins/plugin-storage/package.json +++ b/plugins/plugin-storage/package.json @@ -53,6 +53,7 @@ "@baseplate-dev/ui-components": "workspace:*", "@baseplate-dev/utils": "workspace:*", "@hookform/resolvers": "5.0.1", + "es-toolkit": "1.31.0", "inflection": "3.0.0", "react": "catalog:", "react-dom": "catalog:", diff --git a/plugins/plugin-storage/src/generators/fastify/prisma-file-transformer/prisma-file-transformer.generator.ts b/plugins/plugin-storage/src/generators/fastify/prisma-file-transformer/prisma-file-transformer.generator.ts index 9fcba5edf..399a95eac 100644 --- a/plugins/plugin-storage/src/generators/fastify/prisma-file-transformer/prisma-file-transformer.generator.ts +++ b/plugins/plugin-storage/src/generators/fastify/prisma-file-transformer/prisma-file-transformer.generator.ts @@ -64,14 +64,14 @@ export const prismaFileTransformerGenerator = createGenerator({ const isFieldOptional = operationType === 'update' || foreignRelation.isOptional; const transformer = tsCodeFragment( - `await validateFileUploadInput(${name}, ${quot(category)}, context${ + `await validateFileInput(${name}, ${quot(category)}, context${ operationType === 'create' ? '' : `, existingItem${ operationType === 'upsert' ? '?' : '' }.${foreignRelationFieldName}` })`, - storageModuleImports.validateFileUploadInput.declaration(), + storageModuleImports.validateFileInput.declaration(), ); const prefix = isFieldOptional diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/extractor.json b/plugins/plugin-storage/src/generators/fastify/storage-module/extractor.json index 08083f627..c297a73f1 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/extractor.json +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/extractor.json @@ -1,79 +1,70 @@ { "name": "fastify/storage-module", "templates": { - "adapters-index": { - "type": "ts", - "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", - "group": "adapters", - "importMapProviders": {}, - "pathRootRelativePath": "{module-root}/adapters/index.ts", - "sourceFile": "module/adapters/index.ts", - "variables": {} - }, "adapters-s-3": { "type": "ts", "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", - "group": "adapters", + "group": "main", "importMapProviders": {}, "pathRootRelativePath": "{module-root}/adapters/s3.ts", + "projectExports": { "createS3Adapter": {} }, "sourceFile": "module/adapters/s3.ts", "variables": {} }, - "adapters-types": { - "type": "ts", - "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", - "group": "adapters", - "importMapProviders": {}, - "pathRootRelativePath": "{module-root}/adapters/types.ts", - "sourceFile": "module/adapters/types.ts", - "variables": {} - }, "adapters-url": { "type": "ts", "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", - "group": "adapters", + "group": "main", "importMapProviders": {}, "pathRootRelativePath": "{module-root}/adapters/url.ts", + "projectExports": { "createUrlAdapter": {} }, "sourceFile": "module/adapters/url.ts", "variables": {} }, - "constants-adapters": { + "config-adapters": { "type": "ts", "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", - "group": "constants", "importMapProviders": {}, - "pathRootRelativePath": "{module-root}/constants/adapters.ts", - "sourceFile": "module/constants/adapters.ts", + "pathRootRelativePath": "{module-root}/config/adapters.config.ts", + "projectExports": { + "STORAGE_ADAPTERS": {}, + "StorageAdapterKey": { "isTypeOnly": true } + }, + "sourceFile": "module/config/adapters.config.ts", "variables": { "TPL_ADAPTERS": {} } }, - "constants-file-categories": { + "config-categories": { + "type": "ts", + "fileOptions": { "kind": "singleton" }, + "importMapProviders": {}, + "pathRootRelativePath": "{module-root}/config/categories.config.ts", + "projectExports": { + "FILE_CATEGORIES": {}, + "FileCategoryName": { "isTypeOnly": true }, + "getCategoryByName": {}, + "getCategoryByNameOrThrow": {} + }, + "sourceFile": "module/config/categories.config.ts", + "variables": { "TPL_FILE_CATEGORIES": {} } + }, + "schema-file-category": { "type": "ts", "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", - "group": "constants", + "group": "schema", "importMapProviders": { - "serviceContextImportsProvider": { - "importName": "serviceContextImportsProvider", - "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/core/service-context/generated/ts-import-providers.ts" + "pothosImportsProvider": { + "importName": "pothosImportsProvider", + "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/pothos/pothos/generated/ts-import-providers.ts" } }, - "pathRootRelativePath": "{module-root}/constants/file-categories.ts", - "sourceFile": "module/constants/file-categories.ts", - "variables": { - "TPL_FILE_CATEGORIES": {}, - "TPL_FILE_COUNT_OUTPUT_TYPE": {}, - "TPL_FILE_MODEL_TYPE": {} - } + "pathRootRelativePath": "{module-root}/schema/file-category.enum.ts", + "projectExports": {}, + "sourceFile": "module/schema/file-category.enum.ts", + "variables": {} }, - "schema-file-upload-input-type": { + "schema-file-input": { "type": "ts", "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", "group": "schema", "importMapProviders": { "pothosImportsProvider": { @@ -81,14 +72,14 @@ "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/pothos/pothos/generated/ts-import-providers.ts" } }, - "pathRootRelativePath": "{module-root}/schema/file-upload.input-type.ts", - "sourceFile": "module/schema/file-upload.input-type.ts", + "pathRootRelativePath": "{module-root}/schema/file-input.input-type.ts", + "projectExports": { "fileInputInputType": {} }, + "sourceFile": "module/schema/file-input.input-type.ts", "variables": {} }, "schema-presigned-mutations": { "type": "ts", "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", "group": "schema", "importMapProviders": { "pothosImportsProvider": { @@ -97,13 +88,13 @@ } }, "pathRootRelativePath": "{module-root}/schema/presigned.mutations.ts", + "projectExports": {}, "sourceFile": "module/schema/presigned.mutations.ts", "variables": { "TPL_FILE_OBJECT_TYPE": {} } }, - "schema-public-url-field": { + "schema-public-url": { "type": "ts", "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", "group": "schema", "importMapProviders": { "pothosImportsProvider": { @@ -112,14 +103,14 @@ } }, "pathRootRelativePath": "{module-root}/schema/public-url.field.ts", + "projectExports": {}, "sourceFile": "module/schema/public-url.field.ts", "variables": { "TPL_FILE_OBJECT_TYPE": {} } }, "services-create-presigned-download-url": { "type": "ts", "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", - "group": "services", + "group": "main", "importMapProviders": { "errorHandlerServiceImportsProvider": { "importName": "errorHandlerServiceImportsProvider", @@ -138,8 +129,7 @@ "services-create-presigned-upload-url": { "type": "ts", "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", - "group": "services", + "group": "main", "importMapProviders": { "errorHandlerServiceImportsProvider": { "importName": "errorHandlerServiceImportsProvider", @@ -151,18 +141,14 @@ } }, "pathRootRelativePath": "{module-root}/services/create-presigned-upload-url.ts", - "projectExports": { - "createPresignedUploadUrl": {}, - "CreatePresignedUploadUrlPayload": { "isTypeOnly": true } - }, + "projectExports": { "createPresignedUploadUrl": {} }, "sourceFile": "module/services/create-presigned-upload-url.ts", "variables": { "TPL_FILE_MODEL": {}, "TPL_FILE_MODEL_TYPE": {} } }, "services-download-file": { "type": "ts", "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", - "group": "services", + "group": "main", "importMapProviders": { "errorHandlerServiceImportsProvider": { "importName": "errorHandlerServiceImportsProvider", @@ -181,8 +167,7 @@ "services-upload-file": { "type": "ts", "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", - "group": "services", + "group": "main", "importMapProviders": { "serviceContextImportsProvider": { "importName": "serviceContextImportsProvider", @@ -190,15 +175,14 @@ } }, "pathRootRelativePath": "{module-root}/services/upload-file.ts", - "projectExports": { "uploadFile": {} }, + "projectExports": {}, "sourceFile": "module/services/upload-file.ts", "variables": { "TPL_FILE_MODEL": {}, "TPL_FILE_MODEL_TYPE": {} } }, - "services-validate-upload-input": { + "services-validate-file-input": { "type": "ts", "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", - "group": "services", + "group": "main", "importMapProviders": { "errorHandlerServiceImportsProvider": { "importName": "errorHandlerServiceImportsProvider", @@ -213,43 +197,77 @@ "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/core/service-context/generated/ts-import-providers.ts" } }, - "pathRootRelativePath": "{module-root}/services/validate-upload-input.ts", + "pathRootRelativePath": "{module-root}/services/validate-file-input.ts", "projectExports": { "FileUploadInput": { "isTypeOnly": true }, - "validateFileUploadInput": {} + "validateFileInput": {} }, - "sourceFile": "module/services/validate-upload-input.ts", + "sourceFile": "module/services/validate-file-input.ts", "variables": { "TPL_FILE_MODEL": {} } }, - "utils-mime": { + "types-adapter": { "type": "ts", "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", - "group": "utils", + "group": "main", "importMapProviders": {}, - "pathRootRelativePath": "{module-root}/utils/mime.ts", + "pathRootRelativePath": "{module-root}/types/adapter.ts", "projectExports": { - "getMimeTypeFromContentType": {}, - "validateFileExtensionWithMimeType": {} + "CreatePresignedUploadOptions": { "isTypeOnly": true }, + "FileMetadata": { "isTypeOnly": true }, + "PresignedUploadUrl": { "isTypeOnly": true }, + "StorageAdapter": { "isTypeOnly": true } }, - "sourceFile": "module/utils/mime.ts", + "sourceFile": "module/types/adapter.ts", + "variables": {} + }, + "types-file-category": { + "type": "ts", + "fileOptions": { "kind": "singleton" }, + "group": "main", + "importMapProviders": { + "serviceContextImportsProvider": { + "importName": "serviceContextImportsProvider", + "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/core/service-context/generated/ts-import-providers.ts" + } + }, + "pathRootRelativePath": "{module-root}/types/file-category.ts", + "projectExports": { "FileCategory": { "isTypeOnly": true } }, + "sourceFile": "module/types/file-category.ts", + "variables": { "TPL_FILE_COUNT_OUTPUT_TYPE": {} } + }, + "utils-create-file-category": { + "type": "ts", + "fileOptions": { "kind": "singleton" }, + "group": "main", + "importMapProviders": {}, + "pathRootRelativePath": "{module-root}/utils/create-file-category.ts", + "projectExports": { + "createFileCategory": {}, + "FileSize": {}, + "MimeTypes": {} + }, + "sourceFile": "module/utils/create-file-category.ts", "variables": {} }, - "utils-mime-unit-test": { + "utils-mime": { "type": "ts", "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", - "group": "utils", + "group": "main", "importMapProviders": {}, - "pathRootRelativePath": "{module-root}/utils/mime.unit.test.ts", - "sourceFile": "module/utils/mime.unit.test.ts", + "pathRootRelativePath": "{module-root}/utils/mime.ts", + "projectExports": { + "getEncodingFromContentType": {}, + "getMimeTypeFromContentType": {}, + "InvalidExtensionError": {}, + "validateFileExtensionWithMimeType": {} + }, + "sourceFile": "module/utils/mime.ts", "variables": {} }, - "utils-upload": { + "utils-validate-file-upload-options": { "type": "ts", "fileOptions": { "kind": "singleton" }, - "generator": "@baseplate-dev/plugin-storage#fastify/storage-module", - "group": "utils", + "group": "main", "importMapProviders": { "errorHandlerServiceImportsProvider": { "importName": "errorHandlerServiceImportsProvider", @@ -260,12 +278,12 @@ "packagePathSpecifier": "@baseplate-dev/fastify-generators:src/generators/core/service-context/generated/ts-import-providers.ts" } }, - "pathRootRelativePath": "{module-root}/utils/upload.ts", + "pathRootRelativePath": "{module-root}/utils/validate-file-upload-options.ts", "projectExports": { - "prepareUploadData": {}, - "UploadDataInput": { "isTypeOnly": true } + "FileUploadOptions": { "isTypeOnly": true }, + "validateFileUploadOptions": {} }, - "sourceFile": "module/utils/upload.ts", + "sourceFile": "module/utils/validate-file-upload-options.ts", "variables": { "TPL_FILE_CREATE_INPUT": {} } } } diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/generated/template-paths.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/generated/template-paths.ts index 62c91145a..088f360cd 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/generated/template-paths.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/generated/template-paths.ts @@ -2,23 +2,24 @@ import { appModuleProvider } from '@baseplate-dev/fastify-generators'; import { createGeneratorTask, createProviderType } from '@baseplate-dev/sync'; export interface FastifyStorageModulePaths { - adaptersIndex: string; adaptersS_3: string; - adaptersTypes: string; adaptersUrl: string; - constantsAdapters: string; - constantsFileCategories: string; - schemaFileUploadInputType: string; + configAdapters: string; + configCategories: string; + schemaFileCategory: string; + schemaFileInput: string; schemaPresignedMutations: string; - schemaPublicUrlField: string; + schemaPublicUrl: string; servicesCreatePresignedDownloadUrl: string; servicesCreatePresignedUploadUrl: string; servicesDownloadFile: string; servicesUploadFile: string; - servicesValidateUploadInput: string; + servicesValidateFileInput: string; + typesAdapter: string; + typesFileCategory: string; + utilsCreateFileCategory: string; utilsMime: string; - utilsMimeUnitTest: string; - utilsUpload: string; + utilsValidateFileUploadOptions: string; } const fastifyStorageModulePaths = createProviderType( @@ -34,23 +35,24 @@ const fastifyStorageModulePathsTask = createGeneratorTask({ return { providers: { fastifyStorageModulePaths: { - adaptersIndex: `${moduleRoot}/adapters/index.ts`, adaptersS_3: `${moduleRoot}/adapters/s3.ts`, - adaptersTypes: `${moduleRoot}/adapters/types.ts`, adaptersUrl: `${moduleRoot}/adapters/url.ts`, - constantsAdapters: `${moduleRoot}/constants/adapters.ts`, - constantsFileCategories: `${moduleRoot}/constants/file-categories.ts`, - schemaFileUploadInputType: `${moduleRoot}/schema/file-upload.input-type.ts`, + configAdapters: `${moduleRoot}/config/adapters.config.ts`, + configCategories: `${moduleRoot}/config/categories.config.ts`, + schemaFileCategory: `${moduleRoot}/schema/file-category.enum.ts`, + schemaFileInput: `${moduleRoot}/schema/file-input.input-type.ts`, schemaPresignedMutations: `${moduleRoot}/schema/presigned.mutations.ts`, - schemaPublicUrlField: `${moduleRoot}/schema/public-url.field.ts`, + schemaPublicUrl: `${moduleRoot}/schema/public-url.field.ts`, servicesCreatePresignedDownloadUrl: `${moduleRoot}/services/create-presigned-download-url.ts`, servicesCreatePresignedUploadUrl: `${moduleRoot}/services/create-presigned-upload-url.ts`, servicesDownloadFile: `${moduleRoot}/services/download-file.ts`, servicesUploadFile: `${moduleRoot}/services/upload-file.ts`, - servicesValidateUploadInput: `${moduleRoot}/services/validate-upload-input.ts`, + servicesValidateFileInput: `${moduleRoot}/services/validate-file-input.ts`, + typesAdapter: `${moduleRoot}/types/adapter.ts`, + typesFileCategory: `${moduleRoot}/types/file-category.ts`, + utilsCreateFileCategory: `${moduleRoot}/utils/create-file-category.ts`, utilsMime: `${moduleRoot}/utils/mime.ts`, - utilsMimeUnitTest: `${moduleRoot}/utils/mime.unit.test.ts`, - utilsUpload: `${moduleRoot}/utils/upload.ts`, + utilsValidateFileUploadOptions: `${moduleRoot}/utils/validate-file-upload-options.ts`, }, }, }; diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/generated/template-renderers.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/generated/template-renderers.ts index b918edbfe..ae5809888 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/generated/template-renderers.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/generated/template-renderers.ts @@ -1,4 +1,7 @@ -import type { RenderTsTemplateGroupActionInput } from '@baseplate-dev/core-generators'; +import type { + RenderTsTemplateFileActionInput, + RenderTsTemplateGroupActionInput, +} from '@baseplate-dev/core-generators'; import type { BuilderAction } from '@baseplate-dev/sync'; import { typescriptFileProvider } from '@baseplate-dev/core-generators'; @@ -14,51 +17,41 @@ import { FASTIFY_STORAGE_MODULE_PATHS } from './template-paths.js'; import { FASTIFY_STORAGE_MODULE_TEMPLATES } from './typed-templates.js'; export interface FastifyStorageModuleRenderers { - adaptersGroup: { + configAdapters: { render: ( options: Omit< - RenderTsTemplateGroupActionInput< - typeof FASTIFY_STORAGE_MODULE_TEMPLATES.adaptersGroup - >, - 'importMapProviders' | 'group' | 'paths' - >, - ) => BuilderAction; - }; - constantsGroup: { - render: ( - options: Omit< - RenderTsTemplateGroupActionInput< - typeof FASTIFY_STORAGE_MODULE_TEMPLATES.constantsGroup + RenderTsTemplateFileActionInput< + typeof FASTIFY_STORAGE_MODULE_TEMPLATES.configAdapters >, - 'importMapProviders' | 'group' | 'paths' + 'destination' | 'importMapProviders' | 'template' >, ) => BuilderAction; }; - schemaGroup: { + configCategories: { render: ( options: Omit< - RenderTsTemplateGroupActionInput< - typeof FASTIFY_STORAGE_MODULE_TEMPLATES.schemaGroup + RenderTsTemplateFileActionInput< + typeof FASTIFY_STORAGE_MODULE_TEMPLATES.configCategories >, - 'importMapProviders' | 'group' | 'paths' + 'destination' | 'importMapProviders' | 'template' >, ) => BuilderAction; }; - servicesGroup: { + mainGroup: { render: ( options: Omit< RenderTsTemplateGroupActionInput< - typeof FASTIFY_STORAGE_MODULE_TEMPLATES.servicesGroup + typeof FASTIFY_STORAGE_MODULE_TEMPLATES.mainGroup >, 'importMapProviders' | 'group' | 'paths' >, ) => BuilderAction; }; - utilsGroup: { + schemaGroup: { render: ( options: Omit< RenderTsTemplateGroupActionInput< - typeof FASTIFY_STORAGE_MODULE_TEMPLATES.utilsGroup + typeof FASTIFY_STORAGE_MODULE_TEMPLATES.schemaGroup >, 'importMapProviders' | 'group' | 'paths' >, @@ -94,40 +87,26 @@ const fastifyStorageModuleRenderersTask = createGeneratorTask({ return { providers: { fastifyStorageModuleRenderers: { - adaptersGroup: { + configAdapters: { render: (options) => - typescriptFile.renderTemplateGroup({ - group: FASTIFY_STORAGE_MODULE_TEMPLATES.adaptersGroup, - paths, + typescriptFile.renderTemplateFile({ + template: FASTIFY_STORAGE_MODULE_TEMPLATES.configAdapters, + destination: paths.configAdapters, ...options, }), }, - constantsGroup: { + configCategories: { render: (options) => - typescriptFile.renderTemplateGroup({ - group: FASTIFY_STORAGE_MODULE_TEMPLATES.constantsGroup, - paths, - importMapProviders: { - serviceContextImports, - }, + typescriptFile.renderTemplateFile({ + template: FASTIFY_STORAGE_MODULE_TEMPLATES.configCategories, + destination: paths.configCategories, ...options, }), }, - schemaGroup: { + mainGroup: { render: (options) => typescriptFile.renderTemplateGroup({ - group: FASTIFY_STORAGE_MODULE_TEMPLATES.schemaGroup, - paths, - importMapProviders: { - pothosImports, - }, - ...options, - }), - }, - servicesGroup: { - render: (options) => - typescriptFile.renderTemplateGroup({ - group: FASTIFY_STORAGE_MODULE_TEMPLATES.servicesGroup, + group: FASTIFY_STORAGE_MODULE_TEMPLATES.mainGroup, paths, importMapProviders: { errorHandlerServiceImports, @@ -137,14 +116,13 @@ const fastifyStorageModuleRenderersTask = createGeneratorTask({ ...options, }), }, - utilsGroup: { + schemaGroup: { render: (options) => typescriptFile.renderTemplateGroup({ - group: FASTIFY_STORAGE_MODULE_TEMPLATES.utilsGroup, + group: FASTIFY_STORAGE_MODULE_TEMPLATES.schemaGroup, paths, importMapProviders: { - errorHandlerServiceImports, - serviceContextImports, + pothosImports, }, ...options, }), diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/generated/ts-import-providers.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/generated/ts-import-providers.ts index 322bc082e..6dd8262f2 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/generated/ts-import-providers.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/generated/ts-import-providers.ts @@ -13,17 +13,34 @@ import { import { FASTIFY_STORAGE_MODULE_PATHS } from './template-paths.js'; const storageModuleImportsSchema = createTsImportMapSchema({ + createFileCategory: {}, createPresignedDownloadUrl: {}, + CreatePresignedUploadOptions: { isTypeOnly: true }, createPresignedUploadUrl: {}, - CreatePresignedUploadUrlPayload: { isTypeOnly: true }, + createS3Adapter: {}, + createUrlAdapter: {}, downloadFile: {}, + FILE_CATEGORIES: {}, + FileCategory: { isTypeOnly: true }, + FileCategoryName: { isTypeOnly: true }, + fileInputInputType: {}, + FileMetadata: { isTypeOnly: true }, + FileSize: {}, FileUploadInput: { isTypeOnly: true }, + FileUploadOptions: { isTypeOnly: true }, + getCategoryByName: {}, + getCategoryByNameOrThrow: {}, + getEncodingFromContentType: {}, getMimeTypeFromContentType: {}, - prepareUploadData: {}, - UploadDataInput: { isTypeOnly: true }, - uploadFile: {}, + InvalidExtensionError: {}, + MimeTypes: {}, + PresignedUploadUrl: { isTypeOnly: true }, + STORAGE_ADAPTERS: {}, + StorageAdapter: { isTypeOnly: true }, + StorageAdapterKey: { isTypeOnly: true }, validateFileExtensionWithMimeType: {}, - validateFileUploadInput: {}, + validateFileInput: {}, + validateFileUploadOptions: {}, }); export type StorageModuleImportsProvider = TsImportMapProviderFromSchema< @@ -46,18 +63,34 @@ const fastifyStorageModuleImportsTask = createGeneratorTask({ return { providers: { storageModuleImports: createTsImportMap(storageModuleImportsSchema, { + createFileCategory: paths.utilsCreateFileCategory, createPresignedDownloadUrl: paths.servicesCreatePresignedDownloadUrl, + CreatePresignedUploadOptions: paths.typesAdapter, createPresignedUploadUrl: paths.servicesCreatePresignedUploadUrl, - CreatePresignedUploadUrlPayload: - paths.servicesCreatePresignedUploadUrl, + createS3Adapter: paths.adaptersS_3, + createUrlAdapter: paths.adaptersUrl, downloadFile: paths.servicesDownloadFile, - FileUploadInput: paths.servicesValidateUploadInput, + FILE_CATEGORIES: paths.configCategories, + FileCategory: paths.typesFileCategory, + FileCategoryName: paths.configCategories, + fileInputInputType: paths.schemaFileInput, + FileMetadata: paths.typesAdapter, + FileSize: paths.utilsCreateFileCategory, + FileUploadInput: paths.servicesValidateFileInput, + FileUploadOptions: paths.utilsValidateFileUploadOptions, + getCategoryByName: paths.configCategories, + getCategoryByNameOrThrow: paths.configCategories, + getEncodingFromContentType: paths.utilsMime, getMimeTypeFromContentType: paths.utilsMime, - prepareUploadData: paths.utilsUpload, - UploadDataInput: paths.utilsUpload, - uploadFile: paths.servicesUploadFile, + InvalidExtensionError: paths.utilsMime, + MimeTypes: paths.utilsCreateFileCategory, + PresignedUploadUrl: paths.typesAdapter, + STORAGE_ADAPTERS: paths.configAdapters, + StorageAdapter: paths.typesAdapter, + StorageAdapterKey: paths.configAdapters, validateFileExtensionWithMimeType: paths.utilsMime, - validateFileUploadInput: paths.servicesValidateUploadInput, + validateFileInput: paths.servicesValidateFileInput, + validateFileUploadOptions: paths.utilsValidateFileUploadOptions, }), }, }; diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/generated/typed-templates.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/generated/typed-templates.ts index 00267842a..eb78880dc 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/generated/typed-templates.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/generated/typed-templates.ts @@ -7,300 +7,330 @@ import { } from '@baseplate-dev/fastify-generators'; import path from 'node:path'; -const adaptersIndex = createTsTemplateFile({ +const configAdapters = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, - group: 'adapters', importMapProviders: {}, - name: 'adapters-index', + name: 'config-adapters', + projectExports: { + STORAGE_ADAPTERS: {}, + StorageAdapterKey: { isTypeOnly: true }, + }, source: { path: path.join( import.meta.dirname, - '../templates/module/adapters/index.ts', + '../templates/module/config/adapters.config.ts', ), }, - variables: {}, + variables: { TPL_ADAPTERS: {} }, }); -const adaptersS_3 = createTsTemplateFile({ +const configCategories = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, - group: 'adapters', importMapProviders: {}, - name: 'adapters-s-3', + name: 'config-categories', + projectExports: { + FILE_CATEGORIES: {}, + FileCategoryName: { isTypeOnly: true }, + getCategoryByName: {}, + getCategoryByNameOrThrow: {}, + }, source: { - path: path.join(import.meta.dirname, '../templates/module/adapters/s3.ts'), + path: path.join( + import.meta.dirname, + '../templates/module/config/categories.config.ts', + ), }, - variables: {}, + variables: { TPL_FILE_CATEGORIES: {} }, }); -const adaptersTypes = createTsTemplateFile({ +const adaptersS_3 = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, - group: 'adapters', + group: 'main', importMapProviders: {}, - name: 'adapters-types', + name: 'adapters-s-3', + projectExports: { createS3Adapter: {} }, source: { - path: path.join( - import.meta.dirname, - '../templates/module/adapters/types.ts', - ), + path: path.join(import.meta.dirname, '../templates/module/adapters/s3.ts'), }, variables: {}, }); const adaptersUrl = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, - group: 'adapters', + group: 'main', importMapProviders: {}, name: 'adapters-url', + projectExports: { createUrlAdapter: {} }, source: { path: path.join(import.meta.dirname, '../templates/module/adapters/url.ts'), }, variables: {}, }); -export const adaptersGroup = { - adaptersIndex, - adaptersS_3, - adaptersTypes, - adaptersUrl, -}; - -const constantsAdapters = createTsTemplateFile({ +const servicesCreatePresignedDownloadUrl = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, - group: 'constants', - importMapProviders: {}, - name: 'constants-adapters', - source: { - path: path.join( - import.meta.dirname, - '../templates/module/constants/adapters.ts', - ), + group: 'main', + importMapProviders: { + errorHandlerServiceImports: errorHandlerServiceImportsProvider, + serviceContextImports: serviceContextImportsProvider, }, - variables: { TPL_ADAPTERS: {} }, -}); - -const constantsFileCategories = createTsTemplateFile({ - fileOptions: { kind: 'singleton' }, - group: 'constants', - importMapProviders: { serviceContextImports: serviceContextImportsProvider }, - name: 'constants-file-categories', + name: 'services-create-presigned-download-url', + projectExports: { createPresignedDownloadUrl: {} }, source: { path: path.join( import.meta.dirname, - '../templates/module/constants/file-categories.ts', + '../templates/module/services/create-presigned-download-url.ts', ), }, - variables: { - TPL_FILE_CATEGORIES: {}, - TPL_FILE_COUNT_OUTPUT_TYPE: {}, - TPL_FILE_MODEL_TYPE: {}, - }, + variables: { TPL_FILE_MODEL: {} }, }); -export const constantsGroup = { constantsAdapters, constantsFileCategories }; - -const schemaFileUploadInputType = createTsTemplateFile({ +const servicesCreatePresignedUploadUrl = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, - group: 'schema', - importMapProviders: { pothosImports: pothosImportsProvider }, - name: 'schema-file-upload-input-type', + group: 'main', + importMapProviders: { + errorHandlerServiceImports: errorHandlerServiceImportsProvider, + serviceContextImports: serviceContextImportsProvider, + }, + name: 'services-create-presigned-upload-url', + projectExports: { createPresignedUploadUrl: {} }, source: { path: path.join( import.meta.dirname, - '../templates/module/schema/file-upload.input-type.ts', + '../templates/module/services/create-presigned-upload-url.ts', ), }, - variables: {}, + variables: { TPL_FILE_MODEL: {}, TPL_FILE_MODEL_TYPE: {} }, }); -const schemaPresignedMutations = createTsTemplateFile({ +const servicesDownloadFile = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, - group: 'schema', - importMapProviders: { pothosImports: pothosImportsProvider }, - name: 'schema-presigned-mutations', + group: 'main', + importMapProviders: { + errorHandlerServiceImports: errorHandlerServiceImportsProvider, + serviceContextImports: serviceContextImportsProvider, + }, + name: 'services-download-file', + projectExports: { downloadFile: {} }, source: { path: path.join( import.meta.dirname, - '../templates/module/schema/presigned.mutations.ts', + '../templates/module/services/download-file.ts', ), }, - variables: { TPL_FILE_OBJECT_TYPE: {} }, + variables: { TPL_FILE_MODEL: {} }, }); -const schemaPublicUrlField = createTsTemplateFile({ +const servicesUploadFile = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, - group: 'schema', - importMapProviders: { pothosImports: pothosImportsProvider }, - name: 'schema-public-url-field', + group: 'main', + importMapProviders: { serviceContextImports: serviceContextImportsProvider }, + name: 'services-upload-file', + projectExports: {}, source: { path: path.join( import.meta.dirname, - '../templates/module/schema/public-url.field.ts', + '../templates/module/services/upload-file.ts', ), }, - variables: { TPL_FILE_OBJECT_TYPE: {} }, + variables: { TPL_FILE_MODEL: {}, TPL_FILE_MODEL_TYPE: {} }, }); -export const schemaGroup = { - schemaFileUploadInputType, - schemaPresignedMutations, - schemaPublicUrlField, -}; - -const servicesCreatePresignedDownloadUrl = createTsTemplateFile({ +const servicesValidateFileInput = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, - group: 'services', + group: 'main', importMapProviders: { errorHandlerServiceImports: errorHandlerServiceImportsProvider, + prismaUtilsImports: prismaUtilsImportsProvider, serviceContextImports: serviceContextImportsProvider, }, - name: 'services-create-presigned-download-url', - projectExports: { createPresignedDownloadUrl: {} }, + name: 'services-validate-file-input', + projectExports: { + FileUploadInput: { isTypeOnly: true }, + validateFileInput: {}, + }, source: { path: path.join( import.meta.dirname, - '../templates/module/services/create-presigned-download-url.ts', + '../templates/module/services/validate-file-input.ts', ), }, variables: { TPL_FILE_MODEL: {} }, }); -const servicesCreatePresignedUploadUrl = createTsTemplateFile({ +const typesAdapter = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, - group: 'services', - importMapProviders: { - errorHandlerServiceImports: errorHandlerServiceImportsProvider, - serviceContextImports: serviceContextImportsProvider, - }, - name: 'services-create-presigned-upload-url', + group: 'main', + importMapProviders: {}, + name: 'types-adapter', projectExports: { - createPresignedUploadUrl: {}, - CreatePresignedUploadUrlPayload: { isTypeOnly: true }, + CreatePresignedUploadOptions: { isTypeOnly: true }, + FileMetadata: { isTypeOnly: true }, + PresignedUploadUrl: { isTypeOnly: true }, + StorageAdapter: { isTypeOnly: true }, }, source: { path: path.join( import.meta.dirname, - '../templates/module/services/create-presigned-upload-url.ts', + '../templates/module/types/adapter.ts', ), }, - variables: { TPL_FILE_MODEL: {}, TPL_FILE_MODEL_TYPE: {} }, + variables: {}, }); -const servicesDownloadFile = createTsTemplateFile({ +const typesFileCategory = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, - group: 'services', - importMapProviders: { - errorHandlerServiceImports: errorHandlerServiceImportsProvider, - serviceContextImports: serviceContextImportsProvider, - }, - name: 'services-download-file', - projectExports: { downloadFile: {} }, + group: 'main', + importMapProviders: { serviceContextImports: serviceContextImportsProvider }, + name: 'types-file-category', + projectExports: { FileCategory: { isTypeOnly: true } }, source: { path: path.join( import.meta.dirname, - '../templates/module/services/download-file.ts', + '../templates/module/types/file-category.ts', ), }, - variables: { TPL_FILE_MODEL: {} }, + variables: { TPL_FILE_COUNT_OUTPUT_TYPE: {} }, }); -const servicesUploadFile = createTsTemplateFile({ +const utilsCreateFileCategory = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, - group: 'services', - importMapProviders: { serviceContextImports: serviceContextImportsProvider }, - name: 'services-upload-file', - projectExports: { uploadFile: {} }, + group: 'main', + importMapProviders: {}, + name: 'utils-create-file-category', + projectExports: { createFileCategory: {}, FileSize: {}, MimeTypes: {} }, source: { path: path.join( import.meta.dirname, - '../templates/module/services/upload-file.ts', + '../templates/module/utils/create-file-category.ts', ), }, - variables: { TPL_FILE_MODEL: {}, TPL_FILE_MODEL_TYPE: {} }, + variables: {}, }); -const servicesValidateUploadInput = createTsTemplateFile({ +const utilsMime = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, - group: 'services', + group: 'main', + importMapProviders: {}, + name: 'utils-mime', + projectExports: { + getEncodingFromContentType: {}, + getMimeTypeFromContentType: {}, + InvalidExtensionError: {}, + validateFileExtensionWithMimeType: {}, + }, + source: { + path: path.join(import.meta.dirname, '../templates/module/utils/mime.ts'), + }, + variables: {}, +}); + +const utilsValidateFileUploadOptions = createTsTemplateFile({ + fileOptions: { kind: 'singleton' }, + group: 'main', importMapProviders: { errorHandlerServiceImports: errorHandlerServiceImportsProvider, - prismaUtilsImports: prismaUtilsImportsProvider, serviceContextImports: serviceContextImportsProvider, }, - name: 'services-validate-upload-input', + name: 'utils-validate-file-upload-options', projectExports: { - FileUploadInput: { isTypeOnly: true }, - validateFileUploadInput: {}, + FileUploadOptions: { isTypeOnly: true }, + validateFileUploadOptions: {}, }, source: { path: path.join( import.meta.dirname, - '../templates/module/services/validate-upload-input.ts', + '../templates/module/utils/validate-file-upload-options.ts', ), }, - variables: { TPL_FILE_MODEL: {} }, + variables: { TPL_FILE_CREATE_INPUT: {} }, }); -export const servicesGroup = { +export const mainGroup = { + adaptersS_3, + adaptersUrl, servicesCreatePresignedDownloadUrl, servicesCreatePresignedUploadUrl, servicesDownloadFile, servicesUploadFile, - servicesValidateUploadInput, + servicesValidateFileInput, + typesAdapter, + typesFileCategory, + utilsCreateFileCategory, + utilsMime, + utilsValidateFileUploadOptions, }; -const utilsMime = createTsTemplateFile({ +const schemaFileCategory = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, - group: 'utils', - importMapProviders: {}, - name: 'utils-mime', - projectExports: { - getMimeTypeFromContentType: {}, - validateFileExtensionWithMimeType: {}, - }, + group: 'schema', + importMapProviders: { pothosImports: pothosImportsProvider }, + name: 'schema-file-category', + projectExports: {}, source: { - path: path.join(import.meta.dirname, '../templates/module/utils/mime.ts'), + path: path.join( + import.meta.dirname, + '../templates/module/schema/file-category.enum.ts', + ), }, variables: {}, }); -const utilsMimeUnitTest = createTsTemplateFile({ +const schemaFileInput = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, - group: 'utils', - importMapProviders: {}, - name: 'utils-mime-unit-test', + group: 'schema', + importMapProviders: { pothosImports: pothosImportsProvider }, + name: 'schema-file-input', + projectExports: { fileInputInputType: {} }, source: { path: path.join( import.meta.dirname, - '../templates/module/utils/mime.unit.test.ts', + '../templates/module/schema/file-input.input-type.ts', ), }, variables: {}, }); -const utilsUpload = createTsTemplateFile({ +const schemaPresignedMutations = createTsTemplateFile({ fileOptions: { kind: 'singleton' }, - group: 'utils', - importMapProviders: { - errorHandlerServiceImports: errorHandlerServiceImportsProvider, - serviceContextImports: serviceContextImportsProvider, - }, - name: 'utils-upload', - projectExports: { - prepareUploadData: {}, - UploadDataInput: { isTypeOnly: true }, + group: 'schema', + importMapProviders: { pothosImports: pothosImportsProvider }, + name: 'schema-presigned-mutations', + projectExports: {}, + source: { + path: path.join( + import.meta.dirname, + '../templates/module/schema/presigned.mutations.ts', + ), }, + variables: { TPL_FILE_OBJECT_TYPE: {} }, +}); + +const schemaPublicUrl = createTsTemplateFile({ + fileOptions: { kind: 'singleton' }, + group: 'schema', + importMapProviders: { pothosImports: pothosImportsProvider }, + name: 'schema-public-url', + projectExports: {}, source: { - path: path.join(import.meta.dirname, '../templates/module/utils/upload.ts'), + path: path.join( + import.meta.dirname, + '../templates/module/schema/public-url.field.ts', + ), }, - variables: { TPL_FILE_CREATE_INPUT: {} }, + variables: { TPL_FILE_OBJECT_TYPE: {} }, }); -export const utilsGroup = { utilsMime, utilsMimeUnitTest, utilsUpload }; +export const schemaGroup = { + schemaFileCategory, + schemaFileInput, + schemaPresignedMutations, + schemaPublicUrl, +}; export const FASTIFY_STORAGE_MODULE_TEMPLATES = { - adaptersGroup, - constantsGroup, + configAdapters, + configCategories, + mainGroup, schemaGroup, - servicesGroup, - utilsGroup, }; diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/storage-module.generator.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/storage-module.generator.ts index 40c3e727d..ecf6aed69 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/storage-module.generator.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/storage-module.generator.ts @@ -5,24 +5,18 @@ import { extractPackageVersions, tsCodeFragment, TsCodeUtils, - tsImportBuilder, tsTemplate, tsTypeImportBuilder, - typescriptFileProvider, } from '@baseplate-dev/core-generators'; import { appModuleProvider, configServiceImportsProvider, configServiceProvider, createPothosTypeReference, - errorHandlerServiceImportsProvider, pothosConfigProvider, - pothosImportsProvider, pothosSchemaProvider, pothosTypeOutputProvider, prismaOutputProvider, - prismaUtilsImportsProvider, - serviceContextImportsProvider, } from '@baseplate-dev/fastify-generators'; import { createGenerator, @@ -30,12 +24,13 @@ import { createProviderTask, } from '@baseplate-dev/sync'; import { quot } from '@baseplate-dev/utils'; -import path from 'node:path'; +import { constantCase } from 'es-toolkit'; import { z } from 'zod'; import { STORAGE_PACKAGES } from '#src/constants/index.js'; import { FASTIFY_STORAGE_MODULE_GENERATED } from './generated/index.js'; +import { storageModuleImportsProvider } from './generated/ts-import-providers.js'; const descriptorSchema = z.object({ /** @@ -82,6 +77,7 @@ export const storageModuleGenerator = createGenerator({ buildTasks: ({ fileModel, s3Adapters, categories = [] }) => ({ paths: FASTIFY_STORAGE_MODULE_GENERATED.paths.task, imports: FASTIFY_STORAGE_MODULE_GENERATED.imports.task, + renderers: FASTIFY_STORAGE_MODULE_GENERATED.renderers.task, nodePackages: createNodePackagesTask({ prod: extractPackageVersions(STORAGE_PACKAGES, [ '@aws-sdk/client-s3', @@ -93,26 +89,58 @@ export const storageModuleGenerator = createGenerator({ }), setupFileInputSchema: createGeneratorTask({ dependencies: { - appModule: appModuleProvider, pothosConfig: pothosConfigProvider, + paths: FASTIFY_STORAGE_MODULE_GENERATED.paths.provider, }, - run({ pothosConfig, appModule }) { - const moduleFolder = appModule.getModuleFolder(); + run({ pothosConfig, paths }) { pothosConfig.inputTypes.set( 'FileUploadInput', createPothosTypeReference({ name: 'FileUploadInput', - exportName: 'fileUploadInputInputType', - moduleSpecifier: path.posix.join( - moduleFolder, - 'schema/file-upload.input-type.js', - ), + exportName: 'fileInputInputType', + moduleSpecifier: paths.schemaFileInput, }), ); return {}; }, }), + renderSchema: createGeneratorTask({ + dependencies: { + appModule: appModuleProvider, + renderers: FASTIFY_STORAGE_MODULE_GENERATED.renderers.provider, + pothosSchema: pothosSchemaProvider, + fileObjectType: pothosTypeOutputProvider + .dependency() + .reference(`prisma-object-type:${fileModel}`), + paths: FASTIFY_STORAGE_MODULE_GENERATED.paths.provider, + }, + run({ appModule, pothosSchema, renderers, fileObjectType, paths }) { + const { schemaGroup } = FASTIFY_STORAGE_MODULE_GENERATED.templates; + for (const template of Object.keys(schemaGroup)) { + const renderedPath = paths[template as keyof typeof schemaGroup]; + appModule.moduleImports.push(renderedPath); + pothosSchema.registerSchemaFile(renderedPath); + } + return { + build: async (builder) => { + const fileObjectRef = fileObjectType.getTypeReference(); + await builder.apply( + renderers.schemaGroup.render({ + variables: { + schemaPresignedMutations: { + TPL_FILE_OBJECT_TYPE: fileObjectRef.fragment, + }, + schemaPublicUrl: { + TPL_FILE_OBJECT_TYPE: fileObjectRef.fragment, + }, + }, + }), + ); + }, + }; + }, + }), config: createProviderTask(configServiceProvider, (configService) => { configService.configFields.mergeObj({ AWS_ACCESS_KEY_ID: { @@ -150,88 +178,24 @@ export const storageModuleGenerator = createGenerator({ }), build: createGeneratorTask({ dependencies: { - typescriptFile: typescriptFileProvider, - pothosSchema: pothosSchemaProvider, - pothosImports: pothosImportsProvider, - appModule: appModuleProvider, - serviceContextImports: serviceContextImportsProvider, - errorHandlerServiceImports: errorHandlerServiceImportsProvider, prismaOutput: prismaOutputProvider, configServiceImports: configServiceImportsProvider, - prismaUtilsImports: prismaUtilsImportsProvider, - fileObjectType: pothosTypeOutputProvider - .dependency() - .reference(`prisma-object-type:${fileModel}`), - paths: FASTIFY_STORAGE_MODULE_GENERATED.paths.provider, + renderers: FASTIFY_STORAGE_MODULE_GENERATED.renderers.provider, + storageModuleImports: storageModuleImportsProvider, }, run({ - typescriptFile, - appModule, - pothosSchema, - pothosImports, - serviceContextImports, - errorHandlerServiceImports, prismaOutput, configServiceImports, - prismaUtilsImports, - fileObjectType, - paths, + renderers, + storageModuleImports, }) { - const moduleFolder = appModule.getModuleFolder(); - return { build: async (builder) => { - // Copy adapters - await builder.apply( - typescriptFile.renderTemplateGroup({ - group: FASTIFY_STORAGE_MODULE_GENERATED.templates.adaptersGroup, - paths, - }), - ); - - // Copy schema - const fileObjectRef = fileObjectType.getTypeReference(); - const { schemaGroup } = FASTIFY_STORAGE_MODULE_GENERATED.templates; - for (const template of Object.keys(schemaGroup)) { - appModule.moduleImports.push( - paths[template as keyof typeof schemaGroup], - ); - pothosSchema.registerSchemaFile( - paths[template as keyof typeof schemaGroup], - ); - } - - await builder.apply( - typescriptFile.renderTemplateGroup({ - group: FASTIFY_STORAGE_MODULE_GENERATED.templates.schemaGroup, - paths, - importMapProviders: { - pothosImports, - }, - variables: { - schemaPublicUrlField: { - TPL_FILE_OBJECT_TYPE: fileObjectRef.fragment, - }, - schemaPresignedMutations: { - TPL_FILE_OBJECT_TYPE: fileObjectRef.fragment, - }, - }, - }), - ); - - // Copy services const model = prismaOutput.getPrismaModelFragment(fileModel); const modelType = prismaOutput.getModelTypeFragment(fileModel); - + // Render module await builder.apply( - typescriptFile.renderTemplateGroup({ - group: FASTIFY_STORAGE_MODULE_GENERATED.templates.servicesGroup, - paths, - importMapProviders: { - errorHandlerServiceImports, - serviceContextImports, - prismaUtilsImports, - }, + renderers.mainGroup.render({ variables: { servicesCreatePresignedDownloadUrl: { TPL_FILE_MODEL: model, @@ -243,28 +207,20 @@ export const storageModuleGenerator = createGenerator({ servicesDownloadFile: { TPL_FILE_MODEL: model, }, - servicesValidateUploadInput: { - TPL_FILE_MODEL: model, - }, servicesUploadFile: { TPL_FILE_MODEL: model, TPL_FILE_MODEL_TYPE: modelType, }, - }, - }), - ); - - // Copy utils - await builder.apply( - typescriptFile.renderTemplateGroup({ - group: FASTIFY_STORAGE_MODULE_GENERATED.templates.utilsGroup, - paths, - importMapProviders: { - serviceContextImports, - errorHandlerServiceImports, - }, - variables: { - utilsUpload: { + servicesValidateFileInput: { + TPL_FILE_MODEL: model, + }, + typesFileCategory: { + TPL_FILE_COUNT_OUTPUT_TYPE: tsCodeFragment( + `Prisma.${fileModel}CountOutputType`, + tsTypeImportBuilder(['Prisma']).from('@prisma/client'), + ), + }, + utilsValidateFileUploadOptions: { TPL_FILE_CREATE_INPUT: tsCodeFragment( `Prisma.${fileModel}CreateInput`, tsTypeImportBuilder(['Prisma']).from('@prisma/client'), @@ -274,7 +230,7 @@ export const storageModuleGenerator = createGenerator({ }), ); - // Copy constants + // Render adapters config const adapterMap = new Map(); for (const adapter of s3Adapters) { @@ -289,9 +245,7 @@ export const storageModuleGenerator = createGenerator({ adapterMap.set( adapter.name, TsCodeUtils.templateWithImports([ - tsImportBuilder(['createS3Adapter']).from( - path.posix.join(moduleFolder, 'adapters/index.js'), - ), + storageModuleImports.createS3Adapter.declaration(), configServiceImports.config.declaration(), ])`createS3Adapter(${adapterOptions})`, ); @@ -301,53 +255,50 @@ export const storageModuleGenerator = createGenerator({ 'url', tsCodeFragment( 'createUrlAdapter()', - tsImportBuilder(['createUrlAdapter']).from( - path.posix.join(moduleFolder, 'adapters/index.js'), - ), + storageModuleImports.createUrlAdapter.declaration(), ), ); + await builder.apply( + renderers.configAdapters.render({ + variables: { + TPL_ADAPTERS: TsCodeUtils.mergeFragmentsAsObject(adapterMap), + }, + }), + ); + + // Copy constants + const categoriesMap = new Map(); for (const category of categories) { categoriesMap.set( category.name, - TsCodeUtils.mergeFragmentsAsObject({ - name: quot(category.name), - authorizeUpload: - category.uploadRoles.length > 0 - ? tsTemplate`({ auth }) => auth.hasSomeRole(${TsCodeUtils.mergeFragmentsAsArrayPresorted( - category.uploadRoles.map(quot).sort(), - )})` - : undefined, - defaultAdapter: quot(category.defaultAdapter), - maxFileSize: `${category.maxFileSize ?? 100} * MEGABYTE`, - usedByRelation: quot(category.usedByRelation), - }), + tsTemplate` + ${storageModuleImports.createFileCategory.fragment()}(${TsCodeUtils.mergeFragmentsAsObject( + { + // TODO [2025-06-02]: Remove once validation kicks in and add allowed Mime Types + name: quot(constantCase(category.name)), + maxFileSize: tsTemplate`${storageModuleImports.FileSize.fragment()}.MB(${category.maxFileSize?.toString() ?? '100'})`, + authorize: + category.uploadRoles.length > 0 + ? tsTemplate`{ + upload: ({ auth }) => auth.hasSomeRole(${TsCodeUtils.mergeFragmentsAsArrayPresorted( + category.uploadRoles.map(quot).sort(), + )}) + }` + : undefined, + adapter: quot(category.defaultAdapter), + referencedByRelation: quot(category.usedByRelation), + }, + )})`, ); } await builder.apply( - typescriptFile.renderTemplateGroup({ - group: - FASTIFY_STORAGE_MODULE_GENERATED.templates.constantsGroup, - paths, - importMapProviders: { - serviceContextImports, - }, + renderers.configCategories.render({ variables: { - constantsAdapters: { - TPL_ADAPTERS: - TsCodeUtils.mergeFragmentsAsObject(adapterMap), - }, - constantsFileCategories: { - TPL_FILE_CATEGORIES: - TsCodeUtils.mergeFragmentsAsArray(categoriesMap), - TPL_FILE_COUNT_OUTPUT_TYPE: tsCodeFragment( - `Prisma.${fileModel}CountOutputType`, - tsTypeImportBuilder(['Prisma']).from('@prisma/client'), - ), - TPL_FILE_MODEL_TYPE: modelType, - }, + TPL_FILE_CATEGORIES: + TsCodeUtils.mergeFragmentsAsArray(categoriesMap), }, }), ); diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/index.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/index.ts deleted file mode 100644 index 0b758c793..000000000 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// @ts-nocheck - -export * from './s3.js'; -export type * from './types.js'; -export * from './url.js'; diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/s3.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/s3.ts index 26dea88ac..076a79926 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/s3.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/s3.ts @@ -18,7 +18,7 @@ import type { FileMetadata, PresignedUploadUrl, StorageAdapter, -} from './types.js'; +} from '../types/adapter.js'; /** Options for the S3 adapter. */ interface S3AdapterOptions { @@ -62,6 +62,9 @@ export const createS3Adapter = (options: S3AdapterOptions): StorageAdapter => { { key: path }, ...(contentType ? [{ 'Content-Type': contentType }] : []), ], + Fields: { + 'If-None-Match': '*', + }, Expires: expirationSeconds, }); diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/url.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/url.ts index af4ec246a..58c2c4b1e 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/url.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/url.ts @@ -4,7 +4,7 @@ import type { Readable } from 'node:stream'; import axios from 'axios'; -import type { StorageAdapter } from './types.js'; +import type { StorageAdapter } from '../types/adapter.js'; /** * Minimal adapter that just converts path to URL directly. diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/constants/adapters.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/config/adapters.config.ts similarity index 100% rename from plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/constants/adapters.ts rename to plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/config/adapters.config.ts diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/config/categories.config.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/config/categories.config.ts new file mode 100644 index 000000000..c2a1a2695 --- /dev/null +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/config/categories.config.ts @@ -0,0 +1,23 @@ +// @ts-nocheck + +import type { FileCategory } from '../types/file-category.js'; + +// Collected registry for all file categories +export const FILE_CATEGORIES = TPL_FILE_CATEGORIES as const; + +// Type-safe category lookup +export type FileCategoryName = (typeof FILE_CATEGORIES)[number]['name']; + +// Helper function for services +export function getCategoryByName(name: string): FileCategory | undefined { + return FILE_CATEGORIES.find((c) => c.name === name); +} + +// Helper function with error throwing +export function getCategoryByNameOrThrow(name: string): FileCategory { + const category = getCategoryByName(name); + if (!category) { + throw new Error(`File category ${name} not found.`); + } + return category; +} diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/constants/file-categories.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/constants/file-categories.ts deleted file mode 100644 index 3c2ac10e8..000000000 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/constants/file-categories.ts +++ /dev/null @@ -1,23 +0,0 @@ -// @ts-nocheck - -import type { ServiceContext } from '%serviceContextImports'; - -import type { StorageAdapterKey } from './adapters.js'; - -export interface FileCategory { - name: string; - authorizeUpload?: (context: ServiceContext) => Promise | boolean; - authorizeRead?: ( - file: TPL_FILE_MODEL_TYPE, - context: ServiceContext, - ) => Promise | boolean; - minFileSize?: number; - maxFileSize: number; - allowedMimeTypes?: string[]; - defaultAdapter: StorageAdapterKey; - usedByRelation: keyof TPL_FILE_COUNT_OUTPUT_TYPE; -} - -const MEGABYTE = 1024 * 1024; - -export const FILE_CATEGORIES: FileCategory[] = TPL_FILE_CATEGORIES; diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/file-category.enum.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/file-category.enum.ts new file mode 100644 index 000000000..a617d2e53 --- /dev/null +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/file-category.enum.ts @@ -0,0 +1,9 @@ +// @ts-nocheck + +import { builder } from '%pothosImports'; + +import { FILE_CATEGORIES } from '../config/categories.config.js'; + +export const fileCategoryEnumType = builder.enumType('FileCategory', { + values: Object.values(FILE_CATEGORIES).map((category) => category.name), +}); diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/file-upload.input-type.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/file-input.input-type.ts similarity index 63% rename from plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/file-upload.input-type.ts rename to plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/file-input.input-type.ts index 99c942af7..1368ef8a3 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/file-upload.input-type.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/file-input.input-type.ts @@ -2,7 +2,8 @@ import { builder } from '%pothosImports'; -export const fileUploadInputInputType = builder.inputType('FileUploadInput', { +export const fileInputInputType = builder.inputType('FileInput', { + description: 'Input representing an uploaded file', fields: (t) => ({ id: t.field({ required: true, type: 'Uuid' }), name: t.string({ description: 'Discarded but useful for forms' }), diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/presigned.mutations.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/presigned.mutations.ts index 5252e0624..7fe54b303 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/presigned.mutations.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/presigned.mutations.ts @@ -4,6 +4,7 @@ import { builder } from '%pothosImports'; import { createPresignedDownloadUrl } from '../services/create-presigned-download-url.js'; import { createPresignedUploadUrl } from '../services/create-presigned-upload-url.js'; +import { fileCategoryEnumType } from './file-category.enum.js'; export const presignedUrlFieldObjectType = builder.simpleObject( 'PresignedUrlField', @@ -19,10 +20,10 @@ builder.mutationField('createPresignedUploadUrl', (t) => t.fieldWithInputPayload({ authorize: 'user', input: { - category: t.input.string({ required: true }), + category: t.input.field({ type: fileCategoryEnumType, required: true }), contentType: t.input.string({ required: true }), - fileName: t.input.string({ required: true }), - fileSize: t.input.int({ required: true }), + filename: t.input.string({ required: true }), + size: t.input.int({ required: true }), }, payload: { url: t.payload.string(), diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/public-url.field.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/public-url.field.ts index 049d58982..7779c630b 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/public-url.field.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/schema/public-url.field.ts @@ -2,21 +2,21 @@ import { builder } from '%pothosImports'; -import type { StorageAdapterKey } from '../constants/adapters.js'; +import type { StorageAdapterKey } from '../config/adapters.config.js'; -import { STORAGE_ADAPTERS } from '../constants/adapters.js'; +import { STORAGE_ADAPTERS } from '../config/adapters.config.js'; builder.objectField(TPL_FILE_OBJECT_TYPE, 'publicUrl', (t) => t.string({ description: 'URL of the file where it is publicly hosted. Returns null if it is not publicly available.', nullable: true, - resolve: ({ adapter: adapterName, path }) => { + resolve: ({ adapter: adapterName, storagePath }) => { if (!(adapterName in STORAGE_ADAPTERS)) { throw new Error(`Unknown adapter ${adapterName}`); } const adapter = STORAGE_ADAPTERS[adapterName as StorageAdapterKey]; - return adapter.getPublicUrl?.(path) ?? null; + return adapter.getPublicUrl?.(storagePath) ?? null; }, }), ); diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/create-presigned-download-url.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/create-presigned-download-url.ts index e07e4e341..a4d5d0a8a 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/create-presigned-download-url.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/create-presigned-download-url.ts @@ -4,8 +4,8 @@ import type { ServiceContext } from '%serviceContextImports'; import { ForbiddenError } from '%errorHandlerServiceImports'; -import { STORAGE_ADAPTERS } from '../constants/adapters.js'; -import { FILE_CATEGORIES } from '../constants/file-categories.js'; +import { STORAGE_ADAPTERS } from '../config/adapters.config.js'; +import { getCategoryByNameOrThrow } from '../config/categories.config.js'; interface CreatePresignedDownloadUrlInput { fileId: string; @@ -23,13 +23,11 @@ export async function createPresignedDownloadUrl( where: { id: fileId }, }); - const category = FILE_CATEGORIES.find((c) => c.name === file.category); - if (!category) { - throw new Error(`Invalid file category ${file.category}`); - } + const category = getCategoryByNameOrThrow(file.category); const isAuthorizedToRead = - !category.authorizeRead || (await category.authorizeRead(file, context)); + !category.authorize?.presignedRead || + (await category.authorize.presignedRead(file, context)); if (!isAuthorizedToRead) { throw new ForbiddenError('You are not authorized to read this file'); @@ -47,7 +45,7 @@ export async function createPresignedDownloadUrl( ); } - const url = await adapter.createPresignedDownloadUrl(file.path); + const url = await adapter.createPresignedDownloadUrl(file.storagePath); return { url }; } diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/create-presigned-upload-url.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/create-presigned-upload-url.ts index 83a40e9b1..9dbb954a6 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/create-presigned-upload-url.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/create-presigned-upload-url.ts @@ -4,11 +4,9 @@ import type { ServiceContext } from '%serviceContextImports'; import { BadRequestError } from '%errorHandlerServiceImports'; -import type { UploadDataInput } from '../utils/upload.js'; +import type { FileUploadOptions } from '../utils/validate-file-upload-options.js'; -import { prepareUploadData } from '../utils/upload.js'; - -type CreatePresignedUploadUrlInput = UploadDataInput; +import { validateFileUploadOptions } from '../utils/validate-file-upload-options.js'; export interface CreatePresignedUploadUrlPayload { url: string; @@ -19,13 +17,11 @@ export interface CreatePresignedUploadUrlPayload { } export async function createPresignedUploadUrl( - input: CreatePresignedUploadUrlInput, + input: FileUploadOptions, context: ServiceContext, ): Promise { - const { data, fileCategory, adapter } = await prepareUploadData( - input, - context, - ); + const { fileCreateInput, fileCategory, adapter } = + await validateFileUploadOptions(input, context); if (!adapter.createPresignedUploadUrl) { throw new BadRequestError( @@ -33,15 +29,15 @@ export async function createPresignedUploadUrl( ); } - const file = await TPL_FILE_MODEL.create({ data }); + const file = await TPL_FILE_MODEL.create({ data: fileCreateInput }); const result = await adapter.createPresignedUploadUrl({ - path: data.path, + path: file.storagePath, contentLengthRange: [ fileCategory.minFileSize ?? 0, fileCategory.maxFileSize, ], - contentType: data.mimeType, + contentType: input.contentType, }); return { diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/download-file.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/download-file.ts index b861445b6..4999da3c3 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/download-file.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/download-file.ts @@ -6,9 +6,15 @@ import type { Readable } from 'node:stream'; import { ForbiddenError } from '%errorHandlerServiceImports'; -import { STORAGE_ADAPTERS } from '../constants/adapters.js'; -import { FILE_CATEGORIES } from '../constants/file-categories.js'; - +import { STORAGE_ADAPTERS } from '../config/adapters.config.js'; +import { getCategoryByNameOrThrow } from '../config/categories.config.js'; + +/** + * Downloads a file from storage + * @param fileIdOrFile - The file ID or file object + * @param context - The service context + * @returns The file contents as a stream + */ export async function downloadFile( fileIdOrFile: string | File, context: ServiceContext, @@ -20,13 +26,12 @@ export async function downloadFile( }) : fileIdOrFile; - const category = FILE_CATEGORIES.find((c) => c.name === file.category); - if (!category) { - throw new Error(`Invalid file category ${file.category}`); - } + const category = getCategoryByNameOrThrow(file.category); const isAuthorizedToRead = - !category.authorizeRead || (await category.authorizeRead(file, context)); + context.auth.roles.includes('system') || + !category.authorize?.presignedRead || + (await category.authorize.presignedRead(file, context)); if (!isAuthorizedToRead) { throw new ForbiddenError('You are not authorized to read this file'); @@ -38,5 +43,5 @@ export async function downloadFile( const adapter = STORAGE_ADAPTERS[file.adapter as keyof typeof STORAGE_ADAPTERS]; - return adapter.downloadFile(file.path); + return adapter.downloadFile(file.storagePath); } diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/upload-file.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/upload-file.ts index 80a62f9a0..b4564546b 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/upload-file.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/upload-file.ts @@ -2,23 +2,30 @@ import type { ServiceContext } from '%serviceContextImports'; -import type { UploadDataInput } from '../utils/upload.js'; +import type { FileUploadOptions } from '../utils/validate-file-upload-options.js'; -import { prepareUploadData } from '../utils/upload.js'; - -interface UploadFileInput extends UploadDataInput { - contents: Buffer; -} +import { validateFileUploadOptions } from '../utils/validate-file-upload-options.js'; +/** + * Uploads a file to storage + * @param contents - The file contents + * @param options - The file upload options + * @param context - The service context + * @returns The uploaded file + */ export async function uploadFile( - input: UploadFileInput, + contents: Buffer, + options: FileUploadOptions, context: ServiceContext, ): Promise { - const { data, adapter } = await prepareUploadData(input, context); + const { fileCreateInput, adapter } = await validateFileUploadOptions( + options, + context, + ); - const file = await TPL_FILE_MODEL.create({ data }); + const file = await TPL_FILE_MODEL.create({ data: fileCreateInput }); - await adapter.uploadFile(file.path, input.contents); + await adapter.uploadFile(file.storagePath, contents); return file; } diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/validate-file-input.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/validate-file-input.ts new file mode 100644 index 000000000..08d431a73 --- /dev/null +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/validate-file-input.ts @@ -0,0 +1,90 @@ +// @ts-nocheck + +import type { DataPipeOutput } from '%prismaUtilsImports'; +import type { ServiceContext } from '%serviceContextImports'; + +import { BadRequestError } from '%errorHandlerServiceImports'; + +import type { FileCategory } from '../types/file-category.js'; + +import { STORAGE_ADAPTERS } from '../config/adapters.config.js'; + +export interface FileUploadInput { + id: string; +} + +/** + * Validates a file input and checks the upload is authorized + * @param input - The file input + * @param category - The category of the file + * @param context - The service context + * @param existingId - The existing ID of the file (if any) + * @returns The data pipe output + */ +export async function validateFileInput( + { id }: FileUploadInput, + category: FileCategory, + context: ServiceContext, + existingId?: string | null, +): Promise> { + // if we're updating and not changing the ID, skip checks + if (existingId === id) { + return { data: { connect: { id } } }; + } + + const file = await TPL_FILE_MODEL.findUnique({ + where: { id }, + }); + + // Check if file exists + if (!file) { + throw new BadRequestError(`File with ID "${id}" does not exist`); + } + + // Check authorization: must be system role or the uploader + const isSystemUser = context.auth.roles.includes('system'); + const isUploader = file.uploaderId === context.auth.userId; + + if (!isSystemUser && !isUploader) { + throw new BadRequestError( + `Access denied: You can only use files that you uploaded. File "${id}" was uploaded by a different user.`, + ); + } + + // Check if file is already referenced + if (file.referencedAt) { + throw new BadRequestError( + `File "${id}" is already in use and cannot be referenced again. Please upload a new file.`, + ); + } + + // Check category match + if (file.category !== category.name) { + throw new BadRequestError( + `File category mismatch: File "${id}" belongs to category "${file.category}" but expected "${category.name}". Please upload a file of the correct type.`, + ); + } + + // Validate file was uploaded + const adapter = + STORAGE_ADAPTERS[file.adapter as keyof typeof STORAGE_ADAPTERS]; + const fileMetadata = await adapter.getFileMetadata(file.storagePath); + if (!fileMetadata) { + throw new BadRequestError(`File "${id}" was not uploaded correctly.`); + } + + return { + data: { connect: { id } }, + operations: { + afterPrismaPromises: [ + TPL_FILE_MODEL.update({ + where: { id }, + data: { + referencedAt: new Date(), + size: fileMetadata.size, + }, + }), + ], + }, + }; +} diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/validate-upload-input.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/validate-upload-input.ts deleted file mode 100644 index ba3ad0fcd..000000000 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/services/validate-upload-input.ts +++ /dev/null @@ -1,53 +0,0 @@ -// @ts-nocheck - -import type { DataPipeOutput } from '%prismaUtilsImports'; -import type { ServiceContext } from '%serviceContextImports'; - -import { BadRequestError } from '%errorHandlerServiceImports'; - -export interface FileUploadInput { - id: string; -} - -export async function validateFileUploadInput( - { id }: FileUploadInput, - category: string, - context: ServiceContext, - existingId?: string | null, -): Promise> { - // if we're updating and not changing the ID, skip checks - if (existingId === id) { - return { data: { connect: { id } } }; - } - - const file = await TPL_FILE_MODEL.findUnique({ - where: { id }, - }); - - // Operation must either be conducted by system - // or the user who uploaded the file - if ( - !file || - (!context.auth.roles.includes('system') && - file.uploaderId !== context.auth.userIdOrThrow()) - ) { - throw new BadRequestError(`File with ID ${id} not found`); - } - - if (file.isUsed) { - throw new BadRequestError(`File with ID ${id} is already used elsewhere`); - } - - if (file.category !== category) { - throw new BadRequestError(`File with ID ${id} must match ${category}`); - } - - return { - data: { connect: { id } }, - operations: { - afterPrismaPromises: [ - TPL_FILE_MODEL.update({ where: { id }, data: { isUsed: true } }), - ], - }, - }; -} diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/types.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/types/adapter.ts similarity index 100% rename from plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/adapters/types.ts rename to plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/types/adapter.ts diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/types/file-category.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/types/file-category.ts new file mode 100644 index 000000000..8b1f5c70e --- /dev/null +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/types/file-category.ts @@ -0,0 +1,54 @@ +// @ts-nocheck + +import type { ServiceContext } from '%serviceContextImports'; +import type { File } from '@prisma/client'; + +import type { StorageAdapterKey } from '../config/adapters.config.js'; + +/** + * Configuration for a file category that specifies how files for a + * particular model relation to File model should be handled. + */ +export interface FileCategory { + /** Name of category (must be CONSTANT_CASE) */ + readonly name: TName; + + /** + * Path prefix for this category. + * + * If provided, the path will be prefixed with this value e.g. /// + * + * If not provided, the path will be prefixed with the lowercase form of the name. + */ + readonly pathPrefix?: string; + + /** Maximum file size in bytes */ + readonly maxFileSize: number; + + /** Minimum file size in bytes (optional) */ + readonly minFileSize?: number; + + /** Allowed MIME types */ + readonly allowedMimeTypes?: readonly string[]; + + /** Storage adapter to use for this category */ + readonly adapter: StorageAdapterKey; + + /** + * Authorization rules for this file category. + * If not provided, all access will be denied for external users. + * System operations will still work regardless of authorization. + */ + readonly authorize?: { + upload?: (context: ServiceContext) => Promise | boolean; + presignedRead?: ( + file: File, + context: ServiceContext, + ) => Promise | boolean; + }; + + /** + * The relation that references this file category. + */ + readonly referencedByRelation: keyof TPL_FILE_COUNT_OUTPUT_TYPE; +} diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/create-file-category.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/create-file-category.ts new file mode 100644 index 000000000..097d323ae --- /dev/null +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/create-file-category.ts @@ -0,0 +1,36 @@ +// @ts-nocheck + +import type { FileCategory } from '../types/file-category.js'; + +// Helper for common file size constraints +export const FileSize = { + KB: (n: number) => n * 1024, + MB: (n: number) => n * 1024 * 1024, + GB: (n: number) => n * 1024 * 1024 * 1024, +} as const; + +// Helper for common MIME types +export const MimeTypes = { + images: ['image/jpeg', 'image/png', 'image/webp'], + documents: [ + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ], +} as const; + +export function createFileCategory( + config: FileCategory, +): FileCategory { + if (!/^[A-Z][A-Z0-9_]*$/.test(config.name)) { + throw new Error( + 'File category name must be CONSTANT_CASE (e.g., USER_AVATAR, POST_IMAGE)', + ); + } + + if (config.maxFileSize <= 0) { + throw new Error('Max file size must be positive'); + } + + return config; +} diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/mime.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/mime.ts index 9d742437e..1e2908c2e 100644 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/mime.ts +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/mime.ts @@ -4,21 +4,59 @@ import mime from 'mime-types'; import path from 'node:path'; export function getMimeTypeFromContentType(contentType: string): string { - return contentType.split(':')[0]; + return contentType.split(';')[0].trim(); +} + +export function getEncodingFromContentType( + contentType: string, +): string | undefined { + // Match charset in content type, e.g., text/html; charset=UTF-8 + const match = /charset\s*=\s*["']?([^;"'\s]+)/i.exec(contentType); + if (!match) return undefined; + + const charset = match[1].trim().toLowerCase(); + + // Node.js uses Buffer.isEncoding for valid encodings + return Buffer.isEncoding(charset) ? charset : 'utf-8'; +} + +export class InvalidExtensionError extends Error { + constructor( + message: string, + public readonly expectedFileExtensions: string[], + ) { + super(message); + this.name = 'InvalidMimeTypeError'; + } } export function validateFileExtensionWithMimeType( mimeType: string, - fileName: string, + filename: string, ): void { + if (!mimeType || typeof mimeType !== 'string') { + throw new Error( + `Invalid mime type: ${mimeType}. Must be a valid MIME type string.`, + ); + } + if (!(mimeType in mime.extensions)) { - throw new Error(`Invalid mime type ${mimeType}`); + throw new Error( + `Unsupported mime type: ${mimeType}. Please use a supported file type.`, + ); } + const extensions = mime.extensions[mimeType]; - const extension = path.extname(fileName).slice(1).toLowerCase(); + const extension = path.extname(filename).slice(1).toLowerCase(); + + if (!extension) { + throw new Error(`File "${filename}" must have a file extension.`); + } + if (!extensions.includes(extension)) { - throw new Error( - `File extension ${extension} does not match mime type ${mimeType}`, + throw new InvalidExtensionError( + `File extension ".${extension}" does not match mime type "${mimeType}". Expected one of: ${extensions.map((ext) => `.${ext}`).join(', ')}`, + extensions, ); } } diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/mime.unit.test.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/mime.unit.test.ts deleted file mode 100644 index a34a607e7..000000000 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/mime.unit.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -// @ts-nocheck - -import { describe, expect, it } from 'vitest'; - -import { validateFileExtensionWithMimeType } from './mime.js'; - -describe('validateFileExtensionWithMimeType', () => { - it.each([ - { mimeType: 'text/html', fileName: 'test.html' }, - { mimeType: 'image/jpeg', fileName: 'image.jpg' }, - { mimeType: 'image/jpeg', fileName: 'image.jpeg' }, - ])('should match $fileName as $mimeType', ({ mimeType, fileName }) => { - expect(() => { - validateFileExtensionWithMimeType(mimeType, fileName); - }).not.toThrow(); - }); - - it.each([ - { mimeType: 'text/html', fileName: 'test.html5' }, - { mimeType: 'text/jpeg', fileName: 'image.exe' }, - ])('should not match $fileName as $mimeType', ({ mimeType, fileName }) => { - expect(() => { - validateFileExtensionWithMimeType(mimeType, fileName); - }).toThrow(); - }); -}); diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/upload.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/upload.ts deleted file mode 100644 index 2a120be1f..000000000 --- a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/upload.ts +++ /dev/null @@ -1,111 +0,0 @@ -// @ts-nocheck - -import type { ServiceContext } from '%serviceContextImports'; - -import { BadRequestError, ForbiddenError } from '%errorHandlerServiceImports'; -import { nanoid } from 'nanoid'; - -import type { StorageAdapter } from '../adapters/index.js'; -import type { FileCategory } from '../constants/file-categories.js'; - -import { STORAGE_ADAPTERS } from '../constants/adapters.js'; -import { FILE_CATEGORIES } from '../constants/file-categories.js'; -import { - getMimeTypeFromContentType, - validateFileExtensionWithMimeType, -} from './mime.js'; - -export interface UploadDataInput { - category: string; - contentType: string; - fileName: string; - fileSize: number; -} - -/** - * There are a set of unsafe characters that should be replaced - * - * https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html - * - */ -function makeFileNameSafe(filename: string): string { - return filename.replaceAll(/[^a-zA-Z0-9!\-_.*'()]/g, '_'); -} - -export async function prepareUploadData( - { category, contentType, fileName, fileSize }: UploadDataInput, - context: ServiceContext, -): Promise<{ - data: TPL_FILE_CREATE_INPUT; - fileCategory: FileCategory; - adapter: StorageAdapter; -}> { - const fileCategory = FILE_CATEGORIES.find((c) => c.name === category); - - if (!fileCategory) { - throw new BadRequestError(`Invalid file category ${category}`); - } - - if ( - !fileCategory.authorizeUpload || - !(await Promise.resolve(fileCategory.authorizeUpload(context))) - ) { - throw new ForbiddenError( - `You are not authorized to upload files to ${category}`, - ); - } - - if (fileCategory.minFileSize && fileSize < fileCategory.minFileSize) { - throw new BadRequestError( - `File size is below minimum file size of ${fileCategory.minFileSize}`, - ); - } - - if (fileCategory.maxFileSize && fileSize > fileCategory.maxFileSize) { - throw new BadRequestError( - `File size is above maximum file size of ${fileCategory.maxFileSize}`, - ); - } - - // mime type validation - const mimeType = getMimeTypeFromContentType(contentType); - - validateFileExtensionWithMimeType(mimeType, fileName); - - if ( - fileCategory.allowedMimeTypes && - !fileCategory.allowedMimeTypes.includes(mimeType) - ) { - throw new BadRequestError( - `File mime type ${mimeType} is not allowed for ${fileCategory.name}`, - ); - } - - const adapter = STORAGE_ADAPTERS[fileCategory.defaultAdapter]; - - if (fileName.length > 128) { - throw new BadRequestError(`File name is too long`); - } - - const cleanedFileName = makeFileNameSafe(fileName); - - const path = `${fileCategory.name}/${nanoid(14)}/${cleanedFileName}`; - - return { - adapter, - fileCategory, - data: { - name: cleanedFileName, - path, - category: fileCategory.name, - adapter: fileCategory.defaultAdapter, - mimeType, - size: fileSize, - shouldDelete: false, - isUsed: false, - uploader: context.auth.userId - ? { connect: { id: context.auth.userId } } - : undefined, - }, - }; -} diff --git a/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/validate-file-upload-options.ts b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/validate-file-upload-options.ts new file mode 100644 index 000000000..fc8915c75 --- /dev/null +++ b/plugins/plugin-storage/src/generators/fastify/storage-module/templates/module/utils/validate-file-upload-options.ts @@ -0,0 +1,188 @@ +// @ts-nocheck + +import type { ServiceContext } from '%serviceContextImports'; + +import { BadRequestError, ForbiddenError } from '%errorHandlerServiceImports'; +import { nanoid } from 'nanoid'; +import { z } from 'zod'; + +import type { StorageAdapter } from '../types/adapter.js'; +import type { FileCategory } from '../types/file-category.js'; + +import { STORAGE_ADAPTERS } from '../config/adapters.config.js'; +import { getCategoryByNameOrThrow } from '../config/categories.config.js'; +import { + getEncodingFromContentType, + getMimeTypeFromContentType, + InvalidExtensionError, + validateFileExtensionWithMimeType, +} from './mime.js'; + +// Constants +const MAX_filename_LENGTH = 128; + +/** + * There are a set of unsafe characters that should be replaced + * + * https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html + * + */ +function makefilenameSafe(filename: string): string { + return filename.replaceAll(/[^a-zA-Z0-9!\-_.*'()]/g, '_'); +} + +const fileUploadOptionsSchema = z.object({ + /** The file name */ + filename: z + .string({ required_error: 'File name is required and must be a string' }) + .max(MAX_filename_LENGTH, { + message: `File name is too long (max ${MAX_filename_LENGTH} characters)`, + }), + /** The file size in bytes */ + size: z + .number({ + required_error: 'File size is required and must be a positive number', + }) + .positive({ + message: 'File size is required and must be a positive number', + }), + /** The content type of the file */ + contentType: z.string({ required_error: 'Content type is required' }).min(1), + /** The category of the file */ + category: z.string({ required_error: 'Category is required' }).min(1), +}); + +export type FileUploadOptions = z.infer; + +/** + * Validates file size constraints for the category + */ +function validateFileSize(fileCategory: FileCategory, fileSize: number): void { + if (fileCategory.minFileSize && fileSize < fileCategory.minFileSize) { + throw new BadRequestError( + `File size ${fileSize} bytes is below minimum of ${fileCategory.minFileSize} bytes for category ${fileCategory.name}`, + 'FILE_SIZE_TOO_SMALL', + { + minFileSize: fileCategory.minFileSize, + }, + ); + } + + if (fileCategory.maxFileSize && fileSize > fileCategory.maxFileSize) { + throw new BadRequestError( + `File size ${fileSize} bytes exceeds maximum of ${fileCategory.maxFileSize} bytes for category ${fileCategory.name}`, + 'FILE_SIZE_TOO_LARGE', + { + maxFileSize: fileCategory.maxFileSize, + }, + ); + } +} + +/** + * Validates mime type and file extension + */ +function validateMimeType( + fileCategory: FileCategory, + contentType: string, + filename: string, +): string { + const mimeType = getMimeTypeFromContentType(contentType); + + // Validate file extension matches mime type + try { + validateFileExtensionWithMimeType(mimeType, filename); + } catch (error) { + if (error instanceof InvalidExtensionError) { + throw new BadRequestError(error.message, 'INVALID_FILE_EXTENSION', { + expectedFileExtensions: error.expectedFileExtensions, + }); + } + throw error; + } + + // Check if mime type is allowed for this category + if ( + fileCategory.allowedMimeTypes && + !fileCategory.allowedMimeTypes.includes(mimeType) + ) { + throw new BadRequestError( + `File type ${mimeType} is not allowed for category ${fileCategory.name}. Allowed types: ${fileCategory.allowedMimeTypes.join(', ')}`, + 'INVALID_FILE_TYPE', + { allowedMimeTypes: fileCategory.allowedMimeTypes }, + ); + } + + return mimeType; +} + +/** + * Validates file upload options and returns the validated data for file creation + * @param input - The file upload options + * @param context - The service context + * @returns The validated data + */ +export async function validateFileUploadOptions( + options: FileUploadOptions, + context: ServiceContext, +): Promise<{ + fileCreateInput: TPL_FILE_CREATE_INPUT; + fileCategory: FileCategory; + adapter: StorageAdapter; +}> { + const validatedOptions = fileUploadOptionsSchema.parse(options); + const { category, contentType, filename, size } = validatedOptions; + + // Find and validate file category + const fileCategory = getCategoryByNameOrThrow(category); + + // Only system users or users with upload permission can upload files + if ( + context.auth.roles.includes('system') || + !fileCategory.authorize?.upload || + !(await Promise.resolve(fileCategory.authorize.upload(context))) + ) { + throw new ForbiddenError( + `You are not authorized to upload files to category: ${fileCategory.name}`, + ); + } + + // Validate file size constraints + validateFileSize(fileCategory, size); + + // Validate mime type and file extension + const mimeType = validateMimeType(fileCategory, contentType, filename); + const encoding = getEncodingFromContentType(contentType); + + // Process and clean filename + const cleanedfilename = makefilenameSafe(filename); + + // Generate unique storage path + const pathPrefix = + fileCategory.pathPrefix ?? + fileCategory.name.toLowerCase().replaceAll('_', '-'); + const storagePath = `${pathPrefix}/${nanoid(14)}/${cleanedfilename}`; + + // Get storage adapter + const adapter = STORAGE_ADAPTERS[fileCategory.adapter]; + + // Prepare file record data + const fileCreateInput = { + filename: cleanedfilename, + storagePath, + category: fileCategory.name, + adapter: fileCategory.adapter, + mimeType, + encoding, + size, + uploader: context.auth.userId + ? { connect: { id: context.auth.userId } } + : undefined, + }; + + return { + adapter, + fileCategory, + fileCreateInput, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 140934b0d..5b6a992a0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1318,6 +1318,9 @@ importers: '@hookform/resolvers': specifier: 5.0.1 version: 5.0.1(react-hook-form@7.60.0(react@19.1.0)) + es-toolkit: + specifier: 1.31.0 + version: 1.31.0 inflection: specifier: 3.0.0 version: 3.0.0 From f081e8b1a3b5abe6b604f3d5cad04859ead337e6 Mon Sep 17 00:00:00 2001 From: Kingston Tam Date: Sat, 12 Jul 2025 13:54:51 +0200 Subject: [PATCH 14/22] Fix up react generators too --- .../src/components/file-input/file-input.tsx | 24 +++++++++---------- .../src/components/file-input/upload.gql | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/plugins/plugin-storage/src/generators/react/upload-components/templates/src/components/file-input/file-input.tsx b/plugins/plugin-storage/src/generators/react/upload-components/templates/src/components/file-input/file-input.tsx index 06d0bac60..6ccdd87fa 100644 --- a/plugins/plugin-storage/src/generators/react/upload-components/templates/src/components/file-input/file-input.tsx +++ b/plugins/plugin-storage/src/generators/react/upload-components/templates/src/components/file-input/file-input.tsx @@ -1,5 +1,6 @@ // @ts-nocheck +import type { FileCategory } from '%generatedGraphqlImports'; import type { ReactElement } from 'react'; import { CreateUploadUrlDocument } from '%generatedGraphqlImports'; @@ -19,7 +20,7 @@ import { useUpload } from '../../hooks/use-upload.js'; export interface FileUploadInput { id: string; - name: string; + filename: string; publicUrl?: string | null; } @@ -30,7 +31,7 @@ export interface FileInputProps { onChange?: (value: FileUploadInput | null) => void; value?: FileUploadInput; placeholder?: string; - category: string; + category: FileCategory; imagePreview?: boolean; accept?: Record; } @@ -62,14 +63,13 @@ export function FileInput({ const { isUploading, error, progress, uploadFile, cancelUpload } = useUpload({ getUploadParameters: async (fileToUpload) => { - const contentType = fileToUpload.type; + const contentType = fileToUpload.type || 'application/octet-stream'; const { data } = await createUploadUrl({ variables: { input: { category, - fileName: fileToUpload.name, - fileSize: fileToUpload.size, - // TODO: Figure out what to do if type is blank + filename: fileToUpload.name, + size: fileToUpload.size, contentType, }, }, @@ -86,7 +86,7 @@ export function FileInput({ const uploadedFile = file.meta; if (onChange) { onChange({ - name: uploadedFile.name, + filename: uploadedFile.filename, id: uploadedFile.id, publicUrl: uploadedFile.publicUrl, }); @@ -136,7 +136,7 @@ export function FileInput({
@@ -145,12 +145,12 @@ export function FileInput({ href={value.publicUrl} target="_blank" rel="noreferrer" - aria-label={`Preview ${value.name}`} + aria-label={`Preview ${value.filename}`} > {`Preview )} @@ -162,10 +162,10 @@ export function FileInput({ target="_blank" rel="noreferrer" > - {truncateFilenameWithExtension(value.name)} + {truncateFilenameWithExtension(value.filename)} ) : ( - truncateFilenameWithExtension(value.name) + truncateFilenameWithExtension(value.filename) )}
diff --git a/plugins/plugin-storage/src/generators/react/upload-components/templates/src/components/file-input/upload.gql b/plugins/plugin-storage/src/generators/react/upload-components/templates/src/components/file-input/upload.gql index d7ed57d71..a5009f5dc 100644 --- a/plugins/plugin-storage/src/generators/react/upload-components/templates/src/components/file-input/upload.gql +++ b/plugins/plugin-storage/src/generators/react/upload-components/templates/src/components/file-input/upload.gql @@ -1,6 +1,6 @@ fragment FileInput on TPL_FILE_TYPE { id - name + filename publicUrl } From ed6e3e9273b0bfe6472c11b59669e49c2b20d429 Mon Sep 17 00:00:00 2001 From: Kingston Tam Date: Sat, 12 Jul 2025 13:56:48 +0200 Subject: [PATCH 15/22] Update storage inputs --- .../src/components/circular-progress/circular-progress.tsx | 6 +++++- .../templates/src/components/file-input/file-input.tsx | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/react-generators/src/generators/core/react-components/templates/src/components/circular-progress/circular-progress.tsx b/packages/react-generators/src/generators/core/react-components/templates/src/components/circular-progress/circular-progress.tsx index 763bf1461..eced43c01 100644 --- a/packages/react-generators/src/generators/core/react-components/templates/src/components/circular-progress/circular-progress.tsx +++ b/packages/react-generators/src/generators/core/react-components/templates/src/components/circular-progress/circular-progress.tsx @@ -8,7 +8,7 @@ interface CircularProgressProps { min: number; gaugePrimaryColor: string; gaugeSecondaryColor: string; - size?: 'sm' | 'md' | 'lg'; + size?: 'xs' | 'sm' | 'md' | 'lg'; className?: string; } @@ -27,6 +27,10 @@ export function CircularProgress({ className, }: CircularProgressProps): React.ReactElement { const sizeConfig = { + xs: { + containerSize: 'size-8', + textSize: 'text-xs', + }, sm: { containerSize: 'size-12', textSize: 'text-sm', diff --git a/plugins/plugin-storage/src/generators/react/upload-components/templates/src/components/file-input/file-input.tsx b/plugins/plugin-storage/src/generators/react/upload-components/templates/src/components/file-input/file-input.tsx index 6ccdd87fa..7cad6c03b 100644 --- a/plugins/plugin-storage/src/generators/react/upload-components/templates/src/components/file-input/file-input.tsx +++ b/plugins/plugin-storage/src/generators/react/upload-components/templates/src/components/file-input/file-input.tsx @@ -205,7 +205,7 @@ export function FileInput({ min={0} gaugePrimaryColor="var(--primary)" gaugeSecondaryColor="var(--muted)" - size="sm" + size="xs" className="h-8 w-8" /> -
- ))} - - -
- ); -} - -export default CategoryEditorForm; diff --git a/plugins/plugin-storage/src/storage/core/components/storage-config.tsx b/plugins/plugin-storage/src/storage/core/components/storage-config.tsx index 5f4499f04..95d8223cf 100644 --- a/plugins/plugin-storage/src/storage/core/components/storage-config.tsx +++ b/plugins/plugin-storage/src/storage/core/components/storage-config.tsx @@ -24,7 +24,6 @@ import type { StoragePluginDefinitionInput } from '../schema/plugin-definition.j import { createStorageModels } from '../schema/models.js'; import { createStoragePluginDefinitionSchema } from '../schema/plugin-definition.js'; import AdapterEditorForm from './adapter-editor-form.js'; -import CategoryEditorForm from './category-editor-form.js'; export function StorageConfig({ definition: pluginMetadata, @@ -52,7 +51,6 @@ export function StorageConfig({ 'storage', ), s3Adapters: [], - categories: [], } satisfies StoragePluginDefinitionInput; }, [definition, pluginMetadata?.config]); @@ -149,7 +147,6 @@ export function StorageConfig({ />
- diff --git a/plugins/plugin-storage/src/storage/core/generators/file-categories/file-categories.generator.ts b/plugins/plugin-storage/src/storage/core/generators/file-categories/file-categories.generator.ts new file mode 100644 index 000000000..fb590f07b --- /dev/null +++ b/plugins/plugin-storage/src/storage/core/generators/file-categories/file-categories.generator.ts @@ -0,0 +1,137 @@ +import type { TsCodeFragment } from '@baseplate-dev/core-generators'; + +import { + packageScope, + TsCodeUtils, + tsTemplate, + typescriptFileProvider, +} from '@baseplate-dev/core-generators'; +import { appModuleProvider } from '@baseplate-dev/fastify-generators'; +import { + createGenerator, + createGeneratorTask, + createProviderType, +} from '@baseplate-dev/sync'; +import { CASE_VALIDATORS, quot } from '@baseplate-dev/utils'; +import { posixJoin } from '@baseplate-dev/utils/node'; +import { camelCase, constantCase } from 'es-toolkit'; +import { z } from 'zod'; + +import { + storageModuleConfigProvider, + storageModuleImportsProvider, +} from '#src/generators/index.js'; + +const descriptorSchema = z.object({ + featureId: z.string(), + fileCategories: z.array( + z.object({ + name: CASE_VALIDATORS.CONSTANT_CASE, + maxFileSizeMb: z.number().int().positive(), + adapter: z.string(), + authorize: z.object({ + uploadRoles: z.array(z.string()), + }), + referencedByRelation: z.string(), + }), + ), +}); + +export interface FileCategoriesProvider { + getFileCategoryImportFragment(name: string): TsCodeFragment; +} + +export const fileCategoriesProvider = + createProviderType('storage-file-categories'); + +function getFileCategoryExportName(name: string): string { + return `${camelCase(name)}FileCategory`; +} + +/** + * Generator for a set of file categories (tied to a feature). + */ +export const fileCategoriesGenerator = createGenerator({ + name: 'storage/core/file-categories', + generatorFileUrl: import.meta.url, + descriptorSchema, + buildTasks: ({ featureId, fileCategories }) => ({ + main: createGeneratorTask({ + dependencies: { + storageModuleImports: storageModuleImportsProvider, + typescriptFile: typescriptFileProvider, + appModule: appModuleProvider, + storageModuleConfig: storageModuleConfigProvider, + }, + exports: { + fileCategories: fileCategoriesProvider.export(packageScope, featureId), + }, + run({ + storageModuleImports, + typescriptFile, + appModule, + storageModuleConfig, + }) { + const fileCategoryPath = posixJoin( + appModule.getModuleFolder(), + 'constants', + 'file-categories.ts', + ); + function getFileCategoryImportFragment(name: string): TsCodeFragment { + if (!fileCategories.some((c) => c.name === name)) { + throw new Error(`File category ${name} not found`); + } + return TsCodeUtils.importFragment( + getFileCategoryExportName(name), + fileCategoryPath, + ); + } + return { + providers: { + fileCategories: { + getFileCategoryImportFragment, + }, + }, + build: async (builder) => { + const fileCategoryFragments = new Map(); + for (const category of fileCategories) { + storageModuleConfig.fileCategories.set( + category.name, + getFileCategoryImportFragment(category.name), + ); + fileCategoryFragments.set( + category.name, + tsTemplate` + export const ${getFileCategoryExportName(category.name)} = ${storageModuleImports.createFileCategory.fragment()}(${TsCodeUtils.mergeFragmentsAsObject( + { + // TODO [2025-07-13]: Remove once validation kicks in and add allowed Mime Types + name: quot(constantCase(category.name)), + maxFileSize: tsTemplate`${storageModuleImports.FileSize.fragment()}.MB(${category.maxFileSizeMb.toString()})`, + authorize: + category.authorize.uploadRoles.length > 0 + ? tsTemplate`{ + upload: ({ auth }) => auth.hasSomeRole(${TsCodeUtils.mergeFragmentsAsArrayPresorted( + category.authorize.uploadRoles.map(quot).sort(), + )}) + }` + : undefined, + adapter: quot(category.adapter), + referencedByRelation: quot(category.referencedByRelation), + }, + )})`, + ); + } + + await builder.apply( + typescriptFile.renderTemplateFragment({ + id: `file-categories-${featureId}`, + fragment: TsCodeUtils.mergeFragments(fileCategoryFragments), + destination: fileCategoryPath, + }), + ); + }, + }; + }, + }), + }), +}); diff --git a/plugins/plugin-storage/src/storage/core/node.ts b/plugins/plugin-storage/src/storage/core/node.ts index b58632290..89ebde1a6 100644 --- a/plugins/plugin-storage/src/storage/core/node.ts +++ b/plugins/plugin-storage/src/storage/core/node.ts @@ -6,12 +6,16 @@ import { PluginUtils, webAppEntryType, } from '@baseplate-dev/project-builder-lib'; +import { groupBy } from 'es-toolkit'; import { storageModuleGenerator } from '#src/generators/fastify/index.js'; import { uploadComponentsGenerator } from '#src/generators/react/upload-components/index.js'; +import type { FileTransformerDefinition } from '../transformers/schema/file-transformer.schema.js'; import type { StoragePluginDefinition } from './schema/plugin-definition.js'; +import { fileCategoriesGenerator } from './generators/file-categories/file-categories.generator.js'; + export default createPlatformPluginExport({ dependencies: { appCompiler: appCompilerSpec, @@ -40,21 +44,55 @@ export default createPlatformPluginExport({ bucketConfigVar: a.bucketConfigVar, hostedUrlConfigVar: a.hostedUrlConfigVar, })), - categories: storage.categories.map((c) => ({ - name: c.name, - maxFileSize: c.maxFileSize, - usedByRelation: definitionContainer.nameFromId( - c.usedByRelationRef, - ), - defaultAdapter: definitionContainer.nameFromId( - c.defaultAdapterRef, - ), - uploadRoles: c.uploadRoles.map((r) => - definitionContainer.nameFromId(r), - ), - })), }), }); + + // Add file categories + const transformers = projectDefinition.models.flatMap((m) => + m.service.transformers + .filter((m): m is FileTransformerDefinition => m.type === 'file') + .map((t) => { + const relation = m.model.relations?.find( + (r) => r.id === t.fileRelationRef, + ); + if (!relation) { + throw new Error(`File transformer ${t.id} has no relation`); + } + return { + model: m, + transformer: t, + relation, + }; + }), + ); + + const transformersByFeature = groupBy( + transformers, + (t) => t.model.featureRef, + ); + + for (const [featureId, transformers] of Object.entries( + transformersByFeature, + )) { + appCompiler.addChildrenToFeature(featureId, { + fileCategories: fileCategoriesGenerator({ + featureId, + fileCategories: transformers.map((t) => ({ + name: t.transformer.category.name, + maxFileSizeMb: t.transformer.category.maxFileSizeMb, + adapter: definitionContainer.nameFromId( + t.transformer.category.adapterRef, + ), + authorize: { + uploadRoles: t.transformer.category.authorize.uploadRoles.map( + (r) => definitionContainer.nameFromId(r), + ), + }, + referencedByRelation: t.relation.foreignRelationName, + })), + }), + }); + } }, }); diff --git a/plugins/plugin-storage/src/storage/core/schema/migrations.ts b/plugins/plugin-storage/src/storage/core/schema/migrations.ts index a60516663..01b88d75b 100644 --- a/plugins/plugin-storage/src/storage/core/schema/migrations.ts +++ b/plugins/plugin-storage/src/storage/core/schema/migrations.ts @@ -1,5 +1,8 @@ import type { PluginConfigMigration } from '@baseplate-dev/project-builder-lib'; +import { modelTransformerEntityType } from '@baseplate-dev/project-builder-lib'; +import { constantCase } from 'es-toolkit'; + export const STORAGE_PLUGIN_CONFIG_MIGRATIONS: PluginConfigMigration[] = [ { name: 'move-file-model', @@ -20,4 +23,127 @@ export const STORAGE_PLUGIN_CONFIG_MIGRATIONS: PluginConfigMigration[] = [ }; }, }, + { + name: 'move-categories-to-transformers', + version: 2, + migrate: (config, projectDefinition) => { + interface OldCategory { + name: string; + defaultAdapterRef: string; + maxFileSize: number; + usedByRelationRef: string; + uploadRoles: string[]; + } + + interface ProjectDefinitionConfig { + models: { + name: string; + model: { + relations?: { + name: string; + foreignRelationName: string; + foreignId: string; + modelRef: string; + }[]; + }; + service?: { + transformers?: { + id: string; + type: string; + fileRelationRef: string; + category?: { + name: string; + maxFileSizeMb: number; + authorize: { + uploadRoles: string[]; + }; + adapterRef: string; + }; + }[]; + }; + }[]; + } + + const typedProjectDefinition = + projectDefinition as ProjectDefinitionConfig; + + const typedConfig = config as { + modelRefs: { + file: string; + }; + categories: OldCategory[]; + }; + + const fileModel = typedProjectDefinition.models.find( + (model) => model.name === typedConfig.modelRefs.file, + ); + if (!fileModel) { + throw new Error( + `Could not find file model ${typedConfig.modelRefs.file}`, + ); + } + + for (const category of typedConfig.categories) { + // Find matching relation + const model = typedProjectDefinition.models.find((model) => + model.model.relations?.some( + (relation) => + relation.foreignRelationName === category.usedByRelationRef && + relation.modelRef === fileModel.name, + ), + ); + if (!model) { + throw new Error(`Could not find model for category ${category.name}`); + } + const relation = model.model.relations?.find( + (relation) => + relation.foreignRelationName === category.usedByRelationRef && + relation.modelRef === fileModel.name, + ); + if (!relation) { + throw new Error( + `Could not find relation for category ${category.name}`, + ); + } + const transformer = model.service?.transformers?.find( + (transformer) => transformer.fileRelationRef === relation.name, + ); + if (transformer) { + transformer.category = { + name: constantCase(category.name), + maxFileSizeMb: category.maxFileSize, + authorize: { + uploadRoles: category.uploadRoles, + }, + adapterRef: category.defaultAdapterRef, + }; + } else { + model.service = { + ...model.service, + transformers: [ + ...(model.service?.transformers ?? []), + { + id: modelTransformerEntityType.generateNewId(), + type: 'file', + fileRelationRef: relation.name, + category: { + name: constantCase(category.name), + maxFileSizeMb: category.maxFileSize, + authorize: { + uploadRoles: category.uploadRoles, + }, + adapterRef: category.defaultAdapterRef, + }, + }, + ], + }; + } + } + + return { + ...typedConfig, + categories: undefined, + }; + }, + }, ]; diff --git a/plugins/plugin-storage/src/storage/core/schema/plugin-definition.ts b/plugins/plugin-storage/src/storage/core/schema/plugin-definition.ts index ba89a52bc..cd7f93166 100644 --- a/plugins/plugin-storage/src/storage/core/schema/plugin-definition.ts +++ b/plugins/plugin-storage/src/storage/core/schema/plugin-definition.ts @@ -1,12 +1,10 @@ import type { def } from '@baseplate-dev/project-builder-lib'; import { - authRoleEntityType, createEntityType, definitionSchema, featureEntityType, modelEntityType, - modelForeignRelationEntityType, VALIDATORS, } from '@baseplate-dev/project-builder-lib'; import z from 'zod'; @@ -39,30 +37,6 @@ export const createStoragePluginDefinitionSchema = definitionSchema((ctx) => { type: storageAdapterEntityType }, ), ), - categories: z.array( - z.object({ - name: z.string().min(1), - defaultAdapterRef: ctx.withRef({ - type: storageAdapterEntityType, - onDelete: 'RESTRICT', - }), - maxFileSize: z.preprocess( - (a) => a && Number.parseInt(a as string, 10), - z.number().positive().optional(), - ), - usedByRelationRef: ctx.withRef({ - type: modelForeignRelationEntityType, - onDelete: 'RESTRICT', - parentPath: { context: 'fileModel' }, - }), - uploadRoles: z.array( - ctx.withRef({ - type: authRoleEntityType, - onDelete: 'DELETE', - }), - ), - }), - ), }), (builder) => { builder.addPathToContext('modelRefs.file', modelEntityType, 'fileModel'); diff --git a/plugins/plugin-storage/src/storage/transformers/common.ts b/plugins/plugin-storage/src/storage/transformers/common.ts index 324a646bb..2a5b04f3b 100644 --- a/plugins/plugin-storage/src/storage/transformers/common.ts +++ b/plugins/plugin-storage/src/storage/transformers/common.ts @@ -3,7 +3,7 @@ import { modelTransformerSpec, } from '@baseplate-dev/project-builder-lib'; -import { createFileTransformerSchema } from './types.js'; +import { createFileTransformerSchema } from './schema/file-transformer.schema.js'; export default createPlatformPluginExport({ dependencies: { diff --git a/plugins/plugin-storage/src/storage/transformers/components/file-transformer-form.tsx b/plugins/plugin-storage/src/storage/transformers/components/file-transformer-form.tsx index 4b8f547d2..fa4f4a3d9 100644 --- a/plugins/plugin-storage/src/storage/transformers/components/file-transformer-form.tsx +++ b/plugins/plugin-storage/src/storage/transformers/components/file-transformer-form.tsx @@ -1,13 +1,20 @@ import type { ModelTransformerWebFormProps } from '@baseplate-dev/project-builder-lib/web'; import type { Control } from 'react-hook-form'; -import { PluginUtils } from '@baseplate-dev/project-builder-lib'; +import { + authConfigSpec, + PluginUtils, +} from '@baseplate-dev/project-builder-lib'; import { useProjectDefinition } from '@baseplate-dev/project-builder-lib/web'; -import { SelectFieldController } from '@baseplate-dev/ui-components'; +import { + InputFieldController, + MultiComboboxFieldController, + SelectFieldController, +} from '@baseplate-dev/ui-components'; import type { StoragePluginDefinition } from '#src/storage/core/schema/plugin-definition.js'; -import type { FileTransformerConfig } from '../types.js'; +import type { FileTransformerDefinition } from '../schema/file-transformer.schema.js'; import '#src/styles.css'; @@ -18,8 +25,10 @@ export function FileTransformerForm({ pluginId, }: ModelTransformerWebFormProps): React.JSX.Element { const prefix = name as 'prefix'; - const controlTyped = control as Control<{ prefix: FileTransformerConfig }>; - const { definition } = useProjectDefinition(); + const controlTyped = control as Control<{ + prefix: FileTransformerDefinition; + }>; + const { definition, pluginContainer } = useProjectDefinition(); const storageConfig = PluginUtils.configByIdOrThrow( definition, @@ -36,15 +45,80 @@ export function FileTransformerForm({ value: relation.id, })); + // Get available auth roles + const roleOptions = pluginContainer + .getPluginSpec(authConfigSpec) + .getAuthRoles(definition) + .map((role) => ({ + label: role.name, + value: role.id, + })); + + // Get available storage adapters + const adapterOptions = storageConfig.s3Adapters.map((adapter) => ({ + label: adapter.name, + value: adapter.id, + })); + return ( -
+
+ +
+

+ File Category Configuration +

+ +
+ + + +
+ +
+ + + +
+
); } diff --git a/plugins/plugin-storage/src/storage/transformers/node.ts b/plugins/plugin-storage/src/storage/transformers/node.ts index cf4fe8bf2..55a4553ee 100644 --- a/plugins/plugin-storage/src/storage/transformers/node.ts +++ b/plugins/plugin-storage/src/storage/transformers/node.ts @@ -3,21 +3,17 @@ import type { ModelTransformerCompiler } from '@baseplate-dev/project-builder-li import { createPlatformPluginExport, modelTransformerCompilerSpec, - PluginUtils, } from '@baseplate-dev/project-builder-lib'; import { prismaFileTransformerGenerator } from '#src/generators/fastify/index.js'; -import type { StoragePluginDefinition } from '../core/schema/plugin-definition.js'; -import type { FileTransformerConfig } from './types.js'; +import type { FileTransformerDefinition } from './schema/file-transformer.schema.js'; -function buildFileTransformerCompiler( - pluginId: string, -): ModelTransformerCompiler { +function buildFileTransformerCompiler(): ModelTransformerCompiler { return { name: 'file', - compileTransformer(definition, { definitionContainer, model }) { - const { fileRelationRef } = definition; + compileTransformer(definition, { model }) { + const { fileRelationRef, category } = definition; const foreignRelation = model.model.relations?.find( (relation) => relation.id === fileRelationRef, @@ -29,24 +25,10 @@ function buildFileTransformerCompiler( ); } - const storageDefinition = PluginUtils.configByIdOrThrow( - definitionContainer.definition, - pluginId, - ) as StoragePluginDefinition; - - const category = storageDefinition.categories.find( - (c) => c.usedByRelationRef === foreignRelation.foreignId, - ); - - if (!category) { - throw new Error( - `Could not find category for relation ${foreignRelation.name}`, - ); - } - return prismaFileTransformerGenerator({ category: category.name, name: foreignRelation.name, + featureId: model.featureRef, }); }, }; @@ -57,9 +39,9 @@ export default createPlatformPluginExport({ transformerCompiler: modelTransformerCompilerSpec, }, exports: {}, - initialize: ({ transformerCompiler }, { pluginId }) => { + initialize: ({ transformerCompiler }) => { transformerCompiler.registerTransformerCompiler( - buildFileTransformerCompiler(pluginId), + buildFileTransformerCompiler(), ); return {}; }, diff --git a/plugins/plugin-storage/src/storage/transformers/types.ts b/plugins/plugin-storage/src/storage/transformers/schema/file-transformer.schema.ts similarity index 59% rename from plugins/plugin-storage/src/storage/transformers/types.ts rename to plugins/plugin-storage/src/storage/transformers/schema/file-transformer.schema.ts index 3d52124f9..ebe7c8d06 100644 --- a/plugins/plugin-storage/src/storage/transformers/types.ts +++ b/plugins/plugin-storage/src/storage/transformers/schema/file-transformer.schema.ts @@ -1,14 +1,18 @@ import type { def } from '@baseplate-dev/project-builder-lib'; import { + authRoleEntityType, baseTransformerFields, createDefinitionEntityNameResolver, definitionSchema, modelLocalRelationEntityType, modelTransformerEntityType, } from '@baseplate-dev/project-builder-lib'; +import { CASE_VALIDATORS } from '@baseplate-dev/utils'; import { z } from 'zod'; +import { storageAdapterEntityType } from '#src/storage/core/schema/plugin-definition.js'; + export const createFileTransformerSchema = definitionSchema((ctx) => ctx.withEnt( z.object({ @@ -18,6 +22,22 @@ export const createFileTransformerSchema = definitionSchema((ctx) => onDelete: 'DELETE_PARENT', parentPath: { context: 'model' }, }), + category: z.object({ + name: CASE_VALIDATORS.CONSTANT_CASE, + maxFileSizeMb: z.number().int().positive(), + authorize: z.object({ + uploadRoles: z.array( + ctx.withRef({ + type: authRoleEntityType, + onDelete: 'RESTRICT', + }), + ), + }), + adapterRef: ctx.withRef({ + type: storageAdapterEntityType, + onDelete: 'RESTRICT', + }), + }), type: z.literal('file'), }), { @@ -32,6 +52,6 @@ export const createFileTransformerSchema = definitionSchema((ctx) => ), ); -export type FileTransformerConfig = def.InferInput< +export type FileTransformerDefinition = def.InferInput< typeof createFileTransformerSchema >; diff --git a/plugins/plugin-storage/src/storage/transformers/web.ts b/plugins/plugin-storage/src/storage/transformers/web.ts index 8a2202ab9..8ed876869 100644 --- a/plugins/plugin-storage/src/storage/transformers/web.ts +++ b/plugins/plugin-storage/src/storage/transformers/web.ts @@ -9,9 +9,10 @@ import { PluginUtils, } from '@baseplate-dev/project-builder-lib'; import { modelTransformerWebSpec } from '@baseplate-dev/project-builder-lib/web'; +import { constantCase } from 'es-toolkit'; import type { StoragePluginDefinition } from '../core/schema/plugin-definition.js'; -import type { FileTransformerConfig } from './types.js'; +import type { FileTransformerDefinition } from './schema/file-transformer.schema.js'; import { FileTransformerForm } from './components/file-transformer-form.js'; @@ -28,7 +29,7 @@ function findNonTransformedFileRelations( ) as StoragePluginDefinition; const { transformers } = modelConfig.service ?? {}; const fileTransformers = transformers?.filter( - (transformer): transformer is FileTransformerConfig => + (transformer): transformer is FileTransformerDefinition => transformer.type === 'file', ); return ( @@ -50,7 +51,7 @@ export default createPlatformPluginExport({ }, exports: {}, initialize: ({ transformerWeb }, { pluginId }) => { - transformerWeb.registerTransformerWebConfig({ + transformerWeb.registerTransformerWebConfig({ name: 'file', label: 'File', description: 'Validates and associates file ID to field', @@ -71,10 +72,29 @@ export default createPlatformPluginExport({ modelConfig, pluginId, ); + const fileRelationId = fileRelationIds[0]; + const relation = modelConfig.model.relations?.find( + (r) => r.id === fileRelationId, + ); + if (!relation) { + throw new Error(`Could not find relation ${fileRelationId}`); + } + const storageDefinition = PluginUtils.configByIdOrThrow( + definition, + pluginId, + ) as StoragePluginDefinition; return { id: modelTransformerEntityType.generateNewId(), - type: 'file', + type: 'file' as const, fileRelationRef: fileRelationIds[0], + category: { + name: constantCase(relation.foreignRelationName), + maxFileSizeMb: 20, + authorize: { + uploadRoles: ['user'], + }, + adapterRef: storageDefinition.s3Adapters[0]?.id ?? '', + }, }; }, getSummary: (definition, definitionContainer) => [ From c9b80a881e9ba71c04b7ee265fcbd71dd5d7d5bb Mon Sep 17 00:00:00 2001 From: Kingston Tam Date: Sat, 12 Jul 2025 21:46:26 +0200 Subject: [PATCH 19/22] Update UI of storage plugin --- .../core/components/adapter-dialog.tsx | 122 ++++++++++++ .../core/components/adapter-editor-form.tsx | 188 +++++++++++++----- ...nfig.tsx => storage-definition-editor.tsx} | 116 ++++++----- .../plugin-storage/src/storage/core/web.ts | 4 +- 4 files changed, 329 insertions(+), 101 deletions(-) create mode 100644 plugins/plugin-storage/src/storage/core/components/adapter-dialog.tsx rename plugins/plugin-storage/src/storage/core/components/{storage-config.tsx => storage-definition-editor.tsx} (55%) diff --git a/plugins/plugin-storage/src/storage/core/components/adapter-dialog.tsx b/plugins/plugin-storage/src/storage/core/components/adapter-dialog.tsx new file mode 100644 index 000000000..03aa65b5f --- /dev/null +++ b/plugins/plugin-storage/src/storage/core/components/adapter-dialog.tsx @@ -0,0 +1,122 @@ +import type React from 'react'; + +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + InputFieldController, +} from '@baseplate-dev/ui-components'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useId } from 'react'; +import { useForm } from 'react-hook-form'; +import z from 'zod'; + +import type { StoragePluginDefinitionInput } from '../schema/plugin-definition.js'; + +const adapterSchema = z.object({ + id: z.string(), + name: z.string().min(1, 'Name is required'), + bucketConfigVar: z.string().min(1, 'Bucket config variable is required'), + hostedUrlConfigVar: z.string().optional(), +}); + +type AdapterFormData = z.infer; + +interface AdapterDialogProps { + open?: boolean; + onOpenChange?: (open: boolean) => void; + adapter?: StoragePluginDefinitionInput['s3Adapters'][0]; + isNew?: boolean; + onSave: (adapter: StoragePluginDefinitionInput['s3Adapters'][0]) => void; + asChild?: boolean; + children?: React.ReactNode; +} + +export function AdapterDialog({ + open, + onOpenChange, + adapter, + isNew = false, + onSave, + asChild, + children, +}: AdapterDialogProps): React.JSX.Element { + const form = useForm({ + resolver: zodResolver(adapterSchema), + values: adapter, + }); + + const { control, handleSubmit } = form; + + const onSubmit = handleSubmit((data) => { + onSave(data as StoragePluginDefinitionInput['s3Adapters'][0]); + onOpenChange?.(false); + }); + + const formId = useId(); + + return ( + + {children} + +
{ + e.stopPropagation(); + return onSubmit(e); + }} + > + + {isNew ? 'Add Adapter' : 'Edit Adapter'} + + {isNew + ? 'Enter the details for the new S3 adapter.' + : 'Update the adapter details below.'} + + +
+ + + +
+ + + + +
+
+
+ ); +} diff --git a/plugins/plugin-storage/src/storage/core/components/adapter-editor-form.tsx b/plugins/plugin-storage/src/storage/core/components/adapter-editor-form.tsx index d49d6e5a0..d0dbb24b0 100644 --- a/plugins/plugin-storage/src/storage/core/components/adapter-editor-form.tsx +++ b/plugins/plugin-storage/src/storage/core/components/adapter-editor-form.tsx @@ -1,13 +1,25 @@ import type { Control } from 'react-hook-form'; -import { Button, InputFieldController } from '@baseplate-dev/ui-components'; -import { useFieldArray } from 'react-hook-form'; - -import { cn } from '#src/utils/cn.js'; +import { + Button, + RecordView, + RecordViewActions, + RecordViewItem, + RecordViewItemList, + SectionListSection, + SectionListSectionContent, + SectionListSectionDescription, + SectionListSectionHeader, + SectionListSectionTitle, + useConfirmDialog, +} from '@baseplate-dev/ui-components'; +import { useState } from 'react'; +import { useFieldArray, useWatch } from 'react-hook-form'; import type { StoragePluginDefinitionInput } from '../schema/plugin-definition.js'; import { storageAdapterEntityType } from '../schema/plugin-definition.js'; +import { AdapterDialog } from './adapter-dialog.js'; interface Props { className?: string; @@ -15,60 +27,130 @@ interface Props { } function AdapterEditorForm({ className, control }: Props): React.JSX.Element { - const { fields, append, remove } = useFieldArray({ + const { requestConfirm } = useConfirmDialog(); + const { append, update, remove } = useFieldArray({ control, name: 's3Adapters', }); + const [adapterToEdit, setAdapterToEdit] = useState< + StoragePluginDefinitionInput['s3Adapters'][0] | undefined + >(); + const [isEditing, setIsEditing] = useState(false); - return ( -
-

S3 Adapters

- {fields.map((field, idx) => ( -
- -
- - -
- -
- ))} + const adapters = useWatch({ control, name: 's3Adapters' }); + + function handleSaveAdapter( + newAdapter: StoragePluginDefinitionInput['s3Adapters'][0], + ): void { + const existingIndex = adapters.findIndex( + (adapter) => adapter.id === newAdapter.id, + ); + if (existingIndex === -1) { + append(newAdapter); + } else { + update(existingIndex, newAdapter); + } + } - -
+ function handleDeleteAdapter(adapterIdx: number): void { + const adapter = adapters[adapterIdx]; + requestConfirm({ + title: 'Delete Adapter', + content: `Are you sure you want to delete the adapter "${adapter.name}"?`, + onConfirm: () => { + remove(adapterIdx); + }, + }); + } + + return ( + + + S3 Adapters + + Configure S3 storage adapters for file uploads. Each adapter can have + its own bucket and configuration. + + + + {adapters.map((adapter, adapterIdx) => ( + + + +
+ {adapter.name} +
+
+ + {adapter.bucketConfigVar || ( + + Not configured + + )} + + + {adapter.hostedUrlConfigVar ?? ( + + Not configured + + )} + +
+ + + + +
+ ))} + a.id === adapterToEdit.id) + : true + } + onSave={handleSaveAdapter} + /> + +
+
); } diff --git a/plugins/plugin-storage/src/storage/core/components/storage-config.tsx b/plugins/plugin-storage/src/storage/core/components/storage-definition-editor.tsx similarity index 55% rename from plugins/plugin-storage/src/storage/core/components/storage-config.tsx rename to plugins/plugin-storage/src/storage/core/components/storage-definition-editor.tsx index 95d8223cf..f88a8e785 100644 --- a/plugins/plugin-storage/src/storage/core/components/storage-config.tsx +++ b/plugins/plugin-storage/src/storage/core/components/storage-definition-editor.tsx @@ -4,18 +4,29 @@ import type React from 'react'; import { createAndApplyModelMergerResults, createModelMergerResults, + doesModelMergerResultsHaveChanges, FeatureUtils, ModelUtils, PluginUtils, } from '@baseplate-dev/project-builder-lib'; import { + FeatureComboboxFieldController, + ModelComboboxFieldController, ModelMergerResultAlert, useBlockUnsavedChangesNavigate, useDefinitionSchema, useProjectDefinition, useResettableForm, } from '@baseplate-dev/project-builder-lib/web'; -import { Button, ComboboxFieldController } from '@baseplate-dev/ui-components'; +import { + FormActionBar, + SectionList, + SectionListSection, + SectionListSectionContent, + SectionListSectionDescription, + SectionListSectionHeader, + SectionListSectionTitle, +} from '@baseplate-dev/ui-components'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMemo } from 'react'; @@ -25,7 +36,7 @@ import { createStorageModels } from '../schema/models.js'; import { createStoragePluginDefinitionSchema } from '../schema/plugin-definition.js'; import AdapterEditorForm from './adapter-editor-form.js'; -export function StorageConfig({ +export function StorageDefinitionEditor({ definition: pluginMetadata, metadata, onSave, @@ -54,11 +65,11 @@ export function StorageConfig({ } satisfies StoragePluginDefinitionInput; }, [definition, pluginMetadata?.config]); - const { control, handleSubmit, formState, watch, reset } = - useResettableForm({ - resolver: zodResolver(storagePluginDefinitionSchema), - defaultValues, - }); + const form = useResettableForm({ + resolver: zodResolver(storagePluginDefinitionSchema), + defaultValues, + }); + const { control, reset, handleSubmit, watch } = form; const modelRefs = watch('modelRefs'); const storageFeatureRef = watch('storageFeatureRef'); @@ -69,12 +80,11 @@ export function StorageConfig({ definitionContainer, ); - const result = createModelMergerResults( + return createModelMergerResults( modelRefs, desiredModels, definitionContainer, ); - return result; }, [definitionContainer, storageFeatureRef, modelRefs]); const onSubmit = handleSubmit((data) => @@ -114,43 +124,57 @@ export function StorageConfig({ useBlockUnsavedChangesNavigate({ control, reset, onSubmit }); - const modelOptions = definition.models.map((m) => ({ - label: m.name, - value: m.id, - })); - - const featureOptions = definition.features.map((m) => ({ - label: m.name, - value: m.id, - })); - return ( -
-
- -
- - -
- - - -
+
+
+ + + + + Storage Configuration + + + Configure your storage settings, file models, and S3 adapters. + + + + + +
+ + +
+
+
+ + +
+
+ + + ); } diff --git a/plugins/plugin-storage/src/storage/core/web.ts b/plugins/plugin-storage/src/storage/core/web.ts index 9c12a5ca2..eb3ff4c32 100644 --- a/plugins/plugin-storage/src/storage/core/web.ts +++ b/plugins/plugin-storage/src/storage/core/web.ts @@ -3,7 +3,7 @@ import { webConfigSpec, } from '@baseplate-dev/project-builder-lib'; -import { StorageConfig } from './components/storage-config.js'; +import { StorageDefinitionEditor } from './components/storage-definition-editor.js'; import '../../styles.css'; @@ -13,7 +13,7 @@ export default createPlatformPluginExport({ }, exports: {}, initialize: ({ webConfig }, { pluginId }) => { - webConfig.registerWebConfigComponent(pluginId, StorageConfig); + webConfig.registerWebConfigComponent(pluginId, StorageDefinitionEditor); return {}; }, }); From 9e6211d8222d1a1da476b05866726a5114679de6 Mon Sep 17 00:00:00 2001 From: Kingston Tam Date: Sat, 12 Jul 2025 22:01:07 +0200 Subject: [PATCH 20/22] Fix knip errors --- .../generators/file-categories/file-categories.generator.ts | 2 +- plugins/plugin-storage/src/utils/cn.ts | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) delete mode 100644 plugins/plugin-storage/src/utils/cn.ts diff --git a/plugins/plugin-storage/src/storage/core/generators/file-categories/file-categories.generator.ts b/plugins/plugin-storage/src/storage/core/generators/file-categories/file-categories.generator.ts index fb590f07b..e3a5379f6 100644 --- a/plugins/plugin-storage/src/storage/core/generators/file-categories/file-categories.generator.ts +++ b/plugins/plugin-storage/src/storage/core/generators/file-categories/file-categories.generator.ts @@ -37,7 +37,7 @@ const descriptorSchema = z.object({ ), }); -export interface FileCategoriesProvider { +interface FileCategoriesProvider { getFileCategoryImportFragment(name: string): TsCodeFragment; } diff --git a/plugins/plugin-storage/src/utils/cn.ts b/plugins/plugin-storage/src/utils/cn.ts deleted file mode 100644 index 0f0ba75b9..000000000 --- a/plugins/plugin-storage/src/utils/cn.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const cn = (...classes: (string | undefined | false)[]): string => - classes.filter((x): x is string => !!x).join(' '); From 2764d7b1b4093d5ecb4da0c03a8f8396a55e2499 Mon Sep 17 00:00:00 2001 From: Kingston Tam Date: Sat, 12 Jul 2025 22:07:45 +0200 Subject: [PATCH 21/22] Fix knip errors --- plugins/plugin-storage/src/index.ts | 2 +- .../generators/file-categories/file-categories.generator.ts | 2 +- .../src/storage/core/generators/file-categories/index.ts | 1 + plugins/plugin-storage/src/storage/core/generators/index.ts | 1 + plugins/plugin-storage/src/storage/core/index.ts | 1 + plugins/plugin-storage/src/storage/index.ts | 1 + 6 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 plugins/plugin-storage/src/storage/core/generators/file-categories/index.ts create mode 100644 plugins/plugin-storage/src/storage/core/generators/index.ts create mode 100644 plugins/plugin-storage/src/storage/core/index.ts create mode 100644 plugins/plugin-storage/src/storage/index.ts diff --git a/plugins/plugin-storage/src/index.ts b/plugins/plugin-storage/src/index.ts index 8e0bbc5af..495b88bb1 100644 --- a/plugins/plugin-storage/src/index.ts +++ b/plugins/plugin-storage/src/index.ts @@ -1 +1 @@ -export * from './generators/index.js'; +export * from './storage/index.js'; diff --git a/plugins/plugin-storage/src/storage/core/generators/file-categories/file-categories.generator.ts b/plugins/plugin-storage/src/storage/core/generators/file-categories/file-categories.generator.ts index e3a5379f6..fb590f07b 100644 --- a/plugins/plugin-storage/src/storage/core/generators/file-categories/file-categories.generator.ts +++ b/plugins/plugin-storage/src/storage/core/generators/file-categories/file-categories.generator.ts @@ -37,7 +37,7 @@ const descriptorSchema = z.object({ ), }); -interface FileCategoriesProvider { +export interface FileCategoriesProvider { getFileCategoryImportFragment(name: string): TsCodeFragment; } diff --git a/plugins/plugin-storage/src/storage/core/generators/file-categories/index.ts b/plugins/plugin-storage/src/storage/core/generators/file-categories/index.ts new file mode 100644 index 000000000..853f1f203 --- /dev/null +++ b/plugins/plugin-storage/src/storage/core/generators/file-categories/index.ts @@ -0,0 +1 @@ +export * from './file-categories.generator.js'; diff --git a/plugins/plugin-storage/src/storage/core/generators/index.ts b/plugins/plugin-storage/src/storage/core/generators/index.ts new file mode 100644 index 000000000..f0b4a0a17 --- /dev/null +++ b/plugins/plugin-storage/src/storage/core/generators/index.ts @@ -0,0 +1 @@ +export * from './file-categories/index.js'; diff --git a/plugins/plugin-storage/src/storage/core/index.ts b/plugins/plugin-storage/src/storage/core/index.ts new file mode 100644 index 000000000..8e0bbc5af --- /dev/null +++ b/plugins/plugin-storage/src/storage/core/index.ts @@ -0,0 +1 @@ +export * from './generators/index.js'; diff --git a/plugins/plugin-storage/src/storage/index.ts b/plugins/plugin-storage/src/storage/index.ts new file mode 100644 index 000000000..17f45946d --- /dev/null +++ b/plugins/plugin-storage/src/storage/index.ts @@ -0,0 +1 @@ +export * from './core/index.js'; From 840c45f4ea1c985785003a842fd6c4658878f9b1 Mon Sep 17 00:00:00 2001 From: Kingston Tam Date: Sat, 12 Jul 2025 22:10:49 +0200 Subject: [PATCH 22/22] Fix knip one final time --- plugins/plugin-storage/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/plugin-storage/src/index.ts b/plugins/plugin-storage/src/index.ts index 495b88bb1..c7f4bd1ca 100644 --- a/plugins/plugin-storage/src/index.ts +++ b/plugins/plugin-storage/src/index.ts @@ -1 +1,2 @@ +export * from './generators/index.js'; export * from './storage/index.js';