Client ID Upsert
Honour client-generated IDs when upserting entities — look up by ID, never mutate a loaded row's primary key, and replace rather than re-key when a natural key collides.
Install in BlintOpens the Blint desktop app and adds this pattern to your project. Don't have Blint?
Clients that follow local-first persistence mint record IDs on the device, before the server has ever seen the record (see the Local-First Persistence pattern for the client half). The server must honour these IDs so that subsequent requests can find and update the same rows by ID.
Rules
Never mutate a loaded entity's primary key.
existing.id = input.idfollowed byrepo.save(existing)breaks ORM identity tracking (TypeORM and most active-record ORMs) — it attempts an INSERT instead of an UPDATE, causing duplicate-key violations.Lookup by ID first. When the client supplies an
id, look up by{ id, userId }. If found, update fields in place. If not found, create with the client ID.Natural-key entities need a replace path. Entities with a UNIQUE constraint on a natural key (e.g.
(userId, day)for a one-row-per-day record) may have an existing row under a different ID. When the IDs differ: delete the old row, then create a new one with the client ID.
Patterns
ID-keyed entities
No natural-key uniqueness to worry about. Lookup by id:
const existing = input.id
? await repo.findOne({ where: { id: input.id, userId } })
: null;
if (existing) {
Object.assign(existing, { /* updated fields */ });
await repo.save(existing);
} else {
await repo.save(repo.create({ id: input.id, userId, ...fields }));
}
Natural-key entities
Lookup by natural key; replace when ID differs:
const existing = await repo.findOne({ where: { userId, day } });
if (existing) {
if (input.id && existing.id !== input.id) {
await repo.remove(existing);
await repo.save(repo.create({ id: input.id, userId, day, state }));
} else {
existing.state = state;
await repo.save(existing);
}
} else {
await repo.save(repo.create({ id: input.id, userId, day, state }));
}
Cleanup phase
After the upsert loop, delete any rows the client didn't send — this is a full-replace semantic:
const inputIds = items.filter(i => i.id).map(i => i.id);
if (inputIds.length > 0) {
await repo.createQueryBuilder()
.delete().from(Entity)
.where('userId = :userId', { userId })
.andWhere('id NOT IN (:...ids)', { ids: inputIds })
.execute();
} else {
await repo.delete({ userId });
}
Only apply the cleanup phase to collections the client sends in full; a partial or paged upload must never trigger it.