Skip to content

Version Packages#747

Merged
borisno2 merged 1 commit into
mainfrom
changeset-release/main
Jul 22, 2026
Merged

Version Packages#747
borisno2 merged 1 commit into
mainfrom
changeset-release/main

Conversation

@github-actions

@github-actions github-actions Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.

Releases

@opensaas/stack-core@0.31.0

Minor Changes

  • #750 047487a Thanks @borisno2! - Add a bulkDelete server action for list-level bulk deletion

    context.serverAction now accepts { listKey, action: 'bulkDelete', ids }. It
    deletes each id row-by-row through the secured context, honouring Silent failure
    (a denied or missing row returns null and is not counted; one row's error does
    not abort the rest), and returns { deleted, total }.

    The result is deliberately a count shape rather than the single-op { success }
    shape, so a UI serverAction wrapper that redirects on a single-item success
    (the item-form pattern) does not hijack a list-level bulk operation.

    const result = await context.serverAction({
      listKey: 'Post',
      action: 'bulkDelete',
      ids: ['a', 'b', 'c'],
    })
    // result: { deleted: 2, total: 3 }  // one row was denied/missing
  • #755 9cd06dd Thanks [@list({](https://github.com/list({)! - Admin chrome polish: opt-in nav counts and avatar label cells (#735)

    Two per-list opt-ins for the admin UI, both off by default.

    Nav counts — set ui.navCount: true on a list to show an access-scoped
    record count next to its nav item. The count is fetched through the secured
    context, so it only ever reflects what the current session may see; no count
    query runs for lists that don't opt in, and a list whose query access is
    statically denied renders no count rather than a misleading zero.

    lists: {
      Post: list({
        fields: {
          /* ... */
        },
        ui: { navCount: true },
      }),
    }

    Avatar label cells — set ui.avatar: true to render a list's label column
    with a deterministic initials bubble ahead of the emphasized Item label. The
    initials and colour derive from the row; the palette is Theme-token-derived (no
    raw hex). A per-field cell override (ui.cell) on the label field still wins.

    lists: {
    
        fields: {
          /* ... */
        },
        ui: { avatar: true },
      }),
    }

    New exports:

    • @opensaas/stack-core: resolveNavCounts, isListQueryStaticallyDenied
    • @opensaas/stack-ui: Avatar primitive, AvatarLabelCell, and the
      getInitials, getAvatarTone, AVATAR_TONES helpers. New Slots:
      avatar, cell-avatar-label, nav-count.
  • #759 b190813 Thanks @borisno2! - Add custom Bulk actions from list config (admin list view)

    A list can now declare list-specific Bulk actions under ui.listView.bulkActions. Each action's button renders in the list view's selection bar (in declaration order) alongside the built-in Delete. The action's server-side handler receives the selected ids and the secured context, so all its work runs through access control and hooks — a denied row is a Silent failure absorbed into the outcome, never leaked.

    Post: list({
      fields: { title: text(), status: select({ options: [/* ... */] }) },
      ui: {
        listView: {
          bulkActions: [
            {
              key: 'publish',
              label: 'Publish',
              // Optional: `variant`, `destructive` (confirm first),
              // `hasAccess` (server-side visibility gate).
              handler: async ({ ids, context }) => {
                let n = 0
                for (const id of ids) {
                  const updated = await context.db.post.update({
                    where: { id },
                    data: { status: 'published' },
                  })
                  if (updated) n++
                }
                return { message: `Published ${n} of ${ids.length}` }
              },
            },
          ],
        },
      },
    })

    Only serialisable metadata (key/label/variant/destructive) crosses to the client; the handler/hasAccess functions stay on the server. Clicking the button sends the key and selected ids back through the generic server action, which looks the handler up and runs it with a freshly-rebuilt secured context. Selection is enabled for a list that has custom actions even when Delete is denied. CSV export is documented as a recipe using this surface rather than shipping as a built-in.

  • #754 f67cd79 Thanks @borisno2! - Add the Filter builder input UI for the admin list view (#731)

    The admin list view now ships a FilterBuilder that constructs the ?search=
    filter query the filter engine already consumes (ADR-0017) — a free-text search
    box plus structured field / operator / value rows. Available fields, operators,
    and value suggestions are derived entirely from each field's self-contained
    getFilterSpec (via the serializable collectFilterSuggestions metadata), so
    there is no field-type switch and no functions cross the server/client
    boundary. Applied filters flow through the same secured context.db, so
    filtering can only ever narrow what a session may see.

    @opensaas/stack-core gains serializeFilterQuery(tokens) — the exact inverse
    of parseFilterQuery — so the builder produces the grammar the engine parses
    with the quoting and operator-prefix rules kept next to the parser.

    The FilterBuilder is composable (exported from @opensaas/stack-ui and
    @opensaas/stack-ui/standalone) with theme-token styling and data-slot parts
    for extension:

    import { FilterBuilder } from '@opensaas/stack-ui/standalone'
    import { collectFilterSuggestions } from '@opensaas/stack-core'
    
    // Server component: collect serializable suggestion metadata for the list.
    const suggestions = collectFilterSuggestions(listConfig, 'Post', config)
    
    // Client: build and apply a `?search=` query.
    <FilterBuilder
      suggestions={suggestions}
      defaultValue={search}
      onApply={(query) => router.push(`/admin/post?search=${encodeURIComponent(query)}`)}
    />

    The list view wires this in automatically; existing ?search= URLs keep
    working unchanged.

  • #746 dcb10e2 Thanks @borisno2! - Add the admin UI filter engine: a Filter spec field-builder contract and URL-driven server-side list filtering (ADR-0017).

    Fields now declare their filtering capability through a new optional getFilterSpec method — a peer of getPrismaType/getTypeScriptType on the field-builder contract. It reports the operators a field supports, a pure token→condition mapper, and serializable suggestion metadata. Core field types implement it (text contains + free text, integer/decimal/timestamp/calendarDay comparisons, select/checkbox equality against enumerated values, relationship by label lookup). A field without a spec — password, json, virtual, or any third-party field that hasn't adopted one — is simply not filterable, so the addition degrades gracefully everywhere.

    The admin list view now parses the URL filter query (the list's search param) through the engine and merges the result into the access-controlled query via the secured context, so filtering runs server-side and can only ever narrow — never widen — what a session may see. This replaces the previous hard-coded type === 'text' search; free-text behavior is now driven by each text field's Filter spec.

    Grammar (ADR-0017): implicit-AND tokens, quoted multi-word values, >/>=/</<= comparisons on numeric/date fields, and bare words as free text. Unknown syntax degrades to free text, never errors.

    Multi-word free-text UX shift (intentional, per ADR-0017): bare words now combine with AND, so hello world requires each word to match separately (not the literal substring hello world). To match a contiguous phrase, quote it: "hello world". A pasted URL such as http://x is treated as a single free-text token and searched verbatim — the http: prefix is not parsed as a field.

    New exports from @opensaas/stack-core:

    import {
      parseFilterQuery, // (query) => FilterToken[]  — pure
      buildFilterWhere, // (tokens, specs) => where   — pure
      collectFilterSpecs, // (listConfig, listKey, config) => specs
      buildListFilterWhere, // (query, listConfig, listKey, config) => where
      collectFilterSuggestions, // serializable autocomplete metadata
    } from '@opensaas/stack-core'
    
    // e.g. "status:Published views:>10 author:\"Ada Lovelace\" beta"
    const where = buildListFilterWhere(query, listConfig, listKey, config)
    const rows = await context.db.post.findMany({ where }) // ANDed with the access filter

    Third-party field authors can implement FilterSpec (exported from @opensaas/stack-core/extend) to make their field filterable.

  • #760 f8b6f02 Thanks @borisno2! - Add inline cell editing to admin Relationship tables

    Cells in a to-many Relationship table on the item view are now editable in place.
    Click a cell to edit it, commit with Enter or blur, cancel with Escape. Each
    commit is a single-field update on the related row through the secured
    context, so the related list's own operation- and field-level update access plus
    its hooks/validation apply — never the parent's. The update is optimistic and
    reverts, with a visible reason, on a Silent failure (access denied / row gone) or
    a validation error (inline field errors surface too). Committed values re-render
    through the Cell registry, so select cells stay coloured badges.

    A field the session cannot write — or a table whose related-list update access is
    statically denied — renders read-only with no edit affordance; row-level
    (filter-scoped) denials surface at commit as a revert. Non-editable cells keep
    click-to-navigate; main list tables are unchanged (this is Relationship-table
    only).

    • @opensaas/stack-core: the generic server action gains a distinct
      updateRelated result shape ({ updated, error?, fieldErrors? }), and
      checkFieldAccess is exposed on @opensaas/stack-core/internal so the UI can
      decide the edit affordance without a parallel field-access evaluator.
    • @opensaas/stack-ui: RelationshipTableClient accepts editableColumns; the
      editable cell reuses the field-component registry for its editor and the Cell
      registry for its display (new Slots: relationship-table-cell-display,
      relationship-table-cell-editor, relationship-table-cell-edit-trigger,
      relationship-table-cell-error).
  • #757 c05701e Thanks @borisno2! - Add a pre-linked create drawer to read-only Relationship tables (issue #738)

    The item view's read-only Relationship tables now offer a "+ Add" control that
    opens a drawer hosting the related list's create form, with the back-reference to
    the current record preset and hidden. On submit the new row is created through
    the secured context already linked to the parent, then the drawer closes and the
    table refreshes.

    Create-and-link semantics (ADR-0018): the create runs on the RELATED list, so
    the related list's own create access control, hooks, and field-level access
    apply — never the parent's. The back-reference is set on the server from the
    field/parent id (never trusted from the client payload). The "+ Add" is shown
    only when a back-reference exists to preset the link and the related list's
    create access is not statically denied; a filter/function-scoped denial
    surfaces at commit time as a generic error (no denied-vs-absent leak).

    New generic server action (@opensaas/stack-core):

    await context.serverAction({
      listKey: 'Post', // the RELATED list
      action: 'createRelated',
      data: { title: 'Hello', slug: 'hello' },
      field: 'author', // the back-reference field on Post
      parentId: user.id, // the record being edited
    })
    // → { created: true, id } | { created: false, error?, fieldErrors? }

    The drawer (RelationshipCreateDrawer from @opensaas/stack-ui) mounts on the
    existing relationship-table-toolbar seam and reuses the shared item-form engine
    and field-component registry, so the related list's full validation and required
    fields are enforced even when a required field is not one of the table's columns.

  • #756 8199238 Thanks [@list({](https://github.com/list({)! - Add relationship-table row removal to the admin item view (ADR-0018)

    Each read-only Relationship table row now has a ✕ removal control. By default it
    disconnects the related row from the current record (non-destructive — the
    row survives and still appears on its own list), gated on the related list's
    update access. A per-relationship opt-in truly deletes the related row (behind a
    confirmation, gated on the related list's delete access), or hides the control
    entirely. Where the schema makes disconnect impossible (a required foreign key on
    the related side) the control is hidden unless delete is opted in. Removals run
    through the secured context, so an access-denied removal is a Silent failure: the
    row stays with a visible reason.

    Configure per relationship via ui.itemView.removeAction:

      fields: {
        // Default: ✕ disconnects the post (it still exists).
        posts: relationship({ ref: 'Post.author', many: true }),
        // Opt in to destructive delete (confirmed).
        notes: relationship({
          ref: 'Note.owner',
          many: true,
          ui: { itemView: { removeAction: 'delete' } }, // 'disconnect' (default) | 'delete' | 'none'
        }),
      },
    })

    @opensaas/stack-core adds a removeRelated server action (distinct
    { removed } result shape, like bulkDelete, so a redirect-on-success wrapper
    never hijacks an in-place removal) and the RelationshipItemViewConfig.removeAction
    option.

  • #745 4d99e91 Thanks @borisno2! - Add a Cell registry with default cells for core field types

    List tables now render every value through a Cell resolved by a
    cell-component registry that mirrors the form-field registry's priority chain:
    per-field override → custom type registry → field-type registry → plain-text
    fallback. Each core field type ships a default Cell — text (plain), integer
    (tabular figures), select (coloured Badge), timestamp (formatted date), checkbox
    (mark), and to-one relationship (Item label link). Unknown/third-party types
    without a registered Cell fall back to plain text.

    Select options gain optional, additive per-option UI metadata mapping a value to
    a badge variant. Existing options keep working unchanged; unmapped options render
    the neutral badge.

    // opensaas.config.ts — colour a status value in list-table cells
    status: select({
      options: [
        { label: 'Draft', value: 'draft', ui: { variant: 'secondary' } },
        { label: 'Published', value: 'published', ui: { variant: 'success' } },
      ],
    })

    Register a Cell for a custom/third-party field exactly as you register its form
    component, or override a single field's Cell:

    'use client'
    import { registerCellComponent } from '@opensaas/stack-ui'
    registerCellComponent('myField', MyCell)
    
    // or per-field override (highest priority)
    price: integer({ ui: { cell: CurrencyCell } })
  • #751 20459b5 Thanks [@list({](https://github.com/list({)! - Derive the admin item view from the list shape, with read-only Relationship tables and a totals footer (#734)

    A record's edit page now derives its layout from the list's shape. Scalar and
    to-one fields stay in a details card (whole-form Save/Cancel, unchanged), and
    each to-many relationship renders as a read-only Relationship table: one
    to-many relationship gives a two-column split, none gives a single centered
    card, several stack. Table columns default to the related list's own column
    curation minus the back-reference to the parent, cells come from the cell
    registry, and a totals footer always shows the row count plus sums for any
    explicitly-configured numeric columns (each formatted by that column's Cell).
    Rows are fetched through the secured context, so only access-visible data shows.
    Rows are read-only here — a row click navigates to the related record.

    @opensaas/stack-core gains additive item-view config (no breaking changes):

    lists: {
    
        fields: {
          posts: relationship({
            ref: 'Post.author',
            many: true,
            ui: {
              itemView: {
                // Override the Relationship table's columns…
                columns: ['title', 'status', 'viewCount'],
                // …and sum numeric columns in the totals footer.
                sum: ['viewCount'],
                // Or demote it back to the compact picker in the details card:
                // displayMode: 'picker',
              },
            },
          }),
        },
        // Reorder the Relationship-table sections:
        ui: { itemView: { order: ['posts'] } },
      }),
    }

    New @opensaas/stack-ui exports: RelationshipTable, RelationshipTableClient,
    and the pure deriveItemViewLayout helper (with ItemViewLayout,
    ItemViewArrangement, RelationshipTableSection). The Relationship table ships
    named Slots (relationship-table, relationship-table-toolbar,
    relationship-table-row, relationship-table-cell, relationship-table-footer)
    as extension seams for the follow-up inline-edit, create-drawer, and row-removal
    work.

  • #774 62a1612 Thanks @borisno2! - Bound the admin item-view Relationship tables with a take and a "showing N of M" footer

    The read-only Relationship tables on a record's edit page (issue #734) previously
    fetched every related row unbounded. They now fetch a bounded page of related rows
    and surface the full access-scoped total in the footer.

    • Bounded fetch: each to-many Relationship table fetches at most a default cap
      of related rows (DEFAULT_ITEM_VIEW_TAKE, 10), overridable per relationship via
      ui.itemView.take. Rows are still fetched through the secured context, so only
      access-visible rows come back.
    • "Showing N of M" footer: the totals footer now reads Showing N of M rows,
      where N is the rendered (bounded) count and M is the full access-scoped total,
      fetched via a filtered _count that folds the related list's own query access
      in (mirroring the list view's count columns). A fully-denied related list reads
      Showing 0 of 0 and never leaks a true total. The row count is always shown,
      including the zero-column footer path.
    sessions: relationship({
      ref: 'Session.user',
      many: true,
      // Cap this table at 5 rows; the footer still shows the full access-scoped total.
      ui: { itemView: { take: 5 } },
    })

    Core: mergeIncludeWithAccessControl now preserves a caller-supplied take on a
    to-many relation include (it only narrows the fetch, never widening past the access
    where), so the secured findUnique/findMany include can bound related-row reads.

  • #764 c210319 Thanks [@list({](https://github.com/list({)! - Admin list view: to-many relationship columns render an access-visible count, sort by relation count, and filter by numeric count comparisons (issue #732). Virtual fields render via their Cell but are excluded from sorting and filtering.

    A to-many relationship used as a list column now shows the count of the related rows the session may see — fetched in the SAME query via a filtered Prisma _count, with the related list's query access folded into the count's where, so it never counts rows the session cannot read and issues no per-row query. Clicking the column header sorts by relation _count, and its Filter spec offers numeric comparisons on the count (posts:>5) in the filter builder and in shared URLs.

    Because Prisma cannot compare a relation count in a where, a to-many relationship's Filter spec emits a structured count marker that is resolved to an access-scoped { id: { in } } before the query runs, through the secured context.

    New @opensaas/stack-core exports: buildRelationshipCountSelect, resolveRelationshipCountFilters, isToManyRelationshipField, and RELATIONSHIP_COUNT_FILTER_KEY (with the RelationshipCountFilterMarker type).

    // A to-many relationship column now shows an access-scoped count and is
    // sortable / filterable by that count — zero config:
    
      fields: {
        name: text(),
        posts: relationship({ ref: 'Post.author', many: true }),
      },
    })
    // List view: the `posts` column renders the count; its header sorts by count;
    // `posts:>5` filters by count in the builder and in a shared URL.

