Aller au contenu

API Conventions

Nommage (verbes par couche, suffixes de type, booléens, lexique de statuts, vocabulaire de domaine, modules) : voir naming-conventions.mdc.

Regle absolue

Ne JAMAIS refactoriser du code existant sauf demande explicite. Les conventions ci-dessous s'appliquent au nouveau code uniquement. Si un refactoring semble benefique, demander confirmation d'abord.

DO NOT

  • DO NOT utiliser les patterns legacy (tout hors __new/) ni dtos/ → utiliser model/
  • DO NOT utiliser de dependency injection ou factory pattern → import direct des service objects (export const X = {})
  • DO NOT assigner un service object a une variable intermediaire (const x = SomeService, this.x = SomeService) → appeler SomeService.method() directement depuis l'import
  • DO NOT passer un service object migre en parametre de fonction, propriete d'objet (services: { ... }) ou champ de controller → import statique et appel direct. Exceptions : services NestJS @Injectable() non migres, instances runtime (ex. lemonwayApi cree avec options par factory), state watchers / factories avec deps explicites documentees
  • DO NOT utiliser de decorateurs hors controllers et entites NestJS
  • DO NOT utiliser moment → dayjs
  • DO NOT utiliser var → const par defaut, let si reassignation
  • DO NOT utiliser de primitifs nus pour montants, IDs, durees → types forts (voir section "Types forts")
  • DO NOT utiliser Omit, Pick, Partial, types conditionnels
  • DO NOT muter les objets → produce d'immer ou redeclarer tous les champs
  • DO NOT utiliser produce() d'immer sur des instances de classes (entites TypeORM/ORM) → immer ne peut pas proxifier les class instances sans [immerable] = true. Utiliser l'assignation directe pour muter des entites ORM dans une transaction
  • DO NOT utiliser le spread ... → redeclarer chaque champ ; props optionnelles : key: value ? value : undefined, pas ...(cond ? { key: value } : {}) (cf. .cursor/rules/generic-monorepo-rules.mdc)
  • DO NOT réutiliser le select, le contrat ou un query builder (scopedX) d’une liste pour un getOne — chaque méthode écrit sa query et déclare les champs que l’écran consomme
  • DO NOT try/catch → .then() / .catch() apres promesse ou Result<T, E>. Y compris mapping 23505 (isPgUniqueViolation) : write().then(...).catch((error) => { if (!isPgUniqueViolation(error)) throw error; return Err('…') }). Y compris dans les boucles cron batch : await work().catch((err) => { log; return Err('unexpected-throw') }) puis accumuler l'ID — jamais try { await work() } catch. Exception : code sync non-promisifie (ex. parse CSV) ou lib tierce qui n'expose que du throw sync
  • DO NOT throw pour erreurs metier → Err('error-code' as const)
  • DO NOT return Ok() sur un cas d’erreur / skip metier (row absente, déjà traité, fenêtre encore ouverte) → Err(code) ; le caller (task Graphile / cron) match le code : throw seulement si retry transitoire, sinon return sans retry
  • DO NOT throw d'exceptions NestJS depuis les services pour des erreurs metier → retourner Err(...) et les mapper dans les controllers
  • DO NOT throw new Error nu dans __new/ pour un invariant interne → throw new ApiException({ error, logger }). Catch pour recuperer = la source doit etre un Result. Sentinels TX (Transaction aborted, Dry Run) restent des Error string-matchés par ky_safePgTransaction
  • DO NOT ajouter d'annotations de type explicites quand TypeScript infere deja le type voulu
  • DO NOT construire des helpers metadata-driven autour de prepareParameterizedQuery pour les casts PG simples → declarer cast: 'jsonb' / cast: 'uuid' explicitement dans le mapping
  • DO NOT return await → retourner directement la promesse
  • DO NOT switch ou if/else enchaines → match().exhaustive() de ts-pattern
  • DO NOT mettre de logique metier dans service/ → seulement dans business/
  • DO NOT mettre de validators dans business/ → seulement dans model/
  • DO NOT ecrire null JSON dans une cle typee isoDate.optional() — dropper la cle (jsonb - 'key'). optional() rejette null et casse les lectures suivantes. Canon : clearFundingReceivedByProjectOwnerAt
  • DO NOT extraire un validator utilise une seule fois → l'inliner au callsite
  • DO NOT ajouter un index SQL pour une requête pas encore servie en prod ou non mesurée
  • DO NOT nommer avec mots generiques (data, info, value, details)
  • DO NOT logger de secrets, tokens, cookies de session ou PII (email, telephone, adresse) dans les payloads → logger l'evenement, pas la valeur. Utiliser des summaries sanitisees pour les payloads d'API externes. Ajouter les headers sensibles (cookie, set-cookie, authorization, x-api-key) aux redact.paths de pino. Les secrets passes en query param (webhook apiKey) sont masques par le serializer req (src/__new/lib/log/redact-req-secrets.ts) — un nouveau secret en query = ajouter sa cle a SENSITIVE_QUERY_KEYS, pas un nouveau mecanisme. Si tu logges une URL brute hors du champ req (interceptor, filter, etc.), passe-la par redactUrlSecrets — le serializer et redact.paths ne couvrent que les logs clés req
  • DO NOT swallow des erreurs dans les cron tasks batch (graphile-worker) → accumuler les IDs en echec pendant la boucle (via .catch → Err, pas try/catch), puis throw a la fin si des items ont echoue. Graphile-worker ne retry que si le job throw. Les items deja commites sont naturellement idempotents (filtres par check de statut au prochain run)
  • DO NOT laisser passer une mutation d'achat quand funding.blocked est set → fige PG pour la réconciliation Redis ; détail : domain-map §Investissement primaire + doc module
  • DO NOT mettre de DEFAULT en DB sur les colonnes applicatives (migrations) → comportement explicite dans le code, l'INSERT fournit toujours la valeur. Exceptions : (1) colonnes techniques (createdAt DEFAULT now(), generated columns) ; (2) DEFAULT temporaire de backfill ADD COLUMN NOT NULL DEFAULT … ; ALTER … DROP DEFAULT → rétro-remplit les lignes existantes en metadata-only (pas de réécriture de table) puis le code reprend la main sur les nouvelles lignes (cf. V038, V202606181612)

