diff --git a/packages/base/card-api.gts b/packages/base/card-api.gts index 356769afda1..cd6e05753c9 100644 --- a/packages/base/card-api.gts +++ b/packages/base/card-api.gts @@ -367,7 +367,7 @@ export async function flushLogs() { await logger.flush(); } -export interface StoreSearchResource { +export interface StoreSearchResource { readonly instances: T[]; readonly instancesByRealm: { realm: string; cards: T[] }[]; readonly isLoading: boolean; @@ -384,7 +384,7 @@ export type GetSearchResourceFuncOpts = { realms?: string[]; }; }; -export type GetSearchResourceFunc = ( +export type GetSearchResourceFunc = ( parent: object, getQuery: () => Query | undefined, getRealms?: () => string[] | undefined, diff --git a/packages/base/query-field-support.ts b/packages/base/query-field-support.ts index d02ec391e82..b96998c927b 100644 --- a/packages/base/query-field-support.ts +++ b/packages/base/query-field-support.ts @@ -59,7 +59,7 @@ export function ensureQueryFieldSearchResource( let fieldState = queryFieldState?.get(field.name); let searchResource = fieldState?.searchResource; if (searchResource) { - log.info( + log.debug( `ensureQueryFieldSearchResource: reusing existing resource from fieldState for field=${field.name}`, ); return searchResource; diff --git a/packages/experiments-realm/FileSearchPlayground/playground.json b/packages/experiments-realm/FileSearchPlayground/playground.json new file mode 100644 index 00000000000..bfbc40952b0 --- /dev/null +++ b/packages/experiments-realm/FileSearchPlayground/playground.json @@ -0,0 +1,29 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "FileSearchPlayground", + "module": "../file-search-playground" + } + }, + "type": "card", + "attributes": { + "cardInfo": { + "name": "File Search Playground", + "notes": null, + "summary": "Interactively search files in the realm using query-backed linksToMany(FileDef).", + "cardThumbnailURL": null + }, + "pageSize": 4, + "nameFilter": "rate", + "sortDirection": "asc" + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/packages/experiments-realm/file-search-playground.gts b/packages/experiments-realm/file-search-playground.gts new file mode 100644 index 00000000000..1f69ec29911 --- /dev/null +++ b/packages/experiments-realm/file-search-playground.gts @@ -0,0 +1,231 @@ +import { + contains, + field, + linksToMany, + CardDef, + Component, +} from 'https://cardstack.com/base/card-api'; +import NumberField from 'https://cardstack.com/base/number'; +import StringField from 'https://cardstack.com/base/string'; +import enumField from 'https://cardstack.com/base/enum'; +import { FileDef } from 'https://cardstack.com/base/file-api'; + +const fileSearchQuery = { + filter: { + every: [ + { + type: { + module: 'https://cardstack.com/base/file-api', + name: 'FileDef', + }, + }, + { + on: { + module: 'https://cardstack.com/base/file-api', + name: 'FileDef', + }, + contains: { + name: '$this.nameFilter', + }, + }, + ], + }, + sort: [ + { + by: 'name', + direction: '$this.sortDirection', + }, + ], + page: { + size: '$this.pageSize', + }, + realm: '$thisRealm', +}; + +export class FileSearchPlayground extends CardDef { + static displayName = 'File Search Playground'; + + @field nameFilter = contains(StringField); + @field pageSize = contains(NumberField); + @field sortDirection = contains( + enumField(StringField, { options: ['asc', 'desc'] }), + ); + + @field matchingFiles = linksToMany(FileDef, { + query: fileSearchQuery, + }); + + static isolated = class Isolated extends Component { + get filterDisplay() { + let value = this.args.model.nameFilter; + if (!value?.length) { + return '(empty string)'; + } + return value; + } + + get directionLabel() { + let direction = (this.args.model.sortDirection ?? '').toLowerCase(); + return direction === 'desc' ? 'descending' : 'ascending'; + } + + + }; +} diff --git a/packages/host/app/lib/gc-card-store.ts b/packages/host/app/lib/gc-card-store.ts index ed950e7caea..63da5c3b7b9 100644 --- a/packages/host/app/lib/gc-card-store.ts +++ b/packages/host/app/lib/gc-card-store.ts @@ -35,7 +35,7 @@ type InstanceGraph = Map>; type StoredInstance = CardDef | FileDef; type StoreHooks = { - getSearchResource( + getSearchResource( parent: object, getQuery: () => Query | undefined, getRealms?: () => string[] | undefined, @@ -780,7 +780,7 @@ export default class CardStoreWithGarbageCollection implements CardStore { return dependencyGraph; } - getSearchResource( + getSearchResource( parent: object, getQuery: () => Query | undefined, getRealms?: () => string[] | undefined, diff --git a/packages/host/app/resources/search.ts b/packages/host/app/resources/search.ts index 12c03858e69..1d640c250b0 100644 --- a/packages/host/app/resources/search.ts +++ b/packages/host/app/resources/search.ts @@ -12,11 +12,16 @@ import difference from 'lodash/difference'; import isEqual from 'lodash/isEqual'; import { TrackedArray } from 'tracked-built-ins'; -import type { QueryResultsMeta, ErrorEntry } from '@cardstack/runtime-common'; +import type { + QueryResultsMeta, + ErrorEntry, + SingleCardDocument, +} from '@cardstack/runtime-common'; import { subscribeToRealm, - isCardCollectionDocument, + isLinkableCollectionDocument, isCardInstance, + isFileDefInstance, logger as runtimeLogger, normalizeQueryForSignature, buildQueryParamValue, @@ -25,6 +30,7 @@ import { import type { Query } from '@cardstack/runtime-common/query'; import type { CardDef } from 'https://cardstack.com/base/card-api'; +import type { FileDef } from 'https://cardstack.com/base/file-api'; import type { RealmEventContent } from 'https://cardstack.com/base/matrix-event'; import type RealmServerService from '../services/realm-server'; @@ -32,7 +38,7 @@ import type StoreService from '../services/store'; const waiter = buildWaiter('search-resource:search-waiter'); -export interface Args { +export interface Args { named: { query: Query | undefined; realms: string[] | undefined; @@ -52,9 +58,9 @@ export interface Args { owner: Owner; }; } -export class SearchResource extends Resource< - Args -> { +export class SearchResource< + T extends CardDef | FileDef = CardDef, +> extends Resource> { @service declare private realmServer: RealmServerService; @service declare private store: StoreService; @tracked private realmsToSearch: string[] = []; @@ -228,15 +234,24 @@ export class SearchResource extends Resource< } let newReferences = this._instances.map((i) => i.id); for (let card of this._instances) { - let maybeInstance = card?.id ? this.store.peek(card.id) : undefined; + let isFileMeta = isFileDefInstance(card); + let maybeInstance = card?.id + ? isFileMeta + ? this.store.peek(card.id, { type: 'file-meta' }) + : this.store.peek(card.id) + : undefined; if ( !maybeInstance && (card as unknown as { type?: string })?.type !== 'not-loaded' // TODO: under what circumstances could this happen? ) { - await this.store.add( - card, - { doNotPersist: true }, // search results always have id's - ); + if (isFileMeta) { + await this.store.get(card.id, { type: 'file-meta' }); + } else { + await this.store.add( + card as CardDef, + { doNotPersist: true }, // search results always have id's + ); + } } } let referencesToDrop = difference(oldReferences, newReferences); @@ -293,25 +308,37 @@ export class SearchResource extends Resource< throw err; } let json = await response.json(); - if (!isCardCollectionDocument(json)) { + if (!isLinkableCollectionDocument(json)) { throw new Error( - `The realm search response was not a card collection document: + `The realm search response was not a valid collection document: ${JSON.stringify(json, null, 2)}`, ); } let collectionDoc = json; for (let data of collectionDoc.data) { - let maybeInstance = this.store.peek(data.id!); + let isFileMeta = data.type === 'file-meta'; + let maybeInstance = isFileMeta + ? this.store.peek(data.id!, { type: 'file-meta' }) + : this.store.peek(data.id!); if (!maybeInstance) { - await this.store.add( - { data }, - { doNotPersist: true, relativeTo: new URL(data.id!) }, // search results always have id's - ); + if (isFileMeta) { + await this.store.get(data.id!, { type: 'file-meta' }); + } else { + await this.store.add( + { data } as SingleCardDocument, + { doNotPersist: true, relativeTo: new URL(data.id!) }, // search results always have id's + ); + } } } let results = collectionDoc.data - .map((r) => this.store.peek(r.id!)) // all results will have id's - .filter((i) => isCardInstance(i)) as T[]; + .map((r) => { + let isFileMeta = r.type === 'file-meta'; + return isFileMeta + ? this.store.peek(r.id!, { type: 'file-meta' }) + : this.store.peek(r.id!); + }) + .filter((i) => isCardInstance(i) || isFileDefInstance(i)) as T[]; this.#log.info( `search task complete; total instances=${results.length}; refs=${results .map((r) => r.id) @@ -335,7 +362,7 @@ export class SearchResource extends Resource< // ``` // If you need to use `getSearch()`/`getCards()` in something that is not a Component, then // let's talk. -export function getSearch( +export function getSearch( parent: object, owner: Owner, getQuery: () => Query | undefined, diff --git a/packages/host/app/services/store.ts b/packages/host/app/services/store.ts index 412fa70789a..45172fefbab 100644 --- a/packages/host/app/services/store.ts +++ b/packages/host/app/services/store.ts @@ -644,7 +644,7 @@ export default class StoreService extends Service implements StoreInterface { ).filter(Boolean) as CardDef[]; } - getSearchResource( + getSearchResource( parent: object, getQuery: () => Query | undefined, getRealms?: () => string[] | undefined, @@ -742,6 +742,16 @@ export default class StoreService extends Service implements StoreInterface { this.newReferencePromises.push(deferred.promise); try { await this.ready; + // Check file-meta map as well as card map — file-meta instances + // are loaded into their own map by store.get(id, { type: 'file-meta' }) + let fileMetaInstance = + this.peekError(url, { type: 'file-meta' }) ?? + this.peek(url, { type: 'file-meta' }); + if (fileMetaInstance) { + // File-meta instances don't need auto-saving or card wiring + deferred.fulfill(); + return; + } let instanceOrError = this.peekError(url) ?? this.peek(url); if (!instanceOrError) { instanceOrError = await this.getCardInstance({ diff --git a/packages/host/tests/acceptance/code-submode/card-playground-test.gts b/packages/host/tests/acceptance/code-submode/card-playground-test.gts index 1ae90dee5aa..2401e94c10d 100644 --- a/packages/host/tests/acceptance/code-submode/card-playground-test.gts +++ b/packages/host/tests/acceptance/code-submode/card-playground-test.gts @@ -1381,7 +1381,7 @@ module('Acceptance | code-submode | card playground', function (_hooks) { 'recent file count is correct', ); - let { data: results } = await realm.realmIndexQueryEngine.search({ + let { data: results } = await realm.realmIndexQueryEngine.searchCards({ filter: { type: { module: `${testRealmURL}person`, name: 'Person' } }, }); assert.strictEqual(results.length, 0); @@ -1407,7 +1407,7 @@ module('Acceptance | code-submode | card playground', function (_hooks) { .dom('[data-option-index]') .exists({ count: 1 }, 'new card shows up in instance chooser dropdown'); - ({ data: results } = await realm.realmIndexQueryEngine.search({ + ({ data: results } = await realm.realmIndexQueryEngine.searchCards({ filter: { type: { module: `${testRealmURL}person`, name: 'Person' } }, })); assert.strictEqual(results.length, 1); @@ -1417,7 +1417,7 @@ module('Acceptance | code-submode | card playground', function (_hooks) { test('does not autogenerate card instance if one exists in the realm but is not in recent cards', async function (assert) { removeRecentFiles(); const cardId = `${testRealmURL}Person/pet-mango`; - let { data: results } = await realm.realmIndexQueryEngine.search({ + let { data: results } = await realm.realmIndexQueryEngine.searchCards({ filter: { type: { module: `${testRealmURL}person`, name: 'Pet' } }, }); assert.strictEqual(results.length, 1); @@ -1436,7 +1436,7 @@ module('Acceptance | code-submode | card playground', function (_hooks) { assert.dom('[data-option-index]').exists({ count: 1 }); assert.dom('[data-option-index="0"]').containsText('Mango'); - ({ data: results } = await realm.realmIndexQueryEngine.search({ + ({ data: results } = await realm.realmIndexQueryEngine.searchCards({ filter: { type: { module: `${testRealmURL}person`, name: 'Pet' } }, })); assert.strictEqual(results.length, 1); diff --git a/packages/host/tests/acceptance/code-submode/field-playground-test.gts b/packages/host/tests/acceptance/code-submode/field-playground-test.gts index 8166e0bc48a..c8aa5273e4f 100644 --- a/packages/host/tests/acceptance/code-submode/field-playground-test.gts +++ b/packages/host/tests/acceptance/code-submode/field-playground-test.gts @@ -1188,7 +1188,7 @@ module('Acceptance | code-submode | field playground', function (_hooks) { test('can autogenerate new Spec and field instance (no preexisting Spec)', async function (assert) { let queryEngine = realm.realmIndexQueryEngine; - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: specRef, eq: { @@ -1210,7 +1210,7 @@ module('Acceptance | code-submode | field playground', function (_hooks) { assertFieldExists(assert, 'edit'); assert.dom('[data-test-field="quote"] input').hasNoValue(); - ({ data: matching } = await queryEngine.search({ + ({ data: matching } = await queryEngine.searchCards({ filter: { on: specRef, eq: { diff --git a/packages/host/tests/cards/file-query-card.gts b/packages/host/tests/cards/file-query-card.gts new file mode 100644 index 00000000000..341033feb39 --- /dev/null +++ b/packages/host/tests/cards/file-query-card.gts @@ -0,0 +1,20 @@ +import { + contains, + linksToMany, + field, + CardDef, +} from 'https://cardstack.com/base/card-api'; +import { FileDef } from 'https://cardstack.com/base/file-api'; +import StringField from 'https://cardstack.com/base/string'; + +const fileSearchQuery = { + filter: { + type: { module: 'https://cardstack.com/base/file-api', name: 'FileDef' }, + }, + realm: '$thisRealm', +}; + +export class FileQueryCard extends CardDef { + @field nameFilter = contains(StringField); + @field matchingFiles = linksToMany(FileDef, { query: fileSearchQuery }); +} diff --git a/packages/host/tests/helpers/realm-server-mock/routes.ts b/packages/host/tests/helpers/realm-server-mock/routes.ts index c49f9b2df14..c258a39e843 100644 --- a/packages/host/tests/helpers/realm-server-mock/routes.ts +++ b/packages/host/tests/helpers/realm-server-mock/routes.ts @@ -15,7 +15,7 @@ import { } from '@cardstack/runtime-common'; import type { - CardCollectionDocument, + LinkableCollectionDocument, PrerenderedCardCollectionDocument, } from '@cardstack/runtime-common/document-types'; @@ -33,7 +33,7 @@ const TEST_MATRIX_USER = '@testuser:localhost'; type SearchableRealm = { url?: string; - search: (query: Query) => Promise; + search: (query: Query) => Promise; searchPrerendered: ( query: Query, opts: Pick< @@ -315,7 +315,7 @@ function getSearchableRealmForURL( `Remote realm search failed for ${resolvedRealmURL}: ${response.status} ${responseText}`, ); } - return (await response.json()) as CardCollectionDocument; + return (await response.json()) as LinkableCollectionDocument; }, async searchPrerendered(query: Query, opts) { let url = new URL('_search-prerendered', resolvedRealmURL); diff --git a/packages/host/tests/integration/realm-indexing-test.gts b/packages/host/tests/integration/realm-indexing-test.gts index b0856a10842..a61a1ec0f9b 100644 --- a/packages/host/tests/integration/realm-indexing-test.gts +++ b/packages/host/tests/integration/realm-indexing-test.gts @@ -134,7 +134,7 @@ module(`Integration | realm indexing`, function (hooks) { }, }); let queryEngine = realm.realmIndexQueryEngine; - let { data: cards } = await queryEngine.search({}); + let { data: cards } = await queryEngine.searchCards({}); assert.deepEqual(cards, [ { id: `${testRealmURL}empty`, diff --git a/packages/host/tests/integration/realm-querying-test.gts b/packages/host/tests/integration/realm-querying-test.gts index fcf953d0da8..110b98fdb06 100644 --- a/packages/host/tests/integration/realm-querying-test.gts +++ b/packages/host/tests/integration/realm-querying-test.gts @@ -577,13 +577,31 @@ module(`Integration | realm querying`, function (hooks) { hooks.beforeEach(async function () { let { realm } = await setupIntegrationTestRealm({ mockMatrixUtils, - contents: sampleCards, + contents: { + ...sampleCards, + 'files/sample.txt': 'Hello world', + 'files/sample.md': 'Hello markdown', + 'file-query-card-instance.json': { + data: { + type: 'card', + attributes: { + nameFilter: '', + }, + meta: { + adoptsFrom: { + module: `${testModuleRealm}file-query-card`, + name: 'FileQueryCard', + }, + }, + }, + }, + }, }); queryEngine = realm.realmIndexQueryEngine; }); test(`can search for cards by using the 'eq' filter`, async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}post`, name: 'Post' }, eq: { cardTitle: 'Card 1', cardDescription: 'Sample post' }, @@ -596,7 +614,7 @@ module(`Integration | realm querying`, function (hooks) { }); test(`can use 'eq' to find empty values`, async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}booking`, name: 'Booking' }, eq: { 'posts.author.lastName': null }, @@ -609,7 +627,7 @@ module(`Integration | realm querying`, function (hooks) { }); test(`can use 'eq' to find missing values`, async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}type-examples`, @@ -625,7 +643,7 @@ module(`Integration | realm querying`, function (hooks) { }); test(`can use 'eq' to find empty containsMany field and missing containsMany field`, async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}type-examples`, @@ -641,7 +659,7 @@ module(`Integration | realm querying`, function (hooks) { }); test(`can use 'eq' to find empty linksToMany field and missing linksToMany field`, async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}friends`, @@ -657,7 +675,7 @@ module(`Integration | realm querying`, function (hooks) { }); test(`can use 'eq' to find empty linksTo field`, async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}friend`, @@ -673,7 +691,7 @@ module(`Integration | realm querying`, function (hooks) { }); test(`can search for cards by using a computed field`, async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}post`, name: 'Post' }, eq: { 'author.fullName': 'Carl Stack' }, @@ -686,7 +704,7 @@ module(`Integration | realm querying`, function (hooks) { }); test('can search for cards by using a linksTo field', async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}friend`, name: 'Friend' }, eq: { 'friend.firstName': 'Mango' }, @@ -699,7 +717,7 @@ module(`Integration | realm querying`, function (hooks) { }); test(`can search for cards that have code-ref queryableValue`, async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${baseRealm.url}spec`, @@ -719,8 +737,71 @@ module(`Integration | realm querying`, function (hooks) { ); }); + test('can search for file-meta entries by FileDef type', async function (assert) { + let fileDefRef = { module: `${baseRealm.url}file-api`, name: 'FileDef' }; + let result = (await queryEngine.searchCards({ + filter: { + type: fileDefRef, + }, + })) as unknown as { + data: { id?: string; type: string }[]; + }; + let fileEntry = result.data.find( + (entry) => entry.id === `${testRealmURL}files/sample.txt`, + ); + assert.ok(fileEntry, 'file-meta entry is returned'); + assert.strictEqual( + fileEntry?.type, + 'file-meta', + 'search results include file-meta resource', + ); + }); + + test('can search for file-meta entries by url', async function (assert) { + let fileDefRef = { module: `${baseRealm.url}file-api`, name: 'FileDef' }; + let targetUrl = `${testRealmURL}files/sample.txt`; + let result = (await queryEngine.searchCards({ + filter: { + on: fileDefRef, + eq: { url: targetUrl }, + }, + })) as unknown as { + data: { id?: string; type: string }[]; + }; + assert.deepEqual( + result.data.map((entry) => entry.id), + [targetUrl], + 'filters file-meta entries by url', + ); + assert.strictEqual( + result.data[0]?.type, + 'file-meta', + 'url filter returns file-meta resource', + ); + }); + + test('can search for file-meta entries by FileDef subclass type', async function (assert) { + let markdownRef = { + module: `${baseRealm.url}markdown-file-def`, + name: 'MarkdownDef', + }; + let result = (await queryEngine.searchCards({ + filter: { + type: markdownRef, + }, + })) as unknown as { + data: { id?: string; type: string }[]; + }; + assert.ok( + result.data.some( + (entry) => entry.id === `${testRealmURL}files/sample.md`, + ), + 'returns file-meta entries for subclass FileDef type', + ); + }); + test('can combine multiple filters', async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}post`, @@ -737,7 +818,7 @@ module(`Integration | realm querying`, function (hooks) { }); test('can handle a filter with double negatives', async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}post`, name: 'Post' }, not: { not: { not: { eq: { 'author.firstName': 'Carl' } } } }, @@ -750,7 +831,7 @@ module(`Integration | realm querying`, function (hooks) { }); test('can filter by card type', async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { type: { module: `${testModuleRealm}article`, name: 'Article' }, }, @@ -762,7 +843,7 @@ module(`Integration | realm querying`, function (hooks) { ); matching = ( - await queryEngine.search({ + await queryEngine.searchCards({ filter: { type: { module: `${testModuleRealm}post`, name: 'Post' }, }, @@ -776,7 +857,7 @@ module(`Integration | realm querying`, function (hooks) { }); test(`can filter on a card's own fields using range`, async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}post`, name: 'Post' }, range: { @@ -793,7 +874,7 @@ module(`Integration | realm querying`, function (hooks) { test(`can filter on a nested field inside a containsMany using 'range'`, async function (assert) { { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}booking`, name: 'Booking' }, range: { @@ -808,7 +889,7 @@ module(`Integration | realm querying`, function (hooks) { ); } { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}booking`, name: 'Booking' }, range: { @@ -824,7 +905,7 @@ module(`Integration | realm querying`, function (hooks) { }); test('can use an eq filter with a date field', async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}event`, name: 'Event' }, eq: { @@ -839,7 +920,7 @@ module(`Integration | realm querying`, function (hooks) { }); test(`can filter on a nested field using 'eq'`, async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}post`, name: 'Post' }, eq: { 'author.firstName': 'Carl' }, @@ -853,7 +934,7 @@ module(`Integration | realm querying`, function (hooks) { test(`can filter on a nested field inside a containsMany using 'eq'`, async function (assert) { { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}booking`, name: 'Booking' }, eq: { 'hosts.firstName': 'Arthur' }, @@ -866,7 +947,7 @@ module(`Integration | realm querying`, function (hooks) { ); } { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}booking`, name: 'Booking' }, eq: { 'hosts.firstName': null }, @@ -875,7 +956,7 @@ module(`Integration | realm querying`, function (hooks) { assert.strictEqual(matching.length, 0, 'eq on null hosts.firstName'); } { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}booking`, name: 'Booking' }, eq: { @@ -891,7 +972,7 @@ module(`Integration | realm querying`, function (hooks) { ); } { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}booking`, name: 'Booking' }, eq: { @@ -910,7 +991,7 @@ module(`Integration | realm querying`, function (hooks) { test(`can filter on an array of primitive fields inside a containsMany using 'eq'`, async function (assert) { { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}booking`, @@ -926,7 +1007,7 @@ module(`Integration | realm querying`, function (hooks) { ); } { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}booking`, @@ -942,7 +1023,7 @@ module(`Integration | realm querying`, function (hooks) { ); } { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}booking`, @@ -963,7 +1044,7 @@ module(`Integration | realm querying`, function (hooks) { }); test('can negate a filter', async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}article`, name: 'Article' }, not: { eq: { 'author.firstName': 'Carl' } }, @@ -976,7 +1057,7 @@ module(`Integration | realm querying`, function (hooks) { }); test('can combine multiple types', async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { any: [ { @@ -1001,7 +1082,7 @@ module(`Integration | realm querying`, function (hooks) { // sorting test('can sort in alphabetical order', async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ sort: [ { by: 'author.lastName', @@ -1019,7 +1100,7 @@ module(`Integration | realm querying`, function (hooks) { }); test('can sort in reverse alphabetical order', async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ sort: [ { by: 'author.firstName', @@ -1042,7 +1123,7 @@ module(`Integration | realm querying`, function (hooks) { }); test('can sort by card display name (card type shown in the interface)', async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ sort: [ { on: baseCardRef, @@ -1069,6 +1150,7 @@ module(`Integration | realm querying`, function (hooks) { `${paths.url}vangogh`, // dog `${paths.url}event-1`, // event `${paths.url}event-2`, // event + `${paths.url}file-query-card-instance`, // file query card `${paths.url}friend1`, // friend `${paths.url}friend2`, // friend `${paths.url}empty`, // friends @@ -1088,7 +1170,7 @@ module(`Integration | realm querying`, function (hooks) { }); test('can sort by multiple string field conditions in given directions', async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ sort: [ { by: 'author.lastName', @@ -1117,7 +1199,7 @@ module(`Integration | realm querying`, function (hooks) { }); test('can sort by number value', async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ sort: [ { by: 'editions', @@ -1144,7 +1226,7 @@ module(`Integration | realm querying`, function (hooks) { }); test('can sort by date', async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ sort: [ { by: 'pubDate', @@ -1172,7 +1254,7 @@ module(`Integration | realm querying`, function (hooks) { }); test('can sort by mixed field types', async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ sort: [ { by: 'editions', @@ -1200,7 +1282,7 @@ module(`Integration | realm querying`, function (hooks) { }); test(`can sort on multiple paths in combination with 'any' filter`, async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ sort: [ { by: 'author.lastName', @@ -1243,7 +1325,7 @@ module(`Integration | realm querying`, function (hooks) { }); test(`can sort on multiple paths in combination with 'every' filter`, async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ sort: [ { by: 'author.firstName', @@ -1274,18 +1356,19 @@ module(`Integration | realm querying`, function (hooks) { }); test(`can search for cards by using the 'contains' filter`, async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { contains: { cardTitle: 'ca' }, }, }); - assert.strictEqual(matching.length, 5); + assert.strictEqual(matching.length, 6); assert.deepEqual( matching.map((m) => m.id), [ `${paths.url}card-1`, `${paths.url}cards/1`, `${paths.url}cards/2`, + `${paths.url}file-query-card-instance`, `${paths.url}person-card1`, `${paths.url}person-card2`, ], @@ -1293,7 +1376,7 @@ module(`Integration | realm querying`, function (hooks) { }); test(`can search on specific card by using 'contains' filter`, async function (assert) { - let { data: personMatchingByTitle } = await queryEngine.search({ + let { data: personMatchingByTitle } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}person`, name: 'Person' }, contains: { cardTitle: 'ca' }, @@ -1305,7 +1388,7 @@ module(`Integration | realm querying`, function (hooks) { [`${paths.url}person-card1`, `${paths.url}person-card2`], ); - let { data: dogMatchingByFirstName } = await queryEngine.search({ + let { data: dogMatchingByFirstName } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}dog`, name: 'Dog' }, contains: { firstName: 'go' }, @@ -1319,7 +1402,7 @@ module(`Integration | realm querying`, function (hooks) { }); test(`can use 'contains' filter to find 'null' values`, async function (assert) { - let { data: matching } = await queryEngine.search({ + let { data: matching } = await queryEngine.searchCards({ filter: { on: { module: `${testModuleRealm}dog`, name: 'Dog' }, contains: { cardTitle: null }, @@ -1327,4 +1410,58 @@ module(`Integration | realm querying`, function (hooks) { }); assert.strictEqual(matching.length, 3); }); + + test('query field with linksToMany(FileDef) populates file-meta relationships', async function (assert) { + let result = await queryEngine.cardDocument( + new URL(`${testRealmURL}file-query-card-instance`), + { loadLinks: true }, + ); + assert.strictEqual(result?.type, 'doc', 'result is a doc'); + + let { data } = (result as { type: 'doc'; doc: { data: any } }).doc; + let relationships = data.relationships ?? {}; + + // The query field should have a top-level matchingFiles relationship with data array + let matchingFiles = relationships['matchingFiles'] as { + links?: Record; + data?: { type: string; id: string }[]; + }; + assert.ok(matchingFiles, 'matchingFiles relationship exists'); + assert.ok( + Array.isArray(matchingFiles?.data), + 'matchingFiles has a data array', + ); + assert.ok( + (matchingFiles?.data?.length ?? 0) >= 2, + 'matchingFiles contains at least 2 file results', + ); + + // Each entry in the data array should have type 'file-meta' + for (let entry of matchingFiles?.data ?? []) { + assert.strictEqual( + entry.type, + 'file-meta', + `relationship data entry ${entry.id} has type file-meta`, + ); + } + + // Individual indexed relationships should also have type 'file-meta' + let indexedKeys = Object.keys(relationships).filter((k) => + k.startsWith('matchingFiles.'), + ); + assert.ok( + indexedKeys.length >= 2, + 'individual file relationships are present', + ); + for (let key of indexedKeys) { + let rel = relationships[key] as { + data?: { type: string; id: string }; + }; + assert.strictEqual( + rel?.data?.type, + 'file-meta', + `${key} relationship data has type file-meta`, + ); + } + }); }); diff --git a/packages/host/tests/integration/realm-test.gts b/packages/host/tests/integration/realm-test.gts index b0d6b5f095c..e711987221a 100644 --- a/packages/host/tests/integration/realm-test.gts +++ b/packages/host/tests/integration/realm-test.gts @@ -1014,7 +1014,7 @@ module('Integration | realm', function (hooks) { 'field value is correct', ); - let { data: cards } = await queryEngine.search({ + let { data: cards } = await queryEngine.searchCards({ filter: { on: { module: `http://localhost:4202/test/person`, @@ -2743,7 +2743,7 @@ module('Integration | realm', function (hooks) { let queryEngine = realm.realmIndexQueryEngine; - let { data: cards } = await queryEngine.search({}); + let { data: cards } = await queryEngine.searchCards({}); assert.strictEqual(cards.length, 2, 'two cards found'); let result = await queryEngine.cardDocument( @@ -2794,7 +2794,7 @@ module('Integration | realm', function (hooks) { 'card 1 is still there', ); - cards = (await queryEngine.search({})).data; + cards = (await queryEngine.searchCards({})).data; assert.strictEqual(cards.length, 1, 'only one card remains'); }); diff --git a/packages/host/tests/integration/resources/search-test.ts b/packages/host/tests/integration/resources/search-test.ts index a70aba8159f..80622fa256e 100644 --- a/packages/host/tests/integration/resources/search-test.ts +++ b/packages/host/tests/integration/resources/search-test.ts @@ -8,6 +8,7 @@ import { module, test } from 'qunit'; import type { Loader, Query } from '@cardstack/runtime-common'; import { baseRealm, + isFileDefInstance, type Realm, type LooseSingleCardDocument, } from '@cardstack/runtime-common'; @@ -295,6 +296,8 @@ module(`Integration | search resource`, function (hooks) { 'book.gts': { Book }, 'post.gts': { Post }, ...sampleCards, + 'files/hello.txt': 'Hello world', + 'files/notes.txt': 'Some notes', }, })); }); @@ -587,4 +590,87 @@ module(`Integration | search resource`, function (hooks) { 'meta.page.total remains correct on empty page', ); }); + + test(`can search for file-meta instances using SearchResource`, async function (assert) { + let query: Query = { + filter: { + type: { + module: `${baseRealm.url}file-api`, + name: 'FileDef', + }, + }, + }; + let search = getSearchResourceForTest(loaderService, () => ({ + named: { + query, + realms: [testRealmURL], + isLive: false, + isAutoSaved: false, + storeService, + owner: this.owner, + }, + })); + await search.loaded; + + assert.ok(search.instances.length >= 2, 'returns file-meta instances'); + let ids = search.instances.map((i) => i.id); + assert.ok( + ids.includes(`${testRealmURL}files/hello.txt`), + 'hello.txt is in results', + ); + assert.ok( + ids.includes(`${testRealmURL}files/notes.txt`), + 'notes.txt is in results', + ); + for (let instance of search.instances) { + assert.ok( + isFileDefInstance(instance), + `${instance.id} is a FileDef instance`, + ); + } + }); + + test(`can perform a live search for file-meta instances`, async function (assert) { + let query: Query = { + filter: { + type: { + module: `${baseRealm.url}file-api`, + name: 'FileDef', + }, + }, + }; + let search = getSearchResourceForTest(loaderService, () => ({ + named: { + query, + realms: [testRealmURL], + isLive: true, + isAutoSaved: false, + storeService, + owner: this.owner, + }, + })); + await search.loaded; + + let initialCount = search.instances.length; + assert.ok(initialCount >= 2, 'initial results include file-meta instances'); + + // Write a new file to trigger a live update + await realm.write('files/new-file.txt', 'New content'); + + await waitUntil(() => search.instances.length > initialCount); + + let ids = search.instances.map((i) => i.id); + assert.ok( + ids.includes(`${testRealmURL}files/new-file.txt`), + 'new file appears in live search results', + ); + assert.ok( + isFileDefInstance( + search.instances.find( + (i) => i.id === `${testRealmURL}files/new-file.txt`, + ), + ), + 'new file is a FileDef instance', + ); + }); }); diff --git a/packages/host/tests/unit/index-query-engine-test.ts b/packages/host/tests/unit/index-query-engine-test.ts index bd6be2bf057..2929fa2b903 100644 --- a/packages/host/tests/unit/index-query-engine-test.ts +++ b/packages/host/tests/unit/index-query-engine-test.ts @@ -245,7 +245,7 @@ module('Unit | query', function (hooks) { let { mango, vangogh, paper } = testCards; await setupIndex(dbAdapter, [mango, vangogh, paper]); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), {}, ); @@ -269,7 +269,10 @@ module('Unit | query', function (hooks) { { card: paper, data: { is_deleted: true } }, ]); - let { meta } = await indexQueryEngine.search(new URL(testRealmURL), {}); + let { meta } = await indexQueryEngine.searchCards( + new URL(testRealmURL), + {}, + ); assert.strictEqual(meta.page.total, 2, 'the total results meta is correct'); }); @@ -309,7 +312,7 @@ module('Unit | query', function (hooks) { error_doc: undefined, }, ]); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), {}, ); @@ -327,7 +330,7 @@ module('Unit | query', function (hooks) { await setupIndex(dbAdapter, [mango, vangogh, paper]); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { type } }, ); @@ -351,7 +354,7 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -407,7 +410,7 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -449,7 +452,7 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -493,7 +496,7 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -531,7 +534,7 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -579,7 +582,7 @@ module('Unit | query', function (hooks) { let type = await personCardType(testCards); { - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -597,7 +600,7 @@ module('Unit | query', function (hooks) { assert.deepEqual(getIds(results), [mango.id], 'results are correct'); } { - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -615,7 +618,7 @@ module('Unit | query', function (hooks) { assert.deepEqual(getIds(results), [vangogh.id], 'results are correct'); } { - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -658,7 +661,7 @@ module('Unit | query', function (hooks) { ]); let type = await SimpleSpecType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -707,7 +710,7 @@ module('Unit | query', function (hooks) { ]); let type = await eventType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -757,7 +760,7 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -805,7 +808,7 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -853,7 +856,7 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -901,7 +904,7 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -939,7 +942,7 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -975,7 +978,7 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -1031,7 +1034,7 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -1082,7 +1085,7 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -1103,15 +1106,18 @@ module('Unit | query', function (hooks) { test(`returns empty results when query refers to missing card`, async function (assert) { await setupIndex(dbAdapter, []); - let { cards, meta } = await indexQueryEngine.search(new URL(testRealmURL), { - filter: { - on: { - module: `${testRealmURL}nonexistent`, - name: 'Nonexistent', + let { cards, meta } = await indexQueryEngine.searchCards( + new URL(testRealmURL), + { + filter: { + on: { + module: `${testRealmURL}nonexistent`, + name: 'Nonexistent', + }, + eq: { nonExistentField: 'hello' }, }, - eq: { nonExistentField: 'hello' }, }, - }); + ); assert.strictEqual(cards.length, 0, 'no cards are returned'); assert.strictEqual(meta.page.total, 0, 'total count is zero'); @@ -1122,7 +1128,7 @@ module('Unit | query', function (hooks) { let type = await personCardType(testCards); try { - await indexQueryEngine.search(new URL(testRealmURL), { + await indexQueryEngine.searchCards(new URL(testRealmURL), { filter: { on: type, eq: { @@ -1166,7 +1172,7 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -1210,7 +1216,7 @@ module('Unit | query', function (hooks) { let type = await personCardType(testCards); { - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -1228,7 +1234,7 @@ module('Unit | query', function (hooks) { assert.deepEqual(getIds(results), [mango.id], 'results are correct'); } { - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -1275,7 +1281,7 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -1319,7 +1325,7 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -1360,7 +1366,7 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -1392,7 +1398,7 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -1444,7 +1450,7 @@ module('Unit | query', function (hooks) { ); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -1501,7 +1507,7 @@ module('Unit | query', function (hooks) { ); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { filter: { @@ -1545,7 +1551,7 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { sort: [ @@ -1595,7 +1601,7 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards: results, meta } = await indexQueryEngine.search( + let { cards: results, meta } = await indexQueryEngine.searchCards( new URL(testRealmURL), { sort: [ @@ -1646,7 +1652,7 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards: results } = await indexQueryEngine.search( + let { cards: results } = await indexQueryEngine.searchCards( new URL(testRealmURL), { sort: [ @@ -1710,12 +1716,15 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards, meta } = await indexQueryEngine.search(new URL(testRealmURL), { - filter: { - on: type, - range: { age: { gt: 25 } }, + let { cards, meta } = await indexQueryEngine.searchCards( + new URL(testRealmURL), + { + filter: { + on: type, + range: { age: { gt: 25 } }, + }, }, - }); + ); assert.strictEqual(meta.page.total, 2, 'the total results meta is correct'); assert.deepEqual( @@ -1770,13 +1779,16 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards, meta } = await indexQueryEngine.search(new URL(testRealmURL), { - filter: { - on: type, - type, - range: { age: { gt: 25 } }, + let { cards, meta } = await indexQueryEngine.searchCards( + new URL(testRealmURL), + { + filter: { + on: type, + type, + range: { age: { gt: 25 } }, + }, }, - }); + ); assert.strictEqual(meta.page.total, 2, 'the total results meta is correct'); assert.deepEqual( @@ -1816,13 +1828,16 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards, meta } = await indexQueryEngine.search(new URL(testRealmURL), { - filter: { - on: type, - type, - eq: { name: 'Mango' }, + let { cards, meta } = await indexQueryEngine.searchCards( + new URL(testRealmURL), + { + filter: { + on: type, + type, + eq: { name: 'Mango' }, + }, }, - }); + ); assert.strictEqual(meta.page.total, 1, 'the total results meta is correct'); assert.deepEqual(getIds(cards), [mango.id], 'results are correct'); @@ -1867,13 +1882,16 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards, meta } = await indexQueryEngine.search(new URL(testRealmURL), { - filter: { - on: type, - type, - contains: { 'address.city': 'Barks' }, + let { cards, meta } = await indexQueryEngine.searchCards( + new URL(testRealmURL), + { + filter: { + on: type, + type, + contains: { 'address.city': 'Barks' }, + }, }, - }); + ); assert.strictEqual(meta.page.total, 2, 'the total results meta is correct'); assert.deepEqual( @@ -1913,22 +1931,25 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards, meta } = await indexQueryEngine.search(new URL(testRealmURL), { - filter: { - on: type, - type, - not: { - eq: { name: 'Mango' }, - }, - }, - sort: [ - { + let { cards, meta } = await indexQueryEngine.searchCards( + new URL(testRealmURL), + { + filter: { on: type, - by: 'name', - direction: 'asc', + type, + not: { + eq: { name: 'Mango' }, + }, }, - ], - }); + sort: [ + { + on: type, + by: 'name', + direction: 'asc', + }, + ], + }, + ); assert.strictEqual(meta.page.total, 2, 'the total results meta is correct'); assert.deepEqual( @@ -1993,13 +2014,16 @@ module('Unit | query', function (hooks) { let personType = await personCardType(testCards); let catType = internalKeyToCodeRef([...(await getTypes(paper))].shift()!); - let { cards, meta } = await indexQueryEngine.search(new URL(testRealmURL), { - filter: { - on: personType, - type: catType, - range: { age: { gt: 25 } }, + let { cards, meta } = await indexQueryEngine.searchCards( + new URL(testRealmURL), + { + filter: { + on: personType, + type: catType, + range: { age: { gt: 25 } }, + }, }, - }); + ); assert.strictEqual(meta.page.total, 2, 'the total results meta is correct'); assert.deepEqual( @@ -2054,19 +2078,22 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards, meta } = await indexQueryEngine.search(new URL(testRealmURL), { - filter: { - on: type, - range: { age: { gte: 25 } }, - }, - sort: [ - { + let { cards, meta } = await indexQueryEngine.searchCards( + new URL(testRealmURL), + { + filter: { on: type, - by: 'age', - direction: 'desc', + range: { age: { gte: 25 } }, }, - ], - }); + sort: [ + { + on: type, + by: 'age', + direction: 'desc', + }, + ], + }, + ); assert.strictEqual(meta.page.total, 3, 'the total results meta is correct'); assert.deepEqual( @@ -2121,16 +2148,19 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards, meta } = await indexQueryEngine.search(new URL(testRealmURL), { - filter: { - on: type, - range: { - 'address.number': { - gt: 100, + let { cards, meta } = await indexQueryEngine.searchCards( + new URL(testRealmURL), + { + filter: { + on: type, + range: { + 'address.number': { + gt: 100, + }, }, }, }, - }); + ); assert.strictEqual(meta.page.total, 2, 'the total results meta is correct'); assert.deepEqual( @@ -2173,16 +2203,19 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards, meta } = await indexQueryEngine.search(new URL(testRealmURL), { - filter: { - on: type, - range: { - lotteryNumbers: { - gt: 50, + let { cards, meta } = await indexQueryEngine.searchCards( + new URL(testRealmURL), + { + filter: { + on: type, + range: { + lotteryNumbers: { + gt: 50, + }, }, }, }, - }); + ); assert.strictEqual(meta.page.total, 2, 'the total results meta is correct'); assert.deepEqual( @@ -2237,16 +2270,19 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards, meta } = await indexQueryEngine.search(new URL(testRealmURL), { - filter: { - on: type, - range: { - 'friends.age': { - gt: 25, + let { cards, meta } = await indexQueryEngine.searchCards( + new URL(testRealmURL), + { + filter: { + on: type, + range: { + 'friends.age': { + gt: 25, + }, }, }, }, - }); + ); assert.strictEqual(meta.page.total, 2, 'the total results meta is correct'); assert.deepEqual( @@ -2301,19 +2337,22 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards, meta } = await indexQueryEngine.search(new URL(testRealmURL), { - filter: { - on: type, - range: { age: { lt: 35 } }, - }, - sort: [ - { + let { cards, meta } = await indexQueryEngine.searchCards( + new URL(testRealmURL), + { + filter: { on: type, - by: 'age', - direction: 'desc', + range: { age: { lt: 35 } }, }, - ], - }); + sort: [ + { + on: type, + by: 'age', + direction: 'desc', + }, + ], + }, + ); assert.strictEqual(meta.page.total, 2, 'the total results meta is correct'); assert.deepEqual( @@ -2368,19 +2407,22 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards, meta } = await indexQueryEngine.search(new URL(testRealmURL), { - filter: { - on: type, - range: { age: { lte: 35 } }, - }, - sort: [ - { + let { cards, meta } = await indexQueryEngine.searchCards( + new URL(testRealmURL), + { + filter: { on: type, - by: 'age', - direction: 'desc', + range: { age: { lte: 35 } }, }, - ], - }); + sort: [ + { + on: type, + by: 'age', + direction: 'desc', + }, + ], + }, + ); assert.strictEqual(meta.page.total, 3, 'the total results meta is correct'); assert.deepEqual( @@ -2435,19 +2477,22 @@ module('Unit | query', function (hooks) { ]); let type = await personCardType(testCards); - let { cards, meta } = await indexQueryEngine.search(new URL(testRealmURL), { - filter: { - on: type, - range: { age: { gt: 25, lt: 35 } }, - }, - sort: [ - { + let { cards, meta } = await indexQueryEngine.searchCards( + new URL(testRealmURL), + { + filter: { on: type, - by: 'age', - direction: 'desc', + range: { age: { gt: 25, lt: 35 } }, }, - ], - }); + sort: [ + { + on: type, + by: 'age', + direction: 'desc', + }, + ], + }, + ); assert.strictEqual(meta.page.total, 1, 'the total results meta is correct'); assert.deepEqual(getIds(cards), [vangogh.id], 'results are correct'); @@ -2499,7 +2544,7 @@ module('Unit | query', function (hooks) { let type = await personCardType(testCards); assert.rejects( - indexQueryEngine.search(new URL(testRealmURL), { + indexQueryEngine.searchCards(new URL(testRealmURL), { filter: { on: type, range: { age: { gt: null } }, diff --git a/packages/realm-server/lib/retrieve-scoped-css.ts b/packages/realm-server/lib/retrieve-scoped-css.ts index b503fca9706..a8860679114 100644 --- a/packages/realm-server/lib/retrieve-scoped-css.ts +++ b/packages/realm-server/lib/retrieve-scoped-css.ts @@ -27,10 +27,10 @@ export async function retrieveScopedCSS({ } let scopedCSSQuery: Expression = [ - `SELECT deps, realm_version FROM boxel_index_working WHERE deps IS NOT NULL AND`, + `SELECT deps, realm_version FROM boxel_index_working WHERE type = 'instance' AND deps IS NOT NULL AND`, ...indexCandidateExpressions(candidates), `UNION ALL - SELECT deps, realm_version FROM boxel_index WHERE deps IS NOT NULL AND`, + SELECT deps, realm_version FROM boxel_index WHERE type = 'instance' AND deps IS NOT NULL AND`, ...indexCandidateExpressions(candidates), `ORDER BY realm_version DESC LIMIT 1`, diff --git a/packages/realm-server/tests/cards/sample.md b/packages/realm-server/tests/cards/sample.md new file mode 100644 index 00000000000..fd551cf4a68 --- /dev/null +++ b/packages/realm-server/tests/cards/sample.md @@ -0,0 +1,3 @@ +# Sample + +Hello markdown diff --git a/packages/realm-server/tests/indexing-test.ts b/packages/realm-server/tests/indexing-test.ts index ca983533a68..47e890eb2a0 100644 --- a/packages/realm-server/tests/indexing-test.ts +++ b/packages/realm-server/tests/indexing-test.ts @@ -1100,7 +1100,7 @@ module(basename(__filename), function () { } as LooseSingleCardDocument), ); - let { data: result } = await realm.realmIndexQueryEngine.search({ + let { data: result } = await realm.realmIndexQueryEngine.searchCards({ filter: { on: { module: `${testRealm}person`, name: 'Person' }, eq: { firstName: 'Mang-Mang' }, @@ -1135,7 +1135,7 @@ module(basename(__filename), function () { export class Intentionally Thrown Error {} `, ); - let { data: result } = await realm.realmIndexQueryEngine.search({ + let { data: result } = await realm.realmIndexQueryEngine.searchCards({ filter: { type: { module: `${testRealm}person`, name: 'Person' }, }, @@ -1157,7 +1157,7 @@ module(basename(__filename), function () { `, ); result = ( - await realm.realmIndexQueryEngine.search({ + await realm.realmIndexQueryEngine.searchCards({ filter: { type: { module: `${testRealm}person`, name: 'Person' }, }, @@ -1453,7 +1453,7 @@ module(basename(__filename), function () { test('can incrementally index deleted instance', async function (assert) { await realm.delete('mango.json'); - let { data: result } = await realm.realmIndexQueryEngine.search({ + let { data: result } = await realm.realmIndexQueryEngine.searchCards({ filter: { on: { module: `${testRealm}person`, name: 'Person' }, eq: { firstName: 'Mango' }, @@ -1508,7 +1508,7 @@ module(basename(__filename), function () { `, ); - let { data: result } = await realm.realmIndexQueryEngine.search({ + let { data: result } = await realm.realmIndexQueryEngine.searchCards({ filter: { on: { module: `${testRealm}post`, name: 'Post' }, eq: { nickName: 'Van Gogh-poo' }, @@ -1541,7 +1541,7 @@ module(basename(__filename), function () { `, ); - let { data: result } = await realm.realmIndexQueryEngine.search({ + let { data: result } = await realm.realmIndexQueryEngine.searchCards({ filter: { on: { module: `${testRealm}post`, name: 'Post' }, eq: { 'author.nickName': 'Van Gogh-poo' }, @@ -1553,7 +1553,7 @@ module(basename(__filename), function () { test('can incrementally index instance that depends on deleted card source', async function (assert) { await realm.delete('post.gts'); { - let { data: result } = await realm.realmIndexQueryEngine.search({ + let { data: result } = await realm.realmIndexQueryEngine.searchCards({ filter: { type: { module: `${testRealm}post`, name: 'Post' }, }, @@ -1608,7 +1608,7 @@ module(basename(__filename), function () { `, ); { - let { data: result } = await realm.realmIndexQueryEngine.search({ + let { data: result } = await realm.realmIndexQueryEngine.searchCards({ filter: { on: { module: `${testRealm}post`, name: 'Post' }, eq: { nickName: 'Van Gogh-poo' }, diff --git a/packages/realm-server/tests/realm-endpoints-test.ts b/packages/realm-server/tests/realm-endpoints-test.ts index 4ddfafa7e09..ec261bf94f8 100644 --- a/packages/realm-server/tests/realm-endpoints-test.ts +++ b/packages/realm-server/tests/realm-endpoints-test.ts @@ -1531,6 +1531,14 @@ module(basename(__filename), function () { kind: 'file', }, }, + 'sample.md': { + links: { + related: `${testRealmHref}sample.md`, + }, + meta: { + kind: 'file', + }, + }, 'timers-card.gts': { links: { related: `${testRealmHref}timers-card.gts`, diff --git a/packages/realm-server/tests/realm-endpoints/search-test.ts b/packages/realm-server/tests/realm-endpoints/search-test.ts index b98105f98fa..3f58562f66b 100644 --- a/packages/realm-server/tests/realm-endpoints/search-test.ts +++ b/packages/realm-server/tests/realm-endpoints/search-test.ts @@ -1,7 +1,7 @@ import { module, test } from 'qunit'; import type { Test, SuperTest } from 'supertest'; import { basename } from 'path'; -import type { Realm } from '@cardstack/runtime-common'; +import { baseRealm, type Realm } from '@cardstack/runtime-common'; import type { Query } from '@cardstack/runtime-common/query'; import { setupPermissionedRealm, createJWT } from '../helpers'; import '@cardstack/runtime-common/helpers/code-equality-assertion'; @@ -39,6 +39,17 @@ module(`realm-endpoints/${basename(__filename)}`, function () { }; } + function buildFileDefQuery(): Query { + return { + filter: { + type: { + module: `${baseRealm.url}file-api`, + name: 'FileDef', + }, + }, + }; + } + module('QUERY request (public realm)', function (_hooks) { let query = () => buildPersonQuery('Mango'); @@ -178,6 +189,82 @@ module(`realm-endpoints/${basename(__filename)}`, function () { 'different pages should return different results', ); }); + + test('serves file-meta results when querying for FileDef', async function (assert) { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send(buildFileDefQuery()); + + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + let json = response.body as { data: { id?: string; type: string }[] }; + + assert.ok(json.data.length > 0, 'file-meta results are returned'); + assert.ok( + json.data.every((entry) => entry.type === 'file-meta'), + 'all results are file-meta resources', + ); + assert.ok( + json.data.some((entry) => entry.id === `${realmHref}dir/foo.txt`), + 'expected file-meta entry is present', + ); + }); + + test('filters file-meta results by url', async function (assert) { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ + filter: { + on: { + module: `${baseRealm.url}file-api`, + name: 'FileDef', + }, + eq: { + url: `${realmHref}dir/foo.txt`, + }, + }, + }); + + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + let json = response.body as { data: { id?: string; type: string }[] }; + + assert.deepEqual( + json.data.map((entry) => entry.id), + [`${realmHref}dir/foo.txt`], + 'url filter returns matching file-meta entry', + ); + assert.strictEqual( + json.data[0]?.type, + 'file-meta', + 'url filter returns file-meta resource', + ); + }); + + test('filters file-meta results by FileDef subclass type', async function (assert) { + let response = await request + .post(searchPath) + .set('Accept', 'application/vnd.card+json') + .set('X-HTTP-Method-Override', 'QUERY') + .send({ + filter: { + type: { + module: `${baseRealm.url}markdown-file-def`, + name: 'MarkdownDef', + }, + }, + }); + + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + let json = response.body as { data: { id?: string; type: string }[] }; + + assert.ok( + json.data.some((entry) => entry.id === `${realmHref}sample.md`), + 'returns file-meta entries for subclass FileDef type', + ); + }); }); }); diff --git a/packages/runtime-common/document-types.ts b/packages/runtime-common/document-types.ts index 3b3f1ab41bc..b946baac9ed 100644 --- a/packages/runtime-common/document-types.ts +++ b/packages/runtime-common/document-types.ts @@ -40,6 +40,12 @@ export interface FileMetaCollectionDocument { meta: QueryResultsMeta; } +export interface LinkableCollectionDocument { + data: (CardResource | FileMetaResource)[]; + included?: (FileMetaResource | CardResource)[]; + meta: QueryResultsMeta; +} + export type CardDocument = SingleCardDocument | CardCollectionDocument; export type FileMetaDocument = | SingleFileMetaDocument @@ -101,6 +107,34 @@ export function isCardCollectionDocument( return data.every((resource) => isCardResource(resource)); } +export function isFileMetaCollectionDocument( + doc: any, +): doc is FileMetaCollectionDocument { + if (typeof doc !== 'object' || doc == null) { + return false; + } + if (!('data' in doc)) { + return false; + } + let { data } = doc; + if (!Array.isArray(data)) { + return false; + } + if ('included' in doc) { + let { included } = doc; + if (!isIncluded(included)) { + return false; + } + } + return data.every((resource) => isFileMetaResource(resource)); +} + +export function isLinkableCollectionDocument( + doc: any, +): doc is LinkableCollectionDocument { + return isCardCollectionDocument(doc) || isFileMetaCollectionDocument(doc); +} + export function isPrerenderedCardCollectionDocument( doc: any, ): doc is PrerenderedCardCollectionDocument { diff --git a/packages/runtime-common/index-query-engine.ts b/packages/runtime-common/index-query-engine.ts index e2e6e50c5a8..ef25dff6b4e 100644 --- a/packages/runtime-common/index-query-engine.ts +++ b/packages/runtime-common/index-query-engine.ts @@ -360,6 +360,10 @@ export class IndexQueryEngine { types, display_names: displayNames, } = maybeResult; + realmVersion = + typeof realmVersion === 'string' + ? parseInt(realmVersion) + : (realmVersion ?? 0); return { type: 'file', canonicalURL, @@ -377,6 +381,52 @@ export class IndexQueryEngine { }; } + async hasFileType( + realmURL: URL, + ref: CodeRef, + opts?: GetEntryOptions, + ): Promise { + if (!isResolvedCodeRef(ref)) { + return false; + } + let typeKey = internalKeyFor(ref, undefined); + let rows = (await this.#query([ + 'SELECT 1', + `FROM ${tableFromOpts(opts)} AS i ${tableValuedFunctionsPlaceholder}`, + 'WHERE', + ...every([ + ['i.realm_url =', param(realmURL.href)], + ['i.type =', param('file')], + [tableValuedEach('types'), '=', param(typeKey)], + ]), + 'LIMIT 1', + ] as Expression)) as unknown as { 1: number }[]; + return rows.length > 0; + } + + async hasInstanceType( + realmURL: URL, + ref: CodeRef, + opts?: GetEntryOptions, + ): Promise { + if (!isResolvedCodeRef(ref)) { + return false; + } + let typeKey = internalKeyFor(ref, undefined); + let rows = (await this.#query([ + 'SELECT 1', + `FROM ${tableFromOpts(opts)} AS i ${tableValuedFunctionsPlaceholder}`, + 'WHERE', + ...every([ + ['i.realm_url =', param(realmURL.href)], + ['i.type =', param('instance')], + [tableValuedEach('types'), '=', param(typeKey)], + ]), + 'LIMIT 1', + ] as Expression)) as unknown as { 1: number }[]; + return rows.length > 0; + } + private async getDefinition(codeRef: CodeRef): Promise { if (!isResolvedCodeRef(codeRef)) { throw new Error( @@ -395,6 +445,7 @@ export class IndexQueryEngine { { filter, sort, page }: Query, opts: QueryOptions, selectClauseExpression: CardExpression, + entryType: 'instance' | 'file' = 'instance', ): Promise<{ meta: QueryResultsMeta; results: Partial[]; @@ -406,17 +457,21 @@ export class IndexQueryEngine { ]; if (opts.includeErrors) { - conditions.push(['i.type =', param('instance')]); + conditions.push(['i.type =', param(entryType)]); } else { conditions.push( every([ - ['i.type =', param('instance')], + ['i.type =', param(entryType)], any([['i.has_error = FALSE'], ['i.has_error IS NULL']]), ]), ); } - if (opts.cardUrls && opts.cardUrls.length > 0) { + if ( + entryType === 'instance' && + opts.cardUrls && + opts.cardUrls.length > 0 + ) { conditions.push([ 'i.url IN', ...addExplicitParens( @@ -472,7 +527,7 @@ export class IndexQueryEngine { } } - async search( + async searchCards( realmURL: URL, { filter, sort, page }: Query, opts: QueryOptions = {}, @@ -486,6 +541,7 @@ export class IndexQueryEngine { [ 'SELECT url, ANY_VALUE(pristine_doc) AS pristine_doc, ANY_VALUE(error_doc) AS error_doc', ], + 'instance', ); let cards = results @@ -495,6 +551,58 @@ export class IndexQueryEngine { return { cards, meta }; } + async searchFiles( + realmURL: URL, + { filter, sort, page }: Query, + opts: QueryOptions = {}, + ): Promise<{ files: IndexedFile[]; meta: QueryResultsMeta }> { + let { results, meta } = await this._search( + realmURL, + { filter, sort, page }, + opts, + [ + 'SELECT url, ANY_VALUE(pristine_doc) AS pristine_doc, ANY_VALUE(search_doc) AS search_doc, ANY_VALUE(types) AS types, ANY_VALUE(display_names) AS display_names, ANY_VALUE(deps) AS deps, ANY_VALUE(last_modified) AS last_modified, ANY_VALUE(resource_created_at) AS resource_created_at, ANY_VALUE(realm_version) AS realm_version, ANY_VALUE(realm_url) AS realm_url, ANY_VALUE(indexed_at) AS indexed_at', + ], + 'file', + ); + + let files = results.map((result) => this.fileEntryFromResult(result)); + return { files, meta }; + } + + private fileEntryFromResult(result: Partial): IndexedFile { + let canonicalURL = result.url; + if (!canonicalURL) { + throw new Error('expected file search result to include url'); + } + let lastModified = + typeof result.last_modified === 'string' + ? parseInt(result.last_modified) + : (result.last_modified ?? null); + let resourceCreatedAt = + typeof result.resource_created_at === 'string' + ? parseInt(result.resource_created_at) + : (result.resource_created_at ?? null); + let indexedAt = + typeof result.indexed_at === 'string' + ? parseInt(result.indexed_at) + : (result.indexed_at ?? null); + return { + type: 'file', + canonicalURL, + searchDoc: (result.search_doc as Record | null) ?? null, + resource: (result.pristine_doc as FileMetaResource | null) ?? null, + types: (result.types as string[] | null) ?? null, + displayNames: (result.display_names as string[] | null) ?? null, + deps: (result.deps as string[] | null) ?? null, + lastModified, + resourceCreatedAt, + realmVersion: result.realm_version ?? 0, + realmURL: result.realm_url ?? '', + indexedAt, + }; + } + private generalFieldSortColumn(field: string) { let mappedField = generalSortFields[field]; if (mappedField) { @@ -565,6 +673,7 @@ export class IndexQueryEngine { ' as used_render_type,', 'ANY_VALUE(deps) as deps', ], + 'instance', )) as { meta: QueryResultsMeta; results: (Partial & { diff --git a/packages/runtime-common/index.ts b/packages/runtime-common/index.ts index 2be0c95e246..edcb97bbedb 100644 --- a/packages/runtime-common/index.ts +++ b/packages/runtime-common/index.ts @@ -257,6 +257,8 @@ export type { SingleCardDocument, SingleFileMetaDocument, CardCollectionDocument, + FileMetaCollectionDocument, + LinkableCollectionDocument, } from './document-types'; export type { CardResource, @@ -275,6 +277,8 @@ export { isCardCollectionDocument, isSingleCardDocument, isSingleFileMetaDocument, + isFileMetaCollectionDocument, + isLinkableCollectionDocument, isCardDocumentString, } from './document-types'; export { diff --git a/packages/runtime-common/query-field-utils.ts b/packages/runtime-common/query-field-utils.ts index caf23d74be8..f71d343eeda 100644 --- a/packages/runtime-common/query-field-utils.ts +++ b/packages/runtime-common/query-field-utils.ts @@ -234,7 +234,7 @@ export function normalizeQueryDefinition({ let filter = queryAny.filter as Record | undefined; if (!filter || Object.keys(filter).length === 0) { queryAny.filter = { type: targetRef }; - } else if (!filter.on) { + } else if (!filter.on && !filter.type) { filter.on = targetRef; } diff --git a/packages/runtime-common/realm-index-query-engine.ts b/packages/runtime-common/realm-index-query-engine.ts index 6111bd50a11..289883157b7 100644 --- a/packages/runtime-common/realm-index-query-engine.ts +++ b/packages/runtime-common/realm-index-query-engine.ts @@ -27,14 +27,19 @@ import { import type { Realm } from './realm'; import { FILE_META_RESERVED_KEYS } from './realm'; import { RealmPaths } from './paths'; -import type { Query } from './query'; +import type { Filter, Query } from './query'; import { CardError, type SerializedError } from './error'; -import { isCodeRef, isResolvedCodeRef, visitModuleDeps } from './code-ref'; import { - isCardCollectionDocument, + isCodeRef, + isResolvedCodeRef, + visitModuleDeps, + type CodeRef, +} from './code-ref'; +import { isSingleCardDocument, type SingleCardDocument, - type CardCollectionDocument, + type LinkableCollectionDocument, + isLinkableCollectionDocument, } from './document-types'; import { relationshipEntries } from './relationship-utils'; import type { CardResource, FileMetaResource, Saved } from './resource-types'; @@ -121,26 +126,44 @@ export class RealmIndexQueryEngine { return new URL(this.#realm.url); } - async search(query: Query, opts?: Options): Promise { - let doc: CardCollectionDocument; - let { cards: data, meta } = await this.#indexQueryEngine.search( - new URL(this.#realm.url), - query, - opts, - ); - doc = { - data: data.map((resource) => ({ - ...resource, - ...{ links: { self: resource.id } }, - })), - meta, - }; + async searchCards( + query: Query, + opts?: Options, + ): Promise { + let doc: LinkableCollectionDocument; + + if (await this.queryTargetsFileMeta(query.filter, opts)) { + let { files, meta } = await this.#indexQueryEngine.searchFiles( + new URL(this.#realm.url), + query, + opts, + ); + doc = { + data: files.map((fileEntry) => + fileResourceFromIndex(new URL(fileEntry.canonicalURL), fileEntry), + ), + meta, + }; + } else { + let { cards, meta } = await this.#indexQueryEngine.searchCards( + new URL(this.#realm.url), + query, + opts, + ); + doc = { + data: cards.map((resource) => ({ + ...resource, + ...{ links: { self: resource.id } }, + })), + meta, + }; + } - let omit = doc.data.map((r) => r.id).filter(Boolean) as string[]; // TODO eventually the links will be cached in the index, and this will only // fill in the included resources for links that were not cached (e.g. // volatile fields) if (opts?.loadLinks) { + let omit = doc.data.map((r) => r.id).filter(Boolean) as string[]; let included: (CardResource | FileMetaResource)[] = []; for (let resource of doc.data) { included = await this.loadLinks( @@ -160,6 +183,30 @@ export class RealmIndexQueryEngine { return doc; } + private async queryTargetsFileMeta( + filter: Filter | undefined, + opts?: Options, + ): Promise { + if (!filter) { + return false; + } + let refs: CodeRef[] = []; + collectFilterRefs(filter, refs); + let fileMatch = false; + let instanceMatch = false; + for (let ref of refs) { + if (await this.#indexQueryEngine.hasFileType(this.realmURL, ref, opts)) { + fileMatch = true; + } + if ( + await this.#indexQueryEngine.hasInstanceType(this.realmURL, ref, opts) + ) { + instanceMatch = true; + } + } + return fileMatch && !instanceMatch; + } + async fetchCardTypeSummary() { let results = await this.#indexQueryEngine.fetchCardTypeSummary( new URL(this.#realm.url), @@ -315,7 +362,7 @@ export class RealmIndexQueryEngine { realmURL: URL; opts?: Options; }): Promise<{ - results: CardResource[]; + results: (CardResource | FileMetaResource)[]; errors: QueryFieldErrorDetail[]; searchURL: string; }> { @@ -338,19 +385,32 @@ export class RealmIndexQueryEngine { let { query, realm } = normalized; let searchURL = buildQuerySearchURL(realm, query); - let aggregated: CardResource[] = []; + let aggregated: (CardResource | FileMetaResource)[] = []; let seen = new Set(); let errors: QueryFieldErrorDetail[] = []; - let realmResults: CardResource[] = []; + let realmResults: (CardResource | FileMetaResource)[] = []; if (realm === this.realmURL.href) { try { - let collection = await this.#indexQueryEngine.search( - this.realmURL, - query, - opts, - ); - realmResults = Array.isArray(collection.cards) ? collection.cards : []; + if (await this.queryTargetsFileMeta(query.filter, opts)) { + let { files } = await this.#indexQueryEngine.searchFiles( + this.realmURL, + query, + opts, + ); + realmResults = files.map((fileEntry) => + fileResourceFromIndex(new URL(fileEntry.canonicalURL), fileEntry), + ); + } else { + let collection = await this.#indexQueryEngine.searchCards( + this.realmURL, + query, + opts, + ); + realmResults = Array.isArray(collection.cards) + ? collection.cards + : []; + } } catch (err: unknown) { let message = err instanceof Error ? err.message : String(err ?? 'unknown error'); @@ -374,12 +434,12 @@ export class RealmIndexQueryEngine { realmResults = remoteResult.cards; } - for (let card of realmResults) { - if (!card?.id || seen.has(card.id)) { + for (let result of realmResults) { + if (!result?.id || seen.has(result.id)) { continue; } - seen.add(card.id); - aggregated.push(card); + seen.add(result.id); + aggregated.push(result); } if ( @@ -415,7 +475,7 @@ export class RealmIndexQueryEngine { fieldDefinition: FieldDefinition; fieldName: string; resource: LooseCardResource | FileMetaResource; - results: CardResource[]; + results: (CardResource | FileMetaResource)[]; errors: QueryFieldErrorDetail[]; searchURL: string; }): void { @@ -454,7 +514,7 @@ export class RealmIndexQueryEngine { if (first && first.id) { relationship.links.self = first.id; if (searchURL) { - relationship.data = { type: 'card', id: first.id }; + relationship.data = { type: first.type ?? 'card', id: first.id }; } } else { relationship.links.self = null; @@ -480,7 +540,7 @@ export class RealmIndexQueryEngine { (card): card is CardResource & { id: string } => typeof card.id === 'string', ) - .map((card) => ({ type: 'card', id: card.id })) + .map((card) => ({ type: card.type ?? 'card', id: card.id })) : undefined; resource.relationships[fieldName] = { @@ -495,7 +555,7 @@ export class RealmIndexQueryEngine { } resource.relationships![`${fieldName}.${index}`] = { links: { self: card.id }, - data: { type: 'card', id: card.id }, + data: { type: card.type ?? 'card', id: card.id }, }; }); } @@ -503,7 +563,10 @@ export class RealmIndexQueryEngine { private async fetchRemoteQueryResults( realmHref: string, query: Query, - ): Promise<{ cards: CardResource[]; error?: QueryFieldErrorDetail }> { + ): Promise<{ + cards: (CardResource | FileMetaResource)[]; + error?: QueryFieldErrorDetail; + }> { try { let searchURL = buildQuerySearchURL(realmHref, query); let { realm, realms, ...queryWithoutRealm } = query as Query & { @@ -537,7 +600,7 @@ export class RealmIndexQueryEngine { }; } let json = await response.json(); - if (!isCardCollectionDocument(json)) { + if (!isLinkableCollectionDocument(json)) { return { cards: [], error: { @@ -726,6 +789,25 @@ export class RealmIndexQueryEngine { } } +function collectFilterRefs(filter: Filter, refs: CodeRef[]) { + let filterWithType = filter as { type?: CodeRef; on?: CodeRef }; + if (filterWithType.type) { + refs.push(filterWithType.type); + } + if (filterWithType.on) { + refs.push(filterWithType.on); + } + if ('every' in filter) { + filter.every.forEach((inner) => collectFilterRefs(inner, refs)); + } + if ('any' in filter) { + filter.any.forEach((inner) => collectFilterRefs(inner, refs)); + } + if ('not' in filter) { + collectFilterRefs(filter.not, refs); + } +} + function relativizeDocument(doc: SingleCardDocument, realmURL: URL): void { let primarySelf = doc.data.links?.self ?? doc.data.id; if (!primarySelf) { diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 2ffe301e64a..8829893ff22 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -4,7 +4,7 @@ import { transformResultsToPrerenderedCardsDoc, type SingleCardDocument, type SingleFileMetaDocument, - type CardCollectionDocument, + type LinkableCollectionDocument, type PrerenderedCardCollectionDocument, } from './document-types'; import { isMeta, type CardResource, type Relationship } from './resource-types'; @@ -2965,9 +2965,9 @@ export class Realm { return this.#realmIndexUpdater.isIgnored(url); } - public async search(cardsQuery: Query): Promise { - assertQuery(cardsQuery); - return await this.#realmIndexQueryEngine.search(cardsQuery, { + public async search(query: Query): Promise { + assertQuery(query); + return await this.#realmIndexQueryEngine.searchCards(query, { loadLinks: true, }); } @@ -3095,23 +3095,20 @@ export class Realm { } public async searchPrerendered( - cardsQuery: Query, + query: Query, opts: { htmlFormat: PrerenderedHtmlFormat; cardUrls?: string[]; renderType?: ResolvedCodeRef; }, ): Promise { - assertQuery(cardsQuery); - let results = await this.#realmIndexQueryEngine.searchPrerendered( - cardsQuery, - { - htmlFormat: opts.htmlFormat, - cardUrls: opts.cardUrls, - renderType: opts.renderType, - includeErrors: true, - }, - ); + assertQuery(query); + let results = await this.#realmIndexQueryEngine.searchPrerendered(query, { + htmlFormat: opts.htmlFormat, + cardUrls: opts.cardUrls, + renderType: opts.renderType, + includeErrors: true, + }); return transformResultsToPrerenderedCardsDoc(results); } @@ -3124,7 +3121,7 @@ export class Realm { let htmlFormat: string | undefined; let cardUrls: string[] | string | undefined; let renderType: CodeRef | undefined; - let cardsQuery: unknown; + let query: unknown; if (request.method !== 'QUERY') { return createResponse({ @@ -3172,7 +3169,7 @@ export class Realm { delete payload.prerenderedHtmlFormat; delete payload.cardUrls; delete payload.renderType; - cardsQuery = payload; + query = payload; if (!isValidPrerenderedHtmlFormat(htmlFormat)) { return createResponse({ @@ -3222,7 +3219,7 @@ export class Realm { : undefined; try { - assertQuery(cardsQuery); + assertQuery(query); } catch (e) { if (e instanceof InvalidQueryError) { return createResponse({ @@ -3245,7 +3242,7 @@ export class Realm { throw e; } - let doc = await this.searchPrerendered(cardsQuery as Query, { + let doc = await this.searchPrerendered(query as Query, { htmlFormat, cardUrls: normalizedCardUrls, renderType: normalizedRenderType, diff --git a/packages/runtime-common/search-utils.ts b/packages/runtime-common/search-utils.ts index 30e31546b84..a2ebc03297e 100644 --- a/packages/runtime-common/search-utils.ts +++ b/packages/runtime-common/search-utils.ts @@ -6,7 +6,7 @@ import { type PrerenderedHtmlFormat, } from './prerendered-html-format'; import type { - CardCollectionDocument, + LinkableCollectionDocument, PrerenderedCardCollectionDocument, } from './document-types'; import { SupportedMimeType } from './router'; @@ -253,13 +253,13 @@ export function parsePrerenderedSearchRequestFromPayload(payload: unknown): { } export function combineSearchResults( - docs: CardCollectionDocument[], -): CardCollectionDocument { - let combined: CardCollectionDocument = { + docs: LinkableCollectionDocument[], +): LinkableCollectionDocument { + let combined: LinkableCollectionDocument = { data: [], meta: { page: { total: 0 } }, }; - let included: NonNullable = []; + let included: NonNullable = []; let includedById = new Set(); for (let doc of docs) { @@ -313,14 +313,14 @@ export function combinePrerenderedSearchResults( } type SearchableRealm = { - search: (query: Query) => Promise; + search: (query: Query) => Promise; url?: string; }; export async function searchRealms( realms: Array, query: Query, -): Promise { +): Promise { let realmEntries = realms .filter((realm): realm is SearchableRealm => Boolean(realm)) .map((realm) => ({