Patch Changes

  • #773 5a60291 Thanks @borisno2! - Harden createRelated server action: reject malformed calls that supply only one of field/parentId, and validate that the back-reference names a relationship field before injecting the parent connect.

  • #775 2fcb582 Thanks @borisno2! - Return a generic "Action failed" message (and log the real error server-side) when a custom bulk-action handler throws an unexpected non-Prisma error, instead of surfacing its internal message to the client

  • #771 85c7fc3 Thanks @borisno2! - Harden relationship count-filter resolution: preserve any sibling conditions co-present in a _countFilter marker's AND-member instead of replacing it wholesale, and document why the secured count read intentionally keeps its full projection (context.db does not honour select).

  • #780 55d55e0 Thanks @borisno2! - Fix a to-one relationship filter token (e.g. author:Ada) leaking related-list data by ANDing the related list's query access filter into the nested condition instead of running it unscoped.

  • #794 96e1067 Thanks @borisno2! - Fix multi-column fields (e.g. storage image()/file() in Keystone-parity mode) writing an unrecognised value silently instead of failing validation. The column split now runs after validateFieldRules, not before, at the top-level and nested write paths.

@opensaas/stack-storage-vercel@0.31.0

Minor Changes

  • #777 7098878 Thanks @borisno2! - Honour public: false with real private-blob support. Uploads now map public: false to access: 'private' (previously ignored); download() reads both public and private blobs through @vercel/blob's authorized get() path instead of a plain fetch(url), and rejects with a descriptive "File not found" error for a missing blob instead of an unrelated failure; a new getSignedUrl(filename, expiresIn?) returns a time-limited signed URL for serving private files through developer-controlled routes.

    storage: {
      documents: vercelBlobStorage({
        token: process.env.BLOB_READ_WRITE_TOKEN,
        public: false,
      }),
    }
    
    const provider = createStorageProvider(config, 'documents')
    const signedUrl = await provider.getSignedUrl('report.pdf', 3600)

    This also bumps the @vercel/blob dependency from ^2.3.1 to ^2.6.1, which adds the issueSignedToken/presignUrl APIs getSignedUrl() relies on.

  • #786 7cd3eb4 Thanks @borisno2! - Add allowOverwrite config option to vercelBlobStorage. The Vercel Blob API rejects uploads to an existing pathname unless this is sent, which made stable-filename replace workflows (generateUniqueFilenames: false, e.g. a field with cleanupOnReplace) throw "blob already exists". allowOverwrite now defaults to true when generateUniqueFilenames is false, and false otherwise; an explicit setting always wins.

    vercelBlobStorage({
      token: process.env.BLOB_READ_WRITE_TOKEN,
      generateUniqueFilenames: false,
      allowOverwrite: false, // opt back into reject-on-overwrite with stable names
    })
  • #778 5f8fd73 Thanks @borisno2! - Add Vercel OIDC authentication support to the Vercel Blob storage provider

    The provider no longer requires a static read-write token. New storeId and oidcToken config options enable Vercel OIDC auth — the @vercel/blob SDK exchanges the deployment's VERCEL_OIDC_TOKEN for blob credentials automatically:

    storage: {
      uploads: vercelBlobStorage({
        storeId: process.env.BLOB_STORE_ID, // or omit and just set BLOB_STORE_ID
        pathPrefix: 'uploads',
      }),
    }

    Credential precedence (resolved by the SDK on every call): explicit token → OIDC token (oidcToken or VERCEL_OIDC_TOKEN) plus store id (storeId or BLOB_STORE_ID) → BLOB_READ_WRITE_TOKEN environment variable.

    The constructor no longer throws when no static token is configured — previously this blocked OIDC-authenticated deployments before the SDK's credential resolution could run. When no credentials are available at all, the SDK now throws a descriptive error on the first storage operation instead. Requires @vercel/blob 2.4.1+ (dependency floor bumped from 2.3.1).

