| """routes_tables.py β USER TABLES over the wire (wave 18, contract C3-UT).
|
|
|
| The runtime-database primitive: `core/user_tables.py` (wave-9 C6, host-only until now) served
|
| through the SAME grid machinery every other topic rides β `table_store` for the per-user
|
| workspace strata, `aios_grid.workspace_wire` for the wire shape, `core.grid_events` for every
|
| durable write except rows. Rows are the one genuinely new channel: the events seam has no row
|
| event types (the user_tables docstring's `row_add` gate was described, never built), so row
|
| add/delete/patch are REST endpoints here, walled by `user_tables.is_user_table` +
|
| `user_tables.may_open` β a connector-backed table can never accept an invented row.
|
|
|
| TENANCY: every store touch goes through `session.runtime` (the tenant's store handle), so a
|
| Nurilab admin's tables live under Nurilab's prefix/repo, and the isolation gate's proof #3
|
| covers them for free.
|
|
|
| VISIBILITY is `user_tables.may_open` β creator or admin, fail-closed. There is no module grant
|
| to check because a user table is not a module; the per-table wall is the whole wall, and it is
|
| applied in `_defn_or_refuse` before any payload is built.
|
| """
|
| import threading
|
| import json
|
| import time
|
|
|
| from fastapi import Body, Depends
|
| from fastapi import APIRouter
|
|
|
| from deps import Session, err, require_session
|
|
|
| router = APIRouter(prefix="/api/v1")
|
|
|
|
|
| def _ut():
|
| import core.user_tables as user_tables
|
| return user_tables
|
|
|
|
|
| def _ops(session, table_key):
|
| import core.table_store as table_store
|
| return table_store.make(f"{table_key}_table_workspace", st=session.runtime)
|
|
|
|
|
|
|
|
|
| _REL_LOCK = threading.Lock()
|
| _REL_DIRTY = {}
|
| _REL_RUNNING = {}
|
|
|
|
|
| def _refresh_relations(session):
|
| """Mark this tenant's Links/Rollups stale and refresh them AFTER the response, once.
|
|
|
| β WHY THIS IS NOT A DIRECT CALL ANY MORE, and the numbers are the argument. The owner's
|
| item 2 was "adding a new record visually takes too long, I need to be able to spam it", and
|
| this function was the largest single cost inside `POST /tables/{key}/rows`:
|
| `engine.refresh_relations` DEEP-COPIES every table and every row in the tenant before it can
|
| decide whether anything needs doing (`automation_engine.py:9383-9388`), and when something
|
| does it commits with `flush="sync"` β a store round trip, i.e. an HF Dataset commit on the
|
| default backend. Every added record paid a whole-tenant snapshot plus a synchronous commit
|
| before the 201 came back.
|
|
|
| β AND BACKGROUNDING ALONE WOULD HAVE BEEN A WORSE BUG. Twenty rapid adds would queue twenty
|
| whole-tenant snapshots, each one re-reading a store the previous one just wrote β the spam
|
| the item asks us to support is exactly the load that would melt it. So this COALESCES: at
|
| most one pass runs, and at most one is queued behind it.
|
|
|
| β THE DIRTY FLAG IS THE LOAD-BEARING PART, not the lock. A write landing WHILE a pass is in
|
| flight must still get a pass afterwards, because the in-flight one snapshotted before that
|
| write existed. Without the flag the LAST add in a burst is precisely the one whose rollups
|
| never update β the failure nobody would notice until a total was quietly wrong.
|
|
|
| Eventual consistency is the accepted trade and was already the documented posture: the tick
|
| repairs materialised cells regardless, and answering 503 here would invite the browser to
|
| repeat a mutation that already succeeded.
|
| """
|
| tenant = str(getattr(session, "tenant", "") or "")
|
| rt = session.runtime
|
| with _REL_LOCK:
|
| _REL_DIRTY[tenant] = True
|
| if _REL_RUNNING.get(tenant):
|
| return
|
| _REL_RUNNING[tenant] = True
|
|
|
| def _worker():
|
| import automation_engine as engine
|
| try:
|
| while True:
|
| with _REL_LOCK:
|
| if not _REL_DIRTY.get(tenant):
|
| _REL_RUNNING.pop(tenant, None)
|
| return
|
| _REL_DIRTY.pop(tenant, None)
|
| try:
|
| engine.refresh_relations(rt, log=lambda *_args: None)
|
| except Exception as exc:
|
|
|
|
|
| print(f"[tables] relation refresh deferred: {type(exc).__name__}: {exc}")
|
| finally:
|
|
|
|
|
| with _REL_LOCK:
|
| if _REL_RUNNING.get(tenant) and not _REL_DIRTY.get(tenant):
|
| _REL_RUNNING.pop(tenant, None)
|
|
|
| threading.Thread(target=_worker, daemon=True,
|
| name=f"rel-refresh:{tenant or 'default'}").start()
|
|
|
|
|
| def _defn_or_refuse(session, table_key, st=None):
|
| """The per-table wall: 404 for a key that does not exist, 403 for one this session may not
|
| open. 404-before-403 leaks nothing useful β ut keys are guessable slugs, and 'exists but
|
| not yours' is exactly what may_open is for.
|
|
|
| β `st` LETS A CALLER LEND THE SNAPSHOT IT IS ALREADY HOLDING (W31 QA), the same `lend()` the
|
| nav and `/tables` use since W31-T10/T12. The WALL is untouched β the same `may_open`, asked
|
| about the same table β it is simply not re-reading a 28.5 MB document to ask it.
|
| """
|
| ut = _ut()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| st = st if st is not None else ut.lend(session.runtime)
|
| defn = ut.get(table_key, st=st)
|
| if not defn:
|
| raise err(404, "unknown_table", "that database does not exist")
|
| if not ut.may_open(table_key, session.uname, session.admin, st=st):
|
| raise err(403, "forbidden", "that database belongs to another user")
|
| return defn
|
|
|
|
|
| def _records_or_refuse(session, table_key, st=None):
|
| """The human record-write wall for a database the automation engine owns."""
|
|
|
|
|
| st = st if st is not None else _ut().lend(session.runtime)
|
| defn = _defn_or_refuse(session, table_key, st=st)
|
| if not _ut().records_mutable(table_key, st=st):
|
| raise err(403, "records_read_only",
|
| "records in this automation-owned database are read-only β add Instagram "
|
| "handles in a Profile database and let enrichment populate this database")
|
| return defn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| def _too_big():
|
| """`routes_odoo_tables.TooBigToMaterialise`, imported lazily β one name, two policies below."""
|
| import routes_odoo_tables
|
| return routes_odoo_tables.TooBigToMaterialise
|
|
|
|
|
| def _read_through_rows(table_key, field_keys, rt=None):
|
| """The mirror's rows for one read-through grid, projected to this table's declared columns.
|
|
|
| ββ W31-T45 / D-169 β `rt` IS THE SESSION'S TENANT RUNTIME AND IT IS PASSED THROUGH (D's ask,
|
| `mailbox/D.md` D-2). `_defn_or_refuse` above answers *"may this SESSION open this DATABASE"*;
|
| the guard `whole_pool` fires on `rt` answers a DIFFERENT question β *"is the DuckDB file this
|
| process has open THIS TENANT's"* β and R2 gives GTM Lab connected tables in its own document,
|
| which is exactly the shape that satisfies the first wall while failing the second.
|
| `datastore.ro_con()` reads a process-global `DB_PATH` and one Space process serves every
|
| tenant, so the two walls are not substitutes for each other.
|
|
|
| β ONE FETCH, TWO POLICIES β and the split is the whole of W31-T20. Both `scoped_pool` (which
|
| owes a caller every row) and `scoped_pids` (which owes only the row SET) reach the mirror
|
| through this function, so the pid set the cheap path answers with is IDENTICAL to the pool's
|
| by construction rather than by a second query that agrees today. A pid-only `SELECT` would be
|
| cheaper and would also be a SECOND statement of what a row of this table is
|
| ([[one-question-two-normalizers]]) β the two callers differ in what they do with the REFUSAL,
|
| never in how they ask.
|
|
|
| Raises `TooBigToMaterialise` when the population exceeds one window; the caller decides
|
| whether that is a 409 or an unresolved pid scope.
|
| """
|
| import routes_odoo_tables
|
|
|
| rows_src = [{k: v for k, v in r.items() if k in field_keys or k == "pid"}
|
| for r in routes_odoo_tables.whole_pool(table_key, rt=rt)]
|
| rows_src.sort(key=lambda r: r["pid"])
|
| return rows_src
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _PID_SCOPE_LIMIT = {
|
| "subject": "pids", "effect": "unresolved",
|
| "recommendation": "cohorts and shared-view membership are resolved per page on this grid; "
|
| "filter and read it a window at a time (`/odoo-tables/{key}/rows`), where "
|
| "every total is a SQL count over the whole table",
|
| }
|
|
|
|
|
| def scoped_pool(session: Session, table_key: str):
|
| """`(pids, rows_src, fields_base, defn)` β THE USER-TABLE WALL, on its own.
|
|
|
| `routes_products.scoped_pool`'s sibling, extracted for the same reason (wave 19, item 12): a
|
| caller that only needs "which rows of this database may this session touch" β record comments
|
| β must ask through `_defn_or_refuse` (404/403, the whole wall) rather than growing a second
|
| idea of what a user table's pool is.
|
| """
|
| defn = _defn_or_refuse(session, table_key)
|
| fields_base = [dict(f) for f in (defn.get("fields") or [])]
|
| field_keys = {f["key"] for f in fields_base}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if not _ut().materialises(table_key, st=session.runtime, defn=defn):
|
| try:
|
| rows_src = _read_through_rows(table_key, field_keys, rt=session.runtime)
|
| except _too_big() as e:
|
|
|
|
|
|
|
|
|
|
|
| raise err(409, "window_required", str(e))
|
| except RuntimeError as e:
|
| raise err(503, "store_not_ready", str(e))
|
| return frozenset(r["pid"] for r in rows_src), rows_src, fields_base, defn
|
| rows_src = []
|
| for rid, row in (defn.get("rows") or {}).items():
|
| if not str(rid).isdigit():
|
| continue
|
|
|
|
|
|
|
| r = {k: v for k, v in (row or {}).items() if k in field_keys}
|
| r["pid"] = int(rid)
|
| rows_src.append(r)
|
| rows_src.sort(key=lambda r: r["pid"])
|
| return frozenset(r["pid"] for r in rows_src), rows_src, fields_base, defn
|
|
|
|
|
| @router.patch("/tables/{table_key}/shared/{pid}")
|
| def patch_shared_cell(table_key: str, pid: int, body: dict = Body(default=None),
|
| session: Session = Depends(require_session)):
|
| """Write a cell into the TENANT-WIDE overlay β the product door `core/shared_overlay.py` has
|
| been waiting for since it shipped (W29-T62, wave 30 T28).
|
|
|
| β WHY A SEPARATE STRATUM AT ALL, restated because it is the whole feature and it is not
|
| "sharing would be nice": a user-created column and its values live PER USER, so a shared view
|
| filtering on one names a column other accounts do not have β and an unknown column is an
|
| INACTIVE condition in the tri-state engine, which IGNORES it and therefore WIDENS. The buy
|
| list would silently show the whole catalogue to everyone but its author. A column whose value
|
| is the same for every reader is the precondition for editing it at all.
|
|
|
| β THE WALL IS `_defn_or_refuse`, AND THE STRATUM IS NOT ONE. `shared_overlay` refuses no
|
| reader and no writer by design; "may this session open this surface" is answered HERE, where
|
| the session is. Do not push the question down there.
|
| β A tenant-wide write is not a private one: every account that may open this database sees it.
|
| That is the point, and it is why this door declares the column too β a value with no
|
| definition is a cell nobody can find.
|
| """
|
| body = body if isinstance(body, dict) else {}
|
| key = str(body.get("field") or "").strip()
|
| if not key:
|
| raise err(400, "bad_request", "a field key is required")
|
| _defn_or_refuse(session, table_key)
|
| from core import shared_overlay
|
| if not shared_overlay.is_shared(table_key, key, st=session.runtime):
|
| shared_overlay.put_field(table_key, key, {
|
| "key": key, "label": str(body.get("label") or key), "source": "overlay",
|
| "type": str(body.get("type") or "text"), "shared": True,
|
| "createdBy": session.uname}, st=session.runtime)
|
| try:
|
|
|
|
|
|
|
|
|
| stored = {key: shared_overlay.put_cell(table_key, pid, key, body.get("value"),
|
| st=session.runtime)}
|
| except ValueError as e:
|
|
|
| raise err(400, "bad_value", str(e))
|
| return {"ok": True, "pid": pid, "cells": stored,
|
| "fields": list(shared_overlay.fields(table_key, st=session.runtime))}
|
|
|
|
|
| @router.delete("/tables/{table_key}/shared/fields/{field_key}")
|
| def delete_shared_field(table_key: str, field_key: str,
|
| session: Session = Depends(require_session)):
|
| """Remove a TENANT-WIDE column and every value in it.
|
|
|
| β WHY THIS EXISTS AT ALL, said plainly: W30-T28 shipped the door that CREATES a shared column
|
| and none that removes one, so a column anybody added was permanent for the whole tenant. The
|
| reachability gate found it from the other end β `shared_overlay.drop_field` was complete,
|
| correct, gated, and callable by nothing but its own gate ([[reachable-is-not-the-same-as-built]]).
|
|
|
| β AND THIS ONE IS CREATOR-OR-ADMIN, WHICH THE WRITE DOOR IS NOT. Writing a cell changes a
|
| value; dropping the column deletes that value for EVERY account at once, so it is the
|
| destructive-op wall this repo already uses for a database delete β not `editRole`, which
|
| governs renaming and is not a value wall ([[schema-role-is-not-a-value-wall]]).
|
| β `createdBy` is stamped by the write door above; a column stored before that stamp existed
|
| is admin-only, which is the safe direction.
|
| """
|
| _defn_or_refuse(session, table_key)
|
| from core import shared_overlay
|
| defn = (shared_overlay.fields(table_key, st=session.runtime) or {}).get(str(field_key))
|
| if not defn:
|
| raise err(404, "unknown_field", "that column is not a shared column on this database")
|
| owner = str(defn.get("createdBy") or "")
|
| if not session.admin and owner != session.uname:
|
| raise err(403, "forbidden",
|
| f"a tenant-wide column can be removed by its creator or an admin β this one "
|
| f"was added by {owner or 'somebody else'}, and dropping it would delete the "
|
| f"value for every account")
|
| dropped = shared_overlay.drop_field(table_key, str(field_key), st=session.runtime)
|
| return {"ok": True, "dropped": bool(dropped),
|
| "fields": list(shared_overlay.fields(table_key, st=session.runtime))}
|
|
|
|
|
| def scoped_pids(session: Session, table_key: str, limits=None):
|
| """`(pids, fields_base, defn)` β the SAME wall and the SAME row set as `scoped_pool`, without
|
| building a row.
|
|
|
| ββ WAVE 30 / W30-T30 β THIS IS WHY ONE HIDE-FIELDS CHECKBOX WAS EXPENSIVE. A view write
|
| (`view_upsert`) reaches `grid_events_route`, which built a FULL assembly purely to validate
|
| it: `scoped_pool` allocates a fresh dict per row and then sorts them β ~33k order rows, on
|
| every toggle β and the six keys the events route actually reads from that assembly
|
| (`fields`, `pids`, `measures`, `measure_sets`, `lists`, `views`) contain no row at all.
|
| `rows_src` was computed and discarded.
|
|
|
| β THE PID SET IS IDENTICAL, NOT MERELY EQUIVALENT, and that is the whole safety argument:
|
| `scoped_pool` derives its pids as `frozenset(r["pid"] for r in rows_src)` over exactly the
|
| row ids that pass `str(rid).isdigit()`, which is this comprehension with a dict build in the
|
| middle. The row WALL is unchanged β a narrower or wider set here would be a permission
|
| change, and this is a performance change.
|
|
|
| β It does NOT make the write cheap on its own: `_defn_or_refuse` still costs two whole-document
|
| deep copies (`ut.get` then `may_open`), which is D-87 and W30-T31. This removes the row pass.
|
|
|
| ββ W31-T20 / D-174 β `limits` IS AN OUT-PARAMETER, AND IT IS THE POINT OF THE TICKET. Pass a
|
| list and this function APPENDS R6's sentence to it when the pid set could not be resolved (a
|
| read-through grid whose population exceeds one window). The pid set is then EMPTY, and every
|
| consumer of an empty pid set is fail-closed β but "fail-closed and unannounced" is exactly the
|
| silent limit R6's second sentence forbids, so a caller that renders an envelope or admits a
|
| write is expected to carry the sentence through. Omitting the list means the caller accepts an
|
| unannounced empty scope, which is only ever right for a caller that does not use the pids.
|
| """
|
| defn = _defn_or_refuse(session, table_key)
|
| fields_base = [dict(f) for f in (defn.get("fields") or [])]
|
|
|
|
|
|
|
|
|
|
|
| if not _ut().materialises(table_key, st=session.runtime, defn=defn):
|
| try:
|
| rows = _read_through_rows(table_key, {f["key"] for f in fields_base},
|
| rt=session.runtime)
|
| return frozenset(r["pid"] for r in rows), fields_base, defn
|
| except _too_big() as e:
|
|
|
|
|
|
|
| if limits is not None:
|
| limits.append({**_PID_SCOPE_LIMIT, "cause": str(e)})
|
| return frozenset(), fields_base, defn
|
| except RuntimeError as e:
|
| raise err(503, "store_not_ready", str(e))
|
| pids = frozenset(int(rid) for rid in (defn.get("rows") or {}) if str(rid).isdigit())
|
| return pids, fields_base, defn
|
|
|
|
|
| def ut_write_ctx(session: Session, table_key: str):
|
| """The g-dict a WRITE needs β same keys as `ut_assembly`, no rows.
|
|
|
| Returns the six keys `routes_grid.grid_events_route` reads, so the events route consumes this
|
| or a full assembly interchangeably. `rows_src` is `[]` on purpose rather than absent: a caller
|
| that starts needing rows should fail on an empty list it can see, not on a KeyError.
|
| """
|
| import aios_grid
|
| from core import grid_events
|
|
|
| limits = []
|
| pids, fields_base, defn = scoped_pids(session, table_key, limits=limits)
|
| ctx = grid_events.EventCtx(
|
| uname=session.uname, allowed_pids=pids, fields=[], hidden_keys=frozenset(),
|
| admin=session.admin, fallback_ws=None, seen_ids={}, st=session.runtime,
|
| scope_key=table_key, table=_ops(session, table_key))
|
| ws = grid_events.table_workspace(ctx, allowed_pids=pids, consume_corrections=False)
|
| workspace, fields, views, lists = aios_grid.workspace_wire(
|
| ws, session.uname, set(pids), defs={}, scope_key=table_key, storage_key="",
|
| fields_base=fields_base)
|
| return {"rows_src": [], "pids": pids, "ws": ws, "workspace": workspace,
|
| "fields": fields, "views": views, "lists": lists,
|
| "derived": aios_grid.cohort_cells(lists),
|
| "measures": [], "measure_sets": {}, "today": time.strftime("%Y-%m-%d"),
|
|
|
|
|
| "limits": limits, "defn": defn}
|
|
|
|
|
| def ut_assembly(session: Session, table_key: str, storage_key: str = "",
|
| consume_corrections: bool = True, with_rows: bool = True):
|
| """The user-table mirror of `grid_assembly` / `product_assembly` β SAME g-dict keys, so
|
| `/workspace` and the events route consume any of the three interchangeably.
|
|
|
| Honest absence: `measures`/`measure_sets` are EMPTY β `core.measure_resolve` is
|
| customer-grain, so there is nothing to offer over user rows.
|
|
|
| β WAVE 19 / R9 β `lists` IS NO LONGER EMPTY. "For ANY database new/old": a user table gets
|
| cohorts like every other database, out of its OWN bucket (`ut_<slug>_cohorts`), holding its
|
| own row ids. The wave-18 refusal was correct while there was one customer-keyed bucket and
|
| wrong the moment the store learned about topics.
|
|
|
| ββ W31-T20 / D-174 β `with_rows=False` BUILDS THE ENVELOPE AND NOT THE TABLE, and the
|
| caller that wants it is `/workspace`, which renders no row at all (the grid fetches rows from
|
| `/tables/{key}/rows` or `/odoo-tables/{key}/rows` beside it). Two things follow:
|
| * the read-through line grains become OPENABLE β `scoped_pool` refused their envelope over
|
| 963,783 rows nobody was going to look at, which is D-174 in one sentence;
|
| * every materialised `ut_*` database stops allocating a dict per row and sorting them on a
|
| route whose payload has no rows in it β `ut_odoo_orders` was rebuilding 32,826 of them per
|
| database switch (owner item 7).
|
| β NOTHING IS VALIDATED LESS. `scoped_pids` runs the SAME `_defn_or_refuse` wall and answers
|
| the identical pid set; the flag removes work, never a check β the shape W30-T30 already proved
|
| on the write door.
|
| """
|
| import aios_grid
|
| from core import grid_events
|
|
|
| limits = []
|
| if with_rows:
|
| pids, rows_src, fields_base, defn = scoped_pool(session, table_key)
|
| else:
|
| pids, fields_base, defn = scoped_pids(session, table_key, limits=limits)
|
| rows_src = []
|
|
|
| ops = _ops(session, table_key)
|
| ctx = grid_events.EventCtx(
|
| uname=session.uname, allowed_pids=pids, fields=[], hidden_keys=frozenset(),
|
| admin=session.admin, fallback_ws=None, seen_ids={},
|
|
|
|
|
| st=session.runtime,
|
| scope_key=table_key, table=ops)
|
| ws = grid_events.table_workspace(ctx, allowed_pids=pids,
|
| consume_corrections=consume_corrections)
|
| workspace, fields, views, lists = aios_grid.workspace_wire(
|
| ws, session.uname, set(pids), defs={}, scope_key=table_key,
|
| storage_key=storage_key, fields_base=fields_base)
|
|
|
| return {"rows_src": rows_src, "pids": pids, "ws": ws, "workspace": workspace,
|
| "fields": fields, "views": views, "lists": lists,
|
|
|
| "derived": aios_grid.cohort_cells(lists),
|
| "measures": [], "measure_sets": {}, "today": time.strftime("%Y-%m-%d"),
|
|
|
|
|
| "limits": limits, "defn": defn}
|
|
|
|
|
| def ut_label(defn, key, meta=None):
|
| """THE name of a user table, resolved ONCE (wave 20, item 6a).
|
|
|
| `nav_meta`'s rename wins, then the definition's own label, then the key. Every surface that
|
| shows a database name reads through here, because the alternative is what wave 20 found: the
|
| rail showed the renamed name (it reads `nav_meta`) while the automation editor's picker
|
| showed the original (it reads the definition), and neither looked broken.
|
|
|
| β `set_label` now writes the DEFINITION too, so the two agree at the source. This resolver
|
| stays because it makes every row already stored β renamed before that fix landed β read
|
| correctly today, without a migration.
|
| """
|
| return ((meta or {}).get(key, {}).get("name")
|
| or (defn or {}).get("label") or key)
|
|
|
|
|
| def nav_meta(session):
|
| """The tenant's nav_meta bucket, read defensively. A store blip must not take a list down."""
|
| try:
|
| got = session.runtime.get("nav_meta")
|
| return got if isinstance(got, dict) else {}
|
| except Exception:
|
| return {}
|
|
|
|
|
| @router.get("/tables")
|
| def list_tables(session: Session = Depends(require_session)):
|
| """This session's user tables β the list the '+ New database' surface renders.
|
|
|
| ββ W31-T12 (contract C1, D-175's third instance) β ONE DOCUMENT READ, NOT `1 + 2N`. This
|
| route was never ticketed and has the same shape `/nav` and `/automations` were fixed for:
|
| `all_tables` once, then `may_open` per key (another whole-document deep copy each, 28.6 MB on
|
| tenant #0 measured, 703 ms warm) and `records_mutable` per key on top of that β so a tenant
|
| with ten databases paid twenty-one copies to list them. The wall is UNCHANGED and still asked
|
| about every table; it is handed the document this function already holds. See
|
| `user_tables.lend`'s own note for why inlining the predicate is the one fix that is not
|
| available.
|
| """
|
| ut = _ut()
|
| meta = nav_meta(session)
|
| out = []
|
| tables = ut.all_tables(st=session.runtime)
|
| lent = ut.lend(session.runtime, **{ut.STORE_KEY: tables})
|
| for key, t in sorted(tables.items(),
|
| key=lambda kv: (ut_label(kv[1], kv[0], meta) or "").lower()):
|
| if not ut.may_open(key, session.uname, session.admin, st=lent):
|
| continue
|
| out.append({"key": key, "label": ut_label(t, key, meta),
|
| "source": t.get("source") or "Blank",
|
| "recordsMutable": ut.records_mutable(key, st=lent),
|
| "createdBy": t.get("createdBy") or "",
|
| "created": t.get("created") or "",
|
| "fields": [dict(f) for f in (t.get("fields") or [])],
|
| "rowCount": len(t.get("rows") or {})})
|
| return {"tables": out}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _ROLLUP_CACHE = {}
|
|
|
|
|
| def _rollup_source_offer():
|
| """`{topics:[β¦], windows:[β¦]}` β every (topic, measure, dim) the engine can actually answer.
|
|
|
| β ONLY COMBINATIONS THAT CAN RESOLVE ARE OFFERED, because a rollup that refuses at COMPUTE
|
| time refuses silently β the cells are simply left blank, hours later, on a column that looks
|
| configured. Two exclusions do real work:
|
| * a topic with NO dims cannot be grouped at all, so it can never key a parent row;
|
| * a CROSS-TOPIC metric (`aov`, `margin_pct`, `returns_pct` β `agg: ratio`/`derived` whose
|
| inputs live elsewhere) is refused by `store_query` the moment a `group_by` is present:
|
| *"cross-topic measures are scalar-only"*. Offering one would mint a column that can only
|
| ever error.
|
| Each dim also declares HOW it keys β by an Odoo id or by its own value β because that is what
|
| the user is matching their own column against, and `payment_state` (a value) and
|
| `partner` (an id) are matched to very different columns.
|
| """
|
| if _ROLLUP_CACHE.get("offer"):
|
| return _ROLLUP_CACHE["offer"]
|
| from harness import semantic as sem
|
| from harness import windows as W
|
| ut = _ut()
|
|
|
| topics, metrics = sem.topics(), sem.metrics()
|
| by_topic = {}
|
| for key, m in metrics.items():
|
|
|
| if m.get("agg") in ("ratio", "derived"):
|
| continue
|
| by_topic.setdefault(m["topic"], []).append(
|
| {"key": key, "label": m.get("label") or key, "format": m.get("format") or "usd",
|
| "description": m.get("description") or ""})
|
|
|
| out = []
|
| for tkey, t in sorted(topics.items()):
|
| dims = ((t.get("store") or {}).get("dims") or {})
|
| measures = by_topic.get(tkey) or []
|
| if not dims or not measures:
|
| continue
|
| out.append({
|
| "key": tkey,
|
| "label": t.get("label") or tkey,
|
| "grain": t.get("grain") or "",
|
| "dims": [{"key": dkey,
|
| "label": d.get("label") or dkey,
|
|
|
|
|
|
|
| "keyedBy": "id" if d.get("name_col") else "value"}
|
| for dkey, d in dims.items()],
|
| "measures": sorted(measures, key=lambda m: m["label"].lower()),
|
| })
|
|
|
|
|
|
|
|
|
|
|
| windows = [{"key": k, "label": W.WINDOW_LABELS.get(k, k).format(n="N")}
|
| for k in ut.ROLLUP_SOURCE_WINDOWS]
|
| offer = {"topics": out, "windows": windows}
|
| _ROLLUP_CACHE["offer"] = offer
|
| return offer
|
|
|
|
|
| @router.get("/tables/rollup-sources")
|
| def rollup_sources(session: Session = Depends(require_session)):
|
| """The topic β metric β dim β window offer the rollup field editor renders.
|
|
|
| β DECLARED ABOVE EVERY `/tables/{table_key}/β¦` ROUTE, and kept there deliberately. FastAPI
|
| matches in declaration order, so the day somebody adds a bare `GET /tables/{table_key}` below
|
| this line it still resolves; added ABOVE it, this endpoint would silently start arriving as
|
| `table_key='rollup-sources'` and 404 from the table wall. There is no such route today β
|
| this is the cheap ordering that keeps it from mattering.
|
|
|
| β TENANT-SCOPED, AND IT WAS NOT WHEN FIRST WRITTEN. `sem.topics()` reads the GLOBAL model
|
| files, so nurilab and gtmlab were served the full Odoo offer β they would have seen "Live
|
| Odoo data" in the field editor and been able to build a column that can only ever be blank,
|
| because there is no mirror behind it. Three comments (here, in `apiBridge` and on the
|
| `rollupSourceOffer` prop) each asserted that a tenant with nothing connected receives `[]`,
|
| and the mode switch's "render only when there is a choice" guard is built on that promise.
|
| β `odoo_relational.is_royal` is the authority, reused rather than re-decided: it is already
|
| what `refresh` consults to decide whether these tables may exist at all, and a second copy of
|
| the rule would be a second answer the day a tenant gains a mirror.
|
| """
|
| import odoo_relational
|
| if not odoo_relational.is_royal(session.tenant):
|
| return {"topics": [], "windows": []}
|
| return _rollup_source_offer()
|
|
|
|
|
| @router.post("/tables", status_code=201)
|
| def create_table(body: dict = Body(default=None),
|
| session: Session = Depends(require_session)):
|
| ut = _ut()
|
| body = body or {}
|
| label = str(body.get("label") or "").strip()
|
| if not label:
|
| raise err(400, "bad_label", "give the database a name")
|
| if not session.runtime.available():
|
| raise err(503, "store_unavailable",
|
| "the tenant store is unavailable β nothing was created")
|
| source = body.get("source")
|
| try:
|
| key = ut.create(label, session.uname, fields=body.get("fields"),
|
| source=source, st=session.runtime)
|
| except Exception:
|
| raise err(503, "store_unavailable",
|
| "the tenant store refused the write β nothing was created")
|
| if not key:
|
| raise err(400, "refused",
|
| f"could not create it β the name may be empty or this tenant already has "
|
| f"{ut.MAX_TABLES} databases")
|
| return {"key": key}
|
|
|
|
|
| @router.patch("/tables/{table_key}")
|
| def patch_table(table_key: str, body: dict = Body(default=None),
|
| session: Session = Depends(require_session)):
|
| """Rename a database β IN ITS DEFINITION (wave 20, item 6a).
|
|
|
| β THE RENAME DOOR IN THE NAV WRITES `nav_meta` AND MUST ALSO CALL THIS. `nav_meta` is the
|
| nav's display layer; the definition is what the automation editor's database picker, the
|
| schema drawer and every future reader see. A rename that lands in only one of them leaves a
|
| picker that is confidently wrong rather than obviously stale. Posted to the wave doc as an
|
| amendment for whoever owns that door.
|
| """
|
| defn = _defn_or_refuse(session, table_key)
|
| ut = _ut()
|
| if not (session.admin or defn.get("createdBy") == session.uname):
|
| raise err(403, "forbidden", "only the database's creator or an admin can rename it")
|
| label = ut.set_label(table_key, (body or {}).get("label"), st=session.runtime)
|
| if not label:
|
| raise err(400, "bad_label", "give the database a name")
|
| return {"key": table_key, "label": label}
|
|
|
|
|
| @router.get("/tables/{table_key}/footprint")
|
| def table_footprint(table_key: str, session: Session = Depends(require_session)):
|
| """What dies with this database β the confirm dialog's disclosure (wave 21, item 6a / C3).
|
|
|
| Counts drill to the SAME buckets `user_tables.delete` cleans; a dialog listing categories
|
| without numbers would break [[no-unverifiable-aggregates]] at the scariest moment. Walled
|
| like the delete itself: only someone who could delete may case the joint."""
|
| defn = _defn_or_refuse(session, table_key)
|
| if not (session.admin or defn.get("createdBy") == session.uname):
|
| raise err(403, "forbidden", "only the database's creator or an admin can delete it")
|
| s = session.runtime
|
| views, fields = set(), len(defn.get("fields") or [])
|
| try:
|
| bucket = s.get(f"{table_key}_table_workspace") or {}
|
| for _u, ws in bucket.items():
|
| if isinstance(ws, dict):
|
| views |= set((ws.get("views") or {}).keys())
|
| fields += len(ws.get("fields") or {})
|
| except Exception:
|
| pass
|
| import core.shares as shares
|
| g = shares.grants("database", table_key, st=s)
|
| auto = []
|
| try:
|
| import automation_engine as engine
|
| for aid, d in (engine.all_definitions(s) or {}).items():
|
| if (d.get("config") or {}).get("targetTable") == str(table_key):
|
| auto.append({"id": str(aid), "name": d.get("name") or str(aid)})
|
| except Exception:
|
| pass
|
| return {"rows": len(defn.get("rows") or {}), "fields": fields, "views": len(views),
|
| "sharedUsers": len(g.get("entries") or []),
|
| "automations": sorted(auto, key=lambda a: a["name"].lower())}
|
|
|
|
|
| @router.delete("/tables/{table_key}")
|
| def delete_table(table_key: str, session: Session = Depends(require_session)):
|
| """CREATOR OR ADMIN β checked explicitly (wave 21, item 6a / C3).
|
|
|
| β The wave-20 docstring said "the same actors may_open admits" and that stopped being the
|
| creator-or-admin set the day de5037f taught `may_open` to admit share GRANTEES: a view-role
|
| grantee could reach this route and delete the database somebody shared with them. The wall
|
| is now the definition's own `createdBy`, the same check the rename route always had.
|
|
|
| Deletion cleans the artifact families server-side (`user_tables.delete` lists them) and
|
| DISABLES bound automations with a status note β never deletes them. The client's confirm
|
| dialog disclosed `/footprint` first; the server cannot tell a click from a plan, so the
|
| dialog is a product requirement, not a formality."""
|
| defn = _defn_or_refuse(session, table_key)
|
| if not (session.admin or defn.get("createdBy") == session.uname):
|
| raise err(403, "forbidden", "only the database's creator or an admin can delete it")
|
| try:
|
| import automation_engine as engine
|
| engine.disable_for_table(session.runtime, table_key)
|
| except Exception:
|
| pass
|
| try:
|
| _ut().delete(table_key, st=session.runtime)
|
| except Exception:
|
| raise err(503, "store_unavailable", "the delete did not land β try again")
|
| return {"ok": True}
|
|
|
|
|
|
|
| _IG_FORWARDED = set()
|
|
|
|
|
| def _ig_forward(session):
|
| """Bring this tenant's Instagram tables onto the current schema, at most once per process.
|
|
|
| β WHY A MIGRATION RUNS ON A READ AT ALL, when the module's own rule is that it rides the WRITE
|
| path. `ut_ensure` calling it is right for a schema an automation is about to append to, and
|
| useless for a change a PERSON is waiting to see: the owner's report was *"the first field is
|
| still blank"*, and "re-save the automation and it will fix itself" is not an answer to that.
|
| The write path stays exactly as it was β this is a second door to the same idempotent call,
|
| not a replacement for it.
|
|
|
| β BOUNDED THREE WAYS, because a write on a read is otherwise how a grid gets slow: once per
|
| tenant per process; `migrate_ig_tables` returns without a write when every table is already
|
| current (the common case after the first read); and a failure is SWALLOWED β a migration must
|
| never be the reason a database will not open.
|
|
|
| β THE TENANT IS MARKED BEFORE THE ATTEMPT, deliberately. A migration that raises must not be
|
| retried on every subsequent read of every table for the life of the process β the write path is
|
| still the backstop, so the cost of skipping is a delay, while the cost of retrying is a failing
|
| store call on the hot path of a grid that is trying to render.
|
| """
|
| tenant = str(getattr(session, "tenant", "") or "")
|
| if tenant in _IG_FORWARDED:
|
| return
|
| _IG_FORWARDED.add(tenant)
|
| try:
|
| import automation_engine as engine
|
| engine.migrate_ig_tables(session.runtime, log=lambda *_a: None)
|
| except Exception as e:
|
| print(f"[tables] ig forward-migration skipped: {type(e).__name__}: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| JSON_LIST_MAX = 400
|
|
|
|
|
| def _thin_json(fields, merged, table_key=""):
|
| """Replace oversized `json` cells with a stand-in for the LIST response. Pure; returns a copy.
|
|
|
| β THE STAND-IN IS ITSELF VALID JSON and carries the byte count, so the grid preview reads
|
| `{...} 3 keys` rather than a broken brace, and a reader can see the column holds something
|
| large rather than something empty. `_truncated` is what the viewer keys its fetch on.
|
|
|
| β THE STAND-IN CARRIES ITS OWN `_url`, which is what keeps this change small AND correct: the
|
| viewer needs no table key, no record id and no new props threaded down through three
|
| components to find the document β the route that removed the value says where it went. One
|
| writer of that address instead of a server rule and a client rule that must agree forever.
|
| """
|
| json_keys = [str(f.get("key")) for f in (fields or [])
|
| if str(f.get("type") or "") == "json"]
|
| if not json_keys:
|
| return merged
|
| out = {}
|
| for pid, cells in (merged or {}).items():
|
| row = cells
|
| for key in json_keys:
|
| raw = cells.get(key)
|
| if isinstance(raw, str) and len(raw) > JSON_LIST_MAX:
|
| if row is cells:
|
| row = dict(cells)
|
| row[key] = json.dumps({
|
| "_truncated": True, "bytes": len(raw),
|
| "_url": f"/api/v1/tables/{table_key}/rows/{pid}/fields/{key}"})
|
| out[pid] = row
|
| return out
|
|
|
|
|
| @router.get("/tables/{table_key}/rows")
|
| def table_rows(table_key: str, session: Session = Depends(require_session)):
|
| """The `/customers`-shaped envelope for one user table: `{fields, rows, today, pulled_at,
|
| identity}` β so the client's generic topic fetch consumes it with zero new parsing.
|
|
|
| β THE MERGE ORDER IS THE CONTRACT. `rows_from_pool` sources an overlay-typed field's cell
|
| from the OVERLAY stratum only (that is what makes a custom column render standalone) β a
|
| user table's base values live in its DEFINITION rows, so they are layered UNDER the user's
|
| overlay edits here: base first, overlay wins. Without this every base cell reads empty
|
| (found by this route's own gate check, not by luck)."""
|
| import aios_grid
|
|
|
| _ig_forward(session)
|
| g = ut_assembly(session, table_key)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _overlays = (g.get("ws") or {}).get("overlays") or {}
|
| merged = {}
|
| for _r in g["rows_src"]:
|
| _pid = str(_r["pid"])
|
| _cells = {k: v for k, v in _r.items() if k != "pid"}
|
| _ov = _overlays.get(_pid)
|
| if isinstance(_ov, dict):
|
| _cells.update(_ov)
|
| merged[_pid] = _cells
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| rows = aios_grid.rows_from_pool(
|
| g["rows_src"], g["fields"], _thin_json(g["fields"], merged, table_key), derived=g["derived"])
|
|
|
|
|
|
|
|
|
|
|
|
|
| _report = _ut().limit_report(table_key, st=session.runtime)
|
|
|
|
|
|
|
|
|
|
|
| from core import grid_events as _ge
|
| return {"fields": g["fields"], "rows": rows, "today": g["today"],
|
| "docs": _ge.docs_for(g["pids"], scope_key=table_key, uname=session.uname,
|
| admin=session.admin, st=session.runtime),
|
| "pulled_at": time.strftime("%Y-%m-%d %H:%M"),
|
| "identity": {"pid": "pid"},
|
| "scope": {"table": table_key, "rowCount": len(rows)},
|
| "limits": [_report] if _report else [],
|
| "recordsMutable": _ut().records_mutable(table_key, st=session.runtime)}
|
|
|
|
|
| @router.get("/tables/{table_key}/rows/{pid}/fields/{fkey}")
|
| def table_cell(table_key: str, pid: str, fkey: str,
|
| session: Session = Depends(require_session)):
|
| """ONE cell, whole β the other half of `_thin_json`.
|
|
|
| β WITHOUT THIS THE THINNING WOULD BE A CAP, and a cap on data somebody already paid a vendor
|
| for is exactly what this product refuses everywhere else. The list ships a stand-in; the JSON
|
| viewer opens this for the one row a person is actually reading.
|
|
|
| β SAME PERMISSION WALL AS THE LIST, reached the same way (`ut_assembly` resolves the session's
|
| view of the table), so this cannot become a side door onto a table the caller may not open β
|
| which is the failure a "just fetch the raw cell" helper invites.
|
| β THE OVERLAY WINS, exactly as it does in `table_rows`: a user who has typed over a cell must
|
| read back what they typed, not the definition value underneath it.
|
| """
|
| g = ut_assembly(session, table_key)
|
| field = next((f for f in (g.get("fields") or []) if str(f.get("key")) == str(fkey)), None)
|
| if field is None:
|
| raise err(404, "unknown field", f"{fkey!r} is not a column on this database")
|
| row = next((r for r in g["rows_src"] if str(r.get("pid")) == str(pid)), None)
|
| if row is None:
|
| raise err(404, "unknown record", f"no record {pid!r} in this database")
|
| overlay = ((g.get("ws") or {}).get("overlays") or {}).get(str(pid)) or {}
|
| value = overlay.get(fkey, row.get(fkey))
|
| return {"table": table_key, "pid": str(pid), "field": str(fkey),
|
| "value": "" if value is None else str(value)}
|
|
|
|
|
| @router.post("/tables/{table_key}/rows", status_code=201)
|
| def add_row(table_key: str, body: dict = Body(default=None),
|
| session: Session = Depends(require_session)):
|
| """Append a row β or RESTORE one under its old id (contract C-ADDROW / C-UNDO).
|
|
|
| β THE ANSWER IS ALWAYS THE ID THAT WAS STORED, never the one that was asked for. An undo
|
| that requested `rid: 7` and got 12 because 7 had been re-used must find that out from the
|
| response rather than assume; the client re-anchors on what came back.
|
| """
|
| _records_or_refuse(session, table_key)
|
| ut = _ut()
|
| values = (body or {}).get("values") or {}
|
| if not isinstance(values, dict):
|
| raise err(400, "bad_values", "values must be an object of {fieldKey: value}")
|
| try:
|
| rid = ut.add_row(table_key, values, session.uname, st=session.runtime,
|
| rid=(body or {}).get("rid"))
|
| except Exception:
|
| raise err(503, "store_unavailable", "the row was not saved β the store refused")
|
| if rid is None:
|
|
|
|
|
|
|
| pf = ut.profile_field(table_key, st=session.runtime)
|
| if pf and pf["key"] in values:
|
| _h, ok = ut.normalize_profile(values[pf["key"]], pf["profile"].get("source"))
|
| if not ok:
|
| raise err(400, "refused",
|
| f"{str(values[pf['key']])[:80]!r} is not an Instagram profile β "
|
| f"{pf.get('label') or pf['key']!r} takes a handle (@name) or a "
|
| f"profile link (instagram.com/name)")
|
| raise err(400, "refused",
|
| f"row refused β the table may be at its {ut.MAX_ROWS}-row cap")
|
| _refresh_relations(session)
|
| return {"rid": rid, "pid": int(rid)}
|
|
|
|
|
| @router.post("/tables/{table_key}/rows/import", status_code=201)
|
| def import_rows(table_key: str, body: dict = Body(default=None),
|
| session: Session = Depends(require_session)):
|
| """ββ WAVE-29 T25 (owner item 6) β the IMPORT door: N mapped rows, ONE store write.
|
|
|
| Body: `{"rows": [{fieldKey: value, ...}, ...]}` β already MAPPED by the client's dialog, so a
|
| spreadsheet column name never reaches the store. APPEND-ONLY in v1: every row is a new record,
|
| nothing is matched or overwritten, and the dialog says so before the button is pressed.
|
|
|
| β COMPUTED COLUMNS ARE REFUSED HERE, NOT FILTERED. `is_computed_cell` is the same predicate
|
| the cell wall uses (one evaluator for one question), and a rollup or formula key arriving in
|
| an import is not a stray to be tidied away β it means the client offered a target it should
|
| not have, and silently dropping it would leave the user looking for a column of values that
|
| never arrived. The refusal names the column.
|
|
|
| β ATOMIC. `add_rows` writes nothing unless the whole batch fits under `MAX_ROWS` and every
|
| profile cell validates, because a half-imported file is the worst outcome available: the user
|
| cannot tell which rows landed without reconciling the spreadsheet by hand.
|
| """
|
| _records_or_refuse(session, table_key)
|
| ut = _ut()
|
| rows_in = (body or {}).get("rows")
|
| if not isinstance(rows_in, list) or not rows_in:
|
| raise err(400, "bad_rows", "rows must be a non-empty array of {fieldKey: value} objects")
|
| if any(not isinstance(r, dict) for r in rows_in):
|
| raise err(400, "bad_rows", "every row must be an object of {fieldKey: value}")
|
| defn = _defn_or_refuse(session, table_key)
|
| by_key = {f["key"]: f for f in (defn.get("fields") or [])}
|
| asked = {k for r in rows_in for k in r}
|
| unknown = sorted(k for k in asked if k not in by_key)
|
| if unknown:
|
| raise err(400, "unknown_field",
|
| f"this database has no column {unknown[0]!r}")
|
| computed = sorted(k for k in asked if ut.is_computed_cell(by_key[k]))
|
| if computed:
|
| label = by_key[computed[0]].get("label") or computed[0]
|
| raise err(400, "computed_field",
|
| f"{label!r} is worked out from other columns, so it cannot be imported into")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| for index, row in enumerate(rows_in):
|
| for key, value in row.items():
|
| why = ut.cell_type_refusal(by_key[key], value)
|
| if why:
|
| raise err(400, "bad_value", f"row {index + 1}: {why} β nothing was imported")
|
| try:
|
| made = ut.add_rows(table_key, rows_in, session.uname, st=session.runtime)
|
| except Exception:
|
| raise err(503, "store_unavailable", "nothing was imported β the store refused")
|
| if made is None:
|
| raise err(400, "refused",
|
| f"nothing was imported β {len(rows_in)} rows would take this database past "
|
| f"its {ut.MAX_ROWS}-row cap, or a profile column rejected a value")
|
| _refresh_relations(session)
|
| return {"imported": len(made), "pids": [int(r) for r in made]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| def _field_or_refuse(session, table_key, fkey=""):
|
| """The schema wall. `_defn_or_refuse` first (404/403 on the table), then the per-field rule."""
|
| defn = _defn_or_refuse(session, table_key)
|
| ut = _ut()
|
| if not ut.is_user_table(table_key, st=session.runtime):
|
| raise err(400, "not_a_user_table",
|
| "only a user-created database has an editable schema β a connected source "
|
| "owns its own columns")
|
| if fkey and not ut.may_edit_field(table_key, fkey, session.uname, session.admin,
|
| st=session.runtime):
|
| field = next((f for f in (defn.get("fields") or [])
|
| if f.get("key") == str(fkey)), None)
|
| if isinstance((field or {}).get("automation"), dict) \
|
| and field["automation"].get("preset") is True:
|
|
|
|
|
|
|
|
|
|
|
| raise err(403, "preset_field_locked",
|
| "this is a pre-set column, so its name and type are fixed; you may sort, "
|
| "filter or hide it, edit any Rollup column, and add your own columns")
|
| raise err(403, "forbidden", "that column can only be changed by the database's creator "
|
| "or an admin")
|
| if not fkey and not (session.admin or defn.get("createdBy") == session.uname):
|
| raise err(403, "forbidden", "only the database's creator or an admin can add a column")
|
| return defn
|
|
|
|
|
| @router.post("/tables/{table_key}/fields", status_code=201)
|
| def add_field(table_key: str, body: dict = Body(default=None),
|
| session: Session = Depends(require_session)):
|
| _field_or_refuse(session, table_key)
|
| ut = _ut()
|
| field = ut.add_field(table_key, body or {}, st=session.runtime)
|
| if not field:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| raise err(400, "refused",
|
| _refusal_sentence(ut, session, body or {}, table_key=table_key))
|
| if field.get("type") == "link":
|
| synced = ut.sync_reciprocal_link(table_key, field["key"], st=session.runtime)
|
| field = synced.get("field") or field
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| if field.get("type") in ("link", "rollup"):
|
| _refresh_relations(session)
|
| return {"field": field}
|
|
|
|
|
| def _refusal_sentence(ut, session, body, table_key="", fkey=""):
|
| """Why was this column refused? The specific reason when we can name one, the general list
|
| otherwise β never a specific-sounding guess."""
|
|
|
|
|
|
|
| if isinstance((body or {}).get("profile"), dict):
|
|
|
|
|
|
|
|
|
|
|
|
|
| if str((body or {}).get("type") or "text").strip().lower() != "text":
|
| return ("a profile column is a flag on an ordinary TEXT column β it validates what "
|
| "is typed into it, which it can only do for text")
|
| existing = ut.profile_field(table_key, st=session.runtime) if table_key else None
|
| if existing and existing.get("key") != str(fkey):
|
| return (f"this database already has a profile column β "
|
| f"{existing.get('label') or existing.get('key')!r}. A database has at most "
|
| f"one, so the automation knows which handle to enrich; edit that column, or "
|
| f"take the flag off it first")
|
| bag = (body or {}).get("automation")
|
| if isinstance(bag, dict):
|
| flow = str(bag.get("flowId") or "").strip()
|
| if not flow:
|
| return ("an automation column has to name the automation that fills it β pick a "
|
| "flow, or make this an ordinary column")
|
| if not ut.flow_bound(bag, st=session.runtime):
|
| return (f"this column names automation {flow!r}, which does not exist in this "
|
| f"workspace β it may have been deleted; pick a flow that is still there")
|
| kind = str((body or {}).get("type") or "").strip()
|
| if kind and kind not in ut.UT_FIELD_TYPES:
|
| return (f"{kind!r} is not a column type here (types: "
|
| f"{', '.join(sorted(ut.UT_FIELD_TYPES))})")
|
| return (f"the column was refused β check the name and type, or the table may be at its "
|
| f"{ut.MAX_FIELDS}-column cap (types: {', '.join(sorted(ut.UT_FIELD_TYPES))})")
|
|
|
|
|
| @router.patch("/tables/{table_key}/fields/{fkey}")
|
| def patch_field(table_key: str, fkey: str, body: dict = Body(default=None),
|
| session: Session = Depends(require_session)):
|
| """Edit one column's definition, and MIGRATE its values when options are renamed.
|
|
|
| β A CHOICE RENAME IS AN EXPLICIT MAPPING, NEVER A DIFF (contract C-RENAME). `{renames:
|
| [{from, to}]}` arrives alongside the new options list, because a diff cannot tell "renamed
|
| Blue to Navy" from "deleted Blue, added Navy" β and guessing wrong empties the column and
|
| every saved view that filtered on it.
|
| """
|
| _field_or_refuse(session, table_key, fkey)
|
| ut = _ut()
|
| body = body or {}
|
| migrated = None
|
| renames = body.get("renames")
|
| if renames:
|
| try:
|
| migrated = ut.rename_choice_values(table_key, fkey, renames, st=session.runtime)
|
| except Exception:
|
| raise err(503, "store_unavailable", "the rename did not land β try again")
|
|
|
|
|
|
|
|
|
| try:
|
| import core.table_store as table_store
|
| fn = getattr(table_store, "rename_choice_values", None)
|
| if callable(fn):
|
| fn(table_key, fkey, renames, st=session.runtime)
|
| migrated = dict(migrated or {}, workspace=True)
|
| except Exception:
|
| migrated = dict(migrated or {}, workspace=False)
|
| field = ut.patch_field(table_key, fkey, body, st=session.runtime)
|
| if not field:
|
|
|
|
|
|
|
|
|
| raise err(400, "refused",
|
| _refusal_sentence(ut, session, body, table_key=table_key, fkey=fkey))
|
| synced = ut.sync_reciprocal_link(table_key, fkey, st=session.runtime)
|
| field = synced.get("field") or field
|
| _refresh_relations(session)
|
| out = {"field": field}
|
| if migrated is not None:
|
| out["migrated"] = migrated
|
| return out
|
|
|
|
|
| @router.delete("/tables/{table_key}/fields/{fkey}")
|
| def delete_field(table_key: str, fkey: str, session: Session = Depends(require_session)):
|
| _field_or_refuse(session, table_key, fkey)
|
| if not _ut().delete_field(table_key, fkey, st=session.runtime):
|
| raise err(400, "refused",
|
| "that column could not be removed β a database must keep at least one")
|
| _refresh_relations(session)
|
| return {"deleted": fkey}
|
|
|
|
|
| @router.delete("/tables/{table_key}/rows/{rid}")
|
| def delete_row(table_key: str, rid: str, session: Session = Depends(require_session)):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| lent = _ut().lend(session.runtime)
|
| _records_or_refuse(session, table_key, st=lent)
|
| try:
|
| ok = _ut().delete_row(table_key, rid, st=lent)
|
| except Exception:
|
| raise err(503, "store_unavailable", "the delete did not land β try again")
|
| if not ok:
|
| raise err(400, "refused", "rows can only be deleted from user-created databases")
|
| _refresh_relations(session)
|
| return {"ok": True}
|
|
|
|
|
| @router.patch("/tables/{table_key}/rows/{pid}")
|
| def patch_row(table_key: str, pid: int, body: dict = Body(default=None),
|
| session: Session = Depends(require_session)):
|
| """Cell edits β the products PATCH on the user-table ctx. Routed through
|
| `core.grid_events.handle_one` so truncation and permission rules stay ONE implementation;
|
| the accepted values are read BACK from the bucket, never echoed from the request."""
|
| from core import grid_events
|
|
|
| updates = dict(body or {})
|
| if not updates:
|
| raise err(400, "empty_patch", "no fields to update")
|
| _records_or_refuse(session, table_key)
|
| g = ut_assembly(session, table_key, consume_corrections=False)
|
| if pid not in g["pids"]:
|
| raise err(403, "out_of_scope", "that row is not in this database")
|
| ctx = grid_events.EventCtx(
|
| uname=session.uname, allowed_pids=g["pids"], fields=g["fields"],
|
| admin=session.admin, fallback_ws=None, seen_ids={}, hidden_keys=frozenset(),
|
| st=session.runtime,
|
| scope_key=table_key, table=_ops(session, table_key))
|
| try:
|
| grid_events.handle_one(
|
| {"id": f"patch:{table_key}:{pid}:{time.time_ns()}", "type": "overlay_patch",
|
| "pid": pid, "updates": updates}, ctx)
|
| except grid_events.StoreUnavailable:
|
| raise err(503, "store_unavailable",
|
| "the tenant store is unavailable β your change was not saved")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| stored = dict(((_ut().get(table_key, st=session.runtime) or {}).get("rows") or {})
|
| .get(str(pid)) or {})
|
| accepted = {k: stored.get(k) for k in updates if k in stored}
|
|
|
| def _took(k):
|
| """Did the cell TAKE this write? Normally that is "stored == asked".
|
|
|
| β A PROFILE COLUMN CANONICALISES, so "stored != asked" is its NORMAL success: `@Nurilab`
|
| and `instagram.com/nurilab` both store `nurilab`. Reporting those as refused would tell
|
| the client to roll back a write that landed. But it cannot simply be exempted either β
|
| a junk handle leaves the OLD value sitting in `stored`, which would then read as
|
| accepted. So the question asked is the exact one: **is what is stored the canonical form
|
| of what was asked?** Anything else is a genuine refusal.
|
| """
|
| if k not in accepted:
|
| return False
|
| want = str(updates[k])
|
| if stored.get(k) == want:
|
| return True
|
| pf = _ut().profile_field(table_key, st=session.runtime)
|
| if pf and pf["key"] == k:
|
| handle, ok = _ut().normalize_profile(want, pf["profile"].get("source"))
|
| return bool(ok) and stored.get(k) == handle
|
| return False
|
|
|
| refused = sorted(k for k in updates if not _took(k))
|
| out = {"pid": pid, "updates": accepted}
|
| if refused:
|
| out["refused"] = refused
|
|
|
|
|
|
|
|
|
|
|
|
|
| also = {k: v for k, v in stored.items() if k not in updates and str(v or "") == ""}
|
| cleared = sorted(k for k in also if k in _ut().PROFILE_PRESET_KEYS)
|
| if cleared:
|
| out["cleared"] = cleared
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| _pf = _ut().profile_field(table_key, st=session.runtime)
|
| if _pf and _pf["key"] in updates:
|
| try:
|
| import automation_engine as _engine
|
| _engine.clear_gone(session.runtime, table_key, stored.get(_pf["key"]))
|
| except Exception as e:
|
| print(f"[aios-api] clear_gone failed: {type(e).__name__}: {e}")
|
| _refresh_relations(session)
|
| return out
|
|
|