deepspace 0.23.0 → 0.23.2

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 };
@@ -109,7 +111,7 @@ var BUILT_IN_TOOLS = [
109
111
  description: `Query records from a collection with optional filtering, ordering, and pagination. Returns at most \`limit\` records (default ${DEFAULT_QUERY_LIMIT}). Large result sets are capped at ~30KB: an oversized page is truncated to the leading records that fit and flagged with \`{ truncated, returned, total }\`, so narrow with a \`where\` filter (or page via \`orderBy\` + \`limit\`) to see the rest.`,
110
112
  params: {
111
113
  collection: { type: "string", description: "Collection name to query", required: true },
112
- where: { type: "object", description: "Filter object with field=value equality conditions", required: false },
114
+ where: { type: "object", description: "Filter object with field=value equality conditions; every key must be recordId, createdBy, or a schema column (an unknown key is refused, not ignored)", required: false },
113
115
  orderBy: { type: "string", description: 'Field to order by (or "createdAt"/"updatedAt")', required: false },
114
116
  orderDir: { type: "string", description: 'Order direction: "asc" or "desc" (default: "desc")', required: false },
115
117
  limit: { type: "number", description: `Maximum number of records to return (default: ${DEFAULT_QUERY_LIMIT}). Oversized pages are still capped at ~30KB and truncated with returned/total flags.`, required: false }
@@ -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",
@@ -2540,6 +2551,10 @@ var MSG = {
2540
2551
  var MSG_YJS_SYNC = 22;
2541
2552
  var MSG_YJS_AWARENESS = 23;
2542
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
+ }
2543
2558
  var RECORD_NOT_FOUND = "Record not found";
2544
2559
 
2545
2560
  // src/server/utils/chat-history.ts
@@ -2607,43 +2622,35 @@ async function createChat(stub, userId, opts = {}) {
2607
2622
  return toChatRow(result.record);
2608
2623
  }
2609
2624
  async function updateChat(stub, chatId, userId, patch) {
2625
+ if (!await getChat(stub, chatId, userId)) return false;
2610
2626
  await executeToolAsApp(stub, userId, "records.update", {
2611
2627
  collection: "ai-chats",
2612
2628
  recordId: chatId,
2613
2629
  data: patch
2614
2630
  });
2631
+ return true;
2615
2632
  }
2633
+ var MESSAGE_DELETE_PAGE = 200;
2616
2634
  async function deleteChatCascade(stub, chatId, userId) {
2617
- const list = await executeToolAsApp(stub, userId, "records.query", {
2618
- collection: "ai-messages",
2619
- where: { chatId }
2620
- });
2621
- const errors = [];
2622
- for (const env of list.records) {
2623
- try {
2624
- await executeToolAsApp(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", {
2625
2640
  collection: "ai-messages",
2626
- recordId: env.recordId
2641
+ where: { chatId },
2642
+ limit: MESSAGE_DELETE_PAGE
2627
2643
  });
2628
- } catch (err) {
2629
- errors.push(err);
2644
+ deleted = page.deleted;
2630
2645
  }
2631
- }
2632
- try {
2633
- await executeToolAsApp(stub, userId, "records.delete", {
2634
- collection: "ai-chats",
2635
- recordId: chatId
2636
- });
2637
2646
  } catch (err) {
2638
- errors.push(err);
2639
- }
2640
- if (errors.length > 0) {
2641
- const first = errors[0];
2642
- throw new Error(
2643
- `deleteChatCascade: ${errors.length} delete(s) failed; first: ${first instanceof Error ? first.message : String(first)}`,
2644
- { cause: errors }
2645
- );
2647
+ messagesError = err;
2646
2648
  }
2649
+ await executeToolAsApp(stub, userId, "records.delete", {
2650
+ collection: "ai-chats",
2651
+ recordId: chatId
2652
+ });
2653
+ if (messagesError) throw messagesError;
2647
2654
  }
2648
2655
  async function loadMessages(stub, chatId, userId) {
2649
2656
  const result = await executeToolAsApp(stub, userId, "records.query", {
@@ -2655,6 +2662,7 @@ async function loadMessages(stub, chatId, userId) {
2655
2662
  return result.records.map(toMessageRow);
2656
2663
  }
2657
2664
  async function appendMessage(stub, msg) {
2665
+ if (!await getChat(stub, msg.chatId, msg.userId)) return false;
2658
2666
  const data = {
2659
2667
  chatId: msg.chatId,
2660
2668
  userId: msg.userId,
@@ -2667,6 +2675,7 @@ async function appendMessage(stub, msg) {
2667
2675
  recordId: msg.id,
2668
2676
  data
2669
2677
  });
2678
+ return true;
2670
2679
  }
2671
2680
 
2672
2681
  // src/server/utils/metering.ts
@@ -2789,7 +2798,7 @@ function decodeRoomIdentityHeader(value) {
2789
2798
  // src/server/rooms/base-room.ts
2790
2799
  function connectionAttachmentFromRequest(request) {
2791
2800
  return {
2792
- 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()}`,
2793
2802
  userName: decodeRoomIdentityHeader(request.headers.get("x-user-name")) || "Anonymous",
2794
2803
  userEmail: decodeRoomIdentityHeader(request.headers.get("x-user-email")) || "",
2795
2804
  userImageUrl: decodeRoomIdentityHeader(request.headers.get("x-user-image-url")),
@@ -3302,12 +3311,28 @@ function lintSchema(schema) {
3302
3311
  }
3303
3312
  return warnings;
3304
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
+ }
3305
3331
  var SchemaRegistry = class {
3306
3332
  trusted = /* @__PURE__ */ new Map();
3307
3333
  constructor(schemas = []) {
3308
- for (const schema of schemas) {
3309
- this.registerTrusted(schema);
3310
- }
3334
+ for (const w of lintSchemaSet(schemas)) console.warn(`[schema-lint] ${w}`);
3335
+ for (const schema of schemas) this.registerTrusted(schema);
3311
3336
  }
3312
3337
  registerTrusted(schema) {
3313
3338
  for (const w of lintSchema(schema)) {
@@ -3811,6 +3836,86 @@ function deleteRecord(ctx, collection, recordId, userId, userRole, skipUserRbac
3811
3836
  notifyTeamMembershipChange(ctx, collection, record);
3812
3837
  return { success: true, data: { deleted: true } };
3813
3838
  }
3839
+ function refuseUnknownWhere(collection, schema, where) {
3840
+ const columns = (schema.columns ?? []).map(resolveColumn);
3841
+ const filterable = ["recordId", "createdBy", ...columns.map((c) => c.name)];
3842
+ const unknown = Object.keys(where).filter(
3843
+ (key) => key !== "recordId" && key !== "createdBy" && !columns.some((c) => c.id === columnId(key) || c.name === key)
3844
+ );
3845
+ if (unknown.length > 0) {
3846
+ return {
3847
+ success: false,
3848
+ error: `Unknown field(s) in where for "${collection}": ${unknown.join(", ")} \u2014 filterable fields: ${filterable.join(", ")}`
3849
+ };
3850
+ }
3851
+ const nonPrimitive = Object.entries(where).filter(([, v]) => v !== null && typeof v === "object");
3852
+ if (nonPrimitive.length > 0) {
3853
+ return {
3854
+ success: false,
3855
+ error: `where values must be primitives (equality only): ${nonPrimitive.map(([k]) => k).join(", ")}`
3856
+ };
3857
+ }
3858
+ return null;
3859
+ }
3860
+ function deleteWhere(ctx, collection, where, limit, userId, userRole, skipUserRbac = false) {
3861
+ const resolved = resolveCollection(ctx, collection);
3862
+ if (!resolved.ok) return { success: false, error: resolved.error };
3863
+ const schema = resolved.schema;
3864
+ if (!schema) {
3865
+ return {
3866
+ success: false,
3867
+ error: `records.deleteWhere: collection "${collection}" has no schema, so it has no filterable fields \u2014 delete its rows by recordId with records.delete`
3868
+ };
3869
+ }
3870
+ if (!where || typeof where !== "object" || Array.isArray(where) || Object.keys(where).length === 0) {
3871
+ return { success: false, error: "Missing required param: where (must match at least one field)" };
3872
+ }
3873
+ const whereRefusal = refuseUnknownWhere(collection, schema, where);
3874
+ if (whereRefusal) return whereRefusal;
3875
+ if (limit !== void 0 && !(typeof limit === "number" && Number.isFinite(limit))) {
3876
+ return { success: false, error: "limit must be a finite number" };
3877
+ }
3878
+ const page = Math.min(
3879
+ Math.max(1, Math.floor(limit ?? DEFAULT_DELETE_WHERE_LIMIT)),
3880
+ MAX_DELETE_WHERE_LIMIT
3881
+ );
3882
+ const scan = { capped: false };
3883
+ const matched = executeQuery(
3884
+ ctx,
3885
+ { collection, where, limit: page },
3886
+ userId,
3887
+ userRole,
3888
+ skipUserRbac,
3889
+ scan
3890
+ );
3891
+ if (scan.capped && matched.length < page) {
3892
+ return {
3893
+ success: false,
3894
+ 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`
3895
+ };
3896
+ }
3897
+ if (!skipUserRbac && schema) {
3898
+ const permCtx = ctx.getPermissionContext();
3899
+ for (const record of matched) {
3900
+ if (!canDelete(schema, userRole, record, userId, permCtx)) {
3901
+ return { success: false, error: `DELETE DENIED: role=${userRole}, collection=${collection}` };
3902
+ }
3903
+ }
3904
+ }
3905
+ let deleted = 0;
3906
+ for (const record of matched) {
3907
+ if (deleteRecord(ctx, collection, record.recordId, userId, userRole, skipUserRbac).success) {
3908
+ deleted++;
3909
+ }
3910
+ }
3911
+ if (deleted < matched.length) {
3912
+ return {
3913
+ success: false,
3914
+ error: `Deleted ${deleted} of ${matched.length} matched record(s) in "${collection}"; the rest failed \u2014 retry`
3915
+ };
3916
+ }
3917
+ return { success: true, data: { deleted } };
3918
+ }
3814
3919
  function readRecord(ctx, collection, recordId, userId, userRole, skipUserRbac = false) {
3815
3920
  const resolved = resolveCollection(ctx, collection);
3816
3921
  if (!resolved.ok) return { success: false, error: resolved.error };
@@ -4146,7 +4251,8 @@ function handleSubscribe(ctx, ws, attachment, payload) {
4146
4251
  }
4147
4252
  function handleUnsubscribe(_ctx, _ws, _attachment, _payload) {
4148
4253
  }
4149
- function executeQuery(ctx, query, userId, userRole, skipUserRbac = false) {
4254
+ var MAX_FILTERED_SCAN_ROWS = 5e3;
4255
+ function executeQuery(ctx, query, userId, userRole, skipUserRbac = false, scanState) {
4150
4256
  const resolved = resolveCollection(ctx, query.collection);
4151
4257
  if (!resolved.ok) return [];
4152
4258
  if (!resolved.schema) {
@@ -4159,7 +4265,8 @@ function executeQuery(ctx, query, userId, userRole, skipUserRbac = false) {
4159
4265
  userId,
4160
4266
  userRole,
4161
4267
  resolved.isSystem,
4162
- skipUserRbac
4268
+ skipUserRbac,
4269
+ scanState
4163
4270
  );
4164
4271
  }
4165
4272
  function executeSystemQuery(ctx, query) {
@@ -4196,7 +4303,7 @@ function preloadUserTeamIds(ctx, userId) {
4196
4303
  );
4197
4304
  return cursor.toArray().map((r) => r.col_teamid);
4198
4305
  }
4199
- function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipUserRbac = false) {
4306
+ function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipUserRbac = false, scanState) {
4200
4307
  const columns = (schema.columns ?? []).map(resolveColumn);
4201
4308
  const perms = getRolePermissions(schema, userRole);
4202
4309
  let sql = buildTableSelect(query.collection, columns);
@@ -4204,16 +4311,27 @@ function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipU
4204
4311
  const whereClauses = [];
4205
4312
  let skipPerRecordCheck = skipUserRbac;
4206
4313
  if (!skipUserRbac && schema.teamField && perms.read === "team") {
4207
- const filterCol = schema.teamField === "_rowId" ? "_row_id" : columns.find((c) => c.name === schema.teamField)?.id ?? columnId(schema.teamField);
4208
- const teamIds = preloadUserTeamIds(ctx, userId);
4209
- if (teamIds && teamIds.length > 0) {
4210
- const placeholders = teamIds.map(() => "?").join(", ");
4211
- whereClauses.push(`("${filterCol}" IN (${placeholders}) OR _created_by = ?)`);
4212
- params.push(...teamIds, userId);
4213
- } else {
4214
- whereClauses.push(`_created_by = ?`);
4314
+ const filterCol = schema.teamField === "_rowId" ? "_row_id" : columns.find((c) => c.name === schema.teamField)?.id;
4315
+ const teamIds = filterCol ? preloadUserTeamIds(ctx, userId) : null;
4316
+ if (filterCol) {
4317
+ if (teamIds && teamIds.length > 0) {
4318
+ const placeholders = teamIds.map(() => "?").join(", ");
4319
+ whereClauses.push(`("${filterCol}" IN (${placeholders}) OR _created_by = ?)`);
4320
+ params.push(...teamIds, userId);
4321
+ } else {
4322
+ whereClauses.push(`_created_by = ?`);
4323
+ params.push(userId);
4324
+ }
4325
+ skipPerRecordCheck = true;
4326
+ }
4327
+ } else if (!skipUserRbac && perms.read === "own") {
4328
+ const ownerCol = schema.ownerField ? columns.find((c) => c.name === schema.ownerField)?.id : "_created_by";
4329
+ if (ownerCol) {
4330
+ whereClauses.push(`"${ownerCol}" = ?`);
4215
4331
  params.push(userId);
4332
+ skipPerRecordCheck = true;
4216
4333
  }
4334
+ } else if (!skipUserRbac && perms.read === true) {
4217
4335
  skipPerRecordCheck = true;
4218
4336
  }
4219
4337
  if (query.where) {
@@ -4251,27 +4369,44 @@ function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipU
4251
4369
  } else {
4252
4370
  sql += ` ORDER BY _created_at DESC`;
4253
4371
  }
4254
- if (query.limit) {
4255
- sql += ` LIMIT ?`;
4256
- params.push(query.limit);
4257
- }
4258
- const cursor = ctx.sql.exec(sql, ...params);
4372
+ const readPage = (limitClause, limitParams) => {
4373
+ const cursor = ctx.sql.exec(sql + limitClause, ...params, ...limitParams);
4374
+ const rows = cursor.toArray();
4375
+ const records = [];
4376
+ for (const row of rows) {
4377
+ const r = row;
4378
+ const record = {
4379
+ recordId: r._row_id,
4380
+ data: rowToData(r, columns),
4381
+ createdBy: r._created_by,
4382
+ createdAt: r._created_at,
4383
+ updatedAt: r._updated_at
4384
+ };
4385
+ if (isSystem || skipPerRecordCheck || canRead(schema, userRole, { data: record.data, createdBy: record.createdBy, recordId: record.recordId }, userId, ctx.getPermissionContext())) {
4386
+ records.push(record);
4387
+ }
4388
+ }
4389
+ return { records, scanned: rows.length };
4390
+ };
4391
+ if (!query.limit) return readPage("", []).records;
4392
+ const rowFiltered = !isSystem && !skipPerRecordCheck;
4393
+ if (!rowFiltered) return readPage(" LIMIT ?", [query.limit]).records;
4259
4394
  const results = [];
4260
- for (const row of cursor.toArray()) {
4261
- const r = row;
4262
- const data = rowToData(r, columns);
4263
- const record = {
4264
- recordId: r._row_id,
4265
- data,
4266
- createdBy: r._created_by,
4267
- createdAt: r._created_at,
4268
- updatedAt: r._updated_at
4269
- };
4270
- if (isSystem || skipPerRecordCheck || canRead(schema, userRole, { data: record.data, createdBy: record.createdBy, recordId: record.recordId }, userId, ctx.getPermissionContext())) {
4395
+ const batch = Math.max(query.limit, 200);
4396
+ for (let offset = 0; ; offset += batch) {
4397
+ const { records, scanned } = readPage(" LIMIT ? OFFSET ?", [batch, offset]);
4398
+ for (const record of records) {
4271
4399
  results.push(record);
4400
+ if (results.length >= query.limit) return results;
4401
+ }
4402
+ if (scanned < batch) return results;
4403
+ if (offset + batch >= MAX_FILTERED_SCAN_ROWS) {
4404
+ if (scanState && readPage(" LIMIT ? OFFSET ?", [1, offset + batch]).scanned > 0) {
4405
+ scanState.capped = true;
4406
+ }
4407
+ return results;
4272
4408
  }
4273
4409
  }
4274
- return results;
4275
4410
  }
4276
4411
  function recordMatchesSubscription(record, collection, query, userId, userRole, schema, ctx) {
4277
4412
  if (query.collection !== collection) return false;
@@ -4468,30 +4603,56 @@ async function registerUser(sql, userId, name, email, imageUrl, isAdmin, default
4468
4603
  );
4469
4604
  return { id: userId, email, name, imageUrl, role, createdAt: now, lastSeenAt: now };
4470
4605
  }
4606
+ function rosterFor(ctx, schema, records, publicRoster, attachment) {
4607
+ if (isAnonymousUserId(attachment.userId)) return [];
4608
+ if (attachment.role === "admin") {
4609
+ const permissionContext = ctx.getPermissionContext();
4610
+ return records.filter((record) => canRead(schema, "admin", record, attachment.userId, permissionContext)).map((record) => ({ id: record.recordId, ...record.data }));
4611
+ }
4612
+ if (!getRolePermissions(schema, attachment.role).read) return [];
4613
+ if (schema.roster === "read-policy") {
4614
+ const permissionContext = ctx.getPermissionContext();
4615
+ const readable = new Set(
4616
+ records.filter((record) => canRead(schema, attachment.role, record, attachment.userId, permissionContext)).map((record) => record.recordId)
4617
+ );
4618
+ return publicRoster.filter((user) => readable.has(user.id));
4619
+ }
4620
+ return publicRoster;
4621
+ }
4622
+ function publicIdentities(records) {
4623
+ return records.map(({ recordId, data }) => ({
4624
+ id: recordId,
4625
+ name: data.name,
4626
+ ...data.imageUrl ? { imageUrl: data.imageUrl } : {},
4627
+ role: data.role
4628
+ }));
4629
+ }
4471
4630
  function handleUserList(ctx, ws, attachment) {
4472
4631
  const schema = ctx.schemaRegistry.get("users");
4473
- const permissionContext = ctx.getPermissionContext();
4474
- const visibleRecords = attachment.userId.startsWith("anon-") || !schema ? [] : getAllUserRecords(ctx.sql, ctx.schemaRegistry).filter(
4475
- (record) => canRead(
4476
- schema,
4477
- attachment.role,
4478
- record,
4479
- attachment.userId,
4480
- permissionContext
4481
- )
4482
- );
4483
- const users = visibleRecords.map((record) => {
4484
- if (attachment.role === "admin") return { id: record.recordId, ...record.data };
4485
- const { name, imageUrl, role } = record.data;
4486
- return {
4487
- id: record.recordId,
4488
- name,
4489
- ...imageUrl ? { imageUrl } : {},
4490
- role
4491
- };
4492
- });
4632
+ if (!schema) {
4633
+ ctx.send(ws, { type: MSG.USER_LIST, payload: { users: [] } });
4634
+ return;
4635
+ }
4636
+ const records = getAllUserRecords(ctx.sql, ctx.schemaRegistry);
4637
+ const users = rosterFor(ctx, schema, records, publicIdentities(records), attachment);
4493
4638
  ctx.send(ws, { type: MSG.USER_LIST, payload: { users } });
4494
4639
  }
4640
+ function broadcastUserList(ctx, except) {
4641
+ const schema = ctx.schemaRegistry.get("users");
4642
+ if (!schema) return;
4643
+ const skip = except instanceof Set ? except : new Set(except ? [except] : []);
4644
+ const records = getAllUserRecords(ctx.sql, ctx.schemaRegistry);
4645
+ const publicRoster = publicIdentities(records);
4646
+ for (const other of ctx.state.getWebSockets()) {
4647
+ if (skip.has(other)) continue;
4648
+ const attachment = other.deserializeAttachment();
4649
+ if (!attachment) continue;
4650
+ ctx.send(other, {
4651
+ type: MSG.USER_LIST,
4652
+ payload: { users: rosterFor(ctx, schema, records, publicRoster, attachment) }
4653
+ });
4654
+ }
4655
+ }
4495
4656
  function handleUserUpdate(ctx, _ws, attachment, payload) {
4496
4657
  const existing = getUserRecord(ctx.sql, attachment.userId, ctx.schemaRegistry);
4497
4658
  if (!existing) return;
@@ -4500,6 +4661,8 @@ function handleUserUpdate(ctx, _ws, attachment, payload) {
4500
4661
  if (payload.email) data.email = payload.email;
4501
4662
  if (payload.imageUrl !== void 0) data.imageUrl = payload.imageUrl || void 0;
4502
4663
  putRecord(ctx, "users", attachment.userId, data, attachment.userId, "admin", true, true);
4664
+ const rosterChanged = data.name !== void 0 && data.name !== existing.data.name || data.imageUrl !== void 0 && data.imageUrl !== existing.data.imageUrl;
4665
+ if (rosterChanged) broadcastUserList(ctx);
4503
4666
  }
4504
4667
  async function handleSetRole(ctx, ws, attachment, payload) {
4505
4668
  if (attachment.role !== "admin") {
@@ -4518,20 +4681,17 @@ async function handleSetRole(ctx, ws, attachment, payload) {
4518
4681
  now,
4519
4682
  payload.userId
4520
4683
  );
4684
+ const closing = /* @__PURE__ */ new Set();
4521
4685
  for (const otherWs of ctx.state.getWebSockets()) {
4522
4686
  const otherAttachment = otherWs.deserializeAttachment();
4523
- if (!otherAttachment) continue;
4524
- if (otherAttachment.userId === payload.userId) {
4525
- try {
4526
- otherWs.close(1008, "role-changed");
4527
- } catch {
4528
- }
4529
- continue;
4530
- }
4531
- if (otherAttachment.role === "admin") {
4532
- handleUserList(ctx, otherWs, otherAttachment);
4687
+ if (otherAttachment?.userId !== payload.userId) continue;
4688
+ closing.add(otherWs);
4689
+ try {
4690
+ otherWs.close(1008, "role-changed");
4691
+ } catch {
4533
4692
  }
4534
4693
  }
4694
+ broadcastUserList(ctx, closing);
4535
4695
  }
4536
4696
 
4537
4697
  // src/server/handlers/tools-api.ts
@@ -4618,9 +4778,20 @@ async function executeTool(ctx, toolName, params, userId, userRole, skipUserRbac
4618
4778
  if (!collection) return { success: false, error: "Missing required param: collection" };
4619
4779
  const resolved = resolveCollection(ctx, collection);
4620
4780
  if (!resolved.ok) return { success: false, error: resolved.error };
4781
+ const where = params.where;
4782
+ if (where && typeof where === "object" && Object.keys(where).length > 0) {
4783
+ if (!resolved.schema) {
4784
+ return {
4785
+ success: false,
4786
+ error: `records.query: collection "${collection}" has no schema, so it has no filterable fields \u2014 query it without \`where\``
4787
+ };
4788
+ }
4789
+ const refusal = refuseUnknownWhere(collection, resolved.schema, where);
4790
+ if (refusal) return refusal;
4791
+ }
4621
4792
  const query = {
4622
4793
  collection,
4623
- where: params.where,
4794
+ where,
4624
4795
  orderBy: params.orderBy,
4625
4796
  orderDir: params.orderDir,
4626
4797
  limit: params.limit
@@ -4659,6 +4830,15 @@ async function executeTool(ctx, toolName, params, userId, userRole, skipUserRbac
4659
4830
  if (!recordId) return { success: false, error: "Missing required param: recordId" };
4660
4831
  return deleteRecord(ctx, collection, recordId, userId, userRole, skipUserRbac);
4661
4832
  }
4833
+ // Bounded batch delete — one subrequest per page instead of one per row.
4834
+ // Same authorization posture as `records.delete`; `deleteWhere` itself
4835
+ // validates `where` (every key must name a real field) and `limit`, so no
4836
+ // dispatch path can reach a collection-wide truncation.
4837
+ case "records.deleteWhere": {
4838
+ const collection = params.collection;
4839
+ if (!collection) return { success: false, error: "Missing required param: collection" };
4840
+ return deleteWhere(ctx, collection, params.where, params.limit, userId, userRole, skipUserRbac);
4841
+ }
4662
4842
  // ---- Users ----
4663
4843
  // Bootstrap or refresh the `users` row for a given userId. Same path
4664
4844
  // the SDK uses on WS connect via registerUser, exposed for server
@@ -5206,12 +5386,13 @@ var RecordRoom = class extends BaseRoom {
5206
5386
  const userName = user.userName;
5207
5387
  const userEmail = user.userEmail;
5208
5388
  const userImageUrl = user.userImageUrl;
5209
- const isAuthenticated = !userId.startsWith("anon-");
5389
+ const isAuthenticated = !isAnonymousUserId(userId);
5210
5390
  let attachment;
5211
5391
  if (isAuthenticated) {
5212
5392
  const isOwner2 = this.ownerUserId != null && userId === this.ownerUserId;
5213
5393
  const regStart = Date.now();
5214
5394
  const defaultRole = this.schemaRegistry.get("users")?.defaultRole ?? "member";
5395
+ const before = getUser(this.sql, userId, this.schemaRegistry);
5215
5396
  const registeredUser = await registerUser(
5216
5397
  this.sql,
5217
5398
  userId,
@@ -5223,6 +5404,9 @@ var RecordRoom = class extends BaseRoom {
5223
5404
  this.schemaRegistry
5224
5405
  );
5225
5406
  const regMs = Date.now() - regStart;
5407
+ if (!before || before.name !== registeredUser.name || before.imageUrl !== registeredUser.imageUrl || before.role !== registeredUser.role) {
5408
+ broadcastUserList(this.createUserContext(), ws);
5409
+ }
5226
5410
  attachment = {
5227
5411
  userId: registeredUser.id,
5228
5412
  userName,
@@ -8617,6 +8801,7 @@ export {
8617
8801
  CanvasRoom,
8618
8802
  CronRoom,
8619
8803
  DEFAULT_CONTEXT_CONFIG,
8804
+ DEFAULT_DELETE_WHERE_LIMIT,
8620
8805
  DEFAULT_DO_MANIFEST,
8621
8806
  DEFAULT_QUERY_LIMIT,
8622
8807
  DEPLOYED_APPS_DEFAULTS,
@@ -8627,6 +8812,7 @@ export {
8627
8812
  GLOBAL_DO_TYPE_NAMES,
8628
8813
  JobRoom,
8629
8814
  MAX_APP_FILE_BYTES,
8815
+ MAX_DELETE_WHERE_LIMIT,
8630
8816
  MAX_DEPLOY_ASSET_FILE_BYTES,
8631
8817
  MAX_UPLOAD_PARTS,
8632
8818
  MAX_UPLOAD_REQUEST_BYTES,
@@ -8663,6 +8849,7 @@ export {
8663
8849
  authWorkerFetch,
8664
8850
  bindingManifestFromOutputConfig,
8665
8851
  broadcastChange,
8852
+ broadcastUserList,
8666
8853
  broadcastYjsUpdate,
8667
8854
  buildCronContext,
8668
8855
  buildTableSelect,
@@ -8724,6 +8911,8 @@ export {
8724
8911
  isTestAccountEmail,
8725
8912
  isTestAccountTier,
8726
8913
  lintSchema,
8914
+ lintSchemaSet,
8915
+ lintSchemas,
8727
8916
  loadMessages,
8728
8917
  makeDefaultSummarizer,
8729
8918
  matchOrigin,