Chapitres

DOC-05 / Référence technique · Chapitre 02

La couche données

Une base PostgreSQL unique, trois chemins d'accès, et des conventions de nommage qui font tenir tout l'édifice.

Cette page décrit comment le harness agentique du Synedre OS lit et écrit ses données : une base PostgreSQL unique, un adapter qui traduit le SQL legacy MySQL→PostgreSQL côté Nuxt, des classes Entity Python côté outillage agentique, Drizzle ORM comme schema-as-code pour le DDL et les migrations, et les conventions de nommage (préfixes de tables, i18n _lang, polymorphisme) qui font tenir le tout. Elle est destinée aux ingénieurs qui reprennent le code.

La base unique

Toute la donnée du vaisseau-mère vit dans un seul schéma PostgreSQL : vaisseau_mere_ac, hébergé dans la base ac_hub, dans un container Docker dédié.

ÉlémentValeur
Container Dockerac_postgres
Baseac_hub (≠ ac_postgres)
Schémavaisseau_mere_ac
User<pg_user>
Mot de passeporté par l'environnement, jamais en clair (requis, sinon erreur)

Attention au piège récurrent : le container s'appelle ac_postgres mais la base s'appelle ac_hub. Les tables ps_ac_* vivent dans le schéma vaisseau_mere_ac, pas public.

Trois chemins d'accès cohabitent — deux à l'exécution (DML : lire/écrire des lignes), un au design (DDL : faire évoluer la structure) :

                  PostgreSQL  ac_postgres / ac_hub
                  schéma  vaisseau_mere_ac
                              │
   ┌───────────────────┬──────┴────────────────┬──────────────────────┐
   │ DML runtime        │ DML outillage         │ DDL / structure       │
   │ (A) Nuxt/Nitro     │ (B) Python agentique  │ (C) Drizzle ORM       │
   │  useClientDb()     │  class *Entity        │  drizzle-kit generate │
   │  → PgAdapterClient │  subprocess docker    │  + migrations SQL     │
   │  postgres-js (TCP) │  exec psql            │                       │
   └────────────────────┴───────────────────────┴──────────────────────┘

Les deux chemins DML parlent au même schéma mais via des transports différents : postgres-js (pool TCP) côté Nuxt, docker exec psql côté Python. Le troisième chemin (C) ne manipule pas de lignes : c'est le DDL en schema-as-code (Drizzle ORM) qui fait foi pour l'évolution structurelle des tables ps_ac_*/cs_*.

État live (compté via information_schema.tables) :

FamilleTablesPérimètre
ps_ac_*~159Legacy privé CodeMyShop/mothership (dette à migrer)
sy_*~69Cockpit Synedre OS (agents, chantiers, runs, négociations…)
ps_*~18PrestaShop natif (dette historique : ps_product, ps_category, ps_translation, ps_lang…)
cs_*~1PaaS public OSS — quasi absent de ce schéma (les cs_* vivent surtout dans les bases tenants)

S'y ajoutent une quinzaine de vues dans vaisseau_mere_ac (point critique : ps_ac_agents est une vue sur sy_agents).

Le décompte exact évolue à chaque chantier ; les chiffres ci-dessus sont un instantané. La requête de vérité est donnée plus bas.

Les familles de tables

Le préfixe d'une table encode son périmètre et son régime de propriété.

PréfixePérimètreStatutExemples
cs_*PaaS public OSS (core + community)ciblecs_faq, cs_homepage_block (dans les bases tenants)
sy_*Synedre OS — cockpit interne agentiqueciblesy_chantier_tache, sy_agents, sy_task_run, sy_negociation
ps_ac_*Legacy privé CodeMyShop / mothershipdette à migrerps_ac_chantier, ps_ac_cicatrices, ps_ac_client_vps
ps_*PrestaShop natifdette historiqueps_product, ps_category_lang, ps_translation

