Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +1 -1
- VERSION +1 -1
- api/automation_engine.py +0 -0
- api/connectors_bd.py +745 -0
- api/connectors_ig.py +289 -636
- api/connectors_tt.py +237 -10
- api/main.py +9 -0
- api/odoo_relational.py +335 -21
- api/providers.py +7 -2
- api/routes_automation.py +147 -5
- api/routes_connectors.py +38 -8
- api/routes_customers.py +20 -1
- api/routes_grid.py +19 -4
- api/routes_odoo_tables.py +804 -6
- api/routes_products.py +66 -3
- api/routes_tables.py +175 -0
- platform/aios_grid.py +20 -2
- platform/aios_grid_fields.json +464 -423
- platform/core/grid_events.py +58 -0
- platform/core/user_tables.py +0 -0
- platform/harness/datastore.py +135 -2
- platform/modules/customer_data.py +140 -4
- platform/modules/customers.py +16 -4
- platform/modules/product_data.py +220 -4
- platform/modules/products.py +172 -0
- web/public/sample_customers.json +545 -529
- web/src/automation/AutomationBuilder.tsx +0 -0
- web/src/automation/AutomationDetail.tsx +62 -7
- web/src/automation/AutomationSurface.tsx +35 -3
- web/src/automation/TriggerPicker.tsx +52 -5
- web/src/automation/automationApi.ts +58 -1
- web/src/connectors/ConnectorsPage.tsx +127 -18
- web/src/connectors/brandMarks.tsx +294 -0
- web/src/customer-grid/CustomerGrid.tsx +172 -1
- web/src/customer-grid/ImportDialog.tsx +4 -4
- web/src/customer-grid/apiBridge.ts +0 -0
- web/src/customer-grid/counts.ts +193 -1
- web/src/customer-grid/icons.tsx +29 -5
- web/src/customer-grid/types.ts +44 -0
- web/src/customer-grid/useCustomerData.ts +109 -8
- web/src/customer-grid/useGridColumns.ts +28 -1
- web/src/filter-kit/FieldsHidePanel.tsx +17 -7
- web/src/index.css +91 -0
- web/src/shell/ErrorBoundary.tsx +136 -0
- web/src/shell/Shell.tsx +48 -8
- web/src/shell/errorBoundary.css +32 -0
RELEASES.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v24",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "4258a93",
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v24",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
4258a93
|
api/automation_engine.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
api/connectors_bd.py
ADDED
|
@@ -0,0 +1,745 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""connectors_bd.py -- THE BRIGHT DATA TRANSPORT. One vendor's wire, and nothing else.
|
| 2 |
+
|
| 3 |
+
ββ WAVE 30 Β· T09 (DEBT D-128). Every function below was CORRECT CODE UNDER A WRONG NAME: it lived
|
| 4 |
+
in `connectors_ig.py` and took the dataset id as a PARAMETER, which is the definition of not being
|
| 5 |
+
Instagram's. `connectors_tt.py` therefore had to import thirteen names from the Instagram connector
|
| 6 |
+
to reach a wire neither platform owns -- and the module header there said so in as many words
|
| 7 |
+
(*"THE TRANSPORT IS BORROWED FROM `connectors_ig`, ON PURPOSE ... the name is wrong and the code is
|
| 8 |
+
right"*). This file is that sentence resolved.
|
| 9 |
+
|
| 10 |
+
ββ **THIS MODULE IS THE ONE PLACE A SOCKET IS OPENED TO BRIGHT DATA, AND THAT IS A TESTABILITY
|
| 11 |
+
PROPERTY BEFORE IT IS AN ARCHITECTURAL ONE.** `bd_call` is the single door: `bd_scrape`,
|
| 12 |
+
`bd_snapshot_progress` and the three corpus functions all reach the network THROUGH it, resolving it
|
| 13 |
+
from THIS module's globals. So one monkeypatch on `connectors_bd.bd_call` seals every paid path in
|
| 14 |
+
the product, for both platforms, whatever the caller. Before the split there was no such point --
|
| 15 |
+
`connectors_ig.bd_call` sealed Instagram's rungs and TikTok's, but only because TikTok's connector
|
| 16 |
+
had borrowed Instagram's function object, which is a coincidence of an import rather than a
|
| 17 |
+
guarantee. [[seal-the-transport-not-the-rung]] is the standing rule; this module is where it becomes
|
| 18 |
+
structural. β `verify_automation.py:_seal_bd` patches here AND sweeps `sys.modules` for any other
|
| 19 |
+
binding of the same name, because a re-export elsewhere is a second door that reads exactly as green.
|
| 20 |
+
|
| 21 |
+
β **WHAT IS DELIBERATELY *NOT* HERE: ANY PLATFORM'S VOCABULARY.** No `BD_DS_*` dataset id (those are
|
| 22 |
+
per-platform and stay with their connector), no field map, no profile/post/comment mapper. The test
|
| 23 |
+
for whether something belongs here is not the `bd_` prefix -- it is whether the function could serve
|
| 24 |
+
a THIRD platform tomorrow without an edit. β One Instagram assumption was found INSIDE the moved code
|
| 25 |
+
and is now a parameter rather than a literal: `bd_filter_start`'s engine-added exclusion rule named
|
| 26 |
+
the column `account`, which B-20 MEASURED as `account_id` in Bright Data's TikTok Profiles dataset.
|
| 27 |
+
It rides on every discovery run, is invisible to the condition builder, and would have reached the
|
| 28 |
+
vendor on the first TikTok search that had anything to exclude. See `handle_field`.
|
| 29 |
+
|
| 30 |
+
β **THE ROW PRIMITIVES IN SECTION 3 ARE HERE FOR A REASON AND IT IS NOT TIDINESS.** They are the
|
| 31 |
+
readers every Bright Data mapper needs (a vendor row is JSON with two or three names for one fact),
|
| 32 |
+
both connectors used them, and leaving them in `connectors_ig.py` would have kept the import line
|
| 33 |
+
this ticket exists to delete. They know nothing about Instagram. β `_ig_int` KEEPS ITS NAME on
|
| 34 |
+
purpose: it is referenced by name in the engine and in exact-match gate fixtures, and a rename buys
|
| 35 |
+
nothing but a diff.
|
| 36 |
+
"""
|
| 37 |
+
from __future__ import annotations
|
| 38 |
+
|
| 39 |
+
import json
|
| 40 |
+
import os
|
| 41 |
+
import time
|
| 42 |
+
|
| 43 |
+
import requests
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
# =============================================================================================
|
| 47 |
+
# 1. THE WIRE -- auth, one HTTP door, the scraper endpoints
|
| 48 |
+
# =============================================================================================
|
| 49 |
+
BD_BASE_DEFAULT = "https://api.brightdata.com"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
BD_PATH_SCRAPE = "/datasets/v3/scrape" # SYNC: rows come back inline. param: dataset_id
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
BD_PATH_TRIGGER = "/datasets/v3/trigger" # ASYNC: -> {"snapshot_id": "sd_β¦"}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
BD_PATH_SNAPSHOT = "/datasets/v3/snapshot" # /<sd_id>?format=json -> the rows
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
#: ββ 2026-08-09 β THE SNAPSHOT'S OWN STATUS DOCUMENT, and asking it is the difference between
|
| 62 |
+
#: "the vendor is still working" and "the vendor finished and collected nothing". MEASURED on
|
| 63 |
+
#: `sd_msl80v7l1ti14rhcd6` (nurilab's `roxyfoxypinky`): `{"status": "ready", "records": 0,
|
| 64 |
+
#: "errors": 1, "error_codes": {"crawl_error": 1}, "collection_duration": 320513}`. The rows
|
| 65 |
+
#: endpoint answers `[]` for that snapshot forever, which `bd_scrape`'s old `if rows:` poll could
|
| 66 |
+
#: not distinguish from a snapshot mid-build β so it burned its whole budget and then reported
|
| 67 |
+
#: *"the records are collected, not lost"* about a batch that had collected nothing at all.
|
| 68 |
+
#: β SCRAPER NAMESPACE ONLY (`sd_β¦`). The corpus has no twin here; its status is
|
| 69 |
+
#: `BD_PATH_FILTER_SNAPSHOT/<snap_id>`, and crossing the two 404s (Β§2c).
|
| 70 |
+
BD_PATH_PROGRESS = "/datasets/v3/progress" # /<sd_id> -> {status, records, errors, error_codes}
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
BD_PATH_FILTER = "/datasets/filter" # β NO /v3/ β the CORPUS query (discovery)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
BD_PATH_FILTER_SNAPSHOT = "/datasets/snapshot" # /<snap_id> -> status
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
#: A sync scrape MEASURED at 15β23 s for one URL and ~40 s for two, so the timeout is generous;
|
| 80 |
+
#: it is a ceiling against a hung socket, not a latency budget.
|
| 81 |
+
BD_TIMEOUT = 300.0
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
#: The READ ceiling for one vendor answer, in KB. `fetch` truncates SILENTLY at this size, so it
|
| 85 |
+
#: is load-bearing arithmetic rather than a round number: MEASURED `file_size` was 175,617 bytes
|
| 86 |
+
#: for 5 corpus rows β **~35 KB per profile row**, and `BD_MAX_RECORDS` is set from it with
|
| 87 |
+
#: headroom (500 Γ 35 KB β 17 MB against 32 MB). A truncated body is REPORTED as truncated in
|
| 88 |
+
#: `bd_call` β never allowed to surface as "that is not JSON".
|
| 89 |
+
BD_MAX_KB = 32768
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
#: How long a run waits on a scrape batch the vendor DEFERRED (see `_bd_deferral`). Much shorter
|
| 93 |
+
#: than the corpus filter's budget because a scrape snapshot is minutes, not tens of minutes β
|
| 94 |
+
#: and unlike the corpus path there is no handoff to a later run, so this is the whole wait.
|
| 95 |
+
BD_SCRAPE_WAIT = float(os.environ.get("AIOS_BD_SCRAPE_WAIT") or 180)
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
BD_SCRAPE_POLL = float(os.environ.get("AIOS_BD_SCRAPE_POLL") or 10)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# A Profile response is the enrichment's identity half, so it retains the normal bounded
|
| 102 |
+
# synchronous wait. Post/Reel engagement can create several snapshots per profile; action
|
| 103 |
+
# runners hand those off immediately and the scheduler collects the already-paid work later.
|
| 104 |
+
BD_METRIC_SCRAPE_WAIT = float(os.environ.get("AIOS_BD_METRIC_SCRAPE_WAIT") or 0)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
#: β SPEC, NOT MEASURED β off the pricing page, never returned by the API (the funds gate fires
|
| 108 |
+
#: BEFORE a price is quoted, and `price: 0` means "not priced", not "free"). Every surface that
|
| 109 |
+
#: shows a number derived from this MUST say it is an estimate. `/customer/balance` answers 403
|
| 110 |
+
#: for our token, so there is no way to check a balance or a spend from here either.
|
| 111 |
+
BD_RECORD_PRICE_SPEC = 0.0025
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def bd_key():
|
| 115 |
+
"""The key, or ''. Read fresh every call β see the module note above on why."""
|
| 116 |
+
return (os.environ.get("AIOS_BRIGHTDATA_KEY") or "").strip()
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def bd_base():
|
| 120 |
+
return (os.environ.get("AIOS_BRIGHTDATA_BASE") or BD_BASE_DEFAULT).strip().rstrip("/")
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def bd_ready():
|
| 124 |
+
"""Is the paid rung configured at all? The UI reads this to say so honestly."""
|
| 125 |
+
return bool(bd_key())
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _bd_why(raw, key=""):
|
| 129 |
+
"""The vendor's OWN words for a refusal, as `" β β¦"`, or `""` when it gave none.
|
| 130 |
+
|
| 131 |
+
β THIS WAS THROWN AWAY, AND THE COST WAS MEASURED RATHER THAN IMAGINED. nurilab's live
|
| 132 |
+
discovery automation failed four runs across two days with `the profile source answered 400`
|
| 133 |
+
while the body it discarded said, in full:
|
| 134 |
+
|
| 135 |
+
{"validation_errors":["Filter logical groups can have a maximum of 4 rules."]}
|
| 136 |
+
|
| 137 |
+
β the exact instruction needed to fix it, in our hands, deleted one line before the customer.
|
| 138 |
+
A bare status code sends somebody to read a vendor's schema for a fault we were already
|
| 139 |
+
holding the answer to; it is the same defect as a green dot over nothing, pointed the other
|
| 140 |
+
way. Every non-2xx says WHY now, and the named cases above (402/401/403/429) keep their own
|
| 141 |
+
plainer sentences because those are ours to explain, not the vendor's.
|
| 142 |
+
"""
|
| 143 |
+
from automation_engine import _s # lazy β see the module header
|
| 144 |
+
try:
|
| 145 |
+
text = bytes(raw or b"").decode("utf-8", "replace").strip()[:600]
|
| 146 |
+
except Exception: # noqa: BLE001
|
| 147 |
+
return ""
|
| 148 |
+
if not text:
|
| 149 |
+
return ""
|
| 150 |
+
why = ""
|
| 151 |
+
try:
|
| 152 |
+
d = json.loads(text)
|
| 153 |
+
except Exception: # noqa: BLE001
|
| 154 |
+
d = None
|
| 155 |
+
if isinstance(d, dict):
|
| 156 |
+
errs = d.get("validation_errors") or d.get("errors")
|
| 157 |
+
if isinstance(errs, list) and errs:
|
| 158 |
+
why = "; ".join(str(e) for e in errs)
|
| 159 |
+
else:
|
| 160 |
+
why = str(d.get("error") or d.get("message") or d.get("detail") or "")
|
| 161 |
+
why = why or text
|
| 162 |
+
# β A BODY WE DID NOT WRITE GETS THE SAME TREATMENT AS AN EXCEPTION'S str(). `bd_call` already
|
| 163 |
+
# refuses to put the latter on the wire in case it carries the key; promising that a vendor
|
| 164 |
+
# never echoes an Authorization header back is not a guarantee this module can make.
|
| 165 |
+
if key and key in why:
|
| 166 |
+
why = why.replace(key, "***")
|
| 167 |
+
return f" β {_s(why, 300)}"
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def bd_call(path, params=None, body=None):
|
| 171 |
+
"""One authenticated vendor call. GET when `body` is None, POST otherwise.
|
| 172 |
+
|
| 173 |
+
Returns `(payload, note)`; a non-empty `note` means it did NOT answer, and the note is safe to
|
| 174 |
+
show a user and safe to write to a log.
|
| 175 |
+
|
| 176 |
+
Never raises. Every refusal shape β no key, a guarded base, a 402, a 429, a transport error,
|
| 177 |
+
unparseable JSON β comes back as a note so the caller can record the attempt and drop to the
|
| 178 |
+
next rung.
|
| 179 |
+
"""
|
| 180 |
+
from automation_engine import Refused, fetch, fetch_json # lazy β see the module header
|
| 181 |
+
key = bd_key()
|
| 182 |
+
if not key:
|
| 183 |
+
# β THE FAIL-CLOSED PATH (C4). Named precisely, because "blocked" with no reason sends
|
| 184 |
+
# somebody to read Instagram's status page instead of setting an env var.
|
| 185 |
+
return None, "AIOS_BRIGHTDATA_KEY is not configured β the paid rung is closed"
|
| 186 |
+
qs = "&".join(f"{k}={requests.utils.quote(str(v))}"
|
| 187 |
+
for k, v in (params or {}).items() if v not in (None, ""))
|
| 188 |
+
url = f"{bd_base()}{path}" + (f"?{qs}" if qs else "")
|
| 189 |
+
hdrs = {"Authorization": f"Bearer {key}", "Accept": "application/json"}
|
| 190 |
+
try:
|
| 191 |
+
if body is None:
|
| 192 |
+
status, _final, raw = fetch(url, timeout=BD_TIMEOUT, max_kb=BD_MAX_KB, headers=hdrs)
|
| 193 |
+
else:
|
| 194 |
+
status, raw = fetch_json(url, body, timeout=BD_TIMEOUT, max_kb=BD_MAX_KB,
|
| 195 |
+
headers=hdrs)
|
| 196 |
+
except Refused as e:
|
| 197 |
+
# The BASE was refused by the SSRF rail β a configuration fault, not a vendor outage.
|
| 198 |
+
return None, f"the profile source's address was refused by the URL rail: {e}"
|
| 199 |
+
except Exception as e: # noqa: BLE001
|
| 200 |
+
# β `type(e).__name__` only. A requests exception's str() can carry the full URL, and the
|
| 201 |
+
# Authorization header is one refactor away from being a query param; this line must not
|
| 202 |
+
# be what leaks it.
|
| 203 |
+
return None, f"the profile source did not answer ({type(e).__name__})"
|
| 204 |
+
if status == 402:
|
| 205 |
+
return None, "the profile search is out of credit"
|
| 206 |
+
if status in (401, 403):
|
| 207 |
+
return None, f"the profile source refused our key ({status})"
|
| 208 |
+
if status == 429:
|
| 209 |
+
# β MEASURED during the discovery probe: `too_many_parallel_jobs`. The filter API has a
|
| 210 |
+
# concurrency cap and the probe's most important test died on it β hence every caller
|
| 211 |
+
# below is SERIAL by construction.
|
| 212 |
+
return None, "the profile source is busy with another search; they run one at a time"
|
| 213 |
+
if not (200 <= status < 300):
|
| 214 |
+
return None, f"the profile source answered {status}{_bd_why(raw, key)}"
|
| 215 |
+
# β A BODY THAT REACHED THE READ CEILING IS A TRUNCATION, AND IT MUST SAY SO. `fetch` reads
|
| 216 |
+
# at most `max_kb` and returns what it got β silently. A truncated JSON document then fails
|
| 217 |
+
# `json.loads`, falls through to the NDJSON branch, and comes back as "answered with
|
| 218 |
+
# something that is not JSON": a sentence that sends somebody to read the vendor's schema
|
| 219 |
+
# looking for a fault that is OUR byte ceiling β after a result set that has already been
|
| 220 |
+
# paid for. Naming it here is the same no-silent-caps rule the row and table ceilings follow.
|
| 221 |
+
if len(raw) >= BD_MAX_KB * 1024:
|
| 222 |
+
return None, (f"the profile source's answer exceeded this client's {BD_MAX_KB // 1024} MB read "
|
| 223 |
+
f"ceiling and was TRUNCATED β ask for fewer records; nothing was parsed "
|
| 224 |
+
f"from a partial document")
|
| 225 |
+
text = raw.decode("utf-8", "replace").strip()
|
| 226 |
+
if not text:
|
| 227 |
+
return None, f"the profile source answered {status} with an empty body"
|
| 228 |
+
try:
|
| 229 |
+
return json.loads(text), ""
|
| 230 |
+
except Exception: # noqa: BLE001
|
| 231 |
+
pass
|
| 232 |
+
# NDJSON is the other shape this API uses for row sets. Tried SECOND, so a genuine JSON error
|
| 233 |
+
# is not silently reinterpreted as a one-line NDJSON document.
|
| 234 |
+
rows = []
|
| 235 |
+
for line in text.splitlines():
|
| 236 |
+
line = line.strip()
|
| 237 |
+
if not line:
|
| 238 |
+
continue
|
| 239 |
+
try:
|
| 240 |
+
rows.append(json.loads(line))
|
| 241 |
+
except Exception: # noqa: BLE001
|
| 242 |
+
return None, "the profile source answered with something we could not read"
|
| 243 |
+
return (rows, "") if rows else (None, "the profile source answered with something we could not read")
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _bd_rows(payload):
|
| 247 |
+
"""A vendor answer β the list of row dicts inside it, whatever envelope it arrived in.
|
| 248 |
+
|
| 249 |
+
MEASURED shapes: a bare list (`/v3/scrape`), NDJSON (already listified by `bd_call`), and
|
| 250 |
+
`{"data": [...]}` / `{"results": [...]}` on the snapshot reads. An unreadable envelope yields
|
| 251 |
+
NOTHING rather than a guess.
|
| 252 |
+
"""
|
| 253 |
+
if isinstance(payload, list):
|
| 254 |
+
return [x for x in payload if isinstance(x, dict)]
|
| 255 |
+
if isinstance(payload, dict):
|
| 256 |
+
for k in ("data", "results", "records", "items"):
|
| 257 |
+
v = payload.get(k)
|
| 258 |
+
if isinstance(v, list):
|
| 259 |
+
return [x for x in v if isinstance(x, dict)]
|
| 260 |
+
return [payload] if payload else []
|
| 261 |
+
return []
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def _bd_deferral(payload):
|
| 265 |
+
"""The snapshot id when the 'sync' endpoint DEFERRED, else ''.
|
| 266 |
+
|
| 267 |
+
β MEASURED 2026-08-05, AND IT IS THE TRAP OF THIS WHOLE RUNG. `/v3/scrape` is documented and
|
| 268 |
+
named as the synchronous call, and for a small batch it is β two profile URLs came back
|
| 269 |
+
inline. Give it more work and it answers **200** with no rows and a note instead:
|
| 270 |
+
|
| 271 |
+
{"snapshot_id": "sd_β¦", "message": "Your request is still in progress and cannot be
|
| 272 |
+
retrieved in this call. Use the provided Snapshot ID to track progressβ¦"}
|
| 273 |
+
|
| 274 |
+
Read naively that is a 200 with a body, so a parser looking for "did it error?" sails past
|
| 275 |
+
it, `_bd_rows` wraps the envelope as ONE unusable row, and the caller reports that the vendor
|
| 276 |
+
returned nothing β **while a snapshot we have already been billed for sits on the account,
|
| 277 |
+
finishing, and is never collected.** That is the D-26 failure shape (paid work abandoned)
|
| 278 |
+
arriving through the success path.
|
| 279 |
+
β It is a TIME threshold, not a row count: 2 URLs answered inline, 6 and 10 deferred.
|
| 280 |
+
"""
|
| 281 |
+
if isinstance(payload, dict) and payload.get("snapshot_id") and not payload.get("account"):
|
| 282 |
+
return str(payload["snapshot_id"])
|
| 283 |
+
return ""
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def bd_snapshot_progress(sid):
|
| 287 |
+
"""One scraper snapshot's status. `(state, records, note)`.
|
| 288 |
+
|
| 289 |
+
state β `running` | `done` | `failed` | `unknown`:
|
| 290 |
+
Β· `running` β the vendor is still collecting; poll again.
|
| 291 |
+
Β· `done` β finished. `records` says whether it produced anything.
|
| 292 |
+
Β· `failed` β finished, produced NOTHING, and the vendor blamed the TARGET (an
|
| 293 |
+
`error_codes` entry such as `crawl_error`). On a profile request that is the
|
| 294 |
+
vendor saying it could not reach that account at all, which is a different
|
| 295 |
+
action from "we found no matches" β so it is a distinct state rather than a
|
| 296 |
+
phrase inside the note. A caller that has to grep an English sentence to
|
| 297 |
+
decide what happened is a caller whose behaviour changes when someone
|
| 298 |
+
improves the wording.
|
| 299 |
+
Β· `unknown` β the status call itself did not answer; fall back to probing the rows.
|
| 300 |
+
|
| 301 |
+
ββ 2026-08-09 β THE QUESTION `bd_scrape` COULD NOT ASK. Its poll loop advanced only on
|
| 302 |
+
`if rows:`, so a snapshot the vendor had FINISHED with zero records looked byte-for-byte like
|
| 303 |
+
one still building: the rows endpoint answers `[]` in both cases. MEASURED on nurilab's one
|
| 304 |
+
pending handle β `status: ready, records: 0, errors: 1, crawl_error: 1` after 320 s β while
|
| 305 |
+
the enrich run reported *"deferred β¦ the records are collected, not lost"*, which was false
|
| 306 |
+
about that batch and had been re-reported on three separate runs.
|
| 307 |
+
|
| 308 |
+
β `unknown` IS NOT `done`, and the distinction is the whole reason this returns three states
|
| 309 |
+
rather than a boolean. When the status call itself fails we must fall back to the old
|
| 310 |
+
row-probing behaviour, not conclude the snapshot is empty β an unreachable status endpoint
|
| 311 |
+
would otherwise turn every deferral into a confident "the vendor found nothing".
|
| 312 |
+
|
| 313 |
+
β THE NOTE IS DELIBERATELY VENDOR-NEUTRAL about WHAT was being read. This serves profile,
|
| 314 |
+
post, reel and comment snapshots alike; a sentence naming "the account" would be wrong on
|
| 315 |
+
three of the four, and the caller knows which it asked for.
|
| 316 |
+
"""
|
| 317 |
+
payload, err = bd_call(f"{BD_PATH_PROGRESS}/{sid}", None, None)
|
| 318 |
+
if err or not isinstance(payload, dict):
|
| 319 |
+
return "unknown", 0, ""
|
| 320 |
+
status = str(payload.get("status") or "").strip().lower()
|
| 321 |
+
|
| 322 |
+
def _n(key):
|
| 323 |
+
try:
|
| 324 |
+
return int(payload.get(key) or 0)
|
| 325 |
+
except (TypeError, ValueError):
|
| 326 |
+
return 0
|
| 327 |
+
|
| 328 |
+
records, errors = _n("records"), _n("errors")
|
| 329 |
+
if status not in ("ready", "done", "failed", "error"):
|
| 330 |
+
return "running", records, ""
|
| 331 |
+
if records > 0:
|
| 332 |
+
return "done", records, ""
|
| 333 |
+
codes = payload.get("error_codes")
|
| 334 |
+
named = (", ".join(f"{str(k).replace('_', ' ')} x{v}"
|
| 335 |
+
for k, v in sorted(codes.items()))
|
| 336 |
+
if isinstance(codes, dict) and codes else "")
|
| 337 |
+
if errors or named or status in ("failed", "error"):
|
| 338 |
+
return "failed", 0, ("the source finished this request and collected nothing"
|
| 339 |
+
+ (f" β it reported {named}" if named else "")
|
| 340 |
+
+ "; the target is unreachable, private, or no longer exists")
|
| 341 |
+
return "done", 0, "the source finished this request and found no records for it"
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def bd_scrape(dataset_id, urls, wait=None, deferred=None):
|
| 345 |
+
"""Scrape a batch. `(rows, note)`; a note means it did not answer.
|
| 346 |
+
|
| 347 |
+
Sends the SYNC call and, when the vendor defers it (see `_bd_deferral`), **collects the
|
| 348 |
+
snapshot it handed back** rather than discarding it. One call carries many URLs, which is
|
| 349 |
+
what keeps the per-profile pacing floor from turning a 20-profile run into an hour.
|
| 350 |
+
"""
|
| 351 |
+
requested_urls = [str(u) for u in (urls or []) if u]
|
| 352 |
+
payload, note = bd_call(BD_PATH_SCRAPE, {"dataset_id": dataset_id},
|
| 353 |
+
body=[{"url": u} for u in requested_urls])
|
| 354 |
+
if note:
|
| 355 |
+
return [], note
|
| 356 |
+
sid = _bd_deferral(payload)
|
| 357 |
+
if not sid:
|
| 358 |
+
return _bd_rows(payload), ""
|
| 359 |
+
|
| 360 |
+
# --- THE DEFERRED PATH. Poll the scraper namespace (`sd_β¦` at `/datasets/v3/β¦` β crossing it
|
| 361 |
+
# with the corpus namespace is a flat 404 about a snapshot that is alive).
|
| 362 |
+
budget = BD_SCRAPE_WAIT if wait is None else float(wait)
|
| 363 |
+
waited = 0.0
|
| 364 |
+
while True:
|
| 365 |
+
# β THE STATUS DOCUMENT IS ASKED FIRST, because it is the only surface that can say
|
| 366 |
+
# "finished, and there was nothing". Fetching rows while the vendor is still collecting
|
| 367 |
+
# is also a wasted call on every single poll.
|
| 368 |
+
state, records, empty_note = bd_snapshot_progress(sid)
|
| 369 |
+
if state in ("done", "failed") and not records:
|
| 370 |
+
return [], empty_note # a definitive EMPTY, never our timeout sentence
|
| 371 |
+
if state != "running":
|
| 372 |
+
got, gnote = bd_call(f"{BD_PATH_SNAPSHOT}/{sid}", {"format": "json"})
|
| 373 |
+
if not gnote:
|
| 374 |
+
rows = _bd_rows(got)
|
| 375 |
+
# The status document is itself JSON, so "did it deliver?" is decided by whether
|
| 376 |
+
# what came back looks like ROWS β never by the HTTP code.
|
| 377 |
+
if rows and not _bd_deferral(got) and not (
|
| 378 |
+
len(rows) == 1 and str(rows[0].get("status") or "") in
|
| 379 |
+
("running", "building", "collecting")):
|
| 380 |
+
return rows, ""
|
| 381 |
+
if waited >= budget:
|
| 382 |
+
break
|
| 383 |
+
time.sleep(BD_SCRAPE_POLL)
|
| 384 |
+
waited += BD_SCRAPE_POLL
|
| 385 |
+
# β NAME THE SNAPSHOT. It has been paid for and it is still finishing; a note that said only
|
| 386 |
+
# "no rows" would throw away both the data and the way to get it.
|
| 387 |
+
if isinstance(deferred, list):
|
| 388 |
+
deferred.append({"snapshotId": sid, "datasetId": str(dataset_id),
|
| 389 |
+
"urls": requested_urls})
|
| 390 |
+
return [], (f"the profile source deferred this batch to {sid} and it was not ready within "
|
| 391 |
+
f"{int(budget)}s β the records are collected, not lost; ask for a smaller batch "
|
| 392 |
+
f"or collect {sid} from the control panel")
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
# =============================================================================================
|
| 396 |
+
# 2. THE CORPUS FILTER -- `POST /datasets/filter`, a DIFFERENT namespace from the scraper
|
| 397 |
+
# =============================================================================================
|
| 398 |
+
# β Snapshots minted here are `snap_β¦` and are read at `/datasets/snapshot/β¦`; the scraper's are
|
| 399 |
+
# `sd_β¦` at `/datasets/v3/snapshot/β¦`, and the two 404 each other. Both halves live in this module
|
| 400 |
+
# so that pairing is visible in one place instead of being rediscovered per platform.
|
| 401 |
+
#: β THE VENDOR'S HARD CAP ON ONE LOGICAL GROUP β MEASURED 2026-08-06 against the live filter API
|
| 402 |
+
#: with nurilab's own seven-condition search, which had failed every run for two days:
|
| 403 |
+
#:
|
| 404 |
+
#: 400 {"validation_errors":["Filter logical groups can have a maximum of 4 rules."]}
|
| 405 |
+
#:
|
| 406 |
+
#: β EVERY ONE OF THOSE SEVEN CONDITIONS IS ACCEPTED ALONE (all seven probed individually, all
|
| 407 |
+
#: 200). The refusal is about the SHAPE of the group and nothing else β so no field, operator or
|
| 408 |
+
#: value in the builder is at fault, and no amount of editing the conditions would have found it.
|
| 409 |
+
#: What it means without `bd_group` below: the FIFTH condition anybody adds turns a working
|
| 410 |
+
#: discovery automation into one that fails forever, and the surface blames the search.
|
| 411 |
+
BD_GROUP_MAX = 4
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def bd_group(nodes, operator):
|
| 415 |
+
"""`nodes` β an equivalent list in which NO logical group exceeds `BD_GROUP_MAX` rules.
|
| 416 |
+
|
| 417 |
+
β SAFE BECAUSE AND AND OR ARE ASSOCIATIVE. Folding a run of same-operator rules into a nested
|
| 418 |
+
group of the SAME operator cannot change what the filter matches β `aβ§bβ§cβ§dβ§e` and
|
| 419 |
+
`(aβ§bβ§cβ§d)β§e` are one predicate written two ways. That is why this is a re-SHAPING of the
|
| 420 |
+
owner's search rather than a reinterpretation of it, and why it needs no ruling: a condition
|
| 421 |
+
list means the same thing before and after. Nesting itself is already proven on this API
|
| 422 |
+
(`expand_predicates`' or-groups, `snap_msh14ix81n8hlpo3nr`).
|
| 423 |
+
|
| 424 |
+
β A ONE-RULE CHUNK IS NEVER WRAPPED. `expand_predicates` already carries that law for the
|
| 425 |
+
one-value case β a one-element group is a shape nothing has ever been billed against β and a
|
| 426 |
+
balanced split reaches it whenever a level divides with a remainder of one.
|
| 427 |
+
"""
|
| 428 |
+
nodes = list(nodes or [])
|
| 429 |
+
if len(nodes) <= BD_GROUP_MAX:
|
| 430 |
+
return nodes
|
| 431 |
+
# β BALANCED, NOT "take three and nest the rest". The tail-recursive shape costs one level of
|
| 432 |
+
# DEPTH per extra rule (13 conditions β 5 deep) against a vendor whose nesting limit we have
|
| 433 |
+
# not measured and would discover the same way we discovered this one: in production, on
|
| 434 |
+
# somebody's automation. Chunking makes the depth logβ(n) β 16 conditions fit in two levels.
|
| 435 |
+
out = []
|
| 436 |
+
for i in range(0, len(nodes), BD_GROUP_MAX):
|
| 437 |
+
chunk = nodes[i:i + BD_GROUP_MAX]
|
| 438 |
+
out.append(chunk[0] if len(chunk) == 1 else {"operator": operator, "filters": chunk})
|
| 439 |
+
return bd_group(out, operator)
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
#: β DEBT D-68 β THE VENDOR'S NESTING CEILING, MEASURED IN PRODUCTION 2026-08-06:
|
| 443 |
+
#: 400 β "filter" failed custom validation because logical operators cannot be more than
|
| 444 |
+
#: 3 levels deep
|
| 445 |
+
#: β AND IT IS NOT AN INDEPENDENT BUDGET FROM `BD_GROUP_MAX`. A >4-rule group is fixed by
|
| 446 |
+
#: NESTING, so the 4-rule cap is what MANUFACTURES depth: five top-level conditions carrying
|
| 447 |
+
#: multi-value lists goes `AND`(split) β `OR`(values) β `OR`(split) and is refused. The real
|
| 448 |
+
#: envelope is about 4 top-level conditions x <=4 values each, which is why
|
| 449 |
+
#: `MAX_PREDICATE_VALUES = 12` was never reachable at a realistic condition count.
|
| 450 |
+
BD_MAX_DEPTH = 3
|
| 451 |
+
|
| 452 |
+
|
| 453 |
+
def filter_depth(node):
|
| 454 |
+
"""How many logical levels a vendor `filters` structure nests to. A bare rule is depth 1."""
|
| 455 |
+
if isinstance(node, list):
|
| 456 |
+
return max((filter_depth(n) for n in node), default=0)
|
| 457 |
+
if isinstance(node, dict) and isinstance(node.get("filters"), list):
|
| 458 |
+
return 1 + filter_depth(node["filters"])
|
| 459 |
+
return 1
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
def depth_refusal(predicates, operator="and"):
|
| 463 |
+
"""β D-68 β refuse a search whose SHAPE the vendor will reject, before it is sent.
|
| 464 |
+
|
| 465 |
+
Returns a sentence or `""`.
|
| 466 |
+
|
| 467 |
+
β WHY THIS CANNOT BE A RULE-COUNT CHECK. The failure is in the emitted STRUCTURE, not in the
|
| 468 |
+
conditions: the same five conditions pass or fail depending on how many VALUES each carries,
|
| 469 |
+
because every multi-value list becomes its own OR level and every group over `BD_GROUP_MAX`
|
| 470 |
+
adds a split level. So the only honest test is to BUILD the shape and measure it β which is
|
| 471 |
+
cheap, because we build it anyway.
|
| 472 |
+
β Free to get wrong at the vendor and expensive to diagnose there: a rejected filter is never
|
| 473 |
+
billed, but the 400 says nothing about which condition to drop, so a person edits keywords for
|
| 474 |
+
an afternoon while the shape stays identical. That is what this sentence exists to prevent.
|
| 475 |
+
"""
|
| 476 |
+
try:
|
| 477 |
+
depth = filter_depth(bd_group(expand_predicates(predicates), operator or "and"))
|
| 478 |
+
except Exception: # noqa: BLE001
|
| 479 |
+
return "" # never block a save on this check's own bug
|
| 480 |
+
if depth <= BD_MAX_DEPTH:
|
| 481 |
+
return ""
|
| 482 |
+
multi = [p for p in (predicates or [])
|
| 483 |
+
if isinstance((p or {}).get("value"), (list, tuple)) and len(p["value"]) > 1]
|
| 484 |
+
hint = (f"drop one of the {len(multi)} multi-value conditions, or shorten its list"
|
| 485 |
+
if multi else "drop one condition")
|
| 486 |
+
return (f"this search nests {depth} levels deep and the provider allows "
|
| 487 |
+
f"{BD_MAX_DEPTH} β {hint}. Every extra condition beyond {BD_GROUP_MAX}, and every "
|
| 488 |
+
f"list of values, adds a level")
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
def expand_predicates(predicates):
|
| 492 |
+
"""Stored predicates β the vendor's `filters` list, expanding a value LIST into a nested OR.
|
| 493 |
+
|
| 494 |
+
β MEASURED 2026-08-06: the filter API ACCEPTS a nested `{operator, filters:[β¦]}` inside
|
| 495 |
+
`filters` (`snap_msh14ix81n8hlpo3nr`), which is what makes "Bio contains any of floral,
|
| 496 |
+
flower, beauty" expressible WITHOUT setting the whole search to "match any". The alternative
|
| 497 |
+
β the global Match dropdown β would drag every other condition into the same union, so
|
| 498 |
+
"β¦and at least 10,000 followers" would silently become "β¦or at least 10,000 followers".
|
| 499 |
+
|
| 500 |
+
β A ONE-VALUE CONDITION IS STILL SENT FLAT. Wrapping it in a one-element group would be a
|
| 501 |
+
shape nothing has ever been billed against, adopted for tidiness, on the path that spends
|
| 502 |
+
money. Only the several-values case takes the new shape.
|
| 503 |
+
"""
|
| 504 |
+
out = []
|
| 505 |
+
for p in predicates or []:
|
| 506 |
+
v = (p or {}).get("value")
|
| 507 |
+
if isinstance(v, list) and len(v) > 1:
|
| 508 |
+
# β THE OR-GROUP IS SUBJECT TO THE SAME 4-RULE CAP as the outer one, and this is the
|
| 509 |
+
# leg that hits it first in practice: "Bio contains any of floral, flower, beauty,
|
| 510 |
+
# bouquet, wedding" is five keywords in ONE condition β a single row in the builder,
|
| 511 |
+
# and a group the vendor refuses. `bd_group` is applied to the union too.
|
| 512 |
+
out.append({"operator": "or",
|
| 513 |
+
"filters": bd_group(
|
| 514 |
+
[{"name": p["name"], "operator": p["operator"], "value": one}
|
| 515 |
+
for one in v], "or")})
|
| 516 |
+
elif isinstance(v, list):
|
| 517 |
+
out.append({**p, "value": v[0]} if v else
|
| 518 |
+
{k: val for k, val in p.items() if k != "value"})
|
| 519 |
+
else:
|
| 520 |
+
out.append(dict(p))
|
| 521 |
+
return out
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
#: β HOW MANY HANDLES ONE `not_in` MAY CARRY. W25/R1b MEASURED that the vendor accepts a flat
|
| 525 |
+
#: `account not_in [...]` list to this size β *"the 12-value ceiling was OURS, not theirs"*. It is
|
| 526 |
+
#: also why the rule below is built BY HAND instead of going through `expand_predicates`: that
|
| 527 |
+
#: function turns any multi-value predicate into a nested OR of one-value rules, which for 5,000
|
| 528 |
+
#: handles would be a 5,000-rule tree the vendor refuses on both its caps at once.
|
| 529 |
+
BD_EXCLUDE_MAX = 5000
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
def bd_filter_start(predicates, operator="and", records_limit=5, dataset_id="",
|
| 533 |
+
exclude_handles=(), applied=None, handle_field="account"):
|
| 534 |
+
"""Start ONE corpus query. Returns `(snapshot_id, note)`.
|
| 535 |
+
|
| 536 |
+
ββ WAVE 30 Β· T09 β TWO INSTAGRAM ASSUMPTIONS CAME OUT OF THIS FUNCTION WHEN IT MOVED, AND THE
|
| 537 |
+
SECOND ONE WAS SPENDING MONEY.
|
| 538 |
+
1. `dataset_id` defaulted to `BD_DS_PROFILES`. In a module that serves both platforms a
|
| 539 |
+
default corpus is a wrong answer waiting for a caller who forgot; it now REFUSES with a
|
| 540 |
+
sentence rather than searching Instagram on TikTok's behalf.
|
| 541 |
+
2. β `handle_field`. The engine-added `not_in` exclusion below named the column **`account`**
|
| 542 |
+
β and B-20 MEASURED that Bright Data's TikTok Profiles dataset calls it **`account_id`**
|
| 543 |
+
(5 of our 21 filter names exist there at all; five more are renamed). This rule is
|
| 544 |
+
invisible to the condition builder by design, so no amount of restricting what a PERSON
|
| 545 |
+
may type keeps it off the wire: it rides on every discovery run that has anything to
|
| 546 |
+
exclude β i.e. every run after the first. β **What Bright Data DOES with an unknown filter
|
| 547 |
+
field is UNMEASURED** (refuse, or bill for an unfiltered corpus) and cannot be measured for
|
| 548 |
+
$0, since every `POST /datasets/filter` that answers 200 mints a billable snapshot. Naming
|
| 549 |
+
the column per platform is the half that can be fixed for nothing, so it is fixed here.
|
| 550 |
+
|
| 551 |
+
β ALWAYS THE COMPOUND `{operator, filters:[β¦]}` SHAPE, even for a single condition. A bare
|
| 552 |
+
`{name, operator, value}` is a plausible-looking second shape that has never been sent from
|
| 553 |
+
here, and a one-element `filters` list is a trivial extension of the three-element body that
|
| 554 |
+
HAS been measured end-to-end. One shape, and it is the tested one.
|
| 555 |
+
|
| 556 |
+
ββ WAVE 27 ITEM 7 β `exclude_handles` IS THE ENGINE'S OWN REFINEMENT AND IS NEVER A CONDITION
|
| 557 |
+
ROW. Until now discovery paid for every re-found profile: a nightly search over the same
|
| 558 |
+
keywords returns the same accounts, the upsert notices they are `seen_again`, and the vendor
|
| 559 |
+
has already billed for all of them. This narrows the search at the VENDOR instead of after it.
|
| 560 |
+
It is invisible to the condition builder deliberately (W25/R1b: `not_in` is an engine-added
|
| 561 |
+
refinement, and `BD_OPS_BY_KIND` omits it so nobody can type one) β a person did not write it,
|
| 562 |
+
should not have to maintain it, and would be confused by a condition that changes every night.
|
| 563 |
+
|
| 564 |
+
β AND IT IS DROPPED RATHER THAN RISKED. Two ways a free saving could break a search that
|
| 565 |
+
works, and both are checked before the body is sent:
|
| 566 |
+
1. **DEPTH.** The vendor allows 3 logical levels and the 4-rule group cap MANUFACTURES
|
| 567 |
+
depth, so a search already near the ceiling gains a level from this rule and starts
|
| 568 |
+
answering 400 forever ([[bright-data-group-cap]]).
|
| 569 |
+
2. **`or`.** Under a top-level OR, adding an exclusion as another branch would WIDEN the
|
| 570 |
+
search to "β¦or any account not in this list" β i.e. the whole corpus. Under OR the
|
| 571 |
+
existing filter is wrapped in an AND instead, which costs the level checked in (1).
|
| 572 |
+
A dropped exclusion is not an error: the search runs exactly as it did before and we pay for
|
| 573 |
+
the duplicates, which is the status quo, not a regression.
|
| 574 |
+
"""
|
| 575 |
+
if not str(dataset_id or "").strip():
|
| 576 |
+
# Fail-closed, and it names what is missing. A transport that guesses a corpus is a
|
| 577 |
+
# transport that bills the wrong one.
|
| 578 |
+
return "", "the search was not started β no corpus was named for this platform"
|
| 579 |
+
nodes = bd_group(expand_predicates(predicates), operator)
|
| 580 |
+
filt = {"operator": operator, "filters": nodes}
|
| 581 |
+
handles = [str(h).strip().lstrip("@").lower() for h in (exclude_handles or [])]
|
| 582 |
+
handles = [h for h in dict.fromkeys(handles) if h][:BD_EXCLUDE_MAX]
|
| 583 |
+
if isinstance(applied, dict):
|
| 584 |
+
applied["available"] = len(handles)
|
| 585 |
+
applied["excluded"] = 0
|
| 586 |
+
if handles:
|
| 587 |
+
rule = {"name": handle_field, "operator": "not_in", "value": handles}
|
| 588 |
+
cand = (bd_group([*nodes, rule], "and") if operator == "and"
|
| 589 |
+
else [filt, rule])
|
| 590 |
+
if filter_depth(cand) <= BD_MAX_DEPTH:
|
| 591 |
+
filt = {"operator": "and", "filters": cand}
|
| 592 |
+
if isinstance(applied, dict):
|
| 593 |
+
applied["excluded"] = len(handles)
|
| 594 |
+
elif isinstance(applied, dict):
|
| 595 |
+
applied["dropped"] = ("the search is already at the provider's nesting ceiling, so "
|
| 596 |
+
"the already-found list was left off rather than risk it")
|
| 597 |
+
body = {"dataset_id": dataset_id, "records_limit": int(records_limit),
|
| 598 |
+
# β `bd_group` β the top-level group obeys the vendor's 4-rule cap (see its note).
|
| 599 |
+
# Applied HERE rather than inside `expand_predicates` so the expansion stays a pure
|
| 600 |
+
# stored-shape β wire-shape translation and the rebalance is one readable step on the
|
| 601 |
+
# one path that actually posts a filter.
|
| 602 |
+
"filter": filt}
|
| 603 |
+
payload, note = bd_call(BD_PATH_FILTER, body=body)
|
| 604 |
+
if note:
|
| 605 |
+
return "", note
|
| 606 |
+
sid = str((payload or {}).get("snapshot_id") or "") if isinstance(payload, dict) else ""
|
| 607 |
+
if not sid:
|
| 608 |
+
return "", "the search was accepted but came back with nothing to collect it from"
|
| 609 |
+
return sid, ""
|
| 610 |
+
|
| 611 |
+
|
| 612 |
+
def bd_filter_status(snapshot_id):
|
| 613 |
+
"""`(status, size, note)` for a corpus snapshot. `status` β building | ready | failed | ''."""
|
| 614 |
+
payload, note = bd_call(f"{BD_PATH_FILTER_SNAPSHOT}/{snapshot_id}")
|
| 615 |
+
if note:
|
| 616 |
+
return "", 0, note
|
| 617 |
+
d = payload if isinstance(payload, dict) else {}
|
| 618 |
+
status = str(d.get("status") or "")
|
| 619 |
+
err = str(d.get("error") or "")
|
| 620 |
+
if status == "failed" and "did not match any records" in err.lower():
|
| 621 |
+
# β MEASURED 2026-08-06 (`snap_msh14ix81n8hlpo3nr`): a filter that matches NOTHING comes
|
| 622 |
+
# back `failed` β "Provided filter did not match any records" β not `ready` with zero
|
| 623 |
+
# rows. Passed through, that reached the customer as "the search failed", which is the
|
| 624 |
+
# single most likely outcome of typing a keyword that is too specific and the one most
|
| 625 |
+
# certain to be read as "the product is broken". It is an ANSWER, so it gets its own
|
| 626 |
+
# status and no error note.
|
| 627 |
+
return "empty", 0, ""
|
| 628 |
+
if status == "failed" and err:
|
| 629 |
+
# NOT_ENOUGH_FUNDS is the one everybody will hit; say what it means, not just its name.
|
| 630 |
+
extra = (" β the matched set is too large for this account's balance; narrow the "
|
| 631 |
+
"conditions or lower the limit" if "FUNDS" in err.upper() else "")
|
| 632 |
+
return status, 0, f"the search failed: {err}{extra}"
|
| 633 |
+
return status, int(d.get("dataset_size") or 0), ""
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
def bd_filter_rows(snapshot_id):
|
| 637 |
+
"""The rows of a READY corpus snapshot. `(rows, note)`.
|
| 638 |
+
|
| 639 |
+
β `/download` answered *"Snapshot is building. Try again in a few minutes"* while the status
|
| 640 |
+
endpoint ALREADY said `ready` β delivery lags readiness by minutes. So a download that does
|
| 641 |
+
not parse as rows is reported as not-yet-collectable rather than as an error.
|
| 642 |
+
"""
|
| 643 |
+
payload, note = bd_call(f"{BD_PATH_FILTER_SNAPSHOT}/{snapshot_id}/download",
|
| 644 |
+
{"format": "json"})
|
| 645 |
+
if note:
|
| 646 |
+
return [], note
|
| 647 |
+
rows = _bd_rows(payload)
|
| 648 |
+
if not rows:
|
| 649 |
+
return [], "the search is ready but its rows are not downloadable yet β the vendor's " \
|
| 650 |
+
"delivery lags its own `ready` status by minutes"
|
| 651 |
+
return rows, ""
|
| 652 |
+
|
| 653 |
+
|
| 654 |
+
# =============================================================================================
|
| 655 |
+
# 3. ROW PRIMITIVES -- reading one vendor row, for any platform's mapper
|
| 656 |
+
# =============================================================================================
|
| 657 |
+
#: `bd_call` refuses a partial response at this same transport ceiling. A single source document
|
| 658 |
+
#: therefore fits a JSON cell; Source data is never shortened by `_s`, which would corrupt JSON
|
| 659 |
+
#: and silently lose fields.
|
| 660 |
+
BD_SOURCE_PAYLOAD_MAX = BD_MAX_KB * 1024
|
| 661 |
+
|
| 662 |
+
|
| 663 |
+
def _ig_int(v):
|
| 664 |
+
try:
|
| 665 |
+
return int(v)
|
| 666 |
+
except Exception:
|
| 667 |
+
return None
|
| 668 |
+
|
| 669 |
+
|
| 670 |
+
def _first(node, *names, default=None):
|
| 671 |
+
"""The first of `names` present on `node` with a real value.
|
| 672 |
+
|
| 673 |
+
β THE TOLERANCE IS THE POINT, not laziness. The vendor's `components/schemas` was not readable
|
| 674 |
+
on 2026-08-04 (recorded as RECALLED in the digest), so every media field name below is a
|
| 675 |
+
hypothesis. Reading through a candidate list means a rename costs one more candidate instead
|
| 676 |
+
of a rung that returns nothing and cannot say why. **A field that matches nothing returns
|
| 677 |
+
`default` (None), never 0** β an invented zero is a measurement, and this module's whole
|
| 678 |
+
honest-status contract exists to stop those.
|
| 679 |
+
"""
|
| 680 |
+
if not isinstance(node, dict):
|
| 681 |
+
return default
|
| 682 |
+
for n in names:
|
| 683 |
+
v = node.get(n)
|
| 684 |
+
if v not in (None, ""):
|
| 685 |
+
return v
|
| 686 |
+
return default
|
| 687 |
+
|
| 688 |
+
|
| 689 |
+
def _bd_first_url(v):
|
| 690 |
+
"""A link-in-bio value β one string.
|
| 691 |
+
|
| 692 |
+
β MEASURED 2026-08-05: `external_url` is an **ARRAY** on the real row
|
| 693 |
+
(`["http://linktr.ee/utopianevents/"]`), not the string the previous vendor returned. A dict
|
| 694 |
+
(`external_url_title`) is also in the family. Read through the shape rather than assuming one.
|
| 695 |
+
"""
|
| 696 |
+
if isinstance(v, list):
|
| 697 |
+
v = next((x for x in v if x), "")
|
| 698 |
+
if isinstance(v, dict):
|
| 699 |
+
v = _first(v, "url", "link", default="")
|
| 700 |
+
return str(v or "")
|
| 701 |
+
|
| 702 |
+
|
| 703 |
+
def _bd_flag(node, *names):
|
| 704 |
+
"""A vendor boolean β `'1'` / `''` / None. **None when the key is ABSENT or null**, which is
|
| 705 |
+
the distinction that matters: `is_business_account: false` is a measurement and `''` records
|
| 706 |
+
it; a missing key is not, and must not arrive as "false"."""
|
| 707 |
+
for n in names:
|
| 708 |
+
v = node.get(n) if isinstance(node, dict) else None
|
| 709 |
+
if v is None:
|
| 710 |
+
continue
|
| 711 |
+
return "1" if v is True or str(v).strip().lower() in ("true", "1", "yes") else ""
|
| 712 |
+
return None
|
| 713 |
+
|
| 714 |
+
|
| 715 |
+
def _bd_list(node, *names):
|
| 716 |
+
"""An array field β a comma-joined string, or None when it is absent/empty."""
|
| 717 |
+
for n in names:
|
| 718 |
+
v = node.get(n) if isinstance(node, dict) else None
|
| 719 |
+
if isinstance(v, list) and v:
|
| 720 |
+
return ", ".join(str(x) for x in v if x)[:400]
|
| 721 |
+
return None
|
| 722 |
+
|
| 723 |
+
|
| 724 |
+
def _bd_source_payload(row):
|
| 725 |
+
"""One complete Bright Data response row as valid JSON, without field filtering.
|
| 726 |
+
|
| 727 |
+
Source data is the lossless provider record, not a second relationship cache. A Profile's
|
| 728 |
+
embedded ``posts`` array stays here *and* each parseable post becomes a linked Post record;
|
| 729 |
+
keeping both preserves data while Links remain the interactive relational view. `bd_call`
|
| 730 |
+
rejects partial bodies at ``BD_SOURCE_PAYLOAD_MAX``, and the table-layer JSON ceiling matches
|
| 731 |
+
it, so this function never truncates a paid source response.
|
| 732 |
+
"""
|
| 733 |
+
if not isinstance(row, dict):
|
| 734 |
+
return None
|
| 735 |
+
try:
|
| 736 |
+
packed = json.dumps({str(k): v for k, v in row.items()}, ensure_ascii=False,
|
| 737 |
+
sort_keys=True, separators=(",", ":"))
|
| 738 |
+
except (TypeError, ValueError):
|
| 739 |
+
return None
|
| 740 |
+
# A source row is bounded by the complete raw HTTP response. Leave this defensive assertion
|
| 741 |
+
# explicit: should the transport ceiling and JSON-cell ceiling ever diverge, fail visibly
|
| 742 |
+
# rather than shortening a document and pretending every paid field was retained.
|
| 743 |
+
if len(packed.encode("utf-8")) > BD_SOURCE_PAYLOAD_MAX:
|
| 744 |
+
raise ValueError("a Bright Data source row exceeded the guarded response ceiling")
|
| 745 |
+
return packed
|
api/connectors_ig.py
CHANGED
|
@@ -35,7 +35,8 @@ probe doing `import connectors_ig` would have got a half-built engine and an Att
|
|
| 35 |
inside a vendor call. **An intermittent-by-import-order failure is the worst shape available**, so
|
| 36 |
every reach-back is a `from automation_engine import β¦` INSIDE the function that needs it, where
|
| 37 |
the module is always fully built. There are exactly seven β `_bd_why`, `bd_call`,
|
| 38 |
-
`apify_posts`, `
|
|
|
|
| 39 |
marked `# lazy β see the module header`, and they reach for four kinds of thing:
|
| 40 |
|
| 41 |
fetch / fetch_json / Refused the SSRF-guarded HTTP rail (the scrape runner shares it)
|
|
@@ -65,6 +66,43 @@ import requests
|
|
| 65 |
|
| 66 |
import providers
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
# ---------------------------------------------------------------------------------------------
|
| 70 |
# HANDLE + COUNT READERS (no vendor, no key β pure parsing)
|
|
@@ -94,11 +132,6 @@ def ig_handle(url):
|
|
| 94 |
return re.sub(r"[^A-Za-z0-9._]", "", first)[:40]
|
| 95 |
|
| 96 |
|
| 97 |
-
def _ig_int(v):
|
| 98 |
-
try:
|
| 99 |
-
return int(v)
|
| 100 |
-
except Exception:
|
| 101 |
-
return None
|
| 102 |
|
| 103 |
|
| 104 |
def _ig_zero_is_blank(v):
|
|
@@ -116,200 +149,22 @@ def _ig_zero_is_blank(v):
|
|
| 116 |
return None if n == 0 else n
|
| 117 |
|
| 118 |
|
| 119 |
-
BD_BASE_DEFAULT = "https://api.brightdata.com"
|
| 120 |
BD_DS_PROFILES = "gd_l1vikfch901nx3by4" # Instagram β Profiles. 36 fields, 620M records
|
| 121 |
BD_DS_POSTS = "gd_lk5ns7kz21pck8jpis" # Instagram β Posts. 43 fields
|
| 122 |
BD_DS_REELS = "gd_lyclm20il4r5helnj" # Instagram Reels: views and play counts
|
| 123 |
BD_DS_COMMENTS = "gd_ltppn085pokosxh13" # Instagram Comments: opt-in full comment engagement
|
| 124 |
-
BD_PATH_SCRAPE = "/datasets/v3/scrape" # SYNC: rows come back inline. param: dataset_id
|
| 125 |
-
BD_PATH_TRIGGER = "/datasets/v3/trigger" # ASYNC: -> {"snapshot_id": "sd_β¦"}
|
| 126 |
-
BD_PATH_SNAPSHOT = "/datasets/v3/snapshot" # /<sd_id>?format=json -> the rows
|
| 127 |
-
#: ββ 2026-08-09 β THE SNAPSHOT'S OWN STATUS DOCUMENT, and asking it is the difference between
|
| 128 |
-
#: "the vendor is still working" and "the vendor finished and collected nothing". MEASURED on
|
| 129 |
-
#: `sd_msl80v7l1ti14rhcd6` (nurilab's `roxyfoxypinky`): `{"status": "ready", "records": 0,
|
| 130 |
-
#: "errors": 1, "error_codes": {"crawl_error": 1}, "collection_duration": 320513}`. The rows
|
| 131 |
-
#: endpoint answers `[]` for that snapshot forever, which `bd_scrape`'s old `if rows:` poll could
|
| 132 |
-
#: not distinguish from a snapshot mid-build β so it burned its whole budget and then reported
|
| 133 |
-
#: *"the records are collected, not lost"* about a batch that had collected nothing at all.
|
| 134 |
-
#: β SCRAPER NAMESPACE ONLY (`sd_β¦`). The corpus has no twin here; its status is
|
| 135 |
-
#: `BD_PATH_FILTER_SNAPSHOT/<snap_id>`, and crossing the two 404s (Β§2c).
|
| 136 |
-
BD_PATH_PROGRESS = "/datasets/v3/progress" # /<sd_id> -> {status, records, errors, error_codes}
|
| 137 |
-
BD_PATH_FILTER = "/datasets/filter" # β NO /v3/ β the CORPUS query (discovery)
|
| 138 |
-
BD_PATH_FILTER_SNAPSHOT = "/datasets/snapshot" # /<snap_id> -> status
|
| 139 |
-
#: A sync scrape MEASURED at 15β23 s for one URL and ~40 s for two, so the timeout is generous;
|
| 140 |
-
#: it is a ceiling against a hung socket, not a latency budget.
|
| 141 |
-
BD_TIMEOUT = 300.0
|
| 142 |
-
#: The READ ceiling for one vendor answer, in KB. `fetch` truncates SILENTLY at this size, so it
|
| 143 |
-
#: is load-bearing arithmetic rather than a round number: MEASURED `file_size` was 175,617 bytes
|
| 144 |
-
#: for 5 corpus rows β **~35 KB per profile row**, and `BD_MAX_RECORDS` is set from it with
|
| 145 |
-
#: headroom (500 Γ 35 KB β 17 MB against 32 MB). A truncated body is REPORTED as truncated in
|
| 146 |
-
#: `bd_call` β never allowed to surface as "that is not JSON".
|
| 147 |
-
BD_MAX_KB = 32768
|
| 148 |
-
#: How long a run waits on a scrape batch the vendor DEFERRED (see `_bd_deferral`). Much shorter
|
| 149 |
-
#: than the corpus filter's budget because a scrape snapshot is minutes, not tens of minutes β
|
| 150 |
-
#: and unlike the corpus path there is no handoff to a later run, so this is the whole wait.
|
| 151 |
-
BD_SCRAPE_WAIT = float(os.environ.get("AIOS_BD_SCRAPE_WAIT") or 180)
|
| 152 |
-
BD_SCRAPE_POLL = float(os.environ.get("AIOS_BD_SCRAPE_POLL") or 10)
|
| 153 |
-
# A Profile response is the enrichment's identity half, so it retains the normal bounded
|
| 154 |
-
# synchronous wait. Post/Reel engagement can create several snapshots per profile; action
|
| 155 |
-
# runners hand those off immediately and the scheduler collects the already-paid work later.
|
| 156 |
-
BD_METRIC_SCRAPE_WAIT = float(os.environ.get("AIOS_BD_METRIC_SCRAPE_WAIT") or 0)
|
| 157 |
-
#: β SPEC, NOT MEASURED β off the pricing page, never returned by the API (the funds gate fires
|
| 158 |
-
#: BEFORE a price is quoted, and `price: 0` means "not priced", not "free"). Every surface that
|
| 159 |
-
#: shows a number derived from this MUST say it is an estimate. `/customer/balance` answers 403
|
| 160 |
-
#: for our token, so there is no way to check a balance or a spend from here either.
|
| 161 |
-
BD_RECORD_PRICE_SPEC = 0.0025
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
def bd_key():
|
| 165 |
-
"""The key, or ''. Read fresh every call β see the module note above on why."""
|
| 166 |
-
return (os.environ.get("AIOS_BRIGHTDATA_KEY") or "").strip()
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
def bd_base():
|
| 170 |
-
return (os.environ.get("AIOS_BRIGHTDATA_BASE") or BD_BASE_DEFAULT).strip().rstrip("/")
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
def bd_ready():
|
| 174 |
-
"""Is the paid rung configured at all? The UI reads this to say so honestly."""
|
| 175 |
-
return bool(bd_key())
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
def _bd_why(raw, key=""):
|
| 179 |
-
"""The vendor's OWN words for a refusal, as `" β β¦"`, or `""` when it gave none.
|
| 180 |
-
|
| 181 |
-
β THIS WAS THROWN AWAY, AND THE COST WAS MEASURED RATHER THAN IMAGINED. nurilab's live
|
| 182 |
-
discovery automation failed four runs across two days with `the profile source answered 400`
|
| 183 |
-
while the body it discarded said, in full:
|
| 184 |
-
|
| 185 |
-
{"validation_errors":["Filter logical groups can have a maximum of 4 rules."]}
|
| 186 |
-
|
| 187 |
-
β the exact instruction needed to fix it, in our hands, deleted one line before the customer.
|
| 188 |
-
A bare status code sends somebody to read a vendor's schema for a fault we were already
|
| 189 |
-
holding the answer to; it is the same defect as a green dot over nothing, pointed the other
|
| 190 |
-
way. Every non-2xx says WHY now, and the named cases above (402/401/403/429) keep their own
|
| 191 |
-
plainer sentences because those are ours to explain, not the vendor's.
|
| 192 |
-
"""
|
| 193 |
-
from automation_engine import _s # lazy β see the module header
|
| 194 |
-
try:
|
| 195 |
-
text = bytes(raw or b"").decode("utf-8", "replace").strip()[:600]
|
| 196 |
-
except Exception: # noqa: BLE001
|
| 197 |
-
return ""
|
| 198 |
-
if not text:
|
| 199 |
-
return ""
|
| 200 |
-
why = ""
|
| 201 |
-
try:
|
| 202 |
-
d = json.loads(text)
|
| 203 |
-
except Exception: # noqa: BLE001
|
| 204 |
-
d = None
|
| 205 |
-
if isinstance(d, dict):
|
| 206 |
-
errs = d.get("validation_errors") or d.get("errors")
|
| 207 |
-
if isinstance(errs, list) and errs:
|
| 208 |
-
why = "; ".join(str(e) for e in errs)
|
| 209 |
-
else:
|
| 210 |
-
why = str(d.get("error") or d.get("message") or d.get("detail") or "")
|
| 211 |
-
why = why or text
|
| 212 |
-
# β A BODY WE DID NOT WRITE GETS THE SAME TREATMENT AS AN EXCEPTION'S str(). `bd_call` already
|
| 213 |
-
# refuses to put the latter on the wire in case it carries the key; promising that a vendor
|
| 214 |
-
# never echoes an Authorization header back is not a guarantee this module can make.
|
| 215 |
-
if key and key in why:
|
| 216 |
-
why = why.replace(key, "***")
|
| 217 |
-
return f" β {_s(why, 300)}"
|
| 218 |
|
| 219 |
|
| 220 |
-
def bd_call(path, params=None, body=None):
|
| 221 |
-
"""One authenticated vendor call. GET when `body` is None, POST otherwise.
|
| 222 |
|
| 223 |
-
Returns `(payload, note)`; a non-empty `note` means it did NOT answer, and the note is safe to
|
| 224 |
-
show a user and safe to write to a log.
|
| 225 |
|
| 226 |
-
Never raises. Every refusal shape β no key, a guarded base, a 402, a 429, a transport error,
|
| 227 |
-
unparseable JSON β comes back as a note so the caller can record the attempt and drop to the
|
| 228 |
-
next rung.
|
| 229 |
-
"""
|
| 230 |
-
from automation_engine import Refused, fetch, fetch_json # lazy β see the module header
|
| 231 |
-
key = bd_key()
|
| 232 |
-
if not key:
|
| 233 |
-
# β THE FAIL-CLOSED PATH (C4). Named precisely, because "blocked" with no reason sends
|
| 234 |
-
# somebody to read Instagram's status page instead of setting an env var.
|
| 235 |
-
return None, "AIOS_BRIGHTDATA_KEY is not configured β the paid rung is closed"
|
| 236 |
-
qs = "&".join(f"{k}={requests.utils.quote(str(v))}"
|
| 237 |
-
for k, v in (params or {}).items() if v not in (None, ""))
|
| 238 |
-
url = f"{bd_base()}{path}" + (f"?{qs}" if qs else "")
|
| 239 |
-
hdrs = {"Authorization": f"Bearer {key}", "Accept": "application/json"}
|
| 240 |
-
try:
|
| 241 |
-
if body is None:
|
| 242 |
-
status, _final, raw = fetch(url, timeout=BD_TIMEOUT, max_kb=BD_MAX_KB, headers=hdrs)
|
| 243 |
-
else:
|
| 244 |
-
status, raw = fetch_json(url, body, timeout=BD_TIMEOUT, max_kb=BD_MAX_KB,
|
| 245 |
-
headers=hdrs)
|
| 246 |
-
except Refused as e:
|
| 247 |
-
# The BASE was refused by the SSRF rail β a configuration fault, not a vendor outage.
|
| 248 |
-
return None, f"the profile source's address was refused by the URL rail: {e}"
|
| 249 |
-
except Exception as e: # noqa: BLE001
|
| 250 |
-
# β `type(e).__name__` only. A requests exception's str() can carry the full URL, and the
|
| 251 |
-
# Authorization header is one refactor away from being a query param; this line must not
|
| 252 |
-
# be what leaks it.
|
| 253 |
-
return None, f"the profile source did not answer ({type(e).__name__})"
|
| 254 |
-
if status == 402:
|
| 255 |
-
return None, "the profile search is out of credit"
|
| 256 |
-
if status in (401, 403):
|
| 257 |
-
return None, f"the profile source refused our key ({status})"
|
| 258 |
-
if status == 429:
|
| 259 |
-
# β MEASURED during the discovery probe: `too_many_parallel_jobs`. The filter API has a
|
| 260 |
-
# concurrency cap and the probe's most important test died on it β hence every caller
|
| 261 |
-
# below is SERIAL by construction.
|
| 262 |
-
return None, "the profile source is busy with another search; they run one at a time"
|
| 263 |
-
if not (200 <= status < 300):
|
| 264 |
-
return None, f"the profile source answered {status}{_bd_why(raw, key)}"
|
| 265 |
-
# β A BODY THAT REACHED THE READ CEILING IS A TRUNCATION, AND IT MUST SAY SO. `fetch` reads
|
| 266 |
-
# at most `max_kb` and returns what it got β silently. A truncated JSON document then fails
|
| 267 |
-
# `json.loads`, falls through to the NDJSON branch, and comes back as "answered with
|
| 268 |
-
# something that is not JSON": a sentence that sends somebody to read the vendor's schema
|
| 269 |
-
# looking for a fault that is OUR byte ceiling β after a result set that has already been
|
| 270 |
-
# paid for. Naming it here is the same no-silent-caps rule the row and table ceilings follow.
|
| 271 |
-
if len(raw) >= BD_MAX_KB * 1024:
|
| 272 |
-
return None, (f"the profile source's answer exceeded this client's {BD_MAX_KB // 1024} MB read "
|
| 273 |
-
f"ceiling and was TRUNCATED β ask for fewer records; nothing was parsed "
|
| 274 |
-
f"from a partial document")
|
| 275 |
-
text = raw.decode("utf-8", "replace").strip()
|
| 276 |
-
if not text:
|
| 277 |
-
return None, f"the profile source answered {status} with an empty body"
|
| 278 |
-
try:
|
| 279 |
-
return json.loads(text), ""
|
| 280 |
-
except Exception: # noqa: BLE001
|
| 281 |
-
pass
|
| 282 |
-
# NDJSON is the other shape this API uses for row sets. Tried SECOND, so a genuine JSON error
|
| 283 |
-
# is not silently reinterpreted as a one-line NDJSON document.
|
| 284 |
-
rows = []
|
| 285 |
-
for line in text.splitlines():
|
| 286 |
-
line = line.strip()
|
| 287 |
-
if not line:
|
| 288 |
-
continue
|
| 289 |
-
try:
|
| 290 |
-
rows.append(json.loads(line))
|
| 291 |
-
except Exception: # noqa: BLE001
|
| 292 |
-
return None, "the profile source answered with something we could not read"
|
| 293 |
-
return (rows, "") if rows else (None, "the profile source answered with something we could not read")
|
| 294 |
|
| 295 |
|
| 296 |
-
def _first(node, *names, default=None):
|
| 297 |
-
"""The first of `names` present on `node` with a real value.
|
| 298 |
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
honest-status contract exists to stop those.
|
| 305 |
-
"""
|
| 306 |
-
if not isinstance(node, dict):
|
| 307 |
-
return default
|
| 308 |
-
for n in names:
|
| 309 |
-
v = node.get(n)
|
| 310 |
-
if v not in (None, ""):
|
| 311 |
-
return v
|
| 312 |
-
return default
|
| 313 |
|
| 314 |
|
| 315 |
#: Source-data retention is intentionally unfiltered: if Bright Data returned it in an already
|
|
@@ -317,10 +172,6 @@ def _first(node, *names, default=None):
|
|
| 317 |
#: The one cost boundary is the *request*: the full Comments dataset is only called when an owner
|
| 318 |
#: enables `commentMetrics`.
|
| 319 |
|
| 320 |
-
#: `bd_call` refuses a partial response at this same transport ceiling. A single source document
|
| 321 |
-
#: therefore fits a JSON cell; Source data is never shortened by `_s`, which would corrupt JSON
|
| 322 |
-
#: and silently lose fields.
|
| 323 |
-
BD_SOURCE_PAYLOAD_MAX = BD_MAX_KB * 1024
|
| 324 |
|
| 325 |
#: The vendor's content vocabulary β ours. MEASURED values: profiles' `posts[].content_type` is
|
| 326 |
#: `Image`/`Reel`/`Carousel`; the Posts dataset adds `product_type` `clips`/`carousel_container`.
|
|
@@ -355,18 +206,6 @@ def ig_shortcode(url):
|
|
| 355 |
return ""
|
| 356 |
|
| 357 |
|
| 358 |
-
def _bd_first_url(v):
|
| 359 |
-
"""A link-in-bio value β one string.
|
| 360 |
-
|
| 361 |
-
β MEASURED 2026-08-05: `external_url` is an **ARRAY** on the real row
|
| 362 |
-
(`["http://linktr.ee/utopianevents/"]`), not the string the previous vendor returned. A dict
|
| 363 |
-
(`external_url_title`) is also in the family. Read through the shape rather than assuming one.
|
| 364 |
-
"""
|
| 365 |
-
if isinstance(v, list):
|
| 366 |
-
v = next((x for x in v if x), "")
|
| 367 |
-
if isinstance(v, dict):
|
| 368 |
-
v = _first(v, "url", "link", default="")
|
| 369 |
-
return str(v or "")
|
| 370 |
|
| 371 |
|
| 372 |
def _bd_posts_count(node):
|
|
@@ -389,25 +228,8 @@ def _bd_posts_count(node):
|
|
| 389 |
return None if v == 0 else v
|
| 390 |
|
| 391 |
|
| 392 |
-
def _bd_flag(node, *names):
|
| 393 |
-
"""A vendor boolean β `'1'` / `''` / None. **None when the key is ABSENT or null**, which is
|
| 394 |
-
the distinction that matters: `is_business_account: false` is a measurement and `''` records
|
| 395 |
-
it; a missing key is not, and must not arrive as "false"."""
|
| 396 |
-
for n in names:
|
| 397 |
-
v = node.get(n) if isinstance(node, dict) else None
|
| 398 |
-
if v is None:
|
| 399 |
-
continue
|
| 400 |
-
return "1" if v is True or str(v).strip().lower() in ("true", "1", "yes") else ""
|
| 401 |
-
return None
|
| 402 |
|
| 403 |
|
| 404 |
-
def _bd_list(node, *names):
|
| 405 |
-
"""An array field β a comma-joined string, or None when it is absent/empty."""
|
| 406 |
-
for n in names:
|
| 407 |
-
v = node.get(n) if isinstance(node, dict) else None
|
| 408 |
-
if isinstance(v, list) and v:
|
| 409 |
-
return ", ".join(str(x) for x in v if x)[:400]
|
| 410 |
-
return None
|
| 411 |
|
| 412 |
|
| 413 |
def _bd_tagged_location(node):
|
|
@@ -432,28 +254,6 @@ def _bd_tagged_location(node):
|
|
| 432 |
return ", ".join(parts)[:400] or None
|
| 433 |
|
| 434 |
|
| 435 |
-
def _bd_source_payload(row):
|
| 436 |
-
"""One complete Bright Data response row as valid JSON, without field filtering.
|
| 437 |
-
|
| 438 |
-
Source data is the lossless provider record, not a second relationship cache. A Profile's
|
| 439 |
-
embedded ``posts`` array stays here *and* each parseable post becomes a linked Post record;
|
| 440 |
-
keeping both preserves data while Links remain the interactive relational view. `bd_call`
|
| 441 |
-
rejects partial bodies at ``BD_SOURCE_PAYLOAD_MAX``, and the table-layer JSON ceiling matches
|
| 442 |
-
it, so this function never truncates a paid source response.
|
| 443 |
-
"""
|
| 444 |
-
if not isinstance(row, dict):
|
| 445 |
-
return None
|
| 446 |
-
try:
|
| 447 |
-
packed = json.dumps({str(k): v for k, v in row.items()}, ensure_ascii=False,
|
| 448 |
-
sort_keys=True, separators=(",", ":"))
|
| 449 |
-
except (TypeError, ValueError):
|
| 450 |
-
return None
|
| 451 |
-
# A source row is bounded by the complete raw HTTP response. Leave this defensive assertion
|
| 452 |
-
# explicit: should the transport ceiling and JSON-cell ceiling ever diverge, fail visibly
|
| 453 |
-
# rather than shortening a document and pretending every paid field was retained.
|
| 454 |
-
if len(packed.encode("utf-8")) > BD_SOURCE_PAYLOAD_MAX:
|
| 455 |
-
raise ValueError("a Bright Data source row exceeded the guarded response ceiling")
|
| 456 |
-
return packed
|
| 457 |
|
| 458 |
|
| 459 |
def _bd_comment(row, influencer_key="", shortcode="", fallback_key=""):
|
|
@@ -717,153 +517,12 @@ def bd_true_posts_count(rows):
|
|
| 717 |
return None
|
| 718 |
|
| 719 |
|
| 720 |
-
def _bd_rows(payload):
|
| 721 |
-
"""A vendor answer β the list of row dicts inside it, whatever envelope it arrived in.
|
| 722 |
|
| 723 |
-
MEASURED shapes: a bare list (`/v3/scrape`), NDJSON (already listified by `bd_call`), and
|
| 724 |
-
`{"data": [...]}` / `{"results": [...]}` on the snapshot reads. An unreadable envelope yields
|
| 725 |
-
NOTHING rather than a guess.
|
| 726 |
-
"""
|
| 727 |
-
if isinstance(payload, list):
|
| 728 |
-
return [x for x in payload if isinstance(x, dict)]
|
| 729 |
-
if isinstance(payload, dict):
|
| 730 |
-
for k in ("data", "results", "records", "items"):
|
| 731 |
-
v = payload.get(k)
|
| 732 |
-
if isinstance(v, list):
|
| 733 |
-
return [x for x in v if isinstance(x, dict)]
|
| 734 |
-
return [payload] if payload else []
|
| 735 |
-
return []
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
def _bd_deferral(payload):
|
| 739 |
-
"""The snapshot id when the 'sync' endpoint DEFERRED, else ''.
|
| 740 |
-
|
| 741 |
-
β MEASURED 2026-08-05, AND IT IS THE TRAP OF THIS WHOLE RUNG. `/v3/scrape` is documented and
|
| 742 |
-
named as the synchronous call, and for a small batch it is β two profile URLs came back
|
| 743 |
-
inline. Give it more work and it answers **200** with no rows and a note instead:
|
| 744 |
-
|
| 745 |
-
{"snapshot_id": "sd_β¦", "message": "Your request is still in progress and cannot be
|
| 746 |
-
retrieved in this call. Use the provided Snapshot ID to track progressβ¦"}
|
| 747 |
-
|
| 748 |
-
Read naively that is a 200 with a body, so a parser looking for "did it error?" sails past
|
| 749 |
-
it, `_bd_rows` wraps the envelope as ONE unusable row, and the caller reports that the vendor
|
| 750 |
-
returned nothing β **while a snapshot we have already been billed for sits on the account,
|
| 751 |
-
finishing, and is never collected.** That is the D-26 failure shape (paid work abandoned)
|
| 752 |
-
arriving through the success path.
|
| 753 |
-
β It is a TIME threshold, not a row count: 2 URLs answered inline, 6 and 10 deferred.
|
| 754 |
-
"""
|
| 755 |
-
if isinstance(payload, dict) and payload.get("snapshot_id") and not payload.get("account"):
|
| 756 |
-
return str(payload["snapshot_id"])
|
| 757 |
-
return ""
|
| 758 |
|
| 759 |
|
| 760 |
-
def bd_snapshot_progress(sid):
|
| 761 |
-
"""One scraper snapshot's status. `(state, records, note)`.
|
| 762 |
-
|
| 763 |
-
state β `running` | `done` | `failed` | `unknown`:
|
| 764 |
-
Β· `running` β the vendor is still collecting; poll again.
|
| 765 |
-
Β· `done` β finished. `records` says whether it produced anything.
|
| 766 |
-
Β· `failed` β finished, produced NOTHING, and the vendor blamed the TARGET (an
|
| 767 |
-
`error_codes` entry such as `crawl_error`). On a profile request that is the
|
| 768 |
-
vendor saying it could not reach that account at all, which is a different
|
| 769 |
-
action from "we found no matches" β so it is a distinct state rather than a
|
| 770 |
-
phrase inside the note. A caller that has to grep an English sentence to
|
| 771 |
-
decide what happened is a caller whose behaviour changes when someone
|
| 772 |
-
improves the wording.
|
| 773 |
-
Β· `unknown` β the status call itself did not answer; fall back to probing the rows.
|
| 774 |
-
|
| 775 |
-
ββ 2026-08-09 β THE QUESTION `bd_scrape` COULD NOT ASK. Its poll loop advanced only on
|
| 776 |
-
`if rows:`, so a snapshot the vendor had FINISHED with zero records looked byte-for-byte like
|
| 777 |
-
one still building: the rows endpoint answers `[]` in both cases. MEASURED on nurilab's one
|
| 778 |
-
pending handle β `status: ready, records: 0, errors: 1, crawl_error: 1` after 320 s β while
|
| 779 |
-
the enrich run reported *"deferred β¦ the records are collected, not lost"*, which was false
|
| 780 |
-
about that batch and had been re-reported on three separate runs.
|
| 781 |
-
|
| 782 |
-
β `unknown` IS NOT `done`, and the distinction is the whole reason this returns three states
|
| 783 |
-
rather than a boolean. When the status call itself fails we must fall back to the old
|
| 784 |
-
row-probing behaviour, not conclude the snapshot is empty β an unreachable status endpoint
|
| 785 |
-
would otherwise turn every deferral into a confident "the vendor found nothing".
|
| 786 |
-
|
| 787 |
-
β THE NOTE IS DELIBERATELY VENDOR-NEUTRAL about WHAT was being read. This serves profile,
|
| 788 |
-
post, reel and comment snapshots alike; a sentence naming "the account" would be wrong on
|
| 789 |
-
three of the four, and the caller knows which it asked for.
|
| 790 |
-
"""
|
| 791 |
-
payload, err = bd_call(f"{BD_PATH_PROGRESS}/{sid}", None, None)
|
| 792 |
-
if err or not isinstance(payload, dict):
|
| 793 |
-
return "unknown", 0, ""
|
| 794 |
-
status = str(payload.get("status") or "").strip().lower()
|
| 795 |
|
| 796 |
-
def _n(key):
|
| 797 |
-
try:
|
| 798 |
-
return int(payload.get(key) or 0)
|
| 799 |
-
except (TypeError, ValueError):
|
| 800 |
-
return 0
|
| 801 |
-
|
| 802 |
-
records, errors = _n("records"), _n("errors")
|
| 803 |
-
if status not in ("ready", "done", "failed", "error"):
|
| 804 |
-
return "running", records, ""
|
| 805 |
-
if records > 0:
|
| 806 |
-
return "done", records, ""
|
| 807 |
-
codes = payload.get("error_codes")
|
| 808 |
-
named = (", ".join(f"{str(k).replace('_', ' ')} x{v}"
|
| 809 |
-
for k, v in sorted(codes.items()))
|
| 810 |
-
if isinstance(codes, dict) and codes else "")
|
| 811 |
-
if errors or named or status in ("failed", "error"):
|
| 812 |
-
return "failed", 0, ("the source finished this request and collected nothing"
|
| 813 |
-
+ (f" β it reported {named}" if named else "")
|
| 814 |
-
+ "; the target is unreachable, private, or no longer exists")
|
| 815 |
-
return "done", 0, "the source finished this request and found no records for it"
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
def bd_scrape(dataset_id, urls, wait=None, deferred=None):
|
| 819 |
-
"""Scrape a batch. `(rows, note)`; a note means it did not answer.
|
| 820 |
-
|
| 821 |
-
Sends the SYNC call and, when the vendor defers it (see `_bd_deferral`), **collects the
|
| 822 |
-
snapshot it handed back** rather than discarding it. One call carries many URLs, which is
|
| 823 |
-
what keeps the per-profile pacing floor from turning a 20-profile run into an hour.
|
| 824 |
-
"""
|
| 825 |
-
requested_urls = [str(u) for u in (urls or []) if u]
|
| 826 |
-
payload, note = bd_call(BD_PATH_SCRAPE, {"dataset_id": dataset_id},
|
| 827 |
-
body=[{"url": u} for u in requested_urls])
|
| 828 |
-
if note:
|
| 829 |
-
return [], note
|
| 830 |
-
sid = _bd_deferral(payload)
|
| 831 |
-
if not sid:
|
| 832 |
-
return _bd_rows(payload), ""
|
| 833 |
|
| 834 |
-
|
| 835 |
-
# with the corpus namespace is a flat 404 about a snapshot that is alive).
|
| 836 |
-
budget = BD_SCRAPE_WAIT if wait is None else float(wait)
|
| 837 |
-
waited = 0.0
|
| 838 |
-
while True:
|
| 839 |
-
# β THE STATUS DOCUMENT IS ASKED FIRST, because it is the only surface that can say
|
| 840 |
-
# "finished, and there was nothing". Fetching rows while the vendor is still collecting
|
| 841 |
-
# is also a wasted call on every single poll.
|
| 842 |
-
state, records, empty_note = bd_snapshot_progress(sid)
|
| 843 |
-
if state in ("done", "failed") and not records:
|
| 844 |
-
return [], empty_note # a definitive EMPTY, never our timeout sentence
|
| 845 |
-
if state != "running":
|
| 846 |
-
got, gnote = bd_call(f"{BD_PATH_SNAPSHOT}/{sid}", {"format": "json"})
|
| 847 |
-
if not gnote:
|
| 848 |
-
rows = _bd_rows(got)
|
| 849 |
-
# The status document is itself JSON, so "did it deliver?" is decided by whether
|
| 850 |
-
# what came back looks like ROWS β never by the HTTP code.
|
| 851 |
-
if rows and not _bd_deferral(got) and not (
|
| 852 |
-
len(rows) == 1 and str(rows[0].get("status") or "") in
|
| 853 |
-
("running", "building", "collecting")):
|
| 854 |
-
return rows, ""
|
| 855 |
-
if waited >= budget:
|
| 856 |
-
break
|
| 857 |
-
time.sleep(BD_SCRAPE_POLL)
|
| 858 |
-
waited += BD_SCRAPE_POLL
|
| 859 |
-
# β NAME THE SNAPSHOT. It has been paid for and it is still finishing; a note that said only
|
| 860 |
-
# "no rows" would throw away both the data and the way to get it.
|
| 861 |
-
if isinstance(deferred, list):
|
| 862 |
-
deferred.append({"snapshotId": sid, "datasetId": str(dataset_id),
|
| 863 |
-
"urls": requested_urls})
|
| 864 |
-
return [], (f"the profile source deferred this batch to {sid} and it was not ready within "
|
| 865 |
-
f"{int(budget)}s β the records are collected, not lost; ask for a smaller batch "
|
| 866 |
-
f"or collect {sid} from the control panel")
|
| 867 |
|
| 868 |
|
| 869 |
#: How many profile URLs ride in ONE `/datasets/v3/scrape` call.
|
|
@@ -890,8 +549,49 @@ PROFILE_BATCH_URLS = max(1, int(os.environ.get("AIOS_BD_PROFILE_BATCH") or 5))
|
|
| 890 |
MAX_POST_WINDOW = int(os.environ.get("AIOS_IG_MAX_POST_WINDOW") or 60)
|
| 891 |
|
| 892 |
|
| 893 |
-
def
|
| 894 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 895 |
|
| 896 |
β A DIFFERENT ROUTE FROM `pull_profile`, AND THAT IS THE WHOLE POINT. The profile record
|
| 897 |
embeds at most 12 posts however many you ask for β measured, not assumed. This is the
|
|
@@ -906,16 +606,28 @@ def bd_profile_posts(handle, count, deferred=None, wait=None):
|
|
| 906 |
n = max(1, min(int(count), MAX_POST_WINDOW))
|
| 907 |
except (TypeError, ValueError):
|
| 908 |
n = 12
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 909 |
payload, note = bd_call(BD_PATH_TRIGGER,
|
| 910 |
{"dataset_id": BD_DS_POSTS, "type": "discover_new",
|
| 911 |
"discover_by": "url"},
|
| 912 |
-
body=[
|
| 913 |
-
"num_of_posts": n}])
|
| 914 |
if note:
|
| 915 |
return [], note
|
| 916 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 917 |
if not sid:
|
| 918 |
-
return _bd_rows(payload), ""
|
| 919 |
budget = BD_SCRAPE_WAIT if wait is None else float(wait)
|
| 920 |
waited = 0.0
|
| 921 |
while True:
|
|
@@ -929,7 +641,7 @@ def bd_profile_posts(handle, count, deferred=None, wait=None):
|
|
| 929 |
if rows and not _bd_deferral(got) and not (
|
| 930 |
len(rows) == 1 and str(rows[0].get("status") or "") in
|
| 931 |
("running", "building", "collecting")):
|
| 932 |
-
return rows, ""
|
| 933 |
if waited >= budget:
|
| 934 |
break
|
| 935 |
time.sleep(BD_SCRAPE_POLL)
|
|
@@ -941,17 +653,12 @@ def bd_profile_posts(handle, count, deferred=None, wait=None):
|
|
| 941 |
f"{int(budget)}s β the records are collected, not lost")
|
| 942 |
|
| 943 |
|
| 944 |
-
def
|
| 945 |
-
"""
|
| 946 |
-
|
| 947 |
-
β 2026-08-09 (owner: *"not just last 12 but by group also"*). `groups` is
|
| 948 |
-
`[{"type": "video", "limit": 12}, {"type": "image", "limit": 6}]`. A type nobody named is
|
| 949 |
-
DROPPED, which is the point of a filter β asking for reels and getting carousels back is the
|
| 950 |
-
behaviour this replaces.
|
| 951 |
|
| 952 |
-
β
|
| 953 |
-
|
| 954 |
-
|
| 955 |
"""
|
| 956 |
wanted = {}
|
| 957 |
for g in (groups or []):
|
|
@@ -964,6 +671,45 @@ def select_post_groups(posts, groups):
|
|
| 964 |
continue
|
| 965 |
if t and lim > 0:
|
| 966 |
wanted[t] = max(wanted.get(t, 0), lim)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 967 |
if not wanted:
|
| 968 |
return list(posts or [])
|
| 969 |
dated, undated = [], []
|
|
@@ -1116,6 +862,65 @@ def apify_posts(urls):
|
|
| 1116 |
return [r for r in rows if r], ""
|
| 1117 |
|
| 1118 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1119 |
def apify_profile(handle):
|
| 1120 |
"""One handle -> (canonical profile | None, note). A note means it did NOT answer.
|
| 1121 |
|
|
@@ -1408,7 +1213,7 @@ DEFERRED_MARK = "__aios_deferred_snapshot__"
|
|
| 1408 |
|
| 1409 |
def pull_profile_bd(url, max_posts=None, post_metrics=False,
|
| 1410 |
comment_metrics=False, log=print, pending_metrics=None,
|
| 1411 |
-
pending_profile=None, prefetch=None):
|
| 1412 |
"""The paid rung: one profile, EXACT counts, plus the top posts the anonymous surface hides.
|
| 1413 |
|
| 1414 |
Same return contract as `pull_profile` (`{state, profile, posts, via, note}`) so the runner
|
|
@@ -1517,7 +1322,57 @@ def pull_profile_bd(url, max_posts=None, post_metrics=False,
|
|
| 1517 |
if got:
|
| 1518 |
posts.append(got)
|
| 1519 |
|
| 1520 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1521 |
time.sleep(min(PACE_SECONDS, 1.0)) # the vendor is paid, but it is still someone's API
|
| 1522 |
# Posts and Reels are different Bright Data endpoints. Sending every permalink to the
|
| 1523 |
# Posts endpoint was why Inayma's five video rows got likes/comments but no views: the
|
|
@@ -1525,7 +1380,7 @@ def pull_profile_bd(url, max_posts=None, post_metrics=False,
|
|
| 1525 |
# without duplicating any record, so enabling post metrics still buys one row per post.
|
| 1526 |
ordinary = [p for p in posts if p.get("type") != "video"]
|
| 1527 |
videos = [p for p in posts if p.get("type") == "video"]
|
| 1528 |
-
|
| 1529 |
# `None` keeps direct callers backwards-compatible: they wait for their answer. An
|
| 1530 |
# automation passes one run-level list, which makes a deferred metric batch durable and
|
| 1531 |
# lets this profile finish rather than parking the whole enrichment on it.
|
|
@@ -1589,7 +1444,18 @@ def pull_profile_bd(url, max_posts=None, post_metrics=False,
|
|
| 1589 |
elif metric_notes:
|
| 1590 |
note = f"some post metrics were unavailable: {'; '.join(metric_notes)}"
|
| 1591 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1592 |
note = "; ".join(x for x in (note, top_up_views(posts, log=log)) if x)
|
|
|
|
|
|
|
| 1593 |
|
| 1594 |
# ββ DEBT D-82 (owner ruling R12: *"price, then wire the Posts-dataset call so posts_count
|
| 1595 |
# fills with the true value"*). The Profiles dataset's `posts_count: 0` is discarded as the
|
|
@@ -1665,243 +1531,29 @@ def pull_profile_bd(url, max_posts=None, post_metrics=False,
|
|
| 1665 |
"via": via,
|
| 1666 |
"note": note or ("" if posts else "profile read; no posts were returned")}
|
| 1667 |
|
| 1668 |
-
#: β THE VENDOR'S HARD CAP ON ONE LOGICAL GROUP β MEASURED 2026-08-06 against the live filter API
|
| 1669 |
-
#: with nurilab's own seven-condition search, which had failed every run for two days:
|
| 1670 |
-
#:
|
| 1671 |
-
#: 400 {"validation_errors":["Filter logical groups can have a maximum of 4 rules."]}
|
| 1672 |
-
#:
|
| 1673 |
-
#: β EVERY ONE OF THOSE SEVEN CONDITIONS IS ACCEPTED ALONE (all seven probed individually, all
|
| 1674 |
-
#: 200). The refusal is about the SHAPE of the group and nothing else β so no field, operator or
|
| 1675 |
-
#: value in the builder is at fault, and no amount of editing the conditions would have found it.
|
| 1676 |
-
#: What it means without `bd_group` below: the FIFTH condition anybody adds turns a working
|
| 1677 |
-
#: discovery automation into one that fails forever, and the surface blames the search.
|
| 1678 |
-
BD_GROUP_MAX = 4
|
| 1679 |
-
|
| 1680 |
-
|
| 1681 |
-
def bd_group(nodes, operator):
|
| 1682 |
-
"""`nodes` β an equivalent list in which NO logical group exceeds `BD_GROUP_MAX` rules.
|
| 1683 |
-
|
| 1684 |
-
β SAFE BECAUSE AND AND OR ARE ASSOCIATIVE. Folding a run of same-operator rules into a nested
|
| 1685 |
-
group of the SAME operator cannot change what the filter matches β `aβ§bβ§cβ§dβ§e` and
|
| 1686 |
-
`(aβ§bβ§cβ§d)β§e` are one predicate written two ways. That is why this is a re-SHAPING of the
|
| 1687 |
-
owner's search rather than a reinterpretation of it, and why it needs no ruling: a condition
|
| 1688 |
-
list means the same thing before and after. Nesting itself is already proven on this API
|
| 1689 |
-
(`expand_predicates`' or-groups, `snap_msh14ix81n8hlpo3nr`).
|
| 1690 |
-
|
| 1691 |
-
β A ONE-RULE CHUNK IS NEVER WRAPPED. `expand_predicates` already carries that law for the
|
| 1692 |
-
one-value case β a one-element group is a shape nothing has ever been billed against β and a
|
| 1693 |
-
balanced split reaches it whenever a level divides with a remainder of one.
|
| 1694 |
-
"""
|
| 1695 |
-
nodes = list(nodes or [])
|
| 1696 |
-
if len(nodes) <= BD_GROUP_MAX:
|
| 1697 |
-
return nodes
|
| 1698 |
-
# β BALANCED, NOT "take three and nest the rest". The tail-recursive shape costs one level of
|
| 1699 |
-
# DEPTH per extra rule (13 conditions β 5 deep) against a vendor whose nesting limit we have
|
| 1700 |
-
# not measured and would discover the same way we discovered this one: in production, on
|
| 1701 |
-
# somebody's automation. Chunking makes the depth logβ(n) β 16 conditions fit in two levels.
|
| 1702 |
-
out = []
|
| 1703 |
-
for i in range(0, len(nodes), BD_GROUP_MAX):
|
| 1704 |
-
chunk = nodes[i:i + BD_GROUP_MAX]
|
| 1705 |
-
out.append(chunk[0] if len(chunk) == 1 else {"operator": operator, "filters": chunk})
|
| 1706 |
-
return bd_group(out, operator)
|
| 1707 |
-
|
| 1708 |
-
|
| 1709 |
-
#: β DEBT D-68 β THE VENDOR'S NESTING CEILING, MEASURED IN PRODUCTION 2026-08-06:
|
| 1710 |
-
#: 400 β "filter" failed custom validation because logical operators cannot be more than
|
| 1711 |
-
#: 3 levels deep
|
| 1712 |
-
#: β AND IT IS NOT AN INDEPENDENT BUDGET FROM `BD_GROUP_MAX`. A >4-rule group is fixed by
|
| 1713 |
-
#: NESTING, so the 4-rule cap is what MANUFACTURES depth: five top-level conditions carrying
|
| 1714 |
-
#: multi-value lists goes `AND`(split) β `OR`(values) β `OR`(split) and is refused. The real
|
| 1715 |
-
#: envelope is about 4 top-level conditions x <=4 values each, which is why
|
| 1716 |
-
#: `MAX_PREDICATE_VALUES = 12` was never reachable at a realistic condition count.
|
| 1717 |
-
BD_MAX_DEPTH = 3
|
| 1718 |
-
|
| 1719 |
-
|
| 1720 |
-
def filter_depth(node):
|
| 1721 |
-
"""How many logical levels a vendor `filters` structure nests to. A bare rule is depth 1."""
|
| 1722 |
-
if isinstance(node, list):
|
| 1723 |
-
return max((filter_depth(n) for n in node), default=0)
|
| 1724 |
-
if isinstance(node, dict) and isinstance(node.get("filters"), list):
|
| 1725 |
-
return 1 + filter_depth(node["filters"])
|
| 1726 |
-
return 1
|
| 1727 |
-
|
| 1728 |
-
|
| 1729 |
-
def depth_refusal(predicates, operator="and"):
|
| 1730 |
-
"""β D-68 β refuse a search whose SHAPE the vendor will reject, before it is sent.
|
| 1731 |
-
|
| 1732 |
-
Returns a sentence or `""`.
|
| 1733 |
-
|
| 1734 |
-
β WHY THIS CANNOT BE A RULE-COUNT CHECK. The failure is in the emitted STRUCTURE, not in the
|
| 1735 |
-
conditions: the same five conditions pass or fail depending on how many VALUES each carries,
|
| 1736 |
-
because every multi-value list becomes its own OR level and every group over `BD_GROUP_MAX`
|
| 1737 |
-
adds a split level. So the only honest test is to BUILD the shape and measure it β which is
|
| 1738 |
-
cheap, because we build it anyway.
|
| 1739 |
-
β Free to get wrong at the vendor and expensive to diagnose there: a rejected filter is never
|
| 1740 |
-
billed, but the 400 says nothing about which condition to drop, so a person edits keywords for
|
| 1741 |
-
an afternoon while the shape stays identical. That is what this sentence exists to prevent.
|
| 1742 |
-
"""
|
| 1743 |
-
try:
|
| 1744 |
-
depth = filter_depth(bd_group(expand_predicates(predicates), operator or "and"))
|
| 1745 |
-
except Exception: # noqa: BLE001
|
| 1746 |
-
return "" # never block a save on this check's own bug
|
| 1747 |
-
if depth <= BD_MAX_DEPTH:
|
| 1748 |
-
return ""
|
| 1749 |
-
multi = [p for p in (predicates or [])
|
| 1750 |
-
if isinstance((p or {}).get("value"), (list, tuple)) and len(p["value"]) > 1]
|
| 1751 |
-
hint = (f"drop one of the {len(multi)} multi-value conditions, or shorten its list"
|
| 1752 |
-
if multi else "drop one condition")
|
| 1753 |
-
return (f"this search nests {depth} levels deep and the provider allows "
|
| 1754 |
-
f"{BD_MAX_DEPTH} β {hint}. Every extra condition beyond {BD_GROUP_MAX}, and every "
|
| 1755 |
-
f"list of values, adds a level")
|
| 1756 |
-
|
| 1757 |
-
|
| 1758 |
-
def expand_predicates(predicates):
|
| 1759 |
-
"""Stored predicates β the vendor's `filters` list, expanding a value LIST into a nested OR.
|
| 1760 |
-
|
| 1761 |
-
β MEASURED 2026-08-06: the filter API ACCEPTS a nested `{operator, filters:[β¦]}` inside
|
| 1762 |
-
`filters` (`snap_msh14ix81n8hlpo3nr`), which is what makes "Bio contains any of floral,
|
| 1763 |
-
flower, beauty" expressible WITHOUT setting the whole search to "match any". The alternative
|
| 1764 |
-
β the global Match dropdown β would drag every other condition into the same union, so
|
| 1765 |
-
"β¦and at least 10,000 followers" would silently become "β¦or at least 10,000 followers".
|
| 1766 |
-
|
| 1767 |
-
β A ONE-VALUE CONDITION IS STILL SENT FLAT. Wrapping it in a one-element group would be a
|
| 1768 |
-
shape nothing has ever been billed against, adopted for tidiness, on the path that spends
|
| 1769 |
-
money. Only the several-values case takes the new shape.
|
| 1770 |
-
"""
|
| 1771 |
-
out = []
|
| 1772 |
-
for p in predicates or []:
|
| 1773 |
-
v = (p or {}).get("value")
|
| 1774 |
-
if isinstance(v, list) and len(v) > 1:
|
| 1775 |
-
# β THE OR-GROUP IS SUBJECT TO THE SAME 4-RULE CAP as the outer one, and this is the
|
| 1776 |
-
# leg that hits it first in practice: "Bio contains any of floral, flower, beauty,
|
| 1777 |
-
# bouquet, wedding" is five keywords in ONE condition β a single row in the builder,
|
| 1778 |
-
# and a group the vendor refuses. `bd_group` is applied to the union too.
|
| 1779 |
-
out.append({"operator": "or",
|
| 1780 |
-
"filters": bd_group(
|
| 1781 |
-
[{"name": p["name"], "operator": p["operator"], "value": one}
|
| 1782 |
-
for one in v], "or")})
|
| 1783 |
-
elif isinstance(v, list):
|
| 1784 |
-
out.append({**p, "value": v[0]} if v else
|
| 1785 |
-
{k: val for k, val in p.items() if k != "value"})
|
| 1786 |
-
else:
|
| 1787 |
-
out.append(dict(p))
|
| 1788 |
-
return out
|
| 1789 |
|
| 1790 |
|
| 1791 |
-
#: β HOW MANY HANDLES ONE `not_in` MAY CARRY. W25/R1b MEASURED that the vendor accepts a flat
|
| 1792 |
-
#: `account not_in [...]` list to this size β *"the 12-value ceiling was OURS, not theirs"*. It is
|
| 1793 |
-
#: also why the rule below is built BY HAND instead of going through `expand_predicates`: that
|
| 1794 |
-
#: function turns any multi-value predicate into a nested OR of one-value rules, which for 5,000
|
| 1795 |
-
#: handles would be a 5,000-rule tree the vendor refuses on both its caps at once.
|
| 1796 |
-
BD_EXCLUDE_MAX = 5000
|
| 1797 |
-
|
| 1798 |
-
|
| 1799 |
-
def bd_filter_start(predicates, operator="and", records_limit=5, dataset_id=BD_DS_PROFILES,
|
| 1800 |
-
exclude_handles=(), applied=None):
|
| 1801 |
-
"""Start ONE corpus query. Returns `(snapshot_id, note)`.
|
| 1802 |
-
|
| 1803 |
-
β ALWAYS THE COMPOUND `{operator, filters:[β¦]}` SHAPE, even for a single condition. A bare
|
| 1804 |
-
`{name, operator, value}` is a plausible-looking second shape that has never been sent from
|
| 1805 |
-
here, and a one-element `filters` list is a trivial extension of the three-element body that
|
| 1806 |
-
HAS been measured end-to-end. One shape, and it is the tested one.
|
| 1807 |
-
|
| 1808 |
-
ββ WAVE 27 ITEM 7 β `exclude_handles` IS THE ENGINE'S OWN REFINEMENT AND IS NEVER A CONDITION
|
| 1809 |
-
ROW. Until now discovery paid for every re-found profile: a nightly search over the same
|
| 1810 |
-
keywords returns the same accounts, the upsert notices they are `seen_again`, and the vendor
|
| 1811 |
-
has already billed for all of them. This narrows the search at the VENDOR instead of after it.
|
| 1812 |
-
It is invisible to the condition builder deliberately (W25/R1b: `not_in` is an engine-added
|
| 1813 |
-
refinement, and `BD_OPS_BY_KIND` omits it so nobody can type one) β a person did not write it,
|
| 1814 |
-
should not have to maintain it, and would be confused by a condition that changes every night.
|
| 1815 |
-
|
| 1816 |
-
β AND IT IS DROPPED RATHER THAN RISKED. Two ways a free saving could break a search that
|
| 1817 |
-
works, and both are checked before the body is sent:
|
| 1818 |
-
1. **DEPTH.** The vendor allows 3 logical levels and the 4-rule group cap MANUFACTURES
|
| 1819 |
-
depth, so a search already near the ceiling gains a level from this rule and starts
|
| 1820 |
-
answering 400 forever ([[bright-data-group-cap]]).
|
| 1821 |
-
2. **`or`.** Under a top-level OR, adding an exclusion as another branch would WIDEN the
|
| 1822 |
-
search to "β¦or any account not in this list" β i.e. the whole corpus. Under OR the
|
| 1823 |
-
existing filter is wrapped in an AND instead, which costs the level checked in (1).
|
| 1824 |
-
A dropped exclusion is not an error: the search runs exactly as it did before and we pay for
|
| 1825 |
-
the duplicates, which is the status quo, not a regression.
|
| 1826 |
-
"""
|
| 1827 |
-
nodes = bd_group(expand_predicates(predicates), operator)
|
| 1828 |
-
filt = {"operator": operator, "filters": nodes}
|
| 1829 |
-
handles = [str(h).strip().lstrip("@").lower() for h in (exclude_handles or [])]
|
| 1830 |
-
handles = [h for h in dict.fromkeys(handles) if h][:BD_EXCLUDE_MAX]
|
| 1831 |
-
if isinstance(applied, dict):
|
| 1832 |
-
applied["available"] = len(handles)
|
| 1833 |
-
applied["excluded"] = 0
|
| 1834 |
-
if handles:
|
| 1835 |
-
rule = {"name": "account", "operator": "not_in", "value": handles}
|
| 1836 |
-
cand = (bd_group([*nodes, rule], "and") if operator == "and"
|
| 1837 |
-
else [filt, rule])
|
| 1838 |
-
if filter_depth(cand) <= BD_MAX_DEPTH:
|
| 1839 |
-
filt = {"operator": "and", "filters": cand}
|
| 1840 |
-
if isinstance(applied, dict):
|
| 1841 |
-
applied["excluded"] = len(handles)
|
| 1842 |
-
elif isinstance(applied, dict):
|
| 1843 |
-
applied["dropped"] = ("the search is already at the provider's nesting ceiling, so "
|
| 1844 |
-
"the already-found list was left off rather than risk it")
|
| 1845 |
-
body = {"dataset_id": dataset_id, "records_limit": int(records_limit),
|
| 1846 |
-
# β `bd_group` β the top-level group obeys the vendor's 4-rule cap (see its note).
|
| 1847 |
-
# Applied HERE rather than inside `expand_predicates` so the expansion stays a pure
|
| 1848 |
-
# stored-shape β wire-shape translation and the rebalance is one readable step on the
|
| 1849 |
-
# one path that actually posts a filter.
|
| 1850 |
-
"filter": filt}
|
| 1851 |
-
payload, note = bd_call(BD_PATH_FILTER, body=body)
|
| 1852 |
-
if note:
|
| 1853 |
-
return "", note
|
| 1854 |
-
sid = str((payload or {}).get("snapshot_id") or "") if isinstance(payload, dict) else ""
|
| 1855 |
-
if not sid:
|
| 1856 |
-
return "", "the search was accepted but came back with nothing to collect it from"
|
| 1857 |
-
return sid, ""
|
| 1858 |
|
| 1859 |
|
| 1860 |
-
|
| 1861 |
-
|
| 1862 |
-
|
| 1863 |
-
|
| 1864 |
-
|
| 1865 |
-
|
| 1866 |
-
|
| 1867 |
-
|
| 1868 |
-
|
| 1869 |
-
|
| 1870 |
-
|
| 1871 |
-
|
| 1872 |
-
|
| 1873 |
-
|
| 1874 |
-
# status and no error note.
|
| 1875 |
-
return "empty", 0, ""
|
| 1876 |
-
if status == "failed" and err:
|
| 1877 |
-
# NOT_ENOUGH_FUNDS is the one everybody will hit; say what it means, not just its name.
|
| 1878 |
-
extra = (" β the matched set is too large for this account's balance; narrow the "
|
| 1879 |
-
"conditions or lower the limit" if "FUNDS" in err.upper() else "")
|
| 1880 |
-
return status, 0, f"the search failed: {err}{extra}"
|
| 1881 |
-
return status, int(d.get("dataset_size") or 0), ""
|
| 1882 |
-
|
| 1883 |
-
|
| 1884 |
-
def bd_filter_rows(snapshot_id):
|
| 1885 |
-
"""The rows of a READY corpus snapshot. `(rows, note)`.
|
| 1886 |
-
|
| 1887 |
-
β `/download` answered *"Snapshot is building. Try again in a few minutes"* while the status
|
| 1888 |
-
endpoint ALREADY said `ready` β delivery lags readiness by minutes. So a download that does
|
| 1889 |
-
not parse as rows is reported as not-yet-collectable rather than as an error.
|
| 1890 |
-
"""
|
| 1891 |
-
payload, note = bd_call(f"{BD_PATH_FILTER_SNAPSHOT}/{snapshot_id}/download",
|
| 1892 |
-
{"format": "json"})
|
| 1893 |
-
if note:
|
| 1894 |
-
return [], note
|
| 1895 |
-
rows = _bd_rows(payload)
|
| 1896 |
-
if not rows:
|
| 1897 |
-
return [], "the search is ready but its rows are not downloadable yet β the vendor's " \
|
| 1898 |
-
"delivery lags its own `ready` status by minutes"
|
| 1899 |
-
return rows, ""
|
| 1900 |
|
| 1901 |
|
| 1902 |
def pull_profile(url, max_posts=None, log=print,
|
| 1903 |
post_metrics=False, comment_metrics=False, pending_metrics=None,
|
| 1904 |
-
pending_profile=None, prefetch=None):
|
| 1905 |
"""Everything readable about one public profile β EXACT NUMBERS OR NOTHING.
|
| 1906 |
|
| 1907 |
Returns `{state, profile, posts, via, note}` with state β ok | partial | blocked | error.
|
|
@@ -1943,7 +1595,8 @@ def pull_profile(url, max_posts=None, log=print,
|
|
| 1943 |
paid = pull_profile_bd(url, max_posts=max_posts, post_metrics=post_metrics,
|
| 1944 |
comment_metrics=comment_metrics, log=log,
|
| 1945 |
pending_metrics=pending_metrics,
|
| 1946 |
-
pending_profile=pending_profile, prefetch=prefetch
|
|
|
|
| 1947 |
if paid["state"] in ("ok", "partial"):
|
| 1948 |
return paid
|
| 1949 |
# β 200, NOT 90. This string is the ONLY account of what the vendor said, and at 90 the
|
|
|
|
| 35 |
inside a vendor call. **An intermittent-by-import-order failure is the worst shape available**, so
|
| 36 |
every reach-back is a `from automation_engine import β¦` INSIDE the function that needs it, where
|
| 37 |
the module is always fully built. There are exactly seven β `_bd_why`, `bd_call`,
|
| 38 |
+
`apify_posts`, `apify_reels`, `apify_profile`, `_tag_metric_deferrals`, `pull_profile_bd`,
|
| 39 |
+
`pull_profile` β each
|
| 40 |
marked `# lazy β see the module header`, and they reach for four kinds of thing:
|
| 41 |
|
| 42 |
fetch / fetch_json / Refused the SSRF-guarded HTTP rail (the scrape runner shares it)
|
|
|
|
| 66 |
|
| 67 |
import providers
|
| 68 |
|
| 69 |
+
# ββ WAVE 30 Β· T09 (DEBT D-128) β THE VENDOR WIRE LIVES IN `connectors_bd.py` NOW, AND THIS IS AN
|
| 70 |
+
# IMPORT, NOT A RE-EXPORT-FOR-EVERYONE. Only the names THIS file's own code still calls are listed:
|
| 71 |
+
# `verify_automation.section_split` derives the list both ways from the AST, so an import that
|
| 72 |
+
# outlived its last call site and a use with no import are each a red. Everything else that used to
|
| 73 |
+
# live here β `bd_ready`, `bd_key`, the whole corpus filter, `bd_group`, `expand_predicates` β is
|
| 74 |
+
# reached by its callers directly from `connectors_bd`.
|
| 75 |
+
#
|
| 76 |
+
# β **WHY THE NAMES ARE IMPORTED RATHER THAN CALLED AS `bd.<name>`, deliberately:** the functions
|
| 77 |
+
# below that still call `bd_call` and `bd_scrape` resolve them from THIS module's globals, which is
|
| 78 |
+
# what makes `connectors_ig.bd_call = <stub>` continue to seal them exactly as it did before the
|
| 79 |
+
# split. Changing the call style would have silently retired six working patch sites in the gate.
|
| 80 |
+
# β **AND THE HALF THAT DOES NOT SURVIVE, because it is the money one:** a function that MOVED
|
| 81 |
+
# (`bd_scrape`, `bd_snapshot_progress`, the corpus trio) now resolves `bd_call` from
|
| 82 |
+
# `connectors_bd`'s globals, so patching it HERE no longer seals it. `verify_automation._seal_bd`
|
| 83 |
+
# patches every module that holds a binding and then sweeps `sys.modules` to prove none was missed.
|
| 84 |
+
from connectors_bd import ( # noqa: F401
|
| 85 |
+
BD_MAX_KB,
|
| 86 |
+
BD_METRIC_SCRAPE_WAIT,
|
| 87 |
+
BD_PATH_FILTER,
|
| 88 |
+
BD_PATH_FILTER_SNAPSHOT,
|
| 89 |
+
BD_PATH_SNAPSHOT,
|
| 90 |
+
BD_PATH_TRIGGER,
|
| 91 |
+
BD_SCRAPE_POLL,
|
| 92 |
+
BD_SCRAPE_WAIT,
|
| 93 |
+
_bd_deferral,
|
| 94 |
+
_bd_first_url,
|
| 95 |
+
_bd_flag,
|
| 96 |
+
_bd_list,
|
| 97 |
+
_bd_rows,
|
| 98 |
+
_bd_source_payload,
|
| 99 |
+
_first,
|
| 100 |
+
_ig_int,
|
| 101 |
+
bd_call,
|
| 102 |
+
bd_scrape,
|
| 103 |
+
bd_snapshot_progress
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
|
| 107 |
# ---------------------------------------------------------------------------------------------
|
| 108 |
# HANDLE + COUNT READERS (no vendor, no key β pure parsing)
|
|
|
|
| 132 |
return re.sub(r"[^A-Za-z0-9._]", "", first)[:40]
|
| 133 |
|
| 134 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
|
| 137 |
def _ig_zero_is_blank(v):
|
|
|
|
| 149 |
return None if n == 0 else n
|
| 150 |
|
| 151 |
|
|
|
|
| 152 |
BD_DS_PROFILES = "gd_l1vikfch901nx3by4" # Instagram β Profiles. 36 fields, 620M records
|
| 153 |
BD_DS_POSTS = "gd_lk5ns7kz21pck8jpis" # Instagram β Posts. 43 fields
|
| 154 |
BD_DS_REELS = "gd_lyclm20il4r5helnj" # Instagram Reels: views and play counts
|
| 155 |
BD_DS_COMMENTS = "gd_ltppn085pokosxh13" # Instagram Comments: opt-in full comment engagement
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
|
|
|
|
|
|
|
| 158 |
|
|
|
|
|
|
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
|
|
|
|
|
|
|
| 162 |
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
|
| 170 |
#: Source-data retention is intentionally unfiltered: if Bright Data returned it in an already
|
|
|
|
| 172 |
#: The one cost boundary is the *request*: the full Comments dataset is only called when an owner
|
| 173 |
#: enables `commentMetrics`.
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
#: The vendor's content vocabulary β ours. MEASURED values: profiles' `posts[].content_type` is
|
| 177 |
#: `Image`/`Reel`/`Carousel`; the Posts dataset adds `product_type` `clips`/`carousel_container`.
|
|
|
|
| 206 |
return ""
|
| 207 |
|
| 208 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
|
| 210 |
|
| 211 |
def _bd_posts_count(node):
|
|
|
|
| 228 |
return None if v == 0 else v
|
| 229 |
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
|
| 232 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
|
| 234 |
|
| 235 |
def _bd_tagged_location(node):
|
|
|
|
| 254 |
return ", ".join(parts)[:400] or None
|
| 255 |
|
| 256 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
|
| 258 |
|
| 259 |
def _bd_comment(row, influencer_key="", shortcode="", fallback_key=""):
|
|
|
|
| 517 |
return None
|
| 518 |
|
| 519 |
|
|
|
|
|
|
|
| 520 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
|
| 522 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
|
| 525 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
|
| 527 |
|
| 528 |
#: How many profile URLs ride in ONE `/datasets/v3/scrape` call.
|
|
|
|
| 549 |
MAX_POST_WINDOW = int(os.environ.get("AIOS_IG_MAX_POST_WINDOW") or 60)
|
| 550 |
|
| 551 |
|
| 552 |
+
def _bd_mapped_posts(rows, raw_out=None):
|
| 553 |
+
"""Posts-dataset rows -> our canonical post rows, drops what will not map.
|
| 554 |
+
|
| 555 |
+
β `raw_out` IS NOT A CONVENIENCE. `bd_true_posts_count` reads the vendor's profile-context
|
| 556 |
+
echo, which lives on the RAW row and is destroyed by mapping β so a caller that only kept the
|
| 557 |
+
mapped rows would lose the FREE `posts_count` read and fall through to the branch that BUYS a
|
| 558 |
+
record for it. The sink keeps a window route exactly as cheap as the per-permalink one it
|
| 559 |
+
replaces. Same shape as `deferred=` and `applied=` elsewhere in this module.
|
| 560 |
+
|
| 561 |
+
β `_bd_post_metrics`, the SAME mapper `pull_profile_bd` feeds its per-permalink reads through.
|
| 562 |
+
A window route with its own mapper would be a second answer to "which vendor key is `views`",
|
| 563 |
+
and this repo has already paid for one of those ([[one-question-two-normalizers]]).
|
| 564 |
+
"""
|
| 565 |
+
out = []
|
| 566 |
+
for r in (rows or []):
|
| 567 |
+
if isinstance(raw_out, list) and isinstance(r, dict):
|
| 568 |
+
raw_out.append(r)
|
| 569 |
+
got = _bd_post_metrics(r) if isinstance(r, dict) else None
|
| 570 |
+
if got:
|
| 571 |
+
out.append(got)
|
| 572 |
+
return out
|
| 573 |
+
|
| 574 |
+
|
| 575 |
+
def bd_profile_posts(handle, count, deferred=None, wait=None, post_type="", raw_out=None):
|
| 576 |
+
"""A profile's LAST `count` posts via discover-by-url. `(rows, note)` β rows already MAPPED.
|
| 577 |
+
|
| 578 |
+
ββ WAVE 30 Β· T16 (carried W29-T10) β `post_type` IS THE WHOLE FIX AND IT IS ONE KEY.
|
| 579 |
+
MEASURED 2026-08-10 on `@theresalearns`, same window, same hour, so no probability argument is
|
| 580 |
+
needed: this route WITHOUT the key returned **8 Reels + 3 Carousels** for a 12-post ask; WITH
|
| 581 |
+
`post_type="reel"` it returned **12/12**. Asking for "the last 12 reels" and being handed eight
|
| 582 |
+
is not a filter that under-delivers, it is a filter applied to the wrong set β the shortfall
|
| 583 |
+
scales with a creator's carousel share and nothing in the run says so.
|
| 584 |
+
β It is HONOURED, not silently ignored (the failure mode that looks like success): the returned
|
| 585 |
+
rows carry `content_type: "Reel"`. Same dataset, same route, same mapper, same price.
|
| 586 |
+
β **AND IT DOES NOT REVIVE VIEWS ON BRIGHT DATA.** This measured `product_type`, never `views`;
|
| 587 |
+
the Reels dataset stays type-capable and views-INCAPABLE ([[bd-reels-views-account-level]]).
|
| 588 |
+
Views still come from Apify. Conflating the two questions about one dataset cost a day once.
|
| 589 |
+
|
| 590 |
+
β ROWS ARE MAPPED HERE NOW, and that is the second half of why this had no caller. It returned
|
| 591 |
+
raw `_bd_rows()` β a vendor-shaped list every caller would have had to normalise itself, which
|
| 592 |
+
is a second mapper waiting to disagree with `_bd_post_metrics`. It is the boundary's job.
|
| 593 |
+
|
| 594 |
+
β A DIFFERENT ROUTE FROM `pull_profile`, AND THAT IS THE WHOLE POINT.
|
| 595 |
|
| 596 |
β A DIFFERENT ROUTE FROM `pull_profile`, AND THAT IS THE WHOLE POINT. The profile record
|
| 597 |
embeds at most 12 posts however many you ask for β measured, not assumed. This is the
|
|
|
|
| 606 |
n = max(1, min(int(count), MAX_POST_WINDOW))
|
| 607 |
except (TypeError, ValueError):
|
| 608 |
n = 12
|
| 609 |
+
_row = {"url": f"https://www.instagram.com/{ig_handle(handle) or handle}/",
|
| 610 |
+
"num_of_posts": n}
|
| 611 |
+
if str(post_type or "").strip():
|
| 612 |
+
# β SENT ONLY WHEN ASKED FOR. An unconditional `post_type: "post"` would change what every
|
| 613 |
+
# future caller buys, and the vendor's documented values are exactly `post` and `reel`.
|
| 614 |
+
_row["post_type"] = str(post_type).strip()
|
| 615 |
payload, note = bd_call(BD_PATH_TRIGGER,
|
| 616 |
{"dataset_id": BD_DS_POSTS, "type": "discover_new",
|
| 617 |
"discover_by": "url"},
|
| 618 |
+
body=[_row])
|
|
|
|
| 619 |
if note:
|
| 620 |
return [], note
|
| 621 |
+
# β `isinstance`, NOT `(payload or {}).get(...)` β WAVE 30 Β· T16, and this is a crash that
|
| 622 |
+
# shipped because the function had no caller. The very next line handles the case where the
|
| 623 |
+
# vendor answered with ROWS INLINE instead of a snapshot id, so a list payload is an EXPECTED
|
| 624 |
+
# shape here; `.get` on it raises `AttributeError` before that line can run. Whole, correct-
|
| 625 |
+
# looking, unreachable code is not tested code β the first real caller found this in one run
|
| 626 |
+
# ([[artifact-with-no-importer]], the failure mode rather than the waste).
|
| 627 |
+
sid = _bd_deferral(payload) or (str(payload.get("snapshot_id") or "")
|
| 628 |
+
if isinstance(payload, dict) else "")
|
| 629 |
if not sid:
|
| 630 |
+
return _bd_mapped_posts(_bd_rows(payload), raw_out), ""
|
| 631 |
budget = BD_SCRAPE_WAIT if wait is None else float(wait)
|
| 632 |
waited = 0.0
|
| 633 |
while True:
|
|
|
|
| 641 |
if rows and not _bd_deferral(got) and not (
|
| 642 |
len(rows) == 1 and str(rows[0].get("status") or "") in
|
| 643 |
("running", "building", "collecting")):
|
| 644 |
+
return _bd_mapped_posts(rows, raw_out), ""
|
| 645 |
if waited >= budget:
|
| 646 |
break
|
| 647 |
time.sleep(BD_SCRAPE_POLL)
|
|
|
|
| 653 |
f"{int(budget)}s β the records are collected, not lost")
|
| 654 |
|
| 655 |
|
| 656 |
+
def group_types(groups):
|
| 657 |
+
"""`{type: limit}` for a stored `postGroups` value, ignoring anything malformed.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 658 |
|
| 659 |
+
β ONE READER FOR THE STORED SHAPE. `select_post_groups` built this dict inline and the two
|
| 660 |
+
routing helpers below need the same answer; three copies of "what did the person ask for" is
|
| 661 |
+
how a filter and the read that feeds it end up disagreeing about the ask.
|
| 662 |
"""
|
| 663 |
wanted = {}
|
| 664 |
for g in (groups or []):
|
|
|
|
| 671 |
continue
|
| 672 |
if t and lim > 0:
|
| 673 |
wanted[t] = max(wanted.get(t, 0), lim)
|
| 674 |
+
return wanted
|
| 675 |
+
|
| 676 |
+
|
| 677 |
+
def reels_only_limit(groups):
|
| 678 |
+
"""The reel limit when the config asks for REELS AND NOTHING ELSE, else 0.
|
| 679 |
+
|
| 680 |
+
β THE TEST IS EXCLUSIVITY, NOT PRESENCE. "12 reels AND 6 images" cannot be served by a
|
| 681 |
+
single-type route β one request cannot express it β so a `video in wanted` test would send a
|
| 682 |
+
mixed ask down the reels-only branch and silently drop the images. That is the specialisation
|
| 683 |
+
trap: the narrow route is right only when the ask is narrow.
|
| 684 |
+
"""
|
| 685 |
+
wanted = group_types(groups)
|
| 686 |
+
return wanted.get("video", 0) if set(wanted) == {"video"} else 0
|
| 687 |
+
|
| 688 |
+
|
| 689 |
+
def group_window(groups):
|
| 690 |
+
"""How many posts ONE wide read must return for every group to be fillable.
|
| 691 |
+
|
| 692 |
+
β THE SUM, then bounded by `MAX_POST_WINDOW`. A mixed ask is only satisfiable if the window is
|
| 693 |
+
at least as large as everything asked for, and the vendor's window is what actually limits it β
|
| 694 |
+
which is why the caller REPORTS a bound it could not meet rather than quietly returning fewer
|
| 695 |
+
(a short window reads downstream as "this creator posts less", which is a claim about them).
|
| 696 |
+
"""
|
| 697 |
+
return min(sum(group_types(groups).values()), MAX_POST_WINDOW)
|
| 698 |
+
|
| 699 |
+
|
| 700 |
+
def select_post_groups(posts, groups):
|
| 701 |
+
"""Keep the newest `limit` posts of each named TYPE. Returns the kept posts, newest first.
|
| 702 |
+
|
| 703 |
+
β 2026-08-09 (owner: *"not just last 12 but by group also"*). `groups` is
|
| 704 |
+
`[{"type": "video", "limit": 12}, {"type": "image", "limit": 6}]`. A type nobody named is
|
| 705 |
+
DROPPED, which is the point of a filter β asking for reels and getting carousels back is the
|
| 706 |
+
behaviour this replaces.
|
| 707 |
+
|
| 708 |
+
β NEWEST-FIRST BY `posted_at`, and a post with no date sorts LAST rather than first. A blank
|
| 709 |
+
date is not "just now"; ranking it as though it were would silently prefer exactly the rows we
|
| 710 |
+
know least about ([[sentinel-in-a-sort-key]] β partition, never rank a sentinel).
|
| 711 |
+
"""
|
| 712 |
+
wanted = group_types(groups)
|
| 713 |
if not wanted:
|
| 714 |
return list(posts or [])
|
| 715 |
dated, undated = [], []
|
|
|
|
| 862 |
return [r for r in rows if r], ""
|
| 863 |
|
| 864 |
|
| 865 |
+
def apify_reels(handle, limit):
|
| 866 |
+
"""A profile's LAST `limit` REELS, with view counts INLINE. `(rows, note)`.
|
| 867 |
+
|
| 868 |
+
ββ WAVE 30 Β· T16 (carried W29-T11) β ONE CALL FOR WHAT CURRENTLY TAKES THREE.
|
| 869 |
+
MEASURED 2026-08-10 (`@theresalearns`): `resultsType: "reels"` + `resultsLimit: 12` returned
|
| 870 |
+
**12/12 `productType: clips`, every one carrying a distinct `videoPlayCount`** (2,113 β
|
| 871 |
+
418,232). The shipped path for the same answer is a Bright Data profile read, a client-side
|
| 872 |
+
type filter, and a SEPARATE per-permalink Apify `top_up_views` β three steps, two vendors, and
|
| 873 |
+
one request's worth of data.
|
| 874 |
+
|
| 875 |
+
β A NEW BRANCH, NEVER A RE-POINTING OF THE DEFAULT CHAIN. `ig_post_metrics` does not move and
|
| 876 |
+
Bright Data stays first for it; this is the specialisation for a single-type ask, and it is
|
| 877 |
+
reached only when the config's groups name reels and nothing else.
|
| 878 |
+
β ITS CALLER MUST NOT THEN RUN `top_up_views`. The views are already here; asking Apify again
|
| 879 |
+
per permalink would buy the same numbers twice, which is the shape a "top-up" is meant to
|
| 880 |
+
avoid rather than cause.
|
| 881 |
+
β THE ACTOR PAGE CONTRADICTS ITS OWN INPUT SCHEMA β the page says `resultsType` has no
|
| 882 |
+
`"reels"` value. It does; the response is the evidence ([[measure-the-real-call]]).
|
| 883 |
+
"""
|
| 884 |
+
from automation_engine import Refused, fetch_json # lazy β see the module header
|
| 885 |
+
h = ig_handle(handle) or str(handle or "").strip().lstrip("@")
|
| 886 |
+
if not h:
|
| 887 |
+
return [], "no handle to read reels for"
|
| 888 |
+
key = apify_key()
|
| 889 |
+
if not key:
|
| 890 |
+
return [], "AIOS_APIFY_KEY is not configured β the Apify rung is closed"
|
| 891 |
+
try:
|
| 892 |
+
n = max(1, min(int(limit), MAX_POST_WINDOW))
|
| 893 |
+
except (TypeError, ValueError):
|
| 894 |
+
n = 12
|
| 895 |
+
url = (f"{APIFY_BASE}/acts/{APIFY_ACTOR_POSTS}/run-sync-get-dataset-items"
|
| 896 |
+
f"?token={requests.utils.quote(key)}&timeout={int(APIFY_WAIT)}")
|
| 897 |
+
body = {"directUrls": [f"https://www.instagram.com/{h}/"], "resultsType": "reels",
|
| 898 |
+
"resultsLimit": n, "addParentData": False}
|
| 899 |
+
try:
|
| 900 |
+
status, raw = fetch_json(url, body, timeout=APIFY_WAIT + 60, max_kb=BD_MAX_KB)
|
| 901 |
+
except Refused as e:
|
| 902 |
+
return [], f"Apify's address was refused by the URL rail: {e}"
|
| 903 |
+
except Exception as e: # noqa: BLE001
|
| 904 |
+
# β TYPE NAME ONLY β the token is in the query string of `url`.
|
| 905 |
+
return [], f"Apify did not answer ({type(e).__name__})"
|
| 906 |
+
if status == 402:
|
| 907 |
+
return [], "the Apify account is out of credit"
|
| 908 |
+
if status in (401, 403):
|
| 909 |
+
return [], f"Apify refused our key ({status})"
|
| 910 |
+
if status >= 400:
|
| 911 |
+
return [], f"Apify answered {status}"
|
| 912 |
+
try:
|
| 913 |
+
items = json.loads(raw.decode("utf-8", "replace"))
|
| 914 |
+
except Exception: # noqa: BLE001
|
| 915 |
+
return [], "Apify's answer was not readable JSON"
|
| 916 |
+
if not isinstance(items, list):
|
| 917 |
+
return [], "Apify answered with something other than a list of items"
|
| 918 |
+
rows = [r for r in (providers.normalize_post_apify(x) for x in items) if r]
|
| 919 |
+
if not rows:
|
| 920 |
+
return [], "Apify answered with no reels for this profile"
|
| 921 |
+
return rows, ""
|
| 922 |
+
|
| 923 |
+
|
| 924 |
def apify_profile(handle):
|
| 925 |
"""One handle -> (canonical profile | None, note). A note means it did NOT answer.
|
| 926 |
|
|
|
|
| 1213 |
|
| 1214 |
def pull_profile_bd(url, max_posts=None, post_metrics=False,
|
| 1215 |
comment_metrics=False, log=print, pending_metrics=None,
|
| 1216 |
+
pending_profile=None, prefetch=None, post_groups=None):
|
| 1217 |
"""The paid rung: one profile, EXACT counts, plus the top posts the anonymous surface hides.
|
| 1218 |
|
| 1219 |
Same return contract as `pull_profile` (`{state, profile, posts, via, note}`) so the runner
|
|
|
|
| 1322 |
if got:
|
| 1323 |
posts.append(got)
|
| 1324 |
|
| 1325 |
+
# ββ WAVE 30 Β· T16 β THE WINDOW COMES BEFORE THE PRICE, WHICH IS WHY THIS SITS HERE AND NOT
|
| 1326 |
+
# IN THE ENGINE. `select_post_groups` filters what we already bought, and what we already
|
| 1327 |
+
# bought is the PROFILE's embed β at most 12 posts, MIXED. So "the last 12 reels" has been
|
| 1328 |
+
# landing eight on a creator who posts carousels, forever, with a green run and a summary that
|
| 1329 |
+
# says twelve. The engine's own comment named this and deferred it: *"widening belongs ahead of
|
| 1330 |
+
# that step, in the paid path, and that is its own change"*. This is that change.
|
| 1331 |
+
#
|
| 1332 |
+
# β GATED ON `post_metrics`, DELIBERATELY. Both routes below BUY records. `post_metrics` is
|
| 1333 |
+
# the switch that already means "I am paying per post"; widening without it would spend money
|
| 1334 |
+
# on a run whose config says not to. β And when a group config cannot be honoured for that
|
| 1335 |
+
# reason the run SAYS SO rather than filtering a window it knows is too narrow β a short answer
|
| 1336 |
+
# that reads as a fact about the creator is the failure this note exists to prevent.
|
| 1337 |
+
# β `rows2` IS INITIALISED HERE, not inside the metric block, and that is a defect this
|
| 1338 |
+
# ticket introduced and caught in one run: the block that defines it is now SKIPPED when a
|
| 1339 |
+
# window was bought, so `bd_true_posts_count(rows2 ...)` below raised `UnboundLocalError` on
|
| 1340 |
+
# the new path. A name defined in a branch, read outside it, is a crash waiting for whoever
|
| 1341 |
+
# adds the next branch.
|
| 1342 |
+
rows2 = []
|
| 1343 |
+
views_inline, window_bought, window_note = False, False, ""
|
| 1344 |
+
if post_groups and not post_metrics:
|
| 1345 |
+
window_note = ("post groups were configured but post data is switched off, so they filtered "
|
| 1346 |
+
"the profile's own preview (at most 12 mixed posts) rather than a window "
|
| 1347 |
+
"bought for them")
|
| 1348 |
+
elif post_groups:
|
| 1349 |
+
_reels_n = reels_only_limit(post_groups)
|
| 1350 |
+
if _reels_n and apify_ready():
|
| 1351 |
+
# β ONE CALL: last N reels WITH `videoPlayCount` inline. `views_inline` is what stops
|
| 1352 |
+
# `top_up_views` buying the same numbers a second time, per permalink.
|
| 1353 |
+
wide, wnote = apify_reels(handle, _reels_n)
|
| 1354 |
+
if wide:
|
| 1355 |
+
posts, views_inline, window_bought = wide, True, True
|
| 1356 |
+
else:
|
| 1357 |
+
window_note = f"the reels route did not answer ({_s(wnote, 90)})"
|
| 1358 |
+
else:
|
| 1359 |
+
# β THE FALLBACK IS NOT A DEGRADED COPY β for a reels-only ask it sends Bright Data's
|
| 1360 |
+
# own `post_type: "reel"`, MEASURED at 12/12 where the unfiltered route gives 8. For a
|
| 1361 |
+
# MIXED ask no single-type route exists, so it buys the SUM and lets
|
| 1362 |
+
# `select_post_groups` keep N of each β which is the one path that clause still owns.
|
| 1363 |
+
wide, wnote = bd_profile_posts(handle, group_window(post_groups),
|
| 1364 |
+
post_type=("reel" if _reels_n else ""),
|
| 1365 |
+
deferred=pending_metrics, raw_out=rows2)
|
| 1366 |
+
if wide:
|
| 1367 |
+
posts, window_bought = wide, True
|
| 1368 |
+
else:
|
| 1369 |
+
window_note = f"the post window was not readable ({_s(wnote, 90)})"
|
| 1370 |
+
|
| 1371 |
+
# β `not window_bought` IS THE DOUBLE-SPEND GUARD, and it is the whole reason the routes
|
| 1372 |
+
# above return MAPPED rows. Both of them buy records that already carry likes, comments,
|
| 1373 |
+
# captions and (on the Apify branch) views; re-reading the same permalinks below would pay
|
| 1374 |
+
# twice for numbers already in hand β the exact thing a "top-up" exists to avoid causing.
|
| 1375 |
+
if post_metrics and posts and not window_bought:
|
| 1376 |
time.sleep(min(PACE_SECONDS, 1.0)) # the vendor is paid, but it is still someone's API
|
| 1377 |
# Posts and Reels are different Bright Data endpoints. Sending every permalink to the
|
| 1378 |
# Posts endpoint was why Inayma's five video rows got likes/comments but no views: the
|
|
|
|
| 1380 |
# without duplicating any record, so enabling post metrics still buys one row per post.
|
| 1381 |
ordinary = [p for p in posts if p.get("type") != "video"]
|
| 1382 |
videos = [p for p in posts if p.get("type") == "video"]
|
| 1383 |
+
metric_notes, ordinary_rows = [], []
|
| 1384 |
# `None` keeps direct callers backwards-compatible: they wait for their answer. An
|
| 1385 |
# automation passes one run-level list, which makes a deferred metric batch durable and
|
| 1386 |
# lets this profile finish rather than parking the whole enrichment on it.
|
|
|
|
| 1444 |
elif metric_notes:
|
| 1445 |
note = f"some post metrics were unavailable: {'; '.join(metric_notes)}"
|
| 1446 |
|
| 1447 |
+
# ββ T16 β THE ONE PATH THAT LEGITIMATELY SKIPS THE VIEWS TOP-UP, and it is skipped because
|
| 1448 |
+
# the numbers are already here rather than because nobody wants them. `apify_reels` returns
|
| 1449 |
+
# `videoPlayCount` INLINE on every row; `top_up_views` would then buy the same figure again,
|
| 1450 |
+
# per permalink, from the same vendor. Every other route still tops up β Bright Data's Reels
|
| 1451 |
+
# dataset is MEASURED views-incapable, so a BD window is exactly as short of views as the
|
| 1452 |
+
# profile embed was. β Lifted OUT of the per-permalink block on purpose: leaving it there
|
| 1453 |
+
# would have silently dropped the top-up for a Bright Data WINDOW too, which is the quiet
|
| 1454 |
+
# half-fix this comment exists to refuse.
|
| 1455 |
+
if post_metrics and posts and not views_inline:
|
| 1456 |
note = "; ".join(x for x in (note, top_up_views(posts, log=log)) if x)
|
| 1457 |
+
if window_note:
|
| 1458 |
+
note = "; ".join(x for x in (note, window_note) if x)
|
| 1459 |
|
| 1460 |
# ββ DEBT D-82 (owner ruling R12: *"price, then wire the Posts-dataset call so posts_count
|
| 1461 |
# fills with the true value"*). The Profiles dataset's `posts_count: 0` is discarded as the
|
|
|
|
| 1531 |
"via": via,
|
| 1532 |
"note": note or ("" if posts else "profile read; no posts were returned")}
|
| 1533 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1534 |
|
| 1535 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1536 |
|
| 1537 |
|
| 1538 |
+
|
| 1539 |
+
|
| 1540 |
+
|
| 1541 |
+
|
| 1542 |
+
|
| 1543 |
+
|
| 1544 |
+
|
| 1545 |
+
|
| 1546 |
+
|
| 1547 |
+
|
| 1548 |
+
|
| 1549 |
+
|
| 1550 |
+
|
| 1551 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1552 |
|
| 1553 |
|
| 1554 |
def pull_profile(url, max_posts=None, log=print,
|
| 1555 |
post_metrics=False, comment_metrics=False, pending_metrics=None,
|
| 1556 |
+
pending_profile=None, prefetch=None, post_groups=None):
|
| 1557 |
"""Everything readable about one public profile β EXACT NUMBERS OR NOTHING.
|
| 1558 |
|
| 1559 |
Returns `{state, profile, posts, via, note}` with state β ok | partial | blocked | error.
|
|
|
|
| 1595 |
paid = pull_profile_bd(url, max_posts=max_posts, post_metrics=post_metrics,
|
| 1596 |
comment_metrics=comment_metrics, log=log,
|
| 1597 |
pending_metrics=pending_metrics,
|
| 1598 |
+
pending_profile=pending_profile, prefetch=prefetch,
|
| 1599 |
+
post_groups=post_groups)
|
| 1600 |
if paid["state"] in ("ok", "partial"):
|
| 1601 |
return paid
|
| 1602 |
# β 200, NOT 90. This string is the ONLY account of what the vendor said, and at 90 the
|
api/connectors_tt.py
CHANGED
|
@@ -16,13 +16,15 @@ name below reads through a candidate list it is because the vendor has two names
|
|
| 16 |
public data through a supplier, exactly the rail `connectors_ig.py` states for Instagram. The
|
| 17 |
vendor key is a key to a SUPPLIER.
|
| 18 |
|
| 19 |
-
|
|
|
|
| 20 |
`bd_filter_start` take the dataset id as a PARAMETER β they are Bright Data's wire, not
|
| 21 |
-
Instagram's β
|
| 22 |
truncation guard, the SSRF rail and the snapshot-progress reader, i.e. five places for one bug.
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
| 26 |
|
| 27 |
β **WHAT $0 COULD NOT BUY, so nobody reads this file as more measured than it is:**
|
| 28 |
1. the real ROW shape β `/metadata` describes a DATASET, and Instagram's rows carry undeclared
|
|
@@ -34,17 +36,18 @@ land in the middle of three other tickets' diffs.
|
|
| 34 |
from __future__ import annotations
|
| 35 |
|
| 36 |
import automation_engine as engine
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
_bd_first_url,
|
| 39 |
_bd_flag,
|
| 40 |
_bd_list,
|
| 41 |
_bd_source_payload,
|
| 42 |
_first,
|
| 43 |
_ig_int,
|
| 44 |
-
bd_call,
|
| 45 |
-
bd_filter_start,
|
| 46 |
-
bd_key,
|
| 47 |
-
bd_ready,
|
| 48 |
bd_scrape,
|
| 49 |
)
|
| 50 |
|
|
@@ -291,3 +294,227 @@ TT_NORMALIZERS = {
|
|
| 291 |
"tt_post_metrics": normalize_post,
|
| 292 |
"tt_comments": normalize_comment,
|
| 293 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
public data through a supplier, exactly the rail `connectors_ig.py` states for Instagram. The
|
| 17 |
vendor key is a key to a SUPPLIER.
|
| 18 |
|
| 19 |
+
β **THE TRANSPORT IS `connectors_bd.py` β SHARED, VENDOR-NAMED, AND NO LONGER BORROWED FROM THE
|
| 20 |
+
OTHER PLATFORM'S CONNECTOR** (WAVE 30 Β· T09, DEBT D-128). `bd_call`, `bd_scrape` and
|
| 21 |
`bd_filter_start` take the dataset id as a PARAMETER β they are Bright Data's wire, not
|
| 22 |
+
Instagram's β and re-implementing them here would be a second copy of the deferral handling, the
|
| 23 |
truncation guard, the SSRF rail and the snapshot-progress reader, i.e. five places for one bug.
|
| 24 |
+
Until wave 30 the code was right and the NAME was wrong: this file imported thirteen symbols from
|
| 25 |
+
`connectors_ig`, which read as a dependency on Instagram and was really a dependency on a supplier.
|
| 26 |
+
β **This file now imports ZERO names from `connectors_ig`, and a gate check asserts that**, because
|
| 27 |
+
the sentence above is the kind that quietly stops being true.
|
| 28 |
|
| 29 |
β **WHAT $0 COULD NOT BUY, so nobody reads this file as more measured than it is:**
|
| 30 |
1. the real ROW shape β `/metadata` describes a DATASET, and Instagram's rows carry undeclared
|
|
|
|
| 36 |
from __future__ import annotations
|
| 37 |
|
| 38 |
import automation_engine as engine
|
| 39 |
+
# β T09 β FOUR NAMES CAME OFF THIS LIST AND NOTHING BROKE, which is the point of deriving an
|
| 40 |
+
# import block from the AST both ways. `bd_call`, `bd_filter_start`, `bd_key` and `bd_ready` were
|
| 41 |
+
# imported here under a comment claiming they were *"re-exported for the runners"*; no runner ever
|
| 42 |
+
# read them off this module (the engine imports them from the transport itself), so they were four
|
| 43 |
+
# lines of dependency nobody was paying for. `[[artifact-with-no-importer]]` in its smallest form.
|
| 44 |
+
from connectors_bd import (
|
| 45 |
_bd_first_url,
|
| 46 |
_bd_flag,
|
| 47 |
_bd_list,
|
| 48 |
_bd_source_payload,
|
| 49 |
_first,
|
| 50 |
_ig_int,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
bd_scrape,
|
| 52 |
)
|
| 53 |
|
|
|
|
| 294 |
"tt_post_metrics": normalize_post,
|
| 295 |
"tt_comments": normalize_comment,
|
| 296 |
}
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
# ---------------------------------------------------------------------------------------------
|
| 300 |
+
# THE FETCH β wave 30 Β· W30-T08 (carrying wave-29's dropped T05)
|
| 301 |
+
# ---------------------------------------------------------------------------------------------
|
| 302 |
+
|
| 303 |
+
def tt_handle(url):
|
| 304 |
+
"""A TikTok profile URL **or** a bare handle β the handle. `''` when it is neither.
|
| 305 |
+
|
| 306 |
+
Deliberately permissive about the input and strict about the output, because the two callers
|
| 307 |
+
hand it different things: an automation stores whatever a person typed in the profile column
|
| 308 |
+
(`@nurilab`, `nurilab`, or the full URL), while the discovery runner already holds a clean
|
| 309 |
+
`account_id`. One normaliser, so a row found by discovery and a row typed by hand cannot
|
| 310 |
+
resolve to two different handles.
|
| 311 |
+
"""
|
| 312 |
+
s = str(url or "").strip()
|
| 313 |
+
if not s:
|
| 314 |
+
return ""
|
| 315 |
+
if "tiktok.com" in s.lower():
|
| 316 |
+
# Everything after the first `@`, up to the next path segment or query.
|
| 317 |
+
tail = s.split("@", 1)[1] if "@" in s else ""
|
| 318 |
+
s = tail.split("/")[0].split("?")[0].split("#")[0]
|
| 319 |
+
s = s.strip().lstrip("@").strip()
|
| 320 |
+
# A handle is the vendor's `account_id` shape: alphanumerics, dots and underscores.
|
| 321 |
+
return s if s and all(c.isalnum() or c in "._" for c in s) else ""
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def tt_post_urls(node, limit=0):
|
| 325 |
+
"""β WAVE 30 Β· T10 β the profile row's own post permalinks, newest-first as the vendor sends.
|
| 326 |
+
|
| 327 |
+
β THIS IS WHY TIKTOK POST CAPTURE COSTS NO EXTRA DISCOVERY. The probe measured `top_videos` as
|
| 328 |
+
*"an array of video permalinks, NO empties"* on the PROFILE row we have already bought β so the
|
| 329 |
+
posts read is a scrape of URLs we hold, never a search for them. The two TikTok DISCOVERY
|
| 330 |
+
datasets (posts-by-profile, posts-by-keyword) are **404 for our key**, so a design that reached
|
| 331 |
+
for either would not merely be dearer, it would not work.
|
| 332 |
+
|
| 333 |
+
β `top_posts_data` is deliberately NOT read: the probe calls it *"a thin dup of `top_videos`"*,
|
| 334 |
+
and preferring whichever happened to be longer is how one creator's window silently differs
|
| 335 |
+
from another's.
|
| 336 |
+
β `limit <= 0` means "everything the row carried". The CAP IS THE CALLER'S β `config.maxPosts`,
|
| 337 |
+
validated 1..12 β and it is applied here rather than after the scrape so an unwanted post is
|
| 338 |
+
never bought. [[a-constant-two-features-share]]: the 12 is the vendor's measured profile window,
|
| 339 |
+
not a number this function may invent.
|
| 340 |
+
"""
|
| 341 |
+
raw = (node or {}).get("top_videos")
|
| 342 |
+
out = []
|
| 343 |
+
for item in raw if isinstance(raw, list) else []:
|
| 344 |
+
# The array is documented as permalinks; a vendor that starts sending objects instead must
|
| 345 |
+
# degrade to "no posts", never to a URL built out of a guess.
|
| 346 |
+
u = str(item.get("url") or "").strip() if isinstance(item, dict) else str(item or "").strip()
|
| 347 |
+
if u.lower().startswith("http") and u not in out:
|
| 348 |
+
out.append(u)
|
| 349 |
+
return out[:limit] if limit and limit > 0 else out
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def pull_posts_tt(post_urls, log=print, deferred=None):
|
| 353 |
+
"""The TikTok Posts dataset for a list of permalinks β `(rows, note)`, already normalised.
|
| 354 |
+
|
| 355 |
+
β ONE CALL FOR THE WHOLE WINDOW. `bd_scrape` has always taken a list, and the Instagram side
|
| 356 |
+
measured what happens when a caller forgets: 25 records, one billed snapshot each, a walk still
|
| 357 |
+
running at 67 minutes. Nothing here loops per URL.
|
| 358 |
+
"""
|
| 359 |
+
urls = [str(u) for u in (post_urls or []) if str(u or "").strip()]
|
| 360 |
+
if not urls:
|
| 361 |
+
return [], ""
|
| 362 |
+
rows, note = bd_scrape(TT_DS_POSTS, urls, deferred=deferred)
|
| 363 |
+
if note:
|
| 364 |
+
log(f"[aios-tt] posts: {note}")
|
| 365 |
+
return [], note
|
| 366 |
+
out = [r for r in (normalize_post(n) for n in rows) if r]
|
| 367 |
+
return out, ""
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def pull_comments_tt(post_urls, log=print, deferred=None):
|
| 371 |
+
"""The TikTok Comments dataset for a list of POST permalinks β `(rows, note)`, normalised.
|
| 372 |
+
|
| 373 |
+
β THE MOST EXPENSIVE THING THIS PRODUCT BUYS, and the reason `commentMetrics` defaults OFF on
|
| 374 |
+
both networks: a comments scrape ingests identifiable third parties who never entered anybody's
|
| 375 |
+
list (D-24). The mapper already keeps every commenter field in `source_payload` and promotes
|
| 376 |
+
none of them to a column; this function adds no new exposure, it just has to be asked for.
|
| 377 |
+
"""
|
| 378 |
+
urls = [str(u) for u in (post_urls or []) if str(u or "").strip()]
|
| 379 |
+
if not urls:
|
| 380 |
+
return [], ""
|
| 381 |
+
rows, note = bd_scrape(TT_DS_COMMENTS, urls, deferred=deferred)
|
| 382 |
+
if note:
|
| 383 |
+
log(f"[aios-tt] comments: {note}")
|
| 384 |
+
return [], note
|
| 385 |
+
out = [r for r in (normalize_comment(n) for n in rows) if r]
|
| 386 |
+
return out, ""
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
#: ββ WAVE 30 Β· D-156 β THE MEDIA DATASETS, AS A SET, SO THE HAND-OFF CAN FILTER ON IDENTITY.
|
| 390 |
+
#: `_media_deferrals` uses this to lift ONLY posts/comments snapshots out of the local deferral
|
| 391 |
+
#: list. That is what makes it structurally impossible to file a PROFILE snapshot in the engine's
|
| 392 |
+
#: metric queue β the defect a draft of T10 shipped and A-39 booked as "the wrong fix is worse
|
| 393 |
+
#: than the gap". A membership test cannot be got wrong by a later edit the way `if` order can.
|
| 394 |
+
TT_MEDIA_DATASETS = (TT_DS_POSTS, TT_DS_COMMENTS)
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def _media_deferrals(deferred):
|
| 398 |
+
"""The POSTS/COMMENTS entries of a `bd_scrape` deferral list β never the profile's.
|
| 399 |
+
|
| 400 |
+
β The engine, not this module, decides what a deferral MEANS: it stamps `kind` and the
|
| 401 |
+
handle and files it. TikTok needs no `_tag_metric_deferrals` twin because one dataset is one
|
| 402 |
+
kind here, so the id already carries everything a mapper choice depends on β and importing
|
| 403 |
+
Instagram's tagger is not available anyway (W30-T09 gates ZERO `from connectors_ig` lines).
|
| 404 |
+
"""
|
| 405 |
+
out = []
|
| 406 |
+
for d in deferred or []:
|
| 407 |
+
if isinstance(d, dict) and str(d.get("datasetId") or "") in TT_MEDIA_DATASETS:
|
| 408 |
+
out.append(dict(d))
|
| 409 |
+
return out
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def pull_profile_tt(url, log=print, pending_profile=None, prefetch=None,
|
| 413 |
+
max_posts=0, post_metrics=False, comment_metrics=False):
|
| 414 |
+
"""ONE TikTok profile from the vendor. Same return contract as `pull_profile`.
|
| 415 |
+
|
| 416 |
+
`{state, profile, posts, comments, via, note}` with `state β ok | partial | blocked | error`,
|
| 417 |
+
so the engine's enrich branch treats every network identically and no caller learns a new
|
| 418 |
+
shape.
|
| 419 |
+
|
| 420 |
+
β WAVE 30 Β· T10 β POSTS AND COMMENTS ARE REAL NOW, AND BOTH DEFAULT OFF, exactly as Instagram's
|
| 421 |
+
do. `post_metrics` scrapes the profile row's own `top_videos` permalinks (see `tt_post_urls` β
|
| 422 |
+
no discovery call, because both TikTok discovery datasets 404 for our key); `comment_metrics`
|
| 423 |
+
then scrapes the comments of the posts that came back. β COMMENTS REQUIRE POSTS by construction
|
| 424 |
+
rather than by a rule: their input IS a post permalink, so asking for comments with post capture
|
| 425 |
+
off is a request with no subject, and it returns none instead of quietly buying posts nobody
|
| 426 |
+
asked for.
|
| 427 |
+
|
| 428 |
+
β `partial` IS THE SUCCESS STATE WHENEVER NO MEDIA WAS READ, and that is deliberate rather than
|
| 429 |
+
pessimistic. The Instagram contract reads `ok` only when identity AND media both landed
|
| 430 |
+
(`pull_profile_bd`: *"identity without media is still partial ... a run that wrote a follower
|
| 431 |
+
count and no posts must not paint green over a posts table that did not grow"*). So: posts not
|
| 432 |
+
ASKED for β `partial`, saying so; posts asked for and landed β `ok`; asked for and none came β
|
| 433 |
+
`partial` with the vendor's reason. The state answers "did this pull deliver what it went for",
|
| 434 |
+
never "did the function finish".
|
| 435 |
+
|
| 436 |
+
β **NO FREE RUNG, AND NO FALLBACK CHAIN.** Instagram's `pull_profile` drops to Apify when the
|
| 437 |
+
paid rung refuses; `providers.DEFAULT_CHAINS["tt_profile"]` is deliberately single-provider,
|
| 438 |
+
with its own note explaining that a multi-provider chain is a promise something walks it and
|
| 439 |
+
that nothing walks Instagram's second name today either. So a refusal here is final, and it
|
| 440 |
+
says so instead of implying a retry somewhere.
|
| 441 |
+
"""
|
| 442 |
+
handle = tt_handle(url)
|
| 443 |
+
if not handle:
|
| 444 |
+
return {"state": "error", "profile": {}, "posts": [], "comments": [], "via": "",
|
| 445 |
+
"note": f"{url!r} is not a TikTok profile URL or handle"}
|
| 446 |
+
|
| 447 |
+
# β THE BATCH FAST PATH, same shape as the Instagram side: `prefetch` is `{handle: node}` from
|
| 448 |
+
# one multi-URL scrape covering a whole selection. A hit is a vendor round trip that does not
|
| 449 |
+
# happen; a miss falls through to the single-URL call below.
|
| 450 |
+
cached = prefetch.get(handle) if isinstance(prefetch, dict) else None
|
| 451 |
+
_deferred = []
|
| 452 |
+
if isinstance(cached, dict) and cached:
|
| 453 |
+
rows, note = [cached], ""
|
| 454 |
+
else:
|
| 455 |
+
rows, note = bd_scrape(TT_DS_PROFILES, [tt_profile_url(handle)], deferred=_deferred)
|
| 456 |
+
|
| 457 |
+
node = rows[0] if rows else {}
|
| 458 |
+
profile = normalize_profile(node, handle) if node else {}
|
| 459 |
+
# β THE READABILITY TEST IS `followers`/`following`, NOT "did we get a dict". `normalize_profile`
|
| 460 |
+
# drops blanks, so an unreadable row still returns `{"platform": β¦, "handle": β¦}` β truthy, and
|
| 461 |
+
# carrying nothing anybody asked for. The Instagram rung tests exactly this pair for exactly
|
| 462 |
+
# this reason, and answering "0 followers" instead is the failure it exists to prevent.
|
| 463 |
+
unreadable = profile.get("followers") is None and profile.get("following") is None
|
| 464 |
+
if note or unreadable:
|
| 465 |
+
# β THE DEFERRAL IS HANDED OVER RATHER THAN DISCARDED. A snapshot the vendor is still
|
| 466 |
+
# building HAS ALREADY BEEN PAID FOR; dropping its id bills again on the next run for the
|
| 467 |
+
# same record. That was live on the Instagram profile path until 2026-08-09 β measured on
|
| 468 |
+
# nurilab as two runs, two fresh snapshots, both abandoned β and it is not being
|
| 469 |
+
# reintroduced here by omission.
|
| 470 |
+
if isinstance(pending_profile, list):
|
| 471 |
+
for d in _deferred:
|
| 472 |
+
pending_profile.append({**d, "kind": "profile", "influencer": handle})
|
| 473 |
+
why = note or ("the scrape answered, but no follower/following counts were readable in it "
|
| 474 |
+
"(the field names may have moved - see tiktok-capture.md)")
|
| 475 |
+
return {"state": "blocked", "profile": {}, "posts": [], "comments": [], "via": "brightdata",
|
| 476 |
+
"deferredProfile": [d.get("snapshotId") for d in _deferred],
|
| 477 |
+
"note": why}
|
| 478 |
+
|
| 479 |
+
# --- W30-T10: THE MEDIA, ONLY WHEN IT WAS ASKED FOR. ------------------------------------
|
| 480 |
+
if not post_metrics:
|
| 481 |
+
return {"state": "partial", "profile": profile, "posts": [], "comments": [],
|
| 482 |
+
"via": "brightdata",
|
| 483 |
+
"note": note or "profile read; post capture is off for this step"}
|
| 484 |
+
urls = tt_post_urls(node, limit=max_posts)
|
| 485 |
+
if not urls:
|
| 486 |
+
# β NOT AN ERROR AND NOT A RETRY. A creator with no `top_videos` has nothing to buy, and
|
| 487 |
+
# saying so is what stops the next run paying to be told the same thing.
|
| 488 |
+
return {"state": "partial", "profile": profile, "posts": [], "comments": [],
|
| 489 |
+
"via": "brightdata",
|
| 490 |
+
"note": note or "profile read; this account's row carried no post links"}
|
| 491 |
+
posts, p_note = pull_posts_tt(urls, log=log, deferred=_deferred)
|
| 492 |
+
comments, c_note = ([], "")
|
| 493 |
+
if comment_metrics and posts:
|
| 494 |
+
# The comments dataset is keyed on a POST permalink, so it reads the posts we just bought β
|
| 495 |
+
# `url` from the mapper, never the profile's raw array, so a post the posts scrape refused
|
| 496 |
+
# is not silently asked about again one rung later.
|
| 497 |
+
comments, c_note = pull_comments_tt([p.get("url") for p in posts if p.get("url")],
|
| 498 |
+
log=log, deferred=_deferred)
|
| 499 |
+
# ββ WAVE 30 Β· D-156 β THE MEDIA DEFERRALS ARE HANDED BACK, and the shape of the hand-off is
|
| 500 |
+
# the whole lesson. An earlier draft of T10 appended every `_deferred` entry to
|
| 501 |
+
# `pending_profile` tagged `kind: "profile"`. By the time control reaches here a PROFILE
|
| 502 |
+
# deferral is impossible β the profile branch above returns `blocked` on any note β so **every
|
| 503 |
+
# id fanned out that way was a POSTS or COMMENTS snapshot in the PROFILE queue**, whose
|
| 504 |
+
# collector writes preset profile cells onto somebody's record from post rows. The engine keeps
|
| 505 |
+
# the two queues apart deliberately (`_pending_profile_tasks` vs `_pending_metric_tasks`).
|
| 506 |
+
# β So this returns them under their OWN key, filtered by dataset identity
|
| 507 |
+
# (`_media_deferrals`), and the engine files them in the metric queue with the handle it
|
| 508 |
+
# already holds. Returning rather than appending also keeps the queue's vocabulary out of a
|
| 509 |
+
# connector: this module knows which CORPUS deferred, never what the engine calls it.
|
| 510 |
+
# β `deferredMedia` rides BOTH returns on purpose. The empty-posts case is the one that
|
| 511 |
+
# matters most β that is exactly the run where the vendor took too long, so a caller reading
|
| 512 |
+
# the ids only from the success path would lose every batch it actually paid for.
|
| 513 |
+
deferred_media = _media_deferrals(_deferred)
|
| 514 |
+
if not posts:
|
| 515 |
+
return {"state": "partial", "profile": profile, "posts": [], "comments": [],
|
| 516 |
+
"via": "brightdata", "deferredMedia": deferred_media,
|
| 517 |
+
"note": p_note or note or "profile read; the post source returned nothing"}
|
| 518 |
+
return {"state": "ok", "profile": profile, "posts": posts, "comments": comments,
|
| 519 |
+
"via": "brightdata", "deferredMedia": deferred_media,
|
| 520 |
+
"note": c_note or note or ""}
|
api/main.py
CHANGED
|
@@ -534,6 +534,15 @@ def _prewarm():
|
|
| 534 |
routes_customers.warm_default(rt)
|
| 535 |
import pages as _pages
|
| 536 |
_pages.warm_default(rt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 537 |
print(f"[aios-api] prewarm done in {_t.time() - t0:.1f}s")
|
| 538 |
except Exception as e: # noqa: BLE001 β boot must not die on a warm-up
|
| 539 |
print(f"[aios-api] prewarm skipped: {e}")
|
|
|
|
| 534 |
routes_customers.warm_default(rt)
|
| 535 |
import pages as _pages
|
| 536 |
_pages.warm_default(rt)
|
| 537 |
+
# ββ WAVE 30 Β· T12, THE COLD PATH (owner items 4/5). Automation was the ONE module absent
|
| 538 |
+
# from this list, so its memo was always filled by a visitor rather than by boot: call 1 of
|
| 539 |
+
# `GET /automations` after every deploy downloaded the whole `user_tables` document (35.8 MB
|
| 540 |
+
# ceiling, under the store lock) onto whoever clicked first. Memoising the WARM path β two
|
| 541 |
+
# waves of it β could not touch that, because the cold call is the one that fills the memo.
|
| 542 |
+
# β It elects a STRING and keeps no document; see `warm_default`'s own note on why caching
|
| 543 |
+
# the bucket would trade a latency for memory this tier does not have.
|
| 544 |
+
import routes_automation as _rauto
|
| 545 |
+
_rauto.warm_default(rt)
|
| 546 |
print(f"[aios-api] prewarm done in {_t.time() - t0:.1f}s")
|
| 547 |
except Exception as e: # noqa: BLE001 β boot must not die on a warm-up
|
| 548 |
print(f"[aios-api] prewarm skipped: {e}")
|
api/odoo_relational.py
CHANGED
|
@@ -57,15 +57,33 @@ PRODUCTS_KEY = "ut_odoo_products"
|
|
| 57 |
#: β WAVE 28 (owner R1): *"ALL of Unique ID in Odoo is a database e.g. Customers/Products/Agents,
|
| 58 |
#: etc. Including expenses and GL codes."* Four more DOCUMENT/REGISTRY grains, each measured to
|
| 59 |
#: fit far inside `MAX_ROWS` (19 / 192 / 6,538 / 393 against 60,000).
|
| 60 |
-
#: β THE LINE GRAINS ARE DELIBERATELY NOT HERE and this is R2, not an omission: 256,810 order
|
| 61 |
-
#: lines, 154,917 expense GL lines and 311,140 commission lines cannot live in a `ut_*` document
|
| 62 |
-
#: at any cap (63.9 MB / 2.57 s per copy for the order lines alone). "Expenses" as a BROWSABLE
|
| 63 |
-
#: LEDGER is a read-through mirror grid; "expenses" as a NUMBER is a rollup on the GL account row.
|
| 64 |
AGENTS_KEY = "ut_odoo_agents"
|
| 65 |
ACCOUNTS_KEY = "ut_odoo_accounts"
|
| 66 |
BILLS_KEY = "ut_odoo_bills"
|
| 67 |
VENDORS_KEY = "ut_odoo_vendors"
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
#: The join column the partner-grain tables carry. Derived links resolve through it (`on`/`from`).
|
| 70 |
JOIN_KEY = "partner_id"
|
| 71 |
#: The product-grain equivalent.
|
|
@@ -352,6 +370,150 @@ def order_fields():
|
|
| 352 |
)]
|
| 353 |
|
| 354 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
def product_fields():
|
| 356 |
"""One row per `product.product`, keyed on its id β EVERY product, archived ones included.
|
| 357 |
|
|
@@ -967,8 +1129,69 @@ TABLES = (
|
|
| 967 |
("accounts", ACCOUNTS_KEY, "Odoo GL accounts", account_fields),
|
| 968 |
("vendors", VENDORS_KEY, "Odoo vendors", vendor_fields),
|
| 969 |
("bills", BILLS_KEY, "Odoo vendor bills", bill_fields),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 970 |
)
|
| 971 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 972 |
|
| 973 |
def plan(cur, rt=None):
|
| 974 |
"""The rows that WOULD be written, plus the refusals that apply β no store WRITE at all.
|
|
@@ -976,6 +1199,11 @@ def plan(cur, rt=None):
|
|
| 976 |
Separated from `apply_plan` so a route, a gate and a dry run all measure the same thing, and
|
| 977 |
so **every cap is checked before anything is committed**.
|
| 978 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 979 |
β `rt` IS WHAT MAKES THE TABLE-COUNT CHECK HONEST, and leaving it out was a real half-spawn
|
| 980 |
bug. This spawn writes FOUR tables in four updater passes; a tenant near `MAX_TABLES` would
|
| 981 |
create some and refuse the rest β leaving a locked invoices database with no rollup host,
|
|
@@ -984,25 +1212,78 @@ def plan(cur, rt=None):
|
|
| 984 |
write. `rt` also carries the stored row counts the shrink guard compares against.
|
| 985 |
"""
|
| 986 |
ut = _ut()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 987 |
excluded = excluded_ids(cur)
|
| 988 |
-
|
| 989 |
-
|
| 990 |
-
|
| 991 |
-
|
| 992 |
-
|
| 993 |
-
|
| 994 |
-
|
| 995 |
-
|
| 996 |
-
|
| 997 |
-
|
| 998 |
-
|
| 999 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1000 |
existing = {}
|
| 1001 |
if rt is not None:
|
| 1002 |
try:
|
| 1003 |
existing = dict(rt.get(ut.STORE_KEY) or {})
|
| 1004 |
except Exception: # noqa: BLE001
|
| 1005 |
existing = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1006 |
needed = [k for _b, k, _l, _f in TABLES if k not in existing]
|
| 1007 |
if needed and len(existing) + len(needed) > ut.MAX_TABLES:
|
| 1008 |
problems.append(
|
|
@@ -1012,11 +1293,32 @@ def plan(cur, rt=None):
|
|
| 1012 |
|
| 1013 |
for bucket, key, _label, _fields in TABLES:
|
| 1014 |
rows = built[bucket]
|
| 1015 |
-
|
| 1016 |
-
#
|
| 1017 |
-
|
| 1018 |
-
|
| 1019 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1020 |
stored = len(((existing.get(key) or {}).get("rows")) or {})
|
| 1021 |
if stored and len(rows) < stored * MAX_SHRINK:
|
| 1022 |
problems.append(
|
|
@@ -1024,6 +1326,11 @@ def plan(cur, rt=None):
|
|
| 1024 |
f"more than {int((1 - MAX_SHRINK) * 100)}% is a bad read, not Odoo history "
|
| 1025 |
f"shrinking; refusing rather than deleting rows that still exist")
|
| 1026 |
built["problems"] = problems
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1027 |
return built
|
| 1028 |
|
| 1029 |
|
|
@@ -1107,6 +1414,13 @@ def _ensure_table_inplace(cur, key, label, fields, rows, username, stamp):
|
|
| 1107 |
"recordMode": ut.AUTOMATION_RECORD_MODE,
|
| 1108 |
}
|
| 1109 |
table.setdefault("recordMode", ut.AUTOMATION_RECORD_MODE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1110 |
have = {str(f.get("key")): f for f in (table.get("fields") or [])}
|
| 1111 |
for field in fields:
|
| 1112 |
fkey = str(field.get("key"))
|
|
|
|
| 57 |
#: β WAVE 28 (owner R1): *"ALL of Unique ID in Odoo is a database e.g. Customers/Products/Agents,
|
| 58 |
#: etc. Including expenses and GL codes."* Four more DOCUMENT/REGISTRY grains, each measured to
|
| 59 |
#: fit far inside `MAX_ROWS` (19 / 192 / 6,538 / 393 against 60,000).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
AGENTS_KEY = "ut_odoo_agents"
|
| 61 |
ACCOUNTS_KEY = "ut_odoo_accounts"
|
| 62 |
BILLS_KEY = "ut_odoo_bills"
|
| 63 |
VENDORS_KEY = "ut_odoo_vendors"
|
| 64 |
|
| 65 |
+
#: ββ WAVE 30 / R7 / W30-T35 β THE TWO LINE GRAINS, AND THEY ARRIVE THE ONLY WAY THEY EVER COULD.
|
| 66 |
+
#:
|
| 67 |
+
#: β THE PARAGRAPH THAT STOOD HERE SAID THESE WERE "DELIBERATELY NOT HERE β¦ at any cap", and it
|
| 68 |
+
#: was RIGHT ABOUT THE CAP AND WRONG ABOUT THE CONCLUSION β which is exactly why it is replaced
|
| 69 |
+
#: rather than left standing beside its own contradiction. The obstacle was never the number of
|
| 70 |
+
#: rows; it was that every row had to be COPIED into the shared `user_tables` document. MEASURED
|
| 71 |
+
#: on this box's mirror 2026-08-12: 254,189 order lines in the confirmed scope (256,810 unscoped)
|
| 72 |
+
#: and 963,783 GL lines β 4.2x and 16x `MAX_ROWS`, 63.9 MB and ~240 MB as JSON. Owner ruling R6
|
| 73 |
+
#: settles what that means: *"there is no cap in how many data from the API source β¦ can be pulled
|
| 74 |
+
#: into the app"*, so the answer is a different residency, never a bigger ceiling.
|
| 75 |
+
#:
|
| 76 |
+
#: β THESE TWO TABLES STORE NO ROWS HERE AND NEVER WILL. `routes_odoo_tables` binds them to the
|
| 77 |
+
#: DuckDB mirror (`GRID_SOURCES`) and `core.user_tables.row_limit` answers **0** for them β "this
|
| 78 |
+
#: database stores no rows HERE", which is a different statement from `None` ("connected and
|
| 79 |
+
#: uncapped") and from `MAX_ROWS` ("the editable substrate"). `plan()` below reads that evaluator
|
| 80 |
+
#: and builds no python row for either grain: 963,783 dicts in one process is the dangerous work
|
| 81 |
+
#: the answer exists to prevent. What DOES get written is the DEFINITION β a locked database with
|
| 82 |
+
#: fields, a label, grants and a nav entry, and zero rows. A definition with no rows is a working
|
| 83 |
+
#: grid; that is the whole shape of the conversion.
|
| 84 |
+
ORDER_LINES_KEY = "ut_odoo_order_lines"
|
| 85 |
+
GL_LINES_KEY = "ut_odoo_gl_lines"
|
| 86 |
+
|
| 87 |
#: The join column the partner-grain tables carry. Derived links resolve through it (`on`/`from`).
|
| 88 |
JOIN_KEY = "partner_id"
|
| 89 |
#: The product-grain equivalent.
|
|
|
|
| 370 |
)]
|
| 371 |
|
| 372 |
|
| 373 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 374 |
+
# THE TWO READ-THROUGH GRAINS (W30-T35). Their rows are SERVED FROM THE MIRROR, never stored.
|
| 375 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 376 |
+
# ββ THE KEY SET IS HALF OF A CONTRACT AND `routes_odoo_tables.GRID_SOURCES[β¦]["cols"]` IS THE
|
| 377 |
+
# OTHER HALF. It binds a key to SQL; the label, type and order are declared HERE, once. The two
|
| 378 |
+
# lists must name the SAME columns, and both failure directions are silent:
|
| 379 |
+
# * a bound key with no declaration -> a silent NO-CELL (the row projection is strict);
|
| 380 |
+
# * a declared key with no binding -> an INACTIVE filter leaf, which WIDENS the result set.
|
| 381 |
+
# `verify_scopes.section_line_grids` compares the two sets, which is why neither side may "just
|
| 382 |
+
# add a column".
|
| 383 |
+
#
|
| 384 |
+
# β NO LINK COLUMN AND NO `refreshed` STAMP ON EITHER TABLE, and both absences are findings
|
| 385 |
+
# rather than omissions:
|
| 386 |
+
# * a LINK folds the rows the TARGET table STORES (`compute_relation_cells` reads the raw
|
| 387 |
+
# document, not the mirror), so a link at a read-through grain resolves against nothing and
|
| 388 |
+
# renders a permanently blank column β the trap `account_fields` names from the other end.
|
| 389 |
+
# The join ids ride as ordinary filterable columns instead, so the relationships are all
|
| 390 |
+
# still reachable by a person and by SQL.
|
| 391 |
+
# * `refreshed` means "when this row was last reconciled against Odoo", and it is stamped by
|
| 392 |
+
# `_ensure_table_inplace` onto rows it WRITES. Nothing here is ever written, so the column
|
| 393 |
+
# would be blank for every row forever. `section_line_grids` tolerates the key; the honest
|
| 394 |
+
# thing is not to declare it.
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def order_line_fields():
|
| 398 |
+
"""One row per `sale.order.line` on a CONFIRMED order β the same `state in (sale, done)`
|
| 399 |
+
scope every wholesale metric in this product uses.
|
| 400 |
+
|
| 401 |
+
MEASURED on the mirror 2026-08-12: **254,189 lines in scope** of 256,810 (the 2,621 excluded
|
| 402 |
+
sit on draft/sent/cancelled orders). Every line in scope is on a `sale` order β zero `done` β
|
| 403 |
+
but `done` stays in the option list because it is in the SCOPE, and a filter offering only
|
| 404 |
+
what happens to be stored today goes stale the first time an order is marked done.
|
| 405 |
+
|
| 406 |
+
β THE SCOPE, THE ORDER DATE AND THE ORDER NAME ALL LIVE ACROSS A JOIN. `sale_order_line`
|
| 407 |
+
carries no `state` at all (12 columns, measured), so the binding is a join to `sale_order` β
|
| 408 |
+
which is also what makes `order_no` a readable primary cell instead of a line id.
|
| 409 |
+
|
| 410 |
+
β `qty` IS `int`, NOT `currency`, AND THAT IS A MEASUREMENT. 3,888 of 256,810 lines carry a
|
| 411 |
+
FRACTIONAL quantity (0.2, 0.4, 0.5, 1.66 β¦) and the minimum is -1.0, so the question "does the
|
| 412 |
+
type truncate?" had to be answered rather than assumed: it does not. `int` and `currency` both
|
| 413 |
+
render through the client's `numberText`, which rounds nothing without a `format.decimals`
|
| 414 |
+
bag β the only difference is the `$` a `currency` column prepends. A quantity is not money, so
|
| 415 |
+
it takes the type that does not paint one.
|
| 416 |
+
"""
|
| 417 |
+
return [_preset(f) for f in (
|
| 418 |
+
{"key": "order_no", "label": "Order", "type": "text", "source": "overlay",
|
| 419 |
+
"default": True, "pinned": True,
|
| 420 |
+
"description": "The sale order this line belongs to. Zero orders have a blank name, "
|
| 421 |
+
"which is why it is the primary cell rather than the line id."},
|
| 422 |
+
{"key": "odoo_id", "label": "Odoo ID", "type": "int", "source": "overlay",
|
| 423 |
+
"default": False, "description": "The `sale.order.line` id. Also this row's id."},
|
| 424 |
+
{"key": "order_id", "label": "Odoo order id", "type": "int", "source": "overlay",
|
| 425 |
+
"default": False,
|
| 426 |
+
"description": "The `sale.order` id β the key `ut_odoo_orders` is keyed on."},
|
| 427 |
+
{"key": "customer", "label": "Customer", "type": "text", "source": "overlay",
|
| 428 |
+
"default": True},
|
| 429 |
+
{"key": JOIN_KEY, "label": "Odoo partner id", "type": "int", "source": "overlay",
|
| 430 |
+
"default": False},
|
| 431 |
+
{"key": "product", "label": "Product", "type": "text", "source": "overlay",
|
| 432 |
+
"default": True,
|
| 433 |
+
"description": "Blank on the 101 section and note lines, which carry no product."},
|
| 434 |
+
{"key": PRODUCT_JOIN_KEY, "label": "Odoo product id", "type": "int", "source": "overlay",
|
| 435 |
+
"default": False},
|
| 436 |
+
{"key": "qty", "label": "Qty", "type": "int", "source": "overlay", "default": True,
|
| 437 |
+
"agg": "sum",
|
| 438 |
+
"description": "Ordered quantity. 3,888 lines carry a fraction and some are negative "
|
| 439 |
+
"(returns), so nothing here is rounded."},
|
| 440 |
+
{"key": "price_subtotal", "label": "Line $", "type": "currency", "source": "overlay",
|
| 441 |
+
"default": True, "agg": "sum"},
|
| 442 |
+
{"key": "margin", "label": "Margin $", "type": "currency", "source": "overlay",
|
| 443 |
+
"default": False, "agg": "sum",
|
| 444 |
+
"description": "Odoo's own line margin. Populated on every line."},
|
| 445 |
+
{"key": "purchase_price", "label": "Unit cost", "type": "currency", "source": "overlay",
|
| 446 |
+
"default": False,
|
| 447 |
+
"description": "The cost Odoo priced this line's margin against, per unit."},
|
| 448 |
+
{"key": "order_date", "label": "Order date", "type": "date", "source": "overlay",
|
| 449 |
+
"default": True},
|
| 450 |
+
{"key": "state", "label": "State", "type": "select", "source": "overlay",
|
| 451 |
+
"default": False, "options": ["sale", "done"]},
|
| 452 |
+
_scope_field(),
|
| 453 |
+
)]
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def gl_line_fields():
|
| 457 |
+
"""One row per `account.move.line` β the general ledger, and the owner's "expenses" at the
|
| 458 |
+
grain a person can actually browse.
|
| 459 |
+
|
| 460 |
+
MEASURED 2026-08-12: **963,783 lines**, of which 944,846 posted, 18,885 cancelled and 52
|
| 461 |
+
draft. β UNSCOPED ON PURPOSE β a general ledger whose draft and cancelled entries are
|
| 462 |
+
invisible is a ledger that cannot be reconciled, so `parent_state` rides as a COLUMN and the
|
| 463 |
+
reader chooses. That is the same decision the binding states from the SQL side.
|
| 464 |
+
|
| 465 |
+
β TWO COLUMNS ARE LEGITIMATELY BLANK ON REAL ROWS, named here so neither reads as a defect:
|
| 466 |
+
**61,911 lines carry no partner** (journal entries that are not about a customer), and
|
| 467 |
+
**1,727 carry no account** at all, which is also why `account_code` β the key
|
| 468 |
+
`ut_odoo_accounts` is keyed on β is blank on exactly those 1,727 and the join that supplies
|
| 469 |
+
it is a LEFT one.
|
| 470 |
+
|
| 471 |
+
β `move_type` IS `text`, NOT `select`, and it is the `product_fields.category` argument:
|
| 472 |
+
five values exist today (`out_invoice` 515,634 Β· `entry` 414,992 Β· `in_invoice` 18,901 Β·
|
| 473 |
+
`out_refund` 14,061 Β· `in_refund` 195) and Odoo's enum is longer than what we happen to hold.
|
| 474 |
+
A select whose options go stale answers a filter with a list that cannot match a stored value
|
| 475 |
+
(wave-26 item 24). `line_type` and `parent_state` ARE selects because their option lists were
|
| 476 |
+
measured COMPLETE against the whole table.
|
| 477 |
+
"""
|
| 478 |
+
return [_preset(f) for f in (
|
| 479 |
+
{"key": "entry", "label": "Entry", "type": "text", "source": "overlay",
|
| 480 |
+
"default": True, "pinned": True,
|
| 481 |
+
"description": "The journal entry this line belongs to. Never blank."},
|
| 482 |
+
{"key": "odoo_id", "label": "Odoo ID", "type": "int", "source": "overlay",
|
| 483 |
+
"default": False, "description": "The `account.move.line` id. Also this row's id."},
|
| 484 |
+
{"key": "move_id", "label": "Odoo entry id", "type": "int", "source": "overlay",
|
| 485 |
+
"default": False},
|
| 486 |
+
{"key": "account", "label": "Account", "type": "text", "source": "overlay",
|
| 487 |
+
"default": True},
|
| 488 |
+
{"key": ACCOUNT_JOIN_KEY, "label": "Account code", "type": "text", "source": "overlay",
|
| 489 |
+
"default": True,
|
| 490 |
+
"description": "The GL code, from the joined chart of accounts β the key "
|
| 491 |
+
"`ut_odoo_accounts` is keyed on. Blank on the 1,727 lines with no "
|
| 492 |
+
"account."},
|
| 493 |
+
{"key": "customer", "label": "Partner", "type": "text", "source": "overlay",
|
| 494 |
+
"default": True,
|
| 495 |
+
"description": "Blank on the 61,911 lines that are not about a partner."},
|
| 496 |
+
{"key": JOIN_KEY, "label": "Odoo partner id", "type": "int", "source": "overlay",
|
| 497 |
+
"default": False},
|
| 498 |
+
{"key": "date", "label": "Date", "type": "date", "source": "overlay", "default": True},
|
| 499 |
+
{"key": "debit", "label": "Debit", "type": "currency", "source": "overlay",
|
| 500 |
+
"default": True, "agg": "sum"},
|
| 501 |
+
{"key": "credit", "label": "Credit", "type": "currency", "source": "overlay",
|
| 502 |
+
"default": True, "agg": "sum"},
|
| 503 |
+
{"key": "balance", "label": "Balance", "type": "currency", "source": "overlay",
|
| 504 |
+
"default": True, "agg": "sum",
|
| 505 |
+
"description": "Debit minus credit, as Odoo stores it. Sums to zero over a whole entry."},
|
| 506 |
+
{"key": "line_type", "label": "Line type", "type": "select", "source": "overlay",
|
| 507 |
+
"default": False,
|
| 508 |
+
"options": ["product", "cogs", "payment_term", "line_note", "line_section"]},
|
| 509 |
+
{"key": "move_type", "label": "Document type", "type": "text", "source": "overlay",
|
| 510 |
+
"default": False},
|
| 511 |
+
{"key": "parent_state", "label": "Entry state", "type": "select", "source": "overlay",
|
| 512 |
+
"default": True, "options": ["draft", "posted", "cancel"]},
|
| 513 |
+
_scope_field(),
|
| 514 |
+
)]
|
| 515 |
+
|
| 516 |
+
|
| 517 |
def product_fields():
|
| 518 |
"""One row per `product.product`, keyed on its id β EVERY product, archived ones included.
|
| 519 |
|
|
|
|
| 1129 |
("accounts", ACCOUNTS_KEY, "Odoo GL accounts", account_fields),
|
| 1130 |
("vendors", VENDORS_KEY, "Odoo vendors", vendor_fields),
|
| 1131 |
("bills", BILLS_KEY, "Odoo vendor bills", bill_fields),
|
| 1132 |
+
# ββ W30-T35 / R7 β the two READ-THROUGH grains. They are spec rows like any other, and that
|
| 1133 |
+
# is the point: `apply_plan` creates their DEFINITION (label, fields, lock, nav entry, grants)
|
| 1134 |
+
# exactly as it does for the eight above, and `plan` hands them ZERO rows. Leaving them out of
|
| 1135 |
+
# this tuple was the alternative and it is the wrong one β the route 404s on a key `TABLES`
|
| 1136 |
+
# does not name, so the grids would be bound to the mirror and unreachable, which is this
|
| 1137 |
+
# wave's own [[reachable-is-not-the-same-as-built]] shape.
|
| 1138 |
+
("order_lines", ORDER_LINES_KEY, "Odoo order lines", order_line_fields),
|
| 1139 |
+
("gl_lines", GL_LINES_KEY, "Odoo GL lines", gl_line_fields),
|
| 1140 |
)
|
| 1141 |
|
| 1142 |
+
#: The bucket -> reader map. β ITS ABSENCES ARE LOAD-BEARING: a bucket with no reader has no
|
| 1143 |
+
#: python row builder ANYWHERE, which is what makes "never materialised" structural rather than a
|
| 1144 |
+
#: policy `plan()` could forget. The two line grains are absent for that reason and no other.
|
| 1145 |
+
_READERS = {
|
| 1146 |
+
"customers": lambda cur, excluded: read_customers(cur, excluded=excluded),
|
| 1147 |
+
"products": lambda cur, excluded: read_products(cur),
|
| 1148 |
+
"invoices": lambda cur, excluded: read_invoices(cur, excluded=excluded),
|
| 1149 |
+
"orders": lambda cur, excluded: read_orders(cur, excluded=excluded),
|
| 1150 |
+
"agents": lambda cur, excluded: read_agents(cur),
|
| 1151 |
+
"accounts": lambda cur, excluded: read_accounts(cur),
|
| 1152 |
+
"vendors": lambda cur, excluded: read_vendors(cur),
|
| 1153 |
+
"bills": lambda cur, excluded: read_bills(cur),
|
| 1154 |
+
}
|
| 1155 |
+
|
| 1156 |
+
#: The table keys this module can never materialise β DERIVED from the absence of a reader, never
|
| 1157 |
+
#: typed out, so it cannot drift from the fact it describes.
|
| 1158 |
+
#:
|
| 1159 |
+
#: ββ IT IS STAMPED ONTO THE DEFINITION AT SPAWN, AND THAT IS NOT BELT-AND-BRACES β IT IS THE
|
| 1160 |
+
#: ONLY WAY THESE TWO TABLES EVER GET THE DURABLE FLAG. `core.user_tables.materialises` reads a
|
| 1161 |
+
#: process-global registry first and falls back to a stored `readThrough` stamp, "which is what a
|
| 1162 |
+
#: cold process reads" β but the only writer of that stamp is `strip_materialised`, and it stamps
|
| 1163 |
+
#: exclusively tables it found rows on (`if isinstance(t, dict) and t.get('rows')`, after an early
|
| 1164 |
+
#: return when nothing is fat). A table that was BORN read-through has no rows to strip, so it is
|
| 1165 |
+
#: never stamped, so a process that cannot reach the mirror reads `rows: {}` and calls that the
|
| 1166 |
+
#: answer β an EMPTY GRID with nothing going red, which is the exact failure that docstring names.
|
| 1167 |
+
#: The conversion writes the stamp; a table that needs no conversion still needs the statement.
|
| 1168 |
+
READ_THROUGH_KEYS = frozenset(key for bucket, key, _l, _f in TABLES if bucket not in _READERS)
|
| 1169 |
+
|
| 1170 |
+
|
| 1171 |
+
class _LentDoc:
|
| 1172 |
+
"""A store handle that serves the ONE `user_tables` document `plan()` has ALREADY read.
|
| 1173 |
+
|
| 1174 |
+
ββ THIS IS NOT A MICRO-OPTIMISATION AND IT IS NOT OPTIONAL. `core.user_tables.row_limit`
|
| 1175 |
+
resolves through `materialises` β `get` β `all_tables(st)`, and every one of those is a WHOLE
|
| 1176 |
+
20 MB document read, deep-copied under `Store._lock`. `plan()` asks the evaluator once per
|
| 1177 |
+
table per loop, so passing the live handle would have added ~16 full document copies to a
|
| 1178 |
+
function that already reads it exactly once β and with `st=None` (the gate's fixture posture,
|
| 1179 |
+
and any dry run) those reads resolve to the MODULE-GLOBAL store, i.e. a Hugging Face dataset
|
| 1180 |
+
fetch per table, on a path that has no business touching the network at all.
|
| 1181 |
+
`materialises`' own docstring asks callers to lend the definition they are holding; `row_limit`
|
| 1182 |
+
takes `st` rather than `defn`, so the lending happens one level up, here.
|
| 1183 |
+
|
| 1184 |
+
β It answers ONLY the user-tables document and `None` for anything else, deliberately: a shim
|
| 1185 |
+
that quietly proxied other keys would be a second store with a partial view, which is worse
|
| 1186 |
+
than one that says what it knows.
|
| 1187 |
+
"""
|
| 1188 |
+
|
| 1189 |
+
def __init__(self, doc, key):
|
| 1190 |
+
self._doc, self._key = doc if isinstance(doc, dict) else {}, key
|
| 1191 |
+
|
| 1192 |
+
def get(self, name):
|
| 1193 |
+
return self._doc if name == self._key else None
|
| 1194 |
+
|
| 1195 |
|
| 1196 |
def plan(cur, rt=None):
|
| 1197 |
"""The rows that WOULD be written, plus the refusals that apply β no store WRITE at all.
|
|
|
|
| 1199 |
Separated from `apply_plan` so a route, a gate and a dry run all measure the same thing, and
|
| 1200 |
so **every cap is checked before anything is committed**.
|
| 1201 |
|
| 1202 |
+
Returns one key per bucket plus two that are not buckets: `problems` (refusals β a non-empty
|
| 1203 |
+
list makes `apply_plan` raise before it writes anything) and, since W30-T35, **`limits`** β
|
| 1204 |
+
`{table_key: limit_report}` for every table this plan did not fully materialise, which is R6's
|
| 1205 |
+
second sentence carried as data rather than left for a reader to infer from an empty list.
|
| 1206 |
+
|
| 1207 |
β `rt` IS WHAT MAKES THE TABLE-COUNT CHECK HONEST, and leaving it out was a real half-spawn
|
| 1208 |
bug. This spawn writes FOUR tables in four updater passes; a tenant near `MAX_TABLES` would
|
| 1209 |
create some and refuse the rest β leaving a locked invoices database with no rollup host,
|
|
|
|
| 1212 |
write. `rt` also carries the stored row counts the shrink guard compares against.
|
| 1213 |
"""
|
| 1214 |
ut = _ut()
|
| 1215 |
+
# β R6, AND WITHOUT THIS LINE THE RULE IS ONLY ACCIDENTALLY TRUE. `is_connected` answers from
|
| 1216 |
+
# three places in falling authority: the registry, a stored `connected: True`, then the
|
| 1217 |
+
# `ut_odoo_` naming convention β and that last leg needs the table to ALREADY EXIST. So on a
|
| 1218 |
+
# FIRST spawn, in a process that has not yet built `routes_odoo_tables.GRID_SOURCES`, every one
|
| 1219 |
+
# of these tables reads as unconnected and earns `MAX_ROWS`: R6's cap removal would silently
|
| 1220 |
+
# not apply on exactly the run that creates the databases. This module DECLARES these keys, so
|
| 1221 |
+
# it is the honest place to say what they are. Idempotent (a set add), and it fills the
|
| 1222 |
+
# evaluator's input rather than becoming a second evaluator.
|
| 1223 |
+
ut.register_connected(*[key for _b, key, _l, _f in TABLES])
|
| 1224 |
excluded = excluded_ids(cur)
|
| 1225 |
+
# ββ W30-T35 / R6 / R7 β WHICH BUCKETS ARE BUILT AT ALL IS NOW ASKED, NOT ASSUMED, and it is
|
| 1226 |
+
# `core.user_tables.row_limit` that answers: 0 = "stores no rows HERE" (read-through), None =
|
| 1227 |
+
# "connected and uncapped", MAX_ROWS = "the editable substrate". Its own docstring names this
|
| 1228 |
+
# function as the caller that reads it, which is the seam working as designed β one evaluator,
|
| 1229 |
+
# so the spawn, the write doors and the wire cannot disagree about whether a table is capped
|
| 1230 |
+
# ([[one-evaluator-per-question]]).
|
| 1231 |
+
#
|
| 1232 |
+
# β TWO DIFFERENT REASONS NOT TO BUILD, AND THEY ARE KEPT SEPARATE ON PURPOSE:
|
| 1233 |
+
# * no reader at all β structural, permanent, and the case that must not depend on a store
|
| 1234 |
+
# read succeeding (a cold process with no mirror still must not try to build 963,783 rows);
|
| 1235 |
+
# * a reader exists but the table has already been converted to read-through β the
|
| 1236 |
+
# `ut_odoo_accounts` case. Building 192 rows and letting `strip_materialised` delete them
|
| 1237 |
+
# again on the next pass "works", and it is exactly the wasted, dangerous work `row_limit`
|
| 1238 |
+
# was built to prevent. It also stops a refresh from silently RE-MATERIALISING a table
|
| 1239 |
+
# D-87's conversion had already emptied.
|
| 1240 |
+
#
|
| 1241 |
+
# β THE DOCUMENT IS READ **ONCE**, HERE, AND LENT TO THE EVALUATOR β see `_LentDoc`. It used
|
| 1242 |
+
# to be read after the build loop; it moved up because `row_limit` needs it and reading it per
|
| 1243 |
+
# table per loop is ~16 more whole-document deep copies (or, with `st=None`, a Hugging Face
|
| 1244 |
+
# fetch per table on a path that must never touch the network).
|
| 1245 |
existing = {}
|
| 1246 |
if rt is not None:
|
| 1247 |
try:
|
| 1248 |
existing = dict(rt.get(ut.STORE_KEY) or {})
|
| 1249 |
except Exception: # noqa: BLE001
|
| 1250 |
existing = {}
|
| 1251 |
+
# β THE LENT DOCUMENT CARRIES THE `readThrough` STAMP THIS MODULE IS RESPONSIBLE FOR, and
|
| 1252 |
+
# without it R6's report is silently absent on the run that matters most β the FIRST spawn.
|
| 1253 |
+
# MEASURED: with an empty store, `row_limit` finds no registry entry and no stored stamp, so
|
| 1254 |
+
# it answers `None` ("connected and uncapped") for a grain that stores nothing at all, and
|
| 1255 |
+
# `limit_report` answers None with it β so `plan()["limits"]` came back EMPTY and the grids
|
| 1256 |
+
# were skipped with no stated reason. The structural `reader is None` guard still did its job;
|
| 1257 |
+
# what went missing was the half of R6 that has to SAY WHY.
|
| 1258 |
+
#
|
| 1259 |
+
# β THE OBVIOUS FIX IS THE ONE I DID NOT TAKE: `ut.register_read_through(*READ_THROUGH_KEYS)`
|
| 1260 |
+
# would work in one line, and `core.user_tables` explicitly reserves that registrar β
|
| 1261 |
+
# *"IT IS ALSO THE ONLY PLACE THAT MAY CALL `register_read_through`"* β for
|
| 1262 |
+
# `routes_odoo_tables.sync_read_through`, because ELIGIBILITY needs the mirror and the fold
|
| 1263 |
+
# matrix. That reasoning does not apply to a grain with no reader (there is nothing it could
|
| 1264 |
+
# be eligible FOR), but the law is written without an exception, so this lends the evaluator
|
| 1265 |
+
# the definition instead of taking one. `_ensure_table_inplace` writes exactly this stamp, so
|
| 1266 |
+
# what is lent is the document as it stands the moment this plan applies.
|
| 1267 |
+
lent = _LentDoc({**existing,
|
| 1268 |
+
**{k: {**(existing.get(k) or {}), "readThrough": True}
|
| 1269 |
+
for k in READ_THROUGH_KEYS}},
|
| 1270 |
+
ut.STORE_KEY)
|
| 1271 |
+
|
| 1272 |
+
built, limits = {}, {}
|
| 1273 |
+
caps = {}
|
| 1274 |
+
for bucket, key, _label, _fields in TABLES:
|
| 1275 |
+
reader = _READERS.get(bucket)
|
| 1276 |
+
caps[key] = cap = ut.row_limit(key, st=lent)
|
| 1277 |
+
if reader is None or cap == 0:
|
| 1278 |
+
built[bucket] = []
|
| 1279 |
+
report = ut.limit_report(key, st=lent)
|
| 1280 |
+
if report:
|
| 1281 |
+
limits[key] = report
|
| 1282 |
+
continue
|
| 1283 |
+
built[bucket] = reader(cur, excluded)
|
| 1284 |
+
problems = []
|
| 1285 |
+
|
| 1286 |
+
if rt is not None:
|
| 1287 |
needed = [k for _b, k, _l, _f in TABLES if k not in existing]
|
| 1288 |
if needed and len(existing) + len(needed) > ut.MAX_TABLES:
|
| 1289 |
problems.append(
|
|
|
|
| 1293 |
|
| 1294 |
for bucket, key, _label, _fields in TABLES:
|
| 1295 |
rows = built[bucket]
|
| 1296 |
+
cap = caps[key] # asked ONCE per table, above β never re-read per loop
|
| 1297 |
+
# ββ R6: THE `MAX_ROWS` REFUSAL IS GONE FOR A CONNECTED SOURCE, AND THE SENTENCE IT USED
|
| 1298 |
+
# TO PRINT IS NOW `limit_report`'s STRUCTURED ANSWER. Owner, verbatim: *"there is no cap in
|
| 1299 |
+
# how many data from the API source (as long as its from a connected source like Odoo) that
|
| 1300 |
+
# can be pulled into the app⦠Now if there is lag or it can't be done, you need to
|
| 1301 |
+
# explicitly tell me why and recommend a fix."* Both halves are here: a connected table
|
| 1302 |
+
# answers `None` and is never refused for its size, and anything that IS still bounded is
|
| 1303 |
+
# reported with its cause and its recommendation instead of a hand-typed line.
|
| 1304 |
+
#
|
| 1305 |
+
# β THE `cap and` GUARD IS THE WHOLE CHANGE AND ITS TWO FALSY CASES MEAN OPPOSITE THINGS:
|
| 1306 |
+
# `None` = connected, uncapped, build every row Odoo has; `0` = stores no rows here, and
|
| 1307 |
+
# the loop above already handed it an empty list. Neither may reach the refusal. The
|
| 1308 |
+
# editable substrate still gets `MAX_ROWS` and is still REFUSED, never truncated β a capped
|
| 1309 |
+
# table understates every total it feeds while looking exactly like a complete one.
|
| 1310 |
+
if cap and len(rows) > cap:
|
| 1311 |
+
report = ut.limit_report(key, st=lent) or {}
|
| 1312 |
+
limits[key] = report
|
| 1313 |
+
problems.append(
|
| 1314 |
+
f"{key}: {len(rows):,} rows exceeds the {cap:,}-row ceiling; refusing "
|
| 1315 |
+
f"({report.get('cause', 'a truncated table understates every rollup it feeds')}). "
|
| 1316 |
+
f"{report.get('recommendation', '')}".strip())
|
| 1317 |
+
# β THE SHRINK GUARD SKIPS A READ-THROUGH GRAIN, and without this it would refuse every
|
| 1318 |
+
# spawn after the first conversion: zero rows against a stored population is the INTENDED
|
| 1319 |
+
# end state there, not the partial mirror read this guard exists to catch.
|
| 1320 |
+
if cap == 0:
|
| 1321 |
+
continue
|
| 1322 |
stored = len(((existing.get(key) or {}).get("rows")) or {})
|
| 1323 |
if stored and len(rows) < stored * MAX_SHRINK:
|
| 1324 |
problems.append(
|
|
|
|
| 1326 |
f"more than {int((1 - MAX_SHRINK) * 100)}% is a bad read, not Odoo history "
|
| 1327 |
f"shrinking; refusing rather than deleting rows that still exist")
|
| 1328 |
built["problems"] = problems
|
| 1329 |
+
# R6's second sentence as DATA rather than prose: every table whose rows this plan did not
|
| 1330 |
+
# (or may not) materialise, with the cause and the recommendation `core.user_tables` derives.
|
| 1331 |
+
# β NOT a bucket β `apply_plan` iterates `TABLES` and asks `if bucket in built`, so a key that
|
| 1332 |
+
# is not a bucket name is inert there, exactly as `problems` has always been.
|
| 1333 |
+
built["limits"] = limits
|
| 1334 |
return built
|
| 1335 |
|
| 1336 |
|
|
|
|
| 1414 |
"recordMode": ut.AUTOMATION_RECORD_MODE,
|
| 1415 |
}
|
| 1416 |
table.setdefault("recordMode", ut.AUTOMATION_RECORD_MODE)
|
| 1417 |
+
# W30-T35 β the durable "my rows are not in this document" statement, on the tables no
|
| 1418 |
+
# conversion will ever stamp (see `READ_THROUGH_KEYS`). Written on every pass, not
|
| 1419 |
+
# `setdefault`: it is derived from the code's own structure, so the code is what it must agree
|
| 1420 |
+
# with, and a definition that somehow lost the flag should regain it rather than keep serving
|
| 1421 |
+
# an empty grid.
|
| 1422 |
+
if key in READ_THROUGH_KEYS:
|
| 1423 |
+
table["readThrough"] = True
|
| 1424 |
have = {str(f.get("key")): f for f in (table.get("fields") or [])}
|
| 1425 |
for field in fields:
|
| 1426 |
fkey = str(field.get("key"))
|
api/providers.py
CHANGED
|
@@ -44,9 +44,14 @@ from typing import Callable
|
|
| 44 |
#: vendor's own dashboard is. Wrong by 2x still ranks correctly; wrong by 100x does not, which is
|
| 45 |
#: why they are named and dated rather than guessed silently.
|
| 46 |
#: Bright Data Instagram datasets ~ $0.0015/record (2026-08 list). Apify instagram-scraper
|
| 47 |
-
#: ~ $0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
_COST_BRIGHTDATA = float(os.environ.get("AIOS_COST_BRIGHTDATA") or 0.0015)
|
| 49 |
-
_COST_APIFY = float(os.environ.get("AIOS_COST_APIFY") or 0.
|
| 50 |
|
| 51 |
|
| 52 |
@dataclass(frozen=True)
|
|
|
|
| 44 |
#: vendor's own dashboard is. Wrong by 2x still ranks correctly; wrong by 100x does not, which is
|
| 45 |
#: why they are named and dated rather than guessed silently.
|
| 46 |
#: Bright Data Instagram datasets ~ $0.0015/record (2026-08 list). Apify instagram-scraper
|
| 47 |
+
#: ~ $0.0027/result (2026-08 list). Both re-checked when a provider is added.
|
| 48 |
+
#: β WAVE 30 Β· T16 β APIFY CORRECTED 0.0023 -> 0.0027, and the direction matters: the old number
|
| 49 |
+
#: made the fallback look CHEAPER than it is, and `estimate()` is what a person is shown before
|
| 50 |
+
#: they authorise a run. An under-stated price is the one rounding error a cost guard cannot catch.
|
| 51 |
+
#: β Both are LIST rates used to rank and to estimate. They are not invoices, and nothing here
|
| 52 |
+
#: reads a live price β a modelled figure that says so is honest; one that pretends is not.
|
| 53 |
_COST_BRIGHTDATA = float(os.environ.get("AIOS_COST_BRIGHTDATA") or 0.0015)
|
| 54 |
+
_COST_APIFY = float(os.environ.get("AIOS_COST_APIFY") or 0.0027)
|
| 55 |
|
| 56 |
|
| 57 |
@dataclass(frozen=True)
|
api/routes_automation.py
CHANGED
|
@@ -19,6 +19,7 @@ from fastapi import APIRouter, Body, Depends, Header, Request
|
|
| 19 |
import automation_engine as engine
|
| 20 |
import oauth_connect
|
| 21 |
import routes_oauth
|
|
|
|
| 22 |
from deps import Session, err, module_gate, require_session
|
| 23 |
|
| 24 |
router = APIRouter(prefix="/api/v1")
|
|
@@ -225,6 +226,127 @@ def _tick_state():
|
|
| 225 |
#: or delete β a new database cannot contain the legacy stage cells this retires.
|
| 226 |
_BOARD_RETIRED = set()
|
| 227 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
|
| 229 |
@router.get("/automations")
|
| 230 |
def list_automations(session: Session = Depends(_GATE)):
|
|
@@ -250,8 +372,17 @@ def list_automations(session: Session = Depends(_GATE)):
|
|
| 250 |
# `_without_retired_board`), so the persist step is hygiene, not correctness. No client can
|
| 251 |
# ever be shown state this skip left behind. Nothing writes new `stage_auto_` cells either β
|
| 252 |
# wave 26's R6 deleted the board that made them.
|
| 253 |
-
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
_BOARD_RETIRED.add(session.tenant)
|
| 256 |
# Idempotent Board retirement removes only engine-marked stage fields and legacy Board
|
| 257 |
# state. User-created Status/Stage columns remain intact.
|
|
@@ -319,7 +450,11 @@ def list_automations(session: Session = Depends(_GATE)):
|
|
| 319 |
# save would have written somewhere else β a default that disagrees with
|
| 320 |
# itself across two panels, which is the shape wave 25's R2 fixed for
|
| 321 |
# `targetTable` vs the action's `table`.
|
| 322 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
or engine.DISCOVER_TABLE)},
|
| 324 |
"storeAvailable": bool(session.runtime.available()),
|
| 325 |
# Wave 22 C3 β the trigger vocabulary, session-scoped because email readiness is a
|
|
@@ -449,9 +584,16 @@ def automation_tables(session: Session = Depends(_GATE)):
|
|
| 449 |
pass
|
| 450 |
|
| 451 |
out = []
|
| 452 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
key=lambda kv: (kv[1].get("label") or "").lower()):
|
| 454 |
-
if not user_tables.may_open(key, session.uname, session.admin, st=
|
| 455 |
continue
|
| 456 |
row = {"key": key, "label": t.get("label") or key,
|
| 457 |
"source": t.get("source") or "Blank",
|
|
|
|
| 19 |
import automation_engine as engine
|
| 20 |
import oauth_connect
|
| 21 |
import routes_oauth
|
| 22 |
+
import scope_cache
|
| 23 |
from deps import Session, err, module_gate, require_session
|
| 24 |
|
| 25 |
router = APIRouter(prefix="/api/v1")
|
|
|
|
| 226 |
#: or delete β a new database cannot contain the legacy stage cells this retires.
|
| 227 |
_BOARD_RETIRED = set()
|
| 228 |
|
| 229 |
+
#: ββ WAVE 30 Β· T12 β THE PICKER DEFAULT, MEMOISED. `{tenant: (stamp, table_key)}`, the shape
|
| 230 |
+
#: `scope_cache` stores.
|
| 231 |
+
#:
|
| 232 |
+
#: β WHY THE DERIVED STRING AND NOT THE DOCUMENT. The obvious cache here is the `user_tables`
|
| 233 |
+
#: bucket itself, and it is the wrong one: that bucket is up to 35.8 MB per tenant and this box is
|
| 234 |
+
#: the HF free tier, so caching it would trade a latency problem for a memory one. What the warm
|
| 235 |
+
#: path actually needs is `discover_default_table`'s ANSWER β one short string.
|
| 236 |
+
#:
|
| 237 |
+
#: β AND WHY NOT A PLAIN `_BOARD_RETIRED`-STYLE ONCE-PER-PROCESS SET, which would have been less
|
| 238 |
+
#: code: the election reads which profile databases exist and which hold rows, and BOTH change
|
| 239 |
+
#: while the process lives (somebody creates a database, an automation writes the first row). A
|
| 240 |
+
#: once-per-process memo would pin the picker's default to whatever was true at boot and never
|
| 241 |
+
#: correct itself β a default that disagrees with the save door, which is exactly the wave-25 R2
|
| 242 |
+
#: defect the `"table"` line's own comment records. A TTL bounds the staleness instead.
|
| 243 |
+
#:
|
| 244 |
+
#: β `scope_cache` rather than a hand-rolled dict, because it is the house pattern for precisely
|
| 245 |
+
#: this (`routes_customers`, `routes_products`, `pages` all use it) and it is stale-while-refresh:
|
| 246 |
+
#: once a copy exists NO request blocks on a rebuild. Automation was the one module importing it
|
| 247 |
+
#: nowhere, which the wave-30 scout named as the reason every other surface feels fast.
|
| 248 |
+
_DISCOVER_DEFAULT = {}
|
| 249 |
+
#: 5 minutes β the same order as `apiBridge.ts:CUSTOMERS_FRESH_MS` on the client. Overridable so a
|
| 250 |
+
#: gate can pin it rather than sleep.
|
| 251 |
+
_DISCOVER_DEFAULT_TTL = float(os.environ.get("AIOS_AUTOMATION_DEFAULT_TTL") or 300)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
#: The one store key `_LentTables` intercepts. DERIVED from the engine's own constant rather than
|
| 255 |
+
#: written out here: `engine.ut_all` reads the bucket through it, so if that key ever moves, the
|
| 256 |
+
#: lend moves with it instead of silently becoming a pass-through that still looks correct.
|
| 257 |
+
_UT_STORE_KEY = engine.UT_STORE_KEY
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
class _LentTables:
|
| 261 |
+
"""ββ WAVE 30 Β· T13 β a read-only `st` that serves ONE already-read `user_tables` document
|
| 262 |
+
and passes every other key straight through to the real runtime.
|
| 263 |
+
|
| 264 |
+
β WHY THIS EXISTS RATHER THAN A SECOND COPY OF THE WALL. `automation_tables` calls
|
| 265 |
+
`user_tables.may_open(key, β¦)` per table; `may_open` β `user_tables.get` β `all_tables(st)` β
|
| 266 |
+
`st.get('user_tables')`, and `core/store.py:Store.get` deep-copies the WHOLE tenant document on
|
| 267 |
+
every call, hit or miss, under the store lock. So listing N databases cost **1 + N** full
|
| 268 |
+
deep copies of a document whose documented ceiling is 35.8 MB β and this is the route that runs
|
| 269 |
+
the instant somebody clicks an automation row, i.e. the *"and in the MODULE itself"* half of the
|
| 270 |
+
owner's item 4.
|
| 271 |
+
β THE OBVIOUS FIX IS THE FORBIDDEN ONE. Inlining the creator-or-admin test here would remove
|
| 272 |
+
the N reads and re-create the exact defect wave 20 fixed: this route USED to carry its own
|
| 273 |
+
wider rule (`createdBy in (uname, 'automation', 'scheduler')`), so a non-admin saw a database
|
| 274 |
+
in the picker and was refused the moment they opened it. This function's own docstring calls
|
| 275 |
+
that out β *"a duplicate READER is fine; a duplicate WALL is not"*. `may_open` stays THE one
|
| 276 |
+
resolver, unmodified and still called per table; it is simply no longer charged for a document
|
| 277 |
+
the caller is already holding.
|
| 278 |
+
β AND IT IS A CALLER-SIDE FIX ON PURPOSE: adding a `tables=` parameter to `may_open` would mean
|
| 279 |
+
editing `platform/core/user_tables.py`, which belongs to another lane this wave.
|
| 280 |
+
|
| 281 |
+
β READ-ONLY LENDING, and it is safe by inspection rather than by hope: `all_tables` does
|
| 282 |
+
`dict(st.get(STORE_KEY) or {})`, i.e. it shallow-copies the top level before anybody touches
|
| 283 |
+
it, and `may_open` only ever READS `t.get('createdBy')`. Nothing on this path mutates the
|
| 284 |
+
lent document. Any other store key β the shares registry `role_for` consults, for instance β
|
| 285 |
+
is not intercepted and reaches the real runtime unchanged.
|
| 286 |
+
"""
|
| 287 |
+
|
| 288 |
+
def __init__(self, runtime, tables):
|
| 289 |
+
self._runtime, self._tables = runtime, tables
|
| 290 |
+
|
| 291 |
+
def get(self, name, fresh=False):
|
| 292 |
+
if name == _UT_STORE_KEY and not fresh:
|
| 293 |
+
return self._tables
|
| 294 |
+
return self._runtime.get(name, fresh)
|
| 295 |
+
|
| 296 |
+
def __getattr__(self, name):
|
| 297 |
+
return getattr(self._runtime, name)
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def _discover_default(session, tables):
|
| 301 |
+
"""The discovery picker's default table for this tenant β WITHOUT a bucket read on a warm call.
|
| 302 |
+
|
| 303 |
+
β `tables` is the document the caller ALREADY holds on a cold call (the retirement pass reads
|
| 304 |
+
one). Passing it through means the cold path elects from the copy it has rather than taking a
|
| 305 |
+
second one, so this is never an extra read β only ever a saved one.
|
| 306 |
+
"""
|
| 307 |
+
if not session.runtime.available():
|
| 308 |
+
return ""
|
| 309 |
+
if tables is not None:
|
| 310 |
+
# Cold call: the document is in hand. Elect from it and prime the memo in the same pass.
|
| 311 |
+
value = engine.discover_default_table(session.runtime, tables=tables)
|
| 312 |
+
_DISCOVER_DEFAULT[session.tenant] = (time.time(), value)
|
| 313 |
+
return value
|
| 314 |
+
return scope_cache.get(_DISCOVER_DEFAULT, session.tenant, _DISCOVER_DEFAULT_TTL,
|
| 315 |
+
lambda: engine.discover_default_table(session.runtime))
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def warm_default(rt):
|
| 319 |
+
"""ββ WAVE 30 Β· T12, THE COLD HALF. Elect the discovery picker's default at BOOT.
|
| 320 |
+
|
| 321 |
+
β THE HALF THE MEMO CANNOT FIX, and it is why this exists rather than being a nicety. The memo
|
| 322 |
+
above makes calls 2..N free; **call 1 is still a full `user_tables` download**, and it lands on
|
| 323 |
+
whoever clicks Automation first after a deploy β the one visitor with no cache anywhere,
|
| 324 |
+
waiting on a document whose documented ceiling is 35.8 MB / ~1.4 s, taken under the store's
|
| 325 |
+
single lock. That is the owner's *"only automation has a loading screen"* on a cold Space, and
|
| 326 |
+
two waves of memoising the warm path could never touch it. Automation was the only module
|
| 327 |
+
importing `scope_cache` nowhere AND the only one absent from `_prewarm`.
|
| 328 |
+
|
| 329 |
+
β IT ELECTS, IT DOES NOT CACHE THE DOCUMENT β deliberately, and this is the whole design.
|
| 330 |
+
`discover_default_table` reads the bucket once here, in the prewarm daemon thread where nobody
|
| 331 |
+
is waiting, and what survives is a DERIVED STRING. Holding the 35.8 MB document resident would
|
| 332 |
+
trade a latency the tenant notices for memory the HF free tier does not have, which is the
|
| 333 |
+
argument `_DISCOVER_DEFAULT`'s own note makes against caching it.
|
| 334 |
+
|
| 335 |
+
β SAME TTL AS THE REQUEST PATH, not a permanent set: the election reads which databases exist
|
| 336 |
+
and which hold rows, and both change while the process lives. Priming by assignment is exactly
|
| 337 |
+
what the cold request path already does one function up, so there is one way this memo is
|
| 338 |
+
filled, not two.
|
| 339 |
+
|
| 340 |
+
Returns the elected key (`""` when the store is unavailable), so a caller can log it rather
|
| 341 |
+
than guess whether the warm-up did anything. Called from `main.py:_prewarm` only.
|
| 342 |
+
"""
|
| 343 |
+
tenant = str(getattr(rt, "key", "") or "")
|
| 344 |
+
if not tenant or not rt.available():
|
| 345 |
+
return ""
|
| 346 |
+
value = engine.discover_default_table(rt)
|
| 347 |
+
_DISCOVER_DEFAULT[tenant] = (time.time(), value)
|
| 348 |
+
return value
|
| 349 |
+
|
| 350 |
|
| 351 |
@router.get("/automations")
|
| 352 |
def list_automations(session: Session = Depends(_GATE)):
|
|
|
|
| 372 |
# `_without_retired_board`), so the persist step is hygiene, not correctness. No client can
|
| 373 |
# ever be shown state this skip left behind. Nothing writes new `stage_auto_` cells either β
|
| 374 |
# wave 26's R6 deleted the board that made them.
|
| 375 |
+
# ββ WAVE 30 Β· T12 (owner items 4/5, the complaint that has now survived TWO waves).
|
| 376 |
+
# β W29-T01 GUARDED THE SCAN AND NOT THE READ, and that is the whole of what was left. The
|
| 377 |
+
# line below used to be unconditional β every single request deep-copied the tenant's entire
|
| 378 |
+
# `user_tables` document (documented ceiling 35.8 MB / ~1.4 s, and `core/store.py:Store.get`
|
| 379 |
+
# re-serializes on EVERY `.get()`, hit or miss, UNDER THE STORE LOCK so the copies also queue
|
| 380 |
+
# behind each other) β while on calls 2..N the result was DISCARDED: `tables` had exactly two
|
| 381 |
+
# consumers, the `_BOARD_RETIRED` scan (skipped after call 1) and a picker DEFAULT STRING.
|
| 382 |
+
# A whole-tenant document, per request, to render a rail showing a name and a toggle.
|
| 383 |
+
tables = None
|
| 384 |
+
if session.runtime.available() and session.tenant not in _BOARD_RETIRED:
|
| 385 |
+
tables = engine.ut_all(session.runtime)
|
| 386 |
_BOARD_RETIRED.add(session.tenant)
|
| 387 |
# Idempotent Board retirement removes only engine-marked stage fields and legacy Board
|
| 388 |
# state. User-created Status/Stage columns remain intact.
|
|
|
|
| 450 |
# save would have written somewhere else β a default that disagrees with
|
| 451 |
# itself across two panels, which is the shape wave 25's R2 fixed for
|
| 452 |
# `targetTable` vs the action's `table`.
|
| 453 |
+
# β WAVE 30 Β· T12 β through the per-tenant memo. The ELECTION rule and
|
| 454 |
+
# everything the comment above says about it are unchanged; what changed
|
| 455 |
+
# is that a warm request no longer re-reads a 35.8 MB document to
|
| 456 |
+
# recompute a string that did not move.
|
| 457 |
+
"table": (_discover_default(session, tables)
|
| 458 |
or engine.DISCOVER_TABLE)},
|
| 459 |
"storeAvailable": bool(session.runtime.available()),
|
| 460 |
# Wave 22 C3 β the trigger vocabulary, session-scoped because email readiness is a
|
|
|
|
| 584 |
pass
|
| 585 |
|
| 586 |
out = []
|
| 587 |
+
# ββ WAVE 30 Β· T13 β ONE read of the tenant document, LENT to the wall for every table.
|
| 588 |
+
# It was `1 + N` full deep copies (this `ut_all`, then `may_open` β `get` β `all_tables` per
|
| 589 |
+
# table), each of a document with a 35.8 MB / ~370 ms ceiling, all of them queued behind
|
| 590 |
+
# `Store._lock`. `may_open` is unchanged and still asked about every table β see `_LentTables`
|
| 591 |
+
# on why re-implementing the wall here is the one fix that is NOT available.
|
| 592 |
+
_tables_doc = engine.ut_all(session.runtime)
|
| 593 |
+
_lent = _LentTables(session.runtime, _tables_doc)
|
| 594 |
+
for key, t in sorted(_tables_doc.items(),
|
| 595 |
key=lambda kv: (kv[1].get("label") or "").lower()):
|
| 596 |
+
if not user_tables.may_open(key, session.uname, session.admin, st=_lent):
|
| 597 |
continue
|
| 598 |
row = {"key": key, "label": t.get("label") or key,
|
| 599 |
"source": t.get("source") or "Blank",
|
api/routes_connectors.py
CHANGED
|
@@ -60,13 +60,36 @@ PLANNED = [
|
|
| 60 |
"desc": "Attach files from Drive to records."},
|
| 61 |
{"key": "google_calendar", "label": "Google Calendar", "kind": "oauth",
|
| 62 |
"desc": "Turn dated records into calendar entries."},
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
#
|
| 66 |
-
#
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
]
|
| 71 |
|
| 72 |
#: Token connectors an admin can wire up TODAY by pasting a key β no OAuth client, no app review.
|
|
@@ -156,10 +179,17 @@ def directory(rt, uname, is_admin=False):
|
|
| 156 |
# `needs` string still names the env var, deliberately: it is read by whoever has to set it.
|
| 157 |
rows.append({
|
| 158 |
"key": "scraper", "label": "Scraper", "kind": "builtin",
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
| 160 |
"state": "connected" if _eng.bd_ready() else "unconfigured",
|
| 161 |
"needs": "" if _eng.bd_ready() else "AIOS_BRIGHTDATA_KEY is not set on this deployment",
|
| 162 |
"manage": "",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
})
|
| 164 |
rows.append({
|
| 165 |
"key": "webhooks", "label": "Webhooks", "kind": "builtin",
|
|
|
|
| 60 |
"desc": "Attach files from Drive to records."},
|
| 61 |
{"key": "google_calendar", "label": "Google Calendar", "kind": "oauth",
|
| 62 |
"desc": "Turn dated records into calendar entries."},
|
| 63 |
+
# ββ WAVE 30 Β· R4 β TWO ROWS WERE DELETED HERE, AND THEY WERE MISINFORMATION, not clutter.
|
| 64 |
+
#
|
| 65 |
+
# {"key": "tiktok", "label": "TikTok", ... "The second social source beside Instagram."}
|
| 66 |
+
# {"key": "meta", "label": "WhatsApp and Instagram (Meta)", ... "Blocked on Meta business
|
| 67 |
+
# verification; Instagram data arrives through the Scraper connector"}
|
| 68 |
+
#
|
| 69 |
+
# β THE TIKTOK ROW SAID "not built yet" ABOUT SOMETHING THAT SHIPPED IN WAVE 29 β which is
|
| 70 |
+
# precisely the rot this module's header warns about: *"a provider gets wired up somewhere and
|
| 71 |
+
# the directory keeps calling it planned for a wave or two, which is exactly the
|
| 72 |
+
# misinformation this surface exists to prevent."* TikTok is a PLATFORM of the Scraper
|
| 73 |
+
# connector now (R3/R4), and it appears in the Scraper card's own drill below.
|
| 74 |
+
# β THE META ROW IS A DIFFERENT CASE and is deleted for a different reason: what it described
|
| 75 |
+
# (Instagram arriving another way) is now visible ON the Scraper card, so keeping it would put
|
| 76 |
+
# Instagram on this page twice, once as working and once as blocked. The Meta business
|
| 77 |
+
# verification blocker itself is not lost β it is D-86's subject and lives in
|
| 78 |
+
# `.claude/wiki/research/ads-connectors-owner-actions.md`, where an owner action belongs.
|
| 79 |
+
]
|
| 80 |
+
|
| 81 |
+
#: β WAVE 30 Β· R4 β THE SCRAPER'S PLATFORMS, and the whole shape of the ruling in one list.
|
| 82 |
+
#: The owner: *"the damn Tiktok and Instagram belongs in the same bucket β¦ Only when I click
|
| 83 |
+
#: 'Scraper' β¦ would I see the option to choose either Instagram OR TikTok."* So the directory
|
| 84 |
+
#: mirrors the automation menus exactly: ONE card for the capability, and the platforms are behind
|
| 85 |
+
#: it rather than beside it.
|
| 86 |
+
#: β These are not connectors and must never become rows: they have no state, no credential and
|
| 87 |
+
#: no manage door of their own β the Scraper's key is what is configured, and it configures both.
|
| 88 |
+
SCRAPER_PLATFORMS = [
|
| 89 |
+
{"key": "instagram", "label": "Instagram",
|
| 90 |
+
"desc": "Profiles, posts and comments, by search or by handle."},
|
| 91 |
+
{"key": "tiktok", "label": "TikTok",
|
| 92 |
+
"desc": "The same three, on TikTok's own schema."},
|
| 93 |
]
|
| 94 |
|
| 95 |
#: Token connectors an admin can wire up TODAY by pasting a key β no OAuth client, no app review.
|
|
|
|
| 179 |
# `needs` string still names the env var, deliberately: it is read by whoever has to set it.
|
| 180 |
rows.append({
|
| 181 |
"key": "scraper", "label": "Scraper", "kind": "builtin",
|
| 182 |
+
# β WAVE 30 Β· R4 β the sentence names the CAPABILITY; the platforms are the drill below.
|
| 183 |
+
# It used to say "Instagram profile and post capture", which stopped being true the day
|
| 184 |
+
# TikTok discovery shipped and would have to be re-edited for every platform after it.
|
| 185 |
+
"desc": "Profile, post and comment capture for the discovery automations.",
|
| 186 |
"state": "connected" if _eng.bd_ready() else "unconfigured",
|
| 187 |
"needs": "" if _eng.bd_ready() else "AIOS_BRIGHTDATA_KEY is not set on this deployment",
|
| 188 |
"manage": "",
|
| 189 |
+
# β ONE CREDENTIAL, TWO PLATFORMS β which is why this is a field on the row and not two
|
| 190 |
+
# rows. `bd_ready()` above answers for both; a per-platform state would be a second
|
| 191 |
+
# answer to a question that has one.
|
| 192 |
+
"platforms": [dict(p) for p in SCRAPER_PLATFORMS],
|
| 193 |
})
|
| 194 |
rows.append({
|
| 195 |
"key": "webhooks", "label": "Webhooks", "kind": "builtin",
|
api/routes_customers.py
CHANGED
|
@@ -221,10 +221,14 @@ def grid_assembly(session: Session, scope: str = "customer", storage_key: str =
|
|
| 221 |
|
| 222 |
|
| 223 |
def _payload(session: Session):
|
| 224 |
-
"""`{fields, rows, today, pulled_at}` β X2's shape, which `verify_fields_contract.py`
|
| 225 |
referees. Rows are now built by `aios_grid.rows_from_pool` (embed == standalone by
|
| 226 |
construction); see `grid_assembly` for what that fixed.
|
| 227 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
β `rows_src` is the SHARED cached list β `rows_from_pool` reads it and builds NEW dicts,
|
| 229 |
never mutating a cached row (the same-scope-second-user leak rule).
|
| 230 |
"""
|
|
@@ -233,11 +237,26 @@ def _payload(session: Session):
|
|
| 233 |
g = grid_assembly(session)
|
| 234 |
rows = aios_grid.rows_from_pool(
|
| 235 |
g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
return {"fields": g["fields"], "rows": rows,
|
| 237 |
# `today` rides the payload because every relative date condition must resolve against
|
| 238 |
# the TENANT's day, never the browser's β a client that falls back to its own clock
|
| 239 |
# disagrees with the server for everyone west of it.
|
| 240 |
"today": g["today"],
|
|
|
|
|
|
|
| 241 |
"pulled_at": time.strftime("%Y-%m-%d %H:%M")}
|
| 242 |
|
| 243 |
|
|
|
|
| 221 |
|
| 222 |
|
| 223 |
def _payload(session: Session):
|
| 224 |
+
"""`{fields, rows, today, docs, pulled_at}` β X2's shape, which `verify_fields_contract.py`
|
| 225 |
referees. Rows are now built by `aios_grid.rows_from_pool` (embed == standalone by
|
| 226 |
construction); see `grid_assembly` for what that fixed.
|
| 227 |
|
| 228 |
+
β `docs` joined the shape in wave 30 (W30-T37 / contract C4). Named here rather than left to
|
| 229 |
+
the reader because a docstring that still lists the OLD shape is a stale comment on correct
|
| 230 |
+
code β this repo's D-73 β and it is the first thing anyone greps to learn the payload.
|
| 231 |
+
|
| 232 |
β `rows_src` is the SHARED cached list β `rows_from_pool` reads it and builds NEW dicts,
|
| 233 |
never mutating a cached row (the same-scope-second-user leak rule).
|
| 234 |
"""
|
|
|
|
| 237 |
g = grid_assembly(session)
|
| 238 |
rows = aios_grid.rows_from_pool(
|
| 239 |
g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"])
|
| 240 |
+
# β C4 / D-138 (W30-T37) β THE DOCUMENTS PRODUCER FOR THE CUSTOMER SCOPE. The write door
|
| 241 |
+
# (`doc_add`/`doc_fetch`/`doc_delete`) never stopped working and every client half is
|
| 242 |
+
# complete; what vanished with `app.py` at EXIT-6 was the only thing that ever set this key.
|
| 243 |
+
# All six `onDoc*` handlers in `CustomerGrid.tsx` read `payload?.docs ? β¦ : undefined`, so an
|
| 244 |
+
# ABSENT key β not a broken one β is what has been switching the whole feature off.
|
| 245 |
+
#
|
| 246 |
+
# β IMPORTED, NEVER RE-SERIALISED. `core.grid_events.docs_for` is the ONE serialiser and
|
| 247 |
+
# `routes_tables` (the `ut_*` scope) calls the SAME function with the same argument order.
|
| 248 |
+
# A matching pair here is precisely how the wave-29 close-out reintroduced its own defect in
|
| 249 |
+
# the opposite direction inside a single commit ([[one-question-two-normalizers]]).
|
| 250 |
+
# β `g["pids"]` is the row set this session is ALREADY scoped to β `docs_for` has no
|
| 251 |
+
# "every document in the tenant" mode to reach for, deliberately.
|
| 252 |
+
from core import grid_events as _ge
|
| 253 |
return {"fields": g["fields"], "rows": rows,
|
| 254 |
# `today` rides the payload because every relative date condition must resolve against
|
| 255 |
# the TENANT's day, never the browser's β a client that falls back to its own clock
|
| 256 |
# disagrees with the server for everyone west of it.
|
| 257 |
"today": g["today"],
|
| 258 |
+
"docs": _ge.docs_for(g["pids"], scope_key="customer", uname=session.uname,
|
| 259 |
+
admin=session.admin, st=session.runtime),
|
| 260 |
"pulled_at": time.strftime("%Y-%m-%d %H:%M")}
|
| 261 |
|
| 262 |
|
api/routes_grid.py
CHANGED
|
@@ -757,9 +757,17 @@ def grid_events_route(body: dict = Body(default=None),
|
|
| 757 |
elif scope.startswith("ut_"):
|
| 758 |
# Wave 18 C3-UT β the user-table wall (creator/admin) is inside the assembly; the
|
| 759 |
# measure/cohort context is honestly EMPTY (customer-grain machinery, no meaning here).
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 763 |
else:
|
| 764 |
g = grid_assembly(session, scope=scope, consume_corrections=False)
|
| 765 |
# β THE MEASURE CONTEXT IS NOT OPTIONAL (2026-07-31). Without `measure_offer`,
|
|
@@ -789,7 +797,14 @@ def grid_events_route(body: dict = Body(default=None),
|
|
| 789 |
|
| 790 |
out = {"results": results, "rerender": any(r["rerender"] for r in results)}
|
| 791 |
if ctx.out.doc is not None:
|
| 792 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 793 |
if ctx.out.toast is not None:
|
| 794 |
out["toast"] = ctx.out.toast
|
| 795 |
|
|
|
|
| 757 |
elif scope.startswith("ut_"):
|
| 758 |
# Wave 18 C3-UT β the user-table wall (creator/admin) is inside the assembly; the
|
| 759 |
# measure/cohort context is honestly EMPTY (customer-grain machinery, no meaning here).
|
| 760 |
+
# ββ WAVE 30 / W30-T30 (owner item 2: *"when I click hide fields it crashβ¦ no matter the
|
| 761 |
+
# size"*). This used to be `ut_assembly(...)`, which builds the whole table to validate a
|
| 762 |
+
# write it then throws away: `scoped_pool` allocates a dict per row and sorts them, so ONE
|
| 763 |
+
# hide-fields checkbox rebuilt ~33k order rows before the event was even dispatched.
|
| 764 |
+
# β NOTHING IS VALIDATED LESS. The comment above still holds β this assembly is the
|
| 765 |
+
# permission wall and the admission context β and `ut_write_ctx` returns the SAME six keys
|
| 766 |
+
# this route reads, with a pid set derived from exactly the row ids `scoped_pool` would
|
| 767 |
+
# have kept. What it does not do is materialise the rows nobody here looks at.
|
| 768 |
+
from routes_tables import ut_write_ctx
|
| 769 |
+
|
| 770 |
+
g = ut_write_ctx(session, scope)
|
| 771 |
else:
|
| 772 |
g = grid_assembly(session, scope=scope, consume_corrections=False)
|
| 773 |
# β THE MEASURE CONTEXT IS NOT OPTIONAL (2026-07-31). Without `measure_offer`,
|
|
|
|
| 797 |
|
| 798 |
out = {"results": results, "rerender": any(r["rerender"] for r in results)}
|
| 799 |
if ctx.out.doc is not None:
|
| 800 |
+
# β C4 / W30-T27 β `docPayload` IS THE NAME THE CLIENT ALREADY DECLARES. `types.ts` has
|
| 801 |
+
# carried `docPayload?: {pid, docId, name, mime, data_b64}` since C5, and `Documents.tsx`
|
| 802 |
+
# matches it against the fetch it is waiting on β while this route has been answering
|
| 803 |
+
# `doc`, which `apiBridge.ts` deliberately drops. One object, emitted under the name the
|
| 804 |
+
# consumer looks for, so F's wiring needs no translation step to get wrong.
|
| 805 |
+
# β `doc` stays for one wave: nothing in the client reads it, but a gate might, and
|
| 806 |
+
# removing a key to save six bytes is not worth a red nobody predicted.
|
| 807 |
+
out["doc"] = out["docPayload"] = ctx.out.doc
|
| 808 |
if ctx.out.toast is not None:
|
| 809 |
out["toast"] = ctx.out.toast
|
| 810 |
|
api/routes_odoo_tables.py
CHANGED
|
@@ -14,9 +14,14 @@ it can. `verify_api` now does both.
|
|
| 14 |
|
| 15 |
ADMIN-GATED, and not for tidiness: a refresh REWRITES four locked databases for the whole tenant
|
| 16 |
and deletes the rows that left the population. That is an operator action.
|
|
|
|
|
|
|
|
|
|
| 17 |
"""
|
|
|
|
|
|
|
| 18 |
from fastapi import Depends
|
| 19 |
-
from fastapi import APIRouter
|
| 20 |
|
| 21 |
from deps import Session, err, require_session
|
| 22 |
|
|
@@ -63,8 +68,19 @@ def refresh_odoo_tables(session: Session = Depends(require_session)):
|
|
| 63 |
raise err(400, "not_applicable",
|
| 64 |
"These databases are built from the Royal Imports Odoo mirror (R1).")
|
| 65 |
try:
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
except rel.Refused as e:
|
| 69 |
# A refusal is the ANSWER, not a crash: the caller must see WHY nothing was written.
|
| 70 |
raise err(409, "refused", str(e))
|
|
@@ -93,6 +109,16 @@ def odoo_tables_status(session: Session = Depends(require_session)):
|
|
| 93 |
"""
|
| 94 |
rel, out = _rel(), {}
|
| 95 |
import core.user_tables as user_tables
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
# β ITERATES `rel.TABLES`, NEVER A LITERAL PAIR. It read `(INVOICES_KEY, CUSTOMERS_KEY)`
|
| 97 |
# while those were the only two; the 2026-08-09 widening added products and orders, and a
|
| 98 |
# hard-coded list here would have reported "everything is fine" over two databases it had
|
|
@@ -101,10 +127,782 @@ def odoo_tables_status(session: Session = Depends(require_session)):
|
|
| 101 |
table = user_tables.get(key, st=session.runtime) or {}
|
| 102 |
rows = (table.get("rows") or {})
|
| 103 |
stamps = [str((r or {}).get("refreshed") or "") for r in rows.values()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
out[key] = {
|
| 105 |
"exists": bool(table),
|
| 106 |
-
"rows": len(rows),
|
| 107 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
"locked": table.get("recordMode") == user_tables.AUTOMATION_RECORD_MODE,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
}
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
ADMIN-GATED, and not for tidiness: a refresh REWRITES four locked databases for the whole tenant
|
| 16 |
and deletes the rows that left the population. That is an operator action.
|
| 17 |
+
β WAVE 30 (R6/R7, contract C2) ADDS A THIRD ENDPOINT AND IT IS A DIFFERENT KIND OF THING: the
|
| 18 |
+
READ-THROUGH WINDOW. The two above operate on the materialised copy; `/{table_key}/rows` does not
|
| 19 |
+
read the copy at all. See the block above `GRID_SOURCES` for why that had to change.
|
| 20 |
"""
|
| 21 |
+
import json
|
| 22 |
+
|
| 23 |
from fastapi import Depends
|
| 24 |
+
from fastapi import APIRouter, Query
|
| 25 |
|
| 26 |
from deps import Session, err, require_session
|
| 27 |
|
|
|
|
| 68 |
raise err(400, "not_applicable",
|
| 69 |
"These databases are built from the Royal Imports Odoo mirror (R1).")
|
| 70 |
try:
|
| 71 |
+
out = rel.refresh(session.runtime, session.tenant, username=session.user)
|
| 72 |
+
# ββ W30-T31 β AND THEN THE ROWS THAT SHOULD NOT BE HERE LEAVE AGAIN.
|
| 73 |
+
#
|
| 74 |
+
# The spawn writes every bucket's rows straight into the tenant document by mutating it
|
| 75 |
+
# inside its own updater, so no guard in `core.user_tables` is on that path (measured, not
|
| 76 |
+
# assumed: `_ensure_table_inplace` never calls a function there). Stripping AFTER the write
|
| 77 |
+
# is what makes "a read-through grid stores no rows" true rather than intended β and the
|
| 78 |
+
# refresh is the only moment a stripped table can come back.
|
| 79 |
+
# β Reported in the response, because a silent 7 MB moving in or out of a tenant's
|
| 80 |
+
# document is exactly the kind of thing an operator should be able to see happening.
|
| 81 |
+
sync_read_through()
|
| 82 |
+
moved = _ut().strip_materialised(st=session.runtime)
|
| 83 |
+
return {"ok": True, **out, "unmaterialised": moved}
|
| 84 |
except rel.Refused as e:
|
| 85 |
# A refusal is the ANSWER, not a crash: the caller must see WHY nothing was written.
|
| 86 |
raise err(409, "refused", str(e))
|
|
|
|
| 109 |
"""
|
| 110 |
rel, out = _rel(), {}
|
| 111 |
import core.user_tables as user_tables
|
| 112 |
+
# β W30-T31 β THE ELIGIBILITY PASS RUNS HERE TOO, and the mirror cursor is taken best-effort:
|
| 113 |
+
# the freshness surface must answer on a box with no mirror (that is what it is FOR), so a
|
| 114 |
+
# store that is not ready costs the size half of the question, never the whole endpoint.
|
| 115 |
+
cur = None
|
| 116 |
+
try:
|
| 117 |
+
from harness import datastore
|
| 118 |
+
cur = datastore.ro_con()
|
| 119 |
+
except Exception: # noqa: BLE001
|
| 120 |
+
pass
|
| 121 |
+
eligible, why_not = sync_read_through(cur)
|
| 122 |
# β ITERATES `rel.TABLES`, NEVER A LITERAL PAIR. It read `(INVOICES_KEY, CUSTOMERS_KEY)`
|
| 123 |
# while those were the only two; the 2026-08-09 widening added products and orders, and a
|
| 124 |
# hard-coded list here would have reported "everything is fine" over two databases it had
|
|
|
|
| 127 |
table = user_tables.get(key, st=session.runtime) or {}
|
| 128 |
rows = (table.get("rows") or {})
|
| 129 |
stamps = [str((r or {}).get("refreshed") or "") for r in rows.values()]
|
| 130 |
+
# ββ A FRESHNESS SURFACE THAT LIES IS WORSE THAN NO FRESHNESS SURFACE, and this endpoint
|
| 131 |
+
# was one step from becoming one. Both numbers below are derived from `table["rows"]`, so
|
| 132 |
+
# the moment a grid stops materialising they would read `rows: 0, refreshed: ""` β an
|
| 133 |
+
# operator would see a database that looks EMPTY and STALE on a route whose own docstring
|
| 134 |
+
# says a worklist that quietly stopped updating must be legible without a refresh. So a
|
| 135 |
+
# read-through table is counted from the MIRROR and says where its count came from.
|
| 136 |
+
materialised = user_tables.materialises(key, st=session.runtime)
|
| 137 |
+
if not materialised:
|
| 138 |
+
try:
|
| 139 |
+
rows = {}
|
| 140 |
+
stamps = []
|
| 141 |
+
spec = _source_for(cur, key) if cur is not None else None
|
| 142 |
+
if spec is not None:
|
| 143 |
+
from harness import datastore as _ds
|
| 144 |
+
frm = ({"from_sql": spec["from_sql"]} if spec.get("from_sql")
|
| 145 |
+
else {"table": spec["table"]})
|
| 146 |
+
n = _ds.window(select="1", where=spec.get("where") or "",
|
| 147 |
+
order_by=spec["id"], limit=1, cur=cur, **frm)["total"]
|
| 148 |
+
else:
|
| 149 |
+
n = -1 # unknown, and never reported as zero
|
| 150 |
+
except Exception: # noqa: BLE001
|
| 151 |
+
n = -1
|
| 152 |
out[key] = {
|
| 153 |
"exists": bool(table),
|
| 154 |
+
"rows": (len(rows) if materialised else n),
|
| 155 |
+
"rowsFrom": ("document" if materialised else "mirror"),
|
| 156 |
+
"materialised": materialised,
|
| 157 |
+
# why this grid still keeps its rows in the tenant document, when it does not have to
|
| 158 |
+
"materialisedBecause": (why_not.get(key, "") if materialised else ""),
|
| 159 |
+
"refreshed": (max([s for s in stamps if s], default="") if materialised
|
| 160 |
+
else "live β read through the mirror"),
|
| 161 |
"locked": table.get("recordMode") == user_tables.AUTOMATION_RECORD_MODE,
|
| 162 |
+
# W30/R7: whether this database is served THROUGH the mirror rather than from the copy
|
| 163 |
+
# above. Reported per table, because they convert one at a time and an operator
|
| 164 |
+
# reading `rows: 0` needs to know whether that means "empty" or "not stored here".
|
| 165 |
+
"readThrough": key in _sources(),
|
| 166 |
}
|
| 167 |
+
# β W30-T32 / R6's SECOND SENTENCE, APPLIED TO OUR OWN HALF-BUILT STATE. A read-through
|
| 168 |
+
# binding whose FIELD DECLARATION has not landed yet answers 404 on the rows route, and an
|
| 169 |
+
# operator would read that as "the grid does not exist" rather than as "half of it shipped".
|
| 170 |
+
# These are the two line grains: bound here, declared in `odoo_relational` by W30-T35.
|
| 171 |
+
# β It reports the KEYS, never a field list β inventing a contract here is exactly the second
|
| 172 |
+
# source of truth the ticket forbids.
|
| 173 |
+
pending = {k: {"readThrough": True, "declared": False,
|
| 174 |
+
"cause": "this connected grid has a read-through binding but no field "
|
| 175 |
+
"declaration in odoo_relational yet, so it cannot be opened",
|
| 176 |
+
"recommendation": "declare its fields + a TABLES row (W30-T35); the binding "
|
| 177 |
+
"and the window are already live"}
|
| 178 |
+
for k in _sources() if k not in out}
|
| 179 |
+
return {"ok": True, "applicable": rel.is_royal(session.tenant), "tables": out,
|
| 180 |
+
"bound_not_declared": pending}
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 184 |
+
# ββ THE READ-THROUGH WINDOW β owner ruling R6 ("no cap on connected-source data") via R7.
|
| 185 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 186 |
+
#
|
| 187 |
+
# THE PROBLEM IT REPLACES, in the owner's numbers. `core.user_tables.MAX_ROWS = 60_000` bounds a
|
| 188 |
+
# `ut_*` table because ALL of a tenant's tables live in ONE JSON document that `Store.get`
|
| 189 |
+
# deep-copies per request (D-87: four Odoo databases = 20.7 MB, ~370 ms a copy). Orders at
|
| 190 |
+
# **32,826** is already 55% of that ceiling; order lines (**255,286**) are 4.3Γ over it and GL
|
| 191 |
+
# lines (**971,034**) 16Γ over, so those two could never be grids at all β `odoo_relational.plan`
|
| 192 |
+
# refuses them rather than truncating, which is the correct refusal of the wrong architecture.
|
| 193 |
+
#
|
| 194 |
+
# β "NO CAP" IS NOT A BIGGER NUMBER. Raising `MAX_ROWS` would make every request slower for every
|
| 195 |
+
# tenant, including the ones who never open an Odoo grid. The mirror ALREADY holds all of it
|
| 196 |
+
# uncapped β measured on this box: 963,783 GL lines counted in ~24 ms β so the fix is to stop
|
| 197 |
+
# copying and serve a WINDOW: the requested slice, plus a `SELECT count(*)` that tells the truth
|
| 198 |
+
# about the whole.
|
| 199 |
+
#
|
| 200 |
+
# ββ `total` IS NEVER `len(rows)`. It comes from `datastore.window`'s own count statement over
|
| 201 |
+
# the SAME predicate. A window whose count is its own length is a fabricated aggregate wearing an
|
| 202 |
+
# authoritative face ([[no-unverifiable-aggregates]]).
|
| 203 |
+
#
|
| 204 |
+
# β PREDICATES PUSH DOWN, and this is the half a client cannot be trusted with. A filter chip
|
| 205 |
+
# evaluated over the 200 rows that happen to be in memory would report "40 matches" out of
|
| 206 |
+
# 971,034 β [[one-question-two-normalizers]] at scale. `harness/filter_sql.py` compiles OUR filter
|
| 207 |
+
# vocabulary to SQL and is the same evaluator the TS engine is held in step with, so the fold and
|
| 208 |
+
# the display answer one question.
|
| 209 |
+
#
|
| 210 |
+
# β THE SPEC BELOW IS A SECOND STATEMENT OF SOMETHING `odoo_relational`'s READER ALREADY SAYS,
|
| 211 |
+
# AND THAT IS THE REAL RISK HERE β not SQL injection. It is gated rather than trusted:
|
| 212 |
+
# `verify_scopes.section_read_through` runs the REAL reader over the REAL mirror as an ORACLE and
|
| 213 |
+
# asserts the windowed path agrees ROW FOR ROW and TOTAL FOR TOTAL. A binding that drifts from its
|
| 214 |
+
# reader goes red; a bucket with no binding is REPORTED, never silently served from the copy as
|
| 215 |
+
# though it were read-through ([[one-evaluator-per-question]], [[gate-answers-the-wrong-question]]).
|
| 216 |
+
#
|
| 217 |
+
# β ONE BUCKET IS BOUND HERE (orders). The other seven still serve from the materialised copy and
|
| 218 |
+
# say so on the wire (`readThrough: false`), because R6's second sentence β *"if there is lag or
|
| 219 |
+
# it can't be done, you need to explicitly tell me why and recommend a fix"* β makes an unconverted
|
| 220 |
+
# grid something to REPORT, not something to leave looking converted. Adding one is a spec row plus
|
| 221 |
+
# a green oracle check.
|
| 222 |
+
|
| 223 |
+
#: `ut_*` key -> how to read that grid straight out of the mirror.
|
| 224 |
+
#:
|
| 225 |
+
#: `where`/`select` are SQL WE author (never a request value); every request value is bound through
|
| 226 |
+
#: `params` by `filter_sql`. `cols` maps a FIELD KEY (what `odoo_relational.<x>_fields()` declares,
|
| 227 |
+
#: and what a saved view's filters name) to `(sql expression, coercion)`. The coercion reproduces
|
| 228 |
+
#: the reader's own python cast, by CALLING the reader's helpers where one exists β `_as_date` and
|
| 229 |
+
#: `_in_scope` are imported, not re-implemented.
|
| 230 |
+
#: Keys a reader does not produce (link columns, `refreshed`) are absent here on purpose: they are
|
| 231 |
+
#: filled by the grid at render time exactly as they are on the materialised path.
|
| 232 |
+
GRID_SOURCES = {}
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def _sources():
|
| 236 |
+
"""Build `GRID_SOURCES` lazily β it names `odoo_relational` constants, and importing that
|
| 237 |
+
module at file-import time would drag the Odoo layer into every process that mounts a router.
|
| 238 |
+
"""
|
| 239 |
+
if GRID_SOURCES:
|
| 240 |
+
return GRID_SOURCES
|
| 241 |
+
rel = _rel()
|
| 242 |
+
# β R6 / W30-T29 β TELL THE STORE LAYER WHICH DATABASES ARE CONNECTED, so `MAX_ROWS` stops
|
| 243 |
+
# being a fact about them. `core` never imports up, so the declaration goes this way round.
|
| 244 |
+
# β ALL EIGHT, not just the read-through-bound one: every row in these tables comes from Odoo,
|
| 245 |
+
# which is what R6 is about β being served from the stored copy today is our conversion state,
|
| 246 |
+
# not a property of the data.
|
| 247 |
+
_ut().register_connected(*[key for _b, key, _l, _f in rel.TABLES])
|
| 248 |
+
_s, _i, _n = ((lambda v: str(v or "")), (lambda v: int(v or 0)),
|
| 249 |
+
(lambda v: float(v or 0.0)))
|
| 250 |
+
GRID_SOURCES[rel.ORDERS_KEY] = {
|
| 251 |
+
"table": "sale_order",
|
| 252 |
+
# β IMPORTED, NOT RETYPED. `_CONFIRMED` is the fixed wholesale scope; if it ever changes,
|
| 253 |
+
# this window changes with it and the oracle check proves it did.
|
| 254 |
+
"where": f"{rel._CONFIRMED} AND partner_id IS NOT NULL",
|
| 255 |
+
"id": "id",
|
| 256 |
+
"needs_excluded": True, # `wholesale_scope` is resolved against the excluded set
|
| 257 |
+
"cols": {
|
| 258 |
+
"order_no": ("name", _s),
|
| 259 |
+
"odoo_id": ("id", _i),
|
| 260 |
+
"customer": ("partner_name", _s),
|
| 261 |
+
rel.JOIN_KEY: ("partner_id", _i),
|
| 262 |
+
"order_date": ("date_order", rel._as_date),
|
| 263 |
+
"amount_untaxed": ("amount_untaxed", _n),
|
| 264 |
+
"team": ("team_name", _s),
|
| 265 |
+
"state": ("state", _s),
|
| 266 |
+
"invoice_status": ("invoice_status", _s),
|
| 267 |
+
# β THE SCOPE COLUMN BECOMES REAL SQL, which is the point. On the materialised path it
|
| 268 |
+
# is `_in_scope(pid, excluded)` β a python set test, and a filter on it therefore could
|
| 269 |
+
# not push down. Inlining the ids (ints, from our own query) makes it a column the
|
| 270 |
+
# mirror can filter and sort on, so the R6 "limit" it would otherwise have earned does
|
| 271 |
+
# not exist. `{excluded}` is substituted by `_source_for` below.
|
| 272 |
+
"wholesale_scope": ("CASE WHEN partner_id IN ({excluded}) THEN '' ELSE '1' END", _s),
|
| 273 |
+
},
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 277 |
+
# ββ W30-T32 β THE TWO LINE GRAINS. These are the grids R6 exists for: they have never had a
|
| 278 |
+
# `ut_*` table and never can, at any cap. MEASURED on this box's mirror, warm:
|
| 279 |
+
# sale_order_line 254,189 in the confirmed scope (256,810 unscoped) β 63.9 MB as JSON
|
| 280 |
+
# account_move_line 963,783 β ~240 MB as JSON
|
| 281 |
+
# Against `MAX_ROWS = 60_000` that is 4.2x and 16x, and against the 32 MB per-table document
|
| 282 |
+
# budget it is 2x and 7.5x. Read THROUGH, both serve a page in 134β166 ms.
|
| 283 |
+
#
|
| 284 |
+
# β THE FIELD KEYS BELOW ARE HALF OF A CONTRACT AND `odoo_relational` OWNS THE OTHER HALF
|
| 285 |
+
# (W30-T35, session E). `cols` binds a field key to SQL; the field's label, type and order are
|
| 286 |
+
# DECLARED THERE, once. Until that declaration lands the route answers 404 for these two keys
|
| 287 |
+
# (`rel.TABLES` has no entry), and `odoo_tables_status` REPORTS them as bound-not-declared
|
| 288 |
+
# rather than leaving them invisible β R6's second sentence applied to our own conversion.
|
| 289 |
+
# β A key here with no declaration there is a silent NO-CELL (`rows_from_pool` projects
|
| 290 |
+
# strictly); a key there with no binding here is an INACTIVE filter leaf, which WIDENS. The
|
| 291 |
+
# two lists are checked against each other by `verify_scopes.section_line_grids`.
|
| 292 |
+
ol_key = getattr(rel, "ORDER_LINES_KEY", "ut_odoo_order_lines")
|
| 293 |
+
gl_key = getattr(rel, "GL_LINES_KEY", "ut_odoo_gl_lines")
|
| 294 |
+
_ut().register_connected(ol_key, gl_key)
|
| 295 |
+
# β THE SCOPE IS THE ORDER'S, AND THE LINE TABLE CANNOT ANSWER IT ALONE: `sale_order_line`
|
| 296 |
+
# carries no `state` (12 columns, measured), so the confirmed-order scope β and `order_date`,
|
| 297 |
+
# and the order NAME a person reads the grid by β only exist across the join. MEASURED, warm,
|
| 298 |
+
# best of two: the JOIN beats `order_id IN (SELECT id FROM sale_order WHERE β¦)` at both depths
|
| 299 |
+
# (166 / 483 ms against 237 / 565 ms at offset 0 / 200,000), so the shape is chosen on a
|
| 300 |
+
# number rather than on taste.
|
| 301 |
+
# β `_CONFIRMED` is IMPORTED and QUALIFIED, never retyped β it opens with the bare column
|
| 302 |
+
# `state`, which `sale_order_line` does not have, so the prefix is what keeps it unambiguous
|
| 303 |
+
# if that table ever gains one. `section_line_grids` counts the same population a second way
|
| 304 |
+
# (a subquery, not a join) and the two must agree, which is what catches a mis-qualification.
|
| 305 |
+
GRID_SOURCES[ol_key] = {
|
| 306 |
+
"from_sql": "(sale_order_line sol JOIN sale_order so ON so.id = sol.order_id)",
|
| 307 |
+
"tables": {"sol": "sale_order_line", "so": "sale_order"},
|
| 308 |
+
"where": f"so.{rel._CONFIRMED}",
|
| 309 |
+
"id": "sol.id",
|
| 310 |
+
"needs_excluded": True,
|
| 311 |
+
"cols": {
|
| 312 |
+
"odoo_id": ("sol.id", _i),
|
| 313 |
+
"order_no": ("so.name", _s),
|
| 314 |
+
"order_id": ("sol.order_id", _i),
|
| 315 |
+
"customer": ("sol.order_partner_name", _s),
|
| 316 |
+
rel.JOIN_KEY: ("sol.order_partner_id", _i),
|
| 317 |
+
"product": ("sol.product_name", _s),
|
| 318 |
+
rel.PRODUCT_JOIN_KEY: ("sol.product_id", _i),
|
| 319 |
+
"qty": ("sol.product_uom_qty", _n),
|
| 320 |
+
"price_subtotal": ("sol.price_subtotal", _n),
|
| 321 |
+
"margin": ("sol.margin", _n),
|
| 322 |
+
"purchase_price": ("sol.purchase_price", _n),
|
| 323 |
+
"order_date": ("so.date_order", rel._as_date),
|
| 324 |
+
"state": ("so.state", _s),
|
| 325 |
+
"wholesale_scope": (
|
| 326 |
+
"CASE WHEN sol.order_partner_id IN ({excluded}) THEN '' ELSE '1' END", _s),
|
| 327 |
+
},
|
| 328 |
+
}
|
| 329 |
+
# β UNSCOPED ON PURPOSE, and it is a decision rather than an omission: every other grid here
|
| 330 |
+
# carries a fixed scope, but a GENERAL LEDGER whose draft and cancelled entries are invisible
|
| 331 |
+
# is a ledger that cannot be reconciled. `parent_state` rides as a column so a person filters
|
| 332 |
+
# in SQL over all 963,783 rows instead of us choosing for them. (Posted-only is 944,846.)
|
| 333 |
+
# The join to `account_account` is what makes `account_code` β the key `ut_odoo_accounts` is
|
| 334 |
+
# linked on β available at all; the mirror flattens `account_id`/`account_name` onto the line
|
| 335 |
+
# but not the CODE, and 192 accounts hash-join for free.
|
| 336 |
+
GRID_SOURCES[gl_key] = {
|
| 337 |
+
"from_sql": ("(account_move_line aml LEFT JOIN account_account aa "
|
| 338 |
+
"ON aa.id = aml.account_id)"),
|
| 339 |
+
"tables": {"aml": "account_move_line", "aa": "account_account"},
|
| 340 |
+
"where": "",
|
| 341 |
+
"id": "aml.id",
|
| 342 |
+
"needs_excluded": True,
|
| 343 |
+
"cols": {
|
| 344 |
+
"odoo_id": ("aml.id", _i),
|
| 345 |
+
"entry": ("aml.move_name", _s),
|
| 346 |
+
"move_id": ("aml.move_id", _i),
|
| 347 |
+
"account": ("aml.account_name", _s),
|
| 348 |
+
rel.ACCOUNT_JOIN_KEY: ("aa.code", _s),
|
| 349 |
+
"customer": ("aml.partner_name", _s),
|
| 350 |
+
rel.JOIN_KEY: ("aml.partner_id", _i),
|
| 351 |
+
"date": ("aml.date", rel._as_date),
|
| 352 |
+
"debit": ("aml.debit", _n),
|
| 353 |
+
"credit": ("aml.credit", _n),
|
| 354 |
+
"balance": ("aml.balance", _n),
|
| 355 |
+
"line_type": ("aml.display_type", _s),
|
| 356 |
+
"move_type": ("aml.move_type", _s),
|
| 357 |
+
"parent_state": ("aml.parent_state", _s),
|
| 358 |
+
"wholesale_scope": (
|
| 359 |
+
"CASE WHEN aml.partner_id IN ({excluded}) THEN '' ELSE '1' END", _s),
|
| 360 |
+
},
|
| 361 |
+
}
|
| 362 |
+
# β W30-T31 β THE GL ACCOUNT REGISTRY, BOUND BECAUSE IT IS THE ONE GRID THAT CAN ACTUALLY
|
| 363 |
+
# STOP MATERIALISING TODAY. 192 rows, nothing folds it, nothing links at it, and it fits
|
| 364 |
+
# inside one window β the three conditions `_unmaterialisable` checks. It is small, and that
|
| 365 |
+
# is the point: it is the first shipped database whose rows are NOT in the tenant document,
|
| 366 |
+
# so the stratum is proven on a real table instead of on a mechanism with no subject.
|
| 367 |
+
# β `account_fields()` declares six columns and `read_accounts` is one `cur.execute` over
|
| 368 |
+
# `account_account`; `section_read_through`'s differential oracle holds this binding to it.
|
| 369 |
+
# β `is_expense` REPRODUCES `read_accounts`' predicate IN SQL rather than inventing one, and
|
| 370 |
+
# that predicate is itself a copy of the semantic layer's `gl_lines` scope. Three statements of
|
| 371 |
+
# one rule is two too many, but the reader's own comment explains why it is copied rather than
|
| 372 |
+
# imported, and `section_read_through`'s differential oracle is what keeps this one honest.
|
| 373 |
+
GRID_SOURCES[rel.ACCOUNTS_KEY] = {
|
| 374 |
+
"table": "account_account",
|
| 375 |
+
"where": "",
|
| 376 |
+
"id": "id",
|
| 377 |
+
"cols": {
|
| 378 |
+
rel.ACCOUNT_JOIN_KEY: ("code", _s),
|
| 379 |
+
"account_name": ("name", _s),
|
| 380 |
+
"odoo_id": ("id", _i),
|
| 381 |
+
"account_type": ("account_type", _s),
|
| 382 |
+
"is_expense": ("CASE WHEN account_type IN ('expense','expense_depreciation') "
|
| 383 |
+
"THEN '1' ELSE '' END", _s),
|
| 384 |
+
},
|
| 385 |
+
}
|
| 386 |
+
return GRID_SOURCES
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
def sync_read_through(cur=None):
|
| 390 |
+
"""Register every grid that may stop storing rows, and REPORT why the rest may not.
|
| 391 |
+
|
| 392 |
+
Returns `({key: eligible}, {key: reason})`. Idempotent, cheap, and safe to call from any door:
|
| 393 |
+
registration is additive and `strip_materialised` is a no-op once a table is empty.
|
| 394 |
+
|
| 395 |
+
β IT IS ALSO THE ONLY PLACE THAT MAY CALL `register_read_through`, because the eligibility
|
| 396 |
+
question needs BOTH halves β `odoo_relational`'s field declarations (for the fold matrix) and
|
| 397 |
+
the mirror (for the size) β and `core` may import neither.
|
| 398 |
+
"""
|
| 399 |
+
eligible, reasons = _unmaterialisable(cur)
|
| 400 |
+
keys = [k for k, ok in eligible.items() if ok]
|
| 401 |
+
if keys:
|
| 402 |
+
_ut().register_read_through(*keys)
|
| 403 |
+
return eligible, reasons
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
#: β A mirror can be `ready()` and still lack a column (`ready()` reads entity PHASES; column
|
| 407 |
+
#: backfills checkpoint separately) β the gap that already cost a live 500. Every projected
|
| 408 |
+
#: expression is checked against the real column list and degraded to a literal, exactly as
|
| 409 |
+
#: `odoo_relational._col` does for the reader, so a fresh Space serves a blank cell rather than a
|
| 410 |
+
#: DuckDB Binder error.
|
| 411 |
+
#:
|
| 412 |
+
#: β W30-T32 β IT TAKES AN ALIAS MAP NOW, AND WITHOUT THAT THE GUARD WAS ABOUT TO GO BLIND. The
|
| 413 |
+
#: line-grain grids project `sol.price_subtotal` / `aml.parent_state`, and a dotted string is not
|
| 414 |
+
#: `isalnum()`, so the old single-table version returned EVERY qualified expression unchecked β
|
| 415 |
+
#: the same "expression, nothing to check" branch that correctly skips a CASE. Both of those
|
| 416 |
+
#: columns are 2026-07-28 backfills that a mirror can genuinely be missing, so the blind spot
|
| 417 |
+
#: would have surfaced as a bare DuckDB Binder error on a fresh Space, which is precisely the
|
| 418 |
+
#: failure this helper exists to prevent ([[gate-answers-the-wrong-question]]).
|
| 419 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 420 |
+
# ββ W30-T31 / D-87 β WHICH CONNECTED GRIDS MAY STOP MATERIALISING, AND WHY MOST MAY NOT YET.
|
| 421 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 422 |
+
#
|
| 423 |
+
# The prize is real: all of a tenant's `ut_*` tables live in ONE document that `Store.get`
|
| 424 |
+
# deep-copies on EVERY call, hit or miss, and the four original Odoo grids are 20.7 MB / ~370 ms
|
| 425 |
+
# of it. Every permission check in the app pays that.
|
| 426 |
+
#
|
| 427 |
+
# β AND YOU CANNOT SIMPLY DELETE THE ROWS, WHICH IS THE FINDING THIS FUNCTION ENCODES. Measured
|
| 428 |
+
# mechanically across the eight field declarations, not by eye:
|
| 429 |
+
# * `ut_odoo_invoices` and `ut_odoo_orders` β 16.2 of those 20.7 MB β are folded by SIX link
|
| 430 |
+
# rollups on `ut_odoo_customers` (`ar_outstanding`, `open_invoices`, `oldest_due`,
|
| 431 |
+
# `invoiced_all_time`, `order_count`, `last_order`). `automation_engine.compute_relation_cells`
|
| 432 |
+
# answers those by reading the RAW document, so with the rows gone it writes zeros β silently.
|
| 433 |
+
# * every table with a `link` pointing AT it (agents, vendors, bills, customers) has its link
|
| 434 |
+
# CELLS materialised the same way, from the target's stored rows.
|
| 435 |
+
# * `ut_odoo_customers` and `ut_odoo_products` carry SOURCE rollups, and `rollup_sql.compute`
|
| 436 |
+
# writes those cells INTO their own stored rows: no rows, no cells.
|
| 437 |
+
# * and while the grid client still asks for a whole table (F's W30-T42 is what changes this),
|
| 438 |
+
# a population larger than one window could only be served by TRUNCATING it β which R6
|
| 439 |
+
# forbids more strongly than it forbids a cap.
|
| 440 |
+
#
|
| 441 |
+
# β SO THE PREDICATE IS DERIVED FROM THE DECLARATIONS RATHER THAN LISTED. The day a lane converts
|
| 442 |
+
# those six link rollups to SOURCE rollups (`orders_ytd` on that same table is the precedent), or
|
| 443 |
+
# the day the client pages, the affected grids become eligible here with NO code change β and
|
| 444 |
+
# until then each one's reason is reported per table on the status door, which is R6's second
|
| 445 |
+
# sentence applied to our own conversion state.
|
| 446 |
+
def _fold_reasons(rel):
|
| 447 |
+
"""`{table_key: "why its stored rows are still read by something else"}`.
|
| 448 |
+
|
| 449 |
+
Read out of the FIELD DECLARATIONS themselves β one pass over `rel.TABLES`. A table absent
|
| 450 |
+
from this map is folded by nothing.
|
| 451 |
+
"""
|
| 452 |
+
reasons = {}
|
| 453 |
+
|
| 454 |
+
def _add(key, why):
|
| 455 |
+
reasons.setdefault(str(key), []).append(why)
|
| 456 |
+
|
| 457 |
+
for _bucket, key, _label, mk in rel.TABLES:
|
| 458 |
+
try:
|
| 459 |
+
fields = mk()
|
| 460 |
+
except Exception: # noqa: BLE001
|
| 461 |
+
continue
|
| 462 |
+
by_key = {f.get("key"): f for f in fields if isinstance(f, dict)}
|
| 463 |
+
short = str(key).replace("ut_odoo_", "")
|
| 464 |
+
for f in fields:
|
| 465 |
+
if not isinstance(f, dict):
|
| 466 |
+
continue
|
| 467 |
+
if f.get("type") == "link" and (f.get("link") or {}).get("table"):
|
| 468 |
+
_add(f["link"]["table"], f"{short}.{f['key']} is a link whose cells are built "
|
| 469 |
+
f"from these rows")
|
| 470 |
+
if f.get("type") != "rollup":
|
| 471 |
+
continue
|
| 472 |
+
bag = f.get("rollup") or {}
|
| 473 |
+
if isinstance(bag.get("source"), dict):
|
| 474 |
+
_add(key, f"{short}.{f['key']} is a source rollup and its cells are written "
|
| 475 |
+
f"into these rows")
|
| 476 |
+
continue
|
| 477 |
+
tgt = ((by_key.get(str(bag.get("link") or "")) or {}).get("link") or {}).get("table")
|
| 478 |
+
if tgt:
|
| 479 |
+
_add(tgt, f"{short}.{f['key']} folds these rows")
|
| 480 |
+
return {k: "; ".join(v) for k, v in reasons.items()}
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
def _unmaterialisable(cur=None):
|
| 484 |
+
"""`({key: eligible}, {key: reason})` β who may stop storing rows, and why the rest may not.
|
| 485 |
+
|
| 486 |
+
β SIZE FORCES READ-THROUGH; IT NEVER BLOCKS IT β and getting that backwards was a real bug in
|
| 487 |
+
the first cut of this function. A table too big for the tenant document has NO materialised
|
| 488 |
+
option at all, so making it ineligible would have handed `odoo_relational.plan` a `row_limit`
|
| 489 |
+
of None and invited it to build 963,783 python dicts. The window ceiling is a different, much
|
| 490 |
+
softer thing: it only limits what the whole-table CLIENT door can serve today.
|
| 491 |
+
|
| 492 |
+
rows > MAX_ROWS -> read-through REQUIRED (the document cannot hold it)
|
| 493 |
+
else if something folds it -> stays materialised (a fold over no rows writes ZEROS)
|
| 494 |
+
else if rows > WINDOW_MAX -> stays materialised until the client pages (W30-T42)
|
| 495 |
+
else -> eligible
|
| 496 |
+
|
| 497 |
+
β Without a mirror cursor the size questions cannot be asked, so only a grid that was never
|
| 498 |
+
part of the materialised spawn is eligible β a table is never freed by a question we skipped.
|
| 499 |
+
"""
|
| 500 |
+
rel = _rel()
|
| 501 |
+
folds, eligible, reasons = _fold_reasons(rel), {}, {}
|
| 502 |
+
# β "WAS THIS PART OF THE MATERIALISED SPAWN?", and being in `TABLES` STOPPED ANSWERING IT.
|
| 503 |
+
# W30-T35 declared the two line grains, which have no python row builder anywhere β their
|
| 504 |
+
# absence from `_READERS` IS that statement β so a table the spawn could never materialise
|
| 505 |
+
# started reading as spawned, and on a mirror-less deployment fell through to "its size could
|
| 506 |
+
# not be read" and came back INELIGIBLE. That inverts this function's own first law (size
|
| 507 |
+
# forces read-through; it never blocks it). Ask the question through the reader map, which is
|
| 508 |
+
# what actually decides whether a row could ever have been built.
|
| 509 |
+
spawned = {key for _b, key, _l, _f in rel.TABLES if _b in getattr(rel, "_READERS", {})}
|
| 510 |
+
from harness import datastore
|
| 511 |
+
ut = _ut()
|
| 512 |
+
for key, spec in _sources().items():
|
| 513 |
+
total = None
|
| 514 |
+
if cur is not None:
|
| 515 |
+
try:
|
| 516 |
+
frm = ({"from_sql": spec["from_sql"]} if spec.get("from_sql")
|
| 517 |
+
else {"table": spec["table"]})
|
| 518 |
+
total = datastore.window(select="1", where=spec.get("where") or "",
|
| 519 |
+
order_by=spec["id"], limit=1, cur=cur, **frm)["total"]
|
| 520 |
+
except Exception: # noqa: BLE001
|
| 521 |
+
total = None
|
| 522 |
+
if total is not None and total > ut.MAX_ROWS:
|
| 523 |
+
eligible[key] = True # no other home exists; this is not a choice
|
| 524 |
+
continue
|
| 525 |
+
if total is None and key not in spawned:
|
| 526 |
+
eligible[key] = True # never materialised, so the mirror is its home
|
| 527 |
+
continue
|
| 528 |
+
why = []
|
| 529 |
+
if total is None:
|
| 530 |
+
why.append("its size could not be read from the mirror on this deployment")
|
| 531 |
+
if key in folds:
|
| 532 |
+
why.append(folds[key])
|
| 533 |
+
if total is not None and total > datastore.WINDOW_MAX:
|
| 534 |
+
why.append(f"its {total:,} rows exceed the {datastore.WINDOW_MAX:,}-row window and "
|
| 535 |
+
f"the grid client still asks for whole tables, so un-materialising it "
|
| 536 |
+
f"today could only truncate")
|
| 537 |
+
eligible[key] = not why
|
| 538 |
+
if why:
|
| 539 |
+
reasons[key] = "; ".join(why)
|
| 540 |
+
return eligible, reasons
|
| 541 |
+
|
| 542 |
+
|
| 543 |
+
def _degrade(expr, have_by_alias, default_alias=""):
|
| 544 |
+
bare = expr.strip()
|
| 545 |
+
alias, _, col = bare.partition(".")
|
| 546 |
+
if col and alias.replace("_", "").isalnum() and col.replace("_", "").isalnum():
|
| 547 |
+
have = have_by_alias.get(alias)
|
| 548 |
+
if have is None:
|
| 549 |
+
return expr # an alias we do not own β leave it to the caller's SQL
|
| 550 |
+
return expr if col.lower() in have else "NULL"
|
| 551 |
+
if not bare.replace("_", "").isalnum():
|
| 552 |
+
return expr # an expression, not a bare column β nothing to check
|
| 553 |
+
# β A BARE COLUMN IN A JOINED SPEC RESOLVES THE WAY SQL RESOLVES IT β against every table in
|
| 554 |
+
# the FROM, not against nothing. Checking it against `have_by_alias[""]`, which a joined spec
|
| 555 |
+
# does not have, would degrade every such column to NULL: a silent blank cell, which is the
|
| 556 |
+
# failure this helper exists to avoid rather than to cause.
|
| 557 |
+
have = have_by_alias.get(default_alias)
|
| 558 |
+
if have is None:
|
| 559 |
+
have = set().union(*have_by_alias.values()) if have_by_alias else set()
|
| 560 |
+
return expr if bare.lower() in have else "NULL"
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
def _source_for(cur, table_key):
|
| 564 |
+
"""The resolved spec for one connected grid, or None when this grid is not read-through yet."""
|
| 565 |
+
spec = _sources().get(str(table_key or ""))
|
| 566 |
+
if not spec:
|
| 567 |
+
return None
|
| 568 |
+
from harness import datastore
|
| 569 |
+
# β `tables` maps the SQL alias a projection uses to the mirror table behind it. A single-table
|
| 570 |
+
# spec declares none and its columns are bare, so it degrades against `table` as before.
|
| 571 |
+
aliases = dict(spec.get("tables") or {})
|
| 572 |
+
if spec.get("table"):
|
| 573 |
+
aliases.setdefault("", spec["table"])
|
| 574 |
+
have_by_alias = {}
|
| 575 |
+
for alias, tname in aliases.items():
|
| 576 |
+
cols = datastore.columns_of(tname, cur=cur)
|
| 577 |
+
if not cols:
|
| 578 |
+
return None # the mirror has no such table on this deployment
|
| 579 |
+
have_by_alias[alias] = cols
|
| 580 |
+
excluded = ""
|
| 581 |
+
if spec.get("needs_excluded"):
|
| 582 |
+
ids = sorted(int(p) for p in _rel().excluded_ids(cur))
|
| 583 |
+
# `-1` keeps the IN-list non-empty and matches no Odoo id, so the SQL shape is constant
|
| 584 |
+
# whether or not this tenant excludes a channel.
|
| 585 |
+
excluded = ", ".join(str(i) for i in ids) or "-1"
|
| 586 |
+
cols = {}
|
| 587 |
+
for key, (expr, cast) in spec["cols"].items():
|
| 588 |
+
cols[key] = (_degrade(expr.format(excluded=excluded) if "{excluded}" in expr else expr,
|
| 589 |
+
have_by_alias), cast)
|
| 590 |
+
return {**spec, "cols": cols}
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
#: How deep a page has to be before the walk is worth a sentence. Derived from the measurement in
|
| 594 |
+
#: the route below, not chosen: 100,000 is still 71 ms, 200,000 is 483 ms, and the second half of
|
| 595 |
+
#: the GL table is where it passes a second. Reporting from 100,000 puts the sentence in front of
|
| 596 |
+
#: the person BEFORE the wait rather than after it.
|
| 597 |
+
_DEEP_PAGE = 100_000
|
| 598 |
+
|
| 599 |
+
|
| 600 |
+
#: `filter_sql` speaks the CLIENT's type vocabulary (`types.ts isNumericType`), and two of our
|
| 601 |
+
#: field kinds are spelled differently there. Mapped in one place so the pushdown and the grid
|
| 602 |
+
#: cannot disagree about whether a column is text or a number.
|
| 603 |
+
_FILTER_TYPE = {"select": "status", "checkbox": "text"}
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
def _filter_columns(spec, fields):
|
| 607 |
+
"""`{colId: {sql, type, aggregate}}` β what `filter_sql` needs to compile a predicate.
|
| 608 |
+
|
| 609 |
+
Only columns with a real mirror expression are offered. An omitted column is UNKNOWN to the
|
| 610 |
+
compiler, which skips it β and that is the one behaviour that must be REPORTED rather than
|
| 611 |
+
accepted, because an ignored condition WIDENS (the tri-state engine's inactive-leaf rule).
|
| 612 |
+
`_unpushable` below turns every such skip into an R6 sentence.
|
| 613 |
+
"""
|
| 614 |
+
by_key = {f["key"]: f for f in fields}
|
| 615 |
+
out = {}
|
| 616 |
+
for key, (expr, _cast) in spec["cols"].items():
|
| 617 |
+
ftype = str((by_key.get(key) or {}).get("type") or "text")
|
| 618 |
+
out[key] = {"sql": expr, "type": _FILTER_TYPE.get(ftype, ftype), "aggregate": False}
|
| 619 |
+
return out
|
| 620 |
+
|
| 621 |
+
|
| 622 |
+
def _leaf_cols(nodes):
|
| 623 |
+
"""Every `colId` a filter tree names, at any depth."""
|
| 624 |
+
seen = set()
|
| 625 |
+
for n in (nodes or []):
|
| 626 |
+
if not isinstance(n, dict):
|
| 627 |
+
continue
|
| 628 |
+
if n.get("children") is not None:
|
| 629 |
+
seen |= _leaf_cols(n.get("children"))
|
| 630 |
+
elif n.get("colId"):
|
| 631 |
+
seen.add(str(n["colId"]))
|
| 632 |
+
rhs = n.get("rhs")
|
| 633 |
+
if isinstance(rhs, dict) and rhs.get("colId"):
|
| 634 |
+
seen.add(str(rhs["colId"]))
|
| 635 |
+
return seen
|
| 636 |
+
|
| 637 |
+
|
| 638 |
+
def _json_arg(raw, what):
|
| 639 |
+
"""Decode a JSON query argument, or refuse. β NEVER degrade to "no filter": a filter that
|
| 640 |
+
silently fails to parse WIDENS the answer, and the caller sees a plausible bigger number."""
|
| 641 |
+
if raw in (None, ""):
|
| 642 |
+
return None
|
| 643 |
+
try:
|
| 644 |
+
val = json.loads(raw)
|
| 645 |
+
except Exception: # noqa: BLE001
|
| 646 |
+
raise err(400, "bad_argument", f"{what} must be JSON")
|
| 647 |
+
return val
|
| 648 |
+
|
| 649 |
+
|
| 650 |
+
@router.get("/odoo-tables/{table_key}/rows")
|
| 651 |
+
def odoo_table_rows(table_key: str,
|
| 652 |
+
offset: int = Query(default=0, ge=0),
|
| 653 |
+
limit: int = Query(default=0),
|
| 654 |
+
filters: str = Query(default=None),
|
| 655 |
+
filterConj: str = Query(default="and"),
|
| 656 |
+
sorts: str = Query(default=None),
|
| 657 |
+
search: str = Query(default=None),
|
| 658 |
+
session: Session = Depends(require_session)):
|
| 659 |
+
"""ONE WINDOW over a connected grid, read straight from the mirror (contract C2).
|
| 660 |
+
|
| 661 |
+
`{fields, rows, total, totalUnfiltered, offset, limit, limits, identity, recordsMutable}` β
|
| 662 |
+
`rows` is the requested slice and `total` is the count of everything the CURRENT PREDICATE
|
| 663 |
+
matches, from its own `SELECT count(*)`. `rows.length < total` is the normal case.
|
| 664 |
+
|
| 665 |
+
β `total` is never `len(rows)`; `totalUnfiltered` is the population with the predicate
|
| 666 |
+
dropped, so a client can render "N of M" without inventing either number.
|
| 667 |
+
β The filter, the sort and the search all resolve in SQL against the whole table. Anything
|
| 668 |
+
that CANNOT (a column with no mirror expression, an op the compiler refuses) is reported in
|
| 669 |
+
`limits` with its cause and a recommendation β R6's second sentence β and never silently
|
| 670 |
+
dropped, because an ignored condition widens.
|
| 671 |
+
"""
|
| 672 |
+
import aios_grid
|
| 673 |
+
from harness import datastore
|
| 674 |
+
from routes_tables import _defn_or_refuse
|
| 675 |
+
|
| 676 |
+
rel = _rel()
|
| 677 |
+
# THE WALL FIRST, and it is the SAME one the materialised path uses β 404 for a key that does
|
| 678 |
+
# not exist, 403 for one this session may not open. Reused rather than re-stated: a second
|
| 679 |
+
# idea of "may this session open this database" is a permission bug waiting to happen.
|
| 680 |
+
defn = _defn_or_refuse(session, table_key)
|
| 681 |
+
fields = None
|
| 682 |
+
for _bucket, key, _label, mk_fields in rel.TABLES:
|
| 683 |
+
if key == table_key:
|
| 684 |
+
fields = mk_fields()
|
| 685 |
+
break
|
| 686 |
+
if fields is None:
|
| 687 |
+
raise err(404, "not_connected", "that database is not a connected Odoo grid")
|
| 688 |
+
|
| 689 |
+
try:
|
| 690 |
+
cur = datastore.ro_con()
|
| 691 |
+
except RuntimeError as e:
|
| 692 |
+
# The mirror is mid-first-sync. Relaying it beats serving a window over a partial table.
|
| 693 |
+
raise err(503, "store_not_ready", str(e))
|
| 694 |
+
|
| 695 |
+
spec = _source_for(cur, table_key)
|
| 696 |
+
if spec is None:
|
| 697 |
+
raise err(409, "not_read_through",
|
| 698 |
+
"this connected database is still served from its stored copy; it has no "
|
| 699 |
+
"read-through binding on this deployment yet")
|
| 700 |
+
|
| 701 |
+
cols = _filter_columns(spec, fields)
|
| 702 |
+
limits, tree = [], _json_arg(filters, "filters")
|
| 703 |
+
sort_spec = _json_arg(sorts, "sorts") or []
|
| 704 |
+
|
| 705 |
+
# ββ the predicate, pushed down ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 706 |
+
named = _leaf_cols(tree)
|
| 707 |
+
missing = sorted(named - set(cols))
|
| 708 |
+
if missing:
|
| 709 |
+
limits.append({
|
| 710 |
+
"subject": ", ".join(missing), "effect": "filter_ignored",
|
| 711 |
+
"cause": "these columns have no expression in the mirror (a link, a rollup, or a "
|
| 712 |
+
"column this deployment's mirror has not backfilled), so a condition on "
|
| 713 |
+
"them cannot be answered in SQL",
|
| 714 |
+
"recommendation": "filter on the id column the link is built from, or open the "
|
| 715 |
+
"linked database directly"})
|
| 716 |
+
# β R6, MEASURED, AND IT IS THE KIND OF LIMIT THE RULING EXISTS FOR β a difference in the
|
| 717 |
+
# ANSWER, not in the speed.
|
| 718 |
+
#
|
| 719 |
+
# `filter_sql._value_sql` compiles every numeric comparison as `round_even(x, 0)`, and says
|
| 720 |
+
# why: *"reproduces `aios_grid._round` β¦ The grid displays rounded values; filters must agree
|
| 721 |
+
# with what is on screen."* That is true of `source: "odoo"` columns, which `rows_from_pool`
|
| 722 |
+
# rounds. It is FALSE here β these columns are `source: "overlay"` (a storage choice, not a
|
| 723 |
+
# display one) so `rows_from_pool` passes the exact value through, and the TS engine
|
| 724 |
+
# (`useVisibleRows.toNum`) does not round either. So the pushdown compares at whole units while
|
| 725 |
+
# the cell beside it carries cents.
|
| 726 |
+
#
|
| 727 |
+
# MEASURED on the live orders mirror (32,700 rows, 47.9% with a non-integer amount):
|
| 728 |
+
# > 1000 python 4,476 vs SQL 4,473 (-3)
|
| 729 |
+
# > 173.6 python 24,711 vs SQL 24,726 (+15)
|
| 730 |
+
# > 500.25 python 11,028 vs SQL 11,026 (-2)
|
| 731 |
+
# Small, and NOT nothing. Rounding the wire to match would have been the other fix and it was
|
| 732 |
+
# rejected on measurement: it changes 96 of every 200 money cells (173.55 -> 174) to remove a
|
| 733 |
+
# 0.05% counting difference β a visible product regression traded for an invisible one.
|
| 734 |
+
# β SO IT IS REPORTED INSTEAD. Booked for the owner of `harness/filter_sql.py`, which is not
|
| 735 |
+
# this fence; see mailbox/D.md.
|
| 736 |
+
numeric = sorted(k for k in (named & set(cols))
|
| 737 |
+
if cols[k]["type"] in ("currency", "int", "pct"))
|
| 738 |
+
if numeric:
|
| 739 |
+
limits.append({
|
| 740 |
+
"subject": ", ".join(numeric), "effect": "precision",
|
| 741 |
+
"cause": "a number condition is evaluated in SQL at whole-unit precision "
|
| 742 |
+
"(`filter_sql` rounds to match the grids whose values the server rounds), "
|
| 743 |
+
"while these cells carry their exact value β so a row within half a unit of "
|
| 744 |
+
"the threshold can fall on the other side of it",
|
| 745 |
+
"recommendation": "compare against a whole number, or use a range that does not sit "
|
| 746 |
+
"on a fractional boundary"})
|
| 747 |
+
|
| 748 |
+
where, params = spec["where"], []
|
| 749 |
+
try:
|
| 750 |
+
pred = _fs().compile_filter_tree(
|
| 751 |
+
tree, conj=(filterConj if filterConj in ("and", "or") else "and"), columns=cols,
|
| 752 |
+
today=_today())
|
| 753 |
+
except ValueError as e:
|
| 754 |
+
# β A REFUSAL IS AN ANSWER, NOT A CRASH β and it must not become "no filter". Ranking ops
|
| 755 |
+
# ("top 10") have no SQL form in this compiler; saying so beats returning every row.
|
| 756 |
+
raise err(400, "filter_unsupported", str(e))
|
| 757 |
+
if pred is not None:
|
| 758 |
+
if pred.uses_aggregate:
|
| 759 |
+
raise err(400, "filter_unsupported",
|
| 760 |
+
"a condition on an aggregate column belongs in HAVING, and that path is "
|
| 761 |
+
"deliberately not built for windowed grids")
|
| 762 |
+
where = f"({where}) AND {pred.sql}" if where else pred.sql
|
| 763 |
+
params.extend(pred.params)
|
| 764 |
+
if str(search or "").strip():
|
| 765 |
+
got = _fs().compile_search(search.strip(), cols)
|
| 766 |
+
if got is not None:
|
| 767 |
+
where = f"({where}) AND {got.sql}" if where else got.sql
|
| 768 |
+
params.extend(got.params)
|
| 769 |
+
|
| 770 |
+
# ββ the order, made TOTAL βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 771 |
+
# β `tiebreak_sql` is not optional here: without a total order, LIMIT/OFFSET may return one
|
| 772 |
+
# row on two pages and drop another entirely β a duplicate the user sees with no error
|
| 773 |
+
# anywhere. `compile_order_by`'s own docstring says so.
|
| 774 |
+
order = _fs().compile_order_by(sort_spec, cols, tiebreak_sql=spec["id"]) or spec["id"]
|
| 775 |
+
|
| 776 |
+
select = ", ".join(f'{expr} AS "{key}"' for key, (expr, _c) in spec["cols"].items())
|
| 777 |
+
# β EXACTLY ONE of `table`/`from_sql` β `window` raises if both or neither arrive, so the
|
| 778 |
+
# spec's own shape decides and a malformed spec fails loudly instead of serving a wrong FROM.
|
| 779 |
+
frm = {"from_sql": spec["from_sql"]} if spec.get("from_sql") else {"table": spec["table"]}
|
| 780 |
+
win = datastore.window(select=f'{spec["id"]} AS "_pid", {select}',
|
| 781 |
+
where=where, params=tuple(params), order_by=order,
|
| 782 |
+
offset=offset, limit=(limit or None), cur=cur, **frm)
|
| 783 |
+
if win["clamped"]:
|
| 784 |
+
limits.append({
|
| 785 |
+
"subject": "limit", "effect": "window_clamped",
|
| 786 |
+
"cause": f"one response carries at most {datastore.WINDOW_MAX} rows so a single "
|
| 787 |
+
f"request cannot exhaust memory for every other tenant on the process",
|
| 788 |
+
"recommendation": "page with `offset`; `total` already reports the whole population, "
|
| 789 |
+
"and no row is unreachable"})
|
| 790 |
+
# β R6's SECOND SENTENCE, ON THE ONE LIMIT THAT SURVIVES THE CONVERSION. Removing the row cap
|
| 791 |
+
# does not make every row equally cheap: `LIMIT/OFFSET` WALKS the offset, so the deeper the
|
| 792 |
+
# page the longer the scan. MEASURED warm on 963,783 GL lines β offset 0: 134 ms Β· 100,000:
|
| 793 |
+
# 71 ms Β· 900,000: **1,450 ms**; sorted by date rather than by id, offset 500,000: 1,645 ms.
|
| 794 |
+
# It is a real cost, it is nobody's mistake, and the owner asked to be told rather than to
|
| 795 |
+
# discover it: say so with the fix, which is a CURSOR the client has to send.
|
| 796 |
+
if offset >= _DEEP_PAGE:
|
| 797 |
+
limits.append({
|
| 798 |
+
"subject": "offset", "effect": "slow",
|
| 799 |
+
"cause": f"a page {offset:,} rows deep is reached by walking every row before it "
|
| 800 |
+
f"(SQL OFFSET has no other meaning), which costs about a second past "
|
| 801 |
+
f"half a million rows",
|
| 802 |
+
"recommendation": "jump with a filter or a sort instead of scrolling, or ask for "
|
| 803 |
+
"keyset paging (`id > <last row>`), which is O(page) at any depth "
|
| 804 |
+
"and needs the client to send the last row it holds"})
|
| 805 |
+
|
| 806 |
+
# ββ the wire rows, through the SAME serialiser the materialised path uses ββββββββββββββββββ
|
| 807 |
+
rows_src = _pool_from_window(spec, win)
|
| 808 |
+
overlays = {str(r["pid"]): {k: v for k, v in r.items() if k != "pid"} for r in rows_src}
|
| 809 |
+
|
| 810 |
+
# ββ W30-T28 β THE TENANT-WIDE OVERLAY, ON A READ-THROUGH GRID.
|
| 811 |
+
#
|
| 812 |
+
# A user-added column on a connected grid has nowhere per-user to live: there is no `ut_*` row
|
| 813 |
+
# to hang it on any more, and `table_store`'s per-user strata would make a SHARED view name a
|
| 814 |
+
# column other accounts do not have β an unknown column is an INACTIVE condition in the
|
| 815 |
+
# tri-state engine, which WIDENS. `core/shared_overlay.py` was built for exactly this and has
|
| 816 |
+
# had no product door since it shipped (W29-T62).
|
| 817 |
+
#
|
| 818 |
+
# β `cells(table_key, pids)` TAKES THE PIDS AND THERE IS NO "EVERYTHING" CALL β and here that
|
| 819 |
+
# is an asset rather than a chore: **the window IS the scoped row set**, already narrowed by
|
| 820 |
+
# the wall and the predicate, so the argument it demands is the list we just fetched.
|
| 821 |
+
# β It is NOT a permission wall (its header says so twice); `_defn_or_refuse` above already
|
| 822 |
+
# answered "may this session open this surface".
|
| 823 |
+
shared_defs = _so().fields(table_key, st=session.runtime)
|
| 824 |
+
if shared_defs:
|
| 825 |
+
fields = list(fields) + [dict(f, source="overlay") for f in shared_defs.values()]
|
| 826 |
+
for pid, cells in _so().cells(table_key, [r["pid"] for r in rows_src],
|
| 827 |
+
st=session.runtime).items():
|
| 828 |
+
overlays.setdefault(pid, {}).update(cells)
|
| 829 |
+
rows = aios_grid.rows_from_pool(rows_src, fields, overlays)
|
| 830 |
+
|
| 831 |
+
unfiltered = win["total"] if where == spec["where"] else datastore.window(
|
| 832 |
+
select="1", where=spec["where"], order_by=spec["id"], limit=1, cur=cur, **frm)["total"]
|
| 833 |
+
|
| 834 |
+
return {"ok": True, "fields": fields, "rows": rows,
|
| 835 |
+
# β FROM THE COUNT STATEMENT. Never `len(rows)`.
|
| 836 |
+
"total": win["total"], "totalUnfiltered": unfiltered,
|
| 837 |
+
"offset": win["offset"], "limit": win["limit"], "limits": limits,
|
| 838 |
+
"today": _today(), "identity": {"pid": "pid"},
|
| 839 |
+
"scope": {"table": table_key, "readThrough": True},
|
| 840 |
+
"recordsMutable": bool(defn.get("recordMode") != _ut().AUTOMATION_RECORD_MODE)}
|
| 841 |
+
|
| 842 |
+
|
| 843 |
+
def _pool_from_window(spec, win):
|
| 844 |
+
"""`[{pid, **cells}]` β THE one place a mirror window becomes product rows.
|
| 845 |
+
|
| 846 |
+
β ONE BUILDER, TWO CALLERS, and that is deliberate rather than tidy: the windowed route and
|
| 847 |
+
the whole-table read-through below would otherwise each cast the same columns their own way,
|
| 848 |
+
and a cell that renders differently depending on which door served it is this repo's recorded
|
| 849 |
+
defect class ([[one-question-two-normalizers]]).
|
| 850 |
+
"""
|
| 851 |
+
keys = list(spec["cols"])
|
| 852 |
+
return [{"pid": int(r[0]),
|
| 853 |
+
**{k: spec["cols"][k][1](v) for k, v in zip(keys, r[1:])}} for r in win["rows"]]
|
| 854 |
+
|
| 855 |
+
|
| 856 |
+
class TooBigToMaterialise(Exception):
|
| 857 |
+
"""A read-through table asked for WHOLE exceeds one window β refuse, never truncate."""
|
| 858 |
+
|
| 859 |
+
|
| 860 |
+
def whole_pool(table_key, cur=None):
|
| 861 |
+
"""Every row of a read-through grid, in `scoped_pool`'s shape β or a refusal.
|
| 862 |
+
|
| 863 |
+
β THIS EXISTS FOR THE CLIENT WE HAVE, NOT THE ONE WE WANT. The grid still fetches whole
|
| 864 |
+
tables (`GET /tables/{key}/rows`); paging is F's W30-T42. So a database whose rows have left
|
| 865 |
+
this tenant's document has to be servable to that client somehow, and the honest answer for a
|
| 866 |
+
small one is "read all of it from the mirror". `_unmaterialisable` only ever registers a table
|
| 867 |
+
that fits, so the refusal below is a guard against the population GROWING past the window
|
| 868 |
+
later β at which point R6 requires a sentence, not a quietly shorter grid.
|
| 869 |
+
"""
|
| 870 |
+
from harness import datastore
|
| 871 |
+
cur = cur if cur is not None else datastore.ro_con()
|
| 872 |
+
spec = _source_for(cur, table_key)
|
| 873 |
+
if spec is None:
|
| 874 |
+
raise TooBigToMaterialise(
|
| 875 |
+
f"{table_key}: this database is served through the mirror but has no read-through "
|
| 876 |
+
f"binding on this deployment, so its rows cannot be read at all")
|
| 877 |
+
frm = {"from_sql": spec["from_sql"]} if spec.get("from_sql") else {"table": spec["table"]}
|
| 878 |
+
select = ", ".join(f'{expr} AS "{key}"' for key, (expr, _c) in spec["cols"].items())
|
| 879 |
+
win = datastore.window(select=f'{spec["id"]} AS "_pid", {select}', where=spec.get("where"),
|
| 880 |
+
order_by=spec["id"], limit=datastore.WINDOW_MAX, cur=cur, **frm)
|
| 881 |
+
if win["total"] > len(win["rows"]):
|
| 882 |
+
# β REFUSE, NEVER TRIM. A short grid that says nothing is exactly the silent truncation
|
| 883 |
+
# R6's second sentence is about β and the caller turns this into a message with a cause.
|
| 884 |
+
raise TooBigToMaterialise(
|
| 885 |
+
f"{table_key}: {win['total']:,} rows is more than one {datastore.WINDOW_MAX:,}-row "
|
| 886 |
+
f"window, and this database no longer stores rows in the tenant document; it can "
|
| 887 |
+
f"only be read a page at a time (`/odoo-tables/{table_key}/rows`)")
|
| 888 |
+
return _pool_from_window(spec, win)
|
| 889 |
+
|
| 890 |
+
|
| 891 |
+
def _fs():
|
| 892 |
+
from harness import filter_sql
|
| 893 |
+
return filter_sql
|
| 894 |
+
|
| 895 |
+
|
| 896 |
+
def _so():
|
| 897 |
+
import core.shared_overlay as shared_overlay
|
| 898 |
+
return shared_overlay
|
| 899 |
+
|
| 900 |
+
|
| 901 |
+
def _ut():
|
| 902 |
+
import core.user_tables as user_tables
|
| 903 |
+
return user_tables
|
| 904 |
+
|
| 905 |
+
|
| 906 |
+
def _today():
|
| 907 |
+
import time
|
| 908 |
+
return time.strftime("%Y-%m-%d")
|
api/routes_products.py
CHANGED
|
@@ -147,6 +147,22 @@ def product_assembly(session: Session, scope: str = "product", storage_key: str
|
|
| 147 |
scope_key="product", table=pd.TABLE_OPS)
|
| 148 |
ws = grid_events.table_workspace(ctx, allowed_pids=pids,
|
| 149 |
consume_corrections=consume_corrections)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
workspace, fields, views, lists = aios_grid.workspace_wire(
|
| 151 |
ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key,
|
| 152 |
fields_base=fields_base)
|
|
@@ -178,9 +194,9 @@ def products(session: Session = Depends(module_gate(MODULE))):
|
|
| 178 |
import aios_grid
|
| 179 |
|
| 180 |
g = product_assembly(session)
|
| 181 |
-
rows =
|
| 182 |
-
g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"])
|
| 183 |
-
|
| 184 |
return {"fields": g["fields"], "rows": rows,
|
| 185 |
"today": g["today"], "pulled_at": time.strftime("%Y-%m-%d %H:%M"),
|
| 186 |
"identity": {"pid": "pid", "businessKey": "code"},
|
|
@@ -222,8 +238,14 @@ def patch_product(pid: int, body: dict = Body(default=None),
|
|
| 222 |
|
| 223 |
# What actually landed, read back from the PRODUCT bucket rather than echoed from the
|
| 224 |
# request: a refused key or a truncated value must not be reported as accepted.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
stored = (grid_events.table_workspace(ctx, allowed_pids=None)
|
| 226 |
.get("overlays") or {}).get(str(pid)) or {}
|
|
|
|
| 227 |
accepted = {k: stored.get(k) for k in updates if k in stored}
|
| 228 |
refused = sorted(k for k in updates
|
| 229 |
if k not in accepted or stored.get(k) != str(updates[k]))
|
|
@@ -242,6 +264,47 @@ def patch_product(pid: int, body: dict = Body(default=None),
|
|
| 242 |
PRODUCT_IMAGE_KEY = "image"
|
| 243 |
|
| 244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
def _seed_image(rows, fields):
|
| 246 |
"""R7's "auto-seeded from SKU `code`", as a per-render DEFAULT rather than stored data.
|
| 247 |
|
|
|
|
| 147 |
scope_key="product", table=pd.TABLE_OPS)
|
| 148 |
ws = grid_events.table_workspace(ctx, allowed_pids=pids,
|
| 149 |
consume_corrections=consume_corrections)
|
| 150 |
+
# ββ W30-T36 β THE TENANT-WIDE STRATUM, MERGED OVER THE PER-USER ONE, IN THE ASSEMBLY SO
|
| 151 |
+
# EVERY CONSUMER SEES ONE TRUTH. `routes_grid`'s /workspace route serves
|
| 152 |
+
# `workspace["overlays"] = g["ws"].get("overlays")` verbatim, so doing it here means that door
|
| 153 |
+
# carries shared values with no change in a file this lane does not own.
|
| 154 |
+
#
|
| 155 |
+
# β SAFE TO MUTATE, and that was checked rather than assumed: `table_workspace` reads through
|
| 156 |
+
# `store.get`, which deep-copies, so `ws` is a detached copy and nothing writes it back. A
|
| 157 |
+
# shared value can therefore never leak INTO the per-user bucket by way of this merge.
|
| 158 |
+
# β SHARED WINS per key β these are contract columns, so the tenant-wide value is the answer
|
| 159 |
+
# and any per-user leftover under the same key is stale by construction.
|
| 160 |
+
_shared = pd.shared_cells(pids)
|
| 161 |
+
if _shared:
|
| 162 |
+
_ov = dict(ws.get("overlays") or {})
|
| 163 |
+
for _pid, _cells in _shared.items():
|
| 164 |
+
_ov[_pid] = {**(_ov.get(_pid) or {}), **_cells}
|
| 165 |
+
ws["overlays"] = _ov
|
| 166 |
workspace, fields, views, lists = aios_grid.workspace_wire(
|
| 167 |
ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key,
|
| 168 |
fields_base=fields_base)
|
|
|
|
| 194 |
import aios_grid
|
| 195 |
|
| 196 |
g = product_assembly(session)
|
| 197 |
+
rows = aios_grid.rows_from_pool(
|
| 198 |
+
g["rows_src"], g["fields"], g["ws"].get("overlays"), derived=g["derived"])
|
| 199 |
+
rows = _seed_image(_seed_shared(rows, g["rows_src"], g["fields"]), g["fields"])
|
| 200 |
return {"fields": g["fields"], "rows": rows,
|
| 201 |
"today": g["today"], "pulled_at": time.strftime("%Y-%m-%d %H:%M"),
|
| 202 |
"identity": {"pid": "pid", "businessKey": "code"},
|
|
|
|
| 238 |
|
| 239 |
# What actually landed, read back from the PRODUCT bucket rather than echoed from the
|
| 240 |
# request: a refused key or a truncated value must not be reported as accepted.
|
| 241 |
+
# β W30-T36 β AND FROM THE SHARED BUCKET TOO. The four shared columns are written to
|
| 242 |
+
# `<TABLE_KEY>__shared` by `_ProductTableStore.patch_overlay`, so a read-back that consulted
|
| 243 |
+
# only the per-user stratum would find nothing and report every accepted shared write as
|
| 244 |
+
# `refused` β a correct write reported as a failure, which is the read-path-cannot-witness-
|
| 245 |
+
# the-write-path shape ([[read-path-cannot-witness-write-path]]).
|
| 246 |
stored = (grid_events.table_workspace(ctx, allowed_pids=None)
|
| 247 |
.get("overlays") or {}).get(str(pid)) or {}
|
| 248 |
+
stored = {**stored, **((pd.shared_cells([pid]).get(str(pid))) or {})}
|
| 249 |
accepted = {k: stored.get(k) for k in updates if k in stored}
|
| 250 |
refused = sorted(k for k in updates
|
| 251 |
if k not in accepted or stored.get(k) != str(updates[k]))
|
|
|
|
| 264 |
PRODUCT_IMAGE_KEY = "image"
|
| 265 |
|
| 266 |
|
| 267 |
+
def _seed_shared(rows, rows_src, fields):
|
| 268 |
+
"""W30-T36 β the supplier master as a per-render DEFAULT for the four SHARED columns.
|
| 269 |
+
|
| 270 |
+
Exactly `_seed_image`'s shape and for the same reasons: no migration, and nothing frozen. An
|
| 271 |
+
untouched grid renders byte-identically to the read-only version it replaces β including
|
| 272 |
+
`supplier: "(none)"`, which the pool has always stamped β and **empty means "no override"**,
|
| 273 |
+
so clearing a cell restores the mastersheet value rather than blanking the column.
|
| 274 |
+
|
| 275 |
+
ββ GATED PER KEY ON THE SERVED FIELD LIST, AND HERE THAT IS A PERMISSION WALL RATHER THAN
|
| 276 |
+
TIDINESS. `_seed_image`'s docstring predicted this failure in writing β *"An unconditional seed
|
| 277 |
+
would put the key straight back onto every row AFTER that strip⦠the value here is the visible
|
| 278 |
+
`code`, so nothing new escapes today; the contract is the point, and a future non-code default
|
| 279 |
+
would escape."* **`first_cost` IS that future non-code default**: a money number, and
|
| 280 |
+
`product_assembly` strips a hidden field from BOTH wires precisely so a restricted reader
|
| 281 |
+
cannot read it off the row payload. An ungated seed would re-attach it after the strip.
|
| 282 |
+
(Belt and braces, deliberately: `perm_scope.strip_row` has already removed the key from
|
| 283 |
+
`rows_src` too, so the pool row cannot supply it either.)
|
| 284 |
+
|
| 285 |
+
β JOINED ON `pid`, never on position: `rows_from_pool` iterates the pool, but nothing in the
|
| 286 |
+
contract promises the two lists stay index-aligned, and an off-by-one here would put one SKU's
|
| 287 |
+
supplier on another SKU's row β a wrong answer that looks entirely plausible.
|
| 288 |
+
"""
|
| 289 |
+
import modules.product_data as pd
|
| 290 |
+
|
| 291 |
+
served = {f.get("key") for f in (fields or ())}
|
| 292 |
+
keys = [k for k in pd.SHARED_KEYS() if k in served]
|
| 293 |
+
if not keys:
|
| 294 |
+
return rows
|
| 295 |
+
by_pid = {r.get("pid"): r for r in (rows_src or ())}
|
| 296 |
+
for row in rows:
|
| 297 |
+
src = by_pid.get(row.get("pid")) or {}
|
| 298 |
+
for key in keys:
|
| 299 |
+
# A non-empty stored value WINS; only an empty cell is seeded. Blank is therefore
|
| 300 |
+
# "no override" rather than "blank", which is the documented meaning `_seed_image`
|
| 301 |
+
# already gives this column family and the only one that keeps the master reachable
|
| 302 |
+
# after a mistaken edit.
|
| 303 |
+
if row.get(key) in (None, ""):
|
| 304 |
+
row[key] = src.get(key)
|
| 305 |
+
return rows
|
| 306 |
+
|
| 307 |
+
|
| 308 |
def _seed_image(rows, fields):
|
| 309 |
"""R7's "auto-seeded from SKU `code`", as a per-render DEFAULT rather than stored data.
|
| 310 |
|
api/routes_tables.py
CHANGED
|
@@ -166,6 +166,27 @@ def scoped_pool(session: Session, table_key: str):
|
|
| 166 |
defn = _defn_or_refuse(session, table_key)
|
| 167 |
fields_base = [dict(f) for f in (defn.get("fields") or [])]
|
| 168 |
field_keys = {f["key"] for f in fields_base}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
rows_src = []
|
| 170 |
for rid, row in (defn.get("rows") or {}).items():
|
| 171 |
if not str(rid).isdigit():
|
|
@@ -180,6 +201,144 @@ def scoped_pool(session: Session, table_key: str):
|
|
| 180 |
return frozenset(r["pid"] for r in rows_src), rows_src, fields_base, defn
|
| 181 |
|
| 182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
def ut_assembly(session: Session, table_key: str, storage_key: str = "",
|
| 184 |
consume_corrections: bool = True):
|
| 185 |
"""The user-table mirror of `grid_assembly` / `product_assembly` β SAME g-dict keys, so
|
|
@@ -612,10 +771,26 @@ def table_rows(table_key: str, session: Session = Depends(require_session)):
|
|
| 612 |
# so a tenant using `json` for a short config sees no change at all.
|
| 613 |
rows = aios_grid.rows_from_pool(
|
| 614 |
g["rows_src"], g["fields"], _thin_json(g["fields"], merged, table_key), derived=g["derived"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 615 |
return {"fields": g["fields"], "rows": rows, "today": g["today"],
|
|
|
|
|
|
|
| 616 |
"pulled_at": time.strftime("%Y-%m-%d %H:%M"),
|
| 617 |
"identity": {"pid": "pid"},
|
| 618 |
"scope": {"table": table_key, "rowCount": len(rows)},
|
|
|
|
| 619 |
"recordsMutable": _ut().records_mutable(table_key, st=session.runtime)}
|
| 620 |
|
| 621 |
|
|
|
|
| 166 |
defn = _defn_or_refuse(session, table_key)
|
| 167 |
fields_base = [dict(f) for f in (defn.get("fields") or [])]
|
| 168 |
field_keys = {f["key"] for f in fields_base}
|
| 169 |
+
# ββ W30-T31 / D-87 β A READ-THROUGH DATABASE HAS NO ROWS HERE, SO THEY COME FROM THE MIRROR.
|
| 170 |
+
#
|
| 171 |
+
# This is the one function that turns "what is stored" into "what this session may see", which
|
| 172 |
+
# is exactly why the read-through arm belongs HERE and nowhere else: the rows route, the events
|
| 173 |
+
# route, the comments wall and the assembly all reach rows through it, so they all convert
|
| 174 |
+
# together or not at all. β Reading `defn["rows"]` for such a table would find `{}` and serve
|
| 175 |
+
# an EMPTY GRID β correct-looking, wrong, and silent.
|
| 176 |
+
# β The wall above has already run. This adds no scope of its own and takes none away.
|
| 177 |
+
if not _ut().materialises(table_key, st=session.runtime, defn=defn):
|
| 178 |
+
import routes_odoo_tables
|
| 179 |
+
try:
|
| 180 |
+
rows_src = [r for r in routes_odoo_tables.whole_pool(table_key)]
|
| 181 |
+
except routes_odoo_tables.TooBigToMaterialise as e:
|
| 182 |
+
# R6's second sentence: a limit that cannot be met is a SENTENCE, never a short grid.
|
| 183 |
+
raise err(409, "window_required", str(e))
|
| 184 |
+
except RuntimeError as e:
|
| 185 |
+
raise err(503, "store_not_ready", str(e))
|
| 186 |
+
rows_src = [{k: v for k, v in r.items() if k in field_keys or k == "pid"}
|
| 187 |
+
for r in rows_src]
|
| 188 |
+
rows_src.sort(key=lambda r: r["pid"])
|
| 189 |
+
return frozenset(r["pid"] for r in rows_src), rows_src, fields_base, defn
|
| 190 |
rows_src = []
|
| 191 |
for rid, row in (defn.get("rows") or {}).items():
|
| 192 |
if not str(rid).isdigit():
|
|
|
|
| 201 |
return frozenset(r["pid"] for r in rows_src), rows_src, fields_base, defn
|
| 202 |
|
| 203 |
|
| 204 |
+
@router.patch("/tables/{table_key}/shared/{pid}")
|
| 205 |
+
def patch_shared_cell(table_key: str, pid: int, body: dict = Body(default=None),
|
| 206 |
+
session: Session = Depends(require_session)):
|
| 207 |
+
"""Write a cell into the TENANT-WIDE overlay β the product door `core/shared_overlay.py` has
|
| 208 |
+
been waiting for since it shipped (W29-T62, wave 30 T28).
|
| 209 |
+
|
| 210 |
+
β WHY A SEPARATE STRATUM AT ALL, restated because it is the whole feature and it is not
|
| 211 |
+
"sharing would be nice": a user-created column and its values live PER USER, so a shared view
|
| 212 |
+
filtering on one names a column other accounts do not have β and an unknown column is an
|
| 213 |
+
INACTIVE condition in the tri-state engine, which IGNORES it and therefore WIDENS. The buy
|
| 214 |
+
list would silently show the whole catalogue to everyone but its author. A column whose value
|
| 215 |
+
is the same for every reader is the precondition for editing it at all.
|
| 216 |
+
|
| 217 |
+
β THE WALL IS `_defn_or_refuse`, AND THE STRATUM IS NOT ONE. `shared_overlay` refuses no
|
| 218 |
+
reader and no writer by design; "may this session open this surface" is answered HERE, where
|
| 219 |
+
the session is. Do not push the question down there.
|
| 220 |
+
β A tenant-wide write is not a private one: every account that may open this database sees it.
|
| 221 |
+
That is the point, and it is why this door declares the column too β a value with no
|
| 222 |
+
definition is a cell nobody can find.
|
| 223 |
+
"""
|
| 224 |
+
body = body if isinstance(body, dict) else {}
|
| 225 |
+
key = str(body.get("field") or "").strip()
|
| 226 |
+
if not key:
|
| 227 |
+
raise err(400, "bad_request", "a field key is required")
|
| 228 |
+
_defn_or_refuse(session, table_key)
|
| 229 |
+
from core import shared_overlay
|
| 230 |
+
if not shared_overlay.is_shared(table_key, key, st=session.runtime):
|
| 231 |
+
shared_overlay.put_field(table_key, key, {
|
| 232 |
+
"key": key, "label": str(body.get("label") or key), "source": "overlay",
|
| 233 |
+
"type": str(body.get("type") or "text"), "shared": True,
|
| 234 |
+
"createdBy": session.uname}, st=session.runtime)
|
| 235 |
+
try:
|
| 236 |
+
# β `put_cell`, not `put_cells` β this door writes exactly ONE cell, and the singular is
|
| 237 |
+
# the API that says so. It delegates to the plural, so both stay reachable through the one
|
| 238 |
+
# caller; before this, the singular had no caller at all and `verify_reachability` LENS 2
|
| 239 |
+
# named it (the same lens that found `drop_field` had no door either).
|
| 240 |
+
stored = {key: shared_overlay.put_cell(table_key, pid, key, body.get("value"),
|
| 241 |
+
st=session.runtime)}
|
| 242 |
+
except ValueError as e:
|
| 243 |
+
# A non-scalar RAISES in the stratum rather than being dropped; relay it as the answer.
|
| 244 |
+
raise err(400, "bad_value", str(e))
|
| 245 |
+
return {"ok": True, "pid": pid, "cells": stored,
|
| 246 |
+
"fields": list(shared_overlay.fields(table_key, st=session.runtime))}
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
@router.delete("/tables/{table_key}/shared/fields/{field_key}")
|
| 250 |
+
def delete_shared_field(table_key: str, field_key: str,
|
| 251 |
+
session: Session = Depends(require_session)):
|
| 252 |
+
"""Remove a TENANT-WIDE column and every value in it.
|
| 253 |
+
|
| 254 |
+
β WHY THIS EXISTS AT ALL, said plainly: W30-T28 shipped the door that CREATES a shared column
|
| 255 |
+
and none that removes one, so a column anybody added was permanent for the whole tenant. The
|
| 256 |
+
reachability gate found it from the other end β `shared_overlay.drop_field` was complete,
|
| 257 |
+
correct, gated, and callable by nothing but its own gate ([[reachable-is-not-the-same-as-built]]).
|
| 258 |
+
|
| 259 |
+
β AND THIS ONE IS CREATOR-OR-ADMIN, WHICH THE WRITE DOOR IS NOT. Writing a cell changes a
|
| 260 |
+
value; dropping the column deletes that value for EVERY account at once, so it is the
|
| 261 |
+
destructive-op wall this repo already uses for a database delete β not `editRole`, which
|
| 262 |
+
governs renaming and is not a value wall ([[schema-role-is-not-a-value-wall]]).
|
| 263 |
+
β `createdBy` is stamped by the write door above; a column stored before that stamp existed
|
| 264 |
+
is admin-only, which is the safe direction.
|
| 265 |
+
"""
|
| 266 |
+
_defn_or_refuse(session, table_key)
|
| 267 |
+
from core import shared_overlay
|
| 268 |
+
defn = (shared_overlay.fields(table_key, st=session.runtime) or {}).get(str(field_key))
|
| 269 |
+
if not defn:
|
| 270 |
+
raise err(404, "unknown_field", "that column is not a shared column on this database")
|
| 271 |
+
owner = str(defn.get("createdBy") or "")
|
| 272 |
+
if not session.admin and owner != session.uname:
|
| 273 |
+
raise err(403, "forbidden",
|
| 274 |
+
f"a tenant-wide column can be removed by its creator or an admin β this one "
|
| 275 |
+
f"was added by {owner or 'somebody else'}, and dropping it would delete the "
|
| 276 |
+
f"value for every account")
|
| 277 |
+
dropped = shared_overlay.drop_field(table_key, str(field_key), st=session.runtime)
|
| 278 |
+
return {"ok": True, "dropped": bool(dropped),
|
| 279 |
+
"fields": list(shared_overlay.fields(table_key, st=session.runtime))}
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def scoped_pids(session: Session, table_key: str):
|
| 283 |
+
"""`(pids, fields_base, defn)` β the SAME wall and the SAME row set as `scoped_pool`, without
|
| 284 |
+
building a row.
|
| 285 |
+
|
| 286 |
+
ββ WAVE 30 / W30-T30 β THIS IS WHY ONE HIDE-FIELDS CHECKBOX WAS EXPENSIVE. A view write
|
| 287 |
+
(`view_upsert`) reaches `grid_events_route`, which built a FULL assembly purely to validate
|
| 288 |
+
it: `scoped_pool` allocates a fresh dict per row and then sorts them β ~33k order rows, on
|
| 289 |
+
every toggle β and the six keys the events route actually reads from that assembly
|
| 290 |
+
(`fields`, `pids`, `measures`, `measure_sets`, `lists`, `views`) contain no row at all.
|
| 291 |
+
`rows_src` was computed and discarded.
|
| 292 |
+
|
| 293 |
+
β THE PID SET IS IDENTICAL, NOT MERELY EQUIVALENT, and that is the whole safety argument:
|
| 294 |
+
`scoped_pool` derives its pids as `frozenset(r["pid"] for r in rows_src)` over exactly the
|
| 295 |
+
row ids that pass `str(rid).isdigit()`, which is this comprehension with a dict build in the
|
| 296 |
+
middle. The row WALL is unchanged β a narrower or wider set here would be a permission
|
| 297 |
+
change, and this is a performance change.
|
| 298 |
+
|
| 299 |
+
β It does NOT make the write cheap on its own: `_defn_or_refuse` still costs two whole-document
|
| 300 |
+
deep copies (`ut.get` then `may_open`), which is D-87 and W30-T31. This removes the row pass.
|
| 301 |
+
"""
|
| 302 |
+
defn = _defn_or_refuse(session, table_key)
|
| 303 |
+
fields_base = [dict(f) for f in (defn.get("fields") or [])]
|
| 304 |
+
# β W30-T31: on a read-through database the stored `rows` is `{}` by construction, so the
|
| 305 |
+
# comprehension below would answer an EMPTY pid set β and the promise this function makes is
|
| 306 |
+
# that its set is IDENTICAL to `scoped_pool`'s, not merely cheaper. It defers to that function
|
| 307 |
+
# rather than growing a second idea of the row set; the saving this ticket bought stays on
|
| 308 |
+
# every materialised table, which is all of the big ones.
|
| 309 |
+
if not _ut().materialises(table_key, st=session.runtime, defn=defn):
|
| 310 |
+
pids, _rows, fields_base, defn = scoped_pool(session, table_key)
|
| 311 |
+
return pids, fields_base, defn
|
| 312 |
+
pids = frozenset(int(rid) for rid in (defn.get("rows") or {}) if str(rid).isdigit())
|
| 313 |
+
return pids, fields_base, defn
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def ut_write_ctx(session: Session, table_key: str):
|
| 317 |
+
"""The g-dict a WRITE needs β same keys as `ut_assembly`, no rows.
|
| 318 |
+
|
| 319 |
+
Returns the six keys `routes_grid.grid_events_route` reads, so the events route consumes this
|
| 320 |
+
or a full assembly interchangeably. `rows_src` is `[]` on purpose rather than absent: a caller
|
| 321 |
+
that starts needing rows should fail on an empty list it can see, not on a KeyError.
|
| 322 |
+
"""
|
| 323 |
+
import aios_grid
|
| 324 |
+
from core import grid_events
|
| 325 |
+
|
| 326 |
+
pids, fields_base, defn = scoped_pids(session, table_key)
|
| 327 |
+
ctx = grid_events.EventCtx(
|
| 328 |
+
uname=session.uname, allowed_pids=pids, fields=[], hidden_keys=frozenset(),
|
| 329 |
+
admin=session.admin, fallback_ws=None, seen_ids={}, st=session.runtime,
|
| 330 |
+
scope_key=table_key, table=_ops(session, table_key))
|
| 331 |
+
ws = grid_events.table_workspace(ctx, allowed_pids=pids, consume_corrections=False)
|
| 332 |
+
workspace, fields, views, lists = aios_grid.workspace_wire(
|
| 333 |
+
ws, session.uname, set(pids), defs={}, scope_key=table_key, storage_key="",
|
| 334 |
+
fields_base=fields_base)
|
| 335 |
+
return {"rows_src": [], "pids": pids, "ws": ws, "workspace": workspace,
|
| 336 |
+
"fields": fields, "views": views, "lists": lists,
|
| 337 |
+
"derived": aios_grid.cohort_cells(lists),
|
| 338 |
+
"measures": [], "measure_sets": {}, "today": time.strftime("%Y-%m-%d"),
|
| 339 |
+
"defn": defn}
|
| 340 |
+
|
| 341 |
+
|
| 342 |
def ut_assembly(session: Session, table_key: str, storage_key: str = "",
|
| 343 |
consume_corrections: bool = True):
|
| 344 |
"""The user-table mirror of `grid_assembly` / `product_assembly` β SAME g-dict keys, so
|
|
|
|
| 771 |
# so a tenant using `json` for a short config sees no change at all.
|
| 772 |
rows = aios_grid.rows_from_pool(
|
| 773 |
g["rows_src"], g["fields"], _thin_json(g["fields"], merged, table_key), derived=g["derived"])
|
| 774 |
+
# β R6's SECOND SENTENCE, ON THE WIRE (W30-T29). *"if there is lag or it can't be done, you
|
| 775 |
+
# need to explicitly tell me why and recommend a fix."* A ceiling that still applies to this
|
| 776 |
+
# database says so here, with its cause and the recommendation, rather than waiting to be
|
| 777 |
+
# discovered as a refused paste. `None` for a connected source, and an EMPTY LIST is the
|
| 778 |
+
# honest answer for a table nothing limits β never an absent key, which a client cannot tell
|
| 779 |
+
# apart from an older server.
|
| 780 |
+
_report = _ut().limit_report(table_key, st=session.runtime)
|
| 781 |
+
# β C4 / D-138 β THE DOCUMENTS PRODUCER. Absent since EXIT-6 deleted `app.py`, which was the
|
| 782 |
+
# only thing that ever set this key; the write door never stopped working and every client
|
| 783 |
+
# half is complete, but all six `onDoc*` handlers read `payload?.docs ? β¦ : undefined`, so a
|
| 784 |
+
# missing key has been silently switching the feature off. β ONE shared serialiser with the
|
| 785 |
+
# customer scope β `grid_events.docs_for` β never a twin here.
|
| 786 |
+
from core import grid_events as _ge
|
| 787 |
return {"fields": g["fields"], "rows": rows, "today": g["today"],
|
| 788 |
+
"docs": _ge.docs_for(g["pids"], scope_key=table_key, uname=session.uname,
|
| 789 |
+
admin=session.admin, st=session.runtime),
|
| 790 |
"pulled_at": time.strftime("%Y-%m-%d %H:%M"),
|
| 791 |
"identity": {"pid": "pid"},
|
| 792 |
"scope": {"table": table_key, "rowCount": len(rows)},
|
| 793 |
+
"limits": [_report] if _report else [],
|
| 794 |
"recordsMutable": _ut().records_mutable(table_key, st=session.runtime)}
|
| 795 |
|
| 796 |
|
platform/aios_grid.py
CHANGED
|
@@ -1769,8 +1769,26 @@ def clean_filter_tree(raw, valid_keys, depth=1, budget=None, cohort_ids=None):
|
|
| 1769 |
|
| 1770 |
|
| 1771 |
def _default_view_config(fields):
|
| 1772 |
-
|
| 1773 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1774 |
hidden = [field["key"] for field in fields if field["key"] not in shown]
|
| 1775 |
return {
|
| 1776 |
# `filters` is the ROOT of the filter tree: leaf conditions and/or nested
|
|
|
|
| 1769 |
|
| 1770 |
|
| 1771 |
def _default_view_config(fields):
|
| 1772 |
+
# ββ W30-T41's SERVER HALF (F's ask F-1, answered by D β this file is D's fence).
|
| 1773 |
+
#
|
| 1774 |
+
# β THE SECOND ARM USED TO BE `or field["source"] == "overlay"`, AND IT SWALLOWED THE FIRST
|
| 1775 |
+
# ONE FOR EVERY CONNECTED COLUMN. `user_tables._clean_field` stamps `source: "overlay"` on
|
| 1776 |
+
# every `ut_` field, so on an Odoo grid the arm was true for ALL of them and `default: False`
|
| 1777 |
+
# meant nothing: `odoo_id`, `state`, `customer_link` and `partner_id` opened SHOWN however
|
| 1778 |
+
# they were declared. The exception had become the rule ([[fallback-that-became-the-rule]]),
|
| 1779 |
+
# and it is the same predicate `useGridColumns.isDefaultVisible` carried on the client.
|
| 1780 |
+
#
|
| 1781 |
+
# β AND THE TWO HALVES MUST MOVE TOGETHER, which is why this is not cosmetic. `CustomerGrid`
|
| 1782 |
+
# compares the stored view against its own `defaultViewConfig` by JSON equality; with the
|
| 1783 |
+
# client fixed (T41) and this left alone, the system view would differ from the client's
|
| 1784 |
+
# default on every render β a view that looks permanently dirty and autosaves forever, which
|
| 1785 |
+
# is the failure `verify_filter_engine`'s key-ORDER check exists to prevent, one level down.
|
| 1786 |
+
#
|
| 1787 |
+
# β A USER-CREATED COLUMN IS UNAFFECTED, and that is why the fix is a DELETION rather than a
|
| 1788 |
+
# carve-out for the four Odoo keys: it carries no `default` key at all, so `is not False`
|
| 1789 |
+
# keeps it visible. On the main Customer grid exactly one field moves β `notes`, which asks
|
| 1790 |
+
# to be hidden in its own declaration and was being shown against it.
|
| 1791 |
+
shown = [field["key"] for field in fields if field.get("default") is not False]
|
| 1792 |
hidden = [field["key"] for field in fields if field["key"] not in shown]
|
| 1793 |
return {
|
| 1794 |
# `filters` is the ROOT of the filter tree: leaf conditions and/or nested
|
platform/aios_grid_fields.json
CHANGED
|
@@ -1,423 +1,464 @@
|
|
| 1 |
-
{
|
| 2 |
-
"_comment": "CANONICAL field contract for the AIOS Airtable-style grid
|
| 3 |
-
"fields": [
|
| 4 |
-
{
|
| 5 |
-
"key": "customer",
|
| 6 |
-
"label": "Customer",
|
| 7 |
-
"type": "text",
|
| 8 |
-
"source": "odoo",
|
| 9 |
-
"pinned": true,
|
| 10 |
-
"default": true,
|
| 11 |
-
"description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
|
| 12 |
-
},
|
| 13 |
-
{
|
| 14 |
-
"key": "odoo_status",
|
| 15 |
-
"label": "Odoo record",
|
| 16 |
-
"type": "status",
|
| 17 |
-
"source": "odoo",
|
| 18 |
-
"default": false,
|
| 19 |
-
"description": "Whether this customer still exists in Odoo. Archived means deleted there."
|
| 20 |
-
},
|
| 21 |
-
{
|
| 22 |
-
"key": "agent",
|
| 23 |
-
"label": "Agent",
|
| 24 |
-
"type": "text",
|
| 25 |
-
"source": "odoo",
|
| 26 |
-
"default": true,
|
| 27 |
-
"description": "The sales agent who owns this account."
|
| 28 |
-
},
|
| 29 |
-
{
|
| 30 |
-
"key": "dba",
|
| 31 |
-
"label": "DBA",
|
| 32 |
-
"type": "select",
|
| 33 |
-
"source": "odoo",
|
| 34 |
-
"default": false,
|
| 35 |
-
"options": [
|
| 36 |
-
"Fisch",
|
| 37 |
-
"Royal",
|
| 38 |
-
"Both"
|
| 39 |
-
],
|
| 40 |
-
"description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
|
| 41 |
-
},
|
| 42 |
-
{
|
| 43 |
-
"key": "salesperson",
|
| 44 |
-
"label": "Salesperson",
|
| 45 |
-
"type": "text",
|
| 46 |
-
"source": "odoo",
|
| 47 |
-
"default": false,
|
| 48 |
-
"description": "Who keyed in most of this customer's orders
|
| 49 |
-
},
|
| 50 |
-
{
|
| 51 |
-
"key": "
|
| 52 |
-
"label": "
|
| 53 |
-
"type": "text",
|
| 54 |
-
"source": "odoo",
|
| 55 |
-
"default":
|
| 56 |
-
"description": "
|
| 57 |
-
},
|
| 58 |
-
{
|
| 59 |
-
"key": "
|
| 60 |
-
"label": "
|
| 61 |
-
"type": "text",
|
| 62 |
-
"source": "odoo",
|
| 63 |
-
"default":
|
| 64 |
-
"description": "
|
| 65 |
-
},
|
| 66 |
-
{
|
| 67 |
-
"key": "
|
| 68 |
-
"label": "
|
| 69 |
-
"type": "text",
|
| 70 |
-
"source": "odoo",
|
| 71 |
-
"default":
|
| 72 |
-
"description": "
|
| 73 |
-
},
|
| 74 |
-
{
|
| 75 |
-
"key": "
|
| 76 |
-
"label": "
|
| 77 |
-
"type": "text",
|
| 78 |
-
"source": "odoo",
|
| 79 |
-
"default":
|
| 80 |
-
"description": "
|
| 81 |
-
},
|
| 82 |
-
{
|
| 83 |
-
"key": "
|
| 84 |
-
"label": "
|
| 85 |
-
"type": "
|
| 86 |
-
"source": "odoo",
|
| 87 |
-
"default": false,
|
| 88 |
-
"description": "
|
| 89 |
-
},
|
| 90 |
-
{
|
| 91 |
-
"key": "
|
| 92 |
-
"label": "
|
| 93 |
-
"type": "text",
|
| 94 |
-
"source": "odoo",
|
| 95 |
-
"default": false,
|
| 96 |
-
"description": "
|
| 97 |
-
},
|
| 98 |
-
{
|
| 99 |
-
"key": "
|
| 100 |
-
"label": "
|
| 101 |
-
"type": "
|
| 102 |
-
"source": "odoo",
|
| 103 |
-
"default": false,
|
| 104 |
-
"description": "
|
| 105 |
-
},
|
| 106 |
-
{
|
| 107 |
-
"key": "
|
| 108 |
-
"label": "
|
| 109 |
-
"type": "text",
|
| 110 |
-
"source": "odoo",
|
| 111 |
-
"default": false,
|
| 112 |
-
"description": "
|
| 113 |
-
},
|
| 114 |
-
{
|
| 115 |
-
"key": "
|
| 116 |
-
"label": "
|
| 117 |
-
"type": "
|
| 118 |
-
"source": "odoo",
|
| 119 |
-
"default":
|
| 120 |
-
"description": "
|
| 121 |
-
},
|
| 122 |
-
{
|
| 123 |
-
"key": "
|
| 124 |
-
"label": "
|
| 125 |
-
"type": "
|
| 126 |
-
"source": "odoo",
|
| 127 |
-
"default":
|
| 128 |
-
"description": "
|
| 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 |
-
"key": "
|
| 177 |
-
"label": "
|
| 178 |
-
"type": "currency",
|
| 179 |
-
"source": "odoo",
|
| 180 |
-
"default": false,
|
| 181 |
-
"description": "
|
| 182 |
-
},
|
| 183 |
-
{
|
| 184 |
-
"key": "
|
| 185 |
-
"label": "
|
| 186 |
-
"type": "currency",
|
| 187 |
-
"source": "odoo",
|
| 188 |
-
"default": false,
|
| 189 |
-
"description": "
|
| 190 |
-
},
|
| 191 |
-
{
|
| 192 |
-
"key": "
|
| 193 |
-
"label": "
|
| 194 |
-
"type": "currency",
|
| 195 |
-
"source": "odoo",
|
| 196 |
-
"default": false,
|
| 197 |
-
"description": "Overdue between
|
| 198 |
-
},
|
| 199 |
-
{
|
| 200 |
-
"key": "
|
| 201 |
-
"label": "
|
| 202 |
-
"type": "currency",
|
| 203 |
-
"source": "odoo",
|
| 204 |
-
"default": false,
|
| 205 |
-
"description": "Overdue
|
| 206 |
-
},
|
| 207 |
-
{
|
| 208 |
-
"key": "
|
| 209 |
-
"label": "
|
| 210 |
-
"type": "
|
| 211 |
-
"source": "odoo",
|
| 212 |
-
"default": false,
|
| 213 |
-
"description": "
|
| 214 |
-
},
|
| 215 |
-
{
|
| 216 |
-
"key": "
|
| 217 |
-
"label": "
|
| 218 |
-
"type": "
|
| 219 |
-
"source": "odoo",
|
| 220 |
-
"default": false,
|
| 221 |
-
"description": "
|
| 222 |
-
},
|
| 223 |
-
{
|
| 224 |
-
"key": "
|
| 225 |
-
"label": "
|
| 226 |
-
"type": "
|
| 227 |
-
"source": "odoo",
|
| 228 |
-
"default": false,
|
| 229 |
-
"description": "
|
| 230 |
-
},
|
| 231 |
-
{
|
| 232 |
-
"key": "
|
| 233 |
-
"label": "
|
| 234 |
-
"type": "
|
| 235 |
-
"source": "odoo",
|
| 236 |
-
"default": false,
|
| 237 |
-
"description": "
|
| 238 |
-
},
|
| 239 |
-
{
|
| 240 |
-
"key": "
|
| 241 |
-
"label": "Top
|
| 242 |
-
"type": "
|
| 243 |
-
"source": "odoo",
|
| 244 |
-
"default": false,
|
| 245 |
-
"description": "
|
| 246 |
-
},
|
| 247 |
-
{
|
| 248 |
-
"key": "
|
| 249 |
-
"label": "
|
| 250 |
-
"type": "int",
|
| 251 |
-
"source": "odoo",
|
| 252 |
-
"default": false,
|
| 253 |
-
"description": "
|
| 254 |
-
},
|
| 255 |
-
{
|
| 256 |
-
"key": "
|
| 257 |
-
"label": "
|
| 258 |
-
"type": "
|
| 259 |
-
"source": "odoo",
|
| 260 |
-
"default": false,
|
| 261 |
-
"description": "
|
| 262 |
-
},
|
| 263 |
-
{
|
| 264 |
-
"key": "
|
| 265 |
-
"label": "
|
| 266 |
-
"type": "
|
| 267 |
-
"source": "
|
| 268 |
-
"default": false,
|
| 269 |
-
"description": "
|
| 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 |
-
"default": true,
|
| 301 |
-
"description": "
|
| 302 |
-
},
|
| 303 |
-
{
|
| 304 |
-
"key": "
|
| 305 |
-
"label": "
|
| 306 |
-
"type": "text",
|
| 307 |
-
"source": "odoo",
|
| 308 |
-
"default": true,
|
| 309 |
-
"description": "
|
| 310 |
-
},
|
| 311 |
-
{
|
| 312 |
-
"key": "
|
| 313 |
-
"label": "
|
| 314 |
-
"type": "
|
| 315 |
-
"source": "odoo",
|
| 316 |
-
"default":
|
| 317 |
-
"description": "
|
| 318 |
-
},
|
| 319 |
-
{
|
| 320 |
-
"key": "
|
| 321 |
-
"label": "
|
| 322 |
-
"type": "
|
| 323 |
-
"source": "
|
| 324 |
-
"default": true,
|
| 325 |
-
"description": "
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_comment": "CANONICAL field contract for the AIOS Airtable-style grid β the SINGLE source of truth. Consumed by platform/aios_grid.py (embed/Space host) and aios-web/api/main.py (standalone API), and regenerated into aios-web/web/public/sample_customers.json. Edit HERE only, then run aios-web/verify_fields_contract.py. source=odoo is READ-ONLY; source=overlay is the editable stratum (notes/tags) outside Odoo. type in {text,status,select,currency,int,date,pct} (select = a fixed-choice READ-ONLY brand attribute; dba is the first, wave 2026-08-02). `description` (wave 5) is the CANONICAL per-field description β every field must carry one, and since wave 7 (owner W8, 2026-07-28) every description is ONE SHORT PLAIN sentence (two only when a fact would otherwise mislead): what the field IS, nothing else β no filter tips, no '(none)' coaching, no rationale; the user's workspace NOTE overrides it in the (i) hover, never in this file. BUILDER FACT (documented here, deliberately NOT in user-facing text): blank text attributes display as '(none)', so `is '(none)'` β not `is empty` β finds the blanks on agent/city/state/country/zip/payment_terms/pricelist/tags. filterable:false = the CONDITION BUILDER does not offer it (still displayed, still sortable); every such field must have a replacement declared in aios-web/verify_fields_contract.py. 2026-07-27 partner attributes: country/zip/payment_terms/pricelist/tags/customer_since all ship default:false. zip is TEXT because a postal code has leading zeros. Odoo's credit_limit (1% populated) and user_id salesperson (2%) are deliberately ABSENT; agent_ids is the salesperson field and AR is where credit exposure comes from. Wave-5 item 8 (2026-07-27): ltm_rev and at_risk are DELETED β LTM's replacement is a creatable Sales measure column (the demo column IS Sales Β· the last 12 months), at_risk's replacement is a formula field, e.g. MAX(0, {revenue_ly} - {revenue_ytd}). Wave-6 item 8 (2026-07-27, the no-buildable-presets rule): revenue_ytd, revenue_ly, orders_24m, aov and yoy_pct are DELETED β every one is self-buildable, so a frozen pre-set beside the builder was two ways to ask one question. Replacements (recorded in verify_fields_contract.py): creatable measure columns for Sales / Orders / Avg order $ over any period (harness/measure_filter.py ADMITTED carries revenue, orders and the composite aov), and a formula over two measure columns for YoY, e.g. ({sales_ytd} - {sales_ly}) / {sales_ly}. Stale view colIds naming the five self-heal on the next autosave (the established rule).",
|
| 3 |
+
"fields": [
|
| 4 |
+
{
|
| 5 |
+
"key": "customer",
|
| 6 |
+
"label": "Customer",
|
| 7 |
+
"type": "text",
|
| 8 |
+
"source": "odoo",
|
| 9 |
+
"pinned": true,
|
| 10 |
+
"default": true,
|
| 11 |
+
"description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
"key": "odoo_status",
|
| 15 |
+
"label": "Odoo record",
|
| 16 |
+
"type": "status",
|
| 17 |
+
"source": "odoo",
|
| 18 |
+
"default": false,
|
| 19 |
+
"description": "Whether this customer still exists in Odoo. Archived means deleted there."
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
"key": "agent",
|
| 23 |
+
"label": "Agent",
|
| 24 |
+
"type": "text",
|
| 25 |
+
"source": "odoo",
|
| 26 |
+
"default": true,
|
| 27 |
+
"description": "The sales agent who owns this account."
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"key": "dba",
|
| 31 |
+
"label": "DBA",
|
| 32 |
+
"type": "select",
|
| 33 |
+
"source": "odoo",
|
| 34 |
+
"default": false,
|
| 35 |
+
"options": [
|
| 36 |
+
"Fisch",
|
| 37 |
+
"Royal",
|
| 38 |
+
"Both"
|
| 39 |
+
],
|
| 40 |
+
"description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"key": "salesperson",
|
| 44 |
+
"label": "Salesperson",
|
| 45 |
+
"type": "text",
|
| 46 |
+
"source": "odoo",
|
| 47 |
+
"default": false,
|
| 48 |
+
"description": "Who keyed in most of this customer's orders β not the Agent, who owns the account."
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"key": "street",
|
| 52 |
+
"label": "Street",
|
| 53 |
+
"type": "text",
|
| 54 |
+
"source": "odoo",
|
| 55 |
+
"default": false,
|
| 56 |
+
"description": "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address."
|
| 57 |
+
},
|
| 58 |
+
{
|
| 59 |
+
"key": "street2",
|
| 60 |
+
"label": "Street 2",
|
| 61 |
+
"type": "text",
|
| 62 |
+
"source": "odoo",
|
| 63 |
+
"default": false,
|
| 64 |
+
"description": "Second address line (suite, unit, floor) on the customer's Odoo address."
|
| 65 |
+
},
|
| 66 |
+
{
|
| 67 |
+
"key": "city",
|
| 68 |
+
"label": "City",
|
| 69 |
+
"type": "text",
|
| 70 |
+
"source": "odoo",
|
| 71 |
+
"default": true,
|
| 72 |
+
"description": "City on the customer's Odoo address."
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"key": "state",
|
| 76 |
+
"label": "State",
|
| 77 |
+
"type": "text",
|
| 78 |
+
"source": "odoo",
|
| 79 |
+
"default": true,
|
| 80 |
+
"description": "State or province on the customer's Odoo address."
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"key": "country",
|
| 84 |
+
"label": "Country",
|
| 85 |
+
"type": "text",
|
| 86 |
+
"source": "odoo",
|
| 87 |
+
"default": false,
|
| 88 |
+
"description": "Country on the customer's Odoo address."
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"key": "zip",
|
| 92 |
+
"label": "ZIP",
|
| 93 |
+
"type": "text",
|
| 94 |
+
"source": "odoo",
|
| 95 |
+
"default": false,
|
| 96 |
+
"description": "Postal code on the customer's Odoo address."
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
"key": "customer_since",
|
| 100 |
+
"label": "Customer since",
|
| 101 |
+
"type": "date",
|
| 102 |
+
"source": "odoo",
|
| 103 |
+
"default": false,
|
| 104 |
+
"description": "When this customer was first set up in Odoo."
|
| 105 |
+
},
|
| 106 |
+
{
|
| 107 |
+
"key": "tags",
|
| 108 |
+
"label": "Tags",
|
| 109 |
+
"type": "text",
|
| 110 |
+
"source": "odoo",
|
| 111 |
+
"default": false,
|
| 112 |
+
"description": "Odoo labels on this customer, comma-separated."
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"key": "pricelist",
|
| 116 |
+
"label": "Price list",
|
| 117 |
+
"type": "text",
|
| 118 |
+
"source": "odoo",
|
| 119 |
+
"default": false,
|
| 120 |
+
"description": "The price list this customer buys on."
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"key": "payment_terms",
|
| 124 |
+
"label": "Payment terms",
|
| 125 |
+
"type": "text",
|
| 126 |
+
"source": "odoo",
|
| 127 |
+
"default": false,
|
| 128 |
+
"description": "Payment terms on this customer's account β Net 30, for example."
|
| 129 |
+
},
|
| 130 |
+
{
|
| 131 |
+
"key": "last_order",
|
| 132 |
+
"label": "Last order",
|
| 133 |
+
"type": "date",
|
| 134 |
+
"source": "odoo",
|
| 135 |
+
"default": true,
|
| 136 |
+
"description": "Date of the most recent confirmed order."
|
| 137 |
+
},
|
| 138 |
+
{
|
| 139 |
+
"key": "overdue_days",
|
| 140 |
+
"label": "Overdue days",
|
| 141 |
+
"type": "int",
|
| 142 |
+
"source": "odoo",
|
| 143 |
+
"default": true,
|
| 144 |
+
"description": "How many days late this customer is running against their own usual ordering rhythm."
|
| 145 |
+
},
|
| 146 |
+
{
|
| 147 |
+
"_note": "filterable:false β DERIVED ANALYTIC: est_missed is min(cycles missed, 3) x AOV, a score we compute rather than an object the business has, so a condition on it would read as a fact about the customer when it is a fact about our arithmetic. It still displays and still sorts. Until wave 6 this flag also covered the frozen-window presets (revenue_ytd / revenue_ly / orders_24m / aov / yoy_pct); those are now DELETED outright under the owner's no-buildable-presets rule β see _comment. est_missed itself STAYS: no creatable measure or formula reproduces the cadence model behind it.",
|
| 148 |
+
"key": "est_missed",
|
| 149 |
+
"label": "Est. missed $",
|
| 150 |
+
"type": "currency",
|
| 151 |
+
"source": "odoo",
|
| 152 |
+
"default": true,
|
| 153 |
+
"agg": "sum",
|
| 154 |
+
"filterable": false,
|
| 155 |
+
"description": "Estimated sales missed while quiet: missed orders (capped at 3) times average order value. An estimate, not money owed."
|
| 156 |
+
},
|
| 157 |
+
{
|
| 158 |
+
"_note": "wave 21 R1 β KEY UNCHANGED, LABEL RENAMED. The computation is a DISJOINT split (ar.py credit_exposure): this column is only the not-yet-due residual, its sibling is the past-grace residual, and the two sum to the total. Under the label 'AR open $' the majority-late book read as 'Overdue > Open', which is nonsense in AR vocabulary β 'open' universally means the total. The label now says what the number is; the key stays so saved views and filters keep working.",
|
| 159 |
+
"key": "ar_open",
|
| 160 |
+
"label": "AR current $",
|
| 161 |
+
"type": "currency",
|
| 162 |
+
"source": "odoo",
|
| 163 |
+
"default": false,
|
| 164 |
+
"description": "Invoiced money owed but not yet due (a 5-day grace applies before it counts as overdue)."
|
| 165 |
+
},
|
| 166 |
+
{
|
| 167 |
+
"key": "ar_overdue",
|
| 168 |
+
"label": "AR overdue $",
|
| 169 |
+
"type": "currency",
|
| 170 |
+
"source": "odoo",
|
| 171 |
+
"default": false,
|
| 172 |
+
"description": "Invoiced money past due β same basis as the Collections page."
|
| 173 |
+
},
|
| 174 |
+
{
|
| 175 |
+
"_note": "wave 21 R1 β the TOTAL, added beside the rename above. AR current $ + AR overdue $, i.e. what most people mean by 'open AR'. Composed from the same ar.credit_exposure rows the siblings use, so it is transitively reconciled by ar.validate()'s residual read_group tie β no second oracle.",
|
| 176 |
+
"key": "ar_outstanding",
|
| 177 |
+
"label": "AR outstanding $",
|
| 178 |
+
"type": "currency",
|
| 179 |
+
"source": "odoo",
|
| 180 |
+
"default": false,
|
| 181 |
+
"description": "Total invoiced money owed right now: AR current $ plus AR overdue $."
|
| 182 |
+
},
|
| 183 |
+
{
|
| 184 |
+
"key": "ar_exposure",
|
| 185 |
+
"label": "Credit exposure $",
|
| 186 |
+
"type": "currency",
|
| 187 |
+
"source": "odoo",
|
| 188 |
+
"default": false,
|
| 189 |
+
"description": "The most you could be out if they stopped paying today: open, overdue, draft and not-yet-invoiced."
|
| 190 |
+
},
|
| 191 |
+
{
|
| 192 |
+
"key": "ar_aged_1_30",
|
| 193 |
+
"label": "1-30 days $",
|
| 194 |
+
"type": "currency",
|
| 195 |
+
"source": "odoo",
|
| 196 |
+
"default": false,
|
| 197 |
+
"description": "Overdue between 1 and 30 days. The four aging buckets sum to AR overdue $."
|
| 198 |
+
},
|
| 199 |
+
{
|
| 200 |
+
"key": "ar_aged_31_60",
|
| 201 |
+
"label": "31-60 days $",
|
| 202 |
+
"type": "currency",
|
| 203 |
+
"source": "odoo",
|
| 204 |
+
"default": false,
|
| 205 |
+
"description": "Overdue between 31 and 60 days. The four aging buckets sum to AR overdue $."
|
| 206 |
+
},
|
| 207 |
+
{
|
| 208 |
+
"key": "ar_aged_61_90",
|
| 209 |
+
"label": "61-90 days $",
|
| 210 |
+
"type": "currency",
|
| 211 |
+
"source": "odoo",
|
| 212 |
+
"default": false,
|
| 213 |
+
"description": "Overdue between 61 and 90 days. The four aging buckets sum to AR overdue $."
|
| 214 |
+
},
|
| 215 |
+
{
|
| 216 |
+
"key": "ar_aged_90_plus",
|
| 217 |
+
"label": "90+ days $",
|
| 218 |
+
"type": "currency",
|
| 219 |
+
"source": "odoo",
|
| 220 |
+
"default": false,
|
| 221 |
+
"description": "Overdue by more than 90 days. The four aging buckets sum to AR overdue $."
|
| 222 |
+
},
|
| 223 |
+
{
|
| 224 |
+
"key": "days_to_pay",
|
| 225 |
+
"label": "Days to pay",
|
| 226 |
+
"type": "int",
|
| 227 |
+
"source": "odoo",
|
| 228 |
+
"default": false,
|
| 229 |
+
"description": "Average days to pay an invoice in full. Blank means no fully paid invoice yet."
|
| 230 |
+
},
|
| 231 |
+
{
|
| 232 |
+
"key": "top_category",
|
| 233 |
+
"label": "Top category",
|
| 234 |
+
"type": "text",
|
| 235 |
+
"source": "odoo",
|
| 236 |
+
"default": false,
|
| 237 |
+
"description": "The category this customer spent the most on in the last 12 months."
|
| 238 |
+
},
|
| 239 |
+
{
|
| 240 |
+
"key": "top_category_pct",
|
| 241 |
+
"label": "Top category %",
|
| 242 |
+
"type": "pct",
|
| 243 |
+
"source": "odoo",
|
| 244 |
+
"default": false,
|
| 245 |
+
"description": "Share of last-12-months spend that went to the top category."
|
| 246 |
+
},
|
| 247 |
+
{
|
| 248 |
+
"key": "sku_count",
|
| 249 |
+
"label": "SKUs bought",
|
| 250 |
+
"type": "int",
|
| 251 |
+
"source": "odoo",
|
| 252 |
+
"default": false,
|
| 253 |
+
"description": "Distinct products bought in the last 12 months."
|
| 254 |
+
},
|
| 255 |
+
{
|
| 256 |
+
"key": "top_sku",
|
| 257 |
+
"label": "Top SKU",
|
| 258 |
+
"type": "text",
|
| 259 |
+
"source": "odoo",
|
| 260 |
+
"default": false,
|
| 261 |
+
"description": "The product this customer spent the most on in the last 12 months."
|
| 262 |
+
},
|
| 263 |
+
{
|
| 264 |
+
"key": "days_since",
|
| 265 |
+
"label": "Days since order",
|
| 266 |
+
"type": "int",
|
| 267 |
+
"source": "odoo",
|
| 268 |
+
"default": false,
|
| 269 |
+
"description": "Days since the last confirmed order."
|
| 270 |
+
},
|
| 271 |
+
{
|
| 272 |
+
"key": "typical_gap_days",
|
| 273 |
+
"label": "Typical gap days",
|
| 274 |
+
"type": "int",
|
| 275 |
+
"source": "odoo",
|
| 276 |
+
"default": false,
|
| 277 |
+
"description": "Days this customer usually goes between orders, from their own history."
|
| 278 |
+
},
|
| 279 |
+
{
|
| 280 |
+
"key": "notes",
|
| 281 |
+
"label": "Notes",
|
| 282 |
+
"type": "text",
|
| 283 |
+
"source": "overlay",
|
| 284 |
+
"default": false,
|
| 285 |
+
"description": "Your notes on this customer. Saved in this app only, visible only to you."
|
| 286 |
+
}
|
| 287 |
+
],
|
| 288 |
+
"_product_comment": "ADDITIVE, wave 15 C-TOPIC. The PRODUCT table's field contract. Kept as a SEPARATE top-level key rather than restructuring `fields` into {customer_data, product_data}: both existing readers (aios_grid._load_fields, aios-web/api/main.py) index doc['fields'] directly, and reshaping that mid-wave would break the embed for a cosmetic gain. The keyed shape can arrive when both readers move in ONE commit; until then this is the product half and `fields` is the customer half.",
|
| 289 |
+
"_product_removed_buy_now": "OWNER, 2026-08-03: 'Buy signal' (key buy_now, a select of Buy now / OK) is NO LONGER A PRESET FIELD. It never earned one: it is a formula over two columns that are both still right here, and the platform has a formula field type for exactly that. THE FORMULA, which reproduces the retired column row for row (modules/product_data.validate proves the equivalence, and goes red if it ever stops holding): IF({lead_days} > 0, IF({dos} < {lead_days}, \"Buy now\", \"OK\"), \"\") . Every branch matches the old server rule, including the blanks - the formula engine refuses a comparison against a blank rather than coercing it to 0, so a SKU with no days-of-supply or no lead time comes out empty, which is 'we do not know' and not 'you are fine'. NOTE the column is still COMPUTED in product_data.pool(): it ships nowhere (rows_from_pool projects strictly through this contract, so no Field means no cell on the wire) and exists only as validate()'s oracle. A formula field is PER-USER, so nothing shared may filter on it - the Buy list view filters on dos/lead_days directly (_seed_wave17).",
|
| 290 |
+
"product_data": {
|
| 291 |
+
"identity": "pid",
|
| 292 |
+
"business_key": "code",
|
| 293 |
+
"fields": [
|
| 294 |
+
{
|
| 295 |
+
"key": "code",
|
| 296 |
+
"label": "SKU",
|
| 297 |
+
"type": "text",
|
| 298 |
+
"source": "odoo",
|
| 299 |
+
"pinned": true,
|
| 300 |
+
"default": true,
|
| 301 |
+
"description": "The SKU code β the product's real business key. `pid` is a stable CRC32 of it because the grid keys on an integer."
|
| 302 |
+
},
|
| 303 |
+
{
|
| 304 |
+
"key": "product",
|
| 305 |
+
"label": "Product",
|
| 306 |
+
"type": "text",
|
| 307 |
+
"source": "odoo",
|
| 308 |
+
"default": true,
|
| 309 |
+
"description": "Product name as it appears in Odoo."
|
| 310 |
+
},
|
| 311 |
+
{
|
| 312 |
+
"key": "category",
|
| 313 |
+
"label": "Category",
|
| 314 |
+
"type": "select",
|
| 315 |
+
"source": "odoo",
|
| 316 |
+
"default": true,
|
| 317 |
+
"description": "Product category; '(uncategorized)' when Odoo carries none."
|
| 318 |
+
},
|
| 319 |
+
{
|
| 320 |
+
"key": "supplier",
|
| 321 |
+
"label": "Supplier",
|
| 322 |
+
"type": "text",
|
| 323 |
+
"source": "overlay",
|
| 324 |
+
"default": true,
|
| 325 |
+
"description": "Who makes it. Editable here and shared with everyone in the workspace; seeded from the inventory mastersheet.",
|
| 326 |
+
"shared": true
|
| 327 |
+
},
|
| 328 |
+
{
|
| 329 |
+
"key": "origin_country",
|
| 330 |
+
"label": "Country",
|
| 331 |
+
"type": "text",
|
| 332 |
+
"source": "overlay",
|
| 333 |
+
"default": false,
|
| 334 |
+
"description": "Country of origin. Editable here and shared with everyone; seeded from the inventory mastersheet.",
|
| 335 |
+
"shared": true
|
| 336 |
+
},
|
| 337 |
+
{
|
| 338 |
+
"key": "lead_days",
|
| 339 |
+
"label": "Lead time (days)",
|
| 340 |
+
"type": "int",
|
| 341 |
+
"source": "overlay",
|
| 342 |
+
"default": true,
|
| 343 |
+
"description": "Order-to-arrival days for this supplier. Drives the buy signal. Editable and shared with everyone.",
|
| 344 |
+
"shared": true
|
| 345 |
+
},
|
| 346 |
+
{
|
| 347 |
+
"key": "first_cost",
|
| 348 |
+
"label": "First cost",
|
| 349 |
+
"type": "currency",
|
| 350 |
+
"source": "overlay",
|
| 351 |
+
"default": false,
|
| 352 |
+
"description": "Quoted unit cost at origin, before freight and duty. Editable and shared with everyone.",
|
| 353 |
+
"shared": true
|
| 354 |
+
},
|
| 355 |
+
{
|
| 356 |
+
"key": "price_fisch",
|
| 357 |
+
"label": "Fisch price",
|
| 358 |
+
"type": "currency",
|
| 359 |
+
"source": "odoo",
|
| 360 |
+
"description": "Fisch pricelist price for this SKU. Blank when that list prices it nowhere."
|
| 361 |
+
},
|
| 362 |
+
{
|
| 363 |
+
"key": "price_royal_1",
|
| 364 |
+
"label": "Royal 1 price",
|
| 365 |
+
"type": "currency",
|
| 366 |
+
"source": "odoo",
|
| 367 |
+
"description": "Royal 1 pricelist price for this SKU. Blank when that list prices it nowhere."
|
| 368 |
+
},
|
| 369 |
+
{
|
| 370 |
+
"key": "price_royal_2",
|
| 371 |
+
"label": "Royal 2 price",
|
| 372 |
+
"type": "currency",
|
| 373 |
+
"source": "odoo",
|
| 374 |
+
"description": "Royal 2 pricelist price for this SKU. Blank when that list prices it nowhere."
|
| 375 |
+
},
|
| 376 |
+
{
|
| 377 |
+
"key": "rev_ytd",
|
| 378 |
+
"label": "Revenue YTD",
|
| 379 |
+
"type": "currency",
|
| 380 |
+
"source": "odoo",
|
| 381 |
+
"default": true,
|
| 382 |
+
"description": "Year-to-date revenue for this SKU, BU-scoped when the caller is."
|
| 383 |
+
},
|
| 384 |
+
{
|
| 385 |
+
"key": "rev_ly",
|
| 386 |
+
"label": "Revenue LY",
|
| 387 |
+
"type": "currency",
|
| 388 |
+
"source": "odoo",
|
| 389 |
+
"description": "Same period last year β seasonal wholesale compares like for like."
|
| 390 |
+
},
|
| 391 |
+
{
|
| 392 |
+
"key": "yoy_pct",
|
| 393 |
+
"label": "YoY %",
|
| 394 |
+
"type": "pct",
|
| 395 |
+
"source": "odoo",
|
| 396 |
+
"description": "Year-over-year change; null when last year was zero (a ratio to zero is not a number)."
|
| 397 |
+
},
|
| 398 |
+
{
|
| 399 |
+
"key": "qty_ytd",
|
| 400 |
+
"label": "Units YTD",
|
| 401 |
+
"type": "int",
|
| 402 |
+
"source": "odoo",
|
| 403 |
+
"description": "Units sold year to date."
|
| 404 |
+
},
|
| 405 |
+
{
|
| 406 |
+
"key": "orders_ytd",
|
| 407 |
+
"label": "Orders YTD",
|
| 408 |
+
"type": "int",
|
| 409 |
+
"source": "odoo",
|
| 410 |
+
"description": "Distinct orders containing this SKU, year to date."
|
| 411 |
+
},
|
| 412 |
+
{
|
| 413 |
+
"key": "on_hand",
|
| 414 |
+
"label": "On hand",
|
| 415 |
+
"type": "int",
|
| 416 |
+
"source": "odoo",
|
| 417 |
+
"description": "Units in stock. CONSOLIDATED β one physical warehouse, not brand-tagged, so this column is ABSENT for a BU-scoped caller rather than silently company-wide."
|
| 418 |
+
},
|
| 419 |
+
{
|
| 420 |
+
"key": "unit_cost",
|
| 421 |
+
"label": "Unit cost",
|
| 422 |
+
"type": "currency",
|
| 423 |
+
"source": "odoo",
|
| 424 |
+
"description": "Inventory unit cost. Consolidated; absent for a BU-scoped caller."
|
| 425 |
+
},
|
| 426 |
+
{
|
| 427 |
+
"key": "inv_value",
|
| 428 |
+
"label": "Stock value",
|
| 429 |
+
"type": "currency",
|
| 430 |
+
"source": "odoo",
|
| 431 |
+
"description": "On-hand value at cost. Consolidated; absent for a BU-scoped caller."
|
| 432 |
+
},
|
| 433 |
+
{
|
| 434 |
+
"key": "qty_ltm",
|
| 435 |
+
"label": "Units LTM",
|
| 436 |
+
"type": "int",
|
| 437 |
+
"source": "odoo",
|
| 438 |
+
"description": "Units sold in the last twelve months. Consolidated; absent for a BU-scoped caller."
|
| 439 |
+
},
|
| 440 |
+
{
|
| 441 |
+
"key": "dos",
|
| 442 |
+
"label": "Days of supply",
|
| 443 |
+
"type": "int",
|
| 444 |
+
"source": "odoo",
|
| 445 |
+
"description": "Days of supply at the LTM rate; null means it never sells through. Consolidated; absent for a BU-scoped caller."
|
| 446 |
+
},
|
| 447 |
+
{
|
| 448 |
+
"key": "cover_gap_d",
|
| 449 |
+
"label": "Cover gap (days)",
|
| 450 |
+
"type": "int",
|
| 451 |
+
"source": "odoo",
|
| 452 |
+
"default": false,
|
| 453 |
+
"description": "Days of supply minus lead time. Negative means it runs out before a reorder lands."
|
| 454 |
+
},
|
| 455 |
+
{
|
| 456 |
+
"key": "stock_bucket",
|
| 457 |
+
"label": "Stock status",
|
| 458 |
+
"type": "select",
|
| 459 |
+
"source": "odoo",
|
| 460 |
+
"description": "Dead / excess / healthy bucket from the inventory module. Consolidated; absent for a BU-scoped caller."
|
| 461 |
+
}
|
| 462 |
+
]
|
| 463 |
+
}
|
| 464 |
+
}
|
platform/core/grid_events.py
CHANGED
|
@@ -255,6 +255,64 @@ def _cohorts(ctx):
|
|
| 255 |
return cohort_mod.scoped(ctx.scope_key)
|
| 256 |
|
| 257 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
def _docs_key(ctx):
|
| 259 |
"""The DOCUMENTS bucket this ctx operates on β `_cohorts`'s sibling (wave 19, item 12's
|
| 260 |
silent third case).
|
|
|
|
| 255 |
return cohort_mod.scoped(ctx.scope_key)
|
| 256 |
|
| 257 |
|
| 258 |
+
#: The keys a stored document entry may put on the wire. β `path` IS NOT ONE OF THEM β it is the
|
| 259 |
+
#: byte address inside the tenant's repo, of no use to a browser and a needless disclosure of the
|
| 260 |
+
#: store's layout. `canDelete` is not stored either: it is a fact about the READER, computed per
|
| 261 |
+
#: request below, so one person's answer can never be cached into another's.
|
| 262 |
+
_DOC_WIRE_KEYS = ('id', 'name', 'mime', 'size', 'by', 'ts')
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def docs_for(pids, scope_key='customer', uname='', admin=False, st=None):
|
| 266 |
+
"""`{"<pid>": [CustomerDoc, ...]}` for a SCOPED row set β the producer `payload.docs` lost.
|
| 267 |
+
|
| 268 |
+
ββ WAVE 30 / CONTRACT C4 (D-138). The write door has been live and UNREACHABLE since EXIT-6:
|
| 269 |
+
`doc_add`/`doc_fetch`/`doc_delete` all still work, and the comment inside that handler says
|
| 270 |
+
exactly why nobody noticed β *"the React shell cannot reach it yet (`payload.docs` is set only
|
| 271 |
+
by app.py)"*. `app.py` was deleted, and the producer went with it. Every client half survived:
|
| 272 |
+
`RecordDetail`, `SwipeView` and `Documents` are complete, and all six `onDoc*` handlers in
|
| 273 |
+
`CustomerGrid.tsx` are written as `payload?.docs ? β¦ : undefined`, so an ABSENT `docs` key is
|
| 274 |
+
what has been switching the whole feature off. An empty dict is truthy and turns it on with an
|
| 275 |
+
honest empty state.
|
| 276 |
+
β THIS IS THE SIXTH INSTANCE OF THE REACHABILITY CLASS and the one `verify_reachability.py`
|
| 277 |
+
cannot see β a payload key assembled through helpers and dict merges is not a text-matchable
|
| 278 |
+
literal ([[reachable-is-not-the-same-as-built]], [[artifact-with-no-importer]]).
|
| 279 |
+
|
| 280 |
+
β ONE READER, NOT A MATCHING PAIR. `routes_tables` (ut_*) and `routes_customers` (the customer
|
| 281 |
+
scope) both call THIS β they do not each serialise. Two surfaces answering one question
|
| 282 |
+
separately is how the same defect got reintroduced in the opposite direction inside a single
|
| 283 |
+
commit ([[one-question-two-normalizers]]).
|
| 284 |
+
|
| 285 |
+
β `pids` IS REQUIRED and is the row set the CALLER has already scoped β `shared_overlay.cells`'
|
| 286 |
+
argument, for the same reason: there is no "every document in the tenant" call to reach for.
|
| 287 |
+
A record with no documents gets `[]`, never a missing key, so a client can tell "none" from
|
| 288 |
+
"not served".
|
| 289 |
+
"""
|
| 290 |
+
wanted = {str(int(p)) for p in (pids or [])}
|
| 291 |
+
if not wanted:
|
| 292 |
+
return {}
|
| 293 |
+
scope = str(scope_key or 'customer').strip().lower()
|
| 294 |
+
key = _DOCS_KEY if scope in cohort_mod.LEGACY_SCOPES else f'{scope}_docs'
|
| 295 |
+
try:
|
| 296 |
+
stored = (st if st is not None else store).get(key) or {}
|
| 297 |
+
except Exception: # noqa: BLE001
|
| 298 |
+
stored = {} # a display read degrades; it never breaks the grid
|
| 299 |
+
out = {}
|
| 300 |
+
for pid in wanted:
|
| 301 |
+
row = stored.get(pid)
|
| 302 |
+
docs = []
|
| 303 |
+
for d in (row if isinstance(row, list) else []):
|
| 304 |
+
if not isinstance(d, dict) or not d.get('id'):
|
| 305 |
+
continue
|
| 306 |
+
wire = {k: d.get(k) for k in _DOC_WIRE_KEYS}
|
| 307 |
+
# The delete wall, mirrored onto the wire so the button is absent rather than
|
| 308 |
+
# offered-and-refused. `doc_delete` enforces the same test server-side; this is the
|
| 309 |
+
# DISPLAY of that rule, never a substitute for it.
|
| 310 |
+
wire['canDelete'] = bool(admin or (d.get('by') and d.get('by') == uname))
|
| 311 |
+
docs.append(wire)
|
| 312 |
+
out[pid] = docs
|
| 313 |
+
return out
|
| 314 |
+
|
| 315 |
+
|
| 316 |
def _docs_key(ctx):
|
| 317 |
"""The DOCUMENTS bucket this ctx operates on β `_cohorts`'s sibling (wave 19, item 12's
|
| 318 |
silent third case).
|
platform/core/user_tables.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
platform/harness/datastore.py
CHANGED
|
@@ -19,6 +19,7 @@ Design rules:
|
|
| 19 |
import datetime as dt
|
| 20 |
import json
|
| 21 |
import os
|
|
|
|
| 22 |
import sys
|
| 23 |
import time
|
| 24 |
from pathlib import Path
|
|
@@ -178,12 +179,23 @@ ENTITIES = {
|
|
| 178 |
# β It is synced HERE rather than read from live Odoo because `read_customers` also runs
|
| 179 |
# at BOOT off a hydrated snapshot, where Odoo may be unreachable β sourcing it live would
|
| 180 |
# make the population "whatever source answered this time", changing size with the network.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
"fields": ["name", "team_id", "city", "state_id", "country_id", "active",
|
| 182 |
-
"agent", "salesman_as_agent", "customer_rank",
|
|
|
|
| 183 |
# `backfill_fields` carries it too, so mirrors that already exist pick it up on the next
|
| 184 |
# sync_all() instead of needing a reseed.
|
| 185 |
"backfill_fields": ["agent", "salesman_as_agent", # added 2026-07-28
|
| 186 |
-
"customer_rank"
|
|
|
|
| 187 |
# m2m fields land as <base>_id (the FIRST linked id β the Customers-module convention:
|
| 188 |
# a customer's assigned agent is agent_ids[0], ~one per customer, MECE) + <field> as a
|
| 189 |
# JSON list of ALL ids. Declared explicitly: a 2-element m2m read would otherwise be
|
|
@@ -387,6 +399,127 @@ def ro_con():
|
|
| 387 |
return cur
|
| 388 |
|
| 389 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
SEED_DATASET = os.environ.get("STORE_SEED_DATASET", "royal-imports/cfo-os-data")
|
| 391 |
|
| 392 |
|
|
|
|
| 19 |
import datetime as dt
|
| 20 |
import json
|
| 21 |
import os
|
| 22 |
+
import re
|
| 23 |
import sys
|
| 24 |
import time
|
| 25 |
from pathlib import Path
|
|
|
|
| 179 |
# β It is synced HERE rather than read from live Odoo because `read_customers` also runs
|
| 180 |
# at BOOT off a hydrated snapshot, where Odoo may be unreachable β sourcing it live would
|
| 181 |
# make the population "whatever source answered this time", changing size with the network.
|
| 182 |
+
# β WAVE 30 (session E's ask, W30-T33). `street`/`street2`/`zip` are the POSTAL half of
|
| 183 |
+
# an address the mirror has never carried: it holds `city`, `state_id` and `country_id`
|
| 184 |
+
# and stops there, so `odoo_relational.read_customers` β which reads only the mirror β
|
| 185 |
+
# cannot serve a full address at all, however the column is declared.
|
| 186 |
+
# β DECLARING THEM DOES NOT FILL THEM, and the difference is a whole sync: an existing
|
| 187 |
+
# mirror ALTERs the columns in on the next `sync_all()` and leaves them NULL until the
|
| 188 |
+
# backfill below has walked every partner (a partner's `write_date` did not move because
|
| 189 |
+
# WE changed the schema). Every projection degrades a missing/NULL column to blank rather
|
| 190 |
+
# than raising, so declaring early is safe β it is just not yet true.
|
| 191 |
"fields": ["name", "team_id", "city", "state_id", "country_id", "active",
|
| 192 |
+
"agent", "salesman_as_agent", "customer_rank",
|
| 193 |
+
"street", "street2", "zip", "write_date"],
|
| 194 |
# `backfill_fields` carries it too, so mirrors that already exist pick it up on the next
|
| 195 |
# sync_all() instead of needing a reseed.
|
| 196 |
"backfill_fields": ["agent", "salesman_as_agent", # added 2026-07-28
|
| 197 |
+
"customer_rank", # added 2026-08-11 (W29-T54)
|
| 198 |
+
"street", "street2", "zip"], # added 2026-08-12 (W30-T33)
|
| 199 |
# m2m fields land as <base>_id (the FIRST linked id β the Customers-module convention:
|
| 200 |
# a customer's assigned agent is agent_ids[0], ~one per customer, MECE) + <field> as a
|
| 201 |
# JSON list of ALL ids. Declared explicitly: a 2-element m2m read would otherwise be
|
|
|
|
| 399 |
return cur
|
| 400 |
|
| 401 |
|
| 402 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 403 |
+
# β WAVE 30 / OWNER RULING R6 + R7 β THE WINDOW. Read this before touching `window()`.
|
| 404 |
+
#
|
| 405 |
+
# R6, verbatim: *"there is no cap in how many data from the API source (as long as its from a
|
| 406 |
+
# connected source like Odoo) that can be pulled into the app."* R7 says how: a connected grid
|
| 407 |
+
# reads THROUGH this mirror instead of copying rows into the one `user_tables` document, which
|
| 408 |
+
# `MAX_ROWS = 60_000` bounds. The mirror is already uncapped β 971,034 GL lines live in this file
|
| 409 |
+
# today β so "no cap" is not a bigger number, it is a different mechanism: serve a SLICE and count
|
| 410 |
+
# the whole.
|
| 411 |
+
#
|
| 412 |
+
# ββ `total` IS A `SELECT count(*)` OVER THE SAME PREDICATE, NEVER `len(rows)`. A window whose
|
| 413 |
+
# count is its own length is a fabricated aggregate that reads as authoritative β the class this
|
| 414 |
+
# repo has paid for twice ([[no-unverifiable-aggregates]], [[one-question-two-normalizers]]: a
|
| 415 |
+
# display predicate and a fold predicate answering one question differently). The two statements
|
| 416 |
+
# below are built from ONE `where` + ONE `params` tuple for exactly that reason; they cannot drift
|
| 417 |
+
# apart without deleting a line.
|
| 418 |
+
#
|
| 419 |
+
# β AND THE PREDICATE PUSHES DOWN. `where` is compiled by `harness/filter_sql.py` β the same
|
| 420 |
+
# compiler the client's filter engine is held in step with β so a filter matching rows outside the
|
| 421 |
+
# loaded window still COUNTS them. A caller that filters the returned list instead has silently
|
| 422 |
+
# asked "how many of the 200 rows in memory match" about a 971,034-row table.
|
| 423 |
+
#
|
| 424 |
+
# β `order_by` IS REQUIRED IN PRACTICE AND DEFAULTED HERE. Two pages of an UNORDERED window are
|
| 425 |
+
# not guaranteed to partition the table: DuckDB may legally return a row on page 1 and again on
|
| 426 |
+
# page 2, and the user sees a duplicate with no error anywhere. The default is the physical
|
| 427 |
+
# rowid-ish `1` only when a caller genuinely has no key; every real caller passes one.
|
| 428 |
+
#
|
| 429 |
+
# β TRUST BOUNDARY. `table`, `select`, `where` and `order_by` are SQL we author (a spec row, or
|
| 430 |
+
# `filter_sql`'s output over a whitelisted column map). Every VALUE is bound through `params`.
|
| 431 |
+
# `table` is additionally shape-checked below β not because a caller is hostile, but because a
|
| 432 |
+
# typo'd identifier interpolated into two statements is worth one cheap assertion.
|
| 433 |
+
|
| 434 |
+
#: The most rows ONE request may carry back. Not a cap on the data β every row is reachable by
|
| 435 |
+
#: paging and `total` always tells the truth about how many there are β but a bound on the memory
|
| 436 |
+
#: a single response can cost. R6's second sentence applies: a caller that asks for more is CLAMPED
|
| 437 |
+
#: and the clamp is REPORTED (`routes_odoo_tables` turns it into a `limits` entry), never silent.
|
| 438 |
+
WINDOW_MAX = 5_000
|
| 439 |
+
|
| 440 |
+
#: What a caller gets when it names no window at all. Matches `semantic.store_rows`' own default so
|
| 441 |
+
#: the two windowed readers in this codebase do not disagree about what "a page" means.
|
| 442 |
+
WINDOW_DEFAULT = 200
|
| 443 |
+
|
| 444 |
+
_IDENT_OK = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def window(table=None, select=None, where=None, params=(), order_by=None,
|
| 448 |
+
offset=0, limit=WINDOW_DEFAULT, cur=None, from_sql=None):
|
| 449 |
+
"""One page of a mirror table PLUS the true count of everything the same predicate matches.
|
| 450 |
+
|
| 451 |
+
Returns `{"rows": [tuple, ...], "columns": [name, ...], "total": int, "offset": int,
|
| 452 |
+
"limit": int, "clamped": bool}` β `rows` is the slice, `total` is the population.
|
| 453 |
+
|
| 454 |
+
`select` is the projection expression list ("id, name, amount_untaxed"); `where` is a WHERE
|
| 455 |
+
body with `?` placeholders; `params` binds them, and is used by BOTH statements.
|
| 456 |
+
|
| 457 |
+
β `table` and `from_sql` are the SAME argument wearing two trust levels, and they are separate
|
| 458 |
+
names on purpose. `table` is an identifier and is SHAPE-CHECKED; `from_sql` is a whole FROM
|
| 459 |
+
body β a parenthesised subquery with an alias β which cannot be checked at all. Two of the
|
| 460 |
+
eight connected grids need one (`read_customers` and `read_agents` are single statements whose
|
| 461 |
+
population is a SQL `UNION`), so the capability has to exist; naming it distinctly means a
|
| 462 |
+
reviewer can grep for every caller that hands over raw FROM SQL instead of inferring it.
|
| 463 |
+
"""
|
| 464 |
+
if bool(table) == bool(from_sql):
|
| 465 |
+
raise ValueError("datastore.window: pass exactly one of `table` or `from_sql`")
|
| 466 |
+
if from_sql:
|
| 467 |
+
name = str(from_sql).strip()
|
| 468 |
+
else:
|
| 469 |
+
name = str(table or "").strip()
|
| 470 |
+
if not _IDENT_OK.match(name):
|
| 471 |
+
raise ValueError(f"datastore.window: {table!r} is not a table identifier")
|
| 472 |
+
proj = str(select or "").strip()
|
| 473 |
+
if not proj:
|
| 474 |
+
raise ValueError("datastore.window: a projection is required β there is no implicit *")
|
| 475 |
+
try:
|
| 476 |
+
offset = max(0, int(offset or 0))
|
| 477 |
+
except (TypeError, ValueError):
|
| 478 |
+
offset = 0
|
| 479 |
+
try:
|
| 480 |
+
limit = int(limit if limit is not None else WINDOW_DEFAULT)
|
| 481 |
+
except (TypeError, ValueError):
|
| 482 |
+
limit = WINDOW_DEFAULT
|
| 483 |
+
clamped = limit > WINDOW_MAX or limit < 1
|
| 484 |
+
limit = min(max(limit, 1), WINDOW_MAX)
|
| 485 |
+
|
| 486 |
+
params = tuple(params or ())
|
| 487 |
+
pred = str(where or "").strip()
|
| 488 |
+
tail = f" WHERE {pred}" if pred else ""
|
| 489 |
+
order = str(order_by or "").strip() or "1"
|
| 490 |
+
cur = cur if cur is not None else ro_con()
|
| 491 |
+
|
| 492 |
+
# β THE COUNT RUNS FIRST AND OVER THE SAME `tail` + `params`. Ordering it first is deliberate:
|
| 493 |
+
# if the projection is ever wrong, the caller fails LOUDLY on the rows query rather than
|
| 494 |
+
# quietly returning a good count beside a broken page.
|
| 495 |
+
total = int(cur.execute(f"SELECT count(*) FROM {name}{tail}", params).fetchone()[0] or 0)
|
| 496 |
+
got = cur.execute(
|
| 497 |
+
f"SELECT {proj} FROM {name}{tail} ORDER BY {order} LIMIT {int(limit)} OFFSET {int(offset)}",
|
| 498 |
+
params).fetchall()
|
| 499 |
+
cols = [d[0] for d in (cur.description or [])]
|
| 500 |
+
return {"rows": [tuple(r) for r in got], "columns": cols,
|
| 501 |
+
"total": total, "offset": offset, "limit": limit, "clamped": clamped}
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
def columns_of(table, cur=None):
|
| 505 |
+
"""The column names a mirror table actually has, lowercased.
|
| 506 |
+
|
| 507 |
+
β A mirror can be `ready()` and still be MISSING COLUMNS: `ready()` reads entity PHASES, while
|
| 508 |
+
`_ensure_columns`/`_backfill_columns` checkpoint separately. Asking before projecting is what
|
| 509 |
+
stops a DuckDB `BinderException: Referenced column β¦ not found` reaching a user as a bare 500
|
| 510 |
+
(it already cost one live, which is why `odoo_relational.columns` exists on the app side).
|
| 511 |
+
"""
|
| 512 |
+
name = str(table or "").strip()
|
| 513 |
+
if not _IDENT_OK.match(name):
|
| 514 |
+
raise ValueError(f"datastore.columns_of: {table!r} is not a table identifier")
|
| 515 |
+
cur = cur if cur is not None else ro_con()
|
| 516 |
+
try:
|
| 517 |
+
rows = cur.execute(f"PRAGMA table_info('{name}')").fetchall()
|
| 518 |
+
except Exception: # noqa: BLE001
|
| 519 |
+
return set()
|
| 520 |
+
return {str(r[1]).lower() for r in rows}
|
| 521 |
+
|
| 522 |
+
|
| 523 |
SEED_DATASET = os.environ.get("STORE_SEED_DATASET", "royal-imports/cfo-os-data")
|
| 524 |
|
| 525 |
|
platform/modules/customer_data.py
CHANGED
|
@@ -8,8 +8,11 @@ The page is a customer-list WORKBENCH in two parts:
|
|
| 8 |
metric columns) plus hand-picked members. 'Call list' and 'Win-back' ship as TEMPLATES β
|
| 9 |
the exact formulas the old My Day queues used β and the user can change or reset them.
|
| 10 |
|
| 11 |
-
Composes already-validated customers-module functions
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
| 13 |
({username: {list_name: {rules, sort, members, note}}}); templates are virtual until edited.
|
| 14 |
|
| 15 |
The userβagent link (users.py 'agent' field) scopes the pool to that agent's book; users with
|
|
@@ -247,6 +250,130 @@ def pool_first(agent_name=None, team_id=None, limit=100):
|
|
| 247 |
return _pool_build(agent_name, team_id, limit=limit, fast=True)
|
| 248 |
|
| 249 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
def _dba_attrs(pids, t):
|
| 251 |
"""{pid: 'Fisch' | 'Royal' | 'Both'} β which brand(s) a customer's confirmed orders carry
|
| 252 |
over the pool's own 24-month universe (C-DBA, wave 2026-08-02).
|
|
@@ -433,7 +560,10 @@ def _pool_build(agent_name, team_id, limit, fast=False):
|
|
| 433 |
'customer': tv.get('name') or lv.get('name')
|
| 434 |
or (ltm.get(pid) or {}).get('name') or extra_names.get(pid) or '?',
|
| 435 |
'status': status,
|
| 436 |
-
'agent': a.get('agent', '(none)'),
|
|
|
|
|
|
|
|
|
|
| 437 |
# C-DBA: blank (not '(none)') β a select's blank is its own honest empty state.
|
| 438 |
'dba': dba_a.get(pid, ''),
|
| 439 |
'state': a.get('state', '(none)'),
|
|
@@ -541,7 +671,13 @@ def _reconcile_ledger(rows, total, agent_name, team_id):
|
|
| 541 |
# Everything else is genuinely UNKNOWN now β the record is gone. Blank is the
|
| 542 |
# honest rendering; carrying the last-known numbers forward would state figures
|
| 543 |
# as current that nothing can reconcile ([[no-unverifiable-aggregates]]).
|
| 544 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 545 |
'zip': '(none)', 'payment_terms': '(none)', 'customer_since': '',
|
| 546 |
'_created': '', 'tags': '(none)', 'pricelist': '(none)',
|
| 547 |
'ar_open': 0.0, 'ar_overdue': 0.0, 'ar_outstanding': 0.0, 'ar_exposure': 0.0,
|
|
|
|
| 8 |
metric columns) plus hand-picked members. 'Call list' and 'Win-back' ship as TEMPLATES β
|
| 9 |
the exact formulas the old My Day queues used β and the user can change or reset them.
|
| 10 |
|
| 11 |
+
Composes already-validated customers-module functions, so the page's counts derive FROM the
|
| 12 |
+
lists it renders (rule 8b). β **This header used to end "no separate validate()" and W30-T33
|
| 13 |
+
made that false** β see `validate()` below. The claim held only while every column composed an
|
| 14 |
+
already-reconciled customers-module function; the ADDRESS family does not, so it needed its own
|
| 15 |
+
tie. Persistence: HF store key 'customer_lists'
|
| 16 |
({username: {list_name: {rules, sort, members, note}}}); templates are virtual until edited.
|
| 17 |
|
| 18 |
The userβagent link (users.py 'agent' field) scopes the pool to that agent's book; users with
|
|
|
|
| 250 |
return _pool_build(agent_name, team_id, limit=limit, fast=True)
|
| 251 |
|
| 252 |
|
| 253 |
+
# ------------------------------------------------------------------ the address reconciliation
|
| 254 |
+
#: {our pool key: the `res.partner` field an Odoo domain filters on}. The two m2o columns are
|
| 255 |
+
#: stored here as RESOLVED NAMES (`O.m2o_name`), so the oracle asks Odoo whether the id is SET
|
| 256 |
+
#: while we count whether the name is non-blank β a set id resolving to an empty name is the one
|
| 257 |
+
#: place those two questions could come apart, and the per-key leg is what would report it.
|
| 258 |
+
ADDRESS_FIELDS = {
|
| 259 |
+
'street': 'street', 'street2': 'street2', 'city': 'city',
|
| 260 |
+
'state': 'state_id', 'country': 'country_id', 'zip': 'zip',
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
#: OUR blank. `_partner_attrs` collapses every partner attribute to '(none)' when blank (MECE, so
|
| 264 |
+
#: a group-by has no null bucket), a pool row for a partner with NO attrs takes the same sentinel
|
| 265 |
+
#: from the row template, and `_reconcile_ledger`'s revived rows are written '(none)' outright.
|
| 266 |
+
_ADDRESS_BLANK = ('(none)', '', None)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def address_blank(value):
|
| 270 |
+
"""OUR blank predicate, in ONE place β the fold and the oracle must ask ONE question.
|
| 271 |
+
|
| 272 |
+
β `.strip()` is part of it, and that is the half that diverges from Odoo. A domain
|
| 273 |
+
`('street', '!=', False)` counts a whitespace-only street as PRESENT while this counts it as
|
| 274 |
+
blank, so the two normalizers would disagree by exactly the whitespace-only population.
|
| 275 |
+
MEASURED 2026-08-12 across `street`/`street2`/`city`/`zip` over the whole pool: **0**. The
|
| 276 |
+
leg that keeps it 0 is asserted in `validate()` rather than assumed
|
| 277 |
+
([[one-question-two-normalizers]]).
|
| 278 |
+
"""
|
| 279 |
+
return value in _ADDRESS_BLANK or (isinstance(value, str) and not value.strip())
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def validate(team_id=None):
|
| 283 |
+
"""Reconcile the ADDRESS family the Customer grid ships to an independent Odoo aggregate.
|
| 284 |
+
|
| 285 |
+
β THE POINT OF THIS FUNCTION IS THAT THE NUMBER SURVIVES THE SESSION THAT TOOK IT. W30-T33
|
| 286 |
+
shipped `street`/`street2` after measuring their coverage with a throwaway shell one-liner β
|
| 287 |
+
a measurement, never a `validate()` β so nothing in the repo re-checked it and the figure
|
| 288 |
+
died with that session. The house law is *every metric gets a `validate()` reconciling to an
|
| 289 |
+
INDEPENDENT Odoo aggregate*, and an oracle nobody re-runs rots exactly like a mode nobody
|
| 290 |
+
runs ([[rules-need-gates]]). `aios-web/api/verify_odoo_relational.py` is what re-runs it.
|
| 291 |
+
|
| 292 |
+
β THE TRAP THAT REDS A CORRECT LEG, and it is why every domain below is scoped to the pool's
|
| 293 |
+
OWN pid set: **the customer pool is the WHOLE BOOK**, not `customer_rank > 0 AND active`
|
| 294 |
+
([[customer-grid-pool-vs-odoo-count]]). The 3,617/3,478 pair quoted in the wave-30 mailbox is
|
| 295 |
+
the rank>0-active population and is NOT this one; measured here the same day, the pool holds
|
| 296 |
+
3,629 rows of which 3,487 carry a street. Comparing our count against a differently-scoped
|
| 297 |
+
Odoo count is a true measurement of the wrong subject [[measure-the-real-call]].
|
| 298 |
+
|
| 299 |
+
β IT BUILDS THE FULL `pool()`, NOT `pool_first`/`fast=True`, AND THE COST IS REAL β **444 s
|
| 300 |
+
measured 2026-08-12** (the address itself is ~14 s of that; AR, product mix, salesperson,
|
| 301 |
+
coords and DBA are the rest). Reported rather than optimised away, per R6's second sentence.
|
| 302 |
+
`fast=True` is not the shipping basis: it skips `_reconcile_ledger`, so the RETAINED customers
|
| 303 |
+
(I13) never enter and the population is a different one. The recommended fix if this ever has
|
| 304 |
+
to get cheap is a `families=` argument on `_pool_build` so a reconciliation can ask for the
|
| 305 |
+
families it reconciles β NOT a second cheaper pool builder, which would be two evaluators for
|
| 306 |
+
one question [[one-evaluator-per-question]].
|
| 307 |
+
"""
|
| 308 |
+
rows = pool(team_id=team_id)
|
| 309 |
+
pids = [int(r['pid']) for r in rows if r.get('pid')]
|
| 310 |
+
odoo = O.get_odoo()
|
| 311 |
+
checks = []
|
| 312 |
+
|
| 313 |
+
# ββ the population, and the three numbers that keep the address legs honest βββββββββββββββ
|
| 314 |
+
# An Odoo `search` excludes archived records by default, so `n_visible` counts the partners
|
| 315 |
+
# that are BOTH live and ours. Three populations therefore ride the pool and only one of them
|
| 316 |
+
# can carry an address: rows Odoo can see, rows `_reconcile_ledger` REVIVED (I13 β Odoo
|
| 317 |
+
# dropped them; every attribute is written '(none)'), and rows that are neither, i.e. archived
|
| 318 |
+
# in Odoo but never retained. The third is the one that grows silently, so it is REPORTED as
|
| 319 |
+
# a number rather than absorbed into a tolerance.
|
| 320 |
+
revived = [r for r in rows if r.get('odoo_status') != 'Active']
|
| 321 |
+
rpids = [int(r['pid']) for r in revived if r.get('pid')]
|
| 322 |
+
n_visible = odoo.search_count('res.partner', [('id', 'in', pids)])
|
| 323 |
+
# β ASSERTED, NOT ASSUMED. "The retained rows are the ones Odoo lost" is trivially true when
|
| 324 |
+
# checked against the ledger that did the retaining β that is the self-sealing shape. So it is
|
| 325 |
+
# asked of ODOO: a retained pid Odoo can still see means `_reconcile_ledger` buried a LIVE
|
| 326 |
+
# customer's address behind six '(none)' cells.
|
| 327 |
+
still_live = odoo.search_count('res.partner', [('id', 'in', rpids)]) if rpids else 0
|
| 328 |
+
checks.append({
|
| 329 |
+
'check': "every RETAINED customer (I13) is one Odoo genuinely cannot see β asked of "
|
| 330 |
+
"Odoo, never of the ledger that did the retaining",
|
| 331 |
+
'ours': still_live, 'theirs': 0, 'ok': still_live == 0,
|
| 332 |
+
'detail': {'pool_rows': len(rows), 'odoo_visible': n_visible, 'retained': len(revived),
|
| 333 |
+
'archived_not_retained': len(pids) - n_visible - len(revived)},
|
| 334 |
+
})
|
| 335 |
+
|
| 336 |
+
# ββ the six address columns, each against a domain Odoo evaluates server-side βββββββββββββ
|
| 337 |
+
# β `search_count` WITH A DOMAIN, never a re-read-and-recount: it makes ODOO do the filtering,
|
| 338 |
+
# so the oracle cannot inherit a bug from `_partner_attrs`, which is the code under test.
|
| 339 |
+
for key, field in ADDRESS_FIELDS.items():
|
| 340 |
+
ours = sum(1 for r in rows if not address_blank(r.get(key)))
|
| 341 |
+
theirs = odoo.search_count('res.partner', [('id', 'in', pids), (field, '!=', False)])
|
| 342 |
+
checks.append({
|
| 343 |
+
'check': f"the grid's `{key}` column is non-blank on exactly the pool customers Odoo "
|
| 344 |
+
f"says carry `{field}` β an INDEPENDENT search_count over the pool's own "
|
| 345 |
+
f"pid set, never a count of the rows we just built",
|
| 346 |
+
'ours': ours, 'theirs': theirs, 'ok': ours == theirs,
|
| 347 |
+
# `column` is what the GATE matches on. Substring-matching the prose is how a leg
|
| 348 |
+
# silently stops being checked when somebody rewords it, and this file's own twin
|
| 349 |
+
# (`verify_product_pool`) matches on prose precisely because it had no key to use.
|
| 350 |
+
'detail': {'column': key, 'odoo_field': field, 'blank_here': len(rows) - ours},
|
| 351 |
+
})
|
| 352 |
+
|
| 353 |
+
# ββ the two normalizers are ONE question, and this is the leg that keeps them one βββββββββ
|
| 354 |
+
# Everything above compares OUR `.strip() or '(none)'` against ODOO'S `!= False`. Those agree
|
| 355 |
+
# on every value except a whitespace-only one, which Odoo reports present and this module
|
| 356 |
+
# renders blank. Measured 0 today; if it ever stops being 0 the per-key legs above go red for
|
| 357 |
+
# a reason that has nothing to do with the address being wrong, so the cause is named HERE.
|
| 358 |
+
text_keys = [k for k, f in ADDRESS_FIELDS.items() if f == k]
|
| 359 |
+
ws = {}
|
| 360 |
+
if pids:
|
| 361 |
+
# `O.search_read`, the same door `_partner_attrs` reads through β the client's own method
|
| 362 |
+
# takes `domain=`/`fields=` as keywords and this wrapper is the one with a fixed shape.
|
| 363 |
+
for got in O.search_read('res.partner', [('id', 'in', pids)], text_keys):
|
| 364 |
+
for k in text_keys:
|
| 365 |
+
v = got.get(k)
|
| 366 |
+
if isinstance(v, str) and v and not v.strip():
|
| 367 |
+
ws[k] = ws.get(k, 0) + 1
|
| 368 |
+
checks.append({
|
| 369 |
+
'check': "no pool customer carries a WHITESPACE-ONLY address value β the one input on "
|
| 370 |
+
"which our blank predicate and Odoo's `!= False` provably disagree",
|
| 371 |
+
'ours': sum(ws.values()), 'theirs': 0, 'ok': not ws,
|
| 372 |
+
'detail': {'per_field': ws, 'fields_checked': text_keys},
|
| 373 |
+
})
|
| 374 |
+
return checks
|
| 375 |
+
|
| 376 |
+
|
| 377 |
def _dba_attrs(pids, t):
|
| 378 |
"""{pid: 'Fisch' | 'Royal' | 'Both'} β which brand(s) a customer's confirmed orders carry
|
| 379 |
over the pool's own 24-month universe (C-DBA, wave 2026-08-02).
|
|
|
|
| 560 |
'customer': tv.get('name') or lv.get('name')
|
| 561 |
or (ltm.get(pid) or {}).get('name') or extra_names.get(pid) or '?',
|
| 562 |
'status': status,
|
| 563 |
+
'agent': a.get('agent', '(none)'),
|
| 564 |
+
# β W30-T33 (carried W29-T51) β the street lines beside the city they belong to.
|
| 565 |
+
'street': a.get('street', '(none)'), 'street2': a.get('street2', '(none)'),
|
| 566 |
+
'city': a.get('city', '(none)'),
|
| 567 |
# C-DBA: blank (not '(none)') β a select's blank is its own honest empty state.
|
| 568 |
'dba': dba_a.get(pid, ''),
|
| 569 |
'state': a.get('state', '(none)'),
|
|
|
|
| 671 |
# Everything else is genuinely UNKNOWN now β the record is gone. Blank is the
|
| 672 |
# honest rendering; carrying the last-known numbers forward would state figures
|
| 673 |
# as current that nothing can reconcile ([[no-unverifiable-aggregates]]).
|
| 674 |
+
# β W30-T33: the address keys land HERE TOO, and this is the site a field addition
|
| 675 |
+
# silently skips. `rows_from_pool` projects with `r.get(k)`, so a revived row
|
| 676 |
+
# missing a key renders BLANK rather than raising β archived customers would show
|
| 677 |
+
# an empty address column while live ones showed a full one, and nothing anywhere
|
| 678 |
+
# would report it. Two row templates, one field contract.
|
| 679 |
+
'agent': '(none)', 'street': '(none)', 'street2': '(none)',
|
| 680 |
+
'city': '(none)', 'state': '(none)', 'country': '(none)',
|
| 681 |
'zip': '(none)', 'payment_terms': '(none)', 'customer_since': '',
|
| 682 |
'_created': '', 'tags': '(none)', 'pricelist': '(none)',
|
| 683 |
'ar_open': 0.0, 'ar_overdue': 0.0, 'ar_outstanding': 0.0, 'ar_exposure': 0.0,
|
platform/modules/customers.py
CHANGED
|
@@ -573,8 +573,9 @@ DIMENSIONS = {
|
|
| 573 |
|
| 574 |
|
| 575 |
def _partner_attrs(pids):
|
| 576 |
-
"""{pid: {city, state, country, agent, zip, payment_terms, customer_since,
|
| 577 |
-
β the customer attributes the sum-level rollups slice by and the Customer
|
|
|
|
| 578 |
|
| 579 |
Agent is res.partner.agent_ids (the assigned sales agent; ~one per customer), NOT Odoo's
|
| 580 |
user_id 'salesperson' β MEASURED 2026-07-27 at 26 of 1,548 (2%), which is why agent_ids has
|
|
@@ -589,8 +590,8 @@ def _partner_attrs(pids):
|
|
| 589 |
if not pids:
|
| 590 |
return {}
|
| 591 |
rows = O.search_read('res.partner', [('id', 'in', pids)],
|
| 592 |
-
['
|
| 593 |
-
'property_payment_term_id', 'create_date', 'category_id',
|
| 594 |
'property_product_pricelist'])
|
| 595 |
aids = {a for r in rows for a in (r.get('agent_ids') or [])}
|
| 596 |
anames = ({p['id']: p['name'] for p in O.search_read('res.partner', [('id', 'in', list(aids))], ['name'])}
|
|
@@ -610,6 +611,17 @@ def _partner_attrs(pids):
|
|
| 610 |
# compares dates ISO-LEXICALLY, so a trailing time would sort and filter as text noise.
|
| 611 |
since = (r.get('create_date') or '')
|
| 612 |
out[r['id']] = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
'city': city.title() if city else '(none)',
|
| 614 |
'state': O.m2o_name(r.get('state_id')) or '(none)',
|
| 615 |
'country': O.m2o_name(r.get('country_id')) or '(none)',
|
|
|
|
| 573 |
|
| 574 |
|
| 575 |
def _partner_attrs(pids):
|
| 576 |
+
"""{pid: {street, street2, city, state, country, agent, zip, payment_terms, customer_since,
|
| 577 |
+
tags, pricelist}} β the customer attributes the sum-level rollups slice by and the Customer
|
| 578 |
+
table displays.
|
| 579 |
|
| 580 |
Agent is res.partner.agent_ids (the assigned sales agent; ~one per customer), NOT Odoo's
|
| 581 |
user_id 'salesperson' β MEASURED 2026-07-27 at 26 of 1,548 (2%), which is why agent_ids has
|
|
|
|
| 590 |
if not pids:
|
| 591 |
return {}
|
| 592 |
rows = O.search_read('res.partner', [('id', 'in', pids)],
|
| 593 |
+
['street', 'street2', 'city', 'state_id', 'country_id', 'agent_ids',
|
| 594 |
+
'zip', 'property_payment_term_id', 'create_date', 'category_id',
|
| 595 |
'property_product_pricelist'])
|
| 596 |
aids = {a for r in rows for a in (r.get('agent_ids') or [])}
|
| 597 |
anames = ({p['id']: p['name'] for p in O.search_read('res.partner', [('id', 'in', list(aids))], ['name'])}
|
|
|
|
| 611 |
# compares dates ISO-LEXICALLY, so a trailing time would sort and filter as text noise.
|
| 612 |
since = (r.get('create_date') or '')
|
| 613 |
out[r['id']] = {
|
| 614 |
+
# β W30-T33 (carried W29-T51) β the STREET half of the address. `city`/`state`/`zip`/
|
| 615 |
+
# `country` were already here; the two street lines were read nowhere on the customer
|
| 616 |
+
# path, only by `modules/map.py` for geocoding, and a geocoder's input never reached
|
| 617 |
+
# the grid. β NOT ROUTED THROUGH `map.py`: these come off `res.partner` directly, so a
|
| 618 |
+
# customer the geocoder could not place still shows its address.
|
| 619 |
+
# β `.strip()` WITHOUT `.title()`, unlike `city`. A street line carries unit numbers,
|
| 620 |
+
# directionals and abbreviations ("123 NW 4TH ST APT 2B"), and title-casing turns
|
| 621 |
+
# those into "123 Nw 4Th St Apt 2B" β a mangling `city` does not risk because a city
|
| 622 |
+
# is one word-set of ordinary nouns.
|
| 623 |
+
'street': (r.get('street') or '').strip() or '(none)',
|
| 624 |
+
'street2': (r.get('street2') or '').strip() or '(none)',
|
| 625 |
'city': city.title() if city else '(none)',
|
| 626 |
'state': O.m2o_name(r.get('state_id')) or '(none)',
|
| 627 |
'country': O.m2o_name(r.get('country_id')) or '(none)',
|
platform/modules/product_data.py
CHANGED
|
@@ -57,6 +57,7 @@ from pathlib import Path
|
|
| 57 |
|
| 58 |
import core.odoo as O
|
| 59 |
import core.periods as P
|
|
|
|
| 60 |
import core.table_store as table_store
|
| 61 |
import modules.products as products
|
| 62 |
|
|
@@ -117,9 +118,16 @@ UNSHIPPED_ROW_KEYS = ("buy_now",)
|
|
| 117 |
#: exist β and an unknown column is an INACTIVE condition in the tri-state engine, which IGNORES
|
| 118 |
#: it and therefore WIDENS. The buy list would silently show the whole catalogue to everyone but
|
| 119 |
#: its author. Contract columns are identical for every reader, so the view means one thing.
|
| 120 |
-
#:
|
| 121 |
-
#:
|
| 122 |
-
#:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
_SUPPLIER_MAP_PATH = Path(__file__).resolve().parent.parent / "procurement_suppliers.json"
|
| 124 |
_SUPPLIER_CACHE = {}
|
| 125 |
|
|
@@ -156,7 +164,70 @@ def supplier_master():
|
|
| 156 |
#: the whole reason the table-page factory exists ("a new table object gets its own
|
| 157 |
#: table_store.make('<its>_table_workspace')").
|
| 158 |
TABLE_KEY = 'product_table_workspace'
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
|
| 162 |
def sku_pid(code):
|
|
@@ -286,6 +357,20 @@ def _catalogue_by_code():
|
|
| 286 |
return products.catalogue()
|
| 287 |
|
| 288 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
def pool(team_id=None, t=None):
|
| 290 |
"""One row per ACTIVE product β the PRODUCT analogue of `customer_data.pool`.
|
| 291 |
|
|
@@ -305,6 +390,7 @@ def pool(team_id=None, t=None):
|
|
| 305 |
inv = _inventory_by_code(t)
|
| 306 |
bu_share = {} if consolidated else _bu_ltm_share(t, team_id)
|
| 307 |
sup = supplier_master()
|
|
|
|
| 308 |
cat = _catalogue_by_code()
|
| 309 |
# The LEFT side of the join, indexed by the same code key. β A code here that the catalogue
|
| 310 |
# does not carry belongs to a product no ACTIVE record claims β archived, and R12 keeps those
|
|
@@ -332,6 +418,18 @@ def pool(team_id=None, t=None):
|
|
| 332 |
"qty_ytd": r.get("qty_ytd", 0.0) if r else None,
|
| 333 |
"orders_ytd": r.get("orders_ytd", 0) if r else None,
|
| 334 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
# Wave 17 R3 β the supplier master, on every pull (it is catalogue data, not stock).
|
| 336 |
s = sup.get(code) or {}
|
| 337 |
row.update({
|
|
@@ -504,6 +602,124 @@ def validate(team_id=None, t=None):
|
|
| 504 |
"ours": still_active, "theirs": 0, "ok": still_active == 0,
|
| 505 |
"detail": {"codes": coded_outside[:10], "n_codes": len(outside)},
|
| 506 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 507 |
# WAVE 17 R3 β the BUY SIGNAL must be a total, exact partition of the catalogue, and every
|
| 508 |
# member of it must be re-derivable from the two columns beside it. A signal somebody buys
|
| 509 |
# stock on cannot be "mostly right": the failure that matters is a row that says OK because
|
|
|
|
| 57 |
|
| 58 |
import core.odoo as O
|
| 59 |
import core.periods as P
|
| 60 |
+
import core.shared_overlay as shared_overlay
|
| 61 |
import core.table_store as table_store
|
| 62 |
import modules.products as products
|
| 63 |
|
|
|
|
| 118 |
#: exist β and an unknown column is an INACTIVE condition in the tri-state engine, which IGNORES
|
| 119 |
#: it and therefore WIDENS. The buy list would silently show the whole catalogue to everyone but
|
| 120 |
#: its author. Contract columns are identical for every reader, so the view means one thing.
|
| 121 |
+
#:
|
| 122 |
+
#: ββ 2026-08-12 (W30-T36) β THE OWNER'S ASK IS NOW DELIVERED, AND THE PARAGRAPH ABOVE IS WHY IT
|
| 123 |
+
#: TOOK THREE WAVES. Owner: *"turn this Excel sheet into a User created Field that we can edit."*
|
| 124 |
+
#: It was parked because a per-user column silently WIDENS a shared view β not because editing was
|
| 125 |
+
#: hard. Wave 29's `core/shared_overlay.py` (whose header quotes this very comment) is the stratum
|
| 126 |
+
#: that removes the objection: **one value per (row, column) for the whole tenant**, so the column
|
| 127 |
+
#: still means ONE thing to every reader and a shared view still filters honestly.
|
| 128 |
+
#: β The four columns below are now `source: "overlay"` + `shared: true` in the canonical
|
| 129 |
+
#: contract, their values live in `<TABLE_KEY>__shared`, and the master map is what SEEDS an
|
| 130 |
+
#: unedited cell rather than what freezes it. See `SHARED_KEYS` and `_ProductTableStore`.
|
| 131 |
_SUPPLIER_MAP_PATH = Path(__file__).resolve().parent.parent / "procurement_suppliers.json"
|
| 132 |
_SUPPLIER_CACHE = {}
|
| 133 |
|
|
|
|
| 164 |
#: the whole reason the table-page factory exists ("a new table object gets its own
|
| 165 |
#: table_store.make('<its>_table_workspace')").
|
| 166 |
TABLE_KEY = 'product_table_workspace'
|
| 167 |
+
|
| 168 |
+
_GRID_FIELDS_PATH = Path(__file__).resolve().parent.parent / 'aios_grid_fields.json'
|
| 169 |
+
_SHARED_KEYS = None
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def SHARED_KEYS():
|
| 173 |
+
"""The product columns whose values are TENANT-WIDE β derived from the canonical contract's
|
| 174 |
+
own `shared: true`, never typed out a second time.
|
| 175 |
+
|
| 176 |
+
β IT DELIBERATELY DOES NOT SWALLOW A READ FAILURE. Degrading to `()` would send a shared
|
| 177 |
+
write into the per-user stratum with nothing going wrong anywhere β the widening defect
|
| 178 |
+
reappearing silently, which is the one outcome this whole mechanism exists to prevent. If the
|
| 179 |
+
canonical contract is unreadable the product grid cannot render at all (`pd_fields` parses the
|
| 180 |
+
same file with no guard), so a raise here costs nothing that was still working.
|
| 181 |
+
"""
|
| 182 |
+
global _SHARED_KEYS
|
| 183 |
+
if _SHARED_KEYS is None:
|
| 184 |
+
doc = json.loads(_GRID_FIELDS_PATH.read_text(encoding='utf-8'))
|
| 185 |
+
_SHARED_KEYS = tuple(f['key'] for f in (doc.get('product_data') or {}).get('fields') or []
|
| 186 |
+
if f.get('shared'))
|
| 187 |
+
return _SHARED_KEYS
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
class _ProductTableStore(table_store.TableStore):
|
| 191 |
+
"""The product workspace, with the SHARED columns routed to the tenant-wide stratum.
|
| 192 |
+
|
| 193 |
+
ββ THIS SUBCLASS IS THE WHOLE OF W30-T36's WRITE PATH, AND THE REASON IT LIVES HERE RATHER
|
| 194 |
+
THAN AT A ROUTE IS THAT **THE BROWSER NEVER CALLS `PATCH /products/{pid}`** β measured, zero
|
| 195 |
+
call sites in `aios-web/web/src`. A cell edit travels `POST /grid/events` β `grid_events.
|
| 196 |
+
handle_one` β `_tops(ctx).patch_overlay(...)`, and `_tops` returns `ctx.table`, which
|
| 197 |
+
`routes_grid._ctx` sets to `TABLE_OPS` for the product scope. So this object IS the seam both
|
| 198 |
+
doors pass through; intercepting at either route would have left the other one writing a
|
| 199 |
+
per-user value that only its author could see.
|
| 200 |
+
|
| 201 |
+
β `st=self.st`, NEVER the module default. The shared stratum must resolve to the SAME store
|
| 202 |
+
handle as the per-user one it sits beside β `_tops`' own comment explains that a split, where
|
| 203 |
+
one side is tenant-scoped and the other is not, is worse than a stated residency error because
|
| 204 |
+
a user's value would vanish the moment they saved it. Reading `self.st` means both strata move
|
| 205 |
+
together the day that singleton gains a tenant handle.
|
| 206 |
+
"""
|
| 207 |
+
|
| 208 |
+
def patch_overlay(self, username, pid, updates):
|
| 209 |
+
clean = dict(updates or {})
|
| 210 |
+
if not clean:
|
| 211 |
+
return
|
| 212 |
+
keys = set(SHARED_KEYS())
|
| 213 |
+
shared = {k: v for k, v in clean.items() if k in keys}
|
| 214 |
+
personal = {k: v for k, v in clean.items() if k not in keys}
|
| 215 |
+
if shared:
|
| 216 |
+
shared_overlay.put_cells(TABLE_KEY, pid, shared, st=self.st)
|
| 217 |
+
if personal:
|
| 218 |
+
super().patch_overlay(username, pid, personal)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
TABLE_OPS = _ProductTableStore(TABLE_KEY)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def shared_cells(pids, st=None):
|
| 225 |
+
"""`{"<pid>": {key: value}}` for the SHARED columns of the rows named by `pids`.
|
| 226 |
+
|
| 227 |
+
β `pids` is required and positional all the way down β `shared_overlay.cells` refuses to serve
|
| 228 |
+
"everything" by design, and the caller here always holds an already-scoped pool.
|
| 229 |
+
"""
|
| 230 |
+
return shared_overlay.cells(TABLE_KEY, pids, st=st if st is not None else TABLE_OPS.st)
|
| 231 |
|
| 232 |
|
| 233 |
def sku_pid(code):
|
|
|
|
| 357 |
return products.catalogue()
|
| 358 |
|
| 359 |
|
| 360 |
+
def _pricelist_by_code():
|
| 361 |
+
"""`({code: {price_*: price}}, report)` from `products.pricelist_by_code`, or `({}, β¦)`.
|
| 362 |
+
|
| 363 |
+
A SEAM for the same two reasons `_inventory_by_code` is one: it degrades rather than raises
|
| 364 |
+
(these are columns, not the row set), and `verify_perm_scope`'s section H stubs it to build a
|
| 365 |
+
pool without Odoo. β Unstubbed there, section H would reach live Odoo through the back door
|
| 366 |
+
and the whole file would stop being runnable offline.
|
| 367 |
+
"""
|
| 368 |
+
try:
|
| 369 |
+
return products.pricelist_by_code()
|
| 370 |
+
except Exception as e:
|
| 371 |
+
return {}, {"error": f"{type(e).__name__}: {str(e)[:200]}"}
|
| 372 |
+
|
| 373 |
+
|
| 374 |
def pool(team_id=None, t=None):
|
| 375 |
"""One row per ACTIVE product β the PRODUCT analogue of `customer_data.pool`.
|
| 376 |
|
|
|
|
| 390 |
inv = _inventory_by_code(t)
|
| 391 |
bu_share = {} if consolidated else _bu_ltm_share(t, team_id)
|
| 392 |
sup = supplier_master()
|
| 393 |
+
prices, _price_report = _pricelist_by_code()
|
| 394 |
cat = _catalogue_by_code()
|
| 395 |
# The LEFT side of the join, indexed by the same code key. β A code here that the catalogue
|
| 396 |
# does not carry belongs to a product no ACTIVE record claims β archived, and R12 keeps those
|
|
|
|
| 418 |
"qty_ytd": r.get("qty_ytd", 0.0) if r else None,
|
| 419 |
"orders_ytd": r.get("orders_ytd", 0) if r else None,
|
| 420 |
}
|
| 421 |
+
# β WAVE 30 W30-T34 β the PRICELIST stratum, one column per declared list. Catalogue
|
| 422 |
+
# data like the supplier block, so it rides every pull, scoped or not: a price book is
|
| 423 |
+
# not a thing a business unit owns a slice of, and a Fisch reader who cannot see the
|
| 424 |
+
# Fisch price is exactly who this column is for.
|
| 425 |
+
#
|
| 426 |
+
# β `None`, NEVER 0 AND NEVER A FALLBACK, for the 1,883 SKUs no list prices. That is
|
| 427 |
+
# W29-T52's own negative control: the single `3_global` rule computes over `list_price`,
|
| 428 |
+
# which is 1.00 on 5,817 of 5,871 products, so the fallback is not a cheaper answer β
|
| 429 |
+
# it is a wrong one wearing a currency sign.
|
| 430 |
+
p = prices.get(code) or {}
|
| 431 |
+
for _col, _name in products.PRICELIST_COLUMNS:
|
| 432 |
+
row[_col] = p.get(_col)
|
| 433 |
# Wave 17 R3 β the supplier master, on every pull (it is catalogue data, not stock).
|
| 434 |
s = sup.get(code) or {}
|
| 435 |
row.update({
|
|
|
|
| 602 |
"ours": still_active, "theirs": 0, "ok": still_active == 0,
|
| 603 |
"detail": {"codes": coded_outside[:10], "n_codes": len(outside)},
|
| 604 |
})
|
| 605 |
+
# ββ ββ WAVE 30 W30-T34 β THE PRICELIST COLUMNS, AGAINST ORACLES THAT CANNOT SEE OUR JOIN ββ
|
| 606 |
+
#
|
| 607 |
+
# Three legs, because the column can fail in three different ways and only one of them is a
|
| 608 |
+
# count. `products.pricelist_by_code` builds a `{code: {col: price}}` map by expanding each
|
| 609 |
+
# rule over the products it names, IN PYTHON β that expansion is the fragile part, so every
|
| 610 |
+
# leg below re-asks ODOO instead of re-reading the map.
|
| 611 |
+
price_report = _pricelist_by_code()[1]
|
| 612 |
+
_pl_rows = O.search_read('product.pricelist', [], ['id', 'name'])
|
| 613 |
+
_pl_id = {}
|
| 614 |
+
for _p in _pl_rows:
|
| 615 |
+
_pl_id.setdefault(str(_p.get('name') or '').strip(), _p['id'])
|
| 616 |
+
_today = P.today().isoformat()
|
| 617 |
+
|
| 618 |
+
_targets_memo = {}
|
| 619 |
+
|
| 620 |
+
def _rule_targets(list_name):
|
| 621 |
+
"""(variant_ids, template_ids) a pricelist prices today β read FRESH from Odoo.
|
| 622 |
+
|
| 623 |
+
Memoised for the LIFE OF THIS CALL only: legs 1 and 2 both need all three lists, and
|
| 624 |
+
without this `validate()` makes six identical round trips instead of three (measured:
|
| 625 |
+
~48s of the run). Deliberately NOT an `lru_cache` β an oracle that survives the process
|
| 626 |
+
is an oracle reading yesterday's Odoo.
|
| 627 |
+
"""
|
| 628 |
+
if list_name in _targets_memo:
|
| 629 |
+
return _targets_memo[list_name]
|
| 630 |
+
var, tmpl = set(), set()
|
| 631 |
+
plid = _pl_id.get(list_name)
|
| 632 |
+
if plid is None:
|
| 633 |
+
return var, tmpl
|
| 634 |
+
for r in O.search_read(
|
| 635 |
+
'product.pricelist.item',
|
| 636 |
+
[('pricelist_id', '=', plid), ('compute_price', '=', 'fixed'),
|
| 637 |
+
('applied_on', 'in', ['0_product_variant', '1_product'])],
|
| 638 |
+
['product_id', 'product_tmpl_id', 'applied_on', 'fixed_price',
|
| 639 |
+
'date_start', 'date_end']):
|
| 640 |
+
ds, de = str(r.get('date_start') or '')[:10], str(r.get('date_end') or '')[:10]
|
| 641 |
+
if (ds and ds > _today) or (de and de < _today):
|
| 642 |
+
continue
|
| 643 |
+
fp = r.get('fixed_price')
|
| 644 |
+
if not isinstance(fp, (int, float)) or fp <= 0:
|
| 645 |
+
continue
|
| 646 |
+
if r.get('applied_on') == '0_product_variant' and r.get('product_id'):
|
| 647 |
+
var.add(O.m2o_id(r['product_id']))
|
| 648 |
+
elif r.get('product_tmpl_id'):
|
| 649 |
+
tmpl.add(O.m2o_id(r['product_tmpl_id']))
|
| 650 |
+
_targets_memo[list_name] = (var, tmpl)
|
| 651 |
+
return var, tmpl
|
| 652 |
+
|
| 653 |
+
# LEG 1 β COVERAGE PER LIST. Ours: cells we filled. Theirs: a bare Odoo `search_count` of
|
| 654 |
+
# ACTIVE products a fresh read of that list's rules reaches. The rule set is shared (it IS
|
| 655 |
+
# the data) but the EXPANSION is not, and the expansion is what breaks.
|
| 656 |
+
for _col, _name in products.PRICELIST_COLUMNS:
|
| 657 |
+
_var, _tmpl = _rule_targets(_name)
|
| 658 |
+
_dom = [('active', '=', True), '|', ('id', 'in', sorted(_var)),
|
| 659 |
+
('product_tmpl_id', 'in', sorted(_tmpl))]
|
| 660 |
+
theirs = O.get_odoo().search_count('product.product', _dom) if (_var or _tmpl) else 0
|
| 661 |
+
ours = sum(1 for r in rows if isinstance(r.get(_col), (int, float)))
|
| 662 |
+
checks.append({
|
| 663 |
+
"check": f"{_name} pricelist: SKUs priced in the grid == an INDEPENDENT Odoo count "
|
| 664 |
+
f"of ACTIVE products its date-valid fixed rules reach",
|
| 665 |
+
"ours": ours, "theirs": theirs, "ok": ours == theirs,
|
| 666 |
+
"detail": {"column": _col, "variant_rules": len(_var), "template_rules": len(_tmpl)},
|
| 667 |
+
})
|
| 668 |
+
|
| 669 |
+
# LEG 2 β β THE TICKET'S OWN NEGATIVE CONTROL, AS A LEG. A SKU with no specific item must
|
| 670 |
+
# render BLANK. Asked of ODOO, never of the map that produced the blank β "everything I left
|
| 671 |
+
# empty was genuinely unpriced" is trivially true when checked against the dict that emptied
|
| 672 |
+
# it, which is the self-sealing shape the population leg above exists to end.
|
| 673 |
+
_unpriced = [r for r in rows
|
| 674 |
+
if not any(isinstance(r.get(c), (int, float))
|
| 675 |
+
for c, _n in products.PRICELIST_COLUMNS)]
|
| 676 |
+
_codes = {r["code"] for r in _unpriced}
|
| 677 |
+
_stray = 0
|
| 678 |
+
if _codes:
|
| 679 |
+
# Resolve those codes back to Odoo ids and ask whether ANY declared list prices them.
|
| 680 |
+
_ids, _tmpls = set(), set()
|
| 681 |
+
for _p in O.search_read('product.product',
|
| 682 |
+
[('active', '=', True),
|
| 683 |
+
('default_code', 'in', sorted(c for c in _codes
|
| 684 |
+
if not c.startswith("pid:")))],
|
| 685 |
+
['id', 'product_tmpl_id']):
|
| 686 |
+
_ids.add(_p['id'])
|
| 687 |
+
_tmpls.add(O.m2o_id(_p.get('product_tmpl_id')))
|
| 688 |
+
for _col, _name in products.PRICELIST_COLUMNS:
|
| 689 |
+
_v, _t = _rule_targets(_name)
|
| 690 |
+
_stray += len((_v & _ids) | ({t for t in _tmpls if t in _t}))
|
| 691 |
+
checks.append({
|
| 692 |
+
"check": "every SKU rendering a BLANK price is genuinely unpriced on all three declared "
|
| 693 |
+
"lists (W29-T52's NC: no fallback dressed as a price) β asked of Odoo",
|
| 694 |
+
"ours": _stray, "theirs": 0, "ok": _stray == 0,
|
| 695 |
+
"detail": {"blank_skus": len(_unpriced), "priced_skus": len(rows) - len(_unpriced)},
|
| 696 |
+
})
|
| 697 |
+
|
| 698 |
+
# LEG 3 β β R6's SECOND SENTENCE, AS A NUMBER. *"If there is lag or it can't be done, you
|
| 699 |
+
# need to explicitly tell me why and recommend a fix."* Everything the reader cannot see is
|
| 700 |
+
# counted here rather than dropped. The ASSERTION is the one thing that must never be true β
|
| 701 |
+
# a price cell that is present and not a positive number, i.e. a "free" SKU β while the
|
| 702 |
+
# rest rides `detail` so it is reported without reddening an honest day's data.
|
| 703 |
+
_bad = [r["code"] for r in rows
|
| 704 |
+
for c, _n in products.PRICELIST_COLUMNS
|
| 705 |
+
if r.get(c) is not None and not (isinstance(r.get(c), (int, float)) and r[c] > 0)]
|
| 706 |
+
# β MEASURED 2026-08-12 AND NOT FIXABLE FROM THIS FENCE: `aios_grid.rows_from_pool` sends
|
| 707 |
+
# every non-text field through `_round(v)` = `round(v)` with no ndigits, i.e. to a whole
|
| 708 |
+
# dollar. 52.1% of fixed prices carry cents and the median relative error is 5.26%, so this
|
| 709 |
+
# column ties to the cent HERE and ships rounded. Reported, never silently enforced.
|
| 710 |
+
_cents = sum(1 for r in rows for c, _n in products.PRICELIST_COLUMNS
|
| 711 |
+
if isinstance(r.get(c), (int, float)) and abs(r[c] - round(r[c])) > 1e-9)
|
| 712 |
+
_to_zero = sum(1 for r in rows for c, _n in products.PRICELIST_COLUMNS
|
| 713 |
+
if isinstance(r.get(c), (int, float)) and round(r[c]) == 0)
|
| 714 |
+
checks.append({
|
| 715 |
+
"check": "no price cell is present-but-not-a-positive-number (a 0 would read as FREE; "
|
| 716 |
+
"an unset rule must be blank), and what the reader cannot see is COUNTED",
|
| 717 |
+
"ours": len(_bad), "theirs": 0, "ok": not _bad,
|
| 718 |
+
"detail": {"bad_cells": _bad[:10], "reader_report": price_report,
|
| 719 |
+
"wire_rounding_loses_cents_on": _cents,
|
| 720 |
+
"wire_rounding_to_zero_dollars": _to_zero},
|
| 721 |
+
})
|
| 722 |
+
|
| 723 |
# WAVE 17 R3 β the BUY SIGNAL must be a total, exact partition of the catalogue, and every
|
| 724 |
# member of it must be re-derivable from the two columns beside it. A signal somebody buys
|
| 725 |
# stock on cannot be "mostly right": the failure that matters is a row that says OK because
|
platform/modules/products.py
CHANGED
|
@@ -212,6 +212,178 @@ def catalogue():
|
|
| 212 |
return out
|
| 213 |
|
| 214 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
def catalogue_count():
|
| 216 |
"""The INDEPENDENT population oracle: Odoo's own count of active products.
|
| 217 |
|
|
|
|
| 212 |
return out
|
| 213 |
|
| 214 |
|
| 215 |
+
#: ββ WAVE 30 / W30-T34 (the carried W29-T52) β THE PRICELIST STRATUM, PER LIST.
|
| 216 |
+
#:
|
| 217 |
+
#: MEASURED LIVE 2026-08-12, and every one of these numbers shaped the design rather than
|
| 218 |
+
#: decorating it:
|
| 219 |
+
#:
|
| 220 |
+
#: Β· **Five pricelists, three of them material.** Date-valid fixed rules: Fisch 5,139,
|
| 221 |
+
#: Royal 1 2,702, Royal 2 2,605, Public Pricelist 17, Giftware Deals 1. At SKU level that
|
| 222 |
+
#: is Fisch 3,971 Β· Royal 1 1,920 Β· Royal 2 1,857 Β· Public 6 Β· Giftware 0.
|
| 223 |
+
#: Β· **3,988 of 5,871 active products carry a usable fixed rule; 1,883 carry NONE.**
|
| 224 |
+
#: Β· **1,853 SKUs are priced on all three lists AND THE LISTS DISAGREE** β median relative
|
| 225 |
+
#: spread 16%, max 82%. That is why this is three columns rather than one "the price" or a
|
| 226 |
+
#: low/high pair: neither can tell a Fisch rep what Fisch sells the SKU for, which is the
|
| 227 |
+
#: only question the column exists to answer.
|
| 228 |
+
#: Β· β **`list_price` is 1.00 on 5,817 of the 5,871** β so the single `3_global` rule
|
| 229 |
+
#: computed over it is meaningless, exactly as W29-T52 said. **This reader never falls
|
| 230 |
+
#: through to it**, and that is the ticket's own negative control: a SKU with no specific
|
| 231 |
+
#: item renders BLANK, never a fallback dressed as a price.
|
| 232 |
+
#:
|
| 233 |
+
#: β RESOLVED BY NAME, NOT BY ID. A pricelist renamed or deleted in Odoo makes its column go
|
| 234 |
+
#: blank and makes `report["lists_missing"]` name it β a hard-coded id would keep pointing at
|
| 235 |
+
#: whatever inherited it and mislabel every cell silently.
|
| 236 |
+
PRICELIST_COLUMNS = (
|
| 237 |
+
("price_fisch", "Fisch"),
|
| 238 |
+
("price_royal_1", "Royal 1"),
|
| 239 |
+
("price_royal_2", "Royal 2"),
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def pricelist_by_code():
|
| 244 |
+
"""`({code: {column_key: price}}, report)` β the date-valid FIXED base-tier price for each
|
| 245 |
+
declared pricelist, keyed EXACTLY as `catalogue()` keys its rows.
|
| 246 |
+
|
| 247 |
+
β THE KEYING IS LOAD-BEARING, not a detail. `catalogue()` keys on `default_code` with a
|
| 248 |
+
`pid:{id}` fallback for the ~33 active records that carry none. Keying this map any other
|
| 249 |
+
way would leave those SKUs permanently blank while an oracle counting active products
|
| 250 |
+
counted them β a red gate on a working build, or worse, a silent hole nobody counts.
|
| 251 |
+
|
| 252 |
+
**The base tier, deliberately.** `pricecomp._tier_for` picks the highest `min_quantity` at
|
| 253 |
+
or below an order's quantity, because it is pricing a LINE that has one. A catalogue column
|
| 254 |
+
has no quantity in hand, so it takes the LOWEST `min_quantity` β the price at qty 1. Rules
|
| 255 |
+
above that break are a bulk price, and `report["qty_break_only"]` counts the SKUs whose only
|
| 256 |
+
rule sits on one (MEASURED: 10 rules of 10,464 carry a break at all).
|
| 257 |
+
|
| 258 |
+
**Variant rules beat template rules**, matching `pricecomp._tier_for`: a `0_product_variant`
|
| 259 |
+
rule is the more specific statement about this exact SKU.
|
| 260 |
+
|
| 261 |
+
β DEGRADES TO `({}, report)` on a read failure, matching `_inventory_by_code` rather than
|
| 262 |
+
`catalogue()`: these are COLUMNS, and a product grid that will not render because pricing is
|
| 263 |
+
momentarily unreachable is a worse failure than one with blank price columns. The blanks are
|
| 264 |
+
not silent β `product_data.validate()`'s coverage leg reconciles each column against a fresh
|
| 265 |
+
Odoo count and goes red at zero.
|
| 266 |
+
|
| 267 |
+
β **The report exists because R6's second sentence is law** (*"if it can't be done, you need
|
| 268 |
+
to explicitly tell me why and recommend a fix"*). Everything this reader CANNOT see is
|
| 269 |
+
counted rather than dropped: rules on pricelists the contract does not declare, non-fixed
|
| 270 |
+
(`formula`/`percent`) rules, the `3_global` fallback, and prices that only exist above a
|
| 271 |
+
quantity break.
|
| 272 |
+
"""
|
| 273 |
+
# β THE COUNTERS ARE DISJOINT AND ORDER-DEPENDENT, and saying so is the difference between a
|
| 274 |
+
# report and a misleading one. A rule is classified ONCE, by the first reason it is skipped:
|
| 275 |
+
# undeclared list β out of date β not fixed β global β zero price. So `rules_not_fixed: 0`
|
| 276 |
+
# means "no formula rule on a list we declare", NOT "this Odoo has no formula rules"
|
| 277 |
+
# (MEASURED 2026-08-12: it has exactly one, and it sits on Public Pricelist, which the
|
| 278 |
+
# contract does not declare β so it lands in `rules_undeclared_list`).
|
| 279 |
+
report = {"lists_missing": [], "rules_total": 0, "rules_undeclared_list": 0,
|
| 280 |
+
"rules_not_fixed": 0, "rules_global": 0, "rules_out_of_date": 0,
|
| 281 |
+
"rules_zero_price": 0, "qty_break_only": 0}
|
| 282 |
+
try:
|
| 283 |
+
pl_rows = O.search_read('product.pricelist', [], ['id', 'name'])
|
| 284 |
+
by_name = {}
|
| 285 |
+
for p in pl_rows:
|
| 286 |
+
by_name.setdefault(str(p.get('name') or '').strip(), p['id'])
|
| 287 |
+
wanted = {}
|
| 288 |
+
for col, name in PRICELIST_COLUMNS:
|
| 289 |
+
pid = by_name.get(name)
|
| 290 |
+
if pid is None:
|
| 291 |
+
report["lists_missing"].append(name)
|
| 292 |
+
else:
|
| 293 |
+
wanted[pid] = col
|
| 294 |
+
|
| 295 |
+
# ββ FILTERED SERVER-SIDE, AND THE REASON IS A MEASUREMENT, NOT A STYLE PREFERENCE.
|
| 296 |
+
# Reading all 10,464 rules with 9 fields and sorting them in Python costs **30.1s** on
|
| 297 |
+
# this connection; the same rules under a server-side domain with 6 fields cost
|
| 298 |
+
# **13.6s** (measured 2026-08-12, back to back). `pool()` runs this on a scope's
|
| 299 |
+
# first-ever build, so that 16.5s is 16.5s of somebody's page load.
|
| 300 |
+
#
|
| 301 |
+
# β The domain reproduces the Python predicate EXACTLY, and the null legs are the part
|
| 302 |
+
# that is easy to get wrong: an absent `date_start` is `False`, not a past date, so
|
| 303 |
+
# `('date_start','<=',today)` ALONE would drop every open-ended rule β which is almost
|
| 304 |
+
# all of them.
|
| 305 |
+
today = P.today().isoformat()
|
| 306 |
+
_declared = sorted(wanted)
|
| 307 |
+
_kinds = ['0_product_variant', '1_product']
|
| 308 |
+
_live = [('pricelist_id', 'in', _declared), ('compute_price', '=', 'fixed'),
|
| 309 |
+
('applied_on', 'in', _kinds),
|
| 310 |
+
'|', ('date_start', '=', False), ('date_start', '<=', today),
|
| 311 |
+
'|', ('date_end', '=', False), ('date_end', '>=', today),
|
| 312 |
+
('fixed_price', '>', 0)]
|
| 313 |
+
rules = O.search_read(
|
| 314 |
+
'product.pricelist.item', _live,
|
| 315 |
+
['pricelist_id', 'product_id', 'product_tmpl_id', 'applied_on', 'fixed_price',
|
| 316 |
+
'min_quantity']) if _declared else []
|
| 317 |
+
|
| 318 |
+
# β R6's SECOND SENTENCE, PAID FOR WITH `search_count` RATHER THAN A WIDER READ. Six
|
| 319 |
+
# counts cost ~1.2s together; the rules they count would cost 16s to read. What this
|
| 320 |
+
# reader cannot see is still REPORTED β it is just no longer transferred.
|
| 321 |
+
def _n(extra):
|
| 322 |
+
try:
|
| 323 |
+
return O.get_odoo().search_count('product.pricelist.item', extra)
|
| 324 |
+
except Exception:
|
| 325 |
+
return -1 # -1 reads as "not measured", never as zero
|
| 326 |
+
_dated = ['|', ('date_start', '=', False), ('date_start', '<=', today),
|
| 327 |
+
'|', ('date_end', '=', False), ('date_end', '>=', today)]
|
| 328 |
+
report["rules_total"] = _n([])
|
| 329 |
+
report["rules_undeclared_list"] = _n([('pricelist_id', 'not in', _declared)]) \
|
| 330 |
+
if _declared else report["rules_total"]
|
| 331 |
+
if _declared:
|
| 332 |
+
_on = [('pricelist_id', 'in', _declared)]
|
| 333 |
+
# `percent_price` and formula rules are read NOWHERE in this repo. A rule we cannot
|
| 334 |
+
# price is one the operator is TOLD about, never one that quietly becomes a blank.
|
| 335 |
+
report["rules_not_fixed"] = _n(_on + [('compute_price', '!=', 'fixed')])
|
| 336 |
+
report["rules_global"] = _n(_on + [('compute_price', '=', 'fixed'),
|
| 337 |
+
('applied_on', 'not in', _kinds)])
|
| 338 |
+
report["rules_out_of_date"] = (
|
| 339 |
+
_n(_on + [('compute_price', '=', 'fixed'), ('applied_on', 'in', _kinds)])
|
| 340 |
+
- _n(_on + [('compute_price', '=', 'fixed'),
|
| 341 |
+
('applied_on', 'in', _kinds)] + _dated))
|
| 342 |
+
# A zero price is not "free" β it is an unset rule. Blank says so; 0 does not.
|
| 343 |
+
report["rules_zero_price"] = _n(
|
| 344 |
+
_on + [('compute_price', '=', 'fixed'), ('applied_on', 'in', _kinds)]
|
| 345 |
+
+ _dated + [('fixed_price', '<=', 0)])
|
| 346 |
+
|
| 347 |
+
by_var, by_tmpl = {}, {}
|
| 348 |
+
for r in rules:
|
| 349 |
+
col = wanted[O.m2o_id(r.get('pricelist_id'))]
|
| 350 |
+
if r.get('applied_on') == '0_product_variant' and r.get('product_id'):
|
| 351 |
+
by_var.setdefault((col, O.m2o_id(r['product_id'])), []).append(r)
|
| 352 |
+
elif r.get('product_tmpl_id'):
|
| 353 |
+
by_tmpl.setdefault((col, O.m2o_id(r['product_tmpl_id'])), []).append(r)
|
| 354 |
+
|
| 355 |
+
dom = [('active', '=', True)]
|
| 356 |
+
prods = O.search_read('product.product', dom,
|
| 357 |
+
['id', 'default_code', 'product_tmpl_id'], limit=50000)
|
| 358 |
+
n = O.get_odoo().search_count('product.product', dom)
|
| 359 |
+
if len(prods) != n:
|
| 360 |
+
# Same guard, same reason as `catalogue()`: a short pull renders as a plausible
|
| 361 |
+
# smaller set of priced SKUs with nothing reporting it.
|
| 362 |
+
raise ValueError(
|
| 363 |
+
f"products.pricelist_by_code: the product pull is TRUNCATED β read {len(prods)} "
|
| 364 |
+
f"rows against a search_count of {n}.")
|
| 365 |
+
except Exception as e:
|
| 366 |
+
report["error"] = f"{type(e).__name__}: {str(e)[:200]}"
|
| 367 |
+
return {}, report
|
| 368 |
+
|
| 369 |
+
out = {}
|
| 370 |
+
for p in prods:
|
| 371 |
+
code = (str(p['default_code']).strip() if p.get('default_code') else f"pid:{p['id']}")
|
| 372 |
+
tmpl = O.m2o_id(p.get('product_tmpl_id'))
|
| 373 |
+
cells = {}
|
| 374 |
+
for col, _name in PRICELIST_COLUMNS:
|
| 375 |
+
cands = by_var.get((col, p['id'])) or by_tmpl.get((col, tmpl))
|
| 376 |
+
if not cands:
|
| 377 |
+
continue
|
| 378 |
+
base = min(cands, key=lambda r: r.get('min_quantity') or 0.0)
|
| 379 |
+
if (base.get('min_quantity') or 0.0) > 1.0:
|
| 380 |
+
report["qty_break_only"] += 1
|
| 381 |
+
cells[col] = base.get('fixed_price')
|
| 382 |
+
if cells:
|
| 383 |
+
out.setdefault(code, {}).update(cells)
|
| 384 |
+
return out, report
|
| 385 |
+
|
| 386 |
+
|
| 387 |
def catalogue_count():
|
| 388 |
"""The INDEPENDENT population oracle: Odoo's own count of active products.
|
| 389 |
|
web/public/sample_customers.json
CHANGED
|
@@ -1,529 +1,545 @@
|
|
| 1 |
-
{
|
| 2 |
-
"fields": [
|
| 3 |
-
{
|
| 4 |
-
"key": "customer",
|
| 5 |
-
"label": "Customer",
|
| 6 |
-
"type": "text",
|
| 7 |
-
"source": "odoo",
|
| 8 |
-
"pinned": true,
|
| 9 |
-
"default": true,
|
| 10 |
-
"description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
|
| 11 |
-
},
|
| 12 |
-
{
|
| 13 |
-
"key": "odoo_status",
|
| 14 |
-
"label": "Odoo record",
|
| 15 |
-
"type": "status",
|
| 16 |
-
"source": "odoo",
|
| 17 |
-
"default": false,
|
| 18 |
-
"description": "Whether this customer still exists in Odoo. Archived means deleted there."
|
| 19 |
-
},
|
| 20 |
-
{
|
| 21 |
-
"key": "agent",
|
| 22 |
-
"label": "Agent",
|
| 23 |
-
"type": "text",
|
| 24 |
-
"source": "odoo",
|
| 25 |
-
"default": true,
|
| 26 |
-
"description": "The sales agent who owns this account."
|
| 27 |
-
},
|
| 28 |
-
{
|
| 29 |
-
"key": "dba",
|
| 30 |
-
"label": "DBA",
|
| 31 |
-
"type": "select",
|
| 32 |
-
"source": "odoo",
|
| 33 |
-
"default": false,
|
| 34 |
-
"options": [
|
| 35 |
-
"Fisch",
|
| 36 |
-
"Royal",
|
| 37 |
-
"Both"
|
| 38 |
-
],
|
| 39 |
-
"description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
|
| 40 |
-
},
|
| 41 |
-
{
|
| 42 |
-
"key": "salesperson",
|
| 43 |
-
"label": "Salesperson",
|
| 44 |
-
"type": "text",
|
| 45 |
-
"source": "odoo",
|
| 46 |
-
"default": false,
|
| 47 |
-
"description": "Who keyed in most of this customer's orders β not the Agent, who owns the account."
|
| 48 |
-
},
|
| 49 |
-
{
|
| 50 |
-
"key": "
|
| 51 |
-
"label": "
|
| 52 |
-
"type": "text",
|
| 53 |
-
"source": "odoo",
|
| 54 |
-
"default":
|
| 55 |
-
"description": "
|
| 56 |
-
},
|
| 57 |
-
{
|
| 58 |
-
"key": "
|
| 59 |
-
"label": "
|
| 60 |
-
"type": "text",
|
| 61 |
-
"source": "odoo",
|
| 62 |
-
"default":
|
| 63 |
-
"description": "
|
| 64 |
-
},
|
| 65 |
-
{
|
| 66 |
-
"key": "
|
| 67 |
-
"label": "
|
| 68 |
-
"type": "text",
|
| 69 |
-
"source": "odoo",
|
| 70 |
-
"default":
|
| 71 |
-
"description": "
|
| 72 |
-
},
|
| 73 |
-
{
|
| 74 |
-
"key": "
|
| 75 |
-
"label": "
|
| 76 |
-
"type": "text",
|
| 77 |
-
"source": "odoo",
|
| 78 |
-
"default":
|
| 79 |
-
"description": "
|
| 80 |
-
},
|
| 81 |
-
{
|
| 82 |
-
"key": "
|
| 83 |
-
"label": "
|
| 84 |
-
"type": "
|
| 85 |
-
"source": "odoo",
|
| 86 |
-
"default": false,
|
| 87 |
-
"description": "
|
| 88 |
-
},
|
| 89 |
-
{
|
| 90 |
-
"key": "
|
| 91 |
-
"label": "
|
| 92 |
-
"type": "text",
|
| 93 |
-
"source": "odoo",
|
| 94 |
-
"default": false,
|
| 95 |
-
"description": "
|
| 96 |
-
},
|
| 97 |
-
{
|
| 98 |
-
"key": "
|
| 99 |
-
"label": "
|
| 100 |
-
"type": "
|
| 101 |
-
"source": "odoo",
|
| 102 |
-
"default": false,
|
| 103 |
-
"description": "
|
| 104 |
-
},
|
| 105 |
-
{
|
| 106 |
-
"key": "
|
| 107 |
-
"label": "
|
| 108 |
-
"type": "text",
|
| 109 |
-
"source": "odoo",
|
| 110 |
-
"default": false,
|
| 111 |
-
"description": "
|
| 112 |
-
},
|
| 113 |
-
{
|
| 114 |
-
"key": "
|
| 115 |
-
"label": "
|
| 116 |
-
"type": "
|
| 117 |
-
"source": "odoo",
|
| 118 |
-
"default":
|
| 119 |
-
"description": "
|
| 120 |
-
},
|
| 121 |
-
{
|
| 122 |
-
"key": "
|
| 123 |
-
"label": "
|
| 124 |
-
"type": "
|
| 125 |
-
"source": "odoo",
|
| 126 |
-
"default":
|
| 127 |
-
"description": "
|
| 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 |
-
"key": "
|
| 176 |
-
"label": "
|
| 177 |
-
"type": "currency",
|
| 178 |
-
"source": "odoo",
|
| 179 |
-
"default": false,
|
| 180 |
-
"description": "
|
| 181 |
-
},
|
| 182 |
-
{
|
| 183 |
-
"key": "
|
| 184 |
-
"label": "
|
| 185 |
-
"type": "currency",
|
| 186 |
-
"source": "odoo",
|
| 187 |
-
"default": false,
|
| 188 |
-
"description": "
|
| 189 |
-
},
|
| 190 |
-
{
|
| 191 |
-
"key": "
|
| 192 |
-
"label": "
|
| 193 |
-
"type": "currency",
|
| 194 |
-
"source": "odoo",
|
| 195 |
-
"default": false,
|
| 196 |
-
"description": "Overdue between
|
| 197 |
-
},
|
| 198 |
-
{
|
| 199 |
-
"key": "
|
| 200 |
-
"label": "
|
| 201 |
-
"type": "currency",
|
| 202 |
-
"source": "odoo",
|
| 203 |
-
"default": false,
|
| 204 |
-
"description": "Overdue
|
| 205 |
-
},
|
| 206 |
-
{
|
| 207 |
-
"key": "
|
| 208 |
-
"label": "
|
| 209 |
-
"type": "
|
| 210 |
-
"source": "odoo",
|
| 211 |
-
"default": false,
|
| 212 |
-
"description": "
|
| 213 |
-
},
|
| 214 |
-
{
|
| 215 |
-
"key": "
|
| 216 |
-
"label": "
|
| 217 |
-
"type": "
|
| 218 |
-
"source": "odoo",
|
| 219 |
-
"default": false,
|
| 220 |
-
"description": "
|
| 221 |
-
},
|
| 222 |
-
{
|
| 223 |
-
"key": "
|
| 224 |
-
"label": "
|
| 225 |
-
"type": "
|
| 226 |
-
"source": "odoo",
|
| 227 |
-
"default": false,
|
| 228 |
-
"description": "
|
| 229 |
-
},
|
| 230 |
-
{
|
| 231 |
-
"key": "
|
| 232 |
-
"label": "
|
| 233 |
-
"type": "
|
| 234 |
-
"source": "odoo",
|
| 235 |
-
"default": false,
|
| 236 |
-
"description": "
|
| 237 |
-
},
|
| 238 |
-
{
|
| 239 |
-
"key": "
|
| 240 |
-
"label": "Top
|
| 241 |
-
"type": "
|
| 242 |
-
"source": "odoo",
|
| 243 |
-
"default": false,
|
| 244 |
-
"description": "
|
| 245 |
-
},
|
| 246 |
-
{
|
| 247 |
-
"key": "
|
| 248 |
-
"label": "
|
| 249 |
-
"type": "int",
|
| 250 |
-
"source": "odoo",
|
| 251 |
-
"default": false,
|
| 252 |
-
"description": "
|
| 253 |
-
},
|
| 254 |
-
{
|
| 255 |
-
"key": "
|
| 256 |
-
"label": "
|
| 257 |
-
"type": "
|
| 258 |
-
"source": "odoo",
|
| 259 |
-
"default": false,
|
| 260 |
-
"description": "
|
| 261 |
-
},
|
| 262 |
-
{
|
| 263 |
-
"key": "
|
| 264 |
-
"label": "
|
| 265 |
-
"type": "
|
| 266 |
-
"source": "
|
| 267 |
-
"default": false,
|
| 268 |
-
"description": "
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"fields": [
|
| 3 |
+
{
|
| 4 |
+
"key": "customer",
|
| 5 |
+
"label": "Customer",
|
| 6 |
+
"type": "text",
|
| 7 |
+
"source": "odoo",
|
| 8 |
+
"pinned": true,
|
| 9 |
+
"default": true,
|
| 10 |
+
"description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"key": "odoo_status",
|
| 14 |
+
"label": "Odoo record",
|
| 15 |
+
"type": "status",
|
| 16 |
+
"source": "odoo",
|
| 17 |
+
"default": false,
|
| 18 |
+
"description": "Whether this customer still exists in Odoo. Archived means deleted there."
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"key": "agent",
|
| 22 |
+
"label": "Agent",
|
| 23 |
+
"type": "text",
|
| 24 |
+
"source": "odoo",
|
| 25 |
+
"default": true,
|
| 26 |
+
"description": "The sales agent who owns this account."
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"key": "dba",
|
| 30 |
+
"label": "DBA",
|
| 31 |
+
"type": "select",
|
| 32 |
+
"source": "odoo",
|
| 33 |
+
"default": false,
|
| 34 |
+
"options": [
|
| 35 |
+
"Fisch",
|
| 36 |
+
"Royal",
|
| 37 |
+
"Both"
|
| 38 |
+
],
|
| 39 |
+
"description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
"key": "salesperson",
|
| 43 |
+
"label": "Salesperson",
|
| 44 |
+
"type": "text",
|
| 45 |
+
"source": "odoo",
|
| 46 |
+
"default": false,
|
| 47 |
+
"description": "Who keyed in most of this customer's orders β not the Agent, who owns the account."
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
"key": "street",
|
| 51 |
+
"label": "Street",
|
| 52 |
+
"type": "text",
|
| 53 |
+
"source": "odoo",
|
| 54 |
+
"default": false,
|
| 55 |
+
"description": "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address."
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
"key": "street2",
|
| 59 |
+
"label": "Street 2",
|
| 60 |
+
"type": "text",
|
| 61 |
+
"source": "odoo",
|
| 62 |
+
"default": false,
|
| 63 |
+
"description": "Second address line (suite, unit, floor) on the customer's Odoo address."
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"key": "city",
|
| 67 |
+
"label": "City",
|
| 68 |
+
"type": "text",
|
| 69 |
+
"source": "odoo",
|
| 70 |
+
"default": true,
|
| 71 |
+
"description": "City on the customer's Odoo address."
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"key": "state",
|
| 75 |
+
"label": "State",
|
| 76 |
+
"type": "text",
|
| 77 |
+
"source": "odoo",
|
| 78 |
+
"default": true,
|
| 79 |
+
"description": "State or province on the customer's Odoo address."
|
| 80 |
+
},
|
| 81 |
+
{
|
| 82 |
+
"key": "country",
|
| 83 |
+
"label": "Country",
|
| 84 |
+
"type": "text",
|
| 85 |
+
"source": "odoo",
|
| 86 |
+
"default": false,
|
| 87 |
+
"description": "Country on the customer's Odoo address."
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"key": "zip",
|
| 91 |
+
"label": "ZIP",
|
| 92 |
+
"type": "text",
|
| 93 |
+
"source": "odoo",
|
| 94 |
+
"default": false,
|
| 95 |
+
"description": "Postal code on the customer's Odoo address."
|
| 96 |
+
},
|
| 97 |
+
{
|
| 98 |
+
"key": "customer_since",
|
| 99 |
+
"label": "Customer since",
|
| 100 |
+
"type": "date",
|
| 101 |
+
"source": "odoo",
|
| 102 |
+
"default": false,
|
| 103 |
+
"description": "When this customer was first set up in Odoo."
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
"key": "tags",
|
| 107 |
+
"label": "Tags",
|
| 108 |
+
"type": "text",
|
| 109 |
+
"source": "odoo",
|
| 110 |
+
"default": false,
|
| 111 |
+
"description": "Odoo labels on this customer, comma-separated."
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
"key": "pricelist",
|
| 115 |
+
"label": "Price list",
|
| 116 |
+
"type": "text",
|
| 117 |
+
"source": "odoo",
|
| 118 |
+
"default": false,
|
| 119 |
+
"description": "The price list this customer buys on."
|
| 120 |
+
},
|
| 121 |
+
{
|
| 122 |
+
"key": "payment_terms",
|
| 123 |
+
"label": "Payment terms",
|
| 124 |
+
"type": "text",
|
| 125 |
+
"source": "odoo",
|
| 126 |
+
"default": false,
|
| 127 |
+
"description": "Payment terms on this customer's account β Net 30, for example."
|
| 128 |
+
},
|
| 129 |
+
{
|
| 130 |
+
"key": "last_order",
|
| 131 |
+
"label": "Last order",
|
| 132 |
+
"type": "date",
|
| 133 |
+
"source": "odoo",
|
| 134 |
+
"default": true,
|
| 135 |
+
"description": "Date of the most recent confirmed order."
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
"key": "overdue_days",
|
| 139 |
+
"label": "Overdue days",
|
| 140 |
+
"type": "int",
|
| 141 |
+
"source": "odoo",
|
| 142 |
+
"default": true,
|
| 143 |
+
"description": "How many days late this customer is running against their own usual ordering rhythm."
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
"_note": "filterable:false β DERIVED ANALYTIC: est_missed is min(cycles missed, 3) x AOV, a score we compute rather than an object the business has, so a condition on it would read as a fact about the customer when it is a fact about our arithmetic. It still displays and still sorts. Until wave 6 this flag also covered the frozen-window presets (revenue_ytd / revenue_ly / orders_24m / aov / yoy_pct); those are now DELETED outright under the owner's no-buildable-presets rule β see _comment. est_missed itself STAYS: no creatable measure or formula reproduces the cadence model behind it.",
|
| 147 |
+
"key": "est_missed",
|
| 148 |
+
"label": "Est. missed $",
|
| 149 |
+
"type": "currency",
|
| 150 |
+
"source": "odoo",
|
| 151 |
+
"default": true,
|
| 152 |
+
"agg": "sum",
|
| 153 |
+
"filterable": false,
|
| 154 |
+
"description": "Estimated sales missed while quiet: missed orders (capped at 3) times average order value. An estimate, not money owed."
|
| 155 |
+
},
|
| 156 |
+
{
|
| 157 |
+
"_note": "wave 21 R1 β KEY UNCHANGED, LABEL RENAMED. The computation is a DISJOINT split (ar.py credit_exposure): this column is only the not-yet-due residual, its sibling is the past-grace residual, and the two sum to the total. Under the label 'AR open $' the majority-late book read as 'Overdue > Open', which is nonsense in AR vocabulary β 'open' universally means the total. The label now says what the number is; the key stays so saved views and filters keep working.",
|
| 158 |
+
"key": "ar_open",
|
| 159 |
+
"label": "AR current $",
|
| 160 |
+
"type": "currency",
|
| 161 |
+
"source": "odoo",
|
| 162 |
+
"default": false,
|
| 163 |
+
"description": "Invoiced money owed but not yet due (a 5-day grace applies before it counts as overdue)."
|
| 164 |
+
},
|
| 165 |
+
{
|
| 166 |
+
"key": "ar_overdue",
|
| 167 |
+
"label": "AR overdue $",
|
| 168 |
+
"type": "currency",
|
| 169 |
+
"source": "odoo",
|
| 170 |
+
"default": false,
|
| 171 |
+
"description": "Invoiced money past due β same basis as the Collections page."
|
| 172 |
+
},
|
| 173 |
+
{
|
| 174 |
+
"_note": "wave 21 R1 β the TOTAL, added beside the rename above. AR current $ + AR overdue $, i.e. what most people mean by 'open AR'. Composed from the same ar.credit_exposure rows the siblings use, so it is transitively reconciled by ar.validate()'s residual read_group tie β no second oracle.",
|
| 175 |
+
"key": "ar_outstanding",
|
| 176 |
+
"label": "AR outstanding $",
|
| 177 |
+
"type": "currency",
|
| 178 |
+
"source": "odoo",
|
| 179 |
+
"default": false,
|
| 180 |
+
"description": "Total invoiced money owed right now: AR current $ plus AR overdue $."
|
| 181 |
+
},
|
| 182 |
+
{
|
| 183 |
+
"key": "ar_exposure",
|
| 184 |
+
"label": "Credit exposure $",
|
| 185 |
+
"type": "currency",
|
| 186 |
+
"source": "odoo",
|
| 187 |
+
"default": false,
|
| 188 |
+
"description": "The most you could be out if they stopped paying today: open, overdue, draft and not-yet-invoiced."
|
| 189 |
+
},
|
| 190 |
+
{
|
| 191 |
+
"key": "ar_aged_1_30",
|
| 192 |
+
"label": "1-30 days $",
|
| 193 |
+
"type": "currency",
|
| 194 |
+
"source": "odoo",
|
| 195 |
+
"default": false,
|
| 196 |
+
"description": "Overdue between 1 and 30 days. The four aging buckets sum to AR overdue $."
|
| 197 |
+
},
|
| 198 |
+
{
|
| 199 |
+
"key": "ar_aged_31_60",
|
| 200 |
+
"label": "31-60 days $",
|
| 201 |
+
"type": "currency",
|
| 202 |
+
"source": "odoo",
|
| 203 |
+
"default": false,
|
| 204 |
+
"description": "Overdue between 31 and 60 days. The four aging buckets sum to AR overdue $."
|
| 205 |
+
},
|
| 206 |
+
{
|
| 207 |
+
"key": "ar_aged_61_90",
|
| 208 |
+
"label": "61-90 days $",
|
| 209 |
+
"type": "currency",
|
| 210 |
+
"source": "odoo",
|
| 211 |
+
"default": false,
|
| 212 |
+
"description": "Overdue between 61 and 90 days. The four aging buckets sum to AR overdue $."
|
| 213 |
+
},
|
| 214 |
+
{
|
| 215 |
+
"key": "ar_aged_90_plus",
|
| 216 |
+
"label": "90+ days $",
|
| 217 |
+
"type": "currency",
|
| 218 |
+
"source": "odoo",
|
| 219 |
+
"default": false,
|
| 220 |
+
"description": "Overdue by more than 90 days. The four aging buckets sum to AR overdue $."
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"key": "days_to_pay",
|
| 224 |
+
"label": "Days to pay",
|
| 225 |
+
"type": "int",
|
| 226 |
+
"source": "odoo",
|
| 227 |
+
"default": false,
|
| 228 |
+
"description": "Average days to pay an invoice in full. Blank means no fully paid invoice yet."
|
| 229 |
+
},
|
| 230 |
+
{
|
| 231 |
+
"key": "top_category",
|
| 232 |
+
"label": "Top category",
|
| 233 |
+
"type": "text",
|
| 234 |
+
"source": "odoo",
|
| 235 |
+
"default": false,
|
| 236 |
+
"description": "The category this customer spent the most on in the last 12 months."
|
| 237 |
+
},
|
| 238 |
+
{
|
| 239 |
+
"key": "top_category_pct",
|
| 240 |
+
"label": "Top category %",
|
| 241 |
+
"type": "pct",
|
| 242 |
+
"source": "odoo",
|
| 243 |
+
"default": false,
|
| 244 |
+
"description": "Share of last-12-months spend that went to the top category."
|
| 245 |
+
},
|
| 246 |
+
{
|
| 247 |
+
"key": "sku_count",
|
| 248 |
+
"label": "SKUs bought",
|
| 249 |
+
"type": "int",
|
| 250 |
+
"source": "odoo",
|
| 251 |
+
"default": false,
|
| 252 |
+
"description": "Distinct products bought in the last 12 months."
|
| 253 |
+
},
|
| 254 |
+
{
|
| 255 |
+
"key": "top_sku",
|
| 256 |
+
"label": "Top SKU",
|
| 257 |
+
"type": "text",
|
| 258 |
+
"source": "odoo",
|
| 259 |
+
"default": false,
|
| 260 |
+
"description": "The product this customer spent the most on in the last 12 months."
|
| 261 |
+
},
|
| 262 |
+
{
|
| 263 |
+
"key": "days_since",
|
| 264 |
+
"label": "Days since order",
|
| 265 |
+
"type": "int",
|
| 266 |
+
"source": "odoo",
|
| 267 |
+
"default": false,
|
| 268 |
+
"description": "Days since the last confirmed order."
|
| 269 |
+
},
|
| 270 |
+
{
|
| 271 |
+
"key": "typical_gap_days",
|
| 272 |
+
"label": "Typical gap days",
|
| 273 |
+
"type": "int",
|
| 274 |
+
"source": "odoo",
|
| 275 |
+
"default": false,
|
| 276 |
+
"description": "Days this customer usually goes between orders, from their own history."
|
| 277 |
+
},
|
| 278 |
+
{
|
| 279 |
+
"key": "notes",
|
| 280 |
+
"label": "Notes",
|
| 281 |
+
"type": "text",
|
| 282 |
+
"source": "overlay",
|
| 283 |
+
"default": false,
|
| 284 |
+
"description": "Your notes on this customer. Saved in this app only, visible only to you."
|
| 285 |
+
}
|
| 286 |
+
],
|
| 287 |
+
"rows": [
|
| 288 |
+
{
|
| 289 |
+
"pid": 101,
|
| 290 |
+
"customer": "Poppy Flowers",
|
| 291 |
+
"status": "New",
|
| 292 |
+
"agent": "Naomi Linnell Rivera",
|
| 293 |
+
"city": "Charlottesville",
|
| 294 |
+
"state": "Virginia (US)",
|
| 295 |
+
"last_order": "2026-07-21",
|
| 296 |
+
"est_missed": 0,
|
| 297 |
+
"notes": "",
|
| 298 |
+
"country": "United States",
|
| 299 |
+
"zip": "02720",
|
| 300 |
+
"payment_terms": "30 Days",
|
| 301 |
+
"pricelist": "Fisch 1 (USD)",
|
| 302 |
+
"tags": "Royal",
|
| 303 |
+
"customer_since": "2023-01-10",
|
| 304 |
+
"salesperson": "Jessica",
|
| 305 |
+
"ar_open": 0,
|
| 306 |
+
"ar_overdue": 0,
|
| 307 |
+
"ar_exposure": 0,
|
| 308 |
+
"top_category": "Styrofoam",
|
| 309 |
+
"top_category_pct": 0.47,
|
| 310 |
+
"sku_count": 93,
|
| 311 |
+
"top_sku": "AQUAFOAM FLORAL FOAM BRICK | 48-Piece per Pack",
|
| 312 |
+
"days_to_pay": 34,
|
| 313 |
+
"_created": "2023-01-15 09:10:00",
|
| 314 |
+
"lat": 25.7617,
|
| 315 |
+
"lon": -80.1918,
|
| 316 |
+
"odoo_status": "Archived",
|
| 317 |
+
"dba": "Royal",
|
| 318 |
+
"ar_outstanding": 0
|
| 319 |
+
},
|
| 320 |
+
{
|
| 321 |
+
"pid": 102,
|
| 322 |
+
"customer": "Meadow & Vine Wholesale",
|
| 323 |
+
"status": "Growing",
|
| 324 |
+
"agent": "Carla Jimenez",
|
| 325 |
+
"city": "Portland",
|
| 326 |
+
"state": "Oregon (US)",
|
| 327 |
+
"last_order": "2026-07-19",
|
| 328 |
+
"est_missed": 0,
|
| 329 |
+
"notes": "Expanding to a second storefront.",
|
| 330 |
+
"country": "United States",
|
| 331 |
+
"zip": "77041",
|
| 332 |
+
"payment_terms": "Immediate Payment",
|
| 333 |
+
"pricelist": "Royal 1 (USD)",
|
| 334 |
+
"tags": "Royal, Key account",
|
| 335 |
+
"customer_since": "2024-02-11",
|
| 336 |
+
"salesperson": "Naomi",
|
| 337 |
+
"ar_open": 1240.5,
|
| 338 |
+
"ar_overdue": 0,
|
| 339 |
+
"ar_exposure": 1740.5,
|
| 340 |
+
"top_category": "Ribbon",
|
| 341 |
+
"top_category_pct": 0.95,
|
| 342 |
+
"sku_count": 4,
|
| 343 |
+
"top_sku": "2\" X 24\" X 36\" GREEN STYROFOAM BOARD",
|
| 344 |
+
"days_to_pay": null,
|
| 345 |
+
"_created": "2023-02-15 09:11:00",
|
| 346 |
+
"lat": 27.9506,
|
| 347 |
+
"lon": -82.4572,
|
| 348 |
+
"odoo_status": "Active",
|
| 349 |
+
"dba": "Fisch",
|
| 350 |
+
"ar_outstanding": 1240.5
|
| 351 |
+
},
|
| 352 |
+
{
|
| 353 |
+
"pid": 103,
|
| 354 |
+
"customer": "Bluestem Floral Supply",
|
| 355 |
+
"status": "Growing",
|
| 356 |
+
"agent": "Naomi Linnell Rivera",
|
| 357 |
+
"city": "Kansas City",
|
| 358 |
+
"state": "Missouri (US)",
|
| 359 |
+
"last_order": "2026-07-17",
|
| 360 |
+
"est_missed": 0,
|
| 361 |
+
"notes": "",
|
| 362 |
+
"country": "United States",
|
| 363 |
+
"zip": "11219",
|
| 364 |
+
"payment_terms": "60 Days",
|
| 365 |
+
"pricelist": "Royal 1 (USD)",
|
| 366 |
+
"tags": "Fisch",
|
| 367 |
+
"customer_since": "2025-03-12",
|
| 368 |
+
"salesperson": "Karen",
|
| 369 |
+
"ar_open": 0,
|
| 370 |
+
"ar_overdue": 5120.25,
|
| 371 |
+
"ar_exposure": 5120.25,
|
| 372 |
+
"top_category": "Foams & Finishes",
|
| 373 |
+
"top_category_pct": 0.31,
|
| 374 |
+
"sku_count": 27,
|
| 375 |
+
"top_sku": "SATIN RIBBON 2IN",
|
| 376 |
+
"days_to_pay": 61,
|
| 377 |
+
"_created": "2023-03-15 09:12:00",
|
| 378 |
+
"lat": 28.5384,
|
| 379 |
+
"lon": -81.3789,
|
| 380 |
+
"odoo_status": "Active",
|
| 381 |
+
"dba": "Both",
|
| 382 |
+
"ar_outstanding": 5120.25
|
| 383 |
+
},
|
| 384 |
+
{
|
| 385 |
+
"pid": 104,
|
| 386 |
+
"customer": "Camellia Row Florist",
|
| 387 |
+
"status": "Declining",
|
| 388 |
+
"agent": "Devon Marsh",
|
| 389 |
+
"city": "Savannah",
|
| 390 |
+
"state": "Georgia (US)",
|
| 391 |
+
"last_order": "2026-05-30",
|
| 392 |
+
"est_missed": 41200,
|
| 393 |
+
"notes": "Switched some volume to a local grower.",
|
| 394 |
+
"country": "United States",
|
| 395 |
+
"zip": "07649",
|
| 396 |
+
"payment_terms": "30 Days",
|
| 397 |
+
"pricelist": "Fisch 1 (USD)",
|
| 398 |
+
"tags": "(none)",
|
| 399 |
+
"customer_since": "2026-04-13",
|
| 400 |
+
"salesperson": "(none)",
|
| 401 |
+
"ar_open": 8300,
|
| 402 |
+
"ar_overdue": 940,
|
| 403 |
+
"ar_exposure": 11440,
|
| 404 |
+
"top_category": "All",
|
| 405 |
+
"top_category_pct": 1.0,
|
| 406 |
+
"sku_count": 1,
|
| 407 |
+
"top_sku": "(none)",
|
| 408 |
+
"days_to_pay": 12,
|
| 409 |
+
"_created": "2023-04-15 09:13:00",
|
| 410 |
+
"lat": 30.3322,
|
| 411 |
+
"lon": -81.6557,
|
| 412 |
+
"odoo_status": "Active",
|
| 413 |
+
"dba": "Royal",
|
| 414 |
+
"ar_outstanding": 9240
|
| 415 |
+
},
|
| 416 |
+
{
|
| 417 |
+
"pid": 105,
|
| 418 |
+
"customer": "Harborlight Wholesale Blooms",
|
| 419 |
+
"status": "Growing",
|
| 420 |
+
"agent": "Carla Jimenez",
|
| 421 |
+
"city": "Seattle",
|
| 422 |
+
"state": "Washington (US)",
|
| 423 |
+
"last_order": "2026-07-22",
|
| 424 |
+
"est_missed": 0,
|
| 425 |
+
"notes": "Top-10 account.",
|
| 426 |
+
"country": "United States",
|
| 427 |
+
"zip": "33125",
|
| 428 |
+
"payment_terms": "Immediate Payment",
|
| 429 |
+
"pricelist": "Royal 1 (USD)",
|
| 430 |
+
"tags": "Royal",
|
| 431 |
+
"customer_since": "2023-05-14",
|
| 432 |
+
"salesperson": "Jessica",
|
| 433 |
+
"ar_open": 0,
|
| 434 |
+
"ar_overdue": 0,
|
| 435 |
+
"ar_exposure": 0,
|
| 436 |
+
"top_category": "Styrofoam",
|
| 437 |
+
"top_category_pct": 0.47,
|
| 438 |
+
"sku_count": 93,
|
| 439 |
+
"top_sku": "AQUAFOAM FLORAL FOAM BRICK | 48-Piece per Pack",
|
| 440 |
+
"days_to_pay": 34,
|
| 441 |
+
"_created": "2023-05-15 09:14:00",
|
| 442 |
+
"lat": 26.1224,
|
| 443 |
+
"lon": -80.1373,
|
| 444 |
+
"odoo_status": "Active",
|
| 445 |
+
"dba": "",
|
| 446 |
+
"ar_outstanding": 0
|
| 447 |
+
},
|
| 448 |
+
{
|
| 449 |
+
"pid": 106,
|
| 450 |
+
"customer": "Dogwood & Fern Co.",
|
| 451 |
+
"status": "Dormant",
|
| 452 |
+
"agent": "Devon Marsh",
|
| 453 |
+
"city": "Asheville",
|
| 454 |
+
"state": "North Carolina (US)",
|
| 455 |
+
"last_order": "2026-02-11",
|
| 456 |
+
"est_missed": 52400,
|
| 457 |
+
"notes": "No spring order this year.",
|
| 458 |
+
"country": "United States",
|
| 459 |
+
"zip": "90210",
|
| 460 |
+
"payment_terms": "60 Days",
|
| 461 |
+
"pricelist": "Royal 1 (USD)",
|
| 462 |
+
"tags": "Royal, Key account",
|
| 463 |
+
"customer_since": "2024-06-15",
|
| 464 |
+
"salesperson": "Naomi",
|
| 465 |
+
"ar_open": 1240.5,
|
| 466 |
+
"ar_overdue": 0,
|
| 467 |
+
"ar_exposure": 1740.5,
|
| 468 |
+
"top_category": "Ribbon",
|
| 469 |
+
"top_category_pct": 0.95,
|
| 470 |
+
"sku_count": 4,
|
| 471 |
+
"top_sku": "2\" X 24\" X 36\" GREEN STYROFOAM BOARD",
|
| 472 |
+
"days_to_pay": null,
|
| 473 |
+
"_created": "2023-06-15 09:15:00",
|
| 474 |
+
"lat": 27.3364,
|
| 475 |
+
"lon": -82.5307,
|
| 476 |
+
"odoo_status": "Active",
|
| 477 |
+
"dba": "Fisch",
|
| 478 |
+
"ar_outstanding": 1240.5
|
| 479 |
+
},
|
| 480 |
+
{
|
| 481 |
+
"pid": 107,
|
| 482 |
+
"customer": "Verbena Market Florals",
|
| 483 |
+
"status": "Lost",
|
| 484 |
+
"agent": "Naomi Linnell Rivera",
|
| 485 |
+
"city": "Austin",
|
| 486 |
+
"state": "Texas (US)",
|
| 487 |
+
"last_order": "2025-11-04",
|
| 488 |
+
"est_missed": 78300,
|
| 489 |
+
"notes": "Went with a competitor on freight terms.",
|
| 490 |
+
"country": "United States",
|
| 491 |
+
"zip": "08701",
|
| 492 |
+
"payment_terms": "30 Days",
|
| 493 |
+
"pricelist": "Fisch 1 (USD)",
|
| 494 |
+
"tags": "Fisch",
|
| 495 |
+
"customer_since": "2025-07-16",
|
| 496 |
+
"salesperson": "Karen",
|
| 497 |
+
"ar_open": 0,
|
| 498 |
+
"ar_overdue": 5120.25,
|
| 499 |
+
"ar_exposure": 5120.25,
|
| 500 |
+
"top_category": "Foams & Finishes",
|
| 501 |
+
"top_category_pct": 0.31,
|
| 502 |
+
"sku_count": 27,
|
| 503 |
+
"top_sku": "SATIN RIBBON 2IN",
|
| 504 |
+
"days_to_pay": 61,
|
| 505 |
+
"_created": "2023-07-15 09:16:00",
|
| 506 |
+
"lat": null,
|
| 507 |
+
"lon": null,
|
| 508 |
+
"odoo_status": "Active",
|
| 509 |
+
"dba": "Both",
|
| 510 |
+
"ar_outstanding": 5120.25
|
| 511 |
+
},
|
| 512 |
+
{
|
| 513 |
+
"pid": 108,
|
| 514 |
+
"customer": "Larkspur Lane Supply",
|
| 515 |
+
"status": "New",
|
| 516 |
+
"agent": "Carla Jimenez",
|
| 517 |
+
"city": "Denver",
|
| 518 |
+
"state": "Colorado (US)",
|
| 519 |
+
"last_order": "2026-07-14",
|
| 520 |
+
"est_missed": 0,
|
| 521 |
+
"notes": "First order in April.",
|
| 522 |
+
"country": "United States",
|
| 523 |
+
"zip": "60614",
|
| 524 |
+
"payment_terms": "Immediate Payment",
|
| 525 |
+
"pricelist": "Royal 1 (USD)",
|
| 526 |
+
"tags": "(none)",
|
| 527 |
+
"customer_since": "2026-08-17",
|
| 528 |
+
"salesperson": "(none)",
|
| 529 |
+
"ar_open": 8300,
|
| 530 |
+
"ar_overdue": 940,
|
| 531 |
+
"ar_exposure": 11440,
|
| 532 |
+
"top_category": "All",
|
| 533 |
+
"top_category_pct": 1.0,
|
| 534 |
+
"sku_count": 1,
|
| 535 |
+
"top_sku": "(none)",
|
| 536 |
+
"days_to_pay": 12,
|
| 537 |
+
"_created": "2023-08-15 09:17:00",
|
| 538 |
+
"lat": 33.749,
|
| 539 |
+
"lon": -84.388,
|
| 540 |
+
"odoo_status": "Active",
|
| 541 |
+
"dba": "Royal",
|
| 542 |
+
"ar_outstanding": 9240
|
| 543 |
+
}
|
| 544 |
+
]
|
| 545 |
+
}
|
web/src/automation/AutomationBuilder.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/automation/AutomationDetail.tsx
CHANGED
|
@@ -171,6 +171,32 @@ export default function AutomationDetail({
|
|
| 171 |
const cfg = (automation.config || {}) as Record<string, string | number | boolean>;
|
| 172 |
const kind = automation.kind;
|
| 173 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
const [name, setName] = useState(automation.name || "");
|
| 175 |
const [cron, setCron] = useState(automation.schedule?.cron || "0 6 * * *");
|
| 176 |
const [message, setMessage] = useState("");
|
|
@@ -227,7 +253,10 @@ export default function AutomationDetail({
|
|
| 227 |
Math.min(Math.max(1, Number(cfg.maxPosts) || 10), 12)
|
| 228 |
);
|
| 229 |
|
| 230 |
-
// ---
|
|
|
|
|
|
|
|
|
|
| 231 |
const [recordsLimit, setRecordsLimit] = useState(Number(cfg.recordsLimit || 25));
|
| 232 |
const [joinOp, setJoinOp] = useState(String(cfg.operator || "and"));
|
| 233 |
/**
|
|
@@ -348,10 +377,22 @@ export default function AutomationDetail({
|
|
| 348 |
* β The trigger's OWN pickers keep using the trigger's own table; these are two different
|
| 349 |
* questions and answering both from one variable is what produced the bug.
|
| 350 |
*/
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
|
| 356 |
/**
|
| 357 |
* WHAT STARTS THIS ONE, as a key. The server's stored trigger when it ships one;
|
|
@@ -474,8 +515,11 @@ export default function AutomationDetail({
|
|
| 474 |
* a second reader of a string that exists to be shown to a person. One arithmetic, two
|
| 475 |
* wordings, and neither wording is parsed by anything.
|
| 476 |
*/
|
|
|
|
|
|
|
|
|
|
| 477 |
const heldCount = (): number =>
|
| 478 |
-
|
| 479 |
|
| 480 |
const heldNote = (): string => {
|
| 481 |
const held = heldCount();
|
|
@@ -498,7 +542,18 @@ export default function AutomationDetail({
|
|
| 498 |
targetLabel,
|
| 499 |
};
|
| 500 |
}
|
| 501 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
return {
|
| 503 |
recordsLimit,
|
| 504 |
operator: joinOp,
|
|
|
|
| 171 |
const cfg = (automation.config || {}) as Record<string, string | number | boolean>;
|
| 172 |
const kind = automation.kind;
|
| 173 |
|
| 174 |
+
/**
|
| 175 |
+
* ββ WAVE 30 Β· W30-T17 β IS THIS A CORPUS SEARCH? ONE PREDICATE, AND THAT IS THE POINT.
|
| 176 |
+
*
|
| 177 |
+
* β THE DEFECT THIS CLOSES, AND WHY IT WAS A 400 RATHER THAN A MISSING FEATURE.
|
| 178 |
+
* `automation_engine.py:clean_config`'s discovery branch is already SHARED across both kinds
|
| 179 |
+
* (`if kind in ("discover_instagram","discover_tiktok")`) and it reads `recordsLimit` with NO
|
| 180 |
+
* fallback to the stored value β so a config that omits the key is read as `limit = 0` and
|
| 181 |
+
* refused with the owner's exact sentence about an unbounded discovery query. Three arms in
|
| 182 |
+
* this file tested `kind === "discover_instagram"` as a literal, so a `discover_tiktok`
|
| 183 |
+
* automation fell through `buildConfig` to `{ targetTable }` and every save after the first
|
| 184 |
+
* 400'd β on a panel that was showing the person a records limit the whole time.
|
| 185 |
+
*
|
| 186 |
+
* β A SHARED PREDICATE, NOT A SECOND STRING TEST, AND THE REASON IS MEASURED RATHER THAN
|
| 187 |
+
* STYLISTIC: TikTok's kind shipped in wave 29 and was missed in FOUR places here and THREE in
|
| 188 |
+
* the engine, because each site was an independent literal and none of them was wrong on its
|
| 189 |
+
* own. B is making the same change server-side in the same wave for the same reason. The next
|
| 190 |
+
* platform gets missed once β here β instead of seven times.
|
| 191 |
+
*
|
| 192 |
+
* β NOT the same question as "does this automation have a TikTok trigger". The kind is what
|
| 193 |
+
* `clean_config` branches on and what `RUNNERS` dispatches on; the trigger is what a person
|
| 194 |
+
* picked. `clean_definition`'s law 1 derives one from the other, server-side, and this client
|
| 195 |
+
* reads the RESULT rather than re-deriving it β two derivations of one fact is how the two
|
| 196 |
+
* halves disagree.
|
| 197 |
+
*/
|
| 198 |
+
const isDiscovery = kind === "discover_instagram" || kind === "discover_tiktok";
|
| 199 |
+
|
| 200 |
const [name, setName] = useState(automation.name || "");
|
| 201 |
const [cron, setCron] = useState(automation.schedule?.cron || "0 6 * * *");
|
| 202 |
const [message, setMessage] = useState("");
|
|
|
|
| 253 |
Math.min(Math.max(1, Number(cfg.maxPosts) || 10), 12)
|
| 254 |
);
|
| 255 |
|
| 256 |
+
// --- the DISCOVERY half (R7) β `discover_instagram` and, since W30-T17, `discover_tiktok`.
|
| 257 |
+
// β The 25 is not this panel's invention: it mirrors `automation_engine.py`'s own
|
| 258 |
+
// `DISCOVER_SEED_RECORDS`, which is what the server writes when a discovery trigger is
|
| 259 |
+
// first picked. Two numbers here would be two answers to "how many by default".
|
| 260 |
const [recordsLimit, setRecordsLimit] = useState(Number(cfg.recordsLimit || 25));
|
| 261 |
const [joinOp, setJoinOp] = useState(String(cfg.operator || "and"));
|
| 262 |
/**
|
|
|
|
| 377 |
* β The trigger's OWN pickers keep using the trigger's own table; these are two different
|
| 378 |
* questions and answering both from one variable is what produced the bug.
|
| 379 |
*/
|
| 380 |
+
/*
|
| 381 |
+
β W30-T17 β BOTH DISCOVERY KINDS TAKE THIS BRANCH NOW, AND THE FALLBACK DELIBERATELY DOES NOT.
|
| 382 |
+
β `discover.table` IS THE INSTAGRAM VOCABULARY'S OWN TABLE, not "the discovery table".
|
| 383 |
+
MEASURED (scout, 2026-08-12): the wire carries exactly ONE `discover` object β built by
|
| 384 |
+
`routes_automation.py:list_automations` from `automation_engine.py:BD_FILTER_FIELDS` and
|
| 385 |
+
friends, with no platform argument anywhere on the route β and its `table` is Instagram's.
|
| 386 |
+
Widening this fallback to every discovery kind would therefore have offered a TikTok
|
| 387 |
+
automation the columns of an Instagram database: a list that looks authoritative and names
|
| 388 |
+
the wrong database, which is the exact failure the comment above says is worse than no list.
|
| 389 |
+
So TikTok gets `cfg.targetTable` (which `clean_config` sets to its own `ut_tt_profile`) or
|
| 390 |
+
NOTHING, and nothing renders as "this automation has no table for it to read" β true.
|
| 391 |
+
*/
|
| 392 |
+
const walkTable = isDiscovery
|
| 393 |
+
? String(cfg.targetTable || "") ||
|
| 394 |
+
(kind === "discover_instagram" ? String(discover?.table || "") : "")
|
| 395 |
+
: String(cfg.targetTable || "") || String(automation.trigger?.table || "");
|
| 396 |
|
| 397 |
/**
|
| 398 |
* WHAT STARTS THIS ONE, as a key. The server's stored trigger when it ships one;
|
|
|
|
| 515 |
* a second reader of a string that exists to be shown to a person. One arithmetic, two
|
| 516 |
* wordings, and neither wording is parsed by anything.
|
| 517 |
*/
|
| 518 |
+
/* W30-T17: both discovery kinds hold half-written rows back, because `completePreds` is what
|
| 519 |
+
`buildConfig` sends for both and a held row that is never announced is the silent-drop class
|
| 520 |
+
this module refuses everywhere else. A TikTok automation used to report 0 held, always. */
|
| 521 |
const heldCount = (): number =>
|
| 522 |
+
isDiscovery ? preds.length - completePreds().length : 0;
|
| 523 |
|
| 524 |
const heldNote = (): string => {
|
| 525 |
const held = heldCount();
|
|
|
|
| 542 |
targetLabel,
|
| 543 |
};
|
| 544 |
}
|
| 545 |
+
/*
|
| 546 |
+
β BOTH DISCOVERY KINDS, ONE SHAPE (W30-T17). This arm was `kind === "discover_instagram"`,
|
| 547 |
+
so a TikTok discovery automation fell all the way through to `{ targetTable }` and dropped
|
| 548 |
+
`recordsLimit` on EVERY save. The server's discovery branch reads that key with no
|
| 549 |
+
prev-fallback, so the drop is not "the old value survives" β it is `limit = 0`, which the
|
| 550 |
+
save door refuses outright. Instagram never showed it because this arm existed.
|
| 551 |
+
β The FIRST save is a different question and it is not fixed here: `pickTrigger` calls this
|
| 552 |
+
while `kind` is still `plain` (the server flips it after reading the trigger), so the
|
| 553 |
+
opening PATCH of a brand-new discovery automation carries no limit by construction. That
|
| 554 |
+
one is the engine's seed to set β W30-T04 β and it is why the two tickets are separate.
|
| 555 |
+
*/
|
| 556 |
+
if (isDiscovery) {
|
| 557 |
return {
|
| 558 |
recordsLimit,
|
| 559 |
operator: joinOp,
|
web/src/automation/AutomationSurface.tsx
CHANGED
|
@@ -14,7 +14,7 @@
|
|
| 14 |
// untouched.
|
| 15 |
// ---------------------------------------------------------------------------
|
| 16 |
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
| 17 |
-
import { useCallback, useEffect, useRef, useState } from "react";
|
| 18 |
|
| 19 |
import type { AutomationOpenDetail } from "../apiContract";
|
| 20 |
import { AUTOMATION_OPEN_EVENT } from "../apiContract";
|
|
@@ -26,16 +26,39 @@ import { AUTOMATION_OPEN_EVENT } from "../apiContract";
|
|
| 26 |
// WAVE'S BRAND while a comment asserted parity. So this imports the component that POINTS at the
|
| 27 |
// generated artifact. [[loopable-nav-logo-toggle]] β one element paints the mark.
|
| 28 |
import { Mark } from "../shell/Brand";
|
| 29 |
-
import AutomationDetail from "./AutomationDetail";
|
| 30 |
import type { Automation, AutomationList } from "./automationApi";
|
| 31 |
import {
|
| 32 |
AutomationError,
|
|
|
|
| 33 |
createAutomation,
|
| 34 |
listAutomations,
|
| 35 |
liveStepOf,
|
| 36 |
patchAutomation,
|
| 37 |
} from "./automationApi";
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
/**
|
| 40 |
* The run poll's cadence (item 6). Named constants because they are a MEASURED trade-off, not a
|
| 41 |
* taste: `POLL_MAX_MS` is the longest a finished run can still look live, and it is the only
|
|
@@ -185,7 +208,12 @@ function nextAutomationName(existing: Automation[]): string {
|
|
| 185 |
}
|
| 186 |
|
| 187 |
export default function AutomationSurface() {
|
| 188 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
const [error, setError] = useState("");
|
| 190 |
const [activeId, setActiveId] = useState<string>("");
|
| 191 |
const [railShut, setRailShut] = useState(false);
|
|
@@ -574,7 +602,10 @@ export default function AutomationSurface() {
|
|
| 574 |
The import went with it β it is what would break `npx tsc -b` for the WHOLE client the
|
| 575 |
moment session B deletes the file, which is why this deletion is sequenced first.
|
| 576 |
*/}
|
|
|
|
|
|
|
| 577 |
{active ? (
|
|
|
|
| 578 |
<AutomationDetail
|
| 579 |
key={active.id}
|
| 580 |
automation={active}
|
|
@@ -631,6 +662,7 @@ export default function AutomationSurface() {
|
|
| 631 |
await load();
|
| 632 |
}}
|
| 633 |
/>
|
|
|
|
| 634 |
) : (
|
| 635 |
/*
|
| 636 |
* THE EMPTY STATE IS ONE LINE AND A BUTTON (owner ruling R13 β "never
|
|
|
|
| 14 |
// untouched.
|
| 15 |
// ---------------------------------------------------------------------------
|
| 16 |
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
| 17 |
+
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
|
| 18 |
|
| 19 |
import type { AutomationOpenDetail } from "../apiContract";
|
| 20 |
import { AUTOMATION_OPEN_EVENT } from "../apiContract";
|
|
|
|
| 26 |
// WAVE'S BRAND while a comment asserted parity. So this imports the component that POINTS at the
|
| 27 |
// generated artifact. [[loopable-nav-logo-toggle]] β one element paints the mark.
|
| 28 |
import { Mark } from "../shell/Brand";
|
|
|
|
| 29 |
import type { Automation, AutomationList } from "./automationApi";
|
| 30 |
import {
|
| 31 |
AutomationError,
|
| 32 |
+
cachedAutomations,
|
| 33 |
createAutomation,
|
| 34 |
listAutomations,
|
| 35 |
liveStepOf,
|
| 36 |
patchAutomation,
|
| 37 |
} from "./automationApi";
|
| 38 |
|
| 39 |
+
/**
|
| 40 |
+
* ββ WAVE 30 Β· T21 (owner items 4 + 5) β THE EDITOR STOPS SHIPPING INSIDE THE LIST.
|
| 41 |
+
*
|
| 42 |
+
* β THIS `lazy()` IS THE TICKET. `AutomationDetail` was a STATIC import, and it statically pulls
|
| 43 |
+
* `AutomationBuilder` (which in turn pulls `CondBuilder`, `PresetPlan` and `TriggerPicker`),
|
| 44 |
+
* `AutomationFind` and `AutomationTrigger` β so the whole editor was linked into the one chunk a
|
| 45 |
+
* person must download before the RAIL can paint a list of names. MEASURED at 88,771 B, against
|
| 46 |
+
* `HomePage` at 4,814 and `ConnectorsPage` at 3,551. That is the "Automation takes forever to
|
| 47 |
+
* load" complaint, and it had survived two waves because nothing asserted a number.
|
| 48 |
+
*
|
| 49 |
+
* β AND IT NEEDS ITS OWN `<Suspense>`, NOT `Shell.tsx`'s. `Lazily` already wraps this whole
|
| 50 |
+
* surface, but a Suspense boundary catches ANY suspending descendant β so without an inner
|
| 51 |
+
* boundary, opening an automation would suspend the boundary that owns the RAIL and blank the
|
| 52 |
+
* list you just clicked in. The inner one keeps the rail painted while the editor streams in.
|
| 53 |
+
* `fallback={null}` is deliberate and is `Lazily`'s own choice, not a shortcut: the work area is
|
| 54 |
+
* simply not there for the moment the chunk is in flight, which is what every other route in this
|
| 55 |
+
* product already does. β A skeleton here would be a NEW loading screen in the same wave that
|
| 56 |
+
* exists to remove one (T22).
|
| 57 |
+
* β Default export, checked: `Shell.tsx:53` records that `lazy()` over a NAMED export fails at
|
| 58 |
+
* runtime, on click, and only for that one surface β `AutomationDetail` exports default.
|
| 59 |
+
*/
|
| 60 |
+
const AutomationDetail = lazy(() => import("./AutomationDetail"));
|
| 61 |
+
|
| 62 |
/**
|
| 63 |
* The run poll's cadence (item 6). Named constants because they are a MEASURED trade-off, not a
|
| 64 |
* taste: `POLL_MAX_MS` is the longest a finished run can still look live, and it is the only
|
|
|
|
| 208 |
}
|
| 209 |
|
| 210 |
export default function AutomationSurface() {
|
| 211 |
+
/* β T22 β SEEDED FROM THE CLIENT MEMO, so a revisit inside the freshness window never passes
|
| 212 |
+
through `data === null` and therefore never paints the skeleton. The lazy initialiser runs
|
| 213 |
+
once, on mount, BEFORE the first paint β `useState(cachedAutomations())` would call it on
|
| 214 |
+
every render instead, which is the same value at needless cost. A cold start still returns
|
| 215 |
+
null and still gets the skeleton: it is made rare, not removed (see `automationApi.ts`). */
|
| 216 |
+
const [data, setData] = useState<AutomationList | null>(() => cachedAutomations());
|
| 217 |
const [error, setError] = useState("");
|
| 218 |
const [activeId, setActiveId] = useState<string>("");
|
| 219 |
const [railShut, setRailShut] = useState(false);
|
|
|
|
| 602 |
The import went with it β it is what would break `npx tsc -b` for the WHOLE client the
|
| 603 |
moment session B deletes the file, which is why this deletion is sequenced first.
|
| 604 |
*/}
|
| 605 |
+
{/* T21: the editor's own Suspense boundary β see the `lazy()` above for why it cannot be
|
| 606 |
+
`Shell.tsx`'s. `fallback={null}`, matching `Lazily`. */}
|
| 607 |
{active ? (
|
| 608 |
+
<Suspense fallback={null}>
|
| 609 |
<AutomationDetail
|
| 610 |
key={active.id}
|
| 611 |
automation={active}
|
|
|
|
| 662 |
await load();
|
| 663 |
}}
|
| 664 |
/>
|
| 665 |
+
</Suspense>
|
| 666 |
) : (
|
| 667 |
/*
|
| 668 |
* THE EMPTY STATE IS ONE LINE AND A BUTTON (owner ruling R13 β "never
|
web/src/automation/TriggerPicker.tsx
CHANGED
|
@@ -27,6 +27,8 @@
|
|
| 27 |
// ---------------------------------------------------------------------------
|
| 28 |
import { useEffect, useRef, useState } from "react";
|
| 29 |
|
|
|
|
|
|
|
| 30 |
import type { TriggerOption } from "./automationApi";
|
| 31 |
import { groupTriggers, selectedTrigger } from "./steps";
|
| 32 |
|
|
@@ -46,6 +48,18 @@ import { groupTriggers, selectedTrigger } from "./steps";
|
|
| 46 |
* icons).
|
| 47 |
*/
|
| 48 |
export function TriggerMark({ kind = "" }: { kind?: string }) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
const common = {
|
| 50 |
width: 15, height: 15, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor",
|
| 51 |
strokeWidth: 1.4, strokeLinecap: "round" as const, strokeLinejoin: "round" as const,
|
|
@@ -234,7 +248,7 @@ function TriggerList({
|
|
| 234 |
title={t.detail || ""}
|
| 235 |
onClick={() => onPick(t.key)}
|
| 236 |
>
|
| 237 |
-
<span className="autox-card-mark">
|
| 238 |
<TriggerMark kind={t.key} />
|
| 239 |
</span>
|
| 240 |
<span className="autox-menu-text">
|
|
@@ -271,11 +285,42 @@ function TriggerList({
|
|
| 271 |
reading "connector" in lower case. */}
|
| 272 |
{g.label ? <p className="autox-menu-head">{g.label}</p> : null}
|
| 273 |
{g.rows.map(row)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
{g.sub.map((s) => (
|
| 275 |
-
<
|
| 276 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
{s.rows.map(row)}
|
| 278 |
-
</
|
| 279 |
))}
|
| 280 |
</div>
|
| 281 |
))}
|
|
@@ -367,7 +412,9 @@ export default function TriggerPicker({
|
|
| 367 |
The `<select>` this replaces carried the same guard, and without it a trigger pointed
|
| 368 |
at something withheld from this session would LOOK unconfigured and the next patch
|
| 369 |
would write the blank over it. */}
|
| 370 |
-
<span
|
|
|
|
|
|
|
| 371 |
<TriggerMark kind={picked?.key || ""} />
|
| 372 |
</span>
|
| 373 |
<span className="autox-trigbtn-label">
|
|
|
|
| 27 |
// ---------------------------------------------------------------------------
|
| 28 |
import { useEffect, useRef, useState } from "react";
|
| 29 |
|
| 30 |
+
import { brandForConnector, brandForKind, hasBrandKind } from "../connectors/brandMarks";
|
| 31 |
+
|
| 32 |
import type { TriggerOption } from "./automationApi";
|
| 33 |
import { groupTriggers, selectedTrigger } from "./steps";
|
| 34 |
|
|
|
|
| 48 |
* icons).
|
| 49 |
*/
|
| 50 |
export function TriggerMark({ kind = "" }: { kind?: string }) {
|
| 51 |
+
/*
|
| 52 |
+
ββ WAVE 30 Β· ITEM 7 / R3 + R4 β A BRAND WINS OVER THE HOUSE GLYPH, and it is resolved HERE
|
| 53 |
+
rather than at the four call sites. `TriggerMark` is drawn in the flyout, in the compact
|
| 54 |
+
Properties control, and by `AutomationBuilder` on the trigger card; a per-call-site check is
|
| 55 |
+
three chances to give the same trigger two identities on one screen.
|
| 56 |
+
β The geometric glyphs BELOW ARE NOT DELETED. They are the fallback for every unbranded
|
| 57 |
+
trigger and, for the two branded ones, the record of what this picker drew before the owner
|
| 58 |
+
asked for logos β a magnifier with a person in it, and the same frame with a note. Deleting
|
| 59 |
+
them would also delete the only description of why those two shapes differed.
|
| 60 |
+
*/
|
| 61 |
+
const brand = brandForKind(kind);
|
| 62 |
+
if (brand) return brand;
|
| 63 |
const common = {
|
| 64 |
width: 15, height: 15, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor",
|
| 65 |
strokeWidth: 1.4, strokeLinecap: "round" as const, strokeLinejoin: "round" as const,
|
|
|
|
| 248 |
title={t.detail || ""}
|
| 249 |
onClick={() => onPick(t.key)}
|
| 250 |
>
|
| 251 |
+
<span className={"autox-card-mark" + (hasBrandKind(t.key) ? " is-brand" : "")}>
|
| 252 |
<TriggerMark kind={t.key} />
|
| 253 |
</span>
|
| 254 |
<span className="autox-menu-text">
|
|
|
|
| 285 |
reading "connector" in lower case. */}
|
| 286 |
{g.label ? <p className="autox-menu-head">{g.label}</p> : null}
|
| 287 |
{g.rows.map(row)}
|
| 288 |
+
{/*
|
| 289 |
+
ββ WAVE 30 Β· ITEM 7 / R3 β THE CONNECTOR NEST IS A COLLAPSED DISCLOSURE NOW.
|
| 290 |
+
The owner, verbatim and for the third wave: *"Only when I click 'Scraper' under each
|
| 291 |
+
automation trigger and actions would I see the option to choose either Instagram OR
|
| 292 |
+
TikTok. That's it."*
|
| 293 |
+
|
| 294 |
+
β WHAT WAS HERE WAS THE COMPLAINT ITSELF, and it was an ASYMMETRY rather than a
|
| 295 |
+
missing feature: this rendered `<div className="autox-menu-sub">` with a `<p>` heading
|
| 296 |
+
β always open, merely indented β while `AutomationBuilder`'s action menu four inches
|
| 297 |
+
away already used exactly the markup below. One menu asked you to click; the other
|
| 298 |
+
showed you everything. So this is not a new pattern, it is the OTHER menu's pattern,
|
| 299 |
+
copied verbatim so the two cannot drift apart again.
|
| 300 |
+
|
| 301 |
+
β `<details>` and not a hand-built disclosure: it opens on Enter and Space with no
|
| 302 |
+
keydown handler, it needs no open/closed state of its own to get wrong, and it is
|
| 303 |
+
CLOSED by default, which is the whole ruling.
|
| 304 |
+
β The summary wears the same 26px `autox-card-mark` the rows beneath it wear (T19's
|
| 305 |
+
marks). A smaller slot here was the first version and it made the heading look like a
|
| 306 |
+
different KIND of thing from its own children β which is the asymmetry again, one
|
| 307 |
+
level down.
|
| 308 |
+
*/}
|
| 309 |
{g.sub.map((s) => (
|
| 310 |
+
<details className="autox-menu-nest" key={s.key}>
|
| 311 |
+
<summary className="autox-menu-row autox-menu-sum">
|
| 312 |
+
{brandForConnector(s.key) ? (
|
| 313 |
+
<span className="autox-card-mark is-brand">{brandForConnector(s.key)}</span>
|
| 314 |
+
) : null}
|
| 315 |
+
<span className="autox-menu-text">
|
| 316 |
+
<span className="autox-menu-label">{s.label}</span>
|
| 317 |
+
<span className="autox-menu-detail">
|
| 318 |
+
{s.rows.length} trigger{s.rows.length === 1 ? "" : "s"}
|
| 319 |
+
</span>
|
| 320 |
+
</span>
|
| 321 |
+
</summary>
|
| 322 |
{s.rows.map(row)}
|
| 323 |
+
</details>
|
| 324 |
))}
|
| 325 |
</div>
|
| 326 |
))}
|
|
|
|
| 412 |
The `<select>` this replaces carried the same guard, and without it a trigger pointed
|
| 413 |
at something withheld from this session would LOOK unconfigured and the next patch
|
| 414 |
would write the blank over it. */}
|
| 415 |
+
<span
|
| 416 |
+
className={"autox-card-mark" + (hasBrandKind(picked?.key || "") ? " is-brand" : "")}
|
| 417 |
+
>
|
| 418 |
<TriggerMark kind={picked?.key || ""} />
|
| 419 |
</span>
|
| 420 |
<span className="autox-trigbtn-label">
|
web/src/automation/automationApi.ts
CHANGED
|
@@ -24,11 +24,24 @@ import { API_V1, CREDENTIALS, UNAUTHORIZED_EVENT, checkTenant, signal } from "..
|
|
| 24 |
* `scrape_db` and `field_instagram` survive on the two automations that already use them and
|
| 25 |
* no new one can be created (R6); `discover_instagram` is now reached by PICKING the
|
| 26 |
* `ig_profile_match` trigger rather than by a wizard.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
*/
|
| 28 |
export type AutomationKind =
|
| 29 |
| "scrape_db"
|
| 30 |
| "field_instagram"
|
| 31 |
| "discover_instagram"
|
|
|
|
| 32 |
| "plain";
|
| 33 |
export type RunState = "idle" | "running" | "ok" | "error" | "partial";
|
| 34 |
/*
|
|
@@ -809,8 +822,52 @@ function send<T>(path: string, init?: RequestInit): Promise<T> {
|
|
| 809 |
}).then((r) => result<T>(r));
|
| 810 |
}
|
| 811 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 812 |
export function listAutomations(abort?: AbortSignal): Promise<AutomationList> {
|
| 813 |
-
return send<AutomationList>("/automations", { signal: abort })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 814 |
}
|
| 815 |
|
| 816 |
export function listTables(abort?: AbortSignal): Promise<{ tables: UserTable[] }> {
|
|
|
|
| 24 |
* `scrape_db` and `field_instagram` survive on the two automations that already use them and
|
| 25 |
* no new one can be created (R6); `discover_instagram` is now reached by PICKING the
|
| 26 |
* `ig_profile_match` trigger rather than by a wizard.
|
| 27 |
+
*
|
| 28 |
+
* ββ WAVE 30 Β· W30-T17 β `discover_tiktok` JOINS, AND IT WAS MISSING FROM THIS UNION SINCE THE
|
| 29 |
+
* DAY THE KIND SHIPPED. `automation_engine.py:KINDS` has carried it since wave 29, and
|
| 30 |
+
* `clean_definition`'s law-1 arm flips a `tiktok_profile_match` trigger to it UNCONDITIONALLY β
|
| 31 |
+
* so the server has been storing a kind this client's own type system said could not exist.
|
| 32 |
+
* β THE COST WAS NOT COSMETIC. A union that cannot express the value makes `kind ===
|
| 33 |
+
* "discover_tiktok"` a COMPILE ERROR, so every arm that had to learn TikTok
|
| 34 |
+
* (`buildConfig`, `heldCount`, `walkTable`) could not be written even by someone who noticed β
|
| 35 |
+
* the type was quietly enforcing the bug. This is the client half of [[wire-takes-a-list-caller-passes-one]]
|
| 36 |
+
* turned inside out: the wire carried a value the caller could not name.
|
| 37 |
+
* β It is a DISCOVERY kind, so the rule to reach for is the shared predicate, never a fourth
|
| 38 |
+
* string test: `AutomationDetail.tsx:isDiscovery` is the one place that decides.
|
| 39 |
*/
|
| 40 |
export type AutomationKind =
|
| 41 |
| "scrape_db"
|
| 42 |
| "field_instagram"
|
| 43 |
| "discover_instagram"
|
| 44 |
+
| "discover_tiktok"
|
| 45 |
| "plain";
|
| 46 |
export type RunState = "idle" | "running" | "ok" | "error" | "partial";
|
| 47 |
/*
|
|
|
|
| 822 |
}).then((r) => result<T>(r));
|
| 823 |
}
|
| 824 |
|
| 825 |
+
/**
|
| 826 |
+
* ββ WAVE 30 Β· T22 (owner item 4) β THE CLIENT MEMO THAT MAKES THE LOADING SCREEN RARE.
|
| 827 |
+
*
|
| 828 |
+
* β THE SKELETON IS NOT THE DEFECT AND MUST NOT BE DELETED. `AutomationSurface` renders
|
| 829 |
+
* `.auto-rail-loading` while `data === null`, and W29-T01 added it for a real reason: before it,
|
| 830 |
+
* a person who owns several automations was told "No automations yet." on every single visit,
|
| 831 |
+
* for the whole first round trip. Deleting it restores that false-empty. **The fix is to make
|
| 832 |
+
* `data === null` UNREACHABLE on a revisit, not to hide what it renders.**
|
| 833 |
+
*
|
| 834 |
+
* β WHY THE MEMO LIVES HERE AND NOT IN THE COMPONENT, which is the whole win: `Shell.tsx:1155`
|
| 835 |
+
* ALREADY calls `listAutomations()` on Home mount for its automation tiles. Memoising at this
|
| 836 |
+
* door β the one function both callers share β means Home β Automation is paid for before the
|
| 837 |
+
* click, with **no edit to `shell/`** (another session's fence). A cache inside the component
|
| 838 |
+
* could never see Shell's call.
|
| 839 |
+
*
|
| 840 |
+
* β STALE-WHILE-REVALIDATE, STATED: the seed paints last-known rows and the mount effect still
|
| 841 |
+
* fetches, so a revisit shows real names instantly and corrects itself. Every mutation path in
|
| 842 |
+
* the surface already calls `load()`, which refreshes this memo, so nothing has to remember to
|
| 843 |
+
* invalidate it. An error leaves the memo intact ON PURPOSE β last-known rows beside an error
|
| 844 |
+
* banner beat an empty rail that blames the reader.
|
| 845 |
+
* β Module scope, so it dies with the tab. It is a paint accelerator, never storage β nothing
|
| 846 |
+
* here may outlive a session, because the account decides the tenant [[aios-multi-tenancy]].
|
| 847 |
+
*/
|
| 848 |
+
const AUTOMATIONS_FRESH_MS = 5 * 60 * 1000;
|
| 849 |
+
|
| 850 |
+
let listMemo: { at: number; data: AutomationList } | null = null;
|
| 851 |
+
|
| 852 |
+
/** The last list, if it is still fresh β otherwise `null`, which is a COLD start and says so. */
|
| 853 |
+
export function cachedAutomations(): AutomationList | null {
|
| 854 |
+
if (!listMemo) return null;
|
| 855 |
+
if (Date.now() - listMemo.at > AUTOMATIONS_FRESH_MS) return null;
|
| 856 |
+
return listMemo.data;
|
| 857 |
+
}
|
| 858 |
+
|
| 859 |
+
/** Drop the memo. For a tenant/identity change β a paint accelerator must never cross accounts. */
|
| 860 |
+
export function forgetAutomations(): void {
|
| 861 |
+
listMemo = null;
|
| 862 |
+
}
|
| 863 |
+
|
| 864 |
export function listAutomations(abort?: AbortSignal): Promise<AutomationList> {
|
| 865 |
+
return send<AutomationList>("/automations", { signal: abort }).then((data) => {
|
| 866 |
+
// β ONLY ON SUCCESS. `send` throws on a non-2xx, so a failed poll cannot overwrite a good
|
| 867 |
+
// list with nothing β which would turn a transient 500 into the false-empty above.
|
| 868 |
+
listMemo = { at: Date.now(), data };
|
| 869 |
+
return data;
|
| 870 |
+
});
|
| 871 |
}
|
| 872 |
|
| 873 |
export function listTables(abort?: AbortSignal): Promise<{ tables: UserTable[] }> {
|
web/src/connectors/ConnectorsPage.tsx
CHANGED
|
@@ -8,10 +8,19 @@
|
|
| 8 |
// touching the shell's "an undeclared surface is denied" law (nav.ts'
|
| 9 |
// `CHROME_ROUTES` note carries the full argument).
|
| 10 |
//
|
| 11 |
-
//
|
| 12 |
-
//
|
| 13 |
-
//
|
| 14 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
//
|
| 16 |
// β NO CLIENT UNION OVER `state` OR `kind`. They are the server's vocabulary and
|
| 17 |
// arrive as strings (the wave-9 law): a client `type State = "connected" | β¦`
|
|
@@ -20,6 +29,7 @@
|
|
| 20 |
|
| 21 |
import { useEffect, useState } from "react";
|
| 22 |
import { API_V1, CREDENTIALS } from "../apiContract";
|
|
|
|
| 23 |
|
| 24 |
/** One row of the directory. Everything optional but `key` + `label` is genuinely optional on
|
| 25 |
* the wire β a builtin has no provider, a planned connector has no `connectedAs`. */
|
|
@@ -56,6 +66,22 @@ export interface ConnectorRow {
|
|
| 56 |
note?: string;
|
| 57 |
/** What the admin will be asked for β shown where the decision is made, not in a tour. */
|
| 58 |
hint?: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
}
|
| 60 |
|
| 61 |
type Load =
|
|
@@ -65,6 +91,25 @@ type Load =
|
|
| 65 |
|
| 66 |
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
export function parseDirectory(body: unknown): ConnectorRow[] {
|
| 69 |
const raw = (body as { connectors?: unknown } | null)?.connectors;
|
| 70 |
if (!Array.isArray(raw)) return [];
|
|
@@ -90,13 +135,20 @@ export function parseDirectory(body: unknown): ConnectorRow[] {
|
|
| 90 |
...(str(c.startUrl) ? { startUrl: str(c.startUrl) } : {}),
|
| 91 |
...(str(c.note) ? { note: str(c.note) } : {}),
|
| 92 |
...(str(c.hint) ? { hint: str(c.hint) } : {}),
|
|
|
|
|
|
|
|
|
|
| 93 |
});
|
| 94 |
}
|
| 95 |
return out;
|
| 96 |
}
|
| 97 |
|
| 98 |
-
/** The card's mark: two letters from the connector's own name, in the database chip
|
| 99 |
-
* Unicode-aware for the same reason the account monogram is (`Shell.tsx:299`).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
export function tileText(label: string): string {
|
| 101 |
const chars = String(label ?? "").match(/[\p{L}\p{N}]/gu) ?? [];
|
| 102 |
return chars.slice(0, 2).join("").toUpperCase();
|
|
@@ -205,12 +257,35 @@ export default function ConnectorsPage({
|
|
| 205 |
) : null}
|
| 206 |
|
| 207 |
<div className="conn-grid">
|
| 208 |
-
{rows.map((row) =>
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
<div
|
| 213 |
-
key={row.key}
|
| 214 |
className={"conn-card" + (planned ? " is-planned" : "")}
|
| 215 |
// β `aria-disabled`, NOT `inert`/`pointer-events` alone. A planned card is
|
| 216 |
// non-interactive by R8, and a card that merely LOOKS faded while still taking a
|
|
@@ -220,9 +295,20 @@ export default function ConnectorsPage({
|
|
| 220 |
{...(planned ? { "aria-disabled": true } : {})}
|
| 221 |
>
|
| 222 |
<div className="conn-card-head">
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
<span className="conn-card-name">{row.label}</span>
|
| 227 |
<span className={"conn-state" + stateTone(row.state)}>
|
| 228 |
{/* The server's own word, sentence-cased by CSS never by `toUpperCase` β R6
|
|
@@ -231,6 +317,33 @@ export default function ConnectorsPage({
|
|
| 231 |
</span>
|
| 232 |
</div>
|
| 233 |
<p className="conn-card-desc">{row.desc}</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
{connected && row.connectedAs ? (
|
| 235 |
<p className="conn-card-meta">Connected as {row.connectedAs}</p>
|
| 236 |
) : null}
|
|
@@ -266,9 +379,5 @@ export default function ConnectorsPage({
|
|
| 266 |
</a>
|
| 267 |
) : null}
|
| 268 |
</div>
|
| 269 |
-
);
|
| 270 |
-
})}
|
| 271 |
-
</div>
|
| 272 |
-
</div>
|
| 273 |
);
|
| 274 |
}
|
|
|
|
| 8 |
// touching the shell's "an undeclared surface is denied" law (nav.ts'
|
| 9 |
// `CHROME_ROUTES` note carries the full argument).
|
| 10 |
//
|
| 11 |
+
// ββ WAVE 30 Β· R4 SUPERSEDES WAVE-23 R8 FOR CONNECTOR IDENTITY. A connector card
|
| 12 |
+
// wears its company's REAL mark, drawn as inline SVG by `./brandMarks`. The owner
|
| 13 |
+
// asked for this three times; what stood here was the rule that refused it:
|
| 14 |
+
//
|
| 15 |
+
// "β NO THIRD-PARTY LOGOS AND NO EMOJIS (R8 + DESIGN.md Β§4). A connector's mark
|
| 16 |
+
// is a two-letter tile in the chip family every database already wearsβ¦"
|
| 17 |
+
//
|
| 18 |
+
// β THE REST OF THAT RULE IS UNCHANGED and is not weakened by R4: still no emojis
|
| 19 |
+
// anywhere, still nothing shipped, licensed or hotlinked (every mark is a React
|
| 20 |
+
// component in this directory), and `tileText` SURVIVES as the fallback for any
|
| 21 |
+
// connector we have no logo for β a two-letter tile is the honest answer to "we
|
| 22 |
+
// do not have that company's mark", and deleting it would have left every planned
|
| 23 |
+
// row with an empty square.
|
| 24 |
//
|
| 25 |
// β NO CLIENT UNION OVER `state` OR `kind`. They are the server's vocabulary and
|
| 26 |
// arrive as strings (the wave-9 law): a client `type State = "connected" | β¦`
|
|
|
|
| 29 |
|
| 30 |
import { useEffect, useState } from "react";
|
| 31 |
import { API_V1, CREDENTIALS } from "../apiContract";
|
| 32 |
+
import { brandForConnector } from "./brandMarks";
|
| 33 |
|
| 34 |
/** One row of the directory. Everything optional but `key` + `label` is genuinely optional on
|
| 35 |
* the wire β a builtin has no provider, a planned connector has no `connectedAs`. */
|
|
|
|
| 66 |
note?: string;
|
| 67 |
/** What the admin will be asked for β shown where the decision is made, not in a tour. */
|
| 68 |
hint?: string;
|
| 69 |
+
/**
|
| 70 |
+
* β WAVE 30 Β· R4 β the PLATFORMS behind one connector, today only the Scraper's.
|
| 71 |
+
*
|
| 72 |
+
* β NOT ROWS OF THEIR OWN, and the ruling is precisely about that: *"Only when I click
|
| 73 |
+
* 'Scraper' β¦ would I see the option to choose either Instagram OR TikTok."* They carry no
|
| 74 |
+
* `state`, no credential and no manage door, because one key configures both β a per-platform
|
| 75 |
+
* state would be a second answer to a question that already has one.
|
| 76 |
+
*/
|
| 77 |
+
platforms?: ConnectorPlatform[];
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
/** One platform inside a connector's drill. Name, sentence, and the key its mark is drawn from. */
|
| 81 |
+
export interface ConnectorPlatform {
|
| 82 |
+
key: string;
|
| 83 |
+
label: string;
|
| 84 |
+
desc: string;
|
| 85 |
}
|
| 86 |
|
| 87 |
type Load =
|
|
|
|
| 91 |
|
| 92 |
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
| 93 |
|
| 94 |
+
/**
|
| 95 |
+
* The platform list, read the way `parseDirectory` reads a row: a platform with no name is
|
| 96 |
+
* DROPPED rather than rendered from its key, and a payload with no `platforms` at all yields
|
| 97 |
+
* `[]` β an older server simply shows a card with no drill, which is the honest degradation.
|
| 98 |
+
*/
|
| 99 |
+
export function parsePlatforms(raw: unknown): ConnectorPlatform[] {
|
| 100 |
+
if (!Array.isArray(raw)) return [];
|
| 101 |
+
const out: ConnectorPlatform[] = [];
|
| 102 |
+
for (const item of raw) {
|
| 103 |
+
if (!item || typeof item !== "object") continue;
|
| 104 |
+
const p = item as Record<string, unknown>;
|
| 105 |
+
const key = str(p.key).trim();
|
| 106 |
+
const label = str(p.label).trim();
|
| 107 |
+
if (!key || !label) continue;
|
| 108 |
+
out.push({ key, label, desc: str(p.desc) });
|
| 109 |
+
}
|
| 110 |
+
return out;
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
export function parseDirectory(body: unknown): ConnectorRow[] {
|
| 114 |
const raw = (body as { connectors?: unknown } | null)?.connectors;
|
| 115 |
if (!Array.isArray(raw)) return [];
|
|
|
|
| 135 |
...(str(c.startUrl) ? { startUrl: str(c.startUrl) } : {}),
|
| 136 |
...(str(c.note) ? { note: str(c.note) } : {}),
|
| 137 |
...(str(c.hint) ? { hint: str(c.hint) } : {}),
|
| 138 |
+
...(parsePlatforms(c.platforms).length
|
| 139 |
+
? { platforms: parsePlatforms(c.platforms) }
|
| 140 |
+
: {}),
|
| 141 |
});
|
| 142 |
}
|
| 143 |
return out;
|
| 144 |
}
|
| 145 |
|
| 146 |
+
/** The card's FALLBACK mark: two letters from the connector's own name, in the database chip
|
| 147 |
+
* family. Unicode-aware for the same reason the account monogram is (`Shell.tsx:299`).
|
| 148 |
+
* β SINCE WAVE 30 IT IS THE FALLBACK, NOT THE RULE (R4) β a connector we have a logo for wears
|
| 149 |
+
* the logo, and everything else still wears these two letters. Deleting this on the way to
|
| 150 |
+
* "real logos everywhere" would have emptied every card the mark module has no entry for,
|
| 151 |
+
* which is most of the planned list. */
|
| 152 |
export function tileText(label: string): string {
|
| 153 |
const chars = String(label ?? "").match(/[\p{L}\p{N}]/gu) ?? [];
|
| 154 |
return chars.slice(0, 2).join("").toUpperCase();
|
|
|
|
| 257 |
) : null}
|
| 258 |
|
| 259 |
<div className="conn-grid">
|
| 260 |
+
{rows.map((row) => (
|
| 261 |
+
<ConnectorCard key={row.key} row={row} onKeychain={onKeychain} />
|
| 262 |
+
))}
|
| 263 |
+
</div>
|
| 264 |
+
</div>
|
| 265 |
+
);
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
/**
|
| 269 |
+
* ONE CARD, EXPORTED β which is what lets `_conn_shot.tsx` paint the real thing.
|
| 270 |
+
*
|
| 271 |
+
* β EXTRACTED IN WAVE 30 SO THE DIRECTORY CAN BE LOOKED AT, and that is not a cosmetic reason.
|
| 272 |
+
* The page fetches on mount, and `renderToStaticMarkup` does not run effects β so a static render
|
| 273 |
+
* of `ConnectorsPage` shows a spinner and nothing else, forever. The owner's item 7 is a VISUAL
|
| 274 |
+
* ruling ("their logo to be fucking displayed"), and a ticket whose done-when is visual has to be
|
| 275 |
+
* provable by a picture of the shipping component rather than by a picture of a copy of it.
|
| 276 |
+
* β Behaviour is byte-identical: this is the same JSX, lifted whole out of the map.
|
| 277 |
+
*/
|
| 278 |
+
export function ConnectorCard({
|
| 279 |
+
row,
|
| 280 |
+
onKeychain,
|
| 281 |
+
}: {
|
| 282 |
+
row: ConnectorRow;
|
| 283 |
+
onKeychain: () => void;
|
| 284 |
+
}) {
|
| 285 |
+
const planned = row.state === "planned";
|
| 286 |
+
const connected = row.state === "connected";
|
| 287 |
+
return (
|
| 288 |
<div
|
|
|
|
| 289 |
className={"conn-card" + (planned ? " is-planned" : "")}
|
| 290 |
// β `aria-disabled`, NOT `inert`/`pointer-events` alone. A planned card is
|
| 291 |
// non-interactive by R8, and a card that merely LOOKS faded while still taking a
|
|
|
|
| 295 |
{...(planned ? { "aria-disabled": true } : {})}
|
| 296 |
>
|
| 297 |
<div className="conn-card-head">
|
| 298 |
+
{/* β WAVE 30 Β· R4 β THE LOGO, OR THE LETTERS. Two mutually exclusive marks, and
|
| 299 |
+
they take DIFFERENT chips deliberately: letters ride the base chip because
|
| 300 |
+
12px text needs 4.5:1 and only `--lp-primary` clears it, while a logo brings
|
| 301 |
+
its own colour and wants neutral ground under it. Writing one span that
|
| 302 |
+
switched only its contents would have put a brand mark on a blue chip. */}
|
| 303 |
+
{brandForConnector(row.key, 22) ? (
|
| 304 |
+
<span className="conn-tile is-brand" aria-hidden="true">
|
| 305 |
+
{brandForConnector(row.key, 22)}
|
| 306 |
+
</span>
|
| 307 |
+
) : (
|
| 308 |
+
<span className="shell-db-chip conn-tile" aria-hidden="true">
|
| 309 |
+
{tileText(row.label)}
|
| 310 |
+
</span>
|
| 311 |
+
)}
|
| 312 |
<span className="conn-card-name">{row.label}</span>
|
| 313 |
<span className={"conn-state" + stateTone(row.state)}>
|
| 314 |
{/* The server's own word, sentence-cased by CSS never by `toUpperCase` β R6
|
|
|
|
| 317 |
</span>
|
| 318 |
</div>
|
| 319 |
<p className="conn-card-desc">{row.desc}</p>
|
| 320 |
+
{/* ββ WAVE 30 Β· R4 β THE DRILL, AND IT IS COLLAPSED FOR THE SAME REASON THE
|
| 321 |
+
TRIGGER MENU'S IS. The owner's words are a sequence, not a layout: *"Only when
|
| 322 |
+
I click 'Scraper' β¦ would I see the option to choose either Instagram OR
|
| 323 |
+
TikTok."* So the platforms are behind a disclosure, not listed beside the
|
| 324 |
+
card's own sentence β and it is a real `<details>`, which opens on Enter and
|
| 325 |
+
Space with no keydown handler and carries no open/closed state to get wrong.
|
| 326 |
+
β These rows are INFORMATIONAL. A platform has no credential of its own, so it
|
| 327 |
+
offers no Connect button β the card's own action configures both, and a second
|
| 328 |
+
door here would be one that has nothing to open. */}
|
| 329 |
+
{row.platforms && row.platforms.length ? (
|
| 330 |
+
<details className="conn-plats">
|
| 331 |
+
<summary className="conn-plats-sum">
|
| 332 |
+
{row.platforms.length} platform{row.platforms.length === 1 ? "" : "s"}
|
| 333 |
+
</summary>
|
| 334 |
+
{row.platforms.map((p) => (
|
| 335 |
+
<div className="conn-plat" key={p.key}>
|
| 336 |
+
<span className="conn-plat-mark" aria-hidden="true">
|
| 337 |
+
{brandForConnector(p.key, 16)}
|
| 338 |
+
</span>
|
| 339 |
+
<span className="conn-plat-text">
|
| 340 |
+
<span className="conn-plat-name">{p.label}</span>
|
| 341 |
+
{p.desc ? <span className="conn-plat-desc">{p.desc}</span> : null}
|
| 342 |
+
</span>
|
| 343 |
+
</div>
|
| 344 |
+
))}
|
| 345 |
+
</details>
|
| 346 |
+
) : null}
|
| 347 |
{connected && row.connectedAs ? (
|
| 348 |
<p className="conn-card-meta">Connected as {row.connectedAs}</p>
|
| 349 |
) : null}
|
|
|
|
| 379 |
</a>
|
| 380 |
) : null}
|
| 381 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
);
|
| 383 |
}
|
web/src/connectors/brandMarks.tsx
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// connectors/brandMarks.tsx β WAVE 30, owner item 7 (rulings R3 + R4):
|
| 3 |
+
// THE ONE PLACE A CONNECTOR'S IDENTITY IS DRAWN.
|
| 4 |
+
//
|
| 5 |
+
// The owner, verbatim and for the third time: *"their Instagram and Tiktok logo
|
| 6 |
+
// to be fucking displayed, same with all logos under the Connectors module."*
|
| 7 |
+
//
|
| 8 |
+
// β THIS SUPERSEDES A RULE THAT WAS STILL SHIPPING. Wave-23 R8 said connector
|
| 9 |
+
// identity is a two-letter monogram in the chip family, and it was enforced β
|
| 10 |
+
// `verify_home.py:section_connectors` asserted a real mark could not appear.
|
| 11 |
+
// R4 replaces it FOR CONNECTOR IDENTITY ONLY. Everything else the design
|
| 12 |
+
// constitution says is untouched: no emojis anywhere, and no decorative
|
| 13 |
+
// third-party imagery outside this file's purpose, which is telling a person
|
| 14 |
+
// which company a row is about.
|
| 15 |
+
//
|
| 16 |
+
// β INLINE SVG, NEVER A FILE AND NEVER A REMOTE URL. There is no `assets/` or
|
| 17 |
+
// `icons/` directory in this app and there is not going to be one: every icon
|
| 18 |
+
// in the product is a React component, `index.css` inlines even its font as a
|
| 19 |
+
// `data:` URI, and `customer-grid/types.ts` carries the standing sentence
|
| 20 |
+
// *"Never an external URL: those are neither same-origin nor CSP-safe here."*
|
| 21 |
+
// A remote logo would work today (no CSP header is set) and would be the one
|
| 22 |
+
// thing on the page that can be taken away by somebody else's outage.
|
| 23 |
+
//
|
| 24 |
+
// β ONE MODULE FOR THREE SURFACES, and that is the whole design. The trigger
|
| 25 |
+
// picker, the action menu and the Connectors directory all answer "which
|
| 26 |
+
// company is this?" β three copies of a logo set is three chances to add a
|
| 27 |
+
// platform in two places and forget the third, which is precisely the defect
|
| 28 |
+
// wave 30 exists to fix on the server side of the same question.
|
| 29 |
+
//
|
| 30 |
+
// β WHAT THESE ARE, HONESTLY: recognisable SIMPLIFICATIONS drawn from each
|
| 31 |
+
// brand's published silhouette and brand colour, at 16px, not the trademarked
|
| 32 |
+
// artwork. That is the right call at this size β the real Google `G` is a
|
| 33 |
+
// 500-byte path that renders as mush in a 15px box β and it is stated here so
|
| 34 |
+
// nobody "upgrades" them by pasting a vendor's asset file into a repo that has
|
| 35 |
+
// no place to put one.
|
| 36 |
+
//
|
| 37 |
+
// β THEME. Marks are brand-coloured and must survive BOTH themes, so no mark
|
| 38 |
+
// relies on pure black or pure white for its silhouette: TikTok's note is
|
| 39 |
+
// `currentColor` with its two brand offsets behind it, which is also how the
|
| 40 |
+
// real mark is constructed.
|
| 41 |
+
// ---------------------------------------------------------------------------
|
| 42 |
+
import type { ReactElement } from "react";
|
| 43 |
+
import { useId } from "react";
|
| 44 |
+
|
| 45 |
+
/**
|
| 46 |
+
* The canonical brand names this module can draw. Deliberately NOT the same
|
| 47 |
+
* strings as the connector keys or the trigger kinds: three vocabularies point
|
| 48 |
+
* AT this one, and folding them together would make a rename in any of the
|
| 49 |
+
* three a silent loss of an icon.
|
| 50 |
+
*/
|
| 51 |
+
export type BrandName =
|
| 52 |
+
| "instagram"
|
| 53 |
+
| "tiktok"
|
| 54 |
+
| "odoo"
|
| 55 |
+
| "google"
|
| 56 |
+
| "gmail"
|
| 57 |
+
| "stripe"
|
| 58 |
+
| "shopify"
|
| 59 |
+
| "webhooks"
|
| 60 |
+
| "scraper";
|
| 61 |
+
|
| 62 |
+
/**
|
| 63 |
+
* Trigger and action KIND -> brand.
|
| 64 |
+
*
|
| 65 |
+
* β KEYED ON THE KIND AND NOT ON THE CONNECTOR, and the reason is R3 itself:
|
| 66 |
+
* after this wave Instagram and TikTok share `connector.key === "scraper"`, so
|
| 67 |
+
* the connector can no longer tell them apart. The kind is the only thing that
|
| 68 |
+
* still distinguishes the two products, which is exactly why the picker's own
|
| 69 |
+
* `TriggerMark` has always been keyed this way.
|
| 70 |
+
*
|
| 71 |
+
* β An unmapped kind returns null and the caller draws its existing glyph β a
|
| 72 |
+
* trigger the engine adds tomorrow renders plainly rather than not at all.
|
| 73 |
+
*/
|
| 74 |
+
export const BRAND_OF_KIND: Record<string, BrandName> = {
|
| 75 |
+
ig_profile_match: "instagram",
|
| 76 |
+
tiktok_profile_match: "tiktok",
|
| 77 |
+
enrich_instagram: "instagram",
|
| 78 |
+
enrich_tiktok: "tiktok",
|
| 79 |
+
field_instagram: "instagram",
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* Connector/provider KEY -> brand, for the Connectors directory and for the
|
| 84 |
+
* collapsed connector nests in both automation menus.
|
| 85 |
+
*
|
| 86 |
+
* β `scraper` and `webhooks` are OURS, not brands β they get house glyphs in
|
| 87 |
+
* `currentColor` so they sit in the chip family the way they always have. They
|
| 88 |
+
* are in this map rather than special-cased at the call site so that "what mark
|
| 89 |
+
* does this row wear" has ONE answer, whoever is asking.
|
| 90 |
+
*/
|
| 91 |
+
export const BRAND_OF_CONNECTOR: Record<string, BrandName> = {
|
| 92 |
+
odoo: "odoo",
|
| 93 |
+
google: "google",
|
| 94 |
+
gmail: "gmail",
|
| 95 |
+
stripe: "stripe",
|
| 96 |
+
shopify: "shopify",
|
| 97 |
+
scraper: "scraper",
|
| 98 |
+
webhooks: "webhooks",
|
| 99 |
+
instagram: "instagram",
|
| 100 |
+
tiktok: "tiktok",
|
| 101 |
+
};
|
| 102 |
+
|
| 103 |
+
/** The house frame every mark is drawn in. 16Γ16 user units, sized by the caller. */
|
| 104 |
+
function frame(size: number) {
|
| 105 |
+
return { width: size, height: size, viewBox: "0 0 16 16", "aria-hidden": true } as const;
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
/**
|
| 109 |
+
* β TIKTOK'S NOTE, DRAWN THREE TIMES. The real mark is one glyph offset in
|
| 110 |
+
* cyan and magenta behind a solid copy; drawing only the solid copy loses the
|
| 111 |
+
* brand entirely at this size, and drawing the offsets in black loses it in
|
| 112 |
+
* dark mode. The solid copy is `currentColor` so it inherits the row.
|
| 113 |
+
*/
|
| 114 |
+
const TT_NOTE =
|
| 115 |
+
"M9.05 1.9h2.02c.13 1.2.87 2.13 2.03 2.3v2.03c-.78 0-1.53-.2-2.18-.58v3.98"
|
| 116 |
+
+ "a3.62 3.62 0 1 1-3.62-3.62c.16 0 .32.01.48.03v2.06a1.6 1.6 0 1 0 1.27 1.56z";
|
| 117 |
+
|
| 118 |
+
export function BrandMark({
|
| 119 |
+
name,
|
| 120 |
+
size = 15,
|
| 121 |
+
}: {
|
| 122 |
+
name: BrandName;
|
| 123 |
+
size?: number;
|
| 124 |
+
}): ReactElement | null {
|
| 125 |
+
const f = frame(size);
|
| 126 |
+
// Per-instance, for the reason spelled out at the `<defs>` below. Hooks run for every
|
| 127 |
+
// name, not only Instagram, because a hook cannot sit behind a conditional return.
|
| 128 |
+
const gradId = `aios-brand-ig-${useId()}`;
|
| 129 |
+
if (name === "instagram")
|
| 130 |
+
return (
|
| 131 |
+
<svg {...f} fill="none">
|
| 132 |
+
{/*
|
| 133 |
+
ββ THE GRADIENT ID IS PER-INSTANCE, AND THE FIXED ONE WAS A REAL BUG β FOUND BY A
|
| 134 |
+
PICTURE, NOT BY A GATE. It shipped for an hour as the constant `aios-brand-ig`, on the
|
| 135 |
+
reasoning that duplicate ids resolve to the FIRST definition and every definition is
|
| 136 |
+
byte-identical, so it cannot matter. MEASURED in the connectors shot: the Scraper card
|
| 137 |
+
holds an Instagram mark inside a COLLAPSED `<details>`, a second card below holds a
|
| 138 |
+
visible one β and the visible one painted NOTHING. A closed `<details>` is
|
| 139 |
+
`content-visibility: hidden`, its subtree is not rendered, and a paint server defined
|
| 140 |
+
inside a skipped subtree does not resolve for a user outside it. "The first definition
|
| 141 |
+
wins" IS the failure: whichever copy happens to be hidden decides for all of them.
|
| 142 |
+
β Nothing about `<details>` is special β `display:none` does the same. Any page holding
|
| 143 |
+
two of this mark with the first one hidden loses the second.
|
| 144 |
+
β `useId` rather than a module counter, so the markup stays DETERMINISTIC for a given
|
| 145 |
+
tree β the render gates could not assert against a random id β and stable across a
|
| 146 |
+
server render and its hydration.
|
| 147 |
+
β Every check that looks for this mark matches the PREFIX `url(#aios-brand-ig`.
|
| 148 |
+
*/}
|
| 149 |
+
<defs>
|
| 150 |
+
{/* β `userSpaceOnUse` IS LOAD-BEARING AND ITS ABSENCE LOOKED FINE. The default is
|
| 151 |
+
`objectBoundingBox`, where the coordinates are FRACTIONS of the shape's box β so
|
| 152 |
+
`1,15 β 15,1` put the whole gradient vector far outside every path and each one
|
| 153 |
+
painted a single clamped stop. The mark still rendered, still read as Instagram,
|
| 154 |
+
and was simply flat pink: a defect only a picture catches, which is why one was
|
| 155 |
+
taken ([[ui-invisible-to-assertions]]). In user space the numbers mean viewBox
|
| 156 |
+
units, which is what they were written as. */}
|
| 157 |
+
<linearGradient
|
| 158 |
+
id={gradId} gradientUnits="userSpaceOnUse"
|
| 159 |
+
x1="2" y1="14" x2="14" y2="2"
|
| 160 |
+
>
|
| 161 |
+
<stop offset="0" stopColor="#FEDA75" />
|
| 162 |
+
<stop offset="0.32" stopColor="#FA7E1E" />
|
| 163 |
+
<stop offset="0.62" stopColor="#D62976" />
|
| 164 |
+
<stop offset="1" stopColor="#962FBF" />
|
| 165 |
+
</linearGradient>
|
| 166 |
+
</defs>
|
| 167 |
+
<rect
|
| 168 |
+
x="1.7" y="1.7" width="12.6" height="12.6" rx="3.7"
|
| 169 |
+
stroke={`url(#${gradId})`} strokeWidth="1.5"
|
| 170 |
+
/>
|
| 171 |
+
<circle cx="8" cy="8" r="3" stroke={`url(#${gradId})`} strokeWidth="1.5" />
|
| 172 |
+
<circle cx="11.6" cy="4.4" r="0.95" fill={`url(#${gradId})`} />
|
| 173 |
+
</svg>
|
| 174 |
+
);
|
| 175 |
+
if (name === "tiktok")
|
| 176 |
+
return (
|
| 177 |
+
<svg {...f}>
|
| 178 |
+
<path d={TT_NOTE} fill="#25F4EE" transform="translate(-0.9 -0.5)" />
|
| 179 |
+
<path d={TT_NOTE} fill="#FE2C55" transform="translate(0.9 0.5)" />
|
| 180 |
+
<path d={TT_NOTE} fill="currentColor" />
|
| 181 |
+
</svg>
|
| 182 |
+
);
|
| 183 |
+
if (name === "odoo")
|
| 184 |
+
return (
|
| 185 |
+
<svg {...f}>
|
| 186 |
+
{/* Odoo's brand purple, and its wordmark's twin `o`s β the part of that
|
| 187 |
+
wordmark that survives being shrunk to 15px. */}
|
| 188 |
+
<rect x="1" y="1" width="14" height="14" rx="3.4" fill="#714B67" />
|
| 189 |
+
<circle cx="5.7" cy="8.2" r="2.1" fill="none" stroke="#FFFFFF" strokeWidth="1.5" />
|
| 190 |
+
<circle cx="10.4" cy="8.2" r="2.1" fill="none" stroke="#FFFFFF" strokeWidth="1.5" />
|
| 191 |
+
</svg>
|
| 192 |
+
);
|
| 193 |
+
if (name === "google")
|
| 194 |
+
return (
|
| 195 |
+
<svg {...f} fill="none" strokeWidth="2.3">
|
| 196 |
+
{/* The four-arc G. Each arc is one brand colour, and the bar into the
|
| 197 |
+
centre is what makes it a G rather than a ring. */}
|
| 198 |
+
<path d="M4.46 4.46A5 5 0 0 1 11.54 4.46" stroke="#EA4335" strokeLinecap="round" />
|
| 199 |
+
<path d="M4.46 4.46A5 5 0 0 0 4.46 11.54" stroke="#FBBC05" strokeLinecap="round" />
|
| 200 |
+
<path d="M4.46 11.54A5 5 0 0 0 11.54 11.54" stroke="#34A853" strokeLinecap="round" />
|
| 201 |
+
<path d="M11.54 11.54A5 5 0 0 0 13 8" stroke="#4285F4" strokeLinecap="round" />
|
| 202 |
+
<path d="M13 8H8.4" stroke="#4285F4" />
|
| 203 |
+
</svg>
|
| 204 |
+
);
|
| 205 |
+
if (name === "gmail")
|
| 206 |
+
return (
|
| 207 |
+
<svg {...f} fill="none" stroke="#EA4335" strokeWidth="1.5" strokeLinejoin="round">
|
| 208 |
+
<rect x="1.6" y="3.6" width="12.8" height="9" rx="1.6" />
|
| 209 |
+
<path d="m2 4.4 6 4.6 6-4.6" />
|
| 210 |
+
</svg>
|
| 211 |
+
);
|
| 212 |
+
if (name === "stripe")
|
| 213 |
+
return (
|
| 214 |
+
<svg {...f}>
|
| 215 |
+
<rect x="1" y="1" width="14" height="14" rx="3.4" fill="#635BFF" />
|
| 216 |
+
<path
|
| 217 |
+
d="M10.5 5.6a2.3 2.3 0 0 0-3.6 1.2c0 2.1 4.1 1.5 4.1 3.5a2.4 2.4 0 0 1-3.7 1.3"
|
| 218 |
+
fill="none" stroke="#FFFFFF" strokeWidth="1.5" strokeLinecap="round"
|
| 219 |
+
/>
|
| 220 |
+
</svg>
|
| 221 |
+
);
|
| 222 |
+
if (name === "shopify")
|
| 223 |
+
return (
|
| 224 |
+
<svg {...f}>
|
| 225 |
+
{/* The bag, in Shopify's two greens: the darker one is the handle, which
|
| 226 |
+
is the part that reads as a bag rather than as a box. */}
|
| 227 |
+
<path d="M3.5 5.2h9l-.75 8.4a.9.9 0 0 1-.9.8H5.15a.9.9 0 0 1-.9-.8z" fill="#95BF47" />
|
| 228 |
+
<path
|
| 229 |
+
d="M6.1 6.4V4.5a1.9 1.9 0 0 1 3.8 0v1.9"
|
| 230 |
+
fill="none" stroke="#5E8E3E" strokeWidth="1.4" strokeLinecap="round"
|
| 231 |
+
/>
|
| 232 |
+
</svg>
|
| 233 |
+
);
|
| 234 |
+
/*
|
| 235 |
+
β THE LAST TWO ARE OURS, NOT BRANDS β `currentColor`, house stroke weight,
|
| 236 |
+
so they sit in the chip family beside the coloured logos rather than
|
| 237 |
+
pretending to be a company. Keeping them here is what lets every caller ask
|
| 238 |
+
one question instead of two.
|
| 239 |
+
*/
|
| 240 |
+
if (name === "webhooks")
|
| 241 |
+
return (
|
| 242 |
+
<svg {...f} fill="none" stroke="currentColor" strokeWidth="1.4"
|
| 243 |
+
strokeLinecap="round" strokeLinejoin="round">
|
| 244 |
+
<path d="M6.6 9.4 4.9 11a2.6 2.6 0 1 1-1.5-4.4" />
|
| 245 |
+
<path d="M9.4 6.6 11.1 5a2.6 2.6 0 1 1 1.5 4.4" />
|
| 246 |
+
<path d="M6.2 8h3.6" />
|
| 247 |
+
</svg>
|
| 248 |
+
);
|
| 249 |
+
if (name === "scraper")
|
| 250 |
+
return (
|
| 251 |
+
<svg {...f} fill="none" stroke="currentColor" strokeWidth="1.4"
|
| 252 |
+
strokeLinecap="round" strokeLinejoin="round">
|
| 253 |
+
{/* A magnifier over a grid of rows: it reads a catalogue of profiles and
|
| 254 |
+
makes records out of what matches β which is what the bucket does,
|
| 255 |
+
and it is deliberately NOT either platform's mark, because the whole
|
| 256 |
+
point of R3 is that the bucket is neither one of them. */}
|
| 257 |
+
<path d="M2.6 3.4h10.8M2.6 6.2h5.2M2.6 9h3.4" />
|
| 258 |
+
<circle cx="10.4" cy="10.2" r="3" />
|
| 259 |
+
<path d="m12.6 12.4 1.6 1.6" />
|
| 260 |
+
</svg>
|
| 261 |
+
);
|
| 262 |
+
return null;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
/**
|
| 266 |
+
* Does this kind wear a LOGO rather than a house glyph?
|
| 267 |
+
*
|
| 268 |
+
* β IT EXISTS BECAUSE THE TILE HAS TO CHANGE TOO, and forgetting that would have shipped the
|
| 269 |
+
* defect quietly: `.autox-card-mark` paints a blue tint and sets `color: var(--lp-blue-deep)`,
|
| 270 |
+
* so TikTok's note β which is `currentColor` by design, so it survives dark mode β would have
|
| 271 |
+
* rendered BLUE, and Instagram's gradient would have sat on a coloured chip. The caller adds
|
| 272 |
+
* `is-brand`, which neutralises both. One predicate, so the mark and its tile cannot disagree
|
| 273 |
+
* about whether a row is branded.
|
| 274 |
+
*/
|
| 275 |
+
export function hasBrandKind(kind: string): boolean {
|
| 276 |
+
return Boolean(BRAND_OF_KIND[kind]);
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
/** The connector-key twin of `hasBrandKind`, for the directory's tiles. */
|
| 280 |
+
export function hasBrandConnector(key: string): boolean {
|
| 281 |
+
return Boolean(BRAND_OF_CONNECTOR[key]);
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
/** The mark for a trigger or action KIND, or null when it is not a branded one. */
|
| 285 |
+
export function brandForKind(kind: string, size = 15): ReactElement | null {
|
| 286 |
+
const name = BRAND_OF_KIND[kind];
|
| 287 |
+
return name ? <BrandMark name={name} size={size} /> : null;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
/** The mark for a connector/provider KEY, or null when we have no logo for it. */
|
| 291 |
+
export function brandForConnector(key: string, size = 15): ReactElement | null {
|
| 292 |
+
const name = BRAND_OF_CONNECTOR[key];
|
| 293 |
+
return name ? <BrandMark name={name} size={size} /> : null;
|
| 294 |
+
}
|
web/src/customer-grid/CustomerGrid.tsx
CHANGED
|
@@ -25,6 +25,14 @@ import "@glideapps/glide-data-grid/dist/index.css";
|
|
| 25 |
|
| 26 |
import { useCustomerData } from "./useCustomerData";
|
| 27 |
import type { SurfaceScope } from "./apiBridge";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
import { defaultViewConfig, useGridColumns } from "./useGridColumns";
|
| 29 |
import { activeMeasureRuleIds, pendingMeasures, runPipeline, sliceForDisplay,
|
| 30 |
unresolvedConditions, useVisibleRows } from "./useVisibleRows";
|
|
@@ -666,6 +674,7 @@ function CustomerGrid({
|
|
| 666 |
overlayEdits,
|
| 667 |
setOverlayEdits,
|
| 668 |
patchOverlay,
|
|
|
|
| 669 |
} = useCustomerData(scope, {
|
| 670 |
bindSurface: !embedded,
|
| 671 |
includeWorkspace: !embedded,
|
|
@@ -1402,6 +1411,77 @@ function CustomerGrid({
|
|
| 1402 |
const displaySpec = cleanDisplay(config.display);
|
| 1403 |
const displayMode: DisplayMode = serverWindowed ? "grid" : displaySpec?.mode ?? "grid";
|
| 1404 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1405 |
// CG-8 β measure conditions, answered by the host as `{ruleId: pid[]}`. Converted to Sets
|
| 1406 |
// once here rather than per row: the pipeline runs this over every row of the book.
|
| 1407 |
//
|
|
@@ -1576,7 +1656,20 @@ function CustomerGrid({
|
|
| 1576 |
// pidToIndex is rebuilt over the slice because every index consumer (selection, detail
|
| 1577 |
// navigation, hover) is positional against what is actually on screen.
|
| 1578 |
const [displayCap, setDisplayCap] = useState(DISPLAY_PAGE);
|
| 1579 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1580 |
/**
|
| 1581 |
* ββ WAVE-29 T33 (owner item 17) β THE TOTALS ROW, and the cap fix it forced.
|
| 1582 |
*
|
|
@@ -1598,10 +1691,57 @@ function CustomerGrid({
|
|
| 1598 |
),
|
| 1599 |
[visibleRows, fields]
|
| 1600 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1601 |
const showTotals = useMemo(
|
| 1602 |
() => !config.groupBy && Object.keys(totalsAggs).length > 0,
|
| 1603 |
[config.groupBy, totalsAggs]
|
| 1604 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1605 |
const displayRows = useMemo(() => {
|
| 1606 |
const shown = capped ? sliceForDisplay(visibleRows, displayCap) : visibleRows;
|
| 1607 |
// β APPENDED AFTER THE SLICE, so the cap can never eat the totals row itself β and it is the
|
|
@@ -5236,6 +5376,17 @@ function CustomerGrid({
|
|
| 5236 |
// pixel of the marker column; it only makes its width a number we own.
|
| 5237 |
// βββ end W18-B VOID βββ
|
| 5238 |
rowMarkerWidth={rowMarkerPx(displayRows.length)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5239 |
gridSelection={gridSelection}
|
| 5240 |
onGridSelectionChange={onGridSelectionChange}
|
| 5241 |
getRowThemeOverride={getRowThemeOverride}
|
|
@@ -5769,6 +5920,26 @@ function CustomerGrid({
|
|
| 5769 |
count above still states the full matched count; this bar says what is painted.
|
| 5770 |
Grid + list only: calendar/kanban project the FULL set with their own stated
|
| 5771 |
bounds (a month, a stack's Show-all). */}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5772 |
{capped && (displayMode === "grid" || displayMode === "list") && (
|
| 5773 |
<div className="cg-more">
|
| 5774 |
<span className="cg-more-note">
|
|
|
|
| 25 |
|
| 26 |
import { useCustomerData } from "./useCustomerData";
|
| 27 |
import type { SurfaceScope } from "./apiBridge";
|
| 28 |
+
// β WAVE 30 Β· W30-T42 (contract C2) β the WINDOWED grid's arithmetic, pure and node-run in
|
| 29 |
+
// `verify_grid_ux.py`, because every one of these decisions is taken inside a callback where
|
| 30 |
+
// only its own source text could otherwise be checked.
|
| 31 |
+
import { WINDOW_ROWS } from "./apiBridge";
|
| 32 |
+
import {
|
| 33 |
+
EMPTY_WINDOW_PREDICATE, limitSummary, nextWindowOffset, windowedCapabilityNote,
|
| 34 |
+
windowedFoldNote, windowPredicateKey,
|
| 35 |
+
} from "./counts";
|
| 36 |
import { defaultViewConfig, useGridColumns } from "./useGridColumns";
|
| 37 |
import { activeMeasureRuleIds, pendingMeasures, runPipeline, sliceForDisplay,
|
| 38 |
unresolvedConditions, useVisibleRows } from "./useVisibleRows";
|
|
|
|
| 674 |
overlayEdits,
|
| 675 |
setOverlayEdits,
|
| 676 |
patchOverlay,
|
| 677 |
+
requestWindow,
|
| 678 |
} = useCustomerData(scope, {
|
| 679 |
bindSurface: !embedded,
|
| 680 |
includeWorkspace: !embedded,
|
|
|
|
| 1411 |
const displaySpec = cleanDisplay(config.display);
|
| 1412 |
const displayMode: DisplayMode = serverWindowed ? "grid" : displaySpec?.mode ?? "grid";
|
| 1413 |
|
| 1414 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1415 |
+
// ββ WAVE 30 Β· W30-T42 (contract C2) β PAGING A READ-THROUGH GRID.
|
| 1416 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1417 |
+
//
|
| 1418 |
+
// The client engine is already refused in this mode (`useVisibleRows` passes rows straight
|
| 1419 |
+
// through), which is C2's "F may render no client-side predicate over a windowed grid". That
|
| 1420 |
+
// clause on its own would leave a filter chip INERT: the user narrows 32,826 orders, nothing
|
| 1421 |
+
// filters, and the count keeps reading 32,826 β honest about the scope and silent about the
|
| 1422 |
+
// question. So the predicate has to reach the evaluator that CAN answer it, which is SQL.
|
| 1423 |
+
//
|
| 1424 |
+
// β THE SAVED VIEW'S OWN OBJECTS GO ON THE WIRE, UNTRANSLATED (see `windowRowsPath`).
|
| 1425 |
+
const windowPredicate = useMemo(
|
| 1426 |
+
() => windowPredicateKey(config.filters, config.filterConj, config.sorts, search),
|
| 1427 |
+
[config.filters, config.filterConj, config.sorts, search]
|
| 1428 |
+
);
|
| 1429 |
+
const windowRequest = useCallback(
|
| 1430 |
+
(offset: number) => ({
|
| 1431 |
+
offset,
|
| 1432 |
+
limit: WINDOW_ROWS,
|
| 1433 |
+
filters: config.filters,
|
| 1434 |
+
filterConj: config.filterConj,
|
| 1435 |
+
sorts: config.sorts,
|
| 1436 |
+
search,
|
| 1437 |
+
}),
|
| 1438 |
+
[config.filters, config.filterConj, config.sorts, search]
|
| 1439 |
+
);
|
| 1440 |
+
const sentPredicate = useRef<string | null>(null);
|
| 1441 |
+
useEffect(() => {
|
| 1442 |
+
if (!serverWindowed) {
|
| 1443 |
+
sentPredicate.current = null;
|
| 1444 |
+
return;
|
| 1445 |
+
}
|
| 1446 |
+
if (sentPredicate.current === windowPredicate) return;
|
| 1447 |
+
// β THE FIRST WINDOW WENT OUT WITH NO QUERY ARGS AT ALL β `config` is this component's own
|
| 1448 |
+
// state, seeded from a workspace call that had not landed when the rows request left. So a
|
| 1449 |
+
// view carrying no filter, no sort and no search has ALREADY been answered, and firing here
|
| 1450 |
+
// would spend a round trip to receive the bytes on screen. Any other predicate is a real
|
| 1451 |
+
// question and goes out. One function owns both the key and the empty case, so "no
|
| 1452 |
+
// predicate" cannot be spelled two ways.
|
| 1453 |
+
if (sentPredicate.current === null && windowPredicate === EMPTY_WINDOW_PREDICATE) {
|
| 1454 |
+
sentPredicate.current = windowPredicate;
|
| 1455 |
+
return;
|
| 1456 |
+
}
|
| 1457 |
+
sentPredicate.current = windowPredicate;
|
| 1458 |
+
requestWindow(windowRequest(0));
|
| 1459 |
+
}, [serverWindowed, windowPredicate, windowRequest, requestWindow]);
|
| 1460 |
+
|
| 1461 |
+
/**
|
| 1462 |
+
* The scroll β next window. glide reports the visible RECTANGLE; `nextWindowOffset` decides.
|
| 1463 |
+
*
|
| 1464 |
+
* β `counts.shown` IS THE LOADED COUNT, not `rawRows.length`. They are the same number today
|
| 1465 |
+
* and would stop being one the moment anything layers a row in that the server did not send
|
| 1466 |
+
* (`pendingRows` does exactly that on editable tables) β and the offset this produces is a
|
| 1467 |
+
* promise to the server about where our contiguous run ends. It has to come from the merge
|
| 1468 |
+
* that built the run.
|
| 1469 |
+
*/
|
| 1470 |
+
const onVisibleRegionChanged = useCallback(
|
| 1471 |
+
(range: Rectangle) => {
|
| 1472 |
+
if (!serverWindowed) return;
|
| 1473 |
+
const offset = nextWindowOffset({
|
| 1474 |
+
lastVisibleRow: range.y + range.height,
|
| 1475 |
+
loaded: payload?.counts?.shown ?? 0,
|
| 1476 |
+
matched: payload?.counts?.matched ?? 0,
|
| 1477 |
+
limit: WINDOW_ROWS,
|
| 1478 |
+
});
|
| 1479 |
+
if (offset !== null) requestWindow(windowRequest(offset));
|
| 1480 |
+
},
|
| 1481 |
+
[serverWindowed, payload?.counts?.shown, payload?.counts?.matched, requestWindow,
|
| 1482 |
+
windowRequest]
|
| 1483 |
+
);
|
| 1484 |
+
|
| 1485 |
// CG-8 β measure conditions, answered by the host as `{ruleId: pid[]}`. Converted to Sets
|
| 1486 |
// once here rather than per row: the pipeline runs this over every row of the book.
|
| 1487 |
//
|
|
|
|
| 1656 |
// pidToIndex is rebuilt over the slice because every index consumer (selection, detail
|
| 1657 |
// navigation, hover) is positional against what is actually on screen.
|
| 1658 |
const [displayCap, setDisplayCap] = useState(DISPLAY_PAGE);
|
| 1659 |
+
/**
|
| 1660 |
+
* β W30-T42 β NO CLIENT CAP ON A WINDOWED TABLE, AND THE TWO CAPS WOULD HAVE FOUGHT.
|
| 1661 |
+
*
|
| 1662 |
+
* The display cap exists because the whole-book path holds every row and glide should not be
|
| 1663 |
+
* asked to lay out 33,000 of them at once. In `server-windowed` mode the WINDOW is already
|
| 1664 |
+
* that bound β one page of `WINDOW_ROWS` β so a second cap on top would stop the grid 50 rows
|
| 1665 |
+
* in, under a "Showing first 50 of 200" bar, while the toolbar says "showing 200 of 32,826".
|
| 1666 |
+
* Two truncation stories about one table, neither of them wrong, together a lie.
|
| 1667 |
+
*
|
| 1668 |
+
* β AND IT WOULD HAVE BROKEN THE PAGING OUTRIGHT: the scroll decision reads how far down the
|
| 1669 |
+
* LOADED rows the viewport reaches, and a capped grid can never scroll past the cap, so the
|
| 1670 |
+
* end of the window would be unreachable and the next window never requested.
|
| 1671 |
+
*/
|
| 1672 |
+
const capped = !serverWindowed && visibleRows.length > displayCap;
|
| 1673 |
/**
|
| 1674 |
* ββ WAVE-29 T33 (owner item 17) β THE TOTALS ROW, and the cap fix it forced.
|
| 1675 |
*
|
|
|
|
| 1691 |
),
|
| 1692 |
[visibleRows, fields]
|
| 1693 |
);
|
| 1694 |
+
/**
|
| 1695 |
+
* ββ WAVE 30 Β· W30-T42 (contract C2 / the ticket's own named trap) β THE TOTALS ROW OVER A
|
| 1696 |
+
* WINDOW IS DISCLOSED, NOT DELETED.
|
| 1697 |
+
*
|
| 1698 |
+
* β THE TRAP. `totalsAggs` folds `visibleRows`, which in `server-windowed` mode is exactly the
|
| 1699 |
+
* rows this browser has loaded. Painted in the table's footer with no denominator, "the sum of
|
| 1700 |
+
* whatever happens to be in memory" wears the sum of 32,826 orders β a fabricated aggregate,
|
| 1701 |
+
* the thing [[no-unverifiable-aggregates]] exists to forbid. Grouping is refused in this mode
|
| 1702 |
+
* for the same reason (a group header over a window subtotals the PAGE while claiming to
|
| 1703 |
+
* describe the group), and that refusal stays: a group is a claim about a SET, and the client
|
| 1704 |
+
* cannot see the set.
|
| 1705 |
+
*
|
| 1706 |
+
* β BUT ABSENT IS NOT ONE OF THE TWO HONEST STATES. T42's done-when offers exactly two β
|
| 1707 |
+
* "reads from the server" or "says plainly that it covers the loaded window" β and the wire
|
| 1708 |
+
* carries no aggregates (`rows/total/totalUnfiltered/offset/limit/limits`), so the second one
|
| 1709 |
+
* is the one available. The fold is CORRECT for a question nobody asked; naming the question
|
| 1710 |
+
* is what makes it honest, and the loaded rows are on screen and scrollable, so the number
|
| 1711 |
+
* still drills to rows. The disclosure is `windowedFoldNote`, and it is bound to `showTotals`
|
| 1712 |
+
* by ONE memo below rather than by two conditions that could drift apart β a totals row over a
|
| 1713 |
+
* window with its sentence missing is the whole defect back again.
|
| 1714 |
+
*/
|
| 1715 |
const showTotals = useMemo(
|
| 1716 |
() => !config.groupBy && Object.keys(totalsAggs).length > 0,
|
| 1717 |
[config.groupBy, totalsAggs]
|
| 1718 |
);
|
| 1719 |
+
/** The denominator sentence β non-null EXACTLY when a totals row is painted over a window that
|
| 1720 |
+
* does not hold the whole matched set. Null on a whole-book table (the total is total) and on
|
| 1721 |
+
* a window that happens to hold everything (`windowedFoldNote` refuses to invent a
|
| 1722 |
+
* truncation that is not there). */
|
| 1723 |
+
const foldNote = useMemo(
|
| 1724 |
+
() =>
|
| 1725 |
+
serverWindowed && showTotals
|
| 1726 |
+
? windowedFoldNote(payload?.counts?.shown ?? 0, payload?.counts?.matched ?? 0)
|
| 1727 |
+
: null,
|
| 1728 |
+
[serverWindowed, showTotals, payload?.counts?.shown, payload?.counts?.matched]
|
| 1729 |
+
);
|
| 1730 |
+
/** R6's SECOND SENTENCE, on the client side of the wire: the limits D's route DECLARED on this
|
| 1731 |
+
* response. Received and painted nothing = the limit is silent again, one layer further out. */
|
| 1732 |
+
const limitNote = useMemo(() => limitSummary(payload?.limits), [payload?.limits]);
|
| 1733 |
+
/**
|
| 1734 |
+
* β AND THE LIMIT NOBODY DECLARED, WHICH IS THE ONE A PERSON ACTUALLY NOTICES. Nine controls
|
| 1735 |
+
* this component gates on `serverWindowed` vanish the moment a grid becomes a window β export,
|
| 1736 |
+
* cohorts, folders, grouping, the four alternative views, select-from-file, alert badges β all
|
| 1737 |
+
* of which were working on this grid the day before, because the whole table used to be in the
|
| 1738 |
+
* browser. Every refusal is right on its own; a screen where eight buttons quietly disappear
|
| 1739 |
+
* is not. See `windowedCapabilityNote` for the list and why it is not a guess.
|
| 1740 |
+
*/
|
| 1741 |
+
const capabilityNote = useMemo(
|
| 1742 |
+
() => (serverWindowed ? windowedCapabilityNote(payload?.counts?.matched ?? 0) : null),
|
| 1743 |
+
[serverWindowed, payload?.counts?.matched]
|
| 1744 |
+
);
|
| 1745 |
const displayRows = useMemo(() => {
|
| 1746 |
const shown = capped ? sliceForDisplay(visibleRows, displayCap) : visibleRows;
|
| 1747 |
// β APPENDED AFTER THE SLICE, so the cap can never eat the totals row itself β and it is the
|
|
|
|
| 5376 |
// pixel of the marker column; it only makes its width a number we own.
|
| 5377 |
// βββ end W18-B VOID βββ
|
| 5378 |
rowMarkerWidth={rowMarkerPx(displayRows.length)}
|
| 5379 |
+
/**
|
| 5380 |
+
* ββ W30-T42 (contract C2) β THE SCROLL THAT ASKS FOR THE NEXT WINDOW.
|
| 5381 |
+
*
|
| 5382 |
+
* β The W18-B VOID note above says there is deliberately no such handler "anywhere
|
| 5383 |
+
* near it", and that stays true of the void, which is arithmetic over the box and
|
| 5384 |
+
* must not become scroll-tracking. This one is a different subject: how far down
|
| 5385 |
+
* the LOADED rows the viewport has reached, which is the only question a windowed
|
| 5386 |
+
* grid can answer paging with. It is a no-op on every whole-book table (the
|
| 5387 |
+
* callback returns on `!serverWindowed` before reading anything).
|
| 5388 |
+
*/
|
| 5389 |
+
onVisibleRegionChanged={onVisibleRegionChanged}
|
| 5390 |
gridSelection={gridSelection}
|
| 5391 |
onGridSelectionChange={onGridSelectionChange}
|
| 5392 |
getRowThemeOverride={getRowThemeOverride}
|
|
|
|
| 5920 |
count above still states the full matched count; this bar says what is painted.
|
| 5921 |
Grid + list only: calendar/kanban project the FULL set with their own stated
|
| 5922 |
bounds (a month, a stack's Show-all). */}
|
| 5923 |
+
{/* β W30-T42 β the WINDOWED grid's honest footnotes, in the strip that already exists
|
| 5924 |
+
for exactly this job. Reuses `.cg-more` / `.cg-more-note` rather than adding a
|
| 5925 |
+
class: `index.css` is another lane's fence this wave, and this needs no new pixel.
|
| 5926 |
+
`foldNote` is null unless a totals row is folded over a partial window; `limitNote`
|
| 5927 |
+
is null unless the SERVER declared a limit on this response (R6's second sentence β
|
| 5928 |
+
short on screen, the full cause and recommendation one hover away, the same
|
| 5929 |
+
short-form/`title` split `countLabelCompact` uses for the same strip). */}
|
| 5930 |
+
{!capped && (foldNote || limitNote || capabilityNote) && (
|
| 5931 |
+
<div className="cg-more">
|
| 5932 |
+
{foldNote && <span className="cg-more-note">{foldNote}</span>}
|
| 5933 |
+
{limitNote && (
|
| 5934 |
+
<span className="cg-more-note" title={limitNote.full}>{limitNote.short}</span>
|
| 5935 |
+
)}
|
| 5936 |
+
{capabilityNote && (
|
| 5937 |
+
<span className="cg-more-note" title={capabilityNote.full}>
|
| 5938 |
+
{capabilityNote.short}
|
| 5939 |
+
</span>
|
| 5940 |
+
)}
|
| 5941 |
+
</div>
|
| 5942 |
+
)}
|
| 5943 |
{capped && (displayMode === "grid" || displayMode === "list") && (
|
| 5944 |
<div className="cg-more">
|
| 5945 |
<span className="cg-more-note">
|
web/src/customer-grid/ImportDialog.tsx
CHANGED
|
@@ -92,11 +92,11 @@ export default function ImportDialog({
|
|
| 92 |
body: form,
|
| 93 |
});
|
| 94 |
const body = (await res.json().catch(() => null)) as
|
| 95 |
-
| (Parsed & {
|
| 96 |
| null;
|
| 97 |
if (!res.ok || !body) {
|
| 98 |
setError(
|
| 99 |
-
body?.
|
| 100 |
`That file could not be read (the server answered ${res.status}).`
|
| 101 |
);
|
| 102 |
return;
|
|
@@ -145,11 +145,11 @@ export default function ImportDialog({
|
|
| 145 |
}
|
| 146 |
);
|
| 147 |
const body = (await res.json().catch(() => null)) as
|
| 148 |
-
| { imported?: number;
|
| 149 |
| null;
|
| 150 |
if (!res.ok) {
|
| 151 |
// The server's sentence beats anything invented here: it names the column, or the cap.
|
| 152 |
-
setError(body?.
|
| 153 |
return;
|
| 154 |
}
|
| 155 |
onImported(body?.imported ?? plan.rows.length);
|
|
|
|
| 92 |
body: form,
|
| 93 |
});
|
| 94 |
const body = (await res.json().catch(() => null)) as
|
| 95 |
+
| (Parsed & { error?: { message?: string } })
|
| 96 |
| null;
|
| 97 |
if (!res.ok || !body) {
|
| 98 |
setError(
|
| 99 |
+
body?.error?.message ??
|
| 100 |
`That file could not be read (the server answered ${res.status}).`
|
| 101 |
);
|
| 102 |
return;
|
|
|
|
| 145 |
}
|
| 146 |
);
|
| 147 |
const body = (await res.json().catch(() => null)) as
|
| 148 |
+
| { imported?: number; error?: { message?: string } }
|
| 149 |
| null;
|
| 150 |
if (!res.ok) {
|
| 151 |
// The server's sentence beats anything invented here: it names the column, or the cap.
|
| 152 |
+
setError(body?.error?.message ?? `The server answered ${res.status}.`);
|
| 153 |
return;
|
| 154 |
}
|
| 155 |
onImported(body?.imported ?? plan.rows.length);
|
web/src/customer-grid/apiBridge.ts
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/customer-grid/counts.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
| 9 |
// asserted by aios-web/verify_filter_engine.py alongside the filter engine.
|
| 10 |
// ---------------------------------------------------------------------------
|
| 11 |
|
| 12 |
-
import type { ScopeCounts } from "./types";
|
| 13 |
|
| 14 |
/**
|
| 15 |
* A whole-book table holds every row, so its own count IS the truth.
|
|
@@ -89,3 +89,195 @@ export function countLabelCompact(recordCount: number, scope?: ScopeCounts): str
|
|
| 89 |
}
|
| 90 |
return `showing ${n(scope.shown)} of ${n(scope.matched)}`;
|
| 91 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
// asserted by aios-web/verify_filter_engine.py alongside the filter engine.
|
| 10 |
// ---------------------------------------------------------------------------
|
| 11 |
|
| 12 |
+
import type { FilterNode, GridLimit, ScopeCounts, SortSpec } from "./types";
|
| 13 |
|
| 14 |
/**
|
| 15 |
* A whole-book table holds every row, so its own count IS the truth.
|
|
|
|
| 89 |
}
|
| 90 |
return `showing ${n(scope.shown)} of ${n(scope.matched)}`;
|
| 91 |
}
|
| 92 |
+
|
| 93 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 94 |
+
// ββ WAVE 30 Β· W30-T42 (contract C2) β THE THREE DECISIONS A WINDOWED GRID MAKES,
|
| 95 |
+
// EXTRACTED AS PURE FUNCTIONS SO THEY CAN BE RUN RATHER THAN GREPPED.
|
| 96 |
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 97 |
+
//
|
| 98 |
+
// Everything below is decided per scroll, per keystroke and per filter chip inside
|
| 99 |
+
// `CustomerGrid`, i.e. at CLICK TIME β the one place this lane has repeatedly found defects a
|
| 100 |
+
// source scan cannot see (W30-T40's fields panel built its body through a render prop; the
|
| 101 |
+
// crash was only reachable by opening the panel). A decision that lives in a callback can only
|
| 102 |
+
// be asserted by its own text; a decision that lives in an exported function can be FED the
|
| 103 |
+
// numbers and checked against its answer. So the callbacks up there hold wiring, and the
|
| 104 |
+
// arithmetic lives here, where `gridUx.test.ts` runs it under node.
|
| 105 |
+
|
| 106 |
+
/** The page size one window request asks for. See `apiBridge.WINDOW_ROWS` β this module holds
|
| 107 |
+
* no network knowledge, so the caller passes it in. */
|
| 108 |
+
export interface WindowScroll {
|
| 109 |
+
/**
|
| 110 |
+
* How far down the loaded rows the viewport reaches β the EXCLUSIVE end of glide's visible
|
| 111 |
+
* rectangle (`range.y + range.height`), i.e. the first row index below the fold. Named for
|
| 112 |
+
* what the caller passes rather than for a row it points at: it is one PAST the last visible
|
| 113 |
+
* row, and a doc that called it "the last row" would be off by one in a reader's head.
|
| 114 |
+
*/
|
| 115 |
+
lastVisibleRow: number;
|
| 116 |
+
/** how many rows this browser holds right now (`ScopeCounts.shown`) */
|
| 117 |
+
loaded: number;
|
| 118 |
+
/** rows matching the predicate across the WHOLE scope (`ScopeCounts.matched`) */
|
| 119 |
+
matched: number;
|
| 120 |
+
/** the page size the next request would ask for */
|
| 121 |
+
limit: number;
|
| 122 |
+
/** how many rows from the end to start fetching. Default 25. */
|
| 123 |
+
lead?: number;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
const DEFAULT_LEAD = 25;
|
| 127 |
+
|
| 128 |
+
/**
|
| 129 |
+
* "Should I fetch another window, and from which offset?" β `null` means no.
|
| 130 |
+
*
|
| 131 |
+
* β THE OFFSET IS THE LOADED COUNT, AND THAT IS THE WHOLE CONTRACT WITH THE SERVER. Windows
|
| 132 |
+
* are contiguous from 0 under a TOTAL order (`compile_order_by` appends the id as a tiebreak
|
| 133 |
+
* precisely so `LIMIT/OFFSET` cannot return one row on two pages), so "how many rows do I
|
| 134 |
+
* hold" and "where does the next page start" are the same number. Deriving it from a page
|
| 135 |
+
* counter instead would drift the moment one response comes back short.
|
| 136 |
+
*
|
| 137 |
+
* β AND IT REFUSES ON `matched <= loaded`. When the whole match set is already here there is
|
| 138 |
+
* nothing to page, and asking anyway would spend a request per scroll event forever against a
|
| 139 |
+
* server that answers with an empty window β a poll wearing an infinite scroll.
|
| 140 |
+
*
|
| 141 |
+
* β `loaded < 1` also refuses: the FIRST window belongs to the load effect, not to a scroll.
|
| 142 |
+
* A scroll event that fires before any payload has landed must not race it.
|
| 143 |
+
*/
|
| 144 |
+
export function nextWindowOffset(s: WindowScroll): number | null {
|
| 145 |
+
const { lastVisibleRow, loaded, matched, limit } = s;
|
| 146 |
+
if (!Number.isFinite(limit) || limit < 1) return null;
|
| 147 |
+
if (!Number.isFinite(loaded) || loaded < 1) return null;
|
| 148 |
+
if (!Number.isFinite(matched) || matched <= loaded) return null;
|
| 149 |
+
const wanted = Number.isFinite(s.lead as number) ? (s.lead as number) : DEFAULT_LEAD;
|
| 150 |
+
const lead = Math.max(1, Math.min(wanted, limit));
|
| 151 |
+
if (!Number.isFinite(lastVisibleRow) || lastVisibleRow < loaded - lead) return null;
|
| 152 |
+
return loaded;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
/**
|
| 156 |
+
* The sentence under a totals row that was folded over a WINDOW β `null` when there is
|
| 157 |
+
* nothing to disclose.
|
| 158 |
+
*
|
| 159 |
+
* β THE TICKET'S OWN TRAP, AND WHY THIS IS A DISCLOSURE RATHER THAN A DELETION. `totalsAggs`
|
| 160 |
+
* folds `visibleRows`, which in `server-windowed` mode is exactly the rows this browser has
|
| 161 |
+
* loaded. The number is therefore CORRECT for a question nobody asked ("the sum of the first
|
| 162 |
+
* 200 orders") and wrong for the one the position implies ("the sum of 32,826 orders"). This
|
| 163 |
+
* repo's law is that every number drills to rows ([[no-unverifiable-aggregates]]) β and the
|
| 164 |
+
* loaded rows ARE rows, on screen, scrollable. So the honest fix is to name the denominator,
|
| 165 |
+
* not to hide the fold: W30-T42's done-when offers exactly two acceptable states, "reads from
|
| 166 |
+
* the server" or "says plainly that it covers the loaded window", and absent is neither.
|
| 167 |
+
*
|
| 168 |
+
* β `null` WHEN `loaded >= matched`, and that is not an optimisation. There the loaded rows
|
| 169 |
+
* ARE every matching row, so the totals row is a true total and a caveat under it would invent
|
| 170 |
+
* a truncation that is not there β the same mistake `countLabel` avoids one function up.
|
| 171 |
+
*/
|
| 172 |
+
export function windowedFoldNote(loaded: number, matched: number): string | null {
|
| 173 |
+
if (!Number.isFinite(loaded) || !Number.isFinite(matched)) return null;
|
| 174 |
+
if (loaded >= matched) return null;
|
| 175 |
+
const n = (v: number) => Math.max(0, Math.floor(v)).toLocaleString();
|
| 176 |
+
return `Column totals cover the ${n(loaded)} rows loaded so far, of ${n(matched)} matching.`;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
/**
|
| 180 |
+
* The footer's report of the limits the SERVER declared β `{short, full}` or `null`.
|
| 181 |
+
*
|
| 182 |
+
* β R6's SECOND SENTENCE, ON THE CLIENT SIDE OF THE WIRE, AND IT IS THE HALF THAT GETS
|
| 183 |
+
* DROPPED. D's route already refuses to truncate silently: it names every limit that binds a
|
| 184 |
+
* response with its cause and its recommendation. A client that receives that array and paints
|
| 185 |
+
* nothing has re-created the exact violation the ruling forbids β the limit is now silent
|
| 186 |
+
* again, one layer further out, with a green gate on both sides of the wire.
|
| 187 |
+
*
|
| 188 |
+
* β SHORT ON SCREEN, FULL IN THE `title`. The causes are whole sentences (they have to be β
|
| 189 |
+
* "a number condition is evaluated in SQL at whole-unit precisionβ¦" is not compressible into a
|
| 190 |
+
* chip) and the strip they land in is 30px tall. `countLabelCompact` already established this
|
| 191 |
+
* pattern for the same strip and the same reason: the honest short form on screen, the complete
|
| 192 |
+
* statement one hover away. Nothing is dropped; the long half moves.
|
| 193 |
+
*/
|
| 194 |
+
export function limitSummary(
|
| 195 |
+
limits: GridLimit[] | undefined
|
| 196 |
+
): { short: string; full: string } | null {
|
| 197 |
+
const binding = (limits ?? []).filter(
|
| 198 |
+
(l) => l && typeof l.subject === "string" && (l.effect ?? "none") !== "none"
|
| 199 |
+
);
|
| 200 |
+
if (binding.length === 0) return null;
|
| 201 |
+
const subjects = binding.map((l) => l.subject).join(", ");
|
| 202 |
+
const full = binding
|
| 203 |
+
.map((l) => `${l.subject}: ${l.cause ?? ""}${l.recommendation ? ` β ${l.recommendation}` : ""}`)
|
| 204 |
+
.join("\n\n");
|
| 205 |
+
return { short: `Limits apply to ${subjects}`, full };
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
/**
|
| 209 |
+
* ββ WAVE 30 Β· W30-T42 / R6's SECOND SENTENCE β WHAT A WINDOWED GRID CANNOT DO, SAID OUT LOUD.
|
| 210 |
+
*
|
| 211 |
+
* β THE THING THIS EXISTS TO PREVENT, AND IT IS A CLIFF NOBODY HAD WRITTEN DOWN. Turning
|
| 212 |
+
* `counts.windowed` on for the orders grid does not only change how rows arrive β nine controls
|
| 213 |
+
* `CustomerGrid` already gates on `serverWindowed` DISAPPEAR the same instant, every one of them
|
| 214 |
+
* with no explanation on screen. They were all working on that grid the day before, because the
|
| 215 |
+
* whole table was in the browser. Each refusal is individually correct β a cohort built from the
|
| 216 |
+
* 200 rows that happen to be in memory is a wrong cohort, an export of one page is a wrong
|
| 217 |
+
* export, a group header over a window subtotals the PAGE while claiming to describe the group β
|
| 218 |
+
* and a person who just watched eight buttons vanish is owed the reason.
|
| 219 |
+
*
|
| 220 |
+
* That is R6's second sentence exactly: *"if there is lag or it can't be done, you need to
|
| 221 |
+
* explicitly tell me why and recommend a fix."* A limit that cannot be removed must be REPORTED
|
| 222 |
+
* with its cause and a recommendation, never silently enforced. Silently enforcing this one would
|
| 223 |
+
* have been the violation, not the limit.
|
| 224 |
+
*
|
| 225 |
+
* β THE LIST IS THE CODE'S, NOT A GUESS: every entry below is a control `CustomerGrid.tsx` turns
|
| 226 |
+
* off on `serverWindowed`, and `verify_grid_ux.py` counts the gates so an item cannot be added
|
| 227 |
+
* there without a line here.
|
| 228 |
+
*/
|
| 229 |
+
export const WINDOWED_TOOLS_OFF = [
|
| 230 |
+
"grouping and its subtotals",
|
| 231 |
+
"the kanban, calendar, list, map and catalog views",
|
| 232 |
+
"export",
|
| 233 |
+
"add to cohort, and remove from cohort",
|
| 234 |
+
"add to folder",
|
| 235 |
+
"select from file",
|
| 236 |
+
"view alert badges",
|
| 237 |
+
];
|
| 238 |
+
|
| 239 |
+
export function windowedCapabilityNote(matched: number): { short: string; full: string } {
|
| 240 |
+
const n = Math.max(0, Math.floor(Number.isFinite(matched) ? matched : 0)).toLocaleString();
|
| 241 |
+
return {
|
| 242 |
+
short: "Some tools are off on this grid",
|
| 243 |
+
full:
|
| 244 |
+
`This database is read one page at a time out of ${n} records, so the tools that need `
|
| 245 |
+
+ `every matching row in the browser are off here: ${WINDOWED_TOOLS_OFF.join(", ")}. `
|
| 246 |
+
+ `Filtering, searching and sorting are NOT affected β they run on the server across all `
|
| 247 |
+
+ `${n}. Restoring the rest needs the server to do those folds: an export, a cohort `
|
| 248 |
+
+ `membership and a group summary computed over the whole match set rather than over the `
|
| 249 |
+
+ `page in memory.`,
|
| 250 |
+
};
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
/**
|
| 254 |
+
* The predicate this browser has asked the SERVER to evaluate, as one comparable string.
|
| 255 |
+
*
|
| 256 |
+
* β WHY A KEY AND NOT A DEEP COMPARE AT THE CALL SITE. In `server-windowed` mode every
|
| 257 |
+
* predicate change is a REQUEST, so the effect that fires it must be able to say "this is the
|
| 258 |
+
* same question I already asked" β and `config.filters` is a fresh array on every render of a
|
| 259 |
+
* component that re-renders on hover. Keyed on the VALUE, the effect fires once per real
|
| 260 |
+
* change; keyed on identity it would fire per repaint, which is a paid round trip per mouse
|
| 261 |
+
* move on a 963,783-row grid.
|
| 262 |
+
*
|
| 263 |
+
* β ONE PLACE BUILDS BOTH THE KEY AND THE EMPTINESS TEST (`EMPTY_WINDOW_PREDICATE` below is
|
| 264 |
+
* this function's own answer for a blank view). Two spellings of "no predicate" is how the
|
| 265 |
+
* first window gets re-requested on every mount for nothing.
|
| 266 |
+
*/
|
| 267 |
+
export function windowPredicateKey(
|
| 268 |
+
filters: FilterNode[] | undefined,
|
| 269 |
+
filterConj: string | undefined,
|
| 270 |
+
sorts: SortSpec | undefined,
|
| 271 |
+
search: string | undefined
|
| 272 |
+
): string {
|
| 273 |
+
return JSON.stringify([
|
| 274 |
+
filters ?? [],
|
| 275 |
+
filterConj === "or" ? "or" : "and",
|
| 276 |
+
sorts ?? [],
|
| 277 |
+
(search ?? "").trim(),
|
| 278 |
+
]);
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
/** What `windowPredicateKey` answers for a view with no filter, no sort and no search β i.e.
|
| 282 |
+
* exactly the question the FIRST window (offset 0, no query args) already answered. */
|
| 283 |
+
export const EMPTY_WINDOW_PREDICATE = windowPredicateKey([], "and", [], "");
|
web/src/customer-grid/icons.tsx
CHANGED
|
@@ -27,10 +27,29 @@ import {
|
|
| 27 |
TYPE_SHAPES,
|
| 28 |
} from "./iconShapes";
|
| 29 |
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
return (
|
| 32 |
<>
|
| 33 |
-
{shapes.map((s, i) =>
|
| 34 |
s.fill ? (
|
| 35 |
<path key={i} d={s.d} fill="currentColor" />
|
| 36 |
) : (
|
|
@@ -120,7 +139,10 @@ export function ToneModeIcon({
|
|
| 120 |
viewBox="0 0 16 16"
|
| 121 |
aria-hidden
|
| 122 |
>
|
| 123 |
-
{
|
|
|
|
|
|
|
|
|
|
| 124 |
s.fill ? (
|
| 125 |
<path key={i} d={s.d} fill={paint.stroke} />
|
| 126 |
) : (
|
|
@@ -165,7 +187,8 @@ export function ChartKindIcon({
|
|
| 165 |
viewBox="0 0 16 16"
|
| 166 |
aria-hidden
|
| 167 |
>
|
| 168 |
-
{
|
|
|
|
| 169 |
<path
|
| 170 |
key={i}
|
| 171 |
d={s.d}
|
|
@@ -469,7 +492,8 @@ export function FolderMark({
|
|
| 469 |
viewBox="0 0 16 16"
|
| 470 |
aria-hidden
|
| 471 |
>
|
| 472 |
-
{
|
|
|
|
| 473 |
<path
|
| 474 |
key={i}
|
| 475 |
d={s.d}
|
|
|
|
| 27 |
TYPE_SHAPES,
|
| 28 |
} from "./iconShapes";
|
| 29 |
|
| 30 |
+
/**
|
| 31 |
+
* β WAVE 30 item 2 (R5) β `shapes` IS OPTIONAL, and the `?? []` is the whole point.
|
| 32 |
+
*
|
| 33 |
+
* Every caller reaches this through a TOTAL `Record<K, IconShape[]>`, so TypeScript promises the
|
| 34 |
+
* lookup always hits. That promise is only as good as the key: `type` arrives over the WIRE, and
|
| 35 |
+
* a field whose type is not in the union β a kind added server-side, a stored definition from
|
| 36 |
+
* before a rename β makes `TYPE_SHAPES[type]` `undefined` at runtime with tsc none the wiser.
|
| 37 |
+
* `undefined.map` is a TypeError, and until this wave a TypeError anywhere unmounted the entire
|
| 38 |
+
* React tree: a blank white page, which is exactly what a user calls "it crashes".
|
| 39 |
+
*
|
| 40 |
+
* β RANKED SECOND AND SHIPPED ANYWAY (R5). Every field in `odoo_relational.order_fields` carries
|
| 41 |
+
* a type present in `TYPE_SHAPES`, and the server's `UT_FIELD_TYPES` filters unknown types off
|
| 42 |
+
* the wire β so this is LATENT, not the proven cause of owner item 2. The reporter was a user and
|
| 43 |
+
* cannot be re-interviewed, so both candidate causes are fixed rather than one guessed at.
|
| 44 |
+
*
|
| 45 |
+
* An absent lookup now paints NO GLYPH, which is the honest degradation: the row still renders,
|
| 46 |
+
* still carries its label, and the missing mark says "this type has no icon" rather than taking
|
| 47 |
+
* the product down to say it.
|
| 48 |
+
*/
|
| 49 |
+
function Shapes({ shapes }: { shapes?: IconShape[] }) {
|
| 50 |
return (
|
| 51 |
<>
|
| 52 |
+
{(shapes ?? []).map((s, i) =>
|
| 53 |
s.fill ? (
|
| 54 |
<path key={i} d={s.d} fill="currentColor" />
|
| 55 |
) : (
|
|
|
|
| 139 |
viewBox="0 0 16 16"
|
| 140 |
aria-hidden
|
| 141 |
>
|
| 142 |
+
{/* R5: `?? []` for the same reason as `Shapes` β `mode` is a STORED display mode off
|
| 143 |
+
the wire, so a view saved under a kind this build no longer knows makes the lookup
|
| 144 |
+
undefined and `.map` a TypeError. */}
|
| 145 |
+
{(MODE_SHAPES[mode] ?? []).map((s, i) =>
|
| 146 |
s.fill ? (
|
| 147 |
<path key={i} d={s.d} fill={paint.stroke} />
|
| 148 |
) : (
|
|
|
|
| 187 |
viewBox="0 0 16 16"
|
| 188 |
aria-hidden
|
| 189 |
>
|
| 190 |
+
{/* R5: same guard, same reason β `kind` comes from a saved chart definition. */}
|
| 191 |
+
{(CHART_KIND_SHAPES[kind] ?? []).map((s, i) => (
|
| 192 |
<path
|
| 193 |
key={i}
|
| 194 |
d={s.d}
|
|
|
|
| 492 |
viewBox="0 0 16 16"
|
| 493 |
aria-hidden
|
| 494 |
>
|
| 495 |
+
{/* R5: same guard β a folder's shape is stored in nav prefs. */}
|
| 496 |
+
{(FOLDER_SHAPE_PATHS[shape] ?? []).map((s, i) => (
|
| 497 |
<path
|
| 498 |
key={i}
|
| 499 |
d={s.d}
|
web/src/customer-grid/types.ts
CHANGED
|
@@ -1283,6 +1283,29 @@ export function isPresetField(field: Field): boolean {
|
|
| 1283 |
return (field.source === "odoo" && !field.custom) || isMachineOwned(field);
|
| 1284 |
}
|
| 1285 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1286 |
/**
|
| 1287 |
* β 2026-08-06, owner item 4 β **the cells a person must not type into.**
|
| 1288 |
*
|
|
@@ -3268,6 +3291,25 @@ export interface ScopeCounts {
|
|
| 3268 |
windowed: boolean;
|
| 3269 |
}
|
| 3270 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3271 |
export interface CustomersPayload {
|
| 3272 |
fields: Field[];
|
| 3273 |
rows: Row[];
|
|
@@ -3276,6 +3318,8 @@ export interface CustomersPayload {
|
|
| 3276 |
workspace?: GridWorkspace;
|
| 3277 |
/** Present only for server-windowed tables. See ScopeCounts. */
|
| 3278 |
counts?: ScopeCounts;
|
|
|
|
|
|
|
| 3279 |
/** Assignable people for `user` fields β supplied by the host from the tenant's real user
|
| 3280 |
* list, never invented client-side, so an assignee is always someone who can log in. */
|
| 3281 |
userOptions?: string[];
|
|
|
|
| 1283 |
return (field.source === "odoo" && !field.custom) || isMachineOwned(field);
|
| 1284 |
}
|
| 1285 |
|
| 1286 |
+
/**
|
| 1287 |
+
* β WAVE 30 item 2 (R5) β THE ONE PLACE A FIELD'S NAME IS RESOLVED, and why it is a function.
|
| 1288 |
+
*
|
| 1289 |
+
* `Field.label` is declared `string` and arrives over the WIRE, where nothing enforces that. The
|
| 1290 |
+
* fields panel did `f.label.toLowerCase()` unguarded, so a single label-less definition made a
|
| 1291 |
+
* TypeError the moment a search query was typed β and before this wave a TypeError anywhere
|
| 1292 |
+
* unmounted the whole React tree, which is the blank page a user calls "it crashes".
|
| 1293 |
+
*
|
| 1294 |
+
* β IT LIVES HERE, BESIDE THE OTHER FIELD PREDICATES, RATHER THAN INSIDE THE PANEL, for two
|
| 1295 |
+
* reasons that are worth keeping. A panel-local helper is invisible to every other surface that
|
| 1296 |
+
* will make the same read; and a panel-local helper cannot be RUN under this repo's node harness,
|
| 1297 |
+
* so the search path β the one that only fires once somebody types β would have had no test at all.
|
| 1298 |
+
*
|
| 1299 |
+
* β FALLS BACK TO THE KEY, never to "". A column with no label is still findable and still
|
| 1300 |
+
* nameable by the thing it actually is; an empty string would produce a row a user can see, cannot
|
| 1301 |
+
* search for, and cannot name in a report.
|
| 1302 |
+
*/
|
| 1303 |
+
export function fieldLabel(field: Field): string {
|
| 1304 |
+
return typeof field.label === "string" && field.label
|
| 1305 |
+
? field.label
|
| 1306 |
+
: String(field.key ?? "");
|
| 1307 |
+
}
|
| 1308 |
+
|
| 1309 |
/**
|
| 1310 |
* β 2026-08-06, owner item 4 β **the cells a person must not type into.**
|
| 1311 |
*
|
|
|
|
| 3291 |
windowed: boolean;
|
| 3292 |
}
|
| 3293 |
|
| 3294 |
+
/**
|
| 3295 |
+
* β WAVE 30 (contract C2 / R6's SECOND SENTENCE) β one limit the server could not remove.
|
| 3296 |
+
*
|
| 3297 |
+
* Authored by `routes_odoo_tables.odoo_table_rows`, which refuses to truncate silently: a
|
| 3298 |
+
* predicate it cannot push down, a window it had to clamp, a page deep enough to be slow, each
|
| 3299 |
+
* arrives named, with the cause and the recommendation the owner's ruling demands. The client's
|
| 3300 |
+
* only job is to not swallow it β see `counts.limitSummary`.
|
| 3301 |
+
*
|
| 3302 |
+
* β `effect: "none"` is a limit that did NOT bind this response; `"truncated"` is never emitted
|
| 3303 |
+
* by design (the route raises instead), and the value exists so that a future change cannot
|
| 3304 |
+
* start trimming without saying so.
|
| 3305 |
+
*/
|
| 3306 |
+
export interface GridLimit {
|
| 3307 |
+
subject: string;
|
| 3308 |
+
cause: string;
|
| 3309 |
+
recommendation: string;
|
| 3310 |
+
effect?: string;
|
| 3311 |
+
}
|
| 3312 |
+
|
| 3313 |
export interface CustomersPayload {
|
| 3314 |
fields: Field[];
|
| 3315 |
rows: Row[];
|
|
|
|
| 3318 |
workspace?: GridWorkspace;
|
| 3319 |
/** Present only for server-windowed tables. See ScopeCounts. */
|
| 3320 |
counts?: ScopeCounts;
|
| 3321 |
+
/** C2 β limits the SERVER declared on this response. Empty/absent = nothing binds. */
|
| 3322 |
+
limits?: GridLimit[];
|
| 3323 |
/** Assignable people for `user` fields β supplied by the host from the tenant's real user
|
| 3324 |
* list, never invented client-side, so an assignee is always someone who can log in. */
|
| 3325 |
userOptions?: string[];
|
web/src/customer-grid/useCustomerData.ts
CHANGED
|
@@ -28,12 +28,18 @@ import { topicForScope } from "./types";
|
|
| 28 |
import type { CustomersPayload, Field, GridWorkspace, Row } from "./types";
|
| 29 |
import {
|
| 30 |
clearTopicRowsCache,
|
|
|
|
|
|
|
| 31 |
fetchTopicRows,
|
| 32 |
fetchWorkspace,
|
|
|
|
| 33 |
patchTopicRow,
|
|
|
|
| 34 |
setSurfaceScope,
|
| 35 |
subscribeChanges,
|
|
|
|
| 36 |
} from "./apiBridge";
|
|
|
|
| 37 |
import { DERIVED_CELLS_EVENT, ROWS_STALE_EVENT, WORKSPACE_STALE_EVENT } from "../apiContract";
|
| 38 |
import { planChangeReaction, rowsRefetchDelay } from "./liveWorkspace";
|
| 39 |
import { signal } from "../apiContract";
|
|
@@ -165,6 +171,14 @@ export interface CustomerData {
|
|
| 165 |
setOverlayEdits: Dispatch<SetStateAction<OverlayEdits>>;
|
| 166 |
/** Optimistic write + PATCH /api/customers/{pid}; rolls back on failure. */
|
| 167 |
patchOverlay: (pid: number, updates: Partial<Row>) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
}
|
| 169 |
|
| 170 |
interface CustomerDataOptions {
|
|
@@ -210,6 +224,26 @@ export function useCustomerData(
|
|
| 210 |
// something OUTSIDE the grid's own write path changed rows (shell Add-record, an automation
|
| 211 |
// run). Senders clear the rows cache first, so the refetch cannot be served from the memo.
|
| 212 |
const [rowsBump, setRowsBump] = useState(0);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
/** Item 3 (3c) β the LAST scope whose first paint has already been attempted. See the
|
| 214 |
* `firstPaint` note in the load effect: this is what keeps a workspace failure loud on
|
| 215 |
* arrival and silent on every refetch afterwards. */
|
|
@@ -252,6 +286,12 @@ export function useCustomerData(
|
|
| 252 |
// bump without this is served the copy that predates the change we just detected β a
|
| 253 |
// refetch that costs a request, succeeds, and shows the user nothing.
|
| 254 |
clearTopicRowsCache(topic.rowsPath);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
lastRowsRead = Date.now();
|
| 256 |
setRowsBump((b) => b + 1);
|
| 257 |
};
|
|
@@ -346,17 +386,48 @@ export function useCustomerData(
|
|
| 346 |
const firstPaint = loudScope.current !== scope;
|
| 347 |
loudScope.current = scope;
|
| 348 |
|
|
|
|
|
|
|
| 349 |
async function load() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
const [payload, workspace] = await Promise.all([
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
?
|
| 357 |
-
:
|
|
|
|
| 358 |
]);
|
| 359 |
-
if (cancelled || !payload) return;
|
| 360 |
setData(workspace ? withWorkspace(payload, workspace) : payload);
|
| 361 |
}
|
| 362 |
|
|
@@ -499,6 +570,35 @@ export function useCustomerData(
|
|
| 499 |
});
|
| 500 |
}, [topic, writable]);
|
| 501 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
return {
|
| 503 |
fields: data?.fields ?? [],
|
| 504 |
rawRows: data?.rows ?? [],
|
|
@@ -507,5 +607,6 @@ export function useCustomerData(
|
|
| 507 |
overlayEdits,
|
| 508 |
setOverlayEdits,
|
| 509 |
patchOverlay,
|
|
|
|
| 510 |
};
|
| 511 |
}
|
|
|
|
| 28 |
import type { CustomersPayload, Field, GridWorkspace, Row } from "./types";
|
| 29 |
import {
|
| 30 |
clearTopicRowsCache,
|
| 31 |
+
dropTableRowsCache,
|
| 32 |
+
fetchTableWindow,
|
| 33 |
fetchTopicRows,
|
| 34 |
fetchWorkspace,
|
| 35 |
+
mergeWindow,
|
| 36 |
patchTopicRow,
|
| 37 |
+
readThroughTables,
|
| 38 |
setSurfaceScope,
|
| 39 |
subscribeChanges,
|
| 40 |
+
WINDOW_ROWS,
|
| 41 |
} from "./apiBridge";
|
| 42 |
+
import type { RowWindowRequest } from "./apiBridge";
|
| 43 |
import { DERIVED_CELLS_EVENT, ROWS_STALE_EVENT, WORKSPACE_STALE_EVENT } from "../apiContract";
|
| 44 |
import { planChangeReaction, rowsRefetchDelay } from "./liveWorkspace";
|
| 45 |
import { signal } from "../apiContract";
|
|
|
|
| 171 |
setOverlayEdits: Dispatch<SetStateAction<OverlayEdits>>;
|
| 172 |
/** Optimistic write + PATCH /api/customers/{pid}; rolls back on failure. */
|
| 173 |
patchOverlay: (pid: number, updates: Partial<Row>) => void;
|
| 174 |
+
/**
|
| 175 |
+
* β WAVE 30 Β· W30-T42 (contract C2) β ASK THE SERVER FOR A WINDOW OF A READ-THROUGH GRID.
|
| 176 |
+
*
|
| 177 |
+
* `offset: 0` replaces (a new predicate); `offset > 0` appends (a scroll). A no-op on a
|
| 178 |
+
* whole-book table, so the caller never has to ask which mode it is in twice β `serverWindowed`
|
| 179 |
+
* already answers that from `payload.counts`, and a second test here could disagree with it.
|
| 180 |
+
*/
|
| 181 |
+
requestWindow: (req: RowWindowRequest) => void;
|
| 182 |
}
|
| 183 |
|
| 184 |
interface CustomerDataOptions {
|
|
|
|
| 224 |
// something OUTSIDE the grid's own write path changed rows (shell Add-record, an automation
|
| 225 |
// run). Senders clear the rows cache first, so the refetch cannot be served from the memo.
|
| 226 |
const [rowsBump, setRowsBump] = useState(0);
|
| 227 |
+
/**
|
| 228 |
+
* β WAVE 30 Β· W30-T42 (contract C2) β the three refs a WINDOWED grid needs, and why each one
|
| 229 |
+
* is a ref rather than state: none of them may re-render the grid on its own.
|
| 230 |
+
*
|
| 231 |
+
* `readThroughRef` does THIS table read through the mirror? Answered once per open from
|
| 232 |
+
* `odoo-tables/status`, never from a key list here. Reset to false the
|
| 233 |
+
* moment the scope changes, so a switch cannot carry the previous table's
|
| 234 |
+
* answer into the new table's first scroll.
|
| 235 |
+
* `windowSeq` ONE ordering token shared by the load effect and `requestWindow`. Two
|
| 236 |
+
* paths can `setData` for the same surface (a rowsBump re-load while a
|
| 237 |
+
* scroll is in flight); the newest request wins and every older response
|
| 238 |
+
* is dropped rather than merged, because a merge of two orders is rows in
|
| 239 |
+
* the wrong places with nothing erroring.
|
| 240 |
+
* `inflightOffset` glide fires `onVisibleRegionChanged` on EVERY frame of a drag. Without
|
| 241 |
+
* this the same page is requested dozens of times before the first answer
|
| 242 |
+
* lands β a paid round trip per animation frame.
|
| 243 |
+
*/
|
| 244 |
+
const readThroughRef = useRef(false);
|
| 245 |
+
const windowSeq = useRef(0);
|
| 246 |
+
const inflightOffset = useRef<number | null>(null);
|
| 247 |
/** Item 3 (3c) β the LAST scope whose first paint has already been attempted. See the
|
| 248 |
* `firstPaint` note in the load effect: this is what keeps a workspace failure loud on
|
| 249 |
* arrival and silent on every refetch afterwards. */
|
|
|
|
| 286 |
// bump without this is served the copy that predates the change we just detected β a
|
| 287 |
// refetch that costs a request, succeeds, and shows the user nothing.
|
| 288 |
clearTopicRowsCache(topic.rowsPath);
|
| 289 |
+
// β AND THE WINDOWED FAMILY, which `topic.rowsPath` cannot name: a read-through grid is
|
| 290 |
+
// served from `odoo-tables/<key>/rows?β¦` under one key per offset Γ predicate. Without
|
| 291 |
+
// this the poller notices a change, re-runs the load effect, and the load effect is
|
| 292 |
+
// served the pre-change window straight out of the memo β a refetch that costs a request,
|
| 293 |
+
// succeeds, and shows the user nothing (the exact defect the line above exists to fix).
|
| 294 |
+
dropTableRowsCache(scope);
|
| 295 |
lastRowsRead = Date.now();
|
| 296 |
setRowsBump((b) => b + 1);
|
| 297 |
};
|
|
|
|
| 386 |
const firstPaint = loudScope.current !== scope;
|
| 387 |
loudScope.current = scope;
|
| 388 |
|
| 389 |
+
readThroughRef.current = false;
|
| 390 |
+
|
| 391 |
async function load() {
|
| 392 |
+
const seq = ++windowSeq.current;
|
| 393 |
+
// A workspace that cannot be read at first paint is not "a table with no saved
|
| 394 |
+
// views" β it is a table whose schema we do not have, and the grid used to fill
|
| 395 |
+
// that hole from a localStorage bucket shared with every other surface.
|
| 396 |
+
//
|
| 397 |
+
// β STARTED BEFORE THE READ-THROUGH QUESTION IS ASKED, deliberately. The roster below is
|
| 398 |
+
// memoised per session but its FIRST call is a real round trip, and awaiting it before
|
| 399 |
+
// this line would serialise the workspace behind it on the first grid a person opens.
|
| 400 |
+
// Only the ROWS request has to wait, because only it needs the answer.
|
| 401 |
+
const wsPromise = includeWorkspace
|
| 402 |
+
? fetchWorkspace(scope, { announceFailure: firstPaint })
|
| 403 |
+
: Promise.resolve(null);
|
| 404 |
+
/**
|
| 405 |
+
* ββ W30-T42 (R7 / contract C2) β WHOLE BOOK, OR ONE WINDOW?
|
| 406 |
+
*
|
| 407 |
+
* β THE SERVER DECIDES, NOT A LIST HERE. `odoo-tables/status` reports `readThrough` per
|
| 408 |
+
* table because the bindings convert one bucket at a time; a key list in the client goes
|
| 409 |
+
* stale on the next spec row and fails in the dangerous direction (asking the window
|
| 410 |
+
* route for a table it cannot serve, which answers 409).
|
| 411 |
+
*
|
| 412 |
+
* β AND IT FAILS SOFT TO TODAY'S BEHAVIOUR. An unreachable roster yields the empty set,
|
| 413 |
+
* so the table loads whole exactly as it always has. The opposite default β assume
|
| 414 |
+
* windowed, discover otherwise β would paint an empty grid over a working table.
|
| 415 |
+
*/
|
| 416 |
+
const tableKey = scope.startsWith("ut_") ? scope : null;
|
| 417 |
+
const readThrough = tableKey !== null && (await readThroughTables()).has(tableKey);
|
| 418 |
+
if (cancelled || seq !== windowSeq.current) return;
|
| 419 |
+
readThroughRef.current = readThrough;
|
| 420 |
const [payload, workspace] = await Promise.all([
|
| 421 |
+
readThrough && tableKey !== null
|
| 422 |
+
// The FIRST window carries no predicate: `config` is CustomerGrid's own state, seeded
|
| 423 |
+
// from the workspace that is still in flight beside this call, so it does not exist
|
| 424 |
+
// yet. The grid re-asks with the saved view's predicate the moment it does β see
|
| 425 |
+
// `windowPredicateKey` and the effect that consumes it.
|
| 426 |
+
? fetchTableWindow(tableKey, { offset: 0, limit: WINDOW_ROWS })
|
| 427 |
+
: fetchTopicRows(topic),
|
| 428 |
+
wsPromise,
|
| 429 |
]);
|
| 430 |
+
if (cancelled || seq !== windowSeq.current || !payload) return;
|
| 431 |
setData(workspace ? withWorkspace(payload, workspace) : payload);
|
| 432 |
}
|
| 433 |
|
|
|
|
| 570 |
});
|
| 571 |
}, [topic, writable]);
|
| 572 |
|
| 573 |
+
/**
|
| 574 |
+
* ββ WAVE 30 Β· W30-T42 β THE ONE DOOR THE GRID USES TO PAGE A READ-THROUGH TABLE.
|
| 575 |
+
*
|
| 576 |
+
* Two callers, one function: a PREDICATE change (offset 0, replace) and a SCROLL (offset >
|
| 577 |
+
* 0, append). Keeping them on one door is what makes the two impossible to get out of step β
|
| 578 |
+
* `mergeWindow` reads the offset and decides, so there is no second place where "does this
|
| 579 |
+
* response replace or extend what I hold" is answered.
|
| 580 |
+
*
|
| 581 |
+
* β A no-op on a whole-book table, and silently so. `serverWindowed` (from `payload.counts`)
|
| 582 |
+
* is the client's ONE answer to "is this table windowed"; a second test that could disagree
|
| 583 |
+
* with it is how the grid ends up requesting pages of a table it already holds entirely.
|
| 584 |
+
*/
|
| 585 |
+
const requestWindow = useCallback((req: RowWindowRequest) => {
|
| 586 |
+
if (!readThroughRef.current || STREAMLIT_COMPONENT || EMBEDDED_HTML) return;
|
| 587 |
+
const offset = Math.max(0, Math.floor(req.offset || 0));
|
| 588 |
+
// β ONE REQUEST PER PAGE IN FLIGHT. glide fires its visible-region callback on every frame
|
| 589 |
+
// of a scroll; without this the same offset is asked for on each one. Offset 0 is exempt on
|
| 590 |
+
// purpose β that is a PREDICATE change, and the newest predicate must always be able to
|
| 591 |
+
// overtake an older one rather than be suppressed by it.
|
| 592 |
+
if (offset > 0 && inflightOffset.current === offset) return;
|
| 593 |
+
const seq = ++windowSeq.current;
|
| 594 |
+
if (offset > 0) inflightOffset.current = offset;
|
| 595 |
+
void fetchTableWindow(scope, { ...req, offset }).then((next) => {
|
| 596 |
+
if (offset > 0 && inflightOffset.current === offset) inflightOffset.current = null;
|
| 597 |
+
if (seq !== windowSeq.current || !next) return;
|
| 598 |
+
setData((prev) => mergeWindow(prev, next, offset));
|
| 599 |
+
});
|
| 600 |
+
}, [scope]);
|
| 601 |
+
|
| 602 |
return {
|
| 603 |
fields: data?.fields ?? [],
|
| 604 |
rawRows: data?.rows ?? [],
|
|
|
|
| 607 |
overlayEdits,
|
| 608 |
setOverlayEdits,
|
| 609 |
patchOverlay,
|
| 610 |
+
requestWindow,
|
| 611 |
};
|
| 612 |
}
|
web/src/customer-grid/useGridColumns.ts
CHANGED
|
@@ -140,8 +140,35 @@ export function columnTones(
|
|
| 140 |
return out;
|
| 141 |
}
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
function isDefaultVisible(field: Field): boolean {
|
| 144 |
-
return field.default !== false
|
| 145 |
}
|
| 146 |
|
| 147 |
export function defaultViewConfig(fields: Field[]): ViewConfig {
|
|
|
|
| 140 |
return out;
|
| 141 |
}
|
| 142 |
|
| 143 |
+
/**
|
| 144 |
+
* β WAVE 30 (owner item 2 scouting) β THE OVERLAY ARM IS GONE, and the arithmetic is the reason.
|
| 145 |
+
*
|
| 146 |
+
* This read `field.default !== false || field.source === "overlay"`. The second arm was written
|
| 147 |
+
* when `overlay` meant "a column a user added here", and those columns have no `default` key at
|
| 148 |
+
* all β so the arm was a belt-and-braces no-op for them. Then `user_tables._clean_field` started
|
| 149 |
+
* stamping `source: "overlay"` on EVERY `ut_*` field, and every connected database became a `ut_*`
|
| 150 |
+
* one: the exception quietly became the rule and swallowed the first arm entirely.
|
| 151 |
+
*
|
| 152 |
+
* β WHAT THAT COST, measured on the live orders grid: `odoo_relational.order_fields` declares
|
| 153 |
+
* `default: False` on `odoo_id`, `state`, `customer_link` and `partner_id`, and every one of them
|
| 154 |
+
* opened SHOWN. The grid arrives wide, and "Hide fields" reads inactive because
|
| 155 |
+
* `shownCount === fields.length` β so the control that says "some columns are hidden" says the
|
| 156 |
+
* opposite of what its own table declares. A second instance of [[fallback-that-became-the-rule]].
|
| 157 |
+
*
|
| 158 |
+
* β DROPPING THE ARM DOES NOT HIDE USER COLUMNS, and this is the check that matters before
|
| 159 |
+
* believing the fix. A column somebody created carries no `default` key, `undefined !== false` is
|
| 160 |
+
* true, and it stays visible. `_clean_field` also keeps `default` ONLY when it is `True`, so a
|
| 161 |
+
* `ut_*` column can never arrive carrying `default: false` by accident β only a shipped field
|
| 162 |
+
* CONTRACT (which is written straight into the store, bypassing that validator) can declare it.
|
| 163 |
+
*
|
| 164 |
+
* β THIS PREDICATE HAS A SERVER TWIN and the twin is the one that decides on first open:
|
| 165 |
+
* `platform/aios_grid.py:_default_view_config` carries the same expression and builds the "All
|
| 166 |
+
* records" system view the client pins as its landing default. Fixing one side alone changes
|
| 167 |
+
* nothing a user sees β see `mailbox/F.md` F-1 ([[one-question-two-normalizers]] across two
|
| 168 |
+
* languages).
|
| 169 |
+
*/
|
| 170 |
function isDefaultVisible(field: Field): boolean {
|
| 171 |
+
return field.default !== false;
|
| 172 |
}
|
| 173 |
|
| 174 |
export function defaultViewConfig(fields: Field[]): ViewConfig {
|
web/src/filter-kit/FieldsHidePanel.tsx
CHANGED
|
@@ -15,7 +15,17 @@
|
|
| 15 |
|
| 16 |
import { useState } from "react";
|
| 17 |
import type { Field } from "../customer-grid/types";
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
import { FieldTypeIcon } from "../customer-grid/icons";
|
| 20 |
import { TYPE_LABELS } from "../customer-grid/iconShapes";
|
| 21 |
|
|
@@ -74,7 +84,7 @@ export function FieldsHidePanel({
|
|
| 74 |
const [dragKey, setDragKey] = useState<string | null>(null);
|
| 75 |
const [overKey, setOverKey] = useState<string | null>(null);
|
| 76 |
const q = query.trim().toLowerCase();
|
| 77 |
-
const shown = q ? fields.filter((f) => f.
|
| 78 |
/**
|
| 79 |
* Item 23 β reorder is OFF while a search is narrowing the list. Dropping "City" above
|
| 80 |
* "State" in a list that is hiding the eight fields between them means nothing the user can
|
|
@@ -188,10 +198,10 @@ export function FieldsHidePanel({
|
|
| 188 |
draggable={canReorder && !locked}
|
| 189 |
aria-label={
|
| 190 |
locked
|
| 191 |
-
? `${f
|
| 192 |
: canReorder
|
| 193 |
-
? `Reorder ${f
|
| 194 |
-
: `Reorder ${f
|
| 195 |
}
|
| 196 |
title={
|
| 197 |
canReorder && !locked
|
|
@@ -241,7 +251,7 @@ export function FieldsHidePanel({
|
|
| 241 |
draws (icons.tsx). Titled, because this list is where the icon
|
| 242 |
vocabulary is learned. */}
|
| 243 |
<FieldTypeIcon type={f.type} title={TYPE_LABELS[f.type]} />
|
| 244 |
-
<span className="cg-check-label">{f
|
| 245 |
{/* β OWNER ITEM 3 (2026-08-06): *"it doesn't show pre-set when I hide fields just
|
| 246 |
like how Odoo fields are. This is always the standard label."* Right β the chip
|
| 247 |
asked `source === "odoo"`, so a column an AUTOMATION fills (source `overlay`)
|
|
@@ -262,7 +272,7 @@ export function FieldsHidePanel({
|
|
| 262 |
type="button"
|
| 263 |
className={"cg-check-del" + (armed ? " is-armed" : "")}
|
| 264 |
aria-label={
|
| 265 |
-
armed ? `Permanently delete ${f
|
| 266 |
}
|
| 267 |
title={armed ? undefined : "Removes this field and its values on every row."}
|
| 268 |
onClick={(e) => {
|
|
|
|
| 15 |
|
| 16 |
import { useState } from "react";
|
| 17 |
import type { Field } from "../customer-grid/types";
|
| 18 |
+
// β WAVE 30 item 2 (R5) β `fieldLabel` is the ONE resolver for a field's NAME, and every read
|
| 19 |
+
// of `f.label` in this file goes through it. `Field.label` is declared `string` and arrives
|
| 20 |
+
// over the WIRE, where nothing enforces that; the search filter below did
|
| 21 |
+
// `f.label.toLowerCase()` unguarded, so one label-less definition threw the moment a query
|
| 22 |
+
// was typed β and before this wave a throw anywhere unmounted the whole React tree.
|
| 23 |
+
// β ALL SIX SITES, not just the filter. A guard on the filter alone leaves the visible row
|
| 24 |
+
// and its four aria strings reading `undefined` β [[one-question-two-normalizers]] in
|
| 25 |
+
// miniature, where what a user searches and what they read answer the same question
|
| 26 |
+
// differently. It lives in `types.ts` so the node harness can run it: this panel is JSX and
|
| 27 |
+
// its search path only fires once somebody types, which no static render can do.
|
| 28 |
+
import { fieldLabel, isPresetField } from "../customer-grid/types";
|
| 29 |
import { FieldTypeIcon } from "../customer-grid/icons";
|
| 30 |
import { TYPE_LABELS } from "../customer-grid/iconShapes";
|
| 31 |
|
|
|
|
| 84 |
const [dragKey, setDragKey] = useState<string | null>(null);
|
| 85 |
const [overKey, setOverKey] = useState<string | null>(null);
|
| 86 |
const q = query.trim().toLowerCase();
|
| 87 |
+
const shown = q ? fields.filter((f) => fieldLabel(f).toLowerCase().includes(q)) : fields;
|
| 88 |
/**
|
| 89 |
* Item 23 β reorder is OFF while a search is narrowing the list. Dropping "City" above
|
| 90 |
* "State" in a list that is hiding the eight fields between them means nothing the user can
|
|
|
|
| 198 |
draggable={canReorder && !locked}
|
| 199 |
aria-label={
|
| 200 |
locked
|
| 201 |
+
? `${fieldLabel(f)} is always first`
|
| 202 |
: canReorder
|
| 203 |
+
? `Reorder ${fieldLabel(f)} β drag, or use the arrow keys`
|
| 204 |
+
: `Reorder ${fieldLabel(f)} (clear the search first)`
|
| 205 |
}
|
| 206 |
title={
|
| 207 |
canReorder && !locked
|
|
|
|
| 251 |
draws (icons.tsx). Titled, because this list is where the icon
|
| 252 |
vocabulary is learned. */}
|
| 253 |
<FieldTypeIcon type={f.type} title={TYPE_LABELS[f.type]} />
|
| 254 |
+
<span className="cg-check-label">{fieldLabel(f)}</span>
|
| 255 |
{/* β OWNER ITEM 3 (2026-08-06): *"it doesn't show pre-set when I hide fields just
|
| 256 |
like how Odoo fields are. This is always the standard label."* Right β the chip
|
| 257 |
asked `source === "odoo"`, so a column an AUTOMATION fills (source `overlay`)
|
|
|
|
| 272 |
type="button"
|
| 273 |
className={"cg-check-del" + (armed ? " is-armed" : "")}
|
| 274 |
aria-label={
|
| 275 |
+
armed ? `Permanently delete ${fieldLabel(f)}?` : `Delete ${fieldLabel(f)}`
|
| 276 |
}
|
| 277 |
title={armed ? undefined : "Removes this field and its values on every row."}
|
| 278 |
onClick={(e) => {
|
web/src/index.css
CHANGED
|
@@ -8778,6 +8778,39 @@ a.cg-map-ctl-b { text-decoration: none; }
|
|
| 8778 |
background: var(--lp-surface-2);
|
| 8779 |
color: var(--lp-muted);
|
| 8780 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8781 |
.autox-card-text { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
| 8782 |
.autox-card-title {
|
| 8783 |
overflow: hidden;
|
|
@@ -11152,6 +11185,64 @@ a.cg-map-ctl-b { text-decoration: none; }
|
|
| 11152 |
font-weight: 600;
|
| 11153 |
letter-spacing: 0.02em;
|
| 11154 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11155 |
.conn-card-name {
|
| 11156 |
flex: 1 1 auto;
|
| 11157 |
min-width: 0;
|
|
|
|
| 8778 |
background: var(--lp-surface-2);
|
| 8779 |
color: var(--lp-muted);
|
| 8780 |
}
|
| 8781 |
+
/* ββ WAVE 30 Β· ITEM 7 / R4 β THE TILE A REAL LOGO SITS IN.
|
| 8782 |
+
β IT IS NOT A STYLE PREFERENCE, IT IS A CORRECTNESS FIX. The base tile paints
|
| 8783 |
+
`--lp-blue-tint` and sets `color: var(--lp-blue-deep)`, and both break a brand mark in a
|
| 8784 |
+
different way: Instagram's gradient would sit on a coloured chip that is not its colour, and
|
| 8785 |
+
TikTok's note β drawn in `currentColor` ON PURPOSE, so the mark survives dark mode β would
|
| 8786 |
+
render BLUE. Neutral ground and inherited text put both back where the brand put them.
|
| 8787 |
+
β SPECIFICITY, STATED: this is (0,2,0), so the two `.autox-card.is-X .autox-card-mark` rules
|
| 8788 |
+
above (0,3,0) still win inside a group or machine card. That is correct today because neither
|
| 8789 |
+
of those card kinds is ever branded β and if one becomes so, this comment is where to look.
|
| 8790 |
+
ββ `color: inherit` IS THE RULE, AND IT IS NOT A TIDY-UP β DO NOT REPLACE IT WITH A TOKEN.
|
| 8791 |
+
This shipped as `var(--lp-text)`, a token that is DEFINED NOWHERE (the ink token is `--lp-ink`).
|
| 8792 |
+
It worked by accident and `web_icons` refused it: an unresolvable `var()` is invalid at
|
| 8793 |
+
computed-value time, so `color` β an inherited property β fell back to `unset`, which for an
|
| 8794 |
+
inherited property IS `inherit`. So this line is a byte-identical statement of what the four
|
| 8795 |
+
brand tiles were already doing, and the paragraph above is where the intent was written down.
|
| 8796 |
+
β Pinning a real ink here (`--lp-ink`) would be the one change that BREAKS them: two of the
|
| 8797 |
+
nine marks are drawn in `currentColor` on purpose so they survive a dark ground, and an ink
|
| 8798 |
+
would nail them to one. And minting a `--lp-text` token would mint a second name for
|
| 8799 |
+
`--lp-ink`, which the 23-token parity leg (`index.css` vs `theme.ts`) would carry forever. */
|
| 8800 |
+
.autox-card-mark.is-brand {
|
| 8801 |
+
background: transparent;
|
| 8802 |
+
color: inherit;
|
| 8803 |
+
}
|
| 8804 |
+
/* The connector heading's own small slot: a nest caption is 4xs text, so it takes a 13px mark
|
| 8805 |
+
rather than the 26px row tile β the tile beside a caption reads as a row that lost its label. */
|
| 8806 |
+
.autox-sub-mark {
|
| 8807 |
+
display: inline-flex;
|
| 8808 |
+
align-items: center;
|
| 8809 |
+
justify-content: center;
|
| 8810 |
+
vertical-align: -2px;
|
| 8811 |
+
margin-right: 5px;
|
| 8812 |
+
color: inherit; /* see `.autox-card-mark.is-brand` above β inherited on purpose, never a token */
|
| 8813 |
+
}
|
| 8814 |
.autox-card-text { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
| 8815 |
.autox-card-title {
|
| 8816 |
overflow: hidden;
|
|
|
|
| 11185 |
font-weight: 600;
|
| 11186 |
letter-spacing: 0.02em;
|
| 11187 |
}
|
| 11188 |
+
/* ββ WAVE 30 Β· R4 β THE LOGO TILE. It carries its own box because it does NOT wear
|
| 11189 |
+
`.shell-db-chip`: that class paints `--lp-primary` and white text, which is exactly right for
|
| 11190 |
+
two LETTERS (12px type needs 4.5:1 and only the base chip clears it) and exactly wrong under a
|
| 11191 |
+
brand mark, which brings its own colour. Same 26px square in the same radius, so the grid does
|
| 11192 |
+
not shift between a card with a logo and a card with letters. */
|
| 11193 |
+
.conn-tile.is-brand {
|
| 11194 |
+
display: inline-flex;
|
| 11195 |
+
align-items: center;
|
| 11196 |
+
justify-content: center;
|
| 11197 |
+
flex: 0 0 auto;
|
| 11198 |
+
width: 26px;
|
| 11199 |
+
height: 26px;
|
| 11200 |
+
border-radius: var(--lp-r-md);
|
| 11201 |
+
background: transparent;
|
| 11202 |
+
color: inherit; /* see `.autox-card-mark.is-brand` β inherited on purpose, never a token */
|
| 11203 |
+
}
|
| 11204 |
+
/* The platform drill (R4): collapsed by default, so a card costs one line until somebody asks. */
|
| 11205 |
+
.conn-plats { margin: 6px 0 0; }
|
| 11206 |
+
.conn-plats-sum {
|
| 11207 |
+
display: flex;
|
| 11208 |
+
align-items: center;
|
| 11209 |
+
gap: 5px;
|
| 11210 |
+
cursor: pointer;
|
| 11211 |
+
list-style: none;
|
| 11212 |
+
font-size: var(--lp-fs-3xs);
|
| 11213 |
+
color: var(--lp-muted);
|
| 11214 |
+
}
|
| 11215 |
+
.conn-plats-sum::-webkit-details-marker { display: none; }
|
| 11216 |
+
/* The chevron, drawn the way the automation menu's nest draws it, so the two disclosures in this
|
| 11217 |
+
product are one gesture rather than two that happen to both open. */
|
| 11218 |
+
.conn-plats-sum::before {
|
| 11219 |
+
content: "";
|
| 11220 |
+
flex: 0 0 auto;
|
| 11221 |
+
width: 7px;
|
| 11222 |
+
height: 7px;
|
| 11223 |
+
border-right: 1.4px solid var(--lp-muted);
|
| 11224 |
+
border-bottom: 1.4px solid var(--lp-muted);
|
| 11225 |
+
transform: rotate(-45deg);
|
| 11226 |
+
transition: transform 0.12s ease;
|
| 11227 |
+
}
|
| 11228 |
+
.conn-plats[open] > .conn-plats-sum::before { transform: rotate(45deg); }
|
| 11229 |
+
.conn-plat {
|
| 11230 |
+
display: flex;
|
| 11231 |
+
align-items: flex-start;
|
| 11232 |
+
gap: 8px;
|
| 11233 |
+
padding: 6px 0 0 14px;
|
| 11234 |
+
}
|
| 11235 |
+
.conn-plat-mark {
|
| 11236 |
+
flex: 0 0 auto;
|
| 11237 |
+
display: inline-flex;
|
| 11238 |
+
align-items: center;
|
| 11239 |
+
justify-content: center;
|
| 11240 |
+
margin-top: 1px;
|
| 11241 |
+
color: inherit; /* see `.autox-card-mark.is-brand` β inherited on purpose, never a token */
|
| 11242 |
+
}
|
| 11243 |
+
.conn-plat-text { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
|
| 11244 |
+
.conn-plat-name { font-size: var(--lp-fs-3xs); font-weight: 600; color: var(--lp-ink); }
|
| 11245 |
+
.conn-plat-desc { font-size: var(--lp-fs-4xs); color: var(--lp-muted); }
|
| 11246 |
.conn-card-name {
|
| 11247 |
flex: 1 1 auto;
|
| 11248 |
min-width: 0;
|
web/src/shell/ErrorBoundary.tsx
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// shell / ErrorBoundary.tsx β WAVE 30, owner item 2 (ruling R5).
|
| 3 |
+
//
|
| 4 |
+
// β WHY THIS FILE EXISTS, AND THE MEASUREMENT BEHIND IT. Until today,
|
| 5 |
+
// `ErrorBoundary|componentDidCatch|getDerivedStateFromError` had ZERO hits in the
|
| 6 |
+
// whole of `web/src`. React's contract when a render throws and nothing catches it
|
| 7 |
+
// is to unmount the ENTIRE tree β so one unguarded property access anywhere in the
|
| 8 |
+
// product produced a blank white page. That is exactly what a user calls "it
|
| 9 |
+
// crashes", and it is why owner item 2 arrived as a report with no diagnosis: there
|
| 10 |
+
// was nothing left on screen to diagnose.
|
| 11 |
+
//
|
| 12 |
+
// A boundary is therefore not a workaround for the hide-fields panel. It is the
|
| 13 |
+
// difference between "one surface failed and said so" and "the product vanished".
|
| 14 |
+
//
|
| 15 |
+
// β WHAT NO GATE IN THIS REPO CAN PROVE, measured 2026-08-12 rather than assumed β
|
| 16 |
+
// written here so nobody reads the gate as stronger than it is:
|
| 17 |
+
// Β· there is NO DOM. `jsdom`, `happy-dom`, `linkedom`, `domino` and
|
| 18 |
+
// `react-test-renderer` are all absent from `web/node_modules` (160 packages),
|
| 19 |
+
// and there is no vitest. So the React RECONCILER cannot be run under node.
|
| 20 |
+
// Β· React 18.3.1's server renderers do NOT invoke error boundaries. Both
|
| 21 |
+
// `renderToString` and `renderToStaticMarkup` were fed one throwing child under
|
| 22 |
+
// a boundary declaring `getDerivedStateFromError`; both PROPAGATED the throw and
|
| 23 |
+
// `componentDidCatch` never fired.
|
| 24 |
+
// β the fallback is a SEPARATE exported component precisely so it can be rendered
|
| 25 |
+
// for real, and the class's protocol is driven by hand in `verify_grid_ux.py`
|
| 26 |
+
// (throw β catch β `getDerivedStateFromError` β `render()`), which is React's own
|
| 27 |
+
// documented sequence. The reconciler's guarantee is React's, not ours; what IS
|
| 28 |
+
// ours β that every surface is actually wrapped β is a source scan, and that is
|
| 29 |
+
// the check with teeth ([[reachable-is-not-the-same-as-built]]: a boundary that
|
| 30 |
+
// exists and wraps nothing is this repo's sixth whole-correct-unreachable feature).
|
| 31 |
+
// ---------------------------------------------------------------------------
|
| 32 |
+
|
| 33 |
+
import { Component } from "react";
|
| 34 |
+
import type { ErrorInfo, ReactNode } from "react";
|
| 35 |
+
import "./errorBoundary.css";
|
| 36 |
+
|
| 37 |
+
export interface FailurePanelProps {
|
| 38 |
+
/** The surface that failed, named the way the user got to it ("Odoo orders",
|
| 39 |
+
* "Automation", "Loopable"). A panel that will not say WHAT broke sends the
|
| 40 |
+
* reader back to us with the same sentence item 2 arrived with. */
|
| 41 |
+
surface: string;
|
| 42 |
+
/** The thrown message. Shown, not hidden: a user who can paste one line saves a
|
| 43 |
+
* scouting session. β It is the message ONLY β never a stack, and never a value
|
| 44 |
+
* from a row, because this panel is on screen wherever the failure happened. */
|
| 45 |
+
message?: string;
|
| 46 |
+
onReload: () => void;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/**
|
| 50 |
+
* The contained failure card.
|
| 51 |
+
*
|
| 52 |
+
* It borrows `.shell-placeholder` and `.shell-retry` on purpose β the shell already
|
| 53 |
+
* has two failure panels (the data-error card and the nav-failed card) and a third
|
| 54 |
+
* visual language for the same idea would read as a different kind of problem. The
|
| 55 |
+
* only new paint is the tone strip in `errorBoundary.css`, which is what separates
|
| 56 |
+
* "this did not load" from "this broke".
|
| 57 |
+
*
|
| 58 |
+
* Exported as its own component so it can be rendered for real under node; see the
|
| 59 |
+
* measurement in this file's header for why the class cannot be.
|
| 60 |
+
*/
|
| 61 |
+
export function FailurePanel({ surface, message, onReload }: FailurePanelProps) {
|
| 62 |
+
return (
|
| 63 |
+
<div className="shell-placeholder shell-crash" role="alert">
|
| 64 |
+
<h1>{surface} could not be shown</h1>
|
| 65 |
+
<p>
|
| 66 |
+
Something here failed while drawing. The rest of the app still works β pick
|
| 67 |
+
another database from the sidebar, or reload to try this one again.
|
| 68 |
+
</p>
|
| 69 |
+
{message ? <p className="shell-crash-detail">{message}</p> : null}
|
| 70 |
+
<button
|
| 71 |
+
className="login-submit shell-retry"
|
| 72 |
+
type="button"
|
| 73 |
+
onClick={onReload}
|
| 74 |
+
>
|
| 75 |
+
Reload
|
| 76 |
+
</button>
|
| 77 |
+
</div>
|
| 78 |
+
);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
export interface ErrorBoundaryProps {
|
| 82 |
+
surface: string;
|
| 83 |
+
children: ReactNode;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
export interface ErrorBoundaryState {
|
| 87 |
+
error: Error | null;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/**
|
| 91 |
+
* Catch a render throw and paint `FailurePanel` in its place, leaving every sibling
|
| 92 |
+
* mounted.
|
| 93 |
+
*
|
| 94 |
+
* β A BOUNDARY LATCHES, AND THAT IS THE TRAP TO KNOW ABOUT. Once it holds an error
|
| 95 |
+
* it renders the panel until it is REMOUNTED β there is no "it will sort itself out
|
| 96 |
+
* on the next render". So every mount below passes a `key` that changes when the
|
| 97 |
+
* user moves (the route, the surface), which turns "navigate away and back" into the
|
| 98 |
+
* recovery path a person will actually try. `Reload` is the second door, and it is
|
| 99 |
+
* the same control the shell's two existing failure cards offer.
|
| 100 |
+
*/
|
| 101 |
+
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
| 102 |
+
constructor(props: ErrorBoundaryProps) {
|
| 103 |
+
super(props);
|
| 104 |
+
this.state = { error: null };
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
|
| 108 |
+
// Anything can be thrown in JavaScript, including a string or `undefined`.
|
| 109 |
+
// Normalising here means `render` has one shape to reason about and the panel
|
| 110 |
+
// can never itself throw reading `.message` off a non-Error.
|
| 111 |
+
return { error: error instanceof Error ? error : new Error(String(error)) };
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
componentDidCatch(error: unknown, info: ErrorInfo): void {
|
| 115 |
+
// The console is the only sink this client has today. It is enough to turn the
|
| 116 |
+
// next report from "it crashes" into a stack β which was the whole cost of item
|
| 117 |
+
// 2 arriving undiagnosed. Forwarding this to the server is booked as pending
|
| 118 |
+
// work rather than smuggled in here: it needs a route, and it must be proven to
|
| 119 |
+
// carry no row values before it ships.
|
| 120 |
+
console.error(`[${this.props.surface}] render failed`, error, info.componentStack);
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
render(): ReactNode {
|
| 124 |
+
const { error } = this.state;
|
| 125 |
+
if (error) {
|
| 126 |
+
return (
|
| 127 |
+
<FailurePanel
|
| 128 |
+
surface={this.props.surface}
|
| 129 |
+
message={error.message}
|
| 130 |
+
onReload={() => window.location.reload()}
|
| 131 |
+
/>
|
| 132 |
+
);
|
| 133 |
+
}
|
| 134 |
+
return this.props.children;
|
| 135 |
+
}
|
| 136 |
+
}
|
web/src/shell/Shell.tsx
CHANGED
|
@@ -64,6 +64,7 @@ const SettingsModal = lazy(() =>
|
|
| 64 |
import("../settings/SettingsModal").then((m) => ({ default: m.SettingsModal })));
|
| 65 |
import type { SettingsSection } from "../settings/SettingsModal";
|
| 66 |
import { Brand } from "./Brand";
|
|
|
|
| 67 |
import LoginPage from "./LoginPage";
|
| 68 |
import { CONNECTORS_ROUTE, EMPTY_NAV_PREFS, ENVELOPE_KEYS, HOME_ROUTE, MAX_NAV_FOLDERS, appLink, databaseEntries, dbChipClass, defaultRoute, deleteTable, fetchNav, fetchNavPrefs, fetchTableFootprint, foldNav, postOpened, resolveRoute, saveNavMeta, saveNavPrefs, shapeNav, splitChrome } from "./nav";
|
| 69 |
import type { NavEntry, NavMetaPatch, NavPage, NavPrefs, Recent } from "./nav";
|
|
@@ -95,9 +96,20 @@ const AlertsPane = lazy(() => import("../alerts/AlertsPane"));
|
|
| 95 |
* These chunks are tens of kilobytes off the same origin that just served the shell, so the
|
| 96 |
* honest choice between a flash of a spinner and a frame of nothing is nothing β a spinner that
|
| 97 |
* appears and vanishes within one frame reads as a glitch, not as progress.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
*/
|
| 99 |
-
function Lazily({ children }: { children: ReactNode }) {
|
| 100 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
}
|
| 102 |
|
| 103 |
import { createAlert, fetchInbox } from "../alerts/alertsApi";
|
|
@@ -578,7 +590,26 @@ function AccountMenu({
|
|
| 578 |
);
|
| 579 |
}
|
| 580 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
export default function Shell() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 582 |
const [session, setSession] = useState<Session>({ phase: "checking" });
|
| 583 |
const [nav, setNav] = useState<Nav>({ phase: "idle" });
|
| 584 |
// Wave 18 (C3-UT): bumped after "+ New database" so the rail refetches and the new ut_ key
|
|
@@ -2047,6 +2078,14 @@ export default function Shell() {
|
|
| 2047 |
) : null}
|
| 2048 |
|
| 2049 |
<main className="shell-main">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2050 |
{/* β An honest failure, never a fallback to the bundled sample. Behind
|
| 2051 |
a login, plausible-looking sample revenue is fabricated data on a
|
| 2052 |
screen the user has every reason to trust. */}
|
|
@@ -2096,7 +2135,7 @@ export default function Shell() {
|
|
| 2096 |
{...(active.icon ? { icon: active.icon } : {})}
|
| 2097 |
/>
|
| 2098 |
<div className="shell-auto-host">
|
| 2099 |
-
<Lazily><AutomationSurface /></Lazily>
|
| 2100 |
</div>
|
| 2101 |
</div>
|
| 2102 |
) : active?.kind === "native" ? (
|
|
@@ -2139,7 +2178,7 @@ export default function Shell() {
|
|
| 2139 |
// The one action the page cannot perform itself: a `manage: "keychain"` row opens
|
| 2140 |
// Settings on the Keychains tab, and the Settings MODAL is the frame's. Same division
|
| 2141 |
// as every other panel here β the page knows what it wants, the frame owns the door.
|
| 2142 |
-
<Lazily><ConnectorsPage onKeychain={() => setSettings("keychains")} /></Lazily>
|
| 2143 |
) : nav.phase === "ready" ? (
|
| 2144 |
// β WAVE 23 item 9 (R7, C10) β HOME, AND IT IS THE FALLBACK RATHER THAN A ROUTE MATCH.
|
| 2145 |
//
|
|
@@ -2159,7 +2198,7 @@ export default function Shell() {
|
|
| 2159 |
// hero's create door, and its empty line carries the hero's sentence (HomePage's
|
| 2160 |
// `home-empty`, which branches on `entries.length === 0` for exactly this reason).
|
| 2161 |
// Booked as a dated amendment in the wave doc, since it moves an R4 surface.
|
| 2162 |
-
<Lazily>
|
| 2163 |
<HomePage
|
| 2164 |
// β `dbEntries`, NOT `entries` β found by LOOKING at it (wave 24 close-out visual
|
| 2165 |
// pass), which is the only way it could have been found. The Automation SURFACE is a
|
|
@@ -2208,6 +2247,7 @@ export default function Shell() {
|
|
| 2208 |
<span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
|
| 2209 |
</div>
|
| 2210 |
)}
|
|
|
|
| 2211 |
</main>
|
| 2212 |
|
| 2213 |
{/* The server's own confirmation of a write (X2's `toast`). In standalone
|
|
@@ -2306,7 +2346,7 @@ export default function Shell() {
|
|
| 2306 |
// WAVE 23 C12 (wiring W23-W6) β the picker. It applies views to an EXISTING
|
| 2307 |
// database rather than making one, which is what a platform-curated template is:
|
| 2308 |
// a set of saved views, not a table (R10).
|
| 2309 |
-
<Lazily>
|
| 2310 |
<TemplatePicker
|
| 2311 |
entries={dbEntries}
|
| 2312 |
onToast={setToast}
|
|
@@ -2356,7 +2396,7 @@ export default function Shell() {
|
|
| 2356 |
) : null}
|
| 2357 |
|
| 2358 |
{settings ? (
|
| 2359 |
-
<Lazily>
|
| 2360 |
<SettingsModal
|
| 2361 |
user={session.user}
|
| 2362 |
section={settings}
|
|
@@ -2375,7 +2415,7 @@ export default function Shell() {
|
|
| 2375 |
|
| 2376 |
{/* C-ALERT (item 25): the inbox. */}
|
| 2377 |
{alertsOpen ? (
|
| 2378 |
-
<Lazily>
|
| 2379 |
<AlertsPane
|
| 2380 |
onClose={() => setAlertsOpen(false)}
|
| 2381 |
onInbox={setInbox}
|
|
|
|
| 64 |
import("../settings/SettingsModal").then((m) => ({ default: m.SettingsModal })));
|
| 65 |
import type { SettingsSection } from "../settings/SettingsModal";
|
| 66 |
import { Brand } from "./Brand";
|
| 67 |
+
import { ErrorBoundary } from "./ErrorBoundary";
|
| 68 |
import LoginPage from "./LoginPage";
|
| 69 |
import { CONNECTORS_ROUTE, EMPTY_NAV_PREFS, ENVELOPE_KEYS, HOME_ROUTE, MAX_NAV_FOLDERS, appLink, databaseEntries, dbChipClass, defaultRoute, deleteTable, fetchNav, fetchNavPrefs, fetchTableFootprint, foldNav, postOpened, resolveRoute, saveNavMeta, saveNavPrefs, shapeNav, splitChrome } from "./nav";
|
| 70 |
import type { NavEntry, NavMetaPatch, NavPage, NavPrefs, Recent } from "./nav";
|
|
|
|
| 96 |
* These chunks are tens of kilobytes off the same origin that just served the shell, so the
|
| 97 |
* honest choice between a flash of a spinner and a frame of nothing is nothing β a spinner that
|
| 98 |
* appears and vanishes within one frame reads as a glitch, not as progress.
|
| 99 |
+
*
|
| 100 |
+
* β WAVE 30 (R5) β AND AN ERROR BOUNDARY, INSIDE THE HELPER RATHER THAN AT SIX CALL SITES.
|
| 101 |
+
* Three of these six (`TemplatePicker`, `SettingsModal`, `AlertsPane`) are siblings of `<main>`,
|
| 102 |
+
* so the content-area boundary below cannot cover them; putting it here covers all six with one
|
| 103 |
+
* edit. It also catches what `Suspense` alone cannot: a REJECTED chunk fetch. `lazy()` throws its
|
| 104 |
+
* rejection at render, which before today unmounted the whole product because one deploy-time
|
| 105 |
+
* cache miss made a modal's JavaScript unreachable.
|
| 106 |
*/
|
| 107 |
+
function Lazily({ children, surface }: { children: ReactNode; surface: string }) {
|
| 108 |
+
return (
|
| 109 |
+
<ErrorBoundary surface={surface}>
|
| 110 |
+
<Suspense fallback={null}>{children}</Suspense>
|
| 111 |
+
</ErrorBoundary>
|
| 112 |
+
);
|
| 113 |
}
|
| 114 |
|
| 115 |
import { createAlert, fetchInbox } from "../alerts/alertsApi";
|
|
|
|
| 590 |
);
|
| 591 |
}
|
| 592 |
|
| 593 |
+
/**
|
| 594 |
+
* β WAVE 30 (R5) β THE OUTERMOST BOUNDARY, and the ONLY one that can cover the three
|
| 595 |
+
* EARLY RETURNS below (`FormPublic`, the boot card, `LoginPage`) plus this component's own
|
| 596 |
+
* body: its route resolution, its nav shaping and its derivations all run BEFORE any JSX
|
| 597 |
+
* exists to wrap. A throw in any of them used to take the document with it, and a person
|
| 598 |
+
* who cannot get past the login screen has no navigation left to escape with.
|
| 599 |
+
*
|
| 600 |
+
* β Split rather than nested inline for one reason: a boundary cannot catch a throw from
|
| 601 |
+
* its OWN render. `ErrorBoundary` has to be the parent of the component that fails, so the
|
| 602 |
+
* frame becomes a child and the default export becomes the wrapper.
|
| 603 |
+
*/
|
| 604 |
export default function Shell() {
|
| 605 |
+
return (
|
| 606 |
+
<ErrorBoundary surface="Loopable">
|
| 607 |
+
<ShellFrame />
|
| 608 |
+
</ErrorBoundary>
|
| 609 |
+
);
|
| 610 |
+
}
|
| 611 |
+
|
| 612 |
+
function ShellFrame() {
|
| 613 |
const [session, setSession] = useState<Session>({ phase: "checking" });
|
| 614 |
const [nav, setNav] = useState<Nav>({ phase: "idle" });
|
| 615 |
// Wave 18 (C3-UT): bumped after "+ New database" so the rail refetches and the new ut_ key
|
|
|
|
| 2078 |
) : null}
|
| 2079 |
|
| 2080 |
<main className="shell-main">
|
| 2081 |
+
{/* β WAVE 30 (R5) β THE CONTENT-AREA BOUNDARY, and the reason it is HERE and not around
|
| 2082 |
+
the whole shell: the `<aside>` rail is its SIBLING, so a surface that throws leaves the
|
| 2083 |
+
navigation mounted and the user can walk away from the broken page instead of reloading
|
| 2084 |
+
a blank document. That is the difference the ticket's done-when is naming.
|
| 2085 |
+
β `key={route}` is load-bearing. A boundary LATCHES β once it holds an error it renders
|
| 2086 |
+
the panel until it is remounted β so without the key, navigating away from a failing
|
| 2087 |
+
database would carry its failure card onto every page afterwards. */}
|
| 2088 |
+
<ErrorBoundary key={route} surface={active?.label ?? "This page"}>
|
| 2089 |
{/* β An honest failure, never a fallback to the bundled sample. Behind
|
| 2090 |
a login, plausible-looking sample revenue is fabricated data on a
|
| 2091 |
screen the user has every reason to trust. */}
|
|
|
|
| 2135 |
{...(active.icon ? { icon: active.icon } : {})}
|
| 2136 |
/>
|
| 2137 |
<div className="shell-auto-host">
|
| 2138 |
+
<Lazily surface="Automation"><AutomationSurface /></Lazily>
|
| 2139 |
</div>
|
| 2140 |
</div>
|
| 2141 |
) : active?.kind === "native" ? (
|
|
|
|
| 2178 |
// The one action the page cannot perform itself: a `manage: "keychain"` row opens
|
| 2179 |
// Settings on the Keychains tab, and the Settings MODAL is the frame's. Same division
|
| 2180 |
// as every other panel here β the page knows what it wants, the frame owns the door.
|
| 2181 |
+
<Lazily surface="Connectors"><ConnectorsPage onKeychain={() => setSettings("keychains")} /></Lazily>
|
| 2182 |
) : nav.phase === "ready" ? (
|
| 2183 |
// β WAVE 23 item 9 (R7, C10) β HOME, AND IT IS THE FALLBACK RATHER THAN A ROUTE MATCH.
|
| 2184 |
//
|
|
|
|
| 2198 |
// hero's create door, and its empty line carries the hero's sentence (HomePage's
|
| 2199 |
// `home-empty`, which branches on `entries.length === 0` for exactly this reason).
|
| 2200 |
// Booked as a dated amendment in the wave doc, since it moves an R4 surface.
|
| 2201 |
+
<Lazily surface="Home">
|
| 2202 |
<HomePage
|
| 2203 |
// β `dbEntries`, NOT `entries` β found by LOOKING at it (wave 24 close-out visual
|
| 2204 |
// pass), which is the only way it could have been found. The Automation SURFACE is a
|
|
|
|
| 2247 |
<span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
|
| 2248 |
</div>
|
| 2249 |
)}
|
| 2250 |
+
</ErrorBoundary>
|
| 2251 |
</main>
|
| 2252 |
|
| 2253 |
{/* The server's own confirmation of a write (X2's `toast`). In standalone
|
|
|
|
| 2346 |
// WAVE 23 C12 (wiring W23-W6) β the picker. It applies views to an EXISTING
|
| 2347 |
// database rather than making one, which is what a platform-curated template is:
|
| 2348 |
// a set of saved views, not a table (R10).
|
| 2349 |
+
<Lazily surface="Templates">
|
| 2350 |
<TemplatePicker
|
| 2351 |
entries={dbEntries}
|
| 2352 |
onToast={setToast}
|
|
|
|
| 2396 |
) : null}
|
| 2397 |
|
| 2398 |
{settings ? (
|
| 2399 |
+
<Lazily surface="Settings">
|
| 2400 |
<SettingsModal
|
| 2401 |
user={session.user}
|
| 2402 |
section={settings}
|
|
|
|
| 2415 |
|
| 2416 |
{/* C-ALERT (item 25): the inbox. */}
|
| 2417 |
{alertsOpen ? (
|
| 2418 |
+
<Lazily surface="Alerts">
|
| 2419 |
<AlertsPane
|
| 2420 |
onClose={() => setAlertsOpen(false)}
|
| 2421 |
onInbox={setInbox}
|
web/src/shell/errorBoundary.css
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* shell/errorBoundary.css β WAVE 30 item 2 (R5): the contained-failure card.
|
| 2 |
+
New classes only, co-located with the component that imports them, following
|
| 3 |
+
`shell/navExtras.css`'s precedent β the shell's own rail and placeholder classes
|
| 4 |
+
stay in index.css untouched (that file is another session's fence this wave).
|
| 5 |
+
|
| 6 |
+
β Font sizes are tokens, never literals: `verify_ui.py`'s R5 sweep reads EVERY
|
| 7 |
+
stylesheet under `src/`, not just index.css. */
|
| 8 |
+
|
| 9 |
+
/* The tone strip is the whole point of this rule. `.shell-placeholder` already
|
| 10 |
+
centres a heading, a sentence and a retry button β that is what the nav-failed
|
| 11 |
+
and data-error cards look like, and both of those mean "it did not load". This
|
| 12 |
+
one means "it broke", and a reader who cannot tell those apart reports the wrong
|
| 13 |
+
thing. The strip takes the `-deep` weight because it reads as a stroke
|
| 14 |
+
(DESIGN.md: strokes and text take `-deep`, flat surfaces take the pastel base). */
|
| 15 |
+
.shell-crash {
|
| 16 |
+
border-left: 3px solid var(--lp-red-deep);
|
| 17 |
+
background: var(--lp-red-tint);
|
| 18 |
+
border-radius: var(--lp-r-lg);
|
| 19 |
+
/* Contained, so a long message cannot push the sidebar off-screen β the failure
|
| 20 |
+
panel must never be the second way this surface breaks the app. */
|
| 21 |
+
max-width: 640px;
|
| 22 |
+
margin: 24px;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/* The thrown message, verbatim. Monospace because it is a machine's words, not
|
| 26 |
+
ours, and a reader pasting it back to us should be able to see where it ends. */
|
| 27 |
+
.shell-crash-detail {
|
| 28 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
| 29 |
+
font-size: var(--lp-fs-2xs);
|
| 30 |
+
color: var(--lp-red-deep);
|
| 31 |
+
overflow-wrap: anywhere;
|
| 32 |
+
}
|