@startsimpli/ui 0.4.111 → 0.4.113

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,243 @@
1
+ /**
2
+ * `withScope` — the one seam (bd startsim-nq39j, epic startsim-ux39t).
3
+ *
4
+ * These pin the four things the bead asked to be TESTED rather than inspected:
5
+ * a list read carries the active scope, a blank one never reaches the wire, a
6
+ * request with no type never carries a scope at all, and a write cannot move a
7
+ * row between scopes.
8
+ *
9
+ * WHY THE ASSERTIONS LOOK AT THE ABSENCE OF A KEY. `?scope=` (blank) and
10
+ * `?scope=all` are both 400s server-side, and the blank one is the dangerous
11
+ * one — `normalize_container("")` returns `/`, so an implementation that sends
12
+ * an empty parameter is asking for EVERY scoped row while believing it asked
13
+ * for none (bd startsim-mj609). `toHaveProperty` is therefore the right
14
+ * assertion and `toBe('')` is not.
15
+ */
16
+ import { resolveScope, scopeFilters, scopeKey, withScope, SCOPE_FILTER } from '../scoped-client';
17
+ import type { CollectionClient, EntityRecord, EntityTypeDef, ListEntitiesOptions, Paginated } from '../types';
18
+
19
+ function page<T>(results: T[]): Paginated<T> {
20
+ return { count: results.length, next: null, previous: null, results };
21
+ }
22
+
23
+ function record(over: Partial<EntityRecord> = {}): EntityRecord {
24
+ return {
25
+ id: 1,
26
+ entityType: 'topic',
27
+ externalId: null,
28
+ name: 'A topic',
29
+ data: {},
30
+ createdAt: '2026-08-27T00:00:00Z',
31
+ ...over,
32
+ };
33
+ }
34
+
35
+ function stub() {
36
+ const listTypes = jest.fn(async () => page<EntityTypeDef>([]));
37
+ const listAllEntities = jest.fn(async (_type: string, _options?: ListEntitiesOptions) => [record()]);
38
+ const updateEntity = jest.fn(async () => record());
39
+ const client: CollectionClient = { listTypes, listAllEntities, updateEntity };
40
+ return { client, listTypes, listAllEntities, updateEntity };
41
+ }
42
+
43
+ /** The filters bag as it was actually handed down, whatever the call shape. */
44
+ function filtersOf(mock: jest.Mock, call = 0): Record<string, string> {
45
+ const options = mock.mock.calls[call]?.[1] as ListEntitiesOptions | undefined;
46
+ return options?.filters ?? {};
47
+ }
48
+
49
+ // --------------------------------------------------------------------------
50
+ // resolveScope / scopeFilters — the wire spelling of "everything"
51
+ // --------------------------------------------------------------------------
52
+
53
+ describe('resolveScope — every spelling of "no bound" is one value', () => {
54
+ it.each([
55
+ ['null', null],
56
+ ['undefined', undefined],
57
+ ['empty', ''],
58
+ ['whitespace', ' '],
59
+ // The BROWSER URL's reserved word. A caller can reach the raw parameter
60
+ // without going through useScopeParam — a server component reading
61
+ // searchParams, a test, an app keeping the scope elsewhere — so `all` has to
62
+ // mean the same thing here as it does there.
63
+ ['the reserved word "all"', 'all'],
64
+ ])('resolves %s to null', (_label, input) => {
65
+ expect(resolveScope(input as string | null | undefined)).toBeNull();
66
+ });
67
+
68
+ it('keeps a real path, trimmed, verbatim', () => {
69
+ expect(resolveScope(' /clients/acme ')).toBe('/clients/acme');
70
+ });
71
+
72
+ it('passes a malformed path THROUGH rather than dropping it', () => {
73
+ // The server refuses a value with no leading separator, and that refusal is
74
+ // the right answer: dropping it would silently widen to the whole corpus
75
+ // under a filter the reader believes is active.
76
+ expect(resolveScope('clients/acme')).toBe('clients/acme');
77
+ });
78
+ });
79
+
80
+ describe('scopeFilters — a blank scope is never on the wire', () => {
81
+ it.each([[null], [undefined], [''], [' '], ['all']])(
82
+ 'emits NO scope key at all for %p',
83
+ (input) => {
84
+ const filters = scopeFilters(input as string | null | undefined);
85
+ expect(filters).not.toHaveProperty(SCOPE_FILTER);
86
+ expect(Object.keys(filters)).toHaveLength(0);
87
+ },
88
+ );
89
+
90
+ it('emits the path when there is one', () => {
91
+ expect(scopeFilters('/clients/acme')).toEqual({ scope: '/clients/acme' });
92
+ });
93
+ });
94
+
95
+ // --------------------------------------------------------------------------
96
+ // list reads
97
+ // --------------------------------------------------------------------------
98
+
99
+ describe('withScope — with a scope active, every list request carries it', () => {
100
+ it('puts the scope in the FILTERS bag, which the app puts in the URL string', async () => {
101
+ const { client, listAllEntities } = stub();
102
+ await withScope(client, '/clients/acme').listAllEntities('topic');
103
+ expect(filtersOf(listAllEntities)).toEqual({ scope: '/clients/acme' });
104
+ });
105
+
106
+ it('carries it on every type a workspace reads, not just the first', async () => {
107
+ const { client, listAllEntities } = stub();
108
+ const scoped = withScope(client, '/clients/acme');
109
+ await scoped.listAllEntities('topic');
110
+ await scoped.listAllEntities('draft');
111
+ expect(filtersOf(listAllEntities, 0)).toEqual({ scope: '/clients/acme' });
112
+ expect(filtersOf(listAllEntities, 1)).toEqual({ scope: '/clients/acme' });
113
+ });
114
+
115
+ it('keeps the caller’s own filters alongside it', async () => {
116
+ const { client, listAllEntities } = stub();
117
+ await withScope(client, '/clients/acme').listAllEntities('draft', {
118
+ filters: { 'attr.published_at__gte': '2026-08-10' },
119
+ });
120
+ expect(filtersOf(listAllEntities)).toEqual({
121
+ scope: '/clients/acme',
122
+ 'attr.published_at__gte': '2026-08-10',
123
+ });
124
+ });
125
+
126
+ it('does not overrule a caller that sent its own scope', async () => {
127
+ const { client, listAllEntities } = stub();
128
+ await withScope(client, '/clients/acme').listAllEntities('draft', {
129
+ filters: { scope: '/clients/globex' },
130
+ });
131
+ expect(filtersOf(listAllEntities).scope).toBe('/clients/globex');
132
+ });
133
+
134
+ it('sends no scope key when nothing is in scope', async () => {
135
+ const { client, listAllEntities } = stub();
136
+ await withScope(client, null).listAllEntities('topic', { filters: { page_size: '200' } });
137
+ expect(filtersOf(listAllEntities)).not.toHaveProperty('scope');
138
+ expect(filtersOf(listAllEntities)).toEqual({ page_size: '200' });
139
+ });
140
+
141
+ it('calls an unscoped read exactly the way an unwrapped client is called', async () => {
142
+ // A client that only ever took `(type)` must see what it always saw, so
143
+ // wrapping an existing app in `withScope` is not a behaviour change.
144
+ const { client, listAllEntities } = stub();
145
+ await withScope(client, null).listAllEntities('topic');
146
+ expect(listAllEntities).toHaveBeenCalledWith('topic');
147
+ expect(listAllEntities.mock.calls[0]).toHaveLength(1);
148
+ });
149
+ });
150
+
151
+ describe('withScope — a request with no type never carries a scope', () => {
152
+ it('hands listTypes on with NO arguments', async () => {
153
+ // `?scope=` REQUIRES `?type=` — a 400 without it (bd startsim-mj609),
154
+ // because the parameter resolves through ONE type's declaration and the
155
+ // schema read spans every type.
156
+ const { client, listTypes } = stub();
157
+ await withScope(client, '/clients/acme').listTypes();
158
+ expect(listTypes).toHaveBeenCalledWith();
159
+ expect(listTypes.mock.calls[0]).toHaveLength(0);
160
+ });
161
+
162
+ it.each([[''], [' ']])('adds no scope to a list read whose type is %p', async (type) => {
163
+ const { client, listAllEntities } = stub();
164
+ await withScope(client, '/clients/acme').listAllEntities(type);
165
+ expect(filtersOf(listAllEntities)).not.toHaveProperty('scope');
166
+ });
167
+ });
168
+
169
+ describe('withScope — the shared corpus opts out', () => {
170
+ it('leaves a `scoped: false` read unbounded', async () => {
171
+ // One source registry, one news_item corpus (epic startsim-ux39t decision
172
+ // 2). Narrowing a lookup of a shared type either 400s or silently loses
173
+ // every row outside the bound.
174
+ const { client, listAllEntities } = stub();
175
+ await withScope(client, '/clients/acme').listAllEntities('source', { scoped: false });
176
+ expect(filtersOf(listAllEntities)).not.toHaveProperty('scope');
177
+ });
178
+
179
+ it('scopes by DEFAULT, so forgetting narrows rather than widens', async () => {
180
+ const { client, listAllEntities } = stub();
181
+ await withScope(client, '/clients/acme').listAllEntities('source', {});
182
+ expect(filtersOf(listAllEntities)).toEqual({ scope: '/clients/acme' });
183
+ });
184
+ });
185
+
186
+ // --------------------------------------------------------------------------
187
+ // writes
188
+ // --------------------------------------------------------------------------
189
+
190
+ describe('withScope — a write cannot cross scopes', () => {
191
+ it('hands the payload on BYTE-FOR-BYTE', async () => {
192
+ const { client, updateEntity } = stub();
193
+ const input = { data: { status: 'ready', path: '/clients/globex' } };
194
+ await withScope(client, '/clients/acme').updateEntity(7, input);
195
+ expect(updateEntity).toHaveBeenCalledWith(7, input);
196
+ });
197
+
198
+ it('never rewrites a row’s own scope to the active one', async () => {
199
+ // Patching a row while /clients/acme is selected must NOT move it out of
200
+ // /clients/globex. Changing a row's scope is an explicit act.
201
+ const { client, updateEntity } = stub();
202
+ await withScope(client, '/clients/acme').updateEntity(7, {
203
+ data: { path: '/clients/globex' },
204
+ });
205
+ const [, input] = updateEntity.mock.calls[0] as [unknown, { data: Record<string, unknown> }];
206
+ expect(input.data.path).toBe('/clients/globex');
207
+ });
208
+
209
+ it('never STAMPS a scope onto a write that carried none', async () => {
210
+ // The bead's "writes stamp it" belongs to CREATE, on the upsert path. There
211
+ // is no create on this interface, and stamping an UPDATE is exactly the
212
+ // cross-scope move the same bead forbids.
213
+ const { client, updateEntity } = stub();
214
+ await withScope(client, '/clients/acme').updateEntity(7, { data: { status: 'ready' } });
215
+ const [, input] = updateEntity.mock.calls[0] as [unknown, { data: Record<string, unknown> }];
216
+ expect(input.data).toEqual({ status: 'ready' });
217
+ expect(input).not.toHaveProperty('scope');
218
+ });
219
+ });
220
+
221
+ // --------------------------------------------------------------------------
222
+ // the cache key
223
+ // --------------------------------------------------------------------------
224
+
225
+ describe('scopeKey — a cached read is only valid for its own scope', () => {
226
+ it('is the path for a bound client', () => {
227
+ expect(scopeKey(withScope(stub().client, '/clients/acme'))).toBe('/clients/acme');
228
+ });
229
+
230
+ it('is "all" for an unbounded one', () => {
231
+ expect(scopeKey(withScope(stub().client, null))).toBe('all');
232
+ });
233
+
234
+ it('is "all" for a plain, never-wrapped client, so an existing cache does not split', () => {
235
+ expect(scopeKey(stub().client)).toBe('all');
236
+ expect(scopeKey(undefined)).toBe('all');
237
+ });
238
+
239
+ it('differs between two scopes — the whole point of putting it in a key', () => {
240
+ const { client } = stub();
241
+ expect(scopeKey(withScope(client, '/a'))).not.toBe(scopeKey(withScope(client, '/b')));
242
+ });
243
+ });
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The seam AS THE REVIEW SURFACE SEES IT (bd startsim-nq39j).
3
+ *
4
+ * `scoped-client.test.ts` pins the decorator in isolation. This pins the thing
5
+ * the decorator exists for: a workspace handed a scoped client asks the server
6
+ * for that scope, SHOWS that scope, and stops showing it the moment the scope
7
+ * changes.
8
+ *
9
+ * THE SECOND TEST HERE IS THE ONE THAT MATTERS, and it is a defect this bead
10
+ * introduced and had to fix. react-query keys the two list reads
11
+ * `['entities','topic','all']` — a key with no scope in it. Wrap the client and
12
+ * the REQUEST carries the new scope while the CACHE serves the old scope's rows:
13
+ * the "every list request carries it" acceptance criterion goes green while the
14
+ * board shows another client's work. A scope in the key is the fix and this is
15
+ * what stops it being tidied away.
16
+ */
17
+ import * as React from 'react';
18
+ import { render, screen, waitFor } from '@testing-library/react';
19
+ import userEvent from '@testing-library/user-event';
20
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
21
+
22
+ import { TopicReviewWorkspace } from '../TopicReviewWorkspace';
23
+ import { withScope } from '../scoped-client';
24
+ import type {
25
+ CollectionClient,
26
+ EntityRecord,
27
+ EntityTypeDef,
28
+ ListEntitiesOptions,
29
+ Paginated,
30
+ } from '../types';
31
+
32
+ const TOPIC_TYPE: EntityTypeDef = { id: 1, key: 'topic', label: 'Topic', attributes: [] };
33
+
34
+ function page<T>(results: T[]): Paginated<T> {
35
+ return { count: results.length, next: null, previous: null, results };
36
+ }
37
+
38
+ function topic(id: number, title: string, path: string): EntityRecord {
39
+ return {
40
+ id,
41
+ entityType: 'topic',
42
+ externalId: null,
43
+ name: title,
44
+ data: { title, status: 'suggested', path },
45
+ createdAt: '2026-08-27T00:00:00Z',
46
+ };
47
+ }
48
+
49
+ /** Rows per scope, so "which scope did the board actually render" is answerable. */
50
+ const BY_SCOPE: Record<string, EntityRecord[]> = {
51
+ '/clients/acme': [topic(1, 'Acme desalination', '/clients/acme')],
52
+ '/clients/globex': [topic(2, 'Globex logistics', '/clients/globex')],
53
+ };
54
+
55
+ function stub(overrides: Partial<CollectionClient> = {}) {
56
+ const listTypes = jest.fn(async () => page([TOPIC_TYPE]));
57
+ const listAllEntities = jest.fn(async (type: string, options?: ListEntitiesOptions) => {
58
+ if (type !== 'topic') return [];
59
+ const scope = options?.filters?.scope;
60
+ return scope ? (BY_SCOPE[scope] ?? []) : Object.values(BY_SCOPE).flat();
61
+ });
62
+ const updateEntity = jest.fn(async () => topic(1, 'Acme desalination', '/clients/acme'));
63
+ const client: CollectionClient = { listTypes, listAllEntities, updateEntity, ...overrides };
64
+ return { client, listTypes, listAllEntities, updateEntity };
65
+ }
66
+
67
+ function Harness({ client }: { client: CollectionClient }) {
68
+ // ONE QueryClient across re-renders — otherwise a fresh cache per render would
69
+ // hide exactly the staleness the scope-change test is looking for.
70
+ const [qc] = React.useState(
71
+ () => new QueryClient({ defaultOptions: { queries: { retry: false } } }),
72
+ );
73
+ return (
74
+ <QueryClientProvider client={qc}>
75
+ <TopicReviewWorkspace client={client} />
76
+ </QueryClientProvider>
77
+ );
78
+ }
79
+
80
+ /** Every `topic` list read the client saw, as its scope filter (or `none`). */
81
+ function topicScopes(mock: jest.Mock): string[] {
82
+ return mock.mock.calls
83
+ .filter((call) => call[0] === 'topic')
84
+ .map((call) => (call[1] as ListEntitiesOptions | undefined)?.filters?.scope ?? 'none');
85
+ }
86
+
87
+ describe('a scoped client scopes the whole review surface', () => {
88
+ it('asks the server for the active scope, and renders what came back', async () => {
89
+ const { client, listAllEntities } = stub();
90
+ render(<Harness client={withScope(client, '/clients/acme')} />);
91
+
92
+ expect(await screen.findByText('Acme desalination')).toBeTruthy();
93
+ expect(topicScopes(listAllEntities)).toEqual(['/clients/acme']);
94
+ // NO CLIENT-SIDE FILTERING: the board shows exactly the rows the request
95
+ // returned, so the other scope's row was never fetched to be hidden.
96
+ expect(screen.queryByText('Globex logistics')).toBeNull();
97
+ });
98
+
99
+ it('REFETCHES when the scope changes, instead of serving the previous scope’s rows', async () => {
100
+ const { client, listAllEntities } = stub();
101
+ const { rerender } = render(<Harness client={withScope(client, '/clients/acme')} />);
102
+ expect(await screen.findByText('Acme desalination')).toBeTruthy();
103
+
104
+ rerender(<Harness client={withScope(client, '/clients/globex')} />);
105
+
106
+ expect(await screen.findByText('Globex logistics')).toBeTruthy();
107
+ await waitFor(() => expect(screen.queryByText('Acme desalination')).toBeNull());
108
+ expect(topicScopes(listAllEntities)).toEqual(['/clients/acme', '/clients/globex']);
109
+ });
110
+
111
+ it('reads the SHARED source registry unscoped, and reads it once for both scopes', async () => {
112
+ // One source registry across scopes (epic startsim-ux39t decision 2). It is
113
+ // also keyed without the scope, so switching client does not refetch it.
114
+ const { client, listAllEntities } = stub();
115
+ const { rerender } = render(<Harness client={withScope(client, '/clients/acme')} />);
116
+ expect(await screen.findByText('Acme desalination')).toBeTruthy();
117
+ rerender(<Harness client={withScope(client, '/clients/globex')} />);
118
+ expect(await screen.findByText('Globex logistics')).toBeTruthy();
119
+
120
+ const sourceCalls = listAllEntities.mock.calls.filter((call) => call[0] === 'source');
121
+ expect(sourceCalls).toHaveLength(1);
122
+ expect((sourceCalls[0][1] as ListEntitiesOptions).filters).not.toHaveProperty('scope');
123
+ });
124
+
125
+ it('never puts a scope on the schema read', async () => {
126
+ const { client, listTypes } = stub();
127
+ render(<Harness client={withScope(client, '/clients/acme')} />);
128
+ expect(await screen.findByText('Acme desalination')).toBeTruthy();
129
+ // `?scope=` without `?type=` is a 400 (bd startsim-mj609).
130
+ expect(listTypes).toHaveBeenCalledWith();
131
+ });
132
+ });
133
+
134
+ describe('an update preserves the row’s scope', () => {
135
+ it('PATCHes the row’s own path back, not the active one', async () => {
136
+ // The row lives in /clients/globex; the board is bounded to /clients/acme
137
+ // (a staff view that reached it, a stale cache, a hand-edited URL). Rating
138
+ // it must not MOVE it. PATCH replaces the whole blob, so the path has to be
139
+ // in the payload — with the row's value, never the client's.
140
+ const { client, updateEntity } = stub();
141
+ const scoped = withScope(client, '/clients/acme');
142
+ const stray = topic(9, 'Stray topic', '/clients/globex');
143
+ jest
144
+ .spyOn(client, 'listAllEntities')
145
+ .mockImplementation(async (type: string) => (type === 'topic' ? [stray] : []));
146
+
147
+ render(<Harness client={scoped} />);
148
+ expect(await screen.findByText('Stray topic')).toBeTruthy();
149
+
150
+ await userEvent.click(screen.getByRole('button', { name: /Good/ }));
151
+
152
+ await waitFor(() => expect(updateEntity).toHaveBeenCalled());
153
+ const [id, input] = updateEntity.mock.calls[0] as [
154
+ number,
155
+ { data: Record<string, unknown> },
156
+ ];
157
+ expect(id).toBe(9);
158
+ expect(input.data.path).toBe('/clients/globex');
159
+ expect(input.data.teamVerdict).toBe('good');
160
+ });
161
+ });
162
+
163
+ describe('a refused scope is reported, not rendered as an empty board', () => {
164
+ it('shows the server’s refusal instead of “Nothing to review right now.”', async () => {
165
+ // `?scope=` on a type declaring no scope path is a 400 (bd startsim-mj609).
166
+ // Swallowed, it renders as "this client has no topics" — a confident wrong
167
+ // answer of the same family as silently widening.
168
+ const { client } = stub();
169
+ jest.spyOn(client, 'listAllEntities').mockImplementation(async (type: string) => {
170
+ if (type === 'topic') throw new Error("type 'topic' declares no scope path");
171
+ return [];
172
+ });
173
+
174
+ render(<Harness client={withScope(client, '/clients/acme')} />);
175
+
176
+ expect(await screen.findByText(/declares no scope path/)).toBeTruthy();
177
+ expect(screen.queryByText('Nothing to review right now.')).toBeNull();
178
+ });
179
+ });
@@ -28,7 +28,28 @@ export {
28
28
  type VerdictOption,
29
29
  type VerdictTone,
30
30
  } from './review';