Conventions transverses :

  • Nom singulier (cs_faq, pas cs_faqs) — aligné sur PrestaShop natif.
  • Une table = une entité parente ; pas de table fourre-tout.
  • Fichiers source en kebab-case ; composants Vue en PascalCase.

Historique : le préfixe cs_* résulte d'un rename unique en v0.2.0 depuis ps_ac_* (à l'époque où CodeMyShop était une extension PrestaShop nommée « ac »). Dans le schéma mothership, la plupart des tables sont restées en ps_ac_* (la dette n'a pas encore été soldée), tandis que cs_* est appliqué dans les bases tenants OSS.

Cohabitation des préfixes dans une même entité chantier

Point qui surprend à la reprise : une seule entité logique « chantier » est répartie sur les deux préfixes legacy ET cockpit.

TablePréfixeRôle
ps_ac_chantierps_ac_le chantier (1 ligne par chantier)
ps_ac_chantier_travailps_ac_les travaux granulaires (N par chantier)
sy_chantier_tachesy_les tâches atomiques (N par travail)
sy_chantier_agent, sy_chantier_lock, sy_chantier_qa_runsy_satellites cockpit (équipe, lock multi-session, QA)

Détail révélateur du rename : la table s'appelle sy_chantier_tache, mais sa séquence et sa clé primaire conservent l'ancien nom (cs_chantier_tache_id_tache_seq, cs_chantier_tache_pkey). Le rename a touché le nom de table, pas tous les objets dépendants.

Modèle de données des tables clés

ps_ac_chantier

Clé primaire id_chantier (serial). Colonnes notables :

codename            varchar(64)  NOT NULL   -- kebab-case unique
title               varchar(255) NOT NULL
client_id           varchar(32)             -- NULL = chantier interne
status              varchar(9)   NOT NULL  default 'planning'
priority            varchar(2)   NOT NULL  default 'P2'
current_focus       text
deadline            date
notes / mission_letter / preprod_test_plan  text
external_contacts   text
ship_command        varchar(255)
scope               varchar(32)             -- CHECK enum
auto_explode        boolean NOT NULL default true
mode_auto           boolean NOT NULL default false
max_cost_eur        numeric(8,2)
archived_at / archived_by
date_add / date_upd timestamptz NOT NULL default now()

Contrainte CHECK live sur scope : synedre | codemyshop-oss | codemyshop-enterprise | tenant | business | juridique | negociation | conseil (ou NULL). Cet enum DB est plus large que celui documenté dans la doctrine racine.

Trigger live : tr_chantier_done_cascade_inbox AFTER UPDATE OF status → fonction cascade_resolved_inbox_on_chantier_done() (cascade vers ps_ac_inbox_emails).

sy_chantier_tache

Clé primaire id_tache. Colonnes clés : id_travail (FK logique vers ps_ac_chantier_travail), title, status (default todo), priority (default P2), assignee_codename, estimated_tokens/actual_tokens/actual_cost_usd, recommended_model, position, scope. CHECK sur scope : synedre-internal | codemyshop-oss | codemyshop-enterprise | tenant-single | tenant-multi | infra | doctrine. Référencée par sy_tache_dep (graphe de dépendances inter-tâches).

ps_ac_cicatrices

Le journal des erreurs/leçons (et victoires). Clé primaire id_cicatrice. Colonnes : agent_codename NOT NULL, error_type, description NOT NULL, root_cause, corrected_by, severity (CHECK low|medium|high|critical), kind (default failure ; victory via le skill /victoire), resolved (smallint, CHECK ∈ {0,1,2}), tags text[], importance (1-10), recall_count, learnable. Pas de colonne _lang (contenu interne, non user-facing).

Agents : ps_ac_agents est une VUE sur sy_agents

Piège majeur de reprise. La table physique des agents est sy_agents (clé primaire id_agent, colonnes codename, nickname, role, group_name, active, job_*, cognitive_frame, heritage…). ps_ac_agents n'est pas une table : c'est une vue (SELECT … FROM sy_agents), shim de rétrocompatibilité issu de la migration agents/automates.

La doctrine racine cite encore SELECT … FROM ps_ac_agents WHERE active=1 et le code valide les assignees contre ps_ac_agents — les deux fonctionnent parce que la vue est transparente en lecture. La même mécanique de vue-shim couvre une douzaine d'autres paires :

ps_ac_agents            → sy_agents          ps_ac_automates         → sy_automates
ps_ac_agent_activity    → sy_agent_activity  ps_ac_automate_agents   → sy_automate_agents
ps_ac_agent_heartbeat   → sy_agent_heartbeat ps_ac_automate_conduites→ sy_automate_conduites
ps_ac_agent_relations   → sy_agent_relations ps_ac_automate_logs     → sy_automate_logs
ps_ac_agent_xp[_history]→ sy_agent_xp[...]   ps_ac_smartautomation_* → sy_smartautomation_*

Aux 12 vues-shim s'ajoutent quelques vues analytiques :

  • v_sre_cicatrices_daily — agrège ps_ac_cicatrices des 14 derniers jours par (tenant, jour, severity, kind) avec count(*).
  • v_sre_rollbacks — sur les runs de revue (14 j) par tenant : total runs, nb rollbacks, rollback_rate_pct, nb verdicts NO_GO, dernier run.
  • v_sre_warnings_frequnnest du tableau warnings des runs de revue (30 j) : fréquence par code warning, nb NO_GO, dernière occurrence.
  • cs_errors_unified — union normalisée des erreurs front et back/serveur sous un schéma source commun.
Ces vues sont en lecture seule ; les écritures doivent viser la table sy_* physique. Vérifier avant tout UPDATE ps_ac_agents.

Accès runtime (Nuxt/Nitro) — multi-tenant

resolveClientId(event)useClientDb(event)

useClientDb(event)
   └─ resolveClientId(event)            → string clientId
        1. runtimeConfig.clientId (≠ 'ac-hub')  ← chaque VPS tenant le définit
        2. hostname matching dans CLIENT_DB_MAP ← multi-tenant sur un seul Nuxt
        3. défaut 'ac-hub'
   └─ useClientDbById(clientId)
        └─ shouldDispatchToPg(clientId) ?
             oui → buildPgAdapter(clientId)
             non → throw  (path mysql2 supprimé)

resolveClientId priorise useRuntimeConfig(event).clientId. À défaut, il dérive un mot-clé du hostname et le matche contre les clés de CLIENT_DB_MAP. Fallback final : 'ac-hub'.

useClientDbById(clientId) ne renvoie un adapter que si shouldDispatchToPg() est vrai — c'est-à-dire isDomainOnPg('*') ET le tenant figure dans l'allow-list des tenants migrés. Sinon il throw : le chemin mysql2 a été supprimé.

Porte globale isDomainOnPg('*'). C'est un gate prioritaire : shouldDispatchToPg() renvoie false pour tout le monde (même un tenant listé) tant qu'un flag d'activation global n'est pas positionné. Héritage du déploiement progressif de la migration PostgreSQL : opt-in par domaine au départ, activation globale au cutover. En production le cutover est fait, donc le gate est vrai.

CLIENT_DB_MAP (config DB par tenant)

buildClientDbMap() lit, pour chaque tenant, ses paramètres de connexion depuis une variable d'environnement dédiée (nom dérivé du codename du tenant). getKnownTenantClientIds() renvoie les clés, utilisé pour le broadcast des secrets globaux vers chaque base tenant.

La convention « 1 tenant = 1 codename partout » (une quinzaine de surfaces) impose que resolveClientId() renvoie le codename canonique.

L'adapter postgres-js

Rôle : exposer une interface query/get/run identique à l'ancien useClientDb mysql2, mais dispatcher vers postgres-js (lib postgres) en convertissant à la volée le SQL MySQL legacy.

Le pool

getPgClient() instancie un singleton postgres({...}) :

Hôte, port, utilisateur, base et mot de passe sont lus depuis l'environnement (aucune valeur de production en dur ; le mot de passe est obligatoire, l'instanciation échoue sinon). Le pool lui-même est réglé ainsi :

