@byline/host-tanstack-start 4.5.0 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/admin-shell/chrome/th-sortable.d.ts +8 -1
  2. package/dist/admin-shell/chrome/th-sortable.js +2 -1
  3. package/dist/admin-shell/collections/create.d.ts +3 -1
  4. package/dist/admin-shell/collections/create.js +7 -3
  5. package/dist/admin-shell/collections/edit.d.ts +3 -1
  6. package/dist/admin-shell/collections/edit.js +21 -13
  7. package/dist/admin-shell/collections/list.js +49 -20
  8. package/dist/routes/create-collection-create-route.js +8 -1
  9. package/dist/routes/create-collection-edit-route.js +5 -2
  10. package/dist/routes/create-collection-list-route.js +2 -14
  11. package/dist/routes/list-return-state.d.ts +45 -0
  12. package/dist/routes/list-return-state.js +43 -0
  13. package/dist/server-fns/collections/index.d.ts +1 -0
  14. package/dist/server-fns/collections/index.js +1 -0
  15. package/dist/server-fns/collections/list-view-state.d.ts +45 -0
  16. package/dist/server-fns/collections/list-view-state.js +59 -0
  17. package/dist/server-fns/collections/list.js +37 -14
  18. package/dist/server-fns/collections/set-list-view-preference.d.ts +18 -0
  19. package/dist/server-fns/collections/set-list-view-preference.js +32 -0
  20. package/package.json +8 -8
  21. package/src/admin-shell/chrome/th-sortable.tsx +9 -0
  22. package/src/admin-shell/collections/create.tsx +12 -5
  23. package/src/admin-shell/collections/edit.tsx +12 -4
  24. package/src/admin-shell/collections/list.tsx +49 -7
  25. package/src/routes/create-collection-create-route.tsx +9 -0
  26. package/src/routes/create-collection-edit-route.tsx +4 -0
  27. package/src/routes/create-collection-list-route.tsx +2 -12
  28. package/src/routes/list-return-state.test.node.ts +55 -0
  29. package/src/routes/list-return-state.ts +77 -0
  30. package/src/server-fns/collections/index.ts +1 -0
  31. package/src/server-fns/collections/list-view-state.test.node.ts +141 -0
  32. package/src/server-fns/collections/list-view-state.ts +137 -0
  33. package/src/server-fns/collections/list.ts +51 -26
  34. package/src/server-fns/collections/set-list-view-preference.ts +67 -0
@@ -0,0 +1,77 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * Return-to-list state for the collection editor (issue #17).
11
+ *
12
+ * The list route holds its complete view state in URL search params, so
13
+ * "come back to page 7 of the filtered list" needs no storage: the list
14
+ * encodes its current search into a single `from` param on the editor
15
+ * link, the editor threads it through its own navigations, and
16
+ * close/delete decode it back into list search params. Malformed or
17
+ * absent state degrades to the bare list — never an error.
18
+ *
19
+ * The list route's search schema lives here (rather than in the route
20
+ * factory) so the helpers and the factory share one definition without
21
+ * a circular import.
22
+ */
23
+
24
+ import { z } from 'zod'
25
+
26
+ export const collectionListSearchSchema = z.object({
27
+ page: z.coerce.number().min(1).optional(),
28
+ page_size: z.coerce.number().max(100).optional(),
29
+ order: z.string().optional(),
30
+ desc: z.coerce.boolean().optional(),
31
+ query: z.string().optional(),
32
+ locale: z.string().optional(),
33
+ status: z.string().optional(),
34
+ action: z.enum(['created']).optional(),
35
+ })
36
+
37
+ export type CollectionListSearch = z.infer<typeof collectionListSearchSchema>
38
+
39
+ /**
40
+ * The keys a return target carries. `action` is transient (post-create
41
+ * toast trigger) and must never round-trip through a return target.
42
+ */
43
+ const RETURN_KEYS = ['page', 'page_size', 'order', 'desc', 'query', 'locale', 'status'] as const
44
+
45
+ /** `undefined` when there is nothing worth carrying (bare list). */
46
+ export function encodeListReturnState(search: Record<string, unknown>): string | undefined {
47
+ const params = new URLSearchParams()
48
+ for (const key of RETURN_KEYS) {
49
+ const value = search[key]
50
+ if (value != null && value !== '') params.set(key, String(value))
51
+ }
52
+ const encoded = params.toString()
53
+ return encoded.length > 0 ? encoded : undefined
54
+ }
55
+
56
+ /**
57
+ * Parse a `from` param back into list search params. Runs the decoded
58
+ * pairs through the list search schema; anything malformed returns
59
+ * `undefined` (→ bare list). `desc` needs explicit normalisation
60
+ * because `z.coerce.boolean()` would coerce the string `'false'` to
61
+ * `true`.
62
+ */
63
+ export function decodeListReturnState(
64
+ from: string | undefined
65
+ ): Record<string, unknown> | undefined {
66
+ if (from == null || from.length === 0) return undefined
67
+ const raw: Record<string, unknown> = Object.fromEntries(new URLSearchParams(from))
68
+ delete raw.action
69
+ if (typeof raw.desc === 'string') raw.desc = raw.desc === 'true'
70
+ const parsed = collectionListSearchSchema.safeParse(raw)
71
+ if (!parsed.success) return undefined
72
+ const result: Record<string, unknown> = {}
73
+ for (const [key, value] of Object.entries(parsed.data)) {
74
+ if (value !== undefined) result[key] = value
75
+ }
76
+ return Object.keys(result).length > 0 ? result : undefined
77
+ }
@@ -18,6 +18,7 @@ export * from './list'
18
18
  export * from './reindex'
