deepspace 0.23.0 → 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,82 +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
- var RECORD_NOT_FOUND = "Record not found";
383
-
384
388
  // src/server/schemas/registry.ts
385
389
  function collectionTableName(name) {
386
390
  return `c_${name.replace(/[^a-zA-Z0-9_]/g, "_")}`;
@@ -640,12 +644,28 @@ function lintSchema(schema) {
640
644
  }
641
645
  return warnings;
642
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
+ }
643
664
  var SchemaRegistry = class {
644
665
  trusted = /* @__PURE__ */ new Map();
645
666
  constructor(schemas = []) {
646
- for (const schema of schemas) {
647
- this.registerTrusted(schema);
648
- }
667
+ for (const w of lintSchemaSet(schemas)) console.warn(`[schema-lint] ${w}`);
668
+ for (const schema of schemas) this.registerTrusted(schema);
649
669
  }
650
670
  registerTrusted(schema) {
651
671
  for (const w of lintSchema(schema)) {
@@ -1035,6 +1055,123 @@ function getMessageType(data) {
1035
1055
  return readVarUint(decoder);
1036
1056
  }
1037
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
+
1038
1175
  // src/shared/protocol/messages.ts
1039
1176
  var EMPTY = Object.freeze({});
1040
1177
  var clientBuild = {
@@ -1436,6 +1573,81 @@ function deleteRecord(ctx, collection, recordId, userId, userRole, skipUserRbac
1436
1573
  notifyTeamMembershipChange(ctx, collection, record);
1437
1574
  return { success: true, data: { deleted: true } };
1438
1575
  }
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
+ };
1648
+ }
1649
+ return { success: true, data: { deleted } };
1650
+ }
1439
1651
  function readRecord(ctx, collection, recordId, userId, userRole, skipUserRbac = false) {
1440
1652
  const resolved = resolveCollection(ctx, collection);
1441
1653
  if (!resolved.ok) return { success: false, error: resolved.error };
@@ -1771,7 +1983,8 @@ function handleSubscribe(ctx, ws, attachment, payload) {
1771
1983
  }
1772
1984
  function handleUnsubscribe(_ctx, _ws, _attachment, _payload) {
1773
1985
  }
1774
- function executeQuery(ctx, query, userId, userRole, skipUserRbac = false) {
1986
+ var MAX_FILTERED_SCAN_ROWS = 5e3;
1987
+ function executeQuery(ctx, query, userId, userRole, skipUserRbac = false, scanState) {
1775
1988
  const resolved = resolveCollection(ctx, query.collection);
1776
1989
  if (!resolved.ok) return [];
1777
1990
  if (!resolved.schema) {
@@ -1784,7 +1997,8 @@ function executeQuery(ctx, query, userId, userRole, skipUserRbac = false) {
1784
1997
  userId,
1785
1998
  userRole,
1786
1999
  resolved.isSystem,
1787
- skipUserRbac
2000
+ skipUserRbac,
2001
+ scanState
1788
2002
  );
1789
2003
  }
1790
2004
  function executeSystemQuery(ctx, query) {
@@ -1821,7 +2035,7 @@ function preloadUserTeamIds(ctx, userId) {
1821
2035
  );
1822
2036
  return cursor.toArray().map((r) => r.col_teamid);
1823
2037
  }
1824
- function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipUserRbac = false) {
2038
+ function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipUserRbac = false, scanState) {
1825
2039
  const columns = (schema.columns ?? []).map(resolveColumn);
1826
2040
  const perms = getRolePermissions(schema, userRole);
1827
2041
  let sql = buildTableSelect(query.collection, columns);
@@ -1829,16 +2043,27 @@ function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipU
1829
2043
  const whereClauses = [];
1830
2044
  let skipPerRecordCheck = skipUserRbac;
1831
2045
  if (!skipUserRbac && schema.teamField && perms.read === "team") {
1832
- const filterCol = schema.teamField === "_rowId" ? "_row_id" : columns.find((c) => c.name === schema.teamField)?.id ?? columnId(schema.teamField);
1833
- const teamIds = preloadUserTeamIds(ctx, userId);
1834
- if (teamIds && teamIds.length > 0) {
1835
- const placeholders = teamIds.map(() => "?").join(", ");
1836
- whereClauses.push(`("${filterCol}" IN (${placeholders}) OR _created_by = ?)`);
1837
- params.push(...teamIds, userId);
1838
- } else {
1839
- 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}" = ?`);
1840
2063
  params.push(userId);
2064
+ skipPerRecordCheck = true;
1841
2065
  }
2066
+ } else if (!skipUserRbac && perms.read === true) {
1842
2067
  skipPerRecordCheck = true;
1843
2068
  }
1844
2069
  if (query.where) {
@@ -1876,27 +2101,44 @@ function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipU
1876
2101
  } else {
1877
2102
  sql += ` ORDER BY _created_at DESC`;
1878
2103
  }
1879
- if (query.limit) {
1880
- sql += ` LIMIT ?`;
1881
- params.push(query.limit);
1882
- }
1883
- 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;
1884
2126
  const results = [];
1885
- for (const row of cursor.toArray()) {
1886
- const r = row;
1887
- const data = rowToData(r, columns);
1888
- const record = {
1889
- recordId: r._row_id,
1890
- data,
1891
- createdBy: r._created_by,
1892
- createdAt: r._created_at,
1893
- updatedAt: r._updated_at
1894
- };
1895
- 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) {
1896
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;
1897
2140
  }
1898
2141
  }
1899
- return results;
1900
2142
  }
1901
2143
  function recordMatchesSubscription(record, collection, query, userId, userRole, schema, ctx) {
1902
2144
  if (query.collection !== collection) return false;
@@ -2093,30 +2335,56 @@ async function registerUser(sql, userId, name, email, imageUrl, isAdmin, default
2093
2335
  );
2094
2336
  return { id: userId, email, name, imageUrl, role, createdAt: now, lastSeenAt: now };
2095
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
+ }
2096
2362
  function handleUserList(ctx, ws, attachment) {
2097
2363
  const schema = ctx.schemaRegistry.get("users");
2098
- const permissionContext = ctx.getPermissionContext();
2099
- const visibleRecords = attachment.userId.startsWith("anon-") || !schema ? [] : getAllUserRecords(ctx.sql, ctx.schemaRegistry).filter(
2100
- (record) => canRead(
2101
- schema,
2102
- attachment.role,
2103
- record,
2104
- attachment.userId,
2105
- permissionContext
2106
- )
2107
- );
2108
- const users = visibleRecords.map((record) => {
2109
- if (attachment.role === "admin") return { id: record.recordId, ...record.data };
2110
- const { name, imageUrl, role } = record.data;
2111
- return {
2112
- id: record.recordId,
2113
- name,
2114
- ...imageUrl ? { imageUrl } : {},
2115
- role
2116
- };
2117
- });
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);
2118
2370
  ctx.send(ws, { type: MSG.USER_LIST, payload: { users } });
2119
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
+ }
2120
2388
  function handleUserUpdate(ctx, _ws, attachment, payload) {
2121
2389
  const existing = getUserRecord(ctx.sql, attachment.userId, ctx.schemaRegistry);
2122
2390
  if (!existing) return;
@@ -2125,6 +2393,8 @@ function handleUserUpdate(ctx, _ws, attachment, payload) {
2125
2393
  if (payload.email) data.email = payload.email;
2126
2394
  if (payload.imageUrl !== void 0) data.imageUrl = payload.imageUrl || void 0;
2127
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);
2128
2398
  }
