fix: memory.list tag filter (#1) + Web UI shared-project visibility (#2) #3

Merged
jknapp merged 3 commits from fix/list-tag-filter-and-shared-project-visibility into main 2026-06-12 19:01:33 +00:00
Owner

Summary

Fixes two bugs in the shared-memory service.

#1memory.list tag filter errored at the DB layer

Root cause: apps/web/lib/mcp/tools.ts built the tag filter with a raw Drizzle sql template:

where.push(sql`${memories.tags} @> ${parsed.data.tags}::text[]`);

Drizzle expands a JS array embedded in a sql template into positional params, not a single array param. So:

  • one tag → tags @> ($1)::text[] with $1 = "kind.scratchpad" → Postgres tried to cast a plain string as an array → malformed array literal: "kind.scratchpad".
  • two tags → tags @> ($1, $2)::text[]($1,$2) is a record → cannot cast type record to text[].

memory.write / memory.search never hit this because they go through the typed column (toDriver builds a proper {"a","b"} literal) and memory.search uses tags && ${tags}::text[] via postgres-js (a different driver that auto-interpolates arrays), not Drizzle's sql.

Fix: use Drizzle's array operator, which binds the array as one text[] param via the column's toDriver and keeps the @> "require ALL tags" semantics:

where.push(arrayContains(memories.tags, parsed.data.tags));

Verified the generated SQL: "memories"."tags" @> $1 with param {"a","b"} (single array literal) vs. the old @> ($1,$2)::text[].

#2 — Web UI didn't list projects SHARED to the user

Root cause: apps/web/app/(authed)/projects/page.tsx filtered the list with eq(projects.userId, userId) — owner-only. A user with an rw/ro group share saw nothing, even though project.identify already returned {shared:true, access} for the same project.

Fix: use getAccessibleProjects(userId, groupNames) (owner ∪ group-shared — the same helper memories/search already use), aggregate counts over that id set, and add a shared · ro|rw badge to non-owned rows.

Diagnostic: will shadow@dao-mail.com now see hosting-assistant-ops?

It depends — and this is partly an identity/grouping nuance, not purely the list-query bug:

  • Sharing is by Authentik group (project_shares.group_id), not by individual user. Visibility = "user is in a group the project is shared with".
  • project.identify (MCP/CLI) computes groups from ctx.groups, which for an OIDC bearer token is the LIVE groups claim on the token.
  • The Web UI computes groups from the user_groups table via getUserGroupNames, and user_groups is only refreshed on interactive web sign-in (auth.tssyncUserGroupsFromClaim).

So:

  • If shadow's membership in the group that hosting-assistant-ops is shared with is present in user_groups (i.e. they've signed into the Web UI since being added to that group), then this list-query fix makes the project appear.
  • If shadow got {shared:true} from project.identify via a live bearer-token claim but hasn't signed into the Web UI since joining that group (or their web session's groups claim omitted it), user_groups is stale and the project will still not appear until they re-authenticate to the Web UI. That's an identity-sync nuance, independent of this query fix.

No DB/fixtures in the repo to confirm shadow's user_groups state, so the concrete outcome can't be asserted from source alone. If it still doesn't show after this fix, have shadow sign out/in to the Web UI to resync user_groups.

Not changed (deliberately)

  • apps/web/lib/snippets.ts (listSnippets, line ~352) has the identical raw-sql tag-filter bug (${snippets.tags} @> ${tags}::text[]). Out of scope for these two issues, but it will fail the same way and should get the same arrayContains fix in a follow-up.
  • Did not change memory.search's tag behavior (it's a boost via postgres-js and works correctly); memory.list intentionally remains a strict require-all.
  • Dashboard's "Projects" card stays owned-only by design (per its existing comment); only the dedicated Projects list page was widened.

Closes #1
Closes #2

🤖 Generated with Claude Code

