From d1f71931d02358997cf2fc392f6e111ee9bf348a Mon Sep 17 00:00:00 2001 From: Lucas Coratger <73360179+coratgerl@users.noreply.github.com> Date: Sun, 10 May 2026 21:00:20 +0200 Subject: [PATCH] feat(wabe): add MutexController --- .../documentation/authentication/sessions.md | 10 + .../docs/documentation/codegen.md | 15 +- .../docs/documentation/mutex.md | 53 + packages/wabe-documentation/rspress.config.ts | 305 ++--- packages/wabe-mongodb/src/index.test.ts | 45 +- packages/wabe-mongodb/src/index.ts | 95 +- packages/wabe-postgres/src/index.test.ts | 35 +- packages/wabe-postgres/src/index.ts | 46 + packages/wabe/generated/schema.graphql | 1196 +++++++++-------- packages/wabe/generated/wabe.ts | 24 + .../wabe/src/authentication/Session.test.ts | 17 + packages/wabe/src/authentication/Session.ts | 27 +- .../wabe/src/database/DatabaseController.ts | 18 + packages/wabe/src/database/interface.ts | 9 + packages/wabe/src/index.ts | 1 + .../wabe/src/mutex/MutexController.test.ts | 50 + packages/wabe/src/mutex/MutexController.ts | 53 + packages/wabe/src/mutex/index.ts | 1 + packages/wabe/src/schema/Schema.test.ts | 51 + packages/wabe/src/schema/Schema.ts | 43 + packages/wabe/src/server/generateCodegen.ts | 428 ++++++ packages/wabe/src/server/index.ts | 14 +- 22 files changed, 1783 insertions(+), 753 deletions(-) create mode 100644 packages/wabe-documentation/docs/documentation/mutex.md create mode 100644 packages/wabe/src/mutex/MutexController.test.ts create mode 100644 packages/wabe/src/mutex/MutexController.ts create mode 100644 packages/wabe/src/mutex/index.ts create mode 100644 packages/wabe/src/server/generateCodegen.ts diff --git a/packages/wabe-documentation/docs/documentation/authentication/sessions.md b/packages/wabe-documentation/docs/documentation/authentication/sessions.md index e4a5eee9..1bdf3412 100644 --- a/packages/wabe-documentation/docs/documentation/authentication/sessions.md +++ b/packages/wabe-documentation/docs/documentation/authentication/sessions.md @@ -6,6 +6,16 @@ Wabe gives you the ability to configure your `session` parameters. You can choos The `refreshToken` and the `accessToken` are stored in the `Session` table in the database. The `refreshToken` and the `accessToken` are automatically rotated after each request when `cookieSession` is used to limit the possibilities in case of theft. +## Concurrent refresh protection + +Wabe protects refresh-token rotation with a database-backed mutex (`_Mutex` internal class). + +During `refresh`, Wabe tries to lock a mutex key derived from the current access/refresh token pair: +- if the lock is acquired, refresh rotation continues +- if the lock is not acquired, the refresh request fails immediately + +This prevents concurrent refresh requests from rotating the same session at the same time across multiple instances. + ```ts import { Wabe } from "wabe"; diff --git a/packages/wabe-documentation/docs/documentation/codegen.md b/packages/wabe-documentation/docs/documentation/codegen.md index 8ddd749a..78459539 100644 --- a/packages/wabe-documentation/docs/documentation/codegen.md +++ b/packages/wabe-documentation/docs/documentation/codegen.md @@ -1,11 +1,21 @@ # Codegen -With Wabe, you can configure when code generation is triggered with `codegen`, and provide your own generation pipeline with `onGenerateCodegen`. This inversion of control lets you run any tool you want (for example `oxfmt`, `prettier`, or a custom generator), without Wabe enforcing a formatter strategy. +Wabe can generate code artifacts from your schema when `codegen` is enabled. -Your callback is fully custom. In this example, we only format generated files with `oxfmt`. +By default, Wabe generates: +- `wabe.ts` (TypeScript schema types) +- `schema.graphql` (printed GraphQL schema) + +`onGenerateCodegen` is optional and is called **after** default generation. Use it for post-processing (for example formatting or custom checks). + +Codegen runs only when: +- `isProduction` is `false` +- `NODE_ENV !== "test"` +- `codegen.enabled` is `true` ```ts import { Wabe } from "wabe"; + import { resolve } from "node:path"; const run = async () => { @@ -15,6 +25,7 @@ const run = async () => { enabled: true, path: `${import.meta.dirname}/../generated/`, }, + // Optional: called after wabe.ts/schema.graphql are generated onGenerateCodegen: async ({ path }) => { const process = Bun.spawn(["bunx", "oxfmt", "--write", resolve(path)]); const exitCode = await process.exited; diff --git a/packages/wabe-documentation/docs/documentation/mutex.md b/packages/wabe-documentation/docs/documentation/mutex.md new file mode 100644 index 00000000..93aeb3e9 --- /dev/null +++ b/packages/wabe-documentation/docs/documentation/mutex.md @@ -0,0 +1,53 @@ +# Mutex + +Wabe provides a database-backed mutex controller to coordinate concurrent operations across multiple server instances. + +You can use it through `wabe.controllers.mutex` with three methods: +- `lockMutex(name: string): Promise` +- `unlockMutex(name: string): Promise` +- `getMutexStatus(name: string): Promise` + +## Behavior + +- `lockMutex` uses an atomic compare-and-set under the hood. +- `unlockMutex` also uses an atomic compare-and-set. +- methods return `true` when the state transition is applied, `false` otherwise. +- mutex state is persisted in the internal `_Mutex` class. +- `_Mutex` is root-only (create/read/update/delete). + +## Example + +```ts +import { Wabe } from "wabe"; + +const run = async () => { + const wabe = new Wabe({ + // ... other config fields + }); + + await wabe.start(); + + const mutexName = "daily-billing-sync"; + const acquired = await wabe.controllers.mutex.lockMutex(mutexName); + + if (!acquired) { + // Another process already runs this job + return; + } + + try { + // Critical section + // run your job here + } finally { + await wabe.controllers.mutex.unlockMutex(mutexName); + } +}; + +await run(); +``` + +## Notes + +- choose deterministic names (for example `feature:userId`). +- always release in a `finally` block. +- if lock acquisition fails, decide explicitly whether to fail fast or retry in your own logic. diff --git a/packages/wabe-documentation/rspress.config.ts b/packages/wabe-documentation/rspress.config.ts index f4ff3d5e..62132b69 100644 --- a/packages/wabe-documentation/rspress.config.ts +++ b/packages/wabe-documentation/rspress.config.ts @@ -2,156 +2,157 @@ import * as path from 'node:path' import { defineConfig } from 'rspress/config' export default defineConfig({ - // Wabe because the repository name is wabe (for github pages) - base: '/wabe/', - root: path.join(__dirname, 'docs'), - route: { - cleanUrls: true, - }, - title: 'Wabe: Your backend in minutes not days', - description: - 'Wabe simplifies backend development with essential features like Authentication, Email, and Database, enabling effortless app building and scaling.', - globalStyles: path.join(__dirname, './styles/index.css'), - logoText: 'Wabe', - logo: '/assets/logo.png', - icon: '/assets/favicon.ico', - head: [ - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - '', - ], - builderConfig: { - html: { - tags: [ - { - tag: 'script', - children: "window.RSPRESS_THEME = 'light';", - }, - ], - }, - }, - themeConfig: { - hideNavbar: 'never', - darkMode: false, - search: true, - nav: [ - { - text: 'Documentation', - link: '/documentation/wabe/motivations', - position: 'right', - }, - ], - socialLinks: [ - { - icon: 'github', - content: 'https://github.com/palixir/wabe', - mode: 'link', - }, - { icon: 'X', content: 'https://x.com/coratgerl', mode: 'link' }, - ], - sidebar: { - '/wabe/': [ - { - text: 'Wabe', - collapsible: true, - items: [ - { text: 'Motivations', link: '/documentation/wabe/motivations' }, - { text: 'Quick start', link: '/documentation/wabe/start' }, - { text: 'Wabe concepts', link: '/documentation/wabe/concepts' }, - ], - }, - { - text: 'Schema', - collapsible: true, - collapsed: true, - items: [ - { - text: 'Classes', - link: '/documentation/schema/classes', - }, - { - text: 'Resolvers', - link: '/documentation/schema/resolvers', - }, - { text: 'Enums', link: '/documentation/schema/enums' }, - { - text: 'Scalars', - link: '/documentation/schema/scalars', - }, - ], - }, - { - text: 'Authentication', - collapsible: true, - collapsed: true, - items: [ - { - text: 'Sign In / Up / Out', - link: '/documentation/authentication/interact', - }, - { - text: 'Default auth methods', - link: '/documentation/authentication/defaultMethods', - }, - { - text: 'Two-factor authentication (2FA)', - link: '/documentation/authentication/twoFactor', - }, - { - text: 'Social login (OAuth)', - link: '/documentation/authentication/oauth', - }, - { - text: 'Email password with SRP protocol', - link: '/documentation/authentication/emailPasswordSRP', - }, - { - text: 'Reset password', - link: '/documentation/authentication/resetPassword', - }, - { - text: 'Sessions', - link: '/documentation/authentication/sessions', - }, - { - text: 'Roles', - link: '/documentation/authentication/roles', - }, - { - text: 'Create custom methods', - link: '/documentation/authentication/customMethods', - }, - ], - }, - { text: 'Hooks', link: '/documentation/hooks' }, - { text: 'Routes', link: '/documentation/routes' }, - { text: 'Codegen', link: '/documentation/codegen' }, - { text: 'Root key', link: '/documentation/rootKey' }, - { - text: 'Interact with database', - collapsible: true, - collapsed: true, - items: [ - { text: 'GraphQL', link: '/documentation/graphql/api' }, - { text: 'Database', link: '/documentation/database/index' }, - ], - }, - { - text: 'Security', - link: '/documentation/security/index', - }, - { text: 'Cron', link: '/documentation/cron' }, - { text: 'File', link: '/documentation/file/index' }, - { text: 'Email', link: '/documentation/email/index' }, - ], - }, - }, + // Wabe because the repository name is wabe (for github pages) + base: '/wabe/', + root: path.join(__dirname, 'docs'), + route: { + cleanUrls: true, + }, + title: 'Wabe: Your backend in minutes not days', + description: + 'Wabe simplifies backend development with essential features like Authentication, Email, and Database, enabling effortless app building and scaling.', + globalStyles: path.join(__dirname, './styles/index.css'), + logoText: 'Wabe', + logo: '/assets/logo.png', + icon: '/assets/favicon.ico', + head: [ + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + '', + ], + builderConfig: { + html: { + tags: [ + { + tag: 'script', + children: "window.RSPRESS_THEME = 'light';", + }, + ], + }, + }, + themeConfig: { + hideNavbar: 'never', + darkMode: false, + search: true, + nav: [ + { + text: 'Documentation', + link: '/documentation/wabe/motivations', + position: 'right', + }, + ], + socialLinks: [ + { + icon: 'github', + content: 'https://github.com/palixir/wabe', + mode: 'link', + }, + { icon: 'X', content: 'https://x.com/coratgerl', mode: 'link' }, + ], + sidebar: { + '/wabe/': [ + { + text: 'Wabe', + collapsible: true, + items: [ + { text: 'Motivations', link: '/documentation/wabe/motivations' }, + { text: 'Quick start', link: '/documentation/wabe/start' }, + { text: 'Wabe concepts', link: '/documentation/wabe/concepts' }, + ], + }, + { + text: 'Schema', + collapsible: true, + collapsed: true, + items: [ + { + text: 'Classes', + link: '/documentation/schema/classes', + }, + { + text: 'Resolvers', + link: '/documentation/schema/resolvers', + }, + { text: 'Enums', link: '/documentation/schema/enums' }, + { + text: 'Scalars', + link: '/documentation/schema/scalars', + }, + ], + }, + { + text: 'Authentication', + collapsible: true, + collapsed: true, + items: [ + { + text: 'Sign In / Up / Out', + link: '/documentation/authentication/interact', + }, + { + text: 'Default auth methods', + link: '/documentation/authentication/defaultMethods', + }, + { + text: 'Two-factor authentication (2FA)', + link: '/documentation/authentication/twoFactor', + }, + { + text: 'Social login (OAuth)', + link: '/documentation/authentication/oauth', + }, + { + text: 'Email password with SRP protocol', + link: '/documentation/authentication/emailPasswordSRP', + }, + { + text: 'Reset password', + link: '/documentation/authentication/resetPassword', + }, + { + text: 'Sessions', + link: '/documentation/authentication/sessions', + }, + { + text: 'Roles', + link: '/documentation/authentication/roles', + }, + { + text: 'Create custom methods', + link: '/documentation/authentication/customMethods', + }, + ], + }, + { text: 'Hooks', link: '/documentation/hooks' }, + { text: 'Routes', link: '/documentation/routes' }, + { text: 'Mutex', link: '/documentation/mutex' }, + { text: 'Codegen', link: '/documentation/codegen' }, + { text: 'Root key', link: '/documentation/rootKey' }, + { + text: 'Interact with database', + collapsible: true, + collapsed: true, + items: [ + { text: 'GraphQL', link: '/documentation/graphql/api' }, + { text: 'Database', link: '/documentation/database/index' }, + ], + }, + { + text: 'Security', + link: '/documentation/security/index', + }, + { text: 'Cron', link: '/documentation/cron' }, + { text: 'File', link: '/documentation/file/index' }, + { text: 'Email', link: '/documentation/email/index' }, + ], + }, + }, }) diff --git a/packages/wabe-mongodb/src/index.test.ts b/packages/wabe-mongodb/src/index.test.ts index 55a7f5c1..1813a5db 100644 --- a/packages/wabe-mongodb/src/index.test.ts +++ b/packages/wabe-mongodb/src/index.test.ts @@ -41,6 +41,40 @@ describe('Mongo adapter', () => { ) }) + it('should compare-and-set mutex lock atomically', async () => { + const acquired = await mongoAdapter.compareAndSetMutex({ + name: 'refresh:user-1', + requiredLockedState: false, + newLocked: true, + context, + }) + expect(acquired).toBe(true) + + const secondAcquire = await mongoAdapter.compareAndSetMutex({ + name: 'refresh:user-1', + requiredLockedState: false, + newLocked: true, + context, + }) + expect(secondAcquire).toBe(false) + + const released = await mongoAdapter.compareAndSetMutex({ + name: 'refresh:user-1', + requiredLockedState: true, + newLocked: false, + context, + }) + expect(released).toBe(true) + + const secondRelease = await mongoAdapter.compareAndSetMutex({ + name: 'refresh:user-1', + requiredLockedState: true, + newLocked: false, + context, + }) + expect(secondRelease).toBe(false) + }) + it('should create a row with no values', async () => { const res = await mongoAdapter.createObject({ className: 'Test', @@ -2122,7 +2156,6 @@ describe('Mongo adapter', () => { data: [ { name: 'Document with name', age: 25 }, { name: 'Another document with name', age: 30 }, - // @ts-expect-error { age: 35 }, // No name field ], context, @@ -2145,9 +2178,7 @@ describe('Mongo adapter', () => { className: 'Test', data: [ { name: 'Document with name', age: 25 }, - // @ts-expect-error { age: 30 }, // No name field - // @ts-expect-error { age: 35 }, // No name field ], context, @@ -2167,12 +2198,7 @@ describe('Mongo adapter', () => { it('should handle exists with null values correctly', async () => { await mongoAdapter.createObjects({ className: 'Test', - data: [ - { name: 'Document with name', age: 25 }, - { name: null, age: 30 }, - // @ts-expect-error - { age: 35 }, - ], + data: [{ name: 'Document with name', age: 25 }, { name: null, age: 30 }, { age: 35 }], context, }) @@ -2225,7 +2251,6 @@ describe('Mongo adapter', () => { // Create test documents with JSON data using the adapter await mongoAdapter.createObjects({ className: 'Test', - // @ts-expect-error data: [{ object: null, field1: 'John', float: 2.5 }, { int: 35 }], context, }) diff --git a/packages/wabe-mongodb/src/index.ts b/packages/wabe-mongodb/src/index.ts index a3a7db07..9f8f8685 100644 --- a/packages/wabe-mongodb/src/index.ts +++ b/packages/wabe-mongodb/src/index.ts @@ -228,7 +228,7 @@ export class MongoAdapter implements DatabaseAdapter { await this.client.close() } - createClassIfNotExist(className: keyof T['types'], schema: SchemaInterface) { + async createClassIfNotExist(className: keyof T['types'], schema: SchemaInterface) { if (!this.database) throw new Error('Connection to database is not established') const schemaClass = schema?.classes?.find((currentClass) => currentClass.name === className) @@ -240,12 +240,14 @@ export class MongoAdapter implements DatabaseAdapter { const indexes = schemaClass.indexes || [] - indexes.map((index) => - collection.createIndex( - { - [index.field]: index.order === 'ASC' ? 1 : -1, - }, - { unique: !!index.unique }, + await Promise.all( + indexes.map((index) => + collection.createIndex( + { + [index.field]: index.order === 'ASC' ? 1 : -1, + }, + { unique: !!index.unique }, + ), ), ) } @@ -500,4 +502,83 @@ export class MongoAdapter implements DatabaseAdapter { await collection.deleteMany(whereBuilded) } + + async compareAndSetMutex({ + name, + requiredLockedState, + newLocked, + context: _context, + }: { + name: string + requiredLockedState: boolean + newLocked: boolean + context: unknown + }): Promise { + if (!this.database) throw new Error('Connection to database is not established') + + const normalizedName = name.trim() + if (!normalizedName) throw new Error('Mutex name cannot be empty') + + const collection = this.database.collection('_Mutex') + + if (requiredLockedState === false) { + const updatedDocument = await collection.findOneAndUpdate( + { + name: normalizedName, + locked: false, + }, + { + $set: { + locked: newLocked, + }, + }, + { + returnDocument: 'after', + }, + ) + + if (updatedDocument) return true + + const existingMutex = await collection.findOne( + { name: normalizedName }, + { projection: { _id: 1 } }, + ) + if (existingMutex) return false + + try { + await collection.insertOne({ + name: normalizedName, + locked: newLocked, + }) + return true + } catch (error) { + if ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: number }).code === 11000 + ) + return false + + throw error + } + } + + const updatedDocument = await collection.findOneAndUpdate( + { + name: normalizedName, + locked: requiredLockedState, + }, + { + $set: { + locked: newLocked, + }, + }, + { + returnDocument: 'after', + }, + ) + + return !!updatedDocument + } } diff --git a/packages/wabe-postgres/src/index.test.ts b/packages/wabe-postgres/src/index.test.ts index 34d4f639..8f5e1d6f 100644 --- a/packages/wabe-postgres/src/index.test.ts +++ b/packages/wabe-postgres/src/index.test.ts @@ -50,6 +50,40 @@ describe('Postgres adapter', () => { } }) + it('should compare-and-set mutex lock atomically', async () => { + const acquired = await postgresAdapter.compareAndSetMutex({ + name: 'refresh:user-1', + requiredLockedState: false, + newLocked: true, + context, + }) + expect(acquired).toBe(true) + + const secondAcquire = await postgresAdapter.compareAndSetMutex({ + name: 'refresh:user-1', + requiredLockedState: false, + newLocked: true, + context, + }) + expect(secondAcquire).toBe(false) + + const released = await postgresAdapter.compareAndSetMutex({ + name: 'refresh:user-1', + requiredLockedState: true, + newLocked: false, + context, + }) + expect(released).toBe(true) + + const secondRelease = await postgresAdapter.compareAndSetMutex({ + name: 'refresh:user-1', + requiredLockedState: true, + newLocked: false, + context, + }) + expect(secondRelease).toBe(false) + }) + it('should create a row with no values', async () => { const res = await postgresAdapter.createObject({ className: 'Test', @@ -2156,7 +2190,6 @@ describe('Postgres adapter', () => { // Create test documents with JSON data using the adapter await postgresAdapter.createObjects({ className: 'Test', - // @ts-expect-error data: [{ object: null, field1: 'John', float: 2.5 }, { int: 35 }], context, }) diff --git a/packages/wabe-postgres/src/index.ts b/packages/wabe-postgres/src/index.ts index 02fa3922..b6f8ee9b 100644 --- a/packages/wabe-postgres/src/index.ts +++ b/packages/wabe-postgres/src/index.ts @@ -764,4 +764,50 @@ export class PostgresAdapter implements DatabaseAdapter client.release() } } + + async compareAndSetMutex({ + name, + requiredLockedState, + newLocked, + context: _context, + }: { + name: string + requiredLockedState: boolean + newLocked: boolean + context: unknown + }): Promise { + const normalizedName = name.trim() + if (!normalizedName) throw new Error('Mutex name cannot be empty') + + const client = await this.pool.connect() + + try { + if (!requiredLockedState) { + const result = await client.query( + `INSERT INTO "_Mutex" ("name", "locked") + VALUES ($1, $2) + ON CONFLICT ("name") + DO UPDATE + SET "locked" = EXCLUDED."locked" + WHERE "_Mutex"."locked" = $3 + RETURNING _id`, + [normalizedName, newLocked, requiredLockedState], + ) + + return (result.rowCount || 0) > 0 + } + + const result = await client.query( + `UPDATE "_Mutex" + SET "locked" = $2 + WHERE "name" = $1 AND "locked" = $3 + RETURNING _id`, + [normalizedName, newLocked, requiredLockedState], + ) + + return (result.rowCount || 0) > 0 + } finally { + client.release() + } + } } diff --git a/packages/wabe/generated/schema.graphql b/packages/wabe/generated/schema.graphql index 37898e5d..85fe4b3a 100644 --- a/packages/wabe/generated/schema.graphql +++ b/packages/wabe/generated/schema.graphql @@ -1,7 +1,5 @@ enum RoleEnum { DashboardAdmin - Admin - Client } enum AuthenticationProvider { @@ -31,21 +29,106 @@ Date scalar type """ scalar Date +type Collection1 { + id: ID! + name: String + acl: Collection1ACLObject + createdAt: Date + updatedAt: Date + search: [String] +} + +type Collection1ACLObject { + users: [Collection1ACLObjectUsersACL] + roles: [Collection1ACLObjectRolesACL] +} + +type Collection1ACLObjectUsersACL { + userId: String! + read: Boolean! + write: Boolean! +} + +type Collection1ACLObjectRolesACL { + roleId: String! + read: Boolean! + write: Boolean! +} + +input Collection1Input { + name: String + acl: Collection1ACLObjectInput + createdAt: Date + updatedAt: Date + search: [String] +} + +input Collection1ACLObjectInput { + users: [Collection1ACLObjectUsersACLInput] + roles: [Collection1ACLObjectRolesACLInput] +} + +input Collection1ACLObjectUsersACLInput { + userId: String! + read: Boolean! + write: Boolean! +} + +input Collection1ACLObjectRolesACLInput { + roleId: String! + read: Boolean! + write: Boolean! +} + """ -User class +Input to link an object to a pointer Collection1 """ -type User { - id: ID! +input Collection1PointerInput { + unlink: Boolean + link: ID + createAndLink: Collection1CreateFieldsInput +} + +input Collection1CreateFieldsInput { name: String - age: Int - email: Email - acl: UserACLObject + acl: Collection1ACLObjectCreateFieldsInput createdAt: Date updatedAt: Date search: [String] +} + +input Collection1ACLObjectCreateFieldsInput { + users: [Collection1ACLObjectUsersACLCreateFieldsInput] + roles: [Collection1ACLObjectRolesACLCreateFieldsInput] +} + +input Collection1ACLObjectUsersACLCreateFieldsInput { + userId: String + read: Boolean + write: Boolean +} + +input Collection1ACLObjectRolesACLCreateFieldsInput { + roleId: String + read: Boolean + write: Boolean +} + +""" +Input to add a relation to the class Collection1 +""" +input Collection1RelationInput { + add: [ID!] + remove: [ID!] + createAndAdd: [Collection1CreateFieldsInput!] +} + +type User { + id: ID! authentication: UserAuthentication provider: AuthenticationProvider isOauth: Boolean + email: Email verifiedEmail: Boolean role: Role sessions( @@ -57,28 +140,10 @@ type User { secondFA: UserSecondFA otpSalt: String pendingChallenges: [UserPendingAuthenticationChallenge] -} - -""" -Email scalar type -""" -scalar Email - -type UserACLObject { - users: [UserACLObjectUsersACL] - roles: [UserACLObjectRolesACL] -} - -type UserACLObjectUsersACL { - userId: String! - read: Boolean! - write: Boolean! -} - -type UserACLObjectRolesACL { - roleId: String! - read: Boolean! - write: Boolean! + acl: UserACLObject + createdAt: Date + updatedAt: Date + search: [String] } type UserAuthentication { @@ -97,6 +162,11 @@ type UserAuthenticationEmailPasswordSRP { serverSecretExpiresAt: Date } +""" +Email scalar type +""" +scalar Email + type UserAuthenticationPhonePassword { phone: Phone! password: String! @@ -113,11 +183,13 @@ type UserAuthenticationEmailPassword { } type UserAuthenticationGoogle { + providerUserId: String email: Email! verifiedEmail: Boolean! } type UserAuthenticationGithub { + providerUserId: String email: Email! avatarUrl: String! username: String! @@ -155,49 +227,44 @@ input IdWhereInput { notIn: [ID] } -""" -User class -""" input UserWhereInput { id: IdWhereInput - name: StringWhereInput - age: IntWhereInput - email: EmailWhereInput - acl: UserACLObjectWhereInput - createdAt: DateWhereInput - updatedAt: DateWhereInput - search: SearchWhereInput authentication: UserAuthenticationWhereInput provider: AnyWhereInput isOauth: BooleanWhereInput + email: EmailWhereInput verifiedEmail: BooleanWhereInput role: RoleWhereInput sessions: _SessionRelationWhereInput secondFA: UserSecondFAWhereInput otpSalt: StringWhereInput pendingChallenges: [UserPendingAuthenticationChallengeWhereInput] + acl: UserACLObjectWhereInput + createdAt: DateWhereInput + updatedAt: DateWhereInput + search: SearchWhereInput OR: [UserWhereInput] AND: [UserWhereInput] } -input StringWhereInput { - equalTo: String - notEqualTo: String - in: [String] - notIn: [String] - exists: Boolean +input UserAuthenticationWhereInput { + emailPasswordSRP: UserAuthenticationEmailPasswordSRPWhereInput + phonePassword: UserAuthenticationPhonePasswordWhereInput + emailPassword: UserAuthenticationEmailPasswordWhereInput + google: UserAuthenticationGoogleWhereInput + github: UserAuthenticationGithubWhereInput + OR: [UserAuthenticationWhereInput] + AND: [UserAuthenticationWhereInput] } -input IntWhereInput { - equalTo: Int - notEqualTo: Int - lessThan: Int - lessThanOrEqualTo: Int - greaterThan: Int - greaterThanOrEqualTo: Int - in: [Int] - notIn: [Int] - exists: Boolean +input UserAuthenticationEmailPasswordSRPWhereInput { + email: EmailWhereInput + salt: StringWhereInput + verifier: StringWhereInput + serverSecret: StringWhereInput + serverSecretExpiresAt: DateWhereInput + OR: [UserAuthenticationEmailPasswordSRPWhereInput] + AND: [UserAuthenticationEmailPasswordSRPWhereInput] } input EmailWhereInput { @@ -208,37 +275,14 @@ input EmailWhereInput { exists: Boolean } -input UserACLObjectWhereInput { - users: [UserACLObjectUsersACLWhereInput] - roles: [UserACLObjectRolesACLWhereInput] - OR: [UserACLObjectWhereInput] - AND: [UserACLObjectWhereInput] -} - -input UserACLObjectUsersACLWhereInput { - userId: StringWhereInput - read: BooleanWhereInput - write: BooleanWhereInput - OR: [UserACLObjectUsersACLWhereInput] - AND: [UserACLObjectUsersACLWhereInput] -} - -input BooleanWhereInput { - equalTo: Boolean - notEqualTo: Boolean - in: [Boolean] - notIn: [Boolean] +input StringWhereInput { + equalTo: String + notEqualTo: String + in: [String] + notIn: [String] exists: Boolean } -input UserACLObjectRolesACLWhereInput { - roleId: StringWhereInput - read: BooleanWhereInput - write: BooleanWhereInput - OR: [UserACLObjectRolesACLWhereInput] - AND: [UserACLObjectRolesACLWhereInput] -} - input DateWhereInput { equalTo: Date notEqualTo: Date @@ -251,35 +295,6 @@ input DateWhereInput { exists: Boolean } -input SearchWhereInput { - contains: Search -} - -""" -Search scalar to tokenize and search for all searchable fields -""" -scalar Search - -input UserAuthenticationWhereInput { - emailPasswordSRP: UserAuthenticationEmailPasswordSRPWhereInput - phonePassword: UserAuthenticationPhonePasswordWhereInput - emailPassword: UserAuthenticationEmailPasswordWhereInput - google: UserAuthenticationGoogleWhereInput - github: UserAuthenticationGithubWhereInput - OR: [UserAuthenticationWhereInput] - AND: [UserAuthenticationWhereInput] -} - -input UserAuthenticationEmailPasswordSRPWhereInput { - email: EmailWhereInput - salt: StringWhereInput - verifier: StringWhereInput - serverSecret: StringWhereInput - serverSecretExpiresAt: DateWhereInput - OR: [UserAuthenticationEmailPasswordSRPWhereInput] - AND: [UserAuthenticationEmailPasswordSRPWhereInput] -} - input UserAuthenticationPhonePasswordWhereInput { phone: PhoneWhereInput password: StringWhereInput @@ -303,13 +318,23 @@ input UserAuthenticationEmailPasswordWhereInput { } input UserAuthenticationGoogleWhereInput { + providerUserId: StringWhereInput email: EmailWhereInput verifiedEmail: BooleanWhereInput OR: [UserAuthenticationGoogleWhereInput] AND: [UserAuthenticationGoogleWhereInput] } +input BooleanWhereInput { + equalTo: Boolean + notEqualTo: Boolean + in: [Boolean] + notIn: [Boolean] + exists: Boolean +} + input UserAuthenticationGithubWhereInput { + providerUserId: StringWhereInput email: EmailWhereInput avatarUrl: StringWhereInput username: StringWhereInput @@ -371,6 +396,15 @@ input RoleACLObjectRolesACLWhereInput { AND: [RoleACLObjectRolesACLWhereInput] } +input SearchWhereInput { + contains: Search +} + +""" +Search scalar to tokenize and search for all searchable fields +""" +scalar Search + """ Filter on relation to _Session """ @@ -394,6 +428,29 @@ input UserPendingAuthenticationChallengeWhereInput { AND: [UserPendingAuthenticationChallengeWhereInput] } +input UserACLObjectWhereInput { + users: [UserACLObjectUsersACLWhereInput] + roles: [UserACLObjectRolesACLWhereInput] + OR: [UserACLObjectWhereInput] + AND: [UserACLObjectWhereInput] +} + +input UserACLObjectUsersACLWhereInput { + userId: StringWhereInput + read: BooleanWhereInput + write: BooleanWhereInput + OR: [UserACLObjectUsersACLWhereInput] + AND: [UserACLObjectUsersACLWhereInput] +} + +input UserACLObjectRolesACLWhereInput { + roleId: StringWhereInput + read: BooleanWhereInput + write: BooleanWhereInput + OR: [UserACLObjectRolesACLWhereInput] + AND: [UserACLObjectRolesACLWhereInput] +} + input _SessionACLObjectWhereInput { users: [_SessionACLObjectUsersACLWhereInput] roles: [_SessionACLObjectRolesACLWhereInput] @@ -449,45 +506,40 @@ type UserPendingAuthenticationChallenge { expiresAt: Date! } -""" -User class -""" -input UserInput { - name: String - age: Int - email: Email - acl: UserACLObjectInput - createdAt: Date - updatedAt: Date - search: [String] - authentication: UserAuthenticationInput - provider: AuthenticationProvider - isOauth: Boolean - verifiedEmail: Boolean - role: RolePointerInput - sessions: _SessionRelationInput - secondFA: UserSecondFAInput - otpSalt: String - pendingChallenges: [UserPendingAuthenticationChallengeInput] -} - -input UserACLObjectInput { - users: [UserACLObjectUsersACLInput] - roles: [UserACLObjectRolesACLInput] +type UserACLObject { + users: [UserACLObjectUsersACL] + roles: [UserACLObjectRolesACL] } -input UserACLObjectUsersACLInput { +type UserACLObjectUsersACL { userId: String! read: Boolean! write: Boolean! } -input UserACLObjectRolesACLInput { +type UserACLObjectRolesACL { roleId: String! read: Boolean! write: Boolean! } +input UserInput { + authentication: UserAuthenticationInput + provider: AuthenticationProvider + isOauth: Boolean + email: Email + verifiedEmail: Boolean + role: RolePointerInput + sessions: _SessionRelationInput + secondFA: UserSecondFAInput + otpSalt: String + pendingChallenges: [UserPendingAuthenticationChallengeInput] + acl: UserACLObjectInput + createdAt: Date + updatedAt: Date + search: [String] +} + input UserAuthenticationInput { emailPasswordSRP: UserAuthenticationEmailPasswordSRPInput phonePassword: UserAuthenticationPhonePasswordInput @@ -515,11 +567,13 @@ input UserAuthenticationEmailPasswordInput { } input UserAuthenticationGoogleInput { + providerUserId: String email: Email! verifiedEmail: Boolean! } input UserAuthenticationGithubInput { + providerUserId: String email: Email! avatarUrl: String! username: String! @@ -536,6 +590,23 @@ input UserPendingAuthenticationChallengeInput { expiresAt: Date! } +input UserACLObjectInput { + users: [UserACLObjectUsersACLInput] + roles: [UserACLObjectRolesACLInput] +} + +input UserACLObjectUsersACLInput { + userId: String! + read: Boolean! + write: Boolean! +} + +input UserACLObjectRolesACLInput { + roleId: String! + read: Boolean! + write: Boolean! +} + """ Input to link an object to a pointer User """ @@ -545,43 +616,21 @@ input UserPointerInput { createAndLink: UserCreateFieldsInput } -""" -User class -""" input UserCreateFieldsInput { - name: String - age: Int - email: Email - acl: UserACLObjectCreateFieldsInput - createdAt: Date - updatedAt: Date - search: [String] authentication: UserAuthenticationCreateFieldsInput provider: AuthenticationProvider isOauth: Boolean + email: Email verifiedEmail: Boolean role: RolePointerInput sessions: _SessionRelationInput secondFA: UserSecondFACreateFieldsInput otpSalt: String pendingChallenges: [UserPendingAuthenticationChallengeCreateFieldsInput] -} - -input UserACLObjectCreateFieldsInput { - users: [UserACLObjectUsersACLCreateFieldsInput] - roles: [UserACLObjectRolesACLCreateFieldsInput] -} - -input UserACLObjectUsersACLCreateFieldsInput { - userId: String - read: Boolean - write: Boolean -} - -input UserACLObjectRolesACLCreateFieldsInput { - roleId: String - read: Boolean - write: Boolean + acl: UserACLObjectCreateFieldsInput + createdAt: Date + updatedAt: Date + search: [String] } input UserAuthenticationCreateFieldsInput { @@ -611,11 +660,13 @@ input UserAuthenticationEmailPasswordCreateFieldsInput { } input UserAuthenticationGoogleCreateFieldsInput { + providerUserId: String email: Email verifiedEmail: Boolean } input UserAuthenticationGithubCreateFieldsInput { + providerUserId: String email: Email avatarUrl: String username: String @@ -632,188 +683,30 @@ input UserPendingAuthenticationChallengeCreateFieldsInput { expiresAt: Date } -""" -Input to add a relation to the class User -""" -input UserRelationInput { - add: [ID!] - remove: [ID!] - createAndAdd: [UserCreateFieldsInput!] -} - -type Post { - id: ID! - name: String! - test2: RoleEnum - test3(where: UserWhereInput, offset: Int, first: Int, order: [UserOrder!]): UserConnection - test4: User - experiences: [PostExperience!] - acl: PostACLObject - createdAt: Date - updatedAt: Date - search: [String] -} - -type UserConnection { - ok: Boolean - totalCount: Int - edges: [UserEdge] -} - -type UserEdge { - node: User! -} - -enum UserOrder { - name_ASC - name_DESC - age_ASC - age_DESC - email_ASC - email_DESC - acl_ASC - acl_DESC - createdAt_ASC - createdAt_DESC - updatedAt_ASC - updatedAt_DESC - search_ASC - search_DESC - authentication_ASC - authentication_DESC - provider_ASC - provider_DESC - isOauth_ASC - isOauth_DESC - verifiedEmail_ASC - verifiedEmail_DESC - role_ASC - role_DESC - sessions_ASC - sessions_DESC - secondFA_ASC - secondFA_DESC - otpSalt_ASC - otpSalt_DESC - pendingChallenges_ASC - pendingChallenges_DESC -} - -type PostExperience { - jobTitle: String! - companyName: String! - startDate: String! - endDate: String! - achievements: [String] -} - -type PostACLObject { - users: [PostACLObjectUsersACL] - roles: [PostACLObjectRolesACL] -} - -type PostACLObjectUsersACL { - userId: String! - read: Boolean! - write: Boolean! -} - -type PostACLObjectRolesACL { - roleId: String! - read: Boolean! - write: Boolean! -} - -input PostInput { - name: String! - test2: RoleEnum - test3: UserRelationInput - test4: UserPointerInput - experiences: [PostExperienceInput!] - acl: PostACLObjectInput - createdAt: Date - updatedAt: Date - search: [String] -} - -input PostExperienceInput { - jobTitle: String! - companyName: String! - startDate: String! - endDate: String! - achievements: [String] -} - -input PostACLObjectInput { - users: [PostACLObjectUsersACLInput] - roles: [PostACLObjectRolesACLInput] -} - -input PostACLObjectUsersACLInput { - userId: String! - read: Boolean! - write: Boolean! -} - -input PostACLObjectRolesACLInput { - roleId: String! - read: Boolean! - write: Boolean! -} - -""" -Input to link an object to a pointer Post -""" -input PostPointerInput { - unlink: Boolean - link: ID - createAndLink: PostCreateFieldsInput -} - -input PostCreateFieldsInput { - name: String - test2: RoleEnum - test3: UserRelationInput - test4: UserPointerInput - experiences: [PostExperienceCreateFieldsInput!] - acl: PostACLObjectCreateFieldsInput - createdAt: Date - updatedAt: Date - search: [String] -} - -input PostExperienceCreateFieldsInput { - jobTitle: String - companyName: String - startDate: String - endDate: String - achievements: [String] -} - -input PostACLObjectCreateFieldsInput { - users: [PostACLObjectUsersACLCreateFieldsInput] - roles: [PostACLObjectRolesACLCreateFieldsInput] +input UserACLObjectCreateFieldsInput { + users: [UserACLObjectUsersACLCreateFieldsInput] + roles: [UserACLObjectRolesACLCreateFieldsInput] } -input PostACLObjectUsersACLCreateFieldsInput { +input UserACLObjectUsersACLCreateFieldsInput { userId: String read: Boolean write: Boolean } -input PostACLObjectRolesACLCreateFieldsInput { +input UserACLObjectRolesACLCreateFieldsInput { roleId: String read: Boolean write: Boolean } """ -Input to add a relation to the class Post +Input to add a relation to the class User """ -input PostRelationInput { +input UserRelationInput { add: [ID!] remove: [ID!] - createAndAdd: [PostCreateFieldsInput!] + createAndAdd: [UserCreateFieldsInput!] } type _Session { @@ -932,6 +825,47 @@ type Role { search: [String] } +type UserConnection { + ok: Boolean + totalCount: Int + edges: [UserEdge] +} + +type UserEdge { + node: User! +} + +enum UserOrder { + authentication_ASC + authentication_DESC + provider_ASC + provider_DESC + isOauth_ASC + isOauth_DESC + email_ASC + email_DESC + verifiedEmail_ASC + verifiedEmail_DESC + role_ASC + role_DESC + sessions_ASC + sessions_DESC + secondFA_ASC + secondFA_DESC + otpSalt_ASC + otpSalt_DESC + pendingChallenges_ASC + pendingChallenges_DESC + acl_ASC + acl_DESC + createdAt_ASC + createdAt_DESC + updatedAt_ASC + updatedAt_DESC + search_ASC + search_DESC +} + type RoleACLObject { users: [RoleACLObjectUsersACL] roles: [RoleACLObjectRolesACL] @@ -1093,44 +1027,139 @@ input _InternalConfigCreateFieldsInput { search: [String] } -input _InternalConfigACLObjectCreateFieldsInput { - users: [_InternalConfigACLObjectUsersACLCreateFieldsInput] - roles: [_InternalConfigACLObjectRolesACLCreateFieldsInput] +input _InternalConfigACLObjectCreateFieldsInput { + users: [_InternalConfigACLObjectUsersACLCreateFieldsInput] + roles: [_InternalConfigACLObjectRolesACLCreateFieldsInput] +} + +input _InternalConfigACLObjectUsersACLCreateFieldsInput { + userId: String + read: Boolean + write: Boolean +} + +input _InternalConfigACLObjectRolesACLCreateFieldsInput { + roleId: String + read: Boolean + write: Boolean +} + +""" +Input to add a relation to the class _InternalConfig +""" +input _InternalConfigRelationInput { + add: [ID!] + remove: [ID!] + createAndAdd: [_InternalConfigCreateFieldsInput!] +} + +type _Mutex { + id: ID! + name: String! + locked: Boolean! + acl: _MutexACLObject + createdAt: Date + updatedAt: Date + search: [String] +} + +type _MutexACLObject { + users: [_MutexACLObjectUsersACL] + roles: [_MutexACLObjectRolesACL] +} + +type _MutexACLObjectUsersACL { + userId: String! + read: Boolean! + write: Boolean! +} + +type _MutexACLObjectRolesACL { + roleId: String! + read: Boolean! + write: Boolean! +} + +input _MutexInput { + name: String! + locked: Boolean! + acl: _MutexACLObjectInput + createdAt: Date + updatedAt: Date + search: [String] +} + +input _MutexACLObjectInput { + users: [_MutexACLObjectUsersACLInput] + roles: [_MutexACLObjectRolesACLInput] +} + +input _MutexACLObjectUsersACLInput { + userId: String! + read: Boolean! + write: Boolean! +} + +input _MutexACLObjectRolesACLInput { + roleId: String! + read: Boolean! + write: Boolean! +} + +""" +Input to link an object to a pointer _Mutex +""" +input _MutexPointerInput { + unlink: Boolean + link: ID + createAndLink: _MutexCreateFieldsInput +} + +input _MutexCreateFieldsInput { + name: String + locked: Boolean + acl: _MutexACLObjectCreateFieldsInput + createdAt: Date + updatedAt: Date + search: [String] +} + +input _MutexACLObjectCreateFieldsInput { + users: [_MutexACLObjectUsersACLCreateFieldsInput] + roles: [_MutexACLObjectRolesACLCreateFieldsInput] } -input _InternalConfigACLObjectUsersACLCreateFieldsInput { +input _MutexACLObjectUsersACLCreateFieldsInput { userId: String read: Boolean write: Boolean } -input _InternalConfigACLObjectRolesACLCreateFieldsInput { +input _MutexACLObjectRolesACLCreateFieldsInput { roleId: String read: Boolean write: Boolean } """ -Input to add a relation to the class _InternalConfig +Input to add a relation to the class _Mutex """ -input _InternalConfigRelationInput { +input _MutexRelationInput { add: [ID!] remove: [ID!] - createAndAdd: [_InternalConfigCreateFieldsInput!] + createAndAdd: [_MutexCreateFieldsInput!] } type Query { - """ - User class - """ + collection1(id: ID): Collection1 + collection1s( + where: Collection1WhereInput + offset: Int + first: Int + order: [Collection1Order!] + ): Collection1Connection! user(id: ID): User - - """ - User class - """ users(where: UserWhereInput, offset: Int, first: Int, order: [UserOrder!]): UserConnection! - post(id: ID): Post - posts(where: PostWhereInput, offset: Int, first: Int, order: [PostOrder!]): PostConnection! _session(id: ID): _Session _sessions( where: _SessionWhereInput @@ -1147,91 +1176,63 @@ type Query { first: Int order: [_InternalConfigOrder!] ): _InternalConfigConnection! - - """ - Hello world description - """ - helloWorld(name: String!): String + _mutex(id: ID): _Mutex + _mutexes( + where: _MutexWhereInput + offset: Int + first: Int + order: [_MutexOrder!] + ): _MutexConnection! me: MeOutput } -type PostConnection { +type Collection1Connection { ok: Boolean totalCount: Int - edges: [PostEdge] + edges: [Collection1Edge] } -type PostEdge { - node: Post! +type Collection1Edge { + node: Collection1! } -input PostWhereInput { +input Collection1WhereInput { id: IdWhereInput name: StringWhereInput - test2: AnyWhereInput - test3: UserRelationWhereInput - test4: UserWhereInput - experiences: [PostExperienceWhereInput!] - acl: PostACLObjectWhereInput + acl: Collection1ACLObjectWhereInput createdAt: DateWhereInput updatedAt: DateWhereInput search: SearchWhereInput - OR: [PostWhereInput] - AND: [PostWhereInput] -} - -input PostExperienceWhereInput { - jobTitle: StringWhereInput - companyName: StringWhereInput - startDate: StringWhereInput - endDate: StringWhereInput - achievements: ArrayWhereInput - OR: [PostExperienceWhereInput] - AND: [PostExperienceWhereInput] -} - -input ArrayWhereInput { - equalTo: Any - notEqualTo: Any - contains: Any - notContains: Any - exists: Boolean + OR: [Collection1WhereInput] + AND: [Collection1WhereInput] } -input PostACLObjectWhereInput { - users: [PostACLObjectUsersACLWhereInput] - roles: [PostACLObjectRolesACLWhereInput] - OR: [PostACLObjectWhereInput] - AND: [PostACLObjectWhereInput] +input Collection1ACLObjectWhereInput { + users: [Collection1ACLObjectUsersACLWhereInput] + roles: [Collection1ACLObjectRolesACLWhereInput] + OR: [Collection1ACLObjectWhereInput] + AND: [Collection1ACLObjectWhereInput] } -input PostACLObjectUsersACLWhereInput { +input Collection1ACLObjectUsersACLWhereInput { userId: StringWhereInput read: BooleanWhereInput write: BooleanWhereInput - OR: [PostACLObjectUsersACLWhereInput] - AND: [PostACLObjectUsersACLWhereInput] + OR: [Collection1ACLObjectUsersACLWhereInput] + AND: [Collection1ACLObjectUsersACLWhereInput] } -input PostACLObjectRolesACLWhereInput { +input Collection1ACLObjectRolesACLWhereInput { roleId: StringWhereInput read: BooleanWhereInput write: BooleanWhereInput - OR: [PostACLObjectRolesACLWhereInput] - AND: [PostACLObjectRolesACLWhereInput] + OR: [Collection1ACLObjectRolesACLWhereInput] + AND: [Collection1ACLObjectRolesACLWhereInput] } -enum PostOrder { +enum Collection1Order { name_ASC name_DESC - test2_ASC - test2_DESC - test3_ASC - test3_DESC - test4_ASC - test4_DESC - experiences_ASC - experiences_DESC acl_ASC acl_DESC createdAt_ASC @@ -1330,46 +1331,83 @@ enum _InternalConfigOrder { search_DESC } +type _MutexConnection { + ok: Boolean + totalCount: Int + edges: [_MutexEdge] +} + +type _MutexEdge { + node: _Mutex! +} + +input _MutexWhereInput { + id: IdWhereInput + name: StringWhereInput + locked: BooleanWhereInput + acl: _MutexACLObjectWhereInput + createdAt: DateWhereInput + updatedAt: DateWhereInput + search: SearchWhereInput + OR: [_MutexWhereInput] + AND: [_MutexWhereInput] +} + +input _MutexACLObjectWhereInput { + users: [_MutexACLObjectUsersACLWhereInput] + roles: [_MutexACLObjectRolesACLWhereInput] + OR: [_MutexACLObjectWhereInput] + AND: [_MutexACLObjectWhereInput] +} + +input _MutexACLObjectUsersACLWhereInput { + userId: StringWhereInput + read: BooleanWhereInput + write: BooleanWhereInput + OR: [_MutexACLObjectUsersACLWhereInput] + AND: [_MutexACLObjectUsersACLWhereInput] +} + +input _MutexACLObjectRolesACLWhereInput { + roleId: StringWhereInput + read: BooleanWhereInput + write: BooleanWhereInput + OR: [_MutexACLObjectRolesACLWhereInput] + AND: [_MutexACLObjectRolesACLWhereInput] +} + +enum _MutexOrder { + name_ASC + name_DESC + locked_ASC + locked_DESC + acl_ASC + acl_DESC + createdAt_ASC + createdAt_DESC + updatedAt_ASC + updatedAt_DESC + search_ASC + search_DESC +} + type MeOutput { user: User } type Mutation { - """ - User class - """ + createCollection1(input: CreateCollection1Input!): CreateCollection1Payload + createCollection1s(input: CreateCollection1sInput!): Collection1Connection! + updateCollection1(input: UpdateCollection1Input!): UpdateCollection1Payload + updateCollection1s(input: UpdateCollection1sInput!): Collection1Connection! + deleteCollection1(input: DeleteCollection1Input!): DeleteCollection1Payload + deleteCollection1s(input: DeleteCollection1sInput!): Collection1Connection! createUser(input: CreateUserInput!): CreateUserPayload - - """ - User class - """ createUsers(input: CreateUsersInput!): UserConnection! - - """ - User class - """ updateUser(input: UpdateUserInput!): UpdateUserPayload - - """ - User class - """ updateUsers(input: UpdateUsersInput!): UserConnection! - - """ - User class - """ deleteUser(input: DeleteUserInput!): DeleteUserPayload - - """ - User class - """ deleteUsers(input: DeleteUsersInput!): UserConnection! - createPost(input: CreatePostInput!): CreatePostPayload - createPosts(input: CreatePostsInput!): PostConnection! - updatePost(input: UpdatePostInput!): UpdatePostPayload - updatePosts(input: UpdatePostsInput!): PostConnection! - deletePost(input: DeletePostInput!): DeletePostPayload - deletePosts(input: DeletePostsInput!): PostConnection! create_Session(input: Create_SessionInput!): Create_SessionPayload create_Sessions(input: Create_SessionsInput!): _SessionConnection! update_Session(input: Update_SessionInput!): Update_SessionPayload @@ -1388,9 +1426,12 @@ type Mutation { update_InternalConfigs(input: Update_InternalConfigsInput!): _InternalConfigConnection! delete_InternalConfig(input: Delete_InternalConfigInput!): Delete_InternalConfigPayload delete_InternalConfigs(input: Delete_InternalConfigsInput!): _InternalConfigConnection! - createMutation(input: CreateMutationInput!): Boolean! - customMutation(input: CustomMutationInput!): Int - secondCustomMutation(input: SecondCustomMutationInput!): Int + create_Mutex(input: Create_MutexInput!): Create_MutexPayload + create_Mutexes(input: Create_MutexesInput!): _MutexConnection! + update_Mutex(input: Update_MutexInput!): Update_MutexPayload + update_Mutexes(input: Update_MutexesInput!): _MutexConnection! + delete_Mutex(input: Delete_MutexInput!): Delete_MutexPayload + delete_Mutexes(input: Delete_MutexesInput!): _MutexConnection! """ Mutation to reset the password of the user @@ -1408,6 +1449,79 @@ type Mutation { verifyChallenge(input: VerifyChallengeInput!): VerifyChallengeOutput } +type CreateCollection1Payload { + collection1: Collection1 + ok: Boolean +} + +input CreateCollection1Input { + fields: Collection1CreateFieldsInput +} + +input CreateCollection1sInput { + fields: [Collection1CreateFieldsInput]! + offset: Int + first: Int + order: [Collection1Order] +} + +type UpdateCollection1Payload { + collection1: Collection1 + ok: Boolean +} + +input UpdateCollection1Input { + id: ID + fields: Collection1UpdateFieldsInput +} + +input Collection1UpdateFieldsInput { + name: String + acl: Collection1ACLObjectUpdateFieldsInput + createdAt: Date + updatedAt: Date + search: [String] +} + +input Collection1ACLObjectUpdateFieldsInput { + users: [Collection1ACLObjectUsersACLUpdateFieldsInput] + roles: [Collection1ACLObjectRolesACLUpdateFieldsInput] +} + +input Collection1ACLObjectUsersACLUpdateFieldsInput { + userId: String + read: Boolean + write: Boolean +} + +input Collection1ACLObjectRolesACLUpdateFieldsInput { + roleId: String + read: Boolean + write: Boolean +} + +input UpdateCollection1sInput { + fields: Collection1UpdateFieldsInput + where: Collection1WhereInput + offset: Int + first: Int + order: [Collection1Order] +} + +type DeleteCollection1Payload { + collection1: Collection1 + ok: Boolean +} + +input DeleteCollection1Input { + id: ID +} + +input DeleteCollection1sInput { + where: Collection1WhereInput + order: [Collection1Order] +} + type CreateUserPayload { user: User ok: Boolean @@ -1434,43 +1548,21 @@ input UpdateUserInput { fields: UserUpdateFieldsInput } -""" -User class -""" input UserUpdateFieldsInput { - name: String - age: Int - email: Email - acl: UserACLObjectUpdateFieldsInput - createdAt: Date - updatedAt: Date - search: [String] authentication: UserAuthenticationUpdateFieldsInput provider: AuthenticationProvider isOauth: Boolean + email: Email verifiedEmail: Boolean role: RolePointerInput sessions: _SessionRelationInput secondFA: UserSecondFAUpdateFieldsInput otpSalt: String pendingChallenges: [UserPendingAuthenticationChallengeUpdateFieldsInput] -} - -input UserACLObjectUpdateFieldsInput { - users: [UserACLObjectUsersACLUpdateFieldsInput] - roles: [UserACLObjectRolesACLUpdateFieldsInput] -} - -input UserACLObjectUsersACLUpdateFieldsInput { - userId: String - read: Boolean - write: Boolean -} - -input UserACLObjectRolesACLUpdateFieldsInput { - roleId: String - read: Boolean - write: Boolean + acl: UserACLObjectUpdateFieldsInput + createdAt: Date + updatedAt: Date + search: [String] } input UserAuthenticationUpdateFieldsInput { @@ -1500,11 +1592,13 @@ input UserAuthenticationEmailPasswordUpdateFieldsInput { } input UserAuthenticationGoogleUpdateFieldsInput { + providerUserId: String email: Email verifiedEmail: Boolean } input UserAuthenticationGithubUpdateFieldsInput { + providerUserId: String email: Email avatarUrl: String username: String @@ -1521,111 +1615,43 @@ input UserPendingAuthenticationChallengeUpdateFieldsInput { expiresAt: Date } -input UpdateUsersInput { - fields: UserUpdateFieldsInput - where: UserWhereInput - offset: Int - first: Int - order: [UserOrder] -} - -type DeleteUserPayload { - user: User - ok: Boolean -} - -input DeleteUserInput { - id: ID -} - -input DeleteUsersInput { - where: UserWhereInput - order: [UserOrder] -} - -type CreatePostPayload { - post: Post - ok: Boolean -} - -input CreatePostInput { - fields: PostCreateFieldsInput -} - -input CreatePostsInput { - fields: [PostCreateFieldsInput]! - offset: Int - first: Int - order: [PostOrder] -} - -type UpdatePostPayload { - post: Post - ok: Boolean -} - -input UpdatePostInput { - id: ID - fields: PostUpdateFieldsInput -} - -input PostUpdateFieldsInput { - name: String - test2: RoleEnum - test3: UserRelationInput - test4: UserPointerInput - experiences: [PostExperienceUpdateFieldsInput!] - acl: PostACLObjectUpdateFieldsInput - createdAt: Date - updatedAt: Date - search: [String] -} - -input PostExperienceUpdateFieldsInput { - jobTitle: String - companyName: String - startDate: String - endDate: String - achievements: [String] -} - -input PostACLObjectUpdateFieldsInput { - users: [PostACLObjectUsersACLUpdateFieldsInput] - roles: [PostACLObjectRolesACLUpdateFieldsInput] +input UserACLObjectUpdateFieldsInput { + users: [UserACLObjectUsersACLUpdateFieldsInput] + roles: [UserACLObjectRolesACLUpdateFieldsInput] } -input PostACLObjectUsersACLUpdateFieldsInput { +input UserACLObjectUsersACLUpdateFieldsInput { userId: String read: Boolean write: Boolean } -input PostACLObjectRolesACLUpdateFieldsInput { +input UserACLObjectRolesACLUpdateFieldsInput { roleId: String read: Boolean write: Boolean } -input UpdatePostsInput { - fields: PostUpdateFieldsInput - where: PostWhereInput +input UpdateUsersInput { + fields: UserUpdateFieldsInput + where: UserWhereInput offset: Int first: Int - order: [PostOrder] + order: [UserOrder] } -type DeletePostPayload { - post: Post +type DeleteUserPayload { + user: User ok: Boolean } -input DeletePostInput { +input DeleteUserInput { id: ID } -input DeletePostsInput { - where: PostWhereInput - order: [PostOrder] +input DeleteUsersInput { + where: UserWhereInput + order: [UserOrder] } type Create_SessionPayload { @@ -1854,22 +1880,78 @@ input Delete_InternalConfigsInput { order: [_InternalConfigOrder] } -input CreateMutationInput { - name: Int! +type Create_MutexPayload { + _mutex: _Mutex + ok: Boolean +} + +input Create_MutexInput { + fields: _MutexCreateFieldsInput +} + +input Create_MutexesInput { + fields: [_MutexCreateFieldsInput]! + offset: Int + first: Int + order: [_MutexOrder] +} + +type Update_MutexPayload { + _mutex: _Mutex + ok: Boolean +} + +input Update_MutexInput { + id: ID + fields: _MutexUpdateFieldsInput +} + +input _MutexUpdateFieldsInput { + name: String + locked: Boolean + acl: _MutexACLObjectUpdateFieldsInput + createdAt: Date + updatedAt: Date + search: [String] +} + +input _MutexACLObjectUpdateFieldsInput { + users: [_MutexACLObjectUsersACLUpdateFieldsInput] + roles: [_MutexACLObjectRolesACLUpdateFieldsInput] } -input CustomMutationInput { - a: Int! - b: Int! +input _MutexACLObjectUsersACLUpdateFieldsInput { + userId: String + read: Boolean + write: Boolean +} + +input _MutexACLObjectRolesACLUpdateFieldsInput { + roleId: String + read: Boolean + write: Boolean } -input SecondCustomMutationInput { - sum: SecondCustomMutationSumInput +input Update_MutexesInput { + fields: _MutexUpdateFieldsInput + where: _MutexWhereInput + offset: Int + first: Int + order: [_MutexOrder] +} + +type Delete_MutexPayload { + _mutex: _Mutex + ok: Boolean +} + +input Delete_MutexInput { + id: ID } -input SecondCustomMutationSumInput { - a: Int! - b: Int! +input Delete_MutexesInput { + where: _MutexWhereInput + order: [_MutexOrder] } input ResetPasswordInput { diff --git a/packages/wabe/generated/wabe.ts b/packages/wabe/generated/wabe.ts index 05f9e851..e5ca7db4 100644 --- a/packages/wabe/generated/wabe.ts +++ b/packages/wabe/generated/wabe.ts @@ -60,11 +60,13 @@ export type AuthenticationEmailPassword = { } export type AuthenticationGoogle = { + providerUserId?: string email: string verifiedEmail: boolean } export type AuthenticationGithub = { + providerUserId?: string email: string avatarUrl: string username: string @@ -156,6 +158,16 @@ export type _InternalConfig = { search?: Array } +export type _Mutex = { + id: string + name: string + locked: boolean + acl?: ACLObject + createdAt?: string + updatedAt?: string + search?: Array +} + export type WhereUser = { id: string name?: string @@ -223,6 +235,16 @@ export type Where_InternalConfig = { search?: Array } +export type Where_Mutex = { + id: string + name: string + locked: boolean + acl?: ACLObject + createdAt?: Date + updatedAt?: Date + search?: Array +} + export type CreateMutationInput = { name: number } @@ -424,6 +446,7 @@ export type WabeSchemaTypes = { _Session: _Session Role: Role _InternalConfig: _InternalConfig + _Mutex: _Mutex } export type WabeSchemaWhereTypes = { @@ -432,4 +455,5 @@ export type WabeSchemaWhereTypes = { _Session: Where_Session Role: WhereRole _InternalConfig: Where_InternalConfig + _Mutex: Where_Mutex } diff --git a/packages/wabe/src/authentication/Session.test.ts b/packages/wabe/src/authentication/Session.test.ts index 9eddd89e..3925c0a8 100644 --- a/packages/wabe/src/authentication/Session.test.ts +++ b/packages/wabe/src/authentication/Session.test.ts @@ -20,6 +20,9 @@ describe('Session', () => { const mockDeleteObject = mock(() => Promise.resolve()) as any const mockUpdateObject = mock(() => Promise.resolve()) as any const mockUpdateObjects = mock(() => Promise.resolve([{ id: 'sessionId' }])) as any + const mockLockMutex = mock(() => Promise.resolve(true)) as any + const mockUnlockMutex = mock(() => Promise.resolve(true)) as any + const mockGetMutexStatus = mock(() => Promise.resolve(false)) as any const controllers = { database: { @@ -30,6 +33,11 @@ describe('Session', () => { updateObject: mockUpdateObject, updateObjects: mockUpdateObjects, }, + mutex: { + lockMutex: mockLockMutex, + unlockMutex: mockUnlockMutex, + getMutexStatus: mockGetMutexStatus, + }, } beforeEach(() => { @@ -39,6 +47,9 @@ describe('Session', () => { mockDeleteObject.mockClear() mockUpdateObject.mockClear() mockUpdateObjects.mockClear() + mockLockMutex.mockClear() + mockUnlockMutex.mockClear() + mockGetMutexStatus.mockClear() }) const context = { @@ -519,6 +530,9 @@ describe('Session', () => { first: 1, }) + expect(mockLockMutex).toHaveBeenCalledTimes(1) + expect(mockUnlockMutex).toHaveBeenCalledTimes(1) + const accessTokenExpiresAt = mockUpdateObjects.mock.calls[0][0].data .accessTokenExpiresAt as Date @@ -708,5 +722,8 @@ describe('Session', () => { await expect(session.refresh(accessToken, refreshToken, context)).rejects.toThrow( 'Invalid refresh token', ) + + expect(mockLockMutex).toHaveBeenCalledTimes(1) + expect(mockUnlockMutex).toHaveBeenCalledTimes(1) }) }) diff --git a/packages/wabe/src/authentication/Session.ts b/packages/wabe/src/authentication/Session.ts index 52359538..53254122 100644 --- a/packages/wabe/src/authentication/Session.ts +++ b/packages/wabe/src/authentication/Session.ts @@ -46,25 +46,6 @@ const getJwtVerifyOptions = (context: WabeContext) => { export class Session { private accessToken: string | undefined = undefined private refreshToken: string | undefined = undefined - private static refreshLocks = new Map>() - - private async acquireRefreshLock(lockKey: string) { - while (Session.refreshLocks.has(lockKey)) { - const existingLock = Session.refreshLocks.get(lockKey) - if (existingLock) await existingLock - } - - let release = () => {} - const lockPromise = new Promise((resolve) => { - release = resolve - }) - Session.refreshLocks.set(lockKey, lockPromise) - - return () => { - release() - if (Session.refreshLocks.get(lockKey) === lockPromise) Session.refreshLocks.delete(lockKey) - } - } getAccessTokenExpireAt(config: WabeConfig) { const customExpiresInMs = config?.authentication?.session?.accessTokenExpiresInMs @@ -390,9 +371,9 @@ export class Session { refreshToken, getTokenEncryptionKey(context), ) - const releaseRefreshLock = await this.acquireRefreshLock( - `${accessTokenEncrypted}:${incomingRefreshTokenEncrypted}`, - ) + const lockKey = `${accessTokenEncrypted}:${incomingRefreshTokenEncrypted}` + const didAcquireLock = await context.wabe.controllers.mutex.lockMutex(lockKey) + if (!didAcquireLock) throw new Error('Unable to acquire refresh lock') try { const session = await context.wabe.controllers.database.getObjects({ @@ -548,7 +529,7 @@ export class Session { refreshToken: newRefreshToken, } } finally { - releaseRefreshLock() + await context.wabe.controllers.mutex.unlockMutex(lockKey) } } diff --git a/packages/wabe/src/database/DatabaseController.ts b/packages/wabe/src/database/DatabaseController.ts index 9e6d077d..f9fbf27d 100644 --- a/packages/wabe/src/database/DatabaseController.ts +++ b/packages/wabe/src/database/DatabaseController.ts @@ -8,6 +8,7 @@ import { isUnsafeObjectKey } from '../utils/objectKeys' import { contextWithRoot, notEmpty } from '../utils/export' import type { DevWabeTypes } from '../utils/helper' import { + type CompareAndSetMutexOptions, type CountOptions, type CreateObjectOptions, type CreateObjectsOptions, @@ -1001,6 +1002,23 @@ export class DatabaseController { await this.adapter.clearDatabase() } + async compareAndSetMutex({ + name, + requiredLockedState, + newLocked, + context, + }: CompareAndSetMutexOptions): Promise { + const normalizedName = name.trim() + if (!normalizedName) throw new Error('Mutex name cannot be empty') + + return this.adapter.compareAndSetMutex({ + name: normalizedName, + requiredLockedState, + newLocked, + context: contextWithRoot(context), + }) + } + async getObject({ select, className, diff --git a/packages/wabe/src/database/interface.ts b/packages/wabe/src/database/interface.ts index 68386862..5190a49d 100644 --- a/packages/wabe/src/database/interface.ts +++ b/packages/wabe/src/database/interface.ts @@ -285,6 +285,13 @@ export interface DeleteObjectsOptions< select?: SelectType } +export interface CompareAndSetMutexOptions { + name: string + requiredLockedState: boolean + newLocked: boolean + context: WabeContext +} + export interface DatabaseAdapter { close(): Promise @@ -348,4 +355,6 @@ export interface DatabaseAdapter { >( params: DeleteObjectsOptions, ): Promise + + compareAndSetMutex(params: CompareAndSetMutexOptions): Promise } diff --git a/packages/wabe/src/index.ts b/packages/wabe/src/index.ts index c0bb4f20..8af7580a 100644 --- a/packages/wabe/src/index.ts +++ b/packages/wabe/src/index.ts @@ -5,5 +5,6 @@ export * from './database' export * from './authentication' export * from './file' export * from './email' +export * from './mutex' export * from './utils/export' export * from './cron' diff --git a/packages/wabe/src/mutex/MutexController.test.ts b/packages/wabe/src/mutex/MutexController.test.ts new file mode 100644 index 00000000..2f928a67 --- /dev/null +++ b/packages/wabe/src/mutex/MutexController.test.ts @@ -0,0 +1,50 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test' +import type { Wabe } from '../server' +import type { DevWabeTypes } from '../utils/helper' +import { closeTests, setupTests } from '../utils/testHelper' + +describe('MutexController', () => { + let wabe: Wabe + + beforeAll(async () => { + const setup = await setupTests() + wabe = setup.wabe + }) + + afterAll(async () => { + await closeTests(wabe) + }) + + beforeEach(async () => { + await wabe.controllers.database.clearDatabase() + }) + + it('should lock and unlock a mutex atomically', async () => { + const mutexName = 'refresh:user-1' + + expect(await wabe.controllers.mutex.getMutexStatus(mutexName)).toBe(false) + expect(await wabe.controllers.mutex.lockMutex(mutexName)).toBe(true) + expect(await wabe.controllers.mutex.getMutexStatus(mutexName)).toBe(true) + expect(await wabe.controllers.mutex.lockMutex(mutexName)).toBe(false) + expect(await wabe.controllers.mutex.unlockMutex(mutexName)).toBe(true) + expect(await wabe.controllers.mutex.getMutexStatus(mutexName)).toBe(false) + expect(await wabe.controllers.mutex.unlockMutex(mutexName)).toBe(false) + }) + + it('should allow only one concurrent lock acquisition', async () => { + const mutexName = 'refresh:user-concurrent' + const results = await Promise.all( + Array.from({ length: 10 }, () => wabe.controllers.mutex.lockMutex(mutexName)), + ) + + expect(results.filter(Boolean).length).toBe(1) + }) + + it('should allow re-lock after unlock', async () => { + const mutexName = 'refresh:user-relock' + + expect(await wabe.controllers.mutex.lockMutex(mutexName)).toBe(true) + expect(await wabe.controllers.mutex.unlockMutex(mutexName)).toBe(true) + expect(await wabe.controllers.mutex.lockMutex(mutexName)).toBe(true) + }) +}) diff --git a/packages/wabe/src/mutex/MutexController.ts b/packages/wabe/src/mutex/MutexController.ts new file mode 100644 index 00000000..a07e0b75 --- /dev/null +++ b/packages/wabe/src/mutex/MutexController.ts @@ -0,0 +1,53 @@ +import type { DatabaseController } from '../database' +import type { WabeContext } from '../server/interface' +import type { Wabe, WabeTypes } from '../server' +import { contextWithRoot } from '../utils/export' +import { DevWabeTypes } from 'src/utils/helper' + +export class MutexController { + private databaseController: DatabaseController + private rootContext: WabeContext + + constructor(databaseController: DatabaseController, wabe: Wabe) { + this.databaseController = databaseController + this.rootContext = contextWithRoot({ + wabe, + } as WabeContext) + } + + async lockMutex(name: string): Promise { + return this.databaseController.compareAndSetMutex({ + name, + requiredLockedState: false, + newLocked: true, + context: this.rootContext, + }) + } + + async unlockMutex(name: string): Promise { + return this.databaseController.compareAndSetMutex({ + name, + requiredLockedState: true, + newLocked: false, + context: this.rootContext, + }) + } + + async getMutexStatus(name: string): Promise { + const mutexes = await this.databaseController.getObjects({ + className: '_Mutex', + // @ts-expect-error + where: { + name: { equalTo: name.trim() }, + }, + select: { + // @ts-expect-error + locked: true, + }, + context: this.rootContext, + first: 1, + }) + + return !!mutexes[0]?.locked + } +} diff --git a/packages/wabe/src/mutex/index.ts b/packages/wabe/src/mutex/index.ts new file mode 100644 index 00000000..45082471 --- /dev/null +++ b/packages/wabe/src/mutex/index.ts @@ -0,0 +1 @@ +export * from './MutexController' diff --git a/packages/wabe/src/schema/Schema.test.ts b/packages/wabe/src/schema/Schema.test.ts index 99dfac3c..e459df11 100644 --- a/packages/wabe/src/schema/Schema.test.ts +++ b/packages/wabe/src/schema/Schema.test.ts @@ -479,4 +479,55 @@ describe('Schema', () => { }, }) }) + + it('should add _Mutex as a root-only internal class', () => { + const schema = new Schema({ + schema: { + classes: [], + }, + } as any) + + const mutexClass = schema.schema?.classes?.find((schemaClass) => schemaClass.name === '_Mutex') + + expect(mutexClass).toEqual( + expect.objectContaining({ + name: '_Mutex', + fields: expect.objectContaining({ + name: expect.objectContaining({ + type: 'String', + required: true, + }), + locked: expect.objectContaining({ + type: 'Boolean', + required: true, + }), + }), + indexes: [ + { + field: 'name', + order: 'ASC', + unique: true, + }, + ], + permissions: { + create: { + authorizedRoles: [], + requireAuthentication: true, + }, + read: { + authorizedRoles: [], + requireAuthentication: true, + }, + update: { + authorizedRoles: [], + requireAuthentication: true, + }, + delete: { + authorizedRoles: [], + requireAuthentication: true, + }, + }, + }), + ) + }) }) diff --git a/packages/wabe/src/schema/Schema.ts b/packages/wabe/src/schema/Schema.ts index 8068380c..55d668da 100644 --- a/packages/wabe/src/schema/Schema.ts +++ b/packages/wabe/src/schema/Schema.ts @@ -605,6 +605,48 @@ export class Schema { } } + mutexClass(): ClassInterface { + return { + name: '_Mutex', + fields: { + name: { + type: 'String', + required: true, + }, + locked: { + type: 'Boolean', + required: true, + }, + }, + indexes: [ + { + field: 'name', + order: 'ASC', + unique: true, + }, + ], + // Only root key + permissions: { + create: { + authorizedRoles: [], + requireAuthentication: true, + }, + read: { + authorizedRoles: [], + requireAuthentication: true, + }, + update: { + authorizedRoles: [], + requireAuthentication: true, + }, + delete: { + authorizedRoles: [], + requireAuthentication: true, + }, + }, + } + } + userClass(): ClassInterface { const customAuthenticationConfig = this.config.authentication?.customAuthenticationMethods || [] @@ -843,6 +885,7 @@ export class Schema { this.sessionClass(), this.roleClass(), this.internalConfigClass(), + this.mutexClass(), ]) } } diff --git a/packages/wabe/src/server/generateCodegen.ts b/packages/wabe/src/server/generateCodegen.ts new file mode 100644 index 00000000..21cb0962 --- /dev/null +++ b/packages/wabe/src/server/generateCodegen.ts @@ -0,0 +1,428 @@ +import { type GraphQLSchema, parse, print, printSchema } from 'graphql' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import type { + ClassInterface, + EnumInterface, + MutationResolver, + QueryResolver, + ScalarInterface, + SchemaInterface, + TypeField, + TypeResolver, + WabeObject, + WabePrimaryTypes, +} from '../schema' +import { firstLetterInUpperCase } from '../utils' + +const firstLetterUpperCase = (str: string): string => str.charAt(0).toUpperCase() + str.slice(1) + +const wabePrimaryTypesToTypescriptTypes: Record, string> = { + Boolean: 'boolean', + Int: 'number', + Float: 'number', + String: 'string', + Any: 'any', + Email: 'string', + Phone: 'string', + Date: 'Date', + Hash: 'string', +} + +const inlineObjectTypeMemberSep = '; ' + +const getFileOutputTypeString = () => + `{ name: string${inlineObjectTypeMemberSep}url?: string${inlineObjectTypeMemberSep}urlGeneratedAt?: string${inlineObjectTypeMemberSep}isPresignedUrl: boolean }` + +const getFileInputTypeString = () => + `{ file: File${inlineObjectTypeMemberSep}url?: never${inlineObjectTypeMemberSep}name?: never } | { file?: never${inlineObjectTypeMemberSep}url: string${inlineObjectTypeMemberSep}name: string }` + +const getFileTypeString = (isInput = false) => + isInput ? getFileInputTypeString() : getFileOutputTypeString() + +const wabeTypesToTypescriptTypes = ({ + field, + isInput = false, +}: { + field: TypeField + isInput?: boolean +}) => { + const typedField = field as TypeField & { + typeValue?: string + object?: { name: string; fields: Record> } + class?: string + } + + switch (field.type) { + case 'Date': + return isInput ? 'Date' : 'string' + case 'File': + return getFileTypeString(isInput) + case 'Boolean': + case 'Int': + case 'Float': + case 'String': + case 'Any': + case 'Email': + case 'Phone': + case 'Hash': + return wabePrimaryTypesToTypescriptTypes[ + field.type as keyof typeof wabePrimaryTypesToTypescriptTypes + ] + case 'Array': + if (typedField.typeValue === 'Object' && typedField.object) + return `Array<${typedField.object.name}>` + if (typedField.typeValue === 'File') return `Array<${getFileTypeString(isInput)}>` + return `Array<${wabePrimaryTypesToTypescriptTypes[typedField.typeValue as keyof typeof wabePrimaryTypesToTypescriptTypes]}>` + case 'Pointer': + return typedField.class || 'any' + case 'Relation': + return `Array<${typedField.class || 'any'}>` + case 'Object': + return typedField.object?.name || 'any' + default: + return field.type + } +} + +const fieldKey = (name: string, required?: boolean) => `${name}${required ? '' : 'undefined'}` + +const generateWabeObject = ({ + object, + isInput = false, + prefix = '', +}: { + object: WabeObject + prefix?: string + isInput?: boolean +}): Record> => { + const objectNameWithPrefix = `${prefix}${firstLetterUpperCase(object.name)}` + + return Object.entries(object.fields).reduce( + (acc, [fieldName, field]) => { + const typedField = field as TypeField & { + typeValue?: string + object?: WabeObject + } + const type = wabeTypesToTypescriptTypes({ field, isInput }) + + if ( + field.type === 'Object' || + (field.type === 'Array' && typedField.typeValue === 'Object' && typedField.object) + ) { + const subObject = generateWabeObject({ + object: typedField.object as WabeObject, + isInput, + prefix: objectNameWithPrefix, + }) + + const isArray = field.type === 'Array' + const subTypeName = `${objectNameWithPrefix}${firstLetterUpperCase( + typedField.object?.name || 'Object', + )}` + + return { + ...acc, + ...subObject, + [objectNameWithPrefix]: { + ...acc[objectNameWithPrefix], + [fieldKey(fieldName, field.required)]: isArray ? `Array<${subTypeName}>` : subTypeName, + }, + } + } + + return { + ...acc, + [objectNameWithPrefix]: { + ...acc[objectNameWithPrefix], + [fieldKey(fieldName, field.required)]: `${type}`, + }, + } + }, + {} as Record>, + ) +} + +const mergeNestedStringRecords = (records: Array>>) => + Object.assign({} as Record>, ...records) + +const generateClassTypes = (classes: ClassInterface[], namePrefix = '', isInput = false) => + classes.reduce( + (acc, { name, fields }) => { + const objectsToLoad: Array>> = [] + + const currentClass = Object.entries(fields).reduce( + (acc2, [fieldName, field]) => { + const typedField = field as TypeField & { + typeValue?: string + object?: WabeObject + } + const type = wabeTypesToTypescriptTypes({ field, isInput }) + + if ( + field.type === 'Object' || + (field.type === 'Array' && typedField.typeValue === 'Object' && typedField.object) + ) { + objectsToLoad.push( + generateWabeObject({ + object: typedField.object as WabeObject, + isInput, + }), + ) + } + + return { + ...acc2, + [fieldKey(fieldName, field.required)]: type, + } + }, + {} as Record, + ) + + const completeName = namePrefix ? `${namePrefix}${firstLetterUpperCase(name)}` : name + + return { + ...acc, + ...mergeNestedStringRecords(objectsToLoad), + [completeName]: { id: 'string', ...currentClass }, + } + }, + {} as Record>, + ) + +const generateWabeEnumTypes = (enums: EnumInterface[]) => + enums.reduce( + (acc, { name, values }) => ({ ...acc, [name]: values }), + {} as Record>, + ) + +const generateWabeScalarTypes = (scalars: ScalarInterface[]) => + scalars.reduce((acc, { name }) => ({ ...acc, [name]: 'string' }), {} as Record) + +const generateWabeMutationOrQueryInput = ( + mutationOrQueryName: string, + resolver: MutationResolver | QueryResolver, + isMutation: boolean, +) => { + const objectsToLoad: Array>> = [] + const upperName = firstLetterUpperCase(mutationOrQueryName) + + const resolvedArgs = Object.entries( + (isMutation ? resolver.args?.input : resolver.args) || {}, + ).reduce( + (acc, [name, field]) => { + if (field.type === 'Object') { + const typeName = firstLetterInUpperCase(name) + objectsToLoad.push( + generateWabeObject({ + object: { ...field.object, name: typeName }, + prefix: upperName, + }), + ) + return { + ...acc, + [fieldKey(name, field.required)]: `${upperName}${typeName}`, + } + } + + return { + ...acc, + [fieldKey(name, field.required)]: wabeTypesToTypescriptTypes({ + field, + isInput: true, + }), + } + }, + {} as Record, + ) + + const prettyName = firstLetterInUpperCase(mutationOrQueryName) + + return { + ...(isMutation ? { [`${prettyName}Input`]: resolvedArgs } : {}), + [`${isMutation ? 'Mutation' : 'Query'}${prettyName}Args`]: isMutation + ? { input: `${prettyName}Input` } + : resolvedArgs, + ...mergeNestedStringRecords(objectsToLoad), + } +} + +const generateWabeMutationsAndQueriesTypes = (resolver: TypeResolver) => ({ + ...Object.entries(resolver.mutations || {}).reduce( + (acc, [name, mutation]) => ({ + ...acc, + ...generateWabeMutationOrQueryInput(name, mutation, true), + }), + {}, + ), + ...Object.entries(resolver.queries || {}).reduce( + (acc, [name, query]) => ({ + ...acc, + ...generateWabeMutationOrQueryInput(name, query, false), + }), + {}, + ), +}) + +const wabeClassRecordToString = (wabeClass: Record>) => + Object.entries(wabeClass).reduce((acc, [className, fields]) => { + if (Object.keys(fields).length === 0) return `${acc}export type ${className} = {}\n\n` + + const body = Object.entries(fields) + .map(([name, type]) => `\t${name.replace('undefined', '?')}: ${type}`) + .join('\n') + + return `${acc}export type ${className} = {\n${body}\n}\n\n` + }, '') + +const wabeEnumRecordToString = (wabeEnum: Record>) => + Object.entries(wabeEnum).reduce((acc, [enumName, values]) => { + const hasValues = Object.keys(values).length > 0 + const body = Object.entries(values) + .map(([k, v]) => `\t${k} = '${v}'`) + .join(',\n') + + return `${acc}export enum ${enumName} {\n${body}${hasValues ? ',' : ''}\n}\n\n` + }, '') + +const wabeScalarRecordToString = (wabeScalar: Record) => + Object.entries(wabeScalar).reduce( + (acc, [name, type]) => `${acc}export type ${name} = ${type}\n\n`, + '', + ) + +const wrapLongGraphqlFieldArguments = ({ + content, + indent, + printWidth, +}: { + content: string + indent: string + printWidth: number +}) => + content + .split('\n') + .map((line) => { + if (line.length <= printWidth) return line + + const match = line.match(/^(\s*)([_A-Za-z][_0-9A-Za-z]*)\((.+)\):\s*(.+)$/) + if (!match) return line + + const [, fieldIndent = '', fieldName = '', argsString = '', returnType = ''] = match + if (!fieldName || !argsString || !returnType) return line + + const args = argsString + .split(',') + .map((arg) => arg.trim()) + .filter((arg) => arg.length > 0) + + if (args.length <= 1) return line + + const wrappedArgs = args.map((arg) => `${fieldIndent}${indent}${arg}`).join('\n') + return `${fieldIndent}${fieldName}(\n${wrappedArgs}\n${fieldIndent}): ${returnType}` + }) + .join('\n') + +const generateWabeDevTypes = ({ + scalars, + enums, + classes, +}: { + enums?: EnumInterface[] + scalars?: ScalarInterface[] + classes: ClassInterface[] +}) => { + const wabeScalarType = + scalars && scalars.length > 0 + ? `export type WabeSchemaScalars = ${scalars.map((s) => `'${s.name}'`).join(' | ')}` + : `export type WabeSchemaScalars = ''` + + const buildTypeMap = (typeName: string, entries: string[]) => + entries.length > 0 ? `export type ${typeName} = {\n\t${entries.join('\n\t')}\n}` : '' + + const wabeEnumsString = buildTypeMap( + 'WabeSchemaEnums', + enums?.map((e) => `${e.name}: ${e.name}`) ?? [], + ) + + const wabeTypesString = buildTypeMap( + 'WabeSchemaTypes', + classes.map((c) => `${c.name}: ${c.name}`), + ) + + const wabeWhereString = buildTypeMap( + 'WabeSchemaWhereTypes', + classes.map((c) => `${c.name}: Where${firstLetterUpperCase(c.name)}`), + ) + + return `${wabeScalarType}\n\n${wabeEnumsString}\n\n${wabeTypesString}\n\n${wabeWhereString}` +} + +const normalizeGraphqlSchemaForComparison = (schemaContent: string) => { + try { + return print(parse(schemaContent)) + } catch { + return schemaContent + } +} + +export const generateCodegen = async ({ + schema, + path, + graphqlSchema, +}: { + schema: SchemaInterface + path: string + graphqlSchema: GraphQLSchema +}) => { + await mkdir(path, { recursive: true }) + + let graphqlSchemaContent = printSchema(graphqlSchema) + const indentStr = '\t' + const printWidth = 100 + + graphqlSchemaContent = graphqlSchemaContent.replaceAll(' ', indentStr) + graphqlSchemaContent = graphqlSchemaContent.replace( + /(^[ \t]*)"""([^\n"]+)"""(?=\n)/gm, + (_, indentation: string, description: string) => + `${indentation}"""\n${indentation}${description}\n${indentation}"""`, + ) + graphqlSchemaContent = wrapLongGraphqlFieldArguments({ + content: graphqlSchemaContent, + indent: indentStr, + printWidth, + }) + if (!graphqlSchemaContent.endsWith('\n')) graphqlSchemaContent += '\n' + + const classes = schema.classes ?? [] + const resolvers = schema.resolvers ?? {} + const enums = schema.enums ?? [] + const scalars = schema.scalars ?? [] + + const wabeClasses = generateClassTypes(classes) + const wabeWhereTypes = generateClassTypes(classes, 'Where', true) + const mutationsAndQueries = generateWabeMutationsAndQueriesTypes(resolvers) + + const wabeEnumsInString = wabeEnumRecordToString(generateWabeEnumTypes(enums)) + const wabeScalarsInString = wabeScalarRecordToString(generateWabeScalarTypes(scalars)) + const wabeObjectsInString = wabeClassRecordToString({ + ...wabeClasses, + ...wabeWhereTypes, + ...mutationsAndQueries, + }) + + const wabeDevTypes = generateWabeDevTypes({ scalars, enums, classes }) + const wabeTsContent = `${wabeEnumsInString}${wabeScalarsInString}${wabeObjectsInString}${wabeDevTypes}\n` + + let shouldWriteGraphqlSchema = true + try { + const contentOfGraphqlSchema = (await readFile(`${path}/schema.graphql`)).toString() + const schemasAreEqual = + contentOfGraphqlSchema === graphqlSchemaContent || + normalizeGraphqlSchemaForComparison(contentOfGraphqlSchema) === + normalizeGraphqlSchemaForComparison(graphqlSchemaContent) + shouldWriteGraphqlSchema = !schemasAreEqual + } catch {} + + await writeFile(`${path}/wabe.ts`, wabeTsContent) + if (shouldWriteGraphqlSchema) await writeFile(`${path}/schema.graphql`, graphqlSchemaContent) +} diff --git a/packages/wabe/src/server/index.ts b/packages/wabe/src/server/index.ts index f0e1aab8..8403721f 100644 --- a/packages/wabe/src/server/index.ts +++ b/packages/wabe/src/server/index.ts @@ -14,10 +14,12 @@ import { initializeRoles } from '../authentication/roles' import type { EmailConfig } from '../email' import { EmailController } from '../email/EmailController' import { FileController } from '../file/FileController' +import { MutexController } from '../mutex/MutexController' import { defaultSessionHandler } from './defaultSessionHandler' import type { CronConfig } from '../cron' import type { FileConfig } from '../file' import { WobeGraphqlYogaPlugin } from 'wobe-graphql-yoga' +import { generateCodegen } from './generateCodegen' type SecurityConfig = { corsOptions?: CorsOptions @@ -84,6 +86,7 @@ export type WobeCustomContext = Context & { type WabeControllers = { database: DatabaseController + mutex: MutexController email?: EmailController file?: FileController } @@ -134,8 +137,11 @@ export class Wabe { context.res.send('OK') }) + const databaseController = new DatabaseController(database.adapter) + this.controllers = { - database: new DatabaseController(database.adapter), + database: databaseController, + mutex: new MutexController(databaseController, this), email: email?.adapter ? new EmailController(email.adapter) : undefined, file: file?.adapter ? new FileController(file.adapter, this) : undefined, } @@ -264,6 +270,12 @@ export class Wabe { this.config.codegen.enabled && this.config.codegen.path.length > 0 ) { + await generateCodegen({ + path: this.config.codegen.path, + schema: wabeSchema.schema, + graphqlSchema: this.config.graphqlSchema, + }) + await this.config.onGenerateCodegen?.({ path: this.config.codegen.path, schema: wabeSchema.schema,