brain/knowledge/engineering/architecture-spine.md
Activepieces: open-source AI-first workflow automation platform (self-hosted or cloud, 400+ pieces, MCP support). Monorepo, Turbo (no Nx).
projectId or platformId. Connections with multi-project access use ArrayContains([projectId]) on projectIds.AP_EDITION; EE extends CE through the hooksFactory seam (the mechanic lives on Platform & Editions). Never import src/app/ee/ from CE code.getEntities() in database-connection.ts + migration imported in postgres-connection.ts + added to getMigrations(). No auto-discovery.securityAccess.*-side-effects.ts, called explicitly after mutations.distributedLock, BullMQ dedup, or FOR UPDATE SKIP LOCKED.server/{api,worker,utils} must use safeHttp.axios/createAxios from @activepieces/server-utils. Never raw fetch/axios.create on user/OAuth/third-party URLs.packages/core/* = @activepieces/core-<name> (utils, piece-types, formula, execution — thin, framework-agnostic, dual-format). Exception: packages/core/shared keeps the name @activepieces/shared (thick, app-level, carries DB/EE schemas + heavy deps). Pieces & engine may import the thin members but never @activepieces/shared — they get symbols via @activepieces/pieces-framework. Any change to core/shared needs a version bump in its package.json (patch=fix, minor=new export).
any, no as type casting, no @deprecated APIs.tryCatch/tryCatchSync from @activepieces/shared.web/public/locales/en/translation.json; use formErrors constant.export const myUtils = {...}; React components stay named exports.{var} not {{var}}.npm run lint-dev before done. npm run test-unit (vitest), npm run test-api (CE/EE/Cloud).
has no exported member after merging main is a stale dist/, not broken code. The app typechecks resolve @activepieces/core-* through each package's built .d.ts, not its source, so a symbol main added to a thin core package is invisible to packages/web and packages/server until that package is rebuilt. It reads exactly like a bad merge — tsc names a real export that is right there in the source, and the phantom errors (TS2724 has no exported member, TS2353 property does not exist, TS2719 two different types with this name exist) land in files git auto-merged cleanly, so they read as bad conflict resolution. Confirm by grepping the symbol in packages/core/<name>/dist/, then npx turbo run build --filter=@activepieces/core-<name>. After changing a type in packages/core/execution, rebuild both core/execution and core/shared — @activepieces/shared maps to core/shared/src, which export *s from @activepieces/core-execution, and a half-rebuilt tree keeps reporting the stale shape. Hit 2026-08 merging main into a feature branch: AI_PROVIDER_ENTITY_TYPES (added by #15097) was in core/piece-types/src and re-exported from its index, but absent from dist/, so web's typecheck failed on core/shared importing it. Rebuild the packages before concluding the merge is wrong. It cuts the other way too: a symbol removed from source but still live in a stale dist/ surfaces as TS2739 missing the following properties in code that correctly stopped setting it (2026-08: clientKey/lastUsedAt on mcp-oauth-token.service.ts, gone from core/shared/src but still in core/shared/dist). Quickest triage during a merge: run git diff --name-only <merge-base> origin/main -- <file> and the same against your branch — if neither side touched the file, the error is a stale build, not your conflict resolution.
distributedLock().runExclusive waits for the whole timeoutInSeconds under contention — never put one on a request path. distributed-lock-factory.ts configures Redlock with retryCount = Math.ceil(timeout / 200) and retryDelay: 200, so the retry budget is exactly the lock TTL: a timeoutInSeconds: 15 lock retries 75 times before giving up, and each retry is its own Redis round-trip. N concurrent requests contending on one key therefore generate up to N×75 pure-retry commands against shared Redis while every one of them stalls for up to 15s. Read-mostly checks belong on the cache with the fetch scheduled behind the response (rejectedPromiseHandler + distributedStore.runOnceWithin gives cluster-wide dedupe without a lock); reserve runExclusive for genuine write serialization off the hot path. Surfaced 2026-08 in the Autumn credits gate (PR #14436, f0638438), where an exhausted or cold platform made every webhook, AI-proxy call and chat turn take a reverify lock plus a platform_plan SELECT plus a 5s Autumn HTTP call inline — a ~20s worst case on the highest-volume path in the product. Related: [[ee-platform-plans-billing]].
distributedStore.putBoolean cannot take a TTL, so it writes a key that never expires. put(key, value, ttlInSeconds?) takes one and uses SETEX when given it; putBoolean/putBooleanBatch take only the value and always SET. Reaching for putBoolean for a cheap boolean cache therefore leaks a permanent key per distinct cache key, and a later rename orphans every one of them — the same trap packages/server/CLAUDE.md warns about for a TTL-less put, except here there is no parameter to forget. Use put(key, true, ttl) and accept the JSON byte, or set the expiry yourself.
Don't .max() a business limit on a request body — cap server-side. A .max() on a request-body field rejects the whole request with a 400 the moment a user crosses it, so a user editing a list that reaches 50 items loses their entire save. Reserve .max() for a true trust-boundary DoS guard (Fastify's global body limit already covers gross abuse) and let business limits just apply: accept the input and slice(0, MAX) in the service layer, so the write always succeeds with the limit quietly enforced. Surfaced 2026-07 on POST /v1/chat/memory, where the schema's .max(50)/.max(280) duplicated a slice the save helper already did — redundant and a data-loss bug.
unique() from core-utils is O(n²) over JSON.stringify — never put it on a hot path. It is filter + findIndex with a JSON.stringify on both sides of every comparison, so it blocks the event loop: 1k items → 42ms, 5k → 889ms, 10k → 3.6s, during which health checks, websockets and webhook dispatch all stall. It exists for deep-equality dedupe of objects; for primitives use [...new Set(xs)]. Found 2026-07 as the first statement of the bulk record delete the same PR was trying to speed up (GIT-1652).
kebabCase() from core-utils does not strip punctuation, so it cannot make a URL slug — reach for slugify(). The two sit next to each other in core-utils/utils.ts and read as synonyms, but kebabCase only splits camelCase and swaps spaces/underscores for hyphens: "Acme Inc." comes back acme-inc., dot intact, and any &, ' or / survives too. slugify is the one that drops every non-alphanumeric run. Picking the wrong one is invisible in dev (single-word brand names are identical under both) and only shows up once a real customer name reaches the path, query string or config key you built with it. Neither has a fallback for an all-punctuation input — both return '' — so a caller that needs a non-empty slug supplies its own default (slugify(name) || 'activepieces', as the MCP client catalog does). Note piece_set.key is generated with kebabCase, which is deliberate: it is an opaque handle with a random suffix, not a URL.
DeleteResult.affected is undefined on PGlite — don't count rows with it. TypeORM's PostgresQueryRunner only sets affected when the driver result carries rowCount; PGlite reports affectedRows instead and typeorm-pglite doesn't map it. So result.affected ?? 0 is correct on pg and silently 0 on every PGlite deployment and test — the worst failure mode, since CI is green. Use .returning('id') and count the rows.
Migration timestamps are hand-picked, so two PRs in flight will collide. postgres-connection.ts uses round numbers (1815000000000, 1816000000000, …), not Date.now(), and TypeORM orders migrations by the 13-digit suffix of the class name. Two branches both taking "the next one" produce duplicate keys, and ordering — including rollback-migrations.ts — silently falls back to getMigrations() array order. Check git ls-tree main packages/server/api/src/app/database/migration/postgres/ for the number before you commit, and re-check after any rebase.
CREATE INDEX CONCURRENTLY IF NOT EXISTS can record success over a permanently invalid index. CONCURRENTLY requires transaction = false, so nothing rolls back an interrupted build — it leaves an indisvalid = false index. IF NOT EXISTS then matches on name only, so the retry skips it with a NOTICE and TypeORM marks the migration applied: the query the index was meant to fix stays slow, with a green migration log. An invalid index is not inert either — still maintained on every insert, still blocks HOT updates. Use DROP INDEX CONCURRENTLY IF EXISTS before the create, and assert pg_index.indisvalid after. The existing 1810/1815/1818 index migrations all carry this shape.
TypeORM soft-delete (@DeleteDateColumn) is not canary/rollback-safe on a shared DB. TypeORM only appends WHERE "deleted" IS NULL for code whose entity declares the column, so any two versions sharing one Postgres — every canary window (canary shares prod's DB), every rollback — means old code reads soft-deleted rows as live. During canary a row deleted by new code reappears live and editable on old-code requests; on rollback every soft-deleted row returns permanently. Partially unrecoverable, too: old code's delete is a hard DELETE, so it can destroy a resurrected row the new restore feature could otherwise bring back. Partial indexes (WHERE deleted IS NULL) also stop serving old queries → seq scans. Do it expand-contract: ship the column and make all read paths filter on it first, roll that out everywhere, and only then flip the write path to softDelete(). The additive column is fine — it's the read-semantics change that can't run split across versions, and the same applies to any migration where old code must interpret a column it doesn't know about. Seen in PR #14219 (feat: chat core).
Canary doesn't proxy websockets — only broadcasts reach canary users. Canary is a worker group that also has its own app tier (CANARY_APP_URL, IS_CANARY_APP), sharing prod's Postgres and Redis. The prod app is the ingress and canaryRoutingMiddleware HTTP-proxies a platform whose workerGroupId === 'canary' to the canary app — but the middleware is registered inside the /api scope, so only /api/* is proxied (the SPA is served at root from the baked-in bundle) and it bails on upgrades: if (request.headers.upgrade === 'websocket') return. A canary platform therefore runs the prod frontend, and its websocket is terminated by prod (old code) while its HTTP and flow jobs run on canary. Across a version split, server→client broadcasts still work (socket.io's Redis adapter relays canary's emit name-agnostically), but inbound handlers — LOCK_RESOURCE/UNLOCK_RESOURCE, presence — run on old prod code and silently degrade. The fix, verified 2026-07: point canary-platform websockets at the already-live canary.activepieces.com by making the frontend socket URL a runtime value from an authenticated /api flag (that call is proxied, so canary answers wss://canary.activepieces.com and prod answers same-origin) and deferring socket creation until it resolves. Cross-origin is fine (cors:{origin:'*'}, token in socket.auth, not cookies), and it closes the inbound half of the seam too. kamal-proxy can't help — host/path routing only, no cookie/header routing — and reply.from is HTTP-only. Canary is the only worker group with a separate app tier; dedicated groups share the prod app, so their users' websockets already hit the right code. Workers are the mirror case: they carry workerGroupId in post-upgrade auth but use an explicit socketUrl, so canary workers must point at the canary app by config.