max=20, idle_timeout=60, max_lifetime=1800, connect_timeout=15

PG_SCHEMA = 'vaisseau_mere_ac' (constante).

convertMysqlToPg(sql) — la traduction

Pipeline de réécriture appliqué à chaque requête, dans l'ordre :

#TransformationDétail
1Backticks → guillemets`col`"col"
2Schema-qualifyaprès FROM/JOIN/INTO/UPDATE/TABLE, ps_xxx et cs_xxxvaisseau_mere_ac.ps_xxx
2b/2cDATE_SUB/DATE_ADD + INTERVAL N UNIT → arithmétique PGgère DAY/MONTH/YEAR/HOUR/MINUTE/SECOND, littéral ET placeholder
2dTIMESTAMPDIFF(unit,a,b)FLOOR(EXTRACT(EPOCH FROM (b-a))/divisor)SECOND/MINUTE/HOUR/DAY
IFNULL(a,b)COALESCE(a,b)PG n'a pas IFNULL
INSERT IGNORE INTO …INSERT … ON CONFLICT DO NOTHINGskip si ON CONFLICT déjà présent
Auto-quote des alias AS fooBarAS "fooBar"préserve la casse. Exception : types natifs PG (TEXT, INTEGER…) laissés tels quels pour ne pas casser CAST(x AS TEXT)
3Placeholders ?$1, $2, …conversion positionnelle, en ignorant les ? à l'intérieur de strings quotées (parseur char-par-char)
La transformation #3 ne fait que renommer les marqueurs (?$N) dans le texte SQL. Le binding réel des valeurs n'est PAS textuel : c'est postgres-js qui binde les params positionnels via sql.unsafe(pgSql, params). Le tableau params reste inchangé — aucune valeur n'est interpolée dans la chaîne SQL (pas de risque d'injection sur les params).

