| """routes_changes.py β THE CHANGE TOKEN (wave 29, item 20 / ruling R11, contract C6). |
| |
| GET /api/v1/changes?scope=<scope> |
| -> {"scope": "ut_leads", |
| "tokens": {"rows": "b3f1c9:14", "overlay": "b3f1c9:3"}, |
| "backend": "hf"} |
| |
| The owner's report: *a record created in another tab, by an automation, or by a connector sync |
| does not appear in a filtered view until you reload.* The client had no way to ask whether |
| anything had changed except to re-download everything, so it never asked. |
| |
| β WHY THIS ROUTE EXISTS INSTEAD OF THE OBVIOUS "JUST POLL THE ROWS ENDPOINT", and the arithmetic |
| is the whole argument. `GET /tables/{key}/rows` costs THREE full-tenant deep copies under ONE |
| lock, on ONE uvicorn process (the container runs no `--workers`), against a documented ceiling of |
| 35.8 MB / ~1.4 s per bucket β roughly 4.2 s of lock-held CPU per poll, so TWO TABS SATURATE THE |
| SERVER and every unrelated write queues behind them. This handler performs ZERO `store.get()` |
| calls: `revision_of` below reads an in-memory counter that `Store.put`/`Store.update` bump at the |
| moment a write is accepted. `verify_live_workspace.py`'s server half counts |
| `get`/`_read_strict`/`_download` on this path and goes RED at one β because a token endpoint that |
| deep-copies pays the identical 1.4 s and wins nothing. |
| |
| β IT REACHES ALL THREE WRITERS THE OWNER NAMED, and that is a property of the QUESTION, not of |
| the plumbing: it asks *"did this bucket change"*, never *"did somebody emit an event"*. The event |
| seam (`user_tables.ROW_HOOKS`) fires only from HUMAN write doors by construction β the automation |
| engine's own writer deliberately stays silent, and `verify_automation.py` has a negative control |
| enforcing that silence. A push channel could therefore only ever carry 1 of the 3 writers. A |
| counter on the store sees the automation and the connector sync for free, and the loop-prevention |
| law is untouched. |
| |
| WHAT WALLS THIS: |
| |
| * The TENANT is the wall, and it is structural. Every bucket name is namespaced through the |
| session's OWN runtime (`TenantRuntime.store_key`), so a scope naming another tenant's table |
| resolves to a bucket this tenant does not have and reads 0. There is no cross-tenant answer |
| to give. **Measured, wave-29 QA:** a real `nurilab-admin` session against a `royal-imports` |
| table gets no data here. |
| * β β¦AND, SINCE W29-T82, A PER-TABLE WALL INSIDE THE TENANT. The paragraph that used to sit |
| here argued this route needed none, on the ground that `user_tables.STORE_KEY` is ONE bucket |
| for every `ut_*` table so "there is no per-table fact to withhold". **Half of that is false |
| and the QA pass measured it.** The `rows` token is indeed shared β but `overlay` and `shared` |
| are PER-TABLE buckets (`<scope>_table_workspace`), so a non-zero token against `:0` told a |
| session whether a table it may not open EXISTS and is being edited. That is an |
| existence/activity oracle, and every sibling door (`GET rows`, `POST rows`, `PATCH rows/{pid}`, |
| `PATCH fields/{k}`, `POST rows/import`) answers **404 `unknown_table`** rather than leak it. |
| One door out of six holding a different line is the door somebody probes. |
| * β AND THE COST ARGUMENT WAS RIGHT, so the wall is CACHED rather than paid per poll. |
| `user_tables.may_open` reads the whole `user_tables` bucket through `store.get`, which is a |
| JSON round-trip deep copy under the store lock β the exact 1.4 s this route exists not to |
| spend, and it would be spent by every tab on every interval. `_may_watch` below memoises the |
| verdict per (tenant, user, scope) for `_WALL_TTL` seconds. The common case costs nothing: a |
| tab only polls a scope it already has OPEN, which already passed the same wall on the |
| workspace fetch. A prober pays one read per scope per TTL and gets 404s. |
| |
| So: a valid session, a scope sanitised to a bucket-safe shape, and β for a `ut_*` table β a |
| cached verdict that this session may open it. |
| """ |
| import time |
| from fastapi import APIRouter, Depends |
|
|
| import core.shared_overlay as shared_overlay |
| import core.store as store |
| import core.user_tables as user_tables |
| import modules.customer_data as customer_data |
| import modules.product_data as product_data |
|
|
| from deps import Session, err, require_session |
|
|
| router = APIRouter(prefix="/api/v1") |
|
|
| |
| |
| |
| |
| _SCOPE_MAX = 64 |
|
|
|
|
| def revision_of(handle, name): |
| """`{'rev','updated_at','token'}` for bucket `name` as THIS caller addresses it β or None. |
| |
| β ONE resolver, and the gate calls the HANDLER rather than reproducing this dance, so the |
| route and its control cannot disagree about which physical key was read. A control that |
| re-derives the address is how a gate ends up certifying a key nobody serves. |
| |
| `handle` is whatever the caller holds β a tenant runtime, a `Store`, or `core.store` itself. |
| The runtime is recognised DUCK-TYPED (`store_key` + `store_handle`) rather than imported: |
| `store_key()` is the ONE place a tenant's namespace prefix is applied, and applying it here a |
| second time is how one tenant reads another's counter. |
| |
| β None β never a fabricated token β when the active backend publishes no revision. `PgStore` |
| maintains `rev` in `store_kv` and does not yet expose it, so under `STORE_BACKEND=pg` this |
| answers None, the client stops polling, and the app behaves exactly as it does today. A token |
| frozen at 0 would instead promise liveness the backend is not delivering. |
| """ |
| target, key = handle, str(name) |
| namespaced = getattr(handle, "store_key", None) |
| if callable(namespaced): |
| key = namespaced(name) |
| bound = getattr(handle, "store_handle", None) |
| target = bound if bound is not None else store._d() |
| fn = getattr(target, "revision", None) |
| if not callable(fn): |
| return None |
| try: |
| return fn(key) |
| except Exception: |
| |
| |
| return None |
|
|
|
|
| def _clean_scope(raw): |
| s = str(raw or "").strip().lower() |
| if not s or len(s) > _SCOPE_MAX or any(c not in "abcdefghijklmnopqrstuvwxyz0123456789_" |
| for c in s): |
| return "" |
| return s |
|
|
|
|
| def buckets_for(scope): |
| """`{label: store bucket}` for one topic scope β the map the CLIENT never gets to write. |
| |
| β Every value is imported from the module that WRITES it, never re-spelled here. |
| `user_tables.STORE_KEY`, `customer_data.TABLE_KEY` and `product_data.TABLE_KEY` are the |
| literal keys those modules read and update, so a rename moves this map with it. A hand-copied |
| `'customer_table_workspace'` would keep answering 200 with a token for a bucket nobody writes |
| β a poll that costs nothing, changes never, and looks exactly like "there were no changes". |
| |
| An absent label means "no store residency": `customer` and `product` rows come from Odoo |
| through a 15-minute server cache, which R11 deliberately leaves alone. The client reads a |
| missing/null token as "do not poll this", never as "unchanged". |
| """ |
| s = _clean_scope(scope) |
| if not s: |
| return None |
| |
| |
| if s.startswith(user_tables.KEY_PREFIX): |
| workspace = f"{s}_table_workspace" |
| elif s == "customer": |
| workspace = customer_data.TABLE_KEY |
| elif s == "product": |
| workspace = product_data.TABLE_KEY |
| else: |
| return {} |
| names = {"overlay": workspace, "shared": shared_overlay.bucket(workspace)} |
| if s.startswith(user_tables.KEY_PREFIX): |
| names["rows"] = user_tables.STORE_KEY |
| return names |
|
|
|
|
| |
| |
| |
| |
| |
| _WALL_TTL = 30.0 |
| |
| |
| |
| _WALL_CACHE = {} |
| _WALL_MAX = 512 |
|
|
|
|
| def _may_watch(session, scope): |
| """May this session watch this scope? Cached, because the honest check is expensive. |
| |
| β FAIL-CLOSED THROUGH THE ONE RESOLVER. `user_tables.may_open` is where "who may open this |
| database" is decided (creator Β· admin Β· an explicit share grant); asking a second way here is |
| how two doors end up with two answers ([[one-evaluator-per-question]]). Non-`ut_*` scopes |
| (`customer`, `product`) are module-gated surfaces whose own doors decide access and whose |
| buckets are tenant-wide, so there is no per-table fact to withhold for them. |
| """ |
| if not scope.startswith(user_tables.KEY_PREFIX): |
| return True |
| |
| |
| |
| |
| |
| key = (str(getattr(session.runtime, "store_namespace", "")), session.uname, scope) |
| now = time.monotonic() |
| hit = _WALL_CACHE.get(key) |
| if hit and hit[0] > now: |
| return hit[1] |
| verdict = bool(user_tables.may_open(scope, session.uname, session.admin, st=session.runtime)) |
| |
| |
| |
| |
| |
| |
| |
| if verdict: |
| if len(_WALL_CACHE) >= _WALL_MAX: |
| _WALL_CACHE.clear() |
| _WALL_CACHE[key] = (now + _WALL_TTL, True) |
| return verdict |
|
|
|
|
| @router.get("/changes") |
| def changes(scope: str = "", session: Session = Depends(require_session)): |
| """One tiny read per tab per interval. The response is small, uncacheable and boring.""" |
| names = buckets_for(scope) |
| if names is None: |
| raise err(400, "bad_scope", "name the table you are watching, e.g. ?scope=customer") |
| |
| |
| |
| |
| if names and not _may_watch(session, _clean_scope(scope)): |
| raise err(404, "unknown_table", f"no database {_clean_scope(scope)!r} here") |
| if not names: |
| |
| |
| |
| |
| raise err(404, "unknown_scope", f"nothing here watches {_clean_scope(scope)!r}") |
| tokens = {} |
| for label, bucket in names.items(): |
| rev = revision_of(session.runtime, bucket) |
| |
| |
| |
| |
| tokens[label] = rev.get("token") if isinstance(rev, dict) else None |
| return {"scope": _clean_scope(scope), "tokens": tokens, "backend": store.backend()} |
|
|