@jgengine/convex 0.18.0 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -1,12 +1,14 @@
1
1
  import { defineSchema, defineTable, internalMutationGeneric, mutationGeneric, queryGeneric, } from "convex/server";
2
2
  import { ConvexError, v } from "convex/values";
3
+ import { validateChatMessage } from "@jgengine/core/game/chat";
3
4
  import { createFeedWriteGate, validateFeedWrite, } from "@jgengine/core/multiplayer/feedWriteGate";
4
5
  import { DEFAULT_POSE_SYNC_RULES, decidePoseSync, isPresenceExpired, pickReusablePresence, resolveActivePresence, spawnPresenceState, } from "@jgengine/core/multiplayer/presenceModel";
5
6
  import { commandScopeEscape, scopeWithActor } from "@jgengine/core/runtime/commandRunner";
6
- import { createGameRuntime } from "@jgengine/core/runtime/gameRuntime";
7
+ import { createGameRuntime, initialPlayerState } from "@jgengine/core/runtime/gameRuntime";
7
8
  import { buildHydratePlayers, planServerPersist, shouldAutoSave, trimFeedEntries, } from "@jgengine/core/runtime/hostPersistence";
8
9
  import { JG_MAX_MEMBERS_PER_SERVER, isListablePublicly, isPrivateJoinBlocked, isServerFull, isServerMember, selectJoinTarget, statusAfterLeave, validateSlotsPerServer, withJoinedMember, withoutMember, } from "@jgengine/core/runtime/hostPolicy";
9
- import { createEmptyServerRow, markAllPlayersDirty } from "@jgengine/core/runtime/snapshot";
10
+ import { createEmptyServerRow } from "@jgengine/core/runtime/snapshot";
11
+ import { chunkKeyAt, parseChunkKey, chunkKeysAround, DEFAULT_CHUNK_SIZE } from "@jgengine/core/runtime/worldChunks";
10
12
  import { applyCommandWithOcc, commitIfRevisionMatch } from "./occ.js";
11
13
  /** @internal Re-export shared host-policy pure helpers so existing convex imports keep working. */
12
14
  export { JG_MAX_MEMBERS_PER_SERVER, canJoinPrivateServer, isListablePublicly, validateSlotsPerServer, } from "@jgengine/core/runtime/hostPolicy";
@@ -24,6 +26,8 @@ export function jgengineTables() {
24
26
  modeConfig: v.optional(v.any()),
25
27
  visibility: v.optional(v.union(v.literal("public"), v.literal("private"))),
26
28
  joinCode: v.optional(v.string()),
29
+ topology: v.optional(v.union(v.literal("shared"), v.literal("rooms"))),
30
+ memberCount: v.optional(v.number()),
27
31
  memberUserIds: v.array(v.string()),
28
32
  slotsPerServer: v.number(),
29
33
  save: saveConfigValidator,
@@ -40,10 +44,31 @@ export function jgengineTables() {
40
44
  .index("by_status", ["status"])
41
45
  .index("by_game_status_tick", ["gameId", "status", "tickAnchorMs"])
42
46
  .index("by_dirty", ["dirtyAt"]),
47
+ jgServerCapacity: defineTable({
48
+ serverId: v.id("jgGameServers"),
49
+ gameId: v.string(),
50
+ status: v.union(v.literal("open"), v.literal("running"), v.literal("closed")),
51
+ mode: v.optional(v.string()),
52
+ memberCount: v.number(),
53
+ slotsPerServer: v.number(),
54
+ }).index("by_server", ["serverId"]),
55
+ jgServerMembers: defineTable({
56
+ serverId: v.id("jgGameServers"),
57
+ userId: v.string(),
58
+ joinedAt: v.number(),
59
+ }).index("by_server", ["serverId"]).index("by_server_and_user", ["serverId", "userId"]),
60
+ jgRateLimits: defineTable({
61
+ key: v.string(), startedAt: v.number(), count: v.number(), expiresAt: v.number(),
62
+ }).index("by_key", ["key"]).index("by_expires", ["expiresAt"]),
63
+ jgClientErrors: defineTable({
64
+ userId: v.string(), message: v.string(), details: v.optional(v.any()), createdAt: v.number(),
65
+ }).index("by_created", ["createdAt"]),
43
66
  jgPlayerProfiles: defineTable({
44
67
  userId: v.string(),
45
68
  gameId: v.string(),
46
69
  playerState: v.any(),
70
+ sessionState: v.optional(v.any()),
71
+ sessionServerId: v.optional(v.id("jgGameServers")),
47
72
  revision: v.number(),
48
73
  dirtyAt: v.optional(v.number()),
49
74
  createdAt: v.number(),
@@ -77,6 +102,10 @@ export function jgengineTables() {
77
102
  }).index("by_server_and_action", ["serverId", "action"]),
78
103
  jgPoses: defineTable({
79
104
  serverId: v.string(),
105
+ homeGameId: v.optional(v.string()),
106
+ ownerActorId: v.optional(v.string()),
107
+ lastWorldSnapshotAt: v.optional(v.number()),
108
+ chunkKey: v.optional(v.string()),
80
109
  userId: v.string(),
81
110
  /** One row per live session, so a second tab is a second row rather than a fight over one. */
82
111
  sessionId: v.optional(v.string()),
@@ -95,14 +124,22 @@ export function jgengineTables() {
95
124
  })
96
125
  .index("by_server", ["serverId"])
97
126
  .index("by_server_and_user", ["serverId", "userId"])
98
- .index("by_updated", ["updatedAt"]),
127
+ .index("by_server_and_chunk", ["serverId", "chunkKey"])
128
+ .index("by_server_user_revoked_updated", ["serverId", "userId", "revokedAt", "updatedAt"])
129
+ .index("by_updated", ["updatedAt"])
130
+ .index("by_user", ["userId"])
131
+ .index("by_home_game", ["homeGameId"])
132
+ .index("by_home_game_revoked_updated", ["homeGameId", "revokedAt", "updatedAt"])
133
+ .index("by_revoked", ["revokedAt"])
134
+ .index("by_revoked_updated", ["revokedAt", "updatedAt"]),
99
135
  jgChatMessages: defineTable({
100
136
  serverId: v.string(),
101
137
  channelId: v.string(),
102
138
  userId: v.string(),
103
139
  body: v.string(),
140
+ authorName: v.optional(v.string()),
104
141
  at: v.number(),
105
- }).index("by_server_channel", ["serverId", "channelId"]),
142
+ }).index("by_server_channel", ["serverId", "channelId", "at"]).index("by_at", ["at"]),
106
143
  };
107
144
  }