Patch Changes

  • #779 4a1d9be Thanks @borisno2! - Fix getUrl() returning a fabricated host that never resolves. It now derives the store ID from the configured token/store ID (the same way the SDK does internally) and embeds it along with the access mode, matching the real URL the SDK returns at upload time. A token that doesn't yield a store ID now throws a descriptive error instead of producing a silently wrong URL.

  • #792 fe1ed5c Thanks @borisno2! - Fix unique filename generation to use path.extname semantics (matching the local provider) so extension-less originals no longer get the whole name appended, and pass through an explicit cacheControlMaxAge: 0 instead of dropping it.

  • #776 030d540 Thanks @borisno2! - Fix delete()/download() for @vercel/blob v2, which rejects head() with BlobNotFoundError for missing blobs instead of resolving null. delete() of a missing blob is now a no-op and no longer round-trips through head().

@opensaas/stack-ui@0.31.0

Minor Changes

  • #755 9cd06dd Thanks [@list({](https://github.com/list({)! - Admin chrome polish: opt-in nav counts and avatar label cells (#735)

    Two per-list opt-ins for the admin UI, both off by default.

    Nav counts — set ui.navCount: true on a list to show an access-scoped
    record count next to its nav item. The count is fetched through the secured
    context, so it only ever reflects what the current session may see; no count
    query runs for lists that don't opt in, and a list whose query access is
    statically denied renders no count rather than a misleading zero.

    lists: {
      Post: list({
        fields: {
          /* ... */
        },
        ui: { navCount: true },
      }),
    }

    Avatar label cells — set ui.avatar: true to render a list's label column
    with a deterministic initials bubble ahead of the emphasized Item label. The
    initials and colour derive from the row; the palette is Theme-token-derived (no
    raw hex). A per-field cell override (ui.cell) on the label field still wins.

    lists: {
    
        fields: {
          /* ... */
        },
        ui: { avatar: true },
      }),
    }

    New exports:

    • @opensaas/stack-core: resolveNavCounts, isListQueryStaticallyDenied
    • @opensaas/stack-ui: Avatar primitive, AvatarLabelCell, and the
      getInitials, getAvatarTone, AVATAR_TONES helpers. New Slots:
      avatar, cell-avatar-label, nav-count.
  • #759 b190813 Thanks @borisno2! - Add custom Bulk actions from list config (admin list view)

    A list can now declare list-specific Bulk actions under ui.listView.bulkActions. Each action's button renders in the list view's selection bar (in declaration order) alongside the built-in Delete. The action's server-side handler receives the selected ids and the secured context, so all its work runs through access control and hooks — a denied row is a Silent failure absorbed into the outcome, never leaked.

    Post: list({
      fields: { title: text(), status: select({ options: [/* ... */] }) },
      ui: {
        listView: {
          bulkActions: [
            {
              key: 'publish',
              label: 'Publish',
              // Optional: `variant`, `destructive` (confirm first),
              // `hasAccess` (server-side visibility gate).
              handler: async ({ ids, context }) => {
                let n = 0
                for (const id of ids) {
                  const updated = await context.db.post.update({
                    where: { id },
                    data: { status: 'published' },
                  })
                  if (updated) n++
                }
                return { message: `Published ${n} of ${ids.length}` }
              },
            },
          ],
        },
      },
    })

    Only serialisable metadata (key/label/variant/destructive) crosses to the client; the handler/hasAccess functions stay on the server. Clicking the button sends the key and selected ids back through the generic server action, which looks the handler up and runs it with a freshly-rebuilt secured context. Selection is enabled for a list that has custom actions even when Delete is denied. CSV export is documented as a recipe using this surface rather than shipping as a built-in.

  • #754 f67cd79 Thanks @borisno2! - Add the Filter builder input UI for the admin list view (#731)

    The admin list view now ships a FilterBuilder that constructs the ?search=
    filter query the filter engine already consumes (ADR-0017) — a free-text search
    box plus structured field / operator / value rows. Available fields, operators,
    and value suggestions are derived entirely from each field's self-contained
    getFilterSpec (via the serializable collectFilterSuggestions metadata), so
    there is no field-type switch and no functions cross the server/client
    boundary. Applied filters flow through the same secured context.db, so
    filtering can only ever narrow what a session may see.

    @opensaas/stack-core gains serializeFilterQuery(tokens) — the exact inverse
    of parseFilterQuery — so the builder produces the grammar the engine parses
    with the quoting and operator-prefix rules kept next to the parser.

    The FilterBuilder is composable (exported from @opensaas/stack-ui and
    @opensaas/stack-ui/standalone) with theme-token styling and data-slot parts
    for extension:

    import { FilterBuilder } from '@opensaas/stack-ui/standalone'
    import { collectFilterSuggestions } from '@opensaas/stack-core'
    
    // Server component: collect serializable suggestion metadata for the list.
    const suggestions = collectFilterSuggestions(listConfig, 'Post', config)
    
    // Client: build and apply a `?search=` query.
    <FilterBuilder
      suggestions={suggestions}
      defaultValue={search}
      onApply={(query) => router.push(`/admin/post?search=${encodeURIComponent(query)}`)}
    />

    The list view wires this in automatically; existing ?search= URLs keep
    working unchanged.

  • #746 dcb10e2 Thanks @borisno2! - Add the admin UI filter engine: a Filter spec field-builder contract and URL-driven server-side list filtering (ADR-0017).

    Fields now declare their filtering capability through a new optional getFilterSpec method — a peer of getPrismaType/getTypeScriptType on the field-builder contract. It reports the operators a field supports, a pure token→condition mapper, and serializable suggestion metadata. Core field types implement it (text contains + free text, integer/decimal/timestamp/calendarDay comparisons, select/checkbox equality against enumerated values, relationship by label lookup). A field without a spec — password, json, virtual, or any third-party field that hasn't adopted one — is simply not filterable, so the addition degrades gracefully everywhere.

    The admin list view now parses the URL filter query (the list's search param) through the engine and merges the result into the access-controlled query via the secured context, so filtering runs server-side and can only ever narrow — never widen — what a session may see. This replaces the previous hard-coded type === 'text' search; free-text behavior is now driven by each text field's Filter spec.

    Grammar (ADR-0017): implicit-AND tokens, quoted multi-word values, >/>=/</<= comparisons on numeric/date fields, and bare words as free text. Unknown syntax degrades to free text, never errors.

    Multi-word free-text UX shift (intentional, per ADR-0017): bare words now combine with AND, so hello world requires each word to match separately (not the literal substring hello world). To match a contiguous phrase, quote it: "hello world". A pasted URL such as http://x is treated as a single free-text token and searched verbatim — the http: prefix is not parsed as a field.

    New exports from @opensaas/stack-core:

    import {
      parseFilterQuery, // (query) => FilterToken[]  — pure
      buildFilterWhere, // (tokens, specs) => where   — pure
      collectFilterSpecs, // (listConfig, listKey, config) => specs
      buildListFilterWhere, // (query, listConfig, listKey, config) => where
      collectFilterSuggestions, // serializable autocomplete metadata
    } from '@opensaas/stack-core'
    
    // e.g. "status:Published views:>10 author:\"Ada Lovelace\" beta"
    const where = buildListFilterWhere(query, listConfig, listKey, config)
    const rows = await context.db.post.findMany({ where }) // ANDed with the access filter

    Third-party field authors can implement FilterSpec (exported from @opensaas/stack-core/extend) to make their field filterable.

  • #760 f8b6f02 Thanks @borisno2! - Add inline cell editing to admin Relationship tables

    Cells in a to-many Relationship table on the item view are now editable in place.
    Click a cell to edit it, commit with Enter or blur, cancel with Escape. Each
    commit is a single-field update on the related row through the secured
    context, so the related list's own operation- and field-level update access plus
    its hooks/validation apply — never the parent's. The update is optimistic and
    reverts, with a visible reason, on a Silent failure (access denied / row gone) or
    a validation error (inline field errors surface too). Committed values re-render
    through the Cell registry, so select cells stay coloured badges.

    A field the session cannot write — or a table whose related-list update access is
    statically denied — renders read-only with no edit affordance; row-level
    (filter-scoped) denials surface at commit as a revert. Non-editable cells keep
    click-to-navigate; main list tables are unchanged (this is Relationship-table
    only).

    • @opensaas/stack-core: the generic server action gains a distinct
      updateRelated result shape ({ updated, error?, fieldErrors? }), and
      checkFieldAccess is exposed on @opensaas/stack-core/internal so the UI can
      decide the edit affordance without a parallel field-access evaluator.
    • @opensaas/stack-ui: RelationshipTableClient accepts editableColumns; the
      editable cell reuses the field-component registry for its editor and the Cell
      registry for its display (new Slots: relationship-table-cell-display,
      relationship-table-cell-editor, relationship-table-cell-edit-trigger,
      relationship-table-cell-error).
  • #757 c05701e Thanks @borisno2! - Add a pre-linked create drawer to read-only Relationship tables (issue #738)

    The item view's read-only Relationship tables now offer a "+ Add" control that
    opens a drawer hosting the related list's create form, with the back-reference to
    the current record preset and hidden. On submit the new row is created through
    the secured context already linked to the parent, then the drawer closes and the
    table refreshes.

    Create-and-link semantics (ADR-0018): the create runs on the RELATED list, so
    the related list's own create access control, hooks, and field-level access
    apply — never the parent's. The back-reference is set on the server from the
    field/parent id (never trusted from the client payload). The "+ Add" is shown
    only when a back-reference exists to preset the link and the related list's
    create access is not statically denied; a filter/function-scoped denial
    surfaces at commit time as a generic error (no denied-vs-absent leak).

    New generic server action (@opensaas/stack-core):

    await context.serverAction({
      listKey: 'Post', // the RELATED list
      action: 'createRelated',
      data: { title: 'Hello', slug: 'hello' },
      field: 'author', // the back-reference field on Post
      parentId: user.id, // the record being edited
    })
    // → { created: true, id } | { created: false, error?, fieldErrors? }

    The drawer (RelationshipCreateDrawer from @opensaas/stack-ui) mounts on the
    existing relationship-table-toolbar seam and reuses the shared item-form engine
    and field-component registry, so the related list's full validation and required
    fields are enforced even when a required field is not one of the table's columns.

  • #756 8199238 Thanks [@list({](https://github.com/list({)! - Add relationship-table row removal to the admin item view (ADR-0018)

    Each read-only Relationship table row now has a ✕ removal control. By default it
    disconnects the related row from the current record (non-destructive — the
    row survives and still appears on its own list), gated on the related list's
    update access. A per-relationship opt-in truly deletes the related row (behind a
    confirmation, gated on the related list's delete access), or hides the control
    entirely. Where the schema makes disconnect impossible (a required foreign key on
    the related side) the control is hidden unless delete is opted in. Removals run
    through the secured context, so an access-denied removal is a Silent failure: the
    row stays with a visible reason.

    Configure per relationship via ui.itemView.removeAction:

      fields: {
        // Default: ✕ disconnects the post (it still exists).
        posts: relationship({ ref: 'Post.author', many: true }),
        // Opt in to destructive delete (confirmed).
        notes: relationship({
          ref: 'Note.owner',
          many: true,
          ui: { itemView: { removeAction: 'delete' } }, // 'disconnect' (default) | 'delete' | 'none'
        }),
      },
    })

    @opensaas/stack-core adds a removeRelated server action (distinct
    { removed } result shape, like bulkDelete, so a redirect-on-success wrapper
    never hijacks an in-place removal) and the RelationshipItemViewConfig.removeAction
    option.

  • #745 4d99e91 Thanks @borisno2! - Add a Cell registry with default cells for core field types

    List tables now render every value through a Cell resolved by a
    cell-component registry that mirrors the form-field registry's priority chain:
    per-field override → custom type registry → field-type registry → plain-text
    fallback. Each core field type ships a default Cell — text (plain), integer
    (tabular figures), select (coloured Badge), timestamp (formatted date), checkbox
    (mark), and to-one relationship (Item label link). Unknown/third-party types
    without a registered Cell fall back to plain text.

    Select options gain optional, additive per-option UI metadata mapping a value to
    a badge variant. Existing options keep working unchanged; unmapped options render
    the neutral badge.

    // opensaas.config.ts — colour a status value in list-table cells
    status: select({
      options: [
        { label: 'Draft', value: 'draft', ui: { variant: 'secondary' } },
        { label: 'Published', value: 'published', ui: { variant: 'success' } },
      ],
    })

    Register a Cell for a custom/third-party field exactly as you register its form
    component, or override a single field's Cell:

    'use client'
    import { registerCellComponent } from '@opensaas/stack-ui'
    registerCellComponent('myField', MyCell)
    
    // or per-field override (highest priority)
    price: integer({ ui: { cell: CurrencyCell } })
  • #750 047487a Thanks @borisno2! - Add row selection and a built-in Bulk action Delete to the admin list view

    The list table now renders a selection checkbox column when the list's delete
    access is not statically false. The header checkbox toggles the visible page,
    per-row checkboxes accumulate an explicit id set across pages, and the selection
    clears when the filter changes. A selection bar shows the count, a Clear action,
    a named data-slot="selection-actions" seam for future custom bulk actions, and
    — only when delete access allows — a Delete that confirms first, deletes each
    selected row through the secured context honouring Silent failure, and reports
    "N of M deleted" (partial access denials are visible without revealing which or
    why).

    The admin list view also honours an optional ?pageSize= URL param, preserved
    across sorting, searching and paging.

    New exports: RowSelectionBar (with RowSelectionBarProps /
    RowSelectionBarClassNames) and the useRowSelection hook plus the pure
    isPageFullySelected / getPageCheckboxState helpers.

    import { RowSelectionBar, useRowSelection } from '@opensaas/stack-ui'
    
    const selection = useRowSelection('Post', filterKey)
    <RowSelectionBar
      count={selection.selectedCount}
      onClear={selection.clear}
      onDelete={async () => {
        /* delete the selected ids through the secured context */
      }}
    />
  • #751 20459b5 Thanks [@list({](https://github.com/list({)! - Derive the admin item view from the list shape, with read-only Relationship tables and a totals footer (#734)

    A record's edit page now derives its layout from the list's shape. Scalar and
    to-one fields stay in a details card (whole-form Save/Cancel, unchanged), and
    each to-many relationship renders as a read-only Relationship table: one
    to-many relationship gives a two-column split, none gives a single centered
    card, several stack. Table columns default to the related list's own column
    curation minus the back-reference to the parent, cells come from the cell
    registry, and a totals footer always shows the row count plus sums for any
    explicitly-configured numeric columns (each formatted by that column's Cell).
    Rows are fetched through the secured context, so only access-visible data shows.
    Rows are read-only here — a row click navigates to the related record.

    @opensaas/stack-core gains additive item-view config (no breaking changes):

    lists: {
    
        fields: {
          posts: relationship({
            ref: 'Post.author',
            many: true,
            ui: {
              itemView: {
                // Override the Relationship table's columns…
                columns: ['title', 'status', 'viewCount'],
                // …and sum numeric columns in the totals footer.
                sum: ['viewCount'],
                // Or demote it back to the compact picker in the details card:
                // displayMode: 'picker',
              },
            },
          }),
        },
        // Reorder the Relationship-table sections:
        ui: { itemView: { order: ['posts'] } },
      }),
    }

    New @opensaas/stack-ui exports: RelationshipTable, RelationshipTableClient,
    and the pure deriveItemViewLayout helper (with ItemViewLayout,
    ItemViewArrangement, RelationshipTableSection). The Relationship table ships
    named Slots (relationship-table, relationship-table-toolbar,
    relationship-table-row, relationship-table-cell, relationship-table-footer)
    as extension seams for the follow-up inline-edit, create-drawer, and row-removal
    work.

  • #774 62a1612 Thanks @borisno2! - Bound the admin item-view Relationship tables with a take and a "showing N of M" footer

    The read-only Relationship tables on a record's edit page (issue #734) previously
    fetched every related row unbounded. They now fetch a bounded page of related rows
    and surface the full access-scoped total in the footer.

    • Bounded fetch: each to-many Relationship table fetches at most a default cap
      of related rows (DEFAULT_ITEM_VIEW_TAKE, 10), overridable per relationship via
      ui.itemView.take. Rows are still fetched through the secured context, so only
      access-visible rows come back.
    • "Showing N of M" footer: the totals footer now reads Showing N of M rows,
      where N is the rendered (bounded) count and M is the full access-scoped total,
      fetched via a filtered _count that folds the related list's own query access
      in (mirroring the list view's count columns). A fully-denied related list reads
      Showing 0 of 0 and never leaks a true total. The row count is always shown,
      including the zero-column footer path.
    sessions: relationship({
      ref: 'Session.user',
      many: true,
      // Cap this table at 5 rows; the footer still shows the full access-scoped total.
      ui: { itemView: { take: 5 } },
    })

    Core: mergeIncludeWithAccessControl now preserves a caller-supplied take on a
    to-many relation include (it only narrows the fetch, never widening past the access
    where), so the secured findUnique/findMany include can bound related-row reads.

  • #764 c210319 Thanks [@list({](https://github.com/list({)! - Admin list view: to-many relationship columns render an access-visible count, sort by relation count, and filter by numeric count comparisons (issue #732). Virtual fields render via their Cell but are excluded from sorting and filtering.

    A to-many relationship used as a list column now shows the count of the related rows the session may see — fetched in the SAME query via a filtered Prisma _count, with the related list's query access folded into the count's where, so it never counts rows the session cannot read and issues no per-row query. Clicking the column header sorts by relation _count, and its Filter spec offers numeric comparisons on the count (posts:>5) in the filter builder and in shared URLs.

    Because Prisma cannot compare a relation count in a where, a to-many relationship's Filter spec emits a structured count marker that is resolved to an access-scoped { id: { in } } before the query runs, through the secured context.

    New @opensaas/stack-core exports: buildRelationshipCountSelect, resolveRelationshipCountFilters, isToManyRelationshipField, and RELATIONSHIP_COUNT_FILTER_KEY (with the RelationshipCountFilterMarker type).

    // A to-many relationship column now shows an access-scoped count and is
    // sortable / filterable by that count — zero config:
    
      fields: {
        name: text(),
        posts: relationship({ ref: 'Post.author', many: true }),
      },
    })
    // List view: the `posts` column renders the count; its header sorts by count;
    // `posts:>5` filters by count in the builder and in a shared URL.
  • #788 613902c Thanks @borisno2! - Standalone ListTable now routes every cell through the shared cell registry (CellRenderer), matching ListView. The bespoke relationship renderer and fieldTypes[column] === 'relationship' branch are gone in favour of RelationshipCell, which already handles link navigation and stopPropagation.

    A new optional fieldOptions prop lets select columns resolve label mapping and ui.variant badge colour, exactly like ListView:

    <ListTable
      items={posts}
      fieldTypes={{ title: 'text', status: 'select' }}
      fieldOptions={{
        status: [
          { label: 'Published', value: 'published', ui: { variant: 'success' } },
          { label: 'Draft', value: 'draft' },
        ],
      }}
      columns={['title', 'status']}
    />

    Existing ListTable call sites keep working unchanged.

