deepspace 0.22.1 → 0.23.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
@@ -96,6 +96,8 @@ function registryErrorJson(c, err) {
96
96
 
97
97
  // src/server/utils/tools.ts
98
98
  var DEFAULT_QUERY_LIMIT = 50;
99
+ var DEFAULT_DELETE_WHERE_LIMIT = 100;
100
+ var MAX_DELETE_WHERE_LIMIT = 500;
99
101
  function applyAiToolDefaults(toolName, params) {
100
102
  if (toolName === "records.query" && params.limit === void 0) {
101
103
  return { ...params, limit: DEFAULT_QUERY_LIMIT };
@@ -149,6 +151,15 @@ var BUILT_IN_TOOLS = [
149
151
  recordId: { type: "string", description: "Record ID to delete", required: true }
150
152
  }
151
153
  },
154
+ {
155
+ name: "records.deleteWhere",
156
+ description: `Delete every record matching a filter, in one bounded batch. Deletes at most \`limit\` records (default ${DEFAULT_DELETE_WHERE_LIMIT}, max ${MAX_DELETE_WHERE_LIMIT}) and returns \`{ deleted }\`; repeat the same call until \`deleted\` is less than the limit to drain a larger set. A non-empty \`where\` whose every key names a real field (\`recordId\`, \`createdBy\`, or a schema column) is required \u2014 an unknown key is refused rather than ignored, so this tool never deletes a whole collection. Every matched record goes through the same delete permission check as \`records.delete\`; if any of them is denied, nothing in the batch is deleted.`,
157
+ params: {
158
+ collection: { type: "string", description: "Collection name (must have a schema)", required: true },
159
+ where: { type: "object", description: "Filter object with field=value equality conditions; every key must be recordId, createdBy, or a schema column", required: true },
160
+ limit: { type: "number", description: `Maximum number of records to delete in this call (default: ${DEFAULT_DELETE_WHERE_LIMIT}, max: ${MAX_DELETE_WHERE_LIMIT})`, required: false }
161
+ }
162
+ },
152
163
  // Schema tools
153
164
  {
154
165
  name: "schema.list",
@@ -1586,7 +1597,7 @@ function authWorkerFetch(env, path, init) {
1586
1597
  function buildCronContext(env, ownerUserId, roomId = "default") {
1587
1598
  const roomIdObj = env.RECORD_ROOMS.idFromName(roomId);
1588
1599
  const room = env.RECORD_ROOMS.get(roomIdObj);
1589
- async function executeTool3(tool2, params) {
1600
+ async function executeTool2(tool2, params) {
1590
1601
  const response = await room.fetch(
1591
1602
  new Request("https://internal/api/tools/execute", {
1592
1603
  method: "POST",
@@ -1609,18 +1620,18 @@ function buildCronContext(env, ownerUserId, roomId = "default") {
1609
1620
  const params = { collection };
1610
1621
  if (opts?.where) params.where = opts.where;
1611
1622
  if (opts?.limit) params.limit = opts.limit;
1612
- const data = await executeTool3("records.query", params);
1623
+ const data = await executeTool2("records.query", params);
1613
1624
  const records2 = data?.records;
1614
1625
  return records2 ?? [];
1615
1626
  },
1616
1627
  async create(collection, data) {
1617
- return executeTool3("records.create", { collection, data });
1628
+ return executeTool2("records.create", { collection, data });
1618
1629
  },
1619
1630
  async update(collection, recordId, data) {
1620
- return executeTool3("records.update", { collection, recordId, data });
1631
+ return executeTool2("records.update", { collection, recordId, data });
1621
1632
  },
1622
1633
  async delete(collection, recordId) {
1623
- return executeTool3("records.delete", { collection, recordId });
1634
+ return executeTool2("records.delete", { collection, recordId });
1624
1635
  }
1625
1636
  };
1626
1637
  const integrations = {
@@ -2468,8 +2479,86 @@ function makeDefaultSummarizer(env, options = {}) {
2468
2479
  };
2469
2480
  }
2470
2481
 
2482
+ // src/shared/protocol/constants.ts
2483
+ var MSG = {
2484
+ // ---- Records / CRUD --------------------------------------------------
2485
+ SUBSCRIBE: "core.subscribe",
2486
+ UNSUBSCRIBE: "core.unsubscribe",
2487
+ QUERY_RESULT: "core.query_result",
2488
+ RECORD_CHANGE: "core.record_change",
2489
+ PUT: "core.put",
2490
+ DELETE: "core.delete",
2491
+ ERROR: "core.error",
2492
+ // ---- Users -----------------------------------------------------------
2493
+ USER_INFO: "user.info",
2494
+ USER_LIST: "user.list",
2495
+ SET_ROLE: "user.set_role",
2496
+ USER_UPDATE: "user.update",
2497
+ // ---- Auth signal -----------------------------------------------------
2498
+ // Server → client one-shot on connect from rooms that enforce role-based
2499
+ // writes (Canvas, Game, Cron). Tells the client whether this connection
2500
+ // can write so its hook can disable local write APIs and the UI can
2501
+ // render controls accordingly. Without this, a viewer's click would
2502
+ // round-trip to the server and silently come back as ERROR.
2503
+ AUTH: "auth",
2504
+ // ---- Yjs JSON control frames (binary sync lives in ./yjs.ts) ---------
2505
+ YJS_JOIN: "yjs.join",
2506
+ YJS_LEAVE: "yjs.leave",
2507
+ // ---- Records room misc -----------------------------------------------
2508
+ ACK: "records.ack",
2509
+ LIST_SCHEMAS: "records.list_schemas",
2510
+ RESUBSCRIBE: "records.resubscribe",
2511
+ // ---- CanvasRoom ------------------------------------------------------
2512
+ CANVAS_SHAPES: "canvas.shapes",
2513
+ CANVAS_ADD: "canvas.add",
2514
+ CANVAS_MOVE: "canvas.move",
2515
+ CANVAS_RESIZE: "canvas.resize",
2516
+ CANVAS_DELETE: "canvas.delete",
2517
+ CANVAS_UPDATE: "canvas.update",
2518
+ CANVAS_VIEWPORT: "canvas.viewport",
2519
+ CANVAS_UNDO: "canvas.undo",
2520
+ CANVAS_REDO: "canvas.redo",
2521
+ // ---- CronRoom --------------------------------------------------------
2522
+ CRON_TASKS: "cron.tasks",
2523
+ CRON_HISTORY: "cron.history",
2524
+ CRON_TRIGGER: "cron.trigger",
2525
+ CRON_PAUSE: "cron.pause",
2526
+ CRON_RESUME: "cron.resume",
2527
+ CRON_STATUS: "cron.status",
2528
+ // Per-mutation receipt, sent only to the socket whose trigger/pause/
2529
+ // resume carried a `requestId` — frames without one stay fire-and-forget.
2530
+ CRON_ACK: "cron.ack",
2531
+ // ---- JobRoom ---------------------------------------------------------
2532
+ // Background-job queue with durable, alarm-driven execution. See
2533
+ // server/rooms/job-room.ts. Clients enqueue and subscribe to updates;
2534
+ // the room broadcasts JOB_UPDATE for every status transition.
2535
+ JOB_ENQUEUE: "job.enqueue",
2536
+ JOB_CANCEL: "job.cancel",
2537
+ JOB_RETRY: "job.retry",
2538
+ JOB_UPDATE: "job.update",
2539
+ // ---- PresenceRoom ----------------------------------------------------
2540
+ PRESENCE_SYNC: "presence.sync",
2541
+ PRESENCE_JOIN: "presence.join",
2542
+ PRESENCE_LEAVE: "presence.leave",
2543
+ PRESENCE_UPDATE: "presence.update",
2544
+ // ---- Gateway multiplexing --------------------------------------------
2545
+ GW_SCOPE_CONNECT: "gateway.scope_connect",
2546
+ GW_SCOPE_DISCONNECT: "gateway.scope_disconnect",
2547
+ GW_SCOPE_ERROR: "gateway.scope_error",
2548
+ GW_TOKEN_REFRESH: "gateway.token_refresh",
2549
+ GW_USER_UPDATE: "gateway.user_update"
2550
+ };
2551
+ var MSG_YJS_SYNC = 22;
2552
+ var MSG_YJS_AWARENESS = 23;
2553
+ var ROLE_ANONYMOUS = "viewer";
2554
+ var ANONYMOUS_USER_ID_PREFIX = "anon-";
2555
+ function isAnonymousUserId(userId) {
2556
+ return userId.startsWith(ANONYMOUS_USER_ID_PREFIX);
2557
+ }
2558
+ var RECORD_NOT_FOUND = "Record not found";
2559
+
2471
2560
  // src/server/utils/chat-history.ts
2472
- async function executeTool(stub, userId, tool2, params) {
2561
+ async function executeToolAsApp(stub, userId, tool2, params) {
2473
2562
  const res = await stub.fetch(new Request("https://internal/api/tools/execute", {
2474
2563
  method: "POST",
2475
2564
  headers: {
@@ -2510,13 +2599,15 @@ function toMessageRow(env) {
2510
2599
  }
2511
2600
  async function getChat(stub, chatId, userId) {
2512
2601
  try {
2513
- const result = await executeTool(stub, userId, "records.get", {
2602
+ const result = await executeToolAsApp(stub, userId, "records.get", {
2514
2603
  collection: "ai-chats",
2515
2604
  recordId: chatId
2516
2605
  });
2517
- return result.record ? toChatRow(result.record) : null;
2606
+ if (!result.record) return null;
2607
+ const chat = toChatRow(result.record);
2608
+ return chat.userId === userId ? chat : null;
2518
2609
  } catch (err) {
2519
- if (err instanceof Error && err.message.includes("Record not found")) return null;
2610
+ if (err instanceof Error && err.message.includes(RECORD_NOT_FOUND)) return null;
2520
2611
  throw err;
2521
2612
  }
2522
2613
  }
@@ -2524,53 +2615,45 @@ async function createChat(stub, userId, opts = {}) {
2524
2615
  const data = { userId };
2525
2616
  if (opts.title !== void 0) data.title = opts.title;
2526
2617
  if (opts.model !== void 0) data.model = opts.model;
2527
- const result = await executeTool(stub, userId, "records.create", {
2618
+ const result = await executeToolAsApp(stub, userId, "records.create", {
2528
2619
  collection: "ai-chats",
2529
2620
  data
2530
2621
  });
2531
2622
  return toChatRow(result.record);
2532
2623
  }
2533
2624
  async function updateChat(stub, chatId, userId, patch) {
2534
- await executeTool(stub, userId, "records.update", {
2625
+ if (!await getChat(stub, chatId, userId)) return false;
2626
+ await executeToolAsApp(stub, userId, "records.update", {
2535
2627
  collection: "ai-chats",
2536
2628
  recordId: chatId,
2537
2629
  data: patch
2538
2630
  });
2631
+ return true;
2539
2632
  }
2633
+ var MESSAGE_DELETE_PAGE = 200;
2540
2634
  async function deleteChatCascade(stub, chatId, userId) {
2541
- const list = await executeTool(stub, userId, "records.query", {
2542
- collection: "ai-messages",
2543
- where: { chatId }
2544
- });
2545
- const errors = [];
2546
- for (const env of list.records) {
2547
- try {
2548
- await executeTool(stub, userId, "records.delete", {
2635
+ let messagesError = null;
2636
+ try {
2637
+ let deleted = MESSAGE_DELETE_PAGE;
2638
+ while (deleted === MESSAGE_DELETE_PAGE) {
2639
+ const page = await executeToolAsApp(stub, userId, "records.deleteWhere", {
2549
2640
  collection: "ai-messages",
2550
- recordId: env.recordId
2641
+ where: { chatId },
2642
+ limit: MESSAGE_DELETE_PAGE
2551
2643
  });
2552
- } catch (err) {
2553
- errors.push(err);
2644
+ deleted = page.deleted;
2554
2645
  }
2555
- }
2556
- try {
2557
- await executeTool(stub, userId, "records.delete", {
2558
- collection: "ai-chats",
2559
- recordId: chatId
2560
- });
2561
2646
  } catch (err) {
2562
- errors.push(err);
2563
- }
2564
- if (errors.length > 0) {
2565
- const first = errors[0];
2566
- throw new Error(
2567
- `deleteChatCascade: ${errors.length} delete(s) failed; first: ${first instanceof Error ? first.message : String(first)}`,
2568
- { cause: errors }
2569
- );
2647
+ messagesError = err;
2570
2648
  }
2649
+ await executeToolAsApp(stub, userId, "records.delete", {
2650
+ collection: "ai-chats",
2651
+ recordId: chatId
2652
+ });
2653
+ if (messagesError) throw messagesError;
2571
2654
  }
2572
2655
  async function loadMessages(stub, chatId, userId) {
2573
- const result = await executeTool(stub, userId, "records.query", {
2656
+ const result = await executeToolAsApp(stub, userId, "records.query", {
2574
2657
  collection: "ai-messages",
2575
2658
  where: { chatId, userId },
2576
2659
  orderBy: "createdAt",
@@ -2579,6 +2662,7 @@ async function loadMessages(stub, chatId, userId) {
2579
2662
  return result.records.map(toMessageRow);
2580
2663
  }
2581
2664
  async function appendMessage(stub, msg) {
2665
+ if (!await getChat(stub, msg.chatId, msg.userId)) return false;
2582
2666
  const data = {
2583
2667
  chatId: msg.chatId,
2584
2668
  userId: msg.userId,
@@ -2586,11 +2670,12 @@ async function appendMessage(stub, msg) {
2586
2670
  content: msg.content
2587
2671
  };
2588
2672
  if (msg.parts !== void 0) data.parts = msg.parts;
2589
- await executeTool(stub, msg.userId, "records.create", {
2673
+ await executeToolAsApp(stub, msg.userId, "records.create", {
2590
2674
  collection: "ai-messages",
2591
2675
  recordId: msg.id,
2592
2676
  data
2593
2677
  });
2678
+ return true;
2594
2679
  }
2595
2680
 
2596
2681
  // src/server/utils/metering.ts
@@ -2713,7 +2798,7 @@ function decodeRoomIdentityHeader(value) {
2713
2798
  // src/server/rooms/base-room.ts
2714
2799
  function connectionAttachmentFromRequest(request) {
2715
2800
  return {
2716
- userId: decodeRoomIdentityHeader(request.headers.get("x-user-id")) || `anon-${crypto.randomUUID()}`,
2801
+ userId: decodeRoomIdentityHeader(request.headers.get("x-user-id")) || `${ANONYMOUS_USER_ID_PREFIX}${crypto.randomUUID()}`,
2717
2802
  userName: decodeRoomIdentityHeader(request.headers.get("x-user-name")) || "Anonymous",
2718
2803
  userEmail: decodeRoomIdentityHeader(request.headers.get("x-user-email")) || "",
2719
2804
  userImageUrl: decodeRoomIdentityHeader(request.headers.get("x-user-image-url")),
@@ -2967,79 +3052,6 @@ var BaseRoom = class {
2967
3052
  }
2968
3053
  };
2969
3054
 
2970
- // src/shared/protocol/constants.ts
2971
- var MSG = {
2972
- // ---- Records / CRUD --------------------------------------------------
2973
- SUBSCRIBE: "core.subscribe",
2974
- UNSUBSCRIBE: "core.unsubscribe",
2975
- QUERY_RESULT: "core.query_result",
2976
- RECORD_CHANGE: "core.record_change",
2977
- PUT: "core.put",
2978
- DELETE: "core.delete",
2979
- ERROR: "core.error",
2980
- // ---- Users -----------------------------------------------------------
2981
- USER_INFO: "user.info",
2982
- USER_LIST: "user.list",
2983
- SET_ROLE: "user.set_role",
2984
- USER_UPDATE: "user.update",
2985
- // ---- Auth signal -----------------------------------------------------
2986
- // Server → client one-shot on connect from rooms that enforce role-based
2987
- // writes (Canvas, Game, Cron). Tells the client whether this connection
2988
- // can write so its hook can disable local write APIs and the UI can
2989
- // render controls accordingly. Without this, a viewer's click would
2990
- // round-trip to the server and silently come back as ERROR.
2991
- AUTH: "auth",
2992
- // ---- Yjs JSON control frames (binary sync lives in ./yjs.ts) ---------
2993
- YJS_JOIN: "yjs.join",
2994
- YJS_LEAVE: "yjs.leave",
2995
- // ---- Records room misc -----------------------------------------------
2996
- ACK: "records.ack",
2997
- LIST_SCHEMAS: "records.list_schemas",
2998
- RESUBSCRIBE: "records.resubscribe",
2999
- // ---- CanvasRoom ------------------------------------------------------
3000
- CANVAS_SHAPES: "canvas.shapes",
3001
- CANVAS_ADD: "canvas.add",
3002
- CANVAS_MOVE: "canvas.move",
3003
- CANVAS_RESIZE: "canvas.resize",
3004
- CANVAS_DELETE: "canvas.delete",
3005
- CANVAS_UPDATE: "canvas.update",
3006
- CANVAS_VIEWPORT: "canvas.viewport",
3007
- CANVAS_UNDO: "canvas.undo",
3008
- CANVAS_REDO: "canvas.redo",
3009
- // ---- CronRoom --------------------------------------------------------
3010
- CRON_TASKS: "cron.tasks",
3011
- CRON_HISTORY: "cron.history",
3012
- CRON_TRIGGER: "cron.trigger",
3013
- CRON_PAUSE: "cron.pause",
3014
- CRON_RESUME: "cron.resume",
3015
- CRON_STATUS: "cron.status",
3016
- // Per-mutation receipt, sent only to the socket whose trigger/pause/
3017
- // resume carried a `requestId` — frames without one stay fire-and-forget.
3018
- CRON_ACK: "cron.ack",
3019
- // ---- JobRoom ---------------------------------------------------------
3020
- // Background-job queue with durable, alarm-driven execution. See
3021
- // server/rooms/job-room.ts. Clients enqueue and subscribe to updates;
3022
- // the room broadcasts JOB_UPDATE for every status transition.
3023
- JOB_ENQUEUE: "job.enqueue",
3024
- JOB_CANCEL: "job.cancel",
3025
- JOB_RETRY: "job.retry",
3026
- JOB_UPDATE: "job.update",
3027
- // ---- PresenceRoom ----------------------------------------------------
3028
- PRESENCE_SYNC: "presence.sync",
3029
- PRESENCE_JOIN: "presence.join",
3030
- PRESENCE_LEAVE: "presence.leave",
3031
- PRESENCE_UPDATE: "presence.update",
3032
- // ---- Gateway multiplexing --------------------------------------------
3033
- GW_SCOPE_CONNECT: "gateway.scope_connect",
3034
- GW_SCOPE_DISCONNECT: "gateway.scope_disconnect",
3035
- GW_SCOPE_ERROR: "gateway.scope_error",
3036
- GW_TOKEN_REFRESH: "gateway.token_refresh",
3037
- GW_USER_UPDATE: "gateway.user_update"
3038
- };
3039
- var MSG_YJS_SYNC = 22;
3040
- var MSG_YJS_AWARENESS = 23;
3041
- var ROLE_ANONYMOUS = "viewer";
3042
-
3043
3055
  // src/server/schemas/registry.ts
3044
3056
  function collectionTableName(name) {
3045
3057
  return `c_${name.replace(/[^a-zA-Z0-9_]/g, "_")}`;
@@ -3241,13 +3253,11 @@ function checkUnclaimedOwnerTransition(schema, role, nextData, userId) {
3241
3253
  }
3242
3254
  return `Role '${role}' can set '${schema.ownerField}' only to their own user id or leave it unclaimed`;
3243
3255
  }
3256
+ var SYSTEM_ASSIGNED_COLUMNS = /* @__PURE__ */ new Set(["email", "name", "imageUrl", "role"]);
3257
+ var SYSTEM_MAINTAINED_COLUMNS = /* @__PURE__ */ new Set(["createdAt", "lastSeenAt"]);
3244
3258
  var SYSTEM_MANAGED_COLUMNS = /* @__PURE__ */ new Set([
3245
- "email",
3246
- "name",
3247
- "imageUrl",
3248
- "role",
3249
- "createdAt",
3250
- "lastSeenAt"
3259
+ ...SYSTEM_ASSIGNED_COLUMNS,
3260
+ ...SYSTEM_MAINTAINED_COLUMNS
3251
3261
  ]);
3252
3262
  var USERS_COLUMNS = [
3253
3263
  { name: "email", storage: "text", interpretation: "plain" },
@@ -3301,12 +3311,28 @@ function lintSchema(schema) {
3301
3311
  }
3302
3312
  return warnings;
3303
3313
  }
3314
+ function lintSchemas(schemas) {
3315
+ return [...schemas.flatMap((schema) => lintSchema(schema)), ...lintSchemaSet(schemas)];
3316
+ }
3317
+ function lintSchemaSet(schemas) {
3318
+ const warnings = [];
3319
+ if (!schemas.some((schema) => schema.name === "team_members")) {
3320
+ for (const schema of schemas) {
3321
+ const teamRoles = Object.entries(schema.permissions).filter(([, p]) => [p.read, p.update, p.delete].includes("team")).map(([role]) => role);
3322
+ if (teamRoles.length > 0) {
3323
+ warnings.push(
3324
+ `[${schema.name}] role(s) ${teamRoles.join(", ")} use the 'team' permission level, but no 'team_members' collection is registered. 'team' resolves membership from team_members (teamId, userId, status), so without it those roles are denied everything \u2014 silently. Register a team_members schema or use another level.`
3325
+ );
3326
+ }
3327
+ }
3328
+ }
3329
+ return warnings;
3330
+ }
3304
3331
  var SchemaRegistry = class {
3305
3332
  trusted = /* @__PURE__ */ new Map();
3306
3333
  constructor(schemas = []) {
3307
- for (const schema of schemas) {
3308
- this.registerTrusted(schema);
3309
- }
3334
+ for (const w of lintSchemaSet(schemas)) console.warn(`[schema-lint] ${w}`);
3335
+ for (const schema of schemas) this.registerTrusted(schema);
3310
3336
  }
3311
3337
  registerTrusted(schema) {
3312
3338
  for (const w of lintSchema(schema)) {
@@ -3553,6 +3579,11 @@ var serverBuild = {
3553
3579
  function getResolvedColumns(schema) {
3554
3580
  return (schema.columns ?? []).map(resolveColumn);
3555
3581
  }
3582
+ function systemManagedColumnError(column, isUpdate) {
3583
+ const roleHint = column === "role" ? ", or the admin-gated useUsers().setRole() to change a role" : "";
3584
+ const remedy = isUpdate ? `Re-sending the current value of '${column}' unchanged is accepted and ignored.` : `A users row is created by registration, so no value for '${column}' is accepted here \u2014 omit it.`;
3585
+ return `FIELD ERROR: '${column}' is system-managed and cannot be set by a record write \u2014 nothing was written. Use tools.registerUser({ userId, isAdmin })${roleHint}. ${remedy}`;
3586
+ }
3556
3587
  function notifyTeamMembershipChange(ctx, collection, record) {
3557
3588
  if (collection !== "team_members") return;
3558
3589
  const affectedUserId = record.data.UserId;
@@ -3623,11 +3654,9 @@ function handleDelete(ctx, ws, attachment, payload) {
3623
3654
  }
3624
3655
  }
3625
3656
  function putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRbac = false, systemUpdate = false) {
3626
- const isSystem = SYSTEM_COLLECTIONS.has(collection);
3627
- const schema = ctx.schemaRegistry.get(collection);
3628
- if (!schema && !isSystem) {
3629
- return { success: false, error: `Schema not registered for collection: ${collection}` };
3630
- }
3657
+ const resolved = resolveCollection(ctx, collection);
3658
+ if (!resolved.ok) return { success: false, error: resolved.error };
3659
+ const schema = resolved.schema;
3631
3660
  if (!schema) {
3632
3661
  return { success: false, error: `Schema not registered for collection: ${collection}` };
3633
3662
  }
@@ -3682,17 +3711,7 @@ function putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRb
3682
3711
  }
3683
3712
  }
3684
3713
  }
3685
- let finalData = mergedData;
3686
- if (!systemUpdate && schema.name === "users") {
3687
- finalData = { ...mergedData };
3688
- for (const key of SYSTEM_MANAGED_COLUMNS) {
3689
- if (existing?.data[key] !== void 0) {
3690
- finalData[key] = existing.data[key];
3691
- } else {
3692
- delete finalData[key];
3693
- }
3694
- }
3695
- }
3714
+ const finalData = { ...mergedData };
3696
3715
  if (!skipUserRbac) {
3697
3716
  const permCtx = ctx.getPermissionContext();
3698
3717
  if (isUpdate) {
@@ -3724,6 +3743,17 @@ function putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRb
3724
3743
  return { success: false, error: `FIELD ERROR: ${ownerError}` };
3725
3744
  }
3726
3745
  }
3746
+ if (!systemUpdate && schema.name === "users") {
3747
+ for (const col of columns) {
3748
+ if (!SYSTEM_MANAGED_COLUMNS.has(col.name)) continue;
3749
+ const current = existing?.data[col.name];
3750
+ if (SYSTEM_ASSIGNED_COLUMNS.has(col.name) && Object.hasOwn(data, col.name) && coerceValue(data[col.name], col.storage, col.interpretation) !== coerceValue(current, col.storage, col.interpretation)) {
3751
+ return { success: false, error: systemManagedColumnError(col.name, isUpdate) };
3752
+ }
3753
+ if (current !== void 0) finalData[col.name] = current;
3754
+ else delete finalData[col.name];
3755
+ }
3756
+ }
3727
3757
  const colValues = dataToColumnValues(finalData, columns);
3728
3758
  const now = (/* @__PURE__ */ new Date()).toISOString();
3729
3759
  const tbl = collectionTableName(collection);
@@ -3783,14 +3813,12 @@ function putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRb
3783
3813
  return { success: true, data: { recordId, record } };
3784
3814
  }
3785
3815
  function deleteRecord(ctx, collection, recordId, userId, userRole, skipUserRbac = false) {
3786
- const isSystem = SYSTEM_COLLECTIONS.has(collection);
3787
- const schema = ctx.schemaRegistry.get(collection);
3788
- if (!schema && !isSystem) {
3789
- return { success: false, error: `Schema not registered for collection: ${collection}` };
3790
- }
3816
+ const resolved = resolveCollection(ctx, collection);
3817
+ if (!resolved.ok) return { success: false, error: resolved.error };
3818
+ const schema = resolved.schema;
3791
3819
  const existing = getRecord(ctx.sql, collection, recordId, schema);
3792
3820
  if (!existing) {
3793
- return { success: false, error: "Record not found" };
3821
+ return { success: false, error: RECORD_NOT_FOUND };
3794
3822
  }
3795
3823
  if (!skipUserRbac && schema && !canDelete(schema, userRole, { ...existing, recordId }, userId, ctx.getPermissionContext())) {
3796
3824
  return { success: false, error: `DELETE DENIED: role=${userRole}, collection=${collection}` };
@@ -3808,15 +3836,88 @@ function deleteRecord(ctx, collection, recordId, userId, userRole, skipUserRbac
3808
3836
  notifyTeamMembershipChange(ctx, collection, record);
3809
3837
  return { success: true, data: { deleted: true } };
3810
3838
  }
3811
- function readRecord(ctx, collection, recordId, userId, userRole, skipUserRbac = false) {
3812
- const isSystem = SYSTEM_COLLECTIONS.has(collection);
3813
- const schema = ctx.schemaRegistry.get(collection);
3814
- if (!schema && !isSystem) {
3815
- return { success: false, error: `Schema not registered for collection: ${collection}` };
3839
+ function deleteWhere(ctx, collection, where, limit, userId, userRole, skipUserRbac = false) {
3840
+ const resolved = resolveCollection(ctx, collection);
3841
+ if (!resolved.ok) return { success: false, error: resolved.error };
3842
+ const schema = resolved.schema;
3843
+ if (!schema) {
3844
+ return {
3845
+ success: false,
3846
+ error: `records.deleteWhere: collection "${collection}" has no schema, so it has no filterable fields \u2014 delete its rows by recordId with records.delete`
3847
+ };
3848
+ }
3849
+ if (!where || typeof where !== "object" || Array.isArray(where) || Object.keys(where).length === 0) {
3850
+ return { success: false, error: "Missing required param: where (must match at least one field)" };
3851
+ }
3852
+ const columns = (schema.columns ?? []).map(resolveColumn);
3853
+ const filterable = ["recordId", "createdBy", ...columns.map((c) => c.name)];
3854
+ const unknown = Object.keys(where).filter(
3855
+ (key) => key !== "recordId" && key !== "createdBy" && !columns.some((c) => c.id === columnId(key) || c.name === key)
3856
+ );
3857
+ if (unknown.length > 0) {
3858
+ return {
3859
+ success: false,
3860
+ error: `Unknown field(s) in where for "${collection}": ${unknown.join(", ")} \u2014 filterable fields: ${filterable.join(", ")}`
3861
+ };
3862
+ }
3863
+ const nonPrimitive = Object.entries(where).filter(([, v]) => v !== null && typeof v === "object");
3864
+ if (nonPrimitive.length > 0) {
3865
+ return {
3866
+ success: false,
3867
+ error: `where values must be primitives (equality only): ${nonPrimitive.map(([k]) => k).join(", ")}`
3868
+ };
3869
+ }
3870
+ if (limit !== void 0 && !(typeof limit === "number" && Number.isFinite(limit))) {
3871
+ return { success: false, error: "limit must be a finite number" };
3872
+ }
3873
+ const page = Math.min(
3874
+ Math.max(1, Math.floor(limit ?? DEFAULT_DELETE_WHERE_LIMIT)),
3875
+ MAX_DELETE_WHERE_LIMIT
3876
+ );
3877
+ const scan = { capped: false };
3878
+ const matched = executeQuery(
3879
+ ctx,
3880
+ { collection, where, limit: page },
3881
+ userId,
3882
+ userRole,
3883
+ skipUserRbac,
3884
+ scan
3885
+ );
3886
+ if (scan.capped && matched.length < page) {
3887
+ return {
3888
+ success: false,
3889
+ error: `records.deleteWhere scanned ${MAX_FILTERED_SCAN_ROWS} rows without filling a page of readable matches in "${collection}" \u2014 narrow \`where\` (or delete by recordId) and retry`
3890
+ };
3891
+ }
3892
+ if (!skipUserRbac && schema) {
3893
+ const permCtx = ctx.getPermissionContext();
3894
+ for (const record of matched) {
3895
+ if (!canDelete(schema, userRole, record, userId, permCtx)) {
3896
+ return { success: false, error: `DELETE DENIED: role=${userRole}, collection=${collection}` };
3897
+ }
3898
+ }
3899
+ }
3900
+ let deleted = 0;
3901
+ for (const record of matched) {
3902
+ if (deleteRecord(ctx, collection, record.recordId, userId, userRole, skipUserRbac).success) {
3903
+ deleted++;
3904
+ }
3905
+ }
3906
+ if (deleted < matched.length) {
3907
+ return {
3908
+ success: false,
3909
+ error: `Deleted ${deleted} of ${matched.length} matched record(s) in "${collection}"; the rest failed \u2014 retry`
3910
+ };
3816
3911
  }
3912
+ return { success: true, data: { deleted } };
3913
+ }
3914
+ function readRecord(ctx, collection, recordId, userId, userRole, skipUserRbac = false) {
3915
+ const resolved = resolveCollection(ctx, collection);
3916
+ if (!resolved.ok) return { success: false, error: resolved.error };
3917
+ const schema = resolved.schema;
3817
3918
  const existing = getRecord(ctx.sql, collection, recordId, schema);
3818
3919
  if (!existing) {
3819
- return { success: false, error: "Record not found" };
3920
+ return { success: false, error: RECORD_NOT_FOUND };
3820
3921
  }
3821
3922
  if (!skipUserRbac && schema && !canRead(schema, userRole, { ...existing, recordId }, userId, ctx.getPermissionContext())) {
3822
3923
  return { success: false, error: `READ DENIED: role=${userRole}, collection=${collection}` };
@@ -3847,9 +3948,7 @@ function hasYjsSubscription(attachment, target) {
3847
3948
  (subscription) => subscription.collection === target.collection && subscription.recordId === target.recordId && subscription.fieldName === target.fieldName
3848
3949
  );
3849
3950
  }
3850
- var SYSTEM_COLLECTIONS = /* @__PURE__ */ new Set([
3851
- "canvas-settings"
3852
- ]);
3951
+ var SYSTEM_COLLECTIONS = /* @__PURE__ */ new Set(["canvas-settings"]);
3853
3952
  var SYSTEM_COLLECTION_SCHEMAS = [
3854
3953
  {
3855
3954
  name: "canvas-settings",
@@ -3866,10 +3965,7 @@ function getOrCreateYjsDoc(ctx, docKey) {
3866
3965
  let doc = ctx.yjsDocs.get(docKey);
3867
3966
  if (doc) return doc;
3868
3967
  doc = new Y2.Doc();
3869
- const row = ctx.sql.exec(
3870
- `SELECT state FROM yjs_docs WHERE doc_key = ?`,
3871
- docKey
3872
- ).toArray()[0];
3968
+ const row = ctx.sql.exec(`SELECT state FROM yjs_docs WHERE doc_key = ?`, docKey).toArray()[0];
3873
3969
  if (row?.state) {
3874
3970
  try {
3875
3971
  Y2.applyUpdate(doc, new Uint8Array(row.state));
@@ -3934,11 +4030,17 @@ function handleYjsJoin(ctx, ws, attachment, payload) {
3934
4030
  }
3935
4031
  } else {
3936
4032
  if (!schema) {
3937
- ctx.send(ws, { type: MSG.ERROR, payload: { error: `Schema not registered for collection: ${collection}` } });
4033
+ const resolution = resolveCollection(ctx, collection);
4034
+ ctx.send(ws, {
4035
+ type: MSG.ERROR,
4036
+ payload: {
4037
+ error: resolution.ok ? `Schema not registered for collection: ${collection}` : resolution.error
4038
+ }
4039
+ });
3938
4040
  return;
3939
4041
  }
3940
4042
  if (!record) {
3941
- ctx.send(ws, { type: MSG.ERROR, payload: { error: "Record not found" } });
4043
+ ctx.send(ws, { type: MSG.ERROR, payload: { error: RECORD_NOT_FOUND } });
3942
4044
  return;
3943
4045
  }
3944
4046
  const permCtx = ctx.getPermissionContext();
@@ -3971,7 +4073,10 @@ function handleYjsJoin(ctx, ws, attachment, payload) {
3971
4073
  writeVarUint8Array(encoder2, fullState);
3972
4074
  ctx.sendBinary(ws, toUint8Array(encoder2));
3973
4075
  }
3974
- ctx.send(ws, { type: MSG.YJS_JOIN, payload: { collection, recordId, fieldName, canWrite: hasWriteAccess } });
4076
+ ctx.send(ws, {
4077
+ type: MSG.YJS_JOIN,
4078
+ payload: { collection, recordId, fieldName, canWrite: hasWriteAccess }
4079
+ });
3975
4080
  }
3976
4081
  function handleYjsLeave(ctx, ws, attachment, payload) {
3977
4082
  const { collection, recordId, fieldName } = payload;
@@ -4040,7 +4145,14 @@ function handleYjsBinaryMessage(ctx, ws, attachment, data) {
4040
4145
  if (!syncSchema) return;
4041
4146
  const record = getRecord(ctx.sql, collection, recordId, syncSchema);
4042
4147
  if (!record) return;
4043
- if (!canUpdate(syncSchema, attachment.role, { ...record, recordId }, attachment.userId, ctx.getPermissionContext())) return;
4148
+ if (!canUpdate(
4149
+ syncSchema,
4150
+ attachment.role,
4151
+ { ...record, recordId },
4152
+ attachment.userId,
4153
+ ctx.getPermissionContext()
4154
+ ))
4155
+ return;
4044
4156
  }
4045
4157
  const update = readVarUint8Array(decoder);
4046
4158
  Y2.applyUpdate(doc, update, "client");
@@ -4114,6 +4226,19 @@ function handleYjsDisconnect(ctx, ws, attachment) {
4114
4226
  }
4115
4227
 
4116
4228
  // src/server/handlers/subscriptions.ts
4229
+ function resolveCollection(ctx, collection) {
4230
+ const isSystem = SYSTEM_COLLECTIONS.has(collection);
4231
+ const schema = ctx.schemaRegistry.get(collection);
4232
+ if (!schema && !isSystem) {
4233
+ const known = ctx.schemaRegistry.names().sort().join(", ") || "(none)";
4234
+ const room = ctx.scopeId ? `Room '${ctx.scopeId}'` : "This room";
4235
+ return {
4236
+ ok: false,
4237
+ error: `Schema not registered for collection: ${collection} \u2014 ${room} serves only: ${known}. Records are sharded by scope, so address the room that owns '${collection}', or add it to the schemas this room is constructed with.`
4238
+ };
4239
+ }
4240
+ return { ok: true, schema, isSystem };
4241
+ }
4117
4242
  function handleSubscribe(ctx, ws, attachment, payload) {
4118
4243
  const { subscriptionId, query } = payload;
4119
4244
  const records = executeQuery(ctx, query, attachment.userId, attachment.role);
@@ -4121,16 +4246,23 @@ function handleSubscribe(ctx, ws, attachment, payload) {
4121
4246
  }
4122
4247
  function handleUnsubscribe(_ctx, _ws, _attachment, _payload) {
4123
4248
  }
4124
- function executeQuery(ctx, query, userId, userRole, skipUserRbac = false) {
4125
- const isSystem = SYSTEM_COLLECTIONS.has(query.collection);
4126
- const schema = ctx.schemaRegistry.get(query.collection);
4127
- if (!schema && !isSystem) {
4128
- return [];
4129
- }
4130
- if (!schema) {
4249
+ var MAX_FILTERED_SCAN_ROWS = 5e3;
4250
+ function executeQuery(ctx, query, userId, userRole, skipUserRbac = false, scanState) {
4251
+ const resolved = resolveCollection(ctx, query.collection);
4252
+ if (!resolved.ok) return [];
4253
+ if (!resolved.schema) {
4131
4254
  return executeSystemQuery(ctx, query);
4132
4255
  }
4133
- return executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipUserRbac);
4256
+ return executeTableQuery(
4257
+ ctx,
4258
+ query,
4259
+ resolved.schema,
4260
+ userId,
4261
+ userRole,
4262
+ resolved.isSystem,
4263
+ skipUserRbac,
4264
+ scanState
4265
+ );
4134
4266
  }
4135
4267
  function executeSystemQuery(ctx, query) {
4136
4268
  const tbl = collectionTableName(query.collection);
@@ -4166,7 +4298,7 @@ function preloadUserTeamIds(ctx, userId) {
4166
4298
  );
4167
4299
  return cursor.toArray().map((r) => r.col_teamid);
4168
4300
  }
4169
- function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipUserRbac = false) {
4301
+ function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipUserRbac = false, scanState) {
4170
4302
  const columns = (schema.columns ?? []).map(resolveColumn);
4171
4303
  const perms = getRolePermissions(schema, userRole);
4172
4304
  let sql = buildTableSelect(query.collection, columns);
@@ -4174,16 +4306,27 @@ function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipU
4174
4306
  const whereClauses = [];
4175
4307
  let skipPerRecordCheck = skipUserRbac;
4176
4308
  if (!skipUserRbac && schema.teamField && perms.read === "team") {
4177
- const filterCol = schema.teamField === "_rowId" ? "_row_id" : columns.find((c) => c.name === schema.teamField)?.id ?? columnId(schema.teamField);
4178
- const teamIds = preloadUserTeamIds(ctx, userId);
4179
- if (teamIds && teamIds.length > 0) {
4180
- const placeholders = teamIds.map(() => "?").join(", ");
4181
- whereClauses.push(`("${filterCol}" IN (${placeholders}) OR _created_by = ?)`);
4182
- params.push(...teamIds, userId);
4183
- } else {
4184
- whereClauses.push(`_created_by = ?`);
4309
+ const filterCol = schema.teamField === "_rowId" ? "_row_id" : columns.find((c) => c.name === schema.teamField)?.id;
4310
+ const teamIds = filterCol ? preloadUserTeamIds(ctx, userId) : null;
4311
+ if (filterCol) {
4312
+ if (teamIds && teamIds.length > 0) {
4313
+ const placeholders = teamIds.map(() => "?").join(", ");
4314
+ whereClauses.push(`("${filterCol}" IN (${placeholders}) OR _created_by = ?)`);
4315
+ params.push(...teamIds, userId);
4316
+ } else {
4317
+ whereClauses.push(`_created_by = ?`);
4318
+ params.push(userId);
4319
+ }
4320
+ skipPerRecordCheck = true;
4321
+ }
4322
+ } else if (!skipUserRbac && perms.read === "own") {
4323
+ const ownerCol = schema.ownerField ? columns.find((c) => c.name === schema.ownerField)?.id : "_created_by";
4324
+ if (ownerCol) {
4325
+ whereClauses.push(`"${ownerCol}" = ?`);
4185
4326
  params.push(userId);
4327
+ skipPerRecordCheck = true;
4186
4328
  }
4329
+ } else if (!skipUserRbac && perms.read === true) {
4187
4330
  skipPerRecordCheck = true;
4188
4331
  }
4189
4332
  if (query.where) {
@@ -4221,27 +4364,44 @@ function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipU
4221
4364
  } else {
4222
4365
  sql += ` ORDER BY _created_at DESC`;
4223
4366
  }
4224
- if (query.limit) {
4225
- sql += ` LIMIT ?`;
4226
- params.push(query.limit);
4227
- }
4228
- const cursor = ctx.sql.exec(sql, ...params);
4367
+ const readPage = (limitClause, limitParams) => {
4368
+ const cursor = ctx.sql.exec(sql + limitClause, ...params, ...limitParams);
4369
+ const rows = cursor.toArray();
4370
+ const records = [];
4371
+ for (const row of rows) {
4372
+ const r = row;
4373
+ const record = {
4374
+ recordId: r._row_id,
4375
+ data: rowToData(r, columns),
4376
+ createdBy: r._created_by,
4377
+ createdAt: r._created_at,
4378
+ updatedAt: r._updated_at
4379
+ };
4380
+ if (isSystem || skipPerRecordCheck || canRead(schema, userRole, { data: record.data, createdBy: record.createdBy, recordId: record.recordId }, userId, ctx.getPermissionContext())) {
4381
+ records.push(record);
4382
+ }
4383
+ }
4384
+ return { records, scanned: rows.length };
4385
+ };
4386
+ if (!query.limit) return readPage("", []).records;
4387
+ const rowFiltered = !isSystem && !skipPerRecordCheck;
4388
+ if (!rowFiltered) return readPage(" LIMIT ?", [query.limit]).records;
4229
4389
  const results = [];
4230
- for (const row of cursor.toArray()) {
4231
- const r = row;
4232
- const data = rowToData(r, columns);
4233
- const record = {
4234
- recordId: r._row_id,
4235
- data,
4236
- createdBy: r._created_by,
4237
- createdAt: r._created_at,
4238
- updatedAt: r._updated_at
4239
- };
4240
- if (isSystem || skipPerRecordCheck || canRead(schema, userRole, { data: record.data, createdBy: record.createdBy, recordId: record.recordId }, userId, ctx.getPermissionContext())) {
4390
+ const batch = Math.max(query.limit, 200);
4391
+ for (let offset = 0; ; offset += batch) {
4392
+ const { records, scanned } = readPage(" LIMIT ? OFFSET ?", [batch, offset]);
4393
+ for (const record of records) {
4241
4394
  results.push(record);
4395
+ if (results.length >= query.limit) return results;
4396
+ }
4397
+ if (scanned < batch) return results;
4398
+ if (offset + batch >= MAX_FILTERED_SCAN_ROWS) {
4399
+ if (scanState && readPage(" LIMIT ? OFFSET ?", [1, offset + batch]).scanned > 0) {
4400
+ scanState.capped = true;
4401
+ }
4402
+ return results;
4242
4403
  }
4243
4404
  }
4244
- return results;
4245
4405
  }
4246
4406
  function recordMatchesSubscription(record, collection, query, userId, userRole, schema, ctx) {
4247
4407
  if (query.collection !== collection) return false;
@@ -4438,30 +4598,56 @@ async function registerUser(sql, userId, name, email, imageUrl, isAdmin, default
4438
4598
  );
4439
4599
  return { id: userId, email, name, imageUrl, role, createdAt: now, lastSeenAt: now };
4440
4600
  }
4601
+ function rosterFor(ctx, schema, records, publicRoster, attachment) {
4602
+ if (isAnonymousUserId(attachment.userId)) return [];
4603
+ if (attachment.role === "admin") {
4604
+ const permissionContext = ctx.getPermissionContext();
4605
+ return records.filter((record) => canRead(schema, "admin", record, attachment.userId, permissionContext)).map((record) => ({ id: record.recordId, ...record.data }));
4606
+ }
4607
+ if (!getRolePermissions(schema, attachment.role).read) return [];
4608
+ if (schema.roster === "read-policy") {
4609
+ const permissionContext = ctx.getPermissionContext();
4610
+ const readable = new Set(
4611
+ records.filter((record) => canRead(schema, attachment.role, record, attachment.userId, permissionContext)).map((record) => record.recordId)
4612
+ );
4613
+ return publicRoster.filter((user) => readable.has(user.id));
4614
+ }
4615
+ return publicRoster;
4616
+ }
4617
+ function publicIdentities(records) {
4618
+ return records.map(({ recordId, data }) => ({
4619
+ id: recordId,
4620
+ name: data.name,
4621
+ ...data.imageUrl ? { imageUrl: data.imageUrl } : {},
4622
+ role: data.role
4623
+ }));
4624
+ }
4441
4625
  function handleUserList(ctx, ws, attachment) {
4442
4626
  const schema = ctx.schemaRegistry.get("users");
4443
- const permissionContext = ctx.getPermissionContext();
4444
- const visibleRecords = attachment.userId.startsWith("anon-") || !schema ? [] : getAllUserRecords(ctx.sql, ctx.schemaRegistry).filter(
4445
- (record) => canRead(
4446
- schema,
4447
- attachment.role,
4448
- record,
4449
- attachment.userId,
4450
- permissionContext
4451
- )
4452
- );
4453
- const users = visibleRecords.map((record) => {
4454
- if (attachment.role === "admin") return { id: record.recordId, ...record.data };
4455
- const { name, imageUrl, role } = record.data;
4456
- return {
4457
- id: record.recordId,
4458
- name,
4459
- ...imageUrl ? { imageUrl } : {},
4460
- role
4461
- };
4462
- });
4627
+ if (!schema) {
4628
+ ctx.send(ws, { type: MSG.USER_LIST, payload: { users: [] } });
4629
+ return;
4630
+ }
4631
+ const records = getAllUserRecords(ctx.sql, ctx.schemaRegistry);
4632
+ const users = rosterFor(ctx, schema, records, publicIdentities(records), attachment);
4463
4633
  ctx.send(ws, { type: MSG.USER_LIST, payload: { users } });
4464
4634
  }
4635
+ function broadcastUserList(ctx, except) {
4636
+ const schema = ctx.schemaRegistry.get("users");
4637
+ if (!schema) return;
4638
+ const skip = except instanceof Set ? except : new Set(except ? [except] : []);
4639
+ const records = getAllUserRecords(ctx.sql, ctx.schemaRegistry);
4640
+ const publicRoster = publicIdentities(records);
4641
+ for (const other of ctx.state.getWebSockets()) {
4642
+ if (skip.has(other)) continue;
4643
+ const attachment = other.deserializeAttachment();
4644
+ if (!attachment) continue;
4645
+ ctx.send(other, {
4646
+ type: MSG.USER_LIST,
4647
+ payload: { users: rosterFor(ctx, schema, records, publicRoster, attachment) }
4648
+ });
4649
+ }
4650
+ }
4465
4651
  function handleUserUpdate(ctx, _ws, attachment, payload) {
4466
4652
  const existing = getUserRecord(ctx.sql, attachment.userId, ctx.schemaRegistry);
4467
4653
  if (!existing) return;
@@ -4470,6 +4656,8 @@ function handleUserUpdate(ctx, _ws, attachment, payload) {
4470
4656
  if (payload.email) data.email = payload.email;
4471
4657
  if (payload.imageUrl !== void 0) data.imageUrl = payload.imageUrl || void 0;
4472
4658
  putRecord(ctx, "users", attachment.userId, data, attachment.userId, "admin", true, true);
4659
+ const rosterChanged = data.name !== void 0 && data.name !== existing.data.name || data.imageUrl !== void 0 && data.imageUrl !== existing.data.imageUrl;
4660
+ if (rosterChanged) broadcastUserList(ctx);
4473
4661
  }
4474
4662
  async function handleSetRole(ctx, ws, attachment, payload) {
4475
4663
  if (attachment.role !== "admin") {
@@ -4488,20 +4676,17 @@ async function handleSetRole(ctx, ws, attachment, payload) {
4488
4676
  now,
4489
4677
  payload.userId
4490
4678
  );
4679
+ const closing = /* @__PURE__ */ new Set();
4491
4680
  for (const otherWs of ctx.state.getWebSockets()) {
4492
4681
  const otherAttachment = otherWs.deserializeAttachment();
4493
- if (!otherAttachment) continue;
4494
- if (otherAttachment.userId === payload.userId) {
4495
- try {
4496
- otherWs.close(1008, "role-changed");
4497
- } catch {
4498
- }
4499
- continue;
4500
- }
4501
- if (otherAttachment.role === "admin") {
4502
- handleUserList(ctx, otherWs, otherAttachment);
4682
+ if (otherAttachment?.userId !== payload.userId) continue;
4683
+ closing.add(otherWs);
4684
+ try {
4685
+ otherWs.close(1008, "role-changed");
4686
+ } catch {
4503
4687
  }
4504
4688
  }
4689
+ broadcastUserList(ctx, closing);
4505
4690
  }
4506
4691
 
4507
4692
  // src/server/handlers/tools-api.ts
@@ -4523,7 +4708,10 @@ async function handleToolsRequest(ctx, request, path) {
4523
4708
  const toolName = path.replace("tools/describe/", "");
4524
4709
  const tool2 = BUILT_IN_TOOLS.find((t) => t.name === toolName);
4525
4710
  if (!tool2) {
4526
- return Response.json({ error: `Tool not found: ${toolName}` }, { status: 404, headers: CORS_HEADERS3 });
4711
+ return Response.json(
4712
+ { error: `Tool not found: ${toolName}` },
4713
+ { status: 404, headers: CORS_HEADERS3 }
4714
+ );
4527
4715
  }
4528
4716
  return Response.json({ tool: tool2 }, { headers: CORS_HEADERS3 });
4529
4717
  }
@@ -4568,20 +4756,23 @@ async function handleToolExecute(ctx, request) {
4568
4756
  const recordCtx = {
4569
4757
  sql: ctx.sql,
4570
4758
  schemaRegistry: ctx.schemaRegistry,
4759
+ scopeId: ctx.scopeId,
4571
4760
  state: ctx.state,
4572
4761
  getPermissionContext: ctx.getPermissionContext,
4573
4762
  send: ctx.send
4574
4763
  };
4575
- const result = await executeTool2(recordCtx, tool2, params, userId, userRole, isAppAction);
4764
+ const result = await executeTool(recordCtx, tool2, params, userId, userRole, isAppAction);
4576
4765
  const status = result.success ? 200 : result.error?.includes("not found") ? 404 : 400;
4577
4766
  return Response.json(result, { status, headers: CORS_HEADERS3 });
4578
4767
  }
4579
- async function executeTool2(ctx, toolName, params, userId, userRole, skipUserRbac) {
4768
+ async function executeTool(ctx, toolName, params, userId, userRole, skipUserRbac) {
4580
4769
  switch (toolName) {
4581
4770
  // ---- Records ----
4582
4771
  case "records.query": {
4583
4772
  const collection = params.collection;
4584
4773
  if (!collection) return { success: false, error: "Missing required param: collection" };
4774
+ const resolved = resolveCollection(ctx, collection);
4775
+ if (!resolved.ok) return { success: false, error: resolved.error };
4585
4776
  const query = {
4586
4777
  collection,
4587
4778
  where: params.where,
@@ -4623,6 +4814,15 @@ async function executeTool2(ctx, toolName, params, userId, userRole, skipUserRba
4623
4814
  if (!recordId) return { success: false, error: "Missing required param: recordId" };
4624
4815
  return deleteRecord(ctx, collection, recordId, userId, userRole, skipUserRbac);
4625
4816
  }
4817
+ // Bounded batch delete — one subrequest per page instead of one per row.
4818
+ // Same authorization posture as `records.delete`; `deleteWhere` itself
4819
+ // validates `where` (every key must name a real field) and `limit`, so no
4820
+ // dispatch path can reach a collection-wide truncation.
4821
+ case "records.deleteWhere": {
4822
+ const collection = params.collection;
4823
+ if (!collection) return { success: false, error: "Missing required param: collection" };
4824
+ return deleteWhere(ctx, collection, params.where, params.limit, userId, userRole, skipUserRbac);
4825
+ }
4626
4826
  // ---- Users ----
4627
4827
  // Bootstrap or refresh the `users` row for a given userId. Same path
4628
4828
  // the SDK uses on WS connect via registerUser, exposed for server
@@ -4691,10 +4891,13 @@ async function executeTool2(ctx, toolName, params, userId, userRole, skipUserRba
4691
4891
  }
4692
4892
  function checkYjsPermission(ctx, collection, recordId, userId, userRole, action) {
4693
4893
  if (SYSTEM_COLLECTIONS.has(collection)) return null;
4694
- const schema = ctx.schemaRegistry.get(collection);
4695
- if (!schema) return { success: false, error: `Schema not registered for collection: ${collection}` };
4894
+ const resolution = resolveCollection(ctx, collection);
4895
+ if (!resolution.ok) return { success: false, error: resolution.error };
4896
+ const schema = resolution.schema;
4897
+ if (!schema)
4898
+ return { success: false, error: `Schema not registered for collection: ${collection}` };
4696
4899
  const record = getRecord(ctx.sql, collection, recordId, schema);
4697
- if (!record) return { success: false, error: `Record not found: ${collection}/${recordId}` };
4900
+ if (!record) return { success: false, error: `${RECORD_NOT_FOUND}: ${collection}/${recordId}` };
4698
4901
  const recordWithId = { ...record, recordId };
4699
4902
  const permCtx = ctx.getPermissionContext();
4700
4903
  if (action === "read" && !canRead(schema, userRole, recordWithId, userId, permCtx)) {
@@ -4727,9 +4930,7 @@ function withYjsDoc(ctx, docKey, operation) {
4727
4930
  function executeYjsTool(ctx, toolName, params, userId, userRole) {
4728
4931
  switch (toolName) {
4729
4932
  case "yjs.list": {
4730
- const rows = ctx.sql.exec(
4731
- `SELECT doc_key, updated_at FROM yjs_docs ORDER BY updated_at DESC`
4732
- ).toArray();
4933
+ const rows = ctx.sql.exec(`SELECT doc_key, updated_at FROM yjs_docs ORDER BY updated_at DESC`).toArray();
4733
4934
  const docs = rows.map((row) => {
4734
4935
  const firstColon = row.doc_key.indexOf(":");
4735
4936
  const lastColon = row.doc_key.lastIndexOf(":");
@@ -5169,12 +5370,13 @@ var RecordRoom = class extends BaseRoom {
5169
5370
  const userName = user.userName;
5170
5371
  const userEmail = user.userEmail;
5171
5372
  const userImageUrl = user.userImageUrl;
5172
- const isAuthenticated = !userId.startsWith("anon-");
5373
+ const isAuthenticated = !isAnonymousUserId(userId);
5173
5374
  let attachment;
5174
5375
  if (isAuthenticated) {
5175
5376
  const isOwner2 = this.ownerUserId != null && userId === this.ownerUserId;
5176
5377
  const regStart = Date.now();
5177
5378
  const defaultRole = this.schemaRegistry.get("users")?.defaultRole ?? "member";
5379
+ const before = getUser(this.sql, userId, this.schemaRegistry);
5178
5380
  const registeredUser = await registerUser(
5179
5381
  this.sql,
5180
5382
  userId,
@@ -5186,6 +5388,9 @@ var RecordRoom = class extends BaseRoom {
5186
5388
  this.schemaRegistry
5187
5389
  );
5188
5390
  const regMs = Date.now() - regStart;
5391
+ if (!before || before.name !== registeredUser.name || before.imageUrl !== registeredUser.imageUrl || before.role !== registeredUser.role) {
5392
+ broadcastUserList(this.createUserContext(), ws);
5393
+ }
5189
5394
  attachment = {
5190
5395
  userId: registeredUser.id,
5191
5396
  userName,
@@ -5352,6 +5557,10 @@ var RecordRoom = class extends BaseRoom {
5352
5557
  sql: this.sql,
5353
5558
  state: this.state,
5354
5559
  schemaRegistry: this.schemaRegistry,
5560
+ // Every record room stub is addressed with `idFromName(scopeId)`, so the
5561
+ // DO's own name is the scope. Handlers use it to name the room when a
5562
+ // caller asks it for a collection that lives in a different scope.
5563
+ scopeId: this.state.id.name,
5355
5564
  getPermissionContext: () => this.getPermissionContext(),
5356
5565
  send: (ws, msg) => this.send(ws, msg),
5357
5566
  sendBinary: (ws, data) => this.sendBinaryHelper(ws, data),
@@ -8576,6 +8785,7 @@ export {
8576
8785
  CanvasRoom,
8577
8786
  CronRoom,
8578
8787
  DEFAULT_CONTEXT_CONFIG,
8788
+ DEFAULT_DELETE_WHERE_LIMIT,
8579
8789
  DEFAULT_DO_MANIFEST,
8580
8790
  DEFAULT_QUERY_LIMIT,
8581
8791
  DEPLOYED_APPS_DEFAULTS,
@@ -8586,6 +8796,7 @@ export {
8586
8796
  GLOBAL_DO_TYPE_NAMES,
8587
8797
  JobRoom,
8588
8798
  MAX_APP_FILE_BYTES,
8799
+ MAX_DELETE_WHERE_LIMIT,
8589
8800
  MAX_DEPLOY_ASSET_FILE_BYTES,
8590
8801
  MAX_UPLOAD_PARTS,
8591
8802
  MAX_UPLOAD_REQUEST_BYTES,
@@ -8601,7 +8812,9 @@ export {
8601
8812
  RefundError,
8602
8813
  RegistryClientError,
8603
8814
  STRICT_APP_ID_RE,
8815
+ SYSTEM_ASSIGNED_COLUMNS,
8604
8816
  SYSTEM_COLLECTION_SCHEMAS,
8817
+ SYSTEM_MAINTAINED_COLUMNS,
8605
8818
  SYSTEM_MANAGED_COLUMNS,
8606
8819
  SchemaRegistry,
8607
8820
  SubscriptionAuthError,
@@ -8620,6 +8833,7 @@ export {
8620
8833
  authWorkerFetch,
8621
8834
  bindingManifestFromOutputConfig,
8622
8835
  broadcastChange,
8836
+ broadcastUserList,
8623
8837
  broadcastYjsUpdate,
8624
8838
  buildCronContext,
8625
8839
  buildTableSelect,
@@ -8681,6 +8895,8 @@ export {
8681
8895
  isTestAccountEmail,
8682
8896
  isTestAccountTier,
8683
8897
  lintSchema,
8898
+ lintSchemaSet,
8899
+ lintSchemas,
8684
8900
  loadMessages,
8685
8901
  makeDefaultSummarizer,
8686
8902
  matchOrigin,