2129
2399
  async function handleSetRole(ctx, ws, attachment, payload) {
2130
2400
  if (attachment.role !== "admin") {
@@ -2143,132 +2413,21 @@ async function handleSetRole(ctx, ws, attachment, payload) {
2143
2413
  now,
2144
2414
  payload.userId
2145
2415
  );
2416
+ const closing = /* @__PURE__ */ new Set();
2146
2417
  for (const otherWs of ctx.state.getWebSockets()) {
2147
2418
  const otherAttachment = otherWs.deserializeAttachment();
2148
- if (!otherAttachment) continue;
2149
- if (otherAttachment.userId === payload.userId) {
2150
- try {
2151
- otherWs.close(1008, "role-changed");
2152
- } catch {
2153
- }
2154
- continue;
2155
- }
2156
- if (otherAttachment.role === "admin") {
2157
- 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 {
2158
2424
  }
2159
2425
  }
2426
+ broadcastUserList(ctx, closing);
2160
2427
  }
2161
2428
 
2162
2429
  // src/server/handlers/tools-api.ts
2163
2430
  import * as Y3 from "yjs";
2164
-
2165
- // src/server/utils/tools.ts
2166
- var DEFAULT_QUERY_LIMIT = 50;
2167
- function applyAiToolDefaults(toolName, params) {
2168
- if (toolName === "records.query" && params.limit === void 0) {
2169
- return { ...params, limit: DEFAULT_QUERY_LIMIT };
2170
- }
2171
- return params;
2172
- }
2173
- var BUILT_IN_TOOLS = [
2174
- // Record tools (structured collections with RBAC)
2175
- {
2176
- name: "records.query",
2177
- 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.`,
2178
- params: {
2179
- collection: { type: "string", description: "Collection name to query", required: true },
2180
- where: { type: "object", description: "Filter object with field=value equality conditions", required: false },
2181
- orderBy: { type: "string", description: 'Field to order by (or "createdAt"/"updatedAt")', required: false },
2182
- orderDir: { type: "string", description: 'Order direction: "asc" or "desc" (default: "desc")', required: false },
2183
- 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 }
2184
- }
2185
- },
2186
- {
2187
- name: "records.get",
2188
- description: "Get a single record by ID from a collection",
2189
- params: {
2190
- collection: { type: "string", description: "Collection name", required: true },
2191
- recordId: { type: "string", description: "Record ID to retrieve", required: true }
2192
- }
2193
- },
2194
- {
2195
- name: "records.create",
2196
- description: "Create a new record in a collection",
2197
- params: {
2198
- collection: { type: "string", description: "Collection name", required: true },
2199
- data: { type: "object", description: "Record data matching the collection schema", required: true },
2200
- recordId: { type: "string", description: "Custom record ID (auto-generated if omitted)", required: false }
2201
- }
2202
- },
2203
- {
2204
- name: "records.update",
2205
- description: "Update an existing record in a collection",
2206
- params: {
2207
- collection: { type: "string", description: "Collection name", required: true },
2208
- recordId: { type: "string", description: "Record ID to update", required: true },
2209
- data: { type: "object", description: "Fields to update (merged with existing data)", required: true }
2210
- }
2211
- },
2212
- {
2213
- name: "records.delete",
2214
- description: "Delete a record from a collection",
2215
- params: {
2216
- collection: { type: "string", description: "Collection name", required: true },
2217
- recordId: { type: "string", description: "Record ID to delete", required: true }
2218
- }
2219
- },
2220
- // Schema tools
2221
- {
2222
- name: "schema.list",
2223
- description: "List all registered collection schemas with their fields and permissions",
2224
- params: {}
2225
- },
2226
- {
2227
- name: "schema.describe",
2228
- description: "Get the full schema for a specific collection including field types and role permissions",
2229
- params: {
2230
- collection: { type: "string", description: "Collection name to describe", required: true }
2231
- }
2232
- },
2233
- // User tools
2234
- {
2235
- name: "user.current",
2236
- description: "Get the current user's profile (ID, name, email, role)",
2237
- params: {}
2238
- },
2239
- {
2240
- name: "user.list",
2241
- description: "List all users in this room",
2242
- params: {}
2243
- },
2244
- // Yjs tools (collaborative document access)
2245
- {
2246
- name: "yjs.list",
2247
- description: "List all Yjs documents stored in this room, showing collection, recordId, fieldName, and last update time",
2248
- params: {}
2249
- },
2250
- {
2251
- name: "yjs.getText",
2252
- description: "Read text content from a Yjs document (for fields using useYjsText)",
2253
- params: {
2254
- collection: { type: "string", description: "Collection name", required: true },
2255
- recordId: { type: "string", description: "Record ID", required: true },
2256
- fieldName: { type: "string", description: 'Field name (e.g., "content")', required: true }
2257
- }
2258
- },
2259
- {
2260
- name: "yjs.setText",
2261
- description: "Write text content to a Yjs document (replaces existing text, broadcasts to connected clients)",
2262
- params: {
2263
- collection: { type: "string", description: "Collection name", required: true },
2264
- recordId: { type: "string", description: "Record ID", required: true },
2265
- fieldName: { type: "string", description: 'Field name (e.g., "content")', required: true },
2266
- text: { type: "string", description: "The text content to set", required: true }
2267
- }
2268
- }
2269
- ];
2270
-
2271
- // src/server/handlers/tools-api.ts
2272
2431
  var CORS_HEADERS = {
2273
2432
  "Access-Control-Allow-Origin": "*",
2274
2433
  "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
@@ -2392,6 +2551,15 @@ async function executeTool(ctx, toolName, params, userId, userRole, skipUserRbac
2392
2551
  if (!recordId) return { success: false, error: "Missing required param: recordId" };
2393
2552
  return deleteRecord(ctx, collection, recordId, userId, userRole, skipUserRbac);
2394
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
+ }
2395
2563
  // ---- Users ----
2396
2564
  // Bootstrap or refresh the `users` row for a given userId. Same path
2397
2565
  // the SDK uses on WS connect via registerUser, exposed for server
@@ -2939,12 +3107,13 @@ var RecordRoom = class extends BaseRoom {
2939
3107
  const userName = user.userName;
2940
3108
  const userEmail = user.userEmail;
2941
3109
  const userImageUrl = user.userImageUrl;
2942
- const isAuthenticated = !userId.startsWith("anon-");
3110
+ const isAuthenticated = !isAnonymousUserId(userId);
2943
3111
  let attachment;
2944
3112
  if (isAuthenticated) {
2945
3113
  const isOwner2 = this.ownerUserId != null && userId === this.ownerUserId;
2946
3114
  const regStart = Date.now();
2947
3115
  const defaultRole = this.schemaRegistry.get("users")?.defaultRole ?? "member";
3116
+ const before = getUser(this.sql, userId, this.schemaRegistry);
2948
3117
  const registeredUser = await registerUser(
2949
3118
  this.sql,
2950
3119
  userId,
@@ -2956,6 +3125,9 @@ var RecordRoom = class extends BaseRoom {
2956
3125
  this.schemaRegistry
2957
3126
  );
2958
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
+ }
2959
3131
  attachment = {
2960
3132
  userId: registeredUser.id,
2961
3133
  userName,
@@ -8415,43 +8587,35 @@ async function createChat(stub, userId, opts = {}) {
8415
8587
  return toChatRow(result.record);
8416
8588
  }
8417
8589
  async function updateChat(stub, chatId, userId, patch) {
8590
+ if (!await getChat(stub, chatId, userId)) return false;
8418
8591
  await executeToolAsApp(stub, userId, "records.update", {
8419
8592
  collection: "ai-chats",
8420
8593
  recordId: chatId,
8421
8594
  data: patch
8422
8595
  });
8596
+ return true;
8423
8597
  }
8598
+ var MESSAGE_DELETE_PAGE = 200;
8424
8599
  async function deleteChatCascade(stub, chatId, userId) {
8425
- const list = await executeToolAsApp(stub, userId, "records.query", {
8426
- collection: "ai-messages",
8427
- where: { chatId }
8428
- });
8429
- const errors = [];
8430
- for (const env of list.records) {
8431
- try {
8432
- await executeToolAsApp(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", {
8433
8605
  collection: "ai-messages",
8434
- recordId: env.recordId
8606
+ where: { chatId },
8607
+ limit: MESSAGE_DELETE_PAGE
8435
8608
  });
8436
- } catch (err) {
8437
- errors.push(err);
8609
+ deleted = page.deleted;
8438
8610
  }
8439
- }
8440
- try {
8441
- await executeToolAsApp(stub, userId, "records.delete", {
8442
- collection: "ai-chats",
8443
- recordId: chatId
8444
- });
8445
8611
  } catch (err) {
8446
- errors.push(err);
8447
- }
8448
- if (errors.length > 0) {
8449
- const first = errors[0];
8450
- throw new Error(
8451
- `deleteChatCascade: ${errors.length} delete(s) failed; first: ${first instanceof Error ? first.message : String(first)}`,
8452
- { cause: errors }
8453
- );
8612
+ messagesError = err;
8454
8613
  }
8614
+ await executeToolAsApp(stub, userId, "records.delete", {
8615
+ collection: "ai-chats",
8616
+ recordId: chatId
8617
+ });
8618
+ if (messagesError) throw messagesError;
8455
8619
  }
8456
8620
  async function loadMessages(stub, chatId, userId) {
8457
8621
  const result = await executeToolAsApp(stub, userId, "records.query", {
@@ -8463,6 +8627,7 @@ async function loadMessages(stub, chatId, userId) {
8463
8627
  return result.records.map(toMessageRow);
8464
8628
  }
8465
8629
  async function appendMessage(stub, msg) {
8630
+ if (!await getChat(stub, msg.chatId, msg.userId)) return false;
8466
8631
  const data = {
8467
8632
  chatId: msg.chatId,
8468
8633
  userId: msg.userId,
@@ -8475,6 +8640,7 @@ async function appendMessage(stub, msg) {
8475
8640
  recordId: msg.id,
8476
8641
  data
8477
8642
  });
8643
+ return true;
8478
8644
  }
8479
8645
 
8480
8646
  // src/server/utils/metering.ts
@@ -10176,6 +10342,7 @@ export {
10176
10342
  AI_CHATS_SCHEMA,
10177
10343
  AI_MESSAGES_SCHEMA,
10178
10344
  ALLOWED_BINDING_TYPES,
10345
+ ANONYMOUS_USER_ID_PREFIX,
10179
10346
  APP_ID_RE,
10180
10347
  APP_LOG_EVENT_KEYS,
10181
10348
  APP_NAME_RULES,
@@ -10200,6 +10367,7 @@ export {
10200
10367
  DEEPSPACE_AI_MODELS,
10201
10368
  DEEPSPACE_MODEL_CATALOG_PROVENANCE,
10202
10369
  DEFAULT_CONTEXT_CONFIG,
10370
+ DEFAULT_DELETE_WHERE_LIMIT,
10203
10371
  DEFAULT_DO_MANIFEST,
10204
10372
  DEFAULT_QUERY_LIMIT,
10205
10373
  DEPLOYED_APPS_DEFAULTS,
@@ -10211,6 +10379,7 @@ export {
10211
10379
  JobRoom,
10212
10380
  LOG_LEVELS,
10213
10381
  MAX_APP_FILE_BYTES,
10382
+ MAX_DELETE_WHERE_LIMIT,
10214
10383
  MAX_DEPLOY_ASSET_FILE_BYTES,
10215
10384
  MAX_UPLOAD_PARTS,
10216
10385
  MAX_UPLOAD_REQUEST_BYTES,
@@ -10311,6 +10480,7 @@ export {
10311
10480
  handleClientErrorReport,
10312
10481
  handleSyncMessage,
10313
10482
  hasContent,
10483
+ isAnonymousUserId,
10314
10484
  isAssetNotFoundHandling,
10315
10485
  isAutoProvision,
10316
10486
  isFilesVerbPath,
@@ -10320,6 +10490,8 @@ export {
10320
10490
  isTestAccountEmail,
10321
10491
  isTestAccountTier,
10322
10492
  lintSchema,
10493
+ lintSchemaSet,
10494
+ lintSchemas,
10323
10495
  listDeepSpaceAgentModels,
10324
10496
  loadMessages,
10325
10497
  makeDefaultSummarizer,