Skip to content

Decorators

Mantle uses TC39 Stage 3 decorators to declare database and AWS service permissions on entity query classes -- not on handler functions, which get observability automatically via define*Handler.

Where decorators apply

DecoratorTargetPackagePurpose
@RequiresTableStatic methods@mantleframework/databasePer-method table permissions
@RequiresDatabaseClasses@mantleframework/coreClass-level database permissions
@RequiresS3Static methods@mantleframework/awsS3 operation permissions
@RequiresSQSStatic methods@mantleframework/awsSQS operation permissions
@RequiresEventBridgeStatic methods@mantleframework/awsEventBridge permissions
@RequiresLambdaStatic methods@mantleframework/awsLambda invoke permissions

@RequiresTable

Declare which tables and operations a query method requires. mantle generate permissions reads these declarations to produce least-privilege PostgreSQL roles.

The decorator accepts an array of TablePermission objects:

typescript
import { DatabaseOperation, RequiresTable, withQueryMetrics, getDrizzleClient } from '@mantleframework/database'
import { eq } from '@mantleframework/database/orm'
import { items } from '../schema'

class ItemQueries {
  @RequiresTable([{ table: 'items', operations: [DatabaseOperation.Select] }])
  static async getById(id: string) {
    return withQueryMetrics('Items.getById', async () => {
      const db = await getDrizzleClient()
      const result = await db.select().from(items).where(eq(items.id, id))
      return result[0] ?? null
    })
  }

  @RequiresTable([{ table: 'items', operations: [DatabaseOperation.Select, DatabaseOperation.Insert] }])
  static async create(data: NewItem) {
    return withQueryMetrics('Items.create', async () => {
      const db = await getDrizzleClient()
      const [item] = await db.insert(items).values(data).returning()
      return item!
    })
  }

  @RequiresTable([{ table: 'items', operations: [DatabaseOperation.Select, DatabaseOperation.Update] }])
  static async updateName(id: string, name: string) {
    return withQueryMetrics('Items.updateName', async () => {
      const db = await getDrizzleClient()
      const [updated] = await db.update(items).set({ name }).where(eq(items.id, id)).returning()
      return updated!
    })
  }

  @RequiresTable([{ table: 'items', operations: [DatabaseOperation.Delete] }])
  static async deleteById(id: string) {
    return withQueryMetrics('Items.deleteById', async () => {
      const db = await getDrizzleClient()
      await db.delete(items).where(eq(items.id, id))
    })
  }
}

Key rules:

  • @RequiresTable is a method decorator -- Stage 3, uses WeakMap internally
  • Static methods only -- entity query classes are never instantiated
  • Always wrap the body in withQueryMetrics() for X-Ray tracing and CloudWatch metrics
  • INSERT and UPDATE with .returning() require DatabaseOperation.Select too (DSQL needs SELECT for RETURNING clauses)

Multi-table permissions

A single method can declare permissions on multiple tables:

typescript
@RequiresTable([
  { table: 'files', operations: [DatabaseOperation.Select] },
  { table: 'file_downloads', operations: [DatabaseOperation.Select, DatabaseOperation.Insert] },
])
static getFileWithDownload(fileId: string) { ... }

DatabaseOperation enum

typescript
enum DatabaseOperation {
  Select = 'SELECT',
  Insert = 'INSERT',
  Update = 'UPDATE',
  Delete = 'DELETE',
  All = 'ALL',
}

TablePermission interface

typescript
interface TablePermission {
  table: string                  // Unquoted PostgreSQL table name
  operations: DatabaseOperation[] // SQL operations required on this table
}

@RequiresDatabase

A class decorator (Stage 3, uses Symbol + Object.defineProperty) for declaring class-level database access. Applied to entity query classes that need database initialization.

typescript
import { RequiresDatabase } from '@mantleframework/core'

@RequiresDatabase
class UserQueries {
  // ...
}

Permission extraction

bash
mantle generate permissions

Scans the codebase with ts-morph AST analysis:

  1. Finds @RequiresTable decorators on entity query methods
  2. Finds @RequiresS3, @RequiresSQS, etc. on AWS vendor wrappers
  3. Traces which entity queries each Lambda imports

Produces per-Lambda PostgreSQL roles with least-privilege grants:

hcl
# permissions/generated_dsql_permissions.tf
resource "aws_dsql_cluster_role" "lambda_list_items" {
  cluster_id = module.database.cluster_id
  role_name  = "lambda_list_items"
  grants = [
    { table = "items", operations = ["SELECT"] }
  ]
}

Apply the generated SQL to Aurora DSQL:

bash
mantle db apply-permissions

Stage 3 decorator syntax

All Mantle decorators use TC39 Stage 3 syntax. Never set experimentalDecorators: true in tsconfig.json.

The any constraint rule

Method decorator targets must use any, not unknown:

typescript
// Correct
<T extends (...args: any[]) => any>(target: T, context: ClassMethodDecoratorContext)

// Wrong -- causes "Unable to resolve signature of method decorator"
<T extends (...args: unknown[]) => unknown>(target: T, context: ClassMethodDecoratorContext)

TypeScript cannot match concrete method signatures against unknown constraints.

Handler decorators (not needed)

@Traced, @LogMetrics, and @InjectContext exist but are built into every define*Handler call via withObservability(). Do not apply them to handler code.

Next steps