File size: 67,483 Bytes
e1b3e71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
051f280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e1b3e71
 
051f280
 
 
 
 
e1b3e71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
051f280
e1b3e71
 
051f280
 
 
 
 
 
 
e1b3e71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
051f280
 
 
 
 
 
 
 
e1b3e71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
// ---------------------------------------------------------------------------
// customer-grid / apiBridge.ts β€” the STANDALONE half of the data path (X2).
//
// The branch beside hostBridge.ts. Where `hostBridge` speaks the Streamlit
// Components v1 protocol to a surrounding Python app, this speaks HTTP to the
// API β€” the SAME event objects, the same ids, the same optimism semantics on
// the client. Which one is live is decided once, by mode, and never mixed:
// `installStandaloneBridge()` is called from main.tsx ONLY in standalone, so
// the embed bundle never registers a sink and its behaviour is byte-unchanged
// (pinned by verify_bridge.py, which was written BEFORE this file existed).
//
// ⚠ WHAT THIS WAVE'S WRITE PATH CAN AND CANNOT DO β€” read before extending.
// The client's whole write model is "emit β†’ the host reruns β†’ a fresh payload
// arrives β†’ the UI reflects it" (CustomerGrid's own words: *"the emit itself
// triggers the host rerun, so the fresh name/list arrives with the next payload
// β€” no local mutation to drift from the store"*). Standalone has no rerun and
// X2 fixes `GET /api/v1/customers` at the EXACT current shape β€” `{fields, rows,
// today, pulled_at}`, with no `workspace`, no `cohorts`, no `docs`. So:
//
//   VISIBLE + DURABLE   view_upsert Β· view_delete Β· field_* Β· overlay_patch
//                       (the client already holds these in state + localStorage;
//                        the POST is what makes them durable server-side)
//   DURABLE, INVISIBLE  cohort_* Β· add_to_list Β· folder_* Β· item_move
//                       (they reach the store, but nothing re-reads them until
//                        the payload carries that state β€” EXIT-5). The server's
//                        `toast` is the only feedback the user gets, which is
//                        why it is wired through and not dropped.
//   NOT REACHABLE       doc_add / doc_fetch / doc_delete β€” the Documents panel
//                       is driven by `payload.docs`, which standalone never
//                       receives, so no doc event can be emitted at all. X2's
//                       `doc` field is therefore deliberately NOT handled here:
//                       wiring a response nobody can trigger is untestable dead
//                       code. When `/customers` grows `docs`, feed `doc` into
//                       the same `payload.docPayload` slot the embed uses.
//
// NOTHING HERE FALLS BACK TO `sample_customers.json`. That affordance ("the
// grid still renders with no backend") is unreachable now β€” the frame requires
// a session before CustomerGrid mounts at all β€” and behind a login, sample
// revenue rendered after a server hiccup is fabricated data on a screen the
// user has every reason to trust. An honest failure, always.
// ---------------------------------------------------------------------------

import {
  API_V1,
  CREDENTIALS,
  DATA_ERROR_EVENT,
  DERIVED_CELLS_EVENT,
  ROWS_STALE_EVENT,
  TOAST_EVENT,
  WORKSPACE_STALE_EVENT,
  UNAUTHORIZED_EVENT,
  checkTenant,
  signal,
} from "../apiContract";
import { setStandaloneSink } from "./hostBridge";
import { changedBuckets } from "./liveWorkspace";
import type { ChangeTokens } from "./liveWorkspace";
import { CUSTOMER_TOPIC } from "./types";
import type { CustomersPayload, Field, FilterNode, GridLimit, GridWorkspace, HostEvent, Row,
  SortSpec, TopicConfig } from "./types";
import type { TsPayload, TsRequest } from "./timeSeriesData";

const JSON_HEADERS = { "Content-Type": "application/json" };

async function readJson(res: Response): Promise<unknown> {
  try {
    return await res.json();
  } catch {
    return null;
  }
}

/** 401 is the one status that is never a data problem. Raised once, centrally,

 *  so every call site cannot forget to. Returns true when it handled it.

 *  The rows caches die with the session β€” the next signed-in user may not

 *  be the same person, and a cached book must never cross that boundary. */
function handledUnauthorized(status: number): boolean {
  if (status !== 401) return false;
  rowsCache.clear();
  signal(UNAUTHORIZED_EVENT);
  return true;
}

/**

 * The session check every authenticated call makes: the 401 above, PLUS the TENANT swap the

 * 401 path structurally cannot see.

 *

 * β›” WHY `handledUnauthorized` WAS NEVER ENOUGH. Its own note says the rows cache "dies with the

 * session β€” the next signed-in user may not be the same person". That is the USER boundary, and

 * it is enforced on 401. A TENANT swap produces no 401 at all: `aios_session` is one cookie per

 * ORIGIN, so signing into another tenant in a second tab repoints this one, and every request

 * here keeps returning a cheerful 200 full of somebody else's data. `checkTenant` reads the

 * server's own stamp and reloads the frame; see `apiContract.ts::TENANT_HEADER`.

 */
function handledSession(res: Response): boolean {
  if (checkTenant(res)) return true;
  return handledUnauthorized(res.status);
}

// --- the READ (CP-B) -------------------------------------------------------

/**

 * The last good `/customers` payload, reused across surface switches. The rows

 * are a 15-minute-cached Odoo pull SERVER-side, but every Customer⇄Cohort

 * route change remounts the grid and re-downloaded the whole ~1 MB payload β€”

 * three full transfers in one short session, measured live. Within this window

 * a remount reuses the copy; the workspace (the cheap per-scope call) is always

 * re-fetched, so views/cohorts stay live.

 *

 * β›” THE CACHE IS SESSION-SCOPED OR IT IS A LEAK. It is cleared on ANY 401 and

 * on sign-out/sign-in (Shell calls `clearCustomersCache`) β€” one browser, two

 * accounts, and a cached book served across the boundary would be the exact

 * cross-user leak EXIT-3b exists to prevent, client-side this time.

 *

 * Own edits stay visible ([[the NO-BLIP law]]): `patchCustomer` writes accepted

 * values through into the cached rows, so a scope switch after an edit shows

 * the edit, not the pre-edit pull.

 */
const CUSTOMERS_FRESH_MS = 5 * 60_000;
// Wave 16 C-TOPIC: ONE cache map keyed by the topic's rows path, so the product pull and the
// customer pull each get the same 5-minute remount window without ever serving each other.
const rowsCache = new Map<string, { at: number; payload: CustomersPayload }>();

/**

 * ⭐⭐ WAVE 31 · T21 (owner item 7: *"It still takes a very long time to load from one Database

 * into another"*) β€” THE WORKSPACE ENVELOPE, MEMOISED PER SCOPE.

 *

 * β›” WHAT WAS MISSING, stated exactly: `rowsCache` above has memoised ROWS since EXIT wave 1, and

 * `fetchWorkspace` had NO cache of any kind. So switching back to a database whose rows were

 * still warm STILL paid a full `/workspace` round trip β€” and that call is not cheap: it is

 * `ut_assembly`, which on tenant #0 reads a **28.6 MB** document (703 ms of deep copy, warm) plus

 * the whole `grid_events` workspace build. Measured live on `283b815`: **2,430 ms** for

 * `?scope=customer`. Every database→database hop paid it, both directions, for an envelope that

 * had not changed.

 *

 * ⚠ THE FRESHNESS WINDOW IS THE ROWS CACHE'S, ON PURPOSE. Two windows over one surface would

 * drift into a grid whose columns are newer than its rows, or the reverse β€” a shape this repo has

 * already paid for. One constant, both memos.

 *

 * β›” AND IT IS EVICTED BY THE WRITE PATH, NOT ONLY BY TIME. Every accepted grid event fires

 * `WORKSPACE_STALE_EVENT` β†’ `useCustomerData.reread()`, which calls this with no `allowCached`,

 * so it re-fetches AND overwrites this entry. Without that overwrite a person could hide a

 * field, switch away, switch back, and be served the pre-write envelope β€” the write silently

 * undone on screen, which is worse than the wait it replaced.

 */
const wsCache = new Map<string, { at: number; ws: GridWorkspace }>();

export function clearWorkspaceCache(): void {
  wsCache.clear();
}