108
145
  /**
@@ -151,11 +188,19 @@ export async function resolveActor(ctx, claimedExternalId, mode) {
151
188
  }
152
189
  return null;
153
190
  }
191
+ async function hasMember(ctx, server, userId) {
192
+ if (server.memberUserIds.includes(userId))
193
+ return true;
194
+ if (server.topology !== "shared")
195
+ return false;
196
+ return (await ctx.db.query("jgServerMembers")
197
+ .withIndex("by_server_and_user", q => q.eq("serverId", server._id).eq("userId", userId)).unique()) !== null;
198
+ }
154
199
  async function requireServerMember(ctx, serverId, actorUserId) {
155
200
  const server = await ctx.db.get("jgGameServers", serverId);
156
201
  if (!server)
157
202
  return null;
158
- if (!isServerMember(server.memberUserIds, actorUserId))
203
+ if (!(await hasMember(ctx, server, actorUserId)))
159
204
  return null;
160
205
  return server;
161
206
  }
@@ -229,6 +274,21 @@ function serverDocToRecord(server) {
229
274
  updatedAt: server.updatedAt,
230
275
  };
231
276
  }
277
+ async function serverCapacity(ctx, server) {
278
+ return server.topology === "shared"
279
+ ? ctx.db.query("jgServerCapacity").withIndex("by_server", q => q.eq("serverId", server._id)).unique()
280
+ : null;
281
+ }
282
+ async function ensureServerCapacity(ctx, server) {
283
+ const existing = await serverCapacity(ctx, server);
284
+ if (existing)
285
+ return existing;
286
+ const id = await ctx.db.insert("jgServerCapacity", {
287
+ serverId: server._id, gameId: server.gameId, status: server.status, mode: server.mode,
288
+ memberCount: server.memberCount ?? server.memberUserIds.length, slotsPerServer: server.slotsPerServer,
289
+ });
290
+ return (await ctx.db.get("jgServerCapacity", id));
291
+ }
232
292
  /**
233
293
  * Hydrate a server's `GameRuntimeSnapshot` from its row, its members' profiles, and its world chunks.
234
294
  * Reach for it when writing your own mutation that must read runtime state before touching host tables.
@@ -238,14 +298,27 @@ function serverDocToRecord(server) {
238
298
  export async function loadServerSnapshot(ctx, server, runtime, scope) {
239
299
  const profiles = {};
240
300
  const memberIds = new Set(server.memberUserIds);
241
- const hydrateUserIds = scope?.players === undefined
242
- ? server.memberUserIds
243
- : scope.players.filter((userId) => memberIds.has(userId));
301
+ let hydrateUserIds;
302
+ if (server.topology === "shared") {
303
+ const requested = scope?.players ?? (await ctx.db.query("jgServerMembers")
304
+ .withIndex("by_server", q => q.eq("serverId", server._id)).collect()).map(row => row.userId);
305
+ hydrateUserIds = [];
306
+ for (const userId of new Set(requested))
307
+ if (await hasMember(ctx, server, userId))
308
+ hydrateUserIds.push(userId);
309
+ }
310
+ else {
311
+ hydrateUserIds = scope?.players === undefined ? server.memberUserIds : scope.players.filter(userId => memberIds.has(userId));
312
+ }
313
+ const sessionPlayers = {};
244
314
  for (const userId of hydrateUserIds) {
245
315
  const profile = await ctx.db
246
316
  .query("jgPlayerProfiles")
247
317
  .withIndex("by_user_and_game", (q) => q.eq("userId", userId).eq("gameId", server.gameId))
248
318
  .unique();
319
+ if (server.topology === "shared" && profile) {
320
+ sessionPlayers[userId] = { ...profile.playerState, ...(profile.sessionServerId === server._id ? { session: profile.sessionState ?? {} } : { session: {} }) };
321
+ }
249
322
  profiles[userId] = profile
250
323
  ? {
251
324
  userId,
@@ -256,7 +329,7 @@ export async function loadServerSnapshot(ctx, server, runtime, scope) {
256
329
  }
257
330
  : null;
258
331
  }
259
- const playersByUserId = buildHydratePlayers(serverDocToRecord(server), profiles, hydrateUserIds);
332
+ const playersByUserId = buildHydratePlayers(server.topology === "shared" ? { ...serverDocToRecord(server), memberUserIds: hydrateUserIds, sessionPlayers } : serverDocToRecord(server), profiles, hydrateUserIds);
260
333
  const chunksByKey = {};
261
334
  if (scope?.chunkKeys === undefined) {
262
335
  const chunks = await ctx.db
@@ -323,7 +396,9 @@ export async function applyLeaderboardIncrements(ctx, gameId, entries) {
323
396
  */
324
397
  export async function persistServerSnapshot(ctx, server, snapshot, save) {
325
398
  const now = Date.now();
326
- const plan = planServerPersist(serverDocToRecord(server), snapshot, save, now);
399
+ const shared = server.topology === "shared";
400
+ const record = serverDocToRecord(server);
401
+ const plan = planServerPersist(shared ? { ...record, memberUserIds: Object.keys(snapshot.players), sessionPlayers: {} } : record, snapshot, save, now);
327
402
  if (!plan.changed.any)
328
403
  return false;
329
404
  if (plan.leaderboard.length > 0) {
@@ -336,6 +411,7 @@ export async function persistServerSnapshot(ctx, server, snapshot, save) {
336
411
  .unique();
337
412
  const profilePatch = {
338
413
  playerState: profile.playerState,
414
+ ...(shared ? { sessionState: snapshot.players[profile.userId]?.session ?? {}, sessionServerId: server._id } : {}),
339
415
  revision: profile.revision,
340
416
  updatedAt: profile.updatedAt,
341
417
  };
@@ -351,6 +427,22 @@ export async function persistServerSnapshot(ctx, server, snapshot, save) {
351
427
  });
352
428
  }
353
429
  }
430
+ if (shared) {
431
+ const persisted = new Set(plan.profiles.map(profile => profile.userId));
432
+ for (const userId of snapshot.dirty.players) {
433
+ if (persisted.has(userId))
434
+ continue;
435
+ const player = snapshot.players[userId];
436
+ if (!player)
437
+ continue;
438
+ const existing = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", userId).eq("gameId", server.gameId)).unique();
439
+ const patch = { playerState: player, sessionState: player.session, sessionServerId: server._id, revision: snapshot.revision, updatedAt: now };
440
+ if (existing)
441
+ await ctx.db.patch(existing._id, patch);
442
+ else
443
+ await ctx.db.insert("jgPlayerProfiles", { userId, gameId: server.gameId, createdAt: now, ...patch });
444
+ }
445
+ }
354
446
  for (const chunk of plan.chunks) {
355
447
  const existing = await ctx.db
356
448
  .query("jgWorldChunks")
@@ -380,24 +472,25 @@ export async function persistServerSnapshot(ctx, server, snapshot, save) {
380
472
  if (existing)
381
473
  await ctx.db.delete(existing._id);
382
474
  }
383
- await ctx.db.patch(server._id, {
384
- ...(plan.changed.serverState ? { serverState: plan.server.serverState } : {}),
385
- ...(plan.changed.sessionPlayers ? { sessionPlayers: plan.server.sessionPlayers } : {}),
386
- revision: plan.server.revision,
387
- dirtyAt: plan.server.dirtyAt,
388
- updatedAt: plan.server.updatedAt,
389
- lastSavedAt: plan.server.lastSavedAt,
390
- });
475
+ if (!shared || plan.changed.serverState)
476
+ await ctx.db.patch(server._id, {
477
+ ...(plan.changed.serverState ? { serverState: plan.server.serverState } : {}),
478
+ ...(!shared && plan.changed.sessionPlayers ? { sessionPlayers: plan.server.sessionPlayers } : {}),
479
+ revision: plan.server.revision,
480
+ dirtyAt: plan.server.dirtyAt,
481
+ updatedAt: plan.server.updatedAt,
482
+ lastSavedAt: plan.server.lastSavedAt,
483
+ });
391
484
  return true;
392
485
  }
