fix(tabs): keep the case-default GET read-only, forget a stale id outside the rollback

Merge-time fix for #676 (review nits 4 and 5). sessionCreated forgot a stale
case default inside the try whose catch rolls the registration back; it now
does so after the try, once the session is committed, so the cleanup can
never drop a committed session from the live map. caseDefaultGroup (behind
GET /api/cases/:name/default-group) no longer deletes a stale id as a side
effect: it reports Ungrouped and leaves the value for the next creation to
forget. The two tests that expected the read to clear it now expect it to
write nothing; the creation test still pins the cleanup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-22 19:31:21 +02:00
1 parent 3ca1d4832e
commit a3efc9b963
5 files changed
+23 -18

No files matched your search

+12 -12
View File
@@ -584,6 +584,7 @@ export class TabLayoutService {
const caseDefault = caseName ? this.deps.caseDefaults?.getCaseDefaultGroup(owner, caseName) : undefined;
let staleCaseDefault = false;
const rollback = register?.();
let layout: TabLayout;
try {
const groupId = placement.tabGroupId;
const adjust =
@@ -600,33 +601,32 @@ export class TabLayoutService {
return follow ? placed : pinCreatedChild(placed, sessionId, metadata);
}
: undefined;
const layout = await this.getUnlocked(owner, adjust);
// A default whose group was deleted is ignored above and forgotten here,
// only once the creation's own write has landed.
if (staleCaseDefault && caseName) this.deps.caseDefaults?.deleteCaseDefaultGroup(owner, caseName);
return layout;
layout = await this.getUnlocked(owner, adjust);
} catch (error) {
if (typeof rollback === 'function') rollback();
throw error;
}
// A default whose group was deleted is ignored above and forgotten here, only once the
// creation's own write has landed, and outside the rollback: the session is committed by now,
// and nothing this cleanup does may drop it from the live map.
if (staleCaseDefault && caseName) this.deps.caseDefaults?.deleteCaseDefaultGroup(owner, caseName);
return layout;
});
}
/**
* A case's default session group and the owner's named groups to choose from.
* A stored id that is no longer one of this owner's groups is forgotten and
* reported as `null` (Ungrouped), which is also where creation would put it.
* A stored id that is no longer one of this owner's groups is reported as
* `null` (Ungrouped), which is also where creation would put it. Read-only:
* the next creation for the case forgets the stale id.
*/
async caseDefaultGroup(owner: string, caseName: string): Promise<CaseDefaultGroupView> {
return this.withOwner(owner, async () => {
const { authoritative } = await this.prepareUnlocked(owner);
const groups = authoritative.groups.map((group) => ({ id: group.id, name: group.name }));
const stored = this.deps.caseDefaults?.getCaseDefaultGroup(owner, caseName);
if (typeof stored === 'string' && !groups.some((group) => group.id === stored)) {
this.deps.caseDefaults?.deleteCaseDefaultGroup(owner, caseName);
return { groupId: null, groups };
}
return { groupId: typeof stored === 'string' ? stored : null, groups };
const known = typeof stored === 'string' && groups.some((group) => group.id === stored);
return { groupId: known ? stored : null, groups };
});
}
+2 -1
View File
@@ -1717,7 +1717,8 @@ export function registerCaseRoutes(
* The tab group new sessions of this case land in when the create request names
* no group and no parent places them. Scoped to the caller's own tab layout: the
* options are their groups, and a stored id that is no longer one of them reads
* as Ungrouped (null) and is forgotten. Not admin-gated in multi-user mode: the
* as Ungrouped (null); the read writes nothing (the next creation for the case
* forgets it). Not admin-gated in multi-user mode: the
* value is per owner (like the layout it points into), never server-wide.
*/
app.get('/api/cases/:name/default-group', async (req, reply) => {