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:
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.
## 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)
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>
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>
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 main2026-06-12 19:01:33 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Fixes two bugs in the shared-memory service.
#1 —
memory.listtag filter errored at the DB layerRoot cause:
apps/web/lib/mcp/tools.tsbuilt the tag filter with a raw Drizzlesqltemplate:Drizzle expands a JS array embedded in a
sqltemplate into positional params, not a single array param. So:tags @> ($1)::text[]with$1 = "kind.scratchpad"→ Postgres tried to cast a plain string as an array →malformed array literal: "kind.scratchpad".tags @> ($1, $2)::text[]→($1,$2)is a record →cannot cast type record to text[].memory.write/memory.searchnever hit this because they go through the typed column (toDriverbuilds a proper{"a","b"}literal) andmemory.searchusestags && ${tags}::text[]via postgres-js (a different driver that auto-interpolates arrays), not Drizzle'ssql.Fix: use Drizzle's array operator, which binds the array as one
text[]param via the column'stoDriverand keeps the@>"require ALL tags" semantics:Verified the generated SQL:
"memories"."tags" @> $1with 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.tsxfiltered the list witheq(projects.userId, userId)— owner-only. A user with anrw/rogroup share saw nothing, even thoughproject.identifyalready returned{shared:true, access}for the same project.Fix: use
getAccessibleProjects(userId, groupNames)(owner ∪ group-shared — the same helpermemories/search already use), aggregate counts over that id set, and add ashared · ro|rwbadge to non-owned rows.Diagnostic: will
shadow@dao-mail.comnow seehosting-assistant-ops?It depends — and this is partly an identity/grouping nuance, not purely the list-query bug:
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 fromctx.groups, which for an OIDC bearer token is the LIVEgroupsclaim on the token.user_groupstable viagetUserGroupNames, anduser_groupsis only refreshed on interactive web sign-in (auth.ts→syncUserGroupsFromClaim).So:
shadow's membership in the group thathosting-assistant-opsis shared with is present inuser_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.shadowgot{shared:true}fromproject.identifyvia a live bearer-token claim but hasn't signed into the Web UI since joining that group (or their web session'sgroupsclaim omitted it),user_groupsis 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'suser_groupsstate, so the concrete outcome can't be asserted from source alone. If it still doesn't show after this fix, haveshadowsign out/in to the Web UI to resyncuser_groups.Not changed (deliberately)
apps/web/lib/snippets.ts(listSnippets, line ~352) has the identical raw-sqltag-filter bug (${snippets.tags} @> ${tags}::text[]). Out of scope for these two issues, but it will fail the same way and should get the samearrayContainsfix in a follow-up.memory.search's tag behavior (it's a boost via postgres-js and works correctly);memory.listintentionally remains a strict require-all.Closes #1
Closes #2
🤖 Generated with Claude Code
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>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>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 DrizzlearrayContains(snippets.tags, tags), matching the memory.list fix and preserving require-ALL-tags semantics. Dropped the now-unusedsqlimport. Typecheck (tsc --noEmit) passes. Commitb3f7e60.