393
486
  /** Autosave a server whose `save.auto` interval has elapsed, reusing an already-hydrated snapshot. */
394
- async function flushServerIfDue(ctx, server, now, runtime, loaded) {
487
+ async function flushServerIfDue(ctx, server, now, _runtime, loaded) {
395
488
  if (!shouldAutoSave(server.save, server.dirtyAt, server.lastSavedAt, now)) {
396
489
  return false;
397
490
  }
398
- // Unscoped on purpose: markAllPlayersDirty writes every member, so every member has to be hydrated.
399
- const snapshot = loaded ?? (await loadServerSnapshot(ctx, server, runtime));
400
- await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save);
491
+ if (loaded)
492
+ await persistServerSnapshot(ctx, server, loaded, server.save);
493
+ await ctx.db.patch(server._id, { dirtyAt: undefined, lastSavedAt: now });
401
494
  return true;
402
495
  }
403
496
  export const JG_RUNTIME_TICK_MS = 1_000;
@@ -408,10 +501,9 @@ export const MAX_CHUNKS_PER_QUERY = 64;
408
501
  /**
409
502
  * Build the hosted-server Convex functions for a set of game runtimes.
410
503
  *
411
- * **Supported world size.** One server is one Convex document holding the world row, the member
412
- * roster, and every member's session state, and the host rewrites that document whenever the world
413
- * changes. `slotsPerServer` is therefore capped at {@link JG_MAX_MEMBERS_PER_SERVER} (256) and a
414
- * larger value throws here, at wiring time, rather than at the write that overflows the document.
504
+ * Room topology keeps its roster and session map in the server document and caps capacity at
505
+ * {@link JG_MAX_MEMBERS_PER_SERVER}. Shared topology stores membership, capacity, and per-player
506
+ * session state separately; player-only commands and roster changes do not rewrite the world row.
415
507
  * World geometry that outgrows one row belongs in `jgWorldChunks`, hydrated by key
416
508
  * ({@link LoadSnapshotScope}) and read by clients through `getChunks` — not in `serverState`.
417
509
  *
@@ -421,11 +513,12 @@ export const MAX_CHUNKS_PER_QUERY = 64;
421
513
  */
422
514
  export function createGameServerFunctions(options) {
423
515
  const mode = options?.auth ?? "required";
424
- const hostSlotsPerServer = options?.slotsPerServer ?? 16;
425
- const slotsCheck = validateSlotsPerServer(hostSlotsPerServer);
516
+ const shared = options?.topology === "shared" || (options?.topology === undefined && (options?.runtimes?.some(runtime => runtime.topology === "shared") ?? false));
517
+ const hostSlotsPerServer = shared ? Number.MAX_SAFE_INTEGER : options?.slotsPerServer ?? 16;
518
+ const slotsCheck = validateSlotsPerServer(hostSlotsPerServer, shared ? "shared" : "rooms");
426
519
  if (!slotsCheck.ok)
427
520
  throw new Error(slotsCheck.reason);
428
- const matchmaking = options?.matchmaking ?? "auto";
521
+ const matchmaking = options?.matchmaking ?? (shared ? "singleton" : "auto");
429
522
  const maxServersPerTick = Math.max(1, options?.maxServersPerTick ?? DEFAULT_MAX_SERVERS_PER_TICK);
430
523
  const feedWriteGate = createFeedWriteGate(options?.allowedFeedActions ?? []);
431
524
  const registry = buildRuntimeRegistry(options?.runtimes);
@@ -442,23 +535,24 @@ export function createGameServerFunctions(options) {
442
535
  joinCode: v.optional(v.string()),
443
536
  externalId: v.optional(v.string()),
444
537
  },
445
- returns: v.object({
446
- serverId: v.id("jgGameServers"),
447
- isNew: v.boolean(),
448
- }),
538
+ returns: v.union(v.object({ ok: v.literal(true), serverId: v.id("jgGameServers"), isNew: v.boolean() }), v.object({ ok: v.literal(false), reason: v.union(v.literal("full"), v.literal("closed"), v.literal("unauthorized")) })),
449
539
  handler: async (ctx, args) => {
450
- const actorUserId = await requireActor(ctx, args.externalId, mode);
540
+ const actorUserId = await resolveActor(ctx, args.externalId, mode);
541
+ if (!actorUserId)
542
+ return { ok: false, reason: "unauthorized" };
451
543
  const now = Date.now();
452
544
  const runtime = resolveRuntime(registry, args.gameId);
453
545
  const save = runtime.save;
454
546
  const slotsPerServer = hostSlotsPerServer;
455
547
  let server = args.serverId !== undefined ? await ctx.db.get("jgGameServers", args.serverId) : null;
456
548
  if (args.serverId !== undefined && !server) {
457
- throw new ConvexError("Server not found");
549
+ return { ok: false, reason: "closed" };
458
550
  }
459
551
  if (server && server.gameId !== args.gameId) {
460
- throw new ConvexError("Server belongs to a different game");
552
+ return { ok: false, reason: "unauthorized" };
461
553
  }
554
+ if (server?.status === "closed")
555
+ return { ok: false, reason: "closed" };
462
556
  let mayCreate = true;
463
557
  if (!server) {
464
558
  const joinable = [];
@@ -466,7 +560,7 @@ export function createGameServerFunctions(options) {
466
560
  const rows = await ctx.db
467
561
  .query("jgGameServers")
468
562
  .withIndex("by_game_and_status", (q) => q.eq("gameId", args.gameId).eq("status", status))
469
- .collect();
563
+ .take(100);
470
564
  joinable.push(...rows);
471
565
  }
472
566
  // Two concurrent first-joins can both see an empty list here and both insert. Convex's OCC
@@ -479,7 +573,7 @@ export function createGameServerFunctions(options) {
479
573
  slotsPerServer,
480
574
  });
481
575
  if (target.kind === "refuse")
482
- throw new ConvexError(target.reason);
576
+ return { ok: false, reason: "full" };
483
577
  server = target.kind === "join" ? target.row : null;
484
578
  mayCreate = target.kind === "create";
485
579
  }
@@ -491,6 +585,8 @@ export function createGameServerFunctions(options) {
491
585
  modeConfig: args.modeConfig,
492
586
  visibility: args.visibility ?? "public",
493
587
  joinCode: args.joinCode,
588
+ topology: shared ? "shared" : "rooms",
589
+ memberCount: 0,
494
590
  memberUserIds: [],
495
591
  slotsPerServer,
496
592
  save,
@@ -516,37 +612,64 @@ export function createGameServerFunctions(options) {
516
612
  throw new ConvexError("Server missing after reconcile");
517
613
  server = reconciled;
518
614
  }
519
- if (isServerFull(server.memberUserIds, server.slotsPerServer, actorUserId)) {
520
- throw new ConvexError("Server is full");
615
+ if (!shared && isServerFull(server.memberUserIds, server.slotsPerServer, actorUserId)) {
616
+ return { ok: false, reason: "full" };
521
617
  }
522
618
  if (isPrivateJoinBlocked({
523
619
  visibility: server.visibility,
524
- memberUserIds: server.memberUserIds,
620
+ memberUserIds: await hasMember(ctx, server, actorUserId) ? [actorUserId] : [],
525
621
  userId: actorUserId,
526
622
  joinCode: server.joinCode,
527
623
  suppliedCode: args.joinCode,
528
624
  })) {
529
- throw new ConvexError("Server is private");
625
+ return { ok: false, reason: "unauthorized" };
530
626
  }
531
- const profile = await ctx.db
627
+ let profile = await ctx.db
532
628
  .query("jgPlayerProfiles")
533
629
  .withIndex("by_user_and_game", (q) => q.eq("userId", actorUserId).eq("gameId", args.gameId))
534
630
  .unique();
631
+ if (shared && server.topology !== "shared") {
632
+ for (const userId of server.memberUserIds) {
633
+ const member = await ctx.db.query("jgServerMembers").withIndex("by_server_and_user", q => q.eq("serverId", server._id).eq("userId", userId)).unique();
634
+ if (!member)
635
+ await ctx.db.insert("jgServerMembers", { serverId: server._id, userId, joinedAt: now });
636
+ const state = server.sessionPlayers[userId];
637
+ if (!state)
638
+ continue;
639
+ const stored = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", userId).eq("gameId", server.gameId)).unique();
640
+ const patch = { sessionState: state.session ?? {}, sessionServerId: server._id };
641
+ if (stored)
642
+ await ctx.db.patch(stored._id, patch);
643
+ else
644
+ await ctx.db.insert("jgPlayerProfiles", { userId, gameId: server.gameId, playerState: state, revision: server.revision, createdAt: now, updatedAt: now, ...patch });
645
+ }
646
+ await ctx.db.patch(server._id, { topology: "shared", memberCount: server.memberUserIds.length, memberUserIds: [], sessionPlayers: {} });
647
+ server = (await ctx.db.get("jgGameServers", server._id));
648
+ profile = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", actorUserId).eq("gameId", args.gameId)).unique();
649
+ }
535
650
  const isNew = profile === null;
536
651
  const memberUserIds = withJoinedMember(server.memberUserIds, actorUserId);
537
- await ctx.db.patch(server._id, {
538
- memberUserIds,
539
- status: "running",
540
- updatedAt: now,
541
- dirtyAt: now,
542
- });
652
+ if (shared) {
653
+ const capacity = await ensureServerCapacity(ctx, server);
654
+ const isMember = await hasMember(ctx, server, actorUserId);
655
+ await ctx.db.patch(capacity._id, {
656
+ memberCount: capacity.memberCount + (isMember ? 0 : 1), status: "running", slotsPerServer,
657
+ });
658
+ if (!isMember)
659
+ await ctx.db.insert("jgServerMembers", { serverId: server._id, userId: actorUserId, joinedAt: now });
660
+ if (server.status !== "running")
661
+ await ctx.db.patch(server._id, { status: "running" });
662
+ }
663
+ else {
664
+ await ctx.db.patch(server._id, { memberUserIds, status: "running", updatedAt: now });
665
+ }
543
666
  const refreshed = await ctx.db.get("jgGameServers", server._id);
544
667
  if (!refreshed)
545
668
  throw new ConvexError("Server missing after join");
546
669
  let snapshot = await loadServerSnapshot(ctx, refreshed, runtime, scopeWithActor(runtime.joinScope(actorUserId, isNew), actorUserId));
547
- snapshot = runtime.joinPlayer(snapshot, actorUserId, isNew);
670
+ snapshot = runtime.joinPlayer(snapshot, actorUserId, isNew, now);
548
671
  await persistServerSnapshot(ctx, refreshed, snapshot, save);
549
- return { serverId: refreshed._id, isNew };
672
+ return { ok: true, serverId: refreshed._id, isNew };
550
673
  },
551
674
  });
