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/CHANGELOG.md +15 -0
- package/dist/build.d.ts +2 -1
- package/dist/build.js +89 -16
- package/dist/build.js.map +1 -1
- package/dist/{chunk-A7VKZRCB.js → chunk-UUCAFDYW.js} +2 -2
- package/dist/{chunk-A7VKZRCB.js.map → chunk-UUCAFDYW.js.map} +1 -1
- package/dist/cli.js +735 -559
- package/dist/cli.js.map +1 -1
- package/dist/documentation-client-core.js +1 -1
- package/dist/documentation-default-renderer.cjs +1 -1
- package/dist/documentation-react.js +1 -1
- package/dist/documentation-runtime.js +1 -1
- package/dist/documentation-server-core.js +1 -1
- package/dist/documentation.js +1 -1
- package/dist/documentation.js.map +1 -1
- package/dist/index.d.ts +20 -2
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/schema.d.ts +10 -0
- package/dist/schema.js.map +1 -1
- package/dist/server.d.ts +84 -14
- package/dist/server.js +261 -88
- package/dist/server.js.map +1 -1
- package/dist/worker.d.ts +70 -3
- package/dist/worker.js +444 -272
- package/dist/worker.js.map +1 -1
- package/package.json +1 -1
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")) ||
|
|
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
|
|
647
|
-
|
|
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
|
-
|
|
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
|
|
1833
|
-
const teamIds = preloadUserTeamIds(ctx, userId);
|
|
1834
|
-
if (
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
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
|
-
|
|
1880
|
-
sql
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
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
|
-
|
|
1886
|
-
|
|
1887
|
-
const
|
|
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
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
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 (
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
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
|
|
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
|
-
|
|
8426
|
-
|
|
8427
|
-
|
|
8428
|
-
|
|
8429
|
-
|
|
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
|
-
|
|
8606
|
+
where: { chatId },
|
|
8607
|
+
limit: MESSAGE_DELETE_PAGE
|
|
8435
8608
|
});
|
|
8436
|
-
|
|
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
|
-
|
|
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,
|