Non géré automatiquement (le caller doit porter à la main) : ON DUPLICATE KEY UPDATE, LAST_INSERT_ID(), GROUP_CONCAT, FIND_IN_SET, DATE_FORMAT, CURDATE(). Quand un endpoint en a besoin, on ajoute une branche onPg() dédiée.

Interface PgAdapterClient et heuristique RETURNING

interface PgAdapterClient {
  clientId: string
  query<T>(sql, params?): Promise<T[]>
  get<T>(sql, params?): Promise<T | null>          // [0] ?? null
  run(sql, params?): Promise<{ affectedRows, insertId }>
}

run() émule l'insertId MySQL : si la requête est un INSERT INTO ps_xxx sans RETURNING ni ON CONFLICT, il ajoute RETURNING id_<entité> (convention PS : strip ps_, et aussi ac_ pour ps_ac_*). Garde-fous : tables à PK composite exclues — COMPOSITE_PK_TABLES (ps_customer_group, ps_category_product, ps_carrier_zone, ps_accessory, ps_ac_category_cross) + toute table suffixée _lang/_shop, car celles-ci n'ont pas de colonne id_<table> unique.

Drizzle ORM — schema-as-code pour le DDL

Les deux chemins précédents (postgres-js côté Nuxt, Python côté outillage) font du DML : ils lisent et écrivent des lignes contre une structure supposée déjà en place. Aucun des deux ne crée ni n'altère de table. La structure (DDL — CREATE TABLE, colonnes, types, index, contraintes) est régie par un troisième chemin : Drizzle ORM, utilisé en mode schema-as-code. Les schémas TypeScript sont la source de vérité déclarée de la structure des tables ps_ac_*/cs_* ; le SQL CREATE/ALTER est généré (ou écrit) puis appliqué à la DB, jamais l'inverse.

Héritage : le dialect était MariaDB avant la bascule PostgreSQL (drop MariaDB → dialect: 'postgresql'). Dépendances : drizzle-orm + drizzle-kit.

drizzle.config.ts

