bug fixes, and performance improvements, and drawer
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { topicEvents } from '../server/utils/events';
|
||||
import { createTopicSnapshotStream } from '../server/utils/topic-snapshot-stream';
|
||||
import { appendTextDelta, serializeTopicSnapshot } from '../utils/topic-sync';
|
||||
|
||||
const snapshot = () => ({
|
||||
id: 'topic',
|
||||
name: 'Example',
|
||||
messages: [{
|
||||
id: 'message',
|
||||
activeChildId: null as string | null,
|
||||
updatedAt: new Date('2026-09-27T00:00:00Z'),
|
||||
generation: { status: 'pending', tokens: null as unknown },
|
||||
parts: [{ id: 'part', type: 'text', content: 'hello', finished: false }],
|
||||
}],
|
||||
});
|
||||
const fingerprint = (value: unknown) => createHash('sha256').update(serializeTopicSnapshot(value)).digest('hex');
|
||||
const readEvent = async (reader: ReadableStreamDefaultReader<string>) => {
|
||||
const { value } = await reader.read();
|
||||
return JSON.parse(value!.slice(6));
|
||||
};
|
||||
|
||||
describe('topic snapshot synchronization', () => {
|
||||
test('matches database Dates and hydrated JSON regardless of object key order', () => {
|
||||
const topic = snapshot();
|
||||
const hydrated = JSON.parse(JSON.stringify(topic));
|
||||
expect(fingerprint({ messages: hydrated.messages, name: hydrated.name, id: hydrated.id })).toBe(fingerprint(topic));
|
||||
});
|
||||
|
||||
test('detects older-message, part, generation, variant, and topic changes without relying on count', () => {
|
||||
const original = fingerprint(snapshot());
|
||||
for (const change of [
|
||||
(topic: ReturnType<typeof snapshot>) => topic.name = 'Renamed',
|
||||
(topic: ReturnType<typeof snapshot>) => topic.messages[0]!.activeChildId = 'variant',
|
||||
(topic: ReturnType<typeof snapshot>) => topic.messages[0]!.parts[0]!.content = 'edited',
|
||||
(topic: ReturnType<typeof snapshot>) => topic.messages[0]!.parts[0]!.finished = true,
|
||||
(topic: ReturnType<typeof snapshot>) => topic.messages[0]!.generation.tokens = { output: 123 },
|
||||
]) {
|
||||
const topic = snapshot();
|
||||
change(topic);
|
||||
expect(fingerprint(topic)).not.toBe(original);
|
||||
}
|
||||
});
|
||||
|
||||
test('matching state receives a small ready event, then live changes', async () => {
|
||||
const topic = snapshot();
|
||||
const subscription = createTopicSnapshotStream('match', fingerprint(topic));
|
||||
const result = await subscription.open(async () => topic);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
const reader = result.data.getReader();
|
||||
try {
|
||||
expect(await readEvent(reader)).toEqual({ type: 'synced', payload: { id: 'match' } });
|
||||
await topicEvents.emit('match', { type: 'topic_updated', payload: { name: 'New' } });
|
||||
expect((await readEvent(reader)).payload).toEqual({ name: 'New' });
|
||||
} finally {
|
||||
await reader.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
test('stale or missing fingerprints receive a full replacement snapshot', async () => {
|
||||
for (const offered of [undefined, fingerprint(snapshot())]) {
|
||||
const topic = snapshot();
|
||||
topic.messages[0]!.parts[0]!.content = 'changed';
|
||||
const subscription = createTopicSnapshotStream('stale', offered);
|
||||
const result = await subscription.open(async () => topic);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) continue;
|
||||
const reader = result.data.getReader();
|
||||
try {
|
||||
const event = await readEvent(reader);
|
||||
expect(event.type).toBe('initial_state');
|
||||
expect(event.payload.messages[0].parts[0].content).toBe('changed');
|
||||
} finally {
|
||||
await reader.cancel();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('captures events during the DB query and includes text not yet persisted', async () => {
|
||||
const id = 'query-race';
|
||||
const initial = snapshot();
|
||||
await topicEvents.emit(id, {
|
||||
type: 'text-start', payload: { messageId: 'message', part: initial.messages[0]!.parts[0] },
|
||||
});
|
||||
// This delta predates subscription and has not reached the database yet.
|
||||
await topicEvents.emit(id, {
|
||||
type: 'text-delta', payload: { messageId: 'message', partId: 'part', content: ' world', offset: 5 },
|
||||
});
|
||||
const pending = Promise.withResolvers<ReturnType<typeof snapshot>>();
|
||||
const subscription = createTopicSnapshotStream(id, fingerprint(initial));
|
||||
const opening = subscription.open(() => pending.promise);
|
||||
await topicEvents.emit(id, {
|
||||
type: 'text-delta', payload: { messageId: 'message', partId: 'part', content: '!', offset: 11 },
|
||||
});
|
||||
pending.resolve(snapshot());
|
||||
const result = await opening;
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
const reader = result.data.getReader();
|
||||
try {
|
||||
const state = await readEvent(reader);
|
||||
expect(state.type).toBe('initial_state');
|
||||
const text = state.payload.messages[0].parts[0].content;
|
||||
expect(text).toBe('hello world!');
|
||||
const replay = await readEvent(reader);
|
||||
expect(replay.replay).toBe(true);
|
||||
expect(appendTextDelta(text, replay.payload.content, replay.payload.offset)).toBe(text);
|
||||
} finally {
|
||||
await reader.cancel();
|
||||
topicEvents.clearLiveText(id, 'message');
|
||||
}
|
||||
});
|
||||
|
||||
test('cancellation during snapshot loading does not establish a stream', async () => {
|
||||
const pending = Promise.withResolvers<ReturnType<typeof snapshot>>();
|
||||
const subscription = createTopicSnapshotStream('cancelled', undefined);
|
||||
const opening = subscription.open(() => pending.promise);
|
||||
subscription.close();
|
||||
pending.resolve(snapshot());
|
||||
expect((await opening).ok).toBe(false);
|
||||
});
|
||||
|
||||
test('text offsets handle partial overlaps, duplicates, and gaps', () => {
|
||||
expect(appendTextDelta('hello w', ' world', 5)).toBe('hello world');
|
||||
expect(appendTextDelta('hello world', ' world', 5)).toBe('hello world');
|
||||
expect(appendTextDelta('hello', '!', 5)).toBe('hello!');
|
||||
expect(appendTextDelta('hello', '!', 10)).toBe('hello');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user