Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/tiny-hounds-jump.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 30 additions & 0 deletions packages/core/src/context/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,6 +24,14 @@ export type ServerActionProps =
| { listKey: string; action: 'create'; data: Record<string, unknown> }
| { listKey: string; action: 'update'; id: string; data: Record<string, unknown> }
| { 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
Expand Down Expand Up @@ -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') {
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
157 changes: 157 additions & 0 deletions packages/core/src/query/relationship-options.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>) {
return {
findMany: vi.fn(async (_args?: unknown) => rows),
findFirst: vi.fn(async (_args?: unknown) => rows[0] ?? null),
}
}

function makeContext(
delegates: Record<string, ReturnType<typeof makeDelegate>>,
): 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<string, unknown>
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<string, unknown>
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<string, unknown>
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([])
})
})
70 changes: 70 additions & 0 deletions packages/core/src/query/relationship-options.ts
Original file line number Diff line number Diff line change
@@ -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<RelationshipOption[]> {
const relatedListConfig = config.lists[relatedListKey]
if (!relatedListConfig) return []

const labelField = getLabelFieldName(relatedListConfig)
const fragment = defineFragment<Record<string, unknown>>()({
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<string, unknown>),
}))
}
Loading
Loading