Skip to content

Commit 22bb4f3

Browse files
os-zhuangclaude
andauthored
fix(user-state): upsert sys_user_preference by (user_id, key) (#2116)
The ObjectStack-backed UserDataAdapter (ui.recent / ui.favorites) tripped the UNIQUE(user_id, key) constraint on sys_user_preference: every other navigation logged "Insert operation failed" server-side. A fresh `recent` adapter is created whenever dataSource/user changes (UserStateBridge), so cachedRowId starts null. Rapid navigations fire overlapping debounced save() flushes that both findExisting()->null-> create() — the first insert wins, the second hits the constraint. And when the row already existed but wasn't found, create() threw with no recovery. - Extract an upsert() helper; on create() failure (UNIQUE violation), re-find and update in place — a real (user_id, key) upsert. - Serialize overlapping save() calls via a saveChain so the second save sees the cachedRowId the first set and updates instead of inserting. - save() still swallows errors (provider falls back to localStorage). Tests: + insert-failure recovery, + concurrent-save serialization (13 pass). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9509699 commit 22bb4f3

2 files changed

Lines changed: 117 additions & 37 deletions

File tree

packages/data-objectstack/src/userState.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,59 @@ describe('createObjectStackUserStateAdapter', () => {
221221
}));
222222
});
223223

