| """USER-CREATED TABLES (wave-9 I8 / contract C6) β databases that are not a connector. |
| |
| The owner's "+ New" flow has two branches: connect a SOURCE (Odoo today) or *"create a completely |
| new database blank, where this time the user can actually insert row that they want."* The first |
| branch is the connector seam and lives in `harness/datastore.py`; THIS module is the second. |
| |
| WHY A SEPARATE STORE AND NOT A REGISTRY ROW. `core/registry.py` is a static Python list read at |
| import time β it is the catalogue of modules the PRODUCT ships, and a tenant cannot append to it |
| at runtime without editing code. A user-created table is tenant data, so it lives in the tenant |
| store and is MERGED into the nav beside the registry rows. That also keeps the archived/allowed |
| machinery honest: a user table is never "archived", it is deleted, and it is never in |
| `allowed_modules` because it is not a module. |
| |
| SHAPE (one store key, `user_tables`): |
| |
| {table_key: {'key', 'label', 'source', 'createdBy', 'created', 'fields': [...], 'rows': {...}}} |
| |
| `source` is deliberately NOT 'Odoo' β the nav badge is generated from it, so a blank table reads |
| "Blank" and can never be mistaken for connected data. `rows` is `{row_id: {field_key: value}}`, |
| which is the same overlay shape `TableStore` already uses, so the grid's write path needs no new |
| storage concept. |
| |
| β ROW WRITES ARE ONLY EVER LEGAL HERE. An Odoo-sourced table is READ-ONLY at the source and the |
| row endpoints refuse unless `is_user_table(key)` β a user must not be able to invent a customer |
| in Odoo by typing into a grid. (Wave 18 note: the events seam has NO row event types; row |
| add/delete are REST endpoints on `aios-web/api/routes_tables.py`, and this predicate is their |
| wall.) |
| |
| WAVE 18 (C3-UT): every function takes an optional `st` STORE HANDLE β anything exposing |
| `get(name)` / `update(name, fn, flush=)` / `exists(name)`. Default = `core.store` (tenant #0, |
| the Streamlit host, unchanged). The API passes the session's `TenantRuntime`, whose accessors |
| apply the tenant prefix / repo binding, so a Nurilab table lands in Nurilab's store. |
| """ |
| import datetime as _dt |
| import hashlib |
| import json as _json |
| import re |
|
|
| import core.store as store |
|
|
| STORE_KEY = 'user_tables' |
| MAX_TABLES = 40 |
| MAX_LABEL = 60 |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| MAX_ROWS = 60_000 |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| _CONNECTED = set() |
|
|
|
|
| def register_connected(*table_keys): |
| """Declare table keys as CONNECTED-SOURCE β their rows come from a connector and are served |
| read-through, so this document's ceiling is not a fact about them.""" |
| for key in table_keys: |
| k = str(key or '').strip() |
| if k: |
| _CONNECTED.add(k) |
| return frozenset(_CONNECTED) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _CONNECTED_PREFIXES = {'ut_odoo_'} |
|
|
|
|
| def register_connected_prefix(*prefixes): |
| """Declare a table-key prefix whose automation-sourced, records-locked tables are CONNECTED. |
| |
| The connector layer calls this beside `register_connected` (which names exact keys): the |
| prefix answers for tables this process has not enumerated, the exact keys answer for the ones |
| it has. Returns the whole registered set, so a caller can assert what it just joined. |
| |
| β A PREFIX ALONE NEVER MAKES A TABLE CONNECTED. `is_connected`'s third leg still demands |
| `source == AUTOMATION_SOURCE` and `recordMode == AUTOMATION_RECORD_MODE`, because a person who |
| names a database "Meta ads spend" gets a `ut_meta_β¦`-shaped key too β and exempting it from |
| the ceiling on the strength of its NAME would be exactly the silent hole the fence exists for. |
| """ |
| for prefix in prefixes: |
| p = str(prefix or '').strip() |
| if p: |
| _CONNECTED_PREFIXES.add(p) |
| return connected_prefixes() |
|
|
|
|
| def connected_prefixes(): |
| """The registered key prefixes β the ONE list a caller may read to name the same set. |
| |
| C2's parity gate reads this rather than re-spelling `ut_odoo_` / `ut_meta_` on the far side: |
| a second literal is a second normalizer wearing an assertion's clothes. |
| |
| β AND IT IS THE REGISTRAR'S OWN RETURN VALUE, which is not tidiness β `verify_reachability` |
| went RED the hour this shipped, correctly: a public function whose only callers are `verify_*` |
| files is a feature no user can reach ([[reachable-is-not-the-same-as-built]]), and "another |
| lane's gate will call it" is not a caller. Routing the write door's answer through the read |
| door makes one construction site of the set instead of two, and makes the accessor |
| load-bearing rather than decorative. |
| """ |
| return frozenset(_CONNECTED_PREFIXES) |
|
|
|
|
| def is_connected(table_key, st=None): |
| """Is this database backed by a connected source rather than by typing? |
| |
| THREE ANSWERS, in falling order of authority, and the third is fenced deliberately: |
| 1. the app layer registered it (`register_connected`) β the live path; |
| 2. the stored definition says so (`connected: True`) β survives a process with no registrar; |
| 3. β a REGISTERED KEY PREFIX (`register_connected_prefix`; seeded `ut_odoo_`, and Meta Ads |
| joins it as `ut_meta_`), AND ONLY on a table that is automation-sourced with records |
| locked. A person who names a database "Odoo foo" gets exactly that key, and exempting it |
| from the ceiling on the strength of its name would be a silent hole. Their table is |
| `Blank`-sourced and records-mutable, so it cannot reach this leg. |
| """ |
| key = str(table_key or '').strip() |
| if key in _CONNECTED: |
| return True |
| table = get(key, st) or {} |
| if not table: |
| return False |
| if table.get('connected') is True: |
| return True |
| return (any(key.startswith(p) for p in _CONNECTED_PREFIXES) |
| and table.get('source') == AUTOMATION_SOURCE |
| and table.get('recordMode') == AUTOMATION_RECORD_MODE) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _READ_THROUGH = set() |
|
|
|
|
| def register_read_through(*table_keys): |
| """Declare that these databases are SERVED from the mirror and store no rows here.""" |
| for key in table_keys: |
| k = str(key or '').strip() |
| if k: |
| _READ_THROUGH.add(k) |
| _CONNECTED.add(k) |
| return frozenset(_READ_THROUGH) |
|
|
|
|
| def materialises(table_key, st=None, defn=None): |
| """Does this database keep its rows in the shared `user_tables` document? |
| |
| True for everything except a read-through grid β including every editable table, every |
| automation table, and a connected table whose conversion has not happened yet. |
| |
| β THE STORED FLAG IS NOT A CACHE OF THE REGISTRY, IT IS THE DURABLE HALF, and without it this |
| predicate would be a live hazard rather than a lookup. `_READ_THROUGH` is filled by an app-layer |
| route; a worker process that has never served one would answer True for a table whose rows were |
| stripped weeks ago, read `rows: {}` and serve an EMPTY GRID with nothing going red. So the strip |
| STAMPS the definition and the stamp is what a cold process reads. Same three-tier shape as |
| `is_connected` above, minus the naming convention β there is no safe way to guess this one. |
| |
| β `defn` LETS A CALLER LEND THE DEFINITION IT IS ALREADY HOLDING, and on the row path that is |
| not a micro-optimisation: every `get()` here is another whole-document deep copy under |
| `Store._lock`, so asking this question inside `scoped_pool` without lending would have added a |
| third copy to the very request this ticket exists to make cheaper. |
| """ |
| key = str(table_key or '').strip() |
| if key in _READ_THROUGH: |
| return False |
| if defn is None: |
| defn = get(key, st) or {} |
| return not bool((defn or {}).get('readThrough')) |
|
|
|
|
| def strip_materialised(st=None): |
| """Remove the stored rows of every read-through database. Idempotent; returns what moved. |
| |
| β THIS IS THE HALF THAT MAKES THE INVARIANT TRUE RATHER THAN INTENDED. The spawn |
| (`odoo_relational._ensure_table_inplace`) writes rows by mutating this document inside its own |
| `rt.update(STORE_KEY, β¦)`, so it never passes through any function here β a guard on the write |
| doors below would simply not be on that path. Re-running this after a refresh is therefore the |
| enforcement, and being idempotent is what lets every door call it without coordinating. |
| |
| β The definition STAYS. A read-through table is a real database β its fields, label, views, |
| grants and lock all live here; only the rows are elsewhere. Deleting the definition would take |
| the permission wall and the nav entry with it. |
| """ |
| out = {'dropped': {}, 'before': 0, 'after': 0} |
| if not _READ_THROUGH: |
| return out |
| s = _st(st) |
| try: |
| cur = s.get(STORE_KEY) or {} |
| except Exception: |
| return out |
| fat = {k: len((cur.get(k) or {}).get('rows') or {}) for k in _READ_THROUGH} |
| fat = {k: n for k, n in fat.items() if n} |
| if not fat: |
| return out |
| |
| |
| out['before'] = len(_json.dumps(cur, default=str)) |
|
|
| def _strip(doc): |
| for key in list(_READ_THROUGH): |
| t = (doc or {}).get(key) |
| if isinstance(t, dict) and t.get('rows'): |
| out['dropped'][key] = len(t['rows']) |
| t['rows'] = {} |
| |
| |
| t['readThrough'] = True |
| return doc |
|
|
| s.update(STORE_KEY, _strip, flush='sync') |
| try: |
| out['after'] = len(_json.dumps(s.get(STORE_KEY) or {}, default=str)) |
| except Exception: |
| out['after'] = 0 |
| return out |
|
|
|
|
| def row_limit(table_key, st=None): |
| """The row ceiling that applies to this database: 0, None, or `MAX_ROWS`. |
| |
| ONE evaluator, so the write doors, the spawn's refusal and the wire's report cannot disagree |
| about whether a table is capped ([[one-evaluator-per-question]]). |
| |
| β THE THREE ANSWERS ARE THREE DIFFERENT STATEMENTS AND 0 IS NOT "A VERY SMALL CAP": |
| * **0** β this database stores no rows HERE at all; it is served read-through from the |
| mirror. A caller that builds rows for it is doing wasted, dangerous work: 963,783 GL lines |
| is ~240 MB of python dicts in one process. `odoo_relational.plan` reads this to decide |
| whether to build a bucket at all. |
| * **None** β connected, materialised, and UNCAPPED (R6): however many rows Odoo has. |
| * **MAX_ROWS** β the editable substrate, which really is bounded by this document. |
| """ |
| if not materialises(table_key, st): |
| return 0 |
| return None if is_connected(table_key, st) else MAX_ROWS |
|
|
|
|
| def limit_report(table_key, st=None): |
| """R6's second sentence as data: `{subject, cause, recommendation, effect}` or None. |
| |
| β `effect` is never `truncated`. Every enforcement site REFUSES β a truncated table |
| understates every total it feeds while looking exactly like a complete one, which is the |
| failure this whole module is arranged against. |
| """ |
| cap = row_limit(table_key, st) |
| if cap is None: |
| return None |
| if cap == 0: |
| return { |
| 'subject': 'rows', 'limit': 0, 'effect': 'read_through', |
| 'cause': 'this database is served THROUGH the connector mirror, so its rows are ' |
| 'never copied into this tenant document and no ceiling applies to them', |
| 'recommendation': 'read it with a window (`/odoo-tables/{key}/rows`); the row count ' |
| 'you see there is a SQL count over the whole table', |
| } |
| return { |
| 'subject': 'rows', 'limit': cap, 'effect': 'refused', |
| 'cause': f'this database is EDITABLE, so its rows live in the shared `{STORE_KEY}` ' |
| f'document that every request copies; at the widest shipped row (0.318 KB) ' |
| f'{cap:,} rows is 19.1 MB against a 32 MB per-table budget', |
| 'recommendation': 'connect it to a source instead β connected data is served ' |
| 'read-through from the mirror and is not bounded (R6); for typed data ' |
| 'the increment is D-87, a per-table row key, not a bigger number', |
| } |
|
|
|
|
| MAX_FIELDS = 60 |
| |
| BLANK_SOURCE = 'Blank' |
| |
| AUTOMATION_SOURCE = 'Automation' |
| AUTOMATION_RECORD_MODE = 'automation' |
| |
| KEY_PREFIX = 'ut_' |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _LOCKED_RECORD_KEYS = set() |
|
|
|
|
| def register_locked_records(*keys): |
| """Declare table keys whose records are machine-owned. Idempotent; -> the registered set.""" |
| for k in keys: |
| for one in (k if isinstance(k, (set, frozenset, list, tuple)) else (k,)): |
| if str(one or '').strip(): |
| _LOCKED_RECORD_KEYS.add(str(one).strip()) |
| return frozenset(_LOCKED_RECORD_KEYS) |
|
|
|
|
|
|
| def records_mutable(table_key, st=None): |
| """May a human add/edit/delete records in this database? |
| |
| Ordinary and Profile databases default open. Automation-owned child datasets opt out β by |
| DECLARATION (`register_locked_records`, which needs no write) or by the stored table-level |
| mode; engine writers use their direct coalesced path and are intentionally not routed through |
| this human-door predicate. |
| """ |
| table = get(table_key, st) or {} |
| if not table: |
| return False |
| if str(table_key) in _LOCKED_RECORD_KEYS: |
| return False |
| return table.get('recordMode') != AUTOMATION_RECORD_MODE |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| UT_FIELD_TYPES = {'text', 'select', 'multiselect', 'user', 'int', 'currency', 'pct', 'date', |
| 'checkbox', 'phone', 'email', 'url', 'rating', 'automation', 'json', |
| 'link', 'rollup', 'code', 'image', 'formula'} |
| |
| |
| |
| |
| MAX_JSON_CELL = 32 * 1024 * 1024 |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| PROFILE_SOURCES = ('instagram', 'tiktok') |
|
|
| |
| _IG_HOSTS = ('instagram.com', 'instagr.am') |
|
|
| |
| |
| |
| _IG_RESERVED = frozenset({'p', 'reel', 'reels', 'tv', 'stories', 'explore', 'accounts', |
| 'direct', 'about', 'developer', 'legal', 'privacy', 'terms'}) |
|
|
| |
| _IG_HANDLE_RE = re.compile(r'^[A-Za-z0-9._]{1,30}$') |
|
|
| |
| |
| |
| _TT_HOSTS = ('tiktok.com', 'm.tiktok.com') |
|
|
| |
| |
| _TT_RESERVED = frozenset({'video', 'tag', 'music', 'discover', 'search', 'foryou', 'explore', |
| 'live', 'upload', 'about', 'legal', 'privacy', 'terms', 'effect', |
| 'business', 'ads', 'node', 'login', 'signup'}) |
|
|
| |
| |
| |
| _TT_HANDLE_RE = re.compile(r'^[A-Za-z0-9._]{2,24}$') |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| _PROFILE_RULES = { |
| 'instagram': { |
| 'hosts': _IG_HOSTS, |
| 'reserved': _IG_RESERVED, |
| 'handle': _IG_HANDLE_RE, |
| 'at_handle': False, |
| 'url': 'https://www.instagram.com/{handle}/', |
| }, |
| 'tiktok': { |
| 'hosts': _TT_HOSTS, |
| 'reserved': _TT_RESERVED, |
| 'handle': _TT_HANDLE_RE, |
| 'at_handle': True, |
| 'url': 'https://www.tiktok.com/@{handle}', |
| }, |
| } |
|
|
|
|
| def _profile_rules(source): |
| """The rule bag for a source, or None β the ONE resolution of "is this source real".""" |
| key = str(source or '').strip().lower() |
| if key not in PROFILE_SOURCES: |
| return None |
| return _PROFILE_RULES.get(key) |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| PROFILE_PRESET_KEYS = ( |
| |
| 'full_name', 'followers', 'following', 'posts_count', 'avg_engagement', 'bio', |
| 'external_url', 'verified', 'category', 'business_category', 'is_business', |
| 'is_professional', 'is_private', 'highlights_count', 'bio_hashtags', |
| 'pronouns', 'ig_id', 'profile_url', |
| |
| |
| |
| 'profile_name', 'is_joined_recently', 'has_channel', 'partner_id', 'external_url_title', |
| 'fbid', 'related_accounts', 'country_code', 'source_payload', |
| |
| 'posts_link', 'profile_snapshots_link', 'post_snapshots_link', 'comments_link', |
| 'avg_views_12', 'avg_plays_12', 'avg_likes_12', 'avg_comments_12', |
| 'posts_captured', 'profile_reads', 'post_measurements_captured', 'comments_captured', |
| |
| |
| 'enriched_at', |
| ) |
|
|
|
|
| def _clean_profile(raw): |
| """One `profile` bag β the stored shape, or None (refused). |
| |
| Shaped exactly like `_clean_metric`: a mis-shaped flag REFUSES THE FIELD rather than being |
| dropped, because a column stored without the flag it was created with is a column whose |
| cells nothing will ever validate β the silent half of the same mistake. |
| """ |
| if not isinstance(raw, dict): |
| return None |
| source = str(raw.get('source') or '').strip().lower() |
| if source not in PROFILE_SOURCES: |
| return None |
| return {'source': source} |
|
|
|
|
| def normalize_profile(value, source='instagram'): |
| """A typed profile cell β `(handle, ok)`. THE cell validator the flag promises (C3). |
| |
| Accepts a bare handle (`name`), an at-handle (`@name`) or a profile URL of THIS SOURCE in any |
| of the forms a person actually pastes (`https://www.instagram.com/name/?hl=en`, |
| `instagram.com/name`, `https://www.tiktok.com/@name`). Returns the BARE HANDLE β the URL form |
| is derived for display, so the stored truth has exactly one spelling and a filter on it cannot |
| miss half the column. |
| |
| β C2: the host, the reserved segments and the handle rule all come from `_PROFILE_RULES[source]`. |
| A TikTok URL pasted into an Instagram-flagged column is REFUSED, and vice versa β a cell that |
| names the wrong network is not a handle this row's automation can enrich, and quietly accepting |
| it would defer the failure to a run that then answers nothing. |
| |
| `('', True)` for a blank: clearing a profile cell is legal and is the whole of R6. |
| `(None, False)` for anything else, and the caller refuses the write with a sentence β a |
| value that is neither is not a handle we can enrich, and storing it would put the failure |
| off until the automation runs and answers nothing. |
| """ |
| rules = _profile_rules(source) |
| if rules is None: |
| return (None, False) |
| raw = str(value if value is not None else '').strip() |
| if not raw: |
| return ('', True) |
| cand = raw.lstrip('@') |
| |
| |
| |
| |
| if '/' in cand: |
| |
| |
| probe = cand if '//' in cand else 'https://' + cand.lstrip('/') |
| try: |
| from urllib.parse import urlsplit |
| parts = urlsplit(probe) |
| except Exception: |
| return (None, False) |
| host = (parts.netloc or '').split('@')[-1].split(':')[0].strip().lower() |
| if host.startswith('www.'): |
| host = host[4:] |
| if host in rules['hosts']: |
| segs = [s for s in (parts.path or '').split('/') if s] |
| |
| |
| first = segs[0].lstrip('@') if segs else '' |
| if len(segs) != 1 or not first or first.lower() in rules['reserved']: |
| |
| |
| return (None, False) |
| cand = first |
| elif host: |
| return (None, False) |
| if not rules['handle'].match(cand) or cand.lower() in rules['hosts']: |
| |
| |
| return (None, False) |
| return (cand.lower(), True) |
|
|
|
|
| def profile_url(handle, source='instagram'): |
| """The DERIVED display form of a stored handle (C3: *"the URL form is derived for display"*). |
| Derived and never stored, so the two spellings cannot drift apart. |
| |
| β C2 β the template comes from the SOURCE's own rules. This function used to hardcode |
| `instagram.com` while its guard merely checked membership of `PROFILE_SOURCES`, so the moment |
| that tuple grew a second member it would have handed every TikTok handle an Instagram link β |
| a guard that passes and a body that lies ([[hardcoded-fallback-not-hardcoded-key]]). |
| """ |
| h = str(handle or '').strip().lstrip('@') |
| rules = _profile_rules(source) |
| if not h or rules is None: |
| return '' |
| return rules['url'].format(handle=h) |
|
|
|
|
| def _profile_of(fields, exclude=None): |
| """The profile-flagged field in this list, or None. `exclude` skips one key β a PATCH of the |
| profile field itself must not read as "a second one already exists".""" |
| for f in (fields or []): |
| if (isinstance(f, dict) and isinstance(f.get('profile'), dict) |
| and f.get('key') != exclude): |
| return f |
| return None |
|
|
|
|
| def profile_field(table_key, st=None): |
| """THE profile column of one table, or None. ONE resolver, so "which column holds the |
| handle" has exactly one answer for the write door, the clear, the grid and the engine.""" |
| return _profile_of((get(table_key, st) or {}).get('fields') or []) |
|
|
|
|
| def _st(st): |
| return st if st is not None else store |
|
|
|
|
| |
| |
| |
| |
| |
| |
| ROW_HOOKS = [] |
|
|
|
|
| def emit_row_event(evt): |
| """Fan one row event out to every registered listener. A listener failure is swallowed β |
| a broken trigger must never break typing into a cell β but it is swallowed LOUDLY.""" |
| for hook in list(ROW_HOOKS): |
| try: |
| hook(evt) |
| except Exception as e: |
| print(f'[user-tables] row hook failed: {type(e).__name__}: {e}') |
|
|
|
|
| def _slug(label): |
| s = re.sub(r'[^a-z0-9]+', '_', str(label or '').strip().lower()).strip('_') |
| return (s or 'table')[:40] |
|
|
|
|
| def _ag_formula(raw): |
| """`aios_grid._clean_formula`, reached the way this module reaches everything one layer up. |
| |
| β ONE call site for the function-local import, shared by BOTH field doors (`clean_fields` and |
| `_clean_field`). `core` must not import `aios_grid` at module level β the API's boot path |
| depends on this module staying dependency-light β and the `code` branch already pays that |
| cost twice. A third and fourth copy of `import aios_grid as _agX` is how one of them ends up |
| calling a different validator. |
| |
| β `valid_keys` IS DELIBERATELY NOT PASSED. See the note at `_clean_field`'s formula branch: |
| neither door has reliable sibling-field context, and a second, weaker copy of the ref rule is |
| worse than leaving refs to the read-time behaviour `_clean_formula` already documents. |
| """ |
| import aios_grid as _agf |
| return _agf._clean_formula(raw) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| FORMAT_TZ = ('local', 'utc') |
| FORMAT_MAX_DECIMALS = 4 |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| FIELD_AGGS = ('sum', 'average', 'median', 'min', 'max', 'count') |
|
|
| |
| |
| _OPTION_COLOR_RE = re.compile(r'#[0-9A-F]{6}') |
|
|
|
|
| def _clean_option_colors(raw, options): |
| """`{option label β #RRGGBB}`, capped to the field's own option vocabulary (wave-29 C1). |
| |
| β MIRRORED from `aios_grid._clean_option_colors` as a LOCAL function rather than imported, for |
| the reason `METRIC_MEASURES` and `PROFILE_PRESET_KEYS` are local literals here: `core/` must |
| stay importable without the layers above it, and `aios_grid` sits above this module. The pair |
| is held in step by a gate leg, never by an import. |
| |
| β THE CAP IS THE POINT, not tidiness: colours are keyed by LABEL, so a renamed or deleted |
| option would otherwise leave a colour behind that nothing renders and no editor can reach β |
| and on the next rename that orphan could re-attach to a different option with the same name. |
| Case-insensitive on the way in, canonical on the way out, so `Done` and `done` cannot both |
| claim the same option. |
| """ |
| if not isinstance(raw, dict): |
| return {} |
| supplied = {} |
| for label, color in raw.items(): |
| if not isinstance(label, str) or not isinstance(color, str): |
| continue |
| clean = color.strip().upper() |
| if _OPTION_COLOR_RE.fullmatch(clean): |
| supplied[label.strip().lower()] = clean |
| out = {} |
| for option in options or []: |
| color = supplied.get(str(option).strip().lower()) |
| if color: |
| out[str(option)] = color |
| return out |
|
|
|
|
| def _clean_format(raw): |
| """One `format` bag β the stored shape, or None. |
| |
| ββ THIS FUNCTION EXISTS BECAUSE THE BAG HAD NO STORAGE AT ALL. The column menu has shipped a |
| "Field format" pane (thousands separator, decimal places, abbreviate) since wave 5, and on a |
| `ut_*` database BOTH write doors built their entry dict key-by-key and never copied `format` β |
| so the pane saved, the request succeeded, and the setting was gone by the next read. It looked |
| like a rendering bug and was a persistence one. Same shape as |
| [[flag-shipped-without-its-writer]] from the other end: here the writer exists and the STORE |
| does not. |
| |
| β EVERY KEY IS OPTIONAL AND AN EMPTY BAG IS None, so a field that declares no format is stored |
| exactly as it was before this existed β the wave-5 note's own parity rule. |
| """ |
| if not isinstance(raw, dict): |
| return None |
| out = {} |
| if isinstance(raw.get('thousands'), bool): |
| out['thousands'] = raw['thousands'] |
| if isinstance(raw.get('abbrev'), bool): |
| out['abbrev'] = raw['abbrev'] |
| if isinstance(raw.get('time'), bool): |
| out['time'] = raw['time'] |
| decimals = raw.get('decimals') |
| |
| |
| if isinstance(decimals, int) and not isinstance(decimals, bool): |
| if 0 <= decimals <= FORMAT_MAX_DECIMALS: |
| out['decimals'] = decimals |
| tz = str(raw.get('tz') or '').strip().lower() |
| if tz in FORMAT_TZ: |
| out['tz'] = tz |
| return out or None |
|
|
|
|
| def clean_fields(raw): |
| """Validate a caller-supplied field list into the stored shape β refuse junk, never store |
| it. Returns a list of `{key,label,type,source}` dicts (source is always 'overlay': every |
| base column of a user table is user-editable by construction), or None when nothing |
| survives. Unknown types are DROPPED, not coerced β a column silently retyped is a lie.""" |
| out, seen = [], set() |
| for f in (raw or [])[:MAX_FIELDS]: |
| if not isinstance(f, dict): |
| continue |
| label = ' '.join(str(f.get('label') or f.get('key') or '').split())[:80] |
| key = re.sub(r'[^a-z0-9_]+', '_', str(f.get('key') or _slug(label)).strip().lower()) |
| key = key.strip('_')[:60] |
| ftype = str(f.get('type') or 'text').strip().lower() |
| if not key or key in seen or ftype not in UT_FIELD_TYPES or not label: |
| continue |
| seen.add(key) |
| entry = {'key': key, 'label': label, 'type': ftype, 'source': 'overlay'} |
| |
| |
| |
| fmt = _clean_format(f.get('format')) |
| if fmt: |
| entry['format'] = fmt |
| description = ' '.join(str(f.get('description') or '').split())[:300] |
| if description: |
| entry['description'] = description |
| if ftype in ('select', 'multiselect'): |
| opts = [' '.join(str(o).split())[:60] for o in (f.get('options') or []) |
| if str(o).strip()][:50] |
| if opts: |
| entry['options'] = opts |
| if f.get('default') is True: |
| entry['default'] = True |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if f.get('pinned') is True: |
| entry['pinned'] = True |
| |
| |
| |
| |
| if ftype == 'code' and f.get('code') is not None: |
| import aios_grid as _agc |
| c = _agc._clean_code(f.get('code')) |
| if c: |
| entry['code'] = c |
| |
| |
| |
| |
| |
| |
| if f.get('profile') is not None: |
| p = _clean_profile(f.get('profile')) |
| if p is None or ftype != 'text' or _profile_of(out): |
| continue |
| entry['profile'] = p |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| for bag_key, cleaner in (('link', _clean_link), ('rollup', _clean_rollup)): |
| bag = cleaner(f.get(bag_key)) if f.get(bag_key) is not None else None |
| if ftype == bag_key and bag is None: |
| entry = None |
| break |
| if bag is not None: |
| if ftype != bag_key: |
| entry = None |
| break |
| entry[bag_key] = bag |
| |
| |
| |
| |
| if entry is not None: |
| fx = (_ag_formula(f.get('formula')) if f.get('formula') is not None else None) |
| if (ftype == 'formula') != (fx is not None): |
| entry = None |
| elif fx is not None: |
| entry['formula'] = fx |
| if entry is None: |
| seen.discard(key) |
| continue |
| |
| |
| |
| agg_in = str(f.get('agg') or '').strip() |
| if agg_in in FIELD_AGGS: |
| entry['agg'] = agg_in |
| out.append(entry) |
| return out or None |
|
|
|
|
| def all_tables(st=None): |
| """{key: definition} β every user-created table in this tenant. |
| |
| β THIS READS THE ROWS TOO, and on tenant #0 that is 28.6 MB / ~703 ms per call. Callers that |
| only need DEFINITIONS (labels, `createdBy`, `recordMode`, `fields`) want `all_defs` below. |
| """ |
| try: |
| return dict(_st(st).get(STORE_KEY) or {}) |
| except Exception: |
| return {} |
|
|
|
|
| |
| |
| DEF_ONLY_DROPS = ('rows',) |
|
|
|
|
| def all_defs(st=None): |
| """{key: definition WITHOUT `rows`} β the same listing, for readers that never open a row. |
| |
| ββ W32-T01 (D-185). `all_tables` is the whole 28.6 MB deep copy; this is the same answer for |
| every definition key at ~0.1% of the bytes. `/nav` is the caller that made it worth building: |
| `nav_entries`, `may_open` and the manage/canDelete/locked loop read `label`, `source`, |
| `createdBy` and `recordMode` β and nothing else. |
| |
| β **A DROPPED KEY RAISES, IT DOES NOT READ AS EMPTY.** `defn.get('rows')` on one of these |
| raises `KeyError` naming the projection, because the alternative β `{}` β is indistinguishable |
| from a database that genuinely has no rows, and the caller least able to notice is the one that |
| wanted rows. If you get that error you wanted `all_tables`. |
| |
| β Falls back to `all_tables` on ANY failure, including a store handle too old to project (a |
| fake store installed by a gate, a `_Lent` handle serving a document somebody else read). The |
| fallback is CORRECT-but-slow by construction β it can only ever return MORE than was asked for |
| β which is the one direction a fallback here is allowed to be wrong in. |
| """ |
| try: |
| return dict(_st(st).get_projection(STORE_KEY, drop=DEF_ONLY_DROPS) or {}) |
| except AttributeError: |
| return all_tables(st) |
| except Exception: |
| return {} |
|
|
|
|
| def get(table_key, st=None): |
| return all_tables(st).get(str(table_key)) |
|
|
|
|
| def is_user_table(table_key, st=None): |
| """THE permission predicate for row writes. Cheap and total: a key that is not in this store |
| is not a user table, so it cannot accept invented rows.""" |
| return str(table_key or '').startswith(KEY_PREFIX) and get(table_key, st) is not None |
|
|
|
|
| def create(label, username, fields=None, source=None, st=None): |
| """Create a blank table. Returns its key, or None if refused. |
| |
| Refuses rather than raises on the ordinary conditions (no name, duplicate, cap reached) β |
| the caller is a UI form and a refusal is a message, not an exception. |
| """ |
| label = str(label or '').strip()[:MAX_LABEL] |
| if not label: |
| return None |
| existing = all_tables(st) |
| if len(existing) >= MAX_TABLES: |
| return None |
| key = KEY_PREFIX + _slug(label) |
| if key in existing: |
| for n in range(2, 50): |
| if f'{key}_{n}' not in existing: |
| key = f'{key}_{n}' |
| break |
| else: |
| return None |
| |
| |
| |
| defn = { |
| 'key': key, |
| 'label': label, |
| 'source': (source if source in (BLANK_SOURCE, AUTOMATION_SOURCE) else BLANK_SOURCE), |
| 'createdBy': username, |
| 'created': _dt.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), |
| 'fields': clean_fields(fields) or [{'key': 'name', 'label': 'Name', 'type': 'text', |
| 'source': 'overlay', 'default': True}], |
| 'rows': {}, |
| } |
|
|
| def _add(cur): |
| cur[key] = defn |
| return cur |
|
|
| _st(st).update(STORE_KEY, _add, flush='sync') |
| return key |
|
|
|
|
| def delete(table_key, st=None): |
| """Delete a user table AND its artifact families (wave 21, item 6a / C3). |
| |
| Wave 20 popped the definition alone and booked ten orphan families; the owner then ruled a |
| real Delete (R3) with the footprint disclosed in the confirm dialog first, so the cleanup |
| is no longer optional. Cleaned here, in one place: definition (`user_tables`) Β· the |
| workspace bucket (`<key>_table_workspace`, every stratum incl. `__shared__` β views, |
| custom fields, overlays, folders) Β· cohorts (`<key>_cohorts`) Β· record comments |
| (`<key>_record_comments`) Β· docs metadata (`<key>_docs`) AND the dataset BYTES each |
| metadata row names (D-36, closed wave 23 β wave 21 left those unreachable-but-present) Β· |
| `nav_meta[<key>]` Β· share grants (the database grant plus a view grant per view that |
| lived in this bucket, via `shares.drop_objects`) Β· alert definitions on this topic. |
| Bound AUTOMATIONS are the API layer's to disable (platform must not import the engine). |
| Every family after the definition is best-effort: a store blip mid-sweep must not resurrect |
| the table, and an orphaned empty bucket is residue, not a leak.""" |
| key = str(table_key) |
| s = _st(st) |
|
|
| def _drop(cur): |
| cur.pop(key, None) |
| return cur |
| s.update(STORE_KEY, _drop, flush='sync') |
|
|
| ws_key = f'{key}_table_workspace' |
| view_ids = set() |
| try: |
| bucket = s.get(ws_key) or {} |
| for _u, ws in bucket.items(): |
| if isinstance(ws, dict): |
| view_ids |= set((ws.get('views') or {}).keys()) |
| except Exception: |
| pass |
| |
| |
| |
| |
| |
| |
| |
| |
| doc_paths = [] |
| try: |
| for _pid, _rows in (s.get(f'{key}_docs') or {}).items(): |
| for _doc in (_rows or []): |
| p = str((_doc or {}).get('path') or '') |
| if p: |
| doc_paths.append(p) |
| except Exception: |
| pass |
| |
| |
| |
| |
| |
| |
| try: |
| import core.shared_overlay as _so |
| shared_key = _so.bucket(key) |
| except Exception: |
| shared_key = f'{key}__shared' |
| for b in (ws_key, f'{key}_cohorts', f'{key}_record_comments', f'{key}_docs', shared_key): |
| try: |
| s.update(b, lambda cur: {}, flush='async') |
| except Exception: |
| pass |
| for p in doc_paths: |
| try: |
| import core.store as _store_bytes |
| _store_bytes.delete_path(p) |
| except Exception: |
| pass |
|
|
| def _drop_meta(cur): |
| if isinstance(cur, dict): |
| cur.pop(key, None) |
| return cur |
| try: |
| s.update('nav_meta', _drop_meta, flush='async') |
| except Exception: |
| pass |
| try: |
| import core.shares as shares |
| shares.drop_objects([('database', key)] + [('view', v) for v in view_ids], st=s) |
| except Exception: |
| pass |
| try: |
| import core.alerts as alerts |
| alerts.drop_topic(key, st=s) |
| except Exception: |
| pass |
|
|
|
|
| def set_fields(table_key, fields, st=None): |
| """Replace a table's base-field contract (wave 18 β the automation engine grows tables it |
| creates). Cleaned with the same validator as `create`; refuses to leave a table fieldless.""" |
| clean = clean_fields(fields) |
| if not clean or not is_user_table(table_key, st): |
| return False |
|
|
| def _set(cur): |
| t = cur.get(str(table_key)) |
| if t is not None: |
| t['fields'] = clean |
| return cur |
|
|
| _st(st).update(STORE_KEY, _set, flush='sync') |
| return True |
|
|
|
|
| def add_row(table_key, values=None, username=None, st=None, rid=None): |
| """Append one row. Returns the new row id, or None if refused. |
| |
| `rid` RESTORES A ROW UNDER ITS OLD ID (contract C-ADDROW / C-UNDO). Undo has to put a deleted |
| row back where it was: a restore under a fresh id would break every cohort, comment and view |
| filter that named the original, so the id is part of what is being undone. It is honoured |
| only when that id is FREE β an undo can never overwrite a row somebody has since created in |
| the gap, and it never invents a non-numeric id, because `scoped_pool` reads row ids as ints. |
| """ |
| if not is_user_table(table_key, st) or not records_mutable(table_key, st): |
| return None |
| defn = get(table_key, st) or {} |
| rows = defn.get('rows') or {} |
| |
| |
| |
| cap = row_limit(table_key, st) |
| if cap is not None and len(rows) >= cap: |
| return None |
| want = str(rid or '').strip() |
| if want and want.isdigit() and want not in rows: |
| rid = want |
| else: |
| rid = str(max((int(r) for r in rows if str(r).isdigit()), default=0) + 1) |
| valid = {f['key'] for f in (defn.get('fields') or [])} |
| clean = {k: str(v) for k, v in (values or {}).items() if k in valid} |
| |
| |
| |
| |
| pf = _profile_of(defn.get('fields') or []) |
| if pf and pf['key'] in clean: |
| handle, ok = normalize_profile(clean[pf['key']], pf['profile'].get('source')) |
| if not ok: |
| return None |
| clean[pf['key']] = handle |
|
|
| def _add(cur): |
| t = cur.get(str(table_key)) |
| if t is not None: |
| t.setdefault('rows', {})[rid] = clean |
| return cur |
|
|
| _st(st).update(STORE_KEY, _add, flush='async') |
| |
| |
| emit_row_event({'type': 'record_created', 'table': str(table_key), 'rowId': rid, |
| 'st': _st(st), 'user': str(username or '')}) |
| return rid |
|
|
|
|
| def add_rows(table_key, rows_in, username=None, st=None): |
| """ββ WAVE-29 T25 (owner item 6) β APPEND MANY ROWS IN ONE STORE WRITE. Returns the list of |
| new row ids, or None if the table refuses records at all. |
| |
| β WHY THIS EXISTS RATHER THAN A LOOP OVER `add_row`. Every `add_row` is a read-modify-write of |
| the WHOLE table document plus a row event; importing a 2,000-row spreadsheet that way is 2,000 |
| full-document copies under one lock, which is the same shape as the 1.4 s-per-bucket problem |
| item 20 exists to avoid β on the one uvicorn process this product runs. One `update()` writes |
| them all. |
| |
| β AND WHY NOT `automation_engine.upsert_rows`, which this module's own note (below) says to |
| reuse: that function is UPSERT-BY-KEY shaped, and v1 of the import door is deliberately |
| APPEND-ONLY (no key to merge on, no silent overwrite of a row somebody edited). It also lives |
| in the API layer, and `core/` may not import upward. When the import door grows an "update |
| matching rows" mode, the engine's is the one to lift β the note stands, it just is not this. |
| |
| β THE CAP IS CHECKED AGAINST THE WHOLE BATCH, not per row: a partial import that stops at |
| `MAX_ROWS` leaves the user reconciling a spreadsheet against a table, which is exactly what |
| the refusal-with-a-sentence exists to prevent. Nothing is written if the batch does not fit. |
| |
| β Ids continue the table's own sequence, and a PROFILE column validates every cell exactly as |
| `add_row` does β a row born with an unusable handle is a row the enrich action can never |
| answer, and the failure would otherwise surface as an automation quietly returning nothing. |
| """ |
| if not is_user_table(table_key, st) or not records_mutable(table_key, st): |
| return None |
| batch = [dict(r) for r in (rows_in or []) if isinstance(r, dict)] |
| if not batch: |
| return [] |
| defn = get(table_key, st) or {} |
| rows = defn.get('rows') or {} |
| cap = row_limit(table_key, st) |
| if cap is not None and len(rows) + len(batch) > cap: |
| return None |
| valid = {f['key'] for f in (defn.get('fields') or [])} |
| pf = _profile_of(defn.get('fields') or []) |
| next_id = max((int(r) for r in rows if str(r).isdigit()), default=0) |
| made = [] |
| for values in batch: |
| clean = {k: str(v) for k, v in values.items() if k in valid} |
| if pf and pf['key'] in clean: |
| handle, ok = normalize_profile(clean[pf['key']], pf['profile'].get('source')) |
| if not ok: |
| return None |
| clean[pf['key']] = handle |
| next_id += 1 |
| made.append((str(next_id), clean)) |
|
|
| def _add(cur): |
| t = cur.get(str(table_key)) |
| if t is not None: |
| bucket = t.setdefault('rows', {}) |
| for rid, clean in made: |
| bucket[rid] = clean |
| return cur |
|
|
| _st(st).update(STORE_KEY, _add, flush='sync') |
| |
| |
| |
| |
| for rid, _clean in made: |
| emit_row_event({'type': 'record_created', 'table': str(table_key), 'rowId': rid, |
| 'st': _st(st), 'user': str(username or '')}) |
| return [rid for rid, _ in made] |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| def set_label(table_key, label, st=None): |
| """Rename a table IN ITS DEFINITION. Returns the stored label, or None if refused. |
| |
| β WAVE 20, item 6a β THE RENAME USED TO WRITE ONLY `nav_meta`. That bucket is the nav's |
| display layer, so the rail showed the new name while everything reading the DEFINITION β |
| the automation editor's database picker above all β went on showing the old one. A rename |
| that only some surfaces can see is worse than no rename: the picker was not stale-looking, |
| it was confidently wrong, and a user choosing "Influencers" there could be choosing the |
| table they had renamed to something else months earlier. |
| """ |
| label = ' '.join(str(label or '').split())[:MAX_LABEL] |
| if not label or not is_user_table(table_key, st): |
| return None |
|
|
| def _set(cur): |
| t = cur.get(str(table_key)) |
| if t is not None: |
| t['label'] = label |
| return cur |
|
|
| _st(st).update(STORE_KEY, _set, flush='sync') |
| return label |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| FIELD_EDIT_ROLES = ('admins', 'everyone') |
|
|
| |
| |
| |
| METRIC_MEASURES = ('followers', 'avg_engagement', 'likes', 'comments') |
| METRIC_WINDOWS = ('latest', 'last_3_posts', 'last_7d', 'last_30d') |
| METRIC_PROFILE_MEASURES = ('followers', 'avg_engagement') |
| METRIC_AGGS = ('avg', 'sum', 'latest') |
|
|
|
|
| def _clean_metric(raw): |
| """One `metric` bag β the stored shape, or None (refused). A profile count over a |
| post-count window β or a SUM of a follower count β is a question the series cannot |
| answer; refused at write, never bent into a number that looks plausible (C7).""" |
| if not isinstance(raw, dict): |
| return None |
| measure = str(raw.get('measure') or '').strip() |
| window = str(raw.get('window') or '').strip() |
| agg = str(raw.get('agg') or '').strip() |
| if measure not in METRIC_MEASURES or window not in METRIC_WINDOWS: |
| return None |
| if measure in METRIC_PROFILE_MEASURES and (window == 'last_3_posts' or agg == 'sum'): |
| return None |
| if agg and agg not in METRIC_AGGS: |
| return None |
| out = {'source': 'ig', 'measure': measure, 'window': window} |
| if agg: |
| out['agg'] = agg |
| return out |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| LINK_MAX_IDS = 500 |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| ROLLUP_FNS = ('sum', 'average', 'stdev', 'min', 'max', 'latest', 'count', 'counta', 'countall', |
| 'and', 'or', 'xor', 'concatenate', 'arrayjoin', 'arraycompact', 'arrayunique') |
| ROLLUP_SORT_DIRS = ('asc', 'desc') |
| ROLLUP_CONDITION_OPS = ('eq', 'neq', 'contains', 'not_contains', 'is_empty', 'is_not_empty', |
| 'gt', 'gte', 'lt', 'lte') |
| ROLLUP_CONDITION_CONJ = ('and', 'or') |
| ROLLUP_MAX_CONDITIONS = 20 |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| ROLLUP_REF_OPS = ('gt', 'gte', 'lt', 'lte') |
| |
| |
| |
| |
| ROLLUP_MAX_SIGMAS = 10.0 |
|
|
| |
| |
| |
| |
| |
| |
| |
| ROLLUP_SOURCE_WINDOWS = ( |
| 'all_time', 'today', 'yesterday', 'this_week', 'last_week', 'this_month', 'last_month', |
| 'this_quarter', 'last_quarter', 'this_year', 'last_year', 'ytd', 'ytd_last_year', |
| 'ltm', 'past_week', 'past_month', 'past_year', |
| ) |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| ROLLUP_MAX_LIMIT = MAX_ROWS |
|
|
|
|
| def _field_key(raw): |
| """A field/table key as this module spells them, or '' β the same normalisation |
| `_clean_field` applies to `key`, so a bag can never name a key the field layer would store |
| differently.""" |
| k = re.sub(r'[^a-z0-9_]+', '_', str(raw or '').strip().lower()).strip('_')[:60] |
| return k |
|
|
|
|
| def _clean_link(raw): |
| """One `link` bag β the stored shape, or None (refused). |
| |
| Same posture as `_clean_metric`: a mis-shaped bag REFUSES THE FIELD. A link column stored |
| without a usable target is a column promising a relation that nothing can ever resolve. |
| """ |
| if not isinstance(raw, dict): |
| return None |
| table = _field_key(raw.get('table')) |
| |
| |
| |
| if not table.startswith(KEY_PREFIX) or len(table) <= len(KEY_PREFIX): |
| return None |
| out = {'table': table} |
| on = _field_key(raw.get('on')) |
| frm = _field_key(raw.get('from')) |
| inverse = _field_key(raw.get('inverse')) |
| reciprocal = _field_key(raw.get('reciprocal')) |
| if inverse: |
| |
| |
| if on or frm: |
| return None |
| out['inverse'] = inverse |
| elif on: |
| out['on'] = on |
| |
| |
| |
| if frm: |
| out['from'] = frm |
| elif frm: |
| |
| |
| |
| return None |
| if reciprocal: |
| out['reciprocal'] = reciprocal |
| if raw.get('single') is True: |
| out['single'] = True |
| return out |
|
|
|
|
| def _clean_rollup(raw): |
| """One `rollup` bag β the stored shape, or None (refused). |
| |
| β `limit` + `sortBy` ARE THE DELIBERATE SUPERSET OF AIRTABLE, and they are the owner's |
| headline: *"average Views over last N posts"*. Airtable's rollup conditions filter by |
| PREDICATE, never by RANK, so "the last 12" is not expressible there at all. |
| |
| β `limit` REQUIRES `sortBy`, and that refusal is the whole care in this function. "The last |
| N" with no declared order is not a measurement β it is whichever N rows happen to sit first |
| in the store, which for an append table is capture order and for a merged one is nothing at |
| all. A wrong number that looks right is the failure this module refuses everywhere else. |
| """ |
| if not isinstance(raw, dict): |
| return None |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| src = raw.get('source') |
| if isinstance(src, dict): |
| topic = _field_key(src.get('topic')) |
| measure = _field_key(src.get('measure')) |
| group_by = _field_key(src.get('groupBy')) |
| on = _field_key(src.get('on')) |
| if not (topic and measure and group_by and on): |
| return None |
| bag = {'topic': topic, 'measure': measure, 'groupBy': group_by, 'on': on} |
| window = str(src.get('window') or '').strip().lower() |
| if window: |
| if window not in ROLLUP_SOURCE_WINDOWS: |
| return None |
| bag['window'] = window |
| return {'source': bag} |
| link = _field_key(raw.get('link')) |
| field = _field_key(raw.get('field')) |
| fn = str(raw.get('fn') or '').strip().lower() |
| if not link or fn not in ROLLUP_FNS: |
| return None |
| |
| if not field and fn != 'countall': |
| return None |
| out = {'link': link, 'fn': fn} |
| if field: |
| out['field'] = field |
| try: |
| limit = int(str(raw.get('limit') or '0').strip() or 0) |
| except (TypeError, ValueError): |
| return None |
| sort_by = _field_key(raw.get('sortBy')) |
| distinct_by = _field_key(raw.get('distinctBy')) |
| sort_dir = str(raw.get('sortDir') or '').strip().lower() |
| if limit < 0 or limit > ROLLUP_MAX_LIMIT: |
| return None |
| if (limit or fn == 'latest') and not sort_by: |
| return None |
| if sort_dir and sort_dir not in ROLLUP_SORT_DIRS: |
| return None |
| if sort_by: |
| out['sortBy'] = sort_by |
| |
| |
| |
| out['sortDir'] = sort_dir or 'desc' |
| if limit: |
| out['limit'] = limit |
| elif sort_dir: |
| return None |
| if distinct_by: |
| |
| |
| |
| out['distinctBy'] = distinct_by |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| for bag_key, conj_key, allow_ref in (('where', 'whereConj', False), |
| ('conditions', 'conditionConj', True)): |
| conditions = [] |
| raw_conditions = raw.get(bag_key) or [] |
| if not isinstance(raw_conditions, list) or len(raw_conditions) > ROLLUP_MAX_CONDITIONS: |
| return None |
| for condition in raw_conditions: |
| if not isinstance(condition, dict): |
| return None |
| condition_field = _field_key(condition.get('field')) |
| op = str(condition.get('op') or '').strip().lower() |
| if not condition_field or op not in ROLLUP_CONDITION_OPS: |
| return None |
| item = {'field': condition_field, 'op': op} |
| if op not in ('is_empty', 'is_not_empty'): |
| ref = condition.get('ref') |
| if ref is not None: |
| |
| |
| |
| |
| |
| |
| |
| if not allow_ref: |
| return None |
| if not isinstance(ref, dict) or condition.get('value') is not None: |
| return None |
| if op not in ROLLUP_REF_OPS: |
| return None |
| sigmas = ref.get('sigmas') |
| |
| |
| |
| if isinstance(sigmas, bool) or not isinstance(sigmas, (int, float)): |
| return None |
| sigmas = float(sigmas) |
| if not (-ROLLUP_MAX_SIGMAS <= sigmas <= ROLLUP_MAX_SIGMAS): |
| return None |
| item['ref'] = {'sigmas': sigmas} |
| else: |
| value = condition.get('value') |
| if not isinstance(value, (str, int, float, bool)): |
| return None |
| item['value'] = str(value)[:1000] |
| conditions.append(item) |
| if conditions: |
| conj = str(raw.get(conj_key) or 'and').strip().lower() |
| if conj not in ROLLUP_CONDITION_CONJ: |
| return None |
| out[bag_key] = conditions |
| out[conj_key] = conj |
| return out |
|
|
|
|
| def _clean_field(raw, previous=None): |
| """One field dict β the stored shape, or None. The single validator for create AND patch, so |
| a column cannot be typed one way on the way in and another on the way back.""" |
| prev = previous or {} |
| raw = raw if isinstance(raw, dict) else {} |
| label = ' '.join(str(raw.get('label') or prev.get('label') or '').split())[:80] |
| key = re.sub(r'[^a-z0-9_]+', '_', |
| str(raw.get('key') or prev.get('key') or _slug(label)).strip().lower()) |
| key = key.strip('_')[:60] |
| ftype = str(raw.get('type') or prev.get('type') or 'text').strip().lower() |
| if not key or not label or ftype not in UT_FIELD_TYPES: |
| return None |
| out = {'key': key, 'label': label, 'type': ftype, 'source': 'overlay'} |
| |
| |
| |
| |
| fmt = _clean_format(raw.get('format') if 'format' in raw else prev.get('format')) |
| if fmt: |
| out['format'] = fmt |
| |
| |
| |
| |
| description_raw = (raw.get('description') if 'description' in raw |
| else prev.get('description')) |
| description = ' '.join(str(description_raw or '').split())[:300] |
| if description: |
| out['description'] = description |
| opts_raw = raw.get('options') if 'options' in raw else prev.get('options') |
| if ftype in ('select', 'multiselect'): |
| opts = [' '.join(str(o).split())[:60] for o in (opts_raw or []) if str(o).strip()][:50] |
| if opts: |
| out['options'] = opts |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if ftype in ('select', 'multiselect'): |
| colors_raw = (raw.get('optionColors') if 'optionColors' in raw |
| else prev.get('optionColors')) |
| colors = _clean_option_colors(colors_raw, out.get('options') or []) |
| if colors: |
| out['optionColors'] = colors |
| color_code = (raw.get('colorCodeOptions') if 'colorCodeOptions' in raw |
| else prev.get('colorCodeOptions')) |
| if isinstance(color_code, bool): |
| out['colorCodeOptions'] = color_code |
| |
| |
| |
| |
| |
| if ftype == 'rating': |
| max_raw = raw.get('max') if 'max' in raw else prev.get('max') |
| if max_raw is not None: |
| try: |
| out['max'] = max(2, min(int(max_raw), 10)) |
| except (TypeError, ValueError): |
| pass |
| role = str(raw.get('editRole') or prev.get('editRole') or 'admins').strip().lower() |
| out['editRole'] = role if role in FIELD_EDIT_ROLES else 'admins' |
| if prev.get('default') is True or raw.get('default') is True: |
| out['default'] = True |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if prev.get('pinned') is True or raw.get('pinned') is True: |
| out['pinned'] = True |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| agg_name = str((raw.get('agg') if 'agg' in raw else prev.get('agg')) or '').strip() |
| if agg_name in FIELD_AGGS: |
| out['agg'] = agg_name |
| |
| |
| auto = raw.get('automation') if 'automation' in raw else prev.get('automation') |
| if isinstance(auto, dict): |
| out['automation'] = auto |
| |
| |
| metric_raw = raw.get('metric') if 'metric' in raw else prev.get('metric') |
| if metric_raw is not None: |
| m = _clean_metric(metric_raw) |
| if m is None: |
| return None |
| out['metric'] = m |
| |
| |
| |
| |
| |
| profile_raw = raw.get('profile') if 'profile' in raw else prev.get('profile') |
| if profile_raw is not None: |
| p = _clean_profile(profile_raw) |
| if p is None or ftype != 'text': |
| return None |
| out['profile'] = p |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| prev_type = str(prev.get('type') or '').strip().lower() |
| link_raw = (raw.get('link') if 'link' in raw |
| else (prev.get('link') if prev_type == ftype else None)) |
| if link_raw is not None: |
| lk = _clean_link(link_raw) |
| if lk is None or ftype != 'link': |
| return None |
| out['link'] = lk |
| elif ftype == 'link': |
| return None |
| rollup_raw = (raw.get('rollup') if 'rollup' in raw |
| else (prev.get('rollup') if prev_type == ftype else None)) |
| if rollup_raw is not None: |
| rl = _clean_rollup(rollup_raw) |
| if rl is None or ftype != 'rollup': |
| return None |
| out['rollup'] = rl |
| elif ftype == 'rollup': |
| return None |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| code_raw = raw.get('code') if 'code' in raw else prev.get('code') |
| if code_raw is not None and ftype == 'code': |
| import aios_grid as _agc |
| c = _agc._clean_code(code_raw) |
| if c: |
| out['code'] = c |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| formula_raw = (raw.get('formula') if 'formula' in raw |
| else (prev.get('formula') if prev_type == ftype else None)) |
| if formula_raw is not None: |
| fx = _ag_formula(formula_raw) |
| if fx is None or ftype != 'formula': |
| return None |
| out['formula'] = fx |
| elif ftype == 'formula': |
| return None |
| return out |
|
|
|
|
| def is_derived_link(field): |
| """Is this a link whose cell the ENGINE owns? (an `on` join, not a user-picked set) |
| |
| β ONE definition, read by the write wall, the compute pass and the wire. The alternative is |
| three places independently asking "does the bag have an `on`", which is how one of them |
| silently keeps taking writes after the other two stop. |
| """ |
| lk = (field or {}).get('link') |
| return isinstance(lk, dict) and bool(lk.get('on') or lk.get('inverse')) |
|
|
|
|
| def is_computed_cell(field): |
| """Is this field's cell computed by the SERVER, so a human write is an invented value? |
| |
| The `ut_*` twin of `aios_grid.READONLY_CUSTOM_TYPES`, and deliberately a PREDICATE rather |
| than a type set: a `link` is read-only when it is derived and editable when it is not, which |
| a set of type names cannot express. `grid_events` is the wall that reads it. |
| |
| ββ 2026-08-10 β `formula` JOINS, and it is the one member here whose value the server does |
| NOT compute. Every other member is refused because the server owns the number; a formula cell |
| is refused because NOBODY owns a stored number β the browser recomputes the column on every |
| paint from the row's other cells, so a value written here is overwritten on screen before it |
| is ever read and sits in the store as a fossil no reader agrees with. The client already |
| declines to offer an editor (`READONLY_CELL_TYPES`) and declines the paste |
| (`coerceClipboardValue`); both are courtesy, and this is the wall |
| ([[schema-role-is-not-a-value-wall]] β a courtesy in front of no wall is the shape that |
| keeps shipping). |
| """ |
| f = field or {} |
| return (isinstance(f.get('metric'), dict) or f.get('type') in ('rollup', 'formula') |
| or is_derived_link(f)) |
|
|
|
|
| |
| |
| _NUMERIC_CELL_TYPES = ('int', 'currency', 'pct') |
| _DATE_CELL_TYPES = ('date',) |
|
|
|
|
| def cell_type_refusal(field, value): |
| """Why this value cannot go in this column, or None if it can. β REFUSES; never rewrites. |
| |
| ββ W29-T81 β THE SERVER-SIDE HALF of "can this text be this field's value". Until now the |
| only evaluator was `coerceClipboardValue`, which lives in the BROWSER β so the import door |
| took `{"qa_count": "seventeen-ish"}` at an `int` column, answered `201 {"imported": 1}`, and |
| stored it verbatim. Measured against production on `bac40c2`. A rule enforced only by the |
| client is a rule that holds for exactly one client ([[one-evaluator-per-question]]). |
| |
| β DELIBERATELY NARROWER THAN THE CLIENT'S COERCER, and it must stay that way: this asks only |
| whether a NUMBER is a number and a DATE is a date, the two families where an unusable string |
| is later rendered as a fabricated figure. It accepts everything the client's coercer emits |
| (canonical ISO dates, plain decimals) plus the human spellings that coercer also takes |
| ("$1,234.50"), so no UI path can trip a wall the UI cannot see. Choice vocabularies stay |
| client-side for now: their options change under a stored row, and refusing an import against |
| a vocabulary edited yesterday would reject data that is merely out of date. |
| |
| β BLANK IS ALWAYS ACCEPTABLE. An empty cell is an empty cell in every column; the import |
| plan already omits them, and refusing one would make a ragged spreadsheet unimportable. |
| """ |
| f = field or {} |
| ftype = f.get('type') |
| text = '' if value is None else str(value).strip() |
| if not text: |
| return None |
| label = f.get('label') or f.get('key') or 'that column' |
| if ftype in _NUMERIC_CELL_TYPES: |
| try: |
| float(text.replace('$', '').replace(',', '').replace('%', '').replace(' ', '')) |
| except (TypeError, ValueError): |
| return f'{text[:40]!r} is not a number, and {label!r} holds numbers' |
| return None |
| if ftype == 'rating': |
| try: |
| n = int(text) |
| except (TypeError, ValueError): |
| return f'{text[:40]!r} is not a whole number, and {label!r} holds a rating' |
| top = f.get('max') if isinstance(f.get('max'), int) and f.get('max') else 5 |
| if not 1 <= n <= top: |
| return f'{label!r} takes a rating from 1 to {top}, and {text[:40]!r} is outside it' |
| return None |
| if ftype in _DATE_CELL_TYPES: |
| |
| |
| |
| if not re.match(r'^\d{4}-\d{2}-\d{2}', text): |
| return (f'{text[:40]!r} is not a date, and {label!r} holds dates ' |
| f'(write it as YYYY-MM-DD)') |
| return None |
| return None |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| USER_EDITED_KEY = 'userEdited' |
|
|
|
|
| def user_edited(field): |
| """Has a human taken over this preset column's DEFINITION? (the reconcilers' skip signal)""" |
| auto = (field or {}).get('automation') |
| return isinstance(auto, dict) and auto.get(USER_EDITED_KEY) is True |
|
|
|
|
| def preset_editable(field): |
| """Does the pre-set lock let this column's DEFINITION be changed anyway? |
| |
| β ONE PREDICATE FOR ONE QUESTION, read by all three walls that enforce the lock |
| (`may_edit_field` here, `routes_tables._field_or_refuse`, and the client's `schemaLocked`). |
| Three independent copies of "is this preset?" is how one of them keeps refusing after the |
| other two stop β the shape D-107 wore, and the reason `is_derived_link` above is a function |
| rather than an expression repeated three times. |
| |
| Today it answers YES for exactly one thing: a ROLLUP. The owner's ruling names rollups, and |
| the argument is specific to them rather than a general softening of the lock β a rollup holds |
| no data of its own. It is a QUESTION asked of other rows ("average views over the last N |
| posts"), it can be re-asked at any time, and re-asking it costs nothing because the answer is |
| recomputed from the authoritative store on the next pass. Retyping a preset `followers` |
| column, by contrast, would strand real measurements in a column that can no longer read them. |
| """ |
| return str((field or {}).get('type') or '') == 'rollup' |
|
|
|
|
| def clean_machine_fields(fields): |
| """`(clean, refused)` β the SANCTIONED way for a machine writer to seed a table's schema |
| (wave 25, doc order step 2). `clean` is the list as this module would store it; `refused` |
| names every field that did not survive, so a caller can be LOUD rather than short. |
| |
| β WHY THIS EXISTS. `automation_engine.ut_ensure` writes field dicts STRAIGHT into the |
| `user_tables` bucket with `rt.update(UT_STORE_KEY, β¦)` β it never routes through this |
| module, so **the field layer's own validator has never judged a single column the engine has |
| ever spawned.** That is [[default-must-pass-its-own-guard]] with a live subject: the product |
| seeds fields against a law it does not run. Nothing has broken yet only because the drift is |
| one key wide and in the fail-closed direction (a field with no `editRole` reads as |
| not-`everyone`), which is luck, not design. |
| |
| β THIS IS THE "IMPOSSIBLE" HALF AND IT IS OPT-IN; the LOUD half is `verify_api`'s derived |
| section, which walks every field list the engine declares and refuses to let one through |
| that this validator would change in any way but adding `editRole`. A helper the caller may |
| ignore is not a control β the gate is the control, and it covers a list nobody remembered |
| to route through here. |
| """ |
| clean, refused = [], [] |
| for f in (fields or []): |
| got = _clean_field(f) |
| if got is None: |
| refused.append(str((f or {}).get('key') or f)) |
| else: |
| clean.append(got) |
| return clean, refused |
|
|
|
|
| def may_edit_field(table_key, fkey, viewer, is_admin=False, st=None): |
| """May `viewer` change THIS column's definition? Creator/admin always; others only when the |
| field itself says `editRole: 'everyone'`. Fail-closed on an unknown field.""" |
| table = get(table_key, st) or {} |
| field = next((f for f in (table.get('fields') or []) if f.get('key') == str(fkey)), None) |
| |
| |
| |
| if isinstance((field or {}).get('automation'), dict) \ |
| and field['automation'].get('preset') is True \ |
| and not preset_editable(field): |
| return False |
| if may_open(table_key, viewer, is_admin, st) and ( |
| bool(is_admin) or table.get('createdBy') == viewer): |
| return True |
| for f in (table.get('fields') or []): |
| if f.get('key') == str(fkey): |
| return f.get('editRole') == 'everyone' |
| return False |
|
|
|
|
| def flow_bound(bag, st=None): |
| """C8 (wave 22, owner item 5) β may this `automation` bag be STORED? Only when its |
| `flowId` names an automation definition that exists in this tenant's `automations` bucket. |
| Fail-closed both ways: no flowId is a column that silently never runs, and a flowId naming |
| a deleted flow is the same thing one delete later. (Read tolerance for pre-law fields |
| lives in `aios_grid._clean_automation` β this guards the WRITE doors only.)""" |
| if not isinstance(bag, dict): |
| return True |
| flow = str(bag.get('flowId') or '').strip() |
| if not flow: |
| return False |
| try: |
| return flow in (_st(st).get('automations') or {}) |
| except Exception: |
| return False |
|
|
|
|
| def add_field(table_key, raw, st=None): |
| """Append one column to the shared schema. Returns the stored field, or None if refused.""" |
| if not is_user_table(table_key, st): |
| return None |
| defn = get(table_key, st) or {} |
| have = [f for f in (defn.get('fields') or [])] |
| if len(have) >= MAX_FIELDS: |
| return None |
| field = _clean_field(raw) |
| if not field or any(f.get('key') == field['key'] for f in have): |
| return None |
| if not flow_bound(field.get('automation'), st): |
| return None |
| |
| |
| |
| |
| if field.get('profile') and _profile_of(have): |
| return None |
|
|
| def _add(cur): |
| t = cur.get(str(table_key)) |
| if t is not None: |
| t.setdefault('fields', []).append(field) |
| return cur |
|
|
| _st(st).update(STORE_KEY, _add, flush='sync') |
| return field |
|
|
|
|
| def _reciprocal_link_key(table_key, field_key): |
| digest = hashlib.sha1(f'{table_key}:{field_key}'.encode('utf-8')).hexdigest()[:10] |
| return f'linked_{digest}' |
|
|
|
|
| def sync_reciprocal_link(table_key, field_key, st=None): |
| """Create/repair Airtable's reciprocal link field for one ordinary link. |
| |
| The source cell stores picked target row ids. The reciprocal is a computed inverse link on |
| the target database; it lists source rows that include the current target id. Retargeting or |
| retyping the source removes the obsolete inverse in the same store update. |
| """ |
| table_key, field_key = str(table_key), str(field_key) |
| result = {'field': None, 'reciprocal': None} |
|
|
| def _sync(cur): |
| cur = cur if isinstance(cur, dict) else {} |
| source = cur.get(table_key) or {} |
| source_field = next((f for f in (source.get('fields') or []) |
| if f.get('key') == field_key), None) |
| |
| |
| for candidate in cur.values(): |
| if not isinstance(candidate, dict): |
| continue |
| candidate['fields'] = [f for f in (candidate.get('fields') or []) |
| if not (isinstance(f.get('link'), dict) |
| and f['link'].get('table') == table_key |
| and f['link'].get('inverse') == field_key)] |
| if not source_field or source_field.get('type') != 'link': |
| return cur |
| bag = dict(source_field.get('link') or {}) |
| if bag.get('on') or bag.get('inverse'): |
| return cur |
| target_key = str(bag.get('table') or '') |
| target = cur.get(target_key) |
| if target is None: |
| return cur |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if len(target.get('fields') or []) >= MAX_FIELDS: |
| return cur |
| reciprocal_key = _reciprocal_link_key(table_key, field_key) |
| reciprocal = _clean_field({ |
| 'key': reciprocal_key, |
| 'label': str(source.get('label') or table_key)[:80], |
| 'type': 'link', |
| 'link': {'table': table_key, 'inverse': field_key, |
| 'reciprocal': field_key}, |
| 'editRole': 'admins', |
| }) |
| if reciprocal is None: |
| return cur |
| target.setdefault('fields', []).append(reciprocal) |
| bag['reciprocal'] = reciprocal_key |
| source_field['link'] = bag |
| result['field'] = dict(source_field) |
| result['reciprocal'] = dict(reciprocal) |
| return cur |
|
|
| _st(st).update(STORE_KEY, _sync, flush='sync') |
| return result |
|
|
|
|
| def patch_field(table_key, fkey, raw, st=None): |
| """Edit one column's definition IN PLACE. Returns the stored field, or None if refused. |
| |
| β THE KEY NEVER MOVES. A field's key is what every stored cell is filed under, so accepting |
| a new one here would orphan every value in the column while looking like a rename. The |
| LABEL is the renameable thing; the key is identity. |
| """ |
| if not is_user_table(table_key, st): |
| return None |
| fields = [dict(f) for f in ((get(table_key, st) or {}).get('fields') or [])] |
| idx = next((i for i, f in enumerate(fields) if f.get('key') == str(fkey)), -1) |
| if idx < 0: |
| return None |
| merged = dict(raw or {}) |
| merged['key'] = str(fkey) |
| field = _clean_field(merged, fields[idx]) |
| if not field: |
| return None |
| if 'automation' in merged and not flow_bound(field.get('automation'), st): |
| return None |
| |
| |
| if field.get('profile') and _profile_of(fields, exclude=str(fkey)): |
| return None |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if user_edited(fields[idx]) or (field != fields[idx] |
| and isinstance(fields[idx].get('automation'), dict) |
| and fields[idx]['automation'].get('preset') is True): |
| automation = dict(field.get('automation') or fields[idx].get('automation') or {}) |
| automation[USER_EDITED_KEY] = True |
| field['automation'] = automation |
|
|
| def _set(cur): |
| t = cur.get(str(table_key)) |
| if t is not None: |
| for i, f in enumerate(t.get('fields') or []): |
| if f.get('key') == str(fkey): |
| t['fields'][i] = field |
| break |
| return cur |
|
|
| _st(st).update(STORE_KEY, _set, flush='sync') |
| return field |
|
|
|
|
| def delete_field(table_key, fkey, st=None): |
| """Drop one column from the shared schema. Refuses to leave a table fieldless. |
| |
| β THE CELLS ARE LEFT IN THE ROWS ON PURPOSE. A deleted column whose values were also |
| scrubbed makes an accidental delete unrecoverable; the values are invisible without a field |
| declaring them, and re-adding the column with the same key brings them back. Same reasoning |
| as `routes_tables.delete_table` leaving the workspace bucket in place. Booked, not hidden. |
| """ |
| if not is_user_table(table_key, st): |
| return False |
| fields = (get(table_key, st) or {}).get('fields') or [] |
| if len(fields) <= 1 or not any(f.get('key') == str(fkey) for f in fields): |
| return False |
| doomed = next((f for f in fields if f.get('key') == str(fkey)), {}) |
| doomed_link = doomed.get('link') if isinstance(doomed.get('link'), dict) else {} |
| inverse_source = None |
| if doomed_link.get('inverse'): |
| source_table = str(doomed_link.get('table') or '') |
| source_field = str(doomed_link.get('inverse') or '') |
| source = get(source_table, st) or {} |
| if len(source.get('fields') or []) <= 1: |
| return False |
| inverse_source = (source_table, source_field) |
|
|
| def _drop(cur): |
| t = cur.get(str(table_key)) |
| if t is not None: |
| t['fields'] = [f for f in (t.get('fields') or []) if f.get('key') != str(fkey)] |
| if inverse_source: |
| source_table, source_field = inverse_source |
| source = cur.get(source_table) |
| if isinstance(source, dict): |
| source['fields'] = [f for f in (source.get('fields') or []) |
| if f.get('key') != source_field] |
| |
| |
| for candidate in cur.values(): |
| if not isinstance(candidate, dict): |
| continue |
| candidate['fields'] = [f for f in (candidate.get('fields') or []) |
| if not (isinstance(f.get('link'), dict) |
| and ((f['link'].get('table') == str(table_key) |
| and f['link'].get('inverse') == str(fkey)) |
| or (inverse_source |
| and f['link'].get('table') == inverse_source[0] |
| and f['link'].get('inverse') == inverse_source[1])))] |
| return cur |
|
|
| _st(st).update(STORE_KEY, _drop, flush='sync') |
| return True |
|
|
|
|
| def rename_choice_values(table_key, fkey, renames, st=None): |
| """Rename select/multiselect OPTIONS **and migrate every stored cell** (contract C-RENAME). |
| |
| `renames` = `[{'from': old, 'to': new}, β¦]` β an EXPLICIT MAPPING, never a diff. A diff |
| cannot tell "renamed Blue to Navy" from "deleted Blue and added Navy", and guessing wrong |
| empties a column silently. |
| |
| Returns `{'options': n, 'cells': n}`. This is the DEFINITION half β the base options list and |
| the base row values. A user's per-user overlay stratum and any view filter naming the old |
| value are `core.table_store`'s half of the same contract; the caller runs both. |
| """ |
| pairs = [] |
| for r in (renames or [])[:50]: |
| if not isinstance(r, dict): |
| continue |
| a = ' '.join(str(r.get('from') or '').split())[:60] |
| b = ' '.join(str(r.get('to') or '').split())[:60] |
| if a and b and a != b: |
| pairs.append((a, b)) |
| if not pairs or not is_user_table(table_key, st): |
| return {'options': 0, 'cells': 0} |
| mapping = dict(pairs) |
| counts = {'options': 0, 'cells': 0} |
|
|
| def _apply(cur): |
| t = cur.get(str(table_key)) |
| if t is None: |
| return cur |
| multi = False |
| for f in (t.get('fields') or []): |
| if f.get('key') != str(fkey): |
| continue |
| multi = f.get('type') == 'multiselect' |
| opts = f.get('options') or [] |
| new_opts, seen = [], set() |
| for o in opts: |
| v = mapping.get(o, o) |
| if v not in seen: |
| seen.add(v) |
| new_opts.append(v) |
| if o in mapping: |
| counts['options'] += 1 |
| if opts: |
| f['options'] = new_opts |
| for row in (t.get('rows') or {}).values(): |
| if not isinstance(row, dict) or str(fkey) not in row: |
| continue |
| cell = str(row.get(str(fkey)) or '') |
| if not cell: |
| continue |
| if multi: |
| |
| |
| parts = [p.strip() for p in cell.split(',')] |
| nxt = [mapping.get(p, p) for p in parts] |
| if nxt != parts: |
| row[str(fkey)] = ', '.join(dict.fromkeys(nxt)) |
| counts['cells'] += 1 |
| elif cell in mapping: |
| row[str(fkey)] = mapping[cell] |
| counts['cells'] += 1 |
| return cur |
|
|
| _st(st).update(STORE_KEY, _apply, flush='sync') |
| return counts |
|
|
|
|
| def patch_cells(table_key, row_id, values, st=None): |
| """Write shared definition-row cells for trusted server-side writers.""" |
| if not is_user_table(table_key, st): |
| return False |
| rows = (get(table_key, st) or {}).get('rows') or {} |
| if str(row_id) not in rows: |
| return False |
|
|
| def _set(cur): |
| t = cur.get(str(table_key)) |
| if t is not None: |
| t.setdefault('rows', {}).setdefault(str(row_id), {}).update( |
| {str(k): str(v) for k, v in (values or {}).items()}) |
| return cur |
|
|
| _st(st).update(STORE_KEY, _set, flush='sync') |
| return True |
|
|
|
|
| def patch_link_cell(table_key, row_id, fkey, value, st=None): |
| """Persist one user-picked Link cell in the shared row and return its canonical id string. |
| |
| A Link is a relationship in the database schema, not one user's visual overlay. Persisting |
| it here makes the reciprocal field and every Rollup see the same source of truth. Derived |
| joins and inverse fields are engine-owned and are refused by this door. |
| """ |
| table_key, row_id, fkey = str(table_key), str(row_id), str(fkey) |
| if not is_user_table(table_key, st) or not records_mutable(table_key, st): |
| return None |
| table = get(table_key, st) or {} |
| if row_id not in (table.get('rows') or {}): |
| return None |
| field = next((f for f in (table.get('fields') or []) if f.get('key') == fkey), None) |
| bag = (field or {}).get('link') |
| if (field or {}).get('type') != 'link' or not isinstance(bag, dict) \ |
| or bag.get('on') or bag.get('inverse'): |
| return None |
| target = get(str(bag.get('table') or ''), st) or {} |
| valid = set((target.get('rows') or {}).keys()) |
| raw_ids = value if isinstance(value, (list, tuple, set)) else str(value or '').split(',') |
| picked, seen = [], set() |
| for raw_id in raw_ids: |
| rid = str(raw_id).strip() |
| if not rid or rid in seen: |
| continue |
| if rid not in valid or len(picked) >= LINK_MAX_IDS: |
| return None |
| seen.add(rid) |
| picked.append(rid) |
| if bag.get('single'): |
| break |
| canonical = ','.join(picked) |
|
|
| def _set(cur): |
| current = cur.get(table_key) |
| if current is not None and row_id in (current.get('rows') or {}): |
| current['rows'][row_id][fkey] = canonical |
| return cur |
|
|
| _st(st).update(STORE_KEY, _set, flush='sync') |
| return canonical |
|
|
|
|
| def patch_profile_cell(table_key, row_id, fkey, value, st=None): |
| """Write a PROFILE cell β and, when it is blanked, clear that row's preset cells IN THE SAME |
| WRITE (wave 25, contract C3 + owner ruling R6). Returns |
| `{'handle', 'cleared': [key, β¦]}`, or None when the write is refused. |
| |
| β THIS IS THE `ut_*` WRITE DOOR R6 NAMES, and it is deliberately NOT the event seam. |
| HARD RULE 5: item 5a's clearing *looks* exactly like an event-trigger feature, and |
| implementing it by widening `grid_events`' emit guard is D-40 β deferred by ruling A-15 |
| because customer/product `EventCtx` carries no scoped handle and a nurilab user's edit would |
| fire against royal-imports' automations. Nothing here emits anything. |
| |
| β IT WRITES THE DEFINITION ROW, NOT AN OVERLAY (amendment C3-A1, MEASURED). An ordinary |
| `ut_*` cell edit lands in the typist's own overlay stratum, where the automation engine β |
| which reads `t['rows']` β can never see it. A handle nobody can enrich is the flag being |
| decorative, so a profile cell writes THROUGH to the shared row, exactly as the stage field |
| does for exactly the same reason. |
| |
| β ONE `update`, not two. "In the same write" is the contract, and it is also the only safe |
| shape: a blank committed without its clear leaves a row whose handle is gone and whose |
| follower count still reads 41,000 β stale numbers attributed to nobody, which is worse than |
| either the old row or the empty one. |
| |
| β IT DELETES NO FIELDS AND TOUCHES NO HISTORY. The columns stay (they are the table's |
| schema, and the next handle refills them); `ut_ig_snapshots` is a different table this |
| module never opens. R3's *"one store for one series"* is why that separation holds β the |
| cleared cells are the LATEST-value stamp, and the series itself was never in them. |
| """ |
| if not is_user_table(table_key, st): |
| return None |
| defn = get(table_key, st) or {} |
| rows = defn.get('rows') or {} |
| if str(row_id) not in rows: |
| return None |
| fields = defn.get('fields') or [] |
| fdef = next((f for f in fields if f.get('key') == str(fkey)), None) |
| if not isinstance(fdef, dict) or not isinstance(fdef.get('profile'), dict): |
| return None |
| handle, ok = normalize_profile(value, fdef['profile'].get('source')) |
| if not ok: |
| return None |
| |
| |
| cleared = [] |
| if not handle: |
| cleared = [f['key'] for f in fields |
| if f.get('key') in PROFILE_PRESET_KEYS |
| and isinstance(f.get('automation'), dict) |
| and str(rows.get(str(row_id), {}).get(f['key']) or '') != ''] |
|
|
| def _set(cur): |
| t = cur.get(str(table_key)) |
| if t is not None: |
| row = t.setdefault('rows', {}).setdefault(str(row_id), {}) |
| row[str(fkey)] = handle |
| for k in cleared: |
| row[k] = '' |
| return cur |
|
|
| _st(st).update(STORE_KEY, _set, flush='sync') |
| return {'handle': handle, 'cleared': cleared} |
|
|
|
|
| def delete_row(table_key, row_id, st=None): |
| """β SYNC, AND IT WAS `async` β owner report 2026-08-10: *"it took me forever to delete a |
| record⦠it took me like 3 tries"*. |
| |
| `flush='async'` applies the drop to the in-process CACHE and defers the upload by |
| `store._FLUSH_DELAY` (2 s) so a burst coalesces into one commit. That trade was made for |
| `add_row`, and made there for a stated reason β the owner's *"adding a new record visually |
| takes too long, I need to be able to spam it"*. **This function inherited the flag and none of |
| the argument.** Nobody spams a delete: it is rare, it is destructive, and it is the one |
| mutation where "eventually consistent" is indistinguishable from "it did not work". Inside |
| that window the row is gone from this process and still present in the store, so anything that |
| re-reads the hub copy β a restart, an eviction, another reader β brings it back, and the user |
| deletes it again. |
| |
| β ONE COMMIT PER DELETE is the whole cost, and it is the correct one to pay: the alternative |
| is a destructive action whose durability depends on the container staying up for two seconds. |
| Adds keep their async path untouched β the spam case is real and is theirs. |
| β THIS IS NOT THE WHOLE OF D-118. A sync commit closes the lost-delete window; it does not make |
| two writers safe, which is D-4's job. |
| """ |
| if not is_user_table(table_key, st) or not records_mutable(table_key, st): |
| return False |
|
|
| def _drop(cur): |
| t = cur.get(str(table_key)) |
| if t is not None: |
| (t.get('rows') or {}).pop(str(row_id), None) |
| return cur |
|
|
| _st(st).update(STORE_KEY, _drop, flush='sync') |
| return True |
|
|
|
|
| |
| |
| |
| |
| MACHINE_OWNERS = ('automation', 'scheduler') |
|
|
|
|
| def may_open(table_key, viewer, is_admin=False, st=None): |
| """FAIL-CLOSED visibility: the creator, or an admin. Nothing else. **THE one resolver.** |
| |
| A user table cannot be gated by `allowed_modules` β a module grant written last month cannot |
| describe a table created this morning β so it needs its own rule, and the safe rule is the |
| narrow one. Tables are stored tenant-wide under one key, so without this ANY user (including |
| a BU-scoped sales agent) could open a table somebody else created just by knowing its key. |
| Sharing a user table with named colleagues is a follow-on, and it should reuse the shared- |
| VIEW vocabulary rather than inventing a second one. |
| |
| β WAVE 20 β THERE WAS A SECOND, WIDER RULE, AND THE TWO DISAGREED. `routes_automation`'s |
| table picker admitted `createdBy in (uname, 'automation', 'scheduler')`, so a non-admin could |
| SEE an automation-created database in the picker and then be refused when they opened it, |
| edited it, or tried to delete it. Two ideas of who owns a table is the same defect class as |
| the two ideas of an agent's book (D-30): both surfaces look right in isolation and only |
| disagree in front of a user. The picker now calls THIS function, and the engine stamps a |
| real owner (see `MACHINE_OWNERS`) so nothing legitimate is narrowed by the merge. |
| """ |
| t = get(table_key, st) |
| if not t: |
| return False |
| if bool(is_admin) or t.get('createdBy') == viewer: |
| return True |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| try: |
| import core.shares as shares |
| return shares.may_see('database', table_key, viewer, is_admin=is_admin, st=st) |
| except Exception: |
| return False |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _LENDABLE = (STORE_KEY, 'object_shares') |
|
|
|
|
| class _Lent: |
| """A read-only `st` that serves already-read documents and passes everything else through.""" |
|
|
| def __init__(self, runtime, docs): |
| self._runtime, self._docs = runtime, dict(docs or {}) |
|
|
| def get(self, name, fresh=False): |
| if fresh or name not in _LENDABLE: |
| return self._runtime.get(name, fresh) if fresh else self._runtime.get(name) |
| if name not in self._docs: |
| self._docs[name] = self._runtime.get(name) |
| return self._docs[name] |
|
|
| def get_projection(self, name, drop=()): |
| """β W32-T01: serve the LENT document here too, or a projection re-reads what the pass |
| already holds β `__getattr__` would forward this straight to the runtime and the lend |
| would buy nothing for exactly the callers built to use it. |
| |
| β THE TWO MISMATCHES ARE BOTH SAFE, AND IN OPPOSITE WAYS β which is why this serves the |
| lent document as-is rather than re-projecting it: |
| β’ lent WHOLE, asked for a projection β the caller gets MORE than it asked for. Correct, |
| just not cheap β the one direction a shortcut here is allowed to be wrong in. |
| β’ lent PROJECTED, asked for the whole thing via `get` β the caller gets a `_Projected`, |
| and reading a dropped key RAISES with the reason. Loud, not silent. |
| A re-projection would cost a second copy to convert the first case and change nothing |
| about the second. |
| """ |
| if name not in _LENDABLE: |
| return self._runtime.get_projection(name, drop=drop) |
| if name not in self._docs: |
| self._docs[name] = self._runtime.get_projection(name, drop=drop) |
| return self._docs[name] |
|
|
| def __getattr__(self, name): |
| return getattr(self._runtime, name) |
|
|
|
|
| def lend(st=None, **docs): |
| """A handle that serves `docs` for `_LENDABLE` keys and defers everything else to `st`. |
| |
| `lend(rt, user_tables=doc)` β keyword names are STORE KEYS. Call it once per read pass and |
| hand the result to every predicate in that pass; see the note above for what makes it safe. |
| """ |
| return _Lent(_st(st), {k: v for k, v in docs.items() if v is not None}) |
|
|
|
|
| def nav_entries(viewer=None, is_admin=False, st=None): |
| """Registry-SHAPED dicts so the nav can render user tables beside real modules with no |
| special-casing: the same keys the flat nav reads (`key`, `label`, `source`). |
| |
| Filtered by `may_open`, so the nav cannot offer a row the dispatcher would refuse. |
| |
| β W31-T10 (C1): ONE document read for the whole listing. The filter is unchanged β the same |
| `may_open`, asked about the same tables, in the same order β it is handed the document this |
| function has already read instead of taking a fresh 35.8 MB copy per table. A caller that |
| already holds the document passes its own `lend(...)` as `st` and this pays nothing at all. |
| |
| ββ W32-T02 (R8/D-175): and that ONE read is now a PROJECTED one. This function reads `label`, |
| `source` and `key`, and `may_open` reads `createdBy` (then the shares registry) β **no caller |
| of this listing has ever touched a row.** On tenant #0 that turns a 1,750 ms copy into 1.4 ms, |
| for both consumers: `nav()` and `_placeable_top_keys`, i.e. `GET /nav` and `GET /nav/prefs` β |
| the two that `Shell.tsx` fires CONCURRENTLY on the same `Store._lock`, which is why they added |
| up rather than overlapped and why the rail rows arrived seconds apart (owner item 5). |
| """ |
| tables = all_defs(st) |
| lent = lend(st, **{STORE_KEY: tables}) |
| out = [] |
| for key, t in sorted(tables.items(), |
| key=lambda kv: (kv[1].get('label') or '').lower()): |
| if viewer is not None and not may_open(key, viewer, is_admin, st=lent): |
| continue |
| out.append({'key': key, 'label': t.get('label') or key, |
| 'source': t.get('source') or BLANK_SOURCE, |
| 'user_table': True, 'nav': True, 'validate': False}) |
| return out |
|
|