| """routes_odoo_tables.py β the door to the Odoo relational spawn (wave 27 item 17, contract C8).
|
|
|
| Two endpoints and no cleverness: refresh the locked Odoo databases, and report their freshness.
|
| The work itself lives in `odoo_relational.py`; this file only decides WHO may ask and turns a
|
| refusal into a status code.
|
|
|
| β THE MOUNT IS DONE (`main.py` includes this router) and the wave-23 scar it was written against
|
| β three finished routers shipping 404-dead behind green gates β is covered by `verify_api`
|
| enumerating `main.app.routes`. β BUT WAVE 28 PROVED THAT CONTROL IS ONLY HALF OF THE QUESTION:
|
| being mounted is not being CALLABLE. This router was mounted, enumerated, green, and answered a
|
| plain-text 500 to every request for a day because of an attribute typo in the admin check
|
| (`session.is_admin`, which does not exist). A route-existence check cannot see that; only calling
|
| it can. `verify_api` now does both.
|
|
|
| ADMIN-GATED, and not for tidiness: a refresh REWRITES four locked databases for the whole tenant
|
| and deletes the rows that left the population. That is an operator action.
|
| β WAVE 30 (R6/R7, contract C2) ADDS A THIRD ENDPOINT AND IT IS A DIFFERENT KIND OF THING: the
|
| READ-THROUGH WINDOW. The two above operate on the materialised copy; `/{table_key}/rows` does not
|
| read the copy at all. See the block above `GRID_SOURCES` for why that had to change.
|
| """
|
| import json
|
|
|
| from fastapi import Depends
|
| from fastapi import APIRouter, Query
|
|
|
| from deps import Session, err, require_session
|
|
|
| router = APIRouter(prefix="/api/v1")
|
|
|
|
|
| def _rel():
|
| import odoo_relational
|
| return odoo_relational
|
|
|
|
|
| def _rt():
|
| from harness import runtime
|
| return runtime
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| def _mirror_cur(session):
|
| """A mirror cursor for THIS session's tenant, or a refusal that says which failure it is.
|
|
|
| Returns `(cursor, mismatch_sentence)` β exactly one of the two is None, so a caller cannot
|
| accidentally treat "refused" as "empty".
|
|
|
| β IT CATCHES ONLY THE MISMATCH AND LETS THE OTHER `RuntimeError` THROUGH, because the two mean
|
| opposite actions and every caller already has a policy for the second one:
|
|
|
| * `DatastoreMismatch` β this process has ANOTHER tenant's file open. It never fixes itself by
|
| waiting; the deployment is mis-pinned. Returned as a SENTENCE so each door can choose its
|
| own status (the row doors raise, the status door reports β R6's second sentence).
|
| * a plain `RuntimeError` β the mirror is completing its first sync, and retrying works. It
|
| PROPAGATES, so `odoo_table_rows` keeps answering `503 store_not_ready` and the status door
|
| keeps degrading to "no size half", exactly as each did before this guard existed.
|
|
|
| β Collapsing them was the tempting simplification and it is the defect: `DatastoreMismatch`
|
| SUBCLASSES `RuntimeError`, so a single `except RuntimeError` here would render every
|
| cross-tenant refusal as a retry banner. Catching the subclass first is a correctness rule.
|
| """
|
| rtm = _rt()
|
| try:
|
| return rtm.mirror_cursor(session.runtime), None
|
| except rtm.DatastoreMismatch as e:
|
| return None, str(e)
|
|
|
|
|
| @router.post("/odoo-tables/refresh")
|
| def refresh_odoo_tables(session: Session = Depends(require_session)):
|
| """Rebuild all four locked Odoo databases from the tenant's mirror β customers, products,
|
| invoices and orders (it was invoices + customers until 2026-08-09).
|
|
|
| Idempotent: row ids are Odoo ids, so a re-run updates in place. Returns what MOVED, because
|
| "refreshed" with no counts is indistinguishable from a no-op over an empty mirror.
|
| """
|
|
|
|
|
|
|
|
|
| rel = _rel()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if not session.admin:
|
| raise err(403, "forbidden", "Refreshing the Odoo databases is an admin action.")
|
| if not rel.is_royal(session.tenant):
|
|
|
|
|
| raise err(400, "not_applicable",
|
| "These databases are built from the Royal Imports Odoo mirror (R1).")
|
| try:
|
| out = rel.refresh(session.runtime, session.tenant, username=session.user)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| sync_read_through()
|
| moved = _ut().strip_materialised(st=session.runtime)
|
| return {"ok": True, **out, "unmaterialised": moved}
|
| except rel.Refused as e:
|
|
|
| raise err(409, "refused", str(e))
|
| except RuntimeError as e:
|
|
|
|
|
| raise err(503, "store_not_ready", str(e))
|
| except Exception as e:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| raise err(500, "refresh_failed", f"{type(e).__name__}: {e}")
|
|
|
|
|
| @router.get("/odoo-tables/status")
|
| def odoo_tables_status(session: Session = Depends(require_session)):
|
| """Row counts + the newest `refreshed` stamp per table.
|
|
|
| β The stamp is the whole point while the resync wiring is outstanding: a locked collections
|
| worklist that quietly stopped updating is a worklist that lies, so its age must be readable
|
| without anybody running a refresh to find out.
|
| """
|
| rel, out = _rel(), {}
|
| import core.user_tables as user_tables
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| cur, mirror_refused = None, None
|
| try:
|
| cur, mirror_refused = _mirror_cur(session)
|
| except Exception:
|
| pass
|
| eligible, why_not = sync_read_through(cur)
|
|
|
|
|
|
|
|
|
| for _bucket, key, _label, _fields in rel.TABLES:
|
| table = user_tables.get(key, st=session.runtime) or {}
|
| rows = (table.get("rows") or {})
|
| stamps = [str((r or {}).get("refreshed") or "") for r in rows.values()]
|
|
|
|
|
|
|
|
|
|
|
|
|
| materialised = user_tables.materialises(key, st=session.runtime)
|
| if not materialised:
|
| try:
|
| rows = {}
|
| stamps = []
|
| spec = _source_for(cur, key) if cur is not None else None
|
| if spec is not None:
|
| from harness import datastore as _ds
|
| frm = ({"from_sql": spec["from_sql"]} if spec.get("from_sql")
|
| else {"table": spec["table"]})
|
| n = _ds.window(select="1", where=spec.get("where") or "",
|
| order_by=spec["id"], limit=1, cur=cur, **frm)["total"]
|
| else:
|
| n = -1
|
| except Exception:
|
| n = -1
|
| out[key] = {
|
| "exists": bool(table),
|
| "rows": (len(rows) if materialised else n),
|
|
|
|
|
| "rowsFrom": ("document" if materialised
|
| else ("refused" if mirror_refused else "mirror")),
|
| "materialised": materialised,
|
|
|
| "materialisedBecause": (why_not.get(key, "") if materialised else ""),
|
| "refreshed": (max([s for s in stamps if s], default="") if materialised
|
| else "live β read through the mirror"),
|
| "locked": table.get("recordMode") == user_tables.AUTOMATION_RECORD_MODE,
|
|
|
|
|
|
|
| "readThrough": key in _sources(),
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
| pending = {k: {"readThrough": True, "declared": False,
|
| "cause": "this connected grid has a read-through binding but no field "
|
| "declaration in odoo_relational yet, so it cannot be opened",
|
| "recommendation": "declare its fields + a TABLES row (W30-T35); the binding "
|
| "and the window are already live"}
|
| for k in _sources() if k not in out}
|
| return {"ok": True, "applicable": rel.is_royal(session.tenant), "tables": out,
|
| "bound_not_declared": pending,
|
|
|
|
|
|
|
| "mirrorRefused": (
|
| {"cause": mirror_refused,
|
| "effect": "sizes_unavailable",
|
| "recommendation": "this deployment has another tenant's analytical store open; "
|
| "sizes are withheld rather than read from it. Pin the process "
|
| "to this tenant (AIOS_DUCKDB_PATH / datastore.use_path) to "
|
| "restore them."}
|
| if mirror_refused else None),
|
|
|
|
|
|
|
| "mirrorSeed": _seed_state()}
|
|
|
|
|
| def _seed_state():
|
| """`main.MIRROR_SEED` as a reportable block, or None when the mirror is present.
|
|
|
| β IMPORTED LAZILY AND FAIL-QUIET: `main` imports this router, so a module-level import here
|
| would be a cycle, and a freshness surface must never 500 because a diagnostic was unavailable.
|
| """
|
| try:
|
| import main as _main
|
| from harness import datastore as _ds
|
| if _ds.DB_PATH.exists():
|
| return None
|
| state = dict(_main.MIRROR_SEED)
|
| if not state.get("cause"):
|
| state["cause"] = "this container has no analytical mirror yet"
|
| state["recommendation"] = ("the boot seed runs independently of AIOS_PREWARM; if this "
|
| "persists, check HF_TOKEN on the deployment")
|
| return state
|
| except Exception:
|
| return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| GRID_SOURCES = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _SOURCE_PROVIDERS = []
|
|
|
|
|
| def register_source_provider(fn):
|
| """Add a `() -> {table_key: spec}` builder to the connected-grid registry.
|
|
|
| Additive and idempotent by identity, so a re-import cannot double-register. Returns the number
|
| of providers now known β a caller that wants to assert its registration took has a number.
|
| """
|
| if callable(fn) and fn not in _SOURCE_PROVIDERS:
|
| _SOURCE_PROVIDERS.append(fn)
|
| return len(_SOURCE_PROVIDERS)
|
|
|
|
|
| def _sources():
|
| """Build `GRID_SOURCES` lazily by asking EVERY registered provider β Odoo is one of them.
|
|
|
| ββ ODOO GOES THROUGH THE SEAM TOO, AND THAT IS THE POINT OF W31-T49 RATHER THAN A FLOURISH.
|
| The first version built Odoo's specs inline here and folded other providers in afterwards,
|
| which quietly says "Odoo is the registry and everyone else is an addendum" β two mechanisms
|
| for one question, with the second one exercised by nobody until a connector arrives. It also
|
| left `register_source_provider` with no production caller at all, which `verify_reachability`
|
| correctly reddened as a capability behind no door ([[artifact-with-no-importer]]). Registering
|
| the incumbent through its own seam makes the seam load-bearing from the first request.
|
|
|
| β STILL LAZY, for the original reason: `_odoo_sources` names `odoo_relational` constants, and
|
| importing that module at file-import time would drag the Odoo layer into every process that
|
| mounts a router. A provider is a CALLABLE precisely so it stays unimported until first read.
|
| """
|
| if GRID_SOURCES:
|
| return GRID_SOURCES
|
| for build in list(_SOURCE_PROVIDERS):
|
| try:
|
| extra = build() or {}
|
| except Exception:
|
|
|
|
|
| continue
|
| if extra:
|
| GRID_SOURCES.update(extra)
|
| _ut().register_connected(*extra)
|
| return GRID_SOURCES
|
|
|
|
|
| def _odoo_sources():
|
| """Odoo's read-through bindings β `{table_key: spec}`. Registered as a provider below.
|
|
|
| β IT RETURNS a dict rather than mutating the module global: `_sources` owns the merge, so a
|
| provider that half-built its specs and raised cannot leave a partial registry behind.
|
| """
|
| rel = _rel()
|
|
|
|
|
|
|
|
|
|
|
|
|
| _ut().register_connected(*[key for _b, key, _l, _f in rel.TABLES])
|
| GRID_SOURCES = {}
|
| _s, _i, _n = ((lambda v: str(v or "")), (lambda v: int(v or 0)),
|
| (lambda v: float(v or 0.0)))
|
| GRID_SOURCES[rel.ORDERS_KEY] = {
|
| "table": "sale_order",
|
|
|
|
|
| "where": f"{rel._CONFIRMED} AND partner_id IS NOT NULL",
|
| "id": "id",
|
| "needs_excluded": True,
|
| "cols": {
|
| "order_no": ("name", _s),
|
| "odoo_id": ("id", _i),
|
| "customer": ("partner_name", _s),
|
| rel.JOIN_KEY: ("partner_id", _i),
|
| "order_date": ("date_order", rel._as_date),
|
| "amount_untaxed": ("amount_untaxed", _n),
|
| "team": ("team_name", _s),
|
| "state": ("state", _s),
|
| "invoice_status": ("invoice_status", _s),
|
|
|
|
|
|
|
|
|
|
|
| "wholesale_scope": ("CASE WHEN partner_id IN ({excluded}) THEN '' ELSE '1' END", _s),
|
| },
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| ol_key = getattr(rel, "ORDER_LINES_KEY", "ut_odoo_order_lines")
|
| gl_key = getattr(rel, "GL_LINES_KEY", "ut_odoo_gl_lines")
|
| _ut().register_connected(ol_key, gl_key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| GRID_SOURCES[ol_key] = {
|
| "from_sql": "(sale_order_line sol JOIN sale_order so ON so.id = sol.order_id)",
|
| "tables": {"sol": "sale_order_line", "so": "sale_order"},
|
| "where": f"so.{rel._CONFIRMED}",
|
| "id": "sol.id",
|
| "needs_excluded": True,
|
| "cols": {
|
| "odoo_id": ("sol.id", _i),
|
| "order_no": ("so.name", _s),
|
| "order_id": ("sol.order_id", _i),
|
| "customer": ("sol.order_partner_name", _s),
|
| rel.JOIN_KEY: ("sol.order_partner_id", _i),
|
| "product": ("sol.product_name", _s),
|
| rel.PRODUCT_JOIN_KEY: ("sol.product_id", _i),
|
| "qty": ("sol.product_uom_qty", _n),
|
| "price_subtotal": ("sol.price_subtotal", _n),
|
| "margin": ("sol.margin", _n),
|
| "purchase_price": ("sol.purchase_price", _n),
|
| "order_date": ("so.date_order", rel._as_date),
|
| "state": ("so.state", _s),
|
| "wholesale_scope": (
|
| "CASE WHEN sol.order_partner_id IN ({excluded}) THEN '' ELSE '1' END", _s),
|
| },
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| GRID_SOURCES[gl_key] = {
|
| "from_sql": ("(account_move_line aml LEFT JOIN account_account aa "
|
| "ON aa.id = aml.account_id)"),
|
| "tables": {"aml": "account_move_line", "aa": "account_account"},
|
| "where": "",
|
| "id": "aml.id",
|
| "needs_excluded": True,
|
| "cols": {
|
| "odoo_id": ("aml.id", _i),
|
| "entry": ("aml.move_name", _s),
|
| "move_id": ("aml.move_id", _i),
|
| "account": ("aml.account_name", _s),
|
| rel.ACCOUNT_JOIN_KEY: ("aa.code", _s),
|
| "customer": ("aml.partner_name", _s),
|
| rel.JOIN_KEY: ("aml.partner_id", _i),
|
| "date": ("aml.date", rel._as_date),
|
| "debit": ("aml.debit", _n),
|
| "credit": ("aml.credit", _n),
|
| "balance": ("aml.balance", _n),
|
| "line_type": ("aml.display_type", _s),
|
| "move_type": ("aml.move_type", _s),
|
| "parent_state": ("aml.parent_state", _s),
|
| "wholesale_scope": (
|
| "CASE WHEN aml.partner_id IN ({excluded}) THEN '' ELSE '1' END", _s),
|
| },
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| GRID_SOURCES[rel.ACCOUNTS_KEY] = {
|
| "table": "account_account",
|
| "where": "",
|
| "id": "id",
|
| "cols": {
|
| rel.ACCOUNT_JOIN_KEY: ("code", _s),
|
| "account_name": ("name", _s),
|
| "odoo_id": ("id", _i),
|
| "account_type": ("account_type", _s),
|
| "is_expense": ("CASE WHEN account_type IN ('expense','expense_depreciation') "
|
| "THEN '1' ELSE '' END", _s),
|
| },
|
| }
|
| return GRID_SOURCES
|
|
|
|
|
|
|
|
|
|
|
| register_source_provider(_odoo_sources)
|
|
|
|
|
| def sync_read_through(cur=None):
|
| """Register every grid that may stop storing rows, and REPORT why the rest may not.
|
|
|
| Returns `({key: eligible}, {key: reason})`. Idempotent, cheap, and safe to call from any door:
|
| registration is additive and `strip_materialised` is a no-op once a table is empty.
|
|
|
| β IT IS ALSO THE ONLY PLACE THAT MAY CALL `register_read_through`, because the eligibility
|
| question needs BOTH halves β `odoo_relational`'s field declarations (for the fold matrix) and
|
| the mirror (for the size) β and `core` may import neither.
|
| """
|
| eligible, reasons = _unmaterialisable(cur)
|
| keys = [k for k, ok in eligible.items() if ok]
|
| if keys:
|
| _ut().register_read_through(*keys)
|
| return eligible, reasons
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| def _fold_reasons(rel):
|
| """`{table_key: "why its stored rows are still read by something else"}`.
|
|
|
| Read out of the FIELD DECLARATIONS themselves β one pass over `rel.TABLES`. A table absent
|
| from this map is folded by nothing.
|
| """
|
| reasons = {}
|
|
|
| def _add(key, why):
|
| reasons.setdefault(str(key), []).append(why)
|
|
|
| for _bucket, key, _label, mk in rel.TABLES:
|
| try:
|
| fields = mk()
|
| except Exception:
|
| continue
|
| by_key = {f.get("key"): f for f in fields if isinstance(f, dict)}
|
| short = str(key).replace("ut_odoo_", "")
|
| for f in fields:
|
| if not isinstance(f, dict):
|
| continue
|
| if f.get("type") == "link" and (f.get("link") or {}).get("table"):
|
| _add(f["link"]["table"], f"{short}.{f['key']} is a link whose cells are built "
|
| f"from these rows")
|
| if f.get("type") != "rollup":
|
| continue
|
| bag = f.get("rollup") or {}
|
| if isinstance(bag.get("source"), dict):
|
| _add(key, f"{short}.{f['key']} is a source rollup and its cells are written "
|
| f"into these rows")
|
| continue
|
| tgt = ((by_key.get(str(bag.get("link") or "")) or {}).get("link") or {}).get("table")
|
| if tgt:
|
| _add(tgt, f"{short}.{f['key']} folds these rows")
|
| return {k: "; ".join(v) for k, v in reasons.items()}
|
|
|
|
|
| def _unmaterialisable(cur=None):
|
| """`({key: eligible}, {key: reason})` β who may stop storing rows, and why the rest may not.
|
|
|
| β SIZE FORCES READ-THROUGH; IT NEVER BLOCKS IT β and getting that backwards was a real bug in
|
| the first cut of this function. A table too big for the tenant document has NO materialised
|
| option at all, so making it ineligible would have handed `odoo_relational.plan` a `row_limit`
|
| of None and invited it to build 963,783 python dicts. The window ceiling is a different, much
|
| softer thing: it only limits what the whole-table CLIENT door can serve today.
|
|
|
| rows > MAX_ROWS -> read-through REQUIRED (the document cannot hold it)
|
| else if something folds it -> stays materialised (a fold over no rows writes ZEROS)
|
| else if rows > WINDOW_MAX -> stays materialised until the client pages (W30-T42)
|
| else -> eligible
|
|
|
| β Without a mirror cursor the size questions cannot be asked, so only a grid that was never
|
| part of the materialised spawn is eligible β a table is never freed by a question we skipped.
|
| """
|
| rel = _rel()
|
| folds, eligible, reasons = _fold_reasons(rel), {}, {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| spawned = {key for _b, key, _l, _f in rel.TABLES if _b in getattr(rel, "_READERS", {})}
|
| from harness import datastore
|
| ut = _ut()
|
| for key, spec in _sources().items():
|
| total = None
|
| if cur is not None:
|
| try:
|
| frm = ({"from_sql": spec["from_sql"]} if spec.get("from_sql")
|
| else {"table": spec["table"]})
|
| total = datastore.window(select="1", where=spec.get("where") or "",
|
| order_by=spec["id"], limit=1, cur=cur, **frm)["total"]
|
| except Exception:
|
| total = None
|
| if total is not None and total > ut.MAX_ROWS:
|
| eligible[key] = True
|
| continue
|
| if total is None and key not in spawned:
|
| eligible[key] = True
|
| continue
|
| why = []
|
| if total is None:
|
| why.append("its size could not be read from the mirror on this deployment")
|
| if key in folds:
|
| why.append(folds[key])
|
| if total is not None and total > datastore.WINDOW_MAX:
|
| why.append(f"its {total:,} rows exceed the {datastore.WINDOW_MAX:,}-row window and "
|
| f"the grid client still asks for whole tables, so un-materialising it "
|
| f"today could only truncate")
|
| eligible[key] = not why
|
| if why:
|
| reasons[key] = "; ".join(why)
|
| return eligible, reasons
|
|
|
|
|
| def _degrade(expr, have_by_alias, default_alias=""):
|
| bare = expr.strip()
|
| alias, _, col = bare.partition(".")
|
| if col and alias.replace("_", "").isalnum() and col.replace("_", "").isalnum():
|
| have = have_by_alias.get(alias)
|
| if have is None:
|
| return expr
|
| return expr if col.lower() in have else "NULL"
|
| if not bare.replace("_", "").isalnum():
|
| return expr
|
|
|
|
|
|
|
|
|
| have = have_by_alias.get(default_alias)
|
| if have is None:
|
| have = set().union(*have_by_alias.values()) if have_by_alias else set()
|
| return expr if bare.lower() in have else "NULL"
|
|
|
|
|
| def _source_for(cur, table_key):
|
| """The resolved spec for one connected grid, or None when this grid is not read-through yet."""
|
| spec = _sources().get(str(table_key or ""))
|
| if not spec:
|
| return None
|
| from harness import datastore
|
|
|
|
|
| aliases = dict(spec.get("tables") or {})
|
| if spec.get("table"):
|
| aliases.setdefault("", spec["table"])
|
| have_by_alias = {}
|
| for alias, tname in aliases.items():
|
| cols = datastore.columns_of(tname, cur=cur)
|
| if not cols:
|
| return None
|
| have_by_alias[alias] = cols
|
| excluded = ""
|
| if spec.get("needs_excluded"):
|
| ids = sorted(int(p) for p in _rel().excluded_ids(cur))
|
|
|
|
|
| excluded = ", ".join(str(i) for i in ids) or "-1"
|
| cols = {}
|
| for key, (expr, cast) in spec["cols"].items():
|
| cols[key] = (_degrade(expr.format(excluded=excluded) if "{excluded}" in expr else expr,
|
| have_by_alias), cast)
|
| return {**spec, "cols": cols}
|
|
|
|
|
|
|
|
|
|
|
|
|
| _DEEP_PAGE = 100_000
|
|
|
|
|
|
|
|
|
|
|
| _FILTER_TYPE = {"select": "status", "checkbox": "text"}
|
|
|
|
|
| def _filter_columns(spec, fields):
|
| """`{colId: {sql, type, aggregate}}` β what `filter_sql` needs to compile a predicate.
|
|
|
| Only columns with a real mirror expression are offered. An omitted column is UNKNOWN to the
|
| compiler, which skips it β and that is the one behaviour that must be REPORTED rather than
|
| accepted, because an ignored condition WIDENS (the tri-state engine's inactive-leaf rule).
|
| `_unpushable` below turns every such skip into an R6 sentence.
|
| """
|
| by_key = {f["key"]: f for f in fields}
|
| out = {}
|
| for key, (expr, _cast) in spec["cols"].items():
|
| ftype = str((by_key.get(key) or {}).get("type") or "text")
|
| out[key] = {"sql": expr, "type": _FILTER_TYPE.get(ftype, ftype), "aggregate": False}
|
| return out
|
|
|
|
|
| def _leaf_cols(nodes):
|
| """Every `colId` a filter tree names, at any depth."""
|
| seen = set()
|
| for n in (nodes or []):
|
| if not isinstance(n, dict):
|
| continue
|
| if n.get("children") is not None:
|
| seen |= _leaf_cols(n.get("children"))
|
| elif n.get("colId"):
|
| seen.add(str(n["colId"]))
|
| rhs = n.get("rhs")
|
| if isinstance(rhs, dict) and rhs.get("colId"):
|
| seen.add(str(rhs["colId"]))
|
| return seen
|
|
|
|
|
| def _json_arg(raw, what):
|
| """Decode a JSON query argument, or refuse. β NEVER degrade to "no filter": a filter that
|
| silently fails to parse WIDENS the answer, and the caller sees a plausible bigger number."""
|
| if raw in (None, ""):
|
| return None
|
| try:
|
| val = json.loads(raw)
|
| except Exception:
|
| raise err(400, "bad_argument", f"{what} must be JSON")
|
| return val
|
|
|
|
|
| @router.get("/odoo-tables/{table_key}/rows")
|
| def odoo_table_rows(table_key: str,
|
| offset: int = Query(default=0, ge=0),
|
| limit: int = Query(default=0),
|
| filters: str = Query(default=None),
|
| filterConj: str = Query(default="and"),
|
| sorts: str = Query(default=None),
|
| search: str = Query(default=None),
|
| session: Session = Depends(require_session)):
|
| """ONE WINDOW over a connected grid, read straight from the mirror (contract C2).
|
|
|
| `{fields, rows, total, totalUnfiltered, offset, limit, limits, identity, recordsMutable}` β
|
| `rows` is the requested slice and `total` is the count of everything the CURRENT PREDICATE
|
| matches, from its own `SELECT count(*)`. `rows.length < total` is the normal case.
|
|
|
| β `total` is never `len(rows)`; `totalUnfiltered` is the population with the predicate
|
| dropped, so a client can render "N of M" without inventing either number.
|
| β The filter, the sort and the search all resolve in SQL against the whole table. Anything
|
| that CANNOT (a column with no mirror expression, an op the compiler refuses) is reported in
|
| `limits` with its cause and a recommendation β R6's second sentence β and never silently
|
| dropped, because an ignored condition widens.
|
| """
|
| import aios_grid
|
| from harness import datastore
|
| from routes_tables import _defn_or_refuse
|
|
|
| rel = _rel()
|
|
|
|
|
|
|
| defn = _defn_or_refuse(session, table_key)
|
| fields = None
|
| for _bucket, key, _label, mk_fields in rel.TABLES:
|
| if key == table_key:
|
| fields = mk_fields()
|
| break
|
| if fields is None:
|
| raise err(404, "not_connected", "that database is not a connected Odoo grid")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| try:
|
| cur, mismatch = _mirror_cur(session)
|
| except RuntimeError as e:
|
|
|
|
|
| raise err(503, "store_not_ready", str(e))
|
| if mismatch:
|
| raise err(409, "cross_tenant_store", mismatch)
|
|
|
| spec = _source_for(cur, table_key)
|
| if spec is None:
|
| raise err(409, "not_read_through",
|
| "this connected database is still served from its stored copy; it has no "
|
| "read-through binding on this deployment yet")
|
|
|
| cols = _filter_columns(spec, fields)
|
| limits, tree = [], _json_arg(filters, "filters")
|
| sort_spec = _json_arg(sorts, "sorts") or []
|
|
|
|
|
| named = _leaf_cols(tree)
|
| missing = sorted(named - set(cols))
|
| if missing:
|
| limits.append({
|
| "subject": ", ".join(missing), "effect": "filter_ignored",
|
| "cause": "these columns have no expression in the mirror (a link, a rollup, or a "
|
| "column this deployment's mirror has not backfilled), so a condition on "
|
| "them cannot be answered in SQL",
|
| "recommendation": "filter on the id column the link is built from, or open the "
|
| "linked database directly"})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| numeric = sorted(k for k in (named & set(cols))
|
| if cols[k]["type"] in ("currency", "int", "pct"))
|
| if numeric:
|
| limits.append({
|
| "subject": ", ".join(numeric), "effect": "precision",
|
| "cause": "a number condition is evaluated in SQL at whole-unit precision "
|
| "(`filter_sql` rounds to match the grids whose values the server rounds), "
|
| "while these cells carry their exact value β so a row within half a unit of "
|
| "the threshold can fall on the other side of it",
|
| "recommendation": "compare against a whole number, or use a range that does not sit "
|
| "on a fractional boundary"})
|
|
|
| where, params = spec["where"], []
|
| try:
|
| pred = _fs().compile_filter_tree(
|
| tree, conj=(filterConj if filterConj in ("and", "or") else "and"), columns=cols,
|
| today=_today())
|
| except ValueError as e:
|
|
|
|
|
| raise err(400, "filter_unsupported", str(e))
|
| if pred is not None:
|
| if pred.uses_aggregate:
|
| raise err(400, "filter_unsupported",
|
| "a condition on an aggregate column belongs in HAVING, and that path is "
|
| "deliberately not built for windowed grids")
|
| where = f"({where}) AND {pred.sql}" if where else pred.sql
|
| params.extend(pred.params)
|
| if str(search or "").strip():
|
| got = _fs().compile_search(search.strip(), cols)
|
| if got is not None:
|
| where = f"({where}) AND {got.sql}" if where else got.sql
|
| params.extend(got.params)
|
|
|
|
|
|
|
|
|
|
|
| order = _fs().compile_order_by(sort_spec, cols, tiebreak_sql=spec["id"]) or spec["id"]
|
|
|
| select = ", ".join(f'{expr} AS "{key}"' for key, (expr, _c) in spec["cols"].items())
|
|
|
|
|
| frm = {"from_sql": spec["from_sql"]} if spec.get("from_sql") else {"table": spec["table"]}
|
| win = datastore.window(select=f'{spec["id"]} AS "_pid", {select}',
|
| where=where, params=tuple(params), order_by=order,
|
| offset=offset, limit=(limit or None), cur=cur, **frm)
|
| if win["clamped"]:
|
| limits.append({
|
| "subject": "limit", "effect": "window_clamped",
|
| "cause": f"one response carries at most {datastore.WINDOW_MAX} rows so a single "
|
| f"request cannot exhaust memory for every other tenant on the process",
|
| "recommendation": "page with `offset`; `total` already reports the whole population, "
|
| "and no row is unreachable"})
|
|
|
|
|
|
|
|
|
|
|
|
|
| if offset >= _DEEP_PAGE:
|
| limits.append({
|
| "subject": "offset", "effect": "slow",
|
| "cause": f"a page {offset:,} rows deep is reached by walking every row before it "
|
| f"(SQL OFFSET has no other meaning), which costs about a second past "
|
| f"half a million rows",
|
| "recommendation": "jump with a filter or a sort instead of scrolling, or ask for "
|
| "keyset paging (`id > <last row>`), which is O(page) at any depth "
|
| "and needs the client to send the last row it holds"})
|
|
|
|
|
|
|
|
|
| rows_src = _pool_from_window(spec, win,
|
| stamp=mirror_stamp(spec, cur, table_key,
|
| session.runtime))
|
| overlays = {str(r["pid"]): {k: v for k, v in r.items() if k != "pid"} for r in rows_src}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| shared_defs = _so().fields(table_key, st=session.runtime)
|
| if shared_defs:
|
| fields = list(fields) + [dict(f, source="overlay") for f in shared_defs.values()]
|
| for pid, cells in _so().cells(table_key, [r["pid"] for r in rows_src],
|
| st=session.runtime).items():
|
| overlays.setdefault(pid, {}).update(cells)
|
| rows = aios_grid.rows_from_pool(rows_src, fields, overlays)
|
|
|
| unfiltered = win["total"] if where == spec["where"] else datastore.window(
|
| select="1", where=spec["where"], order_by=spec["id"], limit=1, cur=cur, **frm)["total"]
|
|
|
| return {"ok": True, "fields": fields, "rows": rows,
|
|
|
| "total": win["total"], "totalUnfiltered": unfiltered,
|
| "offset": win["offset"], "limit": win["limit"], "limits": limits,
|
| "today": _today(), "identity": {"pid": "pid"},
|
| "scope": {"table": table_key, "readThrough": True},
|
| "recordsMutable": bool(defn.get("recordMode") != _ut().AUTOMATION_RECORD_MODE)}
|
|
|
|
|
| def mirror_stamp(spec, cur=None, table_key="", rt=None):
|
| """β D-155 β WHEN THIS READ-THROUGH GRID'S DATA WAS LAST RECONCILED AGAINST ODOO, as a date.
|
|
|
| β THE COLUMN EXISTED AND THE BINDING DID NOT, which is the whole of D-155. Every Odoo table
|
| declares `refreshed` (`odoo_relational._refreshed_field`), and a MATERIALISED grid gets a
|
| per-row stamp written by the spawn. A read-through grid has no stored row to carry one, so
|
| `_pool_from_window` produced no `refreshed` key at all and `rows_from_pool` projected it as
|
| `""` β a freshness column that used to say something and now silently says nothing, on the two
|
| biggest grids in the product. Nothing goes red for that; it just quietly stops being true.
|
|
|
| β THE HONEST VALUE ALREADY EXISTS β the mirror keeps a per-entity sync stamp in `_sync_state`,
|
| which is the same fact one level up: these rows are as fresh as the last sync of the table
|
| they are read through. It is per-TABLE, not per-row, and that is not a downgrade β for a grid
|
| that stores no rows, "when did this table last sync" IS the per-row answer.
|
|
|
| β READ ON THE CALLER'S CURSOR, never `datastore.status()`. That opens a SECOND connection to a
|
| file DuckDB holds exclusively, so a freshness column would turn a working grid into an
|
| IOException β a cosmetic fix taking out the feature it decorates.
|
| β Date only (`YYYY-MM-DD`), because `refreshed` is declared `type: "date"` and the grid's date
|
| renderer is what reads it; handing it a full timestamp renders the ISO string a user should
|
| never see (the wave-26 `copyData` scar, one column over).
|
|
|
| ββ THE TABLE COMES FROM THE **ID COLUMN'S ALIAS**, AND THE FIRST DRAFT GOT THIS WRONG IN THE
|
| ONLY WAY THAT MATTERS. It read `spec["table"]` with a `tables[""]` fallback β and **neither of
|
| the two grids D-155 is about has a bare `table` key**: `ut_odoo_order_lines` and
|
| `ut_odoo_gl_lines` are join-shaped (`from_sql` + `tables: {"sol": β¦, "so": β¦}` /
|
| `{"aml": β¦, "aa": β¦}`), so the lookup returned `""` and the fix would have shipped doing
|
| nothing on exactly the two grids it was written for β green gate, unchanged product. It was
|
| caught only because the gate's fixture used `ut_odoo_orders`, a MATERIALISED grid that never
|
| had the defect [[gate-answers-the-wrong-question]].
|
| β The id column IS the grain: `sol.id` means this grid is one row per `sale_order_line`, and
|
| the freshness of a joined lookup table (`sale_order`, `account_account`) is not this grid's
|
| freshness. So the alias is read off `spec["id"]`, and a bare `id` degrades to `table` β which
|
| is exactly the single-table case.
|
|
|
| β AND IT ANSWERS `""` FOR A **MATERIALISED** GRID, ON PURPOSE. Those eight tables store a
|
| per-ROW `refreshed` written by the spawn, which is strictly better than one table-level date β
|
| and this value arrives as an OVERLAY, so returning a stamp here would quietly overwrite eight
|
| working grids' per-row stamps while fixing two blank ones. D-155's subject is the grid that has
|
| NOWHERE to keep a row; a fix that also rewrites the grids that do is a different, unrequested
|
| change [[reuse-and-delete-are-hypotheses]].
|
| """
|
|
|
|
|
|
|
| if table_key:
|
| try:
|
| if _ut().materialises(str(table_key), st=rt):
|
| return ""
|
| except Exception:
|
| return ""
|
| ident = str(spec.get("id") or "")
|
| alias = ident.split(".", 1)[0] if "." in ident else ""
|
| table = str((spec.get("tables") or {}).get(alias) or spec.get("table")
|
| or (spec.get("tables") or {}).get("") or "")
|
| if not table or cur is None:
|
| return ""
|
| try:
|
| row = cur.execute("SELECT updated_at FROM _sync_state WHERE entity = ?",
|
| [table]).fetchone()
|
| except Exception:
|
| return ""
|
| return str((row or [""])[0] or "")[:10]
|
|
|
|
|
| def _pool_from_window(spec, win, stamp=""):
|
| """`[{pid, **cells}]` β THE one place a mirror window becomes product rows.
|
|
|
| β ONE BUILDER, TWO CALLERS, and that is deliberate rather than tidy: the windowed route and
|
| the whole-table read-through below would otherwise each cast the same columns their own way,
|
| and a cell that renders differently depending on which door served it is this repo's recorded
|
| defect class ([[one-question-two-normalizers]]).
|
|
|
| β `stamp` (D-155) rides here for that same reason: both doors must produce the same
|
| `refreshed` cell, and a caller that forgot it would give one door a freshness column and the
|
| other a blank one. Empty when the caller cannot cheaply know β blank is what shipped, so the
|
| degradation is the previous behaviour rather than a new wrong value.
|
| """
|
| keys = list(spec["cols"])
|
| extra = {"refreshed": stamp} if stamp else {}
|
| return [{"pid": int(r[0]), **extra,
|
| **{k: spec["cols"][k][1](v) for k, v in zip(keys, r[1:])}} for r in win["rows"]]
|
|
|
|
|
| class TooBigToMaterialise(Exception):
|
| """A read-through table asked for WHOLE exceeds one window β refuse, never truncate."""
|
|
|
|
|
| def population(table_key, cur=None, rt=None):
|
| """How many rows a read-through grid HAS, without fetching one β or None if it is not bound.
|
|
|
| ββ W31 / B's ask (mailbox/B.md B-2). `routes_tables.scoped_pids` learned "this grid is too big
|
| to list" the only way that existed: call `whole_pool()` and catch `TooBigToMaterialise`. That
|
| pulls a full 5,000-row window out of DuckDB and throws it away on every `/workspace` for
|
| `ut_odoo_gl_lines` and `ut_odoo_order_lines` β MEASURED by B at ~1,600 ms of a ~3,460 ms
|
| in-proc envelope. This asks the SIZE instead: one `SELECT count(*)` over the same predicate.
|
|
|
| β IT ANSWERS A DIFFERENT QUESTION, NOT THE SAME ONE MORE CHEAPLY, and that distinction is what
|
| keeps it safe. B's constraint is that the pid set must stay IDENTICAL to `scoped_pool`'s BY
|
| CONSTRUCTION rather than by two queries that agree today ([[one-question-two-normalizers]]) β
|
| so this returns a COUNT and never a pid. A caller uses it to decide whether to ask for pids at
|
| all; when it does ask, the pids still come from the one `whole_pool` fetch they always did.
|
|
|
| β `None` means "no read-through binding on this deployment", which is NOT `0`. A caller that
|
| collapses them reports an empty grid where it should report an unbound one β the exact
|
| distinction `odoo_tables_status` already reports as `bound_not_declared`.
|
| β `total` is the count over the spec's OWN `where`, i.e. the same population `whole_pool`
|
| would return β the fixed scope is not dropped for being cheaper.
|
| """
|
| from harness import datastore
|
| if rt is not None:
|
| rt.assert_datastore_matches()
|
| cur = cur if cur is not None else datastore.ro_con()
|
| spec = _source_for(cur, table_key)
|
| if spec is None:
|
| return None
|
| frm = {"from_sql": spec["from_sql"]} if spec.get("from_sql") else {"table": spec["table"]}
|
| return datastore.window(select="1", where=spec.get("where") or "", order_by=spec["id"],
|
| limit=1, cur=cur, **frm)["total"]
|
|
|
|
|
| def whole_pool(table_key, cur=None, rt=None):
|
| """Every row of a read-through grid, in `scoped_pool`'s shape β or a refusal.
|
|
|
| β THIS EXISTS FOR THE CLIENT WE HAVE, NOT THE ONE WE WANT. The grid still fetches whole
|
| tables (`GET /tables/{key}/rows`); paging is F's W30-T42. So a database whose rows have left
|
| this tenant's document has to be servable to that client somehow, and the honest answer for a
|
| small one is "read all of it from the mirror". `_unmaterialisable` only ever registers a table
|
| that fits, so the refusal below is a guard against the population GROWING past the window
|
| later β at which point R6 requires a sentence, not a quietly shorter grid.
|
|
|
| ββ W31-T45 / D-169 β `rt` IS THE TENANT RUNTIME, AND IT IS OPTIONAL FOR A STATED REASON
|
| RATHER THAN A LAZY ONE. This function serves ROWS, so it is the door where a cross-tenant read
|
| would be worst β but its only production caller is `routes_tables.py::_read_through_rows`,
|
| which is in another lane's fence THIS WAVE (B's W31-T20 is rewriting that exact path) and does
|
| not thread a session down to here. Making `rt` required would break that caller on import; a
|
| second predicate invented locally would be one question with two normalizers, a recorded
|
| defect class here. So the guard fires when a caller passes the runtime it already holds, and
|
| the ask to thread `session.runtime` through `_read_through_rows` is posted to B in
|
| `mailbox/D.md`. β Until that lands this door is guarded only by `_defn_or_refuse` upstream β
|
| stated here rather than left for a reader to discover, because an unguarded row door is
|
| precisely what D-169 is about.
|
| """
|
| from harness import datastore
|
| if rt is not None:
|
| cur = cur if cur is not None else _rt().mirror_cursor(rt)
|
| rt.assert_datastore_matches()
|
| cur = cur if cur is not None else datastore.ro_con()
|
| spec = _source_for(cur, table_key)
|
| if spec is None:
|
| raise TooBigToMaterialise(
|
| f"{table_key}: this database is served through the mirror but has no read-through "
|
| f"binding on this deployment, so its rows cannot be read at all")
|
| frm = {"from_sql": spec["from_sql"]} if spec.get("from_sql") else {"table": spec["table"]}
|
| select = ", ".join(f'{expr} AS "{key}"' for key, (expr, _c) in spec["cols"].items())
|
| win = datastore.window(select=f'{spec["id"]} AS "_pid", {select}', where=spec.get("where"),
|
| order_by=spec["id"], limit=datastore.WINDOW_MAX, cur=cur, **frm)
|
| if win["total"] > len(win["rows"]):
|
|
|
|
|
| raise TooBigToMaterialise(
|
| f"{table_key}: {win['total']:,} rows is more than one {datastore.WINDOW_MAX:,}-row "
|
| f"window, and this database no longer stores rows in the tenant document; it can "
|
| f"only be read a page at a time (`/odoo-tables/{table_key}/rows`)")
|
| return _pool_from_window(spec, win, stamp=mirror_stamp(spec, cur, table_key, rt))
|
|
|
|
|
| def _fs():
|
| from harness import filter_sql
|
| return filter_sql
|
|
|
|
|
| def _so():
|
| import core.shared_overlay as shared_overlay
|
| return shared_overlay
|
|
|
|
|
| def _ut():
|
| import core.user_tables as user_tables
|
| return user_tables
|
|
|
|
|
| def _today():
|
| import time
|
| return time.strftime("%Y-%m-%d")
|
|
|