DO

  • DO poser CreateAdminActionInterceptor sur toute mutation admin (POST/PUT/PATCH/DELETE) qui change un état — exemptés : simulations compute-only et uploads S3 bruts. M2M (routes x-api-key sans req.user) : source + ApiKeyAuthGuard sur la route, adminId NULL. Routes BO session : adminId via req.user + source optionnel, AdminAuthGuard + même interceptor. Invariant DB : au moins l'un des deux non NULL. Body sensible (PII identité/bancaire) : pickBodyKeys pour ne persister que les clés utiles à l'audit. Service : __new/modules/admin-action/service/admin-action.service.ts
  • DO images et documents admin (ADMIN_IMAGE_UPLOAD_PURPOSES / ADMIN_DOCUMENT_UPLOAD_PURPOSES) via FilesStorageService.uploadPublic({ kind, purpose }) dans s3.buckets.public. Isolation = préfixe {env}/{images|documents}/{purpose}/ — env = prod si ENVIRONMENT=production, sinon dev. Un purpose de plus = 1 entrée tableau + préfixe, pas de nouvel env. Upload serveur = PutObject SDK (pas d'URL pré-signée + axios)
  • DO prefer function name() {} for module-level helpers and named pure functions — reserve const fn = () => for inline callbacks passed as arguments
  • DO declarer @AllowedProjectRoles(...) sur tout controller project-financing-request/:projectId/* — le guard est en default-deny, donc une nouvelle surface nait fermee au lieu de fuir en silence. Refuse au commit par scripts/lint-conventions/lint-project-role-gating.ts (pre-commit uniquement : --no-verify le contourne, et aucune CI ne lance les lints de convention). Matrice des roles : project-financing-request-user/README.md
  • DO factoriser un validator reutilise plusieurs fois dans le meme fichier
  • DO colocate un validator factorise au plus pres de la fonction qui l'utilise

Architecture modulaire

Modules par feature dans src/__new/modules/, flat (pas de sous-modules). Reference : project-financing-request/. Le pourquoi des choix : doc MkDocs projects/api/docs/architecture/.

Couche Role
controllers/ Couche HTTP : auth (guards) + validation payload, rien d'autre. Appel service, throwApiError. Service reduit a un proxy → appel direct du repository autorise
services/ Plomberie : ordre des appels, transactions, assemblage d'entites, checks de flow. Les side effects vivent ici (ou repository). Integration externe propre au module = service du module (ex. XxxS3Service), pas un provider dans __new/lib/
repositories/ Seul acces DB. ~1 table par repository. Types du domaine valides a la lecture. Pas d'interpretation metier de l'absence (get* vs find*, cf naming)
business/ Contraintes logiques/metier pures uniquement : zero side effect, pas d'horloge (now en param). Un check d'une ligne reste inline dans le service ; remapping/parsing utilitaire → lib/. Tests 100% couverture — seule couche où les unit tests sont exigés (cf. §Tests)
mappers/ Projection pure d'un model vers un autre (shape Response, payload provider). Recoit ses sources en parametres ; side effects dans service/repository. Pas de tests exiges : un mapping type→type est couvert par les tests d'integration
guards/ Authn/authz NestJS, enrichit request typee. Throw HTTP uniquement (avec les controllers)
lib/ Utilitaires techniques purs specifiques au module. Ni metier ni plomberie
tasks/, state-watcher/ Entry points additionnels au meme niveau que controllers/ (pas de wrapper entrypoints/)

Modules de l'ere idtlt : dossiers au singulier (repository/, service/) + model/ (validators idtlt) — ne pas renommer.

CRUD simple = controller + repository suffit, pas besoin de service.

__new/lib/ = utilitaires generiques cross-modules (postgres, cache, kysely, providers partages, etc.)

Nommage fichiers : *.repository.ts, *.service.ts, *.model.ts, *.controller.ts, *.mapper.ts, *.guard.ts

Types forts

Pas de primitifs nus → utiliser les branded types de @bricks-common/api-communication. Consulter projects/common/both/api-communication/src/common/ pour la liste complete (nombres, dates, UUIDs, etc.). IDs : Brand<string, 'EntityId'> avec baseUUID.tagged<Id>() (jamais string). Discriminated unions OK pour narrowing.

defined() de space-lift/commonjs/is pour verifier null/undefined. array.push() acceptable dans scope locale.

Patterns de reference

Service object / Repository

Nommage : PascalCase sans suffixe Factory (CustomerioEmailB2bApi, pas customerioEmailB2bApiFactory).

Methodes publiques definies directement dans l'objet exporte (LSP + rename). Helpers prives et deps module-level (logger, axios client) en dessous de l'objet exporte. Ne JAMAIS declarer les methodes publiques comme fonctions separees puis les referencer dans l'objet. Exporter le type : export type MonService = typeof MonService.

Usage : toujours via l'import statique — jamais d'alias, jamais de re-passage en param.

const logger = new Logger('MonService')

export const MonService = {
  async maMethode(id: UUID) {
    return helperPrivee(id)
  },
}

function helperPrivee(id: UUID) {
  logger.log(id)
}

export type MonService = typeof MonService

// appelant (controller, cron, script, helper du meme fichier)
await MonService.maMethode(id)
// MAUVAIS — alias inutile
const monService = MonService
await monService.maMethode(id)

// MAUVAIS — DI d'un service object
async function syncSomething(params: { monService: MonService }) {
  await params.monService.maMethode(id)
}
await syncSomething({ monService: MonService })

// MAUVAIS — champ controller
private readonly monService = MonService
await this.monService.maMethode(id)

// BON
await MonService.maMethode(id)

Model (namespace pattern)

export namespace MonEntite {
  export type Id = Brand<string, 'MonEntiteId'>
  export const id = baseUUID.tagged<Id>()
  export function Id(): Id { return uuidv4() as Id }
  export const validator = object({ id, /* ... */ })
  export type T = typeof validator.T
}

