fsanyoto commited on
Commit
1585551
Β·
verified Β·
1 Parent(s): bb16423

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
RELEASES.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "current": "4089c02",
3
  "releases": [
4
  {
5
  "version": "v53",
 
1
  {
2
+ "current": "db43911",
3
  "releases": [
4
  {
5
  "version": "v53",
VERSION CHANGED
@@ -1 +1 @@
1
- 4089c02
 
1
+ db43911
api/routes_customers.py CHANGED
@@ -320,7 +320,8 @@ def grid_assembly(session: Session, scope: str = "customer", storage_key: str =
320
  _ov[_pid] = {**(_ov.get(_pid) or {}), **_cells}
321
  ws["overlays"] = _ov
322
  workspace, fields, views, lists = aios_grid.workspace_wire(
323
- ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key)
 
324
  # ⭐⭐ W38-T20 β€” AND THE COLUMN DEFINITIONS, BEFORE THE WALL. See `_merge_shared_fields`: this
325
  # position is load-bearing twice, once for the transitive closure and once because it is the
326
  # only order in which the per-field grant marker is ever presented to `hidden_keys`.
 
320
  _ov[_pid] = {**(_ov.get(_pid) or {}), **_cells}
321
  ws["overlays"] = _ov
322
  workspace, fields, views, lists = aios_grid.workspace_wire(
323
+ ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key,
324
+ st=rt)
325
  # ⭐⭐ W38-T20 β€” AND THE COLUMN DEFINITIONS, BEFORE THE WALL. See `_merge_shared_fields`: this
326
  # position is load-bearing twice, once for the transitive closure and once because it is the
327
  # only order in which the per-field grant marker is ever presented to `hidden_keys`.
api/routes_products.py CHANGED
@@ -363,7 +363,7 @@ def product_assembly(session: Session, scope: str = "product", storage_key: str
363
  ctx = grid_events.EventCtx(
364
  uname=session.uname, allowed_pids=pids, fields=[],
365
  hidden_keys=perm_scope.hidden_keys(session.user, MODULE, fields_base, st=session.runtime),
366
- admin=session.admin, fallback_ws=None, seen_ids={},
367
  scope_key="product", table=pd.table_ops(session.runtime))
368
  ws = grid_events.table_workspace(ctx, allowed_pids=pids,
369
  consume_corrections=consume_corrections)
@@ -385,7 +385,7 @@ def product_assembly(session: Session, scope: str = "product", storage_key: str
385
  ws["overlays"] = _ov
386
  workspace, fields, views, lists = aios_grid.workspace_wire(
387
  ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key,
388
- fields_base=fields_base)
389
 
390
  # W43: source-backed Product choices learn values in the shared overlay schema. Apply that
391
  # durable vocabulary before the payload is served, so "Add option: et" is still available
@@ -517,7 +517,7 @@ def patch_product(pid: int, body: dict = Body(default=None),
517
  admin=session.admin, fallback_ws=None, seen_ids={},
518
  hidden_keys=perm_scope.hidden_keys(
519
  session.user, MODULE, g["fields"], st=session.runtime),
520
- scope_key="product", table=pd.table_ops(session.runtime))
521
  try:
522
  grid_events.handle_one(
523
  {"id": f"patch:product:{pid}:{time.time_ns()}", "type": "overlay_patch",
 
363
  ctx = grid_events.EventCtx(
364
  uname=session.uname, allowed_pids=pids, fields=[],
365
  hidden_keys=perm_scope.hidden_keys(session.user, MODULE, fields_base, st=session.runtime),
366
+ admin=session.admin, fallback_ws=None, seen_ids={}, st=session.runtime,
367
  scope_key="product", table=pd.table_ops(session.runtime))
368
  ws = grid_events.table_workspace(ctx, allowed_pids=pids,
369
  consume_corrections=consume_corrections)
 
385
  ws["overlays"] = _ov
386
  workspace, fields, views, lists = aios_grid.workspace_wire(
387
  ws, session.uname, set(pids), defs={}, scope_key=scope, storage_key=storage_key,
388
+ fields_base=fields_base, st=session.runtime)
389
 
390
  # W43: source-backed Product choices learn values in the shared overlay schema. Apply that
391
  # durable vocabulary before the payload is served, so "Add option: et" is still available
 
517
  admin=session.admin, fallback_ws=None, seen_ids={},
518
  hidden_keys=perm_scope.hidden_keys(
519
  session.user, MODULE, g["fields"], st=session.runtime),
520
+ scope_key="product", table=pd.table_ops(session.runtime), st=session.runtime)
521
  try:
522
  grid_events.handle_one(
523
  {"id": f"patch:product:{pid}:{time.time_ns()}", "type": "overlay_patch",
api/routes_shares.py CHANGED
@@ -973,7 +973,7 @@ def _object_ref(session, kind, oid):
973
  "field": "A column", "cohort": "A cohort"}.get(kind, "An item"), None, "")
974
 
975
 
976
- def _cohort_visible(scope, username, allowed_pids=None):
977
  """Adapt the Cohort module's existing list projection to the one registry predicate.
978
 
979
  ``workspace_wire`` is the live Cohort list/open path. It intentionally receives only a
@@ -984,7 +984,7 @@ def _cohort_visible(scope, username, allowed_pids=None):
984
  """
985
  import modules.cohort as cohort_mod
986
 
987
- records = shares.visible_cohorts(scope, username)
988
  return {cid: {**record,
989
  "members": cohort_mod._clean_members(record.get("members"), allowed_pids)}
990
  for cid, record in records.items()}
@@ -999,7 +999,8 @@ def _install_cohort_visibility():
999
  return _cohort_visible("customer", username, allowed_pids)
1000
 
1001
  def _scoped_visible(self, username, allowed_pids=None):
1002
- return _cohort_visible(self.scope, username, allowed_pids)
 
1003
 
1004
  cohort_mod.visible = _customer_visible
1005
  cohort_mod.CohortStore.visible = _scoped_visible
 
973
  "field": "A column", "cohort": "A cohort"}.get(kind, "An item"), None, "")
974
 
975
 
976
+ def _cohort_visible(scope, username, allowed_pids=None, st=None):
977
  """Adapt the Cohort module's existing list projection to the one registry predicate.
978
 
979
  ``workspace_wire`` is the live Cohort list/open path. It intentionally receives only a
 
984
  """
985
  import modules.cohort as cohort_mod
986
 
987
+ records = shares.visible_cohorts(scope, username, st=st)
988
  return {cid: {**record,
989
  "members": cohort_mod._clean_members(record.get("members"), allowed_pids)}
990
  for cid, record in records.items()}
 
999
  return _cohort_visible("customer", username, allowed_pids)
1000
 
1001
  def _scoped_visible(self, username, allowed_pids=None):
1002
+ return _cohort_visible(self.scope, username, allowed_pids,
1003
+ st=getattr(self, 'st', None))
1004
 
1005
  cohort_mod.visible = _customer_visible
1006
  cohort_mod.CohortStore.visible = _scoped_visible
api/routes_tables.py CHANGED
@@ -747,7 +747,7 @@ def ut_write_ctx(session: Session, table_key: str):
747
  ws = grid_events.table_workspace(ctx, allowed_pids=pids, consume_corrections=False)
748
  workspace, fields, views, lists = aios_grid.workspace_wire(
749
  ws, session.uname, set(pids), defs={}, scope_key=table_key, storage_key="",
750
- fields_base=fields_base)
751
  # ⭐⭐ W38-T16 β€” THE SHARED COLUMNS JOIN THE **WRITE** CTX'S CONTRACT TOO, and for the reason
752
  # W36-T21 gave one wall over: `grid_events` refuses a key by asking `ctx.hidden_keys`, so a
753
  # column missing from this list is a column the events transport does not know it must
@@ -1257,7 +1257,7 @@ def ut_assembly(session: Session, table_key: str, storage_key: str = "",
1257
  consume_corrections=consume_corrections)
1258
  workspace, fields, views, lists = aios_grid.workspace_wire(
1259
  ws, session.uname, set(pids), defs={}, scope_key=table_key,
1260
- storage_key=storage_key, fields_base=fields_base)
1261
  # ⭐⭐ W38-T16 β€” THE TENANT-WIDE STRATUM, ON A MATERIALISED GRID. Merged HERE, between
1262
  # `workspace_wire` and the wall, which is `routes_odoo_tables`' own position for the same two
1263
  # lines and for the same reason: the closure must be recomputed on the MERGED contract or a
 
747
  ws = grid_events.table_workspace(ctx, allowed_pids=pids, consume_corrections=False)
748
  workspace, fields, views, lists = aios_grid.workspace_wire(
749
  ws, session.uname, set(pids), defs={}, scope_key=table_key, storage_key="",
750
+ fields_base=fields_base, st=session.runtime)
751
  # ⭐⭐ W38-T16 β€” THE SHARED COLUMNS JOIN THE **WRITE** CTX'S CONTRACT TOO, and for the reason
752
  # W36-T21 gave one wall over: `grid_events` refuses a key by asking `ctx.hidden_keys`, so a
753
  # column missing from this list is a column the events transport does not know it must
 
1257
  consume_corrections=consume_corrections)
1258
  workspace, fields, views, lists = aios_grid.workspace_wire(
1259
  ws, session.uname, set(pids), defs={}, scope_key=table_key,
1260
+ storage_key=storage_key, fields_base=fields_base, st=session.runtime)
1261
  # ⭐⭐ W38-T16 β€” THE TENANT-WIDE STRATUM, ON A MATERIALISED GRID. Merged HERE, between
1262
  # `workspace_wire` and the wall, which is `routes_odoo_tables`' own position for the same two
1263
  # lines and for the same reason: the closure must be recomputed on the MERGED contract or a
platform/aios_grid.py CHANGED
@@ -2962,7 +2962,7 @@ def views_from_defs(defs, saved_views, fields, system_name="All customers", lock
2962
 
2963
 
2964
  def workspace_wire(ws, uname, pool_pids, defs=None, scope_key='customer', storage_key=None,
2965
- fields_base=None, with_cohorts=True):
2966
  """The client's `GridWorkspace` WIRE SHAPE from the stored table workspace β€” the ONE
2967
  projection, shared by both servers (app.py's `_table_grid` and the API's `/workspace`).
2968
 
@@ -2994,7 +2994,7 @@ def workspace_wire(ws, uname, pool_pids, defs=None, scope_key='customer', storag
2994
 
2995
  cohort_lists = []
2996
  if with_cohorts:
2997
- for cid, c in sorted(cohort_mod.scoped(scope_key).visible(uname, pool_pids).items(),
2998
  key=lambda kv: (kv[1].get('name') or '').lower()):
2999
  members = [p for p in (c.get('members') or []) if p in pool_pids]
3000
  entry = {'id': cid, 'name': c.get('name') or cid, 'pids': members}
 
2962
 
2963
 
2964
  def workspace_wire(ws, uname, pool_pids, defs=None, scope_key='customer', storage_key=None,
2965
+ fields_base=None, with_cohorts=True, st=None):
2966
  """The client's `GridWorkspace` WIRE SHAPE from the stored table workspace β€” the ONE
2967
  projection, shared by both servers (app.py's `_table_grid` and the API's `/workspace`).
2968
 
 
2994
 
2995
  cohort_lists = []
2996
  if with_cohorts:
2997
+ for cid, c in sorted(cohort_mod.scoped(scope_key, st=st).visible(uname, pool_pids).items(),
2998
  key=lambda kv: (kv[1].get('name') or '').lower()):
2999
  members = [p for p in (c.get('members') or []) if p in pool_pids]
3000
  entry = {'id': cid, 'name': c.get('name') or cid, 'pids': members}
platform/core/grid_events.py CHANGED
@@ -344,7 +344,7 @@ def _cohorts(ctx):
344
  instead. `modules.cohort.scoped` returns the module itself for the customer topic, so the
345
  customer path is byte-unchanged.
346
  """
347
- return cohort_mod.scoped(ctx.scope_key)
348
 
349
 
350
  #: The keys a stored document entry may put on the wire. β›” `path` IS NOT ONE OF THEM β€” it is the
 
344
  instead. `modules.cohort.scoped` returns the module itself for the customer topic, so the
345
  customer path is byte-unchanged.
346
  """
347
+ return cohort_mod.scoped(ctx.scope_key, st=_store_of(ctx))
348
 
349
 
350
  #: The keys a stored document entry may put on the wire. β›” `path` IS NOT ONE OF THEM β€” it is the
platform/modules/cohort.py CHANGED
@@ -40,13 +40,10 @@ bucket per topic (`<scope>_cohorts`), reached through `scoped(scope)`.
40
  `modules.cohort.visible` (the gates do) keep working through the facade, and every existing
41
  positional call site is byte-unaffected.
42
 
43
- TENANCY, STATED NOT HIDDEN (booked as DEBT, wave 19): this module writes through the MODULE-LEVEL
44
- `core.store`, i.e. tenant #0's dataset repo β€” the same place `customer_docs` and the customer /
45
- product table workspaces already live. It is safe rather than correct: the outer key is the
46
- USERNAME, and `core/users.py`'s registry is ONE global bucket, so a username belongs to exactly
47
- one tenant and no cross-tenant READ is reachable. What is wrong is RESIDENCY (tenant B's lists
48
- sit in tenant #0's repo). Fixing it means threading the runtime handle through `EventCtx`, which
49
- needs `routes_grid._ctx` β€” another session's file this wave.
50
 
51
  No validate(): there is no Odoo aggregate to reconcile a hand-picked list against. Its counts
52
  derive from the rows it renders (rule 8b), and the metrics themselves are already validated by
@@ -104,21 +101,26 @@ def _now():
104
  return _dt.datetime.now().strftime('%Y-%m-%d %H:%M')
105
 
106
 
107
- def _all_for(key, username):
 
 
 
 
 
108
  """{cohort_id: {name, members, note, created, updated}} for one user, from ONE bucket.
109
 
110
  Returns {} when the store is unavailable rather than raising: a cohort list that cannot load
111
  should degrade to "no cohorts", never to a broken page.
112
  """
113
  try:
114
- return dict((store.get(key) or {}).get(username, {}) or {})
115
  except Exception:
116
  return {}
117
 
118
 
119
- def all_for(username):
120
  """{cohort_id: {...}} for one user on the CUSTOMER book β€” see `_all_for`."""
121
- return _all_for(KEY, username)
122
 
123
 
124
  def _clean_members(raw, allowed_pids=None):
@@ -144,15 +146,15 @@ def _clean_members(raw, allowed_pids=None):
144
  return out[:MAX_MEMBERS]
145
 
146
 
147
- def _visible(key, username, allowed_pids=None):
148
  """Every cohort in ONE bucket, with membership already scoped to what this user may open."""
149
  return {cid: {**c, 'members': _clean_members(c.get('members'), allowed_pids)}
150
- for cid, c in _all_for(key, username).items()}
151
 
152
 
153
- def visible(username, allowed_pids=None):
154
  """Every CUSTOMER cohort, membership scoped to what this user may open."""
155
- return _visible(KEY, username, allowed_pids)
156
 
157
 
158
  def new_id(name):
@@ -167,7 +169,7 @@ def new_id(name):
167
  return f"cohort_{slug or 'list'}_{_dt.datetime.now().strftime('%H%M%S')}"
168
 
169
 
170
- def _create(key, username, name, members=(), note=''):
171
  """Create a cohort in ONE bucket and return its id. Raises ValueError on a blank name or when
172
  the per-user cap is reached β€” refusing loudly beats silently dropping somebody's list.
173
 
@@ -177,7 +179,7 @@ def _create(key, username, name, members=(), note=''):
177
  name = str(name or '').strip()[:120]
178
  if not name:
179
  raise ValueError('a cohort needs a name')
180
- existing = _all_for(key, username)
181
  if len(existing) >= MAX_COHORTS:
182
  raise ValueError(f'you already have {MAX_COHORTS} cohorts β€” delete one first')
183
  cid = new_id(name)
@@ -187,16 +189,16 @@ def _create(key, username, name, members=(), note=''):
187
  def _up(data):
188
  data.setdefault(username, {})[cid] = record
189
  return data
190
- store.update(key, _up)
191
  return cid
192
 
193
 
194
- def create(username, name, members=(), note=''):
195
  """Create a CUSTOMER cohort and return its id β€” see `_create`."""
196
- return _create(KEY, username, name, members=members, note=note)
197
 
198
 
199
- def _mutate(key, username, cohort_id, change):
200
  def _up(data):
201
  book = data.setdefault(username, {})
202
  c = book.get(cohort_id)
@@ -205,10 +207,10 @@ def _mutate(key, username, cohort_id, change):
205
  change(c)
206
  c['updated'] = _now()
207
  return data
208
- store.update(key, _up)
209
 
210
 
211
- def _add_members(key, username, cohort_id, pids):
212
  """Union new ids into a cohort, PRESERVING the existing order and appending the new ones.
213
 
214
  Additive on purpose: "add to list" from a view can be clicked twice, and the second click
@@ -216,54 +218,54 @@ def _add_members(key, username, cohort_id, pids):
216
  """
217
  def _change(c):
218
  c['members'] = _clean_members(list(c.get('members') or []) + list(pids or []))
219
- _mutate(key, username, cohort_id, _change)
220
 
221
 
222
- def add_members(username, cohort_id, pids):
223
  """Union new ids into a CUSTOMER cohort β€” see `_add_members`."""
224
- _add_members(KEY, username, cohort_id, pids)
225
 
226
 
227
- def _remove_members(key, username, cohort_id, pids):
228
  drop = {int(p) for p in (pids or []) if str(p).lstrip('-').isdigit()}
229
 
230
  def _change(c):
231
  c['members'] = [p for p in _clean_members(c.get('members')) if p not in drop]
232
- _mutate(key, username, cohort_id, _change)
233
 
234
 
235
- def remove_members(username, cohort_id, pids):
236
- _remove_members(KEY, username, cohort_id, pids)
237
 
238
 
239
- def _rename(key, username, cohort_id, name):
240
  name = str(name or '').strip()[:120]
241
  if not name:
242
  raise ValueError('a cohort needs a name')
243
- _mutate(key, username, cohort_id, lambda c: c.__setitem__('name', name))
244
 
245
 
246
- def rename(username, cohort_id, name):
247
- _rename(KEY, username, cohort_id, name)
248
 
249
 
250
- def _set_note(key, username, cohort_id, note):
251
- _mutate(key, username, cohort_id, lambda c: c.__setitem__('note', str(note or '')[:2000]))
252
 
253
 
254
- def set_note(username, cohort_id, note):
255
- _set_note(KEY, username, cohort_id, note)
256
 
257
 
258
- def _delete(key, username, cohort_id):
259
  def _up(data):
260
  (data.get(username) or {}).pop(cohort_id, None)
261
  return data
262
- store.update(key, _up)
263
 
264
 
265
- def delete(username, cohort_id):
266
- _delete(KEY, username, cohort_id)
267
 
268
 
269
  def rows_for(cohort, pool_rows):
@@ -290,42 +292,43 @@ class CohortStore:
290
  code it already was.
291
  """
292
 
293
- __slots__ = ('scope', 'key', 'noun')
294
 
295
- def __init__(self, scope):
296
  self.scope = str(scope or 'customer').strip().lower()
297
  self.key = key_for(self.scope)
298
  self.noun = noun_for(self.scope)
 
299
 
300
  def __repr__(self):
301
  return f'CohortStore({self.scope!r} -> {self.key!r})'
302
 
303
  def all_for(self, username):
304
- return _all_for(self.key, username)
305
 
306
  def visible(self, username, allowed_pids=None):
307
- return _visible(self.key, username, allowed_pids)
308
 
309
  def create(self, username, name, members=(), note=''):
310
- return _create(self.key, username, name, members=members, note=note)
311
 
312
  def add_members(self, username, cohort_id, pids):
313
- _add_members(self.key, username, cohort_id, pids)
314
 
315
  def remove_members(self, username, cohort_id, pids):
316
- _remove_members(self.key, username, cohort_id, pids)
317
 
318
  def rename(self, username, cohort_id, name):
319
- _rename(self.key, username, cohort_id, name)
320
 
321
  def set_note(self, username, cohort_id, note):
322
- _set_note(self.key, username, cohort_id, note)
323
 
324
  def delete(self, username, cohort_id):
325
- _delete(self.key, username, cohort_id)
326
 
327
 
328
- def scoped(scope=None):
329
  """The cohort store for ONE topic β€” THE resolution point (R9).
330
 
331
  β›” Returns THIS MODULE for the customer topic, not a `CohortStore(KEY)`. Two reasons, and both
@@ -336,9 +339,9 @@ def scoped(scope=None):
336
  that returned a bound object would silently ignore the patch, leaving a gate green over
337
  code it is no longer exercising.
338
  """
339
- if str(scope or 'customer').strip().lower() in LEGACY_SCOPES:
340
  return _sys.modules[__name__]
341
- return CohortStore(scope)
342
 
343
 
344
  #: What ONE row of the customer topic is called β€” the module facade's answer to `CohortStore.noun`,
 
40
  `modules.cohort.visible` (the gates do) keep working through the facade, and every existing
41
  positional call site is byte-unaffected.
42
 
43
+ TENANCY: the API passes its request's ``TenantRuntime`` through ``scoped(..., st=...)``. Both list
44
+ reads and lifecycle writes then resolve through that handle; the module-level ``core.store`` is
45
+ only for legacy callers that have no request runtime. A sandbox or another tenant therefore
46
+ cannot reach tenant #0's Cohort bucket merely because this module was imported there first.
 
 
 
47
 
48
  No validate(): there is no Odoo aggregate to reconcile a hand-picked list against. Its counts
49
  derive from the rows it renders (rule 8b), and the metrics themselves are already validated by
 
101
  return _dt.datetime.now().strftime('%Y-%m-%d %H:%M')
102
 
103
 
104
+ def _store_for(st=None):
105
+ """The request tenant store when supplied, else the legacy module default."""
106
+ return st if st is not None else store
107
+
108
+
109
+ def _all_for(key, username, st=None):
110
  """{cohort_id: {name, members, note, created, updated}} for one user, from ONE bucket.
111
 
112
  Returns {} when the store is unavailable rather than raising: a cohort list that cannot load
113
  should degrade to "no cohorts", never to a broken page.
114
  """
115
  try:
116
+ return dict((_store_for(st).get(key) or {}).get(username, {}) or {})
117
  except Exception:
118
  return {}
119
 
120
 
121
+ def all_for(username, st=None):
122
  """{cohort_id: {...}} for one user on the CUSTOMER book β€” see `_all_for`."""
123
+ return _all_for(KEY, username, st=st)
124
 
125
 
126
  def _clean_members(raw, allowed_pids=None):
 
146
  return out[:MAX_MEMBERS]
147
 
148
 
149
+ def _visible(key, username, allowed_pids=None, st=None):
150
  """Every cohort in ONE bucket, with membership already scoped to what this user may open."""
151
  return {cid: {**c, 'members': _clean_members(c.get('members'), allowed_pids)}
152
+ for cid, c in _all_for(key, username, st=st).items()}
153
 
154
 
155
+ def visible(username, allowed_pids=None, st=None):
156
  """Every CUSTOMER cohort, membership scoped to what this user may open."""
157
+ return _visible(KEY, username, allowed_pids, st=st)
158
 
159
 
160
  def new_id(name):
 
169
  return f"cohort_{slug or 'list'}_{_dt.datetime.now().strftime('%H%M%S')}"
170
 
171
 
172
+ def _create(key, username, name, members=(), note='', st=None):
173
  """Create a cohort in ONE bucket and return its id. Raises ValueError on a blank name or when
174
  the per-user cap is reached β€” refusing loudly beats silently dropping somebody's list.
175
 
 
179
  name = str(name or '').strip()[:120]
180
  if not name:
181
  raise ValueError('a cohort needs a name')
182
+ existing = _all_for(key, username, st=st)
183
  if len(existing) >= MAX_COHORTS:
184
  raise ValueError(f'you already have {MAX_COHORTS} cohorts β€” delete one first')
185
  cid = new_id(name)
 
189
  def _up(data):
190
  data.setdefault(username, {})[cid] = record
191
  return data
192
+ _store_for(st).update(key, _up)
193
  return cid
194
 
195
 
196
+ def create(username, name, members=(), note='', st=None):
197
  """Create a CUSTOMER cohort and return its id β€” see `_create`."""
198
+ return _create(KEY, username, name, members=members, note=note, st=st)
199
 
200
 
201
+ def _mutate(key, username, cohort_id, change, st=None):
202
  def _up(data):
203
  book = data.setdefault(username, {})
204
  c = book.get(cohort_id)
 
207
  change(c)
208
  c['updated'] = _now()
209
  return data
210
+ _store_for(st).update(key, _up)
211
 
212
 
213
+ def _add_members(key, username, cohort_id, pids, st=None):
214
  """Union new ids into a cohort, PRESERVING the existing order and appending the new ones.
215
 
216
  Additive on purpose: "add to list" from a view can be clicked twice, and the second click
 
218
  """
219
  def _change(c):
220
  c['members'] = _clean_members(list(c.get('members') or []) + list(pids or []))
221
+ _mutate(key, username, cohort_id, _change, st=st)
222
 
223
 
224
+ def add_members(username, cohort_id, pids, st=None):
225
  """Union new ids into a CUSTOMER cohort β€” see `_add_members`."""
226
+ _add_members(KEY, username, cohort_id, pids, st=st)
227
 
228
 
229
+ def _remove_members(key, username, cohort_id, pids, st=None):
230
  drop = {int(p) for p in (pids or []) if str(p).lstrip('-').isdigit()}
231
 
232
  def _change(c):
233
  c['members'] = [p for p in _clean_members(c.get('members')) if p not in drop]
234
+ _mutate(key, username, cohort_id, _change, st=st)
235
 
236
 
237
+ def remove_members(username, cohort_id, pids, st=None):
238
+ _remove_members(KEY, username, cohort_id, pids, st=st)
239
 
240
 
241
+ def _rename(key, username, cohort_id, name, st=None):
242
  name = str(name or '').strip()[:120]
243
  if not name:
244
  raise ValueError('a cohort needs a name')
245
+ _mutate(key, username, cohort_id, lambda c: c.__setitem__('name', name), st=st)
246
 
247
 
248
+ def rename(username, cohort_id, name, st=None):
249
+ _rename(KEY, username, cohort_id, name, st=st)
250
 
251
 
252
+ def _set_note(key, username, cohort_id, note, st=None):
253
+ _mutate(key, username, cohort_id, lambda c: c.__setitem__('note', str(note or '')[:2000]), st=st)
254
 
255
 
256
+ def set_note(username, cohort_id, note, st=None):
257
+ _set_note(KEY, username, cohort_id, note, st=st)
258
 
259
 
260
+ def _delete(key, username, cohort_id, st=None):
261
  def _up(data):
262
  (data.get(username) or {}).pop(cohort_id, None)
263
  return data
264
+ _store_for(st).update(key, _up)
265
 
266
 
267
+ def delete(username, cohort_id, st=None):
268
+ _delete(KEY, username, cohort_id, st=st)
269
 
270
 
271
  def rows_for(cohort, pool_rows):
 
292
  code it already was.
293
  """
294
 
295
+ __slots__ = ('scope', 'key', 'noun', 'st')
296
 
297
+ def __init__(self, scope, st=None):
298
  self.scope = str(scope or 'customer').strip().lower()
299
  self.key = key_for(self.scope)
300
  self.noun = noun_for(self.scope)
301
+ self.st = st
302
 
303
  def __repr__(self):
304
  return f'CohortStore({self.scope!r} -> {self.key!r})'
305
 
306
  def all_for(self, username):
307
+ return _all_for(self.key, username, st=self.st)
308
 
309
  def visible(self, username, allowed_pids=None):
310
+ return _visible(self.key, username, allowed_pids, st=self.st)
311
 
312
  def create(self, username, name, members=(), note=''):
313
+ return _create(self.key, username, name, members=members, note=note, st=self.st)
314
 
315
  def add_members(self, username, cohort_id, pids):
316
+ _add_members(self.key, username, cohort_id, pids, st=self.st)
317
 
318
  def remove_members(self, username, cohort_id, pids):
319
+ _remove_members(self.key, username, cohort_id, pids, st=self.st)
320
 
321
  def rename(self, username, cohort_id, name):
322
+ _rename(self.key, username, cohort_id, name, st=self.st)
323
 
324
  def set_note(self, username, cohort_id, note):
325
+ _set_note(self.key, username, cohort_id, note, st=self.st)
326
 
327
  def delete(self, username, cohort_id):
328
+ _delete(self.key, username, cohort_id, st=self.st)
329
 
330
 
331
+ def scoped(scope=None, st=None):
332
  """The cohort store for ONE topic β€” THE resolution point (R9).
333
 
334
  β›” Returns THIS MODULE for the customer topic, not a `CohortStore(KEY)`. Two reasons, and both
 
339
  that returned a bound object would silently ignore the patch, leaving a gate green over
340
  code it is no longer exercising.
341
  """
342
+ if st is None and str(scope or 'customer').strip().lower() in LEGACY_SCOPES:
343
  return _sys.modules[__name__]
344
+ return CohortStore(scope, st=st)
345
 
346
 
347
  #: What ONE row of the customer topic is called β€” the module facade's answer to `CohortStore.noun`,