## Summary Fixes two bugs in the shared-memory service. ### #1 — `memory.list` tag filter errored at the DB layer **Root cause:** `apps/web/lib/mcp/tools.ts` built the tag filter with a raw Drizzle `sql` template: ```ts where.push(sql`${memories.tags} @> ${parsed.data.tags}::text[]`); ``` Drizzle expands a JS array embedded in a `sql` template into **positional params**, not a single array param. So: - one tag → `tags @> ($1)::text[]` with `$1 = "kind.scratchpad"` → Postgres tried to cast a plain string as an array → `malformed array literal: "kind.scratchpad"`. - two tags → `tags @> ($1, $2)::text[]` → `($1,$2)` is a record → `cannot cast type record to text[]`. `memory.write` / `memory.search` never hit this because they go through the typed column (`toDriver` builds a proper `{"a","b"}` literal) and `memory.search` uses `tags && ${tags}::text[]` via **postgres-js** (a different driver that auto-interpolates arrays), not Drizzle's `sql`. **Fix:** use Drizzle's array operator, which binds the array as one `text[]` param via the column's `toDriver` and keeps the `@>` "require ALL tags" semantics: ```ts where.push(arrayContains(memories.tags, parsed.data.tags)); ``` Verified the generated SQL: `"memories"."tags" @> $1` with param `{"a","b"}` (single array literal) vs. the old `@> ($1,$2)::text[]`. ### #2 — Web UI didn't list projects SHARED to the user **Root cause:** `apps/web/app/(authed)/projects/page.tsx` filtered the list with `eq(projects.userId, userId)` — owner-only. A user with an `rw`/`ro` group share saw nothing, even though `project.identify` already returned `{shared:true, access}` for the same project. **Fix:** use `getAccessibleProjects(userId, groupNames)` (owner ∪ group-shared — the same helper `memories`/search already use), aggregate counts over that id set, and add a `shared · ro|rw` badge to non-owned rows. ## Diagnostic: will `shadow@dao-mail.com` now see `hosting-assistant-ops`? It depends — and this is partly an identity/grouping nuance, not purely the list-query bug: - Sharing is **by Authentik group** (`project_shares.group_id`), not by individual user. Visibility = "user is in a group the project is shared with". - `project.identify` (MCP/CLI) computes groups from `ctx.groups`, which for an **OIDC bearer token is the LIVE `groups` claim** on the token. - The **Web UI** computes groups from the `user_groups` table via `getUserGroupNames`, and `user_groups` is **only refreshed on interactive web sign-in** (`auth.ts` → `syncUserGroupsFromClaim`). So: - If `shadow`'s membership in the group that `hosting-assistant-ops` is shared with is present in `user_groups` (i.e. they've signed into the Web UI since being added to that group), then **this list-query fix makes the project appear**. - If `shadow` got `{shared:true}` from `project.identify` via a live bearer-token claim but hasn't signed into the Web UI since joining that group (or their web session's `groups` claim omitted it), `user_groups` is stale and the project will **still not appear** until they re-authenticate to the Web UI. That's an identity-sync nuance, independent of this query fix. No DB/fixtures in the repo to confirm `shadow`'s `user_groups` state, so the concrete outcome can't be asserted from source alone. If it still doesn't show after this fix, have `shadow` sign out/in to the Web UI to resync `user_groups`. ## Not changed (deliberately) - `apps/web/lib/snippets.ts` (`listSnippets`, line ~352) has the **identical** raw-`sql` tag-filter bug (`${snippets.tags} @> ${tags}::text[]`). Out of scope for these two issues, but it will fail the same way and should get the same `arrayContains` fix in a follow-up. - Did not change `memory.search`'s tag behavior (it's a boost via postgres-js and works correctly); `memory.list` intentionally remains a strict require-all. - Dashboard's "Projects" card stays owned-only by design (per its existing comment); only the dedicated Projects list page was widened. Closes #1 Closes #2 🤖 Generated with [Claude Code](https://claude.com/claude-code)
jknapp added 2 commits 2026-06-12 18:38:52 +00:00
memory.list built its tag filter with a raw sql template:
  sql`${memories.tags} @> ${tags}::text[]`
Drizzle expands a JS array embedded in a sql template into positional
params, so one tag produced `@> ($1)::text[]` (Postgres rejected the
bound string as a malformed array literal) and two tags produced
`@> ($1,$2)::text[]` (a record, hence "cannot cast type record to
text[]"). Switch to arrayContains(memories.tags, tags), which binds the
array as one text[] param via the column's toDriver and preserves the
"require ALL tags" (@>) semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Projects page filtered with eq(projects.userId, userId), so a user
with an rw (or ro) share on someone else's project never saw it in the
list — even though project.identify already returned {shared, access}
for the same project. Switch to getAccessibleProjects(userId,
groupNames) (owner ∪ group-shared, the same helper search/memories use)
and aggregate counts over that id set, and label non-owned rows with a
'shared · ro|rw' badge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jknapp added 1 commit 2026-06-12 18:46:46 +00:00
Replace the raw `${snippets.tags} @> ${tags}::text[]` template with
Drizzle's arrayContains, matching the memory.list fix. The raw template
expanded the JS array into positional params, producing a malformed
array literal (one tag) / record-cast error (two tags) at runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author
Owner

Folded in an additional fix on this branch: snippet.list (apps/web/lib/snippets.ts, listSnippets) had the identical tag-filter bug as memory.list — a raw ${snippets.tags} @> ${tags}::text[] template that expanded the JS array into positional params (($1)::text[] / ($1,$2)::text[]), which Postgres rejects as a malformed array literal (one tag) / record cast (two tags). Replaced with Drizzle arrayContains(snippets.tags, tags), matching the memory.list fix and preserving require-ALL-tags semantics. Dropped the now-unused sql import. Typecheck (tsc --noEmit) passes. Commit b3f7e60.

Folded in an additional fix on this branch: `snippet.list` (apps/web/lib/snippets.ts, listSnippets) had the **identical** tag-filter bug as memory.list — a raw ``${snippets.tags} @> ${tags}::text[]`` template that expanded the JS array into positional params (`($1)::text[]` / `($1,$2)::text[]`), which Postgres rejects as a malformed array literal (one tag) / record cast (two tags). Replaced with Drizzle `arrayContains(snippets.tags, tags)`, matching the memory.list fix and preserving require-ALL-tags semantics. Dropped the now-unused `sql` import. Typecheck (`tsc --noEmit`) passes. Commit b3f7e60.
jknapp merged commit 0c11869af8 into main 2026-06-12 19:01:33 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: cybercove-labs/shared-memory#3