marius-schema 0.3.0

Generated #[repr(C)] structs and Collector statics mirroring PostgreSQL schema for the Marius engine.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
// =============================================================================
// DB-Forge — crates/core/schema/build.rs
// Projet Marius · ADR-002 / ADR-003 / Codegen.md / no_std-attitude-within-marius.md
//
// Génère dans $OUT_DIR/generated_schema.rs :
//   - Une struct {Name}Row          : désérialisation sqlx (Option<T> pour nullable)
//   - Une struct {Name}StorageRow   : stockage repr(C) (sentinel pour nullable fixe)
//   - Une struct {Name}VarlenOwned  : données varlena possédées (Option<String>, 'static)
//                                     Absente si pas de varlena (type VarlenOwned = ())
//   - From<{Name}Row> for {Name}StorageRow
//   - static {NAME}_COLLECTOR: Collector<MAX, WORDS>
//   - impl Projection stub (fetch_batch + render + artifact_path)
//
// ─── Philosophie Miroir Binaire ───────────────────────────────────────────────
//
//   PostgreSQL est la Source de Vérité Unique (SoT).
//   Ce script lit le catalogue système (pg_attribute, pg_constraint,
//   information_schema, pg_description, pg_stats) et produit des structs
//   Rust alignées bit-à-bit sur le DDL.
//
//   Toute divergence entre le layout PostgreSQL et la struct Rust est une
//   bombe à retardement : panique au runtime pour Phase 1, corruption
//   silencieuse pour Phase 2 (mmap / SHM). Les static_assertions émises
//   ici font exploser la bombe au moment de la compilation.
//
// ─── Taxonomie des structs générées (ADR-003) ────────────────────────────────
//
//   {Name}Row           : sqlx::FromRow, non-repr(C).
//                         Types natifs Rust + Option<T> pour nullable.
//                         Varlena : Option<String> (allocation sqlx).
//                         Durée de vie : éphémère (consommée dans fetch_batch).
//
//   {Name}StorageRow    : #[repr(C)], layout aligné sur le DDL PostgreSQL.
//                         Types fixed-length uniquement. Nullable → sentinel.
//                         Varlena exclues (fat pointer 16B briserait repr(C)).
//                         Durée de vie : persistante.
//
//   {Name}VarlenOwned   : struct possédée, Send + 'static.
//                         Champs varlena : Option<String>.
//                         Peut traverser tokio::spawn et rayon::par_iter.
//                         render() reconstruit les &str localement via as_deref().
//                         Absente si la table n'a pas de JOIN varlena → type = ().
//
// ─── Séquence de génération par table ────────────────────────────────────────
//
//   1. fetch_columns         : colonnes fixed + varlena depuis pg_attribute.
//   2. fetch_pk_column       : PK depuis information_schema (Single ou Composite).
//   3. fetch_max_id          : MAX(pk_col) → dimensionnement Collector.
//   4. fetch_varlena_cols    : colonnes varlena depuis la table jointe
//                               (max_len, is_pre_escaped, avg_width).
//   5. write_row_struct      : {Name}Row.
//   6. write_store_struct    : {Name}StorageRow + static_assertions layout.
//   7. write_varlen_owned_struct : {Name}VarlenOwned (ou commentaire si vide).
//   8. write_from_impl       : From<{Name}Row> for {Name}StorageRow.
//   9. write_collector       : Collector<MAX, WORDS> statique.
//  10. write_projection_stub : impl Projection
//                               (type VarlenOwned, fetch_batch tuple, render, artifact_path).
//
// Prérequis : DATABASE_URL pointe vers marius avec rôle marius_admin.
// Exécution : automatique via `cargo build`.
// =============================================================================

use std::env;
use std::fmt::Write as FmtWrite;
use std::fs;
use std::path::PathBuf;

// sqlx::Row : nécessaire pour .get::<T, _>(index) sur les résultats non-macro.
// L'API non-macro est obligatoire dans un build.rs : sqlx::query!() requiert
// une connexion active au moment de la compilation du crate principal,
// ce qui est incompatible avec l'exécution séquentielle du build script.
use sqlx::Row;