552
675
  const leaveServer = mutation({
@@ -571,23 +694,65 @@ export function createGameServerFunctions(options) {
571
694
  const memberUserIds = withoutMember(server.memberUserIds, actorUserId);
572
695
  const sessionPlayers = { ...server.sessionPlayers };
573
696
  delete sessionPlayers[actorUserId];
574
- await ctx.db.patch(server._id, {
575
- memberUserIds,
576
- sessionPlayers,
577
- updatedAt: now,
578
- dirtyAt: now,
579
- status: statusAfterLeave(memberUserIds.length, server.status),
580
- });
581
- const pose = await ctx.db
697
+ if (server.topology === "shared") {
698
+ const member = await ctx.db.query("jgServerMembers").withIndex("by_server_and_user", q => q.eq("serverId", server._id).eq("userId", actorUserId)).unique();
699
+ if (member) {
700
+ const capacity = await ensureServerCapacity(ctx, server);
701
+ await ctx.db.delete(member._id);
702
+ await ctx.db.patch(capacity._id, { memberCount: Math.max(0, capacity.memberCount - 1) });
703
+ const profile = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", actorUserId).eq("gameId", server.gameId)).unique();
704
+ if (profile?.sessionServerId === server._id)
705
+ await ctx.db.patch(profile._id, { sessionState: {}, sessionServerId: undefined });
706
+ }
707
+ }
708
+ else {
709
+ await ctx.db.patch(server._id, { memberUserIds, sessionPlayers, updatedAt: now, status: statusAfterLeave(memberUserIds.length, server.status) });
710
+ }
711
+ const poses = await ctx.db
582
712
  .query("jgPoses")
583
713
  .withIndex("by_server_and_user", (q) => q.eq("serverId", args.serverId).eq("userId", actorUserId))
584
- .unique();
585
- if (pose)
714
+ .collect();
715
+ for (const pose of poses)
586
716
  await ctx.db.delete(pose._id);
587
717
  return null;
588
718
  },
589
719
  });