export function clearCustomersCache(): void {
  rowsCache.clear();
  // β›” THE ENVELOPE IS SESSION-SCOPED FOR THE SAME REASON THE ROWS ARE, and it carries MORE that
  // is per-account than they do: `viewer`, `userOptions`, the user's own views and folders. One
  // browser, two accounts, and a cached workspace served across the boundary would show the
  // previous person's saved views under the new person's name.
  clearWorkspaceCache();
  // β›” AND THE READ-THROUGH ROSTER, which is a per-SESSION fact, not a per-topic one. It names
  // which tables this tenant serves through the mirror; a second account signing into the same
  // browser is a different tenant's answer. Same boundary the rows cache is cleared on, and for
  // the same reason.
  readThroughMemo = null;
}

/**

 * Drop ONE topic's rows memo. The change poller's companion (wave 29, item 20).

 *

 * β›” NOT `clearCustomersCache()`, and the difference is a megabyte. That clears every topic's

 * window, so a poll that noticed a change on the Product grid would also force the Customer pool

 * to be re-downloaded on the next surface switch. One bucket changed; one memo is dropped.

 *

 * ⚠ WAVE 30 β€” it now sweeps a PREFIX, because one table can have many cache entries. A windowed

 * grid's key carries its offset and its predicate (see `windowRowsPath`), so `ut_odoo_orders`

 * may hold a dozen. Deleting the bare path would leave every one of them live.

 */
export function clearTopicRowsCache(rowsPath: string): void {
  rowsCache.delete(rowsPath);
  for (const key of [...rowsCache.keys()])
    if (key.startsWith(`${rowsPath}?`)) rowsCache.delete(key);
}

/**

 * ⭐⭐ WAVE 30 Β· W30-T42 β€” DROP EVERY CACHED ROWS RESPONSE FOR ONE TABLE, WHICHEVER DOOR SERVED IT.

 *

 * β›” THE DEFECT THIS PREVENTS, AND IT IS THIS REPO'S NAMED CLASS. Before C2 a table had exactly

 * one rows key (`tables/<key>/rows`) and eight call sites deleted that literal. A read-through

 * grid is served by a DIFFERENT route (`odoo-tables/<key>/rows`) under MANY keys (one per

 * offset Γ— predicate), so every one of those literals silently stopped invalidating anything β€”

 * a write that returned 200 and a grid that kept painting the pre-write page, with no error

 * anywhere. Two invalidation laws for one question is [[one-question-two-normalizers]]; there

 * is one law and it lives here.

 *

 * ⚠ IT MATTERS DESPITE `recordsMutable: false`. A read-through Odoo grid refuses record edits,

 * but W30-T28 shipped tenant-wide SHARED COLUMNS on exactly these grids β€” `PATCH

 * /tables/{key}/shared/{pid}` writes a cell that rides the window payload's own `fields`/rows.

 */
export function dropTableRowsCache(tableKey: string): void {
  const prefixes = [`tables/${tableKey}/rows`, `odoo-tables/${tableKey}/rows`];
  for (const key of [...rowsCache.keys()])
    if (prefixes.some((p) => key === p || key.startsWith(`${p}?`))) rowsCache.delete(key);
}

/**

 * X2 `GET /api/v1/<topic.rowsPath>` β†’ `{fields, rows, today, pulled_at}`, rows already

 * scoped to the session's BU pool (EXIT-3b). The cookie replaces HTTP Basic.

 *

 * Returns null on ANY failure, having raised the matching signal. A null is not

 * an empty table: the frame renders a failure, not "this tenant has no

 * records", because those two look identical and only one of them is true.

 */
/**

 * ⭐ WAVE 30 (D-79's second half) β€” THE ONE READER OF A SERVER REFUSAL, and the reason it exists.

 *

 * β›” MEASURED: five call sites in this file read `body.detail.message`, and **the server has never

 * sent that shape.** `main.py`'s `_error_shape` handler unwraps `deps.err`'s `detail` before the

 * response leaves, so every non-2xx body on the wire is `{"error": {"code", "message"}}` β€” the

 * X2 contract, stated in that handler's own docstring. `body.detail` is `undefined` at all five,

 * so every carefully-worded refusal in the API ("this database already has a profile column β€”

 * 'Handle'. A database has at most one…") was replaced by "The server answered 400."

 *

 * That is worse than a missing feature: the product LOOKED like it was explaining itself. Sixteen

 * other call sites across the client already read `error.message` correctly, which is why nobody

 * noticed β€” the wrong shape survived only where nobody had recently read a refusal out loud.

 *

 * ⚠ THE `detail` LEG STAYS, AS A STRING. FastAPI's own `RequestValidationError` does NOT pass

 * through `_error_shape`, so a 422 from a malformed body still arrives shaped `{"detail": …}`.

 * Reading it as a string is honest; reading `.message` off it never was.

 */
export function refusalMessage(body: unknown, status: number, fallback?: string): string {
  const shaped = body as { error?: { message?: unknown }; detail?: unknown } | null | undefined;
  const named = shaped?.error?.message;
  if (typeof named === "string" && named.trim()) return named;
  if (typeof shaped?.detail === "string" && shaped.detail.trim()) return shaped.detail;
  return fallback ?? `The server answered ${status}.`;
}

export async function fetchTopicRows(topic: TopicConfig): Promise<CustomersPayload | null> {
  const cached = rowsCache.get(topic.rowsPath);
  if (cached && Date.now() - cached.at < CUSTOMERS_FRESH_MS) {
    return cached.payload;
  }
  let res: Response;
  try {
    res = await fetch(`${API_V1}/${topic.rowsPath}`, { credentials: CREDENTIALS });
  } catch {
    signal(DATA_ERROR_EVENT, "Cannot reach the server.");
    return null;
  }
  if (handledSession(res)) return null;
  if (!res.ok) {
    // ⭐ WAVE 30 β€” R's ask, and it is the SIXTH site of the shape T44 closed five of. This path
    // answered every non-2xx with a bare status while `refusalMessage` sat 50 lines above it,
    // and TWO cause-carrying refusals now arrive here: `store_not_ready` (503, live on a
    // connected grid before the mirror's first sync) and the window route's own 409. Both name
    // their cause and their fix; both used to render as "The server answered 503."
    signal(DATA_ERROR_EVENT, refusalMessage(await readJson(res), res.status));
    return null;
  }
  const body = (await readJson(res)) as CustomersPayload | null;
  if (!body || !Array.isArray(body.rows) || !Array.isArray(body.fields)) {
    signal(DATA_ERROR_EVENT, "The server sent an unreadable payload.");
    return null;
  }
  rowsCache.set(topic.rowsPath, { at: Date.now(), payload: body });
  return body;
}

/** The customer topic's fetch β€” kept under its own name because half the write-path notes in

 *  this repo cite it; it IS `fetchTopicRows(CUSTOMER_TOPIC)`. */
export function fetchCustomers(): Promise<CustomersPayload | null> {
  return fetchTopicRows(CUSTOMER_TOPIC);
}

// ═══════════════════════════════════════════════════════════════════════════════════════════
// ⭐⭐ WAVE 30 Β· W30-T42 β€” THE READ-THROUGH WINDOW (owner ruling R6 via R7, contract C2).
// ═══════════════════════════════════════════════════════════════════════════════════════════
//
// THE SHAPE OF THE PROBLEM, in the owner's numbers. `tables/<key>/rows` serves a table by
// copying it out of the tenant's `user_tables` document, which is why `MAX_ROWS = 60_000`
// exists and why order lines (255,286) and GL lines (963,783) could never be grids at all.
// W30-T26 built the other door: `odoo-tables/<key>/rows` reads the DuckDB mirror and answers
// `{rows, total, totalUnfiltered, offset, limit, limits}` β€” the requested slice plus a
// `SELECT count(*)` that tells the truth about the whole.
//
// β›” THE CLIENT MACHINERY FOR THIS ALREADY EXISTED AND WAS DORMANT β€” `TableMode`,
// `ScopeCounts`, `countLabel`'s "showing 200 of 201,558", `useVisibleRows`' pass-through and
// every `serverWindowed` guard in `CustomerGrid`. What did not exist was anything that ever
// SET `counts.windowed`: a mode reachable only by a payload no fetch path had ever built. So
// what is added below is a fetch and a mapping, not an engine.
//
// β›” `counts.matched` IS THE SERVER'S `total`, NEVER `rows.length`. That is the fabricated
// aggregate this repo has paid for twice ([[no-unverifiable-aggregates]]) and it is the ONE
// number the whole contract exists to protect: a 200-row response describing 32,826 orders.

