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);
}
|