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/worker.js CHANGED
@@ -1,3 +1,83 @@
1
+ // src/shared/protocol/constants.ts
2
+ var MSG = {
3
+ // ---- Records / CRUD --------------------------------------------------
4
+ SUBSCRIBE: "core.subscribe",
5
+ UNSUBSCRIBE: "core.unsubscribe",
6
+ QUERY_RESULT: "core.query_result",
7
+ RECORD_CHANGE: "core.record_change",
8
+ PUT: "core.put",
9
+ DELETE: "core.delete",
10
+ ERROR: "core.error",
11
+ // ---- Users -----------------------------------------------------------
12
+ USER_INFO: "user.info",
13
+ USER_LIST: "user.list",
14
+ SET_ROLE: "user.set_role",
15
+ USER_UPDATE: "user.update",
16
+ // ---- Auth signal -----------------------------------------------------
17
+ // Server → client one-shot on connect from rooms that enforce role-based
18
+ // writes (Canvas, Game, Cron). Tells the client whether this connection
19
+ // can write so its hook can disable local write APIs and the UI can
20
+ // render controls accordingly. Without this, a viewer's click would
21
+ // round-trip to the server and silently come back as ERROR.
22
+ AUTH: "auth",
23
+ // ---- Yjs JSON control frames (binary sync lives in ./yjs.ts) ---------
24
+ YJS_JOIN: "yjs.join",
25
+ YJS_LEAVE: "yjs.leave",
26
+ // ---- Records room misc -----------------------------------------------
27
+ ACK: "records.ack",
28
+ LIST_SCHEMAS: "records.list_schemas",
29
+ RESUBSCRIBE: "records.resubscribe",
30
+ // ---- CanvasRoom ------------------------------------------------------
31
+ CANVAS_SHAPES: "canvas.shapes",
32
+ CANVAS_ADD: "canvas.add",
33
+ CANVAS_MOVE: "canvas.move",
34
+ CANVAS_RESIZE: "canvas.resize",
35
+ CANVAS_DELETE: "canvas.delete",
36
+ CANVAS_UPDATE: "canvas.update",
37
+ CANVAS_VIEWPORT: "canvas.viewport",
38
+ CANVAS_UNDO: "canvas.undo",
39
+ CANVAS_REDO: "canvas.redo",
40
+ // ---- CronRoom --------------------------------------------------------
41
+ CRON_TASKS: "cron.tasks",
42
+ CRON_HISTORY: "cron.history",
43
+ CRON_TRIGGER: "cron.trigger",
44
+ CRON_PAUSE: "cron.pause",
45
+ CRON_RESUME: "cron.resume",
46
+ CRON_STATUS: "cron.status",
47
+ // Per-mutation receipt, sent only to the socket whose trigger/pause/
48
+ // resume carried a `requestId` — frames without one stay fire-and-forget.
49
+ CRON_ACK: "cron.ack",
50
+ // ---- JobRoom ---------------------------------------------------------
51
+ // Background-job queue with durable, alarm-driven execution. See
52
+ // server/rooms/job-room.ts. Clients enqueue and subscribe to updates;
53
+ // the room broadcasts JOB_UPDATE for every status transition.
54
+ JOB_ENQUEUE: "job.enqueue",
55
+ JOB_CANCEL: "job.cancel",
56
+ JOB_RETRY: "job.retry",
57
+ JOB_UPDATE: "job.update",
58
+ // ---- PresenceRoom ----------------------------------------------------
59
+ PRESENCE_SYNC: "presence.sync",
60
+ PRESENCE_JOIN: "presence.join",
61
+ PRESENCE_LEAVE: "presence.leave",
62
+ PRESENCE_UPDATE: "presence.update",
63
+ // ---- Gateway multiplexing --------------------------------------------
64
+ GW_SCOPE_CONNECT: "gateway.scope_connect",
65
+ GW_SCOPE_DISCONNECT: "gateway.scope_disconnect",
66
+ GW_SCOPE_ERROR: "gateway.scope_error",
67
+ GW_TOKEN_REFRESH: "gateway.token_refresh",
68
+ GW_USER_UPDATE: "gateway.user_update"
69
+ };
70
+ var MSG_YJS_SYNC = 22;
71
+ var MSG_YJS_AWARENESS = 23;
72
+ var ROLE_ANONYMOUS = "viewer";
73
+ var ANONYMOUS_USER_ID_PREFIX = "anon-";
74
+ function isAnonymousUserId(userId) {
75
+ return userId.startsWith(ANONYMOUS_USER_ID_PREFIX);
76
+ }
77
+ var ROLE_DEFAULT = "member";
78
+ var ROLE_ADMIN = "admin";
79
+ var RECORD_NOT_FOUND = "Record not found";
80
+
1
81
  // src/shared/room-identity-headers.ts
2
82
  var BASE64URL = /^[A-Za-z0-9_-]*$/;
3
83
  var IDENTITY_QUERY_PARAMS = ["userId", "userName", "userEmail", "userImageUrl", "role"];