CléValeurNote
dialect'postgresql'ex-MariaDB
schema3 globs : codemyshop/core/server/db/schema-pg/*.ts, mothership-app/modules/*/server/db/*.ts, codemyshop/enterprise/*/modules/*/server/db/*.tsoù vivent les déclarations TS
outcodemyshop/core/server/db/migrationsdossier des migrations SQL générées
schemaFilter['vaisseau_mere_ac']restreint l'introspection/diff à notre schéma
dbCredentialsparamètres de connexion lus depuis l'environnementvaleurs portées par l'environnement, jamais en clair
strict / verbosetrue / trueconfirmation avant push, sortie détaillée
Le port par défaut de ce config diffère de celui de l'adapter postgres-js / du pattern Python (qui ciblent le container ac_postgres directement). drizzle-kit est pensé pour pointer une DB exposée en TCP, pas pour passer par docker exec.

Commandes drizzle-kit

CommandeEffet
npx drizzle-kit generatediff schémas TS ↔ état connu → écrit une nouvelle migration SQL dans out/
npx drizzle-kit migrateapplique les migrations en attente à la DB de dbCredentials
npx drizzle-kit introspectreverse-engineer une DB existante → fichiers TS (utile pour adopter une table legacy non encore déclarée)

Les schémas TS

Chaque fichier déclare une ou plusieurs tables via pgSchema('vaisseau_mere_ac').table('<nom_table>', { ... }). L'inventaire vit dans codemyshop/core/server/db/schema-pg/ (dont des bridge re-exports d'une ligne vers codemyshop/runtime-base/server/db/schema-pg/ issus de l'extraction OSS), plus un fichier côté mothership qui définit la vraie table mothership.

Ce que les schémas TS typent : nom physique de colonne, type PG, notNull, default/defaultNow, primaryKey (simple ou composite), unique, index. Les types métier sont raffinés via $type<...>() (typage TS sans contrainte DB). Exemple ps_ac_module_registry (source de vérité runtime PS/Nuxt par module et par tenant) :

export const vaisseauMereAcSchema = pgSchema('vaisseau_mere_ac')
export type Runtime      = 'ps' | 'nuxt'
export type ModuleStatus = 'active' | 'disabled' | 'deprecated'

export const moduleRegistryVaisseau = vaisseauMereAcSchema.table('ps_ac_module_registry', {
  idModuleRegistry: serial('id_module_registry').primaryKey(),
  codename: varchar('codename', { length: 128 }).notNull().unique(),
  version:  varchar('version',  { length: 32 }).notNull(),
  runtime:  varchar('runtime', { length: 4 }).$type<Runtime>().notNull().default('ps'),
  status:   varchar('status',  { length: 10 }).$type<ModuleStatus>().notNull().default('active'),
  manifestJson: text('manifest_json').$type<ModuleManifest | null>(),
  // … schemaHash, lastMigratedAt, dateAdd, dateUpd
}, (t) => ({ kRuntimeStatus: index('idx_runtime_status').on(t.runtime, t.status) }))

export type ModuleRegistryPgRow    = typeof moduleRegistryVaisseau.$inferSelect
export type ModuleRegistryPgInsert = typeof moduleRegistryVaisseau.$inferInsert
  • Les ENUM MariaDB ont été portés en varchar(N) + $type<Union>() (contrainte au niveau TS, pas de type ENUM PG).
  • La convention _lang à PK composite se traduit en primaryKey({ columns: [t.idFaq, t.idLang] }).
  • Les $inferSelect/$inferInsert exportés donnent les types de ligne consommables côté code (mais le runtime Nuxt passe par l'adapter postgres-js, pas par le query-builder Drizzle — ici Drizzle ne sert qu'au DDL et au typage).

Les migrations générées et l'application réelle

Fait structurant pour la reprise : le journal de migration (meta/_journal.json) contient une liste d'entrées vide. Le tracking d'application automatique de drizzle-kit migrate n'est donc pas la voie opérante. Les fichiers SQL de out/ sont en réalité écrits idempotents à la main (CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS) et appliqués manuellement par tenant via un psql en stdin sur chaque base concernée. En pratique : Drizzle/le schéma TS = source de vérité déclarative ; l'application réelle reste un psql -f/stdin par tenant, jamais auto-propagé.

Garde-fou anti-dérive TS ↔ DB live

Comme l'application des migrations est manuelle et multi-tenant, un ALTER appliqué sur une base mais oublié sur une autre partirait en production silencieusement. Un automate d'audit ferme ce trou :

  • il lit codemyshop/core/server/db/schema-pg/*.ts et extrait {table → {colonnes}} par regex, en strippant d'abord les commentaires.
  • il interroge information_schema.columns de chaque tenant (WHERE table_schema='vaisseau_mere_ac' AND (table_name LIKE 'ps\_ac\_%' OR LIKE 'cs\_%')), via docker exec local pour le vaisseau-mère, via SSH + docker exec pour les tenants distants.
  • Diff par tenant : bloquant = table ou colonne déclarée en TS mais absente de PG (ALTER manquante) ; info = présent en PG mais pas en TS.
  • Faux positifs filtrés par un fichier d'ignore : tables mothership-only à ignorer sur les tenants, et tables boutique OSS sorties de la base mothership (ignorées uniquement sur la cible mothership).

Exit codes : 0 pas de drift bloquant, 1 drift bloquant, 2 erreur d'exécution.

Gate de déploiement. L'audit est câblé bloquant dans le pipeline de deploy : avec mode strict par défaut, le deploy s'arrête avant de pousser un artefact dont le code attend des colonnes que la DB tenant n'a pas. C'est un check pré-deploy, pas un cron périodique.

Qui fait foi pour l'évolution structurelle

schema-pg/<entity>.ts        ← SOURCE DE VÉRITÉ déclarative du DDL (ps_ac_*/cs_*)
   │  (1) on édite le TS
   ▼
