refactor spacetimedb tables, and fix some stuff

This commit is contained in:
2026-04-07 13:44:26 -04:00
parent ca6c3da793
commit 6a96a2ff18
13 changed files with 2130 additions and 1826 deletions
+4 -1582
View File
File diff suppressed because it is too large Load Diff
+955
View File
@@ -0,0 +1,955 @@
import { t, SenderError } from "spacetimedb/server";
import { spacetimedb } from "./schema";
import {
validateName,
validateMessageLength,
getNextSeqId,
clearSignalingForUser,
autoJoinCommunityServer
} from "./utils";
export const set_typing = spacetimedb.reducer(
{ channelId: t.u64(), typing: t.bool() },
(ctx, { channelId, typing }) => {
const activity = ctx.db.typing_activity.identity.find(ctx.sender);
if (activity) {
ctx.db.typing_activity.identity.update({
identity: ctx.sender,
channel_id: channelId,
is_typing: typing,
});
} else {
ctx.db.typing_activity.insert({
identity: ctx.sender,
channel_id: channelId,
is_typing: typing,
});
}
},
);
export const upload_image = spacetimedb.reducer(
{ data: t.byteArray(), mimeType: t.string(), name: t.string().optional(), clientId: t.string().optional() },
(ctx, { data, mimeType, name, clientId }) => {
if (clientId) {
const existing = ctx.db.upload_status.client_id.find(clientId);
if (existing) {
ctx.db.upload_status.client_id.delete(clientId);
}
ctx.db.upload_status.insert({
client_id: clientId,
identity: ctx.sender,
status: "pending",
image_id: undefined,
error: undefined,
});
}
try {
if (data.length > 4 * 1024 * 1024) {
throw new Error("Image exceeds 4MB limit");
}
const img = ctx.db.image.insert({ id: 0n, data, mime_type: mimeType, name });
if (clientId) {
ctx.db.upload_status.client_id.update({
client_id: clientId,
identity: ctx.sender,
image_id: img.id,
status: "success",
error: undefined,
});
}
return img;
} catch (e: any) {
if (clientId) {
ctx.db.upload_status.client_id.update({
client_id: clientId,
identity: ctx.sender,
image_id: undefined,
status: "error",
error: e.message || "Unknown error",
});
// We return normally here so the "error" status is committed to the table
// and visible to the client.
return;
}
throw new SenderError(e.message || "Unknown error");
}
},
);
export const clear_upload_status = spacetimedb.reducer(
{ clientId: t.string() },
(ctx, { clientId }) => {
const status = ctx.db.upload_status.client_id.find(clientId);
if (status && status.identity.isEqual(ctx.sender)) {
ctx.db.upload_status.client_id.delete(clientId);
}
}
);
export const upload_custom_emoji = spacetimedb.reducer(
{ name: t.string(), category: t.string(), data: t.byteArray() },
(ctx, { name, category, data }) => {
if (data.length > 256 * 1024) {
throw new SenderError("Emoji image exceeds 256KB limit");
}
ctx.db.custom_emoji.insert({ id: 0n, name, category, data });
},
);
export const upload_avatar = spacetimedb.reducer(
{ data: t.byteArray(), mimeType: t.string() },
(ctx, { data, mimeType }) => {
if (data.length > 1024 * 1024) {
throw new SenderError("Avatar exceeds 1MB limit");
}
const img = ctx.db.image.insert({
id: 0n,
data,
mime_type: mimeType,
name: "avatar",
});
const user = ctx.db.user.identity.find(ctx.sender);
if (!user) throw new SenderError("User not found");
ctx.db.user.identity.update({ ...user, avatar_id: img.id });
},
);
export const upload_banner = spacetimedb.reducer(
{ data: t.byteArray(), mimeType: t.string() },
(ctx, { data, mimeType }) => {
if (data.length > 2 * 1024 * 1024) {
throw new SenderError("Banner exceeds 2MB limit");
}
const img = ctx.db.image.insert({
id: 0n,
data,
mime_type: mimeType,
name: "banner",
});
const user = ctx.db.user.identity.find(ctx.sender);
if (!user) throw new SenderError("User not found");
ctx.db.user.identity.update({ ...user, banner_id: img.id });
},
);
export const upload_server_avatar = spacetimedb.reducer(
{ serverId: t.u64(), data: t.byteArray(), mimeType: t.string() },
(ctx, { serverId, data, mimeType }) => {
if (data.length > 1024 * 1024) {
throw new SenderError("Avatar exceeds 1MB limit");
}
const s = ctx.db.server.id.find(serverId);
if (!s) throw new SenderError("Server not found");
const img = ctx.db.image.insert({
id: 0n,
data,
mime_type: mimeType,
name: "server_avatar",
});
ctx.db.server.id.update({ ...s, avatar_id: img.id });
},
);
export const update_server_name = spacetimedb.reducer(
{ serverId: t.u64(), name: t.string() },
(ctx, { serverId, name }) => {
validateName(name);
const s = ctx.db.server.id.find(serverId);
if (!s) throw new SenderError("Server not found");
ctx.db.server.id.update({ ...s, name });
},
);
export const delete_server = spacetimedb.reducer(
{ serverId: t.u64() },
(ctx, { serverId }) => {
const s = ctx.db.server.id.find(serverId);
if (!s) throw new SenderError("Server not found");
for (const c of ctx.db.channel.by_server_id.filter(serverId)) {
for (const m of ctx.db.message.by_channel_id.filter(c.id)) {
ctx.db.message.id.delete(m.id);
}
for (const rm of ctx.db.recent_message.by_channel.filter(c.id)) {
ctx.db.recent_message.id.delete(rm.id);
}
ctx.db.channel.id.delete(c.id);
}
for (const m of ctx.db.server_member.by_server_id.filter(serverId)) {
ctx.db.server_member.id.delete(m.id);
}
ctx.db.server.id.delete(serverId);
},
);
export const toggle_reaction = spacetimedb.reducer(
{
messageId: t.u64(),
emoji: t.string().optional(),
customEmojiId: t.u64().optional(),
},
(ctx, { messageId, emoji, customEmojiId }) => {
if (!emoji && !customEmojiId) {
throw new SenderError("Emoji or CustomEmojiId required");
}
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || !user.subject) {
throw new SenderError("You must be logged in via OIDC to react");
}
const existing = [
...ctx.db.message_reaction.by_message_id.filter(messageId),
].find((r) => {
if (!r.identity.isEqual(ctx.sender)) return false;
if (emoji && r.emoji === emoji) return true;
if (customEmojiId && r.custom_emoji_id === customEmojiId) return true;
return false;
});
if (existing) {
ctx.db.message_reaction.id.delete(existing.id);
} else {
ctx.db.message_reaction.insert({
id: 0n,
message_id: messageId,
identity: ctx.sender,
emoji,
custom_emoji_id: customEmojiId,
});
}
},
);
export const set_name = spacetimedb.reducer(
{ name: t.string() },
(ctx, { name }) => {
validateName(name);
const user = ctx.db.user.identity.find(ctx.sender);
if (!user) throw new SenderError("Cannot set name for unknown user");
ctx.db.user.identity.update({ ...user, name });
},
);
export const set_avatar = spacetimedb.reducer(
{ avatarId: t.u64().optional() },
(ctx, { avatarId }) => {
const user = ctx.db.user.identity.find(ctx.sender);
if (!user) throw new SenderError("Cannot set avatar for unknown user");
ctx.db.user.identity.update({ ...user, avatar_id: avatarId });
},
);
export const set_banner = spacetimedb.reducer(
{ bannerId: t.u64().optional() },
(ctx, { bannerId }) => {
const user = ctx.db.user.identity.find(ctx.sender);
if (!user) throw new SenderError("Cannot set banner for unknown user");
ctx.db.user.identity.update({ ...user, banner_id: bannerId });
},
);
export const set_biography = spacetimedb.reducer(
{ biography: t.string().optional() },
(ctx, { biography }) => {
const user = ctx.db.user.identity.find(ctx.sender);
if (!user) throw new SenderError("User not found");
ctx.db.user.identity.update({ ...user, biography });
},
);
export const set_status = spacetimedb.reducer(
{ status: t.string().optional() },
(ctx, { status }) => {
const user = ctx.db.user.identity.find(ctx.sender);
if (!user) throw new SenderError("User not found");
ctx.db.user.identity.update({ ...user, status });
},
);
export const subscribe_to_channel = spacetimedb.reducer(
{ channelId: t.u64() },
(ctx, { channelId }) => {
const hwm = ctx.db.channel_high_water_mark.channel_id.find(channelId);
const currentMax = hwm ? hwm.last_seq_id : 0n;
const earliest = currentMax > 100n ? currentMax - 100n : 0n;
const existing = ctx.db.channel_subscription.identity.find(ctx.sender);
if (existing) {
if (existing.channel_id !== channelId) {
ctx.db.channel_subscription.identity.update({
...existing,
channel_id: channelId,
earliest_seq_id: earliest,
last_read_seq_id: currentMax,
});
}
} else {
ctx.db.channel_subscription.insert({
identity: ctx.sender,
channel_id: channelId,
earliest_seq_id: earliest,
last_read_seq_id: currentMax,
});
}
},
);
export const extend_subscription = spacetimedb.reducer(
{ channelId: t.u64(), earliestSeqId: t.u64() },
(ctx, { channelId, earliestSeqId }) => {
const existing = ctx.db.channel_subscription.identity.find(ctx.sender);
if (existing && existing.channel_id === channelId) {
ctx.db.channel_subscription.identity.update({
...existing,
earliest_seq_id: earliestSeqId,
});
}
},
);
export const set_talking = spacetimedb.reducer(
{ talking: t.bool(), channelId: t.u64() },
(ctx, { talking, channelId }) => {
const activity = ctx.db.voice_activity.identity.find(ctx.sender);
if (activity) {
ctx.db.voice_activity.identity.update({
identity: ctx.sender,
channel_id: channelId,
is_talking: talking,
});
} else {
ctx.db.voice_activity.insert({
identity: ctx.sender,
channel_id: channelId,
is_talking: talking,
});
}
},
);
export const create_server = spacetimedb.reducer(
{ name: t.string() },
(ctx, { name }) => {
validateName(name);
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || !user.subject) {
throw new SenderError(
"You must be logged in via OIDC to create a server",
);
}
const s = ctx.db.server.insert({ id: 0n, name, owner: ctx.sender, avatar_id: undefined });
ctx.db.server_member.insert({
id: 0n,
identity: ctx.sender,
server_id: s.id,
});
ctx.db.channel.insert({
id: 0n,
server_id: s.id,
name: "general",
kind: { tag: "Text" },
});
ctx.db.channel.insert({
id: 0n,
server_id: s.id,
name: "Voice General",
kind: { tag: "Voice" },
});
},
);
export const join_server = spacetimedb.reducer(
{ serverId: t.u64() },
(ctx, { serverId }) => {
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || !user.subject) {
throw new SenderError("You must be logged in via OIDC to join a server");
}
const s = ctx.db.server.id.find(serverId);
if (!s) throw new SenderError("Server not found");
for (const m of ctx.db.server_member.by_identity.filter(ctx.sender)) {
if (m.server_id === serverId) return;
}
ctx.db.server_member.insert({
id: 0n,
identity: ctx.sender,
server_id: serverId,
});
},
);
export const leave_server = spacetimedb.reducer(
{ serverId: t.u64() },
(ctx, { serverId }) => {
const user = ctx.db.user.identity.find(ctx.sender);
if (!user) return;
for (const m of ctx.db.server_member.by_identity.filter(ctx.sender)) {
if (m.server_id === serverId) {
ctx.db.server_member.id.delete(m.id);
}
}
},
);
export const create_channel = spacetimedb.reducer(
{ name: t.string(), serverId: t.u64(), isVoice: t.bool() },
(ctx, { name, serverId, isVoice }) => {
validateName(name);
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || !user.subject) {
throw new SenderError(
"You must be logged in via OIDC to create a channel",
);
}
const s = ctx.db.server.id.find(serverId);
if (!s) throw new SenderError("Server not found");
ctx.db.channel.insert({
id: 0n,
server_id: serverId,
name,
kind: isVoice ? { tag: "Voice" } : { tag: "Text" },
});
},
);
export const join_voice = spacetimedb.reducer(
{ channelId: t.u64() },
(ctx, { channelId }) => {
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || !user.subject) {
throw new SenderError("You must be logged in via OIDC to join voice");
}
const chan = ctx.db.channel.id.find(channelId);
if (!chan || chan.kind.tag !== "Voice")
throw new SenderError("Invalid voice channel");
const existing = ctx.db.voice_state.identity.find(ctx.sender);
if (existing) {
if (existing.channel_id !== channelId) {
clearSignalingForUser(ctx, ctx.sender);
ctx.db.voice_state.identity.update({
identity: ctx.sender,
channel_id: channelId,
is_sharing_screen: false,
is_muted: false,
is_deafened: false,
});
}
} else {
ctx.db.voice_state.insert({
identity: ctx.sender,
channel_id: channelId,
is_sharing_screen: false,
is_muted: false,
is_deafened: false,
});
}
},
);
export const set_sharing_screen = spacetimedb.reducer(
{ sharing: t.bool() },
(ctx, { sharing }) => {
const state = ctx.db.voice_state.identity.find(ctx.sender);
if (state) {
ctx.db.voice_state.identity.update({
...state,
is_sharing_screen: sharing,
});
if (!sharing) {
for (const w of ctx.db.watching.by_watchee.filter(ctx.sender)) {
ctx.db.watching.id.delete(w.id);
}
for (const row of ctx.db.screen_sdp_offer.by_sender.filter(ctx.sender))
ctx.db.screen_sdp_offer.id.delete(row.id);
for (const row of ctx.db.screen_sdp_answer.by_sender.filter(ctx.sender))
ctx.db.screen_sdp_answer.id.delete(row.id);
for (const row of ctx.db.screen_ice_candidate.by_sender.filter(
ctx.sender,
))
ctx.db.screen_ice_candidate.id.delete(row.id);
}
}
},
);
export const set_mute = spacetimedb.reducer(
{ muted: t.bool() },
(ctx, { muted }) => {
const state = ctx.db.voice_state.identity.find(ctx.sender);
if (state) {
ctx.db.voice_state.identity.update({
...state,
is_muted: muted,
});
}
},
);
export const set_deafen = spacetimedb.reducer(
{ deafened: t.bool() },
(ctx, { deafened }) => {
const state = ctx.db.voice_state.identity.find(ctx.sender);
if (state) {
ctx.db.voice_state.identity.update({
...state,
is_deafened: deafened,
});
}
},
);
export const start_watching = spacetimedb.reducer(
{ watchee: t.identity(), channelId: t.u64() },
(ctx, { watchee, channelId }) => {
if (ctx.sender.isEqual(watchee)) return;
for (const w of ctx.db.watching.by_watcher.filter(ctx.sender)) {
if (w.watchee.isEqual(watchee)) return;
}
ctx.db.watching.insert({
id: 0n,
watcher: ctx.sender,
watchee,
channel_id: channelId,
});
},
);
export const stop_watching = spacetimedb.reducer(
{ watchee: t.identity() },
(ctx, { watchee }) => {
for (const w of ctx.db.watching.by_watcher.filter(ctx.sender)) {
if (w.watchee.isEqual(watchee)) {
ctx.db.watching.id.delete(w.id);
}
}
},
);
export const leave_voice = spacetimedb.reducer((ctx) => {
const existing = ctx.db.voice_state.identity.find(ctx.sender);
if (existing) {
ctx.db.voice_state.identity.delete(ctx.sender);
}
clearSignalingForUser(ctx, ctx.sender);
});
export const send_voice_sdp_offer = spacetimedb.reducer(
{ receiver: t.identity(), sdp: t.string(), channelId: t.u64() },
(ctx, { receiver, sdp, channelId }) => {
ctx.db.voice_sdp_offer.insert({
id: 0n,
sender: ctx.sender,
receiver,
sdp,
channel_id: channelId,
});
},
);
export const send_voice_sdp_answer = spacetimedb.reducer(
{ receiver: t.identity(), sdp: t.string(), channelId: t.u64() },
(ctx, { receiver, sdp, channelId }) => {
ctx.db.voice_sdp_answer.insert({
id: 0n,
sender: ctx.sender,
receiver,
sdp,
channel_id: channelId,
});
},
);
export const send_voice_ice_candidate = spacetimedb.reducer(
{ receiver: t.identity(), candidate: t.string(), channelId: t.u64() },
(ctx, { receiver, candidate, channelId }) => {
ctx.db.voice_ice_candidate.insert({
id: 0n,
sender: ctx.sender,
receiver,
candidate,
channel_id: channelId,
});
},
);
export const send_screen_sdp_offer = spacetimedb.reducer(
{ receiver: t.identity(), sdp: t.string(), channelId: t.u64() },
(ctx, { receiver, sdp, channelId }) => {
ctx.db.screen_sdp_offer.insert({
id: 0n,
sender: ctx.sender,
receiver,
sdp,
channel_id: channelId,
});
},
);
export const send_screen_sdp_answer = spacetimedb.reducer(
{ receiver: t.identity(), sdp: t.string(), channelId: t.u64() },
(ctx, { receiver, sdp, channelId }) => {
ctx.db.screen_sdp_answer.insert({
id: 0n,
sender: ctx.sender,
receiver,
sdp,
channel_id: channelId,
});
},
);
export const send_screen_ice_candidate = spacetimedb.reducer(
{ receiver: t.identity(), candidate: t.string(), channelId: t.u64() },
(ctx, { receiver, candidate, channelId }) => {
ctx.db.screen_ice_candidate.insert({
id: 0n,
sender: ctx.sender,
receiver,
candidate,
channel_id: channelId,
});
},
);
export const set_configuration = spacetimedb.reducer(
{ key: t.string(), value: t.string() },
(ctx, { key, value }) => {
const existing = ctx.db.system_configuration.key.find(key);
if (existing) {
ctx.db.system_configuration.key.update({ key, value });
} else {
ctx.db.system_configuration.insert({ key, value });
}
}
);
export const create_thread = spacetimedb.reducer(
{ name: t.string(), channelId: t.u64(), parentMessageId: t.u64() },
(ctx, { name, channelId, parentMessageId }) => {
let threadName = name;
if (!threadName || threadName.trim().length === 0) {
const parentMsg = ctx.db.message.id.find(parentMessageId);
threadName = (parentMsg?.text && parentMsg.text.trim().length > 0)
? parentMsg.text.substring(0, 32)
: "New Thread";
}
validateName(threadName);
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || !user.subject) {
throw new SenderError(
"You must be logged in via OIDC to create a thread",
);
}
const parentMsg = ctx.db.message.id.find(parentMessageId);
if (!parentMsg) throw new SenderError("Parent message not found");
ctx.db.thread.insert({
id: 0n,
channel_id: channelId,
parent_message_id: parentMessageId,
name: threadName,
});
},
);
export const create_thread_with_message = spacetimedb.reducer(
{
name: t.string(),
channelId: t.u64(),
parentMessageId: t.u64(),
text: t.string(),
imageIds: t.array(t.u64()),
},
(ctx, { name, channelId, parentMessageId, text, imageIds }) => {
if ((!text || text.trim().length === 0) && imageIds.length === 0)
throw new SenderError("Messages must not be empty");
if (text) {
validateMessageLength(ctx, text);
}
const user = ctx.db.user.identity.find(ctx.sender);
if (!user || !user.subject) {
throw new SenderError(
"You must be logged in via OIDC to create a thread",
);
}
const parentMsg = ctx.db.message.id.find(parentMessageId);
if (!parentMsg) throw new SenderError("Parent message not found");
const t = ctx.db.thread.insert({
id: 0n,
channel_id: channelId,
parent_message_id: parentMessageId,
name,
});
const msg = ctx.db.message.insert({
id: 0n,
sender: ctx.sender,
text,
sent: ctx.timestamp,
channel_id: channelId,
thread_id: t.id,
});
const seqId = getNextSeqId(ctx, channelId);
ctx.db.channel_message_sequence.insert({
message_id: msg.id,
channel_id: channelId,
seq_id: seqId,
});
for (const imageId of imageIds) {
ctx.db.message_image.insert({
id: 0n,
message_id: msg.id,
image_id: imageId,
});
}
// Maintain recent_message (last 100)
ctx.db.recent_message.insert({
id: 0n,
channel_id: channelId,
message_id: msg.id,
sender: ctx.sender,
text,
thread_id: t.id,
sent: ctx.timestamp,
seq_id: seqId,
});
const recent = [...ctx.db.recent_message.by_channel.filter(channelId)].sort(
(a, b) => (a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1),
);
if (recent.length > 100) {
ctx.db.recent_message.id.delete(recent[0].id);
}
},
);
export const send_message = spacetimedb.reducer(
{
text: t.string(),
channelId: t.u64(),
threadId: t.u64().optional(),
imageIds: t.array(t.u64()),
},
(ctx, { text, channelId, threadId, imageIds }) => {
if ((!text || text.trim().length === 0) && imageIds.length === 0)
throw new SenderError("Messages must not be empty");
if (text) {
validateMessageLength(ctx, text);
}
const user = ctx.db.user.identity.find(ctx.sender);
if (!user) {
throw new SenderError("You must be registered to send messages");
}
const msg = ctx.db.message.insert({
id: 0n,
sender: ctx.sender,
text,
sent: ctx.timestamp,
channel_id: channelId,
thread_id: threadId,
});
const seqId = getNextSeqId(ctx, channelId);
ctx.db.channel_message_sequence.insert({
message_id: msg.id,
channel_id: channelId,
seq_id: seqId,
});
for (const imageId of imageIds) {
ctx.db.message_image.insert({
id: 0n,
message_id: msg.id,
image_id: imageId,
});
}
// Maintain recent_message (last 100)
ctx.db.recent_message.insert({
id: 0n,
channel_id: channelId,
message_id: msg.id,
sender: ctx.sender,
text,
thread_id: threadId,
sent: ctx.timestamp,
seq_id: seqId,
});
const recent2 = [...ctx.db.recent_message.by_channel.filter(channelId)].sort(
(a, b) => (a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1),
);
if (recent2.length > 100) {
ctx.db.recent_message.id.delete(recent2[0].id);
}
},
);
export const bootstrap_sequences = spacetimedb.reducer((ctx) => {
for (const row of ctx.db.channel_message_sequence.iter()) {
ctx.db.channel_message_sequence.message_id.delete(row.message_id);
}
for (const row of ctx.db.channel_high_water_mark.iter()) {
ctx.db.channel_high_water_mark.channel_id.delete(row.channel_id);
}
const allMessages = [...ctx.db.message.iter()].sort((a, b) =>
a.sent.microsSinceUnixEpoch < b.sent.microsSinceUnixEpoch ? -1 : 1
);
for (const msg of allMessages) {
const seqId = getNextSeqId(ctx, msg.channel_id);
ctx.db.channel_message_sequence.insert({
message_id: msg.id,
channel_id: msg.channel_id,
seq_id: seqId,
});
}
});
export const init = spacetimedb.init((ctx) => {
if (!ctx.db.system_configuration.key.find("max_message_length")) {
ctx.db.system_configuration.insert({ key: "max_message_length", value: "262144" });
}
if (!ctx.db.system_configuration.key.find("initial_identity")) {
ctx.db.system_configuration.insert({ key: "initial_identity", value: ctx.sender.toHexString() });
}
let hasServers = false;
for (const _server of ctx.db.server.iter()) {
hasServers = true;
break;
}
if (!hasServers) {
const s = ctx.db.server.insert({
id: 0n,
name: "Zep",
owner: undefined,
avatar_id: undefined,
});
ctx.db.channel.insert({
id: 0n,
server_id: s.id,
name: "general",
kind: { tag: "Text" },
});
ctx.db.channel.insert({
id: 0n,
server_id: s.id,
name: "Voice General",
kind: { tag: "Voice" },
});
}
});
export const onConnect = spacetimedb.clientConnected((ctx) => {
ctx.db.channel_subscription.identity.delete(ctx.sender);
// Clear any stale upload statuses for this identity
for (const status of ctx.db.upload_status.by_identity.filter(ctx.sender)) {
ctx.db.upload_status.client_id.delete(status.client_id);
}
const user = ctx.db.user.identity.find(ctx.sender);
if (ctx.senderAuth.hasJWT && ctx.senderAuth.jwt) {
const jwt = ctx.senderAuth.jwt;
const issuer = jwt.issuer;
const subject = jwt.subject;
const payload = jwt.fullPayload;
const name =
(payload.name as string) ||
(payload.nickname as string) ||
(payload.preferred_username as string) ||
(payload.email as string);
if (user) {
ctx.db.user.identity.update({
...user,
online: true,
name: user.name || name,
issuer,
subject,
anonymous: false,
});
} else {
ctx.db.user.insert({
name,
identity: ctx.sender,
online: true,
issuer,
subject,
anonymous: false,
avatar_id: undefined,
banner_id: undefined,
biography: undefined,
status: undefined,
});
autoJoinCommunityServer(ctx);
}
} else if (user) {
ctx.db.user.identity.update({ ...user, online: true });
} else {
// New anonymous user
ctx.db.user.insert({
name: undefined,
identity: ctx.sender,
online: true,
issuer: undefined,
subject: undefined,
anonymous: true,
avatar_id: undefined,
banner_id: undefined,
biography: undefined,
status: undefined,
});
autoJoinCommunityServer(ctx);
}
});
export const onDisconnect = spacetimedb.clientDisconnected((ctx) => {
const user = ctx.db.user.identity.find(ctx.sender);
if (user) {
ctx.db.user.identity.update({ ...user, online: false });
}
const existing = ctx.db.voice_state.identity.find(ctx.sender);
if (existing) {
ctx.db.voice_state.identity.delete(ctx.sender);
}
const activity = ctx.db.typing_activity.identity.find(ctx.sender);
if (activity) {
ctx.db.typing_activity.identity.update({
identity: ctx.sender,
channel_id: activity.channel_id,
is_typing: false,
});
}
// Clear upload statuses for this user
for (const status of ctx.db.upload_status.by_identity.filter(ctx.sender)) {
ctx.db.upload_status.client_id.delete(status.client_id);
}
clearSignalingForUser(ctx, ctx.sender);
});
+33
View File
@@ -0,0 +1,33 @@
import { schema } from "spacetimedb/server";
import * as tables from "./tables";
export const spacetimedb = schema({
user: tables.user,
server: tables.server,
server_member: tables.server_member,
channel: tables.channel,
channel_subscription: tables.channel_subscription,
channel_message_sequence: tables.channel_message_sequence,
channel_high_water_mark: tables.channel_high_water_mark,
voice_state: tables.voice_state,
voice_activity: tables.voice_activity,
watching: tables.watching,
voice_sdp_offer: tables.voice_sdp_offer,
voice_sdp_answer: tables.voice_sdp_answer,
voice_ice_candidate: tables.voice_ice_candidate,
screen_sdp_offer: tables.screen_sdp_offer,
screen_sdp_answer: tables.screen_sdp_answer,
screen_ice_candidate: tables.screen_ice_candidate,
thread: tables.thread,
message: tables.message,
message_image: tables.message_image,
message_reaction: tables.message_reaction,
custom_emoji: tables.custom_emoji,
image: tables.image,
typing_activity: tables.typing_activity,
recent_message: tables.recent_message,
system_configuration: tables.system_configuration,
upload_status: tables.upload_status,
});
export default spacetimedb;
+490
View File
@@ -0,0 +1,490 @@
import { t, table } from "spacetimedb/server";
export const channel_kind = t.enum("ChannelKind", { Text: t.unit(), Voice: t.unit() });
export const user = table(
{
name: "user",
public: true,
indexes: [
{ accessor: "by_online", algorithm: "btree", columns: ["online"] },
],
},
{
identity: t.identity().primaryKey(),
name: t.string().optional(),
online: t.bool(),
issuer: t.string().optional(),
subject: t.string().optional(),
anonymous: t.bool(),
avatar_id: t.u64().optional(),
banner_id: t.u64().optional(),
biography: t.string().optional(),
status: t.string().optional(),
},
);
export const server = table(
{ name: "server", public: true },
{
id: t.u64().primaryKey().autoInc(),
name: t.string(),
owner: t.identity().optional(),
avatar_id: t.u64().optional(),
},
);
export const server_member = table(
{
name: "server_member",
public: true,
indexes: [
{ accessor: "by_identity", algorithm: "btree", columns: ["identity"] },
{ accessor: "by_server_id", algorithm: "btree", columns: ["server_id"] },
],
},
{
id: t.u64().primaryKey().autoInc(),
identity: t.identity(),
server_id: t.u64(),
},
);
export const channel = table(
{
name: "channel",
public: true,
indexes: [
{ accessor: "by_server_id", algorithm: "btree", columns: ["server_id"] },
],
},
{
id: t.u64().primaryKey().autoInc(),
server_id: t.u64(),
name: t.string(),
kind: channel_kind,
},
);
export const voice_state = table(
{
name: "voice_state",
public: true,
indexes: [
{
accessor: "by_channel_id",
algorithm: "btree",
columns: ["channel_id"],
},
],
},
{
identity: t.identity().primaryKey(),
channel_id: t.u64(),
is_sharing_screen: t.bool(),
is_muted: t.bool().default(false),
is_deafened: t.bool().default(false),
},
);
export const voice_activity = table(
{
name: "voice_activity",
public: true,
indexes: [
{
accessor: "by_channel_id",
algorithm: "btree",
columns: ["channel_id"],
},
],
},
{
identity: t.identity().primaryKey(),
channel_id: t.u64(),
is_talking: t.bool(),
},
);
export const watching = table(
{
name: "watching",
public: true,
indexes: [
{ accessor: "by_watcher", algorithm: "btree", columns: ["watcher"] },
{ accessor: "by_watchee", algorithm: "btree", columns: ["watchee"] },
{
accessor: "by_channel_id",
algorithm: "btree",
columns: ["channel_id"],
},
],
},
{
id: t.u64().primaryKey().autoInc(),
watcher: t.identity(),
watchee: t.identity(),
channel_id: t.u64(),
},
);
export const voice_sdp_offer = table(
{
name: "voice_sdp_offer",
public: true,
indexes: [
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
{
accessor: "by_channel_id",
algorithm: "btree",
columns: ["channel_id"],
},
],
},
{
id: t.u64().primaryKey().autoInc(),
sender: t.identity(),
receiver: t.identity(),
sdp: t.string(),
channel_id: t.u64(),
},
);
export const voice_sdp_answer = table(
{
name: "voice_sdp_answer",
public: true,
indexes: [
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
{
accessor: "by_channel_id",
algorithm: "btree",
columns: ["channel_id"],
},
],
},
{
id: t.u64().primaryKey().autoInc(),
sender: t.identity(),
receiver: t.identity(),
sdp: t.string(),
channel_id: t.u64(),
},
);
export const voice_ice_candidate = table(
{
name: "voice_ice_candidate",
public: true,
indexes: [
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
{
accessor: "by_channel_id",
algorithm: "btree",
columns: ["channel_id"],
},
],
},
{
id: t.u64().primaryKey().autoInc(),
sender: t.identity(),
receiver: t.identity(),
candidate: t.string(),
channel_id: t.u64(),
},
);
export const screen_sdp_offer = table(
{
name: "screen_sdp_offer",
public: true,
indexes: [
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
{
accessor: "by_channel_id",
algorithm: "btree",
columns: ["channel_id"],
},
],
},
{
id: t.u64().primaryKey().autoInc(),
sender: t.identity(),
receiver: t.identity(),
sdp: t.string(),
channel_id: t.u64(),
},
);
export const screen_sdp_answer = table(
{
name: "screen_sdp_answer",
public: true,
indexes: [
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
{
accessor: "by_channel_id",
algorithm: "btree",
columns: ["channel_id"],
},
],
},
{
id: t.u64().primaryKey().autoInc(),
sender: t.identity(),
receiver: t.identity(),
sdp: t.string(),
channel_id: t.u64(),
},
);
export const screen_ice_candidate = table(
{
name: "screen_ice_candidate",
public: true,
indexes: [
{ accessor: "by_receiver", algorithm: "btree", columns: ["receiver"] },
{ accessor: "by_sender", algorithm: "btree", columns: ["sender"] },
{
accessor: "by_channel_id",
algorithm: "btree",
columns: ["channel_id"],
},
],
},
{
id: t.u64().primaryKey().autoInc(),
sender: t.identity(),
receiver: t.identity(),
candidate: t.string(),
channel_id: t.u64(),
},
);
export const channel_message_sequence = table(
{
name: "channel_message_sequence",
public: false,
indexes: [
{ accessor: "by_channel", algorithm: "btree", columns: ["channel_id"] },
{
accessor: "by_channel_seq",
algorithm: "btree",
columns: ["channel_id", "seq_id"],
},
],
},
{
message_id: t.u64().primaryKey(),
channel_id: t.u64(),
seq_id: t.u64(),
},
);
export const channel_high_water_mark = table(
{
name: "channel_high_water_mark",
public: false,
},
{
channel_id: t.u64().primaryKey(),
last_seq_id: t.u64(),
},
);
export const channel_subscription = table(
{
name: "channel_subscription",
public: false,
indexes: [
{ accessor: "by_channel_id", algorithm: "btree", columns: ["channel_id"] },
],
},
{
identity: t.identity().primaryKey(),
channel_id: t.u64(),
earliest_seq_id: t.u64(),
last_read_seq_id: t.u64(),
},
);
export const thread = table(
{
name: "thread",
public: true,
indexes: [
{
accessor: "by_channel_id",
algorithm: "btree",
columns: ["channel_id"],
},
],
},
{
id: t.u64().primaryKey().autoInc(),
channel_id: t.u64(),
parent_message_id: t.u64().unique(),
name: t.string(),
},
);
export const message = table(
{
name: "message",
public: false,
indexes: [
{
accessor: "by_channel_id",
algorithm: "btree",
columns: ["channel_id"],
},
{ accessor: "by_thread_id", algorithm: "btree", columns: ["thread_id"] },
{ accessor: "by_sent", algorithm: "btree", columns: ["sent"] },
],
},
{
id: t.u64().primaryKey().autoInc(),
sender: t.identity(),
sent: t.timestamp(),
text: t.string(),
channel_id: t.u64(),
thread_id: t.u64().optional(),
},
);
export const message_image = table(
{
name: "message_image",
public: false,
indexes: [
{
accessor: "by_message_id",
algorithm: "btree",
columns: ["message_id"],
},
],
},
{
id: t.u64().primaryKey().autoInc(),
message_id: t.u64(),
image_id: t.u64(),
},
);
export const message_reaction = table(
{
name: "message_reaction",
public: false,
indexes: [
{
accessor: "by_message_id",
algorithm: "btree",
columns: ["message_id"],
},
],
},
{
id: t.u64().primaryKey().autoInc(),
message_id: t.u64(),
identity: t.identity(),
emoji: t.string().optional(),
custom_emoji_id: t.u64().optional(),
},
);
export const custom_emoji = table(
{
name: "custom_emoji",
public: true,
indexes: [{ accessor: "by_name", algorithm: "btree", columns: ["name"] }],
},
{
id: t.u64().primaryKey().autoInc(),
name: t.string(),
category: t.string(),
data: t.byteArray(),
},
);
export const image = table(
{
name: "image",
public: false,
},
{
id: t.u64().primaryKey().autoInc(),
data: t.byteArray(),
mime_type: t.string(),
name: t.string().optional(),
},
);
export const typing_activity = table(
{
name: "typing_activity",
public: true,
indexes: [
{
accessor: "by_channel_id",
algorithm: "btree",
columns: ["channel_id"],
},
],
},
{
identity: t.identity().primaryKey(),
channel_id: t.u64(),
is_typing: t.bool(),
},
);
export const recent_message = table(
{
name: "recent_message",
public: false,
indexes: [{ accessor: "by_channel", algorithm: "btree", columns: ["channel_id"] }],
},
{
id: t.u64().primaryKey().autoInc(),
channel_id: t.u64(),
message_id: t.u64(),
sender: t.identity(),
text: t.string(),
thread_id: t.u64().optional(),
sent: t.timestamp(),
seq_id: t.u64(),
},
);
export const system_configuration = table(
{
name: "system_configuration",
public: true,
},
{
key: t.string().primaryKey(),
value: t.string(),
},
);
export const upload_status = table(
{
name: "upload_status",
public: true,
indexes: [
{ accessor: "by_identity", algorithm: "btree", columns: ["identity"] },
],
},
{
client_id: t.string().primaryKey(),
identity: t.identity(),
image_id: t.u64().optional(),
status: t.string(), // "pending", "success", "error"
error: t.string().optional(),
},
);
+112
View File
@@ -0,0 +1,112 @@
import { SenderError } from "spacetimedb/server";
export function validateName(name: string) {
if (!name || name.trim().length === 0)
throw new SenderError("Names must not be empty");
}
export function validateMessageLength(ctx: any, text: string) {
const maxLengthConf = ctx.db.system_configuration.key.find("max_message_length");
const maxLength = maxLengthConf ? parseInt(maxLengthConf.value) : 262144;
const byteLength = new TextEncoder().encode(text).length;
if (byteLength > maxLength)
throw new SenderError(`Message exceeds maximum length of ${maxLength} bytes (${Math.round(maxLength / 1024)}KB).`);
}
export function getNextSeqId(ctx: any, channelId: bigint): bigint {
const hwm = ctx.db.channel_high_water_mark.channel_id.find(channelId);
const nextSeqId = hwm ? hwm.last_seq_id + 1n : 1n;
if (hwm) {
ctx.db.channel_high_water_mark.channel_id.update({ ...hwm, last_seq_id: nextSeqId });
} else {
ctx.db.channel_high_water_mark.insert({ channel_id: channelId, last_seq_id: nextSeqId });
}
return nextSeqId;
}
export function getVisibleMessageIds(ctx: any): Map<bigint, bigint> {
const result = new Map<bigint, bigint>();
for (const member of ctx.db.server_member.by_identity.filter(ctx.sender)) {
for (const chan of ctx.db.channel.by_server_id.filter(member.server_id)) {
for (const rm of ctx.db.recent_message.by_channel.filter(chan.id)) {
result.set(rm.message_id, rm.seq_id);
}
}
}
const sub = ctx.db.channel_subscription.identity.find(ctx.sender);
if (sub) {
for (const cms of ctx.db.channel_message_sequence.by_channel.filter(sub.channel_id)) {
if (cms.seq_id >= sub.earliest_seq_id) {
result.set(cms.message_id, cms.seq_id);
}
}
}
return result;
}
export function getVisibleImageIds(ctx: any): Set<bigint> {
const ids = new Set<bigint>();
for (const member of ctx.db.server_member.by_identity.filter(ctx.sender)) {
const s = ctx.db.server.id.find(member.server_id);
if (s && s.avatar_id) ids.add(s.avatar_id);
for (const peer of ctx.db.server_member.by_server_id.filter(member.server_id)) {
const u = ctx.db.user.identity.find(peer.identity);
if (u) {
if (u.avatar_id) ids.add(u.avatar_id);
if (u.banner_id) ids.add(u.banner_id);
}
}
}
const visibleMsgIds = getVisibleMessageIds(ctx);
for (const msgId of visibleMsgIds.keys()) {
for (const mi of ctx.db.message_image.by_message_id.filter(msgId)) {
ids.add(mi.image_id);
}
}
return ids;
}
export function clearSignalingForUser(ctx: any, identity: any) {
ctx.db.voice_activity.identity.delete(identity);
for (const w of ctx.db.watching.by_watcher.filter(identity)) {
ctx.db.watching.id.delete(w.id);
}
for (const w of ctx.db.watching.by_watchee.filter(identity)) {
ctx.db.watching.id.delete(w.id);
}
for (const row of ctx.db.voice_sdp_offer.by_sender.filter(identity))
ctx.db.voice_sdp_offer.id.delete(row.id);
for (const row of ctx.db.voice_sdp_offer.by_receiver.filter(identity))
ctx.db.voice_sdp_offer.id.delete(row.id);
for (const row of ctx.db.voice_sdp_answer.by_sender.filter(identity))
ctx.db.voice_sdp_answer.id.delete(row.id);
for (const row of ctx.db.voice_sdp_answer.by_receiver.filter(identity))
ctx.db.voice_sdp_answer.id.delete(row.id);
for (const row of ctx.db.voice_ice_candidate.by_sender.filter(identity))
ctx.db.voice_ice_candidate.id.delete(row.id);
for (const row of ctx.db.voice_ice_candidate.by_receiver.filter(identity))
ctx.db.voice_ice_candidate.id.delete(row.id);
}
export function autoJoinCommunityServer(ctx: any) {
const communityServer = [...ctx.db.server.iter()].find(
(s) => s.name === "Zep",
);
if (communityServer) {
ctx.db.server_member.insert({
id: 0n,
identity: ctx.sender,
server_id: communityServer.id,
});
}
}
+142
View File
@@ -0,0 +1,142 @@
import { t } from "spacetimedb/server";
import { spacetimedb } from "./schema";
import { getVisibleImageIds, getVisibleMessageIds } from "./utils";
export const visible_images = spacetimedb.view(
{ name: "visible_images", public: true },
t.array(
t.row("visible_image_row", {
id: t.u64(),
data: t.byteArray(),
mime_type: t.string(),
name: t.string().optional(),
})
),
(ctx) => {
const imageIds = getVisibleImageIds(ctx);
const results: any[] = [];
for (const id of imageIds) {
const img = ctx.db.image.id.find(id);
if (img) {
results.push({
id: img.id,
// Explicitly copy the data to a new Uint8Array to ensure no buffer sharing issues
data: new Uint8Array(img.data),
mime_type: img.mime_type,
name: img.name ?? undefined
});
}
}
return results;
}
);
export const visible_messages = spacetimedb.view(
{ name: "visible_messages", public: true },
t.array(
t.row("visible_message_row", {
id: t.u64(),
sender: t.identity(),
sent: t.timestamp(),
text: t.string(),
channel_id: t.u64(),
thread_id: t.u64().optional(),
seq_id: t.u64(),
})
),
(ctx) => {
const visibleSeqMap = getVisibleMessageIds(ctx);
const results: any[] = [];
for (const [msgId, seqId] of visibleSeqMap.entries()) {
const msg = ctx.db.message.id.find(msgId);
if (msg) {
results.push({
id: msg.id,
sender: msg.sender,
sent: msg.sent,
text: msg.text,
channel_id: msg.channel_id,
thread_id: msg.thread_id ?? undefined,
seq_id: seqId
});
}
}
return results;
}
);
export const my_channel_subscriptions = spacetimedb.view(
{ name: "my_channel_subscriptions", public: true },
t.array(
t.row("my_channel_subscription_row", {
identity: t.identity(),
channel_id: t.u64(),
earliest_seq_id: t.u64(),
last_read_seq_id: t.u64(),
})
),
(ctx) => {
const sub = ctx.db.channel_subscription.identity.find(ctx.sender);
if (!sub) return [];
return [{
identity: sub.identity,
channel_id: sub.channel_id,
earliest_seq_id: sub.earliest_seq_id,
last_read_seq_id: sub.last_read_seq_id
}];
}
);
export const visible_message_images = spacetimedb.view(
{ name: "visible_message_images", public: true },
t.array(
t.row("visible_message_image_row", {
id: t.u64(),
message_id: t.u64(),
image_id: t.u64(),
})
),
(ctx) => {
const visibleSeqMap = getVisibleMessageIds(ctx);
const results: any[] = [];
for (const msgId of visibleSeqMap.keys()) {
for (const mi of ctx.db.message_image.by_message_id.filter(msgId)) {
results.push({
id: mi.id,
message_id: mi.message_id,
image_id: mi.image_id
});
}
}
return results;
}
);
export const visible_message_reactions = spacetimedb.view(
{ name: "visible_message_reactions", public: true },
t.array(
t.row("visible_message_reaction_row", {
id: t.u64(),
message_id: t.u64(),
identity: t.identity(),
emoji: t.string().optional(),
custom_emoji_id: t.u64().optional(),
})
),
(ctx) => {
const visibleSeqMap = getVisibleMessageIds(ctx);
const results: any[] = [];
for (const msgId of visibleSeqMap.keys()) {
for (const mr of ctx.db.message_reaction.by_message_id.filter(msgId)) {
results.push({
id: mr.id,
message_id: mr.message_id,
identity: mr.identity,
emoji: mr.emoji ?? undefined,
custom_emoji_id: mr.custom_emoji_id ?? undefined
});
}
}
return results;
}
);