Toujours deriver le type du validator. IDs forts obligatoires pour toutes les entites.

Pas de .default() dans le schema Kysely : un default Zod est invisible au callsite et masque en base une valeur qu'on croit fournie. Le champ reste requis (z.boolean(), pas z.boolean().default(false)) et chaque construction pose la valeur explicitement — le compilateur force alors a ne l'oublier nulle part.

La couche DB duplique les brands/value-objects du contrat, jamais la logique metier. Un schema Kysely ne doit pas dependre des packages contrat (api-communication-project-financing, api-communication-bricksoffice, api-communication-internal, …) pour ses types : un id/value-object qui traverse un contrat se redeclare localement (export const id = z.uuid().brand<'XxxId'>(), jamais import { xxxIdSchema } puis = xxxIdSchema ; idem cle S3, coordonnees, enum de MIME). Exception : les VO zod partages de @bricks-common/api-communication (cents_zod, isoDate_zod, address_zod, …) — un seul, on importe. Deux brands/objets de meme forme sont structurellement compatibles (Zod brande sur la string du tag), donc la copie DB matche la copie contrat aux frontieres view/controller sans aucun cast ; as string as UUID reste interdit. Le contrat garde sa propre copie pour le front — independante par design. Ne brander que les ids/VO au format fiable (UUID persiste ou client-generated via crypto.randomUUID(), cle S3, enum) ; un id au format douteux (rep-${Date.now()}) se fiabilise d'abord. Un contrat ne porte que des schemas de forme, jamais de comportement. Une fonction (defaut, construction, regle metier) n'a rien a faire dans api-communication-* : le contrat decrit la forme des donnees echangees, point. Une valeur par defaut de colonne appartient au back, declaree DANS le schema Kysely a cote de la colonne qu'elle initialise (pendant de Id() : export function createDefaultDocumentsStep(): …).