31
- export type { CollectionClient, EntityRecord, EntityTypeDef, AttributeDef, Paginated, DataType } from './types';
31
+ export type {
32
+ CollectionClient,
33
+ EntityRecord,
34
+ EntityTypeDef,
35
+ AttributeDef,
36
+ Paginated,
37
+ DataType,
38
+ EntityFilters,
39
+ ListEntitiesOptions,
40
+ } from './types';
41
+ // The scope seam (bd startsim-nq39j): wrap the injected client once and every
42
+ // review surface in this package is scoped, because every one of them is
43
+ // client-injected. `useScopedCollectionClient` in '@startsimpli/ui/scope' binds
44
+ // it to the URL for a Next host.
45
+ export {
46
+ withScope,
47
+ scopeFilters,
48
+ resolveScope,
49
+ scopeKey,
50
+ SCOPE_FILTER,
51
+ type ScopedCollectionClient,
52
+ } from './scoped-client';
32
53
  export { readData, toCamelKey } from './data';
33
54
  export {
34
55
  parseSources,
@@ -186,6 +186,24 @@ export interface ReviewConfig {
186
186
  * unchanged, so nothing that does not pass this renders differently.
187
187
  */
188
188
  decisionLabels?: Record<string, string>;
189
+ /**
190
+ * Decision keys after which the drawer STAYS on the record instead of
191
+ * auto-advancing to the next one. Default: none (every decision behaves
192
+ * exactly as it does today).
193
+ *
194
+ * WHY THIS EXISTS (bd startsim-t1t1k). Auto-advance is right for pure triage
195
+ * — decide, move on — but wrong for a decision that UNLOCKS a next action on
196
+ * the same record. On OGMC, approving a topic is precisely what makes
197
+ * "Generate drafts" legal for it; advancing scrolled the newly-legal control
198
+ * off the screen, and the reviewer's workaround was to close the drawer,
199
+ * reload, filter to Ready, find the topic again and approve it a second time.
200
+ * Six steps, and the second approve wrote nothing — what the reload bought
201
+ * was a fresh record.
202
+ *
203
+ * Only the consumer knows whether one of its decisions has a follow-on, so
204
+ * this is theirs to declare. Rejecting has none, so it keeps advancing.
205
+ */
206
+ holdAfter?: string[];
189
207
  }
190
208
 
191
209
  export interface ResolvedReview {
@@ -203,6 +221,7 @@ export interface ResolvedReview {
203
221
  sourceAttrs: string[];
204
222
  omitNeedsWork: boolean;
205
223
  decisionLabels: Record<string, string>;
224
+ holdAfter: string[];
206
225
  }
207
226
 
208
227
  const DEFAULT_SOURCE_ATTRS = ['source_1', 'source_2', 'source_3', 'sources'];
@@ -235,6 +254,7 @@ export function resolveReviewConfig(
235
254
  sourceAttrs: (config.sourceAttrs ?? DEFAULT_SOURCE_ATTRS).filter((a) => declared.has(a)),
236
255
  omitNeedsWork: config.omitNeedsWork ?? false,
237
256
  decisionLabels: config.decisionLabels ?? {},
257
+ holdAfter: config.holdAfter ?? [],
238
258
  };
239
259
  }
240
260
 
@@ -284,10 +304,15 @@ export interface ReviewDecision {
284
304
  * A decision only appears when its status target resolves (or, for Needs work,
285
305
  * when there's an entry stage or an edit verdict) — so a two-state or non-workflow
286
306
  * enum simply shows fewer buttons instead of guessing.
307
+ *
308
+ * `holdAfter` overrides the advance flag per decision key (bd startsim-t1t1k) —
309
+ * it changes only whether the queue moves on, never what a decision writes.
287
310
  */
288
311
  export function reviewDecisions(r: ResolvedReview): ReviewDecision[] {
289
312
  const verdictOf = (tone: VerdictTone): string | null =>
290
313
  r.verdicts.find((v) => v.tone === tone)?.value ?? null;
314
+ const held = new Set(r.holdAfter ?? []);
315
+ const advances = (key: string) => !held.has(key);
291
316
  const entry = r.statusChoices[0] ?? null;
292
317
  const out: ReviewDecision[] = [];
293
318
 
@@ -298,7 +323,7 @@ export function reviewDecisions(r: ResolvedReview): ReviewDecision[] {
298
323
  status: r.transitions.approve,
299
324
  verdict: verdictOf('good'),
300
325
  tone: 'good',
301
- advance: true,
326
+ advance: advances('approve'),
302
327
  shortcut: 'a',
303
328
  promptNote: false,
304
329
  });
@@ -325,7 +350,7 @@ export function reviewDecisions(r: ResolvedReview): ReviewDecision[] {
325
350
  status: r.transitions.reject,
326
351
  verdict: verdictOf('bad'),
327
352
  tone: 'bad',
328
- advance: true,
353
+ advance: advances('reject'),
329
354
  shortcut: 'x',
330
355
  promptNote: false,
331
356
  });
@@ -0,0 +1,155 @@
1
+ /**
2
+ * THE ONE SEAM (bd startsim-nq39j, epic startsim-ux39t).
3
+ *
4
+ * `withScope(client, scope)` returns a `CollectionClient` whose LIST reads carry
5
+ * the active scope and whose WRITES cannot cross one. Because every review
6
+ * surface in this package is client-injected — the app wrapper is ~10 LOC and
7
+ * hands in its authed tenant API — wrapping that one object scopes the whole
8
+ * surface at once. Nothing else in `collection/` knows what a scope is, and
9
+ * nothing else should learn: a second place that narrows is a second place that
10
+ * can forget to.
11
+ *
12
+ * THE WIRE SPELLING OF "EVERYTHING" IS SILENCE, and this is the decision the
13
+ * bead asked to be taken deliberately. The BROWSER URL spells it `?scope=all`
14
+ * (`SCOPE_ALL`), because there a missing parameter has to stay distinguishable
15
+ * from an asserted one — see `../scope/useScopeParam`. The WIRE cannot borrow
16
+ * that spelling and must not borrow the empty one either:
17
+ *
18
+ * • `?scope=all` is a 400 — every stored path begins with the separator, so a
19
+ * value that does not is refused as a value that could only match nothing.
20
+ * • `?scope=` (blank) is ALSO a 400, and it is the dangerous one:
21
+ * `normalize_container("")` returns `/`, so the obvious implementation reads
22
+ * an empty parameter as EVERY scoped row (bd startsim-mj609).
23
+ *
24
+ * So "no scope" OMITS THE KEY. That is not merely the safe encoding, it is the
25
+ * only correct one: an omitted parameter returns unscoped rows too, which today
26
+ * is every row, while `?scope=/` would return only rows that carry a path.
27
+ *
28
+ * A PATH IS PASSED THROUGH VERBATIM, INCLUDING A BAD ONE. A value that is not
29
+ * `all` and not blank goes on the wire as the caller wrote it, even without a
30
+ * leading separator, because the server refuses that and a refusal is a better
31
+ * answer than a client that quietly drops a narrowing it did not understand.
32
+ *
33
+ * REFERENTIALLY STABLE OR NOT AT ALL. The returned client is the query key's
34
+ * input and react-query's identity for the injected API; building a new one on
35
+ * every render is the same mistake as building a new `ScopeClient` on every
36
+ * render (see `../scope/types.ts`). Memoize it on the scope, or use
37
+ * `useScopedCollectionClient` from `@startsimpli/ui/scope`, which does.
38
+ */
39
+ import type { CollectionClient, EntityFilters, ListEntitiesOptions } from './types';
40
+
41
+ /**
42
+ * The query parameter the tenant recognises. It is a plain lowercase word, so
43
+ * unlike `attr.x__gte` it survives a camel/snake rewrite unharmed — but it still
44
+ * travels the {@link EntityFilters} path, because one narrowing mechanism is the
45
+ * whole point of `EntityFilters` existing.
46
+ */
47
+ export const SCOPE_FILTER = 'scope';
48
+
49
+ /**
50
+ * The URL's reserved word for "no bound at all". Recognised here as well as in
51
+ * `useScopeParam` because a caller can reach a raw `?scope=` value without going
52
+ * through the hook — a server component reading `searchParams`, a test, an app
53
+ * that keeps the scope somewhere else — and `all` must mean the same thing to
54
+ * every one of them.
55
+ */
56
+ const SCOPE_ALL_VALUE = 'all';
57
+
58
+ /**
59
+ * A `CollectionClient` that knows what it is bounded to.
60
+ *
61
+ * `scope` is exposed for ONE reason and it is not decoration: a cached read is
62
+ * only valid for the scope it was made under, so a consumer's react-query key
63
+ * has to name it. {@link scopeKey} is that read.
64
+ */
65
+ export interface ScopedCollectionClient extends CollectionClient {
66
+ /** The active path, or `null` for no bound. */
67
+ readonly scope: string | null;
68
+ }
69
+
70
+ /**
71
+ * Resolve any spelling of a scope to the one the wire uses: a path, or `null`.
72
+ *
73
+ * `null`, `undefined`, empty, whitespace and the reserved `all` all resolve to
74
+ * `null` — "no bound" — and `null` is spelled on the wire by sending nothing.
75
+ */
76
+ export function resolveScope(scope: string | null | undefined): string | null {
77
+ if (typeof scope !== 'string') return null;
78
+ const trimmed = scope.trim();
79
+ if (!trimmed || trimmed === SCOPE_ALL_VALUE) return null;
80
+ return trimmed;
81
+ }
82
+
83
+ /**
84
+ * The filters a scope contributes to a list read — `{}` when there is no bound.
85
+ *
86
+ * IT RETURNS AN ABSENT KEY, NOT AN EMPTY ONE. `{ scope: '' }` is a 400 that
87
+ * would be read as "the root" by an unlucky implementation; the only encoding of
88
+ * "everything" this package will ever put on the wire is no key at all.
89
+ */
90
+ export function scopeFilters(scope: string | null | undefined): EntityFilters {
91
+ const resolved = resolveScope(scope);
92
+ return resolved === null ? {} : { [SCOPE_FILTER]: resolved };
93
+ }
94
+
95
+ /**
96
+ * The cache-key fragment for a client's bound. `'all'` for no bound, so an
97
+ * UNWRAPPED client (a plain `CollectionClient`, which has no `scope` at all)
98
+ * keys identically to an explicitly unbounded one and an existing consumer's
99
+ * cache does not split in two for no reason.
100
+ */
101
+ export function scopeKey(client: Pick<ScopedCollectionClient, 'scope'> | CollectionClient | null | undefined): string {
102
+ const scope = (client as ScopedCollectionClient | null | undefined)?.scope;
103
+ return resolveScope(scope) ?? SCOPE_ALL_VALUE;
104
+ }
105
+
106
+ /**
107
+ * Bind a `CollectionClient` to a scope.
108
+ *
109
+ * LIST reads gain `?scope=<path>`; WRITES are handed on BYTE-FOR-BYTE. A write
110
+ * that carried the active scope would be able to MOVE a row: patch a row while
111
+ * `/clients/acme` is selected and it would silently leave `/clients/globex`.
112
+ * Changing a row's scope is an explicit act with its own affordance, never a
113
+ * side effect of editing it somewhere. (The bead's phrase "writes stamp it"
114
+ * belongs to CREATE, on the scope-aware upsert path — there is no create on this
115
+ * interface, so there is nothing here to stamp.)
116
+ */
117
+ export function withScope(
118
+ client: CollectionClient,
119
+ scope: string | null | undefined,
120
+ ): ScopedCollectionClient {
121
+ const resolved = resolveScope(scope);
122
+
123
+ return {
124
+ scope: resolved,
125
+
126
+ // Passed on with NO arguments at all. The schema read carries no `?type=`,
127
+ // and `?scope=` without `?type=` is a 400 (bd startsim-mj609) — so this is
128
+ // not an omission to tidy up later, it is the refusal being respected.
129
+ listTypes: () => client.listTypes(),
130
+
131
+ listAllEntities: (type: string, options?: ListEntitiesOptions) => {
132
+ // A type-less list read cannot carry a scope for the same reason
133
+ // `listTypes` cannot: the parameter resolves through ONE type's
134
+ // declaration. A blank type is already a bad request; adding a scope to it
135
+ // would turn a legible 400 into a confusing one.
136
+ const scopable = typeof type === 'string' && type.trim() !== '';
137
+ const applies = scopable && options?.scoped !== false;
138
+ const extra = applies ? scopeFilters(resolved) : {};
139
+ // Merged UNDER the caller's own filters, not over them: a consumer that
140
+ // deliberately sent its own `scope` (a fixture, a one-off drill-down)
141
+ // means it, and the decorator does not overrule it.
142
+ const filters: EntityFilters = { ...extra, ...(options?.filters ?? {}) };
143
+ const next: ListEntitiesOptions = { ...options, filters };
144
+ // No filters and no options in means no options through — an
145
+ // implementation that only ever took `(type)` sees exactly what it did
146
+ // before, so wrapping an old client is not a behaviour change.
147
+ if (Object.keys(filters).length === 0 && options === undefined) {
148
+ return client.listAllEntities(type);
149
+ }
150
+ return client.listAllEntities(type, next);
151
+ },
152
+
153
+ updateEntity: (id, input) => client.updateEntity(id, input),
154
+ };
155
+ }