19
19
  export * from './reorder'
20
20
  export * from './restore-version'
21
+ export * from './set-list-view-preference'
21
22
  export * from './stats'
22
23
  export * from './status'
23
24
  export * from './tree'
@@ -0,0 +1,141 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import { describe, expect, it } from 'vitest'
10
+
11
+ import { resolveListViewState, sortableFieldNames } from './list-view-state.js'
12
+
13
+ const base = {
14
+ params: {},
15
+ preference: null,
16
+ orderable: false,
17
+ sortableFields: ['title', 'summary', 'created_at', 'updated_at'],
18
+ configuredSort: undefined,
19
+ }
20
+
21
+ describe('resolveListViewState', () => {
22
+ it('defaults to page size 15 and no sort when nothing is set', () => {
23
+ expect(resolveListViewState({ ...base })).toEqual({ pageSize: 15 })
24
+ })
25
+
26
+ it('lets explicit URL params win over preference and configured sort', () => {
27
+ const result = resolveListViewState({
28
+ ...base,
29
+ params: { page_size: 15, order: 'title', desc: false },
30
+ preference: { page_size: 100, order: 'summary', desc: true },
31
+ configuredSort: { order: 'created_at', desc: true },
32
+ })
33
+ expect(result).toEqual({
34
+ pageSize: 15,
35
+ sort: { title: 'asc' },
36
+ metaOrder: 'title',
37
+ metaDesc: false,
38
+ })
39
+ })
40
+
41
+ it('preserves the pre-existing "omitted desc means descending" param semantics', () => {
42
+ const result = resolveListViewState({ ...base, params: { order: 'title' } })
43
+ expect(result.sort).toEqual({ title: 'desc' })
44
+ // meta passthrough stays exactly what the URL carried (undefined here).
45
+ expect(result.metaOrder).toBe('title')
46
+ expect(result.metaDesc).toBeUndefined()
47
+ })
48
+
49
+ it('applies the preference on a params-less landing', () => {
50
+ const result = resolveListViewState({
51
+ ...base,
52
+ preference: { page_size: 50, order: 'title', desc: true },
53
+ })
54
+ expect(result).toEqual({
55
+ pageSize: 50,
56
+ sort: { title: 'desc' },
57
+ metaOrder: 'title',
58
+ metaDesc: true,
59
+ })
60
+ })
61
+
62
+ it('skips a stale preference order and falls through to configuredSort', () => {
63
+ const result = resolveListViewState({
64
+ ...base,
65
+ preference: { order: 'removed_field', desc: true },
66
+ configuredSort: { order: 'created_at', desc: true },
67
+ })
68
+ expect(result.sort).toEqual({ created_at: 'desc' })
69
+ expect(result.metaOrder).toBe('created_at')
70
+ })
71
+
72
+ it('ignores an out-of-range or non-integer preference page_size', () => {
73
+ expect(resolveListViewState({ ...base, preference: { page_size: 0 } }).pageSize).toBe(15)
74
+ expect(resolveListViewState({ ...base, preference: { page_size: 999 } }).pageSize).toBe(15)
75
+ expect(resolveListViewState({ ...base, preference: { page_size: 12.5 } }).pageSize).toBe(15)
76
+ })
77
+
78
+ it('applies page_size but never sort preferences on orderable collections', () => {
79
+ const result = resolveListViewState({
80
+ ...base,
81
+ orderable: true,
82
+ preference: { page_size: 50, order: 'title', desc: true },
83
+ })
84
+ expect(result).toEqual({ pageSize: 50, sort: { order_key: 'asc' } })
85
+ })
86
+
87
+ it('still lets explicit params override the drag order on orderable collections', () => {
88
+ const result = resolveListViewState({
89
+ ...base,
90
+ orderable: true,
91
+ params: { order: 'title', desc: true },
92
+ })
93
+ expect(result.sort).toEqual({ title: 'desc' })
94
+ })
95
+
96
+ it('combines a page_size-only preference with configuredSort', () => {
97
+ const result = resolveListViewState({
98
+ ...base,
99
+ preference: { page_size: 30 },
100
+ configuredSort: { order: 'created_at', desc: true },
101
+ })
102
+ expect(result).toEqual({
103
+ pageSize: 30,
104
+ sort: { created_at: 'desc' },
105
+ metaOrder: 'created_at',
106
+ metaDesc: true,
107
+ })
108
+ })
109
+ })
110
+
111
+ describe('sortableFieldNames', () => {
112
+ it('keeps scalar-store fields plus system columns; drops structure, json, file, and relation fields', () => {
113
+ const fields = [
114
+ { name: 'title', type: 'text' },
115
+ { name: 'viewCount', type: 'integer' },
116
+ { name: 'publishedOn', type: 'datetime' },
117
+ { name: 'featured', type: 'checkbox' },
118
+ { name: 'content', type: 'blocks' },
119
+ { name: 'gallery', type: 'array' },
120
+ { name: 'body', type: 'richText' },
121
+ { name: 'featureImage', type: 'image' },
122
+ { name: 'category', type: 'relation' },
123
+ { name: 'meta', type: 'group' },
124
+ ]
125
+ expect(sortableFieldNames(fields)).toEqual([
126
+ 'title',
127
+ 'viewCount',
128
+ 'publishedOn',
129
+ 'featured',
130
+ 'created_at',
131
+ 'updated_at',
132
+ ])
133
+ })
134
+
135
+ it('returns only the system columns for a collection with no sortable fields', () => {
136
+ expect(sortableFieldNames([{ name: 'content', type: 'blocks' }])).toEqual([
137
+ 'created_at',
138
+ 'updated_at',
139
+ ])
140
+ })
141
+ })
@@ -0,0 +1,137 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * Pure precedence resolver for the collection list view's page size and
11
+ * sort — extracted from the list server fn so the chain is testable
12
+ * without a database:
13
+ *
14
+ * URL params → user preference → configured defaultSort → storage
15
+ * default (created_at desc, expressed as "no sort spec")
16
+ *
17
+ * `orderable: true` collections sort by the drag order (`order_key asc`)
18
+ * and never take sort preferences or a configured default — but explicit
19
+ * URL params still win (a shared sorted link opens as sent), and a
20
+ * page-size preference still applies.
21
+ *
22
+ * A preference `order` naming a field that is no longer sortable on the
23
+ * collection is skipped (not an error): the schema may have moved on
24
+ * since the preference was written.
25
+ */
26
+
27
+ import { fieldTypeToStore } from '@byline/core'
28
+
29
+ /**
30
+ * Store types whose value column supports ORDER BY. Structure fields
31
+ * (group/array/blocks) and json/file/relation fields are excluded — a
32
+ * preference naming one (hand-edited, or stale after a schema change)
33
+ * must be skipped rather than sent to the sort compiler.
34
+ */
35
+ const SORTABLE_STORE_TYPES: ReadonlySet<string> = new Set([
36
+ 'text',
37
+ 'numeric',
38
+ 'boolean',
39
+ 'datetime',
40
+ ])
41
+
42
+ /**
43
+ * The field names a stored sort preference may legitimately name: fields
44
+ * whose store type carries an orderable scalar column, plus the document
45
+ * system columns.
46
+ */
47
+ export function sortableFieldNames(fields: Array<{ name: string; type: string }>): string[] {
48
+ return [
49
+ ...fields
50
+ .filter((f) => SORTABLE_STORE_TYPES.has(fieldTypeToStore[f.type]?.storeType ?? ''))
51
+ .map((f) => f.name),
52
+ 'created_at',
53
+ 'updated_at',
54
+ ]
55
+ }
56
+
57
+ export interface ListViewPreferenceValue {
58
+ page_size?: number
59
+ order?: string
60
+ desc?: boolean
61
+ }
62
+
63
+ export interface ResolveListViewStateInput {
64
+ params: { page_size?: number; order?: string; desc?: boolean }
65
+ preference: ListViewPreferenceValue | null
66
+ orderable: boolean
67
+ /** Field names valid as a sort column (collection fields + system columns). */
68
+ sortableFields: string[]
69
+ configuredSort?: { order: string; desc: boolean }
70
+ }
71
+
72
+ export interface ResolvedListViewState {
73
+ pageSize: number
74
+ /** Sort spec for `CollectionHandle.find`; undefined → storage default. */
75
+ sort?: Record<string, 'asc' | 'desc'>
76
+ /** Effective sort echoed through `meta.order` / `meta.desc` for the header indicator. */
77
+ metaOrder?: string
78
+ metaDesc?: boolean
79
+ }
80
+
81
+ export function resolveListViewState(input: ResolveListViewStateInput): ResolvedListViewState {
82
+ const { params, preference, orderable, sortableFields, configuredSort } = input
83
+
84
+ // Defensive clamp at read time — the write path validates 1–100, but a
85
+ // hand-edited row must degrade to the default, not a absurd page.
86
+ const rawPrefPageSize = preference?.page_size
87
+ const prefPageSize =
88
+ typeof rawPrefPageSize === 'number' &&
89
+ Number.isInteger(rawPrefPageSize) &&
90
+ rawPrefPageSize >= 1 &&
91
+ rawPrefPageSize <= 100
92
+ ? rawPrefPageSize
93
+ : undefined
94
+ const pageSize = params.page_size ?? prefPageSize ?? 15
95
+
96
+ // Explicit URL params always win. Semantics preserved from the original
97
+ // inline code: an omitted `desc` alongside an explicit `order` sorts
98
+ // descending, and the meta echo passes the raw param values through.
99
+ if (params.order) {
100
+ return {
101
+ pageSize,
102
+ sort: { [params.order]: params.desc === false ? 'asc' : 'desc' },
103
+ metaOrder: params.order,
104
+ metaDesc: params.desc,
105
+ }
106
+ }
107
+
108
+ if (orderable) {
109
+ return { pageSize, sort: { order_key: 'asc' } }
110
+ }
111
+
112
+ const rawPrefOrder = preference?.order
113
+ const prefOrder =
114
+ typeof rawPrefOrder === 'string' && sortableFields.includes(rawPrefOrder)
115
+ ? rawPrefOrder
116
+ : undefined
117
+ if (prefOrder != null) {
118
+ const desc = preference?.desc === true
119
+ return {
120
+ pageSize,
121
+ sort: { [prefOrder]: desc ? 'desc' : 'asc' },
122
+ metaOrder: prefOrder,
123
+ metaDesc: desc,
124
+ }
125
+ }
126
+
127
+ if (configuredSort != null) {
128
+ return {
129
+ pageSize,
130
+ sort: { [configuredSort.order]: configuredSort.desc ? 'desc' : 'asc' },
131
+ metaOrder: configuredSort.order,
132
+ metaDesc: configuredSort.desc,
133
+ }
134
+ }
135
+
136
+ return { pageSize }
137
+ }
@@ -8,7 +8,8 @@
8
8
 