/** One window's worth of rows. 200 is what D measured the route at (79 ms warm on 32,700

 *  orders, against 474 ms for the whole read); the server clamps anything over

 *  `datastore.WINDOW_MAX` (5,000) and REPORTS the clamp rather than trimming quietly. */
export const WINDOW_ROWS = 200;

export interface RowWindowRequest {
  offset: number;
  limit: number;
  /** The saved view's OWN objects. β›” Not translated β€” see `windowRowsPath`. */
  filters?: FilterNode[] | null;
  filterConj?: string;
  sorts?: SortSpec | null;
  search?: string;
}

/**

 * The request path for one window β€” AND, deliberately, its cache key.

 *

 * β›” THE TICKET'S NAMED TRAP, SOLVED BY CONSTRUCTION. `rowsCache` is keyed by path. A windowed

 * path that omitted its offset would make page 2 a cache HIT on page 1 β€” a scroll that fetches,

 * succeeds, and paints the same rows forever. Rather than remember to key the cache on

 * "path + offset + predicate" at each call site, ONE function builds the string and the caller

 * uses it for both. They cannot disagree because they are the same value.

 *

 * β›” THE PREDICATE RIDES AS THE CLIENT'S OWN VOCABULARY. `filters` is `ViewConfig.filters`

 * (a `FilterNode[]`), `filterConj` is `ViewConfig.filterConj`, `sorts` is `ViewConfig.sorts` β€”

 * JSON, verbatim, no translation layer. D built `compile_filter_tree` to take exactly these

 * objects, and an unparseable `filters` is answered with a 400 rather than "no filter",

 * because a condition that is quietly dropped WIDENS and a wider answer looks plausible.

 *

 * ⚠ Empty parts are OMITTED, not sent blank: a view with no filter must produce the identical

 * path the first (predicate-free) window used, or the very first scroll misses the cache.

 */
export function windowRowsPath(tableKey: string, req: RowWindowRequest): string {
  const q = new URLSearchParams();
  q.set("offset", String(Math.max(0, Math.floor(req.offset || 0))));
  q.set("limit", String(Math.max(1, Math.floor(req.limit || WINDOW_ROWS))));
  if (req.filters && req.filters.length) {
    q.set("filters", JSON.stringify(req.filters));
    if (req.filterConj === "or") q.set("filterConj", "or");
  }
  if (req.sorts && req.sorts.length) q.set("sorts", JSON.stringify(req.sorts));
  const search = (req.search ?? "").trim();
  if (search) q.set("search", search);
  return `odoo-tables/${encodeURIComponent(tableKey)}/rows?${q.toString()}`;
}

/**

 * D's window envelope β†’ the payload this client already knows how to render.

 *

 * β›” `windowed` IS TRUE FOR EVERY READ-THROUGH RESPONSE, INCLUDING ONE THAT HAPPENS TO HOLD THE

 * WHOLE TABLE, and that is not sloppiness. The mode is about WHO EVALUATES THE PREDICATE, not

 * about whether this particular response was truncated. A 192-row table answered in full still

 * had its filter compiled to SQL β€” flipping to `whole-book` because nothing was cut would

 * switch the TypeScript engine back on and filter the server's already-filtered rows a second

 * time. `countLabel` handles the no-truncation case honestly on its own (`shown >= matched`

 * renders a plain count with no "showing … of"), so nothing is lost by keeping the mode stable.

 *

 * Returns null on a shape it cannot read β€” the caller raises the signal, because a payload the

 * client cannot parse is a failure, never an empty table.

 */
export function windowPayload(body: unknown): CustomersPayload | null {
  const b = body as {
    fields?: unknown; rows?: unknown; total?: unknown; totalUnfiltered?: unknown;
    limits?: unknown; today?: unknown; recordsMutable?: unknown;
  } | null;
  if (!b || !Array.isArray(b.rows) || !Array.isArray(b.fields)) return null;
  if (typeof b.total !== "number" || !Number.isFinite(b.total)) return null;
  const rows = b.rows as Row[];
  const unfiltered =
    typeof b.totalUnfiltered === "number" && Number.isFinite(b.totalUnfiltered)
      ? b.totalUnfiltered
      : b.total;
  const out: CustomersPayload = {
    fields: b.fields as Field[],
    rows,
    counts: {
      shown: rows.length,
      // β›” FROM THE SERVER'S COUNT STATEMENT. Never `rows.length`.
      matched: b.total,
      total: unfiltered,
      windowed: true,
    },
  };
  if (Array.isArray(b.limits)) out.limits = b.limits as GridLimit[];
  if (typeof b.today === "string") out.today = b.today;
  if (typeof b.recordsMutable === "boolean") out.recordsMutable = b.recordsMutable;
  return out;
}

/**

 * Fold a freshly-fetched window into what this browser already holds.

 *

 * β›” `offset === 0` REPLACES BUT MUST NOT BLANK THE WORKSPACE. A predicate change re-requests

 * from the top, and the window route serves rows β€” it knows nothing about saved views, folders,

 * cohorts, measures, documents or the viewer, all of which `withWorkspace` merged into the live

 * payload from a different call. Returning the bare response would empty the Views sidebar on

 * every keystroke in the search box. So the window's keys land OVER the existing payload.

 *

 * β›” AN APPEND WHOSE OFFSET DOES NOT MEET THE LOADED COUNT IS DROPPED, NOT GUESSED. Windows are

 * contiguous under a total order; a page that starts anywhere else is a response to a question

 * this browser no longer holds the answer to (a predicate changed mid-flight, two scrolls

 * raced). Appending it anyway would duplicate or skip rows β€” data the user sees, with nothing

 * erroring. Dropping it costs one scroll's latency and the next event re-asks.

 */
export function mergeWindow(

  prev: CustomersPayload | null,

  next: CustomersPayload,

  offset: number

): CustomersPayload | null {
  if (offset <= 0) return { ...(prev ?? {}), ...next };
  if (!prev || !prev.counts?.windowed) return prev;
  if (offset !== prev.rows.length) return prev;
  const rows = [...prev.rows, ...next.rows];
  return {
    ...prev,
    rows,
    limits: next.limits,
    // The NEWEST scope-wide numbers win (the table can move under a long scroll); only `shown`
    // is ours, because only this browser knows how much of it is actually here.
    counts: { ...(next.counts ?? prev.counts), shown: rows.length },
  };
}

/**

 * Which of this tenant's tables are served THROUGH the mirror β€” asked once per session.

 *

 * β›” READ FROM THE SERVER, NEVER A KEY LIST HERE. The bindings convert one bucket at a time

 * (`GRID_SOURCES`), and `odoo-tables/status` reports `readThrough` per table for exactly this

 * reason. A hard-coded list in the client would go stale on D's next spec row and fail in the

 * dangerous direction β€” asking the window route for a table it cannot serve, which answers 409.

 *

 * ⚠ `bound_not_declared` keys are DELIBERATELY not here: they ride a separate map on that

 * response because a binding whose field declaration has not landed answers 404 on the rows

 * route. Reading only `tables` is what keeps a half-shipped grid out of windowed mode.

 *

 * β›” FAIL-SOFT, AND IN THE SAFE DIRECTION. Any failure yields the empty set, i.e. today's

 * whole-book behaviour, and the memo is NOT poisoned β€” a transient 500 must not pin a session

 * into the slow path for as long as the tab is open.

 */
let readThroughMemo: Promise<Set<string>> | null = null;

export function readThroughTables(): Promise<Set<string>> {
  if (!readThroughMemo) {
    readThroughMemo = (async () => {
      try {
        const res = await fetch(`${API_V1}/odoo-tables/status`, { credentials: CREDENTIALS });
        if (handledSession(res) || !res.ok) {
          readThroughMemo = null;
          return new Set<string>();
        }
        const body = (await readJson(res)) as
          { tables?: Record<string, { readThrough?: boolean }> } | null;
        const out = new Set<string>();
        for (const [key, t] of Object.entries(body?.tables ?? {}))
          if (t?.readThrough === true) out.add(key);
        return out;
      } catch {
        readThroughMemo = null;
        return new Set<string>();
      }
    })();
  }
  return readThroughMemo;
}

