server/web/src/lib/chat/spacetime.svelte.ts
vegard 88a22e131b SpacetimeDB: subscription-erfaringer, refresh med enrichFromPg, whitespace-fiks
- Dokumentert at subscriptions ikke støtter JOINs (feiler stille)
- refresh() kaller enrichFromPg() for å hente fersk metadata fra PG
- Whitespace-normalisering i autogenererte module_bindings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:18:45 +01:00

331 lines
8.6 KiB
TypeScript

import type { MessageData, ReactionSummary } from '$lib/types/message';
import type { ChatConnection, ChatUser, MentionRef } from './types';
import { DbConnection, type EventContext } from './module_bindings';
/**
* SpacetimeDB-only chat-adapter.
* All data (historikk + sanntid) kommer fra SpacetimeDB.
* Worker håndterer warmup (PG → ST) og sync (ST → PG).
* Frontend snakker KUN med SpacetimeDB.
*/
export function createSpacetimeChat(
channelId: string,
spacetimeUrl: string,
moduleName: string,
user: ChatUser,
workspaceId: string
): ChatConnection {
let messages = $state<MessageData[]>([]);
let error = $state('');
let connected = $state(false);
let conn: InstanceType<typeof DbConnection> | null = null;
let destroyed = false;
// Lokal reaksjonsstate (SpacetimeDB har message_reaction-tabellen)
let reactionMap = $state<Map<string, ReactionSummary[]>>(new Map());
function spacetimeRowToMessage(row: any): MessageData {
let createdAt: string;
try {
const micros = row.createdAt?.microsSinceEpoch;
const ms = typeof micros === 'bigint' ? Number(micros / 1000n) : Number(micros) / 1000;
createdAt = new Date(ms).toISOString();
} catch {
createdAt = new Date().toISOString();
}
return {
id: row.id,
channel_id: channelId,
reply_to: row.replyTo || null,
author_id: row.authorId || null,
author_name: row.authorName || null,
message_type: row.messageType ?? 'chat',
title: null,
body: row.body,
pinned: false,
visibility: 'workspace',
created_at: createdAt,
updated_at: createdAt,
reactions: reactionMap.get(row.id) ?? [],
kanban_view: null,
calendar_view: null
};
}
function rebuildReactions() {
// Bygg reaksjonsaggregat fra message_reaction-tabellen
if (!conn) return;
const newMap = new Map<string, ReactionSummary[]>();
for (const r of conn.db.message_reaction.iter()) {
const msgId = r.messageId;
if (!newMap.has(msgId)) newMap.set(msgId, []);
const summaries = newMap.get(msgId)!;
const existing = summaries.find(s => s.reaction === r.reaction);
if (existing) {
existing.count++;
if (r.userId === user.id) existing.user_reacted = true;
} else {
summaries.push({
reaction: r.reaction,
count: 1,
user_reacted: r.userId === user.id
});
}
}
reactionMap = newMap;
}
function rebuildMessages() {
if (!conn) return;
const rows: any[] = [];
for (const row of conn.db.chat_message.iter()) {
if (row.channelId === channelId) {
rows.push(row);
}
}
// Sorter kronologisk
rows.sort((a, b) => {
const aMs = extractMs(a.createdAt);
const bMs = extractMs(b.createdAt);
return aMs - bMs;
});
messages = rows.map(r => spacetimeRowToMessage(r));
}
function extractMs(ts: any): number {
try {
const micros = ts?.microsSinceEpoch;
return typeof micros === 'bigint' ? Number(micros / 1000n) : Number(micros) / 1000;
} catch {
return 0;
}
}
async function enrichFromPg() {
try {
const res = await fetch(`/api/channels/${channelId}/messages/metadata`);
if (!res.ok) return;
const data: { id: string; edited_at: string | null; metadata: any }[] = await res.json();
const lookup = new Map(data.map(d => [d.id, d]));
messages = messages.map(m => {
const meta = lookup.get(m.id);
if (!meta) return m;
return {
...m,
edited_at: meta.edited_at,
metadata: meta.metadata
};
});
} catch {
// Ikke kritisk — meldinger vises uansett
}
}
async function enrichMessageFromPg(messageId: string) {
try {
const res = await fetch(`/api/messages/${messageId}/metadata`);
if (!res.ok) return;
const meta: { edited_at: string | null; metadata: any } = await res.json();
messages = messages.map(m =>
m.id === messageId ? { ...m, edited_at: meta.edited_at, metadata: meta.metadata } : m
);
} catch { /* ikke kritisk */ }
}
function connectRealtime() {
try {
conn = DbConnection.builder()
.withUri(spacetimeUrl)
.withDatabaseName(moduleName)
.onConnect((connection) => {
if (destroyed) return;
connected = true;
error = '';
try {
sessionStorage.setItem('spacetime_token', '');
} catch { /* SSR-safe */ }
// Abonner på meldinger + reaksjoner for denne kanalen
connection.subscriptionBuilder()
.onApplied(() => {
// Initialload — bygg state fra subscription
rebuildReactions();
rebuildMessages();
// Hent redigeringsstatus fra PG (SpacetimeDB har ikke edited_at/metadata)
enrichFromPg();
})
.onError(() => {
console.error('[spacetime] subscription error');
})
.subscribe([
`SELECT * FROM chat_message WHERE channel_id = '${channelId}'`,
`SELECT * FROM message_reaction`
]);
})
.onDisconnect(() => {
connected = false;
})
.onConnectError((_ctx, err) => {
console.warn('[spacetime] connection error:', err);
error = 'Tilkobling til sanntidstjeneste feilet';
})
.withToken(getStoredToken() ?? '')
.build();
// Nye meldinger i sanntid
conn.db.chat_message.onInsert((_ctx: EventContext, row) => {
if (destroyed || row.channelId !== channelId) return;
if (messages.some(m => m.id === row.id)) return;
const msg = spacetimeRowToMessage(row);
messages = [...messages, msg];
});
// Meldinger oppdatert (edit)
conn.db.chat_message.onUpdate((_ctx: EventContext, _oldRow, newRow) => {
if (destroyed || newRow.channelId !== channelId) return;
messages = messages.map(m =>
m.id === newRow.id ? { ...m, body: newRow.body, edited_at: new Date().toISOString() } : m
);
// Hent metadata fra PG (ai_processed osv.) for den oppdaterte meldingen
enrichMessageFromPg(newRow.id);
});
// Meldinger slettet
conn.db.chat_message.onDelete((_ctx: EventContext, row) => {
if (destroyed || row.channelId !== channelId) return;
messages = messages.filter(m => m.id !== row.id);
});
// Reaksjoner — rebuild ved endring
conn.db.message_reaction.onInsert((_ctx: EventContext, _row) => {
if (destroyed) return;
rebuildReactions();
// Oppdater reactions i messages
messages = messages.map(m => ({
...m,
reactions: reactionMap.get(m.id) ?? []
}));
});
conn.db.message_reaction.onDelete((_ctx: EventContext, _row) => {
if (destroyed) return;
rebuildReactions();
messages = messages.map(m => ({
...m,
reactions: reactionMap.get(m.id) ?? []
}));
});
} catch (e) {
console.warn('[spacetime] setup feilet:', e);
error = 'Kunne ikke koble til sanntidstjeneste';
}
}
async function send(body: string, _mentions?: MentionRef[], replyTo?: string) {
if (!conn || !connected) {
error = 'Ikke tilkoblet';
return;
}
try {
const id = crypto.randomUUID();
conn.reducers.sendMessage({
id,
channelId,
workspaceId,
authorId: user.id,
authorName: user.name,
body,
replyTo: replyTo ?? ''
});
} catch {
error = 'Kunne ikke sende melding';
}
}
async function edit(messageId: string, newBody: string) {
if (!conn || !connected) return;
try {
conn.reducers.editMessage({
id: messageId,
workspaceId,
newBody
});
} catch {
error = 'Kunne ikke redigere melding';
}
}
async function del(messageId: string) {
if (!conn || !connected) return;
try {
conn.reducers.deleteMessage({
id: messageId,
workspaceId
});
} catch {
error = 'Kunne ikke slette melding';
}
}
async function react(messageId: string, reaction: string) {
if (!conn || !connected) return;
try {
// Sjekk om bruker allerede har reagert med denne
const existing = reactionMap.get(messageId)?.find(
r => r.reaction === reaction && r.user_reacted
);
if (existing) {
conn.reducers.removeReaction({
messageId,
workspaceId,
userId: user.id,
reaction
});
} else {
conn.reducers.addReaction({
messageId,
workspaceId,
userId: user.id,
userName: user.name,
reaction
});
}
} catch {
error = 'Kunne ikke legge til reaksjon';
}
}
function destroy() {
destroyed = true;
if (conn) {
conn.disconnect();
conn = null;
}
}
function getStoredToken(): string | undefined {
try {
return sessionStorage.getItem('spacetime_token') ?? undefined;
} catch {
return undefined;
}
}
// Start tilkobling
connectRealtime();
return {
get messages() { return messages; },
get error() { return error; },
get connected() { return connected; },
get readonly() { return false; },
send,
edit,
delete: del,
react,
refresh: async () => { rebuildMessages(); await enrichFromPg(); },
destroy
};
}