// Fragment-Forge : génération des corps render() + calcul capacité statique.
// VarlenField et generated_file_header() sont nouveaux par rapport au build.rs nominal.
use marius_fragment_forge::{
    FieldSpec, FieldKind, VarlenField,
    generate_render, generate_capacity_consts, generated_file_header,
};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Invalider le cache Cargo si DATABASE_URL ou ce fichier changent.
    // Sans ces directives, Cargo ne ré-exécuterait pas le build script
    // lors d'une modification du schéma PostgreSQL.
    println!("cargo:rerun-if-env-changed=DATABASE_URL");
    println!("cargo:rerun-if-changed=build.rs");

    let database_url = env::var("DATABASE_URL")
        .expect("DB-Forge : DATABASE_URL non définie.");

    let pool = sqlx::PgPool::connect(&database_url).await?;

    // -------------------------------------------------------------------------
    // Tables surveillées avec JOIN varlena optionnel.
    //
    // Format : (schema, table, Option<(join_schema, join_table, fk_col)>)
    //
    //   content.core          : PK=document_id, 3 nullable, RLS activé.
    //                           JOIN sur content.identity (champs texte auteur).
    //   commerce.product_core : PK=id (attnum=2, pas attnum=1), 2 nullable.
    //                           Pas de table jointe (varlena = []).
    //
    // Extension future : lire cette liste depuis meta.containment_intent
    // pour piloter la Forge entièrement depuis le DDL.
    // -------------------------------------------------------------------------
    // Alias local pour le type de la table de surveillance.
    // Élimine le warning clippy::type_complexity sur ce site.
    // Format : (schema, table, Option<(join_schema, join_table, fk_col)>)
    type WatchedTable = (&'static str, &'static str, Option<(&'static str, &'static str, &'static str)>);
    let watched: &[WatchedTable] = &[
        ("content",  "core",         Some(("content", "identity", "document_id"))),
        ("commerce", "product_core", None),
    ];

    let out_dir  = PathBuf::from(env::var("OUT_DIR")?);
    let out_path = out_dir.join("generated_schema.rs");

    // En-tête généré par Fragment-Forge.
    // Contient : avertissement "NE PAS MODIFIER", import PathBuf,
    // et la fonction marius_html_escape() inline (zéro dépendance externe).
    let mut output = String::from(generated_file_header());

    for &(schema, table, varlena_join) in watched {
        let columns = fetch_columns(&pool, schema, table).await?;
        let pk      = fetch_pk_column(&pool, schema, table).await?;
        let max_id  = match &pk {
            PrimaryKey::Single(col) => Some(fetch_max_id(&pool, schema, table, col).await?),
            PrimaryKey::Composite   => None,
        };

        // Colonnes varlena depuis la table jointe (si définie).
        // Si varlena_join est None → varlena reste vide, aucun champ varlena généré.
        let varlena: Vec<VarlenField> = if let Some((vs, vt, _fk)) = varlena_join {
            fetch_varlena_cols(&pool, vs, vt).await?
        } else {
            vec![]
        };

        write_section_header(&mut output, schema, table, &pk);
        write_row_struct(&mut output, schema, table, &columns, &varlena);
        write_store_struct(&mut output, schema, table, &columns);
        write_varlen_owned_struct(&mut output, schema, table, &varlena);
        write_from_impl(&mut output, schema, table, &columns);

        if let (PrimaryKey::Single(col), Some(max)) = (&pk, max_id) {
            write_collector(&mut output, schema, table, col, max);
        }

        write_projection_stub(&mut output, schema, table, &columns, &pk, &varlena, varlena_join);
    }

    fs::write(&out_path, &output)?;
    eprintln!("DB-Forge : généré → {}", out_path.display());
    Ok(())
}

// =============================================================================
// Types internes
// =============================================================================

/// Colonne issue de pg_attribute.
#[derive(Debug)]
struct Column {
    /// Numéro d'attribut physique (ORDER BY attnum = invariant de Symétrie Mécanique).
    attnum:     i16,
    name:       String,
    /// Type SQL normalisé par format_type() (ex: "character varying(255)").
    sql_type:   String,
    /// true si la colonne a une contrainte NOT NULL dans le DDL.
    is_notnull: bool,
}

/// Clé primaire d'une table.
#[derive(Debug)]
enum PrimaryKey {
    /// PK sur une colonne unique → Collector applicable.
    Single(String),
    /// PK composée → Collector N/A (bit-vector sur domaine entier non applicable).
    Composite,
}

// =============================================================================
// I. Introspection — colonnes fixed-length
// =============================================================================

/// Colonnes dans l'ordre physique du heap (attnum ASC).
///
/// ORDER BY attnum est l'invariant de Symétrie Mécanique : il garantit que
/// l'ordre des champs dans {Name}StorageRow (#[repr(C)]) correspond exactement
/// à l'ordre des colonnes dans le heap tuple PostgreSQL.
/// Toute déviation produirait un désalignement silencieux en Phase 2 (mmap).
async fn fetch_columns(
    pool:   &sqlx::PgPool,
    schema: &str,
    table:  &str,
) -> Result<Vec<Column>, sqlx::Error> {
    // API non-macro : pas de vérification compile-time, DATABASE_URL non requise
    // pendant la phase de build du build script.
    // format_type() normalise les types avec précision (ex: "character varying(255)").
    let rows = sqlx::query(
        "SELECT
             a.attnum::smallint,
             a.attname::text,
             format_type(a.atttypid, a.atttypmod),
             a.attnotnull
         FROM  pg_attribute  a
         JOIN  pg_class      c ON a.attrelid = c.oid
         JOIN  pg_namespace  n ON c.relnamespace = n.oid
         WHERE n.nspname     = $1
           AND c.relname     = $2
           AND a.attnum      > 0        -- exclut les colonnes système (attnum <= 0)
           AND NOT a.attisdropped       -- exclut les colonnes supprimées (ALTER TABLE DROP)
         ORDER BY a.attnum              -- invariant de Symétrie Mécanique",
    )
    .bind(schema)
    .bind(table)
    .fetch_all(pool)
    .await?;

    Ok(rows.into_iter().map(|r| Column {
        attnum:     r.get::<i16,    _>(0),
        name:       r.get::<String, _>(1),
        sql_type:   r.get::<String, _>(2),
        is_notnull: r.get::<bool,   _>(3),
    }).collect())
}

// =============================================================================
// I-b. Introspection — clé primaire
// =============================================================================

/// Identifie la PK via information_schema (évite les subtilités de pg_catalog).
///
/// Retourne Single(col) si PK sur une colonne, Composite sinon.
/// Une PK Composite rend le Collector inapplicable : un bit-vector indexé
/// par ID entier suppose un domaine ID contigu sur une seule colonne i64.
async fn fetch_pk_column(
    pool:   &sqlx::PgPool,
    schema: &str,
    table:  &str,
) -> Result<PrimaryKey, sqlx::Error> {
    // fetch_all + match sur longueur remplace fetch_one pour éviter RowNotFound
    // quand la table n'a pas de PK (situation possible en DDL PostgreSQL).
    let rows = sqlx::query(
        "SELECT kcu.column_name::text
         FROM   information_schema.table_constraints  tc
         JOIN   information_schema.key_column_usage   kcu
                ON  kcu.constraint_name = tc.constraint_name
                AND kcu.table_schema    = tc.table_schema
                AND kcu.table_name      = tc.table_name
         WHERE  tc.table_schema     = $1
           AND  tc.table_name       = $2
           AND  tc.constraint_type  = 'PRIMARY KEY'
         ORDER BY kcu.ordinal_position",
    )
    .bind(schema)
    .bind(table)
    .fetch_all(pool)
    .await?;

    match rows.len() {
        0 => {
            eprintln!("DB-Forge [{schema}.{table}] : aucune PK trouvée — traité comme Composite.");
            Ok(PrimaryKey::Composite)
        }
        1 => Ok(PrimaryKey::Single(rows[0].get::<String, _>(0))),
        n => {
            eprintln!("DB-Forge [{schema}.{table}] : PK composite ({n} colonnes) — Collector ignoré.");
            Ok(PrimaryKey::Composite)
        }
    }
}

// =============================================================================
// I-c. Introspection — dimensionnement du Collector
// =============================================================================

/// MAX(pk_col) + marge 20% + arrondi power-of-two en words (blocs de 64 bits).
///
/// ─── Calcul ──────────────────────────────────────────────────────────────────
///
///   max_id        = MAX(pk_col) observé en base (0 si table vide).
///   with_margin   = ceil(max_id × 1.20)  — absorbe la croissance courte-terme.
///   words_needed  = ceil(max_id / 64)    — nombre de mots u64 minimum.
///   words_aligned = next_power_of_two()  — alignement pour SIMD (TZCNT).
///   max_entity_id = words_aligned × 64  — borne du domaine IDs.
///
///   Le Collector<MAX, WORDS> généré aura MAX = max_entity_id, WORDS = words_aligned.
///
/// Note : la colonne PK n'est pas nécessairement nommée 'id'.
/// Ex : document_id sur content.core (attnum=1, pas 'id').
async fn fetch_max_id(
    pool:    &sqlx::PgPool,
    schema:  &str,
    table:   &str,
    pk_col:  &str,
) -> Result<usize, Box<dyn std::error::Error>> {
    // format! obligatoire : sqlx ne supporte pas l'interpolation d'identifiants SQL.
    // Risque injection nul : pk_col est issu de pg_constraint (catalogue système).
    let query = format!(
        "SELECT COALESCE(MAX({pk_col}), 0)::BIGINT FROM {schema}.{table}"
    );

    let max_id: i64 = sqlx::query_scalar::<_, i64>(&query)
        .fetch_one(pool)
        .await
        .unwrap_or(0);

    let with_margin   = (max_id as f64 * 1.20).ceil() as usize;
    let words_needed  = with_margin.max(64).div_ceil(64);
    let words_aligned = words_needed.next_power_of_two();
    let max_entity_id = words_aligned * 64;

    eprintln!(
        "DB-Forge [{schema}.{table}] : MAX({pk_col})={max_id}\
         MAX_ENTITY_ID={max_entity_id} ({} KB)",
        (words_aligned * 8) / 1024,
    );
    Ok(max_entity_id)
}

// =============================================================================
// I-d. Introspection — colonnes varlena de la table jointe
// =============================================================================

/// Récupère les colonnes varlena (varchar, text) d'une table jointe.
///
/// ─── Politique de max_len ────────────────────────────────────────────────────
///
///   VARCHAR(N)            : max_len = N  (atttypmod - 4 dans pg_attribute).
///   TEXT avec CHECK       : max_len = N  (extrait de pg_constraint CHECK).
///   TEXT sans contrainte  : exclu du listing render (cargo:warning émis).
///                           Si référencé dans le listing dense : panic!.
///                           Pour le rendu page complète : fallback 10 000
///                           avec cargo:warning.
///   max_escaped_len > 64 KB : panic! (dépassement du seuil de sécurité AOT).
///
/// ─── Politique is_pre_escaped ────────────────────────────────────────────────
///
///   Si pg_description contient 'marius:pre_escaped' pour la colonne,
///   is_pre_escaped = true → facteur d'escape = 1 (au lieu de 5).
///   Ce tag est posé via : COMMENT ON COLUMN schema.table.col IS 'marius:pre_escaped';
///   Typiquement : slugs, titres normalisés, tout champ garanti sans '&<>"\''.
///
/// ─── Validation AOT (avg_width) ──────────────────────────────────────────────
///
///   Si pg_stats.avg_width > 80% de max_len : cargo:warning.
///   Signal que DYNAMIC_CAP est sous-pression sur les données réelles.
///   Recommandation : augmenter max_len ou relancer ANALYZE si données récentes.
///   avg_width est disponible uniquement après ANALYZE (peut être NULL).
async fn fetch_varlena_cols(
    pool:   &sqlx::PgPool,
    schema: &str,
    table:  &str,
) -> Result<Vec<VarlenField>, Box<dyn std::error::Error>> {
    // Requête principale : colonnes varchar/bpchar/text, dans l'ordre attnum.
    // atttypmod pour VARCHAR(N) = N + 4 (overhead interne PostgreSQL).
    // atttypmod = -1 pour TEXT sans contrainte de longueur.
    let rows = sqlx::query(
        "SELECT
             a.attname::text,
             a.atttypmod::integer,
             COALESCE(d.description, '')::text  -- commentaire COMMENT ON COLUMN
         FROM pg_attribute  a
         JOIN pg_class      c ON c.oid = a.attrelid
         JOIN pg_namespace  n ON n.oid = c.relnamespace
         -- pg_description : commentaires posés par COMMENT ON COLUMN.
         -- objsubid = attnum pour cibler la colonne (pas la table entière).
         LEFT JOIN pg_description d
               ON  d.objoid    = a.attrelid
               AND d.objsubid  = a.attnum
         WHERE n.nspname = $1
           AND c.relname = $2
           AND a.attnum  > 0
           AND NOT a.attisdropped
           AND a.atttypid IN (
               SELECT oid FROM pg_type
               WHERE typname IN ('varchar', 'bpchar', 'text')
           )
         ORDER BY a.attnum",
    )
    .bind(schema)
    .bind(table)
    .fetch_all(pool)
    .await?;

    let mut fields = Vec::new();

    for row in rows {
        let name:        String = row.get(0);
        let typmod:      i32    = row.get(1);
        let description: String = row.get(2);

        // Détection du tag pre_escaped dans le commentaire SQL.
        // Comparaison exacte (trim()) pour éviter les faux positifs.
        let is_pre_escaped = description.trim() == "marius:pre_escaped";

        // ── Résolution de max_len ─────────────────────────────────────────────
        //
        // 1. VARCHAR(N) : atttypmod = N + 4. Si typmod > 4 → max_len = typmod - 4.
        // 2. TEXT/BPCHAR sans limite : typmod = -1.
        //    Tenter d'extraire une contrainte CHECK (length(col) <= N).
        // 3. Aucune contrainte : exclure du listing render.
        let max_len: usize = if typmod > 4 {
            // Cas 1 : VARCHAR(N).
            (typmod - 4) as usize
        } else {
            // Cas 2 : TEXT ou BPCHAR sans précision → chercher CHECK (length(col) <= N).
            let check_row = sqlx::query(
                // pg_constraint contient les CHECK en texte brut (consrc).
                // On cherche un pattern `length(col_name) <= N` ou `char_length(col_name) <= N`.
                "SELECT con.consrc::text
                 FROM   pg_constraint  con
                 JOIN   pg_class       cls ON cls.oid = con.conrelid
                 JOIN   pg_namespace   ns  ON ns.oid  = cls.relnamespace
                 WHERE  ns.nspname  = $1
                   AND  cls.relname = $2
                   AND  con.contype = 'c'  -- CHECK
                   AND  (con.consrc LIKE '%length(' || $3 || ')%'
                      OR con.consrc LIKE '%char_length(' || $3 || ')%')",
            )
            .bind(schema)
            .bind(table)
            .bind(&name)
            .fetch_optional(pool)
            .await?;

            if let Some(check_r) = check_row {
                let consrc: String = check_r.get(0);
                // Extraction de N depuis le texte de la contrainte.
                // Patterns visés : `(length(col) <= 500)` ou `(char_length(col) <= 500)`.
                // On cherche le premier entier après "<=".
                parse_check_length_limit(&consrc).unwrap_or_else(|| {
                    // Contrainte CHECK présente mais parsing échoue (forme inconnue).
                    // Fallback conservateur : 10 000 octets + warning.
                    println!(
                        "cargo:warning=DB-Forge [{schema}.{table}.{name}]: \
                         CHECK trouvé mais longueur non parsable : `{consrc}`. \
                         Fallback max_len=10000."
                    );
                    10_000
                })
            } else {
                // Cas 3 : TEXT sans contrainte ni CHECK → exclusion du listing render.
                println!(
                    "cargo:warning=DB-Forge [{schema}.{table}.{name}]: \
                     TEXT sans contrainte de longueur — exclu du listing render. \
                     Réserver au rendu page complète (allocation dynamique acceptable)."
                );
                continue; // passe à la colonne suivante
            }
        };

        // ── Validation AOT : seuil absolu 64 KB ──────────────────────────────
        //
        // Un champ dont max_escaped_len > 64 KB produirait un DYNAMIC_CAP trop élevé
        // pour être alloué sur la pile, et indiquerait une colonne inadaptée au listing.
        // Ce panic arrête le build avant que le problème n'atteigne le runtime.
        let escape_factor = if is_pre_escaped { 1 } else { VarlenField::HTML_ESCAPE_FACTOR };
        let max_escaped   = max_len * escape_factor;
        if max_escaped > 65_536 {
            panic!(
                "DB-Forge [{schema}.{table}.{name}]: \
                 max_escaped_len ({max_escaped}B) > 64 KB. \
                 Réduire la contrainte VARCHAR/CHECK ou exclure du listing render."
            );
        }

        // ── Validation AOT : pression sur DYNAMIC_CAP (avg_width) ────────────
        //
        // pg_stats.avg_width est la largeur moyenne observée après ANALYZE.
        // Si avg_width > 80% de max_len, DYNAMIC_CAP est sous-pression :
        // les données réelles frôlent le pire cas, ce qui indique soit une
        // mauvaise estimation de max_len, soit une croissance non anticipée.
        let avg_row = sqlx::query(
            "SELECT avg_width::integer FROM pg_stats
             WHERE schemaname = $1 AND tablename = $2 AND attname = $3",
        )
        .bind(schema)
        .bind(table)
        .bind(&name)
        .fetch_optional(pool)
        .await?;

        if let Some(r) = avg_row {
            let avg_width: i32 = r.get(0);
            if avg_width as usize > max_len * 8 / 10 {
                println!(
                    "cargo:warning=DB-Forge [{schema}.{table}.{name}]: \
                     avg_width observé ({avg_width}B) > 80% de max_len ({max_len}B). \
                     Pression sur DYNAMIC_CAP. Relancer ANALYZE si données récentes. \
                     Envisager d'augmenter la contrainte VARCHAR."
                );
            }
        }

        fields.push(VarlenField { name, max_len, is_pre_escaped });
    }

    Ok(fields)
}

/// Extrait la limite N depuis un texte de contrainte CHECK de la forme
/// `(length(col) <= N)` ou `(char_length(col) <= N)`.
///
/// Retourne None si le pattern n'est pas reconnu (contrainte CHECK de forme inconnue).
fn parse_check_length_limit(consrc: &str) -> Option<usize> {
    // Recherche de "<=" puis extraction du premier token numérique suivant.
    let after_le = consrc.split("<=").nth(1)?;
    after_le
        .trim()
        .trim_end_matches(')')
        .trim()
        .parse::<usize>()
        .ok()
}

// =============================================================================
// II. Mapping de types
// =============================================================================

/// Informations de mapping pour un type SQL.
#[derive(Debug, Clone)]
struct TypeMapping {
    /// Type Rust dans la struct Row (sqlx-compatible, peut être Option<T>).
    row_type:   &'static str,

    /// Type Rust dans la struct StorageRow (#[repr(C)]).
    store_type: &'static str,

    /// Expression de conversion Row → StorageRow pour ce champ.
    /// Utilise le placeholder `{field}` substitué par build.rs.
    from_expr:  &'static str,

    /// true si ce type est fixed-length et peut figurer dans repr(C).
    /// false pour les varlena (TEXT, VARCHAR, BYTEA…) et les types Phase 2.
    is_fixed:   bool,

    /// Taille en octets du type dans la struct repr(C).
    /// Utilisé pour le calcul du layout et les static_assertions.
    size_bytes: usize,

    /// Alignement du type en octets.
    /// repr(C) aligne chaque champ sur son alignement naturel et
    /// aligne la struct entière sur le max des alignements.
    /// Utilisé pour calculer la taille padded et émettre assert!(align_of == max_align).
    alignment:  usize,
}

fn map_type(sql_type: &str) -> TypeMapping {
    // Normalise "character varying(255)" → "character varying"
    // pour que le match soit insensible à la précision VARCHAR(N).
    let t = sql_type.split('(').next().unwrap_or(sql_type).trim().to_lowercase();

    match t.as_str() {
        "int8" | "bigint" => TypeMapping {
            row_type: "i64", store_type: "i64",
            // Sentinel -1 : CHECK (price_cents >= 0) garantit que -1 = absent.
            // Pour les timestamps INT8, sentinel 0 (epoch Unix invalide en pratique).
            // ATTENTION : le choix du sentinel est domain-specific.
            // La Forge full requiert une annotation COMMENT ON COLUMN par colonne nullable.
            from_expr: "{field}.unwrap_or(-1)",
            is_fixed: true, size_bytes: 8, alignment: 8,
        },
        "int4" | "integer" | "int" | "serial" => TypeMapping {
            row_type: "i32", store_type: "i32",
            // Sentinel 0 : les IDs PostgreSQL (GENERATED ALWAYS AS IDENTITY) commencent à 1.
            from_expr: "{field}.unwrap_or(0)",
            is_fixed: true, size_bytes: 4, alignment: 4,
        },
        "int2" | "smallint" => TypeMapping {
            row_type: "i16", store_type: "i16",
            from_expr: "{field}.unwrap_or(0)",
            is_fixed: true, size_bytes: 2, alignment: 2,
        },
        "bool" | "boolean" => TypeMapping {
            row_type: "bool", store_type: "bool",
            from_expr: "{field}.unwrap_or(false)",
            is_fixed: true, size_bytes: 1, alignment: 1,
        },
        "uuid" => TypeMapping {
            row_type: "[u8; 16]", store_type: "[u8; 16]",
            from_expr: "{field}.unwrap_or([0u8; 16])",
            // [u8; 16] : alignement 1 (tableau d'octets, pas de contrainte d'alignement).
            is_fixed: true, size_bytes: 16, alignment: 1,
        },
        // TIMESTAMPTZ → i64 µs depuis l'epoch Unix.
        // La Row utilise chrono::DateTime<Utc> (type riche), le Store utilise i64 (POD).
        // Sentinel 0 = absent (epoch 1970-01-01 00:00:00 UTC invalide en pratique).
        "timestamptz" | "timestamp with time zone" => TypeMapping {
            row_type:   "chrono::DateTime<chrono::Utc>",
            store_type: "i64",
            from_expr:  "{field}.map(|dt| dt.timestamp_micros()).unwrap_or(0)",
            is_fixed: true, size_bytes: 8, alignment: 8,
        },
        "timestamp" | "timestamp without time zone" => TypeMapping {
            row_type:   "chrono::NaiveDateTime",
            store_type: "i64",
            from_expr:  "{field}.map(|dt| dt.and_utc().timestamp_micros()).unwrap_or(0)",
            is_fixed: true, size_bytes: 8, alignment: 8,
        },
        "date" => TypeMapping {
            row_type:   "chrono::NaiveDate",
            store_type: "i32",
            // num_days_from_ce() : jours depuis 0001-01-01, toujours positif.
            from_expr:  "{field}.map(|d| d.num_days_from_ce()).unwrap_or(0)",
            is_fixed: true, size_bytes: 4, alignment: 4,
        },
        "float4" | "real" => TypeMapping {
            row_type: "f32", store_type: "f32",
            from_expr: "{field}.unwrap_or(0.0)",
            is_fixed: true, size_bytes: 4, alignment: 4,
        },
        "float8" | "double precision" => TypeMapping {
            row_type: "f64", store_type: "f64",
            from_expr: "{field}.unwrap_or(0.0)",
            is_fixed: true, size_bytes: 8, alignment: 8,
        },
        // Varlena : exclus du StorageRow repr(C).
        // Présents dans la Row (Option<String>) pour transport sqlx.
        // Le render() les accède via RenderPayload (&'a str, zéro copie).
        "text" | "varchar" | "character varying"
        | "jsonb" | "json" | "bytea" | "ltree" => TypeMapping {
            row_type:  "String",
            store_type: "/* VARLENA — exclu du StorageRow repr(C) */",
            from_expr:  "/* VARLENA — non transféré dans StorageRow */",
            is_fixed: false, size_bytes: 0, alignment: 0,
        },
        // pg_lsn : pointeur dans le Write-Ahead Log de PostgreSQL (8 octets).
        // Phase 1 : exclu des structs Rust (non utilisé pour le rendu HTML).
        // Phase 2 (SHM) : le StorageRow lira walsn via pointeur mmap → u64.
        // La colonne existe en DDL pour le mécanisme de resync LSN.
        "pg_lsn" => TypeMapping {
            row_type:   "/* PHASE2_ONLY: walsn → u64 via mmap */",
            store_type: "/* PHASE2_ONLY */",
            from_expr:  "/* PHASE2_ONLY */",
            is_fixed: false, size_bytes: 8, alignment: 8,
        },
        other => {
            println!("cargo:warning=DB-Forge : type SQL inconnu '{other}' — exclu");
            TypeMapping {
                row_type: "/* INCONNU */", store_type: "/* INCONNU */",
                from_expr: "/* INCONNU */",
                is_fixed: false, size_bytes: 0, alignment: 0,
            }
        }
    }
}

// =============================================================================
// III. Génération — Struct Row (sqlx-compatible)
// =============================================================================

/// Génère {Name}Row : struct de désérialisation sqlx.
///
/// Non-repr(C) : l'ordre des champs suit l'ordre de la SELECT (attnum ASC pour fixed,
/// puis champs varlena de la table jointe en fin de liste).
///
/// Les champs varlena de la table jointe sont déclarés Option<String> :
/// le LEFT JOIN peut retourner NULL même si le champ est NOT NULL dans sa table source.
fn write_row_struct(
    out:     &mut String,
    schema:  &str,
    table:   &str,
    columns: &[Column],
    varlena: &[VarlenField],
) {
    let name = to_pascal(&format!("{schema}_{table}"));
    writeln!(out,
        "/// Struct de désérialisation sqlx pour {schema}.{table}.\n\
         /// Types natifs Rust + Option<T> pour nullable. NON repr(C).\n\
         /// Varlena JOIN : Option<String> (allocation sqlx, durée éphémère).\n\
         /// Transformer en StorageRow (From impl) + RenderPayload (as_deref) avant usage."
    ).unwrap();
    writeln!(out, "#[derive(Debug, sqlx::FromRow)]").unwrap();
    writeln!(out, "pub struct {name}Row {{").unwrap();

    for col in columns {
        let m = map_type(&col.sql_type);
        if m.is_fixed {
            if col.is_notnull {
                writeln!(out, "    pub {}: {},", col.name, m.row_type).unwrap();
            } else {
                writeln!(out, "    pub {}: Option<{}>,  // NULLABLE → sentinel dans StorageRow", col.name, m.row_type).unwrap();
            }
        } else {
            if m.row_type.starts_with("/*") {
                // Phase 2 ou type inconnu : commentaire uniquement, aucun champ généré.
                writeln!(out,
                    "    // EXCLU Phase 1 : {} ({}) — {}",
                    col.name, col.sql_type, m.row_type
                ).unwrap();
            } else if col.is_notnull {
                writeln!(out, "    pub {}: {},  // varlena table principale", col.name, m.row_type).unwrap();
            } else {
                writeln!(out, "    pub {}: Option<{}>,  // varlena NULLABLE table principale", col.name, m.row_type).unwrap();
            }
        }
    }

    // Champs varlena depuis la table jointe (LEFT JOIN) — toujours Option<String>.
    // Même si le champ est NOT NULL dans la table jointe, le LEFT JOIN retourne
    // NULL si aucune ligne ne correspond à la FK.
    for v in varlena {
        writeln!(out,
            "    pub {}: Option<String>,  // varlena JOIN VARCHAR({}) — → RenderPayload as_deref",
            v.name, v.max_len
        ).unwrap();
    }

    writeln!(out, "}}\n").unwrap();
}

// =============================================================================
// IV. Génération — Struct StorageRow (#[repr(C)])
// =============================================================================

/// Génère {Name}StorageRow : struct de stockage en mémoire contiguë.
///
/// ─── Invariant repr(C) ───────────────────────────────────────────────────────
///
///   Chaque champ est placé à l'offset dicté par son alignement naturel.
///   La struct est alignée sur le maximum des alignements de ses champs.
///   Le compilateur Rust honore exactement cet invariant pour repr(C).
///
/// ─── Varlena exclues ─────────────────────────────────────────────────────────
///
///   String / &str = fat pointer = 2 mots machine (16B sur x86_64).
///   Inclure un fat pointer dans repr(C) briserait la symétrie binaire avec
///   le heap tuple PostgreSQL. Les varlena sont portées par RenderPayload.
///
/// ─── static_assertions ───────────────────────────────────────────────────────
///
///   Deux assertions compilateur vérifient que la struct générée correspond
///   exactement au layout calculé par build.rs. Un ALTER TABLE non suivi d'une
///   reconstruction déclenche une erreur de compilation, pas une corruption.
fn write_store_struct(
    out:     &mut String,
    schema:  &str,
    table:   &str,
    columns: &[Column],
) {
    let name = to_pascal(&format!("{schema}_{table}"));

    writeln!(out,
        "/// Struct de stockage en mémoire contiguë pour {schema}.{table}.\n\
         /// #[repr(C)] : layout bit-à-bit aligné sur le heap tuple PostgreSQL.\n\
         /// Champs fixed-length uniquement. Nullable → sentinel (0 ou -1 selon type).\n\
         /// Varlena exclues : projetées depuis RenderPayload par Fragment-Forge.\n\
         ///\n\
         /// AVERTISSEMENT NULLABLE : le choix du sentinel est domain-specific.\n\
         /// La Forge full requiert une annotation COMMENT ON COLUMN par colonne nullable.\n\
         /// Pour ce prototype, les valeurs par défaut de map_type() s'appliquent."
    ).unwrap();
    writeln!(out, "#[repr(C)]").unwrap();
    writeln!(out, "#[derive(Debug, Clone, Copy, Default)]").unwrap();
    writeln!(out, "pub struct {name}StorageRow {{").unwrap();

    let mut layout_bytes = 0usize;
    let mut max_align    = 1usize;

    for col in columns {
        let m = map_type(&col.sql_type);
        if m.is_fixed {
            let null_marker = if col.is_notnull { "" } else { " [sentinel]" };
            // Padding d'alignement visuel pour lisibilité du layout.
            let pad = if col.name.len() < 20 {
                " ".repeat(20 - col.name.len().min(20))
            } else {
                String::new()
            };
            writeln!(out,
                "    pub {}: {},{}  // attnum={}, {}B{}",
                col.name, m.store_type, pad,
                col.attnum, m.size_bytes, null_marker,
            ).unwrap();
            layout_bytes += m.size_bytes;
            max_align     = max_align.max(m.alignment);
        } else {
            writeln!(out,
                "    // VARLENA exclu : {} ({}) → RenderPayload",
                col.name, col.sql_type
            ).unwrap();
        }
    }
    writeln!(out, "}}").unwrap();

    // Taille padded repr(C) : arrondie au multiple supérieur de max_align.
    // C'est la taille effective retournée par std::mem::size_of::<Struct>().
    let padded_size = layout_bytes.div_ceil(max_align.max(1)) * max_align.max(1);

    // Commentaire de layout pour diagnostic (visible dans le fichier généré).
    writeln!(out, "// Layout fixed-length : {layout_bytes}B données → {padded_size}B padded (align={max_align}B)").unwrap();
    // Taille du header heap PostgreSQL : MAXALIGN(23 + ceil(N/8)) pour N colonnes.
    // Référence : src/include/access/htup_details.h (HeapTupleHeaderData).
    writeln!(out, "// + {}B header heap PostgreSQL (MAXALIGN(23 + ceil({}/8)))",
        { let n = columns.len(); (23 + n.div_ceil(8)).div_ceil(8) * 8 },
        columns.len()
    ).unwrap();
    writeln!(out).unwrap();

    // static_assertions : vérifient la symétrie binaire à la compilation.
    // Un ALTER TABLE non suivi d'une reconstruction déclenche ici, pas au runtime.
    writeln!(out,
        "const _: () = assert!(\n    \
         std::mem::size_of::<{name}StorageRow>() == {padded_size},\n    \
         \"DB-Forge [{schema}.{table}]: size_of diverge du DDL — reconstruire après ALTER TABLE\",\n\
         );"
    ).unwrap();
    writeln!(out,
        "const _: () = assert!(\n    \
         std::mem::align_of::<{name}StorageRow>() == {max_align},\n    \
         \"DB-Forge [{schema}.{table}]: align_of diverge du DDL — vérifier les types colonnes\",\n\
         );"
    ).unwrap();
    writeln!(out).unwrap();
}

// =============================================================================
// IV-b. Génération — Struct VarlenOwned (données varlena possédées)
// =============================================================================

/// Génère {Name}VarlenOwned : struct possédée portant les données varlena.
///
/// ─── Rôle ────────────────────────────────────────────────────────────────────
///
///   Porte les Option<String> issues du fetch SQLx (ou du buffer de page Phase 2).
///   'static + Send : peut traverser tokio::spawn et rayon::par_iter sans contrainte
///   de lifetime. C'est la propriété qui la distingue de RenderPayload<'a>.
///
///   Le Dispatcher reçoit Vec<(StorageRow, VarlenOwned)> depuis fetch_batch,
///   distribue les tuples sur les threads Rayon, et dans chaque thread :
///     let name_ref = varlena.name.as_deref();  // &str local, lifetime inféré
///   Ces &str sont construits par le corps de render() généré par Fragment-Forge,
///   pas par le Dispatcher — ils n'ont pas besoin d'exister hors de render().
///
/// ─── Si aucun varlena ────────────────────────────────────────────────────────
///
///   type VarlenOwned = () dans le trait.
///   Aucune struct n'est générée. Le compilateur élimine le paramètre à zéro coût.
///   render() reçoit &() et ignore le paramètre varlena.
///
/// ─── Comparaison avec RenderPayload<'a> (session précédente) ─────────────────
///
///   RenderPayload<'a> (Option<&'a str>) n'est plus un type émis dans le fichier
///   généré. Les &str sont reconstruits localement dans render() via as_deref().
///   Le lifetime 'a est inféré par le compilateur depuis le paramètre varlena,
///   sans apparaître dans aucune interface publique.
fn write_varlen_owned_struct(
    out:     &mut String,
    schema:  &str,
    table:   &str,
    varlena: &[VarlenField],
) {
    // Si aucun varlena : type VarlenOwned = () dans le trait → pas de struct à générer.
    // Le commentaire ci-dessous documente l'absence intentionnelle.
    if varlena.is_empty() {
        writeln!(out,
            "// {schema}.{table} : aucun champ varlena — type VarlenOwned = () dans le trait.\n"
        ).unwrap();
        return;
    }

    let name = to_pascal(&format!("{schema}_{table}"));

    writeln!(out,
        "/// Données varlena possédées pour {schema}.{table}.\n\
         /// Send + 'static : traversée tokio::spawn et rayon::par_iter sans contrainte.\n\
         /// Produite par fetch_batch() depuis les Row SQLx (conversion directe).\n\
         /// render() reconstruit les &str localement via as_deref() — zéro copie."
    ).unwrap();
    writeln!(out, "#[derive(Debug, Default)]").unwrap();
    writeln!(out, "pub struct {name}VarlenOwned {{").unwrap();

    for v in varlena {
        writeln!(out,
            "    /// VARCHAR({}) — {} × {}.",
            v.max_len,
            if v.is_pre_escaped { "pré-échappé, facteur" } else { "escape HTML, facteur" },
            if v.is_pre_escaped { 1 } else { VarlenField::HTML_ESCAPE_FACTOR },
        ).unwrap();
        writeln!(out, "    pub {}: Option<String>,", v.name).unwrap();
    }

    writeln!(out, "}}\n").unwrap();
}

// =============================================================================
// V. Génération — From<Row> for StorageRow
// =============================================================================

/// Génère From<{Name}Row> for {Name}StorageRow.
///
/// Transfert des champs fixed-length uniquement.
/// Les champs varlena (Option<String>) restent dans la Row jusqu'à la construction
/// du RenderPayload par le Dispatcher (as_deref()).
///
/// Conversions :
///   NOT NULL  → valeur directe (ou timestamp_micros() pour les types chrono).
///   NULLABLE  → m.from_expr  : unwrap_or(sentinel) ou map(…).unwrap_or(0).
fn write_from_impl(
    out:     &mut String,
    schema:  &str,
    table:   &str,
    columns: &[Column],
) {
    let name = to_pascal(&format!("{schema}_{table}"));
    writeln!(out, "impl From<{name}Row> for {name}StorageRow {{").unwrap();
    writeln!(out, "    fn from(r: {name}Row) -> Self {{").unwrap();
    writeln!(out, "        Self {{").unwrap();

    for col in columns {
        let m = map_type(&col.sql_type);
        if !m.is_fixed { continue; }

        let expr = if col.is_notnull {
            // NOT NULL : conversion directe (Row et StorageRow ont le même type de base)
            match m.row_type {
                "chrono::DateTime<chrono::Utc>" => {
                    format!("r.{}.timestamp_micros()", col.name)
                }
                "chrono::NaiveDateTime" => {
                    format!("r.{}.and_utc().timestamp_micros()", col.name)
                }
                "chrono::NaiveDate" => {
                    format!("r.{}.num_days_from_ce()", col.name)
                }
                _ => format!("r.{}", col.name),
            }
        } else {
            // NULLABLE : déplie Option via le sentinel défini dans map_type
            m.from_expr.replace("{field}", &format!("r.{}", col.name))
        };

        writeln!(out, "            {}: {},", col.name, expr).unwrap();
    }

    writeln!(out, "        }}").unwrap();
    writeln!(out, "    }}").unwrap();
    writeln!(out, "}}\n").unwrap();
}

// =============================================================================
// VI. Génération — Collector<MAX, WORDS> statique
// =============================================================================

/// Génère le Collector dimensionné pour la table.
///
/// MAX_ENTITY_ID : borne du domaine IDs (calculée par fetch_max_id).
/// WORDS         : nombre de mots u64 = MAX_ENTITY_ID / 64.
///
/// La relation WORDS = MAX / 64 est imposée par la Forge car generic_const_exprs
/// est instable en Rust stable. Le type system ne peut pas l'exprimer ;
/// la Forge garantit la cohérence à la génération.
fn write_collector(
    out:           &mut String,
    schema:        &str,
    table:         &str,
    pk_col:        &str,
    max_entity_id: usize,
) {
    let screaming = to_screaming(&format!("{schema}_{table}"));
    let words     = max_entity_id.div_ceil(64);

    writeln!(out, "// Collector dimensionné pour {schema}.{table}").unwrap();
    writeln!(out, "// PK = {pk_col} | MAX_ID+20% arrondi power-of-two").unwrap();
    writeln!(out, "pub const MAX_{screaming}_ID: usize = {max_entity_id};").unwrap();
    writeln!(out, "pub const {screaming}_WORDS: usize = {words};").unwrap();
    writeln!(out,
        "pub static {screaming}_COLLECTOR: crate::collector::Collector<MAX_{screaming}_ID, {screaming}_WORDS> ="
    ).unwrap();
    writeln!(out, "    crate::collector::Collector::new_zeroed();\n").unwrap();
}

// =============================================================================
// VII. Génération — impl Projection stub
// =============================================================================

/// Génère le stub impl Projection pour la table (conforme au trait ADR-003).
///
/// ─── type Record ─────────────────────────────────────────────────────────────
///
///   Record = {Name}StorageRow (champs fixed uniquement, repr(C)).
///
/// ─── type VarlenOwned ────────────────────────────────────────────────────────
///
///   Tables avec varlena  : {Name}VarlenOwned (Option<String> possédées, 'static).
///   Tables sans varlena  : () — coût zéro, éliminé par le compilateur.
///
/// ─── fetch_batch ─────────────────────────────────────────────────────────────
///
///   Retourne Vec<(StorageRow, VarlenOwned)> possédé.
///   La conversion depuis la Row SQLx se fait dans le corps généré :
///     StorageRow  ← From<{Name}Row>
///     VarlenOwned ← déplacement des Option<String> depuis la Row
///
/// ─── render ──────────────────────────────────────────────────────────────────
///
///   Signature : fn render(&StorageRow, &VarlenOwned, &mut String)
///   Corps généré par Fragment-Forge :
///     - as_deref() local pour reconstruire les &str depuis VarlenOwned
///     - push_str (statique) + write_fmt (fixed) + marius_html_escape (varlena)
fn write_projection_stub(
    out:          &mut String,
    schema:       &str,
    table:        &str,
    columns:      &[Column],
    pk:           &PrimaryKey,
    varlena:      &[VarlenField],
    varlena_join: Option<(&str, &str, &str)>,
) {
    let name      = to_pascal(&format!("{schema}_{table}"));
    let proj_name = format!("{name}Projection");
    let screaming = to_screaming(&format!("{schema}_{table}"));

    // Nom du type VarlenOwned selon présence de varlena.
    // () pour les tables sans JOIN varlena : éliminé à la compilation.
    let varlen_owned_type = if varlena.is_empty() {
        "()".to_string()
    } else {
        format!("{name}VarlenOwned")
    };

    // ── SELECT des colonnes fixed-length ─────────────────────────────────────
    let fixed_cols: Vec<&str> = columns.iter()
        .filter(|c| map_type(&c.sql_type).is_fixed)
        .map(|c| c.name.as_str())
        .collect();

    if fixed_cols.is_empty() {
        eprintln!(
            "cargo:warning=DB-Forge [{schema}.{table}] : \
             aucune colonne fixed-length — stub incomplet généré."
        );
    }

    // ── Construction de la requête fetch_batch ────────────────────────────────
    // Si une table jointe est définie, on ajoute les colonnes varlena au SELECT
    // et on construit la clause LEFT JOIN.
    let (select, from_clause) = if let Some((vs, vt, fk)) = varlena_join {
        let varlena_cols: Vec<String> = varlena.iter()
            .map(|v| format!("{vt}.{}", v.name))
            .collect();
        let all_cols: Vec<String> = fixed_cols.iter().map(|c| c.to_string())
            .chain(varlena_cols)
            .collect();
        let select = all_cols.join(", ");
        let from   = format!(
            "{schema}.{table} LEFT JOIN {vs}.{vt} ON {schema}.{table}.{fk} = {vs}.{vt}.{fk}"
        );
        (select, from)
    } else {
        (fixed_cols.join(", "), format!("{schema}.{table}"))
    };

    let where_clause = match pk {
        PrimaryKey::Single(col) => {
            format!("WHERE {schema}.{table}.{col} = ANY($1) ORDER BY {schema}.{table}.{col} ASC")
        }
        PrimaryKey::Composite => "WHERE 1=1 /* PK composite: adapter */".to_string(),
    };

    // ── Fragment-Forge : calcul des capacités ─────────────────────────────────
    // Calculé AVANT l'émission pour éviter une dépendance sur l'ordre de génération.
    // Les constantes de capacité sont émises au niveau MODULE, pas dans le bloc impl.
    let field_specs: Vec<FieldSpec> = columns.iter()
        .filter(|c| map_type(&c.sql_type).is_fixed)
        .filter_map(|c| {
            FieldKind::from_sql_type(&c.sql_type).map(|kind| FieldSpec {
                name:   c.name.clone(),
                kind,
                attnum: c.attnum,
            })
        })
        .collect();

    let pk_field_name = match pk {
        PrimaryKey::Single(col) => col.as_str(),
        PrimaryKey::Composite   => field_specs.first().map(|f| f.name.as_str()).unwrap_or("id"),
    };

    let (static_cap, dynamic_cap, render_body) = generate_render(
        schema, table, &name,
        &field_specs,
        // PK réelle issue de pg_constraint — pas le premier champ par attnum.
        // Ex: document_id est en attnum=1 pour content.core,
        //     id est en attnum=2 pour commerce.product_core.
        pk_field_name,
        varlena,
    );
    let cap_consts = generate_capacity_consts(&screaming, static_cap, dynamic_cap);

    // ── Émission ─────────────────────────────────────────────────────────────
    writeln!(out, "pub struct {proj_name};").unwrap();
    writeln!(out).unwrap();

    // Constantes de capacité au niveau module (pas dans impl).
    writeln!(out, "{cap_consts}").unwrap();

    writeln!(out, "// Pool requis : marius_user (SELECT non révoqué sur {schema}.{table})").unwrap();
    writeln!(out, "// RLS         : voir 09_rls/01_policies.sql").unwrap();
    writeln!(out, "impl crate::projection::Projection for {proj_name} {{").unwrap();

    // type Record : StorageRow, repr(C), fixed-length uniquement.
    writeln!(out, "    type Record = {name}StorageRow;").unwrap();

    // type VarlenOwned : struct possédée ou () si pas de varlena.
    // Send + 'static garantis dans les deux cas : () est trivial, VarlenOwned dérive Default.
    writeln!(out, "    type VarlenOwned = {varlen_owned_type};").unwrap();
    writeln!(out).unwrap();

    // fetch_batch : retourne Vec<(StorageRow, VarlenOwned)>.
    // La Row SQLx sert d'intermédiaire de transport ; elle est consommée ici.
    // StorageRow ← From<Row>, VarlenOwned ← déplacement des Option<String>.
    writeln!(out, "    async fn fetch_batch(").unwrap();
    writeln!(out, "        pool: &sqlx::PgPool,").unwrap();
    writeln!(out, "        ids:  &[i64],").unwrap();
    writeln!(out, "    ) -> Result<Vec<(Self::Record, Self::VarlenOwned)>, sqlx::Error> {{").unwrap();

    if fixed_cols.is_empty() {
        writeln!(out,
            "        todo!(\"DB-Forge: aucune colonne fixed-length pour {schema}.{table}\")"
        ).unwrap();
    } else {
        writeln!(out, "        let rows = sqlx::query_as::<_, {name}Row>(").unwrap();
        writeln!(out,
            "            \"SELECT {select} FROM {from_clause} {where_clause}\","
        ).unwrap();
        writeln!(out, "        )").unwrap();
        writeln!(out, "        .bind(ids)").unwrap();
        writeln!(out, "        .fetch_all(pool)").unwrap();
        writeln!(out, "        .await?;").unwrap();

        // Conversion Row → (StorageRow, VarlenOwned).
        if varlena.is_empty() {
            // Pas de varlena : VarlenOwned = (), From<Row> consomme r entièrement.
            writeln!(out,
                "        Ok(rows.into_iter().map(|r| ({name}StorageRow::from(r), ())).collect())"
            ).unwrap();
        } else {
            // Avec varlena : From<Row> consomme r par valeur ET les Option<String>
            // doivent être déplacées hors de r pour VarlenOwned.
            // Ces deux opérations ne peuvent pas être séquentielles sur la même valeur r :
            // déplacer un champ Option<String> invalide le move ultérieur de r entier
            // (E0382 — partial move).
            //
            // Solution : déstructuration complète de r en un seul pattern let.
            // Tous les champs sont nommés simultanément ; aucun move partiel n'a lieu.
            // StorageRow et VarlenOwned sont construits depuis les bindings nommés,
            // indépendamment l'un de l'autre.
            //
            // Conséquence : From<{Name}Row> for {Name}StorageRow n'est PAS appelé ici.
            // La logique de conversion fixed (sentinels, timestamp_micros) est
            // reproduite inline depuis map_type(). Toute modification de map_type()
            // doit être répercutée dans cette branche.
            writeln!(out, "        Ok(rows.into_iter().map(|r| {{").unwrap();

            // Déstructuration complète de r.
            // Champs fixed : liés par nom, utilisés pour StorageRow.
            // Champs varlena JOIN (dans `varlena`) : liés par nom, utilisés pour VarlenOwned.
            // Champs Phase2 / inconnus (row_type commence par "/*") : ignorés via `..`.
            // La clause `..` absorbe les champs non nommés restants sans les déplacer.
            writeln!(out, "            let {name}Row {{").unwrap();
            // Champs fixed depuis columns.
            for col in columns {
                let m = map_type(&col.sql_type);
                if m.is_fixed {
                    writeln!(out, "                {},", col.name).unwrap();
                }
            }
            // Champs varlena JOIN depuis varlena (absents de columns, issus du JOIN).
            for v in varlena {
                writeln!(out, "                {},", v.name).unwrap();
            }
            // `..` : absorbe les champs varlena de la table principale (non-JOIN)
            // et les champs Phase2/inconnus sans les nommer ni les déplacer.
            writeln!(out, "                ..").unwrap();
            writeln!(out, "            }} = r;").unwrap();

            // Construction de VarlenOwned depuis les bindings varlena nommés.
            writeln!(out, "            let owned = {name}VarlenOwned {{").unwrap();
            for v in varlena {
                writeln!(out, "                {},", v.name).unwrap();
            }
            writeln!(out, "            }};").unwrap();

            // Construction de StorageRow depuis les bindings fixed nommés.
            // Reproduit la logique de From<Row> : sentinels pour nullable,
            // conversions chrono pour les timestamps.
            // Utilise les bindings locaux (pas r.champ — r est déjà déstructuré).
            writeln!(out, "            let storage = {name}StorageRow {{").unwrap();
            for col in columns {
                let m = map_type(&col.sql_type);
                if !m.is_fixed { continue; }

                let expr = if col.is_notnull {
                    match m.row_type {
                        "chrono::DateTime<chrono::Utc>" => {
                            format!("{}.timestamp_micros()", col.name)
                        }
                        "chrono::NaiveDateTime" => {
                            format!("{}.and_utc().timestamp_micros()", col.name)
                        }
                        "chrono::NaiveDate" => {
                            format!("{}.num_days_from_ce()", col.name)
                        }
                        _ => col.name.clone(),
                    }
                } else {
                    // Remplace {field} par le binding local (pas r.champ).
                    m.from_expr.replace("{field}", &col.name)
                };

                if expr == col.name {
                    writeln!(out, "                {},", col.name).unwrap();
                } else {
                    writeln!(out, "                {}: {},", col.name, expr).unwrap();
                }
            }
            writeln!(out, "            }};").unwrap();

            writeln!(out, "            (storage, owned)").unwrap();
            writeln!(out, "        }}).collect())").unwrap();
        }
    }

    writeln!(out, "    }}").unwrap();
    writeln!(out).unwrap();

    // render() : signature conforme au trait ADR-003.
    // varlena : &Self::VarlenOwned — Fragment-Forge reconstruit les &str localement.
    // Pour () (pas de varlena) : le paramètre _varlena est ignoré sans overhead.
    let varlena_param = if varlena.is_empty() {
        "_varlena: &()".to_string()
    } else {
        format!("varlena: &{name}VarlenOwned")
    };
    writeln!(out,
        "    fn render(record: &Self::Record, {varlena_param}, buf: &mut String) {{"
    ).unwrap();
    for line in render_body.lines() {
        writeln!(out, "    {line}").unwrap();
    }
    writeln!(out, "    }}").unwrap();
    writeln!(out).unwrap();

    // artifact_path : chemin déterministe basé sur la PK (lecture depuis StorageRow).
    writeln!(out, "    fn artifact_path(record: &Self::Record) -> PathBuf {{").unwrap();
    writeln!(out, "        let root = std::env::var(\"MARIUS_ARTIFACTS_DIR\")").unwrap();
    writeln!(out, "            .unwrap_or_else(|_| \"artifacts\".to_string());").unwrap();
    writeln!(out,
        "        PathBuf::from(format!(\"{{root}}/{schema}/{table}/{{}}.html\", record.{pk_field_name}))"
    ).unwrap();
    writeln!(out, "    }}").unwrap();

    writeln!(out, "}}\n").unwrap();
}

// =============================================================================
// Utilitaires
// =============================================================================

fn write_section_header(out: &mut String, schema: &str, table: &str, pk: &PrimaryKey) {
    let pk_info = match pk {
        PrimaryKey::Single(col) => format!("PK={col}"),
        PrimaryKey::Composite   => "PK composite — Collector N/A".to_string(),
    };
    writeln!(out, "// {}", "=".repeat(60)).unwrap();
    writeln!(out, "// {schema}.{table} · {pk_info}").unwrap();
    writeln!(out, "// {}\n", "=".repeat(60)).unwrap();
}

/// Convertit "content_core" → "ContentCore".
fn to_pascal(s: &str) -> String {
    s.split('_').map(|w| {
        let mut c = w.chars();
        match c.next() {
            None    => String::new(),
            Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
        }
    }).collect()
}

/// Convertit "content_core" → "CONTENT_CORE".
fn to_screaming(s: &str) -> String {
    s.to_uppercase()
}