Controller

@Post('endpoint')
@UserRoles(CustomerRole.CUSTOMER)
@UseGuards(JwtAuthGuard, UserRoleGuard)
async monEndpoint(@IAM() customer: Customer, @Body() body: unknown) {
  const payload = httpValidate(body, validator)
  const result = await MonService.methode(payload)
  return match(result)
    .with({ ok: true }, ({ value }) => value)
    .with({ ok: false, error: 'not-found' }, () => { throw new NotFoundException() })
    .exhaustive()
}

Logger : controllers → private readonly logger = new Logger(this.constructor.name) ; services, scripts et bootstrap pre-app → module-level const logger = new Logger('Name').

DO NOT poser la logique d'un script one-shot dans __new/modules/*/business/ — colocaliser a cote du .script.ts (scripts/<domaine>/<nom>/, tests dans __tests__/). Modele : ef281-decline-mistaken-coupons.

Error handling

Critere de decision : l'appelant peut-il faire quelque chose de l'echec ? (pas metier vs technique — un fail P2P Lemonway est technique et gerable.)

  • Oui → erreur contractuelle : apiErr({ error, message, type }) dans le service, remontee en Result, traduite par throwApiError(result) au controller (seul throw HTTP). Types exposes : not-found (404), validation-body / validation-params (400), conflict (409), too-many-requests (429) — le payload part dans la reponse.
  • DO : if (!row) return apiErr({ error: 'project-not-found' as const, type: 'not-found' }) → controller : if (!result.ok) return throwApiError(result)
  • DON'T : throw new NotFoundException() depuis un service
  • DON'T : typeof error === 'string' pour splitter une union d'erreur Result — poser Err('code' as const) a la source, puis match exhaustif. Un service qui retourne deja un Result ne throw pas son erreur : il return apiErr(...) (le controller fait throwApiError)
  • Non → erreur interne (invariant casse, schema DB, driver PG — l'appelant ne peut rien en faire) : throw new ApiException({ error, logger, cause? }) a la source → 500 opaque. Pas de Result. Degradable = absorber (decision produit).
  • DO : throw new ApiException({ error: 'pg-validation-failed' as const, body, logger }) ; wrap d'un throw tiers : .catch((cause) => { throw new ApiException({ error: '…' as const, cause, logger }) }) — ajouter du contexte, pas recuperer. Lib frontiere deja en Result (s3-client) → throwApiError au premier consommateur
  • DON'T : new ApiException(...) sans throw ; throw new Error nu ; type HTTP sur ApiException (ca duplique throwApiError) ; catch ApiException pour recuperer (si gerable → apiErr a la source) ; re-emballer un Result en throw ; ApiException pour les sentinels TX (Transaction aborted / Dry Run)
  • Routes admin/BO (contrat bricksoffice) : throwApiError(result) comme ailleurs. AllExceptionsFilter copie exception.message dans HTTP message ; les toasts BO traduisent ce champ comme code kebab. Donc apiErr({ error, message: error, type }) — message = le code, jamais un texte humain. Erreur interne : regle ci-dessus.
  • business/ (fonctions pures) : Err('error-code' as const) nu — le service remappe en apiErr si ca remonte au controller.
  • DO : return Err('insufficient-balance' as const)
  • DON'T : apiErr(...) dans business/ (couple le calcul pur au format HTTP + logger)
  • Validation runtime DB (Kysely + Zod) : ky_parseRows / ky_parseFirstRow / ky_parseOneRow + pgParse_zod → throw new ApiException sur echec schema → 500 (detail loggue, jamais expose). ky_parseFirstRow = firstRowValidator : 0 ligne → undefined, le service mappe en apiErr metier si besoin. ky_parseOneRow = oneRowValidator : exactement 1 ligne sinon throw new ApiException (0 ou >1)
  • Listes paginees admin : Promise.all([page.limit(take).offset(offset), filtered.select(count(*)::int)]) + tiebreaker orderBy(id, 'desc') ecrits en entier dans chaque repo — pas de builder paginate(query) partage (il cacherait le tri stable). Count : ky_parseOneRow(ky_count_zod, countRows, logger) (lib/kysely/ky-parse.ts, miroir zod de pgCountReturnValidator). Recherche : ky_ilikePattern(q). Garde de filtre tableau : if (params.x?.length)
  • .catch() apres promesse pour convertir en Result — ou throw new ApiException si l'echec reste ingerable
  • jsonFromStringValidator au lieu de try/catch pour JSON

Validation

Tout ce qui entre dans le systeme = valide avec idonttrustlikethat : - HTTP : httpValidate(body, validator) dans controllers - DB : queryPgAndValidate avec validator + transactionHandler - APIs externes : valider la reponse - Nullable : .nullable().map(mapNullableToUndefined) - PATCH partiel (BO) : champ de contrat .nullish() — absent = conserve, null = efface, valeur = ecrite. Le service applique un seul produce : if (body.x !== undefined) draft.x = body.x ?? undefined (le json ne stocke pas de null). Pas de conversion null → undefined dans le validator du contrat : elle confondrait « effacer » et « absent ». Un PATCH qui recoit le formulaire entier (autosave App PDP) remplace : absent = efface - Regles metier complexes : .and() sur le validator - Validators communs depuis @bricks-common/api-communication - Payloads des endpoints back-office (domaine Projects) : importer le validator depuis @bricks-common/api-communication-bricksoffice, ne pas le re-declarer dans le controller (source unique partagee avec bricksoffice-projects). IDs branded : minter au bord via le creator de l'entite (Model.Id(payload.x), meme idiome que Cents(n)/Percentage(n)), jamais as unknown as au callsite. Si le validator partage doit etre plus strict (ex. positiveInteger.then(cents)), renforcer le validator partage (meme type de sortie), pas une copie locale. Etat API↔package : projects/common/both/api-communication-bricksoffice/src/VALIDATOR-MUTUALIZATION.md.

Transactions

Mutations = safePgTransaction obligatoire. Locking = FOR UPDATE avant tout UPDATE (sauf increments/decrements atomiques). Err(...) dans transaction = rollback automatique, Ok(...) = commit. Transactions imbriquees via { parent }. setOnRollback pour cleanup en cas de rollback. FOR UPDATE SKIP LOCKED uniquement pour workers/queues. Un FOR UPDATE nu sur un SELECT dont le FROM est un CTE ne verrouille rien : Postgres ignore silencieusement les RTE non verrouillables (FOR UPDATE OF <cte> erreur, mais personne ne l'ecrit). Relire la ligne par id dans une requete dediee sur la vraie table — FOR UPDATE est aussi interdit dans un bras d'UNION (erreur de syntaxe). Colonne json/jsonb ecrite par plusieurs flux : ne jamais ecrire depuis un snapshot lu avant l'acquisition du verrou — sinon lost update sur les cles des autres flux. Soit la lecture est deja sous verrou dans la meme transaction, soit on relit la ligne apres l'avoir verrouillee. Le document ecrit reste typé (GiftCard.T, InvestorFinancialDocument…) : pas de patch jsonb construit a la main.

Business

Fonctions pures : params strictement necessaires, pas de DB/Logger/API, memes inputs = memes outputs. Tests 100% couverture dans *.test.ts a cote — seule couche où les unit tests sont exigés. Un unit test qui a besoin de mocks internes (DB, services, providers) = mauvaise cible (cf. §Tests).

Tests

  • Ne JAMAIS créer de tests spontanément pendant une implémentation — le dev est l'initiateur, pas de bruit dans les diffs
  • En fin d'implem touchant un controller / cron task / worker / webhook, poser la question : proposer d'ajouter les tests d'intégration via le skill upsert-integration-tests
  • Unit tests exigés uniquement dans business/ (100% couverture) — skill upsert-unit-tests ; besoin de mocks internes → test d'intégration ou rien
  • DO NOT unit-tester un builder Slack/mail (concat de lignes) — pas de contrainte métier ; le webhook se couvre en intégration
  • Review du diff de tests : skill review-integration-tests

Stack cible Kysely + Zod

Stack par defaut de tout nouveau module (reference : project-financing-request), a privilegier des que le perimetre est raisonnablement independant — l'interop avec TypeORM/idtlt n'est pas optimale. Nee sur l'Espace Financement :

Outil Remplace Usage
Kysely queryPgAndValidate Query builder type-safe. getKysely() pour l'instance, ky_safePgTransaction pour les transactions
Zod idonttrustlikethat Validation + source unique des types. Schemas dans __new/lib/kysely/schemas/
apiErr Err('code') nu Erreurs gerables structurees : apiErr({ error, message, type })
ApiException throw new Error nu throw new ApiException({ error, logger }) — 500 opaque. Pas un canal HTTP
throwApiError match(result) dans controller Map type metier -> exception NestJS (seul throw HTTP)
space-lift - Ok/Err/Result (partage avec modules existants)

Conventions de nommage

  • Modules : nommage par feature, cf naming-conventions.mdc §8. Le nommage espace-fi est abandonne — ne jamais creer de nouveau nom espace-fi. Tables EF : project_financing_request_* + project_owner_company (ex-espace_fi_spv)
  • Utilitaires Kysely dans __new/lib/kysely/ : prefix ky_ (ex: ky_parseRows, ky_safePgTransaction)
  • Utilitaires generiques dans __new/lib/ : pas de prefix module

Isolation des donnees

Les modules du domaine financement (project-financing-request...) ont leurs propres tables (project_financing_request_*, project_owner_company). Ne JAMAIS reutiliser les tables ou entites de la partie investisseur. Si une donnee existe deja cote investisseur (ex: project_owner, properties, special_purpose_vehicule), creer une table dediee au domaine. Les deux domaines sont completement isoles.

Migrations

Restent sur Flyway (fichiers SQL dans migration/flyway/). Pas d'utilisation des outils de migration Kysely.

  • Les branches DB dev peuvent déjà contenir les objets créés en amont : utiliser IF NOT EXISTS pour chaque CREATE TABLE et CREATE INDEX Flyway.
  • DO NOT mettre à jour mobile_app_minimal_version dans un script Flyway. Le PUT admin est le seul canal : un script au deploy écrase la valeur posée dans le BO et ne vide pas le cache probe:mobile:version:minimal.
  • DO NOT merger une migration Flyway dont le timestamp de version est ≤ au max deja present dans flyway_schema_history sur develop/prod — merge out-of-order → Flyway rejette le deploy (Detected resolved migration not applied) ou marque la version Ignored (prod saute migrate/validate et deploie le code sans le schema). Re-stamper strictement apres le max courant (rename seul, contenu inchange) ; verifier que l'ancienne version n'a jamais ete appliquee avant de renommer.
  • Tables JSON + colonnes _view : getters retournent .json, une _view seulement si le SQL s'en sert — json-view-tables.mdc
  • DO NOT dual-read / dual-write des clés JSON legacy « pour la race de deploy » après un rename/migrate Flyway → les migrations passent avant le traffic ; détail : migrations-flyway.md §Où et quand Flyway s'exécute
  • DO baisser autovacuum_vacuum_scale_factor par table (dans Flyway, métadonnées seules) dès qu'un index partiel couvre une colonne à fort churn → le scale factor global de 0.2 laisse le seuil hors d'atteinte sur les grosses tables, l'index bloate et un scan finit par lire des centaines de Mo de pages mortes. Sur Neon c'est le seul levier (pas de GUC instance) et le seuil doit être atteignable entre deux restarts de compute, qui remettent les compteurs de l'autovacuum à zéro. REINDEX CONCURRENTLY → migration/manual/ (interdit en transaction), et il ne rend que le disque déjà perdu ; détail : migrations-flyway.md §Le runbook des index manuels

Patterns

  • Zod = source unique des types : z.infer<typeof schema> pour deriver les types, pas de duplication
  • Branded types : definis dans Zod (.brand<'TypeId'>()), propages dans Kysely automatiquement
  • Validation runtime DB : ky_parseFirstRow → T | undefined (firstRowValidator) ; ky_parseOneRow → T, throw new ApiException si ≠1 ligne (oneRowValidator, via z.tuple([schema])) ; ky_parseRows → T[] ; schema invalide → throw new ApiException
  • Lectures repository : retour direct T | undefined / T[] — pas de Ok/Err/apiErr au repository
  • Mutations = toujours dans une transaction. Les methodes de mutation du repository prennent trx: Transaction<Database> obligatoire (pas optionnel). Le service appelle ky_safePgTransaction et passe trx au repository. Err(...) = rollback, Ok(...) = commit. Les lectures n'ont pas besoin de transaction
  • Erreurs gerables : apiErr({ error: 'code', message: '...', type: 'not-found' }) dans service, throwApiError(err) dans controller. Types exposes : not-found (404), validation-body / validation-params (400), conflict (409), too-many-requests (429). Internes : throw new ApiException a la source (cf. Error handling). validation-pg / provider-error dans un Result = a eviter

DO NOT (stack Kysely + Zod)

  • DO NOT retourner Ok/Err/apiErr depuis un repository Kysely — lectures = T | undefined / T[] uniquement
  • DO NOT utiliser idonttrustlikethat dans les modules sur cette stack
  • DO NOT utiliser queryPgAndValidate dans les modules sur cette stack
  • DO NOT utiliser safePgTransaction (existant) -> utiliser ky_safePgTransaction. Exception : une mutation qui n'ecrit que des tables legacy (better_auth_user, customers) passe par leurs repos legacy dans une safePgTransaction TypeORM (cf. ProjectFinancingRequestUserAdministrationService.updateUser)
  • DO NOT reutiliser des tables/entites de la partie investisseur
  • DO NOT creer de dossier par module dans __new/lib/ (ex. /lib/espace-fi/) — les utilitaires generiques y vivent par theme

Stack Kysely investisseur

Migration progressive des entites TypeORM legacy vers Kysely + Zod. Tables investisseur plates (referral-link, admin_action, …), une entite par PR.

Outil Usage
Kysely getKysely(), ky_safePgTransaction pour les mutations
Zod Schemas dans __new/lib/kysely/schemas/ — namespace XxxPgSchema, validateurs explicites (row, insertRow, …), z.infer — pas de Pick/Omit TS
queryPgAndValidate Ecritures SQL legacy dans une safePgTransaction TypeORM existante (EntityManager)

Enregistrement des tables

Chaque entite migree ajoute une entree dans Database (database.ts), section commentee investisseur. Si KyselyDb (Kysely<Database> | Transaction<Database>) n'est pas encore exporte depuis database.ts, l'ajouter une fois — les repos l'importent, pas d'alias local par fichier.

Repos (__new/modules/<module>/repository/)

  • Import KyselyDb depuis database.ts — pas de type KyselyDb local par repo
  • Lectures hors tx : getKysely() — params: { … } pour les filtres
  • Mutations dans ky_safePgTransaction : insertOneWithKysely(params: { trx: KyselyDb; row }) — insertInto + ky_parseOneRow. Updates : updateOneWithKysely(params: { trx, … })
  • Appelant dans safePgTransaction TypeORM : insertOneWithTypeorm(params: { transac: EntityManager; row }) — INSERT … RETURNING via transac.query. Lectures/updates en tx : …WithTypeorm(params: { transac, … })
  • Nommage tx : trx cote Kysely, transac cote TypeORM (EntityManager)
  • Le service appelle directement la methode correspondant a sa transaction parente — pas de wrapper de dispatch
  • Ajouter la branche TypeORM seulement si un grep des call sites montre un appelant encore dans safePgTransaction
  • DO NOT wrapper Kysely autour d'un EntityManager — deux stacks, une transaction a la fois

Validation runtime

ky_parseFirstRow / ky_parseOneRow / pgParse_zod : firstRowValidator vs oneRowValidator (cf. pg-return.ts). Schema invalide ou oneRow avec ≠1 ligne → throw (500 + log). find* / get* nullable → ky_parseFirstRow (T | undefined). Validator nommé (rowValidator), jamais z.object({…}) inline dans l'appel. Reference : project-financing-request/.

Tables legacy partagees — lock idtlt ↔ zod

Regle simple : create/update via idtlt, read via Kysely. Sur une table partagee, les ecritures restent sur le write-side typeorm+idtlt ; un XxxPgSchema est par defaut une slice de lecture.

La DB est la seule source de verite ; idtlt et zod n'en sont que des projections, et la shape interne des colonnes jsonb echappe a toute validation SQL. Chaque XxxPgSchema d'une table ayant un modele idtlt write-side porte donc un lock compile-time (__new/lib/kysely/ky-schema-lock.ts) : la divergence casse tsc au lieu d'attendre la prod.

Le code zod/Kysely… Schema zod Lock
lit la table (le defaut) slice minimale (champs lus uniquement) Assert<Extends<IdtltModel, Slice>>
ecrit encore une colonne aussi ecrite par idtlt (exception transitoire) miroir complet Assert<Exact<IdtltModel, Mirror>>

Brands d'ids d'entite : un seul brand canonique cote zod (package contrat s'il existe, sinon le PgSchema de la table), qui raffine le brand UUID partage (uuid_zod.brand<'XxxId'>() — reste assignable la ou les contrats attendent uuid_zod) ; le modele idtlt l'aliasse (pattern Cents). Jamais deux brands homonymes : les encodages Brand<> helpers et z.$brand sont inassignables entre eux. Reference : property-construction-budget-request.schema.ts (miroir), project-payment-schedule.schema.ts (slice).

Debug d'un lock casse — tsc echoue avec Type 'false' does not satisfy the constraint 'true', sans nommer le champ : decouper l'assertion champ par champ avec le meme helper (Assert<Exact<L['champ'], Z['champ']>> par ligne) dans un fichier temporaire — le champ fautif ressort immediatement.

DO NOT (investisseur Kysely)

  • DO NOT migrer plusieurs entites dans une seule PR infra — poser la foundation, puis une entite par PR
  • DO NOT ajouter une methode TypeORM dans un repo si tous les appelants sont deja dans ky_safePgTransaction
  • DO NOT ajouter un XxxPgSchema sur une table qui a une entite TypeORM/idtlt sans poser son lock (Extends en lecture, Exact en ecriture)
  • DO NOT introduire de nouvelle ecriture Kysely sur une table legacy partagee — create/update passent par idtlt

Routes internes (modules internal-*)

Routes server-to-server pour les apps internes hors monorepo (ticketing care, ia-whatsapp, gestion-des-defauts, ai-agent) qui remplacent leurs requetes SQL directes sur la DB Bricks.

  • Modules : src/__new/modules/internal-*/, routes prefixees /internal/<domain>
  • Contrats : descriptors zod dans @bricks-common/api-communication-internal (meme stack que api-communication-project-financing), bodies via bodySafeParse_zod. Consommateurs hors monorepo → la doc d'endpoints auto-generee (MkDocs) est la reference du contrat
  • Auth : 1 cle API dediee par app consommatrice via ApiAuthService ; routes partagees → assertApiKeyInHeadersAnyOf. Exception : une cle par surface quand plusieurs clients du meme chantier consomment les memes routes (project-owner-invoicing = middleware invoice-axonaut + Bubble). Cles requises en production (refinement env) car cle absente = auth desactivee
  • Donnees : lecture seule sur les tables legacy via queryPgAndValidate + idtlt — PAS Kysely, pas d'isolation de domaine : ces routes lisent les tables investisseur existantes
  • Repos : la query vit dans le repo prod du domaine s'il existe (methode ajoutee au besoin — les tests /internal couvrent alors du code prod) ; repo internal-* seulement pour les projections sans foyer naturel. Lecture leniente via methode dediee quand le validateur strict du domaine peut echouer sur du legacy
  • Reponses : valeurs brutes (cents, dates ISO), formatage FR cote consommateur ; parite 1:1 avec la requete SQL remplacee, pas de pagination. Shaping row → contrat dans views/ des que ce n'est plus un passe-plat (nullable DB → optional contrat, renommage) : le controleur ne construit pas d'objet de reponse
  • Tests : happy path uniquement ; l'auth est couverte une fois par le guard test de /internal/ping, pas re-testee par route
  • SQL operateur (/internal/segments) : execute derriere le role PG read-only dedie (DATABASE_USER_RO/DATABASE_PASS_RO, premier DATABASE_SLAVES sinon master) via queryPgAndValidate({ readOnly: true }) (et { includeColumns: true } quand on a besoin de result.fields[]) — la frontiere secu est le role PG (grants DevOps), pas de parsing SQL en code ; erreurs pg (DatabaseError) → 400 invalid-segment-sql avec le message pg (debug operateur), jamais 500 ; SQL operateur toujours encapsule en derived table, parenthese fermante sur sa propre ligne (un -- commentaire final la commenterait)

Domain documentation maintenance

Module docs/index.md : métier et modèle (invariants, qui peut modifier quoi, ce qui se fige). Pas de doc technique (routes, codes d’erreur, max lengths) — descriptors et schémas suffisent.

When modifying business logic in __new/modules/*/business/ or */service/ : - If domain-map.mdc needs updating (new bounded context, changed business rules, new workflow), update it - If an AGENTS.md exists in the module, update it if business rules change - If no AGENTS.md exists and the change is significant, propose creating one