Patch Changes

  • #780 55d55e0 Thanks @borisno2! - Fix a to-one relationship filter token (e.g. author:Ada) leaking related-list data by ANDing the related list's query access filter into the nested condition instead of running it unscoped.

  • #775 2fcb582 Thanks @borisno2! - Exclude json columns from relationship-table inline editing so an unchanged json cell no longer wastes a secured update round-trip

@opensaas/stack-auth@0.31.0

Patch Changes

  • #772 be5772b Thanks @borisno2! - Add a regression test locking the generated Session/Account user FK shape (no @@index([userId]), onDelete: Cascade) so future drift from better-auth parity is caught.

@opensaas/stack-cli@0.31.0

Patch Changes

@opensaas/stack-storage@0.31.0

Patch Changes

  • #795 0e603b9 Thanks @borisno2! - Add integration-style tests proving image()/file() reject unrecognised write value shapes end-to-end in both db.columns: 'keystone' (multi-column) and default single-column (JSON) modes, pinning the #789 fix as a regression guard.

  • #776 030d540 Thanks @borisno2! - StorageProvider.delete() is now documented as idempotent; the local provider swallows ENOENT on delete instead of throwing.

@opensaas/stack-tiptap@0.31.0

Patch Changes

  • #781 db7079a Thanks @borisno2! - Bump @tiptap/pm and @tiptap/extension-placeholder to ^3.28.0 to match @tiptap/starter-kit, fixing a TypeScript build failure caused by duplicate @tiptap/pm versions in the dependency tree.

