brain/wiki/engineering/server-module-anatomy.md
What a server module looks like in packages/server/api/src/app/. The canonical reference is the tables/ module — when this page and that module disagree, the module wins.
A module is six files in one folder: entity, migration, repository, service, controller, module registration. Build them in that order; each depends on the one before.
Zod schemas + z.infer types go in packages/core/shared/src/lib/{domain}/, exported from the src/index.ts barrel. Bump packages/core/shared/package.json — patch for a fix, minor for a new export. Check whether the branch already bumped it.
EntitySchema, never decorators. See tables/table/table.entity.ts.
...BaseColumnSchemaPart for id / created / updatedApIdSchema for foreign keys — { ...ApIdSchema, nullable: false }projectId column + relation to project, CASCADE deleteforeignKeyConstraintName on every join column{ type: String, array: true, nullable: false }Then register it in getEntities() in database/database-connection.ts. TypeORM does not auto-discover; skipping this fails silently at runtime.
Update the entity first — the generator diffs entity state against the database. Then from packages/server/api/:
npm run db-migration -- src/app/database/migration/postgres/MigrationName
Patch the generated file — the CLI emits TypeORM's MigrationInterface, which this repo does not use:
import { QueryRunner } from 'typeorm'
import { Migration } from '../../migration'
export class AddMyColumn1234567890 implements Migration {
name = 'AddMyColumn1234567890'
breaking = false
release = '0.78.0'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "project" ADD COLUMN "description" text`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "project" DROP COLUMN "description"`)
}
}
breaking, release, and a down() that actually reverses up() are all mandatory — CI rejects the migration without them. release is the upcoming version from the root package.json. Register the class at the end of getMigrations() in database/postgres-connection.ts, chronologically.
Full procedure: the Database Migrations Playbook.
const myRepo = repoFactory(MyEntity) — called as myRepo(), or myRepo(entityManager) inside a transaction.
Factory (log: FastifyBaseLogger) => ({ ... }) when it logs, a plain object otherwise. See tables/table/table.service.ts. Mutations that fire events or webhooks put those in a separate *-side-effects.ts and call it explicitly after the mutation.
FastifyPluginAsyncZod. Route configs are declared after the controller, not inline:
export const myController: FastifyPluginAsyncZod = async (fastify) => {
fastify.post('/', CreateRequest, async (request) => {
return myService(request.log).create({
projectId: request.projectId,
request: request.body,
})
})
}
const CreateRequest = {
config: {
security: securityAccess.project(
[PrincipalType.USER, PrincipalType.ENGINE, PrincipalType.SERVICE],
Permission.WRITE_MY_FEATURE,
{ type: ProjectResourceType.BODY },
),
},
schema: {
tags: ['my-feature'],
body: CreateMyFeatureRequest,
response: { [StatusCodes.CREATED]: MyFeature },
},
}
POST for every create and update, DELETE for deletes — never PUT/PATCH. Every route needs a securityAccess:
| Helper | Scope |
|---|---|
securityAccess.project(principals, permission, { type }) | project-scoped, RBAC-checked |
securityAccess.platformAdminOnly(principals) | platform admins |
securityAccess.publicPlatform(principals) | any platform member |
securityAccess.public() | no auth |
A new capability needs a new value in the Permission enum in @activepieces/shared.
export const myModule: FastifyPluginAsyncZod = async (app) => {
app.addHook('preSerialization', entitiesMustBeOwnedByCurrentProject)
await app.register(myController, { prefix: '/v1/my-features' })
}
Register in app.ts, in the CE or EE section. EE-only modules live under src/app/ee/ and gate with platformMustHaveFeatureEnabled((p) => p.plan.myFlag). To extend CE behaviour from EE, use hooksFactory.create<T>(ceDefault) in CE and .set(eeImpl) in the app.ts edition switch — never import src/app/ee/ from CE code.
Queued work: add to SystemJobName or WorkerJobType in shared, register the handler via systemJobHandlers.registerJobHandler() in app.ts.
packages/server/api/test/integration/ce/{feature}.test.ts, using setupTestEnvironment() + createTestContext(app) → ctx.post() / ctx.get(). The DB is cleaned between tests.
Verify with npm run lint-dev and npm run test-api.
getEntities() and getMigrations() are both manual. Nothing is auto-discovered. A missing entity registration fails silently at runtime; a missing migration registration means the migration simply never runs.MigrationInterface to this repo's Migration, or CI rejects it. Never hand-write the SQL instead — generate from the entity diff, then patch.CONCURRENTLY breaks it. Guard on system.get(AppSystemProp.DB_TYPE) === DatabaseType.PGLITE and issue a plain CREATE INDEX on that branch. When you do use CONCURRENTLY, set transaction = false on the migration class — PostgreSQL requires it outside a transaction.breaking = true is the rollback-safety flag, not the customer-facing one. It marks destructive DDL (DROP TABLE/DROP COLUMN, ADD ... NOT NULL without a default) for rollback-migrations.ts. It does not by itself mean the PR needs the ⛓️💥 breaking-change label — decide that from upgrade impact on self-hosters and API consumers.permission: undefined on securityAccess.project(...) silently allows any project member. The argument is required in practice even though the type tolerates omitting it.projectId or platformId. For connections with multi-project access, use ArrayContains([projectId]) on the projectIds array column.