224+
it('recovers from a UNIQUE(user_id, key) insert failure by updating in place', async () => {
225+
// First findExisting() (no cached id) misses; create() loses the race and
226+
// throws; the recovery findExisting() now sees the row → update.
227+
const ds = mockDataSource({
228+
find: vi.fn()
229+
.mockResolvedValueOnce({ data: [] })
230+
.mockResolvedValueOnce({ data: [{ id: 'row-7', user_id: 'u', key: 'k', value: [] }] }) as any,
231+
create: vi.fn().mockRejectedValue(new Error('UNIQUE constraint failed')) as any,
232+
});
233+
234+
const adapter = createObjectStackUserStateAdapter({
235+
dataSource: ds,
236+
userId: 'u',
237+
key: 'k',
238+
});
239+
240+
await adapter.save([{ id: 'q' } as any]);
241+
242+
expect(ds.create).toHaveBeenCalledTimes(1);
243+
expect(ds.update).toHaveBeenCalledWith('sys_user_preference', 'row-7', {
244+
value: [{ id: 'q' }],
245+
updated_at: expect.any(String),
246+
});
247+
});
248+
249+
it('serializes concurrent saves so only one insert happens', async () => {
250+
// Both saves start before either resolves. Without serialization both
251+
// would findExisting()→[]→create() and the second would trip the
252+
// UNIQUE constraint. With the save chain, the second waits, sees the
253+
// cached row id from the first, and updates.
254+
const ds = mockDataSource({
255+
find: vi.fn().mockResolvedValue({ data: [] }) as any,
256+
create: vi.fn().mockResolvedValue({ id: 'row-1' }) as any,
257+
});
258+
259+
const adapter = createObjectStackUserStateAdapter({
260+
dataSource: ds,
261+
userId: 'u',
262+
key: 'ui.recent',
263+
});
264+
265+
await Promise.all([
266+
adapter.save([{ id: 'a' } as any]),
267+
adapter.save([{ id: 'a' }, { id: 'b' }] as any),
268+
]);
269+
270+
expect(ds.create).toHaveBeenCalledTimes(1);
271+
expect(ds.update).toHaveBeenCalledWith('sys_user_preference', 'row-1', {
272+
value: [{ id: 'a' }, { id: 'b' }],
273+
updated_at: expect.any(String),
274+
});
275+
});
276+
224277
it('swallows errors when save() throws', async () => {
225278
const onError = vi.fn();
226279
const ds = mockDataSource({

packages/data-objectstack/src/userState.ts

Lines changed: 64 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ export function createObjectStackUserStateAdapter<T = unknown>(
104104
// without re-querying. Reset on every successful load.
105105
let cachedRowId: string | number | null = null;
106106

107+
// Serializes overlapping save() calls. A fresh adapter is created whenever
108+
// the data source / user changes (see UserStateBridge), so its cachedRowId
109+
// starts null; rapid navigations then fire debounced flushes that can race —
110+
// two saves each findExisting()→null→create() and the second trips the
111+
// UNIQUE(user_id, key) constraint. Chaining writes means the second save
112+
// sees the cachedRowId the first one set and updates instead of inserting.
113+
let saveChain: Promise<void> = Promise.resolve();
114+
107115
const findExisting = async (): Promise<UserPreferenceRecord | null> => {
108116
// NOTE: `QueryParams` uses OData-style `$`-prefixed keys. Using the bare
109117
// names (`filter`, `limit`) silently drops the predicate at the
@@ -146,6 +154,55 @@ export function createObjectStackUserStateAdapter<T = unknown>(
146154
return [];
147155
};
148156

157+
// Upsert by (user_id, key): update in place when the row is known/found,
158+
// otherwise insert — and if the insert loses a UNIQUE(user_id, key) race
159+
// (a concurrent writer created the row, or the backend dropped our find
160+
// predicate), recover by re-finding and updating rather than surfacing the
161+
// failed insert.
162+
const upsert = async (items: T[]): Promise<void> => {
163+
const now = new Date().toISOString();
164+
165+
// Fast path: we already know the row id from a previous load/save.
166+
if (cachedRowId !== null) {
167+
try {
168+
await dataSource.update(resource, cachedRowId, { value: items, updated_at: now });
169+
return;
170+
} catch (updateError) {
171+
// Row may have been deleted server-side — fall through to find/insert.
172+
cachedRowId = null;
173+
onError('save', updateError);
174+
}
175+
}
176+
177+
const existing = await findExisting();
178+
if (existing && existing.id !== undefined && existing.id !== null) {
179+
cachedRowId = existing.id;
180+
await dataSource.update(resource, existing.id, { value: items, updated_at: now });
181+
return;
182+
}
183+
184+
try {
185+
const created = await dataSource.create(resource, {
186+
user_id: userId,
187+
key,
188+
value: items,
189+
updated_at: now,
190+
});
191+
const newId = (created as UserPreferenceRecord | undefined)?.id;
192+
if (newId !== undefined && newId !== null) cachedRowId = newId;
193+
} catch (createError) {
194+
// The row already exists (UNIQUE(user_id, key) violation) — re-find and
195+
// update in place. This turns the insert into a real upsert.
196+
const recovered = await findExisting();
197+
if (recovered && recovered.id !== undefined && recovered.id !== null) {
198+
cachedRowId = recovered.id;
199+
await dataSource.update(resource, recovered.id, { value: items, updated_at: now });
200+
return;
201+
}
202+
throw createError;
203+
}
204+
};
205+
149206
return {
150207
async load(): Promise<T[]> {
151208
try {
@@ -163,45 +220,15 @@ export function createObjectStackUserStateAdapter<T = unknown>(
163220
},
164221

165222
async save(items: T[]): Promise<void> {
166-
try {
167-
const now = new Date().toISOString();
168-
// Fast path: we already know the row id from a previous load/save.
169-
if (cachedRowId !== null) {
170-
try {
171-
await dataSource.update(resource, cachedRowId, {
172-
value: items,
173-
updated_at: now,
174-
});
175-
return;
176-
} catch (updateError) {
177-
// Row may have been deleted server-side — fall through to insert.
178-
cachedRowId = null;
179-
onError('save', updateError);
180-
}
181-
}
182-
183-
const existing = await findExisting();
184-
if (existing && existing.id !== undefined && existing.id !== null) {
185-
cachedRowId = existing.id;
186-
await dataSource.update(resource, existing.id, {
187-
value: items,
188-
updated_at: now,
189-
});
190-
return;
191-
}
192-
193-
const created = await dataSource.create(resource, {
194-
user_id: userId,
195-
key,
196-
value: items,
197-
updated_at: now,
198-
});
199-
const newId = (created as UserPreferenceRecord | undefined)?.id;
200-
if (newId !== undefined && newId !== null) cachedRowId = newId;
201-
} catch (error) {
223+
// Chain onto any in-flight save so concurrent flushes upsert serially
224+
// rather than racing into two inserts (see `saveChain` above). Each link
225+
// catches its own errors so one failure never poisons the chain.
226+
const run = saveChain.then(() => upsert(items)).catch(error => {
202227
onError('save', error);
203228
// Swallow — provider falls back to localStorage as source of truth.
204-
}
229+
});
230+
saveChain = run;
231+
return run;
205232
},
206233
};
207234
}

0 commit comments

Comments
 (0)