feat: Beute L3–L5 (3D-Szene, Umbauen, Durchsicht, offline) und Diktat (T7 L1)
Beute L3 Runden 2+3 und Review-Fixes: - Glas-Holo ohne warmes Leuchten, Wanderboden-Fuss, Fokusflug mit Geist-Ansicht, Inhalte im Fokus, Rähmchen mit Art-Farbe beim Anheben - Hinweis-Geometrie je Zarge nur einmal je Layout gebaut; Drehen mit Pointer-Capture, endet auch bei Loslassen ausserhalb (rig-pointer.ts) - Tiefenunschärfe ab 55 fps, Leistungsprobe bei jeder Bewegungsstufe, Context-Loss-Listener aufgeräumt, Trefferboxen als skalierte Einheitsbox Beute L4 Umbauen (/colonies/[id]/hive): Entwurf mit Basis-Schnappschuss beim ersten Schritt, Rückgängig/Wiederholen, Hinweis bei Serverstand- Änderung, Konfliktblatt. Beute L5: - Durchsicht-Abschnitt „Beute“: Aufbau-Änderungen in der Durchsicht, Blatt-Formulare stoppen das Submit-Bubbling durch Portale (sonst speicherte „Speichern“ im Blatt die ganze Durchsicht), Fokus nach Rückgängig, Folgevorschläge beim Speichern - Offline: Beute im Offline-Paket, Warteschlange pending-hive-changes (DB v5), Abarbeiten in Reihenfolge, keine Online-Änderung überholt wartende, Hinweis bei abgelehnter Änderung mit „Warteschlange öffnen“ T7 L1 Diktat: Spracherkennungs-Kapsel (Web Speech API), Einwilligung standardmässig aus, Diktat-Knopf an Freitextfeldern, Seite „Sprache & Datenschutz“; axe-core als exakte devDependency. Backend: NotificationsService liest Empfänger und schreibt in einer Transaktion mit FOR KEY SHARE (paralleles Löschen eines Benutzers führte zu P2003 und verzögerten Vorschlägen; Ursache der wackeligen Endtests). Zwei e2e-Prüfungen gegen geteilten DB-Zustand entschärft. Gates: Frontend lint, typecheck, 2740 Tests, build; Backend lint, typecheck, 4266 Unit-Tests, build; parallele e2e 3x grün (978). Browser-Prüfung 1280/390 px, Tastatur, offline, reduced motion. Refs #19 #20 #21 #22 #27 #32 Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
ab45d6e24e
commit
1a57de8573
123 files changed
+7639
-355
No files matched your search
@@ -32,6 +32,7 @@ describe('NotificationsService', () => {
|
||||
user: { findMany: jest.Mock };
|
||||
$connect: jest.Mock;
|
||||
$transaction: jest.Mock;
|
||||
$queryRaw: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -52,10 +53,19 @@ describe('NotificationsService', () => {
|
||||
// A transaction client lacks $connect; the root client has it.
|
||||
$connect: jest.fn(),
|
||||
$transaction: jest.fn(),
|
||||
// The FOR KEY SHARE lock on the recipients: every user still exists.
|
||||
$queryRaw: jest.fn().mockImplementation(() => {
|
||||
const found = prisma.user.findMany.mock.results.at(-1);
|
||||
return Promise.resolve(found ? (found.value as unknown) : []);
|
||||
}),
|
||||
};
|
||||
prisma.$transaction.mockImplementation((work: (tx: unknown) => unknown) =>
|
||||
Promise.resolve(work(prisma)),
|
||||
);
|
||||
prisma.$transaction.mockImplementation((work: (tx: unknown) => unknown) => {
|
||||
// A transaction client has no $connect; the code tells the two apart.
|
||||
const txClient = Object.fromEntries(
|
||||
Object.entries(prisma).filter(([key]) => key !== '$connect'),
|
||||
);
|
||||
return Promise.resolve(work(txClient));
|
||||
});
|
||||
held = new Set();
|
||||
service = new NotificationsService(
|
||||
prisma as unknown as PrismaService,
|
||||
@@ -78,6 +88,28 @@ describe('NotificationsService', () => {
|
||||
void service.record({ priority: 'INFO', category: 'SYSTEM', message: 'm' });
|
||||
});
|
||||
|
||||
describe('recipient locking', () => {
|
||||
it('locks the recipients in the write transaction and skips a user deleted meanwhile', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }, { id: 'u2' }]);
|
||||
prisma.$queryRaw.mockResolvedValue([{ id: 'u1' }]);
|
||||
|
||||
const rows = await service.recordForUsers(['u1', 'u2'], {
|
||||
priority: 'INFO',
|
||||
category: 'SYSTEM',
|
||||
message: 'm',
|
||||
});
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
(prisma.$queryRaw.mock.calls[0] as [{ sql: string }])[0].sql,
|
||||
).toContain('FOR KEY SHARE');
|
||||
expect(rows.map((row) => (row as { userId: string }).userId)).toEqual([
|
||||
'u1',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('record', () => {
|
||||
it('writes one row for an eligible recipient and returns it', async () => {
|
||||
prisma.user.findMany.mockResolvedValue([{ id: 'u1' }]);
|
||||
@@ -138,6 +170,7 @@ describe('NotificationsService', () => {
|
||||
it('writes through the transaction client when one is given', async () => {
|
||||
const tx = {
|
||||
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
|
||||
$queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]),
|
||||
notification: {
|
||||
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]),
|
||||
},
|
||||
@@ -180,7 +213,10 @@ describe('NotificationsService', () => {
|
||||
const rows = await service.recordForUsers(['u1', 'u2'], base);
|
||||
|
||||
expect(planner.planIn).toHaveBeenCalledTimes(1);
|
||||
expect(planner.planIn).toHaveBeenCalledWith(prisma, rows);
|
||||
expect(planner.planIn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ notification: prisma.notification }),
|
||||
rows,
|
||||
);
|
||||
expect(
|
||||
prisma.notification.createManyAndReturn.mock.invocationCallOrder[0],
|
||||
).toBeLessThan(planner.planIn.mock.invocationCallOrder[0]);
|
||||
@@ -189,6 +225,7 @@ describe('NotificationsService', () => {
|
||||
it('plans with the caller transaction and opens none of its own', async () => {
|
||||
const tx = {
|
||||
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
|
||||
$queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]),
|
||||
notification: {
|
||||
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n' }]),
|
||||
},
|
||||
@@ -316,6 +353,7 @@ describe('NotificationsService', () => {
|
||||
it('reads and writes through the transaction client when one is given', async () => {
|
||||
const tx = {
|
||||
user: { findMany: jest.fn().mockResolvedValue([{ id: 'u1' }]) },
|
||||
$queryRaw: jest.fn().mockResolvedValue([{ id: 'u1' }]),
|
||||
notification: {
|
||||
createManyAndReturn: jest.fn().mockResolvedValue([{ id: 'n1' }]),
|
||||
},
|
||||
@@ -342,6 +380,7 @@ describe('NotificationsService', () => {
|
||||
let calls: string[];
|
||||
let tx: {
|
||||
$executeRaw: jest.Mock;
|
||||
$queryRaw: jest.Mock;
|
||||
user: { findMany: jest.Mock };
|
||||
notification: { findMany: jest.Mock; createManyAndReturn: jest.Mock };
|
||||
};
|
||||
@@ -353,6 +392,11 @@ describe('NotificationsService', () => {
|
||||
calls.push('lock');
|
||||
return Promise.resolve(1);
|
||||
}),
|
||||
// The key-share lock keeps every user the read just returned.
|
||||
$queryRaw: jest.fn().mockImplementation(() => {
|
||||
const read = tx.user.findMany.mock.results.at(-1);
|
||||
return Promise.resolve(read ? (read.value as unknown) : []);
|
||||
}),
|
||||
user: {
|
||||
// Like the database: the id filter narrows the three eligible users.
|
||||
findMany: jest
|
||||
|
||||
@@ -104,17 +104,18 @@ export class NotificationsService {
|
||||
input: CreateNotificationInput,
|
||||
tx?: Prisma.TransactionClient,
|
||||
): Promise<Notification | null> {
|
||||
const db: DbClient = tx ?? this.prisma;
|
||||
const { userId, dedupeKey, ...content } = input;
|
||||
const keys = uniqueSorted(content.requiredPermissions ?? []);
|
||||
const recipients = await this.recipients(db, keys, [userId]);
|
||||
const [row] = await this.write(
|
||||
db,
|
||||
recipients.map((id) => ({ userId: id, dedupeKey: dedupeKey ?? null })),
|
||||
content,
|
||||
keys,
|
||||
);
|
||||
return row ?? null;
|
||||
return this.inTransaction(tx, async (db) => {
|
||||
const recipients = await this.recipients(db, keys, [userId]);
|
||||
const [row] = await this.write(
|
||||
db,
|
||||
recipients.map((id) => ({ userId: id, dedupeKey: dedupeKey ?? null })),
|
||||
content,
|
||||
keys,
|
||||
);
|
||||
return row ?? null;
|
||||
});
|
||||
}
|
||||
|
||||
/** The given users, minus those missing a permission the content needs. */
|
||||
@@ -127,7 +128,9 @@ export class NotificationsService {
|
||||
return [];
|
||||
}
|
||||
const keys = uniqueSorted(input.requiredPermissions ?? []);
|
||||
return this.fanOut(tx ?? this.prisma, keys, input, userIds);
|
||||
return this.inTransaction(tx, (db) =>
|
||||
this.fanOut(db, keys, input, userIds),
|
||||
);
|
||||
}
|
||||
|
||||
/** Every user holding all of `permission` (and the content's permissions). */
|
||||
@@ -146,7 +149,9 @@ export class NotificationsService {
|
||||
...recipientKeys,
|
||||
...(input.requiredPermissions ?? []),
|
||||
]);
|
||||
return this.fanOut(tx ?? this.prisma, keys, input, undefined);
|
||||
return this.inTransaction(tx, (db) =>
|
||||
this.fanOut(db, keys, input, undefined),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -264,8 +269,20 @@ export class NotificationsService {
|
||||
return { updated: count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Recipients are read and then inserted: both steps belong to one
|
||||
* transaction, in which recipients() locks the user rows, so a user deleted
|
||||
* in between cannot turn the insert into a foreign key violation.
|
||||
*/
|
||||
private inTransaction<T>(
|
||||
tx: Prisma.TransactionClient | undefined,
|
||||
work: (db: Prisma.TransactionClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return tx ? work(tx) : this.prisma.$transaction((own) => work(own));
|
||||
}
|
||||
|
||||
private async fanOut(
|
||||
db: DbClient,
|
||||
db: Prisma.TransactionClient,
|
||||
keys: readonly PermissionKey[],
|
||||
input: FanOutNotificationInput,
|
||||
userIds: readonly string[] | undefined,
|
||||
@@ -287,7 +304,7 @@ export class NotificationsService {
|
||||
|
||||
/** Active user, active role, every key granted - in one query. */
|
||||
private async recipients(
|
||||
db: DbClient,
|
||||
db: Prisma.TransactionClient,
|
||||
keys: readonly PermissionKey[],
|
||||
userIds: readonly string[] | undefined,
|
||||
): Promise<string[]> {
|
||||
@@ -305,7 +322,15 @@ export class NotificationsService {
|
||||
select: { id: true },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
return users.map((user) => user.id);
|
||||
if (users.length === 0) {
|
||||
return [];
|
||||
}
|
||||
// FOR KEY SHARE: a concurrent DELETE of one of these users waits for this
|
||||
// transaction; one that already committed drops out of the result.
|
||||
const locked = await db.$queryRaw<{ id: string }[]>(
|
||||
Prisma.sql`SELECT "id" FROM "User" WHERE "id" = ANY(${users.map((user) => user.id)}) ORDER BY "id" FOR KEY SHARE`,
|
||||
);
|
||||
return locked.map((user) => user.id);
|
||||
}
|
||||
|
||||
private async write(
|
||||
|
||||
Reference in new issue
Block a user