@opensaas/stack-rag@0.31.0

@opensaas/stack-storage-s3@0.31.0

@vercel

vercel Bot commented Jul 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
stack-docs Ready Ready Preview, Comment Jul 21, 2026 12:15pm

@github-actions
github-actions Bot force-pushed the changeset-release/main branch from 7e38f31 to 5a69445 Compare July 19, 2026 13:38
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from 5a69445 to 6daa75b Compare July 20, 2026 01:20
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from 6daa75b to 4c71f8c Compare July 20, 2026 02:05
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from 4c71f8c to 68d74d6 Compare July 20, 2026 02:36
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from 68d74d6 to 80a618d Compare July 20, 2026 03:25
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from 80a618d to 4203ded Compare July 20, 2026 07:20
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from 4203ded to 918c9d0 Compare July 20, 2026 07:50
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from 918c9d0 to 8e29794 Compare July 20, 2026 09:56
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from 8e29794 to 537b9b5 Compare July 20, 2026 10:10
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from 537b9b5 to 9c5d1a0 Compare July 20, 2026 11:00
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from 9c5d1a0 to 08789ce Compare July 20, 2026 11:28
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from 08789ce to eaf136d Compare July 20, 2026 11:33
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from eaf136d to 0210b51 Compare July 20, 2026 13:09
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from 0210b51 to 0216f16 Compare July 20, 2026 14:19
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from 12701fb to b89ab13 Compare July 21, 2026 10:15
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from b89ab13 to 3a5fa39 Compare July 21, 2026 10:35
@github-actions
github-actions Bot force-pushed the changeset-release/main branch 2 times, most recently from cc2d6ec to ecea9c8 Compare July 21, 2026 10:50
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from ecea9c8 to 47a7d6f Compare July 21, 2026 11:09
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from 47a7d6f to 46cd728 Compare July 21, 2026 11:14
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from 46cd728 to eae1853 Compare July 21, 2026 11:41
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from eae1853 to 3bb3cc4 Compare July 21, 2026 11:51
@github-actions
github-actions Bot force-pushed the changeset-release/main branch from 3bb3cc4 to a12512b Compare July 21, 2026 12:03
@github-actions