/** `GET /api/v1/odoo-tables/<key>/rows?…` β€” one window, mapped. Null on any failure, having

 *  raised the matching signal (a null is a failure, never an empty table). */
export async function fetchTableWindow(

  tableKey: string,

  req: RowWindowRequest

): Promise<CustomersPayload | null> {
  const path = windowRowsPath(tableKey, req);
  const cached = rowsCache.get(path);
  if (cached && Date.now() - cached.at < CUSTOMERS_FRESH_MS) return cached.payload;
  let res: Response;
  try {
    res = await fetch(`${API_V1}/${path}`, { credentials: CREDENTIALS });
  } catch {
    signal(DATA_ERROR_EVENT, "Cannot reach the server.");
    return null;
  }
  if (handledSession(res)) return null;
  if (!res.ok) {
    // ⚠ THE SERVER'S OWN SENTENCE, NOT A STATUS CODE. `filter_unsupported` explains which
    // condition has no SQL form and what to use instead; "The server answered 400." explains
    // nothing, which is the defect `refusalMessage` was extracted for one wave ago.
    const body = await readJson(res);
    signal(DATA_ERROR_EVENT, refusalMessage(body, res.status));
    return null;
  }
  const payload = windowPayload(await readJson(res));
  if (!payload) {
    signal(DATA_ERROR_EVENT, "The server sent an unreadable payload.");
    return null;
  }
  rowsCache.set(path, { at: Date.now(), payload });
  return payload;
}

/**

 * `GET /api/v1/workspace` β€” the saved views, custom fields, folders and cohorts

 * for the session user (S1's amendment, 2026-07-30). It exists because X2 pins

 * `/customers` at the EXACT current shape, which carries no `workspace`, and

 * without one the standalone write path is WRITE-ONLY: a saved view reaches the

 * store and is gone from the screen on reload.

 *

 * The body is `{workspace: GridWorkspace}` β€” the client's own built type

 * (types.ts `GridWorkspace`), which is what the embed's host already projects.

 *

 * ⚠ ABSENT IS FINE AND MEANS TODAY'S BEHAVIOUR. A 404 (route not shipped) or a

 * body carrying no workspace return null and the grid runs exactly as it does

 * now β€” views in memory, no `storageKey`, nothing durable. It degrades to the

 * honest gap rather than failing the whole read, because the workspace is an

 * enhancement of the table, not a precondition for it. A 401 still ends the

 * session: that is the one status that is never about this route.

 *

 * β›” WAVE 21 item 3 (3c) β€” BUT A REFUSAL IS NOT AN ABSENCE, and conflating the

 * two is half of the "RI fields on a new database" report.

 *

 * This used to answer `null` to every unhappy path alike: route-not-shipped,

 * 403, 500, connection refused, garbage body. The caller cannot tell those

 * apart, so it did the only thing it could β€” carried on without a workspace β€”

 * and `CustomerGrid` then had no `storageKey` and fell back to a bucket shared

 * with every other surface (see the note at its `storageKey`). A tenant whose

 * `/workspace` 403s for `ut_*` therefore got ANOTHER TABLE'S views and fields,

 * silently, with the grid looking entirely healthy.

 *

 * `announceFailure` is the caller saying "I am the INITIAL load; if this fails

 * for a reason that is not 'no workspace here', say so". It raises the same

 * `DATA_ERROR_EVENT` a failed rows read raises, so the frame renders its honest

 * failure card instead of a grid furnished with somebody else's schema.

 *

 * ⚠ IT IS OFF BY DEFAULT, and that is load-bearing rather than cautious.

 * `reread()` calls this on every workspace-stale signal, and its own rule is

 * "absent stays absent β€” never blank a live panel"; a signal from there would

 * let one dropped packet replace a working table with an error card. The flag

 * is passed explicitly at the one call site that owns the first paint.

 */
export async function fetchWorkspace(

  scope: SurfaceScope = "customer",

  opts: { announceFailure?: boolean; allowCached?: boolean } = {},

): Promise<GridWorkspace | null> {
  const loud = opts.announceFailure === true;
  // ⭐ W31-T21 β€” OPT IN, NEVER BY DEFAULT. Only the first-paint read of a surface may be served
  // from the memo; the post-write `reread()` calls this with no options and therefore always goes
  // to the server, which is what keeps the memo from becoming a second source of truth.
  if (opts.allowCached === true) {
    const hit = wsCache.get(scope);
    if (hit && Date.now() - hit.at < CUSTOMERS_FRESH_MS) return hit.ws;
  }
  let res: Response;
  try {
    res = await fetch(`${API_V1}/workspace?scope=${encodeURIComponent(scope)}`,
                      { credentials: CREDENTIALS });
  } catch {
    if (loud) signal(DATA_ERROR_EVENT, "Cannot reach the server.");
    return null;
  }
  if (handledSession(res)) return null;
  if (!res.ok) {
    // 404 is the ONE status that means "this host does not serve a workspace",
    // which is the enhancement posture above and stays quiet. Everything else
    // is a server that had an answer and would not give it.
    if (loud && res.status !== 404)
      // ⭐ WAVE 30 β€” the server's own sentence when it wrote one, this route's specific fallback
      // when it did not. Same repair as the rows path above; `refusalMessage`'s fallback argument
      // exists precisely so a call site with a better default keeps it.
      signal(DATA_ERROR_EVENT, refusalMessage(
        await readJson(res), res.status,
        `The server answered ${res.status} for this table's saved views and columns.`));
    return null;
  }
  const ws = (await readJson(res)) as { workspace?: GridWorkspace } | null;
  const w = ws?.workspace;
  // A 200 carrying no workspace is still an ABSENCE, not a refusal β€” an older
  // host, or a scope this one has nothing stored for. Unchanged, and quiet.
  const good = w && typeof w.storageKey === "string" && Array.isArray(w.views) ? w : null;
  // ⭐ W31-T21 β€” EVERY SUCCESSFUL READ REFRESHES THE MEMO, including the post-write `reread()`
  // that is not allowed to CONSUME it. That asymmetry is the whole safety argument: the write
  // path can never leave a stale envelope behind for the next switch to serve.
  // ⚠ A FAILURE LEAVES THE PREVIOUS ENTRY ALONE rather than poisoning it with `null` β€” the same
  // "absent stays absent, never blank a live panel" posture `reread()` already takes.
  if (good) wsCache.set(scope, { at: Date.now(), ws: good });
  return good;
}

// --- the CHANGE POLLER (wave 29, item 20 / R11 / contract C6) ---------------

/**

 * `GET /api/v1/changes?scope=…` β†’ `{bucket: opaque token}`, or null.

 *

 * ⚠ A FAILED POLL IS SILENT. It raises no `DATA_ERROR_EVENT`, because the frame REPLACES the whole

 * surface with an error card on that signal β€” so one dropped packet, six times a minute, would turn

 * a working grid into a failure screen. `changedBuckets` reads a null as "no baseline, no change"

 * and the tab simply carries on with what it has. 401 still ends the session: that is the one

 * status which is never about this route.

 */
export async function fetchChangeTokens(scope: string): Promise<ChangeTokens | null> {
  let res: Response;
  try {
    res = await fetch(`${API_V1}/changes?scope=${encodeURIComponent(scope)}`,
                      { credentials: CREDENTIALS });
  } catch {
    return null;
  }
  if (handledSession(res)) return null;
  if (!res.ok) return null;
  const body = (await readJson(res)) as { tokens?: unknown } | null;
  const tokens = body?.tokens;
  if (!tokens || typeof tokens !== "object" || Array.isArray(tokens)) return null;
  const out: ChangeTokens = {};
  for (const [bucket, token] of Object.entries(tokens as Record<string, unknown>))
    out[bucket] = typeof token === "string" ? token : null;
  return out;
}

/** ~10 s. The owner's ask is "a new record shows up in about ten seconds", and the request it

 *  costs is a dict read server-side β€” see `routes_changes.py` for why that is the whole design. */
export const CHANGE_POLL_MS = 10_000;

