- Backend: GET /api/v1/data-resources accepts parent_path; LIKE
'{ws_id}/%/{escaped}/%' AND NOT LIKE '{ws_id}/%/{escaped}/%/%' on
StorageObjects.object_key (workspace-wide, escapes _ and %, mirrors
list_scripts parent_path semantics). 13 new tests in
test_resources.py (helper unit / SQL compile / SQLite behavioral).
- Frontend: listResources gains parentPath arg, propagated through
WorkspaceBoundApi + AuthContext binding. WorkspaceTreeGroup title
count and ScriptExplorer header count now include dataResources.
memberScriptGroups backfills data-only owners so users with only
data resources still render a group. loadDataResources accepts an
optional parentPath, default empty preserves prior behavior.
Codex review of ffec234 flagged:
1. **MEDIUM — loadScriptCount in-flight race on workspace switch.**
Previous implementation used `if (get().scriptCountLoading) return`
to dedupe. That meant switching workspaces WHILE a fetch was in
flight dropped the new fetch entirely; the stale response from the
previous workspace then overwrote state, leaving the dashboard
showing workspace A's total while the user is on workspace B.
Fix: drop the dedupe-via-flag, use a module-level
`_scriptCountSeq` counter. Every call increments, captures the
seq at start, and the response/finally block only mutates state
when `_scriptCountSeq === seq` — stale responses are silently
dropped. Rapid workspace switches each get their own fetch; only
the latest response wins.
2. **LOW — scriptCount scope comment was misleading.**
"范围与 list_scripts(parent_path=\"\") 对齐" is wrong: the
count includes all descendant depths, not just root-level.
Behaviour is correct for the dashboard's "全部脚本" intent but
the comment would mislead the next maintainer. Rewritten to
explicitly call out that the count is the UNION across all
parent_path depths, with the rationale for each design choice.
3. **LOW — local `from backend.scripts import _escape_like_pattern`
inside `resources.py` keyword-search block.**
The "avoid cycle" justification was false: scripts.py and
resources.py don't import each other at module level. Moved to
the top-of-file import block.
Skipped:
- get_workspace_tree `like_prefix` not run through the escape helper
(user_id is a 26-char Crockford ULID so no `_`/`%` can appear, but
the invariant is not documented at the call site). Pre-existing
pattern; out of scope for this round.
Verified: pytest 65 passed; pnpm typecheck clean.
Codex review of #36 + #37 surfaced that my prior `escape="\\"` only
declared the escape character — the pattern literals themselves still
contained unescaped `_` and `%`, so `parent_path="foo_bar"` continued
to match `fooXbar/...`, `foo2bar/...`, etc. My earlier ESCAPE-clause
assertions were tautological: they verified the SQL rendered the
ESCAPE keyword without ever checking that the pattern was actually
escaped. The tests passed; the leak persisted.
Fix in three layers:
1. Real escape: `backend/src/backend/scripts.py` gains
`_escape_like_pattern(value)` that escapes `\` → `\\`, `%` → `\%`,
`_` → `\_` (in that order — the escape char MUST be escaped first).
`_build_list_scripts_descendant_prefix` now returns the escaped
prefix. `list_workspace_directories` and `delete_workspace_directory`
also escape their server-built prefixes. `count_scripts` escapes
the user subtree prefix.
2. Same bug elsewhere: `backend/src/backend/resources.py:404` had the
identical `DataResources.resource_name.like(f"%{keyword}%")`
pattern; a search for "100%" would match everything. Now escaped
too.
3. Count endpoint scope: `count_scripts` was workspace-wide and
skipped the StorageObjects JOIN. Now INNER JOINs StorageObjects
(drops orphans whose current_object_id is dangling) and filters
by `workspace/{user_id}/` subtree so the result matches what
`list_scripts(parent_path="")` would return. Multi-member
workspaces no longer over-report, and orphan rows no longer
inflate the count.
Frontend: `DashboardRoute` is not keyed by workspace/user (only
ScriptsPage is), so without a workspace_id dep the previous
workspace's count persisted across navigation. useEffect now depends
on `currentWorkspace?.workspace_id`; `loadScriptCount` clears the
count to null at the start of the fetch so the dashboard doesn't
flash a stale number.
Tests — backend/tests/test_list_scripts_parent_path.py
- Rewritten with three layers of coverage:
* Pure helper tests for `_escape_like_pattern` (7 cases including
backslash-escape-first ordering).
* SQL-contract tests asserting the COMPILED PATTERN contains the
escaped form (lowercased to neutralise SQLAlchemy keyword casing).
* BEHAVIORAL tests on SQLite in-memory with the same LIKE
semantics — proves the fix actually prevents the wildcard leak.
Includes a negative test (without escape, siblings DO match) so
the fixture is verified to exercise the bug.
Tests — backend/tests/test_count_scripts.py
- Updated to assert the JOIN + user-scope filter. New test verifies
two different users in the same workspace get different subtrees.
Verified:
- pytest backend/tests: 65 passed (43 baseline + 12 list_scripts + 4 count + 6 helper/SQLite behavioral)
- pnpm typecheck: clean
- Raw SQL on MySQL (live DB) confirms `LIKE 'workspace/.../foo\_bar/%%' ESCAPE '\\'`.