deepspace 0.23.0 → 0.23.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -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-2U2VG6L5.js} +2 -2
- package/dist/{chunk-A7VKZRCB.js.map → chunk-2U2VG6L5.js.map} +1 -1
- package/dist/cli.js +937 -739
- 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 +279 -90
- package/dist/server.js.map +1 -1
- package/dist/worker.d.ts +70 -3
- package/dist/worker.js +461 -273
- 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; every key must be recordId, createdBy, or a schema column (an unknown key is refused, not ignored)", 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,86 @@ 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 refuseUnknownWhere(collection, schema, where) {
|
|
1577
|
+
const columns = (schema.columns ?? []).map(resolveColumn);
|
|
1578
|
+
const filterable = ["recordId", "createdBy", ...columns.map((c) => c.name)];
|
|
1579
|
+
const unknown = Object.keys(where).filter(
|
|
1580
|
+
(key) => key !== "recordId" && key !== "createdBy" && !columns.some((c) => c.id === columnId(key) || c.name === key)
|
|
1581
|
+
);
|
|
1582
|
+
if (unknown.length > 0) {
|
|
1583
|
+
return {
|
|
1584
|
+
success: false,
|
|
1585
|
+
error: `Unknown field(s) in where for "${collection}": ${unknown.join(", ")} \u2014 filterable fields: ${filterable.join(", ")}`
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
const nonPrimitive = Object.entries(where).filter(([, v]) => v !== null && typeof v === "object");
|
|
1589
|
+
if (nonPrimitive.length > 0) {
|
|
1590
|
+
return {
|
|
1591
|
+
success: false,
|
|
1592
|
+
error: `where values must be primitives (equality only): ${nonPrimitive.map(([k]) => k).join(", ")}`
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
return null;
|
|
1596
|
+
}
|
|
1597
|
+
function deleteWhere(ctx, collection, where, limit, userId, userRole, skipUserRbac = false) {
|
|
1598
|
+
const resolved = resolveCollection(ctx, collection);
|
|
1599
|
+
if (!resolved.ok) return { success: false, error: resolved.error };
|
|
1600
|
+
const schema = resolved.schema;
|
|
1601
|
+
if (!schema) {
|
|
1602
|
+
return {
|
|
1603
|
+
success: false,
|
|
1604
|
+
error: `records.deleteWhere: collection "${collection}" has no schema, so it has no filterable fields \u2014 delete its rows by recordId with records.delete`
|
|
1605
|
+
};
|
|
1606
|
+
}
|
|
1607
|
+
if (!where || typeof where !== "object" || Array.isArray(where) || Object.keys(where).length === 0) {
|
|
1608
|
+
return { success: false, error: "Missing required param: where (must match at least one field)" };
|
|
1609
|
+
}
|
|
1610
|
+
const whereRefusal = refuseUnknownWhere(collection, schema, where);
|
|
1611
|
+
if (whereRefusal) return whereRefusal;
|
|
1612
|
+
if (limit !== void 0 && !(typeof limit === "number" && Number.isFinite(limit))) {
|
|
1613
|
+
return { success: false, error: "limit must be a finite number" };
|
|
1614
|
+
}
|
|
1615
|
+
const page = Math.min(
|
|
1616
|
+
Math.max(1, Math.floor(limit ?? DEFAULT_DELETE_WHERE_LIMIT)),
|
|
1617
|
+
MAX_DELETE_WHERE_LIMIT
|
|
1618
|
+
);
|
|
1619
|
+
const scan = { capped: false };
|
|
1620
|
+
const matched = executeQuery(
|
|
1621
|
+
ctx,
|
|
1622
|
+
{ collection, where, limit: page },
|
|
1623
|
+
userId,
|
|
1624
|
+
userRole,
|
|
1625
|
+
skipUserRbac,
|
|
1626
|
+
scan
|
|
1627
|
+
);
|
|
1628
|
+
if (scan.capped && matched.length < page) {
|
|
1629
|
+
return {
|
|
1630
|
+
success: false,
|
|
1631
|
+
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`
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
if (!skipUserRbac && schema) {
|
|
1635
|
+
const permCtx = ctx.getPermissionContext();
|
|
1636
|
+
for (const record of matched) {
|
|
1637
|
+
if (!canDelete(schema, userRole, record, userId, permCtx)) {
|
|
1638
|
+
return { success: false, error: `DELETE DENIED: role=${userRole}, collection=${collection}` };
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
let deleted = 0;
|
|
1643
|
+
for (const record of matched) {
|
|
1644
|
+
if (deleteRecord(ctx, collection, record.recordId, userId, userRole, skipUserRbac).success) {
|
|
1645
|
+
deleted++;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
if (deleted < matched.length) {
|
|
1649
|
+
return {
|
|
1650
|
+
success: false,
|
|
1651
|
+
error: `Deleted ${deleted} of ${matched.length} matched record(s) in "${collection}"; the rest failed \u2014 retry`
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
return { success: true, data: { deleted } };
|
|
1655
|
+
}
|
|
1439
1656
|
function readRecord(ctx, collection, recordId, userId, userRole, skipUserRbac = false) {
|
|
1440
1657
|
const resolved = resolveCollection(ctx, collection);
|
|
1441
1658
|
if (!resolved.ok) return { success: false, error: resolved.error };
|
|
@@ -1771,7 +1988,8 @@ function handleSubscribe(ctx, ws, attachment, payload) {
|
|
|
1771
1988
|
}
|
|
1772
1989
|
function handleUnsubscribe(_ctx, _ws, _attachment, _payload) {
|
|
1773
1990
|
}
|
|
1774
|
-
|
|
1991
|
+
var MAX_FILTERED_SCAN_ROWS = 5e3;
|
|
1992
|
+
function executeQuery(ctx, query, userId, userRole, skipUserRbac = false, scanState) {
|
|
1775
1993
|
const resolved = resolveCollection(ctx, query.collection);
|
|
1776
1994
|
if (!resolved.ok) return [];
|
|
1777
1995
|
if (!resolved.schema) {
|
|
@@ -1784,7 +2002,8 @@ function executeQuery(ctx, query, userId, userRole, skipUserRbac = false) {
|
|
|
1784
2002
|
userId,
|
|
1785
2003
|
userRole,
|
|
1786
2004
|
resolved.isSystem,
|
|
1787
|
-
skipUserRbac
|
|
2005
|
+
skipUserRbac,
|
|
2006
|
+
scanState
|
|
1788
2007
|
);
|
|
1789
2008
|
}
|
|
1790
2009
|
function executeSystemQuery(ctx, query) {
|
|
@@ -1821,7 +2040,7 @@ function preloadUserTeamIds(ctx, userId) {
|
|
|
1821
2040
|
);
|
|
1822
2041
|
return cursor.toArray().map((r) => r.col_teamid);
|
|
1823
2042
|
}
|
|
1824
|
-
function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipUserRbac = false) {
|
|
2043
|
+
function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipUserRbac = false, scanState) {
|
|
1825
2044
|
const columns = (schema.columns ?? []).map(resolveColumn);
|
|
1826
2045
|
const perms = getRolePermissions(schema, userRole);
|
|
1827
2046
|
let sql = buildTableSelect(query.collection, columns);
|
|
@@ -1829,16 +2048,27 @@ function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipU
|
|
|
1829
2048
|
const whereClauses = [];
|
|
1830
2049
|
let skipPerRecordCheck = skipUserRbac;
|
|
1831
2050
|
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
|
-
|
|
2051
|
+
const filterCol = schema.teamField === "_rowId" ? "_row_id" : columns.find((c) => c.name === schema.teamField)?.id;
|
|
2052
|
+
const teamIds = filterCol ? preloadUserTeamIds(ctx, userId) : null;
|
|
2053
|
+
if (filterCol) {
|
|
2054
|
+
if (teamIds && teamIds.length > 0) {
|
|
2055
|
+
const placeholders = teamIds.map(() => "?").join(", ");
|
|
2056
|
+
whereClauses.push(`("${filterCol}" IN (${placeholders}) OR _created_by = ?)`);
|
|
2057
|
+
params.push(...teamIds, userId);
|
|
2058
|
+
} else {
|
|
2059
|
+
whereClauses.push(`_created_by = ?`);
|
|
2060
|
+
params.push(userId);
|
|
2061
|
+
}
|
|
2062
|
+
skipPerRecordCheck = true;
|
|
2063
|
+
}
|
|
2064
|
+
} else if (!skipUserRbac && perms.read === "own") {
|
|
2065
|
+
const ownerCol = schema.ownerField ? columns.find((c) => c.name === schema.ownerField)?.id : "_created_by";
|
|
2066
|
+
if (ownerCol) {
|
|
2067
|
+
whereClauses.push(`"${ownerCol}" = ?`);
|
|
1840
2068
|
params.push(userId);
|
|
2069
|
+
skipPerRecordCheck = true;
|
|
1841
2070
|
}
|
|
2071
|
+
} else if (!skipUserRbac && perms.read === true) {
|
|
1842
2072
|
skipPerRecordCheck = true;
|
|
1843
2073
|
}
|
|
1844
2074
|
if (query.where) {
|
|
@@ -1876,27 +2106,44 @@ function executeTableQuery(ctx, query, schema, userId, userRole, isSystem, skipU
|
|
|
1876
2106
|
} else {
|
|
1877
2107
|
sql += ` ORDER BY _created_at DESC`;
|
|
1878
2108
|
}
|
|
1879
|
-
|
|
1880
|
-
sql
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
2109
|
+
const readPage = (limitClause, limitParams) => {
|
|
2110
|
+
const cursor = ctx.sql.exec(sql + limitClause, ...params, ...limitParams);
|
|
2111
|
+
const rows = cursor.toArray();
|
|
2112
|
+
const records = [];
|
|
2113
|
+
for (const row of rows) {
|
|
2114
|
+
const r = row;
|
|
2115
|
+
const record = {
|
|
2116
|
+
recordId: r._row_id,
|
|
2117
|
+
data: rowToData(r, columns),
|
|
2118
|
+
createdBy: r._created_by,
|
|
2119
|
+
createdAt: r._created_at,
|
|
2120
|
+
updatedAt: r._updated_at
|
|
2121
|
+
};
|
|
2122
|
+
if (isSystem || skipPerRecordCheck || canRead(schema, userRole, { data: record.data, createdBy: record.createdBy, recordId: record.recordId }, userId, ctx.getPermissionContext())) {
|
|
2123
|
+
records.push(record);
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
return { records, scanned: rows.length };
|
|
2127
|
+
};
|
|
2128
|
+
if (!query.limit) return readPage("", []).records;
|
|
2129
|
+
const rowFiltered = !isSystem && !skipPerRecordCheck;
|
|
2130
|
+
if (!rowFiltered) return readPage(" LIMIT ?", [query.limit]).records;
|
|
1884
2131
|
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())) {
|
|
2132
|
+
const batch = Math.max(query.limit, 200);
|
|
2133
|
+
for (let offset = 0; ; offset += batch) {
|
|
2134
|
+
const { records, scanned } = readPage(" LIMIT ? OFFSET ?", [batch, offset]);
|
|
2135
|
+
for (const record of records) {
|
|
1896
2136
|
results.push(record);
|
|
2137
|
+
if (results.length >= query.limit) return results;
|
|
2138
|
+
}
|
|
2139
|
+
if (scanned < batch) return results;
|
|
2140
|
+
if (offset + batch >= MAX_FILTERED_SCAN_ROWS) {
|
|
2141
|
+
if (scanState && readPage(" LIMIT ? OFFSET ?", [1, offset + batch]).scanned > 0) {
|
|
2142
|
+
scanState.capped = true;
|
|
2143
|
+
}
|
|
2144
|
+
return results;
|
|
1897
2145
|
}
|
|
1898
2146
|
}
|
|
1899
|
-
return results;
|
|
1900
2147
|
}
|
|
1901
2148
|
function recordMatchesSubscription(record, collection, query, userId, userRole, schema, ctx) {
|
|
1902
2149
|
if (query.collection !== collection) return false;
|
|
@@ -2093,30 +2340,56 @@ async function registerUser(sql, userId, name, email, imageUrl, isAdmin, default
|
|
|
2093
2340
|
);
|
|
2094
2341
|
return { id: userId, email, name, imageUrl, role, createdAt: now, lastSeenAt: now };
|
|
2095
2342
|
}
|
|
2343
|
+
function rosterFor(ctx, schema, records, publicRoster, attachment) {
|
|
2344
|
+
if (isAnonymousUserId(attachment.userId)) return [];
|
|
2345
|
+
if (attachment.role === "admin") {
|
|
2346
|
+
const permissionContext = ctx.getPermissionContext();
|
|
2347
|
+
return records.filter((record) => canRead(schema, "admin", record, attachment.userId, permissionContext)).map((record) => ({ id: record.recordId, ...record.data }));
|
|
2348
|
+
}
|
|
2349
|
+
if (!getRolePermissions(schema, attachment.role).read) return [];
|
|
2350
|
+
if (schema.roster === "read-policy") {
|
|
2351
|
+
const permissionContext = ctx.getPermissionContext();
|
|
2352
|
+
const readable = new Set(
|
|
2353
|
+
records.filter((record) => canRead(schema, attachment.role, record, attachment.userId, permissionContext)).map((record) => record.recordId)
|
|
2354
|
+
);
|
|
2355
|
+
return publicRoster.filter((user) => readable.has(user.id));
|
|
2356
|
+
}
|
|
2357
|
+
return publicRoster;
|
|
2358
|
+
}
|
|
2359
|
+
function publicIdentities(records) {
|
|
2360
|
+
return records.map(({ recordId, data }) => ({
|
|
2361
|
+
id: recordId,
|
|
2362
|
+
name: data.name,
|
|
2363
|
+
...data.imageUrl ? { imageUrl: data.imageUrl } : {},
|
|
2364
|
+
role: data.role
|
|
2365
|
+
}));
|
|
2366
|
+
}
|
|
2096
2367
|
function handleUserList(ctx, ws, attachment) {
|
|
2097
2368
|
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
|
-
});
|
|
2369
|
+
if (!schema) {
|
|
2370
|
+
ctx.send(ws, { type: MSG.USER_LIST, payload: { users: [] } });
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
2373
|
+
const records = getAllUserRecords(ctx.sql, ctx.schemaRegistry);
|
|
2374
|
+
const users = rosterFor(ctx, schema, records, publicIdentities(records), attachment);
|
|
2118
2375
|
ctx.send(ws, { type: MSG.USER_LIST, payload: { users } });
|
|
2119
2376
|
}
|
|
2377
|
+
function broadcastUserList(ctx, except) {
|
|
2378
|
+
const schema = ctx.schemaRegistry.get("users");
|
|
2379
|
+
if (!schema) return;
|
|
2380
|
+
const skip = except instanceof Set ? except : new Set(except ? [except] : []);
|
|
2381
|
+
const records = getAllUserRecords(ctx.sql, ctx.schemaRegistry);
|
|
2382
|
+
const publicRoster = publicIdentities(records);
|
|
2383
|
+
for (const other of ctx.state.getWebSockets()) {
|
|
2384
|
+
if (skip.has(other)) continue;
|
|
2385
|
+
const attachment = other.deserializeAttachment();
|
|
2386
|
+
if (!attachment) continue;
|
|
2387
|
+
ctx.send(other, {
|
|
2388
|
+
type: MSG.USER_LIST,
|
|
2389
|
+
payload: { users: rosterFor(ctx, schema, records, publicRoster, attachment) }
|
|
2390
|
+
});
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2120
2393
|
function handleUserUpdate(ctx, _ws, attachment, payload) {
|
|
2121
2394
|
const existing = getUserRecord(ctx.sql, attachment.userId, ctx.schemaRegistry);
|
|
2122
2395
|
if (!existing) return;
|
|
@@ -2125,6 +2398,8 @@ function handleUserUpdate(ctx, _ws, attachment, payload) {
|
|
|
2125
2398
|
if (payload.email) data.email = payload.email;
|
|
2126
2399
|
if (payload.imageUrl !== void 0) data.imageUrl = payload.imageUrl || void 0;
|
|
2127
2400
|
putRecord(ctx, "users", attachment.userId, data, attachment.userId, "admin", true, true);
|
|
2401
|
+
const rosterChanged = data.name !== void 0 && data.name !== existing.data.name || data.imageUrl !== void 0 && data.imageUrl !== existing.data.imageUrl;
|
|
2402
|
+
if (rosterChanged) broadcastUserList(ctx);
|
|
2128
2403
|
}
|
|
2129
2404
|
async function handleSetRole(ctx, ws, attachment, payload) {
|
|
2130
2405
|
if (attachment.role !== "admin") {
|
|
@@ -2143,132 +2418,21 @@ async function handleSetRole(ctx, ws, attachment, payload) {
|
|
|
2143
2418
|
now,
|
|
2144
2419
|
payload.userId
|
|
2145
2420
|
);
|
|
2421
|
+
const closing = /* @__PURE__ */ new Set();
|
|
2146
2422
|
for (const otherWs of ctx.state.getWebSockets()) {
|
|
2147
2423
|
const otherAttachment = otherWs.deserializeAttachment();
|
|
2148
|
-
if (
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
}
|
|
2154
|
-
continue;
|
|
2155
|
-
}
|
|
2156
|
-
if (otherAttachment.role === "admin") {
|
|
2157
|
-
handleUserList(ctx, otherWs, otherAttachment);
|
|
2424
|
+
if (otherAttachment?.userId !== payload.userId) continue;
|
|
2425
|
+
closing.add(otherWs);
|
|
2426
|
+
try {
|
|
2427
|
+
otherWs.close(1008, "role-changed");
|
|
2428
|
+
} catch {
|
|
2158
2429
|
}
|
|
2159
2430
|
}
|
|
2431
|
+
broadcastUserList(ctx, closing);
|
|
2160
2432
|
}
|
|
2161
2433
|
|
|
2162
2434
|
// src/server/handlers/tools-api.ts
|
|
2163
2435
|
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
2436
|
var CORS_HEADERS = {
|
|
2273
2437
|
"Access-Control-Allow-Origin": "*",
|
|
2274
2438
|
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
@@ -2351,9 +2515,20 @@ async function executeTool(ctx, toolName, params, userId, userRole, skipUserRbac
|
|
|
2351
2515
|
if (!collection) return { success: false, error: "Missing required param: collection" };
|
|
2352
2516
|
const resolved = resolveCollection(ctx, collection);
|
|
2353
2517
|
if (!resolved.ok) return { success: false, error: resolved.error };
|
|
2518
|
+
const where = params.where;
|
|
2519
|
+
if (where && typeof where === "object" && Object.keys(where).length > 0) {
|
|
2520
|
+
if (!resolved.schema) {
|
|
2521
|
+
return {
|
|
2522
|
+
success: false,
|
|
2523
|
+
error: `records.query: collection "${collection}" has no schema, so it has no filterable fields \u2014 query it without \`where\``
|
|
2524
|
+
};
|
|
2525
|
+
}
|
|
2526
|
+
const refusal = refuseUnknownWhere(collection, resolved.schema, where);
|
|
2527
|
+
if (refusal) return refusal;
|
|
2528
|
+
}
|
|
2354
2529
|
const query = {
|
|
2355
2530
|
collection,
|
|
2356
|
-
where
|
|
2531
|
+
where,
|
|
2357
2532
|
orderBy: params.orderBy,
|
|
2358
2533
|
orderDir: params.orderDir,
|
|
2359
2534
|
limit: params.limit
|
|
@@ -2392,6 +2567,15 @@ async function executeTool(ctx, toolName, params, userId, userRole, skipUserRbac
|
|
|
2392
2567
|
if (!recordId) return { success: false, error: "Missing required param: recordId" };
|
|
2393
2568
|
return deleteRecord(ctx, collection, recordId, userId, userRole, skipUserRbac);
|
|
2394
2569
|
}
|
|
2570
|
+
// Bounded batch delete — one subrequest per page instead of one per row.
|
|
2571
|
+
// Same authorization posture as `records.delete`; `deleteWhere` itself
|
|
2572
|
+
// validates `where` (every key must name a real field) and `limit`, so no
|
|
2573
|
+
// dispatch path can reach a collection-wide truncation.
|
|
2574
|
+
case "records.deleteWhere": {
|
|
2575
|
+
const collection = params.collection;
|
|
2576
|
+
if (!collection) return { success: false, error: "Missing required param: collection" };
|
|
2577
|
+
return deleteWhere(ctx, collection, params.where, params.limit, userId, userRole, skipUserRbac);
|
|
2578
|
+
}
|
|
2395
2579
|
// ---- Users ----
|
|
2396
2580
|
// Bootstrap or refresh the `users` row for a given userId. Same path
|
|
2397
2581
|
// the SDK uses on WS connect via registerUser, exposed for server
|
|
@@ -2939,12 +3123,13 @@ var RecordRoom = class extends BaseRoom {
|
|
|
2939
3123
|
const userName = user.userName;
|
|
2940
3124
|
const userEmail = user.userEmail;
|
|
2941
3125
|
const userImageUrl = user.userImageUrl;
|
|
2942
|
-
const isAuthenticated = !userId
|
|
3126
|
+
const isAuthenticated = !isAnonymousUserId(userId);
|
|
2943
3127
|
let attachment;
|
|
2944
3128
|
if (isAuthenticated) {
|
|
2945
3129
|
const isOwner2 = this.ownerUserId != null && userId === this.ownerUserId;
|
|
2946
3130
|
const regStart = Date.now();
|
|
2947
3131
|
const defaultRole = this.schemaRegistry.get("users")?.defaultRole ?? "member";
|
|
3132
|
+
const before = getUser(this.sql, userId, this.schemaRegistry);
|
|
2948
3133
|
const registeredUser = await registerUser(
|
|
2949
3134
|
this.sql,
|
|
2950
3135
|
userId,
|
|
@@ -2956,6 +3141,9 @@ var RecordRoom = class extends BaseRoom {
|
|
|
2956
3141
|
this.schemaRegistry
|
|
2957
3142
|
);
|
|
2958
3143
|
const regMs = Date.now() - regStart;
|
|
3144
|
+
if (!before || before.name !== registeredUser.name || before.imageUrl !== registeredUser.imageUrl || before.role !== registeredUser.role) {
|
|
3145
|
+
broadcastUserList(this.createUserContext(), ws);
|
|
3146
|
+
}
|
|
2959
3147
|
attachment = {
|
|
2960
3148
|
userId: registeredUser.id,
|
|
2961
3149
|
userName,
|
|
@@ -8415,43 +8603,35 @@ async function createChat(stub, userId, opts = {}) {
|
|
|
8415
8603
|
return toChatRow(result.record);
|
|
8416
8604
|
}
|
|
8417
8605
|
async function updateChat(stub, chatId, userId, patch) {
|
|
8606
|
+
if (!await getChat(stub, chatId, userId)) return false;
|
|
8418
8607
|
await executeToolAsApp(stub, userId, "records.update", {
|
|
8419
8608
|
collection: "ai-chats",
|
|
8420
8609
|
recordId: chatId,
|
|
8421
8610
|
data: patch
|
|
8422
8611
|
});
|
|
8612
|
+
return true;
|
|
8423
8613
|
}
|
|
8614
|
+
var MESSAGE_DELETE_PAGE = 200;
|
|
8424
8615
|
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", {
|
|
8616
|
+
let messagesError = null;
|
|
8617
|
+
try {
|
|
8618
|
+
let deleted = MESSAGE_DELETE_PAGE;
|
|
8619
|
+
while (deleted === MESSAGE_DELETE_PAGE) {
|
|
8620
|
+
const page = await executeToolAsApp(stub, userId, "records.deleteWhere", {
|
|
8433
8621
|
collection: "ai-messages",
|
|
8434
|
-
|
|
8622
|
+
where: { chatId },
|
|
8623
|
+
limit: MESSAGE_DELETE_PAGE
|
|
8435
8624
|
});
|
|
8436
|
-
|
|
8437
|
-
errors.push(err);
|
|
8625
|
+
deleted = page.deleted;
|
|
8438
8626
|
}
|
|
8439
|
-
}
|
|
8440
|
-
try {
|
|
8441
|
-
await executeToolAsApp(stub, userId, "records.delete", {
|
|
8442
|
-
collection: "ai-chats",
|
|
8443
|
-
recordId: chatId
|
|
8444
|
-
});
|
|
8445
8627
|
} 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
|
-
);
|
|
8628
|
+
messagesError = err;
|
|
8454
8629
|
}
|
|
8630
|
+
await executeToolAsApp(stub, userId, "records.delete", {
|
|
8631
|
+
collection: "ai-chats",
|
|
8632
|
+
recordId: chatId
|
|
8633
|
+
});
|
|
8634
|
+
if (messagesError) throw messagesError;
|
|
8455
8635
|
}
|
|
8456
8636
|
async function loadMessages(stub, chatId, userId) {
|
|
8457
8637
|
const result = await executeToolAsApp(stub, userId, "records.query", {
|
|
@@ -8463,6 +8643,7 @@ async function loadMessages(stub, chatId, userId) {
|
|
|
8463
8643
|
return result.records.map(toMessageRow);
|
|
8464
8644
|
}
|
|
8465
8645
|
async function appendMessage(stub, msg) {
|
|
8646
|
+
if (!await getChat(stub, msg.chatId, msg.userId)) return false;
|
|
8466
8647
|
const data = {
|
|
8467
8648
|
chatId: msg.chatId,
|
|
8468
8649
|
userId: msg.userId,
|
|
@@ -8475,6 +8656,7 @@ async function appendMessage(stub, msg) {
|
|
|
8475
8656
|
recordId: msg.id,
|
|
8476
8657
|
data
|
|
8477
8658
|
});
|
|
8659
|
+
return true;
|
|
8478
8660
|
}
|
|
8479
8661
|
|
|
8480
8662
|
// src/server/utils/metering.ts
|
|
@@ -10176,6 +10358,7 @@ export {
|
|
|
10176
10358
|
AI_CHATS_SCHEMA,
|
|
10177
10359
|
AI_MESSAGES_SCHEMA,
|
|
10178
10360
|
ALLOWED_BINDING_TYPES,
|
|
10361
|
+
ANONYMOUS_USER_ID_PREFIX,
|
|
10179
10362
|
APP_ID_RE,
|
|
10180
10363
|
APP_LOG_EVENT_KEYS,
|
|
10181
10364
|
APP_NAME_RULES,
|
|
@@ -10200,6 +10383,7 @@ export {
|
|
|
10200
10383
|
DEEPSPACE_AI_MODELS,
|
|
10201
10384
|
DEEPSPACE_MODEL_CATALOG_PROVENANCE,
|
|
10202
10385
|
DEFAULT_CONTEXT_CONFIG,
|
|
10386
|
+
DEFAULT_DELETE_WHERE_LIMIT,
|
|
10203
10387
|
DEFAULT_DO_MANIFEST,
|
|
10204
10388
|
DEFAULT_QUERY_LIMIT,
|
|
10205
10389
|
DEPLOYED_APPS_DEFAULTS,
|
|
@@ -10211,6 +10395,7 @@ export {
|
|
|
10211
10395
|
JobRoom,
|
|
10212
10396
|
LOG_LEVELS,
|
|
10213
10397
|
MAX_APP_FILE_BYTES,
|
|
10398
|
+
MAX_DELETE_WHERE_LIMIT,
|
|
10214
10399
|
MAX_DEPLOY_ASSET_FILE_BYTES,
|
|
10215
10400
|
MAX_UPLOAD_PARTS,
|
|
10216
10401
|
MAX_UPLOAD_REQUEST_BYTES,
|
|
@@ -10311,6 +10496,7 @@ export {
|
|
|
10311
10496
|
handleClientErrorReport,
|
|
10312
10497
|
handleSyncMessage,
|
|
10313
10498
|
hasContent,
|
|
10499
|
+
isAnonymousUserId,
|
|
10314
10500
|
isAssetNotFoundHandling,
|
|
10315
10501
|
isAutoProvision,
|
|
10316
10502
|
isFilesVerbPath,
|
|
@@ -10320,6 +10506,8 @@ export {
|
|
|
10320
10506
|
isTestAccountEmail,
|
|
10321
10507
|
isTestAccountTier,
|
|
10322
10508
|
lintSchema,
|
|
10509
|
+
lintSchemaSet,
|
|
10510
|
+
lintSchemas,
|
|
10323
10511
|
listDeepSpaceAgentModels,
|
|
10324
10512
|
loadMessages,
|
|
10325
10513
|
makeDefaultSummarizer,
|