9
9
  import { createServerFn } from '@tanstack/react-start'
10
10
 
11
- import { getAdminBylineClient } from '@byline/client/server'
11
+ import { getPreferenceCommand } from '@byline/admin/admin-preferences'
12
+ import { getAdminBylineClient, getAdminRequestContext } from '@byline/client/server'
12
13
  import {
13
14
  buildRelationSummaryPopulateMap,
14
15
  ERR_NOT_FOUND,
@@ -22,7 +23,10 @@ import {
22
23
  } from '@byline/core'
23
24
 
24
25
  import { ensureCollection } from '../../integrations/api-utils.js'
26
+ import { bylineCore } from '../../integrations/byline-core.js'
27
+ import { resolveListViewState, sortableFieldNames } from './list-view-state.js'
25
28
  import { serialise } from './utils'
29
+ import type { ListViewPreferenceValue } from './list-view-state.js'
26
30
 
27
31
  // ---------------------------------------------------------------------------
28
32
  // Shared param types
@@ -57,7 +61,6 @@ export const getCollectionDocuments = createServerFn({ method: 'GET' })
57
61
 
58
62
  const client = getAdminBylineClient()
59
63
  const handle = client.collection(path)
60
- const pageSize = params.page_size ?? 20
61
64
 
62
65
  // Routes through CollectionHandle.find so the read pipeline (beforeRead
63
66
  // → findDocuments → afterRead) is identical to any non-admin client.
@@ -69,30 +72,51 @@ export const getCollectionDocuments = createServerFn({ method: 'GET' })
69
72
  if (params.status) where.status = params.status
70
73
  if (params.query) where.query = params.query
71
74
 
72
- // Sort precedence: the caller's explicit `params.order` always wins (a
73
- // shared link opens exactly as sent) → `orderable: true` collections
74
- // default to the fractional `order_key` ascending → the admin config's
75
- // `defaultSort` (boot-validated; mutually exclusive with `orderable`) →
76
- // the storage fallback (`created_at desc`). `configuredSort` is also
77
- // echoed through `meta.order`/`meta.desc` below so the list header can
78
- // render the effective sort indicator on a params-less landing.
75
+ // Sort/page-size precedence (see list-view-state.ts): the caller's
76
+ // explicit params always win (a shared link opens exactly as sent) →
77
+ // the actor's stored per-collection preference → the admin config's
78
+ // `defaultSort` → the storage fallback (`created_at desc`). The
79
+ // effective sort is echoed through `meta.order`/`meta.desc` below so
80
+ // the list header renders the right indicator on a params-less landing.
79
81
  const adminConfig = getCollectionAdminConfig(path)
80
82
  const configuredSort =
81
- !params.order && config.definition.orderable !== true && adminConfig?.defaultSort != null
83
+ config.definition.orderable !== true && adminConfig?.defaultSort != null
82
84
  ? {
83
85
  order: String(adminConfig.defaultSort.field),
84
86
  desc: adminConfig.defaultSort.direction === 'desc',
85
87
  }
86
88
  : undefined
87
- const defaultSort: Record<string, 'asc' | 'desc'> | undefined =
88
- config.definition.orderable === true
89
- ? { order_key: 'asc' }
90
- : configuredSort != null
91
- ? { [configuredSort.order]: configuredSort.desc ? 'desc' : 'asc' }
92
- : undefined
93
- const sortSpec: Record<string, 'asc' | 'desc'> | undefined = params.order
94
- ? { [params.order]: params.desc === false ? 'asc' : 'desc' }
95
- : defaultSort
89
+
90
+ // Per-user preference — read only when it could matter (some param
91
+ // absent). Failures (headless context, unauthenticated preview, DB
92
+ // hiccup) log and fall through: preferences can never break the list.
93
+ let preference: ListViewPreferenceValue | null = null
94
+ const adminStore = bylineCore().adminStore
95
+ if (adminStore != null && (params.page_size == null || params.order == null)) {
96
+ try {
97
+ const context = await getAdminRequestContext()
98
+ const res = await getPreferenceCommand(
99
+ context,
100
+ { scope: `collections.${path}.list` },
101
+ { store: adminStore }
102
+ )
103
+ preference = (res.value as ListViewPreferenceValue | null) ?? null
104
+ } catch (err) {
105
+ getLogger().warn(
106
+ { err, collection: path },
107
+ 'list-view preference read failed — using defaults'
108
+ )
109
+ }
110
+ }
111
+
112
+ const viewState = resolveListViewState({
113
+ params: { page_size: params.page_size, order: params.order, desc: params.desc },
114
+ preference,
115
+ orderable: config.definition.orderable === true,
116
+ sortableFields: sortableFieldNames(config.definition.fields),
117
+ configuredSort,
118
+ })
119
+ const pageSize = viewState.pageSize
96
120
 
97
121
  // Auto-populate relation columns (depth 1) so the list renders each
98
122
  // target's title (via `relationColumnFormatter`) rather than a raw
@@ -109,7 +133,7 @@ export const getCollectionDocuments = createServerFn({ method: 'GET' })
109
133
 
110
134
  const result = await handle.find({
111
135
  where: Object.keys(where).length > 0 ? where : undefined,
112
- sort: sortSpec,
136
+ sort: viewState.sort,
113
137
  locale: params.locale ?? 'en',
114
138
  page: params.page,
115
139
  pageSize,
@@ -147,12 +171,13 @@ export const getCollectionDocuments = createServerFn({ method: 'GET' })
147
171
  docs,
148
172
  meta: {
149
173
  ...result.meta,
150
- // The *effective* sort: explicit params, or the configured
151
- // `defaultSort` when it filled in. The list header renders its
152
- // sort indicator from this, so a params-less landing still shows
153
- // which column ordered the rows.
154
- order: params.order ?? configuredSort?.order,
155
- desc: params.desc ?? configuredSort?.desc,
174
+ // The *effective* sort/page-size: explicit params, or the
175
+ // resolved preference/configured `defaultSort` when they filled
176
+ // in. The list header renders its sort indicator from this, so a
177
+ // params-less landing still shows which column ordered the rows.
178
+ order: viewState.metaOrder,
179
+ desc: viewState.metaDesc,
180
+ pageSize,
156
181
  },
157
182
  included: {
158
183
  collection: {
@@ -0,0 +1,67 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * Persist a per-user list-view preference for one collection.
11
+ *
12
+ * Fire-and-forget from the list UI: changing page size or clicking a
13
+ * column sort quietly upserts the sticky keys for
14
+ * `collections.<path>.list`. The client sends only the keys the
15
+ * interaction changed; the repository merges per key, so a page-size
16
+ * change never wipes a stored sort.
17
+ *
18
+ * Self-service: the target user is always the authenticated actor.
19
+ * `setPreferenceCommand` rejects unauthenticated contexts.
20
+ */
21
+
22
+ import { createServerFn } from '@tanstack/react-start'
23
+
24
+ import { setPreferenceCommand } from '@byline/admin/admin-preferences'
25
+ import { getAdminRequestContext } from '@byline/client/server'
26
+ import { ERR_NOT_FOUND, getLogger } from '@byline/core'
27
+
28
+ import { ensureCollection } from '../../integrations/api-utils.js'
29
+ import { bylineCore } from '../../integrations/byline-core.js'
30
+
31
+ export interface SetListViewPreferenceInput {
32
+ collection: string
33
+ value: {
34
+ page_size?: number
35
+ order?: string
36
+ desc?: boolean
37
+ }
38
+ }
39
+
40
+ export const setListViewPreference = createServerFn({ method: 'POST' })
41
+ .validator((input: SetListViewPreferenceInput) => input)
42
+ .handler(async ({ data }) => {
43
+ // Scopes are keyed by collection path — refuse to mint rows for
44
+ // collections that don't exist, so a scripted caller cannot persist
45
+ // junk scopes (even self-scoped ones).
46
+ const config = await ensureCollection(data.collection)
47
+ if (!config) {
48
+ throw ERR_NOT_FOUND({
49
+ message: 'Collection not found',
50
+ details: { collectionPath: data.collection },
51
+ }).log(getLogger())
52
+ }
53
+
54
+ const adminStore = bylineCore().adminStore
55
+ if (adminStore == null) {
56
+ // Headless hosts without an admin store have no preference storage —
57
+ // the save is a silent no-op, mirroring set-locale's posture.
58
+ return { ok: true as const }
59
+ }
60
+ const context = await getAdminRequestContext()
61
+ await setPreferenceCommand(
62
+ context,
63
+ { scope: `collections.${data.collection}.list`, value: data.value },
64
+ { store: adminStore }
65
+ )
66
+ return { ok: true as const }
67
+ })