interface ChangeWatch {
  timer: ReturnType<typeof setInterval> | null;
  tokens: ChangeTokens | null;
  listeners: Set<(changed: string[]) => void>;
  inFlight: boolean;
  /** ⭐ ONE poll implementation per watch, held here so the interval and the visibility handler

   *  call the SAME function. The visibility handler used to re-inline the body, which quietly

   *  skipped `inFlight` β€” so returning to a tab whose poll was still outstanding stacked a second

   *  request on top of it, exactly what that guard exists to prevent. */
  poll: () => Promise<void>;
}

const changeWatches = new Map<string, ChangeWatch>();
let visibilityBound = false;

/**

 * Watch one scope for server-side changes. Returns its unsubscribe.

 *

 * ⭐ ONE TIMER PER SCOPE, REFCOUNTED, and that is a budget rather than tidiness. A linked-record

 * grid mounts a second `useCustomerData` on the same surface, and a per-hook interval would double

 * the poll rate for a table the user opened once. The done-when is "roughly 6 tiny calls per minute

 * per tab" β€” per TAB, not per component.

 *

 * β›” IT DOES NOT POLL A HIDDEN TAB, and it polls IMMEDIATELY on becoming visible again. A background

 * tab left open overnight would otherwise spend the night asking a question nobody can see the

 * answer to, and the one moment its answer certainly matters is the moment you look at it.

 * `visibilitychange` had zero listeners in this client before this.

 */
export function subscribeChanges(scope: string,

                                 onChange: (changed: string[]) => void): () => void {
  if (typeof window === "undefined") return () => {};
  let watch = changeWatches.get(scope);
  if (!watch) {
    const w0: ChangeWatch = {
      timer: null, tokens: null, listeners: new Set(), inFlight: false,
      poll: async () => {
        // A slow answer must not queue a second request behind itself: on a cold Space the first
        // poll can outlive the interval, and without this the backlog grows while it stays slow.
        if (w0.inFlight || document.visibilityState !== "visible") return;
        w0.inFlight = true;
        try {
          const next = await fetchChangeTokens(scope);
          const changed = changedBuckets(w0.tokens, next);
          if (next) w0.tokens = next;
          if (changed.length) for (const fn of [...w0.listeners]) fn(changed);
        } finally {
          w0.inFlight = false;
        }
      },
    };
    watch = w0;
    changeWatches.set(scope, w0);
  }
  const w = watch;
  w.listeners.add(onChange);

  if (!w.timer) {
    void w.poll();                            // take the baseline now, not in ten seconds
    w.timer = setInterval(() => { void w.poll(); }, CHANGE_POLL_MS);
  }
  if (!visibilityBound) {
    visibilityBound = true;
    window.addEventListener("visibilitychange", () => {
      if (document.visibilityState !== "visible") return;
      for (const watched of changeWatches.values())
        if (watched.timer) void watched.poll();
    });
  }

  return () => {
    w.listeners.delete(onChange);
    if (w.listeners.size === 0 && w.timer) {
      clearInterval(w.timer);
      w.timer = null;
      // ⚠ The tokens are DROPPED with the last listener. A remount must re-baseline rather than
      // compare against a token from before it was unmounted: the payload it is about to fetch is
      // fresh by definition, so treating the interval it missed as "a change" would cost a second
      // full read of what it just read.
      w.tokens = null;
    }
  };
}

/**

 * X2 `PATCH /api/v1/<topic.rowsPath>/{pid}` β€” the overlay stratum only (Odoo is never

 * written). Kept as its own route rather than folded into the events log

 * because X2 keeps it: cell edits are the one write with a per-row identity and

 * a rollback the caller already implements.

 */
export async function patchTopicRow(

  topic: TopicConfig,

  pid: number,

  updates: Partial<Row>,

  /**

   * ⭐ Wave-25 (C3/R6) β€” what the SERVER says this row now holds, for callers that keep an

   * optimistic copy. Two things a cell PATCH can now report that the request cannot predict:

   *   Β· a CANONICALISED value β€” a profile column stores `nurilab` for `@Nurilab` or a pasted

   *     profile URL, so the optimistic copy is a value the store never took;

   *   Β· CLEARED cells β€” blanking a profile handle also clears that row's enriched columns, and

   *     the client never typed those, so nothing else would ever repaint them.

   * An optional callback rather than a richer return type, deliberately: the boolean IS the

   * rollback contract every existing caller is written against, and widening it would make the

   * two call sites that ignore this the ones most likely to get it wrong.

   */

  onAccepted?: (accepted: Partial<Row>, cleared: string[]) => void,

): Promise<boolean> {
  try {
    const res = await fetch(`${API_V1}/${topic.rowsPath}/${pid}`, {
      method: "PATCH",
      credentials: CREDENTIALS,
      headers: JSON_HEADERS,
      body: JSON.stringify(updates),
    });
    if (handledSession(res)) return false;
    const cached = rowsCache.get(topic.rowsPath);
    if (res.ok) {
      // Write the ACCEPTED values (the server's own report, not the request)
      // through into the cached rows, so a remount within the cache window
      // shows this edit instead of the pre-edit pull.
      const body = (await readJson(res)) as
        { updates?: Record<string, unknown>; cleared?: unknown } | null;
      const accepted = body?.updates;
      const cleared = Array.isArray(body?.cleared)
        ? (body.cleared as unknown[]).filter((k): k is string => typeof k === "string")
        : [];
      const blanks: Partial<Row> = {};
      for (const k of cleared) blanks[k] = "";
      if (accepted && typeof accepted === "object") {
        const row = cached?.payload.rows.find((r) => r.pid === pid);
        if (row) Object.assign(row, accepted, blanks);
        onAccepted?.({ ...(accepted as Partial<Row>), ...blanks }, cleared);
      }
      // A user-table edit may change a reciprocal Link or a Rollup in another database. Their
      // row payloads share no cache key with this table, so invalidate the user-table family and
      // let the active grid re-read the server's materialised relationship cells.
      if (topic.rowsPath.startsWith("tables/")) {
        // ⚠ WAVE 30 β€” `odoo-tables/` too. A Link or a Rollup can fold a CONNECTED table's rows
        // into this one, so a user-table edit can move a cell on a read-through grid served by
        // the other route; sweeping only `tables/` would leave that grid painting the pre-write
        // aggregate. The family is the whole rows layer, not one door into it.
        for (const key of [...rowsCache.keys()])
          if (key.startsWith("tables/") || key.startsWith("odoo-tables/")) rowsCache.delete(key);
        signal(ROWS_STALE_EVENT);
      }
    }
    return res.ok;
  } catch {
    return false;
  }
}

/** The customer topic's patch, by its historical name. */
export function patchCustomer(pid: number, updates: Partial<Row>): Promise<boolean> {
  return patchTopicRow(CUSTOMER_TOPIC, pid, updates);
}

/**

 * ⭐ Wave-20 owner item 4 / contract C-ADDROW β€” **APPEND A ROW TO A USER DATABASE**, and

 * (C-UNDO) restore a deleted one under its old id.

 *

 * USER TABLES ONLY, and the refusal is structural rather than checked here: no other scope has

 * a `/tables/{key}/rows` endpoint at all. A connector's rows are read-synced from its source β€”

 * R8 is explicit that a "+" which must refuse is a fake affordance, so the caller never renders

 * one there.

 *

 * ⚠ THE ANSWER IS THE ID THAT WAS STORED, never the one that was asked for. An undo that

 * requests `rid` may find that id re-used, and the server's own note says it answers with what

 * it actually wrote; the caller re-anchors on the returned value rather than assuming.

 *

 * ⚠ The rows cache is CLEARED on success. `fetchTopicRows` holds a 5-minute window per topic,

 * so a re-read straight after an append would serve the payload from before it β€” the new row

 * would appear minutes later, which reads as "the button did nothing". Same reason the shell's

 * retired Add-record bar cleared it (wave 20 item 4 moved that door here).

 */
