diff --git a/.changeset/shy-bugs-obey.md b/.changeset/shy-bugs-obey.md
new file mode 100644
index 00000000..9a279c14
--- /dev/null
+++ b/.changeset/shy-bugs-obey.md
@@ -0,0 +1,7 @@
+---
+"@graphprotocol/hypergraph-react": patch
+"@graphprotocol/hypergraph": patch
+---
+
+Add support for querying public entities across multiple spaces (including an `all` scope) and expose the new API through the React hooks
+
\ No newline at end of file
diff --git a/apps/events/src/routes/podcasts.lazy.tsx b/apps/events/src/routes/podcasts.lazy.tsx
index 0288ef92..c8512b3c 100644
--- a/apps/events/src/routes/podcasts.lazy.tsx
+++ b/apps/events/src/routes/podcasts.lazy.tsx
@@ -1,6 +1,6 @@
import { useEntities } from '@graphprotocol/hypergraph-react';
import { createLazyFileRoute } from '@tanstack/react-router';
-import { Podcast, Topic } from '@/schema';
+import { Podcast, Space, Topic } from '@/schema';
export const Route = createLazyFileRoute('/podcasts')({
component: RouteComponent,
@@ -54,6 +54,8 @@ function RouteComponent() {
backlinksTotalCountsTypeId1: '972d201a-d780-4568-9e01-543f67b26bee',
});
+ console.log({ data, isLoading, isError });
+
const { data: topics } = useEntities(Topic, {
mode: 'public',
first: 10,
@@ -74,7 +76,15 @@ function RouteComponent() {
console.log({ topics });
- console.log({ data, isLoading, isError });
+ const { data: spaces } = useEntities(Space, {
+ mode: 'public',
+ spaces: 'all',
+ include: {
+ avatar: {},
+ },
+ });
+ console.log('spaces', spaces);
+
return (
<>
Podcasts
diff --git a/apps/events/src/schema.ts b/apps/events/src/schema.ts
index 1ac60c30..87f0c45c 100644
--- a/apps/events/src/schema.ts
+++ b/apps/events/src/schema.ts
@@ -1,3 +1,4 @@
+import { SystemIds } from '@graphprotocol/grc-20';
import { Entity, Id, Type } from '@graphprotocol/hypergraph';
export const User = Entity.Schema(
@@ -304,3 +305,19 @@ export const Episode = Entity.Schema(
},
},
);
+
+export const Space = Entity.Schema(
+ {
+ name: Type.String,
+ avatar: Type.Relation(Image),
+ },
+ {
+ types: [Id('362c1dbd-dc64-44bb-a3c4-652f38a642d7')],
+ properties: {
+ name: Id(SystemIds.NAME_PROPERTY),
+ avatar: Id('1155beff-fad5-49b7-a2e0-da4777b8792c'),
+ },
+ },
+);
+
+export type Space = Entity.Entity;
diff --git a/packages/hypergraph-react/src/hooks/use-entities-public-infinite.ts b/packages/hypergraph-react/src/hooks/use-entities-public-infinite.ts
index c5b6ad1d..5ce1a48c 100644
--- a/packages/hypergraph-react/src/hooks/use-entities-public-infinite.ts
+++ b/packages/hypergraph-react/src/hooks/use-entities-public-infinite.ts
@@ -10,17 +10,24 @@ export const useEntitiesPublicInfinite = (
type: S,
params?: QueryPublicParams,
) => {
- const { enabled = true, filter, include, space: spaceFromParams, first = 2, offset = 0 } = params ?? {};
+ const { enabled = true, filter, include, space: spaceFromParams, spaces, first = 2, offset = 0 } = params ?? {};
const { space: spaceFromContext } = useHypergraphSpaceInternal();
const space = spaceFromParams ?? spaceFromContext;
+ const spaceSelectionKey = spaces ?? space;
const typeIds = SchemaAST.getAnnotation(Constants.TypeIdsSymbol)(type.ast as SchemaAST.TypeLiteral).pipe(
Option.getOrElse(() => []),
);
const result = useInfiniteQueryTanstack({
- queryKey: ['hypergraph-public-entities', space, typeIds, include, filter, 'infinite'],
+ queryKey: ['hypergraph-public-entities', spaceSelectionKey, typeIds, include, filter, 'infinite'],
queryFn: async ({ pageParam }) => {
- return Entity.findManyPublic(type, { filter, include, space, first, offset: pageParam });
+ return Entity.findManyPublic(type, {
+ filter,
+ include,
+ ...(spaces ? { spaces } : { space }),
+ first,
+ offset: pageParam,
+ });
},
getNextPageParam: (_lastPage, pages) => {
return offset + pages.length * first;
diff --git a/packages/hypergraph-react/src/hooks/use-entities.tsx b/packages/hypergraph-react/src/hooks/use-entities.tsx
index 0d7673f3..883702b9 100644
--- a/packages/hypergraph-react/src/hooks/use-entities.tsx
+++ b/packages/hypergraph-react/src/hooks/use-entities.tsx
@@ -2,13 +2,21 @@ import type { Entity } from '@graphprotocol/hypergraph';
import type * as Schema from 'effect/Schema';
import { useEntitiesPrivate } from '../internal/use-entities-private.js';
import { useEntitiesPublic } from '../internal/use-entities-public.js';
+import { useHypergraphSpaceInternal } from '../internal/use-hypergraph-space-internal.js';
-type UseEntitiesParams = {
+type SpaceSelectionInputOptionInContext = {
+ space?: never;
+ spaces?: never;
+};
+
+type UseEntitiesParams = (
+ | Entity.SpaceSelectionInput
+ | SpaceSelectionInputOptionInContext
+) & {
mode: 'public' | 'private';
filter?: Entity.EntityFilter> | undefined;
// TODO: restrict multi-level nesting to the actual relation keys
include?: Entity.EntityInclude | undefined;
- space?: string | undefined;
first?: number | undefined;
offset?: number | undefined;
orderBy?:
@@ -21,18 +29,21 @@ type UseEntitiesParams = {
};
export function useEntities(type: S, params: UseEntitiesParams) {
- const { mode, filter, include, space, first, offset, orderBy, backlinksTotalCountsTypeId1 } = params;
+ const { mode, filter, include, space, spaces, first, offset, orderBy, backlinksTotalCountsTypeId1 } = params;
+ const { space: spaceFromContext } = useHypergraphSpaceInternal();
+ const resolvedSpace = space ?? spaceFromContext;
+ const publicSpaceParams = spaces ? { spaces } : { space: resolvedSpace };
const publicResult = useEntitiesPublic(type, {
enabled: mode === 'public',
filter,
include,
first,
offset,
- space,
orderBy,
backlinksTotalCountsTypeId1,
+ ...publicSpaceParams,
});
- const localResult = useEntitiesPrivate(type, { enabled: mode === 'private', filter, include, space });
+ const localResult = useEntitiesPrivate(type, { enabled: mode === 'private', filter, include, space: resolvedSpace });
if (mode === 'public') {
return {
diff --git a/packages/hypergraph-react/src/internal/types.ts b/packages/hypergraph-react/src/internal/types.ts
index f0bfdde3..80bca8ff 100644
--- a/packages/hypergraph-react/src/internal/types.ts
+++ b/packages/hypergraph-react/src/internal/types.ts
@@ -3,17 +3,4 @@ import type * as Schema from 'effect/Schema';
export type QueryPublicParams = {
enabled?: boolean | undefined;
- filter?: Entity.EntityFilter> | undefined;
- // TODO: restrict multi-level nesting to the actual relation keys
- include?: Entity.EntityInclude | undefined;
- space?: string | undefined;
- first?: number | undefined;
- offset?: number | undefined;
- orderBy?:
- | {
- property: keyof Schema.Schema.Type;
- direction: 'asc' | 'desc';
- }
- | undefined;
- backlinksTotalCountsTypeId1?: string | undefined;
-};
+} & Entity.FindManyPublicParams;
diff --git a/packages/hypergraph-react/src/internal/use-entities-public.tsx b/packages/hypergraph-react/src/internal/use-entities-public.tsx
index e78b7aa2..d50b7106 100644
--- a/packages/hypergraph-react/src/internal/use-entities-public.tsx
+++ b/packages/hypergraph-react/src/internal/use-entities-public.tsx
@@ -12,6 +12,7 @@ export const useEntitiesPublic = (type: S,
filter,
include,
space: spaceFromParams,
+ spaces,
first = 100,
offset,
orderBy,
@@ -19,6 +20,7 @@ export const useEntitiesPublic = (type: S,
} = params ?? {};
const { space: spaceFromContext } = useHypergraphSpaceInternal();
const space = spaceFromParams ?? spaceFromContext;
+ const spaceSelectionKey = spaces ?? space;
const typeIds = SchemaAST.getAnnotation(Constants.TypeIdsSymbol)(type.ast as SchemaAST.TypeLiteral).pipe(
Option.getOrElse(() => []),
);
@@ -26,7 +28,7 @@ export const useEntitiesPublic = (type: S,
const result = useQueryTanstack({
queryKey: [
'hypergraph-public-entities',
- space,
+ spaceSelectionKey,
typeIds,
include,
filter,
@@ -39,7 +41,7 @@ export const useEntitiesPublic = (type: S,
return Entity.findManyPublic(type, {
filter,
include,
- space,
+ ...(spaces ? { spaces } : { space }),
first,
offset,
orderBy,
diff --git a/packages/hypergraph/src/entity/find-many-public.ts b/packages/hypergraph/src/entity/find-many-public.ts
index 7219796a..7cd7da5b 100644
--- a/packages/hypergraph/src/entity/find-many-public.ts
+++ b/packages/hypergraph/src/entity/find-many-public.ts
@@ -8,12 +8,14 @@ import { request } from 'graphql-request';
import type { RelationsListWithNodes } from '../utils/convert-relations.js';
import type { RelationTypeIdInfo } from '../utils/get-relation-type-ids.js';
import { buildRelationsSelection } from '../utils/relation-query-helpers.js';
+import type { SpaceSelection } from './internal/space-selection.js';
+import { normalizeSpaceSelection } from './internal/space-selection.js';
+import type { SpaceSelectionInput } from './types.js';
-export type FindManyPublicParams = {
+export type FindManyPublicParams = SpaceSelectionInput & {
filter?: Entity.EntityFilter> | undefined;
// TODO: restrict multi-level nesting to the actual relation keys
include?: Entity.EntityInclude | undefined;
- space: string;
first?: number | undefined;
offset?: number | undefined;
orderBy?:
@@ -25,26 +27,65 @@ export type FindManyPublicParams = {
backlinksTotalCountsTypeId1?: string | undefined;
};
-const buildEntitiesQuery = (relationInfoLevel1: RelationTypeIdInfo[], useOrderBy: boolean) => {
- const level1Relations = buildRelationsSelection(relationInfoLevel1);
+const buildEntitiesQuery = (
+ relationInfoLevel1: RelationTypeIdInfo[],
+ useOrderBy: boolean,
+ spaceSelection: SpaceSelection,
+) => {
+ const level1Relations = buildRelationsSelection(relationInfoLevel1, spaceSelection.mode);
const queryName = useOrderBy ? 'entitiesOrderedByProperty' : 'entities';
- const orderByParams = useOrderBy ? '$propertyId: UUID!, $sortDirection: SortOrder!, ' : '';
+ const variableDefinitions = [
+ spaceSelection.mode === 'single'
+ ? '$spaceId: UUID!'
+ : spaceSelection.mode === 'many'
+ ? '$spaceIds: [UUID!]!'
+ : undefined,
+ '$typeIds: [UUID!]!',
+ useOrderBy ? '$propertyId: UUID!' : undefined,
+ useOrderBy ? '$sortDirection: SortOrder!' : undefined,
+ '$first: Int',
+ '$filter: EntityFilter!',
+ '$offset: Int',
+ '$backlinksTotalCountsTypeId1: UUID',
+ '$backlinksTotalCountsTypeId1Present: Boolean!',
+ ]
+ .filter(Boolean)
+ .join(', ');
+
const orderByArgs = useOrderBy ? 'propertyId: $propertyId\n sortDirection: $sortDirection\n ' : '';
+ const entitySpaceFilter =
+ spaceSelection.mode === 'single'
+ ? 'spaceIds: {in: [$spaceId]},'
+ : spaceSelection.mode === 'many'
+ ? 'spaceIds: {in: $spaceIds},'
+ : '';
+ const valuesListFilter =
+ spaceSelection.mode === 'single'
+ ? '(filter: { spaceId: { is: $spaceId } })'
+ : spaceSelection.mode === 'many'
+ ? '(filter: { spaceId: { in: $spaceIds } })'
+ : '';
+ const backlinksSpaceFilter =
+ spaceSelection.mode === 'single'
+ ? 'spaceId: {is: $spaceId}, '
+ : spaceSelection.mode === 'many'
+ ? 'spaceId: {in: $spaceIds}, '
+ : '';
return `
-query ${queryName}($spaceId: UUID!, $typeIds: [UUID!]!, ${orderByParams}$first: Int, $filter: EntityFilter!, $offset: Int, $backlinksTotalCountsTypeId1: UUID, $backlinksTotalCountsTypeId1Present: Boolean!) {
+query ${queryName}(${variableDefinitions}) {
entities: ${queryName}(
${orderByArgs}filter: { and: [{
relations: {some: {typeId: {is: "8f151ba4-de20-4e3c-9cb4-99ddf96f48f1"}, toEntityId: {in: $typeIds}}},
- spaceIds: {in: [$spaceId]},
+ ${entitySpaceFilter}
}, $filter]}
first: $first
offset: $offset
) {
id
name
- valuesList(filter: {spaceId: {is: $spaceId}}) {
+ valuesList${valuesListFilter} {
propertyId
string
boolean
@@ -52,7 +93,7 @@ query ${queryName}($spaceId: UUID!, $typeIds: [UUID!]!, ${orderByParams}$first:
time
point
}
- backlinksTotalCountsTypeId1: backlinks(filter: { spaceId: {is: $spaceId}, fromEntity: { typeIds: { is: [$backlinksTotalCountsTypeId1] } }}) @include(if: $backlinksTotalCountsTypeId1Present) {
+ backlinksTotalCountsTypeId1: backlinks(filter: { ${backlinksSpaceFilter}fromEntity: { typeIds: { is: [$backlinksTotalCountsTypeId1] } }}) @include(if: $backlinksTotalCountsTypeId1Present) {
totalCount
}
${level1Relations}
@@ -150,7 +191,16 @@ export const findManyPublic = async (
type: S,
params?: FindManyPublicParams,
) => {
- const { filter, include, space, first = 100, offset = 0, orderBy, backlinksTotalCountsTypeId1 } = params ?? {};
+ const {
+ filter,
+ include,
+ space,
+ spaces,
+ first = 100,
+ offset = 0,
+ orderBy,
+ backlinksTotalCountsTypeId1,
+ } = params ?? {};
// constructing the relation type ids for the query
const relationTypeIds = Utils.getRelationTypeIds(type, include);
@@ -187,18 +237,26 @@ export const findManyPublic = async (
}
// Build the query dynamically with aliases for each relation type ID
- const queryDocument = buildEntitiesQuery(relationTypeIds, Boolean(orderBy));
+ const spaceSelection = normalizeSpaceSelection(space, spaces);
+
+ // Build the query dynamically with aliases for each relation type ID
+ const queryDocument = buildEntitiesQuery(relationTypeIds, Boolean(orderBy), spaceSelection);
const filterParams = filter ? Utils.translateFilterToGraphql(filter, type) : {};
const queryVariables: Record = {
- spaceId: space,
typeIds,
first,
filter: filterParams,
offset,
};
+ if (spaceSelection.mode === 'single') {
+ queryVariables.spaceId = spaceSelection.spaceId;
+ } else if (spaceSelection.mode === 'many') {
+ queryVariables.spaceIds = spaceSelection.spaceIds;
+ }
+
if (orderByPropertyId && sortDirection) {
queryVariables.propertyId = orderByPropertyId;
queryVariables.sortDirection = sortDirection;
diff --git a/packages/hypergraph/src/entity/find-one-public.ts b/packages/hypergraph/src/entity/find-one-public.ts
index 2dc26c08..055a883a 100644
--- a/packages/hypergraph/src/entity/find-one-public.ts
+++ b/packages/hypergraph/src/entity/find-one-public.ts
@@ -21,7 +21,7 @@ export type FindOnePublicParams = {
};
const buildEntityQuery = (relationInfoLevel1: RelationTypeIdInfo[]) => {
- const relationsSelection = buildRelationsSelection(relationInfoLevel1);
+ const relationsSelection = buildRelationsSelection(relationInfoLevel1, 'single');
const relationsSelectionBlock = relationsSelection ? `\n ${relationsSelection}\n` : '';
return `
query entity($id: UUID!, $spaceId: UUID!) {
diff --git a/packages/hypergraph/src/entity/internal/space-selection.ts b/packages/hypergraph/src/entity/internal/space-selection.ts
new file mode 100644
index 00000000..791702d0
--- /dev/null
+++ b/packages/hypergraph/src/entity/internal/space-selection.ts
@@ -0,0 +1,35 @@
+export type SpaceSelection =
+ | {
+ mode: 'single';
+ spaceId: string;
+ }
+ | {
+ mode: 'many';
+ spaceIds: readonly [string, ...string[]];
+ }
+ | {
+ mode: 'all';
+ };
+
+export const normalizeSpaceSelection = (
+ space: string | undefined,
+ spaces: readonly [string, ...string[]] | 'all' | undefined,
+): SpaceSelection => {
+ if (space && spaces) {
+ throw new Error('Provide either "space" or "spaces", not both.');
+ }
+
+ if (space) {
+ return { mode: 'single', spaceId: space };
+ }
+
+ if (spaces === 'all') {
+ return { mode: 'all' };
+ }
+
+ if (spaces && spaces.length > 0) {
+ return { mode: 'many', spaceIds: spaces };
+ }
+
+ throw new Error('Either "space" or non-empty "spaces" must be provided.');
+};
diff --git a/packages/hypergraph/src/entity/search-many-public.ts b/packages/hypergraph/src/entity/search-many-public.ts
index 5d45a8e2..bad8d144 100644
--- a/packages/hypergraph/src/entity/search-many-public.ts
+++ b/packages/hypergraph/src/entity/search-many-public.ts
@@ -19,7 +19,7 @@ export type SearchManyPublicParams = {
};
const buildSearchQuery = (relationInfoLevel1: RelationTypeIdInfo[]) => {
- const relationsSelection = buildRelationsSelection(relationInfoLevel1);
+ const relationsSelection = buildRelationsSelection(relationInfoLevel1, 'single');
return `
query searchEntities($query: String!, $spaceId: UUID!, $typeIds: [UUID!]!, $first: Int, $filter: EntityFilter!, $offset: Int) {
diff --git a/packages/hypergraph/src/entity/types.ts b/packages/hypergraph/src/entity/types.ts
index 2a0a56b8..f665c47b 100644
--- a/packages/hypergraph/src/entity/types.ts
+++ b/packages/hypergraph/src/entity/types.ts
@@ -104,3 +104,17 @@ export type EntityFilter = CrossFieldFilter<
id?: EntityIdFilter;
}
>;
+
+export type SpaceSelectionInput =
+ | {
+ space: string;
+ spaces?: never;
+ }
+ | {
+ space?: never;
+ spaces: readonly [string, ...string[]];
+ }
+ | {
+ space?: never;
+ spaces: 'all';
+ };
diff --git a/packages/hypergraph/src/utils/relation-query-helpers.ts b/packages/hypergraph/src/utils/relation-query-helpers.ts
index c6cf5359..c934cd26 100644
--- a/packages/hypergraph/src/utils/relation-query-helpers.ts
+++ b/packages/hypergraph/src/utils/relation-query-helpers.ts
@@ -1,14 +1,38 @@
import type { RelationTypeIdInfo } from './get-relation-type-ids.js';
+type SpaceSelectionMode = 'single' | 'many' | 'all';
+
+const buildValuesListFilter = (spaceSelectionMode: SpaceSelectionMode) => {
+ if (spaceSelectionMode === 'single') {
+ return '(filter: {spaceId: {is: $spaceId}})';
+ }
+ if (spaceSelectionMode === 'many') {
+ return '(filter: {spaceId: {in: $spaceIds}})';
+ }
+ return '';
+};
+
+const buildRelationSpaceFilter = (spaceSelectionMode: SpaceSelectionMode) => {
+ if (spaceSelectionMode === 'single') {
+ return 'spaceId: {is: $spaceId}, ';
+ }
+ if (spaceSelectionMode === 'many') {
+ return 'spaceId: {in: $spaceIds}, ';
+ }
+ return '';
+};
+
export const getRelationAlias = (typeId: string) => `relations_${typeId.replace(/-/g, '_')}`;
-const buildRelationsListFragment = (info: RelationTypeIdInfo, level: 1 | 2) => {
+const buildRelationsListFragment = (info: RelationTypeIdInfo, level: 1 | 2, spaceSelectionMode: SpaceSelectionMode) => {
const alias = getRelationAlias(info.typeId);
const nestedPlaceholder = info.includeNodes && level === 1 ? '__LEVEL2_RELATIONS__' : '';
const listField = info.listField ?? 'relations';
const connectionField = listField === 'backlinks' ? 'backlinks' : 'relations';
const toEntityField = listField === 'backlinks' ? 'fromEntity' : 'toEntity';
const toEntitySelectionHeader = toEntityField === 'toEntity' ? 'toEntity' : `toEntity: ${toEntityField}`;
+ const valuesListFilter = buildValuesListFilter(spaceSelectionMode);
+ const relationSpaceFilter = buildRelationSpaceFilter(spaceSelectionMode);
if (!info.includeNodes && !info.includeTotalCount) {
return '';
@@ -24,7 +48,7 @@ const buildRelationsListFragment = (info: RelationTypeIdInfo, level: 1 | 2) => {
nodes {
id
entity {
- valuesList(filter: {spaceId: {is: $spaceId}}) {
+ valuesList${valuesListFilter} {
propertyId
string
boolean
@@ -36,7 +60,7 @@ const buildRelationsListFragment = (info: RelationTypeIdInfo, level: 1 | 2) => {
${toEntitySelectionHeader} {
id
name
- valuesList(filter: {spaceId: {is: $spaceId}}) {
+ valuesList${valuesListFilter} {
propertyId
string
boolean
@@ -52,28 +76,36 @@ const buildRelationsListFragment = (info: RelationTypeIdInfo, level: 1 | 2) => {
return `
${alias}: ${connectionField}(
- filter: {spaceId: {is: $spaceId}, typeId: {is: "${info.typeId}"}},
+ filter: {${relationSpaceFilter}typeId: {is: "${info.typeId}"}},
) {${totalCountSelection}${nodesSelection}
}`;
};
-const buildLevel2RelationsFragment = (relationInfoLevel2: RelationTypeIdInfo[]) => {
+const buildLevel2RelationsFragment = (
+ relationInfoLevel2: RelationTypeIdInfo[],
+ spaceSelectionMode: SpaceSelectionMode,
+) => {
if (relationInfoLevel2.length === 0) return '';
- return relationInfoLevel2.map((info) => buildRelationsListFragment(info, 2)).join('\n');
+ return relationInfoLevel2.map((info) => buildRelationsListFragment(info, 2, spaceSelectionMode)).join('\n');
};
-const buildLevel1RelationsFragment = (relationInfoLevel1: RelationTypeIdInfo[]) => {
+const buildLevel1RelationsFragment = (
+ relationInfoLevel1: RelationTypeIdInfo[],
+ spaceSelectionMode: SpaceSelectionMode,
+) => {
if (relationInfoLevel1.length === 0) return '';
return relationInfoLevel1
.map((info) => {
- const level2Fragment = buildLevel2RelationsFragment(info.children ?? []);
- const fragment = buildRelationsListFragment(info, 1);
+ const level2Fragment = buildLevel2RelationsFragment(info.children ?? [], spaceSelectionMode);
+ const fragment = buildRelationsListFragment(info, 1, spaceSelectionMode);
return fragment.replace('__LEVEL2_RELATIONS__', level2Fragment);
})
.join('\n');
};
-export const buildRelationsSelection = (relationInfoLevel1: RelationTypeIdInfo[]) =>
- buildLevel1RelationsFragment(relationInfoLevel1);
+export const buildRelationsSelection = (
+ relationInfoLevel1: RelationTypeIdInfo[],
+ spaceSelectionMode: SpaceSelectionMode,
+) => buildLevel1RelationsFragment(relationInfoLevel1, spaceSelectionMode);
diff --git a/packages/hypergraph/test/entity/space-selection.test.ts b/packages/hypergraph/test/entity/space-selection.test.ts
new file mode 100644
index 00000000..8a4144af
--- /dev/null
+++ b/packages/hypergraph/test/entity/space-selection.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'vitest';
+import { normalizeSpaceSelection } from '../../src/entity/internal/space-selection.js';
+
+describe('normalizeSpaceSelection', () => {
+ it('returns single selection when only space is provided', () => {
+ expect(normalizeSpaceSelection('space-id', undefined)).toEqual({
+ mode: 'single',
+ spaceId: 'space-id',
+ });
+ });
+
+ it('returns many selection when spaces array is provided', () => {
+ expect(normalizeSpaceSelection(undefined, ['space-1', 'space-2'])).toEqual({
+ mode: 'many',
+ spaceIds: ['space-1', 'space-2'],
+ });
+ });
+
+ it('returns all selection when spaces is the string "all"', () => {
+ expect(normalizeSpaceSelection(undefined, 'all')).toEqual({ mode: 'all' });
+ });
+
+ it('throws when both space and spaces are provided', () => {
+ expect(() => normalizeSpaceSelection('space-id', ['space-2'])).toThrowError(
+ 'Provide either "space" or "spaces", not both.',
+ );
+ });
+
+ it('throws when neither space nor spaces are provided', () => {
+ expect(() => normalizeSpaceSelection(undefined, undefined)).toThrowError(
+ 'Either "space" or non-empty "spaces" must be provided.',
+ );
+ });
+
+ it('throws when spaces array is empty despite being typed differently', () => {
+ expect(() => normalizeSpaceSelection(undefined, [] as unknown as readonly [string, ...string[]])).toThrowError(
+ 'Either "space" or non-empty "spaces" must be provided.',
+ );
+ });
+});