Copy link
Copy Markdown
Contributor Author

Coverage Report for Core Package Coverage (./packages/core)

Status Category Percentage Covered / Total
🟢 Lines 92.63% (🎯 65%) 1145 / 1236
🟢 Statements 91.11% (🎯 65%) 1221 / 1340
🟢 Functions 97.93% (🎯 62%) 190 / 194
🟢 Branches 81.91% (🎯 50%) 820 / 1001
File CoverageNo changed files found.
Generated in workflow #1500 for commit e9bd5a1 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor Author

Coverage Report for UI Package Coverage (./packages/ui)

Status Category Percentage Covered / Total
🔵 Lines 76.72% 244 / 318
🔵 Statements 76.29% 251 / 329
🔵 Functions 69.15% 74 / 107
🔵 Branches 64.25% 160 / 249
File CoverageNo changed files found.
Generated in workflow #1500 for commit e9bd5a1 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor Author

Coverage Report for CLI Package Coverage (./packages/cli)

Status Category Percentage Covered / Total
🔵 Lines 79.4% 1496 / 1884
🔵 Statements 79.1% 1556 / 1967
🔵 Functions 86.19% 206 / 239
🔵 Branches 67.31% 655 / 973
File CoverageNo changed files found.
Generated in workflow #1500 for commit e9bd5a1 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor Author

