Chapitres
Sur cette page
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ément | Valeur |
|---|---|
| Container Docker | ac_postgres |
| Base | ac_hub (≠ ac_postgres) |
| Schéma | vaisseau_mere_ac |
| User | <pg_user> |
| Mot de passe | porté 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) :
| Famille | Tables | Périmètre |
|---|---|---|
ps_ac_* | ~159 | Legacy privé CodeMyShop/mothership (dette à migrer) |
sy_* | ~69 | Cockpit Synedre OS (agents, chantiers, runs, négociations…) |
ps_* | ~18 | PrestaShop natif (dette historique : ps_product, ps_category, ps_translation, ps_lang…) |
cs_* | ~1 | PaaS 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éfixe | Périmètre | Statut | Exemples |
|---|---|---|---|
cs_* | PaaS public OSS (core + community) | cible | cs_faq, cs_homepage_block (dans les bases tenants) |
sy_* | Synedre OS — cockpit interne agentique | cible | sy_chantier_tache, sy_agents, sy_task_run, sy_negociation |
ps_ac_* | Legacy privé CodeMyShop / mothership | dette à migrer | ps_ac_chantier, ps_ac_cicatrices, ps_ac_client_vps |
ps_* | PrestaShop natif | dette historique | ps_product, ps_category_lang, ps_translation |
Conventions transverses :
- Nom singulier (
cs_faq, pascs_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.
| Table | Préfixe | Rôle |
|---|---|---|
ps_ac_chantier | ps_ac_ | le chantier (1 ligne par chantier) |
ps_ac_chantier_travail | ps_ac_ | les travaux granulaires (N par chantier) |
sy_chantier_tache | sy_ | les tâches atomiques (N par travail) |
sy_chantier_agent, sy_chantier_lock, sy_chantier_qa_run… | sy_ | 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ègeps_ac_cicatricesdes 14 derniers jours par (tenant, jour, severity, kind) aveccount(*).v_sre_rollbacks— sur les runs de revue (14 j) par tenant : total runs, nb rollbacks,rollback_rate_pct, nb verdictsNO_GO, dernier run.v_sre_warnings_freq—unnestdu tableauwarningsdes runs de revue (30 j) : fréquence par code warning, nbNO_GO, dernière occurrence.cs_errors_unified— union normalisée des erreurs front et back/serveur sous un schémasourcecommun.
Ces vues sont en lecture seule ; les écritures doivent viser la tablesy_*physique. Vérifier avant toutUPDATE 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 globaleisDomainOnPg('*'). C'est un gate prioritaire :shouldDispatchToPg()renvoiefalsepour 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 :
| # | Transformation | Détail |
|---|---|---|
| 1 | Backticks → guillemets | `col` → "col" |
| 2 | Schema-qualify | après FROM/JOIN/INTO/UPDATE/TABLE, ps_xxx et cs_xxx → vaisseau_mere_ac.ps_xxx |
| 2b/2c | DATE_SUB/DATE_ADD + INTERVAL N UNIT → arithmétique PG | gère DAY/MONTH/YEAR/HOUR/MINUTE/SECOND, littéral ET placeholder |
| 2d | TIMESTAMPDIFF(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 NOTHING | skip si ON CONFLICT déjà présent |
| — | Auto-quote des alias AS fooBar → AS "fooBar" | préserve la casse. Exception : types natifs PG (TEXT, INTEGER…) laissés tels quels pour ne pas casser CAST(x AS TEXT) |
| 3 | Placeholders ? → $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'estpostgres-jsqui binde lesparamspositionnels viasql.unsafe(pgSql, params). Le tableauparamsreste 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é | Valeur | Note |
|---|---|---|
dialect | 'postgresql' | ex-MariaDB |
schema | 3 globs : codemyshop/core/server/db/schema-pg/*.ts, mothership-app/modules/*/server/db/*.ts, codemyshop/enterprise/*/modules/*/server/db/*.ts | où vivent les déclarations TS |
out | codemyshop/core/server/db/migrations | dossier des migrations SQL générées |
schemaFilter | ['vaisseau_mere_ac'] | restreint l'introspection/diff à notre schéma |
dbCredentials | paramètres de connexion lus depuis l'environnement | valeurs portées par l'environnement, jamais en clair |
strict / verbose | true / true | confirmation 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 containerac_postgresdirectement). drizzle-kit est pensé pour pointer une DB exposée en TCP, pas pour passer pardocker exec.
Commandes drizzle-kit
| Commande | Effet |
|---|---|
npx drizzle-kit generate | diff schémas TS ↔ état connu → écrit une nouvelle migration SQL dans out/ |
npx drizzle-kit migrate | applique les migrations en attente à la DB de dbCredentials |
npx drizzle-kit introspect | reverse-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 enprimaryKey({ columns: [t.idFaq, t.idLang] }). - Les
$inferSelect/$inferInsertexporté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/*.tset extrait{table → {colonnes}}par regex, en strippant d'abord les commentaires. - il interroge
information_schema.columnsde chaque tenant (WHERE table_schema='vaisseau_mere_ac' AND (table_name LIKE 'ps\_ac\_%' OR LIKE 'cs\_%')), viadocker execlocal pour le vaisseau-mère, via SSH +docker execpour 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 :
| Helper | Usage | Note |
|---|---|---|
_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-separated | SQL inline via psql -c ; ne pas utiliser sur colonnes TEXT à newlines |
_run_sql_csv(sql) | lectures avec newlines | SQL 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()→ forceclient_id = canonical_clientsi la colonne est whitelistée → filtre surfields→INSERT … (cols, date_add, date_upd) VALUES (…, NOW(), NOW()) RETURNING <pk>.update(pk, data): filtre surfields, ajoutedate_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) :
codenamekebab-case (4-64 chars) et unique.priority∈ {P0,P1,P2,P3}.- chaque tâche :
title+assignee_codenamenon vide, et l'assignee doit exister dansps_ac_agents(la vue). - recrutement multi-agents : si
scopecommence partenant, 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 : siestimated_tokensabsent, appelle l'estimateur de tokens ; validescope∈VALID_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érationstest_result='fail') ou ≥8000 tokens ; modèle intermédiaire si ≥1500 ; sinon modèle léger.attach_skill(id_tache, skill_name): lookupsy_skill.name→ INSERTsy_tache_skill(ON CONFLICT DO NOTHING). Si skill inconnu → INSERTsy_skill_proposal(statuspending) et retourneFalse.attach_tool(id_tache, tool_slug): lookupsy_chantier_tool.slug→ INSERTsy_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 todo→cancelled, 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_langséparée. - Séparation stricte : la parente porte FK/flags/dates/enums ; le
_langportetitle,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
ps_ac_agentsest une vue sursy_agents— lire via la vue, écrire via la table physiquesy_*. Idem pour 11 autres paires (12 vues-shimps_ac_*→sy_*au total).- Container
ac_postgres≠ baseac_hub≠ schémavaisseau_mere_ac. - 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.
- L'enum
scopeen DB est plus large que la doctrine écrite — la contrainte CHECK fait foi. sy_chantier_tachea gardé séquence/PK nomméescs_chantier_tache_*(résidu de rename).- Le SQL legacy MySQL passe par
convertMysqlToPg(); les fonctions non couvertes (GROUP_CONCAT,DATE_FORMAT,ON DUPLICATE KEY UPDATE…) cassent silencieusement et exigent une brancheonPg()manuelle.