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