From a482ede8ae0d483a4f59a850f39b90260d3ab3b5 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Thu, 16 Jul 2026 10:10:07 +0700 Subject: [PATCH 1/3] fix: dedup card .json file rows in boxel-cli search output The realm-server's mixed entry-search default now returns both an `instance` row and a `file` row for every dual-indexed card `.json`. Compose the `_isCardInstanceFile` dedup filter into `boxel search` queries that lack a narrowing positive type anchor (and aren't already pinned to a single kind via `scope`), so each card surfaces once while plain files stay listed. Mirrors the host search-sheet dedup. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/boxel-cli/src/commands/search.ts | 100 ++++++++++++++++++ .../tests/commands/search-query.test.ts | 71 +++++++++++++ .../tests/integration/search.test.ts | 50 +++++++-- 3 files changed, 213 insertions(+), 8 deletions(-) diff --git a/packages/boxel-cli/src/commands/search.ts b/packages/boxel-cli/src/commands/search.ts index 32f2c71fcb4..02c16f93493 100644 --- a/packages/boxel-cli/src/commands/search.ts +++ b/packages/boxel-cli/src/commands/search.ts @@ -7,6 +7,13 @@ import { import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; import { resolveRealmIdentifier } from '../lib/resolve-realm-identifier.ts'; import { resourceIdentity } from '@cardstack/runtime-common/resource-identity'; +import { CARD_INSTANCE_FILE_KEY } from '@cardstack/runtime-common/search-doc-keys'; +import { + baseRef, + baseCardRef, + baseFieldRef, + baseFileRef, +} from '@cardstack/runtime-common/constants'; import { FG_RED, DIM, RESET } from '../lib/colors.ts'; import { cliLog } from '../lib/cli-log.ts'; @@ -206,6 +213,97 @@ export function itemsFromSearchEntryDoc( return items; } +// A mixed (`scope: 'all'`) entry search returns both rows of a dual-indexed +// card `.json` — an `instance` row and a `file` row — so a card `.json` shows +// up twice unless the filter discriminates. The dedup filter keeps card +// instances and plain files but drops the redundant `.json` file row: the +// `_isCardInstanceFile` key is stamped only on that file row, so `eq: false` +// (which matches an absent key too) keeps every other row and drops it. +// +// Mirrors the host's search-sheet dedup (`excludeCardInstanceFileRows` / +// `scopeFilters` in host `card-search/query-builder.ts`). The type-anchor +// detection mirrors `getTypeRefsFromFilter` / `hasNarrowingPositiveTypeRef` +// there, reimplemented locally because runtime-common's query-field-utils pulls +// its `https://cardstack.com/base/*` imports into boxel-cli's dependency-light +// graph (same boundary the wire-translation helpers above note). +const ROOT_TYPE_REFS = [baseRef, baseCardRef, baseFieldRef, baseFileRef]; + +// Root refs (BaseDef/CardDef/FieldDef/FileDef) span kinds rather than narrowing +// to one, so an anchor on one of them must not suppress the dedup: a +// `type: baseRef` query still matches both rows of a card `.json`. +function isRootTypeRef(ref: unknown): boolean { + if (typeof ref !== 'object' || ref == null) { + return false; + } + let { module, name } = ref as { module?: unknown; name?: unknown }; + return ROOT_TYPE_REFS.some( + (root) => root.module === module && root.name === name, + ); +} + +interface TypeRefResult { + ref: unknown; + negated: boolean; +} + +// Walk a card-rooted filter and collect every type/on anchor with its polarity +// (flipped under each enclosing `not`). +function collectTypeRefs(filter: unknown, negated = false): TypeRefResult[] { + if (typeof filter !== 'object' || filter == null || Array.isArray(filter)) { + return []; + } + let f = filter as Record; + if (f.on != null) { + return [{ ref: f.on, negated }]; + } + if (f.type != null) { + return [{ ref: f.type, negated }]; + } + if (f.not != null) { + return collectTypeRefs(f.not, !negated); + } + if (Array.isArray(f.every)) { + return f.every.flatMap((node) => collectTypeRefs(node, negated)); + } + if (Array.isArray(f.any)) { + return f.any.flatMap((node) => collectTypeRefs(node, negated)); + } + return []; +} + +// True when the filter carries a positive, kind-narrowing type anchor — a +// picked card type matches only instance rows (no dupe to drop) and a picked +// file type must stay free to surface a `.json` file row that legitimately +// matches it. Root refs don't count (see `isRootTypeRef`). +function hasNarrowingTypeAnchor(filter: unknown): boolean { + return collectTypeRefs(filter).some( + (r) => !r.negated && !isRootTypeRef(r.ref), + ); +} + +/** + * Make a search query invariant to the mixed entry-search default: compose the + * `_isCardInstanceFile` dedup filter so each card `.json` surfaces once (via its + * instance row) while plain files stay listed. + * + * Skipped when the wire `scope` already pins a single kind (`'cards'`/`'files'`) + * or the filter carries a narrowing positive type anchor — the same rule the + * host applies. Exported for unit testing. + */ +export function composeMixedScopeDedup( + query: Record, +): Record { + if (query.scope === 'cards' || query.scope === 'files') { + return query; + } + if (hasNarrowingTypeAnchor(query.filter)) { + return query; + } + let dedup = { eq: { [CARD_INSTANCE_FILE_KEY]: false } }; + let filter = query.filter == null ? dedup : { every: [query.filter, dedup] }; + return { ...query, filter }; +} + /** * Federated search across one or more realms via the `_federated-search` * server endpoint. @@ -233,6 +331,8 @@ export async function search( let realmServerUrl = active.profile.realmServerUrl.replace(/\/$/, ''); let searchUrl = `${realmServerUrl}/_federated-search`; + query = composeMixedScopeDedup(query); + let realms: string[] = []; for (let realm of Array.isArray(realmUrls) ? realmUrls : [realmUrls]) { let resolved = resolveRealmIdentifier(realm, { profileManager: pm }); diff --git a/packages/boxel-cli/tests/commands/search-query.test.ts b/packages/boxel-cli/tests/commands/search-query.test.ts index 026b7a5c99a..2e083de218d 100644 --- a/packages/boxel-cli/tests/commands/search-query.test.ts +++ b/packages/boxel-cli/tests/commands/search-query.test.ts @@ -1,7 +1,9 @@ import { describe, it, expect } from 'vitest'; +import { baseRef } from '@cardstack/runtime-common/constants'; import { searchEntryRequestBody, itemsFromSearchEntryDoc, + composeMixedScopeDedup, } from '../../src/commands/search.ts'; const SkillRef = { @@ -13,6 +15,8 @@ const CardDefRef = { name: 'CardDef', }; +const DEDUP = { eq: { _isCardInstanceFile: false } }; + describe('searchEntryRequestBody — card-rooted query → entry wire grammar', () => { it('always requests the data-only fieldset and the given realms', () => { let body = searchEntryRequestBody({}, ['https://realm/a/']); @@ -182,3 +186,70 @@ describe('itemsFromSearchEntryDoc — flatten a data-only entry doc to items', ( expect(itemsFromSearchEntryDoc({})).toEqual([]); }); }); + +describe('composeMixedScopeDedup — invariant mixed-scope output', () => { + it('injects the dedup as the sole filter when the query has none', () => { + expect(composeMixedScopeDedup({})).toEqual({ filter: DEDUP }); + }); + + it('injects the dedup for a cardUrls-only lookup (no filter)', () => { + let query = { cardUrls: ['https://realm/a/x.json'] }; + expect(composeMixedScopeDedup(query)).toEqual({ + cardUrls: ['https://realm/a/x.json'], + filter: DEDUP, + }); + }); + + it('ANDs the dedup with an existing anchorless filter', () => { + expect(composeMixedScopeDedup({ filter: { matches: 'hello' } })).toEqual({ + filter: { every: [{ matches: 'hello' }, DEDUP] }, + }); + expect( + composeMixedScopeDedup({ filter: { eq: { _title: 'Mango' } } }), + ).toEqual({ filter: { every: [{ eq: { _title: 'Mango' } }, DEDUP] } }); + }); + + it('leaves a narrowing positive type anchor unchanged', () => { + let byType = { filter: { type: SkillRef } }; + expect(composeMixedScopeDedup(byType)).toBe(byType); + let byOn = { filter: { on: CardDefRef, eq: { cardTitle: 'x' } } }; + expect(composeMixedScopeDedup(byOn)).toBe(byOn); + let nested = { + filter: { every: [{ type: SkillRef }, { eq: { status: 'active' } }] }, + }; + expect(composeMixedScopeDedup(nested)).toBe(nested); + }); + + it('still dedups when the only anchor is a kind-spanning root ref', () => { + expect(composeMixedScopeDedup({ filter: { type: baseRef } })).toEqual({ + filter: { every: [{ type: baseRef }, DEDUP] }, + }); + }); + + it('still dedups when the type anchor is negated', () => { + expect( + composeMixedScopeDedup({ filter: { not: { type: SkillRef } } }), + ).toEqual({ filter: { every: [{ not: { type: SkillRef } }, DEDUP] } }); + }); + + it('skips the dedup when scope already pins a single kind', () => { + let cards = { scope: 'cards' }; + expect(composeMixedScopeDedup(cards)).toBe(cards); + let files = { scope: 'files' }; + expect(composeMixedScopeDedup(files)).toBe(files); + }); + + it('injects the dedup for the explicit mixed scope', () => { + expect(composeMixedScopeDedup({ scope: 'all' })).toEqual({ + scope: 'all', + filter: DEDUP, + }); + }); + + it('translates to the item.-prefixed dedup key on the wire', () => { + let body = searchEntryRequestBody(composeMixedScopeDedup({}), [ + 'https://realm/a/', + ]); + expect(body.filter).toEqual({ eq: { 'item._isCardInstanceFile': false } }); + }); +}); diff --git a/packages/boxel-cli/tests/integration/search.test.ts b/packages/boxel-cli/tests/integration/search.test.ts index 00013df10d7..899bc3c87b9 100644 --- a/packages/boxel-cli/tests/integration/search.test.ts +++ b/packages/boxel-cli/tests/integration/search.test.ts @@ -56,6 +56,10 @@ beforeAll(async () => { }, }, }), + // A plain (non-card) file: it has only a `file` row, so it must still + // appear in a mixed list-all after the card `.json` file rows are + // deduped away. + 'readme.txt': 'plain file contents', }, permissions: { [ownerUserId]: ['read', 'write', 'realm-owner'], @@ -86,17 +90,47 @@ afterAll(async () => { }); describe('federated search (integration)', () => { - it('returns all results when no filter is supplied', async () => { + it('returns each card once (no `.json` file-row dupe) plus plain files', async () => { let result = await search(realmHref, {}, { profileManager }); expect(result.ok, `search failed: ${result.error}`).toBe(true); - let titles = (result.data ?? []).map( - (entry) => - (entry as { attributes?: { cardTitle?: string } }).attributes - ?.cardTitle, - ); - expect(titles).toEqual( - expect.arrayContaining(['Shared Card', 'Other Card']), + let entries = (result.data ?? []) as { + id?: string; + type?: string; + attributes?: { cardTitle?: string }; + }[]; + + // Each card `.json` is dual-indexed (an instance row + a file row); the + // dedup drops the file row so the card appears exactly once. + let cardTitles = entries + .filter((e) => e.type === 'card') + .map((e) => e.attributes?.cardTitle) + .sort(); + expect(cardTitles).toEqual(['Other Card', 'Shared Card']); + + // No id appears twice. + let ids = entries.map((e) => e.id); + expect(ids.length).toBe(new Set(ids).size); + + // The plain (non-card) file is still listed. + let fileIds = entries + .filter((e) => e.type === 'file-meta') + .map((e) => e.id); + expect(fileIds).toContain(`${realmHref}readme.txt`); + // ...and neither card's `.json` file row leaked in. + expect(fileIds).not.toContain(`${realmHref}shared-card.json`); + expect(fileIds).not.toContain(`${realmHref}other-card.json`); + }); + + it('returns a single entry for a cardUrls `.json` lookup', async () => { + let result = await search( + realmHref, + { cardUrls: [`${realmHref}shared-card.json`] }, + { profileManager }, ); + expect(result.ok, `search failed: ${result.error}`).toBe(true); + let entries = result.data ?? []; + expect(entries.length).toBe(1); + expect((entries[0] as { id?: string }).id).toBe(`${realmHref}shared-card`); }); it('filters by cardTitle and returns only the matching card', async () => { From 1ff2bece5e21383bc26cc7d9c28ae6f490d2a243 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Thu, 16 Jul 2026 10:27:08 +0700 Subject: [PATCH 2/3] fix: keep boxel-cli search dependency-light (drop constants import) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `@cardstack/runtime-common/constants` import pulled runtime-common's `@cardstack/base/*` type graph into boxel-cli's `tsc --noEmit`, breaking CI lint. Detect a positive type anchor with a local filter walk instead, using the ticket's rule (skip dedup on any positive type/on anchor) — `_isCardInstanceFile` still comes from the import-clean search-doc-keys. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/boxel-cli/src/commands/search.ts | 79 ++++++------------- .../tests/commands/search-query.test.ts | 7 -- 2 files changed, 22 insertions(+), 64 deletions(-) diff --git a/packages/boxel-cli/src/commands/search.ts b/packages/boxel-cli/src/commands/search.ts index 02c16f93493..e1f0da086db 100644 --- a/packages/boxel-cli/src/commands/search.ts +++ b/packages/boxel-cli/src/commands/search.ts @@ -8,12 +8,6 @@ import { ensureTrailingSlash } from '@cardstack/runtime-common/paths'; import { resolveRealmIdentifier } from '../lib/resolve-realm-identifier.ts'; import { resourceIdentity } from '@cardstack/runtime-common/resource-identity'; import { CARD_INSTANCE_FILE_KEY } from '@cardstack/runtime-common/search-doc-keys'; -import { - baseRef, - baseCardRef, - baseFieldRef, - baseFileRef, -} from '@cardstack/runtime-common/constants'; import { FG_RED, DIM, RESET } from '../lib/colors.ts'; import { cliLog } from '../lib/cli-log.ts'; @@ -221,64 +215,35 @@ export function itemsFromSearchEntryDoc( // (which matches an absent key too) keeps every other row and drops it. // // Mirrors the host's search-sheet dedup (`excludeCardInstanceFileRows` / -// `scopeFilters` in host `card-search/query-builder.ts`). The type-anchor -// detection mirrors `getTypeRefsFromFilter` / `hasNarrowingPositiveTypeRef` -// there, reimplemented locally because runtime-common's query-field-utils pulls -// its `https://cardstack.com/base/*` imports into boxel-cli's dependency-light -// graph (same boundary the wire-translation helpers above note). -const ROOT_TYPE_REFS = [baseRef, baseCardRef, baseFieldRef, baseFileRef]; - -// Root refs (BaseDef/CardDef/FieldDef/FileDef) span kinds rather than narrowing -// to one, so an anchor on one of them must not suppress the dedup: a -// `type: baseRef` query still matches both rows of a card `.json`. -function isRootTypeRef(ref: unknown): boolean { - if (typeof ref !== 'object' || ref == null) { - return false; - } - let { module, name } = ref as { module?: unknown; name?: unknown }; - return ROOT_TYPE_REFS.some( - (root) => root.module === module && root.name === name, - ); -} - -interface TypeRefResult { - ref: unknown; - negated: boolean; -} - -// Walk a card-rooted filter and collect every type/on anchor with its polarity -// (flipped under each enclosing `not`). -function collectTypeRefs(filter: unknown, negated = false): TypeRefResult[] { +// `scopeFilters` in host `card-search/query-builder.ts`). The type-anchor check +// is a local walk rather than an import of runtime-common's +// `getTypeRefsFromFilter`, whose module pulls the `@cardstack/base/*` graph into +// boxel-cli's dependency-light build (the same boundary the wire-translation +// helpers above note). +// +// True when the filter carries a positive (non-negated) `type`/`on` anchor. A +// picked type discriminates the kind by itself — a card type matches only +// instance rows (no dupe to drop), a file type must stay free to surface a +// `.json` file row that legitimately matches it — so the dedup is skipped. +// Polarity flips under each enclosing `not`, so a negated anchor doesn't count. +function hasPositiveTypeAnchor(filter: unknown, negated = false): boolean { if (typeof filter !== 'object' || filter == null || Array.isArray(filter)) { - return []; + return false; } let f = filter as Record; - if (f.on != null) { - return [{ ref: f.on, negated }]; - } - if (f.type != null) { - return [{ ref: f.type, negated }]; + if (f.on != null || f.type != null) { + return !negated; } if (f.not != null) { - return collectTypeRefs(f.not, !negated); + return hasPositiveTypeAnchor(f.not, !negated); } if (Array.isArray(f.every)) { - return f.every.flatMap((node) => collectTypeRefs(node, negated)); + return f.every.some((node) => hasPositiveTypeAnchor(node, negated)); } if (Array.isArray(f.any)) { - return f.any.flatMap((node) => collectTypeRefs(node, negated)); + return f.any.some((node) => hasPositiveTypeAnchor(node, negated)); } - return []; -} - -// True when the filter carries a positive, kind-narrowing type anchor — a -// picked card type matches only instance rows (no dupe to drop) and a picked -// file type must stay free to surface a `.json` file row that legitimately -// matches it. Root refs don't count (see `isRootTypeRef`). -function hasNarrowingTypeAnchor(filter: unknown): boolean { - return collectTypeRefs(filter).some( - (r) => !r.negated && !isRootTypeRef(r.ref), - ); + return false; } /** @@ -287,8 +252,8 @@ function hasNarrowingTypeAnchor(filter: unknown): boolean { * instance row) while plain files stay listed. * * Skipped when the wire `scope` already pins a single kind (`'cards'`/`'files'`) - * or the filter carries a narrowing positive type anchor — the same rule the - * host applies. Exported for unit testing. + * or the filter carries a positive type anchor — the same rule the host applies. + * Exported for unit testing. */ export function composeMixedScopeDedup( query: Record, @@ -296,7 +261,7 @@ export function composeMixedScopeDedup( if (query.scope === 'cards' || query.scope === 'files') { return query; } - if (hasNarrowingTypeAnchor(query.filter)) { + if (hasPositiveTypeAnchor(query.filter)) { return query; } let dedup = { eq: { [CARD_INSTANCE_FILE_KEY]: false } }; diff --git a/packages/boxel-cli/tests/commands/search-query.test.ts b/packages/boxel-cli/tests/commands/search-query.test.ts index 2e083de218d..76b4b5da7f3 100644 --- a/packages/boxel-cli/tests/commands/search-query.test.ts +++ b/packages/boxel-cli/tests/commands/search-query.test.ts @@ -1,5 +1,4 @@ import { describe, it, expect } from 'vitest'; -import { baseRef } from '@cardstack/runtime-common/constants'; import { searchEntryRequestBody, itemsFromSearchEntryDoc, @@ -220,12 +219,6 @@ describe('composeMixedScopeDedup — invariant mixed-scope output', () => { expect(composeMixedScopeDedup(nested)).toBe(nested); }); - it('still dedups when the only anchor is a kind-spanning root ref', () => { - expect(composeMixedScopeDedup({ filter: { type: baseRef } })).toEqual({ - filter: { every: [{ type: baseRef }, DEDUP] }, - }); - }); - it('still dedups when the type anchor is negated', () => { expect( composeMixedScopeDedup({ filter: { not: { type: SkillRef } } }), From a1420f14e993aedec0095b78b7184f7edb2eb51b Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Thu, 16 Jul 2026 11:01:40 +0700 Subject: [PATCH 3/3] fix: keep the search dedup when the only type anchor is a root ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root refs (BaseDef/CardDef/FieldDef/FileDef) span kinds rather than narrowing to one — every file row carries BaseDef in its type chain, so a type: BaseDef query matches both rows of a card .json and would leak duplicates when it suppressed the dedup. Match the host's hasNarrowingPositiveTypeRef rule, with the root refs as local literals (URL and scoped-alias module spellings) so the dependency-light build stays free of the runtime-common constants import. Co-Authored-By: Claude Fable 5 --- packages/boxel-cli/src/commands/search.ts | 65 +++++++++++++------ .../tests/commands/search-query.test.ts | 23 ++++++- 2 files changed, 68 insertions(+), 20 deletions(-) diff --git a/packages/boxel-cli/src/commands/search.ts b/packages/boxel-cli/src/commands/search.ts index e1f0da086db..a34254ff838 100644 --- a/packages/boxel-cli/src/commands/search.ts +++ b/packages/boxel-cli/src/commands/search.ts @@ -215,33 +215,60 @@ export function itemsFromSearchEntryDoc( // (which matches an absent key too) keeps every other row and drops it. // // Mirrors the host's search-sheet dedup (`excludeCardInstanceFileRows` / -// `scopeFilters` in host `card-search/query-builder.ts`). The type-anchor check -// is a local walk rather than an import of runtime-common's -// `getTypeRefsFromFilter`, whose module pulls the `@cardstack/base/*` graph into -// boxel-cli's dependency-light build (the same boundary the wire-translation -// helpers above note). -// -// True when the filter carries a positive (non-negated) `type`/`on` anchor. A -// picked type discriminates the kind by itself — a card type matches only -// instance rows (no dupe to drop), a file type must stay free to surface a -// `.json` file row that legitimately matches it — so the dedup is skipped. -// Polarity flips under each enclosing `not`, so a negated anchor doesn't count. -function hasPositiveTypeAnchor(filter: unknown, negated = false): boolean { +// `scopeFilters` in host `card-search/query-builder.ts`). The type-anchor +// detection mirrors `getTypeRefsFromFilter` / `hasNarrowingPositiveTypeRef` +// there, reimplemented locally — with the root refs as literals — because both +// runtime-common modules pull the `@cardstack/base/*` graph into boxel-cli's +// dependency-light build (the same boundary the wire-translation helpers above +// note). + +// Root refs (BaseDef/CardDef/FieldDef/FileDef) span kinds rather than +// narrowing to one — every file row carries `BaseDef` in its type chain, so a +// `type: BaseDef` query matches both rows of a card `.json` and still needs +// the dedup. The base-realm module can be spelled as its URL or its scoped +// alias; both resolve to the same module server-side. +const ROOT_TYPE_REF_MODULES = [ + 'https://cardstack.com/base/card-api', + '@cardstack/base/card-api', +]; +const ROOT_TYPE_REF_NAMES = ['BaseDef', 'CardDef', 'FieldDef', 'FileDef']; + +function isRootTypeRef(ref: unknown): boolean { + if (typeof ref !== 'object' || ref == null) { + return false; + } + let { module, name } = ref as { module?: unknown; name?: unknown }; + return ( + typeof module === 'string' && + typeof name === 'string' && + ROOT_TYPE_REF_MODULES.includes(module) && + ROOT_TYPE_REF_NAMES.includes(name) + ); +} + +// True when the filter carries a positive (non-negated), kind-narrowing +// `type`/`on` anchor. A narrowing type discriminates the kind by itself — a +// card type matches only instance rows (no dupe to drop), a file type must +// stay free to surface a `.json` file row that legitimately matches it — so +// the dedup is skipped. Polarity flips under each enclosing `not`, so a +// negated anchor doesn't count — and neither does a kind-spanning root ref +// (see `isRootTypeRef`). +function hasNarrowingTypeAnchor(filter: unknown, negated = false): boolean { if (typeof filter !== 'object' || filter == null || Array.isArray(filter)) { return false; } let f = filter as Record; if (f.on != null || f.type != null) { - return !negated; + return !negated && !isRootTypeRef(f.on ?? f.type); } if (f.not != null) { - return hasPositiveTypeAnchor(f.not, !negated); + return hasNarrowingTypeAnchor(f.not, !negated); } if (Array.isArray(f.every)) { - return f.every.some((node) => hasPositiveTypeAnchor(node, negated)); + return f.every.some((node) => hasNarrowingTypeAnchor(node, negated)); } if (Array.isArray(f.any)) { - return f.any.some((node) => hasPositiveTypeAnchor(node, negated)); + return f.any.some((node) => hasNarrowingTypeAnchor(node, negated)); } return false; } @@ -252,8 +279,8 @@ function hasPositiveTypeAnchor(filter: unknown, negated = false): boolean { * instance row) while plain files stay listed. * * Skipped when the wire `scope` already pins a single kind (`'cards'`/`'files'`) - * or the filter carries a positive type anchor — the same rule the host applies. - * Exported for unit testing. + * or the filter carries a narrowing positive type anchor — the same rule the + * host applies. Exported for unit testing. */ export function composeMixedScopeDedup( query: Record, @@ -261,7 +288,7 @@ export function composeMixedScopeDedup( if (query.scope === 'cards' || query.scope === 'files') { return query; } - if (hasPositiveTypeAnchor(query.filter)) { + if (hasNarrowingTypeAnchor(query.filter)) { return query; } let dedup = { eq: { [CARD_INSTANCE_FILE_KEY]: false } }; diff --git a/packages/boxel-cli/tests/commands/search-query.test.ts b/packages/boxel-cli/tests/commands/search-query.test.ts index 76b4b5da7f3..618b07af40f 100644 --- a/packages/boxel-cli/tests/commands/search-query.test.ts +++ b/packages/boxel-cli/tests/commands/search-query.test.ts @@ -13,6 +13,10 @@ const CardDefRef = { module: 'https://cardstack.com/base/card-api', name: 'CardDef', }; +const BaseDefRef = { + module: 'https://cardstack.com/base/card-api', + name: 'BaseDef', +}; const DEDUP = { eq: { _isCardInstanceFile: false } }; @@ -211,7 +215,7 @@ describe('composeMixedScopeDedup — invariant mixed-scope output', () => { it('leaves a narrowing positive type anchor unchanged', () => { let byType = { filter: { type: SkillRef } }; expect(composeMixedScopeDedup(byType)).toBe(byType); - let byOn = { filter: { on: CardDefRef, eq: { cardTitle: 'x' } } }; + let byOn = { filter: { on: SkillRef, eq: { skillTitle: 'x' } } }; expect(composeMixedScopeDedup(byOn)).toBe(byOn); let nested = { filter: { every: [{ type: SkillRef }, { eq: { status: 'active' } }] }, @@ -219,6 +223,23 @@ describe('composeMixedScopeDedup — invariant mixed-scope output', () => { expect(composeMixedScopeDedup(nested)).toBe(nested); }); + it('still dedups when the only anchor is a kind-spanning root ref', () => { + // Every file row carries BaseDef in its type chain, so a root-ref anchor + // matches both rows of a card `.json` — it doesn't narrow the kind. + expect(composeMixedScopeDedup({ filter: { type: BaseDefRef } })).toEqual({ + filter: { every: [{ type: BaseDefRef }, DEDUP] }, + }); + let byOn = { filter: { on: CardDefRef, eq: { cardTitle: 'x' } } }; + expect(composeMixedScopeDedup(byOn)).toEqual({ + filter: { every: [byOn.filter, DEDUP] }, + }); + // The scoped-alias spelling of the base-realm module counts too. + let aliasRef = { module: '@cardstack/base/card-api', name: 'FileDef' }; + expect(composeMixedScopeDedup({ filter: { type: aliasRef } })).toEqual({ + filter: { every: [{ type: aliasRef }, DEDUP] }, + }); + }); + it('still dedups when the type anchor is negated', () => { expect( composeMixedScopeDedup({ filter: { not: { type: SkillRef } } }),