Coverage Report for Auth Package Coverage (./packages/auth)

Status Category Percentage Covered / Total
🔵 Lines 97.45% 115 / 118
🔵 Statements 97.52% 118 / 121
🔵 Functions 100% 38 / 38
🔵 Branches 92.85% 78 / 84
File CoverageNo changed files found.
Generated in workflow #1500 for commit e9bd5a1 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor Author

Coverage Report for Storage Package Coverage (./packages/storage)

Status Category Percentage Covered / Total
🔵 Lines 76.65% 197 / 257
🔵 Statements 78.13% 218 / 279
🔵 Functions 85.89% 67 / 78
🔵 Branches 72.98% 181 / 248
File CoverageNo changed files found.
Generated in workflow #1500 for commit e9bd5a1 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor Author

Coverage Report for RAG Package Coverage (./packages/rag)

Status Category Percentage Covered / Total
🔵 Lines 47.97% 355 / 740
🔵 Statements 48.14% 377 / 783
🔵 Functions 54.26% 70 / 129
🔵 Branches 42.55% 180 / 423
File CoverageNo changed files found.
Generated in workflow #1500 for commit e9bd5a1 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor Author

Coverage Report for Storage S3 Package Coverage (./packages/storage-s3)

Status Category Percentage Covered / Total
🔵 Lines 100% 40 / 40
🔵 Statements 100% 40 / 40
🔵 Functions 100% 9 / 9
🔵 Branches 100% 19 / 19
File CoverageNo changed files found.
Generated in workflow #1500 for commit e9bd5a1 by the Vitest Coverage Report Action

@github-actions

Copy link
Copy Markdown
Contributor Author

Coverage Report for Storage Vercel Package Coverage (./packages/storage-vercel)

Status Category Percentage Covered / Total
🔵 Lines 100% 68 / 68
🔵 Statements 100% 71 / 71
🔵 Functions 100% 15 / 15
🔵 Branches 97.87% 46 / 47
File CoverageNo changed files found.
Generated in workflow #1500 for commit e9bd5a1 by the Vitest Coverage Report Action

@borisno2
borisno2 merged commit edc477c into main Jul 22, 2026
6 checks passed
@borisno2
borisno2 deleted the changeset-release/main branch July 22, 2026 06:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant