File size: 13,219 Bytes
dcdb685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a878ebb
dcdb685
 
 
 
a878ebb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcdb685
a878ebb
dcdb685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a878ebb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dcdb685
 
 
 
 
 
a878ebb
 
 
 
 
 
dcdb685
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
"""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")

#: A scope reaches a STORE KEY, so it is sanitised as strictly as one: lowercase, digits and
#: underscores only. Not paranoia about SQL β€” `Store` addresses files and `PgStore` binds
#: parameters β€” but a scope is user-supplied text that becomes part of a path in the HF backend,
#: and "it cannot escape today" is a property of the current backend rather than of this route.
_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):                     # a tenant runtime owns the namespace prefix
        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:
        # A backend that raises here must not take the poll down with it: the caller degrades to
        # "no token for this bucket", which is the same honest null the pg path returns.
        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
    #: The shared stratum's bucket is asked FOR rather than spelled, so C5's `BUCKET_SUFFIX` has
    #: exactly one definition and a rename cannot leave the poller watching a dead key.
    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


#: How long a may-open verdict is trusted. Short enough that a REVOKED grant stops answering
#: within a poll interval or two; long enough that a tab polling every few seconds pays the
#: bucket read at most once per TTL. ⚠ A REVOCATION is therefore visible here up to `_WALL_TTL`
#: late β€” stated rather than glossed, because the alternative (no cache) is the 1.4 s-per-poll
#: cost this route was built to avoid, and the leak this bounds is an existence oracle, not data.
_WALL_TTL = 30.0
#: (tenant, user, scope) -> (expires_at, verdict). Bounded: a prober walking thousands of scopes
#: must not be able to grow this without limit, so it is cleared wholesale when it gets large β€”
#: cheaper than an LRU and the only cost of being wrong is one extra bucket read.
_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
    # ⚠ THE NAMESPACE STRING, not `runtime.tenant` β€” that attribute is a `Tenant` OBJECT and is
    # unhashable, so keying on it raised a TypeError INSIDE the handler, which `TestClient`
    # re-raises: the gate CRASHED rather than reddening ([[gate-must-go-red-not-crash]], caught
    # by the very leg added for this ticket). `store_namespace` is the string that already makes
    # one tenant's buckets distinct from another's, so it is the right axis anyway.
    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))
    # β›” ONLY A `True` IS CACHED, and the asymmetry is deliberate. `user_tables.all_tables()`
    # SWALLOWS a store error and answers `{}`, which makes `may_open` False β€” so caching a
    # negative would turn one transient store hiccup into "no database <name> here" for the whole
    # TTL, on a poller, for a table the user has open. A false negative that expires in 30 s is a
    # worse failure than one extra bucket read: the read costs latency, the cache costs trust
    # ([[lost-write-looks-like-failed-read]] β€” a refusal derived from a swallowed error reads to
    # the user as their data being gone).
    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")
    # β›” W29-T82 β€” THE SAME REFUSAL EVERY SIBLING DOOR GIVES, word for word. A session that may
    # not open this table must not be able to tell whether it exists, and "404 unknown_table" is
    # the sentence the other five row doors answer with. A different code here would itself be
    # the tell.
    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:
        # β›” A well-formed scope this map does not know is a 404, NOT an empty `tokens` object.
        # An empty map is indistinguishable from "nothing here ever changes", so a topic added
        # later would poll forever against silence and read as a working live view that never
        # updates. Loud beats plausible.
        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)
        # β›” A NULL IS AN ANSWER, not an omission. The pg backend maintains `rev` in `store_kv`
        # and does not yet expose it, so after D-4 flips this reads None until `PgStore` grows a
        # `revision()`. Sending null and letting the client stop polling is the honest failure;
        # a token frozen at 0 would promise liveness the backend is not delivering.
        tokens[label] = rev.get("token") if isinstance(rev, dict) else None
    return {"scope": _clean_scope(scope), "tokens": tokens, "backend": store.backend()}