@@ -51,7 +131,7 @@ function authenticatedRoomRequest(request, auth, identity) {
51
131
  // src/server/rooms/base-room.ts
52
132
  function connectionAttachmentFromRequest(request) {
53
133
  return {
54
- userId: decodeRoomIdentityHeader(request.headers.get("x-user-id")) || `anon-${crypto.randomUUID()}`,
134
+ userId: decodeRoomIdentityHeader(request.headers.get("x-user-id")) || `${ANONYMOUS_USER_ID_PREFIX}${crypto.randomUUID()}`,
55
135
  userName: decodeRoomIdentityHeader(request.headers.get("x-user-name")) || "Anonymous",
56
136
  userEmail: decodeRoomIdentityHeader(request.headers.get("x-user-email")) || "",
57
137
  userImageUrl: decodeRoomIdentityHeader(request.headers.get("x-user-image-url")),
@@ -305,81 +385,6 @@ var BaseRoom = class {
305
385
  }
306
386
  };
307
387
 
308
- // src/shared/protocol/constants.ts
309
- var MSG = {
310
- // ---- Records / CRUD --------------------------------------------------
311
- SUBSCRIBE: "core.subscribe",
312
- UNSUBSCRIBE: "core.unsubscribe",
313
- QUERY_RESULT: "core.query_result",
314
- RECORD_CHANGE: "core.record_change",
315
- PUT: "core.put",
316
- DELETE: "core.delete",
317
- ERROR: "core.error",
318
- // ---- Users -----------------------------------------------------------
319
- USER_INFO: "user.info",
320
- USER_LIST: "user.list",
321
- SET_ROLE: "user.set_role",
322
- USER_UPDATE: "user.update",
323
- // ---- Auth signal -----------------------------------------------------
324
- // Server → client one-shot on connect from rooms that enforce role-based
325
- // writes (Canvas, Game, Cron). Tells the client whether this connection
326
- // can write so its hook can disable local write APIs and the UI can
327
- // render controls accordingly. Without this, a viewer's click would
328
- // round-trip to the server and silently come back as ERROR.
329
- AUTH: "auth",
330
- // ---- Yjs JSON control frames (binary sync lives in ./yjs.ts) ---------
331
- YJS_JOIN: "yjs.join",
332
- YJS_LEAVE: "yjs.leave",
333
- // ---- Records room misc -----------------------------------------------
334
- ACK: "records.ack",
335
- LIST_SCHEMAS: "records.list_schemas",
336
- RESUBSCRIBE: "records.resubscribe",
337
- // ---- CanvasRoom ------------------------------------------------------
338
- CANVAS_SHAPES: "canvas.shapes",
339
- CANVAS_ADD: "canvas.add",
340
- CANVAS_MOVE: "canvas.move",
341
- CANVAS_RESIZE: "canvas.resize",
342
- CANVAS_DELETE: "canvas.delete",
343
- CANVAS_UPDATE: "canvas.update",
344
- CANVAS_VIEWPORT: "canvas.viewport",
345
- CANVAS_UNDO: "canvas.undo",
346
- CANVAS_REDO: "canvas.redo",
347
- // ---- CronRoom --------------------------------------------------------
348
- CRON_TASKS: "cron.tasks",
349
- CRON_HISTORY: "cron.history",
350
- CRON_TRIGGER: "cron.trigger",
351
- CRON_PAUSE: "cron.pause",
352
- CRON_RESUME: "cron.resume",
353
- CRON_STATUS: "cron.status",
354
- // Per-mutation receipt, sent only to the socket whose trigger/pause/
355
- // resume carried a `requestId` — frames without one stay fire-and-forget.
356
- CRON_ACK: "cron.ack",
357
- // ---- JobRoom ---------------------------------------------------------
358
- // Background-job queue with durable, alarm-driven execution. See
359
- // server/rooms/job-room.ts. Clients enqueue and subscribe to updates;
360
- // the room broadcasts JOB_UPDATE for every status transition.
361
- JOB_ENQUEUE: "job.enqueue",
362
- JOB_CANCEL: "job.cancel",
363
- JOB_RETRY: "job.retry",
364
- JOB_UPDATE: "job.update",
365
- // ---- PresenceRoom ----------------------------------------------------
366
- PRESENCE_SYNC: "presence.sync",
367
- PRESENCE_JOIN: "presence.join",
368
- PRESENCE_LEAVE: "presence.leave",
369
- PRESENCE_UPDATE: "presence.update",
370
- // ---- Gateway multiplexing --------------------------------------------
371
- GW_SCOPE_CONNECT: "gateway.scope_connect",
372
- GW_SCOPE_DISCONNECT: "gateway.scope_disconnect",
373
- GW_SCOPE_ERROR: "gateway.scope_error",
374
- GW_TOKEN_REFRESH: "gateway.token_refresh",
375
- GW_USER_UPDATE: "gateway.user_update"
376
- };
377
- var MSG_YJS_SYNC = 22;
378
- var MSG_YJS_AWARENESS = 23;
379
- var ROLE_ANONYMOUS = "viewer";
380
- var ROLE_DEFAULT = "member";
381
- var ROLE_ADMIN = "admin";
382
-
383
388
  // src/server/schemas/registry.ts
384
389
  function collectionTableName(name) {
385
390
  return `c_${name.replace(/[^a-zA-Z0-9_]/g, "_")}`;
@@ -581,13 +586,11 @@ function checkUnclaimedOwnerTransition(schema, role, nextData, userId) {
581
586
  }
582
587
  return `Role '${role}' can set '${schema.ownerField}' only to their own user id or leave it unclaimed`;
583
588
  }
589
+ var SYSTEM_ASSIGNED_COLUMNS = /* @__PURE__ */ new Set(["email", "name", "imageUrl", "role"]);
590
+ var SYSTEM_MAINTAINED_COLUMNS = /* @__PURE__ */ new Set(["createdAt", "lastSeenAt"]);
584
591
  var SYSTEM_MANAGED_COLUMNS = /* @__PURE__ */ new Set([
585
- "email",
586
- "name",
587
- "imageUrl",
588
- "role",
589
- "createdAt",
590
- "lastSeenAt"
592
+ ...SYSTEM_ASSIGNED_COLUMNS,
593
+ ...SYSTEM_MAINTAINED_COLUMNS
591
594
  ]);
592
595
  var USERS_COLUMNS = [
593
596
  { name: "email", storage: "text", interpretation: "plain" },
@@ -641,12 +644,28 @@ function lintSchema(schema) {
641
644
  }
642
645
  return warnings;
643
646
  }
647
+ function lintSchemas(schemas) {
648
+ return [...schemas.flatMap((schema) => lintSchema(schema)), ...lintSchemaSet(schemas)];
649
+ }
650
+ function lintSchemaSet(schemas) {
651
+ const warnings = [];
652
+ if (!schemas.some((schema) => schema.name === "team_members")) {
653
+ for (const schema of schemas) {
654
+ const teamRoles = Object.entries(schema.permissions).filter(([, p]) => [p.read, p.update, p.delete].includes("team")).map(([role]) => role);
655
+ if (teamRoles.length > 0) {
656
+ warnings.push(
657
+ `[${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.`
658
+ );
659
+ }
660
+ }
661
+ }
662
+ return warnings;
663
+ }
644
664
  var SchemaRegistry = class {
645
665
  trusted = /* @__PURE__ */ new Map();
646
666
  constructor(schemas = []) {
647
- for (const schema of schemas) {
648
- this.registerTrusted(schema);
649
- }
667
+ for (const w of lintSchemaSet(schemas)) console.warn(`[schema-lint] ${w}`);
668
+ for (const schema of schemas) this.registerTrusted(schema);
650
669
  }
651
670
  registerTrusted(schema) {
652
671
  for (const w of lintSchema(schema)) {
@@ -1036,6 +1055,123 @@ function getMessageType(data) {
1036
1055
  return readVarUint(decoder);
1037
1056
  }
1038
1057
 
1058
+ // src/server/utils/tools.ts
1059
+ var DEFAULT_QUERY_LIMIT = 50;
1060
+ var DEFAULT_DELETE_WHERE_LIMIT = 100;
1061
+ var MAX_DELETE_WHERE_LIMIT = 500;
1062
+ function applyAiToolDefaults(toolName, params) {
1063
+ if (toolName === "records.query" && params.limit === void 0) {
1064
+ return { ...params, limit: DEFAULT_QUERY_LIMIT };
1065
+ }
1066
+ return params;
1067
+ }
1068
+ var BUILT_IN_TOOLS = [
1069
+ // Record tools (structured collections with RBAC)
1070
+ {
1071
+ name: "records.query",
1072
+ 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.`,
1073
+ params: {
1074
+ collection: { type: "string", description: "Collection name to query", required: true },
1075
+ where: { type: "object", description: "Filter object with field=value equality conditions", required: false },
1076
+ orderBy: { type: "string", description: 'Field to order by (or "createdAt"/"updatedAt")', required: false },
1077
+ orderDir: { type: "string", description: 'Order direction: "asc" or "desc" (default: "desc")', required: false },
1078
+ 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 }
1079
+ }
1080
+ },
1081
+ {
1082
+ name: "records.get",
1083
+ description: "Get a single record by ID from a collection",
1084
+ params: {
1085
+ collection: { type: "string", description: "Collection name", required: true },
1086
+ recordId: { type: "string", description: "Record ID to retrieve", required: true }
1087
+ }
1088
+ },
1089
+ {
1090
+ name: "records.create",
1091
+ description: "Create a new record in a collection",
1092
+ params: {
1093
+ collection: { type: "string", description: "Collection name", required: true },
1094
+ data: { type: "object", description: "Record data matching the collection schema", required: true },
1095
+ recordId: { type: "string", description: "Custom record ID (auto-generated if omitted)", required: false }
1096
+ }
1097
+ },
1098
+ {
1099
+ name: "records.update",
1100
+ description: "Update an existing record in a collection",
1101
+ params: {
1102
+ collection: { type: "string", description: "Collection name", required: true },
1103
+ recordId: { type: "string", description: "Record ID to update", required: true },
1104
+ data: { type: "object", description: "Fields to update (merged with existing data)", required: true }
1105
+ }
1106
+ },
1107
+ {
1108
+ name: "records.delete",
1109
+ description: "Delete a record from a collection",
1110
+ params: {
1111
+ collection: { type: "string", description: "Collection name", required: true },
1112
+ recordId: { type: "string", description: "Record ID to delete", required: true }
1113
+ }
1114
+ },
1115
+ {
1116
+ name: "records.deleteWhere",
1117
+ 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.`,
1118
+ params: {
1119
+ collection: { type: "string", description: "Collection name (must have a schema)", required: true },
1120
+ where: { type: "object", description: "Filter object with field=value equality conditions; every key must be recordId, createdBy, or a schema column", required: true },
1121
+ 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 }
1122
+ }
1123
+ },
1124
+ // Schema tools
1125
+ {
1126
+ name: "schema.list",
1127
+ description: "List all registered collection schemas with their fields and permissions",
1128
+ params: {}
1129
+ },
1130
+ {
1131
+ name: "schema.describe",
1132
+ description: "Get the full schema for a specific collection including field types and role permissions",
1133
+ params: {
1134
+ collection: { type: "string", description: "Collection name to describe", required: true }
1135
+ }
1136
+ },
1137
+ // User tools
1138
+ {
1139
+ name: "user.current",
1140
+ description: "Get the current user's profile (ID, name, email, role)",
1141
+ params: {}
1142
+ },
1143
+ {
1144
+ name: "user.list",
1145
+ description: "List all users in this room",
1146
+ params: {}
1147
+ },
1148
+ // Yjs tools (collaborative document access)
1149
+ {
1150
+ name: "yjs.list",
1151
+ description: "List all Yjs documents stored in this room, showing collection, recordId, fieldName, and last update time",
1152
+ params: {}
1153
+ },
1154
+ {
1155
+ name: "yjs.getText",
1156
+ description: "Read text content from a Yjs document (for fields using useYjsText)",
1157
+ params: {
1158
+ collection: { type: "string", description: "Collection name", required: true },
1159
+ recordId: { type: "string", description: "Record ID", required: true },
1160
+ fieldName: { type: "string", description: 'Field name (e.g., "content")', required: true }
1161
+ }
1162
+ },
1163
+ {
1164
+ name: "yjs.setText",
1165
+ description: "Write text content to a Yjs document (replaces existing text, broadcasts to connected clients)",
1166
+ params: {
1167
+ collection: { type: "string", description: "Collection name", required: true },
1168
+ recordId: { type: "string", description: "Record ID", required: true },
1169
+ fieldName: { type: "string", description: 'Field name (e.g., "content")', required: true },
1170
+ text: { type: "string", description: "The text content to set", required: true }
1171
+ }
1172
+ }
1173
+ ];
1174
+
1039
1175
  // src/shared/protocol/messages.ts
1040
1176
  var EMPTY = Object.freeze({});
1041
1177
  var clientBuild = {
@@ -1180,6 +1316,11 @@ function encode(message) {
1180
1316
  function getResolvedColumns(schema) {
1181
1317
  return (schema.columns ?? []).map(resolveColumn);
1182
1318
  }
1319
+ function systemManagedColumnError(column, isUpdate) {
1320
+ const roleHint = column === "role" ? ", or the admin-gated useUsers().setRole() to change a role" : "";
1321
+ 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.`;
1322
+ 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}`;
1323
+ }
1183
1324
  function notifyTeamMembershipChange(ctx, collection, record) {
1184
1325
  if (collection !== "team_members") return;
1185
1326
  const affectedUserId = record.data.UserId;
@@ -1250,11 +1391,9 @@ function handleDelete(ctx, ws, attachment, payload) {
1250
1391
  }
1251
1392
  }
1252
1393
  function putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRbac = false, systemUpdate = false) {
1253
- const isSystem = SYSTEM_COLLECTIONS.has(collection);
1254
- const schema = ctx.schemaRegistry.get(collection);
1255
- if (!schema && !isSystem) {
1256
- return { success: false, error: `Schema not registered for collection: ${collection}` };
1257
- }
1394
+ const resolved = resolveCollection(ctx, collection);
1395
+ if (!resolved.ok) return { success: false, error: resolved.error };
1396
+ const schema = resolved.schema;
1258
1397
  if (!schema) {
1259
1398
  return { success: false, error: `Schema not registered for collection: ${collection}` };
1260
1399
  }
@@ -1309,17 +1448,7 @@ function putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRb
1309
1448
  }
1310
1449
  }
1311
1450
  }
1312
- let finalData = mergedData;
1313
- if (!systemUpdate && schema.name === "users") {
1314
- finalData = { ...mergedData };
1315
- for (const key of SYSTEM_MANAGED_COLUMNS) {
1316
- if (existing?.data[key] !== void 0) {
1317
- finalData[key] = existing.data[key];
1318
- } else {
1319
- delete finalData[key];
1320
- }
1321
- }
1322
- }
1451
+ const finalData = { ...mergedData };
1323
1452
  if (!skipUserRbac) {
1324
1453
  const permCtx = ctx.getPermissionContext();
1325
1454
  if (isUpdate) {
@@ -1351,6 +1480,17 @@ function putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRb
1351
1480
  return { success: false, error: `FIELD ERROR: ${ownerError}` };
1352
1481
  }
1353
1482
  }
1483
+ if (!systemUpdate && schema.name === "users") {
1484
+ for (const col of columns) {
1485
+ if (!SYSTEM_MANAGED_COLUMNS.has(col.name)) continue;
1486
+ const current = existing?.data[col.name];
1487
+ 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)) {
1488
+ return { success: false, error: systemManagedColumnError(col.name, isUpdate) };
1489
+ }
1490
+ if (current !== void 0) finalData[col.name] = current;
1491
+ else delete finalData[col.name];
1492
+ }
1493
+ }
1354
1494
  const colValues = dataToColumnValues(finalData, columns);
1355
1495
  const now = (/* @__PURE__ */ new Date()).toISOString();
1356
1496
  const tbl = collectionTableName(collection);
@@ -1410,14 +1550,12 @@ function putRecord(ctx, collection, recordId, data, userId, userRole, skipUserRb
1410
1550
  return { success: true, data: { recordId, record } };
1411
1551
  }
1412
1552
  function deleteRecord(ctx, collection, recordId, userId, userRole, skipUserRbac = false) {
1413
- const isSystem = SYSTEM_COLLECTIONS.has(collection);
1414
- const schema = ctx.schemaRegistry.get(collection);
1415
- if (!schema && !isSystem) {
1416
- return { success: false, error: `Schema not registered for collection: ${collection}` };
1417
- }
1553
+ const resolved = resolveCollection(ctx, collection);
1554
+ if (!resolved.ok) return { success: false, error: resolved.error };
1555
+ const schema = resolved.schema;
1418
1556
  const existing = getRecord(ctx.sql, collection, recordId, schema);
1419
1557
  if (!existing) {
1420
- return { success: false, error: "Record not found" };
1558
+ return { success: false, error: RECORD_NOT_FOUND };
1421
1559
  }
1422
1560
  if (!skipUserRbac && schema && !canDelete(schema, userRole, { ...existing, recordId }, userId, ctx.getPermissionContext())) {
1423
1561
  return { success: false, error: `DELETE DENIED: role=${userRole}, collection=${collection}` };
@@ -1435,15 +1573,88 @@ function deleteRecord(ctx, collection, recordId, userId, userRole, skipUserRbac
1435
1573
  notifyTeamMembershipChange(ctx, collection, record);
1436
1574
  return { success: true, data: { deleted: true } };
1437
1575
  }
1438
- function readRecord(ctx, collection, recordId, userId, userRole, skipUserRbac = false) {
1439
- const isSystem = SYSTEM_COLLECTIONS.has(collection);
1440
- const schema = ctx.schemaRegistry.get(collection);
1441
- if (!schema && !isSystem) {
1442
- return { success: false, error: `Schema not registered for collection: ${collection}` };
1576
+ function deleteWhere(ctx, collection, where, limit, userId, userRole, skipUserRbac = false) {
1577
+ const resolved = resolveCollection(ctx, collection);
1578
+ if (!resolved.ok) return { success: false, error: resolved.error };
1579
+ const schema = resolved.schema;
1580
+ if (!schema) {
1581
+ return {
1582
+ success: false,
1583
+ error: `records.deleteWhere: collection "${collection}" has no schema, so it has no filterable fields \u2014 delete its rows by recordId with records.delete`
1584
+ };
1585
+ }
1586
+ if (!where || typeof where !== "object" || Array.isArray(where) || Object.keys(where).length === 0) {
1587
+ return { success: false, error: "Missing required param: where (must match at least one field)" };
1588
+ }
1589
+ const columns = (schema.columns ?? []).map(resolveColumn);
1590
+ const filterable = ["recordId", "createdBy", ...columns.map((c) => c.name)];
1591
+ const unknown = Object.keys(where).filter(
1592
+ (key) => key !== "recordId" && key !== "createdBy" && !columns.some((c) => c.id === columnId(key) || c.name === key)
1593
+ );
1594
+ if (unknown.length > 0) {
1595
+ return {
1596
+ success: false,
1597
+ error: `Unknown field(s) in where for "${collection}": ${unknown.join(", ")} \u2014 filterable fields: ${filterable.join(", ")}`
1598
+ };
1599
+ }
1600
+ const nonPrimitive = Object.entries(where).filter(([, v]) => v !== null && typeof v === "object");
1601
+ if (nonPrimitive.length > 0) {
1602
+ return {
1603
+ success: false,
1604
+ error: `where values must be primitives (equality only): ${nonPrimitive.map(([k]) => k).join(", ")}`
1605
+ };
1606
+ }
1607
+ if (limit !== void 0 && !(typeof limit === "number" && Number.isFinite(limit))) {
1608
+ return { success: false, error: "limit must be a finite number" };
1609
+ }
1610
+ const page = Math.min(
1611
+ Math.max(1, Math.floor(limit ?? DEFAULT_DELETE_WHERE_LIMIT)),
1612
+ MAX_DELETE_WHERE_LIMIT
1613
+ );
1614
+ const scan = { capped: false };
1615
+ const matched = executeQuery(
1616
+ ctx,
1617
+ { collection, where, limit: page },
1618
+ userId,
1619
+ userRole,
1620
+ skipUserRbac,
1621
+ scan
1622
+ );
1623
+ if (scan.capped && matched.length < page) {
1624
+ return {
1625
+ success: false,
1626
+ 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`
1627
+ };
1628
+ }
1629
+ if (!skipUserRbac && schema) {
1630
+ const permCtx = ctx.getPermissionContext();
1631
+ for (const record of matched) {
1632
+ if (!canDelete(schema, userRole, record, userId, permCtx)) {
1633
+ return { success: false, error: `DELETE DENIED: role=${userRole}, collection=${collection}` };
1634
+ }
1635
+ }
1636
+ }
1637
+ let deleted = 0;
1638
+ for (const record of matched) {
1639
+ if (deleteRecord(ctx, collection, record.recordId, userId, userRole, skipUserRbac).success) {
1640
+ deleted++;
1641
+ }
1642
+ }
1643
+ if (deleted < matched.length) {
1644
+ return {
1645
+ success: false,
1646
+ error: `Deleted ${deleted} of ${matched.length} matched record(s) in "${collection}"; the rest failed \u2014 retry`
1647
+ };
1443
1648
  }
1649
+ return { success: true, data: { deleted } };
1650
+ }
1651
+ function readRecord(ctx, collection, recordId, userId, userRole, skipUserRbac = false) {
1652
+ const resolved = resolveCollection(ctx, collection);
1653
+ if (!resolved.ok) return { success: false, error: resolved.error };
1654
+ const schema = resolved.schema;
1444
1655
  const existing = getRecord(ctx.sql, collection, recordId, schema);
1445
1656
  if (!existing) {
1446
- return { success: false, error: "Record not found" };
1657
+ return { success: false, error: RECORD_NOT_FOUND };
1447
1658
  }
1448
1659
  if (!skipUserRbac && schema && !canRead(schema, userRole, { ...existing, recordId }, userId, ctx.getPermissionContext())) {
1449
1660
  return { success: false, error: `READ DENIED: role=${userRole}, collection=${collection}` };
@@ -1474,9 +1685,7 @@ function hasYjsSubscription(attachment, target) {
1474
1685
  (subscription) => subscription.collection === target.collection && subscription.recordId === target.recordId && subscription.fieldName === target.fieldName
1475
1686
  );
1476
1687
  }
1477
- var SYSTEM_COLLECTIONS = /* @__PURE__ */ new Set([
1478
- "canvas-settings"
1479
- ]);
1688
+ var SYSTEM_COLLECTIONS = /* @__PURE__ */ new Set(["canvas-settings"]);
1480
1689
  var SYSTEM_COLLECTION_SCHEMAS = [
1481
1690
  {
1482
1691
  name: "canvas-settings",
@@ -1493,10 +1702,7 @@ function getOrCreateYjsDoc(ctx, docKey) {
1493
1702
  let doc = ctx.yjsDocs.get(docKey);
1494
1703
  if (doc) return doc;
1495
1704
  doc = new Y2.Doc();
1496
- const row = ctx.sql.exec(
1497
- `SELECT state FROM yjs_docs WHERE doc_key = ?`,
1498
- docKey
1499
- ).toArray()[0];
1705
+ const row = ctx.sql.exec(`SELECT state FROM yjs_docs WHERE doc_key = ?`, docKey).toArray()[0];
1500
1706
  if (row?.state) {
1501
1707
  try {
1502
1708
  Y2.applyUpdate(doc, new Uint8Array(row.state));
@@ -1561,11 +1767,17 @@ function handleYjsJoin(ctx, ws, attachment, payload) {
1561
1767
  }
1562
1768
  } else {
1563
1769
  if (!schema) {
1564
- ctx.send(ws, { type: MSG.ERROR, payload: { error: `Schema not registered for collection: ${collection}` } });
1770
+ const resolution = resolveCollection(ctx, collection);
1771
+ ctx.send(ws, {
1772
+ type: MSG.ERROR,
1773
+ payload: {
1774
+ error: resolution.ok ? `Schema not registered for collection: ${collection}` : resolution.error
1775
+ }
1776
+ });
1565
1777
  return;
1566
1778
  }
1567
1779
  if (!record) {
1568
- ctx.send(ws, { type: MSG.ERROR, payload: { error: "Record not found" } });
1780
+ ctx.send(ws, { type: MSG.ERROR, payload: { error: RECORD_NOT_FOUND } });
1569
1781
  return;
1570
1782
  }
1571
1783
  const permCtx = ctx.getPermissionContext();
@@ -1598,7 +1810,10 @@ function handleYjsJoin(ctx, ws, attachment, payload) {
1598
1810
  writeVarUint8Array(encoder2, fullState);
1599
1811
  ctx.sendBinary(ws, toUint8Array(encoder2));
1600
1812
  }
1601
- ctx.send(ws, { type: MSG.YJS_JOIN, payload: { collection, recordId, fieldName, canWrite: hasWriteAccess } });
1813
+ ctx.send(ws, {
1814
+ type: MSG.YJS_JOIN,
1815
+ payload: { collection, recordId, fieldName, canWrite: hasWriteAccess }
1816
+ });
1602
1817
  }
1603
1818
  function handleYjsLeave(ctx, ws, attachment, payload) {
1604
1819
  const { collection, recordId, fieldName } = payload;
@@ -1667,7 +1882,14 @@ function handleYjsBinaryMessage(ctx, ws, attachment, data) {
1667
1882
  if (!syncSchema) return;
1668
1883
  const record = getRecord(ctx.sql, collection, recordId, syncSchema);
1669
1884
  if (!record) return;
1670
- if (!canUpdate(syncSchema, attachment.role, { ...record, recordId }, attachment.userId, ctx.getPermissionContext())) return;
1885
+ if (!canUpdate(
1886
+ syncSchema,
1887
+ attachment.role,
1888
+ { ...record, recordId },
1889
+ attachment.userId,
1890
+ ctx.getPermissionContext()
1891
+ ))
1892
+ return;
1671
1893
  }
1672
1894
  const update = readVarUint8Array(decoder);
1673
1895
  Y2.applyUpdate(doc, update, "client");
@@ -1741,6 +1963,19 @@ function handleYjsDisconnect(ctx, ws, attachment) {
1741
1963
  }
1742
1964
 
1743
1965
  // src/server/handlers/subscriptions.ts
1966
+ function resolveCollection(ctx, collection) {
1967
+ const isSystem = SYSTEM_COLLECTIONS.has(collection);
1968
+ const schema = ctx.schemaRegistry.get(collection);
1969
+ if (!schema && !isSystem) {
1970
+ const known = ctx.schemaRegistry.names().sort().join(", ") || "(none)";
1971
+ const room = ctx.scopeId ? `Room '${ctx.scopeId}'` : "This room";
1972
+ return {
1973
+ ok: false,
1974
+ 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.`
1975
+ };
1976
+ }
1977
+ return { ok: true, schema, isSystem };
1978
+ }
1744
1979
  function handleSubscribe(ctx, ws, attachment, payload) {
1745
1980
  const { subscriptionId, query } = payload;
1746
1981
  const records = executeQuery(ctx, query, attachment.userId, attachment.role);
@@ -1748,16 +1983,23 @@ function handleSubscribe(ctx, ws, attachment, payload) {
1748
1983
  }
1749
1984
  function handleUnsubscribe(_ctx, _ws, _attachment, _payload) {
1750
1985
  }
1751
- function executeQuery(ctx, query, userId, userRole, skipUserRbac = false) {
1752
- const isSystem = SYSTEM_COLLECTIONS.has(query.collection);
1753
- const schema = ctx.schemaRegistry.get(query.collection);
1754
- if (!schema && !isSystem) {
1755
- return [];
1756
- }
1757
- if (!schema) {
1986
+ var MAX_FILTERED_SCAN_ROWS = 5e3;
1987
+ function executeQuery(ctx, query, userId, userRole, skipUserRbac = false, scanState) {
1988
+ const resolved = resolveCollection(ctx, query.collection);
1989
+ if (!resolved.ok) return [];
1990
+ if (!resolved.schema) {
1758
1991
  return executeSystemQuery(ctx, query);
1759
1992
  }
1760
- return executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipUserRbac);
1993
+ return executeTableQuery(
1994
+ ctx,
1995
+ query,
1996
+ resolved.schema,
1997
+ userId,
1998
+ userRole,
1999
+ resolved.isSystem,
2000
+ skipUserRbac,
2001
+ scanState
2002
+ );
1761
2003
  }
1762
2004
  function executeSystemQuery(ctx, query) {
1763
2005
  const tbl = collectionTableName(query.collection);
@@ -1793,7 +2035,7 @@ function preloadUserTeamIds(ctx, userId) {
1793
2035
  );
1794
2036
  return cursor.toArray().map((r) => r.col_teamid);
1795
2037
  }
1796
- function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipUserRbac = false) {
2038
+ function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipUserRbac = false, scanState) {
1797
2039
  const columns = (schema.columns ?? []).map(resolveColumn);
1798
2040
  const perms = getRolePermissions(schema, userRole);
1799
2041
  let sql = buildTableSelect(query.collection, columns);
@@ -1801,16 +2043,27 @@ function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipU
1801
2043
  const whereClauses = [];
1802
2044
  let skipPerRecordCheck = skipUserRbac;
1803
2045
  if (!skipUserRbac && schema.teamField && perms.read === "team") {
1804
- const filterCol = schema.teamField === "_rowId" ? "_row_id" : columns.find((c) => c.name === schema.teamField)?.id ?? columnId(schema.teamField);
1805
- const teamIds = preloadUserTeamIds(ctx, userId);
1806
- if (teamIds && teamIds.length > 0) {
1807
- const placeholders = teamIds.map(() => "?").join(", ");
1808
- whereClauses.push(`("${filterCol}" IN (${placeholders}) OR _created_by = ?)`);
1809
- params.push(...teamIds, userId);
1810
- } else {
1811
- whereClauses.push(`_created_by = ?`);
2046
+ const filterCol = schema.teamField === "_rowId" ? "_row_id" : columns.find((c) => c.name === schema.teamField)?.id;
2047
+ const teamIds = filterCol ? preloadUserTeamIds(ctx, userId) : null;
2048
+ if (filterCol) {
2049
+ if (teamIds && teamIds.length > 0) {
2050
+ const placeholders = teamIds.map(() => "?").join(", ");
2051
+ whereClauses.push(`("${filterCol}" IN (${placeholders}) OR _created_by = ?)`);
2052
+ params.push(...teamIds, userId);
2053
+ } else {
2054
+ whereClauses.push(`_created_by = ?`);
2055
+ params.push(userId);
2056
+ }
2057
+ skipPerRecordCheck = true;
2058
+ }
2059
+ } else if (!skipUserRbac && perms.read === "own") {
2060
+ const ownerCol = schema.ownerField ? columns.find((c) => c.name === schema.ownerField)?.id : "_created_by";
2061
+ if (ownerCol) {
2062
+ whereClauses.push(`"${ownerCol}" = ?`);
1812
2063
  params.push(userId);
2064
+ skipPerRecordCheck = true;
1813
2065
  }
2066
+ } else if (!skipUserRbac && perms.read === true) {
1814
2067
  skipPerRecordCheck = true;
1815
2068
  }
1816
2069
  if (query.where) {
@@ -1848,27 +2101,44 @@ function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipU
1848
2101
  } else {
1849
2102
  sql += ` ORDER BY _created_at DESC`;
1850
2103
  }
1851
- if (query.limit) {
1852
- sql += ` LIMIT ?`;
1853
- params.push(query.limit);
1854
- }
1855
- const cursor = ctx.sql.exec(sql, ...params);
2104
+ const readPage = (limitClause, limitParams) => {
2105
+ const cursor = ctx.sql.exec(sql + limitClause, ...params, ...limitParams);
2106
+ const rows = cursor.toArray();
2107
+ const records = [];
2108
+ for (const row of rows) {
2109
+ const r = row;
2110
+ const record = {
2111
+ recordId: r._row_id,
2112
+ data: rowToData(r, columns),
2113
+ createdBy: r._created_by,
2114
+ createdAt: r._created_at,
2115
+ updatedAt: r._updated_at
2116
+ };
2117
+ if (isSystem || skipPerRecordCheck || canRead(schema, userRole, { data: record.data, createdBy: record.createdBy, recordId: record.recordId }, userId, ctx.getPermissionContext())) {
2118
+ records.push(record);
2119
+ }
2120
+ }
2121
+ return { records, scanned: rows.length };
2122
+ };
2123
+ if (!query.limit) return readPage("", []).records;
2124
+ const rowFiltered = !isSystem && !skipPerRecordCheck;
2125
+ if (!rowFiltered) return readPage(" LIMIT ?", [query.limit]).records;
1856
2126
  const results = [];
1857
- for (const row of cursor.toArray()) {
1858
- const r = row;
1859
- const data = rowToData(r, columns);
1860
- const record = {
1861
- recordId: r._row_id,
1862
- data,
1863
- createdBy: r._created_by,
1864
- createdAt: r._created_at,
1865
- updatedAt: r._updated_at
1866
- };
1867
- if (isSystem || skipPerRecordCheck || canRead(schema, userRole, { data: record.data, createdBy: record.createdBy, recordId: record.recordId }, userId, ctx.getPermissionContext())) {
2127
+ const batch = Math.max(query.limit, 200);
2128
+ for (let offset = 0; ; offset += batch) {
2129
+ const { records, scanned } = readPage(" LIMIT ? OFFSET ?", [batch, offset]);
2130
+ for (const record of records) {
1868
2131
  results.push(record);
2132
+ if (results.length >= query.limit) return results;
2133
+ }
2134
+ if (scanned < batch) return results;
2135
+ if (offset + batch >= MAX_FILTERED_SCAN_ROWS) {
2136
+ if (scanState && readPage(" LIMIT ? OFFSET ?", [1, offset + batch]).scanned > 0) {
2137
+ scanState.capped = true;
2138
+ }
2139
+ return results;
1869
2140
  }
1870
2141
  }
1871
- return results;
1872
2142
  }
1873
2143
  function recordMatchesSubscription(record, collection, query, userId, userRole, schema, ctx) {
1874
2144
  if (query.collection !== collection) return false;
@@ -2065,30 +2335,56 @@ async function registerUser(sql, userId, name, email, imageUrl, isAdmin, default
2065
2335
  );
2066
2336
  return { id: userId, email, name, imageUrl, role, createdAt: now, lastSeenAt: now };
2067
2337
  }
2338
+ function rosterFor(ctx, schema, records, publicRoster, attachment) {
2339
+ if (isAnonymousUserId(attachment.userId)) return [];
2340
+ if (attachment.role === "admin") {
2341
+ const permissionContext = ctx.getPermissionContext();
2342
+ return records.filter((record) => canRead(schema, "admin", record, attachment.userId, permissionContext)).map((record) => ({ id: record.recordId, ...record.data }));
2343
+ }
2344
+ if (!getRolePermissions(schema, attachment.role).read) return [];
2345
+ if (schema.roster === "read-policy") {
2346
+ const permissionContext = ctx.getPermissionContext();
2347
+ const readable = new Set(
2348
+ records.filter((record) => canRead(schema, attachment.role, record, attachment.userId, permissionContext)).map((record) => record.recordId)
2349
+ );
2350
+ return publicRoster.filter((user) => readable.has(user.id));
2351
+ }
2352
+ return publicRoster;
2353
+ }
2354
+ function publicIdentities(records) {
2355
+ return records.map(({ recordId, data }) => ({
2356
+ id: recordId,
2357
+ name: data.name,
2358
+ ...data.imageUrl ? { imageUrl: data.imageUrl } : {},
2359
+ role: data.role
2360
+ }));
2361
+ }
2068
2362
  function handleUserList(ctx, ws, attachment) {
2069
2363
  const schema = ctx.schemaRegistry.get("users");
2070
- const permissionContext = ctx.getPermissionContext();
2071
- const visibleRecords = attachment.userId.startsWith("anon-") || !schema ? [] : getAllUserRecords(ctx.sql, ctx.schemaRegistry).filter(
2072
- (record) => canRead(
2073
- schema,
2074
- attachment.role,
2075
- record,
2076
- attachment.userId,
2077
- permissionContext
2078
- )
2079
- );
2080
- const users = visibleRecords.map((record) => {
2081
- if (attachment.role === "admin") return { id: record.recordId, ...record.data };
2082
- const { name, imageUrl, role } = record.data;
2083
- return {
2084
- id: record.recordId,
2085
- name,
2086
- ...imageUrl ? { imageUrl } : {},
2087
- role
2088
- };
2089
- });
2364
+ if (!schema) {
2365
+ ctx.send(ws, { type: MSG.USER_LIST, payload: { users: [] } });
2366
+ return;
2367
+ }
2368
+ const records = getAllUserRecords(ctx.sql, ctx.schemaRegistry);
2369
+ const users = rosterFor(ctx, schema, records, publicIdentities(records), attachment);
2090
2370
  ctx.send(ws, { type: MSG.USER_LIST, payload: { users } });
2091
2371
  }
2372
+ function broadcastUserList(ctx, except) {
2373
+ const schema = ctx.schemaRegistry.get("users");
2374
+ if (!schema) return;
2375
+ const skip = except instanceof Set ? except : new Set(except ? [except] : []);
2376
+ const records = getAllUserRecords(ctx.sql, ctx.schemaRegistry);
2377
+ const publicRoster = publicIdentities(records);
2378
+ for (const other of ctx.state.getWebSockets()) {
2379
+ if (skip.has(other)) continue;
2380
+ const attachment = other.deserializeAttachment();
2381
+ if (!attachment) continue;
2382
+ ctx.send(other, {
2383
+ type: MSG.USER_LIST,
2384
+ payload: { users: rosterFor(ctx, schema, records, publicRoster, attachment) }
2385
+ });
2386
+ }
2387
+ }
2092
2388
  function handleUserUpdate(ctx, _ws, attachment, payload) {
2093
2389
  const existing = getUserRecord(ctx.sql, attachment.userId, ctx.schemaRegistry);
2094
2390
  if (!existing) return;
@@ -2097,6 +2393,8 @@ function handleUserUpdate(ctx, _ws, attachment, payload) {
2097
2393
  if (payload.email) data.email = payload.email;
2098
2394
  if (payload.imageUrl !== void 0) data.imageUrl = payload.imageUrl || void 0;
2099
2395
  putRecord(ctx, "users", attachment.userId, data, attachment.userId, "admin", true, true);
2396
+ const rosterChanged = data.name !== void 0 && data.name !== existing.data.name || data.imageUrl !== void 0 && data.imageUrl !== existing.data.imageUrl;
2397
+ if (rosterChanged) broadcastUserList(ctx);
2100
2398
  }
2101
2399
  async function handleSetRole(ctx, ws, attachment, payload) {
2102
2400
  if (attachment.role !== "admin") {
@@ -2115,132 +2413,21 @@ async function handleSetRole(ctx, ws, attachment, payload) {
2115
2413
  now,
2116
2414
  payload.userId
2117
2415
  );
2416
+ const closing = /* @__PURE__ */ new Set();
2118
2417
  for (const otherWs of ctx.state.getWebSockets()) {
2119
2418
  const otherAttachment = otherWs.deserializeAttachment();
2120
- if (!otherAttachment) continue;
2121
- if (otherAttachment.userId === payload.userId) {
2122
- try {
2123
- otherWs.close(1008, "role-changed");
2124
- } catch {
2125
- }
2126
- continue;
2127
- }
2128
- if (otherAttachment.role === "admin") {
2129
- handleUserList(ctx, otherWs, otherAttachment);
2419
+ if (otherAttachment?.userId !== payload.userId) continue;
2420
+ closing.add(otherWs);
2421
+ try {
2422
+ otherWs.close(1008, "role-changed");
2423
+ } catch {
2130
2424
  }
2131
2425
  }
2426
+ broadcastUserList(ctx, closing);
2132
2427
  }
2133
2428
 
2134
2429
  // src/server/handlers/tools-api.ts
2135
2430
  import * as Y3 from "yjs";
2136
-
2137
- // src/server/utils/tools.ts
2138
- var DEFAULT_QUERY_LIMIT = 50;
2139
- function applyAiToolDefaults(toolName, params) {
2140
- if (toolName === "records.query" && params.limit === void 0) {
2141
- return { ...params, limit: DEFAULT_QUERY_LIMIT };
2142
- }
2143
- return params;
2144
- }
2145
- var BUILT_IN_TOOLS = [
2146
- // Record tools (structured collections with RBAC)
2147
- {
2148
- name: "records.query",
2149
- 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.`,
2150
- params: {
2151
- collection: { type: "string", description: "Collection name to query", required: true },
2152
- where: { type: "object", description: "Filter object with field=value equality conditions", required: false },
2153
- orderBy: { type: "string", description: 'Field to order by (or "createdAt"/"updatedAt")', required: false },
2154
- orderDir: { type: "string", description: 'Order direction: "asc" or "desc" (default: "desc")', required: false },
2155
- 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 }
2156
- }
2157
- },
2158
- {
2159
- name: "records.get",
2160
- description: "Get a single record by ID from a collection",
2161
- params: {
2162
- collection: { type: "string", description: "Collection name", required: true },
2163
- recordId: { type: "string", description: "Record ID to retrieve", required: true }
2164
- }
2165
- },
2166
- {
2167
- name: "records.create",
2168
- description: "Create a new record in a collection",
2169
- params: {
2170
- collection: { type: "string", description: "Collection name", required: true },
2171
- data: { type: "object", description: "Record data matching the collection schema", required: true },
2172
- recordId: { type: "string", description: "Custom record ID (auto-generated if omitted)", required: false }
2173
- }
2174
- },
2175
- {
2176
- name: "records.update",
2177
- description: "Update an existing record in a collection",
2178
- params: {
2179
- collection: { type: "string", description: "Collection name", required: true },
2180
- recordId: { type: "string", description: "Record ID to update", required: true },
2181
- data: { type: "object", description: "Fields to update (merged with existing data)", required: true }
2182
- }
2183
- },
2184
- {
2185
- name: "records.delete",
2186
- description: "Delete a record from a collection",
2187
- params: {
2188
- collection: { type: "string", description: "Collection name", required: true },
2189
- recordId: { type: "string", description: "Record ID to delete", required: true }
2190
- }
2191
- },
2192
- // Schema tools
2193
- {
2194
- name: "schema.list",
2195
- description: "List all registered collection schemas with their fields and permissions",
2196
- params: {}
2197
- },
2198
- {
2199
- name: "schema.describe",
2200
- description: "Get the full schema for a specific collection including field types and role permissions",
2201
- params: {
2202
- collection: { type: "string", description: "Collection name to describe", required: true }
2203
- }
2204
- },
2205
- // User tools
2206
- {
2207
- name: "user.current",
2208
- description: "Get the current user's profile (ID, name, email, role)",
2209
- params: {}
2210
- },
2211
- {
2212
- name: "user.list",
2213
- description: "List all users in this room",
2214
- params: {}
2215
- },
2216
- // Yjs tools (collaborative document access)
2217
- {
2218
- name: "yjs.list",
2219
- description: "List all Yjs documents stored in this room, showing collection, recordId, fieldName, and last update time",
2220
- params: {}
2221
- },
2222
- {
2223
- name: "yjs.getText",
2224
- description: "Read text content from a Yjs document (for fields using useYjsText)",
2225
- params: {
2226
- collection: { type: "string", description: "Collection name", required: true },
2227
- recordId: { type: "string", description: "Record ID", required: true },
2228
- fieldName: { type: "string", description: 'Field name (e.g., "content")', required: true }
2229
- }
2230
- },
2231
- {
2232
- name: "yjs.setText",
2233
- description: "Write text content to a Yjs document (replaces existing text, broadcasts to connected clients)",
2234
- params: {
2235
- collection: { type: "string", description: "Collection name", required: true },
2236
- recordId: { type: "string", description: "Record ID", required: true },
2237
- fieldName: { type: "string", description: 'Field name (e.g., "content")', required: true },
2238
- text: { type: "string", description: "The text content to set", required: true }
2239
- }
2240
- }
2241
- ];
2242
-
2243
- // src/server/handlers/tools-api.ts
2244
2431
  var CORS_HEADERS = {
2245
2432
  "Access-Control-Allow-Origin": "*",
2246
2433
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
@@ -2258,7 +2445,10 @@ async function handleToolsRequest(ctx, request, path) {
2258
2445
  const toolName = path.replace("tools/describe/", "");
2259
2446
  const tool3 = BUILT_IN_TOOLS.find((t) => t.name === toolName);
2260
2447
  if (!tool3) {
2261
- return Response.json({ error: `Tool not found: ${toolName}` }, { status: 404, headers: CORS_HEADERS });
2448
+ return Response.json(
2449
+ { error: `Tool not found: ${toolName}` },
2450
+ { status: 404, headers: CORS_HEADERS }
2451
+ );
2262
2452
  }
2263
2453
  return Response.json({ tool: tool3 }, { headers: CORS_HEADERS });
2264
2454
  }
@@ -2303,6 +2493,7 @@ async function handleToolExecute(ctx, request) {
2303
2493
  const recordCtx = {
2304
2494
  sql: ctx.sql,
2305
2495
  schemaRegistry: ctx.schemaRegistry,
2496
+ scopeId: ctx.scopeId,
2306
2497
  state: ctx.state,
2307
2498
  getPermissionContext: ctx.getPermissionContext,
2308
2499
  send: ctx.send
@@ -2317,6 +2508,8 @@ async function executeTool(ctx, toolName, params, userId, userRole, skipUserRbac
2317
2508
  case "records.query": {
2318
2509
  const collection = params.collection;
2319
2510
  if (!collection) return { success: false, error: "Missing required param: collection" };
2511
+ const resolved = resolveCollection(ctx, collection);
2512
+ if (!resolved.ok) return { success: false, error: resolved.error };
2320
2513
  const query = {
2321
2514
  collection,
2322
2515
  where: params.where,
@@ -2358,6 +2551,15 @@ async function executeTool(ctx, toolName, params, userId, userRole, skipUserRbac
2358
2551
  if (!recordId) return { success: false, error: "Missing required param: recordId" };
2359
2552
  return deleteRecord(ctx, collection, recordId, userId, userRole, skipUserRbac);
2360
2553
  }
2554
+ // Bounded batch delete — one subrequest per page instead of one per row.
2555
+ // Same authorization posture as `records.delete`; `deleteWhere` itself
2556
+ // validates `where` (every key must name a real field) and `limit`, so no
2557
+ // dispatch path can reach a collection-wide truncation.
2558
+ case "records.deleteWhere": {
2559
+ const collection = params.collection;
2560
+ if (!collection) return { success: false, error: "Missing required param: collection" };
2561
+ return deleteWhere(ctx, collection, params.where, params.limit, userId, userRole, skipUserRbac);
2562
+ }
2361
2563
  // ---- Users ----
2362
2564
  // Bootstrap or refresh the `users` row for a given userId. Same path
2363
2565
  // the SDK uses on WS connect via registerUser, exposed for server
@@ -2426,10 +2628,13 @@ async function executeTool(ctx, toolName, params, userId, userRole, skipUserRbac
2426
2628
  }
2427
2629
  function checkYjsPermission(ctx, collection, recordId, userId, userRole, action) {
2428
2630
  if (SYSTEM_COLLECTIONS.has(collection)) return null;
2429
- const schema = ctx.schemaRegistry.get(collection);
2430
- if (!schema) return { success: false, error: `Schema not registered for collection: ${collection}` };
2631
+ const resolution = resolveCollection(ctx, collection);
2632
+ if (!resolution.ok) return { success: false, error: resolution.error };
2633
+ const schema = resolution.schema;
2634
+ if (!schema)
2635
+ return { success: false, error: `Schema not registered for collection: ${collection}` };
2431
2636
  const record = getRecord(ctx.sql, collection, recordId, schema);
2432
- if (!record) return { success: false, error: `Record not found: ${collection}/${recordId}` };
2637
+ if (!record) return { success: false, error: `${RECORD_NOT_FOUND}: ${collection}/${recordId}` };
2433
2638
  const recordWithId = { ...record, recordId };
2434
2639
  const permCtx = ctx.getPermissionContext();
2435
2640
  if (action === "read" && !canRead(schema, userRole, recordWithId, userId, permCtx)) {
@@ -2462,9 +2667,7 @@ function withYjsDoc(ctx, docKey, operation) {
2462
2667
  function executeYjsTool(ctx, toolName, params, userId, userRole) {
2463
2668
  switch (toolName) {
2464
2669
  case "yjs.list": {
2465
- const rows = ctx.sql.exec(
2466
- `SELECT doc_key, updated_at FROM yjs_docs ORDER BY updated_at DESC`
2467
- ).toArray();
2670
+ const rows = ctx.sql.exec(`SELECT doc_key, updated_at FROM yjs_docs ORDER BY updated_at DESC`).toArray();
2468
2671
  const docs = rows.map((row) => {
2469
2672
  const firstColon = row.doc_key.indexOf(":");
2470
2673
  const lastColon = row.doc_key.lastIndexOf(":");
@@ -2904,12 +3107,13 @@ var RecordRoom = class extends BaseRoom {
2904
3107
  const userName = user.userName;
2905
3108
  const userEmail = user.userEmail;
2906
3109
  const userImageUrl = user.userImageUrl;
2907
- const isAuthenticated = !userId.startsWith("anon-");
3110
+ const isAuthenticated = !isAnonymousUserId(userId);
2908
3111
  let attachment;
2909
3112
  if (isAuthenticated) {
2910
3113
  const isOwner2 = this.ownerUserId != null && userId === this.ownerUserId;
2911
3114
  const regStart = Date.now();
2912
3115
  const defaultRole = this.schemaRegistry.get("users")?.defaultRole ?? "member";
3116
+ const before = getUser(this.sql, userId, this.schemaRegistry);
2913
3117
  const registeredUser = await registerUser(
2914
3118
  this.sql,
2915
3119
  userId,
@@ -2921,6 +3125,9 @@ var RecordRoom = class extends BaseRoom {
2921
3125
  this.schemaRegistry
2922
3126
  );
2923
3127
  const regMs = Date.now() - regStart;
3128
+ if (!before || before.name !== registeredUser.name || before.imageUrl !== registeredUser.imageUrl || before.role !== registeredUser.role) {
3129
+ broadcastUserList(this.createUserContext(), ws);
3130
+ }
2924
3131
  attachment = {
2925
3132
  userId: registeredUser.id,
2926
3133
  userName,
@@ -3087,6 +3294,10 @@ var RecordRoom = class extends BaseRoom {
3087
3294
  sql: this.sql,
3088
3295
  state: this.state,
3089
3296
  schemaRegistry: this.schemaRegistry,
3297
+ // Every record room stub is addressed with `idFromName(scopeId)`, so the
3298
+ // DO's own name is the scope. Handlers use it to name the room when a
3299
+ // caller asks it for a collection that lives in a different scope.
3300
+ scopeId: this.state.id.name,
3090
3301
  getPermissionContext: () => this.getPermissionContext(),
3091
3302
  send: (ws, msg) => this.send(ws, msg),
3092
3303
  sendBinary: (ws, data) => this.sendBinaryHelper(ws, data),
@@ -7542,7 +7753,7 @@ function authWorkerFetch(env, path, init) {
7542
7753
  function buildCronContext(env, ownerUserId, roomId = "default") {
7543
7754
  const roomIdObj = env.RECORD_ROOMS.idFromName(roomId);
7544
7755
  const room = env.RECORD_ROOMS.get(roomIdObj);
7545
- async function executeTool3(tool3, params) {
7756
+ async function executeTool2(tool3, params) {
7546
7757
  const response = await room.fetch(
7547
7758
  new Request("https://internal/api/tools/execute", {
7548
7759
  method: "POST",
@@ -7565,18 +7776,18 @@ function buildCronContext(env, ownerUserId, roomId = "default") {
7565
7776
  const params = { collection };
7566
7777
  if (opts?.where) params.where = opts.where;
7567
7778
  if (opts?.limit) params.limit = opts.limit;
7568
- const data = await executeTool3("records.query", params);
7779
+ const data = await executeTool2("records.query", params);
7569
7780
  const records2 = data?.records;
7570
7781
  return records2 ?? [];
7571
7782
  },
7572
7783
  async create(collection, data) {
7573
- return executeTool3("records.create", { collection, data });
7784
+ return executeTool2("records.create", { collection, data });
7574
7785
  },
7575
7786
  async update(collection, recordId, data) {
7576
- return executeTool3("records.update", { collection, recordId, data });
7787
+ return executeTool2("records.update", { collection, recordId, data });
7577
7788
  },
7578
7789
  async delete(collection, recordId) {
7579
- return executeTool3("records.delete", { collection, recordId });
7790
+ return executeTool2("records.delete", { collection, recordId });
7580
7791
  }
7581
7792
  };
7582
7793
  const integrations = {
@@ -8312,7 +8523,7 @@ function makeDefaultSummarizer(env, options = {}) {
8312
8523
  }
8313
8524
 
8314
8525
  // src/server/utils/chat-history.ts
8315
- async function executeTool2(stub, userId, tool3, params) {
8526
+ async function executeToolAsApp(stub, userId, tool3, params) {
8316
8527
  const res = await stub.fetch(new Request("https://internal/api/tools/execute", {
8317
8528
  method: "POST",
8318
8529
  headers: {
@@ -8353,13 +8564,15 @@ function toMessageRow(env) {
8353
8564
  }
8354
8565
  async function getChat(stub, chatId, userId) {
8355
8566
  try {
8356
- const result = await executeTool2(stub, userId, "records.get", {
8567
+ const result = await executeToolAsApp(stub, userId, "records.get", {
8357
8568
  collection: "ai-chats",
8358
8569
  recordId: chatId
8359
8570
  });
8360
- return result.record ? toChatRow(result.record) : null;
8571
+ if (!result.record) return null;
8572
+ const chat = toChatRow(result.record);
8573
+ return chat.userId === userId ? chat : null;
8361
8574
  } catch (err) {
8362
- if (err instanceof Error && err.message.includes("Record not found")) return null;
8575
+ if (err instanceof Error && err.message.includes(RECORD_NOT_FOUND)) return null;
8363
8576
  throw err;
8364
8577
  }
8365
8578
  }
@@ -8367,53 +8580,45 @@ async function createChat(stub, userId, opts = {}) {
8367
8580
  const data = { userId };
8368
8581
  if (opts.title !== void 0) data.title = opts.title;
8369
8582
  if (opts.model !== void 0) data.model = opts.model;
8370
- const result = await executeTool2(stub, userId, "records.create", {
8583
+ const result = await executeToolAsApp(stub, userId, "records.create", {
8371
8584
  collection: "ai-chats",
8372
8585
  data
8373
8586
  });
8374
8587
  return toChatRow(result.record);
8375
8588
  }
8376
8589
  async function updateChat(stub, chatId, userId, patch) {
8377
- await executeTool2(stub, userId, "records.update", {
8590
+ if (!await getChat(stub, chatId, userId)) return false;
8591
+ await executeToolAsApp(stub, userId, "records.update", {
8378
8592
  collection: "ai-chats",
8379
8593
  recordId: chatId,
8380
8594
  data: patch
8381
8595
  });
8596
+ return true;
8382
8597
  }
8598
+ var MESSAGE_DELETE_PAGE = 200;
8383
8599
  async function deleteChatCascade(stub, chatId, userId) {
8384
- const list = await executeTool2(stub, userId, "records.query", {
8385
- collection: "ai-messages",
8386
- where: { chatId }
8387
- });
8388
- const errors = [];
8389
- for (const env of list.records) {
8390
- try {
8391
- await executeTool2(stub, userId, "records.delete", {
8600
+ let messagesError = null;
8601
+ try {
8602
+ let deleted = MESSAGE_DELETE_PAGE;
8603
+ while (deleted === MESSAGE_DELETE_PAGE) {
8604
+ const page = await executeToolAsApp(stub, userId, "records.deleteWhere", {
8392
8605
  collection: "ai-messages",
8393
- recordId: env.recordId
8606
+ where: { chatId },
8607
+ limit: MESSAGE_DELETE_PAGE
8394
8608
  });
8395
- } catch (err) {
8396
- errors.push(err);
8609
+ deleted = page.deleted;
8397
8610
  }
8398
- }
8399
- try {
8400
- await executeTool2(stub, userId, "records.delete", {
8401
- collection: "ai-chats",
8402
- recordId: chatId
8403
- });
8404
8611
  } catch (err) {
8405
- errors.push(err);
8406
- }
8407
- if (errors.length > 0) {
8408
- const first = errors[0];
8409
- throw new Error(
8410
- `deleteChatCascade: ${errors.length} delete(s) failed; first: ${first instanceof Error ? first.message : String(first)}`,
8411
- { cause: errors }
8412
- );
8612
+ messagesError = err;
8413
8613
  }
8614
+ await executeToolAsApp(stub, userId, "records.delete", {
8615
+ collection: "ai-chats",
8616
+ recordId: chatId
8617
+ });
8618
+ if (messagesError) throw messagesError;
8414
8619
  }
8415
8620
  async function loadMessages(stub, chatId, userId) {
8416
- const result = await executeTool2(stub, userId, "records.query", {
8621
+ const result = await executeToolAsApp(stub, userId, "records.query", {
8417
8622
  collection: "ai-messages",
8418
8623
  where: { chatId, userId },
8419
8624
  orderBy: "createdAt",
@@ -8422,6 +8627,7 @@ async function loadMessages(stub, chatId, userId) {
8422
8627
  return result.records.map(toMessageRow);
8423
8628
  }
8424
8629
  async function appendMessage(stub, msg) {
8630
+ if (!await getChat(stub, msg.chatId, msg.userId)) return false;
8425
8631
  const data = {
8426
8632
  chatId: msg.chatId,
8427
8633
  userId: msg.userId,
@@ -8429,11 +8635,12 @@ async function appendMessage(stub, msg) {
8429
8635
  content: msg.content
8430
8636
  };
8431
8637
  if (msg.parts !== void 0) data.parts = msg.parts;
8432
- await executeTool2(stub, msg.userId, "records.create", {
8638
+ await executeToolAsApp(stub, msg.userId, "records.create", {
8433
8639
  collection: "ai-messages",
8434
8640
  recordId: msg.id,
8435
8641
  data
8436
8642
  });
8643
+ return true;
8437
8644
  }
8438
8645
 
8439
8646
  // src/server/utils/metering.ts
@@ -10135,6 +10342,7 @@ export {
10135
10342
  AI_CHATS_SCHEMA,
10136
10343
  AI_MESSAGES_SCHEMA,
10137
10344
  ALLOWED_BINDING_TYPES,
10345
+ ANONYMOUS_USER_ID_PREFIX,
10138
10346
  APP_ID_RE,
10139
10347
  APP_LOG_EVENT_KEYS,
10140
10348
  APP_NAME_RULES,
@@ -10159,6 +10367,7 @@ export {
10159
10367
  DEEPSPACE_AI_MODELS,
10160
10368
  DEEPSPACE_MODEL_CATALOG_PROVENANCE,
10161
10369
  DEFAULT_CONTEXT_CONFIG,
10370
+ DEFAULT_DELETE_WHERE_LIMIT,
10162
10371
  DEFAULT_DO_MANIFEST,
10163
10372
  DEFAULT_QUERY_LIMIT,
10164
10373
  DEPLOYED_APPS_DEFAULTS,
@@ -10170,6 +10379,7 @@ export {
10170
10379
  JobRoom,
10171
10380
  LOG_LEVELS,
10172
10381
  MAX_APP_FILE_BYTES,
10382
+ MAX_DELETE_WHERE_LIMIT,
10173
10383
  MAX_DEPLOY_ASSET_FILE_BYTES,
10174
10384
  MAX_UPLOAD_PARTS,
10175
10385
  MAX_UPLOAD_REQUEST_BYTES,
@@ -10185,6 +10395,7 @@ export {
10185
10395
  PresenceRoom,
10186
10396
  REACTIONS_SCHEMA,
10187
10397
  READ_RECEIPTS_SCHEMA,
10398
+ RECORD_NOT_FOUND,
10188
10399
  REGISTRY_INSTANCE,
10189
10400
  RELEASE_STAMP_PATH,
10190
10401
  REPO_STORE_LIMIT_BYTES,
@@ -10201,7 +10412,9 @@ export {
10201
10412
  SDK_RUN_WORKER_FIRST,
10202
10413
  SECURE_ROOM_BOUNDARIES_MIGRATION_ID,
10203
10414
  STRICT_APP_ID_RE,
10415
+ SYSTEM_ASSIGNED_COLUMNS,
10204
10416
  SYSTEM_COLLECTIONS,
10417
+ SYSTEM_MAINTAINED_COLUMNS,
10205
10418
  SYSTEM_MANAGED_COLUMNS,
10206
10419
  SchemaRegistry,
10207
10420
  TEST_ACCOUNT_EMAIL_SUFFIX,
@@ -10267,6 +10480,7 @@ export {
10267
10480
  handleClientErrorReport,
10268
10481
  handleSyncMessage,
10269
10482
  hasContent,
10483
+ isAnonymousUserId,
10270
10484
  isAssetNotFoundHandling,
10271
10485
  isAutoProvision,
10272
10486
  isFilesVerbPath,
@@ -10276,6 +10490,8 @@ export {
10276
10490
  isTestAccountEmail,
10277
10491
  isTestAccountTier,
10278
10492
  lintSchema,
10493
+ lintSchemaSet,
10494
+ lintSchemas,
10279
10495
  listDeepSpaceAgentModels,
10280
10496
  loadMessages,
10281
10497
  makeDefaultSummarizer,