From c4b9ac3a4f2e0c915a0e5581de6dc0e27573515b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 11:17:06 +0000 Subject: [PATCH 1/4] fix(data-objectstack): the console's preference adapters share one sys_user_preference read per load createObjectStackUserStateAdapter's load() now joins an open read for its (data source, resource, user). Every load of that scope issued before the next microtask adds its key; the read is then sent once and each adapter takes its own row. One key keeps the per-key query; several go as one `key in (...)` query with `$top` equal to the key count. Nothing is kept past the send, and save() still finds and writes its own row. Claude-Session: https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK Co-authored-by: Claude --- packages/data-objectstack/src/userState.ts | 136 ++++++++++++++++++--- 1 file changed, 116 insertions(+), 20 deletions(-) diff --git a/packages/data-objectstack/src/userState.ts b/packages/data-objectstack/src/userState.ts index fbdffaaf18..cc27d4df58 100644 --- a/packages/data-objectstack/src/userState.ts +++ b/packages/data-objectstack/src/userState.ts @@ -43,6 +43,22 @@ * As soon as the backend supports `sys_user_preference`, persistence * "lights up" with no code change. * + * ## One read per load (objectui#12187) + * + * The console builds one adapter per slot (favorites, recent items, flow + * palette recents) over the same data source and user, and their providers + * call `load()` in the same effects pass. Those loads share ONE request: the + * first `load()` opens a read for its (data source, resource, user), every + * `load()` of that scope issued before the next microtask adds its key to it, + * and the read is then sent once and each adapter takes its own row from the + * answer. A read that collected one key sends the per-key query this adapter + * always sent; several keys go as one `key in (...)` query. + * + * Nothing is kept past that: the read closes the moment it is sent, so a later + * `load()` (a new adapter after a language or account switch) asks the server + * again, and a different user or data source never joins another's read. + * `save()` does not use it: a write still finds and writes its own row. + * * @module */ @@ -102,6 +118,71 @@ interface UserPreferenceRecord { const DEFAULT_RESOURCE = 'sys_user_preference'; +/** + * The query for the rows of `keys`. A lone key keeps the per-key query this + * adapter has always sent. Several keys go as one membership query; the + * object's unique index on `(user_id, key)` (per organization) bounds the + * answer to one row per key, so a `$top` of the key count drops none of them. + */ +function preferenceQuery(userId: string, keys: readonly string[]): QueryParams { + if (keys.length === 1) { + return { $filter: { user_id: userId, key: keys[0] }, $top: 1 }; + } + return { $filter: { user_id: userId, key: { $in: [...keys] } }, $top: keys.length }; +} + +/** One request for the rows of several adapters' keys. */ +interface SharedRead { + /** The keys asked for. Grows until the request is sent, then fixed. */ + readonly keys: Set; + /** The rows the one request answers with. */ + readonly rows: Promise; +} + +/** + * The reads still collecting keys: per data source, then per (resource, user). + * An entry lives only until its request is sent, one microtask after it opens. + */ +const openReads = new WeakMap>(); + +/** + * Join the open read for this (data source, resource, user), or open one. + * See "One read per load" in the module header. + */ +function joinSharedRead( + dataSource: DataSource, + resource: string, + userId: string, + key: string, +): SharedRead { + let scopes = openReads.get(dataSource); + if (!scopes) { + scopes = new Map(); + openReads.set(dataSource, scopes); + } + const scope = JSON.stringify([resource, userId]); + const open = scopes.get(scope); + if (open) { + open.keys.add(key); + return open; + } + + const keys = new Set([key]); + const opened = scopes; + const read: SharedRead = { + keys, + rows: Promise.resolve().then(async () => { + // Closed before the request goes out: a load after this point opens the + // next read instead of waiting on, or being served by, this one. + if (opened.get(scope) === read) opened.delete(scope); + const result = await dataSource.find(resource, preferenceQuery(userId, [...keys])); + return (result?.data ?? []) as UserPreferenceRecord[]; + }), + }; + scopes.set(scope, read); + return read; +} + /** * Build a `UserDataAdapter` backed by ObjectStack. * @@ -131,25 +212,11 @@ export function createObjectStackUserStateAdapter( // sees the cachedRowId the first one set and updates instead of inserting. let saveChain: Promise = Promise.resolve(); - const findExisting = async (): Promise => { - // NOTE: `QueryParams` uses OData-style `$`-prefixed keys. Using the bare - // names (`filter`, `limit`) silently drops the predicate at the - // `convertQueryParams` layer, which then returns *any* row in the - // `sys_user_preference` table — that caused the Favorites adapter to load - // the Recents row (and vice-versa), so clicked items leaked into Starred. - const params: QueryParams = { - $filter: { - user_id: userId, - key, - }, - $top: 1, - }; - const result = await dataSource.find(resource, params); - const rows = (result?.data ?? []) as UserPreferenceRecord[]; - // Defensive: if the backend ignored our predicate and returned mixed - // rows, only accept ones tagged for this (user, key) pair. Rows that - // omit those metadata fields entirely are trusted (covers mocks and - // backends that strip metadata from the projection). + // Defensive: if the backend ignored our predicate and returned mixed + // rows, only accept ones tagged for this (user, key) pair. Rows that + // omit those metadata fields entirely are trusted (covers mocks and + // backends that strip metadata from the projection). + const matchRow = (rows: UserPreferenceRecord[]): UserPreferenceRecord | null => { const match = rows.find(r => { if (!r) return false; const userMismatch = r.user_id !== undefined && r.user_id !== userId; @@ -159,6 +226,35 @@ export function createObjectStackUserStateAdapter( return match ?? null; }; + // The per-key read, used by writes. Loads go through the shared read below. + const findExisting = async (): Promise => { + // NOTE: `QueryParams` uses OData-style `$`-prefixed keys. Using the bare + // names (`filter`, `limit`) silently drops the predicate at the + // `convertQueryParams` layer, which then returns *any* row in the + // `sys_user_preference` table — that caused the Favorites adapter to load + // the Recents row (and vice-versa), so clicked items leaked into Starred. + const result = await dataSource.find(resource, preferenceQuery(userId, [key])); + return matchRow((result?.data ?? []) as UserPreferenceRecord[]); + }; + + // A load's read: this key's row out of the one request every adapter of + // this (data source, resource, user) shares for the load (objectui#12187). + const loadExisting = async (): Promise => { + const read = joinSharedRead(dataSource, resource, userId, key); + const rows = await read.rows; + // The read asked for this key alone: the per-key answer, read as above. + if (read.keys.size === 1) return matchRow(rows); + // An answer for several keys: a row is this key's only when it names it. + const own = rows.find( + r => r && r.key === key && (r.user_id === undefined || r.user_id === userId), + ); + if (own) return own; + // A row that names no key cannot be told apart in an answer for several + // keys; this key's own read, as before the share, answers instead. + if (rows.some(r => r && r.key === undefined)) return findExisting(); + return null; + }; + const decodeValue = (value: unknown): T[] => { // The server may return the JSON column already parsed, or as a string. if (Array.isArray(value)) return value as T[]; @@ -229,7 +325,7 @@ export function createObjectStackUserStateAdapter( return { async load(): Promise { try { - const row = await findExisting(); + const row = await loadExisting(); if (!row) { cachedRowId = null; return []; From 6fffff3368cdeac97d9a76301f02838dfa0c662a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 11:21:33 +0000 Subject: [PATCH 2/4] test(app-shell,data-objectstack): pin one sys_user_preference read per console boot consoleUserPreferenceBudget now boots the real ConsoleShell and ConnectedShell over a fake framework that answers preference reads by user_id and key (= or in): one read per boot asking for the three keys, every consumer gets its own row, each adapter writes its own row, and a user switch reads again for the new user. The 11797 wire pin's first case moves to the shared read; userState.sharedRead-12187 pins the share's edges (user and data-source scope, nothing kept past the send, untagged rows, a failed read, writes unchanged). Claude-Session: https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK Co-authored-by: Claude --- .../consoleUserPreferenceBudget.test.tsx | 480 ++++++++++-------- .../src/userState.inflightShare-11797.test.ts | 37 +- .../src/userState.sharedRead-12187.test.ts | 201 ++++++++ 3 files changed, 493 insertions(+), 225 deletions(-) create mode 100644 packages/data-objectstack/src/userState.sharedRead-12187.test.ts diff --git a/packages/app-shell/src/console/__tests__/consoleUserPreferenceBudget.test.tsx b/packages/app-shell/src/console/__tests__/consoleUserPreferenceBudget.test.tsx index 93efa70394..3a3dcdd71b 100644 --- a/packages/app-shell/src/console/__tests__/consoleUserPreferenceBudget.test.tsx +++ b/packages/app-shell/src/console/__tests__/consoleUserPreferenceBudget.test.tsx @@ -1,158 +1,176 @@ /** - * objectui#5544 — `sys_user_preference` request budget across one console mount. + * objectui#12187 — one `sys_user_preference` read per console load. * - * ## What the card reported, and what this file actually pins + * ## The budget, and why it changed * - * The card measured a cold console load and grouped the resource timings by - * `new URL(r.name).pathname`, reporting `/api/v1/data/sys_user_preference` ×3 - * on prod and ×6 on staging as duplicate requests. Those numbers are the - * card's measurement against a deployed build — NOT taken here, and not - * reproducible from this repo. + * `ConnectedShell`'s `UserStateBridge` attaches three user-state adapters, one + * per slot (`ui.favorites`, `ui.recent`, `ui.flow.palette.recents`), and each + * slot is its own `(user, key)` row. objectui#5544 and objectui#11797 counted + * that as three reads per load, one per key, and this file used to pin three. + * objectui#12187 rules the budget at ONE: the adapters share one read of the + * user's rows for the load, and each takes its own key from it. * - * Grouping by pathname is the trap. The console's per-user KV reads all share - * one pathname and differ only in the `key` predicate, which rides in the - * query string: `ConsoleShell`'s `UserStateBridge` attaches THREE adapters, - * one per slot — `ui.favorites`, `ui.recent`, `ui.flow.palette.recents` — and - * each is a distinct row in `sys_user_preference`. Three reads on a cold load - * is therefore the correct budget, not a defect; a pathname-level count cannot - * tell those three apart from the same read issued three times. + * The hazard objectui#5544 named still stands: a dedup that serves one key's + * answer to all three consumers makes the count look right and starves two of + * them. So the count is pinned together with the payloads: every consumer + * must still receive its own row. * - * ## Both directions, because a count assertion alone is not enough + * ## The tree * - * The card proposes "dedup at the request layer (in-flight requests sharing a - * Promise per key)". That dedup already exists — `ObjectStackAdapter.find()` - * coalesces on `resource + serialized params` — and the third case below pins - * it. The hazard is a future "fix" that widens the coalescing key to the - * ENDPOINT: it would collapse these three distinct reads into one and starve - * two of the three consumers, while making a pathname-level count look - * perfect. So this file pins the budget AND the payloads together: + * The real `ConsoleShell` providers and the real `ConnectedShell`: its + * `AdapterProvider` builds the real `ObjectStackAdapter` and client, and its + * `UserStateBridge` builds the three adapters. The network is a fake framework + * on the global `fetch` that answers preference reads the way the server does, + * by `user_id` and by `key` (`=` or `in`), and refuses any other filter + * operator loudly. Only the session is stood in. * - * 1. exactly one read per distinct preference key across one mount, and - * 2. every consumer still receives its own row. + * ## The pins * - * Assertion 2 is what fails on a naive endpoint-level dedup; assertion 1 is - * what fails if a caller starts fetching twice. + * 1. One boot puts ONE `sys_user_preference` GET on the wire, and it asks + * for the three keys. + * 2. Every consumer receives its own row from that one answer. + * 3. Each adapter still writes its own row, and only its own. + * 4. Signing in as another user reads again, for that user, and every + * consumer ends on that user's rows. + * 5. Two loads of one key at once are still one request. */ -import { describe, it, expect, vi } from 'vitest'; -import { useEffect } from 'react'; -import { render, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { useEffect, type ReactNode } from 'react'; +import { render, waitFor, act } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +const { session } = vi.hoisted(() => ({ + /** The signed-in user the mocked `useAuth` reports; switched per test. */ + session: { userId: 'u1' }, +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + info: vi.fn(), success: vi.fn(), warning: vi.fn(), error: vi.fn(), + dismiss: vi.fn(), custom: vi.fn(), loading: vi.fn(), promise: vi.fn(), + }), + Toaster: () => null, +})); +// The ADR-0069 remediation gate needs an AuthProvider above it; not under test. +vi.mock('../RemediationOverlay', () => ({ RemediationOverlay: () => null })); +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ + isLoading: false, + isAuthenticated: true, + user: { id: session.userId, email: `${session.userId}@example.com` }, + session: null, + isAuthEnabled: true, + error: null, + organizations: [], + activeOrganization: null, + isOrganizationsLoading: false, + getAuthConfig: async () => ({}), + }), + useWorkspaceAdminStatus: () => ({ isAdmin: false, isResolved: true }), +})); + import { ObjectStackAdapter, createObjectStackUserStateAdapter } from '@object-ui/data-objectstack'; -import { - UserStateAdaptersProvider, - useAttachUserStateAdapters, -} from '../../context/UserStateAdapters'; -import { FavoritesProvider, useFavorites } from '../../context/FavoritesProvider'; -import { RecentItemsProvider, useRecentItems } from '../../context/RecentItemsProvider'; -import { - FlowPaletteRecentsProvider, - useFlowPaletteRecents, -} from '../../context/FlowPaletteRecentsProvider'; +import { ConsoleShell, ConnectedShell } from '../ConsoleShell'; +import { useFavorites } from '../../context/FavoritesProvider'; +import { useRecentItems } from '../../context/RecentItemsProvider'; +import { useFlowPaletteRecents } from '../../context/FlowPaletteRecentsProvider'; -/** - * The three preference keys `ConsoleShell`'s `UserStateBridge` attaches on a - * console boot. Kept in this order so a diff against that component reads - * straight across. - */ +const RESOURCE = 'sys_user_preference'; const FAVORITES_KEY = 'ui.favorites'; const RECENT_KEY = 'ui.recent'; const FLOW_KEY = 'ui.flow.palette.recents'; +const BRIDGE_KEYS = [FAVORITES_KEY, RECENT_KEY, FLOW_KEY]; -/** The row each key resolves to — distinct payloads, so a starved consumer shows up. */ -const ROWS: Record = { - [FAVORITES_KEY]: [{ id: 'object:contact', label: 'Contact', href: '/contact', type: 'object' }], - [RECENT_KEY]: [{ id: 'object:lead', label: 'Lead', href: '/lead', type: 'object' }], - [FLOW_KEY]: ['flow_step_alpha'], +/** Each user's stored rows, one distinct payload per key, so a starved consumer shows up. */ +const STORE: Record> = { + u1: { + [FAVORITES_KEY]: [{ id: 'object:contact', label: 'Contact', href: '/contact', type: 'object' }], + [RECENT_KEY]: [{ id: 'object:lead', type: 'object', name: 'lead', href: '/lead', visitedAt: '2026-10-01T00:00:00.000Z' }], + [FLOW_KEY]: ['flow_step_alpha'], + }, + u2: { + [FAVORITES_KEY]: [{ id: 'object:invoice', label: 'Invoice', href: '/invoice', type: 'object' }], + [RECENT_KEY]: [{ id: 'object:quote', type: 'object', name: 'quote', href: '/quote', visitedAt: '2026-10-02T00:00:00.000Z' }], + [FLOW_KEY]: ['flow_step_omega'], + }, }; -/** Pull the `key` predicate out of the adapter's serialized query options. */ -function keyOf(opts: unknown): string { - const filters = (opts as { filters?: unknown })?.filters; - if (!Array.isArray(filters)) return ''; - for (const part of filters) { - if (Array.isArray(part) && part[0] === 'key') return String(part[2]); +const rowId = (user: string, key: string) => `row-${user}-${key}`; + +interface PrefRow { id: string; user_id: string; key: string; value: unknown } +type FilterNode = [string, ...unknown[]]; + +/** Answer one filter node the way the server's AST does, for the operators this path sends. */ +function matches(node: FilterNode, row: PrefRow): boolean { + const [head, ...rest] = node; + if (head === 'and') return (rest as FilterNode[]).every((n) => matches(n, row)); + if (head === 'or') return (rest as FilterNode[]).some((n) => matches(n, row)); + const [op, value] = rest; + const actual = (row as unknown as Record)[head]; + if (op === '=') return actual === value; + if (op === 'in') return Array.isArray(value) && value.includes(actual); + throw new Error(`fake framework: filter operator ${String(op)} is not one this path sends`); +} + +interface PrefGet { user: string | undefined; keys: string[]; top: string | null } +interface PrefWrite { method: string; id: string | undefined; value: unknown } + +/** Every preference request the console made, read off the wire. */ +const wire = { gets: [] as PrefGet[], writes: [] as PrefWrite[] }; + +/** Collect the `user_id` and the keys a filter asks for (`key = k` or `key in [...]`). */ +function scopeOf(node: FilterNode, into: PrefGet): PrefGet { + const [head, ...rest] = node; + if (head === 'and' || head === 'or') { + (rest as FilterNode[]).forEach((n) => scopeOf(n, into)); + return into; } - return ''; + const [op, value] = rest; + if (head === 'user_id' && op === '=') into.user = String(value); + if (head === 'key' && op === '=') into.keys.push(String(value)); + if (head === 'key' && op === 'in') into.keys.push(...(value as string[])); + return into; } -/** - * A real `ObjectStackAdapter` over a counting transport stub. Every - * `client.data.find` is recorded, so the assertions below count what would - * have left the browser rather than what a component believes it asked for. - */ -function makeCountingAdapter(calls: { resource: string; key: string }[]) { - const ds = new ObjectStackAdapter({ - baseUrl: 'http://test.local', - fetch: vi.fn( - async () => - new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }), - ), - }) as unknown as { - connected: boolean; - connectionState: string; - client: unknown; - find: (resource: string, params?: unknown) => Promise; - }; - ds.connected = true; - ds.connectionState = 'connected'; - ds.client = { - data: { - find: async (resource: string, opts: unknown) => { - const key = keyOf(opts); - calls.push({ resource, key }); - // A real round-trip is not instantaneous; leaving a gap here is what - // lets an in-flight coalescer be observed at all. - await new Promise(resolve => setTimeout(resolve, 5)); - const value = ROWS[key]; - // The client SDK answers with `records`, which is what - // `normalizeQueryResult` reads — `data` here would normalize to empty. - if (!value) return { records: [], total: 0 }; - return { records: [{ id: `row-${key}`, user_id: 'u1', key, value }], total: 1 }; - }, - }, - }; - return ds; +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); } -/** - * Mirrors `ConsoleShell`'s `UserStateBridge` effect body: one adapter per slot, - * created once per (user, dataSource) and attached to the providers. The real - * bridge is module-private to `ConsoleShell`, and exporting it purely for a - * test would widen that module's public surface, so the effect is replicated - * here with the same three keys. - */ -function BridgeReplica({ ds, userId }: { ds: unknown; userId: string }) { - const attach = useAttachUserStateAdapters(); - useEffect(() => { - if (!userId || !ds) return; - const favorites = createObjectStackUserStateAdapter({ - dataSource: ds as never, - userId, - key: FAVORITES_KEY, - }); - const recent = createObjectStackUserStateAdapter({ - dataSource: ds as never, - userId, - key: RECENT_KEY, - }); - const flowPaletteRecents = createObjectStackUserStateAdapter({ - dataSource: ds as never, - userId, - key: FLOW_KEY, - }); - attach('favorites', favorites); - attach('recent', recent); - attach('flowPaletteRecents', flowPaletteRecents); - return () => { - attach('favorites', null); - attach('recent', null); - attach('flowPaletteRecents', null); - }; - }, [userId, ds, attach]); - return null; +/** The fake framework: discovery, empty metadata, and the preference rows of {@link STORE}. */ +async function framework(input: RequestInfo | URL, init?: RequestInit): Promise { + const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const url = new URL(raw, 'http://localhost/'); + const method = (init?.method ?? 'GET').toUpperCase(); + // A real round trip is not instantaneous; the gap is what lets two loads overlap. + await new Promise((resolve) => setTimeout(resolve, 5)); + + if (url.pathname.endsWith('/api/v1/discovery')) { + return json(200, { success: true, data: { capabilities: {}, routes: {} } }); + } + if (url.pathname.includes('/api/v1/meta/')) { + return json(200, { success: true, data: { items: [] } }); + } + const prefix = `/api/v1/data/${RESOURCE}`; + if (url.pathname.startsWith(prefix)) { + if (method === 'GET') { + const filter = JSON.parse(url.searchParams.get('filter') ?? '["and"]') as FilterNode; + wire.gets.push(scopeOf(filter, { user: undefined, keys: [], top: url.searchParams.get('top') })); + const rows: PrefRow[] = Object.entries(STORE).flatMap(([user, byKey]) => + Object.entries(byKey).map(([key, value]) => ({ id: rowId(user, key), user_id: user, key, value })), + ); + const top = Number(url.searchParams.get('top') ?? Infinity); + const records = rows.filter((r) => matches(filter, r)).slice(0, top); + return json(200, { success: true, data: { records, total: records.length } }); + } + const id = url.pathname.slice(prefix.length + 1) || undefined; + const body = init?.body ? (JSON.parse(String(init.body)) as { value?: unknown }) : {}; + wire.writes.push({ method, id, value: body.value }); + return json(200, { success: true, data: { object: RESOURCE, id, record: { id } } }); + } + return json(404, {}); } interface Seen { @@ -162,107 +180,153 @@ interface Seen { } /** - * Module-scope holder, written from an effect and never from the render phase - * — the same shape `MetadataProvider.requestBudget.test.tsx` uses. It is not a - * prop, because writing through a prop is what the react-compiler rule refuses. + * Module-scope holders, written from an effect and never from the render phase + * — the same shape `MetadataProvider.requestBudget.test.tsx` uses. Not props, + * because writing through a prop is what the react-compiler rule refuses. */ const latest: { current: Seen } = { current: { favorites: [], recent: [], flow: [] } }; - -function resetSeen() { - latest.current = { favorites: [], recent: [], flow: [] }; -} +const actions: { + current: { + addFavorite?: ReturnType['addFavorite']; + addRecentItem?: ReturnType['addRecentItem']; + recordRecent?: ReturnType['recordRecent']; + }; +} = { current: {} }; function Consumers() { - const { favorites } = useFavorites(); - const { recentItems } = useRecentItems(); - const { recents } = useFlowPaletteRecents(); + const { favorites, addFavorite } = useFavorites(); + const { recentItems, addRecentItem } = useRecentItems(); + const { recents, recordRecent } = useFlowPaletteRecents(); useEffect(() => { latest.current = { - favorites: favorites.map(f => f.id), - recent: recentItems.map(r => r.id), + favorites: favorites.map((f) => f.id), + recent: recentItems.map((r) => r.id), flow: [...recents], }; - }, [favorites, recentItems, recents]); - return
ready
; + actions.current = { addFavorite, addRecentItem, recordRecent }; + }, [favorites, recentItems, recents, addFavorite, addRecentItem, recordRecent]); + return
content
; } -function mountConsoleUserState(ds: unknown) { - return render( - - - - - - - - - - , +function Console({ children }: { children: ReactNode }) { + return ( + + + {children} + + ); } -/** Let every pending microtask/timer drain so a LATE duplicate is still counted. */ -async function settle() { - await new Promise(resolve => setTimeout(resolve, 120)); +function boot() { + return render(); } -describe('console sys_user_preference request budget (objectui#5544)', () => { - it('reads each preference key exactly once across one mount', async () => { - const calls: { resource: string; key: string }[] = []; - resetSeen(); - mountConsoleUserState(makeCountingAdapter(calls)); +/** Let every pending effect, microtask and timer drain, so a LATE extra read is still counted. */ +async function settle(ms = 150) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, ms)); + }); +} + +const U1_SEEN: Seen = { favorites: ['object:contact'], recent: ['object:lead'], flow: ['flow_step_alpha'] }; +const U2_SEEN: Seen = { favorites: ['object:invoice'], recent: ['object:quote'], flow: ['flow_step_omega'] }; + +describe('console sys_user_preference request budget (objectui#12187)', () => { + beforeEach(() => { + session.userId = 'u1'; + wire.gets.length = 0; + wire.writes.length = 0; + latest.current = { favorites: [], recent: [], flow: [] }; + actions.current = {}; + localStorage.clear(); + vi.stubGlobal('fetch', vi.fn(framework)); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); - await waitFor(() => expect(calls.length).toBeGreaterThan(0)); + it('one boot puts one sys_user_preference read on the wire, asking for the three keys', async () => { + boot(); + await waitFor(() => expect(wire.gets.length).toBeGreaterThan(0)); await settle(); - const prefCalls = calls.filter(c => c.resource === 'sys_user_preference'); - // One read per slot — three distinct keys, none of them fetched twice. - expect(prefCalls.filter(c => c.key === FAVORITES_KEY)).toHaveLength(1); - expect(prefCalls.filter(c => c.key === RECENT_KEY)).toHaveLength(1); - expect(prefCalls.filter(c => c.key === FLOW_KEY)).toHaveLength(1); - // …and nothing else reached the transport on this endpoint. - expect(prefCalls.map(c => c.key).sort()).toEqual( - [FAVORITES_KEY, FLOW_KEY, RECENT_KEY].sort(), - ); + expect(wire.gets).toHaveLength(1); + expect(wire.gets[0].user).toBe('u1'); + expect([...wire.gets[0].keys].sort()).toEqual([...BRIDGE_KEYS].sort()); }); - it('delivers its own row to every consumer that was fetching one', async () => { - const calls: { resource: string; key: string }[] = []; - resetSeen(); - mountConsoleUserState(makeCountingAdapter(calls)); - - // Three distinct payloads, three distinct consumers. Collapsing the three - // reads into one (an endpoint-level dedup) starves two of them, and this - // is the assertion that catches it. - await waitFor(() => { - expect(latest.current.favorites).toEqual(['object:contact']); - expect(latest.current.recent).toEqual(['object:lead']); - expect(latest.current.flow).toEqual(['flow_step_alpha']); - }); + it('every consumer receives its own row from that one answer', async () => { + boot(); + // Three distinct payloads, three distinct consumers: an answer handed to + // the wrong adapter, or to only one of them, fails here. + await waitFor(() => expect(latest.current).toEqual(U1_SEEN)); + expect(wire.gets).toHaveLength(1); }); - it('coalesces two IDENTICAL in-flight reads into one round trip', async () => { - // The dedup the card asks for already exists at the request layer: - // `find()` shares one Promise per `resource + params`. Two adapters on the - // same key issue one request and both still resolve with the row. - const calls: { resource: string; key: string }[] = []; - const ds = makeCountingAdapter(calls); - const a = createObjectStackUserStateAdapter({ - dataSource: ds as never, - userId: 'u1', - key: FAVORITES_KEY, - }); - const b = createObjectStackUserStateAdapter({ - dataSource: ds as never, - userId: 'u1', - key: FAVORITES_KEY, + it('each adapter still writes its own row, and only its own', async () => { + boot(); + await waitFor(() => expect(latest.current).toEqual(U1_SEEN)); + await settle(); + expect(wire.writes).toEqual([]); + + act(() => { + actions.current.addFavorite?.({ id: 'object:account', label: 'Account', href: '/account', type: 'object' }); + actions.current.addRecentItem?.({ id: 'object:deal', type: 'object', name: 'deal', href: '/deal' }); + actions.current.recordRecent?.('flow_step_beta'); }); + // Each provider debounces its write (500 ms); all three land, once each. + await waitFor(() => expect(wire.writes).toHaveLength(3), { timeout: 3000 }); + await settle(); + expect(wire.writes).toHaveLength(3); + + const byId = new Map(wire.writes.map((w) => [w.id, w])); + // An update of the row its own load found — no insert, no other row. + expect(wire.writes.every((w) => w.method === 'PATCH' || w.method === 'PUT')).toBe(true); + expect([...byId.keys()].sort()).toEqual(BRIDGE_KEYS.map((k) => rowId('u1', k)).sort()); + expect((byId.get(rowId('u1', FAVORITES_KEY))?.value as { id: string }[]).map((f) => f.id)).toContain( + 'object:account', + ); + expect((byId.get(rowId('u1', RECENT_KEY))?.value as { id: string }[])[0].id).toBe('object:deal'); + expect(byId.get(rowId('u1', FLOW_KEY))?.value).toEqual(['flow_step_beta', 'flow_step_alpha']); + // A write found its row from the load: it asked the server nothing more. + expect(wire.gets).toHaveLength(1); + }); + + it('signing in as another user reads again, for that user, and ends on that user\'s rows', async () => { + const { rerender } = boot(); + await waitFor(() => expect(latest.current).toEqual(U1_SEEN)); + await settle(); + const before = wire.gets.length; + + session.userId = 'u2'; + rerender(); + + await waitFor(() => expect(latest.current).toEqual(U2_SEEN)); + await settle(); + + const afterSwitch = wire.gets.slice(before); + const forU2 = afterSwitch.filter((g) => g.user === 'u2'); + // One read for the new user, asking for all three keys. + expect(forU2).toHaveLength(1); + expect([...forU2[0].keys].sort()).toEqual([...BRIDGE_KEYS].sort()); + // No u2 read was answered with u1's rows: every consumer shows u2's. + expect(latest.current).toEqual(U2_SEEN); + }); + + it('two loads of one key at once are still one request', async () => { + const ds = new ObjectStackAdapter({ baseUrl: 'http://test.local', fetch: framework as typeof fetch, autoReconnect: false }); + await ds.connect(); + const a = createObjectStackUserStateAdapter({ dataSource: ds, userId: 'u1', key: FAVORITES_KEY }); + const b = createObjectStackUserStateAdapter({ dataSource: ds, userId: 'u1', key: FAVORITES_KEY }); + const [first, second] = await Promise.all([a.load(), b.load()]); - expect(calls.filter(c => c.key === FAVORITES_KEY)).toHaveLength(1); - // Coalesced, not starved — both callers get the data. - expect(first).toEqual([ROWS[FAVORITES_KEY][0]]); - expect(second).toEqual([ROWS[FAVORITES_KEY][0]]); + expect(wire.gets).toHaveLength(1); + expect(wire.gets[0].keys).toEqual([FAVORITES_KEY]); + expect(first).toEqual(STORE.u1[FAVORITES_KEY]); + expect(second).toEqual(STORE.u1[FAVORITES_KEY]); }); }); diff --git a/packages/data-objectstack/src/userState.inflightShare-11797.test.ts b/packages/data-objectstack/src/userState.inflightShare-11797.test.ts index 6e8b1d13ef..79067530e0 100644 --- a/packages/data-objectstack/src/userState.inflightShare-11797.test.ts +++ b/packages/data-objectstack/src/userState.inflightShare-11797.test.ts @@ -14,14 +14,15 @@ * the identical filter" on Home → Studio. Measured through the REAL adapter * and the REAL client, the console's user-state bridge attaches three * adapters — favorites, recent items and flow-palette recents — and each - * loads its own `(user, key)` row: three requests whose filters differ in the - * `key` predicate, which sits at the tail of a long encoded query string. - * They are three questions, not one question asked three times, so there is - * nothing to share between them. + * loads its own `(user, key)` row. The card was closed on that reading: three + * requests whose filters differ in the `key` predicate. * - * The case that IS a duplicate — two loads of one `(user, key)` at once — is - * already one request: `find` shares an in-flight read by resource + params. - * This file pins both, so the reading the card was closed on stays checkable. + * objectui#12187 ruled the budget at one read per load: the three loads now + * share ONE request that asks for the three keys (`key in (...)`), and each + * adapter takes its own row from the answer. The first case below pins that. + * + * The case that was already a duplicate — two loads of one `(user, key)` at + * once — is one request, and a load after a save still reaches the server. */ import { describe, it, expect, vi } from 'vitest'; import { ObjectStackAdapter } from './index'; @@ -74,22 +75,24 @@ const rows = (records: unknown[]) => ({ success: true, data: { records, total: r const BRIDGE_KEYS = ['ui.favorites', 'ui.recent', 'ui.flow.palette.recents']; describe('sys_user_preference reads on console entry (objectui#11797)', () => { - it("the bridge's three loads are three different questions — one request per key", async () => { + it("the bridge's three loads share one request, and each takes its own row (objectui#12187)", async () => { const { ds, held } = await heldAdapter(); const loads = BRIDGE_KEYS.map((key) => createObjectStackUserStateAdapter({ dataSource: ds, userId: 'u1', key }).load(), ); await drain(); - expect(held).toHaveLength(3); - expect(new Set(held.map((r) => r.path)).size).toBe(3); - held.forEach((r, i) => { - expect(r.path.startsWith('/api/v1/data/sys_user_preference?')).toBe(true); - expect(r.path).toContain(`["key","=","${BRIDGE_KEYS[i]}"]`); - }); - - held.forEach((r) => r.respond(200, rows([]))); - await Promise.all(loads); + expect(held).toHaveLength(1); + expect(held[0].path.startsWith('/api/v1/data/sys_user_preference?')).toBe(true); + expect(held[0].path).toContain('["user_id","=","u1"]'); + expect(held[0].path).toContain(`["key","in",${JSON.stringify(BRIDGE_KEYS)}]`); + expect(held[0].path).toContain('top=3'); + + held[0].respond( + 200, + rows(BRIDGE_KEYS.map((key, i) => ({ id: `p${i}`, user_id: 'u1', key, value: [key] }))), + ); + await expect(Promise.all(loads)).resolves.toEqual(BRIDGE_KEYS.map((key) => [key])); }); it('two loads of one (user, key) at once put one request on the wire', async () => { diff --git a/packages/data-objectstack/src/userState.sharedRead-12187.test.ts b/packages/data-objectstack/src/userState.sharedRead-12187.test.ts new file mode 100644 index 0000000000..a7f5336f1e --- /dev/null +++ b/packages/data-objectstack/src/userState.sharedRead-12187.test.ts @@ -0,0 +1,201 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12187 — the user-state adapters' shared read, at its edges. + * + * `load()` joins one open read per (data source, resource, user); every load + * of that scope issued before the next microtask adds its key, and the read is + * sent once. These cases pin what the share must NOT do, through the REAL + * adapter and the REAL client, counted on the wire: + * + * - serve one user's rows to another user's adapter; + * - serve a later load from an earlier answer (nothing is kept past the send); + * - let a second data source join the first one's read; + * - attribute a row that names no key to some key in a shared answer; + * - let one failed read throw out of any adapter's `load()`; + * - change a write: `save()` still finds and writes its own row. + */ +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackAdapter } from './index'; +import { createObjectStackUserStateAdapter } from './userState'; + +const BASE = 'http://test.local'; + +interface HeldRequest { + method: string; + path: string; + respond: (status: number, body: unknown) => void; +} + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +/** A connected adapter whose data requests wait until the test answers them. */ +async function heldAdapter(held: HeldRequest[] = []) { + const fetch = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith('/api/v1/discovery')) { + return Promise.resolve(json(200, { success: true, data: { capabilities: {}, routes: {} } })); + } + return new Promise((resolve) => { + held.push({ + method: (init?.method ?? 'GET').toUpperCase(), + path: decodeURIComponent(url.replace(BASE, '')), + respond: (status, body) => resolve(json(status, body)), + }); + }); + }); + const ds = new ObjectStackAdapter({ baseUrl: BASE, fetch, autoReconnect: false }); + await ds.connect(); + return { ds, held }; +} + +async function drain() { + for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); +} + +const rows = (records: unknown[]) => ({ success: true, data: { records, total: records.length } }); + +const gets = (held: HeldRequest[]) => held.filter((r) => r.method === 'GET'); + +describe('the user-state adapters share one read per load (objectui#12187)', () => { + it("never answers one user's adapter with another user's rows", async () => { + const { ds, held } = await heldAdapter(); + const forU1 = createObjectStackUserStateAdapter({ dataSource: ds, userId: 'u1', key: 'ui.recent' }); + const forU2 = createObjectStackUserStateAdapter({ dataSource: ds, userId: 'u2', key: 'ui.recent' }); + const loads = [forU1.load(), forU2.load()]; + await drain(); + + // Two users, two reads, each scoped to its own user. + expect(held).toHaveLength(2); + const u1Read = held.find((r) => r.path.includes('["user_id","=","u1"]')); + const u2Read = held.find((r) => r.path.includes('["user_id","=","u2"]')); + expect(u1Read).toBeDefined(); + expect(u2Read).toBeDefined(); + + u1Read!.respond(200, rows([{ id: 'p1', user_id: 'u1', key: 'ui.recent', value: ['u1-item'] }])); + u2Read!.respond(200, rows([{ id: 'p2', user_id: 'u2', key: 'ui.recent', value: ['u2-item'] }])); + await expect(Promise.all(loads)).resolves.toEqual([['u1-item'], ['u2-item']]); + }); + + it('a load issued after the shared read was sent asks the server again', async () => { + const { ds, held } = await heldAdapter(); + const favorites = createObjectStackUserStateAdapter({ dataSource: ds, userId: 'u1', key: 'ui.favorites' }); + const recent = createObjectStackUserStateAdapter({ dataSource: ds, userId: 'u1', key: 'ui.recent' }); + + const first = [favorites.load(), recent.load()]; + await drain(); + expect(gets(held)).toHaveLength(1); + + // Asked while the first read is still in flight: it is already closed. + const later = recent.load(); + await drain(); + expect(gets(held)).toHaveLength(2); + // Alone in its read, so it is the per-key query this adapter always sent. + expect(held[1].path).toContain('["key","=","ui.recent"]'); + expect(held[1].path).toContain('top=1'); + + held[0].respond(200, rows([ + { id: 'p1', user_id: 'u1', key: 'ui.favorites', value: ['f-old'] }, + { id: 'p2', user_id: 'u1', key: 'ui.recent', value: ['r-old'] }, + ])); + held[1].respond(200, rows([{ id: 'p2', user_id: 'u1', key: 'ui.recent', value: ['r-new'] }])); + await expect(Promise.all(first)).resolves.toEqual([['f-old'], ['r-old']]); + await expect(later).resolves.toEqual(['r-new']); + }); + + it("a second data source does not join the first one's read", async () => { + const shared: HeldRequest[] = []; + const { ds: one } = await heldAdapter(shared); + const { ds: two } = await heldAdapter(shared); + const loads = [ + createObjectStackUserStateAdapter({ dataSource: one, userId: 'u1', key: 'ui.recent' }).load(), + createObjectStackUserStateAdapter({ dataSource: two, userId: 'u1', key: 'ui.recent' }).load(), + ]; + await drain(); + + expect(shared).toHaveLength(2); + shared.forEach((r) => r.respond(200, rows([]))); + await expect(Promise.all(loads)).resolves.toEqual([[], []]); + }); + + it('a shared answer whose rows name no key is not attributed: each key reads its own row', async () => { + const { ds, held } = await heldAdapter(); + const favorites = createObjectStackUserStateAdapter({ dataSource: ds, userId: 'u1', key: 'ui.favorites' }); + const recent = createObjectStackUserStateAdapter({ dataSource: ds, userId: 'u1', key: 'ui.recent' }); + const loads = [favorites.load(), recent.load()]; + await drain(); + + expect(held).toHaveLength(1); + held[0].respond(200, rows([{ id: 'p0', value: ['which-key'] }])); + await drain(); + + // Neither adapter took the untagged row; each asked for its own key. + const perKey = held.slice(1); + expect(perKey).toHaveLength(2); + expect(perKey.map((r) => r.path)).toEqual(expect.arrayContaining([ + expect.stringContaining('["key","=","ui.favorites"]'), + expect.stringContaining('["key","=","ui.recent"]'), + ])); + perKey.find((r) => r.path.includes('ui.favorites'))!.respond(200, rows([{ id: 'p1', value: ['f'] }])); + perKey.find((r) => r.path.includes('ui.recent'))!.respond(200, rows([{ id: 'p2', value: ['r'] }])); + await expect(Promise.all(loads)).resolves.toEqual([['f'], ['r']]); + }); + + it('a failed shared read resolves every load to [] and reports it to each adapter', async () => { + const { ds, held } = await heldAdapter(); + const onFavoritesError = vi.fn(); + const onRecentError = vi.fn(); + const loads = [ + createObjectStackUserStateAdapter({ dataSource: ds, userId: 'u1', key: 'ui.favorites', onError: onFavoritesError }).load(), + createObjectStackUserStateAdapter({ dataSource: ds, userId: 'u1', key: 'ui.recent', onError: onRecentError }).load(), + ]; + await drain(); + + expect(held).toHaveLength(1); + held[0].respond(500, { success: false, error: { code: 'INTERNAL_ERROR', message: 'boom' } }); + await expect(Promise.all(loads)).resolves.toEqual([[], []]); + expect(onFavoritesError).toHaveBeenCalledWith('load', expect.anything()); + expect(onRecentError).toHaveBeenCalledWith('load', expect.anything()); + }); + + it("a save with no row known finds its own key's row, then writes only that row", async () => { + const { ds, held } = await heldAdapter(); + const favorites = createObjectStackUserStateAdapter({ dataSource: ds, userId: 'u1', key: 'ui.favorites' }); + // A second adapter on the same scope, saving at the same time: writes do not share. + const recent = createObjectStackUserStateAdapter({ dataSource: ds, userId: 'u1', key: 'ui.recent' }); + + const saves = [favorites.save(['f']), recent.save(['r'])]; + await drain(); + + const finds = gets(held); + expect(finds).toHaveLength(2); + expect(finds.map((r) => r.path)).toEqual(expect.arrayContaining([ + expect.stringContaining('["key","=","ui.favorites"]'), + expect.stringContaining('["key","=","ui.recent"]'), + ])); + finds.find((r) => r.path.includes('ui.favorites'))!.respond(200, rows([{ id: 'pf', user_id: 'u1', key: 'ui.favorites', value: [] }])); + finds.find((r) => r.path.includes('ui.recent'))!.respond(200, rows([{ id: 'pr', user_id: 'u1', key: 'ui.recent', value: [] }])); + await drain(); + + const writes = held.filter((r) => r.method !== 'GET'); + expect(writes).toHaveLength(2); + expect(writes.map((r) => r.path).sort()).toEqual([ + '/api/v1/data/sys_user_preference/pf', + '/api/v1/data/sys_user_preference/pr', + ]); + writes.forEach((r) => r.respond(200, { success: true, data: { object: 'sys_user_preference', id: 'x', record: { id: 'x' } } })); + await Promise.all(saves); + }); +}); From b57a415e67c03d81f5a92dcad466bff5f2bfd3e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 11:28:55 +0000 Subject: [PATCH 3/4] chore(changeset): data-objectstack patch for the shared sys_user_preference read Claude-Session: https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK Co-authored-by: Claude --- .changeset/12187-user-preference-one-read.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .changeset/12187-user-preference-one-read.md diff --git a/.changeset/12187-user-preference-one-read.md b/.changeset/12187-user-preference-one-read.md new file mode 100644 index 0000000000..a51945a108 --- /dev/null +++ b/.changeset/12187-user-preference-one-read.md @@ -0,0 +1,12 @@ +--- +'@object-ui/data-objectstack': patch +--- + +A console page load reads `sys_user_preference` once, not once per preference key (objectui#12187). + +- **The user-state adapters share one read per load.** `createObjectStackUserStateAdapter`'s `load()` now joins an open read for its data source, resource and user. Every load of that scope issued before the next microtask adds its key, the read is sent once, and each adapter takes its own row from the answer. The console's favorites, recent items and flow palette recents used to make three requests, one per key; they now make one, `user_id = USER and key in (the three keys)`, with `$top` set to the number of keys. The object's unique index on `(user_id, key)` bounds the answer to one row per key. +- **A read that collects one key is unchanged.** It sends the same per-key query, `$top: 1`, as before. +- **Nothing is kept past the request.** The read closes when it is sent. A later `load()`, such as a new adapter after a language or account switch, asks the server again. A different user or data source never joins another's read. +- **Writes are unchanged.** `save()` still finds and writes its own row, and the stored rows and their shape are the same. + +No export, option or type changes. From 55be3d7569e3f4fde7146452b51154cbb54196aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 11:33:05 +0000 Subject: [PATCH 4/4] refactor(data-objectstack): type the shared read's data source by the options member, not a new any Claude-Session: https://claude.ai/code/session_016djJF12Qt14ejKR5Vjt4sK Co-authored-by: Claude --- packages/data-objectstack/src/userState.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/data-objectstack/src/userState.ts b/packages/data-objectstack/src/userState.ts index cc27d4df58..679236051a 100644 --- a/packages/data-objectstack/src/userState.ts +++ b/packages/data-objectstack/src/userState.ts @@ -150,7 +150,7 @@ const openReads = new WeakMap>(); * See "One read per load" in the module header. */ function joinSharedRead( - dataSource: DataSource, + dataSource: ObjectStackUserStateAdapterOptions['dataSource'], resource: string, userId: string, key: string,