migration SQL out/ (manuelle, idempotente)
   │  (2) psql -f / stdin sur CHAQUE tenant impacté
   ▼
PG live (vaisseau-mère + chaque tenant)
   ▲
   └─ (3) l'audit vérifie TS == live, bloque le deploy si drift

Règle de reprise : pour faire évoluer une table ps_ac_*/cs_*, on édite d'abord le schéma TS, on génère/écrit la migration idempotente, on l'applique par tenant, puis on relance l'audit (et de toute façon le deploy le rejoue). Ne jamais ALTER la DB live sans répercuter le TS. Inversement, les classes Entity Python ne créent jamais de colonne : leur tuple fields est une whitelist de colonnes supposées déjà présentes, pas une déclaration de structure.

Accès agentique (Python) — le pattern Entity

Fichier socle : synedre/ac_entities/base.py. Une quarantaine de classes *Entity en héritent (chantier.py, cicatrice.py, agent.py, doctrine.py, client_vps.py…).

Transport : docker exec psql

Contrairement au runtime Nuxt (pool TCP), le Python tape la DB via subprocess docker exec … psql. Trois helpers :

HelperUsageNote
_run_sql_write(sql)écrituresécrit le SQL dans un fichier temp, le docker cp dans le container (chemin unique pid+uuid pour éviter une race multi-process), puis psql -f <fichier> avec ON_ERROR_STOP=1 — pas de pipe stdin
_run_sql_read(sql)lectures tab-separatedSQL inline via psql -c ; ne pas utiliser sur colonnes TEXT à newlines
_run_sql_csv(sql)lectures avec newlinesSQL inline via psql -c, mode --csv (RFC 4180), NULL → ''

Toutes préfixent SET search_path TO vaisseau_mere_ac, public;. DB_NAME = PG_SCHEMA (alias rétrocompat : en PG le « préfixe de table » legacy devient le schéma).

La classe Entity

class Entity:
    table: str          # ex "ps_ac_chantier"
    pk: str             # ex "id_chantier"
    fields: tuple       # whitelist de colonnes acceptées en INSERT/UPDATE
    canonical_client = "ac-hub"
  • create(data) : validate() → force client_id = canonical_client si la colonne est whitelistée → filtre sur fieldsINSERT … (cols, date_add, date_upd) VALUES (…, NOW(), NOW()) RETURNING <pk>.
  • update(pk, data) : filtre sur fields, ajoute date_upd=NOW().
  • find/find_one/exists/delete. Échappement SQL via _esc() — booléens → TRUE/FALSE, dict/list → json.dumps.

Les sous-classes overrident validate(data, mode) pour les règles métier (ValidationError bloquante + warnings non-bloquants).

ChantierEntity.create_with_skeleton(...)

Crée atomiquement chantier + premier travail + ≥1 tâche(s), pour interdire les chantiers orphelins. Signature (extrait) :

ChantierEntity().create_with_skeleton(
    codename, title, *,
    first_travail: dict,                 # {codename, title, [priority, current_phase, ...]}
    first_taches: list[dict] | None,     # doctrine v3 — préféré
    first_tache: dict | None,            # singulier — backward compat
    client_id=None, priority="P2", scope=None, current_focus=None, notes=None,
) -> {id_chantier, id_travail, id_tache, id_taches, codenames, warnings}

Validations bloquantes (lèvent ValidationError) :

  • codename kebab-case (4-64 chars) et unique.
  • priority ∈ {P0,P1,P2,P3}.
  • chaque tâche : title + assignee_codename non vide, et l'assignee doit exister dans ps_ac_agents (la vue).
  • recrutement multi-agents : si scope commence par tenant, exige ≥2 assignees distincts.

Warnings non bloquants : estimated_tokens manquant par tâche.

Exécution : une seule transaction BEGIN; … COMMIT; — INSERT chantier (ps_ac_chantier), puis travail (ps_ac_chantier_travail, lookup id_chantier via SELECT … WHERE codename=), puis chaque tâche (sy_chantier_tache, lookup id_travail). ON_ERROR_STOP=1 garantit le rollback si une INSERT échoue. recommended_model est auto-calculé par tâche si absent.

TacheEntity — estimation, modèle, skills/outils

  • create() override : si estimated_tokens absent, appelle l'estimateur de tokens ; valide scopeVALID_SCOPES ; warn si l'assignee n'est pas dans l'équipe production du chantier (sy_chantier_agent, garde-fou non bloquant).
  • recommend_model_for(tokens, priority, fail_recurrent) : heuristique — modèle haut de gamme si P0 ou échec récurrent (≥2 itérations test_result='fail') ou ≥8000 tokens ; modèle intermédiaire si ≥1500 ; sinon modèle léger.
  • attach_skill(id_tache, skill_name) : lookup sy_skill.name → INSERT sy_tache_skill (ON CONFLICT DO NOTHING). Si skill inconnu → INSERT sy_skill_proposal (status pending) et retourne False.
  • attach_tool(id_tache, tool_slug) : lookup sy_chantier_tool.slug → INSERT sy_tache_tool. Si inconnu → sy_skill_proposal + False.

Cascades de statut (Active Record orchestrant)

Les update() de TacheEntity et TravailEntity portent une cascade automatique :

tâche → done/cancelled (toutes les tâches du travail terminales, ≥1 done)
   └─ TacheEntity._cascade_to_travail
        ├─ QA team recrutée (role IN ('validation','lead_validation')) ? → qa_run() applique le verdict
        └─ sinon → travail = 'done'
travail → done/cancelled (tous les travaux du chantier terminaux, ≥1 done)
   └─ TravailEntity._cascade_to_chantier
        ├─ garde-fou discovery-only : tous done en phase 'discovery' → auto-explode au lieu de promouvoir
        ├─ sinon → chantier = 'test' (preprod, attente review) — cascade max 1 cran
        └─ warn non bloquant si preprod_test_plan / ship_command manquants

Cran additionnel : un travail-bis avec resolves_travail_id NOT NULL qui passe done ferme automatiquement le travail paused parent (paused→done, tâches todocancelled, append decisions_json).

Requête de vérité (compter les familles)

docker exec ac_postgres psql -U <pg_user> -d ac_hub -c "\dt vaisseau_mere_ac.*"
docker exec ac_postgres psql -U <pg_user> -d ac_hub -tA -c \
  "SELECT pg_get_viewdef('vaisseau_mere_ac.ps_ac_agents'::regclass, true);"

Conventions i18n et polymorphisme

Ces conventions valent pour cs_* (OSS) et sont suivies par les ps_ac_*/ps_* legacy.

Tables _lang

Tout texte visible par un visiteur vit dans une table sœur <entité>_lang, jamais dans la table parente.

  • Suffixe exactement _lang (pas _translation, _i18n, _locale).
  • PK composite (id_<entité>, id_lang), sans auto-increment ; multi-shop → (id_<entité>, id_lang, id_shop), jamais de table _shop_lang séparée.
  • Séparation stricte : la parente porte FK/flags/dates/enums ; le _lang porte title, description, meta_* et tout texte user-visible.

Exemple live ps_ac_faq_lang : (id_faq, id_lang, question varchar, answer text).

Cette convention a un impact direct sur l'adapter : les tables _lang sont exclues de l'heuristique RETURNING id_<entité> car elles n'ont pas de PK simple. Côté front, les chaînes passent par t('domain.key', 'fallback') (lit ps_translation) ; règle P0 anti-i18n hardcodé.

Polymorphisme parent_type / parent_id

Quand une feature s'applique à plusieurs entités parentes (FAQ pour CMS et catégorie et produit), une seule table polymorphique — jamais une par parent.

-- live : ps_ac_faq
id_faq       integer PK
parent_type  varchar       -- 'cms' | 'category' | 'product'
parent_id    integer
position / active / date_add / date_upd
-- + ps_ac_faq_lang (id_faq, id_lang, question, answer)

Exception : extension 1:1 d'une entité PrestaShop native → pattern cs_<ps_entity>_extra (PK = id_<ps_entity>, aussi FK vers ps_<ps_entity>), sans polymorphisme. Exemples live : ps_ac_customer_extra, ps_ac_cms_extra, ps_ac_employee_extra. Tables N-N pures : cs_<a>_<b> en ordre alphabétique, sans suffixe _asso/_link, sans _lang.

Pas de JSON métier en colonne

Colonnes *_json de contenu interdites (payload_json, content_i18n, labels_json…). Seule tolérance : payloads techniques éphémères (webhooks, logs, état de session) documentés par COMMENT ON COLUMN. Dans le cockpit, ps_ac_chantier_travail.context_json/decisions_json/discoveries_json et divers sy_* usent du JSON pour de l'audit-trail technique (append-only), pas pour du contenu user-facing — c'est la tolérance, pas la règle. Un audit de schéma (cron) flagge les violations en P0.

Pièges de reprise

  1. ps_ac_agents est une vue sur sy_agents — lire via la vue, écrire via la table physique sy_*. Idem pour 11 autres paires (12 vues-shim ps_ac_*sy_* au total).
  2. Container ac_postgres ≠ base ac_hub ≠ schéma vaisseau_mere_ac.
  3. L'adapter Nuxt throw pour tout tenant non migré PG (path mysql2 supprimé). Un tenant doit figurer dans l'allow-list des tenants migrés.
  4. L'enum scope en DB est plus large que la doctrine écrite — la contrainte CHECK fait foi.
  5. sy_chantier_tache a gardé séquence/PK nommées cs_chantier_tache_* (résidu de rename).
  6. Le SQL legacy MySQL passe par convertMysqlToPg() ; les fonctions non couvertes (GROUP_CONCAT, DATE_FORMAT, ON DUPLICATE KEY UPDATE…) cassent silencieusement et exigent une branche onPg() manuelle.