export async function addTableRow(

  tableKey: string,

  values: Record<string, unknown> = {},

  rid?: string | number

): Promise<{ rid: string | number; pid: number } | null> {
  try {
    const res = await fetch(`${API_V1}/tables/${encodeURIComponent(tableKey)}/rows`, {
      method: "POST",
      credentials: CREDENTIALS,
      headers: JSON_HEADERS,
      body: JSON.stringify(rid === undefined ? { values } : { rid, values }),
    });
    if (handledSession(res)) return null;
    const body = (await readJson(res)) as { rid?: string | number; pid?: number;
                                            error?: { message?: string } } | null;
    if (!res.ok) {
      // The server states WHY (a row cap, a store refusal). Surfacing its sentence beats a
      // generic failure toast, and staying silent would be the worst of the three.
      const why = refusalMessage(body, res.status);
      signal(TOAST_EVENT, why);
      return null;
    }
    if (body?.rid === undefined || typeof body?.pid !== "number") {
      signal(DATA_ERROR_EVENT, "The server did not say which row it created.");
      return null;
    }
    dropTableRowsCache(tableKey);
    return { rid: body.rid, pid: body.pid };
  } catch {
    signal(DATA_ERROR_EVENT, "Cannot reach the server.");
    return null;
  }
}

/**

 * ⭐ WAVE 21 item 7 (contract C2) β€” ADD A COLUMN TO A USER DATABASE'S **DEFINITION**,

 * not to this user's overlay.

 *

 * β›” THE BUG THIS EXISTS FOR. Every column the grid creates has always gone out as a

 * `field_upsert` EVENT, which lands in the per-user workspace stratum

 * (`<key>_table_workspace`). That is right for the connector surfaces, where a custom

 * column IS one person's annotation of somebody else's data. It is wrong for a user

 * database, where the columns ARE the table β€” and it is why the automation editor's

 * "Automation column" picker was empty by construction: that picker reads the DEFINITION

 * (`user_tables`), which the grid had never written to. Two stores, one word, and the two

 * surfaces disagreed with no error anywhere. The route has existed since wave 18 with no

 * client caller; this is that caller, and `routes_tables.py`'s own comment above it names

 * this exact defect.

 *

 * ⚠ SCOPED TO THE AUTOMATION KIND THIS WAVE (C2, deliberately narrow). Moving EVERY kind

 * onto the definition is the right end state and is booked as debt: it changes who can see

 * a column (everyone with the table, not just its author), which is a visible change to

 * shipped behaviour on three surfaces and does not belong in a wave that is fixing this

 * one's blindness.

 *

 * ⚠ THE SERVER'S FIELD IS THE ANSWER, never the request. `add_field` re-slugs and

 * re-types on the way in, so the caller inserts what came BACK β€” a column ordered under a

 * key the store does not hold is a column that renders nowhere.

 */
/**

 * ⭐⭐ 2026-08-07 (D-79's last half) β€” PATCH one column's DEFINITION on a user table.

 *

 * β›” WHY IT EXISTS: wave 25 shipped the profile flag's READER everywhere β€” the column-menu line,

 * the cell validator, R6's clear-on-write, the one-per-table refusal β€” and never its WRITER. The

 * route (`PATCH /tables/{key}/fields/{fkey}`) has accepted `profile` since that wave with no

 * client caller, so the enrich action could not be bound to any database a person made

 * themselves. This is that caller.

 *

 * ⚠ THE SERVER'S FIELD IS THE ANSWER, never the request β€” the same rule `addTableField` carries.

 * `_clean_field` enforces `type: 'text'` for a profile flag and refuses a SECOND one with a

 * sentence naming the column that already has it, so the refusal is worth surfacing verbatim.

 */
/**

 * ⭐ 2026-08-07 β€” the sibling databases a `link` column may point at, WITH their fields.

 *

 * `GET /tables` already returns exactly this (key, label, fields) filtered by `may_open`, so

 * there is no new route and no new wall: a database you cannot open is a database you cannot

 * link to, decided by the same predicate that decides whether you can see it at all.

 *

 * ⚠ Returns `[]` on any failure rather than throwing. The link editor renders "no other

 * databases yet" for an empty list, which is the truthful reading of both an empty workspace

 * and an unreachable server β€” and a create pane that explodes because a picker could not fetch

 * is worse than one that says it has nothing to offer.

 */
export async function fetchLinkTargets(): Promise<LinkTarget[]> {
  try {
    const res = await fetch(`${API_V1}/tables`, { credentials: CREDENTIALS });
    if (handledSession(res) || !res.ok) return [];
    const body = (await readJson(res)) as { tables?: LinkTarget[] } | null;
    return Array.isArray(body?.tables) ? body.tables : [];
  } catch {
    return [];
  }
}

export interface LinkTarget {
  key: string;
  label: string;
  fields: Field[];
}

/**

 * ⭐⭐ 2026-08-09 β€” the READ-THROUGH rollup's offer: which governed Odoo topic, which metric of

 * it, grouped by which dimension, over which date window.

 *

 * β›” WHY THIS IS A DIFFERENT LIST FROM `fetchLinkTargets`. A LINK rollup folds rows that live in

 * the workspace, so its offer is "the other databases". A SOURCE rollup folds rows that were

 * never copied here at all β€” 256,810 order lines answered by one grouped SQL query β€” so its

 * offer is the semantic model, and the server derives it from `model/topics/*.yml` +

 * `model/metrics/*.yml` rather than from anything the client knows.

 *

 * ⚠ Returns an EMPTY offer on any failure rather than throwing, for the same reason the link

 * picker does: the editor then renders "no live sources are available", which is the truthful

 * reading of both a tenant with no Odoo mirror and an unreachable server.

 */
export async function fetchRollupSources(): Promise<RollupSourceOffer> {
  try {
    const res = await fetch(`${API_V1}/tables/rollup-sources`, { credentials: CREDENTIALS });
    if (handledSession(res) || !res.ok) return { topics: [], windows: [] };
    const body = (await readJson(res)) as Partial<RollupSourceOffer> | null;
    return {
      topics: Array.isArray(body?.topics) ? body!.topics! : [],
      windows: Array.isArray(body?.windows) ? body!.windows! : [],
    };
  } catch {
    return { topics: [], windows: [] };
  }
}

export interface RollupSourceOffer {
  topics: RollupSourceTopic[];
  windows: { key: string; label: string }[];
}

export interface RollupSourceTopic {
  key: string;
  label: string;
  grain: string;
  /** `keyedBy` says whether the group key is an Odoo ID or the dimension's own value β€” which is

   *  what decides which column on THIS database it should be matched against. */
  dims: { key: string; label: string; keyedBy: "id" | "value" }[];
  measures: { key: string; label: string; format: string; description: string }[];
}

export async function patchTableField(

  tableKey: string,

  fieldKey: string,

  patch: Record<string, unknown>

): Promise<Field | null> {
  try {
    const res = await fetch(
      `${API_V1}/tables/${encodeURIComponent(tableKey)}/fields/${encodeURIComponent(fieldKey)}`,
      { method: "PATCH", credentials: CREDENTIALS, headers: JSON_HEADERS,
        body: JSON.stringify(patch) }
    );
    if (handledSession(res)) return null;
    const body = (await readJson(res)) as
      | { field?: Field; error?: { message?: string } }
      | null;
    if (!res.ok) {
      signal(TOAST_EVENT, refusalMessage(body, res.status));
      return null;
    }
    // ⭐ 2026-08-09 β€” the rows cache is a SCHEMA behind after this too, and for a rollup it is a
    // VALUES behind: `PATCH /fields` refreshes the relations host-side, so the aggregate in every
    // row changed the moment this returned. `addTableField` has dropped it since the day it was
    // written; this door needed the same line and did not have it.
    dropTableRowsCache(tableKey);
    return body?.field ?? null;
  } catch {
    signal(TOAST_EVENT, "That change did not reach the server.");
    return null;
  }
}

/**

 * ⭐⭐ 2026-08-10 β€” REMOVE a column from a user database's SHARED definition.

 *

 * β›” THE ROUTE HAS EXISTED SINCE THE DEFINITION DOORS WERE BUILT AND NOTHING EVER CALLED IT β€”

 * `DELETE /api/v1/tables/{key}/fields/{fkey}`, mounted, guarded by `_field_or_refuse`, and

 * unreachable from the product ([[artifact-with-no-importer]] from the consumer end). The

 * consequence was invisible while only `link`/`rollup` used that stratum, because both arrive as

 * pre-set columns on the databases people actually have. It stops being invisible the moment a

 * FORMULA column lands there: `field_delete` (the overlay event) scrubs a per-user bucket the

 * definition does not read, so the column would come back on the next render and the Delete

 * control would be a button that lies.

 *

 * ⚠ NOT optimistic, unlike the overlay delete. The server refuses the last remaining column and

 * refuses a pre-set one (`may_edit_field`), and removing a column from the screen that the store

 * still holds is the same lie in the other direction.

 */