590
720
  const helpers = {
721
+ async ensureServer(ctx, gameId) {
722
+ if (!shared || matchmaking !== "singleton")
723
+ throw new Error("ensureServer requires a shared singleton host");
724
+ for (const status of ["running", "open"]) {
725
+ const existing = await ctx.db.query("jgGameServers").withIndex("by_game_and_status", q => q.eq("gameId", gameId).eq("status", status)).first();
726
+ if (existing)
727
+ return existing;
728
+ }
729
+ const runtime = resolveRuntime(registry, gameId);
730
+ const now = Date.now();
731
+ const id = await ctx.db.insert("jgGameServers", {
732
+ gameId, topology: "shared", status: "running", memberUserIds: [], slotsPerServer: hostSlotsPerServer,
733
+ save: runtime.save, serverState: defaultServerStateForGame(gameId), sessionPlayers: {}, revision: 0,
734
+ tickAnchorMs: now, createdAt: now, updatedAt: now,
735
+ });
736
+ const server = (await ctx.db.get("jgGameServers", id));
737
+ await ensureServerCapacity(ctx, server);
738
+ return server;
739
+ },
740
+ async resetPlayerProfile(ctx, serverId, userId) {
741
+ const server = await requireServerMember(ctx, serverId, userId);
742
+ if (!server)
743
+ throw new ConvexError("Not a member of this server");
744
+ const state = initialPlayerState(resolveRuntime(registry, server.gameId), userId);
745
+ const profile = await ctx.db.query("jgPlayerProfiles").withIndex("by_user_and_game", q => q.eq("userId", userId).eq("gameId", server.gameId)).unique();
746
+ const now = Date.now();
747
+ const patch = { playerState: state, sessionState: state.session, sessionServerId: server._id, revision: (profile?.revision ?? 0) + 1, updatedAt: now };
748
+ if (profile)
749
+ await ctx.db.patch(profile._id, patch);
750
+ else
751
+ await ctx.db.insert("jgPlayerProfiles", { userId, gameId: server.gameId, createdAt: now, ...patch });
752
+ if (server.topology !== "shared")
753
+ await ctx.db.patch(server._id, { sessionPlayers: { ...server.sessionPlayers, [userId]: state } });
754
+ return state;
755
+ },
591
756
  async loadSnapshot(ctx, serverId, scope) {
592
757
  const server = await ctx.db.get("jgGameServers", serverId);
593
758
  if (!server)
@@ -602,13 +767,13 @@ export function createGameServerFunctions(options) {
602
767
  return persistServerSnapshot(ctx, server, snapshot, save ?? server.save);
603
768
  },
604
769
  async runCommand(ctx, args) {
605
- const actorUserId = await requireActor(ctx, args.externalId, mode);
770
+ const actorUserId = args.actorUserId ?? await requireActor(ctx, args.externalId, mode);
606
771
  const serverId = args.serverId;
607
772
  const server = await ctx.db.get("jgGameServers", serverId);
608
773
  if (!server) {
609
774
  return { ok: false, reason: "Server not found" };
610
775
  }
611
- if (!isServerMember(server.memberUserIds, actorUserId)) {
776
+ if (!(await hasMember(ctx, server, actorUserId))) {
612
777
  return { ok: false, reason: "Not a member of this server" };
613
778
  }
614
779
  const loadedRevision = server.revision;
@@ -639,6 +804,10 @@ export function createGameServerFunctions(options) {
639
804
  if (!commit.ok) {
640
805
  return commit;
641
806
  }
807
+ await args.beforePersist?.(ctx, { server: latest, runtime, snapshot: result.snapshot });
808
+ const callbackEscape = commandScopeEscape(scope, result.snapshot.dirty, args.command);
809
+ if (callbackEscape !== null)
810
+ throw new ConvexError(callbackEscape);
642
811
  await persistServerSnapshot(ctx, latest, result.snapshot, latest.save);
643
812
  return { ok: true };
644
813
  },
@@ -655,7 +824,7 @@ export function createGameServerFunctions(options) {
655
824
  })),
656
825
  },
657
826
  returns: v.union(v.object({ ok: v.literal(true) }), v.object({ ok: v.literal(false), reason: v.string() })),
658
- handler: (ctx, args) => helpers.runCommand(ctx, args),
827
+ handler: (ctx, args) => helpers.runCommand(ctx, { serverId: args.serverId, command: args.command, input: args.input, externalId: args.externalId }),
659
828
  });
660
829
  const flushSave = mutation({
661
830
  args: {
@@ -668,10 +837,7 @@ export function createGameServerFunctions(options) {
668
837
  const server = await requireServerMember(ctx, args.serverId, actorUserId);
669
838
  if (server === null)
670
839
  return false;
671
- const runtime = resolveRuntime(registry, server.gameId);
672
- // Unscoped on purpose: markAllPlayersDirty writes every member.
673
- const snapshot = await loadServerSnapshot(ctx, server, runtime);
674
- await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save);
840
+ await ctx.db.patch(server._id, { dirtyAt: undefined, lastSavedAt: Date.now() });
675
841
  return true;
676
842
  },
677
843
  });
@@ -727,20 +893,38 @@ export function createGameServerFunctions(options) {
727
893
  mode: v.optional(v.string()),
728
894
  memberCount: v.number(),
729
895
  slotsPerServer: v.number(),
730
- revision: v.number(),
731
- updatedAt: v.number(),
732
896
  }), v.null()),
