From a8a21e892239da7e015d9d9636dc86b885e5fa68 Mon Sep 17 00:00:00 2001 From: Hassan Abdel-Rahman Date: Fri, 20 Feb 2026 10:58:41 -0500 Subject: [PATCH] Omit deps from query field results during indexing --- AGENTS.md | 5 +- packages/realm-server/tests/indexing-test.ts | 153 ++++++++++++++++++ .../index-runner/card-indexer.ts | 5 + .../index-runner/dependency-resolver.ts | 119 +++++++++++--- 4 files changed, 262 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index df4e6de566..4edf5ab403 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,7 @@ `ember test --path dist --filter "some text that appears in module name or test name"` Note that the filter is matched against the module name and test name, not the file name! Try to avoid using pipe characters in the filter, since they can confuse auto-approval tool use filters set up by the user. - run `pnpm lint` in this directory to lint changes made to this package +- run `pnpm lint:fix` directly in this directory to apply fixes for lint failures made to this package that can be automatically fixed. #### Iterating on host tests with the Chrome MCP server @@ -96,6 +97,7 @@ Make sure not to commit `.only` to source control - make sure to kill previously running realm-server tests if they are still running before starting a new test run. - run `pnpm lint` directly in this directory to lint changes made to this package +- run `pnpm lint:fix` directly in this directory to apply fixes for lint failures made to this package that can be automatically fixed. ### packages/postgres - If you need to make a database migration use `pnpm create migration_name` to create a migration file so that the correct date timestamp prefix will be added to the file name. Then implement the migration inside the newly created file. @@ -106,7 +108,8 @@ ### packages/runtime-common - Functionality is tested via host and/or realm-server tests -- run `pnpm lint` directly in packages/host or directly in packages/realm-server to lint for changes made in this package. This package will be linted since both packages/host and package/realm-server consume this package. +- run `pnpm lint` directly in this directory to lint changes made to this package +- run `pnpm lint:js:fix` directly in this directory to apply fixes for js lint failures made to this package that can be automatically fixed. ## PR Instructions diff --git a/packages/realm-server/tests/indexing-test.ts b/packages/realm-server/tests/indexing-test.ts index 41f5f38eff..73be8636b6 100644 --- a/packages/realm-server/tests/indexing-test.ts +++ b/packages/realm-server/tests/indexing-test.ts @@ -2036,6 +2036,159 @@ module(basename(__filename), function () { ); }); + // remove this once we have a query based relationship invalidation strategy + test('does not capture deps from query-backed relationships', async function (assert) { + await realm.write( + 'query-rel-target.gts', + ` + import { CardDef, Component, contains, field } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class QueryRelTarget extends CardDef { + @field cardTitle = contains(StringField); + + static embedded = class Embedded extends Component { + + } + } + `, + ); + + await realm.write( + 'query-rel-consumer.gts', + ` + import { CardDef, Component, contains, field, linksTo, linksToMany } from "https://cardstack.com/base/card-api"; + import StringField from "https://cardstack.com/base/string"; + + export class QueryRelConsumer extends CardDef { + @field cardTitle = contains(StringField); + @field favorite = linksTo(() => CardDef, { + query: { + filter: { + eq: { + cardTitle: 'target', + }, + }, + }, + }); + @field matches = linksToMany(() => CardDef, { + query: { + filter: { + eq: { + cardTitle: 'target', + }, + }, + page: { + size: 10, + number: 0, + }, + }, + }); + + static isolated = class Isolated extends Component { + + } + } + `, + ); + + await realm.write( + 'query-rel-target-1.json', + JSON.stringify({ + data: { + attributes: { cardTitle: 'target' }, + meta: { + adoptsFrom: { + module: './query-rel-target', + name: 'QueryRelTarget', + }, + }, + }, + } as LooseSingleCardDocument), + ); + + await realm.write( + 'query-rel-consumer-1.json', + JSON.stringify({ + data: { + attributes: { cardTitle: 'consumer' }, + meta: { + adoptsFrom: { + module: './query-rel-consumer', + name: 'QueryRelConsumer', + }, + }, + }, + } as LooseSingleCardDocument), + ); + + let queryConsumerDoc = await realm.realmIndexQueryEngine.cardDocument( + new URL(`${testRealm}query-rel-consumer-1`), + { loadLinks: true }, + ); + if (queryConsumerDoc?.type === 'doc') { + let relationships = queryConsumerDoc.doc.data.relationships ?? {}; + let favorite = relationships.favorite as + | { + links?: Record; + data?: { type: string; id: string } | null; + } + | undefined; + let matches = relationships.matches as + | { + links?: Record; + data?: { type: string; id: string }[]; + } + | undefined; + assert.strictEqual( + typeof favorite?.links?.search, + 'string', + 'query linksTo relationship is present', + ); + assert.deepEqual( + favorite?.data, + { + type: 'card', + id: `${testRealm}query-rel-target-1`, + }, + 'query linksTo relationship contains matched target', + ); + assert.strictEqual( + typeof matches?.links?.search, + 'string', + 'query linksToMany relationship is present', + ); + assert.deepEqual( + matches?.data, + [ + { + type: 'card', + id: `${testRealm}query-rel-target-1`, + }, + ], + 'query linksToMany relationship contains matched targets', + ); + } else { + assert.ok(false, 'expected query-backed consumer document'); + } + + let deps = await depsFor(`${testRealm}query-rel-consumer-1.json`); + assert.true(deps.length > 0, 'consumer instance has deps'); + assert.notOk( + deps.includes(`${testRealm}query-rel-target-1.json`), + 'query-backed relationship target is not tracked as a dependency', + ); + assert.notOk( + deps.includes(`${testRealm}query-rel-target`), + 'query-backed relationship target module is not tracked as a dependency', + ); + }); + test('collects glimmer scoped CSS deps from first-degree and second-degree relationship instances', async function (assert) { await realm.write( 'second-rel.gts', diff --git a/packages/runtime-common/index-runner/card-indexer.ts b/packages/runtime-common/index-runner/card-indexer.ts index 4141919420..2424d4c97e 100644 --- a/packages/runtime-common/index-runner/card-indexer.ts +++ b/packages/runtime-common/index-runner/card-indexer.ts @@ -152,6 +152,10 @@ export async function performCardIndexing({ ), ); + let queryFieldPaths = dependencyResolver.extractQueryFieldRelationshipPaths( + resource, + (renderResult?.serialized?.data as CardResource | undefined) ?? null, + ); let relationshipDeps = new Set([ ...dependencyResolver.extractDirectRelationshipDeps( resource, @@ -164,6 +168,7 @@ export async function performCardIndexing({ ...dependencyResolver.extractSearchDocRelationshipDeps( renderError.searchData ?? null, instanceURL, + queryFieldPaths, ), ]); renderError.error.deps.push(...relationshipDeps); diff --git a/packages/runtime-common/index-runner/dependency-resolver.ts b/packages/runtime-common/index-runner/dependency-resolver.ts index ba8ff067df..eceb405ed6 100644 --- a/packages/runtime-common/index-runner/dependency-resolver.ts +++ b/packages/runtime-common/index-runner/dependency-resolver.ts @@ -158,7 +158,11 @@ export class IndexRunnerDependencyResolver { return deps; } - for (let value of Object.values(relationships)) { + let queryFieldPaths = this.queryFieldRelationshipPaths(relationships); + for (let [key, value] of Object.entries(relationships)) { + if (this.isQueryFieldRelationshipKey(key, queryFieldPaths)) { + continue; + } let entries = Array.isArray(value) ? value : [value]; for (let relationship of entries) { this.addRelationshipDependencyCandidates( @@ -175,6 +179,7 @@ export class IndexRunnerDependencyResolver { extractSearchDocRelationshipDeps( searchDoc: Record | null | undefined, relativeTo: URL, + queryFieldPaths?: Set, ): Set { let deps = new Set(); if (!searchDoc) { @@ -194,7 +199,7 @@ export class IndexRunnerDependencyResolver { } let visited = new Set(); - let visit = (value: unknown) => { + let visit = (value: unknown, path: string[] = []) => { if (value == null || typeof value !== 'object') { return; } @@ -205,7 +210,7 @@ export class IndexRunnerDependencyResolver { if (Array.isArray(value)) { for (let item of value) { - visit(item); + visit(item, path); } return; } @@ -221,8 +226,14 @@ export class IndexRunnerDependencyResolver { } } - for (let nested of Object.values(value as Record)) { - visit(nested); + for (let [key, nested] of Object.entries( + value as Record, + )) { + let nextPath = [...path, key]; + if (this.isQueryFieldSearchDocPath(nextPath, queryFieldPaths)) { + continue; + } + visit(nested, nextPath); } }; @@ -239,13 +250,6 @@ export class IndexRunnerDependencyResolver { return deps; } - let addDep = (id: string) => { - let normalized = this.normalizeRelationshipDependency(id, relativeTo); - if (normalized) { - deps.add(normalized); - } - }; - let data = serialized.data as CardResource | undefined; for (let dep of this.extractDirectRelationshipDeps( data ?? null, @@ -253,13 +257,9 @@ export class IndexRunnerDependencyResolver { )) { deps.add(dep); } - - for (let resource of serialized.included ?? []) { - if (typeof resource.id === 'string') { - addDep(resource.id); - } - } - + // Intentionally do not derive deps from `included` resources. We only track + // relationship edges from the consuming resource itself so query-backed + // relationships (which can seed included records) do not become deps. return deps; } @@ -388,6 +388,11 @@ export class IndexRunnerDependencyResolver { relationshipSearchDoc?: Record | null; relativeTo: URL; }): Promise> { + let queryFieldPaths = this.extractQueryFieldRelationshipPaths( + relationshipResource, + relationshipSourceResource ?? null, + (relationshipSerialized?.data as CardResource | undefined) ?? null, + ); let relationshipDeps = new Set([ ...this.extractRenderedRelationshipDeps({ renderedResource: relationshipResource, @@ -401,6 +406,7 @@ export class IndexRunnerDependencyResolver { ...this.extractSearchDocRelationshipDeps( relationshipSearchDoc, relativeTo, + queryFieldPaths, ), ]); let [expandedModuleDeps, expandedRelationshipDeps] = await Promise.all([ @@ -634,7 +640,14 @@ export class IndexRunnerDependencyResolver { let deps = new Set(); let renderedRelationships = renderedResource.relationships; + let queryFieldPaths = this.extractQueryFieldRelationshipPaths( + renderedResource, + sourceResource ?? null, + ); for (let [key, value] of Object.entries(renderedRelationships)) { + if (this.isQueryFieldRelationshipKey(key, queryFieldPaths)) { + continue; + } let renderedEntries = Array.isArray(value) ? value : [value]; for (let relationship of renderedEntries) { this.addRelationshipDependencyCandidates( @@ -663,6 +676,22 @@ export class IndexRunnerDependencyResolver { return deps; } + extractQueryFieldRelationshipPaths( + ...resources: RelationshipSource[] + ): Set { + let paths = new Set(); + for (let resource of resources) { + let relationships = resource?.relationships; + if (!relationships) { + continue; + } + for (let fieldPath of this.queryFieldRelationshipPaths(relationships)) { + paths.add(fieldPath); + } + } + return paths; + } + private async getRelationshipDependencyRows( urls: string[], ): Promise> { @@ -899,4 +928,56 @@ export class IndexRunnerDependencyResolver { } return collected; } + + private queryFieldRelationshipPaths( + relationships: + | Pick['relationships'] + | FileMetaResource['relationships'] + | undefined, + ): Set { + let paths = new Set(); + if (!relationships) { + return paths; + } + for (let [key, value] of Object.entries(relationships)) { + if (Array.isArray(value) || /\.\d+$/.test(key)) { + continue; + } + if (typeof value?.links?.search === 'string' && value.links.search) { + paths.add(key); + } + } + return paths; + } + + private isQueryFieldRelationshipKey( + key: string, + queryFieldPaths: Set, + ): boolean { + for (let queryFieldPath of queryFieldPaths) { + if (key === queryFieldPath || key.startsWith(`${queryFieldPath}.`)) { + return true; + } + } + return false; + } + + private isQueryFieldSearchDocPath( + path: string[], + queryFieldPaths?: Set, + ): boolean { + if (!queryFieldPaths || queryFieldPaths.size === 0 || path.length === 0) { + return false; + } + let joined = path.join('.'); + for (let queryFieldPath of queryFieldPaths) { + if ( + joined === queryFieldPath || + joined.startsWith(`${queryFieldPath}.`) + ) { + return true; + } + } + return false; + } }