export async function deleteTableField(

  tableKey: string,

  fieldKey: string

): Promise<boolean> {
  try {
    const res = await fetch(
      `${API_V1}/tables/${encodeURIComponent(tableKey)}/fields/${encodeURIComponent(fieldKey)}`,
      { method: "DELETE", credentials: CREDENTIALS }
    );
    if (handledSession(res)) return false;
    if (!res.ok) {
      const body = (await readJson(res)) as { error?: { message?: string } } | null;
      signal(TOAST_EVENT, refusalMessage(body, res.status));
      return false;
    }
    // The schema moved, and a deleted LINK takes its reciprocal and every rollup that folded it
    // with it (`_refresh_relations` runs on this route) β€” so the cached rows are values behind,
    // not just a column behind. Same line `patchTableField` needed.
    dropTableRowsCache(tableKey);
    return true;
  } catch {
    signal(TOAST_EVENT, "That delete did not reach the server.");
    return false;
  }
}

/**

 * ⭐⭐ 2026-08-09 β€” THE DEFINITION SHAPE, NOT A THREE-KEY SUMMARY.

 *

 * This used to be typed `{key, label, type}`, which was not merely narrow β€” it was the second

 * half of the defect that made a user-built Rollup permanently blank. `ColumnMenu` builds a

 * complete `rollup` bag and hands it over; a parameter type naming three keys meant the only

 * caller sent three keys, so the bag never left the browser. `_clean_field` REFUSES a rollup

 * with no bag, so the shape below is what makes the create legal at all.

 */
export interface TableFieldDefinition {
  key: string;
  label: string;
  type: string;
  link?: Record<string, unknown>;
  rollup?: Record<string, unknown>;
  options?: string[];
  [extra: string]: unknown;
}

export async function addTableField(

  tableKey: string,

  field: TableFieldDefinition

): Promise<Field | null> {
  try {
    const res = await fetch(`${API_V1}/tables/${encodeURIComponent(tableKey)}/fields`, {
      method: "POST",
      credentials: CREDENTIALS,
      headers: JSON_HEADERS,
      body: JSON.stringify(field),
    });
    if (handledSession(res)) return null;
    const body = (await readJson(res)) as
      | { field?: Field; error?: { message?: string } }
      | null;
    if (!res.ok) {
      // The server states WHY β€” the column cap, an unknown type (this is the 400 to expect
      // until `UT_FIELD_TYPES` learns `automation`, C2's other half), a wall refusal. Its
      // sentence beats anything invented here.
      const why = refusalMessage(body, res.status);
      signal(TOAST_EVENT, why);
      return null;
    }
    const made = body?.field;
    if (!made || typeof made.key !== "string") {
      signal(DATA_ERROR_EVENT, "The server did not say which column it created.");
      return null;
    }
    // The definition feeds BOTH payloads for a user table (`ut_assembly` builds the rows
    // envelope and `/workspace` from the same `fields_base`), so the cached rows are now a
    // schema behind. Cheap to drop, and a stale schema is not a stale number β€” it is a
    // column that exists on the server and nowhere on screen.
    dropTableRowsCache(tableKey);
    return made;
  } catch {
    signal(DATA_ERROR_EVENT, "Cannot reach the server.");
    return null;
  }
}

/**

 * ⭐ WAVE 21 item 11 (ruling R10, contract C5) β€” parse an uploaded `.xlsx`/`.csv` and

 * hand back its COLUMNS AND VALUES as strings.

 *

 * β›” THE FILE NEVER BECOMES DATA. This reads a spreadsheet to find out which records the

 * user means; nothing here writes a row, creates a field, or keeps the file. The server

 * parses (openpyxl is not a thing a browser has) and answers with strings β€” the matching

 * itself is client-side, over rows this browser already holds (`fileSelect.ts`).

 *

 * ⚠ `truncated` IS THE CONTRACT'S POINT. C5 caps a column at 20k values, and a cap the

 * caller cannot see is a silent wrong answer: a 30k-row file would report ten thousand

 * records "not found" that are simply past the cap. The dialog surfaces this flag as its

 * own sentence, never folded into the miss count ([[no-unverifiable-aggregates]]).

 */
export interface TabularUpload {
  columns: string[];
  rows: number;
  values: Record<string, string[]>;
  truncated: boolean;
}

export async function uploadTabular(file: File): Promise<TabularUpload | null> {
  const form = new FormData();
  form.append("file", file);
  try {
    // ⚠ NO Content-Type HEADER. The browser must set it, because only the browser knows
    // the multipart boundary it generated β€” writing `multipart/form-data` by hand omits
    // the boundary and the server cannot parse the body at all.
    const res = await fetch(`${API_V1}/uploads/tabular`, {
      method: "POST",
      credentials: CREDENTIALS,
      body: form,
    });
    if (handledSession(res)) return null;
    const body = (await readJson(res)) as
      | { columns?: unknown; rows?: unknown; values?: unknown; truncated?: unknown;
          error?: { message?: string } }
      | null;
    if (!res.ok) {
      const why = refusalMessage(
        body,
        res.status,
        `The file could not be read (the server answered ${res.status}).`
      );
      signal(TOAST_EVENT, why);
      return null;
    }
    const columns = Array.isArray(body?.columns)
      ? body.columns.filter((c): c is string => typeof c === "string")
      : [];
    const rawValues = (body?.values && typeof body.values === "object" ? body.values : {}) as
      Record<string, unknown>;
    const values: Record<string, string[]> = {};
    for (const col of columns)
      values[col] = Array.isArray(rawValues[col])
        ? (rawValues[col] as unknown[]).map((v) => (v === null || v === undefined ? "" : String(v)))
        : [];
    if (!columns.length) {
      signal(TOAST_EVENT, "That file has no readable columns.");
      return null;
    }
    return {
      columns,
      rows: typeof body?.rows === "number" ? body.rows : 0,
      values,
      truncated: body?.truncated === true,
    };
  } catch {
    signal(DATA_ERROR_EVENT, "Cannot reach the server.");
    return null;
  }
}

/** C-UNDO's other half: drop a row this session just added. Same cache rule as the append. */
export async function deleteTableRow(

  tableKey: string,

  rid: string | number

): Promise<boolean> {
  try {
    const res = await fetch(
      `${API_V1}/tables/${encodeURIComponent(tableKey)}/rows/${encodeURIComponent(String(rid))}`,
      { method: "DELETE", credentials: CREDENTIALS }
    );
    if (handledSession(res)) return false;
    if (res.ok) dropTableRowsCache(tableKey);
    return res.ok;
  } catch {
    return false;
  }
}

/**

 * Item 7 (contract C-TS) β€” `POST /api/v1/grid/timeseries`.

 *

 * A READ that is a POST, because the request body carries a pid list that can run to

 * thousands: a URL cannot hold the caller's book, and putting customer ids in a query string

 * would also put them in every access log. The server intersects those pids with the caller's

 * `allowed_pids` regardless, so the body narrows the answer and can never widen it.

 *

 * ⚠ RETURNS `null` ON EVERY FAILURE, AND THE PANEL MUST SAY SO RATHER THAN DRAW ZEROES.

 * That is the difference between this and a payload of empty buckets: "we could not ask" and

 * "the answer is nothing" look identical on a chart and are not the same claim. A 400 is the

 * honest "narrow the span"; a 403 is `out_of_scope` (every requested pid was outside the

 * caller's book); a 401 ends the session like everywhere else.

 */