733
- handler: (ctx, args) => withMember(ctx, mode, args, null, (server) => ({
897
+ handler: (ctx, args) => withMember(ctx, mode, args, null, async (server) => ({
734
898
  _id: server._id,
735
899
  gameId: server.gameId,
736
900
  status: server.status,
737
901
  mode: server.mode,
738
- memberCount: server.memberUserIds.length,
902
+ memberCount: (await serverCapacity(ctx, server))?.memberCount ?? server.memberCount ?? server.memberUserIds.length,
739
903
  slotsPerServer: server.slotsPerServer,
740
- revision: server.revision,
741
- updatedAt: server.updatedAt,
742
904
  })),
743
905
  });
906
+ const getServerCapacity = query({
907
+ args: { serverId: v.id("jgGameServers"), externalId: v.optional(v.string()) },
908
+ returns: v.union(v.object({
909
+ _id: v.id("jgGameServers"), gameId: v.string(),
910
+ status: v.union(v.literal("open"), v.literal("running"), v.literal("closed")),
911
+ mode: v.optional(v.string()), memberCount: v.number(), slotsPerServer: v.number(),
912
+ }), v.null()),
913
+ handler: async (ctx, args) => {
914
+ const actor = await resolveActor(ctx, args.externalId, mode);
915
+ if (!actor)
916
+ return null;
917
+ const capacity = await ctx.db.query("jgServerCapacity").withIndex("by_server", q => q.eq("serverId", args.serverId)).unique();
918
+ if (capacity) {
919
+ const member = await ctx.db.query("jgServerMembers").withIndex("by_server_and_user", q => q.eq("serverId", args.serverId).eq("userId", actor)).unique();
920
+ if (!member)
921
+ return null;
922
+ return { _id: capacity.serverId, gameId: capacity.gameId, status: capacity.status, mode: capacity.mode, memberCount: capacity.memberCount, slotsPerServer: capacity.slotsPerServer };
923
+ }
924
+ const server = await requireServerMember(ctx, args.serverId, actor);
925
+ return server ? { _id: server._id, gameId: server.gameId, status: server.status, mode: server.mode, memberCount: server.memberUserIds.length, slotsPerServer: server.slotsPerServer } : null;
926
+ },
927
+ });
744
928
  /**
745
929
  * Read world chunks by key — the client half of the chunk escape hatch. Without it a game that moved
746
930
  * geometry out of `serverState` had no way to show it, because `getServer` returns only the row.
@@ -874,17 +1058,17 @@ export function createGameServerFunctions(options) {
874
1058
  .query("jgGameServers")
875
1059
  .withIndex("by_game_and_status", (q) => q.eq("gameId", args.gameId).eq("status", "running"))
876
1060
  .take(Math.min(limit * 4, 200));
877
- return rows
1061
+ return Promise.all(rows
878
1062
  .filter((row) => isListablePublicly(row.visibility))
879
1063
  .slice(0, limit)
880
- .map((row) => ({
1064
+ .map(async (row) => ({
881
1065
  _id: row._id,
882
1066
  status: row.status,
883
- memberCount: row.memberUserIds.length,
1067
+ memberCount: (await serverCapacity(ctx, row))?.memberCount ?? row.memberCount ?? row.memberUserIds.length,
884
1068
  slotsPerServer: row.slotsPerServer,
885
1069
  mode: row.mode,
886
1070
  updatedAt: row.updatedAt,
887
- }));
1071
+ })));
888
1072
  },
889
1073
  });
890
1074
  // Only games that declare `loop.onTick` are swept, and only `maxServersPerTick` of their servers per
@@ -906,7 +1090,7 @@ export function createGameServerFunctions(options) {
906
1090
  .withIndex("by_game_status_tick", (q) => q.eq("gameId", gameId).eq("status", "running"))
907
1091
  .take(budget);
908
1092
  for (const server of servers) {
909
- if (server.memberUserIds.length === 0)
1093
+ if (((await serverCapacity(ctx, server))?.memberCount ?? server.memberCount ?? server.memberUserIds.length) === 0)
910
1094
  continue;
911
1095
  const elapsedMs = now - server.tickAnchorMs;
912
1096
  if (elapsedMs < JG_RUNTIME_TICK_MS)
@@ -915,7 +1099,7 @@ export function createGameServerFunctions(options) {
915
1099
  const runtime = resolveRuntime(registry, server.gameId);
916
1100
  // Unscoped on purpose: onTick is handed the whole world and may touch any of it.
917
1101
  const loaded = await loadServerSnapshot(ctx, server, runtime);
918
- const snapshot = runtime.tick(loaded, elapsedMs / 1_000);
1102
+ const snapshot = runtime.tick(loaded, elapsedMs / 1_000, now);
919
1103
  await persistServerSnapshot(ctx, server, snapshot, server.save);
920
1104
  await ctx.db.patch(server._id, { tickAnchorMs: now });
921
1105
  ticked += 1;
@@ -937,10 +1121,7 @@ export function createGameServerFunctions(options) {
937
1121
  .take(maxServersPerTick);
938
1122
  let saved = 0;
939
1123
  for (const server of servers) {
940
- const runtime = resolveRuntime(registry, server.gameId);
941
- // Unscoped on purpose: markAllPlayersDirty writes every member.
942
- const snapshot = await loadServerSnapshot(ctx, server, runtime);
943
- await persistServerSnapshot(ctx, server, markAllPlayersDirty(snapshot), server.save);
1124
+ await ctx.db.patch(server._id, { dirtyAt: undefined, lastSavedAt: Date.now() });
944
1125
  saved += 1;
945
1126
  }
946
1127
  return { saved };
@@ -953,6 +1134,7 @@ export function createGameServerFunctions(options) {
953
1134
  flushSave,
954
1135
  getServer,
955
1136
  getServerMeta,
1137
+ getServerCapacity,
956
1138
  getChunks,
957
1139
  getPlayerProfile,
958
1140
  getFeed,
@@ -1054,6 +1236,7 @@ export function createPresenceFunctions(options) {
1054
1236
  const list = query({
1055
1237
  args: {
1056
1238
  serverId: v.string(),
1239
+ viewerChunkKey: v.optional(v.string()),
1057
1240
  externalId: v.optional(v.string()),
1058
1241
  },
1059
1242
  returns: v.array(v.object({
@@ -1067,12 +1250,17 @@ export function createPresenceFunctions(options) {
1067
1250
  lastSeenAt: v.number(),
1068
1251
  })),
1069
1252
  handler: async (ctx, args) => {
1070
- return withMember(ctx, mode, args, [], async () => {
1253
+ return withMember(ctx, mode, args, [], async (_server, actorUserId) => {
1071
1254
  const threshold = Date.now() - freshWindowMs;
1072
- const rows = await ctx.db
1073
- .query("jgPoses")
1074
- .withIndex("by_server", (q) => q.eq("serverId", args.serverId))
1075
- .collect();
1255
+ const own = args.viewerChunkKey === undefined ? await ctx.db.query("jgPoses")
1256
+ .withIndex("by_server_and_user", q => q.eq("serverId", args.serverId).eq("userId", actorUserId)).first() : null;
1257
+ const center = parseChunkKey(args.viewerChunkKey ?? (own ? chunkKeyAt([own.x, own.y, own.z]) : "0,0"));
1258
+ if (!center)
1259
+ return [];
1260
+ const rows = [];
1261
+ for (const chunkKey of chunkKeysAround([center.cx * DEFAULT_CHUNK_SIZE, 0, center.cz * DEFAULT_CHUNK_SIZE], 1)) {
1262
+ rows.push(...await ctx.db.query("jgPoses").withIndex("by_server_and_chunk", q => q.eq("serverId", args.serverId).eq("chunkKey", chunkKey)).take(256));
1263
+ }
1076
1264
  return rows
1077
1265
  .filter((row) => row.revokedAt === undefined && row.updatedAt >= threshold)
1078
1266
  .map((row) => ({
@@ -1122,6 +1310,9 @@ export function createPresenceFunctions(options) {
1122
1310
  ? (resolveActivePresence(resolvable).active ?? pickReusablePresence(resolvable))?.row
1123
1311
  : rows.find((row) => row.sessionId === args.sessionId);
1124
1312
  const rules = rulesFor(args.kind ?? existing?.kind);
1313
+ if (existing && now - existing.updatedAt < (rules.minIntervalMs ?? 100)) {
1314
+ return { pose: { x: existing.x, y: existing.y, z: existing.z, rotationY: existing.rotationY, rotationPitch: existing.rotationPitch }, lastSeenAt: existing.updatedAt, displaced: existing.revokedAt !== undefined };
1315
+ }
1125
1316
  const current = existing === undefined
1126
1317
  ? spawnPresenceState((await options?.resolveSpawn?.(ctx, {
1127
1318
  serverId: args.serverId,
@@ -1157,7 +1348,7 @@ export function createPresenceFunctions(options) {
1157
1348
  const displaced = existing?.revokedAt !== undefined;
1158
1349
  if (existing) {
1159
1350
  if (decision.changed || decision.refreshKeepAlive || displaced || Object.keys(identity).length > 0) {
1160
- await ctx.db.patch(existing._id, { ...pose, ...identity, updatedAt: now, revokedAt: undefined });
1351
+ await ctx.db.patch(existing._id, { ...pose, ...identity, chunkKey: chunkKeyAt([pose.x, pose.y, pose.z]), updatedAt: now, revokedAt: undefined });
1161
1352
  }
1162
1353
  }
1163
1354
  else {
@@ -1165,6 +1356,7 @@ export function createPresenceFunctions(options) {
1165
1356
  serverId: args.serverId,
1166
1357
  userId: actorUserId,
1167
1358
  ...pose,
1359
+ chunkKey: chunkKeyAt([pose.x, pose.y, pose.z]),
1168
1360
  ...identity,
1169
1361
  updatedAt: now,
1170
1362
  });
@@ -1230,12 +1422,130 @@ export function createPresenceFunctions(options) {
1230
1422
  });
1231
1423
  return { list, sync, leave, reapIdlePresence };
1232
1424
  }
1425
+ /**
1426
+ * Scan one bounded presence page and schedule its handler and continuation in separate transactions.
1427
+ * @capability online-player-batches Schedule bounded active-player or home-game work in separate transactions.
1428
+ */
1429
+ export async function forEachOnlinePlayer(ctx, options) {
1430
+ const batchSize = options.batchSize ?? 25;
1431
+ if (!Number.isSafeInteger(batchSize) || batchSize < 1 || batchSize > 256)
1432
+ throw new Error("batchSize must be between 1 and 256");
1433
+ const nowMs = options.nowMs ?? Date.now();
1434
+ const freshWindowMs = options.freshWindowMs ?? 60_000;
1435
+ if (!Number.isFinite(nowMs) || !Number.isFinite(freshWindowMs) || freshWindowMs <= 0 || !Number.isFinite(nowMs - freshWindowMs))
1436
+ throw new Error("Invalid online-player time window");
1437
+ const page = await ctx.db.query("jgPoses").withIndex("by_updated", q => q.gte("updatedAt", nowMs - freshWindowMs))
1438
+ .paginate({ numItems: batchSize, cursor: options.cursor ?? null });
1439
+ const players = [];
1440
+ for (const row of page.page) {
1441
+ if (row.revokedAt !== undefined)
1442
+ continue;
1443
+ const canonical = options.groupBy === "homeGameId" && row.homeGameId
1444
+ ? await ctx.db.query("jgPoses").withIndex("by_home_game_revoked_updated", q => q.eq("homeGameId", row.homeGameId).eq("revokedAt", undefined)).order("desc").first()
1445
+ : await ctx.db.query("jgPoses").withIndex("by_server_user_revoked_updated", q => q.eq("serverId", row.serverId).eq("userId", row.userId).eq("revokedAt", undefined)).order("desc").first();
1446
+ if (canonical?._id === row._id)
1447
+ players.push({ serverId: row.serverId, userId: row.userId, ...(row.homeGameId ? { homeGameId: row.homeGameId } : {}) });
1448
+ }
1449
+ if (players.length)
1450
+ await ctx.scheduler.runAfter(0, options.handler, { players, nowMs });
1451
+ if (!page.isDone)
1452
+ await ctx.scheduler.runAfter(0, options.continuation, { cursor: page.continueCursor, nowMs });
1453
+ return { scheduled: players.length, remaining: !page.isDone };
1454
+ }
1455
+ /**
1456
+ * Consume one request in a fixed rate window; concurrent callers serialize on the indexed key.
1457
+ * @capability host-rate-limit Enforce indexed per-key request windows inside authoritative mutations.
1458
+ */
1459
+ export async function rateLimit(ctx, args) {
1460
+ const now = args.nowMs ?? Date.now();
1461
+ if (!args.key || args.key.length > 512 || !Number.isFinite(now) || !Number.isFinite(args.windowMs) || args.windowMs <= 0 || !Number.isFinite(now + args.windowMs) || !Number.isSafeInteger(args.max) || args.max < 1)
1462
+ throw new Error("Invalid rate limit policy");
1463
+ const row = await ctx.db.query("jgRateLimits").withIndex("by_key", q => q.eq("key", args.key)).unique();
1464
+ if (row && now < row.expiresAt && row.count >= args.max)
1465
+ return { ok: false, retryAfterMs: row.expiresAt - now };
1466
+ const patch = !row || now >= row.expiresAt ? { startedAt: now, expiresAt: now + args.windowMs, count: 1 } : { startedAt: row.startedAt, expiresAt: row.expiresAt, count: row.count + 1 };
1467
+ if (row)
1468
+ await ctx.db.patch(row._id, patch);
1469
+ else
1470
+ await ctx.db.insert("jgRateLimits", { key: args.key, ...patch });
1471
+ return { ok: true, retryAfterMs: 0 };
1472
+ }
1473
+ /**
1474
+ * Write validated chat in a host transaction after the caller resolves its actor and world.
1475
+ * @capability host-chat-send Validate, rate-limit and persist chat inside an authorized host mutation.
1476
+ */
1477
+ export async function sendChatMessage(ctx, args) {
1478
+ const maxBodyLength = args.maxBodyLength ?? 240;
1479
+ if (!Number.isSafeInteger(maxBodyLength) || maxBodyLength < 1 || maxBodyLength > 4096)
1480
+ throw new Error("Invalid chat body limit");
1481
+ if (!(args.channelId ?? "global").trim() || (args.channelId?.length ?? 0) > 256 || (args.authorName?.length ?? 0) > 160)
1482
+ return { ok: false, reason: "invalid_channel_or_author" };
1483
+ const validation = validateChatMessage(args.body, { maxLength: args.maxBodyLength ?? 240 });
1484
+ if (!validation.ok)
1485
+ return validation;
1486
+ const gate = await rateLimit(ctx, { key: JSON.stringify(["chat", args.serverId, args.userId]), windowMs: args.minIntervalMs ?? 2000, max: 1 });
1487
+ if (!gate.ok)
1488
+ return { ok: false, reason: "rate_limited" };
1489
+ await ctx.db.insert("jgChatMessages", { serverId: args.serverId, channelId: args.channelId ?? "global", userId: args.userId, body: validation.text, authorName: args.authorName, at: Date.now() });
1490
+ return { ok: true };
1491
+ }
1492
+ /**
1493
+ * Bounded recent chat for a resolved server; authorization belongs to the calling query.
1494
+ * @capability host-chat-history Read bounded channel history inside an authorized host query.
1495
+ */
1496
+ export async function recentChatMessages(ctx, args) {
1497
+ const limit = args.limit ?? 50;
1498
+ if (!Number.isFinite(limit))
1499
+ throw new Error("Invalid chat limit");
1500
+ return ctx.db.query("jgChatMessages").withIndex("by_server_channel", q => q.eq("serverId", args.serverId).eq("channelId", args.channelId ?? "global"))
1501
+ .order("desc").take(Math.min(100, Math.max(1, Math.floor(limit))));
1502
+ }
1503
+ /**
1504
+ * Authenticated, bounded client diagnostics and bounded retention sweeps.
1505
+ * @capability client-error-reporting Accept authenticated bounded diagnostics with cooldown and retention sweeps.
1506
+ */
1507
+ export function createClientErrorFunctions() {
1508
+ const reportClientError = mutation({
1509
+ args: { message: v.string(), kind: v.optional(v.string()), stack: v.optional(v.string()), componentStack: v.optional(v.string()), url: v.optional(v.string()), pathname: v.optional(v.string()), userAgent: v.optional(v.string()), agentPrompt: v.optional(v.string()) },
1510
+ returns: v.object({ ok: v.boolean(), reason: v.optional(v.string()) }),
1511
+ handler: async (ctx, args) => {
1512
+ const actor = await resolveActor(ctx, undefined, "required");
1513
+ if (!actor)
1514
+ return { ok: false, reason: "unauthorized" };
1515
+ const message = JSON.stringify(args);
1516
+ if (new TextEncoder().encode(message).length > 4096)
1517
+ return { ok: false, reason: "too_long" };
1518
+ const gate = await rateLimit(ctx, { key: `client-error:${actor}`, windowMs: 10_000, max: 1 });
1519
+ if (!gate.ok)
1520
+ return { ok: false, reason: "rate_limited" };
1521
+ await ctx.db.insert("jgClientErrors", { userId: actor, message, createdAt: Date.now() });
1522
+ return { ok: true };
1523
+ },
1524
+ });
1525
+ const pruneClientErrors = internalMutation({ args: {}, handler: async (ctx) => {
1526
+ const rows = await ctx.db.query("jgClientErrors").withIndex("by_created", q => q.lt("createdAt", Date.now() - 7 * 86_400_000)).take(256);
1527
+ for (const row of rows)
1528
+ await ctx.db.delete(row._id);
1529
+ return { deleted: rows.length, remaining: rows.length === 256 };
1530
+ } });
1531
+ const pruneRateLimits = internalMutation({ args: {}, handler: async (ctx) => {
1532
+ const rows = await ctx.db.query("jgRateLimits").withIndex("by_expires", q => q.lte("expiresAt", Date.now())).take(256);
1533
+ for (const row of rows)
1534
+ await ctx.db.delete(row._id);
1535
+ return { deleted: rows.length, remaining: rows.length === 256 };
1536
+ } });
1537
+ return { reportClientError, pruneClientErrors, pruneRateLimits };
1538
+ }
1233
1539
  /** @internal */
1234
1540
  export function createChatFunctions(options) {
1235
1541
  const mode = options?.auth ?? "required";
1236
- const historyLimit = options?.historyLimit ?? 100;
1237
- const maxBodyLength = options?.maxBodyLength ?? 500;
1238
- const minIntervalMs = options?.minIntervalMs ?? 300;
1542
+ if (options?.historyLimit !== undefined && (!Number.isSafeInteger(options.historyLimit) || options.historyLimit < 1))
1543
+ throw new Error("Invalid chat history limit");
1544
+ const historyLimit = Math.min(100, options?.historyLimit ?? 50);
1545
+ const maxBodyLength = options?.maxBodyLength ?? 240;
1546
+ const minIntervalMs = options?.minIntervalMs ?? 2_000;
1547
+ if (!Number.isSafeInteger(maxBodyLength) || maxBodyLength < 1 || maxBodyLength > 4096 || !Number.isFinite(minIntervalMs) || minIntervalMs <= 0)
1548
+ throw new Error("Invalid chat policy");
1239
1549
  const messages = query({
1240
1550
  args: {
1241
1551
  serverId: v.string(),
@@ -1282,28 +1592,9 @@ export function createChatFunctions(options) {
1282
1592
  if ((await requireServerMember(ctx, args.serverId, actorUserId)) === null) {
1283
1593
  return { ok: false, reason: "not a member of this server" };
1284
1594
  }
1285
- const body = args.body.trim();
1286
- if (body.length === 0)
1287
- return { ok: false, reason: "empty message" };
1288
- if (body.length > maxBodyLength)
1289
- return { ok: false, reason: "message too long" };
1290
- const now = Date.now();
1291
- const newestByActor = await ctx.db
1292
- .query("jgChatMessages")
1293
- .withIndex("by_server_channel", (q) => q.eq("serverId", args.serverId).eq("channelId", args.channelId))
1294
- .order("desc")
1295
- .filter((q) => q.eq(q.field("userId"), actorUserId))
1296
- .first();
1297
- if (newestByActor && now - newestByActor.at < minIntervalMs) {
1298
- return { ok: false, reason: "sending too fast" };
1299
- }
1300
- await ctx.db.insert("jgChatMessages", {
1301
- serverId: args.serverId,
1302
- channelId: args.channelId,
1303
- userId: actorUserId,
1304
- body,
1305
- at: now,
1306
- });
1595
+ const outcome = await sendChatMessage(ctx, { serverId: args.serverId, userId: actorUserId, channelId: args.channelId, body: args.body, maxBodyLength, minIntervalMs });
1596
+ if (!outcome.ok)
1597
+ return outcome.reason === "rate_limited" ? { ok: false, reason: "sending too fast" } : outcome;
1307
1598
  const recent = await ctx.db
1308
1599
  .query("jgChatMessages")
1309
1600
  .withIndex("by_server_channel", (q) => q.eq("serverId", args.serverId).eq("channelId", args.channelId))
@@ -1315,7 +1606,13 @@ export function createChatFunctions(options) {
1315
1606
  return { ok: true };
1316
1607
  },
1317
1608
  });
1318
- return { messages, sendMessage };
1609
+ const pruneChatMessages = internalMutation({ args: {}, handler: async (ctx) => {
1610
+ const rows = await ctx.db.query("jgChatMessages").withIndex("by_at", q => q.lt("at", Date.now() - 86_400_000)).take(256);
1611
+ for (const row of rows)
1612
+ await ctx.db.delete(row._id);
1613
+ return { deleted: rows.length, remaining: rows.length === 256 };
1614
+ } });
1615
+ return { messages, recent: messages, sendMessage, pruneChatMessages };
1319
1616
  }
1320
1617
  const JG_CRON_SPECS = [
1321
1618
  { name: "jg tick", intervalSeconds: 1, functionKey: "tickActiveServers", module: "runtime" },
@@ -1323,6 +1620,18 @@ const JG_CRON_SPECS = [
1323
1620
  { name: "jg presence reap", intervalSeconds: 60, functionKey: "reapIdlePresence", module: "presence" },
1324
1621
  ];
1325
1622
  /** @internal */
1326
- export function jgengineCronSpecs() {
1327
- return JG_CRON_SPECS;
1623
+ export function jgengineCronSpecs(options = {}) {
1624
+ const specs = JG_CRON_SPECS.filter(spec => spec.functionKey !== "tickActiveServers" || options.runtimes?.some(runtime => runtime.hasTick));
1625
+ if (options.chat)
1626
+ specs.push({ name: "jg chat prune", intervalSeconds: 3600, functionKey: "pruneChatMessages", module: "chat" });
1627
+ if (options.clientErrors) {
1628
+ specs.push({ name: "jg error prune", intervalSeconds: 3600, functionKey: "pruneClientErrors", module: "clientErrors" });
1629
+ specs.push({ name: "jg rate limit prune", intervalSeconds: 3600, functionKey: "pruneRateLimits", module: "clientErrors" });
1630
+ }
1631
+ return specs.map(spec => {
1632
+ const intervalSeconds = options.intervalSeconds?.[spec.functionKey] ?? spec.intervalSeconds;
1633
+ if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0)
1634
+ throw new Error("Cron intervalSeconds must be positive");
1635
+ return { ...spec, intervalSeconds };
1636
+ });
1328
1637
  }