diff --git a/.changeset/tiny-hounds-jump.md b/.changeset/tiny-hounds-jump.md new file mode 100644 index 00000000..036d0e31 --- /dev/null +++ b/.changeset/tiny-hounds-jump.md @@ -0,0 +1,22 @@ +--- +'@opensaas/stack-core': minor +'@opensaas/stack-ui': minor +--- + +Add a relationship-options read primitive: `getRelationshipOptions(context, config, relatedListKey, { search?, take?, selectedIds? })` returns a bounded, projected `{ id, label }[]` for relationship editors. It selects only `id` and the resolved label field (via `getLabelFieldName`), so no depth-5 auto-include ever runs; `search` filters via `contains` when the label field is text; results are ordered by the label field; and currently-selected `selectedIds` are always unioned into the result even when outside the `search`/`take` window. Operation-level `query` access on the related list still applies (denied → `[]`). + +Also adds a `relationshipOptions` op on `context.serverAction` so hosts can resolve options from a client without a bespoke endpoint: + +```typescript +await context.serverAction({ + listKey: 'Post', + action: 'relationshipOptions', + field: 'author', + search: 'ada', + take: 20, + selectedIds: ['user-123'], +}) +// => { success: true, data: [{ id: 'user-123', label: 'Ada Lovelace' }, ...] } +``` + +`getRelationshipOptions` is exported from `@opensaas/stack-core` and re-exported from `@opensaas/stack-ui` for server components that already hold a context. diff --git a/packages/core/src/context/index.ts b/packages/core/src/context/index.ts index e6646a4d..66e64084 100644 --- a/packages/core/src/context/index.ts +++ b/packages/core/src/context/index.ts @@ -12,6 +12,7 @@ import { ValidationError, DatabaseError } from '../hooks/index.js' import { getDbKey } from '../lib/case-utils.js' import type { PrismaClientLike } from '../access/types.js' import { buildInclude, pickFields, isFragment } from '../query/index.js' +import { getRelationshipOptions } from '../query/relationship-options.js' import { runWritePipeline, createWriteStrategy, @@ -23,6 +24,14 @@ export type ServerActionProps = | { listKey: string; action: 'create'; data: Record } | { listKey: string; action: 'update'; id: string; data: Record } | { listKey: string; action: 'delete'; id: string } + | { + listKey: string + action: 'relationshipOptions' + field: string + search?: string + take?: number + selectedIds?: string[] + } /** * Tracks which (listName, operation) pairs have already warned about an ignored @@ -422,6 +431,27 @@ export function getContext< } try { + if (props.action === 'relationshipOptions') { + const fieldConfig = listConfig.fields[props.field] as + | { type?: string; ref?: string } + | undefined + if (!fieldConfig || fieldConfig.type !== 'relationship' || !fieldConfig.ref) { + return { + success: false, + error: `Field "${props.field}" on list "${props.listKey}" is not a relationship field`, + } + } + + const relatedListKey = fieldConfig.ref.split('.')[0] + const options = await getRelationshipOptions(context, config, relatedListKey, { + search: props.search, + take: props.take, + selectedIds: props.selectedIds, + }) + + return { success: true, data: options } + } + let result: unknown = null if (props.action === 'create') { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3f864e66..37124b18 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -66,3 +66,9 @@ export type { FieldConfigValidationError } from './validation/field-config.js' // root surface — those live on '@opensaas/stack-core/internal'. export { defineFragment, runQuery, runQueryOne } from './query/index.js' export type { ResultOf, RelationSelector, QueryArgs } from './query/index.js' + +// Relationship-options read primitive — bounded, projected fetch for +// relationship editors. Backs the `relationshipOptions` context.serverAction +// op; also callable directly wherever a full context is already in hand. +export { getRelationshipOptions } from './query/relationship-options.js' +export type { RelationshipOption, RelationshipOptionsArgs } from './query/relationship-options.js' diff --git a/packages/core/src/query/relationship-options.test.ts b/packages/core/src/query/relationship-options.test.ts new file mode 100644 index 00000000..bf2e8a23 --- /dev/null +++ b/packages/core/src/query/relationship-options.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, vi } from 'vitest' +import { getRelationshipOptions } from './relationship-options.js' +import type { QueryRunnerContext } from './index.js' +import type { OpenSaasConfig } from '../config/types.js' + +function makeDelegate(rows: Array>) { + return { + findMany: vi.fn(async (_args?: unknown) => rows), + findFirst: vi.fn(async (_args?: unknown) => rows[0] ?? null), + } +} + +function makeContext( + delegates: Record>, +): QueryRunnerContext { + return { db: delegates } +} + +const authors = [ + { id: 'a1', name: 'Ada Lovelace' }, + { id: 'a2', name: 'Alan Turing' }, + { id: 'a3', name: 'Grace Hopper' }, +] + +function makeConfig(): OpenSaasConfig { + return { + db: { provider: 'sqlite', url: 'file:./test.db' }, + lists: { + Author: { + fields: { + name: { type: 'text' }, + }, + access: { operation: { query: () => true } }, + }, + NumericLabel: { + fields: { + rank: { type: 'integer' }, + }, + ui: { labelField: 'rank' }, + access: { operation: { query: () => true } }, + }, + }, + } as unknown as OpenSaasConfig +} + +describe('getRelationshipOptions', () => { + it('returns { id, label }[] via a scalar-only fragment with no nested include', async () => { + const delegate = makeDelegate(authors) + const context = makeContext({ author: delegate }) + const config = makeConfig() + + const result = await getRelationshipOptions(context, config, 'Author', {}) + + expect(result).toEqual([ + { id: 'a1', label: 'Ada Lovelace' }, + { id: 'a2', label: 'Alan Turing' }, + { id: 'a3', label: 'Grace Hopper' }, + ]) + + const call = delegate.findMany.mock.calls[0][0] as Record + expect(call.include).toBeUndefined() + }) + + it('bounds the result by take', async () => { + const delegate = makeDelegate(authors.slice(0, 2)) + const context = makeContext({ author: delegate }) + const config = makeConfig() + + await getRelationshipOptions(context, config, 'Author', { take: 2 }) + + expect(delegate.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 2 })) + }) + + it('orders by the label field ascending and filters via contains on a text label field', async () => { + const delegate = makeDelegate([authors[0]]) + const context = makeContext({ author: delegate }) + const config = makeConfig() + + await getRelationshipOptions(context, config, 'Author', { search: 'Ada' }) + + expect(delegate.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { name: { contains: 'Ada' } }, + orderBy: { name: 'asc' }, + }), + ) + }) + + it('does not filter (first-N) when the label field is not a text field', async () => { + const rows = [{ id: 'n1', rank: 1 }] + const delegate = makeDelegate(rows) + const context = makeContext({ numericLabel: delegate }) + const config = makeConfig() + + await getRelationshipOptions(context, config, 'NumericLabel', { search: '1' }) + + const call = delegate.findMany.mock.calls[0][0] as Record + expect(call.where).toBeUndefined() + expect(call.orderBy).toEqual({ rank: 'asc' }) + }) + + it('unions currently-selected ids even when beyond take / not matching search', async () => { + // The bounded/search-scoped query only returns a1 (mimicking take:1 + search). + const primaryDelegate = makeDelegate([authors[0]]) + const context = makeContext({ author: primaryDelegate }) + const config = makeConfig() + + // The selected-ids query (a separate findMany call) resolves a3, which fell + // outside the primary window. + primaryDelegate.findMany.mockImplementationOnce(async () => [authors[0]]) + primaryDelegate.findMany.mockImplementationOnce(async () => [authors[2]]) + + const result = await getRelationshipOptions(context, config, 'Author', { + take: 1, + selectedIds: ['a3'], + }) + + expect(result).toEqual([ + { id: 'a1', label: 'Ada Lovelace' }, + { id: 'a3', label: 'Grace Hopper' }, + ]) + + const selectedCall = primaryDelegate.findMany.mock.calls[1][0] as Record + expect(selectedCall.where).toEqual({ id: { in: ['a3'] } }) + }) + + it('does not re-query when the selected id is already within the primary window', async () => { + const delegate = makeDelegate([authors[0]]) + const context = makeContext({ author: delegate }) + const config = makeConfig() + + await getRelationshipOptions(context, config, 'Author', { selectedIds: ['a1'] }) + + expect(delegate.findMany).toHaveBeenCalledTimes(1) + }) + + it('returns [] when the related list query access is denied (findMany returns [])', async () => { + const delegate = makeDelegate([]) + const context = makeContext({ author: delegate }) + const config = makeConfig() + + const result = await getRelationshipOptions(context, config, 'Author', { + selectedIds: ['a1'], + }) + + expect(result).toEqual([]) + }) + + it('returns [] when the related list does not exist in config', async () => { + const context = makeContext({}) + const config = makeConfig() + + const result = await getRelationshipOptions(context, config, 'Missing', {}) + + expect(result).toEqual([]) + }) +}) diff --git a/packages/core/src/query/relationship-options.ts b/packages/core/src/query/relationship-options.ts new file mode 100644 index 00000000..ef45a067 --- /dev/null +++ b/packages/core/src/query/relationship-options.ts @@ -0,0 +1,70 @@ +import type { OpenSaasConfig } from '../config/types.js' +import { getLabelFieldName, getItemLabel } from '../config/label.js' +import { defineFragment, runQuery, type QueryRunnerContext } from './index.js' + +const DEFAULT_TAKE = 50 + +export interface RelationshipOption { + id: string + label: string +} + +export interface RelationshipOptionsArgs { + /** Filters the label field via `contains` when it is a text field. */ + search?: string + /** Bounds the primary (search-scoped) window. @default 50 */ + take?: number + /** Always unioned into the result, even outside the search/take window. */ + selectedIds?: string[] +} + +/** + * Bounded, projected fetch of `{ id, label }` options for a relationship + * editor — the read primitive behind the `relationshipOptions` serverAction + * op. Selects only `id` and the resolved label field (via + * {@link getLabelFieldName}), so the fragment carries no relation keys and + * `buildIncludeWithAccessControl`'s depth-5 auto-include never runs. + * + * Operation-level `query` access on `relatedListKey` still applies — a denied + * list resolves to `[]` (via the underlying access-controlled `findMany`). + */ +export async function getRelationshipOptions( + context: QueryRunnerContext, + config: OpenSaasConfig, + relatedListKey: string, + args: RelationshipOptionsArgs = {}, +): Promise { + const relatedListConfig = config.lists[relatedListKey] + if (!relatedListConfig) return [] + + const labelField = getLabelFieldName(relatedListConfig) + const fragment = defineFragment>()({ + id: true, + [labelField]: true, + }) + + const { search, take = DEFAULT_TAKE, selectedIds = [] } = args + const labelFieldConfig = relatedListConfig.fields[labelField] as { type?: string } | undefined + const where = + search && labelFieldConfig?.type === 'text' ? { [labelField]: { contains: search } } : undefined + + const primary = await runQuery(context, relatedListKey, fragment, { + where, + orderBy: { [labelField]: 'asc' }, + take, + }) + + const seenIds = new Set(primary.map((item) => (item as { id: string }).id)) + const missingSelectedIds = selectedIds.filter((id) => !seenIds.has(id)) + + const selected = missingSelectedIds.length + ? await runQuery(context, relatedListKey, fragment, { + where: { id: { in: missingSelectedIds } }, + }) + : [] + + return [...primary, ...selected].map((item) => ({ + id: (item as { id: string }).id, + label: getItemLabel(relatedListConfig, item as Record), + })) +} diff --git a/packages/core/tests/context.test.ts b/packages/core/tests/context.test.ts index ea25ca31..aa0427e2 100644 --- a/packages/core/tests/context.test.ts +++ b/packages/core/tests/context.test.ts @@ -57,6 +57,7 @@ describe('getContext', () => { fields: { title: { type: 'text' }, content: { type: 'text' }, + author: { type: 'relationship', ref: 'User.posts' }, }, access: { operation: { @@ -200,6 +201,119 @@ describe('getContext', () => { error: 'Database connection failed', }) }) + + describe('relationshipOptions', () => { + it('returns { id, label }[] for the related list, unfiltered/unincluded', async () => { + mockPrisma.user.findMany.mockResolvedValue([ + { id: 'u1', name: 'Ada' }, + { id: 'u2', name: 'Alan' }, + ]) + + const context = await getContext(config, mockPrisma, null) + const result = await context.serverAction({ + listKey: 'Post', + action: 'relationshipOptions', + field: 'author', + }) + + expect(result).toEqual({ + success: true, + data: [ + { id: 'u1', label: 'Ada' }, + { id: 'u2', label: 'Alan' }, + ], + }) + const call = mockPrisma.user.findMany.mock.calls[0][0] + expect(call.include).toBeUndefined() + }) + + it('bounds the query by take', async () => { + mockPrisma.user.findMany.mockResolvedValue([{ id: 'u1', name: 'Ada' }]) + + const context = await getContext(config, mockPrisma, null) + await context.serverAction({ + listKey: 'Post', + action: 'relationshipOptions', + field: 'author', + take: 1, + }) + + expect(mockPrisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({ take: 1 })) + }) + + it('unions selectedIds beyond the take/search window', async () => { + mockPrisma.user.findMany.mockResolvedValueOnce([{ id: 'u1', name: 'Ada' }]) + mockPrisma.user.findMany.mockResolvedValueOnce([{ id: 'u9', name: 'Zed' }]) + + const context = await getContext(config, mockPrisma, null) + const result = await context.serverAction({ + listKey: 'Post', + action: 'relationshipOptions', + field: 'author', + take: 1, + selectedIds: ['u9'], + }) + + expect(result).toEqual({ + success: true, + data: [ + { id: 'u1', label: 'Ada' }, + { id: 'u9', label: 'Zed' }, + ], + }) + }) + + it('returns [] data when the related list query access is denied', async () => { + const deniedConfig: OpenSaasConfig = { + ...config, + lists: { + ...config.lists, + User: { + ...config.lists.User, + access: { operation: { query: () => false } }, + }, + }, + } + mockPrisma.user.findMany.mockResolvedValue([{ id: 'u1', name: 'Ada' }]) + + const context = await getContext(deniedConfig, mockPrisma, null) + const result = await context.serverAction({ + listKey: 'Post', + action: 'relationshipOptions', + field: 'author', + }) + + expect(result).toEqual({ success: true, data: [] }) + }) + + it('returns an error when the field is not a relationship field', async () => { + const context = await getContext(config, mockPrisma, null) + const result = await context.serverAction({ + listKey: 'Post', + action: 'relationshipOptions', + field: 'title', + }) + + expect(result).toEqual({ + success: false, + error: 'Field "title" on list "Post" is not a relationship field', + }) + }) + + it('returns an error when the field does not exist', async () => { + const context = await getContext(config, mockPrisma, null) + const result = await context.serverAction({ + listKey: 'Post', + action: 'relationshipOptions', + field: 'nonexistent', + }) + + expect(result).toEqual({ + success: false, + error: 'Field "nonexistent" on list "Post" is not a relationship field', + }) + }) + }) }) describe('db operations', () => { diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index e58bf2fb..146b543d 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -74,5 +74,9 @@ export type { // Utility functions export { cn, formatListName, formatFieldName, getFieldDisplayValue } from './lib/utils.js' +// Relationship-options read primitive (re-exported from @opensaas/stack-core) +export { getRelationshipOptions } from './lib/getRelationshipOptions.js' +export type { RelationshipOption, RelationshipOptionsArgs } from './lib/getRelationshipOptions.js' + // Theme utilities export { generateThemeCSS, getThemeStyleTag, presetThemes } from './lib/theme.js' diff --git a/packages/ui/src/lib/getRelationshipOptions.ts b/packages/ui/src/lib/getRelationshipOptions.ts new file mode 100644 index 00000000..2115b3fd --- /dev/null +++ b/packages/ui/src/lib/getRelationshipOptions.ts @@ -0,0 +1,12 @@ +/** + * Bounded, projected fetch of `{ id, label }` options for a relationship + * editor. Re-exported from `@opensaas/stack-core` — the single implementation + * also backs the `relationshipOptions` context.serverAction op, so a server + * component with a full context in hand (e.g. `prepareItemForm`) and a client + * calling through `serverAction` both resolve options identically. + */ +export { + getRelationshipOptions, + type RelationshipOption, + type RelationshipOptionsArgs, +} from '@opensaas/stack-core' diff --git a/packages/ui/tests/lib/getRelationshipOptions.test.ts b/packages/ui/tests/lib/getRelationshipOptions.test.ts new file mode 100644 index 00000000..148ae00b --- /dev/null +++ b/packages/ui/tests/lib/getRelationshipOptions.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from 'vitest' +import { getRelationshipOptions } from '../../src/lib/getRelationshipOptions.js' +import type { AccessContext, OpenSaasConfig } from '@opensaas/stack-core' + +interface DelegateStub { + findMany: (args?: unknown) => Promise>> + findFirst: (args?: unknown) => Promise | null> +} + +function makeContext(delegates: Record): AccessContext { + const context = { + db: delegates, + session: null, + storage: {}, + plugins: {}, + _isSudo: false, + _resolveOutputCounter: { depth: 0 }, + } + return context as unknown as AccessContext +} + +describe('getRelationshipOptions (stack-ui re-export)', () => { + it('resolves { id, label }[] for a relationship field against a full AccessContext', async () => { + const findMany = vi.fn(async () => [ + { id: 'u1', name: 'Ada Lovelace' }, + { id: 'u2', name: 'Alan Turing' }, + ]) + const context = makeContext({ user: { findMany, findFirst: vi.fn() } }) + + const config: OpenSaasConfig = { + db: { provider: 'sqlite', url: 'file:./test.db' }, + lists: { + User: { + fields: { name: { type: 'text' } }, + access: { operation: { query: () => true } }, + }, + }, + } + + const result = await getRelationshipOptions(context, config, 'User', {}) + + expect(result).toEqual([ + { id: 'u1', label: 'Ada Lovelace' }, + { id: 'u2', label: 'Alan Turing' }, + ]) + expect(findMany.mock.calls[0][0]).not.toHaveProperty('include') + }) + + it('returns [] for an unknown related list', async () => { + const context = makeContext({}) + const config: OpenSaasConfig = { + db: { provider: 'sqlite', url: 'file:./test.db' }, + lists: {}, + } + + expect(await getRelationshipOptions(context, config, 'Missing', {})).toEqual([]) + }) +})