export async function fetchTimeseries(

  body: TsRequest,

  scope: SurfaceScope = surfaceScope

): Promise<{ payload: TsPayload | null; status: number }> {
  try {
    const res = await fetch(
      `${API_V1}/grid/timeseries?scope=${encodeURIComponent(scope)}`,
      {
        method: "POST",
        credentials: CREDENTIALS,
        headers: JSON_HEADERS,
        body: JSON.stringify(body),
      }
    );
    if (handledSession(res)) return { payload: null, status: res.status };
    if (!res.ok) return { payload: null, status: res.status };
    const json = (await readJson(res)) as TsPayload | null;
    // Shape-check before trusting it: `columns` and `rows` are indexed POSITIONALLY against
    // each other, so a payload missing either would index into undefined all the way down.
    if (!json || !Array.isArray(json.columns) || !Array.isArray(json.rows))
      return { payload: null, status: res.status };
    return { payload: json, status: res.status };
  } catch {
    return { payload: null, status: 0 };
  }
}

/**

 * wave17 owner item 9 / ruling R4 (contract C-CAL) β€” per-DAY measure values.

 *

 * β›” WHY A SECOND CHANNEL rather than a parameter on the time-series one: **every group carries

 * its OWN pid set.** A calendar day holds whatever records the date field placed there, so the

 * subject changes from cell to cell; one `allowed_pids` for the whole request would compute

 * every day over everybody, which is a different question with the same shape.

 *

 * The caps are the SERVER's and it answers a 400 with its reason rather than trimming: 31 days,

 * 6 fields, 186 cells. The client's job is to send no more than that, never to guess what was

 * dropped. `values[field][day] === null` is UNANSWERED β€” a day past the tenant's today, a day

 * whose pids are all outside the reader's book, or a measure that could not resolve β€” and the

 * renderer draws "β€”" for all three. Never 0.

 */
export interface CalMetricsResponse {
  values: Record<string, Record<string, number | null>>;
  today?: string;
  /** C-TSWIN at day grain: which window each metric was resolved under. */
  windows?: Record<string, { kind: string; label: string }>;
  dropped?: { field: string; reason: string }[];
  problems?: string[];
}

export async function fetchCalendarMetrics(

  body: { groups: { key: string; pids: number[] }[]; fields: string[] },

  scope: SurfaceScope = surfaceScope

): Promise<{ payload: CalMetricsResponse | null; status: number }> {
  try {
    const res = await fetch(
      `${API_V1}/grid/calendar_metrics?scope=${encodeURIComponent(scope)}`,
      {
        method: "POST",
        credentials: CREDENTIALS,
        headers: JSON_HEADERS,
        body: JSON.stringify(body),
      }
    );
    if (handledSession(res)) return { payload: null, status: res.status };
    if (!res.ok) return { payload: null, status: res.status };
    const json = (await readJson(res)) as CalMetricsResponse | null;
    // Shape-checked before it is trusted, the `fetchTimeseries` rule: every lookup below is
    // `values[field][day]`, so a payload without `values` would read `undefined` as a value and
    // paint blanks that look like real "β€”" refusals.
    if (!json || !json.values || typeof json.values !== "object")
      return { payload: null, status: res.status };
    return { payload: json, status: res.status };
  } catch {
    return { payload: null, status: 0 };
  }
}

// --- the WRITE (CP-C) ------------------------------------------------------

/**

 * The replay bound. The SAME 24 the Streamlit value slot uses, for a different

 * reason: there, the window exists because one slot gets clobbered by a second

 * write; here, it bounds how much a failed POST may carry forward. Both rest on

 * the same server guarantee β€” X2 dedups by event id β€” so re-sending is free and

 * losing an event is not.

 */
export const MAX_REPLAY = 24;

let pending: HostEvent[] = [];
let inFlight = false;

/**

 * Which SURFACE this grid is drawing β€” `customer` (the whole scoped pool),

 * `cohort` (hand-curated sets) or `product` (the SKU table β€” wave 16 C-TOPIC).

 * It lives at module scope beside the queue on purpose: the queue is already

 * module-level, and an event must carry the scope it was CREATED under even if

 * it drains after a route change.

 *

 * ⚠ The server refuses an unknown value rather than defaulting it, so this is

 * never a free-text field. Read and write must agree on what a scope is: the

 * grid asks `/workspace?scope=X` and every event it then emits says `X`.

 */
export type SurfaceScope = "customer" | "cohort" | "product" | `ut_${string}`;
let surfaceScope: SurfaceScope = "customer";

export function setSurfaceScope(scope: SurfaceScope): void {
  surfaceScope = scope;
}

/** For the gate: the queue is module state and a test needs a known start. */
export function _resetQueue(): void {
  pending = [];
  inFlight = false;
}

export function _pendingIds(): string[] {
  return pending.map((e) => e.id);
}

/**

 * ONE request in flight at a time, and the queue drains in order.

 *

 * ORDER IS NOT COSMETIC: `field_upsert` then `view_upsert` is a field that

 * exists and a view that shows it; the other way round is a view referencing a

 * column the store has never heard of. Parallel POSTs would race exactly there.

 *

 * ⚠ A FAILED BATCH IS REQUEUED BUT NOT RETRIED ON A TIMER. It goes back at the

 * FRONT (it happened first) and rides the next emit. A self-scheduling retry

 * against a server that is down is a tight loop, and a timer is a background

 * behaviour nobody asked for; the next user action is a perfectly good clock.

 * The cost is bounded and known: with no further action, the last batch stays

 * unsent β€” which is strictly better than today, where standalone drops every

 * event on the floor.

 */
async function drain(): Promise<void> {
  if (inFlight || pending.length === 0) return;
  inFlight = true;
  const batch = pending;
  pending = [];
  let requeue = false;
  try {
    const res = await fetch(`${API_V1}/grid/events`, {
      method: "POST",
      credentials: CREDENTIALS,
      headers: JSON_HEADERS,
      body: JSON.stringify({ events: batch, scopeKey: surfaceScope }),
    });
    // A dead session is NOT retryable β€” replaying into a 401 forever would
    // turn one expired cookie into an unbounded request loop.
    if (!handledSession(res)) {
      if (res.ok) applyEventResult(await readJson(res));
      else requeue = true;
    }
  } catch {
    requeue = true;
  } finally {
    inFlight = false;
    if (requeue) {
      // Front, and bounded: events that arrived during the flight are NEWER.
      pending = [...batch, ...pending].slice(-MAX_REPLAY);
    } else if (pending.length) {
      void drain();
    }
  }
}

/**

 * X2's `{results:[{id, rerender}], doc?, toast?}`.

 *

 * `results` needs nothing: the client is already optimistic and the server's

 * per-event ack carries no correction. `toast` is surfaced β€” for the

 * echo-dependent events it is the only feedback that exists in standalone.

 * `doc` is deliberately unhandled; see this file's header.

 */
function applyEventResult(body: unknown): void {
  const b = body as {
    toast?: unknown; rerender?: unknown; results?: unknown; derived?: unknown;
  } | null;
  const toast = b?.toast;
  if (typeof toast === "string" && toast.trim() !== "") signal(TOAST_EVENT, toast.trim());

  // owner item 2 β€” a measure column's values, computed server-side right after the write and
  // returned here rather than waiting for the `/workspace` re-read below. Raised BEFORE the
  // stale signal so the numbers paint on the earlier of the two, whichever the server sent.
  // Shape-checked, because a shortcut that corrupts rows is worse than no shortcut.
  if (b?.derived && typeof b.derived === "object" && !Array.isArray(b.derived))
    signal(DERIVED_CELLS_EVENT, b.derived);

  // The server already tells us when a write changed durable state β€” `rerender`
  // is exactly the flag the Streamlit adapter uses to trigger its own rerun. We
  // were dropping it, which is why cohort membership, list adds and folder moves
  // never appeared in standalone until a manual reload.
  const results = Array.isArray(b?.results) ? (b.results as { rerender?: unknown }[]) : [];
  if (b?.rerender === true || results.some((r) => r?.rerender === true)) {
    signal(WORKSPACE_STALE_EVENT);
  }
}

/** The sink hostBridge falls through to in standalone. Returns true: the event

 *  is now OURS (queued and owned), which is all the caller ever needed. */
function standaloneSink(event: HostEvent): boolean {
  pending.push(event);
  if (pending.length > MAX_REPLAY) pending.shift();
  void drain();
  return true;
}

/** Called from main.tsx in standalone ONLY. Never in the embed β€” that is the

 *  whole of "standalone-only branches", enforced at the one wiring point. */
export function installStandaloneBridge(): void {
  setStandaloneSink(standaloneSink);
}