feat(persistence): PR6 T6.2 — ThreadMetaRepository.get filters by workspace_id

`get()` accepts `workspace_id: str | None | _AutoSentinel = AUTO` and
moves the workspace check into the SQL WHERE so cross-workspace lookups
short-circuit without loading the row. `MemoryThreadMetaStore._get_owned_record`
gets the same filter for parity.

The user_id check stays as a post-load comparison (preserves the existing
shared-row semantics where row.user_id IS NULL means "everyone in this
workspace"). Cross-workspace always returns None, never the row.

3 new tests cover the three states: in-workspace get returns the row,
out-of-workspace get returns None even when user_id matches, explicit
workspace_id=None bypasses (migration / CLI). Existing 22 thread_meta
tests stay green.
This commit is contained in:
1445043649
2026-05-13 17:19:00 +08:00
parent 361e653d37
commit 296a4f1950
3 changed files with 72 additions and 7 deletions
@@ -71,13 +71,18 @@ class ThreadMetaRepository(ThreadMetaStore):
thread_id: str,
*,
user_id: str | None | _AutoSentinel = AUTO,
workspace_id: str | None | _WorkspaceAutoSentinel = WORKSPACE_AUTO,
) -> dict | None:
resolved_user_id = resolve_user_id(user_id, method_name="ThreadMetaRepository.get")
resolved_workspace_id = resolve_workspace_id(workspace_id, method_name="ThreadMetaRepository.get")
stmt = select(ThreadMetaRow).where(ThreadMetaRow.thread_id == thread_id)
if resolved_workspace_id is not None:
stmt = stmt.where(ThreadMetaRow.workspace_id == resolved_workspace_id)
async with self._sf() as session:
row = await session.get(ThreadMetaRow, thread_id)
row = (await session.execute(stmt)).scalar_one_or_none()
if row is None:
return None
# Enforce owner filter unless explicitly bypassed (user_id=None).
# Owner filter still applies inside the workspace scope.
if resolved_user_id is not None and row.user_id != resolved_user_id:
return None
return self._row_to_dict(row)