@startsimpli/ui 0.4.119 → 0.4.121

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.119",
3
+ "version": "0.4.121",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -47,8 +47,8 @@
47
47
  "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
48
48
  "react": "^18.0.0 || ^19.0.0",
49
49
  "react-dom": "^18.0.0 || ^19.0.0",
50
- "@startsimpli/api": "^0.5.58",
51
50
  "@startsimpli/i18n": "^0.1.3",
51
+ "@startsimpli/api": "^0.5.58",
52
52
  "@startsimpli/auth": "^0.4.35"
53
53
  },
54
54
  "peerDependenciesMeta": {
@@ -123,8 +123,8 @@
123
123
  "tailwindcss": "^3.4.19",
124
124
  "tsup": "^8.5.1",
125
125
  "typescript": "^6.0.3",
126
- "@startsimpli/realtime": "0.0.2",
127
- "@startsimpli/i18n": "0.1.3"
126
+ "@startsimpli/i18n": "0.1.3",
127
+ "@startsimpli/realtime": "0.0.2"
128
128
  },
129
129
  "keywords": [
130
130
  "ui",
@@ -17,7 +17,7 @@ import { Badge } from '../ui/badge';
17
17
  import { notify } from '../toast/use-notify';
18
18
  import { MarkdownRenderer } from '../blog/MarkdownRenderer';
19
19
  import type { CollectionClient, EntityRecord } from './types';
20
- import { readData, toCamelKey } from './data';
20
+ import { declaredBlob, readData, writeData } from './data';
21
21
  import { scopeKey } from './scoped-client';
22
22
  import {
23
23
  groupDrafts,
@@ -127,8 +127,13 @@ export function DraftReviewWorkspace({ client }: DraftReviewWorkspaceProps) {
127
127
  // The FULL blob, spread from the record: PATCH REPLACES `data`, so an
128
128
  // attribute this board never touches — the scope path included — has to be
129
129
  // sent back to survive. A write is never a move between scopes.
130
- const data: Record<string, unknown> = { ...record.data };
131
- for (const [k, v] of Object.entries(changes)) data[toCamelKey(k)] = v;
130
+ // Re-keyed to the type's DECLARED attribute names before it goes back:
131
+ // the client cannot rebuild an underscore that sat before a digit, so a
132
+ // whole-blob write would rename `source_1` to `source1` (bd
133
+ // startsim-8hgmq.18, collection/data.ts).
134
+ let data: Record<string, unknown> = { ...record.data };
135
+ for (const [k, v] of Object.entries(changes)) data = writeData(data, k, v);
136
+ data = declaredBlob(data, (draftType?.attributes ?? []).map((a) => a.name));
132
137
  return client.updateEntity(record.id, { data });
133
138
  }
134
139
 
@@ -24,7 +24,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
24
24
  import { notify } from '../toast/use-notify';
25
25
  import { cn } from '../../lib/utils';
26
26
  import type { CollectionClient, EntityRecord, EntityTypeDef } from './types';
27
- import { toCamelKey } from './data';
27
+ import { writeData } from './data';
28
28
  import {
29
29
  applyDecisionToData,
30
30
  fieldStr,
@@ -63,6 +63,8 @@ export interface InlineReviewActionsProps {
63
63
 
64
64
  export function InlineReviewActions({ client, type, record, config, onSaved }: InlineReviewActionsProps) {
65
65
  const cfg = useMemo(() => resolveReviewConfig(type, config), [type, config]);
66
+ /** The type's declared attribute names — the spellings a write must send back. */
67
+ const declaredNames = useMemo(() => (type.attributes ?? []).map((a) => a.name), [type]);
66
68
  const decisions = useMemo(
67
69
  () => [...reviewDecisions(cfg)].sort((a, b) => ORDER.indexOf(a.key) - ORDER.indexOf(b.key)),
68
70
  [cfg],
@@ -89,18 +91,10 @@ export function InlineReviewActions({ client, type, record, config, onSaved }: I
89
91
  setAppliedKey(d.key); // optimistic
90
92
  setBusy(d.key);
91
93
  try {
92
- const data = applyDecisionToData(record.data, d, cfg);
93
- if (noteText !== undefined) {
94
- const camel = toCamelKey(cfg.noteAttr);
95
- const t = noteText.trim();
96
- if (t) {
97
- data[camel] = t;
98
- if (camel !== cfg.noteAttr) delete data[cfg.noteAttr];
99
- } else {
100
- delete data[camel];
101
- delete data[cfg.noteAttr];
102
- }
103
- }
94
+ // Declared names re-key the whole outgoing blob, so a decision here cannot
95
+ // rename `source_1` to `source1` (bd startsim-8hgmq.18, collection/data.ts).
96
+ let data = applyDecisionToData(record.data, d, cfg, declaredNames);
97
+ if (noteText !== undefined) data = writeData(data, cfg.noteAttr, noteText.trim());
104
98
  await client.updateEntity(record.id, { data });
105
99
  onSaved?.();
106
100
  return true;
@@ -42,7 +42,7 @@ import {
42
42
  import { notify } from '../toast/use-notify';
43
43
  import { cn } from '../../lib/utils';
44
44
  import type { CollectionClient, EntityRecord, EntityTypeDef } from './types';
45
- import { readData, toCamelKey } from './data';
45
+ import { declaredBlob, readData, writeData } from './data';
46
46
  import { parseSources } from './drafts';
47
47
  import {
48
48
  resolveReviewConfig,
@@ -111,6 +111,8 @@ function ReviewDrawerInner({
111
111
  }: ReviewDrawerProps & { record: EntityRecord }) {
112
112
  const cfg = useMemo(() => resolveReviewConfig(type, config), [type, config]);
113
113
  const decisions = useMemo(() => reviewDecisions(cfg), [cfg]);
114
+ /** The type's declared attribute names — the spellings a write must send back. */
115
+ const declaredNames = useMemo(() => (type.attributes ?? []).map((a) => a.name), [type]);
114
116
  const [mode, setMode] = useState<'review' | 'edit'>('review');
115
117
  // Local optimistic copy so a click reflects instantly (the parent also refetches).
116
118
  const [data, setData] = useState<Record<string, unknown>>(() => ({ ...record.data }));
@@ -140,19 +142,20 @@ function ReviewDrawerInner({
140
142
  */
141
143
  const decided = useMemo(() => ({ ...record, data }), [record, data]);
142
144
 
143
- /** Merge changes into the (camelCased) blob and PATCH; the backend REPLACES data. */
145
+ /**
146
+ * Merge changes into the blob and PATCH; the backend REPLACES data.
147
+ *
148
+ * The whole blob goes back, so it is re-keyed to the type's DECLARED attribute
149
+ * names first (`declaredBlob`). Without that, every decision renames
150
+ * `source_1` to `source1` on the way out — the client cannot rebuild an
151
+ * underscore that sat before a digit. See collection/data.ts and bd
152
+ * startsim-8hgmq.18. It also REPAIRS: a row that already holds `source1` is
153
+ * written back as `source_1`.
154
+ */
144
155
  async function patch(changes: Record<string, unknown>, key: string, advance = false) {
145
- const nextData: Record<string, unknown> = { ...data };
146
- for (const [k, v] of Object.entries(changes)) {
147
- const camel = toCamelKey(k);
148
- if (v === '' || v == null) {
149
- delete nextData[camel];
150
- delete nextData[k];
151
- } else {
152
- nextData[camel] = v;
153
- if (camel !== k) delete nextData[k];
154
- }
155
- }
156
+ let nextData: Record<string, unknown> = { ...data };
157
+ for (const [k, v] of Object.entries(changes)) nextData = writeData(nextData, k, v);
158
+ nextData = declaredBlob(nextData, declaredNames);
156
159
  setData(nextData); // optimistic
157
160
  setBusy(key);
158
161
  try {
@@ -17,7 +17,7 @@ import { Textarea } from '../ui/textarea';
17
17
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
18
18
  import { notify } from '../toast/use-notify';
19
19
  import type { CollectionClient, EntityRecord, EntityTypeDef } from './types';
20
- import { readData, toCamelKey } from './data';
20
+ import { declaredBlob, readData, writeData } from './data';
21
21
  import { scopeKey } from './scoped-client';
22
22
 
23
23
  const STATUSES = ['suggested', 'ready', 'rejected', 'written'] as const;
@@ -104,8 +104,13 @@ export function TopicReviewWorkspace({ client, markets = [], renderDetail }: Top
104
104
  }, [records]);
105
105
 
106
106
  async function patch(record: EntityRecord, changes: Record<string, unknown>) {
107
- const data: Record<string, unknown> = { ...record.data };
108
- for (const [k, v] of Object.entries(changes)) data[toCamelKey(k)] = v;
107
+ // Re-keyed to the type's DECLARED attribute names before it goes back:
108
+ // the client cannot rebuild an underscore that sat before a digit, so a
109
+ // whole-blob write would rename `source_1` to `source1` (bd
110
+ // startsim-8hgmq.18, collection/data.ts).
111
+ let data: Record<string, unknown> = { ...record.data };
112
+ for (const [k, v] of Object.entries(changes)) data = writeData(data, k, v);
113
+ data = declaredBlob(data, (topicType?.attributes ?? []).map((a) => a.name));
109
114
  try {
110
115
  // The FULL blob, spread from the record — so an attribute this workspace
111
116
  // never touches (the scope path among them) survives a PATCH that
@@ -0,0 +1,248 @@
1
+ /**
2
+ * THE WIRE ROUND TRIP: a declared attribute whose name has an underscore before
3
+ * a DIGIT must survive being read into a surface and written back out.
4
+ *
5
+ * bd startsim-8hgmq.18. The shared API client transforms keys in BOTH directions
6
+ * and the pair is not an involution:
7
+ *
8
+ * snakeToCamel: /_([a-z0-9])/ -> uppercase so source_1 -> source1
9
+ * camelToSnake: /[A-Z]/ -> _lowercase so source1 -> source1
10
+ *
11
+ * Uppercasing a digit is a no-op, so the underscore is destroyed on the way IN
12
+ * and cannot be reconstructed on the way OUT. `team_verdict` survives only
13
+ * because `teamVerdict` has a hump to split on. Any surface that reads the
14
+ * camelised blob and PATCHes the WHOLE thing back therefore RENAMES the declared
15
+ * attribute `source_1` to the undeclared `source1` — which the app still renders
16
+ * (readData tries both spellings) but which `attr.source_1=` server-side filters
17
+ * and `redenormalize_attributes` cannot see.
18
+ *
19
+ * WHY THE ASSERTIONS RUN ON THE WIRE, not on the surface's output object. The
20
+ * outgoing blob is camelCase by construction; a test that stops there passes
21
+ * while the wire is still wrong. It also cannot see the OTHER failure: two keys
22
+ * in the outgoing object that camelToSnake collapses onto one wire key, where
23
+ * only insertion order decides the survivor. So every case here goes
24
+ *
25
+ * stored (wire) -> snakeToCamel -> surface write path -> camelToSnake -> wire
26
+ *
27
+ * using the REAL transforms imported from @startsimpli/api, so the test cannot
28
+ * drift from the client it is describing.
29
+ *
30
+ * THE FIX UNDER TEST is not "camelise harder": it is to write declared
31
+ * attributes back under their DECLARED (snake) name. camelToSnake is the
32
+ * identity on a pure-snake key — `source_1` -> `source_1`, `team_verdict` ->
33
+ * `team_verdict` — so a blob keyed by declared names round-trips exactly, with
34
+ * no change to the shared client and no change for any other app.
35
+ */
36
+ import { render, screen, waitFor } from '@testing-library/react';
37
+ import userEvent from '@testing-library/user-event';
38
+ import { snakeToCamel, camelToSnake } from '@startsimpli/api';
39
+
40
+ import { declaredBlob, writeData } from '../data';
41
+ import { ReviewDrawer } from '../ReviewDrawer';
42
+ import type { CollectionClient, EntityRecord, EntityTypeDef } from '../types';
43
+
44
+ /** The blob as the tenant stores it — the n8n writer's spelling. */
45
+ const STORED: Record<string, unknown> = {
46
+ title: 'Gulf Conflict Risk Checklist',
47
+ status: 'suggested',
48
+ team_verdict: '',
49
+ source_1: 'https://example.com/a',
50
+ source_2: 'https://example.com/b',
51
+ source_3: 'https://example.com/c',
52
+ _origin: 'n8n',
53
+ source_meta: { verified: true },
54
+ };
55
+
56
+ /** What the browser client hands a component after transforming the response. */
57
+ function asRead(stored: Record<string, unknown>): Record<string, unknown> {
58
+ return snakeToCamel(stored) as Record<string, unknown>;
59
+ }
60
+
61
+ /** What the tenant actually stores after the client transforms the request. */
62
+ function asStored(outgoing: Record<string, unknown>): Record<string, unknown> {
63
+ return camelToSnake(outgoing) as Record<string, unknown>;
64
+ }
65
+
66
+ const TOPIC_TYPE: EntityTypeDef = {
67
+ id: 'tt',
68
+ key: 'topic',
69
+ label: 'Topic',
70
+ attributes: [
71
+ { id: '1', name: 'title', dataType: 'text', required: false, config: {} },
72
+ {
73
+ id: '2',
74
+ name: 'status',
75
+ dataType: 'enum',
76
+ required: false,
77
+ config: { choices: ['suggested', 'ready', 'rejected', 'written'] },
78
+ },
79
+ { id: '3', name: 'team_verdict', dataType: 'text', required: false, config: {} },
80
+ { id: '4', name: 'team_notes', dataType: 'longtext', required: false, config: {} },
81
+ { id: '5', name: 'source_1', dataType: 'text', required: false, config: {} },
82
+ { id: '6', name: 'source_2', dataType: 'text', required: false, config: {} },
83
+ { id: '7', name: 'source_3', dataType: 'text', required: false, config: {} },
84
+ ],
85
+ } as unknown as EntityTypeDef;
86
+
87
+ const DECLARED = TOPIC_TYPE.attributes.map((a) => a.name);
88
+
89
+ // ── the pure helpers ────────────────────────────────────────────────────────
90
+
91
+ describe('the transform pair is lossy, which is why this bug exists', () => {
92
+ it('destroys the underscore before a digit and cannot restore it', () => {
93
+ expect(snakeToCamel({ source_1: 'x' })).toEqual({ source1: 'x' });
94
+ expect(camelToSnake({ source1: 'x' })).toEqual({ source1: 'x' });
95
+ });
96
+
97
+ it('is the identity on a declared snake key, which is what the fix relies on', () => {
98
+ for (const name of DECLARED) {
99
+ expect(camelToSnake({ [name]: 'x' })).toEqual({ [name]: 'x' });
100
+ }
101
+ });
102
+ });
103
+
104
+ describe('declaredBlob', () => {
105
+ it('renames a camel-collapsed key back to the declared attribute name', () => {
106
+ const out = declaredBlob(asRead(STORED), DECLARED);
107
+ expect(asStored(out)).toMatchObject({
108
+ source_1: 'https://example.com/a',
109
+ source_2: 'https://example.com/b',
110
+ source_3: 'https://example.com/c',
111
+ });
112
+ expect(asStored(out)).not.toHaveProperty('source1');
113
+ });
114
+
115
+ it('leaves the camel spelling behind — both keys would collide on the wire', () => {
116
+ const out = declaredBlob(asRead(STORED), DECLARED);
117
+ expect(out).not.toHaveProperty('source1');
118
+ });
119
+
120
+ it('repairs an ALREADY-corrupted row on the next write', () => {
121
+ const corrupted = { title: 'x', source1: 'https://example.com/a', status: 'ready' };
122
+ const out = declaredBlob(asRead(corrupted), DECLARED);
123
+ expect(asStored(out)).toEqual({
124
+ title: 'x',
125
+ source_1: 'https://example.com/a',
126
+ status: 'ready',
127
+ });
128
+ });
129
+
130
+ it('prefers the declared spelling when a row carries BOTH (JSONB orders the short key first, so the declared value is the one the client kept)', () => {
131
+ // The client collapses both onto `source1`; the later (declared) entry wins.
132
+ const both = { source1: 'stale', source_1: 'fresh' };
133
+ expect(asRead(both)).toEqual({ source1: 'fresh' });
134
+ expect(asStored(declaredBlob(asRead(both), DECLARED))).toEqual({ source_1: 'fresh' });
135
+ });
136
+
137
+ it('does not touch undeclared blob keys, which already round-trip exactly', () => {
138
+ const out = asStored(declaredBlob(asRead(STORED), DECLARED));
139
+ expect(out).toMatchObject({ _origin: 'n8n', source_meta: { verified: true } });
140
+ });
141
+
142
+ it('is idempotent', () => {
143
+ const once = declaredBlob(asRead(STORED), DECLARED);
144
+ expect(declaredBlob(once, DECLARED)).toEqual(once);
145
+ });
146
+
147
+ it('drops a key nothing declared away only when it is a lossy alias of a declared one', () => {
148
+ const out = declaredBlob({ ...asRead(STORED), somethingElse: 1 }, DECLARED);
149
+ expect(out).toHaveProperty('somethingElse', 1);
150
+ });
151
+ });
152
+
153
+ describe('writeData', () => {
154
+ it('writes under the declared name and removes the camel alias', () => {
155
+ const blob = writeData(asRead(STORED), 'source_1', 'https://example.com/new');
156
+ expect(blob).not.toHaveProperty('source1');
157
+ expect(asStored(blob)).toMatchObject({ source_1: 'https://example.com/new' });
158
+ });
159
+
160
+ it('deletes every spelling when the value is blank', () => {
161
+ const blob = writeData(asRead(STORED), 'source_1', '');
162
+ expect(blob).not.toHaveProperty('source1');
163
+ expect(blob).not.toHaveProperty('source_1');
164
+ });
165
+
166
+ it('round-trips a hump-bearing declared name without minting a second key', () => {
167
+ const blob = writeData(asRead(STORED), 'team_verdict', 'approved');
168
+ expect(Object.keys(asStored(blob)).filter((k) => k.includes('verdict'))).toEqual([
169
+ 'team_verdict',
170
+ ]);
171
+ expect(asStored(blob).team_verdict).toBe('approved');
172
+ });
173
+ });
174
+
175
+ // ── the surface: a drawer decision ──────────────────────────────────────────
176
+
177
+ function stubClient(saved: Record<string, unknown>[]): CollectionClient {
178
+ return {
179
+ listTypes: jest.fn(async () => ({ count: 1, next: null, previous: null, results: [TOPIC_TYPE] })),
180
+ listAllEntities: jest.fn(async () => []),
181
+ updateEntity: jest.fn(async (_id, input: { data?: Record<string, unknown> }) => {
182
+ saved.push(input.data ?? {});
183
+ return {} as EntityRecord;
184
+ }),
185
+ } as unknown as CollectionClient;
186
+ }
187
+
188
+ const RECORD: EntityRecord = {
189
+ id: 'topic-1',
190
+ entityType: 'topic',
191
+ name: 'Gulf Conflict Risk Checklist',
192
+ data: asRead(STORED),
193
+ } as unknown as EntityRecord;
194
+
195
+ describe('ReviewDrawer decision — the surface corrupting topics today', () => {
196
+ it('keeps source_1 on the wire after an Approve', async () => {
197
+ const saved: Record<string, unknown>[] = [];
198
+ render(
199
+ <ReviewDrawer
200
+ client={stubClient(saved)}
201
+ type={TOPIC_TYPE}
202
+ records={[RECORD]}
203
+ record={RECORD}
204
+ onClose={() => {}}
205
+ onNavigate={() => {}}
206
+ />,
207
+ );
208
+
209
+ await userEvent.click(await screen.findByRole('button', { name: /approve/i }));
210
+ await waitFor(() => expect(saved).toHaveLength(1));
211
+
212
+ const wire = asStored(saved[0]!);
213
+ expect(wire).toHaveProperty('source_1', 'https://example.com/a');
214
+ expect(wire).toHaveProperty('source_2', 'https://example.com/b');
215
+ expect(wire).toHaveProperty('source_3', 'https://example.com/c');
216
+ expect(wire).not.toHaveProperty('source1');
217
+ expect(wire).not.toHaveProperty('source2');
218
+ expect(wire).not.toHaveProperty('source3');
219
+ // the decision itself still lands, under its declared names
220
+ expect(wire.status).toBe('ready');
221
+ expect(wire.team_verdict).toBeTruthy();
222
+ });
223
+
224
+ it('repairs an already-renamed row rather than re-minting the bad key', async () => {
225
+ const saved: Record<string, unknown>[] = [];
226
+ const corrupted = {
227
+ ...RECORD,
228
+ data: asRead({ title: 't', status: 'written', source1: 'https://example.com/a' }),
229
+ } as EntityRecord;
230
+ render(
231
+ <ReviewDrawer
232
+ client={stubClient(saved)}
233
+ type={TOPIC_TYPE}
234
+ records={[corrupted]}
235
+ record={corrupted}
236
+ onClose={() => {}}
237
+ onNavigate={() => {}}
238
+ />,
239
+ );
240
+
241
+ await userEvent.click(await screen.findByRole('button', { name: /reject/i }));
242
+ await waitFor(() => expect(saved).toHaveLength(1));
243
+
244
+ const wire = asStored(saved[0]!);
245
+ expect(wire).toHaveProperty('source_1', 'https://example.com/a');
246
+ expect(wire).not.toHaveProperty('source1');
247
+ });
248
+ });
@@ -1,17 +1,145 @@
1
1
  /**
2
- * camelCase-aware read/write helpers for an Entity.data blob. The tenant API
3
- * client camelCases response keys (incl. the data blob), so a snake_case attr
4
- * `team_verdict` lands as `teamVerdict`; try the camel form first, then the raw
5
- * key. Pure + framework-free.
2
+ * camelCase-aware read/write helpers for an Entity.data blob.
3
+ *
4
+ * ── WHY THIS MODULE EXISTS AT ALL ───────────────────────────────────────────
5
+ *
6
+ * The tenant API client transforms keys in BOTH directions, recursively, and
7
+ * that includes the `data` blob — which is not an API contract but whatever
8
+ * attribute names the tenant declared. So the declared attribute `team_verdict`
9
+ * reaches a component as `teamVerdict`. {@link readData} tries the camel form
10
+ * first and the raw name second, so a component reads either shape.
11
+ *
12
+ * ── THE TRAP THIS MODULE ALSO EXISTS TO CLOSE (bd startsim-8hgmq.18) ────────
13
+ *
14
+ * The transform pair is NOT an involution:
15
+ *
16
+ * snake -> camel: /_([a-z0-9])/ -> uppercase so source_1 -> source1
17
+ * camel -> snake: /[A-Z]/ -> _lowercase so source1 -> source1
18
+ *
19
+ * Uppercasing a DIGIT is a no-op, so the underscore is destroyed on the way in
20
+ * and there is no hump to split on on the way back out. `team_verdict` survives
21
+ * only because `teamVerdict` has one. Any surface that reads the camelised blob
22
+ * and PATCHes the WHOLE thing back therefore RENAMES the declared attribute
23
+ * `source_1` to the undeclared `source1`. Nothing looks broken — `readData`
24
+ * finds either spelling — but the rename is invisible to `attr.source_1=`
25
+ * server-side filters (which this tenant answers by matching NOTHING, silently),
26
+ * to `redenormalize_attributes`, and to anything reading the row outside the app.
27
+ *
28
+ * THE FIX IS NOT TO CAMELISE HARDER. camelToSnake is the IDENTITY on a pure
29
+ * snake_case key — `source_1` -> `source_1`, `team_verdict` -> `team_verdict` —
30
+ * so a blob written back under its DECLARED names round-trips exactly, with no
31
+ * change to the shared client and no change for any other app. That is what
32
+ * {@link declaredBlob} and {@link writeData} do; every surface that writes a
33
+ * whole blob back must run it through one of them.
34
+ *
35
+ * WHAT THIS CANNOT FIX, stated so it is not mistaken for coverage: a declared
36
+ * name that is not itself snake_case (`sourceMeta`, `URL`) is mangled by
37
+ * camelToSnake no matter what a surface writes, because the surface never sees a
38
+ * wire key it could restore. Only the client can protect those, by not
39
+ * transforming the blob at all. Measured 2026-09-09 on marketing-agents: zero
40
+ * such declared names exist, and the whole lossy set is {source_1, source_2,
41
+ * source_3} on the topic type.
42
+ *
43
+ * Pure + framework-free.
6
44
  */
7
45
  import type { EntityRecord } from './types';
8
46
 
47
+ /** The spelling the shared client produces from a wire key. */
9
48
  export function toCamelKey(name: string): string {
10
49
  return name.replace(/_+([a-z0-9])/g, (_m, c: string) => c.toUpperCase());
11
50
  }
12
51
 
52
+ /**
53
+ * The spelling the shared client sends to the wire for a key.
54
+ *
55
+ * A local mirror of `@startsimpli/api` utils/case-transform so `collection/`
56
+ * stays free of a runtime dependency on the API package. It cannot drift
57
+ * unnoticed: __tests__/blob-round-trip.test.ts imports the REAL pair and pins
58
+ * these against it.
59
+ */
60
+ function toWireKey(key: string): string {
61
+ return key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
62
+ }
63
+
64
+ /**
65
+ * True when a declared attribute name does NOT survive the client's
66
+ * snake -> camel -> snake round trip, i.e. its camel spelling is a LOSSY ALIAS
67
+ * that no longer names the attribute. Today that is exactly the names with an
68
+ * underscore before a digit (`source_1`).
69
+ */
70
+ export function isLossyName(name: string): boolean {
71
+ return toWireKey(toCamelKey(name)) !== name;
72
+ }
73
+
13
74
  export function readData(data: EntityRecord['data'] | undefined, name: string): unknown {
14
75
  if (!data) return undefined;
15
76
  const bag = data as Record<string, unknown>;
16
77
  return bag[toCamelKey(name)] ?? bag[name];
17
78
  }
79
+
80
+ /**
81
+ * Set one declared attribute in a blob, under the spelling that survives the
82
+ * wire, and remove every other spelling of it.
83
+ *
84
+ * A blank value (`''`, null, undefined) DELETES the attribute — the behaviour
85
+ * every caller here already had.
86
+ *
87
+ * WHICH SPELLING IT WRITES. A lossy name goes in under its DECLARED (snake)
88
+ * name, which camelToSnake then passes through untouched. Any other name keeps
89
+ * the camel spelling the blob already uses, because that is what round-trips for
90
+ * it and because writing a second spelling alongside the spread blob leaves two
91
+ * keys that COLLIDE on the wire, with only key order deciding the survivor. That
92
+ * is why `_edit_history` is written as `EditHistory` and must stay that way.
93
+ */
94
+ export function writeData(
95
+ data: Record<string, unknown> | undefined,
96
+ name: string,
97
+ value: unknown,
98
+ ): Record<string, unknown> {
99
+ const out: Record<string, unknown> = { ...(data ?? {}) };
100
+ const camel = toCamelKey(name);
101
+ delete out[camel];
102
+ delete out[name];
103
+ if (value === '' || value == null) return out;
104
+ out[isLossyName(name) ? name : camel] = value;
105
+ return out;
106
+ }
107
+
108
+ /**
109
+ * Re-key a whole outgoing blob so every DECLARED attribute goes back to the
110
+ * tenant under its declared name. Run it on any blob that is about to be PATCHed
111
+ * in full — the backend REPLACES `data`, so the spread carries every key the
112
+ * client mangled on the way in, not just the ones the surface touched.
113
+ *
114
+ * IT ALSO REPAIRS. A row already holding `source1` is written back as `source_1`,
115
+ * so the corruption drains as records are worked rather than needing a migration
116
+ * for rows anyone still touches.
117
+ *
118
+ * Only LOSSY names are re-keyed. Everything else already survives the round trip
119
+ * unchanged, so rewriting it would churn the blob for no gain — and undeclared
120
+ * keys (`_origin`, `review`, `notes`, `sourceMeta`) are left exactly as they
121
+ * arrived, because the surface has no way to know their wire spelling.
122
+ *
123
+ * @param names the declared attribute names, i.e. `type.attributes.map(a => a.name)`
124
+ */
125
+ export function declaredBlob(
126
+ data: Record<string, unknown> | undefined,
127
+ names: Iterable<string>,
128
+ ): Record<string, unknown> {
129
+ const out: Record<string, unknown> = { ...(data ?? {}) };
130
+ for (const name of names) {
131
+ if (!isLossyName(name)) continue;
132
+ const camel = toCamelKey(name);
133
+ const hasDeclared = Object.prototype.hasOwnProperty.call(out, name);
134
+ const hasCamel = Object.prototype.hasOwnProperty.call(out, camel);
135
+ if (!hasDeclared && !hasCamel) continue;
136
+ // The declared spelling wins a tie. It is also the one the client already
137
+ // kept: a row carrying both collapses onto the camel key on the way in and
138
+ // JSONB orders the SHORTER key first, so the declared value is the later
139
+ // entry and last-write-wins hands it to the app.
140
+ const value = hasDeclared ? out[name] : out[camel];
141
+ delete out[camel];
142
+ out[name] = value;
143
+ }
144
+ return out;
145
+ }
@@ -50,7 +50,7 @@ export {
50
50
  SCOPE_FILTER,
51
51
  type ScopedCollectionClient,
52
52
  } from './scoped-client';
53
- export { readData, toCamelKey } from './data';
53
+ export { readData, toCamelKey, writeData, declaredBlob, isLossyName } from './data';
54
54
  export {
55
55
  parseSources,
56
56
  normUrl,
@@ -15,7 +15,7 @@
15
15
  * Nothing here is tenant-specific — the defaults are conventions, not requirements.
16
16
  */
17
17
  import type { AttributeDef, EntityTypeDef } from './types';
18
- import { readData, toCamelKey } from './data';
18
+ import { declaredBlob, readData, writeData } from './data';
19
19
 
20
20
  // ---- keyboard guard (lifted so the drawer + any review surface share it) ----
21
21
 
@@ -368,17 +368,23 @@ export function applyDecisionToData(
368
368
  data: Record<string, unknown> | undefined,
369
369
  decision: ReviewDecision,
370
370
  cfg: ResolvedReview,
371
+ /**
372
+ * The type's declared attribute names. Passing them re-keys the WHOLE outgoing
373
+ * blob to the declared spellings, which is what stops a decision renaming
374
+ * `source_1` to `source1` on the way back to the tenant (bd startsim-8hgmq.18,
375
+ * collection/data.ts). Optional only so an existing caller cannot break; every
376
+ * caller that has a type SHOULD pass them.
377
+ */
378
+ declaredNames: Iterable<string> = [],
371
379
  ): Record<string, unknown> {
372
- const next: Record<string, unknown> = { ...(data ?? {}) };
380
+ let next: Record<string, unknown> = { ...(data ?? {}) };
373
381
  const set = (attr: string, value: string | null) => {
374
382
  if (value == null || value === '') return;
375
- const camel = toCamelKey(attr);
376
- next[camel] = value;
377
- if (camel !== attr) delete next[attr];
383
+ next = writeData(next, attr, value);
378
384
  };
379
385
  set(cfg.statusName, decision.status);
380
386
  set(cfg.verdictAttr, decision.verdict);
381
- return next;
387
+ return declaredBlob(next, declaredNames);
382
388
  }
383
389
 
384
390
  // ---- small read helpers used by the component (kept here so they're tested) ----